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
puretrace/client.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""PureTrace REST v1 adapter (02-puretrace.md).
|
|
2
|
+
|
|
3
|
+
Routes used, all under `/v1`: rubrics, papers (list, submit, get, report) and paper-type
|
|
4
|
+
detection. There is deliberately no code path to the certification or author-response
|
|
5
|
+
routes (INV-1, INV-2): the client can only send the requests built below.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
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
|
+
ASSESSED_STATUSES,
|
|
23
|
+
PAPER_TYPES,
|
|
24
|
+
TERMINAL_STATUSES,
|
|
25
|
+
USES,
|
|
26
|
+
Artifacts,
|
|
27
|
+
Assessment,
|
|
28
|
+
AssessmentFailed,
|
|
29
|
+
AssessmentTimeout,
|
|
30
|
+
AuthError,
|
|
31
|
+
CallRecord,
|
|
32
|
+
Dimension,
|
|
33
|
+
Fact,
|
|
34
|
+
NotApplicable,
|
|
35
|
+
NotFound,
|
|
36
|
+
PureTraceError,
|
|
37
|
+
Question,
|
|
38
|
+
QuotaExhausted,
|
|
39
|
+
RateLimited,
|
|
40
|
+
RequestRejected,
|
|
41
|
+
Rubric,
|
|
42
|
+
ServiceUnavailable,
|
|
43
|
+
TypeGuess,
|
|
44
|
+
Use,
|
|
45
|
+
UseNotEnabled,
|
|
46
|
+
)
|
|
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
|
+
class PureTrace:
|
|
62
|
+
"""Built from the configuration, the run id and the uses the run enables (KB-015).
|
|
63
|
+
|
|
64
|
+
The HTTP client is created here: use one instance from one event loop, and `aclose` it.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, api_base: str, api_key: str, run_id: str, enabled_uses: list[str], *,
|
|
68
|
+
poll_interval_s: float = 10.0, poll_max_s: float = 60.0,
|
|
69
|
+
assessment_timeout_s: float = 1800.0, request_timeout_s: float = 120.0,
|
|
70
|
+
retries: int = 3, transport: httpx.AsyncBaseTransport | None = None,
|
|
71
|
+
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
|
72
|
+
monotonic: Callable[[], float] = time.monotonic) -> None:
|
|
73
|
+
unknown = set(enabled_uses) - set(USES)
|
|
74
|
+
if unknown:
|
|
75
|
+
raise ValueError(f"unknown uses: {sorted(unknown)}")
|
|
76
|
+
if not api_key:
|
|
77
|
+
raise ValueError("an API key is required")
|
|
78
|
+
self._base = api_base.rstrip("/")
|
|
79
|
+
self._key = api_key
|
|
80
|
+
self._run_id = run_id
|
|
81
|
+
self._uses = frozenset(enabled_uses)
|
|
82
|
+
self._poll = poll_interval_s
|
|
83
|
+
self._poll_max = poll_max_s
|
|
84
|
+
self._timeout = assessment_timeout_s
|
|
85
|
+
self._retries = retries
|
|
86
|
+
self._sleep = sleep
|
|
87
|
+
self._monotonic = monotonic
|
|
88
|
+
self._client = httpx.AsyncClient(base_url=self._base, transport=transport,
|
|
89
|
+
timeout=request_timeout_s,
|
|
90
|
+
headers={"Authorization": f"Bearer {api_key}"})
|
|
91
|
+
self._rubrics: list[Rubric] | None = None
|
|
92
|
+
|
|
93
|
+
async def aclose(self) -> None:
|
|
94
|
+
await self._client.aclose()
|
|
95
|
+
|
|
96
|
+
# ---- transport ---------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def _scrub(self, text: str) -> str:
|
|
99
|
+
return text.replace(self._key, "[REDACTED]")
|
|
100
|
+
|
|
101
|
+
async def _request(self, method: str, path: str, records: list[CallRecord], *,
|
|
102
|
+
json_body: Any = None, files: Any = None, data: Any = None,
|
|
103
|
+
params: dict[str, Any] | None = None,
|
|
104
|
+
headers: dict[str, str] | None = None,
|
|
105
|
+
described_body: Any = None) -> Any:
|
|
106
|
+
"""One logical request with retries. Every attempt appends a CallRecord."""
|
|
107
|
+
if not path.startswith("/v1/"):
|
|
108
|
+
raise ValueError("only /v1 routes are called (INV-7)")
|
|
109
|
+
body_text = _canonical(described_body if described_body is not None
|
|
110
|
+
else {"json": json_body, "params": params})
|
|
111
|
+
delay = 1.0
|
|
112
|
+
for attempt in range(self._retries + 1):
|
|
113
|
+
started, t0 = _now(), self._monotonic()
|
|
114
|
+
response: httpx.Response | None = None
|
|
115
|
+
error_type: str | None = None
|
|
116
|
+
try:
|
|
117
|
+
response = await self._client.request(method, path, json=json_body,
|
|
118
|
+
files=files, data=data, params=params,
|
|
119
|
+
headers=headers)
|
|
120
|
+
except httpx.TransportError as exc:
|
|
121
|
+
error_type = f"transport:{type(exc).__name__}"
|
|
122
|
+
text = self._scrub(response.text) if response is not None else None
|
|
123
|
+
code = None
|
|
124
|
+
if response is not None and response.status_code >= 400:
|
|
125
|
+
try:
|
|
126
|
+
code = response.json().get("error", {}).get("code")
|
|
127
|
+
except (ValueError, AttributeError):
|
|
128
|
+
code = None
|
|
129
|
+
error_type = f"http_{response.status_code}:{code or 'unknown'}"
|
|
130
|
+
records.append(CallRecord(
|
|
131
|
+
call_id=uuid.uuid4().hex, endpoint=f"{method} {path}",
|
|
132
|
+
request_digest=_sha256(body_text.encode()),
|
|
133
|
+
response_digest=_sha256(text.encode()) if text is not None else None,
|
|
134
|
+
status=response.status_code if response is not None else None,
|
|
135
|
+
started_at=started, duration_ms=int((self._monotonic() - t0) * 1000),
|
|
136
|
+
service_version=response.headers.get("X-API-Version") if response else None,
|
|
137
|
+
quota_remaining=response.headers.get("RateLimit-Remaining") if response else None,
|
|
138
|
+
error_type=error_type, request_body=body_text, response_body=text))
|
|
139
|
+
if response is not None and response.status_code < 400:
|
|
140
|
+
try:
|
|
141
|
+
return response.json()
|
|
142
|
+
except ValueError:
|
|
143
|
+
raise ServiceUnavailable(f"PureTrace {path} returned a non-JSON body",
|
|
144
|
+
list(records)) from None
|
|
145
|
+
retryable, wait = self._retry_decision(response, code)
|
|
146
|
+
if retryable and attempt < self._retries:
|
|
147
|
+
await self._sleep(wait if wait is not None else delay)
|
|
148
|
+
delay *= 2
|
|
149
|
+
continue
|
|
150
|
+
raise self._error(response, code, records)
|
|
151
|
+
raise AssertionError("unreachable")
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _retry_decision(response: httpx.Response | None,
|
|
155
|
+
code: str | None) -> tuple[bool, float | None]:
|
|
156
|
+
if response is None:
|
|
157
|
+
return True, None
|
|
158
|
+
status = response.status_code
|
|
159
|
+
if status == 429 and code != "quota_exceeded":
|
|
160
|
+
try:
|
|
161
|
+
return True, float(response.headers.get("Retry-After", ""))
|
|
162
|
+
except ValueError:
|
|
163
|
+
return True, None
|
|
164
|
+
if status == 409 and code == "idempotency_in_progress":
|
|
165
|
+
return True, None
|
|
166
|
+
return status >= 500, None
|
|
167
|
+
|
|
168
|
+
def _error(self, response: httpx.Response | None, code: str | None,
|
|
169
|
+
records: list[CallRecord]) -> PureTraceError:
|
|
170
|
+
if response is None:
|
|
171
|
+
return ServiceUnavailable("PureTrace unreachable", list(records))
|
|
172
|
+
status = response.status_code
|
|
173
|
+
msg = f"PureTrace {status} {code or ''}".strip()
|
|
174
|
+
if status in (401, 403):
|
|
175
|
+
return AuthError(msg, list(records), code)
|
|
176
|
+
if status == 404:
|
|
177
|
+
return NotFound(msg, list(records), code)
|
|
178
|
+
if status == 429:
|
|
179
|
+
if code == "quota_exceeded":
|
|
180
|
+
return QuotaExhausted(msg, list(records), code)
|
|
181
|
+
return RateLimited(msg, list(records), code)
|
|
182
|
+
if status >= 500 or (status == 409 and code == "idempotency_in_progress"):
|
|
183
|
+
return ServiceUnavailable(msg, list(records), code)
|
|
184
|
+
return RequestRejected(msg, list(records), code)
|
|
185
|
+
|
|
186
|
+
# ---- interface ---------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
async def preflight(self) -> list[CallRecord]:
|
|
189
|
+
"""Checks the key reads rubrics and papers. `papers:write` cannot be checked
|
|
190
|
+
without submitting; a missing write scope surfaces on the first submit."""
|
|
191
|
+
records: list[CallRecord] = []
|
|
192
|
+
await self._request("GET", "/v1/rubrics", records)
|
|
193
|
+
await self._request("GET", "/v1/papers", records, params={"limit": 1})
|
|
194
|
+
return records
|
|
195
|
+
|
|
196
|
+
async def rubrics(self) -> tuple[list[Rubric], list[CallRecord]]:
|
|
197
|
+
records: list[CallRecord] = []
|
|
198
|
+
if self._rubrics is None:
|
|
199
|
+
page = await self._request("GET", "/v1/rubrics", records)
|
|
200
|
+
out = []
|
|
201
|
+
for summary in page["data"]:
|
|
202
|
+
detail = await self._request("GET", f"/v1/rubrics/{summary['paper_type']}",
|
|
203
|
+
records)
|
|
204
|
+
out.append(Rubric(paper_type=detail["paper_type"], label=detail["label"],
|
|
205
|
+
rubric_version=detail.get("rubric_version"),
|
|
206
|
+
total_weight=float(detail["total_weight"]),
|
|
207
|
+
dimensions=list(detail["dimensions"])))
|
|
208
|
+
self._rubrics = out
|
|
209
|
+
return list(self._rubrics), records
|
|
210
|
+
|
|
211
|
+
async def detect_type(self, title: str, abstract: str | None = None
|
|
212
|
+
) -> TypeGuess | NotApplicable:
|
|
213
|
+
records: list[CallRecord] = []
|
|
214
|
+
body: dict[str, Any] = {"title": title}
|
|
215
|
+
if abstract:
|
|
216
|
+
body["abstract"] = abstract
|
|
217
|
+
resp = await self._request("POST", "/v1/paper-types/detect", records, json_body=body)
|
|
218
|
+
guess = str(resp["paper_type"])
|
|
219
|
+
if guess not in PAPER_TYPES:
|
|
220
|
+
return NotApplicable(reason="detected type is not one of the five PureTrace types",
|
|
221
|
+
detected=guess, records=records)
|
|
222
|
+
return TypeGuess(paper_type=guess, confidence=float(resp["confidence"]),
|
|
223
|
+
records=records)
|
|
224
|
+
|
|
225
|
+
async def assess(self, pdf: bytes, title: str, authors: str, artifacts: Artifacts,
|
|
226
|
+
paper_type: str | None, *, use: Use) -> Assessment | NotApplicable:
|
|
227
|
+
if use not in self._uses:
|
|
228
|
+
raise UseNotEnabled(f"the run did not enable PureTrace {use}")
|
|
229
|
+
records: list[CallRecord] = []
|
|
230
|
+
if paper_type is None:
|
|
231
|
+
guess = await self.detect_type(title)
|
|
232
|
+
records.extend(guess.records)
|
|
233
|
+
if isinstance(guess, NotApplicable):
|
|
234
|
+
guess.records = records
|
|
235
|
+
return guess
|
|
236
|
+
paper_type = guess.paper_type
|
|
237
|
+
elif paper_type not in PAPER_TYPES:
|
|
238
|
+
return NotApplicable(reason="paper type is not one of the five PureTrace types",
|
|
239
|
+
detected=paper_type, records=records)
|
|
240
|
+
|
|
241
|
+
pdf_hash = _sha256(pdf)
|
|
242
|
+
fields = {"title": title, "authors": authors, "paper_type": paper_type,
|
|
243
|
+
**{k: v for k, v in artifacts.__dict__.items() if v is not None}}
|
|
244
|
+
idempotency_key = _sha256(f"{self._run_id}:{pdf_hash}".encode())
|
|
245
|
+
try:
|
|
246
|
+
paper = await self._request(
|
|
247
|
+
"POST", "/v1/papers", records, data=fields,
|
|
248
|
+
files={"file": ("paper.pdf", pdf, "application/pdf")},
|
|
249
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
250
|
+
described_body={"form": fields, "pdf_sha256": pdf_hash,
|
|
251
|
+
"idempotency_key": idempotency_key, "use": use})
|
|
252
|
+
paper = await self._poll_until_done(paper, records)
|
|
253
|
+
return await self._assessment(paper, records)
|
|
254
|
+
except PureTraceError as exc:
|
|
255
|
+
exc.records = list(records)
|
|
256
|
+
raise
|
|
257
|
+
|
|
258
|
+
async def report(self, paper_id: str) -> Assessment:
|
|
259
|
+
records: list[CallRecord] = []
|
|
260
|
+
try:
|
|
261
|
+
paper = await self._request("GET", f"/v1/papers/{paper_id}", records)
|
|
262
|
+
if paper["status"] == "failed":
|
|
263
|
+
raise AssessmentFailed(f"paper {paper_id} failed", list(records))
|
|
264
|
+
if paper["status"] not in ASSESSED_STATUSES:
|
|
265
|
+
raise RequestRejected(f"paper {paper_id} is still {paper['status']}",
|
|
266
|
+
list(records), "not_assessed")
|
|
267
|
+
return await self._assessment(paper, records)
|
|
268
|
+
except PureTraceError as exc:
|
|
269
|
+
exc.records = list(records)
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
# ---- internals ---------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
async def _poll_until_done(self, paper: dict[str, Any],
|
|
275
|
+
records: list[CallRecord]) -> dict[str, Any]:
|
|
276
|
+
deadline = self._monotonic() + self._timeout
|
|
277
|
+
interval = self._poll
|
|
278
|
+
while paper["status"] not in TERMINAL_STATUSES:
|
|
279
|
+
if self._monotonic() >= deadline:
|
|
280
|
+
raise AssessmentTimeout(f"paper {paper['id']} still {paper['status']}")
|
|
281
|
+
await self._sleep(interval)
|
|
282
|
+
interval = min(interval * 1.5, self._poll_max)
|
|
283
|
+
paper = await self._request("GET", f"/v1/papers/{paper['id']}", records)
|
|
284
|
+
if paper["status"] == "failed":
|
|
285
|
+
raise AssessmentFailed(f"paper {paper['id']} failed")
|
|
286
|
+
return paper
|
|
287
|
+
|
|
288
|
+
async def _assessment(self, paper: dict[str, Any],
|
|
289
|
+
records: list[CallRecord]) -> Assessment:
|
|
290
|
+
rep = await self._request("GET", f"/v1/papers/{paper['id']}/report", records)
|
|
291
|
+
try:
|
|
292
|
+
return self._parse_assessment(paper, rep, records)
|
|
293
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
294
|
+
# A response missing the §4 fields is an upstream fault, never a partial score.
|
|
295
|
+
raise ServiceUnavailable(f"unexpected report shape: {type(exc).__name__} {exc}",
|
|
296
|
+
list(records)) from None
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _parse_assessment(paper: dict[str, Any], rep: dict[str, Any],
|
|
300
|
+
records: list[CallRecord]) -> Assessment:
|
|
301
|
+
score = rep["score"]
|
|
302
|
+
return Assessment(
|
|
303
|
+
paper_id=paper["id"], status=paper["status"], paper_type=score["paper_type"],
|
|
304
|
+
rubric_version=score.get("rubric_version"), overall=float(score["overall"]),
|
|
305
|
+
provisional=bool(score.get("provisional", False)),
|
|
306
|
+
dimensions=[Dimension(key=d["key"], label=d["label"], score=float(d["score"]),
|
|
307
|
+
weight=float(d["weight"]),
|
|
308
|
+
confidence=float(d["confidence"]),
|
|
309
|
+
rationale=d.get("rationale"),
|
|
310
|
+
needs_author_input=bool(d.get("needs_author_input", False)))
|
|
311
|
+
for d in score["dimensions"]],
|
|
312
|
+
facts=[Fact(key=f["key"], label=f["label"], value=str(f["value"]),
|
|
313
|
+
confidence=f.get("confidence"), source_excerpt=f.get("source_excerpt"))
|
|
314
|
+
for f in rep["facts"]],
|
|
315
|
+
open_questions=[Question(question_id=q["question_id"], fact_key=q["fact_key"],
|
|
316
|
+
label=q["label"], prompt=q["prompt"],
|
|
317
|
+
required=bool(q.get("required", False)))
|
|
318
|
+
for q in rep.get("questions", [])],
|
|
319
|
+
paper_hash=rep.get("paper_hash") or paper.get("paper_hash"),
|
|
320
|
+
records=list(records))
|
puretrace/types.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""PureTrace adapter types (02-puretrace.md §3, §4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
PaperType = Literal["vision", "llm_finetune", "llm_pretrain", "rl", "graph_ml"]
|
|
9
|
+
PAPER_TYPES: tuple[str, ...] = ("vision", "llm_finetune", "llm_pretrain", "rl", "graph_ml")
|
|
10
|
+
Use = Literal["screening", "selfcheck"]
|
|
11
|
+
USES: tuple[str, ...] = ("screening", "selfcheck")
|
|
12
|
+
|
|
13
|
+
# Polling stops on these. `anchoring` and `certified` can come back when PureTrace returns an
|
|
14
|
+
# existing paper for a PDF the organization already holds (deduplication); the adapter never
|
|
15
|
+
# certifies anything itself (INV-1).
|
|
16
|
+
ASSESSED_STATUSES = ("needs_author_input", "ready_to_certify", "anchoring", "certified")
|
|
17
|
+
TERMINAL_STATUSES = (*ASSESSED_STATUSES, "failed")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CallRecord:
|
|
22
|
+
"""One HTTP call (01-purescholar.md §4 shape, KB-015). Never contains the key."""
|
|
23
|
+
|
|
24
|
+
call_id: str
|
|
25
|
+
endpoint: str
|
|
26
|
+
request_digest: str
|
|
27
|
+
response_digest: str | None
|
|
28
|
+
status: int | None # HTTP status, or None when no response arrived
|
|
29
|
+
started_at: str
|
|
30
|
+
duration_ms: int
|
|
31
|
+
service_version: str | None
|
|
32
|
+
quota_remaining: str | None
|
|
33
|
+
error_type: str | None
|
|
34
|
+
request_body: str
|
|
35
|
+
response_body: str | None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
return dict(self.__dict__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Artifacts:
|
|
43
|
+
code_repository_url: str | None = None
|
|
44
|
+
dataset_url: str | None = None
|
|
45
|
+
model_weights_url: str | None = None
|
|
46
|
+
environment_url: str | None = None
|
|
47
|
+
evaluation_url: str | None = None
|
|
48
|
+
notes: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Dimension:
|
|
53
|
+
key: str
|
|
54
|
+
label: str
|
|
55
|
+
score: float
|
|
56
|
+
weight: float
|
|
57
|
+
confidence: float
|
|
58
|
+
rationale: str | None
|
|
59
|
+
needs_author_input: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Fact:
|
|
64
|
+
key: str
|
|
65
|
+
label: str
|
|
66
|
+
value: str
|
|
67
|
+
confidence: float | None
|
|
68
|
+
source_excerpt: str | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Question:
|
|
73
|
+
question_id: str
|
|
74
|
+
fact_key: str
|
|
75
|
+
label: str
|
|
76
|
+
prompt: str
|
|
77
|
+
required: bool
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Assessment:
|
|
82
|
+
"""A PureTrace result. The score always travels with its type, rubric version and
|
|
83
|
+
provisional flag (INV-4); scores are not comparable across paper types."""
|
|
84
|
+
|
|
85
|
+
paper_id: str
|
|
86
|
+
status: str
|
|
87
|
+
paper_type: str
|
|
88
|
+
rubric_version: str | None
|
|
89
|
+
overall: float
|
|
90
|
+
provisional: bool
|
|
91
|
+
dimensions: list[Dimension]
|
|
92
|
+
facts: list[Fact]
|
|
93
|
+
open_questions: list[Question]
|
|
94
|
+
paper_hash: str | None
|
|
95
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class TypeGuess:
|
|
100
|
+
paper_type: str
|
|
101
|
+
confidence: float
|
|
102
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class NotApplicable:
|
|
107
|
+
"""The paper is outside the five PureTrace types (INV-5). Nothing was submitted."""
|
|
108
|
+
|
|
109
|
+
reason: str
|
|
110
|
+
detected: str | None = None
|
|
111
|
+
records: list[CallRecord] = field(default_factory=list)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Rubric:
|
|
116
|
+
paper_type: str
|
|
117
|
+
label: str
|
|
118
|
+
rubric_version: str | None
|
|
119
|
+
total_weight: float
|
|
120
|
+
dimensions: list[dict[str, Any]]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---- errors (KB-015) --------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
class PureTraceError(Exception):
|
|
126
|
+
def __init__(self, message: str, records: list[CallRecord] | None = None,
|
|
127
|
+
code: str | None = None) -> None:
|
|
128
|
+
super().__init__(message)
|
|
129
|
+
self.records: list[CallRecord] = records or []
|
|
130
|
+
self.code = code
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AuthError(PureTraceError):
|
|
134
|
+
"""401, or 403 (for example `insufficient_scope`)."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class NotFound(PureTraceError):
|
|
138
|
+
"""404: does not exist, or belongs to another organization."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class RequestRejected(PureTraceError):
|
|
142
|
+
"""400, 409 (other than `idempotency_in_progress`), 422."""
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class RateLimited(PureTraceError):
|
|
146
|
+
"""`429 rate_limit_exceeded` after the retries."""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class QuotaExhausted(PureTraceError):
|
|
150
|
+
"""`429 quota_exceeded`: the monthly allowance is spent; not retried (KB-016)."""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ServiceUnavailable(PureTraceError):
|
|
154
|
+
"""5xx, timeouts or connection failures after the retries."""
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class AssessmentFailed(PureTraceError):
|
|
158
|
+
"""PureTrace marked the paper `failed`."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class AssessmentTimeout(PureTraceError):
|
|
162
|
+
"""The assessment did not finish within the configured timeout."""
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class UseNotEnabled(PureTraceError):
|
|
166
|
+
"""The run did not enable this use; nothing was submitted (INV-3)."""
|
writer/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""writer: the paper and the run bundle, built from the ledger
|
|
2
|
+
(docs/knowledge_base/06-writer.md)."""
|
|
3
|
+
|
|
4
|
+
from .core import Writer
|
|
5
|
+
from .types import BuildResult, CompilerUnavailable, PaperOptions, RunContext, WriterError
|
|
6
|
+
|
|
7
|
+
__all__ = ["BuildResult", "CompilerUnavailable", "PaperOptions", "RunContext", "Writer",
|
|
8
|
+
"WriterError"]
|