Skip to content

cross-document validation Module - #11

Merged
Selectus2 merged 8 commits into
lending_poc/mainfrom
feature/data_validation
Aug 14, 2026
Merged

cross-document validation Module#11
Selectus2 merged 8 commits into
lending_poc/mainfrom
feature/data_validation

Conversation

@Ankita-Advitot

@Ankita-Advitot Ankita-Advitot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Add POST /cases API with document validation pipeline and persistence

Summary

Implements the core lending validation workflow end-to-end: applicants submit KYC and income documents to a new POST /cases endpoint, 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.
  • Request/response schemas in 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 local BAAI/bge-small-en-v1.5 embeddings (no external API call).

Persistence layer (db/)

  • SQLAlchemy models: Case, Document, GoldenRecord, PipelineResult, ValidationResult, shared enums.
  • Six Alembic migrations building up the schema incrementally (cases → documents → golden records → pipeline results → validation results → nullable source_file_ref fix).
  • Async DB session setup and config.

Infra & tooling

  • FastAPI app entrypoint, config, Dockerfile, docker-compose.yml for local Postgres + app.
  • pyproject.toml for 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/cases request/response reference.
  • docs/features.md — detailed explanation of every pipeline stage and config value.

Notes for reviewers

  • Business rules worth double-checking: the amount-tolerance gate (3%) on salary-credit matching, the fuller-name golden-record rule, and the month-by-month (not cross-month) employer consistency check.
  • SALARY_DATE is not itself in the scoring weight table — it gates whether a credit is found, while EMPLOYER and SALARY_CREDIT_COUNT carry the scoring weight for that signal.
  • Thresholds in validation_config.py are currently plain constants; the code comments flag these as intended to move to environment-driven settings later.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.5 embeddings.

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.

Comment on lines +13 to +37
@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
],
)
Comment thread lending-poc/app/services/business_validation.py
Comment on lines +108 to +119
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
],
},
),
Comment thread lending-poc/app/models/types.py Outdated
Comment on lines +15 to +26
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")
Comment thread lending-poc/app/services/persistence.py

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from scripts.run_demo import parse_case # noqa: E402
Comment on lines 8 to 10
ports:
- "5432:5432"
- "55432:5432"
volumes:
Comment thread lending-poc/app/schemas/case.py Outdated
Comment on lines +7 to +16
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
Comment thread lending-poc/app/config.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the EncryptedString protection used by GoldenRecord.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_SLIP wrapper, but the request schema permits multiple wrappers. Duplicate IDs then overwrite entries in slip_results_by_doc_id and doc_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 expected produces a negative percentage, which is always <= 3. Since the request currently does not constrain net_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_ratio can give its narration a perfect employer score (for example, ACME BONUS against employer ACME), 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_pipeline performs 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 SentenceTransformer by 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_reason is discarded when persisting each result (and is also absent from the response model). For score-based FAIL decisions, the pipeline reason is only score_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_ref reaches the unique constraint on cases.applicant_ref (db/models/case.py:24) and raises an unhandled IntegrityError, 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_record is ignored here; address_similarity re-encodes both the golden address and every document address. This repeats expensive model inference—including re-encoding the same golden address for each check. Reuse golden.address_embedding and 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-5 explicitly establishes that module as the shared owner and says the pipeline should import them. DocType, CheckType, and Decision are 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.md is not present in the repository, so this schema documentation points to a broken path. Reference docs/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.md does not exist; the request shape is documented in docs/cases_api.md. Point readers to the existing contract.
"""Parses the raw request JSON shape (see docs/Workflow.md) into CaseInput.

Comment thread lending-poc/app/schemas/case.py Outdated
Comment on lines +17 to +21
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
Comment on lines +40 to +45
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,
},

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the documents.extracted_fields JSONB 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_salary accepts zero and negative values. A negative expected salary makes _within_amount_tolerance compute 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_reason is discarded when validation rows are persisted; the DB model/migration has no corresponding column. For a score-based FAIL, the pipeline row only stores score_below_fail_threshold, so the concrete causes can be lost entirely. Persist result.failure_reason with 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 only score_meets_pass_threshold, leaving clients unable to tell why an individual result failed. Expose the per-check reason and map it in create_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_similarity is based on token_set_ratio, which returns 100 when one name's tokens are a subset of the other. Consequently, a PAN name such as Sneha Arbitrary Lokhande can be considered related to Sneha 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_pipeline performs 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 SentenceTransformer by hub model name downloads model artifacts on a cache miss. The container build does not preload this model, so the first /cases request 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_similarity encodes golden.address again for every address-bearing document. This repeats the expensive transformer inference and ignores the cached vector. Compare golden.address_embedding to 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_ref is 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, and pan_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_any is true. For example, Sneha Sunil Lokhande and Lokhande S. S. X. become a perfect match despite the unexplained X. 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, not NO_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,

Comment thread lending-poc/app/services/persistence.py Outdated
Comment on lines +53 to +55
doc_type=DocType.PAN,
source_file_ref=case.pan.source_file_ref,
extracted_fields={"name": case.pan.name, "pan_number": case.pan.pan_number},
Comment thread lending-poc/app/matching/exact.py
… 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 under lending-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, but SALARY_SLIP uses salary_slips instead. 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 in lending-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_embedding is 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_transactions docstring 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 with source_file_ref = NULL after 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), pass device='cpu' explicitly (or update the docstring).
    return SentenceTransformer(EMBEDDING_MODEL_NAME)

@Selectus2
Selectus2 merged commit b10c284 into lending_poc/main Aug 14, 2026
1 check passed
@Selectus2
Selectus2 deleted the feature/data_validation branch August 14, 2026 10:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants