""" 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()