From 81d5ca24c19957773e9d6243e5ae29c372bd00e8 Mon Sep 17 00:00:00 2001 From: Lasse Server Date: Mon, 3 Aug 2026 14:19:51 +0200 Subject: [PATCH] Make the theme configurable; add a doctor and an assistant setup guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fork for Hungary would have looked Swedish. Only party colours were configurable — the warm paper, the institutional blue and the Garamond serif were hardcoded, and that palette was drawn from riksdagen.se deliberately. Nothing said so anywhere. A `theme:` block now sets those as CSS custom properties at runtime, the same mechanism party colours already used, so no stylesheet needs editing. README and PORTING.md both say plainly that the default is not neutral. scripts/doctor.py reports what a machine can and cannot do: Python and dependencies, PostgreSQL with its extensions and text-search setting, schema and row counts, the chat endpoint, whether that model can actually call tools, the embedding dimension against the configured one, GPU, sudo, and which ports are free. It changes nothing and exits non-zero on failures so a script can gate on it. Tool calling is checked separately because a model can converse perfectly and still never call a tool, which here means confident answers with no sources. docs/ASSISTANT-SETUP.md is a prompt plus the interview an assistant should run: ask the doctor before asking the user, one section at a time, always recommend a default, never invent a parliament's colours or data URL, and verify each stage with real output rather than an exit code. Verified against the live deployment: 26 checks OK, no failures. --- README.md | 18 +++ docs/ASSISTANT-SETUP.md | 173 ++++++++++++++++++++++ docs/PORTING.md | 22 ++- frontend/src/App.tsx | 5 + frontend/src/types.ts | 2 + parliament.py | 3 + parliament.yaml | 18 +++ scripts/doctor.py | 310 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 docs/ASSISTANT-SETUP.md create mode 100644 scripts/doctor.py diff --git a/README.md b/README.md index a8bba13..8a0d586 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ deploy/examples/ systemd units and an nginx site, with placeholders | | | |---|---| +| [docs/ASSISTANT-SETUP.md](docs/ASSISTANT-SETUP.md) | **Setting this up with an AI assistant** — a prompt to start with, and the interview it should run. Probably the fastest way in. | | [docs/SETUP.md](docs/SETUP.md) | Install, step by step: PostgreSQL with pgvector, a chat model (vLLM, Ollama, OpenRouter, Berget, OpenAI), an embeddings endpoint, and the first data load. Every step has a command that proves it worked, plus a symptom/cause/fix table. | | [docs/YOUR-PARLIAMENT.md](docs/YOUR-PARLIAMENT.md) | **Read this before changing anything.** Fork vs clone, how to add your parliament without creating merge conflicts, how to pull in updates, how to contribute back, and how to undo mistakes. Written for people who do not use git much. | | [docs/PORTING.md](docs/PORTING.md) | What a non-Swedish deployment actually has to write: `parliament.yaml`, an ingest adapter, prompts in your language. Honest about which parts are real work. | @@ -156,6 +157,23 @@ deployment keep its own values outside the repository entirely: Set `PROMPTS_RELOAD=1` in development to re-read prompt files on every call. +**Not sure what is missing?** + +```bash +python scripts/doctor.py +``` + +Checks Python, the database and its extensions, the text-search configuration, the chat +model, whether that model can actually call tools, the embedding dimension, and which +ports are free. Changes nothing. + +### Appearance + +The shipped design is deliberately Nordic-institutional and was drawn from riksdagen.se +— it will look Swedish wherever you deploy it. Override the `theme:` block in +`parliament.yaml` to match your own parliament's visual language; the values are +injected as CSS custom properties at runtime, so no stylesheet needs editing. + ### Running a fork If you maintain a deployment as a fork, keep it differing from upstream only in diff --git a/docs/ASSISTANT-SETUP.md b/docs/ASSISTANT-SETUP.md new file mode 100644 index 0000000..67f1c1c --- /dev/null +++ b/docs/ASSISTANT-SETUP.md @@ -0,0 +1,173 @@ +# Setting up plenum with an AI assistant + +Most people setting this up will do it with an assistant rather than alone. This page +is written for that: a prompt to start with, and the interview the assistant should run. + +--- + +## Give your assistant this + +> I want to set up **plenum** for the *[your parliament]*. The repository is at +> `[path]`. Read `docs/ASSISTANT-SETUP.md` and walk me through it — ask me the +> questions in the interview one section at a time, run the checks yourself, and tell +> me what you find before changing anything. I am not an expert on databases or git, +> so explain trade-offs in plain terms and tell me when a choice does not matter. + +--- + +## For the assistant + +Your job is to end with a running deployment and a user who understands what was set +up. Rules: + +1. **Run `python scripts/doctor.py` first**, before asking anything. It reports the + machine's actual state — Python version, database, models, GPU, free ports. Several + interview questions answer themselves from its output; do not ask what you can see. +2. **Ask one section at a time.** Do not present all twenty questions at once. +3. **Recommend a default for every question** and say why. Most users want to be told + what is sensible, not handed a menu. +4. **Never invent a value.** If you do not know the parliament's party colours or its + open-data URL, say so and ask, or leave the default and flag it. +5. **Verify each stage before moving on.** Every section below has a check. Report its + real output; never infer success from an exit code. +6. **Prefer adding files to editing them.** See [YOUR-PARLIAMENT.md](YOUR-PARLIAMENT.md). + Getting this wrong makes every future update painful. + +--- + +### Section 1 — What is this deployment? + +Ask: + +- Which parliament? (country, and the body's name in its own language and in English) +- What should the site be called? +- Which language will the interface and the model's answers be in? +- Is there an existing site whose look it should resemble, or should it look neutral? + +Then write `parliament..yaml` — a **new file**, not an edit of `parliament.yaml` — +and set `PARLIAMENT_CONFIG=parliament..yaml` in `.env`. + +> **Quote short codes.** YAML reads unquoted `no`, `yes`, `on`, `off`, `y`, `n` as +> booleans. `country: NO` becomes false. Write `country: "NO"`. + +Check: `python -c "from parliament import PARLIAMENT; print(PARLIAMENT.meta)"` + +### Section 2 — How should it look? + +The shipped design is deliberately Nordic-institutional — warm paper, deep blue, a +Garamond serif — and was drawn from riksdagen.se. **It will look Swedish anywhere you +deploy it** unless changed. + +Ask whether that is fine, or whether it should match their own parliament's visual +language. If the latter, ask for a primary colour and whether they want a serif +(traditional, institutional) or sans-serif (plainer, more like a tool) for headings. + +Set the `theme:` block in their config. Every key maps to a CSS variable, so nothing in +the stylesheet needs editing. + +Check: `curl -s localhost:8000/api/meta | python -m json.tool | grep -A8 theme` + +### Section 3 — The database + +Usually decided by the doctor output. If PostgreSQL is missing, offer the container: + +```bash +docker run -d --name plenum-pg \ + -e POSTGRES_USER=plenum -e POSTGRES_PASSWORD= -e POSTGRES_DB=plenum \ + -p 5432:5432 pgvector/pgvector:pg16 +``` + +Ask only what you cannot detect: whether an existing server should be used, and whether +they have rights to `CREATE EXTENSION` on it. + +Set `app.fts_config` to a dictionary from `SELECT cfgname FROM pg_ts_config;` matching +their language. If there is none, `simple` works without stemming — tell them the +consequence: a search for a word will not match its inflected forms. + +Check: `python scripts/doctor.py` — the PostgreSQL section should be all OK. + +### Section 4 — The chat model + +Read the doctor's GPU line before asking. + +| What the doctor found | Recommend | +|---|---| +| A GPU with 12 GB or more | vLLM — fastest, handles concurrency | +| A GPU under 12 GB, or none | Ollama — easy, slower, or a hosted provider | +| No GPU and no wish to run models | OpenRouter, or Berget if EU data residency matters | + +Ask: are they willing to pay per request, or does this need to run on their own +hardware? Does the data have to stay in a particular jurisdiction? + +If local, offer to pull a model. **It must support tool calling** — without it, chat +answers without searching, which is the one failure this project cannot tolerate. +Qwen3 8B and above is a good default. Below ~7B, tool calling gets unreliable. + +Check: `python scripts/doctor.py` — both "Chat model" and "Tool calling" must be OK. If +tool calling fails, change the model. Do not proceed. + +### Section 5 — Embeddings + +A **separate** endpoint from chat, and the most commonly confused step. + +Ask whether to run embeddings locally (small model, ~2 GB VRAM, free) or via an API. + +The dimension is load-bearing: it must match `embeddings.dimension` in their config and +the `vector(N)` columns. Changing it later means re-embedding everything. Settle it +**before** the schema is created. + +| Model | Dimension | +|---|---| +| `Qwen/Qwen3-Embedding-0.6B` | 384 | +| `nomic-embed-text` (Ollama) | 768 | +| `text-embedding-3-small` (OpenAI) | 1536 | + +Check: the doctor's Embeddings section must say the dimension matches. + +### Section 6 — The data + +Ask for their parliament's open-data URL and whether it publishes bulk archives or only +a per-record API. + +If it is not Sweden, an adapter has to be written: `ingest/adapters/.py`. Read +`ingest/adapters/riksdagen.py` first — it documents the traps that recur across +sources: repeated elements arriving as an object when there is one and a list when +there are several, null serialised as the string `"None"`, HTML in text fields, party +codes in mixed case. + +Do not write an adapter from guesswork. Fetch one real record, show the user its +actual fields, and map from that. + +Check: load a small batch and inspect it — +`python -m ingest.cli load --source documents --limit 50`, then query a few rows and +show them to the user. Wrong-looking data here is far cheaper to fix than after a +full load. + +### Section 7 — Serving it + +Ask whether this is a personal machine or a public site. + +Public means: nginx, TLS, systemd. Templates are in `deploy/examples/`, with +`__PROJECT_ROOT__` and `__DOMAIN__` to substitute. + +Two things to state plainly rather than assume they know: + +- `database_query` runs model-written SQL. It is restricted to reads by two layers, but + it should still connect as a `SELECT`-only role. See [SECURITY.md](../SECURITY.md). +- Do **not** add `EnvironmentFile=` to the systemd unit. systemd's parser rejects + `KEY =` with a space, silently yielding empty values. The app loads `.env` itself. + +Check: `curl -s -o /dev/null -w '%{http_code}' https:///api/meta` → 200. + +--- + +## Finish by telling them + +- Where their config lives and that it is theirs to edit +- That `python scripts/doctor.py` diagnoses most later problems +- That updates come via `git fetch upstream && git merge upstream/main` +- Which parts are still Swedish: the interface strings are not translated unless they + did it, and `content/` holds site copy they will want to rewrite + +If anything was left unfinished — no adapter yet, no data loaded, TLS not set up — say +so explicitly rather than implying it is done. diff --git a/docs/PORTING.md b/docs/PORTING.md index b50e105..5fc84ac 100644 --- a/docs/PORTING.md +++ b/docs/PORTING.md @@ -78,7 +78,27 @@ of `kärnkraft` returns nothing at all. Set `PROMPTS_RELOAD=1` while you iterate so you are not restarting the server between edits. -## 4. UI language +## 4. Appearance + +The shipped look — warm paper, deep institutional blue, a Garamond serif — was designed +against riksdagen.se. It is not neutral, and it will look Swedish in Budapest or Sofia +unless you change it. + +Set `theme:` in your config. The keys map to CSS custom properties injected at runtime, +so the stylesheet itself needs no editing: + +```yaml +theme: + paper: "#ffffff" + ink: "#1a1a1a" + blue: "#8a2b2b" # your primary + font_display: "'Source Serif 4', Georgia, serif" + font_body: "system-ui, sans-serif" +``` + +Party colours come from `parties:` and are already per-deployment. + +## 5. UI language The frontend has no i18n layer yet — Swedish strings are written directly into about twenty components. Porting the UI currently means translating those in place. Site diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2f6955b..2c7fb5a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -98,6 +98,11 @@ function SearchView() { root.style.setProperty(`--party-${party.code}`, party.color); } root.style.setProperty("--party-na", meta.data.party_defaults.unknown_color); + // Brand tokens, so a deployment can match its own parliament's visual language + // without editing the stylesheet. Omitted keys keep the shipped defaults. + for (const [token, value] of Object.entries(meta.data.theme ?? {})) { + root.style.setProperty(`--${token.replace(/_/g, "-")}`, value); + } setPhotoUrlTemplate(meta.data.urls?.person_photo ?? ""); if (meta.data.site?.title) document.title = meta.data.site.title; }, [meta.data]); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fbe48ca..a89330a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -71,6 +71,8 @@ export type MetaResponse = { }; parties: Party[]; party_defaults: { unknown_color: string; code_pattern: string }; + /** Brand tokens injected as CSS custom properties; see parliament.yaml `theme:`. */ + theme?: Record; /** Keyed by the value stored in speeches.activity_type. */ activity_types: Record; vocabulary: Record; diff --git a/parliament.py b/parliament.py index 64386b3..ae51287 100644 --- a/parliament.py +++ b/parliament.py @@ -110,6 +110,7 @@ class Parliament: sources: dict[str, Any] embeddings: Embeddings site: dict[str, Any] + theme: dict[str, str] path: Path # -- lookups ------------------------------------------------------------ @@ -184,6 +185,7 @@ class Parliament: for p in self.parties ], "party_defaults": self.party_defaults, + "theme": self.theme, "activity_types": self.activity_types, "vocabulary": self.vocabulary, "urls": self.urls, @@ -255,6 +257,7 @@ def load(path: Optional[Path] = None) -> Parliament: sources=data.get("sources", {}), embeddings=embeddings, site=data.get("site", {}), + theme=data.get("theme", {}), path=path, ) diff --git a/parliament.yaml b/parliament.yaml index 7e484f4..a731a2c 100644 --- a/parliament.yaml +++ b/parliament.yaml @@ -178,6 +178,24 @@ embeddings: base_url_env: EMBEDDING_BASE_URL chunk_chars: 500 +# Visual identity. Optional — omit and the defaults apply. +# +# The shipped look is deliberately Nordic-institutional: warm paper, deep blue, a +# Garamond serif for headings. It was designed against riksdagen.se and will look +# Swedish wherever you deploy it. Override these to match your own parliament's +# visual language; the values are injected as CSS custom properties at runtime, so +# no stylesheet needs editing. +theme: + paper: "#f7f3ea" # page background + ink: "#14202e" # body text + blue: "#1f3a5f" # primary — headers, links, active states + ochre: "#b8892b" # accent — highlights, callouts + crimson: "#a61f2c" # errors and warnings + # Any CSS family. Set both to one family for a plainer, less editorial look. + # Names map to CSS variables: font_display -> --font-display, ink -> --ink. + font_display: "'EB Garamond Variable', Georgia, serif" + font_body: "'Inter Variable', system-ui, sans-serif" + # Served to the frontend via GET /api/meta. site: # Deployment identity, not country data. Upstream ships neutral values; a diff --git a/scripts/doctor.py b/scripts/doctor.py new file mode 100644 index 0000000..d3e827b --- /dev/null +++ b/scripts/doctor.py @@ -0,0 +1,310 @@ +"""Check whether this machine can run plenum, and report what is missing. + + python scripts/doctor.py + +Every check prints OK, WARN or FAIL with the actual value it found, so the output can +be pasted somewhere or read by an assistant deciding what to do next. Nothing is +changed — this only looks. + +Exit code is 1 if any check FAILed, so it can gate a setup script. +""" +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlparse + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +OK, WARN, FAIL = "OK ", "WARN", "FAIL" +_results: list[str] = [] + + +def report(status: str, check: str, detail: str = "") -> None: + _results.append(status) + print(f" [{status}] {check}" + (f" — {detail}" if detail else "")) + + +def section(title: str) -> None: + print(f"\n{title}") + + +def _port_open(host: str, port: int, timeout: float = 2.0) -> bool: + try: + with socket.create_connection((host, port), timeout=timeout): + return True + except OSError: + return False + + +def _http_ok(url: str, timeout: float = 5.0) -> tuple[bool, str]: + try: + import requests + + r = requests.get(url, timeout=timeout) + return r.status_code < 500, f"HTTP {r.status_code}" + except Exception as exc: + return False, type(exc).__name__ + + +# ── environment ─────────────────────────────────────────────────────────────── + + +def check_python() -> None: + section("Python") + v = sys.version_info + if (v.major, v.minor) >= (3, 10): + report(OK, "version", f"{v.major}.{v.minor}.{v.micro}") + else: + report(FAIL, "version", f"{v.major}.{v.minor} — plenum needs 3.10 or newer") + + missing = [m for m in ("fastapi", "psycopg2", "openai", "yaml", "pgvector") + if not _importable(m)] + if missing: + report(FAIL, "dependencies", f"missing {missing} — run: pip install -e '.[dev]'") + else: + report(OK, "dependencies", "installed") + + +def _importable(name: str) -> bool: + import importlib.util + + return importlib.util.find_spec(name) is not None + + +def check_config() -> None: + section("Configuration") + env = ROOT / ".env" + if env.exists(): + report(OK, ".env", str(env)) + else: + report(FAIL, ".env", "missing — copy .env.example to .env") + return + + try: + from dotenv import load_dotenv + + load_dotenv(env) + except Exception as exc: + report(WARN, ".env", f"could not load: {exc}") + + try: + from parliament import PARLIAMENT + + report(OK, "parliament config", + f"{PARLIAMENT.meta.get('name')} ({PARLIAMENT.meta.get('country')}), " + f"fts={PARLIAMENT.language.fts_config}, " + f"embeddings={PARLIAMENT.embeddings.dimension}d") + except Exception as exc: + report(FAIL, "parliament config", str(exc)[:120]) + + +# ── database ────────────────────────────────────────────────────────────────── + + +def check_database() -> None: + section("PostgreSQL") + host = os.getenv("PG_HOST", "localhost") + port = int(os.getenv("PG_PORT", 5432)) + if not _port_open(host, port): + report(FAIL, "reachable", f"nothing listening on {host}:{port}") + return + report(OK, "reachable", f"{host}:{port}") + + try: + from postgres_client import pg + + ver = pg.execute("SELECT version() AS v")[0]["v"].split(",")[0] + report(OK, "connected", ver) + except Exception as exc: + report(FAIL, "connected", f"{type(exc).__name__}: {str(exc).strip()[:90]}") + return + + exts = {r["extname"] for r in pg.execute("SELECT extname FROM pg_extension")} + for ext in ("vector", "pg_trgm"): + if ext in exts: + report(OK, f"extension {ext}", "installed") + else: + report(FAIL, f"extension {ext}", f'missing — run: CREATE EXTENSION {ext};') + + try: + cfg = pg.execute("SELECT current_setting('app.fts_config') AS c")[0]["c"] + from parliament import PARLIAMENT + + if cfg == PARLIAMENT.language.fts_config: + report(OK, "app.fts_config", cfg) + else: + report(FAIL, "app.fts_config", + f"database says {cfg!r}, config says {PARLIAMENT.language.fts_config!r}") + except Exception: + report(FAIL, "app.fts_config", + "not set — ALTER DATABASE SET app.fts_config = '';") + + tables = pg.execute( + "SELECT count(*) AS n FROM information_schema.tables " + "WHERE table_schema='public' AND table_type='BASE TABLE'" + )[0]["n"] + if tables >= 22: + report(OK, "schema", f"{tables} tables") + elif tables == 0: + report(FAIL, "schema", "empty — psql -f _postgres/schema.sql") + else: + report(WARN, "schema", f"only {tables} tables; expected 22") + + try: + n = pg.execute("SELECT count(*) AS n FROM speeches")[0]["n"] + report(OK if n else WARN, "data", + f"{n:,} speeches" if n else "no data yet — run ingest.cli") + except Exception: + report(WARN, "data", "tables not queryable yet") + + +# ── models ──────────────────────────────────────────────────────────────────── + + +def check_chat_model() -> None: + section("Chat model") + url = os.getenv("LLM_DIRECT_URL") + if not url: + report(FAIL, "LLM_DIRECT_URL", "not set — chat and research will not work") + return + report(OK, "LLM_DIRECT_URL", url) + + ok, detail = _http_ok(url.rstrip("/") + "/models") + if not ok: + report(FAIL, "reachable", f"{detail} — is the server running?") + return + report(OK, "reachable", detail) + + model = os.getenv("LLM_MODEL_SMART") or os.getenv("LLM_MODEL") + if not model: + report(WARN, "LLM_MODEL_SMART", "not set") + return + + try: + from packages.llm import LLM + + llm = LLM(base_url=url, model=model, api_key=os.getenv("LLM_BEARER") or None, + silent=True) + r = llm.generate(messages=[{"role": "user", "content": "Reply with OK."}], + think=False, max_tokens=16) + if isinstance(r, str): + report(FAIL, f"model {model}", r[:100]) + else: + report(OK, f"model {model}", "responds") + except Exception as exc: + report(FAIL, f"model {model}", str(exc)[:100]) + + +def check_tool_calling() -> None: + """A model can converse fine and still never call a tool — which here means + confident answers with no sources.""" + section("Tool calling (required for grounded answers)") + url, model = os.getenv("LLM_DIRECT_URL"), os.getenv("LLM_MODEL_SMART") or os.getenv("LLM_MODEL") + if not (url and model): + report(WARN, "skipped", "no model configured") + return + try: + from packages.llm import LLM, get_tools, register_tool + + @register_tool + def _doctor_probe(topic: str) -> str: + """Look up a topic. + + Args: + topic: What to look up. + """ + return f"result for {topic}" + + llm = LLM(base_url=url, model=model, api_key=os.getenv("LLM_BEARER") or None, + tools=get_tools(["_doctor_probe"]), silent=True) + llm.generate(messages=[{"role": "user", + "content": "Use the tool to look up 'energy'."}], think=False) + if [m for m in llm.messages if m.get("role") == "tool"]: + report(OK, "tool calling", "works") + else: + report(FAIL, "tool calling", + f"{model} did not call the tool — chat will answer without sources") + except Exception as exc: + report(WARN, "tool calling", str(exc)[:100]) + + +def check_embeddings() -> None: + section("Embeddings") + url = os.getenv("EMBEDDING_BASE_URL") + if not url: + report(WARN, "EMBEDDING_BASE_URL", "not set — semantic search unavailable") + return + report(OK, "EMBEDDING_BASE_URL", url) + try: + from parliament import PARLIAMENT + from postgres_client import pg + + vec = pg.make_embeddings(["doctor test"])[0] + want = PARLIAMENT.embeddings.dimension + if len(vec) == want: + report(OK, "dimension", f"{len(vec)} matches parliament.yaml") + else: + report(FAIL, "dimension", + f"model returns {len(vec)}, config expects {want} — fix before ingesting") + except Exception as exc: + report(FAIL, "embedding call", str(exc)[:100]) + + +# ── host ────────────────────────────────────────────────────────────────────── + + +def check_host() -> None: + section("Host") + for tool, why in (("psql", "applying the schema"), ("node", "building the frontend"), + ("npm", "building the frontend")): + path = shutil.which(tool) + report(OK if path else WARN, tool, path or f"not found — needed for {why}") + + try: + gpu = subprocess.run(["nvidia-smi", "--query-gpu=name,memory.total", + "--format=csv,noheader"], + capture_output=True, text=True, timeout=8) + if gpu.returncode == 0 and gpu.stdout.strip(): + report(OK, "GPU", gpu.stdout.strip().replace("\n", "; ")) + else: + report(WARN, "GPU", "none detected — use Ollama on CPU, or a hosted provider") + except Exception: + report(WARN, "GPU", "nvidia-smi not available") + + if shutil.which("systemctl"): + can_sudo = subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0 + report(OK if can_sudo else WARN, "sudo", + "passwordless" if can_sudo else "will prompt — needed to install services") + + section("Ports") + for port, what in ((8000, "API / vLLM"), (5432, "PostgreSQL"), (11434, "Ollama"), + (8003, "embeddings"), (8001, "MCP server"), (8005, "eval scorer")): + report(OK if _port_open("127.0.0.1", port, 0.4) else WARN, f"{port}", + f"{what}: " + ("in use" if _port_open("127.0.0.1", port, 0.4) else "free")) + + +def main() -> int: + print("plenum doctor — checking this machine. Nothing will be changed.") + for check in (check_python, check_config, check_database, check_chat_model, + check_tool_calling, check_embeddings, check_host): + try: + check() + except Exception as exc: # a broken check must not hide the others + report(WARN, check.__name__, f"check itself failed: {exc}") + + failed = _results.count(FAIL) + warned = _results.count(WARN) + print(f"\n{_results.count(OK)} ok, {warned} warnings, {failed} failures") + if failed: + print("Fix the FAIL lines before continuing; see docs/SETUP.md.") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())