"""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 # ── 双语内容 ── 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