"""원문 텍스트 → fact 후보 추출 — 겹들을 엮어 결과를 만드는 자리. 이 파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다: 무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마 어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용 답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조 무엇을 돌려주는가 여기 호출 → 검증 → CollectedFact 조립 ★ 입력이 무엇이든 여기로 모인다 사장님이 붙여넣은 텍스트도, static_html 어댑터가 받아온 페이지 본문도 똑같이 '원문 문자열' 하나다. 도메인마다 파서를 짜는 대신 여기 한 곳을 쓴다. ★ 나가는 값은 전부 후보다 통과한 fact 도 UNVERIFIED 로 들어간다. 사장님이 확인해야 사이트에 나간다 — 그 게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다. """ 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 import provider from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmNotConfigured as GeminiNotConfigured 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: Optional[str] = None, max_retries: int = 2, client: Optional[httpx.AsyncClient] = None, ) -> ExtractResult: """원문 텍스트에서 업종 스키마 fact 를 뽑는다. ★ source_url 은 필수다. 출처 없는 fact 는 FACT_SOURCE_REQUIRED 로 거부되므로 여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다). ★ 원문이 짧으면 **API 를 호출하지 않는다** — 근거가 없는데 부르면 그게 곧 환각 유발이다. """ llm = provider.active() if not llm.is_configured(): raise GeminiNotConfigured("API 키가 설정되지 않았다") if not (source_url or "").strip(): raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다") model = model or llm.DEFAULT_MODEL text = (source_text or "").strip() if len(text) < MIN_SOURCE_CHARS: LOG.i(f"[extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다") return ExtractResult(rejected=[("(전체)", f"원문이 {len(text)}자로 너무 짧다 — 호출하지 않았다")]) owns_client = client is None client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) try: llm_result = await llm.generate( client, model, prompt=build_prompt(place_name, category, text), # ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다. response_schema=RESPONSE_SCHEMA, temperature=0.0, max_retries=max_retries, ) finally: if owns_client: await client.aclose() rows = llm_result.json.get("facts") if llm_result.json else None 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 = llm_result.usage LOG.i( f"[extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · " f"반려 {len(rejected)}건 · model={model} · " f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}" ) if rejected: for label, why in rejected[:10]: LOG.w(f"[extract] 반려 {label} — {why}") return ExtractResult(facts=facts, rejected=rejected)