初始化

This commit is contained in:
2026-07-18 16:13:52 +08:00
parent c0070f0a5c
commit fe8b417ab6
75 changed files with 12898 additions and 1 deletions
+61
View File
@@ -0,0 +1,61 @@
"""LLM 翻译 + 投资事件抽取模块 (M4)。
公共 API:
- load_llm_config / make_sync_client / make_async_client
- translate_and_extract / translate_and_extract_async
- PromptTemplate / parse_translation_json
- translate_all_deduped(批量管道)
- EnTranslatedArticle / EventExtraction / LLMTranslationOutput / Sentiment
"""
from llm.client import (
LLMConfig,
load_llm_config,
make_async_client,
make_sync_client,
)
from llm.extractor import (
DEFAULT_MAX_ATTEMPTS,
MAX_CONTENT_CHARS,
PromptTemplate,
parse_translation_json,
translate_and_extract,
translate_and_extract_async,
)
from llm.models import (
INTERNATIONAL_EVENT_TYPES,
MAX_IMPORTANCE,
MIN_IMPORTANCE,
EnTranslatedArticle,
EventExtraction,
LLMCallError,
LLMTranslationOutput,
Sentiment,
)
from llm.pipeline import translate_all_deduped
__all__ = [
# 客户端
"LLMConfig",
"load_llm_config",
"make_async_client",
"make_sync_client",
# 翻译+抽取
"DEFAULT_MAX_ATTEMPTS",
"MAX_CONTENT_CHARS",
"PromptTemplate",
"parse_translation_json",
"translate_and_extract",
"translate_and_extract_async",
# 批量管道
"translate_all_deduped",
# 模型
"EnTranslatedArticle",
"EventExtraction",
"INTERNATIONAL_EVENT_TYPES",
"LLMCallError",
"LLMTranslationOutput",
"MAX_IMPORTANCE",
"MIN_IMPORTANCE",
"Sentiment",
]
+131
View File
@@ -0,0 +1,131 @@
"""LLM 客户端抽象与工厂。
支持 DeepSeek 和 Qwen(百炼),两者均为 OpenAI 兼容接口,共用 openai SDK。
配置来源:
- .env → API Key / Base URL(密钥和地址)
- configs/system.yaml → provider / model / timeout / temperature(功能配置)
"""
import logging
import os
from dataclasses import dataclass
from pathlib import Path
import yaml
from openai import AsyncOpenAI, OpenAI
logger = logging.getLogger(__name__)
# Provider 默认基址
_DEEPSEEK_DEFAULT_BASE = "https://api.deepseek.com"
_QWEN_DEFAULT_BASE = "https://dashscope.aliyuncs.com/compatible-mode/v1"
# 默认模型
_DEEPSEEK_DEFAULT_MODEL = "deepseek-chat"
_QWEN_DEFAULT_MODEL = "qwen-plus"
def _load_system_config() -> dict:
"""加载 configs/system.yaml 中 llm 段配置。"""
config_path = Path("configs/system.yaml")
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
return raw.get("llm", {})
except Exception:
logger.warning("加载 llm 配置失败,使用空配置")
return {}
@dataclass
class LLMConfig:
"""LLM 调用配置(provider / model / api_key / base_url / 参数)。"""
provider: str # "deepseek" / "qwen"
model: str
api_key: str
base_url: str
timeout_sec: float = 60.0
temperature: float = 0.1
max_tokens: int = 8192
def __post_init__(self) -> None:
if not self.api_key:
raise ValueError(f"LLM provider={self.provider} 的 API key 为空")
def load_llm_config(
provider: str | None = None,
*,
model: str | None = None,
) -> LLMConfig:
"""根据配置文件构造 LLMConfig。
provider 为 None 时读 system.yaml llm.provider,默认 deepseek。
model 为 None 时读 system.yaml 中对应 provider 的 model。
Raises:
ValueError: API key 未配置
"""
config = _load_system_config()
p = (provider or config.get("provider", "deepseek")).lower()
if p == "deepseek":
api_key = os.environ.get("DEEPSEEK_API_KEY", "")
base = os.environ.get("DEEPSEEK_BASE_URL", _DEEPSEEK_DEFAULT_BASE)
m = model or config.get("deepseek_model", _DEEPSEEK_DEFAULT_MODEL)
elif p in ("qwen", "dashscope"):
api_key = os.environ.get("QWEN_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or ""
base = os.environ.get("QWEN_BASE_URL", _QWEN_DEFAULT_BASE)
m = model or config.get("qwen_model", _QWEN_DEFAULT_MODEL)
p = "qwen"
else:
raise ValueError(f"未知 LLM provider: {p!r},仅支持 deepseek / qwen")
if not api_key:
raise ValueError(
f"LLM provider={p} 的 API key 未配置,请检查 .env 中的 "
f"{'DEEPSEEK_API_KEY' if p == 'deepseek' else 'QWEN_API_KEY'}"
)
timeout = float(config.get("timeout_sec", 60.0))
temperature = float(config.get("temperature", 0.1))
max_tokens = int(config.get("max_tokens", 8192))
return LLMConfig(
provider=p,
model=m,
api_key=api_key,
base_url=base,
timeout_sec=timeout,
temperature=temperature,
max_tokens=max_tokens,
)
def make_sync_client(config: LLMConfig) -> OpenAI:
"""构造同步 OpenAI 客户端(指向 DeepSeek/Qwen 兼容端点)。"""
logger.info(
"初始化同步 LLM 客户端: provider=%s model=%s base_url=%s",
config.provider, config.model, config.base_url,
)
return OpenAI(
api_key=config.api_key,
base_url=config.base_url,
timeout=config.timeout_sec,
)
def make_async_client(config: LLMConfig) -> AsyncOpenAI:
"""构造异步 OpenAI 客户端(用于批处理高并发)。"""
logger.info(
"初始化异步 LLM 客户端: provider=%s model=%s base_url=%s",
config.provider, config.model, config.base_url,
)
return AsyncOpenAI(
api_key=config.api_key,
base_url=config.base_url,
timeout=config.timeout_sec,
)
+370
View File
@@ -0,0 +1,370 @@
"""LLM 翻译 + 投资事件抽取主流程。
输入: ProcessedArticleM2/M3 输出)
输出: EnTranslatedArticle(含中英文双语内容 + 抽取事件 + 元信息)
设计:
1. 加载 prompts/translation_and_extraction.md,分离 system/user 模板
2. 单次 LLM 调用完成翻译 + 事件抽取(节省 token)
3. 解析 JSON → Pydantic LLMTranslationOutput 强校验 + 重试
4. 限制正文长度避免触顶 context window
"""
import asyncio
import json
import logging
import re
import time
from pathlib import Path
from openai import AsyncOpenAI, OpenAI
from extractor.models import ProcessedArticle
from llm.client import LLMConfig
from llm.models import (
EnTranslatedArticle,
LLMCallError,
LLMTranslationOutput,
)
logger = logging.getLogger(__name__)
# Prompt 模板路径
DEFAULT_PROMPT_PATH = Path("prompts/translation_and_extraction.md")
# 文章正文截断长度(保守取 8K 字符,避免超出上下文窗口)
MAX_CONTENT_CHARS = 8000
# 重试设置
DEFAULT_MAX_ATTEMPTS = 3
RETRY_BASE_WAIT_SEC = 1.0
RETRY_MAX_WAIT_SEC = 8.0
# Prompt 中 system / user 分隔标记
_SYSTEM_SECTION_START = "## System Prompt"
_USER_SECTION_START = "## User Input"
class PromptTemplate:
"""Prompt 模板加载器。
从 prompts/translation_and_extraction.md 读取模板,
分离 System Prompt 和 User Input 两部分。
User Input 支持 {title} / {source_name} / {publish_time} / {content} 占位符。
"""
def __init__(self, template_path: str | Path = DEFAULT_PROMPT_PATH) -> None:
self._path = Path(template_path)
raw = self._path.read_text(encoding="utf-8")
# 分离 system 和 user 两部分
self._system_prompt, self._user_template = self._parse_template(raw)
@staticmethod
def _parse_template(raw: str) -> tuple[str, str]:
"""解析模板文件,返回 (system_prompt, user_template)。"""
# 找到 ## System Prompt 之后的内容直到 ## User Input
sys_match = re.search(
r"## System Prompt\s*\n(.*?)(?=---\s*\n## User Input)",
raw, re.DOTALL
)
user_match = re.search(
r"## User Input\s*\n(.*)",
raw, re.DOTALL
)
system = sys_match.group(1).strip() if sys_match else ""
user = user_match.group(1).strip() if user_match else raw
return system, user
def render(self, article: ProcessedArticle) -> tuple[str, str]:
"""渲染 Prompt,返回 (system_prompt, user_prompt)。
对正文做截断处理。
"""
content = article.content
if len(content) > MAX_CONTENT_CHARS:
logger.debug(
"文章 %s 超长截断: %d%d",
article.url_hash, len(content), MAX_CONTENT_CHARS,
)
content = content[:MAX_CONTENT_CHARS] + "\n\n[正文过长已截断]"
user_prompt = (
self._user_template
.replace("{title}", article.title)
.replace("{source_name}", article.source_name)
.replace("{publish_time}", article.publish_time or "未知")
.replace("{content}", content)
)
return self._system_prompt, user_prompt
# --------------------------------------------------------------------------- #
# JSON 提取(LLM 偶尔会包 ```json 围栏)
# --------------------------------------------------------------------------- #
def _extract_json_object(text: str) -> str:
"""从 LLM 输出中提取首个 JSON 对象字符串(去围栏 / 取首个 {...})。"""
s = text.strip()
if s.startswith("```"):
s = s.strip("`")
if s.lower().startswith("json"):
s = s[4:].lstrip("\n").lstrip()
if s.endswith("```"):
s = s[:-3]
start = s.find("{")
if start < 0:
return s
depth = 0
for i in range(start, len(s)):
if s[i] == "{":
depth += 1
elif s[i] == "}":
depth -= 1
if depth == 0:
return s[start : i + 1]
return s[start:]
def parse_translation_json(raw: str) -> LLMTranslationOutput:
"""把 LLM 输出文本解析为 LLMTranslationOutput(可能抛 LLMCallError)。"""
payload = _extract_json_object(raw)
try:
obj = json.loads(payload)
except json.JSONDecodeError as e:
raise LLMCallError(f"JSON 解析失败: {e}") from e
if not isinstance(obj, dict):
raise LLMCallError(f"JSON 顶层非对象: {type(obj).__name__}")
try:
return LLMTranslationOutput.model_validate(obj)
except Exception as e:
raise LLMCallError(f"翻译输出 schema 校验失败: {e}") from e
# --------------------------------------------------------------------------- #
# 词数计算
# --------------------------------------------------------------------------- #
def _count_zh_chars(text: str) -> int:
"""统计中文文本字数(汉字 + 数字 + 字母混合)。"""
# 简单统计:去除空白后长度
return len(text.replace(" ", "").replace("\n", "").replace("\r", ""))
# --------------------------------------------------------------------------- #
# 同步 / 异步 LLM 调用
# --------------------------------------------------------------------------- #
def _call_llm_sync(
client: OpenAI,
config: LLMConfig,
system_prompt: str,
user_prompt: str,
) -> tuple[str, dict[str, int | None]]:
"""同步单次 LLM 调用,返回 (raw_text, usage)。"""
resp = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=config.temperature,
max_tokens=config.max_tokens,
response_format={"type": "json_object"},
)
text = resp.choices[0].message.content or ""
usage = {
"prompt_tokens": getattr(resp.usage, "prompt_tokens", None) if resp.usage else None,
"completion_tokens": (
getattr(resp.usage, "completion_tokens", None) if resp.usage else None
),
}
return text, usage
async def _call_llm_async(
client: AsyncOpenAI,
config: LLMConfig,
system_prompt: str,
user_prompt: str,
) -> tuple[str, dict[str, int | None]]:
"""异步单次 LLM 调用。"""
resp = await client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=config.temperature,
max_tokens=config.max_tokens,
response_format={"type": "json_object"},
)
text = resp.choices[0].message.content or ""
usage = {
"prompt_tokens": getattr(resp.usage, "prompt_tokens", None) if resp.usage else None,
"completion_tokens": (
getattr(resp.usage, "completion_tokens", None) if resp.usage else None
),
}
return text, usage
# --------------------------------------------------------------------------- #
# 主入口:翻译 + 事件抽取
# --------------------------------------------------------------------------- #
def translate_and_extract(
client: OpenAI,
config: LLMConfig,
article: ProcessedArticle,
*,
template: PromptTemplate | None = None,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
) -> EnTranslatedArticle:
"""同步翻译 + 事件抽取(单篇文章,带重试)。
Args:
client: OpenAI 同步客户端
config: LLM 配置
article: 待处理的英文新闻
template: Prompt 模板,默认加载 prompts/translation_and_extraction.md
max_attempts: 最大重试次数
Returns:
EnTranslatedArticle 含双语内容 + 事件
Raises:
LLMCallError: 所有重试均失败
"""
tpl = template or PromptTemplate()
system_prompt, user_prompt = tpl.render(article)
last_err: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
raw, usage = _call_llm_sync(client, config, system_prompt, user_prompt)
output = parse_translation_json(raw)
# 检查 content_zh 非空
if not output.content_zh.strip():
raise LLMCallError("LLM 返回的 content_zh 为空")
return EnTranslatedArticle(
source_id=article.source_id,
source_name=article.source_name,
url=article.url,
url_hash=article.url_hash,
title=article.title,
title_zh=output.title_zh,
content_en=article.content,
content_zh=output.content_zh,
publish_time=article.publish_time,
word_count=article.word_count,
word_count_zh=_count_zh_chars(output.content_zh),
events=output.events,
provider=config.provider,
model=config.model,
attempts=attempt,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
)
except LLMCallError as e:
last_err = e
logger.warning(
"LLM 翻译抽取失败 url=%s 尝试 %d/%d: %s",
article.url, attempt, max_attempts, e.reason,
)
except Exception as e:
last_err = e
logger.warning(
"LLM 调用异常 url=%s 尝试 %d/%d: %s: %s",
article.url, attempt, max_attempts, type(e).__name__, e,
)
if attempt < max_attempts:
wait = min(RETRY_BASE_WAIT_SEC * (2 ** (attempt - 1)), RETRY_MAX_WAIT_SEC)
time.sleep(wait)
raise LLMCallError(
f"LLM 翻译抽取放弃,共 {max_attempts} 次尝试: {last_err}",
attempts=max_attempts,
)
async def translate_and_extract_async(
client: AsyncOpenAI,
config: LLMConfig,
article: ProcessedArticle,
*,
template: PromptTemplate | None = None,
max_attempts: int = DEFAULT_MAX_ATTEMPTS,
semaphore: asyncio.Semaphore | None = None,
) -> EnTranslatedArticle:
"""异步翻译 + 事件抽取(批处理用),与同步版逻辑等价。"""
tpl = template or PromptTemplate()
system_prompt, user_prompt = tpl.render(article)
async def _run() -> EnTranslatedArticle:
last_err: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
raw, usage = await _call_llm_async(client, config, system_prompt, user_prompt)
output = parse_translation_json(raw)
if not output.content_zh.strip():
raise LLMCallError("LLM 返回的 content_zh 为空")
return EnTranslatedArticle(
source_id=article.source_id,
source_name=article.source_name,
url=article.url,
url_hash=article.url_hash,
title=article.title,
title_zh=output.title_zh,
content_en=article.content,
content_zh=output.content_zh,
publish_time=article.publish_time,
word_count=article.word_count,
word_count_zh=_count_zh_chars(output.content_zh),
events=output.events,
provider=config.provider,
model=config.model,
attempts=attempt,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
)
except LLMCallError as e:
last_err = e
logger.warning(
"LLM 翻译抽取失败 url=%s 尝试 %d/%d: %s",
article.url, attempt, max_attempts, e.reason,
)
except Exception as e:
last_err = e
logger.warning(
"LLM 调用异常 url=%s 尝试 %d/%d: %s: %s",
article.url, attempt, max_attempts, type(e).__name__, e,
)
if attempt < max_attempts:
wait = min(RETRY_BASE_WAIT_SEC * (2 ** (attempt - 1)), RETRY_MAX_WAIT_SEC)
await asyncio.sleep(wait)
raise LLMCallError(
f"LLM 翻译抽取放弃,共 {max_attempts} 次尝试: {last_err}",
attempts=max_attempts,
)
if semaphore is None:
return await _run()
async with semaphore:
return await _run()
+144
View File
@@ -0,0 +1,144 @@
"""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
+238
View File
@@ -0,0 +1,238 @@
"""批量翻译 + 事件抽取管道。
输入: data/deduped/{YYYYMMDD}/uniques/{url_hash}.jsonM3 去重后唯一条目)
输出: data/events/{YYYYMMDD}/{url_hash}.json
"""
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
import yaml
from crawler.utils import get_news_day
from extractor.models import ProcessedArticle
from llm.client import LLMConfig, load_llm_config, make_sync_client
from llm.extractor import PromptTemplate, translate_and_extract
from llm.models import EnTranslatedArticle, LLMCallError
logger = logging.getLogger(__name__)
# 默认并发数
_DEFAULT_CONCURRENCY = 3
def _load_concurrency() -> int:
"""从 system.yaml 读取 LLM 并发配置。"""
config_path = Path("configs/system.yaml")
if config_path.exists():
try:
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)
return int(raw.get("llm", {}).get("concurrency", _DEFAULT_CONCURRENCY))
except Exception:
pass
return _DEFAULT_CONCURRENCY
def _load_deduped_articles(
date_str: str,
) -> list[ProcessedArticle]:
"""加载指定日期的去重后唯一条目。
Args:
date_str: 日期 YYYYMMDD
Returns:
ProcessedArticle 列表
"""
base_dir = Path(f"data/deduped/{date_str}/uniques")
if not base_dir.exists():
return []
articles: list[ProcessedArticle] = []
for json_file in sorted(base_dir.glob("*.json")):
try:
data = json.loads(json_file.read_text(encoding="utf-8"))
articles.append(ProcessedArticle(**data))
except (json.JSONDecodeError, Exception) as e:
logger.warning("解析去重文章失败 %s: %s", json_file, e)
return articles
def _process_one(
article: ProcessedArticle,
client,
config: LLMConfig,
template: PromptTemplate,
) -> EnTranslatedArticle | None:
"""处理单篇文章的翻译 + 事件抽取。返回 None 表示失败。"""
try:
return translate_and_extract(
client=client,
config=config,
article=article,
template=template,
)
except LLMCallError as e:
logger.error(
"翻译抽取最终失败 url=%s: %s", article.url, e.reason
)
return None
except Exception as e:
logger.exception("翻译抽取未预期异常 url=%s: %s", article.url, e)
return None
def translate_all_deduped(
date_str: str | None = None,
*,
provider: str | None = None,
model: str | None = None,
concurrency: int | None = None,
) -> dict:
"""对去重后的所有唯一条目执行翻译 + 事件抽取。
Args:
date_str: 日期 YYYYMMDD,默认当前新闻日
provider: LLM provider,默认从 system.yaml 读取
model: LLM model,默认从 system.yaml 读取
concurrency: 并发数,默认从 system.yaml 读取
Returns:
统计 dict
"""
if date_str is None:
date_str = get_news_day()
concurrency = concurrency or _load_concurrency()
logger.info("══════ 开始翻译+事件抽取,日期: %s,并发: %d ══════",
date_str, concurrency)
# 加载去重后的文章
articles = _load_deduped_articles(date_str)
if not articles:
logger.warning("去重目录无文章: data/deduped/%s/uniques/", date_str)
return {"date": date_str, "total": 0, "success": 0, "failed": 0, "elapsed_sec": 0}
# 初始化 LLM 客户端
config = load_llm_config(provider=provider, model=model)
client = make_sync_client(config)
template = PromptTemplate()
# 输出目录
out_dir = Path(f"data/events/{date_str}")
out_dir.mkdir(parents=True, exist_ok=True)
start_time = datetime.now()
success = 0
failed = 0
# 增量:跳过已翻译的文章
new_articles = []
skipped = 0
for a in articles:
if (out_dir / f"{a.url_hash}.json").exists():
skipped += 1
else:
new_articles.append(a)
if skipped > 0:
logger.info("增量跳过 %d 篇已翻译,剩余 %d 篇待处理", skipped, len(new_articles))
articles = new_articles
# 并发处理
with ThreadPoolExecutor(max_workers=concurrency) as executor:
futures = {
executor.submit(_process_one, article, client, config, template): article
for article in articles
}
for future in as_completed(futures):
article = futures[future]
try:
result = future.result()
except Exception as e:
logger.error("并发任务异常 url=%s: %s", article.url, e)
failed += 1
continue
if result is None:
failed += 1
continue
# 写入输出文件
out_file = out_dir / f"{result.url_hash}.json"
out_file.write_text(
result.model_dump_json(indent=2, ensure_ascii=False),
encoding="utf-8",
)
success += 1
logger.info(
"[%s] ✅ %s%s (%d events, %d zh chars)",
result.source_id,
result.title[:40],
result.title_zh[:30],
len(result.events),
result.word_count_zh,
)
elapsed = (datetime.now() - start_time).total_seconds()
# 写入事件索引
_write_event_index(date_str, success, failed, elapsed, config)
logger.info(
"══════ 翻译+事件抽取完成: 成功 %d / 失败 %d / 总计 %d,耗时 %.1f 秒 ══════",
success, failed, len(articles), elapsed,
)
return {
"date": date_str,
"total": len(articles),
"success": success,
"failed": failed,
"elapsed_sec": elapsed,
"provider": config.provider,
"model": config.model,
}
def _write_event_index(
date_str: str,
success: int,
failed: int,
elapsed_sec: float,
config: LLMConfig,
) -> None:
"""写入事件索引文件。
Args:
date_str: 日期
success: 成功数
failed: 失败数
elapsed_sec: 耗时(秒)
config: LLM 配置
"""
out_dir = Path(f"data/events/{date_str}")
out_dir.mkdir(parents=True, exist_ok=True)
index_data = {
"date": date_str,
"success": success,
"failed": failed,
"elapsed_sec": round(elapsed_sec, 1),
"provider": config.provider,
"model": config.model,
"generated_at": datetime.now().isoformat(),
}
index_path = out_dir / "index.json"
index_path.write_text(
json.dumps(index_data, indent=2, ensure_ascii=False),
encoding="utf-8",
)
logger.info("事件索引已写入: %s", index_path)