cross-document validation Module - #11
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an end-to-end “document validation & decision” pipeline to lending-poc, including in-memory validation logic, DB persistence (SQLAlchemy + Alembic), and a new POST /cases API entrypoint to run the pipeline and store results.
Changes:
- Introduces the validation pipeline (golden record build, identity validation, business validation, scoring, decision) plus demo/edge-case scripts and workflow documentation.
- Adds Postgres persistence layer: SQLAlchemy models for cases/documents/golden_records/validation_results/pipeline_results and an Alembic migration (including
pgvector). - Adds encryption-at-rest for Aadhaar/PAN fields and replaces address “stub” embeddings with local
BAAI/bge-small-en-v1.5embeddings.
Reviewed changes
Copilot reviewed 33 out of 35 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| lending-poc/scripts/sample_case.json | Adds a realistic sample payload used for demos and manual validation. |
| lending-poc/scripts/run_demo.py | Adds a CLI demo runner for the in-memory pipeline. |
| lending-poc/scripts/edge_case_scenarios.py | Adds scenario-based pipeline runs to probe edge behaviors. |
| lending-poc/pyproject.toml | Adds dependencies for pgvector, cryptography, sentence-transformers, rapidfuzz. |
| lending-poc/docs/Workflow.md | Documents the request shape, outputs, and pipeline stages/logic. |
| lending-poc/docker-compose.yml | Switches DB image to pgvector-enabled Postgres and changes host port mapping. |
| lending-poc/app/services/validation_config.py | Centralizes thresholds/weights/constants for validation and decisioning. |
| lending-poc/app/services/scoring.py | Implements weighted score aggregation across validation results. |
| lending-poc/app/services/pipeline.py | Orchestrates golden record → validations → scoring → decision with audit log. |
| lending-poc/app/services/persistence.py | Persists a pipeline run and resolves doc_id→FK mapping + JSON-safe evidence. |
| lending-poc/app/services/identity_validation.py | Implements identity checks against the golden record + mandatory presence checks. |
| lending-poc/app/services/golden_record.py | Builds the golden record and computes address embeddings. |
| lending-poc/app/services/dto.py | Adds dataclasses/enums used across parsing, validation, scoring, decisioning. |
| lending-poc/app/services/decision_engine.py | Applies mandatory-field rules + score thresholds to produce PASS/FAIL/REVIEW. |
| lending-poc/app/services/case_parsing.py | Parses request JSON into DTOs for pipeline execution. |
| lending-poc/app/services/business_validation.py | Matches salary slips to bank transactions and computes employer/count checks. |
| lending-poc/app/services/init.py | Package marker for services. |
| lending-poc/app/schemas/case.py | Adds request/response models for POST /cases. |
| lending-poc/app/models/validation_result.py | Adds ORM model for persisted validation results. |
| lending-poc/app/models/types.py | Adds EncryptedString type decorator for encrypted-at-rest strings. |
| lending-poc/app/models/pipeline_result.py | Adds ORM model for persisted pipeline runs and review metadata fields. |
| lending-poc/app/models/golden_record.py | Adds ORM model for golden records including vector embeddings and encrypted fields. |
| lending-poc/app/models/document.py | Adds ORM model for stored document JSON + source reference. |
| lending-poc/app/models/case.py | Adds ORM model for cases and relationships. |
| lending-poc/app/models/init.py | Exposes ORM models for Alembic metadata registration. |
| lending-poc/app/matching/fuzzy.py | Implements fuzzy name/employer similarity (RapidFuzz + initials logic). |
| lending-poc/app/matching/exact.py | Implements exact/tri-state matching for Aadhaar, PAN, DOB. |
| lending-poc/app/matching/embeddings.py | Implements local address embeddings + cosine similarity. |
| lending-poc/app/matching/init.py | Package marker for matching. |
| lending-poc/app/main.py | Registers the new cases router. |
| lending-poc/app/config.py | Adds encryption key config setting. |
| lending-poc/app/api/cases.py | Adds POST /cases endpoint to run pipeline and persist results. |
| lending-poc/alembic/versions/12522c432f16_add_cases_documents_golden_records_.py | Creates tables/enums and enables the vector extension. |
| lending-poc/alembic/env.py | Registers models to support Alembic autogenerate. |
| lending-poc/.gitignore | Ignores local virtualenv directory. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| @router.post("/cases", response_model=CaseCreateResponse) | ||
| async def create_case( | ||
| request: CaseCreateRequest, db: AsyncSession = Depends(get_db) | ||
| ) -> CaseCreateResponse: | ||
| case_input = parse_case(request.model_dump()) | ||
| 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=r.evidence, | ||
| ) | ||
| for r in pipeline_result.validation_results | ||
| ], | ||
| ) |
| extracted_fields={ | ||
| "account_holder": case.bank_statement.name, | ||
| "transactions": [ | ||
| { | ||
| "narration": txn.narration, | ||
| "amount": txn.amount, | ||
| "date": txn.txn_date.isoformat() if txn.txn_date else None, | ||
| } | ||
| for txn in case.bank_statement.transactions | ||
| ], | ||
| }, | ||
| ), |
| impl = String | ||
| cache_ok = True | ||
|
|
||
| def process_bind_param(self, value: str | None, dialect) -> str | None: | ||
| if value is None: | ||
| return None | ||
| return Fernet(settings.ENCRYPTION_KEY).encrypt(value.encode("utf-8")).decode("utf-8") | ||
|
|
||
| def process_result_value(self, value: str | None, dialect) -> str | None: | ||
| if value is None: | ||
| return None | ||
| return Fernet(settings.ENCRYPTION_KEY).decrypt(value.encode("utf-8")).decode("utf-8") |
|
|
||
| sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | ||
|
|
||
| from scripts.run_demo import parse_case # noqa: E402 |
| ports: | ||
| - "5432:5432" | ||
| - "55432:5432" | ||
| volumes: |
| from typing import Any | ||
|
|
||
| from pydantic import BaseModel | ||
|
|
||
|
|
||
| class DocumentIn(BaseModel): | ||
| doc_type: str | ||
| extracted_fields: dict[str, Any] | None = None | ||
| source_file_ref: str | None = None | ||
| salary_slips: list[dict[str, Any]] | None = None # only present when doc_type == SALARY_SLIP |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 27 changed files in this pull request and generated 2 comments.
Suppressed comments (14)
lending-poc/app/services/persistence.py:56
- The PAN number is persisted in plaintext inside
documents.extracted_fields, bypassing theEncryptedStringprotection used byGoldenRecord.pan_number. This leaves a sensitive identifier unencrypted at rest. Encrypt/redact this extracted value or store it only in an encrypted column.
doc_type=DocType.PAN,
source_file_ref=case.pan.source_file_ref,
extracted_fields={"name": case.pan.name, "pan_number": case.pan.pan_number},
),
lending-poc/app/services/case_parsing.py:91
- The index restarts for every
SALARY_SLIPwrapper, but the request schema permits multiple wrappers. Duplicate IDs then overwrite entries inslip_results_by_doc_idanddoc_id_to_pk, causing checks and persisted foreign keys to be associated with the wrong slip. Generate the ID from the total number of slips already appended so it stays unique across wrappers.
doc_id=f"SALARY_SLIP-{i}",
lending-poc/app/services/business_validation.py:42
- Negative declared salaries pass this gate for every positive credit because division by a negative
expectedproduces a negative percentage, which is always<= 3. Since the request currently does not constrainnet_salary, this can select an unrelated credit. Reject non-positive expected amounts before calculating the percentage.
def _within_amount_tolerance(amount: float, expected: float) -> bool:
if not expected:
return False
pct_diff = abs(amount - expected) / expected * 100.0
return pct_diff <= cfg.SALARY_AMOUNT_TOLERANCE_PCT
lending-poc/app/services/business_validation.py:63
- The amount-only eligibility gate does not prevent a same-employer reimbursement, bonus, or advance from masking a missing salary when that credit happens to be within 3% of the declared amount. Such a transaction remains eligible, and
token_set_ratiocan give its narration a perfect employer score (for example,ACME BONUSagainst employerACME), so it can be selected as salary evidence. Add an independent salary-credit signal/transaction classification, or narrow the stated guarantee; amount proximity alone cannot provide it.
if txn.amount is not None
and txn.txn_date is not None
and txn.amount > 0
and start <= txn.txn_date <= end
and (expected_amount is None or _within_amount_tolerance(txn.amount, expected_amount))
lending-poc/app/api/cases.py:22
run_pipelineperforms synchronous CPU-heavy embedding inference inside an async route, blocking the event loop for the full validation run and stalling unrelated requests. Run the pipeline in a worker thread/process (for example,await asyncio.to_thread(...)or FastAPI's threadpool helper), or make this a background job for production workloads.
pipeline_result = run_pipeline(case_input)
lending-poc/app/matching/embeddings.py:24
- Loading
SentenceTransformerby Hub model ID downloads model files on the first address-bearing request in a fresh container. The Dockerfile only installs dependencies and does not cache this model, so startup/request handling depends on external network access and fails in offline deployments, contrary to the stated local/no-external-call behavior. Bake the model into the image and load a local path with offline/local-only settings.
def _get_model():
from sentence_transformers import SentenceTransformer
return SentenceTransformer(EMBEDDING_MODEL_NAME)
lending-poc/app/services/persistence.py:152
ValidationResult.failure_reasonis discarded when persisting each result (and is also absent from the response model). For score-based FAIL decisions, the pipeline reason is onlyscore_below_fail_threshold, so the concrete causes are irretrievably lost after this request. Persist a nullable failure-reason field and expose it with each per-check result.
ValidationResultModel(
case_id=case.id,
document_id=doc_id_to_pk.get(result.document_id) if result.document_id else None,
check_type=result.check_type,
passed=result.passed,
score=result.score,
evidence=json_safe(result.evidence) if result.evidence else None,
)
lending-poc/app/api/cases.py:23
- A second submission with the same
applicant_refreaches the unique constraint oncases.applicant_ref(db/models/case.py:24) and raises an unhandledIntegrityError, producing a 500. Handle this contract explicitly—return a 409 for duplicates, make creation idempotent, or remove the uniqueness rule if multiple cases per applicant are valid.
case = await save_pipeline_result(db, case_input, pipeline_result)
lending-poc/db/migrations/versions/0006_make_document_source_file_ref_nullable.py:25
- After the upgrade permits and the API persists null source references, this downgrade fails when PostgreSQL tries to restore
NOT NULL. Define a downgrade data policy (backfill a valid value, remove affected rows, or make the migration intentionally irreversible) before altering the constraint.
def downgrade() -> None:
op.alter_column("documents", "source_file_ref", existing_type=sa.String(), nullable=False)
lending-poc/app/services/identity_validation.py:73
- The Golden Record embedding computed in
build_golden_recordis ignored here;address_similarityre-encodes both the golden address and every document address. This repeats expensive model inference—including re-encoding the same golden address for each check. Reusegolden.address_embeddingand encode only the candidate address (and skip inference entirely when validating the source document against itself).
if doc_address is not None and golden.address is not None:
similarity = address_similarity(golden.address, doc_address)
score = similarity * 100.0
passed = similarity >= cfg.ADDRESS_SIMILARITY_THRESHOLD
lending-poc/app/services/dto.py:19
- These DTO enums duplicate the database enums even though
db/models/enums.py:1-5explicitly establishes that module as the shared owner and says the pipeline should import them.DocType,CheckType, andDecisionare consequently distinct Python types from those declared by the ORM, despite persistence passing DTO values directly into ORM fields. Import the shared enums instead of redefining all three here.
class DocType(str, Enum):
AADHAAR = "AADHAAR"
PAN = "PAN"
ADDRESS_PROOF = "ADDRESS_PROOF"
SALARY_SLIP = "SALARY_SLIP"
lending-poc/app/services/dto.py:7
- This module description is already outdated: this PR adds ORM persistence and maps these DTOs to database models in
app/services/persistence.py. Update the docstring so it does not claim persistence is a future addition.
No database/ORM involved yet — these dataclasses are what the extraction
pipeline's JSON gets parsed into, and what every service function passes
around. When persistence is added later, these become the shape that gets
mapped to/from the DB models, but the validation logic itself does not
change.
lending-poc/app/schemas/case.py:4
docs/Workflow.mdis not present in the repository, so this schema documentation points to a broken path. Referencedocs/cases_api.md, which contains the actual contract.
Mirrors the JSON shape documented in docs/Workflow.md and used by
scripts/sample_case.json — one applicant_ref plus a flat list of documents.
lending-poc/app/services/case_parsing.py:2
- The referenced
docs/Workflow.mddoes not exist; the request shape is documented indocs/cases_api.md. Point readers to the existing contract.
"""Parses the raw request JSON shape (see docs/Workflow.md) into CaseInput.
| class DocumentIn(BaseModel): | ||
| doc_type: str | ||
| extracted_fields: dict[str, Any] | ||
| source_file_ref: str | None = None | ||
| salary_slips: list[SalarySlipIn] | None = None # only present when doc_type == SALARY_SLIP |
| extracted_fields={ | ||
| "name": case.aadhaar.name, | ||
| "address": case.aadhaar.address, | ||
| "aadhaar_number": case.aadhaar.aadhaar_number, | ||
| "date_of_birth": case.aadhaar.date_of_birth.isoformat() if case.aadhaar.date_of_birth else None, | ||
| }, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 27 changed files in this pull request and generated 2 comments.
Suppressed comments (17)
lending-poc/app/services/persistence.py:44
- Aadhaar is encrypted in
GoldenRecord, but this copy is persisted verbatim inside thedocuments.extracted_fieldsJSONB column. This leaves the same identifier plaintext at rest and bypasses the newly required encryption key. Encrypt/redact sensitive values in raw document storage as well, or store them only in encrypted columns.
extracted_fields={
"name": case.aadhaar.name,
"address": case.aadhaar.address,
"aadhaar_number": case.aadhaar.aadhaar_number,
"date_of_birth": case.aadhaar.date_of_birth.isoformat() if case.aadhaar.date_of_birth else None,
lending-poc/app/services/case_parsing.py:54
- A second AADHAAR, PAN, ADDRESS_PROOF, or BANK_STATEMENT silently overwrites the first DTO, so validation and persistence ignore a submitted document even though the feature guide promises one row per submitted document. Reject duplicate singleton document types (or model them as collections) before assigning them.
for doc in payload["documents"]:
doc_type = doc["doc_type"]
if doc_type == "AADHAAR":
lending-poc/app/services/case_parsing.py:91
- The index restarts for every SALARY_SLIP wrapper. Since the request schema permits multiple such wrappers, two slips can both receive
SALARY_SLIP-0; the business-results dictionary drops one result and persistence links both checks to the last document with that key. Generate the ID from the total number of slips already accumulated, or reject multiple wrappers.
doc_id=f"SALARY_SLIP-{i}",
lending-poc/app/schemas/case.py:42
net_salaryaccepts zero and negative values. A negative expected salary makes_within_amount_tolerancecompute a negative percentage, so every positive credit passes the amount gate and may be reported as the salary match. Constrain this field to a positive float; Pydantic will still coerce numeric strings.
net_salary: float | str | None = None
lending-poc/app/services/persistence.py:152
- Per-check
failure_reasonis discarded when validation rows are persisted; the DB model/migration has no corresponding column. For a score-based FAIL, the pipeline row only storesscore_below_fail_threshold, so the concrete causes can be lost entirely. Persistresult.failure_reasonwith each validation result.
ValidationResultModel(
case_id=case.id,
document_id=doc_id_to_pk.get(result.document_id) if result.document_id else None,
check_type=result.check_type,
passed=result.passed,
score=result.score,
evidence=json_safe(result.evidence) if result.evidence else None,
)
lending-poc/app/schemas/case.py:108
- The response omits
failure_reason. A PASS can contain failed low-weight checks, but its top-level reason is onlyscore_meets_pass_threshold, leaving clients unable to tell why an individual result failed. Expose the per-check reason and map it increate_case.
class ValidationResultOut(BaseModel):
check_type: str
passed: bool
score: float
document_id: str | None = None
evidence: dict[str, Any] | None = None
lending-poc/app/services/golden_record.py:39
name_similarityis based ontoken_set_ratio, which returns 100 when one name's tokens are a subset of the other. Consequently, a PAN name such asSneha Arbitrary Lokhandecan be considered related toSneha Lokhande, win solely because it has more tokens, and become the trusted name without the extra token being corroborated. Use a stricter promotion test or keep Aadhaar authoritative when added tokens cannot be verified.
if aadhaar_name and pan_name:
related = name_similarity(aadhaar_name, pan_name) >= FULLER_NAME_RELATEDNESS_THRESHOLD
if related and len(pan_name.split()) > len(aadhaar_name.split()):
return pan_name, "PAN"
return aadhaar_name, "AADHAAR"
lending-poc/app/services/business_validation.py:53
- This amount predicate cannot exclude a reimbursement/bonus with a coincidentally close amount as the docstring claims: any positive transaction within 3% passes, and a same-employer narration can then clear the selection score. Such a payment will be reported as salary evidence. Add a reliable transaction-type signal/classifier or otherwise distinguish salary credits instead of relying only on amount and employer similarity.
tolerance of what this specific slip declared. The tolerance gate is
on amount alone -- a large reimbursement/bonus/advance with a
coincidentally close amount is excluded here, before narration
similarity ever gets a vote, so it can never mask a genuinely missing
salary credit.
lending-poc/app/api/cases.py:22
run_pipelineperforms synchronous sentence-transformer loading and CPU inference directly inside an async route. While it runs, the event loop cannot serve health checks or other requests. Offload the pipeline to a bounded worker/thread pool (or make it a background job) and await that boundary here.
pipeline_result = run_pipeline(case_input)
lending-poc/db/migrations/versions/0006_make_document_source_file_ref_nullable.py:25
- After this migration is used, valid rows may contain NULL. The downgrade immediately restores NOT NULL without backfilling or rejecting those rows, so rollback will fail as soon as any document omitted
source_file_ref. Define a downgrade data policy (backfill/delete) before altering the constraint.
def downgrade() -> None:
op.alter_column("documents", "source_file_ref", existing_type=sa.String(), nullable=False)
lending-poc/app/matching/embeddings.py:24
- Constructing
SentenceTransformerby hub model name downloads model artifacts on a cache miss. The container build does not preload this model, so the first/casesrequest requires outbound network access and can fail or stall in an offline/restricted deployment. Bundle/prefetch a pinned model during image build or load a configured local artifact at startup.
return SentenceTransformer(EMBEDDING_MODEL_NAME)
lending-poc/app/services/identity_validation.py:73
- The Golden Record already computed and retained this address embedding, but
address_similarityencodesgolden.addressagain for every address-bearing document. This repeats the expensive transformer inference and ignores the cached vector. Comparegolden.address_embeddingto only the document embedding instead.
if doc_address is not None and golden.address is not None:
similarity = address_similarity(golden.address, doc_address)
score = similarity * 100.0
passed = similarity >= cfg.ADDRESS_SIMILARITY_THRESHOLD
lending-poc/app/api/cases.py:23
cases.applicant_refis unique, but this endpoint neither documents nor handles a repeated reference. A retry or a second case for the same applicant reaches the flush and becomes an unhandled integrity error/500. Decide whether the POST is idempotent or multiple cases are allowed; otherwise return a defined conflict response.
case = await save_pipeline_result(db, case_input, pipeline_result)
lending-poc/app/services/persistence.py:136
- The in-memory Golden Record records
name_source,address_source,dob_source,aadhaar_source, andpan_source, but none are written here (and the DB model has no source columns). This loses the traceability promised by the feature guide after the request completes. Persist the source document IDs alongside the values.
GoldenRecordModel(
case_id=case.id,
name=golden.name,
address=golden.address,
address_embedding=golden.address_embedding or None,
lending-poc/app/matching/fuzzy.py:78
- Once all available full words have been consumed, any additional initial is ignored and the function still returns 100 because
matched_anyis true. For example,Sneha Sunil LokhandeandLokhande S. S. X.become a perfect match despite the unexplainedX. Treat an unmatched initial as non-genuine rather than boosting the pair.
# else: no remaining full word to check this initial against —
# tolerated as an omitted token, not a contradiction.
return matched_any
lending-poc/docs/features.md:59
- The implementation and public enum call this outcome
MISMATCH, notNO_MATCH. Using the wrong value in the feature guide misstates the matching contract.
- **AADHAAR / PAN / DOB** — exact matching (`app.matching.exact`). Result is `MATCH` (score 100), `NO_MATCH` (score 0), or `INCONCLUSIVE` (score 50, e.g. one side missing/unparseable).
lending-poc/app/services/identity_validation.py:107
- This validates source fields against values copied from those same source documents: Aadhaar address/number/DOB, PAN number, and whichever document supplied the selected name are tautological matches. Those circular results receive substantial scoring weight and inflate confidence without independent corroboration. Exclude source-to-self checks from scoring or model source presence separately from cross-document agreement.
if case.aadhaar:
results.extend(
validate_document_against_golden(
document_id=case.aadhaar.doc_id,
doc_name=case.aadhaar.name,
| doc_type=DocType.PAN, | ||
| source_file_ref=case.pan.source_file_ref, | ||
| extracted_fields={"name": case.pan.name, "pan_number": case.pan.pan_number}, |
… in JSONB - aadhaar_match: return INCONCLUSIVE when a masked value exposes fewer than MIN_OVERLAPPING_DIGITS visible digits, preventing a single-digit suffix from producing a definitive MATCH - persistence: mask all but the last 4 chars of PAN before writing to the documents.extracted_fields JSONB column; full value is encrypted in GoldenRecord
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 25 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (9)
lending-poc/app/services/case_parsing.py:1
- This docstring references
docs/Workflow.md, but there is no such file underlending-poc/docs/. Please update the reference to an existing document (e.g.docs/cases_api.md).
"""Parses the raw request JSON shape (see docs/Workflow.md) into CaseInput.
lending-poc/docs/cases_api.md:74
- The Notes section contradicts itself: it says every document needs
extracted_fields, butSALARY_SLIPusessalary_slipsinstead. Please clarify the rule so consumers don't send an invalid payload.
- Every document needs `doc_type` and `extracted_fields`; `source_file_ref` is optional.
- `SALARY_SLIP` is the only `doc_type` that carries a `salary_slips` array instead of a flat `extracted_fields` — a case can include multiple salary slips (one per month).
lending-poc/app/schemas/case.py:4
- This module docstring references
docs/Workflow.md, but that file doesn't exist inlending-poc/docs/. Point to the current API documentation instead.
Mirrors the JSON shape documented in docs/Workflow.md and used by
scripts/sample_case.json — one applicant_ref plus a flat list of documents.
lending-poc/app/services/identity_validation.py:82
- ADDRESS validation recomputes embeddings for the Golden Record address every time, even though
GoldenRecord.address_embeddingis already computed during golden-record construction. This will unnecessarily re-encode the same address and slow down requests.
if doc_address is not None and golden.address is not None:
similarity = address_similarity(golden.address, doc_address)
score = similarity * 100.0
passed = similarity >= cfg.ADDRESS_SIMILARITY_THRESHOLD
results.append(
lending-poc/app/services/business_validation.py:54
- The
_candidate_transactionsdocstring says a reimbursement/bonus with a coincidentally close amount is excluded by the amount gate, but the gate only excludes transactions that are not within the tolerance. Please adjust the wording to match the actual behavior.
tolerance of what this specific slip declared. The tolerance gate is
on amount alone -- a large reimbursement/bonus/advance with a
coincidentally close amount is excluded here, before narration
similarity ever gets a vote, so it can never mask a genuinely missing
salary credit.
lending-poc/app/services/validation_config.py:26
- This comment claims the amount tolerance gate stops a reimbursement/bonus/advance with a coincidentally close amount, but the code only excludes transactions that are outside the tolerance. The current wording is misleading for future calibration work.
# A transaction must land within this percentage of the slip's declared
# net_salary to be eligible as a salary-credit match AT ALL, independent
# of how well its narration scores. This is what stops a same-employer
# reimbursement/bonus/advance with a coincidentally close amount from
# masking a genuinely missing salary credit -- the gate is on the amount
lending-poc/scripts/sample_case.json:14
- This note says a decoy transaction "closer in amount" is excluded by the 3% tolerance gate, but the gate would only exclude it if it's still outside the 3% window. Clarifying this avoids future confusion when validating edge cases.
"amount_tolerance_gate": "SALARY_AMOUNT_TOLERANCE_PCT (3%) in validation_config.py hard-excludes any transaction whose amount is not within 3% of the slip's declared net_salary from even being a match candidate, regardless of narration/employer score. This is why the May reimbursement (4500 vs expected 78000, ~94% off) can never be mistaken for the real salary credit -- and why a decoy transaction closer in amount (tested separately in edge_case_scenarios.py) is also correctly excluded rather than relying on narration keywords like 'REIMBURSEMENT', which don't generalize."
lending-poc/db/migrations/versions/0006_make_document_source_file_ref_nullable.py:25
downgrade()will fail if any rows were inserted withsource_file_ref = NULLafter the upgrade (it can't make the column NOT NULL while NULLs exist). Consider backfilling a sentinel value (or deleting) before altering the column.
def downgrade() -> None:
op.alter_column("documents", "source_file_ref", existing_type=sa.String(), nullable=False)
lending-poc/app/matching/embeddings.py:24
- The docstring states embeddings run on CPU, but
SentenceTransformer(...)will default to CUDA if available. If you want to force CPU (as documented), passdevice='cpu'explicitly (or update the docstring).
return SentenceTransformer(EMBEDDING_MODEL_NAME)
Add
POST /casesAPI with document validation pipeline and persistenceSummary
Implements the core lending validation workflow end-to-end: applicants submit KYC and income documents to a new
POST /casesendpoint, which builds a "Golden Record" identity profile, cross-validates every document against it, verifies declared salary against actual bank credits, computes a weighted confidence score, and returns a PASS / FAIL / NEEDS_REVIEW decision. The full case and its results are persisted via async SQLAlchemy, backed by new Alembic migrations. This PR also adds the FastAPI app boilerplate, Docker setup, and initial documentation, since this is the first feature landing in the service.What's included
API
POST /cases(app/api/cases.py) — accepts an applicant reference and a flat list of documents (AADHAAR,PAN,ADDRESS_PROOF,SALARY_SLIP× N,BANK_STATEMENT), runs the validation pipeline, persists the result, and returns the decision plus per-check results.GET /health(app/api/health.py) — basic liveness/readiness check.app/schemas/case.py.Validation pipeline (
app/services/)case_parsing.py— parses raw request JSON into typed DTOs, with null-safe field handling and date/month parsing.golden_record.py— merges Aadhaar/PAN/Address Proof into one trusted identity profile, with a fuller-name preference rule for the golden name and an Aadhaar-first, Address-Proof-fallback rule for address.identity_validation.py— checks every document (name, address, Aadhaar, PAN, DOB) against the Golden Record, plus a mandatory-field presence check.business_validation.py— matches each salary slip to a bank credit within a month-level window, gated by amount tolerance before narration scoring, with per-slip employer consistency checks and an aggregate salary-credit-count check.scoring.py— combines all checks into one weighted overall score, renormalized to only the check types observed per case.decision_engine.py— final PASS/FAIL/NEEDS_REVIEW logic, with mandatory-field failures short-circuiting to a hard FAIL.pipeline.py— orchestrates the above in sequence with an in-memory audit log.persistence.py— writes Case, Document, GoldenRecord, ValidationResult, and PipelineResult rows in a single transaction.validation_config.py— centralizes all thresholds/weights as tunable constants.Matching primitives (
app/matching/)fuzzy.py— name/employer similarity via RapidFuzz token-set ratio plus an initials-expansion pass.exact.py— tri-state (MATCH/MISMATCH/INCONCLUSIVE) exact matching for Aadhaar, PAN, and DOB, accounting for masked values.embeddings.py— address similarity via localBAAI/bge-small-en-v1.5embeddings (no external API call).Persistence layer (
db/)Case,Document,GoldenRecord,PipelineResult,ValidationResult, shared enums.source_file_reffix).Infra & tooling
docker-compose.ymlfor local Postgres + app.pyproject.tomlfor dependency/tooling setup.scripts/sample_case.json— a realistic mixed-signal test case (deliberately not a clean happy path) documenting verified pipeline behavior across pass/fail/boundary conditions.Documentation
docs/cases_api.md—/casesrequest/response reference.docs/features.md— detailed explanation of every pipeline stage and config value.Notes for reviewers
SALARY_DATEis not itself in the scoring weight table — it gates whether a credit is found, whileEMPLOYERandSALARY_CREDIT_COUNTcarry the scoring weight for that signal.validation_config.pyare currently plain constants; the code comments flag these as intended to move to environment-driven settings later.