여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
124 lines
4.3 KiB
Python
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
|