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.
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/README.zh-CN.md +434 -0
- package/SKILL.md +195 -0
- package/bin/eduevidence.js +127 -0
- package/domains/education/manifest.json +183 -0
- package/domains/education/outcome_taxonomy.json +127 -0
- package/domains/manifest.json +26 -0
- package/domains/policy/frame.schema.json +234 -0
- package/domains/policy/manifest.json +10 -0
- package/domains/policy/methodology_checklist.json +109 -0
- package/domains/policy/outcome_taxonomy.json +53 -0
- package/domains/policy/references/causal-identification.md +45 -0
- package/domains/policy/references/cost-evidence.md +44 -0
- package/domains/policy/references/equity.md +42 -0
- package/domains/policy/references/evidence-hierarchy.md +41 -0
- package/domains/policy/references/implementation-evidence.md +47 -0
- package/eduevidence_cli.py +26 -0
- package/engine/__init__.py +11 -0
- package/engine/__pycache__/__init__.cpython-312.pyc +0 -0
- package/engine/__pycache__/analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/bias.cpython-312.pyc +0 -0
- package/engine/__pycache__/briefs.cpython-312.pyc +0 -0
- package/engine/__pycache__/capabilities.cpython-312.pyc +0 -0
- package/engine/__pycache__/citation_check.cpython-312.pyc +0 -0
- package/engine/__pycache__/contracts.cpython-312.pyc +0 -0
- package/engine/__pycache__/datasets.cpython-312.pyc +0 -0
- package/engine/__pycache__/events.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_graph.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidence_review.cpython-312.pyc +0 -0
- package/engine/__pycache__/evidencecore.cpython-312.pyc +0 -0
- package/engine/__pycache__/gap_lens.cpython-312.pyc +0 -0
- package/engine/__pycache__/gaps.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_store.cpython-312.pyc +0 -0
- package/engine/__pycache__/graph_validate.cpython-312.pyc +0 -0
- package/engine/__pycache__/ids.cpython-312.pyc +0 -0
- package/engine/__pycache__/library.cpython-312.pyc +0 -0
- package/engine/__pycache__/library_builtin.cpython-312.pyc +0 -0
- package/engine/__pycache__/living.cpython-312.pyc +0 -0
- package/engine/__pycache__/log.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_analysis.cpython-312.pyc +0 -0
- package/engine/__pycache__/meta_synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/migration.cpython-312.pyc +0 -0
- package/engine/__pycache__/mode_router.cpython-312.pyc +0 -0
- package/engine/__pycache__/paths.cpython-312.pyc +0 -0
- package/engine/__pycache__/pilot.cpython-312.pyc +0 -0
- package/engine/__pycache__/planner.cpython-312.pyc +0 -0
- package/engine/__pycache__/project.cpython-312.pyc +0 -0
- package/engine/__pycache__/projections.cpython-312.pyc +0 -0
- package/engine/__pycache__/robustness.cpython-312.pyc +0 -0
- package/engine/__pycache__/run.cpython-312.pyc +0 -0
- package/engine/__pycache__/semantics.cpython-312.pyc +0 -0
- package/engine/__pycache__/study_design.cpython-312.pyc +0 -0
- package/engine/__pycache__/synthesis.cpython-312.pyc +0 -0
- package/engine/__pycache__/tribunal.cpython-312.pyc +0 -0
- package/engine/__pycache__/update.cpython-312.pyc +0 -0
- package/engine/__pycache__/versions.cpython-312.pyc +0 -0
- package/engine/analysis.py +308 -0
- package/engine/bias.py +178 -0
- package/engine/briefs.py +106 -0
- package/engine/capabilities.py +99 -0
- package/engine/citation_check.py +192 -0
- package/engine/contracts.py +117 -0
- package/engine/datasets.py +165 -0
- package/engine/events.py +67 -0
- package/engine/evidence_graph.py +571 -0
- package/engine/evidence_review.py +88 -0
- package/engine/evidencecore.py +182 -0
- package/engine/gap_lens.py +132 -0
- package/engine/gaps.py +169 -0
- package/engine/graph_store.py +335 -0
- package/engine/graph_validate.py +87 -0
- package/engine/ids.py +77 -0
- package/engine/library.py +268 -0
- package/engine/library_builtin.py +301 -0
- package/engine/living.py +671 -0
- package/engine/log.py +39 -0
- package/engine/meta_analysis.py +333 -0
- package/engine/meta_synthesis.py +111 -0
- package/engine/migration.py +397 -0
- package/engine/mode_router.py +72 -0
- package/engine/paths.py +15 -0
- package/engine/pilot.py +368 -0
- package/engine/planner.py +126 -0
- package/engine/project.py +118 -0
- package/engine/projections.py +240 -0
- package/engine/robustness.py +109 -0
- package/engine/run.py +85 -0
- package/engine/semantics.py +135 -0
- package/engine/study_design.py +87 -0
- package/engine/synthesis.py +187 -0
- package/engine/tribunal.py +408 -0
- package/engine/update.py +113 -0
- package/engine/versions.py +12 -0
- package/install.sh +510 -0
- package/integrations/__init__.py +1 -0
- package/integrations/__pycache__/__init__.cpython-312.pyc +0 -0
- package/integrations/__pycache__/agent_mcp.cpython-312.pyc +0 -0
- package/integrations/__pycache__/smart_web_fetch.cpython-312.pyc +0 -0
- package/integrations/agent_mcp.py +856 -0
- package/integrations/smart_web_fetch.py +59 -0
- package/package.json +50 -0
- package/pyproject.toml +55 -0
- package/references/applicability-policy.md +88 -0
- package/references/education-framing.md +132 -0
- package/references/effect_size_formulas.md +35 -0
- package/references/evaluation-design.md +111 -0
- package/references/evidence-quality.md +79 -0
- package/references/grade_framework.md +29 -0
- package/references/intervention-design.md +98 -0
- package/references/methodology-audit.md +103 -0
- package/references/outcome-taxonomy.md +106 -0
- package/references/retrieval-protocol.md +142 -0
- package/references/skeptic-protocol.md +93 -0
- package/references/social_science_pitfalls.md +48 -0
- package/references/source-validity.md +140 -0
- package/references/tribunal-policy.md +112 -0
- package/references/wwc_standards.md +29 -0
- package/retrieval/__init__.py +1 -0
- package/retrieval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/corpus_store.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/dedupe.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/failures.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/fetch.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/search.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/source.cpython-312.pyc +0 -0
- package/retrieval/__pycache__/validate.cpython-312.pyc +0 -0
- package/retrieval/corpus_store.py +181 -0
- package/retrieval/dedupe.py +127 -0
- package/retrieval/failures.py +90 -0
- package/retrieval/fetch.py +435 -0
- package/retrieval/search.py +493 -0
- package/retrieval/source.py +160 -0
- package/retrieval/validate.py +257 -0
- package/schemas/agent-mcp-approval.schema.json +57 -0
- package/schemas/chart-spec.schema.json +88 -0
- package/schemas/cross-model-review.schema.json +28 -0
- package/schemas/education-frame.schema.json +106 -0
- package/schemas/evaluation.schema.json +35 -0
- package/schemas/evidence.schema.json +81 -0
- package/schemas/fetch-result.schema.json +119 -0
- package/schemas/intervention.schema.json +46 -0
- package/schemas/methodology.schema.json +102 -0
- package/schemas/report-result.schema.json +381 -0
- package/schemas/report-spec.schema.json +130 -0
- package/schemas/source.schema.json +311 -0
- package/schemas/v2/analysis-plan.schema.json +28 -0
- package/schemas/v2/analysis-run.schema.json +33 -0
- package/schemas/v2/claim.schema.json +26 -0
- package/schemas/v2/dataset-asset.schema.json +40 -0
- package/schemas/v2/decision-snapshot.schema.json +53 -0
- package/schemas/v2/evidence-link.schema.json +38 -0
- package/schemas/v2/finding.schema.json +47 -0
- package/schemas/v2/graph-revision.schema.json +30 -0
- package/schemas/v2/knowledge-gap.schema.json +40 -0
- package/schemas/v2/methodology-audit.schema.json +30 -0
- package/schemas/v2/outcome.schema.json +18 -0
- package/schemas/v2/project.schema.json +31 -0
- package/schemas/v2/research-intent.schema.json +24 -0
- package/schemas/v2/run.schema.json +43 -0
- package/schemas/v2/source.schema.json +24 -0
- package/schemas/v2/study-design.schema.json +67 -0
- package/schemas/v2/study.schema.json +37 -0
- package/schemas/v3/pilot-outcome.schema.json +132 -0
- package/schemas/v3/run-manifest.schema.json +193 -0
- package/schemas/v3/synthesis.schema.json +86 -0
- package/schemas/v4/drift-report.schema.json +66 -0
- package/schemas/v4/evidence-library.schema.json +42 -0
- package/schemas/v4/living-subscription.schema.json +55 -0
- package/schemas/v4/meta-analysis.schema.json +152 -0
- package/schemas/verdict.schema.json +56 -0
- package/scripts/__init__.py +0 -0
- package/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_evaluator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_judge.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_routing.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v2.cpython-312.pyc +0 -0
- package/scripts/__pycache__/benchmark_v3.cpython-312.pyc +0 -0
- package/scripts/__pycache__/build_result.cpython-312.pyc +0 -0
- package/scripts/__pycache__/claim_audit.cpython-312.pyc +0 -0
- package/scripts/__pycache__/complexity_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/compute_confidence.cpython-312.pyc +0 -0
- package/scripts/__pycache__/dashboard_server.cpython-312.pyc +0 -0
- package/scripts/__pycache__/did_regression.cpython-312.pyc +0 -0
- package/scripts/__pycache__/effect_calculator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_matrix.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_score.cpython-312.pyc +0 -0
- package/scripts/__pycache__/evidence_semantics.cpython-312.pyc +0 -0
- package/scripts/__pycache__/fetch_benchmark.cpython-312.pyc +0 -0
- package/scripts/__pycache__/lint_report_layout.cpython-312.pyc +0 -0
- package/scripts/__pycache__/orchestrator.cpython-312.pyc +0 -0
- package/scripts/__pycache__/pre_verdict_gate.cpython-312.pyc +0 -0
- package/scripts/__pycache__/recompute_demo_quality.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/render_report_html.cpython-312.pyc +0 -0
- package/scripts/__pycache__/run_workspace.cpython-312.pyc +0 -0
- package/scripts/__pycache__/skill_lint.cpython-312.pyc +0 -0
- package/scripts/__pycache__/startup_probe.cpython-312.pyc +0 -0
- package/scripts/__pycache__/sync_killer_demo_report.cpython-312.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.0.2.pyc +0 -0
- package/scripts/__pycache__/test_adversarial_empirical.cpython-312-pytest-9.1.1.pyc +0 -0
- package/scripts/__pycache__/validate_schema.cpython-312.pyc +0 -0
- package/scripts/audit_dois.py +292 -0
- package/scripts/bake_pack.sh +37 -0
- package/scripts/benchmark.py +183 -0
- package/scripts/benchmark_evaluator.py +371 -0
- package/scripts/benchmark_judge.py +535 -0
- package/scripts/benchmark_routing.py +120 -0
- package/scripts/benchmark_v2.py +304 -0
- package/scripts/benchmark_v3.py +552 -0
- package/scripts/build_esl_artifacts.py +1921 -0
- package/scripts/build_evidence_library.py +307 -0
- package/scripts/build_killer_demo.py +295 -0
- package/scripts/build_result.py +311 -0
- package/scripts/check_version_consistency.py +96 -0
- package/scripts/citation_check.py +123 -0
- package/scripts/claim_audit.py +157 -0
- package/scripts/complexity_gate.py +180 -0
- package/scripts/compute_confidence.py +176 -0
- package/scripts/dashboard_server.py +536 -0
- package/scripts/did_regression.py +315 -0
- package/scripts/effect_calculator.py +99 -0
- package/scripts/enrich_projects_human_and_lieflat.py +315 -0
- package/scripts/evidence_matrix.py +129 -0
- package/scripts/evidence_score.py +234 -0
- package/scripts/evidence_semantics.py +87 -0
- package/scripts/fetch_benchmark.py +177 -0
- package/scripts/generate_metrics.py +99 -0
- package/scripts/generate_new_projects.py +686 -0
- package/scripts/generate_promo.py +192 -0
- package/scripts/lint_report_layout.py +182 -0
- package/scripts/orchestrator.py +1456 -0
- package/scripts/pre_verdict_gate.py +513 -0
- package/scripts/quickstart.py +121 -0
- package/scripts/rebake_all_5themes.py +88 -0
- package/scripts/recompute_demo_quality.py +205 -0
- package/scripts/render_report.py +270 -0
- package/scripts/render_report_html.py +356 -0
- package/scripts/retraction_watch.py +110 -0
- package/scripts/run_workspace.py +337 -0
- package/scripts/serve_web.py +54 -0
- package/scripts/skill_lint.py +150 -0
- package/scripts/startup_probe.py +265 -0
- package/scripts/sync_killer_demo_report.py +270 -0
- package/scripts/test_adversarial_empirical.py +541 -0
- package/scripts/validate_schema.py +256 -0
- package/skill/agents/education-planner.md +80 -0
- package/skill/agents/evaluation-designer.md +74 -0
- package/skill/agents/evidence-analyst.md +106 -0
- package/skill/agents/evidence-judge.md +111 -0
- package/skill/agents/evidence-retriever.md +80 -0
- package/skill/agents/intervention-designer.md +82 -0
- package/skill/agents/method-reviewer.md +104 -0
- package/skill/agents/skeptic.md +89 -0
- package/skill/sub-skills/aihot-trend-analysis/SKILL.md +31 -0
- package/skill/sub-skills/contradiction-analysis/SKILL.md +17 -0
- package/skill/sub-skills/data-analysis/SKILL.md +23 -0
- package/skill/sub-skills/ethics-review/SKILL.md +25 -0
- package/skill/sub-skills/evidence-extraction/SKILL.md +19 -0
- package/skill/sub-skills/evidence-review/SKILL.md +18 -0
- package/skill/sub-skills/gap-analysis/SKILL.md +25 -0
- package/skill/sub-skills/literature-review/SKILL.md +21 -0
- package/skill/sub-skills/methodology-audit/SKILL.md +20 -0
- package/skill/sub-skills/report-generation/SKILL.md +51 -0
- package/skill/sub-skills/research-planning/SKILL.md +21 -0
- package/skill/sub-skills/study-design/SKILL.md +16 -0
- package/skill/task-briefs/adjudicate.md +17 -0
- package/skill/task-briefs/audit.md +15 -0
- package/skill/task-briefs/challenge.md +15 -0
- package/skill/task-briefs/evaluate.md +13 -0
- package/skill/task-briefs/extract.md +16 -0
- package/skill/task-briefs/frame.md +17 -0
- package/skill/task-briefs/intervene.md +14 -0
- package/skill/task-briefs/present.md +16 -0
- package/skill/task-briefs/retrieve.md +15 -0
- package/visualization/eduevidence-report/assets/base.css +337 -0
- package/visualization/eduevidence-report/motion/motion.css +157 -0
- package/visualization/eduevidence-report/motion/motion.js +107 -0
- package/visualization/eduevidence-report/references/bilingual-style.md +7 -0
- package/visualization/eduevidence-report/references/component-catalog.md +145 -0
- package/visualization/eduevidence-report/references/evidence-expansion.md +65 -0
- package/visualization/eduevidence-report/references/full-report-outline.md +86 -0
- package/visualization/eduevidence-report/references/layout-constraints.md +63 -0
- package/visualization/eduevidence-report/references/lieflat-composition.md +79 -0
- package/visualization/eduevidence-report/references/motion-system.md +31 -0
- package/visualization/eduevidence-report/schemas/adapter-envelope.schema.json +22 -0
- package/visualization/eduevidence-report/schemas/visual-layout.schema.json +87 -0
- package/visualization/eduevidence-report/scripts/__pycache__/adapter_contract.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_artifact_manifest.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_charts.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_figures.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_infographics.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/build_report.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/charts_data.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/lieflat_engine.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/__pycache__/zh_labels.cpython-312.pyc +0 -0
- package/visualization/eduevidence-report/scripts/adapter_contract.py +72 -0
- package/visualization/eduevidence-report/scripts/build_artifact_manifest.py +70 -0
- package/visualization/eduevidence-report/scripts/build_charts.py +283 -0
- package/visualization/eduevidence-report/scripts/build_figures.py +515 -0
- package/visualization/eduevidence-report/scripts/build_infographics.py +268 -0
- package/visualization/eduevidence-report/scripts/build_report.py +3211 -0
- package/visualization/eduevidence-report/scripts/charts_data.py +617 -0
- package/visualization/eduevidence-report/scripts/check_mobile_layout.js +220 -0
- package/visualization/eduevidence-report/scripts/lieflat_engine.py +852 -0
- package/visualization/eduevidence-report/scripts/zh_labels.py +245 -0
- package/visualization/eduevidence-report/themes/academic.css +94 -0
- package/visualization/eduevidence-report/themes/claude.css +96 -0
- package/visualization/eduevidence-report/themes/datalab-dark.css +147 -0
- package/visualization/eduevidence-report/themes/datalab.css +151 -0
- 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
|