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
@@ -0,0 +1,75 @@ | |||||
| 1 | +# ============================================================ | ||||
| 2 | +# SPID — project resolution & timelines | ||||
| 3 | +# | ||||
| 4 | +# Merges many mentions (across filings/years) into a single | ||||
| 5 | +# Project record + a chronological timeline. Pure DuckDB SQL | ||||
| 6 | +# so it scales to millions of mentions. | ||||
| 7 | +# ============================================================ | ||||
| 8 | +from __future__ import annotations | ||||
| 9 | + | ||||
| 10 | +from spid.config import get_logger | ||||
| 11 | +from spid.store import Store | ||||
| 12 | + | ||||
| 13 | +log = get_logger("resolve") | ||||
| 14 | + | ||||
| 15 | +# Grouping key: a project is identified by (company, type, anchor) | ||||
| 16 | +# where anchor = first location if present, else first salient name token. | ||||
| 17 | +_BUILD_PROJECTS = """ | ||||
| 18 | +CREATE OR REPLACE TEMP TABLE _m AS | ||||
| 19 | +SELECT *, | ||||
| 20 | + lower(coalesce(try(locations[1]), '')) AS loc1, | ||||
| 21 | + regexp_extract( | ||||
| 22 | + lower(regexp_replace(project_name, '[^A-Za-z ]', '', 'g')), | ||||
| 23 | + '([a-z]{4,})', 1) AS name_tok | ||||
| 24 | +FROM project_mentions; | ||||
| 25 | + | ||||
| 26 | +CREATE OR REPLACE TEMP TABLE _keyed AS | ||||
| 27 | +SELECT *, | ||||
| 28 | + cik || '|' || project_type || '|' || | ||||
| 29 | + CASE WHEN loc1 <> '' THEN loc1 ELSE coalesce(nullif(name_tok,''),'general') END AS gkey, | ||||
| 30 | + md5(cik || '|' || project_type || '|' || | ||||
| 31 | + CASE WHEN loc1 <> '' THEN loc1 ELSE coalesce(nullif(name_tok,''),'general') END) AS project_id | ||||
| 32 | +FROM _m; | ||||
| 33 | + | ||||
| 34 | +DELETE FROM projects; | ||||
| 35 | +INSERT INTO projects | ||||
| 36 | +SELECT | ||||
| 37 | + project_id, | ||||
| 38 | + any_value(cik) AS cik, | ||||
| 39 | + any_value(ticker) AS ticker, | ||||
| 40 | + any_value(company_name) AS company_name, | ||||
| 41 | + any_value(sector) AS sector, | ||||
| 42 | + any_value(project_type) AS project_type, | ||||
| 43 | + -- representative name: the highest-confidence mention's name | ||||
| 44 | + arg_max(project_name, confidence) AS project_name, | ||||
| 45 | + nullif(any_value(loc1), '') AS canonical_location, | ||||
| 46 | + list_distinct(flatten(list(technologies))) AS technologies, | ||||
| 47 | + -- latest-by-date status | ||||
| 48 | + arg_max(status, filing_date) AS status, | ||||
| 49 | + max(amount_usd) AS total_amount_usd, | ||||
| 50 | + count(*) AS n_mentions, | ||||
| 51 | + count(DISTINCT accession_number) AS n_filings, | ||||
| 52 | + min(filing_date) AS first_seen, | ||||
| 53 | + max(filing_date) AS last_seen, | ||||
| 54 | + min(list_min(years)) AS first_year, | ||||
| 55 | + max(list_max(years)) AS last_year, | ||||
| 56 | + round(avg(confidence), 3) AS avg_confidence, | ||||
| 57 | + arg_max(description, confidence) AS description | ||||
| 58 | +FROM _keyed | ||||
| 59 | +GROUP BY project_id; | ||||
| 60 | + | ||||
| 61 | +DELETE FROM project_timeline; | ||||
| 62 | +INSERT INTO project_timeline | ||||
| 63 | +SELECT project_id, accession_number, filing_date, form_type, status, amount_usd, | ||||
| 64 | + confidence, left(description, 300) AS snippet | ||||
| 65 | +FROM _keyed | ||||
| 66 | +ORDER BY project_id, filing_date; | ||||
| 67 | +""" | ||||
| 68 | + | ||||
| 69 | + | ||||
| 70 | +def resolve_projects(store: Store) -> dict: | ||||
| 71 | + store.con.execute(_BUILD_PROJECTS) | ||||
| 72 | + n_proj = store.count("projects") | ||||
| 73 | + n_tl = store.count("project_timeline") | ||||
| 74 | + log.info("Resolved %d projects from mentions (%d timeline rows).", n_proj, n_tl) | ||||
| 75 | + return {"projects": n_proj, "timeline_rows": n_tl} | ||||
added
code/audit/spid_snapshot/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_snapshot/store.py
+239 −0
@@ -0,0 +1,239 @@ | |||||
| 1 | +# ============================================================ | ||||
| 2 | +# SPID — DuckDB store | ||||
| 3 | +# | ||||
| 4 | +# Source corpus (filings / sections / companies) is exposed as | ||||
| 5 | +# read-only views over the existing parquet. SPID writes its | ||||
| 6 | +# own tables: mentions, projects, timeline, graph, embeddings. | ||||
| 7 | +# ============================================================ | ||||
| 8 | +from __future__ import annotations | ||||
| 9 | + | ||||
| 10 | +from pathlib import Path | ||||
| 11 | + | ||||
| 12 | +import duckdb | ||||
| 13 | + | ||||
| 14 | +from spid.config import Settings, get_settings | ||||
| 15 | +from spid.extract.base import ProjectMention | ||||
| 16 | + | ||||
| 17 | +SCHEMA = """ | ||||
| 18 | +CREATE TABLE IF NOT EXISTS project_mentions ( | ||||
| 19 | + mention_id VARCHAR PRIMARY KEY, | ||||
| 20 | + accession_number VARCHAR, | ||||
| 21 | + cik VARCHAR, | ||||
| 22 | + section_id VARCHAR, | ||||
| 23 | + section_name VARCHAR, | ||||
| 24 | + project_name VARCHAR, | ||||
| 25 | + project_type VARCHAR, | ||||
| 26 | + description VARCHAR, | ||||
| 27 | + objective VARCHAR, | ||||
| 28 | + amount_usd DOUBLE, | ||||
| 29 | + amount_raw VARCHAR, | ||||
| 30 | + locations VARCHAR[], | ||||
| 31 | + technologies VARCHAR[], | ||||
| 32 | + partners VARCHAR[], | ||||
| 33 | + suppliers VARCHAR[], | ||||
| 34 | + benefits VARCHAR[], | ||||
| 35 | + risks VARCHAR[], | ||||
| 36 | + years INTEGER[], | ||||
| 37 | + status VARCHAR, | ||||
| 38 | + confidence DOUBLE, | ||||
| 39 | + backend VARCHAR, | ||||
| 40 | + -- denormalised filing context (filled on load) | ||||
| 41 | + ticker VARCHAR, | ||||
| 42 | + company_name VARCHAR, | ||||
| 43 | + form_type VARCHAR, | ||||
| 44 | + filing_date DATE, | ||||
| 45 | + fiscal_year INTEGER, | ||||
| 46 | + sector VARCHAR | ||||
| 47 | +); | ||||
| 48 | + | ||||
| 49 | +CREATE TABLE IF NOT EXISTS projects ( | ||||
| 50 | + project_id VARCHAR PRIMARY KEY, | ||||
| 51 | + cik VARCHAR, | ||||
| 52 | + ticker VARCHAR, | ||||
| 53 | + company_name VARCHAR, | ||||
| 54 | + sector VARCHAR, | ||||
| 55 | + project_type VARCHAR, | ||||
| 56 | + project_name VARCHAR, | ||||
| 57 | + canonical_location VARCHAR, | ||||
| 58 | + technologies VARCHAR[], | ||||
| 59 | + status VARCHAR, | ||||
| 60 | + total_amount_usd DOUBLE, | ||||
| 61 | + n_mentions INTEGER, | ||||
| 62 | + n_filings INTEGER, | ||||
| 63 | + first_seen DATE, | ||||
| 64 | + last_seen DATE, | ||||
| 65 | + first_year INTEGER, | ||||
| 66 | + last_year INTEGER, | ||||
| 67 | + avg_confidence DOUBLE, | ||||
| 68 | + description VARCHAR | ||||
| 69 | +); | ||||
| 70 | + | ||||
| 71 | +CREATE TABLE IF NOT EXISTS project_timeline ( | ||||
| 72 | + project_id VARCHAR, | ||||
| 73 | + accession_number VARCHAR, | ||||
| 74 | + filing_date DATE, | ||||
| 75 | + form_type VARCHAR, | ||||
| 76 | + status VARCHAR, | ||||
| 77 | + amount_usd DOUBLE, | ||||
| 78 | + confidence DOUBLE, | ||||
| 79 | + snippet VARCHAR | ||||
| 80 | +); | ||||
| 81 | + | ||||
| 82 | +CREATE TABLE IF NOT EXISTS kg_nodes ( | ||||
| 83 | + node_id VARCHAR PRIMARY KEY, | ||||
| 84 | + node_type VARCHAR, -- Company | Project | Technology | Location | Partner | ||||
| 85 | + label VARCHAR, | ||||
| 86 | + props JSON | ||||
| 87 | +); | ||||
| 88 | + | ||||
| 89 | +CREATE TABLE IF NOT EXISTS kg_edges ( | ||||
| 90 | + src VARCHAR, | ||||
| 91 | + rel VARCHAR, -- owns | uses | located_in | partners_with | requires_capital | ||||
| 92 | + dst VARCHAR, | ||||
| 93 | + props JSON | ||||
| 94 | +); | ||||
| 95 | + | ||||
| 96 | +CREATE TABLE IF NOT EXISTS project_embeddings ( | ||||
| 97 | + project_id VARCHAR PRIMARY KEY, | ||||
| 98 | + model VARCHAR, | ||||
| 99 | + dim INTEGER, | ||||
| 100 | + vector FLOAT[] | ||||
| 101 | +); | ||||
| 102 | +""" | ||||
| 103 | + | ||||
| 104 | + | ||||
| 105 | +class Store: | ||||
| 106 | + def __init__(self, settings: Settings | None = None, read_only: bool = False): | ||||
| 107 | + self.s = settings or get_settings() | ||||
| 108 | + self.s.db_path.parent.mkdir(parents=True, exist_ok=True) | ||||
| 109 | + self.con = duckdb.connect(str(self.s.db_path), read_only=read_only) | ||||
| 110 | + if not read_only: | ||||
| 111 | + self._init() | ||||
| 112 | + | ||||
| 113 | + def _init(self) -> None: | ||||
| 114 | + self.con.execute( | ||||
| 115 | + "CREATE TABLE IF NOT EXISTS spid_sections (" | ||||
| 116 | + "section_id VARCHAR PRIMARY KEY, accession_number VARCHAR, section_name VARCHAR," | ||||
| 117 | + "item_number VARCHAR, text VARCHAR, word_count INTEGER)") | ||||
| 118 | + self._register_sources() | ||||
| 119 | + self.con.execute(SCHEMA) | ||||
| 120 | + | ||||
| 121 | + def _register_sources(self) -> None: | ||||
| 122 | + c = self.con | ||||
| 123 | + c.execute( | ||||
| 124 | + "CREATE TABLE IF NOT EXISTS extracted_sections (" | ||||
| 125 | + "section_id VARCHAR PRIMARY KEY, accession_number VARCHAR, backend VARCHAR," | ||||
| 126 | + "n_mentions INTEGER, extracted_at TIMESTAMP DEFAULT current_timestamp)") | ||||
| 127 | + c.execute(f"CREATE OR REPLACE VIEW src_filings AS SELECT * FROM read_parquet('{self.s.filings_parquet}')") | ||||
| 128 | + c.execute(f"CREATE OR REPLACE VIEW src_sections AS SELECT * FROM read_parquet('{self.s.sections_parquet}')") | ||||
| 129 | + c.execute(f"CREATE OR REPLACE VIEW src_companies AS SELECT * FROM read_parquet('{self.s.companies_parquet}')") | ||||
| 130 | + # unified, enriched section feed: pre-parsed sections (all forms since 2026-10-27) | ||||
| 131 | + # UNION ingested 10-K items (legacy S&P 500 run); a 10-K already ingested in | ||||
| 132 | + # spid_sections is not read a second time from the parsed corpus. | ||||
| 133 | + scope = self.s.section_scope | ||||
| 134 | + scope_sql = "" | ||||
| 135 | + if scope: | ||||
| 136 | + parts = [] | ||||
| 137 | + for form, items in scope.items(): | ||||
| 138 | + q = ",".join("'" + i + "'" for i in items) | ||||
| 139 | + parts.append(f"(form_type = '{form}' AND section_name IN ({q}))") | ||||
| 140 | + others = ",".join("'" + f + "'" for f in scope) | ||||
| 141 | + scope_sql = " WHERE " + " OR ".join(parts) + f" OR form_type NOT IN ({others})" | ||||
| 142 | + c.execute(f""" | ||||
| 143 | + CREATE OR REPLACE VIEW v_sections AS | ||||
| 144 | + WITH parsed AS ( | ||||
| 145 | + SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.text, s.word_count, | ||||
| 146 | + f.form_type | ||||
| 147 | + FROM src_sections s JOIN src_filings f USING (accession_number) | ||||
| 148 | + WHERE NOT (f.form_type = '10-K' | ||||
| 149 | + AND s.accession_number IN (SELECT DISTINCT accession_number FROM spid_sections)) | ||||
| 150 | + ), | ||||
| 151 | + all_sec AS ( | ||||
| 152 | + SELECT section_id, accession_number, section_name, item_number, text, word_count | ||||
| 153 | + FROM parsed{scope_sql} | ||||
| 154 | + UNION ALL | ||||
| 155 | + SELECT section_id, accession_number, section_name, item_number, text, word_count | ||||
| 156 | + FROM spid_sections | ||||
| 157 | + ) | ||||
| 158 | + SELECT s.section_id, s.accession_number, s.section_name, s.item_number, | ||||
| 159 | + s.text, s.word_count, | ||||
| 160 | + f.cik, f.ticker, f.company_name AS filing_company, f.form_type, | ||||
| 161 | + CAST(f.filing_date AS DATE) AS filing_date, f.fiscal_year, | ||||
| 162 | + co.company_name AS company_name, co.sector, co.sub_industry, co.sic_code, co.state | ||||
| 163 | + FROM all_sec s | ||||
| 164 | + JOIN src_filings f USING (accession_number) | ||||
| 165 | + LEFT JOIN src_companies co ON f.cik = co.cik | ||||
| 166 | + """) | ||||
| 167 | + | ||||
| 168 | + # ---- read side ---------------------------------------------------- | ||||
| 169 | + def companies(self) -> list[tuple]: | ||||
| 170 | + return self.con.execute( | ||||
| 171 | + "SELECT DISTINCT cik, ticker FROM src_filings ORDER BY cik" | ||||
| 172 | + ).fetchall() | ||||
| 173 | + | ||||
| 174 | + def sections_for(self, *, ciks: list[str] | None = None, forms: list[str] | None = None, | ||||
| 175 | + limit: int | None = None, min_words: int = 30, skip_done: bool = True) -> list[dict]: | ||||
| 176 | + where = [f"word_count >= {int(min_words)}"] | ||||
| 177 | + if skip_done: | ||||
| 178 | + where.append("section_id NOT IN (SELECT section_id FROM extracted_sections)") | ||||
| 179 | + if ciks: | ||||
| 180 | + quoted = ",".join("'" + c.replace("'", "") + "'" for c in ciks) | ||||
| 181 | + where.append(f"cik IN ({quoted})") | ||||
| 182 | + if forms: | ||||
| 183 | + quoted = ",".join("'" + f.replace("'", "") + "'" for f in forms) | ||||
| 184 | + where.append(f"form_type IN ({quoted})") | ||||
| 185 | + sql = "SELECT * FROM v_sections WHERE " + " AND ".join(where) | ||||
| 186 | + sql += " ORDER BY filing_date" | ||||
| 187 | + if limit: | ||||
| 188 | + sql += f" LIMIT {int(limit)}" | ||||
| 189 | + res = self.con.execute(sql) | ||||
| 190 | + table = res.to_arrow_table() if hasattr(res, "to_arrow_table") else res.fetch_arrow_table() # DuckDB >= 2.0 / < 2.0 | ||||
| 191 | + return table.to_pylist() | ||||
| 192 | + | ||||
| 193 | + # ---- write side --------------------------------------------------- | ||||
| 194 | + def upsert_mentions(self, mentions: list[ProjectMention], ctx: dict) -> int: | ||||
| 195 | + """ctx maps accession_number -> filing context dict.""" | ||||
| 196 | + if not mentions: | ||||
| 197 | + return 0 | ||||
| 198 | + rows = [] | ||||
| 199 | + for m in mentions: | ||||
| 200 | + cx = ctx.get(m.accession_number, {}) | ||||
| 201 | + rows.append(( | ||||
| 202 | + m.mention_id, m.accession_number, m.cik, m.section_id, m.section_name, | ||||
| 203 | + m.project_name, m.project_type, m.description, m.objective, | ||||
| 204 | + m.amount_usd, m.amount_raw, m.locations, m.technologies, m.partners, | ||||
| 205 | + m.suppliers, m.benefits, m.risks, m.years, m.status, m.confidence, m.backend, | ||||
| 206 | + cx.get("ticker"), cx.get("company_name") or cx.get("filing_company"), | ||||
| 207 | + cx.get("form_type"), cx.get("filing_date"), cx.get("fiscal_year"), cx.get("sector"), | ||||
| 208 | + )) | ||||
| 209 | + self.con.executemany( | ||||
| 210 | + "INSERT OR REPLACE INTO project_mentions VALUES " | ||||
| 211 | + "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", rows) | ||||
| 212 | + return len(rows) | ||||
| 213 | + | ||||
| 214 | + def mark_extracted(self, rows: list[tuple]) -> None: | ||||
| 215 | + """rows: (section_id, accession_number, backend, n_mentions).""" | ||||
| 216 | + if rows: | ||||
| 217 | + self.con.executemany( | ||||
| 218 | + "INSERT OR REPLACE INTO extracted_sections (section_id, accession_number, backend, n_mentions) " | ||||
| 219 | + "VALUES (?,?,?,?)", rows) | ||||
| 220 | + | ||||
| 221 | + def backfill_extracted(self, backend: str = "llm") -> int: | ||||
| 222 | + """Record every section currently visible in v_sections as already extracted. | ||||
| 223 | + | ||||
| 224 | + Used once, before the Russell 3000 corpus is merged, so the legacy S&P 500 run | ||||
| 225 | + (whose sections were processed without a ledger) is not re-billed. | ||||
| 226 | + """ | ||||
| 227 | + self.con.execute(f""" | ||||
| 228 | + INSERT OR REPLACE INTO extracted_sections (section_id, accession_number, backend, n_mentions) | ||||
| 229 | + SELECT v.section_id, v.accession_number, '{backend}', | ||||
| 230 | + (SELECT COUNT(*) FROM project_mentions m WHERE m.section_id = v.section_id) | ||||
| 231 | + FROM v_sections v | ||||
| 232 | + """) | ||||
| 233 | + return self.count("extracted_sections") | ||||
| 234 | + | ||||
| 235 | + def count(self, table: str) -> int: | ||||
| 236 | + return self.con.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] | ||||
| 237 | + | ||||
| 238 | + def close(self) -> None: | ||||
| 239 | + self.con.close() | ||||
added
code/audit/spid_snapshot/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
results/n0_probe/manifest.json
+27 −0
@@ -0,0 +1,27 @@ | |||||
| 1 | +{ | ||||
| 2 | + "package": "n0_probe", | ||||
| 3 | + "script": "/Users/simon-pierreboucher/firc0001_review/scripts/a00_probe_schema.py", | ||||
| 4 | + "script_sha256": "b3bd5ca2a11782452a7d9c78b7f1ff63236fc60847ad4fc16914c1383f3b5a93", | ||||
| 5 | + "started": "2026-11-05T14:10:08", | ||||
| 6 | + "ended": "2026-11-05T14:10:17", | ||||
| 7 | + "wall_seconds": 8.3, | ||||
| 8 | + "node": "m3u96b", | ||||
| 9 | + "python": "3.12.15", | ||||
| 10 | + "duckdb": "2.0.0", | ||||
| 11 | + "threads": 4, | ||||
| 12 | + "memory_limit": "24GB", | ||||
| 13 | + "seed": 20261105, | ||||
| 14 | + "databases": { | ||||
| 15 | + "/Users/simon-pierreboucher/UQO_RESEARCH_DB/project-db/data/db/spid.duckdb": { | ||||
| 16 | + "bytes": 3155701760, | ||||
| 17 | + "mtime": "2026-10-28T12:21:11.091389" | ||||
| 18 | + }, | ||||
| 19 | + "/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/duckdb/edgar_research.duckdb": { | ||||
| 20 | + "bytes": 73500995584, | ||||
| 21 | + "mtime": "2026-10-27T21:34:46.297884" | ||||
| 22 | + } | ||||
| 23 | + }, | ||||
| 24 | + "read_only": true, | ||||
| 25 | + "row_counts": {}, | ||||
| 26 | + "notes": {} | ||||
| 27 | +} | ||||
| \ No newline at end of file | |||||
added
results/n0_probe/schema.json
+937 −0
@@ -0,0 +1,937 @@ | |||||
| 1 | +{ | ||||
| 2 | + "extracted_sections": [ | ||||
| 3 | + [ | ||||
| 4 | + "section_id", | ||||
| 5 | + "VARCHAR" | ||||
| 6 | + ], | ||||
| 7 | + [ | ||||
| 8 | + "accession_number", | ||||
| 9 | + "VARCHAR" | ||||
| 10 | + ], | ||||
| 11 | + [ | ||||
| 12 | + "backend", | ||||
| 13 | + "VARCHAR" | ||||
| 14 | + ], | ||||
| 15 | + [ | ||||
| 16 | + "n_mentions", | ||||
| 17 | + "INTEGER" | ||||
| 18 | + ], | ||||
| 19 | + [ | ||||
| 20 | + "extracted_at", | ||||
| 21 | + "TIMESTAMP" | ||||
| 22 | + ] | ||||
| 23 | + ], | ||||
| 24 | + "project_mentions": [ | ||||
| 25 | + [ | ||||
| 26 | + "mention_id", | ||||
| 27 | + "VARCHAR" | ||||
| 28 | + ], | ||||
| 29 | + [ | ||||
| 30 | + "accession_number", | ||||
| 31 | + "VARCHAR" | ||||
| 32 | + ], | ||||
| 33 | + [ | ||||
| 34 | + "cik", | ||||
| 35 | + "VARCHAR" | ||||
| 36 | + ], | ||||
| 37 | + [ | ||||
| 38 | + "section_id", | ||||
| 39 | + "VARCHAR" | ||||
| 40 | + ], | ||||
| 41 | + [ | ||||
| 42 | + "section_name", | ||||
| 43 | + "VARCHAR" | ||||
| 44 | + ], | ||||
| 45 | + [ | ||||
| 46 | + "project_name", | ||||
| 47 | + "VARCHAR" | ||||
| 48 | + ], | ||||
| 49 | + [ | ||||
| 50 | + "project_type", | ||||
| 51 | + "VARCHAR" | ||||
| 52 | + ], | ||||
| 53 | + [ | ||||
| 54 | + "description", | ||||
| 55 | + "VARCHAR" | ||||
| 56 | + ], | ||||
| 57 | + [ | ||||
| 58 | + "objective", | ||||
| 59 | + "VARCHAR" | ||||
| 60 | + ], | ||||
| 61 | + [ | ||||
| 62 | + "amount_usd", | ||||
| 63 | + "DOUBLE" | ||||
| 64 | + ], | ||||
| 65 | + [ | ||||
| 66 | + "amount_raw", | ||||
| 67 | + "VARCHAR" | ||||
| 68 | + ], | ||||
| 69 | + [ | ||||
| 70 | + "locations", | ||||
| 71 | + "VARCHAR[]" | ||||
| 72 | + ], | ||||
| 73 | + [ | ||||
| 74 | + "technologies", | ||||
| 75 | + "VARCHAR[]" | ||||
| 76 | + ], | ||||
| 77 | + [ | ||||
| 78 | + "partners", | ||||
| 79 | + "VARCHAR[]" | ||||
| 80 | + ], | ||||
| 81 | + [ | ||||
| 82 | + "suppliers", | ||||
| 83 | + "VARCHAR[]" | ||||
| 84 | + ], | ||||
| 85 | + [ | ||||
| 86 | + "benefits", | ||||
| 87 | + "VARCHAR[]" | ||||
| 88 | + ], | ||||
| 89 | + [ | ||||
| 90 | + "risks", | ||||
| 91 | + "VARCHAR[]" | ||||
| 92 | + ], | ||||
| 93 | + [ | ||||
| 94 | + "years", | ||||
| 95 | + "INTEGER[]" | ||||
| 96 | + ], | ||||
| 97 | + [ | ||||
| 98 | + "status", | ||||
| 99 | + "VARCHAR" | ||||
| 100 | + ], | ||||
| 101 | + [ | ||||
| 102 | + "confidence", | ||||
| 103 | + "DOUBLE" | ||||
| 104 | + ], | ||||
| 105 | + [ | ||||
| 106 | + "backend", | ||||
| 107 | + "VARCHAR" | ||||
| 108 | + ], | ||||
| 109 | + [ | ||||
| 110 | + "ticker", | ||||
| 111 | + "VARCHAR" | ||||
| 112 | + ], | ||||
| 113 | + [ | ||||
| 114 | + "company_name", | ||||
| 115 | + "VARCHAR" | ||||
| 116 | + ], | ||||
| 117 | + [ | ||||
| 118 | + "form_type", | ||||
| 119 | + "VARCHAR" | ||||
| 120 | + ], | ||||
| 121 | + [ | ||||
| 122 | + "filing_date", | ||||
| 123 | + "DATE" | ||||
| 124 | + ], | ||||
| 125 | + [ | ||||
| 126 | + "fiscal_year", | ||||
| 127 | + "INTEGER" | ||||
| 128 | + ], | ||||
| 129 | + [ | ||||
| 130 | + "sector", | ||||
| 131 | + "VARCHAR" | ||||
| 132 | + ] | ||||
| 133 | + ], | ||||
| 134 | + "projects": [ | ||||
| 135 | + [ | ||||
| 136 | + "project_id", | ||||
| 137 | + "VARCHAR" | ||||
| 138 | + ], | ||||
| 139 | + [ | ||||
| 140 | + "cik", | ||||
| 141 | + "VARCHAR" | ||||
| 142 | + ], | ||||
| 143 | + [ | ||||
| 144 | + "ticker", | ||||
| 145 | + "VARCHAR" | ||||
| 146 | + ], | ||||
| 147 | + [ | ||||
| 148 | + "company_name", | ||||
| 149 | + "VARCHAR" | ||||
| 150 | + ], | ||||
| 151 | + [ | ||||
| 152 | + "sector", | ||||
| 153 | + "VARCHAR" | ||||
| 154 | + ], | ||||
| 155 | + [ | ||||
| 156 | + "project_type", | ||||
| 157 | + "VARCHAR" | ||||
| 158 | + ], | ||||
| 159 | + [ | ||||
| 160 | + "project_name", | ||||
| 161 | + "VARCHAR" | ||||
| 162 | + ], | ||||
| 163 | + [ | ||||
| 164 | + "canonical_location", | ||||
| 165 | + "VARCHAR" | ||||
| 166 | + ], | ||||
| 167 | + [ | ||||
| 168 | + "technologies", | ||||
| 169 | + "VARCHAR[]" | ||||
| 170 | + ], | ||||
| 171 | + [ | ||||
| 172 | + "status", | ||||
| 173 | + "VARCHAR" | ||||
| 174 | + ], | ||||
| 175 | + [ | ||||
| 176 | + "total_amount_usd", | ||||
| 177 | + "DOUBLE" | ||||
| 178 | + ], | ||||
| 179 | + [ | ||||
| 180 | + "n_mentions", | ||||
| 181 | + "INTEGER" | ||||
| 182 | + ], | ||||
| 183 | + [ | ||||
| 184 | + "n_filings", | ||||
| 185 | + "INTEGER" | ||||
| 186 | + ], | ||||
| 187 | + [ | ||||
| 188 | + "first_seen", | ||||
| 189 | + "DATE" | ||||
| 190 | + ], | ||||
| 191 | + [ | ||||
| 192 | + "last_seen", | ||||
| 193 | + "DATE" | ||||
| 194 | + ], | ||||
| 195 | + [ | ||||
| 196 | + "first_year", | ||||
| 197 | + "INTEGER" | ||||
| 198 | + ], | ||||
| 199 | + [ | ||||
| 200 | + "last_year", | ||||
| 201 | + "INTEGER" | ||||
| 202 | + ], | ||||
| 203 | + [ | ||||
| 204 | + "avg_confidence", | ||||
| 205 | + "DOUBLE" | ||||
| 206 | + ], | ||||
| 207 | + [ | ||||
| 208 | + "description", | ||||
| 209 | + "VARCHAR" | ||||
| 210 | + ] | ||||
| 211 | + ], | ||||
| 212 | + "project_timeline": [ | ||||
| 213 | + [ | ||||
| 214 | + "project_id", | ||||
| 215 | + "VARCHAR" | ||||
| 216 | + ], | ||||
| 217 | + [ | ||||
| 218 | + "accession_number", | ||||
| 219 | + "VARCHAR" | ||||
| 220 | + ], | ||||
| 221 | + [ | ||||
| 222 | + "filing_date", | ||||
| 223 | + "DATE" | ||||
| 224 | + ], | ||||
| 225 | + [ | ||||
| 226 | + "form_type", | ||||
| 227 | + "VARCHAR" | ||||
| 228 | + ], | ||||
| 229 | + [ | ||||
| 230 | + "status", | ||||
| 231 | + "VARCHAR" | ||||
| 232 | + ], | ||||
| 233 | + [ | ||||
| 234 | + "amount_usd", | ||||
| 235 | + "DOUBLE" | ||||
| 236 | + ], | ||||
| 237 | + [ | ||||
| 238 | + "confidence", | ||||
| 239 | + "DOUBLE" | ||||
| 240 | + ], | ||||
| 241 | + [ | ||||
| 242 | + "snippet", | ||||
| 243 | + "VARCHAR" | ||||
| 244 | + ] | ||||
| 245 | + ], | ||||
| 246 | + "spid_sections": [ | ||||
| 247 | + [ | ||||
| 248 | + "section_id", | ||||
| 249 | + "VARCHAR" | ||||
| 250 | + ], | ||||
| 251 | + [ | ||||
| 252 | + "accession_number", | ||||
| 253 | + "VARCHAR" | ||||
| 254 | + ], | ||||
| 255 | + [ | ||||
| 256 | + "section_name", | ||||
| 257 | + "VARCHAR" | ||||
| 258 | + ], | ||||
| 259 | + [ | ||||
| 260 | + "item_number", | ||||
| 261 | + "VARCHAR" | ||||
| 262 | + ], | ||||
| 263 | + [ | ||||
| 264 | + "text", | ||||
| 265 | + "VARCHAR" | ||||
| 266 | + ], | ||||
| 267 | + [ | ||||
| 268 | + "word_count", | ||||
| 269 | + "INTEGER" | ||||
| 270 | + ] | ||||
| 271 | + ], | ||||
| 272 | + "v_sections": [ | ||||
| 273 | + [ | ||||
| 274 | + "section_id", | ||||
| 275 | + "VARCHAR" | ||||
| 276 | + ], | ||||
| 277 | + [ | ||||
| 278 | + "accession_number", | ||||
| 279 | + "VARCHAR" | ||||
| 280 | + ], | ||||
| 281 | + [ | ||||
| 282 | + "section_name", | ||||
| 283 | + "VARCHAR" | ||||
| 284 | + ], | ||||
| 285 | + [ | ||||
| 286 | + "item_number", | ||||
| 287 | + "VARCHAR" | ||||
| 288 | + ], | ||||
| 289 | + [ | ||||
| 290 | + "text", | ||||
| 291 | + "VARCHAR" | ||||
| 292 | + ], | ||||
| 293 | + [ | ||||
| 294 | + "word_count", | ||||
| 295 | + "INTEGER" | ||||
| 296 | + ], | ||||
| 297 | + [ | ||||
| 298 | + "cik", | ||||
| 299 | + "VARCHAR" | ||||
| 300 | + ], | ||||
| 301 | + [ | ||||
| 302 | + "ticker", | ||||
| 303 | + "VARCHAR" | ||||
| 304 | + ], | ||||
| 305 | + [ | ||||
| 306 | + "filing_company", | ||||
| 307 | + "VARCHAR" | ||||
| 308 | + ], | ||||
| 309 | + [ | ||||
| 310 | + "form_type", | ||||
| 311 | + "VARCHAR" | ||||
| 312 | + ], | ||||
| 313 | + [ | ||||
| 314 | + "filing_date", | ||||
| 315 | + "DATE" | ||||
| 316 | + ], | ||||
| 317 | + [ | ||||
| 318 | + "fiscal_year", | ||||
| 319 | + "INTEGER" | ||||
| 320 | + ], | ||||
| 321 | + [ | ||||
| 322 | + "company_name", | ||||
| 323 | + "VARCHAR" | ||||
| 324 | + ], | ||||
| 325 | + [ | ||||
| 326 | + "sector", | ||||
| 327 | + "VARCHAR" | ||||
| 328 | + ], | ||||
| 329 | + [ | ||||
| 330 | + "sub_industry", | ||||
| 331 | + "VARCHAR" | ||||
| 332 | + ], | ||||
| 333 | + [ | ||||
| 334 | + "sic_code", | ||||
| 335 | + "VARCHAR" | ||||
| 336 | + ], | ||||
| 337 | + [ | ||||
| 338 | + "state", | ||||
| 339 | + "VARCHAR" | ||||
| 340 | + ] | ||||
| 341 | + ], | ||||
| 342 | + "src_filings": [ | ||||
| 343 | + [ | ||||
| 344 | + "accession_number", | ||||
| 345 | + "VARCHAR" | ||||
| 346 | + ], | ||||
| 347 | + [ | ||||
| 348 | + "cik", | ||||
| 349 | + "VARCHAR" | ||||
| 350 | + ], | ||||
| 351 | + [ | ||||
| 352 | + "ticker", | ||||
| 353 | + "VARCHAR" | ||||
| 354 | + ], | ||||
| 355 | + [ | ||||
| 356 | + "company_name", | ||||
| 357 | + "VARCHAR" | ||||
| 358 | + ], | ||||
| 359 | + [ | ||||
| 360 | + "form_type", | ||||
| 361 | + "VARCHAR" | ||||
| 362 | + ], | ||||
| 363 | + [ | ||||
| 364 | + "filing_date", | ||||
| 365 | + "DATE" | ||||
| 366 | + ], | ||||
| 367 | + [ | ||||
| 368 | + "report_date", | ||||
| 369 | + "DATE" | ||||
| 370 | + ], | ||||
| 371 | + [ | ||||
| 372 | + "fiscal_year", | ||||
| 373 | + "INTEGER" | ||||
| 374 | + ], | ||||
| 375 | + [ | ||||
| 376 | + "fiscal_quarter", | ||||
| 377 | + "INTEGER" | ||||
| 378 | + ], | ||||
| 379 | + [ | ||||
| 380 | + "sec_url", | ||||
| 381 | + "VARCHAR" | ||||
| 382 | + ], | ||||
| 383 | + [ | ||||
| 384 | + "local_path", | ||||
| 385 | + "VARCHAR" | ||||
| 386 | + ], | ||||
| 387 | + [ | ||||
| 388 | + "primary_document", | ||||
| 389 | + "VARCHAR" | ||||
| 390 | + ], | ||||
| 391 | + [ | ||||
| 392 | + "file_size_bytes", | ||||
| 393 | + "BIGINT" | ||||
| 394 | + ], | ||||
| 395 | + [ | ||||
| 396 | + "download_status", | ||||
| 397 | + "VARCHAR" | ||||
| 398 | + ], | ||||
| 399 | + [ | ||||
| 400 | + "parse_status", | ||||
| 401 | + "VARCHAR" | ||||
| 402 | + ], | ||||
| 403 | + [ | ||||
| 404 | + "created_at", | ||||
| 405 | + "TIMESTAMP" | ||||
| 406 | + ], | ||||
| 407 | + [ | ||||
| 408 | + "updated_at", | ||||
| 409 | + "TIMESTAMP" | ||||
| 410 | + ] | ||||
| 411 | + ], | ||||
| 412 | + "src_sections": [ | ||||
| 413 | + [ | ||||
| 414 | + "section_id", | ||||
| 415 | + "VARCHAR" | ||||
| 416 | + ], | ||||
| 417 | + [ | ||||
| 418 | + "accession_number", | ||||
| 419 | + "VARCHAR" | ||||
| 420 | + ], | ||||
| 421 | + [ | ||||
| 422 | + "section_name", | ||||
| 423 | + "VARCHAR" | ||||
| 424 | + ], | ||||
| 425 | + [ | ||||
| 426 | + "item_number", | ||||
| 427 | + "VARCHAR" | ||||
| 428 | + ], | ||||
| 429 | + [ | ||||
| 430 | + "section_order", | ||||
| 431 | + "INTEGER" | ||||
| 432 | + ], | ||||
| 433 | + [ | ||||
| 434 | + "text", | ||||
| 435 | + "VARCHAR" | ||||
| 436 | + ], | ||||
| 437 | + [ | ||||
| 438 | + "word_count", | ||||
| 439 | + "INTEGER" | ||||
| 440 | + ], | ||||
| 441 | + [ | ||||
| 442 | + "char_count", | ||||
| 443 | + "INTEGER" | ||||
| 444 | + ], | ||||
| 445 | + [ | ||||
| 446 | + "created_at", | ||||
| 447 | + "TIMESTAMP" | ||||
| 448 | + ] | ||||
| 449 | + ], | ||||
| 450 | + "src_companies": [ | ||||
| 451 | + [ | ||||
| 452 | + "cik", | ||||
| 453 | + "VARCHAR" | ||||
| 454 | + ], | ||||
| 455 | + [ | ||||
| 456 | + "ticker", | ||||
| 457 | + "VARCHAR" | ||||
| 458 | + ], | ||||
| 459 | + [ | ||||
| 460 | + "company_name", | ||||
| 461 | + "VARCHAR" | ||||
| 462 | + ], | ||||
| 463 | + [ | ||||
| 464 | + "sector", | ||||
| 465 | + "VARCHAR" | ||||
| 466 | + ], | ||||
| 467 | + [ | ||||
| 468 | + "sub_industry", | ||||
| 469 | + "VARCHAR" | ||||
| 470 | + ], | ||||
| 471 | + [ | ||||
| 472 | + "sic_code", | ||||
| 473 | + "VARCHAR" | ||||
| 474 | + ], | ||||
| 475 | + [ | ||||
| 476 | + "state", | ||||
| 477 | + "VARCHAR" | ||||
| 478 | + ], | ||||
| 479 | + [ | ||||
| 480 | + "date_added", | ||||
| 481 | + "DATE" | ||||
| 482 | + ], | ||||
| 483 | + [ | ||||
| 484 | + "created_at", | ||||
| 485 | + "TIMESTAMP" | ||||
| 486 | + ], | ||||
| 487 | + [ | ||||
| 488 | + "updated_at", | ||||
| 489 | + "TIMESTAMP" | ||||
| 490 | + ], | ||||
| 491 | + [ | ||||
| 492 | + "in_sp500", | ||||
| 493 | + "BOOLEAN" | ||||
| 494 | + ], | ||||
| 495 | + [ | ||||
| 496 | + "in_russell3000", | ||||
| 497 | + "BOOLEAN" | ||||
| 498 | + ], | ||||
| 499 | + [ | ||||
| 500 | + "sic_description", | ||||
| 501 | + "VARCHAR" | ||||
| 502 | + ], | ||||
| 503 | + [ | ||||
| 504 | + "state_of_incorporation", | ||||
| 505 | + "VARCHAR" | ||||
| 506 | + ], | ||||
| 507 | + [ | ||||
| 508 | + "exchange", | ||||
| 509 | + "VARCHAR" | ||||
| 510 | + ], | ||||
| 511 | + [ | ||||
| 512 | + "fiscal_year_end", | ||||
| 513 | + "VARCHAR" | ||||
| 514 | + ], | ||||
| 515 | + [ | ||||
| 516 | + "russell_weight_pct", | ||||
| 517 | + "DOUBLE" | ||||
| 518 | + ], | ||||
| 519 | + [ | ||||
| 520 | + "shard_node", | ||||
| 521 | + "VARCHAR" | ||||
| 522 | + ] | ||||
| 523 | + ], | ||||
| 524 | + "views": [ | ||||
| 525 | + [ | ||||
| 526 | + "src_companies", | ||||
| 527 | + "CREATE VIEW src_companies AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/companies/companies.parquet');" | ||||
| 528 | + ], | ||||
| 529 | + [ | ||||
| 530 | + "src_filings", | ||||
| 531 | + "CREATE VIEW src_filings AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/filings/filings.parquet');" | ||||
| 532 | + ], | ||||
| 533 | + [ | ||||
| 534 | + "src_sections", | ||||
| 535 | + "CREATE VIEW src_sections AS SELECT * FROM read_parquet('/Users/simon-pierreboucher/UQO_RESEARCH_DB/data/parquet/filing_sections/filing_sections.parquet');" | ||||
| 536 | + ], | ||||
| 537 | + [ | ||||
| 538 | + "v_sections", | ||||
| 539 | + "CREATE VIEW v_sections AS WITH parsed AS (SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.\"text\", s.word_count, f.form_type FROM src_sections AS s INNER JOIN src_filings AS f USING (accession_number) WHERE (NOT ((f.form_type = '10-K') AND (s.accession_number = ANY(SELECT DISTINCT accession_number FROM spid_sections))))), all_sec AS ((SELECT section_id, accession_number, section_name, item_number, \"text\", word_count FROM parsed WHERE (((form_type = '10-K') AND (section_name IN ('business', 'risk_factors', 'properties', 'md_and_a', 'quantitative_disclosures'))) OR (NOT (form_type IN ('10-K'))))) UNION ALL (SELECT section_id, accession_number, section_name, item_number, \"text\", word_count FROM spid_sections)) SELECT s.section_id, s.accession_number, s.section_name, s.item_number, s.\"text\", s.word_count, f.cik, f.ticker, f.company_name AS filing_company, f.form_type, CAST(f.filing_date AS DATE) AS filing_date, f.fiscal_year, co.company_name AS company_name, co.sector, co.sub_industry, co.sic_code, co.state FROM all_sec AS s INNER JOIN src_filings AS f USING (accession_number) LEFT JOIN src_companies AS co ON ((f.cik = co.cik));" | ||||
| 540 | + ] | ||||
| 541 | + ], | ||||
| 542 | + "counts": { | ||||
| 543 | + "extracted_sections": 1123098, | ||||
| 544 | + "project_mentions": 308065, | ||||
| 545 | + "projects": 121099, | ||||
| 546 | + "project_timeline": 308065, | ||||
| 547 | + "spid_sections": 27164 | ||||
| 548 | + }, | ||||
| 549 | + "ledger_words": [ | ||||
| 550 | + 1123098, | ||||
| 551 | + 3605932059 | ||||
| 552 | + ], | ||||
| 553 | + "parquet_files": [ | ||||
| 554 | + 1342283, | ||||
| 555 | + 29.13085581 | ||||
| 556 | + ], | ||||
| 557 | + "edgar_filings": [ | ||||
| 558 | + [ | ||||
| 559 | + "accession_number", | ||||
| 560 | + "VARCHAR" | ||||
| 561 | + ], | ||||
| 562 | + [ | ||||
| 563 | + "cik", | ||||
| 564 | + "VARCHAR" | ||||
| 565 | + ], | ||||
| 566 | + [ | ||||
| 567 | + "ticker", | ||||
| 568 | + "VARCHAR" | ||||
| 569 | + ], | ||||
| 570 | + [ | ||||
| 571 | + "company_name", | ||||
| 572 | + "VARCHAR" | ||||
| 573 | + ], | ||||
| 574 | + [ | ||||
| 575 | + "form_type", | ||||
| 576 | + "VARCHAR" | ||||
| 577 | + ], | ||||
| 578 | + [ | ||||
| 579 | + "filing_date", | ||||
| 580 | + "DATE" | ||||
| 581 | + ], | ||||
| 582 | + [ | ||||
| 583 | + "report_date", | ||||
| 584 | + "DATE" | ||||
| 585 | + ], | ||||
| 586 | + [ | ||||
| 587 | + "fiscal_year", | ||||
| 588 | + "INTEGER" | ||||
| 589 | + ], | ||||
| 590 | + [ | ||||
| 591 | + "fiscal_quarter", | ||||
| 592 | + "INTEGER" | ||||
| 593 | + ], | ||||
| 594 | + [ | ||||
| 595 | + "sec_url", | ||||
| 596 | + "VARCHAR" | ||||
| 597 | + ], | ||||
| 598 | + [ | ||||
| 599 | + "local_path", | ||||
| 600 | + "VARCHAR" | ||||
| 601 | + ], | ||||
| 602 | + [ | ||||
| 603 | + "primary_document", | ||||
| 604 | + "VARCHAR" | ||||
| 605 | + ], | ||||
| 606 | + [ | ||||
| 607 | + "file_size_bytes", | ||||
| 608 | + "BIGINT" | ||||
| 609 | + ], | ||||
| 610 | + [ | ||||
| 611 | + "download_status", | ||||
| 612 | + "VARCHAR" | ||||
| 613 | + ], | ||||
| 614 | + [ | ||||
| 615 | + "parse_status", | ||||
| 616 | + "VARCHAR" | ||||
| 617 | + ], | ||||
| 618 | + [ | ||||
| 619 | + "created_at", | ||||
| 620 | + "TIMESTAMP" | ||||
| 621 | + ], | ||||
| 622 | + [ | ||||
| 623 | + "updated_at", | ||||
| 624 | + "TIMESTAMP" | ||||
| 625 | + ] | ||||
| 626 | + ], | ||||
| 627 | + "edgar_filing_sections": [ | ||||
| 628 | + [ | ||||
| 629 | + "section_id", | ||||
| 630 | + "VARCHAR" | ||||
| 631 | + ], | ||||
| 632 | + [ | ||||
| 633 | + "accession_number", | ||||
| 634 | + "VARCHAR" | ||||
| 635 | + ], | ||||
| 636 | + [ | ||||
| 637 | + "section_name", | ||||
| 638 | + "VARCHAR" | ||||
| 639 | + ], | ||||
| 640 | + [ | ||||
| 641 | + "item_number", | ||||
| 642 | + "VARCHAR" | ||||
| 643 | + ], | ||||
| 644 | + [ | ||||
| 645 | + "section_order", | ||||
| 646 | + "INTEGER" | ||||
| 647 | + ], | ||||
| 648 | + [ | ||||
| 649 | + "text", | ||||
| 650 | + "VARCHAR" | ||||
| 651 | + ], | ||||
| 652 | + [ | ||||
| 653 | + "word_count", | ||||
| 654 | + "INTEGER" | ||||
| 655 | + ], | ||||
| 656 | + [ | ||||
| 657 | + "char_count", | ||||
| 658 | + "INTEGER" | ||||
| 659 | + ], | ||||
| 660 | + [ | ||||
| 661 | + "created_at", | ||||
| 662 | + "TIMESTAMP" | ||||
| 663 | + ] | ||||
| 664 | + ], | ||||
| 665 | + "edgar_companies": [ | ||||
| 666 | + [ | ||||
| 667 | + "cik", | ||||
| 668 | + "VARCHAR" | ||||
| 669 | + ], | ||||
| 670 | + [ | ||||
| 671 | + "ticker", | ||||
| 672 | + "VARCHAR" | ||||
| 673 | + ], | ||||
| 674 | + [ | ||||
| 675 | + "company_name", | ||||
| 676 | + "VARCHAR" | ||||
| 677 | + ], | ||||
| 678 | + [ | ||||
| 679 | + "sector", | ||||
| 680 | + "VARCHAR" | ||||
| 681 | + ], | ||||
| 682 | + [ | ||||
| 683 | + "sub_industry", | ||||
| 684 | + "VARCHAR" | ||||
| 685 | + ], | ||||
| 686 | + [ | ||||
| 687 | + "sic_code", | ||||
| 688 | + "VARCHAR" | ||||
| 689 | + ], | ||||
| 690 | + [ | ||||
| 691 | + "state", | ||||
| 692 | + "VARCHAR" | ||||
| 693 | + ], | ||||
| 694 | + [ | ||||
| 695 | + "date_added", | ||||
| 696 | + "DATE" | ||||
| 697 | + ], | ||||
| 698 | + [ | ||||
| 699 | + "created_at", | ||||
| 700 | + "TIMESTAMP" | ||||
| 701 | + ], | ||||
| 702 | + [ | ||||
| 703 | + "updated_at", | ||||
| 704 | + "TIMESTAMP" | ||||
| 705 | + ], | ||||
| 706 | + [ | ||||
| 707 | + "in_sp500", | ||||
| 708 | + "BOOLEAN" | ||||
| 709 | + ], | ||||
| 710 | + [ | ||||
| 711 | + "in_russell3000", | ||||
| 712 | + "BOOLEAN" | ||||
| 713 | + ], | ||||
| 714 | + [ | ||||
| 715 | + "sic_description", | ||||
| 716 | + "VARCHAR" | ||||
| 717 | + ], | ||||
| 718 | + [ | ||||
| 719 | + "state_of_incorporation", | ||||
| 720 | + "VARCHAR" | ||||
| 721 | + ], | ||||
| 722 | + [ | ||||
| 723 | + "exchange", | ||||
| 724 | + "VARCHAR" | ||||
| 725 | + ], | ||||
| 726 | + [ | ||||
| 727 | + "fiscal_year_end", | ||||
| 728 | + "VARCHAR" | ||||
| 729 | + ], | ||||
| 730 | + [ | ||||
| 731 | + "russell_weight_pct", | ||||
| 732 | + "DOUBLE" | ||||
| 733 | + ], | ||||
| 734 | + [ | ||||
| 735 | + "shard_node", | ||||
| 736 | + "VARCHAR" | ||||
| 737 | + ] | ||||
| 738 | + ], | ||||
| 739 | + "edgar_annual_panel": [ | ||||
| 740 | + [ | ||||
| 741 | + "cik", | ||||
| 742 | + "VARCHAR" | ||||
| 743 | + ], | ||||
| 744 | + [ | ||||
| 745 | + "ticker", | ||||
| 746 | + "VARCHAR" | ||||
| 747 | + ], | ||||
| 748 | + [ | ||||
| 749 | + "fiscal_year", | ||||
| 750 | + "INTEGER" | ||||
| 751 | + ], | ||||
| 752 | + [ | ||||
| 753 | + "filing_count", | ||||
| 754 | + "BIGINT" | ||||
| 755 | + ], | ||||
| 756 | + [ | ||||
| 757 | + "total_chunks", | ||||
| 758 | + "BIGINT" | ||||
| 759 | + ], | ||||
| 760 | + [ | ||||
| 761 | + "ai_adoption_mean", | ||||
| 762 | + "DOUBLE" | ||||
| 763 | + ], | ||||
| 764 | + [ | ||||
| 765 | + "ai_adoption_max", | ||||
| 766 | + "DOUBLE" | ||||
| 767 | + ], | ||||
| 768 | + [ | ||||
| 769 | + "ai_adoption_std", | ||||
| 770 | + "DOUBLE" | ||||
| 771 | + ], | ||||
| 772 | + [ | ||||
| 773 | + "ai_adoption_top5pct", | ||||
| 774 | + "DOUBLE" | ||||
| 775 | + ], | ||||
| 776 | + [ | ||||
| 777 | + "ai_adoption_wmean", | ||||
| 778 | + "DOUBLE" | ||||
| 779 | + ], | ||||
| 780 | + [ | ||||
| 781 | + "ai_disruption_risk_mean", | ||||
| 782 | + "DOUBLE" | ||||
| 783 | + ], | ||||
| 784 | + [ | ||||
| 785 | + "ai_disruption_risk_max", | ||||
| 786 | + "DOUBLE" | ||||
| 787 | + ], | ||||
| 788 | + [ | ||||
| 789 | + "ai_disruption_risk_std", | ||||
| 790 | + "DOUBLE" | ||||
| 791 | + ], | ||||
| 792 | + [ | ||||
| 793 | + "ai_disruption_risk_top5pct", | ||||
| 794 | + "DOUBLE" | ||||
| 795 | + ], | ||||
| 796 | + [ | ||||
| 797 | + "ai_disruption_risk_wmean", | ||||
| 798 | + "DOUBLE" | ||||
| 799 | + ], | ||||
| 800 | + [ | ||||
| 801 | + "ai_infrastructure_mean", | ||||
| 802 | + "DOUBLE" | ||||
| 803 | + ], | ||||
| 804 | + [ | ||||
| 805 | + "ai_infrastructure_max", | ||||
| 806 | + "DOUBLE" | ||||
| 807 | + ], | ||||
| 808 | + [ | ||||
| 809 | + "ai_infrastructure_std", | ||||
| 810 | + "DOUBLE" | ||||
| 811 | + ], | ||||
| 812 | + [ | ||||
| 813 | + "ai_infrastructure_top5pct", | ||||
| 814 | + "DOUBLE" | ||||
| 815 | + ], | ||||
| 816 | + [ | ||||
| 817 | + "ai_infrastructure_wmean", | ||||
| 818 | + "DOUBLE" | ||||
| 819 | + ], | ||||
| 820 | + [ | ||||
| 821 | + "esg_commitment_mean", | ||||
| 822 | + "DOUBLE" | ||||
| 823 | + ], | ||||
| 824 | + [ | ||||
| 825 | + "esg_commitment_max", | ||||
| 826 | + "DOUBLE" | ||||
| 827 | + ], | ||||
| 828 | + [ | ||||
| 829 | + "esg_commitment_std", | ||||
| 830 | + "DOUBLE" | ||||
| 831 | + ], | ||||
| 832 | + [ | ||||
| 833 | + "esg_commitment_top5pct", | ||||
| 834 | + "DOUBLE" | ||||
| 835 | + ], | ||||
| 836 | + [ | ||||
| 837 | + "esg_commitment_wmean", | ||||
| 838 | + "DOUBLE" | ||||
| 839 | + ], | ||||
| 840 | + [ | ||||
| 841 | + "climate_risk_mean", | ||||
| 842 | + "DOUBLE" | ||||
| 843 | + ], | ||||
| 844 | + [ | ||||
| 845 | + "climate_risk_max", | ||||
| 846 | + "DOUBLE" | ||||
| 847 | + ], | ||||
| 848 | + [ | ||||
| 849 | + "climate_risk_std", | ||||
| 850 | + "DOUBLE" | ||||
| 851 | + ], | ||||
| 852 | + [ | ||||
| 853 | + "climate_risk_top5pct", | ||||
| 854 | + "DOUBLE" | ||||
| 855 | + ], | ||||
| 856 | + [ | ||||
| 857 | + "climate_risk_wmean", | ||||
| 858 | + "DOUBLE" | ||||
| 859 | + ], | ||||
| 860 | + [ | ||||
| 861 | + "cybersecurity_mean", | ||||
| 862 | + "DOUBLE" | ||||
| 863 | + ], | ||||
| 864 | + [ | ||||
| 865 | + "cybersecurity_max", | ||||
| 866 | + "DOUBLE" | ||||
| 867 | + ], | ||||
| 868 | + [ | ||||
| 869 | + "cybersecurity_std", | ||||
| 870 | + "DOUBLE" | ||||
| 871 | + ], | ||||
| 872 | + [ | ||||
| 873 | + "cybersecurity_top5pct", | ||||
| 874 | + "DOUBLE" | ||||
| 875 | + ], | ||||
| 876 | + [ | ||||
| 877 | + "cybersecurity_wmean", | ||||
| 878 | + "DOUBLE" | ||||
| 879 | + ], | ||||
| 880 | + [ | ||||
| 881 | + "automation_labor_mean", | ||||
| 882 | + "DOUBLE" | ||||
| 883 | + ], | ||||
| 884 | + [ | ||||
| 885 | + "automation_labor_max", | ||||
| 886 | + "DOUBLE" | ||||
| 887 | + ], | ||||
| 888 | + [ | ||||
| 889 | + "automation_labor_std", | ||||
| 890 | + "DOUBLE" | ||||
| 891 | + ], | ||||
| 892 | + [ | ||||
| 893 | + "automation_labor_top5pct", | ||||
| 894 | + "DOUBLE" | ||||
| 895 | + ], | ||||
| 896 | + [ | ||||
| 897 | + "automation_labor_wmean", | ||||
| 898 | + "DOUBLE" | ||||
| 899 | + ], | ||||
| 900 | + [ | ||||
| 901 | + "macro_risk_mean", | ||||
| 902 | + "DOUBLE" | ||||
| 903 | + ], | ||||
| 904 | + [ | ||||
| 905 | + "macro_risk_max", | ||||
| 906 | + "DOUBLE" | ||||
| 907 | + ], | ||||
| 908 | + [ | ||||
| 909 | + "macro_risk_std", | ||||
| 910 | + "DOUBLE" | ||||
| 911 | + ], | ||||
| 912 | + [ | ||||
| 913 | + "macro_risk_top5pct", | ||||
| 914 | + "DOUBLE" | ||||
| 915 | + ], | ||||
| 916 | + [ | ||||
| 917 | + "macro_risk_wmean", | ||||
| 918 | + "DOUBLE" | ||||
| 919 | + ] | ||||
| 920 | + ], | ||||
| 921 | + "edgar_tables": [ | ||||
| 922 | + "anchor_embeddings", | ||||
| 923 | + "annual_final", | ||||
| 924 | + "annual_panel", | ||||
| 925 | + "chunk_scores", | ||||
| 926 | + "companies", | ||||
| 927 | + "embeddings_metadata", | ||||
| 928 | + "filings", | ||||
| 929 | + "filing_chunks", | ||||
| 930 | + "filing_scores", | ||||
| 931 | + "filing_sections", | ||||
| 932 | + "processing_log", | ||||
| 933 | + "quarterly_final", | ||||
| 934 | + "quarterly_panel" | ||||
| 935 | + ], | ||||
| 936 | + "temp_ok": true | ||||
| 937 | +} | ||||
| \ No newline at end of file | |||||
| 938 | |||||