Instructions to use aelgendy/QModel with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use aelgendy/QModel with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf aelgendy/QModel:Q4_K_M # Run inference directly in the terminal: llama cli -hf aelgendy/QModel:Q4_K_M
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf aelgendy/QModel:Q4_K_M # Run inference directly in the terminal: llama cli -hf aelgendy/QModel:Q4_K_M
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf aelgendy/QModel:Q4_K_M # Run inference directly in the terminal: ./llama-cli -hf aelgendy/QModel:Q4_K_M
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf aelgendy/QModel:Q4_K_M # Run inference directly in the terminal: ./build/bin/llama-cli -hf aelgendy/QModel:Q4_K_M
Use Docker
docker model run hf.co/aelgendy/QModel:Q4_K_M
- LM Studio
- Jan
- Ollama
How to use aelgendy/QModel with Ollama:
ollama run hf.co/aelgendy/QModel:Q4_K_M
- Unsloth Studio
How to use aelgendy/QModel with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for aelgendy/QModel to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for aelgendy/QModel to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for aelgendy/QModel to start chatting
- Pi
How to use aelgendy/QModel with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q4_K_M
Configure the model in Pi
# Install Pi: npm install -g @mariozechner/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "aelgendy/QModel:Q4_K_M" } ] } } }Run Pi
# Start Pi in your project directory: pi
- OpenClaw new
How to use aelgendy/QModel with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q4_K_M
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "aelgendy/QModel:Q4_K_M" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
- Docker Model Runner
How to use aelgendy/QModel with Docker Model Runner:
docker model run hf.co/aelgendy/QModel:Q4_K_M
- Lemonade
How to use aelgendy/QModel with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull aelgendy/QModel:Q4_K_M
Run and chat with the model
lemonade run user.QModel-Q4_K_M
List all available models
lemonade list
- Hermes Agent
How to use aelgendy/QModel with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf aelgendy/QModel:Q4_K_M
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default aelgendy/QModel:Q4_K_M
Run Hermes
hermes
- Atomic Chat
| """Application state, lifespan, and core RAG pipeline.""" | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import re | |
| import time | |
| from contextlib import asynccontextmanager | |
| from typing import AsyncIterator, Dict, Literal, Optional, Tuple | |
| import faiss | |
| from fastapi import FastAPI, HTTPException | |
| from sentence_transformers import SentenceTransformer | |
| from app.analysis import ( | |
| count_occurrences, | |
| detect_analysis_intent, | |
| detect_surah_info, | |
| lookup_surah_info, | |
| ) | |
| from app.arabic_nlp import detect_language, normalize_arabic | |
| from app.config import cfg | |
| from app.cache import pipeline_cache | |
| from app.llm import LLMProvider, get_llm_provider | |
| from app.prompts import build_messages, not_found_answer | |
| from app.search import ( | |
| BM25InvertedIndex, | |
| build_context, | |
| hybrid_search, | |
| lookup_hadith_references, | |
| lookup_quran_verses, | |
| merge_search_results, | |
| rewrite_query, | |
| text_search, | |
| ) | |
| logger = logging.getLogger("qmodel.state") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SURAH INFO PROGRAMMATIC FALLBACK | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _results_fallback(results: list, lang: str) -> str: | |
| """Generate a direct answer from search results when LLM returns empty. | |
| Presents ALL high-scoring results so the user sees every place | |
| a text is mentioned (e.g. same verse in multiple surahs). | |
| """ | |
| if not results: | |
| return not_found_answer(lang) | |
| # Include all results whose score is at least 50% of the top score, | |
| # or score >= 2.0 (strong match), whichever captures more. | |
| top_score = results[0].get("_score", 0.0) | |
| threshold = max(top_score * 0.5, 2.0) if top_score >= 2.0 else 0.0 | |
| relevant = [r for r in results if r.get("_score", 0.0) >= threshold] or results[:1] | |
| quran_hits = [r for r in relevant if r.get("type") == "quran"] | |
| hadith_hits = [r for r in relevant if r.get("type") == "hadith"] | |
| lines: list[str] = [] | |
| # --- Quran results --- | |
| if quran_hits: | |
| if len(quran_hits) == 1: | |
| r = quran_hits[0] | |
| surah_ar = r.get("surah_name_ar", "") | |
| surah_en = r.get("surah_name_en", "") | |
| verse_num = r.get("verse_number", "") | |
| if lang == "arabic": | |
| lines.append(f"ΩΨ°Ω Ψ§ΩΨ’ΩΨ© Ψ§ΩΩΨ±ΩΩ Ψ© Ω Ω Ψ³ΩΨ±Ψ© {surah_ar} ({surah_en})Ψ Ψ§ΩΨ’ΩΨ© {verse_num}.") | |
| else: | |
| lines.append(f"This noble verse is from Surah {surah_en} ({surah_ar}), verse {verse_num}.") | |
| else: | |
| if lang == "arabic": | |
| refs = [ | |
| f"Ψ³ΩΨ±Ψ© {r.get('surah_name_ar', '')} ({r.get('surah_name_en', '')})Ψ Ψ§ΩΨ’ΩΨ© {r.get('verse_number', '')}" | |
| for r in quran_hits | |
| ] | |
| lines.append(f"ΩΨ°Ψ§ Ψ§ΩΩΨ΅ Ψ§ΩΩΨ±ΩΩ Ψ°ΩΩΩΨ± ΩΩ {len(quran_hits)} Ω ΩΨ§ΨΆΨΉ:") | |
| for ref in refs: | |
| lines.append(f" β’ {ref}") | |
| else: | |
| refs = [ | |
| f"Surah {r.get('surah_name_en', '')} ({r.get('surah_name_ar', '')}), verse {r.get('verse_number', '')}" | |
| for r in quran_hits | |
| ] | |
| lines.append(f"This text appears in {len(quran_hits)} places:") | |
| for ref in refs: | |
| lines.append(f" β’ {ref}") | |
| for r in quran_hits: | |
| ar_text = r.get("arabic", "") | |
| en_text = r.get("english", "") | |
| surah_ar = r.get("surah_name_ar", "") | |
| surah_en = r.get("surah_name_en", "") | |
| surah_num = r.get("surah_number", "") | |
| verse_num = r.get("verse_number", "") | |
| lines.append("") | |
| lines.append("βββββββββββββββββββββββββββββββββββββββββββββββ") | |
| lines.append(f"β β {ar_text} β") | |
| lines.append(f"β π Translation: {en_text}") | |
| lines.append(f"β π Source: Ψ³ΩΨ±Ψ© {surah_ar} ({surah_en}) | Ψ±ΩΩ Ψ§ΩΨ³ΩΨ±Ψ©: {surah_num} | Ψ§ΩΨ’ΩΨ©: {verse_num}") | |
| lines.append("βββββββββββββββββββββββββββββββββββββββββββββββ") | |
| # --- Hadith results --- | |
| if hadith_hits: | |
| for r in hadith_hits: | |
| ar_text = r.get("arabic", "") | |
| en_text = r.get("english", "") | |
| source = r.get("source") or r.get("reference", "") | |
| grade = r.get("grade", "") | |
| grade_str = f" [{grade}]" if grade else "" | |
| if lang == "arabic": | |
| lines.append(f"\nΨ§ΩΨΨ―ΩΨ« Ψ§ΩΨ΄Ψ±ΩΩ{grade_str}:") | |
| else: | |
| lines.append(f"\nHadith{grade_str}:") | |
| lines.append("") | |
| lines.append("βββββββββββββββββββββββββββββββββββββββββββββββ") | |
| lines.append(f"β β {ar_text} β") | |
| lines.append(f"β π Translation: {en_text}") | |
| lines.append(f"β π Source: {source}") | |
| lines.append("βββββββββββββββββββββββββββββββββββββββββββββββ") | |
| if lang == "arabic": | |
| lines.append("\nΩΨ§ΩΩΩ Ψ£ΨΉΩΩ .") | |
| else: | |
| lines.append("\nAnd Allah knows best.") | |
| return "\n".join(lines) | |
| def _surah_info_fallback(info: dict, lang: str) -> str: | |
| """Generate a direct answer from surah metadata when LLM fails.""" | |
| name_ar = info.get("surah_name_ar", "") | |
| name_en = info.get("surah_name_en", "") | |
| number = info.get("surah_number", "") | |
| verses = info.get("total_verses", "") | |
| rev = info.get("revelation_type", "") | |
| translit = info.get("surah_name_transliteration", "") | |
| rev_ar = "Ω ΩΩΨ©" if rev == "meccan" else "Ω Ψ―ΩΩΨ©" if rev == "medinan" else rev | |
| rev_en = rev.capitalize() if rev else "" | |
| if lang == "arabic": | |
| return ( | |
| f"Ψ³ΩΨ±Ψ© {name_ar} ({translit}) ΩΩ Ψ§ΩΨ³ΩΨ±Ψ© Ψ±ΩΩ {number} ΩΩ Ψ§ΩΩΨ±Ψ’Ω Ψ§ΩΩΨ±ΩΩ .\n" | |
| f"ΨΉΨ―Ψ― Ψ’ΩΨ§ΨͺΩΨ§: {verses} Ψ’ΩΨ©.\n" | |
| f"ΩΩΨΉΩΨ§: {rev_ar}.\n" | |
| f"ΩΨ§ΩΩΩ Ψ£ΨΉΩΩ ." | |
| ) | |
| return ( | |
| f"Surah {name_en} ({translit} / {name_ar}) is surah number {number} " | |
| f"in the Holy Quran.\n" | |
| f"Total verses: {verses}.\n" | |
| f"Revelation type: {rev_en}.\n" | |
| f"And Allah knows best." | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # POST-GENERATION HALLUCINATION CHECK | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _QUOTE_RE = re.compile(r"β\s*(.+?)\s*β", re.DOTALL) | |
| _SURAH_REF_AR = re.compile( | |
| r"(?:Ψ³ΩΨ±Ψ©|Ψ³ΩΨ±Ω)\s+([\u0600-\u06FF\u0750-\u077F\s]+?)[\s,Ψ]*" | |
| r"(?:Ψ§ΩΨ’ΩΨ©|Ψ’ΩΨ©|Ψ§ΩΨ§ΩΩ|Ψ§ΩΩ)?\s*(\d+)", | |
| ) | |
| _SURAH_REF_EN = re.compile( | |
| r"(?:surah|sura)\s+([A-Za-z\-' ]+?)[\s,]*" | |
| r"(?:ayah|verse|ayat)?\s*(\d+)", | |
| re.I, | |
| ) | |
| _SURAH_NUM_REF = re.compile(r"\b(\d{1,3})\s*:\s*(\d{1,3})\b") | |
| def _build_valid_refs(results: list) -> set: | |
| """Build a set of valid (surah_number, verse_number) tuples from results.""" | |
| refs = set() | |
| for r in results: | |
| if r.get("type") == "quran": | |
| sn = r.get("surah_number") | |
| vn = r.get("verse_number") | |
| if sn and vn: | |
| refs.add((int(sn), int(vn))) | |
| return refs | |
| def _build_surah_name_map(results: list) -> dict: | |
| """Build a map from normalized surah names to surah numbers.""" | |
| name_map = {} | |
| for r in results: | |
| if r.get("type") == "quran": | |
| sn = r.get("surah_number") | |
| if sn: | |
| sn = int(sn) | |
| for field in ("surah_name_ar", "surah_name_en", "surah_name_transliteration"): | |
| name = r.get(field, "").strip().lower() | |
| name = re.sub(r"^(Ψ§Ω|al[\-\s']*)", "", name).strip() | |
| if name: | |
| name_map[name] = sn | |
| return name_map | |
| def _verify_references(answer: str, results: list) -> str: | |
| """Check that surah/verse references in the answer match retrieved results. | |
| Replaces hallucinated references with corrected ones or warnings. | |
| """ | |
| valid_refs = _build_valid_refs(results) | |
| if not valid_refs: | |
| return answer # No quran results β nothing to verify | |
| name_map = _build_surah_name_map(results) | |
| # Check numeric references like "16:53" | |
| def _check_num_ref(m: re.Match) -> str: | |
| sn, vn = int(m.group(1)), int(m.group(2)) | |
| if (sn, vn) in valid_refs: | |
| return m.group(0) | |
| # Check if any valid ref exists β if so, the LLM hallucinated a different ref | |
| logger.warning("Hallucinated reference: %d:%d not in sources", sn, vn) | |
| # Find closest valid reference to suggest | |
| if len(valid_refs) == 1: | |
| correct = next(iter(valid_refs)) | |
| return f"{correct[0]}:{correct[1]}" | |
| return m.group(0) # Multiple refs β can't auto-correct | |
| answer = _SURAH_NUM_REF.sub(_check_num_ref, answer) | |
| # Check Arabic surah name references like "Ψ³ΩΨ±Ψ© Ψ₯Ψ¨Ψ±Ψ§ΩΩΩ Ψ Ψ§ΩΨ’ΩΨ© 7" | |
| def _check_ar_ref(m: re.Match) -> str: | |
| raw_name = m.group(1).strip() | |
| verse_num = int(m.group(2)) | |
| name_norm = re.sub(r"^(Ψ§Ω)", "", raw_name).strip().lower() | |
| matched_sn = name_map.get(name_norm) | |
| if matched_sn and (matched_sn, verse_num) in valid_refs: | |
| return m.group(0) # Valid reference | |
| # Check if the combined reference is wrong | |
| for (sn, vn) in valid_refs: | |
| # Find the correct surah name for this ref | |
| for r in results: | |
| if r.get("type") == "quran" and int(r.get("surah_number", 0)) == sn and int(r.get("verse_number", 0)) == vn: | |
| correct_name = r.get("surah_name_ar", "") | |
| logger.warning( | |
| "Hallucinated reference: Ψ³ΩΨ±Ψ© %s Ψ’ΩΨ© %d -> correcting to Ψ³ΩΨ±Ψ© %s Ψ’ΩΨ© %d", | |
| raw_name, verse_num, correct_name, vn, | |
| ) | |
| return f"Ψ³ΩΨ±Ψ© {correct_name}Ψ Ψ§ΩΨ’ΩΨ© {vn}" | |
| return m.group(0) | |
| answer = _SURAH_REF_AR.sub(_check_ar_ref, answer) | |
| # Check English surah name references | |
| def _check_en_ref(m: re.Match) -> str: | |
| raw_name = m.group(1).strip() | |
| verse_num = int(m.group(2)) | |
| name_norm = re.sub(r"^(al[\-\s']*)", "", raw_name, flags=re.I).strip().lower() | |
| matched_sn = name_map.get(name_norm) | |
| if matched_sn and (matched_sn, verse_num) in valid_refs: | |
| return m.group(0) | |
| for (sn, vn) in valid_refs: | |
| for r in results: | |
| if r.get("type") == "quran" and int(r.get("surah_number", 0)) == sn and int(r.get("verse_number", 0)) == vn: | |
| correct_name = r.get("surah_name_en", "") | |
| logger.warning( | |
| "Hallucinated reference: Surah %s verse %d -> correcting to Surah %s verse %d", | |
| raw_name, verse_num, correct_name, vn, | |
| ) | |
| return f"Surah {correct_name}, verse {vn}" | |
| return m.group(0) | |
| answer = _SURAH_REF_EN.sub(_check_en_ref, answer) | |
| return answer | |
| def _verify_citations(answer: str, results: list) -> str: | |
| """Check that quoted Arabic text in the answer actually appears in retrieved results. | |
| If a quoted block doesn't match any source, replace it with a warning. | |
| This prevents the model from fabricating hadith or verse text. | |
| """ | |
| source_texts_raw = [] | |
| for r in results: | |
| for field in ("arabic", "english", "text"): | |
| val = r.get(field, "") | |
| if val: | |
| source_texts_raw.append(re.sub(r"\s+", " ", val.strip())) | |
| # Pre-compute normalized versions for diacritics-insensitive comparison | |
| source_texts_norm = [normalize_arabic(s) for s in source_texts_raw] | |
| def _check_quote(m: re.Match) -> str: | |
| quoted_raw = re.sub(r"\s+", " ", m.group(1).strip()) | |
| quoted_norm = normalize_arabic(quoted_raw) | |
| if len(quoted_norm) < 10: | |
| return m.group(0) # too short to verify | |
| for src_raw, src_norm in zip(source_texts_raw, source_texts_norm): | |
| # 1. Exact substring match (raw β preserves diacritics) | |
| if quoted_raw in src_raw or src_raw in quoted_raw: | |
| return m.group(0) | |
| # 2. Normalized substring match (strips diacritics/punctuation) | |
| if quoted_norm in src_norm or src_norm in quoted_norm: | |
| return m.group(0) | |
| # 3. Word overlap on normalized text (β₯50% of quoted words found) | |
| q_words = set(quoted_norm.split()) | |
| s_words = set(src_norm.split()) | |
| if q_words and len(q_words & s_words) / len(q_words) >= 0.5: | |
| return m.group(0) | |
| # Quote not found in any source β flag it | |
| logger.warning("Hallucination detected: quoted text not in sources: %.80s...", quoted_norm) | |
| return "β β οΈ [ΨͺΩ ΨΨ°Ω ΩΨ΅ ΨΊΩΨ± Ω ΩΨ«Ω β Ψ§ΩΩΨ΅ ΨΊΩΨ± Ω ΩΨ¬ΩΨ― ΩΩ ΩΨ§ΨΉΨ―Ψ© Ψ§ΩΨ¨ΩΨ§ΩΨ§Ψͺ] β" | |
| return _QUOTE_RE.sub(_check_quote, answer) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HADITH GRADE INFERENCE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _verify_surah_info(answer: str, surah_info: dict) -> str: | |
| """Verify and correct surah metadata in the LLM answer. | |
| Replaces hallucinated surah names and verse counts with the correct | |
| values from the authoritative surah_info lookup. | |
| """ | |
| if not surah_info: | |
| return answer | |
| correct_name_ar = surah_info.get("surah_name_ar", "") | |
| correct_name_en = surah_info.get("surah_name_en", "") | |
| correct_verses = surah_info.get("total_verses") | |
| correct_number = surah_info.get("surah_number") | |
| correct_type = surah_info.get("revelation_type", "") | |
| correct_translit = surah_info.get("surah_name_transliteration", "") | |
| correct_ar_norm = normalize_arabic(correct_name_ar).lower() | |
| correct_ar_bare = re.sub(r"^Ψ§Ω", "", correct_ar_norm).strip() | |
| # Words that can follow "Ψ³ΩΨ±Ψ©" but aren't surah names | |
| _NOT_SURAH_NAMES = { | |
| "Ω ΩΩΨ©", "Ω ΩΩ", "Ω Ψ―ΩΩΨ©", "Ω Ψ―ΩΩ", "Ψ¨Ψ§ΩΩΨΊΨ©", "Ω Ω", "ΩΩ", "ΩΩ", | |
| "Ψ§ΩΨͺΩ", "Ψ§ΩΩΨ±ΩΩ Ψ©", "Ψ§ΩΩ Ψ¨Ψ§Ψ±ΩΨ©", "ΩΨ°Ω", "ΨͺΩΩ", | |
| } | |
| _NOT_SURAH_NAMES_NORM = {normalize_arabic(w).lower() for w in _NOT_SURAH_NAMES} | |
| # ββ Fix wrong surah names βββββββββββββββββββββββββββββββββββββββ | |
| def _fix_surah_name_ar(m: re.Match) -> str: | |
| found_name = m.group(1).strip() | |
| found_norm = normalize_arabic(found_name).lower() | |
| found_bare = re.sub(r"^Ψ§Ω", "", found_norm).strip() | |
| if found_name in _NOT_SURAH_NAMES or found_norm in _NOT_SURAH_NAMES_NORM: | |
| return m.group(0) | |
| if found_bare == correct_ar_bare or found_norm == correct_ar_norm: | |
| return m.group(0) | |
| if found_bare.startswith(correct_ar_bare) or found_norm.startswith(correct_ar_norm): | |
| return m.group(0) | |
| logger.warning( | |
| "Surah info hallucination: Ψ³ΩΨ±Ψ© %s -> correcting to Ψ³ΩΨ±Ψ© %s", | |
| found_name, correct_name_ar, | |
| ) | |
| return m.group(0).replace(found_name, correct_name_ar) | |
| answer = re.sub( | |
| r"(?:Ψ³ΩΨ±Ψ©|Ψ³ΩΨ±Ω)\s+([\u0621-\u06FF\u0750-\u077F]+(?:\s[\u0621-\u06FF\u0750-\u077F]+)?)" | |
| r"(?=[\s,ΨΨΨ\.\n?!]|$)", | |
| _fix_surah_name_ar, | |
| answer, | |
| ) | |
| if correct_name_en: | |
| def _fix_surah_name_en(m: re.Match) -> str: | |
| found = m.group(1).strip() | |
| if found.lower() == correct_name_en.lower(): | |
| return m.group(0) | |
| if correct_translit and found.lower() == correct_translit.lower(): | |
| return m.group(0) | |
| logger.warning( | |
| "Surah info hallucination: Surah %s -> correcting to Surah %s", | |
| found, correct_name_en, | |
| ) | |
| return m.group(0).replace(found, correct_name_en) | |
| answer = re.sub( | |
| r"(?:Surah|sura)\s+([A-Za-z'\-]+(?:[\s\-][A-Za-z'\-]+)*)", | |
| _fix_surah_name_en, | |
| answer, | |
| flags=re.I, | |
| ) | |
| if correct_number is not None: | |
| def _fix_surah_number(m: re.Match) -> str: | |
| num = int(m.group(2)) | |
| if num == correct_number: | |
| return m.group(0) | |
| logger.warning( | |
| "Surah info hallucination: surah number %d -> correcting to %d", | |
| num, correct_number, | |
| ) | |
| return m.group(1) + " " + str(correct_number) | |
| answer = re.sub( | |
| r"(Ψ±ΩΩ [ΩΨ§]*|ΨͺΨ±ΨͺΩΨ¨[ΩΨ§]*)\s+(\d+)", | |
| _fix_surah_number, | |
| answer, | |
| ) | |
| answer = re.sub( | |
| r"((?:surah\s+)?number)\s+(\d+)", | |
| _fix_surah_number, | |
| answer, | |
| flags=re.I, | |
| ) | |
| if correct_verses is not None: | |
| def _fix_verse_count(m: re.Match) -> str: | |
| num = int(m.group(1)) | |
| if num == correct_verses: | |
| return m.group(0) | |
| logger.warning( | |
| "Surah info hallucination: %d verses -> correcting to %d", | |
| num, correct_verses, | |
| ) | |
| return m.group(0).replace(m.group(1), str(correct_verses)) | |
| answer = re.sub( | |
| r"(\d+)\s*(?:Ψ’ΩΨ©|Ψ’ΩΨ§Ψͺ|Ψ’ΩΩ)", | |
| _fix_verse_count, | |
| answer, | |
| ) | |
| answer = re.sub( | |
| r"(Ψ§ΩΨ’ΩΨ©|Ψ§ΩΨ§ΩΩ)\s+(\d+)", | |
| lambda m: m.group(1) + " " + (str(correct_verses) if int(m.group(2)) != correct_verses else m.group(2)), | |
| answer, | |
| ) | |
| answer = re.sub( | |
| r"(\d+)\s+(?:verses|ayat|ayahs)", | |
| _fix_verse_count, | |
| answer, | |
| flags=re.I, | |
| ) | |
| answer = re.sub( | |
| r"(ΨΉΨ―Ψ―[ΩΨ§]*\s+)(\d+)", | |
| lambda m: m.group(1) + (str(correct_verses) if int(m.group(2)) != correct_verses else m.group(2)), | |
| answer, | |
| ) | |
| return answer | |
| def infer_hadith_grade(item: dict) -> dict: | |
| """Infer hadith grade from collection name if not present.""" | |
| if item.get("type") != "hadith" or item.get("grade"): | |
| return item | |
| collection = item.get("collection", "").lower() | |
| reference = item.get("reference", "").lower() | |
| combined = f"{collection} {reference}" | |
| if any(s in combined for s in ["sahih al-bukhari", "sahih bukhari", "bukhari"]): | |
| item["grade"] = "Sahih" | |
| elif any(s in combined for s in ["sahih muslim", "sahih al-muslim"]): | |
| item["grade"] = "Sahih" | |
| elif any(s in combined for s in ["sunan an-nasai", "sunan an-nasa", "nasa'i", "nasa"]): | |
| item["grade"] = "Sahih" | |
| elif any(s in combined for s in ["jami at-tirmidhi", "tirmidhi", "at-tirmidhi"]): | |
| item["grade"] = "Hasan" | |
| elif any(s in combined for s in ["sunan abu dawood", "abu dawood", "abo daud", "abou daoude"]): | |
| item["grade"] = "Hasan" | |
| elif any(s in combined for s in ["sunan ibn majah", "ibn majah", "ibn maja"]): | |
| item["grade"] = "Hasan" | |
| elif any(s in combined for s in ["muwatta malik", "muwatta", "malik"]): | |
| item["grade"] = "Hasan" | |
| elif any(s in combined for s in ["musnad ahmad", "ahmad", "ahmed"]): | |
| item["grade"] = "Hasan/Sahih" | |
| elif any(s in combined for s in ["sunan al-darimi", "darimi", "al-darimi"]): | |
| item["grade"] = "Hasan" | |
| return item | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # APP STATE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AppState: | |
| embed_model: Optional[SentenceTransformer] = None | |
| faiss_index: Optional[faiss.Index] = None | |
| dataset: Optional[list] = None | |
| llm: Optional[LLMProvider] = None | |
| bm25_index: Optional[BM25InvertedIndex] = None | |
| ready: bool = False | |
| # O(1) lookup tables built at startup | |
| quran_verse_idx: Dict[Tuple[int, int], dict] = {} # (surah_num, verse_num) -> item | |
| hadith_ref_idx: Dict[Tuple[str, int], dict] = {} # (collection, hadith_num) -> item | |
| surah_info_map: Dict[str, dict] = {} # normalised name key -> info | |
| state = AppState() | |
| def _build_lookup_tables(dataset: list) -> None: | |
| """Build O(1) lookup dicts and inverted BM25 index from the loaded dataset. | |
| Called once at startup β O(n) cost paid once, saves time on every request. | |
| """ | |
| from app.arabic_nlp import normalize_arabic as _norm | |
| quran_verse_idx: Dict[Tuple[int, int], dict] = {} | |
| hadith_ref_idx: Dict[Tuple[str, int], dict] = {} | |
| surah_info_map: Dict[str, dict] = {} | |
| seen_surahs: set = set() | |
| for item in dataset: | |
| if item.get("type") == "quran": | |
| sn = item.get("surah_number") | |
| vn = item.get("ayah_number") or item.get("verse_number") | |
| if sn and vn: | |
| quran_verse_idx[(sn, vn)] = item | |
| # One info entry per surah (first verse encountered) | |
| if sn and sn not in seen_surahs: | |
| seen_surahs.add(sn) | |
| info = { | |
| "surah_number": sn, | |
| "surah_name_ar": item.get("surah_name_ar", ""), | |
| "surah_name_en": item.get("surah_name_en", ""), | |
| "surah_name_transliteration": item.get("surah_name_transliteration", ""), | |
| "total_verses": item.get("total_verses"), | |
| "revelation_type": item.get("revelation_type", ""), | |
| } | |
| for field in ("surah_name_ar", "surah_name_en", "surah_name_transliteration"): | |
| val = item.get(field, "") | |
| if not val: | |
| continue | |
| key = _norm(val, aggressive=True).lower() | |
| key_clean = re.sub(r"^(Ψ§Ω|al[\-\s']*)", "", key, flags=re.I).strip() | |
| surah_info_map[key] = info | |
| if key_clean and key_clean != key: | |
| surah_info_map[key_clean] = info | |
| elif item.get("type") == "hadith": | |
| collection = item.get("collection") | |
| hadith_num = item.get("hadith_number") | |
| if collection and hadith_num is not None: | |
| try: | |
| hadith_ref_idx[(collection, int(hadith_num))] = item | |
| except (TypeError, ValueError): | |
| pass | |
| state.quran_verse_idx = quran_verse_idx | |
| state.hadith_ref_idx = hadith_ref_idx | |
| state.surah_info_map = surah_info_map | |
| logger.info("Building full-dataset inverted BM25 index...") | |
| bm25 = BM25InvertedIndex() | |
| bm25.build(dataset) | |
| state.bm25_index = bm25 | |
| logger.info( | |
| "Lookup tables ready: %d Quran verses | %d hadith refs | %d surah keys | %d BM25 terms", | |
| len(quran_verse_idx), len(hadith_ref_idx), len(surah_info_map), len(bm25.posting_list), | |
| ) | |
| async def lifespan(app: FastAPI): | |
| """Initialize state on startup.""" | |
| logger.info("Loading embed model: %s", cfg.EMBED_MODEL) | |
| state.embed_model = SentenceTransformer(cfg.EMBED_MODEL) | |
| logger.info("Loading FAISS index: %s", cfg.FAISS_INDEX) | |
| state.faiss_index = faiss.read_index(cfg.FAISS_INDEX) | |
| logger.info("Loading metadata: %s", cfg.METADATA_FILE) | |
| with open(cfg.METADATA_FILE, "r", encoding="utf-8") as f: | |
| state.dataset = json.load(f) | |
| state.dataset = [infer_hadith_grade(item) for item in state.dataset] | |
| logger.info("Building lookup tables...") | |
| _build_lookup_tables(state.dataset) | |
| logger.info("Initializing LLM provider: %s", cfg.LLM_BACKEND) | |
| state.llm = get_llm_provider() | |
| state.ready = True | |
| logger.info( | |
| "QModel v6 ready | backend=%s | dataset=%d | faiss=%d | threshold=%.2f", | |
| cfg.LLM_BACKEND, | |
| len(state.dataset) if state.dataset else 0, | |
| state.faiss_index.ntotal if state.faiss_index else 0, | |
| cfg.CONFIDENCE_THRESHOLD, | |
| ) | |
| yield | |
| state.ready = False | |
| logger.info("QModel shutdown") | |
| def check_ready(): | |
| """Raise 503 if service isn't ready.""" | |
| if not state.ready: | |
| raise HTTPException( | |
| status_code=503, | |
| detail="Service is still initialising. Please retry shortly.", | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # SHARED PRE-PROCESSING (steps 1-7, used by both pipeline variants) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def _rag_preprocess( | |
| question: str, | |
| top_k: int, | |
| source_type: Optional[Literal["quran", "hadith"]], | |
| grade_filter: Optional[str], | |
| ) -> dict: | |
| """Rewrite β search β analyse β language detect. | |
| Returns a dict with keys: results, rewrite, surah_info, analysis, | |
| lang, top_score, intent. | |
| """ | |
| # 1. Query rewriting & Intent detection | |
| rewrite = await rewrite_query(question, state.llm) | |
| intent = rewrite.get("intent", "general") | |
| # 1b. Override source_type if explicitly mentioned in query | |
| if not source_type: | |
| norm_q = normalize_arabic(question).lower() | |
| if any(x in norm_q for x in ["ΩΩ Ψ§ΩΩΨ±Ψ§Ω", "Ψ§ΩΨ§Ψͺ Ω Ω", "Ψ³ΩΨ±Ω", "Ψ§ΩΨ§ΩΩ"]): | |
| source_type = "quran" | |
| elif any(x in norm_q for x in ["ΩΩ Ψ§ΩΨΨ―ΩΨ«", "ΨΨ―ΩΨ« ΩΨ¨ΩΩ", "ΩΨ§Ω Ψ±Ψ³ΩΩ Ψ§ΩΩΩ"]): | |
| source_type = "hadith" | |
| # 2. Concurrent: surah info + analysis intent + hybrid search | |
| surah_task = detect_surah_info(question, rewrite) | |
| kw_task = detect_analysis_intent(question, rewrite) | |
| search_task = hybrid_search( | |
| question, rewrite, | |
| state.embed_model, state.faiss_index, state.dataset, | |
| top_k, source_type, grade_filter, | |
| bm25_index=state.bm25_index, | |
| ) | |
| surah_det, analysis_kw, results = await asyncio.gather( | |
| surah_task, kw_task, search_task, | |
| ) | |
| # 2b. Direct reference lookup β O(1) with pre-built indices | |
| direct_queries = list(dict.fromkeys([ | |
| question, | |
| rewrite.get("ar_query", ""), | |
| rewrite.get("en_query", ""), | |
| ])) | |
| direct_results = [] | |
| if source_type in (None, "quran"): | |
| for query in direct_queries: | |
| direct_results.extend( | |
| lookup_quran_verses(query, state.dataset, limit=top_k, | |
| verse_idx=state.quran_verse_idx) | |
| ) | |
| if source_type in (None, "hadith"): | |
| for query in direct_queries: | |
| direct_results.extend( | |
| lookup_hadith_references(query, state.dataset, limit=top_k, | |
| hadith_idx=state.hadith_ref_idx) | |
| ) | |
| results = merge_search_results(direct_results, results, limit=top_k) | |
| # 2c. Text search fallback (offloaded to thread worker) | |
| ar_q = rewrite.get("ar_query", "") | |
| text_src = source_type | |
| if not text_src and intent in ("tafsir", "count", "surah_info"): | |
| text_src = "quran" | |
| elif not text_src and intent in ("hadith", "auth"): | |
| text_src = "hadith" | |
| text_limit = top_k * 2 if intent in ("auth", "hadith", "tafsir") else top_k | |
| text_results = [] | |
| for q in dict.fromkeys([ar_q, question]): | |
| if not q: | |
| continue | |
| hits = await asyncio.to_thread(text_search, q, state.dataset, text_src, text_limit) | |
| # If the query is an exact verse text that appears in multiple surahs, | |
| # expand to all occurrences (capped at 20 to keep context manageable). | |
| if ( | |
| hits | |
| and hits[0].get("_score", 0) >= 3.0 | |
| and text_src in (None, "quran") | |
| and intent in ("tafsir", "general") | |
| ): | |
| all_quran_hits = await asyncio.to_thread(text_search, q, state.dataset, "quran", None) | |
| hits = [ | |
| r for r in all_quran_hits | |
| if r.get("_score", 0) >= 3.0 | |
| ] | |
| hits.sort(key=lambda r: ( | |
| r.get("surah_number", 999), | |
| r.get("ayah_number") or r.get("verse_number", 0), | |
| )) | |
| hits = hits[:20] | |
| for hit in hits: | |
| if intent == "auth" and hit.get("_score", 0) > 2.0: | |
| hit = {**hit, "_score": hit["_score"] + 1.0} | |
| text_results.append(hit) | |
| results = merge_search_results(results, text_results, limit=top_k) | |
| # 3a. Surah metadata lookup β O(1) with pre-built map | |
| surah_info = None | |
| if surah_det: | |
| surah_info = await lookup_surah_info( | |
| surah_det["surah_query"], state.dataset, | |
| surah_info_map=state.surah_info_map, | |
| ) | |
| if surah_info: | |
| intent = "surah_info" | |
| logger.info( | |
| "Surah info: %s -> %s (%d verses)", | |
| surah_det["surah_query"], | |
| surah_info["surah_name_en"], | |
| surah_info.get("total_verses", 0), | |
| ) | |
| # 3b. Word frequency count | |
| analysis = None | |
| if analysis_kw and not surah_info: | |
| count_src = "hadith" if intent in ("hadith", "auth") else "quran" | |
| analysis = await count_occurrences(analysis_kw, state.dataset, source_type=count_src) | |
| logger.info("Analysis: kw=%s src=%s count=%d", analysis_kw, count_src, analysis["total_count"]) | |
| # 4. Language detection | |
| lang = detect_language(question) | |
| top_score = results[0].get("_score", 0.0) if results else 0.0 | |
| logger.info( | |
| "Search done | intent=%s | top_score=%.3f | threshold=%.2f", | |
| intent, top_score, cfg.CONFIDENCE_THRESHOLD, | |
| ) | |
| return { | |
| "results": results, | |
| "rewrite": rewrite, | |
| "surah_info": surah_info, | |
| "analysis": analysis, | |
| "lang": lang, | |
| "top_score": top_score, | |
| "intent": intent, | |
| } | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CORE RAG PIPELINE (non-streaming, with hallucination checks) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def run_rag_pipeline( | |
| question: str, | |
| top_k: int = cfg.TOP_K_RETURN, | |
| source_type: Optional[Literal["quran", "hadith"]] = None, | |
| grade_filter: Optional[str] = None, | |
| ) -> dict: | |
| """Core RAG pipeline: rewrite -> search -> verify -> generate.""" | |
| t0 = time.perf_counter() | |
| cached = await pipeline_cache.get(question, top_k, source_type or "", grade_filter or "") | |
| if cached: | |
| logger.info("Pipeline cache hit | question=%.60s", question) | |
| return cached | |
| ctx = await _rag_preprocess(question, top_k, source_type, grade_filter) | |
| results = ctx["results"] | |
| surah_info = ctx["surah_info"] | |
| analysis = ctx["analysis"] | |
| lang = ctx["lang"] | |
| top_score = ctx["top_score"] | |
| intent = ctx["intent"] | |
| # 5. Confidence gate (skip for surah_info) | |
| # Use dynamic threshold based on intent | |
| threshold = cfg.INTENT_THRESHOLDS.get(intent, cfg.CONFIDENCE_THRESHOLD) | |
| if not surah_info and top_score < threshold: | |
| logger.warning( | |
| "Low confidence (%.3f < %.2f for intent %s) β returning safe fallback", | |
| top_score, threshold, intent, | |
| ) | |
| return { | |
| "answer": not_found_answer(lang), | |
| "language": lang, | |
| "intent": intent, | |
| "analysis": analysis, | |
| "sources": results, | |
| "top_score": top_score, | |
| "latency_ms": int((time.perf_counter() - t0) * 1000), | |
| } | |
| # 5b. Surah metadata: deterministic answer (faster & more reliable than LLM) | |
| if surah_info and intent == "surah_info": | |
| answer = _surah_info_fallback(surah_info, lang) | |
| latency = int((time.perf_counter() - t0) * 1000) | |
| logger.info( | |
| "Pipeline done (surah_info deterministic) | lang=%s | %d ms", | |
| lang, latency, | |
| ) | |
| return { | |
| "answer": answer, | |
| "language": lang, | |
| "intent": intent, | |
| "analysis": None, | |
| "sources": results, | |
| "top_score": top_score, | |
| "latency_ms": latency, | |
| } | |
| # 6. Build context + prompt + LLM call | |
| context = build_context(results) | |
| messages = build_messages(context, question, lang, intent, analysis, surah_info) | |
| try: | |
| answer = await state.llm.chat( | |
| messages, | |
| max_tokens=cfg.MAX_TOKENS, | |
| temperature=cfg.TEMPERATURE, | |
| ) | |
| # Extract reasoning content (thinking block) | |
| thinking = None | |
| think_match = re.search(r"<think>([\s\S]*?)</think>", answer, flags=re.IGNORECASE) | |
| if think_match: | |
| thinking = think_match.group(1).strip() | |
| # Strip residual <think> blocks that Qwen3 may emit | |
| answer = re.sub(r"<think>[\s\S]*?</think>", "", answer, flags=re.IGNORECASE) | |
| answer = re.sub(r"<think>[\s\S]*$", "", answer, flags=re.IGNORECASE) | |
| answer = answer.strip() | |
| if not answer: | |
| logger.warning("LLM returned empty answer β using results fallback") | |
| if surah_info: | |
| answer = _surah_info_fallback(surah_info, lang) | |
| elif results: | |
| answer = _results_fallback(results, lang) | |
| else: | |
| answer = not_found_answer(lang) | |
| except Exception as exc: | |
| logger.error("LLM call failed: %s", exc) | |
| raise HTTPException(status_code=502, detail="LLM service unavailable") | |
| # 7. Post-generation hallucination check | |
| answer = _verify_citations(answer, results) | |
| answer = _verify_references(answer, results) | |
| if surah_info: | |
| answer = _verify_surah_info(answer, surah_info) | |
| latency = int((time.perf_counter() - t0) * 1000) | |
| logger.info( | |
| "Pipeline done | intent=%s | lang=%s | top_score=%.3f | %d ms", | |
| intent, lang, top_score, latency, | |
| ) | |
| result = { | |
| "answer": answer, | |
| "thinking": thinking, | |
| "language": lang, | |
| "intent": intent, | |
| "analysis": analysis, | |
| "sources": results, | |
| "top_score": top_score, | |
| "latency_ms": latency, | |
| } | |
| await pipeline_cache.set(result, question, top_k, source_type or "", grade_filter or "") | |
| return result | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STREAMING RAG PIPELINE (token streaming with sentence verification) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class StreamingVerificationBuffer: | |
| """Sentence-buffered streaming citation & reference verification.""" | |
| def __init__(self, results: list, surah_info: Optional[dict] = None): | |
| self.results = results | |
| self.surah_info = surah_info | |
| self.buffer = "" | |
| def feed(self, token: str) -> List[str]: | |
| self.buffer += token | |
| emitted: List[str] = [] | |
| while True: | |
| match = re.search(r"(\n|\. |\? |Ψ |ββ+β)", self.buffer) | |
| if not match: | |
| break | |
| idx = match.end() | |
| chunk = self.buffer[:idx] | |
| self.buffer = self.buffer[idx:] | |
| chunk = _verify_citations(chunk, self.results) | |
| chunk = _verify_references(chunk, self.results) | |
| if self.surah_info: | |
| chunk = _verify_surah_info(chunk, self.surah_info) | |
| emitted.append(chunk) | |
| return emitted | |
| def flush(self) -> str: | |
| if not self.buffer: | |
| return "" | |
| chunk = self.buffer | |
| self.buffer = "" | |
| chunk = _verify_citations(chunk, self.results) | |
| chunk = _verify_references(chunk, self.results) | |
| if self.surah_info: | |
| chunk = _verify_surah_info(chunk, self.surah_info) | |
| return chunk | |
| async def run_rag_pipeline_stream( | |
| question: str, | |
| top_k: int = cfg.TOP_K_RETURN, | |
| source_type: Optional[Literal["quran", "hadith"]] = None, | |
| grade_filter: Optional[str] = None, | |
| ) -> AsyncIterator[dict]: | |
| """Streaming RAG pipeline: runs full pre-processing then streams LLM tokens. | |
| Yields dicts with keys: | |
| {"type": "metadata", "sources": [...], "language": str, "intent": str, "top_score": float} | |
| {"type": "token", "content": str} | |
| {"type": "done", "latency_ms": int} | |
| """ | |
| t0 = time.perf_counter() | |
| ctx = await _rag_preprocess(question, top_k, source_type, grade_filter) | |
| results = ctx["results"] | |
| surah_info = ctx["surah_info"] | |
| analysis = ctx["analysis"] | |
| lang = ctx["lang"] | |
| top_score = ctx["top_score"] | |
| intent = ctx["intent"] | |
| # Emit metadata so the client has sources before any tokens arrive | |
| yield { | |
| "type": "metadata", | |
| "sources": results, | |
| "language": lang, | |
| "intent": intent, | |
| "top_score": top_score, | |
| "analysis": analysis, | |
| } | |
| # ββ Deterministic / fallback paths ββββββββββββββββββββββββββββββββ | |
| if not surah_info and top_score < cfg.CONFIDENCE_THRESHOLD: | |
| yield {"type": "token", "content": not_found_answer(lang)} | |
| yield {"type": "done", "latency_ms": int((time.perf_counter() - t0) * 1000)} | |
| return | |
| if surah_info and intent == "surah_info": | |
| yield {"type": "token", "content": _surah_info_fallback(surah_info, lang)} | |
| yield {"type": "done", "latency_ms": int((time.perf_counter() - t0) * 1000)} | |
| return | |
| # ββ Stream LLM generation token by token with verification buffer ββ | |
| context = build_context(results) | |
| messages = build_messages(context, question, lang, intent, analysis, surah_info) | |
| in_think = False | |
| verifier = StreamingVerificationBuffer(results, surah_info) | |
| try: | |
| async for token in state.llm.chat_stream( | |
| messages, | |
| max_tokens=cfg.MAX_TOKENS, | |
| temperature=cfg.TEMPERATURE, | |
| ): | |
| if not token: | |
| continue | |
| # Handle <think> tags for reasoning support | |
| if "<think>" in token.lower(): | |
| in_think = True | |
| token = re.sub(r"<think>", "", token, flags=re.IGNORECASE) | |
| if "</think>" in token.lower(): | |
| in_think = False | |
| parts = re.split(r"</think>", token, flags=re.IGNORECASE) | |
| if parts[0]: | |
| yield {"type": "reasoning", "content": parts[0]} | |
| if len(parts) > 1 and parts[1]: | |
| for verified_chunk in verifier.feed(parts[1]): | |
| yield {"type": "token", "content": verified_chunk} | |
| continue | |
| if in_think: | |
| yield {"type": "reasoning", "content": token} | |
| else: | |
| for verified_chunk in verifier.feed(token): | |
| yield {"type": "token", "content": verified_chunk} | |
| remaining = verifier.flush() | |
| if remaining: | |
| yield {"type": "token", "content": remaining} | |
| except Exception as exc: | |
| logger.error("LLM stream failed: %s", exc) | |
| raise HTTPException(status_code=502, detail="LLM service unavailable") | |
| latency = int((time.perf_counter() - t0) * 1000) | |
| logger.info( | |
| "Stream done | intent=%s | lang=%s | top_score=%.3f | %d ms", | |
| intent, lang, top_score, latency, | |
| ) | |
| yield {"type": "done", "latency_ms": latency} | |