"""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