COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다. 생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다. 공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 — 답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다. - common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외 - services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가 겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다) - copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만 - place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만) - SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다 - faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게 - prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다) - shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다 - site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다 - frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다 - postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON, 0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다 - docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md 백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다: test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다 site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과 로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0 0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
117 lines
5.6 KiB
Python
117 lines
5.6 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
|
|
# 목표 수를 채운 공통 질문(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
|