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