1021 lines
35 KiB
Python
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\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"
|
|
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() |