From 6605e2c44557aa8ce720e5e5e63981cc35d1264b Mon Sep 17 00:00:00 2001 From: yashgoel331 Date: Mon, 29 Jun 2026 11:35:01 +0000 Subject: [PATCH 1/2] suggestions should not be about non milk transaction details --- assets/prompts/suggestions_system.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/assets/prompts/suggestions_system.md b/assets/prompts/suggestions_system.md index ef9f2bd..3b8cf43 100644 --- a/assets/prompts/suggestions_system.md +++ b/assets/prompts/suggestions_system.md @@ -20,6 +20,8 @@ You generate follow-up farmer questions from recent conversation context. ## Scope - Suggestions should stay within agriculture/livestock context. +- Do not generate questions about bank accounts or non-milk financial transactions. +- Allow only milk/cooperative milk-payment related transaction questions (for example: milk payment, milk rate, bonus, PD/price differential, dividend). - Avoid unrelated, generic, or repetitive questions. ## Tool usage From d1f3be51387aff197c49fd2fa8d0fcdefa72e3f0 Mon Sep 17 00:00:00 2001 From: yashgoel331 Date: Mon, 29 Jun 2026 15:45:13 +0000 Subject: [PATCH 2/2] Add hybrid suggestions pipeline with feature-flag rollout. Introduce retrieval evidence extraction, quality-gated hybrid input, prompt guardrails, observability logs, and updated API/FE documentation behind SUGGESTIONS_HYBRID_ENABLED. --- API_DOC.md | 36 +++--- app/config.py | 4 + app/services/chat.py | 159 +++++++++++++++++++++++++- app/tasks/suggestions.py | 163 ++++++++++++++++++++++++++- assets/prompts/suggestions_system.md | 18 ++- docs/CHAT_ENDPOINT_FE_INTEGRATION.md | 104 ++++++++++++++++- example.env | 5 + 7 files changed, 463 insertions(+), 26 deletions(-) diff --git a/API_DOC.md b/API_DOC.md index f42b938..127d00c 100644 --- a/API_DOC.md +++ b/API_DOC.md @@ -27,24 +27,30 @@ Handles chat sessions between a user and the AI assistant. - **Description**: - Initiates a chat session with the AI assistant. Uses the `agrinet_agent` to process the query and streams the response. When `use_translation_pipeline=true`, the query is translated to English, the agent responds in English, and the response is translated to `target_lang` before streaming. -### 2. suggestions (GET) -Handles suggestions for questions for the farmer to ask. +### 2. Suggestions (GET) +Returns follow-up questions the farmer can ask, generated in the background after a valid chat turn. -- **URL**: `/api/suggestions/` +- **URL**: `/api/suggest/` - **Method**: `GET` +- **Authentication**: Required (JWT Bearer token) - **Query Parameters**: - - `session_id`: The unique identifier for the chat session. - - `target_lang`: The target language of the query. Defaults to `mr`. (Can use other languages as well for testing) - -- **Response**: - - A `Response` object that contains the suggestions for questions for the farmer to ask. - - Each suggestion is a dictionary with the following keys: - - `question`: The question for the farmer to ask. - - `context`: The context of the question. - - NOTE: - - Look at open-webui's Suggestions UI for reference. - - When clicked, the question and context should be combined using '{question} {context}' format. + - `session_id`: The unique identifier for the chat session. (required) + - `target_lang`: Language for suggested questions. Defaults to `gu`. Supported for generation: English and Gujarati. + +- **Response**: + - JSON array of strings (3–5 suggested follow-up questions), e.g. `["Question 1?", "Question 2?"]` + - Returns `[]` if suggestions are not yet available (endpoint waits up to 8s while generation is pending) + +- **Generation pipeline** (background; does not affect chat stream): + 1. Triggered after moderation passes on `GET /api/chat/` + 2. Runs after chat streaming completes (FastAPI background task) + 3. Builds input from conversation history; optionally includes distilled `search_documents` evidence when `SUGGESTIONS_HYBRID_ENABLED=true` and retrieval quality gate passes + 4. Suggestions agent (tool-free LLM) generates follow-ups; result cached for 30 minutes + +- **Configuration**: + - `SUGGESTIONS_HYBRID_ENABLED` (default `false`): enable hybrid input (conversation + retrieval evidence). When disabled, suggestions use conversation-only input. + +- **See also**: [Chat Endpoint FE Integration – Suggestions Pipeline](docs/CHAT_ENDPOINT_FE_INTEGRATION.md#suggestions-pipeline) ### 3. transcribe (POST) diff --git a/app/config.py b/app/config.py index f2d9650..3aae453 100644 --- a/app/config.py +++ b/app/config.py @@ -81,6 +81,10 @@ class Settings(BaseSettings): # non-load-bearing (2h is generous slack; voice's old 24h was incidental). history_cache_ttl_seconds: int = int(os.getenv("HISTORY_CACHE_TTL_SECONDS", str(60 * 60 * 2))) suggestions_cache_ttl: int = 60 * 30 # 30 minutes + # Suggestions rollout flag: when false, suggestions stay conversation-only. + suggestions_hybrid_enabled: bool = os.getenv("SUGGESTIONS_HYBRID_ENABLED", "false").strip().lower() in { + "1", "true", "yes", "on" + } farmer_animal_api_cache_ttl: int = 60 * 60 * 24 * 17 # 17 days # Session-ownership locking (voice call concurrency) — consumed by app/utils.py # once the voice surface folds in; inert on the chat path. diff --git a/app/services/chat.py b/app/services/chat.py index aec644a..47127a3 100644 --- a/app/services/chat.py +++ b/app/services/chat.py @@ -2,8 +2,10 @@ from typing import AsyncGenerator from functools import lru_cache import os +import time import regex import re +from collections import defaultdict from fastapi import BackgroundTasks from agents.agrinet import agrinet_agent from agents.moderation import moderation_agent @@ -147,6 +149,10 @@ def should_translate_batch(batch_text: str, word_count: int) -> bool: logger = get_logger(__name__) WHATSAPP_RESPONSE_MAX_CHARS = 1600 SUGGESTIONS_PENDING_TTL = 30 +SUGGESTIONS_SHADOW_CONTEXT_TTL = 60 * 10 # 10 minutes +SUGGESTIONS_SHADOW_MAX_CALLS = 2 +SUGGESTIONS_SHADOW_MAX_SNIPPETS_PER_CALL = 2 +SUGGESTIONS_SHADOW_MAX_SNIPPET_CHARS = 600 GENERIC_UNAVAILABLE_MESSAGE_EN = ( "I am unable to process your request right now. Please try again later." ) @@ -166,6 +172,107 @@ def _response_max_chars_for_channel(channel: str | None) -> int | None: return WHATSAPP_RESPONSE_MAX_CHARS return None + +def _normalize_ws(value: str) -> str: + return re.sub(r"\s+", " ", (value or "").strip()) + + +def _extract_query_from_search_header(text: str) -> str: + match = re.search(r"> Search Results for `([^`]+)`", text or "") + return (match.group(1) if match else "").strip() + + +def _extract_snippets_from_search_return(content: str) -> tuple[list[str], bool]: + no_results = "No results found for" in (content or "") + fenced_blocks = re.findall(r"```(.*?)```", content or "", flags=re.DOTALL) + snippets: list[str] = [] + for block in fenced_blocks: + cleaned = _normalize_ws(block) + if not cleaned: + continue + snippets.append(cleaned[:SUGGESTIONS_SHADOW_MAX_SNIPPET_CHARS]) + return snippets, no_results + + +def _extract_shadow_search_evidence(new_messages: list) -> dict: + """Extract/distill current-turn search_documents evidence for shadow logging. + + This function is intentionally read-only and side-effect free; Phase 1 only + observes retrieval evidence and does not alter suggestion generation. + """ + search_call_ids: set[str] = set() + search_call_queries: dict[str, str] = {} + returns_by_call_id: dict[str, list[str]] = defaultdict(list) + + for message in new_messages or []: + for part in getattr(message, "parts", []) or []: + kind = getattr(part, "part_kind", "") + if kind == "tool-call" and getattr(part, "tool_name", "") == "search_documents": + tool_call_id = getattr(part, "tool_call_id", "") + if not tool_call_id: + continue + search_call_ids.add(tool_call_id) + args = getattr(part, "args", {}) or {} + query = "" + if isinstance(args, dict): + query = str(args.get("query", "")).strip() + search_call_queries[tool_call_id] = query + elif kind == "tool-return": + tool_call_id = getattr(part, "tool_call_id", "") + if tool_call_id: + returns_by_call_id[tool_call_id].append(str(getattr(part, "content", "") or "")) + + ordered_ids = [x for x in list(returns_by_call_id.keys()) if x in search_call_ids] + ordered_ids = ordered_ids[-SUGGESTIONS_SHADOW_MAX_CALLS:] # most recent calls only + + distilled_calls = [] + total_snippets = 0 + no_result_calls = 0 + seen_snippets: set[str] = set() + + for tool_call_id in ordered_ids: + query = search_call_queries.get(tool_call_id, "") + snippets_for_call: list[str] = [] + call_no_results = False + for content in returns_by_call_id.get(tool_call_id, []): + if not query: + query = _extract_query_from_search_header(content) + snippets, no_results = _extract_snippets_from_search_return(content) + if no_results: + call_no_results = True + for snippet in snippets: + key = _normalize_ws(snippet).lower() + if not key or key in seen_snippets: + continue + seen_snippets.add(key) + snippets_for_call.append(snippet) + if len(snippets_for_call) >= SUGGESTIONS_SHADOW_MAX_SNIPPETS_PER_CALL: + break + if len(snippets_for_call) >= SUGGESTIONS_SHADOW_MAX_SNIPPETS_PER_CALL: + break + + if call_no_results: + no_result_calls += 1 + total_snippets += len(snippets_for_call) + distilled_calls.append( + { + "tool_call_id": tool_call_id, + "query": query, + "no_results": call_no_results, + "snippet_count": len(snippets_for_call), + "snippets": snippets_for_call, + } + ) + + return { + "search_call_count": len(search_call_ids), + "search_return_count": sum(len(v) for k, v in returns_by_call_id.items() if k in search_call_ids), + "distilled_call_count": len(distilled_calls), + "total_snippets": total_snippets, + "no_result_calls": no_result_calls, + "distilled_calls": distilled_calls, + } + async def stream_chat_messages( query: str, session_id: str, @@ -180,6 +287,7 @@ async def stream_chat_messages( pipeline_variant: str = "legacy", ) -> AsyncGenerator[str, None]: """Async generator for streaming chat messages.""" + request_start_monotonic_s = time.monotonic() # OSS sticky variant => run the dev OSS path (translation pipeline + vLLM # agent model). 'legacy' keeps the current prod behaviour byte-for-byte; # with OSS_PIPELINE_PCT=0 every session is 'legacy'. @@ -462,11 +570,26 @@ async def localize_system_text(text_en: str) -> str: try: suggestions_cache_key = f"suggestions_{session_id}_{target_lang}" status_key = f"{suggestions_cache_key}:pending" + suggestions_queued_wall_ms = int(time.time() * 1000) + suggestions_queued_monotonic_s = time.monotonic() # Mark pending and clear stale suggestions so callers wait for fresh output. await set_cache(status_key, True, ttl=SUGGESTIONS_PENDING_TTL) await cache.delete(suggestions_cache_key) - background_tasks.add_task(create_suggestions, session_id, target_lang, pipeline_variant) - logger.info("Successfully added suggestions task") + background_tasks.add_task( + create_suggestions, + session_id, + target_lang, + pipeline_variant, + queued_wall_ms=suggestions_queued_wall_ms, + queued_monotonic_s=suggestions_queued_monotonic_s, + ) + logger.info( + "suggestions_task_queued session_id=%s target_lang=%s variant=%s queued_wall_ms=%s", + session_id, + target_lang, + pipeline_variant, + suggestions_queued_wall_ms, + ) except Exception as e: logger.error(f"Error adding suggestions task: {str(e)}") else: @@ -958,3 +1081,35 @@ async def _stream_to_client(english_src): logger.info(f"Updating message history for session {session_id} with {len(messages)} messages") await update_message_history(session_id, messages) + try: + shadow_evidence = _extract_shadow_search_evidence(new_messages) + shadow_payload = { + "session_id": session_id, + "target_lang": target_lang, + "variant": pipeline_variant, + "captured_from": "new_messages", + "captured_wall_ms": int(time.time() * 1000), + **shadow_evidence, + } + shadow_cache_key = f"suggestions_shadow_{session_id}_{target_lang}" + await set_cache(shadow_cache_key, shadow_payload, ttl=SUGGESTIONS_SHADOW_CONTEXT_TTL) + logger.info( + "suggestions_shadow_evidence session_id=%s variant=%s calls=%s returns=%s distilled_calls=%s snippets=%s no_result_calls=%s", + session_id, + pipeline_variant, + shadow_evidence["search_call_count"], + shadow_evidence["search_return_count"], + shadow_evidence["distilled_call_count"], + shadow_evidence["total_snippets"], + shadow_evidence["no_result_calls"], + ) + except Exception as e: + logger.warning("suggestions_shadow_evidence_failed session_id=%s error=%s", session_id, e) + stream_complete_wall_ms = int(time.time() * 1000) + logger.info( + "chat_stream_complete session_id=%s variant=%s stream_complete_wall_ms=%s stream_elapsed_ms=%s", + session_id, + pipeline_variant, + stream_complete_wall_ms, + int((time.monotonic() - request_start_monotonic_s) * 1000), + ) diff --git a/app/tasks/suggestions.py b/app/tasks/suggestions.py index 44968b8..334afd3 100644 --- a/app/tasks/suggestions.py +++ b/app/tasks/suggestions.py @@ -3,8 +3,11 @@ """ from contextlib import nullcontext +import time +import re +from typing import Optional from helpers.utils import get_logger -from app.utils import _get_message_history, trim_history, format_message_pairs, set_cache +from app.utils import _get_message_history, trim_history, format_message_pairs, set_cache, get_cache from app.core.cache import cache from agents.models import ( LLM_MODEL_NAME, @@ -20,6 +23,11 @@ logger = get_logger(__name__) SUGGESTIONS_CACHE_TTL = 60*30 # 30 minutes +SUGGESTIONS_HYBRID_MIN_SNIPPETS = 2 +SUGGESTIONS_HYBRID_MIN_CHARS = 240 +SUGGESTIONS_HYBRID_MIN_OVERLAP = 0.15 +SUGGESTIONS_CONVERSATION_LIMIT_HYBRID = 3 +SUGGESTIONS_CONVERSATION_LIMIT_FALLBACK = 5 try: from langfuse import propagate_attributes, get_client as get_langfuse_client @@ -27,11 +35,103 @@ propagate_attributes = None get_langfuse_client = None -async def create_suggestions(session_id: str, target_lang: str = 'mr', variant: str = "legacy"): + +_TOKEN_RE = re.compile(r"[\w\-]+", flags=re.UNICODE) + + +def _tokenize(text: str) -> set[str]: + return {t.lower() for t in _TOKEN_RE.findall(text or "") if t} + + +def _overlap_ratio(query: str, text: str) -> float: + q = _tokenize(query) + if not q: + return 0.0 + t = _tokenize(text) + if not t: + return 0.0 + return len(q & t) / len(q) + + +def _is_shadow_retrieval_usable(shadow_payload: dict | None) -> tuple[bool, str]: + if not shadow_payload: + return False, "missing_shadow_payload" + if int(shadow_payload.get("search_return_count", 0)) < 1: + return False, "no_search_returns" + + distilled_calls = shadow_payload.get("distilled_calls", []) or [] + if not distilled_calls: + return False, "no_distilled_calls" + + all_snippets: list[str] = [] + has_no_result = False + best_overlap = 0.0 + for call in distilled_calls: + query = str(call.get("query", "") or "") + snippets = call.get("snippets", []) or [] + if bool(call.get("no_results", False)): + has_no_result = True + for snippet in snippets: + snippet_text = str(snippet or "").strip() + if snippet_text: + all_snippets.append(snippet_text) + if query: + best_overlap = max(best_overlap, _overlap_ratio(query, snippet_text)) + + if has_no_result: + return False, "explicit_no_results" + if len(all_snippets) < SUGGESTIONS_HYBRID_MIN_SNIPPETS: + return False, "insufficient_snippets" + if sum(len(x) for x in all_snippets) < SUGGESTIONS_HYBRID_MIN_CHARS: + return False, "insufficient_evidence_chars" + if best_overlap < SUGGESTIONS_HYBRID_MIN_OVERLAP: + return False, "low_query_overlap" + + return True, "ok" + + +def _format_retrieval_evidence(shadow_payload: dict) -> str: + lines: list[str] = [] + for idx, call in enumerate(shadow_payload.get("distilled_calls", []) or [], start=1): + query = str(call.get("query", "") or "").strip() or "unknown" + snippets = call.get("snippets", []) or [] + if not snippets: + continue + lines.append(f"Search {idx} Query: {query}") + for s_idx, snippet in enumerate(snippets, start=1): + lines.append(f"- Evidence {s_idx}: {snippet}") + lines.append("") + return "\n".join(lines).strip() + + +async def create_suggestions( + session_id: str, + target_lang: str = 'mr', + variant: str = "legacy", + *, + queued_wall_ms: Optional[int] = None, + queued_monotonic_s: Optional[float] = None, +): """ Create and save suggestions for a session """ - logger.info(f"Getting suggestions for session {session_id}") + task_start_monotonic_s = time.monotonic() + task_start_wall_ms = int(time.time() * 1000) + queue_delay_ms = None + if queued_monotonic_s is not None: + queue_delay_ms = int((task_start_monotonic_s - queued_monotonic_s) * 1000) + elif queued_wall_ms is not None: + queue_delay_ms = task_start_wall_ms - queued_wall_ms + + logger.info( + "suggestions_task_started session_id=%s target_lang=%s variant=%s start_wall_ms=%s queued_wall_ms=%s queue_delay_ms=%s", + session_id, + target_lang, + variant, + task_start_wall_ms, + queued_wall_ms, + queue_delay_ms, + ) # Run suggestions on the same backend as the session's pipeline: OSS sessions # use the self-hosted gemma model (no API cost; completes full-OSS for chat), @@ -46,15 +146,55 @@ async def create_suggestions(session_id: str, target_lang: str = 'mr', variant: try: # Get message history raw_history = await _get_message_history(session_id) + shadow_cache_key = f"suggestions_shadow_{session_id}_{target_lang}" + shadow_payload = await get_cache(shadow_cache_key) + retrieval_usable, retrieval_reason = _is_shadow_retrieval_usable(shadow_payload) + hybrid_enabled = bool(getattr(settings, "suggestions_hybrid_enabled", False)) + if not hybrid_enabled: + retrieval_usable = False + retrieval_reason = "hybrid_feature_flag_disabled" history = trim_history(raw_history, 30_000, include_tool_calls=False, include_system_prompts=False ) - message_pairs = "\n\n".join(format_message_pairs(history, 5)) + conversation_limit = ( + SUGGESTIONS_CONVERSATION_LIMIT_HYBRID + if retrieval_usable + else SUGGESTIONS_CONVERSATION_LIMIT_FALLBACK + ) + message_pairs = "\n\n".join(format_message_pairs(history, conversation_limit)) target_lang_name = Language.get(target_lang).display_name(target_lang) - message = f"**Conversation**\n\n{message_pairs}\n\n**Based on the conversation, suggest 3-5 questions the farmer can ask in {target_lang_name}.**" + retrieval_section = "" + if retrieval_usable and shadow_payload: + retrieval_section = _format_retrieval_evidence(shadow_payload) + + if retrieval_section: + message = ( + f"**Recent Conversation**\n\n{message_pairs}\n\n" + f"**Retrieved Evidence**\n\n{retrieval_section}\n\n" + f"**Using both the recent conversation and retrieved evidence, suggest 3-5 practical questions " + f"the farmer can ask in {target_lang_name}. If there is a conflict, prioritize evidence-grounded " + f"and answerable questions.**" + ) + suggestion_input_mode = "hybrid" + else: + message = ( + f"**Conversation**\n\n{message_pairs}\n\n" + f"**Based on the conversation, suggest 3-5 questions the farmer can ask in {target_lang_name}.**" + ) + suggestion_input_mode = "conversation_only" + + logger.info( + "suggestions_input_mode session_id=%s mode=%s retrieval_reason=%s conversation_pairs=%s retrieval_usable=%s hybrid_enabled=%s", + session_id, + suggestion_input_mode, + retrieval_reason, + conversation_limit, + retrieval_usable, + hybrid_enabled, + ) session_id_safe = (session_id or "")[:200] session_ctx = ( @@ -78,6 +218,8 @@ async def create_suggestions(session_id: str, target_lang: str = 'mr', variant: "session_id": session_id, "target_lang": target_lang, "model_name": sug_model_name, + "input_mode": suggestion_input_mode, + "retrieval_reason": retrieval_reason, "message": message, }, model=sug_model_name, @@ -108,7 +250,16 @@ async def create_suggestions(session_id: str, target_lang: str = 'mr', variant: # Store suggestions in cache result = await set_cache(f"suggestions_{session_id}_{target_lang}", suggestions, ttl=SUGGESTIONS_CACHE_TTL) - logger.info(f"Suggestions saved for session {session_id}: {result}") + cache_write_wall_ms = int(time.time() * 1000) + logger.info( + "suggestions_cache_written session_id=%s target_lang=%s success=%s cache_write_wall_ms=%s task_elapsed_ms=%s queue_delay_ms=%s", + session_id, + target_lang, + result, + cache_write_wall_ms, + int((time.monotonic() - task_start_monotonic_s) * 1000), + queue_delay_ms, + ) return suggestions diff --git a/assets/prompts/suggestions_system.md b/assets/prompts/suggestions_system.md index 3b8cf43..f118867 100644 --- a/assets/prompts/suggestions_system.md +++ b/assets/prompts/suggestions_system.md @@ -1,4 +1,4 @@ -You generate follow-up farmer questions from recent conversation context. +You generate follow-up farmer questions from recent conversation and optional retrieved evidence. ## Output contract - Output **only** suggested questions (no commentary). @@ -17,6 +17,7 @@ You generate follow-up farmer questions from recent conversation context. - Keep each question short and specific. - Prioritize practical next actions. - Prefer relevance to the last user problem and likely next decision. +- Do not copy long sentences from evidence; convert evidence into short farmer-style follow-up questions. ## Scope - Suggestions should stay within agriculture/livestock context. @@ -24,9 +25,22 @@ You generate follow-up farmer questions from recent conversation context. - Allow only milk/cooperative milk-payment related transaction questions (for example: milk payment, milk rate, bonus, PD/price differential, dividend). - Avoid unrelated, generic, or repetitive questions. +## Answerability guardrails +- Suggest only questions this agent can realistically answer with its current agriculture/cooperative capabilities. +- Do not suggest personal account lookup actions the agent cannot perform (for example: "check my passbook balance", "show my pending PD/payment balance", "show my salary balance"). +- Do not suggest language-switch requests to unsupported languages (anything other than English or Gujarati). +- Prefer explainer-style cooperative questions over personal ledger lookup questions (for example ask "how is PD calculated?" instead of "what is my pending PD amount?"). + ## Tool usage - Do not call tools for suggestion generation. ## Input format -Conversation History: ... +Recent Conversation: ... +Retrieved Evidence (optional): ... Generate Suggestions In: English|Gujarati + +## Hybrid-input usage +- If `Retrieved Evidence` is present, use it as the primary source for factual grounding. +- Use `Recent Conversation` to keep continuity and avoid repeating what was already answered. +- If conversation and evidence conflict, prefer evidence-grounded and answerable questions. +- If no evidence is present, rely only on conversation context. diff --git a/docs/CHAT_ENDPOINT_FE_INTEGRATION.md b/docs/CHAT_ENDPOINT_FE_INTEGRATION.md index 2ba2b44..015fc89 100644 --- a/docs/CHAT_ENDPOINT_FE_INTEGRATION.md +++ b/docs/CHAT_ENDPOINT_FE_INTEGRATION.md @@ -274,4 +274,106 @@ await streamChat({ - Use the same `session_id` for a conversation to keep context. - If omitted, the backend generates a UUID; capture it from the first response if you need it for suggestions or history. -- Suggestions: `GET /api/suggestions/?session_id={session_id}&target_lang={target_lang}` + +--- + +## Suggestions Pipeline + +Follow-up question suggestions are generated **in the background** after each valid chat turn. They do not block chat streaming. + +### Endpoint + +| Property | Value | +|----------|-------| +| **URL** | `GET /api/suggest/` | +| **Auth** | Required (JWT Bearer token) | +| **Response** | JSON array of question strings | + +### Query Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `session_id` | string | Yes | — | Same session ID used in chat | +| `target_lang` | string | No | `gu` | Language for suggested questions (English or Gujarati) | + +### Response Format + +Returns a JSON array of 3–5 farmer follow-up questions, e.g.: + +```json +[ + "What signs show a cow is ready for breeding after calving?", + "How long should I wait before the first insemination?", + "What nutrition changes are needed during the waiting period?" +] +``` + +On cache miss (suggestions not ready yet), the endpoint polls for up to **8 seconds** while generation is pending, then returns `[]` if still unavailable. + +### When Suggestions Are Generated + +Suggestions run only when moderation passes (`valid_agricultural`): + +1. User sends chat request → moderation runs synchronously. +2. If valid, backend marks suggestions as `:pending`, clears stale cache, and queues `create_suggestions` as a FastAPI background task. +3. Chat agent streams the response (unchanged behavior). +4. After the stream completes, message history is persisted and current-turn `search_documents` evidence is distilled into a shadow cache payload. +5. FastAPI runs the queued background task → suggestions agent generates follow-ups → result is written to cache. + +**Important:** Background tasks run after the streaming response finishes, so suggestions always have access to the completed turn (including tool returns). + +### Input Modes (Hybrid vs Conversation-Only) + +Controlled by `SUGGESTIONS_HYBRID_ENABLED` (default: `false`). + +| Flag | Mode | Input to suggestions agent | +|------|------|----------------------------| +| `false` | `conversation_only` | Last 5 user/assistant pairs | +| `true` + retrieval gate passes | `hybrid` | Last 3 pairs + distilled retrieval evidence | +| `true` + retrieval gate fails | `conversation_only` | Last 5 pairs (fallback) | + +When hybrid is enabled, retrieval evidence must pass a quality gate: + +- at least one `search_documents` return in the current turn +- not an explicit no-result payload +- at least 2 deduped snippets after distillation +- minimum total evidence length +- minimum lexical overlap between search query and snippets + +If the gate fails, suggestions fall back to conversation-only automatically. + +### Frontend Integration Notes + +- Poll `GET /api/suggest/` after chat stream completes (or retry briefly if you receive `[]`). +- Use the same `session_id` and `target_lang` as the chat request. +- When the user taps a suggestion, send it as the next chat `query`. +- Suggestions are cached for 30 minutes per `(session_id, target_lang)`. + +### Example + +```javascript +async function fetchSuggestions(sessionId, targetLang = 'en') { + const params = new URLSearchParams({ + session_id: sessionId, + target_lang: targetLang, + }); + + const res = await fetch(`/api/suggest/?${params}`, { + headers: { Authorization: `Bearer ${getJwtToken()}` }, + }); + + if (!res.ok) throw new Error(`Suggestions failed: ${res.status}`); + return res.json(); // string[] +} +``` + +### Operational Logs (for debugging) + +| Log event | Meaning | +|-----------|---------| +| `suggestions_task_queued` | Background generation scheduled after moderation | +| `chat_stream_complete` | Chat stream finished; history persisted | +| `suggestions_shadow_evidence` | Retrieval evidence extracted for current turn | +| `suggestions_task_started` | Background task began (includes `queue_delay_ms`) | +| `suggestions_input_mode` | `mode=hybrid` or `conversation_only`, plus `retrieval_reason` | +| `suggestions_cache_written` | Suggestions saved and ready to serve | diff --git a/example.env b/example.env index ad46623..47414ff 100644 --- a/example.env +++ b/example.env @@ -151,6 +151,11 @@ REDIS_DB=0 # ============================================ # Optional: Server Configuration # ============================================ +# Suggestions rollout +# false -> conversation-only suggestions (baseline) +# true -> enable hybrid suggestions (conversation + retrieved evidence, when retrieval quality passes) +# SUGGESTIONS_HYBRID_ENABLED=false + # Server host and port (defaults shown) # HOST=0.0.0.0 # PORT=8000