Coverage for src / quber / playground / metadata.py: 99%
75 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""Filing metadata: what a document is, derived and displayed.
3A document carries filing_type, year, period and version as columns. Nothing
4displayable is stored beyond them: the label a card, the workspace header or
5an export filename shows is derived here at read time, so grooming the
6metadata corrects every label at once.
8`infer` pre-fills the metadata from a filename. It is a convenience and never
9authoritative — the upload form shows what it guessed and any field the user
10touches is pinned. The same rules live in `ui/src/metadata.js` for the form;
11`tests/playground/test_metadata.py` runs one case list through both so the
12two implementations cannot drift apart silently.
14The identity tuple (folder, filing_type, year, period, version) is what the
15overwrite flow keys on. It only exists when filing_type, year and period are
16all present, and it compares case-insensitively; `tuple_key` is that rule in
17one place. `overlap_key` is the same tuple without the version — two versions
18of one filing share it, which is exactly what the library's possible-duplicate
19flag reports.
20"""
22from __future__ import annotations
24import re
25from typing import Any, Dict, Iterable, Optional, Tuple
27#: The normalized identity tuple: (folder, filing_type, year, period, version).
28TupleKey = Tuple[str, str, int, str, Optional[int]]
30PERIODS = ("Q1", "Q2", "Q3", "Q4", "H1", "H2", "FY")
32# (pattern, groups) pairs tried in order; the first hit decides the period.
33# Compact quarter-year compounds come first so "q126" reads as Q1 2026 rather
34# than as a bare Q1 with the year lost.
35_PERIOD_PATTERNS: list[tuple[re.Pattern[str], str]] = [
36 (re.compile(r"(?<!\d)([1-4])\s*q\s*(20\d{2})(?!\d)", re.I), "q-year"),
37 (re.compile(r"(?<!\d)(20\d{2})[-_. ]?q([1-4])(?!\d)", re.I), "year-q"),
38 (re.compile(r"(?<![a-z0-9])q([1-4])[-_. ]?(\d{2})(?!\d)", re.I), "q-yy"),
39 (re.compile(r"(?<![a-z0-9])q([1-4])(?![0-9])", re.I), "q"),
40 (re.compile(r"(?<![a-z0-9])h([12])(?![0-9])", re.I), "h"),
41 (re.compile(r"(first|second)[-_ ]half", re.I), "half"),
42 (re.compile(r"(?<![a-z0-9])(fy|full[-_ ]?year|annual)(?![a-z0-9])", re.I), "fy"),
43]
45_YEAR_RE = re.compile(r"(?<!\d)(20\d{2})(?!\d)")
47# Explicit form tokens first, then the press-release word, then the deck
48# vocabulary the design names for 99-2.
49_TYPE_PATTERNS: list[tuple[re.Pattern[str], str]] = [
50 (re.compile(r"(?<![a-z0-9])10[-_ ]?k(?![a-z0-9])", re.I), "10-K"),
51 (re.compile(r"(?<![a-z0-9])10[-_ ]?q(?![a-z0-9])", re.I), "10-Q"),
52 (re.compile(r"(?<![a-z0-9])99[-_. ]?1(?![0-9])", re.I), "99-1"),
53 (re.compile(r"(?<![a-z0-9])99[-_. ]?2(?![0-9])", re.I), "99-2"),
54 (re.compile(r"press[-_ ]?release", re.I), "99-1"),
55 (re.compile(r"supplemental|presentation|deck|slides|earnings", re.I), "99-2"),
56]
59def infer(filename: str) -> Dict[str, Any]:
60 """Filing metadata guessed from a filename: {filing_type, year, period},
61 each None when nothing in the name says. A 10-K with no explicit period
62 defaults to FY — an annual report is annual."""
63 stem = filename.rsplit("/", 1)[-1]
64 stem = re.sub(r"\.[^.]+$", "", stem)
66 filing_type = next((label for rx, label in _TYPE_PATTERNS if rx.search(stem)), None)
68 period: Optional[str] = None
69 year: Optional[int] = None
70 for rx, kind in _PERIOD_PATTERNS:
71 m = rx.search(stem)
72 if not m:
73 continue
74 if kind == "q-year":
75 period, year = f"Q{m.group(1)}", int(m.group(2))
76 elif kind == "year-q":
77 period, year = f"Q{m.group(2)}", int(m.group(1))
78 elif kind == "q-yy":
79 period, year = f"Q{m.group(1)}", 2000 + int(m.group(2))
80 elif kind == "q":
81 period = f"Q{m.group(1)}"
82 elif kind == "h":
83 period = f"H{m.group(1)}"
84 elif kind == "half":
85 period = "H1" if m.group(1).lower() == "first" else "H2"
86 else:
87 period = "FY"
88 break
90 if year is None:
91 m = _YEAR_RE.search(stem)
92 if m:
93 year = int(m.group(1))
94 if period is None and filing_type == "10-K":
95 period = "FY"
96 return {"filing_type": filing_type, "year": year, "period": period}
99def filing_label(
100 filing_type: Optional[str],
101 year: Optional[int],
102 period: Optional[str],
103 version: Optional[int],
104) -> str:
105 """The display label: `99-2 · Q1 2026 · v2` with missing parts omitted.
106 Empty when the metadata is entirely absent — the title then stands alone."""
107 parts: list[str] = []
108 if filing_type:
109 parts.append(filing_type)
110 py = " ".join(str(p) for p in (period, year) if p)
111 if py:
112 parts.append(py)
113 if version:
114 parts.append(f"v{version}")
115 return " · ".join(parts)
118def export_stem(*candidates: Optional[str]) -> str:
119 """A filename-safe stem from the first non-empty candidate — label, then
120 title, then original filename, per the label precedence."""
121 for c in candidates:
122 if c and c.strip():
123 stem = re.sub(r"[^a-z0-9]+", "-", c.lower()).strip("-")[:60]
124 if stem:
125 return stem
126 return "document"
129def _norm(s: Optional[str]) -> str:
130 return (s or "").strip().lower()
133def tuple_key(
134 folder: Optional[str],
135 filing_type: Optional[str],
136 year: Optional[int],
137 period: Optional[str],
138 version: Optional[int],
139) -> Optional[TupleKey]:
140 """The identity tuple, normalized for comparison — or None when it does
141 not exist because filing_type, year or period is missing. Documents
142 without a tuple never collide and never replace."""
143 if not (filing_type and year and period):
144 return None
145 return (_norm(folder), _norm(filing_type), int(year), _norm(period), version)
148def overlap_key(
149 folder: Optional[str],
150 filing_type: Optional[str],
151 year: Optional[int],
152 period: Optional[str],
153) -> Optional[Tuple[str, str, int, str]]:
154 """The tuple without its version: what v1/v2 siblings share, and what the
155 possible-duplicate flag groups by."""
156 key = tuple_key(folder, filing_type, year, period, version=None)
157 return key[:4] if key else None
160def annotate_overlaps(docs: Iterable[Dict[str, Any]]) -> None:
161 """Set `overlap` on each document dict: True when another document shares
162 its versionless tuple. Documents with incomplete metadata never flag."""
163 rows = list(docs)
164 counts: Dict[Tuple[str, str, int, str], int] = {}
165 for d in rows:
166 key = overlap_key(d.get("folder"), d.get("filing_type"), d.get("year"), d.get("period"))
167 if key is not None:
168 counts[key] = counts.get(key, 0) + 1
169 for d in rows:
170 key = overlap_key(d.get("folder"), d.get("filing_type"), d.get("year"), d.get("period"))
171 d["overlap"] = bool(key is not None and counts[key] > 1)