dakheel commited on
Commit
7b8dd4e
·
verified ·
1 Parent(s): da59c24

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +69 -34
  2. huda_v66_pdf.py +312 -0
  3. huda_v66_quality.py +469 -0
app.py CHANGED
@@ -37,10 +37,10 @@ import gradio as gr
37
  import numpy as np
38
  import pandas as pd
39
  from huggingface_hub import snapshot_download
40
- from huda_v65_quality import compose_answer, split_questions, assess, terms, corpus_profile
41
 
42
  # ─── Config ────────────────────────────────────────────────────────────────
43
- APP_VERSION = "65.0.0-EDITORIAL-PDF-PREVIEW"
44
  # V61 adds concise, citation-traceable extractive briefs; V57 keeps its own answer gate.
45
  V61_ENABLED = os.getenv("HUDANET_V61_COMPOSER_ENABLED", "1") == "1"
46
  # V57 introduces a strictly gated direct-evidence answer path; frozen neural weights remain search assets.
@@ -69,7 +69,7 @@ os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
69
  # ---------------------------------------------------------------------------
70
  # HUDA Studio UI v1.3 | UI and exports repaired; frozen v43 decision pipeline intact.
71
  # ---------------------------------------------------------------------------
72
- UI_VERSION = "FOCUS-3.4-EDITORIAL-PDF"
73
  STUDIO_CSS = r"""
74
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Noto+Naskh+Arabic:wght@400;500;600;700&display=swap');
75
  :root,html[data-theme="light"]{
@@ -1365,14 +1365,14 @@ WELCOME_HTML = f'''<div class="hd-welcome">
1365
  </div></div>'''
1366
 
1367
 
1368
- def render_result(result: dict, lang: str, translations: dict[str,str]) -> str:
1369
  if result.get('decision')=='input_error':
1370
  return '<div class="hd-empty">'+_esc(result.get('input_error','تعذر تحليل السؤال'))+'</div>'
1371
  if result.get('decision')=='multi':
1372
  blocks=[]
1373
  for idx,sub in enumerate(result.get('sub_results') or [],1):
1374
  label=_esc(sub.get('v63_question',''))
1375
- blocks.append(f'<section class="v63-sub-result"><h2>السؤال {idx}: {label}</h2>'+render_result(sub,lang,translations)+'</section>')
1376
  return '<div class="v63-multi">'+''.join(blocks)+'</div>'
1377
  answered=result.get('decision')=='answer'
1378
  direct=result.get('v57_decision_route')=='V57_DIRECT_SOURCE_MATCH'
@@ -1399,17 +1399,38 @@ def render_result(result: dict, lang: str, translations: dict[str,str]) -> str:
1399
  raise RuntimeError('V61 unsupported answer part.')
1400
  indexes=' '.join(f'<sup class="v61-cite">[{i}]</sup>' for i in sorted(refnums))
1401
  texts.append(f'<p class="v61-claim">{_esc(part.get("text",""))} {indexes}</p>')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1402
  rows=[]
1403
- for ref in used:
1404
  label=' · '.join(v for v in (ref.get('book',''),('ص '+str(ref['page'])) if ref.get('page') else '',ref.get('title','')) if v)
1405
  rows.append(f'<li><span class="v61-ref-number">[{int(ref["number"])}]</span> <strong>{_esc(label)}</strong>'
1406
  f'<div class="v61-support">{_esc(ref.get("support_excerpt",""))}</div></li>')
1407
- note='مراجع متعددة من نصوص المكتبة' if len(used)>1 else 'مرجع واحد اجتاز الفحص الآلي'
1408
  heading='الجواب المستخرج من الأدلة'
1409
  body=f'''<div class="hd-answer-card v61-answer">
1410
  <div class="v61-answer-head"><span>جواب واحد من النصوص المتاحة</span><span class="v61-mode">{_esc(note)}</span></div>
1411
  <div class="hd-answer-text v61-body" dir="rtl" id="hd-answer">{''.join(texts)}</div>
1412
- <details class="v61-references" open><summary>📚 المراجع المستخدمة في الجواب ({len(used)})</summary>
1413
  <ol>{''.join(rows)}</ol></details>
1414
  <p class="hd-risk-note">{_esc(brief.get('note',''))} أرقام المراجع تشير للنصوص المقتبسة أعلاه؛ وليست مقياسًا لصحة الحكم.</p></div>'''
1415
  else:
@@ -1430,7 +1451,7 @@ def render_result(result: dict, lang: str, translations: dict[str,str]) -> str:
1430
  state='ok' if usable else 'no'
1431
  label='مستخلص موثق بمصادره' if usable else ('جواب مختصر غير محسوم' if answered else 'امتناع لعدم كفاية الأدلة')
1432
  return f'''<section class="hd-res" aria-label="نتيجة السؤال">
1433
- <div class="hd-res-heading"><div><div class="hd-result-label">نتيجة الاستعلام · HUDA-Net V65</div>
1434
  <div class="hd-result-title">{_esc(heading)}</div></div><span class="hd-status {state}">● {_esc(label)}</span>
1435
  <div class="hd-r-kpis"><div>تقدير بحث V43 القديم (ليس ثقة هذا الجواب)<b>{conf:.1f}%</b></div>
1436
  <div>المراجع المستخدمة فعليًا<b>{len(brief.get('references') or []) if usable else 0}</b></div>
@@ -1568,7 +1589,7 @@ def _build_json(query: str, result: dict) -> str:
1568
  "supplemental_candidates": result.get("supplemental_candidates", []),
1569
  "exported_at": datetime.now().isoformat(),
1570
  "query": query, "decision": result.get("decision"),
1571
- "confidence_pct": None, # no calibrated confidence for V65 answer
1572
  "legacy_v43_retrieval_confidence_pct": float(result.get("probability_top1_correct", 0.0) or 0.0) * 100,
1573
  "threshold_pct_legacy_only": EXPECTED_THRESHOLD * 100,
1574
  "selected_evidence": result.get("selected_visible_evidence"),
@@ -1620,11 +1641,11 @@ def _build_bookmarks_json(session: SessionState) -> str:
1620
  return _rename_export(str(path),'المحفوظات','.json','المحفوظات')
1621
 
1622
 
1623
- def _build_pdf(query: str, result: dict) -> str:
1624
- """V65 editorial PDF: no false confidence framing, supports multi-question reports."""
1625
- from huda_v65_pdf import export_pdf
1626
- raw=export_pdf(query,result)
1627
- return _rename_export(raw,query,'.pdf','تقرير')
1628
 
1629
  # ---------------------------------------------------------------------------
1630
  # Studio interface. Frozen model assets and run_inference() above are unchanged.
@@ -1639,7 +1660,7 @@ def create_interface() -> gr.Blocks:
1639
  gr.HTML(f'''<header class="h59-header" role="banner"><div class="h59-header-inner">
1640
  <div class="h59-identity"><span class="h59-mark">۞</span><div><div class="h59-brand">هُدى نت</div>
1641
  <div class="h59-brand-sub">بحث موسع · أسئلة مستقلة · إجابة مستخرجة مع مراجع</div></div>
1642
- <span class="h59-pill">V65 VERIFIED-EXTRACT RESEARCH</span></div>
1643
  <nav class="h59-nav" aria-label="التنقل السريع">
1644
  <button type="button" data-h59-panel="h59-evidence">المصادر</button>
1645
  <button type="button" data-h59-panel="h59-options">الإعدادات</button>
@@ -1652,7 +1673,7 @@ def create_interface() -> gr.Blocks:
1652
  with gr.Column(elem_classes=['h59-main'],elem_id='h59-main'):
1653
  gr.HTML('''<section class="h59-intro"><div class="h59-eyebrow">۞ مساحة البحث الفقهي بالمصادر</div>
1654
  <h1>اسأل. خذ خلاصة موثقة. راجع مصادرها.</h1>
1655
- <p>جواب واحد موجز عندما تدعمه النصوص، ومراجع مستخدمة تحته، والأدلة التفصيلية تُفتح عند الطلب.</p></section>''')
1656
 
1657
  with gr.Group(elem_classes=['h59-query-panel'],elem_id='h59-search'):
1658
  gr.HTML('''<div class="hd-card-heading"><h2>ما المسألة التي تريد البحث عنها؟</h2>
@@ -1660,6 +1681,9 @@ def create_interface() -> gr.Blocks:
1660
  query_input=gr.Textbox(lines=2,max_lines=5,show_label=False,container=False,
1661
  placeholder='مثال: ما أركان الحج الأربعة؟',elem_id='query-input')
1662
  gr.HTML('<div class="hd-counter" id="hd-char-count">0 / 1200</div>')
 
 
 
1663
  with gr.Row(elem_classes=['h59-actions']):
1664
  submit_btn=gr.Button('⌕ ابحث ولخّص بالمصادر',variant='primary',elem_id='submit-btn',scale=5)
1665
  clear_btn=gr.Button('مسح السؤال',elem_classes=['hd-outline-btn'],scale=1)
@@ -1708,6 +1732,7 @@ def create_interface() -> gr.Blocks:
1708
  set_b_btn=gr.Button('تعيين النتيجة ب',size='sm')
1709
  compare_btn=gr.Button('فتح المقارنة',size='sm')
1710
  gr.HTML('<div class="h59-divider"></div>')
 
1711
  gr.HTML('<div class="h59-inline-note">تنزيل نتائج السؤال الحالي أو الأدلة المحفوظة:</div>')
1712
  with gr.Row(elem_classes=['h59-tools-row']):
1713
  exp_json=gr.Button('JSON',size='sm')
@@ -1726,10 +1751,10 @@ def create_interface() -> gr.Blocks:
1726
 
1727
  history_bridge=gr.Textbox(elem_id='history-bridge',elem_classes=['hd-bridge'],label='history event')
1728
  bookmark_bridge=gr.Textbox(elem_id='bookmark-bridge',elem_classes=['hd-bridge'],label='bookmark event')
1729
- gr.HTML(f'<footer class="h59-footer">هُدى نت · V65 Editorial Preview · {UI_VERSION} · استرجاع المصادر تجريبي وليس بديلًا عن مراجعة المرجع الأصلي</footer>')
1730
 
1731
  # ---------------- Application handlers (session isolated) ----------------
1732
- def on_submit(query,ev,tr,ss,sm,session):
1733
  if len(str(query or "")) > 1200:
1734
  gr.Warning('السؤال طويل جدًا. اختصره إلى 1200 حرف أو أقل قبل الإرسال.')
1735
  yield (gr.update(visible=False),gr.update(),gr.update(),gr.update(),gr.update(),{},'',{},session)
@@ -1758,8 +1783,8 @@ def create_interface() -> gr.Blocks:
1758
  gr.Warning('تعذّرت الترجمة مؤقتًا، وسيظهر النص العربي الأصلي للمصدر.')
1759
  if result.get('decision')!='input_error':
1760
  session.add_query(q,result,lang,translations)
1761
- cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':translations}
1762
- rendered=render_result(result,lang,translations)
1763
  evidence=render_evidence(result,lang,translations,ev,ss,sm,session)
1764
  yield (gr.update(visible=False),gr.update(value=rendered),gr.update(value=evidence),
1765
  gr.update(value=render_stats(session)),gr.update(visible=(result.get('decision')!='input_error')),result,q,cfg,session)
@@ -1773,15 +1798,15 @@ def create_interface() -> gr.Blocks:
1773
  return (gr.update(visible=False),gr.update(value=WELCOME_HTML),gr.update(value=''),
1774
  gr.update(visible=False),gr.update(value=''),{},'',{})
1775
 
1776
- def rerender(ev,tr,ss,sm,result,query,cfg,session):
1777
  if not result:return gr.update(),gr.update(),cfg
1778
  lang=cfg.get('lang') or _detect_lang(query)
1779
  translations=cfg.get('translations') or {}
1780
  if lang=='en' and cfg.get('tr')!=int(tr):
1781
  try:translations=_prepare_translations(result,tr)
1782
  except Exception:pass
1783
- new_cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':translations}
1784
- return (gr.update(value=render_result(result,lang,translations)),
1785
  gr.update(value=render_evidence(result,lang,translations,ev,ss,sm,session)),new_cfg)
1786
 
1787
  def on_open_history(session):
@@ -1789,12 +1814,12 @@ def create_interface() -> gr.Blocks:
1789
  def on_clear_history(session):
1790
  session.history.clear()
1791
  return gr.update(value=render_history(session)),session
1792
- def on_history_select(hid,ev,tr,ss,sm,session):
1793
  entry=session.find(_bridge_id(hid))
1794
  if not entry:return gr.update(),gr.update(),gr.update(),gr.update(),{},'',{}
1795
  result=entry['result'];lang=entry['lang'];trans=entry.get('translations') or {}
1796
  cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':trans}
1797
- return (gr.update(value=render_result(result,lang,trans)),
1798
  gr.update(value=render_evidence(result,lang,trans,ev,ss,sm,session)),
1799
  gr.update(value=entry['query']),gr.update(visible=True),result,entry['query'],cfg)
1800
  def on_bookmark(raw,result,cfg,session):
@@ -1813,12 +1838,12 @@ def create_interface() -> gr.Blocks:
1813
  else:session.compare_b={'query':query,'result':result}
1814
  return gr.update(open=True),gr.update(value=render_compare(session)),session
1815
  def on_open_compare(session):return gr.update(open=True),gr.update(value=render_compare(session))
1816
- def on_export(fmt,query,result):
1817
  if not result:
1818
  gr.Warning('اسأل أولًا ثم صدّر النتيجة.')
1819
  return gr.update()
1820
  try:
1821
- path=_build_json(query,result) if fmt=='json' else _build_csv(result,query) if fmt=='csv' else _build_pdf(query,result)
1822
  return gr.update(value=path,visible=True)
1823
  except Exception as exc:
1824
  print('Export error:',type(exc).__name__,str(exc)[:300])
@@ -1829,26 +1854,26 @@ def create_interface() -> gr.Blocks:
1829
  except Exception as exc:
1830
  gr.Warning(str(exc));return gr.update()
1831
 
1832
- inputs=[query_input,evidence_count,translation_count,show_scores,show_meta,session_state]
1833
  outputs=[loading_html,result_html,evidence_html,stats_html,evidence_section,last_result,last_query,last_cfg,session_state]
1834
  submit_btn.click(on_submit,inputs=inputs,outputs=outputs,concurrency_limit=1)
1835
  query_input.submit(on_submit,inputs=inputs,outputs=outputs,concurrency_limit=1)
1836
  clear_btn.click(on_clear,outputs=[loading_html,result_html,evidence_html,evidence_section,query_input,last_result,last_query,last_cfg])
1837
  for button,example in example_buttons:
1838
  button.click(lambda val=example: val,outputs=[query_input])
1839
- for control in (evidence_count,translation_count,show_scores,show_meta):
1840
- control.change(rerender,inputs=[evidence_count,translation_count,show_scores,show_meta,last_result,last_query,last_cfg,session_state],
1841
  outputs=[result_html,evidence_html,last_cfg])
1842
  history_btn.click(lambda session: (gr.update(open=True),gr.update(open=True),gr.update(value=render_history(session))), inputs=[session_state],outputs=[tools_panel,history_panel,history_body])
1843
  clear_history_btn.click(on_clear_history,inputs=[session_state],outputs=[history_body,session_state])
1844
- history_bridge.change(on_history_select,inputs=[history_bridge,evidence_count,translation_count,show_scores,show_meta,session_state],
1845
  outputs=[result_html,evidence_html,query_input,evidence_section,last_result,last_query,last_cfg])
1846
  bookmark_bridge.change(on_bookmark,inputs=[bookmark_bridge,last_result,last_cfg,session_state],outputs=[evidence_html,session_state])
1847
  set_a_btn.click(lambda q,r,s:on_set_compare('a',q,r,s),inputs=[last_query,last_result,session_state],outputs=[compare_panel,compare_body,session_state])
1848
  set_b_btn.click(lambda q,r,s:on_set_compare('b',q,r,s),inputs=[last_query,last_result,session_state],outputs=[compare_panel,compare_body,session_state])
1849
  compare_btn.click(on_open_compare,inputs=[session_state],outputs=[compare_panel,compare_body])
1850
  for button,fmt in ((exp_json,'json'),(exp_csv,'csv'),(exp_pdf,'pdf')):
1851
- button.click(lambda q,r,f=fmt:on_export(f,q,r),inputs=[last_query,last_result],outputs=[export_file])
1852
  exp_bm.click(on_export_bookmarks,inputs=[session_state],outputs=[export_file])
1853
  return demo
1854
 
@@ -1862,7 +1887,17 @@ HEAD_HTML += """<style>
1862
  .v63-multi{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;width:100%;min-width:0}
