[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:
parent
b2c8bb033e
commit
8a09af6599
@ -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` — 이 숙소의 노래
|
||||
|
||||
|
||||
@ -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`)가 호텔·모텔·리조트가 아닌 곳.
|
||||
분류가 비어도 적용한다(펜션인데 네이버 분류가 없는 곳이 있다). 카페·음식점·체험시설은 카탈로그가 없어 채우지 않는다.
|
||||
|
||||
@ -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)에 이 가게 프로필을 보내 검색 키워드를
|
||||
|
||||
@ -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) 이다 — 업소 이름이 프롬프트에 들어가므로 옆집이 나눠 쓸 수 없다.
|
||||
|
||||
29
postgres-init/migrations/0012_place_faqs_template_source.sql
Normal file
29
postgres-init/migrations/0012_place_faqs_template_source.sql
Normal 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 $$;
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 생성 — ★ 사실이 아니라 문장에만 쓴다
|
||||
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다
|
||||
|
||||
|
||||
class FactStatus(CodeEnum):
|
||||
|
||||
135
solution/backend/common/faq_catalog/__init__.py
Normal file
135
solution/backend/common/faq_catalog/__init__.py
Normal 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)
|
||||
50
solution/backend/common/faq_catalog/resources/pension.json
Normal file
50
solution/backend/common/faq_catalog/resources/pension.json
Normal 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": ["편의점", "마트"]}
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(),
|
||||
|
||||
98
solution/backend/services/faq_fill.py
Normal file
98
solution/backend/services/faq_fill.py
Normal 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)]
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 — 잡을 만들지 않는다."""
|
||||
|
||||
104
solution/backend/tests/test_faq_fill.py
Normal file
104
solution/backend/tests/test_faq_fill.py
Normal 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
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 탭에서 확인할 수 있습니다.`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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('');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user