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
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)
|