fix: prevent cross-person contamination + fix merge scope matching

Critical fixes:
- Sibling matching now requires BOTH request_number AND search_scope
  (previously matched on request_number alone, mixing different people)
- If page 2 has no search_scope, inheritance is skipped (safe default)
- Inherit search_scope from sibling doc during confirm so merge validation works
- Normalize scope values on both sides when checking merge_allowed
- Merge was blocked because page 2 had empty search_scope, making same_scope always false
This commit is contained in:
Georges Haddad
2026-04-11 18:11:36 +03:00
parent 123492ae90
commit eb79b3781f
+51 -30
View File
@@ -71,7 +71,7 @@ def _get_document(doc_id: int) -> dict | None:
is_subsequent_page = False is_subsequent_page = False
sibling_doc = None sibling_doc = None
# Method 1: PDF group link # Method 1: PDF group link (reliable, unique grouping)
if doc.get("pdf_group_id") and (doc.get("page_number") or 0) > 1: if doc.get("pdf_group_id") and (doc.get("page_number") or 0) > 1:
is_subsequent_page = True is_subsequent_page = True
sibling_doc = conn.execute( sibling_doc = conn.execute(
@@ -80,27 +80,32 @@ def _get_document(doc_id: int) -> dict | None:
(doc["pdf_group_id"],), (doc["pdf_group_id"],),
).fetchone() ).fetchone()
# Method 2: Same request_number + page_info indicates page 2+ # 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: if not sibling_doc and not current_first:
page_info_str = doc.get("page_info") or "" page_info_str = doc.get("page_info") or ""
req_num = doc.get("request_number") 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): if req_num and _is_subsequent_page(page_info_str):
is_subsequent_page = True is_subsequent_page = True
# Find another document with same request_number that has person data if doc_scope:
sibling_doc = conn.execute( # Match by request_number + search_scope (safe)
"""SELECT * FROM documents
WHERE request_number=? AND id != ? AND person_id IS NOT NULL
ORDER BY id LIMIT 1""",
(req_num, doc["id"]),
).fetchone()
if not sibling_doc:
# Page 1 may not be confirmed yet; look for one with raw extraction
sibling_doc = conn.execute( sibling_doc = conn.execute(
"""SELECT * FROM documents """SELECT * FROM documents
WHERE request_number=? AND id != ? AND raw_extraction_json IS NOT NULL WHERE request_number=? AND search_scope=? AND id != ?
AND person_id IS NOT NULL
ORDER BY id LIMIT 1""", ORDER BY id LIMIT 1""",
(req_num, doc["id"]), (req_num, doc_scope, doc["id"]),
).fetchone() ).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: if sibling_doc and not current_first:
sibling_doc = dict(sibling_doc) sibling_doc = dict(sibling_doc)
@@ -203,14 +208,14 @@ def _get_merge_candidates(
if normalized_father and person.get("father_name"): if normalized_father and person.get("father_name"):
if normalize_arabic(person["father_name"]) != normalized_father: if normalize_arabic(person["father_name"]) != normalized_father:
continue continue
scope_values = [scope.strip() for scope in (person.get("search_scopes") or "").split(",") if scope.strip()] scope_values = [_normalize_scope(s) or s.strip() for s in (person.get("search_scopes") or "").split(",") if s.strip()]
same_scope = bool(normalized_scope and normalized_scope in scope_values) same_scope = bool(normalized_scope and normalized_scope in scope_values)
registry_match = bool( registry_match = bool(
normalized_registry normalized_registry
and person.get("registry_number") and person.get("registry_number")
and person["registry_number"].strip() == normalized_registry and person["registry_number"].strip() == normalized_registry
) )
person["search_scope_list"] = scope_values person["search_scope_list"] = [s.strip() for s in (person.get("search_scopes") or "").split(",") if s.strip()]
person["same_scope"] = same_scope person["same_scope"] = same_scope
person["registry_match"] = registry_match person["registry_match"] = registry_match
person["merge_allowed"] = same_scope or registry_match person["merge_allowed"] = same_scope or registry_match
@@ -346,7 +351,7 @@ async def confirm_document(doc_id: int, request: Request):
current_doc = dict(current_doc) current_doc = dict(current_doc)
# 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 + 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 = (
current_doc.get("pdf_group_id") current_doc.get("pdf_group_id")
@@ -366,23 +371,26 @@ async def confirm_document(doc_id: int, request: Request):
if page1_doc and page1_doc["person_id"]: if page1_doc and page1_doc["person_id"]:
page1_person_id = page1_doc["person_id"] page1_person_id = page1_doc["person_id"]
# Method 2: Same request_number + page_info indicates page 2+ # Method 2: Same request_number + search_scope + page_info indicates page 2+
if not page1_person_id and not first_name: if not page1_person_id and not first_name:
req_num = current_doc.get("request_number") or "" req_num = (current_doc.get("request_number") or "").strip()
page_info_val = current_doc.get("page_info") or "" 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): if req_num and _is_subsequent_page(page_info_val):
is_subsequent = True is_subsequent = True
sibling = conn.execute( if doc_scope:
"""SELECT d.person_id, p.first_name, p.father_name, p.family_name, sibling = conn.execute(
p.registry_number """SELECT d.person_id, p.first_name, p.father_name, p.family_name,
FROM documents d p.registry_number
LEFT JOIN persons p ON p.id = d.person_id FROM documents d
WHERE d.request_number=? AND d.id != ? AND d.person_id IS NOT NULL LEFT JOIN persons p ON p.id = d.person_id
ORDER BY d.id LIMIT 1""", WHERE d.request_number=? AND d.search_scope=? AND d.id != ?
(req_num, doc_id), AND d.person_id IS NOT NULL
).fetchone() ORDER BY d.id LIMIT 1""",
if sibling and sibling["person_id"]: (req_num, doc_scope, doc_id),
page1_person_id = sibling["person_id"] ).fetchone()
if sibling and 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
if page1_person_id and not first_name: if page1_person_id and not first_name:
@@ -399,6 +407,19 @@ async def confirm_document(doc_id: int, request: Request):
person_data[field] = sibling_person[field] person_data[field] = sibling_person[field]
registry_number = (person_data.get("registry_number") or "").strip() or None registry_number = (person_data.get("registry_number") or "").strip() or None
# For subsequent pages, also inherit search_scope from the sibling doc
# so that merge validation can work (it requires same_scope)
if page1_person_id and is_subsequent:
sibling_scope_row = conn.execute(
"SELECT search_scope FROM documents WHERE person_id=? AND search_scope IS NOT NULL AND TRIM(search_scope) != '' LIMIT 1",
(page1_person_id,),
).fetchone()
if sibling_scope_row:
_inherited_scope = (sibling_scope_row["search_scope"] or "").strip()
# Store for later use if body didn't provide search_scope
if not (body.get("search_scope") or "").strip() and _inherited_scope:
body["search_scope"] = _inherited_scope
if not first_name: if not first_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="First name is required") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="First name is required")
if current_doc["status"] == "pending": if current_doc["status"] == "pending":