diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md index 0e0c235..f3f9206 100644 --- a/docs/DATA_MODEL.md +++ b/docs/DATA_MODEL.md @@ -135,8 +135,16 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 · ### `place_faqs` — FAQ -`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이 -데이터 모양으로 강제된 자리다. +출처(`generated_by`)마다 근거 요구가 다르다. + +| generated_by | 무엇 | source_fact_ids | 어디에 나가나 | +|---|---|---|---| +| `LLM`(4) | 확인된 fact 로 쓴 문장 | 근거 key 필수 — 없으면 저장하지 않는다(`copy_service`) | 화면 · JSON-LD · llms.txt | +| `OWNER`(1) | 사장님이 쓰거나 고친 문장 | 없을 수 있다 | 화면 · JSON-LD · llms.txt | +| `TEMPLATE`(5) | 20개를 채운 공통 질문 + 문의 안내 답 | 없음 | **화면만** | + +★ 예전 문서는 "비면 발행 게이트가 반려한다" 고 적었지만 그런 검사는 없었다(2026-09-14 확인). +근거 강제는 저장 시점(`copy_service`)에 있다. 채우기 규칙은 [DECISIONS 8절](DECISIONS.md). ### `place_songs` — 이 숙소의 노래 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 776ec68..32d0d31 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -282,3 +282,32 @@ LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠 손댔는지 알 수 없다. `expire_generated` 는 `generated_by` 로 가른다 — 사장님이 정정하면 `faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다). 반려(`REJECTED`)한 FAQ 는 그대로 둔다. + +--- + +## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14) + +**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건, +산하연 풀빌라 fact 4건 · FAQ 4건. + +**어떻게** +- 생성 상한 `max_faqs` 8 → 20 (`services/faq_fill.FAQ_TARGET`). +- 노출 중 FAQ 가 20개에 모자라면 업종 카탈로그(`common/faq_catalog/resources/pension.json`, 30문항)에서 + **겹치지 않는** 질문을 카탈로그 순서대로 고른다. 건너뛰는 것: + - 답할 fact 가 있는 질문 — LLM 이 fact 로 답할 자리다. 프롬프트에 그 질문들을 실어 먼저 쓰게 한다. + - 기존 FAQ(생성분·사장님 입력·정정분)와 **근거 fact key** 가 겹치거나 **질문 키워드**가 겹치는 질문. + key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다. +- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다. + 업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다 + (`frontend … canvas/variants/faq/useFaqList.ts` 주석). +- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다. +- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다. + 그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 — + 이제 그 거절은 **카탈로그가 없는 업종**(카페·음식점·호텔)에만 남는다. + +**어디에 안 나가나** — 사이트 화면에는 나간다. FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · SEO 감사 FAQ +점수에서는 뺀다. 답이 없는 문답을 구조화 데이터로 내보내면 AI 검색에 잡음이고, 모든 펜션에 같은 문구라 +고유 콘텐츠로 세면 내용 없는 사이트가 발행 게이트를 통과한다. + +**적용 범위** — 숙박 업종이면서 외부 분류(`places.external_category`)가 호텔·모텔·리조트가 아닌 곳. +분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 3e1bcf1..65a2880 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,48 @@ --- +## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) + +**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 +겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다. +``` +생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분) + → 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀 + → "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED) +``` + +**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). + +**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면 +업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 — +FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다. + +**바꾼 곳** +- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외. +- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다. +- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4). +- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT). + `generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`). + init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다. +- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다. +- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가 + (노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다). +- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만), + `run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다. +- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다. +- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시. + +**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는 +fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md). + +**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침). +실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다. +site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed. +로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0. +0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과. + +--- + ## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다 **무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index ee479da..a7e2fa5 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -229,20 +229,24 @@ CREATE TABLE IF NOT EXISTS public.place_facts ( deleted BOOLEAN NOT NULL DEFAULT FALSE ); --- ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다. +-- FAQ. 출처(generated_by)마다 근거 요구가 다르다 — LLM 생성분은 source_fact_ids 필수(없으면 저장 안 함), +-- 사장님 입력(OWNER)·20개 채우기 문의 안내(TEMPLATE)는 근거가 없다. 규칙: docs/DECISIONS.md 8절 CREATE TABLE IF NOT EXISTS public.place_faqs ( faq_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), place_id uuid NOT NULL, question VARCHAR(500) NOT NULL, answer TEXT NOT NULL, - source_fact_ids JSONB NULL, -- ★ 근거 fact_id 배열. 비면 반려 - generated_by SMALLINT NOT NULL, -- SourceType: 4=llm 1=owner + source_fact_ids JSONB NULL, -- 근거 fact key 배열(LLM 생성분만) + generated_by SMALLINT NOT NULL, -- SourceType: 1=owner 4=llm 5=template(문의 안내) status SMALLINT NOT NULL DEFAULT 1, -- FactStatus. 3·4 만 노출 sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE ); +-- migrations/0012 와 같은 문구다 — 바꾸면 둘 다 고친다. +COMMENT ON COLUMN public.place_faqs.generated_by IS 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)'; +COMMENT ON COLUMN public.place_faqs.source_fact_ids IS '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.'; -- LLM 이 만든 여행 일정. 기간당 한 행, body 에 코스 5개가 통째로 든다. -- ★ 키는 (place_id, duration) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다. diff --git a/postgres-init/migrations/0012_place_faqs_template_source.sql b/postgres-init/migrations/0012_place_faqs_template_source.sql new file mode 100644 index 0000000..1dbdc7f --- /dev/null +++ b/postgres-init/migrations/0012_place_faqs_template_source.sql @@ -0,0 +1,29 @@ +-- 0012 · place_faqs 출처 코드값 5(TEMPLATE) — FAQ 20개 채우기의 문의 안내 문항 +-- +-- ★ 컬럼·제약 변경은 없다. generated_by 는 CHECK 없는 SMALLINT 라 값 5 가 그대로 들어간다 +-- (로컬 DB 제약 조회 2026-09-14: place_faqs_pkey 하나뿐). 이 파일은 코드값의 뜻을 DB 에 남긴다 — +-- psql 로 행을 보는 사람이 5 를 모르는 값으로 읽지 않게. +-- ★ 옛 주석 "source_fact_ids 가 비면 발행 게이트가 반려한다" 는 사실이 아니었다 — 그런 검사는 없고, +-- 근거 강제는 저장 시점(copy_service)에 있다. 규칙: docs/DECISIONS.md 8절 +-- ★ 컬럼이 있을 때만 단다. 0005(표 이름 이동) 전 구조의 DB 에서 손으로 돌려도 실패하지 않게 — +-- COMMENT ON 은 대상이 없으면 에러다. 대상이 있으면 덮어쓰기라 재실행해도 안전하다. +-- init.sql 의 place_faqs 뒤에 같은 COMMENT ON 이 있다 — 문구를 바꾸면 둘 다 고친다. + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'generated_by' + ) THEN + COMMENT ON COLUMN public.place_faqs.generated_by IS + 'SourceType: 1=owner(사장님 입력·정정) 4=llm(확인된 fact 로 생성) 5=template(20개 채우기 문의 안내 — 화면만, JSON-LD·llms.txt 제외)'; + END IF; + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = 'place_faqs' AND column_name = 'source_fact_ids' + ) THEN + COMMENT ON COLUMN public.place_faqs.source_fact_ids IS + '근거 fact key 배열. llm 생성분만 채운다(없으면 저장하지 않는다). owner·template 은 NULL 일 수 있다.'; + END IF; +END $$; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 212706c..f2193ff 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -280,7 +280,12 @@ class place_facts(MainTableMixin, MAIN_BASE): class place_faqs(MainTableMixin, MAIN_BASE): - """FAQ. ★ 확보된 fact 만 근거로 쓴다 — source_fact_ids 가 비면 발행 게이트가 반려한다.""" + """FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다. + + LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다) + OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다 + TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다. + ★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다.""" __tablename__ = "place_faqs" @@ -288,8 +293,8 @@ class place_faqs(MainTableMixin, MAIN_BASE): place_id = Column(UUID(as_uuid=True), nullable=False, index=True) question = Column(String(500), nullable=False) answer = Column(Text, nullable=False) - source_fact_ids = Column(JSONB, nullable=True) # ★ 근거 fact_id 배열. 비면 반려 - generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER + source_fact_ids = Column(JSONB, nullable=True) # 근거 fact key 배열 — LLM 생성분만 채운다 + generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM | OWNER | TEMPLATE status = Column(SmallInteger, nullable=False, server_default=text("1"), default=FactStatus.UNVERIFIED.value) sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0) diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index abc4d1b..3d3a78c 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -206,13 +206,14 @@ class PlaceStatus(CodeEnum): class SourceType(CodeEnum): - """facts.source_type / media.source_type / place_aliases.source_type 공용 코드값. + """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. 값이 어디서 왔는지 — 모든 사실은 출처를 갖는다.""" - OWNER = 1 # 사장님이 직접 입력·업로드 - API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) - CRAWL = 3 # 크롤링 - LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 + OWNER = 1 # 사장님이 직접 입력·업로드 + API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) + CRAWL = 3 # 크롤링 + LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 + TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다 class FactStatus(CodeEnum): diff --git a/solution/backend/common/faq_catalog/__init__.py b/solution/backend/common/faq_catalog/__init__.py new file mode 100644 index 0000000..1b5efc6 --- /dev/null +++ b/solution/backend/common/faq_catalog/__init__.py @@ -0,0 +1,135 @@ +"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문. + +★ 왜 필요한가 + COPY 잡은 확인된 fact 로만 FAQ 를 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건, + 산하연 풀빌라 4건 — 그 근거로는 FAQ 가 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다. + +★ 공통 **답**은 주장을 하지 않는다 — "…은 숙소로 문의 부탁드립니다" 뿐이다. + 예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는 + 조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 값·가능 여부를 적으면 + 같은 사고다. 문의 안내만 쓴다. + +resources/*.json 을 최초 사용 시 로드·검증한다. fact_keys 는 업종 스키마에 있는 key 여야 한다 — +오타가 난 key 는 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다. +""" +import json +from dataclasses import dataclass +from pathlib import Path + +from common.category_schema import get_schema +from common.enums import PlaceCategory + +_RESOURCE_DIR = Path(__file__).parent / "resources" + +_catalogs: list | None = None + + +class FaqCatalogError(RuntimeError): + """카탈로그 리소스 로드/검증 실패.""" + + +@dataclass(frozen=True) +class CatalogItem: + id: str + question: str + topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부") + fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다 + keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교) + + +@dataclass(frozen=True) +class FaqCatalog: + name: str + label: str + category: PlaceCategory + exclude_external_categories: tuple[str, ...] + fallback_with_contact: tuple[str, ...] + fallback_without_contact: tuple[str, ...] + items: tuple[CatalogItem, ...] + + def applies_to(self, category: int, external_category: str | None) -> bool: + """업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다. + + ★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다. + 호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다.""" + if category != self.category.value: + return False + label = external_category or "" + return not any(word in label for word in self.exclude_external_categories) + + +def _strings(value, source: str, name: str, *, allow_empty: bool = False) -> tuple[str, ...]: + if not isinstance(value, list) or (not value and not allow_empty): + raise FaqCatalogError(f"{source}: '{name}' 은 비어 있지 않은 목록이어야 함") + if not all(isinstance(v, str) and v for v in value): + raise FaqCatalogError(f"{source}: '{name}' 에 문자열이 아닌 값 — {value}") + return tuple(value) + + +def _parse(doc: dict, source: str) -> FaqCatalog: + try: + category = PlaceCategory(doc.get("category")) + except ValueError as ex: + raise FaqCatalogError(f"{source}: PlaceCategory 에 없는 category={doc.get('category')}") from ex + schema = get_schema(category) + + answers = doc.get("fallback_answers") or {} + with_contact = _strings(answers.get("with_contact"), source, "with_contact") + without_contact = _strings(answers.get("without_contact"), source, "without_contact") + for text in with_contact + without_contact: + if "{topic}" not in text: + raise FaqCatalogError(f"{source}: 공통 답변에 {{topic}} 이 없음 — {text}") + if not all("{contact}" in text for text in with_contact): + raise FaqCatalogError(f"{source}: with_contact 답변에 {{contact}} 가 없음") + + items: list[CatalogItem] = [] + seen: set[str] = set() + for row in doc.get("items") or []: + for name in ("id", "question", "topic"): + if not isinstance(row.get(name), str) or not row[name].strip(): + raise FaqCatalogError(f"{source}: 항목 '{name}' 이 비었음 — {row}") + if row["id"] in seen: + raise FaqCatalogError(f"{source}: id 중복 — {row['id']}") + seen.add(row["id"]) + fact_keys = _strings(row.get("fact_keys"), source, f"{row['id']}.fact_keys", allow_empty=True) + unknown = [k for k in fact_keys if not schema.has(k)] + if unknown: + raise FaqCatalogError(f"{source}: {row['id']} 의 fact_keys 가 업종 스키마에 없음 — {unknown}") + keywords = _strings(row.get("keywords"), source, f"{row['id']}.keywords") + if any(" " in k for k in keywords): + raise FaqCatalogError(f"{source}: {row['id']} 의 keywords 에 공백 — 질문은 공백을 지우고 비교한다") + items.append(CatalogItem(row["id"], row["question"], row["topic"], fact_keys, + tuple(k.lower() for k in keywords))) + if not items: + raise FaqCatalogError(f"{source}: items 가 비었음") + + return FaqCatalog( + name=str(doc.get("name") or source), + label=str(doc.get("label") or ""), + category=category, + exclude_external_categories=_strings( + doc.get("exclude_external_categories") or [], source, "exclude_external_categories", allow_empty=True), + fallback_with_contact=with_contact, + fallback_without_contact=without_contact, + items=tuple(items), + ) + + +def load_catalogs() -> list[FaqCatalog]: + """리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등).""" + global _catalogs + if _catalogs is None: + loaded = [] + for path in sorted(_RESOURCE_DIR.glob("*.json")): + try: + doc = json.loads(path.read_text(encoding="utf-8")) + except Exception as ex: + raise FaqCatalogError(f"카탈로그 파일 로드 실패: {path}: {ex}") from ex + loaded.append(_parse(doc, path.name)) + _catalogs = loaded + return list(_catalogs) + + +def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None: + """이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다).""" + return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None) diff --git a/solution/backend/common/faq_catalog/resources/pension.json b/solution/backend/common/faq_catalog/resources/pension.json new file mode 100644 index 0000000..87b69ba --- /dev/null +++ b/solution/backend/common/faq_catalog/resources/pension.json @@ -0,0 +1,50 @@ +{ + "name": "pension", + "label": "펜션", + "category": 1, + "exclude_external_categories": ["호텔", "모텔", "리조트"], + "fallback_answers": { + "with_contact": [ + "{topic} {contact}로 문의해 주시면 안내해 드립니다.", + "{topic} {contact}로 연락 주시면 자세히 알려 드립니다.", + "{topic} 예약 전에 {contact}로 확인해 주세요." + ], + "without_contact": [ + "{topic} 예약 전에 숙소로 직접 문의 부탁드립니다.", + "{topic} 숙소에 문의해 주시면 안내해 드립니다.", + "{topic} 이용 전에 숙소로 확인 부탁드립니다." + ] + }, + "items": [ + {"id": "check_in", "question": "체크인은 몇 시부터인가요?", "topic": "체크인 시간", "fact_keys": ["check_in_time"], "keywords": ["체크인", "입실시간"]}, + {"id": "check_out", "question": "체크아웃은 몇 시까지인가요?", "topic": "체크아웃 시간", "fact_keys": ["check_out_time"], "keywords": ["체크아웃", "퇴실시간"]}, + {"id": "late_check_in", "question": "늦은 시간에 도착해도 입실할 수 있나요?", "topic": "늦은 시간 입실 가능 여부", "fact_keys": ["reception_hours"], "keywords": ["늦은시간", "늦게도착", "늦게입실", "늦은입실", "밤늦게"]}, + {"id": "luggage", "question": "입실 전이나 퇴실 후에 짐을 맡길 수 있나요?", "topic": "짐 보관 가능 여부", "fact_keys": [], "keywords": ["짐보관", "짐을맡", "짐맡"]}, + {"id": "checkout_cleaning", "question": "퇴실할 때 설거지나 분리수거를 해야 하나요?", "topic": "퇴실 시 정리 방법", "fact_keys": [], "keywords": ["설거지", "분리수거", "퇴실청소", "뒷정리"]}, + {"id": "cancel", "question": "예약을 취소하면 환불은 어떻게 되나요?", "topic": "취소·환불 규정", "fact_keys": ["cancel_policy"], "keywords": ["취소", "환불"]}, + {"id": "weekday_weekend_price", "question": "평일과 주말 요금이 다른가요?", "topic": "평일·주말 요금", "fact_keys": ["weekday_price", "weekend_price"], "keywords": ["평일", "주말요금"]}, + {"id": "peak_price", "question": "성수기 요금이 따로 있나요?", "topic": "성수기 요금", "fact_keys": ["peak_price"], "keywords": ["성수기"]}, + {"id": "deposit", "question": "보증금이 있나요?", "topic": "보증금 여부", "fact_keys": [], "keywords": ["보증금"]}, + {"id": "extra_costs", "question": "숙박 요금 외에 추가로 드는 비용이 있나요?", "topic": "숙박 요금 외 추가 비용", "fact_keys": ["extra_person_fee", "bbq_fee"], "keywords": ["추가비용", "추가로드는", "별도요금"]}, + {"id": "capacity", "question": "기준 인원과 최대 인원은 몇 명인가요?", "topic": "기준 인원과 최대 인원", "fact_keys": ["standard_capacity", "max_capacity"], "keywords": ["기준인원", "최대인원"]}, + {"id": "extra_person", "question": "인원을 추가하면 요금이 얼마인가요?", "topic": "인원 추가 요금", "fact_keys": ["extra_person_fee"], "keywords": ["인원추가", "추가인원", "인원을추가"]}, + {"id": "pet", "question": "반려동물과 함께 묵을 수 있나요?", "topic": "반려동물 동반 가능 여부", "fact_keys": ["pet_allowed"], "keywords": ["반려", "애견", "강아지"]}, + {"id": "baby", "question": "아기와 함께 가도 되나요? 유아용품이 있나요?", "topic": "유아 동반과 유아용품", "fact_keys": ["baby_amenities"], "keywords": ["아기", "유아"]}, + {"id": "room_types", "question": "어떤 객실(동)이 있나요?", "topic": "객실 구성", "fact_keys": ["room_type", "total_rooms", "building_scale"], "keywords": ["객실종류", "어떤객실", "객실구성"]}, + {"id": "bed", "question": "침대는 어떻게 구성되어 있나요?", "topic": "침대 구성", "fact_keys": ["bed_type"], "keywords": ["침대", "침구"]}, + {"id": "bathroom", "question": "객실에 욕실은 몇 개인가요?", "topic": "욕실 구성", "fact_keys": ["bathroom_count", "has_bathroom"], "keywords": ["욕실", "화장실"]}, + {"id": "toiletries", "question": "수건과 세면도구가 준비되어 있나요?", "topic": "수건·세면도구 제공 여부", "fact_keys": ["has_toiletries"], "keywords": ["수건", "세면도구", "어메니티"]}, + {"id": "aircon", "question": "냉난방이 되나요?", "topic": "냉난방 시설", "fact_keys": ["has_aircon"], "keywords": ["냉난방", "에어컨", "난방"]}, + {"id": "wifi", "question": "와이파이를 쓸 수 있나요?", "topic": "와이파이 이용 여부", "fact_keys": ["wifi", "has_internet"], "keywords": ["와이파이", "인터넷", "wifi"]}, + {"id": "view", "question": "객실에서 어떤 풍경이 보이나요?", "topic": "객실 전망", "fact_keys": ["view"], "keywords": ["풍경", "전망"]}, + {"id": "cooking", "question": "객실에서 요리할 수 있나요?", "topic": "객실 내 취사 가능 여부", "fact_keys": ["cooking_allowed", "has_kitchen"], "keywords": ["취사", "요리", "조리"]}, + {"id": "bbq", "question": "바비큐를 할 수 있나요?", "topic": "바비큐 이용 가능 여부", "fact_keys": ["bbq_available"], "keywords": ["바비큐", "바베큐"]}, + {"id": "bbq_fee", "question": "바비큐 요금과 제공되는 준비물은 무엇인가요?", "topic": "바비큐 요금과 준비물", "fact_keys": ["bbq_fee"], "keywords": ["바비큐요금", "바베큐요금", "바비큐비용", "숯"]}, + {"id": "breakfast", "question": "조식이 제공되나요?", "topic": "조식 제공 여부", "fact_keys": ["breakfast"], "keywords": ["조식", "아침식사"]}, + {"id": "pool", "question": "수영장이 있나요? 온수로 이용할 수 있나요?", "topic": "수영장·온수 이용 여부", "fact_keys": [], "keywords": ["수영장", "온수풀", "풀장"]}, + {"id": "smoking", "question": "흡연할 수 있는 곳이 있나요?", "topic": "흡연 가능 구역", "fact_keys": ["smoking"], "keywords": ["흡연", "담배"]}, + {"id": "parking", "question": "주차할 수 있나요? 몇 대까지 가능한가요?", "topic": "주차 가능 여부", "fact_keys": ["parking", "parking_capacity"], "keywords": ["주차"]}, + {"id": "pickup", "question": "대중교통으로 가면 픽업이 되나요?", "topic": "픽업 서비스", "fact_keys": ["pickup_service"], "keywords": ["픽업", "대중교통", "셔틀"]}, + {"id": "nearby", "question": "근처에 편의점이나 마트가 있나요?", "topic": "주변 편의시설", "fact_keys": [], "keywords": ["편의점", "마트"]} + ] +} diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 3e66d99..ed984ce 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -220,9 +220,11 @@ def fake_renderer(monkeypatch, tmp_path_factory): for unit in payload.get("units") or []: facts.extend(unit.get("facts") or []) count += sum(1 for f in facts if f.get("type") == "text" and long(f.get("value"))) + # 문의 안내(sourceType=TEMPLATE)는 모든 가게에 같은 문구라 고유 콘텐츠가 아니다. count += sum( 1 for q in payload.get("faqs") or [] - if str(q.get("question") or "").strip() and long(q.get("answer")) + if q.get("sourceType") != 5 + and str(q.get("question") or "").strip() and long(q.get("answer")) ) count += sum(1 for m in payload.get("media") or [] if long(m.get("alt"))) return count diff --git a/solution/backend/crud/faq_crud.py b/solution/backend/crud/faq_crud.py index a470211..6de4445 100644 --- a/solution/backend/crud/faq_crud.py +++ b/solution/backend/crud/faq_crud.py @@ -89,7 +89,9 @@ class FaqCRUD(IFaqCRUD): .where( place_faqs.place_id == place_id, place_faqs.deleted == False, # noqa: E712 - place_faqs.generated_by == SourceType.LLM.value, + # 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다. + # 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다. + place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)), # 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — # 반려는 판단의 기록이라 재생성이 지울 이유가 없다. place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)), diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 9421f0f..1ce28ad 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -9,11 +9,15 @@ 실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다 (근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절). - 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다. + - FAQ 가 목표 수(20)에 모자라면 업종 카탈로그에서 겹치지 않는 공통 질문을 **문의 안내** 답으로 채운다 + (services/faq_fill · common/faq_catalog). 답에 값·가능 여부를 적지 않으므로 사실을 만들지 않는다. + ★ fact 가 0건이어도(또는 API 키가 없어도) 채운다 — 그때는 LLM 을 부르지 않고 채우기만 한다. """ import uuid from common.category_schema import CategorySchemaError, get_schema from common.database.db_session_manager import DB_SESSION_MNG +from common.faq_catalog import FaqCatalog, find_catalog from common.database.model.models import place_facts, place_faqs, place_channels, places, place_units from common.enums import ( PUBLISHABLE_FACT_STATUSES, @@ -31,7 +35,7 @@ from crud.fact_crud import FactCRUD from crud.faq_crud import FaqCRUD from crud.place_crud import PlaceCRUD from router.v1.fact.protocol import Req_UpsertFact -from services import place_research +from services import faq_fill, place_research from services.external import gemini_text from services.fact_service import FactService @@ -50,9 +54,6 @@ async def run_copy(job: dict) -> dict: place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] - if not gemini_text.is_configured(): - raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") - err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, @@ -159,9 +160,33 @@ async def run_copy(job: dict) -> dict: if by_unit.get(str(u.unit_id)) ] - if not grounded and not unit_summaries: - # 근거가 없으면 아무것도 쓰지 않는다 — 유료 호출조차 하지 않는다. - return {"place_id": place_id, "intro": False, "faqs": 0, "note": "근거로 쓸 확인된 fact 가 없다"} + # FAQ 채우기에 쓸 업종 카탈로그. 없으면(카페·음식점·호텔) 채우지 않는다. + catalog = find_catalog(place.category, place.external_category) + # 사업장·객실 fact 를 가리지 않는다 — "기준 인원" 은 객실 fact 로 답한다. + known_fact_keys = {r.key for r in fact_rows if (r.value or "").strip()} + + # ── LLM 을 부르지 않는 경우 ── 근거가 없거나(환각·유료 호출 방지) 키가 없을 때. + # ★ 그래도 카탈로그가 있으면 FAQ 는 문의 안내로 20개를 채운다 — fact 가 0건이어도 20개(DECISIONS 8절). + ungrounded = not grounded and not unit_summaries + if ungrounded or not gemini_text.is_configured(): + note = "근거로 쓸 확인된 fact 가 없다" if ungrounded else "GEMINI_API_KEY 미설정" + if catalog is None: + if not ungrounded: + raise CopyAborted(f"{note} — 소개문·FAQ 를 생성할 수 없다") + return {"place_id": place_id, "intro": False, "faqs": 0, "faq_fill": 0, "note": note} + if ungrounded: + # 근거가 사라졌으니 예전 생성분도 근거가 없다 — 내리고 새로 채운다. + # 키만 없는 경우는 내리지 않는다: 멀쩡한 생성 FAQ 가 키 설정 하나 때문에 사라지면 안 된다. + await DB_SESSION_MNG.execute_lambda_claim( + place_faqs.DBType(), + lambda s: _faq_crud.expire_generated(s, pid, GTime.UTC()), + ) + filled = await _fill_faqs(pid, catalog, known_fact_keys, place.phone) + LOG.i(f"[copy] place={place_id} LLM 호출 없음({note}) · 문의 안내 채움 {filled}건") + return { + "place_id": place_id, "grounded_facts": len(grounded), "intro": False, "meta": False, + "faqs": 0, "faq_fill": filled, "rejected": [], "note": note, + } try: copy = await gemini_text.generate_copy( @@ -170,6 +195,8 @@ async def run_copy(job: dict) -> dict: grounded, unit_summaries=unit_summaries or None, records=records or None, + suggested_questions=faq_fill.suggested_questions(catalog, known_fact_keys) if catalog else None, + max_faqs=faq_fill.FAQ_TARGET, model=external_api_config.gemini_text_model, ) except gemini_text.GeminiNotConfigured as ex: @@ -182,6 +209,7 @@ async def run_copy(job: dict) -> dict: "intro": False, "meta": False, "faqs": 0, + "faq_fill": 0, # 목표 수를 채운 문의 안내 문항 수 # ★ 반려된 문장을 그대로 남긴다 — 소개문이 왜 안 나왔는지 운영자가 알아야 한다. "rejected": [list(r) for r in (copy.rejected or [])][:20], } @@ -245,6 +273,53 @@ async def run_copy(job: dict) -> dict: if run_err == ErrorType.SUCCESS: stat["faqs"] += 1 + # ── 목표 수 채우기 ── 생성분이 모자라면 카탈로그의 겹치지 않는 공통 질문을 문의 안내로 채운다. + if catalog is not None: + stat["faq_fill"] = await _fill_faqs(pid, catalog, known_fact_keys, place.phone) + LOG.i(f"[copy] place={place_id} 소개문 {'O' if stat['intro'] else 'X'} · FAQ {stat['faqs']}건 · " - f"반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)") + f"문의 안내 채움 {stat['faq_fill']}건 · 반려 {len(stat['rejected'])}건 (근거 fact {len(grounded)}개)") return stat + + +async def _fill_faqs(pid: uuid.UUID, catalog: FaqCatalog, known_fact_keys: set[str], phone: str | None) -> int: + """노출 중인 FAQ 가 목표 수에 모자란 만큼 문의 안내 문항을 넣는다. 넣은 건수를 돌려준다. + + ★ 기존 FAQ 는 **노출 중인 것 전부**로 센다 — 방금 넣은 생성분만이 아니라 재생성이 남긴 + 사장님 입력·정정분까지. 그래야 사장님이 이미 답한 주제에 문의 안내가 겹쳐 붙지 않는다. + ★ 바로 노출값(VERIFIED)으로 넣는다. 답이 주장을 하지 않아 확인할 대상이 없다 — + 대신 JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다(shared selectAnsweredFaqs).""" + l_err, rows = await DB_SESSION_MNG.execute_lambda( + place_faqs.DBType(), + DBWRType.DB_READ.value, + lambda s: _faq_crud.list_faqs(s, pid, True), + ) + if l_err != ErrorType.SUCCESS: + LOG.e_no_callstack(f"[copy] FAQ 채우기 건너뜀 — 목록 조회 실패 place={pid} {l_err.name}") + return 0 + + picks = faq_fill.pick_fill_faqs( + catalog, + [faq_fill.ExistingFaq(r.question, r.source_fact_ids) for r in rows], + known_fact_keys, + phone, + ) + next_order = max((r.sort_order for r in rows), default=-1) + 1 + added = 0 + for offset, pick in enumerate(picks): + row = place_faqs( + place_id=pid, + question=pick.question, + answer=pick.answer, + source_fact_ids=None, + generated_by=SourceType.TEMPLATE.value, + status=FactStatus.VERIFIED.value, + sort_order=next_order + offset, + ) + run_err = await DB_SESSION_MNG.execute_lambda_run( + [place_faqs.DBType()], + [lambda s, r=row: _faq_crud.add_faq(s, r)], + ) + if run_err == ErrorType.SUCCESS: + added += 1 + return added diff --git a/solution/backend/services/external/gemini_text.py b/solution/backend/services/external/gemini_text.py index 05419a2..f2c74b4 100644 --- a/solution/backend/services/external/gemini_text.py +++ b/solution/backend/services/external/gemini_text.py @@ -97,6 +97,7 @@ async def generate_copy( *, unit_summaries: Optional[list[dict]] = None, records: Optional[list[str]] = None, + suggested_questions: Optional[list[str]] = None, max_faqs: int = 8, model: str = DEFAULT_TEXT_MODEL, max_retries: int = 2, @@ -125,7 +126,7 @@ async def generate_copy( body = { "contents": [{"role": "user", "parts": [{ - "text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records) + "text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions) }]}], "generationConfig": { "responseMimeType": "application/json", diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index c18e6ed..0742059 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -266,6 +266,12 @@ class FactService: res.result.SetResult(ErrorType.FACT_INVALID_KEY) return res + # 규칙 4 — TEMPLATE 은 FAQ 문의 안내 전용 출처다(services/faq_fill). fact 에는 쓸 수 없다. + # OWNER 도 자동 수집도 아니라서, 막지 않으면 아래 분기에서 사람 입력처럼 바로 노출값이 된다. + if req.source_type == SourceType.TEMPLATE: + res.result.SetResult(ErrorType.INVALID_REQUEST_DATA) + return res + pid = uuid.UUID(place_id) pub_err, published = await DB_SESSION_MNG.execute_lambda( place_facts.DBType(), diff --git a/solution/backend/services/faq_fill.py b/solution/backend/services/faq_fill.py new file mode 100644 index 0000000..b6ca2ad --- /dev/null +++ b/solution/backend/services/faq_fill.py @@ -0,0 +1,98 @@ +"""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)] diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 34f26d7..d5e3acf 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -904,22 +904,21 @@ class PlaceService: async def start_copy(self, user_info: UserInfo, place_id: str, req: Req_StartCopy) -> Res_StartCopy: """소개문·FAQ 생성을 큐에 넣는다. - ★ 근거로 쓸 확인된 fact 가 없으면 잡을 만들지 않는다 — - 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다.""" + ★ 근거로 쓸 확인된 fact 가 없으면 LLM 은 부르지 않는다 — + 근거 없이 문장을 쓰면 그게 환각이고, 유료 호출만 낭비된다. + ★ 그래도 FAQ 카탈로그가 있는 업종(펜션)이면 잡을 만든다 — fact 가 0건이어도 FAQ 는 + 문의 안내로 20개를 채운다(DECISIONS.md 8절). 그 경로는 LLM 을 안 쓰므로 API 키도 필요 없다.""" from common.database.model.models import place_facts as facts_model + from common.faq_catalog import find_catalog from crud.fact_crud import FactCRUD from services.external import gemini_text res = Res_StartCopy() - err_type, _place = await self._load(user_info, place_id) + err_type, place = await self._load(user_info, place_id) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - if not gemini_text.is_configured(): - res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED) - return res - crud = FactCRUD() f_err, rows = await DB_SESSION_MNG.execute_lambda( facts_model.DBType(), @@ -930,9 +929,15 @@ class PlaceService: res.result.SetResult(f_err) return res res.grounded_facts = sum(1 for r in rows if r.unit_id is None and (r.value or "").strip()) - if res.grounded_facts == 0: + # 객실 fact 만 있어도 LLM 이 쓴다(copy_service 의 unit_summaries) — 키 검사는 그 기준으로 한다. + needs_llm = any((r.value or "").strip() for r in rows) + if not needs_llm and find_catalog(place.category, place.external_category) is None: + # 쓸 근거도, 채울 공통 질문도 없다(카페·음식점·호텔). res.result.SetResult(ErrorType.FAQ_UNGROUNDED) return res + if needs_llm and not gemini_text.is_configured(): + res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED) + return res job_id, created = await enqueue_job( self.queue, JobType.COPY, diff --git a/solution/backend/services/prompts/copy.py b/solution/backend/services/prompts/copy.py index 6a5a00d..b4d127f 100644 --- a/solution/backend/services/prompts/copy.py +++ b/solution/backend/services/prompts/copy.py @@ -56,6 +56,7 @@ def build_prompt( max_faqs: int, unit_facts: Optional[Sequence[FactLike]] = None, records: Optional[Sequence[str]] = None, + suggested_questions: Optional[Sequence[str]] = None, ) -> str: """소개문·메타·FAQ 생성 프롬프트. @@ -79,6 +80,14 @@ def build_prompt( "업소에 대해 확인된 기록(출처가 있는 글):", *(f"- {line}" for line in records), ]) + if suggested_questions: + # 업종 카탈로그 중 위 사실로 답할 수 있는 질문들(services/faq_fill.suggested_questions). + # ★ 채우기는 fact 가 있는 질문을 건너뛴다 — 여기서 모델이 안 쓰면 그 주제는 비어 버린다. + sections.extend([ + "", + "FAQ 로 먼저 쓸 질문(위 사실로 답할 수 있는 것):", + *(f"- {q}" for q in suggested_questions), + ]) sections.extend([ "", "출력:", @@ -96,6 +105,9 @@ def build_prompt( "- false·불가·없음 값을 가능하다고 표현하지 않는다.", "- 홍보성·평가성 표현을 쓰지 않는다.", "- 근거 없는 FAQ는 만들지 않는다.", + # ★ 실측(2026-09-14, 로컬): 노출 중인 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 처럼 두 주제를 묶었다. + # 묶으면 문항 수는 그대로인데 다룬 주제가 줄고, 채우기의 겹침 판정도 두 주제를 함께 지운다. + "- FAQ 한 문항에는 주제 하나만 묻는다(예: 체크인과 체크아웃을 한 문항에 묶지 않는다).", "- 한국어 존댓말을 사용한다.", ]) return "\n".join(sections) diff --git a/solution/backend/services/seo_audit.py b/solution/backend/services/seo_audit.py index 9b4604d..fe947c6 100644 --- a/solution/backend/services/seo_audit.py +++ b/solution/backend/services/seo_audit.py @@ -6,6 +6,8 @@ """ from dataclasses import dataclass +from common.enums import SourceType + @dataclass(frozen=True) class AuditCheck: @@ -31,7 +33,8 @@ def _check(id, group, label, points, passed, detail, recommendation=None, partia def evaluate(snapshot: dict, *, verified: bool, site=None, version=None, ai_checks: list | None = None) -> dict: place = snapshot.get("place") or {} facts = snapshot.get("facts") or [] - faqs = snapshot.get("faqs") or [] + # 문의 안내(TEMPLATE)는 답이 아니다 — "질문형 FAQ" 에 세면 채우기만으로 만점이 난다. + faqs = [q for q in (snapshot.get("faqs") or []) if q.get("generated_by") != SourceType.TEMPLATE.value] media = snapshot.get("media") or [] built = bool(version and getattr(version, "built_at", None)) unique_count = int(getattr(version, "unique_content_count", 0) or 0) if version else 0 diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index 74f8af1..78d0a3d 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -52,7 +52,8 @@ async def _place_with_facts(client, h, n_verified=5): async def _faq_rows(db_engine, pid): async with db_engine.begin() as c: return (await c.execute( - text("SELECT question, answer, source_fact_ids, status FROM place_faqs WHERE place_id = :p ORDER BY sort_order"), + text("SELECT question, answer, source_fact_ids, status, generated_by FROM place_faqs " + "WHERE place_id = :p ORDER BY sort_order"), {"p": uuid.UUID(pid)}, )).all() @@ -86,22 +87,29 @@ async def test_copy_generates_intro_and_faq_as_published(auth_headers, client, d assert intro[0]["source_type"] == SourceType.LLM.value -async def test_copy_refuses_without_verified_facts(auth_headers, client, monkeypatch): - """검증: 확인된 fact 가 하나도 없는 사업장에서 생성을 시도한다. - 기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 근거 없이 쓰면 환각이고 유료 호출만 낭비다.""" +async def _must_not_call_llm(*args, **kwargs): + raise AssertionError("★ 근거 없이 LLM 을 불렀다") + + +async def test_copy_refuses_without_facts_when_no_catalog(auth_headers, client, monkeypatch): + """검증: 확인된 fact 가 하나도 없는 **호텔**(펜션 카탈로그 제외 대상)에서 생성을 시도한다. + 기대결과: FAQ_UNGROUNDED — ★ 잡을 만들지 않는다. 쓸 근거도, 채울 공통 질문도 없다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) 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": "빈주소"}) + 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": "빈호텔주소", "category_name": "호텔"}) r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value -async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, monkeypatch): - """검증: 크롤링으로 들어온 미검증 fact 만 있는 사업장. - 기대결과: 근거로 안 쳐서 FAQ_UNGROUNDED — 미검증 값으로 쓴 문장도 미검증이다.""" +async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, db_engine, monkeypatch): + """검증: 크롤링으로 들어온 미검증 fact 만 있는 펜션. + 기대결과: ★ 근거 0건으로 친다 — LLM 을 부르지 않는다(미검증 값으로 쓴 문장도 미검증이다). + FAQ 는 문의 안내로 20개를 채우고, 미검증인 체크인도 '답이 있는 주제' 로 치지 않는다.""" monkeypatch.setattr(gemini_text, "is_configured", lambda: True) + monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) 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": "미검증주소"}) @@ -109,8 +117,36 @@ async def test_unverified_facts_are_not_used_as_grounding(auth_headers, client, "key": "check_in_time", "value": "15:00", "source_type": SourceType.CRAWL.value, "source_url": "https://ota.test/1"}) - r = await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) - assert r.json()["result"]["code"] == ErrorType.FAQ_UNGROUNDED.value + body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json() + assert body["result"]["success"] is True and body["grounded_facts"] == 0 + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faqs"] == 0 and job["result"]["faq_fill"] == 20 + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 and all(r[4] == SourceType.TEMPLATE.value for r in live) + assert any("체크인" in r[0] for r in live) + + +async def test_zero_facts_still_fill_twenty_without_api_key(auth_headers, client, db_engine, monkeypatch): + """검증: fact 0건 · GEMINI 키 없음인 펜션(분류 없음 — 스테이머뭄처럼). + 기대결과: ★ 잡이 만들어지고 FAQ 가 정확히 20건 — 채우기는 LLM 을 안 쓰므로 키가 필요 없다.""" + monkeypatch.setattr(gemini_text, "is_configured", lambda: False) + monkeypatch.setattr(gemini_text, "generate_copy", _must_not_call_llm) + 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": "무근거주소"}) + + body = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json() + assert body["result"]["success"] is True, body["result"] + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{body['job_id']}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faq_fill"] == 20 + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 and len({r[0] for r in live}) == 20 async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, monkeypatch): @@ -128,7 +164,10 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] assert job["result"]["faqs"] == 1 assert any("수영장" in str(r) for r in job["result"]["rejected"]) - assert len(await _faq_rows(db_engine, pid)) == 1 + rows = await _faq_rows(db_engine, pid) + assert len([r for r in rows if r[4] == SourceType.LLM.value]) == 1 + # 채우기가 넣은 문의 안내에도 '수영장 있나요?' 의 거짓 답("네 있습니다")은 없다. + assert not any("있습니다" in r[1] for r in rows if r[4] == SourceType.TEMPLATE.value) async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch): @@ -161,9 +200,10 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, # 사장님이 문구를 고쳐 승인 — 잠긴 상태(CORRECTED)가 되고 책임 주체가 사람으로 넘어온다. async with db_engine.begin() as c: await c.execute( - text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q WHERE place_id = :p"), + text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q " + "WHERE place_id = :p AND generated_by = :llm"), {"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value, - "q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid)}, + "q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid), "llm": SourceType.LLM.value}, ) _patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])])) @@ -177,6 +217,55 @@ async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, assert any(r[0] == "새 질문?" and r[3] == FactStatus.VERIFIED.value for r in rows) +_PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) + + +async def test_copy_fills_faqs_to_target_without_duplicates(auth_headers, client, db_engine, monkeypatch): + """검증: 생성된 FAQ 가 1건뿐이고, 사장님이 직접 쓴 FAQ 가 1건 있다. + 기대결과: 노출 중 FAQ 가 정확히 20건 — + · 모자란 만큼은 문의 안내(TEMPLATE)로 채운다 + · fact 로 답할 수 있는 주제(체크인·체크아웃·취소·취사·반려동물)에는 문의 안내를 붙이지 않는다 + · 사장님이 이미 쓴 주제(보증금)도 다시 넣지 않는다""" + _patch(monkeypatch, _copy()) + h = await auth_headers("u1") + pid = await _place_with_facts(client, h) + owner = await client.post(f"/v1/place/{pid}/faq", headers=h, json={"question": "보증금이 있나요?", "answer": "없습니다."}) + assert owner.json()["result"]["success"] is True + + job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"] + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] + assert job["status"] == JobStatus.DONE.value, job.get("last_error") + assert job["result"]["faqs"] == 1 + assert job["result"]["faq_fill"] == 18 + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 + assert len({r[0] for r in live}) == 20, "같은 질문이 두 번 들어갔다" + + fills = [r for r in live if r[4] == SourceType.TEMPLATE.value] + assert len(fills) == 18 + fill_text = " ".join(r[0] for r in fills) + for topic in ("체크인", "체크아웃", "취소", "요리", "반려동물", "보증금"): + assert topic not in fill_text, f"★ 이미 답이 있는 주제 '{topic}' 에 문의 안내가 붙었다" + assert all("문의" in r[1] or "확인" in r[1] for r in fills), "문의 안내가 아닌 답이 섞였다" + + +async def test_regeneration_replaces_fill_faqs(auth_headers, client, db_engine, monkeypatch): + """검증: 생성을 두 번 돌린다. + 기대결과: 노출 중 FAQ 는 여전히 20건 — 옛 문의 안내는 내려가고 다시 고른다(쌓이지 않는다).""" + _patch(monkeypatch, _copy()) + h = await auth_headers("u1") + pid = await _place_with_facts(client, h) + for _ in range(2): + await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) + await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain() + + live = [r for r in await _faq_rows(db_engine, pid) if r[3] in _PUBLISHABLE] + assert len(live) == 20 + assert len({r[0] for r in live}) == 20 + + async def test_copy_requires_api_key(auth_headers, client, monkeypatch): """검증: GEMINI_API_KEY 없이 생성을 시도한다. 기대결과: GENERATOR_NOT_CONFIGURED — 잡을 만들지 않는다.""" diff --git a/solution/backend/tests/test_faq_fill.py b/solution/backend/tests/test_faq_fill.py new file mode 100644 index 0000000..db0a464 --- /dev/null +++ b/solution/backend/tests/test_faq_fill.py @@ -0,0 +1,104 @@ +"""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 diff --git a/solution/frontend/src/api/generated/model/sourceType.ts b/solution/frontend/src/api/generated/model/sourceType.ts index a2ad4d3..3098e1d 100644 --- a/solution/frontend/src/api/generated/model/sourceType.ts +++ b/solution/frontend/src/api/generated/model/sourceType.ts @@ -6,7 +6,7 @@ */ /** - * facts.source_type / media.source_type / place_aliases.source_type 공용 코드값. + * facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. 값이 어디서 왔는지 — 모든 사실은 출처를 갖는다. */ export type SourceType = typeof SourceType[keyof typeof SourceType]; @@ -18,4 +18,5 @@ export const SourceType = { API: 2, CRAWL: 3, LLM: 4, + TEMPLATE: 5, } as const; diff --git a/solution/frontend/src/features/builder/FaqPanel.tsx b/solution/frontend/src/features/builder/FaqPanel.tsx index e9826e8..7d0b41d 100644 --- a/solution/frontend/src/features/builder/FaqPanel.tsx +++ b/solution/frontend/src/features/builder/FaqPanel.tsx @@ -1,7 +1,7 @@ import {useState} from 'react'; import {useQueryClient} from '@tanstack/react-query'; import {Check, Loader2, MessageCircleQuestion, Plus, Sparkles, X} from 'lucide-react'; -import {FactStatus, PUBLISHABLE_FACT_STATUSES} from '@o2o/shared'; +import {FactStatus, PUBLISHABLE_FACT_STATUSES, SourceType} from '@o2o/shared'; import {getListFaqsQueryKey, useCreateFaq, useListFaqs, useTransitionFaq} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; @@ -59,6 +59,8 @@ function FaqEditor({placeId}: {placeId: string}) { const faqs = listQuery.data?.faqs ?? []; const publishable = listQuery.data?.publishable ?? 0; const pending = listQuery.data?.pending_review ?? 0; + // 20개를 채운 공통 질문. 답이 "숙소로 문의" 뿐이라 따로 센다 — 실제 답이 몇 개인지 보여야 채울 마음이 든다. + const inquiry = faqs.filter((faq) => faq.generated_by === SourceType.TEMPLATE).length; const submit = async () => { const q = question.trim(); @@ -95,7 +97,7 @@ function FaqEditor({placeId}: {placeId: string}) {
{faq.question}
{isLive ? ( - 노출 중 + + {faq.generated_by === SourceType.TEMPLATE ? '문의 안내' : '노출 중'} + ) : (+ FAQ 가 20개에 모자라 손님이 자주 묻는 질문을 문의 안내로 + 채웠습니다. 사이트에는 보이지만 AI 검색용 데이터에는 나가지 않습니다. 같은 질문에 직접 답을 추가하면 + 다음 생성 때 문의 안내 항목은 빠집니다. +
+ )}