formulagate 1.0.1__py3-none-any.whl
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.
- formulagate/__init__.py +158 -0
- formulagate/agents/__init__.py +42 -0
- formulagate/agents/arxiv_scanner.py +388 -0
- formulagate/agents/cross_domain_linker.py +297 -0
- formulagate/agents/formula_classifier.py +404 -0
- formulagate/agents/graph.py +375 -0
- formulagate/agents/industry_researcher.py +355 -0
- formulagate/agents/quantum_specialist.py +406 -0
- formulagate/api.py +776 -0
- formulagate/atomic.py +437 -0
- formulagate/audit.py +62 -0
- formulagate/auth.py +206 -0
- formulagate/bench.py +68 -0
- formulagate/billing.py +379 -0
- formulagate/cache.py +364 -0
- formulagate/calibration.py +863 -0
- formulagate/cli.py +680 -0
- formulagate/conformal.py +233 -0
- formulagate/corpus.py +23 -0
- formulagate/database.py +1302 -0
- formulagate/dense.py +148 -0
- formulagate/dimensions.py +616 -0
- formulagate/domain.py +839 -0
- formulagate/domain_ml.py +234 -0
- formulagate/domain_tables.py +232 -0
- formulagate/equivalence.py +178 -0
- formulagate/formula_extract.py +718 -0
- formulagate/gate.py +468 -0
- formulagate/grounding.py +259 -0
- formulagate/hybrid.py +125 -0
- formulagate/index_cache.py +132 -0
- formulagate/langchain.py +488 -0
- formulagate/metrics.py +190 -0
- formulagate/middleware.py +591 -0
- formulagate/paper_defs.py +83 -0
- formulagate/physics_constraints.py +313 -0
- formulagate/physics_signals.py +217 -0
- formulagate/plans.py +179 -0
- formulagate/py.typed +0 -0
- formulagate/rag.py +172 -0
- formulagate/rerank.py +128 -0
- formulagate/retriever.py +244 -0
- formulagate/scoring.py +581 -0
- formulagate/sdk.py +475 -0
- formulagate/semantic_entropy.py +245 -0
- formulagate/semantic_gate.py +244 -0
- formulagate/symbol_grounding.py +760 -0
- formulagate/symbol_ontology.py +143 -0
- formulagate/tex_ingest.py +261 -0
- formulagate/verify_smt.py +658 -0
- formulagate/weak_supervision.py +107 -0
- formulagate-1.0.1.dist-info/METADATA +531 -0
- formulagate-1.0.1.dist-info/RECORD +56 -0
- formulagate-1.0.1.dist-info/WHEEL +5 -0
- formulagate-1.0.1.dist-info/entry_points.txt +3 -0
- formulagate-1.0.1.dist-info/top_level.txt +1 -0
formulagate/__init__.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Formulagate: a physics-aware gate that abstains instead of hallucinating.
|
|
2
|
+
|
|
3
|
+
Integrators should import :mod:`formulagate.sdk` — a small, stable surface
|
|
4
|
+
(``Formulagate``, ``verify``, ``check``). Everything exported here is the
|
|
5
|
+
internal machinery those two calls are built from, and it may move between
|
|
6
|
+
minor versions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from formulagate.atomic import (
|
|
10
|
+
AtomicClaim,
|
|
11
|
+
AtomicReport,
|
|
12
|
+
AtomicVerdict,
|
|
13
|
+
AtomicVerifier,
|
|
14
|
+
decompose_rule,
|
|
15
|
+
)
|
|
16
|
+
from formulagate.audit import build_rag_audit
|
|
17
|
+
from formulagate.bench import RerankBenchmark, benchmark_rerankers
|
|
18
|
+
from formulagate.calibration import (
|
|
19
|
+
CalibrationError,
|
|
20
|
+
CalibrationParams,
|
|
21
|
+
CalibrationReport,
|
|
22
|
+
MultiCalibration,
|
|
23
|
+
calibrate_confidence,
|
|
24
|
+
evaluate_calibration,
|
|
25
|
+
fit_calibration,
|
|
26
|
+
fit_multi_calibration,
|
|
27
|
+
get_calibration,
|
|
28
|
+
get_multi_calibration,
|
|
29
|
+
load_calibration,
|
|
30
|
+
load_multi_calibration,
|
|
31
|
+
save_calibration,
|
|
32
|
+
save_multi_calibration,
|
|
33
|
+
set_calibration,
|
|
34
|
+
set_multi_calibration,
|
|
35
|
+
)
|
|
36
|
+
from formulagate.conformal import (
|
|
37
|
+
ConformalCalibration,
|
|
38
|
+
ConformalError,
|
|
39
|
+
conformal_threshold,
|
|
40
|
+
fit_conformal,
|
|
41
|
+
)
|
|
42
|
+
from formulagate.dense import DenseHybridRetriever, HashEmbedder, SentenceTransformerEmbedder
|
|
43
|
+
from formulagate.dimensions import Dimension, DimensionVerdict, check_dimensions
|
|
44
|
+
from formulagate.equivalence import EquivalenceVerdict, check_equivalence
|
|
45
|
+
from formulagate.formula_extract import (
|
|
46
|
+
Formula,
|
|
47
|
+
canonicalize,
|
|
48
|
+
extract_formulas,
|
|
49
|
+
extract_latex_spans,
|
|
50
|
+
structural_match,
|
|
51
|
+
)
|
|
52
|
+
from formulagate.gate import (
|
|
53
|
+
DiscoveryResult,
|
|
54
|
+
GateEvaluation,
|
|
55
|
+
GateSignals,
|
|
56
|
+
discover_linkages,
|
|
57
|
+
evaluate_gate,
|
|
58
|
+
evaluate_gate_signals,
|
|
59
|
+
)
|
|
60
|
+
from formulagate.grounding import (
|
|
61
|
+
GroundingScore,
|
|
62
|
+
check_grounding,
|
|
63
|
+
semantic_grounding,
|
|
64
|
+
token_level_grounding,
|
|
65
|
+
)
|
|
66
|
+
from formulagate.hybrid import HybridRrfRetriever, rrf_fuse
|
|
67
|
+
from formulagate.metrics import (
|
|
68
|
+
AbstainReport,
|
|
69
|
+
evaluate_abstain_cases,
|
|
70
|
+
evaluate_golden_cases,
|
|
71
|
+
load_golden_cases,
|
|
72
|
+
)
|
|
73
|
+
from formulagate.physics_constraints import (
|
|
74
|
+
ConstraintVerdict,
|
|
75
|
+
ConstraintsReport,
|
|
76
|
+
check_physics_constraints,
|
|
77
|
+
)
|
|
78
|
+
from formulagate.physics_signals import PhysicsSignals, physics_signals
|
|
79
|
+
from formulagate.rag import LexicalStubRetriever, RagDecision, run_scientific_rag
|
|
80
|
+
from formulagate.rerank import CrossEncoderReranker, FormulagateReranker, RerankingRetriever
|
|
81
|
+
from formulagate.scoring import SCORE_WEIGHT_REL, score_record
|
|
82
|
+
from formulagate.semantic_entropy import (
|
|
83
|
+
SemanticEntropy,
|
|
84
|
+
compute_semantic_entropy,
|
|
85
|
+
jaccard_cluster,
|
|
86
|
+
nli_cluster,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
__all__ = [
|
|
90
|
+
"SCORE_WEIGHT_REL",
|
|
91
|
+
"AbstainReport",
|
|
92
|
+
"AtomicClaim",
|
|
93
|
+
"AtomicReport",
|
|
94
|
+
"AtomicVerdict",
|
|
95
|
+
"AtomicVerifier",
|
|
96
|
+
"CalibrationError",
|
|
97
|
+
"CalibrationParams",
|
|
98
|
+
"CalibrationReport",
|
|
99
|
+
"ConformalCalibration",
|
|
100
|
+
"ConformalError",
|
|
101
|
+
"CrossEncoderReranker",
|
|
102
|
+
"DenseHybridRetriever",
|
|
103
|
+
"Dimension",
|
|
104
|
+
"DimensionVerdict",
|
|
105
|
+
"DiscoveryResult",
|
|
106
|
+
"EquivalenceVerdict",
|
|
107
|
+
"Formula",
|
|
108
|
+
"FormulagateReranker",
|
|
109
|
+
"GateEvaluation",
|
|
110
|
+
"GateSignals",
|
|
111
|
+
"GroundingScore",
|
|
112
|
+
"HashEmbedder",
|
|
113
|
+
"HybridRrfRetriever",
|
|
114
|
+
"LexicalStubRetriever",
|
|
115
|
+
"MultiCalibration",
|
|
116
|
+
"PhysicsSignals",
|
|
117
|
+
"RagDecision",
|
|
118
|
+
"RerankBenchmark",
|
|
119
|
+
"RerankingRetriever",
|
|
120
|
+
"SemanticEntropy",
|
|
121
|
+
"SentenceTransformerEmbedder",
|
|
122
|
+
"benchmark_rerankers",
|
|
123
|
+
"build_rag_audit",
|
|
124
|
+
"calibrate_confidence",
|
|
125
|
+
"canonicalize",
|
|
126
|
+
"check_dimensions",
|
|
127
|
+
"check_equivalence",
|
|
128
|
+
"check_grounding",
|
|
129
|
+
"conformal_threshold",
|
|
130
|
+
"decompose_rule",
|
|
131
|
+
"discover_linkages",
|
|
132
|
+
"evaluate_abstain_cases",
|
|
133
|
+
"evaluate_calibration",
|
|
134
|
+
"evaluate_gate",
|
|
135
|
+
"evaluate_gate_signals",
|
|
136
|
+
"evaluate_golden_cases",
|
|
137
|
+
"extract_formulas",
|
|
138
|
+
"extract_latex_spans",
|
|
139
|
+
"fit_calibration",
|
|
140
|
+
"fit_conformal",
|
|
141
|
+
"fit_multi_calibration",
|
|
142
|
+
"get_calibration",
|
|
143
|
+
"get_multi_calibration",
|
|
144
|
+
"load_calibration",
|
|
145
|
+
"load_golden_cases",
|
|
146
|
+
"load_multi_calibration",
|
|
147
|
+
"physics_signals",
|
|
148
|
+
"rrf_fuse",
|
|
149
|
+
"run_scientific_rag",
|
|
150
|
+
"save_calibration",
|
|
151
|
+
"save_multi_calibration",
|
|
152
|
+
"score_record",
|
|
153
|
+
"set_calibration",
|
|
154
|
+
"set_multi_calibration",
|
|
155
|
+
"structural_match",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
__version__ = "1.0.1"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Formulagate discovery agents.
|
|
2
|
+
|
|
3
|
+
Discovery Layer (citable sources only):
|
|
4
|
+
1. ArxivScanner — Extract formulas from arXiv papers
|
|
5
|
+
2. QuantumSpecialist — Quantum-physics-focused arXiv discovery
|
|
6
|
+
|
|
7
|
+
Enrichment Layer:
|
|
8
|
+
3. FormulaClassifier — Tag formulas with domain, subdomain, industries
|
|
9
|
+
4. IndustryResearcher — Map formulas to real-world industry problems
|
|
10
|
+
|
|
11
|
+
Application Layer:
|
|
12
|
+
5. CrossDomainLinker — Find physics solutions for an industry problem
|
|
13
|
+
|
|
14
|
+
All formulas pass through the Formulagate Semantic Gate for validation.
|
|
15
|
+
Agents that produced simulated observations (synthetic trajectories, caption
|
|
16
|
+
placeholders) were removed: a formula corpus is only as trustworthy as the
|
|
17
|
+
provenance of its weakest record.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
from formulagate.agents import AgentSupervisor
|
|
21
|
+
supervisor = AgentSupervisor()
|
|
22
|
+
results = await supervisor.run()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from formulagate.agents.arxiv_scanner import ArxivScanner
|
|
26
|
+
from formulagate.agents.quantum_specialist import QuantumSpecialist
|
|
27
|
+
from formulagate.agents.formula_classifier import FormulaClassifier
|
|
28
|
+
from formulagate.agents.industry_researcher import IndustryResearcher
|
|
29
|
+
from formulagate.agents.cross_domain_linker import CrossDomainLinker
|
|
30
|
+
from formulagate.agents.graph import AgentSupervisor, AgentState, FormulaDiscovery, BaseAgent
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ArxivScanner",
|
|
34
|
+
"QuantumSpecialist",
|
|
35
|
+
"FormulaClassifier",
|
|
36
|
+
"IndustryResearcher",
|
|
37
|
+
"CrossDomainLinker",
|
|
38
|
+
"AgentSupervisor",
|
|
39
|
+
"AgentState",
|
|
40
|
+
"FormulaDiscovery",
|
|
41
|
+
"BaseAgent",
|
|
42
|
+
]
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Agent 1: arXiv Formula Scanner.
|
|
2
|
+
|
|
3
|
+
Scans arXiv papers for physics formulas using the arXiv API and Ollama/LLM
|
|
4
|
+
for formula extraction. Each formula passes through Formulagate's gate.
|
|
5
|
+
|
|
6
|
+
Workflow (LangGraph-compatible):
|
|
7
|
+
1. fetch_arxiv — Query arXiv API for physics papers
|
|
8
|
+
2. extract_text — Download paper abstract/text
|
|
9
|
+
3. extract_formulas — Use Ollama/LLM to find LaTeX formulas
|
|
10
|
+
4. validate_gate — Pass through Formulagate Semantic Gate
|
|
11
|
+
5. store_result — Add to corpus
|
|
12
|
+
|
|
13
|
+
Supports:
|
|
14
|
+
- Ollama (local, free) — qwen2.5, llama3
|
|
15
|
+
- DeepSeek API (cloud, high quality) — when API key available
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import logging
|
|
22
|
+
import re
|
|
23
|
+
import urllib.request
|
|
24
|
+
import urllib.parse
|
|
25
|
+
import xml.etree.ElementTree as ET
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from formulagate.agents.graph import BaseAgent, FormulaDiscovery
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# ─── arXiv API ────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
ARXIV_API = "http://export.arxiv.org/api/query"
|
|
35
|
+
|
|
36
|
+
PHYSICS_CATEGORIES = [
|
|
37
|
+
"hep-th", # High Energy Physics - Theory
|
|
38
|
+
"hep-ph", # High Energy Physics - Phenomenology
|
|
39
|
+
"gr-qc", # General Relativity and Quantum Cosmology
|
|
40
|
+
"quant-ph", # Quantum Physics
|
|
41
|
+
"cond-mat", # Condensed Matter
|
|
42
|
+
"astro-ph", # Astrophysics
|
|
43
|
+
"physics", # General Physics
|
|
44
|
+
"nucl-th", # Nuclear Theory
|
|
45
|
+
"hep-ex", # High Energy Physics - Experiment
|
|
46
|
+
"hep-lat", # High Energy Physics - Lattice
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
# ─── Ollama / LLM prompts ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
FORMULA_EXTRACTION_PROMPT = """You are a PhD physicist extracting formulas from a research paper.
|
|
52
|
+
Extract ALL LaTeX formulas from this text. For each formula:
|
|
53
|
+
1. Write the LaTeX inside $$...$$
|
|
54
|
+
2. Give a ONE-LINE description of what it represents
|
|
55
|
+
3. State the physics domain (e.g., Quantum Field Theory, General Relativity, etc.)
|
|
56
|
+
|
|
57
|
+
Return as JSON list:
|
|
58
|
+
[{"formula": "E = mc^2", "description": "Mass-energy equivalence", "domain": "Relativity"}]
|
|
59
|
+
|
|
60
|
+
Text to analyze:
|
|
61
|
+
{text}
|
|
62
|
+
|
|
63
|
+
Return ONLY valid JSON. No extra text."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
FORMULA_VALIDATION_PROMPT = """Validate this physics formula:
|
|
67
|
+
|
|
68
|
+
Formula: {formula}
|
|
69
|
+
Description: {description}
|
|
70
|
+
Domain: {domain}
|
|
71
|
+
|
|
72
|
+
Check:
|
|
73
|
+
1. Dimensional analysis — do units match?
|
|
74
|
+
2. Physical consistency — does it violate known conservation laws?
|
|
75
|
+
3. Mathematical correctness — proper LaTeX, well-formed expression?
|
|
76
|
+
|
|
77
|
+
Return JSON:
|
|
78
|
+
{{"valid": true/false, "confidence": 0.0-1.0, "issues": ["..."], "corrected_formula": "..."}}"""
|
|
79
|
+
|
|
80
|
+
# ─── Agent Implementation ─────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ArxivScanner(BaseAgent):
|
|
84
|
+
"""Scans arXiv for physics formulas.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
corpus_path: Where to save discovered formulas
|
|
88
|
+
max_results: Max papers per query
|
|
89
|
+
use_ollama: Use local Ollama (True) or cloud API (False)
|
|
90
|
+
ollama_model: Ollama model name for extraction
|
|
91
|
+
openrouter_key: OpenRouter API key (uses DeepSeek via OpenRouter)
|
|
92
|
+
openrouter_model: Model on OpenRouter (default: deepseek/deepseek-chat)
|
|
93
|
+
deepseek_key: Direct DeepSeek API key (legacy, prefer openrouter_key)
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
corpus_path: Path | None = None,
|
|
99
|
+
max_results: int = 5,
|
|
100
|
+
use_ollama: bool = True,
|
|
101
|
+
ollama_model: str = "qwen2.5:1.5b",
|
|
102
|
+
openrouter_key: str | None = None,
|
|
103
|
+
openrouter_model: str = "deepseek/deepseek-chat",
|
|
104
|
+
deepseek_key: str | None = None,
|
|
105
|
+
):
|
|
106
|
+
super().__init__("arxiv", corpus_path)
|
|
107
|
+
self.max_results = max_results
|
|
108
|
+
self.use_ollama = use_ollama
|
|
109
|
+
self.ollama_model = ollama_model
|
|
110
|
+
self.openrouter_key = openrouter_key
|
|
111
|
+
self.openrouter_model = openrouter_model
|
|
112
|
+
self.deepseek_key = deepseek_key # Legacy direct API
|
|
113
|
+
|
|
114
|
+
async def run(self, state) -> list[FormulaDiscovery]:
|
|
115
|
+
"""Main entry point for LangGraph."""
|
|
116
|
+
self.log(f"Scanning arXiv (max={self.max_results} papers)...")
|
|
117
|
+
discoveries = []
|
|
118
|
+
|
|
119
|
+
for category in PHYSICS_CATEGORIES[:3]: # Top 3 categories for speed
|
|
120
|
+
papers = self._fetch_arxiv_papers(category, max_results=self.max_results)
|
|
121
|
+
self.log(f" {category}: {len(papers)} papers found")
|
|
122
|
+
|
|
123
|
+
for paper in papers:
|
|
124
|
+
try:
|
|
125
|
+
formulas = self._extract_formulas(paper)
|
|
126
|
+
for f_data in formulas:
|
|
127
|
+
formula = f_data.get("formula", "")
|
|
128
|
+
desc = f_data.get("description", "")
|
|
129
|
+
domain = f_data.get("domain", "Physics")
|
|
130
|
+
|
|
131
|
+
if not formula or len(formula) < 5:
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Validate through Formulagate Gate
|
|
135
|
+
brief = f"{domain}: {desc}"
|
|
136
|
+
ok, detail = self.validate_with_gate(brief, formula)
|
|
137
|
+
|
|
138
|
+
discovery = FormulaDiscovery(
|
|
139
|
+
agent="arxiv",
|
|
140
|
+
source=paper.get("title", "Unknown"),
|
|
141
|
+
source_url=paper.get("url", ""),
|
|
142
|
+
brief=brief,
|
|
143
|
+
formula=formula,
|
|
144
|
+
domain=domain,
|
|
145
|
+
confidence=f_data.get("confidence", 0.5),
|
|
146
|
+
gate_passed=ok,
|
|
147
|
+
gate_detail=detail,
|
|
148
|
+
)
|
|
149
|
+
discoveries.append(discovery)
|
|
150
|
+
|
|
151
|
+
status = "✅" if ok else "❌"
|
|
152
|
+
self.log(f" {status} {formula[:60]}…")
|
|
153
|
+
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
logger.warning("arXiv paper extraction failed: %s", exc)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
self.log(f"Done: {len(discoveries)} formulas discovered")
|
|
159
|
+
return discoveries
|
|
160
|
+
|
|
161
|
+
def _fetch_arxiv_papers(self, category: str, max_results: int = 5) -> list[dict]:
|
|
162
|
+
"""Query arXiv API for recent papers in a category."""
|
|
163
|
+
query = f"cat:{category}"
|
|
164
|
+
params = {
|
|
165
|
+
"search_query": query,
|
|
166
|
+
"start": "0",
|
|
167
|
+
"max_results": str(max_results),
|
|
168
|
+
"sortBy": "submittedDate",
|
|
169
|
+
"sortOrder": "descending",
|
|
170
|
+
}
|
|
171
|
+
url = f"{ARXIV_API}?{urllib.parse.urlencode(params)}"
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
req = urllib.request.Request(url, headers={"User-Agent": "Formulagate/0.3"})
|
|
175
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
176
|
+
data = resp.read().decode("utf-8")
|
|
177
|
+
|
|
178
|
+
root = ET.fromstring(data)
|
|
179
|
+
ns = {
|
|
180
|
+
"atom": "http://www.w3.org/2005/Atom",
|
|
181
|
+
"arxiv": "http://arxiv.org/schemas/atom",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
papers = []
|
|
185
|
+
for entry in root.findall("atom:entry", ns):
|
|
186
|
+
title = entry.find("atom:title", ns)
|
|
187
|
+
summary = entry.find("atom:summary", ns)
|
|
188
|
+
link = entry.find("atom:id", ns)
|
|
189
|
+
|
|
190
|
+
title_text = title.text.strip() if title is not None and title.text else ""
|
|
191
|
+
summary_text = summary.text.strip() if summary is not None and summary.text else ""
|
|
192
|
+
link_text = link.text.strip() if link is not None and link.text else ""
|
|
193
|
+
|
|
194
|
+
# Skip if no useful content
|
|
195
|
+
if len(summary_text) < 50:
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
papers.append({
|
|
199
|
+
"title": title_text,
|
|
200
|
+
"summary": summary_text,
|
|
201
|
+
"url": link_text,
|
|
202
|
+
"category": category,
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
return papers
|
|
206
|
+
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
# No synthetic fallback: a failed fetch must yield zero papers, never
|
|
209
|
+
# invented ones, or the corpus silently absorbs fabricated physics.
|
|
210
|
+
logger.warning("arXiv API failed for %s: %s", category, exc)
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
def _extract_formulas(self, paper: dict) -> list[dict]:
|
|
214
|
+
"""Extract LaTeX formulas from a paper using available LLM."""
|
|
215
|
+
text = f"Title: {paper.get('title', '')}\n\nAbstract: {paper.get('summary', '')}"
|
|
216
|
+
|
|
217
|
+
# Priority: OpenRouter → Ollama → Direct DeepSeek → Regex
|
|
218
|
+
if self.openrouter_key:
|
|
219
|
+
return self._extract_with_openrouter(text)
|
|
220
|
+
elif self.use_ollama:
|
|
221
|
+
return self._extract_with_ollama(text)
|
|
222
|
+
elif self.deepseek_key:
|
|
223
|
+
return self._extract_with_deepseek(text)
|
|
224
|
+
else:
|
|
225
|
+
return self._extract_regex(text)
|
|
226
|
+
|
|
227
|
+
def _extract_with_ollama(self, text: str) -> list[dict]:
|
|
228
|
+
"""Use local Ollama for formula extraction."""
|
|
229
|
+
prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
payload = json.dumps({
|
|
233
|
+
"model": self.ollama_model,
|
|
234
|
+
"prompt": prompt,
|
|
235
|
+
"stream": False,
|
|
236
|
+
"options": {"temperature": 0.1, "num_predict": 1000},
|
|
237
|
+
}).encode("utf-8")
|
|
238
|
+
|
|
239
|
+
req = urllib.request.Request(
|
|
240
|
+
"http://127.0.0.1:11434/api/generate",
|
|
241
|
+
data=payload,
|
|
242
|
+
headers={"Content-Type": "application/json"},
|
|
243
|
+
method="POST",
|
|
244
|
+
)
|
|
245
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
246
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
247
|
+
response = str(data.get("response", ""))
|
|
248
|
+
|
|
249
|
+
# Try to parse JSON from response
|
|
250
|
+
return self._parse_formula_json(response)
|
|
251
|
+
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
logger.warning("Ollama extraction failed: %s", exc)
|
|
254
|
+
return self._extract_regex(text)
|
|
255
|
+
|
|
256
|
+
def _extract_with_openrouter(self, text: str) -> list[dict]:
|
|
257
|
+
"""Use OpenRouter (DeepSeek) for formula extraction."""
|
|
258
|
+
prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
payload = json.dumps({
|
|
262
|
+
"model": self.openrouter_model,
|
|
263
|
+
"messages": [
|
|
264
|
+
{"role": "system", "content": "You are a PhD physicist. Return ONLY valid JSON."},
|
|
265
|
+
{"role": "user", "content": prompt},
|
|
266
|
+
],
|
|
267
|
+
"temperature": 0.1,
|
|
268
|
+
"max_tokens": 1000,
|
|
269
|
+
}).encode("utf-8")
|
|
270
|
+
|
|
271
|
+
req = urllib.request.Request(
|
|
272
|
+
"https://openrouter.ai/api/v1/chat/completions",
|
|
273
|
+
data=payload,
|
|
274
|
+
headers={
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
"Authorization": f"Bearer {self.openrouter_key}",
|
|
277
|
+
"HTTP-Referer": "https://github.com/formulagate", # Optional: for rankings
|
|
278
|
+
"X-Title": "Formulagate Physics Discovery",
|
|
279
|
+
},
|
|
280
|
+
method="POST",
|
|
281
|
+
)
|
|
282
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
283
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
284
|
+
|
|
285
|
+
response = data["choices"][0]["message"]["content"]
|
|
286
|
+
logger.info("OpenRouter response: %s", response[:200])
|
|
287
|
+
return self._parse_formula_json(response)
|
|
288
|
+
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
logger.warning("OpenRouter extraction failed: %s", exc)
|
|
291
|
+
return self._extract_regex(text)
|
|
292
|
+
|
|
293
|
+
def _extract_with_deepseek(self, text: str) -> list[dict]:
|
|
294
|
+
"""Use DeepSeek API for high-quality formula extraction."""
|
|
295
|
+
prompt = FORMULA_EXTRACTION_PROMPT.format(text=text[:3000])
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
payload = json.dumps({
|
|
299
|
+
"model": "deepseek-v4-flash",
|
|
300
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
301
|
+
"temperature": 0.1,
|
|
302
|
+
"max_tokens": 1000,
|
|
303
|
+
}).encode("utf-8")
|
|
304
|
+
|
|
305
|
+
req = urllib.request.Request(
|
|
306
|
+
"https://api.deepseek.com/v1/chat/completions",
|
|
307
|
+
data=payload,
|
|
308
|
+
headers={
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
"Authorization": f"Bearer {self.deepseek_key}",
|
|
311
|
+
},
|
|
312
|
+
method="POST",
|
|
313
|
+
)
|
|
314
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
315
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
316
|
+
response = data["choices"][0]["message"]["content"]
|
|
317
|
+
return self._parse_formula_json(response)
|
|
318
|
+
|
|
319
|
+
except Exception as exc:
|
|
320
|
+
logger.warning("DeepSeek extraction failed: %s", exc)
|
|
321
|
+
return self._extract_regex(text)
|
|
322
|
+
|
|
323
|
+
def _extract_regex(self, text: str) -> list[dict]:
|
|
324
|
+
"""Fallback: regex-based LaTeX formula extraction."""
|
|
325
|
+
formulas = []
|
|
326
|
+
|
|
327
|
+
# Match $$...$$ and $...$ patterns
|
|
328
|
+
patterns = [
|
|
329
|
+
r'\$\$(.+?)\$\$',
|
|
330
|
+
r'\$(.+?)\$',
|
|
331
|
+
r'\\\[(.+?)\\\]',
|
|
332
|
+
r'\\\((.+?)\\\)',
|
|
333
|
+
r'\\begin\{equation\}(.+?)\\end\{equation\}',
|
|
334
|
+
r'\\begin\{align\}(.+?)\\end\{align\}',
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
seen = set()
|
|
338
|
+
for pattern in patterns:
|
|
339
|
+
matches = re.findall(pattern, text, re.DOTALL)
|
|
340
|
+
for match in matches:
|
|
341
|
+
formula = match.strip()
|
|
342
|
+
if len(formula) > 5 and formula not in seen:
|
|
343
|
+
seen.add(formula)
|
|
344
|
+
formulas.append({
|
|
345
|
+
"formula": formula[:500],
|
|
346
|
+
"description": "Extracted from paper abstract",
|
|
347
|
+
"domain": "Physics",
|
|
348
|
+
"confidence": 0.3,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
return formulas[:5]
|
|
352
|
+
|
|
353
|
+
def _parse_formula_json(self, response: str) -> list[dict]:
|
|
354
|
+
"""Parse JSON formula list from LLM response."""
|
|
355
|
+
# Try direct JSON parse
|
|
356
|
+
try:
|
|
357
|
+
data = json.loads(response)
|
|
358
|
+
if isinstance(data, list):
|
|
359
|
+
return data
|
|
360
|
+
if isinstance(data, dict) and "formulas" in data:
|
|
361
|
+
return data["formulas"]
|
|
362
|
+
except json.JSONDecodeError:
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
# Try to extract JSON array from response
|
|
366
|
+
json_match = re.search(r'\[.*\]', response, re.DOTALL)
|
|
367
|
+
if json_match:
|
|
368
|
+
try:
|
|
369
|
+
return json.loads(json_match.group(0))
|
|
370
|
+
except json.JSONDecodeError:
|
|
371
|
+
pass
|
|
372
|
+
|
|
373
|
+
# Fallback: regex formulas from response text
|
|
374
|
+
return self._extract_regex(response)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
# ─── CLI test ─────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
import asyncio
|
|
381
|
+
|
|
382
|
+
async def main():
|
|
383
|
+
scanner = ArxivScanner(max_results=2, use_ollama=False)
|
|
384
|
+
discoveries = await scanner.run(None)
|
|
385
|
+
for d in discoveries:
|
|
386
|
+
print(f" [{d.domain}] {d.formula[:80]}")
|
|
387
|
+
|
|
388
|
+
asyncio.run(main())
|