Always-on grounded retrieval + role-scoped Ollama models (#62 Phases 1–3) (#65)
Deploy Beta / unit-tests (push) Successful in 11s
Unit Tests / test (push) Successful in 10s
Deploy Beta / docker (push) Successful in 30s
Deploy Beta / deploy-beta (push) Successful in 6m49s

## Summary
- Closes Phases 1–3 of [#62](#62) (Phase 4 eval harness left for a follow-up).
- **Accuracy:** Retrieval is decided every turn (`GroundingDecider`, fails open). `FAST` no longer skips search — it only selects `OLLAMA_MODEL_FAST`. Search failures surface an explicit error instead of hallucinating from parametric memory.
- **Search:** Pluggable `services/search/` with **SearxNG primary** + DDGS failover, ranking/dedupe/rumour filtering, numbered dated source blocks, citations persisted on `Prompt.citations` and emitted as `{"v":1,"type":"citations",...}` after stream end.
- **Models:** Role-scoped `OLLAMA_MODEL_THINKING` / `_FAST` / `_UTILITY` / `OLLAMA_EMBED_MODEL=nomic-embed-text`, configurable `num_ctx`, real model name on `PromptMetric`, `reindex_embeddings` management command + loud embedding-dimension mismatch.

## SearxNG (ops)
See README **SearxNG** section. Short version: run `searxng/searxng` on the GPU host, enable `json` in `settings.yml`, set `SEARXNG_BASE_URL=http://10.0.0.128:8080` in prod/beta secrets, open `:8080` on the LAN firewall like Ollama.

## Test plan
- [x] `SKIP_RAG_INIT=1 python manage.py test chat_backend.tests` — 442 OK (6 skipped)
- [ ] Deploy beta with updated secrets (`OLLAMA_MODEL_*`, `OLLAMA_EMBED_MODEL=nomic-embed-text`, `SEARXNG_BASE_URL`)
- [ ] After embed change: `python manage.py reindex_embeddings`
- [ ] Verify `did Taylor Swift get married` in FAST and THINKING returns grounded answer with citations frame
- [ ] Kill SearxNG and confirm factual turns return search_unavailable (not Joe Alwyn hallucination); non-factual chat still worksReviewed-on: #65
This commit was merged in pull request #65.
This commit is contained in:
westfarn committed 2026-08-02 11:46:02 -07:00
1 parent 57a2350b8e
commit d8f5b8ebf2
32 files changed
+1734 -187

No files matched your search

+47 -26
View File
@@ -11,7 +11,6 @@ from asgiref.sync import sync_to_async
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from langchain_core.messages import HumanMessage, AIMessage, BaseMessage
from langchain_community.tools import DuckDuckGoSearchRun
from langgraph.graph import StateGraph, END
from .models import Conversation, Prompt, PromptMetric, DocumentWorkspace, CustomUser
@@ -30,6 +29,8 @@ from .services.title_generator import title_generator
from .services.moderation_classifier import moderation_classifier, ModerationLabel
from .services.prompt_classifier.prompt_classifier import PromptClassifier, PromptType
from .services.data_analysis_service import AsyncDataAnalysisService
from .services.grounded_chat import citations_frame, prepare_grounded_chat
from chat_backend.ollama_config import ollama_model_for_role, resolve_chat_role
from .utils import (
TokenUsageCollector,
aiter_text_chunks,
@@ -43,7 +44,6 @@ from finance.services.quotas import FeatureNotAllowed, QuotaExceeded, check_gene
logger = logging.getLogger(__name__)
CHANNEL_NAME: str = "llm_messages"
MODEL_NAME: str = "llama3.2"
PROMPT_CLASSIFIER = PromptClassifier()
# --- Database Helpers (Reused) ---
@@ -146,7 +146,7 @@ def get_messages(conversation_id, prompt, file_string: str = None, file_type: st
return transformed_messages, prompt_instance
@database_sync_to_async
def save_generated_message(conversation_id, message):
def save_generated_message(conversation_id, message, citations=None):
conversation = Conversation.objects.get(id=conversation_id)
serializer = PromptSerializer(
data={
@@ -159,6 +159,8 @@ def save_generated_message(conversation_id, message):
prompt_instance = serializer.save()
prompt_instance.conversation_id = conversation.id
prompt_instance.save()
if citations is not None:
Prompt.objects.filter(pk=prompt_instance.pk).update(citations=citations)
else:
print(serializer.errors)
@@ -221,6 +223,8 @@ class ChatState(TypedDict):
error: Union[str, None]
model_name: str
chat_user: Any
citations: List[Dict[str, Any]]
resolved_model: str
# --- LangGraph Nodes ---
@@ -277,22 +281,7 @@ async def generation_node(state: ChatState) -> ChatState:
}
return {"response_generator": {"type": "text", "content": "Image Generation is not supported at this time, but it will be soon."}}
# Feature Flag: Internet Access
if prompt_type == PromptType.SEARCH:
# Check modelName first - if FAST, we skip search regardless of settings
if state.get("model_name") == "FAST":
pass
elif getattr(settings, "ALLOW_INTERNET_ACCESS", False):
try:
search = DuckDuckGoSearchRun()
search_results = search.run(state["message"])
messages.append(HumanMessage(content=f"Search Results: {search_results}"))
except Exception as e:
logger.error(f"Search failed: {e}")
pass
else:
pass
# Feature Flag: Internet Access / always-on grounding handled below for chat.
if prompt_type == PromptType.RAG:
chat_user = state.get("chat_user")
if chat_user is not None:
@@ -318,10 +307,26 @@ async def generation_node(state: ChatState) -> ChatState:
generator = service.generate_response(prompt_instance.message, decoded_file, file_type)
return {"response_generator": generator}
else: # GENERAL_CHAT or others
service = AsyncLLMService()
generator = service.generate_response(messages, prompt_instance.message, conversation_id)
return {"response_generator": generator}
else:
# GENERAL_CHAT / SEARCH / UNKNOWN — always-on grounding (#62).
# FAST selects a smaller model; it no longer skips search.
grounded = await prepare_grounded_chat(
message=state["message"],
messages=messages,
model_name=state.get("model_name"),
conversation_id=conversation_id,
)
if grounded.error:
return {
"response_generator": grounded.error,
"citations": [],
"resolved_model": grounded.model_name or "",
}
return {
"response_generator": grounded.generator,
"citations": grounded.citations,
"resolved_model": grounded.model_name,
}
# --- LangGraph Definition ---
@@ -475,12 +480,13 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
if not decoded_file:
decoded_file, file_type = await get_conversation_file_async(conversation_id)
resolved_model = ollama_model_for_role(resolve_chat_role(model))
prompt_metric = await create_prompt_metric(
prompt_instance.id,
prompt_instance.message,
True if file else False,
file_type,
MODEL_NAME,
resolved_model,
conversation_id,
)
@@ -498,6 +504,8 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
"error": None,
"model_name": model,
"chat_user": chat_user,
"citations": [],
"resolved_model": resolved_model,
}
print("Initial State: ", initial_state)
@@ -535,8 +543,21 @@ class ChatConsumerGraph(AsyncWebsocketConsumer):
tokens_in, tokens_out = usage.pair
await self.send("END_OF_THE_STREAM_ENDER_GAME_42")
await save_generated_message(conversation_id, full_response)
citations = final_state.get("citations") or []
if citations:
await self.send_json_message(json.dumps(citations_frame(citations)))
final_model = final_state.get("resolved_model") or resolved_model
if final_model and final_model != prompt_metric.model_name:
prompt_metric.model_name = final_model
await database_sync_to_async(prompt_metric.save)(
update_fields=["model_name"]
)
await save_generated_message(
conversation_id, full_response, citations=citations
)
await finish_prompt_metric(
prompt_metric,
len(full_response),