server.py resolved SOURCE_JSON to ../c4a_evidence/index_source.json which does not exist in the deployed container. The C4B service ships index_source.json in its own directory. Fix resolves robustly relative to the service root.
154 lines
5.7 KiB
Python
154 lines
5.7 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")
|
|
SOURCE_JSON = 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", [])
|
|
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()
|