Merge branch 'feature/faq' — FAQ 20개 채우기(펜션 공통 질문 + 문의 안내)

This commit is contained in:
민헌 2026-09-14 17:05:56 +09:00
commit 33b7b94300
29 changed files with 804 additions and 62 deletions

View File

@ -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` — 이 숙소의 노래

View File

@ -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`)가 호텔·모텔·리조트가 아닌 곳.
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.

View File

@ -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)에 이 가게 프로필을 보내 검색 키워드를

View File

@ -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) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.

View File

@ -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 $$;

View File

@ -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)

View File

@ -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):

View File

@ -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)

View File

@ -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": ["편의점", "마트"]}
]
}

View File

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

View File

@ -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)),

View File

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

View File

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

View File

@ -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(),

View File

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

View File

@ -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,

View File

@ -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)

View File

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

View File

@ -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 — 잡을 만들지 않는다."""

View File

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

View File

@ -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;

View File

@ -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}) {
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-xs font-bold">자주 묻는 질문</span>
<span className="text-[10px] text-muted-foreground">
노출 {publishable}건{pending > 0 && ` · 대기 ${pending}건`}
노출 {publishable}건{inquiry > 0 && ` (문의 안내 ${inquiry})`}{pending > 0 && ` · 대기 ${pending}건`}
</span>
</div>
@ -160,7 +162,9 @@ function FaqEditor({placeId}: {placeId: string}) {
<div className="flex items-start justify-between gap-2">
<p className="flex-1 text-xs font-semibold break-words">{faq.question}</p>
{isLive ? (
<span className="shrink-0 text-[10px] text-muted-foreground">노출 중</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{faq.generated_by === SourceType.TEMPLATE ? '문의 안내' : '노출 중'}
</span>
) : (
<Badge variant="warning" className="shrink-0 text-[10px]">
승인 대기
@ -209,6 +213,13 @@ function FaqEditor({placeId}: {placeId: string}) {
직접 쓴 질문·답변은 <strong className="text-foreground">사장님이 확정한 값</strong>으로 바로 노출되고,
AI 가 만들어 둔 항목은 승인해야 사이트와 AI 검색용 데이터에 나갑니다.
</p>
{inquiry > 0 && (
<p className="text-[11px] leading-relaxed text-muted-foreground">
FAQ 가 20개에 모자라 손님이 자주 묻는 질문을 <strong className="text-foreground">문의 안내</strong>로
채웠습니다. 사이트에는 보이지만 AI 검색용 데이터에는 나가지 않습니다. 같은 질문에 직접 답을 추가하면
다음 생성 때 문의 안내 항목은 빠집니다.
</p>
)}
</div>
);
}

View File

@ -55,19 +55,27 @@ export function notifyVision(job: JobData) {
);
}
/** 재생성(소개문·FAQ) 결과 알림. 생성물도 미검증으로 들어가 사람이 승인해야 나간다. */
/**
* 재생성(소개문·FAQ) 결과 알림.
*
* ★ `faq_fill` 을 같이 본다. fact 가 0건이면 AI 가 쓴 FAQ(`faqs`)는 0 이지만 문의 안내로 20개가 채워진다 —
* `faqs` 만 보면 "새로 만든 문장이 없습니다" 가 떠서, FAQ 탭에 20개가 있는데 실패로 읽힌다.
*/
export function notifyCopy(job: JobData) {
const faqCount = readNumber(job.result, 'faqs');
const fillCount = readNumber(job.result, 'faq_fill');
const hasIntro = (job.result as {intro?: unknown} | undefined)?.intro === true;
if (!hasIntro && faqCount === 0) {
if (!hasIntro && faqCount === 0 && fillCount === 0) {
notify.warn(
'새로 만든 문장이 없습니다.',
readNote(job) ?? '근거로 쓸 확인된 정보가 부족합니다. 정보를 먼저 확인해 주세요.',
);
return;
}
const parts = [`소개문 ${hasIntro ? '1건' : '없음'}`, `FAQ ${faqCount}건`];
if (fillCount > 0) parts.push(`문의 안내 ${fillCount}건`);
notify.success(
'소개문을 다시 만들었습니다',
`소개문 ${hasIntro ? '1건' : '없음'} · FAQ ${faqCount}건 — 확인하신 뒤 사이트에 나갑니다.`,
'소개문·FAQ 를 다시 만들었습니다',
`${parts.join(' · ')} — FAQ 탭에서 확인할 수 있습니다.`,
);
}

View File

@ -1,5 +1,6 @@
import {
isPublishableFact,
SourceType,
type FactEntry,
type FaqEntry,
type SitePayload,
@ -83,13 +84,24 @@ export function missingRequiredFacts(facts: FactEntry[]): FactEntry[] {
);
}
/** 노출 가능한 FAQ 만. FAQPage JSON-LD 의 입력이기도 하다. */
/** 노출 가능한 FAQ 만 — 화면에 그리는 목록. 문의 안내(TEMPLATE)도 들어간다. */
export function selectPublishableFaqs(faqs: FaqEntry[]): FaqEntry[] {
return faqs
.filter((f) => isPublishableFact(f.status) && f.question && f.answer)
.sort((a, b) => a.sortOrder - b.sortOrder);
}
/**
* 실제로 답하는 FAQ 만 — FAQPage JSON-LD · llms.txt 의 입력.
*
* ★ 문의 안내(TEMPLATE)를 뺀다. FAQ 를 20개로 채우려고 붙인 공통 질문이라 답이
* "숙소로 문의 부탁드립니다" 뿐이다. 구조화 데이터로 내보내면 AI 검색이 인용할 답이 없는
* 문항이 섞이고, 같은 문구가 모든 펜션 사이트에 반복된다.
*/
export function selectAnsweredFaqs(faqs: FaqEntry[]): FaqEntry[] {
return selectPublishableFaqs(faqs).filter((f) => f.sourceType !== SourceType.TEMPLATE);
}
/** 단위(객실·메뉴) 안의 fact 도 같은 규칙으로 거른 사본을 준다. */
export function sanitizeUnits(units: UnitInfo[]): UnitInfo[] {
return units

View File

@ -32,6 +32,8 @@ export const SourceType = {
API: 2,
CRAWL: 3,
LLM: 4,
/** FAQ 전용 — 목표 수를 채운 공통 질문 + 문의 안내 답(backend services/faq_fill). 사실이 아니다. */
TEMPLATE: 5,
} as const;
export type SourceType = (typeof SourceType)[keyof typeof SourceType];

View File

@ -11,7 +11,7 @@ import {
} from 'node:fs';
import {basename, dirname, join, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import type {SitePayload} from '@o2o/shared';
import {SourceType, type SitePayload} from '@o2o/shared';
import {
joinUrl,
parseSectionData,
@ -494,6 +494,8 @@ function countUniqueContent(payload: SitePayload): number {
if (fact.type === 'text' && long(fact.value)) count += 1;
}
for (const faq of payload.faqs) {
// 문의 안내(TEMPLATE)는 모든 가게에 같은 문구다 — 세면 내용 없는 사이트가 게이트를 통과한다.
if (faq.sourceType === SourceType.TEMPLATE) continue;
if (String(faq.question ?? '').trim() && long(faq.answer)) count += 1;
}
for (const item of payload.media) {

View File

@ -1,3 +1,4 @@
import {SourceType} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {faqList} from '@site/lib/derive';
import {Section} from '@site/lib/ui';
@ -18,12 +19,15 @@ export function FaqSection() {
if (faqs.length === 0) return null;
// ★ 문의 안내(TEMPLATE)가 섞이면 "모두 사업자가 확인한 내용" 이 거짓이 된다 — 그땐 설명을 달지 않는다.
const hasInquiry = faqs.some((faq) => faq.sourceType === SourceType.TEMPLATE);
return (
<Section
id="faq"
tone="alt"
title="자주 묻는 질문"
lead="아래 답변은 모두 사업자가 확인한 내용입니다."
lead={hasInquiry ? undefined : '아래 답변은 모두 사업자가 확인한 내용입니다.'}
>
<div className={faqs.length > 4 ? 'grid gap-3 lg:grid-cols-2 lg:gap-4' : 'space-y-3'}>
{faqs.map((faq, index) => (

View File

@ -6,8 +6,8 @@ import {
factValue,
joinUrl,
sanitizeUnits,
selectAnsweredFaqs,
selectPublishable,
selectPublishableFaqs,
type ChannelLink,
type FactEntry,
type SitePayload,
@ -370,9 +370,10 @@ function priceRange(payload: SitePayload): string | undefined {
* FAQPage — AEO 에서 가장 크게 먹히는 마크업.
* "체크인 몇 시예요?" 같은 질문에 이 홈페이지가 답으로 잡히는 자리다.
* 확인된 FAQ 가 하나도 없으면 아예 내보내지 않는다(빈 FAQPage 는 감점).
* ★ 문의 안내(TEMPLATE)는 싣지 않는다 — 답이 없는 문답이다(shared selectAnsweredFaqs).
*/
export function faqJsonLd(payload: SitePayload): Json | null {
const faqs = selectPublishableFaqs(payload.faqs);
const faqs = selectAnsweredFaqs(payload.faqs);
if (faqs.length === 0) return null;
return {

View File

@ -3,8 +3,8 @@ import {
factText,
joinUrl,
sanitizeUnits,
selectAnsweredFaqs,
selectPublishable,
selectPublishableFaqs,
type SitePayload,
} from '@o2o/shared';
import {BOOKING_CHANNELS, SCHEMA_TYPE, UNIT_SPEC, unitBaseRate} from './jsonld';
@ -88,7 +88,8 @@ export function renderLlmsTxt(payload: SitePayload): string {
}
// ── FAQ — LLM 이 가장 잘 인용하는 부분 ─────────────────
const faqs = selectPublishableFaqs(payload.faqs);
// ★ 문의 안내(TEMPLATE)는 뺀다. 규칙 3("모르는 것은 정보 없음") 과 달리 질문 자체가 우리가 붙인 것이다.
const faqs = selectAnsweredFaqs(payload.faqs);
if (faqs.length > 0) {
lines.push('## 자주 묻는 질문');
lines.push('');