Files
boardroom-map/orchestrator/decks.py
T
Jonathan KirkwoodandClaude Fable 5 b1d7aed9f4 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>
2026-07-06 14:15:12 -05:00

123 lines
4.6 KiB
Python

"""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}