o2o-site-AEO/solution/backend/services/faq_fill.py
민헌 8a09af6599 [feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내
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
2026-09-14 17:05:43 +09:00

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)]