431d4fe010
야간 수집 뉴스 (KST 00:00~05:00) topic×country 비교 분석 1페이지 카드.
Phase 4 Global Digest 와 코드/로직/테이블 분리, 알고리즘만 services/clustering_common 공유.
Backend 신규:
- migrations/255_morning_briefings.sql: morning_briefings + briefing_topics
(briefing_date UNIQUE, UNIQUE(briefing_id,topic_rank), FK CASCADE,
historical_* 3컬럼 nullable, cluster_members JSONB, country_perspectives
JSONB, status 4-state success|partial|failed|empty)
- app/models/briefing.py: SQLAlchemy ORM
- app/services/briefing/loader.py: KST 5h 윈도우 + news_sources prefix
fallback (Phase 4 패턴 미러) + historical candidate pool 로더
- app/services/briefing/clustering.py: cluster_global topic-first
(LAMBDA=ln(2)/2h, MIN_COUNTRIES_PER_TOPIC=2, MAX_TOPICS=7)
- app/services/briefing/comparator.py: call_primary 26B + JSON envelope
sanitize (cap perspectives 10 / divergences 3 / convergences 2 /
quotes 5) + fallback row 고정 형태 + retrieve_historical cosine top-K
- app/services/briefing/pipeline.py: load→cluster→select(K=7,λ=0.6)
→historical→compare→status 4-state→delete+insert transaction
- app/workers/briefing_worker.py: APScheduler/수동 호출 공용 진입점,
600s hard cap
- app/prompts/briefing_comparative.txt: 한국어 비교 분석 JSON 프롬프트,
{articles_block} + {historical_block} 2섹션, 인용 금지 라벨
- app/api/briefing.py: GET /latest, GET ?date=, POST /regenerate?date=
(admin, sync delete+insert tx, regenerated:true)
Backend 수정:
- app/main.py: briefing_router 등록 (/api/briefing prefix). scheduler
등록은 PR-3 에서.
- app/services/digest/selection.py: select_for_llm 매개변수화 (K, λ
caller 주입). Phase 4 동작은 default 값으로 보존.
Historical 정책:
- BRIEFING_HISTORICAL_ENABLED env flag, default off.
- flag off → historical_* 컬럼 모두 NULL, prompt {historical_block} 빈
라벨, retrieval 호출 안 함.
- flag on (PR-1b 에서 enable) → cluster centroid 와 과거 30일 doc
embedding cosine top-K 5 (sim≥0.70), prompt 에 주입.
Country canonical (실측 확인 후):
- documents.country 컬럼 부재 확정
- document_chunks.country 매칭률 0% (chunks 자체가 뉴스에 안 만들어짐)
- 유일 country 신호 = news_sources prefix 매핑 (Phase 4 와 동일)
Tests:
- tests/test_briefing_historical.py: 3 경로 회귀 (flag off/on with
fixture/on zero match) + sanitize cap + fallback row 형태.
Verification: PR-1.8 에서 GPU 컨테이너 pytest + 수동 regenerate.
253 lines
8.5 KiB
Python
253 lines
8.5 KiB
Python
"""Cluster → 26B MLX 비교 분석 호출 + JSON envelope + historical context + fallback row.
|
|
|
|
Plan §"LLM Parse 실패 시 Fallback Topic Row (고정 형태)":
|
|
LLM JSON parse 2회 재시도 후 실패 → 고정 형태 fallback 저장 (drop 금지).
|
|
|
|
Plan §"Historical Context":
|
|
BRIEFING_HISTORICAL_ENABLED=true 시 cluster centroid 와 historical candidate
|
|
cosine top-K 5 (similarity ≥0.70) 추출 → 프롬프트 {historical_block} 주입.
|
|
LLM 응답 envelope 의 historical_context 옵션 필드.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
from ai.client import parse_json_response
|
|
from core.utils import setup_logger
|
|
from services.clustering_common import normalize_vector
|
|
|
|
logger = setup_logger("briefing_comparator")
|
|
|
|
LLM_CALL_TIMEOUT = 25 # 초. Phase 4 와 동일
|
|
HISTORICAL_TOP_K = 5
|
|
HISTORICAL_SIMILARITY_MIN = 0.70
|
|
HISTORICAL_WINDOW_DAYS = 30
|
|
|
|
# JSON envelope cap (프롬프트 + 후처리 양쪽 강제)
|
|
MAX_PERSPECTIVES = 10
|
|
MAX_DIVERGENCES = 3
|
|
MAX_CONVERGENCES = 2
|
|
MAX_KEY_QUOTES = 5
|
|
MAX_PERSPECTIVE_SUMMARY_LEN = 240 # 한국어 1~2문장 ≤120자 × 2
|
|
MAX_HISTORICAL_CONTEXT_LEN = 240
|
|
FALLBACK_HEADLINE = "LLM 분석 실패로 원문 기사 묶음만 표시합니다."
|
|
FALLBACK_TOPIC_LABEL = "주요 뉴스 묶음"
|
|
|
|
_llm_sem = asyncio.Semaphore(1)
|
|
_PROMPT_PATH = Path(__file__).resolve().parent.parent.parent / "prompts" / "briefing_comparative.txt"
|
|
_PROMPT_TEMPLATE: str | None = None
|
|
|
|
|
|
def historical_enabled() -> bool:
|
|
return os.environ.get("BRIEFING_HISTORICAL_ENABLED", "false").lower() in {"1", "true", "yes"}
|
|
|
|
|
|
def _load_prompt() -> str:
|
|
global _PROMPT_TEMPLATE
|
|
if _PROMPT_TEMPLATE is None:
|
|
_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8")
|
|
return _PROMPT_TEMPLATE
|
|
|
|
|
|
def _build_articles_block(selected: list[dict]) -> str:
|
|
lines = []
|
|
for i, m in enumerate(selected, start=1):
|
|
country = m.get("country") or "??"
|
|
source = m.get("ai_sub_group") or ""
|
|
text = (m.get("ai_summary_truncated") or m.get("ai_summary") or m.get("title") or "").strip()
|
|
lines.append(f"[{i}] ({country} · {source}) {text}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _build_historical_block(historical_docs: list[dict]) -> str:
|
|
if not historical_docs:
|
|
return "(과거 참고 자료 없음)"
|
|
lines = ["※ 이전 30일 흐름 참고용 — 본 분석에서 직접 인용 금지, 맥락 파악 용도."]
|
|
for i, d in enumerate(historical_docs, start=1):
|
|
text = (d.get("ai_summary") or d.get("title") or "").strip()
|
|
# historical 은 ai_summary 가 길 수 있어 200자 cap
|
|
if len(text) > 200:
|
|
text = text[:200] + "…"
|
|
lines.append(f"[H{i}] {text}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def build_prompt(selected: list[dict], historical_docs: list[dict]) -> str:
|
|
template = _load_prompt()
|
|
articles_block = _build_articles_block(selected)
|
|
historical_block = _build_historical_block(historical_docs)
|
|
return template.replace("{articles_block}", articles_block).replace(
|
|
"{historical_block}", historical_block
|
|
)
|
|
|
|
|
|
def retrieve_historical(
|
|
cluster: dict,
|
|
candidates: list[dict],
|
|
*,
|
|
top_k: int = HISTORICAL_TOP_K,
|
|
sim_min: float = HISTORICAL_SIMILARITY_MIN,
|
|
) -> list[dict]:
|
|
"""cluster centroid 와 candidate pool 의 cosine top-K (sim ≥ sim_min).
|
|
|
|
candidates 가 비어있거나 sim 미달 시 빈 list.
|
|
"""
|
|
if not candidates:
|
|
return []
|
|
centroid = cluster["centroid"]
|
|
scored = []
|
|
for d in candidates:
|
|
v = normalize_vector(d["embedding"])
|
|
sim = float(np.dot(centroid, v))
|
|
if sim >= sim_min:
|
|
scored.append((sim, d))
|
|
scored.sort(key=lambda x: -x[0])
|
|
return [d for _, d in scored[:top_k]]
|
|
|
|
|
|
async def _try_call_llm(client: Any, prompt: str) -> str:
|
|
async with _llm_sem:
|
|
return await asyncio.wait_for(
|
|
client.call_primary(prompt),
|
|
timeout=LLM_CALL_TIMEOUT,
|
|
)
|
|
|
|
|
|
def _truncate_str(s: Any, limit: int) -> str:
|
|
if not isinstance(s, str):
|
|
return ""
|
|
s = s.strip()
|
|
if len(s) > limit:
|
|
s = s[:limit].rstrip() + "…"
|
|
return s
|
|
|
|
|
|
def _sanitize_envelope(parsed: dict, cluster: dict) -> dict | None:
|
|
"""LLM 응답 envelope 검증 + cap 강제. None 반환 시 fallback 발동."""
|
|
if not isinstance(parsed, dict):
|
|
return None
|
|
|
|
topic_label = _truncate_str(parsed.get("topic_label"), 120)
|
|
headline = _truncate_str(parsed.get("headline"), 200)
|
|
if not topic_label or not headline:
|
|
return None
|
|
|
|
# country_perspectives
|
|
raw_persp = parsed.get("country_perspectives")
|
|
perspectives = []
|
|
if isinstance(raw_persp, list):
|
|
for p in raw_persp[:MAX_PERSPECTIVES]:
|
|
if not isinstance(p, dict):
|
|
continue
|
|
country = _truncate_str(p.get("country"), 10).upper()
|
|
summary = _truncate_str(p.get("summary"), MAX_PERSPECTIVE_SUMMARY_LEN)
|
|
ids = p.get("article_ids") or []
|
|
if not isinstance(ids, list):
|
|
ids = []
|
|
ids = [int(x) for x in ids if isinstance(x, (int, str)) and str(x).isdigit()]
|
|
if country and summary:
|
|
perspectives.append({"country": country, "summary": summary, "article_ids": ids})
|
|
if not perspectives:
|
|
# 비교 분석 가치가 없는 응답 → fallback
|
|
return None
|
|
|
|
def _str_array(key: str, cap: int, item_limit: int) -> list[str]:
|
|
raw = parsed.get(key)
|
|
if not isinstance(raw, list):
|
|
return []
|
|
out = []
|
|
for it in raw[:cap]:
|
|
t = _truncate_str(it, item_limit)
|
|
if t:
|
|
out.append(t)
|
|
return out
|
|
|
|
divergences = _str_array("divergences", MAX_DIVERGENCES, 200)
|
|
convergences = _str_array("convergences", MAX_CONVERGENCES, 200)
|
|
|
|
# key_quotes: [{country, source, quote}]
|
|
raw_quotes = parsed.get("key_quotes")
|
|
quotes = []
|
|
if isinstance(raw_quotes, list):
|
|
for q in raw_quotes[:MAX_KEY_QUOTES]:
|
|
if not isinstance(q, dict):
|
|
continue
|
|
entry = {
|
|
"country": _truncate_str(q.get("country"), 10).upper(),
|
|
"source": _truncate_str(q.get("source"), 60),
|
|
"quote": _truncate_str(q.get("quote"), 240),
|
|
}
|
|
if entry["quote"]:
|
|
quotes.append(entry)
|
|
|
|
historical_context = _truncate_str(parsed.get("historical_context"), MAX_HISTORICAL_CONTEXT_LEN) or None
|
|
|
|
return {
|
|
"topic_label": topic_label,
|
|
"headline": headline,
|
|
"country_perspectives": perspectives,
|
|
"divergences": divergences,
|
|
"convergences": convergences,
|
|
"key_quotes": quotes,
|
|
"historical_context": historical_context,
|
|
"llm_fallback_used": False,
|
|
}
|
|
|
|
|
|
def _make_fallback(cluster: dict) -> dict:
|
|
"""Plan §"Fallback Topic Row (고정 형태)". drop 금지, country_perspectives 빈 list."""
|
|
return {
|
|
"topic_label": FALLBACK_TOPIC_LABEL,
|
|
"headline": FALLBACK_HEADLINE,
|
|
"country_perspectives": [],
|
|
"divergences": [],
|
|
"convergences": [],
|
|
"key_quotes": [],
|
|
"historical_context": None,
|
|
"llm_fallback_used": True,
|
|
}
|
|
|
|
|
|
async def compare_cluster_with_fallback(
|
|
client: Any,
|
|
cluster: dict,
|
|
selected: list[dict],
|
|
historical_docs: list[dict] | None = None,
|
|
) -> dict:
|
|
"""1 cluster 비교 분석. LLM 2회 재시도 → 실패 시 fallback row.
|
|
|
|
Returns:
|
|
sanitized envelope dict (Plan §"LLM 프롬프트 출력 envelope") + llm_fallback_used.
|
|
"""
|
|
historical_docs = historical_docs or []
|
|
prompt = build_prompt(selected, historical_docs)
|
|
|
|
for attempt in range(2):
|
|
try:
|
|
raw = await _try_call_llm(client, prompt)
|
|
except asyncio.TimeoutError:
|
|
logger.warning(
|
|
f"LLM timeout {LLM_CALL_TIMEOUT}s "
|
|
f"(attempt={attempt + 1}, cluster size={len(cluster['members'])})"
|
|
)
|
|
continue
|
|
except Exception as e:
|
|
logger.warning(f"LLM 호출 실패 attempt={attempt + 1}: {e}")
|
|
continue
|
|
|
|
parsed = parse_json_response(raw)
|
|
sanitized = _sanitize_envelope(parsed, cluster) if parsed else None
|
|
if sanitized:
|
|
return sanitized
|
|
logger.warning(
|
|
f"envelope 검증 실패 attempt={attempt + 1} "
|
|
f"(raw_len={len(raw) if raw else 0}, parsed_keys={list(parsed.keys()) if isinstance(parsed, dict) else None})"
|
|
)
|
|
|
|
return _make_fallback(cluster)
|