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
99 lines
4.2 KiB
Python
99 lines
4.2 KiB
Python
"""FAQ 목표 수 채우기 — 생성된 FAQ 가 모자라면 카탈로그에서 겹치지 않는 공통 질문을 고른다.
|
|
|
|
순수 함수만 둔다(DB·네트워크 없음). 무엇이 왜 골렸는지를 이 파일만 읽고 답할 수 있어야 한다.
|
|
|
|
고르는 규칙 (카탈로그 순서 = 우선순위)
|
|
1. 답할 fact 가 있는 질문은 고르지 않는다 — 그건 LLM 이 fact 로 답할 자리다.
|
|
"주차할 수 있나요?" 에 parking=true 가 있는데 "문의 부탁드립니다" 가 붙으면 아는 것을 숨긴 셈이다.
|
|
2. 기존 FAQ 가 이미 다룬 주제는 고르지 않는다. 기존 FAQ 에는 LLM 생성분 · 사장님 입력 · 정정분이 모두 든다.
|
|
- 근거 fact key 가 겹치면 같은 주제다. LLM 은 "주차 및 와이파이" 처럼 두 주제를 한 문항에 묶고
|
|
근거에 [parking, wifi] 를 적는다 — 낱말 대조만으로는 둘 중 하나를 놓친다.
|
|
- 질문에 카탈로그 키워드가 들어 있으면 같은 주제다(사장님 입력은 근거 key 가 없다).
|
|
3. 공통 답은 문의 안내뿐이다. 값·가능 여부를 적지 않는다(common/faq_catalog 머리주석).
|
|
"""
|
|
import re
|
|
from dataclasses import dataclass
|
|
from typing import Iterable, Optional
|
|
|
|
from common.faq_catalog import FaqCatalog
|
|
|
|
# ★ 사이트에 싣는 FAQ 목표 수. 생성 상한(max_faqs)도 이 값을 쓴다.
|
|
FAQ_TARGET = 20
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ExistingFaq:
|
|
question: str
|
|
source_fact_ids: Optional[list] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FillFaq:
|
|
catalog_id: str
|
|
question: str
|
|
answer: str
|
|
|
|
|
|
def _compact(text: str) -> str:
|
|
return re.sub(r"\s+", "", text or "").lower()
|
|
|
|
|
|
def _base_key(key: str) -> str:
|
|
"""객실 근거는 "A동:max_capacity" 로 적힌다 — 주제 비교에는 key 만 쓴다."""
|
|
return str(key).rsplit(":", 1)[-1]
|
|
|
|
|
|
def _with_topic_particle(topic: str) -> str:
|
|
"""주제 뒤에 은/는 을 붙인다. 받침이 있으면 '은'."""
|
|
last = topic.strip()[-1:]
|
|
if "가" <= last <= "힣":
|
|
return topic + ("은" if (ord(last) - ord("가")) % 28 else "는")
|
|
return topic + "은(는)"
|
|
|
|
|
|
def fallback_answer(catalog: FaqCatalog, index: int, topic: str, phone: Optional[str]) -> str:
|
|
"""문의 안내 문구. 문구를 번갈아 써서 스무 줄이 전부 같은 문장이 되지 않게 한다."""
|
|
phone = (phone or "").strip()
|
|
templates = catalog.fallback_with_contact if phone else catalog.fallback_without_contact
|
|
template = templates[index % len(templates)]
|
|
return template.format(topic=_with_topic_particle(topic), contact=f"전화({phone})")
|
|
|
|
|
|
def pick_fill_faqs(
|
|
catalog: FaqCatalog,
|
|
existing: Iterable[ExistingFaq],
|
|
fact_keys: Iterable[str],
|
|
phone: Optional[str] = None,
|
|
target: int = FAQ_TARGET,
|
|
) -> list[FillFaq]:
|
|
"""목표 수까지 모자란 만큼 카탈로그 질문을 고른다. 모자라지 않으면 빈 목록."""
|
|
existing = list(existing)
|
|
need = target - len(existing)
|
|
if need <= 0:
|
|
return []
|
|
|
|
asked = [_compact(faq.question) for faq in existing]
|
|
covered_keys = {_base_key(k) for faq in existing for k in (faq.source_fact_ids or [])}
|
|
known_keys = {_base_key(k) for k in fact_keys}
|
|
|
|
picked: list[FillFaq] = []
|
|
for item in catalog.items:
|
|
if len(picked) >= need:
|
|
break
|
|
if known_keys.intersection(item.fact_keys):
|
|
continue # 규칙 1
|
|
if covered_keys.intersection(item.fact_keys):
|
|
continue # 규칙 2 — 근거 key
|
|
if any(keyword in question for keyword in item.keywords for question in asked):
|
|
continue # 규칙 2 — 질문 낱말
|
|
answer = fallback_answer(catalog, len(picked), item.topic, phone)
|
|
picked.append(FillFaq(item.id, item.question, answer))
|
|
return picked
|
|
|
|
|
|
def suggested_questions(catalog: FaqCatalog, fact_keys: Iterable[str]) -> list[str]:
|
|
"""fact 로 답할 수 있는 카탈로그 질문 — 프롬프트에 실어 LLM 이 이 질문들부터 쓰게 한다.
|
|
채우기(규칙 1)가 이 질문들을 건너뛰므로, LLM 이 안 쓰면 그 주제는 비게 된다."""
|
|
known = {_base_key(k) for k in fact_keys}
|
|
return [item.question for item in catalog.items if known.intersection(item.fact_keys)]
|