Update step7_index.py

This commit is contained in:
2026-03-19 09:57:59 +09:00
parent 8dcc0af15d
commit 4885984a38

View File

@@ -1,31 +1,42 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from dotenv import load_dotenv
load_dotenv()
""" """
make_outline.py make_outline.py
기능: 기능:
- output_context/context/domain_prompt.txt - output_context/context/domain_prompt.txt
- output_context/context/corpus.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 os
import sys import sys
import re import re
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Tuple from typing import List, Dict, Any, Tuple
from openai import OpenAI 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 설정 (구조 유지) =====
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" GPT_MODEL = "gpt-5-2025-08-07"
client = OpenAI(api_key=OPENAI_API_KEY) client = OpenAI(api_key=OPENAI_API_KEY)
# ===== 목차 구성을 위한 정규식 ===== # ===== 목차 파싱용 정규식 보완 (5분할 대응) =====
RE_KEYWORDS = re.compile(r"(#\S+)") RE_KEYWORDS = re.compile(r"(#\S+)")
RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$") RE_L1 = re.compile(r"^\s*(\d+)\.\s+(.+?)\s*$")
RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$") RE_L2 = re.compile(r"^\s*(\d+\.\d+)\s+(.+?)\s*$")
@@ -39,126 +50,85 @@ def log(msg: str):
def load_domain_prompt() -> str: def load_domain_prompt() -> str:
p = CONTEXT_DIR / "domain_prompt.txt" p = CONTEXT_DIR / "domain_prompt.txt"
if not p.exists(): if not p.exists():
log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행하십시오.") log("domain_prompt.txt가 없습니다. 먼저 domain_prompt.py를 실행해야 합니다.")
sys.exit(1) sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip() return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_corpus() -> str: def load_corpus() -> str:
p = CONTEXT_DIR / "corpus.txt" p = CONTEXT_DIR / "corpus.txt"
if not p.exists(): if not p.exists():
log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행하십시오.") log("corpus.txt가 없습니다. 먼저 make_corpus.py를 실행해야 합니다.")
sys.exit(1) sys.exit(1)
return p.read_text(encoding="utf-8", errors="ignore").strip() return p.read_text(encoding="utf-8", errors="ignore").strip()
# 기존 RE_L1, RE_L2와 일치하지 않는 가이드용 정규식 추가. # 기존 RE_L1, RE_L2는 유지하고 아래 두 개를 추가/교체합니다.
RE_L3_HEAD = re.compile(r"^\s*(\d+\.\d+\.\d+)\s+(.+)$") 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_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: def generate_outline(domain_prompt: str, corpus: str) -> str:
""" sys_msg = {
GPT를 호출하여 전체 보고서의 목차를 구성합니다. "role": "system",
- doc_type: 'report' (일반 보고서) 또는 'briefing' (브리핑 자료) "content": (
- attach_pages: 브리핑 자료 시 첨부 페이지 수 domain_prompt + "\n\n"
""" "너는 건설/측량 DX 기술 보고서의 구조를 설계하는 시니어 기술사이다. "
if doc_type == 'briefing': "주어진 corpus를 분석하여, 실무자가 즉시 활용 가능한 고밀도 지침서 목차를 설계하라."
sys_msg = { ),
"role": "system", }
"content": (
domain_prompt + "\n\n" user_msg = {
"당신은 측량 및 지리정보 분야의 보고서 기획 전문가입니다. " "role": "user",
"제공된 정보를 분석하여, A4 1~2매 분량의 '핵심 브리핑 자료' 목차를 작성하세요. " "content": f"""
"본문은 '1. 개요 - 2. 현황 - 3. 문제점 및 대책 - 4. 결론' 형식을 기본으로 하되 내용에 맞춰 조정 가능합니다. " 아래 [corpus]를 바탕으로 보고서 제목과 전략적 목차를 설계하라.
"각 세부 주제별로 데이터 근거와 시각화 방안을 포함한 기획안을 도출하세요."
), [corpus]
} {corpus}
attach_str = ""
for i in range(1, attach_pages + 1): 요구 사항:
attach_str += f""" 1) 첫 줄에 보고서 제목 1개를 작성하라.
[첨부 {i}] 2) 그 아래 목차를 번호 기반 계측 구조로 작성하라.
- 제목: 본문 내용을 보완하는 상세 데이터/참고 자료 (본문과 제목은 다르게) - 대목차: 1. / 2. / 3. ...
- 리드문: 본문에서 언급된 핵심 수치나 근거를 요약하여 제시 - 중목차: 1.1 / 1.2 / ...
- 세부 항목: [- 주제 | #키워드 | [시각화방안] | 내용 가이드] (3~4개 구성) - 소목차: 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( resp = client.chat.completions.create(
model=GPT_MODEL, model=GPT_MODEL,
messages=[sys_msg, user_msg], messages=[sys_msg, user_msg],
temperature=0.3,
) )
return (resp.choices[0].message.content or "").strip() return (resp.choices[0].message.content or "").strip()
def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]: 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 "", []
"""
lines = [l.strip() for l in outline_text.splitlines() if l.strip()]
if not lines:
return "제목 없음", []
# 1. 제목 추출 title = lines[0].strip() # 첫 줄은 보고서 제목
title_line = lines[0]
title = re.sub(r'^\[?보고서 제목\]?[:\s]*', '', title_line).strip()
# 2. 계층 구조 파싱
rows = [] rows = []
current_section = None current_section = None # 현재 처리 중인 소목차(1.1.1)를 추적
for ln in lines[1:]: for ln in lines[1:]:
raw = ln.strip() raw = ln.strip()
# 소분류 (1.1.1) # 1. 소목차 헤더(1.1.1 제목) 발견 시
m3_head = RE_L3_HEAD.match(raw) m3_head = RE_L3_HEAD.match(raw)
if m3_head: if m3_head:
num, s_title = m3_head.groups() num, s_title = m3_head.groups()
@@ -166,16 +136,19 @@ def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
"depth": 3, "depth": 3,
"num": num, "num": num,
"title": s_title, "title": s_title,
"sub_topics": [] "sub_topics": [] # 여기에 아래 줄의 꼭지들을 담을 예정
} }
rows.append(current_section) rows.append(current_section)
continue continue
# 상세 주제 (- 주제 | #키워드 | [형] | 가이드) # 2. 세부 꼭지(- 주제 | #키워드 | [형] | 가이드) 발견 시
m_topic = RE_L3_TOPIC.match(raw) m_topic = RE_L3_TOPIC.match(raw)
if m_topic and current_section: if m_topic and current_section:
t_title, kws_raw, t_type, guide = m_topic.groups() t_title, kws_raw, t_type, guide = m_topic.groups()
# 키워드 추출 (#키워드 형태)
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)] kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(kws_raw)]
# 현재 소목차(current_section)의 리스트에 추가
current_section["sub_topics"].append({ current_section["sub_topics"].append({
"topic_title": t_title, "topic_title": t_title,
"keywords": kws, "keywords": kws,
@@ -184,18 +157,18 @@ def parse_outline(outline_text: str) -> Tuple[str, List[Dict[str, Any]]]:
}) })
continue continue
# 대분류 (1.) # 3. 대목차(1.) 처리
m1 = RE_L1.match(raw) m1 = RE_L1.match(raw)
if m1: if m1:
rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()}) rows.append({"depth": 1, "num": m1.group(1).strip(), "title": m1.group(2).strip()})
current_section = None current_section = None # 소목차 구간 종료
continue continue
# 중분류 (1.1) # 4. 중목차(1.1) 처리
m2 = RE_L2.match(raw) m2 = RE_L2.match(raw)
if m2: if m2:
rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()}) rows.append({"depth": 2, "num": m2.group(1).strip(), "title": m2.group(2).strip()})
current_section = None current_section = None # 소목차 구간 종료
continue continue
return title, rows return title, rows
@@ -208,16 +181,33 @@ def html_escape(s: str) -> str:
.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: def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
"""목차 구조를 HTML 테이블로 변환""" """
테스트.html의 table 스타일을 그대로 쓰는 전제의 표 HTML
"""
head = """ head = """
<table border="1" style="width:100%; border-collapse: collapse;"> <table>
<thead> <thead>
<tr style="background-color: #f2f2f2;"> <tr>
<th>분</th> <th>분</th>
<th>번호</th> <th>번호</th>
<th>항목명</th> <th>제목</th>
<th>상세 가이드 / 키워드</th> <th>키워드</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -228,85 +218,287 @@ def build_outline_table_html(rows: List[Dict[str, Any]]) -> str:
depth = r["depth"] depth = r["depth"]
num = html_escape(r["num"]) num = html_escape(r["num"])
title = html_escape(r["title"]) title = html_escape(r["title"])
kw = " ".join([f"#{k}" for k in r.get("keywords", []) if k])
kw = html_escape(kw)
if depth == 3: if depth == 1:
kw_list = [] body_parts.append(
for st in r.get("sub_topics", []): f"""
kw_list.append(f"<b>{html_escape(st['topic_title'])}</b>: {html_escape(st['guide'])}") <tr>
detail = "<br>".join(kw_list) <td class="group-cell">대목차</td>
cls_name = "소분류" <td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
elif depth == 2: elif depth == 2:
detail = "" body_parts.append(
cls_name = "중분류" f"""
<tr>
<td class="group-cell">중목차</td>
<td>{num}</td>
<td>{title}</td>
<td></td>
</tr>
"""
)
else: else:
detail = "" body_parts.append(
cls_name = "대분류" f"""
<tr>
<td class="group-cell">소목차</td>
<td>{num}</td>
<td>{title}</td>
<td>{kw}</td>
</tr>
"""
)
body_parts.append( tail = """
f""" </tbody>
<tr> </table>
<td style="padding: 8px; text-align: center;">{cls_name}</td> """
<td style="padding: 8px; text-align: center;">{num}</td>
<td style="padding: 8px; font-weight: {'bold' if depth < 3 else 'normal'};">{title}</td>
<td style="padding: 8px; font-size: 0.9em;">{detail}</td>
</tr>
"""
)
tail = "</tbody></table>"
return head + "\n".join(body_parts) + tail return head + "\n".join(body_parts) + tail
def build_outline_html(report_title: str, rows: List[Dict[str, Any]]) -> str: 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"""
<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> return f"""<!DOCTYPE html>
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>보고서 목차 구성안</title> <title>{html_escape(report_title)} - Outline</title>
<style> <style>{css}</style>
body {{ font-family: 'Malgun Gothic', sans-serif; padding: 40px; line-height: 1.6; color: #333; }}
h1 {{ color: #1a365d; border-bottom: 2px solid #1a365d; padding-bottom: 10px; }}
.info {{ background: #f7fafc; padding: 15px; border-left: 5px solid #1a365d; margin-bottom: 20px; }}
</style>
</head> </head>
<body> <body>
<h1>{html_escape(report_title)}</h1> {''.join(html_pages)}
<div class="info">
본 목차는 제공된 코퍼스를 기반으로 AI가 설계한 보고서 구성안입니다.
</div>
{table_html}
</body> </body>
</html> </html>
""" """
def main(input_dir, output_dir, doc_type='report', attach_pages=1): def main():
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)
log("=== 목차 생성 시작 ===") log("=== 목차 생성 시작 ===")
domain_prompt = load_domain_prompt() domain_prompt = load_domain_prompt()
corpus = load_corpus() corpus = load_corpus()
# RAG에서 일부 정보를 가져올 수 있으면 가져옴 (선택 사항) outline = generate_outline(domain_prompt, corpus)
rag_chunks = ""
outline = generate_outline(domain_prompt, corpus, rag_chunks, doc_type, attach_pages)
# TXT 저장 # TXT 저장 유지
out_txt = CONTEXT_DIR / "outline_issue_report.txt" out_txt = CONTEXT_DIR / "outline_issue_report.txt"
out_txt.write_text(outline, encoding="utf-8") out_txt.write_text(outline, encoding="utf-8")
log(f"목차 TXT 저장 완료: {out_txt}") log(f"목차 TXT 저장 완료: {out_txt}")
# HTML 미리보기용 저장 # HTML 추가 저장
title, rows = parse_outline(outline) title, rows = parse_outline(outline)
out_html = CONTEXT_DIR / "outline_issue_report.html" out_html = CONTEXT_DIR / "outline_issue_report.html"
out_html.write_text(build_outline_html(title, rows), encoding="utf-8") 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__": if __name__ == "__main__":
main() main()