← back to Bankrupt Leads

src/extract.py

113 lines

"""
Asset extractor — local Ollama (default qwen3:14b) with JSON-mode.

Uses /api/chat format="json" so the model returns parsable output.
For tougher filings, swap EXTRACT_MODEL to gemma3:27b.
"""
from __future__ import annotations

import argparse
import os
from pathlib import Path

import psycopg

from llm import chat_json

PG_DSN = os.environ.get("BANKRUPT_LEADS_PG", "postgresql:///bankrupt_leads")
MODEL = os.environ.get("EXTRACT_MODEL", "qwen3:14b")

EXTRACT_SCHEMA = """
You extract saleable assets from US bankruptcy court filings (Ch. 7 / 11 / Subchapter V).

Input: the raw text of ONE filing -- typically a Schedule A/B, a 363 sale motion, an
auctioneer retention application, an asset purchase agreement (APA), or an abandonment
notice.

Output: a JSON object with a single key "assets" whose value is an array. Each array
element has this shape:

{
  "category":      "real_estate" | "equipment" | "vehicles" | "inventory" | "ip" | "receivables" | "other",
  "subcategory":   short free-text (e.g. "commercial kitchen", "CNC machine", "domain portfolio"),
  "description":   full description as written in the filing (preserve make/model/year),
  "location":      city/state or street address if present, else null,
  "book_value_usd": number or null,
  "secured_party": name of secured creditor if disclosed, else null,
  "sale_date":     ISO date if a hearing / auction / bid deadline is set, else null,
  "sale_venue":    "auction" | "private_sale" | "stalking_horse" | null,
  "contact_name":  trustee, auctioneer, or debtor counsel (best point of contact),
  "contact_email": email if present, else null,
  "contact_phone": phone if present, else null,
  "raw_excerpt":   verbatim excerpt (<=500 chars) the extraction was based on
}

Rules:
- If the filing contains NO saleable assets, return {"assets": []}.
- Never invent values. If a field is not in the text, use null.
- Collapse duplicate line items into one entry.
- Ignore exempt property (homestead, retirement, tools-of-trade exemptions).
- Return ONLY the JSON object.
"""


def load_filing_text(filing_id: str) -> tuple[str, str]:
    with psycopg.connect(PG_DSN) as conn, conn.cursor() as cur:
        cur.execute(
            "SELECT case_id, text_path FROM bankrupt_leads.filings WHERE filing_id = %s",
            (filing_id,),
        )
        row = cur.fetchone()
        if not row:
            raise SystemExit(f"filing {filing_id} not found")
        case_id, text_path = row
    return case_id, Path(text_path).read_text(encoding="utf-8", errors="replace")


def extract(filing_text: str) -> list[dict]:
    result = chat_json(
        MODEL,
        system=EXTRACT_SCHEMA,
        user=filing_text[:100_000],
        num_ctx=32768,
    )
    return result.get("assets", []) if isinstance(result, dict) else []


def persist(case_id: str, filing_id: str, assets: list[dict]) -> None:
    with psycopg.connect(PG_DSN) as conn, conn.cursor() as cur:
        for a in assets:
            cur.execute(
                """
                INSERT INTO bankrupt_leads.assets
                    (case_id, filing_id, category, subcategory, description, location,
                     book_value_usd, secured_party, sale_date, sale_venue,
                     contact_name, contact_email, contact_phone, raw_excerpt)
                VALUES (%(case_id)s, %(filing_id)s, %(category)s, %(subcategory)s,
                        %(description)s, %(location)s, %(book_value_usd)s, %(secured_party)s,
                        %(sale_date)s, %(sale_venue)s, %(contact_name)s, %(contact_email)s,
                        %(contact_phone)s, %(raw_excerpt)s)
                """,
                {**a, "case_id": case_id, "filing_id": filing_id},
            )
        cur.execute(
            "UPDATE bankrupt_leads.filings SET extracted_at = now() WHERE filing_id = %s",
            (filing_id,),
        )
        conn.commit()


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--filing-id", required=True)
    args = ap.parse_args()

    case_id, text = load_filing_text(args.filing_id)
    assets = extract(text)
    print(f"extracted {len(assets)} asset(s) from {args.filing_id}")
    persist(case_id, args.filing_id, assets)


if __name__ == "__main__":
    main()