ruslanmv's picture
Deploy Matrix Context Console
ce45eb0 verified
Raw
History Blame Contribute Delete
13.8 kB
"""Minimal v1 REST surface over the engine — stdlib only, no web framework.
The package stays dependency-light (numpy-only core), so the server is built on
``http.server`` rather than FastAPI. It exposes the SDK 1:1 as JSON over HTTP.
The centerpiece is ``POST /v1/inspect``: it returns the full explainable
contract — routing scores, selected vs. unselected experts, kept and dropped
items with their score breakdown, and the final prompt-ready pack — which the
Context Console UI and the future MCP server both build on.
Endpoints (all under ``/v1``):
GET /v1/health liveness + store size
GET /v1/experts the typed expert taxonomy + seed descriptions
GET /v1/items list stored items (?scope=&expert= filters)
POST /v1/remember write an item
POST /v1/inspect explainable routed pack (the contract)
POST /v1/pack prompt-ready context pack
POST /v1/forget delete an item by id
The dispatch is a pure function ``dispatch(service, method, path, body, query)``
returning ``(status, payload)`` so the contract is testable without sockets.
"""
from __future__ import annotations
import json
from functools import lru_cache
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Dict, Optional, Tuple
from urllib.parse import parse_qs, urlparse
from ... import CONTRACT_VERSION, __version__
from ...manager import ContextManager, _item_dict
from ...routing.experts import EXPERT_DESCRIPTIONS
from ...schema.enums import EXPERTS
API_PREFIX = "/v1"
_INSPECTOR_HTML = Path(__file__).parent / "inspector.html"
_CONSOLE_DIR = Path(__file__).parent / "console"
_CONTENT_TYPES = {".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml", ".json": "application/json",
".png": "image/png", ".ico": "image/x-icon"}
@lru_cache(maxsize=1)
def inspector_html() -> str:
"""The bundled single-file Context Inspector UI (served at / and /ui)."""
return _INSPECTOR_HTML.read_text(encoding="utf-8")
def console_asset(name: str) -> Optional[Tuple[bytes, str]]:
"""Read a bundled Console asset by name (path-traversal safe).
Returns ``(bytes, content_type)`` or ``None`` if it is not a known file.
The Console is a zero-dependency, same-origin SPA served at /console.
"""
name = (name or "").lstrip("/") or "index.html"
if "/" in name or "\\" in name or name.startswith("."):
return None # no nested paths / traversal in Phase 0
path = _CONSOLE_DIR / name
if not path.is_file() or path.parent != _CONSOLE_DIR:
return None
ctype = _CONTENT_TYPES.get(path.suffix, "application/octet-stream")
return path.read_bytes(), ctype
class HttpError(Exception):
"""Raised by handlers to return a specific status + message."""
def __init__(self, status: int, message: str):
super().__init__(message)
self.status = status
self.message = message
def _need(body: dict, key: str, typ=str):
if not isinstance(body, dict) or key not in body:
raise HttpError(400, f"missing required field: {key!r}")
val = body[key]
if not isinstance(val, typ):
raise HttpError(400, f"field {key!r} must be {typ.__name__}")
return val
class RestService:
"""Pure request handlers over a ContextManager — no HTTP concerns."""
def __init__(self, manager: ContextManager):
self.m = manager
# ---- GET --------------------------------------------------------------
def health(self) -> dict:
return {"status": "ok", "name": self.m.config.name,
"version": __version__, "items": len(self.m.store.all_items())}
def experts(self) -> dict:
return {"experts": [{"name": e, "description": EXPERT_DESCRIPTIONS.get(e, "")}
for e in EXPERTS]}
def list_items(self, query: Dict[str, list]) -> dict:
scope = (query.get("scope") or [None])[0]
expert = (query.get("expert") or [None])[0]
items = self.m.items(scope=scope, expert=expert)
return {"items": [_item_dict(it) for it in items], "count": len(items)}
def version(self) -> dict:
return {"contract_version": CONTRACT_VERSION, "implementation": "matrix-context",
"implementation_version": __version__, "name": self.m.config.name}
def scopes(self) -> dict:
"""Discover the scope hierarchy present in the store."""
scopes = sorted({it.scope for it in self.m.store.all_items()})
return {"scopes": scopes, "count": len(scopes)}
def get_item(self, item_id: str) -> dict:
it = self.m.store.get(item_id)
if it is None:
raise HttpError(404, f"item not found: {item_id}")
return {"item": _item_dict(it)}
# ---- POST -------------------------------------------------------------
def remember(self, body: dict) -> dict:
content = _need(body, "content")
it = self.m.remember(
content,
expert=body.get("expert", "semantic"),
scope=body.get("scope", "/"),
importance=float(body.get("importance", 0.5)),
tags=tuple(body.get("tags", ()) or ()),
ttl=body.get("ttl"),
)
return {"item": _item_dict(it)}
def inspect(self, body: dict) -> dict:
return self.m.build_inspection(
_need(body, "query"),
scope=body.get("scope", "/"),
top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)),
max_tokens=int(body.get("max_tokens", 600)),
pin_experts=tuple(body.get("pin_experts", ()) or ()),
)
def pack(self, body: dict) -> dict:
pk = self.m.build_pack(
_need(body, "query"),
scope=body.get("scope", "/"),
top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)),
max_tokens=int(body.get("max_tokens", 600)),
pin_experts=tuple(body.get("pin_experts", ()) or ()),
)
return {
"tokens": pk.tokens,
"selected_experts": pk.selected_experts,
"routing_reason": pk.routing_reason,
"citations": pk.citations,
"prompt": pk.to_prompt(),
"items": [{"id": p.item.id, "expert": p.item.expert,
"content": p.item.content} for p in pk.items],
}
def router_explain(self, body: dict) -> dict:
"""Routing decision only (the essential inspectability differentiator):
selected vs. unselected experts and per-expert scores."""
ins = self.m.build_inspection(
_need(body, "query"),
scope=body.get("scope", "/"),
top_experts=int(body.get("top_experts", self.m.DEFAULT_TOP_EXPERTS)),
max_tokens=int(body.get("max_tokens", 600)),
pin_experts=tuple(body.get("pin_experts", ()) or ()),
)
r = ins["routing"]
return {
"query": ins["query"],
"selected_experts": r["selected_experts"],
"unselected_experts": r["unselected_experts"],
"scores": [{"expert": e, "score": s} for e, s in
sorted(r["scores"].items(), key=lambda x: -x[1])],
"widened": r["widened"],
"reason": r["reason"],
}
def forget(self, body: dict) -> dict:
item_id = _need(body, "id")
return {"id": item_id, "deleted": self.m.forget(item_id)}
# Routing tables: path -> handler name.
_GET = {
f"{API_PREFIX}/health": "health",
f"{API_PREFIX}/version": "version",
f"{API_PREFIX}/experts": "experts",
f"{API_PREFIX}/scopes": "scopes",
f"{API_PREFIX}/items": "list_items",
}
_POST = {
f"{API_PREFIX}/remember": "remember",
f"{API_PREFIX}/recall": "inspect", # recall is inspect's routed candidates
f"{API_PREFIX}/inspect": "inspect",
f"{API_PREFIX}/pack": "pack",
f"{API_PREFIX}/router/explain": "router_explain",
f"{API_PREFIX}/forget": "forget",
}
_ALL_PATHS = set(_GET) | set(_POST)
_ITEM_PREFIX = f"{API_PREFIX}/items/"
def dispatch(service: RestService, method: str, path: str,
body: Optional[dict] = None,
query: Optional[Dict[str, list]] = None) -> Tuple[int, dict]:
"""Pure router: map a request to a handler and return (status, payload)."""
method = method.upper()
try:
if method == "GET" and path in _GET:
name = _GET[path]
handler = getattr(service, name)
return 200, (handler(query or {}) if name == "list_items" else handler())
# GET /v1/items/{id}
if path.startswith(_ITEM_PREFIX) and len(path) > len(_ITEM_PREFIX):
if method != "GET":
return 405, {"error": f"method {method} not allowed for {path}"}
return 200, service.get_item(path[len(_ITEM_PREFIX):])
if method == "POST" and path in _POST:
status = 201 if path == f"{API_PREFIX}/remember" else 200
return status, getattr(service, _POST[path])(body or {})
if path in _ALL_PATHS:
return 405, {"error": f"method {method} not allowed for {path}"}
return 404, {"error": f"not found: {path}"}
except HttpError as e:
return e.status, {"error": e.message}
except (ValueError, TypeError) as e:
return 400, {"error": str(e)}
def _make_handler(service: RestService):
class Handler(BaseHTTPRequestHandler):
server_version = "matrix-context/" + __version__
def log_message(self, *a): # quiet by default
pass
def _send(self, status: int, payload: dict):
data = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_html(self, html: str):
data = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_bytes(self, data: bytes, content_type: str):
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _read_body(self) -> dict:
length = int(self.headers.get("Content-Length") or 0)
if not length:
return {}
raw = self.rfile.read(length)
try:
parsed = json.loads(raw or b"{}")
except json.JSONDecodeError:
raise HttpError(400, "invalid JSON body")
if not isinstance(parsed, dict):
raise HttpError(400, "JSON body must be an object")
return parsed
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path in ("/", "/ui", "/inspector"):
self._send_html(inspector_html())
return
if parsed.path == "/favicon.ico":
self._send(204, {})
return
# Context Console (same-origin SPA): /console and /console/<asset>
if parsed.path == "/console" or parsed.path == "/console/":
asset = console_asset("index.html")
if asset:
self._send_bytes(*asset)
return
if parsed.path.startswith("/console/"):
asset = console_asset(parsed.path[len("/console/"):])
if asset:
self._send_bytes(*asset)
else:
self._send(404, {"error": f"not found: {parsed.path}"})
return
status, payload = dispatch(service, "GET", parsed.path,
query=parse_qs(parsed.query))
self._send(status, payload)
def do_POST(self):
parsed = urlparse(self.path)
try:
body = self._read_body()
except HttpError as e:
self._send(e.status, {"error": e.message})
return
status, payload = dispatch(service, "POST", parsed.path, body=body)
self._send(status, payload)
return Handler
def create_app(manager: Optional[ContextManager] = None, *,
host: str = "127.0.0.1", port: int = 8088,
name: str = "rest", path: Optional[str] = None) -> HTTPServer:
"""Build (but do not start) the HTTP server bound to ``host:port``.
A single-threaded server is used so the SQLite connection stays on one
thread; it is sufficient for local use, the UI, and CI. Call
``server.serve_forever()`` to run, or use :func:`serve`.
"""
if manager is None:
manager = ContextManager.create(name, path=path or f"{name}.matrix-context.db")
service = RestService(manager)
return HTTPServer((host, port), _make_handler(service))
def serve(host: str = "127.0.0.1", port: int = 8088,
manager: Optional[ContextManager] = None,
name: str = "rest", path: Optional[str] = None) -> None: # pragma: no cover
server = create_app(manager, host=host, port=port, name=name, path=path)
print(f"matrix-context REST listening on http://{host}:{port}{API_PREFIX}")
try:
server.serve_forever()
except KeyboardInterrupt:
server.server_close()