Spaces:
Running on Zero
Running on Zero
Upload 3 files
Browse files- app.py +69 -34
- huda_v66_pdf.py +312 -0
- 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
|
| 41 |
|
| 42 |
# ─── Config ────────────────────────────────────────────────────────────────
|
| 43 |
-
APP_VERSION = "
|
| 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.
|
| 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
|
| 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(
|
| 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(
|
| 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
|
| 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
|
| 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 |
-
"""
|
| 1625 |
-
from
|
| 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">
|
| 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>جواب
|
| 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">هُدى نت ·
|
| 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':'عرض بحثي استخراجي، وليس فتوى معتمدة. عرض «مفصل» يضيف فقط نقاطاً وجدت لها أدلة منفصلة، ولا يدّعي استيعاب جميع الأحكام.'}
|