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()
except Exception:
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
+21
View File
@@ -233,6 +233,27 @@ async def retry_all_errors():
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")
async def scan_duplicates():
"""
+2 -5
View File
@@ -31,12 +31,9 @@ async def search(
if property_number or district or 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:
selected_person = get_person_with_properties(
persons[0]["id"],
persons[0].get("search_scope"),
)
selected_person = get_person_with_properties(persons[0]["id"], None)
return render_template(
templates,
+16 -7
View File
@@ -20,7 +20,7 @@ def _normalize_scope(text: str | None) -> str | None:
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())
pattern = f"%{norm}%"
raw_pattern = f"%{query.strip()}%"
@@ -30,9 +30,9 @@ def search_persons(query: str) -> list[dict]:
rows = conn.execute(
f"""
SELECT p.*,
{scope_expr} AS search_scope,
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
LEFT JOIN documents d ON d.person_id = p.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.first_name LIKE ?
OR p.family_name LIKE ?
GROUP BY p.id, {scope_expr}
GROUP BY p.id
ORDER BY
CASE WHEN p.first_name_norm = ? THEN 0
WHEN p.family_name_norm = ? THEN 0
ELSE 1 END,
p.first_name,
p.family_name,
search_scope
p.family_name
""",
(pattern, pattern, raw_pattern, raw_pattern, raw_pattern, norm, norm),
).fetchall()
@@ -56,7 +55,17 @@ def search_persons(query: str) -> list[dict]:
results = []
for row in rows:
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)
return results
+22
View File
@@ -58,6 +58,12 @@
</div>
{% 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 %}
<div class="alert-banner">
<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) {
btn.disabled = true;
const original = btn.textContent;
+1 -1
View File
@@ -144,7 +144,7 @@
{% else %}
<div class="persons-grid">
{% 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-meta">
{% if p.family_origin %}{{ p.family_origin }} · {% endif %}