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
105 lines
5.4 KiB
Python
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
|