← 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()