Commit cdf420fc authored by 郭阔's avatar 郭阔

perf(harvest): attempt3 先按磁盘证据重建结果,零 token 替代整场重跑

runtime 类失败(结构化交付失败)时,采集成果其实已全部落盘(registry+归档):
新增 reconstruct-result.py 按运行窗口内的 registry 条目重建 structured_output
(归档文件逐篇核验),注入原信封——秒级完成、零 token,跳过备用 provider 重跑。
窗口内无交付(business 失败/真没干成活)自动落回原 attempt3 重跑路径。
实测:ft 14:25 窗口重建 40 篇核验通过;空窗口 exit 3 正确回退。
parent 53d6062f
......@@ -254,6 +254,20 @@ for ATTEMPT in 1 2 3; do
fi
fi
if [ "$ATTEMPT" = "3" ]; then
# 先尝试磁盘重建:runtime 类失败=活已干完只是交卷失败,成果都在
# registry/归档里——重建结果零 token 秒级完成,不再整场重跑。
# (business 失败=没干成活,窗口内必无交付,重建自然落空走重跑)
if [ "${_RETRY_KIND:-}" = "runtime" ]; then
if python3 "$SCRIPT_DIR/reconstruct-result.py" \
--source "$SOURCE" --count "$COUNT" \
--start-epoch "$START_EPOCH" --json-out "$JSON_OUT" \
--skill-config "$(nh_skill_dir)/config.json" \
--vault-root "${OBSIDIAN_VAULT_PATH:-$HOME/NewsHub}" \
--date-tag "$DATE_TAG"; then
echo "[attempt 3/3] ✅ 已按磁盘证据重建结果,跳过备用 provider 重跑"
break
fi
fi
if [ -n "${FALLBACK_PROVIDER:-}" ] && [ "${FALLBACK_PROVIDER}" != "${PROVIDER:-}" ]; then
cp -f "$JSON_OUT" "${JSON_OUT}.attempt2" 2>/dev/null || true
cp -f "$RAW_OUT" "${RAW_OUT}.attempt2" 2>/dev/null || true
......
#!/usr/bin/env python3
"""reconstruct-result.py - 从磁盘证据重建采集结果,替代 attempt3 整场重跑。
场景:attempt1/2 的采集工作已全部落盘(归档+registry),仅结构化交付失败。
此时备用 provider 从头重采纯属浪费——直接按 registry 里本次运行窗口内的
条目重建 structured_output,零 token 成本,秒级完成。
退出码:0=重建成功(≥1 篇,已写入 JSON_OUT);3=窗口内无交付(应照常走重跑)。
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
def parse_iso(ts):
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
except (ValueError, AttributeError):
return 0.0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", required=True)
ap.add_argument("--count", type=int, default=20)
ap.add_argument("--start-epoch", type=float, required=True, help="本次运行开始时间(date +%s)")
ap.add_argument("--json-out", required=True, help="最后一次尝试的信封,重建结果注入其中")
ap.add_argument("--skill-config", required=True, help="技能 config.json(取 vaultFolder)")
ap.add_argument("--vault-root", default=str(Path.home() / "NewsHub"))
ap.add_argument("--date-tag", default=None, help="运行日 YYYYMMDD(默认今天)")
args = ap.parse_args()
try:
cfg = json.loads(Path(args.skill_config).read_text(encoding="utf-8"))
except (OSError, ValueError):
print("[reconstruct] 技能配置不可读,放弃重建", file=sys.stderr)
return 3
folder = next((s.get("vaultFolder") for s in cfg.get("sources", [])
if s.get("id") == args.source), None)
if not folder:
print(f"[reconstruct] 源 {args.source} 无 vaultFolder,放弃重建", file=sys.stderr)
return 3
date_tag = args.date_tag or datetime.now().strftime("%Y%m%d")
day_dir = Path(args.vault_root).expanduser() / folder / date_tag
registry = day_dir / "registry.json"
if not registry.is_file():
print(f"[reconstruct] 无 registry: {registry}", file=sys.stderr)
return 3
try:
entries = json.loads(registry.read_text(encoding="utf-8")).get("articles", [])
except (OSError, ValueError):
return 3
arts = []
for e in entries:
if parse_iso(e.get("processedAt", "")) < args.start_epoch:
continue
archive = day_dir / e.get("originalFilename", "")
if not archive.is_file():
continue
arts.append({
"url": e.get("url", ""),
"title": e.get("titleEn") or e.get("titleZh") or e.get("url", ""),
"published_at": e.get("date", ""),
"archive_path": str(archive),
"status": "SUCCESS",
"failure_type": None,
"failure_reason": None,
"quality_evidence": [],
})
if not arts:
print(f"[reconstruct] 运行窗口({datetime.fromtimestamp(args.start_epoch):%H:%M} 起)内无交付", file=sys.stderr)
return 3
so = {
"schema_version": 1,
"source": args.source,
"requested_count": args.count,
"attempted_count": len(arts),
"succeeded_count": len(arts),
"failed_count": 0,
"status": "SUCCESS",
"source_exhausted": False,
"exhaustion_reason": "",
"warnings": ["结构化交付失败,结果由磁盘证据重建(registry+归档核验);"
"如个别文章摘要/推送未竟,下次运行 finalize 会自动补齐"],
"failures": [],
"articles": arts,
}
# 保留原信封的运行元数据(成本/会话等),只补上结构化结果
try:
envelope = json.loads(Path(args.json_out).read_text(encoding="utf-8"))
if not isinstance(envelope, dict):
raise ValueError
except (OSError, ValueError):
envelope = {"is_error": False, "result": "ok"}
envelope["structured_output"] = so
envelope["is_error"] = False
Path(args.json_out).write_text(json.dumps(envelope, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"[reconstruct] 重建成功:{len(arts)} 篇(窗口内 registry 条目,归档文件已核验)")
return 0
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment