Spaces:
Running on Zero
Running on Zero
Real Excel test 2: bug fixes in src, new example, text
Browse files- .gitattributes +2 -0
- README.md +16 -3
- app.py +8 -6
- examples/online-retail-ii-model-replies.json +71 -0
- examples/online-retail-ii-p1.png +0 -0
- examples/online-retail-ii-p2.png +0 -0
- examples/online-retail-ii-p3.png +0 -0
- examples/online-retail-ii-p4.png +3 -0
- examples/online-retail-ii-p5.png +0 -0
- examples/online-retail-ii-p6.png +3 -0
- examples/online-retail-ii.pdf +0 -0
- src/weekly_analyst/analysis/checks.py +87 -87
- src/weekly_analyst/core/store.py +79 -78
- src/weekly_analyst/data/extract.py +162 -158
- src/weekly_analyst/data/metrics_calc.py +38 -38
- src/weekly_analyst/data/recompute.py +80 -78
- src/weekly_analyst/systems/detect.py +59 -59
- src/weekly_analyst/systems/draft.py +2 -2
- src/weekly_analyst/systems/report_findings.py +3 -3
- src/weekly_analyst/systems/report_model.py +94 -94
.gitattributes
CHANGED
|
@@ -34,3 +34,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
media/weekly-analyst-demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
media/weekly-analyst-demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
examples/online-retail-ii-p4.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
examples/online-retail-ii-p6.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -49,7 +49,7 @@ MiniCPM-V 4.7 1B (f16) runs locally through llama.cpp. It selects tools and para
|
|
| 49 |
| Real model, quiet week | silent in about 1 s |
|
| 50 |
| Real model, spike or drop week | PDF draft in about 195 s on a 2-vCPU Colab, about 5 min on the Space; approve re-renders, re-checks and writes it to the sent folder |
|
| 51 |
| Peak memory | 5.2 GB |
|
| 52 |
-
| Test suite (full project) |
|
| 53 |
| First weight edit (16,384 values, no training; superseded by the LoRA), held-out plans, 80 tasks, no grammar | 64/80 to 80/80; a random edit of the same size stayed at 64/80 |
|
| 54 |
| Small LoRA, round 2 (`lora3`), scored once on held-out seeds 400 to 439 (deployed on the Space) | plans 80/80, drafts 80/80 and 40/40 on each of six variants without a grammar, including two held-out reworded instructions (unedited model: 65/80, 0/80, 0/40 each) |
|
| 55 |
| First real-data test (UCI Online Retail, about 540,000 invoice rows, T4, model output unconstrained) | valid plan and draft each time; after we fixed a bug in our own number check, a full report for 3 of 3 weeks (it halted on all 3 before the fix) |
|
|
@@ -110,6 +110,19 @@ Example output for the week of 2011-12-04 (ISO week 2011-W48), made with `script
|
|
| 110 |
|---|---|---|
|
| 111 |
An earlier try on the Tableau Superstore sample stayed silent because its segments were far below the size minimum.
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
## Honest limits
|
| 114 |
|
| 115 |
- Detection: recall 0.21 on the holdout replay at the quiet gate (the bar was 0.80), and 14% of quiet weeks raised an alert. We know why, we have not fixed it.
|
|
@@ -117,13 +130,13 @@ Example output for the week of 2011-12-04 (ISO week 2011-W48), made with `script
|
|
| 117 |
- All scores are on synthetic tasks from one generator, without a JSON grammar, greedy, 80 tasks scored once per round. In production the agent uses a grammar and retries, so the real gain is probably smaller. The first LoRA failed an instruction that did not name the JSON fields (0/40); round 2 fixed that. Dev wordings were written in the same session as the training wordings, so dev was optimistic; the fresh-seed score is the clean number.
|
| 118 |
- The Space runs the round 2 GGUF; its llama.cpp check used unspent seeds 500 to 511 plus a held-out wording. The real-data test used one dataset and was not run on the Space.
|
| 119 |
- Free SQL: the unedited model got 21 of 30 under a grammar (bar 24), so named queries are the main path. This was not re-measured with the LoRA.
|
| 120 |
-
- Real data:
|
| 121 |
- Chart reading by the vision encoder is not claimed (the vision projector would not load in the runtime used). CPU only on the Space; Apple Silicon speed is untested.
|
| 122 |
- The scenarios on the Space are synthetic. Nothing is uploaded, and the source file is never modified.
|
| 123 |
|
| 124 |
## Run the tests
|
| 125 |
|
| 126 |
-
The full test suite is in `tests/` of the GitHub repo (with `eval/` and `pyproject.toml`): `pip install -e ".[dev]" && pytest`. It uses a stand-in model, not the real one, and takes about 10 minutes on a 12-core machine when split across processes (about 25 minutes in one process). On 2026-11-
|
| 127 |
|
| 128 |
## Run it
|
| 129 |
|
|
|
|
| 49 |
| Real model, quiet week | silent in about 1 s |
|
| 50 |
| Real model, spike or drop week | PDF draft in about 195 s on a 2-vCPU Colab, about 5 min on the Space; approve re-renders, re-checks and writes it to the sent folder |
|
| 51 |
| Peak memory | 5.2 GB |
|
| 52 |
+
| Test suite (full project) | 659 passed, 2 skipped, 0 failed on 2026-11-13 (includes 5 new tests for bugs found on real data) |
|
| 53 |
| First weight edit (16,384 values, no training; superseded by the LoRA), held-out plans, 80 tasks, no grammar | 64/80 to 80/80; a random edit of the same size stayed at 64/80 |
|
| 54 |
| Small LoRA, round 2 (`lora3`), scored once on held-out seeds 400 to 439 (deployed on the Space) | plans 80/80, drafts 80/80 and 40/40 on each of six variants without a grammar, including two held-out reworded instructions (unedited model: 65/80, 0/80, 0/40 each) |
|
| 55 |
| First real-data test (UCI Online Retail, about 540,000 invoice rows, T4, model output unconstrained) | valid plan and draft each time; after we fixed a bug in our own number check, a full report for 3 of 3 weeks (it halted on all 3 before the fix) |
|
|
|
|
| 110 |
|---|---|---|
|
| 111 |
An earlier try on the Tableau Superstore sample stayed silent because its segments were far below the size minimum.
|
| 112 |
|
| 113 |
+
### A second real Excel file (UCI Online Retail II, 2026-11-13)
|
| 114 |
+
|
| 115 |
+
We pointed the app at a second real workbook, the UCI Online Retail II sheet for 2009-2010 (525,461 invoice lines, 53 weeks), on a T4 with `lora3` and unconstrained model calls. It found three bugs of ours, now fixed and covered by tests:
|
| 116 |
+
|
| 117 |
+
- A raw `.xlsx` halted with "file is not valid UTF-8". It now says the file is an Excel workbook and must be exported as CSV first.
|
| 118 |
+
- A first run crashed with `unable to open database file` when the work folder did not exist yet. The store now creates it.
|
| 119 |
+
- Our number check and the model-path calculation looked for a column literally named `date` and ignored `date_column` from the metrics file. All four weeks failed until we fixed it. After the fix, 4 of 4 weeks produced a full report.
|
| 120 |
+
|
| 121 |
+
I recomputed the headline numbers of one report from the CSV and they match the PDF exactly. The draft text is as formulaic as before. Details: [results/real-excel-online-retail-ii.md](results/real-excel-online-retail-ii.md). Example: [report PDF](examples/online-retail-ii-2010-w47.pdf), the [model's replies](examples/online-retail-ii-model-replies.json), the converter `scripts/online_retail_ii_to_csv.py` and the metrics file `examples/metrics_online_retail_ii.yaml`.
|
| 122 |
+
|
| 123 |
+
|  |  |  |
|
| 124 |
+
|---|---|---|
|
| 125 |
+
|
| 126 |
## Honest limits
|
| 127 |
|
| 128 |
- Detection: recall 0.21 on the holdout replay at the quiet gate (the bar was 0.80), and 14% of quiet weeks raised an alert. We know why, we have not fixed it.
|
|
|
|
| 130 |
- All scores are on synthetic tasks from one generator, without a JSON grammar, greedy, 80 tasks scored once per round. In production the agent uses a grammar and retries, so the real gain is probably smaller. The first LoRA failed an instruction that did not name the JSON fields (0/40); round 2 fixed that. Dev wordings were written in the same session as the training wordings, so dev was optimistic; the fresh-seed score is the clean number.
|
| 131 |
- The Space runs the round 2 GGUF; its llama.cpp check used unspent seeds 500 to 511 plus a held-out wording. The real-data test used one dataset and was not run on the Space.
|
| 132 |
- Free SQL: the unedited model got 21 of 30 under a grammar (bar 24), so named queries are the main path. This was not re-measured with the LoRA.
|
| 133 |
+
- Real data: two public datasets (UCI Online Retail and Online Retail II). The draft text there is formulaic; the numbers come from checked code. The product reads CSV only, so an Excel sheet must be converted first (`scripts/online_retail_to_csv.py` is one example).
|
| 134 |
- Chart reading by the vision encoder is not claimed (the vision projector would not load in the runtime used). CPU only on the Space; Apple Silicon speed is untested.
|
| 135 |
- The scenarios on the Space are synthetic. Nothing is uploaded, and the source file is never modified.
|
| 136 |
|
| 137 |
## Run the tests
|
| 138 |
|
| 139 |
+
The full test suite is in `tests/` of the GitHub repo (with `eval/` and `pyproject.toml`): `pip install -e ".[dev]" && pytest`. It uses a stand-in model, not the real one, and takes about 10 minutes on a 12-core machine when split across processes (about 25 minutes in one process). On 2026-11-13: 659 passed, 2 skipped, 0 failed.
|
| 140 |
|
| 141 |
## Run it
|
| 142 |
|
app.py
CHANGED
|
@@ -272,7 +272,7 @@ Set `WEEKLY_ANALYST_LORA=0` for the older 16,384-value plan edit.
|
|
| 272 |
Synthetic tasks from one generator. Reword b and c were never trained on.
|
| 273 |
|
| 274 |
### Honest limits
|
| 275 |
-
- The scenarios on this Space and the reliability figures are synthetic. The
|
| 276 |
- For free SQL the unedited 1B model got 21 of 30 questions right under a grammar, short of the 24 of 30 bar, so named,
|
| 277 |
parameterized queries are the main path and free SQL is a last resort. That was measured before the LoRA; free SQL
|
| 278 |
was not re-measured with the LoRA model this Space runs.
|
|
@@ -281,8 +281,10 @@ Synthetic tasks from one generator. Reword b and c were never trained on.
|
|
| 281 |
- The change detector's recall on the holdout replay is 0.21 (the bar was 0.80), and 14% of quiet weeks raised an alert.
|
| 282 |
- The LoRA and weight-edit scores are synthetic, from one task generator, scored once. A first real-data test (the UCI Online
|
| 283 |
Retail sheet, about 540,000 invoice rows, run on a T4 and not on this Space) gave a valid plan and draft each time, and
|
| 284 |
-
after we fixed a bug in our own number check the pipeline produced a full report for 3 of 3 weeks.
|
| 285 |
-
|
|
|
|
|
|
|
| 286 |
"""
|
| 287 |
|
| 288 |
with gr.Blocks(title="Weekly Analyst") as demo_app:
|
|
@@ -308,10 +310,10 @@ with gr.Blocks(title="Weekly Analyst") as demo_app:
|
|
| 308 |
with gr.Tab("Example reports (already run)"):
|
| 309 |
gr.Markdown(
|
| 310 |
"The first two reports (synthetic data) were produced by this same code with the real model and the earlier "
|
| 311 |
-
"plan edit, on a 2-core CPU, then approved with the real approve command. The
|
| 312 |
-
"(UCI Online Retail, week of 2011-12-04) made with the current LoRA model on a T4;
|
| 313 |
)
|
| 314 |
-
for prefix, title in (("drop", "Sales drop"), ("spike", "Sales spike"), ("online-retail", "Real data: Online Retail, 2011-W48")):
|
| 315 |
pages, pdf = example_files(prefix)
|
| 316 |
gr.Markdown(f"#### {title}")
|
| 317 |
gr.Gallery(value=pages, label=title, columns=3, height=420, object_fit="contain")
|
|
|
|
| 272 |
Synthetic tasks from one generator. Reword b and c were never trained on.
|
| 273 |
|
| 274 |
### Honest limits
|
| 275 |
+
- The scenarios on this Space and the reliability figures are synthetic. The real-data tests are described below.
|
| 276 |
- For free SQL the unedited 1B model got 21 of 30 questions right under a grammar, short of the 24 of 30 bar, so named,
|
| 277 |
parameterized queries are the main path and free SQL is a last resort. That was measured before the LoRA; free SQL
|
| 278 |
was not re-measured with the LoRA model this Space runs.
|
|
|
|
| 281 |
- The change detector's recall on the holdout replay is 0.21 (the bar was 0.80), and 14% of quiet weeks raised an alert.
|
| 282 |
- The LoRA and weight-edit scores are synthetic, from one task generator, scored once. A first real-data test (the UCI Online
|
| 283 |
Retail sheet, about 540,000 invoice rows, run on a T4 and not on this Space) gave a valid plan and draft each time, and
|
| 284 |
+
after we fixed a bug in our own number check the pipeline produced a full report for 3 of 3 weeks. A second workbook
|
| 285 |
+
(Online Retail II, 525,461 lines) produced a full report for 4 of 4 weeks after we fixed three more bugs of ours: a
|
| 286 |
+
misleading error for a raw .xlsx, a crash when the work folder did not exist, and a hard-coded `date` column name.
|
| 287 |
+
The agent still reads CSV only. Nothing has been measured for general capabilities beyond the agent's task.
|
| 288 |
"""
|
| 289 |
|
| 290 |
with gr.Blocks(title="Weekly Analyst") as demo_app:
|
|
|
|
| 310 |
with gr.Tab("Example reports (already run)"):
|
| 311 |
gr.Markdown(
|
| 312 |
"The first two reports (synthetic data) were produced by this same code with the real model and the earlier "
|
| 313 |
+
"plan edit, on a 2-core CPU, then approved with the real approve command. The last two are real public Excel data "
|
| 314 |
+
"(UCI Online Retail, week of 2011-12-04, and Online Retail II, 2010-W47) made with the current LoRA model on a T4; their draft text is formulaic."
|
| 315 |
)
|
| 316 |
+
for prefix, title in (("drop", "Sales drop"), ("spike", "Sales spike"), ("online-retail", "Real data: Online Retail, 2011-W48"), ("online-retail-ii", "Real data: Online Retail II, 2010-W47")):
|
| 317 |
pages, pdf = example_files(prefix)
|
| 318 |
gr.Markdown(f"#### {title}")
|
| 319 |
gr.Gallery(value=pages, label=title, columns=3, height=420, object_fit="contain")
|
examples/online-retail-ii-model-replies.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"note": "The model's raw replies for the week 2010-W47 (cut 2010-11-28), lora3 GGUF, T4, no JSON schema or grammar. plan = first reply, draft = second.",
|
| 3 |
+
"plan": {
|
| 4 |
+
"steps": [
|
| 5 |
+
{
|
| 6 |
+
"item_key": "return_rate|2010-W47|Country=EIRE,CustomerType=registered",
|
| 7 |
+
"tool": "named_query",
|
| 8 |
+
"args": {
|
| 9 |
+
"name": "by_segment",
|
| 10 |
+
"params": {
|
| 11 |
+
"metric": "return_rate",
|
| 12 |
+
"segment_col": "Country",
|
| 13 |
+
"period": "2010-W47"
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"item_key": "return_rate|2010-W47|Country=Germany,CustomerType=registered",
|
| 19 |
+
"tool": "named_query",
|
| 20 |
+
"args": {
|
| 21 |
+
"name": "by_segment",
|
| 22 |
+
"params": {
|
| 23 |
+
"metric": "return_rate",
|
| 24 |
+
"segment_col": "Country",
|
| 25 |
+
"period": "2010-W47"
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"item_key": "revenue|2010-W47|Country=EIRE,CustomerType=registered",
|
| 31 |
+
"tool": "named_query",
|
| 32 |
+
"args": {
|
| 33 |
+
"name": "by_segment",
|
| 34 |
+
"params": {
|
| 35 |
+
"metric": "revenue",
|
| 36 |
+
"segment_col": "Country",
|
| 37 |
+
"period": "2010-W47"
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
"draft": {
|
| 44 |
+
"headline": "Return rate moved up led by EIRE registered",
|
| 45 |
+
"recommended_action": "Review the affected segments with the owning team",
|
| 46 |
+
"findings": [
|
| 47 |
+
{
|
| 48 |
+
"item_key": "return_rate|2010-W47|Country=EIRE,CustomerType=registered",
|
| 49 |
+
"so_what": "Return rate moved up in EIRE registered",
|
| 50 |
+
"metric_refs": [
|
| 51 |
+
"return_rate"
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"item_key": "return_rate|2010-W47|Country=Germany,CustomerType=registered",
|
| 56 |
+
"so_what": "Return rate moved down in Germany registered",
|
| 57 |
+
"metric_refs": [
|
| 58 |
+
"return_rate"
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"item_key": "revenue|2010-W47|Country=EIRE,CustomerType=registered",
|
| 63 |
+
"so_what": "Revenue moved down in EIRE registered",
|
| 64 |
+
"metric_refs": [
|
| 65 |
+
"revenue"
|
| 66 |
+
]
|
| 67 |
+
}
|
| 68 |
+
],
|
| 69 |
+
"caveats": []
|
| 70 |
+
}
|
| 71 |
+
}
|
examples/online-retail-ii-p1.png
ADDED
|
examples/online-retail-ii-p2.png
ADDED
|
examples/online-retail-ii-p3.png
ADDED
|
examples/online-retail-ii-p4.png
ADDED
|
Git LFS Details
|
examples/online-retail-ii-p5.png
ADDED
|
examples/online-retail-ii-p6.png
ADDED
|
Git LFS Details
|
examples/online-retail-ii.pdf
ADDED
|
Binary file (55.2 kB). View file
|
|
|
src/weekly_analyst/analysis/checks.py
CHANGED
|
@@ -1,87 +1,87 @@
|
|
| 1 |
-
"""Deterministic report checks on the rendered PDF text (spec 6.3)."""
|
| 2 |
-
|
| 3 |
-
from collections.abc import Sequence
|
| 4 |
-
from dataclasses import dataclass
|
| 5 |
-
from pathlib import Path
|
| 6 |
-
from typing import Literal
|
| 7 |
-
|
| 8 |
-
from weekly_analyst.analysis.analyst import wording_check
|
| 9 |
-
from weekly_analyst.core.text import numerals
|
| 10 |
-
from weekly_analyst.data.metrics_file import MetricDef, MetricsFile
|
| 11 |
-
from weekly_analyst.data.recompute import matches_displayed, recompute
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
@dataclass
|
| 15 |
-
class ShownNumber:
|
| 16 |
-
text: str
|
| 17 |
-
metric: str
|
| 18 |
-
segment: dict[str, str] | None
|
| 19 |
-
period: str
|
| 20 |
-
prev_period: str | None = None
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
@dataclass
|
| 24 |
-
class Failure:
|
| 25 |
-
kind: Literal["wrong_number", "no_metric", "wording", "unlisted_number"]
|
| 26 |
-
detail: str
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
def _recomputed(snapshot_path: Path, metric: MetricDef, shown: ShownNumber) -> float | None:
|
| 30 |
-
"""The value a shown number must match: its level, or the signed change from prev_period to period."""
|
| 31 |
-
now = recompute(snapshot_path, metric, shown.segment, shown.period)
|
| 32 |
-
if shown.prev_period is None:
|
| 33 |
-
return now
|
| 34 |
-
before = recompute(snapshot_path, metric, shown.segment, shown.prev_period)
|
| 35 |
-
if now is None or before is None:
|
| 36 |
-
return None
|
| 37 |
-
return now - before
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def check_report(
|
| 41 |
-
pdf_text: str,
|
| 42 |
-
shown_numbers: list[ShownNumber],
|
| 43 |
-
metrics: MetricsFile,
|
| 44 |
-
snapshot_path: Path,
|
| 45 |
-
exempt: Sequence[str] = (),
|
| 46 |
-
model_text: str = "",
|
| 47 |
-
) -> list[Failure]:
|
| 48 |
-
"""Check each shown number, the wording, and every numeral on the page.
|
| 49 |
-
|
| 50 |
-
exempt holds the numerals the renderer writes itself (dates, page numbers, indices). model_text is
|
| 51 |
-
the model-written text: every numeral in it must be a shown number, with no exemption.
|
| 52 |
-
"""
|
| 53 |
-
failures: list[Failure] = []
|
| 54 |
-
by_name = {m.name: m for m in metrics.metrics}
|
| 55 |
-
|
| 56 |
-
for shown in shown_numbers:
|
| 57 |
-
metric = by_name.get(shown.metric)
|
| 58 |
-
if metric is None:
|
| 59 |
-
failures.append(Failure("no_metric", f"{shown.text!r} names no known metric {shown.metric!r}"))
|
| 60 |
-
continue
|
| 61 |
-
try:
|
| 62 |
-
value = _recomputed(snapshot_path, metric, shown)
|
| 63 |
-
matched = value is not None and matches_displayed(value, shown.text)
|
| 64 |
-
except ValueError as err:
|
| 65 |
-
failures.append(Failure("wrong_number", f"{shown.text!r} cannot be verified: {err}"))
|
| 66 |
-
continue
|
| 67 |
-
if not matched:
|
| 68 |
-
failures.append(
|
| 69 |
-
Failure("wrong_number", f"{shown.text!r} not confirmed by recompute (value {value!r})")
|
| 70 |
-
)
|
| 71 |
-
|
| 72 |
-
for flag in wording_check(pdf_text):
|
| 73 |
-
failures.append(Failure("wording", flag))
|
| 74 |
-
|
| 75 |
-
listed = {shown.text for shown in shown_numbers}
|
| 76 |
-
model_tokens = set(numerals(model_text))
|
| 77 |
-
exempt_tokens = set(exempt)
|
| 78 |
-
for token in dict.fromkeys(numerals(pdf_text)):
|
| 79 |
-
# A numeral from model text is judged by the stricter model_text rule below, so it is not reported twice.
|
| 80 |
-
if token not in listed and token not in exempt_tokens and token not in model_tokens:
|
| 81 |
-
failures.append(Failure("unlisted_number", token))
|
| 82 |
-
|
| 83 |
-
for token in dict.fromkeys(numerals(model_text)):
|
| 84 |
-
if token not in listed:
|
| 85 |
-
failures.append(Failure("unlisted_number", token))
|
| 86 |
-
|
| 87 |
-
return failures
|
|
|
|
| 1 |
+
"""Deterministic report checks on the rendered PDF text (spec 6.3)."""
|
| 2 |
+
|
| 3 |
+
from collections.abc import Sequence
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Literal
|
| 7 |
+
|
| 8 |
+
from weekly_analyst.analysis.analyst import wording_check
|
| 9 |
+
from weekly_analyst.core.text import numerals
|
| 10 |
+
from weekly_analyst.data.metrics_file import MetricDef, MetricsFile
|
| 11 |
+
from weekly_analyst.data.recompute import matches_displayed, recompute
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class ShownNumber:
|
| 16 |
+
text: str
|
| 17 |
+
metric: str
|
| 18 |
+
segment: dict[str, str] | None
|
| 19 |
+
period: str
|
| 20 |
+
prev_period: str | None = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Failure:
|
| 25 |
+
kind: Literal["wrong_number", "no_metric", "wording", "unlisted_number"]
|
| 26 |
+
detail: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _recomputed(snapshot_path: Path, metric: MetricDef, shown: ShownNumber, date_column: str) -> float | None:
|
| 30 |
+
"""The value a shown number must match: its level, or the signed change from prev_period to period."""
|
| 31 |
+
now = recompute(snapshot_path, metric, shown.segment, shown.period, date_column)
|
| 32 |
+
if shown.prev_period is None:
|
| 33 |
+
return now
|
| 34 |
+
before = recompute(snapshot_path, metric, shown.segment, shown.prev_period, date_column)
|
| 35 |
+
if now is None or before is None:
|
| 36 |
+
return None
|
| 37 |
+
return now - before
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def check_report(
|
| 41 |
+
pdf_text: str,
|
| 42 |
+
shown_numbers: list[ShownNumber],
|
| 43 |
+
metrics: MetricsFile,
|
| 44 |
+
snapshot_path: Path,
|
| 45 |
+
exempt: Sequence[str] = (),
|
| 46 |
+
model_text: str = "",
|
| 47 |
+
) -> list[Failure]:
|
| 48 |
+
"""Check each shown number, the wording, and every numeral on the page.
|
| 49 |
+
|
| 50 |
+
exempt holds the numerals the renderer writes itself (dates, page numbers, indices). model_text is
|
| 51 |
+
the model-written text: every numeral in it must be a shown number, with no exemption.
|
| 52 |
+
"""
|
| 53 |
+
failures: list[Failure] = []
|
| 54 |
+
by_name = {m.name: m for m in metrics.metrics}
|
| 55 |
+
|
| 56 |
+
for shown in shown_numbers:
|
| 57 |
+
metric = by_name.get(shown.metric)
|
| 58 |
+
if metric is None:
|
| 59 |
+
failures.append(Failure("no_metric", f"{shown.text!r} names no known metric {shown.metric!r}"))
|
| 60 |
+
continue
|
| 61 |
+
try:
|
| 62 |
+
value = _recomputed(snapshot_path, metric, shown, metrics.date_column)
|
| 63 |
+
matched = value is not None and matches_displayed(value, shown.text)
|
| 64 |
+
except ValueError as err:
|
| 65 |
+
failures.append(Failure("wrong_number", f"{shown.text!r} cannot be verified: {err}"))
|
| 66 |
+
continue
|
| 67 |
+
if not matched:
|
| 68 |
+
failures.append(
|
| 69 |
+
Failure("wrong_number", f"{shown.text!r} not confirmed by recompute (value {value!r})")
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
for flag in wording_check(pdf_text):
|
| 73 |
+
failures.append(Failure("wording", flag))
|
| 74 |
+
|
| 75 |
+
listed = {shown.text for shown in shown_numbers}
|
| 76 |
+
model_tokens = set(numerals(model_text))
|
| 77 |
+
exempt_tokens = set(exempt)
|
| 78 |
+
for token in dict.fromkeys(numerals(pdf_text)):
|
| 79 |
+
# A numeral from model text is judged by the stricter model_text rule below, so it is not reported twice.
|
| 80 |
+
if token not in listed and token not in exempt_tokens and token not in model_tokens:
|
| 81 |
+
failures.append(Failure("unlisted_number", token))
|
| 82 |
+
|
| 83 |
+
for token in dict.fromkeys(numerals(model_text)):
|
| 84 |
+
if token not in listed:
|
| 85 |
+
failures.append(Failure("unlisted_number", token))
|
| 86 |
+
|
| 87 |
+
return failures
|
src/weekly_analyst/core/store.py
CHANGED
|
@@ -1,78 +1,79 @@
|
|
| 1 |
-
"""SQLite state store: weekly alert counts, reported items, run states (spec 6.6)."""
|
| 2 |
-
|
| 3 |
-
import json
|
| 4 |
-
import sqlite3
|
| 5 |
-
from collections.abc import Iterator
|
| 6 |
-
from contextlib import contextmanager
|
| 7 |
-
from datetime import datetime
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
def iso_week_of(ts: datetime) -> str:
|
| 12 |
-
iso = ts.isocalendar()
|
| 13 |
-
return f"{iso.year}-W{iso.week:02d}"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
_SCHEMA = """
|
| 17 |
-
CREATE TABLE IF NOT EXISTS alerts (
|
| 18 |
-
item_key TEXT NOT NULL,
|
| 19 |
-
iso_week TEXT NOT NULL,
|
| 20 |
-
run_id TEXT NOT NULL
|
| 21 |
-
);
|
| 22 |
-
CREATE TABLE IF NOT EXISTS runs (
|
| 23 |
-
run_id TEXT PRIMARY KEY,
|
| 24 |
-
state TEXT NOT NULL,
|
| 25 |
-
payload TEXT NOT NULL
|
| 26 |
-
);
|
| 27 |
-
"""
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
class StateStore:
|
| 31 |
-
def __init__(self, path: Path) -> None:
|
| 32 |
-
self._path = Path(path)
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
(item_key, iso_week, run_id),
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
(run_id, state,
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
| 1 |
+
"""SQLite state store: weekly alert counts, reported items, run states (spec 6.6)."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import sqlite3
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def iso_week_of(ts: datetime) -> str:
|
| 12 |
+
iso = ts.isocalendar()
|
| 13 |
+
return f"{iso.year}-W{iso.week:02d}"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
_SCHEMA = """
|
| 17 |
+
CREATE TABLE IF NOT EXISTS alerts (
|
| 18 |
+
item_key TEXT NOT NULL,
|
| 19 |
+
iso_week TEXT NOT NULL,
|
| 20 |
+
run_id TEXT NOT NULL
|
| 21 |
+
);
|
| 22 |
+
CREATE TABLE IF NOT EXISTS runs (
|
| 23 |
+
run_id TEXT PRIMARY KEY,
|
| 24 |
+
state TEXT NOT NULL,
|
| 25 |
+
payload TEXT NOT NULL
|
| 26 |
+
);
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class StateStore:
|
| 31 |
+
def __init__(self, path: Path) -> None:
|
| 32 |
+
self._path = Path(path)
|
| 33 |
+
self._path.parent.mkdir(parents=True, exist_ok=True) # a first run has no work folder yet
|
| 34 |
+
with self._transaction() as conn:
|
| 35 |
+
conn.executescript(_SCHEMA)
|
| 36 |
+
|
| 37 |
+
@contextmanager
|
| 38 |
+
def _transaction(self) -> Iterator[sqlite3.Connection]:
|
| 39 |
+
conn = sqlite3.connect(self._path)
|
| 40 |
+
try:
|
| 41 |
+
with conn:
|
| 42 |
+
yield conn
|
| 43 |
+
finally:
|
| 44 |
+
conn.close()
|
| 45 |
+
|
| 46 |
+
def alerts_sent_in_week(self, iso_week: str) -> int:
|
| 47 |
+
with self._transaction() as conn:
|
| 48 |
+
row = conn.execute(
|
| 49 |
+
"SELECT COUNT(*) FROM alerts WHERE iso_week = ?", (iso_week,)
|
| 50 |
+
).fetchone()
|
| 51 |
+
return int(row[0])
|
| 52 |
+
|
| 53 |
+
def record_alert(self, item_key: str, iso_week: str, run_id: str) -> None:
|
| 54 |
+
with self._transaction() as conn:
|
| 55 |
+
conn.execute(
|
| 56 |
+
"INSERT INTO alerts (item_key, iso_week, run_id) VALUES (?, ?, ?)",
|
| 57 |
+
(item_key, iso_week, run_id),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def reported_keys(self) -> set[str]:
|
| 61 |
+
with self._transaction() as conn:
|
| 62 |
+
rows = conn.execute("SELECT DISTINCT item_key FROM alerts").fetchall()
|
| 63 |
+
return {row[0] for row in rows}
|
| 64 |
+
|
| 65 |
+
def save_state(self, run_id: str, state: str, payload: dict) -> None:
|
| 66 |
+
with self._transaction() as conn:
|
| 67 |
+
conn.execute(
|
| 68 |
+
"INSERT OR REPLACE INTO runs (run_id, state, payload) VALUES (?, ?, ?)",
|
| 69 |
+
(run_id, state, json.dumps(payload, sort_keys=True)),
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def load_run(self, run_id: str) -> dict | None:
|
| 73 |
+
with self._transaction() as conn:
|
| 74 |
+
row = conn.execute(
|
| 75 |
+
"SELECT state, payload FROM runs WHERE run_id = ?", (run_id,)
|
| 76 |
+
).fetchone()
|
| 77 |
+
if row is None:
|
| 78 |
+
return None
|
| 79 |
+
return {"run_id": run_id, "state": row[0], "payload": json.loads(row[1])}
|
src/weekly_analyst/data/extract.py
CHANGED
|
@@ -1,158 +1,162 @@
|
|
| 1 |
-
"""Read-only CSV extract to a Parquet snapshot (spec 6.2 state 2, 6.5)."""
|
| 2 |
-
|
| 3 |
-
import codecs
|
| 4 |
-
import csv
|
| 5 |
-
import hashlib
|
| 6 |
-
import logging
|
| 7 |
-
import os
|
| 8 |
-
import re
|
| 9 |
-
import tempfile
|
| 10 |
-
from dataclasses import dataclass
|
| 11 |
-
from datetime import date
|
| 12 |
-
from pathlib import Path
|
| 13 |
-
|
| 14 |
-
import polars as pl
|
| 15 |
-
|
| 16 |
-
from weekly_analyst.core.config import Settings
|
| 17 |
-
from weekly_analyst.data.metrics_file import MetricsFile
|
| 18 |
-
|
| 19 |
-
log = logging.getLogger(__name__)
|
| 20 |
-
|
| 21 |
-
ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
|
| 22 |
-
CHUNK_BYTES = 1 << 20
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
code,
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
return False
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
if
|
| 87 |
-
|
| 88 |
-
if
|
| 89 |
-
raise _halt("
|
| 90 |
-
if
|
| 91 |
-
raise _halt("bad_header",
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
os.
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read-only CSV extract to a Parquet snapshot (spec 6.2 state 2, 6.5)."""
|
| 2 |
+
|
| 3 |
+
import codecs
|
| 4 |
+
import csv
|
| 5 |
+
import hashlib
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import tempfile
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from datetime import date
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import polars as pl
|
| 15 |
+
|
| 16 |
+
from weekly_analyst.core.config import Settings
|
| 17 |
+
from weekly_analyst.data.metrics_file import MetricsFile
|
| 18 |
+
|
| 19 |
+
log = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
ISO_DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}")
|
| 22 |
+
CHUNK_BYTES = 1 << 20
|
| 23 |
+
WORKBOOK_MAGIC = (b"PK\x03\x04", b"\xd0\xcf\x11\xe0") # .xlsx (zip) and old .xls (OLE)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ExtractError(Exception):
|
| 27 |
+
def __init__(self, code: str, detail: str):
|
| 28 |
+
super().__init__(f"{code}: {detail}")
|
| 29 |
+
self.code = code
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@dataclass(frozen=True)
|
| 33 |
+
class ExtractResult:
|
| 34 |
+
snapshot_hash: str
|
| 35 |
+
parquet_path: Path
|
| 36 |
+
rows_read: int
|
| 37 |
+
rows_in_file: int
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _halt(code: str, detail: str, rows_read: int, rows_in_file: int | str) -> ExtractError:
|
| 41 |
+
log.error(
|
| 42 |
+
"extract halted code=%s rows_read=%s rows_in_file=%s detail=%s",
|
| 43 |
+
code,
|
| 44 |
+
rows_read,
|
| 45 |
+
rows_in_file,
|
| 46 |
+
detail,
|
| 47 |
+
)
|
| 48 |
+
return ExtractError(code, detail)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _hash_and_check_utf8(csv_path: Path) -> str:
|
| 52 |
+
digest = hashlib.sha256()
|
| 53 |
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
| 54 |
+
with open(csv_path, "rb") as fh:
|
| 55 |
+
if fh.read(4) in WORKBOOK_MAGIC:
|
| 56 |
+
raise _halt("not_csv", "this is an Excel workbook, not a CSV; export the sheet as CSV (UTF-8) first", 0, "unknown")
|
| 57 |
+
fh.seek(0)
|
| 58 |
+
for chunk in iter(lambda: fh.read(CHUNK_BYTES), b""):
|
| 59 |
+
digest.update(chunk)
|
| 60 |
+
try:
|
| 61 |
+
decoder.decode(chunk)
|
| 62 |
+
except UnicodeDecodeError:
|
| 63 |
+
raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
|
| 64 |
+
try:
|
| 65 |
+
decoder.decode(b"", final=True)
|
| 66 |
+
except UnicodeDecodeError:
|
| 67 |
+
raise _halt("bad_encoding", "file is not valid UTF-8", 0, "unknown") from None
|
| 68 |
+
return digest.hexdigest()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _is_iso_date(value: str) -> bool:
|
| 72 |
+
if not ISO_DATE.fullmatch(value):
|
| 73 |
+
return False
|
| 74 |
+
try:
|
| 75 |
+
date.fromisoformat(value)
|
| 76 |
+
except ValueError:
|
| 77 |
+
return False
|
| 78 |
+
return True
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _scan_source(csv_path: Path, metrics: MetricsFile) -> tuple[list[str], int, int | None]:
|
| 82 |
+
"""Stream the file once: check the header, count data rows, find the first bad date."""
|
| 83 |
+
with open(csv_path, "r", encoding="utf-8-sig", newline="") as fh:
|
| 84 |
+
reader = csv.reader(fh)
|
| 85 |
+
header = next(reader, None)
|
| 86 |
+
if header is None:
|
| 87 |
+
return [], 0, None
|
| 88 |
+
if not header:
|
| 89 |
+
raise _halt("bad_header", "header row is blank", 0, "unknown")
|
| 90 |
+
if any(";" in cell for cell in header):
|
| 91 |
+
raise _halt("bad_header", "header uses a semicolon delimiter; use commas", 0, "unknown")
|
| 92 |
+
if len(set(header)) != len(header):
|
| 93 |
+
raise _halt("duplicate_columns", "header has duplicate column names", 0, "unknown")
|
| 94 |
+
if metrics.date_column not in header:
|
| 95 |
+
raise _halt("bad_header", f"header has no date column {metrics.date_column!r}", 0, "unknown")
|
| 96 |
+
date_index = header.index(metrics.date_column)
|
| 97 |
+
ncols = len(header)
|
| 98 |
+
rows = 0
|
| 99 |
+
first_bad_date: int | None = None
|
| 100 |
+
for record in reader:
|
| 101 |
+
if len(record) != ncols:
|
| 102 |
+
raise _halt(
|
| 103 |
+
"bad_header",
|
| 104 |
+
f"data row {rows + 1} has {len(record)} fields, header has {ncols}",
|
| 105 |
+
0,
|
| 106 |
+
"unknown",
|
| 107 |
+
)
|
| 108 |
+
rows += 1
|
| 109 |
+
if first_bad_date is None and not _is_iso_date(record[date_index]):
|
| 110 |
+
first_bad_date = rows
|
| 111 |
+
return header, rows, first_bad_date
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _write_atomic(df: pl.DataFrame, final: Path) -> None:
|
| 115 |
+
fd, tmp_name = tempfile.mkstemp(dir=final.parent, prefix=".extract-", suffix=".tmp")
|
| 116 |
+
tmp = Path(tmp_name)
|
| 117 |
+
try:
|
| 118 |
+
with os.fdopen(fd, "wb") as fh:
|
| 119 |
+
df.write_parquet(fh)
|
| 120 |
+
fh.flush()
|
| 121 |
+
os.fsync(fh.fileno())
|
| 122 |
+
os.replace(tmp, final)
|
| 123 |
+
except BaseException:
|
| 124 |
+
tmp.unlink(missing_ok=True)
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def extract(csv_path: Path, metrics: MetricsFile, out_dir: Path, settings: Settings) -> ExtractResult:
|
| 129 |
+
size = csv_path.stat().st_size
|
| 130 |
+
if size > settings.max_bytes:
|
| 131 |
+
raise _halt("too_big", f"file is {size} bytes, cap is {settings.max_bytes}", 0, "unknown")
|
| 132 |
+
|
| 133 |
+
snapshot_hash = _hash_and_check_utf8(csv_path)
|
| 134 |
+
header, rows, first_bad_date = _scan_source(csv_path, metrics)
|
| 135 |
+
if rows == 0:
|
| 136 |
+
raise _halt("empty", "no data rows after the header", 0, 0)
|
| 137 |
+
if rows > settings.max_rows:
|
| 138 |
+
raise _halt("too_many_rows", f"{rows} rows, cap is {settings.max_rows}", 0, rows)
|
| 139 |
+
cells = rows * len(header)
|
| 140 |
+
if cells > settings.max_cells:
|
| 141 |
+
raise _halt("too_many_cells", f"{cells} cells, cap is {settings.max_cells}", 0, rows)
|
| 142 |
+
if first_bad_date is not None:
|
| 143 |
+
raise _halt("bad_date", f"data row {first_bad_date} is not an ISO date (YYYY-MM-DD)", 0, rows)
|
| 144 |
+
|
| 145 |
+
metric_columns = metrics.columns()
|
| 146 |
+
present = [name for name in header if name in metric_columns]
|
| 147 |
+
df = pl.read_csv(
|
| 148 |
+
csv_path,
|
| 149 |
+
columns=present,
|
| 150 |
+
schema_overrides={metrics.date_column: pl.Utf8},
|
| 151 |
+
infer_schema_length=None,
|
| 152 |
+
)
|
| 153 |
+
df = df.with_columns(pl.col(metrics.date_column).str.to_date("%Y-%m-%d", strict=True))
|
| 154 |
+
|
| 155 |
+
final = out_dir / f"snapshot-{snapshot_hash}.parquet"
|
| 156 |
+
_write_atomic(df, final)
|
| 157 |
+
return ExtractResult(
|
| 158 |
+
snapshot_hash=snapshot_hash,
|
| 159 |
+
parquet_path=final,
|
| 160 |
+
rows_read=df.height,
|
| 161 |
+
rows_in_file=rows,
|
| 162 |
+
)
|
src/weekly_analyst/data/metrics_calc.py
CHANGED
|
@@ -1,38 +1,38 @@
|
|
| 1 |
-
"""Model-path metric computation with polars (spec 6.3, 6.8).
|
| 2 |
-
|
| 3 |
-
Independent of recompute.py by design: no shared code or engine.
|
| 4 |
-
"""
|
| 5 |
-
|
| 6 |
-
import math
|
| 7 |
-
|
| 8 |
-
import polars as pl
|
| 9 |
-
|
| 10 |
-
from weekly_analyst.core.config import Settings
|
| 11 |
-
from weekly_analyst.data.metrics_file import MetricDef
|
| 12 |
-
|
| 13 |
-
DATE_COLUMN = "date"
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def compute(
|
| 17 |
-
df: pl.DataFrame, m: MetricDef, segment: dict[str, str] | None, period: str
|
| 18 |
-
) -> float | None:
|
| 19 |
-
"""Value of metric m for one ISO week (e.g. "2026-W46") and segment.
|
| 20 |
-
|
| 21 |
-
Returns None when the slice has fewer rows than Settings().min_rows_per_segment,
|
| 22 |
-
or when there are no non-null values to aggregate.
|
| 23 |
-
"""
|
| 24 |
-
predicate = pl.col(
|
| 25 |
-
for key, value in (segment or {}).items():
|
| 26 |
-
predicate = predicate & (pl.col(key) == value)
|
| 27 |
-
rows = df.filter(predicate)
|
| 28 |
-
if rows.height < Settings().min_rows_per_segment:
|
| 29 |
-
return None
|
| 30 |
-
values = rows.get_column(m.column).drop_nulls()
|
| 31 |
-
if m.agg == "count":
|
| 32 |
-
return float(values.len())
|
| 33 |
-
if values.len() == 0:
|
| 34 |
-
return None
|
| 35 |
-
result = values.sum() if m.agg == "sum" else values.mean()
|
| 36 |
-
if result is None or math.isnan(result):
|
| 37 |
-
return None
|
| 38 |
-
return float(result)
|
|
|
|
| 1 |
+
"""Model-path metric computation with polars (spec 6.3, 6.8).
|
| 2 |
+
|
| 3 |
+
Independent of recompute.py by design: no shared code or engine.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
|
| 8 |
+
import polars as pl
|
| 9 |
+
|
| 10 |
+
from weekly_analyst.core.config import Settings
|
| 11 |
+
from weekly_analyst.data.metrics_file import MetricDef
|
| 12 |
+
|
| 13 |
+
DATE_COLUMN = "date"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def compute(
|
| 17 |
+
df: pl.DataFrame, m: MetricDef, segment: dict[str, str] | None, period: str, date_column: str = DATE_COLUMN
|
| 18 |
+
) -> float | None:
|
| 19 |
+
"""Value of metric m for one ISO week (e.g. "2026-W46") and segment.
|
| 20 |
+
|
| 21 |
+
Returns None when the slice has fewer rows than Settings().min_rows_per_segment,
|
| 22 |
+
or when there are no non-null values to aggregate.
|
| 23 |
+
"""
|
| 24 |
+
predicate = pl.col(date_column).cast(pl.Date).dt.strftime("%G-W%V") == period
|
| 25 |
+
for key, value in (segment or {}).items():
|
| 26 |
+
predicate = predicate & (pl.col(key) == value)
|
| 27 |
+
rows = df.filter(predicate)
|
| 28 |
+
if rows.height < Settings().min_rows_per_segment:
|
| 29 |
+
return None
|
| 30 |
+
values = rows.get_column(m.column).drop_nulls()
|
| 31 |
+
if m.agg == "count":
|
| 32 |
+
return float(values.len())
|
| 33 |
+
if values.len() == 0:
|
| 34 |
+
return None
|
| 35 |
+
result = values.sum() if m.agg == "sum" else values.mean()
|
| 36 |
+
if result is None or math.isnan(result):
|
| 37 |
+
return None
|
| 38 |
+
return float(result)
|
src/weekly_analyst/data/recompute.py
CHANGED
|
@@ -1,78 +1,80 @@
|
|
| 1 |
-
"""Independent recompute of a metric from the Parquet snapshot (spec 6.3).
|
| 2 |
-
|
| 3 |
-
Deliberately separate from metrics_calc: pyarrow and pyarrow.compute only, so the
|
| 4 |
-
two implementations share neither engine nor code.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import math
|
| 8 |
-
import re
|
| 9 |
-
from decimal import ROUND_HALF_UP, Decimal
|
| 10 |
-
from pathlib import Path
|
| 11 |
-
|
| 12 |
-
import pyarrow as pa
|
| 13 |
-
import pyarrow.compute as pc
|
| 14 |
-
import pyarrow.parquet as pq
|
| 15 |
-
|
| 16 |
-
from weekly_analyst.core.config import Settings
|
| 17 |
-
from weekly_analyst.data.metrics_file import MetricDef
|
| 18 |
-
|
| 19 |
-
# The snapshot's date column. recompute's signature carries no date column name,
|
| 20 |
-
# and the simulator and metrics fixture both use "date".
|
| 21 |
-
DATE_COLUMN = "date"
|
| 22 |
-
|
| 23 |
-
_PERIOD = re.compile(r"^(\d{4})-W(\d{2})$")
|
| 24 |
-
_SHOWN = re.compile(
|
| 25 |
-
r"^(?P<sign>[+-]?)(?P<int>\d{1,3}(?:,\d{3})+|\d+)(?:\.(?P<frac>\d+))?(?P<suffix>[KMB%]?)$"
|
| 26 |
-
)
|
| 27 |
-
# Divisor that turns the raw value into the unit shown ("%" shows a fraction as percent).
|
| 28 |
-
_SCALE = {
|
| 29 |
-
"": Decimal(1),
|
| 30 |
-
"K": Decimal(10**3),
|
| 31 |
-
"M": Decimal(10**6),
|
| 32 |
-
"B": Decimal(10**9),
|
| 33 |
-
"%": Decimal("0.01"),
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _parse_period(period: str) -> tuple[int, int]:
|
| 38 |
-
match = _PERIOD.match(period)
|
| 39 |
-
if match is None:
|
| 40 |
-
raise ValueError(f"period must look like 2026-W46, got {period!r}")
|
| 41 |
-
return int(match.group(1)), int(match.group(2))
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def recompute(
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
result = pc.
|
| 62 |
-
|
| 63 |
-
result = pc.
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
if
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Independent recompute of a metric from the Parquet snapshot (spec 6.3).
|
| 2 |
+
|
| 3 |
+
Deliberately separate from metrics_calc: pyarrow and pyarrow.compute only, so the
|
| 4 |
+
two implementations share neither engine nor code.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
import re
|
| 9 |
+
from decimal import ROUND_HALF_UP, Decimal
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import pyarrow as pa
|
| 13 |
+
import pyarrow.compute as pc
|
| 14 |
+
import pyarrow.parquet as pq
|
| 15 |
+
|
| 16 |
+
from weekly_analyst.core.config import Settings
|
| 17 |
+
from weekly_analyst.data.metrics_file import MetricDef
|
| 18 |
+
|
| 19 |
+
# The snapshot's date column. recompute's signature carries no date column name,
|
| 20 |
+
# and the simulator and metrics fixture both use "date".
|
| 21 |
+
DATE_COLUMN = "date"
|
| 22 |
+
|
| 23 |
+
_PERIOD = re.compile(r"^(\d{4})-W(\d{2})$")
|
| 24 |
+
_SHOWN = re.compile(
|
| 25 |
+
r"^(?P<sign>[+-]?)(?P<int>\d{1,3}(?:,\d{3})+|\d+)(?:\.(?P<frac>\d+))?(?P<suffix>[KMB%]?)$"
|
| 26 |
+
)
|
| 27 |
+
# Divisor that turns the raw value into the unit shown ("%" shows a fraction as percent).
|
| 28 |
+
_SCALE = {
|
| 29 |
+
"": Decimal(1),
|
| 30 |
+
"K": Decimal(10**3),
|
| 31 |
+
"M": Decimal(10**6),
|
| 32 |
+
"B": Decimal(10**9),
|
| 33 |
+
"%": Decimal("0.01"),
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _parse_period(period: str) -> tuple[int, int]:
|
| 38 |
+
match = _PERIOD.match(period)
|
| 39 |
+
if match is None:
|
| 40 |
+
raise ValueError(f"period must look like 2026-W46, got {period!r}")
|
| 41 |
+
return int(match.group(1)), int(match.group(2))
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def recompute(
|
| 45 |
+
snapshot_path: Path, m: MetricDef, segment: dict[str, str] | None, period: str, date_column: str = DATE_COLUMN
|
| 46 |
+
) -> float | None:
|
| 47 |
+
year, week = _parse_period(period)
|
| 48 |
+
segment = segment or {}
|
| 49 |
+
table = pq.read_table(snapshot_path, columns=[date_column, m.column, *segment])
|
| 50 |
+
|
| 51 |
+
dates = pc.cast(table[date_column], pa.date32())
|
| 52 |
+
mask = pc.and_(pc.equal(pc.iso_year(dates), year), pc.equal(pc.iso_week(dates), week))
|
| 53 |
+
for key, value in segment.items():
|
| 54 |
+
mask = pc.and_(mask, pc.equal(pc.cast(table[key], pa.string()), value))
|
| 55 |
+
rows = table.filter(mask, null_selection_behavior="drop")
|
| 56 |
+
|
| 57 |
+
if rows.num_rows < Settings().min_rows_per_segment:
|
| 58 |
+
return None
|
| 59 |
+
values = rows[m.column]
|
| 60 |
+
if m.agg == "sum":
|
| 61 |
+
result = pc.sum(values)
|
| 62 |
+
elif m.agg == "mean":
|
| 63 |
+
result = pc.mean(values)
|
| 64 |
+
else:
|
| 65 |
+
result = pc.count(values)
|
| 66 |
+
result = result.as_py()
|
| 67 |
+
return None if result is None else float(result)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def matches_displayed(value: float, shown: str) -> bool:
|
| 71 |
+
match = _SHOWN.match(shown.strip())
|
| 72 |
+
if match is None:
|
| 73 |
+
raise ValueError(f"unrecognised displayed number: {shown!r}")
|
| 74 |
+
if not math.isfinite(value):
|
| 75 |
+
return False
|
| 76 |
+
frac = match.group("frac") or ""
|
| 77 |
+
expected = Decimal(match.group("sign") + match.group("int").replace(",", "") + ("." + frac if frac else ""))
|
| 78 |
+
scaled = Decimal(repr(float(value))) / _SCALE[match.group("suffix")]
|
| 79 |
+
rounded = scaled.quantize(Decimal(1).scaleb(-len(frac)), rounding=ROUND_HALF_UP)
|
| 80 |
+
return rounded == expected
|
src/weekly_analyst/systems/detect.py
CHANGED
|
@@ -1,59 +1,59 @@
|
|
| 1 |
-
"""Detect state: score and rank candidate changes, then select alerts within the weekly budget (spec 6.2, 6.6)."""
|
| 2 |
-
|
| 3 |
-
import math
|
| 4 |
-
from dataclasses import replace
|
| 5 |
-
|
| 6 |
-
from weekly_analyst.core.components import ItemsC, SnapshotC, ValidationC
|
| 7 |
-
from weekly_analyst.core.context import RunContext
|
| 8 |
-
from weekly_analyst.core.entity import Entity
|
| 9 |
-
from weekly_analyst.core.items import Item
|
| 10 |
-
from weekly_analyst.core.system import System
|
| 11 |
-
from weekly_analyst.core.world import World
|
| 12 |
-
from weekly_analyst.data.metrics_calc import compute
|
| 13 |
-
from weekly_analyst.detection import remaining_budget, score_items, select_alerts
|
| 14 |
-
from weekly_analyst.systems.common import frame, metric_map, prev_period, split
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
class DetectSystem(System):
|
| 18 |
-
name = "detect"
|
| 19 |
-
requires = (ValidationC, SnapshotC)
|
| 20 |
-
produces = (ItemsC,)
|
| 21 |
-
|
| 22 |
-
def run(self, world: World, entity: Entity, ctx: RunContext) -> None:
|
| 23 |
-
validation = world.get(entity, ValidationC)
|
| 24 |
-
df = frame(world, entity, ctx)
|
| 25 |
-
history, current, period = split(df, ctx.metrics.date_column)
|
| 26 |
-
selected: list[Item] = []
|
| 27 |
-
if not validation.cold_start:
|
| 28 |
-
items = score_items(history, current, ctx.metrics, ctx.settings)
|
| 29 |
-
prev = prev_period(period)
|
| 30 |
-
priced = [
|
| 31 |
-
replace(item, detail={**item.detail, "abs_change": _abs_change(ctx, df, item, prev)})
|
| 32 |
-
for item in items
|
| 33 |
-
]
|
| 34 |
-
min_change = {m.name: m.min_change for m in ctx.metrics.metrics}
|
| 35 |
-
budget = remaining_budget(ctx.store, period, ctx.settings)
|
| 36 |
-
selected = select_alerts(priced, budget, min_change, ctx.store.reported_keys())
|
| 37 |
-
schema_change = any(f["kind"] == "schema_change" for f in validation.findings)
|
| 38 |
-
world.add(
|
| 39 |
-
entity,
|
| 40 |
-
ItemsC(
|
| 41 |
-
selected=selected,
|
| 42 |
-
material=bool(selected) or schema_change,
|
| 43 |
-
alerts_before=ctx.store.alerts_sent_in_week(period),
|
| 44 |
-
),
|
| 45 |
-
)
|
| 46 |
-
|
| 47 |
-
def ends_run(self, world: World, entity: Entity) -> bool:
|
| 48 |
-
"""A quiet snapshot (no material item, no schema change) ends after detect; nothing is sent."""
|
| 49 |
-
return not world.get(entity, ItemsC).material
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def _abs_change(ctx: RunContext, df, item: Item, prev: str) -> float:
|
| 53 |
-
metric = metric_map(ctx)[item.metric]
|
| 54 |
-
segment = dict(item.segment)
|
| 55 |
-
now = compute(df, metric, segment, item.period)
|
| 56 |
-
before = compute(df, metric, segment, prev)
|
| 57 |
-
if now is None or before is None:
|
| 58 |
-
return math.nan # a NaN change fails the minimum-change gate
|
| 59 |
-
return abs(now - before)
|
|
|
|
| 1 |
+
"""Detect state: score and rank candidate changes, then select alerts within the weekly budget (spec 6.2, 6.6)."""
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
from dataclasses import replace
|
| 5 |
+
|
| 6 |
+
from weekly_analyst.core.components import ItemsC, SnapshotC, ValidationC
|
| 7 |
+
from weekly_analyst.core.context import RunContext
|
| 8 |
+
from weekly_analyst.core.entity import Entity
|
| 9 |
+
from weekly_analyst.core.items import Item
|
| 10 |
+
from weekly_analyst.core.system import System
|
| 11 |
+
from weekly_analyst.core.world import World
|
| 12 |
+
from weekly_analyst.data.metrics_calc import compute
|
| 13 |
+
from weekly_analyst.detection import remaining_budget, score_items, select_alerts
|
| 14 |
+
from weekly_analyst.systems.common import frame, metric_map, prev_period, split
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class DetectSystem(System):
|
| 18 |
+
name = "detect"
|
| 19 |
+
requires = (ValidationC, SnapshotC)
|
| 20 |
+
produces = (ItemsC,)
|
| 21 |
+
|
| 22 |
+
def run(self, world: World, entity: Entity, ctx: RunContext) -> None:
|
| 23 |
+
validation = world.get(entity, ValidationC)
|
| 24 |
+
df = frame(world, entity, ctx)
|
| 25 |
+
history, current, period = split(df, ctx.metrics.date_column)
|
| 26 |
+
selected: list[Item] = []
|
| 27 |
+
if not validation.cold_start:
|
| 28 |
+
items = score_items(history, current, ctx.metrics, ctx.settings)
|
| 29 |
+
prev = prev_period(period)
|
| 30 |
+
priced = [
|
| 31 |
+
replace(item, detail={**item.detail, "abs_change": _abs_change(ctx, df, item, prev)})
|
| 32 |
+
for item in items
|
| 33 |
+
]
|
| 34 |
+
min_change = {m.name: m.min_change for m in ctx.metrics.metrics}
|
| 35 |
+
budget = remaining_budget(ctx.store, period, ctx.settings)
|
| 36 |
+
selected = select_alerts(priced, budget, min_change, ctx.store.reported_keys())
|
| 37 |
+
schema_change = any(f["kind"] == "schema_change" for f in validation.findings)
|
| 38 |
+
world.add(
|
| 39 |
+
entity,
|
| 40 |
+
ItemsC(
|
| 41 |
+
selected=selected,
|
| 42 |
+
material=bool(selected) or schema_change,
|
| 43 |
+
alerts_before=ctx.store.alerts_sent_in_week(period),
|
| 44 |
+
),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def ends_run(self, world: World, entity: Entity) -> bool:
|
| 48 |
+
"""A quiet snapshot (no material item, no schema change) ends after detect; nothing is sent."""
|
| 49 |
+
return not world.get(entity, ItemsC).material
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _abs_change(ctx: RunContext, df, item: Item, prev: str) -> float:
|
| 53 |
+
metric = metric_map(ctx)[item.metric]
|
| 54 |
+
segment = dict(item.segment)
|
| 55 |
+
now = compute(df, metric, segment, item.period, ctx.metrics.date_column)
|
| 56 |
+
before = compute(df, metric, segment, prev, ctx.metrics.date_column)
|
| 57 |
+
if now is None or before is None:
|
| 58 |
+
return math.nan # a NaN change fails the minimum-change gate
|
| 59 |
+
return abs(now - before)
|
src/weekly_analyst/systems/draft.py
CHANGED
|
@@ -38,8 +38,8 @@ def _direction(df, ctx: RunContext, item) -> str:
|
|
| 38 |
"""up, down or unchanged for the item's metric against the prior week: words only, no numbers for the model."""
|
| 39 |
metric = metric_map(ctx)[item.metric]
|
| 40 |
segment = dict(item.segment)
|
| 41 |
-
now = compute(df, metric, segment, item.period)
|
| 42 |
-
before = compute(df, metric, segment, prev_period(item.period))
|
| 43 |
if now is None or before is None:
|
| 44 |
return "unknown"
|
| 45 |
return "up" if now > before else "down" if now < before else "unchanged"
|
|
|
|
| 38 |
"""up, down or unchanged for the item's metric against the prior week: words only, no numbers for the model."""
|
| 39 |
metric = metric_map(ctx)[item.metric]
|
| 40 |
segment = dict(item.segment)
|
| 41 |
+
now = compute(df, metric, segment, item.period, ctx.metrics.date_column)
|
| 42 |
+
before = compute(df, metric, segment, prev_period(item.period), ctx.metrics.date_column)
|
| 43 |
if now is None or before is None:
|
| 44 |
return "unknown"
|
| 45 |
return "up" if now > before else "down" if now < before else "unchanged"
|
src/weekly_analyst/systems/report_findings.py
CHANGED
|
@@ -23,7 +23,7 @@ def finding_for(ctx: RunContext, df: pl.DataFrame, item: Item, text) -> tuple[Fi
|
|
| 23 |
shown = public_item(item) # what the report prints
|
| 24 |
label = segment_label(dict(shown.segment))
|
| 25 |
key = plan_item_key(shown)
|
| 26 |
-
level = compute(df, metric, segment, item.period)
|
| 27 |
if level is None:
|
| 28 |
raise Halt(f"{key}: too few rows to report")
|
| 29 |
points = series(ctx, df, metric, segment)
|
|
@@ -66,7 +66,7 @@ def attribution_rows(df: pl.DataFrame, date_col: str, metric, col: str, period:
|
|
| 66 |
rows = []
|
| 67 |
for r in attribute_change(week(df, date_col, prev), week(df, date_col, period), metric, col).iter_rows(named=True):
|
| 68 |
segment = {col: str(r["segment"])}
|
| 69 |
-
now, before = compute(df, metric, segment, period), compute(df, metric, segment, prev)
|
| 70 |
if now is not None and before is not None:
|
| 71 |
rows.append(AttributionRow(segment=sanitize.label(str(r["segment"])), change=ShownNumber(change_text(now - before), metric.name, segment, period)))
|
| 72 |
return rows
|
|
@@ -76,7 +76,7 @@ def series(ctx: RunContext, df: pl.DataFrame, metric, segment: dict) -> list[tup
|
|
| 76 |
periods = df.select(period_expr(ctx.metrics.date_column).unique().sort()).to_series().to_list()
|
| 77 |
points = []
|
| 78 |
for period in periods[-SAMPLE_WEEKS:]:
|
| 79 |
-
value = compute(df, metric, segment, period)
|
| 80 |
if value is not None:
|
| 81 |
points.append((period, "W" + period.split("-W", 1)[1], value))
|
| 82 |
return points
|
|
|
|
| 23 |
shown = public_item(item) # what the report prints
|
| 24 |
label = segment_label(dict(shown.segment))
|
| 25 |
key = plan_item_key(shown)
|
| 26 |
+
level = compute(df, metric, segment, item.period, ctx.metrics.date_column)
|
| 27 |
if level is None:
|
| 28 |
raise Halt(f"{key}: too few rows to report")
|
| 29 |
points = series(ctx, df, metric, segment)
|
|
|
|
| 66 |
rows = []
|
| 67 |
for r in attribute_change(week(df, date_col, prev), week(df, date_col, period), metric, col).iter_rows(named=True):
|
| 68 |
segment = {col: str(r["segment"])}
|
| 69 |
+
now, before = compute(df, metric, segment, period, date_col), compute(df, metric, segment, prev, date_col)
|
| 70 |
if now is not None and before is not None:
|
| 71 |
rows.append(AttributionRow(segment=sanitize.label(str(r["segment"])), change=ShownNumber(change_text(now - before), metric.name, segment, period)))
|
| 72 |
return rows
|
|
|
|
| 76 |
periods = df.select(period_expr(ctx.metrics.date_column).unique().sort()).to_series().to_list()
|
| 77 |
points = []
|
| 78 |
for period in periods[-SAMPLE_WEEKS:]:
|
| 79 |
+
value = compute(df, metric, segment, period, ctx.metrics.date_column)
|
| 80 |
if value is not None:
|
| 81 |
points.append((period, "W" + period.split("-W", 1)[1], value))
|
| 82 |
return points
|
src/weekly_analyst/systems/report_model.py
CHANGED
|
@@ -1,94 +1,94 @@
|
|
| 1 |
-
"""Report model: the input the fixed PDF template renders, built from the draft and the run's components (spec 6.8)."""
|
| 2 |
-
|
| 3 |
-
import polars as pl
|
| 4 |
-
|
| 5 |
-
from weekly_analyst.analysis.draft import DraftFields
|
| 6 |
-
from weekly_analyst.analysis.plan import _item_key as plan_item_key
|
| 7 |
-
from weekly_analyst.core.components import EvidenceC, SourceC, ValidationC
|
| 8 |
-
from weekly_analyst.core.context import RunContext
|
| 9 |
-
from weekly_analyst.core.entity import Entity
|
| 10 |
-
from weekly_analyst.core.items import Item
|
| 11 |
-
from weekly_analyst.core.world import World
|
| 12 |
-
from weekly_analyst.data.metrics_calc import compute
|
| 13 |
-
from weekly_analyst.report.model import DataTable, Finding, Kpi, MetricDefinition, ReportModel, ShownNumber
|
| 14 |
-
from weekly_analyst.systems.common import frame, metric_map, prev_period, public_item, raw_items
|
| 15 |
-
from weekly_analyst.systems.report_findings import change_text, finding_for, level_text
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def build_report(world: World, entity: Entity, ctx: RunContext, draft: DraftFields) -> ReportModel:
|
| 19 |
-
df = frame(world, entity, ctx)
|
| 20 |
-
validation = world.get(entity, ValidationC)
|
| 21 |
-
period = validation.period
|
| 22 |
-
prev = prev_period(period)
|
| 23 |
-
items = raw_items(world, entity)
|
| 24 |
-
texts = {f.item_key: f for f in draft.findings}
|
| 25 |
-
findings: list[Finding] = []
|
| 26 |
-
tables: list[DataTable] = []
|
| 27 |
-
for item in items:
|
| 28 |
-
found, table = finding_for(ctx, df, item, texts[plan_item_key(public_item(item))])
|
| 29 |
-
findings.append(found)
|
| 30 |
-
tables.append(table)
|
| 31 |
-
snapshot_date = df[ctx.metrics.date_column].max().isoformat()
|
| 32 |
-
risks = list(draft.caveats) + [
|
| 33 |
-
f"{f['kind'].replace('_', ' ')}: {f['detail']}" for f in validation.findings
|
| 34 |
-
]
|
| 35 |
-
failed = [r for r in world.get(entity, EvidenceC).results if "error" in r]
|
| 36 |
-
if failed:
|
| 37 |
-
risks.append(f"{len(failed)} planned follow-up check(s) could not be run and are not reflected in this report")
|
| 38 |
-
if validation.cold_start:
|
| 39 |
-
risks.append(
|
| 40 |
-
f"insufficient history: fewer than {ctx.settings.cold_start_periods} prior weeks, "
|
| 41 |
-
"so no change alerts were sent"
|
| 42 |
-
)
|
| 43 |
-
next_checks = [f"Check the source rows behind {plan_item_key(public_item(item))}" for item in items]
|
| 44 |
-
if any(f["kind"] == "schema_change" for f in validation.findings):
|
| 45 |
-
next_checks.append("Confirm the schema change with the owner of the source file")
|
| 46 |
-
return ReportModel(
|
| 47 |
-
report_name="Weekly analysis",
|
| 48 |
-
reporting_period=period,
|
| 49 |
-
prior_period=prev,
|
| 50 |
-
data_as_of=snapshot_date,
|
| 51 |
-
prepared_for=", ".join(ctx.recipients) or "Recipients not set",
|
| 52 |
-
report_version="1",
|
| 53 |
-
run_id=entity.run_id,
|
| 54 |
-
snapshot_date=snapshot_date,
|
| 55 |
-
snapshot_hash=world.get(entity, SourceC).snapshot_hash,
|
| 56 |
-
headline=draft.headline,
|
| 57 |
-
recommended_action=draft.recommended_action,
|
| 58 |
-
kpis=_kpis(ctx, df, period, prev, items),
|
| 59 |
-
findings=findings,
|
| 60 |
-
risks_caveats=risks,
|
| 61 |
-
next_checks=next_checks,
|
| 62 |
-
metric_definitions=[
|
| 63 |
-
MetricDefinition(
|
| 64 |
-
name=m.name,
|
| 65 |
-
definition=m.definition,
|
| 66 |
-
owner=m.owner,
|
| 67 |
-
includes=m.inclusions,
|
| 68 |
-
excludes=m.exclusions,
|
| 69 |
-
version=str(m.version),
|
| 70 |
-
checksum=ctx.metrics.checksum,
|
| 71 |
-
)
|
| 72 |
-
for m in ctx.metrics.metrics
|
| 73 |
-
],
|
| 74 |
-
data_tables=tables,
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _kpis(ctx: RunContext, df: pl.DataFrame, period: str, prev: str, items: list[Item]) -> list[Kpi]:
|
| 79 |
-
kpis = []
|
| 80 |
-
by_name = metric_map(ctx)
|
| 81 |
-
for name in dict.fromkeys(item.metric for item in items):
|
| 82 |
-
metric = by_name[name]
|
| 83 |
-
now = compute(df, metric, None, period)
|
| 84 |
-
before = compute(df, metric, None, prev)
|
| 85 |
-
if now is None or before is None:
|
| 86 |
-
continue
|
| 87 |
-
kpis.append(
|
| 88 |
-
Kpi(
|
| 89 |
-
label=metric.name,
|
| 90 |
-
value=ShownNumber(level_text(now), metric.name, None, period),
|
| 91 |
-
vs_prior=ShownNumber(change_text(now - before), metric.name, None, period),
|
| 92 |
-
)
|
| 93 |
-
)
|
| 94 |
-
return kpis
|
|
|
|
| 1 |
+
"""Report model: the input the fixed PDF template renders, built from the draft and the run's components (spec 6.8)."""
|
| 2 |
+
|
| 3 |
+
import polars as pl
|
| 4 |
+
|
| 5 |
+
from weekly_analyst.analysis.draft import DraftFields
|
| 6 |
+
from weekly_analyst.analysis.plan import _item_key as plan_item_key
|
| 7 |
+
from weekly_analyst.core.components import EvidenceC, SourceC, ValidationC
|
| 8 |
+
from weekly_analyst.core.context import RunContext
|
| 9 |
+
from weekly_analyst.core.entity import Entity
|
| 10 |
+
from weekly_analyst.core.items import Item
|
| 11 |
+
from weekly_analyst.core.world import World
|
| 12 |
+
from weekly_analyst.data.metrics_calc import compute
|
| 13 |
+
from weekly_analyst.report.model import DataTable, Finding, Kpi, MetricDefinition, ReportModel, ShownNumber
|
| 14 |
+
from weekly_analyst.systems.common import frame, metric_map, prev_period, public_item, raw_items
|
| 15 |
+
from weekly_analyst.systems.report_findings import change_text, finding_for, level_text
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def build_report(world: World, entity: Entity, ctx: RunContext, draft: DraftFields) -> ReportModel:
|
| 19 |
+
df = frame(world, entity, ctx)
|
| 20 |
+
validation = world.get(entity, ValidationC)
|
| 21 |
+
period = validation.period
|
| 22 |
+
prev = prev_period(period)
|
| 23 |
+
items = raw_items(world, entity)
|
| 24 |
+
texts = {f.item_key: f for f in draft.findings}
|
| 25 |
+
findings: list[Finding] = []
|
| 26 |
+
tables: list[DataTable] = []
|
| 27 |
+
for item in items:
|
| 28 |
+
found, table = finding_for(ctx, df, item, texts[plan_item_key(public_item(item))])
|
| 29 |
+
findings.append(found)
|
| 30 |
+
tables.append(table)
|
| 31 |
+
snapshot_date = df[ctx.metrics.date_column].max().isoformat()
|
| 32 |
+
risks = list(draft.caveats) + [
|
| 33 |
+
f"{f['kind'].replace('_', ' ')}: {f['detail']}" for f in validation.findings
|
| 34 |
+
]
|
| 35 |
+
failed = [r for r in world.get(entity, EvidenceC).results if "error" in r]
|
| 36 |
+
if failed:
|
| 37 |
+
risks.append(f"{len(failed)} planned follow-up check(s) could not be run and are not reflected in this report")
|
| 38 |
+
if validation.cold_start:
|
| 39 |
+
risks.append(
|
| 40 |
+
f"insufficient history: fewer than {ctx.settings.cold_start_periods} prior weeks, "
|
| 41 |
+
"so no change alerts were sent"
|
| 42 |
+
)
|
| 43 |
+
next_checks = [f"Check the source rows behind {plan_item_key(public_item(item))}" for item in items]
|
| 44 |
+
if any(f["kind"] == "schema_change" for f in validation.findings):
|
| 45 |
+
next_checks.append("Confirm the schema change with the owner of the source file")
|
| 46 |
+
return ReportModel(
|
| 47 |
+
report_name="Weekly analysis",
|
| 48 |
+
reporting_period=period,
|
| 49 |
+
prior_period=prev,
|
| 50 |
+
data_as_of=snapshot_date,
|
| 51 |
+
prepared_for=", ".join(ctx.recipients) or "Recipients not set",
|
| 52 |
+
report_version="1",
|
| 53 |
+
run_id=entity.run_id,
|
| 54 |
+
snapshot_date=snapshot_date,
|
| 55 |
+
snapshot_hash=world.get(entity, SourceC).snapshot_hash,
|
| 56 |
+
headline=draft.headline,
|
| 57 |
+
recommended_action=draft.recommended_action,
|
| 58 |
+
kpis=_kpis(ctx, df, period, prev, items),
|
| 59 |
+
findings=findings,
|
| 60 |
+
risks_caveats=risks,
|
| 61 |
+
next_checks=next_checks,
|
| 62 |
+
metric_definitions=[
|
| 63 |
+
MetricDefinition(
|
| 64 |
+
name=m.name,
|
| 65 |
+
definition=m.definition,
|
| 66 |
+
owner=m.owner,
|
| 67 |
+
includes=m.inclusions,
|
| 68 |
+
excludes=m.exclusions,
|
| 69 |
+
version=str(m.version),
|
| 70 |
+
checksum=ctx.metrics.checksum,
|
| 71 |
+
)
|
| 72 |
+
for m in ctx.metrics.metrics
|
| 73 |
+
],
|
| 74 |
+
data_tables=tables,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _kpis(ctx: RunContext, df: pl.DataFrame, period: str, prev: str, items: list[Item]) -> list[Kpi]:
|
| 79 |
+
kpis = []
|
| 80 |
+
by_name = metric_map(ctx)
|
| 81 |
+
for name in dict.fromkeys(item.metric for item in items):
|
| 82 |
+
metric = by_name[name]
|
| 83 |
+
now = compute(df, metric, None, period, ctx.metrics.date_column)
|
| 84 |
+
before = compute(df, metric, None, prev, ctx.metrics.date_column)
|
| 85 |
+
if now is None or before is None:
|
| 86 |
+
continue
|
| 87 |
+
kpis.append(
|
| 88 |
+
Kpi(
|
| 89 |
+
label=metric.name,
|
| 90 |
+
value=ShownNumber(level_text(now), metric.name, None, period),
|
| 91 |
+
vs_prior=ShownNumber(change_text(now - before), metric.name, None, period),
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
return kpis
|