feat(kb): public endpoints for the EspoCRM KnowledgeBase UI

Three endpoints under /kb/*, same X-Api-Key auth as the admin and
SmartAssistant routes:

- POST /kb/search — hybrid search + rerank, returns ranked chunks as JSON.
  No LLM in the path, so it's fast enough for a live UI.
- GET  /kb/sources — list of active sources (law / regulation / circular)
  with basic metadata, for the browse tab.
- GET  /kb/source/{id}/chunks — all chunks of one source, ordered, so
  the UI can show a document inline with its hierarchical headings.
- POST /kb/ask — full agent loop (same runner as SmartAssistant but with
  allowed_toolsets=legal), for the ask-shira mode of the UI.

Dates are serialized to ISO strings so the frontend doesn't have to deal
with Python date objects.

Refs Task Master #2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 19:01:28 +00:00
parent 7861b03f4b
commit 2a67f4d893
2 changed files with 173 additions and 0 deletions
+2
View File
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.routes.admin_kb import router as admin_kb_router from api.routes.admin_kb import router as admin_kb_router
from api.routes.health import router as health_router from api.routes.health import router as health_router
from api.routes.kb_public import router as kb_public_router
from api.routes.smart_assistant import router as smart_assistant_router from api.routes.smart_assistant import router as smart_assistant_router
logging.basicConfig( logging.basicConfig(
@@ -36,6 +37,7 @@ app.add_middleware(
app.include_router(health_router) app.include_router(health_router)
app.include_router(smart_assistant_router) app.include_router(smart_assistant_router)
app.include_router(admin_kb_router) app.include_router(admin_kb_router)
app.include_router(kb_public_router)
@app.on_event("startup") @app.on_event("startup")
+171
View File
@@ -0,0 +1,171 @@
"""Public KB endpoints — direct search without the LLM layer.
These endpoints serve the EspoCRM KnowledgeBase extension UI. Auth is the
same X-Api-Key gate used by SmartAssistant (so the EspoCRM backend proxies
calls with the shared key).
"""
from __future__ import annotations
import logging
import os
from typing import Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from api.services.kb import search as kb_search
from api.services.kb import ingest as kb_ingest
logger = logging.getLogger("shira.kb.public")
router = APIRouter(prefix="/kb", tags=["kb"])
def _verify_auth(request: Request) -> None:
expected = os.environ.get("API_KEY", "")
if not expected:
raise HTTPException(status_code=503, detail="API_KEY not configured")
provided = request.headers.get("X-Api-Key", "")
if provided != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
class SearchRequest(BaseModel):
query: str = Field(..., min_length=1, max_length=500)
kind: Literal["any", "law", "regulation", "circular"] = "any"
top_k: int = Field(8, ge=1, le=15)
@router.post("/search")
async def search(body: SearchRequest, request: Request):
"""Hybrid search + rerank. Returns ranked chunks with citations."""
_verify_auth(request)
hits = await kb_search.search(
query=body.query,
kind=body.kind,
top_k=body.top_k,
)
# Dates come back as datetime.date; serialize as ISO strings for the UI.
serialized = []
for h in hits:
item = dict(h)
for k in ("published_at", "effective_at"):
if item.get(k) is not None:
item[k] = item[k].isoformat()
serialized.append(item)
return {"query": body.query, "kind": body.kind, "count": len(serialized), "hits": serialized}
@router.get("/sources")
async def list_sources(request: Request):
"""Browse mode: list all active sources with basic metadata."""
_verify_auth(request)
from api.services.kb.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT s.id, s.kind, s.title, s.identifier, s.published_at,
s.effective_at, s.source_url,
(SELECT COUNT(*) FROM kb_chunk c WHERE c.source_id = s.id) AS chunk_count
FROM kb_source s
WHERE s.superseded_by IS NULL
ORDER BY s.kind, s.published_at DESC NULLS LAST, s.title
"""
)
sources = []
for r in rows:
d = dict(r)
for k in ("published_at", "effective_at"):
if d.get(k) is not None:
d[k] = d[k].isoformat()
sources.append(d)
return {"sources": sources, "count": len(sources)}
@router.get("/source/{source_id}/chunks")
async def source_chunks(source_id: int, request: Request):
"""Browse mode: return all chunks of a single source, ordered."""
_verify_auth(request)
from api.services.kb.db import get_pool
pool = await get_pool()
async with pool.acquire() as conn:
source = await conn.fetchrow(
"SELECT id, kind, title, identifier, published_at, source_url "
"FROM kb_source WHERE id = $1",
source_id,
)
if not source:
raise HTTPException(status_code=404, detail="source not found")
chunks = await conn.fetch(
"""
SELECT chunk_index, heading_path, section_ref, content, token_count
FROM kb_chunk WHERE source_id = $1 ORDER BY chunk_index
""",
source_id,
)
src = dict(source)
if src.get("published_at") is not None:
src["published_at"] = src["published_at"].isoformat()
return {"source": src, "chunks": [dict(c) for c in chunks]}
class AskRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=2000)
conversation_id: str | None = None
conversation_history: list[dict] | None = None
@router.post("/ask")
async def ask(body: AskRequest, request: Request):
"""Full agent loop: the user's question goes to shira, she calls
search_insurance_kb as needed and returns a summarized answer.
Thin wrapper around the existing smart-assistant chat endpoint so the
KnowledgeBase UI can expose an ask mode without rebuilding prompt
plumbing.
"""
_verify_auth(request)
from api.services.agent_runner import AgentRunner
from api.services.prompt_builder import build_office_prompt
from api.services.skills import list_skills
runner = AgentRunner(
ai_gateway_url=os.environ.get("AI_GATEWAY_URL", "http://localhost:3000"),
ai_gateway_api_key=os.environ.get("AI_GATEWAY_API_KEY", ""),
model=os.environ.get("CLAUDE_MODEL", "sonnet"),
max_tokens=int(os.environ.get("MAX_OUTPUT_TOKENS", "4096")),
max_iterations=int(os.environ.get("MAX_AGENT_ITERATIONS", "10")),
)
context = {"currentUser": {"id": "kb-ui", "name": "KB UI"}}
system_prompt = build_office_prompt(
context,
user_profile="",
available_skills=list_skills(),
)
messages: list[dict] = []
for m in (body.conversation_history or []):
role = m.get("role", "user")
messages.append({
"role": "assistant" if role == "assistant" else "user",
"content": m.get("content", ""),
})
messages.append({"role": "user", "content": body.message})
try:
text = await runner.run(
system_prompt=system_prompt,
messages=messages,
context=context,
espocrm_url=os.environ.get("ESPOCRM_URL", ""),
espocrm_api_key=os.environ.get("ESPOCRM_API_KEY", ""),
allowed_toolsets=["legal"],
)
except Exception as e:
logger.exception("[kb.ask] error")
raise HTTPException(status_code=500, detail=str(e))
return {"text": text, "conversationId": body.conversation_id}