Module 5 phase 4 step 1 (docs/kb/modules/05-faza4-plan.md, §4): first user-facing HTTP entry point to the KB. FastAPI wrapping kb_retrieval.cascade_query/flat_query — GET /search (query_text -> embed via Ollama@SOLARIA -> cascade/flat -> envelope join -> JSON with per-source links) and GET /healthz. Search API only, no answer synthesis (phase 5) and no server-side dist filtering — the 0.45/0.55 colour thresholds are a frontend concern (plan §7, a later step). Hard startup invariant (plan §2 decision 2): refuses to start unless the configured EMBED_MODEL is present in both document_chunk.model and document_summary.embedding_model. Note the latter: document_summary.model is the LLM that *wrote* the summary (claude-haiku-4-5/gemma3:12b), not the embedder — checked live against kb-postgres@PIHA before writing this, see app/startup.py's docstring. Verified end-to-end with a live docker run: the invariant crash-loops on a mismatched EMBED_MODEL and passes through to a real /search hit against the live corpus with a correct model. Repo-only: no deploy, no npm/OIDC/DNS wiring (plan §8, later step), no local embed fallback (plan §5, later step) — Ollama@SOLARIA is called directly and a failure surfaces as 503, not a crash. Also: scripts/deploy/deploy.sh's gate now builds each service via `docker compose build` instead of a raw `docker build <svc_dir>`, so a service whose docker-compose.yml declares a repo-root build context (needed here to COPY packages/kb-retrieval/, the packages/ Dockerfile convention already documented in CLAUDE.md) resolves the same way in the gate as it does at real deploy time (deploy-node.sh's `docker compose ... up --build`). No behavior change for existing single-context services — verified against llm-gateway's compose file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
73 lines
2.5 KiB
Python
73 lines
2.5 KiB
Python
"""kb-query -- module 5 phase 4 (docs/kb/modules/05-faza4-plan.md §4): first user-facing HTTP
|
|
entry point to the KB. Wraps `kb_retrieval.cascade_query`/`flat_query` (module 5 phase 3,
|
|
already gated PASS -- docs/sessions/2026-07-21.md) in FastAPI. This is a search API, not chat:
|
|
no answer synthesis, no LLM call over the results (that is phase 5, out of scope here).
|
|
|
|
Embed path is deliberately simple for this step: calls Ollama on SOLARIA directly, no
|
|
cache/circuit-breaker/local-PIHA-fallback (plan §2 decision 2, §5) -- that state machine is a
|
|
later, separate step. A failed embed call (SOLARIA unreachable) surfaces as 503 to the caller
|
|
rather than a bare 500.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
import aiohttp
|
|
from fastapi import FastAPI, HTTPException, Query
|
|
|
|
from kb_retrieval.embed import check_ollama_health
|
|
|
|
from app.db import create_pool
|
|
from app.search import run_search
|
|
from app.startup import validate_embed_model
|
|
|
|
KB_DSN = os.environ.get("KB_DSN")
|
|
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://solaria:11434")
|
|
EMBED_MODEL = os.environ.get("EMBED_MODEL", "bge-m3")
|
|
SUMMARY_MODEL = os.environ.get("SUMMARY_MODEL", "claude-haiku-4-5")
|
|
OLLAMA_HEALTH_TIMEOUT_S = 3.0
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
if not KB_DSN:
|
|
raise RuntimeError("KB_DSN is required (see env.example)")
|
|
|
|
pool = await create_pool(KB_DSN)
|
|
async with pool.acquire() as conn:
|
|
# Hard invariant (plan §2 decision 2): refuse to start rather than silently serve
|
|
# queries against a mismatched embedding space.
|
|
await validate_embed_model(conn, EMBED_MODEL)
|
|
|
|
app.state.pool = pool
|
|
app.state.http = aiohttp.ClientSession()
|
|
try:
|
|
yield
|
|
finally:
|
|
await app.state.http.close()
|
|
await pool.close()
|
|
|
|
|
|
app = FastAPI(lifespan=lifespan)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict:
|
|
sol_up = await check_ollama_health(app.state.http, OLLAMA_URL, OLLAMA_HEALTH_TIMEOUT_S)
|
|
return {"status": "ok", "sol_status": "up" if sol_up else "down"}
|
|
|
|
|
|
@app.get("/search")
|
|
async def search(
|
|
q: str = Query(..., min_length=1),
|
|
mode: str = Query("cascade", pattern="^(cascade|flat)$"),
|
|
) -> dict:
|
|
try:
|
|
async with app.state.pool.acquire() as conn:
|
|
return await run_search(
|
|
conn, app.state.http, OLLAMA_URL, q, mode, EMBED_MODEL, SUMMARY_MODEL
|
|
)
|
|
except aiohttp.ClientError as exc:
|
|
raise HTTPException(status_code=503, detail=f"embed backend unavailable: {exc}") from exc
|