-
Notifications
You must be signed in to change notification settings - Fork 0
cross-document validation Module #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
1fae7b9
cross-document validation Module
Ankita-Advitot 0e61f28
fix: github comments resolved
Ankita-Advitot 7fdfce3
Merged main branch
Ankita-Advitot c86035c
fix: removed duplicate DB setup code
Ankita-Advitot 50ab5e1
fix: solved github comments
Ankita-Advitot 2505a61
docs: added 2 docs for API and implementation doc
Ankita-Advitot e97f185
fix: case.py added specific attribute and fixed attributeError
Ankita-Advitot a96bd3c
fix: enforce MIN_OVERLAPPING_DIGITS for masked Aadhaar and redact PAN…
Selectus2 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| from fastapi import APIRouter, Depends, HTTPException | ||
| from sqlalchemy.ext.asyncio import AsyncSession | ||
|
|
||
| from app.database import get_db | ||
| from app.schemas.case import CaseCreateRequest, CaseCreateResponse, ValidationResultOut | ||
| from app.services.case_parsing import parse_case | ||
| from app.services.persistence import save_pipeline_result | ||
| from app.services.pipeline import run_pipeline | ||
| from app.utils.json_safe import json_safe | ||
|
|
||
| router = APIRouter(tags=["cases"]) | ||
|
|
||
|
|
||
| @router.post("/cases", response_model=CaseCreateResponse) | ||
| async def create_case( | ||
| request: CaseCreateRequest, db: AsyncSession = Depends(get_db) | ||
| ) -> CaseCreateResponse: | ||
| try: | ||
| case_input = parse_case(request.model_dump()) | ||
| except ValueError as exc: | ||
| raise HTTPException(status_code=400, detail=str(exc)) from exc | ||
| pipeline_result = run_pipeline(case_input) | ||
| case = await save_pipeline_result(db, case_input, pipeline_result) | ||
|
|
||
| return CaseCreateResponse( | ||
| case_id=str(case.id), | ||
| applicant_ref=case_input.applicant_ref, | ||
| decision=pipeline_result.decision_result.decision.value, | ||
| overall_score=pipeline_result.decision_result.overall_score, | ||
| reasons=pipeline_result.decision_result.reasons, | ||
| validation_results=[ | ||
| ValidationResultOut( | ||
| check_type=r.check_type.value, | ||
| passed=r.passed, | ||
| score=r.score, | ||
| document_id=r.document_id, | ||
| evidence=json_safe(r.evidence) if r.evidence else None, | ||
| ) | ||
| for r in pipeline_result.validation_results | ||
| ], | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| """Address similarity via embeddings. | ||
|
|
||
| Embeddings come from BAAI/bge-small-en-v1.5 (sentence-transformers), | ||
| running locally on CPU — no API key or network call per request. The | ||
| model is loaded once per process (module-level singleton) since load time | ||
| is the expensive part; encoding individual addresses is fast. | ||
| """ | ||
|
|
||
| import math | ||
| import re | ||
| from functools import lru_cache | ||
|
|
||
| EMBEDDING_MODEL_NAME = "BAAI/bge-small-en-v1.5" | ||
| EMBEDDING_DIMENSIONS = 384 | ||
|
|
||
| # bge models are trained to prepend this instruction for retrieval queries. | ||
| _QUERY_PREFIX = "represent this sentence for searching relevant passages: " | ||
|
|
||
|
|
||
| @lru_cache(maxsize=1) | ||
| def _get_model(): | ||
| from sentence_transformers import SentenceTransformer | ||
|
|
||
| return SentenceTransformer(EMBEDDING_MODEL_NAME) | ||
|
|
||
|
|
||
| def _normalize_address(address: str) -> str: | ||
| text = address.lower() | ||
| text = re.sub(r"[^a-z0-9\s]", " ", text) | ||
| return re.sub(r"\s+", " ", text).strip() | ||
|
|
||
|
|
||
| def get_address_embedding(address: str) -> list[float]: | ||
| normalized = _normalize_address(address) | ||
| if not normalized: | ||
| return [0.0] * EMBEDDING_DIMENSIONS | ||
|
|
||
| model = _get_model() | ||
| vector = model.encode(_QUERY_PREFIX + normalized, normalize_embeddings=True) | ||
| return vector.tolist() | ||
|
|
||
|
|
||
| def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float: | ||
| if not vec_a or not vec_b or len(vec_a) != len(vec_b): | ||
| return 0.0 | ||
| dot = sum(a * b for a, b in zip(vec_a, vec_b)) | ||
| norm_a = math.sqrt(sum(a * a for a in vec_a)) | ||
| norm_b = math.sqrt(sum(b * b for b in vec_b)) | ||
| if norm_a == 0 or norm_b == 0: | ||
| return 0.0 | ||
| return dot / (norm_a * norm_b) | ||
|
|
||
|
|
||
| def address_similarity(address_a: str, address_b: str) -> float: | ||
| if not address_a or not address_b: | ||
| return 0.0 | ||
| vec_a = get_address_embedding(address_a) | ||
| vec_b = get_address_embedding(address_b) | ||
| similarity = cosine_similarity(vec_a, vec_b) | ||
| return max(0.0, similarity) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Exact-match checks for Aadhaar, PAN, and date of birth. | ||
|
|
||
| Aadhaar numbers are often masked in extracted documents (e.g. | ||
| "XXXX XXXX 4321"). MatchResult is tri-state because a masked value can be | ||
| inconclusive rather than a clean match/mismatch. | ||
| """ | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import date | ||
| from enum import Enum | ||
|
|
||
| MIN_OVERLAPPING_DIGITS = 4 | ||
|
|
||
|
|
||
| class MatchResult(str, Enum): | ||
| MATCH = "MATCH" | ||
| MISMATCH = "MISMATCH" | ||
| INCONCLUSIVE = "INCONCLUSIVE" | ||
|
|
||
|
|
||
| @dataclass | ||
| class ExactCheckOutcome: | ||
| result: MatchResult | ||
| reason: str | None = None | ||
|
|
||
|
|
||
| def _normalize(value: str) -> str: | ||
| return "".join(ch for ch in value.upper() if ch.isdigit() or ch == "X") | ||
|
|
||
|
|
||
| def _is_masked(value: str) -> bool: | ||
| return "X" in value | ||
|
|
||
|
|
||
| def aadhaar_match(golden: str | None, candidate: str | None) -> ExactCheckOutcome: | ||
| if not golden or not candidate: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "missing_value") | ||
|
|
||
| g = _normalize(golden) | ||
| c = _normalize(candidate) | ||
|
|
||
| if not _is_masked(g) and not _is_masked(c): | ||
| return ( | ||
| ExactCheckOutcome(MatchResult.MATCH) | ||
| if g == c | ||
| else ExactCheckOutcome(MatchResult.MISMATCH, "digits_differ") | ||
| ) | ||
|
|
||
| if _is_masked(g) != _is_masked(c): | ||
| masked, unmasked = (g, c) if _is_masked(g) else (c, g) | ||
| trailing_digits = "".join(ch for ch in masked if ch != "X") | ||
| if not trailing_digits: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "no_unmasked_digits") | ||
| if len(trailing_digits) < MIN_OVERLAPPING_DIGITS: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "insufficient_unmasked_digits") | ||
| if len(unmasked) < len(trailing_digits): | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "unmasked_value_too_short") | ||
| suffix = unmasked[-len(trailing_digits):] | ||
| return ( | ||
| ExactCheckOutcome(MatchResult.MATCH) | ||
| if suffix == trailing_digits | ||
| else ExactCheckOutcome(MatchResult.MISMATCH, "suffix_digits_differ") | ||
| ) | ||
|
|
||
| # Both masked: compare position-wise where both sides have a digit. | ||
| if len(g) != len(c): | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "masked_length_mismatch") | ||
|
|
||
| overlapping = 0 | ||
| for gd, cd in zip(g, c): | ||
| if gd == "X" or cd == "X": | ||
| continue | ||
| overlapping += 1 | ||
| if gd != cd: | ||
| return ExactCheckOutcome(MatchResult.MISMATCH, "overlapping_digits_differ") | ||
|
|
||
| if overlapping < MIN_OVERLAPPING_DIGITS: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "insufficient_unmasked_digits") | ||
|
|
||
| return ExactCheckOutcome(MatchResult.MATCH) | ||
|
|
||
|
|
||
| def pan_match(golden: str | None, candidate: str | None) -> ExactCheckOutcome: | ||
| if not golden or not candidate: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "missing_value") | ||
| g = golden.strip().upper() | ||
| c = candidate.strip().upper() | ||
| return ( | ||
| ExactCheckOutcome(MatchResult.MATCH) | ||
| if g == c | ||
| else ExactCheckOutcome(MatchResult.MISMATCH, "pan_differs") | ||
| ) | ||
|
|
||
|
|
||
| def dob_match(golden: date | None, candidate: date | None) -> ExactCheckOutcome: | ||
| if golden is None or candidate is None: | ||
| return ExactCheckOutcome(MatchResult.INCONCLUSIVE, "missing_value") | ||
| return ( | ||
| ExactCheckOutcome(MatchResult.MATCH) | ||
| if golden == candidate | ||
| else ExactCheckOutcome(MatchResult.MISMATCH, "dob_differs") | ||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| """Name and employer string similarity. | ||
|
|
||
| Base similarity comes from RapidFuzz's token_set_ratio (handles word | ||
| reordering and extra/missing tokens). On top of that, an initials-expansion | ||
| pass resolves abbreviations like "Ankita A.S" vs "Ankita Advitot Sunil", | ||
| which plain character-level fuzzy matching can't do on its own. | ||
| """ | ||
|
|
||
| from rapidfuzz import fuzz | ||
|
|
||
| MIN_OVERLAP_TOKEN = 1 | ||
|
|
||
|
|
||
| def _tokenize(name: str) -> list[str]: | ||
| return [tok.strip(".").lower() for tok in name.replace(".", ". ").split() if tok.strip(".")] | ||
|
|
||
|
|
||
| def _is_initial(token: str) -> bool: | ||
| return len(token) == 1 | ||
|
|
||
|
|
||
| def _initials_genuine_match(tokens_a: list[str], tokens_b: list[str]) -> bool: | ||
| """True if the side with more single-letter tokens is a genuine | ||
| initials-abbreviation of the other side's full-word tokens. | ||
|
|
||
| Every initial on the shorter side must either (a) match the first | ||
| letter of a distinct, unused full word on the other side, or (b) have | ||
| no full word left to compare against at all (the other name simply | ||
| doesn't spell out that token, e.g. a middle/last name omitted | ||
| entirely). What's never tolerated is an initial whose letter | ||
| contradicts one of the available full words when a candidate exists | ||
| to compare against — that's what keeps a genuinely wrong initial | ||
| (e.g. "P" when the real name starts with "A") from being boosted. | ||
| """ | ||
| full_a = [t for t in tokens_a if not _is_initial(t)] | ||
| full_b = [t for t in tokens_b if not _is_initial(t)] | ||
| initials_a = [t for t in tokens_a if _is_initial(t)] | ||
| initials_b = [t for t in tokens_b if _is_initial(t)] | ||
|
|
||
| # Pick the side that actually has initials to expand. | ||
| if initials_b and not initials_a: | ||
| initials, full = initials_b, full_a | ||
| elif initials_a and not initials_b: | ||
| initials, full = initials_a, full_b | ||
| else: | ||
| return False | ||
|
|
||
| if not initials or not full: | ||
| return False | ||
|
|
||
| # There must be at least one shared full token (anchor) between the | ||
| # two names, otherwise this is likely a different person entirely. | ||
| shared_full_words = set(full_a) & set(full_b) | ||
| if not shared_full_words: | ||
| return False | ||
|
|
||
| # Words already spelled out identically on both sides (e.g. a shared | ||
| # first name) don't need to be re-derived from an initial — only the | ||
| # remaining full words are available to satisfy the initials. | ||
| remaining_full = list(full) | ||
| for word in shared_full_words: | ||
| if word in remaining_full: | ||
| remaining_full.remove(word) | ||
|
|
||
| available_first_letters = [w[0] for w in remaining_full] | ||
| matched_any = False | ||
| for letter in initials: | ||
| if letter in available_first_letters: | ||
| available_first_letters.remove(letter) | ||
| matched_any = True | ||
| elif available_first_letters: | ||
| # A candidate full word exists but its first letter doesn't | ||
| # match this initial — a genuine contradiction, not boosted. | ||
| return False | ||
| # else: no remaining full word to check this initial against — | ||
| # tolerated as an omitted token, not a contradiction. | ||
|
|
||
| return matched_any | ||
|
|
||
|
|
||
| def _base_token_set_ratio(a: str, b: str) -> float: | ||
| return float(fuzz.token_set_ratio(a.lower(), b.lower())) | ||
|
|
||
|
|
||
| def name_similarity(name_a: str, name_b: str) -> float: | ||
| if not name_a or not name_b: | ||
| return 0.0 | ||
|
|
||
| base = _base_token_set_ratio(name_a, name_b) | ||
|
|
||
| tokens_a = _tokenize(name_a) | ||
| tokens_b = _tokenize(name_b) | ||
|
|
||
| if _initials_genuine_match(tokens_a, tokens_b): | ||
| return 100.0 | ||
|
|
||
| return base | ||
|
|
||
|
|
||
| def employer_similarity(employer_a: str, employer_b: str) -> float: | ||
| if not employer_a or not employer_b: | ||
| return 0.0 | ||
| return _base_token_set_ratio(employer_a, employer_b) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.