Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
<!-- Absolute, not docs/example.png: PyPI renders this README standalone on
pypi.org, where a relative path resolves against pypi.org and 404s. -->
![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.
Expand All @@ -22,9 +24,12 @@ mpe-lkg

Then open <http://localhost:5100>.

`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.

<details>
<summary>Install from source instead</summary>
Expand Down Expand Up @@ -73,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 |
Expand Down Expand Up @@ -170,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`).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "mpe-lkg"
version = "0.2.0"
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"
Expand Down
2 changes: 1 addition & 1 deletion src/mpe_lkg/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from __future__ import annotations

__version__ = "0.2.0"
__version__ = "0.3.0"

__all__ = ["__version__", "create_app", "main", "health"]

Expand Down
97 changes: 95 additions & 2 deletions src/mpe_lkg/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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("/")
Expand All @@ -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 = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
'<rect width="32" height="32" rx="7" fill="#2a78d6"/>'
'<circle cx="10" cy="11" r="3.4" fill="#fff"/><circle cx="22" cy="9" r="2.6" fill="#fff"/>'
'<circle cx="16" cy="23" r="3" fill="#fff"/>'
'<path d="M10 11 L22 9 M10 11 L16 23 M22 9 L16 23" stroke="#fff" stroke-width="1.6" fill="none"/>'
"</svg>"
)
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":
Expand Down
103 changes: 94 additions & 9 deletions src/mpe_lkg/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
Expand All @@ -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)), ""),
}
Loading
Loading