N0: audit infrastructure (read-only helpers, hash-verified 2324b58 snapshot, spid import shim, schema probe)
Stage D node batch N0 for FIRC-0001. sha256 of the nine snapshot files equals the node copies (extract/llm.py, rules.py, base.py, taxonomy.py, resolve.py mtime 2026-06-17); both DuckDB files open read_only=True; TEMP tables allowed; DuckDB temp_directory redirected to ~/firc0001_review/tmp. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
23 changed files +3,242 −0
added
code/audit/a00_probe_schema.py
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +"""N0 probe: open both DuckDB files read-only, describe the tables used by N1-N13.""" | |
| 2 | +import sys, json | |
| 3 | +from pathlib import Path | |
| 4 | +sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| 5 | +from common import * | |
| 6 | + | |
| 7 | +r = Run("n0_probe") | |
| 8 | +con = connect(SPID_DB) | |
| 9 | +out = {} | |
| 10 | +for t in ["extracted_sections", "project_mentions", "projects", "project_timeline", "spid_sections", "v_sections", "src_filings", "src_sections", "src_companies"]: | |
| 11 | + out[t] = [tuple(x[:2]) for x in con.execute(f"DESCRIBE {t}").fetchall()] | |
| 12 | +out["views"] = con.execute("select view_name, sql from duckdb_views() where not internal").fetchall() | |
| 13 | +out["counts"] = {t: con.execute(f"select count(*) from {t}").fetchone()[0] for t in ["extracted_sections", "project_mentions", "projects", "project_timeline", "spid_sections"]} | |
| 14 | +out["ledger_words"] = con.execute("select count(*), sum(v.word_count) from extracted_sections e join (select section_id, word_count from src_sections union all select section_id, word_count from spid_sections) v using(section_id)").fetchone() | |
| 15 | +out["parquet_files"] = con.execute("select count(*), sum(length(text))/1e9 from src_sections").fetchone() | |
| 16 | +e = connect(EDGAR_DB) | |
| 17 | +for t in ["filings", "filing_sections", "companies", "annual_panel"]: | |
| 18 | + out["edgar_"+t] = [tuple(x[:2]) for x in e.execute(f"DESCRIBE {t}").fetchall()] | |
| 19 | +out["edgar_tables"] = [x[0] for x in e.execute("select table_name from duckdb_tables() where not internal").fetchall()] | |
| 20 | +r.write_json("schema.json", out) | |
| 21 | +# can we create a temp table on a read_only connection? | |
| 22 | +try: | |
| 23 | + con.execute("CREATE TEMP TABLE _t AS SELECT 1 AS x"); out["temp_ok"] = True | |
| 24 | +except Exception as ex: | |
| 25 | + out["temp_ok"] = str(ex) | |
| 26 | +r.write_json("schema.json", out) | |
| 27 | +r.finish() | |
added
code/audit/common.py
+116 −0
@@ -0,0 +1,116 @@ | ||
| 1 | +"""Shared helpers for the FIRC-0001 Stage D read-only audits (run on M3U96b). | |
| 2 | + | |
| 3 | +Every script opens the two DuckDB files with read_only=True, SET threads=4, | |
| 4 | +SET memory_limit='24GB', writes CSV/JSON under ~/firc0001_review/results/<name>/ | |
| 5 | +and a manifest.json (script sha256, DuckDB mtimes, start/end, row counts). | |
| 6 | +Nothing is ever written under ~/UQO_RESEARCH_DB. | |
| 7 | +""" | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import csv | |
| 11 | +import datetime as dt | |
| 12 | +import hashlib | |
| 13 | +import json | |
| 14 | +import os | |
| 15 | +import sys | |
| 16 | +import time | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +import duckdb | |
| 20 | + | |
| 21 | +HOME = Path(os.environ.get("HOME", "~")).expanduser() | |
| 22 | +RDB = HOME / "UQO_RESEARCH_DB" | |
| 23 | +SPID_DB = RDB / "project-db" / "data" / "db" / "spid.duckdb" | |
| 24 | +EDGAR_DB = RDB / "data" / "duckdb" / "edgar_research.duckdb" | |
| 25 | +WORK = HOME / "firc0001_review" | |
| 26 | +RESULTS = WORK / "results" | |
| 27 | +THREADS = int(os.environ.get("FIRC_THREADS", "4")) | |
| 28 | +MEMLIMIT = os.environ.get("FIRC_MEMLIMIT", "24GB") | |
| 29 | +SEED = 20261105 | |
| 30 | + | |
| 31 | + | |
| 32 | +def connect(path: Path = SPID_DB) -> duckdb.DuckDBPyConnection: | |
| 33 | + con = duckdb.connect(str(path), read_only=True) | |
| 34 | + con.execute(f"SET threads={THREADS}") | |
| 35 | + con.execute(f"SET memory_limit='{MEMLIMIT}'") | |
| 36 | + con.execute("SET preserve_insertion_order=false") | |
| 37 | + # spill files must never land next to the databases (default = <db>.tmp) | |
| 38 | + (WORK / "tmp").mkdir(parents=True, exist_ok=True) | |
| 39 | + con.execute(f"SET temp_directory='{WORK / 'tmp'}'") | |
| 40 | + return con | |
| 41 | + | |
| 42 | + | |
| 43 | +def attach_edgar(con: duckdb.DuckDBPyConnection, alias: str = "edgar") -> None: | |
| 44 | + con.execute(f"ATTACH '{EDGAR_DB}' AS {alias} (READ_ONLY)") | |
| 45 | + | |
| 46 | + | |
| 47 | +def sha256_file(p: Path) -> str: | |
| 48 | + h = hashlib.sha256() | |
| 49 | + with open(p, "rb") as f: | |
| 50 | + for chunk in iter(lambda: f.read(1 << 20), b""): | |
| 51 | + h.update(chunk) | |
| 52 | + return h.hexdigest() | |
| 53 | + | |
| 54 | + | |
| 55 | +class Run: | |
| 56 | + """Context for one audit script: output dir, timing, manifest.""" | |
| 57 | + | |
| 58 | + def __init__(self, name: str, script: str | None = None, out: str | None = None): | |
| 59 | + self.name = name | |
| 60 | + self.script = Path(script or sys.argv[0]).resolve() | |
| 61 | + self.out = Path(out) if out else RESULTS / name | |
| 62 | + self.out.mkdir(parents=True, exist_ok=True) | |
| 63 | + self.t0 = time.time() | |
| 64 | + self.started = dt.datetime.now().isoformat(timespec="seconds") | |
| 65 | + self.rows: dict[str, int] = {} | |
| 66 | + self.notes: dict = {} | |
| 67 | + | |
| 68 | + def write_csv(self, fname: str, cols: list[str], rows) -> int: | |
| 69 | + rows = list(rows) | |
| 70 | + with open(self.out / fname, "w", newline="") as f: | |
| 71 | + w = csv.writer(f) | |
| 72 | + w.writerow(cols) | |
| 73 | + w.writerows(rows) | |
| 74 | + self.rows[fname] = len(rows) | |
| 75 | + print(f"[{self.name}] {fname}: {len(rows)} rows", flush=True) | |
| 76 | + return len(rows) | |
| 77 | + | |
| 78 | + def dump(self, fname: str, con, sql: str, params=None): | |
| 79 | + cur = con.execute(sql, params) if params else con.execute(sql) | |
| 80 | + cols = [d[0] for d in cur.description] | |
| 81 | + return self.write_csv(fname, cols, cur.fetchall()) | |
| 82 | + | |
| 83 | + def write_json(self, fname: str, obj) -> None: | |
| 84 | + with open(self.out / fname, "w") as f: | |
| 85 | + json.dump(obj, f, indent=2, default=str) | |
| 86 | + print(f"[{self.name}] {fname} written", flush=True) | |
| 87 | + | |
| 88 | + def finish(self, extra: dict | None = None) -> None: | |
| 89 | + man = { | |
| 90 | + "package": self.name, | |
| 91 | + "script": str(self.script), | |
| 92 | + "script_sha256": sha256_file(self.script) if self.script.exists() else None, | |
| 93 | + "started": self.started, | |
| 94 | + "ended": dt.datetime.now().isoformat(timespec="seconds"), | |
| 95 | + "wall_seconds": round(time.time() - self.t0, 1), | |
| 96 | + "node": os.uname().nodename, | |
| 97 | + "python": sys.version.split()[0], | |
| 98 | + "duckdb": duckdb.__version__, | |
| 99 | + "threads": THREADS, | |
| 100 | + "memory_limit": MEMLIMIT, | |
| 101 | + "seed": SEED, | |
| 102 | + "databases": { | |
| 103 | + str(SPID_DB): {"bytes": SPID_DB.stat().st_size, | |
| 104 | + "mtime": dt.datetime.fromtimestamp(SPID_DB.stat().st_mtime).isoformat()}, | |
| 105 | + str(EDGAR_DB): {"bytes": EDGAR_DB.stat().st_size, | |
| 106 | + "mtime": dt.datetime.fromtimestamp(EDGAR_DB.stat().st_mtime).isoformat()}, | |
| 107 | + }, | |
| 108 | + "read_only": True, | |
| 109 | + "row_counts": self.rows, | |
| 110 | + "notes": self.notes, | |
| 111 | + } | |
| 112 | + if extra: | |
| 113 | + man.update(extra) | |
| 114 | + with open(self.out / "manifest.json", "w") as f: | |
| 115 | + json.dump(man, f, indent=2, default=str) | |
| 116 | + print(f"[{self.name}] done in {man['wall_seconds']} s", flush=True) | |
added
code/audit/run_all.sh
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# Stage D node batch for FIRC-0001: runs N1..N13 in the plan's order, sequentially, read-only. | |
| 3 | +# usage: bash run_all.sh (on M3U96b, from ~/firc0001_review; scripts/ holds the aNN_*.py copies) | |
| 4 | +set -u | |
| 5 | +cd "$HOME/firc0001_review" | |
| 6 | +ORDER="a01_ledger_reconciliation.py a03_resolver_speccurve.py a04_runs_segmenters.py a04_git_evidence.sh a02_grounding.py a05_focus_coverage.py a06_false_positive_classes.py a09_timeseries_cohort.py a10_universe_artefacts.py a07_8k_item_benchmark.py a08_rules_baseline.py a11_annotation_frame.py a12_env_lock.sh a13_export_stats_check.sh" | |
| 7 | +for S in $ORDER; do | |
| 8 | + bash scripts/run_one.sh "$S" | |
| 9 | + NAME=$(basename "$S" | sed -E 's/\.(py|sh)$//') | |
| 10 | + while [ ! -f "logs/$NAME.done" ]; do sleep 60; done | |
| 11 | + cat "logs/$NAME.done" | |
| 12 | +done | |
added
code/audit/run_one.sh
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +#!/bin/bash | |
| 2 | +# Run one audit script on M3U96b in the background under nice, with a log and a done-marker. | |
| 3 | +# usage: run_one.sh <script.py|script.sh> [args...] (from ~/firc0001_review) | |
| 4 | +set -u | |
| 5 | +cd "$HOME/firc0001_review" | |
| 6 | +S="$1"; shift | |
| 7 | +NAME=$(basename "$S" | sed -E 's/\.(py|sh)$//') | |
| 8 | +LOG="logs/$NAME.log"; DONE="logs/$NAME.done" | |
| 9 | +rm -f "$DONE" | |
| 10 | +PY="$HOME/UQO_RESEARCH_DB/.venv/bin/python" | |
| 11 | +if [[ "$S" == *.sh ]]; then CMD="bash scripts/$S $*"; else CMD="$PY -I scripts/$S $*"; fi | |
| 12 | +nohup bash -c "echo START \$(date -Iseconds); nice -n 10 env OMP_NUM_THREADS=4 FIRC_THREADS=4 $CMD; echo RC=\$? END \$(date -Iseconds) > $DONE" > "$LOG" 2>&1 & | |
| 13 | +echo "started $NAME pid=$! log=$LOG" | |
added
code/audit/spid/__init__.py
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +"""spid package shim: hash-verified snapshot copies of the 2324b58 extractor modules (see spid_snapshot/SHA256SUMS).""" | |
added
code/audit/spid/config.py
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — configuration & paths | |
| 3 | +# ============================================================ | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +import os | |
| 8 | +from dataclasses import dataclass, field | |
| 9 | +from functools import lru_cache | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +# project-db/ (this file lives in project-db/spid/config.py) | |
| 13 | +PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| 14 | + | |
| 15 | + | |
| 16 | +def _load_dotenv() -> None: | |
| 17 | + """Minimal .env loader (no python-dotenv dependency required).""" | |
| 18 | + env_path = PROJECT_ROOT / ".env" | |
| 19 | + if not env_path.exists(): | |
| 20 | + return | |
| 21 | + for line in env_path.read_text().splitlines(): | |
| 22 | + line = line.strip() | |
| 23 | + if not line or line.startswith("#") or "=" not in line: | |
| 24 | + continue | |
| 25 | + key, _, val = line.partition("=") | |
| 26 | + key, val = key.strip(), val.strip().strip('"').strip("'") | |
| 27 | + os.environ.setdefault(key, val) | |
| 28 | + | |
| 29 | + | |
| 30 | +_load_dotenv() | |
| 31 | +# The parent UQO_RESEARCH_DB that holds the parsed corpus. | |
| 32 | +RESEARCH_ROOT = PROJECT_ROOT.parent | |
| 33 | +SOURCE_DATA = RESEARCH_ROOT / "data" | |
| 34 | + | |
| 35 | + | |
| 36 | +def _parse_scope(spec: str) -> dict: | |
| 37 | + out: dict = {} | |
| 38 | + for part in (spec or "").split(";"): | |
| 39 | + part = part.strip() | |
| 40 | + if not part or ":" not in part: | |
| 41 | + continue | |
| 42 | + form, items = part.split(":", 1) | |
| 43 | + out[form.strip()] = [i.strip() for i in items.split(",") if i.strip()] | |
| 44 | + return out | |
| 45 | + | |
| 46 | + | |
| 47 | +@dataclass(frozen=True) | |
| 48 | +class Settings: | |
| 49 | + """Resolved runtime configuration for the SPID platform.""" | |
| 50 | + | |
| 51 | + # --- source corpus (read-only) ------------------------------------- | |
| 52 | + source_data: Path = SOURCE_DATA | |
| 53 | + parquet_dir: Path = SOURCE_DATA / "parquet" | |
| 54 | + processed_dir: Path = SOURCE_DATA / "processed" | |
| 55 | + | |
| 56 | + @property | |
| 57 | + def filings_parquet(self) -> Path: | |
| 58 | + return self.parquet_dir / "filings" / "filings.parquet" | |
| 59 | + | |
| 60 | + @property | |
| 61 | + def sections_parquet(self) -> Path: | |
| 62 | + return self.parquet_dir / "filing_sections" / "filing_sections.parquet" | |
| 63 | + | |
| 64 | + @property | |
| 65 | + def companies_parquet(self) -> Path: | |
| 66 | + return self.parquet_dir / "companies" / "companies.parquet" | |
| 67 | + | |
| 68 | + # --- SPID outputs (writable) --------------------------------------- | |
| 69 | + db_path: Path = PROJECT_ROOT / "data" / "db" / "spid.duckdb" | |
| 70 | + out_dir: Path = PROJECT_ROOT / "data" | |
| 71 | + | |
| 72 | + # --- extraction ---------------------------------------------------- | |
| 73 | + backend: str = field( | |
| 74 | + default_factory=lambda: os.environ.get("SPID_BACKEND", "llm") | |
| 75 | + ) # "llm" (gpt-4o-mini) | "rules" | |
| 76 | + min_confidence: float = 0.45 | |
| 77 | + # which parsed sections feed the extractor, per form (None/empty = every section) | |
| 78 | + section_scope: dict = field(default_factory=lambda: _parse_scope(os.environ.get( | |
| 79 | + "SPID_SECTION_SCOPE", | |
| 80 | + "10-K:business,risk_factors,properties,md_and_a,quantitative_disclosures"))) | |
| 81 | + max_section_chars: int = 600_000 | |
| 82 | + | |
| 83 | + # --- embeddings ---------------------------------------------------- | |
| 84 | + embedding_model: str = field( | |
| 85 | + default_factory=lambda: os.environ.get("SPID_EMBED_MODEL", "all-MiniLM-L6-v2") | |
| 86 | + ) | |
| 87 | + | |
| 88 | + # --- LLM backend (OpenAI-compatible) ------------------------------- | |
| 89 | + llm_base_url: str = field( | |
| 90 | + default_factory=lambda: os.environ.get( | |
| 91 | + "SPID_LLM_BASE_URL", "https://api.openai.com/v1" | |
| 92 | + ) | |
| 93 | + ) | |
| 94 | + llm_model: str = field( | |
| 95 | + default_factory=lambda: os.environ.get("SPID_LLM_MODEL", "gpt-4o-mini") | |
| 96 | + ) | |
| 97 | + llm_api_key: str = field( | |
| 98 | + default_factory=lambda: os.environ.get("OPENAI_API_KEY", "") | |
| 99 | + or os.environ.get("MACLUSTR_API_KEY", "") | |
| 100 | + ) | |
| 101 | + llm_timeout: float = 120.0 | |
| 102 | + | |
| 103 | + def ensure_dirs(self) -> None: | |
| 104 | + self.db_path.parent.mkdir(parents=True, exist_ok=True) | |
| 105 | + (self.out_dir / "exports").mkdir(parents=True, exist_ok=True) | |
| 106 | + | |
| 107 | + | |
| 108 | +@lru_cache(maxsize=1) | |
| 109 | +def get_settings() -> Settings: | |
| 110 | + s = Settings() | |
| 111 | + s.ensure_dirs() | |
| 112 | + return s | |
| 113 | + | |
| 114 | + | |
| 115 | +def get_logger(name: str) -> logging.Logger: | |
| 116 | + logger = logging.getLogger(f"spid.{name}") | |
| 117 | + if not logger.handlers: | |
| 118 | + h = logging.StreamHandler() | |
| 119 | + h.setFormatter( | |
| 120 | + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s | %(message)s", | |
| 121 | + datefmt="%H:%M:%S") | |
| 122 | + ) | |
| 123 | + logger.addHandler(h) | |
| 124 | + logger.setLevel(os.environ.get("SPID_LOG_LEVEL", "INFO")) | |
| 125 | + logger.propagate = False | |
| 126 | + return logger | |
added
code/audit/spid/extract/__init__.py
+0 −0
added
code/audit/spid/extract/base.py
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — extraction interface & record schema | |
| 3 | +# ============================================================ | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import hashlib | |
| 7 | +from dataclasses import dataclass, field, asdict | |
| 8 | +from typing import Protocol | |
| 9 | + | |
| 10 | + | |
| 11 | +@dataclass | |
| 12 | +class ProjectMention: | |
| 13 | + """A single project signal extracted from one filing section. | |
| 14 | + | |
| 15 | + Mentions are the atomic unit; `resolve.py` merges many mentions | |
| 16 | + (across filings & years) into a single Project with a timeline. | |
| 17 | + """ | |
| 18 | + mention_id: str | |
| 19 | + accession_number: str | |
| 20 | + cik: str | |
| 21 | + section_id: str | |
| 22 | + section_name: str | |
| 23 | + | |
| 24 | + project_name: str | |
| 25 | + project_type: str | |
| 26 | + description: str | |
| 27 | + objective: str = "" | |
| 28 | + | |
| 29 | + amount_usd: float | None = None | |
| 30 | + amount_raw: str | None = None | |
| 31 | + locations: list[str] = field(default_factory=list) | |
| 32 | + technologies: list[str] = field(default_factory=list) | |
| 33 | + partners: list[str] = field(default_factory=list) | |
| 34 | + suppliers: list[str] = field(default_factory=list) | |
| 35 | + benefits: list[str] = field(default_factory=list) | |
| 36 | + risks: list[str] = field(default_factory=list) | |
| 37 | + years: list[int] = field(default_factory=list) | |
| 38 | + status: str = "mentioned" | |
| 39 | + | |
| 40 | + confidence: float = 0.0 | |
| 41 | + backend: str = "rules" | |
| 42 | + | |
| 43 | + def to_row(self) -> dict: | |
| 44 | + d = asdict(self) | |
| 45 | + # store list fields as JSON-friendly via duckdb list[str] | |
| 46 | + return d | |
| 47 | + | |
| 48 | + @staticmethod | |
| 49 | + def make_id(accession: str, section_id: str, ptype: str, anchor: str) -> str: | |
| 50 | + h = hashlib.sha1(f"{accession}|{section_id}|{ptype}|{anchor}".encode()).hexdigest() | |
| 51 | + return h[:20] | |
| 52 | + | |
| 53 | + | |
| 54 | +class Extractor(Protocol): | |
| 55 | + name: str | |
| 56 | + | |
| 57 | + def extract(self, *, text: str, accession_number: str, cik: str, | |
| 58 | + section_id: str, section_name: str) -> list[ProjectMention]: | |
| 59 | + ... | |
| 60 | + | |
| 61 | + | |
| 62 | +def get_extractor(backend: str): | |
| 63 | + """Factory. `backend` in {'rules','llm'}.""" | |
| 64 | + backend = (backend or "rules").lower() | |
| 65 | + if backend == "llm": | |
| 66 | + from spid.extract.llm import LLMExtractor | |
| 67 | + return LLMExtractor() | |
| 68 | + from spid.extract.rules import RuleExtractor | |
| 69 | + return RuleExtractor() | |
added
code/audit/spid/extract/llm.py
+202 −0
@@ -0,0 +1,202 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — LLM extractor (OpenAI-compatible; default gpt-4o-mini) | |
| 3 | +# | |
| 4 | +# Uses the chat-completions JSON mode to extract structured | |
| 5 | +# project records. Cost-controlled: only project-relevant | |
| 6 | +# windows of each section are sent to the model. | |
| 7 | +# ============================================================ | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import json | |
| 11 | +import re | |
| 12 | +import time | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | + | |
| 16 | +from spid import taxonomy as tx | |
| 17 | +from spid.config import get_logger, get_settings | |
| 18 | +from spid.extract.base import ProjectMention | |
| 19 | +from spid.extract.rules import _PROJECT_GATE | |
| 20 | + | |
| 21 | +log = get_logger("extract.llm") | |
| 22 | + | |
| 23 | +_SYSTEM = """You are a financial analyst extracting CORPORATE PROJECTS from SEC filing text. | |
| 24 | +A "project" is any concrete initiative, investment, program, expansion, construction, | |
| 25 | +product/R&D effort, acquisition/integration, technology deployment, partnership, or | |
| 26 | +strategic program the company is pursuing. | |
| 27 | + | |
| 28 | +Return STRICT JSON: {"projects": [ ... ]}. Each project object has: | |
| 29 | + project_name : short descriptive name (use the official name if one is stated) | |
| 30 | + project_type : EXACTLY ONE of the allowed types below | |
| 31 | + description : 1 sentence, grounded in the text (no speculation) | |
| 32 | + objective : the goal/why (or "") | |
| 33 | + status : one of planned | in_progress | completed | mentioned | |
| 34 | + amount_usd : numeric USD figure if stated (e.g. 1500000000), else null | |
| 35 | + locations : array of places (city/state/country), else [] | |
| 36 | + technologies : array of technologies involved, else [] | |
| 37 | + partners : array of named partners/JV counterparties, else [] | |
| 38 | + benefits : array of expected benefits, else [] | |
| 39 | + risks : array of stated risks, else [] | |
| 40 | + years : array of 4-digit years mentioned, else [] | |
| 41 | + confidence : 0.0-1.0 (how clearly this is a real, concrete project) | |
| 42 | + | |
| 43 | +Allowed project_type values: | |
| 44 | +%s | |
| 45 | + | |
| 46 | +Rules: | |
| 47 | +- Extract ONLY real initiatives, not generic risk-factor boilerplate or hypotheticals. | |
| 48 | +- If the text describes no concrete project, return {"projects": []}. | |
| 49 | +- Do not invent facts. Numbers/locations must appear in the text. | |
| 50 | +- Prefer fewer high-quality projects over many vague ones.""" % "\n".join( | |
| 51 | + f" - {k}: {v}" for k, v in tx.TYPE_LABELS.items() | |
| 52 | +) | |
| 53 | + | |
| 54 | + | |
| 55 | +def _focus(text: str, budget: int = 9000) -> str: | |
| 56 | + """Keep only windows around project-signal hits to bound token cost.""" | |
| 57 | + if len(text) <= budget: | |
| 58 | + return text | |
| 59 | + spans: list[tuple[int, int]] = [] | |
| 60 | + for m in _PROJECT_GATE.finditer(text): | |
| 61 | + spans.append((max(0, m.start() - 220), min(len(text), m.end() + 380))) | |
| 62 | + if sum(b - a for a, b in spans) > budget * 1.4: | |
| 63 | + break | |
| 64 | + if not spans: | |
| 65 | + return text[:budget] | |
| 66 | + # merge overlapping windows | |
| 67 | + spans.sort() | |
| 68 | + merged = [spans[0]] | |
| 69 | + for a, b in spans[1:]: | |
| 70 | + if a <= merged[-1][1]: | |
| 71 | + merged[-1] = (merged[-1][0], max(merged[-1][1], b)) | |
| 72 | + else: | |
| 73 | + merged.append((a, b)) | |
| 74 | + chunks, total = [], 0 | |
| 75 | + for a, b in merged: | |
| 76 | + chunks.append(text[a:b]) | |
| 77 | + total += b - a | |
| 78 | + if total >= budget: | |
| 79 | + break | |
| 80 | + return "\n...\n".join(chunks) | |
| 81 | + | |
| 82 | + | |
| 83 | +class LLMExtractor: | |
| 84 | + name = "llm" | |
| 85 | + | |
| 86 | + def __init__(self) -> None: | |
| 87 | + self.s = get_settings() | |
| 88 | + if not self.s.llm_api_key: | |
| 89 | + raise RuntimeError("No LLM API key configured (OPENAI_API_KEY / MACLUSTR_API_KEY).") | |
| 90 | + self._client = httpx.Client( | |
| 91 | + base_url=self.s.llm_base_url, | |
| 92 | + headers={"Authorization": f"Bearer {self.s.llm_api_key}"}, | |
| 93 | + timeout=self.s.llm_timeout, | |
| 94 | + ) | |
| 95 | + | |
| 96 | + def _call(self, content: str, retries: int = 3) -> dict: | |
| 97 | + payload = { | |
| 98 | + "model": self.s.llm_model, | |
| 99 | + "messages": [ | |
| 100 | + {"role": "system", "content": _SYSTEM}, | |
| 101 | + {"role": "user", "content": content}, | |
| 102 | + ], | |
| 103 | + "temperature": 0, | |
| 104 | + "response_format": {"type": "json_object"}, | |
| 105 | + "max_tokens": 2000, | |
| 106 | + } | |
| 107 | + last = None | |
| 108 | + for attempt in range(retries): | |
| 109 | + try: | |
| 110 | + r = self._client.post("/chat/completions", json=payload) | |
| 111 | + if r.status_code == 429 or r.status_code >= 500: | |
| 112 | + raise httpx.HTTPStatusError("retryable", request=r.request, response=r) | |
| 113 | + r.raise_for_status() | |
| 114 | + txt = r.json()["choices"][0]["message"]["content"] | |
| 115 | + return json.loads(txt) | |
| 116 | + except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: | |
| 117 | + last = e | |
| 118 | + time.sleep(1.5 * (attempt + 1)) | |
| 119 | + log.warning("LLM call failed after %d retries: %s", retries, last) | |
| 120 | + return {"projects": []} | |
| 121 | + | |
| 122 | + def extract(self, *, text: str, accession_number: str, cik: str, | |
| 123 | + section_id: str, section_name: str) -> list[ProjectMention]: | |
| 124 | + if not text or not _PROJECT_GATE.search(text): | |
| 125 | + return [] | |
| 126 | + content = (f"SECTION: {section_name}\n\n" + _focus(text))[:14000] | |
| 127 | + data = self._call(content) | |
| 128 | + out: list[ProjectMention] = [] | |
| 129 | + for p in data.get("projects", []) or []: | |
| 130 | + if not isinstance(p, dict): | |
| 131 | + continue | |
| 132 | + ptype = self._norm_type(p.get("project_type", "")) | |
| 133 | + desc = str(p.get("description", "")).strip()[:600] | |
| 134 | + if not desc: | |
| 135 | + continue | |
| 136 | + amount = p.get("amount_usd") | |
| 137 | + try: | |
| 138 | + amount = float(amount) if amount not in (None, "", "null") else None | |
| 139 | + except (TypeError, ValueError): | |
| 140 | + amount = None | |
| 141 | + anchor = f"{ptype}:{str(p.get('project_name',''))[:40]}" | |
| 142 | + conf = p.get("confidence", 0.6) | |
| 143 | + try: | |
| 144 | + conf = float(conf) | |
| 145 | + except (TypeError, ValueError): | |
| 146 | + conf = 0.6 | |
| 147 | + out.append(ProjectMention( | |
| 148 | + mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor), | |
| 149 | + accession_number=accession_number, cik=cik, | |
| 150 | + section_id=section_id, section_name=section_name, | |
| 151 | + project_name=str(p.get("project_name") or tx.TYPE_LABELS.get(ptype, ptype))[:200], | |
| 152 | + project_type=ptype, description=desc, | |
| 153 | + objective=str(p.get("objective", ""))[:400], | |
| 154 | + amount_usd=amount, | |
| 155 | + amount_raw=(f"${amount:,.0f}" if amount else None), | |
| 156 | + locations=_strlist(p.get("locations")), | |
| 157 | + technologies=_strlist(p.get("technologies")) or tx.extract_technologies(desc), | |
| 158 | + partners=_strlist(p.get("partners")), | |
| 159 | + benefits=_strlist(p.get("benefits")), | |
| 160 | + risks=_strlist(p.get("risks")), | |
| 161 | + years=_intlist(p.get("years")) or tx.extract_years(desc), | |
| 162 | + status=self._norm_status(p.get("status")), | |
| 163 | + confidence=round(max(0.0, min(1.0, conf)), 3), | |
| 164 | + backend=self.name, | |
| 165 | + )) | |
| 166 | + return out | |
| 167 | + | |
| 168 | + @staticmethod | |
| 169 | + def _norm_type(raw: str) -> str: | |
| 170 | + raw = (raw or "").strip().lower().replace(" ", "_").replace("-", "_") | |
| 171 | + if raw in tx.PROJECT_TYPES: | |
| 172 | + return raw | |
| 173 | + # fuzzy contains match against labels/keys | |
| 174 | + for k, label in tx.TYPE_LABELS.items(): | |
| 175 | + if raw and (raw in k or raw in label.lower().replace(" ", "_")): | |
| 176 | + return k | |
| 177 | + return "technology_deployment" | |
| 178 | + | |
| 179 | + @staticmethod | |
| 180 | + def _norm_status(raw) -> str: | |
| 181 | + raw = (str(raw or "")).strip().lower() | |
| 182 | + return raw if raw in ("planned", "in_progress", "completed", "mentioned") else "mentioned" | |
| 183 | + | |
| 184 | + | |
| 185 | +def _strlist(v) -> list[str]: | |
| 186 | + if not v: | |
| 187 | + return [] | |
| 188 | + if isinstance(v, str): | |
| 189 | + v = [v] | |
| 190 | + return [str(x).strip()[:120] for x in v if str(x).strip()][:8] | |
| 191 | + | |
| 192 | + | |
| 193 | +def _intlist(v) -> list[int]: | |
| 194 | + out = [] | |
| 195 | + for x in (v or []): | |
| 196 | + try: | |
| 197 | + y = int(str(x)[:4]) | |
| 198 | + if 1980 <= y <= 2040: | |
| 199 | + out.append(y) | |
| 200 | + except (TypeError, ValueError): | |
| 201 | + continue | |
| 202 | + return sorted(set(out)) | |
added
code/audit/spid/extract/rules.py
+120 −0
@@ -0,0 +1,120 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — deterministic rule-based extractor | |
| 3 | +# | |
| 4 | +# High-precision baseline that needs NO LLM. Scans section text | |
| 5 | +# sentence-by-sentence for project signals, classifies the type, | |
| 6 | +# and pulls money / location / tech / year / status. | |
| 7 | +# ============================================================ | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import re | |
| 11 | + | |
| 12 | +from spid import taxonomy as tx | |
| 13 | +from spid.extract.base import ProjectMention | |
| 14 | + | |
| 15 | +# split into sentence-ish units; keep it cheap for 100k+ sections | |
| 16 | +_SENT_SPLIT = re.compile(r"(?<=[.;:!?])\s+(?=[A-Z(\"$])") | |
| 17 | +_WS = re.compile(r"\s+") | |
| 18 | +# generic "project-ish" gate so we skip boilerplate sentences fast | |
| 19 | +_PROJECT_GATE = re.compile( | |
| 20 | + r"\b(project|initiative|program|invest|expan|construct|build|launch|develop|" | |
| 21 | + r"deploy|implement|acqui|partner|facilit|capacity|capital|migrat|transform|" | |
| 22 | + r"pipeline|plant|data ?cent|automat|sustainab|renewable|restructur)\w*", | |
| 23 | + re.IGNORECASE, | |
| 24 | +) | |
| 25 | +_NAMED_PROJECT = re.compile( | |
| 26 | + r"\b((?:Project|Program|Initiative)\s+[A-Z][A-Za-z0-9\-]+" | |
| 27 | + r"|[A-Z][A-Za-z0-9\-]+\s+(?:Project|Program|Initiative))\b" | |
| 28 | +) | |
| 29 | + | |
| 30 | + | |
| 31 | +def _clean(s: str) -> str: | |
| 32 | + return _WS.sub(" ", s).strip() | |
| 33 | + | |
| 34 | + | |
| 35 | +class RuleExtractor: | |
| 36 | + name = "rules" | |
| 37 | + | |
| 38 | + def extract(self, *, text: str, accession_number: str, cik: str, | |
| 39 | + section_id: str, section_name: str) -> list[ProjectMention]: | |
| 40 | + if not text: | |
| 41 | + return [] | |
| 42 | + text = text[:600_000] | |
| 43 | + out: list[ProjectMention] = [] | |
| 44 | + seen: set[str] = set() | |
| 45 | + | |
| 46 | + for raw_sent in _SENT_SPLIT.split(text): | |
| 47 | + sent = _clean(raw_sent) | |
| 48 | + if not (20 <= len(sent) <= 700): | |
| 49 | + continue | |
| 50 | + if not _PROJECT_GATE.search(sent): | |
| 51 | + continue | |
| 52 | + | |
| 53 | + low = sent.lower() | |
| 54 | + matched_types: list[str] = [] | |
| 55 | + for ptype, regexes in tx.TYPE_SIGNALS.items(): | |
| 56 | + if any(rx.search(low) for rx in regexes): | |
| 57 | + matched_types.append(ptype) | |
| 58 | + if not matched_types: | |
| 59 | + continue | |
| 60 | + | |
| 61 | + amount, amount_raw = tx.extract_money(sent) | |
| 62 | + locations = tx.extract_locations(sent) | |
| 63 | + techs = tx.extract_technologies(sent) | |
| 64 | + years = tx.extract_years(sent) | |
| 65 | + status = tx.detect_status(sent) | |
| 66 | + nm = _NAMED_PROJECT.search(sent) | |
| 67 | + | |
| 68 | + # keep at most the 2 strongest types for one sentence | |
| 69 | + for ptype in matched_types[:2]: | |
| 70 | + anchor = f"{ptype}:{(locations[0] if locations else '')}:{int(amount or 0)}" | |
| 71 | + if anchor in seen: | |
| 72 | + continue | |
| 73 | + seen.add(anchor) | |
| 74 | + | |
| 75 | + conf = self._confidence(ptype, amount, locations, years, status, nm, low) | |
| 76 | + if conf < 0.30: | |
| 77 | + continue | |
| 78 | + | |
| 79 | + pname = nm.group(1) if nm else self._synth_name(ptype, locations, techs) | |
| 80 | + out.append(ProjectMention( | |
| 81 | + mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor), | |
| 82 | + accession_number=accession_number, cik=cik, | |
| 83 | + section_id=section_id, section_name=section_name, | |
| 84 | + project_name=pname, project_type=ptype, | |
| 85 | + description=sent[:500], | |
| 86 | + amount_usd=amount, amount_raw=amount_raw, | |
| 87 | + locations=locations, technologies=techs, years=years, | |
| 88 | + status=status, confidence=round(conf, 3), backend=self.name, | |
| 89 | + )) | |
| 90 | + return out | |
| 91 | + | |
| 92 | + @staticmethod | |
| 93 | + def _synth_name(ptype: str, locations: list[str], techs: list[str]) -> str: | |
| 94 | + label = tx.TYPE_LABELS.get(ptype, ptype) | |
| 95 | + if locations: | |
| 96 | + return f"{label} — {locations[0]}" | |
| 97 | + if techs: | |
| 98 | + return f"{label} ({techs[0]})" | |
| 99 | + return label | |
| 100 | + | |
| 101 | + @staticmethod | |
| 102 | + def _confidence(ptype, amount, locations, years, status, nm, low) -> float: | |
| 103 | + c = 0.40 | |
| 104 | + if nm: | |
| 105 | + c += 0.20 | |
| 106 | + if amount: | |
| 107 | + c += 0.18 | |
| 108 | + if locations: | |
| 109 | + c += 0.10 | |
| 110 | + if years: | |
| 111 | + c += 0.06 | |
| 112 | + if status in ("planned", "in_progress", "completed"): | |
| 113 | + c += 0.08 | |
| 114 | + # strong, specific phrasings | |
| 115 | + if re.search(r"\b(announced|committed|invest\w+|will (build|open|invest))\b", low): | |
| 116 | + c += 0.06 | |
| 117 | + # de-weight vague risk-factor / forward-looking hedges | |
| 118 | + if re.search(r"\b(may|might|could|potential\w*|if we|uncertain)\b", low): | |
| 119 | + c -= 0.08 | |
| 120 | + return max(0.0, min(1.0, c)) | |
added
code/audit/spid/taxonomy.py
+261 −0
@@ -0,0 +1,261 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — project taxonomy & lightweight value extractors | |
| 3 | +# | |
| 4 | +# Shared by both the rule-based and the LLM extractor so that | |
| 5 | +# `project_type`, `technology`, money, location and dates are | |
| 6 | +# normalised the same way regardless of backend. | |
| 7 | +# ============================================================ | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import re | |
| 11 | + | |
| 12 | +# ---------------------------------------------------------------------- | |
| 13 | +# Canonical project types. `signals` are high-precision regex fragments | |
| 14 | +# (already lower-cased) used by the rule extractor and as the controlled | |
| 15 | +# vocabulary the LLM is asked to classify into. | |
| 16 | +# ---------------------------------------------------------------------- | |
| 17 | +PROJECT_TYPES: dict[str, dict] = { | |
| 18 | + "manufacturing_expansion": { | |
| 19 | + "label": "Manufacturing / Capacity Expansion", | |
| 20 | + "signals": [ | |
| 21 | + r"(expand|increas\w+|add\w*|ramp\w*|scal\w+|new|build\w*|construct\w*|open\w*)" | |
| 22 | + r"[^.]{0,60}(manufactur\w+|production|plant|factory|facilit\w+|capacity|assembly|fab\b)", | |
| 23 | + ], | |
| 24 | + }, | |
| 25 | + "plant_construction": { | |
| 26 | + "label": "Plant / Site Construction", | |
| 27 | + "signals": [r"(construct\w*|build\w*|broke ground|commission\w*|new (plant|site|facility))" | |
| 28 | + r"[^.]{0,60}(plant|facility|site|campus|complex|refinery|mill)"], | |
| 29 | + }, | |
| 30 | + "data_center": { | |
| 31 | + "label": "Data Center", | |
| 32 | + "signals": [r"data\s?cent(er|re)s?", r"hyperscal\w+", r"server farm"], | |
| 33 | + }, | |
| 34 | + "cloud_migration": { | |
| 35 | + "label": "Cloud Migration", | |
| 36 | + "signals": [r"(migrat\w+|mov\w+|transition\w*)[^.]{0,40}(cloud)", | |
| 37 | + r"cloud (migration|platform|adoption|infrastructure|first)"], | |
| 38 | + }, | |
| 39 | + "ai_initiative": { | |
| 40 | + "label": "AI / ML Initiative", | |
| 41 | + "signals": [r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|" | |
| 42 | + r"large language model|\bllm\b|neural network)\b", | |
| 43 | + r"\bai[- ](powered|driven|enabled|capabilit\w+|platform|model|tools?)\b"], | |
| 44 | + }, | |
| 45 | + "digital_transformation": { | |
| 46 | + "label": "Digital Transformation", | |
| 47 | + "signals": [r"digital (transformation|transition|initiative|strateg\w+|platform)", | |
| 48 | + r"digitali[sz]ation"], | |
| 49 | + }, | |
| 50 | + "erp_implementation": { | |
| 51 | + "label": "ERP / Systems Implementation", | |
| 52 | + "signals": [r"\b(erp|sap|oracle|workday|s/?4 ?hana)\b[^.]{0,40}(implement\w+|deploy\w+|migrat\w+|system|platform)", | |
| 53 | + r"(implement\w+|deploy\w+|upgrad\w+)[^.]{0,40}\berp\b", | |
| 54 | + r"enterprise resource planning"], | |
| 55 | + }, | |
| 56 | + "rd_program": { | |
| 57 | + "label": "R&D Program", | |
| 58 | + "signals": [r"research and development", r"\br&d\b", | |
| 59 | + r"(develop\w+|invest\w+)[^.]{0,40}(next[- ]generation|new technolog\w+|new product)"], | |
| 60 | + }, | |
| 61 | + "product_launch": { | |
| 62 | + "label": "New Product / Launch", | |
| 63 | + "signals": [r"(launch\w*|introduc\w+|releas\w+|roll\w*[- ]?out|unveil\w+)" | |
| 64 | + r"[^.]{0,50}(product|platform|service|offering|solution|model|line)", | |
| 65 | + r"new product (line|launch|introduction)"], | |
| 66 | + }, | |
| 67 | + "drug_pipeline": { | |
| 68 | + "label": "Drug / Clinical Pipeline", | |
| 69 | + "signals": [r"(phase\s?(i{1,3}|1|2|3)\b)", r"clinical trial", r"\bfda (approval|clearance|submission)\b", | |
| 70 | + r"(drug|therap\w+|candidate|molecule|pipeline)[^.]{0,40}(develop\w+|trial|approval)"], | |
| 71 | + }, | |
| 72 | + "ma_integration": { | |
| 73 | + "label": "M&A / Integration", | |
| 74 | + "signals": [r"(acqui\w+|merg\w+|takeover|business combination)", | |
| 75 | + r"integration of [a-z]", r"purchase of [a-z]"], | |
| 76 | + }, | |
| 77 | + "geographic_expansion": { | |
| 78 | + "label": "Geographic Expansion", | |
| 79 | + "signals": [r"(expand\w*|enter\w*|launch\w*|establish\w*)[^.]{0,50}" | |
| 80 | + r"(market|region|countr\w+|geograph\w+|international|overseas|abroad)", | |
| 81 | + r"market entry"], | |
| 82 | + }, | |
| 83 | + "capex_program": { | |
| 84 | + "label": "Capital Expenditure Program", | |
| 85 | + "signals": [r"capital (expenditure|expenditures|investment|spending|program|project|budget)", | |
| 86 | + r"\bcapex\b", r"invest\w+[^.]{0,30}(\$|usd)"], | |
| 87 | + }, | |
| 88 | + "supply_chain": { | |
| 89 | + "label": "Supply Chain Initiative", | |
| 90 | + "signals": [r"supply chain", r"(reshor\w+|nearshor\w+|onshor\w+|offshor\w+)", | |
| 91 | + r"(distribution|logistics|fulfillment|warehouse|inventory)[^.]{0,40}" | |
| 92 | + r"(network|center|optimi[sz]\w+|expansion|investment)"], | |
| 93 | + }, | |
| 94 | + "automation": { | |
| 95 | + "label": "Automation / Robotics", | |
| 96 | + "signals": [r"automat\w+", r"robot\w+", r"(industrial|warehouse) automation"], | |
| 97 | + }, | |
| 98 | + "sustainability": { | |
| 99 | + "label": "Sustainability / ESG", | |
| 100 | + "signals": [r"sustainabilit\w+", r"\besg\b", r"net[- ]zero", r"carbon (neutral|reduction|footprint|emissions?)", | |
| 101 | + r"decarboni[sz]\w+", r"emission reduction"], | |
| 102 | + }, | |
| 103 | + "energy_transition": { | |
| 104 | + "label": "Energy Transition / Renewables", | |
| 105 | + "signals": [r"renewable (energy|power)", r"(solar|wind|hydrogen|geothermal|battery storage)[^.]{0,40}" | |
| 106 | + r"(project|facility|farm|plant|capacity|investment)", | |
| 107 | + r"clean energy", r"energy transition"], | |
| 108 | + }, | |
| 109 | + "infrastructure": { | |
| 110 | + "label": "Infrastructure Investment", | |
| 111 | + "signals": [r"infrastructure (investment|project|upgrade|expansion|spending|build\w*)", | |
| 112 | + r"(network|grid|pipeline|broadband|fiber) (expansion|build\w*|upgrade|deployment)"], | |
| 113 | + }, | |
| 114 | + "cost_reduction": { | |
| 115 | + "label": "Cost Reduction / Restructuring", | |
| 116 | + "signals": [r"(cost (reduction|savings|cutting)|restructur\w+|efficiency (program|initiative)|" | |
| 117 | + r"productivity (program|initiative)|streamlin\w+|optimi[sz]ation (program|initiative))"], | |
| 118 | + }, | |
| 119 | + "partnership": { | |
| 120 | + "label": "Strategic Partnership", | |
| 121 | + "signals": [r"(strategic (partnership|alliance|collaboration)|joint venture|\bjv\b|" | |
| 122 | + r"(partner\w+|collaborat\w+|team\w* up)[^.]{0,40}(with))"], | |
| 123 | + }, | |
| 124 | + "technology_deployment": { | |
| 125 | + "label": "Technology Deployment", | |
| 126 | + "signals": [r"(deploy\w+|implement\w+|roll\w*[- ]?out|adopt\w+|install\w+)[^.]{0,50}" | |
| 127 | + r"(technolog\w+|system|platform|software|solution|tool)"], | |
| 128 | + }, | |
| 129 | +} | |
| 130 | + | |
| 131 | +# ---------------------------------------------------------------------- | |
| 132 | +# Technology vocabulary -> canonical tag | |
| 133 | +# ---------------------------------------------------------------------- | |
| 134 | +TECHNOLOGY_TERMS: dict[str, str] = { | |
| 135 | + r"\b(artificial intelligence|machine learning|generative ai|gen ?ai|deep learning|llm|neural)\b": "AI/ML", | |
| 136 | + r"\bcloud\b": "Cloud", | |
| 137 | + r"\b(erp|sap|oracle|workday|s/?4 ?hana|peoplesoft)\b": "ERP", | |
| 138 | + r"\b(robot\w+|automation|cobots?)\b": "Robotics/Automation", | |
| 139 | + r"\b(semiconductor|chip|wafer|fab|node\b|nanometer|foundry)\b": "Semiconductors", | |
| 140 | + r"\b(solar|wind|renewable|hydrogen|geothermal)\b": "Renewable Energy", | |
| 141 | + r"\b(battery|batteries|energy storage|lithium)\b": "Battery/Storage", | |
| 142 | + r"\b(ev|electric vehicle|charging)\b": "Electric Vehicles", | |
| 143 | + r"\b(blockchain|crypto|digital asset|tokeni[sz])\b": "Blockchain", | |
| 144 | + r"\b(5g|broadband|fiber|fibre|network infrastructure)\b": "Connectivity", | |
| 145 | + r"\b(data\s?cent(er|re)|hyperscale|server)\b": "Data Center", | |
| 146 | + r"\b(cyber ?security|encryption|zero trust)\b": "Cybersecurity", | |
| 147 | + r"\b(iot|internet of things|sensors?|edge computing)\b": "IoT/Edge", | |
| 148 | + r"\b(quantum)\b": "Quantum", | |
| 149 | + r"\b(biolog\w+|gene|mrna|crispr|cell therapy|molecul\w+)\b": "Biotech", | |
| 150 | + r"\b(saas|software[- ]as[- ]a[- ]service|platform)\b": "Software/SaaS", | |
| 151 | +} | |
| 152 | + | |
| 153 | +# ---------------------------------------------------------------------- | |
| 154 | +# Status keywords -> canonical status | |
| 155 | +# ---------------------------------------------------------------------- | |
| 156 | +STATUS_TERMS: list[tuple[str, str]] = [ | |
| 157 | + (r"\b(completed|commission\w+|operational|in service|came online|opened|finalized)\b", "completed"), | |
| 158 | + (r"\b(under construction|underway|in progress|ongoing|ramping|being (built|implemented))\b", "in_progress"), | |
| 159 | + (r"\b(announc\w+|plan\w+|intend\w+|expect\w+|will (build|invest|launch|open)|propos\w+|" | |
| 160 | + r"anticipat\w+|target\w+|by (20\d\d)|slated)\b", "planned"), | |
| 161 | +] | |
| 162 | + | |
| 163 | +# ---------------------------------------------------------------------- | |
| 164 | +# Geography | |
| 165 | +# ---------------------------------------------------------------------- | |
| 166 | +US_STATES = { | |
| 167 | + "alabama","alaska","arizona","arkansas","california","colorado","connecticut","delaware", | |
| 168 | + "florida","georgia","hawaii","idaho","illinois","indiana","iowa","kansas","kentucky", | |
| 169 | + "louisiana","maine","maryland","massachusetts","michigan","minnesota","mississippi", | |
| 170 | + "missouri","montana","nebraska","nevada","new hampshire","new jersey","new mexico", | |
| 171 | + "new york","north carolina","north dakota","ohio","oklahoma","oregon","pennsylvania", | |
| 172 | + "rhode island","south carolina","south dakota","tennessee","texas","utah","vermont", | |
| 173 | + "virginia","washington","west virginia","wisconsin","wyoming", | |
| 174 | +} | |
| 175 | +COUNTRIES = { | |
| 176 | + "united states","u.s.","usa","canada","mexico","china","india","japan","germany","france", | |
| 177 | + "united kingdom","u.k.","ireland","brazil","singapore","south korea","korea","taiwan", | |
| 178 | + "vietnam","thailand","malaysia","indonesia","australia","spain","italy","netherlands", | |
| 179 | + "switzerland","sweden","poland","israel","saudi arabia","united arab emirates","uae", | |
| 180 | + "philippines","czech republic","hungary","turkey","egypt","south africa","argentina","chile", | |
| 181 | +} | |
| 182 | + | |
| 183 | +_MONEY_RE = re.compile( | |
| 184 | + r"(?:US)?\$\s?(\d[\d,]*\.?\d*)\s?(billion|bn|million|mm|mn|m\b|thousand|k\b|trillion)?", | |
| 185 | + re.IGNORECASE, | |
| 186 | +) | |
| 187 | +_MULT = { | |
| 188 | + "trillion": 1e12, "billion": 1e9, "bn": 1e9, "million": 1e6, "mm": 1e6, "mn": 1e6, | |
| 189 | + "m": 1e6, "thousand": 1e3, "k": 1e3, | |
| 190 | +} | |
| 191 | +_YEAR_RE = re.compile(r"\b(?:fiscal\s+|fy\s?)?(20[0-3]\d|19[89]\d)\b", re.IGNORECASE) | |
| 192 | +_QUARTER_RE = re.compile(r"\bQ([1-4])\s?(20\d\d)\b", re.IGNORECASE) | |
| 193 | +_TECH_COMPILED = [(re.compile(p, re.IGNORECASE), tag) for p, tag in TECHNOLOGY_TERMS.items()] | |
| 194 | +_STATUS_COMPILED = [(re.compile(p, re.IGNORECASE), s) for p, s in STATUS_TERMS] | |
| 195 | +_CITY_STATE_RE = re.compile(r"\bin\s+([A-Z][a-zA-Z.]+(?:\s[A-Z][a-zA-Z.]+){0,2}),\s+([A-Z][a-zA-Z]+)") | |
| 196 | + | |
| 197 | + | |
| 198 | +def extract_money(text: str) -> tuple[float | None, str | None]: | |
| 199 | + """Return (usd_amount, raw_span) for the largest explicit dollar figure.""" | |
| 200 | + best_val, best_raw = None, None | |
| 201 | + for m in _MONEY_RE.finditer(text): | |
| 202 | + try: | |
| 203 | + num = float(m.group(1).replace(",", "")) | |
| 204 | + except ValueError: | |
| 205 | + continue | |
| 206 | + unit = (m.group(2) or "").lower().strip() | |
| 207 | + val = num * _MULT.get(unit, 1.0) | |
| 208 | + if best_val is None or val > best_val: | |
| 209 | + best_val, best_raw = val, m.group(0).strip() | |
| 210 | + return best_val, best_raw | |
| 211 | + | |
| 212 | + | |
| 213 | +def extract_locations(text: str) -> list[str]: | |
| 214 | + low = text.lower() | |
| 215 | + found: list[str] = [] | |
| 216 | + for st in US_STATES: | |
| 217 | + if re.search(rf"\b{re.escape(st)}\b", low): | |
| 218 | + found.append(st.title()) | |
| 219 | + for co in COUNTRIES: | |
| 220 | + if re.search(rf"\b{re.escape(co)}\b", low): | |
| 221 | + found.append(co.upper() if len(co) <= 4 else co.title()) | |
| 222 | + for m in _CITY_STATE_RE.finditer(text): | |
| 223 | + found.append(f"{m.group(1)}, {m.group(2)}") | |
| 224 | + # de-dup preserving order | |
| 225 | + seen, out = set(), [] | |
| 226 | + for f in found: | |
| 227 | + k = f.lower() | |
| 228 | + if k not in seen: | |
| 229 | + seen.add(k); out.append(f) | |
| 230 | + return out[:6] | |
| 231 | + | |
| 232 | + | |
| 233 | +def extract_years(text: str) -> list[int]: | |
| 234 | + yrs = {int(y) for y in _YEAR_RE.findall(text)} | |
| 235 | + for m in _QUARTER_RE.finditer(text): | |
| 236 | + yrs.add(int(m.group(2))) | |
| 237 | + return sorted(yrs) | |
| 238 | + | |
| 239 | + | |
| 240 | +def extract_technologies(text: str) -> list[str]: | |
| 241 | + tags = [] | |
| 242 | + for rx, tag in _TECH_COMPILED: | |
| 243 | + if rx.search(text) and tag not in tags: | |
| 244 | + tags.append(tag) | |
| 245 | + return tags | |
| 246 | + | |
| 247 | + | |
| 248 | +def detect_status(text: str) -> str: | |
| 249 | + for rx, status in _STATUS_COMPILED: | |
| 250 | + if rx.search(text): | |
| 251 | + return status | |
| 252 | + return "mentioned" | |
| 253 | + | |
| 254 | + | |
| 255 | +# pre-compile project-type signals | |
| 256 | +TYPE_SIGNALS = { | |
| 257 | + ptype: [re.compile(sig, re.IGNORECASE) for sig in spec["signals"]] | |
| 258 | + for ptype, spec in PROJECT_TYPES.items() | |
| 259 | +} | |
| 260 | +TYPE_LABELS = {k: v["label"] for k, v in PROJECT_TYPES.items()} | |
| 261 | +ALL_TYPES = list(PROJECT_TYPES.keys()) | |
added
code/audit/spid_snapshot/SHA256SUMS
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +4ba1811f9513ab472855764ba8d09363060fed6e7d009f68f5b2749e85833ab1 worktrees/FIRC-0001/code/audit/spid_snapshot/base.py | |
| 2 | +8c4a273631e0015867a1c88d92e284faca906ea16e9af2595dfa5cb06d5b8d06 worktrees/FIRC-0001/code/audit/spid_snapshot/config.py | |
| 3 | +c53536becfe273f03882c309ba6ac79d4423c5bf1320865d044a0d00effada90 worktrees/FIRC-0001/code/audit/spid_snapshot/ingest.py | |
| 4 | +ae7f3f61a8de36842ba8c99a566883465fe8d6672ec723ee438f963e50d4eb89 worktrees/FIRC-0001/code/audit/spid_snapshot/llm.py | |
| 5 | +6135f39adb9e78b7e50b81a9034db07e45f30fcb3a329df5c6c9a79bad028712 worktrees/FIRC-0001/code/audit/spid_snapshot/pipeline.py | |
| 6 | +925ab1258b2a364f1c9ad5be5817b4d815fe11e7fca5ad32f3ac71b5be59652d worktrees/FIRC-0001/code/audit/spid_snapshot/resolve.py | |
| 7 | +7f191c18b0426d83931fa6f1f461f3a2d43bcc1d151a14a6e084f24db6766807 worktrees/FIRC-0001/code/audit/spid_snapshot/rules.py | |
| 8 | +1219c923fff72b9946bc068baacd70802fd5fab86766e62141dbdd2ea066a54f worktrees/FIRC-0001/code/audit/spid_snapshot/store.py | |
| 9 | +92ad69bcc4e448d03496943221fdcb78b4326baa7db682a6b6713c260c9c4e7a worktrees/FIRC-0001/code/audit/spid_snapshot/taxonomy.py | |
added
code/audit/spid_snapshot/base.py
+69 −0
@@ -0,0 +1,69 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — extraction interface & record schema | |
| 3 | +# ============================================================ | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import hashlib | |
| 7 | +from dataclasses import dataclass, field, asdict | |
| 8 | +from typing import Protocol | |
| 9 | + | |
| 10 | + | |
| 11 | +@dataclass | |
| 12 | +class ProjectMention: | |
| 13 | + """A single project signal extracted from one filing section. | |
| 14 | + | |
| 15 | + Mentions are the atomic unit; `resolve.py` merges many mentions | |
| 16 | + (across filings & years) into a single Project with a timeline. | |
| 17 | + """ | |
| 18 | + mention_id: str | |
| 19 | + accession_number: str | |
| 20 | + cik: str | |
| 21 | + section_id: str | |
| 22 | + section_name: str | |
| 23 | + | |
| 24 | + project_name: str | |
| 25 | + project_type: str | |
| 26 | + description: str | |
| 27 | + objective: str = "" | |
| 28 | + | |
| 29 | + amount_usd: float | None = None | |
| 30 | + amount_raw: str | None = None | |
| 31 | + locations: list[str] = field(default_factory=list) | |
| 32 | + technologies: list[str] = field(default_factory=list) | |
| 33 | + partners: list[str] = field(default_factory=list) | |
| 34 | + suppliers: list[str] = field(default_factory=list) | |
| 35 | + benefits: list[str] = field(default_factory=list) | |
| 36 | + risks: list[str] = field(default_factory=list) | |
| 37 | + years: list[int] = field(default_factory=list) | |
| 38 | + status: str = "mentioned" | |
| 39 | + | |
| 40 | + confidence: float = 0.0 | |
| 41 | + backend: str = "rules" | |
| 42 | + | |
| 43 | + def to_row(self) -> dict: | |
| 44 | + d = asdict(self) | |
| 45 | + # store list fields as JSON-friendly via duckdb list[str] | |
| 46 | + return d | |
| 47 | + | |
| 48 | + @staticmethod | |
| 49 | + def make_id(accession: str, section_id: str, ptype: str, anchor: str) -> str: | |
| 50 | + h = hashlib.sha1(f"{accession}|{section_id}|{ptype}|{anchor}".encode()).hexdigest() | |
| 51 | + return h[:20] | |
| 52 | + | |
| 53 | + | |
| 54 | +class Extractor(Protocol): | |
| 55 | + name: str | |
| 56 | + | |
| 57 | + def extract(self, *, text: str, accession_number: str, cik: str, | |
| 58 | + section_id: str, section_name: str) -> list[ProjectMention]: | |
| 59 | + ... | |
| 60 | + | |
| 61 | + | |
| 62 | +def get_extractor(backend: str): | |
| 63 | + """Factory. `backend` in {'rules','llm'}.""" | |
| 64 | + backend = (backend or "rules").lower() | |
| 65 | + if backend == "llm": | |
| 66 | + from spid.extract.llm import LLMExtractor | |
| 67 | + return LLMExtractor() | |
| 68 | + from spid.extract.rules import RuleExtractor | |
| 69 | + return RuleExtractor() | |
added
code/audit/spid_snapshot/config.py
+126 −0
@@ -0,0 +1,126 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — configuration & paths | |
| 3 | +# ============================================================ | |
| 4 | +from __future__ import annotations | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +import os | |
| 8 | +from dataclasses import dataclass, field | |
| 9 | +from functools import lru_cache | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +# project-db/ (this file lives in project-db/spid/config.py) | |
| 13 | +PROJECT_ROOT = Path(__file__).resolve().parent.parent | |
| 14 | + | |
| 15 | + | |
| 16 | +def _load_dotenv() -> None: | |
| 17 | + """Minimal .env loader (no python-dotenv dependency required).""" | |
| 18 | + env_path = PROJECT_ROOT / ".env" | |
| 19 | + if not env_path.exists(): | |
| 20 | + return | |
| 21 | + for line in env_path.read_text().splitlines(): | |
| 22 | + line = line.strip() | |
| 23 | + if not line or line.startswith("#") or "=" not in line: | |
| 24 | + continue | |
| 25 | + key, _, val = line.partition("=") | |
| 26 | + key, val = key.strip(), val.strip().strip('"').strip("'") | |
| 27 | + os.environ.setdefault(key, val) | |
| 28 | + | |
| 29 | + | |
| 30 | +_load_dotenv() | |
| 31 | +# The parent UQO_RESEARCH_DB that holds the parsed corpus. | |
| 32 | +RESEARCH_ROOT = PROJECT_ROOT.parent | |
| 33 | +SOURCE_DATA = RESEARCH_ROOT / "data" | |
| 34 | + | |
| 35 | + | |
| 36 | +def _parse_scope(spec: str) -> dict: | |
| 37 | + out: dict = {} | |
| 38 | + for part in (spec or "").split(";"): | |
| 39 | + part = part.strip() | |
| 40 | + if not part or ":" not in part: | |
| 41 | + continue | |
| 42 | + form, items = part.split(":", 1) | |
| 43 | + out[form.strip()] = [i.strip() for i in items.split(",") if i.strip()] | |
| 44 | + return out | |
| 45 | + | |
| 46 | + | |
| 47 | +@dataclass(frozen=True) | |
| 48 | +class Settings: | |
| 49 | + """Resolved runtime configuration for the SPID platform.""" | |
| 50 | + | |
| 51 | + # --- source corpus (read-only) ------------------------------------- | |
| 52 | + source_data: Path = SOURCE_DATA | |
| 53 | + parquet_dir: Path = SOURCE_DATA / "parquet" | |
| 54 | + processed_dir: Path = SOURCE_DATA / "processed" | |
| 55 | + | |
| 56 | + @property | |
| 57 | + def filings_parquet(self) -> Path: | |
| 58 | + return self.parquet_dir / "filings" / "filings.parquet" | |
| 59 | + | |
| 60 | + @property | |
| 61 | + def sections_parquet(self) -> Path: | |
| 62 | + return self.parquet_dir / "filing_sections" / "filing_sections.parquet" | |
| 63 | + | |
| 64 | + @property | |
| 65 | + def companies_parquet(self) -> Path: | |
| 66 | + return self.parquet_dir / "companies" / "companies.parquet" | |
| 67 | + | |
| 68 | + # --- SPID outputs (writable) --------------------------------------- | |
| 69 | + db_path: Path = PROJECT_ROOT / "data" / "db" / "spid.duckdb" | |
| 70 | + out_dir: Path = PROJECT_ROOT / "data" | |
| 71 | + | |
| 72 | + # --- extraction ---------------------------------------------------- | |
| 73 | + backend: str = field( | |
| 74 | + default_factory=lambda: os.environ.get("SPID_BACKEND", "llm") | |
| 75 | + ) # "llm" (gpt-4o-mini) | "rules" | |
| 76 | + min_confidence: float = 0.45 | |
| 77 | + # which parsed sections feed the extractor, per form (None/empty = every section) | |
| 78 | + section_scope: dict = field(default_factory=lambda: _parse_scope(os.environ.get( | |
| 79 | + "SPID_SECTION_SCOPE", | |
| 80 | + "10-K:business,risk_factors,properties,md_and_a,quantitative_disclosures"))) | |
| 81 | + max_section_chars: int = 600_000 | |
| 82 | + | |
| 83 | + # --- embeddings ---------------------------------------------------- | |
| 84 | + embedding_model: str = field( | |
| 85 | + default_factory=lambda: os.environ.get("SPID_EMBED_MODEL", "all-MiniLM-L6-v2") | |
| 86 | + ) | |
| 87 | + | |
| 88 | + # --- LLM backend (OpenAI-compatible) ------------------------------- | |
| 89 | + llm_base_url: str = field( | |
| 90 | + default_factory=lambda: os.environ.get( | |
| 91 | + "SPID_LLM_BASE_URL", "https://api.openai.com/v1" | |
| 92 | + ) | |
| 93 | + ) | |
| 94 | + llm_model: str = field( | |
| 95 | + default_factory=lambda: os.environ.get("SPID_LLM_MODEL", "gpt-4o-mini") | |
| 96 | + ) | |
| 97 | + llm_api_key: str = field( | |
| 98 | + default_factory=lambda: os.environ.get("OPENAI_API_KEY", "") | |
| 99 | + or os.environ.get("MACLUSTR_API_KEY", "") | |
| 100 | + ) | |
| 101 | + llm_timeout: float = 120.0 | |
| 102 | + | |
| 103 | + def ensure_dirs(self) -> None: | |
| 104 | + self.db_path.parent.mkdir(parents=True, exist_ok=True) | |
| 105 | + (self.out_dir / "exports").mkdir(parents=True, exist_ok=True) | |
| 106 | + | |
| 107 | + | |
| 108 | +@lru_cache(maxsize=1) | |
| 109 | +def get_settings() -> Settings: | |
| 110 | + s = Settings() | |
| 111 | + s.ensure_dirs() | |
| 112 | + return s | |
| 113 | + | |
| 114 | + | |
| 115 | +def get_logger(name: str) -> logging.Logger: | |
| 116 | + logger = logging.getLogger(f"spid.{name}") | |
| 117 | + if not logger.handlers: | |
| 118 | + h = logging.StreamHandler() | |
| 119 | + h.setFormatter( | |
| 120 | + logging.Formatter("%(asctime)s %(levelname)-7s %(name)s | %(message)s", | |
| 121 | + datefmt="%H:%M:%S") | |
| 122 | + ) | |
| 123 | + logger.addHandler(h) | |
| 124 | + logger.setLevel(os.environ.get("SPID_LOG_LEVEL", "INFO")) | |
| 125 | + logger.propagate = False | |
| 126 | + return logger | |
added
code/audit/spid_snapshot/ingest.py
+135 −0
@@ -0,0 +1,135 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — raw 10-K ingester | |
| 3 | +# | |
| 4 | +# The pre-parsed corpus only sectioned 10-Q & 8-K filings; the | |
| 5 | +# 10-K HTML was left raw. This module cleans the raw 10-K HTML | |
| 6 | +# and extracts the project-dense Items (1 Business, 1A Risk, | |
| 7 | +# 7 MD&A, 2 Properties) into a SPID-owned `spid_sections` table | |
| 8 | +# that `v_sections` unions in — so 10-Ks flow through the same | |
| 9 | +# extraction pipeline. | |
| 10 | +# ============================================================ | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import glob | |
| 14 | +import re | |
| 15 | +import uuid | |
| 16 | +from pathlib import Path | |
| 17 | + | |
| 18 | +from spid.config import get_logger, get_settings | |
| 19 | +from spid.store import Store | |
| 20 | + | |
| 21 | +log = get_logger("ingest") | |
| 22 | + | |
| 23 | +RAW_SEC = get_settings().source_data / "raw" / "sec" | |
| 24 | + | |
| 25 | +# 10-K items we keep (where projects/capex/strategy concentrate) | |
| 26 | +_KEEP_ITEMS = { | |
| 27 | + "1": "business", "1A": "risk_factors", "2": "properties", | |
| 28 | + "7": "md_and_a", "7A": "quantitative_disclosures", | |
| 29 | +} | |
| 30 | +_ITEM_RE = re.compile(r"(?:^|\n)\s*ITEM\s+(\d+[AB]?)\s*[.\-:—–]", re.IGNORECASE) | |
| 31 | +_TAG_RE = re.compile(r"<[^>]+>") | |
| 32 | +_WS_RE = re.compile(r"[ \t]+") | |
| 33 | +_NL_RE = re.compile(r"\n{3,}") | |
| 34 | + | |
| 35 | + | |
| 36 | +def _clean_html(path: Path) -> str: | |
| 37 | + raw = path.read_text(errors="ignore") | |
| 38 | + try: | |
| 39 | + from bs4 import BeautifulSoup | |
| 40 | + soup = BeautifulSoup(raw, "lxml") | |
| 41 | + for t in soup(["script", "style", "table"]): | |
| 42 | + t.decompose() | |
| 43 | + text = soup.get_text("\n") | |
| 44 | + except Exception: # noqa: BLE001 | |
| 45 | + text = _TAG_RE.sub(" ", raw) | |
| 46 | + import html as _html | |
| 47 | + text = _html.unescape(text) | |
| 48 | + text = _WS_RE.sub(" ", text) | |
| 49 | + text = _NL_RE.sub("\n\n", text) | |
| 50 | + return text | |
| 51 | + | |
| 52 | + | |
| 53 | +def _extract_items(text: str) -> list[tuple[str, str]]: | |
| 54 | + matches = list(_ITEM_RE.finditer(text)) | |
| 55 | + if not matches: | |
| 56 | + return [("full_body", text)] | |
| 57 | + out: list[tuple[str, str]] = [] | |
| 58 | + for i, m in enumerate(matches): | |
| 59 | + item = m.group(1).upper() | |
| 60 | + if item not in _KEEP_ITEMS: | |
| 61 | + continue | |
| 62 | + start = m.start() | |
| 63 | + end = matches[i + 1].start() if i + 1 < len(matches) else len(text) | |
| 64 | + body = text[start:end].strip() | |
| 65 | + if len(body) > 400: # skip the table-of-contents stubs | |
| 66 | + out.append((_KEEP_ITEMS[item], body[:600_000])) | |
| 67 | + # keep the longest instance of each section (TOC vs real body) | |
| 68 | + best: dict[str, str] = {} | |
| 69 | + for name, body in out: | |
| 70 | + if name not in best or len(body) > len(best[name]): | |
| 71 | + best[name] = body | |
| 72 | + return list(best.items()) | |
| 73 | + | |
| 74 | + | |
| 75 | +def _find_primary(acc: str, cik: str) -> Path | None: | |
| 76 | + d = RAW_SEC / cik / "10-K" / acc.replace("-", "") | |
| 77 | + if not d.is_dir(): | |
| 78 | + return None | |
| 79 | + cands = glob.glob(str(d / "*.htm")) + glob.glob(str(d / "*.html")) + glob.glob(str(d / "*.txt")) | |
| 80 | + cands = [c for c in cands if "R" + "" not in Path(c).name or True] | |
| 81 | + if not cands: | |
| 82 | + return None | |
| 83 | + return Path(max(cands, key=lambda p: Path(p).stat().st_size)) | |
| 84 | + | |
| 85 | + | |
| 86 | +_SPID_SECTIONS_DDL = """ | |
| 87 | +CREATE TABLE IF NOT EXISTS spid_sections ( | |
| 88 | + section_id VARCHAR PRIMARY KEY, | |
| 89 | + accession_number VARCHAR, | |
| 90 | + section_name VARCHAR, | |
| 91 | + item_number VARCHAR, | |
| 92 | + text VARCHAR, | |
| 93 | + word_count INTEGER | |
| 94 | +); | |
| 95 | +""" | |
| 96 | + | |
| 97 | + | |
| 98 | +def ingest_10k(store: Store, *, ciks: list[str] | None = None, limit: int | None = None) -> dict: | |
| 99 | + con = store.con | |
| 100 | + con.execute(_SPID_SECTIONS_DDL) | |
| 101 | + where = "form_type='10-K'" | |
| 102 | + if ciks: | |
| 103 | + q = ",".join("'" + c + "'" for c in ciks) | |
| 104 | + where += f" AND cik IN ({q})" | |
| 105 | + sql = f"SELECT accession_number, cik FROM src_filings WHERE {where} ORDER BY filing_date DESC" | |
| 106 | + if limit: | |
| 107 | + sql += f" LIMIT {int(limit)}" | |
| 108 | + filings = con.execute(sql).fetchall() | |
| 109 | + | |
| 110 | + rows, done, ok = [], 0, 0 | |
| 111 | + for acc, cik in filings: | |
| 112 | + done += 1 | |
| 113 | + # skip if already ingested | |
| 114 | + if con.execute("SELECT 1 FROM spid_sections WHERE accession_number=? LIMIT 1", [acc]).fetchone(): | |
| 115 | + ok += 1 | |
| 116 | + continue | |
| 117 | + path = _find_primary(acc, cik) | |
| 118 | + if not path: | |
| 119 | + continue | |
| 120 | + try: | |
| 121 | + text = _clean_html(path) | |
| 122 | + for name, body in _extract_items(text): | |
| 123 | + rows.append((str(uuid.uuid4()), acc, name, "", body, len(body.split()))) | |
| 124 | + ok += 1 | |
| 125 | + except Exception as e: # noqa: BLE001 | |
| 126 | + log.warning("ingest failed %s: %s", acc, e) | |
| 127 | + if len(rows) >= 400: | |
| 128 | + con.executemany("INSERT OR REPLACE INTO spid_sections VALUES (?,?,?,?,?,?)", rows); rows = [] | |
| 129 | + if done % 50 == 0: | |
| 130 | + log.info(" ... ingested %d/%d 10-Ks", done, len(filings)) | |
| 131 | + if rows: | |
| 132 | + con.executemany("INSERT OR REPLACE INTO spid_sections VALUES (?,?,?,?,?,?)", rows) | |
| 133 | + n = store.count("spid_sections") | |
| 134 | + log.info("Ingested 10-K sections: %d filings processed, %d section rows total.", ok, n) | |
| 135 | + return {"filings_processed": ok, "section_rows": n} | |
added
code/audit/spid_snapshot/llm.py
+202 −0
@@ -0,0 +1,202 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — LLM extractor (OpenAI-compatible; default gpt-4o-mini) | |
| 3 | +# | |
| 4 | +# Uses the chat-completions JSON mode to extract structured | |
| 5 | +# project records. Cost-controlled: only project-relevant | |
| 6 | +# windows of each section are sent to the model. | |
| 7 | +# ============================================================ | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import json | |
| 11 | +import re | |
| 12 | +import time | |
| 13 | + | |
| 14 | +import httpx | |
| 15 | + | |
| 16 | +from spid import taxonomy as tx | |
| 17 | +from spid.config import get_logger, get_settings | |
| 18 | +from spid.extract.base import ProjectMention | |
| 19 | +from spid.extract.rules import _PROJECT_GATE | |
| 20 | + | |
| 21 | +log = get_logger("extract.llm") | |
| 22 | + | |
| 23 | +_SYSTEM = """You are a financial analyst extracting CORPORATE PROJECTS from SEC filing text. | |
| 24 | +A "project" is any concrete initiative, investment, program, expansion, construction, | |
| 25 | +product/R&D effort, acquisition/integration, technology deployment, partnership, or | |
| 26 | +strategic program the company is pursuing. | |
| 27 | + | |
| 28 | +Return STRICT JSON: {"projects": [ ... ]}. Each project object has: | |
| 29 | + project_name : short descriptive name (use the official name if one is stated) | |
| 30 | + project_type : EXACTLY ONE of the allowed types below | |
| 31 | + description : 1 sentence, grounded in the text (no speculation) | |
| 32 | + objective : the goal/why (or "") | |
| 33 | + status : one of planned | in_progress | completed | mentioned | |
| 34 | + amount_usd : numeric USD figure if stated (e.g. 1500000000), else null | |
| 35 | + locations : array of places (city/state/country), else [] | |
| 36 | + technologies : array of technologies involved, else [] | |
| 37 | + partners : array of named partners/JV counterparties, else [] | |
| 38 | + benefits : array of expected benefits, else [] | |
| 39 | + risks : array of stated risks, else [] | |
| 40 | + years : array of 4-digit years mentioned, else [] | |
| 41 | + confidence : 0.0-1.0 (how clearly this is a real, concrete project) | |
| 42 | + | |
| 43 | +Allowed project_type values: | |
| 44 | +%s | |
| 45 | + | |
| 46 | +Rules: | |
| 47 | +- Extract ONLY real initiatives, not generic risk-factor boilerplate or hypotheticals. | |
| 48 | +- If the text describes no concrete project, return {"projects": []}. | |
| 49 | +- Do not invent facts. Numbers/locations must appear in the text. | |
| 50 | +- Prefer fewer high-quality projects over many vague ones.""" % "\n".join( | |
| 51 | + f" - {k}: {v}" for k, v in tx.TYPE_LABELS.items() | |
| 52 | +) | |
| 53 | + | |
| 54 | + | |
| 55 | +def _focus(text: str, budget: int = 9000) -> str: | |
| 56 | + """Keep only windows around project-signal hits to bound token cost.""" | |
| 57 | + if len(text) <= budget: | |
| 58 | + return text | |
| 59 | + spans: list[tuple[int, int]] = [] | |
| 60 | + for m in _PROJECT_GATE.finditer(text): | |
| 61 | + spans.append((max(0, m.start() - 220), min(len(text), m.end() + 380))) | |
| 62 | + if sum(b - a for a, b in spans) > budget * 1.4: | |
| 63 | + break | |
| 64 | + if not spans: | |
| 65 | + return text[:budget] | |
| 66 | + # merge overlapping windows | |
| 67 | + spans.sort() | |
| 68 | + merged = [spans[0]] | |
| 69 | + for a, b in spans[1:]: | |
| 70 | + if a <= merged[-1][1]: | |
| 71 | + merged[-1] = (merged[-1][0], max(merged[-1][1], b)) | |
| 72 | + else: | |
| 73 | + merged.append((a, b)) | |
| 74 | + chunks, total = [], 0 | |
| 75 | + for a, b in merged: | |
| 76 | + chunks.append(text[a:b]) | |
| 77 | + total += b - a | |
| 78 | + if total >= budget: | |
| 79 | + break | |
| 80 | + return "\n...\n".join(chunks) | |
| 81 | + | |
| 82 | + | |
| 83 | +class LLMExtractor: | |
| 84 | + name = "llm" | |
| 85 | + | |
| 86 | + def __init__(self) -> None: | |
| 87 | + self.s = get_settings() | |
| 88 | + if not self.s.llm_api_key: | |
| 89 | + raise RuntimeError("No LLM API key configured (OPENAI_API_KEY / MACLUSTR_API_KEY).") | |
| 90 | + self._client = httpx.Client( | |
| 91 | + base_url=self.s.llm_base_url, | |
| 92 | + headers={"Authorization": f"Bearer {self.s.llm_api_key}"}, | |
| 93 | + timeout=self.s.llm_timeout, | |
| 94 | + ) | |
| 95 | + | |
| 96 | + def _call(self, content: str, retries: int = 3) -> dict: | |
| 97 | + payload = { | |
| 98 | + "model": self.s.llm_model, | |
| 99 | + "messages": [ | |
| 100 | + {"role": "system", "content": _SYSTEM}, | |
| 101 | + {"role": "user", "content": content}, | |
| 102 | + ], | |
| 103 | + "temperature": 0, | |
| 104 | + "response_format": {"type": "json_object"}, | |
| 105 | + "max_tokens": 2000, | |
| 106 | + } | |
| 107 | + last = None | |
| 108 | + for attempt in range(retries): | |
| 109 | + try: | |
| 110 | + r = self._client.post("/chat/completions", json=payload) | |
| 111 | + if r.status_code == 429 or r.status_code >= 500: | |
| 112 | + raise httpx.HTTPStatusError("retryable", request=r.request, response=r) | |
| 113 | + r.raise_for_status() | |
| 114 | + txt = r.json()["choices"][0]["message"]["content"] | |
| 115 | + return json.loads(txt) | |
| 116 | + except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: | |
| 117 | + last = e | |
| 118 | + time.sleep(1.5 * (attempt + 1)) | |
| 119 | + log.warning("LLM call failed after %d retries: %s", retries, last) | |
| 120 | + return {"projects": []} | |
| 121 | + | |
| 122 | + def extract(self, *, text: str, accession_number: str, cik: str, | |
| 123 | + section_id: str, section_name: str) -> list[ProjectMention]: | |
| 124 | + if not text or not _PROJECT_GATE.search(text): | |
| 125 | + return [] | |
| 126 | + content = (f"SECTION: {section_name}\n\n" + _focus(text))[:14000] | |
| 127 | + data = self._call(content) | |
| 128 | + out: list[ProjectMention] = [] | |
| 129 | + for p in data.get("projects", []) or []: | |
| 130 | + if not isinstance(p, dict): | |
| 131 | + continue | |
| 132 | + ptype = self._norm_type(p.get("project_type", "")) | |
| 133 | + desc = str(p.get("description", "")).strip()[:600] | |
| 134 | + if not desc: | |
| 135 | + continue | |
| 136 | + amount = p.get("amount_usd") | |
| 137 | + try: | |
| 138 | + amount = float(amount) if amount not in (None, "", "null") else None | |
| 139 | + except (TypeError, ValueError): | |
| 140 | + amount = None | |
| 141 | + anchor = f"{ptype}:{str(p.get('project_name',''))[:40]}" | |
| 142 | + conf = p.get("confidence", 0.6) | |
| 143 | + try: | |
| 144 | + conf = float(conf) | |
| 145 | + except (TypeError, ValueError): | |
| 146 | + conf = 0.6 | |
| 147 | + out.append(ProjectMention( | |
| 148 | + mention_id=ProjectMention.make_id(accession_number, section_id, ptype, anchor), | |
| 149 | + accession_number=accession_number, cik=cik, | |
| 150 | + section_id=section_id, section_name=section_name, | |
| 151 | + project_name=str(p.get("project_name") or tx.TYPE_LABELS.get(ptype, ptype))[:200], | |
| 152 | + project_type=ptype, description=desc, | |
| 153 | + objective=str(p.get("objective", ""))[:400], | |
| 154 | + amount_usd=amount, | |
| 155 | + amount_raw=(f"${amount:,.0f}" if amount else None), | |
| 156 | + locations=_strlist(p.get("locations")), | |
| 157 | + technologies=_strlist(p.get("technologies")) or tx.extract_technologies(desc), | |
| 158 | + partners=_strlist(p.get("partners")), | |
| 159 | + benefits=_strlist(p.get("benefits")), | |
| 160 | + risks=_strlist(p.get("risks")), | |
| 161 | + years=_intlist(p.get("years")) or tx.extract_years(desc), | |
| 162 | + status=self._norm_status(p.get("status")), | |
| 163 | + confidence=round(max(0.0, min(1.0, conf)), 3), | |
| 164 | + backend=self.name, | |
| 165 | + )) | |
| 166 | + return out | |
| 167 | + | |
| 168 | + @staticmethod | |
| 169 | + def _norm_type(raw: str) -> str: | |
| 170 | + raw = (raw or "").strip().lower().replace(" ", "_").replace("-", "_") | |
| 171 | + if raw in tx.PROJECT_TYPES: | |
| 172 | + return raw | |
| 173 | + # fuzzy contains match against labels/keys | |
| 174 | + for k, label in tx.TYPE_LABELS.items(): | |
| 175 | + if raw and (raw in k or raw in label.lower().replace(" ", "_")): | |
| 176 | + return k | |
| 177 | + return "technology_deployment" | |
| 178 | + | |
| 179 | + @staticmethod | |
| 180 | + def _norm_status(raw) -> str: | |
| 181 | + raw = (str(raw or "")).strip().lower() | |
| 182 | + return raw if raw in ("planned", "in_progress", "completed", "mentioned") else "mentioned" | |
| 183 | + | |
| 184 | + | |
| 185 | +def _strlist(v) -> list[str]: | |
| 186 | + if not v: | |
| 187 | + return [] | |
| 188 | + if isinstance(v, str): | |
| 189 | + v = [v] | |
| 190 | + return [str(x).strip()[:120] for x in v if str(x).strip()][:8] | |
| 191 | + | |
| 192 | + | |
| 193 | +def _intlist(v) -> list[int]: | |
| 194 | + out = [] | |
| 195 | + for x in (v or []): | |
| 196 | + try: | |
| 197 | + y = int(str(x)[:4]) | |
| 198 | + if 1980 <= y <= 2040: | |
| 199 | + out.append(y) | |
| 200 | + except (TypeError, ValueError): | |
| 201 | + continue | |
| 202 | + return sorted(set(out)) | |
added
code/audit/spid_snapshot/pipeline.py
+95 −0
@@ -0,0 +1,95 @@ | ||
| 1 | +# ============================================================ | |
| 2 | +# SPID — extraction engine | |
| 3 | +# | |
| 4 | +# Iterates enriched sections, runs the chosen extractor | |
| 5 | +# (rules or gpt-4o-mini), filters by confidence, and loads | |
| 6 | +# mentions into DuckDB. LLM calls are threaded (IO-bound). | |
| 7 | +# ============================================================ | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from concurrent.futures import ThreadPoolExecutor, as_completed | |
| 11 | + | |
| 12 | +from spid.config import get_logger, get_settings | |
| 13 | +from spid.extract.base import get_extractor | |
| 14 | +from spid.store import Store | |
| 15 | + | |
| 16 | +log = get_logger("pipeline") | |
| 17 | + | |
| 18 | + | |
| 19 | +def _ctx_from_rows(rows: list[dict]) -> dict: | |
| 20 | + ctx = {} | |
| 21 | + for r in rows: | |
| 22 | + ctx.setdefault(r["accession_number"], { | |
| 23 | + "ticker": r.get("ticker"), | |
| 24 | + "company_name": r.get("company_name") or r.get("filing_company"), | |
| 25 | + "filing_company": r.get("filing_company"), | |
| 26 | + "form_type": r.get("form_type"), | |
| 27 | + "filing_date": r.get("filing_date"), | |
| 28 | + "fiscal_year": r.get("fiscal_year"), | |
| 29 | + "sector": r.get("sector"), | |
| 30 | + }) | |
| 31 | + return ctx | |
| 32 | + | |
| 33 | + | |
| 34 | +def run_extraction(store: Store, *, backend: str | None = None, | |
| 35 | + ciks: list[str] | None = None, forms: list[str] | None = None, | |
| 36 | + limit_sections: int | None = None, min_words: int = 30, | |
| 37 | + workers: int = 8, min_confidence: float | None = None, | |
| 38 | + flush_every: int = 500) -> dict: | |
| 39 | + s = get_settings() | |
| 40 | + backend = backend or s.backend | |
| 41 | + min_conf = s.min_confidence if min_confidence is None else min_confidence | |
| 42 | + extractor = get_extractor(backend) | |
| 43 | + | |
| 44 | + rows = store.sections_for(ciks=ciks, forms=forms, limit=limit_sections, min_words=min_words) | |
| 45 | + ctx = _ctx_from_rows(rows) | |
| 46 | + log.info("Extracting from %d sections | backend=%s | workers=%d", len(rows), backend, workers) | |
| 47 | + | |
| 48 | + kept = 0 | |
| 49 | + buf = [] | |
| 50 | + done_rows: list[tuple] = [] | |
| 51 | + | |
| 52 | + def work(r: dict): | |
| 53 | + return extractor.extract(text=r["text"], accession_number=r["accession_number"], | |
| 54 | + cik=r["cik"], section_id=r["section_id"], | |
| 55 | + section_name=r["section_name"]) | |
| 56 | + | |
| 57 | + def flush(): | |
| 58 | + nonlocal kept, buf, done_rows | |
| 59 | + if buf: | |
| 60 | + kept += store.upsert_mentions(buf, ctx) | |
| 61 | + buf = [] | |
| 62 | + if done_rows: | |
| 63 | + store.mark_extracted(done_rows) | |
| 64 | + done_rows = [] | |
| 65 | + | |
| 66 | + done = 0 | |
| 67 | + if backend == "llm" and workers > 1: | |
| 68 | + with ThreadPoolExecutor(max_workers=workers) as ex: | |
| 69 | + futs = {ex.submit(work, r): r for r in rows} | |
| 70 | + for fut in as_completed(futs): | |
| 71 | + done += 1 | |
| 72 | + r = futs[fut] | |
| 73 | + try: | |
| 74 | + ms = [m for m in fut.result() if m.confidence >= min_conf] | |
| 75 | + done_rows.append((r["section_id"], r["accession_number"], backend, len(ms))) | |
| 76 | + except Exception as e: # noqa: BLE001 | |
| 77 | + log.warning("section failed: %s", e); ms = [] | |
| 78 | + buf.extend(ms) | |
| 79 | + if len(buf) >= flush_every: | |
| 80 | + flush() | |
| 81 | + if done % 200 == 0: | |
| 82 | + log.info(" ... %d/%d sections, %d mentions kept", done, len(rows), kept + len(buf)) | |
| 83 | + else: | |
| 84 | + for r in rows: | |
| 85 | + done += 1 | |
| 86 | + ms = [m for m in work(r) if m.confidence >= min_conf] | |
| 87 | + done_rows.append((r["section_id"], r["accession_number"], backend, len(ms))) | |
| 88 | + buf.extend(ms) | |
| 89 | + if len(buf) >= flush_every: | |
| 90 | + flush() | |
| 91 | + if done % 2000 == 0: | |
| 92 | + log.info(" ... %d/%d sections, %d mentions kept", done, len(rows), kept + len(buf)) | |
| 93 | + flush() | |
| 94 | + log.info("Extraction complete: %d mentions from %d sections.", kept, len(rows)) | |
| 95 | + return {"sections": len(rows), "mentions": kept, "backend": backend} | |
added
code/audit/spid_snapshot/resolve.py
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
code/audit/spid_snapshot/rules.py
+120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added
code/audit/spid_snapshot/store.py
+239 −0
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
added
code/audit/spid_snapshot/taxonomy.py
+261 −0
Large diff (261 lines) is not loaded by default to keep the page fast.Load diff
added
results/n0_probe/manifest.json
+27 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
results/n0_probe/schema.json
+937 −0
Large diff (939 lines) is not loaded by default to keep the page fast.Load diff