From f0f9388c1f22d80a10a91108ba0d6f3ef4a0f1e3 Mon Sep 17 00:00:00 2001 From: m3taversal Date: Fri, 24 Apr 2026 17:58:30 +0100 Subject: [PATCH] feat(diagnostics): add POST /api/search for chat API contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the search endpoint to accept POST bodies matching the embedded chat contract (query/limit/min_score/domain/confidence/exclude → slug/path/title/domain/confidence/score/body_excerpt). GET path retained for legacy callers and adds a min_score override for hackathon debug. - _qdrant_hits_to_results() shapes raw hits into chat response format - handle_api_search() dispatches POST vs GET - /api/search added to _PUBLIC_PATHS (chat is unauthenticated) - POST route registered alongside existing GET Resolves VPS↔repo drift flagged by Argus before next deploy.sh run. Co-Authored-By: Claude Opus 4.7 (1M context) --- diagnostics/app.py | 106 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 14 deletions(-) diff --git a/diagnostics/app.py b/diagnostics/app.py index 51f3a6a..dbcf3cc 100644 --- a/diagnostics/app.py +++ b/diagnostics/app.py @@ -42,7 +42,7 @@ API_KEY_FILE = Path(os.environ.get("ARGUS_API_KEY_FILE", "/opt/teleo-eval/secret # Endpoints that skip auth (dashboard is public for now, can lock later) _PUBLIC_PATHS = frozenset({"/", "/prs", "/ops", "/health", "/agents", "/epistemic", "/legacy", "/audit", "/api/metrics", "/api/snapshots", "/api/vital-signs", - "/api/contributors", "/api/domains", "/api/audit", "/api/yield", "/api/cost-per-claim", "/api/fix-rates", "/api/compute-profile", "/api/review-queue", "/api/daily-digest"}) + "/api/contributors", "/api/domains", "/api/audit", "/api/yield", "/api/cost-per-claim", "/api/fix-rates", "/api/compute-profile", "/api/review-queue", "/api/daily-digest", "/api/search"}) def _get_db() -> sqlite3.Connection: @@ -663,38 +663,115 @@ async def handle_api_domains(request): return web.json_response({"domains": breakdown}) -async def handle_api_search(request): - """GET /api/search — semantic search over claims via Qdrant + graph expansion. +def _qdrant_hits_to_results(hits, include_expanded=False): + """Shape raw Qdrant hits into Ship's chat-API contract.""" + results = [] + for h in hits: + payload = h.get("payload", {}) or {} + path = payload.get("claim_path", "") or "" + slug = path.rsplit("/", 1)[-1] + if slug.endswith(".md"): + slug = slug[:-3] + results.append({ + "slug": slug, + "path": path, + "title": payload.get("claim_title", ""), + "domain": payload.get("domain"), + "confidence": payload.get("confidence"), + "score": round(float(h.get("score", 0.0) or 0.0), 4), + "body_excerpt": payload.get("snippet", "") or "", + }) + return results - Query params: - q: search query (required) - domain: filter by domain (optional) - confidence: filter by confidence level (optional) - limit: max results, default 10 (optional) - exclude: comma-separated claim paths to exclude (optional) - expand: enable graph expansion, default true (optional) + +async def handle_api_search(request): + """Semantic search over claims via Qdrant. + + POST contract (Ship's chat API): + body: {"query": str, "limit": int, "min_score": float?, "domain": str?, "confidence": str?, "exclude": [str]?} + response: {"query": str, "results": [{"slug","path","title","domain","confidence","score","body_excerpt"}], "total": int} + + GET (legacy + hackathon debug): + q: search query (required) + limit, domain, confidence, exclude, expand + min_score: if set, bypasses two-pass lib threshold (default lib behavior otherwise) """ + if request.method == "POST": + try: + body = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + + query = (body.get("query") or "").strip() + if not query: + return web.json_response({"error": "query required"}, status=400) + + try: + limit = min(int(body.get("limit") or 5), 50) + except (TypeError, ValueError): + return web.json_response({"error": "limit must be int"}, status=400) + try: + min_score = float(body.get("min_score") if body.get("min_score") is not None else 0.25) + except (TypeError, ValueError): + return web.json_response({"error": "min_score must be float"}, status=400) + + domain = body.get("domain") + confidence = body.get("confidence") + exclude = body.get("exclude") or None + + vector = embed_query(query) + if vector is None: + return web.json_response({"error": "embedding failed"}, status=502) + + hits = search_qdrant(vector, limit=limit, domain=domain, + confidence=confidence, exclude=exclude, + score_threshold=min_score) + results = _qdrant_hits_to_results(hits) + return web.json_response({"query": query, "results": results, "total": len(results)}) + + # GET path query = request.query.get("q", "").strip() if not query: return web.json_response({"error": "q parameter required"}, status=400) domain = request.query.get("domain") confidence = request.query.get("confidence") - limit = min(int(request.query.get("limit", "10")), 50) + try: + limit = min(int(request.query.get("limit", "10")), 50) + except ValueError: + return web.json_response({"error": "limit must be int"}, status=400) exclude_raw = request.query.get("exclude", "") exclude = [p.strip() for p in exclude_raw.split(",") if p.strip()] if exclude_raw else None expand = request.query.get("expand", "true").lower() != "false" + min_score_raw = request.query.get("min_score") - # Use shared search library (Layer 1 + Layer 2) + if min_score_raw is not None: + try: + min_score = float(min_score_raw) + except ValueError: + return web.json_response({"error": "min_score must be float"}, status=400) + vector = embed_query(query) + if vector is None: + return web.json_response({"error": "embedding failed"}, status=502) + hits = search_qdrant(vector, limit=limit, domain=domain, + confidence=confidence, exclude=exclude, + score_threshold=min_score) + direct = _qdrant_hits_to_results(hits) + return web.json_response({ + "query": query, + "direct_results": direct, + "expanded_results": [], + "total": len(direct), + }) + + # Default GET: Layer 1 + Layer 2 via lib result = kb_search(query, expand=expand, domain=domain, confidence=confidence, exclude=exclude) - if "error" in result: error = result["error"] if error == "embedding_failed": return web.json_response({"error": "embedding failed"}, status=502) return web.json_response({"error": error}, status=500) - return web.json_response(result) @@ -2268,6 +2345,7 @@ def create_app() -> web.Application: app.router.add_get("/api/contributors", handle_api_contributors) app.router.add_get("/api/domains", handle_api_domains) app.router.add_get("/api/search", handle_api_search) + app.router.add_post("/api/search", handle_api_search) app.router.add_get("/api/audit", handle_api_audit) app.router.add_get("/audit", handle_audit_page) app.router.add_post("/api/usage", handle_api_usage)