"""원문 텍스트 → 업종 스키마 fact 추출 프롬프트·응답 스키마. 무엇을 묻는가 여기 어떻게 부르는가 services/llm/gemini.py 답을 믿을 것인가 services/grounding/extract.py evidence 대조 무엇을 돌려주는가 services/external/gemini_extract.py ★ 왜 사이트별 파서 대신 이걸 쓰는가 사장님 홈페이지는 카페24·아임웹·워드프레스·수제 HTML 이 전부 제각각이고 JSON-LD 가 있는 곳이 드물다. 도메인마다 파서를 짜는 것은 끝이 없다. 대신 **원문에서 찾아 오게 하고, 찾았다는 근거를 원문과 대조**한다. ★ 이 프롬프트의 유일한 임무는 "옮겨 적기" 다. 생성이 아니다. 값을 추론·환산·보정하지 못하게 막는 문장이 규칙의 대부분인 이유다. 실제 안전장치는 프롬프트가 아니라 evidence 대조 코드에 있다(grounding/extract.py). """ from common.category_schema import get_schema from common.enums import PlaceCategory # Gemini responseSchema(OpenAPI subset). ★ evidence 는 required 다 — # 근거를 못 적는 값은 애초에 받지 않는다. RESPONSE_SCHEMA = { "type": "object", "properties": { "facts": { "type": "array", "items": { "type": "object", "properties": { "key": {"type": "string", "description": "아래 필드 목록의 key 중 하나. 목록에 없는 key 는 절대 쓰지 않는다"}, "value": {"type": "string", "description": "원문 표기 그대로. bool 필드는 'true' 또는 'false'"}, "unit_name": {"type": "string", "description": "scope=unit 필드일 때 어느 객실·메뉴·프로그램인지. place 스코프면 빈 문자열"}, "evidence": {"type": "string", "description": "이 값의 근거가 된 원문 문장을 **글자 그대로** 복사. 요약·수정 금지"}, }, "required": ["key", "value", "evidence"], }, } }, "required": ["facts"], } _RULES = """규칙 1. 아래 [필드 목록]에 있는 key 만 쓴다. 목록에 없는 key 는 버린다. 2. value 는 원문 표기를 **그대로** 옮긴다. 단위 환산·반올림·요약·번역을 하지 않는다. - "오후 3시" 를 "15:00" 으로 바꾸지 않는다. 원문이 "오후 3시" 면 "오후 3시" 다. 3. evidence 에는 그 값이 적힌 **원문 문장을 글자 그대로 복사**한다. - 원문에 없는 문장을 쓰면 그 항목은 버려진다. - 여러 문장에 걸쳐 있으면 값이 실제로 적힌 한 문장만 고른다. 4. 원문에 없는 것은 만들지 않는다. 모르면 그 key 를 빼면 된다. 빈 값으로 채우지 않는다. - "보통 펜션은 3시 체크인" 같은 일반 상식으로 채우지 않는다. 5. type=bool 필드는 value 를 "true" 또는 "false" 로만 쓴다. - 원문에 "반려동물 동반 불가" 라고 있으면 pet_allowed = "false" 다. 부정 표현을 놓치지 않는다. - 원문에 언급이 아예 없으면 그 key 를 **쓰지 않는다**. "없다"고 단정하지 않는다. 6. type=number 필드는 숫자만 쓴다(단위·쉼표 없이). 원문이 "최대 4인" 이면 "4" 다. 7. scope=unit 필드는 unit_name 을 반드시 채운다. 같은 객실·메뉴의 항목은 unit_name 을 똑같이 쓴다. - unit_name 은 원문에 적힌 이름 그대로. "A동", "커플룸", "김치찌개". 8. 홍보 문구·후기·다른 업소 이야기는 근거로 쓰지 않는다. - "최고의 전망", "인생 맛집" 같은 표현은 사실이 아니다. 9. 같은 key 가 원문에서 여러 번 다르게 적혀 있으면, 가장 구체적이고 최신인 한 건만 고른다. """ def _field_lines(category: PlaceCategory) -> str: """업종 스키마를 그대로 프롬프트에 편다. ★ 여기서 목록을 따로 관리하지 않는다 — resources/*.json 이 유일한 출처다. 업종 필드가 늘면 프롬프트도 자동으로 같이 는다. """ schema = get_schema(category) lines = [] for spec in schema.fields.values(): # ★ allow_llm 필드(소개문 등)는 목록에 넣지 않는다 — LLM 이 **쓰는** 칸이지 # 원문에서 옮겨 담는 사실이 아니다. 여기에 원문을 채우면 발행본의 소개가 # 수집 원문으로 덮인다(2026-08-31 사고, tests/test_collector.py 참고). if spec.allow_llm: continue bits = [f"- {spec.key} ({spec.label})", f"type={spec.type}", f"scope={spec.scope}"] if spec.unit: bits.append(f"단위={spec.unit}") if spec.critical: # ★ 틀리면 예약 클레임이 나는 항목. 애매하면 비우라고 명시한다. bits.append("※중요-확실할 때만") lines.append(" · ".join(bits)) return "\n".join(lines) def build_prompt(place_name: str, category: PlaceCategory, source_text: str, *, max_chars: int = 30000) -> str: """추출 프롬프트. source_text 는 앞에서 잘라 넣는다 — 사장님 홈페이지 한 페이지 본문은 대개 이 안에 들어오고, 넘치면 뒤쪽은 대개 푸터·저작권·다른 페이지 링크라 fact 가 거의 없다. """ schema = get_schema(category) text = (source_text or "").strip()[:max_chars] return f"""너는 사업장 정보를 **원문에서 찾아 옮겨 적는** 추출기다. 글을 쓰는 것이 아니다. [사업장] {place_name} (업종: {schema.label}) {_RULES} [필드 목록] {_field_lines(category)} [원문] \"\"\" {text} \"\"\" 원문에서 찾은 항목만 facts 배열에 담아라. 찾은 게 없으면 빈 배열을 반환한다."""