From 448c6843d78bec25a9d29f85919abcf0d4b8c020 Mon Sep 17 00:00:00 2001 From: Morten Punnerud-Engelstad Date: Mon, 10 Aug 2026 14:05:26 +0200 Subject: [PATCH 1/2] Show the screenshot on PyPI, and cover the legacy embeddings endpoint The example image did not render on pypi.org. The README used a relative path, and PyPI renders the README standalone, so docs/example.png resolved against pypi.org and 404'd. It is now an absolute raw.githubusercontent URL, verified to return 200 image/png. Also adds tests for the Ollama HTTP paths a real Ollama on a developer's machine cannot exercise, using a stub server: - An Ollama older than v0.3.4 has no /api/embed at all. The fallback to the singular /api/embeddings existed but had no test, and it is one of the two plausible causes of the blank page in #1. - A 404 meaning "no such model" and a 404 meaning "no such route" look identical and mean opposite things. The first must name the ollama pull command and must NOT retry the legacy route; the second must fall back. - A response with fewer rows than inputs is refused rather than silently misaligning every later vector. One of these took two minutes on its own: constructing an HTTPServer without serving it does not give a closed port -- the socket is already bound and listening, so the connection succeeds and the request blocks until the 120-second timeout. The test now closes a real socket to get a refused port, which takes 2 seconds instead of 122, six times over in CI. --- README.md | 4 +- pyproject.toml | 2 +- src/mpe_lkg/__init__.py | 2 +- tests/test_ollama_compat.py | 165 ++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/test_ollama_compat.py diff --git a/README.md b/README.md index d6ca699..ab97ee0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ [![PyPI](https://img.shields.io/pypi/v/mpe-lkg.svg)](https://pypi.org/project/mpe-lkg/) [![Python](https://img.shields.io/pypi/pyversions/mpe-lkg.svg)](https://pypi.org/project/mpe-lkg/) -![Example](docs/example.png) + +![Example](https://raw.githubusercontent.com/punnerud/Local_Knowledge_Graph/main/docs/example.png) Ask a local Llama model a question, watch it reason step by step, and see the steps drawn as a knowledge graph where the edges are the semantic similarity between them. diff --git a/pyproject.toml b/pyproject.toml index aae9741..168fff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mpe-lkg" -version = "0.2.0" +version = "0.2.1" description = "Local Knowledge Graph: a local LLM reasons step by step, and the steps become a graph." readme = "README.md" requires-python = ">=3.10" diff --git a/src/mpe_lkg/__init__.py b/src/mpe_lkg/__init__.py index e583a15..702bf6e 100644 --- a/src/mpe_lkg/__init__.py +++ b/src/mpe_lkg/__init__.py @@ -11,7 +11,7 @@ from __future__ import annotations -__version__ = "0.2.0" +__version__ = "0.2.1" __all__ = ["__version__", "create_app", "main", "health"] diff --git a/tests/test_ollama_compat.py b/tests/test_ollama_compat.py new file mode 100644 index 0000000..9bec04c --- /dev/null +++ b/tests/test_ollama_compat.py @@ -0,0 +1,165 @@ +"""The Ollama HTTP contract, against a stub server rather than a real Ollama. + +These cover the paths a real Ollama on this machine cannot exercise: an old build +that predates /api/embed, a 404 that means "no such model" rather than "no such +route", and a response that returns the wrong number of rows. + +The old-endpoint path is the one that matters most. Issue #1 reported a blank page +on a working Ollama; /api/embed only exists from Ollama v0.3.4, so anyone on an +older build got a 404 that the original code turned into an exception before the +event stream opened. That fallback had no test until now. +""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer + +import numpy as np +import pytest + +from mpe_lkg.backends import BackendError, OllamaEmbedding + + +class StubOllama: + """A configurable stand-in for the Ollama HTTP API.""" + + def __init__(self, *, has_modern_endpoint=True, missing_model=False, short_rows=False, dim=8): + self.has_modern_endpoint = has_modern_endpoint + self.missing_model = missing_model + self.short_rows = short_rows + self.dim = dim + self.paths: list[str] = [] + stub = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_a): + pass + + def _json(self, code, payload): + body = json.dumps(payload).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + stub.paths.append(self.path) + if self.path == "/api/tags": + self._json(200, {"models": [{"name": "stub-embed", "capabilities": ["embedding"]}]}) + else: + self._json(404, {"error": "not found"}) + + def do_POST(self): + stub.paths.append(self.path) + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length) or b"{}") + + if self.path == "/api/embed": + if stub.missing_model: + model = request.get("model", "") + self._json(404, {"error": f'model "{model}" not found, try pulling it first'}) + return + if not stub.has_modern_endpoint: + # Ollama before v0.3.4: the route simply does not exist. + self._json(404, {"error": "404 page not found"}) + return + n = len(request.get("input", [])) + if stub.short_rows: + n = max(0, n - 1) + self._json(200, {"embeddings": [[0.1] * stub.dim for _ in range(n)]}) + return + + if self.path == "/api/embeddings": + # The legacy endpoint is singular: one prompt, one vector. + assert "prompt" in request, "legacy endpoint takes 'prompt', not 'input'" + self._json(200, {"embedding": [0.2] * stub.dim}) + return + + self._json(404, {"error": "not found"}) + + self._server = HTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self._server.server_port}" + + def __enter__(self): + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self + + def __exit__(self, *_exc): + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + + +class TestOldOllamaWithoutApiEmbed: + """Ollama before v0.3.4 has no /api/embed, only the singular /api/embeddings.""" + + def test_it_falls_back_and_still_returns_vectors(self): + with StubOllama(has_modern_endpoint=False) as stub: + backend = OllamaEmbedding("stub-embed", base_url=stub.url) + vectors = backend.embed(["one", "two", "three"]) + + assert vectors.shape == (3, 8) + np.testing.assert_allclose(np.linalg.norm(vectors, axis=1), 1.0, atol=1e-5) + + def test_it_tries_the_modern_endpoint_first(self): + with StubOllama(has_modern_endpoint=False) as stub: + OllamaEmbedding("stub-embed", base_url=stub.url).embed(["one"]) + posts = [p for p in stub.paths if p.startswith("/api/embed")] + + assert posts[0] == "/api/embed" + assert "/api/embeddings" in posts + + def test_the_fallback_is_reported_in_describe(self): + """Which endpoint answered is worth knowing when diagnosing a blank page.""" + with StubOllama(has_modern_endpoint=False) as stub: + backend = OllamaEmbedding("stub-embed", base_url=stub.url) + backend.embed(["one"]) + assert backend.describe()["endpoint"] == "/api/embeddings" + + def test_a_modern_server_never_touches_the_legacy_route(self): + with StubOllama(has_modern_endpoint=True) as stub: + backend = OllamaEmbedding("stub-embed", base_url=stub.url) + backend.embed(["one", "two"]) + + assert "/api/embeddings" not in stub.paths + assert backend.describe()["endpoint"] == "/api/embed" + + +class TestTheTwoKindsOf404: + """A missing route and a missing model both answer 404 and mean opposite things.""" + + def test_a_missing_model_names_the_pull_command(self): + with StubOllama(missing_model=True) as stub: + backend = OllamaEmbedding("llama3.1:8b", base_url=stub.url) + with pytest.raises(BackendError) as excinfo: + backend.embed(["one"]) + + assert "ollama pull llama3.1:8b" in excinfo.value.hint + assert "/api/embeddings" not in stub.paths, "a missing model must not retry the legacy route" + + +class TestMisalignedResponses: + def test_a_short_response_is_refused_rather_than_misaligned(self): + """Silently dropping a row shifts every later vector onto the wrong text.""" + with StubOllama(short_rows=True) as stub: + backend = OllamaEmbedding("stub-embed", base_url=stub.url) + with pytest.raises(BackendError, match="returned 2 vectors"): + backend.embed(["one", "two", "three"]) + + def test_an_unreachable_server_says_how_to_start_it(self): + # A genuinely closed port, so the connection is refused immediately. + # Constructing an HTTPServer and not serving it does NOT give that: the + # socket is already bound and listening, so the connect succeeds and the + # request then blocks until the request timeout -- two minutes per run. + import socket + + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + + backend = OllamaEmbedding("stub-embed", base_url=f"http://127.0.0.1:{port}") + with pytest.raises(BackendError) as excinfo: + backend.embed(["one"]) + assert "ollama serve" in excinfo.value.hint From f816d01b056e1b8448207e2c4fedebc5ba123ca7 Mon Sep 17 00:00:00 2001 From: Morten Punnerud-Engelstad Date: Mon, 10 Aug 2026 14:12:20 +0200 Subject: [PATCH 2/2] Pick a chat model that is installed, and let the page change it Reported from a real first run: three usable models installed -- llama3.2:3b, nomic-embed-text, all-minilm -- and the app insisting on pulling llama3.1:8b. The embedding model had always been discovered from what Ollama reports. The chat model was hardcoded, so LKG_CHAT_MODEL was a *default* rather than an override, and anyone whose model happened to have another name was told to download one they did not need. It is now discovered the same way, with a preference order used only to break a tie between several installed models -- any installed model beats a missing one, so this can no longer produce a "not found" when something usable is present. Three additions to the page: - Dropdowns for the chat and embedding model, listing what Ollama actually has. Which model answers is the first thing a new user gets wrong, and it was only changeable through an environment variable documented in the README. - When Ollama has no chat model at all, the page lists a few with their download sizes, links to ollama.com, and can fetch one with streamed progress rather than printing a command and stopping. - A favicon, because it was a 404 in everyone's terminal on every page load. Two guards on the download route, which exists to start a multi-gigabyte transfer: - An allowlist, not free text. Only the models the page offers can be named. - A same-origin check. A page on the internet can POST to a service on your loopback address, so starting a download has to be something this page asked for. The model-selection route is equally not free text: a name is only accepted if Ollama reports it as installed, because that value goes straight to the model API. 18 new tests, including the exact reported case: llama3.2:3b present, llama3.1:8b absent, health must be ok. Verified end to end against a real Ollama with no environment variables set at all. --- README.md | 16 +-- pyproject.toml | 2 +- src/mpe_lkg/__init__.py | 2 +- src/mpe_lkg/app.py | 97 +++++++++++++++++- src/mpe_lkg/backends.py | 103 ++++++++++++++++++-- src/mpe_lkg/templates/index.html | 141 +++++++++++++++++++++++++++ tests/test_models_route.py | 162 +++++++++++++++++++++++++++++++ 7 files changed, 504 insertions(+), 19 deletions(-) create mode 100644 tests/test_models_route.py diff --git a/README.md b/README.md index ab97ee0..0b1e14c 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,12 @@ mpe-lkg Then open . -`mpe-lkg doctor` reports whether Ollama is reachable, which models are installed, and the exact -`ollama pull` command for anything missing. It exits non-zero when something is wrong, so it -works in a script. +The models are chosen for you from whatever Ollama has installed, and the page has a dropdown +for each so you can change them. If Ollama has no chat model at all, the page lists a few with +their download sizes and can fetch one. + +`mpe-lkg doctor` reports the same thing from the terminal, exiting non-zero when something is +wrong so it works in a script.
Install from source instead @@ -75,7 +78,7 @@ Everything is an environment variable, and the defaults work unchanged. | Variable | Default | Meaning | |---|---|---| | `OLLAMA_URL` | `http://localhost:11434` | Where Ollama is listening | -| `LKG_CHAT_MODEL` | `llama3.1:8b` | The model that does the reasoning | +| `LKG_CHAT_MODEL` | *(auto)* | The model that does the reasoning. Empty means: use an installed chat model. This is an override, not a default | | `LKG_EMBED_MODEL` | *(auto)* | Embedding model. Empty means: use an installed embedding model if there is one, otherwise fall back to the chat model | | `LKG_HOST` / `LKG_PORT` | `127.0.0.1` / `5100` | Where the app listens | | `LKG_DEBUG` | off | Set to `1` for the Flask debugger. Do not do this on a shared network | @@ -172,8 +175,9 @@ installed, and what to pull. Errors are now shown in the page itself rather than browser console. **It says a model is not found.** -The default chat model is `llama3.1:8b`. If you have a different one, either pull that, or -set `LKG_CHAT_MODEL` to a model you already have. +It should not: the app picks whichever chat and embedding models Ollama actually reports, and +the page has a dropdown for each. If Ollama has no chat model at all, the page lists a few with +their download sizes and can fetch one for you. **Ollama runs in Docker or on another machine.** Set `OLLAMA_URL`, and make sure Ollama binds beyond localhost (`OLLAMA_HOST=0.0.0.0`). diff --git a/pyproject.toml b/pyproject.toml index 168fff7..e06b671 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mpe-lkg" -version = "0.2.1" +version = "0.3.0" description = "Local Knowledge Graph: a local LLM reasons step by step, and the steps become a graph." readme = "README.md" requires-python = ">=3.10" diff --git a/src/mpe_lkg/__init__.py b/src/mpe_lkg/__init__.py index 702bf6e..08ed803 100644 --- a/src/mpe_lkg/__init__.py +++ b/src/mpe_lkg/__init__.py @@ -11,7 +11,7 @@ from __future__ import annotations -__version__ = "0.2.1" +__version__ = "0.3.0" __all__ = ["__version__", "create_app", "main", "health"] diff --git a/src/mpe_lkg/app.py b/src/mpe_lkg/app.py index bf10860..7dc99e0 100644 --- a/src/mpe_lkg/app.py +++ b/src/mpe_lkg/app.py @@ -39,7 +39,7 @@ def make_backends() -> tuple: LKG_EMBED_BACKEND=hf LKG_HF_MODEL=HuggingFaceTB/SmolLM2-135M \ LKG_HF_LAYER=blocks.-1 python app.py """ - chat = backends.OllamaChat(backends.DEFAULT_CHAT_MODEL) + chat = backends.OllamaChat(_selected_chat()) if os.environ.get("LKG_EMBED_BACKEND") == "hf": from .layers import HiddenStateEmbedding @@ -50,7 +50,7 @@ def make_backends() -> tuple: pooling=os.environ.get("LKG_HF_POOLING", "last"), ) - return chat, backends.OllamaEmbedding(backends.DEFAULT_EMBED_MODEL) + return chat, backends.OllamaEmbedding(_selected_embedding()) @app.route("/") @@ -64,6 +64,99 @@ def health(): return jsonify(backends.health(backends.DEFAULT_BASE_URL)) +def _selected_chat() -> str: + """The chat model in use: an explicit choice, else whatever is installed.""" + return app.config.get("CHAT_MODEL") or backends.pick_chat_model( + backends.DEFAULT_BASE_URL, backends.DEFAULT_CHAT_MODEL + ) + + +def _selected_embedding() -> str: + return app.config.get("EMBED_MODEL", backends.DEFAULT_EMBED_MODEL) + + +@app.route("/models", methods=["GET", "POST"]) +def models(): + """List what Ollama has, and let the page choose among it. + + Being told to pull a model you do not need, while three usable ones sit + installed, is the worst version of this app's first-run experience. + """ + if request.method == "POST": + wanted = request.json or {} + installed = {m["name"] for m in backends.list_models(backends.DEFAULT_BASE_URL)} + + for key, config_key in (("chat", "CHAT_MODEL"), ("embedding", "EMBED_MODEL")): + name = (wanted.get(key) or "").strip() + if not name: + continue + # Only ever select something Ollama actually reports. This value is + # sent straight to the model API, so it is not a free-text field. + if name not in installed: + return jsonify({"error": f"'{name}' is not installed"}), 400 + app.config[config_key] = name + + installed = backends.list_models(backends.DEFAULT_BASE_URL) + return jsonify({ + "chat": [m["name"] for m in installed if not m["is_embedding"]], + "embedding": [m["name"] for m in installed if m["is_embedding"]], + "selected": {"chat": _selected_chat(), "embedding": _selected_embedding()}, + "suggested": backends.SUGGESTED, + "ollama_url": backends.DEFAULT_BASE_URL, + }) + + +def _same_origin() -> bool: + """Refuse cross-site requests to the state-changing routes. + + A page on the internet can POST to a service on your loopback address. Starting + a multi-gigabyte download has to be something *this* page asked for. + """ + site = request.headers.get("Sec-Fetch-Site") + if site: + return site in ("same-origin", "none") + origin = request.headers.get("Origin") or request.headers.get("Referer") or "" + return not origin or origin.startswith(request.host_url.rstrip("/")) + + +@app.route("/pull", methods=["POST"]) +def pull(): + """Download a model, streaming Ollama's progress to the page.""" + if not _same_origin(): + return jsonify({"error": "cross-origin request refused"}), 403 + + name = ((request.json or {}).get("model") or "").strip() + # An allowlist, not free text: this route causes a multi-gigabyte download, and + # the set of things a first-run user needs is small and known. + if name not in {entry["name"] for entry in backends.SUGGESTED}: + return jsonify({"error": f"'{name}' is not one of the offered models"}), 400 + + def generate(): + try: + for chunk in backends.pull_model(name, backends.DEFAULT_BASE_URL): + yield _sse({"type": "pull", **chunk}) + yield _sse({"type": "pull_done", "model": name}) + except backends.BackendError as exc: + yield _sse({"type": "error", "message": str(exc), "hint": exc.hint}) + + return Response(generate(), mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.route("/favicon.ico") +def favicon(): + """A real answer, so the browser stops logging a 404 on every page load.""" + dot = ( + '' + '' + '' + '' + '' + "" + ) + return Response(dot, mimetype="image/svg+xml", headers={"Cache-Control": "max-age=86400"}) + + @app.route("/query", methods=["GET", "POST"]) def query(): if request.method == "POST": diff --git a/src/mpe_lkg/backends.py b/src/mpe_lkg/backends.py index 854ebee..cccfc2e 100644 --- a/src/mpe_lkg/backends.py +++ b/src/mpe_lkg/backends.py @@ -27,7 +27,10 @@ import requests DEFAULT_BASE_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434") -DEFAULT_CHAT_MODEL = os.environ.get("LKG_CHAT_MODEL", "llama3.1:8b") +# Empty means "use whatever chat model Ollama actually has". Setting LKG_CHAT_MODEL +# is an override, not a default: hardcoding a name here is what told a user with a +# perfectly good model installed to go and pull one they did not need. +DEFAULT_CHAT_MODEL = os.environ.get("LKG_CHAT_MODEL", "") # Empty means "look at what Ollama actually has and pick something sensible". DEFAULT_EMBED_MODEL = os.environ.get("LKG_EMBED_MODEL", "") REQUEST_TIMEOUT = float(os.environ.get("LKG_TIMEOUT", "120")) @@ -380,6 +383,79 @@ def list_models(base_url: str = DEFAULT_BASE_URL, *, session: requests.Session | return models +# Only used to break a tie when several chat models are installed. Any installed +# model beats a missing one, so this never causes a "not found". +CHAT_PREFERENCE = ("llama3.1:8b", "llama3.2:3b", "qwen3", "mistral", "gemma3", "phi4") + +# Offered in the UI when Ollama has nothing usable. Sizes are what `ollama pull` +# actually downloads, so the page can say what it is about to cost. +SUGGESTED = [ + {"name": "llama3.2:3b", "size": "2.0 GB", "role": "chat", "note": "good default"}, + {"name": "llama3.1:8b", "size": "4.7 GB", "role": "chat", "note": "stronger, slower"}, + {"name": "gemma3:1b", "size": "0.8 GB", "role": "chat", "note": "smallest usable"}, + {"name": "nomic-embed-text", "size": "0.3 GB", "role": "embedding", "note": "recommended"}, + {"name": "all-minilm", "size": "45 MB", "role": "embedding", "note": "tiny"}, +] + + +def chat_models(base_url: str = DEFAULT_BASE_URL) -> list[str]: + """Installed models that can hold a conversation.""" + return [m["name"] for m in list_models(base_url) if not m["is_embedding"]] + + +def embedding_models(base_url: str = DEFAULT_BASE_URL) -> list[str]: + return [m["name"] for m in list_models(base_url) if m["is_embedding"]] + + +def pick_chat_model(base_url: str = DEFAULT_BASE_URL, requested: str = "") -> str: + """Choose a chat model that is actually installed. + + The embedding model has always been discovered rather than assumed; the chat + model was hardcoded, so a user with a perfectly good model installed under a + different name was told to pull one they did not need. An explicit request wins, + and is returned even when absent so the caller can report it honestly. + """ + if requested: + return requested + + available = chat_models(base_url) + if not available: + return DEFAULT_CHAT_MODEL or CHAT_PREFERENCE[0] + + by_base = {name.split(":")[0]: name for name in available} + for preferred in CHAT_PREFERENCE: + if preferred in available: + return preferred + if preferred.split(":")[0] in by_base: + return by_base[preferred.split(":")[0]] + return sorted(available)[0] + + +def pull_model(name: str, base_url: str = DEFAULT_BASE_URL): + """Stream ``ollama pull`` progress as dicts. Yields until the download ends.""" + try: + response = requests.post( + f"{base_url.rstrip('/')}/api/pull", + json={"model": name, "stream": True}, + stream=True, + timeout=(10, 3600), + ) + response.raise_for_status() + except requests.RequestException as exc: + raise BackendError(f"Could not start the download of '{name}'.", hint=str(exc)) from exc + + for line in response.iter_lines(): + if not line: + continue + try: + chunk = json.loads(line.decode("utf-8")) + except json.JSONDecodeError: + continue + if chunk.get("error"): + raise BackendError(f"Ollama could not pull '{name}': {chunk['error']}") + yield chunk + + def health(base_url: str = DEFAULT_BASE_URL) -> dict: """Everything the UI needs to explain why nothing is happening.""" models = list_models(base_url) @@ -394,18 +470,27 @@ def health(base_url: str = DEFAULT_BASE_URL) -> dict: } names = {m["name"] for m in models} - chat_ok = DEFAULT_CHAT_MODEL in names or any( - n.split(":")[0] == DEFAULT_CHAT_MODEL.split(":")[0] for n in names - ) + chat = pick_chat_model(base_url, DEFAULT_CHAT_MODEL) + chat_ok = chat in names or any(n.split(":")[0] == chat.split(":")[0] for n in names) if not chat_ok: return { "ok": False, "base_url": base_url, "models": sorted(names), - "problem": f"Ollama is running but does not have the chat model '{DEFAULT_CHAT_MODEL}'.", - "hint": f"Install it with: ollama pull {DEFAULT_CHAT_MODEL}\n" - f"Or point the app at a model you already have by setting " - f"LKG_CHAT_MODEL to one of: {', '.join(sorted(names))}", + "problem": f"Ollama is running but does not have the chat model '{chat}'.", + "hint": f"Install it with: ollama pull {chat}\n" + f"Or pick one you already have in the page, or set LKG_CHAT_MODEL to " + f"one of: {', '.join(sorted(names))}", + "chat_model": chat, + "embedding_model": "", } - return {"ok": True, "base_url": base_url, "models": sorted(names), "problem": "", "hint": ""} + return { + "ok": True, + "base_url": base_url, + "models": sorted(names), + "problem": "", + "hint": "", + "chat_model": chat, + "embedding_model": next(iter(embedding_models(base_url)), ""), + } diff --git a/src/mpe_lkg/templates/index.html b/src/mpe_lkg/templates/index.html index 53f3446..81b9024 100644 --- a/src/mpe_lkg/templates/index.html +++ b/src/mpe_lkg/templates/index.html @@ -28,6 +28,20 @@ .notice { color: #7a5c00; background: #fff6d8; border: 1px solid #e0c356; padding: 6px 10px; margin-top: 8px; font-size: 13px; } #status { color: #666; font-size: 13px; min-height: 1.4em; margin-bottom: 10px; } + #models { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + font-size: 13px; color: #52514e; margin: 8px 0 4px; } + #models label { display: flex; align-items: center; gap: 5px; } + #models select { font: inherit; padding: 3px 6px; } + .setup { border: 1px solid #e0c356; background: #fff6d8; padding: 12px 14px; + border-radius: 4px; margin-bottom: 14px; } + .setup h3 { margin: 0 0 6px; font-size: 15px; } + .setup table { border-collapse: collapse; margin: 8px 0; font-size: 13px; } + .setup td { padding: 3px 12px 3px 0; } + .setup code { background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 3px; } + .setup button { padding: 4px 10px; font-size: 13px; margin: 0; } + .pull-progress { font-size: 13px; color: #52514e; margin-top: 8px; } + .pull-bar { height: 6px; background: rgba(0,0,0,.1); border-radius: 3px; overflow: hidden; margin-top: 4px; } + .pull-bar span { display: block; height: 100%; background: #2a78d6; width: 0; transition: width .2s; } @@ -38,10 +52,12 @@

