# -*- 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\geulbeot-light\geulbeot-light\00.test\hwpx\out") OUTPUT_ROOT = Path(r"D:\for python\geulbeot-light\geulbeot-light\00.test\hwpx\out\out") # 출력 위치 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("<", "<") .replace(">", ">") .replace('"', """) .replace("'", "'")) 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 = """ """ 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""" """ ) elif depth == 2: body_parts.append( f""" """ ) else: body_parts.append( f""" """ ) tail = """
구분 번호 제목 키워드
대목차 {num} {title}
중목차 {num} {title}
소목차 {num} {title} {kw}
""" 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"""

{html_escape(report_title)}

확정 목차 표 형태 정리본
목차는 outline_issue_report.txt를 기반으로 표로 재구성됨
목차
{table_html}
""") return f""" {html_escape(report_title)} - Outline {''.join(html_pages)} """ 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()