| """ |
| ByteAstra — Core Tutoring Engine. |
| |
| This is the domain-agnostic orchestrator that powers every tutoring interaction. |
| It takes a `domain` parameter and coordinates: |
| 1. Loading domain configuration (prompts, thresholds, retrieval settings) |
| 2. Condensing follow-up queries that contain pronouns into standalone queries |
| 3. Retrieving relevant chunks from ChromaDB |
| 4. TWO-TIER grounding check: |
| Tier 1 (Pre-emptive): If no chunks clear the relevance threshold, skip the |
| LLM entirely and serve the canned refusal directly. |
| Tier 2 (Post-hoc): If the LLM produces OUT_OF_SYLLABUS despite chunks |
| clearing the threshold, the stream buffer catches it |
| before any text reaches the client. |
| 5. Assembling the prompt (system + context + history + query) |
| 6. Streaming the LLM completion with human-readable citation expansion |
| |
| IMPORTANT: This file must contain ZERO domain-specific logic. |
| If you find yourself writing `if domain == "ayurveda":`, that belongs in |
| the domain's YAML config file, not here. |
| """ |
| from __future__ import annotations |
| import logging |
| import re |
| import json |
| from dataclasses import dataclass |
| from functools import lru_cache |
| from pathlib import Path |
| from typing import AsyncGenerator |
|
|
| import yaml |
|
|
| from app.services import rag, llm |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| _DOMAINS_DIR = Path(__file__).parent.parent / "domains" |
|
|
| OUT_OF_SYLLABUS_SENTINEL = "OUT_OF_SYLLABUS" |
|
|
| |
| _FOLLOW_UP_PRONOUNS = frozenset([ |
| "it", "its", "they", "them", "their", "theirs", |
| "this", "that", "these", "those", |
| "he", "she", "his", "her", |
| "what about", "and what", "how about", |
| ]) |
|
|
|
|
| @dataclass |
| class TutoringResult: |
| """Final assembled result from the engine (non-streaming path).""" |
| content: str |
| citations: list[rag.RetrievedChunk] |
| is_grounded: bool |
|
|
|
|
| def _levenshtein_distance(s1: str, s2: str) -> int: |
| """Calculate the Levenshtein distance between two strings.""" |
| if len(s1) < len(s2): |
| return _levenshtein_distance(s2, s1) |
| if len(s2) == 0: |
| return len(s1) |
| |
| previous_row = range(len(s2) + 1) |
| for i, c1 in enumerate(s1): |
| current_row = [i + 1] |
| for j, c2 in enumerate(s2): |
| insertions = previous_row[j + 1] + 1 |
| deletions = current_row[j] + 1 |
| substitutions = previous_row[j] + (c1 != c2) |
| current_row.append(min(insertions, deletions, substitutions)) |
| previous_row = current_row |
| |
| return previous_row[-1] |
|
|
|
|
| def _clean_stem_word(w: str) -> str: |
| """Normalize and stem a word to handle plurals simply.""" |
| w = w.lower().strip() |
| if w.endswith("ies") and len(w) > 4: |
| w = w[:-3] + "y" |
| elif w.endswith("es") and len(w) > 3: |
| w = w[:-2] |
| elif w.endswith("s") and len(w) > 3: |
| w = w[:-1] |
| return w |
|
|
|
|
| def _sanskrit_phonetic_hash(w: str) -> str: |
| """ |
| Generate a simple phonetic key for a Sanskrit transliterated word. |
| Aligns common spelling differences (e.g. sh/s, v/b, cch/ch, ri/r). |
| """ |
| w = w.lower().strip() |
| w = w.replace("sh", "s") |
| w = w.replace("s'", "s") |
| w = w.replace("ś", "s") |
| w = w.replace("ṣ", "s") |
| w = w.replace("v", "b") |
| w = w.replace("cch", "ch") |
| w = w.replace("chh", "ch") |
| w = w.replace("ri", "r") |
| w = w.replace("ṛ", "r") |
| w = w.replace("dh", "d") |
| w = w.replace("th", "t") |
| w = w.replace("ph", "p") |
| w = w.replace("kh", "k") |
| w = w.replace("gh", "g") |
| w = w.replace("bh", "b") |
| w = w.replace("jh", "j") |
| w = w.replace("aa", "a") |
| w = w.replace("ā", "a") |
| w = w.replace("ee", "i") |
| w = w.replace("ī", "i") |
| w = w.replace("oo", "u") |
| w = w.replace("ū", "u") |
| return w |
|
|
|
|
| |
| _SYNONYMS_FILE = _DOMAINS_DIR / "ayurveda_synonyms.json" |
| _SYNONYMS = {} |
| try: |
| if _SYNONYMS_FILE.exists(): |
| with open(_SYNONYMS_FILE, "r", encoding="utf-8") as f: |
| _SYNONYMS = json.load(f) |
| logger.info("Loaded %d synonym mappings.", len(_SYNONYMS)) |
| except Exception as e: |
| logger.error("Failed to load synonyms file: %s", e) |
|
|
|
|
| @lru_cache(maxsize=16) |
| def _load_domain_config(domain: str) -> dict: |
| """ |
| Load and cache a domain's YAML config. |
| Raises FileNotFoundError if the domain config doesn't exist. |
| """ |
| config_path = _DOMAINS_DIR / f"{domain}.yaml" |
| if not config_path.exists(): |
| raise FileNotFoundError( |
| f"No domain config found for '{domain}'. " |
| f"Expected file at: {config_path}" |
| ) |
| with open(config_path, "r", encoding="utf-8") as f: |
| config = yaml.safe_load(f) |
| logger.info("Loaded domain config: %s", domain) |
| return config |
|
|
|
|
| def _needs_condensation(query: str) -> bool: |
| """ |
| Detect if a query is a follow-up that requires pronoun resolution. |
| Returns True if the query contains any follow-up pronouns at the |
| start or as isolated words. |
| """ |
| q_lower = query.lower().strip() |
| tokens = set(q_lower.split()) |
| |
| if tokens & _FOLLOW_UP_PRONOUNS: |
| return True |
| |
| for phrase in ("what about", "and what", "how about"): |
| if q_lower.startswith(phrase): |
| return True |
| return False |
|
|
|
|
| async def condense_query(query: str, history: list[dict], llm_cfg: dict) -> str: |
| """ |
| Reformulate a pronoun-containing follow-up query into a standalone query |
| by asking the LLM to resolve contextual references using the recent history. |
| |
| Returns the condensed query string. On failure (LLM offline / empty output), |
| falls back to the original query unchanged. |
| |
| The intermediate condensed string is logged at INFO level so callers and |
| tests can assert on it without modifying this function's signature. |
| """ |
| if not _needs_condensation(query): |
| return query |
|
|
| |
| recent = history[-4:] if len(history) >= 4 else history |
| condensation_prompt = [ |
| { |
| "role": "system", |
| "content": ( |
| "You are a query rewriter. Your ONLY job is to rewrite the follow-up question " |
| "to resolve pronouns (like 'it', 'that', 'this', 'they') using the conversation history.\n\n" |
| "CRITICAL RULES:\n" |
| "1. Preserve the exact question and all new topics introduced in the follow-up.\n" |
| "2. Output ONLY the rewritten question as a single sentence. Do not add any preamble, explanation, or notes.\n\n" |
| "EXAMPLE:\n" |
| "History:\n" |
| "User: What are the qualities of Pitta dosha?\n" |
| "Follow-up: How does that relate to Ama (toxins)?\n" |
| "Output: How does Pitta dosha relate to Ama (toxins)?" |
| ), |
| } |
| ] |
| condensation_prompt.extend(recent) |
| condensation_prompt.append({"role": "user", "content": f"Follow-up question to rewrite: {query}"}) |
|
|
| condensed_tokens: list[str] = [] |
| try: |
| async for token in llm.stream_completion( |
| condensation_prompt, |
| model=llm_cfg.get("model_name"), |
| temperature=0.0, |
| max_tokens=128, |
| ): |
| condensed_tokens.append(token) |
| except Exception as exc: |
| logger.warning("condense_query failed, using original query: %s", exc) |
| return query |
|
|
| condensed = "".join(condensed_tokens).strip() |
| if not condensed: |
| logger.warning("condense_query returned empty string, using original query.") |
| return query |
|
|
| logger.info("condense_query: '%s' → '%s'", query, condensed) |
| return condensed |
|
|
|
|
| def make_citations_readable(text: str, domain_config: dict) -> str: |
| """ |
| Expand abbreviated citation codes in `text` to human-readable form using |
| mappings loaded from the domain config's `citation_mappings` section. |
| |
| Two levels of expansion are applied: |
| |
| 1. Composite codes (e.g. CS.Su.8 → "Charaka Samhita, Sutrasthana, Chapter 8") |
| Matched via a dynamically compiled regex from the YAML keys. |
| |
| 2. Bare book+chapter codes (e.g. CS.19 → "Charaka Samhita, Chapter 19") |
| Applied as a fallback after composite expansion, so it never clobbers a |
| composite match. |
| |
| Mapping keys starting with "_" (internal config keys like _book_names) are |
| skipped when building the citation regex. |
| """ |
| citation_mappings: dict = domain_config.get("citation_mappings", {}) |
| if not citation_mappings: |
| return text |
|
|
| |
| |
| sorted_keys = sorted( |
| (k for k in citation_mappings if not k.startswith("_")), |
| key=len, |
| reverse=True, |
| ) |
| for key in sorted_keys: |
| full_name = citation_mappings[key] |
| |
| pattern = re.compile( |
| r"\b" + re.escape(key) + r"(?:\.(\d+))?\b" |
| ) |
|
|
| def _replace_composite(m: re.Match, _full_name: str = full_name) -> str: |
| chapter_num = m.group(1) |
| if chapter_num: |
| return f"{_full_name}, Chapter {chapter_num}" |
| return _full_name |
|
|
| text = pattern.sub(_replace_composite, text) |
|
|
| |
| |
| book_names: dict = citation_mappings.get("_book_names", {}) |
| if book_names: |
| bare_abbrevs = "|".join(re.escape(abbr) for abbr in book_names.keys()) |
| bare_pattern = re.compile(r"\b(" + bare_abbrevs + r")\.(\d+)\b") |
|
|
| def _replace_bare(m: re.Match) -> str: |
| abbr = m.group(1) |
| chapter_num = m.group(2) |
| book = book_names.get(abbr, abbr) |
| return f"{book}, Chapter {chapter_num}" |
|
|
| text = bare_pattern.sub(_replace_bare, text) |
|
|
| return text |
|
|
|
|
| def _build_prompt( |
| domain_config: dict, |
| query: str, |
| chunks: list[rag.RetrievedChunk], |
| history: list[dict], |
| ) -> list[dict]: |
| """ |
| Assemble the full message list for the LLM. |
| |
| Format: |
| [system_message, ...filtered_history, context_injection + user_message] |
| |
| NOTE on history deduplication: |
| main.py persists the user's current message to MongoDB *before* calling |
| get_recent_history(). This means the history list already contains the |
| current user turn. We filter it out here (by removing any trailing |
| user-role message that matches the query exactly) to avoid sending the |
| query twice in the prompt. |
| """ |
| prompts = domain_config.get("prompts", {}), |
| |
| if isinstance(prompts, tuple): |
| prompts = prompts[0] |
| system_prompt = prompts.get("system", "You are a helpful tutoring assistant.") |
| context_header = prompts.get("context_header", "--- CONTEXT ---") |
|
|
| |
| filtered_history = list(history) |
| if ( |
| filtered_history |
| and filtered_history[-1].get("role") == "user" |
| and filtered_history[-1].get("content", "").strip() == query.strip() |
| ): |
| filtered_history = filtered_history[:-1] |
|
|
| |
| context_parts = [context_header] |
| for i, chunk in enumerate(chunks, 1): |
| source_info = f"Source: {chunk.source}" |
| if chunk.chapter: |
| source_info += f", {chunk.chapter}" |
| if chunk.section: |
| source_info += f", {chunk.section}" |
| context_parts.append(f"\n{chunk.content}\n({source_info})") |
|
|
| context_block = "\n".join(context_parts) |
| |
| synthesis_instruction = ( |
| "\n\n[INSTRUCTION] The context above contains raw textbook extracts with noise (chapter titles, author names, navigation text). " |
| "IGNORE all chapter/section titles, author/publication lines, and '[back to top]' links. " |
| "EXTRACT and EXPLAIN the actual Ayurvedic content: definitions, qualities (Gunas), sub-types, functions, and clinical significance. " |
| "Write a detailed, comprehensive BAMS exam answer (aiming for 600-800 words) starting DIRECTLY with the topic (not a book title). " |
| "List ALL items in any classification exhaustively (e.g. all 5 Vata sub-types, all 7 Dhatus). " |
| "Explain each concept thoroughly using BAMS curriculum knowledge. " |
| "Use Sanskrit terms with English meanings in brackets. " |
| "End with exactly ONE 'Sources:' section at the very end." |
| ) |
|
|
|
|
|
|
|
|
|
|
|
|
| |
| user_message_with_context = ( |
| f"{context_block}\n\n--- STUDENT QUESTION ---\n{query}{synthesis_instruction}" |
| ) |
|
|
| messages = [{"role": "system", "content": system_prompt}] |
| messages.extend(filtered_history) |
| messages.append({"role": "user", "content": user_message_with_context}) |
| return messages |
|
|
|
|
| def is_conversational_query(query: str) -> bool: |
| """Check if the user query is conversational (greeting, thanks, identity, help).""" |
| q = query.lower().strip().strip("?!.,🌿") |
|
|
| greetings = {"hello", "hi", "hey", "hola", "greetings", "good morning", "good afternoon", "good evening", "namaste", "pranam"} |
| identities = { |
| "who are you", "what is your name", "what are you", "tell me about yourself", |
| "tell me about you", "what can you do", "how can you help", "how do you help" |
| } |
| thanks = {"thank you", "thanks", "thank you so much", "dhanyavad", "dhanyawad"} |
| help_queries = {"help", "how to use this", "how to use", "how does this work"} |
|
|
| |
| if any(re.search(r"\b" + re.escape(g) + r"\b", q) for g in greetings) or re.search(r"\btutor\b", q): |
| return True |
| if any(re.search(r"\b" + re.escape(i) + r"\b", q) for i in identities) or re.search(r"\bwhat can you do\b", q) or re.search(r"\bhow can you help\b", q): |
| return True |
| if any(re.search(r"\b" + re.escape(t) + r"\b", q) for t in thanks): |
| return True |
| if any(re.search(r"\b" + re.escape(h) + r"\b", q) for h in help_queries): |
| return True |
|
|
| |
| words = q.split() |
| if len(words) <= 3 and any(w in greetings or w in help_queries or w in {"ok", "okay", "yes", "no", "sure"} for w in words): |
| return True |
|
|
| return False |
|
|
|
|
| def get_conversational_response_fallback(query: str) -> str: |
| """Return a high-quality static response for conversational queries if the LLM server is offline.""" |
| q = query.lower().strip().strip("?!.,🌿") |
| greetings = {"hello", "hi", "hey", "hola", "greetings", "good morning", "good afternoon", "good evening", "namaste", "pranam"} |
| identities = { |
| "who are you", "what is your name", "what are you", "tell me about yourself", |
| "tell me about you", "what can you do", "how can you help", "how do you help" |
| } |
| thanks = {"thank you", "thanks", "thank you so much", "dhanyavad", "dhanyawad"} |
|
|
| if any(g in q for g in greetings) or "tutor" in q: |
| return "Hello! I am your ByteAstra Ayurvedic tutoring assistant. How can I help you study the BAMS syllabus today?" |
| if any(i in q for i in identities) or "what can you do" in q or "help" in q: |
| return "I am ByteAstra, your AI-powered BAMS tutor. I can answer questions about the principles of Ayurveda based strictly on classical texts such as the Charaka Samhita and Sushruta Samhita, providing verified citations." |
| if any(t in q for t in thanks): |
| return "You're very welcome! Let me know if you have any other questions from the syllabus." |
|
|
| return "Hello! I am ByteAstra, your Ayurvedic tutoring assistant. Ask me any question from the BAMS syllabus to get started." |
|
|
|
|
| async def stream_answer( |
| domain: str, |
| query: str, |
| history: list[dict], |
| ) -> AsyncGenerator[dict, None]: |
| """ |
| Main streaming entry point for the engine. |
| |
| Yields dicts with shape: |
| {"type": "status", "engine_mode": "hybrid"|"fallback"} |
| {"type": "citations", "citations": [...], "is_grounded": True/False} |
| {"type": "delta", "content": "..."} (repeated) |
| {"type": "done"} |
| |
| On error: |
| {"type": "error", "error": "...message"} |
| |
| Grounding architecture — TWO TIERS: |
| ┌─────────────────────────────────────────────────────────────────────┐ |
| │ Tier 1 (Pre-emptive): Retrieval threshold check BEFORE the LLM. │ |
| │ • If no chunk exceeds relevance_threshold → serve canned refusal │ |
| │ directly, without calling the LLM at all. │ |
| │ • Eliminates state-bleed: LLM never inspects old history turns. │ |
| ├─────────────────────────────────────────────────────────────────────┤ |
| │ Tier 2 (Stream buffer): Safety net for chunks that pass threshold │ |
| │ but the model still judges insufficient for the specific query. │ |
| │ • Buffer stream until first newline OR 40 characters. │ |
| │ • Strip whitespace; if buffer contains OUT_OF_SYLLABUS sentinel: │ |
| │ - Cancel stream, emit is_grounded=False, empty citations, │ |
| │ stream the clean canned refusal message. │ |
| │ • Otherwise flush buffer and continue normal streaming. │ |
| └─────────────────────────────────────────────────────────────────────┘ |
| """ |
| try: |
| config = _load_domain_config(domain) |
| except FileNotFoundError as e: |
| yield {"type": "error", "error": str(e)} |
| return |
|
|
| try: |
| retrieval_cfg = config.get("retrieval", {}) |
| collection_name: str = retrieval_cfg.get("collection_name", f"domain_{domain}") |
| top_k: int = retrieval_cfg.get("top_k", 3) |
| threshold: float = retrieval_cfg.get("relevance_threshold", 0.45) |
| history_turns: int = retrieval_cfg.get("history_turns", 3) |
| llm_cfg = config.get("llm", {}) |
|
|
| out_of_syllabus_msg: str = config.get("prompts", {}).get( |
| "out_of_syllabus", |
| "I couldn't find this in the current knowledge base.", |
| ) |
|
|
| is_online = await llm.check_connection() |
|
|
| |
| |
| |
| def _is_adversarial(q: str) -> bool: |
| q_low = q.lower() |
| adversarial_indicators = [ |
| "guess anyway", "best guess", "speculate", "speculation", |
| "not in your textbook", "not in the textbook", "outside the textbook", |
| "bypass constraints" |
| ] |
| return any(ind in q_low for ind in adversarial_indicators) |
|
|
| if _is_adversarial(query): |
| logger.info("Adversarial speculation detected in query, triggering immediate out-of-syllabus refusal.") |
| engine_mode = "hybrid" if is_online else "fallback" |
| yield {"type": "status", "engine_mode": engine_mode} |
| yield {"type": "citations", "citations": [], "is_grounded": False} |
| for word in out_of_syllabus_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| yield {"type": "done"} |
| return |
|
|
| |
| |
| chunks = rag.retrieve(query, collection_name, top_k=top_k, relevance_threshold=threshold) |
|
|
| |
| if chunks: |
| |
| q_words = re.findall(r"\b[a-zA-Z]{3,}\b", query.lower()) |
| generic_words = { |
| |
| "what", "how", "does", "that", "relate", "explain", "describe", "discuss", "ayurveda", "ayurvedic", |
| "according", "textbook", "samhita", "charaka", "sushruta", "chapter", "section", "treatment", |
| "concept", "theory", "definition", "disease", "biological", "meaning", "define", "list", "give", |
| "write", "detail", "with", "from", "when", "where", "which", "into", "your", "tell", "about", |
| "study", "student", "syllabus", "text", "body", "human", "are", "the", "and", "for", "its", "why", |
| "who", "whom", "whose", "this", "these", "those", "can", "could", "would", "should", "will", "shall", |
| "been", "have", "has", "had", "was", "were", "srotas", "dosha", "dhatu", "mala", "agni", "vata", |
| "pitta", "kapha", "tridosha", "difference", "between", "concept", "relation", "relationship", |
| "function", "functions", "location", "locations", "subtype", "subtypes", "role", "roles", "effect", |
| "effects", "cause", "causes", "symptom", "symptoms", "significance", "primary", "secondary", |
| "major", "minor", "possible", "balance", "imbalance", "cured", "cure", "treat", "treatment", |
| "therapy", "therapies", "therapeusis", "normal", "abnormal", "qualities", "quality", "sutra", |
| "sutrasthana", "nidana", "chikitsa", "kalpa", "siddhi", |
| |
| "interact", "interacts", "interacting", "interaction", "interactions", "affect", "affects", |
| "affecting", "affected", "occur", "occurs", "occurring", "occurred", "happen", "happens", |
| "happening", "happened", "manifest", "manifests", "manifesting", "manifested", "manifestation", |
| "manifestations", "produce", "produces", "producing", "produced", "result", "results", |
| "resulting", "resulted", "lead", "leads", "leading", "cause", "causes", "causing", "caused", |
| "connect", "connects", "connecting", "connected", "connection", "connections", "compare", |
| "compares", "comparing", "compared", "contrast", "contrasts", "contrasting", "contrasted", |
| "differentiate", "differentiates", "differentiating", "distinguish", "distinguishes", |
| "distinguishing", "identify", "identifies", "identifying", "show", "shows", "showing", |
| "ask", "asks", "asking", "answer", "answers", "answering", "say", "says", "said", "saying", |
| |
| "concepts", "theories", "system", "systems", "process", "processes", "mechanism", "mechanisms", |
| "action", "actions", "type", "types", "classification", "classifications", "class", "classes", |
| "category", "categories", "group", "groups", "term", "terms", "word", "words", "name", "names", |
| "definition", "definitions", "meaning", "meanings", "explanation", "explanations", "description", |
| "descriptions", "difference", "differences", "relation", "relations", "relationship", "relationships", |
| "association", "associations", "similar", "similarity", "similarities", "common", "various", |
| "several", "many", "person", "patient", "individual", "people", "life", "health", "diseases", |
| "disorder", "disorders", "illness", "illnesses", "condition", "conditions", "state", "states" |
| } |
| significant_query_words = [w for w in q_words if w not in generic_words] |
| |
| |
| combined_chunks_text = " ".join(c.content.lower() for c in chunks) |
| |
| context_words_cleaned = {_clean_stem_word(w) for w in re.findall(r"\b[a-zA-Z]{3,}\b", combined_chunks_text)} |
| |
| |
| missing_words = [] |
| matched_count = 0 |
| for word in significant_query_words: |
| stemmed_q_word = _clean_stem_word(word) |
| |
| |
| equivalent_words = {stemmed_q_word} |
| for w_lookup in (word.lower(), stemmed_q_word): |
| if w_lookup in _SYNONYMS: |
| for syn in _SYNONYMS[w_lookup]: |
| equivalent_words.add(_clean_stem_word(syn)) |
| |
| matched = False |
| for eq_word in equivalent_words: |
| eq_phonetic = _sanskrit_phonetic_hash(eq_word) |
| |
| for cw in context_words_cleaned: |
| |
| if eq_word in cw or cw in eq_word: |
| matched = True |
| break |
| |
| |
| limit = 1 if len(eq_word) <= 5 else 2 |
| if _levenshtein_distance(eq_word, cw) <= limit: |
| matched = True |
| break |
| |
| |
| if eq_phonetic == _sanskrit_phonetic_hash(cw): |
| matched = True |
| break |
| if matched: |
| break |
| |
| if matched: |
| matched_count += 1 |
| else: |
| missing_words.append(word) |
| |
| |
| min_required = max(1, len(significant_query_words) // 2) if significant_query_words else 0 |
| if significant_query_words and matched_count < min_required: |
| logger.info( |
| "Lexical validation failed: matched only %d/%d significant terms (missing: %s). Setting chunks to empty.", |
| matched_count, len(significant_query_words), missing_words |
| ) |
| chunks = [] |
|
|
| if not chunks: |
| |
| if is_conversational_query(query): |
| engine_mode = "hybrid" if is_online else "fallback" |
| yield {"type": "status", "engine_mode": engine_mode} |
| yield {"type": "citations", "citations": [], "is_grounded": True} |
|
|
| import asyncio |
| if is_online: |
| conv_prompt = [ |
| { |
| "role": "system", |
| "content": ( |
| "You are ByteAstra, a helpful AI tutoring assistant for BAMS (Bachelor of " |
| "Ayurvedic Medicine and Surgery) students. You are friendly, conversational, " |
| "and happy to answer greetings, introduce yourself, explain your tutoring capabilities, " |
| "and guide students on how to use this platform. Keep your response brief and polite." |
| ) |
| } |
| ] |
| conv_prompt.extend(history[-6:]) |
| conv_prompt.append({"role": "user", "content": query}) |
|
|
| async for token in llm.stream_completion( |
| conv_prompt, |
| model=llm_cfg.get("model_name"), |
| temperature=0.7, |
| max_tokens=128, |
| ): |
| yield {"type": "delta", "content": token} |
| else: |
| fallback_msg = get_conversational_response_fallback(query) |
| for word in fallback_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| await asyncio.sleep(0.01) |
|
|
| yield {"type": "done"} |
| return |
|
|
| |
| logger.info( |
| "Tier 1 grounding gate triggered: no chunks above threshold for query '%s'", |
| query[:80], |
| ) |
| engine_mode = "hybrid" if is_online else "fallback" |
| yield {"type": "status", "engine_mode": engine_mode} |
| yield {"type": "citations", "citations": [], "is_grounded": False} |
| for word in out_of_syllabus_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| yield {"type": "done"} |
| return |
|
|
| |
| engine_mode = "hybrid" if is_online else "fallback" |
| yield {"type": "status", "engine_mode": engine_mode} |
|
|
| if not is_online: |
| |
| yield {"type": "citations", "citations": [], "is_grounded": False} |
| for word in out_of_syllabus_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| yield {"type": "done"} |
| return |
|
|
| |
| trimmed_history = history[-(history_turns * 2):] |
| messages = _build_prompt(config, query, chunks, trimmed_history) |
|
|
| |
| |
| |
| buffer: list[str] = [] |
| buffer_done = False |
| is_grounded_confirmed = False |
| citation_emitted = False |
|
|
| async def _emit_citations(grounded: bool) -> None: |
| """Helper to emit the citations event exactly once.""" |
| nonlocal citation_emitted |
| if citation_emitted: |
| return |
| citation_emitted = True |
| if grounded: |
| yield_data = { |
| "type": "citations", |
| "citations": [c.to_citation().model_dump() for c in chunks], |
| "is_grounded": True, |
| } |
| else: |
| yield_data = {"type": "citations", "citations": [], "is_grounded": False} |
| |
| return yield_data |
|
|
| async def _stream_with_buffer() -> AsyncGenerator[dict, None]: |
| """ |
| Inner generator that wraps the LLM stream with the Tier 2 buffer logic. |
| Emits citations event AFTER we've confirmed the model is not refusing. |
| """ |
| nonlocal buffer_done, is_grounded_confirmed, citation_emitted |
|
|
| raw_stream = llm.stream_completion( |
| messages, |
| model=llm_cfg.get("model_name"), |
| temperature=llm_cfg.get("temperature"), |
| max_tokens=llm_cfg.get("max_tokens"), |
| ) |
|
|
| async for token in raw_stream: |
| if not buffer_done: |
| buffer.append(token) |
| combined = "".join(buffer) |
|
|
| |
| hit_newline = "\n" in combined |
| hit_length = len(combined.replace(" ", "").replace("\n", "")) >= 30 |
|
|
| if hit_newline or hit_length: |
| buffer_done = True |
| normalized = combined.strip().replace("\n", "").upper() |
|
|
| if OUT_OF_SYLLABUS_SENTINEL in normalized: |
| |
| logger.info( |
| "Tier 2 stream buffer triggered: model emitted OUT_OF_SYLLABUS " |
| "for query '%s'", query[:80] |
| ) |
| is_grounded_confirmed = False |
| citation_emitted = True |
| yield {"type": "citations", "citations": [], "is_grounded": False} |
| for word in out_of_syllabus_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| return |
| else: |
| |
| is_grounded_confirmed = True |
| citation_emitted = True |
| yield { |
| "type": "citations", |
| "citations": [c.to_citation().model_dump() for c in chunks], |
| "is_grounded": True, |
| } |
| |
| expanded = make_citations_readable(combined, config) |
| yield {"type": "delta", "content": expanded} |
| |
| else: |
| |
| expanded = make_citations_readable(token, config) |
| yield {"type": "delta", "content": expanded} |
|
|
| |
| if not buffer_done and buffer: |
| buffer_done = True |
| combined = "".join(buffer) |
| normalized = combined.strip().replace("\n", "").upper() |
|
|
| if OUT_OF_SYLLABUS_SENTINEL in normalized: |
| is_grounded_confirmed = False |
| citation_emitted = True |
| yield {"type": "citations", "citations": [], "is_grounded": False} |
| for word in out_of_syllabus_msg.split(" "): |
| yield {"type": "delta", "content": word + " "} |
| else: |
| is_grounded_confirmed = True |
| if not citation_emitted: |
| citation_emitted = True |
| yield { |
| "type": "citations", |
| "citations": [c.to_citation().model_dump() for c in chunks], |
| "is_grounded": True, |
| } |
| expanded = make_citations_readable(combined, config) |
| yield {"type": "delta", "content": expanded} |
| async for event in _stream_with_buffer(): |
| yield event |
|
|
| yield {"type": "done"} |
| except Exception as e: |
| logger.exception("Error in stream_answer processing:") |
| yield {"type": "error", "error": str(e)} |
|
|
|
|
|
|
|
|