Files
intl_news/llm/pipeline.py
T
simon 3b44f64f66 refactor: 清理历史 AI Agent 文档残留 + 重构 docs/ + Pipeline 健壮性修复
- docs: 删除 CLAUDE.md / continuation.md / english-news-plan.md 及旧版 intlnews_usage.*,
        统一迁移到 docs/{README,architecture,quickstart,usage,pipeline,configuration,deployment,development,faq}.md
- README: 精简为仓库入口,指向 docs/
- configs/sources.yaml: 更新注释指向新文档
- .env.example: 修正 DashScope Embedding 端点说明

Pipeline 修复:
- dedup/llm/embedding/vectorstore/reporter: 过滤 M2 no_content / 空正文,避免污染下游与 Qdrant
- dedup/pipeline: 改为先写唯一文件再写指纹,避免崩溃导致文章永久丢失
- crawler/orchestrator: sources_crawled 改为“尝试数”,成功数 = crawled - failed
- crawler/storage: write_index_jsonl 从文章路径推断日期,修复跨天/测试路径问题
- scheduler/pipeline: STEP_TIMEOUTS 实际生效(SIGALRM)
- scheduler/reporter: emb_count 排除 index.json;日报跳过无原文事件
- vectorstore/pipeline: payload 增加 source_ids;--recreate --all 时空日期也重建 collection
- app/cli: extract/dedup/translate/embed/index/pipeline 支持 --date;embed/index 支持 --all;crawl 全源失败返回非零
- scripts: domestic_full/crawl_8g/crawl_2g/pipeline 安全加载 .env;M1 全失败不标记且最终退出码=1
2026-08-22 20:47:53 +08:00

248 lines
7.3 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.
"""批量翻译 + 事件抽取管道。
输入: data/deduped/{YYYYMMDD}/uniques/{url_hash}.json(M3 去重后唯一条目)
输出: 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"))
article = ProcessedArticle(**data)
# 防御性过滤:旧版数据里可能残留 M2 no_content 唯一篇,
# 避免用空正文调用 LLM。
if article.status != "success" or not article.content.strip():
continue
articles.append(article)
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 客户端(translation 场景配置见 system.yaml llm_scenes.translation)
config = load_llm_config(provider=provider, model=model, scene="translation")
client = make_sync_client(config)
logger.info(
"AI 大模型(场景 translation): provider=%s model=%s",
config.provider, config.model,
)
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)