Local Llama Knowledge Graph

+
+
@@ -237,6 +253,131 @@

Local Llama Knowledge Graph

submit.addEventListener('click', run); query.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); }); + // ---- model selection ------------------------------------------------- + // Which model answers is the first thing a new user gets wrong, so it is + // shown and changeable here rather than only through an environment + // variable they have to read the README to discover. + const modelsBar = document.getElementById('models'); + const setup = document.getElementById('setup'); + + function picker(id, label, options, selected) { + if (!options.length) return ''; + const opts = options.map(name => + ``).join(''); + return ``; + } + + async function loadModels() { + let data; + try { + data = await (await fetch('/models')).json(); + } catch { + modelsBar.textContent = 'Could not reach the server.'; + return; + } + + modelsBar.innerHTML = + picker('pick-chat', 'Chat model', data.chat, data.selected.chat) + + picker('pick-embed', 'Embeddings', data.embedding, data.selected.embedding); + + for (const [id, key] of [['pick-chat', 'chat'], ['pick-embed', 'embedding']]) { + const el = document.getElementById(id); + if (!el) continue; + el.addEventListener('change', async () => { + await fetch('/models', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [key]: el.value }) + }); + status.textContent = `Now using ${el.value}.`; + }); + } + + setup.innerHTML = ''; + if (!data.chat.length) showSetup(data); + } + + function showSetup(data) { + // Nothing usable is installed. Say what to get, how big it is, and offer + // to fetch it -- rather than printing a command and leaving. + const rows = data.suggested.map(m => ` + + ${m.name} + ${m.size} + ${m.role} — ${m.note} + + `).join(''); + + setup.innerHTML = ` +
+

