o2o-site-AEO/solution/backend/services/grounding/story.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

124 lines
4.3 KiB
Python

"""지역 이야기 응답 해석 — 모델이 준 JSON 에서 **쓸 수 있는 항목만** 남긴다."""
import json
import re
from urllib.parse import quote
from common.logger import LOG
# 종류별 '이름 칸' — 이게 비면 화면에 세울 수 없다(제목 없는 카드가 된다).
_TITLE_KEY = {
"songs": "title",
"daily": "title",
"people": "name",
"chronicle": "title",
"reading": "title",
"postcard": "line",
"quiz": "question",
}
# 출처를 **모델에게 받지 않고 코드가 만드는** 종류.
_SEARCH_LINK_KINDS = {"reading"}
def _search_source(title: str, region_label: str) -> dict:
"""제목으로 찾아가는 검색 링크."""
region = (region_label or "").split()[-1] if region_label else ""
query = title if (not region or region[:-1] in title or region in title) else f"{title} {region}"
return {"name": "네이버에서 찾아보기",
"url": "https://search.naver.com/search.naver?query=" + quote(query)}
# 코드펜스를 두르고 오는 경우가 있다.
_FENCE_RE = re.compile(r"^\s*```(?:json)?\s*|\s*```\s*$", re.MULTILINE)
def _payload_text(payload: dict) -> str:
choices = payload.get("choices") or []
if not choices or not isinstance(choices[0], dict):
return ""
return ((choices[0].get("message") or {}).get("content")) or ""
def _first_source(payload: dict) -> dict | None:
"""Perplexity 가 실제로 읽은 첫 출처."""
for row in payload.get("search_results") or []:
if isinstance(row, dict) and (row.get("url") or "").startswith("http"):
return {"name": row.get("title") or row.get("url"), "url": row["url"]}
return None
def _clean_source(value) -> dict | None:
"""모델이 준 source."""
if not isinstance(value, dict):
return None
url = (value.get("url") or "").strip()
if not url.startswith("http"):
return None
return {"name": (value.get("name") or url).strip(), "url": url}
def parse_items(
payload: dict, kind: str, limit: int, region_label: str = ""
) -> tuple[list[dict], list[str]]:
"""(쓸 수 있는 항목, 버린 이유) — 버린 이유는 로그와 잡 결과에 남긴다."""
title_key = _TITLE_KEY.get(kind)
if title_key is None:
raise ValueError(f"모르는 지역 이야기 종류: {kind}")
text = _FENCE_RE.sub("", _payload_text(payload)).strip()
if not text:
return [], ["응답이 비었다"]
try:
envelope = json.loads(text)
except (json.JSONDecodeError, ValueError) as ex:
LOG.w(f"[story] {kind} JSON 파싱 실패: {ex}")
return [], [f"JSON 이 아니다: {ex}"]
if not isinstance(envelope, dict):
return [], ["최상위가 객체가 아니다"]
raw_items = envelope.get("items")
if not isinstance(raw_items, list):
return [], ["items 가 배열이 아니다"]
fallback = _first_source(payload)
out: list[dict] = []
dropped: list[str] = []
for raw in raw_items:
if len(out) >= limit:
break
if not isinstance(raw, dict):
dropped.append("항목이 객체가 아니다")
continue
title = (raw.get(title_key) or "").strip() if isinstance(raw.get(title_key), str) else ""
if not title:
dropped.append(f"{title_key} 가 없다")
continue
item = {k: v for k, v in raw.items() if v not in (None, "", [], {})}
item[title_key] = title
if kind in _SEARCH_LINK_KINDS:
# 출처는 코드가 만든다(위 _SEARCH_LINK_KINDS).
item["source"] = _search_source(title, region_label)
item.pop("verified", None)
out.append(item)
continue
source = _clean_source(raw.get("source")) or fallback
if source is None:
dropped.append(f"{title}: 출처가 없다")
continue
item["source"] = source
# 모델이 "확인" 이라고 우겨도, 대체 출처로 때운 항목은 확인필요다 — 그 URL 은 이 항목이 아니라 이번 검색 전체의 출처다.
if item.get("verified") not in ("확인", "확인필요"):
item["verified"] = "확인필요"
elif _clean_source(raw.get("source")) is None:
item["verified"] = "확인필요"
out.append(item)
return out, dropped