[fix] solution/backend,docs: LLM 이 쓴 문장은 승인 없이 나간다 — 소개문이 생성되고도 영영 빈칸이던 것

실측(2026-09-10, 힐튼 가든 인 서울 강남): 로그는 `[copy] 소개문 O` 이고 DB 에도 문장이
있는데 발행본의 소개는 빈칸이었다. status=1(UNVERIFIED) 이라 스냅샷이 담지 않았고,
그 자리를 fact 로 조립한 한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채워
화면만 보면 생성이 실패한 것처럼 보이지도 않았다.

승인이 안 된 이유는 승인할 화면이 없어서다 — 수집 확인은 07:29 에 끝나는데 소개문은
07:31 에 도착한다. 사장님은 그 화면을 이미 지나간 뒤다.

게이트는 뒤가 아니라 앞에 둔다: 입력이 확인된 fact 뿐이고(근거가 없으면 유료 호출조차
하지 않는다), 근거 없는 FAQ 는 저장되지 않는다. 이미 승인된 사실로 쓴 문장을 한 번 더
승인받는 것은 같은 사실을 두 번 승인하는 일이다.

- fact_service: LLM 출처는 후보가 아니라 노출값으로 앉힌다. API·CRAWL 은 그대로 후보다
- fact_service: 사장님이 고친 문장(CORRECTED)은 LLM 이 못 덮게 잠금을 **명시적으로** 건다.
  지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 경로가 대신 해 주고 있었다 —
  LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6)
- copy_service: 생성 FAQ 를 VERIFIED 로 저장
- faq_crud: 재생성 대상을 status 가 아니라 generated_by 로 가른다. 생성분이 VERIFIED 로
  들어가면 status 로는 사람이 손댔는지 알 수 없다 — 그대로 뒀다면 재생성이 옛 FAQ 를
  못 내려 같은 질문이 쌓인다. 반려(REJECTED)한 것은 그대로 둔다
- tests: 잡을 하나만 처리하면 지역 이야기 잡에 밀린다 — process_one → drain
- DECISIONS 7절 신설, 6-2 의 "FAQ 에는 넓히지 않는다" 를 결론과 함께 고침. DEVLOG 추가

검증: fact·copy·faq 35 passed(신규 2건 — LLM 문장이 승인 없이 노출값이 되는지 ·
CORRECTED 를 못 덮는지). 전체 577 passed / 8 failed, 8건은 전부 컨테이너 환경변수 유입
(.env 키 · 프론트 소스 부재 · SITE_PUBLIC_HOST)이고 코드 회귀가 아니다.
운영 재기동 후 힐튼으로 재생성: intro status=3 · FAQ 6건 VERIFIED · 옛 7건 EXPIRED 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hbyang 2026-09-10 17:03:44 +09:00
parent fc67962efc
commit 4d5a77453b
8 changed files with 198 additions and 32 deletions

View File

@ -231,7 +231,8 @@
이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라 이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라
공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는 공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는
종류가 아니다. **fact·사진·FAQ 에는 이 예외를 넓히지 않는다.** 종류가 아니다. **fact·사진에는 이 예외를 넓히지 않는다.**
(2026-09-10: FAQ 와 소개문에는 넓혔다 — 7절이 근거다. 수집 fact 와 사진은 그대로다.)
### 6-3. 프롬프트는 한 벌이다 ### 6-3. 프롬프트는 한 벌이다
@ -249,3 +250,35 @@ Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. 구조는
**종류당 1회, 지역당 1세트.** 다섯을 한 프롬프트에 넣으면 출력이 잘리고, 한 종이 실패하면 **종류당 1회, 지역당 1세트.** 다섯을 한 프롬프트에 넣으면 출력이 잘리고, 한 종이 실패하면
전부 다시 돌고, 검색 출처가 어느 항목 것인지 섞인다. 항목당 1회는 반대로 낭비다. 전부 다시 돌고, 검색 출처가 어느 항목 것인지 섞인다. 항목당 1회는 반대로 낭비다.
---
## 7. LLM 이 쓴 문장은 승인 없이 나간다 (2026-09-10)
소개문·FAQ 는 생성된 뒤 **사장님 승인**을 받아야 발행본에 나갔다(`UNVERIFIED` → `VERIFIED`).
그 단계를 없앤다. 생성 즉시 노출값이다.
**왜 — 승인받을 화면이 없었다.**
실측(2026-09-10, 힐튼 가든 인 서울 강남): 수집 확인이 07:29 에 끝나고 소개문은 07:31 에 도착했다.
사장님은 이미 확인 화면을 지나간 뒤였다. 로그는 `[copy] 소개문 O` 인데 발행본의 소개는 빈칸이고,
그 자리를 fact 로 조립한 한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있었다.
**생성은 되는데 영영 안 나가는** 상태였고, 화면 어디에도 그 이유가 보이지 않았다.
**왜 안전한가 — 게이트가 뒤가 아니라 앞에 있다.**
- 입력이 **확인된 fact 뿐이다**. `copy_service``PUBLISHABLE` 만 근거로 넘기고, 근거가 하나도
없으면 유료 호출조차 하지 않는다. 즉 이 문장은 이미 승인된 사실로만 쓰였다 —
한 번 더 승인받는 것은 같은 사실을 두 번 승인하는 일이다.
- 근거 fact 가 없는 FAQ 는 저장되지 않는다. `ground_check` 가 반려한 문장도 마찬가지다.
- 6-2(지역 이야기)와 같은 규약이다. 그때 "fact·사진·FAQ 에는 넓히지 않는다" 고 적었는데,
**FAQ 와 소개문에는 넓힌다** 로 바꾼다. 근거는 위 두 줄이다: 이 둘은 수집된 *사실*이 아니라
이미 확인된 사실로 쓴 *문장*이다. 수집 fact(체크인·반려동물·취소 규정)와 사진에는
여전히 넓히지 않는다 — 그건 틀리면 예약 클레임이 나는 값이고 근거가 우리 밖에 있다.
**남겨 둔 잠금.** 사장님이 고친 문장(`CORRECTED`)은 재생성이 덮지 않는다(절대규칙 6).
지금까지 이 잠금은 "자동 출처는 노출값 경로로 못 간다"는 구조가 대신 지켜 줬다 —
LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠금을 **명시적으로 다시 걸었다.**
**FAQ 재생성의 기준이 바뀐다.** 생성분이 `VERIFIED` 로 들어가므로 status 만으로는 사람이
손댔는지 알 수 없다. `expire_generated``generated_by` 로 가른다 — 사장님이 정정하면
`faq_service` 가 그 값을 `OWNER` 로 바꾼다(책임 주체의 기록이고, 원래부터 있던 자리다).
반려(`REJECTED`)한 FAQ 는 그대로 둔다.

View File

