Files
2026-07-18 16:13:52 +08:00

145 lines
4.3 KiB
Python

"""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