o2o-site-AEO/solution/backend/tests/test_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

105 lines
5.4 KiB
Python

"""FAQ 목표 수 채우기 — ★ 공통 답은 문의 안내뿐이고, 이미 다룬 주제는 다시 넣지 않는다.
이 기능이 절대 하면 안 되는 것:
- 공통 답에 값·가능 여부를 적는 것 (가게마다 다르다 — 틀리면 예약 클레임)
- fact 로 답할 수 있는 질문에 "문의 부탁드립니다" 를 붙이는 것 (아는 것을 숨긴다)
- 기존 FAQ 와 같은 주제를 다시 넣는 것
"""
import re
from common.enums import ErrorType, PlaceCategory
from common.faq_catalog import find_catalog
from services import faq_fill
from services.faq_fill import ExistingFaq
def _pension():
catalog = find_catalog(PlaceCategory.LODGING.value, "펜션")
assert catalog is not None
return catalog
def _ids(picks):
return {p.catalog_id for p in picks}
def test_pension_catalog_has_thirty_questions():
"""검증: 카탈로그 로드. fact_keys 가 업종 스키마에 없으면 로더가 예외를 던진다."""
catalog = _pension()
assert len(catalog.items) == 30
assert len({item.id for item in catalog.items}) == 30
def test_catalog_scope():
"""검증: 호텔·카페에는 펜션 질문을 붙이지 않는다. 분류가 비어 있는 숙박업(스테이머뭄)에는 붙인다."""
assert find_catalog(PlaceCategory.LODGING.value, "호텔") is None
assert find_catalog(PlaceCategory.CAFE.value, None) is None
assert find_catalog(PlaceCategory.LODGING.value, None) is not None
def test_fills_only_the_shortfall():
"""검증: 기존 FAQ 5건(카탈로그와 무관한 질문). 기대결과: 15건만 고른다. 20건이면 0건."""
catalog = _pension()
picks = faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"기타 질문 {i}") for i in range(5)], set())
assert len(picks) == 15 and len(_ids(picks)) == 15
assert faq_fill.pick_fill_faqs(catalog, [ExistingFaq(f"질문 {i}") for i in range(20)], set()) == []
def test_skips_topics_answerable_by_facts():
"""검증: 체크인·반려동물 fact 가 있다(객실 fact 는 'A동:max_capacity' 로 적힌다).
기대결과: 그 질문들은 문의 안내로 채우지 않는다 — LLM 이 fact 로 답할 자리다."""
picks = faq_fill.pick_fill_faqs(_pension(), [], {"check_in_time", "pet_allowed", "A동:max_capacity"}, target=30)
assert not _ids(picks) & {"check_in", "pet", "capacity"}
def test_skips_topics_already_asked_by_keyword():
"""검증: 사장님이 근거 key 없이 쓴 FAQ 가 있다. 기대결과: 질문 낱말로 같은 주제를 알아본다."""
existing = [ExistingFaq("반려견 데려가도 되나요?"), ExistingFaq("주차 되나요?")]
picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30)
assert not _ids(picks) & {"pet", "parking"}
def test_skips_topics_covered_by_source_fact_keys():
"""검증: LLM FAQ 의 질문에는 키워드가 없지만 근거 key 가 [wifi, A동:max_capacity] 다.
기대결과: 와이파이·인원 질문을 넣지 않는다 — LLM 은 두 주제를 한 문항에 묶기도 한다."""
existing = [ExistingFaq("편의 안내가 궁금해요", ["wifi", "A동:max_capacity"])]
picks = faq_fill.pick_fill_faqs(_pension(), existing, set(), target=30)
assert not _ids(picks) & {"wifi", "capacity"}
def test_answers_are_inquiry_only():
"""검증: 공통 답 문구.
기대결과: ★ 연락처가 없으면 숫자가 하나도 없다(가격·시각을 지어내지 않는다). 연락처가 있으면 그 번호만 들어간다."""
catalog = _pension()
for pick in faq_fill.pick_fill_faqs(catalog, [], set(), target=30):
assert not re.search(r"\d", pick.answer), pick.answer
assert "숙소" in pick.answer
for pick in faq_fill.pick_fill_faqs(catalog, [], set(), phone="010-1234-5678", target=30):
assert "전화(010-1234-5678)로" in pick.answer
assert not re.search(r"\d", pick.answer.replace("010-1234-5678", "")), pick.answer
def test_answer_particle_and_rotation():
"""검증: 주제 뒤 조사(은/는)와 문구 돌려쓰기."""
catalog = _pension()
assert faq_fill.fallback_answer(catalog, 0, "보증금 여부", None).startswith("보증금 여부는 ")
assert faq_fill.fallback_answer(catalog, 0, "주변 편의시설", None).startswith("주변 편의시설은 ")
assert len({faq_fill.fallback_answer(catalog, i, "주제", None) for i in range(3)}) == 3
def test_suggested_questions_are_the_answerable_ones():
"""검증: 프롬프트에 실을 질문 = fact 로 답할 수 있는 카탈로그 질문."""
questions = faq_fill.suggested_questions(_pension(), {"parking", "check_out_time"})
assert questions == ["체크아웃은 몇 시까지인가요?", "주차할 수 있나요? 몇 대까지 가능한가요?"]
async def test_template_source_cannot_write_fact(auth_headers, client):
"""검증: fact 를 TEMPLATE 출처로 쓴다.
기대결과: INVALID_REQUEST_DATA — 막지 않으면 사람 입력처럼 바로 노출값이 된다(fact_service 규칙 4)."""
h = await auth_headers("u1")
pid = (await client.post("/v1/place", headers=h, json={"name": "틀펜션", "category": 1})).json()["place"]["place_id"]
await client.post(f"/v1/place/{pid}/verify", headers=h, json={"source": 2, "road_address": "틀주소"})
r = await client.post(f"/v1/place/{pid}/fact", headers=h, json={
"key": "check_in_time", "value": "15:00", "source_type": 5, "source_url": "template:test"})
assert r.json()["result"]["code"] == ErrorType.INVALID_REQUEST_DATA.value