diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e7b38b4..776ec68 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -231,7 +231,8 @@ 이건 "미검증 값 노출 금지" 에 STORY 만 예외를 두는 것이다. 근거: 이 값들은 fact 가 아니라 공적 지식이고, 화면이 확신도와 출처를 함께 밝히며, 틀려도 예약·요금처럼 손님이 손해를 보는 -종류가 아니다. **fact·사진·FAQ 에는 이 예외를 넓히지 않는다.** +종류가 아니다. **fact·사진에는 이 예외를 넓히지 않는다.** +(2026-09-10: FAQ 와 소개문에는 넓혔다 — 7절이 근거다. 수집 fact 와 사진은 그대로다.) ### 6-3. 프롬프트는 한 벌이다 @@ -249,3 +250,35 @@ Perplexity 는 실제로 읽은 `search_results` 를 함께 준다. 구조는 **종류당 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 는 그대로 둔다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index ebcc8c9..c632067 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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` 시안에 맞춘다 (렌더러 이식) **무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라 diff --git a/solution/backend/crud/faq_crud.py b/solution/backend/crud/faq_crud.py index 02a456a..a470211 100644 --- a/solution/backend/crud/faq_crud.py +++ b/solution/backend/crud/faq_crud.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG 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.utils.gtime import GTime @@ -72,17 +72,27 @@ class FaqCRUD(IFaqCRUD): return ErrorType.DB_RUN_FAILED async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]: - """재생성 전에 **아직 확인 안 된** LLM 생성 FAQ 만 내린다. + """재생성 전에 **LLM 이 쓴** FAQ 를 내린다. - ★ 사람이 확인·정정한 FAQ(VERIFIED/CORRECTED)는 건드리지 않는다 — - 재생성이 사람의 판단을 덮어쓰면 fact 쪽 규칙과 어긋난다.""" + ★ 사람이 정정한 FAQ 는 건드리지 않는다 — 재생성이 사람의 판단을 덮어쓰면 + fact 쪽 규칙과 어긋난다. + + ★ 가르는 기준이 status 에서 generated_by 로 바뀌었다 (2026-09-10). + 생성분이 UNVERIFIED 로 들어가던 시절에는 status 만으로 "사람이 손댔는가" 를 알 수 + 있었다. 이제 생성분도 VERIFIED 로 들어가므로(copy_service) status 로는 둘이 구분되지 + 않는다 — 그대로 두면 재생성이 옛 FAQ 를 못 내리고 같은 질문이 쌓인다. + 책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service 가 + generated_by 를 OWNER 로 바꾼다.""" try: query = ( update(place_faqs) .where( place_faqs.place_id == place_id, 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) ) diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 98c6e69..9421f0f 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -3,8 +3,12 @@ ★ LLM 은 사실을 만들지 않는다. 문장만 쓴다. - 입력은 **확보된 fact(노출 가능한 것)만**. 미검증 값으로 문장을 쓰면 그 문장도 미검증이다. - 생성물은 `ground_check` 를 통과한 것만 저장한다(클라이언트가 이미 걸러 보내지만 근거를 다시 요구한다). - - 소개문은 fact 로 들어가되 **UNVERIFIED 후보**다. 사람이 승인해야 사이트에 나간다. - - FAQ 도 같은 검증 흐름을 탄다. 근거 fact 가 없는 FAQ 는 저장하지 않는다. + - 소개문·FAQ 는 **바로 노출값**이다(VERIFIED). 승인 단계를 두지 않는다 — 2026-09-10 결정. + 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고, 근거 없는 FAQ 는 저장조차 하지 않는다. + 확인된 사실로 쓴 문장을 한 번 더 승인받게 하면 같은 사실을 두 번 승인하는 셈이고, + 실제로는 그 화면이 닫힌 뒤에 문장이 도착해 발행본이 영영 빈칸이었다 + (근거·실측: services/fact_service.upsert_fact · docs/DECISIONS.md 7절). + - 사장님이 고친 문장(CORRECTED)은 재생성이 덮지 않는다. 그 잠금은 그대로다. """ import uuid @@ -193,7 +197,8 @@ async def run_copy(job: dict) -> dict: ) 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)): if not (text_value or "").strip(): continue @@ -228,7 +233,9 @@ async def run_copy(job: dict) -> dict: answer=faq.answer, source_fact_ids=list(faq.fact_keys), generated_by=SourceType.LLM.value, - status=FactStatus.UNVERIFIED.value, + # ★ 바로 노출한다 (2026-09-10 결정 — fact_service.upsert_fact 주석이 근거). + # 근거 fact 가 없으면 위에서 이미 버렸으므로, 여기 남은 것은 전부 확인된 사실로 쓴 문장이다. + status=FactStatus.VERIFIED.value, sort_order=order, ) run_err = await DB_SESSION_MNG.execute_lambda_run( diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index 30d2e56..c18e6ed 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -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.enums import ( FACT_STATUS_TRANSITIONS, + LOCKED_FACT_STATUSES, PUBLISHABLE_FACT_STATUSES, DBWRType, ErrorType, @@ -52,6 +53,8 @@ class FactService: 값이 다르면 **노출값은 그대로 두고** 후보(PENDING_OWNER)로 적재 → 사람이 승인해야 노출값이 교체된다 수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다 + 문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값 + (사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석) 핵심은 재수집이 노출 중인 사실을 밀어내지 않는다는 것이다. 밀어내면 사이트에서 체크인 시간 같은 항목이 사라지고, 그 사이 방문자는 정보를 못 본다. @@ -60,7 +63,8 @@ class FactService: 1. key 는 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY) 2. owner 가 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED) 3. LLM 은 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7) - 4. 자동 수집은 노출값을 직접 못 바꾼다 — 후보로만 (절대규칙 1·6) + 4. 자동 수집은 노출값을 직접 못 바꾼다 — 후보로만 (절대규칙 1·6). + ★ 예외는 LLM 문장 하나뿐이고, 그것도 CORRECTED 는 못 덮는다 5. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만 """ @@ -287,6 +291,24 @@ class FactService: res.outcome = FactWriteOutcome.REFRESHED 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: 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) diff --git a/solution/backend/tests/test_copy_api.py b/solution/backend/tests/test_copy_api.py index e0e090f..74f8af1 100644 --- a/solution/backend/tests/test_copy_api.py +++ b/solution/backend/tests/test_copy_api.py @@ -3,8 +3,10 @@ 이 잡이 절대 하면 안 되는 것: - 미검증 fact 를 근거로 문장을 쓰는 것 (그 문장도 미검증이 된다) - 근거 없이 생성하는 것 (그게 환각이다) - - 생성물을 바로 사이트에 노출하는 것 (사람 승인이 있어야 한다) - - 사람이 확인한 FAQ 를 재생성이 덮어쓰는 것 + - 사람이 정정한 FAQ·소개문(CORRECTED)을 재생성이 덮어쓰는 것 + +★ 반대로 '생성물을 바로 노출하는 것' 은 이제 금지가 아니다(2026-09-10 결정). + 게이트는 입력 쪽에 있다 — 확인된 fact 로만 쓰고, 근거 없는 FAQ 는 저장되지 않는다. """ import uuid @@ -55,9 +57,10 @@ async def _faq_rows(db_engine, pid): )).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 를 생성한다. - 기대결과: 생성되지만 ★ 전부 미검증 — 사람이 승인해야 사이트에 나간다.""" + 기대결과: ★ 바로 노출값(VERIFIED) — 승인 단계를 두지 않는다(2026-09-10 결정). + 게이트는 앞에 있다: 입력이 확인된 fact 뿐이고 근거 없는 FAQ 는 저장되지 않는다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") 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["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"] assert job["status"] == JobStatus.DONE.value, job.get("last_error") assert job["result"]["intro"] is True assert job["result"]["faqs"] == 1 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 가 기록돼야 한다" - # 소개문도 fact 로 들어가되 미검증 후보다 + # 소개문도 fact 로 들어가고, 승인 없이 바로 노출값이다 facts = (await client.get(f"/v1/place/{pid}/fact/list", headers=h)).json() 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 @@ -120,7 +123,7 @@ async def test_faq_without_grounding_is_dropped(auth_headers, client, db_engine, h = await auth_headers("u1") pid = await _place_with_facts(client, h) 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"] 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") pid = await _place_with_facts(client, h) 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"] assert job["result"]["intro"] is False 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): - """검증: 사람이 승인한 FAQ 가 있는 상태에서 재생성한다. - 기대결과: ★ 승인된 FAQ 는 남는다 — 재생성이 사람의 판단을 덮어쓰면 안 된다.""" +async def test_regeneration_keeps_faq_the_owner_corrected(auth_headers, client, db_engine, monkeypatch): + """검증: 사장님이 고친 FAQ 가 있는 상태에서 재생성한다. + 기대결과: ★ 고친 FAQ 는 남는다 — 재생성이 사람의 판단을 덮어쓰면 안 된다. + + ★ '승인' 이 아니라 '정정' 으로 검증한다(2026-09-10). 생성분이 곧바로 VERIFIED 로 들어가면서 + status 만으로는 사람이 손댔는지 알 수 없게 됐다. 책임 주체를 적어 두는 자리는 + generated_by 이고, 사장님이 정정하면 faq_service 가 그 값을 OWNER 로 바꾼다.""" _patch(monkeypatch, _copy()) h = await auth_headers("u1") pid = await _place_with_facts(client, h) 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: await c.execute( - text("UPDATE place_faqs SET status = :s WHERE place_id = :p"), - {"s": FactStatus.VERIFIED.value, "p": uuid.UUID(pid)}, + text("UPDATE place_faqs SET status = :s, generated_by = :g, question = :q WHERE place_id = :p"), + {"s": FactStatus.CORRECTED.value, "g": SourceType.OWNER.value, + "q": "체크인 시간이 어떻게 되나요?", "p": uuid.UUID(pid)}, ) _patch(monkeypatch, _copy(faqs=[gemini_text.GeneratedFaq("새 질문?", "새 답변", ["check_in_time"])])) 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) - approved = [r for r in rows if r[3] == FactStatus.VERIFIED.value] - assert approved and approved[0][0] == "체크인은 몇 시인가요?", "★ 승인된 FAQ 가 재생성에 밀려났다" + kept = [r for r in rows if r[3] == FactStatus.CORRECTED.value] + 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): diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 38f4e0c..7e73cc8 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -82,6 +82,48 @@ async def test_llm_cannot_write_non_sentence_fields(auth_headers, client): 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): """검증: 크롤링으로 처음 들어온 값. diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index 691318b..b469cee 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -19,7 +19,10 @@ async def _place(client, headers, name="FAQ펜션"): 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() async with db_engine.begin() as conn: await conn.execute(