No chat model installed yet

+

This app talks to Ollama on ${data.ollama_url}. Pick one to download, + or run ollama pull <name> yourself.

+ ${rows}
+ +
`; + + setup.querySelectorAll('.get').forEach(btn => + btn.addEventListener('click', () => pullModel(btn.dataset.model, btn))); + } + + function pullModel(name, button) { + const box = setup.querySelector('.pull-progress'); + const text = box.querySelector('.pull-text'); + const bar = box.querySelector('.pull-bar span'); + box.hidden = false; + setup.querySelectorAll('.get').forEach(b => { b.disabled = true; }); + text.textContent = `Starting ${name}…`; + + // A plain fetch with a reader: EventSource cannot POST, and the model + // name has to be in a body the server can validate against its allowlist. + fetch('/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: name }) + }).then(async (response) => { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const blocks = buffer.split('\n\n'); + buffer = blocks.pop(); + for (const block of blocks) { + const line = block.split('\n').find(l => l.startsWith('data: ')); + if (!line) continue; + const event = JSON.parse(line.slice(6)); + if (event.type === 'error') { + text.textContent = event.message; + setup.querySelectorAll('.get').forEach(b => { b.disabled = false; }); + return; + } + if (event.type === 'pull_done') { + text.textContent = `${name} is ready.`; + bar.style.width = '100%'; + loadModels(); + return; + } + text.textContent = `${name}: ${event.status || 'working'}`; + if (event.total) { + bar.style.width = `${((event.completed || 0) / event.total * 100).toFixed(1)}%`; + } + } + } + }).catch(err => { + text.textContent = `Download failed: ${err}`; + setup.querySelectorAll('.get').forEach(b => { b.disabled = false; }); + }); + } + + loadModels(); + downloadBtn.addEventListener('click', () => { // Export what is actually on screen. vis.js already draws to a canvas, so // reading that canvas gives the user their own layout -- re-plotting the diff --git a/tests/test_models_route.py b/tests/test_models_route.py new file mode 100644 index 0000000..064d7e8 --- /dev/null +++ b/tests/test_models_route.py @@ -0,0 +1,162 @@ +"""Choosing a model from the page, and picking a sensible one automatically. + +The reported first-run experience was: three usable models installed, and the app +insisting on pulling a fourth. Auto-selection is the fix; the picker is so the +choice is visible and changeable without reading the README. +""" + +import json + +import pytest +from conftest import normal_script, read_events + +from mpe_lkg import backends + + +def fake_models(*names_and_kinds): + return [{"name": n, "is_embedding": e, "capabilities": []} for n, e in names_and_kinds] + + +@pytest.fixture +def installed(monkeypatch): + """Pretend Ollama has exactly this set.""" + + def apply(*names_and_kinds): + models = fake_models(*names_and_kinds) + monkeypatch.setattr(backends, "list_models", lambda *a, **k: models) + return models + + return apply + + +class TestAutomaticChoice: + def test_it_uses_an_installed_model_rather_than_a_hardcoded_one(self, installed): + """The exact reported case: llama3.2:3b present, llama3.1:8b not.""" + installed(("llama3.2:3b", False), ("nomic-embed-text:latest", True)) + assert backends.pick_chat_model() == "llama3.2:3b" + + def test_health_is_ok_when_any_chat_model_is_present(self, installed): + installed(("llama3.2:3b", False), ("all-minilm:latest", True)) + status = backends.health() + assert status["ok"] is True + assert status["chat_model"] == "llama3.2:3b" + assert status["embedding_model"] == "all-minilm:latest" + + def test_an_explicit_request_wins_over_discovery(self, installed): + installed(("llama3.2:3b", False), ("mistral:7b", False)) + assert backends.pick_chat_model(requested="mistral:7b") == "mistral:7b" + + def test_preference_breaks_a_tie_deterministically(self, installed): + installed(("zzz:1b", False), ("llama3.1:8b", False), ("aaa:1b", False)) + assert backends.pick_chat_model() == "llama3.1:8b" + + def test_any_model_beats_none_even_outside_the_preference_list(self, installed): + installed(("some-obscure-model:latest", False)) + assert backends.pick_chat_model() == "some-obscure-model:latest" + + def test_embedding_models_are_never_offered_as_chat_models(self, installed): + installed(("nomic-embed-text:latest", True), ("all-minilm:latest", True)) + assert backends.chat_models() == [] + assert backends.health()["ok"] is False + + def test_nothing_installed_still_produces_an_actionable_message(self, installed): + installed() + status = backends.health() + assert status["ok"] is False + assert "ollama serve" in status["hint"] + + +class TestModelsRoute: + def test_it_lists_what_is_installed_and_what_is_selected(self, flask_client, installed): + installed(("llama3.2:3b", False), ("nomic-embed-text:latest", True)) + client, _ = flask_client(normal_script()) + + payload = client.get("/models").get_json() + assert payload["chat"] == ["llama3.2:3b"] + assert payload["embedding"] == ["nomic-embed-text:latest"] + assert payload["selected"]["chat"] == "llama3.2:3b" + assert payload["suggested"], "a first-run user needs something to pick from" + + def test_selecting_an_installed_model_sticks(self, flask_client, installed): + installed(("llama3.2:3b", False), ("mistral:7b", False)) + client, _ = flask_client(normal_script()) + + client.post("/models", json={"chat": "mistral:7b"}) + assert client.get("/models").get_json()["selected"]["chat"] == "mistral:7b" + + def test_a_model_that_is_not_installed_is_refused(self, flask_client, installed): + """The value goes straight to the model API, so it is not free text.""" + installed(("llama3.2:3b", False)) + client, _ = flask_client(normal_script()) + + response = client.post("/models", json={"chat": "../../etc/passwd"}) + assert response.status_code == 400 + assert "not installed" in response.get_json()["error"] + + def test_an_empty_choice_changes_nothing(self, flask_client, installed): + installed(("llama3.2:3b", False)) + client, _ = flask_client(normal_script()) + assert client.post("/models", json={"chat": ""}).status_code == 200 + + +class TestPullRoute: + def test_only_the_offered_models_can_be_pulled(self, flask_client): + """This route causes a multi-gigabyte download; it is an allowlist.""" + client, _ = flask_client(normal_script()) + response = client.post("/pull", json={"model": "attacker/whatever"}) + assert response.status_code == 400 + + def test_a_cross_site_request_is_refused(self, flask_client): + """A page on the internet can POST to a service on your loopback address.""" + client, _ = flask_client(normal_script()) + response = client.post( + "/pull", + json={"model": "llama3.2:3b"}, + headers={"Sec-Fetch-Site": "cross-site"}, + ) + assert response.status_code == 403 + + def test_progress_is_streamed_and_ends(self, flask_client, monkeypatch): + chunks = [ + {"status": "pulling manifest"}, + {"status": "downloading", "completed": 50, "total": 100}, + {"status": "success"}, + ] + monkeypatch.setattr(backends, "pull_model", lambda *a, **k: iter(chunks)) + client, _ = flask_client(normal_script()) + + events = read_events(client.post("/pull", json={"model": "llama3.2:3b"})) + assert [e["type"] for e in events][-1] == "pull_done" + assert any(e.get("total") == 100 for e in events) + + def test_a_failed_pull_is_reported_rather_than_hanging(self, flask_client, monkeypatch): + def boom(*_a, **_k): + raise backends.BackendError("disk full", hint="free some space") + yield # pragma: no cover - makes this a generator + + monkeypatch.setattr(backends, "pull_model", boom) + client, _ = flask_client(normal_script()) + + events = read_events(client.post("/pull", json={"model": "llama3.2:3b"})) + assert events[0]["type"] == "error" + assert "disk full" in events[0]["message"] + + +class TestFavicon: + def test_the_browser_gets_an_answer(self, flask_client): + """It was a 404 on every single page load, in everyone's terminal.""" + client, _ = flask_client(normal_script()) + response = client.get("/favicon.ico") + assert response.status_code == 200 + assert response.mimetype == "image/svg+xml" + + +class TestSuggestions: + def test_every_suggested_model_is_described_well_enough_to_choose(self): + for entry in backends.SUGGESTED: + assert set(entry) == {"name", "size", "role", "note"} + assert entry["role"] in ("chat", "embedding") + assert entry["size"], "a user deciding on a download needs the size" + + def test_the_suggestions_are_json_safe(self): + json.dumps(backends.SUGGESTED)