dakheel commited on
Commit
c853a1b
·
verified ·
1 Parent(s): 53470f7

feat: add modular generic retrieval and evidence synthesis pipeline

Browse files

Implemented HUDA-Net v37.0.0 modular generic architecture.

Key changes:
- Split query analysis, evidence processing, compatibility checks, consensus detection, ranking, and answer synthesis into separate modules.
- Added generic intent and constraint matching without question-specific hardcoded answers.
- Added logical evidence gates to prevent unrelated high-scoring results from building the final answer.
- Added multi-source answer synthesis with source attribution.
- Added automatic conflict detection between rulings and evidence clusters.
- Added detailed evidence inspection while keeping the final user-facing answer clear and professional.
- Added external JSON resources for semantic rules, evidence schema, ranking configuration, and answer templates.
- Added generic pipeline tests and validation reports.
- Updated Arabic dialect normalization and retrieval rules.
- Isolated conversation history from current-query retrieval.

DEPLOY_HUDANET_V37.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy HUDA-Net v37.0.0
2
+
3
+ 1. Upload `app.py`, `hudanet_dialects.json`, `hudanet_retrieval_rules.json`, and the entire `hudanet_core` directory to the Space root.
4
+ 2. Preserve the directory structure exactly. The JSON files inside `hudanet_core/resources` are required at startup.
5
+ 3. Keep the existing read-only `HF_TOKEN` Space secret.
6
+ 4. Run a Factory reboot.
7
+ 5. Confirm that the startup log reports HUDA-Net v37.0.0 and that the generic evidence self-test passes.
8
+
9
+ The old named-intent answer rules are intentionally removed. `hudanet_retrieval_rules.json` now contains an empty compatibility table; all decisions come from the generic modular library.
app.py CHANGED
@@ -1,6 +1,6 @@
1
  # -*- coding: utf-8 -*-
