Files
intl_news/llm/models.py
T
simon c72a5ed13a feat: AI 模型按场景独立配置(llm_scenes)+ 去重多来源合并
- system.yaml 新增 llm_scenes(translation / daily_report,含用途/方法/模型要求说明)
- load_llm_config(scene=...) 场景覆盖;日报摘要 temperature 0.3 硬编码 → 配置
- M3 去重:唯一篇记录 source_ids(跨源重复合并,首个来源为 source_id)
- source_ids 经翻译透传至 events,日报事件 source 多来源拼接展示(≤3 个)
- 已部署 pi5:merge 实证 10 源合并;日报 report_id=224 正常入库
2026-08-12 09:56:55 +08:00

149 lines
4.4 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.
"""LLM 翻译 + 投资事件抽取数据模型 (M4)。
EnTranslatedArticle 是 M4 最终落盘格式,包含中英文双语内容和抽取的事件。
EventExtraction 是 LLM JSON 输出直接映射,经 Pydantic 强校验。
"""
import re
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, Field, field_validator
class Sentiment(StrEnum):
"""事件情绪倾向。"""
POSITIVE = "positive" # 利好
NEUTRAL = "neutral" # 中性
NEGATIVE = "negative" # 利空
# 国际财经事件类型(LLM Prompt 中展示)
INTERNATIONAL_EVENT_TYPES: tuple[str, ...] = (
"财报披露",
"并购收购",
"产品发布",
"监管政策",
"宏观经济",
"央行决议",
"行业动态",
"技术突破",
"高管变动",
"诉讼法律",
"市场异动",
"地缘政治",
"大宗商品",
"外汇波动",
"其他",
)
# 美股代码正则:1-5 个大写字母
_US_STOCK_RE = re.compile(r"^[A-Z]{1,5}$")
MIN_IMPORTANCE = 1
MAX_IMPORTANCE = 5
class EventExtraction(BaseModel):
"""LLM 输出的单个事件,直接映射 JSON。"""
event_type: str = Field(..., description="事件类型,见 INTERNATIONAL_EVENT_TYPES")
stock_codes: list[str] = Field(
default_factory=list,
description="涉及美股代码,如 AAPL、TSLA;无相关股票时为空",
)
sentiment: Sentiment = Field(..., description="positive/neutral/negative")
importance: int = Field(
..., ge=MIN_IMPORTANCE, le=MAX_IMPORTANCE, description="1-5 重要程度"
)
summary_zh: str = Field(
default="",
max_length=200,
description="一句话中文事件摘要",
)
@field_validator("stock_codes")
@classmethod
def _validate_stock_codes(cls, v: list[str]) -> list[str]:
"""剔除非美股代码格式、去重、统一大写。"""
cleaned: list[str] = []
seen: set[str] = set()
for code in v:
s = (code or "").strip().upper()
if not s or not _US_STOCK_RE.match(s):
continue
if s not in seen:
seen.add(s)
cleaned.append(s)
return cleaned
@field_validator("event_type")
@classmethod
def _normalize_event_type(cls, v: str) -> str:
s = (v or "").strip()
return s if s else "其他"
class LLMTranslationOutput(BaseModel):
"""LLM 单次调用的完整输出 JSON 映射。"""
title_zh: str = Field(..., description="中文翻译标题")
content_zh: str = Field(..., description="中文翻译正文")
events: list[EventExtraction] = Field(
default_factory=list, description="提取的投资事件列表"
)
class EnTranslatedArticle(BaseModel):
"""M4 最终落盘格式:双语文章 + 抽取事件 + 调用元信息。"""
# ── 来源标识 ──
source_id: str
source_name: str
url: str
url_hash: str
source_ids: list[str] = Field(
default_factory=list,
description="去重合并后的所有来源 ID(透传自 ProcessedArticle.source_ids)",
)
# ── 双语内容 ──
title: str = "" # 英文原标题
title_zh: str = "" # 中文翻译标题
content_en: str = "" # 英文原文
content_zh: str = "" # 中文翻译
publish_time: str = "" # ISO 8601
word_count: int = 0 # 英文词数
word_count_zh: int = 0 # 中文译文字数
# ── 抽取事件 ──
events: list[EventExtraction] = Field(default_factory=list)
# ── 调用元信息 ──
provider: str = "" # deepseek / qwen
model: str = ""
translated_at: datetime = Field(default_factory=datetime.now)
attempts: int = 1 # 实际调用次数(含重试)
prompt_tokens: int | None = None
completion_tokens: int | None = None
def short_summary(self) -> str:
codes = set()
for ev in self.events:
codes.update(ev.stock_codes)
codes_str = ",".join(sorted(codes)[:5]) or "-"
return (
f"[{self.source_id}] {self.title[:30]}... "
f"→ {len(self.events)}events, stocks: {codes_str}"
)
class LLMCallError(Exception):
"""LLM 调用失败(网络 / 解析 / 校验)。"""
def __init__(self, reason: str, *, attempts: int = 0) -> None:
super().__init__(reason)
self.reason = reason
self.attempts = attempts