trading-system-docs/tolaria/c4b-search-service/server.py
Red Queen 5e4191578a fix(tolaria): C5D search-source pipeline — build+verify source from current Tolaria before rebuild
- SearchSourceBuilder: deterministischer Vault->Source-Snapshot (read-only),
  atomar (temp->validate->fsync->replace), Secret-Scan fail-closed
- verify_integrity: exakte object_id/path Set-Equality (stale Source kann
  nie APPLIED), Canary implizit ueber erwartetes Objekt-Set
- apply_commit: Source-Build+Verification vor Rebuild; VERIFYING_SEARCH-Resume
  (kein Doppel-Rebuild) — C5E-Replay-Crash-Fall abgedeckt
- Fix: source_object_count ist keine 0-Fehlerbedingung (echter Defekt)
- SearchSourceBuildError + RC_SEARCH_SOURCE_BUILD_FAILURE (Human Gate)
- c4b: source_path env-konfigurierbar, indexed_object_ids/paths Read-Back
- Testsuite: 17 neue Tests (Test-Plan A-O + Realistic C4-Integration)
2026-08-26 12:12:45 +00:00

160 lines
6.2 KiB
Python

"""
TOLARIA SEARCH SERVICE — HTTP Server (C4B)
================================================================
Bietet:
POST /api/search — Suche (exact/keyword/metadata), Honest Mode
GET /api/search — convenience (query/mode via query-params)
GET /api/search/health — Search-Health (ehrlich: supported_modes)
POST /api/search/rebuild — Rebuild aus Forgejo-Source (kontrolliert)
Storage: derived rebuildbarer JSON-Index. Kein pgvector/Postgres (C4D).
Keine automatisch öffentliche Rebuild-Admin ohne Auth-Header. Vault-APIs bleiben
unangetastet (Backward-Compatibility).
"""
from __future__ import annotations
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
from search_api import TolariaSearch, SearchError, SUPPORTED_MODES
DATA_DIR = os.path.join(os.path.dirname(__file__), "data")
# C5D: Source-Pfad env-konfigurierbar. Default = bisheriger baked-in Pfad,
# aber C5D schreibt den frischen derived Snapshot an den konfigurierten Pfad.
SOURCE_JSON = os.environ.get("TOLARIA_SEARCH_SOURCE",
os.path.join(os.path.dirname(__file__), "index_source.json"))
INDEX_JSON = os.path.join(DATA_DIR, "search_index.json")
REBUILD_TOKEN = os.environ.get("TOLARIA_SEARCH_REBUILD_TOKEN", "")
engine = TolariaSearch(index_path=INDEX_JSON)
def load_or_rebuild():
"""Lädt persistierten Index oder rebuildet aus Source."""
if os.path.exists(INDEX_JSON):
try:
with open(INDEX_JSON) as f:
payload = json.load(f)
# Rebuild in memory aus persistierten docs
from search_engine import Doc
docs = []
for d in payload["docs"]:
docs.append(Doc(
path=d["path"], title=d["title"], id=d.get("id"),
type=d.get("type"), role=d.get("role"),
representation=d.get("representation"), state=d.get("state"),
content_hash=d.get("content_hash", ""),
body=d.get("body", "") or "",
aliases=d.get("aliases", []), tags=d.get("tags", []),
derived_from=d.get("derived_from"),
is_legacy=d.get("is_legacy", False),
))
engine.index.build(docs, secret_filter=True)
engine.built_at = payload.get("built_at_ms")
engine.source_head = payload.get("source_head")
engine.object_count = payload.get("object_count", len(docs))
engine.secret_blocked_objects = payload.get("secret_blocked", [])
# C5D: Objekt-Set aus persistiertem Index wiederherstellen (Read-Back).
engine.indexed_object_ids = payload.get("indexed_object_ids", [])
engine.indexed_paths = payload.get("indexed_paths", [])
return "loaded"
except Exception as e:
pass
if os.path.exists(SOURCE_JSON):
engine.rebuild_from_source(SOURCE_JSON)
return "rebuilt"
return "none"
def do_rebuild():
return engine.rebuild_from_source(SOURCE_JSON)
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def _send(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _read_json(self):
length = int(self.headers.get("Content-Length", 0))
if length <= 0:
return {}
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except Exception:
return {}
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/api/search":
qs = parse_qs(parsed.query)
req = {
"query": qs.get("query", [""])[0],
"mode": qs.get("mode", ["hybrid"])[0],
}
for k in ("limit", "offset"):
if k in qs:
req[k] = int(qs[k][0])
if "include_historical" in qs:
req["include_historical"] = qs["include_historical"][0].lower() == "true"
self._send(self._dispatch(req))
elif parsed.path == "/api/search/health":
self._send(engine.health())
else:
self._send({"error": {"code": "not_found", "reason": self.path}}, 404)
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == "/api/search":
self._send(self._dispatch(self._read_json()))
elif parsed.path == "/api/search/rebuild":
# kontrolliert: nur mit Token
auth = self.headers.get("Authorization", "")
expected = "Bearer " + REBUILD_TOKEN
if not REBUILD_TOKEN or auth.strip() != expected:
self._send({"error": {"code": "unauthorized", "reason": "rebuild requires token"}}, 403)
return
res = do_rebuild()
self._send({"status": "ok", "rebuilt": res})
else:
self._send({"error": {"code": "not_found", "reason": self.path}}, 404)
def _dispatch(self, req):
try:
return engine.search(req)
except SearchError as e:
return {"error": {"code": e.code, "reason": e.reason}}
except Exception as e:
return {"error": {"code": "internal_error", "reason": str(e)[:200]}}
def _send(self, obj, code=200):
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
load_or_rebuild()
port = int(os.environ.get("TOLARIA_SEARCH_PORT", "8325"))
httpd = ThreadingHTTPServer(("0.0.0.0", port), Handler)
print(f"TOLARIA SEARCH SERVICE listening on :{port} (supported={SUPPORTED_MODES})",
flush=True)
httpd.serve_forever()
if __name__ == "__main__":
from search_api import SUPPORTED_MODES
main()