o2o-site-AEO/backend/services/prompts/extract.py
Mina Choi 6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00

111 lines
5.7 KiB
Python

"""원문 텍스트 → 업종 스키마 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 배열에 담아라. 찾은 게 없으면 빈 배열을 반환한다."""