수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
521 lines
25 KiB
Python
521 lines
25 KiB
Python
import uuid
|
|
|
|
from fastapi import Depends
|
|
|
|
from common.category_schema import CategorySchemaError, get_schema
|
|
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,
|
|
FactStatus,
|
|
FactWriteOutcome,
|
|
PlaceCategory,
|
|
SourceType,
|
|
)
|
|
from common.logger import LOG
|
|
from common.models.gmodel import UserInfo
|
|
from common.utils.gtime import GTime
|
|
from crud.fact_crud import FactCRUD, IFactCRUD
|
|
from crud.place_crud import PlaceCRUD
|
|
from router.v1.fact.protocol import (
|
|
FactData,
|
|
FieldSpecData,
|
|
Req_TransitionFact,
|
|
Req_UpsertFact,
|
|
Res_CategorySchema,
|
|
Res_ExtractedFact,
|
|
Res_ExtractFacts,
|
|
Res_Fact,
|
|
Res_FactList,
|
|
)
|
|
from services.external.gemini_extract import extract_facts
|
|
from services.intro_summary import summarize_intro
|
|
from services.llm.gemini import GeminiError, GeminiNotConfigured
|
|
|
|
# 사장님이 붙여넣은 원문의 출처 표기. ★ 실제 URL 이 아니라 경로 식별자다 —
|
|
# fact 는 출처가 비면 거부되는데(FACT_SOURCE_REQUIRED), 이 값은 웹 주소가 없다.
|
|
# '어디서 왔는가' 는 여전히 명확하다: 사장님이 화면에 직접 붙여넣었다.
|
|
OWNER_PASTE_SOURCE = "owner:paste"
|
|
|
|
# 자동 출처. CRAWL과 LLM 문장은 아래의 즉시 노출 경로를 먼저 거친다.
|
|
_AUTO_SOURCES = (SourceType.API, SourceType.CRAWL, SourceType.LLM)
|
|
|
|
|
|
class FactService:
|
|
"""fact 기록 + 검증 상태 전이.
|
|
|
|
── 세 갈래 프로세스 ────────────────────────────────────────────────
|
|
생성 : 크롤링 → 노출값(VERIFIED), API → 후보 → 사람이 승인
|
|
업데이트: 같은 값이면 검증 유지(REFRESHED). 다른 CRAWL 값은 바로 교체한다.
|
|
API 값이나 사람 입력·정정본과 충돌하는 값은 후보(PENDING_OWNER)로 적재한다.
|
|
수정 : 사람이 직접 입력 → 노출값 즉시 교체. 정정(CORRECTED)은 잠금 표시가 붙는다
|
|
문장 : LLM 이 쓴 소개문·메타(allow_llm 필드) → 승인 없이 바로 노출값
|
|
(사실이 아니라 **이미 승인된 사실로 쓴 문장**이다 — upsert_fact 주석)
|
|
|
|
핵심은 재수집이 노출 중인 사실을 밀어내지 않는다는 것이다. 밀어내면 사이트에서
|
|
체크인 시간 같은 항목이 사라지고, 그 사이 방문자는 정보를 못 본다.
|
|
|
|
지켜야 하는 규칙:
|
|
1. key 는 사업장 업종 스키마에 있는 것만 (FACT_INVALID_KEY)
|
|
2. owner 가 아닌 출처는 source_url 필수 (FACT_SOURCE_REQUIRED)
|
|
3. LLM 은 스키마가 허용한 문장 필드에만 쓴다 (절대규칙 7)
|
|
4. 크롤링은 승인 없이 노출한다. 사람 입력·정정본은 덮지 않는다.
|
|
LLM 문장도 즉시 노출하되 CORRECTED는 보존한다.
|
|
5. 상태 전이는 FACT_STATUS_TRANSITIONS 에 있는 것만
|
|
"""
|
|
|
|
def __init__(self, crud: IFactCRUD = Depends(FactCRUD), place_crud: PlaceCRUD = Depends(PlaceCRUD)):
|
|
self.crud = crud
|
|
self.place_crud = place_crud
|
|
|
|
# ---- 사업장 로드(회사 스코프) ----
|
|
async def _load_place(self, user_info: UserInfo, place_id: str):
|
|
err_type, place = await DB_SESSION_MNG.execute_lambda(
|
|
places.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
|
|
)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return ErrorType.PLACE_NOT_FOUND, None
|
|
return ErrorType.SUCCESS, place
|
|
|
|
async def _mark_content_updated(self, place_id: str, ts):
|
|
"""★ 노출값이 실제로 바뀌었다 — 이 사업장만 다시 빌드하면 된다는 표시.
|
|
부가 효과라 실패해도 본 흐름을 막지 않는다(다음 변경 때 다시 찍힌다)."""
|
|
err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[places.DBType()],
|
|
[lambda s: self._touch(s, place_id, ts)],
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
LOG.e_no_callstack(f"[fact] content_updated_at 갱신 실패 place={place_id}")
|
|
|
|
async def _touch(self, s, place_id: str, ts):
|
|
from sqlalchemy import update
|
|
|
|
query = update(places).where(places.place_id == uuid.UUID(place_id)).values(content_updated_at=ts, updated_at=ts)
|
|
return await DB_SESSION_MNG.add(s, query)
|
|
|
|
# ---- 업종 스키마 노출(관리 화면 폼 생성용) ----
|
|
async def get_category_schema(self, user_info: UserInfo, place_id: str) -> Res_CategorySchema:
|
|
res = Res_CategorySchema()
|
|
err_type, place = await self._load_place(user_info, place_id)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
try:
|
|
schema = get_schema(PlaceCategory(place.category))
|
|
except (CategorySchemaError, ValueError):
|
|
res.result.SetResult(ErrorType.PLACE_INVALID_CATEGORY)
|
|
return res
|
|
res.category = schema.name
|
|
res.label = schema.label
|
|
res.fields = [FieldSpecData(**spec.to_dict()) for spec in schema.fields.values()]
|
|
return res
|
|
|
|
# ---- 조회 ----
|
|
async def list_facts(self, user_info: UserInfo, place_id: str, unit_id=None, publishable_only: bool = False) -> Res_FactList:
|
|
res = Res_FactList()
|
|
err_type, _place = await self._load_place(user_info, place_id)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
list_err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_facts.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.crud.list_facts(s, uuid.UUID(place_id), unit_id, None, publishable_only, True),
|
|
)
|
|
if list_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(list_err)
|
|
return res
|
|
res.facts = [FactData.model_validate(r) for r in rows]
|
|
await self._attach_summaries(res.facts)
|
|
# ★ 사이트에 나갈 수 있는 건수. 발행 게이트가 보는 숫자와 같은 기준이다.
|
|
res.publishable = sum(1 for r in rows if FactStatus(r.status) in PUBLISHABLE_FACT_STATUSES)
|
|
# 재수집이 올려놓은 확인 대기 건수 — 관리 화면의 '검토할 것' 배지.
|
|
res.pending_review = sum(1 for r in rows if FactStatus(r.status) == FactStatus.PENDING_OWNER)
|
|
return res
|
|
|
|
async def _attach_summaries(self, facts: list) -> None:
|
|
"""intro/room_intro 원문이 길면 캔버스 미리보기용 요약을 얹는다.
|
|
|
|
★ place_facts 에는 저장하지 않는다 — 이 응답(FactData.summary)에만 실린다.
|
|
원문이 짧으면 API 를 부르지 않는다(summarize_intro)."""
|
|
for f in facts:
|
|
f.summary = await summarize_intro(f.key, f.value)
|
|
|
|
# ---- 기록 ----
|
|
async def extract_from_text(self, user_info: UserInfo, place_id: str, text: str) -> Res_ExtractFacts:
|
|
"""사장님이 붙여넣은 원문 → fact 후보.
|
|
|
|
★ 폴백 3단계의 2번이다. TourAPI 에 없고 네이버에도 요금표뿐인 업소
|
|
(실측: 조이모텔)는 이 입구가 없으면 발행 근거가 영영 안 찬다.
|
|
|
|
★ 안전장치는 두 겹이다.
|
|
① services/grounding/extract.py 가 **모델이 적은 근거 문장이 원문에 실제로 있는지**
|
|
대조한다. 없으면 버린다(지어낸 값 차단).
|
|
② 통과한 값도 여기서 upsert_fact 를 그대로 탄다 — 스키마 검사·CORRECTED 잠금 등
|
|
기존 관문을 우회하는 뒷문을 만들지 않는다.
|
|
|
|
★ source_type 은 CRAWL 이 아니라 **OWNER 가 아니다**. 사장님이 붙여넣긴 했지만
|
|
값을 고른 것은 모델이므로 LLM 으로 남긴다 — 출처를 사람으로 위장하면
|
|
"사장님이 확인한 값" 과 구분이 사라진다.
|
|
"""
|
|
res = Res_ExtractFacts()
|
|
err_type, place = await self._load_place(user_info, place_id)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
try:
|
|
schema = get_schema(PlaceCategory(place.category))
|
|
except (CategorySchemaError, ValueError):
|
|
res.result.SetResult(ErrorType.PLACE_INVALID_CATEGORY)
|
|
return res
|
|
|
|
try:
|
|
extracted = await extract_facts(
|
|
place.name, PlaceCategory(place.category), text,
|
|
source_url=OWNER_PASTE_SOURCE,
|
|
)
|
|
except GeminiNotConfigured:
|
|
res.result.SetResult(ErrorType.GENERATOR_NOT_CONFIGURED)
|
|
return res
|
|
except GeminiError as ex:
|
|
LOG.w(f"[fact] 붙여넣기 추출 실패: {type(ex).__name__}: {ex}")
|
|
res.result.SetResult(ErrorType.GENERATOR_CALL_FAILED)
|
|
return res
|
|
|
|
res.rejections = [[label, why] for label, why in extracted.rejected]
|
|
res.rejected = len(extracted.rejected)
|
|
|
|
# 단위(객실·메뉴) 이름을 실제 unit 으로 매핑한다. 없는 단위는 만들지 않는다 —
|
|
# 단위 생성은 수집 파이프라인(ensure_units)의 일이라 여기서 늘리지 않는다.
|
|
unit_map = await self._unit_map(place_id)
|
|
|
|
for fact in extracted.facts:
|
|
spec = schema.get(fact.key)
|
|
row = Res_ExtractedFact(
|
|
key=fact.key,
|
|
label=(spec.label if spec else fact.key),
|
|
value=str(fact.value or ""),
|
|
unit_name=fact.unit_name,
|
|
)
|
|
unit_id = unit_map.get(fact.unit_name) if fact.unit_name else None
|
|
if fact.unit_name and unit_id is None:
|
|
row.reason = f"'{fact.unit_name}' 단위가 아직 없다 — 수집으로 객실·메뉴가 먼저 만들어져야 한다"
|
|
res.facts.append(row)
|
|
res.rejected += 1
|
|
continue
|
|
|
|
write = await self.upsert_fact(user_info, place_id, Req_UpsertFact(
|
|
key=fact.key,
|
|
value=fact.value,
|
|
unit_id=unit_id,
|
|
source_type=SourceType.LLM,
|
|
source_url=OWNER_PASTE_SOURCE,
|
|
))
|
|
if write.result.result == ErrorType.SUCCESS.value:
|
|
row.stored = True
|
|
res.stored += 1
|
|
else:
|
|
row.reason = write.result.desc or "저장 실패"
|
|
res.rejected += 1
|
|
res.facts.append(row)
|
|
|
|
LOG.i(f"[fact] 붙여넣기 추출 — 저장 {res.stored}건 · 반려 {res.rejected}건 (place={place_id})")
|
|
return res
|
|
|
|
async def _unit_map(self, place_id: str) -> dict:
|
|
"""단위 이름 → unit_id. 붙여넣기 값이 어느 객실 것인지 잇는 데만 쓴다."""
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_units.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.place_crud.list_units(s, uuid.UUID(place_id)),
|
|
)
|
|
if err != ErrorType.SUCCESS:
|
|
return {}
|
|
return {r.name: r.unit_id for r in (rows or []) if r.name}
|
|
|
|
|
|
async def upsert_fact(self, user_info: UserInfo, place_id: str, req: Req_UpsertFact) -> Res_Fact:
|
|
"""fact 를 기록한다. 출처에 따라 경로가 갈린다.
|
|
|
|
사람(owner) → 노출값을 직접 교체한다(수정 프로세스)
|
|
크롤링 → 원값을 바로 노출, 직접 입력·정정본과 충돌하면 후보로 보존
|
|
API → 노출값과 같으면 확인 시각만 갱신, 다르면 후보로 적재
|
|
"""
|
|
res = Res_Fact()
|
|
err_type, place = await self._load_place(user_info, place_id)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
# 규칙 1 — 업종 스키마에 있는 key 만
|
|
try:
|
|
schema = get_schema(PlaceCategory(place.category))
|
|
except (CategorySchemaError, ValueError):
|
|
res.result.SetResult(ErrorType.PLACE_INVALID_CATEGORY)
|
|
return res
|
|
spec = schema.get(req.key)
|
|
if spec is None:
|
|
res.result.SetResult(ErrorType.FACT_INVALID_KEY)
|
|
return res
|
|
|
|
# 규칙 2 — owner 가 아니면 출처 URL 필수
|
|
if req.source_type != SourceType.OWNER and not (req.source_url or "").strip():
|
|
res.result.SetResult(ErrorType.FACT_SOURCE_REQUIRED)
|
|
return res
|
|
|
|
# 규칙 3 — ★ LLM 은 사실을 만들지 않는다. 스키마가 허용한 문장 필드에만 쓸 수 있다.
|
|
if req.source_type == SourceType.LLM and not spec.allow_llm:
|
|
res.result.SetResult(ErrorType.FACT_INVALID_KEY)
|
|
return res
|
|
|
|
pid = uuid.UUID(place_id)
|
|
pub_err, published = await DB_SESSION_MNG.execute_lambda(
|
|
place_facts.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.crud.get_published_fact(s, pid, req.unit_id, req.key),
|
|
)
|
|
if pub_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(pub_err)
|
|
return res
|
|
|
|
now = GTime.UTC()
|
|
same_value = published is not None and (published.value or "") == (req.value or "")
|
|
|
|
# 2026-09-14: 크롤링 원값을 바로 표시한다. 사람 입력의 출처까지 바뀌지 않게
|
|
# 동일값 갱신보다 먼저 보호한다.
|
|
if req.source_type == SourceType.CRAWL and published is not None and (
|
|
published.source_type == SourceType.OWNER.value
|
|
or FactStatus(published.status) in LOCKED_FACT_STATUSES
|
|
):
|
|
if same_value:
|
|
res.outcome = FactWriteOutcome.REFRESHED
|
|
return await self._reload(res, pid, published.fact_id)
|
|
return await self._write_candidate(res, pid, req, published, now, spec)
|
|
|
|
# ── 값이 그대로다 — 검증을 초기화하지 않고 '언제 다시 확인했는지'만 갱신 ──
|
|
if same_value:
|
|
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
|
|
place_facts.DBType(),
|
|
lambda s: self.crud.refresh_collected(s, published.fact_id, req.source_type.value, req.source_url, now),
|
|
)
|
|
if run_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(run_err)
|
|
return res
|
|
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 in (SourceType.LLM, SourceType.CRAWL):
|
|
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)
|
|
|
|
async def _write_candidate(self, res, pid, req, published, now, spec):
|
|
"""자동 수집 — 노출값은 건드리지 않고 후보로 적재한다.
|
|
|
|
★ 이게 업데이트 프로세스의 핵심이다. 노출값을 밀어내면 사이트에서 사실이 사라진다.
|
|
노출값이 이미 있으면 PENDING_OWNER(사람 확인 대기), 없으면 UNVERIFIED."""
|
|
target_status = FactStatus.PENDING_OWNER if published is not None else FactStatus.UNVERIFIED
|
|
|
|
cand_err, candidate = await DB_SESSION_MNG.execute_lambda(
|
|
place_facts.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.crud.get_candidate(s, pid, req.unit_id, req.key, req.source_type.value),
|
|
)
|
|
if cand_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(cand_err)
|
|
return res
|
|
|
|
# 같은 출처가 이미 올려둔 후보가 있으면 갱신한다(같은 후보가 계속 쌓이지 않게).
|
|
if candidate is not None:
|
|
run_err, _rc = await DB_SESSION_MNG.execute_lambda_claim(
|
|
place_facts.DBType(),
|
|
lambda s: self.crud.update_candidate(
|
|
s, candidate.fact_id, req.value, req.source_url, target_status.value, now
|
|
),
|
|
)
|
|
if run_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(run_err)
|
|
return res
|
|
res.outcome = FactWriteOutcome.CANDIDATE_UPDATED
|
|
return await self._reload(res, pid, candidate.fact_id)
|
|
|
|
fact = place_facts(
|
|
place_id=pid,
|
|
unit_id=req.unit_id,
|
|
key=req.key,
|
|
value=req.value,
|
|
unit=spec.unit,
|
|
source_type=req.source_type.value,
|
|
source_url=(req.source_url or None),
|
|
collected_at=now,
|
|
status=target_status.value,
|
|
expires_at=req.expires_at,
|
|
)
|
|
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_facts.DBType()],
|
|
[lambda s: self.crud.add_fact(s, fact)],
|
|
)
|
|
if run_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(run_err)
|
|
return res
|
|
res.outcome = FactWriteOutcome.CANDIDATE_CREATED
|
|
res.fact = FactData.model_validate(fact)
|
|
return res
|
|
|
|
async def _replace_published(self, res, place_id, pid, req, published, now, spec, user_info):
|
|
"""직접 입력·크롤링·허용된 LLM 문장을 노출값으로 교체한다.
|
|
|
|
크롤링 자동 노출은 verified_by를 비워 사람이 승인한 이력과 구별한다.
|
|
기존 노출값은 지우지 않고 EXPIRED 이력으로 남긴다."""
|
|
fact = place_facts(
|
|
place_id=pid,
|
|
unit_id=req.unit_id,
|
|
key=req.key,
|
|
value=req.value,
|
|
unit=spec.unit,
|
|
source_type=req.source_type.value,
|
|
source_url=(req.source_url or None),
|
|
collected_at=now,
|
|
verified_by=None if req.source_type == SourceType.CRAWL else uuid.UUID(user_info.user_id),
|
|
verified_at=now,
|
|
status=FactStatus.VERIFIED.value,
|
|
expires_at=req.expires_at,
|
|
)
|
|
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_facts.DBType()],
|
|
[
|
|
lambda s: self._expire_then_ok(s, pid, req, now),
|
|
lambda s: self.crud.add_fact(s, fact),
|
|
*([lambda s: self._reject_others_ok(s, pid, fact, now, fact.fact_id)]
|
|
if req.source_type == SourceType.CRAWL else []),
|
|
],
|
|
)
|
|
if run_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(run_err)
|
|
return res
|
|
|
|
# ★ 노출값이 바뀌었다 → 이 사업장만 재빌드 대상이 된다.
|
|
await self._mark_content_updated(place_id, now)
|
|
res.outcome = FactWriteOutcome.PUBLISHED_REPLACED if published is not None else FactWriteOutcome.PUBLISHED_CREATED
|
|
res.fact = FactData.model_validate(fact)
|
|
return res
|
|
|
|
async def _expire_then_ok(self, s, pid, req, now):
|
|
"""execute_lambda_run 은 각 람다가 ErrorType 을 돌려주길 요구한다 — rowcount 는 버린다."""
|
|
err_type, _rowcount = await self.crud.expire_published(s, pid, req.unit_id, req.key, now)
|
|
return err_type
|
|
|
|
async def _reload(self, res, pid, fact_id):
|
|
_e, row = await DB_SESSION_MNG.execute_lambda(
|
|
place_facts.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.crud.get_fact(s, pid, fact_id),
|
|
)
|
|
res.fact = FactData.model_validate(row) if row is not None else None
|
|
return res
|
|
|
|
# ---- 검증 상태 전이 ----
|
|
async def transition(self, user_info: UserInfo, place_id: str, fact_id: str, req: Req_TransitionFact) -> Res_Fact:
|
|
"""검증 상태를 바꾼다. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다.
|
|
|
|
후보를 노출 상태로 승격시키면 **기존 노출값을 EXPIRED 로 내리고 남은 후보를 정리**한다 —
|
|
이게 업데이트 프로세스의 마지막 단계(사람의 승인)다."""
|
|
res = Res_Fact()
|
|
err_type, _place = await self._load_place(user_info, place_id)
|
|
if err_type != ErrorType.SUCCESS:
|
|
res.result.SetResult(err_type)
|
|
return res
|
|
|
|
pid = uuid.UUID(place_id)
|
|
fid = uuid.UUID(fact_id)
|
|
get_err, fact = await DB_SESSION_MNG.execute_lambda(
|
|
place_facts.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: self.crud.get_fact(s, pid, fid),
|
|
)
|
|
if get_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(ErrorType.FACT_NOT_FOUND)
|
|
return res
|
|
|
|
current = FactStatus(fact.status)
|
|
target = req.status
|
|
if target not in FACT_STATUS_TRANSITIONS.get(current, set()):
|
|
res.result.SetResult(ErrorType.FACT_INVALID_TRANSITION)
|
|
return res
|
|
|
|
now = GTime.UTC()
|
|
data: dict = {}
|
|
if target in PUBLISHABLE_FACT_STATUSES:
|
|
data["verified_by"] = uuid.UUID(user_info.user_id)
|
|
data["verified_at"] = now
|
|
if target == FactStatus.CORRECTED:
|
|
if req.value is None:
|
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
|
return res
|
|
# 사람이 고친 값 — 출처가 owner 로 바뀌고 자동 수집은 이후 후보로만 도전할 수 있다.
|
|
data["value"] = req.value
|
|
data["source_type"] = SourceType.OWNER.value
|
|
|
|
promoting = target in PUBLISHABLE_FACT_STATUSES and current not in PUBLISHABLE_FACT_STATUSES
|
|
funcs = []
|
|
if promoting:
|
|
# 승격 전에 자리를 비운다 — 노출값 유니크(1건) 때문에 순서가 중요하다.
|
|
funcs.append(lambda s: self._expire_published_ok(s, pid, fact, now))
|
|
funcs.append(lambda s: self._transition_ok(s, fid, current, target, data))
|
|
if promoting:
|
|
funcs.append(lambda s: self._reject_others_ok(s, pid, fact, now, fid))
|
|
|
|
run_err = await DB_SESSION_MNG.execute_lambda_run([place_facts.DBType()], funcs)
|
|
if run_err != ErrorType.SUCCESS:
|
|
res.result.SetResult(
|
|
ErrorType.FACT_INVALID_TRANSITION if run_err == ErrorType.DB_EMPTY_DATA else run_err
|
|
)
|
|
return res
|
|
|
|
# 노출값이 바뀌는 전이(승격 / 정정 / 노출값 내림)면 재빌드 대상으로 표시한다.
|
|
if promoting or current in PUBLISHABLE_FACT_STATUSES:
|
|
await self._mark_content_updated(place_id, now)
|
|
|
|
res.outcome = FactWriteOutcome.PUBLISHED_REPLACED if promoting else None
|
|
return await self._reload(res, pid, fid)
|
|
|
|
async def _expire_published_ok(self, s, pid, fact, now):
|
|
err_type, _rc = await self.crud.expire_published(s, pid, fact.unit_id, fact.key, now, except_fact_id=fact.fact_id)
|
|
return err_type
|
|
|
|
async def _reject_others_ok(self, s, pid, fact, now, keep_fact_id):
|
|
err_type, _rc = await self.crud.reject_candidates(s, pid, fact.unit_id, fact.key, now, except_fact_id=keep_fact_id)
|
|
return err_type
|
|
|
|
async def _transition_ok(self, s, fid, current, target, data):
|
|
"""전이가 0행이면 그 사이 다른 사람이 상태를 바꿨다는 뜻 — 트랜잭션을 중단시킨다."""
|
|
err_type, rowcount = await self.crud.transition(s, fid, (current.value,), target.value, data)
|
|
if err_type != ErrorType.SUCCESS:
|
|
return err_type
|
|
return ErrorType.SUCCESS if rowcount else ErrorType.DB_EMPTY_DATA
|