@ -5,6 +5,45 @@
--- ---
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
DB 에도 문장이 있는데 `status=1(UNVERIFIED)` 이라 스냅샷이 담지 않았다. 그 자리는 fact 로 조립한
한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있어서, 화면만 보면 생성이 실패한
것처럼 보이지도 않았다.
**왜 승인이 안 됐나 — 승인할 화면이 없었다.**
```
07:29:04 수집 완료 → 여기서 사장님이 [맞아요] 를 눌러 fact 가 VERIFIED 가 된다
07:31:11 ★ 소개문 도착 — 2분 늦게. 확인 화면은 이미 지나갔다
```
**한 일**
- `fact_service.upsert_fact`: **LLM 출처는 후보가 아니라 노출값으로 앉힌다.** 자동 출처(API·CRAWL)는
그대로 후보다. 게이트는 앞에 있다 — 입력이 확인된 fact 뿐이라 이미 승인된 사실로 쓴 문장이다.
- `copy_service`: 생성 FAQ 를 `VERIFIED` 로 저장한다. 근거 없는 FAQ 는 여전히 저장하지 않는다.
- **잠금은 명시적으로 다시 걸었다.** 사장님이 고친 문장(`CORRECTED`)은 LLM 이 못 덮는다.
지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 **경로**가 대신 해 주고 있었다 —
LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6).
- `faq_crud.expire_generated`: 재생성 대상을 status 가 아니라 `generated_by` 로 가른다.
생성분이 VERIFIED 로 들어가면 status 로는 사람이 손댔는지 알 수 없다. 그대로 뒀다면 재생성이
옛 FAQ 를 못 내려 같은 질문이 쌓였을 것이다.
- 결론과 근거는 [DECISIONS.md 7절](DECISIONS.md). 6-2 의 "FAQ 에는 넓히지 않는다" 도 함께 고쳤다.
**곁다리로 잡은 것 — 테스트가 통째로 막혀 있던 진짜 이유**
ORM 의 TIMESTAMPTZ 기본값이 `(now() AT TIME ZONE 'utc')` 였다. timestamptz 에 이걸 쓰면 값이
시간대 없는 벽시계로 떨어졌다가 세션 시간대로 다시 해석돼 **서버 시간대만큼 미래로 밀린다.**
실측: 잡의 `run_after` 가 7시간 뒤로 박혀 `claim`(`run_after <= now()`)에 영영 안 걸렸고,
COPY 관련 테스트가 "잡이 PENDING 인 채" 무더기로 실패했다. 원인이 코드가 아니라 스키마라
읽히지 않는 종류다. 운영은 멀쩡했다 — 운영 DB 는 `init.sql`(`DEFAULT now()`)로 만들어지고
이 기본값은 **ORM 이 스키마를 만들 때만**, 즉 테스트 DB 에서만 쓰인다.
`init.sql` 과 같은 `now()` 로 맞췄다. 스키마는 init.sql 이 단일 출처다.
**검증** — fact·copy·faq 35건 통과(신규 2건: LLM 문장이 승인 없이 노출값이 되는지 ·
CORRECTED 를 못 덮는지).
---
## 2026-09-10 — 옛 항구 템플릿을 `/s/stay` 시안에 맞춘다 (렌더러 이식) ## 2026-09-10 — 옛 항구 템플릿을 `/s/stay` 시안에 맞춘다 (렌더러 이식)
**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라 **무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라

View File

@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_faqs from common.database.model.models import place_faqs
from common.enums import ErrorType, FactStatus from common.enums import ErrorType, FactStatus, SourceType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -72,17 +72,27 @@ class FaqCRUD(IFaqCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]: async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
"""재생성 전에 **아직 확인 안 된** LLM 생성 FAQ 만 내린다. """재생성 전에 **LLM 이 쓴** FAQ 를 내린다.
사람이 확인·정정한 FAQ(VERIFIED/CORRECTED) 건드리지 않는다 사람이 정정한 FAQ 건드리지 않는다 재생성이 사람의 판단을 덮어쓰면
재생성이 사람의 판단을 덮어쓰면 fact 규칙과 어긋난다.""" fact 규칙과 어긋난다.
가르는 기준이 status 에서 generated_by 바뀌었다 (2026-09-10).
생성분이 UNVERIFIED 들어가던 시절에는 status 만으로 "사람이 손댔는가"
있었다. 이제 생성분도 VERIFIED 들어가므로(copy_service) status 로는 둘이 구분되지
않는다 그대로 두면 재생성이 FAQ 내리고 같은 질문이 쌓인다.
책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service
generated_by OWNER 바꾼다."""
try: try:
query = ( query = (
update(place_faqs) update(place_faqs)
.where( .where(
place_faqs.place_id == place_id, place_faqs.place_id == place_id,
place_faqs.deleted == False, # noqa: E712 place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_((FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)), place_faqs.generated_by == SourceType.LLM.value,
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 —
# 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
) )
.values(status=FactStatus.EXPIRED.value, updated_at=ts) .values(status=FactStatus.EXPIRED.value, updated_at=ts)
) )

View File

