Group search results by owner; auto-resume stuck pending documents

This commit is contained in:
Krikorios
2026-05-07 21:59:29 +03:00
parent 631a4c3e3d
commit 60eb58cc9f
6 changed files with 80 additions and 13 deletions
+18
View File
@@ -38,6 +38,24 @@ async def lifespan(app: FastAPI):
cleanup_expired_sessions() cleanup_expired_sessions()
except Exception: except Exception:
pass pass
# Resume any documents that were left in 'pending' state from a previous run
# (e.g. server restart killed the background extraction task).
try:
import asyncio
from database.connection import get_db
from services.extractor import get_default_provider
from routers.upload import _extract_and_save
default_provider = get_default_provider()
with get_db() as conn:
stuck = conn.execute(
"SELECT id, image_path, provider FROM documents WHERE status='pending'"
).fetchall()
for row in stuck:
provider = row["provider"] or default_provider
asyncio.create_task(_extract_and_save(row["id"], row["image_path"], provider))
except Exception:
pass
yield yield
+21
View File
@@ -233,6 +233,27 @@ async def retry_all_errors():
return JSONResponse({"ok": True, "retried": len(rows)}) return JSONResponse({"ok": True, "retried": len(rows)})
@router.post("/documents/resume-pending")
async def resume_pending():
"""Re-fire background extraction for any docs stuck in 'pending'.
Useful when a previous server restart killed in-flight extraction tasks."""
import asyncio
from services.extractor import get_default_provider
from routers.upload import _extract_and_save
default_provider = get_default_provider()
with get_db() as conn:
rows = conn.execute(
"SELECT id, image_path, provider FROM documents WHERE status='pending'"
).fetchall()
for row in rows:
provider = row["provider"] or default_provider
asyncio.create_task(_extract_and_save(row["id"], row["image_path"], provider))
return JSONResponse({"ok": True, "resumed": len(rows)})
@router.post("/documents/scan-duplicates") @router.post("/documents/scan-duplicates")
async def scan_duplicates(): async def scan_duplicates():
""" """
+2 -5
View File
@@ -31,12 +31,9 @@ async def search(
if property_number or district or block: if property_number or district or block:
properties = search_properties(property_number, district, block) properties = search_properties(property_number, district, block)
# If exactly one person found, preload their full details # If exactly one person found, preload their full details (across all scopes)
if len(persons) == 1 and not properties: if len(persons) == 1 and not properties:
selected_person = get_person_with_properties( selected_person = get_person_with_properties(persons[0]["id"], None)
persons[0]["id"],
persons[0].get("search_scope"),
)
return render_template( return render_template(
templates, templates,
+16 -7
View File
@@ -20,7 +20,7 @@ def _normalize_scope(text: str | None) -> str | None:
def search_persons(query: str) -> list[dict]: def search_persons(query: str) -> list[dict]:
"""Search persons by name, keeping separate result rows per search scope.""" """Search persons by name. One row per person, aggregating all their search scopes."""
norm = normalize_arabic(query.strip()) norm = normalize_arabic(query.strip())
pattern = f"%{norm}%" pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%" raw_pattern = f"%{query.strip()}%"
@@ -30,9 +30,9 @@ def search_persons(query: str) -> list[dict]:
rows = conn.execute( rows = conn.execute(
f""" f"""
SELECT p.*, SELECT p.*,
{scope_expr} AS search_scope,
COUNT(DISTINCT pr.id) AS property_count, COUNT(DISTINCT pr.id) AS property_count,
COUNT(DISTINCT d.id) AS document_count COUNT(DISTINCT d.id) AS document_count,
GROUP_CONCAT(DISTINCT {scope_expr}) AS search_scopes_raw
FROM persons p FROM persons p
LEFT JOIN documents d ON d.person_id = p.id LEFT JOIN documents d ON d.person_id = p.id
LEFT JOIN properties pr ON pr.document_id = d.id LEFT JOIN properties pr ON pr.document_id = d.id
@@ -41,14 +41,13 @@ def search_persons(query: str) -> list[dict]:
OR p.father_name LIKE ? OR p.father_name LIKE ?
OR p.first_name LIKE ? OR p.first_name LIKE ?
OR p.family_name LIKE ? OR p.family_name LIKE ?
GROUP BY p.id, {scope_expr} GROUP BY p.id
ORDER BY ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0 CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0 WHEN p.family_name_norm = ? THEN 0
ELSE 1 END, ELSE 1 END,
p.first_name, p.first_name,
p.family_name, p.family_name
search_scope
""", """,
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm), (pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall() ).fetchall()
@@ -56,7 +55,17 @@ def search_persons(query: str) -> list[dict]:
results = [] results = []
for row in rows: for row in rows:
person = dict(row) person = dict(row)
person["search_scope"] = _normalize_scope(person.get("search_scope")) raw = person.pop("search_scopes_raw", None) or ""
scopes = [s.strip() for s in raw.split(",") if s and s.strip()]
# Deduplicate while preserving order
seen = set()
unique_scopes = []
for s in scopes:
if s not in seen:
seen.add(s)
unique_scopes.append(s)
person["search_scopes"] = unique_scopes
person["search_scope"] = "، ".join(unique_scopes) if unique_scopes else None
results.append(person) results.append(person)
return results return results
+22
View File
@@ -58,6 +58,12 @@
</div> </div>
{% endif %} {% endif %}
{% if stats.processing %}
<div class="alert-banner">
<button class="btn btn-sm btn-primary" onclick="resumePending(this)">استئناف معالجة الوثائق المعلّقة ({{ stats.processing }})</button>
</div>
{% endif %}
{% if stats.pending_review %} {% if stats.pending_review %}
<div class="alert-banner"> <div class="alert-banner">
<a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة →</a> <a href="/review/next">ابدأ مراجعة {{ stats.pending_review }} وثيقة →</a>
@@ -182,6 +188,22 @@ async function retryAllErrors(btn) {
} }
} }
async function resumePending(btn) {
btn.disabled = true;
const original = btn.textContent;
btn.textContent = 'جارٍ الاستئناف...';
const res = await fetch('/documents/resume-pending', { method: 'POST' });
if (res.ok) {
const data = await res.json();
alert(`تم استئناف ${data.resumed} وثيقة. قد تستغرق المعالجة بضع لحظات.`);
location.reload();
} else {
alert('فشل الاستئناف.');
btn.disabled = false;
btn.textContent = original;
}
}
async function scanDuplicates(btn) { async function scanDuplicates(btn) {
btn.disabled = true; btn.disabled = true;
const original = btn.textContent; const original = btn.textContent;
+1 -1
View File
@@ -144,7 +144,7 @@
{% else %} {% else %}
<div class="persons-grid"> <div class="persons-grid">
{% for p in persons %} {% for p in persons %}
<a href="/persons/{{ p.id }}{% if p.search_scope %}?search_scope={{ p.search_scope|urlencode }}{% endif %}" class="person-card"> <a href="/persons/{{ p.id }}" class="person-card">
<div class="person-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div> <div class="person-name">{{ p.first_name }} {{ p.father_name or '' }} {{ p.family_name or '' }}</div>
<div class="person-meta"> <div class="person-meta">
{% if p.family_origin %}{{ p.family_origin }} · {% endif %} {% if p.family_origin %}{{ p.family_origin }} · {% endif %}