Make the theme configurable; add a doctor and an assistant setup guide

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.
main
Lasse Edfast 1 week ago
parent 469dc17c49
commit 81d5ca24c1
  1. 18
      README.md
  2. 173
      docs/ASSISTANT-SETUP.md
  3. 22
      docs/PORTING.md
  4. 5
      frontend/src/App.tsx
  5. 2
      frontend/src/types.ts
  6. 3
      parliament.py
  7. 18
      parliament.yaml
  8. 310
      scripts/doctor.py

@ -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/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/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. | | [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. 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 ### Running a fork
If you maintain a deployment as a fork, keep it differing from upstream only in If you maintain a deployment as a fork, keep it differing from upstream only in

@ -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.<code>.yaml` — a **new file**, not an edit of `parliament.yaml`
and set `PARLIAMENT_CONFIG=parliament.<code>.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=<generate one> -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/<name>.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://<domain>/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.

@ -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 Set `PROMPTS_RELOAD=1` while you iterate so you are not restarting the server between
edits. 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 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 twenty components. Porting the UI currently means translating those in place. Site

@ -98,6 +98,11 @@ function SearchView() {
root.style.setProperty(`--party-${party.code}`, party.color); root.style.setProperty(`--party-${party.code}`, party.color);
} }
root.style.setProperty("--party-na", meta.data.party_defaults.unknown_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 ?? ""); setPhotoUrlTemplate(meta.data.urls?.person_photo ?? "");
if (meta.data.site?.title) document.title = meta.data.site.title; if (meta.data.site?.title) document.title = meta.data.site.title;
}, [meta.data]); }, [meta.data]);

@ -71,6 +71,8 @@ export type MetaResponse = {
}; };
parties: Party[]; parties: Party[];
party_defaults: { unknown_color: string; code_pattern: string }; party_defaults: { unknown_color: string; code_pattern: string };
/** Brand tokens injected as CSS custom properties; see parliament.yaml `theme:`. */
theme?: Record<string, string>;
/** Keyed by the value stored in speeches.activity_type. */ /** Keyed by the value stored in speeches.activity_type. */
activity_types: Record<string, DebateType>; activity_types: Record<string, DebateType>;
vocabulary: Record<string, string>; vocabulary: Record<string, string>;

@ -110,6 +110,7 @@ class Parliament:
sources: dict[str, Any] sources: dict[str, Any]
embeddings: Embeddings embeddings: Embeddings
site: dict[str, Any] site: dict[str, Any]
theme: dict[str, str]
path: Path path: Path
# -- lookups ------------------------------------------------------------ # -- lookups ------------------------------------------------------------
@ -184,6 +185,7 @@ class Parliament:
for p in self.parties for p in self.parties
], ],
"party_defaults": self.party_defaults, "party_defaults": self.party_defaults,
"theme": self.theme,
"activity_types": self.activity_types, "activity_types": self.activity_types,
"vocabulary": self.vocabulary, "vocabulary": self.vocabulary,
"urls": self.urls, "urls": self.urls,
@ -255,6 +257,7 @@ def load(path: Optional[Path] = None) -> Parliament:
sources=data.get("sources", {}), sources=data.get("sources", {}),
embeddings=embeddings, embeddings=embeddings,
site=data.get("site", {}), site=data.get("site", {}),
theme=data.get("theme", {}),
path=path, path=path,
) )

@ -178,6 +178,24 @@ embeddings:
base_url_env: EMBEDDING_BASE_URL base_url_env: EMBEDDING_BASE_URL
chunk_chars: 500 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. # Served to the frontend via GET /api/meta.
site: site:
# Deployment identity, not country data. Upstream ships neutral values; a # Deployment identity, not country data. Upstream ships neutral values; a

@ -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 <db> SET app.fts_config = '<language>';")
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())
Loading…
Cancel
Save