hudanet-api / app.py
dakheel's picture
Fix HUDA-Net v4 frontend render loop and bridge stability
a7265aa verified
Raw
History Blame Contribute Delete
39 kB
# -*- coding: utf-8 -*-
"""
HUDA-Net v43-AR | Frozen Neural Decision Core + Arabic/English Query Layer | Hugging Face Spaces
Active query path:
TOP2 Retriever -> Stage4-v2 Reranker -> RRF k=60 -> Stage5 Calibrator
-> fixed threshold 0.7023535690146311 -> visible-evidence grounding.
This active app has no semantic keyword rules, manual synonym expansion,
dialect dictionary, or regex intent classification. Arabic and English queries are
accepted by the interface; the frozen decision core and confidence policy are unchanged.
"""
from __future__ import annotations
# Hugging Face ZeroGPU must be imported before Torch/CUDA initialization.
import spaces
import hashlib
import html
import importlib.util
import json
import os
import re
import threading
import time
from pathlib import Path
from typing import Any
import gradio as gr
import numpy as np
import pandas as pd
from huggingface_hub import snapshot_download
APP_VERSION = "43.0.0-AR"
UI_VERSION = "4.0-MVP"
TRANSLATION_MODEL_REPO = os.getenv("HUDANET_AR_EN_TRANSLATOR_REPO", "Helsinki-NLP/opus-mt-ar-en").strip()
MODEL_REPO = os.getenv("HUDANET_V43_MODEL_REPO", "dakheel/hudanet-v43-ar").strip()
CORPUS_REPO = os.getenv("HUDANET_V43_CORPUS_REPO", "dakheel/hudanet-v43-ar-runtime-corpus").strip()
HF_TOKEN = os.getenv("HF_TOKEN", "").strip()
EXPECTED_TOP2_SHA256 = "b1e27b53b616f98bb406dcb94a88583a2b1d659dedb0a45464a310c4200d5173"
EXPECTED_RERANKER_SHA256 = "82e301c2aef211540b628f4b5517d81de79bf5c8e0cf1f69524cf0dec40d0fbc"
EXPECTED_THRESHOLD = 0.7023535690146311
EXPECTED_RRF_K = 60
EXPECTED_CORPUS_ROWS = 2796
ROOT = Path("/tmp/hudanet_v43_ar")
MODEL_ROOT = ROOT / "model_release"
CORPUS_ROOT = ROOT / "runtime_corpus"
os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
os.environ.setdefault("GRADIO_ANALYTICS_ENABLED", "False")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
# Script detection is presentation/query-routing only, not a semantic classifier.
_ARABIC_CHAR_RE = re.compile(r"[\u0600-\u06FF]")
_LATIN_CHAR_RE = re.compile(r"[A-Za-z]")
_RUNTIME_LOCK = threading.Lock()
_CACHE_LOCK = threading.Lock()
_TRANSLATION_LOCK = threading.Lock()
_QUERY_CACHE: dict[str, dict[str, Any]] = {}
_TRANSLATION_CACHE: dict[str, str] = {}
_TRANSLATOR_TOKENIZER = None
_TRANSLATOR_MODEL = None
_CACHE_MAX = 96
_TRANSLATION_CACHE_MAX = 256
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def _recursive_fingerprint(path: Path) -> str:
path = path.resolve()
files = sorted(p for p in path.rglob("*") if p.is_file())
if not files:
raise RuntimeError(f"No files found under model directory: {path}")
h = hashlib.sha256()
for p in files:
rel = str(p.relative_to(path)).replace("\\", "/")
size = p.stat().st_size
sha = _sha256_file(p)
h.update(rel.encode("utf-8"))
h.update(b"\x00")
h.update(str(size).encode("ascii"))
h.update(b"\x00")
h.update(sha.encode("ascii"))
h.update(b"\n")
return h.hexdigest()
def _load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def _download_release_assets() -> tuple[Path, Path]:
ROOT.mkdir(parents=True, exist_ok=True)
MODEL_ROOT.mkdir(parents=True, exist_ok=True)
CORPUS_ROOT.mkdir(parents=True, exist_ok=True)
print(f"⬇️ Downloading frozen v43-AR model release: {MODEL_REPO}")
snapshot_download(
repo_id=MODEL_REPO,
repo_type="model",
token=HF_TOKEN or None,
local_dir=str(MODEL_ROOT),
allow_patterns=[
"retriever/**",
"reranker/**",
"runtime/**",
"config/**",
"release/**",
"reports/STAGE7_FINAL_BLIND_REPORT.json",
],
max_workers=8,
)
if not HF_TOKEN:
raise RuntimeError(
"HF_TOKEN is missing. The v43 Arabic runtime corpus is private. "
"Keep the existing read-only HF_TOKEN under Space Settings -> Secrets."
)
print(f"⬇️ Downloading private frozen Arabic runtime corpus: {CORPUS_REPO}")
snapshot_download(
repo_id=CORPUS_REPO,
repo_type="dataset",
token=HF_TOKEN,
local_dir=str(CORPUS_ROOT),
allow_patterns=[
"v43_ar_runtime_corpus.parquet",
"v43_ar_passage_embeddings.npy",
"RUNTIME_CORPUS_MANIFEST.json",
],
max_workers=8,
)
return MODEL_ROOT, CORPUS_ROOT
def _verify_release(model_root: Path, corpus_root: Path) -> tuple[dict, dict]:
release = _load_json(model_root / "release" / "RELEASE_MANIFEST.json")
corpus_manifest = _load_json(corpus_root / "RUNTIME_CORPUS_MANIFEST.json")
if release.get("release") != "HUDA-Net v43-AR":
raise RuntimeError("Unexpected model release identity.")
if int(release.get("rrf_k", -1)) != EXPECTED_RRF_K:
raise RuntimeError("Frozen RRF k mismatch.")
if abs(float(release.get("abstention_threshold", -1)) - EXPECTED_THRESHOLD) > 1e-12:
raise RuntimeError("Frozen abstention threshold mismatch.")
if release.get("hidden_answer_pool_allowed") is not False:
raise RuntimeError("Release manifest violates hidden-answer-pool contract.")
print("🔐 Verifying frozen retriever fingerprint...")
top2_hash = _recursive_fingerprint(model_root / "retriever")
if top2_hash != EXPECTED_TOP2_SHA256 or top2_hash != release.get("top2_recursive_sha256"):
raise RuntimeError(f"Retriever fingerprint mismatch: {top2_hash}")
print("🔐 Verifying frozen reranker fingerprint...")
rer_hash = _recursive_fingerprint(model_root / "reranker")
if rer_hash != EXPECTED_RERANKER_SHA256 or rer_hash != release.get("reranker_recursive_sha256"):
raise RuntimeError(f"Reranker fingerprint mismatch: {rer_hash}")
corpus_file = corpus_root / "v43_ar_runtime_corpus.parquet"
embeddings_file = corpus_root / "v43_ar_passage_embeddings.npy"
if _sha256_file(corpus_file) != corpus_manifest.get("corpus_sha256"):
raise RuntimeError("Runtime corpus SHA256 mismatch.")
if _sha256_file(embeddings_file) != corpus_manifest.get("embeddings_sha256"):
raise RuntimeError("Runtime embedding SHA256 mismatch.")
if int(corpus_manifest.get("records", -1)) != EXPECTED_CORPUS_ROWS:
raise RuntimeError("Runtime corpus row count manifest mismatch.")
if corpus_manifest.get("question_labels_included") is not False:
raise RuntimeError("Runtime corpus must not contain evaluation question labels.")
if corpus_manifest.get("hidden_answer_field_included") is not False:
raise RuntimeError("Runtime corpus must not contain a hidden answer field.")
return release, corpus_manifest
def _load_frozen_runtime(model_root: Path, corpus_root: Path):
runtime_py = model_root / "runtime" / "runtime_v43_ar.py"
spec = importlib.util.spec_from_file_location("hudanet_v43_frozen_runtime", runtime_py)
if spec is None or spec.loader is None:
raise RuntimeError("Could not load frozen runtime_v43_ar.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
runtime = module.HudanetArabicV43Runtime(
top2_model_dir=str(model_root / "retriever"),
reranker_model_dir=str(model_root / "reranker"),
calibrator_path=str(model_root / "config" / "RANKING_CONFIDENCE_CALIBRATOR.joblib"),
policy_path=str(model_root / "config" / "ABSTENTION_POLICY.json"),
)
corpus = pd.read_parquet(corpus_root / "v43_ar_runtime_corpus.parquet").fillna("")
embeddings = np.asarray(
np.load(corpus_root / "v43_ar_passage_embeddings.npy", allow_pickle=False),
dtype=np.float32,
)
required = {
"record_id", "passage_ar", "book_ar", "author_ar", "title", "chapter",
"category", "ruling", "page_number", "source_file",
}
missing = required - set(corpus.columns)
if missing:
raise RuntimeError(f"Runtime corpus missing columns: {sorted(missing)}")
if len(corpus) != EXPECTED_CORPUS_ROWS:
raise RuntimeError(f"Expected {EXPECTED_CORPUS_ROWS} corpus rows; found {len(corpus)}")
if corpus["record_id"].astype(str).nunique() != EXPECTED_CORPUS_ROWS:
raise RuntimeError("Runtime corpus record_id values are not unique.")
if any(c in corpus.columns for c in ("question_ar_gold", "split", "leakage_family_id", "answer_ar_gold")):
raise RuntimeError("Evaluation labels or hidden answer fields leaked into runtime corpus.")
if embeddings.ndim != 2 or embeddings.shape[0] != len(corpus):
raise RuntimeError(f"Embedding shape mismatch: {embeddings.shape} vs {len(corpus)} rows")
if not np.isfinite(embeddings).all():
raise RuntimeError("Runtime embeddings contain non-finite values.")
runtime.corpus = corpus.reset_index(drop=True).copy()
runtime.passage_embeddings = embeddings
if abs(float(runtime.threshold) - EXPECTED_THRESHOLD) > 1e-12:
raise RuntimeError("Loaded runtime threshold is not the frozen Stage5 threshold.")
return runtime, corpus
_BOOT_STARTED = time.perf_counter()
_MODEL_ROOT, _CORPUS_ROOT = _download_release_assets()
_RELEASE_MANIFEST, _CORPUS_MANIFEST = _verify_release(_MODEL_ROOT, _CORPUS_ROOT)
RUNTIME, CORPUS = _load_frozen_runtime(_MODEL_ROOT, _CORPUS_ROOT)
BOOT_SECONDS = time.perf_counter() - _BOOT_STARTED
print(
f"✅ HUDA-Net v43-AR frozen runtime ready | rows={len(CORPUS):,} | "
f"rrf_k={EXPECTED_RRF_K} | threshold={EXPECTED_THRESHOLD:.6f} | {BOOT_SECONDS:.1f}s"
)
def _clean_query(value: Any) -> str:
text = str(value or "").replace("\x00", " ").strip()
text = re.sub(r"\s+", " ", text)
return text[:1200]
def _is_arabic_enough(text: str) -> bool:
return len(_ARABIC_CHAR_RE.findall(text)) >= 3
def _detect_query_language(text: str) -> str:
"""Detect Arabic vs English from the query's dominant writing script.
This is used only for UI direction and response language. It does not alter
retrieval semantics, ranking features, RRF, calibration, or the threshold.
"""
value = str(text or "")
ar = len(_ARABIC_CHAR_RE.findall(value))
en = len(_LATIN_CHAR_RE.findall(value))
if ar == 0 and en == 0:
return "ar"
if ar >= en * 1.2:
return "ar"
if en >= ar * 1.2:
return "en"
for ch in value:
if _ARABIC_CHAR_RE.match(ch):
return "ar"
if _LATIN_CHAR_RE.match(ch):
return "en"
return "ar"
def _qdir(lang: str) -> str:
return "ltr" if lang == "en" else "rtl"
def _qt(lang: str, ar: str, en: str, tag: str = "span", cls: str = "") -> str:
value = en if lang == "en" else ar
direction = _qdir(lang)
lang_attr = "en" if lang == "en" else "ar"
class_attr = f' class="{cls}"' if cls else ""
return f'<{tag}{class_attr} dir="{direction}" lang="{lang_attr}">{value}</{tag}>'
def _load_translation_model():
global _TRANSLATOR_TOKENIZER, _TRANSLATOR_MODEL
if _TRANSLATOR_TOKENIZER is not None and _TRANSLATOR_MODEL is not None:
return _TRANSLATOR_TOKENIZER, _TRANSLATOR_MODEL
with _TRANSLATION_LOCK:
if _TRANSLATOR_TOKENIZER is not None and _TRANSLATOR_MODEL is not None:
return _TRANSLATOR_TOKENIZER, _TRANSLATOR_MODEL
print(f"⬇️ Loading Arabic→English presentation translator: {TRANSLATION_MODEL_REPO}")
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained(TRANSLATION_MODEL_REPO)
model = AutoModelForSeq2SeqLM.from_pretrained(TRANSLATION_MODEL_REPO)
model.eval()
model.to("cpu")
_TRANSLATOR_TOKENIZER = tok
_TRANSLATOR_MODEL = model
return tok, model
def _translation_cache_put(source: str, translated: str) -> None:
with _TRANSLATION_LOCK:
if source in _TRANSLATION_CACHE:
_TRANSLATION_CACHE.pop(source, None)
_TRANSLATION_CACHE[source] = translated
while len(_TRANSLATION_CACHE) > _TRANSLATION_CACHE_MAX:
first = next(iter(_TRANSLATION_CACHE))
_TRANSLATION_CACHE.pop(first, None)
def _translate_ar_to_en(text: str) -> str:
source = str(text or "").strip()
if not source:
return ""
with _TRANSLATION_LOCK:
cached = _TRANSLATION_CACHE.get(source)
if cached:
return cached
tok, model = _load_translation_model()
import torch
# Chunk by tokenizer IDs so long source passages are translated completely
# instead of being silently truncated by the translation model.
ids = tok(source, add_special_tokens=False).input_ids
chunk_size = 380
chunks = [tok.decode(ids[i:i + chunk_size], skip_special_tokens=True) for i in range(0, len(ids), chunk_size)] or [source]
translated_parts: list[str] = []
with _TRANSLATION_LOCK, torch.inference_mode():
for start in range(0, len(chunks), 6):
batch_text = chunks[start:start + 6]
batch = tok(batch_text, return_tensors="pt", padding=True, truncation=True, max_length=512)
generated = model.generate(
**batch,
num_beams=3,
max_new_tokens=512,
early_stopping=True,
)
translated_parts.extend(tok.batch_decode(generated, skip_special_tokens=True))
translated = " ".join(x.strip() for x in translated_parts if x and x.strip()).strip()
if not translated:
raise RuntimeError("Arabic-to-English translation returned empty text.")
_translation_cache_put(source, translated)
return translated
def _prepare_english_translations(result: dict, count: int) -> dict[str, str]:
"""Translate visible Arabic evidence for English-query presentation only.
The frozen retrieval/reranking/calibration decision is completed before this
function is called. These translations are never fed back into ranking.
"""
candidates = list(result.get("candidates", []) or [])[: max(1, min(int(count), 8))]
selected = result.get("selected_visible_evidence", {}) or {}
items = candidates + ([selected] if selected else [])
out: dict[str, str] = {}
for item in items:
rid = str(item.get("record_id", ""))
passage = str(item.get("passage_ar", "")).strip()
if rid and passage and rid not in out:
out[rid] = _translate_ar_to_en(passage)
return out
def _esc(value: Any) -> str:
return html.escape(str(value or ""), quote=True)
def _fmt_num(value: Any, digits: int = 3) -> str:
try:
return f"{float(value):.{digits}f}"
except Exception:
return ""
def _ui(ar: str, en: str, tag: str = "span", extra_class: str = "") -> str:
cls = ("i18n " + extra_class).strip()
return (
f'<{tag} class="{cls} i18n-ar" dir="rtl" lang="ar">{ar}</{tag}>'
f'<{tag} class="{cls} i18n-en" dir="ltr" lang="en">{en}</{tag}>'
)
def _confidence_meter(probability: float, threshold: float, answered: bool) -> str:
pct = max(0.0, min(100.0, probability * 100.0))
threshold_pct = max(0.0, min(100.0, threshold * 100.0))
state = "pass" if answered else "hold"
return (
f'<div class="confidence-meter {state}" aria-label="Confidence meter">'
'<div class="confidence-scale">'
f'<span class="confidence-fill" style="width:{pct:.2f}%"></span>'
f'<span class="threshold-pin" style="left:{threshold_pct:.2f}%" title="{threshold_pct:.1f}%"></span>'
'</div>'
'<div class="confidence-legend">'
f'<span>{_ui("الثقة", "Confidence")}<strong>{pct:.1f}%</strong></span>'
f'<span>{_ui("حد الإجابة", "Answer threshold")}<strong>{threshold_pct:.1f}%</strong></span>'
'</div>'
'</div>'
)
def _candidate_card(candidate: dict, number: int, selected_id: str, answered: bool, query_lang: str, translations: dict[str, str]) -> str:
rid = str(candidate.get("record_id", ""))
selected = rid == selected_id
book = _esc(candidate.get("book_ar", "") or "")
author = _esc(candidate.get("author_ar", ""))
title = _esc(candidate.get("title", ""))
page = _esc(candidate.get("page_number", ""))
passage_ar = str(candidate.get("passage_ar", "")).strip()
source_parts = [x for x in (book, author) if x]
source_line = " · ".join(source_parts) or "—"
if page:
source_line += f" · {_qt(query_lang, 'ص', 'p.')} {page}"
badge = ""
if selected and answered:
badge = _qt(query_lang, "المصدر الداعم", "Supporting source", "span", "source-badge")
if query_lang == "en":
translated = _esc(translations.get(rid, ""))
if not translated:
raise RuntimeError(f"Missing English translation for visible evidence record {rid}")
body = f'<div class="evidence-passage" dir="ltr" lang="en">{translated}</div>'
body += (
'<details class="source-original">'
'<summary>Original Arabic</summary>'
f'<div class="evidence-passage source-arabic" dir="rtl" lang="ar">{_esc(passage_ar)}</div>'
'</details>'
)
heading = f"Source {number}"
else:
body = f'<div class="evidence-passage source-arabic" dir="rtl" lang="ar">{_esc(passage_ar)}</div>'
heading = title or f"المصدر {number}"
return (
f'<article class="evidence-card" dir="{_qdir(query_lang)}" lang="{query_lang}">'
'<div class="evidence-card-head">'
f'<div><span class="evidence-index">{number}</span>{badge}</div>'
f'<div class="evidence-source" dir="auto">{source_line}</div>'
'</div>'
f'<h3>{heading}</h3>'
f'{body}'
'</article>'
)
def _render_evidence(result: dict, count: int, query_lang: str, translations: dict[str, str]) -> str:
candidates = list(result.get("candidates", []) or [])[: max(1, min(int(count), 5))]
selected = result.get("selected_visible_evidence", {}) or {}
selected_id = str(selected.get("record_id", ""))
answered = result.get("decision") == "answer"
cards = [_candidate_card(c, i + 1, selected_id, answered, query_lang, translations) for i, c in enumerate(candidates)]
if not cards:
return ""
return (
f'<section class="evidence-section" dir="{_qdir(query_lang)}" lang="{query_lang}">'
f'{_qt(query_lang, "المصادر", "Sources", "h2", "section-title")}'
'<div class="evidence-list">' + "".join(cards) + '</div></section>'
)
def _answer_html(result: dict, query_lang: str, translations: dict[str, str]) -> str:
selected = result.get("selected_visible_evidence", {}) or {}
passage_ar = str(selected.get("passage_ar", "")).strip()
rid = str(selected.get("record_id", ""))
book = str(selected.get("book_ar", "")).strip()
author = str(selected.get("author_ar", "")).strip()
page = str(selected.get("page_number", "")).strip()
answered = result.get("decision") == "answer"
if not answered:
return (
f'<section class="result-card result-abstain" dir="{_qdir(query_lang)}" lang="{query_lang}">'
f'{_qt(query_lang, "لم أجد ثقة كافية للإجابة", "I do not have enough confidence to answer", "h2")}'
f'{_qt(query_lang, "راجع المصادر أدناه أو أعد صياغة السؤال.", "Review the sources below or rephrase the question.", "p")}'
'</section>'
)
if not passage_ar:
raise RuntimeError("Answer was allowed but selected visible passage is empty.")
if str(result.get("answer_support_record_id", "")) != rid:
raise RuntimeError("Visible-evidence grounding invariant failed.")
if query_lang == "en":
answer_text = translations.get(rid, "").strip()
if not answer_text:
raise RuntimeError("English answer requested but selected visible evidence was not translated.")
answer_body = f'<div class="answer-body" dir="ltr" lang="en">{_esc(answer_text)}</div>'
original = (
'<details class="source-original answer-original">'
'<summary>Original Arabic</summary>'
f'<div class="answer-body source-arabic" dir="rtl" lang="ar">{_esc(passage_ar)}</div>'
'</details>'
)
else:
answer_body = f'<div class="answer-body source-arabic" dir="rtl" lang="ar">{_esc(passage_ar)}</div>'
original = ""
source_parts = [x for x in (book, author) if x]
source_line = " · ".join(source_parts)
if page:
source_line += (" · " if source_line else "") + (("ص" if query_lang == "ar" else "p.") + f" {page}")
return (
f'<section class="result-card result-answer" dir="{_qdir(query_lang)}" lang="{query_lang}">'
f'{_qt(query_lang, "الجواب", "Answer", "h2")}'
f'{answer_body}'
f'<div class="answer-source" dir="auto">{_esc(source_line) if source_line else ""}</div>'
f'{original}'
'</section>'
)
def _diagnostics(result: dict, elapsed: float) -> dict:
selected = result.get("selected_visible_evidence", {}) or {}
corpus_rows = int(len(getattr(RUNTIME, "corpus", [])))
return {
"version": APP_VERSION,
"decision": result.get("decision"),
"probability_top1_correct": round(float(result.get("probability_top1_correct", 0.0)), 6),
"frozen_threshold": EXPECTED_THRESHOLD,
"selected_visible_record_id": str(selected.get("record_id", "")),
"answer_support_record_id": result.get("answer_support_record_id"),
"hidden_answer_pool_used": bool(result.get("hidden_answer_pool_used", False)),
"full_corpus_retrieval": corpus_rows == EXPECTED_CORPUS_ROWS,
"retriever_scored_rows": corpus_rows,
"reranker_candidates": len(result.get("candidates", []) or []),
"query_time_seconds": round(elapsed, 3),
}
def _cache_get(key: str):
with _CACHE_LOCK:
value = _QUERY_CACHE.get(key)
return value.copy() if isinstance(value, dict) else None
def _cache_put(key: str, value: dict):
with _CACHE_LOCK:
if key in _QUERY_CACHE:
_QUERY_CACHE.pop(key, None)
_QUERY_CACHE[key] = value
while len(_QUERY_CACHE) > _CACHE_MAX:
first = next(iter(_QUERY_CACHE))
_QUERY_CACHE.pop(first, None)
DISPLAY_EVIDENCE_COUNT = 3
@spaces.GPU(duration=60)
def answer_question(query: str):
q = _clean_query(query)
query_lang = _detect_query_language(q)
query_dir = _qdir(query_lang)
if not q:
return (
f'<section class="result-card result-abstain" dir="{query_dir}" lang="{query_lang}">'
+ _qt(query_lang, "اكتب سؤالًا أولًا", "Enter a question first", "h2")
+ '</section>',
"",
{},
)
corpus = getattr(RUNTIME, "corpus", None)
embeddings = getattr(RUNTIME, "passage_embeddings", None)
if corpus is None or embeddings is None:
raise RuntimeError("Frozen runtime corpus/index is not loaded.")
if len(corpus) != EXPECTED_CORPUS_ROWS or int(embeddings.shape[0]) != EXPECTED_CORPUS_ROWS:
raise RuntimeError("Full production corpus invariant failed.")
result = _cache_get(q)
started = time.perf_counter()
if result is None:
with _RUNTIME_LOCK:
result = RUNTIME.rank(q, top_k=20)
_cache_put(q, result)
elapsed = time.perf_counter() - started
if result.get("hidden_answer_pool_used") is not False:
raise RuntimeError("Hidden answer pool detected; refusing to render.")
if result.get("decision") == "answer":
selected = result.get("selected_visible_evidence", {}) or {}
if str(result.get("answer_support_record_id", "")) != str(selected.get("record_id", "")):
raise RuntimeError("Answer support is not the selected visible evidence.")
translations: dict[str, str] = {}
translation_error = ""
if query_lang == "en":
try:
translations = _prepare_english_translations(result, DISPLAY_EVIDENCE_COUNT)
except Exception as exc:
translation_error = f"{type(exc).__name__}: {exc}"
if result.get("decision") == "answer":
return (
'<section class="result-card result-abstain" dir="ltr" lang="en"><h2>English rendering is temporarily unavailable</h2><p>Please try again shortly.</p></section>',
"",
{"version": APP_VERSION, "query_language": "en", "translation_error": translation_error, "retrieval_executed": True},
)
diag = _diagnostics(result, elapsed)
diag["query_language"] = query_lang
diag["answer_language"] = query_lang
diag["query_direction"] = query_dir
diag["translation_layer_used"] = query_lang == "en"
if translation_error:
diag["translation_error"] = translation_error
return (
_answer_html(result, query_lang, translations),
_render_evidence(result, DISPLAY_EVIDENCE_COUNT, query_lang, translations),
diag,
)
def clear_ui():
return "", "", "", {}
CSS = r'''
:root {
--bg:#f7f9f8; --surface:#ffffff; --surface-2:#f2f6f4; --text:#17211d;
--muted:#68766f; --line:#dce5e0; --brand:#0f8a68; --brand-hover:#0b7559;
--danger:#8f3b45; --shadow:0 14px 40px rgba(20,42,34,.07);
}
html[data-huda-theme="dark"] {
--bg:#0c1411; --surface:#111d18; --surface-2:#16251f; --text:#f3f7f5;
--muted:#a5b3ad; --line:#293c34; --brand:#58cbaa; --brand-hover:#70d7ba;
--danger:#ff9ca8; --shadow:0 18px 50px rgba(0,0,0,.22); color-scheme:dark;
}
html[data-huda-theme="light"] { color-scheme:light; }
body, .gradio-container { background:var(--bg)!important; color:var(--text)!important; font-family:Calibri,Aptos,"Segoe UI",Tahoma,Arial,sans-serif!important; }
.gradio-container { max-width:none!important; padding:0!important; }
#huda-shell { max-width:1040px!important; margin:0 auto!important; padding:28px 22px 48px!important; }
#frontend_shell, #answer_output, #evidence_output { margin:0!important; padding:0!important; border:0!important; background:transparent!important; }
.huda-bridge, #diagnostics_json { display:none!important; }
footer { display:none!important; }
.huda-app { color:var(--text); }
.huda-app *, #answer_output *, #evidence_output * { box-sizing:border-box; }
.huda-app button, .huda-app textarea { font:inherit; }
.huda-topbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:54px; direction:inherit; }
.huda-brand { font-size:23px; font-weight:800; letter-spacing:-.25px; color:var(--text); }
.huda-controls { display:flex; gap:8px; }
.huda-control { min-width:78px; height:40px; padding:0 14px; border:1px solid var(--line); border-radius:10px; background:var(--surface); color:var(--text); cursor:pointer; font-weight:700; }
.huda-control:hover { border-color:var(--brand); }
.huda-main { max-width:860px; margin:0 auto; }
.huda-intro { margin-bottom:26px; }
.huda-intro h1 { margin:0; font-size:38px; line-height:1.22; letter-spacing:-.5px; color:var(--text)!important; }
.huda-intro p { margin:10px 0 0; color:var(--muted)!important; font-size:16px; line-height:1.7; }
.huda-question-wrap { background:var(--surface); border:1px solid var(--line); border-radius:18px; padding:18px; box-shadow:var(--shadow); }
.huda-question { width:100%; min-height:190px; resize:vertical; border:1px solid var(--line); border-radius:14px; background:var(--surface-2); color:var(--text)!important; padding:18px; font-size:18px; line-height:1.75; outline:none; caret-color:var(--brand); }
.huda-question::placeholder { color:var(--muted)!important; opacity:.8; }
.huda-question:focus { border-color:var(--brand); box-shadow:0 0 0 3px color-mix(in srgb,var(--brand) 18%,transparent); }
.huda-actions { display:flex; gap:10px; margin-top:12px; }
.huda-submit { flex:1; min-height:50px; border:0; border-radius:12px; background:var(--brand); color:#fff!important; font-weight:800; cursor:pointer; }
html[data-huda-theme="dark"] .huda-submit { color:#082019!important; }
.huda-submit:hover { background:var(--brand-hover); }
.huda-submit:disabled { opacity:.65; cursor:wait; }
.huda-clear { width:116px; min-height:50px; border:1px solid var(--line); border-radius:12px; background:var(--surface); color:var(--text)!important; font-weight:700; cursor:pointer; }
.huda-clear:hover { border-color:var(--brand); }
.result-card, .evidence-section { max-width:860px; margin:24px auto 0; }
.result-card { background:var(--surface); border:1px solid var(--line); border-radius:18px; padding:24px; box-shadow:var(--shadow); }
.result-card h2, .evidence-section h2 { margin:0 0 14px; color:var(--text)!important; font-size:23px; }
.result-card p { margin:6px 0 0; color:var(--muted)!important; line-height:1.7; }
.result-abstain { border-inline-start:4px solid var(--danger); }
.result-answer { border-inline-start:4px solid var(--brand); }
.answer-body { color:var(--text)!important; font-size:18px; line-height:1.9; white-space:pre-wrap; }
.answer-source { margin-top:18px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted)!important; font-size:14px; }
.section-title { margin-bottom:14px!important; }
.evidence-list { display:grid; gap:12px; }
.evidence-card { background:var(--surface); border:1px solid var(--line); border-radius:16px; padding:20px; color:var(--text); }
.evidence-card-head { display:flex; justify-content:space-between; align-items:flex-start; gap:14px; margin-bottom:10px; }
.evidence-card-head > div:first-child { display:flex; align-items:center; gap:8px; }
.evidence-index { display:inline-grid; place-items:center; width:28px; height:28px; border-radius:8px; background:var(--surface-2); color:var(--brand)!important; font-weight:800; }
.source-badge { color:var(--brand)!important; font-size:13px; font-weight:800; }
.evidence-source { color:var(--muted)!important; font-size:13px; text-align:end; }
.evidence-card h3 { margin:0 0 10px; color:var(--text)!important; font-size:18px; }
.evidence-passage { color:var(--text)!important; line-height:1.85; font-size:16px; white-space:pre-wrap; }
.source-original { margin-top:14px; border-top:1px solid var(--line); padding-top:10px; }
.source-original summary { color:var(--muted)!important; cursor:pointer; font-weight:700; }
.source-original[open] summary { margin-bottom:10px; }
.source-arabic { font-family:Calibri,Aptos,"Segoe UI",Tahoma,Arial,sans-serif!important; }
.i18n[hidden] { display:none!important; }
@media (max-width:720px) {
#huda-shell { padding:20px 14px 36px!important; }
.huda-topbar { margin-bottom:38px; }
.huda-brand { font-size:21px; }
.huda-control { min-width:64px; height:38px; padding:0 10px; }
.huda-intro h1 { font-size:31px; }
.huda-question-wrap { padding:14px; border-radius:16px; }
.huda-question { min-height:170px; font-size:17px; }
.huda-actions { flex-direction:column; }
.huda-clear { width:100%; }
.result-card { padding:20px; }
.evidence-card-head { flex-direction:column; }
.evidence-source { text-align:start; }
}
'''
JS = r'''
(() => {
const root = document.documentElement;
const KEY_LANG='hudanet-lang-v4', KEY_THEME='hudanet-theme-v4';
const $=(s,c=document)=>c.querySelector(s);
const $$=(s,c=document)=>Array.from(c.querySelectorAll(s));
const store={
get:k=>{try{return localStorage.getItem(k)}catch(e){return null}},
set:(k,v)=>{try{localStorage.setItem(k,v)}catch(e){}}
};
let busy=false;
let resultObserver=null;
function bridgeInput(){
const host=document.getElementById('question_bridge');
return host?.querySelector('textarea,input')||null;
}
function bridgeButton(id){
const host=document.getElementById(id);
if(!host)return null;
return host.tagName==='BUTTON'?host:(host.querySelector('button')||null);
}
function nativeSet(el,val){
if(!el)return;
const proto=el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;
const setter=Object.getOwnPropertyDescriptor(proto,'value')?.set;
if(setter)setter.call(el,String(val)); else el.value=String(val);
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
}
function language(){return root.dataset.hudaLang==='en'?'en':'ar'}
function theme(){return root.dataset.hudaTheme==='dark'?'dark':'light'}
function setText(el,val){if(el&&el.textContent!==val)el.textContent=val}
function applyI18n(){
const lang=language();
$$('.i18n').forEach(el=>{
const shouldHide=!el.classList.contains('i18n-'+lang);
if(el.hidden!==shouldHide)el.hidden=shouldHide;
});
const app=$('#huda-app-root');
if(app){app.dir=lang==='ar'?'rtl':'ltr';app.lang=lang}
const q=$('#huda_question_input');
if(q){
q.placeholder=lang==='ar'?'اكتب سؤالك عن الحج أو العمرة…':'Type your Hajj or Umrah question…';
q.setAttribute('aria-label',lang==='ar'?'سؤالك عن الحج أو العمرة':'Your Hajj or Umrah question');
}
setText($('#huda_lang_label'),lang==='ar'?'English':'العربية');
setText($('#huda_theme_label'),theme()==='dark'?(lang==='ar'?'فاتح':'Light'):(lang==='ar'?'داكن':'Dark'));
if(!busy)setText($('#huda_submit_label'),lang==='ar'?'إرسال':'Submit');
setText($('#huda_clear_label'),lang==='ar'?'مسح':'Clear');
}
function applyLang(lang){
root.dataset.hudaLang=lang==='en'?'en':'ar';
store.set(KEY_LANG,root.dataset.hudaLang);
applyI18n();
}
function applyTheme(t){
root.dataset.hudaTheme=t==='dark'?'dark':'light';
store.set(KEY_THEME,root.dataset.hudaTheme);
applyI18n();
}
function sync(){
const q=$('#huda_question_input');
nativeSet(bridgeInput(),q?.value||'');
}
function setBusy(value){
busy=!!value;
const button=$('#huda_submit');
if(button)button.disabled=busy;
setText($('#huda_submit_label'),busy?(language()==='ar'?'جارٍ البحث…':'Searching…'):(language()==='ar'?'إرسال':'Submit'));
}
function submit(){
if(busy)return;
const q=$('#huda_question_input');
if(!q)return;
sync();
const b=bridgeButton('send_bridge');
if(!b){console.error('HUDA-Net bridge submit button not found');return}
setBusy(true);
b.click();
window.setTimeout(()=>{if(busy)setBusy(false)},120000);
}
function clearAll(){
const q=$('#huda_question_input');
if(q){q.value='';q.focus()}
sync();
setBusy(false);
bridgeButton('clear_bridge')?.click();
}
function observeResults(){
if(resultObserver)return;
const answer=document.getElementById('answer_output');
if(!answer)return;
resultObserver=new MutationObserver(()=>{if(busy)setBusy(false)});
resultObserver.observe(answer,{childList:true,subtree:true,characterData:true});
}
function wireEvents(){
if(document.documentElement.dataset.hudaV4Wired==='1')return;
document.documentElement.dataset.hudaV4Wired='1';
document.addEventListener('click',e=>{
const t=e.target.closest('button');
if(!t)return;
if(t.id==='huda_lang_toggle'){e.preventDefault();applyLang(language()==='ar'?'en':'ar')}
else if(t.id==='huda_theme_toggle'){e.preventDefault();applyTheme(theme()==='dark'?'light':'dark')}
else if(t.id==='huda_submit'){e.preventDefault();submit()}
else if(t.id==='huda_clear'){e.preventDefault();clearAll()}
},true);
document.addEventListener('keydown',e=>{
if(e.target?.id==='huda_question_input'&&(e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();submit()}
},true);
}
function boot(attempt=0){
const app=$('#huda-app-root');
const q=$('#huda_question_input');
if(!app||!q){
if(attempt<120)window.setTimeout(()=>boot(attempt+1),100);
return;
}
const savedLang=store.get(KEY_LANG);
const savedTheme=store.get(KEY_THEME);
root.dataset.hudaLang=savedLang==='en'?'en':'ar';
root.dataset.hudaTheme=savedTheme==='light'||savedTheme==='dark'?savedTheme:(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
applyI18n();
wireEvents();
observeResults();
window.setTimeout(sync,150);
}
boot();
})();
'''
FRONTEND_HTML = r'''
<div class="huda-app" id="huda-app-root" dir="rtl" lang="ar">
<header class="huda-topbar">
<div class="huda-brand">HUDA-Net</div>
<div class="huda-controls">
<button type="button" class="huda-control" id="huda_lang_toggle"><span id="huda_lang_label">English</span></button>
<button type="button" class="huda-control" id="huda_theme_toggle"><span id="huda_theme_label">فاتح</span></button>
</div>
</header>
<main class="huda-main">
<section class="huda-intro">
<h1 class="i18n i18n-ar">اسأل HUDA-Net</h1>
<h1 class="i18n i18n-en" hidden>Ask HUDA-Net</h1>
<p class="i18n i18n-ar">إرشاد الحج والعمرة المستند إلى الشواهد.</p>
<p class="i18n i18n-en" hidden>Evidence-grounded Hajj and Umrah guidance.</p>
</section>
<section class="huda-question-wrap">
<textarea id="huda_question_input" class="huda-question" rows="6" maxlength="1200" dir="auto" autocomplete="off" spellcheck="true" placeholder="اكتب سؤالك عن الحج أو العمرة…"></textarea>
<div class="huda-actions">
<button type="button" class="huda-submit" id="huda_submit"><span id="huda_submit_label">إرسال</span></button>
<button type="button" class="huda-clear" id="huda_clear"><span id="huda_clear_label">مسح</span></button>
</div>
</section>
</main>
</div>
'''
with gr.Blocks(title="HUDA-Net") as demo:
with gr.Column(elem_id="huda-shell"):
gr.HTML(FRONTEND_HTML, elem_id="frontend_shell")
answer = gr.HTML("", elem_id="answer_output")
evidence = gr.HTML("", elem_id="evidence_output")
diagnostics = gr.JSON(value={}, elem_id="diagnostics_json", visible=False)
with gr.Column(elem_classes=["huda-bridge"]):
question_bridge = gr.Textbox(value="", elem_id="question_bridge", show_label=False)
send_bridge = gr.Button("submit", elem_id="send_bridge")
clear_bridge = gr.Button("clear", elem_id="clear_bridge")
outputs = [answer, evidence, diagnostics]
send_bridge.click(answer_question, [question_bridge], outputs, show_progress="minimal", concurrency_limit=1)
clear_bridge.click(clear_ui, None, [question_bridge, answer, evidence, diagnostics], queue=False)
try:
demo.queue(default_concurrency_limit=1, max_size=32)
except TypeError:
demo.queue()
demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True, share=False, ssr_mode=False, css=CSS, js=JS)