[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:
parent
fc67962efc
commit
4d5a77453b
@ -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 는 그대로 둔다.
|
||||||
|
|||||||
@ -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` 시안에 맞춘다 (렌더러 이식)
|
||||||
|
|
||||||
**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라
|
**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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):
|
||||||
"""검증: 크롤링으로 처음 들어온 값.
|
"""검증: 크롤링으로 처음 들어온 값.
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user