[feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내

COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다.
생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다.
공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 —
답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다.

- common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외
- services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가
  겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다)
- copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만
- place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만)
- SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다
- faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게
- prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다)
- shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD ·
  llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다
- site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다
- frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다
- postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON,
  0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다
- docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md

백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다
site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과
로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0
0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
This commit is contained in:
민헌 2026-09-14 17:05:43 +09:00
parent b2c8bb033e
commit 8a09af6599
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('');