Files
test/converters/pipeline/step7_index.py

505 lines
15 KiB
Python

# -*- coding: utf-8 -*-
"""
make_outline.py
기능:
- output_context/context/domain_prompt.txt
- output_context/context/corpus.txt
을 기반으로 목차를 생성하고,
1) outline_issue_report.txt 저장
2) outline_issue_report.html 저장 (테스트.html 레이아웃 기반 표 형태)
"""
import os
import sys
import re
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Tuple
from openai import OpenAI
from api_config import API_KEYS
# ===== 경로 설정 =====
DATA_ROOT = Path(r"D:\for python\survey_test\process")
OUTPUT_ROOT = Path(r"D:\for python\survey_test\output")
CONTEXT_DIR = OUTPUT_ROOT / "context"
LOG_DIR = OUTPUT_ROOT / "logs"
for d in [CONTEXT_DIR, LOG_DIR]:
d.mkdir(parents=True, exist_ok=True)
# ===== OpenAI 설정 (구조 유지) =====
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
RE_KEYWORDS = re.compile(r"(#\S+)")
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
RE_L3 = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+?)\s*$")
def log(msg: str):
print(msg, flush=True)
with (LOG_DIR / "make_outline_log.txt").open("a", encoding="utf-8") as f:
f.write(msg + "\n")
def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_corpus() -> str:
p = CONTEXT_DIR / "corpus.txt"
if not p.exists():
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip()
# 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$")
RE_L3_TOPIC = re.compile(r"^\s*[\-\*]\s+(.+?)\s*\|\s*(.+?)\s*\|\s*(\[.+?\])\s*\|\s*(.+)$")
def generate_outline(domain_prompt: str, corpus: str) -> str:
sys_msg = {
"role": "system",
"content": (
domain_prompt + "\n\n"
"너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
"주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
),
}
user_msg = {
"role": "user",
"content": f"""
아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
[corpus]
{corpus}
요구 사항:
1) 첫 줄에 보고서 제목 1개를 작성하라.
2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
- 대목차: 1. / 2. / 3. ...
- 중목차: 1.1 / 1.2 / ...
- 소목차: 1.1.1 / 1.1.2 / ...
3) **수량 제약 (중요)**:
- 대목차(1.)는 5~8개로 구성하라.
- **중목차(1.1) 하나당 소목차(1.1.1, 1.1.2...)는 반드시 2개에서 4개 사이로 구성하라.** (절대 1개만 만들지 말 것)
- 소목차(1.1.1) 하나당 '핵심주제(꼭지)'는 반드시 2개에서 3개 사이로 구성하라.
[소목차 작성 형식]
1.1.1 소목차 제목
- 핵심주제 1 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
- 핵심주제 2 | #키워드 | [유형] | 집필가이드(데이터/표 구성 지침)
5) [유형] 분류 가이드:
- [비교형]: 기존 vs DX 방식의 비교표(Table)가 필수적인 경우
- [기술형]: RMSE, GSD, 중복도 등 정밀 수치와 사양 설명이 핵심인 경우
- [절차형]: 단계별 워크플로 및 체크리스트가 중심인 경우
- [인사이트형]: 한계점 분석 및 전문가 제언(☞)이 중심인 경우
6) 집필가이드는 50자 내외로, "어떤 데이터를 검색해서 어떤 표를 그려라"와 같이 구체적으로 지시하라.
7) 대목차는 최대 8개 이내로 구성하라.
"""
}
resp = client.chat.completions.create(
model=GPT_MODEL,
messages=[sys_msg, user_msg],
)
return (resp.choices[0].message.content or "").strip()
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()]
if not lines: return "", []
title = lines[0].strip() # 첫 줄은 보고서 제목
rows = []
current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
for ln in lines[1:]:
raw = ln.strip()
# 1. 소목차 헤더(1.1.1 제목) 발견 시
m3_head = RE_L3_HEAD.match(raw)
if m3_head:
num, s_title = m3_head.groups()
current_section = {
"depth": 3,
"num": num,
"title": s_title,
"sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
}
rows.append(current_section)
continue
# 2. 세부 꼭지(- 주제 | #키워드 | [유형] | 가이드) 발견 시
m_topic = RE_L3_TOPIC.match(raw)
if m_topic and current_section:
t_title, kws_raw, t_type, guide = m_topic.groups()
# 키워드 추출 (#키워드 형태)
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
# 현재 소목차(current_section)의 리스트에 추가
current_section["sub_topics"].append({
"topic_title": t_title,
"keywords": kws,
"type": t_type,
"guide": guide
})
continue
# 3. 대목차(1.) 처리
m1 = RE_L1.match(raw)
if m1:
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
# 4. 중목차(1.1) 처리
m2 = RE_L2.match(raw)
if m2:
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
current_section = None # 소목차 구간 종료
continue
return title, rows
def html_escape(s: str) -> str:
s = s or ""
return (s.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;"))
def chunk_rows(rows: List[Dict[str, Any]], max_rows_per_page: int = 26) -> List[List[Dict[str, Any]]]:
"""
A4 1장에 표가 길어지면 넘치므로, 단순 행 개수로 페이지 분할한다.
"""
out = []
cur = []
for r in rows:
cur.append(r)
if len(cur) >= max_rows_per_page:
out.append(cur)
cur = []
if cur:
out.append(cur)
return out
def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
"""
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
"""
head = """
<table>
<thead>
<tr>
<th>구분</th>
<th>번호</th>
<th>제목</th>
<th>키워드</th>
</tr>
</thead>
<tbody>
"""
body_parts = []
for r in rows:
depth = r["depth"]
num = html_escape(r["num"])
title = html_escape(r["title"])
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
kw = html_escape(kw)
if depth == 1:
body_parts.append(
f"""
<tr>
<td class="group-cell">대목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
elif depth == 2:
body_parts.append(
f"""
<tr>
<td class="group-cell">중목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
else:
body_parts.append(
f"""
<tr>
<td class="group-cell">소목차</td>
<td>{num}</td>
<td>{title}</td>
<td>{kw}</td>
</tr>
"""
)
tail = """
</tbody>
</table>
"""
return head + "\n".join(body_parts) + tail
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str:
"""
테스트.html 레이아웃 구조를 그대로 따라 A4 시트 형태로 HTML 생성
"""
css = r"""
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&display=swap');
:root {
--primary-blue: #3057B9;
--gray-light: #F2F2F2;
--gray-medium: #E6E6E6;
--gray-dark: #666666;
--border-light: #DDDDDD;
--text-black: #000000;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
}
body {
font-family: 'Noto Sans KR', sans-serif;
background-color: #f0f0f0;
color: var(--text-black);
line-height: 1.35;
display: flex;
justify-content: center;
padding: 10px 0;
}
.sheet {
background-color: white;
width: 210mm;
height: 297mm;
padding: 20mm 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
margin-bottom: 12px;
}
@media print {
body { background: none; padding: 0; }
.sheet { box-shadow: none; margin: 0; border: none; page-break-after: always; }
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
font-size: 8.5pt;
color: var(--gray-dark);
}
.header-title {
font-size: 24pt;
font-weight: 900;
margin-bottom: 8px;
letter-spacing: -1.5px;
color: #111;
}
.title-divider {
height: 4px;
background-color: var(--primary-blue);
width: 100%;
margin-bottom: 20px;
}
.lead-box {
background-color: var(--gray-light);
padding: 18px 20px;
margin-bottom: 5px;
border-radius: 2px;
text-align: center;
}
.lead-box div {
font-size: 13pt;
font-weight: 700;
color: var(--primary-blue);
letter-spacing: -0.5px;
}
.lead-notes {
font-size: 8.5pt;
color: #777;
margin-bottom: 20px;
padding-left: 5px;
text-align: right;
}
.body-content { flex: 1; }
.section { margin-bottom: 22px; }
.section-title {
font-size: 13pt;
font-weight: 700;
display: flex;
align-items: center;
margin-bottom: 10px;
color: #111;
}
.section-title::before {
content: "";
display: inline-block;
width: 10px;
height: 10px;
background-color: #999;
margin-right: 10px;
}
table {
width: 100%;
border-collapse: collapse;
margin: 8px 0;
font-size: 9.5pt;
border-top: 1.5px solid #333;
}
th {
background-color: var(--gray-medium);
font-weight: 700;
padding: 10px;
border: 1px solid var(--border-light);
}
td {
padding: 10px;
border: 1px solid var(--border-light);
vertical-align: middle;
}
.group-cell {
background-color: #F9F9F9;
font-weight: 700;
width: 16%;
text-align: center;
color: var(--primary-blue);
white-space: nowrap;
}
.page-footer {
margin-top: 15px;
padding-top: 10px;
display: flex;
justify-content: space-between;
font-size: 8.5pt;
color: var(--gray-dark);
border-top: 1px solid #EEE;
}
.footer-page { flex: 1; text-align: center; }
"""
pages = chunk_rows(rows, max_rows_per_page=26)
html_pages = []
total_pages = len(pages) if pages else 1
for i, page_rows in enumerate(pages, start=1):
table_html = build_outline_table_html(page_rows)
html_pages.append(f"""
<div class="sheet">
<header class="page-header">
<div class="header-left">
보고서: 목차 자동 생성 결과
</div>
<div class="header-right">
작성일자: {datetime.now().strftime("%Y. %m. %d.")}
</div>
</header>
<div class="title-block">
<h1 class="header-title">{html_escape(report_title)}</h1>
<div class="title-divider"></div>
</div>
<div class="body-content">
<div class="lead-box">
<div>확정 목차 표 형태 정리본</div>
</div>
<div class="lead-notes">목차는 outline_issue_report.txt를 기반으로 표로 재구성됨</div>
<div class="section">
<div class="section-title">목차</div>
{table_html}
</div>
</div>
<footer class="page-footer">
<div class="footer-slogan">Word Style v2 Outline</div>
<div class="footer-page">- {i} / {total_pages} -</div>
<div class="footer-info">outline_issue_report.html</div>
</footer>
</div>
""")
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{html_escape(report_title)} - Outline</title>
<style>{css}</style>
</head>
<body>
{''.join(html_pages)}
</body>
</html>
"""
def main():
log("=== 목차 생성 시작 ===")
domain_prompt = load_domain_prompt()
corpus = load_corpus()
outline = generate_outline(domain_prompt, corpus)
# TXT 저장 유지
out_txt = CONTEXT_DIR / "outline_issue_report.txt"
out_txt.write_text(outline, encoding="utf-8")
log(f"목차 TXT 저장 완료: {out_txt}")
# HTML 추가 저장
title, rows = parse_outline(outline)
out_html = CONTEXT_DIR / "outline_issue_report.html"
out_html.write_text(build_outline_html(title, rows), encoding="utf-8")
log(f"목차 HTML 저장 완료: {out_html}")
log("=== 목차 생성 종료 ===")
if __name__ == "__main__":
main()