실측(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>
115 lines
5.3 KiB
Python
115 lines
5.3 KiB
Python
from abc import ABC, abstractmethod
|
|
from typing import Tuple
|
|
|
|
from sqlalchemy import and_, select, update
|
|
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, SourceType
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
|
|
_PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
|
_ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE
|
|
|
|
|
|
# FAQ CRUD. fact 와 같은 검증 상태 흐름을 탄다 — 생성된 문장도 사람이 확인해야 나간다.
|
|
class IFaqCRUD(ABC):
|
|
@abstractmethod
|
|
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def transition(self, cdb: AsyncSession, faq_id, from_statuses, to_status, data: dict) -> Tuple[ErrorType, int]:
|
|
pass
|
|
|
|
@abstractmethod
|
|
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
|
|
pass
|
|
|
|
|
|
class FaqCRUD(IFaqCRUD):
|
|
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool = False) -> Tuple[ErrorType, list]:
|
|
try:
|
|
conds = [place_faqs.place_id == place_id, place_faqs.deleted == False] # noqa: E712
|
|
conds.append(place_faqs.status.in_(_PUBLISHABLE if publishable_only else _ACTIVE))
|
|
query = select(place_faqs).where(and_(*conds)).order_by(place_faqs.sort_order.asc(), place_faqs.created_at.asc())
|
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
|
return (err_type, list(rows) if err_type == ErrorType.SUCCESS else [])
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, []
|
|
|
|
async def get_faq(self, cdb: AsyncSession, place_id, faq_id) -> Tuple[ErrorType, place_faqs]:
|
|
try:
|
|
query = select(place_faqs).where(
|
|
place_faqs.faq_id == faq_id, place_faqs.place_id == place_id, place_faqs.deleted == False # noqa: E712
|
|
).limit(1)
|
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type, None
|
|
if len(rows) != 1:
|
|
return ErrorType.DB_INVALID_KEY, None
|
|
return ErrorType.SUCCESS, rows[0]
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, None
|
|
|
|
async def add_faq(self, cdb: AsyncSession, faq: place_faqs) -> ErrorType:
|
|
try:
|
|
return await DB_SESSION_MNG.insert(cdb, faq)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED
|
|
|
|
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
|
|
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다.
|
|
|
|
★ 사람이 정정한 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.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)
|
|
)
|
|
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, 0
|
|
|
|
async def transition(self, cdb: AsyncSession, faq_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
|
|
try:
|
|
query = (
|
|
update(place_faqs)
|
|
.where(place_faqs.faq_id == faq_id, place_faqs.status.in_(tuple(from_statuses)), place_faqs.deleted == False) # noqa: E712
|
|
.values(status=to_status, updated_at=GTime.UTC(), **data)
|
|
)
|
|
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
|
|
except Exception as ex:
|
|
LOG.e_no_callstack(ex)
|
|
return ErrorType.DB_RUN_FAILED, 0
|