puremethod 0.1.0__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.
- app/__init__.py +2 -0
- app/__main__.py +31 -0
- app/_web/404/index.html +1 -0
- app/_web/404.html +1 -0
- app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
- app/_web/__next._full.txt +36 -0
- app/_web/__next._tree.txt +4 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
- app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
- app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
- app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
- app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
- app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
- app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
- app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
- app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
- app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
- app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
- app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
- app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
- app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
- app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
- app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
- app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
- app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
- app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
- app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
- app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
- app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
- app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
- app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
- app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
- app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
- app/_web/_not-found/__next._full.txt +24 -0
- app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
- app/_web/_not-found/__next._tree.txt +4 -0
- app/_web/_not-found/index.html +1 -0
- app/_web/_not-found/index.txt +24 -0
- app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
- app/_web/credentials/__next._full.txt +39 -0
- app/_web/credentials/__next._tree.txt +5 -0
- app/_web/credentials/index.html +1 -0
- app/_web/credentials/index.txt +39 -0
- app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
- app/_web/executors/__next._full.txt +39 -0
- app/_web/executors/__next._tree.txt +5 -0
- app/_web/executors/index.html +1 -0
- app/_web/executors/index.txt +39 -0
- app/_web/index.html +1 -0
- app/_web/index.txt +36 -0
- app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
- app/_web/new/__next._full.txt +39 -0
- app/_web/new/__next._tree.txt +5 -0
- app/_web/new/index.html +1 -0
- app/_web/new/index.txt +39 -0
- app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
- app/_web/profiles/__next._full.txt +39 -0
- app/_web/profiles/__next._tree.txt +5 -0
- app/_web/profiles/index.html +1 -0
- app/_web/profiles/index.txt +39 -0
- app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
- app/_web/run/__next._full.txt +40 -0
- app/_web/run/__next._tree.txt +5 -0
- app/_web/run/index.html +1 -0
- app/_web/run/index.txt +40 -0
- app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
- app/_web/services/__next._full.txt +39 -0
- app/_web/services/__next._tree.txt +5 -0
- app/_web/services/index.html +1 -0
- app/_web/services/index.txt +39 -0
- app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
- app/_web/setup/__next._full.txt +39 -0
- app/_web/setup/__next._tree.txt +5 -0
- app/_web/setup/index.html +1 -0
- app/_web/setup/index.txt +39 -0
- app/_web/signin/__next._full.txt +30 -0
- app/_web/signin/__next._tree.txt +4 -0
- app/_web/signin/__next.signin.__PAGE__.txt +36 -0
- app/_web/signin/index.html +1 -0
- app/_web/signin/index.txt +30 -0
- app/accounts.py +137 -0
- app/addresses.py +30 -0
- app/api/__init__.py +61 -0
- app/api/auth_routes.py +54 -0
- app/api/common.py +102 -0
- app/api/config_routes.py +562 -0
- app/api/links.py +22 -0
- app/api/run_routes.py +311 -0
- app/api/setup_routes.py +267 -0
- app/bundled.py +521 -0
- app/compose.py +229 -0
- app/db.py +186 -0
- app/executor_config.py +95 -0
- app/keys.py +29 -0
- app/launcher.py +389 -0
- app/leases.py +40 -0
- app/literature_sources.py +131 -0
- app/model_endpoints.py +104 -0
- app/model_search.py +252 -0
- app/outputs.py +77 -0
- app/paths.py +93 -0
- app/readonly.py +70 -0
- app/repo.py +746 -0
- app/scan.py +65 -0
- app/server.py +24 -0
- app/settings.py +125 -0
- app/setup.py +117 -0
- app/ssh_chain.py +102 -0
- app/ssh_hosts.py +244 -0
- app/ssh_machines.py +101 -0
- app/statuses.py +4 -0
- app/supervisor.py +87 -0
- app/tunnels.py +222 -0
- app/unseal.py +27 -0
- app/worker.py +657 -0
- executor/__init__.py +47 -0
- executor/base.py +161 -0
- executor/common.py +139 -0
- executor/local_docker.py +239 -0
- executor/local_process.py +247 -0
- executor/ssh.py +481 -0
- executor/types.py +128 -0
- executor/watchdog.py +108 -0
- ledger/__init__.py +46 -0
- ledger/artifacts.py +62 -0
- ledger/canonical.py +48 -0
- ledger/core.py +337 -0
- ledger/errors.py +36 -0
- ledger/model.py +107 -0
- ledger/payloads.py +228 -0
- ledger/rules.py +204 -0
- ledger/storage.py +226 -0
- llm/__init__.py +45 -0
- llm/backends/__init__.py +0 -0
- llm/backends/anthropic_backend.py +145 -0
- llm/backends/base.py +87 -0
- llm/backends/openai_backend.py +211 -0
- llm/core.py +416 -0
- llm/prices.py +98 -0
- llm/tokens.py +18 -0
- llm/types.py +106 -0
- loop/__init__.py +19 -0
- loop/config.py +108 -0
- loop/core.py +1160 -0
- loop/decide.py +61 -0
- loop/harness.py +132 -0
- loop/prompts.py +88 -0
- loop/view.py +202 -0
- puremethod-0.1.0.dist-info/METADATA +79 -0
- puremethod-0.1.0.dist-info/RECORD +171 -0
- puremethod-0.1.0.dist-info/WHEEL +4 -0
- puremethod-0.1.0.dist-info/entry_points.txt +2 -0
- puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
- puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
- purescholar/__init__.py +43 -0
- purescholar/client.py +380 -0
- purescholar/types.py +164 -0
- puretrace/__init__.py +49 -0
- puretrace/client.py +320 -0
- puretrace/types.py +166 -0
- writer/__init__.py +8 -0
- writer/core.py +430 -0
- writer/facts.py +162 -0
- writer/latex.py +62 -0
- writer/lint.py +85 -0
- writer/macros.py +114 -0
- writer/render.py +120 -0
- writer/tectonic.py +79 -0
- writer/types.py +61 -0
purescholar/client.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""PureScholar REST v1 adapter (01-purescholar.md).
|
|
2
|
+
|
|
3
|
+
Read-only (INV-5): the client builds only the GET and POST requests below, none of which
|
|
4
|
+
writes to a corpus. Only `/api/v1/*` routes are called, with the API key (INV-4).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from .types import (
|
|
22
|
+
REQUIRED_SCOPES,
|
|
23
|
+
AuthError,
|
|
24
|
+
CallRecord,
|
|
25
|
+
Citation,
|
|
26
|
+
CitationRecord,
|
|
27
|
+
Evidence,
|
|
28
|
+
Grounded,
|
|
29
|
+
Identity,
|
|
30
|
+
NotCitable,
|
|
31
|
+
NotFound,
|
|
32
|
+
NoveltyEvidence,
|
|
33
|
+
PaperRef,
|
|
34
|
+
PureScholarError,
|
|
35
|
+
QuotaExhausted,
|
|
36
|
+
RateLimited,
|
|
37
|
+
RequestRejected,
|
|
38
|
+
ServiceUnavailable,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_ENVELOPE = {"citations", "invalid_citations", "insufficient_evidence", "external_added",
|
|
42
|
+
"lab_evidence", "external_evidence", "notice", "error", "sub_questions",
|
|
43
|
+
"reasoning_trace", "grounding"}
|
|
44
|
+
_SORTS = ("relevance", "cited", "recent")
|
|
45
|
+
_DOI_RE = re.compile(r"^(?:https?://(?:dx\.)?doi\.org/)?(10\.\d{4,9}/\S+)$", re.IGNORECASE)
|
|
46
|
+
_OPENALEX_RE = re.compile(r"(?:openalex\.org/)?(W\d+)$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _now() -> str:
|
|
50
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _sha256(data: bytes) -> str:
|
|
54
|
+
return hashlib.sha256(data).hexdigest()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _canonical(value: Any) -> str:
|
|
58
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _error_code(response: httpx.Response) -> str | None:
|
|
62
|
+
"""Both shapes: FastAPI `{"detail": {"error": code}}` and `{"error": code}` (API.md)."""
|
|
63
|
+
try:
|
|
64
|
+
body = response.json()
|
|
65
|
+
except ValueError:
|
|
66
|
+
return None
|
|
67
|
+
if not isinstance(body, dict):
|
|
68
|
+
return None
|
|
69
|
+
detail = body.get("detail")
|
|
70
|
+
if isinstance(detail, dict) and isinstance(detail.get("error"), str):
|
|
71
|
+
return str(detail["error"])
|
|
72
|
+
if isinstance(body.get("error"), str):
|
|
73
|
+
return str(body["error"])
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PureScholar:
|
|
78
|
+
"""Built from the configuration (§3, KB-015). Use one instance from one event loop."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, api_base: str, api_key: str, *, timeout_s: float = 120.0,
|
|
81
|
+
retries: int = 3, transport: httpx.AsyncBaseTransport | None = None,
|
|
82
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
83
|
+
monotonic: Callable[[], float] = time.monotonic) -> None:
|
|
84
|
+
if not api_key:
|
|
85
|
+
raise ValueError("an API key is required")
|
|
86
|
+
self._key = api_key
|
|
87
|
+
self._retries = retries
|
|
88
|
+
self._sleep = sleep
|
|
89
|
+
self._monotonic = monotonic
|
|
90
|
+
# PURESCHOLAR_API_BASE is an origin; the adapter appends /api/v1 (§3).
|
|
91
|
+
self._client = httpx.AsyncClient(base_url=api_base.rstrip("/"), transport=transport,
|
|
92
|
+
timeout=timeout_s,
|
|
93
|
+
headers={"Authorization": f"Bearer {api_key}"})
|
|
94
|
+
|
|
95
|
+
async def aclose(self) -> None:
|
|
96
|
+
await self._client.aclose()
|
|
97
|
+
|
|
98
|
+
# ---- transport ---------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _scrub(self, text: str) -> str:
|
|
101
|
+
return text.replace(self._key, "[REDACTED]")
|
|
102
|
+
|
|
103
|
+
async def _request(self, method: str, route: str, records: list[CallRecord],
|
|
104
|
+
body: dict[str, Any] | None = None) -> tuple[dict[str, Any], str]:
|
|
105
|
+
"""One logical request with retries. Returns the JSON body and the final call id."""
|
|
106
|
+
path = "/api/v1" + route
|
|
107
|
+
body_text = _canonical(body)
|
|
108
|
+
delay = 1.0
|
|
109
|
+
for attempt in range(self._retries + 1):
|
|
110
|
+
started, t0 = _now(), self._monotonic()
|
|
111
|
+
call_id = uuid.uuid4().hex
|
|
112
|
+
response: httpx.Response | None = None
|
|
113
|
+
error_type: str | None = None
|
|
114
|
+
try:
|
|
115
|
+
response = await self._client.request(method, path, json=body)
|
|
116
|
+
except httpx.TransportError as exc:
|
|
117
|
+
error_type = f"transport:{type(exc).__name__}"
|
|
118
|
+
text = self._scrub(response.text) if response is not None else None
|
|
119
|
+
code = _error_code(response) if response is not None and response.status_code >= 400 \
|
|
120
|
+
else None
|
|
121
|
+
if response is not None and response.status_code >= 400:
|
|
122
|
+
error_type = f"http_{response.status_code}:{code or 'unknown'}"
|
|
123
|
+
records.append(CallRecord(
|
|
124
|
+
call_id=call_id, endpoint=f"{method} {path}",
|
|
125
|
+
request_digest=_sha256(body_text.encode()),
|
|
126
|
+
response_digest=_sha256(text.encode()) if text is not None else None,
|
|
127
|
+
status=response.status_code if response is not None else None,
|
|
128
|
+
started_at=started, duration_ms=int((self._monotonic() - t0) * 1000),
|
|
129
|
+
service_version=response.headers.get("X-Service-Version") if response else None,
|
|
130
|
+
quota_remaining=response.headers.get("RateLimit-Remaining") if response else None,
|
|
131
|
+
error_type=error_type, request_body=body_text, response_body=text))
|
|
132
|
+
if response is not None and response.status_code < 400:
|
|
133
|
+
try:
|
|
134
|
+
data = response.json()
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise ServiceUnavailable(f"PureScholar {route} returned a non-JSON body",
|
|
137
|
+
list(records)) from None
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
raise ServiceUnavailable(f"PureScholar {route} returned a non-object body",
|
|
140
|
+
list(records))
|
|
141
|
+
return data, call_id
|
|
142
|
+
retryable, wait = self._retry_decision(response, code)
|
|
143
|
+
if retryable and attempt < self._retries:
|
|
144
|
+
await self._sleep(wait if wait is not None else delay)
|
|
145
|
+
delay *= 2
|
|
146
|
+
continue
|
|
147
|
+
raise self._error(response, code, records)
|
|
148
|
+
raise AssertionError("unreachable")
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _retry_decision(response: httpx.Response | None,
|
|
152
|
+
code: str | None) -> tuple[bool, float | None]:
|
|
153
|
+
if response is None:
|
|
154
|
+
return True, None
|
|
155
|
+
if response.status_code == 429 and code != "monthly_quota_exhausted":
|
|
156
|
+
try:
|
|
157
|
+
return True, float(response.headers.get("Retry-After", ""))
|
|
158
|
+
except ValueError:
|
|
159
|
+
return True, None
|
|
160
|
+
return response.status_code >= 500, None
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _error(response: httpx.Response | None, code: str | None,
|
|
164
|
+
records: list[CallRecord]) -> PureScholarError:
|
|
165
|
+
if response is None:
|
|
166
|
+
return ServiceUnavailable("PureScholar unreachable", list(records))
|
|
167
|
+
status = response.status_code
|
|
168
|
+
msg = f"PureScholar {status} {code or ''}".strip()
|
|
169
|
+
if code == "key_orphaned":
|
|
170
|
+
msg += ": the key's creator left the lab; a lab owner must mint a new key"
|
|
171
|
+
if status in (401, 403):
|
|
172
|
+
return AuthError(msg, list(records), code)
|
|
173
|
+
if status == 404:
|
|
174
|
+
return NotFound(msg, list(records), code)
|
|
175
|
+
if status == 429:
|
|
176
|
+
if code == "monthly_quota_exhausted":
|
|
177
|
+
return QuotaExhausted(msg, list(records), code)
|
|
178
|
+
return RateLimited(msg, list(records), code)
|
|
179
|
+
if status >= 500:
|
|
180
|
+
return ServiceUnavailable(msg, list(records), code)
|
|
181
|
+
return RequestRejected(msg, list(records), code)
|
|
182
|
+
|
|
183
|
+
async def _call(self, method: str, route: str, body: dict[str, Any] | None,
|
|
184
|
+
records: list[CallRecord]) -> tuple[dict[str, Any], str]:
|
|
185
|
+
try:
|
|
186
|
+
return await self._request(method, route, records, body)
|
|
187
|
+
except PureScholarError as exc:
|
|
188
|
+
exc.records = list(records)
|
|
189
|
+
raise
|
|
190
|
+
|
|
191
|
+
# ---- parsing -----------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _evidence(ch: dict[str, Any]) -> Evidence:
|
|
195
|
+
return Evidence(marker=str(ch["id"]), paper_id=ch.get("paper_id"),
|
|
196
|
+
title=ch.get("title"), authors=list(ch.get("authors") or []),
|
|
197
|
+
year=ch.get("year"), venue=ch.get("venue"), section=ch.get("section"),
|
|
198
|
+
content=str(ch.get("content") or ""),
|
|
199
|
+
source=str(ch.get("source") or "external"), url=ch.get("url"))
|
|
200
|
+
|
|
201
|
+
def _grounded(self, data: dict[str, Any], call_id: str,
|
|
202
|
+
records: list[CallRecord]) -> Grounded:
|
|
203
|
+
if data.get("notice"):
|
|
204
|
+
raise ServiceUnavailable(f"generation disabled upstream: {data['notice']}",
|
|
205
|
+
list(records), "notice")
|
|
206
|
+
if data.get("error"):
|
|
207
|
+
raise ServiceUnavailable(f"upstream error: {data['error']}", list(records),
|
|
208
|
+
"upstream_error")
|
|
209
|
+
try:
|
|
210
|
+
evidence = [self._evidence(ch) for key in ("lab_evidence", "external_evidence")
|
|
211
|
+
for ch in data.get(key) or []]
|
|
212
|
+
by_marker = {e.marker: e for e in evidence}
|
|
213
|
+
citations = [Citation(marker=str(c["marker"]), paper_id=c.get("paper_id"),
|
|
214
|
+
title=c.get("title"), url=c.get("url"),
|
|
215
|
+
quartile=c.get("quartile"), conf_rank=c.get("conf_rank"),
|
|
216
|
+
doc_type=c.get("doc_type"), valid=bool(c["valid"]),
|
|
217
|
+
evidence=by_marker.get(str(c["marker"])), call_id=call_id)
|
|
218
|
+
for c in data.get("citations") or []]
|
|
219
|
+
return Grounded(
|
|
220
|
+
body={k: v for k, v in data.items() if k not in _ENVELOPE},
|
|
221
|
+
citations=citations,
|
|
222
|
+
invalid_citations=[str(m) for m in data.get("invalid_citations") or []],
|
|
223
|
+
insufficient_evidence=bool(data.get("insufficient_evidence", False)),
|
|
224
|
+
evidence=evidence, grounding=data.get("grounding"),
|
|
225
|
+
sub_questions=[str(q) for q in data.get("sub_questions") or []],
|
|
226
|
+
records=list(records))
|
|
227
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
228
|
+
raise ServiceUnavailable(f"unexpected response shape: {type(exc).__name__} {exc}",
|
|
229
|
+
list(records)) from None
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _paper(w: dict[str, Any], call_id: str) -> PaperRef:
|
|
233
|
+
return PaperRef(title=w.get("title"), authors=list(w.get("authors") or []),
|
|
234
|
+
year=w.get("year"), venue=w.get("venue"), doi=w.get("doi"),
|
|
235
|
+
url=w.get("url"), abstract=w.get("abstract"),
|
|
236
|
+
cited_by_count=w.get("cited_by_count"), quartile=w.get("quartile"),
|
|
237
|
+
conf_rank=w.get("conf_rank"), call_id=call_id)
|
|
238
|
+
|
|
239
|
+
# ---- interface ---------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
async def preflight(self) -> Identity:
|
|
242
|
+
records: list[CallRecord] = []
|
|
243
|
+
data, _ = await self._call("GET", "/me", None, records)
|
|
244
|
+
scopes = [str(s) for s in data.get("scopes") or []]
|
|
245
|
+
missing = [s for s in REQUIRED_SCOPES if s not in scopes]
|
|
246
|
+
if missing:
|
|
247
|
+
raise AuthError(f"the PureScholar key lacks required scopes: {missing}",
|
|
248
|
+
list(records), "missing_scope")
|
|
249
|
+
return Identity(auth=str(data.get("auth")), lab_id=data.get("lab_id"),
|
|
250
|
+
lab_name=data.get("lab_name"), scopes=scopes, quota=data.get("quota"),
|
|
251
|
+
records=records)
|
|
252
|
+
|
|
253
|
+
async def _tool(self, tool: str, text: str) -> Grounded:
|
|
254
|
+
if not text.strip():
|
|
255
|
+
raise ValueError("input must not be empty")
|
|
256
|
+
records: list[CallRecord] = []
|
|
257
|
+
data, call_id = await self._call("POST", f"/tools/{tool}", {"input": text}, records)
|
|
258
|
+
return self._grounded(data, call_id, records)
|
|
259
|
+
|
|
260
|
+
async def literature_map(self, topic: str) -> Grounded:
|
|
261
|
+
return await self._tool("literature-map", topic)
|
|
262
|
+
|
|
263
|
+
async def find_gaps(self, topic: str) -> Grounded:
|
|
264
|
+
return await self._tool("gaps", topic)
|
|
265
|
+
|
|
266
|
+
async def critique(self, idea: str) -> Grounded:
|
|
267
|
+
return await self._tool("fixit", idea)
|
|
268
|
+
|
|
269
|
+
async def ask(self, question: str, deep: bool = True,
|
|
270
|
+
balance: float | None = None) -> Grounded:
|
|
271
|
+
if not question.strip():
|
|
272
|
+
raise ValueError("question must not be empty")
|
|
273
|
+
if balance is not None and not 0.0 <= balance <= 1.0:
|
|
274
|
+
raise ValueError("balance must be between 0 and 1")
|
|
275
|
+
body: dict[str, Any] = {"question": question, "deep": deep}
|
|
276
|
+
if balance is not None:
|
|
277
|
+
body["balance"] = balance
|
|
278
|
+
records: list[CallRecord] = []
|
|
279
|
+
data, call_id = await self._call("POST", "/ask", body, records)
|
|
280
|
+
return self._grounded(data, call_id, records)
|
|
281
|
+
|
|
282
|
+
async def retrieve(self, query: str, k: int = 10) -> tuple[list[Evidence], list[CallRecord]]:
|
|
283
|
+
if not 1 <= k <= 50:
|
|
284
|
+
raise ValueError("k must be between 1 and 50")
|
|
285
|
+
records: list[CallRecord] = []
|
|
286
|
+
data, _ = await self._call("POST", "/search", {"query": query, "k": k}, records)
|
|
287
|
+
try:
|
|
288
|
+
return [self._evidence(ch) for ch in data.get("chunks") or []], records
|
|
289
|
+
except (KeyError, TypeError) as exc:
|
|
290
|
+
raise ServiceUnavailable(f"unexpected response shape: {exc}", records) from None
|
|
291
|
+
|
|
292
|
+
async def search_papers(self, query: str, from_year: int | None = None,
|
|
293
|
+
sort: str = "relevance", limit: int = 15
|
|
294
|
+
) -> tuple[list[PaperRef], list[CallRecord]]:
|
|
295
|
+
if sort not in _SORTS:
|
|
296
|
+
raise ValueError(f"sort must be one of {_SORTS}")
|
|
297
|
+
if not 1 <= limit <= 50:
|
|
298
|
+
raise ValueError("limit must be between 1 and 50")
|
|
299
|
+
body: dict[str, Any] = {"query": query, "sort": sort, "limit": limit}
|
|
300
|
+
if from_year is not None:
|
|
301
|
+
body["from_year"] = from_year
|
|
302
|
+
records: list[CallRecord] = []
|
|
303
|
+
data, call_id = await self._call("POST", "/papers/search", body, records)
|
|
304
|
+
return [self._paper(w, call_id) for w in data.get("works") or []], records
|
|
305
|
+
|
|
306
|
+
async def novelty_evidence(self, hypothesis: str, limit: int = 10) -> NoveltyEvidence:
|
|
307
|
+
"""§5 step 6: papers by relevance, then by citations, then a grounded `ask` on
|
|
308
|
+
whether those works already test the hypothesis. `loop` makes the judgement."""
|
|
309
|
+
records: list[CallRecord] = []
|
|
310
|
+
closest: list[PaperRef] = []
|
|
311
|
+
seen: set[str] = set()
|
|
312
|
+
for sort in ("relevance", "cited"):
|
|
313
|
+
works, recs = await self.search_papers(hypothesis, sort=sort, limit=limit)
|
|
314
|
+
records.extend(recs)
|
|
315
|
+
for w in works:
|
|
316
|
+
ident = (w.doi or w.url or w.title or "").lower()
|
|
317
|
+
if ident and ident not in seen:
|
|
318
|
+
seen.add(ident)
|
|
319
|
+
closest.append(w)
|
|
320
|
+
titles = "; ".join(w.title for w in closest[:limit] if w.title)
|
|
321
|
+
question = ("Has prior work already tested this hypothesis, or one very close to it? "
|
|
322
|
+
f"Hypothesis: {hypothesis}. Candidate prior works: {titles or 'none found'}.")
|
|
323
|
+
answer = await self.ask(question, deep=True)
|
|
324
|
+
records.extend(answer.records)
|
|
325
|
+
return NoveltyEvidence(hypothesis=hypothesis, closest=closest, answer=answer,
|
|
326
|
+
records=records)
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def citation_for(item: Citation | PaperRef) -> CitationRecord | NotCitable:
|
|
330
|
+
"""INV-1: only a valid citation matching retrieved evidence, or a paper-search
|
|
331
|
+
result, and only with a DOI, an OpenAlex ID or a URL."""
|
|
332
|
+
if isinstance(item, Citation):
|
|
333
|
+
if not item.valid:
|
|
334
|
+
return NotCitable("the citation is marked invalid (never retrieved)")
|
|
335
|
+
if item.evidence is None:
|
|
336
|
+
return NotCitable("the citation's marker is not in the response's evidence")
|
|
337
|
+
ev = item.evidence
|
|
338
|
+
ident = _identifier(item.paper_id, item.url or ev.url)
|
|
339
|
+
if ident is None:
|
|
340
|
+
return NotCitable("no DOI, OpenAlex ID or URL (an uploaded file is not "
|
|
341
|
+
"resolvable outside the lab)")
|
|
342
|
+
kind, value = ident
|
|
343
|
+
return CitationRecord(
|
|
344
|
+
key=_cite_key(ev.authors, ev.year, value), id_kind=kind, id=value,
|
|
345
|
+
title=item.title or ev.title, authors=list(ev.authors), year=ev.year,
|
|
346
|
+
venue=ev.venue, url=item.url or ev.url, source=ev.source,
|
|
347
|
+
evidence_markers=[item.marker], excerpts=[ev.content] if ev.content else [],
|
|
348
|
+
call_id=item.call_id)
|
|
349
|
+
ident = _identifier(item.doi, item.url)
|
|
350
|
+
if ident is None:
|
|
351
|
+
return NotCitable("the paper has no DOI, OpenAlex ID or URL")
|
|
352
|
+
kind, value = ident
|
|
353
|
+
return CitationRecord(key=_cite_key(item.authors, item.year, value), id_kind=kind,
|
|
354
|
+
id=value, title=item.title, authors=list(item.authors),
|
|
355
|
+
year=item.year, venue=item.venue, url=item.url,
|
|
356
|
+
source="external", evidence_markers=[], excerpts=[],
|
|
357
|
+
call_id=item.call_id)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _identifier(primary: str | None, url: str | None) -> tuple[Any, str] | None:
|
|
361
|
+
"""DOI if there is one, otherwise an OpenAlex ID, otherwise the URL (§5 step 7)."""
|
|
362
|
+
for candidate in (primary, url):
|
|
363
|
+
if candidate:
|
|
364
|
+
m = _DOI_RE.match(candidate.strip())
|
|
365
|
+
if m:
|
|
366
|
+
return "doi", m.group(1).lower()
|
|
367
|
+
for candidate in (primary, url):
|
|
368
|
+
if candidate and "openalex" in candidate.lower():
|
|
369
|
+
m = _OPENALEX_RE.search(candidate.strip())
|
|
370
|
+
if m:
|
|
371
|
+
return "openalex", m.group(1)
|
|
372
|
+
if url and url.lower().startswith(("http://", "https://")):
|
|
373
|
+
return "url", url
|
|
374
|
+
return None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _cite_key(authors: list[str], year: int | None, ident: str) -> str:
|
|
378
|
+
"""Stable per identifier, so the same source always gets the same key in a run."""
|
|
379
|
+
surname = re.sub(r"[^a-z]", "", (authors[0].split()[-1] if authors else "anon").lower())
|
|
380
|
+
return f"{surname or 'anon'}{year or 'nd'}{_sha256(ident.encode())[:6]}"
|
purescholar/types.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""PureScholar adapter types (01-purescholar.md §3, §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
REQUIRED_SCOPES: tuple[str, ...] = ("ask", "search", "papers.read", "tools.run")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class CallRecord:
|
|
13
|
+
"""One HTTP call (§4, KB-015). Never contains the key."""
|
|
14
|
+
|
|
15
|
+
call_id: str
|
|
16
|
+
endpoint: str
|
|
17
|
+
request_digest: str
|
|
18
|
+
response_digest: str | None
|
|
19
|
+
status: int | None # HTTP status, or None when no response arrived
|
|
20
|
+
started_at: str
|
|
21
|
+
duration_ms: int
|
|
22
|
+
service_version: str | None
|
|
23
|
+
quota_remaining: str | None
|
|
24
|
+
error_type: str | None
|
|
25
|
+
request_body: str
|
|
26
|
+
response_body: str | None
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
return dict(self.__dict__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Evidence:
|
|
34
|
+
marker: str
|
|
35
|
+
paper_id: str | None
|
|
36
|
+
title: str | None
|
|
37
|
+
authors: list[str]
|
|
38
|
+
year: int | None
|
|
39
|
+
venue: str | None
|
|
40
|
+
section: str | None
|
|
41
|
+
content: str
|
|
42
|
+
source: str # lab | personal | external
|
|
43
|
+
url: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Citation:
|
|
48
|
+
"""A `[cID]` marker as PureScholar resolved it. `evidence` is the retrieved chunk the
|
|
49
|
+
marker points to, when the response carried it; `call_id` is the call it came from."""
|
|
50
|
+
|
|
51
|
+
marker: str
|
|
52
|
+
paper_id: str | None
|
|
53
|
+
title: str | None
|
|
54
|
+
url: str | None
|
|
55
|
+
quartile: str | None
|
|
56
|
+
conf_rank: str | None
|
|
57
|
+
doc_type: str | None
|
|
58
|
+
valid: bool
|
|
59
|
+
evidence: Evidence | None
|
|
60
|
+
call_id: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class PaperRef:
|
|
65
|
+
title: str | None
|
|
66
|
+
authors: list[str]
|
|
67
|
+
year: int | None
|
|
68
|
+
venue: str | None
|
|
69
|
+
doi: str | None
|
|
70
|
+
url: str | None
|
|
71
|
+
abstract: str | None
|
|
72
|
+
cited_by_count: int | None
|
|
73
|
+
quartile: str | None
|
|
74
|
+
conf_rank: str | None
|
|
75
|
+
call_id: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Grounded:
|
|
80
|
+
"""A grounded envelope. The signals pass through exactly as they arrived (INV-2)."""
|
|
81
|
+
|
|
82
|
+
body: dict[str, Any]
|
|
83
|
+
citations: list[Citation]
|
|
84
|
+
invalid_citations: list[str]
|
|
85
|
+
insufficient_evidence: bool
|
|
86
|
+
evidence: list[Evidence]
|
|
87
|
+
grounding: dict[str, Any] | None
|
|
88
|
+
sub_questions: list[str] = field(default_factory=list)
|
|
89
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Identity:
|
|
94
|
+
auth: str
|
|
95
|
+
lab_id: int | None
|
|
96
|
+
lab_name: str | None
|
|
97
|
+
scopes: list[str]
|
|
98
|
+
quota: dict[str, Any] | None
|
|
99
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class NoveltyEvidence:
|
|
104
|
+
"""The closest prior works found, and a grounded answer on overlap. `loop` judges."""
|
|
105
|
+
|
|
106
|
+
hypothesis: str
|
|
107
|
+
closest: list[PaperRef]
|
|
108
|
+
answer: Grounded
|
|
109
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclass(frozen=True)
|
|
113
|
+
class CitationRecord:
|
|
114
|
+
key: str
|
|
115
|
+
id_kind: Literal["doi", "openalex", "url"]
|
|
116
|
+
id: str
|
|
117
|
+
title: str | None
|
|
118
|
+
authors: list[str]
|
|
119
|
+
year: int | None
|
|
120
|
+
venue: str | None
|
|
121
|
+
url: str | None
|
|
122
|
+
source: str
|
|
123
|
+
evidence_markers: list[str]
|
|
124
|
+
excerpts: list[str]
|
|
125
|
+
call_id: str
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class NotCitable:
|
|
130
|
+
reason: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---- errors (KB-015) --------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
class PureScholarError(Exception):
|
|
136
|
+
def __init__(self, message: str, records: list[CallRecord] | None = None,
|
|
137
|
+
code: str | None = None) -> None:
|
|
138
|
+
super().__init__(message)
|
|
139
|
+
self.records: list[CallRecord] = records or []
|
|
140
|
+
self.code = code
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class AuthError(PureScholarError):
|
|
144
|
+
"""401, or 403 with its code (`missing_scope`, `key_orphaned`, ...)."""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class NotFound(PureScholarError):
|
|
148
|
+
"""404."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class RequestRejected(PureScholarError):
|
|
152
|
+
"""400, 409, 413, 415, 422."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class RateLimited(PureScholarError):
|
|
156
|
+
"""`429 rate_limited` after the retries."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class QuotaExhausted(PureScholarError):
|
|
160
|
+
"""`429 monthly_quota_exhausted`. The run pauses until the user acts."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ServiceUnavailable(PureScholarError):
|
|
164
|
+
"""5xx, timeouts, connection failures, or generation disabled (`notice`)."""
|
puretrace/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""puretrace: adapter to the PureTrace service (docs/knowledge_base/02-puretrace.md)."""
|
|
2
|
+
|
|
3
|
+
from .client import PureTrace
|
|
4
|
+
from .types import (
|
|
5
|
+
PAPER_TYPES,
|
|
6
|
+
Artifacts,
|
|
7
|
+
Assessment,
|
|
8
|
+
AssessmentFailed,
|
|
9
|
+
AssessmentTimeout,
|
|
10
|
+
AuthError,
|
|
11
|
+
CallRecord,
|
|
12
|
+
Dimension,
|
|
13
|
+
Fact,
|
|
14
|
+
NotApplicable,
|
|
15
|
+
NotFound,
|
|
16
|
+
PureTraceError,
|
|
17
|
+
Question,
|
|
18
|
+
QuotaExhausted,
|
|
19
|
+
RateLimited,
|
|
20
|
+
RequestRejected,
|
|
21
|
+
Rubric,
|
|
22
|
+
ServiceUnavailable,
|
|
23
|
+
TypeGuess,
|
|
24
|
+
UseNotEnabled,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"PAPER_TYPES",
|
|
29
|
+
"Artifacts",
|
|
30
|
+
"Assessment",
|
|
31
|
+
"AssessmentFailed",
|
|
32
|
+
"AssessmentTimeout",
|
|
33
|
+
"AuthError",
|
|
34
|
+
"CallRecord",
|
|
35
|
+
"Dimension",
|
|
36
|
+
"Fact",
|
|
37
|
+
"NotApplicable",
|
|
38
|
+
"NotFound",
|
|
39
|
+
"PureTrace",
|
|
40
|
+
"PureTraceError",
|
|
41
|
+
"Question",
|
|
42
|
+
"QuotaExhausted",
|
|
43
|
+
"RateLimited",
|
|
44
|
+
"RequestRejected",
|
|
45
|
+
"Rubric",
|
|
46
|
+
"ServiceUnavailable",
|
|
47
|
+
"TypeGuess",
|
|
48
|
+
"UseNotEnabled",
|
|
49
|
+
]
|