Implement BDEF v1.1 grading: scoring core, per-deck pipeline, ledger, dashboard, StartOS layer
- Deterministic scoring.py (quant 60 / qual 40 / flags -15, profitability heaviest) - Per-company JSON ledger with forecast-target chaining deck N-1 -> N - Single-shot sandbox agent with guided-JSON fallback ladder (no tool loop) - Portfolio dashboard with sparklines, KPI hit rates, BDEF category bars - 48 unit tests green; endpoints smoke-tested; npm check+build green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
1dde915540
commit
b1d7aed9f4
@@ -0,0 +1,122 @@
|
||||
"""Deck discovery — map the inbox onto (company, period) grading units.
|
||||
|
||||
The inbox is organized by company: /data/inbox/<company>/<deck files>. The
|
||||
subfolder name is the company slug; the reporting period is parsed from each
|
||||
filename (2026-Q2, Q2 2026, 2026-H1, 2026-05, FY2026 ...). Files whose period
|
||||
cannot be parsed form a period-less unit that the extractor's own deck.period
|
||||
can later fill in. Files at the inbox root are skipped (we would not know the
|
||||
company) and reported so the UI can nag the operator.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
SUPPORTED_EXTS = {".pdf", ".pptx", ".docx", ".txt", ".md", ".text"}
|
||||
|
||||
# Canonical period forms: "2026-Q2", "2026-H1", "2026-05", "FY2026".
|
||||
_PERIOD_PATTERNS = [
|
||||
# 2026-Q2 / 2026Q2 / 2026_Q2 / 2026 Q2
|
||||
(re.compile(r"(?<!\d)((?:19|20)\d{2})[-_ ]?[Qq]([1-4])(?!\d)"),
|
||||
lambda m: f"{m.group(1)}-Q{m.group(2)}"),
|
||||
# Q2-2026 / Q2_2026 / Q2 2026
|
||||
(re.compile(r"(?<![A-Za-z0-9])[Qq]([1-4])[-_ ]((?:19|20)\d{2})(?!\d)"),
|
||||
lambda m: f"{m.group(2)}-Q{m.group(1)}"),
|
||||
# 2026-H1 / 2026H2
|
||||
(re.compile(r"(?<!\d)((?:19|20)\d{2})[-_ ]?[Hh]([12])(?!\d)"),
|
||||
lambda m: f"{m.group(1)}-H{m.group(2)}"),
|
||||
# FY2026 / FY-2026 / FY 2026
|
||||
(re.compile(r"(?<![A-Za-z0-9])[Ff][Yy][-_ ]?((?:19|20)\d{2})(?!\d)"),
|
||||
lambda m: f"FY{m.group(1)}"),
|
||||
# 2026-05 (month 01-12; checked last so Q/H/FY forms win)
|
||||
(re.compile(r"(?<!\d)((?:19|20)\d{2})[-_](0[1-9]|1[0-2])(?!\d)"),
|
||||
lambda m: f"{m.group(1)}-{m.group(2)}"),
|
||||
]
|
||||
|
||||
# Granularity ranks break ties between periods starting the same month
|
||||
# (coarser first: FY2026 < 2026-H1 < 2026-Q1 < 2026-01).
|
||||
_GRAN_FY, _GRAN_H, _GRAN_Q, _GRAN_M = 0, 1, 2, 3
|
||||
_UNKNOWN_KEY = (9999, 99, 9)
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase [a-z0-9-] slug for a company folder name."""
|
||||
s = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return s or "company"
|
||||
|
||||
|
||||
def parse_period_from_name(filename: str) -> str | None:
|
||||
"""Canonical period parsed from a filename, or None."""
|
||||
base = os.path.basename(filename)
|
||||
for pat, canon in _PERIOD_PATTERNS:
|
||||
m = pat.search(base)
|
||||
if m:
|
||||
return canon(m)
|
||||
return None
|
||||
|
||||
|
||||
def period_sort_key(period: str | None) -> tuple:
|
||||
"""(year, start_month, granularity_rank); unknown/None sorts last."""
|
||||
if not period:
|
||||
return _UNKNOWN_KEY
|
||||
m = re.fullmatch(r"((?:19|20)\d{2})-Q([1-4])", period)
|
||||
if m:
|
||||
return (int(m.group(1)), (int(m.group(2)) - 1) * 3 + 1, _GRAN_Q)
|
||||
m = re.fullmatch(r"((?:19|20)\d{2})-H([12])", period)
|
||||
if m:
|
||||
return (int(m.group(1)), (int(m.group(2)) - 1) * 6 + 1, _GRAN_H)
|
||||
m = re.fullmatch(r"((?:19|20)\d{2})-(0[1-9]|1[0-2])", period)
|
||||
if m:
|
||||
return (int(m.group(1)), int(m.group(2)), _GRAN_M)
|
||||
m = re.fullmatch(r"FY((?:19|20)\d{2})", period)
|
||||
if m:
|
||||
return (int(m.group(1)), 1, _GRAN_FY)
|
||||
return _UNKNOWN_KEY
|
||||
|
||||
|
||||
def discover(inbox_dir: str) -> dict:
|
||||
"""Scan the inbox into grading units.
|
||||
|
||||
Returns {"units": [{company_slug, period, period_source, files, ignored}],
|
||||
"skipped": [root-level file names]}. Units are grouped by (company, parsed
|
||||
period), sorted by company then oldest period first (period-less last)."""
|
||||
units: dict[tuple, dict] = {}
|
||||
skipped: list[str] = []
|
||||
if not os.path.isdir(inbox_dir):
|
||||
return {"units": [], "skipped": []}
|
||||
for entry in sorted(os.listdir(inbox_dir)):
|
||||
if entry.startswith("."):
|
||||
continue
|
||||
path = os.path.join(inbox_dir, entry)
|
||||
if os.path.isfile(path):
|
||||
skipped.append(entry)
|
||||
continue
|
||||
if not os.path.isdir(path):
|
||||
continue
|
||||
company = slugify(entry)
|
||||
for fn in sorted(os.listdir(path)):
|
||||
if fn.startswith("."):
|
||||
continue
|
||||
fpath = os.path.join(path, fn)
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
period = parse_period_from_name(fn)
|
||||
key = (company, period_sort_key(period), period)
|
||||
unit = units.setdefault(key, {
|
||||
"company_slug": company,
|
||||
"period": period,
|
||||
"period_source": "filename" if period else "unknown",
|
||||
"files": [],
|
||||
"ignored": [],
|
||||
})
|
||||
ext = os.path.splitext(fn)[1].lower()
|
||||
if ext in SUPPORTED_EXTS:
|
||||
unit["files"].append(os.path.abspath(fpath))
|
||||
else:
|
||||
unit["ignored"].append(fn)
|
||||
out = [u for _, u in sorted(units.items(), key=lambda kv: (kv[0][0], kv[0][1]))
|
||||
if u["files"]]
|
||||
for u in out:
|
||||
u["files"].sort()
|
||||
u["ignored"].sort()
|
||||
return {"units": out, "skipped": skipped}
|
||||
Reference in New Issue
Block a user