@ -3,8 +3,12 @@
LLM 사실을 만들지 않는다. 문장만 쓴다. LLM 사실을 만들지 않는다. 문장만 쓴다.
- 입력은 **확보된 fact(노출 가능한 )**. 미검증 값으로 문장을 쓰면 문장도 미검증이다. - 입력은 **확보된 fact(노출 가능한 )**. 미검증 값으로 문장을 쓰면 문장도 미검증이다.
- 생성물은 `ground_check` 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다). - 생성물은 `ground_check` 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다).
- 소개문은 fact 들어가되 **UNVERIFIED 후보**. 사람이 승인해야 사이트에 나간다. - 소개문·FAQ **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 2026-09-10 결정.
- FAQ 같은 검증 흐름을 탄다. 근거 fact 없는 FAQ 저장하지 않는다. 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 저장조차 하지 않는다.
확인된 사실로 문장을 승인받게 하면 같은 사실을 승인하는 셈이고,
실제로는 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다
(근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7).
- 사장님이 고친 문장(CORRECTED) 재생성이 덮지 않는다. 잠금은 그대로다.
""" """
import uuid import uuid
@ -193,7 +197,8 @@ async def run_copy(job: dict) -> dict:
) )
service = FactService(_fact_crud, _place_crud) service = FactService(_fact_crud, _place_crud)
# 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인한다(뒷문 없음). # 소개문·메타는 fact 로 들어간다 — FactService 가 allow_llm 을 다시 확인하고(뒷문 없음),
# LLM 출처라 후보가 아니라 노출값으로 앉힌다(upsert_fact 의 LLM 분기).
for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)): for key, text_value in (("intro", copy.intro), ("meta_description", copy.meta_description)):
if not (text_value or "").strip(): if not (text_value or "").strip():
continue continue
@ -228,7 +233,9 @@ async def run_copy(job: dict) -> dict:
answer=faq.answer, answer=faq.answer,
source_fact_ids=list(faq.fact_keys), source_fact_ids=list(faq.fact_keys),
generated_by=SourceType.LLM.value, generated_by=SourceType.LLM.value,
status=FactStatus.UNVERIFIED.value, # ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거).
# 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다.
status=FactStatus.VERIFIED.value,
sort_order=order, sort_order=order,
) )
run_err = await DB_SESSION_MNG.execute_lambda_run( run_err = await DB_SESSION_MNG.execute_lambda_run(

View File

@ -7,6 +7,7 @@ from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_facts, places, place_units from common.database.model.models import place_facts, places, place_units
from common.enums import ( from common.enums import (
FACT_STATUS_TRANSITIONS, FACT_STATUS_TRANSITIONS,
LOCKED_FACT_STATUSES,
PUBLISHABLE_FACT_STATUSES, PUBLISHABLE_FACT_STATUSES,
DBWRType, DBWRType,
ErrorType, ErrorType,
@ -52,6 +53,8 @@ class FactService:
값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER) 적재 값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER) 적재
사람이 승인해야 노출값이 교체된다 사람이 승인해야 노출값이 교체된다
수정 : 사람이 직접 입력 노출값 즉시 교체. 정정(CORRECTED) 잠금 표시가 붙는다 수정 : 사람이 직접 입력 노출값 즉시 교체. 정정(CORRECTED) 잠금 표시가 붙는다
문장 : LLM 소개문·메타(allow_llm 필드) 승인 없이 바로 노출값
(사실이 아니라 **이미 승인된 사실로 문장**이다 upsert_fact 주석)
핵심은 재수집이 노출 중인 사실을 밀어내지 않는다는 것이다. 밀어내면 사이트에서 핵심은 재수집이 노출 중인 사실을 밀어내지 않는다는 것이다. 밀어내면 사이트에서
체크인 시간 같은 항목이 사라지고, 사이 방문자는 정보를 본다. 체크인 시간 같은 항목이 사라지고, 사이 방문자는 정보를 본다.
@ -60,7 +63,8 @@ class FactService:
1. key 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY) 1. key 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY)
2. owner 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED) 2. owner 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED)
3. LLM 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7) 3. LLM 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7)
4. 자동 수집은 노출값을 직접 바꾼다 후보로만 (절대규칙 1·6) 4. 자동 수집은 노출값을 직접 바꾼다 후보로만 (절대규칙 1·6).
예외는 LLM 문장 하나뿐이고, 그것도 CORRECTED 덮는다
5. 상태 전이는 FACT_STATUS_TRANSITIONS 있는 것만 5. 상태 전이는 FACT_STATUS_TRANSITIONS 있는 것만
""" """
@ -287,6 +291,24 @@ class FactService:
res.outcome = FactWriteOutcome.REFRESHED res.outcome = FactWriteOutcome.REFRESHED
return await self._reload(res, pid, published.fact_id) return await self._reload(res, pid, published.fact_id)
# ★ LLM 이 쓴 문장은 후보로 두지 않고 **바로 노출값**이다 (2026-09-10 결정).
#
# 왜 예외인가 — 후보 단계는 '이 값이 사실인가' 를 사람에게 묻는 자리다. 체크인 시각이
# 틀리면 예약 클레임이 나므로 그 물음이 필요하다. 그런데 소개문은 사실이 아니라 문장이고,
# 그 재료는 **이미 노출값인 fact**뿐이다(규칙 3 + copy_service 가 grounded 만 넘긴다).
# 확인된 사실로 쓴 문장을 한 번 더 확인받게 하면, 같은 사실을 두 번 승인하는 셈이다.
#
# 실측(2026-09-10, 힐튼 가든 인 서울 강남): 수집 확인이 07:29 에 끝나고 소개문이 07:31 에
# 도착했다. 사장님이 확인 화면을 지나간 뒤에 오는 값이라 승인할 화면 자체가 없었고,
# 소개문은 생성됐는데(`[copy] 소개문 O`) 발행본은 영영 빈칸이었다.
# → 지역 이야기를 검수 없이 PUBLISHED 로 싣는 것과 같은 규약이다(DECISIONS.md 6절).
# ★ 단 사장님이 고친 문장(CORRECTED)은 덮지 않는다. 지금까지 이 잠금은 "자동 출처는
# 노출값에 손을 못 댄다" 는 경로 자체가 지켜 줬는데(_write_candidate), LLM 만 경로를
# 바꾸면 그 보호가 사라진다 — 잠금은 여기서 명시적으로 다시 건다(절대규칙 6).
if req.source_type == SourceType.LLM:
if published is not None and FactStatus(published.status) in LOCKED_FACT_STATUSES:
return await self._write_candidate(res, pid, req, published, now, spec)
return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info)
if req.source_type in _AUTO_SOURCES: if req.source_type in _AUTO_SOURCES:
return await self._write_candidate(res, pid, req, published, now, spec) return await self._write_candidate(res, pid, req, published, now, spec)
return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info) return await self._replace_published(res, place_id, pid, req, published, now, spec, user_info)

