Files
test/converters/pipeline/step8_content.py

1021 lines
35 KiB
Python

# -*- coding: utf-8 -*-
"""
step8_generate_report_gemini.py
기능
- 확정 목차(outline_issue_report.txt)를 읽어 섹션(소목차) 목록을 만든다.
- 섹션별로 RAG에서 근거 청크를 검색한다(FAISS 있으면 FAISS, 없으면 키워드 기반).
- 섹션별 본문 초안을 생성한다(내부 근거 우선, 원문 보존 원칙).
- 섹션별 이미지 후보를 매핑하고, md에는 이미지 자리표시자를 삽입한다.
- 산출물 2개를 만든다.
1) report_draft.md
2) report_sections.json
변경사항 (OpenAI → Gemini)
- google.genai 라이브러리 사용
- 자율성 통제: temperature=0.3, thinking_budget=0
- 원문 보존 원칙 강화
- 소목차별 중복 방지 로직 추가
- ★ 이미지 assets 복사 로직 추가
"""
import os
import re
import json
import shutil # ★ 추가: 이미지 복사용
from dataclasses import dataclass, field
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional, Tuple
import numpy as np
try:
import faiss # type: ignore
except Exception:
faiss = None
# ===== 하이브리드 API 설정 =====
# 검색/임베딩: OpenAI (기존 FAISS 인덱스 호환)
# 본문 작성: Gemini (글쓰기 품질)
from google import genai
from google.genai import types
from openai import OpenAI
from api_config import API_KEYS
# OpenAI (임베딩/검색용)
OPENAI_API_KEY = API_KEYS.get('GPT_API_KEY', '')
EMBED_MODEL = "text-embedding-3-small"
openai_client = OpenAI(api_key=OPENAI_API_KEY)
# Gemini (본문 작성용)
GEMINI_API_KEY = API_KEYS.get('GEMINI_API_KEY', '')
GEMINI_MODEL = "gemini-3-pro-preview"
gemini_client = genai.Client(api_key=GEMINI_API_KEY)
# ===== 경로 설정 =====
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"
RAG_DIR = OUTPUT_ROOT / "rag"
GEN_DIR = OUTPUT_ROOT / "generated"
# ★ 추가: 이미지 assets 경로
ASSETS_DIR = GEN_DIR / "assets"
IMAGES_ROOT = DATA_ROOT / "images" # 추출된 이미지 원본 위치
for d in [CONTEXT_DIR, LOG_DIR, RAG_DIR, GEN_DIR, ASSETS_DIR]:
d.mkdir(parents=True, exist_ok=True)
# 파일명
OUTLINE_PATH = CONTEXT_DIR / "outline_issue_report.txt"
DOMAIN_PROMPT_PATH = CONTEXT_DIR / "domain_prompt.txt"
# 선택 파일(있으면 사용)
FAISS_INDEX_PATH = RAG_DIR / "faiss.index"
FAISS_META_PATH = RAG_DIR / "meta.json"
FAISS_VECTORS_PATH = RAG_DIR / "vectors.npy"
# 이미지 메타(있으면 캡션 보강)
IMAGE_META_PATH = DATA_ROOT / "image_metadata.json"
# 출력 파일
REPORT_MD_PATH = GEN_DIR / "report_draft.md"
REPORT_JSON_PATH = GEN_DIR / "report_sections.json"
# 설정값
TOP_K_EVIDENCE = int(os.getenv("TOP_K_EVIDENCE", "10"))
MAX_IMAGES_PER_SECTION = int(os.getenv("MAX_IMAGES_PER_SECTION", "3"))
MAX_EVIDENCE_SNIPPET_CHARS = int(os.getenv("MAX_EVIDENCE_SNIPPET_CHARS", "900"))
# 패턴
RE_TITLE_LINE = re.compile(r"^\s*(.+?)\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*$")
RE_KEYWORDS = re.compile(r"(#\S+)")
RE_IMAGE_PATH_IN_MD = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
def log(msg: str):
line = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
with (LOG_DIR / "step8_generate_report_log.txt").open("a", encoding="utf-8") as f:
f.write(line + "\n")
@dataclass
class SubTopic:
title: str
keywords: List[str]
type: str
guide: str
@dataclass
class OutlineItem:
number: str
title: str
depth: int
sub_topics: List[SubTopic] = field(default_factory=list)
def read_text(p: Path) -> str:
return p.read_text(encoding="utf-8", errors="ignore").strip()
def load_domain_prompt() -> str:
if not DOMAIN_PROMPT_PATH.exists():
raise RuntimeError(f"domain_prompt.txt 없음: {DOMAIN_PROMPT_PATH}")
return read_text(DOMAIN_PROMPT_PATH)
def load_outline() -> Tuple[str, List[OutlineItem]]:
if not OUTLINE_PATH.exists():
raise RuntimeError("목차 파일이 없습니다.")
raw = OUTLINE_PATH.read_text(encoding="utf-8", errors="ignore").splitlines()
if not raw:
return "", []
report_title = raw[0].strip()
items: List[OutlineItem] = []
current_l3 = None
# 꼭지 파싱용 정규식
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*(.+)$")
for ln in raw[1:]:
line = ln.strip()
if not line:
continue
m3h = re_l3_head.match(line)
if m3h:
current_l3 = OutlineItem(number=m3h.group(1), title=m3h.group(2), depth=3)
items.append(current_l3)
continue
m3t = re_l3_topic.match(line)
if m3t and current_l3:
kws = [k.lstrip("#").strip() for k in RE_KEYWORDS.findall(m3t.group(2))]
current_l3.sub_topics.append(SubTopic(
title=m3t.group(1), keywords=kws, type=m3t.group(3), guide=m3t.group(4)
))
continue
m2 = RE_L2.match(line)
if m2:
items.append(OutlineItem(number=m2.group(1), title=m2.group(2), depth=2))
current_l3 = None
continue
m1 = RE_L1.match(line)
if m1:
items.append(OutlineItem(number=m1.group(1), title=m1.group(2), depth=1))
current_l3 = None
continue
return report_title, items
def load_image_metadata() -> Dict[str, Dict[str, Any]]:
"""image_metadata.json이 있으면 image_file 기준으로 맵을 만든다."""
if not IMAGE_META_PATH.exists():
return {}
try:
data = json.loads(IMAGE_META_PATH.read_text(encoding="utf-8", errors="ignore"))
out: Dict[str, Dict[str, Any]] = {}
for it in data:
fn = (it.get("image_file") or "").strip()
if fn:
out[fn] = it
return out
except Exception as e:
log(f"[WARN] image_metadata.json 로드 실패: {e}")
return {}
def iter_rag_items() -> List[Dict[str, Any]]:
"""rag 폴더의 *_chunks.json 모두 로드"""
items: List[Dict[str, Any]] = []
files = sorted(RAG_DIR.glob("*_chunks.json"))
if not files:
raise RuntimeError(f"rag 폴더에 *_chunks.json 없음: {RAG_DIR}")
for f in files:
try:
data = json.loads(f.read_text(encoding="utf-8", errors="ignore"))
if isinstance(data, list):
for it in data:
if isinstance(it, dict):
items.append(it)
except Exception as e:
log(f"[WARN] RAG 파일 로드 실패: {f.name} {e}")
return items
def normalize_ws(s: str) -> str:
return " ".join((s or "").split())
def make_evidence_snippet(text: str, max_chars: int) -> str:
t = normalize_ws(text)
if len(t) <= max_chars:
return t
return t[:max_chars] + "..."
def get_item_key(it: Dict[str, Any]) -> Tuple[str, int]:
src = (it.get("source") or "").strip()
ch = int(it.get("chunk") or 0)
return (src, ch)
def build_item_index(items: List[Dict[str, Any]]) -> Dict[Tuple[str, int], Dict[str, Any]]:
m: Dict[Tuple[str, int], Dict[str, Any]] = {}
for it in items:
m[get_item_key(it)] = it
return m
def try_load_faiss():
"""faiss.index, meta.json, vectors.npy가 모두 있고 faiss 모듈이 있으면 사용"""
if faiss is None:
log("[INFO] faiss 모듈 없음 - 키워드 검색 사용")
return None
if not (FAISS_INDEX_PATH.exists() and FAISS_META_PATH.exists() and FAISS_VECTORS_PATH.exists()):
log("[INFO] FAISS 파일 없음 - 키워드 검색 사용")
return None
try:
index = faiss.read_index(str(FAISS_INDEX_PATH))
metas = json.loads(FAISS_META_PATH.read_text(encoding="utf-8", errors="ignore"))
vecs = np.load(str(FAISS_VECTORS_PATH))
log(f"[INFO] FAISS 로드 성공 - 인덱스 차원: {index.d}, 메타 수: {len(metas)}")
return index, metas, vecs
except Exception as e:
log(f"[WARN] FAISS 로드 실패: {e}")
return None
def embed_query_openai(q: str) -> np.ndarray:
"""OpenAI 임베딩 (기존 FAISS 인덱스와 호환)"""
try:
resp = openai_client.embeddings.create(model=EMBED_MODEL, input=[q])
v = np.array(resp.data[0].embedding, dtype="float32")
n = np.linalg.norm(v) + 1e-12
return v / n
except Exception as e:
log(f"[WARN] OpenAI 임베딩 실패: {e}")
return np.zeros(1536, dtype="float32") # OpenAI 차원
def retrieve_with_faiss(
index,
metas: List[Dict[str, Any]],
item_map: Dict[Tuple[str, int], Dict[str, Any]],
query: str,
top_k: int
) -> List[Dict[str, Any]]:
qv = embed_query_openai(query).reshape(1, -1).astype("float32")
D, I = index.search(qv, top_k)
out: List[Dict[str, Any]] = []
for idx in I[0]:
if idx < 0 or idx >= len(metas):
continue
meta = metas[idx]
src = (meta.get("source") or "").strip()
ch = int(meta.get("chunk") or 0)
it = item_map.get((src, ch))
if it:
out.append(it)
return out
def tokenize_simple(s: str) -> List[str]:
s = normalize_ws(s).lower()
return [t for t in re.split(r"\s+", s) if t]
def retrieve_with_keywords(
all_items: List[Dict[str, Any]],
query: str,
keywords: List[str],
top_k: int
) -> List[Dict[str, Any]]:
q_tokens = set(tokenize_simple(query))
k_tokens = set([kw.lower() for kw in keywords if kw])
scored: List[Tuple[float, Dict[str, Any]]] = []
for it in all_items:
txt = " ".join([
str(it.get("title") or ""),
str(it.get("keywords") or ""),
str(it.get("summary") or ""),
str(it.get("text") or ""),
str(it.get("folder_context") or ""),
str(it.get("source_path") or ""),
])
t = normalize_ws(txt).lower()
score = 0.0
for tok in q_tokens:
if tok and tok in t:
score += 1.0
for tok in k_tokens:
if tok and tok in t:
score += 2.0
if it.get("has_images"):
score += 0.5
if score > 0:
scored.append((score, it))
scored.sort(key=lambda x: x[0], reverse=True)
return [it for _, it in scored[:top_k]]
def select_images_for_section(
evidences: List[Dict[str, Any]],
image_meta_by_file: Dict[str, Dict[str, Any]],
max_images: int
) -> List[Dict[str, Any]]:
"""근거 청크에서 images를 모아 섹션 이미지 후보를 만들고 상한으로 자른다."""
seen = set()
out: List[Dict[str, Any]] = []
def infer_image_file(p: str) -> str:
p = p.replace("\\", "/")
return p.split("/")[-1]
for ev in evidences:
imgs = ev.get("images") or []
if not isinstance(imgs, list):
continue
for img in imgs:
if not isinstance(img, dict):
continue
rel_path = (img.get("path") or "").strip()
if not rel_path:
continue
key = rel_path.replace("\\", "/")
if key in seen:
continue
seen.add(key)
img_file = infer_image_file(key)
meta = image_meta_by_file.get(img_file, {})
caption = ""
if meta:
caption = (meta.get("caption") or "").strip()
if not caption:
caption = (img.get("alt") or "").strip() or img_file
out.append({
"image_id": "",
"rel_path": key,
"image_file": img_file,
"caption": caption,
"source_path": ev.get("source_path") or ev.get("source") or "",
"page": meta.get("page", None) if meta else None,
"type": meta.get("type", None) if meta else None,
})
if len(out) >= max_images:
return out
return out
def make_image_placeholders(section_number: str, images: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""섹션번호 기반으로 이미지아이디를 만들고 placeholder를 만든다."""
sec_key = section_number.replace(".", "_")
out = []
for i, img in enumerate(images, start=1):
img_id = f"{sec_key}_img{i:02d}"
out.append({**img, "image_id": img_id, "placeholder": f"{{{{IMG:{img_id}}}}}"})
return out
# ★ 추가: 이미지 파일을 assets 폴더로 복사하는 함수
def copy_images_to_assets(image_info_list: List[Dict[str, Any]]) -> None:
"""선택된 이미지들을 generated/assets/로 복사"""
for img in image_info_list:
# 원본 경로 찾기 (여러 경로 시도)
rel_path = img.get('rel_path', '')
src_path = None
# 1차: DATA_ROOT 기준 상대경로
candidate1 = DATA_ROOT / rel_path
if candidate1.exists():
src_path = candidate1
# 2차: IMAGES_ROOT에서 파일명으로 검색
if src_path is None:
candidate2 = IMAGES_ROOT / img.get('image_file', '')
if candidate2.exists():
src_path = candidate2
# 3차: DATA_ROOT 전체에서 파일명 검색 (재귀)
if src_path is None:
img_file = img.get('image_file', '')
if img_file:
for found in DATA_ROOT.rglob(img_file):
src_path = found
break
if src_path and src_path.exists():
# image_id 기반으로 새 파일명 생성 (확장자 유지)
ext = src_path.suffix or '.png'
dst_filename = f"{img['image_id']}{ext}"
dst_path = ASSETS_DIR / dst_filename
try:
shutil.copy2(src_path, dst_path)
img['asset_path'] = f"assets/{dst_filename}"
log(f" [IMG] {img['image_id']}{dst_filename}")
except Exception as e:
log(f" [WARN] 이미지 복사 실패: {img['image_id']} - {e}")
img['asset_path'] = None
else:
log(f" [WARN] 이미지 없음: {rel_path} ({img.get('image_file', '')})")
img['asset_path'] = None
# ===== Gemini 프롬프트 구성 (자율성 통제 강화) =====
def build_system_instruction(domain_prompt: str) -> str:
"""
Gemini 시스템 지시문 (v4 - 최종)
"""
return f"""{domain_prompt}
═══════════════════════════════════════════════════════════════
★★★ 절대 준수 규칙 ★★★
═══════════════════════════════════════════════════════════════
[금지 사항]
1. 원문의 수치, 용어, 표현을 임의로 변경 금지
2. 제공되지 않은 정보 추론/창작 금지
3. 추측성 표현 금지 ("~로 보인다", "~일 것이다")
4. 중복 내용 작성 금지
5. 마크다운 헤딩(#, ##, ###, ####) 사용 금지
6. ★ "꼭지", "항목 1", "Topic" 등 내부 분류 용어 출력 금지
7. ★ "1. 2. 3." 형태 번호 사용 금지 (반드시 "1) 2) 3)" 사용)
[필수 사항]
1. 원문 최대 보존
2. 수치는 원본 그대로
3. 전문 용어 변경 없이 사용
4. 보고서 형식으로 전문적 작성
═══════════════════════════════════════════════════════════════
★★★ 번호 체계 및 서식 규칙 (필수) ★★★
═══════════════════════════════════════════════════════════════
【레벨별 번호와 서식】
■ 1단계: 1), 2), 3)
■ 2단계: (1), (2), (3)
■ 3단계: ①, ②, ③ 또는 -, *
【핵심 서식 규칙】
★ 모든 번호의 제목은 반드시 **볼드** 처리
★ 제목과 본문 사이에 반드시 빈 줄(엔터) 삽입
★ 본문과 다음 번호 사이에 반드시 빈 줄(엔터) 삽입
【올바른 예시】
```
1) **VRS GNSS 측량의 개요**
인공위성과 위성기준점을 이용한 위치 측량 방식이다. 실시간 보정을 통해 높은 정확도를 확보할 수 있다.
2) **UAV 사진측량의 특징**
무인항공기를 활용한 광역 측량 방식이다. 목적에 따라 다음과 같이 구분된다.
(1) **맵핑측량**
정사영상 제작에 특화된 촬영 방식이다.
(2) **모델측량**
3D 모델 생성에 특화된 촬영 방식이다.
```
【잘못된 예시 - 절대 금지】
```
꼭지 1 VRS GNSS 측량 ← "꼭지" 용어 금지!
1. VRS GNSS 측량 ← "1." 형태 금지!
1) VRS GNSS 측량 인공위성을... ← 제목+본문 한줄 금지!
1) VRS GNSS 측량 ← 볼드 없음 금지!
```
═══════════════════════════════════════════════════════════════
[작성 형식]
- 섹션 제목 없이 바로 본문 시작
- 주제별 구분: 1), 2), 3) + **볼드 제목** + 줄바꿈 + 본문
- 하위 구분: (1), (2), (3) + **볼드 제목** + 줄바꿈 + 본문
- [비교형]: 마크다운 표 포함
- [기술형]: 기술 사양/수치 정확히 기재
- [절차형]: 단계별 1), 2), 3) 사용
[출력 제한]
- 마크다운 헤딩 금지
- "꼭지", "Topic", "항목" 등 분류 용어 출력 금지
- 내부 메모용 표현 금지
- 출처 표시 금지
═══════════════════════════════════════════════════════════════
"""
def build_user_prompt(
report_title: str,
item, # OutlineItem
evidences,
image_info_list,
previous_sections_summary: str = ""
) -> str:
"""
섹션별 사용자 프롬프트 (v4)
"""
# 근거 자료 정리
ev_text = ""
for i, ev in enumerate(evidences, 1):
src = ev.get('source_path') or ev.get('source', '내부자료')
text = ev.get('text', '')[:1500]
title = ev.get('title', '')
keywords = ev.get('keywords', '')
ev_text += f"""
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[데이터 {i}] 출처: {src}
제목: {title}
키워드: {keywords}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
{text}
"""
# ★ "꼭지" → "주제"로 변경, 번호 부여
topic_guides = ""
for idx, st in enumerate(item.sub_topics, 1):
topic_guides += f"""
【작성할 내용 {idx}{st.title}
- 유형: {st.type}
- 핵심 키워드: {', '.join(['#'+k for k in st.keywords]) if st.keywords else '없음'}
- 참고 지침: {st.guide}
- ★ 출력 시 "{idx}) **{st.title}**" 형태로 시작할 것
"""
# 이미지 안내
img_guide = ""
if image_info_list:
img_guide = "\n【삽입 가능 이미지】\n"
for img in image_info_list:
img_guide += f" - {img['placeholder']}: {img['caption']}\n"
img_guide += " → 문맥에 맞는 위치에 삽입\n"
# 중복 방지
dup_guide = ""
if previous_sections_summary:
dup_guide = f"""
【중복 방지 - 이미 다룬 내용이므로 제외】
{previous_sections_summary}
"""
# ★ 서식 리마인더 강화
format_reminder = """
═══════════════════════════════════════════════════════════════
★★★ 출력 서식 필수 준수 ★★★
═══════════════════════════════════════════════════════════════
1) **제목은 반드시 볼드**
본문은 제목 다음 줄에 작성
2) **다음 제목도 볼드**
본문...
(1) **하위 제목도 볼드**
하위 본문...
"꼭지", "항목", "Topic" 등 내부 용어 절대 출력 금지!
★ 제목과 본문 사이 반드시 빈 줄!
═══════════════════════════════════════════════════════════════
"""
return f"""
╔═══════════════════════════════════════════════════════════════╗
║ 보고서: {report_title}
║ 작성 섹션: {item.number} {item.title}
╚═══════════════════════════════════════════════════════════════╝
{dup_guide}
【이 섹션에서 다룰 내용】
{topic_guides}
{img_guide}
{format_reminder}
【참고 데이터】
{ev_text}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
지시: '{item.number} {item.title}' 섹션 본문을 작성하라.
★ 번호: 1), 2) → (1), (2) → -, *
★ 제목: 반드시 **볼드**
★ 줄바꿈: 제목↔본문 사이 빈 줄 필수
★ 금지어: "꼭지", "항목", "Topic" 출력 금지
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
def generate_section_text_gemini(
system_instruction: str,
user_prompt: str
) -> str:
"""
Gemini API를 사용한 섹션 본문 생성
- temperature=0.3으로 자율성 억제
"""
try:
response = gemini_client.models.generate_content(
model=GEMINI_MODEL,
contents=user_prompt,
config=types.GenerateContentConfig(
system_instruction=system_instruction,
temperature=0.3, # 낮은 temperature로 창의성 억제
)
)
return (response.text or "").strip()
except Exception as e:
log(f"[ERROR] Gemini API 호출 실패: {e}")
return f"[생성 실패: {e}]"
import re
def extract_section_summary(text: str, max_chars: int = 200) -> str:
"""섹션 본문에서 핵심 키워드/주제 추출 (중복 방지용)"""
# 첫 200자 또는 첫 문단
lines = text.split('\n')
summary_parts = []
char_count = 0
for line in lines:
line = line.strip()
if not line or line.startswith('#'):
continue
summary_parts.append(line)
char_count += len(line)
if char_count >= max_chars:
break
return ' '.join(summary_parts)[:max_chars]
def fix_numbering_format(text: str) -> str:
"""
Gemini가 "1. 2. 3." 형태로 출력했을 때 "1) 2) 3)" 형태로 변환
변환 규칙:
- "1. ""1) " (줄 시작, 들여쓰기 0)
- " 1. "" (1) " (들여쓰기 있으면 하위 레벨)
"""
lines = text.split('\n')
result = []
for line in lines:
# 원본 들여쓰기 측정
stripped = line.lstrip()
indent = len(line) - len(stripped)
# "숫자. " 패턴 감지 (마크다운 순서 리스트)
match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
if match:
num = match.group(1)
content = match.group(2)
if indent == 0:
# 최상위 레벨: 1. → 1)
result.append(f"{num}) {content}")
elif indent <= 4:
# 1단계 들여쓰기: 1. → (1)
result.append(" " * indent + f"({num}) {content}")
else:
# 2단계 이상 들여쓰기: 그대로 유지 또는 - 로 변환
result.append(" " * indent + f"- {content}")
else:
result.append(line)
return '\n'.join(result)
def clean_generated_text_final(section_number: str, text: str) -> str:
"""
Gemini 출력 후처리 (최종 버전)
1. 중복 섹션 제목 제거
2. "꼭지 N" 패턴 제거
3. 번호 체계 변환 (1. → 1))
4. 제목 볼드 + 줄바꿈 강제 적용
5. #### 헤딩 → 볼드 변환
"""
# 1단계: 기본 정리
lines = text.split('\n')
cleaned = []
for line in lines:
stripped = line.strip()
# 중복 섹션 제목 제거 (# 숫자.숫자.숫자 형태)
if re.match(r'^#{1,4}\s*\d+(\.\d+)*\s+', stripped):
continue
# "꼭지 N" 패턴 제거 (독립 라인)
if re.match(r'^[\*\*]*꼭지\s*\d+[\*\*]*\s*', stripped):
continue
# "**꼭지 N 제목**" → "**제목**" 변환
cleaned_line = re.sub(r'\*\*꼭지\s*\d+\s*', '**', stripped)
# #### 헤딩 → 볼드
h4_match = re.match(r'^####\s+(.+)$', cleaned_line)
if h4_match:
title = h4_match.group(1).strip()
if not re.match(r'^\d+', title):
cleaned.append(f"\n**{title}**\n")
continue
# 빈 줄 연속 방지 (3줄 이상 → 2줄)
if not stripped:
if len(cleaned) >= 2 and not cleaned[-1].strip() and not cleaned[-2].strip():
continue
cleaned.append(cleaned_line if cleaned_line != stripped else line)
result = '\n'.join(cleaned)
# 2단계: 번호 체계 변환
result = fix_numbering_format(result)
# 3단계: 제목+본문 붙어있는 것 분리 + 볼드 적용
result = fix_title_format(result)
return result.strip()
def fix_numbering_format(text: str) -> str:
"""
"1. ""1) " 변환
들여쓰기 있으면 "(1)" 형태로
"""
lines = text.split('\n')
result = []
for line in lines:
stripped = line.lstrip()
indent = len(line) - len(stripped)
# "숫자. " 패턴 (마크다운 순서 리스트)
match = re.match(r'^(\d+)\.\s+(.+)$', stripped)
if match:
num = match.group(1)
content = match.group(2)
if indent == 0:
# 최상위: 1. → 1)
result.append(f"{num}) {content}")
elif indent <= 4:
# 1단계 들여쓰기: → (1)
result.append(" " * indent + f"({num}) {content}")
else:
# 2단계 이상: → -
result.append(" " * indent + f"- {content}")
else:
result.append(line)
return '\n'.join(result)
def fix_title_format(text: str) -> str:
"""
번호+제목+본문 한줄 → 번호+제목 / 본문 분리
제목에 볼드 적용
핵심: **볼드 제목** 뒤에 본문이 이어지면 줄바꿈 삽입
"""
lines = text.split('\n')
result = []
for line in lines:
stripped = line.strip()
indent = len(line) - len(stripped)
indent_str = " " * indent
# 패턴 1: "1) **제목** 본문..." → "1) **제목**\n\n본문..."
m1 = re.match(r'^(\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped)
if m1:
num = m1.group(1)
title = m1.group(2)
body = m1.group(3).strip()
result.append(f"{indent_str}{num}) {title}")
result.append("")
result.append(f"{indent_str}{body}")
result.append("")
continue
# 패턴 2: "(1) **제목** 본문..." → "(1) **제목**\n\n본문..."
m2 = re.match(r'^\((\d+)\)\s+(\*\*[^*]+\*\*)\s+(.{20,})$', stripped)
if m2:
num = m2.group(1)
title = m2.group(2)
body = m2.group(3).strip()
result.append(f"{indent_str}({num}) {title}")
result.append("")
result.append(f"{indent_str}{body}")
result.append("")
continue
# 패턴 3: "1) 제목:" 또는 "1) 제목" (볼드 없음, 짧은 제목) → 볼드 적용
m3 = re.match(r'^(\d+)\)\s+([^*\n]{3,40})$', stripped)
if m3:
num = m3.group(1)
title = m3.group(2).strip().rstrip(':')
# 문장이 아닌 제목으로 판단 (마침표로 안 끝남)
if not title.endswith(('.', '', '', '', '')):
result.append(f"{indent_str}{num}) **{title}**")
result.append("")
continue
# 패턴 4: "(1) 제목" (볼드 없음) → 볼드 적용
m4 = re.match(r'^\((\d+)\)\s+([^*\n]{3,40})$', stripped)
if m4:
num = m4.group(1)
title = m4.group(2).strip().rstrip(':')
if not title.endswith(('.', '', '', '', '')):
result.append(f"{indent_str}({num}) **{title}**")
result.append("")
continue
result.append(line)
# 연속 빈줄 정리
final = []
for line in result:
if not line.strip():
if len(final) >= 2 and not final[-1].strip() and not final[-2].strip():
continue
final.append(line)
return '\n'.join(final)
def main():
log("=== step8 Gemini 기반 보고서 생성 시작 ===")
domain_prompt = load_domain_prompt()
report_title, outline_items = load_outline()
log(f"보고서 제목: {report_title}")
log(f"목차 항목 수: {len(outline_items)}")
# 데이터 및 이미지 메타 로드
image_meta_by_file = load_image_metadata()
all_rag_items = iter_rag_items()
item_map = build_item_index(all_rag_items)
faiss_pack = try_load_faiss()
use_faiss = faiss_pack is not None
log(f"RAG 청크 수: {len(all_rag_items)}")
log(f"FAISS 사용: {use_faiss}")
# 시스템 지시문 (한 번만 생성)
system_instruction = build_system_instruction(domain_prompt)
md_lines = [f"# {report_title}", ""]
report_json_sections = []
# 중복 방지를 위한 이전 섹션 요약 누적
previous_sections_summary = ""
# ★ 추가: 복사된 이미지 카운트
total_images_copied = 0
for it in outline_items:
# 대목차와 중목차는 제목만 적고 통과
if it.depth < 3:
prefix = "## " if it.depth == 1 else "### "
md_lines.append(f"\n{prefix}{it.number} {it.title}\n")
continue
log(f"집필 중: {it.number} {it.title} (꼭지 {len(it.sub_topics)}개)")
# 꼭지들의 키워드를 합쳐서 검색
all_kws = []
for st in it.sub_topics:
all_kws.extend(st.keywords)
query = f"{it.title} " + " ".join(all_kws)
# RAG 검색
if use_faiss:
evidences = retrieve_with_faiss(faiss_pack[0], faiss_pack[1], item_map, query, 12)
else:
evidences = retrieve_with_keywords(all_rag_items, query, all_kws, 12)
log(f" → 검색된 근거 청크: {len(evidences)}")
# 이미지 선택 및 플레이스홀더 생성
section_images = select_images_for_section(evidences, image_meta_by_file, MAX_IMAGES_PER_SECTION)
image_info_list = make_image_placeholders(it.number, section_images)
# ★ 추가: 이미지 파일을 assets 폴더로 복사
copy_images_to_assets(image_info_list)
copied_count = sum(1 for img in image_info_list if img.get('asset_path'))
total_images_copied += copied_count
# 사용자 프롬프트 생성
user_prompt = build_user_prompt(
report_title=report_title,
item=it,
evidences=evidences,
image_info_list=image_info_list,
previous_sections_summary=previous_sections_summary
)
# Gemini로 본문 생성
section_text = generate_section_text_gemini(system_instruction, user_prompt)
section_text = clean_generated_text_final(it.number, section_text) # ★ 이 한 줄만 추가!
# 마크다운 내용 추가
md_lines.append(f"\n#### {it.number} {it.title}\n")
md_lines.append(section_text + "\n")
# 중복 방지를 위해 현재 섹션 요약 누적 ← 이 부분은 그대로!
section_summary = extract_section_summary(section_text)
if section_summary:
previous_sections_summary += f"\n- {it.number}: {section_summary[:100]}..."
# JSON용 데이터 수집 (★ asset_path 추가)
report_json_sections.append({
"section_id": it.number,
"section_title": it.title,
"generated_text": section_text,
"sub_topics": [vars(st) for st in it.sub_topics],
"evidence_count": len(evidences),
"assets": [
{
"type": "image",
"image_id": img["image_id"],
"filename": img["image_file"],
"caption": img["caption"],
"placeholder": img["placeholder"],
"source_path": img.get("source_path", ""),
"page": img.get("page"),
"asset_path": img.get("asset_path"), # ★ 추가
}
for img in image_info_list
]
})
log(f" → 생성 완료 ({len(section_text)} 자)")
# 1. 마크다운(.md) 파일 저장
REPORT_MD_PATH.write_text("\n".join(md_lines), encoding="utf-8")
# 2. JSON(.json) 파일 저장
REPORT_JSON_PATH.write_text(
json.dumps({
"generated_at": datetime.now().isoformat(),
"report_title": report_title,
"model": GEMINI_MODEL,
"sections": report_json_sections
}, ensure_ascii=False, indent=2),
encoding="utf-8"
)
log(f"")
log(f"═══════════════════════════════════════════════════")
log(f"파일 저장 완료:")
log(f" 1. {REPORT_MD_PATH}")
log(f" 2. {REPORT_JSON_PATH}")
log(f" 3. {ASSETS_DIR} (이미지 {total_images_copied}개 복사)") # ★ 추가
log(f"═══════════════════════════════════════════════════")
log("=== step8 보고서 생성 종료 ===")
if __name__ == "__main__":
main()