From 4885984a38b3d1c5319df60d1459cefddcc5099c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EA=B2=BD=EB=AF=BC?= Date: Thu, 19 Mar 2026 09:57:59 +0900 Subject: [PATCH] Update step7_index.py --- .../converters/pipeline/step7_index.py | 530 ++++++++++++------ 1 file changed, 361 insertions(+), 169 deletions(-) diff --git a/03.Code/업로드용/converters/pipeline/step7_index.py b/03.Code/업로드용/converters/pipeline/step7_index.py index aec8eb0..4f40baf 100644 --- a/03.Code/업로드용/converters/pipeline/step7_index.py +++ b/03.Code/업로드용/converters/pipeline/step7_index.py @@ -1,31 +1,42 @@ # -*- coding: utf-8 -*- -from dotenv import load_dotenv -load_dotenv() - """ make_outline.py 기능: - output_context/context/domain_prompt.txt - output_context/context/corpus.txt -를 바탕으로 보고서 목차를 생성합니다. -1) outline_issue_report.txt 생성 -2) outline_issue_report.html 생성 (미리보기용) +을 기반으로 목차를 생성하고, + +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 = os.environ.get("OPENAI_API_KEY") +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*$") @@ -39,143 +50,105 @@ def log(msg: str): def load_domain_prompt() -> str: p = CONTEXT_DIR / "domain_prompt.txt" if not p.exists(): - log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행하십시오.") + 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를 실행하십시오.") + 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*(.+)$") +# 기존 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, rag_chunks: str = "", doc_type: str = 'report', attach_pages: int = 1) -> str: - """ - GPT를 호출하여 전체 보고서의 목차를 구성합니다. - - doc_type: 'report' (일반 보고서) 또는 'briefing' (브리핑 자료) - - attach_pages: 브리핑 자료 시 첨부 페이지 수 - """ - if doc_type == 'briefing': - sys_msg = { - "role": "system", - "content": ( - domain_prompt + "\n\n" - "당신은 측량 및 지리정보 분야의 보고서 기획 전문가입니다. " - "제공된 정보를 분석하여, A4 1~2매 분량의 '핵심 브리핑 자료' 목차를 작성하세요. " - "본문은 '1. 개요 - 2. 현황 - 3. 문제점 및 대책 - 4. 결론' 형식을 기본으로 하되 내용에 맞춰 조정 가능합니다. " - "각 세부 주제별로 데이터 근거와 시각화 방안을 포함한 기획안을 도출하세요." - ), - } - attach_str = "" - for i in range(1, attach_pages + 1): - attach_str += f""" -[첨부 {i}] -- 제목: 본문 내용을 보완하는 상세 데이터/참고 자료 (본문과 제목은 다르게) -- 리드문: 본문에서 언급된 핵심 수치나 근거를 요약하여 제시 -- 세부 항목: [- 주제 | #키워드 | [시각화방안] | 내용 가이드] (3~4개 구성) +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개 이내로 구성하라. """ - user_msg = { - "role": "user", - "content": f""" -다음 정보를 바탕으로 브리핑 보고서 목차를 생성하세요. - -[정보원(Corpus)] -{corpus[:8000]} - -[작성 규칙] -1. 최상단에 [보고서 제목]을 작성 (전문적이고 명확하게) -2. 본문(1페이지 분량)과 첨부({attach_pages}페이지 분량)로 구분 -3. 각 페이지별로 리드문(전체 내용을 관통하는 핵심 메시지) 포함 -4. 세부 주제(Topic)는 다음 형식을 준수: - - 주제명 | #키워드 | [시각화방안] | 내용 가이드 - - 시각화방안: 표, 그래프, 비교표, 다이어그램 등 구체적으로 명시 -5. (중요) 코퍼스 내의 핵심 수치, 기준, 측량 기법 등을 세부 항목 가이드에 포함할 것. - -{attach_str} -""", - } - else: - # 일반 보고서 모드 - sys_msg = { - "role": "system", - "content": ( - domain_prompt + "\n\n" - "당신은 건설/측량 DX 기술 전문가이자 보고서 기획자입니다. " - "제시된 코퍼스를 분석하여, 실무에 즉시 활용 가능한 고품질 기술 보고서 목차를 생성하세요. " - "목차는 대분류(1.), 중분류(1.1), 소분류(1.1.1)의 3단계 계층 구조를 따릅니다. " - "각 소분류(1.1.1) 하위에는 반드시 구체적인 집필 가이드를 포함해야 합니다." - ), - } - user_msg = { - "role": "user", - "content": f""" -다음 정보를 바탕으로 기술 보고서 목차를 생성하세요. - -[정보원(Corpus)] -{corpus[:10000]} - -[작성 규칙] -1. 최상단에 [보고서 제목] 1개를 작성 -2. 목차는 1. / 1.1 / 1.1.1 형식의 3단계 구조 -3. 소분류(1.1.1) 하단에는 해당 섹션에서 다룰 상세 주제들을 다음 형식으로 나열: - - 주제명 | #핵심키워드 | [구성형식] | 집필 가이드(수치나 핵심 기법 포함) - - 구성형식 예시: [비교표], [기술설명], [절차도], [성과분석] 등 -4. 도메인 지식(측량 정확도 기준, 사용 장비 등)이 각 세부 항목 가이드에 녹아있어야 함. - -출력은 목차 텍스트만 깔끔하게 출력하세요. -""", - } - + } resp = client.chat.completions.create( model=GPT_MODEL, messages=[sys_msg, user_msg], - temperature=0.3, ) return (resp.choices[0].message.content or "").strip() + + def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: - """ - 생성된 목차 텍스트를 구조화된 데이터로 파싱합니다. - """ - lines = [l.strip() for l in outline_text.splitlines() if l.strip()] - if not lines: - return "제목 없음", [] + lines = [ln.rstrip() for ln in outline_text.splitlines() if ln.strip()] + if not lines: return "", [] - # 1. 제목 추출 - title_line = lines[0] - title = re.sub(r'^\[?보고서 제목\]?[:\s]*', '', title_line).strip() - - # 2. 계층 구조 파싱 + title = lines[0].strip() # 첫 줄은 보고서 제목 rows = [] - current_section = None + current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적 for ln in lines[1:]: raw = ln.strip() - - # 소분류 (1.1.1) + + # 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, + "depth": 3, + "num": num, "title": s_title, - "sub_topics": [] + "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, @@ -184,18 +157,18 @@ def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: }) continue - # 대분류 (1.) + # 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 + current_section = None # 소목차 구간 종료 continue - # 중분류 (1.1) + # 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 + current_section = None # 소목차 구간 종료 continue return title, rows @@ -208,16 +181,33 @@ def html_escape(s: str) -> str: .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 테이블로 변환""" + """ + 테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML + """ head = """ - +
- - + + - - + + @@ -228,85 +218,287 @@ def build_outline_table_html(rows: List[Dict[str, Any]]) -> str: depth = r["depth"] num = html_escape(r["num"]) title = html_escape(r["title"]) - - if depth == 3: - kw_list = [] - for st in r.get("sub_topics", []): - kw_list.append(f"{html_escape(st['topic_title'])}: {html_escape(st['guide'])}") - detail = "
".join(kw_list) - cls_name = "소분류" + 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: - detail = "" - cls_name = "중분류" + body_parts.append( + f""" + + + + + + + """ + ) else: - detail = "" - cls_name = "대분류" + body_parts.append( + f""" + + + + + + + """ + ) - body_parts.append( - f""" - - - - - - - """ - ) - - tail = "
분류
구분 번호항목명상세 가이드 / 키워드제목키워드
대목차{num}{title}
중목차{num}{title}
소목차{num}{title}{kw}
{cls_name}{num}{title}{detail}
" + tail = """ + + + """ return head + "\n".join(body_parts) + tail def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: - table_html = build_outline_table_html(rows) + """ + 테스트.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 + -

