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.
Files changed (171) hide show
  1. app/__init__.py +2 -0
  2. app/__main__.py +31 -0
  3. app/_web/404/index.html +1 -0
  4. app/_web/404.html +1 -0
  5. app/_web/__next.!KGNvbnNvbGUp.__PAGE__.txt +42 -0
  6. app/_web/__next._full.txt +36 -0
  7. app/_web/__next._tree.txt +4 -0
  8. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_buildManifest.js +11 -0
  9. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_clientMiddlewareManifest.js +1 -0
  10. app/_web/_next/static/6az3RZxafySyERvJdZAgV/_ssgManifest.js +1 -0
  11. app/_web/_next/static/chunks/00fylloju-1x2.js +1 -0
  12. app/_web/_next/static/chunks/03hxwfej4sm9g.js +1 -0
  13. app/_web/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
  14. app/_web/_next/static/chunks/0gus9jro9n3j3.js +1 -0
  15. app/_web/_next/static/chunks/0n12a63ieedt9.js +1 -0
  16. app/_web/_next/static/chunks/0ns7paypbayry.js +31 -0
  17. app/_web/_next/static/chunks/0s1d7y-_wn8wn.js +1 -0
  18. app/_web/_next/static/chunks/12s40azz56b3b.js +1 -0
  19. app/_web/_next/static/chunks/1b_14wnek5geq.js +1 -0
  20. app/_web/_next/static/chunks/1oderptqhsaox.css +1 -0
  21. app/_web/_next/static/chunks/1rcnm-dleh7p6.js +1 -0
  22. app/_web/_next/static/chunks/1u4klctqx_w0w.js +1 -0
  23. app/_web/_next/static/chunks/1vahkxwiu_bqp.js +22 -0
  24. app/_web/_next/static/chunks/26mp4u1wew2xu.js +1 -0
  25. app/_web/_next/static/chunks/2aq880yo-2z88.js +1 -0
  26. app/_web/_next/static/chunks/2f5ovhdilzkal.js +1 -0
  27. app/_web/_next/static/chunks/2qds0qd18u74f.js +1 -0
  28. app/_web/_next/static/chunks/2qgmxvbiean_r.js +1 -0
  29. app/_web/_next/static/chunks/3b5v1mle2gmac.js +1 -0
  30. app/_web/_next/static/chunks/3hw8x3h98ysh4.js +6 -0
  31. app/_web/_next/static/chunks/3op5__grw4752.js +1 -0
  32. app/_web/_next/static/chunks/3scuzzds0vtp7.js +1 -0
  33. app/_web/_next/static/chunks/3v_5wdpvvcxz8.js +1 -0
  34. app/_web/_next/static/chunks/3wevuf_oyh97l.js +1 -0
  35. app/_web/_next/static/chunks/turbopack-3-ry682tkyiqk.js +1 -0
  36. app/_web/_not-found/__next._full.txt +24 -0
  37. app/_web/_not-found/__next._not-found.__PAGE__.txt +30 -0
  38. app/_web/_not-found/__next._tree.txt +4 -0
  39. app/_web/_not-found/index.html +1 -0
  40. app/_web/_not-found/index.txt +24 -0
  41. app/_web/credentials/__next.!KGNvbnNvbGUp.credentials.__PAGE__.txt +46 -0
  42. app/_web/credentials/__next._full.txt +39 -0
  43. app/_web/credentials/__next._tree.txt +5 -0
  44. app/_web/credentials/index.html +1 -0
  45. app/_web/credentials/index.txt +39 -0
  46. app/_web/executors/__next.!KGNvbnNvbGUp.executors.__PAGE__.txt +46 -0
  47. app/_web/executors/__next._full.txt +39 -0
  48. app/_web/executors/__next._tree.txt +5 -0
  49. app/_web/executors/index.html +1 -0
  50. app/_web/executors/index.txt +39 -0
  51. app/_web/index.html +1 -0
  52. app/_web/index.txt +36 -0
  53. app/_web/new/__next.!KGNvbnNvbGUp.new.__PAGE__.txt +46 -0
  54. app/_web/new/__next._full.txt +39 -0
  55. app/_web/new/__next._tree.txt +5 -0
  56. app/_web/new/index.html +1 -0
  57. app/_web/new/index.txt +39 -0
  58. app/_web/profiles/__next.!KGNvbnNvbGUp.profiles.__PAGE__.txt +46 -0
  59. app/_web/profiles/__next._full.txt +39 -0
  60. app/_web/profiles/__next._tree.txt +5 -0
  61. app/_web/profiles/index.html +1 -0
  62. app/_web/profiles/index.txt +39 -0
  63. app/_web/run/__next.!KGNvbnNvbGUp.run.__PAGE__.txt +47 -0
  64. app/_web/run/__next._full.txt +40 -0
  65. app/_web/run/__next._tree.txt +5 -0
  66. app/_web/run/index.html +1 -0
  67. app/_web/run/index.txt +40 -0
  68. app/_web/services/__next.!KGNvbnNvbGUp.services.__PAGE__.txt +46 -0
  69. app/_web/services/__next._full.txt +39 -0
  70. app/_web/services/__next._tree.txt +5 -0
  71. app/_web/services/index.html +1 -0
  72. app/_web/services/index.txt +39 -0
  73. app/_web/setup/__next.!KGNvbnNvbGUp.setup.__PAGE__.txt +46 -0
  74. app/_web/setup/__next._full.txt +39 -0
  75. app/_web/setup/__next._tree.txt +5 -0
  76. app/_web/setup/index.html +1 -0
  77. app/_web/setup/index.txt +39 -0
  78. app/_web/signin/__next._full.txt +30 -0
  79. app/_web/signin/__next._tree.txt +4 -0
  80. app/_web/signin/__next.signin.__PAGE__.txt +36 -0
  81. app/_web/signin/index.html +1 -0
  82. app/_web/signin/index.txt +30 -0
  83. app/accounts.py +137 -0
  84. app/addresses.py +30 -0
  85. app/api/__init__.py +61 -0
  86. app/api/auth_routes.py +54 -0
  87. app/api/common.py +102 -0
  88. app/api/config_routes.py +562 -0
  89. app/api/links.py +22 -0
  90. app/api/run_routes.py +311 -0
  91. app/api/setup_routes.py +267 -0
  92. app/bundled.py +521 -0
  93. app/compose.py +229 -0
  94. app/db.py +186 -0
  95. app/executor_config.py +95 -0
  96. app/keys.py +29 -0
  97. app/launcher.py +389 -0
  98. app/leases.py +40 -0
  99. app/literature_sources.py +131 -0
  100. app/model_endpoints.py +104 -0
  101. app/model_search.py +252 -0
  102. app/outputs.py +77 -0
  103. app/paths.py +93 -0
  104. app/readonly.py +70 -0
  105. app/repo.py +746 -0
  106. app/scan.py +65 -0
  107. app/server.py +24 -0
  108. app/settings.py +125 -0
  109. app/setup.py +117 -0
  110. app/ssh_chain.py +102 -0
  111. app/ssh_hosts.py +244 -0
  112. app/ssh_machines.py +101 -0
  113. app/statuses.py +4 -0
  114. app/supervisor.py +87 -0
  115. app/tunnels.py +222 -0
  116. app/unseal.py +27 -0
  117. app/worker.py +657 -0
  118. executor/__init__.py +47 -0
  119. executor/base.py +161 -0
  120. executor/common.py +139 -0
  121. executor/local_docker.py +239 -0
  122. executor/local_process.py +247 -0
  123. executor/ssh.py +481 -0
  124. executor/types.py +128 -0
  125. executor/watchdog.py +108 -0
  126. ledger/__init__.py +46 -0
  127. ledger/artifacts.py +62 -0
  128. ledger/canonical.py +48 -0
  129. ledger/core.py +337 -0
  130. ledger/errors.py +36 -0
  131. ledger/model.py +107 -0
  132. ledger/payloads.py +228 -0
  133. ledger/rules.py +204 -0
  134. ledger/storage.py +226 -0
  135. llm/__init__.py +45 -0
  136. llm/backends/__init__.py +0 -0
  137. llm/backends/anthropic_backend.py +145 -0
  138. llm/backends/base.py +87 -0
  139. llm/backends/openai_backend.py +211 -0
  140. llm/core.py +416 -0
  141. llm/prices.py +98 -0
  142. llm/tokens.py +18 -0
  143. llm/types.py +106 -0
  144. loop/__init__.py +19 -0
  145. loop/config.py +108 -0
  146. loop/core.py +1160 -0
  147. loop/decide.py +61 -0
  148. loop/harness.py +132 -0
  149. loop/prompts.py +88 -0
  150. loop/view.py +202 -0
  151. puremethod-0.1.0.dist-info/METADATA +79 -0
  152. puremethod-0.1.0.dist-info/RECORD +171 -0
  153. puremethod-0.1.0.dist-info/WHEEL +4 -0
  154. puremethod-0.1.0.dist-info/entry_points.txt +2 -0
  155. puremethod-0.1.0.dist-info/licenses/LICENSE +96 -0
  156. puremethod-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +173 -0
  157. purescholar/__init__.py +43 -0
  158. purescholar/client.py +380 -0
  159. purescholar/types.py +164 -0
  160. puretrace/__init__.py +49 -0
  161. puretrace/client.py +320 -0
  162. puretrace/types.py +166 -0
  163. writer/__init__.py +8 -0
  164. writer/core.py +430 -0
  165. writer/facts.py +162 -0
  166. writer/latex.py +62 -0
  167. writer/lint.py +85 -0
  168. writer/macros.py +114 -0
  169. writer/render.py +120 -0
  170. writer/tectonic.py +79 -0
  171. 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
+ ]