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
app/bundled.py ADDED
@@ -0,0 +1,521 @@
1
+ """The bundled local PureScholar (08-app.md §5 step 12, INV-8; KB-025, KB-031).
2
+
3
+ Per tenant: a private Docker network, a pgvector container with no published port and a
4
+ named volume (the corpus survives restarts), and the PureScholar backend in headless mode,
5
+ published on 127.0.0.1 only. `app` reaches it only through Docker: readiness is the container's
6
+ health check, and the API key reaches PureScholar only as its SHA-256. The `purescholar`
7
+ adapter is the only HTTP client (SYS-INV-5).
8
+
9
+ `status_for` and `url` read the state the start task recorded and confirm the backend is still
10
+ healthy (a dead container never hands out its old port); they never decrypt. `Starter` is
11
+ the literature-service task (a worker process): it decrypts only the LLM credentials
12
+ PureScholar uses and the keys of enabled literature sources (§5 step 3, KB-039). State
13
+ changes are compare-and-set, so a start never overwrites a newer request or a stop made while
14
+ it ran; a start that loses removes what it created.
15
+ Secrets reach `docker run` through the subprocess environment (`-e NAME` without a value),
16
+ never on a command line. Stopping removes the backend container, and with it the LLM key in
17
+ Docker's state (§9); the database container and its volume stay."""
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import ipaddress
23
+ import json
24
+ import os
25
+ import secrets
26
+ import socket
27
+ import subprocess
28
+ import time
29
+ from collections.abc import Callable
30
+ from dataclasses import dataclass
31
+ from typing import Any
32
+ from urllib.parse import urlsplit
33
+
34
+ from cryptography.hazmat.primitives import hashes
35
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
36
+
37
+ from app import literature_sources, model_endpoints, tunnels
38
+ from app.db import Database
39
+ from app.repo import TenantRepo
40
+ from app.settings import Settings
41
+ from app.setup import PURESCHOLAR_CREDENTIAL, LocalStatus, ServiceStatus
42
+
43
+ # Pinned by digest (08 §5 step 12). pgvector/pgvector:pg16 as of 2026-10-29.
44
+ PGVECTOR_IMAGE = ("pgvector/pgvector@sha256:"
45
+ "1d533553fefe4f12e5d80c7b80622ba0c382abb5758856f52983d8789179f0fb")
46
+ # The PureScholar backend at the pinned commit (01-purescholar.md §8, d2d2693), by the digest
47
+ # of its multi-arch index (linux/amd64, linux/arm64) on GitHub Container Registry (KB-031,
48
+ # KB-040).
49
+ # `PM_PURESCHOLAR_IMAGE` overrides it for development and tests; without either, local mode
50
+ # reports failed.
51
+ PURESCHOLAR_IMAGE: str | None = (
52
+ "ghcr.io/hamzalabwork-sudo/purescholar-backend@sha256:"
53
+ "cbaee84502c582771d12d795f8c00c2b8d25a8ed616a016ef762e6b837384729")
54
+ NOT_PUBLISHED = ("the bundled PureScholar image is not published yet; "
55
+ "use a remote PureScholar for now")
56
+ NOT_RUNNING = ("the bundled PureScholar is not running; restart puremethod, or save the "
57
+ "literature setting again")
58
+ NO_SENTENCE_TRANSFORMERS = ("this PureScholar image does not include the local embedding "
59
+ "model yet; choose another search quality")
60
+ TOKEN_PREFIX = "psk_live_" # noqa: S105 (the public prefix of PureScholar keys)
61
+ READY_TIMEOUT_S = 300.0
62
+ # A deep `/ask` (sub-questions, reflection rounds, rate-limited searches) often runs past the
63
+ # adapter's 120 s default, and a timed-out ask is retried from scratch: a run paused after
64
+ # four full attempts (DL-20261102-02). The bundled instance serves only this machine.
65
+ REQUEST_TIMEOUT_S = 600.0
66
+ POLL_S = 2.0
67
+ PROBE_TIMEOUT_S = 5.0 # the liveness probe runs in API requests: a hung Docker is "not healthy"
68
+
69
+ Docker = Callable[[list[str], dict[str, str] | None], tuple[int, str]]
70
+
71
+
72
+ def run_docker(args: list[str], env: dict[str, str] | None = None,
73
+ timeout: float = 300.0) -> tuple[int, str]:
74
+ """`docker <args>`; `env` adds variables that `-e NAME` arguments read. No shell."""
75
+ try:
76
+ proc = subprocess.run(["docker", *args], # noqa: S603, S607
77
+ env={**os.environ, **env} if env else None,
78
+ capture_output=True, text=True, encoding="utf-8",
79
+ errors="replace", timeout=timeout, check=False)
80
+ except FileNotFoundError:
81
+ return 127, "docker is not installed"
82
+ except subprocess.TimeoutExpired:
83
+ return 124, "docker did not answer"
84
+ return proc.returncode, (proc.stdout + proc.stderr).strip()
85
+
86
+
87
+ def _docker(docker: Docker | None) -> Docker:
88
+ return docker or run_docker # resolved per call, so tests can replace run_docker
89
+
90
+
91
+ def image(settings: Settings) -> str | None:
92
+ return settings.purescholar_image or PURESCHOLAR_IMAGE
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Names:
97
+ network: str
98
+ db: str
99
+ backend: str
100
+ volume: str
101
+
102
+ @classmethod
103
+ def of(cls, tenant_id: str) -> Names:
104
+ s = hashlib.sha256(tenant_id.encode()).hexdigest()[:10]
105
+ return cls(f"puremethod-ps-{s}", f"puremethod-ps-db-{s}", f"puremethod-ps-{s}-api",
106
+ f"puremethod-ps-data-{s}")
107
+
108
+
109
+ def db_password(master_key: bytes) -> str:
110
+ """Derived, never stored (KB-031): the database is reachable only on its own network."""
111
+ return HKDF(algorithm=hashes.SHA256(), length=24, salt=None,
112
+ info=b"purescholar-db").derive(master_key).hex()
113
+
114
+
115
+ def _healthy(tenant_id: str, docker: Docker | None) -> bool:
116
+ args = ["inspect", "-f", "{{.State.Status}} {{.State.Health.Status}}",
117
+ Names.of(tenant_id).backend]
118
+ code, out = docker(args, None) if docker else run_docker(args, None,
119
+ timeout=PROBE_TIMEOUT_S)
120
+ return code == 0 and out.strip() == "running healthy"
121
+
122
+
123
+ def _loopback(host: str) -> bool:
124
+ if host.lower() == "localhost" or host.lower().endswith(".localhost"):
125
+ return True
126
+ try:
127
+ ip = ipaddress.ip_address(host)
128
+ except ValueError:
129
+ return False # a name: the container resolves it itself
130
+ mapped = getattr(ip, "ipv4_mapped", None)
131
+ return ip.is_loopback or ip.is_unspecified or bool(mapped and mapped.is_loopback)
132
+
133
+
134
+ # ---- read side (API process and run workers) -----------------------------------------------
135
+
136
+ def status_for(settings: Settings, docker: Docker | None = None) -> LocalStatus:
137
+ def status(repo: TenantRepo) -> ServiceStatus:
138
+ if image(settings) is None:
139
+ return ServiceStatus("failed", NOT_PUBLISHED)
140
+ row = repo.bundled()
141
+ if row is None:
142
+ return ServiceStatus("stopped", "the bundled PureScholar has not started yet")
143
+ state = row["state"]
144
+ if state in ("pending", "starting"):
145
+ return ServiceStatus("starting", "starting the bundled PureScholar")
146
+ if state == "ready":
147
+ if _healthy(repo.tenant_id, docker):
148
+ return ServiceStatus("ready")
149
+ return ServiceStatus("failed", NOT_RUNNING)
150
+ return ServiceStatus(state, row["reason"] or state)
151
+ return status
152
+
153
+
154
+ def url(repo: TenantRepo, docker: Docker | None = None) -> str | None:
155
+ """The bundled PureScholar's address while it is ready and healthy, else None: a run never
156
+ sends its key to a port a dead container left behind."""
157
+ row = repo.bundled()
158
+ if row is None or row["state"] != "ready" or not row["port"]:
159
+ return None
160
+ if not _healthy(repo.tenant_id, docker):
161
+ return None
162
+ return f"http://127.0.0.1:{int(row['port'])}"
163
+
164
+
165
+ def request_start(repo: TenantRepo) -> None:
166
+ """Asks the supervisor for a start task (on local mode, and at server start). A start
167
+ already running sees the new request and its result is discarded."""
168
+ repo.set_bundled("pending", "")
169
+
170
+
171
+ # ---- the literature-service task (worker side) --------------------------------------------
172
+
173
+ class StartFailed(Exception):
174
+ """A reason the user can act on. Never carries a credential value."""
175
+
176
+
177
+ def _free_port() -> int:
178
+ with socket.socket() as s:
179
+ s.bind(("127.0.0.1", 0))
180
+ return int(s.getsockname()[1])
181
+
182
+
183
+ # A model with no key: PureScholar counts a provider as configured only with a key set, and
184
+ # sends it as a bearer token, which such servers ignore. Not a secret (08 §5 step 12, KB-038).
185
+ PLACEHOLDER_KEY = "no-key-needed"
186
+
187
+
188
+ def model_source(repo: TenantRepo, setting: dict[str, Any], mode: str) -> dict[str, Any]:
189
+ """The model PureScholar generates with: provider, model, the credential name ("" for none)
190
+ and its endpoint (`base_url`, `local_port` or `ssh`). From the LLM credential's latest
191
+ passed check (KB-031), or from a profile role of a checked profile (KB-038, KB-041).
192
+ Raises StartFailed saying what to check first."""
193
+ if setting.get("llm_profile"):
194
+ profile = repo.get_profile(str(setting["llm_profile"]))
195
+ role = str(setting.get("llm_role") or "analyst")
196
+ if profile is None or role not in profile.roles:
197
+ raise StartFailed("the LLM profile chosen for PureScholar no longer exists")
198
+ if not repo.profile_checked(profile): # its current version (KB-041)
199
+ raise StartFailed(f"check the LLM profile {profile.name!r} first: PureScholar "
200
+ "uses its model")
201
+ spec = profile.roles[role]
202
+ try:
203
+ expanded = model_endpoints.expand(spec, mode, repo.resolve_machine) # type: ignore[arg-type]
204
+ except (KeyError, ValueError) as exc:
205
+ raise StartFailed(f"the profile's SSH machine cannot be used: {exc}") from None
206
+ source: dict[str, Any] = {"provider": str(spec["provider"]), "model": str(spec["model"]),
207
+ "credential": str(spec.get("credential") or "")}
208
+ for key in ("base_url", "ssh"):
209
+ if key in expanded:
210
+ source[key] = expanded[key]
211
+ if spec.get("local_port"):
212
+ source["local_port"] = int(spec["local_port"])
213
+ source.pop("base_url", None) # the bridge gives the container its address
214
+ return source
215
+ name = str(setting["llm_credential"])
216
+ cred = next((c for c in repo.list_credentials() if c.name == name), None)
217
+ cc = repo.latest_credential_check(cred.id) if cred else None
218
+ if cred is None or cc is None or cc.kind != "llm" or not (
219
+ cc.report and cc.report.get("ok")):
220
+ raise StartFailed(f"check the LLM key {name!r} first: PureScholar uses its model")
221
+ source = {"provider": str(cc.request["provider"]), "model": str(cc.request["model"]),
222
+ "credential": name}
223
+ for key in ("base_url", "local_port", "ssh"):
224
+ if cc.request.get(key):
225
+ source[key] = cc.request[key]
226
+ return source
227
+
228
+
229
+ def _generation_env(repo: TenantRepo, setting: dict[str, Any],
230
+ lookup: Callable[[str], str], source: dict[str, Any]) -> dict[str, str]:
231
+ """PureScholar's model settings from `model_source`."""
232
+ name = str(source["credential"])
233
+ provider, model = str(source["provider"]), str(source["model"])
234
+ key = lookup(name) if name else PLACEHOLDER_KEY
235
+ embedding = str(setting["embedding"])
236
+ if embedding == "sentence-transformers":
237
+ raise StartFailed(NO_SENTENCE_TRANSFORMERS)
238
+ if provider == "anthropic":
239
+ env = {"LLM_PROVIDER": "anthropic", "ANTHROPIC_API_KEY": key, "ANTHROPIC_MODEL": model}
240
+ else:
241
+ env = {"LLM_PROVIDER": "openai", "OPENAI_API_KEY": key, "OPENAI_MODEL": model}
242
+ if provider == "openai_compatible" and bridge_spec(source) is not None:
243
+ pass # OPENAI_API_BASE is the bridge's address, set by `Starter` (KB-036)
244
+ elif provider == "openai_compatible":
245
+ base = str(source.get("base_url") or "").rstrip("/")
246
+ if _loopback(urlsplit(base).hostname or ""):
247
+ raise StartFailed("the bundled PureScholar runs in a container and cannot reach "
248
+ "a model on 127.0.0.1; use a remote PureScholar, or a model "
249
+ "address the container can reach")
250
+ # PureScholar appends /v1/chat/completions to OPENAI_API_BASE.
251
+ if not base.endswith("/v1"):
252
+ raise StartFailed("the bundled PureScholar needs an OpenAI-compatible base URL "
253
+ "ending in /v1")
254
+ env["OPENAI_API_BASE"] = base[: -len("/v1")]
255
+ if provider == "openai_compatible":
256
+ if embedding == "openai":
257
+ # PureScholar's OpenAI embeddings ignore OPENAI_API_BASE: the key would go to
258
+ # api.openai.com.
259
+ raise StartFailed("OpenAI embeddings need an OpenAI model key; with an "
260
+ "OpenAI-compatible model, choose the basic search quality")
261
+ env["EMBED_BACKEND"] = embedding
262
+ if embedding == "openai":
263
+ other = str(setting.get("openai_credential") or "")
264
+ if "OPENAI_API_KEY" in env and other != name:
265
+ raise StartFailed("PureScholar uses one OpenAI key for chat and embeddings; use the "
266
+ "same key for both, or another search quality")
267
+ env["OPENAI_API_KEY"] = lookup(other)
268
+ return env
269
+
270
+
271
+ # ---- a local or tunnelled model for the container (08 §5 step 12, KB-036) ---------------------
272
+
273
+ DOCKER_HOST_NAME = "host.docker.internal"
274
+ KEEP_ALIVE_POLL_S = 5.0
275
+
276
+
277
+ def bridge_spec(request: dict[str, Any]) -> dict[str, Any] | None:
278
+ """What the container needs a bridge to: a model on this computer (`local_port`) or on an
279
+ SSH machine (`ssh`), from `model_source`. None for a URL."""
280
+ if request.get("local_port"):
281
+ return {"local_port": int(request["local_port"])}
282
+ if isinstance(request.get("ssh"), dict):
283
+ return {"ssh": request["ssh"]}
284
+ return None
285
+
286
+
287
+ class Bridge:
288
+ """The address the container uses for the model, and what keeps it open. On Docker
289
+ Desktop the container reaches the host's loopback as host.docker.internal, so a tunnel
290
+ listens on 127.0.0.1 and a local model needs nothing. On Docker Engine it cannot, so the
291
+ tunnel, or a relay for a local model, listens on the bundled network's gateway address
292
+ (INV-8)."""
293
+
294
+ def __init__(self, host: str, port: int, keeper: Any = None) -> None:
295
+ self.host, self.port, self.keeper = host, port, keeper
296
+
297
+ @property
298
+ def url(self) -> str: # PureScholar appends /v1/chat/completions
299
+ return f"http://{self.host}:{self.port}"
300
+
301
+ def close(self) -> None:
302
+ if self.keeper is not None:
303
+ self.keeper.close()
304
+
305
+
306
+ class Starter:
307
+ """Starts (or restarts) one tenant's bundled PureScholar and records the outcome."""
308
+
309
+ def __init__(self, settings: Settings, repo: TenantRepo, *, docker: Docker | None = None,
310
+ clock: Callable[[], float] = time.monotonic,
311
+ sleep: Callable[[float], None] = time.sleep,
312
+ timeout_s: float = READY_TIMEOUT_S) -> None:
313
+ self._settings, self._repo, self._docker = settings, repo, _docker(docker)
314
+ self._clock, self._sleep, self._timeout = clock, sleep, timeout_s
315
+ self.names = Names.of(repo.tenant_id)
316
+ self.bridge: Bridge | None = None # kept open by the literature task while it runs
317
+
318
+ def credentials_needed(self) -> set[str]:
319
+ setting = self._repo.service_setting("literature") or {}
320
+ names = {str(n) for n in (setting.get("llm_credential"),
321
+ setting.get("openai_credential")) if n}
322
+ if setting.get("mode") != "local":
323
+ return names
324
+ # The keys of enabled sources only (08 §5 step 3, KB-039).
325
+ names |= set(literature_sources.keys_needed(setting).values())
326
+ try:
327
+ source = model_source(self._repo, setting, self._settings.mode)
328
+ except StartFailed:
329
+ return names # the start reports it
330
+ if source["credential"]:
331
+ names.add(str(source["credential"]))
332
+ spec = bridge_spec(source)
333
+ if spec and "ssh" in spec: # the tunnel's SSH keys (KB-036)
334
+ names |= {str(h["key_credential"]) for h in spec["ssh"].get("hops", [])}
335
+ return names
336
+
337
+ def run(self, lookup: Callable[[str], str], scrub: Callable[[str], str]) -> None:
338
+ if not self._repo.set_bundled("starting", expect=("pending", "starting")):
339
+ return # stopped or already settled meanwhile
340
+ setting = self._repo.service_setting("literature")
341
+ if not setting or setting.get("mode") != "local":
342
+ self._remove_backend()
343
+ self._repo.set_bundled("stopped", "the literature service is not local",
344
+ expect=("starting",))
345
+ return
346
+ img = image(self._settings)
347
+ if img is None:
348
+ self._repo.set_bundled("failed", NOT_PUBLISHED, expect=("starting",))
349
+ return
350
+ try:
351
+ source = model_source(self._repo, setting, self._settings.mode)
352
+ env = _generation_env(self._repo, setting, lookup, source)
353
+ env |= literature_sources.env(setting, lookup) # KB-039
354
+ spec = bridge_spec(source)
355
+ if spec is not None:
356
+ self.bridge = self._bridge(spec, lookup)
357
+ env["OPENAI_API_BASE"] = self.bridge.url
358
+ key = self._key()
359
+ port = self._start(img, env, key[0], bridged=spec is not None)
360
+ except StartFailed as exc:
361
+ self._close_bridge()
362
+ self._repo.set_bundled("failed", scrub(str(exc)), expect=("starting",))
363
+ return
364
+ still_local = (self._repo.service_setting("literature") or {}).get("mode") == "local"
365
+ if not still_local or not self._repo.set_bundled("ready", port=port, key=key,
366
+ expect=("starting",)):
367
+ # Stopped, switched to remote or re-requested while this ran: what it started
368
+ # must not keep running with the LLM key (review of slice 4, MAJOR-2). A newer
369
+ # start brings the database back up itself.
370
+ self._remove_backend()
371
+ self._docker(["stop", "-t", "10", self.names.db], None)
372
+ self._close_bridge()
373
+
374
+ def _close_bridge(self) -> None:
375
+ if self.bridge is not None:
376
+ self.bridge.close()
377
+ self.bridge = None
378
+
379
+ def _desktop(self) -> bool:
380
+ code, out = self._docker(["info", "--format", "{{.OperatingSystem}}"], None)
381
+ return code == 0 and "docker desktop" in out.lower()
382
+
383
+ def _gateway(self) -> str:
384
+ n = self.names
385
+ if self._docker(["network", "inspect", n.network], None)[0] != 0:
386
+ self._ok(["network", "create", "--label", f"puremethod.tenant={n.network}",
387
+ n.network])
388
+ out = self._ok(["network", "inspect", "-f", "{{(index .IPAM.Config 0).Gateway}}",
389
+ n.network]).strip()
390
+ if not out:
391
+ raise StartFailed("cannot find the bundled network's gateway address")
392
+ return out
393
+
394
+ def _bridge(self, spec: dict[str, Any], lookup: Callable[[str], str]) -> Bridge:
395
+ desktop = self._desktop()
396
+ bind = "127.0.0.1" if desktop else self._gateway()
397
+ host = DOCKER_HOST_NAME if desktop else bind
398
+ if "local_port" in spec:
399
+ if desktop:
400
+ return Bridge(host, int(spec["local_port"]))
401
+ relay = tunnels.Relay(int(spec["local_port"]), bind)
402
+ return Bridge(host, relay.open(), relay)
403
+ ssh = spec["ssh"]
404
+ tunnel = tunnels.Tunnel(list(ssh["hops"]), int(ssh["remote_port"]), lookup, bind)
405
+ return Bridge(host, tunnel.open(), tunnel)
406
+
407
+ def keep_open(self, sleep: Callable[[float], None] = time.sleep) -> None:
408
+ """While the backend runs on this bridge, keep it open (08 §5 step 12): until the
409
+ service is stopped or restarted, or the backend container is gone."""
410
+ try:
411
+ while self.bridge is not None:
412
+ row = self._repo.bundled() or {}
413
+ if row.get("state") != "ready" or \
414
+ self._docker(["container", "inspect", self.names.backend], None)[0] != 0:
415
+ return
416
+ sleep(KEEP_ALIVE_POLL_S)
417
+ finally:
418
+ self._close_bridge()
419
+
420
+ def _remove_backend(self) -> None:
421
+ self._docker(["rm", "-f", self.names.backend], None)
422
+
423
+ def _key(self) -> tuple[str, str]:
424
+ """(sha256, credential id) of the PureScholar key. Generated here, sealed as the
425
+ `purescholar` credential, and never decrypted again (KB-031). Regenerated whenever the
426
+ stored `purescholar` credential is not the one generated (review, BLOCKER-1)."""
427
+ row = self._repo.bundled() or {}
428
+ cred = next((c for c in self._repo.list_credentials()
429
+ if c.name == PURESCHOLAR_CREDENTIAL), None)
430
+ if cred is not None and row.get("key_sha256") and row.get("key_credential_id") == cred.id:
431
+ return str(row["key_sha256"]), cred.id
432
+ token = TOKEN_PREFIX + secrets.token_urlsafe(32)
433
+ if cred is not None:
434
+ self._repo.delete_credential(cred.id)
435
+ made = self._repo.add_credential(PURESCHOLAR_CREDENTIAL, "purescholar-key", token)
436
+ key = (hashlib.sha256(token.encode()).hexdigest(), made.id)
437
+ self._repo.set_bundled("starting", key=key, expect=("starting",))
438
+ return key
439
+
440
+ def _ok(self, args: list[str], env: dict[str, str] | None = None) -> str:
441
+ code, out = self._docker(args, env)
442
+ if code != 0:
443
+ first = out.splitlines()[0] if out else f"exit {code}"
444
+ raise StartFailed(f"docker {args[0]} failed: {first}")
445
+ return out
446
+
447
+ def _start(self, img: str, gen_env: dict[str, str], digest: str,
448
+ bridged: bool = False) -> int:
449
+ n, label = self.names, f"puremethod.tenant={self.names.network}"
450
+ if self._docker(["network", "inspect", n.network], None)[0] != 0:
451
+ self._ok(["network", "create", "--label", label, n.network])
452
+ pw = db_password(self._settings.master_key)
453
+ if self._docker(["container", "inspect", n.db], None)[0] != 0:
454
+ self._ok(["run", "-d", "--name", n.db, "--network", n.network, "--label", label,
455
+ "-e", "POSTGRES_PASSWORD", "-e", "POSTGRES_DB=purechain",
456
+ "-v", f"{n.volume}:/var/lib/postgresql/data", PGVECTOR_IMAGE],
457
+ {"POSTGRES_PASSWORD": pw})
458
+ else:
459
+ self._ok(["start", n.db])
460
+ self._remove_backend() # new settings take effect on each start
461
+ port = _free_port()
462
+ env = {**gen_env, "DATABASE_URL": f"postgresql://postgres:{pw}@{n.db}:5432/purechain",
463
+ "BOOTSTRAP_API_KEY_SHA256": digest}
464
+ args = ["run", "-d", "--name", n.backend, "--network", n.network, "--label", label,
465
+ "-p", f"127.0.0.1:{port}:8000", "-e", "HEADLESS_MODE=true",
466
+ "-e", "BOOTSTRAP_LAB_NAME=Local", "-e", "ENABLE_EXTERNAL_RETRIEVAL=true"]
467
+ if bridged: # Docker Engine needs the name mapped; Docker Desktop has it already
468
+ args += ["--add-host", f"{DOCKER_HOST_NAME}:host-gateway"]
469
+ for name in sorted(env):
470
+ args += ["-e", name] # the value comes from the subprocess environment
471
+ self._ok([*args, img], env)
472
+ end = self._clock() + self._timeout
473
+ while self._clock() < end:
474
+ code, health = self._docker(["inspect", "-f",
475
+ "{{.State.Status}} {{.State.Health.Status}}",
476
+ n.backend], None)
477
+ health = health.strip()
478
+ if code == 0 and health == "running healthy":
479
+ return port
480
+ if code != 0 or health.startswith(("exited", "dead")) or \
481
+ health.endswith("unhealthy"):
482
+ self._remove_backend()
483
+ raise StartFailed(f"the bundled PureScholar did not start; see "
484
+ f"`docker logs {n.backend}` before saving the setting again")
485
+ self._sleep(POLL_S)
486
+ self._remove_backend()
487
+ raise StartFailed(f"the bundled PureScholar was not healthy within "
488
+ f"{int(self._timeout)} s")
489
+
490
+
491
+ # ---- the server's start and shutdown (launcher side; crosses tenants, ids only) ------------
492
+
493
+ def local_tenants(db: Database) -> list[str]:
494
+ with db.tx() as c:
495
+ rows = c.execute("SELECT tenant_id, config FROM service_settings "
496
+ "WHERE service = 'literature'")
497
+ return [str(t) for t, cfg in rows if json.loads(cfg).get("mode") == "local"]
498
+
499
+
500
+ def start_all(db: Database) -> None:
501
+ for tid in local_tenants(db):
502
+ request_start(TenantRepo(db, tid))
503
+
504
+
505
+ def stop_tenant(repo: TenantRepo, reason: str, docker: Docker | None = None) -> None:
506
+ """Records the stop first, so a start still running cannot mark the service ready, then
507
+ removes the backend (and the LLM key in its Docker state) and stops the database. The
508
+ database's volume, and so the corpus, stays. No decryption."""
509
+ repo.set_bundled("stopped", reason)
510
+ n = Names.of(repo.tenant_id)
511
+ _docker(docker)(["rm", "-f", n.backend], None)
512
+ _docker(docker)(["stop", "-t", "10", n.db], None)
513
+
514
+
515
+ def stop_all(db: Database, docker: Docker | None = None) -> None:
516
+ """Stops every tenant's bundled PureScholar that has state, whatever its mode now
517
+ (`puremethod stop`, Ctrl+C)."""
518
+ with db.tx() as c:
519
+ tenants = [str(r[0]) for r in c.execute("SELECT tenant_id FROM bundled_services")]
520
+ for tid in tenants:
521
+ stop_tenant(TenantRepo(db, tid), "puremethod is not running", docker)