Bi-encoder embeddings collapse a whole query into one vector, losing multi-part reasoning structure — queries like "the procedure for setting an IP on an AVC-X" or "what is the action economy for a fighter casting a spell while prone" lose nuance. Agent-ModernColBERT is a late-interaction retriever: per-token embeddings, scored via MaxSim at query time, built specifically for agentic/multi-hop queries. Implements Option A from the issue (in-process, via `pylate`) rather than Option B (managed cf-orch service) — cf-orch already has `agent-moderncolbert` registered in model_registry.yaml with a `pagepiper/retrieve` assignment in assignments.yaml pointing at it and referencing this issue directly, someone had already pre-wired that side. - app/services/colbert_index.py: new ColBERTIndex class, mirrors BM25Index's dirty-flag/rebuild-from-SQLite pattern exactly — no separate per-shelve indexing step needed, just mark_dirty() on the same callback that already marks BM25 dirty. - app/services/retriever.py: hybrid_search's semantic half now merges BM25 with ColBERT MaxSim scores (min-max normalized per-batch, since MaxSim is unbounded unlike the old sqlite-vec L2-distance path) instead of Ollama-embed + sqlite-vec cosine. BM25 merge/rank/per-doc-cap/ adjacent-chunk-window logic is unchanged. - app/main.py / app/deps.py: per-user ColBERTIndex registry, same pattern as the existing per-user BM25Index registry. - Existing BYOK tier gate preserved exactly (llm is None check) — this is a retrieval-technology swap, not a tier/licensing change. The ColBERT model runs locally via pylate with no Ollama dependency, but gating still follows product tiering. - 12 new tests. pylate is intentionally NOT installed in the dev/test env — see the cf-sysadmin skill's "Known Gotchas" for why (installing it directly into the shared `cf` conda env broke several other services' torch/transformers pins on 2026-07-10). Tests inject fake pylate modules via sys.modules instead. Known follow-up (not addressed here): shelve scripts still compute and store Ollama embeddings into `page_vecs` at shelve time — that table is no longer read by search/chat now that retrieval uses the ColBERT index. Removing the now-redundant embedding step is separate cleanup. Closes: #8
190 lines
5.3 KiB
Python
190 lines
5.3 KiB
Python
# app/api/chat.py
|
|
"""
|
|
RAG chat endpoint — retrieves relevant page chunks and synthesizes an answer.
|
|
|
|
BSL 1.1 — BYOK gate: requires PAGEPIPER_OLLAMA_URL or a Paid tier license.
|
|
Returns 402 with clear upgrade message if neither is configured.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.cloud_session import require_paid_tier
|
|
from app.deps import UserCtx, get_user_ctx
|
|
from app.services.retriever import Retriever
|
|
from app.services.synthesizer import Synthesizer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
|
|
|
|
|
class ChatTurn(BaseModel):
|
|
role: str # "user" | "assistant"
|
|
content: str
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
message: str
|
|
history: list[ChatTurn] = []
|
|
doc_ids: list[str] | None = None
|
|
top_k: int = 10
|
|
|
|
|
|
class ChatResponse(BaseModel):
|
|
answer: str
|
|
citations: list[dict]
|
|
|
|
|
|
class ChatFeedbackRequest(BaseModel):
|
|
rating: int # 1 = thumbs up, -1 = thumbs down
|
|
question: str = ""
|
|
answer: str = ""
|
|
doc_ids: list[str] = []
|
|
|
|
|
|
def _get_llm_router():
|
|
"""Return LLMRouter if Ollama/cf-orch configured, else None."""
|
|
from app.config import get_llm_config
|
|
|
|
cfg = get_llm_config()
|
|
if cfg is None:
|
|
return None
|
|
from circuitforge_core.llm import LLMRouter
|
|
|
|
return LLMRouter(cfg)
|
|
|
|
|
|
def _build_llm_for_alloc(alloc) -> "LLMRouter":
|
|
"""Wrap a cf-orch task allocation in a minimal LLMRouter for completion calls."""
|
|
from circuitforge_core.llm import LLMRouter
|
|
|
|
base_url = alloc.url.rstrip("/")
|
|
if not base_url.endswith("/v1"):
|
|
base_url += "/v1"
|
|
cfg = {
|
|
"fallback_order": ["orch_task"],
|
|
"backends": {
|
|
"orch_task": {
|
|
"type": "openai_compat",
|
|
"base_url": base_url,
|
|
"model": alloc.model or "default",
|
|
"supports_images": False,
|
|
}
|
|
},
|
|
}
|
|
return LLMRouter(cfg)
|
|
|
|
|
|
def _run_chat(req: "ChatRequest", ctx: "UserCtx", llm) -> "ChatResponse":
|
|
retriever = Retriever(ctx.bm25, ctx.colbert)
|
|
chunks = retriever.hybrid_search(
|
|
query=req.message,
|
|
top_k=req.top_k,
|
|
doc_ids=req.doc_ids,
|
|
db_path=ctx.db_path,
|
|
vec_db_path=ctx.vec_db_path,
|
|
llm=llm,
|
|
)
|
|
if not chunks:
|
|
return ChatResponse(
|
|
answer=(
|
|
"I couldn't find any relevant passages. "
|
|
"Try a different query or check which documents are indexed."
|
|
),
|
|
citations=[],
|
|
)
|
|
synth = Synthesizer(llm)
|
|
result = synth.synthesize(
|
|
message=req.message,
|
|
history=[t.model_dump() for t in req.history],
|
|
chunks=chunks,
|
|
)
|
|
return ChatResponse(
|
|
answer=result.answer,
|
|
citations=[
|
|
{
|
|
"doc_id": c.doc_id,
|
|
"page_number": c.page_number,
|
|
"snippet": c.snippet,
|
|
"bm25_score": c.bm25_score,
|
|
}
|
|
for c in result.citations
|
|
],
|
|
)
|
|
|
|
|
|
def _require_llm():
|
|
"""Return LLMRouter or raise 402."""
|
|
llm = _get_llm_router()
|
|
if llm is None:
|
|
raise HTTPException(
|
|
status_code=402,
|
|
detail={
|
|
"error": "ollama_required",
|
|
"message": (
|
|
"RAG chat requires Ollama. Set PAGEPIPER_OLLAMA_URL in your .env file, "
|
|
"then restart. Run: ollama pull nomic-embed-text && ollama pull mistral:7b"
|
|
),
|
|
},
|
|
)
|
|
return llm
|
|
|
|
|
|
@router.post("")
|
|
def chat(
|
|
req: ChatRequest,
|
|
ctx: UserCtx = Depends(get_user_ctx),
|
|
_tier: str = Depends(require_paid_tier),
|
|
) -> ChatResponse:
|
|
orch_url = os.environ.get("CF_ORCH_URL", "").strip()
|
|
|
|
if orch_url:
|
|
try:
|
|
from circuitforge_orch.client import CFOrchClient, TaskNotFound # type: ignore[import]
|
|
api_key = os.environ.get("CF_LICENSE_KEY", "")
|
|
client = CFOrchClient(orch_url, api_key=api_key)
|
|
with client.task_allocate("pagepiper", "rag_query") as alloc:
|
|
llm = _build_llm_for_alloc(alloc)
|
|
return _run_chat(req, ctx, llm)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"cf-orch task allocation for pagepiper.rag_query failed, falling back to LLMRouter: %s",
|
|
exc,
|
|
)
|
|
|
|
llm = _require_llm()
|
|
return _run_chat(req, ctx, llm)
|
|
|
|
|
|
@router.get("/feedback/status")
|
|
def chat_feedback_status() -> dict:
|
|
enabled = os.environ.get("PAGEPIPER_CHAT_FEEDBACK", "").lower() in ("1", "true", "yes")
|
|
return {"enabled": enabled}
|
|
|
|
|
|
@router.post("/feedback")
|
|
def submit_chat_feedback(
|
|
req: ChatFeedbackRequest,
|
|
ctx: UserCtx = Depends(get_user_ctx),
|
|
) -> dict:
|
|
import json
|
|
import sqlite3
|
|
|
|
if req.rating not in (1, -1):
|
|
from fastapi import HTTPException
|
|
raise HTTPException(status_code=422, detail="rating must be 1 or -1")
|
|
|
|
con = sqlite3.connect(ctx.db_path)
|
|
try:
|
|
con.execute(
|
|
"INSERT INTO chat_feedback (rating, question, answer, doc_ids) VALUES (?, ?, ?, ?)",
|
|
(req.rating, req.question[:2000], req.answer[:4000], json.dumps(req.doc_ids)),
|
|
)
|
|
con.commit()
|
|
finally:
|
|
con.close()
|
|
return {"ok": True}
|