# -*- 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()