2
  """
3
- HUDA-Net Smart All-in-One v36.4.2 Academic Integrated — Gradio Stable
4
  =============================
5
  Deploy this file as app.py in a Hugging Face Space and add HF_TOKEN as a
6
  read-only Space secret.
@@ -28,6 +28,8 @@ from scipy import sparse
28
  from sklearn.feature_extraction.text import TfidfVectorizer
29
  import joblib
30
 
 
 
31
  import json
32
  from pathlib import Path
33
 
@@ -39,53 +41,85 @@ except Exception:
39
  _DIALECTS = {"interrogative_particles": [], "clitic_exceptions": [],
40
  "dialect_phrases": {}, "spelling_variants": {}, "en_spelling_variants": {}}
41
 
42
- # تحويل القوائم إلى أنماط regex مرة واحدة (ليس في كل استدعاء)
43
- _CLITIC_PATTERN = re.compile(
44
- r'(?:^|(?<=\s))'
45
- r'(' + '|'.join(re.escape(p) for p in sorted(
46
- _DIALECTS.get("interrogative_particles", []), key=len, reverse=True
47
- )) + r')'
48
- r'(?=[^\s])'
49
- ) if _DIALECTS.get("interrogative_particles") else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
- _CLITIC_EXCEPTIONS_SET = set(_DIALECTS.get("clitic_exceptions", []))
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
 
54
- def _apply_dialect_normalization(s: str) -> str:
55
- """
56
- حل جينيرك واحد يغطي:
57
- 1. استبدال العبارات اللهجية بالفصحى
58
- 2. فصل أدوات الاستفهام الملتصقة
59
- 3. تصحيح الأخطاء الإملائية الشائعة
60
-
61
- يُستدعى من norm_ar_ui و norm_ar. لا يحتاج تغيير في أي مكان آخر.
62
- """
63
- # ① استبدال العبارات اللهجية (الأطول أولاً لتجنب التطابق الجزئي)
64
- for dialect, fusha in sorted(
65
- _DIALECTS.get("dialect_phrases", {}).items(), key=lambda x: len(x[0]), reverse=True
66
- ):
67
- s = s.replace(dialect, fusha)
68
-
69
- # ② فصل أدوات الاستفهام الملتصقة (ماحكم → ما حكم)
70
- if _CLITIC_PATTERN:
71
- def _split_clitic(m):
72
- particle = m.group(1)
73
- # لا تفصل إذا الكلمة الكاملة استثناء (ماذا، هلال، إلخ)
74
- # نشوف الكلمة الكاملة بعد الأداة
75
- start = m.end()
76
- rest = s[start:start + 12] # أقصى طول كلمة عربية تقريباً
77
- word_end = re.match(r'[^\s]*', rest)
78
- full_word = particle + (word_end.group() if word_end else "")
79
- if full_word in _CLITIC_EXCEPTIONS_SET:
80
- return m.group(0) # لا تفصل
81
- return particle + ' '
82
- s = _CLITIC_PATTERN.sub(_split_clitic, s)
83
-
84
- # ③ تصحيح الأخطاء الإملائية
85
- for wrong, correct in _DIALECTS.get("spelling_variants", {}).items():
86
- s = s.replace(wrong, correct)
87
 
88
- return _SPACE.sub(" ", s).strip()
 
 
 
 
 
 
 
 
89
 
90
  try:
91
  from IPython.display import display
@@ -150,7 +184,7 @@ def _zerogpu_registration_only():
150
 
151
  _download_hudanet_private_datasets()
152
 
153
- VERSION = "36.4.2"
154
  CONFIG = {
155
  "INPUT_ROOT": "/kaggle/input",
156
  "WORK_ROOT": "/tmp/hudanet_v27",
@@ -596,7 +630,7 @@ def norm_ar_base(v: Any) -> str:
596
  def norm_en(v: Any) -> str:
597
  s = unicodedata.normalize("NFKC", clean_display(v)).casefold()
598
  s = re.sub(r"[^a-z0-9\s'-]", " ", s)
599
- for a,b in EN_REPL.items(): s = re.sub(rf"\b{re.escape(a)}\b", b, s)
600
  return _SPACE.sub(" ", s).strip()
601
 
602
  def has_ar(v: Any) -> bool: return bool(_AR.search(str(v or "")))
@@ -2922,6 +2956,7 @@ def smart_all_in_one() -> Dict[str, Any]:
2922
 
2923
 
2924
  HUDANET_SMART_RESULT = smart_all_in_one()
 
2925
 
2926
 
2927
  # ======================== PROFESSIONAL BILINGUAL UI ========================
@@ -2939,7 +2974,7 @@ import pandas as pd
2939
  from scipy import sparse
2940
  import joblib
2941
 
2942
- UI_VERSION = "36.4.2"
2943
  UI_CONFIG = {
2944
  "INPUT_ROOT": "/kaggle/input",
2945
  "RUNTIME_DATASET_SLUG": "hudanet-bilingual-certified-runtime",
@@ -3218,7 +3253,7 @@ def normalize_filter_payload(
3218
  "sort_by": sort_value if sort_value in FILTER_SORT_VALUES else "relevance",
3219
  "evidence_count": count,
3220
  "min_score": round(score, 3),
3221
- "use_context": _filter_bool(use_context, True),
3222
  "compare": _filter_bool(compare, True),
3223
  "diverse": _filter_bool(diverse, True),
3224
  }
@@ -3246,7 +3281,7 @@ def norm_ar_ui(v: Any) -> str:
3246
  def norm_en_ui(v: Any) -> str:
3247
  s=unicodedata.normalize("NFKC",clean_ui(v)).casefold()
3248
  s=re.sub(r"[^a-z0-9\s'-]"," ",s)
3249
- for a,b in EN_REPL.items(): s=re.sub(rf"\b{re.escape(a)}\b",b,s)
3250
  return _SPACE.sub(" ",s).strip()
3251
 
3252
 
@@ -3497,65 +3532,25 @@ def _contains_any_phrase_ui(normalized_text: str, phrases: Sequence[Any], lang:
3497
 
3498
 
3499
  def extract_case_facts_ui(query:str,lang:str)->dict:
3500
- raw=clean_ui(query); n=norm_ar_ui(raw) if lang=="ar" else norm_en_ui(raw)
3501
- facts={}; missing=[]
3502
- if lang=="ar":
3503
- maps={
3504
- "rite":[("عمرة",["عمرة","العمره"]),("حج",["حج","الحج"])],
3505
- "mode":[("تمتع",["تمتع","متمتع"]),("قران",["قران","قارن"]),("إفراد",["افراد","مفرد"])],
3506
- "gender":[("امرأة",["امرأة","امراه","حائض","نفاس"]),("رجل",["رجل","ذكر"])],
3507
- "intent":[("عمدًا",["عمدا","متعمد"]),("نسيانًا",["نسي","نسيت","ناسيا"]),("جهلًا",["جاهل","لم اعلم","لا اعلم"]),("إكراهًا",["مكره","اجبر"])],
3508
- "ihram":[("لم يُحرم",["لم يحرم","بلا احرام","دون احرام"]),("مُحرم",[هو محرم","حال الاحرام","في الاحرام"]),("بعد التحلل",["بعد التحلل"]),("قبل التحلل",["قبل التحلل"])],
3509
- "time":[("قبل الفجر",["قبل الفجر"]),("بعد الفجر",["بعد الفجر"]),("يوم النحر",["يوم النحر"]),("قبل الوقوف",["قبل عرفة","قبل الوقوف"]),("بعد الوقوف",["بعد عرفة","بعد الوقوف"])],
3510
- "place":[("الميقات",["ميقات","المواقيت"]),("مكة",["مكة","الحرم"]),("عرفة",["عرفة","عرفات"]),("منى",["منى"]),("مزدلفة",["مزدلفة"])],
3511
- "ability":[("يستطيع الرجوع",["يستطيع الرجوع","يمكنه الرجوع","قادر على الرجوع"]),("لا يستطيع الرجوع",["لا يستطيع الرجوع","تعذر الرجوع","غير قادر على الرجوع"])],
3512
- }
3513
- else:
3514
- maps={
3515
- "rite":[("Umrah",["umrah"]),("Hajj",["hajj"])],
3516
- "mode":[("Tamattu",["tamattu"]),("Qiran",["qiran"]),("Ifrad",["ifrad"])],
3517
- "gender":[("Woman",["woman","female","menstruating"]),("Man",["man","male"])],
3518
- "intent":[("Intentional",["intentionally","deliberately"]),("Forgotten",["forgot","forgetfully"]),("Unaware",["ignorant","did not know","unaware"]),("Compelled",["forced","compelled"])],
3519
- "ihram":[("No ihram",["without ihram","did not enter ihram"]),("In ihram",["while in ihram","in ihram"]),("After tahallul",["after tahallul"]),("Before tahallul",["before tahallul"])],
3520
- "time":[("Before dawn",["before dawn"]),("After dawn",["after dawn"]),("Day of sacrifice",["day of sacrifice"]),("Before Arafah",["before arafah"]),("After Arafah",["after arafah"])],
3521
- "place":[("Miqat",["miqat","meeqat"]),("Makkah",["makkah","haram"]),("Arafah",["arafah","arafat"]),("Mina",["mina"]),("Muzdalifah",["muzdalifah"])],
3522
- "ability":[("Can return",["can return","able to return"]),("Cannot return",["cannot return","unable to return"])],
3523
- }
3524
- for field,choices in maps.items():
3525
- for value,terms in choices:
3526
- if _contains_any_phrase_ui(n, terms, lang): facts[field]=value; break
3527
- action_terms=_explain_tokens(raw,lang)[:6]
3528
- if action_terms: facts["action"]=" · ".join(action_terms)
3529
- # Missing details are requested only when they can plausibly change a ruling.
3530
- if _contains_any_phrase_ui(n, (["ميقات","المواقيت"] if lang=="ar" else ["miqat","meeqat"]), lang) and "ability" not in facts:
3531
- missing.append("القدرة على الرجوع إلى الميقات" if lang=="ar" else "ability to return to the miqat")
3532
- if _contains_any_phrase_ui(n, (["نسي","نسيت","ترك","فعل","جاوز","تجاوز"] if lang=="ar" else ["forgot","left","did","passed"]), lang) and "intent" not in facts:
3533
- missing.append("هل وقع الفعل عمدًا أم نسيانًا أو جهلًا" if lang=="ar" else "whether it was intentional, forgotten, or due to lack of knowledge")
3534
- if _contains_any_phrase_ui(n, (["شعر","ظفر","عطر","تعطر","استعمال الطيب","لبس","صيد","محظور"] if lang=="ar" else ["hair","nail","perfume","clothing","hunting","prohibition"]), lang) and "ihram" not in facts:
3535
- missing.append("هل كان الشخص محرمًا وقت الفعل" if lang=="ar" else "whether the person was in ihram at the time")
3536
- return {"facts":facts,"missing":list(dict.fromkeys(missing)),"labels":CASE_FACT_LABELS[lang]}
3537
-
3538
- RULING_PATTERNS={
3539
- "ar":[
3540
- ("pillar",r"\bركن\b"),("condition",r"\bشرط\b"),
3541
- ("obligatory",r"\b(?:واجب|يجب|يلزم|وجوب|لازم)\b"),
3542
- ("prohibited",r"\b(?:حرام|يحرم|محظور)\b|\bلا\s+يجوز\b"),
3543
- ("recommended",r"\b(?:مستحب|سنة|مندوب)\b"),
3544
- ("disliked",r"\b(?:مكروه|كراهة)\b"),
3545
- ("permissible",r"\b(?:جائز|يجوز|مباح)\b"),
3546
- ("remedy",r"\b(?:دم|فدية|كفارة)\b"),
3547
- ],
3548
- "en":[
3549
- ("pillar",r"\bpillar\b"),("condition",r"\bcondition\b"),
3550
- ("obligatory",r"\b(?:obligatory|required|must)\b"),
3551
- ("prohibited",r"\b(?:prohibited|forbidden)\b|\bnot\s+permissible\b"),
3552
- ("recommended",r"\b(?:recommended|sunnah)\b"),
3553
- ("disliked",r"\b(?:disliked|makruh)\b"),
3554
- ("permissible",r"\b(?:permissible|allowed)\b"),
3555
- ("remedy",r"\b(?:fidyah|sacrifice|expiation)\b"),
3556
- ],
3557
- }
3558
- INCOMPATIBLE={frozenset(("prohibited","permissible")),frozenset(("obligatory","recommended")),frozenset(("pillar","recommended")),frozenset(("condition","recommended"))}
3559
 
3560
  def canonical_ruling_ui(text:str,lang:str)->list[str]:
3561
  n=norm_ar_ui(text) if lang=="ar" else norm_en_ui(text)
@@ -3571,6 +3566,8 @@ RULING_FILTER_LABELS = {
3571
  "ar": {
3572
  "pillar": "ركن",
3573
  "condition": "شرط",
 
 
3574
  "obligatory": "واجب أو لازم",
3575
  "prohibited": "محرم أو غير جائز",
3576
  "recommended": "مستحب أو سنة",
@@ -3582,6 +3579,8 @@ RULING_FILTER_LABELS = {
3582
  "en": {
3583
  "pillar": "Pillar",
3584
  "condition": "Condition",
 
 
3585
  "obligatory": "Obligatory or required",
3586
  "prohibited": "Prohibited",
3587
  "recommended": "Recommended or Sunnah",
@@ -3595,59 +3594,76 @@ RULING_FILTER_KEYS = tuple(RULING_FILTER_LABELS["en"].keys())
3595
 
3596
 
3597
 
3598
- def analyze_direct_answer_intent_ui(query: Any, lang: str) -> dict:
3599
- """Detect the exact requested act, timing, and consequence before ranking sources."""
3600
- qn = norm_ar_ui(query) if lang == "ar" else norm_en_ui(query)
3601
- ar = lang == "ar"
3602
 
3603
- if ar:
3604
- first_tahallul = "التحلل الاول" in qn or "اول تحلل" in qn
3605
- before_first = first_tahallul and any(x in qn for x in ("قبل التحلل", "قبل التحلل الاول", "قبل اول تحلل"))
3606
- hair_action = any(x in qn for x in ("قص الشعر", "قص شعر", "حلق الشعر", "حلق شعر", "تقصير الشعر", "تقصير شعر", "قص من شعره", "حلق راسه"))
3607
- intercourse_action = any(x in qn for x in ("جامع", "الجماع", "وطئ", "الوطء", "واقع زوجته", "مباشرة النساء"))
3608
- else:
3609
- first_tahallul = "first tahallul" in qn or "first release" in qn
3610
- before_first = first_tahallul and any(x in qn for x in ("before the first", "before first", "prior to first"))
3611
- hair_action = any(x in qn for x in ("cutting hair", "cut hair", "shaving hair", "shave hair", "shortening hair", "shorten hair"))
3612
- intercourse_action = any(x in qn for x in ("intercourse", "sexual relations", "sexual intercourse", "had relations", "relations with his wife"))
3613
-
3614
- intents = {
3615
- "first_tahallul": first_tahallul,
3616
- "hair_before_first_tahallul": bool(first_tahallul and before_first and hair_action),
3617
- "intercourse_before_first_tahallul": bool(first_tahallul and before_first and intercourse_action),
3618
- "generic_first_tahallul": bool(first_tahallul and not hair_action and not intercourse_action),
3619
- "missed_arafah": (("عرفة" in qn or "عرفات" in qn) and any(x in qn for x in ("فاته", "فات", "لم يدرك", "لم يقف"))) if ar else (("arafah" in qn or "arafat" in qn) and any(x in qn for x in ("missed", "misses", "did not catch", "failed to stand"))),
3620
- "arafah_end_time": (("عرفة" in qn or "عرفات" in qn) and any(x in qn for x in ("متى ينتهي", "نهاية وقت", "الى متى", "آخر وقت"))) if ar else (("arafah" in qn or "arafat" in qn) and any(x in qn for x in ("when does", "end", "until what time", "last time"))),
3621
- "jamrat_start_time": (("جمرة العقبة" in qn or "رمي العقبة" in qn) and any(x in qn for x in ("متى يبدأ", "بداية وقت", "وقت الرمي"))) if ar else (("jamrat al-aqabah" in qn or "jamrat al aqabah" in qn or "aqabah" in qn) and any(x in qn for x in ("when does", "begin", "start", "starting time"))),
3622
- "miqat_remedy": (("ميقات" in qn or "المواقيت" in qn) and any(x in qn for x in ("تجاوز", "جاوز", "مر", "بلا احرام", "دون احرام"))) if ar else (("miqat" in qn or "meeqat" in qn) and any(x in qn for x in ("passed", "passing", "without ihram", "did not enter ihram"))),
3623
- "miqat_after_makkah": (("ميقات" in qn and "مكة" in qn) if ar else ("miqat" in qn and "makkah" in qn)),
3624
- "tawaf_wada_before_leave": (("طواف الوداع" in qn and any(x in qn for x in ("قبل ان اغادر", "قبل مغادرة", "ما زلت في مكة", "تذكرت قبل"))) if ar else (("tawaf al-wada" in qn or "farewell tawaf" in qn) and any(x in qn for x in ("before leaving", "before i leave", "still in makkah", "remembered before")))),
3625
- "tawaf_doubt": (("طواف" in qn and "شك" in qn and any(x in qn for x in ("عدد", "اشواط", "شوط"))) if ar else ("tawaf" in qn and ("doubt" in qn or "unsure" in qn) and any(x in qn for x in ("circuits", "rounds", "number")))),
3626
- "menstruating_tawaf_necessity": (("طواف" in qn and ("حائض" in qn or "حيض" in qn) and ("ضرورة" in qn or "تعذر" in qn)) if ar else ("tawaf" in qn and ("menstruating" in qn or "menstruation" in qn) and ("necessity" in qn or "cannot wait" in qn or "cannot return"))),
3627
- "tamattu_sacrifice": (("تمتع" in qn and any(x in qn for x in ("هدي", "دم", "ذبح"))) if ar else ("tamattu" in qn and any(x in qn for x in ("sacrifice", "hady", "blood sacrifice")))),
3628
- "hajj_modes_comparison": (
3629
- all(x in qn for x in ("تمتع", "قران", "افراد")) and any(x in qn for x in ("فرق", "الفرق", "ما الفرق"))
3630
- ) if ar else (
3631
- ("tamattu" in qn or "tamatu" in qn) and "qiran" in qn and "ifrad" in qn and any(x in qn for x in ("difference", "compare", "distinguish"))
3632
- ),
3633
- "jamarat_before_zawal": (
3634
- ("رمي" in qn and ("جمرات" in qn or "الجمرات" in qn) and "زوال" in qn)
3635
- ) if ar else (
3636
- ("stoning" in qn or "jamarat" in qn) and any(x in qn for x in ("before zawal", "before midday", "before noon"))
3637
- ),
3638
- "ihsar_prevented_hajj": (
3639
- any(x in qn for x in ("احصر", "الاحصار", "منع من اتمام الحج", "منع من اكمال الحج", "تعذر عليه اتمام الحج"))
3640
- ) if ar else (
3641
- "hajj" in qn and any(x in qn for x in ("prevented from completing", "prevented from finishing", "obstructed from completing", "ihsar", "blocked from completing"))
3642
- ),
3643
- }
3644
- intents["requires_operational_answer"] = any(
3645
- value for key, value in intents.items()
3646
- if key not in {"miqat_after_makkah", "first_tahallul"}
3647
  )
3648
- return intents
3649
 
3650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3651
  def _direct_source_text_ui(source: Mapping[str,Any], lang: str) -> str:
3652
  normalizer = norm_ar_ui if lang == "ar" else norm_en_ui
3653
  values = []
@@ -3658,132 +3674,26 @@ def _direct_source_text_ui(source: Mapping[str,Any], lang: str) -> str:
3658
  return normalizer(" ".join(values))
3659
 
3660
 
3661
- def direct_intent_diagnostics_ui(source: Mapping[str,Any], query: Any, lang: str) -> dict:
3662
- """Return exact action/timing/consequence diagnostics for one candidate source."""
3663
- intents = analyze_direct_answer_intent_ui(query, lang)
3664
- if not intents.get("requires_operational_answer"):
3665
- return {"passed": source_has_usable_answer_ui(source), "required": [], "missing": [], "conflicts": []}
3666
 
3667
- text = _direct_source_text_ui(source, lang)
3668
- ar = lang == "ar"
3669
- required, missing, conflicts = [], [], []
3670
-
3671
- def has(tokens):
3672
- return any(token in text for token in tokens)
3673
-
3674
- def require(label, passed):
3675
- required.append(label)
3676
- if not passed:
3677
- missing.append(label)
3678
- return passed
3679
-
3680
- if intents["hair_before_first_tahallul"]:
3681
- hair = has(("شعر", "حلق", "تقصير", "قص الشعر") if ar else ("hair", "shav", "shorten", "cutting hair"))
3682
- consequence = has(("محظور", "يحرم", "لا يجوز", "فدية", "دم", "مد", "شاة") if ar else ("prohibited", "not permissible", "fidyah", "sacrifice", "mudd", "dam", "penalty"))
3683
- intercourse_only = has(("جامع", "جماع", "وطء") if ar else ("intercourse", "sexual relations")) and not hair
3684
- stoning_only = has(("رمي جمرة", "سبع حصيات") if ar else ("stoning jamrat", "seven pebbles")) and not hair
3685
- require("hair_act", hair)
3686
- require("hair_ruling_or_consequence", consequence)
3687
- if intercourse_only:
3688
- conflicts.append("intercourse_evidence_for_hair_question")
3689
- if stoning_only:
3690
- conflicts.append("stoning_evidence_for_hair_question")
3691
-
3692
- elif intents["intercourse_before_first_tahallul"]:
3693
- intercourse = has(("جامع", "جماع", "وطء", "واقع زوجته") if ar else ("intercourse", "sexual relations", "had relations"))
3694
- before_timing = has(("قبل التحلل", "قبل ان يتحلل", "قبل رمي", "قبل التحلل الاول") if ar else ("before first tahallul", "before the first tahallul", "before release", "before stoning"))
3695
- consequence_groups = [
3696
- has(("فسد حجه", "يفسد الحج", "بطل الحج") if ar else ("invalidates hajj", "hajj is invalid", "pilgrimage is invalid")),
3697
- has(("يمضي في فاسده", "يتم مناسكه", "يمضي في الحج") if ar else ("continue the rites", "complete the rites", "continue hajj")),
3698
- has(("يقضي", "يحج من قابل", "العام القادم") if ar else ("make it up", "perform hajj the following year", "next year")),
3699
- has(("بدنة", "دم", "هدي", "ذبح") if ar else ("camel", "sacrifice", "hady", "slaughter")),
3700
- ]
3701
- consequence_count = sum(bool(x) for x in consequence_groups)
3702
- stoning_only = has(("ترمى بسبع", "سبع حصيات", "رمي جمرة العقبة") if ar else ("seven pebbles", "stoning jamrat al-aqabah")) and not intercourse
3703
- require("intercourse_act", intercourse)
3704
- require("before_first_tahallul_timing", before_timing or consequence_groups[0])
3705
- require("principal_consequences", consequence_count >= 2)
3706
- if stoning_only:
3707
- conflicts.append("stoning_instructions_for_intercourse_question")
3708
-
3709
- elif intents["generic_first_tahallul"]:
3710
- hajj_context = has(("الحج", "يوم النحر", "ينحر", "نحر", "هدي", "حل له كل شيء إلا النساء") if ar else ("hajj", "day of sacrifice", "sacrifice", "slaughter", "hady", "everything except women"))
3711
- acts = sum(1 for token in (("رمي", "حلق", "تقصير", "طواف الافاضة", "ينحر", "نحر") if ar else ("stoning", "shaving", "shortening", "tawaf al-ifadah", "sacrifice", "slaughter")) if token in text)
3712
- umrah_only = has(("عمرة", "سعي") if ar else ("umrah", "sai")) and not has(("يوم النحر",) if ar else ("day of sacrifice",))
3713
- require("hajj_context", hajj_context)
3714
- require("first_tahallul_act", acts >= 1)
3715
- if umrah_only:
3716
- conflicts.append("umrah_release_not_hajj_first_tahallul")
3717
-
3718
- if intents["missed_arafah"]:
3719
- require("exit_by_umrah", has(("عمرة",) if ar else ("umrah",)))
3720
- require("repeat_hajj", has(("قابل", "العام القادم", "يقضي") if ar else ("following year", "next year", "make up", "performs hajj")))
3721
- require("sacrifice", has(("دم", "هدي", "ذبح") if ar else ("sacrifice", "hady", "slaughter")))
3722
-
3723
- if intents["arafah_end_time"]:
3724
- require("explicit_arafah_end_time", has(("فجر يوم النحر", "طلوع الفجر من يوم النحر", "الى الفجر", "حتى الفجر") if ar else ("dawn of the day of sacrifice", "until dawn", "fajr of the day of sacrifice")))
3725
-
3726
- if intents["jamrat_start_time"]:
3727
- act = has(("رمي جمرة العقبة", "رمي العقبة") if ar else ("stoning jamrat al-aqabah", "stone jamrat al-aqabah", "aqabah stoning"))
3728
- time = has(("نصف الليل", "بعد الفجر", "طلوع الشمس", "يبدأ وقت") if ar else ("midnight", "after dawn", "sunrise", "stoning time begins", "begins at"))
3729
- require("jamrat_al_aqabah_act", act)
3730
- require("explicit_start_time", time)
3731
-
3732
- if intents["miqat_remedy"]:
3733
- require("miqat_remedy", has(("يرجع", "الرجوع", "يعود", "دم", "ذبح") if ar else ("return", "go back", "sacrifice", "slaughter")))
3734
-
3735
- if intents["tawaf_wada_before_leave"]:
3736
- require("tawaf_before_departure", has(("قبل الخروج", "قبل ان يخرج", "آخر عهده", "يطوف للوداع") if ar else ("before leaving", "before departure", "last act", "bids farewell")))
3737
-
3738
- if intents["tawaf_doubt"]:
3739
- require("build_on_certainty", has(("اليقين", "الاقل", "يبني على", "اقل العدد") if ar else ("certainty", "lesser number", "lower number", "builds on")))
3740
-
3741
- if intents["menstruating_tawaf_necessity"]:
3742
- require("operational_necessity_ruling", has(("تتحفظ", "التحفظ", "تطوف للضرورة", "تعذر التأخر", "تعذر الرجوع") if ar else ("take protective measures", "protect herself", "perform tawaf out of necessity", "cannot wait", "cannot return")))
3743
-
3744
- if intents["tamattu_sacrifice"]:
3745
- obligation = has(("يجب", "واجب", "يلزمه هدي", "عليه هدي") if ar else ("obligatory", "must offer", "owes a sacrifice", "required"))
3746
- exception_only = has(("اهل مكة", "اهل الحرم", "لا هدي عليهم") if ar else ("people of makkah", "people of haram", "do not owe a sacrifice")) and not obligation
3747
- require("general_tamattu_obligation", obligation)
3748
- if exception_only:
3749
- conflicts.append("exception_only_without_general_rule")
3750
-
3751
- if intents["hajj_modes_comparison"]:
3752
- tamattu = has(("تمتع",) if ar else ("tamattu", "tamatu"))
3753
- qiran = has(("قران",) if ar else ("qiran",))
3754
- ifrad = has(("افراد",) if ar else ("ifrad",))
3755
- definition_hits = sum((
3756
- has(("عمرة ثم", "يتحلل من العمرة", "عمرة مستقلة ثم حج") if ar else ("umrah then", "exits ihram after umrah", "separate umrah then hajj")),
3757
- has(("الحج والعمرة باحرام واحد", "جمع الحج والعمرة", "قرن الحج بالعمرة") if ar else ("hajj and umrah in one ihram", "combines hajj and umrah", "one ihram for hajj and umrah")),
3758
- has(("الحج وحده", "يحرم بالحج فقط") if ar else ("hajj alone", "hajj only")),
3759
- ))
3760
- require("all_three_hajj_modes", tamattu and qiran and ifrad)
3761
- require("mode_definitions", definition_hits >= 2)
3762
-
3763
- if intents["jamarat_before_zawal"]:
3764
- act = has(("رمي الجمرات", "جمرات التشريق") if ar else ("stoning the jamarat", "jamarat during tashriq", "stoning during the days of tashriq"))
3765
- direct_rule = has(("لا يجوز", "لا يصح", "قبل الوقت", "بعد الزوال") if ar else ("not permissible", "not valid", "before its time", "after zawal", "after midday"))
3766
- require("jamarat_tashriq_act", act)
3767
- require("direct_before_zawal_rule", direct_rule)
3768
-
3769
- if intents["ihsar_prevented_hajj"]:
3770
- obstruction = has(("احصر", "الاحصار", "محصر", "منع من اتمام النسك", "منع من الحج") if ar else ("ihsar", "obstructed", "prevented from completing", "prevented from finishing"))
3771
- remedy = has(("هدي", "ذبح", "يحلق", "يتحلل", "مكانه") if ar else ("sacrifice", "slaughter", "shave", "exit ihram", "release from ihram"))
3772
- death_or_proxy = has(("مات", "عجز عنه", "يقضى عنه") if ar else ("dies", "died", "becomes unable", "proxy performance", "completed on his behalf")) and not obstruction
3773
- require("ihsar_or_obstruction", obstruction)
3774
- require("ihsar_remedy", remedy)
3775
- if death_or_proxy:
3776
- conflicts.append("death_or_proxy_record_for_ihsar_question")
3777
-
3778
- passed = not missing and not conflicts and source_has_usable_answer_ui(source)
3779
  return {
3780
- "passed": passed,
3781
- "required": required,
3782
- "missing": list(dict.fromkeys(missing)),
3783
- "conflicts": list(dict.fromkeys(conflicts)),
 
 
3784
  }
3785
 
3786
-
3787
  def source_satisfies_direct_intent_ui(source: Mapping[str,Any], query: Any, lang: str) -> bool:
3788
  return bool(direct_intent_diagnostics_ui(source, query, lang).get("passed"))
3789
 
@@ -3813,68 +3723,11 @@ def answer_satisfies_direct_intent_ui(answer: Any, query: Any, lang: str) -> boo
3813
 
3814
 
3815
  def _direct_intent_bonus_ui(source: Mapping[str,Any], query: Any, lang: str) -> float:
3816
- intents = analyze_direct_answer_intent_ui(query, lang)
3817
- if not intents.get("requires_operational_answer"):
3818
- return 0.0
3819
- text = _direct_source_text_ui(source, lang)
3820
- ar = lang == "ar"
3821
- diag = direct_intent_diagnostics_ui(source, query, lang)
3822
- bonus = 1.60 if diag.get("passed") else -2.25
3823
- if intents["hair_before_first_tahallul"] and any(token in text for token in (("شعر", "حلق", "تقصير", "فدية") if ar else ("hair", "shav", "shorten", "fidyah"))):
3824
- bonus += 0.85
3825
- if intents["intercourse_before_first_tahallul"] and any(token in text for token in (("جامع", "جماع", "فسد", "بدنة") if ar else ("intercourse", "invalidates", "camel", "following year"))):
3826
- bonus += 0.90
3827
- if intents["missed_arafah"] and any(token in text for token in (("عمرة", "العام القادم", "دم") if ar else ("umrah", "following year", "sacrifice"))):
3828
- bonus += 0.70
3829
- if intents["generic_first_tahallul"] and any(token in text for token in (("يوم النحر", "رمي", "حلق") if ar else ("day of sacrifice", "stoning", "shaving"))):
3830
- bonus += 0.65
3831
- if intents["miqat_remedy"] and any(token in text for token in (("يرجع", "الرجوع") if ar else ("return", "go back"))):
3832
- bonus += 0.55
3833
- if intents["tamattu_sacrifice"] and any(token in text for token in (("يجب", "واجب") if ar else ("obligatory", "must"))):
3834
- bonus += 0.55
3835
- if intents["generic_first_tahallul"] and any(token in text for token in (("التحلل الاول", "متى يحصل التحلل الاول") if ar else ("first tahallul", "when first tahallul occurs"))):
3836
- bonus += 1.10
3837
- if intents["hajj_modes_comparison"] and all(token in text for token in (("تمتع", "قران", "افراد") if ar else ("tamattu", "qiran", "ifrad"))):
3838
- bonus += 1.20
3839
- if intents["jamarat_before_zawal"] and any(token in text for token in (("بعد الزوال", "لا يجوز", "لا يصح") if ar else ("after zawal", "after midday", "not permissible", "not valid"))):
3840
- bonus += 0.95
3841
- if intents["ihsar_prevented_hajj"] and any(token in text for token in (("احصار", "محصر", "احصر") if ar else ("ihsar", "obstructed", "prevented from completing"))):
3842
- bonus += 1.20
3843
- return bonus
3844
-
3845
 
3846
  def direct_query_expansion_ui(query: Any, lang: str) -> str:
3847
- intents = analyze_direct_answer_intent_ui(query, lang)
3848
- if lang == "ar":
3849
- if intents["hair_before_first_tahallul"]: return "ما حكم قص أو حلق الشعر قبل التحلل الأول من الحج وما الفدية أو الدم المترتب؟"
3850
- if intents["intercourse_before_first_tahallul"]: return "ماذا يلزم من جامع قبل التحلل الأول من الحج من فساد الحج والمضي فيه والقضاء والبدنة؟"
3851
- if intents["generic_first_tahallul"]: return "متى يحصل التحلل الأول من الحج؟"
3852
- if intents["hajj_modes_comparison"]: return "ما الفرق بين التمتع والقران والإفراد: تعريف كل نسك وكيفية الإحرام والتحلل والهدي؟"
3853
- if intents["jamarat_before_zawal"]: return "ما حكم رمي جمرات أيام التشريق قبل الزوال ومتى يبدأ وقت الرمي الصحيح؟"
3854
- if intents["ihsar_prevented_hajj"]: return "ما حكم المحصر الذي منع من إتمام الحج وما الذي يفعله من الهدي والتحلل؟"
3855
- if intents["missed_arafah"]: return "ماذا يلزم من فاته الوقوف بعرفة حتى طلع فجر يوم النحر؟"
3856
- if intents["arafah_end_time"]: return "إلى متى يمتد وقت الوقوف بعرفة ومتى ينتهي؟"
3857
- if intents["jamrat_start_time"]: return "متى يبدأ وقت رمي جمرة العقبة يوم النحر؟"
3858
- if intents["tawaf_wada_before_leave"]: return "من نسي طواف الوداع ثم تذكر قبل مغادرة مكة ماذا يفعل؟"
3859
- if intents["tawaf_doubt"]: return "من شك في عدد أشواط الطواف هل يبني على اليقين وهو الأقل؟"
3860
- if intents["menstruating_tawaf_necessity"]: return "ما حكم الحائض في طواف الإفاضة عند تعذر الانتظار والرجوع؟"
3861
- if intents["miqat_remedy"]: return "من تجاوز الميقات بلا إحرام هل يرجع إلى الميقات ومتى يلزمه الدم؟"
3862
- if intents["tamattu_sacrifice"]: return "هل هدي التمتع واجب على المتمتع من غير حاضري المسجد الحرام؟"
3863
- else:
3864
- if intents["hair_before_first_tahallul"]: return "What is the ruling and fidyah for cutting, shaving, or shortening hair before the first Tahallul of Hajj?"
3865
- if intents["intercourse_before_first_tahallul"]: return "What are the consequences of intercourse before the first Tahallul of Hajj, including invalidation, completion, make-up Hajj, and sacrifice?"
3866
- if intents["generic_first_tahallul"]: return "When does the first Tahallul of Hajj occur?"
3867
- if intents["hajj_modes_comparison"]: return "What are the practical differences between Tamattu, Qiran, and Ifrad, including ihram, Umrah, release, and sacrifice?"
3868
- if intents["jamarat_before_zawal"]: return "Is stoning the Jamarat during the Days of Tashriq valid before zawal, and when does the valid time begin?"
3869
- if intents["ihsar_prevented_hajj"]: return "What must an obstructed pilgrim prevented from completing Hajj do regarding sacrifice and release from ihram?"
3870
- if intents["missed_arafah"]: return "What must a pilgrim do after missing the standing at Arafah until dawn of the Day of Sacrifice?"
3871
- if intents["arafah_end_time"]: return "Until what time does the standing at Arafah remain valid, and when does its time end?"
3872
- if intents["jamrat_start_time"]: return "At what time does stoning Jamrat al-Aqabah begin on the Day of Sacrifice?"
3873
- if intents["tawaf_wada_before_leave"]: return "What should a pilgrim do after forgetting Tawaf al-Wada but remembering before leaving Makkah?"
3874
- if intents["tawaf_doubt"]: return "If a pilgrim doubts the number of Tawaf circuits, should the pilgrim build on certainty and the lesser number?"
3875
- if intents["menstruating_tawaf_necessity"]: return "What may a menstruating woman do for Tawaf al-Ifadah when she cannot wait or return?"
3876
- if intents["miqat_remedy"]: return "After passing the miqat without ihram, when must the pilgrim return and when is a sacrifice due?"
3877
- if intents["tamattu_sacrifice"]: return "Is the Tamattu sacrifice obligatory for a pilgrim who is not a resident of the Sacred Mosque area?"
3878
  return ""
3879
 
3880
  def merge_search_results_ui(primary: Mapping[str,Any], extra: Mapping[str,Any]) -> dict:
@@ -3914,41 +3767,13 @@ def merge_search_results_ui(primary: Mapping[str,Any], extra: Mapping[str,Any])
3914
 
3915
 
3916
  def direct_answer_preface_ui(query: Any, lang: str, support: Sequence[Mapping[str,Any]]) -> str:
3917
- intents = analyze_direct_answer_intent_ui(query, lang)
3918
- texts = [_direct_source_text_ui(src, lang) for src in support or []]
3919
- ar = lang == "ar"
3920
- if intents["tawaf_wada_before_leave"] and any(source_satisfies_direct_intent_ui(src, query, lang) for src in support or []):
3921
- return ("إذا تذكرت قبل مغادرة مكة، فطف طواف الوداع قبل خروجك واجعله آخر عهدك بالبيت. ما دمت ما زلت في مكة وتستطيع أداءه، فلا تخرج قبل أن تطوفه." if ar else "If you remember before leaving Makkah, perform Tawaf al-Wada before departure and make it your last act at the House. While you are still in Makkah and able to perform it, do not leave before completing it.")
3922
- if intents["miqat_remedy"]:
3923
- has_return = any(any(token in text for token in (("يرجع", "الرجوع", "يعود") if ar else ("return", "go back"))) for text in texts)
3924
- has_sacrifice = any(any(token in text for token in (("دم", "ذبح", "هدي") if ar else ("sacrifice", "slaughter", "hady"))) for text in texts)
3925
- if has_return and has_sacrifice:
3926
- return ("إن كنت تستطيع الرجوع إلى الميقات قبل إتمام النسك، فالواجب أن ترجع فتحرم منه. فإن لم ترجع وأحرمت من دونه، فيأتي حكم الدم في فرع عدم الرجوع بحسب الشواهد المسترجعة." if ar else "If you can return to the miqat before completing the rite, return and enter ihram from there. If you do not return and enter ihram from a point after it, the sacrifice ruling applies to that non-return branch according to the retrieved evidence.")
3927
- if intents["hajj_modes_comparison"]:
3928
- joined = " ".join(texts)
3929
- enough = all(token in joined for token in (("تمتع", "قران", "افراد") if ar else ("tamattu", "qiran", "ifrad")))
3930
- if enough:
3931
- return ("التمتع: يعتمر أولًا ثم يتحلل من العمرة، ثم يحرم بالحج في عامه. القران: يجمع الحج والعمرة في إحرام واحد ولا يتحلل بينهما. الإفراد: يحرم بالحج وحده. ويشترك التمتع والقران في وجوب الهدي على من استوفى شروطه، بخلاف الإفراد." if ar else "Tamattu: the pilgrim performs Umrah first, exits its ihram, then enters a separate ihram for Hajj in the same year. Qiran: Hajj and Umrah are combined in one ihram without release between them. Ifrad: the pilgrim enters ihram for Hajj alone. Tamattu and Qiran carry the sacrifice requirement when its conditions apply, unlike Ifrad.")
3932
- if intents["jamarat_before_zawal"]:
3933
- joined = " ".join(texts)
3934
- supported = any(token in joined for token in (("بعد الزوال", "قبل الوقت", "لا يجوز", "لا يصح") if ar else ("after zawal", "after midday", "before its time", "not permissible", "not valid")))
3935
- if supported:
3936
- return ("لا يصح رمي جمرات أيام التشريق قبل الزوال بحسب الشواهد المسترجعة؛ يبدأ وقتها بعد الزوال." if ar else "According to the retrieved evidence, stoning the Jamarat during the Days of Tashriq is not valid before zawal; its valid time begins after zawal.")
3937
  return ""
3938
 
3939
-
3940
  def filter_case_missing_for_query_ui(missing: Sequence[str], query: Any, lang: str) -> list[str]:
3941
- intents = analyze_direct_answer_intent_ui(query, lang)
3942
- if intents["tawaf_wada_before_leave"] or intents["arafah_end_time"] or intents["jamrat_start_time"] or intents["tawaf_doubt"] or intents["generic_first_tahallul"] or intents["hajj_modes_comparison"] or intents["jamarat_before_zawal"] or intents["ihsar_prevented_hajj"]:
3943
- return []
3944
- # v36.4.1 general-obligation intent softening
3945
- _qn = norm_ar_ui(query) if lang == "ar" else norm_en_ui(query)
3946
- _general_obligation = bool(re.search(r"ما\s+الواجب\s+على\s+من\s+(?:ترك|فعل|تجاوز|نسي|فات)", _qn)) if lang == "ar" else bool(re.search(r"what\s+(?:is\s+required|must|should)\b.*\b(?:after|if|when)\b.*\b(?:missing|omitting|passing|forgetting|left|leaving)\b", _qn))
3947
- if _general_obligation:
3948
- missing = [m for m in (missing or []) if not any(w in m for w in ("عمد", "نسيان", "جهل", "intentional", "forgotten", "knowledge"))]
3949
  return list(dict.fromkeys(missing or []))
3950
 
3951
-
3952
  def _answer_source_relevance_ui(source: Mapping[str,Any], query: str, lang: str) -> float:
3953
  """Rank already-supported sources by how directly their answer resolves the request."""
3954
  if not source_has_usable_answer_ui(source):
@@ -4006,49 +3831,54 @@ def _answer_source_relevance_ui(source: Mapping[str,Any], query: str, lang: str)
4006
 
4007
 
4008
  def rank_support_for_answer_ui(support: Sequence[Mapping[str,Any]], query: str, lang: str) -> list[dict]:
4009
- usable = [dict(src) for src in (support or []) if source_has_usable_answer_ui(src)]
4010
- return sorted(usable, key=lambda src: _answer_source_relevance_ui(src, query, lang), reverse=True)
4011
-
4012
 
4013
  def compose_answer_for_filter_preferences_ui(
4014
  engine, support: Sequence[Mapping[str,Any]], style: str, lang: str,
4015
  compare_sources: bool, consensus: Mapping[str,Any], query: str = "",
4016
  ) -> str:
4017
- """Select a direct usable answer before optional multi-source synthesis."""
4018
- ranked = rank_support_for_answer_ui(support, query, lang)
4019
- if not ranked:
4020
- return ""
4021
- if not compare_sources:
4022
- return engine._select_answer(ranked[0], style)
4023
- return compose_multi_source_answer_ui(engine, ranked, style, lang, consensus)
4024
-
4025
 
4026
- def analyze_source_consensus_ui(sources:Sequence[Mapping[str,Any]],lang:str)->dict:
4027
- rows=[]
4028
- books=set()
4029
- category_books=defaultdict(set)
4030
- seen_rows=set()
4031
- for src in sources:
4032
- bid=clean_ui(src.get("book_id","")) or clean_ui(src.get("book","")) or clean_ui(src.get("record_id",""))
4033
- if not bid:
4034
- continue
4035
- books.add(bid)
4036
- ruling=informative_ruling_ui(src.get("ruling",""),lang) or usable_output_text_ui(src.get("answer_short","")) or usable_output_text_ui(src.get("answer",""))
4037
- categories=canonical_ruling_ui(ruling,lang) if ruling else ["unspecified"]
4038
- for category in set(categories):
4039
- if category!="unspecified":
4040
- category_books[category].add(bid)
4041
- row_key=(bid, norm_ar_ui(ruling) if lang=="ar" else norm_en_ui(ruling))
4042
- if row_key not in seen_rows:
4043
- rows.append({"book_id":bid,"book":clean_ui(src.get("book","")),"ruling":ruling,"categories":categories})
4044
- seen_rows.add(row_key)
4045
- cats=Counter({category:len(book_ids) for category,book_ids in category_books.items()})
4046
- active=set(cats)
4047
- conflict_pairs=[sorted(x) for x in INCOMPATIBLE if x.issubset(active)]
4048
- majority=cats.most_common(1)[0] if cats else ("unspecified",0)
4049
- ratio=min(1.0,(majority[1]/max(1,len(books)))) if cats else 0.0
4050
- state="conflict" if conflict_pairs else ("agreement" if len(books)>=2 and ratio>=0.60 else ("mixed" if len(active)>1 else "insufficient"))
4051
- return {"state":state,"books":len(books),"categories":dict(cats),"majority":majority[0],"agreement_ratio":round(ratio,3),"conflict_pairs":conflict_pairs,"rows":rows[:8]}
 
 
 
 
 
 
 
 
4052
 
4053
  def render_case_facts_inline(case:Mapping[str,Any],lang:str)->str:
4054
  if not case or not case.get("facts"): return ""
@@ -5024,7 +4854,7 @@ class ProfessionalEvidenceEngine:
5024
  @staticmethod
5025
  def _cache_key(query: str, lang: str, filters: dict) -> tuple:
5026
  stable = json.dumps(filters or {}, ensure_ascii=False, sort_keys=True, default=str)
5027
- return (clean_ui(query), str(lang), stable)
5028
 
5029
  def _cache_get(self, key):
5030
  value = self._search_cache.get(key)
@@ -5479,19 +5309,19 @@ class ProfessionalEvidenceEngine:
5479
  return {"answer":prompt,"mode":"broad_query","security":decision,"exact":[],"related":[],"distant":[],"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":original,"case_facts":case,"consensus":{},"stats":{"latency":time.perf_counter()-started,"allowed_records":0,"allowed_books":0,"matched_books":0,"exact_count":0,"related_count":0,"distant_count":0,"neural_search_skipped":True}}
5480
  if decision["action"] in messages[lang]:
5481
  return {"answer":messages[lang][decision["action"]],"mode":decision["action"],"security":decision,"exact":[],"related":[],"distant":[],"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":clean_ui(query),"case_facts":case,"consensus":{},"stats":{"latency":time.perf_counter()-started,"allowed_records":0,"allowed_books":0,"matched_books":0,"exact_count":0,"related_count":0,"distant_count":0}}
 
 
5482
  effective=original
5483
- if filters.get("use_context",True) and previous_user:
5484
- n=norm_ar_ui(original) if ar else norm_en_ui(original)
5485
- starters=("وماذا","طيب","ولو","واذا","إذا","ماذا عن") if ar else ("what if","and if","how about","then what","what about")
5486
- if len(n.split())<=8 or any(n.startswith(norm_ar_ui(s) if ar else norm_en_ui(s)) for s in starters):
5487
- effective=previous_user+"\n"+("سؤال متابعة: " if ar else "Follow-up: ")+original
5488
  compact=compact_query_ui(original,lang); parts=split_multi_ui(compact,lang)
5489
  if len(parts)>1:
5490
  sub=[]
5491
  for part in parts:
5492
- result=self.search(part,lang,filters); best=result["exact"] or result["related"]
 
 
5493
  if not best: continue
5494
- consensus=analyze_source_consensus_ui(result["exact"]+result["related"],lang)
5495
  display_consensus=consensus if compare_sources else {}
5496
  sub_answer=compose_answer_for_filter_preferences_ui(self,result["exact"]+result["related"],filters.get("answer_style","detailed"),lang,compare_sources,display_consensus,part)
5497
  if not usable_output_text_ui(sub_answer):
@@ -5536,8 +5366,9 @@ class ProfessionalEvidenceEngine:
5536
  search=merge_search_results_ui(search,expanded_search)
5537
  search.setdefault("stats",{})["expanded_query"]=expanded_query
5538
  search=annotate_direct_intent_diagnostics_ui(search,intent_query,lang)
 
5539
  support=search["exact"] or search["related"]
5540
- consensus=analyze_source_consensus_ui(search["exact"]+search["related"],lang)
5541
  if not support:
5542
  if int(search.get("stats", {}).get("allowed_records", 0) or 0) == 0:
5543
  msg=("لا يوجد سجل يطابق اجتماع الفلاتر الحالية. هذا ليس فشلًا في البحث؛ بعض الاختيارات متعارضة. أزل فلترًا واحدًا أو استخدم إعادة الضبط." if ar else "No record matches the current filter intersection. This is not a retrieval failure; some selections conflict. Remove one filter or reset them.")
@@ -5564,8 +5395,9 @@ class ProfessionalEvidenceEngine:
5564
  collective_comparison=all(token in combined_text for token in (("تمتع","قران","افراد") if ar else ("tamattu","qiran","ifrad")))
5565
  if complete_support:
5566
  complete_operational_support=True
5567
- complete_ids={clean_ui(src.get("record_id","")) for src in complete_support}
5568
- ranked_support=complete_support+[src for src in ranked_support if clean_ui(src.get("record_id","")) not in complete_ids]
 
5569
  elif collective_comparison:
5570
  complete_operational_support=True
5571
  else:
@@ -5573,11 +5405,14 @@ class ProfessionalEvidenceEngine:
5573
  msg=(("لم أجد في الكتاب أو الكتب المحددة نصًا يجيب عن الفرع العملي المطلوب مباشرة. أزل فلتر الكتاب أو استخدم نطاق التغطية الواسعة." if selected_books else "وجدت شواهد قريبة من الموضوع، لكنها لا تجيب عن الفرع العملي المطلوب مباشرة، لذلك لن أعرض جوابًا ناقصًا." ) if ar else (("The selected book or books do not contain evidence that directly resolves the requested practical branch. Remove the book filter or use Wide Coverage." if selected_books else "Nearby evidence was found, but it does not directly resolve the requested practical branch, so an incomplete answer will not be presented.")))
5574
  mode=("selected_books_insufficient" if selected_books else "direct_answer_incomplete")
5575
  return {"answer":msg,"mode":mode,"security":decision,**search,"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":effective,"case_facts":case,"consensus":consensus if compare_sources else {},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
5576
- primary=ranked_support[0]; direct=float(primary.get("direct_probability",primary.get("score",0)) or 0); agreement=int(primary.get("retriever_agreement",0) or 0)
5577
- second=max([float(x.get("direct_probability",x.get("score",0)) or 0) for x in ranked_support[1:]]+[0.0]); margin=direct-second
5578
  exact_present=bool(search["exact"]); matched_books=int(search.get("stats",{}).get("matched_books",0))
5579
- uncertain=(not complete_operational_support and not exact_present and matched_books<int(UI_CONFIG["ABSTAIN_MIN_BOOKS"]) and (margin<float(UI_CONFIG["ABSTAIN_MARGIN"]) or agreement<2))
5580
- conflict_weak=(consensus.get("state")=="conflict" and not exact_present and not intents.get("requires_operational_answer"))
 
 
 
5581
  missing_critical=bool(case.get("missing")) and not exact_present and direct<0.78
5582
  if uncertain or conflict_weak:
5583
  missing=case.get("missing",[])
@@ -5588,7 +5423,9 @@ class ProfessionalEvidenceEngine:
5588
  else:
5589
  prompt=("النتائج القريبة متقاربة جدًا في الثقة ولا تكفي لبناء حكم آمن. وضّح الفعل والوقت وحالة الإحرام." if ar else "The closest candidates are too close in confidence to ground a safe ruling. Clarify the act, timing, and ihram status.")
5590
  return {"answer":prompt,"mode":"needs_context","security":decision,**search,"confidence":round(direct*100,1),"language":lang,"query":clean_ui(query),"effective_query":effective,"primary":primary,"case_facts":case,"consensus":consensus if compare_sources else {},"uncertainty":{"margin":margin,"agreement":agreement,"reason":"missing_or_conflicting_context"},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
5591
- answer=compose_answer_for_filter_preferences_ui(self,search["exact"]+search["related"],filters.get("answer_style","detailed"),lang,compare_sources,consensus if compare_sources else {},intent_query)
 
 
5592
  if not usable_output_text_ui(answer):
5593
  msg=("وجدت سجلات مرتبطة، لكن نصوص الإجابة المتاحة كانت قوالب ناقصة أو غير مترجمة، لذلك لن أعرضها كحكم. استخدم كتابًا آخر أو أزل الفلاتر." if ar else "Related records were found, but their available answers were incomplete templates or untranslated placeholders, so they will not be presented as a ruling. Try another book or remove the filters.")
5594
  return {"answer":msg,"mode":"placeholder_blocked","security":decision,**search,"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":effective,"primary":primary,"case_facts":case,"consensus":consensus if compare_sources else {},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
@@ -5632,85 +5469,50 @@ class ProfessionalEvidenceEngine:
5632
 
5633
 
5634
  def validate_answer_quality_v36_4() -> dict:
5635
- """Pure regressions for exact action matching, evidence safety, and context continuity."""
5636
- checks=[]
 
5637
  def add(name, passed, value=""):
5638
  checks.append({"name":name,"passed":bool(passed),"value":value})
5639
 
5640
- add("arabic_typo_tawaf_wada", norm_ar_ui("وش حكم من نسي طواف الداع")=="ما حكم من نسي طواف الوداع")
5641
- add("arabic_typo_talbiyah", "التلبية" in norm_ar_ui(يش حكم ترك اللبية بعد الإحرام"))
5642
- add("english_typo_pilgrim", "pilgrim" in norm_en_ui("When does a pigrim complete Hajj"))
5643
- add("english_typo_sacrifice", "sacrifice" in norm_en_ui("Day of Sacrfice"))
5644
- add("english_typo_required", "required" in norm_en_ui("What is requird after Arafah"))
5645
- add("english_typo_standing", "standing" in norm_en_ui("When does staning at Arafah end"))
5646
- add("english_typo_begin", "begin" in norm_en_ui("When does stoning begn"))
5647
-
5648
- placeholder="See the Arabic short answer extracted and summarized from the book."
5649
- add("placeholder_detected", contains_output_placeholder_ui(placeholder))
5650
- add("placeholder_suppressed", usable_output_text_ui(placeholder)=="")
5651
- add("evidence_placeholder_summary", contains_output_placeholder_ui("Arabic source excerpt/summary from the book."))
5652
- add("evidence_placeholder_exact_wording", contains_output_placeholder_ui("See the Arabic excerpt for the exact wording."))
5653
- add("generic_page_title_suppressed", usable_output_text_ui("Question about Tawaf rulings on source page 12.")=="")
5654
- valid="A mutamatti owes a sacrificial animal."
5655
- add("valid_answer_preserved", usable_output_text_ui(valid)==valid)
5656
- add("valid_answer_boilerplate_trimmed", usable_output_text_ui("If he passes it, he must return. This is the ruling stated in this section of the book.")=="If he passes it, he must return")
5657
-
5658
- facts=extract_case_facts_ui("طيب وإذا تذكرت قبل أن أغادر مكة؟","ar")
5659
- add("no_false_male_from_remembered", facts.get("facts",{}).get("gender")!="رجل", facts)
5660
- parts=split_multi_ui("What's the ruling on passing the miqat without ihram and what is the ruling on forgetting Tawaf al-Wada?","en")
5661
- add("english_multi_intent_contraction", len(parts)==2, parts)
5662
-
5663
- hair_query="What is the ruling on cutting hair before the first Tahallul?"
5664
- hair_source={"question":"Cutting hair in ihram","title":"Hair fidyah","chapter":"Fidyah","answer":"Cutting or shortening hair while still in ihram is prohibited and requires fidyah according to the amount removed.","evidence":"For cutting hair before release from ihram, fidyah is due."}
5665
- intercourse_source={"question":"Intercourse before first Tahallul","title":"Intercourse","chapter":"Hajj","answer":"Intercourse before the first Tahallul invalidates Hajj; the pilgrim continues the rites, performs Hajj the following year, and offers a camel.","evidence":"Before first Tahallul, intercourse invalidates Hajj and requires completion, make-up Hajj, and sacrifice."}
5666
- stoning_source={"question":"How to stone Jamrat al-Aqabah","title":"Stoning","chapter":"Hajj","answer":"Stone it with seven pebbles and say takbir after each pebble.","evidence":"The pilgrim stones Jamrat al-Aqabah with seven pebbles."}
5667
- generic_tahallul={"question":"When is first Tahallul achieved?","title":"First Tahallul","chapter":"Day of Sacrifice","answer":"In Hajj on the Day of Sacrifice, first Tahallul is achieved through the relevant acts such as stoning and shaving or shortening.","evidence":"After stoning and shaving on the Day of Sacrifice, first Tahallul occurs."}
5668
-
5669
- add("hair_exact_source_passes", source_satisfies_direct_intent_ui(hair_source,hair_query,"en"))
5670
- add("intercourse_source_rejected_for_hair", not source_satisfies_direct_intent_ui(intercourse_source,hair_query,"en"))
5671
- add("stoning_source_rejected_for_hair", not source_satisfies_direct_intent_ui(stoning_source,hair_query,"en"))
5672
- add("sacrifice_only_answer_rejected_for_hair", not answer_satisfies_direct_intent_ui("A sacrifice is due.",hair_query,"en"))
5673
-
5674
- intercourse_query="What is required after intercourse before the first Tahallul?"
5675
- add("intercourse_exact_source_passes", source_satisfies_direct_intent_ui(intercourse_source,intercourse_query,"en"))
5676
- add("stoning_source_rejected_for_intercourse", not source_satisfies_direct_intent_ui(stoning_source,intercourse_query,"en"))
5677
- add("generic_tahallul_not_intercourse_answer", not source_satisfies_direct_intent_ui(generic_tahallul,intercourse_query,"en"))
5678
- add("generic_first_tahallul_source_passes", source_satisfies_direct_intent_ui(generic_tahallul,"Which acts establish the first Tahallul of Hajj?","en"))
5679
-
5680
- missed={"question":"What should someone do if he misses Arafah?","title":"Missing Arafah","chapter":"Fidyah","answer":"Missing Arafah means missing Hajj; he exits through Umrah, performs Hajj the following year, and owes a sacrifice.","evidence":"Whoever misses Arafah exits by Umrah and returns next year with a sacrifice."}
5681
- add("missed_arafah_complete_source", source_satisfies_direct_intent_ui(missed,"What is requird if a pilgrim misses the standing at Arafah?","en"))
5682
-
5683
- wrong_tahallul={"question":"Hair in Umrah","title":"Umrah haircut","chapter":"Umrah","answer":"After Tawaf and Sai in Umrah, the pilgrim cuts the hair and exits ihram.","evidence":""}
5684
- add("umrah_hair_not_first_tahallul", not source_satisfies_direct_intent_ui(wrong_tahallul,hair_query,"en"))
5685
-
5686
- follow={"question":"Tawaf al-Wada","title":"Farewell Tawaf","chapter":"Departure","answer":"The pilgrim should perform Tawaf al-Wada before departure and make it the last act at the House.","evidence":""}
5687
- effective="ما حكم من نسي طواف الوداع؟\nسؤال متابعة: طيب وإذا تذكرت قبل أن أغادر مكة؟"
5688
- add("followup_before_leave_detected", analyze_direct_answer_intent_ui(effective,"ar").get("tawaf_wada_before_leave"))
5689
- add("followup_source_complete", source_satisfies_direct_intent_ui(follow,"What should a pilgrim who forgot Tawaf al-Wada do if remembered before leaving Makkah?","en"))
5690
-
5691
- add("conditions_boilerplate_trimmed", usable_output_text_ui("A direct ruling. The ruling is applied with the conditions and qualifications stated in that chapter.")=="A direct ruling")
5692
- add("record_extraction_template_blocked", usable_output_text_ui("This record was extracted from the Hajj section. The Arabic source excerpt is preserved in the Arabic fields. Source excerpt: text")=="")
5693
- add("comparison_intent_detected", analyze_direct_answer_intent_ui("What is the difference between Tamattu, Qiran, and Ifrad?","en").get("hajj_modes_comparison"))
5694
- add("before_zawal_intent_detected", analyze_direct_answer_intent_ui("ما حكم رمي الجمرات قبل الزوال في أيام التشريق؟","ar").get("jamarat_before_zawal"))
5695
- add("ihsar_intent_detected", analyze_direct_answer_intent_ui("What is the ruling when a pilgrim is prevented from completing Hajj?","en").get("ihsar_prevented_hajj"))
5696
- ihsar_source={"question":"Ihsar","title":"Obstructed pilgrim","chapter":"Obstruction","answer":"An obstructed pilgrim offers the required sacrifice and exits ihram.","evidence":"A pilgrim prevented from completing Hajj slaughters the sacrifice and releases from ihram."}
5697
- proxy_source={"question":"Died during Hajj","title":"Proxy completion","chapter":"Ability","answer":"The remaining rites are completed on behalf of one who died or became unable.","evidence":""}
5698
- add("ihsar_source_passes", source_satisfies_direct_intent_ui(ihsar_source,"What must a pilgrim prevented from completing Hajj do?","en"))
5699
- add("proxy_source_rejected_for_ihsar", not source_satisfies_direct_intent_ui(proxy_source,"What must a pilgrim prevented from completing Hajj do?","en"))
5700
-
5701
- consensus=analyze_source_consensus_ui([
5702
- {"book_id":"one","book":"One","ruling":"Obligatory","answer":""},
5703
- {"book_id":"one","book":"One","ruling":"Obligatory","answer":""},
5704
- {"book_id":"one","book":"One","ruling":"Obligatory","answer":""},
5705
- ],"en")
5706
- add("consensus_never_above_one", 0.0 <= float(consensus.get("agreement_ratio",0)) <= 1.0, consensus)
5707
- rendered=render_consensus_inline({"state":"agreement","books":1,"agreement_ratio":2.0},"en")
5708
- add("rendered_consensus_clamped", "200%" not in rendered and "100%" in rendered, rendered)
5709
-
5710
- failed=[x for x in checks if not x["passed"]]
5711
  if failed:
5712
- raise RuntimeError("HUDA-Net v36.4 answer-quality self-test failed: "+json.dumps(failed,ensure_ascii=False))
5713
- print(f"✅ HUDA-Net v36.4 answer-quality self-test passed: {len(checks)} checks")
5714
  return {"passed":True,"tested":len(checks),"checks":checks}
5715
 
5716
  def validate_specificity_guard_v33(engine: ProfessionalEvidenceEngine) -> dict:
@@ -5981,6 +5783,15 @@ def tier_card(source:dict,lang:str,number:int,user_query:str="") -> str:
5981
  direct_rejection=clean_ui(source.get("direct_intent_rejection",""))
5982
  if direct_rejection:
5983
  reason += ((" · سبب رفضه للجواب المباشر: " if ar else " · Direct-answer rejection: ")+direct_rejection)
 
 
 
 
 
 
 
 
 
5984
  score=max(0.0,min(1.0,float(source.get("score",0) or 0))); score_pct=score*100
5985
  is_quran=bool(ar and re.search(r'[﴿﷽]|قال الله|قوله تعالى',evidence))
5986
  citation_class="quran-ayah" if is_quran else ("citation-text" if ar else "source-text-en")
@@ -9250,8 +9061,8 @@ def create_professional_app():
9250
  ar_kinds=gr.CheckboxGroup(ar_opts["source_kinds"],value=[],label="مصدر السجل")
9251
 
9252
  with gr.Accordion("خيارات متقدمة",open=False):
9253
- gr.HTML(field_guide("استخدام سياق المحادثة","يفيد في الأسئلة القصيرة التابعة مثل: وماذا لو فعلت كذا؟","ar"))
9254
- ar_context=gr.Checkbox(value=True,label="استخدم السؤال السابق في أسئلة المتابعة")
9255
  gr.HTML(field_guide("مقارنة صيغ الأحكام","يعرض تنبيهًا عند اختلاف صياغة الحكم بين أقرب المصادر.","ar"))
9256
  ar_compare=gr.Checkbox(value=True,label="قارن صيغ الأحكام بين المصادر")
9257
  gr.HTML(field_guide("تمثيل جميع الكتب","يحتفظ المحرك بأفضل شاهد آمن من كل كتاب مسموح. هذا الضمان ثابت حتى لا يختفي أي كتاب بسبب ترتيب النتائج.","ar"))
@@ -9340,8 +9151,8 @@ def create_professional_app():
9340
  en_kinds=gr.CheckboxGroup(en_opts["source_kinds"],value=[],label="Record source")
9341
 
9342
  with gr.Accordion("Advanced options",open=False):
9343
- gr.HTML(field_guide("Conversation context","Helps short follow-up questions such as: What if I did it later?","en"))
9344
- en_context=gr.Checkbox(value=True,label="Use the previous question for follow-ups")
9345
  gr.HTML(field_guide("Compare ruling formulations","Shows a note when the closest sources use different ruling formulations.","en"))
9346
  en_compare=gr.Checkbox(value=True,label="Compare ruling formulations across sources")
9347
  gr.HTML(field_guide("Represent every book","Keeps the best safe item from every allowed book, even when its relevance is only distant.","en"))
@@ -9429,7 +9240,7 @@ def create_professional_app():
9429
 
9430
  reset_outputs_ar=[ar_books,ar_authors,ar_types,ar_madhhabs,ar_categories,ar_rulings,ar_kinds,ar_style,ar_mode,ar_sort,ar_count,ar_min,ar_context,ar_compare,ar_diverse]
9431
  reset_outputs_en=[en_books,en_authors,en_types,en_madhhabs,en_categories,en_rulings,en_kinds,en_style,en_mode,en_sort,en_count,en_min,en_context,en_compare,en_diverse]
9432
- defaults=([],[],[],[],[],[],[],"detailed","balanced","relevance",1,0,True,True,True)
9433
  ar_reset_event=ar_reset.click(lambda:defaults,None,reset_outputs_ar,queue=False)
9434
  en_reset_event=en_reset.click(lambda:defaults,None,reset_outputs_en,queue=False)
9435
  for event in (ar_reset_event,en_reset_event):
 
1
  # -*- coding: utf-8 -*-
2
  """
