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,493 @@
1
+ #!/usr/bin/env python3
2
+ """retrieval/search.py — Multi-Channel Hybrid Search Engine.
3
+
4
+ Supports both:
5
+ 1. Zero-Config Academic & Open Search Channels (No API Keys Required):
6
+ - OpenAlex (250M+ scholarly works with DOIs, abstracts, citations)
7
+ - Semantic Scholar (Academic papers & citations)
8
+ - CrossRef (Official DOI metadata registry)
9
+ - AIHot (AI & EdTech dynamic trend feed / search via aihot.virxact.com)
10
+ - AgentSearch / ArXiv (Open scientific papers)
11
+ - DuckDuckGo (Zero-auth general web search fallback)
12
+
13
+ 2. User-Configured Search Channels (Key-Based):
14
+ - Tavily (TAVILY_API_KEY)
15
+ - Brave Search (BRAVE_API_KEY)
16
+ - SerpAPI (SERPAPI_API_KEY)
17
+ - Serper (SERPER_API_KEY)
18
+ - Exa (EXA_API_KEY)
19
+ - Bocha (BOCHA_API_KEY)
20
+
21
+ Pure stdlib HTTP client with robust error handling, SSL verification,
22
+ timeout safeguards, and intelligent multi-source deduplication.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import re
29
+ import urllib.error
30
+ import urllib.parse
31
+ import urllib.request
32
+ from dataclasses import asdict, dataclass, field
33
+ from typing import Any, List, Optional
34
+
35
+ USER_AGENT = "EduEvidence-Research-Agent/4.0 (+https://eduevidence.ai)"
36
+ DEFAULT_TIMEOUT = 12
37
+
38
+ from engine.log import get_log # noqa: E402
39
+
40
+ log = get_log("search")
41
+
42
+
43
+ @dataclass
44
+ class SearchHit:
45
+ title: str
46
+ url: str
47
+ snippet: str
48
+ provider: str
49
+ doi: Optional[str] = None
50
+ year: Optional[int] = None
51
+ citation_count: Optional[int] = None
52
+ authors: List[str] = field(default_factory=list)
53
+ is_academic: bool = False
54
+ score: float = 1.0
55
+
56
+ def to_dict(self) -> dict:
57
+ return asdict(self)
58
+
59
+
60
+ def _safe_get_json(url: str, headers: Optional[dict] = None, timeout: int = DEFAULT_TIMEOUT) -> Optional[dict]:
61
+ req_headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
62
+ if headers:
63
+ req_headers.update(headers)
64
+ req = urllib.request.Request(url, headers=req_headers)
65
+ try:
66
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
67
+ if resp.status == 200:
68
+ data = resp.read().decode("utf-8", errors="replace")
69
+ return json.loads(data)
70
+ except Exception:
71
+ return None
72
+ return None
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # 1. Zero-Config Providers
77
+ # ---------------------------------------------------------------------------
78
+
79
+ class OpenAlexProvider:
80
+ """Zero-Config search over OpenAlex 250M+ scholarly works."""
81
+ name = "openalex"
82
+
83
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
84
+ hits = []
85
+ encoded = urllib.parse.quote(query)
86
+ url = f"https://api.openalex.org/works?search={encoded}&per-page={min(limit, 25)}"
87
+ data = _safe_get_json(url)
88
+ if not data or "results" not in data:
89
+ return hits
90
+
91
+ for item in data.get("results", []):
92
+ title = item.get("display_name") or item.get("title") or "Untitled Paper"
93
+ doi = item.get("doi")
94
+ primary_loc = item.get("primary_location") or {}
95
+ landing_url = primary_loc.get("landing_page_url") or doi or f"https://openalex.org/{item.get('id', '')}"
96
+
97
+ # Reconstruct abstract inverted index if present
98
+ snippet = ""
99
+ inv = item.get("abstract_inverted_index")
100
+ if inv and isinstance(inv, dict):
101
+ words = {}
102
+ for w, pos_list in inv.items():
103
+ for p in pos_list:
104
+ words[p] = w
105
+ snippet = " ".join(words[p] for p in sorted(words.keys())[:100])
106
+ if not snippet:
107
+ snippet = title
108
+
109
+ authors = [
110
+ a.get("author", {}).get("display_name", "")
111
+ for a in item.get("authorships", [])
112
+ if a.get("author", {}).get("display_name")
113
+ ]
114
+
115
+ hits.append(SearchHit(
116
+ title=title,
117
+ url=landing_url,
118
+ snippet=snippet,
119
+ provider=self.name,
120
+ doi=doi.replace("https://doi.org/", "") if doi else None,
121
+ year=item.get("publication_year"),
122
+ citation_count=item.get("cited_by_count", 0),
123
+ authors=authors[:5],
124
+ is_academic=True,
125
+ score=1.2,
126
+ ))
127
+ return hits
128
+
129
+
130
+ class SemanticScholarProvider:
131
+ """Zero-Config search over Semantic Scholar Graph API."""
132
+ name = "semanticscholar"
133
+
134
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
135
+ hits = []
136
+ encoded = urllib.parse.quote(query)
137
+ fields = "title,abstract,authors,year,citationCount,isOpenAccess,externalIds,url"
138
+ url = f"https://api.semanticscholar.org/graph/v1/paper/search?query={encoded}&limit={min(limit, 20)}&fields={fields}"
139
+ data = _safe_get_json(url)
140
+ if not data or "data" not in data:
141
+ return hits
142
+
143
+ for item in data.get("data", []):
144
+ title = item.get("title") or "Untitled"
145
+ ext = item.get("externalIds") or {}
146
+ doi = ext.get("DOI")
147
+ paper_url = item.get("url") or (f"https://doi.org/{doi}" if doi else None) or f"https://www.semanticscholar.org/paper/{item.get('paperId')}"
148
+ snippet = item.get("abstract") or title
149
+ authors = [a.get("name", "") for a in item.get("authors", []) if a.get("name")]
150
+
151
+ hits.append(SearchHit(
152
+ title=title,
153
+ url=paper_url,
154
+ snippet=snippet,
155
+ provider=self.name,
156
+ doi=doi,
157
+ year=item.get("year"),
158
+ citation_count=item.get("citationCount", 0),
159
+ authors=authors[:5],
160
+ is_academic=True,
161
+ score=1.15,
162
+ ))
163
+ return hits
164
+
165
+
166
+ class CrossRefProvider:
167
+ """Zero-Config search over official CrossRef DOI registry."""
168
+ name = "crossref"
169
+
170
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
171
+ hits = []
172
+ encoded = urllib.parse.quote(query)
173
+ url = f"https://api.crossref.org/works?query={encoded}&rows={min(limit, 20)}"
174
+ data = _safe_get_json(url)
175
+ if not data or "message" not in data or "items" not in data["message"]:
176
+ return hits
177
+
178
+ for item in data["message"]["items"]:
179
+ titles = item.get("title", [])
180
+ title = titles[0] if titles else "Untitled"
181
+ doi = item.get("DOI")
182
+ url_link = item.get("URL") or (f"https://doi.org/{doi}" if doi else "")
183
+
184
+ # Author names
185
+ authors = []
186
+ for a in item.get("author", []):
187
+ given = a.get("given", "")
188
+ family = a.get("family", "")
189
+ name = f"{given} {family}".strip()
190
+ if name:
191
+ authors.append(name)
192
+
193
+ # Year
194
+ issued = item.get("issued", {}).get("date-parts", [[None]])
195
+ year = issued[0][0] if issued and issued[0] else None
196
+
197
+ hits.append(SearchHit(
198
+ title=title,
199
+ url=url_link,
200
+ snippet=item.get("abstract", title),
201
+ provider=self.name,
202
+ doi=doi,
203
+ year=year if isinstance(year, int) else None,
204
+ citation_count=item.get("is-referenced-by-count", 0),
205
+ authors=authors[:5],
206
+ is_academic=True,
207
+ score=1.1,
208
+ ))
209
+ return hits
210
+
211
+
212
+ class AIHotProvider:
213
+ """Zero-Config search / dynamic feed for AI & EdTech developments."""
214
+ name = "aihot"
215
+
216
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
217
+ hits = []
218
+ encoded = urllib.parse.quote(query)
219
+ url = f"https://aihot.virxact.com/api/search?q={encoded}&limit={min(limit, 15)}"
220
+ data = _safe_get_json(url, timeout=6)
221
+ if data and isinstance(data, dict):
222
+ items = data.get("data") or data.get("results") or data.get("items") or []
223
+ for item in items:
224
+ title = item.get("title") or "AIHot News"
225
+ link = item.get("url") or item.get("link") or "https://aihot.virxact.com"
226
+ snippet = item.get("summary") or item.get("content") or title
227
+ hits.append(SearchHit(
228
+ title=title,
229
+ url=link,
230
+ snippet=snippet,
231
+ provider=self.name,
232
+ is_academic=False,
233
+ score=0.9,
234
+ ))
235
+ return hits
236
+
237
+
238
+ class AgentSearchProvider:
239
+ """Zero-Config SciPhi / ArXiv Open Scientific Search."""
240
+ name = "agentsearch"
241
+
242
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
243
+ hits = []
244
+ # ArXiv API fallback (pure stdlib XML-based open search)
245
+ encoded = urllib.parse.quote(query)
246
+ url = f"https://export.arxiv.org/api/query?search_query=all:{encoded}&start=0&max_results={min(limit, 10)}"
247
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
248
+ try:
249
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
250
+ if resp.status == 200:
251
+ text = resp.read().decode("utf-8", errors="replace")
252
+ # Quick regex extraction
253
+ entries = re.findall(r"<entry>(.*?)</entry>", text, re.DOTALL)
254
+ for entry in entries:
255
+ title_m = re.search(r"<title>(.*?)</title>", entry, re.DOTALL)
256
+ summary_m = re.search(r"<summary>(.*?)</summary>", entry, re.DOTALL)
257
+ id_m = re.search(r"<id>(.*?)</id>", entry, re.DOTALL)
258
+ published_m = re.search(r"<published>(\d{4})", entry)
259
+
260
+ title = re.sub(r"\s+", " ", title_m.group(1).strip()) if title_m else "ArXiv Paper"
261
+ snippet = re.sub(r"\s+", " ", summary_m.group(1).strip()) if summary_m else title
262
+ link = id_m.group(1).strip() if id_m else ""
263
+ year = int(published_m.group(1)) if published_m else None
264
+
265
+ hits.append(SearchHit(
266
+ title=title,
267
+ url=link,
268
+ snippet=snippet,
269
+ provider=self.name,
270
+ year=year,
271
+ is_academic=True,
272
+ score=1.1,
273
+ ))
274
+ except Exception:
275
+ pass
276
+ return hits
277
+
278
+
279
+ class DuckDuckGoProvider:
280
+ """Zero-Config privacy web search fallback."""
281
+ name = "duckduckgo"
282
+
283
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
284
+ hits = []
285
+ encoded = urllib.parse.quote(query)
286
+ url = f"https://html.duckduckgo.com/html/?q={encoded}"
287
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
288
+ try:
289
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
290
+ if resp.status == 200:
291
+ html_text = resp.read().decode("utf-8", errors="replace")
292
+ results = re.findall(r'<a class="result__snippet[^>]*href="([^"]+)"[^>]*>(.*?)</a>', html_text)
293
+ for link, snippet in results[:limit]:
294
+ clean_snippet = re.sub(r"<[^>]+>", "", snippet).strip()
295
+ hits.append(SearchHit(
296
+ title=clean_snippet[:80],
297
+ url=link,
298
+ snippet=clean_snippet,
299
+ provider=self.name,
300
+ is_academic=False,
301
+ score=0.8,
302
+ ))
303
+ except Exception:
304
+ pass
305
+ return hits
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # 2. Configured Providers (API Keys)
310
+ # ---------------------------------------------------------------------------
311
+
312
+ class TavilyProvider:
313
+ """Tavily Search API."""
314
+ name = "tavily"
315
+
316
+ def __init__(self, api_key: Optional[str] = None):
317
+ self.api_key = api_key or os.environ.get("TAVILY_API_KEY", "")
318
+
319
+ def is_available(self) -> bool:
320
+ return bool(self.api_key)
321
+
322
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
323
+ if not self.is_available():
324
+ return []
325
+ hits = []
326
+ payload = json.dumps({"query": query, "max_results": limit, "search_depth": "advanced"}).encode("utf-8")
327
+ req = urllib.request.Request(
328
+ "https://api.tavily.com/search",
329
+ data=payload,
330
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", "User-Agent": USER_AGENT}
331
+ )
332
+ try:
333
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
334
+ data = json.loads(resp.read().decode("utf-8"))
335
+ for item in data.get("results", []):
336
+ hits.append(SearchHit(
337
+ title=item.get("title", "Untitled"),
338
+ url=item.get("url", ""),
339
+ snippet=item.get("content", ""),
340
+ provider=self.name,
341
+ score=1.3,
342
+ ))
343
+ except Exception:
344
+ pass
345
+ return hits
346
+
347
+
348
+ class BraveSearchProvider:
349
+ """Brave Search API."""
350
+ name = "brave"
351
+
352
+ def __init__(self, api_key: Optional[str] = None):
353
+ self.api_key = api_key or os.environ.get("BRAVE_API_KEY", "")
354
+
355
+ def is_available(self) -> bool:
356
+ return bool(self.api_key)
357
+
358
+ def search(self, query: str, limit: int = 10) -> List[SearchHit]:
359
+ if not self.is_available():
360
+ return []
361
+ hits = []
362
+ encoded = urllib.parse.quote(query)
363
+ url = f"https://api.search.brave.com/res/v1/web/search?q={encoded}&count={min(limit, 20)}"
364
+ req = urllib.request.Request(
365
+ url,
366
+ headers={"Accept": "application/json", "X-Subscription-Token": self.api_key, "User-Agent": USER_AGENT}
367
+ )
368
+ try:
369
+ with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as resp:
370
+ data = json.loads(resp.read().decode("utf-8"))
371
+ for item in data.get("web", {}).get("results", []):
372
+ hits.append(SearchHit(
373
+ title=item.get("title", "Untitled"),
374
+ url=item.get("url", ""),
375
+ snippet=item.get("description", ""),
376
+ provider=self.name,
377
+ score=1.25,
378
+ ))
379
+ except Exception:
380
+ pass
381
+ return hits
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # 3. Multi-Channel Orchestrator
386
+ # ---------------------------------------------------------------------------
387
+
388
+ class MultiSearchRouter:
389
+ """Orchestrates zero-config and configured search channels with deduplication."""
390
+
391
+ def __init__(self):
392
+ self.zero_config_academic = [
393
+ OpenAlexProvider(),
394
+ SemanticScholarProvider(),
395
+ CrossRefProvider(),
396
+ AgentSearchProvider(),
397
+ ]
398
+ self.zero_config_web = [
399
+ AIHotProvider(),
400
+ DuckDuckGoProvider(),
401
+ ]
402
+ self.configured_providers = [
403
+ TavilyProvider(),
404
+ BraveSearchProvider(),
405
+ ]
406
+
407
+ def get_provider_status(self) -> List[dict]:
408
+ status = []
409
+ for p in self.zero_config_academic:
410
+ status.append({"provider": p.name, "type": "academic_zero_config", "status": "active", "requires_key": False})
411
+ for p in self.zero_config_web:
412
+ status.append({"provider": p.name, "type": "web_zero_config", "status": "active", "requires_key": False})
413
+ for p in self.configured_providers:
414
+ avail = p.is_available()
415
+ status.append({
416
+ "provider": p.name,
417
+ "type": "commercial_api",
418
+ "status": "active" if avail else "unconfigured",
419
+ "requires_key": True
420
+ })
421
+ return status
422
+
423
+ def search(self, query: str, limit: int = 15, academic_only: bool = False) -> List[SearchHit]:
424
+ all_hits: List[SearchHit] = []
425
+ seen_urls = set()
426
+
427
+ # 1. Try configured high-priority commercial providers if active
428
+ if not academic_only:
429
+ for cp in self.configured_providers:
430
+ if cp.is_available():
431
+ try:
432
+ hits = cp.search(query, limit=limit)
433
+ for h in hits:
434
+ if h.url not in seen_urls:
435
+ seen_urls.add(h.url)
436
+ all_hits.append(h)
437
+ except Exception:
438
+ pass
439
+
440
+ # 2. Run Zero-Config Academic Providers
441
+ for ap in self.zero_config_academic:
442
+ try:
443
+ hits = ap.search(query, limit=limit)
444
+ for h in hits:
445
+ if h.url not in seen_urls:
446
+ seen_urls.add(h.url)
447
+ all_hits.append(h)
448
+ except Exception:
449
+ pass
450
+
451
+ # 3. Run Zero-Config Web/Dynamic Providers if not academic_only
452
+ if not academic_only:
453
+ for wp in self.zero_config_web:
454
+ try:
455
+ hits = wp.search(query, limit=5)
456
+ for h in hits:
457
+ if h.url not in seen_urls:
458
+ seen_urls.add(h.url)
459
+ all_hits.append(h)
460
+ except Exception:
461
+ pass
462
+
463
+ # 4. Fallback to verified offline domain corpus if external search returned 0 hits
464
+ if not all_hits:
465
+ try:
466
+ from retrieval.corpus_store import DomainCorpusStore
467
+ log.info("external channels empty; falling back to offline corpus query=%r", query)
468
+ all_hits = DomainCorpusStore.search_offline(query, limit=limit)
469
+ except Exception:
470
+ pass
471
+
472
+ # 5. Sort by score descending (academic papers prioritized)
473
+ all_hits.sort(key=lambda x: (x.score, x.citation_count or 0), reverse=True)
474
+ log.debug("search query=%r academic_only=%s hits=%d", query, academic_only, len(all_hits))
475
+ return all_hits[:limit]
476
+
477
+
478
+ search_router = MultiSearchRouter()
479
+
480
+
481
+ def search_evidence(query: str, limit: int = 15, academic_only: bool = False) -> List[dict]:
482
+ """Top-level convenience entry for searching evidence across all channels."""
483
+ hits = search_router.search(query, limit=limit, academic_only=academic_only)
484
+ return [h.to_dict() for h in hits]
485
+
486
+
487
+ if __name__ == "__main__":
488
+ import sys
489
+ q = sys.argv[1] if len(sys.argv) > 1 else "AI tutoring mastery learning outcomes"
490
+ print(f"[*] Searching across multi-channel hybrid engine for: {q!r}")
491
+ results = search_evidence(q, limit=5)
492
+ print(f"[+] Found {len(results)} search hits:")
493
+ print(json.dumps(results, indent=2, ensure_ascii=False))
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env python3
2
+ """source.py — Source Registry (总体实施计划 §11 / Smart Web Fetch v3 §9, §14).
3
+
4
+ Unified source structure; Evidence Objects reference only source_id. The fetch
5
+ provider is a reading path and must NEVER be shown as the citation target
6
+ (v3 §6): r.jina.ai / markdown.new / defuddle are not papers.
7
+
8
+ source_id is year-independent (P1-05): it is derived from the first author's
9
+ surname + a title slug, so a corrected publication year never changes every
10
+ downstream reference.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import re
16
+ from typing import Any
17
+ from urllib.parse import urlparse
18
+
19
+ AUTHORITY_LEVELS = {
20
+ "tier1_paper_doi": 1,
21
+ "tier2_academic_database": 2,
22
+ "tier3_professional_institution": 3,
23
+ "tier4_news_secondary": 4,
24
+ "tier5_general_web": 5,
25
+ }
26
+
27
+ _TITLE_STOPWORDS = {
28
+ "a", "an", "the", "of", "in", "on", "for", "with", "and", "or", "to",
29
+ "from", "by", "at", "as", "is", "are", "was", "were", "be", "been",
30
+ }
31
+
32
+
33
+ def title_fingerprint(title: str) -> str:
34
+ """Normalized title fingerprint for dedup (v3 §13): lowercase, alnum only."""
35
+ norm = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", "", title.lower())
36
+ return norm[:64]
37
+
38
+
39
+ def content_hash(content: str) -> str:
40
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
41
+
42
+
43
+ def _first_author_surname(authors: list[str]) -> str:
44
+ """Surname of the first author, accepting 'Bastani, Osbert' or 'Osbert Bastani'."""
45
+ if not authors:
46
+ return ""
47
+ first = authors[0].strip()
48
+ if not first:
49
+ return ""
50
+ parts = [p for p in re.split(r"[\s,]+", first) if p]
51
+ if not parts:
52
+ return ""
53
+ if "," in first:
54
+ return parts[0].strip("., ")
55
+ return parts[-1].strip("., ")
56
+
57
+
58
+ def _title_slug(title: str, max_words: int = 3, max_len: int = 32) -> str:
59
+ """Short alnum-hyphen slug from the first significant title words."""
60
+ words = [w for w in re.findall(r"[a-zA-Z0-9\u4e00-\u9fff]+", title.lower()) if w not in _TITLE_STOPWORDS]
61
+ return "-".join(words[:max_words])[:max_len].rstrip("-")
62
+
63
+
64
+ def parse_doi_from_url(url: str) -> str | None:
65
+ """Extract a DOI from common URL shapes:
66
+ https://doi.org/10.xxxx/yyyy · https://dx.doi.org/10.xxxx/yyyy ·
67
+ https://dl.acm.org/doi/10.xxxx/yyyy · any URL containing a bare DOI.
68
+ """
69
+ if not url:
70
+ return None
71
+ m = re.search(r"(10\.\d{4,9}/[^\s/?&#;]+)", url, re.I)
72
+ return m.group(1).rstrip(")") if m else None
73
+
74
+
75
+ def generate_source_id(
76
+ *,
77
+ authors: list[str] | None = None,
78
+ title: str = "",
79
+ doi: str | None = None,
80
+ canonical_url: str | None = None,
81
+ ) -> str:
82
+ """Generate a stable, year-independent source_id.
83
+
84
+ Format: S-<FIRST_AUTHOR_SURNAME>-<TITLE_SLUG> (e.g. S-BASTANI-GENERATIVE-AI).
85
+ The publication year is metadata only and must never be embedded in the ID
86
+ (P1-05): a corrected year must not change every downstream reference.
87
+
88
+ Fallbacks when no surname/title is available: DOI suffix, then URL host,
89
+ then the literal 'SOURCE'.
90
+ """
91
+ surname = _first_author_surname(authors or [])
92
+ if not doi and canonical_url:
93
+ doi = parse_doi_from_url(canonical_url)
94
+ slug = _title_slug(title)
95
+ if not slug and doi:
96
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", doi.rsplit("/", 1)[-1]).strip("-")[:32]
97
+ if not slug and canonical_url:
98
+ slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(canonical_url).netloc).strip("-")[:32]
99
+ base = f"S-{(surname or 'SOURCE').upper()}"
100
+ return f"{base}-{slug.upper()}" if slug else base
101
+
102
+
103
+ def make_source(
104
+ *,
105
+ source_id: str | None = None,
106
+ title: str,
107
+ canonical_url: str,
108
+ authority_level: str,
109
+ source_type: str = "paper",
110
+ authors: list[str] | None = None,
111
+ year: int | None = None,
112
+ doi: str | None = None,
113
+ discovered_by: str = "search",
114
+ discovery_provider: str = "",
115
+ fetch: dict[str, Any] | None = None,
116
+ ) -> dict[str, Any]:
117
+ """Create a Source Object with dedupe keys precomputed.
118
+
119
+ source_id may be omitted: it is then generated year-independently from
120
+ authors/title/DOI (see generate_source_id).
121
+ """
122
+ if authority_level not in AUTHORITY_LEVELS:
123
+ raise ValueError(f"unknown authority_level {authority_level!r}")
124
+ # Auto-extract DOI from a doi.org/dx.doi.org URL when not given explicitly,
125
+ # so the same paper behind a doi.org URL and a mirror URL dedupes correctly.
126
+ if not doi:
127
+ doi = parse_doi_from_url(canonical_url)
128
+ if not source_id:
129
+ source_id = generate_source_id(authors=authors, title=title, doi=doi, canonical_url=canonical_url)
130
+ return {
131
+ "source_id": source_id,
132
+ "title": title,
133
+ "authors": authors or [],
134
+ "year": year,
135
+ "doi": doi or "",
136
+ "canonical_url": canonical_url,
137
+ "source_type": source_type,
138
+ "authority_level": authority_level,
139
+ "discovered_by": discovered_by,
140
+ "discovery_provider": discovery_provider,
141
+ "fetch": fetch or {},
142
+ "content_hash": (fetch or {}).get("content_hash", ""),
143
+ "dedupe_keys": {
144
+ "canonical_url": canonical_url.rstrip("/"),
145
+ "doi": (doi or "").lower(),
146
+ "title_fingerprint": title_fingerprint(title),
147
+ "content_hash": (fetch or {}).get("content_hash", ""),
148
+ },
149
+ "status": "DISCOVERED",
150
+ }
151
+
152
+
153
+ def is_higher_authority(a: str, b: str) -> bool:
154
+ """True if source a is a more authoritative tier than source b."""
155
+ return AUTHORITY_LEVELS.get(a, 5) < AUTHORITY_LEVELS.get(b, 5)
156
+
157
+
158
+ def update_source_status(source: dict[str, Any], status: str) -> dict[str, Any]:
159
+ source["status"] = status
160
+ return source