"""Deterministic claim analysis: a 0-100 score, a plain-English narrative, a recommended starting tier, and a recommended collection letter assembled from the tier templates in /root/.hermes/references/dre-letter-templates.md. Transparent rules engine (no external LLM) so every score is explainable and auditable for FDCPA/TDCPA compliance. To move to an LLM later, swap the generator behind these functions and keep the return shape identical. """ from __future__ import annotations import json import uuid from datetime import datetime, timezone TIER_ORDER = ["TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"] TIER_LABELS = { "TIER_1": "Tier 1 - Soft Touch", "TIER_2": "Tier 2 - Formal Demand", "TIER_2_5": "Tier 2.5 - Lien Threat", "TIER_3": "Tier 3 - Escalation", "TIER_4": "Tier 4 - Legal Action", } TIER_DESCRIPTIONS = { "TIER_1": "Friendly reminder email + ACH payment link (Day 1-5)", "TIER_2": "Formal demand letter via certified mail (Day 7-14)", "TIER_2_5": "Pre-lien notice for construction claims (Day 15-21)", "TIER_3": "Final notice before legal action (Day 21-30)", "TIER_4": "Referral to partner law firm (Day 30+)", } def next_tier(current: str) -> str | None: """Return the tier after `current` in the escalation order, or None at Tier 4.""" if current not in TIER_ORDER: return None i = TIER_ORDER.index(current) return TIER_ORDER[i + 1] if i + 1 < len(TIER_ORDER) else None def _age_days(invoice_date: str | None) -> int | None: if not invoice_date: return None try: d = datetime.strptime(invoice_date[:10], "%Y-%m-%d") d = d.replace(tzinfo=timezone.utc) return (datetime.now(timezone.utc) - d).days except (ValueError, TypeError): return None def score_claim(*, amount_cents: int, business_type: str, doc_count: int, description: str | None, invoice_date: str | None, client_reference: str | None) -> dict: """Compute a 0-100 analysis score with a component breakdown and narrative. Returns: {"score": int, "components": {name: {points, max, note}}, "summary": str, "recommended_tier": str} """ components: dict[str, dict] = {} # 1. Amount (0-25) — higher balance = higher recovery priority. if amount_cents >= 1_000_000: components["amount"] = {"points": 25, "max": 25, "note": "High-value claim ($10k+)"} elif amount_cents >= 500_000: components["amount"] = {"points": 20, "max": 25, "note": "Significant balance ($5k-$10k)"} elif amount_cents >= 250_000: components["amount"] = {"points": 15, "max": 25, "note": "Moderate balance ($2.5k-$5k)"} elif amount_cents >= 100_000: components["amount"] = {"points": 10, "max": 25, "note": "Low balance ($1k-$2.5k)"} elif amount_cents >= 50_000: components["amount"] = {"points": 6, "max": 25, "note": "Small balance ($500-$1k)"} else: components["amount"] = {"points": 3, "max": 25, "note": "Minimal balance (<$500)"} # 2. Documentation (0-25) — evidence quality drives collectability. if doc_count >= 4: components["documentation"] = {"points": 25, "max": 25, "note": f"{doc_count} documents on file"} elif doc_count >= 2: components["documentation"] = {"points": 18, "max": 25, "note": f"{doc_count} documents on file"} elif doc_count == 1: components["documentation"] = {"points": 12, "max": 25, "note": "1 document on file"} else: components["documentation"] = {"points": 5, "max": 25, "note": "No evidence uploaded yet"} # 3. Collectability by debtor entity type (0-20). collect = { "LLC": (20, "Registered LLC - assets traceable"), "CORPORATION": (20, "Registered corporation - assets traceable"), "PARTNERSHIP": (16, "Partnership"), "SOLE_PROPRIETORSHIP": (12, "Sole proprietorship"), "INDIVIDUAL": (8, "Individual debtor"), "OTHER": (10, "Other entity type"), } pts, note = collect.get(business_type, (10, "Other entity type")) components["collectability"] = {"points": pts, "max": 20, "note": note} # 4. Claim completeness (0-20) — how fully the intake form was filled. comp = 0 if description: comp += 8 if invoice_date: comp += 6 if client_reference: comp += 6 components["completeness"] = { "points": comp, "max": 20, "note": "Description, invoice date, and client reference provided", } # 5. Debt age (0-10) — fresher debt is more collectable. age = _age_days(invoice_date) if age is None: age_pts, age_note = 2, "Invoice date not provided" elif age <= 90: age_pts, age_note = 10, f"{age} days old (fresh)" elif age <= 180: age_pts, age_note = 8, f"{age} days old" elif age <= 365: age_pts, age_note = 6, f"{age} days old" elif age <= 730: age_pts, age_note = 3, f"{age} days old (stale)" else: age_pts, age_note = 2, f"{age} days old (very stale)" components["age"] = {"points": age_pts, "max": 10, "note": age_note} score = sum(c["points"] for c in components.values()) # Recommended starting tier from score band. if score >= 75: rec = "TIER_1" rec_note = "Strong case; soft touch should resolve" elif score >= 60: rec = "TIER_2" rec_note = "Solid case; begin with formal demand" elif score >= 40: rec = "TIER_3" rec_note = "Moderate case; escalate if initial contact fails" else: rec = "TIER_4" rec_note = "Weak or high-effort case; review carefully before accepting" summary = ( f"Analysis score {score}/100. {rec_note}. " f"Key factors: {components['amount']['note']}; " f"{components['documentation']['note']}; " f"{components['collectability']['note']}; " f"{components['age']['note']}." ) return { "score": score, "components": components, "summary": summary, "recommended_tier": rec, } def analyze_and_store(conn, claim_id: str, actor: str = "staff") -> dict: """Run score_claim for a claim and persist score/approval + audit row. Single source of truth for analysis persistence, shared by the staff /analyze endpoint (actor="staff") and post-intake auto-analysis (actor="system"). Returns the score_claim() result dict. Assumes the caller owns the transaction and will commit. """ row = conn.execute( "SELECT c.id, c.amount_cents, c.description, c.invoice_date, c.client_reference, " "d.business_type FROM claims c JOIN debtors d ON d.id = c.debtor_id " "WHERE c.id = ?", (claim_id,), ).fetchone() if row is None: raise ValueError(f"claim not found: {claim_id}") doc_count = conn.execute( "SELECT COUNT(*) AS n FROM documents WHERE claim_id = ?", (claim_id,) ).fetchone()["n"] result = score_claim( amount_cents=row["amount_cents"], business_type=row["business_type"], doc_count=doc_count, description=row["description"], invoice_date=row["invoice_date"], client_reference=row["client_reference"], ) now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") conn.execute( "UPDATE claims SET analysis_score = ?, analysis_summary = ?, analysis_components = ?, " "analysis_at = ?, recommended_tier = ?, approval_status = 'PENDING', updated_at = ? WHERE id = ?", (result["score"], result["summary"], json.dumps(result["components"]), now, result["recommended_tier"], now, claim_id), ) conn.execute( "INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, reason, created_at) " "VALUES (?, 'claim', ?, 'analysis', 'analysis_score', NULL, ?, ?, NULL, ?)", (str(uuid.uuid4()), claim_id, str(result["score"]), actor, now), ) return result # --------------------------------------------------------------- # Letter templates (Tier 1-4 + 2.5). Placeholders are filled from claim data. # FDCPA disclosure + payment link required on every queued letter. # --------------------------------------------------------------- _FDCPA = "This communication is from a debt collector attempting to collect a debt. Any information obtained will be used for that purpose." _PAY = "Payment can be made at: https://pay.debtrecoveryexperts.com" def _letter_template(tier: str) -> tuple[str, str]: """Return (subject, body) for the given tier. Body uses {placeholders}.""" if tier == "TIER_1": subject = "Outstanding Balance - {debtor_name}" body = ( "This is a friendly reminder regarding an outstanding balance with one of our clients.\n\n" "Client: {client_name}\n" "Amount Due: {amount_display}\n" "Invoice Reference: {invoice_ref}\n\n" "We understand things get overlooked. Please remit payment or contact us to discuss a " "resolution within 5 business days.\n\n" + _PAY + "\n\n" + _FDCPA + "\n\n" "- Debt Recovery Experts" ) elif tier == "TIER_2": subject = "FORMAL DEMAND FOR PAYMENT - {debtor_name}" body = ( "This letter constitutes formal demand for full payment of the outstanding balance described below.\n\n" "Client: {client_name}\n" "Amount Due: {amount_display}\n" "Invoice Reference: {invoice_ref}\n\n" "Despite previous attempts to resolve this matter amicably, the amount remains unpaid.\n\n" "PLEASE TAKE NOTICE that if the full balance is not received within fourteen (14) calendar days, " "we will proceed with escalated collection measures, including but not limited to referral to our " "legal department, filing of a civil suit to obtain judgment, and placement of liens against real " "or personal property.\n\n" "Contact our offices immediately to arrange payment or discuss a resolution.\n\n" + _PAY + "\n\n" + _FDCPA + "\n\n" "- Debt Recovery Experts\nCollections Department\ncollections@debtrecoveryexperts.com" ) elif tier == "TIER_2_5": subject = "NOTICE OF INTENT TO FILE LIEN - {debtor_name}" body = ( "This letter serves as formal notice of our intent to file a lien against the property described below.\n\n" "Client: {client_name}\n" "Property/Project: {debtor_address}\n" "Unpaid Amount: {amount_display}\n\n" "Texas Property Code allows for the filing of a mechanic's lien against real property where " "improvements were made and remain unpaid. We have been authorized to take the following actions:\n\n" "- Filing a Sworn Statement of Account with the county clerk\n" "- Recording a mechanic's lien against the property\n" "- Pursuing foreclosure on the lien if necessary\n\n" "A mechanic's lien will attach to the property title, affect your ability to sell or refinance, " "and appear on title searches.\n\n" "To avoid lien filing, full payment must be received within ten (10) calendar days.\n\n" + _PAY + "\n\n" + _FDCPA + "\n\n" "- Debt Recovery Experts\nCollections Department" ) elif tier == "TIER_3": subject = "FINAL NOTICE - IMMEDIATE ACTION REQUIRED" body = ( "FINAL NOTICE - This is your last opportunity to resolve this matter before legal action.\n\n" "Client: {client_name}\n" "Amount Due: {amount_display}\n" "Original Invoice Date: {invoice_date}\n\n" "Multiple attempts have been made to collect this debt. Despite these efforts, the full balance " "remains unpaid.\n\n" "UNLESS FULL PAYMENT IS RECEIVED WITHIN TEN (10) CALENDAR DAYS, we will refer this matter to our " "legal counsel, initiate civil litigation to obtain a judgment, pursue all available post-judgment " "remedies including wage garnishment, bank account levy, and asset seizure, and report this debt to " "credit reporting agencies.\n\n" "You may be held liable for court costs, attorney's fees, and additional interest.\n\n" "Contact our offices immediately. This is your final opportunity to resolve this without court " "intervention.\n\n" + _PAY + "\n\n" + _FDCPA + "\n\n" "- Debt Recovery Experts\nCollections Department" ) else: # TIER_4 subject = "LEGAL ACTION - {debtor_name}" body = ( "This letter confirms that your account has been referred for legal action.\n\n" "Client: {client_name}\n" "Amount Due: {amount_display}\n" "Legal Reference: {claim_number}\n\n" "Effective immediately, this matter has been forwarded to our legal counsel for lawsuit " "preparation. A civil petition will be filed seeking judgment for the full amount owed, " "pre-judgment interest as allowed by law, court costs and filing fees, and attorney's fees.\n\n" "Upon obtaining a judgment, we will pursue collection through all available legal channels, " "including wage garnishment, bank account levy, lien against real property, and post-judgment " "discovery of assets.\n\n" "All further communication regarding this matter should be directed to our legal counsel.\n\n" + _FDCPA + "\n\n" "- Debt Recovery Experts\nLegal Liaison Division" ) return subject, body def recommend_letter(*, tier: str, client_name: str, debtor_name: str, amount_display: str, invoice_ref: str, claim_number: str, debtor_address: str = "") -> dict: """Assemble the recommended letter for a claim's current tier.""" if tier not in _letter_tier_map(): tier = "TIER_1" subject, body = _letter_template(tier) subject = subject.format(debtor_name=debtor_name) body = body.format( debtor_name=debtor_name, client_name=client_name, amount_display=amount_display, invoice_ref=invoice_ref or "N/A", invoice_date=invoice_ref or "N/A", claim_number=claim_number, debtor_address=debtor_address or "Property address on file", ) return {"subject": subject, "body": body, "tier": tier} def _letter_tier_map() -> set[str]: return {"TIER_1", "TIER_2", "TIER_2_5", "TIER_3", "TIER_4"}