3
+ HUDA-Net Modular Generic Evidence Library v37.0.0 Academic Integrated — Gradio Stable
4
  =============================
5
  Deploy this file as app.py in a Hugging Face Space and add HF_TOKEN as a
6
  read-only Space secret.
 
28
  from sklearn.feature_extraction.text import TfidfVectorizer
29
  import joblib
30
 
31
+ from hudanet_core import GenericEvidencePipeline
32
+
33
  import json
34
  from pathlib import Path
35
 
 
41
  _DIALECTS = {"interrogative_particles": [], "clitic_exceptions": [],
42
  "dialect_phrases": {}, "spelling_variants": {}, "en_spelling_variants": {}}
43
 
44
+ # External retrieval policy. Keep scenario/ranking rules out of app.py so they can
45
+ # be reviewed and extended without changing the retrieval engine itself.
46
+ _RETRIEVAL_RULES_PATH = Path(__file__).parent / "hudanet_retrieval_rules.json"
47
+ if not _RETRIEVAL_RULES_PATH.is_file():
48
+ raise RuntimeError(
49
+ "hudanet_retrieval_rules.json is missing. Place it beside app.py before starting HUDA-Net."
50
+ )
51
+ try:
52
+ _RETRIEVAL_RULES = json.loads(_RETRIEVAL_RULES_PATH.read_text(encoding="utf-8"))
53
+ except json.JSONDecodeError as exc:
54
+ raise RuntimeError(
55
+ f"Invalid hudanet_retrieval_rules.json at line {exc.lineno}, column {exc.colno}: {exc.msg}"
56
+ ) from exc
57
+ if not isinstance(_RETRIEVAL_RULES, dict) or not isinstance(_RETRIEVAL_RULES.get("intents"), dict):
58
+ raise RuntimeError("hudanet_retrieval_rules.json must contain an object named 'intents'.")
59
+ _RETRIEVAL_RULES_FINGERPRINT = hashlib.sha256(
60
+ json.dumps(_RETRIEVAL_RULES, ensure_ascii=False, sort_keys=True).encode("utf-8")
61
+ ).hexdigest()[:16]
62
+
63
+ # Dialect resources are normalized lazily because the canonical base normalizer is
64
+ # defined later in the file. Every replacement uses Arabic word boundaries. This
65
+ # prevents short keys such as "تو" and "وش" from corrupting valid words such as
66
+ # "توفرها" and "وشرعا".
67
+ _DIALECT_RULE_CACHE = None
68
+ _AR_WORD_BOUNDARY_CLASS = r"\w\u0600-\u06FF"
69
+
70
+
71
+ def _compile_whole_arabic_rule(key: str):
72
+ return re.compile(
73
+ rf"(?<![{_AR_WORD_BOUNDARY_CLASS}]){re.escape(key)}(?![{_AR_WORD_BOUNDARY_CLASS}])"
74
+ )
75
 
 
76
 
77
+ def _dialect_rule_cache():
78
+ global _DIALECT_RULE_CACHE
79
+ if _DIALECT_RULE_CACHE is not None:
80
+ return _DIALECT_RULE_CACHE
81
+
82
+ base_normalizer = globals().get("norm_ar_base")
83
+ if not callable(base_normalizer):
84
+ # This fallback is used only during unusually early calls while the module is
85
+ # still being defined. Normal runtime calls use norm_ar_base below.
86
+ def base_normalizer(value):
87
+ value = unicodedata.normalize("NFKC", str(value or "")).replace("ـ", "")
88
+ value = re.sub(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]", "", value)
89
+ value = value.translate(str.maketrans({"أ":"ا","إ":"ا","آ":"ا","ٱ":"ا","ى":"ي","ؤ":"و","ئ":"ي","ک":"ك","ی":"ي","ۀ":"ة"}))
90
+ value = re.sub(r"[^\w\s\u0600-\u06FF]", " ", value.casefold())
91
+ return re.sub(r"\s+", " ", value).strip()
92
+
93
+ def compile_mapping(mapping):
94
+ compiled = []
95
+ normalized = {}
96
+ for raw_key, raw_value in (mapping or {}).items():
97
+ key = base_normalizer(raw_key)
98
+ value = base_normalizer(raw_value)
99
+ if not key or key == value:
100
+ continue
101
+ # Last declaration wins; then longest phrases are applied first.
102
+ normalized[key] = value
103
+ for key, value in sorted(normalized.items(), key=lambda item: (-len(item[0]), item[0])):
104
+ compiled.append((_compile_whole_arabic_rule(key), value))
105
+ return compiled
106
+
107
+ _DIALECT_RULE_CACHE = {
108
+ "phrases": compile_mapping(_DIALECTS.get("dialect_phrases", {})),
109
+ "spelling": compile_mapping(_DIALECTS.get("spelling_variants", {})),
110
+ }
111
+ return _DIALECT_RULE_CACHE
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
+ def _apply_dialect_normalization(s: str) -> str:
115
+ """Normalize only complete dialect words/phrases, never substrings of valid words."""
116
+ text = str(s or "")
117
+ rules = _dialect_rule_cache()
118
+ for pattern, replacement in rules["phrases"]:
119
+ text = pattern.sub(replacement, text)
120
+ for pattern, replacement in rules["spelling"]:
121
+ text = pattern.sub(replacement, text)
122
+ return re.sub(r"\s+", " ", text).strip()
123
 
124
  try:
125
  from IPython.display import display
 
184
 
185
  _download_hudanet_private_datasets()
186
 
