"""원문 텍스트 → fact 후보 추출 — 겹들을 엮어 결과를 만드는 자리. 이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마 어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용 답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조 무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립 ★ 입력이 무엇이든 여기로 모인다 사장님이 붙여넣은 텍스트도, static_html 어댑터가 받아온 페이지 본문도 똑같이 '원문 문자열' 하나다. 도메인마다 파서를 짜는 대신 여기 한 곳을 쓴다. ★ 나가는 값은 전부 후보다 통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 — 그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다. """ import json from dataclasses import dataclass, field from typing import Optional import httpx from common.category_schema import get_schema from common.enums import PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact from services.grounding.extract import verify from services.llm.gemini import ( DEFAULT_MODEL, GeminiInvalidOutput, GeminiNotConfigured, call, extract_text, is_configured, price, read_usage, ) from services.prompts.extract import RESPONSE_SCHEMA, build_prompt # 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고, # 호출비만 나간다. MIN_SOURCE_CHARS = 80 @dataclass class ExtractResult: """추출 결과. facts 는 검증을 통과한 것만 담긴다. rejected 에는 (항목, 사유) 가 들어간다 — 조용히 버리지 않는다. 운영자가 "왜 체크인 시간이 안 들어왔나" 를 이 목록으로 읽는다. """ facts: list[CollectedFact] = field(default_factory=list) rejected: list[tuple[str, str]] = field(default_factory=list) @property def ok(self) -> bool: return bool(self.facts) async def extract_facts( place_name: str, category: PlaceCategory, source_text: str, *, source_url: str, model: str = DEFAULT_MODEL, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> ExtractResult: """원문 텍스트에서 업종 스키마 fact 를 뽑는다. ★ source_url 은 필수다. 출처 없는 fact 는 FACT_SOURCE_REQUIRED 로 거부되므로 여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다). ★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다. """ if not is_configured(): raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다") if not (source_url or "").strip(): raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다") text = (source_text or "").strip() if len(text) < MIN_SOURCE_CHARS: LOG.i(f"[gemini-extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다") return ExtractResult(rejected=[("(전체)", f"원문이 {len(text)}자로 너무 짧다 — 호출하지 않았다")]) body = { "contents": [{"role": "user", "parts": [{"text": build_prompt(place_name, category, text)}]}], "generationConfig": { "responseMimeType": "application/json", "responseSchema": RESPONSE_SCHEMA, # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다. "temperature": 0.0, }, } owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) try: payload = await call(client, model, body, max_retries) parsed = json.loads(extract_text(payload)) except json.JSONDecodeError as ex: raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex finally: if owns_client: await client.aclose() rows = parsed.get("facts") if not isinstance(rows, list): raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}") # ★ 여기가 관문이다. 모델이 뭘 적어 냈든 원문과 대조해서 통과한 것만 나간다. passed, rejected = verify(rows, source_text=text, schema=get_schema(category)) facts = [ CollectedFact( key=row["key"], value=row["value"], scope=row["scope"], unit_name=row["unit_name"], source_url=source_url, ) for row in passed ] usage = read_usage(payload) LOG.i( f"[gemini-extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · " f"반려 {len(rejected)}건 · model={model} · " f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}" ) if rejected: for label, why in rejected[:10]: LOG.w(f"[gemini-extract] 반려 {label} — {why}") return ExtractResult(facts=facts, rejected=rejected)