{html_escape(report_title)}

-
- 본 목차는 제공된 코퍼스를 기반으로 AI가 설계한 보고서 구성안입니다. -
- {table_html} + {''.join(html_pages)} """ -def main(input_dir, output_dir, doc_type='report', attach_pages=1): - global DATA_ROOT, OUTPUT_ROOT, CONTEXT_DIR, LOG_DIR - DATA_ROOT = Path(input_dir) - OUTPUT_ROOT = Path(output_dir) - CONTEXT_DIR = OUTPUT_ROOT / "context" - LOG_DIR = OUTPUT_ROOT / "logs" - for d in [CONTEXT_DIR, LOG_DIR]: - d.mkdir(parents=True, exist_ok=True) +def main(): log("=== 목차 생성 시작 ===") domain_prompt = load_domain_prompt() corpus = load_corpus() - # RAG에서 일부 정보를 가져올 수 있으면 가져옴 (선택 사항) - rag_chunks = "" - outline = generate_outline(domain_prompt, corpus, rag_chunks, doc_type, attach_pages) + outline = generate_outline(domain_prompt, corpus) - # TXT 저장 + # TXT 저장 유지 out_txt = CONTEXT_DIR / "outline_issue_report.txt" out_txt.write_text(outline, encoding="utf-8") log(f"목차 TXT 저장 완료: {out_txt}") - # HTML 미리보기용 저장 + # 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(f"목차 HTML 저장 완료: {out_html}") - log("=== 목차 생성 완료 ===") + log("=== 목차 생성 종료 ===") if __name__ == "__main__": main()