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:
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
Reference in New Issue
Block a user