1863
  .hd-evidence,.hd-pass-preview,.hd-passage{overflow-wrap:anywhere;word-break:break-word}
1864
  </style>"""
 
 
 
 
 
 
 
 
 
 
1865
  demo=create_interface()
1866
  if __name__=='__main__':
1867
  demo.queue(default_concurrency_limit=1)
1868
- demo.launch(server_name='0.0.0.0',server_port=7860,head=HEAD_HTML,show_error=False)
 
37
  import numpy as np
38
  import pandas as pd
39
  from huggingface_hub import snapshot_download
40
+ from huda_v66_quality import compose_answer, split_questions, assess, terms, corpus_profile
41
 
42
  # ─── Config ────────────────────────────────────────────────────────────────
43
+ APP_VERSION = "66.0.0-DUAL-ANSWER-PREVIEW"
44
  # V61 adds concise, citation-traceable extractive briefs; V57 keeps its own answer gate.
45
  V61_ENABLED = os.getenv("HUDANET_V61_COMPOSER_ENABLED", "1") == "1"
46
  # V57 introduces a strictly gated direct-evidence answer path; frozen neural weights remain search assets.
 
69
  # ---------------------------------------------------------------------------
70
  # HUDA Studio UI v1.3 | UI and exports repaired; frozen v43 decision pipeline intact.
71
  # ---------------------------------------------------------------------------
72
+ UI_VERSION = "FOCUS-3.5-SHORT-DETAILED"
73
  STUDIO_CSS = r"""
