Fix extraction for companies and religious entities

This commit is contained in:
Georges Haddad
2026-04-15 10:07:57 +03:00
parent 85b5b21106
commit 88f047a654
4 changed files with 506 additions and 98 deletions
+150 -85
View File
@@ -8,7 +8,12 @@ from fastapi.templating import Jinja2Templates
from config import UPLOAD_DIR from config import UPLOAD_DIR
from database.connection import get_db from database.connection import get_db
from services.extractor import extract_document from services.extractor import (
extract_document,
get_available_providers,
get_default_provider,
verify_page_correlation,
)
from services.search_service import normalize_arabic, _normalize_scope from services.search_service import normalize_arabic, _normalize_scope
router = APIRouter() router = APIRouter()
@@ -36,6 +41,60 @@ def _is_subsequent_page(page_info: str) -> bool:
return False return False
def _find_page1_candidate(conn, doc: dict) -> tuple[bool, dict | None]:
"""Return whether the doc looks like a later page and the best page-1 candidate."""
is_subsequent_page = False
sibling_doc = None
if doc.get("pdf_group_id") and (doc.get("page_number") or 0) > 1:
is_subsequent_page = True
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE pdf_group_id=? AND page_number=1""",
(doc["pdf_group_id"],),
).fetchone()
if not sibling_doc:
page_info_str = doc.get("page_info") or ""
req_num = (doc.get("request_number") or "").strip()
doc_scope = (doc.get("search_scope") or "").strip()
if req_num and _is_subsequent_page(page_info_str):
is_subsequent_page = True
if doc_scope:
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE request_number=? AND search_scope=? AND id != ?
ORDER BY CASE WHEN page_number=1 THEN 0 ELSE 1 END,
CASE WHEN person_id IS NOT NULL THEN 0 ELSE 1 END,
id
LIMIT 1""",
(req_num, doc_scope, doc["id"]),
).fetchone()
return is_subsequent_page, dict(sibling_doc) if sibling_doc else None
def _build_correlation_context(doc: dict) -> dict:
person = doc.get("person") or {}
return {
"document_id": doc.get("id"),
"request_number": doc.get("request_number"),
"request_date": doc.get("request_date"),
"page_info": doc.get("page_info"),
"page_number": doc.get("page_number"),
"search_scope": doc.get("search_scope"),
"applicant_name_raw": doc.get("applicant_name_raw"),
"person": {
"first_name": person.get("first_name"),
"father_name": person.get("father_name"),
"mother_name": person.get("mother_name"),
"family_name": person.get("family_name"),
"registry_number": person.get("registry_number"),
"registry_place": person.get("registry_place"),
},
}
def _get_document(doc_id: int) -> dict | None: def _get_document(doc_id: int) -> dict | None:
with get_db() as conn: with get_db() as conn:
row = conn.execute("SELECT * FROM documents WHERE id=?", (doc_id,)).fetchone() row = conn.execute("SELECT * FROM documents WHERE id=?", (doc_id,)).fetchone()
@@ -66,58 +125,24 @@ def _get_document(doc_id: int) -> dict | None:
# Works for both PDF splits (pdf_group_id) and individually uploaded images (via request_number + page_info). # Works for both PDF splits (pdf_group_id) and individually uploaded images (via request_number + page_info).
doc["inherited_from_page1"] = False doc["inherited_from_page1"] = False
doc["page1_person_id"] = None doc["page1_person_id"] = None
doc["page1_doc_id"] = None
current_first = (doc["person"].get("first_name") or "").strip() current_first = (doc["person"].get("first_name") or "").strip()
is_subsequent_page = False is_subsequent_page, sibling_doc = _find_page1_candidate(conn, doc)
sibling_doc = None
# Method 1: PDF group link (reliable, unique grouping) if sibling_doc:
if doc.get("pdf_group_id") and (doc.get("page_number") or 0) > 1: doc["page1_doc_id"] = sibling_doc.get("id")
is_subsequent_page = True
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE pdf_group_id=? AND page_number=1""",
(doc["pdf_group_id"],),
).fetchone()
# Method 2: Same request_number + search_scope + page_info indicates page 2+
# MUST match on BOTH request_number AND search_scope to avoid cross-person contamination
if not sibling_doc and not current_first:
page_info_str = doc.get("page_info") or ""
req_num = (doc.get("request_number") or "").strip()
doc_scope = (doc.get("search_scope") or "").strip()
if req_num and _is_subsequent_page(page_info_str):
is_subsequent_page = True
if doc_scope:
# Match by request_number + search_scope (safe)
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE request_number=? AND search_scope=? AND id != ?
AND person_id IS NOT NULL
ORDER BY id LIMIT 1""",
(req_num, doc_scope, doc["id"]),
).fetchone()
if not sibling_doc:
sibling_doc = conn.execute(
"""SELECT * FROM documents
WHERE request_number=? AND search_scope=? AND id != ?
AND raw_extraction_json IS NOT NULL
ORDER BY id LIMIT 1""",
(req_num, doc_scope, doc["id"]),
).fetchone()
# If no search_scope on this doc, DON'T guess — leave it for manual entry
if sibling_doc and not current_first:
sibling_doc = dict(sibling_doc)
if sibling_doc.get("person_id"): if sibling_doc.get("person_id"):
doc["page1_person_id"] = sibling_doc["person_id"]
if sibling_doc.get("person_id") and not current_first:
person = conn.execute( person = conn.execute(
"SELECT * FROM persons WHERE id=?", (sibling_doc["person_id"],) "SELECT * FROM persons WHERE id=?", (sibling_doc["person_id"],)
).fetchone() ).fetchone()
if person: if person:
doc["person"] = dict(person) doc["person"] = dict(person)
doc["inherited_from_page1"] = True doc["inherited_from_page1"] = True
doc["page1_person_id"] = sibling_doc["person_id"] elif sibling_doc.get("raw_extraction_json") and not current_first:
elif sibling_doc.get("raw_extraction_json"):
try: try:
sib_extracted = json.loads(sibling_doc["raw_extraction_json"]) sib_extracted = json.loads(sibling_doc["raw_extraction_json"])
sib_person = sib_extracted.get("person", {}) sib_person = sib_extracted.get("person", {})
@@ -282,7 +307,64 @@ async def review_document(request: Request, doc_id: int, wait: int = 0):
) )
return templates.TemplateResponse( return templates.TemplateResponse(
request, "review.html", {"doc": doc, "upload_dir": "/uploads"} request,
"review.html",
{
"doc": doc,
"upload_dir": "/uploads",
"providers": get_available_providers(),
"current_provider": doc.get("provider") or get_default_provider(),
"ai_verification_available": any(
provider["id"] in {"claude", "gemini"} for provider in get_available_providers()
),
},
)
@router.post("/review/{doc_id}/verify-correlation")
async def review_verify_correlation(doc_id: int):
with get_db() as conn:
row = conn.execute("SELECT * FROM documents WHERE id=?", (doc_id,)).fetchone()
if not row:
return JSONResponse({"error": "Document not found"}, status_code=404)
current_doc = _get_document(doc_id)
if not current_doc:
return JSONResponse({"error": "Document not found"}, status_code=404)
is_subsequent_page, sibling_doc = _find_page1_candidate(conn, dict(row))
if not is_subsequent_page:
return JSONResponse(
{"error": "This document is not identified as page 2 or later."},
status_code=400,
)
if not sibling_doc:
return JSONResponse(
{"error": "No page 1 candidate was found for AI verification."},
status_code=404,
)
candidate_doc = _get_document(sibling_doc["id"])
if not candidate_doc:
return JSONResponse({"error": "Candidate page not found"}, status_code=404)
try:
result = await verify_page_correlation(
current_doc["image_path"],
candidate_doc["image_path"],
_build_correlation_context(current_doc),
_build_correlation_context(candidate_doc),
)
except Exception as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return JSONResponse(
{
"ok": True,
"current_doc_id": current_doc["id"],
"candidate_doc_id": candidate_doc["id"],
**result,
}
) )
@@ -353,43 +435,8 @@ async def confirm_document(doc_id: int, request: Request):
# For multi-page documents (page 2+), resolve sibling document's person # For multi-page documents (page 2+), resolve sibling document's person
# Works via pdf_group_id OR request_number+search_scope + page_info # Works via pdf_group_id OR request_number+search_scope + page_info
page1_person_id = None page1_person_id = None
is_subsequent = ( is_subsequent, sibling = _find_page1_candidate(conn, current_doc)
current_doc.get("pdf_group_id") if sibling and sibling.get("person_id"):
and (current_doc.get("page_number") or 0) > 1
)
# Method 1: PDF group link
if is_subsequent:
page1_doc = conn.execute(
"""SELECT d.person_id, p.first_name, p.father_name, p.family_name,
p.registry_number
FROM documents d
LEFT JOIN persons p ON p.id = d.person_id
WHERE d.pdf_group_id=? AND d.page_number=1 AND d.person_id IS NOT NULL""",
(current_doc["pdf_group_id"],),
).fetchone()
if page1_doc and page1_doc["person_id"]:
page1_person_id = page1_doc["person_id"]
# Method 2: Same request_number + search_scope + page_info indicates page 2+
if not page1_person_id and not first_name:
req_num = (current_doc.get("request_number") or "").strip()
page_info_val = current_doc.get("page_info") or ""
doc_scope = (current_doc.get("search_scope") or "").strip()
if req_num and _is_subsequent_page(page_info_val):
is_subsequent = True
if doc_scope:
sibling = conn.execute(
"""SELECT d.person_id, p.first_name, p.father_name, p.family_name,
p.registry_number
FROM documents d
LEFT JOIN persons p ON p.id = d.person_id
WHERE d.request_number=? AND d.search_scope=? AND d.id != ?
AND d.person_id IS NOT NULL
ORDER BY d.id LIMIT 1""",
(req_num, doc_scope, doc_id),
).fetchone()
if sibling and sibling["person_id"]:
page1_person_id = sibling["person_id"] page1_person_id = sibling["person_id"]
# Inherit person fields from sibling if not provided # Inherit person fields from sibling if not provided
@@ -658,7 +705,7 @@ async def confirm_document(doc_id: int, request: Request):
@router.post("/extract/{doc_id}") @router.post("/extract/{doc_id}")
async def retrigger_extraction(doc_id: int, provider: str = ""): async def retrigger_extraction(doc_id: int, provider: str = ""):
"""Re-run extraction for a document (retry after error).""" """Re-run extraction for a document and reset the extracted state first."""
with get_db() as conn: with get_db() as conn:
doc = conn.execute( doc = conn.execute(
"SELECT image_path, provider FROM documents WHERE id=?", (doc_id,) "SELECT image_path, provider FROM documents WHERE id=?", (doc_id,)
@@ -666,14 +713,32 @@ async def retrigger_extraction(doc_id: int, provider: str = ""):
if not doc: if not doc:
return JSONResponse({"error": "not found"}, status_code=404) return JSONResponse({"error": "not found"}, status_code=404)
use_provider = provider or doc["provider"] or get_default_provider()
with get_db() as conn: with get_db() as conn:
conn.execute( conn.execute(
"UPDATE documents SET status='pending', extraction_error=NULL WHERE id=?", """UPDATE documents
(doc_id,), SET status='pending',
person_id=NULL,
request_number=NULL,
request_date=NULL,
applicant_name_raw=NULL,
request_purpose=NULL,
data_valid_until=NULL,
registry_office=NULL,
owns_properties=NULL,
declared_property_count=NULL,
page_info=NULL,
search_scope=NULL,
raw_extraction_json=NULL,
extraction_error=NULL,
provider=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(use_provider, doc_id),
) )
conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,)) conn.execute("DELETE FROM properties WHERE document_id=?", (doc_id,))
from routers.upload import _extract_and_save from routers.upload import _extract_and_save
use_provider = provider or doc["provider"] or ""
asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider)) asyncio.create_task(_extract_and_save(doc_id, doc["image_path"], use_provider))
return JSONResponse({"ok": True, "message": "Extraction started"}) return JSONResponse({"ok": True, "message": "Extraction started"})
+147 -3
View File
@@ -28,18 +28,19 @@ CRITICAL INSTRUCTIONS:
- معلومات محولة لغاية (data_valid_until) found near the bottom. - معلومات محولة لغاية (data_valid_until) found near the bottom.
- أمانة السجل (registry_office) found at the bottom right. - أمانة السجل (registry_office) found at the bottom right.
- عدد العقارات: (declared_property_count) located right under the properties table. - عدد العقارات: (declared_property_count) located right under the properties table.
7. The properties table has 8 columns in order from RIGHT to LEFT as they appear on the page: 7. If the property owner is a company, religious entity, or organization (e.g., شركة, وقف, مطرانية, جمعية) rather than a natural person, extract its full name into the `first_name` field and leave `father_name`, `mother_name`, `family_name`, etc. as null.
8. The properties table has 8 columns in order from RIGHT to LEFT as they appear on the page:
col1(rightmost)=اسم الفريق, col2=رقم العقار, col3=القسم, col4=البلوك, col1(rightmost)=اسم الفريق, col2=رقم العقار, col3=القسم, col4=البلوك,
col5=المنطقة العقارية, col6=القضاء, col7=عدد الأسهم, col8(leftmost)=نوع الملكية col5=المنطقة العقارية, col6=القضاء, col7=عدد الأسهم, col8(leftmost)=نوع الملكية
Map these to JSON keys: party_name, property_number, section, block, real_estate_district, qaza, num_shares, ownership_type Map these to JSON keys: party_name, property_number, section, block, real_estate_district, qaza, num_shares, ownership_type
8. Return ONLY valid JSON matching the schema. No markdown, no explanation.""" 9. Return ONLY valid JSON matching the schema. No markdown, no explanation."""
USER_PROMPT = ( USER_PROMPT = (
"Extract all data from this Lebanese real estate property card. " "Extract all data from this Lebanese real estate property card. "
"Return a single JSON object with these keys: " "Return a single JSON object with these keys: "
"request_number, request_date, applicant_name_raw, request_purpose, data_valid_until, " "request_number, request_date, applicant_name_raw, request_purpose, data_valid_until, "
"registry_office, page_info, search_scope, owns_properties, declared_property_count, " "registry_office, page_info, search_scope, owns_properties, declared_property_count, "
"person (object with: first_name, father_name, mother_name, family_name, " "person (object with: first_name (or company/entity name), father_name, mother_name, family_name, "
"family_origin, nationality, birth_date, registry_number, registry_place), " "family_origin, nationality, birth_date, registry_number, registry_place), "
"properties (array of objects with: party_name, property_number, section, block, " "properties (array of objects with: party_name, property_number, section, block, "
"real_estate_district, qaza, num_shares, ownership_type), " "real_estate_district, qaza, num_shares, ownership_type), "
@@ -47,6 +48,149 @@ USER_PROMPT = (
"Use null for missing fields. Include every property row from the table (or empty array if none)." "Use null for missing fields. Include every property row from the table (or empty array if none)."
) )
CORRELATION_SYSTEM_PROMPT = """You are verifying whether two scanned Lebanese real estate document pages belong to the same multi-page request and the same person.
Rules:
1. Compare both page images and the extracted metadata together.
2. Strong signals: matching request number, matching search scope, matching page numbering in the same sequence, matching applicant/person names, matching footer/header identifiers, and obvious continuation of the same document layout.
3. Weak OCR differences are common in Arabic letters such as س and ن, ب and ت, or Arabic-Indic digits. Do not reject a match solely because of one likely OCR confusion.
4. Reject when there are clear contradictions in request number, search scope, person identity, or unrelated page numbering.
5. Return JSON only.
"""
def _get_ai_verification_provider(preferred_provider: str = "") -> str:
"""Return a provider capable of vision reasoning for verification."""
providers = [p["id"] for p in get_available_providers() if p["id"] in {"claude", "gemini"}]
if preferred_provider in providers:
return preferred_provider
if DEFAULT_PROVIDER in providers:
return DEFAULT_PROVIDER
if providers:
return providers[0]
raise ValueError("No AI verification provider configured")
def _build_correlation_user_prompt(current_context: dict, candidate_context: dict) -> str:
return (
"Determine whether PAGE_A and PAGE_B belong to the same multi-page request/document for the same person. "
"Treat minor OCR mistakes as possible noise. Return one JSON object with keys: "
"same_document (boolean), confidence ('high'|'medium'|'low'), verdict_ar (short Arabic sentence), "
"reasons_ar (array of short Arabic bullet strings), mismatch_flags (array of short Arabic strings), "
"recommended_action ('auto-link'|'manual-review').\n\n"
f"PAGE_A_CONTEXT={json.dumps(current_context, ensure_ascii=False)}\n"
f"PAGE_B_CONTEXT={json.dumps(candidate_context, ensure_ascii=False)}"
)
async def verify_page_correlation(
current_image_path: str,
candidate_image_path: str,
current_context: dict,
candidate_context: dict,
provider: str = "",
) -> dict:
"""Ask a vision model if two pages belong to the same multi-page request."""
provider = _get_ai_verification_provider(provider)
if provider == "claude":
import anthropic
client = anthropic.AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
current_full_path = _resolve_path(current_image_path)
candidate_full_path = _resolve_path(candidate_image_path)
current_img_data, current_media_type = _encode_image(current_full_path)
candidate_img_data, candidate_media_type = _encode_image(candidate_full_path)
message = await client.messages.create(
model=CLAUDE_MODEL,
max_tokens=1200,
system=[{"type": "text", "text": CORRELATION_SYSTEM_PROMPT}],
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": _build_correlation_user_prompt(current_context, candidate_context)},
{
"type": "image",
"source": {
"type": "base64",
"media_type": current_media_type,
"data": current_img_data,
},
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": candidate_media_type,
"data": candidate_img_data,
},
},
],
}
],
)
raw_text = _strip_code_fences(message.content[0].text)
result = json.loads(raw_text)
elif provider == "gemini":
import asyncio
from google import genai
from google.genai import types
client = genai.Client(api_key=GEMINI_API_KEY)
current_full_path = _resolve_path(current_image_path)
candidate_full_path = _resolve_path(candidate_image_path)
with open(current_full_path, "rb") as f:
current_bytes = f.read()
with open(candidate_full_path, "rb") as f:
candidate_bytes = f.read()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
current_mime = mime_map.get(Path(current_full_path).suffix.lower(), "image/jpeg")
candidate_mime = mime_map.get(Path(candidate_full_path).suffix.lower(), "image/jpeg")
def _call():
response = client.models.generate_content(
model=GEMINI_MODEL,
contents=[
types.Content(
parts=[
types.Part(text=CORRELATION_SYSTEM_PROMPT + "\n\n" + _build_correlation_user_prompt(current_context, candidate_context)),
types.Part(inline_data=types.Blob(mime_type=current_mime, data=current_bytes)),
types.Part(inline_data=types.Blob(mime_type=candidate_mime, data=candidate_bytes)),
]
)
],
config=types.GenerateContentConfig(
temperature=0.1,
max_output_tokens=1600,
),
)
return response.text
raw_text = await asyncio.get_event_loop().run_in_executor(None, _call)
raw_text = _strip_code_fences(raw_text)
result = json.loads(raw_text)
else:
raise ValueError(f"Provider {provider} does not support AI correlation verification")
return {
"provider": provider,
"same_document": bool(result.get("same_document")),
"confidence": result.get("confidence") or "medium",
"verdict_ar": result.get("verdict_ar") or "تعذر توليد خلاصة واضحة.",
"reasons_ar": result.get("reasons_ar") or [],
"mismatch_flags": result.get("mismatch_flags") or [],
"recommended_action": result.get("recommended_action") or "manual-review",
}
def _resolve_path(image_path: str) -> str: def _resolve_path(image_path: str) -> str:
if Path(image_path).is_absolute(): if Path(image_path).is_absolute():
return image_path return image_path
+104
View File
@@ -75,6 +75,87 @@
line-height: 1.35; line-height: 1.35;
} }
.page-group-banner {
align-items: flex-start;
}
.page-group-copy {
display: flex;
flex: 1;
gap: .9rem;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
}
.page-group-actions {
margin-inline-start: auto;
}
.rescan-banner {
justify-content: space-between;
flex-wrap: wrap;
}
.rescan-copy {
display: inline-block;
margin-inline-start: .35rem;
}
.rescan-controls {
display: flex;
align-items: center;
gap: .65rem;
margin-inline-start: auto;
}
.rescan-controls select {
min-width: 220px;
padding: .62rem .8rem;
border: 1px solid rgba(59, 130, 246, .28);
border-radius: 12px;
background: rgba(255,255,255,.96);
color: var(--text);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.correlation-result {
margin-bottom: 1rem;
}
.correlation-heading {
display: flex;
justify-content: space-between;
gap: .75rem;
align-items: baseline;
flex-wrap: wrap;
}
.correlation-meta {
font-size: .82rem;
opacity: .9;
}
.correlation-list {
margin: .6rem 0 0;
padding-inline-start: 1.25rem;
}
.correlation-warnings {
margin-top: .4rem;
}
/* ============================ /* ============================
Review — Responsive Review — Responsive
============================ */ ============================ */
@@ -95,6 +176,18 @@
.review-nav .btn { .review-nav .btn {
flex: 1 1 calc(50% - .5rem); flex: 1 1 calc(50% - .5rem);
} }
.page-group-actions {
width: 100%;
margin-inline-start: 0;
}
.rescan-controls {
width: 100%;
margin-inline-start: 0;
}
.rescan-controls select,
.rescan-controls .btn {
flex: 1 1 0;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -111,6 +204,17 @@
.review-nav .btn { .review-nav .btn {
flex-basis: 100%; flex-basis: 100%;
} }
.page-group-actions .btn {
width: 100%;
}
.rescan-controls {
flex-direction: column;
align-items: stretch;
}
.rescan-controls select {
min-width: 0;
width: 100%;
}
.form-section { .form-section {
padding: 1rem; padding: 1rem;
} }
+98 -3
View File
@@ -24,6 +24,28 @@
</div> </div>
</div> </div>
{% if doc.status in ['extracted', 'confirmed', 'error'] %}
<div class="info-banner rescan-banner">
<div>
<strong>إعادة المسح</strong>
<span class="rescan-copy">إذا خلط الذكاء الاصطناعي بين أحرف مثل س و ن، يمكنك إعادة القراءة بنفس المحرك أو بمحرك آخر.</span>
</div>
<div class="rescan-controls">
<label for="providerSelect" class="sr-only">محرك الاستخراج</label>
<select id="providerSelect" title="محرك الاستخراج">
{% for provider in providers %}
<option value="{{ provider.id }}" {{ 'selected' if provider.id == current_provider else '' }}>
{{ provider.name }}
</option>
{% endfor %}
</select>
<button type="button" class="btn btn-warning btn-sm" id="rescanBtn" onclick="retriggerExtraction({{ doc.id }})">
إعادة المسح
</button>
</div>
</div>
{% endif %}
{% if doc.status == 'error' %} {% if doc.status == 'error' %}
<div class="error-banner"> <div class="error-banner">
⚠ خطأ في الاستخراج: {{ doc.extraction_error }} ⚠ خطأ في الاستخراج: {{ doc.extraction_error }}
@@ -57,6 +79,8 @@
{% if doc.get('is_subsequent_page') %} {% if doc.get('is_subsequent_page') %}
<div class="info-banner page-group-banner"> <div class="info-banner page-group-banner">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
<div class="page-group-copy">
<div>
هذه صفحة تكميلية ({{ doc.get('page_info') or doc.get('page_number') or '' }}) من مستند متعدد الصفحات. هذه صفحة تكميلية ({{ doc.get('page_info') or doc.get('page_number') or '' }}) من مستند متعدد الصفحات.
{% if doc.get('inherited_from_page1') %} {% if doc.get('inherited_from_page1') %}
بيانات الشخص مستوردة تلقائياً من الصفحة الأولى. بيانات الشخص مستوردة تلقائياً من الصفحة الأولى.
@@ -64,6 +88,16 @@
يرجى التأكد من بيانات الشخص يدوياً — لم يتم العثور على صفحة أولى مؤكدة بعد. يرجى التأكد من بيانات الشخص يدوياً — لم يتم العثور على صفحة أولى مؤكدة بعد.
{% endif %} {% endif %}
</div> </div>
{% if doc.get('page1_doc_id') and ai_verification_available %}
<div class="page-group-actions">
<button type="button" class="btn btn-secondary btn-sm" id="verifyCorrelationBtn" onclick="verifyCorrelation({{ doc.id }})">
تحقق بالذكاء الاصطناعي
</button>
</div>
{% endif %}
</div>
</div>
<div id="correlationResult" class="hidden"></div>
{% endif %} {% endif %}
<!-- Person Info --> <!-- Person Info -->
@@ -418,9 +452,70 @@ async function confirmDocument() {
} }
async function retriggerExtraction(docId) { async function retriggerExtraction(docId) {
if (!confirm('إعادة استخراج البيانات بالذكاء الاصطناعي؟')) return; const provider = document.getElementById('providerSelect')?.value || '';
await fetch(`/extract/${docId}`, { method: 'POST' }); const btn = document.getElementById('rescanBtn');
location.reload(); const providerLabel = document.getElementById('providerSelect')?.selectedOptions?.[0]?.text || 'المحرك الحالي';
if (!confirm(`إعادة استخراج البيانات باستخدام ${providerLabel}؟ سيتم استبدال النتائج الحالية.`)) return;
if (btn) {
btn.disabled = true;
btn.textContent = 'جارٍ إعادة المسح...';
}
try {
const params = new URLSearchParams();
if (provider) params.set('provider', provider);
const res = await fetch(`/extract/${docId}?${params.toString()}`, { method: 'POST' });
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || data.detail || 'تعذر بدء إعادة المسح.');
}
window.location.href = `/review/${docId}?wait=1`;
} catch (e) {
alert('حدث خطأ أثناء إعادة المسح: ' + e.message);
if (btn) {
btn.disabled = false;
btn.textContent = 'إعادة المسح';
}
}
}
async function verifyCorrelation(docId) {
const btn = document.getElementById('verifyCorrelationBtn');
const resultEl = document.getElementById('correlationResult');
if (!btn || !resultEl) return;
btn.disabled = true;
btn.textContent = 'جارٍ التحقق...';
resultEl.className = 'info-banner correlation-result';
resultEl.innerHTML = 'جارٍ فحص الترابط بين الصفحة الحالية والصفحة الأولى...';
try {
const res = await fetch(`/review/${docId}/verify-correlation`, { method: 'POST' });
const data = await res.json();
if (!res.ok || !data.ok) {
throw new Error(data.error || 'تعذر التحقق من الترابط.');
}
const statusClass = data.same_document ? 'success-banner' : 'error-banner';
const reasons = (data.reasons_ar || []).map(item => `<li>${item}</li>`).join('');
const mismatches = (data.mismatch_flags || []).map(item => `<li>${item}</li>`).join('');
resultEl.className = `${statusClass} correlation-result`;
resultEl.innerHTML = `
<div class="correlation-heading">
<strong>${data.verdict_ar}</strong>
<span class="correlation-meta">الثقة: ${data.confidence} | المحرك: ${data.provider}</span>
</div>
${reasons ? `<ul class="correlation-list">${reasons}</ul>` : ''}
${mismatches ? `<ul class="correlation-list correlation-warnings">${mismatches}</ul>` : ''}
`;
} catch (e) {
resultEl.className = 'error-banner correlation-result';
resultEl.textContent = 'فشل التحقق: ' + e.message;
} finally {
btn.disabled = false;
btn.textContent = 'تحقق بالذكاء الاصطناعي';
}
} }
async function deleteDoc(docId) { async function deleteDoc(docId) {