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 # 목표 수를 채운 공통 질문(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)), ) .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