diff --git a/api/main.py b/api/main.py index 78bd214..d43ee0e 100644 --- a/api/main.py +++ b/api/main.py @@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.routes.admin_kb import router as admin_kb_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 logging.basicConfig( @@ -36,6 +37,7 @@ app.add_middleware( app.include_router(health_router) app.include_router(smart_assistant_router) app.include_router(admin_kb_router) +app.include_router(kb_public_router) @app.on_event("startup") diff --git a/api/routes/kb_public.py b/api/routes/kb_public.py new file mode 100644 index 0000000..05829c6 --- /dev/null +++ b/api/routes/kb_public.py @@ -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}