187
+ VERSION = "37.0.0"
188
  CONFIG = {
189
  "INPUT_ROOT": "/kaggle/input",
190
  "WORK_ROOT": "/tmp/hudanet_v27",
 
630
  def norm_en(v: Any) -> str:
631
  s = unicodedata.normalize("NFKC", clean_display(v)).casefold()
632
  s = re.sub(r"[^a-z0-9\s'-]", " ", s)
633
+ for a,b in {**EN_REPL, **_DIALECTS.get("en_spelling_variants", {})}.items(): s = re.sub(rf"\b{re.escape(a)}\b", b, s)
634
  return _SPACE.sub(" ", s).strip()
635
 
636
  def has_ar(v: Any) -> bool: return bool(_AR.search(str(v or "")))
 
2956
 
2957
 
2958
  HUDANET_SMART_RESULT = smart_all_in_one()
2959
+ print(f"🔒 HUDA-Net UI v{UI_VERSION if 'UI_VERSION' in globals() else VERSION} | current-turn retrieval only | rules={_RETRIEVAL_RULES_FINGERPRINT}")
2960
 
2961
 
2962
  # ======================== PROFESSIONAL BILINGUAL UI ========================
 
2974
  from scipy import sparse
2975
  import joblib
2976
 
2977
+ UI_VERSION = "37.0.0"
2978
  UI_CONFIG = {
2979
  "INPUT_ROOT": "/kaggle/input",
2980
  "RUNTIME_DATASET_SLUG": "hudanet-bilingual-certified-runtime",
 
3253
  "sort_by": sort_value if sort_value in FILTER_SORT_VALUES else "relevance",
3254
  "evidence_count": count,
3255
  "min_score": round(score, 3),
3256
+ "use_context": False,
3257
  "compare": _filter_bool(compare, True),
3258
  "diverse": _filter_bool(diverse, True),
3259
  }
 
3281
  def norm_en_ui(v: Any) -> str:
3282
  s=unicodedata.normalize("NFKC",clean_ui(v)).casefold()
3283
  s=re.sub(r"[^a-z0-9\s'-]"," ",s)
3284
+ for a,b in {**EN_REPL, **_DIALECTS.get("en_spelling_variants", {})}.items(): s=re.sub(rf"\b{re.escape(a)}\b",b,s)
3285
  return _SPACE.sub(" ",s).strip()
3286
 
3287
 
 
3532
 
3533
 
3534
  def extract_case_facts_ui(query:str,lang:str)->dict:
3535
+ """Generic question frame displayed in the UI; no fiqh scenario is hard-coded."""
3536
+ frame=generic_evidence_pipeline_ui().query_analyzer.analyze(query,lang)
3537
+ request_labels={
3538
+ "ar":{"definition":"تعريف","conditions":"شروط","pillars":"أركان","duties":"واجبات","ruling":"حكم","validity":"صحة","remedy":"ما يترتب أو يلزم","procedure":"كيفية","timing":"توقيت","amount":"عدد أو مقدار","location":"مكان","cause":"سبب أو حكمة","comparison":"مقارنة","evidence":"دليل أو مصدر","exception":"استثناء","list":"قائمة","components":"عناصر","description":"بيان"},
3539
+ "en":{"definition":"Definition","conditions":"Conditions","pillars":"Pillars","duties":"Duties","ruling":"Ruling","validity":"Validity","remedy":"Remedy or consequence","procedure":"Procedure","timing":"Timing","amount":"Amount","location":"Location","cause":"Cause or wisdom","comparison":"Comparison","evidence":"Evidence or source","exception":"Exception","list":"List","components":"Components","description":"Description"},
3540
+ }[lang]
3541
+ facts={
3542
+ "request_type":request_labels.get(frame.primary_request_type,frame.primary_request_type),
3543
+ "polarity":(("واقعة منفية أو متعذرة" if frame.polarity=="negative" else اقعة مثبتة") if lang=="ar" else ("Negative or unavailable case" if frame.polarity=="negative" else "Affirmative case")),
3544
+ }
3545
+ if frame.subject_terms:
3546
+ facts["action"]=" · ".join(frame.subject_terms[:8])
3547
+ if frame.dimensions:
3548
+ facts["dimensions"]=" · ".join(frame.dimensions)
3549
+ labels={
3550
+ "ar":{"request_type":"نوع المطلوب","polarity":"بنية الواقعة","action":"موضوع السؤال","dimensions":"أبعاد السؤال"},
3551
+ "en":{"request_type":"Request type","polarity":"Case structure","action":"Question topic","dimensions":"Question dimensions"},
3552
+ }[lang]
3553
+ return {"facts":facts,"missing":[],"labels":labels}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3554
 
3555
  def canonical_ruling_ui(text:str,lang:str)->list[str]:
3556
  n=norm_ar_ui(text) if lang=="ar" else norm_en_ui(text)
 
3566
  "ar": {
3567
  "pillar": "ركن",
3568
  "condition": "شرط",
3569
+ "obligation_dropped": "يسقط الوجوب",
3570
+ "disputed": "فيه خلاف أو وجهان",
3571
  "obligatory": "واجب أو لازم",
3572
  "prohibited": "محرم أو غير جائز",
3573
  "recommended": "مستحب أو سنة",
 
3579
  "en": {
3580
  "pillar": "Pillar",
3581
  "condition": "Condition",
3582
+ "obligation_dropped": "Obligation dropped",
3583
+ "disputed": "Disputed or two views",
3584
  "obligatory": "Obligatory or required",
3585
  "prohibited": "Prohibited",
3586
  "recommended": "Recommended or Sunnah",
 
3594
 
3595
 
3596
 
 
 
 
 
3597
 
3598
+ _GENERIC_EVIDENCE_PIPELINE = None
3599
+
3600
+
3601
+ def generic_evidence_pipeline_ui() -> GenericEvidencePipeline:
3602
+ """Load the generic evidence library lazily after UI normalizers exist."""
3603
+ global _GENERIC_EVIDENCE_PIPELINE
3604
+ if _GENERIC_EVIDENCE_PIPELINE is None:
3605
+ resource_root = Path(__file__).resolve().parent / "hudanet_core" / "resources"
3606
+ _GENERIC_EVIDENCE_PIPELINE = GenericEvidencePipeline(
3607
+ resource_root,
3608
+ normalizers={"ar": norm_ar_ui, "en": norm_en_ui},
3609
+ )
3610
+ return _GENERIC_EVIDENCE_PIPELINE
3611
+
3612
+
3613
+ def apply_generic_evidence_gate_ui(search: Mapping[str, Any], query: Any, lang: str) -> dict:
3614
+ """Reclassify every retrieved record through the generic logic-first gate."""
3615
+ return generic_evidence_pipeline_ui().annotate_and_rebucket(query, search, lang)
3616
+
3617
+
3618
+ def generic_resolution_ui(
3619
+ query: Any,
3620
+ sources: Sequence[Mapping[str, Any]],
3621
+ lang: str,
3622
+ style: str = "detailed",
3623
+ compare_sources: bool = True,
3624
+ ):
3625
+ return generic_evidence_pipeline_ui().resolve(
3626
+ query, sources, lang, style=style, compare_sources=compare_sources
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3627
  )
 
3628
 
3629
 
3630
+ def _external_direct_intent_ui(query: Any, lang: str) -> dict:
3631
+ """Deprecated compatibility hook. Generic semantic resources replace named intents."""
3632
+ return {}
3633
+
3634
+ def _rule_pattern_hit_ui(text: str, patterns: Sequence[str]) -> bool:
3635
+ for pattern in patterns or []:
3636
+ try:
3637
+ if re.search(str(pattern), text, re.I):
3638
+ return True
3639
+ except re.error as exc:
3640
+ raise RuntimeError(f"Invalid retrieval rule regex: {pattern}: {exc}") from exc
3641
+ return False
3642
+
3643
+
3644
+ def analyze_direct_answer_intent_ui(query: Any, lang: str) -> dict:
3645
+ """Compatibility shim: all semantic decisions are now generic and modular."""
3646
+ frame = generic_evidence_pipeline_ui().query_analyzer.analyze(query, lang)
3647
+ legacy_keys = (
3648
+ "external_direct_rule", "no_hajj_deputy", "first_tahallul",
3649
+ "hair_before_first_tahallul", "intercourse_before_first_tahallul",
3650
+ "generic_first_tahallul", "missed_arafah", "arafah_end_time",
3651
+ "jamrat_start_time", "miqat_remedy", "miqat_after_makkah",
3652
+ "tawaf_wada_before_leave", "tawaf_doubt",
3653
+ "menstruating_tawaf_necessity", "tamattu_sacrifice",
3654
+ "hajj_modes_comparison", "jamarat_before_zawal", "ihsar_prevented_hajj",
3655
+ )
3656
+ result = {key: False for key in legacy_keys}
3657
+ result.update({
3658
+ "external_intent_id": "",
3659
+ "requires_operational_answer": False,
3660
+ "generic_request_type": frame.primary_request_type,
3661
+ "generic_subject_terms": list(frame.subject_terms),
3662
+ "generic_critical_terms": list(frame.critical_terms),
3663
+ "generic_polarity": frame.polarity,
3664
+ })
3665
+ return result
3666
+
3667
  def _direct_source_text_ui(source: Mapping[str,Any], lang: str) -> str:
3668
  normalizer = norm_ar_ui if lang == "ar" else norm_en_ui
3669
  values = []
 
3674
  return normalizer(" ".join(values))
3675
 
3676
 
 
 
 
 
 
3677
 
3678
+ def _external_source_rule_diagnostics_ui(source: Mapping[str,Any], query: Any, lang: str) -> dict:
3679
+ """Deprecated compatibility hook; no named scenarios are evaluated."""
3680
+ return {"matched":False,"passed":True,"scenario":False,"preferred":False,"ambiguous":False}
3681
+
3682
+ def direct_intent_diagnostics_ui(source: Mapping[str,Any], query: Any, lang: str) -> dict:
3683
+ """Generic source diagnostics in the legacy UI contract."""
3684
+ resolution=generic_resolution_ui(query,[source],lang,style="short",compare_sources=False)
3685
+ if not resolution.ranked:
3686
+ return {"passed":False,"required":[],"missing":["missing_source"],"conflicts":[]}
3687
+ item=resolution.ranked[0]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3688
  return {
3689
+ "passed":bool(item.accepted),
3690
+ "required":["request_type","topic_or_case","answer_text"],
3691
+ "missing":list(item.hard_rejections),
3692
+ "conflicts":[],
3693
+ "generic_score":round(item.score,4),
3694
+ "metrics":{k:round(float(v),4) for k,v in item.metrics.items()},
3695
  }
3696
 
 
3697
  def source_satisfies_direct_intent_ui(source: Mapping[str,Any], query: Any, lang: str) -> bool:
3698
  return bool(direct_intent_diagnostics_ui(source, query, lang).get("passed"))
3699
 
 
3723
 
3724
 
3725
  def _direct_intent_bonus_ui(source: Mapping[str,Any], query: Any, lang: str) -> float:
3726
+ """Legacy hook retained for API compatibility; generic ranking owns the score."""
3727
+ return 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3728
 
3729
  def direct_query_expansion_ui(query: Any, lang: str) -> str:
3730
+ """No question-specific expansions. Generic evidence promotion handles recall."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3731
  return ""
3732
 
3733
  def merge_search_results_ui(primary: Mapping[str,Any], extra: Mapping[str,Any]) -> dict:
 
3767
 
3768
 
3769
  def direct_answer_preface_ui(query: Any, lang: str, support: Sequence[Mapping[str,Any]]) -> str:
3770
+ """No hard-coded answer prefaces; synthesis is extractive and source-grounded."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3771
  return ""
3772
 
 
3773
  def filter_case_missing_for_query_ui(missing: Sequence[str], query: Any, lang: str) -> list[str]:
3774
+ """Keep only deduplicated case details; no scenario-specific exceptions."""
 
 
 
 
 
 
 
3775
  return list(dict.fromkeys(missing or []))
3776
 
 
3777
  def _answer_source_relevance_ui(source: Mapping[str,Any], query: str, lang: str) -> float:
3778
  """Rank already-supported sources by how directly their answer resolves the request."""
3779
  if not source_has_usable_answer_ui(source):
 
3831
 
3832
 
3833
  def rank_support_for_answer_ui(support: Sequence[Mapping[str,Any]], query: str, lang: str) -> list[dict]:
3834
+ """Return only evidence accepted by the generic request/case/outcome gate."""
3835
+ resolution = generic_resolution_ui(query, support, lang, style="short", compare_sources=True)
3836
+ return [dict(item.source) for item in resolution.accepted]
3837
 
3838
  def compose_answer_for_filter_preferences_ui(
3839
  engine, support: Sequence[Mapping[str,Any]], style: str, lang: str,
3840
  compare_sources: bool, consensus: Mapping[str,Any], query: str = "",
3841
  ) -> str:
3842
+ """Build the final answer generically from accepted source text and source consensus."""
3843
+ resolution = generic_resolution_ui(
3844
+ query, support, lang, style=style, compare_sources=compare_sources
3845
+ )
3846
+ return resolution.answer
 
 
 
3847
 
3848
+ def analyze_source_consensus_ui(
3849
+ sources: Sequence[Mapping[str,Any]], lang: str, query: str = ""
3850
+ ) -> dict:
3851
+ """Expose modular consensus in the legacy UI shape."""
3852
+ if not sources:
3853
+ return {"state":"insufficient","books":0,"categories":{},"majority":"unspecified","agreement_ratio":0.0,"conflict_pairs":[],"rows":[]}
3854
+ probe = query or clean_ui((sources[0] or {}).get("question", "")) or clean_ui((sources[0] or {}).get("title", ""))
3855
+ resolution = generic_resolution_ui(probe, sources, lang, style="short", compare_sources=True)
3856
+ generic = resolution.consensus
3857
+ state_map = {"agreement":"agreement","mixed":"mixed","conflict":"conflict","single_source":"insufficient","insufficient":"insufficient"}
3858
+ categories = {}
3859
+ rows = []
3860
+ for item in resolution.accepted:
3861
+ for outcome in item.evidence.outcomes:
3862
+ if outcome != "unspecified":
3863
+ categories[outcome] = categories.get(outcome, 0) + 1
3864
+ rows.append({
3865
+ "book_id": item.evidence.book_id,
3866
+ "book": item.evidence.book,
3867
+ "ruling": clean_ui(item.source.get("ruling", "")) or item.evidence.answer_text,
3868
+ "categories": list(item.evidence.outcomes),
3869
+ })
3870
+ selected_weight = float((generic.clusters.get(generic.selected_cluster, {}) or {}).get("weight", 0.0) or 0.0)
3871
+ total_weight = sum(float((cluster or {}).get("weight", 0.0) or 0.0) for cluster in generic.clusters.values())
3872
+ return {
3873
+ "state": state_map.get(generic.state, "insufficient"),
3874
+ "books": generic.book_count,
3875
+ "categories": categories,
3876
+ "majority": generic.selected_cluster or "unspecified",
3877
+ "agreement_ratio": round(selected_weight / max(total_weight, 1e-9), 3),
3878
+ "conflict_pairs": [],
3879
+ "rows": rows[:8],
3880
+ "generic_explanation": generic.explanation,
3881
+ }
3882
 
3883
  def render_case_facts_inline(case:Mapping[str,Any],lang:str)->str:
3884
  if not case or not case.get("facts"): return ""
 
4854
  @staticmethod
4855
  def _cache_key(query: str, lang: str, filters: dict) -> tuple:
4856
  stable = json.dumps(filters or {}, ensure_ascii=False, sort_keys=True, default=str)
4857
+ return (UI_VERSION, _RETRIEVAL_RULES_FINGERPRINT, clean_ui(query), str(lang), stable)
4858
 
4859
  def _cache_get(self, key):
4860
  value = self._search_cache.get(key)
 
5309
  return {"answer":prompt,"mode":"broad_query","security":decision,"exact":[],"related":[],"distant":[],"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":original,"case_facts":case,"consensus":{},"stats":{"latency":time.perf_counter()-started,"allowed_records":0,"allowed_books":0,"matched_books":0,"exact_count":0,"related_count":0,"distant_count":0,"neural_search_skipped":True}}
5310
  if decision["action"] in messages[lang]:
5311
  return {"answer":messages[lang][decision["action"]],"mode":decision["action"],"security":decision,"exact":[],"related":[],"distant":[],"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":clean_ui(query),"case_facts":case,"consensus":{},"stats":{"latency":time.perf_counter()-started,"allowed_records":0,"allowed_books":0,"matched_books":0,"exact_count":0,"related_count":0,"distant_count":0}}
5312
+ # Conversation history is presentation state only. Retrieval always receives
5313
+ # the current message exactly as submitted, preventing topic leakage between turns.
5314
  effective=original
5315
+ previous_user=""
 
 
 
 
5316
  compact=compact_query_ui(original,lang); parts=split_multi_ui(compact,lang)
5317
  if len(parts)>1:
5318
  sub=[]
5319
  for part in parts:
5320
+ result=self.search(part,lang,filters)
5321
+ result=apply_generic_evidence_gate_ui(result,part,lang)
5322
+ best=result["exact"] or result["related"]
5323
  if not best: continue
5324
+ consensus=analyze_source_consensus_ui(result["exact"]+result["related"],lang,part)
5325
  display_consensus=consensus if compare_sources else {}
5326
  sub_answer=compose_answer_for_filter_preferences_ui(self,result["exact"]+result["related"],filters.get("answer_style","detailed"),lang,compare_sources,display_consensus,part)
5327
  if not usable_output_text_ui(sub_answer):
 
5366
  search=merge_search_results_ui(search,expanded_search)
5367
  search.setdefault("stats",{})["expanded_query"]=expanded_query
5368
  search=annotate_direct_intent_diagnostics_ui(search,intent_query,lang)
5369
+ search=apply_generic_evidence_gate_ui(search,intent_query,lang)
5370
  support=search["exact"] or search["related"]
5371
+ consensus=analyze_source_consensus_ui(search["exact"]+search["related"],lang,intent_query)
5372
  if not support:
5373
  if int(search.get("stats", {}).get("allowed_records", 0) or 0) == 0:
5374
  msg=("لا يوجد سجل يطابق اجتماع الفلاتر الحالية. هذا ليس فشلًا في البحث؛ بعض الاختيارات متعارضة. أزل فلترًا واحدًا أو استخدم إعادة الضبط." if ar else "No record matches the current filter intersection. This is not a retrieval failure; some selections conflict. Remove one filter or reset them.")
 
5395
  collective_comparison=all(token in combined_text for token in (("تمتع","قران","افراد") if ar else ("tamattu","qiran","ifrad")))
5396
  if complete_support:
5397
  complete_operational_support=True
5398
+ # Sources that fail the requested action/scenario remain visible in the
5399
+ # evidence explorer, but they are excluded from answer composition.
5400
+ ranked_support=complete_support
5401
  elif collective_comparison:
5402
  complete_operational_support=True
5403
  else:
 
5405
  msg=(("لم أجد في الكتاب أو الكتب المحددة نصًا يجيب عن الفرع العملي المطلوب مباشرة. أزل فلتر الكتاب أو استخدم نطاق التغطية الواسعة." if selected_books else "وجدت شواهد قريبة من الموضوع، لكنها لا تجيب عن الفرع العملي المطلوب مباشرة، لذلك لن أعرض جوابًا ناقصًا." ) if ar else (("The selected book or books do not contain evidence that directly resolves the requested practical branch. Remove the book filter or use Wide Coverage." if selected_books else "Nearby evidence was found, but it does not directly resolve the requested practical branch, so an incomplete answer will not be presented.")))
5406
  mode=("selected_books_insufficient" if selected_books else "direct_answer_incomplete")
5407
  return {"answer":msg,"mode":mode,"security":decision,**search,"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":effective,"case_facts":case,"consensus":consensus if compare_sources else {},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
5408
+ primary=ranked_support[0]; direct=float(primary.get("generic_score",primary.get("direct_probability",primary.get("score",0))) or 0); agreement=int(primary.get("retriever_agreement",0) or 0)
5409
+ second=max([float(x.get("generic_score",x.get("direct_probability",x.get("score",0))) or 0) for x in ranked_support[1:]]+[0.0]); margin=direct-second
5410
  exact_present=bool(search["exact"]); matched_books=int(search.get("stats",{}).get("matched_books",0))
5411
+ generic_strong=bool(float(primary.get("generic_score",0.0) or 0.0)>=0.70)
5412
+ uncertain=(not generic_strong and not complete_operational_support and not exact_present and matched_books<int(UI_CONFIG["ABSTAIN_MIN_BOOKS"]) and (margin<float(UI_CONFIG["ABSTAIN_MARGIN"]) or agreement<2))
5413
+ # The modular consensus layer presents strong conflicts with their sources;
5414
+ # do not suppress them behind a generic clarification message.
5415
+ conflict_weak=False
5416
  missing_critical=bool(case.get("missing")) and not exact_present and direct<0.78
5417
  if uncertain or conflict_weak:
5418
  missing=case.get("missing",[])
 
5423
  else:
5424
  prompt=("النتائج القريبة متقاربة جدًا في الثقة ولا تكفي لبناء حكم آمن. وضّح الفعل والوقت وحالة الإحرام." if ar else "The closest candidates are too close in confidence to ground a safe ruling. Clarify the act, timing, and ihram status.")
5425
  return {"answer":prompt,"mode":"needs_context","security":decision,**search,"confidence":round(direct*100,1),"language":lang,"query":clean_ui(query),"effective_query":effective,"primary":primary,"case_facts":case,"consensus":consensus if compare_sources else {},"uncertainty":{"margin":margin,"agreement":agreement,"reason":"missing_or_conflicting_context"},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
5426
+ answer_consensus=analyze_source_consensus_ui(ranked_support,lang,intent_query)
5427
+ answer=compose_answer_for_filter_preferences_ui(self,ranked_support,filters.get("answer_style","detailed"),lang,compare_sources,answer_consensus if compare_sources else {},intent_query)
5428
+ consensus=answer_consensus
5429
  if not usable_output_text_ui(answer):
5430
  msg=("وجدت سجلات مرتبطة، لكن نصوص الإجابة المتاحة كانت قوالب ناقصة أو غير مترجمة، لذلك لن أعرضها كحكم. استخدم كتابًا آخر أو أزل الفلاتر." if ar else "Related records were found, but their available answers were incomplete templates or untranslated placeholders, so they will not be presented as a ruling. Try another book or remove the filters.")
5431
  return {"answer":msg,"mode":"placeholder_blocked","security":decision,**search,"confidence":0.0,"language":lang,"query":clean_ui(query),"effective_query":effective,"primary":primary,"case_facts":case,"consensus":consensus if compare_sources else {},"stats":{**search.get("stats",{}),"latency":time.perf_counter()-started}}
 
5469
 
5470
 
5471
  def validate_answer_quality_v36_4() -> dict:
5472
+ """Generic regressions: type isolation, case coverage, conflict, and synthesis."""
5473
+ pipeline = generic_evidence_pipeline_ui()
5474
+ checks = []
5475
  def add(name, passed, value=""):
5476
  checks.append({"name":name,"passed":bool(passed),"value":value})
5477
 
5478
+ definition = {
5479
+ "record_id":"definition", "book_id":"furu", "book":"الفروع", "title":"معنى الحج لغة وشرعا",
5480
+ "question":"ما معنى الحج؟", "ruling":"تعريف", "answer":"الحج لغة القصد، وشرعا قصد مكة للنسك.",
5481
+ "dense_score":0.99, "cross_encoder_score":0.95, "score":0.95,
5482
+ }
5483
+ conditions = {
5484
+ "record_id":"conditions", "book_id":"dalil", "book":"دليل الطالب", "title":"شروط وجوب الحج",
5485
+ "question":"ما شروط وجوب الحج؟", "ruling":"شروط",
5486
+ "answer":"شروط وجوب الحج: الإسلام، والعقل، والبلوغ، وكمال الحرية، والاستطاعة.",
5487
+ "dense_score":0.88, "cross_encoder_score":0.75, "score":0.70,
5488
+ }
5489
+ condition_result = pipeline.resolve("ما هي الشروط التي يجب توفرها لوجوب الحج؟", [definition, conditions], "ar")
5490
+ add("generic_type_gate_rejects_definition", any(x.evidence.record_id=="definition" and not x.accepted for x in condition_result.ranked), condition_result.details)
5491
+ add("generic_conditions_selected", bool(condition_result.consensus.selected and condition_result.consensus.selected[0].evidence.record_id=="conditions"), condition_result.answer)
5492
+ add("generic_conditions_answer_contains_list", all(term in norm_ar_ui(condition_result.answer) for term in ("اسلام","عقل","بلوغ","حري","استطاع")), condition_result.answer)
5493
+
5494
+ decisive = {
5495
+ "record_id":"dropped", "book_id":"rawd", "book":"الروض المربع", "title":"عجز من لا يجد نائبا",
5496
+ "question":"ما حكم من لم يجد نائبا؟", "ruling":"يسقط الوجوب",
5497
+ "answer":"إذا لم يجد الشخص نائبا للحج عنه، يسقط عنه وجوب الحج.",
5498
+ "dense_score":0.82, "cross_encoder_score":0.71, "score":0.65,
5499
+ }
5500
+ disputed = {
5501
+ "record_id":"two_views", "book_id":"furu", "book":"الفروع", "title":"من وجد المال ولم يجد نائبا",
5502
+ "question":"من لم يجد نائبا للحج", "ruling":"وجهان",
5503
+ "answer":"إن وجد مالا ولم يجد نائبا ففي وجوب الحج في ذمته وجهان.",
5504
+ "dense_score":0.98, "cross_encoder_score":0.90, "score":0.92,
5505
+ }
5506
+ ruling_result = pipeline.resolve("ما هو الحكم إذا لم يجد الشخص نائبا للحج عنه؟", [disputed, decisive], "ar")
5507
+ add("generic_decisive_result_beats_dispute", bool(ruling_result.consensus.selected and ruling_result.consensus.selected[0].evidence.record_id=="dropped"), ruling_result.answer)
5508
+ add("generic_answer_grounded_in_source", "يسقط" in norm_ar_ui(ruling_result.answer), ruling_result.answer)
5509
+ add("generic_sources_are_named", "الروض المربع" in ruling_result.answer, ruling_result.answer)
5510
+ add("no_question_specific_intents", not bool((_RETRIEVAL_RULES.get("intents") or {})), _RETRIEVAL_RULES)
5511
+
5512
+ failed=[item for item in checks if not item["passed"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5513
  if failed:
5514
+ raise RuntimeError("HUDA-Net v37.0.0 generic evidence self-test failed: "+json.dumps(failed,ensure_ascii=False))
5515
+ print(f"✅ HUDA-Net v37.0.0 generic evidence self-test passed: {len(checks)} checks")
5516
  return {"passed":True,"tested":len(checks),"checks":checks}
5517
 
5518
  def validate_specificity_guard_v33(engine: ProfessionalEvidenceEngine) -> dict:
 
5783
  direct_rejection=clean_ui(source.get("direct_intent_rejection",""))
5784
  if direct_rejection:
5785
  reason += ((" · سبب رفضه للجواب المباشر: " if ar else " · Direct-answer rejection: ")+direct_rejection)
5786
+ generic_score=float(source.get("generic_score",0.0) or 0.0)
5787
+ generic_reasons=list(source.get("generic_reasons",[]) or [])
5788
+ generic_rejections=list(source.get("generic_rejections",[]) or [])
5789
+ if generic_reasons:
5790
+ reason += ((" · التحليل الجينيريك: " if ar else " · Generic analysis: ")+"؛ ".join(map(str,generic_reasons)))
5791
+ if generic_rejections:
5792
+ reason += ((" · بوابة الرفض: " if ar else " · Gate rejection: ")+", ".join(map(str,generic_rejections)))
5793
+ if generic_score:
5794
+ reason += ((" · درجة التوافق المنطقي " if ar else " · Logic compatibility ")+f"{generic_score*100:.1f}%")
5795
  score=max(0.0,min(1.0,float(source.get("score",0) or 0))); score_pct=score*100
5796
  is_quran=bool(ar and re.search(r'[﴿﷽]|قال الله|قوله تعالى',evidence))
5797
  citation_class="quran-ayah" if is_quran else ("citation-text" if ar else "source-text-en")
 
9061
  ar_kinds=gr.CheckboxGroup(ar_opts["source_kinds"],value=[],label="مصدر السجل")
9062
 
9063
  with gr.Accordion("خيارات متقدمة",open=False):
9064
+ gr.HTML(field_guide("سياق المحادثة","سجل المحادثة للعرض فقط. كل سؤال يُسترجع مستقلًا لمنع تسرّب موضوع السؤال السابق.","ar"))
9065
+ ar_context=gr.Checkbox(value=False,label="السؤال الحالي فقط في الاسترجاع",interactive=False)
9066
  gr.HTML(field_guide("مقارنة صيغ الأحكام","يعرض تنبيهًا عند اختلاف صياغة الحكم بين أقرب المصادر.","ar"))
9067
  ar_compare=gr.Checkbox(value=True,label="قارن صيغ الأحكام بين المصادر")
9068
  gr.HTML(field_guide("تمثيل جميع الكتب","يحتفظ المحرك بأفضل شاهد آمن من كل كتاب مسموح. هذا الضمان ثابت حتى لا يختفي أي كتاب بسبب ترتيب النتائج.","ar"))
 
9151
  en_kinds=gr.CheckboxGroup(en_opts["source_kinds"],value=[],label="Record source")
9152
 
9153
  with gr.Accordion("Advanced options",open=False):
9154
+ gr.HTML(field_guide("Conversation context","Conversation history is display-only. Each message is retrieved independently to prevent topic leakage.","en"))
9155
+ en_context=gr.Checkbox(value=False,label="Current question only for retrieval",interactive=False)
9156
  gr.HTML(field_guide("Compare ruling formulations","Shows a note when the closest sources use different ruling formulations.","en"))
9157
  en_compare=gr.Checkbox(value=True,label="Compare ruling formulations across sources")
9158
  gr.HTML(field_guide("Represent every book","Keeps the best safe item from every allowed book, even when its relevance is only distant.","en"))
 
9240
 
9241
  reset_outputs_ar=[ar_books,ar_authors,ar_types,ar_madhhabs,ar_categories,ar_rulings,ar_kinds,ar_style,ar_mode,ar_sort,ar_count,ar_min,ar_context,ar_compare,ar_diverse]
9242
  reset_outputs_en=[en_books,en_authors,en_types,en_madhhabs,en_categories,en_rulings,en_kinds,en_style,en_mode,en_sort,en_count,en_min,en_context,en_compare,en_diverse]
9243
+ defaults=([],[],[],[],[],[],[],"detailed","balanced","relevance",1,0,False,True,True)
9244
  ar_reset_event=ar_reset.click(lambda:defaults,None,reset_outputs_ar,queue=False)
9245
  en_reset_event=en_reset.click(lambda:defaults,None,reset_outputs_en,queue=False)
9246
  for event in (ar_reset_event,en_reset_event):
hudanet_core/README.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HUDA-Net Generic Evidence Library v37.0.0
2
+
3
+ This package separates the answer pipeline into independent, reviewable layers. It contains no stored answer for any named fiqh question.
4
+
5
+ ## Runtime flow
6
+
7
+ 1. `query.py` classifies the request and extracts the subject, critical case constraints, polarity, and requested dimensions.
8
+ 2. `evidence.py` converts every retrieved record into a normalized evidence frame.
9
+ 3. `compatibility.py` applies hard gates before ranking. A neural score cannot rescue a request-type mismatch or a missing case constraint.
10
+ 4. `consensus.py` groups outcomes by independent books, detects incompatible results, prefers a direct decisive result over a merely disputed formulation when justified, and preserves dissent.
11
+ 5. `synthesis.py` builds the visible answer extractively from accepted source text, merges complementary list items, names every source used, and presents strong conflicts separately.
12
+ 6. `pipeline.py` orchestrates ranking, promotion of compatible distant evidence, rejection/demotion of incompatible evidence, consensus, and answer synthesis.
13
+
14
+ ## External resources
15
+
16
+ - `resources/semantic_rules.json`: bilingual request types, negation, dimensions, and ruling outcomes.
17
+ - `resources/evidence_schema.json`: generic evidence-field roles.
18
+ - `resources/ranking_config.json`: logic-first weights, hard thresholds, type compatibility, and limits.
19
+ - `resources/answer_templates.json`: presentation-only strings and extractive cleanup rules.
20
+
21
+ ## Safety properties
22
+
23
+ - Current-turn retrieval only. Conversation history is display-only.
24
+ - No question-specific intent ID and no expected answer stored in JSON.
25
+ - Fail closed when no evidence passes request-type and case-constraint gates.
26
+ - Strong incompatible outcomes are displayed as separate sourced views.
27
+ - Distant neural candidates may be promoted only after passing the generic logic gate.
28
+ - Every evidence card receives generic compatibility metrics, acceptance reasons, or rejection reasons.
hudanet_core/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ """HUDA-Net generic evidence resolution library."""
2
+ from .pipeline import GenericEvidencePipeline, ResolutionResult
3
+
4
+ __all__ = ["GenericEvidencePipeline", "ResolutionResult"]
hudanet_core/compatibility.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Iterable, List, Mapping, Sequence, Set, Tuple
4
+
5
+ from .text import TextProcessor
6
+ from .types import EvidenceFrame, QueryFrame, ScoredEvidence
7
+
8
+
9
+ class CompatibilityScorer:
10
+ def __init__(self, text: TextProcessor, semantic: dict, ranking: dict):
11
+ self.text = text
12
+ self.semantic = semantic
13
+ self.config = ranking
14
+ self.weights = {key: float(value) for key, value in ranking.get("weights", {}).items()}
15
+ self.thresholds = {key: float(value) for key, value in ranking.get("thresholds", {}).items()}
16
+ self.compatibility = ranking.get("type_compatibility", {})
17
+ self.incompatible_outcomes = {
18
+ frozenset(pair) for pair in semantic.get("incompatible_outcomes", [])
19
+ }
20
+
21
+ @staticmethod
22
+ def _bounded_score(value: object) -> float:
23
+ try:
24
+ number = float(value or 0.0)
25
+ except Exception:
26
+ return 0.0
27
+ if number > 1.0:
28
+ number /= 100.0
29
+ return max(0.0, min(1.0, number))
30
+
31
+ def _type_match(self, query: QueryFrame, evidence: EvidenceFrame) -> float:
32
+ if "unclassified" in evidence.answer_types:
33
+ return 0.48
34
+ values = []
35
+ for query_type in query.request_types:
36
+ allowed = self.compatibility.get(query_type, {})
37
+ for answer_type in evidence.answer_types:
38
+ values.append(float(allowed.get(answer_type, 0.0)))
39
+ return max(values or [0.0])
40
+
41
+ def _topic_match(self, query: QueryFrame, evidence: EvidenceFrame) -> Tuple[float, float]:
42
+ metadata_terms = self.text.content_terms(evidence.metadata_text, query.language)
43
+ answer_terms = self.text.content_terms(evidence.answer_text, query.language)
44
+ metadata_overlap = self.text.fuzzy_term_overlap(query.subject_terms, metadata_terms)
45
+ answer_overlap = self.text.fuzzy_term_overlap(query.subject_terms, answer_terms)
46
+ topic = max(metadata_overlap, 0.72 * metadata_overlap + 0.28 * answer_overlap, 0.52 * answer_overlap)
47
+ critical = self.text.fuzzy_term_overlap(query.critical_terms, tuple(metadata_terms) + tuple(answer_terms))
48
+ return topic, critical
49
+
50
+ def _polarity_alignment(self, query: QueryFrame, evidence: EvidenceFrame) -> float:
51
+ if query.polarity != "negative":
52
+ return 0.75
53
+ if not query.negated_terms:
54
+ return 0.62
55
+ answer_terms = self.text.content_terms(f"{evidence.metadata_text} {evidence.answer_text}", query.language)
56
+ term_coverage = self.text.fuzzy_term_overlap(query.negated_terms, answer_terms)
57
+ negative_patterns = self.semantic.get("negation_markers", {}).get(query.language, []) or []
58
+ has_negative = any(self.text.phrase_hit(f"{evidence.normalized_metadata} {evidence.normalized_answer}", pattern) for pattern in negative_patterns)
59
+ return min(1.0, 0.18 + 0.58 * term_coverage + (0.24 if has_negative else 0.0))
60
+
61
+ def _directness(self, query: QueryFrame, evidence: EvidenceFrame) -> float:
62
+ type_match = self._type_match(query, evidence)
63
+ outcome_directness = evidence.decisiveness
64
+ if query.primary_request_type in {"definition", "conditions", "comparison", "procedure", "timing", "amount", "location", "cause"}:
65
+ outcome_directness = max(outcome_directness, 0.78 if query.primary_request_type in evidence.answer_types else 0.35)
66
+ if query.asks_for_decisive_answer and "disputed" in evidence.outcomes and len(evidence.outcomes) == 1:
67
+ outcome_directness *= 0.42
68
+ return min(1.0, 0.56 * type_match + 0.44 * outcome_directness)
69
+
70
+ def _neural_prior(self, source: Mapping[str, object]) -> float:
71
+ values = [
72
+ self._bounded_score(source.get("direct_probability")),
73
+ self._bounded_score(source.get("score")),
74
+ self._bounded_score(source.get("dense_score")),
75
+ self._bounded_score(source.get("cross_encoder_score")),
76
+ self._bounded_score(source.get("bm25_score")),
77
+ ]
78
+ values.sort(reverse=True)
79
+ return 0.55 * values[0] + 0.25 * values[1] + 0.12 * values[2] + 0.08 * values[3]
80
+
81
+ def score(self, query: QueryFrame, evidence: EvidenceFrame, source: Mapping[str, object]) -> ScoredEvidence:
82
+ type_match = self._type_match(query, evidence)
83
+ topic_match, critical_coverage = self._topic_match(query, evidence)
84
+ polarity = self._polarity_alignment(query, evidence)
85
+ directness = self._directness(query, evidence)
86
+ completeness = evidence.completeness
87
+ neural = self._neural_prior(source)
88
+ source_quality = 0.68
89
+ source_kind = str(source.get("source_kind", "")).casefold()
90
+ if any(token in source_kind for token in ("clean", "منظف", "certified", "معتمد")):
91
+ source_quality = 0.85
92
+ if any(token in source_kind for token in ("raw", "خام", "ocr")):
93
+ source_quality = min(source_quality, 0.58)
94
+
95
+ metrics = {
96
+ "request_type_match": type_match,
97
+ "topic_match": topic_match,
98
+ "critical_constraint_coverage": critical_coverage,
99
+ "polarity_alignment": polarity,
100
+ "answer_directness": directness,
101
+ "evidence_completeness": completeness,
102
+ "source_quality": source_quality,
103
+ "neural_prior": neural,
104
+ }
105
+ score = sum(self.weights.get(name, 0.0) * value for name, value in metrics.items())
106
+ compositional_request = query.primary_request_type in {"conditions", "list", "components", "pillars", "duties"}
107
+ compositional_extension = bool(
108
+ compositional_request
109
+ and type_match >= 0.85
110
+ and neural >= 0.60
111
+ and evidence.completeness >= 0.30
112
+ )
113
+ if compositional_extension:
114
+ # Generic component recovery: a specialized condition/component can be
115
+ # useful even when its short title omits the broader subject named in
116
+ # the user's question. Neural retrieval is only a recall witness here;
117
+ # the evidence still must match the requested answer type.
118
+ score = min(1.0, score + 0.075)
119
+ hard_rejections: List[str] = []
120
+ reasons: List[str] = []
121
+
122
+ if query.confidence >= 0.60 and type_match < self.thresholds.get("hard_type_mismatch", 0.18):
123
+ hard_rejections.append("request_type_mismatch")
124
+ if (
125
+ topic_match < self.thresholds.get("minimum_topic_match", 0.18)
126
+ and critical_coverage < self.thresholds.get("minimum_critical_coverage", 0.24)
127
+ and not compositional_extension
128
+ ):
129
+ hard_rejections.append("insufficient_case_or_topic_overlap")
130
+ if query.polarity == "negative" and polarity < self.thresholds.get("minimum_negative_alignment", 0.36):
131
+ hard_rejections.append("negative_case_not_covered")
132
+ if not evidence.answer_text.strip():
133
+ hard_rejections.append("missing_answer_text")
134
+
135
+ if type_match >= 0.80:
136
+ reasons.append("نوع الجواب يطابق نوع السؤال" if query.language == "ar" else "Answer type matches the request")
137
+ if critical_coverage >= 0.68:
138
+ reasons.append("قيود الواقعة مغطاة بوضوح" if query.language == "ar" else "Case constraints are clearly covered")
139
+ if directness >= 0.75:
140
+ reasons.append("النتيجة مباشرة وقابلة للبناء عليها" if query.language == "ar" else "The outcome is direct and usable")
141
+ if neural >= 0.75:
142
+ reasons.append("الاسترجاع الهجين يدعم الصلة" if query.language == "ar" else "Hybrid retrieval supports relevance")
143
+ if compositional_extension:
144
+ reasons.append("شاهد مكمل من النوع نفسه جرى استرداده للتجميع" if query.language == "ar" else "A same-type complementary source was recovered for synthesis")
145
+ if "disputed" in evidence.outcomes and query.asks_for_decisive_answer:
146
+ reasons.append("الشاهد يعرض خلافًا لا نتيجة حاسمة" if query.language == "ar" else "The source presents disagreement rather than a decisive outcome")
147
+
148
+ accepted = not hard_rejections and score >= self.thresholds.get("accept", 0.54)
149
+ promoted = accepted and score >= self.thresholds.get("promote_distant", 0.68)
150
+ source_copy = dict(source)
151
+ source_copy.update({
152
+ "generic_score": round(float(score), 6),
153
+ "generic_accepted": bool(accepted),
154
+ "generic_promoted": bool(promoted),
155
+ "generic_rejections": list(hard_rejections),
156
+ "generic_reasons": list(reasons),
157
+ "generic_metrics": {key: round(float(value), 4) for key, value in metrics.items()},
158
+ "generic_answer_types": list(evidence.answer_types),
159
+ "generic_outcomes": list(evidence.outcomes),
160
+ })
161
+ return ScoredEvidence(
162
+ source=source_copy,
163
+ evidence=evidence,
164
+ score=float(score),
165
+ accepted=accepted,
166
+ promoted=promoted,
167
+ hard_rejections=hard_rejections,
168
+ reasons=reasons,
169
+ metrics=metrics,
170
+ )
hudanet_core/consensus.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import defaultdict
4
+ from typing import Dict, Iterable, List, Sequence, Tuple
5
+
6
+ from .text import TextProcessor
7
+ from .types import ConsensusResult, QueryFrame, ScoredEvidence
8
+
9
+
10
+ class ConsensusAnalyzer:
11
+ def __init__(self, text: TextProcessor, semantic: dict, ranking: dict):
12
+ self.text = text
13
+ self.semantic = semantic
14
+ self.ranking = ranking
15
+ self.incompatible = {frozenset(pair) for pair in semantic.get("incompatible_outcomes", [])}
16
+
17
+ def _cluster_key(self, query: QueryFrame, item: ScoredEvidence) -> str:
18
+ outcomes = [value for value in item.evidence.outcomes if value != "unspecified"]
19
+ if query.primary_request_type in {"conditions", "definition", "procedure", "comparison", "timing", "amount", "location", "cause", "evidence"}:
20
+ return query.primary_request_type
21
+ decisive = [value for value in outcomes if value != "disputed"]
22
+ if decisive:
23
+ return "+".join(sorted(decisive))
24
+ if outcomes:
25
+ return "+".join(sorted(outcomes))
26
+ sentence = item.evidence.sentences[0] if item.evidence.sentences else item.evidence.answer_text
27
+ signature = self.text.content_terms(sentence, query.language)[:7]
28
+ return "text:" + ":".join(signature)
29
+
30
+ def analyze(self, query: QueryFrame, ranked: Sequence[ScoredEvidence]) -> ConsensusResult:
31
+ accepted = [item for item in ranked if item.accepted]
32
+ clusters: Dict[str, Dict[str, object]] = {}
33
+ grouped: Dict[str, List[ScoredEvidence]] = defaultdict(list)
34
+ for item in accepted:
35
+ key = self._cluster_key(query, item)
36
+ item.cluster_key = key
37
+ grouped[key].append(item)
38
+ for key, items in grouped.items():
39
+ books = {item.evidence.book_id or item.evidence.book or item.evidence.record_id for item in items}
40
+ weight = sum(item.score * (0.82 + 0.18 * item.evidence.decisiveness) for item in items)
41
+ clusters[key] = {
42
+ "weight": float(weight),
43
+ "books": len(books),
44
+ "items": items,
45
+ "outcomes": sorted({outcome for item in items for outcome in item.evidence.outcomes}),
46
+ }
47
+ if not clusters:
48
+ return ConsensusResult(
49
+ state="insufficient", selected_cluster="", selected=[], dissent=[], clusters={},
50
+ confidence=0.0, source_count=0, book_count=0,
51
+ explanation="لا توجد شواهد اجتازت البوابة العامة." if query.language == "ar" else "No evidence passed the generic gate.",
52
+ )
53
+
54
+ ordered = sorted(clusters.items(), key=lambda pair: (float(pair[1]["weight"]), int(pair[1]["books"])), reverse=True)
55
+ selected_key, selected_cluster = ordered[0]
56
+
57
+ # A decisive ruling is preferred over a merely disputed formulation when both
58
+ # cover the same case. The disagreement remains visible as dissent.
59
+ if selected_key == "disputed" and query.asks_for_decisive_answer:
60
+ decisive_options = [pair for pair in ordered[1:] if pair[0] != "disputed"]
61
+ if decisive_options:
62
+ best_key, best_cluster = decisive_options[0]
63
+ if float(best_cluster["weight"]) >= 0.52 * float(selected_cluster["weight"]):
64
+ selected_key, selected_cluster = best_key, best_cluster
65
+
66
+ selected = sorted(selected_cluster["items"], key=lambda item: item.score, reverse=True)
67
+ dissent = sorted(
68
+ [item for key, cluster in ordered if key != selected_key for item in cluster["items"]],
69
+ key=lambda item: item.score,
70
+ reverse=True,
71
+ )
72
+ selected_weight = float(selected_cluster["weight"])
73
+ total_weight = sum(float(cluster["weight"]) for cluster in clusters.values())
74
+ share = selected_weight / max(total_weight, 1e-9)
75
+
76
+ conflict = False
77
+ selected_outcomes = set(selected_cluster.get("outcomes", []))
78
+ for key, cluster in ordered:
79
+ if key == selected_key:
80
+ continue
81
+ other_outcomes = set(cluster.get("outcomes", []))
82
+ if any(pair.issubset(selected_outcomes | other_outcomes) and pair & selected_outcomes and pair & other_outcomes for pair in self.incompatible):
83
+ relative = float(cluster["weight"]) / max(selected_weight, 1e-9)
84
+ if relative >= float(self.ranking.get("thresholds", {}).get("conflict_weight_ratio", 0.62)):
85
+ conflict = True
86
+ break
87
+
88
+ if conflict:
89
+ state = "conflict"
90
+ elif len(selected) >= 2 or int(selected_cluster["books"]) >= 2:
91
+ state = "agreement" if share >= 0.58 else "mixed"
92
+ else:
93
+ state = "single_source"
94
+
95
+ all_books = {item.evidence.book_id or item.evidence.book for item in accepted}
96
+ confidence = min(0.99, max(0.05, 0.58 * selected[0].score + 0.27 * share + 0.15 * min(1.0, len(all_books) / 4.0)))
97
+ explanation_map = {
98
+ "ar": {
99
+ "agreement": "تدعم عدة مصادر النتيجة نفسها.",
100
+ "mixed": "النتيجة الأقوى واضحة مع وجود صيغ أو تفاصيل إضافية.",
101
+ "conflict": "توجد نتائج قوية متعارضة، لذلك عُرضت الآراء منفصلة.",
102
+ "single_source": "يعتمد الجواب على أقوى شاهد مباشر متاح.",
103
+ },
104
+ "en": {
105
+ "agreement": "Multiple sources support the same result.",
106
+ "mixed": "The strongest result is clear, with additional formulations or details.",
107
+ "conflict": "Strong sources conflict, so the views are presented separately.",
108
+ "single_source": "The answer relies on the strongest direct evidence available.",
109
+ },
110
+ }
111
+ return ConsensusResult(
112
+ state=state,
113
+ selected_cluster=selected_key,
114
+ selected=selected,
115
+ dissent=dissent,
116
+ clusters=clusters,
117
+ confidence=float(confidence),
118
+ source_count=len(accepted),
119
+ book_count=len(all_books),
120
+ explanation=explanation_map[query.language][state],
121
+ )
hudanet_core/evidence.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Dict, List, Mapping, Tuple
5
+
6
+ from .text import TextProcessor
7
+ from .types import EvidenceFrame
8
+
9
+
10
+ class EvidenceAnalyzer:
11
+ def __init__(self, text: TextProcessor, semantic: dict, schema: dict):
12
+ self.text = text
13
+ self.semantic = semantic
14
+ self.schema = schema
15
+
16
+ @staticmethod
17
+ def _clean(value: Any) -> str:
18
+ text = str(value or "").strip()
19
+ if text.casefold() in {"nan", "none", "null", "n/a"}:
20
+ return ""
21
+ return re.sub(r"\s+", " ", text)
22
+
23
+ def _join_fields(self, source: Mapping[str, Any], names: List[str]) -> str:
24
+ return " ".join(filter(None, (self._clean(source.get(name, "")) for name in names)))
25
+
26
+ def analyze(self, source: Mapping[str, Any], lang: str) -> EvidenceFrame:
27
+ metadata_fields = list(self.schema.get("metadata_fields", []))
28
+ answer_fields = list(self.schema.get("answer_fields", []))
29
+ metadata_text = self._join_fields(source, metadata_fields)
30
+ answer_text = self._join_fields(source, answer_fields)
31
+ normalized_metadata = self.text.normalize(metadata_text, lang)
32
+ normalized_answer = self.text.normalize(answer_text, lang)
33
+ ruling_text = self._clean(source.get("ruling", ""))
34
+ normalized_outcome_text = self.text.normalize(f"{answer_text} {ruling_text}", lang)
35
+ all_text = f"{normalized_metadata} {normalized_answer}".strip()
36
+
37
+ answer_types: List[str] = []
38
+ for type_id, rule in self.semantic.get("request_types", {}).items():
39
+ patterns = (rule.get("evidence_patterns", {}) or {}).get(lang, []) or []
40
+ if any(self.text.phrase_hit(all_text, pattern) for pattern in patterns):
41
+ answer_types.append(type_id)
42
+
43
+ outcomes: List[str] = []
44
+ for outcome_id, rule in self.semantic.get("outcomes", {}).items():
45
+ patterns = (rule.get("patterns", {}) or {}).get(lang, []) or []
46
+ if any(self.text.phrase_hit(normalized_outcome_text, pattern) for pattern in patterns):
47
+ outcomes.append(outcome_id)
48
+
49
+ sentences = self.text.sentences(answer_text)
50
+ list_items = self.text.split_list_items(answer_text, lang)
51
+ content_terms = self.text.content_terms(f"{metadata_text} {answer_text}", lang)
52
+ decisive = 0.25
53
+ if outcomes:
54
+ decisive = max(float(self.semantic["outcomes"][name].get("decisiveness", 0.65)) for name in outcomes)
55
+ if "disputed" in outcomes and len(outcomes) == 1:
56
+ decisive = min(decisive, 0.38)
57
+ completeness = min(1.0, 0.18 + 0.06 * len(sentences) + 0.055 * len(list_items) + 0.012 * len(self.text.tokens(answer_text, lang)))
58
+ if len(normalized_answer) < 12:
59
+ completeness *= 0.45
60
+
61
+ return EvidenceFrame(
62
+ record_id=self._clean(source.get("record_id", "")),
63
+ book_id=self._clean(source.get("book_id", source.get("book", ""))),
64
+ book=self._clean(source.get("book", source.get("book_ar", source.get("book_en", "")))),
65
+ page=self._clean(source.get("page", source.get("page_number", ""))),
66
+ metadata_text=metadata_text,
67
+ answer_text=answer_text,
68
+ normalized_metadata=normalized_metadata,
69
+ normalized_answer=normalized_answer,
70
+ answer_types=tuple(dict.fromkeys(answer_types)) or ("unclassified",),
71
+ outcomes=tuple(dict.fromkeys(outcomes)) or ("unspecified",),
72
+ subject_terms=tuple(content_terms),
73
+ sentences=sentences,
74
+ list_items=list_items,
75
+ decisiveness=float(decisive),
76
+ completeness=float(completeness),
77
+ raw=source,
78
+ )
hudanet_core/pipeline.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence
5
+
6
+ from .compatibility import CompatibilityScorer
7
+ from .consensus import ConsensusAnalyzer
8
+ from .evidence import EvidenceAnalyzer
9
+ from .query import QueryAnalyzer
10
+ from .resources import ResourceBundle
11
+ from .synthesis import AnswerSynthesizer
12
+ from .text import TextProcessor
13
+ from .types import ConsensusResult, ResolutionResult, ScoredEvidence
14
+
15
+
16
+ class GenericEvidencePipeline:
17
+ """Generic, evidence-first resolution pipeline with no question-specific answers."""
18
+
19
+ def __init__(self, resource_root: Path, normalizers: Optional[Dict[str, Callable[[Any], str]]] = None):
20
+ self.resources = ResourceBundle(Path(resource_root))
21
+ self.text = TextProcessor(self.resources.semantic, normalizers=normalizers)
22
+ self.query_analyzer = QueryAnalyzer(self.text, self.resources.semantic)
23
+ self.evidence_analyzer = EvidenceAnalyzer(self.text, self.resources.semantic, self.resources.schema)
24
+ self.scorer = CompatibilityScorer(self.text, self.resources.semantic, self.resources.ranking)
25
+ self.consensus_analyzer = ConsensusAnalyzer(self.text, self.resources.semantic, self.resources.ranking)
26
+ self.synthesizer = AnswerSynthesizer(self.text, self.resources.semantic, self.resources.templates, self.resources.ranking)
27
+
28
+ def rank(self, query: Any, sources: Sequence[Mapping[str, Any]], lang: str) -> List[ScoredEvidence]:
29
+ frame = self.query_analyzer.analyze(query, lang)
30
+ scored: List[ScoredEvidence] = []
31
+ for source in sources or []:
32
+ evidence = self.evidence_analyzer.analyze(source, lang)
33
+ scored.append(self.scorer.score(frame, evidence, source))
34
+ scored.sort(key=lambda item: item.score, reverse=True)
35
+ return scored
36
+
37
+ def resolve(
38
+ self,
39
+ query: Any,
40
+ sources: Sequence[Mapping[str, Any]],
41
+ lang: str,
42
+ *,
43
+ style: str = "detailed",
44
+ compare_sources: bool = True,
45
+ ) -> ResolutionResult:
46
+ query_frame = self.query_analyzer.analyze(query, lang)
47
+ ranked: List[ScoredEvidence] = []
48
+ for source in sources or []:
49
+ evidence = self.evidence_analyzer.analyze(source, lang)
50
+ ranked.append(self.scorer.score(query_frame, evidence, source))
51
+ ranked.sort(key=lambda item: item.score, reverse=True)
52
+ accepted = [item for item in ranked if item.accepted]
53
+ rejected = [item for item in ranked if not item.accepted]
54
+ consensus = self.consensus_analyzer.analyze(query_frame, ranked)
55
+ answer = self.synthesizer.synthesize(
56
+ query_frame, consensus, style=style, compare_sources=compare_sources
57
+ )
58
+ return ResolutionResult(
59
+ answer=answer,
60
+ query=query_frame,
61
+ ranked=ranked,
62
+ accepted=accepted,
63
+ rejected=rejected,
64
+ consensus=consensus,
65
+ confidence=consensus.confidence,
66
+ details={
67
+ "request_type": query_frame.primary_request_type,
68
+ "subject_terms": list(query_frame.subject_terms),
69
+ "critical_terms": list(query_frame.critical_terms),
70
+ "polarity": query_frame.polarity,
71
+ "accepted": len(accepted),
72
+ "rejected": len(rejected),
73
+ "consensus_state": consensus.state,
74
+ "selected_cluster": consensus.selected_cluster,
75
+ },
76
+ )
77
+
78
+ def annotate_and_rebucket(self, query: Any, search: Mapping[str, Any], lang: str) -> Dict[str, Any]:
79
+ result = dict(search or {})
80
+ all_items: List[Dict[str, Any]] = []
81
+ for tier in ("exact", "related", "distant"):
82
+ for raw in result.get(tier, []) or []:
83
+ item = dict(raw)
84
+ item["generic_original_tier"] = tier
85
+ all_items.append(item)
86
+ resolution = self.resolve(query, all_items, lang, style="short", compare_sources=True)
87
+ exact: List[Dict[str, Any]] = []
88
+ related: List[Dict[str, Any]] = []
89
+ distant: List[Dict[str, Any]] = []
90
+ selected_ids = {
91
+ item.evidence.record_id or str(id(item.source)) for item in resolution.consensus.selected
92
+ }
93
+ for item in resolution.ranked:
94
+ source = dict(item.source)
95
+ source["generic_cluster"] = item.cluster_key
96
+ source["generic_selected_for_answer"] = (
97
+ item.evidence.record_id or str(id(item.source))
98
+ ) in selected_ids
99
+ original = source.get("generic_original_tier", "distant")
100
+ if item.accepted:
101
+ if original == "exact" and item.score >= 0.72:
102
+ exact.append(source)
103
+ elif item.promoted or original in {"exact", "related"}:
104
+ related.append(source)
105
+ else:
106
+ distant.append(source)
107
+ else:
108
+ source["generic_gate_rejected"] = True
109
+ distant.append(source)
110
+ exact.sort(key=lambda source: float(source.get("generic_score", 0.0)), reverse=True)
111
+ related.sort(key=lambda source: float(source.get("generic_score", 0.0)), reverse=True)
112
+ distant.sort(key=lambda source: float(source.get("generic_score", 0.0)), reverse=True)
113
+ result["exact"] = exact
114
+ result["related"] = related
115
+ result["distant"] = distant
116
+ result.setdefault("stats", {})["generic_request_type"] = resolution.query.primary_request_type
117
+ result["stats"]["generic_accepted"] = len(resolution.accepted)
118
+ result["stats"]["generic_rejected"] = len(resolution.rejected)
119
+ result["stats"]["generic_consensus_state"] = resolution.consensus.state
120
+ result["generic_resolution"] = resolution.details
121
+ return result
hudanet_core/query.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Dict, List, Tuple
5
+
6
+ from .text import TextProcessor
7
+ from .types import QueryFrame
8
+
9
+
10
+ class QueryAnalyzer:
11
+ def __init__(self, text: TextProcessor, semantic: dict):
12
+ self.text = text
13
+ self.semantic = semantic
14
+
15
+ def analyze(self, query: Any, lang: str) -> QueryFrame:
16
+ raw = str(query or "").strip()
17
+ normalized = self.text.normalize(raw, lang)
18
+ request_scores: List[Tuple[str, float]] = []
19
+ matched_intent_tokens = set()
20
+ for type_id, rule in self.semantic.get("request_types", {}).items():
21
+ patterns = (rule.get("patterns", {}) or {}).get(lang, []) or []
22
+ hits = sum(1 for pattern in patterns if self.text.phrase_hit(normalized, pattern))
23
+ if hits:
24
+ priority = float(rule.get("priority", 1.0) or 1.0)
25
+ request_scores.append((type_id, min(1.0, 0.58 + 0.18 * hits) * priority))
26
+ matched_intent_tokens.update(self.text.content_terms(" ".join(rule.get("keywords", {}).get(lang, [])), lang))
27
+ request_scores.sort(key=lambda item: item[1], reverse=True)
28
+ request_types = tuple(item[0] for item in request_scores)
29
+ primary = request_types[0] if request_types else "ruling"
30
+
31
+ all_terms = list(self.text.content_terms(normalized, lang))
32
+ generic_words = set(self.text.content_terms(" ".join(self.semantic.get("generic_question_words", {}).get(lang, [])), lang))
33
+ subject_terms = [term for term in all_terms if term not in matched_intent_tokens and term not in generic_words]
34
+ if not subject_terms:
35
+ subject_terms = all_terms
36
+
37
+ negation_markers = self.semantic.get("negation_markers", {}).get(lang, []) or []
38
+ polarity = "negative" if any(self.text.phrase_hit(normalized, pattern) for pattern in negation_markers) else "affirmative"
39
+ negated_terms: List[str] = []
40
+ if polarity == "negative":
41
+ tokens = self.text.tokens(normalized, lang)
42
+ marker_words = set(self.semantic.get("negation_tokens", {}).get(lang, []))
43
+ for index, token in enumerate(tokens):
44
+ if token in marker_words:
45
+ for following in tokens[index + 1:index + 5]:
46
+ stem = self.text.light_stem(following, lang)
47
+ if stem and stem not in negated_terms:
48
+ negated_terms.append(stem)
49
+
50
+ critical_terms = list(subject_terms)
51
+ for term in negated_terms:
52
+ if term not in critical_terms:
53
+ critical_terms.append(term)
54
+
55
+ dimensions: List[str] = []
56
+ for dimension, rule in self.semantic.get("dimensions", {}).items():
57
+ for pattern in (rule.get("patterns", {}) or {}).get(lang, []) or []:
58
+ if self.text.phrase_hit(normalized, pattern):
59
+ dimensions.append(dimension)
60
+ break
61
+
62
+ decisive_types = set(self.semantic.get("decisive_request_types", []))
63
+ asks_for_decisive = primary in decisive_types and not any(
64
+ self.text.phrase_hit(normalized, pattern)
65
+ for pattern in self.semantic.get("asks_for_disagreement", {}).get(lang, []) or []
66
+ )
67
+ asks_for_sources = any(
68
+ self.text.phrase_hit(normalized, pattern)
69
+ for pattern in self.semantic.get("asks_for_sources", {}).get(lang, []) or []
70
+ )
71
+ confidence = request_scores[0][1] if request_scores else 0.48
72
+ return QueryFrame(
73
+ raw=raw,
74
+ normalized=normalized,
75
+ language=lang,
76
+ request_types=request_types or ("ruling",),
77
+ primary_request_type=primary,
78
+ subject_terms=tuple(subject_terms),
79
+ critical_terms=tuple(critical_terms),
80
+ negated_terms=tuple(negated_terms),
81
+ polarity=polarity,
82
+ dimensions=tuple(dict.fromkeys(dimensions)),
83
+ asks_for_decisive_answer=asks_for_decisive,
84
+ asks_for_sources=asks_for_sources,
85
+ confidence=float(min(1.0, confidence)),
86
+ )
hudanet_core/resources.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+
8
+ class ResourceError(RuntimeError):
9
+ pass
10
+
11
+
12
+ class ResourceBundle:
13
+ REQUIRED = (
14
+ "semantic_rules.json",
15
+ "evidence_schema.json",
16
+ "ranking_config.json",
17
+ "answer_templates.json",
18
+ )
19
+
20
+ def __init__(self, root: Path):
21
+ self.root = Path(root)
22
+ self.data: Dict[str, Dict[str, Any]] = {}
23
+ for name in self.REQUIRED:
24
+ self.data[name] = self._load(name)
25
+ self._validate()
26
+
27
+ def _load(self, name: str) -> Dict[str, Any]:
28
+ path = self.root / name
29
+ if not path.is_file():
30
+ raise ResourceError(f"Missing HUDA-Net resource: {path}")
31
+ try:
32
+ value = json.loads(path.read_text(encoding="utf-8"))
33
+ except json.JSONDecodeError as exc:
34
+ raise ResourceError(
35
+ f"Invalid JSON in {path.name}, line {exc.lineno}, column {exc.colno}: {exc.msg}"
36
+ ) from exc
37
+ if not isinstance(value, dict):
38
+ raise ResourceError(f"{path.name} must contain a JSON object")
39
+ return value
40
+
41
+ def _validate(self) -> None:
42
+ semantic = self.semantic
43
+ required = ("request_types", "outcomes", "stopwords", "negation_markers")
44
+ missing = [key for key in required if key not in semantic]
45
+ if missing:
46
+ raise ResourceError(f"semantic_rules.json is missing: {', '.join(missing)}")
47
+ ranking = self.ranking
48
+ if "weights" not in ranking or "thresholds" not in ranking:
49
+ raise ResourceError("ranking_config.json must contain weights and thresholds")
50
+ total = sum(float(v) for v in ranking["weights"].values())
51
+ if not 0.98 <= total <= 1.02:
52
+ raise ResourceError(f"Ranking weights must sum to 1.0, got {total:.4f}")
53
+
54
+ @property
55
+ def semantic(self) -> Dict[str, Any]:
56
+ return self.data["semantic_rules.json"]
57
+
58
+ @property
59
+ def schema(self) -> Dict[str, Any]:
60
+ return self.data["evidence_schema.json"]
61
+
62
+ @property
63
+ def ranking(self) -> Dict[str, Any]:
64
+ return self.data["ranking_config.json"]
65
+
66
+ @property
67
+ def templates(self) -> Dict[str, Any]:
68
+ return self.data["answer_templates.json"]
hudanet_core/resources/answer_templates.json ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1.0.0",
3
+ "answer_prefix": {
4
+ "ar": "**الإجابة:** ",
5
+ "en": "**Answer:** "
6
+ },
7
+ "list_heading": {
8
+ "ar": "**الإجابة:** العناصر المستخلصة من أقوى الشواهد هي:",
9
+ "en": "**Answer:** The elements extracted from the strongest evidence are:"
10
+ },
11
+ "comparison_heading": {
12
+ "ar": "**الإجابة المقارنة:**",
13
+ "en": "**Comparative answer:**"
14
+ },
15
+ "sources_heading": {
16
+ "ar": "المصادر التي بُني عليها الجواب",
17
+ "en": "Sources used for the answer"
18
+ },
19
+ "dissent_heading": {
20
+ "ar": "صياغات أو آراء أخرى ظهرت في المصادر",
21
+ "en": "Other formulations or views found in the sources"
22
+ },
23
+ "conflict_intro": {
24
+ "ar": "**النتيجة:** توجد في المصادر المرفوعة نتائج قوية متعارضة، لذلك لا يصح دمجها في حكم واحد:",
25
+ "en": "**Result:** The uploaded sources contain strong conflicting outcomes, so they should not be merged into one ruling:"
26
+ },
27
+ "insufficient": {
28
+ "ar": "لم أجد شاهدًا يطابق نوع السؤال وقيود الواقعة بدرجة تكفي لبناء جواب موثق.",
29
+ "en": "No evidence matched the request type and case constraints strongly enough to build a grounded answer."
30
+ },
31
+ "preferred_answer_fields": ["answer_short", "answer_detailed", "answer", "answer_ar", "answer_en", "evidence", "ruling"],
32
+ "preferred_clause_markers": {
33
+ "ar": ["الخلاصة(?:\\s+المعتمدة)?(?:\\s+للسؤال)?\\s*[::]", "وخلاصتها\\s*[::]", "فالجواب\\s*[::]", "الإجابة\\s*[::]"],
34
+ "en": ["the answer is\\s*[::]", "in summary\\s*[::]", "the conclusion is\\s*[::]"]
35
+ },
36
+ "preamble_patterns": {
37
+ "ar": [
38
+ "^في\\s+كتاب\\s+.+?وردت\\s+المسالة\\s+بمعناها\\s*[::]\\s*",
39
+ "^ذكر\\s+المصنف\\s+.+?\\s*[::]\\s*",
40
+ "^بحسب\\s+النص\\s+المستخرج\\s*[::]\\s*"
41
+ ],
42
+ "en": [
43
+ "^in\\s+the\\s+book\\s+.+?the\\s+issue\\s+is\\s+stated\\s+as\\s*[::]\\s*",
44
+ "^according\\s+to\\s+the\\s+extracted\\s+text\\s*[::]\\s*"
45
+ ]
46
+ },
47
+ "drop_item_patterns": {
48
+ "ar": ["^(?:ذكر|قال|ورد|في كتاب|الخلاصة|المسالة|السؤال|الجواب)$", "^كتاب\\s+", "^المؤلف\\s+"],
49
+ "en": ["^(?:the book|the author|the question|the answer|in summary)$"]
50
+ }
51
+ }
hudanet_core/resources/evidence_schema.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1.0.0",
3
+ "description": "Generic field roles for HUDA-Net evidence records.",
4
+ "metadata_fields": [
5
+ "question",
6
+ "title",
7
+ "chapter",
8
+ "category",
9
+ "ruling",
10
+ "book",
11
+ "book_ar",
12
+ "book_en",
13
+ "madhhab",
14
+ "source_type"
15
+ ],
16
+ "answer_fields": [
17
+ "answer_short",
18
+ "answer_detailed",
19
+ "answer",
20
+ "answer_ar",
21
+ "answer_en",
22
+ "evidence",
23
+ "answer_evidence",
24
+ "answer_evidence_en"
25
+ ],
26
+ "identity_fields": [
27
+ "record_id",
28
+ "book_id",
29
+ "source_file",
30
+ "original_row"
31
+ ],
32
+ "source_fields": [
33
+ "book",
34
+ "author",
35
+ "chapter",
36
+ "page",
37
+ "page_number",
38
+ "source_type",
39
+ "madhhab",
40
+ "source_kind"
41
+ ]
42
+ }
hudanet_core/resources/ranking_config.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1.0.0",
3
+ "description": "Logic-first generic evidence ranking. Neural scores are deliberately a small prior.",
4
+ "weights": {
5
+ "request_type_match": 0.23,
6
+ "topic_match": 0.19,
7
+ "critical_constraint_coverage": 0.18,
8
+ "polarity_alignment": 0.10,
9
+ "answer_directness": 0.14,
10
+ "evidence_completeness": 0.07,
11
+ "source_quality": 0.05,
12
+ "neural_prior": 0.04
13
+ },
14
+ "thresholds": {
15
+ "accept": 0.53,
16
+ "promote_distant": 0.66,
17
+ "hard_type_mismatch": 0.18,
18
+ "minimum_topic_match": 0.16,
19
+ "minimum_critical_coverage": 0.22,
20
+ "minimum_negative_alignment": 0.34,
21
+ "conflict_weight_ratio": 0.62
22
+ },
23
+ "type_compatibility": {
24
+ "definition": {"definition": 1.0, "description": 0.45, "evidence": 0.25, "unclassified": 0.35},
25
+ "conditions": {"conditions": 1.0, "list": 0.92, "components": 0.78, "validity": 0.58, "ruling": 0.35, "unclassified": 0.35},
26
+ "pillars": {"pillars": 1.0, "list": 0.9, "components": 0.75, "conditions": 0.45, "unclassified": 0.35},
27
+ "duties": {"duties": 1.0, "list": 0.9, "components": 0.75, "ruling": 0.58, "unclassified": 0.35},
28
+ "ruling": {"ruling": 1.0, "validity": 0.74, "remedy": 0.72, "exception": 0.65, "evidence": 0.42, "unclassified": 0.38},
29
+ "validity": {"validity": 1.0, "ruling": 0.82, "conditions": 0.62, "unclassified": 0.35},
30
+ "remedy": {"remedy": 1.0, "ruling": 0.78, "duties": 0.58, "unclassified": 0.35},
31
+ "procedure": {"procedure": 1.0, "duties": 0.72, "components": 0.64, "ruling": 0.38, "unclassified": 0.35},
32
+ "timing": {"timing": 1.0, "procedure": 0.62, "ruling": 0.55, "unclassified": 0.35},
33
+ "amount": {"amount": 1.0, "remedy": 0.64, "list": 0.55, "unclassified": 0.35},
34
+ "location": {"location": 1.0, "procedure": 0.55, "conditions": 0.45, "unclassified": 0.35},
35
+ "cause": {"cause": 1.0, "evidence": 0.65, "ruling": 0.42, "unclassified": 0.35},
36
+ "comparison": {"comparison": 1.0, "definition": 0.72, "description": 0.65, "list": 0.55, "unclassified": 0.35},
37
+ "evidence": {"evidence": 1.0, "ruling": 0.62, "definition": 0.4, "unclassified": 0.35},
38
+ "exception": {"exception": 1.0, "ruling": 0.78, "conditions": 0.55, "unclassified": 0.35},
39
+ "list": {"list": 1.0, "conditions": 0.82, "pillars": 0.82, "duties": 0.82, "components": 0.82, "unclassified": 0.35},
40
+ "components": {"components": 1.0, "list": 0.9, "conditions": 0.7, "unclassified": 0.35},
41
+ "description": {"description": 1.0, "definition": 0.7, "procedure": 0.55, "unclassified": 0.35}
42
+ },
43
+ "limits": {
44
+ "max_list_items": 18,
45
+ "max_selected_sources": 6,
46
+ "max_dissent_sources": 3
47
+ }
48
+ }
hudanet_core/resources/semantic_rules.json ADDED
@@ -0,0 +1,1142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "1.0.0",
3
+ "description": "Generic bilingual semantic rules. No question, book, or fiqh answer is hard-coded.",
4
+ "decisive_request_types": [
5
+ "ruling",
6
+ "validity",
7
+ "remedy",
8
+ "timing",
9
+ "amount",
10
+ "location",
11
+ "exception"
12
+ ],
13
+ "generic_question_words": {
14
+ "ar": [
15
+ "ما",
16
+ "ماذا",
17
+ "ماهو",
18
+ "ماهي",
19
+ "هو",
20
+ "هي",
21
+ "الحكم",
22
+ "الشخص",
23
+ "المسالة",
24
+ "السؤال",
25
+ "عن",
26
+ "في",
27
+ "من",
28
+ "على",
29
+ "اذا",
30
+ "هل"
31
+ ],
32
+ "en": [
33
+ "what",
34
+ "which",
35
+ "who",
36
+ "when",
37
+ "where",
38
+ "how",
39
+ "is",
40
+ "are",
41
+ "the",
42
+ "ruling",
43
+ "person",
44
+ "case",
45
+ "question",
46
+ "if",
47
+ "whether"
48
+ ]
49
+ },
50
+ "stopwords": {
51
+ "ar": [
52
+ "ما",
53
+ "ماذا",
54
+ "هل",
55
+ "هو",
56
+ "هي",
57
+ "هذا",
58
+ "هذه",
59
+ "ذلك",
60
+ "تلك",
61
+ "الذي",
62
+ "التي",
63
+ "من",
64
+ "في",
65
+ "على",
66
+ "عن",
67
+ "الى",
68
+ "إلى",
69
+ "ثم",
70
+ "او",
71
+ "أو",
72
+ "و",
73
+ "ف",
74
+ "ب",
75
+ "ل",
76
+ "ك",
77
+ "كما",
78
+ "عند",
79
+ "مع",
80
+ "اذا",
81
+ "إذا",
82
+ "الشخص",
83
+ "المسالة",
84
+ "السؤال",
85
+ "الحكم",
86
+ "يمكن",
87
+ "يكون",
88
+ "كانت",
89
+ "كان",
90
+ "لم",
91
+ "لا",
92
+ "لن",
93
+ "ليس",
94
+ "ليست",
95
+ "عنه",
96
+ "عنها",
97
+ "عليها",
98
+ "عليه",
99
+ "لها",
100
+ "له",
101
+ "التي",
102
+ "الذي",
103
+ "توفرها"
104
+ ],
105
+ "en": [
106
+ "what",
107
+ "which",
108
+ "who",
109
+ "when",
110
+ "where",
111
+ "how",
112
+ "is",
113
+ "are",
114
+ "was",
115
+ "were",
116
+ "the",
117
+ "a",
118
+ "an",
119
+ "of",
120
+ "in",
121
+ "on",
122
+ "for",
123
+ "to",
124
+ "from",
125
+ "with",
126
+ "and",
127
+ "or",
128
+ "if",
129
+ "person",
130
+ "case",
131
+ "question",
132
+ "ruling",
133
+ "does",
134
+ "do",
135
+ "did",
136
+ "not",
137
+ "no",
138
+ "without",
139
+ "him",
140
+ "her",
141
+ "it",
142
+ "that",
143
+ "which"
144
+ ]
145
+ },
146
+ "negation_tokens": {
147
+ "ar": [
148
+ "لا",
149
+ "لم",
150
+ "لن",
151
+ "ليس",
152
+ "ليست",
153
+ "بدون",
154
+ "دون",
155
+ "غير",
156
+ "تعذر",
157
+ "عجز",
158
+ "فقد"
159
+ ],
160
+ "en": [
161
+ "not",
162
+ "no",
163
+ "never",
164
+ "without",
165
+ "cannot",
166
+ "unable",
167
+ "failed",
168
+ "missing"
169
+ ]
170
+ },
171
+ "negation_markers": {
172
+ "ar": [
173
+ "(?:^|\\s)(?:لا|لم|لن|ليس|ليست|بدون|دون|غير)(?:\\s|$)",
174
+ "(?:تعذر|عجز|فقد|امتنع)"
175
+ ],
176
+ "en": [
177
+ "\\b(?:not|no|never|without|cannot|can't|unable|failed|missing)\\b"
178
+ ]
179
+ },
180
+ "asks_for_disagreement": {
181
+ "ar": [
182
+ "(?:هل|ما)\\s+(?:فيه|فيها)\\s+(?:خلاف|قولان|وجهان)",
183
+ "ما\\s+اقوال",
184
+ "اذكر\\s+الخلاف"
185
+ ],
186
+ "en": [
187
+ "\\b(?:views|opinions|disagreement|difference of opinion)\\b"
188
+ ]
189
+ },
190
+ "asks_for_sources": {
191
+ "ar": [
192
+ "(?:ما|اذكر)\\s+(?:الدليل|المصدر|المصادر|النص)",
193
+ "من\\s+اي\\s+كتاب"
194
+ ],
195
+ "en": [
196
+ "\\b(?:source|sources|evidence|proof|citation|which book)\\b"
197
+ ]
198
+ },
199
+ "dimensions": {
200
+ "linguistic": {
201
+ "patterns": {
202
+ "ar": [
203
+ "لغة",
204
+ "لغويا"
205
+ ],
206
+ "en": [
207
+ "linguistically",
208
+ "language meaning"
209
+ ]
210
+ }
211
+ },
212
+ "technical": {
213
+ "patterns": {
214
+ "ar": [
215
+ "شرعا",
216
+ "اصطلاحا"
217
+ ],
218
+ "en": [
219
+ "legally",
220
+ "technically",
221
+ "in sharia"
222
+ ]
223
+ }
224
+ },
225
+ "obligation": {
226
+ "patterns": {
227
+ "ar": [
228
+ "وجوب",
229
+ "يجب",
230
+ "واجب"
231
+ ],
232
+ "en": [
233
+ "obligation",
234
+ "obligatory",
235
+ "required"
236
+ ]
237
+ }
238
+ },
239
+ "validity": {
240
+ "patterns": {
241
+ "ar": [
242
+ "صحة",
243
+ "صحيح",
244
+ "يصح"
245
+ ],
246
+ "en": [
247
+ "validity",
248
+ "valid",
249
+ "validly"
250
+ ]
251
+ }
252
+ }
253
+ },
254
+ "request_types": {
255
+ "definition": {
256
+ "priority": 1.12,
257
+ "keywords": {
258
+ "ar": [
259
+ "تعريف",
260
+ "معنى",
261
+ "لغة",
262
+ "شرعا"
263
+ ],
264
+ "en": [
265
+ "definition",
266
+ "meaning",
267
+ "linguistically",
268
+ "legally"
269
+ ]
270
+ },
271
+ "patterns": {
272
+ "ar": [
273
+ "(?:ما|ماهو|ما هي)\\s+(?:تعريف|معنى)",
274
+ "لغة\\s+وشرعا",
275
+ "ما\\s+المقصود"
276
+ ],
277
+ "en": [
278
+ "what\\s+(?:is|does).*(?:definition|mean)",
279
+ "linguistically.*legally"
280
+ ]
281
+ },
282
+ "evidence_patterns": {
283
+ "ar": [
284
+ "\\bتعريف\\b",
285
+ "لغة.*شرعا",
286
+ "معناه",
287
+ "هو\\s+.+?\\s+شرعا"
288
+ ],
289
+ "en": [
290
+ "\\bdefinition\\b",
291
+ "linguistically.*legally",
292
+ "means",
293
+ "is defined as"
294
+ ]
295
+ }
296
+ },
297
+ "conditions": {
298
+ "priority": 1.1,
299
+ "keywords": {
300
+ "ar": [
301
+ "شروط",
302
+ "يشترط",
303
+ "شرط",
304
+ "يتوقف",
305
+ "وجوب",
306
+ "يجب",
307
+ "توفر",
308
+ "متطلبات"
309
+ ],
310
+ "en": [
311
+ "conditions",
312
+ "requirements",
313
+ "prerequisites"
314
+ ]
315
+ },
316
+ "patterns": {
317
+ "ar": [
318
+ "(?:ما|ماهي|ما هي)\\s+الشروط",
319
+ "شروط\\s+",
320
+ "ما\\s+الذي\\s+يشترط",
321
+ "يتوقف.*على"
322
+ ],
323
+ "en": [
324
+ "what\\s+are\\s+the\\s+(?:conditions|requirements|prerequisites)",
325
+ "conditions?\\s+for",
326
+ "what\\s+is\\s+required\\s+for"
327
+ ]
328
+ },
329
+ "evidence_patterns": {
330
+ "ar": [
331
+ "\\bشروط?\\b",
332
+ "يشترط",
333
+ "لا\\s+.+?\\s+الا\\s+ب",
334
+ "شرط\\s+(?:الوجوب|الصحة|الاجزاء)"
335
+ ],
336
+ "en": [
337
+ "\\bconditions?\\b",
338
+ "\\brequirements?\\b",
339
+ "prerequisites",
340
+ "is conditional upon"
341
+ ]
342
+ }
343
+ },
344
+ "pillars": {
345
+ "priority": 1.08,
346
+ "keywords": {
347
+ "ar": [
348
+ "اركان",
349
+ "ركن"
350
+ ],
351
+ "en": [
352
+ "pillars",
353
+ "pillar"
354
+ ]
355
+ },
356
+ "patterns": {
357
+ "ar": [
358
+ "ما\\s+(?:هي\\s+)?اركان",
359
+ "اركان\\s+"
360
+ ],
361
+ "en": [
362
+ "what\\s+are\\s+the\\s+pillars",
363
+ "pillars?\\s+of"
364
+ ]
365
+ },
366
+ "evidence_patterns": {
367
+ "ar": [
368
+ "\\bاركان?\\b",
369
+ "\\bركن\\b"
370
+ ],
371
+ "en": [
372
+ "\\bpillars?\\b"
373
+ ]
374
+ }
375
+ },
376
+ "duties": {
377
+ "priority": 1.07,
378
+ "keywords": {
379
+ "ar": [
380
+ "واجبات",
381
+ "الواجب",
382
+ "يلزم"
383
+ ],
384
+ "en": [
385
+ "duties",
386
+ "obligations",
387
+ "required acts"
388
+ ]
389
+ },
390
+ "patterns": {
391
+ "ar": [
392
+ "ما\\s+(?:هي\\s+)?الواجبات",
393
+ "ما\\s+الواجب",
394
+ "ماذا\\s+يلزم"
395
+ ],
396
+ "en": [
397
+ "what\\s+are\\s+the\\s+(?:duties|obligations)",
398
+ "what\\s+is\\s+required"
399
+ ]
400
+ },
401
+ "evidence_patterns": {
402
+ "ar": [
403
+ "\\bواجبات?\\b",
404
+ "\\bيلزم\\b",
405
+ "\\bيجب\\b"
406
+ ],
407
+ "en": [
408
+ "\\bduties\\b",
409
+ "\\bobligations\\b",
410
+ "\\brequired\\b"
411
+ ]
412
+ }
413
+ },
414
+ "validity": {
415
+ "priority": 1.09,
416
+ "keywords": {
417
+ "ar": [
418
+ "صحة",
419
+ "صحيح",
420
+ "يصح",
421
+ "يبطل",
422
+ "فساد"
423
+ ],
424
+ "en": [
425
+ "valid",
426
+ "validity",
427
+ "invalid",
428
+ "invalidate"
429
+ ]
430
+ },
431
+ "patterns": {
432
+ "ar": [
433
+ "هل\\s+(?:يصح|يبطل)",
434
+ "ما\\s+حكم\\s+صحة",
435
+ "صحة\\s+"
436
+ ],
437
+ "en": [
438
+ "is\\s+.+?\\s+valid",
439
+ "does\\s+.+?\\s+invalidate",
440
+ "validity\\s+of"
441
+ ]
442
+ },
443
+ "evidence_patterns": {
444
+ "ar": [
445
+ "\\b(?:يصح|صحيح|لا يصح|باطل|يبطل|فسد|يفسد)\\b"
446
+ ],
447
+ "en": [
448
+ "\\b(?:valid|invalid|invalidates|void)\\b"
449
+ ]
450
+ }
451
+ },
452
+ "remedy": {
453
+ "priority": 1.08,
454
+ "keywords": {
455
+ "ar": [
456
+ "فدية",
457
+ "دم",
458
+ "كفارة",
459
+ "قضاء",
460
+ "ماذا يلزم"
461
+ ],
462
+ "en": [
463
+ "fidyah",
464
+ "sacrifice",
465
+ "expiation",
466
+ "make up",
467
+ "remedy"
468
+ ]
469
+ },
470
+ "patterns": {
471
+ "ar": [
472
+ "ما\\s+(?:يلزمه|يلزم|عليه)",
473
+ "ما\\s+(?:الفدية|الكفارة)",
474
+ "هل\\s+عليه\\s+(?:دم|فدية)"
475
+ ],
476
+ "en": [
477
+ "what\\s+(?:is|becomes)\\s+due",
478
+ "what\\s+(?:fidyah|sacrifice|expiation)",
479
+ "does\\s+.+?\\s+owe"
480
+ ]
481
+ },
482
+ "evidence_patterns": {
483
+ "ar": [
484
+ "\\b(?:فدية|دم|كفارة|قضاء|شاة|بدنة|صيام|اطعام)\\b"
485
+ ],
486
+ "en": [
487
+ "\\b(?:fidyah|sacrifice|expiation|make-up|camel|sheep|fasting|feeding)\\b"
488
+ ]
489
+ }
490
+ },
491
+ "timing": {
492
+ "priority": 1.08,
493
+ "keywords": {
494
+ "ar": [
495
+ "متى",
496
+ "وقت",
497
+ "يبدا",
498
+ "ينتهي"
499
+ ],
500
+ "en": [
501
+ "when",
502
+ "time",
503
+ "begin",
504
+ "end"
505
+ ]
506
+ },
507
+ "patterns": {
508
+ "ar": [
509
+ "^متى",
510
+ "ما\\s+(?:هو\\s+)?وقت",
511
+ "متى\\s+(?:يبدا|ينتهي)",
512
+ "الى\\s+متى"
513
+ ],
514
+ "en": [
515
+ "^when",
516
+ "what\\s+time",
517
+ "when\\s+does\\s+.+?\\s+(?:begin|end)"
518
+ ]
519
+ },
520
+ "evidence_patterns": {
521
+ "ar": [
522
+ "\\bوقت\\b",
523
+ "يبدا",
524
+ "ينتهي",
525
+ "من\\s+.+?\\s+الى"
526
+ ],
527
+ "en": [
528
+ "\\btime\\b",
529
+ "begins",
530
+ "ends",
531
+ "from\\s+.+?\\s+until"
532
+ ]
533
+ }
534
+ },
535
+ "amount": {
536
+ "priority": 1.04,
537
+ "keywords": {
538
+ "ar": [
539
+ "كم",
540
+ "عدد",
541
+ "مقدار"
542
+ ],
543
+ "en": [
544
+ "how many",
545
+ "how much",
546
+ "number",
547
+ "amount"
548
+ ]
549
+ },
550
+ "patterns": {
551
+ "ar": [
552
+ "^كم",
553
+ "ما\\s+(?:هو\\s+)?(?:عدد|مقدار)"
554
+ ],
555
+ "en": [
556
+ "^how\\s+(?:many|much)",
557
+ "what\\s+(?:number|amount)"
558
+ ]
559
+ },
560
+ "evidence_patterns": {
561
+ "ar": [
562
+ "\\b(?:عدد|مقدار)\\b",
563
+ "\\b(?:واحد|اثنان|ثلاثة|اربعة|خمسة|ستة|سبعة|ثمانية|تسعة|عشرة)\\b",
564
+ "\\d+"
565
+ ],
566
+ "en": [
567
+ "\\b(?:number|amount)\\b",
568
+ "\\d+"
569
+ ]
570
+ }
571
+ },
572
+ "location": {
573
+ "priority": 1.04,
574
+ "keywords": {
575
+ "ar": [
576
+ "اين",
577
+ "مكان",
578
+ "من اين"
579
+ ],
580
+ "en": [
581
+ "where",
582
+ "location",
583
+ "place"
584
+ ]
585
+ },
586
+ "patterns": {
587
+ "ar": [
588
+ "^اين",
589
+ "من\\s+اين",
590
+ "ما\\s+(?:هو\\s+)?مكان"
591
+ ],
592
+ "en": [
593
+ "^where",
594
+ "from\\s+where",
595
+ "what\\s+place"
596
+ ]
597
+ },
598
+ "evidence_patterns": {
599
+ "ar": [
600
+ "\\bمكان\\b",
601
+ "في\\s+.+",
602
+ "من\\s+.+"
603
+ ],
604
+ "en": [
605
+ "\\bplace\\b",
606
+ "\\blocation\\b",
607
+ "at\\s+.+",
608
+ "from\\s+.+"
609
+ ]
610
+ }
611
+ },
612
+ "cause": {
613
+ "priority": 1.04,
614
+ "keywords": {
615
+ "ar": [
616
+ "لماذا",
617
+ "سبب",
618
+ "علة",
619
+ "حكمة"
620
+ ],
621
+ "en": [
622
+ "why",
623
+ "reason",
624
+ "cause",
625
+ "wisdom"
626
+ ]
627
+ },
628
+ "patterns": {
629
+ "ar": [
630
+ "^لماذا",
631
+ "ما\\s+(?:هو\\s+)?(?:السبب|العلة|الحكمة)"
632
+ ],
633
+ "en": [
634
+ "^why",
635
+ "what\\s+(?:is\\s+)?the\\s+(?:reason|cause|wisdom)"
636
+ ]
637
+ },
638
+ "evidence_patterns": {
639
+ "ar": [
640
+ "\\b(?:سبب|علة|حكمة|لان|لأن)\\b"
641
+ ],
642
+ "en": [
643
+ "\\b(?:reason|cause|because|wisdom)\\b"
644
+ ]
645
+ }
646
+ },
647
+ "comparison": {
648
+ "priority": 1.06,
649
+ "keywords": {
650
+ "ar": [
651
+ "الفرق",
652
+ "قارن",
653
+ "مقارنة"
654
+ ],
655
+ "en": [
656
+ "difference",
657
+ "compare",
658
+ "comparison"
659
+ ]
660
+ },
661
+ "patterns": {
662
+ "ar": [
663
+ "ما\\s+الفرق",
664
+ "قارن\\s+بين",
665
+ "مقارنة\\s+بين"
666
+ ],
667
+ "en": [
668
+ "what\\s+is\\s+the\\s+difference",
669
+ "compare\\s+",
670
+ "comparison\\s+between"
671
+ ]
672
+ },
673
+ "evidence_patterns": {
674
+ "ar": [
675
+ "الفرق\\s+بين",
676
+ "اما\\s+.+?\\s+واما",
677
+ "ينقسم\\s+الى"
678
+ ],
679
+ "en": [
680
+ "difference\\s+between",
681
+ "whereas",
682
+ "types\\s+are"
683
+ ]
684
+ }
685
+ },
686
+ "procedure": {
687
+ "priority": 1.03,
688
+ "keywords": {
689
+ "ar": [
690
+ "كيف",
691
+ "صفة",
692
+ "طريقة",
693
+ "ماذا يفعل"
694
+ ],
695
+ "en": [
696
+ "how",
697
+ "procedure",
698
+ "method",
699
+ "what should do"
700
+ ]
701
+ },
702
+ "patterns": {
703
+ "ar": [
704
+ "^كيف",
705
+ "ما\\s+(?:هي\\s+)?صفة",
706
+ "ما\\s+(?:هي\\s+)?طريقة",
707
+ "ماذا\\s+يفعل"
708
+ ],
709
+ "en": [
710
+ "^how",
711
+ "what\\s+is\\s+the\\s+(?:procedure|method)",
712
+ "what\\s+should\\s+.+?\\s+do"
713
+ ]
714
+ },
715
+ "evidence_patterns": {
716
+ "ar": [
717
+ "ثم\\s+",
718
+ "يفعل",
719
+ "يبدا",
720
+ "يذهب",
721
+ "يرجع",
722
+ "يطوف",
723
+ "يسعى"
724
+ ],
725
+ "en": [
726
+ "then",
727
+ "should do",
728
+ "begins",
729
+ "goes",
730
+ "returns",
731
+ "performs"
732
+ ]
733
+ }
734
+ },
735
+ "evidence": {
736
+ "priority": 1.02,
737
+ "keywords": {
738
+ "ar": [
739
+ "دليل",
740
+ "نص",
741
+ "مصدر"
742
+ ],
743
+ "en": [
744
+ "evidence",
745
+ "proof",
746
+ "source",
747
+ "text"
748
+ ]
749
+ },
750
+ "patterns": {
751
+ "ar": [
752
+ "ما\\s+(?:هو\\s+)?الدليل",
753
+ "اذكر\\s+(?:النص|المصدر)"
754
+ ],
755
+ "en": [
756
+ "what\\s+is\\s+the\\s+(?:evidence|proof)",
757
+ "cite\\s+the\\s+source"
758
+ ]
759
+ },
760
+ "evidence_patterns": {
761
+ "ar": [
762
+ "قال\\s+الله",
763
+ "قال\\s+النبي",
764
+ "الدليل",
765
+ "نص\\s+"
766
+ ],
767
+ "en": [
768
+ "evidence",
769
+ "proof",
770
+ "the text states",
771
+ "reported"
772
+ ]
773
+ }
774
+ },
775
+ "exception": {
776
+ "priority": 1.02,
777
+ "keywords": {
778
+ "ar": [
779
+ "استثناء",
780
+ "الا",
781
+ "متى يسقط"
782
+ ],
783
+ "en": [
784
+ "exception",
785
+ "unless",
786
+ "when waived"
787
+ ]
788
+ },
789
+ "patterns": {
790
+ "ar": [
791
+ "ما\\s+(?:هو\\s+)?الاستثناء",
792
+ "متى\\s+يسقط",
793
+ "هل\\s+يستثنى"
794
+ ],
795
+ "en": [
796
+ "what\\s+is\\s+the\\s+exception",
797
+ "when\\s+is\\s+.+?\\s+waived",
798
+ "is\\s+.+?\\s+excepted"
799
+ ]
800
+ },
801
+ "evidence_patterns": {
802
+ "ar": [
803
+ "الا\\s+",
804
+ "يستثنى",
805
+ "يسقط",
806
+ "يعفى"
807
+ ],
808
+ "en": [
809
+ "unless",
810
+ "except",
811
+ "waived",
812
+ "exempt"
813
+ ]
814
+ }
815
+ },
816
+ "list": {
817
+ "priority": 0.95,
818
+ "keywords": {
819
+ "ar": [
820
+ "اذكر",
821
+ "عدد",
822
+ "ما هي"
823
+ ],
824
+ "en": [
825
+ "list",
826
+ "enumerate",
827
+ "what are"
828
+ ]
829
+ },
830
+ "patterns": {
831
+ "ar": [
832
+ "^اذكر",
833
+ "عدد\\s+",
834
+ "ما\\s+هي\\s+"
835
+ ],
836
+ "en": [
837
+ "^list",
838
+ "^enumerate",
839
+ "what\\s+are\\s+the"
840
+ ]
841
+ },
842
+ "evidence_patterns": {
843
+ "ar": [
844
+ "(?:هي|وهي|خمسة|اربعة|ثلاثة|ستة|سبعة|ثمانية|تسعة|عشرة)\\s*[::]"
845
+ ],
846
+ "en": [
847
+ "(?:are|include|consist of)\\s*[::]"
848
+ ]
849
+ }
850
+ },
851
+ "components": {
852
+ "priority": 0.98,
853
+ "keywords": {
854
+ "ar": [
855
+ "عناصر",
856
+ "اجزاء",
857
+ "مكونات"
858
+ ],
859
+ "en": [
860
+ "components",
861
+ "elements",
862
+ "parts"
863
+ ]
864
+ },
865
+ "patterns": {
866
+ "ar": [
867
+ "ما\\s+(?:هي\\s+)?(?:العناصر|الاجزاء|المكونات)"
868
+ ],
869
+ "en": [
870
+ "what\\s+are\\s+the\\s+(?:components|elements|parts)"
871
+ ]
872
+ },
873
+ "evidence_patterns": {
874
+ "ar": [
875
+ "(?:العناصر|الاجزاء|المكونات)"
876
+ ],
877
+ "en": [
878
+ "(?:components|elements|parts)"
879
+ ]
880
+ }
881
+ },
882
+ "description": {
883
+ "priority": 0.82,
884
+ "keywords": {
885
+ "ar": [
886
+ "صف",
887
+ "بيان",
888
+ "ما هو"
889
+ ],
890
+ "en": [
891
+ "describe",
892
+ "explain",
893
+ "what is"
894
+ ]
895
+ },
896
+ "patterns": {
897
+ "ar": [
898
+ "^صف",
899
+ "^بين",
900
+ "^ما\\s+هو"
901
+ ],
902
+ "en": [
903
+ "^describe",
904
+ "^explain",
905
+ "^what\\s+is"
906
+ ]
907
+ },
908
+ "evidence_patterns": {
909
+ "ar": [
910
+ "بيان",
911
+ "صفة",
912
+ "وصف"
913
+ ],
914
+ "en": [
915
+ "description",
916
+ "explains",
917
+ "is a"
918
+ ]
919
+ }
920
+ },
921
+ "ruling": {
922
+ "priority": 1.0,
923
+ "keywords": {
924
+ "ar": [
925
+ "حكم",
926
+ "يجوز",
927
+ "يجب",
928
+ "يحرم",
929
+ "حكم",
930
+ "الحكم"
931
+ ],
932
+ "en": [
933
+ "ruling",
934
+ "permissible",
935
+ "obligatory",
936
+ "prohibited"
937
+ ]
938
+ },
939
+ "patterns": {
940
+ "ar": [
941
+ "ما\\s+(?:هو\\s+)?الحكم",
942
+ "ما\\s+حكم",
943
+ "هل\\s+(?:يجوز|يجب|يحرم|يلزم)",
944
+ "ماذا\\s+يترتب"
945
+ ],
946
+ "en": [
947
+ "what\\s+is\\s+the\\s+ruling",
948
+ "is\\s+.+?\\s+(?:permissible|obligatory|prohibited)",
949
+ "what\\s+follows\\s+if"
950
+ ]
951
+ },
952
+ "evidence_patterns": {
953
+ "ar": [
954
+ "\\b(?:يجوز|لا يجوز|يجب|لا يجب|يحرم|واجب|جائز|يسقط|يلزم|لا يلزم|وجهان|قولان|روايتان)\\b"
955
+ ],
956
+ "en": [
957
+ "\\b(?:permissible|not permissible|obligatory|not obligatory|prohibited|required|waived|two views|disputed)\\b"
958
+ ]
959
+ }
960
+ }
961
+ },
962
+ "outcomes": {
963
+ "obligatory": {
964
+ "decisiveness": 0.94,
965
+ "patterns": {
966
+ "ar": [
967
+ "(?:^|\\s)(?:يجب|واجب|يلزم|لازم)(?:\\s|$)"
968
+ ],
969
+ "en": [
970
+ "\\b(?:obligatory|required|must|is due)\\b"
971
+ ]
972
+ }
973
+ },
974
+ "not_obligatory": {
975
+ "decisiveness": 0.96,
976
+ "patterns": {
977
+ "ar": [
978
+ "(?:لا\\s+يجب|لا\\s+يلزم|غير\\s+واجب)"
979
+ ],
980
+ "en": [
981
+ "\\b(?:not obligatory|not required|does not have to)\\b"
982
+ ]
983
+ }
984
+ },
985
+ "obligation_dropped": {
986
+ "decisiveness": 0.98,
987
+ "patterns": {
988
+ "ar": [
989
+ "(?:يسقط|سقط).*?(?:الوجوب|وجوب)",
990
+ "يعفى.*?(?:الوجوب|الواجب)"
991
+ ],
992
+ "en": [
993
+ "(?:obligation|duty).*?(?:is dropped|falls away|is waived)",
994
+ "no longer obligatory"
995
+ ]
996
+ }
997
+ },
998
+ "permissible": {
999
+ "decisiveness": 0.94,
1000
+ "patterns": {
1001
+ "ar": [
1002
+ "(?:^|\\s)(?:يجوز|جائز|مباح)(?:\\s|$)"
1003
+ ],
1004
+ "en": [
1005
+ "\\b(?:permissible|allowed|may)\\b"
1006
+ ]
1007
+ }
1008
+ },
1009
+ "prohibited": {
1010
+ "decisiveness": 0.96,
1011
+ "patterns": {
1012
+ "ar": [
1013
+ "(?:لا\\s+يجوز|يحرم|حرام|محظور)"
1014
+ ],
1015
+ "en": [
1016
+ "\\b(?:not permissible|prohibited|forbidden)\\b"
1017
+ ]
1018
+ }
1019
+ },
1020
+ "valid": {
1021
+ "decisiveness": 0.93,
1022
+ "patterns": {
1023
+ "ar": [
1024
+ "(?:يصح|صحيح|اجزا|مجزئ)"
1025
+ ],
1026
+ "en": [
1027
+ "\\b(?:valid|validly performed|sufficient)\\b"
1028
+ ]
1029
+ }
1030
+ },
1031
+ "invalid": {
1032
+ "decisiveness": 0.97,
1033
+ "patterns": {
1034
+ "ar": [
1035
+ "(?:لا\\s+يصح|باطل|يبطل|فسد|يفسد)"
1036
+ ],
1037
+ "en": [
1038
+ "\\b(?:invalid|void|invalidates)\\b"
1039
+ ]
1040
+ }
1041
+ },
1042
+ "recommended": {
1043
+ "decisiveness": 0.87,
1044
+ "patterns": {
1045
+ "ar": [
1046
+ "(?:مستحب|سنة|مندوب)"
1047
+ ],
1048
+ "en": [
1049
+ "\\b(?:recommended|sunnah)\\b"
1050
+ ]
1051
+ }
1052
+ },
1053
+ "disliked": {
1054
+ "decisiveness": 0.86,
1055
+ "patterns": {
1056
+ "ar": [
1057
+ "(?:مكروه|كراهة)"
1058
+ ],
1059
+ "en": [
1060
+ "\\b(?:disliked|makruh)\\b"
1061
+ ]
1062
+ }
1063
+ },
1064
+ "remedy_required": {
1065
+ "decisiveness": 0.92,
1066
+ "patterns": {
1067
+ "ar": [
1068
+ "(?:عليه|يلزمه|يجب عليه).*?(?:دم|فدية|كفارة|قضاء|صيام|اطعام|شاة|بدنة)"
1069
+ ],
1070
+ "en": [
1071
+ "(?:owes|must offer|is required).*?(?:fidyah|sacrifice|expiation|make up|fasting|feeding)"
1072
+ ]
1073
+ }
1074
+ },
1075
+ "no_remedy": {
1076
+ "decisiveness": 0.93,
1077
+ "patterns": {
1078
+ "ar": [
1079
+ "(?:لا\\s+شيء\\s+عليه|لا\\s+دم\\s+عليه|لا\\s+فدية)"
1080
+ ],
1081
+ "en": [
1082
+ "(?:nothing is due|no sacrifice is due|no fidyah)"
1083
+ ]
1084
+ }
1085
+ },
1086
+ "disputed": {
1087
+ "decisiveness": 0.36,
1088
+ "patterns": {
1089
+ "ar": [
1090
+ "(?:وجهان|قولان|روايتان|فيه\\s+خلاف|اختلف)"
1091
+ ],
1092
+ "en": [
1093
+ "(?:two views|two opinions|two reports|disputed|scholars differ)"
1094
+ ]
1095
+ }
1096
+ },
1097
+ "condition": {
1098
+ "decisiveness": 0.78,
1099
+ "patterns": {
1100
+ "ar": [
1101
+ "(?:شرط|يشترط|من\\s+شروط)"
1102
+ ],
1103
+ "en": [
1104
+ "\\b(?:condition|prerequisite|required condition)\\b"
1105
+ ]
1106
+ }
1107
+ },
1108
+ "pillar": {
1109
+ "decisiveness": 0.82,
1110
+ "patterns": {
1111
+ "ar": [
1112
+ "(?:ركن|من\\s+اركان)"
1113
+ ],
1114
+ "en": [
1115
+ "\\b(?:pillar|essential pillar)\\b"
1116
+ ]
1117
+ }
1118
+ }
1119
+ },
1120
+ "incompatible_outcomes": [
1121
+ [
1122
+ "obligatory",
1123
+ "not_obligatory"
1124
+ ],
1125
+ [
1126
+ "obligatory",
1127
+ "obligation_dropped"
1128
+ ],
1129
+ [
1130
+ "permissible",
1131
+ "prohibited"
1132
+ ],
1133
+ [
1134
+ "valid",
1135
+ "invalid"
1136
+ ],
1137
+ [
1138
+ "remedy_required",
1139
+ "no_remedy"
1140
+ ]
1141
+ ]
1142
+ }
hudanet_core/synthesis.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Dict, Iterable, List, Sequence, Set, Tuple
5
+
6
+ from .text import TextProcessor
7
+ from .types import ConsensusResult, QueryFrame, ScoredEvidence
8
+
9
+
10
+ class AnswerSynthesizer:
11
+ def __init__(self, text: TextProcessor, semantic: dict, templates: dict, ranking: dict):
12
+ self.text = text
13
+ self.semantic = semantic
14
+ self.templates = templates
15
+ self.ranking = ranking
16
+
17
+ @staticmethod
18
+ def _clean(value: str) -> str:
19
+ return re.sub(r"\s+", " ", str(value or "")).strip()
20
+
21
+ def _source_label(self, item: ScoredEvidence, lang: str) -> str:
22
+ book = item.evidence.book or ("مصدر غير مسمى" if lang == "ar" else "Unnamed source")
23
+ page = item.evidence.page
24
+ return f"{book} (ص {page})" if lang == "ar" and page else (f"{book} (p. {page})" if page else book)
25
+
26
+ def _strip_preamble(self, text: str, lang: str) -> str:
27
+ value = self._clean(text)
28
+ patterns = self.templates.get("preamble_patterns", {}).get(lang, []) or []
29
+ for pattern in patterns:
30
+ value = re.sub(pattern, "", value, flags=re.I).strip(" .:؛-")
31
+ markers = self.templates.get("preferred_clause_markers", {}).get(lang, []) or []
32
+ for marker in markers:
33
+ match = re.search(marker, value, re.I)
34
+ if match and match.end() < len(value):
35
+ candidate = value[match.end():].strip(" .:؛-")
36
+ if len(candidate) >= 12:
37
+ value = candidate
38
+ return value
39
+
40
+ def _sentence_score(self, sentence: str, query: QueryFrame, item: ScoredEvidence) -> float:
41
+ sentence_terms = self.text.content_terms(sentence, query.language)
42
+ critical = self.text.fuzzy_term_overlap(query.critical_terms, sentence_terms)
43
+ subject = self.text.fuzzy_term_overlap(query.subject_terms, sentence_terms)
44
+ outcome_bonus = 0.0
45
+ normalized = self.text.normalize(sentence, query.language)
46
+ for outcome in item.evidence.outcomes:
47
+ for pattern in self.semantic.get("outcomes", {}).get(outcome, {}).get("patterns", {}).get(query.language, []) or []:
48
+ if self.text.phrase_hit(normalized, pattern):
49
+ outcome_bonus = max(outcome_bonus, 1.0)
50
+ return 0.43 * critical + 0.27 * subject + 0.22 * outcome_bonus + 0.08 * min(1.0, len(sentence) / 100.0)
51
+
52
+ def _best_extract(self, query: QueryFrame, item: ScoredEvidence) -> str:
53
+ candidates: List[str] = []
54
+ source = item.source
55
+ preferred_fields = self.templates.get("preferred_answer_fields", [])
56
+ for field in preferred_fields:
57
+ value = self._clean(source.get(field, ""))
58
+ if value:
59
+ candidates.extend(self.text.sentences(self._strip_preamble(value, query.language)) or (value,))
60
+ if not candidates:
61
+ candidates.extend(item.evidence.sentences)
62
+ ranked = sorted(candidates, key=lambda sentence: self._sentence_score(sentence, query, item), reverse=True)
63
+ return self._strip_preamble(ranked[0], query.language) if ranked else ""
64
+
65
+ def _dedupe_fragments(self, values: Iterable[str], lang: str) -> List[str]:
66
+ result: List[str] = []
67
+ normalized: List[str] = []
68
+ for value in values:
69
+ clean = self._clean(value).strip(" .؛،,-")
70
+ if lang == "ar":
71
+ clean = re.sub(r"^(?:و|ف|ثم|او|أو)\s*", "", clean).strip()
72
+ norm = self.text.normalize(clean, lang)
73
+ if len(norm) < 2:
74
+ continue
75
+ duplicate = False
76
+ for existing in normalized:
77
+ similarity = self.text.fuzzy_term_overlap(self.text.content_terms(norm, lang), self.text.content_terms(existing, lang))
78
+ if norm == existing or (similarity >= 0.90 and min(len(norm), len(existing)) / max(len(norm), len(existing)) >= 0.65):
79
+ duplicate = True
80
+ break
81
+ if not duplicate:
82
+ normalized.append(norm)
83
+ result.append(clean)
84
+ return result
85
+
86
+ def _list_answer(self, query: QueryFrame, selected: Sequence[ScoredEvidence]) -> str:
87
+ items: List[str] = []
88
+ for scored in selected:
89
+ raw_items = list(scored.evidence.list_items)
90
+ if not raw_items:
91
+ extract = self._best_extract(query, scored)
92
+ raw_items = list(self.text.split_list_items(extract, query.language))
93
+ items.extend(raw_items)
94
+ items = self._dedupe_fragments(items, query.language)
95
+ # Filter obvious preamble fragments and retain concise propositions.
96
+ filtered = []
97
+ for item in items:
98
+ normalized = self.text.normalize(item, query.language)
99
+ if any(self.text.phrase_hit(normalized, pattern) for pattern in self.templates.get("drop_item_patterns", {}).get(query.language, []) or []):
100
+ continue
101
+ if 1 <= len(normalized.split()) <= 18:
102
+ filtered.append(item)
103
+ items = filtered[: int(self.ranking.get("limits", {}).get("max_list_items", 18))]
104
+ if len(items) < 2:
105
+ return self._best_extract(query, selected[0])
106
+ bullet = "\n".join(f"- {item}" for item in items)
107
+ heading = self.templates.get("list_heading", {}).get(query.language, "Answer:")
108
+ return f"{heading}\n{bullet}"
109
+
110
+ def _conflict_answer(self, query: QueryFrame, consensus: ConsensusResult) -> str:
111
+ labels: List[str] = []
112
+ for key, cluster in sorted(consensus.clusters.items(), key=lambda pair: float(pair[1]["weight"]), reverse=True):
113
+ items = sorted(cluster["items"], key=lambda item: item.score, reverse=True)
114
+ if not items:
115
+ continue
116
+ extract = self._best_extract(query, items[0])
117
+ sources = "، ".join(self._source_label(item, query.language) for item in items[:3])
118
+ if query.language == "ar":
119
+ labels.append(f"- **قول أو اتجاه:** {extract}\n **مصادره:** {sources}")
120
+ else:
121
+ labels.append(f"- **View:** {extract}\n **Sources:** {sources}")
122
+ intro = self.templates.get("conflict_intro", {}).get(query.language, "")
123
+ return intro + "\n" + "\n".join(labels)
124
+
125
+ def synthesize(self, query: QueryFrame, consensus: ConsensusResult, *, style: str = "detailed", compare_sources: bool = True) -> str:
126
+ selected = consensus.selected
127
+ if not selected:
128
+ return self.templates.get("insufficient", {}).get(query.language, "")
129
+ if consensus.state == "conflict":
130
+ body = self._conflict_answer(query, consensus)
131
+ elif query.primary_request_type in {"conditions", "list", "components", "pillars", "duties"}:
132
+ body = self._list_answer(query, selected)
133
+ elif query.primary_request_type == "comparison":
134
+ parts = []
135
+ for item in selected[:5]:
136
+ extract = self._best_extract(query, item)
137
+ if extract:
138
+ parts.append(f"- **{self._source_label(item, query.language)}:** {extract}")
139
+ body = (self.templates.get("comparison_heading", {}).get(query.language, "") + "\n" + "\n".join(parts)).strip()
140
+ else:
141
+ extracts = self._dedupe_fragments((self._best_extract(query, item) for item in selected[:4]), query.language)
142
+ if not extracts:
143
+ return self.templates.get("insufficient", {}).get(query.language, "")
144
+ body = extracts[0]
145
+ if style != "short" and len(extracts) > 1:
146
+ complementary = [value for value in extracts[1:] if value not in body and len(value) >= 12]
147
+ if complementary:
148
+ body += "\n\n" + "\n".join(f"- {value}" for value in complementary[:3])
149
+ prefix = self.templates.get("answer_prefix", {}).get(query.language, "")
150
+ body = prefix + body
151
+
152
+ source_labels = []
153
+ seen: Set[str] = set()
154
+ for item in selected:
155
+ key = item.evidence.book_id or item.evidence.book or item.evidence.record_id
156
+ if key in seen:
157
+ continue
158
+ seen.add(key)
159
+ source_labels.append(self._source_label(item, query.language))
160
+ source_heading = self.templates.get("sources_heading", {}).get(query.language, "Sources")
161
+ body += f"\n\n**{source_heading}:** " + "، ".join(source_labels)
162
+
163
+ if consensus.dissent and compare_sources:
164
+ dissent_labels = []
165
+ seen_dissent: Set[str] = set()
166
+ for item in consensus.dissent:
167
+ key = item.evidence.book_id or item.evidence.book or item.evidence.record_id
168
+ if key in seen_dissent:
169
+ continue
170
+ seen_dissent.add(key)
171
+ extract = self._best_extract(query, item)
172
+ if extract:
173
+ dissent_labels.append(f"{self._source_label(item, query.language)}: {extract}")
174
+ if len(dissent_labels) >= 3:
175
+ break
176
+ if dissent_labels:
177
+ heading = self.templates.get("dissent_heading", {}).get(query.language, "Other formulations")
178
+ body += f"\n\n**{heading}:**\n" + "\n".join(f"- {value}" for value in dissent_labels)
179
+ return body.strip()
hudanet_core/tests/test_generic_pipeline.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import json
3
+
4
+ from hudanet_core import GenericEvidencePipeline
5
+
6
+ ROOT = Path(__file__).resolve().parents[1] / "resources"
7
+ pipeline = GenericEvidencePipeline(ROOT)
8
+
9
+
10
+ def src(record_id, book, title, question, ruling, answer, score=.7, dense=.8, cross=.7, **extra):
11
+ value = {
12
+ "record_id": record_id,
13
+ "book_id": book,
14
+ "book": book,
15
+ "title": title,
16
+ "question": question,
17
+ "ruling": ruling,
18
+ "answer": answer,
19
+ "score": score,
20
+ "dense_score": dense,
21
+ "cross_encoder_score": cross,
22
+ "source_kind": "نسخة منظفة معتمدة",
23
+ }
24
+ value.update(extra)
25
+ return value
26
+
27
+
28
+ def assert_true(condition, label, value=None):
29
+ if not condition:
30
+ raise AssertionError(f"{label}: {value!r}")
31
+
32
+
33
+ def run():
34
+ checks = []
35
+ definition = src("definition", "الفروع", "معنى الحج لغة وشرعا", "ما معنى الحج؟", "تعريف", "الحج لغة القصد، وشرعا قصد مكة للنسك.", .98, .99, .95)
36
+ conditions = src("conditions", "دليل الطالب", "شروط وجوب الحج", "ما شروط وجوب الحج؟", "شروط", "شروط وجوب الحج: الإسلام، والعقل، والبلوغ، وكمال الحرية، والاستطاعة.")
37
+ result = pipeline.resolve("ما هي الشروط التي يجب توفرها لوجوب الحج؟", [definition, conditions], "ar")
38
+ checks.append(("definition rejected for conditions", not next(x for x in result.ranked if x.evidence.record_id == "definition").accepted))
39
+ checks.append(("conditions selected", result.consensus.selected[0].evidence.record_id == "conditions"))
40
+ checks.append(("conditions list", all(term in result.answer for term in ("الإسلام", "العقل", "البلوغ", "الاستطاعة"))))
41
+
42
+ extensions = [
43
+ src("road", "الإقناع", "أمن الطريق من الاستطاعة", "هل أمن الطريق شرط؟", "شرط", "يشترط في الاستطاعة أمن الطريق."),
44
+ src("time", "الكافي", "سعة الوقت", "ما حكم ضيق الوقت؟", "شرط", "ومن شروط الاستطاعة سعة الوقت للوصول وأداء النسك."),
45
+ src("money", "المنتهى", "الديون والنفقات", "ما ضابط المال؟", "شرط", "يشترط أن يفضل المال عن الديون والنفقات الشرعية والحوائج الأصلية."),
46
+ ]
47
+ result = pipeline.resolve("ما هي الشروط التي يجب توفرها لوجوب الحج؟", [conditions] + extensions, "ar")
48
+ checks.append(("multi-source conditions merge", all(term in result.answer for term in ("أمن الطريق", "سعة الوقت", "الديون", "النفقات", "الحوائج الأصلية"))))
49
+ checks.append(("all selected sources named", all(book in result.answer for book in ("دليل الطالب", "الإقناع", "الكافي", "المنتهى"))))
50
+
51
+ decisive = src("dropped", "الروض المربع", "عجز من لا يجد نائبا", "ما حكم من لم يجد نائبا؟", "يسقط الوجوب", "إذا لم يجد الشخص نائبا للحج عنه، يسقط عنه وجوب الحج.", .55, .82, .71)
52
+ disputed = src("views", "الفروع", "من لم يجد نائبا", "من لم يجد نائبا للحج", "وجهان", "إن وجد مالا ولم يجد نائبا ففي وجوب الحج في ذمته وجهان.", .95, .98, .9)
53
+ result = pipeline.resolve("ما هو الحكم إذا لم يجد الشخص نائبا للحج عنه؟", [disputed, decisive], "ar")
54
+ checks.append(("decisive beats disputed", result.consensus.selected[0].evidence.record_id == "dropped"))
55
+ checks.append(("direct ruling in answer", "يسقط" in result.answer))
56
+ checks.append(("dissent stays visible", "وجهان" in result.answer and "الفروع" in result.answer))
57
+
58
+ permitted = src("p", "كتاب أ", "حكم الفعل", "هل يجوز الفعل؟", "جائز", "يجوز هذا الفعل عند تحقق شروطه.")
59
+ prohibited = src("x", "كتاب ب", "حكم الفعل", "هل يجوز الفعل؟", "محرم", "لا يجوز هذا الفعل وهو محرم.")
60
+ result = pipeline.resolve("هل يجوز هذا الفعل؟", [permitted, prohibited], "ar")
61
+ checks.append(("strong incompatible outcomes conflict", result.consensus.state == "conflict"))
62
+ checks.append(("conflict sources separated", "كتاب أ" in result.answer and "كتاب ب" in result.answer))
63
+
64
+ unrelated = src("u", "كتاب ج", "فضل العبادة", "ما فضلها؟", "فضيلة", "هذه العبادة عظيمة الأجر.", .99, .99, .99)
65
+ result = pipeline.resolve("متى يبدأ وقت هذا النسك؟", [unrelated], "ar")
66
+ checks.append(("unrelated high neural score rejected", not result.accepted))
67
+
68
+ en_def = src("en_d", "Book D", "Definition of Hajj", "What does Hajj mean?", "Definition", "Hajj means intending Makkah for the rites.")
69
+ en_cond = src("en_c", "Book C", "Conditions of Hajj", "What are the conditions?", "Conditions", "The conditions are Islam, sanity, puberty, freedom, and capability.")
70
+ result = pipeline.resolve("What are the conditions for the obligation of Hajj?", [en_def, en_cond], "en")
71
+ checks.append(("english type isolation", result.consensus.selected and result.consensus.selected[0].evidence.record_id == "en_c"))
72
+
73
+ search = {"exact": [definition], "related": [], "distant": [conditions], "stats": {}}
74
+ gated = pipeline.annotate_and_rebucket("ما شروط وجوب الحج؟", search, "ar")
75
+ checks.append(("compatible distant promoted", any(x["record_id"] == "conditions" for x in gated["related"] + gated["exact"])))
76
+ checks.append(("incompatible exact demoted", any(x["record_id"] == "definition" for x in gated["distant"])))
77
+
78
+ failed = [label for label, passed in checks if not passed]
79
+ assert_true(not failed, "failed checks", failed)
80
+ print(json.dumps({"passed": True, "tested": len(checks), "checks": checks}, ensure_ascii=False, indent=2))
81
+
82
+
83
+ if __name__ == "__main__":
84
+ run()
hudanet_core/text.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import unicodedata
5
+ from difflib import SequenceMatcher
6
+ from typing import Any, Callable, Iterable, List, Sequence, Set, Tuple
7
+
8
+ _AR_DIACRITICS = re.compile(r"[\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06ED]")
9
+ _TOKEN_RE = re.compile(r"[\w\u0600-\u06FF]+", re.UNICODE)
10
+ _SENTENCE_RE = re.compile(r"(?<=[.!؟?؛;])\s+|\n+")
11
+
12
+
13
+ class TextProcessor:
14
+ def __init__(self, semantic: dict, normalizers: dict[str, Callable[[Any], str]] | None = None):
15
+ self.semantic = semantic
16
+ self.normalizers = normalizers or {}
17
+ self.stopwords = {
18
+ lang: set(values or []) for lang, values in semantic.get("stopwords", {}).items()
19
+ }
20
+
21
+ def normalize(self, value: Any, lang: str) -> str:
22
+ callback = self.normalizers.get(lang)
23
+ if callable(callback):
24
+ return re.sub(r"\s+", " ", callback(value)).strip()
25
+ text = unicodedata.normalize("NFKC", str(value or "")).replace("ـ", "")
26
+ if lang == "ar":
27
+ text = _AR_DIACRITICS.sub("", text)
28
+ text = text.translate(str.maketrans({
29
+ "أ": "ا", "إ": "ا", "آ": "ا", "ٱ": "ا", "ى": "ي",
30
+ "ؤ": "و", "ئ": "ي", "ک": "ك", "ی": "ي", "ۀ": "ة",
31
+ }))
32
+ text = text.casefold()
33
+ text = re.sub(r"[؟،؛:!.,\-_/\\]+", " ", text)
34
+ text = re.sub(r"[^\w\s\u0600-\u06FF]", " ", text)
35
+ return re.sub(r"\s+", " ", text).strip()
36
+
37
+ def tokens(self, value: Any, lang: str, *, content_only: bool = False) -> List[str]:
38
+ normalized = self.normalize(value, lang)
39
+ values = [token for token in _TOKEN_RE.findall(normalized) if token]
40
+ if content_only:
41
+ stop = self.stopwords.get(lang, set())
42
+ values = [token for token in values if token not in stop and len(token) > 1]
43
+ return values
44
+
45
+ def content_terms(self, value: Any, lang: str) -> Tuple[str, ...]:
46
+ terms: List[str] = []
47
+ for token in self.tokens(value, lang, content_only=True):
48
+ stem = self.light_stem(token, lang)
49
+ if stem and stem not in terms:
50
+ terms.append(stem)
51
+ return tuple(terms)
52
+
53
+ def light_stem(self, token: str, lang: str) -> str:
54
+ token = self.normalize(token, lang)
55
+ if lang != "ar" or len(token) < 4:
56
+ return token
57
+ original = token
58
+ for prefix in ("وال", "بال", "كال", "فال", "لل", "ال"):
59
+ if token.startswith(prefix) and len(token) - len(prefix) >= 3:
60
+ token = token[len(prefix):]
61
+ break
62
+ for suffix in ("يات", "ات", "ون", "ين", "ان", "ها", "هم", "هن", "كم", "كن", "نا", "ية", "ه", "ة", "ي"):
63
+ if token.endswith(suffix) and len(token) - len(suffix) >= 3:
64
+ token = token[:-len(suffix)]
65
+ break
66
+ return token or original
67
+
68
+ def sentences(self, value: Any) -> Tuple[str, ...]:
69
+ text = str(value or "").strip()
70
+ parts = [re.sub(r"\s+", " ", part).strip(" -•\t") for part in _SENTENCE_RE.split(text)]
71
+ return tuple(part for part in parts if len(part) >= 3)
72
+
73
+ def phrase_hit(self, normalized_text: str, pattern: str) -> bool:
74
+ try:
75
+ return bool(re.search(pattern, normalized_text, re.I))
76
+ except re.error as exc:
77
+ raise RuntimeError(f"Invalid HUDA-Net semantic regex: {pattern}: {exc}") from exc
78
+
79
+ def fuzzy_term_overlap(self, query_terms: Sequence[str], evidence_terms: Sequence[str]) -> float:
80
+ q = [term for term in query_terms if term]
81
+ e = [term for term in evidence_terms if term]
82
+ if not q:
83
+ return 0.5
84
+ if not e:
85
+ return 0.0
86
+ matched = 0.0
87
+ used: Set[int] = set()
88
+ for query_term in q:
89
+ best_index = -1
90
+ best = 0.0
91
+ for index, evidence_term in enumerate(e):
92
+ if index in used:
93
+ continue
94
+ if query_term == evidence_term:
95
+ score = 1.0
96
+ elif min(len(query_term), len(evidence_term)) >= 4:
97
+ score = SequenceMatcher(None, query_term, evidence_term).ratio()
98
+ else:
99
+ score = 0.0
100
+ if score > best:
101
+ best = score
102
+ best_index = index
103
+ if best >= 0.78:
104
+ matched += best
105
+ used.add(best_index)
106
+ return min(1.0, matched / max(1, len(q)))
107
+
108
+ def jaccard(self, left: Sequence[str], right: Sequence[str]) -> float:
109
+ a, b = set(left), set(right)
110
+ if not a and not b:
111
+ return 1.0
112
+ if not a or not b:
113
+ return 0.0
114
+ return len(a & b) / len(a | b)
115
+
116
+ def split_list_items(self, value: Any, lang: str) -> Tuple[str, ...]:
117
+ text = str(value or "").strip()
118
+ if not text:
119
+ return ()
120
+ candidates: List[str] = []
121
+ for line in re.split(r"[\n\r]+|[؛;]", text):
122
+ line = line.strip(" -•\t")
123
+ if not line:
124
+ continue
125
+ if ":" in line:
126
+ left, right = line.split(":", 1)
127
+ if len(right.strip()) >= 3:
128
+ line = right.strip()
129
+ chunks = [chunk.strip(" -•\t.") for chunk in re.split(r"[،,]", line) if chunk.strip()]
130
+ if len(chunks) == 1 and lang == "ar" and line.count(" و") >= 2:
131
+ chunks = [chunk.strip(" -•\t.") for chunk in re.split(r"\s+و(?=[\u0600-\u06FF])", line) if chunk.strip()]
132
+ candidates.extend(chunks)
133
+ result: List[str] = []
134
+ seen: Set[str] = set()
135
+ for item in candidates:
136
+ item = re.sub(r"^(?:و|ثم|او|أو)\s+", "", item).strip()
137
+ normalized = self.normalize(item, lang)
138
+ if len(normalized) < 2 or len(normalized.split()) > 18:
139
+ continue
140
+ if normalized not in seen:
141
+ seen.add(normalized)
142
+ result.append(item)
143
+ return tuple(result)
hudanet_core/types.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Mapping, Sequence, Tuple
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class QueryFrame:
9
+ raw: str
10
+ normalized: str
11
+ language: str
12
+ request_types: Tuple[str, ...]
13
+ primary_request_type: str
14
+ subject_terms: Tuple[str, ...]
15
+ critical_terms: Tuple[str, ...]
16
+ negated_terms: Tuple[str, ...]
17
+ polarity: str
18
+ dimensions: Tuple[str, ...]
19
+ asks_for_decisive_answer: bool
20
+ asks_for_sources: bool
21
+ confidence: float
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class EvidenceFrame:
26
+ record_id: str
27
+ book_id: str
28
+ book: str
29
+ page: str
30
+ metadata_text: str
31
+ answer_text: str
32
+ normalized_metadata: str
33
+ normalized_answer: str
34
+ answer_types: Tuple[str, ...]
35
+ outcomes: Tuple[str, ...]
36
+ subject_terms: Tuple[str, ...]
37
+ sentences: Tuple[str, ...]
38
+ list_items: Tuple[str, ...]
39
+ decisiveness: float
40
+ completeness: float
41
+ raw: Mapping[str, Any]
42
+
43
+
44
+ @dataclass
45
+ class ScoredEvidence:
46
+ source: Dict[str, Any]
47
+ evidence: EvidenceFrame
48
+ score: float
49
+ accepted: bool
50
+ promoted: bool
51
+ hard_rejections: List[str] = field(default_factory=list)
52
+ reasons: List[str] = field(default_factory=list)
53
+ metrics: Dict[str, float] = field(default_factory=dict)
54
+ cluster_key: str = ""
55
+
56
+
57
+ @dataclass
58
+ class ConsensusResult:
59
+ state: str
60
+ selected_cluster: str
61
+ selected: List[ScoredEvidence]
62
+ dissent: List[ScoredEvidence]
63
+ clusters: Dict[str, Dict[str, Any]]
64
+ confidence: float
65
+ source_count: int
66
+ book_count: int
67
+ explanation: str
68
+
69
+
70
+ @dataclass
71
+ class ResolutionResult:
72
+ answer: str
73
+ query: QueryFrame
74
+ ranked: List[ScoredEvidence]
75
+ accepted: List[ScoredEvidence]
76
+ rejected: List[ScoredEvidence]
77
+ consensus: ConsensusResult
78
+ confidence: float
79
+ details: Dict[str, Any]
hudanet_retrieval_rules.json CHANGED
@@ -1,329 +1,5 @@
1
  {
2
- "version": "1.0.0",
3
- "description": "قواعد استرجاع وتجميع خارجية لهدى نت. تفصل نوع السؤال عن النتائج وتجمع المسائل المركبة من السجلات المتخصصة.",
4
- "intents": {
5
- "hajj_obligation_conditions": {
6
- "query_patterns": {
7
- "ar": [
8
- "(?:ما\\s+هي\\s+)?شروط(?:\\s+الوجوب)?(?:\\s+التي\\s+يجب\\s+توفرها)?\\s+لوجوب\\s+الحج",
9
- "شروط\\s+وجوب\\s+الحج",
10
- "على\\s+من\\s+يجب\\s+الحج",
11
- "متى\\s+يجب\\s+الحج"
12
- ],
13
- "en": [
14
- "conditions?.*(?:obligation|obligatory).*(?:hajj)",
15
- "requirements?.*(?:hajj).*(?:obligatory|obligation)",
16
- "on\\s+whom\\s+is\\s+hajj\\s+obligatory"
17
- ]
18
- },
19
- "topic_clusters": [
20
- {
21
- "id": "general_conditions",
22
- "weight": 1.0,
23
- "patterns": {
24
- "ar": [
25
- "شروط\\s+وجوب\\s+الحج",
26
- "شروط\\s+وجوب\\s+الحج\\s+والعمرة",
27
- "على\\s+من\\s+يجب\\s+الحج"
28
- ],
29
- "en": [
30
- "conditions?\\s+for\\s+the\\s+obligation\\s+of\\s+hajj",
31
- "on\\s+whom\\s+is\\s+hajj\\s+obligatory"
32
- ]
33
- }
34
- },
35
- {
36
- "id": "validity_and_sufficiency_conditions",
37
- "weight": 0.97,
38
- "patterns": {
39
- "ar": [
40
- "شروط\\s+الوجوب\\s+والصحة",
41
- "شروط\\s+الوجوب\\s+والاجزاء",
42
- "شروط\\s+الوجوب\\s+فقط"
43
- ],
44
- "en": [
45
- "conditions?\\s+of\\s+obligation\\s+and\\s+validity",
46
- "conditions?\\s+of\\s+obligation\\s+and\\s+sufficiency"
47
- ]
48
- }
49
- },
50
- {
51
- "id": "ability_provisions_transport",
52
- "weight": 0.95,
53
- "patterns": {
54
- "ar": [
55
- "ماهية\\s+القدرة",
56
- "ضابط\\s+القدرة",
57
- "الاستطاعة.*الزاد.*الراحلة",
58
- "الزاد\\s+والراحلة"
59
- ],
60
- "en": [
61
- "ability.*provisions.*(?:mount|transport)",
62
- "capacity.*hajj"
63
- ]
64
- }
65
- },
66
- {
67
- "id": "financial_obligations",
68
- "weight": 0.93,
69
- "patterns": {
70
- "ar": [
71
- "الاستطاعة\\s+والديون",
72
- "قضاء\\s+الواجبات",
73
- "الديون.*الواجبات"
74
- ],
75
- "en": [
76
- "ability\\s+and\\s+debts",
77
- "financial\\s+obligations"
78
- ]
79
- }
80
- },
81
- {
82
- "id": "lawful_expenses",
83
- "weight": 0.92,
84
- "patterns": {
85
- "ar": [
86
- "الاستطاعة\\s+والنفقات\\s+الشرعية",
87
- "النفقات\\s+الشرعية"
88
- ],
89
- "en": [
90
- "ability\\s+and\\s+lawful\\s+expenses",
91
- "maintenance\\s+expenses"
92
- ]
93
- }
94
- },
95
- {
96
- "id": "basic_needs",
97
- "weight": 0.92,
98
- "patterns": {
99
- "ar": [
100
- "الاستطاعة\\s+والحوائج\\s+الاصلية",
101
- "الحوائج\\s+الاصلية"
102
- ],
103
- "en": [
104
- "ability\\s+and\\s+essential\\s+needs",
105
- "basic\\s+needs"
106
- ]
107
- }
108
- },
109
- {
110
- "id": "road_safety",
111
- "weight": 0.91,
112
- "patterns": {
113
- "ar": [
114
- "امن\\s+الطريق",
115
- "امان\\s+الطريق"
116
- ],
117
- "en": [
118
- "safety\\s+of\\s+the\\s+(?:road|route)",
119
- "safe\\s+route"
120
- ]
121
- }
122
- },
123
- {
124
- "id": "sufficient_time",
125
- "weight": 0.9,
126
- "patterns": {
127
- "ar": [
128
- "سعة\\s+الوقت",
129
- "الوقت\\s+لا\\s+يكفي\\s+للوصول"
130
- ],
131
- "en": [
132
- "sufficient\\s+time",
133
- "time.*reach"
134
- ]
135
- }
136
- }
137
- ],
138
- "answer_items": [
139
- {
140
- "id": "islam",
141
- "label_ar": "الإسلام",
142
- "label_en": "Islam",
143
- "terms_ar": [
144
- "الإسلام",
145
- "المسلم"
146
- ],
147
- "terms_en": [
148
- "islam",
149
- "muslim"
150
- ]
151
- },
152
- {
153
- "id": "sanity",
154
- "label_ar": "العقل",
155
- "label_en": "Sanity",
156
- "terms_ar": [
157
- "العقل",
158
- "المكلف"
159
- ],
160
- "terms_en": [
161
- "sanity",
162
- "sane",
163
- "legally accountable"
164
- ]
165
- },
166
- {
167
- "id": "puberty",
168
- "label_ar": "البلوغ",
169
- "label_en": "Puberty",
170
- "terms_ar": [
171
- "البلوغ",
172
- "بالغ",
173
- "المكلف"
174
- ],
175
- "terms_en": [
176
- "puberty",
177
- "adult",
178
- "legally accountable"
179
- ]
180
- },
181
- {
182
- "id": "freedom",
183
- "label_ar": "كمال الحرية",
184
- "label_en": "Full freedom",
185
- "terms_ar": [
186
- "كمال الحرية",
187
- "الحرية",
188
- "الحر"
189
- ],
190
- "terms_en": [
191
- "full freedom",
192
- "free person",
193
- "freedom"
194
- ]
195
- },
196
- {
197
- "id": "ability",
198
- "label_ar": "الاستطاعة، ومن أهمها وجود الزاد والراحلة المناسبين",
199
- "label_en": "Capability, including adequate provisions and transport",
200
- "terms_ar": [
201
- "الاستطاعة",
202
- "القادر",
203
- "الزاد",
204
- "الراحلة"
205
- ],
206
- "terms_en": [
207
- "ability",
208
- "capability",
209
- "provisions",
210
- "mount",
211
- "transport"
212
- ],
213
- "required_term_groups_ar": [
214
- [
215
- "الاستطاعة",
216
- "القادر"
217
- ],
218
- [
219
- "الزاد"
220
- ],
221
- [
222
- "الراحلة"
223
- ]
224
- ],
225
- "required_term_groups_en": [
226
- [
227
- "ability",
228
- "capability"
229
- ],
230
- [
231
- "provisions"
232
- ],
233
- [
234
- "mount",
235
- "transport"
236
- ]
237
- ]
238
- },
239
- {
240
- "id": "road_safety",
241
- "label_ar": "أمن الطريق",
242
- "label_en": "Safety of the route",
243
- "terms_ar": [
244
- "أمن الطريق",
245
- "امن الطريق",
246
- "الطريق آمنا",
247
- "الطريق امن"
248
- ],
249
- "terms_en": [
250
- "safe route",
251
- "safety of the route",
252
- "road is safe"
253
- ]
254
- },
255
- {
256
- "id": "sufficient_time",
257
- "label_ar": "سعة الوقت بما يكفي للوصول وأداء النسك",
258
- "label_en": "Sufficient time to reach and perform the rites",
259
- "terms_ar": [
260
- "سعة الوقت",
261
- "الوقت لا يكفي",
262
- "يمكن السير فيه"
263
- ],
264
- "terms_en": [
265
- "sufficient time",
266
- "time to reach",
267
- "travel at a normal pace"
268
- ]
269
- },
270
- {
271
- "id": "obligations_expenses",
272
- "label_ar": "أن يكون المال فاضلًا بعد قضاء الواجبات والديون والنفقات الشرعية",
273
- "label_en": "Wealth remaining after debts, obligations, and lawful maintenance expenses",
274
- "terms_ar": [
275
- "قضاء الواجبات",
276
- "الديون",
277
- "النفقات الشرعية",
278
- "نفقة عياله"
279
- ],
280
- "terms_en": [
281
- "debts",
282
- "financial obligations",
283
- "lawful expenses",
284
- "dependents"
285
- ],
286
- "required_term_groups_ar": [
287
- [
288
- "قضاء الواجبات",
289
- "الديون"
290
- ],
291
- [
292
- "النفقات الشرعية",
293
- "نفقة عياله",
294
- "النفقات"
295
- ]
296
- ],
297
- "required_term_groups_en": [
298
- [
299
- "debts",
300
- "financial obligations"
301
- ],
302
- [
303
- "lawful expenses",
304
- "dependents",
305
- "maintenance"
306
- ]
307
- ]
308
- },
309
- {
310
- "id": "basic_needs",
311
- "label_ar": "أن يكون المال فاضلًا عن الحوائج الأصلية",
312
- "label_en": "Wealth exceeding essential needs",
313
- "terms_ar": [
314
- "الحوائج الأصلية",
315
- "الحوائج الاصلية",
316
- "ضروريات الحياة"
317
- ],
318
- "terms_en": [
319
- "essential needs",
320
- "basic needs",
321
- "necessities"
322
- ]
323
- }
324
- ],
325
- "minimum_items_for_structured_answer": 5,
326
- "max_sources_per_cluster": 2
327
- }
328
- }
329
- }
 
1
  {
2
+ "version": "2.0.0",
3
+ "description": "Legacy direct-intent table intentionally empty. All answer gating, ranking, consensus, and synthesis are handled generically by hudanet_core.",
4
+ "intents": {}
5
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
hudanet_v37_generic_tests.json ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "passed": true,
3
+ "tested": 14,
4
+ "checks": [
5
+ [
6
+ "definition rejected for conditions",
7
+ true
8
+ ],
9
+ [
10
+ "conditions selected",
11
+ true
12
+ ],
13
+ [
14
+ "conditions list",
15
+ true
16
+ ],
17
+ [
18
+ "multi-source conditions merge",
19
+ true
20
+ ],
21
+ [
22
+ "all selected sources named",
23
+ true
24
+ ],
25
+ [
26
+ "decisive beats disputed",
27
+ true
28
+ ],
29
+ [
30
+ "direct ruling in answer",
31
+ true
32
+ ],
33
+ [
34
+ "dissent stays visible",
35
+ true
36
+ ],
37
+ [
38
+ "strong incompatible outcomes conflict",
39
+ true
40
+ ],
41
+ [
42
+ "conflict sources separated",
43
+ true
44
+ ],
45
+ [
46
+ "unrelated high neural score rejected",
47
+ true
48
+ ],
49
+ [
50
+ "english type isolation",
51
+ true
52
+ ],
53
+ [
54
+ "compatible distant promoted",
55
+ true
56
+ ],
57
+ [
58
+ "incompatible exact demoted",
59
+ true
60
+ ]
61
+ ]
62
+ }
hudanet_v37_manifest.json ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "37.0.0",
3
+ "architecture": "modular_generic_evidence_library",
4
+ "files": [
5
+ {
6
+ "path": "app.py",
7
+ "size": 529861,
8
+ "sha256": "1ccf29c63b47092a7f4d6320faa1a55f6e05d262a3f715a79e14ab90b60a7459"
9
+ },
10
+ {
11
+ "path": "hudanet_dialects.json",
12
+ "size": 61047,
13
+ "sha256": "9d73324aa9a53cef401b1b155691c5c594504ef5a89618709856d8ff86032413"
14
+ },
15
+ {
16
+ "path": "hudanet_retrieval_rules.json",
17
+ "size": 204,
18
+ "sha256": "a9710d648dcb07055d1256b9e414f7bf3f235e3f75b420ff6c05fdeeda0d4ec5"
19
+ },
20
+ {
21
+ "path": "DEPLOY_HUDANET_V37.md",
22
+ "size": 655,
23
+ "sha256": "51ef70330f3748703e2361d539ddc4025b2b0a9d4750ff4530d05b074fd5e044"
24
+ },
25
+ {
26
+ "path": "hudanet_v37_validation_report.json",
27
+ "size": 2022,
28
+ "sha256": "74c46eb7e2a4b613b6ad2c22537b198c334ae8b95c120cbb48778aa2485a1766"
29
+ },
30
+ {
31
+ "path": "hudanet_v37_generic_tests.json",
32
+ "size": 895,
33
+ "sha256": "c89730411206fa56ae77aaf589d02eaa78349b62986bf293e23b72b66d34fec4"
34
+ },
35
+ {
36
+ "path": "hudanet_core/README.md",
37
+ "size": 1982,
38
+ "sha256": "4cace1731877f91937ebbe03330850d3c9cf1461a569d2fd12fdc3d06a2d236c"
39
+ },
40
+ {
41
+ "path": "hudanet_core/__init__.py",
42
+ "size": 175,
43
+ "sha256": "c417d4feb14e04c42769554348a0fe5f9cd2764ae29fcce6bca48fb29ce1acb1"
44
+ },
45
+ {
46
+ "path": "hudanet_core/compatibility.py",
47
+ "size": 9275,
48
+ "sha256": "a4a98d3d2d973bc3087d1a662ffb62b064e2c2a89903b3bbad52bd2ee19f7bdb"
49
+ },
50
+ {
51
+ "path": "hudanet_core/consensus.py",
52
+ "size": 6332,
53
+ "sha256": "8fb742c3c72653d8b813df059d3603dcf10d99e20c80a43777802df50ef97fca"
54
+ },
55
+ {
56
+ "path": "hudanet_core/evidence.py",
57
+ "size": 3722,
58
+ "sha256": "7aa467f07fcb24dd314e5e9e541b22f152b74e49a71de0f0e6898dbbf2ca3297"
59
+ },
60
+ {
61
+ "path": "hudanet_core/pipeline.py",
62
+ "size": 5877,
63
+ "sha256": "b09767818b2ad3a153a507fdbe895cc47dee875f7f8b424ff3536befe826baa4"
64
+ },
65
+ {
66
+ "path": "hudanet_core/query.py",
67
+ "size": 4124,
68
+ "sha256": "f0576b66a2d710b0c2cde91a7483848f72ad9d8eb7cdf8299dab1b97f07e91d2"
69
+ },
70
+ {
71
+ "path": "hudanet_core/resources/answer_templates.json",
72
+ "size": 2457,
73
+ "sha256": "e8389097845d43ebcdaed9046b0ef61ce547d4c5d44e60f311f0166bb674506b"
74
+ },
75
+ {
76
+ "path": "hudanet_core/resources/evidence_schema.json",
77
+ "size": 701,
78
+ "sha256": "912073cee04802fa30eff22614f452433715d754bb2beb0512f2f4e7d094759f"
79
+ },
80
+ {
81
+ "path": "hudanet_core/resources/ranking_config.json",
82
+ "size": 2607,
83
+ "sha256": "ebb900b730084b884c3b69af8ea616305db227cb9c711f140d6ed1a054fe9048"
84
+ },
85
+ {
86
+ "path": "hudanet_core/resources/semantic_rules.json",
87
+ "size": 23721,
88
+ "sha256": "acd203d5210d3ff94c60a3c2acf4ff4b9af21b00bfbe3c6ffb93c7a16c4538cb"
89
+ },
90
+ {
91
+ "path": "hudanet_core/resources.py",
92
+ "size": 2268,
93
+ "sha256": "d043d3df9da9ec9c43b9a269f395ba2754f08945a80f98ff6b6516fad3087e1d"
94
+ },
95
+ {
96
+ "path": "hudanet_core/synthesis.py",
97
+ "size": 9375,
98
+ "sha256": "743a4bb6dacad9a7cae5c7aa509ebec1cccab76450feac5535d4a9ac86d2151f"
99
+ },
100
+ {
101
+ "path": "hudanet_core/tests/test_generic_pipeline.py",
102
+ "size": 6204,
103
+ "sha256": "71ee43f050d64ae8d31625e4ec7ee7f4933df876d42f05ed98bee47071b22485"
104
+ },
105
+ {
106
+ "path": "hudanet_core/text.py",
107
+ "size": 6077,
108
+ "sha256": "cef781df2ca32c651afa86c0374c094559c0b63d4508a5e604909fef61757a7b"
109
+ },
110
+ {
111
+ "path": "hudanet_core/types.py",
112
+ "size": 1861,
113
+ "sha256": "91cb0fe6f7688fdd6c7d9e677450af8e8ca3a5ebc76007f3e9c0e43de057c984"
114
+ }
115
+ ],
116
+ "file_count": 22,
117
+ "total_size": 681442,
118
+ "validation": {
119
+ "ast_json_regex": true,
120
+ "generic_regressions": 14,
121
+ "semantic_regexes": 229,
122
+ "full_space_runtime_executed": false
123
+ }
124
+ }
hudanet_v37_validation_report.json ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "37.0.0",
3
+ "checks": [
4
+ {
5
+ "name": "python_ast_parse",
6
+ "passed": true,
7
+ "details": {
8
+ "files": 12
9
+ }
10
+ },
11
+ {
12
+ "name": "json_valid",
13
+ "passed": true,
14
+ "details": {
15
+ "files": 6
16
+ }
17
+ },
18
+ {
19
+ "name": "semantic_regex_valid",
20
+ "passed": true,
21
+ "details": {
22
+ "patterns": 229
23
+ }
24
+ },
25
+ {
26
+ "name": "named_intents_absent",
27
+ "passed": true,
28
+ "details": {
29
+ "version": "2.0.0",
30
+ "description": "Legacy direct-intent table intentionally empty. All answer gating, ranking, consensus, and synthesis are handled generically by hudanet_core.",
31
+ "intents": {}
32
+ }
33
+ },
34
+ {
35
+ "name": "forbidden_special_case_absent:no_hajj_deputy",
36
+ "passed": true,
37
+ "details": null
38
+ },
39
+ {
40
+ "name": "forbidden_special_case_absent:preferred_answer_ar",
41
+ "passed": true,
42
+ "details": null
43
+ },
44
+ {
45
+ "name": "forbidden_special_case_absent:إذا لم يجد الشخص نائبًا للحج عنه، يسقط عنه وجوب الحج",
46
+ "passed": true,
47
+ "details": null
48
+ },
49
+ {
50
+ "name": "app_hook:VERSION = \"37.0.0\"",
51
+ "passed": true,
52
+ "details": null
53
+ },
54
+ {
55
+ "name": "app_hook:UI_VERSION = \"37.0.0\"",
56
+ "passed": true,
57
+ "details": null
58
+ },
59
+ {
60
+ "name": "app_hook:apply_generic_evidence_gate_ui(search,intent_query,lang)",
61
+ "passed": true,
62
+ "details": null
63
+ },
64
+ {
65
+ "name": "app_hook:from hudanet_core import GenericEvidencePipeline",
66
+ "passed": true,
67
+ "details": null
68
+ },
69
+ {
70
+ "name": "conversation_context_disabled",
71
+ "passed": true,
72
+ "details": null
73
+ },
74
+ {
75
+ "name": "dialect_does_not_corrupt_tawaffur",
76
+ "passed": true,
77
+ "details": "توفرها"
78
+ },
79
+ {
80
+ "name": "dialect_does_not_split_wa_sharan",
81
+ "passed": true,
82
+ "details": "وشرعا"
83
+ }
84
+ ],
85
+ "passed": true,
86
+ "tested": 14,
87
+ "failed": []
88
+ }