eduevidence 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (312) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +506 -0
  3. package/README.zh-CN.md +434 -0
  4. package/SKILL.md +195 -0
  5. package/bin/eduevidence.js +127 -0
  6. package/domains/education/manifest.json +183 -0
  7. package/domains/education/outcome_taxonomy.json +127 -0
  8. package/domains/manifest.json +26 -0
  9. package/domains/policy/frame.schema.json +234 -0
  10. package/domains/policy/manifest.json +10 -0
  11. package/domains/policy/methodology_checklist.json +109 -0
  12. package/domains/policy/outcome_taxonomy.json +53 -0
  13. package/domains/policy/references/causal-identification.md +45 -0
  14. package/domains/policy/references/cost-evidence.md +44 -0
  15. package/domains/policy/references/equity.md +42 -0
  16. package/domains/policy/references/evidence-hierarchy.md +41 -0
  17. package/domains/policy/references/implementation-evidence.md +47 -0
  18. package/eduevidence_cli.py +26 -0
  19. package/engine/__init__.py +11 -0
  20. package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
  21. package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
  22. package/engine/__pycache__/bias.cpython-312.pyc +0 -0
  23. package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
  24. package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
  25. package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
  26. package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
  27. package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
  28. package/engine/__pycache__/events.cpython-312.pyc +0 -0
  29. package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
  30. package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
  31. package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
  32. package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
  33. package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
  34. package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
  35. package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
  36. package/engine/__pycache__/ids.cpython-312.pyc +0 -0
  37. package/engine/__pycache__/library.cpython-312.pyc +0 -0
  38. package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
  39. package/engine/__pycache__/living.cpython-312.pyc +0 -0
  40. package/engine/__pycache__/log.cpython-312.pyc +0 -0
  41. package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
  42. package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
  43. package/engine/__pycache__/migration.cpython-312.pyc +0 -0
  44. package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
  45. package/engine/__pycache__/paths.cpython-312.pyc +0 -0
  46. package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
  47. package/engine/__pycache__/planner.cpython-312.pyc +0 -0
  48. package/engine/__pycache__/project.cpython-312.pyc +0 -0
  49. package/engine/__pycache__/projections.cpython-312.pyc +0 -0
  50. package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
  51. package/engine/__pycache__/run.cpython-312.pyc +0 -0
  52. package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
  53. package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
  54. package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
  55. package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
  56. package/engine/__pycache__/update.cpython-312.pyc +0 -0
  57. package/engine/__pycache__/versions.cpython-312.pyc +0 -0
  58. package/engine/analysis.py +308 -0
  59. package/engine/bias.py +178 -0
  60. package/engine/briefs.py +106 -0
  61. package/engine/capabilities.py +99 -0
  62. package/engine/citation_check.py +192 -0
  63. package/engine/contracts.py +117 -0
  64. package/engine/datasets.py +165 -0
  65. package/engine/events.py +67 -0
  66. package/engine/evidence_graph.py +571 -0
  67. package/engine/evidence_review.py +88 -0
  68. package/engine/evidencecore.py +182 -0
  69. package/engine/gap_lens.py +132 -0
  70. package/engine/gaps.py +169 -0
  71. package/engine/graph_store.py +335 -0
  72. package/engine/graph_validate.py +87 -0
  73. package/engine/ids.py +77 -0
  74. package/engine/library.py +268 -0
  75. package/engine/library_builtin.py +301 -0
  76. package/engine/living.py +671 -0
  77. package/engine/log.py +39 -0
  78. package/engine/meta_analysis.py +333 -0
  79. package/engine/meta_synthesis.py +111 -0
  80. package/engine/migration.py +397 -0
  81. package/engine/mode_router.py +72 -0
  82. package/engine/paths.py +15 -0
  83. package/engine/pilot.py +368 -0
  84. package/engine/planner.py +126 -0
  85. package/engine/project.py +118 -0
  86. package/engine/projections.py +240 -0
  87. package/engine/robustness.py +109 -0
  88. package/engine/run.py +85 -0
  89. package/engine/semantics.py +135 -0
  90. package/engine/study_design.py +87 -0
  91. package/engine/synthesis.py +187 -0
  92. package/engine/tribunal.py +408 -0
  93. package/engine/update.py +113 -0
  94. package/engine/versions.py +12 -0
  95. package/install.sh +510 -0
  96. package/integrations/__init__.py +1 -0
  97. package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
  98. package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
  99. package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
  100. package/integrations/agent_mcp.py +856 -0
  101. package/integrations/smart_web_fetch.py +59 -0
  102. package/package.json +50 -0
  103. package/pyproject.toml +55 -0
  104. package/references/applicability-policy.md +88 -0
  105. package/references/education-framing.md +132 -0
  106. package/references/effect_size_formulas.md +35 -0
  107. package/references/evaluation-design.md +111 -0
  108. package/references/evidence-quality.md +79 -0
  109. package/references/grade_framework.md +29 -0
  110. package/references/intervention-design.md +98 -0
  111. package/references/methodology-audit.md +103 -0
  112. package/references/outcome-taxonomy.md +106 -0
  113. package/references/retrieval-protocol.md +142 -0
  114. package/references/skeptic-protocol.md +93 -0
  115. package/references/social_science_pitfalls.md +48 -0
  116. package/references/source-validity.md +140 -0
  117. package/references/tribunal-policy.md +112 -0
  118. package/references/wwc_standards.md +29 -0
  119. package/retrieval/__init__.py +1 -0
  120. package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
  121. package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
  122. package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
  123. package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
  124. package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
  125. package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
  126. package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
  127. package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
  128. package/retrieval/corpus_store.py +181 -0
  129. package/retrieval/dedupe.py +127 -0
  130. package/retrieval/failures.py +90 -0
  131. package/retrieval/fetch.py +435 -0
  132. package/retrieval/search.py +493 -0
  133. package/retrieval/source.py +160 -0
  134. package/retrieval/validate.py +257 -0
  135. package/schemas/agent-mcp-approval.schema.json +57 -0
  136. package/schemas/chart-spec.schema.json +88 -0
  137. package/schemas/cross-model-review.schema.json +28 -0
  138. package/schemas/education-frame.schema.json +106 -0
  139. package/schemas/evaluation.schema.json +35 -0
  140. package/schemas/evidence.schema.json +81 -0
  141. package/schemas/fetch-result.schema.json +119 -0
  142. package/schemas/intervention.schema.json +46 -0
  143. package/schemas/methodology.schema.json +102 -0
  144. package/schemas/report-result.schema.json +381 -0
  145. package/schemas/report-spec.schema.json +130 -0
  146. package/schemas/source.schema.json +311 -0
  147. package/schemas/v2/analysis-plan.schema.json +28 -0
  148. package/schemas/v2/analysis-run.schema.json +33 -0
  149. package/schemas/v2/claim.schema.json +26 -0
  150. package/schemas/v2/dataset-asset.schema.json +40 -0
  151. package/schemas/v2/decision-snapshot.schema.json +53 -0
  152. package/schemas/v2/evidence-link.schema.json +38 -0
  153. package/schemas/v2/finding.schema.json +47 -0
  154. package/schemas/v2/graph-revision.schema.json +30 -0
  155. package/schemas/v2/knowledge-gap.schema.json +40 -0
  156. package/schemas/v2/methodology-audit.schema.json +30 -0
  157. package/schemas/v2/outcome.schema.json +18 -0
  158. package/schemas/v2/project.schema.json +31 -0
  159. package/schemas/v2/research-intent.schema.json +24 -0
  160. package/schemas/v2/run.schema.json +43 -0
  161. package/schemas/v2/source.schema.json +24 -0
  162. package/schemas/v2/study-design.schema.json +67 -0
  163. package/schemas/v2/study.schema.json +37 -0
  164. package/schemas/v3/pilot-outcome.schema.json +132 -0
  165. package/schemas/v3/run-manifest.schema.json +193 -0
  166. package/schemas/v3/synthesis.schema.json +86 -0
  167. package/schemas/v4/drift-report.schema.json +66 -0
  168. package/schemas/v4/evidence-library.schema.json +42 -0
  169. package/schemas/v4/living-subscription.schema.json +55 -0
  170. package/schemas/v4/meta-analysis.schema.json +152 -0
  171. package/schemas/verdict.schema.json +56 -0
  172. package/scripts/__init__.py +0 -0
  173. package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  174. package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
  175. package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
  176. package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
  177. package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
  178. package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
  179. package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
  180. package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
  181. package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
  182. package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
  183. package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
  184. package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
  185. package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
  186. package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
  187. package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
  188. package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
  189. package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
  190. package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
  191. package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
  192. package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
  193. package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
  194. package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
  195. package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
  196. package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
  197. package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
  198. package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
  199. package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
  200. package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
  201. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
  202. package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
  203. package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
  204. package/scripts/audit_dois.py +292 -0
  205. package/scripts/bake_pack.sh +37 -0
  206. package/scripts/benchmark.py +183 -0
  207. package/scripts/benchmark_evaluator.py +371 -0
  208. package/scripts/benchmark_judge.py +535 -0
  209. package/scripts/benchmark_routing.py +120 -0
  210. package/scripts/benchmark_v2.py +304 -0
  211. package/scripts/benchmark_v3.py +552 -0
  212. package/scripts/build_esl_artifacts.py +1921 -0
  213. package/scripts/build_evidence_library.py +307 -0
  214. package/scripts/build_killer_demo.py +295 -0
  215. package/scripts/build_result.py +311 -0
  216. package/scripts/check_version_consistency.py +96 -0
  217. package/scripts/citation_check.py +123 -0
  218. package/scripts/claim_audit.py +157 -0
  219. package/scripts/complexity_gate.py +180 -0
  220. package/scripts/compute_confidence.py +176 -0
  221. package/scripts/dashboard_server.py +536 -0
  222. package/scripts/did_regression.py +315 -0
  223. package/scripts/effect_calculator.py +99 -0
  224. package/scripts/enrich_projects_human_and_lieflat.py +315 -0
  225. package/scripts/evidence_matrix.py +129 -0
  226. package/scripts/evidence_score.py +234 -0
  227. package/scripts/evidence_semantics.py +87 -0
  228. package/scripts/fetch_benchmark.py +177 -0
  229. package/scripts/generate_metrics.py +99 -0
  230. package/scripts/generate_new_projects.py +686 -0
  231. package/scripts/generate_promo.py +192 -0
  232. package/scripts/lint_report_layout.py +182 -0
  233. package/scripts/orchestrator.py +1456 -0
  234. package/scripts/pre_verdict_gate.py +513 -0
  235. package/scripts/quickstart.py +121 -0
  236. package/scripts/rebake_all_5themes.py +88 -0
  237. package/scripts/recompute_demo_quality.py +205 -0
  238. package/scripts/render_report.py +270 -0
  239. package/scripts/render_report_html.py +356 -0
  240. package/scripts/retraction_watch.py +110 -0
  241. package/scripts/run_workspace.py +337 -0
  242. package/scripts/serve_web.py +54 -0
  243. package/scripts/skill_lint.py +150 -0
  244. package/scripts/startup_probe.py +265 -0
  245. package/scripts/sync_killer_demo_report.py +270 -0
  246. package/scripts/test_adversarial_empirical.py +541 -0
  247. package/scripts/validate_schema.py +256 -0
  248. package/skill/agents/education-planner.md +80 -0
  249. package/skill/agents/evaluation-designer.md +74 -0
  250. package/skill/agents/evidence-analyst.md +106 -0
  251. package/skill/agents/evidence-judge.md +111 -0
  252. package/skill/agents/evidence-retriever.md +80 -0
  253. package/skill/agents/intervention-designer.md +82 -0
  254. package/skill/agents/method-reviewer.md +104 -0
  255. package/skill/agents/skeptic.md +89 -0
  256. package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
  257. package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
  258. package/skill/sub-skills/data-analysis/SKILL.md +23 -0
  259. package/skill/sub-skills/ethics-review/SKILL.md +25 -0
  260. package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
  261. package/skill/sub-skills/evidence-review/SKILL.md +18 -0
  262. package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
  263. package/skill/sub-skills/literature-review/SKILL.md +21 -0
  264. package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
  265. package/skill/sub-skills/report-generation/SKILL.md +51 -0
  266. package/skill/sub-skills/research-planning/SKILL.md +21 -0
  267. package/skill/sub-skills/study-design/SKILL.md +16 -0
  268. package/skill/task-briefs/adjudicate.md +17 -0
  269. package/skill/task-briefs/audit.md +15 -0
  270. package/skill/task-briefs/challenge.md +15 -0
  271. package/skill/task-briefs/evaluate.md +13 -0
  272. package/skill/task-briefs/extract.md +16 -0
  273. package/skill/task-briefs/frame.md +17 -0
  274. package/skill/task-briefs/intervene.md +14 -0
  275. package/skill/task-briefs/present.md +16 -0
  276. package/skill/task-briefs/retrieve.md +15 -0
  277. package/visualization/eduevidence-report/assets/base.css +337 -0
  278. package/visualization/eduevidence-report/motion/motion.css +157 -0
  279. package/visualization/eduevidence-report/motion/motion.js +107 -0
  280. package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
  281. package/visualization/eduevidence-report/references/component-catalog.md +145 -0
  282. package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
  283. package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
  284. package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
  285. package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
  286. package/visualization/eduevidence-report/references/motion-system.md +31 -0
  287. package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
  288. package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
  289. package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
  290. package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
  291. package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
  292. package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
  293. package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
  294. package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
  295. package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
  296. package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
  297. package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
  298. package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
  299. package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
  300. package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
  301. package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
  302. package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
  303. package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
  304. package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
  305. package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
  306. package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
  307. package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
  308. package/visualization/eduevidence-report/themes/academic.css +94 -0
  309. package/visualization/eduevidence-report/themes/claude.css +96 -0
  310. package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
  311. package/visualization/eduevidence-report/themes/datalab.css +151 -0
  312. package/visualization/eduevidence-report/themes/presentation.css +140 -0
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """failures.py — Failure states & recovery routing (Smart Web Fetch v3 §11-12).
3
+
4
+ Search failure and fetch failure are different problems and must be told apart:
5
+
6
+ SEARCH_NO_RESULT -> 重新搜索 / 换 discovery provider
7
+ SEARCH_LOW_QUALITY -> 放宽检索式 / 降级接受低权威来源
8
+ FETCH_FAILED -> 换 fetch provider / 回 Discovery 找替代来源
9
+ FETCH_PARTIAL -> 规则确认后才可进入 Evidence Extraction
10
+ SOURCE_INVALID -> 丢弃并找替代
11
+ SOURCE_DUPLICATE -> 去重合并,不算独立证据
12
+
13
+ Recovery principle (v3 §12): never infinite-retry the same fetch. After the
14
+ full degradation chain fails, return to Discovery and find an alternate source
15
+ for the same paper/fact.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from typing import Any
20
+
21
+ FAILURE_STATES = (
22
+ "SEARCH_NO_RESULT",
23
+ "SEARCH_LOW_QUALITY",
24
+ "FETCH_FAILED",
25
+ "FETCH_PARTIAL",
26
+ "SOURCE_INVALID",
27
+ "SOURCE_DUPLICATE",
28
+ "UNSUPPORTED_CLAIM",
29
+ "CONFLICT_UNRESOLVED",
30
+ "SCOPE_MISMATCH",
31
+ "METHODOLOGY_TOO_WEAK",
32
+ "INSUFFICIENT_EVIDENCE",
33
+ "AGENT_MCP_UNAVAILABLE",
34
+ "REPORT_INVALID",
35
+ )
36
+
37
+ RECOVERY_ACTION = {
38
+ "SEARCH_NO_RESULT": "rerun_search_with_broader_terms",
39
+ "SEARCH_LOW_QUALITY": "widen_query_or_accept_lower_authority_tier",
40
+ "FETCH_FAILED": "alternate_fetch_provider_then_alternate_source",
41
+ "FETCH_PARTIAL": "rule_confirm_or_human_confirm_before_extraction",
42
+ "SOURCE_INVALID": "discard_and_find_alternate_source",
43
+ "SOURCE_DUPLICATE": "merge_keep_highest_authority",
44
+ "UNSUPPORTED_CLAIM": "downgrade_claim_or_drop",
45
+ "CONFLICT_UNRESOLVED": "stay_uncertain_do_not_force_adjudication",
46
+ "SCOPE_MISMATCH": "shrink_conclusion_scope",
47
+ "METHODOLOGY_TOO_WEAK": "do_not_use_as_support",
48
+ "INSUFFICIENT_EVIDENCE": "mark_insufficient_evidence",
49
+ "AGENT_MCP_UNAVAILABLE": "degrade_to_platform_native_mode",
50
+ "REPORT_INVALID": "block_publish_rerun_render",
51
+ }
52
+
53
+
54
+ def classify_fetch(fetch_result: dict[str, Any]) -> str:
55
+ """Map a fetch result to the canonical failure/state token."""
56
+ status = fetch_result.get("fetch_status", "FETCH_FAILED")
57
+ if status == "FETCH_VALID":
58
+ validation = fetch_result.get("validation", {})
59
+ if validation.get("passed"):
60
+ return "FETCH_VALID"
61
+ return "FETCH_PARTIAL"
62
+ if status == "FETCH_PARTIAL":
63
+ return "FETCH_PARTIAL"
64
+ return "FETCH_FAILED"
65
+
66
+
67
+ def recovery_plan(state: str, context: dict[str, Any] | None = None) -> dict[str, Any]:
68
+ """Return the recovery plan for a failure state (no infinite retry)."""
69
+ if state not in RECOVERY_ACTION:
70
+ raise ValueError(f"unknown state {state!r}; known: {sorted(RECOVERY_ACTION)}")
71
+ plan: dict[str, Any] = {
72
+ "state": state,
73
+ "action": RECOVERY_ACTION[state],
74
+ "retry": False,
75
+ "note": "",
76
+ }
77
+ ctx = context or {}
78
+ if state == "FETCH_FAILED":
79
+ plan["note"] = (
80
+ f"degradation chain exhausted ({ctx.get('fallback_chain', [])}); "
81
+ "do not retry the same URL — return to Discovery for an alternate source "
82
+ "of the same paper/fact."
83
+ )
84
+ elif state == "FETCH_PARTIAL":
85
+ plan["note"] = "content partially readable; require rule/human confirmation before Evidence Extraction (v3 §8)."
86
+ elif state == "SEARCH_NO_RESULT":
87
+ plan["note"] = "no results for the query; broaden terms or switch discovery provider."
88
+ elif state == "SOURCE_DUPLICATE":
89
+ plan["note"] = "same paper behind mirror URL; merge and keep the highest-authority entry."
90
+ return plan
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env python3
2
+ """fetch.py — Fetch Reliability Layer (Smart Web Fetch 方案 v3 §2-8).
3
+
4
+ Fetch only, never search. Responsibility: given a known URL, reliably read the
5
+ content and clean it into Markdown/text. Degradation chain (v3 §3 / §12):
6
+
7
+ native (builtin)
8
+ -> jina_reader
9
+ -> defuddle (local HTML -> main-text extraction, no third party)
10
+ -> markdown_new
11
+ -> raw_html
12
+ -> FETCH_FAILED
13
+
14
+ Every provider attempt is validated immediately (v3 §7): only
15
+ validation.passed=True ends the chain. HTTP 200 with a captcha/login/error
16
+ page or a too-short body continues to the next provider. Each attempt records
17
+ Fetch Provenance (original_url preserved, the real resp.geturl() as
18
+ resolved_url, provider tracked). Only FETCH_VALID (or rule-confirmed
19
+ FETCH_PARTIAL) may enter Evidence Extraction; FETCH_FAILED must never let the
20
+ model guess content from search snippets (v3 §8).
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import json
26
+ import re
27
+ import urllib.error
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from urllib.parse import urlparse
31
+ from datetime import datetime, timezone
32
+ from html.parser import HTMLParser
33
+ from typing import Any, Callable
34
+
35
+ from retrieval.validate import (
36
+ is_private_url,
37
+ resolves_to_private,
38
+ validate_fetch_result,
39
+ )
40
+ from engine.log import get_log
41
+
42
+ log = get_log("fetch")
43
+
44
+ FETCH_PROVIDERS = ("builtin", "jina_reader", "defuddle", "markdown_new", "raw_html")
45
+ JINA_READER_PREFIX = "https://r.jina.ai/"
46
+ MARKDOWN_NEW_PREFIX = "https://markdown.new/"
47
+ MAX_REDIRECTS = 5
48
+ MAX_BODY_BYTES = 10 * 1024 * 1024 # 10 MB
49
+ USER_AGENT = "EduEvidence/1.0 (+evidence)"
50
+
51
+ # Providers that fetch the original URL directly (locally). A private-network
52
+ # resolution on these aborts the whole chain — third-party providers must
53
+ # never receive private content.
54
+ LOCAL_PROVIDERS = ("builtin", "defuddle", "raw_html")
55
+
56
+
57
+ class _BodyTooLarge(Exception):
58
+ """Raised when a fetch response exceeds MAX_BODY_BYTES."""
59
+
60
+
61
+ class _MaxRedirectHandler(urllib.request.HTTPRedirectHandler):
62
+ """Redirect handler that aborts after max_redirects hops (default 5)."""
63
+
64
+ def __init__(self, max_redirects: int = MAX_REDIRECTS):
65
+ super().__init__()
66
+ self.max_redirects = max_redirects
67
+
68
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
69
+ hops = getattr(req, "_redirect_count", 0)
70
+ if hops >= self.max_redirects:
71
+ raise urllib.error.HTTPError(
72
+ req.full_url, code, f"redirect limit ({self.max_redirects}) exceeded", headers, fp
73
+ )
74
+ new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
75
+ if new_req is not None:
76
+ new_req._redirect_count = hops + 1 # type: ignore[attr-defined]
77
+ return new_req
78
+
79
+
80
+ def _http_get(
81
+ url: str,
82
+ timeout: int = 20,
83
+ max_redirects: int = MAX_REDIRECTS,
84
+ max_bytes: int = MAX_BODY_BYTES,
85
+ ) -> tuple[int, str, str]:
86
+ """GET url; returns (status, body, resolved_url=resp.geturl()).
87
+
88
+ Raises URLError / HTTPError / OSError / ValueError on network problems and
89
+ _BodyTooLarge when the response exceeds max_bytes. Redirects are limited
90
+ to max_redirects hops.
91
+ """
92
+ opener = urllib.request.build_opener(_MaxRedirectHandler(max_redirects))
93
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
94
+ with opener.open(req, timeout=timeout) as resp:
95
+ body = resp.read(max_bytes + 1)
96
+ if len(body) > max_bytes:
97
+ raise _BodyTooLarge(f"response body exceeds {max_bytes} bytes")
98
+ return resp.status, body.decode("utf-8", errors="replace"), resp.geturl()
99
+
100
+
101
+ def _hash(content: str) -> str:
102
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
103
+
104
+
105
+ # ---------------------------------------------------------------- defuddle
106
+
107
+
108
+ _SKIP_TAGS = {
109
+ "script", "style", "nav", "header", "footer", "aside", "iframe",
110
+ "noscript", "svg", "form", "button", "select", "template",
111
+ }
112
+ _MAIN_TAGS = {"main", "article"}
113
+ _BLOCK_TAGS = {
114
+ "p", "div", "section", "article", "main", "blockquote", "pre",
115
+ "li", "tr", "ul", "ol", "table", "h1", "h2", "h3", "h4", "h5", "h6",
116
+ }
117
+
118
+
119
+ class _MainTextExtractor(HTMLParser):
120
+ """html.parser-based main-content extraction (the local 'defuddle')."""
121
+
122
+ def __init__(self) -> None:
123
+ super().__init__(convert_charrefs=True)
124
+ self._skip_depth = 0
125
+ self._main_depth = 0
126
+ self._in_main = False
127
+ self._main_parts: list[str] = []
128
+ self._fallback_parts: list[str] = []
129
+
130
+ def _target(self) -> list[str]:
131
+ return self._main_parts if self._in_main else self._fallback_parts
132
+
133
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
134
+ tag = tag.lower()
135
+ if tag in _SKIP_TAGS:
136
+ self._skip_depth += 1
137
+ return
138
+ if self._skip_depth:
139
+ return
140
+ if tag in _MAIN_TAGS:
141
+ if self._main_depth == 0:
142
+ self._in_main = True
143
+ self._main_depth += 1
144
+ if tag in _BLOCK_TAGS:
145
+ self._target().append("\n")
146
+
147
+ def handle_endtag(self, tag: str) -> None:
148
+ tag = tag.lower()
149
+ if tag in _SKIP_TAGS:
150
+ self._skip_depth = max(0, self._skip_depth - 1)
151
+ return
152
+ if self._skip_depth:
153
+ return
154
+ if tag in _MAIN_TAGS:
155
+ self._main_depth = max(0, self._main_depth - 1)
156
+ if self._main_depth == 0:
157
+ self._in_main = False
158
+ if tag in _BLOCK_TAGS:
159
+ self._target().append("\n")
160
+
161
+ def handle_data(self, data: str) -> None:
162
+ if self._skip_depth:
163
+ return
164
+ self._target().append(data)
165
+
166
+ def result(self) -> str:
167
+ parts = self._main_parts if self._main_parts else self._fallback_parts
168
+ text = "".join(parts)
169
+ text = re.sub(r"[ \t\r\f\v]+", " ", text)
170
+ text = re.sub(r"\n{3,}", "\n\n", text)
171
+ return text.strip()
172
+
173
+
174
+ def extract_main_text(html: str) -> str:
175
+ """Local HTML -> readable-text cleaning (defuddle provider).
176
+
177
+ Drops script/style/nav/header/footer/aside etc. and prefers the text inside
178
+ <main>/<article> when present; falls back to the full cleaned body.
179
+ """
180
+ parser = _MainTextExtractor()
181
+ try:
182
+ parser.feed(html)
183
+ parser.close()
184
+ except Exception:
185
+ # malformed HTML must never crash the degradation chain
186
+ return re.sub(r"<[^>]+>", " ", html)
187
+ return parser.result()
188
+
189
+
190
+ # ------------------------------------------------------------- providers
191
+
192
+
193
+ def _fetch_builtin(url: str, timeout: int) -> tuple[int, str, str]:
194
+ return _http_get(url, timeout=timeout)
195
+
196
+
197
+ def _fetch_jina_reader(url: str, timeout: int) -> tuple[int, str, str]:
198
+ return _http_get(JINA_READER_PREFIX + url, timeout=timeout)
199
+
200
+
201
+ def _fetch_defuddle(url: str, timeout: int) -> tuple[int, str, str]:
202
+ return _http_get(url, timeout=timeout)
203
+
204
+
205
+ def _fetch_markdown_new(url: str, timeout: int) -> tuple[int, str, str]:
206
+ return _http_get(MARKDOWN_NEW_PREFIX + url, timeout=timeout)
207
+
208
+
209
+ def _fetch_raw_html(url: str, timeout: int) -> tuple[int, str, str]:
210
+ return _http_get(url, timeout=timeout)
211
+
212
+
213
+ _PROVIDER_FETCHERS: dict[str, Callable[[str, int], tuple[int, str, str]]] = {
214
+ "builtin": _fetch_builtin,
215
+ "jina_reader": _fetch_jina_reader,
216
+ "defuddle": _fetch_defuddle,
217
+ "markdown_new": _fetch_markdown_new,
218
+ "raw_html": _fetch_raw_html,
219
+ }
220
+
221
+
222
+ def _clean_for_provider(provider: str, body: str) -> str:
223
+ if provider in ("builtin", "raw_html"):
224
+ return _strip_html_to_text(body)
225
+ if provider == "defuddle":
226
+ return extract_main_text(body)
227
+ return body.strip() # jina_reader / markdown_new already return text
228
+
229
+
230
+ def _strip_html_to_text(html: str) -> str:
231
+ """Minimal HTML noise reduction: drop scripts/styles/nav, keep text."""
232
+ html = re.sub(r"(?is)<(script|style|nav|header|footer|aside)[^>]*>.*?</\1>", " ", html)
233
+ html = re.sub(r"(?is)<[^>]+>", " ", html)
234
+ html = re.sub(r"\s+", " ", html)
235
+ return html.strip()
236
+
237
+
238
+ def _build_candidate(
239
+ *,
240
+ original_url: str,
241
+ provider: str,
242
+ status: int,
243
+ body: str,
244
+ resolved_url: str,
245
+ fetched_at: str,
246
+ expect_title: str | None,
247
+ ) -> "FetchResult":
248
+ """Turn one raw provider response into a validated FetchResult candidate."""
249
+ ok = status is not None and status < 400 and bool(body.strip())
250
+ clean = _clean_for_provider(provider, body) if ok else ""
251
+ cand = FetchResult(
252
+ original_url=original_url,
253
+ resolved_url=resolved_url or original_url,
254
+ fetch_provider=provider,
255
+ fetch_status="FETCH_VALID" if ok else "FETCH_FAILED",
256
+ fetched_at=fetched_at,
257
+ raw_size=len(body.encode("utf-8")),
258
+ content=clean,
259
+ )
260
+ cand.clean_size = len(clean.encode("utf-8")) if clean else 0
261
+ cand.content_hash = _hash(clean) if clean else ""
262
+ cand.content_length = cand.clean_size
263
+ if cand.raw_size > 0 and cand.clean_size > 0:
264
+ cand.compression_ratio = 1 - (cand.clean_size / cand.raw_size)
265
+ cand.validation = validate_fetch_result(cand.to_dict(), expect_title=expect_title)
266
+ return cand
267
+
268
+
269
+ def _copy_candidate(target: "FetchResult", cand: "FetchResult", *, status: str) -> None:
270
+ """Copy a candidate's content/provenance into the final result."""
271
+ target.resolved_url = cand.resolved_url
272
+ target.fetch_provider = cand.fetch_provider
273
+ target.fetch_status = status
274
+ target.content = cand.content
275
+ target.raw_size = cand.raw_size
276
+ target.clean_size = cand.clean_size
277
+ target.compression_ratio = cand.compression_ratio
278
+ target.content_hash = cand.content_hash
279
+ target.content_length = cand.content_length
280
+ target.validation = cand.validation
281
+
282
+
283
+ @dataclass
284
+ class FetchResult:
285
+ """One fetch attempt outcome (mirrors schemas/fetch-result.schema.json)."""
286
+
287
+ original_url: str
288
+ resolved_url: str = ""
289
+ fetch_method: str = "smart_web_fetch"
290
+ fetch_provider: str = "builtin"
291
+ fetch_status: str = "FETCH_FAILED"
292
+ fetched_at: str = ""
293
+ content_hash: str = ""
294
+ content_length: int = 0
295
+ raw_size: int = 0
296
+ clean_size: int = 0
297
+ compression_ratio: float = 0.0
298
+ fallback_used: bool = False
299
+ fallback_chain: list[str] = field(default_factory=list)
300
+ content: str = ""
301
+ validation: dict[str, Any] = field(default_factory=dict)
302
+
303
+ def to_dict(self) -> dict[str, Any]:
304
+ return {
305
+ "original_url": self.original_url,
306
+ "resolved_url": self.resolved_url,
307
+ "fetch_method": self.fetch_method,
308
+ "fetch_provider": self.fetch_provider,
309
+ "fetch_status": self.fetch_status,
310
+ "fetched_at": self.fetched_at,
311
+ "content_hash": self.content_hash,
312
+ "content_length": self.content_length,
313
+ "raw_size": self.raw_size,
314
+ "clean_size": self.clean_size,
315
+ "compression_ratio": round(self.compression_ratio, 3),
316
+ "fallback_used": self.fallback_used,
317
+ "fallback_chain": self.fallback_chain,
318
+ "content": self.content if self.fetch_status != "FETCH_FAILED" else "",
319
+ "validation": self.validation,
320
+ }
321
+
322
+
323
+ def fetch_url(
324
+ url: str,
325
+ *,
326
+ use_smart_fetch: bool = True,
327
+ timeout: int = 20,
328
+ expect_title: str | None = None,
329
+ ) -> dict[str, Any]:
330
+ """Fetch a URL through the degradation chain.
331
+
332
+ fetch -> validate -> decide fallback per provider: each attempt is validated
333
+ immediately and only validation.passed=True ends the chain; HTTP 200 with a
334
+ captcha/login/error page or a too-short body continues to the next provider.
335
+ If no provider passes, the best readable attempt is kept as FETCH_PARTIAL
336
+ (rule-confirmed before extraction), otherwise FETCH_FAILED. Never raises
337
+ for content issues.
338
+
339
+ Private targets (original URL private, or a redirect/DNS resolution landing
340
+ on a private network) abort the chain: third-party providers never see
341
+ private content.
342
+ """
343
+ result = FetchResult(original_url=url, fetched_at=datetime.now(timezone.utc).isoformat())
344
+ # Scheme whitelist: refuse non-http(s) BEFORE any read attempt (P1-2) —
345
+ # urllib would happily read file:// with its default handlers.
346
+ if urlparse(url).scheme.lower() not in ("http", "https"):
347
+ result.fetch_status = "FETCH_FAILED"
348
+ result.validation = {
349
+ "passed": False,
350
+ "checks": {"scheme_allowed": False, "http_success": False},
351
+ "issues": [f"unsupported URL scheme: {urlparse(url).scheme!r} (only http/https)"],
352
+ }
353
+ return result.to_dict()
354
+ # Private URLs are handled locally only (no third-party cleaning providers):
355
+ # the chain is trimmed so jina_reader/markdown_new never receive private
356
+ # content (P1-1, invariant stated in LOCAL_PROVIDERS).
357
+ chain = FETCH_PROVIDERS if use_smart_fetch else ("builtin", "raw_html")
358
+ original_private = is_private_url(url)
359
+ if original_private:
360
+ chain = tuple(p for p in chain if p in LOCAL_PROVIDERS)
361
+ best_partial: FetchResult | None = None
362
+
363
+ for provider in chain:
364
+ if result.fetch_status == "FETCH_VALID":
365
+ break
366
+ try:
367
+ status, body, resolved_url = _PROVIDER_FETCHERS[provider](url, timeout)
368
+ except (_BodyTooLarge, urllib.error.URLError, OSError, ValueError) as exc:
369
+ result.fallback_chain.append(f"{provider}:error")
370
+ log.debug("provider error provider=%s url=%s err=%s", provider, url, exc)
371
+ continue
372
+ result.fallback_chain.append(f"{provider}:{status}")
373
+ log.debug("provider attempt provider=%s status=%s url=%s", provider, status, url)
374
+
375
+ # Security gate: a public request that resolves (directly or via DNS)
376
+ # to a private network must abort the whole chain.
377
+ if provider in LOCAL_PROVIDERS and not original_private:
378
+ if is_private_url(resolved_url) or resolves_to_private(resolved_url) is True:
379
+ result.fallback_chain.append(f"{provider}:private_target")
380
+ result.validation = {
381
+ "passed": False,
382
+ "checks": {"private_target": True, "http_success": False},
383
+ "issues": ["fetch resolved to a private/local network; chain aborted"],
384
+ }
385
+ log.warning("private-target abort provider=%s url=%s resolved=%s",
386
+ provider, url, resolved_url)
387
+ return result.to_dict()
388
+
389
+ cand = _build_candidate(
390
+ original_url=url,
391
+ provider=provider,
392
+ status=status,
393
+ body=body,
394
+ resolved_url=resolved_url,
395
+ fetched_at=result.fetched_at,
396
+ expect_title=expect_title,
397
+ )
398
+ if cand.validation.get("passed"):
399
+ # The gate passed: accept this provider's content and stop.
400
+ _copy_candidate(result, cand, status="FETCH_VALID")
401
+ result.fallback_used = provider != "builtin"
402
+ break
403
+ # Validation failed (captcha / login / error / short body / URL
404
+ # mismatch): keep the best readable attempt as FETCH_PARTIAL and
405
+ # continue down the chain.
406
+ if cand.fetch_status == "FETCH_VALID" and (
407
+ best_partial is None or cand.clean_size > best_partial.clean_size
408
+ ):
409
+ best_partial = cand
410
+
411
+ if result.fetch_status != "FETCH_VALID":
412
+ if best_partial is not None:
413
+ _copy_candidate(result, best_partial, status="FETCH_PARTIAL")
414
+ result.fallback_used = True
415
+ log.info("FETCH_PARTIAL url=%s chain=%s", url, result.fallback_chain)
416
+ else:
417
+ result.fetch_status = "FETCH_FAILED"
418
+ result.validation = {
419
+ "passed": False,
420
+ "checks": {"http_success": False, "body_length_ok": False},
421
+ "issues": ["all providers failed"],
422
+ }
423
+ log.warning("FETCH_FAILED url=%s chain=%s", url, result.fallback_chain)
424
+
425
+ return result.to_dict()
426
+
427
+
428
+ if __name__ == "__main__":
429
+ import sys
430
+
431
+ if len(sys.argv) < 2:
432
+ print("usage: fetch.py <url> [expected_title]", file=sys.stderr)
433
+ sys.exit(2)
434
+ print(json.dumps(fetch_url(sys.argv[1], expect_title=sys.argv[2] if len(sys.argv) > 2 else None),
435
+ ensure_ascii=False, indent=2))