- 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>
123 lines
4.6 KiB
Python
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}
|