249 lines
7.8 KiB
Python
249 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
fleet_api.py — Lightweight HTTP API for the shared fleet palace.
|
|
|
|
Exposes fleet memory search and recording over HTTP so that Alpha servers and other
|
|
wizard deployments can query the palace without direct filesystem access.
|
|
|
|
Endpoints:
|
|
GET /health
|
|
Returns {"status": "ok", "palace": "<path>"}
|
|
|
|
GET /search?q=<query>[&room=<room>][&n=<int>]
|
|
Returns {"results": [...], "query": "...", "room": "...", "count": N}
|
|
Each result: {"text": "...", "room": "...", "wing": "...", "score": 0.9}
|
|
|
|
GET /wings
|
|
Returns {"wings": ["bezalel", ...]} — distinct wizard wings present
|
|
|
|
POST /record
|
|
Body: {"text": "...", "room": "...", "wing": "...", "source_file": "...", "metadata": {...}}
|
|
Returns {"success": true, "id": "..."}
|
|
|
|
Error responses use {"error": "<message>"} with appropriate HTTP status codes.
|
|
|
|
Usage:
|
|
# Default: localhost:7771, fleet palace at /var/lib/mempalace/fleet
|
|
python mempalace/fleet_api.py
|
|
|
|
# Custom host/port/palace:
|
|
FLEET_PALACE_PATH=/data/fleet python mempalace/fleet_api.py --host 0.0.0.0 --port 8080
|
|
|
|
Refs: #1078, #1075, #1085
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
# Add repo root to path so we can import nexus.mempalace
|
|
_HERE = Path(__file__).resolve().parent
|
|
_REPO_ROOT = _HERE.parent
|
|
if str(_REPO_ROOT) not in sys.path:
|
|
sys.path.insert(0, str(_REPO_ROOT))
|
|
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 7771
|
|
MAX_RESULTS = 50
|
|
|
|
|
|
def _get_palace_path() -> Path:
|
|
return Path(os.environ.get("FLEET_PALACE_PATH", "/var/lib/mempalace/fleet"))
|
|
|
|
|
|
def _json_response(handler: BaseHTTPRequestHandler, status: int, body: dict) -> None:
|
|
payload = json.dumps(body).encode()
|
|
handler.send_response(status)
|
|
handler.send_header("Content-Type", "application/json")
|
|
handler.send_header("Content-Length", str(len(payload)))
|
|
handler.end_headers()
|
|
handler.wfile.write(payload)
|
|
|
|
|
|
def _handle_health(handler: BaseHTTPRequestHandler) -> None:
|
|
palace = _get_palace_path()
|
|
_json_response(handler, 200, {
|
|
"status": "ok",
|
|
"palace": str(palace),
|
|
"palace_exists": palace.exists(),
|
|
})
|
|
|
|
|
|
def _handle_search(handler: BaseHTTPRequestHandler, qs: dict) -> None:
|
|
query_terms = qs.get("q", [""])
|
|
q = query_terms[0].strip() if query_terms else ""
|
|
if not q:
|
|
_json_response(handler, 400, {"error": "Missing required parameter: q"})
|
|
return
|
|
|
|
room_terms = qs.get("room", [])
|
|
room = room_terms[0].strip() if room_terms else None
|
|
|
|
n_terms = qs.get("n", [])
|
|
try:
|
|
n = max(1, min(int(n_terms[0]), MAX_RESULTS)) if n_terms else 10
|
|
except (ValueError, IndexError):
|
|
_json_response(handler, 400, {"error": "Invalid parameter: n must be an integer"})
|
|
return
|
|
|
|
try:
|
|
from nexus.mempalace.searcher import search_fleet, MemPalaceUnavailable
|
|
except ImportError as exc:
|
|
_json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"})
|
|
return
|
|
|
|
try:
|
|
results = search_fleet(q, room=room, n_results=n)
|
|
except Exception as exc: # noqa: BLE001
|
|
_json_response(handler, 503, {"error": str(exc)})
|
|
return
|
|
|
|
_json_response(handler, 200, {
|
|
"query": q,
|
|
"room": room,
|
|
"count": len(results),
|
|
"results": [
|
|
{
|
|
"text": r.text,
|
|
"room": r.room,
|
|
"wing": r.wing,
|
|
"score": round(r.score, 4),
|
|
}
|
|
for r in results
|
|
],
|
|
})
|
|
|
|
|
|
def _handle_wings(handler: BaseHTTPRequestHandler) -> None:
|
|
"""Return distinct wizard wing names found in the fleet palace directory."""
|
|
palace = _get_palace_path()
|
|
if not palace.exists():
|
|
_json_response(handler, 503, {
|
|
"error": f"Fleet palace not found: {palace}",
|
|
})
|
|
return
|
|
|
|
wings = sorted({
|
|
p.name for p in palace.iterdir() if p.is_dir()
|
|
})
|
|
_json_response(handler, 200, {"wings": wings})
|
|
|
|
|
|
def _handle_record(handler: BaseHTTPRequestHandler) -> None:
|
|
"""Handle POST /record to add a new memory."""
|
|
content_length = int(handler.headers.get("Content-Length", 0))
|
|
if not content_length:
|
|
_json_response(handler, 400, {"error": "Missing request body"})
|
|
return
|
|
|
|
try:
|
|
body = json.loads(handler.rfile.read(content_length))
|
|
except json.JSONDecodeError:
|
|
_json_response(handler, 400, {"error": "Invalid JSON body"})
|
|
return
|
|
|
|
text = body.get("text", "").strip()
|
|
if not text:
|
|
_json_response(handler, 400, {"error": "Missing required field: text"})
|
|
return
|
|
|
|
room = body.get("room", "general")
|
|
wing = body.get("wing")
|
|
source_file = body.get("source_file", "")
|
|
metadata = body.get("metadata", {})
|
|
|
|
try:
|
|
from nexus.mempalace.searcher import add_memory, MemPalaceUnavailable
|
|
except ImportError as exc:
|
|
_json_response(handler, 503, {"error": f"MemPalace module not available: {exc}"})
|
|
return
|
|
|
|
try:
|
|
# Note: add_memory uses MEMPALACE_PATH by default.
|
|
# For fleet_api, we should probably use FLEET_PALACE_PATH.
|
|
palace_path = _get_palace_path()
|
|
doc_id = add_memory(
|
|
text=text,
|
|
room=room,
|
|
wing=wing,
|
|
palace_path=palace_path,
|
|
source_file=source_file,
|
|
extra_metadata=metadata
|
|
)
|
|
_json_response(handler, 201, {"success": True, "id": doc_id})
|
|
except Exception as exc:
|
|
_json_response(handler, 503, {"error": str(exc)})
|
|
|
|
|
|
class FleetAPIHandler(BaseHTTPRequestHandler):
|
|
"""Request handler for the fleet memory API."""
|
|
|
|
def log_message(self, fmt: str, *args) -> None: # noqa: ANN001
|
|
# Prefix with tag for easier log filtering
|
|
sys.stderr.write(f"[fleet_api] {fmt % args}\n")
|
|
|
|
def do_GET(self) -> None: # noqa: N802
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
qs = parse_qs(parsed.query)
|
|
|
|
if path == "/health":
|
|
_handle_health(self)
|
|
elif path == "/search":
|
|
_handle_search(self, qs)
|
|
elif path == "/wings":
|
|
_handle_wings(self)
|
|
else:
|
|
_json_response(self, 404, {
|
|
"error": f"Unknown endpoint: {path}",
|
|
"endpoints": ["/health", "/search", "/wings"],
|
|
})
|
|
|
|
def do_POST(self) -> None: # noqa: N802
|
|
parsed = urlparse(self.path)
|
|
path = parsed.path.rstrip("/") or "/"
|
|
|
|
if path == "/record":
|
|
_handle_record(self)
|
|
else:
|
|
_json_response(self, 404, {
|
|
"error": f"Unknown endpoint: {path}",
|
|
"endpoints": ["/record"],
|
|
})
|
|
|
|
|
|
def make_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> HTTPServer:
|
|
return HTTPServer((host, port), FleetAPIHandler)
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Fleet palace HTTP API server."
|
|
)
|
|
parser.add_argument("--host", default=DEFAULT_HOST, help=f"Bind host (default: {DEFAULT_HOST})")
|
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Bind port (default: {DEFAULT_PORT})")
|
|
args = parser.parse_args(argv)
|
|
|
|
palace = _get_palace_path()
|
|
print(f"[fleet_api] Palace: {palace}")
|
|
if not palace.exists():
|
|
print(f"[fleet_api] WARNING: palace path does not exist yet: {palace}", file=sys.stderr)
|
|
|
|
server = make_server(args.host, args.port)
|
|
print(f"[fleet_api] Listening on http://{args.host}:{args.port}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\n[fleet_api] Shutting down.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|