"""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}