o2o-site-AEO/solution/backend/services/snapshot.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

390 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""빌드 스냅샷 조립 — DB 에서 '사이트에 나갈 것만' 골라 빌더 입력을 만든다."""
import uuid
from datetime import datetime, timezone
from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import (
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_posts, place_reviews, place_songs, place_units,
site_sections, sites, place_social_posts,
)
from common.enums import (
PUBLISHABLE_FACT_STATUSES,
DBWRType,
ErrorType,
FactStatus,
LocalContentStatus,
LocalContentType,
LocalSource,
MediaStatus,
PlaceCategory,
PostStatus,
ReviewStatus,
SongStatus,
)
from common.logger import LOG
from services.external.naver import region_key
_PUBLISHABLE = tuple(s.value for s in PUBLISHABLE_FACT_STATUSES)
# 지역 정보를 종류별로 몇 건까지 박제할지.
_LOCAL_MAX_PER_TYPE = 20
# 지역 이야기는 종류당 **한 행**이다(항목은 body.items 안에 있다 — migrations/0004 `uq_local_contents_kind`).
# 지역 원문(body)에서 스냅샷으로 옮기지 않는 키.
_LOCAL_BODY_DROP = ("raw",)
async def build_snapshot(place) -> dict:
"""사업장 1건의 빌드 스냅샷을 만든다."""
pid = place.place_id if isinstance(place.place_id, uuid.UUID) else uuid.UUID(str(place.place_id))
category = PlaceCategory(place.category)
schema = get_schema(category)
fact_rows = await _select(
select(place_facts).where(
place_facts.place_id == pid,
place_facts.deleted == False, # noqa: E712
place_facts.status.in_(_PUBLISHABLE),
)
)
unit_rows = await _select(
select(place_units).where(place_units.place_id == pid, place_units.deleted == False) # noqa: E712
.order_by(place_units.sort_order.asc())
)
faq_rows = await _select(
select(place_faqs).where(
place_faqs.place_id == pid,
place_faqs.deleted == False, # noqa: E712
place_faqs.status.in_(_PUBLISHABLE),
).order_by(place_faqs.sort_order.asc())
)
# 승인된 사진만.
media_rows = await _select(
select(place_photos).where(
place_photos.place_id == pid,
place_photos.deleted == False, # noqa: E712
place_photos.status == MediaStatus.APPROVED.value,
).order_by(place_photos.sort_order.asc())
)
# 완성(READY)된 최신 곡 하나.
song_rows = await _select(
select(place_songs).where(
place_songs.place_id == pid,
place_songs.deleted == False, # noqa: E712
place_songs.status == SongStatus.READY.value,
).order_by(place_songs.created_at.desc()).limit(1)
)
# 미니 블로그 — 사장님이 승인한 글과 이미 게재된 글.
post_rows = await _select(
select(place_posts).where(
place_posts.place_id == pid,
place_posts.deleted == False, # noqa: E712
place_posts.status.in_((PostStatus.APPROVED.value, PostStatus.PUBLISHED.value)),
).order_by(place_posts.created_at.desc()).limit(200)
)
# 이용 후기 — 검수를 통과한 것만.
review_rows = await _select(
select(place_reviews).where(
place_reviews.place_id == pid,
place_reviews.deleted == False, # noqa: E712
place_reviews.status == ReviewStatus.PUBLISHED.value,
).order_by(place_reviews.published_at.desc()).limit(200)
)
social_rows = await _select(select(place_social_posts).where(
place_social_posts.place_id == pid, place_social_posts.deleted == False, # noqa: E712
place_social_posts.status == 'POSTED', place_social_posts.posted_at.is_not(None),
).order_by(place_social_posts.posted_at.desc()).limit(3))
local_rows = await _local_contents(place)
snapshot = {
"place": {
"name": place.name,
"category": category.value,
"category_name": schema.label,
"road_address": place.road_address,
"address": place.address,
"phone": place.phone,
"latitude": str(place.latitude) if place.latitude is not None else None,
"longitude": str(place.longitude) if place.longitude is not None else None,
},
"facts": [
{
"key": r.key,
"label": (schema.get(r.key).label if schema.get(r.key) else r.key),
"value": r.value,
"unit": r.unit,
"scope": (schema.get(r.key).scope if schema.get(r.key) else "place"),
"unit_id": str(r.unit_id) if r.unit_id else None,
# 게이트가 다시 볼 수 있게 상태를 함께 싣는다(스냅샷은 감사 기록이기도 하다).
"status": r.status,
# 출처와 확인 시각도 박제한다.
"source_type": r.source_type,
"source_url": r.source_url,
"collected_at": _iso(r.collected_at),
"verified_at": _iso(r.verified_at),
}
for r in fact_rows
],
# sort_order 를 함께 싣는다 — 객실·메뉴 순서는 사장님이 정한 것이고, 발행본도 그 순서를 따른다.
"units": [{"unit_id": str(r.unit_id), "name": r.name, "sort_order": r.sort_order} for r in unit_rows],
"faqs": [
{
"faq_id": str(r.faq_id),
"question": r.question,
"answer": r.answer,
# 렌더러가 노출 필터를 한 번 더 걸 수 있게 상태·출처를 싣는다(fact 와 같은 규칙).
"status": r.status,
"generated_by": r.generated_by,
"sort_order": r.sort_order,
}
for r in faq_rows
],
# alt 가 없는 사진은 넣지 않는다 — 빌더가 렌더하지 않고, 접근성·AI 검색 신호도 잃는다.
"media": [
{
"media_id": str(r.media_id),
"url": r.url,
"origin_url": r.origin_url,
"source_type": r.source_type,
"label": r.label,
"alt_text": r.alt_text,
"width": r.width,
"height": r.height,
"sort_order": r.sort_order,
"unit_id": str(r.unit_id) if r.unit_id else None,
}
for r in media_rows
if (r.alt_text or "").strip()
],
# 지역 정보.
"local": local_rows,
# 승인 토큰·계정 자격증명·근거 원문은 공개 스냅샷으로 보내지 않는다.
"social_posts": [{"post_id": str(r.post_id), "provider": r.provider, "body": r.body,
"permalink": r.permalink, "posted_at": r.posted_at.isoformat()}
for r in social_rows],
# 이 숙소의 노래.
"songs": [
{
"song_id": str(r.song_id),
"title": r.title,
"lyrics": r.lyrics,
"style": r.style,
"file_name": r.file_name,
"duration_sec": float(r.duration_sec) if r.duration_sec is not None else None,
}
for r in song_rows
if (r.file_name or "").strip()
],
# 본문과 날짜만 싣는다.
"posts": [
{
"post_id": str(r.post_id),
"body": r.body,
"topic_kind": int(r.topic_kind),
"published_at": (r.published_at or r.approved_at or r.created_at).isoformat(),
}
for r in post_rows
],
# 손님이 적은 표시 이름만 싣는다.
"reviews": [
{
"review_id": str(r.review_id),
"body": r.body,
"nickname": r.nickname or "",
"published_at": (r.published_at or r.created_at).isoformat(),
}
for r in review_rows
],
}
LOG.i(
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])} · 글 {len(snapshot['posts'])} · 후기 {len(snapshot['reviews'])}"
)
return snapshot
async def _local_contents(place) -> dict:
"""사업장의 노출 가능한 지역·주변 정보."""
# getattr 로 읽는다 — 이 함수는 ORM 행뿐 아니라 테스트의 가짜 place 객체도 받는다.
region_code = str(getattr(place, "region_code", None) or "").strip()
if not region_code:
# 저장된 값이 없으면 도로명주소에서 즉석에서 유도한다.
region_code = region_key(
str(getattr(place, "road_address", None) or getattr(place, "address", None) or "")
) or ""
now = datetime.now(timezone.utc)
contents: list[dict] = []
seen: dict[int, int] = {} # 종류별 누적 건수 — 두 캐시를 합쳐 상한을 센다
# ── 지역 캐시(area_contents): 날씨 + 운영자가 수기로 발행한 항목 ──
if region_code:
query = (
select(area_contents)
.where(
area_contents.region_code == region_code,
area_contents.deleted == False, # noqa: E712
# **지역 단위 항목만** 본다 — 날씨와 지역 이야기다(external_id 없이 지역에 한 벌).
area_contents.external_id.is_(None),
area_contents.status == LocalContentStatus.PUBLISHED.value,
or_(area_contents.display_start_at.is_(None), area_contents.display_start_at <= now),
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
.order_by(area_contents.content_type.asc(), area_contents.collected_at.desc())
)
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False)
)
if err != ErrorType.SUCCESS:
# 지역 정보가 없다고 발행을 막지 않는다 — 사업장의 사실이 아니라 곁들이는 정보다.
LOG.w(f"[snapshot] 지역 정보 조회 실패 region={region_code}: {err.name}")
rows = []
contents += _local_rows(rows or [], seen)
# ── 업장 주변: 공용 실체(area_contents) × 사이트 개인화(site_sections) ──
place_id = getattr(place, "place_id", None)
if place_id is not None:
personal = await _site_places(place_id)
if personal:
ids = [uuid.UUID(k) for k in personal if _is_uuid(k)]
shared_q = select(area_contents).where(
area_contents.local_content_id.in_(ids),
area_contents.deleted == False, # noqa: E712
or_(area_contents.display_end_at.is_(None), area_contents.display_end_at > now),
)
err, rows = await DB_SESSION_MNG.execute_lambda(
area_contents.DBType(), DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, shared_q, raise_error=False),
)
if err != ErrorType.SUCCESS:
LOG.w(f"[snapshot] 주변 정보 조회 실패 place={place_id}: {err.name}")
rows = []
merged = []
for row in rows or []:
mine = personal.get(str(row.local_content_id)) or {}
if mine.get("hidden"):
continue
body = dict(row.body if isinstance(row.body, dict) else {})
# 거리만 얹는다.
if mine.get("distanceMeters") is not None:
body["distanceMeters"] = mine["distanceMeters"]
merged.append((row, body, mine.get("distanceMeters")))
merged.sort(key=lambda t: (not bool(t[1].get("imageUrl")), t[2] if t[2] is not None else 1 << 30))
contents += _local_rows(
[_Row(r, b) for r, b, _ in merged], seen, source=LocalSource.TOUR_API.value
)
# ── 여행 일정(place_itineraries) — 업장마다 기간당 한 행 ──────────
itineraries: list[dict] = []
if place_id is not None:
from services.itinerary_llm_service import get_itineraries
itineraries = await get_itineraries(place_id)
return {
"region_code": region_code or None,
"contents": contents,
"itineraries": itineraries,
}
class _Row:
"""area_contents 행 + 사이트 값이 얹힌 body."""
__slots__ = ("content_type", "source", "title", "body", "collected_at", "kind",
"latitude", "longitude")
def __init__(self, row, body):
self.content_type, self.source = row.content_type, row.source
self.title, self.body, self.collected_at, self.kind = row.title, body, row.collected_at, row.kind
self.latitude, self.longitude = row.latitude, row.longitude
def _is_uuid(value: str) -> bool:
try:
uuid.UUID(value)
except (ValueError, AttributeError, TypeError):
return False
return True
async def _site_places(place_id) -> dict:
"""이 사이트의 주변 개인화 맵(ref → {kind, distanceMeters, hidden})."""
q = (
select(site_sections.data)
.join(sites, sites.site_id == site_sections.site_id)
.where(
sites.place_id == place_id,
sites.deleted == False, # noqa: E712
site_sections.section_id == "local",
site_sections.deleted == False, # noqa: E712
)
.limit(1)
)
err, rows = await DB_SESSION_MNG.execute_lambda(
site_sections.DBType(), DBWRType.DB_READ.value, lambda s: DB_SESSION_MNG.execute(s, q, raise_error=False)
)
if err != ErrorType.SUCCESS or not rows:
return {}
data = rows[0]
return (data or {}).get("places") or {} if isinstance(data, dict) else {}
def _local_rows(rows, seen: dict[int, int], source: int | None = None) -> list[dict]:
"""행 → 스냅샷 항목."""
out = []
for row in rows:
content_type = int(row.content_type)
kind = getattr(row, "kind", None)
taken = seen.get(content_type, 0)
# 맛집 수집은 전체 보존인데 여기서 20개로 자르면 발행본만 일부가 사라진다.
if content_type != LocalContentType.RESTAURANT.value and taken >= _LOCAL_MAX_PER_TYPE:
continue
seen[content_type] = taken + 1
body = row.body if isinstance(row.body, dict) else {}
entry = {
"content_type": content_type,
"source": source if source is not None else row.source,
"title": row.title,
"body": {k: v for k, v in body.items() if k not in _LOCAL_BODY_DROP},
"collected_at": _iso(row.collected_at),
}
if kind:
entry["kind"] = kind
# 좌표는 **컬럼**에서 온다.
for key, value in (("latitude", getattr(row, "latitude", None)),
("longitude", getattr(row, "longitude", None))):
if value is not None:
entry[key] = str(value)
out.append(entry)
return out
def _iso(value) -> str | None:
"""datetime → ISO8601 문자열."""
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.isoformat()
async def _select(query) -> list:
"""조회 실패는 **빈 목록**이다 — 미리보기·발행이 조각 하나 때문에 통째로 죽지 않게."""
err, rows = await DB_SESSION_MNG.execute_lambda(
place_facts.DBType(),
DBWRType.DB_READ.value,
lambda s: DB_SESSION_MNG.execute(s, query, raise_error=False),
)
return list(rows) if err == ErrorType.SUCCESS else []