Files
dre/backend/analysis.py
T
root 7a62b0b340 Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate
- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake)
- packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog)
- letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified)
- docuseal.py: DocuSeal signing integration
- staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table
- frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard)
- landing-mockups/: 4 design-stance mockups + favicons
- legal/: aup/privacy/sms-terms/terms HTML
- docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates
- review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint
- compliance/DRE_Compliance_Manual.md: updated

Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
2026-08-26 02:26:33 -04:00

321 lines
14 KiB
Python

"""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"}