View File

@ -3,8 +3,10 @@
잡이 절대 하면 되는 : 잡이 절대 하면 되는 :
- 미검증 fact 근거로 문장을 쓰는 ( 문장도 미검증이 된다) - 미검증 fact 근거로 문장을 쓰는 ( 문장도 미검증이 된다)
- 근거 없이 생성하는 (그게 환각이다) - 근거 없이 생성하는 (그게 환각이다)
- 생성물을 바로 사이트에 노출하는 (사람 승인이 있어야 한다) - 사람이 정정한 FAQ·소개문(CORRECTED) 재생성이 덮어쓰는
- 사람이 확인한 FAQ 재생성이 덮어쓰는
반대로 '생성물을 바로 노출하는 것' 이제 금지가 아니다(2026-09-10 결정).
게이트는 입력 쪽에 있다 확인된 fact 로만 쓰고, 근거 없는 FAQ 저장되지 않는다.
""" """
import uuid import uuid
@ -55,9 +57,10 @@ async def _faq_rows(db_engine, pid):
)).all() )).all()
async def test_copy_generates_intro_and_faq_as_candidates(auth_headers, client, db_engine, monkeypatch): async def test_copy_generates_intro_and_faq_as_published(auth_headers, client, db_engine, monkeypatch):
"""검증: 소개문·FAQ 를 생성한다. """검증: 소개문·FAQ 를 생성한다.
기대결과: 생성되지만 전부 미검증 사람이 승인해야 사이트에 나간다.""" 기대결과: 바로 노출값(VERIFIED) 승인 단계를 두지 않는다(2026-09-10 결정).
게이트는 앞에 있다: 입력이 확인된 fact 뿐이고 근거 없는 FAQ 저장되지 않는다."""
_patch(monkeypatch, _copy()) _patch(monkeypatch, _copy())
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place_with_facts(client, h) pid = await _place_with_facts(client, h)
@ -66,20 +69,20 @@ async def test_copy_generates_intro_and_faq_as_candidates(auth_headers, client,
assert body["result"]["success"] is True assert body["result"]["success"] is True
assert body["grounded_facts"] == 5 assert body["grounded_facts"] == 5
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).process_one() 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"] 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["status"] == JobStatus.DONE.value, job.get("last_error")
assert job["result"]["intro"] is True assert job["result"]["intro"] is True
assert job["result"]["faqs"] == 1 assert job["result"]["faqs"] == 1
rows = await _faq_rows(db_engine, pid) rows = await _faq_rows(db_engine, pid)
assert rows[0][3] == FactStatus.UNVERIFIED.value, "★ 생성된 FAQ 가 바로 노출 상태면 안 된" assert rows[0][3] == FactStatus.VERIFIED.value, "★ 생성된 FAQ 는 바로 노출값이어야 한"
assert rows[0][2] == ["check_in_time"], "근거 fact 가 기록돼야 한다" assert rows[0][2] == ["check_in_time"], "근거 fact 가 기록돼야 한다"
# 소개문도 fact 로 들어가되 미검증 후보 # 소개문도 fact 로 들어가고, 승인 없이 바로 노출값이
facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json() facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
intro = [f for f in facts["facts"] if f["key"] == "intro"] intro = [f for f in facts["facts"] if f["key"] == "intro"]
assert intro and intro[0]["status"] == FactStatus.UNVERIFIED.value assert intro and intro[0]["status"] == FactStatus.VERIFIED.value
assert intro[0]["source_type"] == SourceType.LLM.value assert intro[0]["source_type"] == SourceType.LLM.value
@ -120,7 +123,7 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine,
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place_with_facts(client, h) pid = await _place_with_facts(client, h)
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"] 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).process_one() await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["result"]["faqs"] == 1 assert job["result"]["faqs"] == 1
@ -135,36 +138,43 @@ async def test_rejected_sentences_are_reported(auth_headers, client, monkeypatch
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place_with_facts(client, h) pid = await _place_with_facts(client, h)
job_id = (await client.post(f"/v1/place/{pid}/copy", headers=h, json={})).json()["job_id"] 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).process_one() await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"] job = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]
assert job["result"]["intro"] is False assert job["result"]["intro"] is False
assert any("수영장" in str(r) for r in job["result"]["rejected"]) assert any("수영장" in str(r) for r in job["result"]["rejected"])
async def test_regeneration_keeps_human_approved_faq(auth_headers, client, db_engine, monkeypatch): async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, db_engine, monkeypatch):
"""검증: 사람이 승인한 FAQ 가 있는 상태에서 재생성한다. """검증: 사장님이 고친 FAQ 가 있는 상태에서 재생성한다.
기대결과: 승인된 FAQ 남는다 재생성이 사람의 판단을 덮어쓰면 된다.""" 기대결과: 고친 FAQ 남는다 재생성이 사람의 판단을 덮어쓰면 된다.
'승인' 아니라 '정정' 으로 검증한다(2026-09-10). 생성분이 곧바로 VERIFIED 들어가면서
status 만으로는 사람이 손댔는지 없게 됐다. 책임 주체를 적어 두는 자리는
generated_by 이고, 사장님이 정정하면 faq_service 값을 OWNER 바꾼다."""
_patch(monkeypatch, _copy()) _patch(monkeypatch, _copy())
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place_with_facts(client, h) pid = await _place_with_facts(client, h)
await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).process_one() await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
# 사람이 승인 # 사장님이 문구를 고쳐 승인 — 잠긴 상태(CORRECTED)가 되고 책임 주체가 사람으로 넘어온다.
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( await c.execute(
text("UPDATE place_faqs SET status = :s WHERE place_id = :p"), text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q WHERE place_id = :p"),
{"s": FactStatus.VERIFIED.value, "p": uuid.UUID(pid)}, {"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value,
"q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid)},
) )
_patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])])) _patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])]))
await client.post(f"/v1/place/{pid}/copy", headers=h, json={}) await client.post(f"/v1/place/{pid}/copy", headers=h, json={})
await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).process_one() await Worker("w", JobQueue(), build_handler(), job_deadline_sec=30).drain()
rows = await _faq_rows(db_engine, pid) rows = await _faq_rows(db_engine, pid)
approved = [r for r in rows if r[3] == FactStatus.VERIFIED.value] kept = [r for r in rows if r[3] == FactStatus.CORRECTED.value]
assert approved and approved[0][0] == "체크인은 몇 시인가요?", "★ 승인된 FAQ 가 재생성에 밀려났다" assert kept and kept[0][0] == "체크인 시간이 어떻게 되나요?", "★ 사장님이 고친 FAQ 가 재생성에 밀려났다"
# 새로 생성된 것은 바로 노출값으로 함께 선다.
assert any(r[0] == "새 질문?" and r[3] == FactStatus.VERIFIED.value for r in rows)
async def test_copy_requires_api_key(auth_headers, client, monkeypatch): async def test_copy_requires_api_key(auth_headers, client, monkeypatch):

View File

@ -82,6 +82,48 @@ async def test_llm_cannot_write_non_sentence_fields(auth_headers, client):
assert body["result"]["success"] is True assert body["result"]["success"] is True
async def test_llm_sentence_is_published_without_approval(auth_headers, client):
"""검증: LLM 이 소개문(allow_llm)을 기록한다.
기대결과: 후보가 아니라 바로 노출값(VERIFIED) 2026-09-10 결정.
소개문은 사실이 아니라 이미 승인된 사실로 문장이라 승인을 받을 이유가 없다."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="l2")
body = await _crawl(client, h, pid, "intro", "조용한 숙소입니다.", SourceType.LLM)
assert body["fact"]["status"] == FactStatus.VERIFIED.value
assert body["outcome"] != FactWriteOutcome.CANDIDATE_CREATED.value
# 사이트 빌드가 보는 집합에 바로 들어간다.
facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
assert facts["publishable"] >= 1
async def test_llm_cannot_overwrite_corrected_sentence(auth_headers, client):
"""검증: 사장님이 고친 소개문(CORRECTED)을 LLM 이 다시 쓴다.
기대결과: 노출값은 사장님 문장 그대로 절대규칙 6. 문장은 후보로만 남는다.
잠금은 지금까지 '자동 출처는 노출값 경로로 못 간다' 구조가 대신 지켜 줬다.
LLM 경로를 지나가게 되면서 잠금을 명시적으로 다시 걸어야 했다."""
h = await auth_headers("u1")
pid = await _verified_place(client, h, kakao="l3")
await _crawl(client, h, pid, "intro", "생성된 첫 문장입니다.", SourceType.LLM)
facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
fact_id = next(f["fact_id"] for f in facts["facts"] if f["key"] == "intro")
r = await client.post(f"/v1/place/{pid}/fact/{fact_id}/transition", headers=h, json={
"status": FactStatus.CORRECTED.value, "value": "사장님이 고친 문장입니다."})
assert r.json()["result"]["success"] is True
body = await _crawl(client, h, pid, "intro", "생성기가 다시 쓴 문장입니다.", SourceType.LLM)
assert body["outcome"] == FactWriteOutcome.CANDIDATE_CREATED.value
facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json()
published = [f for f in facts["facts"]
if f["key"] == "intro" and f["status"] == FactStatus.CORRECTED.value]
assert published and published[0]["value"] == "사장님이 고친 문장입니다."
# ── 생성 프로세스 ───────────────────────────────────────────────────────── # ── 생성 프로세스 ─────────────────────────────────────────────────────────
async def test_crawled_fact_starts_as_candidate_and_is_not_published(auth_headers, client): async def test_crawled_fact_starts_as_candidate_and_is_not_published(auth_headers, client):
"""검증: 크롤링으로 처음 들어온 값. """검증: 크롤링으로 처음 들어온 값.

View File

@ -19,7 +19,10 @@ async def _place(client, headers, name="FAQ펜션"):
async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인가요?", answer="15시입니다.", order=0): async def _seed_generated_faq(db_engine, pid, question="체크인은 몇 시인가요?", answer="15시입니다.", order=0):
"""COPY 잡이 남기고 간 상태를 그대로 만든다 — LLM 출처 · UNVERIFIED.""" """LLM 출처 · UNVERIFIED 인 FAQ 한 건.
2026-09-10 부터 COPY 잡은 VERIFIED 넣는다. 상태는 전에 생성된 행과
사장님이 반려·정정을 거치며 지나가는 자리다 상태 의미는 그대로 지켜져야 하므로 남긴다."""
fid = uuid.uuid4() fid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(