o2o-site-AEO/solution/backend/services/prompts/copy.py
hbyang 7b238efffb [feat] solution/backend: 업소 조사 — 소개문이 쓸 재료를 출처와 함께 찾아온다
소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라
**쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 fact 3건이 전부이고, TourAPI 는 미등록,
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그런데 이 업소의 내력
(1925년 적산가옥 · 히로쓰 가옥 후문 옆 · A동 B동 컨셉)은 블로그·기사에 공개돼 있다.
그걸 가져오는 단계가 없었을 뿐이다.

★ 문장을 검색모델에게 시키지 않는다. 재료만 모으고 소개문은 지금처럼 Gemini 가 쓴다 —
  소개문을 바로 시키면 그 문장의 근거를 우리가 못 갖고, ground_check 가 전부 반려한다.

- services/prompts/place_research.py: 사실 조각을 **출처와 함께** 요구한다. 요금·객실 수·
  체크인·취소 규정은 묻지 않는다 — 그건 fact 이고 블로그의 옛값이 섞이면 예약 클레임이다
- services/grounding/place_research.py: 출처 없는 항목은 버린다(story 와 같은 규율) +
  **상호 대조**를 더한다. 지역 이야기는 틀려도 지역 이야기지만, 업소 조사가 틀리면 남의
  가게 이야기가 이 사장님 소개문이 된다 — 이 레포에서 가장 비싼 실수다
- services/place_research.py: 조사 → place_channels.raw 에 근거 적재. **확정하지 않는다** —
  남이 쓴 글이라 공식 채널·sameAs 로 나가면 안 된다. 새 표를 만들지 않았다
- copy_service: 확정 링크만 읽던 근거를 raw.kind=research 까지 넓혔다. 확정 여부는
  "화면에 채널로 낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다
- prompts/copy: 조사 기록을 fact 목록이 아니라 **별도 절**로 준다(fact 자리에 섞으니
  모델이 값 하나로 읽고 안 썼다). 소개문 분량 100~250자 → 200~600자·2~3문단 —
  옛 길이로는 확인된 사실을 나열하면 끝나 기록이 들어갈 자리가 없었다
- collect_service: 수집이 끝난 **뒤** 조사한다. 앞에 두면 네이버·TourAPI 가 이미 준 것을
  다시 묻는 꼴이라 검색 요금이 헛돈다

실측(스테이,머뭄): 조사 8건 채택·0건 버림 → 소개문이
"1925년에 지어진 100년 된 적산가옥을 리노베이션한 숙소 … 히로쓰 가옥 후문 바로 옆" 으로.
발행본 본문 10,798자 → 11,100자. 생성물은 여전히 PENDING_OWNER 로 들어가 사장님이 확인해야
노출된다(절대규칙 1).
2026-09-10 14:17:44 +09:00

102 lines
3.8 KiB
Python

"""Prompt contract for grounded homepage copy."""
from typing import Optional, Protocol, Sequence
from common.enums import PlaceCategory
class FactLike(Protocol):
key: str
label: str
value: str
unit: Optional[str]
RESPONSE_SCHEMA = {
"type": "object",
"properties": {
"intro": {"type": "string"},
"intro_fact_keys": {"type": "array", "items": {"type": "string"}},
"meta_description": {"type": "string"},
"faqs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"question": {"type": "string"},
"answer": {"type": "string"},
"fact_keys": {"type": "array", "items": {"type": "string"}},
},
"required": ["question", "answer", "fact_keys"],
},
},
},
"required": ["intro", "intro_fact_keys", "meta_description", "faqs"],
}
_CATEGORY_LABEL = {
PlaceCategory.LODGING: "숙박업소",
PlaceCategory.CAFE: "카페",
PlaceCategory.RESTAURANT: "음식점",
PlaceCategory.CLINIC: "관광·체험 시설",
}
def _fact_lines(facts: Sequence[FactLike]) -> str:
return "\n".join(
f"- {fact.key} ({fact.label}) = {fact.value}" + (f" {fact.unit}" if fact.unit else "")
for fact in facts
)
def build_prompt(
place_name: str,
category: PlaceCategory,
facts: Sequence[FactLike],
max_faqs: int,
unit_facts: Optional[Sequence[FactLike]] = None,
records: Optional[Sequence[str]] = None,
) -> str:
"""소개문·메타·FAQ 생성 프롬프트.
★ `records` 는 fact 가 아니라 **글**이다(수집 원문 · 업소 조사 결과).
예전에는 이것도 fact 목록에 `- source:<uuid> (수집 원문) = …` 로 섞여 들어갔다.
모델은 그걸 값 하나로 읽고 거의 쓰지 않았다 — 실측(2026-09-10, 스테이,머뭄):
조사 근거 448자를 넣어도 소개문은 "주방 시설을 갖춘 독채형 객실을 운영하는
숙박업소입니다" 에서 한 발도 못 나갔다. 재료를 재료 자리에 놓아야 쓴다.
"""
sections = [
f"'{place_name}'({_CATEGORY_LABEL.get(category, '사업장')})의 공식 홈페이지 문구를 작성한다.",
"",
"확인된 사업장 사실:",
_fact_lines(facts) if facts else "- 없음",
]
if unit_facts:
sections.extend(["", "확인된 객실·메뉴 사실:", _fact_lines(unit_facts)])
if records:
sections.extend([
"",
"업소에 대해 확인된 기록(출처가 있는 글):",
*(f"- {line}" for line in records),
])
sections.extend([
"",
"출력:",
# ★ 100~250자였다. 그 길이로는 확인된 사실을 나열하면 끝나서, 기록이 있어도
# 들어갈 자리가 없었다. 시안(/s/stay)의 소개는 3문단 450자 안팎이다.
"- intro: 소개문 200~600자. 사실이 충분하면 2~3문단으로 나눈다(문단 사이 빈 줄)",
"- intro_fact_keys: 소개문의 근거 key",
"- meta_description: 검색 요약 50~120자",
f"- faqs: 최대 {max_faqs}개, 각 항목에 근거 fact_keys 포함",
"",
"규칙:",
"- 위 사실에 없는 숫자·시설·지역 정보를 지어내지 마라.",
"- **기록 절에 있는 내용은 적극적으로 쓴다.** 그것도 출처가 확인된 사실이다 —"
" 건물의 내력·공간 구성·주변과의 관계처럼 이 업소만의 이야기가 거기 있다.",
"- false·불가·없음 값을 가능하다고 표현하지 않는다.",
"- 홍보성·평가성 표현을 쓰지 않는다.",
"- 근거 없는 FAQ는 만들지 않는다.",
"- 한국어 존댓말을 사용한다.",
])
return "\n".join(sections)