74
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans+Arabic:wght@400;500;600;700&family=Noto+Naskh+Arabic:wght@400;500;600;700&display=swap');
75
  :root,html[data-theme="light"]{
 
1365
  </div></div>'''
1366
 
1367
 
1368
+ def render_result(result: dict, lang: str, translations: dict[str,str], answer_mode: str = "مختصر") -> str:
1369
  if result.get('decision')=='input_error':
1370
  return '<div class="hd-empty">'+_esc(result.get('input_error','تعذر تحليل السؤال'))+'</div>'
1371
  if result.get('decision')=='multi':
1372
  blocks=[]
1373
  for idx,sub in enumerate(result.get('sub_results') or [],1):
1374
  label=_esc(sub.get('v63_question',''))
1375
+ blocks.append(f'<section class="v63-sub-result"><h2>السؤال {idx}: {label}</h2>'+render_result(sub,lang,translations,answer_mode)+'</section>')
1376
  return '<div class="v63-multi">'+''.join(blocks)+'</div>'
1377
  answered=result.get('decision')=='answer'
1378
  direct=result.get('v57_decision_route')=='V57_DIRECT_SOURCE_MATCH'
 
1399
  raise RuntimeError('V61 unsupported answer part.')
1400
  indexes=' '.join(f'<sup class="v61-cite">[{i}]</sup>' for i in sorted(refnums))
1401
  texts.append(f'<p class="v61-claim">{_esc(part.get("text",""))} {indexes}</p>')
1402
+ # A single verified fact can have several corroborating references.
1403
+ # Detailed output adds only independently supported facets.
1404
+ if answer_mode == 'مفصل':
1405
+ details=list(brief.get('detailed_parts') or [])
1406
+ if details:
1407
+ detail_rows=[]
1408
+ for n,detail in enumerate(details,1):
1409
+ cite_numbers=[int(x) for x in detail.get('ref_numbers') or []]
1410
+ if not cite_numbers or not set(cite_numbers).issubset(source_refs):
1411
+ raise RuntimeError('V66 details cite a missing evidence record.')
1412
+ cites=' '.join(f'<sup class="v61-cite">[{i}]</sup>' for i in cite_numbers)
1413
+ detail_rows.append(f'<li>{_esc(detail.get("text",""))} {cites}</li>')
1414
+ texts.append('<div class="v66-detailed"><strong>التفصيل بحسب ما تدعمه المصادر:</strong><ol>'+''.join(detail_rows)+'</ol></div>')
1415
+ else:
1416
+ texts.append('<p class="v66-detail-note">لا توجد نقاط إضافية اجتازت فحص الأدلة. لا نطيل الجواب بمعلومات غير موثقة.</p>')
1417
+ shown_nums=set()
1418
+ for part in parts:shown_nums.update(int(i) for i in part.get('ref_numbers') or [])
1419
+ if answer_mode == 'مفصل':
1420
+ for detail in brief.get('detailed_parts') or []:
1421
+ shown_nums.update(int(i) for i in detail.get('ref_numbers') or [])
1422
+ displayed=[r for r in used if int(r.get('number',0)) in shown_nums]
1423
  rows=[]
1424
+ for ref in displayed:
1425
  label=' · '.join(v for v in (ref.get('book',''),('ص '+str(ref['page'])) if ref.get('page') else '',ref.get('title','')) if v)
1426
  rows.append(f'<li><span class="v61-ref-number">[{int(ref["number"])}]</span> <strong>{_esc(label)}</strong>'
1427
  f'<div class="v61-support">{_esc(ref.get("support_excerpt",""))}</div></li>')
1428
+ note='مراجع متعددة من نصوص المكتبة' if len(displayed)>1 else 'مرجع واحد اجتاز الفحص الآلي'
1429
  heading='الجواب المستخرج من الأدلة'
1430
  body=f'''<div class="hd-answer-card v61-answer">
1431
  <div class="v61-answer-head"><span>جواب واحد من النصوص المتاحة</span><span class="v61-mode">{_esc(note)}</span></div>
1432
  <div class="hd-answer-text v61-body" dir="rtl" id="hd-answer">{''.join(texts)}</div>
1433
+ <details class="v61-references" open><summary>📚 المراجع المستخدمة في الجواب ({len(displayed)})</summary>
1434
  <ol>{''.join(rows)}</ol></details>
1435
  <p class="hd-risk-note">{_esc(brief.get('note',''))} أرقام المراجع تشير للنصوص المقتبسة أعلاه؛ وليست مقياسًا لصحة الحكم.</p></div>'''
1436
  else:
 
1451
  state='ok' if usable else 'no'
1452
  label='مستخلص موثق بمصادره' if usable else ('جواب مختصر غير محسوم' if answered else 'امتناع لعدم كفاية الأدلة')
1453
  return f'''<section class="hd-res" aria-label="نتيجة السؤال">
1454
+ <div class="hd-res-heading"><div><div class="hd-result-label">نتيجة الاستعلام · HUDA-Net V66</div>
1455
  <div class="hd-result-title">{_esc(heading)}</div></div><span class="hd-status {state}">● {_esc(label)}</span>
1456
  <div class="hd-r-kpis"><div>تقدير بحث V43 القديم (ليس ثقة هذا الجواب)<b>{conf:.1f}%</b></div>
1457
  <div>المراجع المستخدمة فعليًا<b>{len(brief.get('references') or []) if usable else 0}</b></div>
 
1589
  "supplemental_candidates": result.get("supplemental_candidates", []),
1590
  "exported_at": datetime.now().isoformat(),
1591
  "query": query, "decision": result.get("decision"),
1592
+ "confidence_pct": None, # no calibrated confidence for V66 answer
1593
  "legacy_v43_retrieval_confidence_pct": float(result.get("probability_top1_correct", 0.0) or 0.0) * 100,
1594
  "threshold_pct_legacy_only": EXPECTED_THRESHOLD * 100,
1595
  "selected_evidence": result.get("selected_visible_evidence"),
 
1641
  return _rename_export(str(path),'المحفوظات','.json','المحفوظات')
1642
 
1643
 
1644
+ def _build_pdf(query: str, result: dict, answer_mode: str = 'مختصر', include_appendix: bool = False) -> str:
1645
+ """V66 dual-mode PDF: no false confidence framing, supports multi-question reports."""
1646
+ from huda_v66_pdf import export_pdf
1647
+ raw=export_pdf(query,result,mode=answer_mode,include_appendix=include_appendix)
1648
+ return _rename_export(raw,query,'.pdf','تقرير_'+('مفصل' if answer_mode=='مفصل' else 'مختصر'))
1649
 
1650
  # ---------------------------------------------------------------------------
1651
  # Studio interface. Frozen model assets and run_inference() above are unchanged.
 
1660
  gr.HTML(f'''<header class="h59-header" role="banner"><div class="h59-header-inner">
1661
  <div class="h59-identity"><span class="h59-mark">۞</span><div><div class="h59-brand">هُدى نت</div>
1662
  <div class="h59-brand-sub">بحث موسع · أسئلة مستقلة · إجابة مستخرجة مع مراجع</div></div>
1663
+ <span class="h59-pill">V66 DUAL-ANSWER RESEARCH</span></div>
1664
  <nav class="h59-nav" aria-label="التنقل السريع">
1665
  <button type="button" data-h59-panel="h59-evidence">المصادر</button>
1666
  <button type="button" data-h59-panel="h59-options">الإعدادات</button>
 
1673
  with gr.Column(elem_classes=['h59-main'],elem_id='h59-main'):
1674
  gr.HTML('''<section class="h59-intro"><div class="h59-eyebrow">۞ مساحة البحث الفقهي بالمصادر</div>
1675
  <h1>اسأل. خذ خلاصة موثقة. راجع مصادرها.</h1>
1676
+ <p>اختر جوابًا مختصرًا أو مفصلًا من الأدلة المقبولة. كل معلومة إضافية لها مرجعها، ونتائج البحث الأخرى منفصلة.</p></section>''')
1677
 
1678
  with gr.Group(elem_classes=['h59-query-panel'],elem_id='h59-search'):
1679
  gr.HTML('''<div class="hd-card-heading"><h2>ما المسألة التي تريد البحث عنها؟</h2>
 
1681
  query_input=gr.Textbox(lines=2,max_lines=5,show_label=False,container=False,
1682
  placeholder='مثال: ما أركان الحج الأربعة؟',elem_id='query-input')
1683
  gr.HTML('<div class="hd-counter" id="hd-char-count">0 / 1200</div>')
1684
+ with gr.Row(elem_classes=['v66-mode-bar']):
1685
+ answer_mode=gr.Radio(choices=['مختصر','مفصل'],value='مختصر',label='طريقة عرض الإجابة وPDF',interactive=True,elem_id='v66-answer-mode')
1686
+ gr.HTML('<span class="v66-mode-hint">مفصل = نقاط إضافية لها أدلة صريحة فقط؛ لا نضيف أحكامًا تخمينية</span>')
1687
  with gr.Row(elem_classes=['h59-actions']):
1688
  submit_btn=gr.Button('⌕ ابحث ولخّص بالمصادر',variant='primary',elem_id='submit-btn',scale=5)
1689
  clear_btn=gr.Button('مسح السؤال',elem_classes=['hd-outline-btn'],scale=1)
 
1732
  set_b_btn=gr.Button('تعيين النتيجة ب',size='sm')
1733
  compare_btn=gr.Button('فتح المقارنة',size='sm')
1734
  gr.HTML('<div class="h59-divider"></div>')
1735
+ include_pdf_appendix=gr.Checkbox(value=False,label='PDF: إضافة ملحق بنتائج البحث غير المستخدمة (اختياري، قد تتضمن نصوصًا مشوهة)')
1736
  gr.HTML('<div class="h59-inline-note">تنزيل نتائج السؤال الحالي أو الأدلة المحفوظة:</div>')
1737
  with gr.Row(elem_classes=['h59-tools-row']):
1738
  exp_json=gr.Button('JSON',size='sm')
 
1751
 
1752
  history_bridge=gr.Textbox(elem_id='history-bridge',elem_classes=['hd-bridge'],label='history event')
1753
  bookmark_bridge=gr.Textbox(elem_id='bookmark-bridge',elem_classes=['hd-bridge'],label='bookmark event')
1754
+ gr.HTML(f'<footer class="h59-footer">هُدى نت · V66 Dual-Answer Preview · {UI_VERSION} · استرجاع المصادر تجريبي وليس بديلًا عن مراجعة المرجع الأصلي</footer>')
1755
 
1756
  # ---------------- Application handlers (session isolated) ----------------
1757
+ def on_submit(query,ev,tr,ss,sm,answer_mode,session):
1758
  if len(str(query or "")) > 1200:
1759
  gr.Warning('السؤال طويل جدًا. اختصره إلى 1200 حرف أو أقل قبل الإرسال.')
1760
  yield (gr.update(visible=False),gr.update(),gr.update(),gr.update(),gr.update(),{},'',{},session)
 
1783
  gr.Warning('تعذّرت الترجمة مؤقتًا، وسيظهر النص العربي الأصلي للمصدر.')
1784
  if result.get('decision')!='input_error':
1785
  session.add_query(q,result,lang,translations)
1786
+ cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':translations,'answer_mode':answer_mode}
1787
+ rendered=render_result(result,lang,translations,answer_mode)
1788
  evidence=render_evidence(result,lang,translations,ev,ss,sm,session)
1789
  yield (gr.update(visible=False),gr.update(value=rendered),gr.update(value=evidence),
1790
  gr.update(value=render_stats(session)),gr.update(visible=(result.get('decision')!='input_error')),result,q,cfg,session)
 
1798
  return (gr.update(visible=False),gr.update(value=WELCOME_HTML),gr.update(value=''),
1799
  gr.update(visible=False),gr.update(value=''),{},'',{})
1800
 
1801
+ def rerender(ev,tr,ss,sm,answer_mode,result,query,cfg,session):
1802
  if not result:return gr.update(),gr.update(),cfg
1803
  lang=cfg.get('lang') or _detect_lang(query)
1804
  translations=cfg.get('translations') or {}
1805
  if lang=='en' and cfg.get('tr')!=int(tr):
1806
  try:translations=_prepare_translations(result,tr)
1807
  except Exception:pass
1808
+ new_cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':translations,'answer_mode':answer_mode}
1809
+ return (gr.update(value=render_result(result,lang,translations,answer_mode)),
1810
  gr.update(value=render_evidence(result,lang,translations,ev,ss,sm,session)),new_cfg)
1811
 
1812
  def on_open_history(session):
 
1814
  def on_clear_history(session):
1815
  session.history.clear()
1816
  return gr.update(value=render_history(session)),session
1817
+ def on_history_select(hid,ev,tr,ss,sm,answer_mode,session):
1818
  entry=session.find(_bridge_id(hid))
1819
  if not entry:return gr.update(),gr.update(),gr.update(),gr.update(),{},'',{}
1820
  result=entry['result'];lang=entry['lang'];trans=entry.get('translations') or {}
1821
  cfg={'ev':int(ev),'tr':int(tr),'ss':bool(ss),'sm':bool(sm),'lang':lang,'translations':trans}
1822
+ return (gr.update(value=render_result(result,lang,trans,answer_mode)),
1823
  gr.update(value=render_evidence(result,lang,trans,ev,ss,sm,session)),
1824
  gr.update(value=entry['query']),gr.update(visible=True),result,entry['query'],cfg)
1825
  def on_bookmark(raw,result,cfg,session):
 
1838
  else:session.compare_b={'query':query,'result':result}
1839
  return gr.update(open=True),gr.update(value=render_compare(session)),session
1840
  def on_open_compare(session):return gr.update(open=True),gr.update(value=render_compare(session))
1841
+ def on_export(fmt,query,result,answer_mode,include_appendix):
1842
  if not result:
1843
  gr.Warning('اسأل أولًا ثم صدّر النتيجة.')
1844
  return gr.update()
1845
  try:
1846
+ path=_build_json(query,result) if fmt=='json' else _build_csv(result,query) if fmt=='csv' else _build_pdf(query,result,answer_mode,include_appendix)
1847
  return gr.update(value=path,visible=True)
1848
  except Exception as exc:
1849
  print('Export error:',type(exc).__name__,str(exc)[:300])
 
1854
  except Exception as exc:
1855
  gr.Warning(str(exc));return gr.update()
1856
 
1857
+ inputs=[query_input,evidence_count,translation_count,show_scores,show_meta,answer_mode,session_state]
1858
  outputs=[loading_html,result_html,evidence_html,stats_html,evidence_section,last_result,last_query,last_cfg,session_state]
1859
  submit_btn.click(on_submit,inputs=inputs,outputs=outputs,concurrency_limit=1)
1860
  query_input.submit(on_submit,inputs=inputs,outputs=outputs,concurrency_limit=1)
1861
  clear_btn.click(on_clear,outputs=[loading_html,result_html,evidence_html,evidence_section,query_input,last_result,last_query,last_cfg])
1862
  for button,example in example_buttons:
1863
  button.click(lambda val=example: val,outputs=[query_input])
1864
+ for control in (evidence_count,translation_count,show_scores,show_meta,answer_mode):
1865
+ control.change(rerender,inputs=[evidence_count,translation_count,show_scores,show_meta,answer_mode,last_result,last_query,last_cfg,session_state],
1866
  outputs=[result_html,evidence_html,last_cfg])
1867
  history_btn.click(lambda session: (gr.update(open=True),gr.update(open=True),gr.update(value=render_history(session))), inputs=[session_state],outputs=[tools_panel,history_panel,history_body])
1868
  clear_history_btn.click(on_clear_history,inputs=[session_state],outputs=[history_body,session_state])
1869
+ history_bridge.change(on_history_select,inputs=[history_bridge,evidence_count,translation_count,show_scores,show_meta,answer_mode,session_state],
1870
  outputs=[result_html,evidence_html,query_input,evidence_section,last_result,last_query,last_cfg])
1871
  bookmark_bridge.change(on_bookmark,inputs=[bookmark_bridge,last_result,last_cfg,session_state],outputs=[evidence_html,session_state])
1872
  set_a_btn.click(lambda q,r,s:on_set_compare('a',q,r,s),inputs=[last_query,last_result,session_state],outputs=[compare_panel,compare_body,session_state])
1873
  set_b_btn.click(lambda q,r,s:on_set_compare('b',q,r,s),inputs=[last_query,last_result,session_state],outputs=[compare_panel,compare_body,session_state])
1874
  compare_btn.click(on_open_compare,inputs=[session_state],outputs=[compare_panel,compare_body])
1875
  for button,fmt in ((exp_json,'json'),(exp_csv,'csv'),(exp_pdf,'pdf')):
1876
+ button.click(lambda q,r,m,a,f=fmt:on_export(f,q,r,m,a),inputs=[last_query,last_result,answer_mode,include_pdf_appendix],outputs=[export_file])
1877
  exp_bm.click(on_export_bookmarks,inputs=[session_state],outputs=[export_file])
1878
  return demo
1879
 
 
1887
  .v63-multi{display:grid;grid-template-columns:minmax(0,1fr);gap:12px;width:100%;min-width:0}
1888
  .hd-evidence,.hd-pass-preview,.hd-passage{overflow-wrap:anywhere;word-break:break-word}
1889
  </style>"""
1890
+ HEAD_HTML += """<style>
1891
+ .v66-mode-bar{align-items:center;flex-wrap:wrap;gap:16px;background:var(--panel,#102d26);padding:12px;border-radius:12px;margin:4px 0 10px}
1892
+ .v66-mode-bar .wrap{flex:1;min-width:200px}
1893
+ .v66-mode-hint{font-size:13px;color:var(--muted,#95b5a9);line-height:1.7}
1894
+ .v66-detailed{margin-top:18px;background:rgba(26,116,84,.075);padding:14px 18px;border-radius:12px;line-height:2}
1895
+ .v66-detailed ol{padding-right:25px;margin-top:8px;list-style:decimal}
1896
+ .v66-detailed li{padding:6px 0;border-bottom:1px solid var(--line,rgba(100,150,110,.2))}
1897
+ .v66-detail-note{opacity:.85;font-size:14px;margin-top:16px}
1898
+ </style>"""
1899
+
1900
  demo=create_interface()
1901
  if __name__=='__main__':
1902
  demo.queue(default_concurrency_limit=1)
1903
+ demo.launch(server_name='0.0.0.0',server_port=7860,head=HEAD_HTML,show_error=False)
huda_v66_pdf.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """HUDA-Net V66 concise/detailed Arabic PDF exporter.
3
+
4
+ Report is not an independent proof of a religious ruling. No remote calls.
5
+ Relies on the same reportlab, arabic_reshaper, python-bidi already used by V64.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import tempfile
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from reportlab.lib.pagesizes import A4
16
+ from reportlab.pdfbase import pdfmetrics
17
+ from reportlab.pdfbase.ttfonts import TTFont
18
+ from reportlab.pdfgen.canvas import Canvas
19
+ from arabic_reshaper import reshape
20
+ from bidi.algorithm import get_display
21
+
22
+ DEEP = '#102C28'
23
+ GREEN = '#15684D'
24
+ MINT = '#D9F3E8'
25
+ CREAM = '#F8F6F0'
26
+ INK = '#19322E'
27
+ MUTED = '#526862'
28
+ EDGE = '#D6E2DA'
29
+ AMBER = '#9B692B'
30
+ WHITE = '#FFFFFF'
31
+
32
+
33
+ def _rgb(v):
34
+ v=v.lstrip('#')
35
+ return tuple(int(v[i:i+2],16)/255 for i in (0,2,4))
36
+
37
+
38
+ def _font_pair():
39
+ paths=[
40
+ ('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf','/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'),
41
+ ('/usr/share/fonts/truetype/noto/NotoSansArabic-Regular.ttf','/usr/share/fonts/truetype/noto/NotoSansArabic-Bold.ttf'),
42
+ ('C:/Windows/Fonts/arial.ttf','C:/Windows/Fonts/arialbd.ttf')]
43
+ for reg,bold in paths:
44
+ if Path(reg).is_file():
45
+ if not Path(bold).is_file():bold=reg
46
+ if 'HUDAV66' not in pdfmetrics.getRegisteredFontNames():
47
+ pdfmetrics.registerFont(TTFont('HUDAV66',reg))
48
+ pdfmetrics.registerFont(TTFont('HUDAV66B',bold))
49
+ return 'HUDAV66','HUDAV66B'
50
+ try:
51
+ import matplotlib
52
+ p=Path(matplotlib.__file__).parent/'mpl-data/fonts/ttf/DejaVuSans.ttf'
53
+ if p.is_file():
54
+ if 'HUDAV66' not in pdfmetrics.getRegisteredFontNames():
55
+ pdfmetrics.registerFont(TTFont('HUDAV66',str(p)))
56
+ pdfmetrics.registerFont(TTFont('HUDAV66B',str(p)))
57
+ return 'HUDAV66','HUDAV66B'
58
+ except ImportError:pass
59
+ raise RuntimeError('تعذر العثور على خط عربي داخل بيئة التشغيل')
60
+
61
+
62
+ def _vis(s):
63
+ return get_display(reshape(str(s or '')),base_dir='R')
64
+
65
+
66
+ def _clean(s):
67
+ return ' '.join(str(s or '').replace('\x00','').split())
68
+
69
+
70
+ def _short(s,n=335):
71
+ s=_clean(s)
72
+ if len(s)<=n:return s,False
73
+ mark=max(s.rfind('،',int(n*.6),n),s.rfind(' ',int(n*.7),n),s.rfind('؛',int(n*.6),n))
74
+ if mark<int(n*.5):mark=n
75
+ return s[:mark].rstrip('،؛ .')+'…',True
76
+
77
+
78
+ def _wrap(s,font,size,width):
79
+ """Shape *complete* Arabic line, then measure its glyphs to avoid overflow."""
80
+ lines=[]
81
+ for paragraph in str(s or '').replace('\r','').split('\n'):
82
+ if not paragraph.strip():
83
+ lines.append('');continue
84
+ current=''
85
+ for w in paragraph.split():
86
+ candidate=(current+' '+w).strip()
87
+ if current and pdfmetrics.stringWidth(_vis(candidate),font,size)>width:
88
+ lines.append(current);current=w
89
+ else:current=candidate
90
+ if pdfmetrics.stringWidth(_vis(current),font,size)>width:
91
+ tmp=''
92
+ for char in current:
93
+ if tmp and pdfmetrics.stringWidth(_vis(tmp+char),font,size)>width:
94
+ lines.append(tmp);tmp=char
95
+ else:tmp+=char
96
+ current=tmp
97
+ if current:lines.append(current)
98
+ return lines
99
+
100
+
101
+ class Writer:
102
+ def __init__(self,path):
103
+ self.W,self.H=A4
104
+ self.x=44;self.right=self.W-44;self.width=self.W-88
105
+ self.c=Canvas(path,pagesize=A4,pageCompression=1)
106
+ self.c.setTitle('هدى نت | تقرير بحث فقهي')
107
+ self.c.setAuthor('HUDA-Net Research Preview')
108
+ self.reg,self.bold=_font_pair()
109
+ self.page=0
110
+ self.y=0
111
+ self._start(first=True)
112
+
113
+ def paint(self,color):self.c.setFillColorRGB(*_rgb(color))
114
+
115
+ def _right(self,s,y,size=10,color=INK,bold=False,x=None):
116
+ self.paint(color)
117
+ self.c.setFont(self.bold if bold else self.reg,size)
118
+ self.c.drawRightString(self.right if x is None else x,y,_vis(s))
119
+
120
+ def _start(self,first=False):
121
+ self.page+=1
122
+ c=self.c;W,H=self.W,self.H
123
+ self.paint(WHITE);c.rect(0,0,W,H,fill=1,stroke=0)
124
+ self.paint(DEEP);c.rect(0,H-87,W,87,fill=1,stroke=0)
125
+ self.paint(GREEN);c.rect(0,H-89,W,2.5,fill=1,stroke=0)
126
+ self.paint('#2E866A');c.roundRect(self.x,H-67,104,28,11,fill=1,stroke=0)
127
+ self._right('تقرير بحثي',H-57,10,WHITE,True,x=self.x+90)
128
+ self._right('هُدى نت',H-42,20,WHITE,True)
129
+ self._right('بحث فقهي مستند إلى المصادر | نسخة تجريبية',H-63,8.4,'#BFDCD0')
130
+ self.y=H-111
131
+
132
+ def _footer(self):
133
+ c=self.c
134
+ self.paint(EDGE);c.rect(self.x,37,self.width,.75,fill=1,stroke=0)
135
+ self._right(f'صفحة {self.page} | هُدى نت',23,8,MUTED)
136
+ self.paint(MUTED);c.setFont(self.reg,7)
137
+ c.drawString(self.x,23,'HUDA-NET • V66 / RESEARCH PREVIEW')
138
+
139
+ def newpage(self):
140
+ self._footer();self.c.showPage();self._start()
141
+
142
+ def need(self,h):
143
+ if self.y-h<70:self.newpage()
144
+
145
+ def text(self,s,size=10,color=INK,bold=False,leading=None,indent=0,limit=None):
146
+ leading=leading or size*1.83
147
+ s=_clean(s) if '\n' not in str(s or '') else str(s)
148
+ if limit is not None:s=_short(s,limit)[0]
149
+ lines=_wrap(s,self.bold if bold else self.reg,size,self.width-indent)
150
+ for line in lines:
151
+ self.need(leading)
152
+ if line:self._right(line,self.y,size,color,bold,self.right-indent)
153
+ self.y-=leading
154
+ return len(lines)*leading
155
+
156
+ def title(self,heading):
157
+ self.need(40)
158
+ self._right(heading,self.y,12,GREEN,True)
159
+ self.y-=23
160
+
161
+ def strip(self,label,content,color=GREEN):
162
+ lh=_wrap(content,self.reg,11.8,self.width-38)
163
+ h=40+len(lh)*22
164
+ self.need(h+12)
165
+ ytop=self.y
166
+ self.paint('#EDF8F2' if color==GREEN else '#FFF8EF')
167
+ self.c.roundRect(self.x,ytop-h,self.width,h,13,fill=1,stroke=0)
168
+ self.paint(color);self.c.roundRect(self.right-5,ytop-h,5,h,2,fill=1,stroke=0)
169
+ self._right(label,ytop-24,10,color,True,self.right-18)
170
+ yy=ytop-50
171
+ for line in lh:
172
+ self._right(line,yy,11.8,INK,False,self.right-19)
173
+ yy-=22
174
+ self.y=ytop-h-18
175
+
176
+ def source_card(self,index,item,used=True):
177
+ name=_clean(item.get('book') or item.get('book_ar') or 'مرجع غير مسمى')
178
+ page=_clean(item.get('page') or item.get('page_number') or '')
179
+ title=_clean(item.get('title') or '')
180
+ excerpt=_clean(item.get('support_excerpt') or item.get('passage_ar') or '')
181
+ excerpt,trimmed=_short(excerpt,270 if used else 195)
182
+ if not excerpt:return
183
+ textlines=_wrap(excerpt,self.reg,9.7,self.width-46)
184
+ toplines=_wrap(title,self.bold,10,self.width-110)[:2]
185
+ h=38+len(toplines)*16+len(textlines)*17+29
186
+ self.need(h+14)
187
+ top=self.y
188
+ self.paint('#F7F9F7');self.c.roundRect(self.x,top-h,self.width,h,10,fill=1,stroke=0)
189
+ self.paint(EDGE);self.c.setStrokeColorRGB(*_rgb(EDGE));self.c.roundRect(self.x,top-h,self.width,h,10,fill=0,stroke=1)
190
+ self.paint(GREEN if used else MUTED);self.c.circle(self.right-23,top-23,12,fill=1,stroke=0)
191
+ self._right(str(index),top-26.5,9,WHITE,True,self.right-20)
192
+ self._right((name+((' | ص '+page) if page else ''))[:110],top-24,9.5,GREEN if used else MUTED,True,self.right-47)
193
+ cur=top-44
194
+ for line in toplines:
195
+ self._right(line,cur,10,INK,True,self.right-23)
196
+ cur-=16
197
+ cur-=1
198
+ for line in textlines:
199
+ self._right(line,cur,9.7,INK,False,self.right-23)
200
+ cur-=17
201
+ if trimmed:self._right('النص مقتطع للاختصار. المرجع الكامل متاح في تصدير JSON/CSV.',cur-1,7.5,MUTED,False,self.right-23)
202
+ self.y=top-h-14
203
+
204
+ def note(self,txt):
205
+ self.need(52)
206
+ self.paint(CREAM)
207
+ lines=_wrap(txt,self.reg,8.5,self.width-30)
208
+ h=max(45,len(lines)*16+23)
209
+ self.need(h+15)
210
+ top=self.y
211
+ self.c.roundRect(self.x,top-h,self.width,h,9,fill=1,stroke=0)
212
+ cur=top-23
213
+ for l in lines:
214
+ self._right(l,cur,8.5,AMBER,False,self.right-14);cur-=16
215
+ self.y=top-h-17
216
+
217
+ def close(self):
218
+ self._footer();self.c.save()
219
+
220
+
221
+ def _question_report(writer,question,result,no=None,mode="مختصر",include_appendix=False):
222
+ c=result or {}
223
+ if no is not None:
224
+ writer._right('السؤال '+str(no),writer.y,10,GREEN,True)
225
+ writer.y-=22
226
+ writer.text('السؤال',size=9,color=MUTED,bold=True)
227
+ writer.text(question,size=16,color=DEEP,bold=True,leading=29)
228
+ writer.y-=9
229
+ status=str(c.get('decision') or '')
230
+ brief=c.get('v61_brief') or {}
231
+ approved=status=='answer' and brief.get('status') in ('SINGLE_SOURCE','MULTI_SOURCE') and bool(brief.get('references'))
232
+ invalid=status=='input_error'
233
+ if approved:
234
+ parts=brief.get('answer_parts') or []
235
+ clauses=[]
236
+ for part in parts:
237
+ clause=_clean(part.get('text'))
238
+ if not clause:continue
239
+ cites=' '.join('['+str(n)+']' for n in (part.get('ref_numbers') or []))
240
+ clauses.append(clause+' '+cites)
241
+ if clauses:
242
+ writer.strip('الجواب من الأدلة المتاحة','\n'.join(clauses),GREEN)
243
+ else:approved=False
244
+ if approved and mode=='مفصل':
245
+ details=list(brief.get('detailed_parts') or [])
246
+ if details:
247
+ writer.title('تفاصيل إضافية مدعومة بالنصوص')
248
+ for idx,detail in enumerate(details,1):
249
+ cites=' '.join('['+str(n)+']' for n in (detail.get('ref_numbers') or []))
250
+ writer.text(str(idx)+'. '+str(detail.get('text') or '')+' '+cites,size=10.5,leading=20)
251
+ writer.y-=8
252
+ else:
253
+ writer.text('لا توجد تفاصيل إضافية اجتازت التحقق الآلي؛ لا تُضاف معلومات غير موثقة لملء التقرير.',size=8.6,color=MUTED)
254
+ writer.y-=10
255
+ if not approved:
256
+ msg=('السؤال غير واضح أو خارج نطاق المكتبة. لم يتم إجراء استدلال فقهي.' if invalid else
257
+ 'لم تتوفر أدلة مباشرة كافية للتحقق من جواب هذا السؤال. النتائج المعروضة أدناه، إن وجدت، مراجع بحثية فقط وليست جوابًا معتمدًا.')
258
+ writer.strip('النتيجة: لم يُصدر النظام جوابًا',msg,AMBER)
259
+ if invalid and c.get('input_error'):
260
+ writer.text(str(c.get('input_error')),size=9,color=MUTED)
261
+ writer.y-=8
262
+ needed=set()
263
+ for part in (brief.get('answer_parts') or []):needed.update(int(n) for n in part.get('ref_numbers') or [])
264
+ if mode=='مفصل':
265
+ for part in (brief.get('detailed_parts') or []):needed.update(int(n) for n in part.get('ref_numbers') or [])
266
+ refs=[r for r in (brief.get('references') or []) if int(r.get('number',0)) in needed] if approved else []
267
+ if refs:
268
+ writer.title('المراجع التي استُخدمت في تكوين الجواب')
269
+ for n,ref in enumerate(refs[:5],1):writer.source_card(ref.get('number') or n,ref,used=True)
270
+ writer.note('هذه المراجع تدعم المقتطف المستخرج آليًا بحسب فحص النظام. صحة الفتوى والنص الأصلي لم تخضع لاعتماد علمي مستقل.')
271
+ if invalid:return
272
+ if not include_appendix:return
273
+ # Research-only evidence is intentionally concise and clearly separated;
274
+ # unrelated OCR blocks are never copied en masse to the PDF.
275
+ other=[]
276
+ used_ids={str(r.get('record_id')) for r in refs}
277
+ for row in list(c.get('candidates') or [])+list(c.get('supplemental_candidates') or []):
278
+ rid=str(row.get('record_id') or '')
279
+ if rid and rid not in used_ids and rid not in {str(x.get('record_id')) for x in other}:
280
+ other.append(row)
281
+ if len(other)>=3:break
282
+ if other:
283
+ writer.title('نتائج بحث إضافية | غير مستخدمة في الجواب')
284
+ for i,row in enumerate(other,1):writer.source_card(i,row,used=False)
285
+ if writer.y>155:
286
+ writer.note('ترتيب البحث لا يعني صحة الدليل. قد تحتوي النتائج على صفحات غير مرتبطة أو نصوص استخراج مشوّهة. تحقّق من المرجع الأصلي.')
287
+
288
+
289
+ def export_pdf(query,result,mode="مختصر",include_appendix=False):
290
+ """Return a temporary file; caller handles safe, human-readable naming."""
291
+ fd,path=tempfile.mkstemp(prefix='huda_v66_',suffix='.pdf')
292
+ os.close(fd)
293
+ try:
294
+ writer=Writer(path)
295
+ writer.text("صيغة التقرير: "+("مفصل" if mode=="مفصل" else "مختصر"),size=9,color=GREEN,bold=True)
296
+ writer.text('تاريخ الإصدار: '+datetime.now().strftime('%Y-%m-%d %H:%M'),size=9,color=MUTED)
297
+ writer.text('تقرير أدلة بحثية، وليس فتوى رسمية أو تقديرًا معتمدًا لصحة الحكم.',size=8.8,color=MUTED)
298
+ writer.y-=15
299
+ if (result or {}).get('decision')=='multi':
300
+ questions=list((result or {}).get('v63_questions') or [])
301
+ for n,item in enumerate((result or {}).get('sub_results') or [],1):
302
+ question=questions[n-1] if n<=len(questions) else item.get('v63_question','سؤال '+str(n))
303
+ if n>1:writer.newpage()
304
+ _question_report(writer,question,item,no=n,mode=mode,include_appendix=include_appendix)
305
+ else:
306
+ _question_report(writer,query,result or {},mode=mode,include_appendix=include_appendix)
307
+ writer.close()
308
+ return path
309
+ except Exception:
310
+ try:os.remove(path)
311
+ except OSError:pass
312
+ raise
huda_v66_quality.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """HUDA-Net V65: conservative corpus-gated question handling and grounded extracts.
3
+
4
+ No finetuning, unverified probability calibration, or claims of sharia certification.
5
+ A source excerpt is a research extract, not a verified fatwa. Fail closed.
6
+ """
7
+ from __future__ import annotations
8
+ import re
9
+ import math
10
+ from collections import Counter
11
+ from difflib import SequenceMatcher
12
+ from typing import Any
13
+
14
+ DIAC = re.compile(r'[\u064b-\u065f\u0670\u0640]')
15
+ AR = re.compile(r'[\u0621-\u064a]')
16
+ LAT = re.compile(r'[A-Za-z]')
17
+ TOK = re.compile(r'[\u0621-\u064a]+|[a-z]+|[0-9]+')
18
+
19
+ # Grammar stop words, *not* a fixed FAQ or prewritten fatwa database.
20
+ STOP = set('ما ماذا هل كم كيف لماذا متى اين أين وش وشلون ايش ايش هي ايش هو حكم حكمه احكام ماحكم اريد ابغى ابي ابيك اريدك سؤال الجواب عطني اشرح هل يمكن على عن الى إلي من في مع عند اذا لانه حيث هذا هذه تلك ذلك اللي الي الذي التي متى بين فيه به له لها لهم لهم كل اي عندي عندنا انا انت انتي انتم هو هي كنته كنت صار سويت سويته والله بالله طيب اذن الان ثم بعد قبل فقط ايضا واحد انه هذي هاذا فيه شي شيء خصوصا فهل اذا كان كانوا '.split())
21
+ STOP.update('الاصطلاح الاصطلاحي الشرعي شرعا وجوب اجزاء ماهو ماهي عرف يعتبر ماحكم يعتبر الحكم الفقهي الفقهيه الحاكم شريعة الشرع للتفصيل التفصيل بيانه بيان المقصود'.split())
22
+ FILLER = set('بلا بلابلا بلابلابلا blah blabla bla blafoo lol lolol ههه ههههه اوكي ok test testing abc xyz 123 qwerty asdf'.split())
23
+ QUESTION_SIGNALS = set('ما ماذا هل وش ايش كم كيف لماذا متى اين حكم يجوز يجوزلي جائز جايز واجب يجب اركان شروط تعريف معنى نسيت ناسي سهوت تركت ارمي هللي قصيت حلقت سويت ماذا'.split())
24
+ # Morphological normalization aids only, never creates a new jurisprudential ruling.
25
+ ALIASES_RAW = {'الحج':'حج','حجا':'حج','حجة':'حج','حجي':'حج','الحاج':'حج',
26
+ 'العمرة':'عمره','العمرة':'عمره','العمره':'عمره',
27
+ 'الجمره':'جمر','الجمرة':'جمر','الجمرات':'جمر','الجمار':'جمر','رمي':'رمي','الرمي':'رمي','يرمي':'رمي','ارمي':'رمي','رميت':'رمي',
28
+ 'نسيت':'نسي','ناسي':'نسي','ناسيا':'نسي','نسى':'نسي','سهوا':'نسي','سهو':'نسي',
29
+ 'الصبي':'صبي','صغير':'صبي','الطفل':'صبي','الحائض':'حيض','الحائضه':'حيض','حائض':'حيض','حاضت':'حيض',
30
+ 'قصيت':'شعر','قصرت':'شعر','حلق':'شعر','حلقت':'شعر','شعري':'شعر','الشعر':'شعر','شعر':'شعر',
31
+ 'الصيام':'صوم','الصوم':'صوم','صيام':'صوم','صام':'صوم',
32
+ 'واجب':'واجب','يجب':'واجب','فرض':'واجب','فريضة':'واجب',
33
+ 'الطواف':'طواف','السعي':'سعي','المروة':'مروه','الصفا':'صفا','عرفه':'عرفه','بعرفة':'عرفه',
34
+ 'الوقوف':'وقوف','ميقات':'ميقات','الميقات':'ميقات',
35
+ 'صح':'صحيح','صحة':'صحيح','صحيحة':'صحيح','يصح':'صحيح','صحيح':'صحيح',
36
+ 'الفدية':'فديه','فدية':'فديه','دم':'دم', 'الركن':'ركن','الاركان':'ركن','أركان':'ركن',
37
+ 'التلبية':'تلبيه','واجبات':'واجبات','الواجبات':'واجبات',
38
+ 'ترك':'ترك','تركت':'ترك','التارك':'ترك','تركه':'ترك'}
39
+
40
+ def norm(v:Any)->str:
41
+ s=DIAC.sub('',str(v or '').lower()).translate(str.maketrans({'أ':'ا','إ':'ا','آ':'ا','ٱ':'ا','ؤ':'و','ئ':'ي','ى':'ي','ة':'ه'}))
42
+ return ' '.join(TOK.findall(s))
43
+ ALIASES={norm(k):norm(v) for k,v in ALIASES_RAW.items()}
44
+
45
+ def words(v:Any)->list[str]:return TOK.findall(norm(v))
46
+ def canonical(v:str)->str:
47
+ v=norm(v)
48
+ if v in ALIASES:return ALIASES[v]
49
+ # Arabic clitics: بالحج، فالحج، وبالحج، بالحائض... must match
50
+ # the same lexical anchors as الحج and الحائض. Only strip a clitic
51
+ # when the remaining expression is explicitly in the alias map.
52
+ for pre in ('وب','فب','ب','ف','و','ل'):
53
+ if v.startswith(pre) and len(v)>len(pre)+2:
54
+ rest=v[len(pre):]
55
+ if rest in ALIASES:return ALIASES[rest]
56
+ if v.startswith('لل') and len(v)>4:v=v[2:]
57
+ elif v.startswith('ل') and len(v)>3 and v[1:] in ALIASES:v=v[1:]
58
+ elif v.startswith('و') and len(v)>4 and v[1:] in ALIASES:v=v[1:]
59
+ if v.startswith('وال') and len(v)>5:v=v[3:]
60
+ elif v.startswith('ال') and len(v)>4:v=v[2:]
61
+ return ALIASES.get(v,v)
62
+
63
+ def terms(v:Any)->list[str]:
64
+ out=[]
65
+ for w in words(v):
66
+ if w in FILLER or w in STOP or w in QUESTION_SIGNALS or w.isdigit() or (len(w)<3 and canonical(w) not in ('حج','دم')):continue
67
+ c=canonical(w)
68
+ if c not in out and c not in STOP and len(c)>=2:out.append(c)
69
+ return out
70
+
71
+ def split_questions(value:Any,limit:int=3)->dict:
72
+ raw=str(value or '').strip()[:1200]
73
+ if not raw:return {'status':'EMPTY','questions':[],'dropped':[]}
74
+ parts=re.split(r'[\n\?\u061f\uff1f]+',raw)
75
+ good=[];dropped=[]
76
+ for part in parts:
77
+ part=' '.join(part.strip(' ،؛;.!').split())
78
+ if not part:continue
79
+ w=words(part)
80
+ if not terms(part) or (len(set(w))<=1 and len(w)>1):
81
+ dropped.append(part[:120]);continue
82
+ if len(LAT.findall(part))>max(6,len(AR.findall(part))*2) and len(w)<6:
83
+ dropped.append(part[:120]);continue
84
+ good.append(part)
85
+ status='TOO_MANY' if len(good)>limit else 'MULTI' if len(good)>1 else 'OK' if good else 'NO_QUESTION'
86
+ return {'status':status,'questions':good if status!='TOO_MANY' else [],'dropped':dropped}
87
+
88
+ class DomainProfile:
89
+ """Vocabulary learned from frozen corpus subject metadata, not query examples."""
90
+ def __init__(self,records):
91
+ counts=Counter()
92
+ for r in records:
93
+ raw=' '.join(str(r.get(k,'') or '') for k in ('title','chapter','category'))
94
+ counts.update(set(terms(raw)))
95
+ self.common={k for k,v in counts.items() if v>=2}
96
+ self.all=set(counts)
97
+ def check(self,q:str)->tuple[bool,str]:
98
+ w=words(q);tt=terms(q)
99
+ if not tt:return False,'لم أتعرف على مسألة واضحة في السؤال.'
100
+ junk=sum(word in FILLER for word in w)
101
+ if junk>=2 or (junk and junk/len(w)>.35):
102
+ return False,'السؤال يتضمن كلامًا غير مفهوم؛ اكتب المسألة الفقهية دون كلمات عشوائية.'
103
+ if len(w)>1 and len(set(w))<=2 and max(Counter(w).values())>=len(w)-1:
104
+ return False,'النص مكرر أو غير واضح؛ اكتب سؤالًا فقهيًا مفهومًا.'
105
+ if not set(tt).intersection(self.common):
106
+ return False,'السؤال خارج نطاق المصادر المتاحة أو غير واضح. اكتب سؤالًا محددًا عن الحج أو العمرة.'
107
+ if len(tt)==1 and len(w)<3 and not any(s in w for s in QUESTION_SIGNALS):
108
+ return False,'اكتب سؤالًا فقهيًا كاملًا حتى أحدد الحكم المطلوب.'
109
+ return True,''
110
+
111
+ def corpus_profile(corpus)->DomainProfile:
112
+ return DomainProfile(corpus.to_dict('records') if hasattr(corpus,'to_dict') else corpus)
113
+
114
+ def clean_excerpt(value:Any)->str:
115
+ s=' '.join(str(value or '').split())
116
+ if '\ufffd' in s or len(s)<33:return ''
117
+ # Prefer explicit pre-extracted summary, do not drag arbitrary OCR footnotes into answer.
118
+ for mark in (' ويدل عليه نص ', ' نص المصدر:', ' وفي النص المصور:'):
119
+ if mark in s:s=s.split(mark,1)[0].strip()
120
+ for mark in ('وخلاصتها في المذهب الحنبلي:', 'الخلاصة المعتمدة للسؤال:', 'وردت المسألة بمعناها:'):
121
+ if mark in s:s=s.split(mark,1)[-1].strip()
122
+ if len(AR.findall(s))<24:return ''
123
+ if len(LAT.findall(s))>max(12,len(AR.findall(s))*.14):return ''
124
+ if len(words(s))<5:return ''
125
+ if s.startswith(('قال:', 'قال :')) and len(s)<125:return ''
126
+ if s.count('»')+s.count('«')>max(8,len(s)//80):return ''
127
+ if len(re.findall(r'[/\\|#_\[\]]',s))>max(5,len(s)//45):return ''
128
+ if len(s)>440:
129
+ m=re.search(r'[.؛؟]\s',s[90:400])
130
+ if m:s=s[:90+m.end()].strip()
131
+ else:return ''
132
+ return s.strip(' .،؛:')
133
+
134
+ def question_intent(question:str)->str:
135
+ q=norm(question)
136
+ if any(k in q for k in ('تعريف','ما معنى','معنى','المقصود')) or (any(k in q for k in ('ما هو','ما هي')) and not any(k in q for k in ('حكم','وجوب','يجوز','يجب'))):return 'definition'
137
+ if any(k in q for k in ('متى','وقت','ينتهي','يبدا','بدايه')):return 'time'
138
+ if any(k in q for k in ('اركان','واجبات','كم عدد','اشواط')):return 'enumeration'
139
+ if any(k in q for k in ('شروط','شرط')):return 'conditions'
140
+ if any(k in q for k in ('نسيت','ناسي','سهوا','سهو','ترك','وش علي','ماذا افعل','فعلت')):return 'remedy'
141
+ return 'ruling'
142
+
143
+ def anchor_coverage(qt:list[str],text:str)->float:
144
+ if not qt:return 0.
145
+ ts=set(terms(text))
146
+ points=0.
147
+ for q in qt:
148
+ if q in ts:points+=1
149
+ elif len(q)>4 and any(len(x)>4 and SequenceMatcher(None,q,x).ratio()>=.84 for x in ts):points+=.7
150
+ return points/len(qt)
151
+
152
+ def _is_ruling_text(claim:str)->bool:
153
+ n=norm(claim)
154
+ return any(w in n for w in ('واجب','فرض','فريض','ركن','يجب','يجوز','لا يجوز','لا يجب','يصح','لا يصح','صحيح','لا يجزي','لا يجز','يحرم','حرام','مباح','يرخص','ليس عليه','عليه دم','لا شيء عليه','يسقط','يستحب','سنه','سنة','بدونها'))
155
+
156
+ def _same_context(question:str,claim:str,title:str)->bool:
157
+ """The source must answer question's requested kind of information, not nearby trivia."""
158
+ intent=question_intent(question); s=norm(claim);t=norm(title)
159
+ if intent=='definition':return any(w in s for w in ('معنى','تعريف','شرعا','هو قصد','يقصد','اصطلاحا'))
160
+ if intent=='time':return any(w in s for w in ('وقت','يبدا','ينتهي','الفجر','الزوال','غروب','نصف الليل','الشمس','ليلا'))
161
+ if intent=='enumeration':return any(w in s for w in ('اركان','واجبات','اشواط','اربعه','سبعه','ثلاثه','خمسه'))
162
+ if intent=='conditions':return any(w in s for w in ('شرط','شروط','يشترط','الا اذا','لا يصح'))
163
+ if intent=='remedy':return any(w in s for w in ('نسي','سهوا','ترك','فات','لم يرم','يعيد','يقضي','يلزمه','عليه دم','فديه','دم','لا شيء عليه'))
164
+ return _is_ruling_text(claim)
165
+
166
+ def _ruling_focus(q:str,claim:str,title:str)->bool:
167
+ """For ruling questions reject answers about ancillary conditions or rituals.
168
+ The rule is compositional: subjects + a judgment in the same local clause.
169
+ """
170
+ qt=terms(q)
171
+ if question_intent(q)!='ruling':return True
172
+ # A conditional special-case answer must not be passed off as an unconditional ruling.
173
+ nq=norm(q); nt=norm(title)
174
+ if not any(k in nq for k in ('اذا','ان لم','عند عدم','في حال','لمن لم')) and any(k in nt for k in ('عند عدم','اذا لم','في حال','لمن لم','اذا عجز','عند العجز')):
175
+ return False
176
+ lines=re.split(r'[.؛؟،]',claim)
177
+ for clause in lines:
178
+ if not _is_ruling_text(clause):continue
179
+ if anchor_coverage(qt,clause)>= (0.99 if len(qt)<=2 else .68):
180
+ # Distinguish an obligation's existence from a list of conditions to it.
181
+ norm_clause=norm(clause)
182
+ if ('شروط' in norm_clause or 'الا بشروط' in norm_clause) and not any(x in norm(q) for x in ('شروط','شرط')):
183
+ # A source chiefly about requirements does not itself state the primary ruling.
184
+ if len(qt)<=1:continue
185
+ return True
186
+ return False
187
+
188
+ def _required_facet_binding(q:str,claim:str,title:str)->bool:
189
+ """Block a nearby but different jurisprudential question from being cited.
190
+
191
+ For omissions, 'ترك طواف الوداع ... بلا إحرام' is not an answer
192
+ about *leaving ihram*. For multi-aspect questions require every aspect.
193
+ """
194
+ w=words(q);nc=norm(claim);nt=norm(title)
195
+ facets={'وجوب':('وجوب','واجب','يجب','فريض','فرض'),
196
+ 'اجزاء':('اجزاء','اجزا','يجز','تجز','سقوط الفرض','حجة الاسلام'),
197
+ 'صحه':('صحه','صحيح','يصح','لا يصح'),
198
+ 'فديه':('فديه','دم','كفاره')}
199
+ # These are secondary subquestions, not mere stopwords. Exact clauses
200
+ # need to cover the requested dimensions, not a nearby definition.
201
+ if 'وجوب' in norm(q) and ('اجزاء' in norm(q) or 'اجز' in norm(q)):
202
+ if not all(any(x in nc for x in options) for options in (facets['وجوب'],facets['اجزاء'])):
203
+ return False
204
+ for n,token in enumerate(w[:-1]):
205
+ if canonical(token)!='ترك':continue
206
+ obj=canonical(w[n+1])
207
+ if obj in {'حج','عمره','احرام','رمي','طواف','سعي','شعر','حلق','تقصير','مبيت','وقوف','واجب'}:
208
+ # 'ترك الإحرام من الميقات' is a narrower legal question than
209
+ # 'ترك الإحرام' altogether. Do not generalize a qualified act.
210
+ if obj=='احرام' and not any(k in norm(q) for k in ('ميقات','مواقيت')):
211
+ if re.search(r'ترك\s+(?:ال)?احرام\s+من\s+(?:ال)?ميقات',norm(claim)):
212
+ return False
213
+ textseq=[canonical(x) for x in words(title+' '+claim)]
214
+ if not any(textseq[i]=='ترك' and obj in textseq[i+1:i+3] for i in range(len(textseq)-1)):
215
+ # A title may use a clear omission synonym.
216
+ nn=norm(title+' '+claim)
217
+ if obj=='احرام' and any(x in nn for x in ('لم يحرم','دون احرام','بلا احرام','ترك الاحرام')):
218
+ # Still require the omission to be the *topic* of the
219
+ # passage rather than a trailing context word.
220
+ if not any(x in norm(title) for x in ('احرام','ميقات')):return False
221
+ else:return False
222
+ return True
223
+
224
+ def assess(q:str,rec:dict)->dict:
225
+ title=str(rec.get('title') or '')
226
+ claim=clean_excerpt(rec.get('passage_ar'))
227
+ qt=terms(q)
228
+ reasons=[]
229
+ if not claim:reasons.append('UNREADABLE_SOURCE')
230
+ if not qt:reasons.append('NO_QUESTION_TOPICS')
231
+ if not title or len(title)<4:reasons.append('NO_TITLE')
232
+ if reasons:return {'score':0.,'eligible':False,'claim':'','reasons':reasons}
233
+ cover=anchor_coverage(qt,claim)
234
+ title_cover=anchor_coverage(qt,title)
235
+ # Strict topic check on the claim (metadata/title never sufficient).
236
+ if cover < (1 if len(qt)<=2 else .50):reasons.append('SUBJECT_NOT_SUPPORTED_IN_CLAIM')
237
+ if not _same_context(q,claim,title):reasons.append('REQUESTED_INTENT_MISMATCH')
238
+ if not _ruling_focus(q,claim,title):reasons.append('RULING_NOT_ABOUT_QUESTION')
239
+ if not _required_facet_binding(q,claim,title):reasons.append('WRONG_TARGET_OR_MISSING_ASPECT')
240
+ if re.search(r'^(?:لا )?(?:يجوز|يصح|يجب|يلزم|يمكن|ليس) (?:ذلك|هذا|هذه)\b',norm(claim)):
241
+ reasons.append('UNRESOLVED_REFERENCE_THAT')
242
+ # Duplicate/misleading title lists should not become evidence on their own.
243
+ score=.55*cover+.25*title_cover+.20*(0 if reasons else 1)
244
+ eligible=not reasons and score>=.62
245
+ return {'score':round(score,4),'eligible':eligible,'claim':claim,
246
+ 'reasons':reasons,'title_coverage':round(title_cover,3),'body_coverage':round(cover,3),
247
+ 'intent':question_intent(q)}
248
+
249
+ def _stance(claim:str)->str:
250
+ s=norm(claim)
251
+ neg=any(x in s for x in ('لا يجوز','لا يصح','لا يجب','لا يجزي','يحرم','ليس عليه','يسقط عنه'))
252
+ pos=any(x in s for x in ('يجوز','يصح','يجب','يلزم','جائز','عليه دم','فرض','واجب'))
253
+ return 'MIXED' if neg and pos else 'NEG' if neg else 'POS' if pos else 'NEUTRAL'
254
+
255
+ def same_proposition(a:str,b:str,q:str)->bool:
256
+ # References must support the SAME claim, not merely concern the same fiqh topic.
257
+ ta=set(terms(a));tb=set(terms(b));qt=set(terms(q))
258
+ sa,sb=_stance(a),_stance(b)
259
+ if sa!=sb and sa!='NEUTRAL' and sb!='NEUTRAL':return False
260
+ content_a=ta-qt;content_b=tb-qt
261
+ intersection=len(content_a&content_b)
262
+ similarity=intersection/max(1,min(len(content_a),len(content_b)))
263
+ return intersection>=3 and similarity>=.55 and anchor_coverage(list(qt),b)>=.75
264
+
265
+ def focused_claim(question:str,claim:str)->str:
266
+ """Prefer a relevant self-contained assertion over unrelated follow-up rulings.
267
+
268
+ This is a text *selection*, not a generation rule. It never creates an
269
+ unstated ruling and is only used after the independent evidence check.
270
+ """
271
+ base=str(claim or '').strip(' .؛')
272
+ if not base:return ''
273
+ if len(base)<=170 and not ('؛' in base):return base
274
+ parts=[p.strip(' .؛') for p in re.split(r'[؛\n]',base) if p.strip(' .؛')]
275
+ if not parts:return base
276
+ focus=[]
277
+ qt=terms(question)
278
+ # Don't include collateral punishment, unrelated detailed exception or tangents
279
+ # just because they occur in the same source paragraph.
280
+ for p in parts:
281
+ if not _same_context(question,p,''):continue
282
+ if anchor_coverage(qt,p) < (.95 if len(qt)<=2 else .7):continue
283
+ if not _ruling_focus(question,p,''):continue
284
+ focus.append(p)
285
+ if focus:
286
+ # Select the shortest direct supported clause, not the longest excerpt.
287
+ # It must preserve contrastive qualifiers within the chosen clause.
288
+ focus.sort(key=lambda x:(len(x)>205,len(x)))
289
+ return focus[0]
290
+ return base
291
+
292
+ def compose_answer(question:str,result:dict,max_refs:int=3)->dict:
293
+ all_records=[];seen=set()
294
+ for rec in ([result.get('selected_visible_evidence')] + list(result.get('candidates') or []) + list(result.get('supplemental_candidates') or [])):
295
+ if not rec:continue
296
+ rid=str(rec.get('record_id') or '')
297
+ if not rid or rid in seen:continue
298
+ seen.add(rid);all_records.append(rec)
299
+ scored=[]
300
+ for rec in all_records:
301
+ ev=assess(question,rec)
302
+ if ev['eligible']:scored.append((ev['score'],rec,ev['claim']))
303
+ scored.sort(key=lambda a:(-a[0],str(a[1].get('record_id'))))
304
+ if not scored:
305
+ return {'status':'NEEDS_CHECK','answer_parts':[],'references':[],'candidate_count':len(all_records),'reasons':['NO_DIRECT_ELIGIBLE_EVIDENCE']}
306
+ top=scored[0]
307
+ answer_claim=focused_claim(question,top[2])
308
+ # Keep full direct claim only when the focused extraction still matches.
309
+ if not answer_claim or not _same_context(question,answer_claim,str(top[1].get('title') or '')):
310
+ answer_claim=top[2]
311
+ refs=[];books=set()
312
+ for score,rec,claim in scored:
313
+ book=str(rec.get('book_ar') or rec.get('book') or rec.get('source_file') or '')
314
+ if not book or book in books:continue
315
+ if refs and not same_proposition(answer_claim,focused_claim(question,claim),question):continue
316
+ books.add(book)
317
+ refs.append({'number':len(refs)+1,'record_id':str(rec.get('record_id')),
318
+ 'book':book,'page':str(rec.get('page_number') or rec.get('page') or ''),
319
+ 'title':str(rec.get('title') or ''),'source_file':str(rec.get('source_file') or ''),
320
+ 'support_excerpt':focused_claim(question,claim) if refs else answer_claim})
321
+ if len(refs)>=max_refs:break
322
+ # Source text itself, never invent combined rulings. 1 source is okay only if direct.
323
+ status='MULTI_SOURCE' if len(refs)>1 else 'SINGLE_SOURCE'
324
+ return {'status':status,'answer_parts':[{'text':answer_claim.rstrip(' .؛')+'.',
325
+ 'ref_numbers':[x['number'] for x in refs]}],
326
+ 'references':refs,'candidate_count':len(all_records),
327
+ 'used_selected_record_id':str(top[1].get('record_id')),
328
+ 'original_selected_record_id':str((result.get('selected_visible_evidence') or {}).get('record_id') or ''),
329
+ 'readable_candidates':len(scored),'answer_score_NOT_PROBABILITY':top[0],
330
+ 'reasons':['Extractive document-backed research excerpt; not expert-verified fiqh'],
331
+ 'note':'هذا نص موجز مستخرج من المكتبة، وليس فتوى مُراجعة. تمت مطابقة موضوع السؤال ونوع الحكم بصورة آلية؛ وقد توجد اختلافات فقهية أو أخطاء في المصدر.'}
332
+
333
+
334
+ # ========================================================================
335
+ # V66 | UNIVERSAL QUESTION-EVENT BINDING + EVIDENCE-CITED EXPANSION
336
+ # These are general-purpose checks. They are not prewritten fiqh answers.
337
+ # ========================================================================
338
+ _V65_ASSESS = assess
339
+
340
+
341
+ def _explicit_omission(q: str, claim: str) -> bool:
342
+ """A remedy answer requires evidence of a *missed/omitted act*.
343
+
344
+ The shared words 'رمي' and 'جمار' are not enough: الدعاء بعد الرمي
345
+ describes a different act and must never be cited as a remedy.
346
+ """
347
+ if question_intent(q) != 'remedy':
348
+ return True
349
+ qn = norm(q)
350
+ body = norm(claim)
351
+ if not any(w in qn for w in ('نسي', 'نسيت', 'ناسي', 'سهو', 'سهوا', 'ترك', 'تركت', 'فات', 'لم افعل', 'لم ارم', 'ما رميت')):
352
+ return True
353
+ # Do not accept fuzzy substrings (e.g. 'دم' inside an unrelated word).
354
+ event = re.search(r'(?:^|\s)(?:نسي|نسيت|النسيان|سهوا|ناسيا|ترك|تركت|فات|فاتته|فاته|نسيه|لم\s+يرم|لم\s+ارم|لم\s+يفعل|لم\s+يؤد|لم\s+يود)(?:\s|$)', body)
355
+ if not event:
356
+ return False
357
+ # Require a statement about consequence/correction rather than generic
358
+ # narrative about an omission, and refuse statements solely about du'a.
359
+ remedy = re.search(r'(?:^|\s)(?:يلزمه|لزمه|يجب|عليه|يقضي|قضاء|يعيد|اعاده|يعود|يرجع|يتدارك|تدارك|فديه|دم|كفاره|يسقط|لا\s+شيء|لا\s+حرج|لا\s+يلزمه|معذور|مجزئ|يصح)(?:\s|$)',body)
360
+ return bool(remedy) and (('دعاء' not in body and 'يدعو' not in body) or ('نسي' in body and 'قضاء' in body))
361
+
362
+
363
+ def _request_relation(q: str, title: str, claim: str) -> bool:
364
+ """Non-overlapping domain facets: omission vs performance,
365
+ and conditional exception vs unconditional principal ruling.
366
+ """
367
+ intent=question_intent(q)
368
+ if intent == 'remedy' and not _explicit_omission(q,claim):
369
+ return False
370
+ if intent == 'ruling':
371
+ raw=norm(q);c=norm(claim);t=norm(title)
372
+ if any(x in raw for x in ('ما حكم حج', 'هل يصح حج', 'حكم الحج')) and not any(x in raw for x in ('اذا','لو','عند','بعد')):
373
+ if any(x in t for x in ('اذا افسد','بالوطء','الجماع','اذا بلغ','عند العجز','من لا يجد','قبل الطواف','بعد الرمي')):
374
+ return False
375
+ # Do not answer a broad ruling question with unrelated severe penalty.
376
+ if 'جحد' in c and 'كافر' in c and not any(x in raw for x in ('جحد','انكر')):
377
+ return False
378
+ return True
379
+
380
+
381
+ def assess(q: str, rec: dict) -> dict:
382
+ base=_V65_ASSESS(q,rec)
383
+ if not base.get('eligible'):
384
+ return base
385
+ if not _request_relation(q,str(rec.get('title') or ''),base.get('claim','')):
386
+ return dict(base,eligible=False,reasons=list(base.get('reasons') or [])+['V66_QUESTION_EVENT_OR_SCOPE_MISMATCH'])
387
+ return base
388
+
389
+
390
+ def _facet(question: str,claim: str,title: str) -> str:
391
+ t=norm(title+' '+claim)
392
+ if any(w in t for w in ('لا يجز','لم يجز','حجه الاسلام','حجة الاسلام','اجزاء')):return 'obligation_fulfillment'
393
+ if any(w in t for w in ('طواف الوداع','يسقط الوداع')):return 'farewell_tawaf'
394
+ if any(w in t for w in ('طواف الافاضه','حتى تطهر','حتى تطهري')):return 'essential_tawaf'
395
+ if any(w in t for w in ('يجوز الاحرام','يجوز للحايض الاحرام')):return 'ihram'
396
+ if any(w in t for w in ('الوقوف بعرفه','المبيت','رمي الجمار')) and question_intent(question)!='remedy':return 'rituals'
397
+ if 'ركن من اركان الاسلام' in t:return 'pillar_of_islam'
398
+ if any(w in t for w in ('واجب','يجب','فرض','ركن')):return 'basic_ruling'
399
+ if any(w in t for w in ('يجوز','صحيح','يصح')):return 'validity'
400
+ return 'other'
401
+
402
+
403
+ def compose_answer(question:str,result:dict,max_refs:int=7)->dict:
404
+ """One compact conclusion plus distinct, individually cited facets.
405
+
406
+ No claim is invented, no unverified number of conditions is promised.
407
+ Diverse claims cannot be attached to an unrelated master citation.
408
+ """
409
+ records=[];seen=set()
410
+ for rec in ([result.get('selected_visible_evidence')] + list(result.get('candidates') or []) + list(result.get('supplemental_candidates') or [])):
411
+ if not rec:continue
412
+ rid=str(rec.get('record_id') or '')
413
+ if not rid or rid in seen:continue
414
+ seen.add(rid)
415
+ ev=assess(question,rec)
416
+ if ev.get('eligible'):
417
+ claim=focused_claim(question,ev['claim'])
418
+ if claim and _request_relation(question,str(rec.get('title') or ''),claim):
419
+ records.append((ev['score'],rec,claim))
420
+ records.sort(key=lambda e:(-e[0],str(e[1].get('record_id',''))))
421
+ if not records:
422
+ return {'status':'NEEDS_CHECK','answer_parts':[],'detailed_parts':[],'references':[],
423
+ 'candidate_count':len(seen),'reasons':['V66_NO_DIRECT_TARGET_EVIDENCE']}
424
+ chosen=records[0]
425
+ main=chosen[2].rstrip(' .؛')+'.'
426
+ refs=[];main_refs=[];extra=[];usedbooks=set();facets={_facet(question,main,str(chosen[1].get('title') or ''))}
427
+ def addref(rec,claim):
428
+ book=str(rec.get('book_ar') or rec.get('book') or rec.get('source_file') or 'مرجع غير مسمى')
429
+ number=len(refs)+1
430
+ refs.append({'number':number,'record_id':str(rec.get('record_id')),
431
+ 'book':book,'page':str(rec.get('page_number') or rec.get('page') or ''),
432
+ 'title':str(rec.get('title') or ''),'source_file':str(rec.get('source_file') or ''),
433
+ 'support_excerpt':claim})
434
+ usedbooks.add(book)
435
+ return number
436
+ n=addref(chosen[1],main);main_refs.append(n)
437
+ for score,rec,claim in records[1:]:
438
+ book=str(rec.get('book_ar') or rec.get('book') or rec.get('source_file') or 'مرجع غير مسمى')
439
+ if book in usedbooks:continue
440
+ if same_proposition(main,claim,question):
441
+ main_refs.append(addref(rec,claim));
442
+ if len(main_refs)>=3:break
443
+ # Addition of a separate, verifiably related facet is optional. We do not
444
+ # force details just to lengthen an answer; each gets its own exact cite.
445
+ for score,rec,claim in records[1:]:
446
+ if len(extra)>=4 or len(refs)>=max_refs:break
447
+ book=str(rec.get('book_ar') or rec.get('book') or rec.get('source_file') or 'مرجع غير مسمى')
448
+ if book in usedbooks:continue
449
+ facet=_facet(question,claim,str(rec.get('title') or ''))
450
+ if facet in facets or facet=='other':continue
451
+ if same_proposition(main,claim,question):continue
452
+ # For safety: only support a distinct facet that is explicitly
453
+ # about the same question and is not a conditional special case.
454
+ if not _request_relation(question,str(rec.get('title') or ''),claim):continue
455
+ if anchor_coverage(terms(question),claim)<(.99 if len(terms(question))<=2 else .70):continue
456
+ # If an answer appears to reverse the primary statement, do not
457
+ # synthetically reconcile the difference.
458
+ if _stance(main) in ('POS','NEG') and _stance(claim) in ('POS','NEG') and _stance(main)!=_stance(claim):continue
459
+ detail_ref=addref(rec,claim)
460
+ extra.append({'text':claim.rstrip(' .؛')+'.','ref_numbers':[detail_ref], 'facet':facet})
461
+ facets.add(facet)
462
+ return {'status':'MULTI_SOURCE' if len(refs)>1 else 'SINGLE_SOURCE',
463
+ 'answer_parts':[{'text':main,'ref_numbers':main_refs}],
464
+ 'detailed_parts':extra,'references':refs,
465
+ 'candidate_count':len(seen),'readable_candidates':len(records),
466
+ 'used_selected_record_id':str(chosen[1].get('record_id')),
467
+ 'answer_score_NOT_PROBABILITY':chosen[0],
468
+ 'reasons':['V66_EXTRACTIVE_MULTI_FACET_NOT_GOLD'],
469
+ 'note':'عرض بحثي استخراجي، وليس فتوى معتمدة. عرض «مفصل» يضيف فقط نقاطاً وجدت لها أدلة منفصلة، ولا يدّعي استيعاب جميع الأحكام.'}