COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다. 생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다. 공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 — 답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다. - common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외 - services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가 겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다) - copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만 - place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만) - SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다 - faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게 - prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다) - shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다 - site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다 - frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다 - postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON, 0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다 - docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md 백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다 site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과 로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0 0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
114 lines
4.8 KiB
Python
114 lines
4.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,
|
|
suggested_questions: 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),
|
|
])
|
|
if suggested_questions:
|
|
# 업종 카탈로그 중 위 사실로 답할 수 있는 질문들(services/faq_fill.suggested_questions).
|
|
# ★ 채우기는 fact 가 있는 질문을 건너뛴다 — 여기서 모델이 안 쓰면 그 주제는 비어 버린다.
|
|
sections.extend([
|
|
"",
|
|
"FAQ 로 먼저 쓸 질문(위 사실로 답할 수 있는 것):",
|
|
*(f"- {q}" for q in suggested_questions),
|
|
])
|
|
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는 만들지 않는다.",
|
|
# ★ 실측(2026-09-14, 로컬): 노출 중인 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 처럼 두 주제를 묶었다.
|
|
# 묶으면 문항 수는 그대로인데 다룬 주제가 줄고, 채우기의 겹침 판정도 두 주제를 함께 지운다.
|
|
"- FAQ 한 문항에는 주제 하나만 묻는다(예: 체크인과 체크아웃을 한 문항에 묶지 않는다).",
|
|
"- 한국어 존댓말을 사용한다.",
|
|
])
|
|
return "\n".join(sections)
|