307 Commits

Author SHA1 Message Date
0bafbcb21f Harden PromptKit upgrade integration 2026-08-25 23:46:21 +00:00
7005688b80 Complete PromptKit upgrade audit 2026-08-25 19:56:23 +00:00
4cd5f505df Capture provider failures in secure debug artifacts 2026-08-25 19:55:09 +00:00
b3b23fb381 Migrate comparison bundles to v2 2026-08-25 19:52:41 +00:00
0c9cd6d5fb Enable PromptKit repair attempts 2026-08-25 19:49:57 +00:00
ce79ea92c5 Carry repair provenance through application workflows 2026-08-25 19:46:56 +00:00
20107b0dfd Map PromptKit repair results and generation errors 2026-08-25 19:44:15 +00:00
1b38f66240 Extend prompt execution contract 2026-08-25 19:40:57 +00:00
b92f83e49b Adopt PromptKit profile inheritance 2026-08-25 19:38:16 +00:00
24a8579cee Upgrade PromptKit to v0.8.0 2026-08-25 19:32:22 +00:00
515cdada04 Plan the PromptKit v0.8.0 upgrade 2026-08-25 19:28:07 +00:00
53aa0b0a55 Merge remote-tracking branch 'origin/main' 2026-08-13 13:52:21 +00:00
fc8ddada9a Close out the repository audit 2026-08-13 13:52:16 +00:00
13b06039b1 Retire completed audit records 2026-08-13 04:32:28 +00:00
b9080466a2 Document audit record retirement checklist 2026-08-13 04:31:31 +00:00
142f2f92e7 Retire completed comparison roadmaps 2026-08-13 04:28:03 +00:00
88fde0df7f Reconcile internal implementation guides 2026-08-13 04:25:40 +00:00
b985c5faac Consolidate generated text test ownership 2026-08-13 04:22:33 +00:00
d6829af32b Remove unused alert envelope retention 2026-08-13 04:17:05 +00:00
cd7b9aef2b Retire unused module and forecast compatibility exports 2026-08-13 04:15:42 +00:00
c3ebf06bd5 Retire unused weather bundle persistence helpers 2026-08-13 04:12:38 +00:00
7884b9a6c3 Retire dormant prompt compatibility APIs 2026-08-13 04:10:45 +00:00
17468cb8dd Consolidate CLI report date policy 2026-08-13 04:06:37 +00:00
71b7a74d3d Validate fact requirements through briefing vocabulary 2026-08-13 04:02:21 +00:00
2c4c0bbd90 Define briefing fact requirement vocabulary 2026-08-13 03:59:09 +00:00
965f16d7a4 Consolidate Distributor template parsing 2026-08-13 03:55:03 +00:00
fb891fad07 Reduce comparison bundle recognition reads 2026-08-13 03:52:20 +00:00
0516ee148d Fetch independent weather sources concurrently 2026-08-13 03:45:55 +00:00
e6450138c2 Reuse weather API readiness response 2026-08-13 03:38:49 +00:00
57aa27c9de Clean up comparison test workers 2026-08-13 03:34:08 +00:00
166c4ce53b Remove production waits from deterministic tests 2026-08-13 03:32:32 +00:00
78fc461a75 Make tests independent of host state 2026-08-13 03:29:43 +00:00
5e492cf1fb Preserve comparison failures during cancellation 2026-08-13 03:26:11 +00:00
79cba800ee Report comparison cleanup recovery state 2026-08-13 03:18:55 +00:00
302f5aba2d Honor cancellation during comparison replacement 2026-08-13 03:13:17 +00:00
0314a302f1 Preflight comparison transaction names 2026-08-13 03:09:00 +00:00
707db5394c Validate canonical comparison manifests 2026-08-13 03:06:28 +00:00
70cad789ea Preserve batch cancellation outcomes 2026-08-13 03:02:59 +00:00
4b748c2e53 Bound Distributor response diagnostics 2026-08-13 02:55:25 +00:00
0b57d99a97 Validate Distributor endpoints before publication 2026-08-13 02:44:21 +00:00
04b8358965 Harden report output publication 2026-08-13 02:40:29 +00:00
f4e3a6f26c Preflight report output filenames 2026-08-13 02:33:32 +00:00
44ee389334 Reconcile prompt execution provenance 2026-08-13 02:30:40 +00:00
ef2634c2cb Validate generated text catalog before collection 2026-08-13 02:22:09 +00:00
a18d5134c7 Keep generated prose out of Markdown structure 2026-08-13 02:17:11 +00:00
2bd921f247 Validate render identity and daypart fallbacks 2026-08-13 02:11:25 +00:00
360c665a3e Route report projections through prepared identity 2026-08-13 02:00:18 +00:00
e2dd8d0e29 Establish prepared metadata identity 2026-08-13 01:52:11 +00:00
e520ffb13b Bound generated text content and diagnostics 2026-08-13 01:47:47 +00:00
f8beed04cf Enforce generated text report identity 2026-08-13 01:36:56 +00:00
44af91cadf Secure prompt debug filesystem writes 2026-08-13 01:28:49 +00:00
a38d291f63 Harden prompt debug redaction 2026-08-13 01:19:35 +00:00
27849813db Refresh SPC outlook definition sources 2026-08-13 01:12:47 +00:00
41b86109e3 Correct daypart identity and display handling 2026-08-13 01:08:20 +00:00
13829cc65c Centralize daypart key canonicalization 2026-08-13 01:00:54 +00:00
daf0c7efd7 Correct derived briefing weather semantics 2026-08-13 00:58:04 +00:00
9b4e53702b Normalize briefing module options and weather stories 2026-08-13 00:53:31 +00:00
730929e2ed Validate precipitation probabilities and ice wording 2026-08-13 00:48:40 +00:00
8e49ba88c7 Preserve metric forecast units and overnight alerts 2026-08-13 00:45:08 +00:00
8fafacf921 Preserve civil daypart clocks across DST 2026-08-13 00:39:46 +00:00
8bb7307f22 Validate hourly forecast time bounds 2026-08-13 00:37:43 +00:00
c515529b3a Bound Weather API response diagnostics 2026-08-13 00:35:38 +00:00
3c1ebab289 Validate Weather API endpoints and retries 2026-08-13 00:32:22 +00:00
2d956f7315 Strengthen CLI action preflight and coverage 2026-08-13 00:28:31 +00:00
4d5a1d9709 Cancel actions on process interrupts 2026-08-13 00:24:16 +00:00
1d3ea64541 Require nonblank notification identities 2026-08-13 00:21:20 +00:00
706086e3de Apply configuration secrets atomically 2026-08-13 00:18:20 +00:00
26a681e0b1 Validate configuration source keys and overrides 2026-08-13 00:15:26 +00:00
5139c1a586 Correct curated prompt package contracts 2026-08-13 00:11:31 +00:00
0b869af75e Add an implementation plan to address the audit findings 2026-08-13 00:00:59 +00:00
c3eeb298f0 Close the audit and add the remediation roadmap 2026-08-12 18:09:28 +00:00
6945306a2f Consolidate and triage the audit findings 2026-08-12 18:01:17 +00:00
4f52555389 Complete the Stage 24 documentation audit 2026-08-12 17:52:46 +00:00
fa19452dec Complete the Stage 23 refactoring audit 2026-08-12 17:43:35 +00:00
91e7e5f321 Complete the Stage 22 efficiency audit 2026-08-12 17:33:30 +00:00
4bb3913276 Complete the Stage 21 test durability audit 2026-08-12 17:25:42 +00:00
ab571cd8ab Complete the Stage 20 test risk audit 2026-08-12 17:18:36 +00:00
798e6f11c5 Complete the Stage 19 test hygiene audit 2026-08-12 17:13:57 +00:00
c49c50bc8d Complete the Stage 18 comparison execution audit 2026-08-12 17:05:03 +00:00
d328a1daa6 Record Stage 17 comparison publication audit 2026-08-12 16:58:42 +00:00
7ae3820e12 Record Stage 16 batch and Distributor audit 2026-08-12 16:52:02 +00:00
a4ef76f17a Record Stage 15 output publication audit 2026-08-12 16:45:47 +00:00
5ed1e264fc Record Stage 14 application preparation audit 2026-08-12 16:39:22 +00:00
c025afcd1a Record the Stage 13 rendering audit 2026-08-12 16:31:34 +00:00
2b06541ef8 Record the Stage 12 generated text audit 2026-08-12 16:24:13 +00:00
d92ff0ef48 Record Stage 11 Promptkit security audit 2026-08-12 16:17:19 +00:00
880ad710ae Record Stage 10 prompt boundary audit 2026-08-12 16:11:55 +00:00
edde330390 Complete the Stage 9 briefing audit 2026-08-12 16:03:43 +00:00
ae52606772 Record Stage 8 module audit findings 2026-08-12 15:56:41 +00:00
8a323d5574 Record Stage 7 derivation audit findings 2026-08-12 15:52:05 +00:00
cfb64ded34 Complete the Stage 6 weather data audit 2026-08-12 15:45:10 +00:00
5ed448df11 Complete the Stage 5 CLI audit 2026-08-12 15:35:13 +00:00
725c1420dd Complete configuration and secrets audit 2026-08-12 15:24:20 +00:00
e5250bd6cb Complete report identity and time audit 2026-08-12 15:13:57 +00:00
00fe0c3e96 Record the architecture audit findings 2026-08-12 15:08:34 +00:00
6d2c097657 Establish the repository audit baseline 2026-08-12 15:03:01 +00:00
e7c7262404 Add audit workflow plan 2026-08-12 14:52:26 +00:00
151c536cb9 Add comparison diagnostics to the future roadmap 2026-08-12 14:46:17 +00:00
2b1fb26e7d Revise the daily report prompt text to include further detail regarding geographic scope 2026-08-05 09:31:30 -05:00
3c7383e2ce Revise the daily report prompt text 2026-08-03 08:29:39 -05:00
eed47b4f68 Finish profile comparison follow-up fixes 2026-08-02 14:24:24 +00:00
6c185b8d0e Finalize profile comparison implementation 2026-08-02 13:35:31 +00:00
faf547e4a8 Complete comparison failure summaries 2026-08-02 13:28:16 +00:00
acb476a142 Report committed comparison cleanup failures 2026-08-02 13:23:18 +00:00
606b4423f1 Authorize comparison replacement at commit time 2026-08-02 13:17:50 +00:00
1716702c99 Make prompt debug creation concurrency safe 2026-08-02 13:11:41 +00:00
e0229d9c90 Document profile comparison workflow 2026-08-02 06:10:15 +00:00
ccf6b66880 Complete comparison command output 2026-08-02 06:02:24 +00:00
b489c56a48 Add comparison command request parsing 2026-08-02 05:56:31 +00:00
d39e42de30 Assemble comparison application workflow 2026-08-02 05:50:29 +00:00
d642791c10 Add concurrent comparison profile execution 2026-08-02 05:39:51 +00:00
236e3d16c4 Separate report execution from publication 2026-08-02 05:35:59 +00:00
4fa873983d Extract immutable report preparation 2026-08-02 05:30:52 +00:00
de1ae896b3 Add comparison profile preflight 2026-08-02 05:25:03 +00:00
6173e50d25 Publish comparison bundles safely 2026-08-02 05:21:04 +00:00
3bca2f41f7 Define comparison artifact contracts 2026-08-02 05:12:36 +00:00
af9cb0c0dc Document Weatherreporter v0.11.0
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-02 02:09:47 +00:00
20c82776dc Finish output directory follow-up work 2026-08-02 02:08:30 +00:00
f364ce773d Complete configurable output directory implementation 2026-08-02 01:42:58 +00:00
0dc6a06cd3 Document configured output directories 2026-08-02 01:41:03 +00:00
2af6a5cfd2 Apply configured output directories 2026-08-02 01:38:06 +00:00
0c4c575eea Add output directory configuration contract 2026-08-02 01:34:23 +00:00
114f7f5f85 Make release validation portable
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2026-08-02 00:36:54 +00:00
328c7a5693 Document Weatherreporter v0.10.0
Some checks failed
ci/woodpecker/tag/release Pipeline failed
2026-08-02 00:29:36 +00:00
fe176a2abc Finish stateless execution cleanup 2026-08-02 00:15:41 +00:00
ab9218b124 Complete stateless execution remediation 2026-08-01 22:01:28 +00:00
8d6ab0eb56 Remove per-report batch notification state 2026-08-01 21:56:25 +00:00
76cd399c76 Keep batch notification failures out of report counts 2026-08-01 21:54:53 +00:00
bf1746a756 Preflight batch output destinations 2026-08-01 21:51:42 +00:00
28bdc04fba Prevent output publication after cancellation 2026-08-01 21:49:18 +00:00
b67fae886e Complete stateless execution exit gate 2026-08-01 20:18:54 +00:00
71a2eae87b Reconcile internal stateless documentation 2026-08-01 20:16:47 +00:00
bd34ec57f8 Document stateless output operations 2026-08-01 20:11:57 +00:00
97215ddb9b Expand stateless workflow test coverage 2026-08-01 20:06:40 +00:00
dd7881acfb Remove workspace state subsystem 2026-08-01 20:00:54 +00:00
ece31567b8 Remove historical inspection commands 2026-08-01 19:58:11 +00:00
7ffc3dc603 Run report generation without workspace state 2026-08-01 19:52:22 +00:00
4bdba6f2b7 Stop persisting notification receipts 2026-08-01 19:40:51 +00:00
b184ca7cbd Move prompt debug capture out of state 2026-08-01 19:35:12 +00:00
62a12dd661 Write reports to operator-selected outputs 2026-08-01 19:33:12 +00:00
ac8d618111 Remove dormant forecast comparison policy 2026-08-01 19:24:34 +00:00
5ddd3ee19c Remove recent changes from prompt execution 2026-08-01 19:22:11 +00:00
8be9b020d4 Record stateless execution architecture decision 2026-08-01 19:18:45 +00:00
7f5a9c0357 Plan the stateless execution refactor 2026-08-01 19:16:44 +00:00
7d591487e4 Clean up roadmap and troubleshooting documentation 2026-08-01 18:16:01 +00:00
1250247986 Correct profile test boundaries and fallback coverage 2026-08-01 17:24:21 +00:00
117c5336ba Finalize domain prompt profile roadmap 2026-08-01 14:27:39 +00:00
c5ec4f83b2 Document logical prompt profile configuration 2026-08-01 14:25:09 +00:00
39c097a710 Verify profile selection in application workflows 2026-08-01 14:20:57 +00:00
993120a9f2 Adopt logical prompt profile defaults 2026-08-01 14:16:55 +00:00
c20e285d5f Wire embedded profile fallbacks 2026-08-01 14:14:36 +00:00
acbe22dcad Add embedded weather profile catalog 2026-08-01 14:13:11 +00:00
cc97ae186c Plan domain profiles and ephemeral state 2026-08-01 14:07:23 +00:00
51c35f7c22 Upgrade Promptkit to version 0.5.0 2026-08-01 13:38:18 +00:00
f014a078ee Plan domain-specific prompt profiles 2026-08-01 02:15:45 +00:00
8c19ad763b Require precipitation timing in generated text 2026-08-01 01:25:57 +00:00
2dbba36bf0 Document Weatherreporter v0.9.0 2026-07-31 19:22:43 +00:00
f302581722 Document Weatherreporter release procedure 2026-07-31 19:17:24 +00:00
cf82633ab7 Harden release publication plumbing 2026-07-31 19:13:45 +00:00
8d8cdbf3c5 Finalize Promptkit migration documentation 2026-07-31 17:27:04 +00:00
a206979307 Restore CLI and inspection coverage 2026-07-31 17:22:19 +00:00
a6515c0e56 Restore batch workflow coverage 2026-07-31 17:15:01 +00:00
41df5058ba Simplify prompt report orchestration 2026-07-31 17:08:33 +00:00
e1bc174ea9 Restore single-report workflow coverage 2026-07-31 17:02:31 +00:00
34c395d7e5 Track completed execution artifact paths 2026-07-31 16:51:12 +00:00
870b54a4a0 Harden durable prompt state contracts 2026-07-31 16:45:26 +00:00
25782447eb Correct artifact path bookkeeping 2026-07-31 16:37:00 +00:00
b96f40e5ca Document Promptkit report generation 2026-07-31 05:03:02 +00:00
2c68d0a85f Complete Promptkit batch execution cutover 2026-07-31 04:56:48 +00:00
a6d11c01e8 Add Promptkit debug capture for generated reports 2026-07-31 04:48:16 +00:00
06b26d5e88 Use Promptkit for single report generation 2026-07-31 04:41:02 +00:00
9a17a8de93 Add Promptkit configuration and inspection seams 2026-07-31 04:27:36 +00:00
6064af2295 Add secure prompt debug storage 2026-07-31 04:20:30 +00:00
a52a6ed22a Add durable prompt execution state records 2026-07-31 04:13:00 +00:00
b0b703eab4 Add Promptkit execution adapter 2026-07-31 04:04:46 +00:00
e4e824ed41 Define prompt execution contract 2026-07-31 03:58:18 +00:00
d5fcbfd20c Prepare reports for Promptkit migration 2026-07-31 03:53:47 +00:00
2e0fb65a8b Add scriptorium prompts and schemas to the temporary roadmap 2026-07-30 21:12:52 -05:00
5e96790d85 Correct documentation refresh findings 2026-07-31 01:59:13 +00:00
35f4f82e94 Clarify future roadmap statuses 2026-07-31 01:39:33 +00:00
b605596bcb Refresh report and template internals documentation 2026-07-31 01:36:28 +00:00
9303502b32 Refresh deterministic domain documentation 2026-07-31 01:29:48 +00:00
f9eef80233 Refresh internal state and adapter documentation 2026-07-31 01:26:20 +00:00
c6f8570474 Refresh CLI collection and app internals 2026-07-31 01:21:23 +00:00
1130d807dc Refresh Distributor integration guides 2026-07-31 01:17:37 +00:00
ff2e664c62 Refresh Scriptorium integration guide 2026-07-31 01:14:04 +00:00
2f3558cf33 Refresh Weather API integration guide 2026-07-31 01:11:22 +00:00
154d31c3e8 Refresh report template guide 2026-07-31 01:07:32 +00:00
6b1ff862f3 Refresh troubleshooting guidance 2026-07-31 01:03:58 +00:00
0c27fab384 Refresh README and operations guide 2026-07-31 01:00:21 +00:00
ad3b788f8c Refresh CLI and configuration reference 2026-07-31 00:57:46 +00:00
82acb8dc1a Refresh documentation foundation and repair links 2026-07-31 00:50:48 +00:00
3aaddda676 Add feature roadmap for adoption of the promptkit LLM adapter library 2026-07-30 17:00:32 +00:00
7f989839cd Implement default precision=0 for upstream weatherapi endpoints 2026-07-02 11:39:17 -05:00
27506168f8 Implement warmup and fetch retry in the weatherapi adapter 2026-07-02 11:33:16 -05:00
dc11e08e22 Update the Alert Digest partial template to be more concise 2026-07-02 11:05:31 -05:00
fdddb5f08d Add background definitions for SPC convective outlook risk products 2026-06-21 14:30:08 -05:00
f78186b020 Remove redundant alert text from the data package 2026-06-21 08:38:11 -05:00
8dd604afb4 Update default sections of the Area Forecast Discussion provided to different report types 2026-06-20 20:27:37 -05:00
52bb17c8fa Document CLI output contract 2026-06-20 23:01:14 +00:00
7952e4fb25 Wire CLI action summaries 2026-06-20 22:55:59 +00:00
0281327365 Centralize CLI output helpers 2026-06-20 22:49:51 +00:00
bf76eae301 Add CLI result summaries 2026-06-20 22:47:16 +00:00
0d47662cf9 Add detailed generate result 2026-06-20 22:43:49 +00:00
f4f009b904 Add a feature roadmap and staged implentation plan to harmonize CLI command outputs 2026-06-20 17:39:05 -05:00
3c1b753952 Tighten workspace artifact path handling 2026-06-20 09:09:56 -05:00
bdbab48d10 Document managed workspace artifact layout 2026-06-20 13:37:18 +00:00
16cc4b3f63 Update app workflow path expectations 2026-06-20 13:33:30 +00:00
0ef861ed8f Discover metadata with new workspace filenames 2026-06-20 13:31:22 +00:00
6ae7eb44cf Update managed workspace artifact paths 2026-06-20 13:29:41 +00:00
8f6aa8aa8b Add a feature roadmap and staged implentation plan to refactor the local workspace layout 2026-06-20 08:26:19 -05:00
b8e889ad13 Finalize and close the distributor report path refactor roadmap 2026-06-20 07:41:37 -05:00
15ee4af1a1 Document report-specific distributor paths 2026-06-20 03:00:29 +00:00
4c606eb39f Remove legacy distributor report path config 2026-06-20 02:58:41 +00:00
8d2ac163ae Use report-specific distributor paths 2026-06-20 02:55:06 +00:00
8709b5f4d8 Generalize distributor report path rendering 2026-06-20 02:49:39 +00:00
fd48ebecb8 Add per-report distributor path overrides 2026-06-20 02:46:05 +00:00
021e5dd8b1 Add report distributor path defaults 2026-06-20 02:42:20 +00:00
7adf5e1b08 Add a feature roadmap and implementation plan to refactor configuration for distributor output paths 2026-06-19 21:38:30 -05:00
455cc67d4c Use neutral endpoint in example config 2026-06-17 21:14:20 +00:00
dd3133ee2a Validate batch distributor uploads 2026-06-17 21:12:55 +00:00
b3637cddd6 Document batch distributor uploads 2026-06-17 21:11:28 +00:00
662db5e511 Report batch notifications in CLI output 2026-06-17 21:03:14 +00:00
2ef91cf1b1 Upload batch distributor notifications 2026-06-17 21:00:03 +00:00
1d2f176977 Suppress per-report notifications during batch runs 2026-06-17 20:52:27 +00:00
2b3bcdd4f1 Build batch distributor upload requests 2026-06-17 20:48:38 +00:00
a82f03feb8 Add batch notification app identity types 2026-06-17 20:44:34 +00:00
f1d4e38414 Add batch distributor notification state artifacts 2026-06-17 20:39:22 +00:00
32060bd370 Add batch distributor notification config 2026-06-17 20:36:02 +00:00
133f83f4ce Create a roadmap and implementation plan for batch distributor uploads 2026-06-17 15:31:36 -05:00
42f0e16b02 Fix to ensure unique document IDs when batch reports are generated 2026-06-17 14:39:53 -05:00
a2f0a2fc36 Refresh documentation for current collection behavior 2026-06-17 16:15:23 +00:00
9f552cff6b Validate batch collection migration 2026-06-17 16:12:17 +00:00
b913194fb4 Update batch collection documentation 2026-06-17 16:11:09 +00:00
3eccafad6b Remove static batch report resolution 2026-06-17 16:07:40 +00:00
a9d87bdbaa Reuse collected batch data 2026-06-17 16:04:14 +00:00
6f9255105d Add data-aware batch planning 2026-06-17 15:59:13 +00:00
c04e3c5599 Add daily coverage planning helper 2026-06-17 15:53:12 +00:00
f15315f1b9 Require collected data for report generation 2026-06-17 15:50:04 +00:00
0ef6cd567e Add app collection seam 2026-06-17 15:46:01 +00:00
b308ff4d6b Add canonical weather collection package 2026-06-17 15:42:00 +00:00
5ecbc06c85 Add a feature roadmap and implementation plan to refactor the morning and evening batch reports and add a standalone data collection package 2026-06-17 10:36:22 -05:00
21e97f5d4e Fix date formatting in the SPC alert digest lines 2026-06-16 22:07:36 -05:00
3900b3313b Add SPC Outlook summaries to the alert digest template 2026-06-16 21:42:20 -05:00
5d416cfc4a Implement alert instruction whitespace normalization 2026-06-16 21:19:03 -05:00
6532e8824a Update the alert digest wording 2026-06-16 21:09:17 -05:00
d321492995 Move the Alert Digest into a shared partial template, and add it to the today, tomorrow, and daily reports 2026-06-16 20:53:27 -05:00
b57110e5c8 Update the hourly report template to trim excess whitespace when alert and/or preciptiation sections are omitted 2026-06-16 20:44:28 -05:00
482e83903c Update the hourly report template to remove newlines between hourly forecast report items 2026-06-16 20:39:24 -05:00
21a7748b2c Update upstream weatherapi alert handling 2026-06-16 20:34:01 -05:00
b36e198bfe Update the shared precipitation timing template 2026-06-16 19:20:26 -05:00
f9d6d42b1b Updated precipitation timing language in the shared template 2026-06-16 19:10:11 -05:00
d9ab1e47ec Align documentation with cleanup results 2026-06-16 15:52:55 +00:00
4f755704d9 Document template partials 2026-06-16 15:45:23 +00:00
a13f04fce5 Clean up state artifact writes 2026-06-16 15:37:21 +00:00
1f5b347cd2 Clean up app test setup 2026-06-16 15:30:23 +00:00
3639636813 Clean up CLI test setup 2026-06-16 15:23:03 +00:00
ca27d81163 Share Scriptorium run execution plumbing 2026-06-16 15:14:11 +00:00
d74ba0f259 Unify report module config traversal 2026-06-16 15:08:20 +00:00
121f28fd29 Share day-style render context and template blocks 2026-06-16 15:03:35 +00:00
e3bcecc5c1 Share day-style generated text validation 2026-06-16 14:55:22 +00:00
0884eb0ce5 Added a staged roadmap to implement the small changes and refactors identified by the audit 2026-06-16 09:51:22 -05:00
a27e870522 Audit code quality and deduplication opportunities 2026-06-16 08:25:58 -05:00
d90801cff5 Separate the daily report and tomorrow report definitions 2026-06-16 08:17:25 -05:00
0f63159482 Document curated data package exports 2026-06-15 20:46:11 +00:00
2792933833 Add data package export regression coverage 2026-06-15 20:42:48 +00:00
9261431329 Curate daypart prompt exports 2026-06-15 20:38:56 +00:00
1bfd865333 Curate current and hourly prompt exports 2026-06-15 20:32:12 +00:00
e5af7477af Use exported module values in data packages 2026-06-15 20:27:28 +00:00
92fcbfcc05 Attach prompt export values in module registry 2026-06-15 20:25:12 +00:00
ff6aade42c Add runtime prompt values to module outputs 2026-06-15 20:23:02 +00:00
4fac69c9f0 Confirm daily documentation updates 2026-06-15 20:19:31 +00:00
90ab6973e6 Confirm legacy daily report cleanup 2026-06-15 20:19:31 +00:00
90a502f50e Confirm daily CLI workflow integration 2026-06-15 20:19:31 +00:00
273f462e09 Confirm daily report registry cutover 2026-06-15 20:19:31 +00:00
5361d5647b Confirm daily render context 2026-06-15 20:19:31 +00:00
d2e90da148 Confirm daily generated text assets 2026-06-15 20:19:31 +00:00
047ce32ac6 Confirm daily planning module implementation 2026-06-15 20:19:31 +00:00
5896168a93 Add feature roadmap and implementation plan to clean up and rationalize the fields provided to the data package 2026-06-15 13:03:31 -05:00
fe9c40741a Validate daily report cutover 2026-06-15 16:56:11 +00:00
88004a1827 Document daily report operations and templates 2026-06-15 16:54:41 +00:00
a515b7e7d9 Remove legacy daily report references 2026-06-15 16:49:37 +00:00
696454cf34 Require explicit dates for daily generation 2026-06-15 16:46:10 +00:00
8c97788682 Replace legacy daily report with generated text daily report 2026-06-15 16:43:06 +00:00
5203440ba0 Add daily render context 2026-06-15 16:29:18 +00:00
3d452a120a Add daily generated text assets 2026-06-15 16:23:13 +00:00
4eece7cc8a Add daily planning module 2026-06-15 16:17:22 +00:00
d0d0b698f9 Revise the feature roadmap for consistency with the implementation plan 2026-06-15 11:11:36 -05:00
4c4b01f265 Add a feature roadmap and implementation plan for a new daily report 2026-06-15 11:07:23 -05:00
58fe794227 Update the today report template 2026-06-15 11:01:03 -05:00
63dfc0b55a Validate Today report implementation 2026-06-15 15:05:23 +00:00
1ddc33eb17 Document Today report workflow 2026-06-15 15:04:14 +00:00
4a0238909b Add Today generate command workflow 2026-06-15 14:58:36 +00:00
3d5f71e72d Add Today report to morning batch 2026-06-15 14:54:37 +00:00
8ff5c44324 Add Today generated text assets 2026-06-15 14:45:49 +00:00
4e704e4f51 Add Today planning module scaffold 2026-06-15 14:37:08 +00:00
7b4c73d1e6 Add staged implementation plan for the new today report type 2026-06-15 08:48:04 -05:00
7efd8b5855 Add today report roadmap 2026-06-15 13:07:40 +00:00
7cbc59d8a7 Clarify future roadmap documentation 2026-06-15 13:00:59 +00:00
67b30dbad6 Remove completed roadmap cleanup plans 2026-06-15 12:52:18 +00:00
cd8d77b37c Reduce CLI test setup duplication 2026-06-15 12:48:36 +00:00
bb79232e3e Simplify Weather API source fetching 2026-06-15 12:44:57 +00:00
b4e0aadbef Clean up generated text helpers 2026-06-15 12:41:45 +00:00
4fe0f40cef Make report module overrides explicit 2026-06-15 12:36:53 +00:00
40b42f4bf3 Centralize report name resolution 2026-06-15 12:33:55 +00:00
e8f1aa5caf Centralize final report finalization 2026-06-15 12:28:54 +00:00
a02af0bce0 Centralize generated text template catalog 2026-06-15 12:24:47 +00:00
252 changed files with 30684 additions and 16718 deletions

3
.gitignore vendored
View File

@@ -1,6 +1,5 @@
# Compiled application binary and testing workspace
# Compiled application binary
/weatherreporter
/workspace
# ---> Go
# If you prefer the allow list template instead of the deny list, see community template:

View File

@@ -2,8 +2,50 @@ when:
- event: tag
steps:
- name: validate-release
image: golang:1.26.5
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
release_note="docs/releases/$version.md"
if ! printf '%s\n' "$version" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release tag: $version" >&2
exit 1
fi
test -s "$release_note"
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
git diff --check
- name: build-release-assets
image: golang:1.25
image: golang:1.26.5
depends_on:
- validate-release
commands:
- |
set -eu
@@ -33,8 +75,11 @@ steps:
build_binary windows amd64 ".exe"
build_binary windows arm64 ".exe"
host_binary="$dist/weatherreporter-$version-$(go env GOOS)-$(go env GOARCH)"
test "$("$host_binary" --version)" = "weatherreporter $version"
- name: publish-release
image: woodpeckerci/plugin-release
image: woodpeckerci/plugin-release:0.3.1
depends_on:
- build-release-assets
settings:
@@ -42,6 +87,8 @@ steps:
from_secret: GITEA_RELEASE_TOKEN
files:
- dist/weatherreporter-*
title: Weatherreporter ${CI_COMMIT_TAG}
note: docs/releases/${CI_COMMIT_TAG}.md
checksum: sha256
checksum-file: SHA256SUMS
checksum-flatten: true

View File

@@ -1,4 +1 @@
Please carefully review the documents in `docs/policy` before making any changes to this repository.
- `architecture.md` provides the canonical high-level architecture policy for this repository.
- `development.md` provides more granular development policy for this repository.
- `documentation.md` provides the canonical documentation policy for this repository.
Please review `docs/development.md` for initial orientation in this repository and follow its task-specific reading guide.

View File

@@ -1,22 +1,31 @@
# weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. It builds JSON module snapshots, passes
YAML prompt data packages to `scriptorium`, and keeps inspectable artifacts
under a local workspace. It can also upload successfully generated managed
Markdown reports to a configured `distributor` HTTP upload endpoint.
Weatherreporter is a Go CLI that turns normalized weather data into
human-facing Markdown reports.
It produces a Markdown report at an operator-owned destination and can upload
the completed output through Distributor. It can also compare explicitly
selected Promptkit profiles against one shared prepared report and publish a
local comparison bundle.
## Quickstart
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate today
```
Configure a Weather API endpoint first; see the
[configuration reference](docs/config.md). The report is written to
`today.md` in the current directory when `output.directory` is not configured.
Set that configuration value for an ordinary publication directory, or use
`--out` for one command. See the [CLI reference](docs/cli.md) and [operations
guide](docs/operations.md) for command and operating details.
## Documentation
- [CLI reference](docs/cli.md)
- [Configuration reference](docs/config.md)
- [Operations guide](docs/operations.md)
- [Troubleshooting](docs/troubleshooting.md)
- [Comparison bundle contract](docs/integrations/comparison-bundle.md)
- [Development guide](docs/development.md)
- [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md)

View File

@@ -3,14 +3,29 @@ package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"gitea.maximumdirect.net/eric/weatherreporter/internal/cli"
)
func main() {
if err := cli.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
if err := runCommand(os.Args[1:], os.Stdout, os.Stderr, cli.Run); err != nil {
fmt.Fprintf(os.Stderr, "weatherreporter: %v\n", err)
os.Exit(1)
}
}
func runCommand(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error) error {
return runCommandWithSignalContext(args, stdout, stderr, runner, signal.NotifyContext)
}
type signalContextFunc func(context.Context, ...os.Signal) (context.Context, context.CancelFunc)
func runCommandWithSignalContext(args []string, stdout, stderr io.Writer, runner func(context.Context, []string, io.Writer, io.Writer) error, signalContext signalContextFunc) error {
ctx, stop := signalContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return runner(ctx, args, stdout, stderr)
}

View File

@@ -0,0 +1,36 @@
package main
import (
"context"
"errors"
"io"
"os"
"syscall"
"testing"
)
func TestRunCommandBuildsCancelableSignalContext(t *testing.T) {
var signals []os.Signal
stopped := false
signalContext := func(parent context.Context, requested ...os.Signal) (context.Context, context.CancelFunc) {
signals = append([]os.Signal(nil), requested...)
ctx, cancel := context.WithCancel(parent)
cancel()
return ctx, func() {
stopped = true
}
}
err := runCommandWithSignalContext(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
return ctx.Err()
}, signalContext)
if !errors.Is(err, context.Canceled) {
t.Fatalf("runCommandWithSignalContext() error = %v, want context cancellation", err)
}
if len(signals) != 2 || signals[0] != os.Interrupt || signals[1] != syscall.SIGTERM {
t.Fatalf("requested signals = %#v, want Interrupt and SIGTERM", signals)
}
if !stopped {
t.Fatal("signal context stop function was not called")
}
}

View File

@@ -0,0 +1,58 @@
//go:build unix
package main
import (
"context"
"errors"
"io"
"os"
"syscall"
"testing"
"time"
)
func TestRunCommandCancelsActionContextOnSignal(t *testing.T) {
for _, tt := range []struct {
name string
signal os.Signal
}{
{name: "Interrupt", signal: os.Interrupt},
{name: "Terminate", signal: syscall.SIGTERM},
} {
t.Run(tt.name, func(t *testing.T) {
started := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- runCommand(nil, io.Discard, io.Discard, func(ctx context.Context, _ []string, _, _ io.Writer) error {
close(started)
<-ctx.Done()
return ctx.Err()
})
}()
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("runner did not receive an action context")
}
process, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess() error = %v", err)
}
if err := process.Signal(tt.signal); err != nil {
t.Fatalf("Signal(%v) error = %v", tt.signal, err)
}
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("runCommand() error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("interrupt did not cancel the action context")
}
})
}
}

View File

@@ -0,0 +1,100 @@
# 0001: Make Weatherreporter Execution Stateless
Status: Accepted
Date: 2026-08-01
## Context
Weather reports are ephemeral products. Forecasts and current conditions change
continuously, so the useful response to an old, failed, or superseded report is
normally a new generation rather than replaying or inspecting a prior run.
The existing run-addressed workspace retains module snapshots, prompt inputs,
execution receipts, generated text, rendered reports, metadata, and
notification receipts. That provenance store accumulates operational history
whose recovery and compatibility obligations are disproportionate to the value
of an ephemeral weather report. It also exists solely to support local Recent
Changes comparison for a rarely used report section.
The temporary roadmap that defined the feature scope and implementation plan
has been retired under the repository's documentation lifecycle. The
[architecture policy](../policy/architecture.md) defines the resulting system
invariants; this decision records their durable rationale.
## Decision
Weatherreporter will operate as a stateless transformation pipeline:
```text
Weather API input
-> deterministic facts and modules
-> Promptkit data package and generated text
-> repository-owned Markdown rendering
-> operator-owned report output
-> optional Distributor upload
```
Ordinary invocations will retain intermediate values only for the active
process and will publish one operator-owned Markdown output atomically. A
failed or canceled generation must not truncate or partially replace an
existing selected output. Single-report Distributor notification follows
successful publication; batch notification follows successful publication of
every planned report.
Weatherreporter will remove local Recent Changes comparison instead of
retaining application state to support it. It will remove run-addressed
workspace artifacts, historical inspection, and backward-compatible workspace
decoding. RunIDs may remain active correlation and Distributor idempotency
values, but will not identify retained application history.
Explicit `--llm-debug-dir` capture remains the sole diagnostic-file exception.
The operator selects and manages that secure location; ordinary execution does
not create an implicit debug location or a general logging store, and debug
capture must continue to exclude credentials.
Any future forecast comparison must use a structured product supplied by the
Weather API rather than local Weatherreporter history. The proposed
[Upstream Forecast Change Product](../roadmap/future.md#upstream-forecast-change-product)
defines the required upstream direction. A future integration must not add a
local snapshot fallback.
## Alternatives Considered
### Retain The Bounded Current-State Design
Retaining a managed workspace with current metadata, receipts, and snapshots
would preserve inspection and local comparison, but keeps an application-owned
history subsystem, artifact compatibility burden, and recovery surface that do
not match the report lifecycle.
### Time-Based Retention
Expiring workspace material after a fixed period reduces accumulation but still
requires retention policy, cleanup behavior, failure handling, and historical
format support. It does not remove the mismatch between retained provenance and
ephemeral report products.
### Bounded Run History
Keeping only a fixed number of prior runs limits storage volume but still makes
Weatherreporter responsible for run selection, comparison, inspection, and
state migration. It also creates arbitrary history gaps without establishing an
authoritative forecast baseline.
## Consequences
The CLI, configuration, prompt-input, workspace, and inspection contracts will
change together. Legacy workspace material will not be migrated, decoded, or
automatically deleted; operators remain responsible for any desired cleanup.
Current action results will carry active identity, selected profile, safe
effective model information, output location, notification result, and safe
errors instead of historical artifact paths. Tests will protect atomic output,
batch and notification ordering, explicit secure debug capture, and the
absence of ordinary application-managed state.
This decision deliberately leaves the Weather API responsible for any future
forecast-history comparison. It avoids a cache, archive, retention engine,
manifest, resume mechanism, or replacement inspection surface in
Weatherreporter.

View File

@@ -1,112 +1,204 @@
# Weatherreporter CLI
`weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects stored artifacts.
`weatherreporter` generates Markdown weather reports, runs report batches, and
compares explicitly selected Promptkit profiles against one prepared report. It
has no command for inspecting prior runs or application-owned state.
## Shortest Useful Command
```sh
weatherreporter generate daily --date 2026-05-29 --out ./daily.md
weatherreporter generate today
```
This loads configuration, fetches weather data, writes managed workspace
artifacts, runs `scriptorium render` as a preflight check, runs
`scriptorium run`, and writes an extra Markdown copy to `./daily.md`. If
distributor notification is enabled in configuration, the command also uploads
the managed Markdown report after final metadata is saved.
The command uses the configured Weather API and atomically writes `today.md`.
With no configured output directory, it writes in the current directory. See
the [configuration reference](config.md) to supply the required Weather API
endpoint and choose an ordinary output directory.
## Commands
## Commands And Usage
```text
weatherreporter --help
weatherreporter generate daily [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate three-day [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate weekend [--config PATH] [--units VALUE] [--tz NAME] [--out PATH]
weatherreporter generate storm [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] --start TIME --end TIME
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH]
weatherreporter inspect reports [--config PATH] [--limit N]
weatherreporter inspect metadata [--config PATH] RUN_ID
weatherreporter inspect modules [--config PATH] RUN_ID
weatherreporter inspect data-package [--config PATH] RUN_ID
weatherreporter inspect prior [--config PATH] RUN_ID
weatherreporter inspect sources [--config PATH] RUN_ID
weatherreporter --version
weatherreporter generate daily --date YYYY-MM-DD [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate today [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--date YYYY-MM-DD] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate tomorrow [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter generate hourly [--config PATH] [--units VALUE] [--tz NAME] [--out PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run morning [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter run evening [--config PATH] [--units VALUE] [--tz NAME] [--out-dir PATH] [--llm-debug-dir PATH] [--quiet]
weatherreporter compare REPORT --profile PROFILE --profile PROFILE [--config PATH] [--units VALUE] [--tz NAME] [--date YYYY-MM-DD] [--out-dir PATH] [--replace] [--llm-debug-dir PATH] [--quiet]
```
Implemented `generate` commands write a JSON module snapshot, YAML data package,
preflight artifact, managed Markdown report, and metadata under the configured
workspace. `--out` writes an extra Markdown copy for the operator; distributor
notification uses the managed report path, not the extra copy. `generate
tomorrow` and `generate hourly` write managed generated-text artifacts,
validate structured text from Scriptorium, and render the managed Markdown
report from embedded templates. `generate hourly` covers the next six hours in
the effective report timezone and does not accept date or event window flags.
`generate storm` requires explicit event-window bounds with `--start` and
`--end`.
`weatherreporter --version` prints the version embedded in the executable.
Tagged release binaries report their semantic version tag; ordinary local
builds report `development`.
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Report. Batch
runs continue independent reports after a failure, print a JSON summary to
stdout, write compact status lines to stderr, and return nonzero when any report
failed. `--out-dir` writes extra Markdown copies for the operator; distributor
notification uses each managed report path, not the extra copies. When
notification is enabled, batch summaries and status lines include notification
status, accepted distributor run ID, or notification error fields for each
attempted report.
| Command | Contract |
| --- | --- |
| `generate daily` | Requires `--date YYYY-MM-DD`; the date is interpreted in the effective report timezone. Its default filename is `daily-YYYY-MM-DD.md`. |
| `generate today` | Accepts an optional `--date YYYY-MM-DD`; without it, the current local date in the effective report timezone is used. Its default filename is `today.md`. |
| `generate tomorrow` | Uses the next local civil day and writes `tomorrow.md` by default. |
| `generate hourly` | Covers the next six hours in the effective report timezone and writes `hourly.md` by default. It does not accept `--date`, `--hours`, or `--duration`. |
| `run morning` and `run evening` | Run their defined report batches beneath the configured output directory, or the current directory when none is configured. `--out-dir` selects another directory. `--out` is not accepted. |
| `compare REPORT` | Accepts `daily`, `today`, `tomorrow`, or `hourly`. It requires at least two distinct, nonblank `--profile` values in their supplied order. Daily requires `--date`; Today accepts it optionally; Tomorrow and Hourly do not accept it. |
Hourly Report is explicit only; it is not included in `run morning` or `run
evening`.
`generate` accepts the four report command names shown above. `run` accepts
only `morning` and `evening`. `compare` always requires explicit profile
selection: `promptkit.profile` is not used as a comparison default. Batch
membership and notification ordering are described in the
[operations guide](operations.md).
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
## Output, Errors, And Quiet Mode
## Flags
For `generate`, the report's default filename is placed beneath
`output.directory` when configured, otherwise the current directory. `--out
PATH` selects one complete output file instead. A relative path is resolved
from the current directory; an absolute path is used as given. For a batch,
the configured directory has the same role and `--out-dir PATH` selects its
output directory instead. For `compare`, `--out-dir PATH` selects one exact
bundle directory; otherwise the report-derived comparison directory is placed
beneath the configured directory or current directory. `--replace` is required
to replace an existing nonempty recognized comparison bundle. See the
[configuration reference](config.md) for the field's validation and path rules
and the [comparison bundle contract](integrations/comparison-bundle.md) for the
bundle format.
- `-h`, `--help`: show help.
- `--config PATH`: load configuration from `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`.
- `--units VALUE`: override configured Weather API units for `generate` and `run`.
- `--tz NAME`: override configured Weather API timezone for `generate` and `run`.
- `--out PATH`: write an extra Markdown report copy where supported by the `generate` command.
- `--out-dir PATH`: write extra Markdown report copies for `run morning` and `run evening`.
- `--date YYYY-MM-DD`: optional date for `generate daily`; defaults to the current local date in the configured timezone.
- `--start TIME`: required start time for `generate storm`.
- `--end TIME`: required end time for `generate storm`.
- `--limit N`: maximum records for `inspect reports`; defaults to `20`, and `0` means no limit.
Outputs are written atomically. A generation, rendering, write, or cancellation
failure before publication leaves an existing destination unchanged. A
notification failure occurs after publication, so the newly written output
remains available.
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
`SIGINT` and `SIGTERM` cancel an active action. Weatherreporter lets that
cancellation reach the action before exiting; when the action has a result, it
emits the usual failed summary and exits nonzero. A canceled batch retains any
reports that were already published, marks interrupted and unstarted reports
as `canceled`, skips batch notification, and identifies cancellation separately
from report failures.
Distributor notification is configured only through `notify.distributor`; there
are no distributor-specific CLI flags.
Action commands (`generate`, `run`, and `compare`) write a JSON summary to
stdout unless `--quiet` is set. `run` also writes compact per-report and batch
status lines to stderr. A pre-run error, such as an invalid flag, missing
required argument, or configuration-load failure, produces no partial JSON
summary. When an action fails after it has produced a result, its summary has
`"status": "failed"` and an `error` field.
## Common Workflows
`--quiet` is supported by action commands only. It suppresses action summaries
and routine batch status output; it does not suppress command errors.
### Generate Summary
A generate summary identifies the command, report, run, generation time, valid
period, prompt version, timezone, and status. Successful output has an absolute
`outputPath`:
```json
{
"command": "generate",
"reportId": "today",
"promptId": "weather.today_generated_text",
"promptVersion": "2.1.0",
"runId": "20260529T120000.000000000Z_today",
"status": "succeeded",
"timezone": "America/Chicago",
"outputPath": "/srv/weather/today.md"
}
```
When available, the summary also includes the effective `profileId`,
`backendId`, `modelName`, `sourceWarnings`, `validationStatus`, requested
`repairAttempts`, `llmDebugPath`, and compact Distributor `notification`
result. `repairAttempts` is `0` when the initial output passed validation,
positive when PromptKit made corrective generation calls, and omitted when
validation did not complete. The summary does not
include historical or transient artifact paths such as metadata, prompt input,
raw generated text, render context, or notification receipts.
### Run Summary And Stderr
A run summary contains `command`, `batch`, `status`, `startedAt`, `finishedAt`,
`total`, `succeeded`, `failed`, and a `reports` array. Each report item includes
its identity, status, effective profile and model details when available,
source warnings, validation status, repair-attempt count when validation
completed, and absolute `outputPath` after publication.
The top-level summary may also contain a batch `notification` object and
`error`. Batch status is `failed` if any report or the batch notification fails.
The `total`, `succeeded`, and `failed` counters describe report items only, so
a failed batch notification can leave `failed` at `0` while the top-level
notification and action status are `failed`.
When cancellation stops a batch, the summary also includes a nonzero
`canceled` count. Canceled reports have `"status": "canceled"`; they are not
included in `failed`, and the action still has failed status and exits nonzero.
Without `--quiet`, batch status lines use this form:
```text
report=today status=succeeded output="/srv/weather/reports/today.md"
batch=morning total=2 succeeded=2 failed=0 canceled=0
```
### Compare Summary
A comparison summary contains these fields in this order: `command`,
`comparisonId`, `reportId`, `reportName`, `promptId`, `promptVersion`,
`promptHash`, `status`, `startedAt`, `finishedAt`, `timezone`, `validPeriod`,
`outputDirectory`, `manifestPath`, `dataPackagePath`, `total`, `succeeded`,
`failed`, `results`, and optional `error`. Published artifact paths and each
successful `results[].reportPath` are absolute. `results` preserves the
supplied profile order and each item contains `position`, `profileId`, optional
`backendId`, `modelName`, `status`, optional `validationStatus`, optional
`repairAttempts`, optional `reportPath`, optional `llmDebugPath`, and optional
safe `error`. The repair-attempt semantics match the generate summary.
The comparison status is `succeeded` only when every selected profile succeeds
and the bundle is published. Individual profile failures still publish a
complete partial bundle and return a failed command result. Cancellation or a
failure before publication omits the artifact paths and returns a safe
top-level error; the resolved `outputDirectory` and finalized timestamp remain
when available. The safe error includes only a category and message: aggregate
and unclassified application failures use `application`; cancellation uses
`canceled`; deadlines use `deadline_exceeded`; prompt execution uses its
published Promptkit category; destination failures use `destination_<kind>`;
and committed cleanup failures use `publication_cleanup` with a message that
states whether a complete prior bundle, partial remnants, or no prior bundle
remains, or that recovery state could not be inspected. It does not expose
provider diagnostics, filesystem causes, or recovery paths. A provider HTTP
failure may include its numeric status in the safe message. See the
[comparison bundle contract](integrations/comparison-bundle.md) for durable
artifact fields and failure invariants.
If the bundle is published but cleanup of its replaced prior bundle fails, the
summary still includes the published artifact paths and has status `failed`.
Its JSON error is `publication_cleanup`; the returned command error identifies
a recovery path only when cleanup left a sibling behind. Only a reported
complete prior bundle is a rollback artifact.
## Flag Reference
| Flag | Accepted by | Meaning |
| --- | --- | --- |
| `-h`, `--help` | top level, `compare` | Show help without loading configuration or contacting a provider. |
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
| `--units VALUE` | `generate`, `run`, `compare` | Override `weather_api.units` for this command. |
| `--tz NAME` | `generate`, `run`, `compare` | Override `weather_api.timezone` for this command. |
| `--out PATH` | every `generate` command | Write the report to this complete file destination instead of the configured or current-directory default. |
| `--llm-debug-dir PATH` | every `generate`, `run`, and `compare` command | On Unix hosts, write requested sensitive prompt diagnostics under this absolute path. Other hosts fail closed when the flag is requested. |
| `--profile PROFILE` | `compare` | Select one explicit profile. Repeat at least twice with distinct, nonblank IDs. |
| `--out-dir PATH` | `run morning`, `run evening`, `compare` | Write batch reports beneath this directory, or select the exact comparison directory. |
| `--replace` | `compare` | Authorize replacement of a recognized nonempty comparison bundle. |
| `--quiet` | `generate`, `run`, `compare` | Suppress all action summaries and routine batch status output. |
| `--date YYYY-MM-DD` | `generate daily`, `generate today`, `compare daily`, `compare today` | Required for Daily; optional for Today. |
Distributor notification is configured through `notify.distributor`; there are
no Distributor-specific CLI flags. See the [configuration reference](config.md).
## Invocation Examples
```sh
weatherreporter generate tomorrow --out ./tomorrow.md
weatherreporter generate hourly
weatherreporter generate three-day --out ./three-day.md
weatherreporter generate weekend --out ./weekend.md
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00 --out ./storm.md
weatherreporter run morning --out-dir ./reports
weatherreporter run evening --out-dir ./reports
weatherreporter generate daily --date 2026-05-29
weatherreporter generate today --out ./reports/today.md
weatherreporter generate hourly --out /srv/weather/hourly.md
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
weatherreporter run morning --out-dir ./reports --llm-debug-dir /var/tmp/weatherreporter-debug
weatherreporter compare daily --date 2026-05-29 --profile weather-light --profile weather-balanced --out-dir ./comparison-daily-2026-05-29
```
## Inspection
```sh
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
weatherreporter inspect modules 20260529T100000.000000000Z_daily_today
weatherreporter inspect data-package 20260529T100000.000000000Z_daily_today
weatherreporter inspect prior 20260529T100000.000000000Z_daily_today
weatherreporter inspect sources 20260529T100000.000000000Z_daily_today
```
`inspect reports` lists recent generated runs with artifact paths and source
warning counts. The other inspect commands require a RunID. `inspect modules`
returns the persisted ordered module snapshot for a run. `inspect prior`
returns the prior comparable snapshot metadata selected from stored metadata, or
`null` when none exists. `inspect sources` shows source provenance and source
warnings without dumping full weather payloads.

View File

@@ -1,254 +1,266 @@
# Weatherreporter Configuration
Configuration is YAML. By default, `weatherreporter` reads:
Weatherreporter reads YAML configuration. The default path is:
```text
/usr/local/etc/weatherreporter/config.yml
```
Use `--config PATH` to load a different file. If the default file is absent,
built-in defaults are used. If `--config PATH` points to a missing file, loading
fails.
If the default file is absent, Weatherreporter uses built-in defaults. An
explicit `--config PATH` must exist. Values are applied in this order:
Precedence is:
1. built-in defaults;
2. the configuration file, when present; and
3. the `--units` and `--tz` command-line overrides.
1. CLI flags
2. configuration file
3. built-in defaults
Environment variables do not override configuration fields. Output flags select
operator-owned destinations for one command and do not change configuration.
The CLI configuration overrides are `--units` and `--tz`. Output flags control
report copies for the current command but do not change configuration files.
Environment variables do not override configuration fields.
## Maintained Examples
## Minimal Config
- [minimal-config.yml](../examples/minimal-config.yml) is the smallest useful
collection and generation configuration.
- [config.yml](../examples/config.yml) is a representative production-oriented
configuration using synthetic endpoints and no credentials.
- [weather-light-local-profile.yml](../examples/weather-light-local-profile.yml)
is a complete endpoint-only override for the embedded `weather-light`
profile.
See [examples/minimal-config.yml](../examples/minimal-config.yml).
The configuration examples are loaded by the configuration test suite. The
profile example is inspected through the Promptkit adapter test suite.
## Minimal Configuration
```yaml
weather_api:
base_url: https://weather.api.example.com/
```
`weather_api.base_url` is required for commands that fetch weather data. Other
fields fall back to defaults.
## Production-Oriented Config
See [examples/config.yml](../examples/config.yml). The example is loaded by the
config test suite.
`weather_api.base_url` is required for workflows that collect weather data.
All omitted fields use their built-in defaults.
## Field Reference
### `weather_api`
- `base_url`: absolute base URL for the Weather API. Required for generation and fetch workflows.
- `timeout`: HTTP timeout duration. Default: `10s`.
- `precision`: numeric precision query value. Default: `1`.
- `units`: Weather API units query value. Default: `us`.
- `timezone`: report timezone and Weather API timezone query value where supported. Default: `America/Chicago`.
- `format`: Weather API response format. Must be `json`. Default: `json`.
| Field | Default | Rules |
| --- | --- | --- |
| `base_url` | empty | Absolute HTTP(S) Weather API URL. Required for collection and generation. |
| `timeout` | `10s` | Must be greater than zero. |
| `precision` | `0` | Must be zero or greater. Sent as the Weather API precision query value. |
| `units` | `us` | Required Weather API units query value; `--units` overrides it for one command. |
| `timezone` | `America/Chicago` | Required report and Weather API timezone; `--tz` overrides it for one command. |
| `format` | `json` | Required and must be `json`. |
Timezone values may be IANA names, configured aliases such as `Chicago` and
`Stl`, US timezone abbreviations, or UTC offsets such as `-5` and `+09:30`.
`Stl`, US timezone abbreviations, or signed UTC offsets such as `-5`, `+0930`,
and `+09:30`. Numeric offsets require a sign, one or two hour digits, and an
optional two-digit minute component with or without a colon. Hours must be
from `00` through `23`, minutes from `00` through `59`, so the largest accepted
offset magnitude is `23:59`.
### `location`
`location` is descriptive prompt context included in module metadata and
Scriptorium data packages. It does not select a Weather API endpoint or enable
multiple configured forecast locations.
`location` supplies descriptive prompt context; it does not choose a Weather
API endpoint or configure multiple forecast locations.
- `id`: short local identifier. Default: `home`.
- `name`: human-readable location name. Default: `Brentwood`.
- `region`: broader forecast area context. Default: `St. Louis Metro`.
| Field | Default |
| --- | --- |
| `id` | `home` |
| `name` | `Brentwood` |
| `region` | `St. Louis Metro` |
The prompt-facing location object also includes `timezone`, derived from the
effective `weather_api.timezone` after CLI overrides such as `--tz`.
The prompt-facing location timezone is derived from the effective
`weather_api.timezone` after command-line overrides.
### `secrets`
- `directory`: optional directory of file-backed environment secrets. Default:
empty, which disables secret loading.
`secrets.directory` defaults to empty, which disables secret loading. When it
is set, every regular file directly in that directory is staged after the file
and command-line overrides, then applied only after the complete configuration
has validated successfully. A rejected load leaves the existing environment
unchanged. A file basename must match
`[A-Za-z_][A-Za-z0-9_]*`; it becomes an environment variable name, and the
file contents replace any existing value. One trailing LF or CRLF is removed.
When configured, each regular file directly under `secrets.directory` is loaded
after config file parsing and CLI overrides. The file basename must be a valid
environment variable name matching `[A-Za-z_][A-Za-z0-9_]*`; the file contents
become the environment variable value and overwrite any existing value. One
trailing LF or CRLF is stripped. Subdirectories, symlinks, invalid filenames,
missing directories, and unreadable files fail config loading.
Missing directories, unreadable files, subdirectories, symlinks, non-regular
files, and invalid names fail configuration loading. Put only secret values in
this directory, never in the YAML file.
### `notify`
### `output`
`notify.distributor` controls distributor notification after successful report
generation. It is disabled by default and does not add CLI flags. When enabled,
weatherreporter uploads one distributor bundle per generated report after
report rendering succeeds and final metadata is saved.
`output.directory` selects the ordinary operator-owned publication directory
for individual reports, batches, and the default parent of comparison bundles.
- `enabled`: whether distributor notification config is active. Default:
`false`.
- `endpoint`: absolute distributor endpoint URL. Required when enabled.
Default: `https://distributor.example.com`.
- `token_env`: environment variable name that will contain the distributor
upload token. Required when enabled. Default: `DISTRIBUTOR_UPLOAD_TOKEN`.
- `timeout`: distributor operation timeout. Must be greater than zero when
enabled. Default: `30s`.
- `failure_policy`: must be `error` when enabled. Default: `error`.
- `pipeline_id_template`: template for the distributor pipeline ID. Required
when enabled. Default: empty.
- `bundle_id_template`: template for distributor bundle IDs. Default:
`weatherreporter.{location_id}.{report_id}`.
- `idempotency_key_template`: template for distributor idempotency keys.
Default: `{bundle_id}.{run_id}`.
- `report_path_templates`: ordered list of templates for Markdown report paths
inside the distributor bundle. Each rendered path maps to the same managed
Markdown report source. Default:
```yaml
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
```
| Field | Default | Rules |
| --- | --- | --- |
| `directory` | empty | An omitted or empty value uses the invocation working directory. A nonempty value must contain at least one non-whitespace character. |
Supported template variables are `location_id`, `report_id`, `run_id`,
The configured value is preserved while configuration loads: it is not cleaned,
made absolute, inspected, created, or expanded through environment variables or
a home-directory shortcut. At execution, an absolute directory is used as
given; a relative directory resolves from the invocation working directory, not
from the configuration file's location. A missing directory is created when a
report is successfully published. An existing non-directory or an uninspectable
path fails output preflight before prompt inspection, weather collection, or
publication.
For one `generate` command, `--out` is a complete file destination and takes
precedence over `output.directory`. For `run`, `--out-dir` takes precedence.
For `compare`, `--out-dir` selects its exact bundle directory; without it, the
comparison's report-derived directory is placed beneath `output.directory`.
Those explicit flags do not inspect or rebase beneath the configured directory.
See the [CLI reference](cli.md) for command selection and the [operations
guide](operations.md) for publication and failure handling.
### `notify.distributor`
Distributor notification is disabled by default. Its fields are:
| Field | Default | Rules when notification is enabled |
| --- | --- | --- |
| `enabled` | `false` | Activates Distributor notification validation. |
| `endpoint` | `https://distributor.example.com` | Must be an absolute HTTP(S) base URL with a host and no userinfo, query, or fragment. A path prefix is allowed. |
| `token_env` | `DISTRIBUTOR_UPLOAD_TOKEN` | Must name a valid environment variable. |
| `timeout` | `30s` | Must be greater than zero. |
| `failure_policy` | `error` | Must be `error`. |
| `pipeline_id_template` | empty | Required single-report pipeline ID template. |
| `bundle_id_template` | `weatherreporter.{location_id}.{report_id}` | Required single-report bundle ID template. |
| `idempotency_key_template` | `{bundle_id}.{run_id}` | Required single-report idempotency-key template. |
| `batch.enabled` | `true` | Activates batch notification validation when Distributor notification is enabled. |
| `batch.pipeline_id_template` | `weatherreporter` | Required when batch notification is enabled. |
| `batch.bundle_id_template` | `weatherreporter.{location_id}.{batch}` | Required when batch notification is enabled. |
| `batch.idempotency_key_template` | `{bundle_id}.{batch_run_id}` | Required when batch notification is enabled. |
The upload token is read from the environment variable named by `token_env`.
Use `secrets.directory` when a file-backed secret is appropriate.
When notification is enabled, Weatherreporter validates the Distributor endpoint
before prompt inspection, weather collection, or output publication. Use an
HTTP(S) base URL such as `https://distributor.example.com/archive`; do not put
credentials, a query string, or a fragment in the endpoint.
When notification is enabled, each rendered single-report pipeline ID, bundle
ID, and idempotency key must contain at least one non-whitespace character.
Single-report bundle templates accept `location_id`, `report_id`, `run_id`,
`artifact_group`, `batch_output_name`, `valid_start_date`, `valid_end_date`,
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, and
`valid_end_stamp`. Date values use `YYYY-MM-DD`, time values use `HHMM`, and
stamp values use `YYYY-MM-DDTHHMM` in the effective report timezone.
`pipeline_id_template` and `idempotency_key_template` may also use `bundle_id`.
`valid_start_time`, `valid_end_time`, `valid_start_stamp`, `valid_end_stamp`,
Pipeline and idempotency-key templates may also use `bundle_id`. Dates use
`YYYY-MM-DD`; times use `HHMM`; and stamps use `YYYY-MM-DDTHHMM` in the
effective report timezone.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The rendered bundle ID is the stable logical source identity for the
report stream. The rendered idempotency key is the per-run retry identity.
Batch bundle and pipeline templates accept `location_id`, `batch`,
`batch_run_id`, and `batch_started_date`; batch idempotency-key templates may
also use `bundle_id`. `batch_started_date` is the batch start date in the
effective report timezone.
Rendered report paths must be unique relative paths with `/` separators. They
must not contain backslashes, empty path segments, `.`, `..`, `manifest.json`,
or `.distributor.json`.
`reports.<report>.distributor.path_templates` overrides the default ordered
Distributor paths for that report. Each rendered path must be a unique relative
path with `/` separators. Backslashes, empty segments, `.` and `..` segments,
`manifest.json`, and the reserved Distributor sidecar basename are rejected.
The default paths are:
The upload token is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing. Config files should
name the variable only; they should not contain the token value.
| Report | Paths |
| --- | --- |
| `hourly` | `hourly/index.md` |
| `daily` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md` |
| `today` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `today/index.md` |
| `tomorrow` | `daily/{valid_start_date}/{run_id}.md`, `daily/{valid_start_date}/index.md`, `tomorrow/index.md` |
See the [operations guide](operations.md) for notification timing, uploaded
output selection, and failure handling.
### `missing_source`
- `default`: missing-source behavior for optional sources. One of `error`, `warn`, or `none`. Default: `warn`.
- `sources`: optional map of source-specific overrides, using the same policy values.
Hourly forecast data is required for generated reports. Optional sources use
the missing-source policy. Source override keys include `observations`,
`missing_source.default` defaults to `warn` and accepts `error`, `warn`, or
`none`. `missing_source.sources` optionally overrides that policy by source.
Hourly forecast data is required for generated reports and cannot have a
source-specific policy. Supported optional source keys are `observations`,
`current`, `narrative`, `alerts`, `discussion`, `weather_story`, and
`spc_convective_outlooks`.
`spc_convective_outlooks`; any other key is rejected.
### `scriptorium`
### `promptkit`
- `binary`: `scriptorium` executable name or path. Default: `scriptorium`.
- `config_path`: optional Scriptorium config path passed to the adapter.
- `profile`: optional Scriptorium profile passed to the adapter.
- `timeout`: subprocess timeout. Default: `2m`.
- `extra_args`: optional additional arguments passed to Scriptorium commands.
Promptkit configuration selects the executor and prompt/profile checks for
every `generate`, `run`, and `compare` command. A top-level `scriptorium:` configuration
key is rejected with a migration error; it is not translated or ignored.
### `workspace`
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
individual `generate`, `run`, or `compare` command when explicitly needed.
See [optional prompt debug capture](operations.md#optional-prompt-debug-capture)
for platform availability, security, and retention requirements.
- `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: module snapshot and metadata directory under `workspace.root`. Default: `snapshots`.
- `reports_dir`: managed Markdown report directory under `workspace.root`. Default: `reports`.
- `data_packages_dir`: prompt input package directory under `workspace.root`. Default: `data-packages`.
- `preflight_dir`: Scriptorium render output directory under `workspace.root`. Default: `preflight`.
- `notifications_dir`: distributor notification debug artifact directory under `workspace.root`. Default: `notifications`.
| Field | Default | Rules |
| --- | --- | --- |
| `profile` | empty | Optional global profile selection for every report in one command. When empty, each exact prompt version selects its declared default. |
| `profile_file` | empty | Optional external Promptkit profile file. It cannot be combined with `profile_dir`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
| `profile_dir` | empty | Optional external Promptkit profile directory. It cannot be combined with `profile_file`. A same-ID profile completely replaces Weatherreporter's embedded definition. |
| `timeout` | `2m` | Must be greater than zero. |
| `local.endpoint` | empty | Optional absolute URL for the conventional local backend. A blank endpoint leaves it unregistered. |
| `local.concurrency_limit` | `1` | Maximum local backend concurrency. `0` is unlimited; negative values are invalid. |
Workspace subdirectories must be relative paths that stay inside
`workspace.root`.
`profile` selects an ID; `profile_file` and `profile_dir` supply definitions.
They are separate decisions. An explicit `profile` applies to every selected
report. Otherwise Hourly selects `weather-light`, while Daily, Today, and
Tomorrow select `weather-balanced` through their exact `2.1.0` prompt
definitions.
Promptkit resolves a selected profile definition from a test or embedding
consumer's explicit in-memory profile, then the configured `profile_file` or
`profile_dir`, then Weatherreporter's embedded catalog, and finally Promptkit's
built-in catalog. Weatherreporter's embedded `weather-*` definitions are small
aliases of Promptkit's maintained base profiles, so Promptkit also resolves
their inherited target and settings. A configured definition with the same ID
as either a selected profile or an inherited base takes precedence. A matching
malformed external profile fails rather than using the embedded definition. The
[Promptkit integration guide](integrations/promptkit.md) owns the catalog and
precedence details.
An endpoint-only profile may intentionally have no backend identity. Profiles
that require a direct API key are rejected before collection, while Promptkit
resolves optional environment credential sources during execution.
To replace the default Hourly definition with a local OpenAI-compatible
endpoint, set `profile_file` to a copy of
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml).
The example has no credential and should be edited for the local endpoint and
model before use. An alternative profile may use `backend: local`; in that
case `promptkit.local.endpoint` supplies the conventional local backend
endpoint.
### `dayparts`
`dayparts` is a list of named local-time windows used by forecast derivation.
Each entry has:
`dayparts` is a non-empty list of named local-time windows used in forecast
derivation. Every item needs `name`, `start`, and `end`; start and end use
`HH:MM`. Defaults are `overnight` (`00:00``06:00`), `morning`
(`06:00``10:00`), `midday` (`10:00``15:00`), `afternoon`
(`15:00``17:00`), and `evening` (`17:00``24:00`).
- `name`
- `start`
- `end`
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
midday, afternoon, and evening.
### `recent_change`
- `temperature_degrees`: temperature change threshold. Default: `5`.
- `precip_probability_points`: precipitation probability threshold. Default: `20`.
- `wind_gust_miles_per_hour`: wind gust change threshold. Default: `10`.
- `precip_timing_shift_minutes`: precipitation timing shift threshold. Default: `120`.
Recent Changes are added to prompt input when a prior comparable module
snapshot exists and a threshold is crossed.
Names remain display text, but each name must have a distinct canonical
identity. Canonicalization trims whitespace, lowercases letters, and collapses
punctuation and whitespace to underscores; for example, `Morning`,
`morning!`, and `morning` conflict. Planning recognizes the canonical
identities `morning`, `afternoon`, `evening`, and `overnight` regardless of
their display capitalization or punctuation.
### `reports`
`reports` optionally overrides the ordered deterministic modules declared by
report definitions. Omit a report entry to use its default module order.
`reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults.
Supported report keys are `daily`, `tomorrow`, `hourly`, `three_day`,
`weekend`, and `storm`. Canonical report IDs such as `daily_today` are also
accepted.
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`. Keys are
trimmed, case-folded to lowercase, and normalize hyphens to underscores before
lookup.
Each report entry supports:
Each report entry can contain:
- `deterministic_modules`: ordered module list. Entries may be string module
IDs or objects with `id` and optional `options`.
- `deterministic_modules`: an ordered list of module IDs, or objects with `id`
and optional `options`.
- `distributor.path_templates`: an optional, non-empty ordered list of
Distributor paths for that report.
Example:
```yaml
reports:
daily:
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- short_term
- spc_convective_discussion
- hourly_forecast
hourly:
deterministic_modules:
- metadata
- current_conditions
- hourly_forecast
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- key_messages
- short_term
- spc_convective_discussion
- weather_story
```
Unknown reports, unknown modules, duplicate modules, incompatible report/module
combinations, duplicate stanza names, and invalid options fail config loading.
`area_forecast_discussion.options.sections` may contain `product`,
`key_messages`, `short_term`, and `long_term`. Empty or omitted `sections`
includes all available AFD sections.
The module registry accepts all module IDs documented in
[Module Contract Internals](internal/module.md). Unknown or unimplemented
module IDs fail validation instead of being skipped.
## Secrets
Configuration files should not contain raw secrets. Use `secrets.directory` to
load secret values from files into environment variables for integrations that
read credentials from the environment. Secret file names become environment
variable names, and secret file contents become values. For distributor
notification, this allows a file such as
`<secrets.directory>/DISTRIBUTOR_UPLOAD_TOKEN` to supply the token referenced by
`notify.distributor.token_env`.
## Maintained Examples
- [examples/minimal-config.yml](../examples/minimal-config.yml): smallest
useful config for generation and fetching.
- [examples/config.yml](../examples/config.yml): production-oriented config
covering maintained fields.
Both example files are loaded by the config test suite.
Unknown reports and modules, duplicate modules, incompatible report-module
combinations, duplicate stanza names, invalid path templates, and invalid
module options fail configuration loading. The accepted module IDs and module
option contracts are documented in the [module contract internals](internal/module.md).

88
docs/development.md Normal file
View File

@@ -0,0 +1,88 @@
# Development
This is the first-read guide for people and coding agents working on
Weatherreporter. It provides a concise repository orientation and routes each
kind of change to its canonical documentation.
Weatherreporter is a Go CLI that collects normalized weather data, derives
deterministic report facts and module snapshots, executes Promptkit for
single-report generated text, renders Markdown reports, and can upload completed
operator-owned outputs through Distributor. Start with the [README](../README.md) for product
context and the [architecture policy](policy/architecture.md) for system
boundaries and invariants.
## What To Read
| When working on | Read | Why |
| --- | --- | --- |
| Product behavior or the shortest useful workflow | [README](../README.md), [CLI reference](cli.md), and [operations guide](operations.md) | These own product orientation, invocation, and normal operation. |
| Application shape, package boundaries, dependency direction, safety properties, or architectural invariants | [Architecture policy](policy/architecture.md) and relevant ADRs under `docs/adr/`, when present | Architecture defines the intended system; ADRs preserve significant decision rationale. |
| Any documentation addition, revision, move, or removal | [Documentation policy](policy/documentation.md) | It defines canonical owners, audience boundaries, current-state rules, and document lifecycle. |
| Adding, changing, reviewing, or deleting tests | [Testing policy](policy/testing.md) and focused package tests | The policy defines risk-based sufficiency, durable test boundaries, doubles, and test-maintenance criteria. |
| CLI commands, flags, output, quiet mode, or command wiring | [CLI reference](cli.md) and [CLI internals](internal/cli.md) | The reference owns the user contract; the internal guide owns command composition and output flow. |
| Configuration fields, defaults, loading, overrides, validation, or secrets | [Configuration reference](config.md), [architecture policy](policy/architecture.md), and tests under `internal/config` | These separate the user-visible contract, architectural rules, and executable behavior. |
| Top-level generation, batch, comparison, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md), [comparison execution internals](internal/comparison-execution.md), and [comparison publication internals](internal/comparison-publication.md) | They own workflow ordering, concurrent profile execution, output publication, failure propagation, and orchestration invariants. |
| Weather API transport, source envelopes, source warnings, or collection | [Weather API integration](integrations/weatherapi.md), [weather-data internals](internal/weather-data.md), and [collection internals](internal/collect.md) | These separate the external contract, normalized source facts, and app-facing collection behavior. |
| Forecast periods, weather derivation, collected facts, or derived facts | [Forecast derivation internals](internal/forecast-derivation.md) and [fact contracts](internal/facts.md) | They own deterministic derivation and the fact boundaries used by reports. |
| Report definitions, valid periods, report IDs, output naming, or batch composition | [Report registry internals](internal/report-registry.md) and [app orchestration internals](internal/app-orchestration.md) | Report definitions own selection and period rules; orchestration owns execution. |
| Module IDs, module composition, briefing values, or prompt-facing exports | [Module contract internals](internal/module.md), [module builder internals](internal/briefing.md), and [prompt-input internals](internal/prompt-input.md) | These own module contracts, value construction, and the curated prompt-package boundary. |
| Prompt execution, profiles, prepared report inputs, or result handling | `internal/promptexec`, the Promptkit adapter, [prepared report internals](internal/prepared-report.md), and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract, immutable preparation, and input construction. |
| Durable comparison bundles or their compatibility | [Comparison bundle contract](integrations/comparison-bundle.md) and [comparison publication internals](internal/comparison-publication.md) | The integration document owns the external schema; internals own how it is published. |
| Generated-text schemas, validation, render contexts, templates, or Markdown rendering | [Generated-text internals](internal/generatedtext.md), [report-template internals](internal/reporttemplate.md), and [report template guide](templates.md) | These own structured text, renderer implementation, and the maintainer-facing template surface. |
| Output destinations, atomic publication, prompt diagnosis, or legacy cleanup | [Operations guide](operations.md), [App orchestration internals](internal/app-orchestration.md), and [comparison publication internals](internal/comparison-publication.md) | Operations owns operator workflows; internals own implementation boundaries. |
| Distributor bundles, uploads, notification results, or failures | [Distributor adapter internals](internal/distributor-adapter.md), [Distributor integration contracts](integrations/distributor/), and [operations guide](operations.md) | These separate adapter behavior, external contracts, and operational lifecycle. |
| Maintained example configuration | [Configuration reference](config.md) and files under `examples/` | The reference owns field meaning; examples own complete copyable files. |
| Release preparation, tagging, publication, or verification | [Release procedure](release.md) | It owns version selection, release-note preparation, candidate validation, tag publication, CI behavior, and post-publication checks. |
| Proposed, deferred, or unimplemented work | Documents under `docs/roadmap/` | Future behavior and implementation status belong only in roadmaps until implemented. |
For an existing subsystem, inspect its focused internal document, package-local
types, and tests before changing behavior. Use the package boundaries already
present before introducing a new package or abstraction.
## Repository Map
| Area | Responsibility |
| --- | --- |
| `cmd/weatherreporter` | Binary entry point. |
| `internal/cli` | Command parsing, flags, help, output, and command wiring. |
| `internal/app` | Stateless generation, batches, comparisons, collection coordination, output publication, and notification. |
| `internal/comparison` | Comparison identities, logical bundles, guarded destinations, and atomic bundle publication. |
| `internal/config` | Configuration defaults, loading, precedence, secrets, and validation. |
| `internal/adapters` | Weather API, Promptkit, and Distributor boundaries. |
| `internal/weatherdata`, `internal/forecast`, `internal/facts` | Normalized source facts and deterministic derivation. |
| `internal/report`, `internal/module`, `internal/briefing` | Report registry plus module and briefing contracts. |
| `internal/promptinput`, `internal/generatedtext`, `internal/reporttemplate` | Prompt packages, generated-text validation, render contexts, and Markdown templates. |
| `internal/fileutil`, `internal/timeutil` | Atomic output operations, clocks, dates, timezones, and periods. |
| `docs` | User, operator, integration, internal, policy, and roadmap documentation. |
| `examples` | Maintained copyable configuration. |
The [architecture policy](policy/architecture.md) is authoritative for
normative boundaries. Focused documents under `docs/internal/` own detailed
implemented subsystem behavior.
## Contributor Workflow
1. Read the documents and focused tests identified by the task guide.
2. Use focused package checks while iterating.
3. Run `gofmt -w` on changed Go files.
4. Update the canonical documentation and maintained examples in the same
change when behavior changes.
5. Run repository-wide validation before considering the work complete.
Preserve actionable error context, keep secrets out of logs and fixtures, and
avoid validation that requires live Weather API, Promptkit providers, or Distributor
services. The architecture and testing policies own the detailed rules.
## Baseline Validation
Run:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Use focused package tests during development and add broader or race-enabled
checks when required by the [testing policy](policy/testing.md) and the risks of
the change.

View File

@@ -0,0 +1,109 @@
# Comparison Bundle Contract
A comparison bundle is the durable, flat artifact produced when one report is
executed with multiple explicit Promptkit profiles. This document is the
canonical contract for consumers of those bundles. Command invocation and JSON
action summaries belong to the [CLI reference](../cli.md); destination handling
and retention belong to the [operations guide](../operations.md).
## Version And Layout
The current and only supported manifest schema version is
`weatherreporter.comparison.v2`. A bundle directory contains exactly these
regular, non-symlinked files:
```text
comparison.json
data-package.yml
NN-profile-slug.md
```
`comparison.json` is the manifest and `data-package.yml` is the exact YAML
input supplied to every selected profile. There is one Markdown file for each
successful result and none for failed results. `NN` is the one-based selected
profile position, zero padded to at least two digits (and widened only when
needed for 100 or more profiles). The profile slug preserves ASCII letters,
digits, `-`, and `_`; each run of other characters becomes one `-`; edge `-`
and `_` characters are removed; the value is capped at 64 bytes; and an empty
slug becomes `profile`. Logical profile IDs remain authoritative in the
manifest.
All manifest paths are basenames relative to the bundle root. They never use
path separators, `.` or `..`. The CLI reports absolute paths only after a
bundle has been published.
## Manifest Schema
The manifest is UTF-8 JSON, encoded as two-space-indented JSON with one
trailing newline. Its fields appear in this order:
```text
schemaVersion, comparisonId, startedAt, finishedAt, reportId, validPeriod,
timezone, promptId, promptVersion, promptHash, dataPackage, total, succeeded,
failed, results
```
`validPeriod` contains `start` and `end`; it is a nonempty half-open period.
`dataPackage` contains `path` (always `data-package.yml`) and `sha256` (the
lowercase, 64-character SHA-256 digest of that file's exact bytes). `results`
is in the explicit profile-selection order. Its result-object fields appear in
this order:
```text
position, profileId, backendId, modelName, status, validationStatus,
repairAttempts, reportPath, error
```
`startedAt` and `finishedAt` are nonzero UTC timestamps, and the latter is not
earlier than the former. `validPeriod` retains its resolved time offset.
`reportId`, `timezone`, prompt identity, model name, and comparison ID are
nonblank. `promptHash` and `dataPackage.sha256` are lowercase SHA-256 digests.
## Result Invariants
`total` is at least two and equals the number of results. Positions are
contiguous from one, profile IDs are distinct and nonblank, and
`succeeded + failed == total`.
A successful result has `status: "succeeded"`, `validationStatus: "passed"`,
and a non-negative `repairAttempts` count,
a `reportPath` exactly equal to the canonical `NN-profile-slug.md` filename for
its position, total, and logical profile ID, and no `error`. A failed result has
`status: "failed"`, no `reportPath`, and an `error` object with nonblank
`category` and `message`. Its validation status is absent, `failed`, or
`skipped`; it may also be `passed` when a WeatherReporter step after PromptKit
validation failed. Error messages are valid UTF-8 and no longer than 1,024 bytes.
`backendId` and `validationStatus` are omitted when unavailable. A failed
result with any completed validation status must retain its non-negative
`repairAttempts`; early operational failures omit both fields.
Every successful Markdown file is declared by exactly one successful result.
The directory contains no extra entries. Consumers can therefore verify the
data-package digest and the full manifest-to-file mapping without scanning a
larger workspace.
## Compatibility And Sensitivity
Weatherreporter recognizes a replaceable bundle only when it exactly satisfies
the current version, schema, file set, file types, relative-path rules, and
data-package digest. JSON field names are case-sensitive canonical names and a
field may appear only once in each manifest object. It rejects unknown,
case-variant, or duplicate fields; multiple JSON values; extra entries;
symlinks; and future or otherwise unsupported versions. Treat a bundle that
fails recognition as an ordinary directory, not as a compatible bundle.
Only v2 is recognized as a replaceable bundle; v1 is unsupported and must be
moved or removed before a replacement at the same destination. When replacing
a recognized bundle, cancellation observed before the new
bundle is installed preserves the prior bundle rather than committing the
replacement.
Cleanup of a prior bundle occurs only after its replacement is committed and
does not affect the new bundle's compatibility. A cleanup error may identify a
complete recovery bundle, partial remnants, no remaining sibling, or an
uninspectable state; this operational state is not recorded in the manifest.
The manifest contains safe operational provenance, but `data-package.yml` and
the generated Markdown can contain sensitive weather or location context. Do
not assume these artifacts are safe for public distribution. Handle retention,
access, and deletion according to the [operations guide](../operations.md).

View File

@@ -1,136 +1,77 @@
# Upstream Producer Integration
# Distributor HTTP Upload Contract
Audience: developers and LLM coding agents adding `distributor` support to an upstream Go producer application.
Weatherreporter integrates with the HTTP upload API provided by
`gitea.maximumdirect.net/eric/distributor v0.5.0`. It submits source bundles to
a configured pipeline and reads the resulting run status. Configuration fields
and notification lifecycle are documented in the [configuration reference](../../config.md)
and [operations guide](../../operations.md).
This document is the copyable implementation guide for submitting producer outputs to a `distributor` pipeline whose source backend is `http_upload`.
## Upload Admission
## Required Inputs
Weatherreporter uses an absolute HTTP(S) endpoint with a host as a base URL.
It allows a path prefix but rejects userinfo, query strings, and fragments
before local report work begins. The client posts a gzip-compressed source
bundle to:
The upstream application needs these values from deployment or operator configuration:
- distributor endpoint: the HTTP server base URL, such as `https://distributor.example.com`;
- upload token: bearer token that authenticates the producer;
- pipeline id: configured `http_upload` pipeline that should process this upload;
- generated files: regular local files to include in the source bundle;
- bundle id: stable identifier for the logical report stream or artifact;
- idempotency key: unique key for one producer run, reused only when retrying that same run.
Do not put destination routing, public URLs, transform settings, or credentials in the source manifest. Those belong in the `distributor` pipeline configuration.
The token, pipeline id, bundle id, and idempotency key have different jobs. The token authenticates the producer. The pipeline id selects the configured distributor workflow, including destinations and publishing policy. The bundle id tells `distributor` whether a new upload is a newer version of the same source; keep it stable across runs that should replace the same managed destination artifact. The idempotency key tells `distributor` whether an upload request is a retry; change it for each distinct producer run so new content is enqueued.
## Recommended Workflow
Use `gitea.maximumdirect.net/eric/distributor/pkg/upload`.
For most producers, use `UploadFiles`. It accepts producer-generated files, builds a temporary valid source bundle with `pkg/bundle`, uploads a gzip-compressed tar archive, and removes temporary files when the call returns.
Use `UploadBundle` only when the producer already assembled a complete bundle directory containing `manifest.json`.
Add the dependency from the upstream application:
```sh
go get gitea.maximumdirect.net/eric/distributor
```text
POST /v1/pipelines/<pipeline_id>/upload
Authorization: Bearer <token>
Content-Type: application/gzip
Idempotency-Key: <key>
```
## Minimal Go Example
The authenticated token must be allowed to use the selected upload pipeline.
A successful response is `202 Accepted` with JSON containing `run_id` and
`status`. Acceptance means Distributor staged and validated the source bundle;
it does not mean downstream destinations have published it.
```go
package reports
The adapter requires nonblank pipeline ID, bundle ID, and idempotency key, plus
at least one source-file mapping, before calling Distributor. It reads the bearer token from
the configured environment variable and redacts that value from errors. Request
construction and timeout handling belong to the [Distributor adapter](../../internal/distributor-adapter.md).
import (
"context"
"errors"
"fmt"
"os"
"time"
Weatherreporter reads at most 1 MiB from each Distributor response. An
oversized response fails notification with a stable local diagnostic. Normal
Weatherreporter results retain upload and status identity but do not repeat
Distributor response bodies, status reports, or remote error text.
"gitea.maximumdirect.net/eric/distributor/pkg/bundle"
"gitea.maximumdirect.net/eric/distributor/pkg/upload"
)
## Idempotency
func SubmitReport(reportPath, summaryPath string) error {
endpoint := os.Getenv("DISTRIBUTOR_UPLOAD_ENDPOINT")
token := os.Getenv("DISTRIBUTOR_UPLOAD_TOKEN")
if endpoint == "" || token == "" {
return fmt.Errorf("distributor endpoint and token are required")
}
Distributor scopes idempotency to the token, pipeline ID, and key. Keys must be
non-empty ASCII values of at most 128 bytes using letters, digits, `.`, `_`,
`-`, and `:`. Weatherreporter always supplies a rendered key; it does not rely
on the client library's generated-key fallback.
pipelineID := "weather-hourly"
reportID := "weather.hourly.brentwood"
runID := time.Now().UTC().Format("20060102T150405.000000000Z")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
Reusing a key for the same normalized source manifest returns the original
accepted run. Reusing it for different content returns `409 Conflict`, which
the adapter exposes as a Weatherreporter idempotency-conflict error. A distinct
report or batch run therefore needs a distinct key; reuse a key only when
retrying that same upload.
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: endpoint,
Token: token,
})
if err != nil {
return err
}
## Run Status And Retention
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: pipelineID,
ID: reportID,
IdempotencyKey: reportID + "." + runID,
Files: []bundle.BundleFile{
{SourcePath: reportPath, Path: "report.md"},
{SourcePath: summaryPath, Path: "summary.txt"},
},
})
if err != nil {
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
return err
}
After acceptance, Weatherreporter reads:
fmt.Printf("distributor accepted run %s\n", result.RunID)
return nil
}
```text
GET /runs/<run_id>
Authorization: Bearer <token>
```
## Producer Responsibilities
The status record provides `run_id`, `pipeline_id`, status timestamps, optional
JSON `report`, and an `error` for failures. Statuses are `accepted`, `queued`,
`running`, `succeeded`, and `failed`. A terminal `failed` status makes the
notification fail; the adapter preserves the returned status details for the
application to record.
- Use a stable bundle id for the logical producer output that should replace the same destination artifact, such as `weather.hourly.brentwood`.
- Set `PipelineID` to the configured upload pipeline that should process the bundle.
- Do not include per-run timestamps, random values, or job ids in the bundle id unless each run should be treated as a different source.
- Use an idempotency key that changes for every distinct producer run, such as `<bundle-id>.<run-id>`.
- Reuse the same idempotency key only when retrying the exact same producer run with the same source manifest.
- Map each generated file to a clean slash-separated bundle path, such as `report.md` or `assets/chart.png`.
- Include only regular files. Symlinks, directories as files, devices, FIFOs, and sockets are rejected.
- Keep file contents stable after upload inputs are selected. Bundle digests are calculated from file bytes.
- Treat upload success as admission only. `UploadFiles` and `UploadBundle` return after the server accepts and validates the upload, not after all destinations publish.
Run and idempotency records are in-memory. Completed records expire according
to Distributor's `server.http.retention`, and a Distributor restart removes
retained status and idempotency state. Status polling decisions are internal
orchestration behavior; see the
[Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md).
Valid bundle paths are relative slash paths. They must not be empty, absolute, contain backslashes, contain `.` or `..` path segments, contain empty path segments, or use reserved basenames `manifest.json` or `.distributor.json`.
## Compatibility Reference
## Idempotency And Status
`pkg/upload` sends `Idempotency-Key` on every upload. If the caller omits one, the package generates a random key for that call and reuses it for in-process retries. That is enough for transient network retry within one process, but it does not give cross-process retry identity.
For producer jobs that may retry after process restart, supply a key derived from the producer run, such as `<bundle-id>.<run-id>`. Reusing the same key with the same token, pipeline id, and normalized source manifest returns the original accepted run. Reusing the same key with different source content in that scope returns a conflict. Reusing one key across multiple distinct report generations prevents those generations from being treated as new uploads.
`Status` polls `/runs/<run-id>` while the distributor server retains the in-memory status record. Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to the server's `server.http.retention` setting, and server restart clears status and idempotency records.
Optional status check:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
## References
In the `distributor` source tree:
- `docs/consumers/pkg-upload.md`: Go upload package workflow.
- `docs/consumers/pkg-bundle.md`: Go bundle package workflow.
- `docs/integrations/http-upload.md`: canonical HTTP upload wire contract.
- `docs/integrations/source-bundle.md`: canonical source bundle file-format contract.
The upstream canonical HTTP wire contract is
`docs/integrations/http-upload.md` in the Distributor repository. This page
documents only the portion exercised by Weatherreporter.

View File

@@ -1,90 +1,36 @@
# `pkg/bundle`
# Distributor Source Bundle Mapping
Audience: upstream Go producer developers and LLM coding agents using `distributor` source bundle helpers.
Weatherreporter uses the source-bundle format through Distributor's
`pkg/upload.UploadFiles` helper. It does not create bundle directories or call
`pkg/bundle` directly. The helper creates a temporary bundle, writes and
validates `manifest.json`, archives it, and removes the temporary bundle when
the upload call returns.
Import path:
## File Mappings
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
Every mapping pairs an operator-owned Markdown output with one bundle-relative
path. A single-report notification maps its published output to each rendered
path configured for that report. A batch notification combines mappings for
every included published output and rejects duplicate bundle paths.
`pkg/bundle` builds, writes, parses, and validates local source bundles. Use it directly when a producer writes bundles for `distributor` to discover, or when a producer wants to assemble and validate a bundle before using another transport.
The report source is the output selected for that command; the application does
not scan local directories. It renders notification paths after publication;
see the [operations guide](../../operations.md) and the
[Distributor adapter](../../internal/distributor-adapter.md) for the boundary.
The canonical source bundle file-format contract is [Source Bundle Contract](../integrations/source-bundle.md).
Bundle paths must be clean, relative, slash-separated paths. They cannot be
empty or absolute, contain backslashes, empty segments, `.` or `..`, or use
`manifest.json` or `.distributor.json` as a basename. The mapped source must be
a regular file. File mapping order is preserved and affects the bundle digest.
## Preferred Complete-Bundle Workflow
The bundle manifest uses schema version `1`, carries the rendered bundle ID and
creation time, and records each mapped file's path, SHA-256 digest, and size.
Destination routing, publication, and Distributor-managed destination state are
not source-bundle fields.
Use `WriteBundle` when producer-generated files live outside the final bundle root.
## Compatibility Reference
```go
manifest, err := bundle.WriteBundle(bundle.WriteBundleOptions{
Root: "/var/spool/distributor/weather/hourly-2026-06-07T15",
ID: "weather.hourly.brentwood",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = manifest
```
`WriteBundle` copies each source file into a staged bundle root, writes `manifest.json`, validates the staged bundle, and promotes it into place. Set `Overwrite: true` only when the producer intentionally replaces an existing bundle root.
## Existing Bundle Root Workflow
Use `BuildManifest` and `WriteManifest` when files are already staged under the final bundle root.
```go
root := "/var/spool/distributor/weather/hourly-2026-06-07T15"
manifest, err := bundle.BuildManifest(bundle.BuildOptions{
Root: root,
ID: "weather.hourly.brentwood",
Files: []string{"report.md", "summary.txt"},
})
if err != nil {
return err
}
if err := bundle.WriteManifest(root, manifest, bundle.WriteManifestOptions{}); err != nil {
return err
}
if err := bundle.ValidateBundle(root, manifest); err != nil {
return err
}
```
Use `Scan: true` instead of `Files` only when every valid regular file under the root should be included. Scan mode includes dotfiles, skips reserved metadata files, rejects symlinks, and sorts paths lexically.
## Paths And Ordering
Bundle paths are slash-separated paths relative to the bundle root.
Invalid paths include:
- empty paths;
- absolute paths;
- paths containing backslashes;
- `.` or `..` path segments;
- empty path segments;
- any basename of `manifest.json` or `.distributor.json`.
Explicit file lists preserve caller order. File order is part of the bundle digest, so producers should choose it deliberately and keep it stable.
The manifest `ID` is the logical source identity used by `distributor` destination comparison. Keep it stable for runs that should replace the same managed destination artifact. If every run uses a different manifest `ID`, `distributor` treats those runs as different sources and may report a destination conflict instead of replacing older output.
## Validation And Digest Helpers
Use `ValidateBundle` before handing an existing local bundle to another process. It verifies manifest semantics, file existence, regular-file type, file size, per-file SHA-256 digests, and bundle digest.
Useful helpers:
- `LoadManifest`: read `manifest.json` from a bundle root.
- `ParseManifest` and `MarshalManifest`: parse or write manifest bytes.
- `ValidateManifest`: validate manifest-only semantics.
- `FileDigest`, `BundleDigest`, and `ValidateDigest`: digest helpers for diagnostics and tests.
## Boundaries
`pkg/bundle` does not upload bundles, publish destinations, transform Markdown, select pipelines, configure credentials, or write destination state. Those concerns belong to `pkg/upload` or the `distributor` application.
The upstream canonical file-format contract is
`docs/integrations/source-bundle.md` in the Distributor repository. It defines
the complete manifest and archive format; this page records only the mapping and
path constraints Weatherreporter relies on.

View File

@@ -1,122 +1,56 @@
# `pkg/upload`
# Distributor Upload Client Contract
Audience: upstream Go producer developers and LLM coding agents submitting bundles to `distributor serve`.
Weatherreporter uses `gitea.maximumdirect.net/eric/distributor/pkg/upload` at
the pinned module version `v0.5.0`. It constructs one client per notification
attempt and calls `UploadFiles`, followed by `Status` for the accepted run.
Import path:
## Client And Upload
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/upload"
```
The adapter constructs the client with the prevalidated HTTP(S) endpoint,
bearer token, and an HTTP client whose timeout is the configured Distributor
timeout. The endpoint may include a path prefix but never userinfo, a query, or
a fragment. It passes no custom retry options, so the pinned client's defaults
apply: three attempts, 100 ms base delay, and one-second maximum delay.
The adapter bounds every response to 1 MiB before handing it to the pinned
client. A response above that boundary is rejected as a local overflow rather
than decoding or retaining a prefix.
`pkg/upload` is the producer-facing HTTP upload client. It builds on `pkg/bundle`, packages valid source bundles as gzip-compressed tar archives, sends bearer authentication, routes uploads to a configured pipeline, includes idempotency keys, and exposes a status polling helper.
For each notification, Weatherreporter calls `UploadFiles` with:
`UploadFiles` examples also use:
- the rendered pipeline ID;
- the rendered bundle ID as the source manifest ID;
- the report or batch generation time as `Created`;
- the published-output-to-bundle-path mappings described in the
[bundle mapping contract](pkg-bundle.md); and
- a rendered idempotency key.
```go
import "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
```
It leaves bundle validation enabled. `UploadFiles` creates the temporary source
bundle and sends it as a gzip-compressed tar archive; Weatherreporter does not
call `UploadBundle` or submit prebuilt bundle roots.
The canonical HTTP wire contract is [HTTP Upload API Contract](../integrations/http-upload.md).
## Retry, Conflict, And Status
## Client Construction
The pinned upload client retries only `503 Service Unavailable` and retryable
network failures. It does not retry successful `202` responses or other HTTP
errors. Because every Weatherreporter request supplies an idempotency key, a
retry keeps the same upload identity.
```go
client, err := upload.NewClient(upload.ClientOptions{
Endpoint: "https://distributor.example.com",
Token: token,
})
if err != nil {
return err
}
```
The client decodes the accepted upload result (`run_id`, `status`) and the run
status record. A `409` response is an upstream idempotency conflict; the
adapter translates it to its own conflict error without exposing the token.
`Endpoint` is the distributor server base URL. The client derives `/v1/pipelines/<pipeline-id>/upload` and `/runs/<run-id>`. `Token` is required and is sent as `Authorization: Bearer <token>`. Token values are redacted from client errors.
The adapter then calls `Status` for the accepted run. A terminal `failed`
status is a notification failure. A status lookup failure or a timeout before a
terminal status remains attached to the otherwise accepted upload as diagnostic
status information. Normal diagnostics use local status classifications; they
do not expose remote response text or the status report. Polling cadence, final
failure handling, and redaction are internal behavior documented in the
[Distributor adapter](../../internal/distributor-adapter.md) and
[application orchestration](../../internal/app-orchestration.md).
`HTTPClient` and `Retry` are optional. Defaults use a 30 second HTTP timeout and safe retry settings.
## Compatibility Reference
## Upload Producer Files
Use `UploadFiles` when the producer has generated output files but has not assembled a bundle directory.
```go
result, err := client.UploadFiles(ctx, upload.UploadFilesOptions{
PipelineID: "weather-hourly",
ID: "weather.hourly.brentwood",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
Files: []bundle.BundleFile{
{SourcePath: "/tmp/weather/report.md", Path: "report.md"},
{SourcePath: "/tmp/weather/summary.txt", Path: "summary.txt"},
},
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required and selects the configured distributor workflow for this upload. `ID` is the source manifest id and identifies the logical artifact inside that workflow. `UploadFiles` creates a temporary bundle, writes and validates a manifest, uploads the archive, and removes temporary files when the call returns. It does not write into producer source directories.
## Upload An Existing Bundle
Use `UploadBundle` when the producer already has a complete local bundle root containing `manifest.json`.
```go
result, err := client.UploadBundle(ctx, upload.UploadBundleOptions{
PipelineID: "weather-hourly",
Root: "/var/spool/weather/hourly-2026-06-07T15",
IdempotencyKey: "weather.hourly.brentwood.20260607T150000Z",
})
if err != nil {
return err
}
_ = result.RunID
```
`PipelineID` is required for existing bundles too. `UploadBundle` validates the local bundle by default and uploads only `manifest.json` plus manifest-listed files. Unlisted files are not uploaded.
## Result And Status
Upload success means the server returned `202 Accepted` after staging and validating the upload. It does not mean all configured destinations have published.
Poll status while the server retains the in-memory run record:
```go
status, err := client.Status(ctx, result.RunID)
if err != nil {
return err
}
if status.Status == "failed" {
return fmt.Errorf("distributor run failed: %s", status.Error)
}
```
Status values are `accepted`, `queued`, `running`, `succeeded`, and `failed`. Completed records expire according to `server.http.retention`; server restart clears run status and idempotency records.
## Idempotency And Retry
Every upload request includes `Idempotency-Key`.
If `IdempotencyKey` is omitted, the client generates a random 128-bit lowercase hexadecimal key for that upload operation and reuses it for retries within the same call. For cross-process retry safety, producers should pass a key derived from the producer run, such as `<bundle-id>.<run-id>`.
Do not reuse the same idempotency key for multiple distinct report generations. Reuse it only when retrying the exact same run with the same token, pipeline id, and source manifest. A repeated key with the same manifest in that scope returns the original accepted run instead of enqueueing another run; a repeated key with different content returns an idempotency conflict.
The client retries only safe cases:
- `503 Service Unavailable`;
- temporary network errors;
- ambiguous mid-upload failures.
It does not retry after `202 Accepted` and does not retry `400`, `401`, `403`, `404`, `409`, `413`, or `415`.
Detect conflicting key reuse with `errors.As`:
```go
var conflict *upload.IdempotencyConflictError
if errors.As(err, &conflict) {
return fmt.Errorf("idempotency key was reused for different bundle content: %w", err)
}
```
## Boundaries
`pkg/upload` does not configure server pipelines, choose destinations, wait for publication completion automatically, persist client queues, provide durable idempotency across server restarts, or expose destination state. It submits complete source bundles to the configured HTTP upload API.
The upstream package workflow is documented in
`docs/consumers/pkg-upload.md` in the Distributor repository. Weatherreporter
uses only the client construction, `UploadFiles`, retry/conflict behavior, and
`Status` operations described here.

View File

@@ -0,0 +1,82 @@
# Promptkit Integration
Weatherreporter uses Promptkit for all generated-text reports. The four logical prompts are `weather.daily_generated_text`, `weather.today_generated_text`, `weather.tomorrow_generated_text`, and `weather.hourly_generated_text`, each at version `2.1.0`. Their prompt assets, generated-text JSON Schemas, and Weatherreporter profile catalog are embedded by `internal/promptassets`.
## Logical Profile Catalog
Prompt definitions select a stable Weatherreporter profile ID. Each embedded
definition contains only its ID and one Promptkit base-profile reference; the
effective execution settings resolve from Promptkit's maintained catalog:
| Profile ID | Model | Reasoning effort | Timeout | Service tier | Default reports |
| --- | --- | --- | --- | --- | --- |
| `weather-light` | `deepseek/deepseek-v4-flash` | Provider default | 180 seconds | `flex` | Hourly |
| `weather-balanced` | `~google/gemini-flash-latest` | `high` | 240 seconds | `flex` | Daily, Today, Tomorrow |
| `weather-deep` | `~anthropic/claude-sonnet-latest` | `high` | 240 seconds | `flex` | None |
The `~` prefix is part of each OpenRouter rolling-alias model ID. The embedded
profiles intentionally omit endpoints, credentials, and execution settings;
Promptkit owns inherited resolution and its provider-native defaults.
Promptkit's built-in `rakestrawhome-gemma-4-31b` is also available for ordinary
and comparison selection and reports the `rakestrawhome` backend without
Weatherreporter-specific configuration.
## Selection And Active Execution
Before weather collection, Weatherreporter validates the report's exact generated-text report/schema/template catalog binding, prompt version and hash, output contract, and selected profile. Active profiles must resolve a nonblank model; an endpoint-only profile may intentionally have no backend identity. A nonblank `promptkit.profile` selects one profile ID for every report in the command; otherwise the prompt's declared default selects it. Promptkit resolves the selected definition in this order:
1. explicit in-memory profiles used by an embedding consumer or test;
2. the configured `profile_file` or `profile_dir`;
3. Weatherreporter's embedded fallback profiles; and
4. Promptkit's built-in catalog.
A source falls through only when the selected ID is absent. Promptkit resolves a
derived profile's base with the same source precedence, so a configured base
can shadow a built-in base. A missing, cyclic, malformed, or incomplete
selected inheritance chain is an error and does not fall back.
Profiles that require a direct API key are unsupported. Optional environment
credential sources are Promptkit runtime concerns and are not checked by
Weatherreporter during profile inspection. Active results retain the selected
logical profile ID and resolved backend and model. Ordinary errors, summaries,
logs, and outputs exclude endpoints, credentials, rendered messages, schemas,
request bodies, response bodies, and complete parameter maps.
Promptkit receives the YAML data package as an inline input and returns structured JSON that Weatherreporter validates before rendering its own Markdown template. Before accepting that JSON, Weatherreporter requires exactly one preparation callback and reconciles its prompt/profile/backend/model and rendered/input hashes with the inspected identity and completed result. The callback output contract and completed validation must use the report's expected JSON Schema mode and path. The package contains only reviewed prompt-facing warning summaries, never source transport or provenance details. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions. Ordinary generation errors disclose only the safe Weatherreporter category and optional HTTP status; provider code, type, and message are written only to the explicit secure failure-debug artifact.
Each embedded prompt permits one Promptkit-owned corrective generation after an
eligible failed or explicitly empty result. This is not an application retry:
Weatherreporter performs no provider retry, profile fallback, or request-level
output-contract override. Promptkit reports cumulative usage and the actual
number of corrective calls; repair exhaustion remains a completed validation
failure.
When capture is enabled, its preparation artifact projects a provider endpoint
to its scheme and host and retains only reviewed execution settings. Provider
extras and URL user information, paths, queries, and fragments are omitted.
Capture storage remains confined to the operator-selected debug root; an unsafe
filesystem path causes the requested execution to fail. Host availability and
operator handling are documented in the
[operations guide](../operations.md#optional-prompt-debug-capture).
## Comparison Execution
For `compare`, Weatherreporter validates the report's generated-text catalog
binding, one exact prompt, and every explicitly selected profile before weather
collection. It prepares one deterministic YAML
data package, retains immutable copies of the report inputs, and executes every
profile against the same exact data-package bytes. Each profile remains an
independent Promptkit execution: one provider, provenance, or validation failure does not
stop its peers, while caller cancellation applies to every in-flight execution.
Weatherreporter starts selected profile executions concurrently and does not
add an application-level concurrency limit. Promptkit owns backend capacity and
any profile or backend concurrency policy. A shared Weatherreporter executor
must safely accept those concurrent `Execute` calls. The durable comparison output and
its compatibility rules are defined by the
[comparison bundle contract](comparison-bundle.md); the user-facing command
contract is in the [CLI reference](../cli.md).
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. Promptkit results are accepted only when their raw JSON is at most 64 KiB; the adapter drops larger results before copying them into Weatherreporter's execution values or debug artifacts. The validator also limits total generated prose to 20,000 characters, with 4,000-character summary and timing fields, a 12,000-character Hourly discussion, and at most 12 day-style paragraphs of 4,000 characters each. Prompts return an empty string for `precipitation_timing` when the deterministic package contains no precipitation windows.
Prompt/profile configuration and the maintained local override example are owned by the [configuration reference](../config.md). Adapter construction and mapping are documented in the [Promptkit adapter internals](../internal/promptkit-adapter.md).

View File

@@ -1,118 +0,0 @@
# Scriptorium Integration
This document describes the external Scriptorium CLI contract used by
`weatherreporter`.
## Purpose
`weatherreporter` invokes Scriptorium as a subprocess to preflight prompt input
and generate report artifacts. This page documents the CLI surface the adapter
uses, not the full Scriptorium product.
## Commands Used
Render preflight:
```bash
scriptorium render \
--prompt <prompt_id> \
--input data_package=<path> \
--format json
```
Report generation:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <artifact_path>
```
Structured generated-text report generation uses the same command shape:
```bash
scriptorium run \
--prompt <prompt_id> \
--input data_package=<path> \
--out <generated_text_raw_path>
```
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured YAML created by
`internal/promptinput`; module snapshots remain separate JSON artifacts for
inspection and Recent Changes.
For generated-text reports, Scriptorium selects the structured output schema
from the prompt configuration associated with the prompt ID. `weatherreporter`
does not pass `--format`, schema path, or JSON Schema flags for structured
generation.
## Configured Arguments
The adapter can prepend configured flags before prompt-specific arguments:
- `--config <path>` from `scriptorium.config_path`
- `--profile <profile>` from `scriptorium.profile`
It appends `scriptorium.extra_args` after the built-in arguments. Extra
arguments are passed directly as argv items.
`scriptorium.binary` selects the executable name or path. If unset inside the
adapter, it falls back to `scriptorium`.
## Execution Behavior
The adapter runs Scriptorium without shell interpolation. Arguments are passed
through `exec.CommandContext`.
`scriptorium.timeout` limits each subprocess call when configured. Context
cancellation or timeout returns an execution error.
Stdout and stderr are captured separately. Each stream is capped at 1 MiB and
the result records whether truncation occurred.
## Results
Render results include:
- full argv recorded as `command`
- stdout
- stderr
- exit code
- truncation flags when applicable
Run results include the same fields plus the requested output path. Structured
generated-text run results use the same captured fields and output-path
recording, with the output path pointing at the raw generated-text JSON
artifact.
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. For direct Markdown reports, Scriptorium writes the
managed Markdown artifact to the `--out` path. For generated-text-template
reports, Scriptorium writes raw JSON to the `--out` path; later
weatherreporter workflow steps validate those bytes and render Markdown from an
embedded template.
## Failure Behavior
The adapter validates required request fields before starting Scriptorium:
- prompt ID
- data package path
- output path for `run` and structured generated-text `run`
Nonzero exits return both the captured result and an error containing the exit
code and stderr. A `run` exit code such as `2` is still treated as an error by
the adapter, even if Scriptorium wrote output to the requested artifact path.
Subprocess start failures, context cancellation, and timeouts return errors
without fabricating a successful result.
## Security Notes
- The adapter does not invoke a shell.
- Generated artifacts, rendered prompt context, stdout, and stderr can contain
operationally sensitive data.
- API keys should be provided through the Scriptorium environment or
Scriptorium configuration, not through `weatherreporter` CLI arguments.

View File

@@ -1,26 +1,56 @@
# Weather API Integration
This document describes the external Weather API contract used by
`weatherreporter`.
Weatherreporter fetches normalized weather inputs from a configured Weather API
base URL. This guide defines the HTTP contract the service must satisfy; it is
not a general Weather API reference. Configuration values are defined in the
[configuration reference](../config.md). Normalization and collection behavior
are documented in [Weather data internals](../internal/weather-data.md) and
[Collection internals](../internal/collect.md).
## Purpose
## Base URL And Requests
`weatherreporter` uses a configured Weather API base URL to fetch normalized
weather source data and assemble a `weatherdata.Bundle`. This is an integration
contract for the project adapter, not a complete public API reference for the
upstream service.
`weather_api.base_url` must be an absolute HTTP(S) URL. Weatherreporter joins
each endpoint path to the configured base URL path, so a service hosted under a
path prefix must keep that prefix available. Requests use `GET` and carry the
configured timeout on every HTTP attempt.
## Base URL
Every request sends `format` and, except where noted below, `units`. The
configured format must be `json`.
`weather_api.base_url` must be an absolute URL. Adapter requests join this base
URL with the endpoint paths listed below. Generation and explicit bundle fetches
fail before any HTTP request when the base URL is empty or not absolute.
Before retrieving sources, Weatherreporter requests `/conditions/current` with
the same `format`, `units`, and `precision` query parameters used for current
conditions. After a readable 2xx response, it retains that response for the
normal current-conditions source step rather than making a second identical
request. Failure after the readiness request's internal retry budget stops the
fetch before source requests begin.
The HTTP client uses `weather_api.timeout`.
## Endpoints And Query Parameters
The adapter makes one source request for each endpoint, subject to retry on
transient failures. A successful readiness request supplies the current
conditions source response. The remaining independent source requests run
concurrently, then their results are processed in the source order shown below.
This keeps source provenance, missing-source policy, and surfaced errors
deterministic regardless of response order.
| Source | Endpoint | Query parameters | Availability |
| --- | --- | --- | --- |
| Observations | `/observations` | `format`, `units`, `precision` | Optional |
| Current conditions | `/conditions/current` | `format`, `units`, `precision` | Optional |
| Hourly forecast | `/forecast/hourly` | `format`, `units`, `precision`, `tz` | Required |
| Narrative forecast | `/forecast/narrative` | `format`, `units`, `precision`, `tz` | Optional |
| Active alerts | `/alerts/active` | `format`, `units` | Optional; `data: null` means checked with no active alerts |
| Forecast discussion | `/discussion` | `format`, `units`, `tz` | Optional |
| Weather story | `/weatherstories/latest` | `format` | Optional |
| SPC convective outlooks | `/outlooks/convective` | `format`, `tz` | Optional; non-null empty lists are checked empty data |
`precision` comes from `weather_api.precision`; `tz` comes from
`weather_api.timezone`. Weatherreporter does not call day-slice forecast or
discussion-subsection endpoints.
## Response Envelope
Every response used by the adapter must be JSON with a top-level `data` field:
Each endpoint response must be JSON with a top-level `data` member:
```json
{
@@ -28,171 +58,97 @@ Every response used by the adapter must be JSON with a top-level `data` field:
}
```
For most sources, `data: null` is treated as a missing source. Missing optional
sources follow the configured missing-source policy. Missing hourly forecast
data fails bundle fetching because hourly periods are required for report
generation.
An absent `data` member is treated as a missing source. For ordinary sources,
`data: null` is also missing. The active-alert exception is listed above: its
explicit `null` payload represents an empty alert result.
`/alerts/active` is the exception: a successful response with `data: null`
means the endpoint was checked and there are no current active alerts. The
adapter records a non-missing alerts source and an empty alert run.
Hourly forecast data must be present and contain at least one `period`. Every
hourly period needs nonzero `startTime` and `endTime` values, with `endTime`
after `startTime`; a missing, malformed, empty, or invalidly bounded hourly
product fails collection. The remaining sources follow the configured
missing-source policy. Under `error`, collection fails; under `warn`, the
source is omitted and an inspectable warning is recorded; under `none`, the
source is omitted without a warning. A per-source policy overrides the default.
See [Configuration](../config.md) for policy settings and [Weather data
internals](../internal/weather-data.md) for recorded source metadata.
For `/outlooks/convective`, `data: null` means no latest run is available and
follows missing-source policy. A non-null run with empty `outlooks` and
`discussions` arrays is checked empty data, not a missing source.
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
Malformed `data` for an optional source follows its missing-source policy.
Malformed JSON envelopes, non-2xx statuses, and response read failures include
endpoint context in returned errors. Decode errors include source context when
they fail the fetch; optional malformed sources follow the missing-source policy.
## Payload Fields Used
## Query Parameters
Weatherreporter decodes only the fields below; additional upstream fields are
ignored. Timestamps must be JSON values accepted by Go's `time.Time` decoder.
The adapter sends these query parameters:
### Observations And Current Conditions
- `format`: from `weather_api.format`; configuration validation requires `json`
- `units`: from `weather_api.units`
- `precision`: from `weather_api.precision` on observations, current
conditions, hourly forecast, and narrative forecast requests
- `tz`: from `weather_api.timezone` on hourly forecast, narrative forecast,
discussion, and SPC convective outlook requests
`/observations` uses `stationId`, `stationName`, `timestamp`, `conditionCode`,
`isDay`, `textDescription`, `temperatureC`, `temperatureF`, `dewpointC`,
`dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`, `windGustMph`,
`windDirectionDegrees`, `barometricPressurePa`, `barometricPressureInHg`,
`visibilityMeters`, `visibilityMiles`, `relativeHumidityPercent`,
`apparentTemperatureC`, `apparentTemperatureF`, and `presentWeather`.
Alerts do not receive `precision` or `tz`. Weather story requests receive only
`format=json`. SPC convective outlook requests receive only `format=json` and
`tz`; they do not receive `units` or `precision`.
`/conditions/current` uses `conditionText`, `isDay`,
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
## SPC Convective Outlooks
### Hourly And Narrative Forecasts
The adapter fetches SPC convective outlook data from:
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
`elevationFeet`, and `periods`.
```text
GET /outlooks/convective?format=json&tz=<weather_api.timezone>
```
Each `periods` item uses `startTime`, `endTime`, `name`, `isDay`,
`conditionCode`, `textDescription`, `temperatureC`, `temperatureF`,
`temperatureCMin`, `temperatureFMin`, `temperatureCMax`, `temperatureFMax`,
`dewpointC`, `dewpointF`, `windSpeedKmh`, `windSpeedMph`, `windGustKmh`,
`windGustMph`, `windDirectionDegrees`, `barometricPressurePa`,
`barometricPressureInHg`, `visibilityMeters`, `visibilityMiles`,
`apparentTemperatureC`, `apparentTemperatureF`, `cloudCoverPercent`,
`probabilityOfPrecipitationPercent`, `precipitationAmountMm`,
`precipitationAmountIn`, `snowfallDepthMM`, `snowfallDepthIn`, `uvIndex`, and
`relativeHumidityPercent`.
The response uses the standard `data` envelope. `data: null` means no latest
run is available and follows missing-source policy. A non-null object with
empty `outlooks` and `discussions` arrays is accepted as checked empty data.
### Alerts, Discussion, And Weather Story
Run fields consumed by weatherreporter:
`/alerts/active` uses the `asOf` timestamp and keeps each item in `alerts` as
an alert payload. Weatherreporter does not require a separate alert-item schema
at this integration boundary.
- `locationId`
- `locationName`
- `asOf`
- `issuedAt`
- `updatedAt`
- `product`
- `outlooks`
- `discussions`
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
section uses `qualifier`, `text`, and `issuedAt`.
Outlook fields consumed:
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
- `id`
- `provider`
- `product`
- `day`
- `outlookType`
- `label`
- `labelText`
- `forecaster`
- `severityRank`
- `validFrom`
- `validTo`
- `issuedAt`
- `expiresAt`
- `sourceUrl`
- `imageUrl`
- `containsLocation`
- `geometry`
### SPC Convective Outlooks
Discussion fields consumed:
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
- `day`
- `headline`
- `summary`
- `discussion`
- `updatedAt`
Each outlook uses `id`, `provider`, `product`, `day`, `outlookType`, `label`,
`labelText`, `forecaster`, `severityRank`, `validFrom`, `validTo`, `issuedAt`,
`expiresAt`, `sourceUrl`, `imageUrl`, `containsLocation`, and GeoJSON
`geometry`. Each discussion uses `day`, `headline`, `summary`, `discussion`,
and `updatedAt`.
GeoJSON `geometry` is decoded into collected weather facts and persisted in
bundle/debug artifacts, but prompt-facing SPC module output omits geometry.
## Timeouts, Retries, And Failures
## Endpoints Used
The configured Weather API timeout applies to each warmup and source HTTP
attempt. Weatherreporter retries transient transport and response-read failures
and these response statuses: `408`, `429`, `500`, `502`, `503`, and `504`.
It does not retry other HTTP statuses, malformed envelopes, missing data, or
payload decoding failures. A canceled context also stops an in-progress retry
delay.
The adapter fetches these endpoints once per bundle:
The adapter accepts response bodies up to 10 MiB and rejects larger bodies
before decoding. A non-2xx response reports its relative endpoint and status,
without including upstream response text. Request construction, response-limit,
read, and decode failures include endpoint context in their errors.
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
- `/weatherstories/latest`
- `/outlooks/convective`
`weatherreporter` does not call day-slice forecast endpoints or discussion
subsection endpoints. Report-period selection and daypart summarization happen
inside Go after the full hourly and narrative products are fetched.
## Required And Optional Sources
Hourly forecast is required:
- `data: null` for `/forecast/hourly` fails the fetch.
- an hourly forecast with no `periods` fails the fetch.
- malformed hourly data fails the fetch.
Other fetched sources are optional and follow `missing_source.default` or a
source-specific `missing_source.sources` policy:
- `observations` for `/observations`
- `current` for `/conditions/current`
- `narrative` for `/forecast/narrative`
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
- `weather_story` for `/weatherstories/latest`
- `spc_convective_outlooks` for `/outlooks/convective`
Policy behavior:
- `error`: fail the fetch for that source
- `warn`: omit the source data, add a warning, and continue
- `none`: omit the source data and continue without a warning
For `/alerts/active`, an HTTP error or missing `data` field still fails or
follows the relevant error path, but explicit `data: null` is not a
missing-source condition.
For `/outlooks/convective`, a non-null data object with empty outlook and
discussion arrays is accepted as checked empty data.
## Source Identity
For source payloads accepted into the bundle, including the explicit `null`
alerts payload, the adapter records:
- source name
- endpoint path
- query parameters sent
- fetch time
- source issue and update timestamps when present in the payload
- SHA-256 hash of the compact raw `data` JSON
Warnings are recorded both on the affected source and on the bundle-level
warnings list.
## Compatibility Assumptions
The adapter expects payload fields compatible with the internal weather data
bundle types in `internal/weatherdata/bundle.go`, including:
- observation timestamps and observation values
- current condition values
- forecast run metadata and `periods`
- active alert run data
- discussion metadata, key messages, and short/long-term section text
- latest weather story title, description, timing, priority, order, alt text,
and download URL
- SPC convective outlook run metadata, outlooks, discussions, and GeoJSON
geometry
The adapter intentionally keeps upstream transport and envelope details inside
`internal/adapters/weatherapi`; downstream packages consume the normalized
bundle.
Retry counts and delays are adapter behavior rather than Weather API request
parameters. Do not depend on a particular attempt count when implementing the
service.

View File

@@ -1,188 +1,72 @@
# App Orchestration Internals
# Application Orchestration Internals
This document describes the workflow coordinator in `internal/app`.
`internal/app` owns stateless report generation, batch execution, comparison
orchestration, atomic output publication, and notification coordination after
`internal/cli` has parsed arguments and loaded configuration. The user contract
is owned by the [CLI reference](../cli.md) and [operations guide](../operations.md).
## Purpose
## Single-Report Flow
`internal/app` coordinates the top-level use cases after CLI parsing and config
loading are complete. It resolves report definitions, fetches weather data,
builds collected and derived facts, builds module snapshots and prompt-input
artifacts, invokes Scriptorium through the adapter boundary, optionally
notifies distributor through an app-owned notifier boundary, persists managed
state, runs batches, and reads existing artifacts for inspection.
`GenerateDetailed` resolves the requested report and output destination before initializing an optional explicit debug writer. An explicit output file wins; otherwise the configured output directory is used, falling back to the captured working directory. Output preflight validates the final filename, permits only an absent or regular final destination, and validates the bounded same-directory temporary form without creating a missing parent. It then validates the report's generated-text catalog binding, exact Promptkit prompt, and selected profile before collecting weather data. Profile inspection requires a model, permits an empty backend identity for endpoint-only profiles, and leaves inherited resolution and optional credential sources to Promptkit. The resolved profile, backend, model, and actual repair count (once a completed execution exists) are carried in the active result; the configured repair budget remains part of the exact prompt contract.
## Inputs And Outputs
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit only against the inspected prompt and profile, reconciles the preparation callback and completed result with that identity and the prepared report schema, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` writes the completed Markdown through a same-directory temporary file, rechecks the final destination and context after close and immediately before the atomic rename. Only after that write succeeds does single-report notification run.
Inputs:
Failures return an active partial result with safe identity, profile, warning, validation, debug, and output information when available. After rendering and immediately before publication, the workflow checks for cancellation or deadline expiry. Any failure before publication leaves an existing destination unchanged. A notification failure retains the newly published output.
- `GenerateRequest` for one report command
- `BatchRequest` for morning or evening batch commands
- `FetchBundleRequest` for explicit bundle fetch and save workflows
- `ReportRequest` for single-report generation
- resolved report definitions from `internal/report`
- weather data bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state`
- optional renderer, notifier, and state-store fakes for tests
## Batches
Outputs:
`RunBatchDetailed` selects an explicit output directory first, otherwise the configured directory and then the captured working directory. It does this before creating at most one explicit debug writer or validating generated-text catalog, prompt, and profile candidates for the selected batch. It collects once, calculates the data-dependent plan, then validates and retains the final output path for every planned report before invoking the same generation core sequentially.
- generated report results with JSON module snapshot, YAML data package,
preflight, report, metadata, prior snapshot, Recent Changes, Scriptorium
result details, generated-text artifact paths when applicable, and
notification result when attempted
- batch summaries with per-report status, artifact paths, error text, and
notification outcome when attempted
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, module snapshots, data
packages, prior snapshots, and source provenance
Each item has an independent result. A failed item does not stop later items; successful items retain their published output paths. Per-report notification is suppressed during a batch. Batch notification runs only after every planned report has published successfully. It is skipped when any item failed. Batch result counters count report items only; a batch notification failure is represented by the top-level notification result and still produces a failed batch outcome.
## Boundaries
Cancellation and deadline expiry stop the sequential loop before another report
starts. Completed report results and published paths remain successful; the
interrupted and unstarted planned reports have `canceled` status and are counted
separately from failed reports. The batch notification result records that
delivery was skipped, and the returned error retains the original context cause
for callers and CLI projection.
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, own fact
derivation algorithms, define report periods, compare rendered Markdown, or
construct Scriptorium argv.
## Comparisons
Report selection and report identity policy come from `internal/report`.
Collected and derived fact contracts come from `internal/facts`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Distributor
upload behavior stays in `internal/adapters/distributor`. Filesystem layout and
persisted metadata stay in `internal/state`.
`CompareDetailed` validates ordered explicit profile IDs, resolves the report,
and preflights the exact bundle destination before initializing optional prompt
debugging, prompt inspection, or collection. It then validates the report's
generated-text catalog binding, inspects the one prompt and every selected
profile, collects once, and delegates shared report
construction to the prepared-report flow. It does not accept a notifier.
## Data Flow Terms
Once the destination is resolved, the partial result retains its absolute
output directory even when later preflight, debug initialization, inspection,
collection, or preparation fails. Every initialized result is finalized with a
finished timestamp. If prompt inspection succeeds before a later profile
inspection fails, the partial result retains the resolved prompt ID, version,
and hash. Artifact paths are added only after publication commits.
- `CollectedFacts` are normalized source facts fetched once from Weather API
and made available to derivation and module builders.
- `DerivedFacts` are deterministic calculations over collected facts, the
resolved valid period, daypart configuration, and report-specific windows.
- `module.Output` values are ordered deterministic stanzas built from collected
and derived facts for prompt input and inspection.
- `GeneratedText` is structured prose returned by Scriptorium for
generated-text-template reports and validated by `internal/generatedtext`.
- `RenderContext` is the typed template input built from report metadata,
module outputs, and validated generated text before Markdown rendering.
The comparison execution core starts each inspected profile independently,
keeps results in selection order, and waits for all started work. Every profile
reconciles its callback and completion provenance before its JSON can be
rendered. Independent profile failures are recorded and do not stop peers; a
completed profile failure remains recorded if cancellation happens later.
Context cancellation marks only unfinished or cancellation-terminated work and
prevents publication. Details of
prepared values, execution and debugging, and publication are documented in [prepared report
internals](prepared-report.md), [comparison execution
internals](comparison-execution.md), and [comparison publication
internals](comparison-publication.md).
## Config Fields Used
When publication has committed its new bundle, application results contain the
absolute manifest, data-package, and successful report paths even if removal of
the previous sibling backup then fails. That cleanup failure is still returned
as an operational error rather than treating the new bundle as unpublished;
the returned error identifies the observed recovery state and includes a path
only when cleanup left a sibling behind.
- `weather_api.*` for Weather API client construction and module metadata
- `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds
- `notify.distributor.*` for optional notification after report generation
## Boundaries And Verification
Output copy flags are command request fields. They are not configuration
defaults.
The package does not parse flags, load YAML, implement transport, construct provider SDKs, or define report-period policy. Prompt, profile, weather, and Distributor implementations remain behind project-owned contracts.
## Generation Workflow
Focused checks:
Single-report generation shares this setup:
1. Resolve the command report to a `report.Resolved` value.
2. Create or use a filesystem store.
3. Locate any prior compatible snapshot through `internal/state`.
4. Fetch a Weather API bundle.
5. Build collected and derived facts once.
6. Execute configured modules and save the module snapshot.
7. Compute Recent Changes from structured prior and current module snapshots.
8. Build and save the YAML Scriptorium `data_package`.
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
For `scriptorium_markdown` reports, generation then:
12. Runs Scriptorium report generation to the managed report path.
13. Copies the managed report to the requested `--out` path when provided.
14. Saves metadata with the managed report path.
15. If distributor notification is enabled, notifies using the managed report
path as the source file.
16. Saves a distributor notification debug artifact and updates metadata with
its path.
For `generated_text_template` reports, generation then:
12. Runs structured Scriptorium generation to the raw generated-text JSON path.
13. Saves the structured Scriptorium run result.
14. Validates and saves normalized generated text.
15. Builds and saves a typed render context.
16. Renders Markdown from the embedded template to the managed report path.
17. Saves final metadata with generated-text paths, render context path, schema
ID, and managed report path.
18. Copies the managed report to the requested `--out` path when provided.
19. If distributor notification is enabled, notifies using the managed report
path as the source file.
20. Saves a distributor notification debug artifact and updates metadata with
its path.
If render preflight returns both a result and an error, preflight JSON and
metadata are persisted before the error is returned. If Scriptorium report
generation returns an error after writing output, the managed report and
metadata remain inspectable. Notification is not attempted after Weather API,
module snapshot, prompt input, render, Scriptorium run, or metadata-save
failures.
Generated-text report failures are returned with report ID, RunID, and the
failed operation. When available, the app preserves the latest generated-text
artifacts already reached by the workflow: preflight output, structured run
result, raw generated text, validated generated text, and render context.
When notification is attempted, the debug artifact records request identity,
including rendered pipeline ID, bundle paths, accepted upload fields,
distributor status fields, raw status report JSON when available, and redacted
failure context.
`--out` copies are never used as notification source files.
## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Tomorrow Report. Batch output copy names come
from report definitions. Batch generation continues independent reports after a
failure, records each result, writes compact status lines to stderr, emits a
JSON summary to stdout, and returns an aggregate error when any report failed.
When notification is enabled, each successfully generated report is notified
independently. Notification failure marks that report failed, records
notification fields in the batch result, and does not stop later reports.
`--out-dir` copies are never used as notification source files.
## Inspection Workflow
Inspection workflows load existing filesystem state only. They do not fetch
weather data or invoke Scriptorium. Run-specific inspect commands share the same
store and metadata lookup path, then load the requested artifact or derived
inspection view.
## Failure Behavior
- Resolve errors stop the requested workflow before fetching weather data.
- Weather API and module execution errors stop that report before Scriptorium
runs.
- Prompt input validation fails before render preflight.
- Render and run errors preserve Scriptorium stderr and exit-code context.
- Generated-text report errors preserve available intermediate artifacts and do
not create extra output copies.
- Notification errors are wrapped with report ID, RunID, and managed report path
context and are recorded separately in batch results.
- Metadata and artifact path errors include filesystem context.
- Batch failures are recorded per report and surfaced through an aggregate
batch error.
## Tests
Inspect:
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
- `internal/state/filesystem_test.go`
## Invariants
- Report behavior is resolved through `internal/report`.
- Generated reports use the same app request and result types regardless of
report ID.
- Render preflight precedes Scriptorium report generation.
- Generated-text reports render Markdown from a curated render context, not from
a raw data package.
- Recent Changes are computed from structured module snapshots.
- Metadata links artifacts produced for a run.
- Distributor notification maps the managed Markdown report path to configured
bundle paths; extra output copies are not upload sources.
```sh
go test ./internal/app ./internal/collect
```

View File

@@ -1,140 +1,103 @@
# Module Builder Internals
This document describes module builder behavior in `internal/briefing`.
`internal/briefing` builds typed module outputs from resolved report context,
collected facts, and derived facts. It owns the module registry, including
module support, fact requirements, option types, missing-data policy, builders,
and prompt-export hooks. It does not collect data, derive periods, write a
snapshot, construct YAML, invoke Promptkit, or render a report.
## Purpose
## Registry and construction
`internal/briefing` turns report metadata, collected weather data, and derived
forecast facts into prompt-facing module outputs. The package also owns the
module registry used to validate report composition and config overrides.
Every `ModuleDefinition` declares an ID, stanza name, default option value,
required collected and derived facts, supported report IDs, missing-data
behavior, duplicate policy, builder, and optional prompt exporter. The
briefing-owned fact-requirement vocabulary supplies each prerequisite's stable
identity, category, and availability predicate; registry construction rejects
unknown requirements and requirements listed under the wrong category.
Module outputs are structured prompt inputs. They are not rendered report prose
and they are not persisted by this package.
`BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior:
## Inputs And Outputs
- `omit` returns no output for unavailable optional facts;
- `error` returns the missing fact requirements; and
- `empty` allows the builder to emit an explicit checked-empty value.
Inputs:
Unsupported `warn` behavior, missing builders, duplicate registry IDs or
stanza names, output ID or stanza mismatches, and exporter failures all return
errors with module context. A successful builder gets a pass-through prompt
value unless its definition supplies an exporter.
- resolved report definition, generation time, timezone, and valid period
- collected facts built from `weatherdata.Bundle`
- derived daily, daypart, precipitation, alert, and storm-window facts where
required
- configured units, timezone, and descriptive location context
- typed module options from report defaults or config overrides
## Built value families
Outputs:
Source-oriented builders shape report metadata, current conditions, narrative
and hourly forecasts, alert digest, SPC outlooks and discussion, area forecast
discussion, and weather story. Derived builders shape daily and daypart
summaries, precipitation timing, outdoor windows, and the report-specific
Daily, Today, and Tomorrow planning values.
- `ModuleDefinition` values with module ID, stanza name, option type,
supported reports, fact requirements, missing-data behavior, and builder
- `module.Output` values for source-oriented stanzas:
`metadata`, `current_conditions`, `narrative_forecast`, `hourly_forecast`,
`alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`
- `module.Output` values for derived stanzas:
`derived_daily_summary`, `derived_daypart_summaries`, `precip_timing`,
`outdoor_windows`, and `tomorrow_planning`
The daily summary preserves generic feels-like values as
`apparent_temperature_max_f`; it does not label them as a heat index. Daypart
temperature phrases retain below-zero meaning, including through temperature
trends that cross zero. Outdoor windows add a 25-point risk penalty and an
explicit reason for each snow, ice, or fog indicator. Equal scores retain input
order for both best and worst windows.
Every registered composition entry has a builder. Unknown or unimplemented
module IDs fail validation instead of being skipped.
The module registry preserves rich values for templates and snapshots while
curating prompt exports where needed. In particular, source warnings are a
metadata summary, checked-empty alerts and SPC outlooks remain distinct from
missing sources, and prompt-safe SPC values omit geometry and other
template-only or source details. The complete module composition is in
[module internals](module.md); fact derivation is in [fact contracts](facts.md).
Alert digests are built from selected alert items and source provenance, not a
provider response envelope.
Tomorrow Report supports the Daily-style civil-day modules plus
`tomorrow_planning` and `hourly_forecast`; those outputs feed the Tomorrow
GeneratedText prompt package and embedded Markdown template.
Derived daypart-summary maps use the forecast package's canonical daypart
identity and reject any collision instead of replacing an earlier value.
Planning applies the same identity when recognizing morning, afternoon,
evening, and overnight windows; display labels remain separate and preserve
configured text with rune-safe first-letter capitalization.
Hourly Report supports source and valid-period modules that operate over its
rolling six-hour period: `metadata`, `current_conditions`, `hourly_forecast`,
`precip_timing`, `alert_digest`, `spc_convective_outlooks`,
`area_forecast_discussion`, `spc_convective_discussion`, and `weather_story`.
It does not support daily/daypart-only modules such as
`derived_daily_summary`, `derived_daypart_summaries`, `outdoor_windows`, or
`tomorrow_planning`.
The embedded SPC background-definition asset records its authoritative sources,
source update dates, and maintainer review schedule. Its categorical
`official_description` values transcribe the [SPC convective-outlook risk
table](https://www.spc.noaa.gov/about/outlooks/); its Conditional Intensity
Group entries follow the [SPC conditional-intensity
reference](https://www.spc.noaa.gov/exper/conditional-intensity-information).
`plain_language` values are Weatherreporter summaries. Weatherreporter
maintainers review the asset annually and whenever either source changes.
Prompt-facing module values use local, human-readable date and time labels
where the LLM is expected to reason about report content. Canonical timestamps
remain in report metadata, source provenance, and integration artifacts.
`area_forecast_discussion` accepts an optional typed section filter. Accepted
typed option pointers are normalized to the declared value type before builder
execution. Planning modules are report-specific: `daily_planning` supports Daily,
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
## Boundaries
## Missing data and boundaries
- This package selects and shapes already-collected weather facts for prompts.
- It validates module composition against report compatibility and option
types.
- It does not fetch weather data, compare prior snapshots, write module
snapshots, build YAML data packages, invoke Scriptorium, or write workflow
metadata.
Optional current conditions, narrative products, discussions, and weather
stories may be omitted. A weather story is usable only when it has non-blank
displayable content (title, description, alternate text, or download URL) or a
valid start/end period; otherwise collection applies its optional-source policy
and the module is omitted. Required derived modules fail when their declared facts
are unavailable. Empty alert and outlook runs can still produce checked-empty
modules. SPC discussion is omitted unless a retained categorical outlook meets
the package's severity criterion and matching discussion text exists.
## Config Fields Used
`ModuleContext` carries the effective units, timezone, location context, and
prepared identity. Report preparation creates that one `PreparedIdentity` for
the shared report identity, timing, configuration context, and source warnings
before module construction. The metadata module projects its matching fields
from that value and retains its prompt-safe shape. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by [prompt
input](prompt-input.md).
The app layer passes effective units, timezone, and location context into the
module context. `internal/facts` consumes daypart configuration before module
builders run. Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
## Verification and invariants
`area_forecast_discussion` uses optional `sections` configuration to include a
subset of discussion fields. Hourly Report defaults this module to
`key_messages` and `short_term`.
Focused tests cover source and derived values, registry validation, option
handling, prompt exporters, support rules, and missing-data behavior:
`spc_convective_outlooks` uses collected SPC run metadata and derived
report-period outlooks. It emits `checked: true` for a successfully fetched
empty run, reports `outlook_count`, and includes prompt-facing outlook fields
such as risk label, `period_begins`, `period_ends`, image URL, and whether the
outlook contains the configured location. It does not emit GeoJSON geometry,
source URL, expiration time, or severity rank.
```sh
go test ./internal/briefing
```
Prompt-facing module intervals use friendly local `period_begins` and
`period_ends` labels. Canonical report metadata, source provenance,
`issued_at`, `updated_at`, and point-in-time fields remain separate.
`spc_convective_discussion` uses the same derived report-period outlooks and
discussion records. It is omitted unless at least one retained categorical
outlook for the same SPC day has severity rank `3` or higher and matching
discussion text exists.
## External Adapters Used
None directly.
## State Or Manifest Behavior
None. `internal/app` collects module outputs into a `module.Snapshot`, and
`internal/state` persists that snapshot.
## Skip And Resume Behavior
None. Builders either emit a module output, omit optional unavailable data, or
return an error for invalid required inputs.
## Failure Behavior
- Required derived modules return errors when their dependent facts are not
available.
- Module registry construction rejects duplicate module IDs and duplicate
stanza names.
- Composition validation rejects unknown modules, duplicate modules,
incompatible report/module combinations, duplicate stanza names, and invalid
option shapes.
- Source-oriented module builders omit missing optional current conditions,
forecast discussion, and weather story stanzas.
- Alert digest output distinguishes checked empty alert data from missing alert
source data.
- SPC convective outlook output distinguishes checked empty outlook data from
missing outlook source data and omits GeoJSON geometry from prompt-facing
fields.
- SPC convective discussion output is omitted unless a retained outlook has
severity rank `3` or higher and matching discussion text is available.
## Tests
Inspect:
- `internal/briefing/base_modules_test.go`
- `internal/briefing/derived_modules_test.go`
- `internal/briefing/modules_test.go`
- `internal/app/app_test.go`
## Invariants
- Module outputs contain structured weather facts and source context.
- Common metadata includes RunID, report ID, prompt ID, valid period, source
provenance, source hashes, source warnings, and configured prompt location.
- Prompt input packaging and Scriptorium execution remain outside this package.
Builders emit structured facts, never report prose. The app collects their
outputs into an in-memory module snapshot for prompt input and rendering.

View File

@@ -1,75 +0,0 @@
# Changes Internals
This document describes structured Recent Changes comparison.
## Purpose
`internal/changes` compares current and prior module snapshots and emits
compact change records for prompt input data packages.
## Inputs And Outputs
Inputs:
- prior module snapshot
- current module snapshot
- comparison thresholds from configuration
Outputs:
- ordered `changes.Change` items with type, message, previous value, and current
value where useful
## Boundaries
- This package compares structured module snapshot data only.
- It does not read filesystem state, find prior snapshots, render Markdown,
invoke Scriptorium, or compare generated report text.
## Config Fields Used
The app maps these fields into comparison thresholds:
- `recent_change.temperature_degrees`
- `recent_change.precip_probability_points`
- `recent_change.wind_gust_miles_per_hour`
- `recent_change.precip_timing_shift_minutes`
## External Adapters Used
None.
## State Or Manifest Behavior
None directly. The app loads prior module snapshots through `internal/state`
before calling comparison functions.
## Skip And Resume Behavior
No resume behavior. When the app has no prior comparable snapshot, it sends an
empty Recent Changes list without calling a comparison function.
## Failure Behavior
- Daily comparison requires `derived_daily_summary` and
`derived_daypart_summaries` stanzas. It also uses `alert_digest` and
`precip_timing` when present.
- 3-Day comparison requires `derived_daypart_summaries`.
- Weekend comparison requires `derived_daypart_summaries`.
- Storm Report comparison returns no changes.
## Tests
Inspect:
- `internal/changes/daily_test.go`
- `internal/changes/three_day_test.go`
- `internal/changes/weekend_test.go`
- `internal/app/app_test.go`
## Invariants
- Recent Changes are based on structured snapshots, not Markdown report text.
- Report compatibility is determined outside this package by report definitions
and state lookup.
- Output stays compact enough for prompt input.

21
docs/internal/cli.md Normal file
View File

@@ -0,0 +1,21 @@
# CLI Internals
`internal/cli` parses terminal arguments, loads configuration, constructs app requests, and translates app results to bounded JSON summaries. The public contract belongs in the [CLI reference](../cli.md).
The root `--version` flag reports the build version supplied by `internal/buildinfo`. Tagged release builds replace its development default at link time.
The executable derives its action context from `SIGINT` and `SIGTERM` and
passes it to `Runner.Run`. Signal cancellation therefore uses the same action,
summary, and error paths as other context cancellation.
For each `generate`, `run`, or `compare` action, `Runner` constructs one project-owned Promptkit executor after request preflight and configuration loading. It captures an absolute working directory, resolves only a relative explicit output override against it, and passes the working directory, loaded configuration, resolved override, and any `--llm-debug-dir` request to the app. The raw configured fallback remains in the configuration for app-owned destination selection. `run` uses the same explicit-resolution rule for `--out-dir`.
The CLI dispatches generation, batch, and comparison actions. It has no persisted-run or inspection dispatch. Generation and batch summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. Comparison summaries retain their ordered profile results and published bundle paths when available. All summaries intentionally exclude prompt input, raw generated text, render context, endpoints, credentials, and full Distributor payloads. A failed action with a partial result still emits its safe summary before its error is returned unless `--quiet` is set.
CLI code owns report-date flag acceptance and date resolution, but not report
composition, weather collection, output publication, provider execution, or
notification policy. Focused checks:
```sh
go test ./internal/cli
```

36
docs/internal/collect.md Normal file
View File

@@ -0,0 +1,36 @@
# Collection Internals
`internal/collect` is the small application-facing boundary that obtains one
normalized Weather API bundle. The external HTTP contract belongs in the
[Weather API integration guide](../integrations/weatherapi.md); normalized
source values belong in [weather-data internals](weather-data.md).
## Contract
`Run` receives a context and effective configuration in `Request`. It creates
the Weather API adapter, calls `FetchBundle`, and returns the adapter's
normalized bundle in `Result`. Adapter construction errors are wrapped as
weather-collection setup errors and fetch errors as bundle-collection errors.
The package neither chooses reports nor derives facts, builds modules, invokes
Promptkit, writes files, or sends notifications. Request scheduling, endpoint
retrieval, response limits, and source-level warnings belong to the Weather
API adapter and its integration contract.
## Application Use
`internal/app` owns the `Collector` interface used by report workflows and
tests. Its default implementation delegates to `collect.Run`; callers may
substitute a collector at that boundary. Application orchestration owns
collection timing, reuse across a workflow, and the handling of nil collection
results. See [app orchestration internals](app-orchestration.md) for that
flow.
## Verification
Focused package tests cover a successful fetch and wrapping failures from
adapter construction and bundle retrieval:
```sh
go test ./internal/collect
```

View File

@@ -0,0 +1,32 @@
# Comparison Execution Internals
The comparison execution core receives an already prepared report and an
already inspected, ordered profile list. It initializes an outcome for every
selected profile, launches each started profile in its own goroutine, and
waits for every started goroutine before returning. Results retain the supplied
selection order even though execution completes in an arbitrary order.
Every profile uses the exact inspected prompt identity and a private copy of
the same prepared data package. Provider, generated-text validation, rendering,
or debug-write failure becomes that profile's safe failed outcome and does not
cancel its peers. The shared executor must support those concurrent `Execute`
calls. The application deliberately imposes no additional semaphore: Promptkit
owns backend capacity. A profile failure completed before a later cancellation
remains its original safe outcome; cancellation or a deadline marks only
unfinished or cancellation-terminated outcomes as skipped or failed, joins work,
and prevents bundle publication.
When debugging is enabled, each execution receives a deterministic reference
derived from the comparison identity, ordered profile position, and safe
profile slug. This keeps concurrent captures separate. The debug writer itself
owns secure-root validation and file permissions. It safely creates shared
missing ancestors during concurrent writes, then rejects symlink and non-
directory components. Operational retention and sensitivity are documented in
the [operations guide](../operations.md). This secure writer is enabled only on
Unix hosts; comparison fails before execution when another host requests debug
capture.
The output result and its safe errors are converted into the durable contract
only by comparison publication. See [comparison publication
internals](comparison-publication.md) and the external [comparison bundle
contract](../integrations/comparison-bundle.md).

View File

@@ -0,0 +1,47 @@
# Comparison Publication Internals
`internal/comparison` separates the logical bundle from filesystem mechanics.
The application builds a validated manifest, exact shared data-package bytes,
and only the Markdown files for successful profiles. The durable layout,
schema, and compatibility rules are owned by the [comparison bundle
contract](../integrations/comparison-bundle.md).
Recognition first token-validates the manifest's object fields, rejecting
unknown, case-variant, and duplicate names before decoding its typed schema.
Manifest validation derives each successful report filename from its ordered
position, total profile count, and logical profile ID; logical-bundle and
filesystem validation then require that exact path and file set.
Destination planning is read-only. It requires an exact absolute target that
is neither the filesystem root nor the working directory, rejects unsafe
symlinks and non-directories, accepts a missing or empty directory, and permits
replacement only for a recognized current bundle. Publication rechecks the
destination namespace and type immediately before it writes a private sibling
staging directory. For replacement, it moves the prior bundle to a private
sibling backup, fully reauthorizes that moved entry, checks for cancellation,
and restores it if cancellation or installing the new bundle prevents
replacement. If guarded restoration fails, the error retains the prior bundle's
recovery path.
Planning also validates the final component and the bounded fixed names used
for private staging and backup siblings. A destination that cannot form those
names is rejected before publication creates a missing parent directory; a
maximum-length valid destination remains usable because transaction siblings do
not incorporate its basename.
The new bundle is committed only after the staged directory has been installed
at the target. From that point its artifact paths are authoritative: a failure
to remove the retained sibling backup does not roll back the new bundle.
After a cleanup failure, publication inspects the sibling without masking the
original filesystem cause. Its inspectable cleanup result distinguishes a
complete recognized recovery bundle, partial remnants, an absent sibling, or
an uninspectable state. A recovery path is reported only when something
remains; only a complete recognized bundle is suitable for rollback recovery.
The application preflights before prompt inspection and collection. Publication
performs its transaction-boundary checks and final moved-destination
authorization before installation. A cancellation or any failure before the
commit leaves the prior destination untouched. Completed bundles include
partial profile results; comparison publication never coordinates Distributor
notification. Operator-facing lifecycle and cleanup are in the
[operations guide](../operations.md).

View File

@@ -1,123 +1,71 @@
# Distributor Adapter Internals
This document describes the distributor upload adapter in
`internal/adapters/distributor`.
`internal/adapters/distributor` translates a local delivery request into the
Distributor Go client's upload and status calls, then returns a local delivery
result. The external API, authentication, and idempotency contract is owned by
the [Distributor API guide](../integrations/distributor/api.md) and
[Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
## Purpose
## Client construction
The adapter submits generated weatherreporter Markdown reports to a configured
distributor HTTP upload endpoint. It isolates distributor package types,
token-env lookup, upload client construction, source-bundle file mapping,
timeout handling, status polling, and upload error wrapping from app
orchestration.
`Client` holds the endpoint, the name of the environment variable containing
the token, an optional timeout, and an injectable upstream-client factory.
`New` validates its configuration before creating the adapter. For each upload,
the adapter reads the token from the configured environment variable and builds
the upstream client with that endpoint, token, and an HTTP client whose timeout
matches the local positive timeout. Its transport reads at most 1 MiB from any
Distributor response before the pinned client decodes it; an oversized response
is a distinct local failure and does not trigger an extra upload attempt.
## Inputs And Outputs
The upstream client is an implementation dependency, not a source of
application configuration: retry ownership, pipeline selection, path
templates, and report rendering are defined by
[configuration](../config.md) and [application orchestration](app-orchestration.md).
Inputs:
## Upload translation
- distributor endpoint URL
- token environment variable name
- upload timeout
- pipeline ID
- bundle ID
- idempotency key
- source Markdown report path and bundle-relative path mappings
- bundle created timestamp
- context for cancellation
Before calling the dependency, `Upload` validates the endpoint and token
configuration plus the local pipeline ID, bundle ID, idempotency key, and every
file's source and bundle paths. It maps the request as follows:
Outputs:
| Local request | Distributor client value |
| --- | --- |
| Pipeline ID | Upload pipeline identifier |
| Bundle ID | Bundle identifier |
| Idempotency key | Upload idempotency key |
| File source and bundle paths | Bundle file entries |
| Creation timestamp | Bundle creation time |
- accepted distributor run ID
- accepted distributor upload status
- distributor run status, status polling error, and raw run report JSON when available
- weatherreporter-owned idempotency conflict error when applicable
The call inherits the caller's context and applies the configured positive
timeout. The adapter does not read report files, construct bundle layouts, or
persist notification artifacts.
## Boundaries
## Status and errors
`internal/adapters/distributor` is the only weatherreporter package that imports
`gitea.maximumdirect.net/eric/distributor/pkg/upload` or
`gitea.maximumdirect.net/eric/distributor/pkg/bundle`.
An accepted upload is followed by one status request. When a timeout is
configured, a nonterminal result is polled until `succeeded` or `failed`, or
until the context ends. The translated `UploadResult` contains the run ID,
status, and `RunStatus`, including pipeline ID and lifecycle timestamps.
Remote response bodies, status reports, and remote error text are not retained
in normal results. HTTP failures retain a local typed status-code and
retryability classification; conflicts retain the local idempotency-conflict
type.
The app layer passes weatherreporter-owned request values to the adapter. The
adapter does not choose report types, render templates, select output copies,
configure destinations, wait for downstream publication, transform Markdown, or
persist notification state.
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
the caller can report an accepted-but-unconfirmed delivery, using a bounded
repository-owned diagnostic rather than remote text. A terminal failed run
returns that result and an error. Upload failures return no result. Upstream
idempotency conflicts become the local `IdempotencyConflictError`, which adds
endpoint, pipeline, bundle, idempotency, and file-path context while redacting
the token.
Full upstream distributor package and HTTP contract details stay under
`docs/integrations/distributor/`.
## Verification
## Config Fields Used
Focused tests cover configuration validation, request mapping, response size
boundaries, safe diagnostics, timeouts and polling, status translation, conflict
handling, and token redaction. A local HTTP server exercises the production
upload and status boundary:
The adapter is built from `notify.distributor` config:
- `endpoint`
- `token_env`
- `timeout`
The app layer renders pipeline ID, bundle ID, idempotency key, and bundle paths
from:
- `pipeline_id_template`
- `bundle_id_template`
- `idempotency_key_template`
- `report_path_templates`
The token value is read from the environment variable named by `token_env`
after config loading and `secrets.directory` processing.
## Upload Behavior
The adapter calls distributor `UploadFiles` with one or more file mappings:
- pipeline ID: the rendered distributor workflow selector
- source path: the managed Markdown report path selected by app orchestration
- bundle paths: rendered bundle-relative report paths
- created: the report generation timestamp
The adapter creates a distributor upload client with the configured endpoint,
bearer token, and timeout-backed HTTP client. It also wraps the upload context
with the configured timeout when the timeout is greater than zero.
After upload acceptance, the adapter polls distributor `Status` for the accepted
run ID until the run reaches `succeeded` or `failed`, or until the configured
timeout expires. It returns the latest status, error text, and raw report JSON in
weatherreporter-owned types so app orchestration can persist them in the
notification debug artifact. Status lookup failures or timeout before a terminal
state are kept as debug status errors on an otherwise accepted upload. A
terminal distributor run status of `failed` is returned as a notification failure
with the status report preserved.
## Failure Behavior
The adapter validates required endpoint, token env name, token value, pipeline
ID, bundle ID, idempotency key, upload files, source paths, bundle paths, and
upload client inputs before uploading.
Upload failures include endpoint, pipeline ID, bundle ID, idempotency key,
source paths, and bundle paths context. Token values are redacted from adapter
errors.
Distributor idempotency conflicts are exposed as a weatherreporter-owned
`IdempotencyConflictError`, so callers do not depend on upstream distributor
types.
## Tests
Inspect:
- `internal/adapters/distributor/client_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
Adapter tests use a fake upload client factory and do not require a live
distributor service.
## Invariants
- Distributor package types do not leak outside the adapter.
- Only the managed Markdown report is uploaded.
- The adapter never scans the workspace.
- Token values are not included in errors, CLI output, metadata, docs, or
examples.
- Destination routing and Markdown-to-HTML transformation belong to
distributor, not weatherreporter.
```sh
go test ./internal/adapters/distributor
```

View File

@@ -1,94 +1,71 @@
# Fact Contracts Internals
This document describes the fact contract boundary.
`internal/facts` is the deterministic boundary between a collected weather
bundle and report-scoped facts. It preserves normalized source values and then
selects and summarizes the values needed for one resolved report. Provider
transport and normalized bundle semantics belong to
[weather-data internals](weather-data.md); report identity and valid-period
selection belong to [report registry internals](report-registry.md).
## Purpose
## Collected facts
`internal/facts` separates normalized upstream facts collected for a report run
from conservative report-scoped facts derived from them. The package gives app
orchestration one place to build reusable facts before module execution.
`BuildCollected` projects a `weatherdata.Bundle` into `CollectedFacts`. It
retains the fetched timestamp and every normalized product: observations,
current conditions, hourly, narrative, alerts, discussion, daily, weather
story, and convective outlook data. Source provenance and warnings are copied
into their own slices so downstream consumers can inspect data completeness
without treating it as an ordinary weather fact.
## Inputs And Outputs
Alert facts retain individual alert payloads for period selection together with
their copied source provenance; they do not retain a provider response envelope.
Inputs:
A nil bundle produces an empty collected value. Collection itself, missing
source policy, and source hashes are outside this package.
- `weatherdata.Bundle` from the Weather API adapter
- resolved report definition and valid period
- report timezone
- configured daypart definitions
## Report-scoped derivation
Outputs:
`BuildDerived` requires a valid resolved period and a valid report timezone. It
uses half-open period overlap to select hourly, narrative, daily, and alert
data; it also derives precipitation timing. Convective outlooks are retained
only when their valid interval overlaps the report period, with discussions
kept for represented outlook days. Both collections are sorted deterministically.
It rejects collected hourly data with a precipitation probability outside the
finite 0 through 100 percentage domain before constructing derived facts.
- `facts.CollectedFacts` with normalized source facts plus separate source
provenance and warnings. SPC convective outlook source data is carried
through when present in the bundle, including upstream geometry and source
provenance.
- `facts.DerivedFacts` with valid-period forecast slices, alert overlaps,
report-period SPC convective outlooks and discussions, daily summaries,
daypart summaries, and Storm Report window summary
Report identity controls the summary shape:
Hourly Report uses the generic valid-period hourly and narrative selection
for its rolling six-hour window. Its derived facts include precipitation timing
from the selected hourly periods, alert overlaps for the six-hour period, and
SPC outlooks/discussions overlapping that period. It does not build daily
summaries, daypart summaries, or a storm-window summary.
| Report family | Derived summary |
| --- | --- |
| Hourly | Rolling-period selections and precipitation timing; no daily or daypart summary |
| Daily, Today, Tomorrow | One local civil-day summary and its dayparts |
## Boundaries
`DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md).
Daily alert overlaps remain scoped to the civil day, while overnight daypart
summaries retain alerts that overlap their complete next-day window.
- This package owns fact assembly and reusable deterministic derivation for a
report run.
- SPC convective outlook derivation selects already-collected outlooks whose
half-open valid intervals overlap the resolved report period and retains
discussions for represented outlook days.
- Derived SPC outlook records preserve the collected outlook fields, including
geometry, for downstream components that need source-level facts. Prompt
modules decide which fields are exposed to Scriptorium.
- It does not fetch upstream data, build prompt wording, compare prior
snapshots, write workflow state, invoke Scriptorium, or define modules.
## Missing data and failures
## Config Fields Used
Optional normalized products remain nil or yield empty selections; the package
does not create substitute values. A present convective-outlook run with no
matching outlooks produces non-nil empty outlook and discussion slices, while
a missing run produces nil slices.
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
- `weather_api.timezone`
Derivation fails for an invalid report period, invalid timezone, unsupported
report ID, or when a requested daily summary has no hourly forecast data.
Invalid daypart definitions surface from forecast derivation. The package does
not access the CLI, filesystem, subprocesses, or network.
## External Adapters Used
## Verification and invariants
None directly. Collected facts are built from `weatherdata.Bundle`.
Focused tests cover collected-fact separation, report-period selection,
hourly behavior, daily summaries, and convective outlook selection:
## State Or Manifest Behavior
```sh
go test ./internal/facts
```
None. Source provenance and warnings remain data fields for downstream metadata
and inspection.
## Failure Behavior
- Invalid or missing report valid periods return an error.
- Invalid timezone names return an error.
- Missing required hourly forecast data returns the underlying forecast
derivation error for reports that require daily summaries.
- Hourly Report can derive its default module facts without daily or
daypart summaries.
- Missing optional narrative, alert, discussion, daily, or weather story data
produces empty or nil derived fields.
- Missing optional SPC convective outlook data produces a nil collected field.
- A present SPC convective outlook source with no report-period matches
produces non-nil empty derived outlook and discussion slices.
## Tests
Inspect:
- `internal/facts/facts_test.go`
- `internal/app/app_test.go`
## Invariants
- Collected facts are built once from a fetched bundle.
- Derived facts are scoped to one resolved report.
- SPC convective outlook selection uses the resolved report period and the
already-collected outlook run.
- Source provenance and warnings stay separate from ordinary fact fields.
- Prompt-specific wording and one-off presentation decisions stay outside this
package.
Facts are derived once for a resolved report from already collected data.
They remain reusable structured values for prompt input and template
presentation, which are owned elsewhere.

View File

@@ -1,76 +1,47 @@
# Forecast Derivation Internals
This document describes deterministic forecast summarization in
`internal/forecast`.
`internal/forecast` deterministically selects and summarizes normalized
forecast data. It has no transport, filesystem, CLI, subprocess, or report
registry dependency. The report-scoped caller is [fact
contracts](facts.md), which owns the choice of data required by each report.
## Purpose
## Daily Derivation
`internal/forecast` converts normalized weather data into daily and period
summaries used by fact builders and module builders.
`BuildDailySummary` builds one summary for one local civil day. The facts
layer calls it for Daily, Today, and Tomorrow reports; it does not provide a
multi-day or arbitrary-period summary constructor. `timeutil.Period` supplies
the shared half-open overlap rule used while selecting source values.
## Inputs And Outputs
`ResolveDayparts` turns configured local clock ranges into windows. A range
whose end is not after its start continues into the next civil day. The
available daypart and timezone settings are defined in the
[configuration reference](../config.md).
Inputs:
The summary keeps selected hourly and narrative values, the discussion,
source warnings and provenance, alert overlaps, and one summary for each
resolved daypart. Daypart summaries derive their measurements, conditions,
weather indicators, and precipitation timing from normalized forecast
periods. `BuildPrecipTiming` is also available to the facts layer for a
report's selected hourly periods.
- `weatherdata.Bundle`
- local date or resolved report period
- timezone
- configured daypart definitions
## Boundaries And Failures
Outputs:
Daily-summary construction requires a bundle with hourly forecast data,
valid precipitation probabilities, and valid daypart definitions. Optional
normalized products remain absent when unavailable. Invalid alerts are ignored
while valid overlaps are selected for the relevant day or daypart window.
- `forecast.DailySummary` for one local civil day
- one clipped daily summary per local day or partial day from
`BuildPeriodDailySummaries`
- daypart summaries with selected hourly periods, ranges, timed maximums,
conditions, indicators, and alert overlaps
Thresholds, text classification, unit normalization, and alert selection are
package implementation rules. Report identity, period selection, and the
resulting derived-fact shape are owned by [fact contracts](facts.md); external
source semantics are owned by [weather-data internals](weather-data.md).
## Boundaries
## Verification
- This package groups, selects, and summarizes already-normalized forecast
data.
- It does not perform HTTP calls, parse CLI flags, resolve report definitions,
compare prior snapshots, build prompt input packages, or invoke Scriptorium.
Focused `internal/forecast` tests exercise daily and overnight dayparts,
summary derivation, invalid precipitation data, precipitation timing, and
alert overlap handling. `internal/facts` tests cover the report-scoped caller:
## Config Fields Used
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
Threshold constants for basic indicators live in forecast code rather than
configuration.
## External Adapters Used
None directly. Forecast data arrives through `weatherdata.Bundle`.
## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and module output.
## Skip And Resume Behavior
None. Missing optional source context can produce empty selections, but missing
required hourly data fails summarization.
## Failure Behavior
- A nil bundle or missing hourly forecast data returns an error.
- Invalid daypart definitions return parse errors with context.
- Alert records without parseable RFC3339 timing are skipped.
- Empty selected periods produce empty summaries rather than generated prose.
## Tests
Inspect:
- `internal/forecast/derive_test.go`
- `internal/timeutil/periods_test.go`
## Invariants
- Go owns report-period selection and meteorological summarization.
- Weather facts come from normalized source data.
- Outputs remain JSON-inspectable and independent of CLI, state, and adapters.
```sh
go test ./internal/forecast ./internal/facts
```

View File

@@ -1,94 +1,76 @@
# Generated Text Internals
This document describes structured generated-text handling in
`internal/generatedtext`.
`internal/generatedtext` validates the structured prose produced for generated-
text reports and turns validated prose plus rich module values into typed render
contexts. It owns the catalog that pairs a generated-text report definition
with its validator, schema ID, template ID, and context builder. The complete
maintainer-facing context fields belong to [report templates](../templates.md).
## Purpose
## Catalog and validation
`internal/generatedtext` validates structured text returned for
generated-text-template reports and builds curated render contexts for
templates. The implemented contracts are Tomorrow Report and Hourly Report.
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated text. `LookupDefinition` requires the exact report, schema, and
template triple and rejects unknown IDs, unsupported pairs, and a pair that
belongs to another report before the run begins. A handler validates and
normalizes raw JSON into a typed value, loads its canonical schema through
`internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`.
## Inputs And Outputs
Daily, Today, and Tomorrow use a day-style value with required trimmed summary
and one or more nonblank discussion paragraphs. Hourly requires trimmed summary
and a single trimmed discussion string. Every form also requires the
`precipitation_timing` field; an empty string means there is no supported timing
prose to render. Typed decoding requires the exact lowercase JSON field names,
rejects missing, duplicate, case-variant, and unknown fields, and checks field
shapes; no general-purpose JSON Schema engine is used at runtime.
Inputs:
The validator accepts at most 64 KiB of raw JSON before it allocates typed
values. Its JSON Schemas and typed checks limit `summary` and
`precipitation_timing` to 4,000 characters each. Hourly
`forecast_discussion` is limited to 12,000 characters. Day-style discussion
accepts at most 12 paragraphs of at most 4,000 characters each. Across all
prose fields, one report may contain at most 20,000 characters. These bounds
apply before trimming, filtering, normalization, and template rendering.
- raw GeneratedText JSON for Tomorrow Report or Hourly Report
- report metadata from `internal/briefing`
- a module snapshot from `internal/module`
- validated generated text
Malformed JSON and field values return short, content-safe errors. They name
only canonical fields where useful and never echo provider values or unknown
field names. The Promptkit adapter also drops an oversized provider result
before copying it into execution or debug state; direct executor implementations
receive the same enforcement in this package.
Outputs:
## Render contexts
- typed `Tomorrow` generated text
- typed `Hourly` generated text
- normalized stable JSON for validated generated text
- typed `TomorrowRenderContext` values for `internal/reporttemplate`
- typed `HourlyRenderContext` values for `internal/reporttemplate`
The catalog's report-specific builders receive the prepared report identity, a
rich module snapshot, derived facts needed to order dayparts, and the matching
validated generated text. They require the identity's report ID to match the
selected builder. When the optional metadata stanza is present, every shared
identity field must agree with that prepared authority before context
construction continues. Builders then decode the module stanzas needed by the
template and build typed Daily, Today, Tomorrow, or Hourly contexts. Contexts
expose only display-ready report values, generated prose, and module values;
they do not expose complete collected or derived fact bundles. Ordered slices
remain the template iteration surface rather than maps.
The hourly generated text JSON accepts:
Optional source stanzas become nil or fallback context fields. Today also
computes whether its ordered dayparts contain a displayable condition so the
template can render either rows or its explicit no-details fallback. Missing
required stanzas, type-decoding failures, conflicting identity values, invalid
metadata, or a generated-text type that does not match the chosen handler fail
before template execution. Prompt packages, raw Promptkit output handling, and
template asset lookup remain outside this package.
```json
{
"summary": "string",
"forecast_discussion": "string",
"precipitation_timing": "string",
"confidence": "string"
}
## Verification and invariants
Focused tests cover the catalog, each report-specific validator, normalization,
schema/template mismatches, context construction, optional modules, and typed
stanza errors:
```sh
go test ./internal/generatedtext
```
`summary` and `forecast_discussion` are required after trimming whitespace.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
The Tomorrow generated text JSON accepts:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
`summary` is required after trimming whitespace. `forecast_discussion` must
contain at least one nonblank paragraph after trimming blank items.
`precipitation_timing` and `confidence` are optional and omitted from normalized
JSON when blank.
## Boundaries
- This package owns typed generated-text validation and render-context shaping.
- It uses typed module snapshot decoding through `module.StanzaValue`.
- It does not invoke Scriptorium, write state artifacts, choose report
definitions, compare snapshots, or render templates directly in production
workflows.
- It does not use a Go JSON Schema dependency; schema enforcement in Go is
limited to typed JSON decoding, unknown-field rejection, and required-field
checks.
## Failure Behavior
- Malformed generated-text JSON fails with decode context.
- Unknown generated-text JSON fields fail during decoding.
- Empty required fields fail after trimming whitespace.
- Tomorrow forecast discussion fails when no nonblank paragraphs remain.
- Missing optional render-context stanzas become nil module pointers.
- Invalid render metadata, including missing timezone, missing generated time,
or invalid valid period, fails before template rendering.
## Tests
Inspect:
- `internal/generatedtext/hourly_test.go`
- `internal/generatedtext/tomorrow_test.go`
- `internal/generatedtext/render_context_test.go`
## Invariants
- Render contexts are curated structs, not raw prompt-input packages.
- Required generated text is normalized before downstream artifact storage.
- Missing optional weather narrative stanzas produce empty or fallback render
context fields rather than forcing raw module data into templates.
Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. The renderer applies its plain-text policy to every
generated prose insertion, preserving ordinary text and paragraph breaks while
preventing provider text from creating Markdown or HTML structure. Every report
definition must resolve to exactly one supported catalog pair.

View File

@@ -1,167 +1,67 @@
# Module Contract Internals
This document describes the module contract in `internal/module`.
`internal/module` defines the envelope between report composition,
module builders, in-memory snapshots, templates, and prompt packages. It
does not define a report, execute a builder, or choose prompt-export policy;
those responsibilities belong to [report registry](report-registry.md) and
[briefing](briefing.md).
## Purpose
## Outputs and snapshots
`internal/module` defines the shared identifiers and data envelopes used for
prompt-facing modules. Report definitions use module IDs for composition,
module builders produce outputs with stanza names, prompt input packages consume
snapshots, and Recent Changes compares snapshot stanzas.
Each `Output` has a module ID, stanza name, rich `Value`, and runtime-only
`PromptValue`. `DataPackageValue` returns the prompt value when present and
otherwise the rich value. This permits custom prompt exports without shrinking
the template value.
## Inputs And Outputs
`NewSnapshot` builds and validates the ordered in-memory snapshot. Its JSON
representation carries a package-owned schema marker, IDs, stanza names, and rich values only;
`PromptValue` is deliberately excluded. `StanzaValue` decodes a named rich
stanza into a caller-supplied type, reporting a missing stanza separately from
a decoding error.
Inputs:
Snapshots reject missing schema versions, empty IDs or stanza names, and
duplicate IDs or stanza names. Output order is caller-owned and preserved.
- ordered `module.ConfigItem` values from report definitions or config
overrides
- `module.Output` values produced by module builders
## Registered IDs and default composition
Outputs:
The registered IDs are `metadata`, `current_conditions`,
`narrative_forecast`, `hourly_forecast`, `derived_daily_summary`,
`derived_daypart_summaries`, `precip_timing`, `alert_digest`,
`spc_convective_outlooks`, `area_forecast_discussion`,
`spc_convective_discussion`, `weather_story`, `outdoor_windows`,
`today_planning`, `tomorrow_planning`, and `daily_planning`.
- stable `module.ID` constants
- typed option structs for registered modules
- `module.Snapshot` with schema version `weatherreporter.modules.v1`
- ordered snapshot outputs with module ID, stanza name, and typed value
- typed stanza lookup through `module.StanzaValue`
The registry declares these ordered default compositions:
## Registered Module IDs
| Report | Ordered modules |
| --- | --- |
| Daily | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD (long term), SPC discussion, weather story, outdoor windows, daily planning, hourly forecast |
| Today | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, hourly forecast, today planning |
| Tomorrow | metadata, current conditions, narrative forecast, daily summary, daypart summaries, precipitation timing, alert digest, SPC outlooks, AFD, SPC discussion, weather story, outdoor windows, tomorrow planning, hourly forecast |
| Hourly | metadata, current conditions, hourly forecast, precipitation timing, alert digest, SPC outlooks, AFD (key messages and short term), SPC discussion, weather story |
The registry recognizes these IDs:
The only non-empty default option is the AFD section selection. It accepts a
`sections` list; omitted or empty selects all available sections. Report
definitions may narrow it as shown above. Option shape and report compatibility
are validated by the briefing registry. Accepted typed option pointers are
canonicalized to the declared value type before a module builder receives them.
- `metadata`
- `current_conditions`
- `narrative_forecast`
- `hourly_forecast`
- `derived_daily_summary`
- `derived_daypart_summaries`
- `precip_timing`
- `alert_digest`
- `spc_convective_outlooks`
- `area_forecast_discussion`
- `spc_convective_discussion`
- `weather_story`
- `outdoor_windows`
- `tomorrow_planning`
## Rich and prompt-facing values
Every registered module has a builder. Report composition entries that refer to
unknown or unimplemented module IDs fail validation instead of being skipped.
Rich values remain available to module snapshots and render contexts.
Briefing attaches custom prompt exports only for current conditions, hourly
forecast, and derived daypart summaries; all other current builders use
pass-through values. The prompt package owns how exported stanzas are grouped
and serialized; see [prompt input](prompt-input.md).
## Tomorrow Composition
## Verification and invariants
The default Tomorrow Report module order is:
Focused tests cover snapshot validation and order, typed stanza lookup, and
prompt-value fallback:
1. `metadata`
2. `current_conditions`
3. `narrative_forecast`
4. `derived_daily_summary`
5. `derived_daypart_summaries`
6. `precip_timing`
7. `alert_digest`
8. `spc_convective_outlooks`
9. `area_forecast_discussion`
10. `spc_convective_discussion`
11. `weather_story`
12. `outdoor_windows`
13. `tomorrow_planning`
14. `hourly_forecast`
The embedded Tomorrow template uses selected deterministic fields from these
module outputs after GeneratedText validation.
## Hourly Composition
The default Hourly Report module order is:
1. `metadata`
2. `current_conditions`
3. `hourly_forecast`
4. `precip_timing`
5. `alert_digest`
6. `spc_convective_outlooks`
7. `area_forecast_discussion`
8. `spc_convective_discussion`
9. `weather_story`
Hourly Report does not include daily or daypart summary modules by default.
Its `area_forecast_discussion` item is configured to include only
`key_messages` and `short_term`.
## Options
Most modules use an empty options struct, including
`spc_convective_outlooks` and `spc_convective_discussion`.
`area_forecast_discussion` accepts:
```yaml
sections:
- product
- key_messages
- short_term
- long_term
```sh
go test ./internal/module
```
An omitted or empty `sections` list includes all available discussion sections.
Invalid option shapes fail during config normalization or composition
validation.
## SPC Convective Module Outputs
`spc_convective_outlooks` emits a prompt-facing risk-product stanza with:
- `checked`
- `as_of`
- `issued_at`
- `location_id`
- `location_name`
- `outlook_count`
- `outlooks`
Each outlook entry may include `day`, `outlook_type`, `label`, `label_text`,
`period_begins`, `period_ends`, `issued_at`, `contains_location`, and
`image_url`. It omits GeoJSON geometry, source URL, expiration time, and
severity rank.
`spc_convective_discussion` emits a narrative stanza only when a retained
report-period categorical outlook has severity rank `3` or higher and matching
discussion text is available. Its output includes `included_because` and
`discussions`; each discussion may include `day`, `period_begins`,
`period_ends`, `headline`, `summary`, `discussion`, and `updated_at`.
Discussions are included only for SPC days whose retained categorical outlooks
meet the severity threshold.
## Boundaries
- This package owns module identifiers, config item envelopes, output
envelopes, snapshot validation, and typed stanza lookup.
- It does not define report IDs, execute builders, fetch weather data, derive
forecast facts, write state, or invoke Scriptorium.
## State Or Manifest Behavior
`module.Snapshot` values are persisted by `internal/state` as JSON. Snapshot
validation rejects missing schema version, missing module IDs, missing stanza
names, duplicate module outputs, and duplicate stanza names while preserving
output order.
## Failure Behavior
- Snapshot construction fails for duplicate module outputs or duplicate stanza
names.
- Typed stanza lookup returns `found=false` for missing stanzas.
- Typed stanza lookup wraps JSON marshal/decode failures with stanza context.
## Tests
Inspect:
- `internal/module/module_test.go`
- `internal/briefing/modules_test.go`
- `internal/report/period_test.go`
## Invariants
- `internal/module` does not import `internal/report`.
- Module IDs are stable strings.
- Each emitted module output has exactly one stanza name and one typed value.
- Snapshot output order is caller-owned and preserved.
Module IDs and stanza names are stable, every emitted output has one of each,
and this package never imports the report registry.

View File

@@ -0,0 +1,38 @@
# Prepared Report Internals
`internal/app` validates the report's generated-text catalog binding during
prompt inspection, before collection, and carries the resulting handler into
`preparedReport` construction after collection. This is the immutable boundary
shared by ordinary report generation and profile comparison; it is not a
durable artifact.
Preparation first establishes one `PreparedIdentity` for the report run, report
and prompt IDs, variant, generation time, units, timezone, valid period,
location, and source warnings. It passes that identity to the configured module
snapshot, curated prompt-input package, serialized YAML, generated-text render
context, and generated-text definition. Each boundary projects only the fields
it needs from that prepared authority.
Preparation deep-copies mutable facts, snapshots, identity, and data-package
bytes before returning them. Consumers receive independent copies so one
execution cannot change another's input or rendering context.
Before accepting generated JSON, the execution boundary reconciles the prepared
report definition, inspected prompt hash and selected profile identity, the one
preparation callback, and the completed Promptkit result. The callback and
completion must agree on prompt, profile, backend, model, and rendered/input
hashes; the callback output must also carry the prepared report's configured
repair budget, while the completed validation records the actual corrective
calls used within that budget. A mismatch produces no rendered Markdown and leaves
results with only the inspected safe identity.
Single-report generation executes one prepared profile and publishes its
Markdown. Comparison prepares once, gives every selected profile the same YAML
bytes, and only then assembles the resulting logical bundle. The prompt-input
shape is owned by [prompt-input internals](prompt-input.md); profile execution
semantics are owned by [Promptkit integration](../integrations/promptkit.md).
Catalog incompatibility stops prompt inspection before weather collection or
model work. Preparation failure has no publication side effects. Tests for this
boundary cover catalog-preflight ordering, mutation isolation, byte equality,
and reuse by both execution paths.

View File

@@ -1,144 +1,36 @@
# Prompt Input Internals
This document describes YAML prompt data package construction in
`internal/promptinput`.
`internal/promptinput` turns prepared report metadata and an ordered module
snapshot into the YAML data package passed to Promptkit. The externally visible
prompt and inline-input contract is owned by the [Promptkit integration
guide](../integrations/promptkit.md); preparation of the inputs is owned by
[prepared report internals](prepared-report.md).
## Purpose
## Package Construction
`internal/promptinput` converts report metadata, an ordered module snapshot,
Recent Changes, and source warnings into the `data_package` file passed to
Scriptorium.
`Build` projects report identity, the report-local current date, source-warning
summaries, and each snapshot output's prompt-facing value into a package. It
does not expose source transport or provenance details. The module snapshot
defines stanza order and selects curated prompt values; the corresponding
module contracts are documented in [module internals](module.md) and [briefing
internals](briefing.md).
The persisted data package is YAML with schema version
`weatherreporter.data_package.v2`. It is separate from the JSON module snapshot
used for inspection and comparison.
`MarshalYAML` validates the package before serializing it. Serialization emits
the metadata stanza first, then groups the remaining recognized stanzas in the
package's fixed category order while preserving snapshot order within a
category. `Validate` enforces the supported schema version, required report
identity and period values, and a nonempty, complete ordered briefing.
## Inputs And Outputs
This package does not collect weather, choose an output destination, execute a
provider, or persist data packages. The application passes its in-memory YAML
to the Promptkit adapter as part of prepared report execution.
Inputs:
## Verification
- report metadata from app/state orchestration
- `module.Snapshot`
- optional `[]changes.Change`
Focused tests cover package construction, report-local dates, validation,
curated snapshot exports, deterministic YAML grouping, and safe source-warning
projection:
Outputs:
- `promptinput.Package` with schema version, RunID, report metadata, named
module stanzas grouped for prompt presentation, Recent Changes, and source
warnings
- YAML bytes from `promptinput.MarshalYAML`
- YAML file written atomically by `promptinput.Save`
The YAML shape includes:
```yaml
schema_version: weatherreporter.data_package.v2
run_id: <run_id>
report:
id: <report_id>
prompt_id: <prompt_id>
briefing:
metadata: {}
applicable_risk_products:
alert_digest: {}
spc_convective_outlooks: {}
derived_summaries:
derived_daily_summary: {}
derived_daypart_summaries: {}
precip_timing: {}
outdoor_windows: {}
narrative_products:
narrative_forecast: {}
area_forecast_discussion: {}
spc_convective_discussion: {}
weather_story: {}
raw_data:
current_conditions: {}
hourly_forecast: {}
recent_changes:
items: []
```sh
go test ./internal/promptinput
```
The `briefing` mapping keeps `metadata` directly under `briefing` and groups
weather module stanzas under prompt-facing categories. This grouping is a YAML
presentation concern only: module snapshots remain flat, and loaded
`promptinput.Package` values expose flat stanza names in `Briefing.Values`.
Within each category, stanza order follows the module snapshot output order.
Prompt-facing module intervals use local `period_begins` and `period_ends`
labels; canonical report metadata and source timestamps remain structured
timestamps where applicable.
Tomorrow Report and Hourly Report module snapshots use the same package schema
and categories when converted into prompt input. The default hourly module list
places
`precip_timing` under `derived_summaries`, alert and SPC outlooks under
`applicable_risk_products`, AFD/SPC discussion/weather story under
`narrative_products`, and current/hourly data under `raw_data`. It does not
include civil-day summary stanzas. Generated-text and render context artifacts
are produced later in app orchestration and are not part of the YAML data
package.
The default Tomorrow module list includes civil-day summary stanzas,
`tomorrow_planning`, and `hourly_forecast` in the data package before
structured GeneratedText is requested from Scriptorium.
Current categories are:
- `applicable_risk_products`: location-applicable alerts, warnings, outlooks,
and similar risk products. Current stanzas include `alert_digest` and
`spc_convective_outlooks`.
- `derived_summaries`: deterministic summaries and calculated report facts.
- `narrative_products`: official narrative text products and forecast stories.
Current stanzas include `narrative_forecast`,
`area_forecast_discussion`, `spc_convective_discussion`, and
`weather_story`.
- `raw_data`: minimally transformed underlying weather data.
## Boundaries
- This package owns prompt package schema, YAML marshaling, YAML loading, and
validation.
- It does not fetch weather data, derive forecast summaries, execute modules,
find prior snapshots, compare changes, choose artifact paths, or invoke
Scriptorium.
## Config Fields Used
None directly. Config-derived values such as timezone, units, and prompt
location are already present in report metadata and module stanzas before this
package runs.
## External Adapters Used
None.
## State Or Manifest Behavior
`promptinput.Save` writes YAML atomically. Managed workspace paths are owned by
`internal/state`.
## Skip And Resume Behavior
None. Recent Changes is always present as an `items` list and may be empty.
## Failure Behavior
Validation fails before render preflight when required top-level fields are
missing or inconsistent, when the valid period is invalid, or when no module
stanzas are present. Save failures include filesystem operation and path
context.
## Tests
Inspect:
- `internal/promptinput/package_test.go`
- `internal/app/app_test.go`
## Invariants
- Scriptorium receives structured YAML through `--input data_package=<path>`.
- Module stanza order is deterministic within each prompt-facing category.
- Every non-metadata module stanza has exactly one prompt-input category.
- Recent Changes are provided by `internal/changes`; this package does not
infer changes from rendered report text.

View File

@@ -0,0 +1,17 @@
# Promptkit Adapter Internals
`internal/adapters/promptkit` maps Weatherreporter's project-owned executor contract to Promptkit. The CLI maps `promptkit` configuration to a `PromptExecutorConfig` and constructs one executor per action. Promptkit dependency types do not escape the adapter.
The adapter supplies Weatherreporter's embedded prompt, schema, and fallback profile filesystems to each engine. Promptkit resolves configured operator profile sources, embedded profile aliases and their bases, and its built-in catalog; the adapter does not parse profile YAML, resolve inheritance, merge sources, inspect optional environment credentials, or probe endpoints.
The adapter exposes exact prompt and profile validation plus prepared execution. It maps safe prompt identity, logical profile, effective backend/model, preparation, execution, validation, and optional debug values into `promptexec`. An empty backend identity remains valid for an endpoint-only profile; a nonblank model is required. PromptKit's configured repair-call budget and the completed result's actual corrective-call count are retained, along with its cumulative provider usage and final candidate. Structured provider generation failures become project-owned redacted generation errors that retain only bounded details through explicit accessors. `Execute` passes the YAML package as an inline Promptkit input; it does not construct a filesystem URI or write a package file.
The application uses the preparation callback to record active safe provenance in memory and optionally writes content-rich diagnostics only through an explicit debug writer. The adapter returns raw output for application validation and rendering. It does not retain application state, render Markdown, choose report definitions, or send Distributor notifications.
Focused tests:
```sh
go test ./internal/adapters/promptkit ./internal/cli ./internal/app
```
The public logical prompt/profile/schema contract is owned by the [Promptkit integration guide](../integrations/promptkit.md).

View File

@@ -1,120 +1,38 @@
# Report Registry Internals
This document describes report identity, valid-period resolution, batch
membership, output naming, artifact grouping, and comparison declarations in
`internal/report`.
`internal/report` owns the in-process registry of report identities and the
resolution of a report's valid period. Command names and configuration aliases
belong to the [CLI reference](../cli.md) and [configuration
reference](../config.md), respectively.
## Purpose
## Registry And Resolution
`internal/report` is the canonical source for report definitions. App, state,
module building, and CLI wiring consume resolved definitions instead of owning
report identity policy themselves.
`DefaultRegistry` supplies the maintained definitions. `Lookup` returns a
definition by its internal ID, while `Resolve` combines it with a request time,
location, and optional date to produce `Resolved`. The result carries the
definition, generation time, timezone, and resolved valid period; its metadata
and output-name helpers keep derived identity values consistent for callers.
## Definition Fields
Definitions carry the internal collaborators needed downstream: prompt and
template identity, module configuration, output naming, Distributor path
templates, and fixed batch eligibility. The external prompt contract is owned
by the [Promptkit integration guide](../integrations/promptkit.md), template
surface by the [report template guide](../templates.md), and published
Distributor paths by the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
Each report definition declares:
`WithModuleOverrides` returns an independently cloned registry with replacement
module configuration for recognized report IDs. The application owns batch
planning and data-dependent inclusion; see [app orchestration
internals](app-orchestration.md).
- report ID and display name
- Scriptorium prompt ID
- generation mode
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
- default ordered module composition
The registry never collects weather data, parses CLI flags, writes output,
executes Promptkit, or delivers a report.
Markdown report definitions use the `scriptorium_markdown` generation mode.
Their template and structured-text schema identifiers are empty. Tomorrow
Report and Hourly Report declare `generated_text_template`; the app uses their
template and schema identifiers to validate generated text and render embedded
Markdown templates.
## Verification
## Reports
Focused tests protect retained report definitions, period resolution, Daily
run-ID disambiguation, and rejection of retired command or configuration names:
| Report | ID | Prompt | Generation mode | Artifact group | Batch copy | Prior compatibility |
| --- | --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `scriptorium_markdown` | `daily` | `daily.md` | Daily Today |
| Tomorrow Report | `tomorrow` | `weather.tomorrow_generated_text` | `generated_text_template` | `tomorrow` | `tomorrow.md` | Tomorrow Report |
| Hourly Report | `hourly` | `weather.hourly_generated_text` | `generated_text_template` | `hourly` | `hourly.md` | Hourly Report |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `scriptorium_markdown` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `scriptorium_markdown` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `scriptorium_markdown` | `storm` | `storm.md` | Storm Report |
All report definitions are eligible for generation.
## Valid Periods
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Tomorrow Report covers the next local civil day from generation time.
- Hourly Report covers the half-open six-hour period from generation time in
the effective report timezone. The duration is an internal report constant,
not a configuration field.
- 3-Day Outlook covers the interval from generation time through local midnight
three days later.
- Weekend Outlook covers the upcoming weekend window and is not scheduled for
Sunday morning batch resolution.
- Storm Report covers an explicit event window supplied by the caller.
Storm event windows can be parsed from local `YYYY-MM-DDTHH:MM` timestamps in
the configured timezone or RFC3339 timestamps with explicit offsets. End time
must be after start time.
## Boundaries
`internal/report` defines report metadata and time coverage. It does not fetch
weather data, build module values, compare snapshot contents, write state,
parse CLI flags, or invoke Scriptorium.
The CLI owns public command names. The app maps those command names to report
IDs, then uses the registry for report policy.
## Config Fields Used
The app supplies `weather_api.timezone` as a loaded `time.Location`. Batch
output path copying uses batch output names from report definitions. Report
module overrides can use short keys such as `tomorrow` and `hourly`, or
canonical report IDs such as `daily_today`.
## Batch Membership
Morning batches include Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. Evening batches include Tomorrow Report. Hourly Report is not part
of a scheduled batch.
## State And App Usage
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- Module composition defaults use `Modules`.
- Prior lookup checks `CompatiblePriorIDs` and the comparison strategy.
- RunIDs include the resolved report ID.
## Failure Behavior
- Unknown report IDs and batch names return actionable errors.
- Weekend Outlook resolution returns an error when resolved directly on Sunday.
- Storm Report resolution requires start and end, with end after start.
## Tests
Inspect:
- `internal/report/period_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Report selection goes through the registry.
- Direct Markdown reports have empty template and generated-text schema IDs.
- Generated-text-template reports declare prompt, template, and schema IDs in
their report definition.
- Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility,
default module composition, comparison compatibility, and comparison strategy
are declared by report definition.
```sh
go test ./internal/report
```

View File

@@ -1,100 +1,56 @@
# Report Template Internals
This document describes embedded Markdown templates and GeneratedText schemas
in `internal/reporttemplate`.
`internal/reporttemplate` embeds and renders the repository's native Markdown
templates. The current template IDs are `daily`, `today`, `tomorrow`, and
`hourly`. The template files, partials, and complete render-context field
reference are maintained in
[report templates](../templates.md).
## Purpose
## Assets and lookup
`internal/reporttemplate` owns repository-native report templates and companion
GeneratedText JSON schemas. The implemented template contracts are Tomorrow
Report and Hourly Report.
The package embeds top-level templates and shared partials. `Template` returns
the requested embedded template and fails with the requested ID when it is
unknown or unreadable.
The package embeds assets from:
Generated-text schemas and Promptkit definitions are owned by
`internal/promptassets`; report-template owns Markdown source only. Report
definitions select IDs, while [generated-text internals](generatedtext.md)
verifies the supported schema/template pairing.
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
## Rendering
## Inputs And Outputs
`Render` loads the top-level template, creates a `text/template` with helper
functions and `missingkey=error`, parses the template, parses every shared
partial, and executes the result against the typed render context. This makes
missing context fields, bad template syntax, unreadable partials, and execution
failures actionable with template or partial context.
Inputs:
Top-level templates decide which shared partials they invoke. The current
partials cover daypart forecast variants, alert digest, and precipitation
timing. Template code receives curated typed contexts rather than raw data
packages or complete fact bundles, and it must not reimplement weather
selection or generated-text validation. Context construction rejects
report-identity disagreements before template execution. Every generated-prose
insertion uses the `plainText` helper. It retains ordinary prose and paragraph
breaks but renders Markdown/HTML syntax, code indentation, and control
characters as safe text, so the repository templates remain the sole owners of
report structure.
- template ID from a report definition
- typed render context built by `internal/generatedtext`
## Boundaries and verification
Outputs:
This package does not collect weather data, build modules, validate generated
text, construct contexts, resolve report definitions, write state, execute
Promptkit, or upload reports. It produces Markdown bytes for application
orchestration to persist.
- template source for inspection and tests
- GeneratedText schema bytes for prompt/schema configuration
- rendered Markdown bytes for app orchestration to persist
Focused tests cover template lookup, rendering, partial behavior, daypart
fallbacks, missing keys, and malformed context:
The implemented template IDs are `tomorrow` and `hourly`. The implemented
schema IDs are also `tomorrow` and `hourly`, backed by
`tomorrow.generated_text.schema.json` and `hourly.generated_text.schema.json`.
```sh
go test ./internal/reporttemplate
```
## Boundaries
This package owns embedded asset lookup, Go template parsing, and Markdown
template execution. It does not fetch weather data, build module outputs,
validate GeneratedText, construct render contexts, choose report definitions,
write artifacts, invoke Scriptorium, or notify distributor.
GeneratedText validation is owned by `internal/generatedtext`. App
orchestration decides which template and schema IDs apply to a report through
`internal/report` definitions.
## Template Contracts
Tomorrow and Hourly rendering use typed render contexts with:
- report metadata labels such as title, location, valid period, and generation
time
- validated GeneratedText prose slots
- deterministic labels derived from module outputs, including current
conditions, hourly forecast rows, precipitation timing, alerts, SPC outlooks,
forecast discussion, SPC discussion, and weather story
Tomorrow additionally exposes forecast-date labels, ordered daypart forecast
rows, daily/daypart summaries, tomorrow planning facts, and a multi-paragraph
forecast discussion generated-text slot. The ordered daypart slice is built in
Go so templates do not range over maps.
Templates use `text/template` with `missingkey=error`, so missing context fields
fail rendering instead of producing incomplete Markdown.
## Schema Contract
The GeneratedText schemas describe the structured prose Scriptorium is expected
to write for each generated-text prompt. Hourly requires:
- `summary`
- `forecast_discussion`
Tomorrow requires `summary` and a nonempty `forecast_discussion` array. Both
schemas allow optional `precipitation_timing` and `confidence`, and reject
additional properties. Weather truth remains in module outputs; GeneratedText
is limited to prose slots consumed by the template.
## Failure Behavior
- Unknown template IDs return actionable lookup errors.
- Unknown schema IDs return actionable lookup errors.
- Template parse errors include the template ID.
- Template execution errors include the template ID and usually identify the
missing context field.
## Tests
Inspect:
- `internal/reporttemplate/reporttemplate_test.go`
- `internal/generatedtext/render_context_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- Embedded templates and schemas live as separate files, not inline Go strings.
- Report definitions select templates by ID.
- Templates render from curated render contexts, not raw data packages.
- GeneratedText schemas describe LLM prose slots, not deterministic weather
facts.
Embedded templates stay as separate files and shared fragments stay under the
partial directory. Generated-text schemas are embedded separately by
`internal/promptassets` and describe prose slots rather than deterministic
weather facts.

View File

@@ -1,110 +0,0 @@
# Scriptorium Adapter Internals
This document describes the subprocess adapter in
`internal/adapters/scriptorium`.
## Purpose
The adapter runs `scriptorium render` for prompt preflight and `scriptorium run`
for Markdown report generation or structured generated-text output. It isolates
subprocess execution, argv construction, timeout handling, output capture, and
exit-code interpretation from app and domain packages.
## Inputs And Outputs
Inputs:
- prompt ID
- YAML prompt input data package path
- report output path for `run`
- raw generated-text output path for structured `run`
- configured binary, config path, profile, timeout, and extra arguments
- context for cancellation
Outputs:
- argv used for execution
- captured stdout and stderr
- truncation flags for captured output
- exit code
- report output path for `run`
- raw generated-text output path for structured `run`
## Boundaries
`internal/adapters/scriptorium` owns Scriptorium command construction and
subprocess execution. It does not choose report types, build prompt input,
fetch weather data, decide workflow order, or persist workflow metadata.
The adapter exposes request and result structs for render, Markdown run, and
structured generated-text run operations. State persistence uses state-owned
artifact shapes; app orchestration converts adapter results before saving.
## Config Fields Used
- `scriptorium.binary`
- `scriptorium.config_path`
- `scriptorium.profile`
- `scriptorium.timeout`
- `scriptorium.extra_args`
## Commands
Render preflight argv starts with:
```text
scriptorium render --prompt <prompt_id> --input data_package=<path> --format json
```
Report generation argv starts with:
```text
scriptorium run --prompt <prompt_id> --input data_package=<path> --out <path>
```
Structured generated-text argv uses the same `scriptorium run` form, with the
`--out` value set to the raw generated-text JSON artifact path. The adapter
does not add `--format`, schema path, or JSON Schema flags for structured
generation; Scriptorium selects the structured output schema from prompt
configuration.
Configured `--config` and `--profile` flags are inserted after the subcommand
and before prompt-specific arguments. Extra arguments are appended after the
built-in arguments.
## Execution Behavior
The adapter runs commands without shell interpolation. The same private
execution path is used by render, Markdown run, and structured run after
command-specific request validation and argv construction.
When `scriptorium.timeout` is greater than zero, each subprocess call uses a
context with that timeout. Stdout and stderr are captured separately, capped at
1 MiB each, and marked as truncated when the cap is reached.
## Failure Behavior
- Missing prompt ID or data package path returns an error before subprocess
execution.
- Missing run output path returns an error before subprocess execution.
- Subprocess start errors, context cancellation, and timeouts are wrapped with
operation context by the caller-facing method.
- Nonzero render, Markdown run, and structured run exits return the captured
result plus an error containing the exit code and stderr.
## Tests
Inspect:
- `internal/adapters/scriptorium/runner_test.go`
- `internal/app/app_test.go`
- `internal/cli/root_test.go`
## Invariants
- No shell interpolation is used.
- The Scriptorium input name is `data_package`.
- The file at the data package path is YAML produced by `internal/promptinput`.
- Render, Markdown run, and structured run preserve command-specific result
structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -1,146 +0,0 @@
# State Internals
This document describes filesystem state in `internal/state`.
## Purpose
`internal/state` owns managed workspace paths, atomic JSON writes, persisted
metadata, prior snapshot lookup, and read-only artifact inspection helpers.
## Inputs And Outputs
Inputs:
- workspace configuration
- resolved report definition and valid period
- module snapshot
- prompt input data package
- preflight artifact
- generated-text raw, run-result, validated text, and render-context artifacts
- rendered report path preparation request
- RunID for inspection lookups
Outputs:
- module snapshot JSON path
- prompt input data package YAML path
- render preflight JSON path
- generated-text raw JSON path
- generated-text run-result JSON path
- validated generated-text JSON path
- render context JSON path
- managed Markdown report path
- metadata JSON path
- prior comparable snapshot metadata
- loaded module snapshot, data package, generated text, generated-text run
result, or render context
- recent report records for inspection
## Boundaries
`internal/state` owns local filesystem layout, path validation, durable writes,
metadata reads, prior lookup, and report listing. It does not fetch weather
data, derive forecasts, build prompt input content, compare module contents,
invoke Scriptorium, import adapter result types, or parse CLI flags.
Preflight persistence uses the state-owned `PreflightArtifact` shape. The app
converts adapter render results into that shape before saving.
## Config Fields Used
- `workspace.root`
- `workspace.snapshots_dir`
- `workspace.reports_dir`
- `workspace.data_packages_dir`
- `workspace.preflight_dir`
- `workspace.notifications_dir`
Workspace subdirectories must be relative paths that stay under
`workspace.root`.
## Managed Layout
Paths are derived from the resolved report definition's artifact group, the
valid-period start date for dated artifacts, and the RunID.
```text
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.modules.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.raw.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.run.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.generated_text.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.render_context.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.yaml
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
notifications/<artifact_group>/<YYYY-MM-DD>/<run_id>.distributor.json
reports/<artifact_group>/<run_id>.md
```
Metadata is stored beside module snapshots and links the module snapshot, data
package, preflight, report paths, notification path when attempted, and
configured prompt location. For generated-text-template reports, metadata also
records the generated text schema ID and links the raw generated text,
Scriptorium run result, validated generated text, and render context artifacts.
Markdown-report metadata omits those generated-text fields. Report listing
walks metadata files under the snapshots directory.
## Prior Lookup
Prior snapshot lookup reads stored metadata through the shared lookup path and
selects the latest earlier snapshot whose report ID is compatible with the
current report definition.
- Daily Today compares with prior Daily Today snapshots for the same valid
local date.
- Tomorrow Report compares with prior Tomorrow Report snapshots for the same
valid local date.
- 3-Day Outlook compares with prior 3-Day snapshots for the same valid local
date.
- Weekend Outlook compares with prior Weekend snapshots for the same weekend
window.
- Hourly Report uses the rolling-window comparison strategy and currently
returns no prior snapshot from filesystem lookup.
- Storm Report has no prior lookup because explicit event-window comparison is
not searched by the filesystem store.
## Writes And Inspection
Durable JSON writes use shared atomic file helpers. Generated-text raw and
validated JSON artifacts are written atomically as bytes; generated-text run
result and render context artifacts are written atomically as JSON. Managed
Markdown reports are prepared by creating their parent directory; Scriptorium
writes the report body to the prepared path. Extra Markdown copies are handled
by app orchestration. Distributor notification debug artifacts are written
atomically when notification is attempted and include rendered distributor
pipeline ID, bundle ID, idempotency key, bundle paths, upload status, latest
run status, and redacted errors.
Inspection helpers read existing metadata, module snapshot, data package,
generated text, generated-text run result, and render context files. Missing
metadata directories return no inspection records or no prior snapshot rather
than creating state.
## Failure Behavior
- Invalid workspace paths return validation errors.
- Missing required metadata fields prevent metadata writes.
- JSON writes use a temporary file followed by rename where practical.
- Read and decode failures include path context.
- Unknown RunIDs produce an actionable lookup error.
## Tests
Inspect:
- `internal/state/filesystem_test.go`
- `internal/app/app_test.go`
## Invariants
- Managed paths stay under the configured workspace root.
- Artifact grouping comes from report definitions.
- Metadata links artifacts produced for a run.
- Generated-text artifacts live under the snapshots tree beside module
snapshots and metadata.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -1,101 +1,76 @@
# Weather Data Internals
This document describes Weather API ingestion into `weatherdata.Bundle`.
`internal/weatherdata` owns the normalized, wire-independent weather bundle
that passes from collection through rendering. The Weather API
adapter translates provider responses into these types; its request, response,
and availability contract is documented in the
[Weather API integration guide](../integrations/weatherapi.md).
## Purpose
## Bundle contract
`internal/adapters/weatherapi` fetches normalized weather data from the
configured Weather API and assembles the bundle consumed by forecast derivation
and module builders. Module builders expose normalized current conditions and
weather story context when those sources are available.
`Bundle` has a collection timestamp (`FetchedAt`), source provenance
(`Sources`), and collection-level warnings (`Warnings`). Its product fields are
optional so an allowed missing source can be represented without manufacturing
weather data.
## Inputs And Outputs
| Field | Normalized product |
| --- | --- |
| `Observation` | Station observation |
| `Current` | Current conditions |
| `Hourly` | Hourly forecast periods |
| `Narrative` | Narrative forecast |
| `Alerts` | Active-alert check, including an explicitly empty result |
| `Discussion` | Forecast discussion and its time-range sections |
| `Daily` | Daily forecast periods when supplied |
| `WeatherStory` | Latest weather story |
| `SPCConvectiveOutlooks` | Convective outlook run, discussions, and GeoJSON geometry |
Inputs:
The bundle carries values rather than provider request details. Consumers use
it to construct report facts and data packages; they should not infer a
provider endpoint or retry policy from the normalized types. See
[collection](collect.md) for assembly and
[report templates](../templates.md) for the values exposed to authors.
- `config.Config` with Weather API URL, timeout, format, units, timezone,
precision, and missing-source policy
- HTTP responses using the Weather API `data` envelope
An alert run retains its check time and individual alert payloads for overlap
selection. Its source entry retains provider provenance; the full provider
envelope is not carried into the normalized bundle.
Outputs:
## Source provenance
- `weatherdata.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, latest weather story, source
records, source warnings, and typed SPC convective outlook data when that
optional source is available
- optional saved bundle JSON through app fetch helpers
Every checked source is represented by a `Source` entry. The record identifies
the source (`Name`), request location and query (`Endpoint`, `Query`), fetch
time, provider issue and update times when available, a SHA-256 digest of the
source data, and whether the source was unavailable (`Missing`). Its warnings
stay with that source in addition to the bundle-level warning list.
## Boundaries
An empty product can be meaningful checked data. For example, an explicit
empty alerts result is not missing and retains its source hash. A source is
marked missing only when the adapter's missing-source policy treats the
response or parsing failure as unavailable. The policy itself belongs to the
[configuration reference](../config.md).
- The adapter owns HTTP calls, response-envelope handling, source hashing, and
decoding into internal bundle types.
- It does not derive dayparts, resolve report periods, build module values, compare
snapshots, write report state, or invoke Scriptorium.
Accepted hourly forecast periods always have nonzero start and end times, with
the end after the start. Collection rejects a required hourly product that does
not meet those bounds before it enters downstream derivation.
## Config Fields Used
## Warning semantics
- `weather_api.base_url`
- `weather_api.timeout`
- `weather_api.format`
- `weather_api.units`
- `weather_api.timezone`
- `weather_api.precision`
- `missing_source.default`
- `missing_source.sources`
`SourceWarning` has a source name, stable code, severity, explanatory message,
endpoint, and `CompletenessImpact`. When collection proceeds with a warning,
the same warning appears in `Source.Warnings` and `Bundle.Warnings` so both
local provenance and whole-run consumers see it. A policy that treats a missing
source as an error returns no partial bundle.
## External Adapters Used
Warnings describe data completeness, not rendering or delivery failures.
Those failures are reported by [application orchestration](app-orchestration.md).
- Weather API HTTP service
## Boundaries and verification
See [Weather API integration](../integrations/weatherapi.md) for the external
contract used by this project.
This package defines data shapes and has no HTTP client, configuration loader,
filesystem access, or template behavior. Focused tests cover the normalized
types and the Weather API adapter verifies translation into them:
## State Or Manifest Behavior
The adapter records source name, endpoint, query, fetch time, source timestamps
when available, SHA-256 hash over compact raw `data` JSON, missing status, and
source warnings. Successful `data: null` responses from `/alerts/active`
represent a checked empty active-alert list, not a missing source. Successful
non-null `/outlooks/convective` responses with empty outlook and discussion
arrays represent checked empty outlook data.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
SPC convective outlook data is stored on
`weatherdata.Bundle.SPCConvectiveOutlooks`. The collected run keeps upstream
run metadata, location identifiers, ordered outlook records, discussion
records, and each outlook's raw GeoJSON geometry. Source provenance for this
payload uses the `spc_convective_outlooks` source name, endpoint
`/outlooks/convective`, the query sent by the adapter, timestamps, and a hash
of the raw `data` object.
## Skip And Resume Behavior
No resume behavior. Optional missing or malformed sources may be omitted,
warned, or treated as errors according to missing-source policy. Hourly forecast
data is required and cannot be skipped.
## Failure Behavior
- Missing or invalid `weather_api.base_url` prevents client construction.
- HTTP errors, response read failures, and envelope decode failures include
endpoint context.
- Missing hourly data or hourly forecasts with no periods fail bundle fetch.
- Optional sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
- Explicit `data: null` from `/outlooks/convective` follows optional
missing-source policy.
## Tests
Inspect:
- `internal/adapters/weatherapi/client_test.go`
- `internal/app/app_test.go`
## Invariants
- Weather facts come from normalized source data.
- Full hourly and narrative products are fetched; Go owns report-period
selection.
- Source provenance and warnings remain inspectable downstream.
```sh
go test ./internal/weatherdata
go test ./internal/adapters/weatherapi
```

View File

@@ -1,313 +1,235 @@
# Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery,
and operational caveats. For symptom-specific diagnosis, see
[Troubleshooting](troubleshooting.md).
This guide covers normal output handling, Distributor notification, secure
prompt diagnostics, and cleanup of legacy application state. See the [CLI
reference](cli.md) for command syntax and the [configuration reference](config.md)
for fields, defaults, and notification templates.
## Normal Workflow
## Normal Operation
Generation commands:
After configuring a Weather API endpoint, generate one report:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate hourly
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```sh
weatherreporter generate today
```
Generation commands resolve a report period, fetch a Weather API bundle, build
a JSON module snapshot, build a YAML prompt input data package, run
`scriptorium render`, and write managed artifacts under the configured
workspace. Markdown-path reports then run `scriptorium run` directly to the
managed Markdown report path.
With no configured output directory, the command writes `today.md` in the
current directory. Set `output.directory` to use one ordinary publication
directory for reports, or choose a one-command operator-owned file with
`--out`; a relative path is resolved from the current directory and an absolute
path is used directly. The explicit flag takes precedence over the configured
directory. Weatherreporter renders in memory and atomically replaces the
selected destination only after generation and rendering succeed. It does not
create a default workspace, metadata, receipts, or intermediate output files.
`generate tomorrow` and `generate hourly` use the generated-text-template
workflow. They run structured `scriptorium run` to raw GeneratedText JSON,
validate the structured text, save a render context, and render the managed
Markdown report from embedded templates. `generate hourly` covers the six-hour
rolling period from generation time in the effective report timezone and is not
part of scheduled morning or evening batches.
A missing configured directory is created only as part of successful report
publication. If its existing path is not a directory or cannot be inspected,
the command stops before prompt inspection or weather collection, leaving any
existing report unchanged. See the [configuration reference](config.md) for the
field definition and validation rules.
When distributor notification is enabled, weatherreporter uploads the managed
Markdown report after report rendering succeeds and final metadata is saved.
`--out PATH` writes an extra Markdown copy for generated reports; it is not used
as the distributor upload source.
Before a destination is published, provider, validation, rendering, write, and
cancellation failures leave an existing report unchanged. A notification
failure happens after publication, so retain and use the completed Markdown
file while resolving the delivery error. The JSON result identifies the
absolute output path and active profile, backend, model, warnings, validation,
debug, and notification information; see the [CLI reference](cli.md) for its
exact fields.
Batch commands:
Weatherreporter validates the final output filename before prompt inspection or
weather collection. A valid long filename is published through a short,
same-directory temporary sibling, so temporary naming does not shorten the
operator-selected destination. A rejected filename does not create a missing
parent directory. The final destination itself must be absent or a regular
file: symlinks, directories, named pipes, sockets, and other special objects
are rejected before prompt inspection or weather collection. The destination is
checked again immediately before the atomic replacement; cancellation or a
deadline at that point leaves the prior report unchanged and skips notification.
```text
weatherreporter run morning
weatherreporter run evening
`SIGINT` and `SIGTERM` request orderly cancellation of an active action. The
command lets cancellation and related cleanup finish before it exits; use the
usual failed result or error to determine whether an output was published.
## Batch Outputs And Distributor Notification
Run a scheduled batch with an explicit output directory when appropriate:
```sh
weatherreporter run morning --out-dir ./reports
```
`run morning` generates Daily Today and the 3-Day Outlook, plus Weekend Outlook
except on Sunday. `run evening` generates the Tomorrow Report. Batch
commands print a JSON summary to stdout, write compact per-report status lines
to stderr, continue independent reports after one report fails, and return
nonzero when any report failed. When notification is configured, the summary and
status lines include notification status, accepted distributor run ID, or
notification error fields for each attempted report. `--out-dir PATH` writes
extra Markdown copies using report default filenames such as `daily.md`,
`three-day.md`, `weekend.md`, and `tomorrow.md`; these copies are not used as
distributor upload sources.
Without `--out-dir`, batch reports are written beneath `output.directory` when
configured, otherwise the current directory. The explicit directory applies
only to that command and takes precedence over the configured fallback.
Morning runs Today, Tomorrow, and every eligible dated Daily Report; evening
runs Tomorrow and the same eligible Daily Reports. Eligible Daily dates begin
after tomorrow and require complete hourly coverage for their local civil day.
A batch collects once, determines the complete report set, and validates every
final output destination before executing its first report prompt. A destination
collision, such as a directory named `tomorrow.md`, stops the batch before any
report output is created or replaced. After successful validation, each selected
report processes independently and successful outputs remain available if
another report fails. If cancellation or a deadline is observed during the
sequence, Weatherreporter stops before starting another report. It retains
already published files, marks interrupted and unstarted reports as canceled in
the result, and skips batch notification.
## Filesystem Layout
When `notify.distributor.enabled` and batch notification are enabled,
Weatherreporter sends one Distributor upload only after every selected output
exists. If an item fails, the batch notification is skipped and successful
files remain at their selected destinations. A batch notification failure also
leaves all successfully published report files in place. Distributor source
files are those operator-owned Markdown outputs; rendered bundle paths and
delivery status appear in the result, not in a local notification receipt.
Remote Distributor response text is not included in command output. Instead,
notification failures use stable local diagnostics while retaining the upload
and status identities needed to investigate delivery with Distributor.
Report counters count report items only. A batch notification failure therefore
returns a failed batch status even when all report counters show success; the
top-level notification result contains the delivery diagnostic.
The default workspace root is `workspace`.
For a single report, Distributor notification follows the atomic output write.
Enabled notification configuration, including the HTTP(S) endpoint and
templates, is validated before report processing. A malformed endpoint does not
collect weather data, generate a report, publish output, or invoke Distributor.
See the [configuration reference](config.md) for endpoint, pipeline, bundle,
idempotency-key, and per-report path templates.
```text
workspace/
snapshots/
daily/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
hourly/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
tomorrow/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
<run_id>.generated_text.raw.json
<run_id>.generated_text.run.json
<run_id>.generated_text.json
<run_id>.render_context.json
storm/
YYYY-MM-DD/
<run_id>.modules.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.yaml
three-day/
YYYY-MM-DD/
<run_id>.data_package.yaml
weekend/
YYYY-MM-DD/
<run_id>.data_package.yaml
hourly/
YYYY-MM-DD/
<run_id>.data_package.yaml
tomorrow/
YYYY-MM-DD/
<run_id>.data_package.yaml
storm/
YYYY-MM-DD/
<run_id>.data_package.yaml
preflight/
daily/
YYYY-MM-DD/
<run_id>.render.json
three-day/
YYYY-MM-DD/
<run_id>.render.json
weekend/
YYYY-MM-DD/
<run_id>.render.json
hourly/
YYYY-MM-DD/
<run_id>.render.json
tomorrow/
YYYY-MM-DD/
<run_id>.render.json
storm/
YYYY-MM-DD/
<run_id>.render.json
notifications/
daily/
YYYY-MM-DD/
<run_id>.distributor.json
three-day/
YYYY-MM-DD/
<run_id>.distributor.json
weekend/
YYYY-MM-DD/
<run_id>.distributor.json
hourly/
YYYY-MM-DD/
<run_id>.distributor.json
tomorrow/
YYYY-MM-DD/
<run_id>.distributor.json
storm/
YYYY-MM-DD/
<run_id>.distributor.json
reports/
daily/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
hourly/
<run_id>.md
tomorrow/
<run_id>.md
storm/
<run_id>.md
## Comparison Bundles
Use `compare` when an operator needs to evaluate explicit Promptkit profiles
against the same report input. The command writes one flat, operator-owned
bundle directory and never sends a Distributor notification. Command syntax,
profile validation, JSON output, and exit behavior belong to the
[CLI reference](cli.md); the durable file contract belongs to the
[comparison bundle contract](integrations/comparison-bundle.md).
The output destination follows the normal `output.directory` fallback. An
explicit `--out-dir` takes precedence and names the exact bundle directory,
not a parent to be combined with another name. The standard names are derived
from the report output name, such as `comparison-today` and
`comparison-daily-2026-05-29`; see the [configuration reference](config.md)
for output-directory resolution.
A comparison bundle contains the shared data package, a manifest, and one
Markdown file for every successful profile. Treat all of these files as
potentially sensitive: the data package and generated reports can contain
location or forecast context. Weatherreporter creates no application-owned
history, retention store, or cleanup job. Retain, archive, or remove only the
specific bundle directories your operating policy permits.
The destination is preflighted before prompt inspection and collection, then
rechecked immediately before an atomic publish. A missing or empty directory
is usable. A nonempty directory can be replaced only when `--replace` is given
and it is recognized as a current Weatherreporter comparison bundle; ordinary
directories, symlinks, and unsafe destinations are rejected. Existing v1
bundles are not recognized for replacement: move or remove them first.
Cancellation and
all failures before publication preserve an existing bundle, including a
cancellation observed while a replacement is being prepared. If guarded
restoration cannot complete, the error names the retained sibling bundle for
manual recovery. Profile failures are different: the command publishes a
complete partial bundle, with failed profiles represented in the manifest and
no Markdown file for those profiles.
Comparison preflight also checks that private publication siblings can be
formed. An infeasible destination name is rejected before a missing parent
directory is created.
## Local Prompt Profile Override
Hourly normally selects the embedded `weather-light` profile. To use a local
OpenAI-compatible model without changing prompts or application code, copy
[weather-light-local-profile.yml](../examples/weather-light-local-profile.yml),
set its `endpoint` and `model` for the local server, and configure the copy as
`promptkit.profile_file`. The profile file's `weather-light` definition
completely replaces the embedded definition; it does not affect a report that
selects another profile ID.
Prompt and profile validation occurs before weather collection. A malformed
profile file, missing required credential, or unsupported selected backend
stops the command before collection. A reachable profile can still fail later
if its local model endpoint is unavailable; Weatherreporter does not switch to
a remote profile.
## Optional Prompt Debug Capture
Use `--llm-debug-dir` only when content-rich prompt diagnostics are required:
```sh
weatherreporter generate today --llm-debug-dir /var/tmp/weatherreporter-debug
```
Managed artifact filenames use the RunID, so repeated runs for the same valid
period do not overwrite each other.
The directory must be absolute. Requested captures are written with restrictive
permissions beneath the supplied directory, organized by report and run. They
can contain rendered prompts and generated output, so limit access to trusted
operators and remove the captures when they are no longer needed. Comparison
captures additionally identify each selected profile so concurrent executions
remain distinct. Normal output, summaries, and routine logs omit that sensitive
content. Debug capture is never created for an ordinary command without
`--llm-debug-dir`.
## RunID And Metadata
Secure prompt debug capture is currently available only on Unix hosts, where
Weatherreporter can keep every traversal and write anchored to opened directory
descriptors without following symbolic links. On other platforms, requesting
`--llm-debug-dir` fails before prompt inspection, weather collection, or
provider execution; ordinary commands without the flag remain available.
RunIDs are based on generation time plus report ID:
Preparation captures retain only the provider endpoint origin and reviewed
execution settings. URL user information, paths, queries, fragments, and
unrecognized provider parameters are omitted.
```text
20260529T100000.123456789Z_daily_today
Each run directory may contain `preparation.json` (v3), `execution.json` (v3),
and, for a provider generation failure, `failure.json` (v1). The failure
artifact retains the safe category, HTTP status, and provider code, type, and
message for trusted debugging only. Ordinary command output never includes
those provider details.
Capture writes are confined to the requested root and fail if an unsafe
filesystem component prevents secure artifact creation.
If capture creation or writing fails, the affected run fails rather than
silently continuing without the requested diagnostics.
## Diagnosing Failures
Start with the command error and JSON summary. For a report generation failure,
the selected destination was not replaced; for a notification failure, inspect
the completed destination and the notification result. For a batch failure,
use the per-report statuses and retain successful output files. For a comparison
failure, inspect the published manifest when its path is present: individual
profile failures retain their safe result and successful Markdown files, while
cancellation and pre-publication errors leave the prior destination unchanged.
If a replacement commits but cleanup of its prior sibling backup fails, the new
bundle remains valid and its artifact paths appear in the failed command
summary. The summary records a safe `publication_cleanup` error that indicates
whether a complete prior bundle remains, only partial remnants remain, or no
prior bundle remains; it also identifies when the sibling cannot be inspected.
The returned command error includes a recovery path only when a sibling remains.
Preserve a complete recognized recovery bundle until it has been inspected and
cleaned up manually; partial remnants are not a rollback artifact. Do not
remove the new bundle to retry cleanup.
Enable explicit debug capture only when content-rich Promptkit diagnostics are
necessary.
Weatherreporter does not retain runs for later inspection, resume failed work,
or provide automatic cleanup, archival, remote state, daemon operation, or
automatic storm monitoring.
## Manual Cleanup Of Legacy Workspaces
Older installations may have a directory named `workspace` containing reports,
snapshots, prompt inputs, or notification records from previous versions.
Current commands neither read nor update it. After confirming that no separate
retention requirement applies, remove that specific legacy directory manually;
do not use a broad cleanup command that could remove current operator outputs.
For example, from the directory that contains the old directory:
```sh
rm -rf ./workspace
```
Each generated report writes metadata that links:
- RunID, report ID, variant, and prompt ID
- generation time, timezone, and valid period
- source location, source hashes, and source warnings
- module snapshot path
- prompt input data package path
- preflight output path
- managed Markdown report path
- generated text schema ID and generated-text artifact paths for
generated-text-template reports
- distributor notification debug artifact path, when notification is attempted
Batch summaries include report status, error text when applicable, notification
outcome when attempted, valid period, and known artifact paths for each
attempted report. Notification fields are `notificationStatus`,
`notificationRunId`, and `notificationError`.
## Distributor Notification
Distributor notification is configured with `notify.distributor` and is
disabled by default. When enabled, weatherreporter uploads the managed Markdown
report path recorded in the report result and metadata. That single source file
can be mapped to one or more configured bundle paths. By default, it is mapped
to one dated report path. Extra copies written by `--out` or `--out-dir` are
operator conveniences only.
The rendered pipeline ID selects the configured distributor `http_upload`
workflow. The default bundle ID is a stable logical source identity derived from
producer name, location ID, and report ID:
```text
weatherreporter.{location_id}.{report_id}
```
The default idempotency key appends RunID to the rendered bundle ID so each
report generation has a distinct retry identity. The default bundle path uses
the valid-period start date, artifact group, and RunID. Distributor owns
destination merge, retention, and derived snapshot behavior such as `latest`.
Notification happens after final metadata save for generated reports. Weather
API, module snapshot, data-package, render preflight, Scriptorium run,
generated-text validation, template rendering, and metadata-save failures do
not trigger notification. A notification failure fails that report.
In a batch, other reports continue, the failed report includes notification
fields in the JSON summary, and the batch returns nonzero.
Each notification attempt writes a debug artifact under `notifications/`. The
artifact records the rendered pipeline ID, bundle ID, idempotency key, managed
source path, bundle-relative paths, bundle created timestamp, accepted upload
response, and the latest distributor run status response when available.
Weatherreporter polls status until distributor reports `succeeded` or `failed`,
or until the configured notification timeout expires. The run status includes
the distributor status, error text, and raw run report JSON, which can show
actions such as `replace_older`, `skip_same`, `skip_destination_newer`, or
`failed`. Token values are not written.
Weatherreporter is responsible for selecting the managed Markdown report,
constructing a source bundle, and submitting it to the configured distributor
HTTP endpoint. Distributor remains responsible for destination routing,
publication, and any downstream Markdown-to-HTML transformation. Distributor
leaves destination files alone when they are not tracked by a newly uploaded
bundle, so existing uploaded dated report paths can remain available.
## Inspection
Inspection commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or run `scriptorium`.
```text
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
Use `inspect reports` to find RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use
`inspect modules` to review the persisted ordered module snapshot, and
`inspect data-package` to review the structured prompt package used for
rendering. Use `inspect prior` to see the prior comparable snapshot selected for
Recent Changes, or `null` when none exists. Use `inspect sources` to review
source provenance and warnings without dumping full weather payloads.
## Recent Changes
Recent Changes are computed from structured module snapshots, not rendered
Markdown or YAML text.
Daily Today compares with prior Daily Today snapshots for the same valid local
date. Tomorrow Report compares with prior Tomorrow Report snapshots for the
same valid local date. 3-Day Outlook compares with prior compatible 3-Day
snapshots for the same valid local date. Weekend Outlook compares with prior
compatible Weekend snapshots for the same weekend window. Hourly Report and
Storm Report leave Recent Changes empty.
When no prior comparable snapshot exists, or no configured threshold is crossed,
`recentChanges.items` is empty.
## Recovery
A failed generation run may still leave useful artifacts:
- If `scriptorium render` returns a result with a nonzero exit code, the
preflight JSON and metadata are written for inspection.
- If `scriptorium run` exits nonzero after writing a report, the managed report
and metadata remain available.
- Hourly generated-text failures preserve available intermediate artifacts,
such as the structured run result, raw generated-text JSON, validated
generated text, and render context. Metadata links those paths when it can be
safely written.
- If distributor notification fails, report artifacts and final metadata remain
available, but the report or batch command returns nonzero.
- For batch commands, inspect the stdout JSON summary first, then inspect the
artifact paths for each failed report.
For a bad report, start with:
```text
weatherreporter inspect metadata RUN_ID
weatherreporter inspect sources RUN_ID
weatherreporter inspect modules RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
```
## Operational Caveats
- The application uses one configured Weather API endpoint.
- The application writes local filesystem state only.
- The application does not implement resume, cleanup, archive, remote storage,
daemon operation, or automatic storm monitoring.
- Generated reports and Scriptorium stderr can contain sensitive operational
context. Store workspace artifacts with appropriate filesystem permissions.
This removal cannot be recovered by Weatherreporter. Keep or archive any
historical files that are still needed before deleting them.

View File

@@ -1,125 +1,105 @@
# Architecture
# Architecture Policy
This document defines the development principles for this Go project. It is inward-facing: developers and LLM coding agents should use it to preserve the projects shape, boundaries, and invariants as the code evolves.
## Purpose
## weatherreporter
`weatherreporter` is a deterministic weather briefing and report-preparation application. It consumes normalized weather data from the internal weatherfeeder-backed API, derives report-specific module snapshots and prompt packages, compares module snapshots against prior runs, and invokes an external prompt runner to produce human-facing reports.
This policy defines Weatherreporter's system shape, ownership, dependency direction,
and safety invariants. The [development guide](../development.md) owns the
package inventory; focused documents in `docs/internal/` own implementation detail.
The application should keep meteorological data selection, daypart grouping, threshold detection, forecast-period resolution, and recent-change comparison inside Go domain packages. LLM prompts should receive curated module-based prompt packages rather than raw unbounded source payloads wherever practical.
## System Shape
Report types must be defined through a registry or equivalent mechanism. Each report definition should declare its report ID, prompt ID, valid-period resolver, module composition, comparison strategy, and output naming behavior. Avoid scattering report-type conditionals across CLI and orchestration code.
Weatherreporter is a deterministic weather-report CLI. It collects normalized
weather data, derives facts and modules, builds a curated YAML data package,
executes exact-version Promptkit prompts, validates structured generated prose,
and renders repository-owned Markdown in memory. Completed Markdown is
atomically published to an operator-owned output destination and may then be
uploaded through Distributor.
Generated reports must be associated with explicit metadata, including report type, location, generation time, valid period, source product timestamps or hashes, module snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
An explicit profile comparison prepares one report input once, executes the
same exact prompt and data package across selected profiles concurrently, and
atomically publishes one operator-owned comparison bundle. It remains local:
it does not create application state or send a Distributor notification.
`scriptorium` is an external adapter, not domain logic. Subprocess execution must be isolated under `internal/adapters/scriptorium`, use context-aware execution, avoid shell interpolation, capture actionable stderr, and keep scriptorium-specific flags from leaking into domain packages.
The supported report products are Daily, Today, Tomorrow, and Hourly. A batch
collects once, validates its complete candidate prompt/profile set before
collection, then determines and validates every planned output destination
before executing reports sequentially with one executor. It continues after
independent report failures and sends a batch notification only after every
planned report succeeds.
`distributor` is also an external adapter. Upload behavior must be isolated
under `internal/adapters/distributor`, dependency types from the distributor
module must not leak outside that adapter, and the selected upload source must
be the managed Markdown report rather than optional output copies or broad
workspace scans.
## Ownership And Boundaries
## Project Shape
- `internal/cli` owns command parsing, help, summaries, and one executor
construction per action.
- `internal/config` owns defaults, loading, validation, and secret loading.
- `internal/app` owns in-memory workflow order, partial results, atomic output
publication, and notification coordination through project-owned contracts.
- `internal/comparison` owns comparison identity, durable logical bundle
validation, safe destination recognition, and atomic bundle publication.
- Deterministic domain packages own weather derivation, report periods, modules,
generated-text validation, and template contexts.
- `internal/adapters/weatherapi`, `internal/adapters/promptkit`, and
`internal/adapters/distributor` own their external dependency mechanics.
Default to a small, explicit, dependency-light Go application. Keep the design modular enough to test and change safely, but do not add abstraction unless it protects a real boundary or enables a real extension point.
Dependency-specific Promptkit types remain inside its adapter. The application
does not parse flags, construct provider clients, or render provider output
directly.
Business/domain logic should live outside CLI, transport, and external-adapter packages.
## Prompt Execution Invariants
## Dependency Policy
- Prompts receive curated module packages, never unbounded raw weather payloads.
- Every execution validates the exact prompt version and output contract before
collection. The selected profile is configured explicitly or declared by the
prompt; profiles requiring unsupported direct API keys fail before collection.
A profile may have an empty backend identity when it supplies an endpoint;
PromptKit resolves inherited profiles and optional credential sources when it
executes them.
- Prompt and profile validation completes before weather collection. Raw output
is validated before template rendering.
- PromptKit may make at most the prompt contract's one corrective generation;
exhaustion is a validation rejection, not an application-level retry.
- Comparison validates every explicit profile before collection, prepares one
immutable report input, and delegates backend capacity to Promptkit rather
than adding an application-wide execution limit.
- Generated text fills defined prose slots only. Deterministic facts remain
authoritative and repository-owned templates produce all Markdown output.
- Sensitive rendered prompts, schemas, input bodies, provider endpoints, and
credentials never enter normal summaries or logs. They are written only to
an explicit secure debug root when requested.
- Provider-controlled diagnostics never enter ordinary outputs; they are
retained only in explicit secure failure-debug artifacts.
Prefer the Go standard library where practical.
## Output, Notification, And Testing Invariants
Use external dependencies only when justified by correctness, security, interoperability, or substantial complexity reduction. Good reasons include complex security-sensitive behavior, such as HTML sanitization, or widely used de facto standards, such as YAML parsing.
- Normal execution is stateless: it keeps weather data, prompt input, generated
text, and render context in memory and creates no application-owned durable
state.
- Markdown writes are atomic at an operator-selected destination. A
pre-publication failure, including cancellation observed immediately before
publication, does not replace an existing destination; a notification failure
does not remove a newly published output.
- A single-report final destination is either absent or a regular file.
Symlinks and special filesystem objects are rejected during preflight and
rechecked immediately before the atomic replacement.
- Configuration or explicit CLI input selects that operator-owned destination;
it does not create an application-owned state boundary.
- Comparison bundles are flat, versioned operator outputs. Their guarded
replacement accepts only a recognized current bundle; cancellation and every
pre-publication failure preserve a prior bundle, while individual profile
failures can publish a complete partial bundle.
- Distributor uploads use only the published Markdown output, never a scan of
local files. Single notification follows publication; batch notification
follows publication of every selected report. Batch counters describe report
outcomes only; a failed batch notification is represented separately at the
batch level.
- Comparison never invokes Distributor notification.
- Profile comparison supports operator review only: it does not score, rank,
select, resample, or replay profile executions.
- Default tests are deterministic, offline, and use Promptkit/provider fakes
rather than live provider calls. See the [testing policy](testing.md).
Avoid dependencies for small conveniences. Do not let external dependency types leak across internal package boundaries unless the dependency is itself the explicit public contract of that package.
## Non-Goals
## Package Layout
Use this layout unless the project has a documented reason to differ:
- `internal/app`: application orchestration and top-level use cases.
- `internal/cli`: CLI command definitions, flags, argument parsing, and command wiring.
- `internal/config`: configuration structs, defaults, loading, precedence, and validation.
- `internal/adapters/<name>`: adapters for external CLIs, APIs, databases, object stores, or libraries.
- `internal/api`: HTTP API handlers and request/response types, when the application exposes an HTTP API.
- `internal/transport/http`: HTTP client code, when the application calls HTTP services.
Package-private implementation constants may live near the package that owns them, preferably in `constants.go` when useful.
## Configuration
Centralize configuration loading, processing, precedence, defaults, and validation in `internal/config`.
The goal is to make configuration discoverable and avoid implicit or hidden operational values. User-visible defaults and cross-package operational defaults should be defined in `internal/config/defaults.go`.
Configuration precedence is:
1. CLI flags
2. configuration file
3. built-in defaults
Prefer YAML configuration unless the project has a strong reason to use another format. Config files should be discovered at `/usr/local/etc/<app_name>/config.yml`, with a CLI override via `--config`.
Configuration files should not contain raw secrets unless the application is explicitly designed for that. Prefer environment variables or secret files for secrets. File-backed secrets are loaded through `secrets.directory`; secret values must not be logged, persisted, or included in user-facing output.
## Adapters and External Integrations
Use a hexagonal architecture style for external integrations.
External adapters belong under `internal/adapters/<name>`. If an adapter uses an external dependency, that dependencys interface must not leak outside the adapter package. Other packages should interact only with the adapters API, so the dependency can be swapped, upgraded, or removed without touching unrelated code.
Adapters should be thin. Domain decisions belong in application/domain packages, not inside adapter glue.
## Components and Registries
When the application has major workflow components, each component should live
near the package that owns its contract and have explicit inputs and outputs.
The orchestrator should compose components in an explicit order using a default
sequence, dependency graph, or documented orchestration rule.
If users can select components, validators, renderers, or adapters, selection
should go through a registry or equivalent mechanism rather than scattered
conditionals.
## Embedded Assets
Store embedded JSON schemas, Markdown prompts, templates, and similar assets as separate files, not inline string literals, unless there is a strong reason otherwise.
## Errors and Logging
Errors should be actionable and preserve context. Wrap errors with operation and path/resource context. CLI code should convert internal errors into concise user-facing messages.
Errors and logs must not expose secrets.
Use structured logging where practical. Logs should describe operations, paths, external calls, retries, and failure causes, but should not include large user data by default.
## Context, Timeouts, and Cancellation
Long-running operations should accept `context.Context`. External calls,
subprocesses, HTTP requests, storage operations, and multi-step workflows should
respect cancellation and timeouts.
## State, Files, and Safety
If the application writes durable state, writes should be atomic where
practical. Multi-step workflows should preserve enough state to support
inspection and retry diagnosis after failure.
Code that deletes, moves, or overwrites files must use narrow, explicit paths. Avoid broad parent-directory operations. Cleanup that can cause data loss must be opt-in.
## Testing
Core logic should be testable without real external services. Use fakes, fixtures, or local test doubles for adapters where practical.
Config examples should be load-tested. Important CLI workflows should have
parser or command tests. Component contracts should have focused tests that do
not require running the full application unless end-to-end coverage is
intentional.
## Documentation
Documentation should follow the project documentation policy. Keep user docs focused on implemented behavior. Put future, planned, or aspirational work only under `docs/roadmap/`.
When changing architecture, config, CLI behavior, adapters, or component
contracts, update the relevant docs and examples in the same change.
Weatherreporter is not a weather-data ingestion service, general LLM
orchestration framework, plugin platform, HTTP service, multi-user job system,
or a replacement for Promptkit or Distributor.

View File

@@ -1,204 +0,0 @@
# Development Policy
This document is the contributor workflow policy for `weatherreporter`.
Developers and LLM coding agents should use it with
`docs/policy/architecture.md` and `docs/policy/documentation.md`.
## Repository Layout
- `cmd/weatherreporter`: binary entry point.
- `internal/app`: orchestration for generation, batches, fetch helpers, and
inspection.
- `internal/cli`: command parsing, flag handling, help text, and JSON output.
- `internal/config`: configuration structs, defaults, loading, overrides, and
validation.
- `internal/fileutil`: shared atomic filesystem write and copy helpers.
- `internal/adapters/distributor`: Distributor upload adapter.
- `internal/adapters/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/weatherdata`: normalized weather source facts, source metadata, and
source warnings.
- `internal/forecast`: deterministic forecast derivation.
- `internal/facts`: collected and derived report fact contracts.
- `internal/module`: module IDs, config items, output envelopes, and snapshots.
- `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations.
- `internal/briefing`: prompt-facing module value builders and module registry.
- `internal/changes`: structured Recent Changes comparison.
- `internal/promptinput`: Scriptorium `data_package` construction and
validation.
- `internal/state`: filesystem paths, atomic JSON writes, metadata, lookup, and
inspection support.
- `internal/timeutil`: clock, date, timezone, and period helpers.
- `docs`: user, operator, developer, integration, internal, policy, and roadmap
documentation.
- `examples`: maintained copyable examples.
## Local Validation
Use focused checks while editing and broader checks before committing:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Useful focused checks:
```bash
go test ./internal/cli ./internal/config
go test ./internal/app ./internal/state
go test ./internal/adapters/distributor ./internal/adapters/weatherapi ./internal/adapters/scriptorium
go test ./internal/forecast ./internal/report ./internal/briefing ./internal/changes ./internal/promptinput
```
Run `gofmt -w` on changed Go files before committing.
## Coding Conventions
- Keep domain logic out of `cmd`, `internal/cli`, and adapter packages.
- Prefer small explicit structs and functions over broad framework-style
abstractions.
- Keep package APIs narrow and named around implemented behavior.
- Return errors with operation, path, endpoint, report, or RunID context.
- Do not log or expose secrets.
- Use `context.Context` for external calls, subprocesses, and orchestrated
workflows that may be canceled.
- Use atomic writes for durable JSON artifacts where practical.
- Keep report selection and prompt IDs centralized in `internal/report`.
- Keep Scriptorium argv construction inside `internal/adapters/scriptorium`.
- Keep distributor package types and upload-client construction inside
`internal/adapters/distributor`.
- Keep Weather API transport and envelope handling inside
`internal/adapters/weatherapi`.
## Dependency Policy
Prefer the Go standard library. Add dependencies only when they materially
improve correctness, interoperability, security, or maintainability.
Current external dependencies:
- `gitea.maximumdirect.net/eric/distributor` for distributor source bundle
construction and HTTP upload client behavior.
- `gopkg.in/yaml.v3` for YAML configuration parsing.
When adding a dependency:
- explain why the standard library is not enough;
- keep dependency types from leaking across unrelated package boundaries;
- add tests for the behavior the dependency supports;
- update this policy if the dependency becomes part of contributor workflow.
## Configuration Changes
Configuration is owned by `internal/config`.
When adding or changing a field:
- update `Config` and the nested config struct in `config.go`;
- add or adjust defaults in `defaults.go` when the field has a safe default;
- update loading or CLI override behavior in `load.go` only when needed;
- validate required values and accepted ranges in `validate.go`;
- add or update config tests;
- update `docs/config.md` and maintained examples when the field is user
visible;
- keep secrets out of example config files.
Configuration precedence is:
1. CLI overrides supported by `config.LoadOptions`;
2. configuration file values;
3. built-in defaults.
The default config path is `/usr/local/etc/weatherreporter/config.yml`.
## CLI Changes
The CLI is owned by `internal/cli`.
When adding or changing a command or flag:
- update help text and parser behavior together;
- convert parsed values into app-layer request structs;
- keep domain decisions in `internal/app` or domain packages;
- add parser or command tests in `internal/cli`;
- update `docs/cli.md`;
- update `docs/operations.md` or `docs/troubleshooting.md` when behavior affects
operators.
CLI commands should return concise actionable errors and avoid printing partial
JSON when command construction fails.
## Components And Adapters
Use existing package boundaries before adding a package.
Add a new internal component only when it owns a distinct implemented contract.
Define its inputs, outputs, state behavior, failure behavior, tests, and
invariants in `docs/internal/`.
Adapters should stay thin:
- HTTP adapters own transport, request construction, envelope handling, and
decode boundaries.
- subprocess adapters own argv construction, timeout handling, stdout/stderr
capture, and exit-code interpretation.
- adapter packages should not own report selection, forecast summarization,
Recent Changes, or prompt input schema decisions.
When an external contract changes, update the matching file under
`docs/integrations/`.
## Tests
Core tests must not require live Weather API, Scriptorium, or distributor
services.
Preferred test patterns:
- fake command runners for subprocess behavior;
- `httptest.Server` for Weather API behavior;
- fake distributor upload clients for notification behavior;
- filesystem temp directories for state behavior;
- deterministic clocks for report periods and RunIDs;
- table tests for config validation, CLI parsing, period resolution, and
threshold behavior.
Add focused tests near the package that owns the behavior. Use app-level tests
for workflow ordering, persistence, and cross-package contracts.
## Examples
Examples under `examples/` must be real, maintained, and free of secrets.
When updating examples:
- use implemented config fields only;
- avoid private endpoints and credentials;
- keep comments short and operationally useful;
- add or update validation coverage when a new example file is introduced;
- link maintained examples from `docs/config.md`.
Do not add generated report examples unless they can be kept current without
live external services.
## Documentation Checklist
Documentation updates are part of behavior changes.
Update:
- `README.md` for project orientation or quickstart changes;
- `docs/cli.md` for command and flag changes;
- `docs/config.md` for config fields, defaults, and precedence changes;
- `docs/operations.md` for state, artifact, batch, inspection, and recovery
behavior;
- `docs/troubleshooting.md` for recurring operator-facing failure modes;
- `docs/internal/` for component contracts and invariants;
- `docs/integrations/` for external Weather API, Scriptorium, or distributor
contract changes;
- `docs/roadmap/` only for unimplemented or deferred work.
Non-roadmap docs must describe implemented behavior only.

View File

@@ -1,356 +1,230 @@
# Go Project Documentation Policy
# Documentation Policy
## Purpose
Project documentation must help four audiences:
1. users who need to run the application;
2. administrators/operators who need to configure and operate it;
3. developers who need to understand and change it safely;
4. LLM coding agents that need clear scope, boundaries, and invariants.
Docs should be accurate, concise, task-oriented, and organized by audience. Prefer links to canonical docs over repetition.
This policy assigns each Weatherreporter documentation topic to one canonical
owner. Its goal is to keep documentation accurate, concise, discoverable, and
resistant to drift for users, operators, developers, integrators, maintainers,
and coding agents.
## Core Rules
### 1. Keep docs concise
Each document should cover a defined scope and only the essentials for that scope.
Avoid:
- long background explanations;
- repeated reference material;
- implementation detail in user-facing docs;
- aspirational language outside roadmap docs;
- verbose examples where one minimal example is clearer.
### 2. Document only implemented behavior outside roadmap files
Unimplemented, planned, aspirational, experimental, or future work may be described only under:
- `docs/roadmap/`
No other documentation file, including `README.md`, should describe code, features, modules, stages, commands, config fields, or behaviors that do not currently exist.
If a feature is partial, non-roadmap docs may describe only the implemented portion and its current boundary.
### 3. Use canonical homes
Each type of information should have one canonical location.
Canonical homes:
- project purpose and quickstart: `README.md`
- development principles: `docs/policy/architecture.md`
- configuration reference: `docs/config.md`
- CLI reference: `docs/cli.md`
- operations and recovery: `docs/operations.md`
- troubleshooting: `docs/troubleshooting.md`
- implemented internals: `docs/internal/`
- future work: `docs/roadmap/`
- contributor workflow: `docs/policy/development.md`
- copyable examples: `examples/`
Other files should summarize briefly and link to the canonical source.
### 4. Keep examples real
Examples should be valid, maintained, and free of secrets.
Where practical:
- example configs should load successfully;
- example commands should match real CLI syntax;
- important examples should be covered by tests.
## Documentation Profiles
All projects require:
- `README.md`
- `docs/policy/architecture.md`
Additional docs depend on the project.
### Small library
Recommended:
- `docs/policy/development.md`, if contributor conventions are non-obvious
### Simple CLI
Required:
- `docs/cli.md`
Recommended:
- `docs/policy/development.md`
### Config-driven CLI
Required:
- `docs/cli.md`
- `docs/config.md`
Recommended:
- `examples/`
- `docs/policy/development.md`
### Stateful or operator-facing application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
Recommended:
- `docs/troubleshooting.md`
- `examples/`
- `docs/policy/development.md`
### Modular, staged, service-oriented, or orchestration application
Required:
- `docs/cli.md`, if CLI-based
- `docs/config.md`, if config-driven
- `docs/operations.md`
- `docs/internal/`
- `docs/policy/development.md`
Recommended:
- `docs/troubleshooting.md`
- validated examples under `examples/`
## Required Documents
### README.md
**Audience:** users, administrators, operators
The README is the outward-facing project orientation page.
It should include, in order:
1. concise description;
2. elevator pitch;
3. shortest useful command or usage example;
4. links to targeted docs.
The README should be short. It is not a manual.
The “shortest useful command” means the simplest command that performs the projects core use case. (It does not mean `app --help`.)
### docs/policy/architecture.md
**Audience:** developers, LLM coding agents
`docs/policy/architecture.md` is required for every project.
It is an inward-facing development policy document. It should describe how the project is intended to be built and changed.
It should include:
- project shape;
- core design principles;
- package and boundary philosophy;
- state/persistence philosophy, if applicable;
- external integration philosophy, if applicable;
- error-handling and logging principles;
- testing expectations;
- documentation expectations;
- architectural invariants;
- explicit non-goals, if useful.
For small projects, this file may be brief. It may simply state that the project is intentionally narrow, monolithic, and dependency-light.
### docs/policy/development.md
**Audience:** developers, LLM coding agents
Required for projects maintained by humans and LLM coding agents.
It should include:
- repository layout;
- build/test commands;
- coding conventions;
- dependency policy;
- how to add config fields;
- how to add CLI flags;
- how to add stages/modules/adapters, if applicable;
- how to update examples;
- documentation update expectations.
### docs/config.md
**Audience:** administrators, operators, advanced users
Required for applications with configuration files.
It should include, in order:
1. config file locations and discovery precedence;
2. minimal working config;
3. production-oriented config;
4. full configuration reference;
5. secrets handling, if applicable;
6. links to maintained examples.
The full configuration reference should be canonical.
### docs/cli.md
**Audience:** users, administrators, operators
Required for CLI applications.
It should include, in order:
1. shortest useful command;
2. command overview;
3. complete flag reference;
4. common workflows;
5. diagnostic or recovery commands, if applicable.
Explain when commands are useful, not just their syntax.
### docs/operations.md
**Audience:** administrators, operators
Required for applications that maintain state, support resume behavior, run multiple stages, write durable artifacts, use remote storage, or require recovery procedures.
It should cover:
- normal workflow;
- filesystem layout;
- remote storage layout, if applicable;
- logs and manifests;
- resume/retry behavior;
- cleanup behavior;
- archive/backup behavior;
- safe recovery procedures;
- operational caveats.
### docs/troubleshooting.md
**Audience:** administrators, operators
Recommended once recurring failure modes exist.
Each entry should include:
- symptom;
- likely cause;
- diagnostic command or inspection step;
- safe fix;
- relevant links.
### docs/internal/
**Audience:** developers, LLM coding agents
Required for modular, staged, service-oriented, or orchestration projects.
This directory describes implemented internal components. It is not the roadmap.
Use one file per major component where useful.
Each component doc should include:
1. purpose;
2. inputs and outputs;
3. boundaries;
4. config fields used;
5. external adapters used;
6. state or manifest behavior, if applicable;
7. skip/resume behavior, if applicable;
8. failure behavior;
9. tests to inspect before changing;
10. architectural invariants.
### docs/roadmap/
**Audience:** maintainers, developers, LLM coding agents
This is the only place for planned, future, aspirational, experimental, or unimplemented work.
Roadmap docs should clearly distinguish:
- proposed work;
- accepted plans;
- deferred ideas;
- rejected ideas;
- implementation prompts or task breakdowns, if useful.
Roadmap docs should not be confused with current behavior.
### docs/integrations/
**Audience:** developers, LLM coding agents
Required for projects that depend on external CLIs, APIs, services, protocols, or file formats where the integration contract is important to maintain.
This directory contains concise, versioned reference notes for external integration contracts. It should document only the parts of the external system that this project actually uses.
Use one file per integration where useful.
## Examples Directory
Projects with non-trivial configuration or workflows should include `examples/`.
Useful examples include:
- minimal working config;
- production-oriented config;
- full annotated config;
- local development config;
- remote/object-storage config;
- minimal session/input file.
Examples should be valid, maintained, tested when practical, and linked from relevant docs.
## Security and Privacy
Docs and examples must not include:
- real API keys;
- tokens;
- passwords;
- private keys;
- private environment dumps;
- sensitive user data;
- raw private transcripts;
- private infrastructure details unless intentionally public.
Document secret-handling mechanisms, not actual secret values.
## Maintenance Rules
When docs change, verify the affected behavior.
Where practical:
- load example config files in tests;
- test CLI examples or command parser behavior;
- validate documented flags against real flags;
- remove stale references;
- update links after renames;
- keep roadmap content out of non-roadmap docs.
If documentation and code disagree, fix the documentation and/or open a roadmap item; do not leave aspirational behavior in current-behavior docs.
Documentation is complete only when it matches the current code.
## Documentation Change Checklist
Before merging documentation changes, verify:
- README is concise and orientation-focused.
- `docs/policy/architecture.md` describes development principles.
- Future work appears only under `docs/roadmap/`.
- User-facing docs avoid unnecessary internals.
- Developer-facing docs preserve boundaries and invariants.
- Config examples match the schema.
- CLI examples match real commands and flags.
- Defaults appear in the canonical config reference.
- No secrets or private data are included.
- Links are accurate.
### One Canonical Documentation Owner
Each authoritative fact belongs in one canonical document or documentation
area. A non-owning document may give a short, stable summary for orientation,
but it must link to the canonical owner instead of maintaining a second
definition.
Volatile details include commands, flags, configuration fields and defaults,
report and module IDs, schemas, file names, paths, status and exit behavior,
retry behavior, and runtime guarantees. If readers could reasonably treat a
statement as a contract, its exact documentation belongs with the owner named
in this policy.
Executable sources of truth and documentation owners serve different purposes.
Code, schemas, and embedded assets determine runtime behavior. The canonical
document owns the corresponding explanation or reference for readers. Both may
necessarily express the same contract, but other documentation should summarize
and link rather than create another complete reference. When implementation and
documentation disagree, verify the intended behavior and update them together.
### Current State, Decisions, And Future Work
Outside `docs/roadmap/`, documentation describes implemented behavior only.
Partial features may be described only to their implemented boundary.
An accepted architecture decision may describe an approved direction before it
is implemented, but acceptance is not evidence that the behavior exists.
Current-state documents change when the implementation lands. Temporary
roadmaps own future work, sequencing, and implementation status; they do not
replace durable policies, decisions, or current contracts.
### Audience And Detail
Write for the document's stated audience and include only the detail needed for
its owned topic. User and operator documentation should not expose incidental
implementation detail. Developer documentation should link to user-facing and
external contracts instead of restating them.
### Links
Use descriptive link text and repository-relative links for repository
documents. Link to the canonical owner rather than to a duplicate summary.
Check every added or changed link, and repair or remove links when their target
moves or is retired.
### Examples And Code Fences
Complete copyable files belong in `examples/` when maintained examples exist.
Documentation may use the smallest illustrative snippet needed for its owned
topic, but should link to a maintained example instead of embedding a second
complete copy.
Examples must be valid, secret-free, and tested where practical. Commands,
flags, configuration, imports, and Go snippets must match implemented behavior.
Use a language tag on fenced code blocks, and identify fragments that are
illustrative rather than directly runnable.
### Security And Privacy
Documentation and examples must not contain real credentials, private keys,
private environment dumps, sensitive source material, or private
infrastructure details unless intentionally public. Document secret-handling
mechanisms, not secret values.
## Canonical Ownership
| Topic | Canonical owner | Owned content | Content owned elsewhere |
| --- | --- | --- | --- |
| Product orientation and minimal quickstart | `README.md` | What Weatherreporter is, why it is useful, one shortest successful invocation, and links onward. | Complete command reference, configuration reference, operational procedures, architecture, and implementation detail. |
| Contributor workflow and package inventory | `docs/development.md` | Repository layout, local workflow, validation commands, coding conventions, task-specific change guidance, dependency workflow, and repository hygiene. | Architectural invariants, user-facing contracts, detailed subsystem behavior, and future work. |
| Current application architecture | `docs/policy/architecture.md` | System shape, normative ownership, dependency direction, package boundaries, invariants, safety properties, and non-goals. | Concrete implementation mechanics, contributor procedures, decision history, and future work. |
| Documentation organization | `docs/policy/documentation.md` | Documentation ownership, audience boundaries, maintenance rules, and document lifecycle. | Application architecture and runtime behavior. |
| Testing policy | `docs/policy/testing.md` | Test philosophy, risk-based sufficiency, stable test boundaries, doubles, coverage guidance, regression policy, and criteria for adding, rewriting, or deleting tests. | Subsystem behavior, application contracts, subsystem-specific test inventories, and implementation plans. |
| Release procedure | `docs/release.md` | Version policy, release preparation, validation, tagging, automated publication, verification, failure handling, and release ordering. | General contributor workflow, product contracts, release-specific change summaries, and implementation history. |
| Release notes | `docs/releases/` | One versioned, changelog-style summary for each release, including compatibility and operator action. The file at the tagged commit supplies the corresponding Gitea release body. | Current CLI, configuration, operations, integration, architecture, and internal contracts; release procedure; implementation plans. |
| CLI contract | `docs/cli.md` | Commands, arguments, flags, invocation semantics, stdout and stderr behavior, summaries, and exit behavior. | Configuration field definitions, complete operating procedures, runtime filesystem layout, and command implementation. |
| Configuration contract | `docs/config.md` | Discovery and precedence, fields, defaults, secrets, validation rules, and user-selectable values. | Complete example files, CLI syntax, output lifecycle, and loading implementation. |
| Operations | `docs/operations.md` | Normal output handling, atomic replacement, notification behavior, diagnosis, explicit debug capture, manual legacy-workspace cleanup, permissions, and operational caveats. | Complete CLI syntax, configuration field definitions, logical external contracts, and implementation mechanics. |
| Report template surface | `docs/templates.md` | Implemented template files and partials, render-context fields, editing rules, and maintainer-facing template examples. | Weather derivation, module implementation, generated-text validation internals, and operator procedures. |
| External and durable integration contracts | `docs/integrations/` | Weather API, Promptkit, Distributor, external formats and protocols, durable logical paths and schemas, compatibility behavior, and upstream or downstream responsibilities. | Physical runtime placement and lifecycle, internal transformations, CLI syntax, and configuration defaults. |
| Internal subsystem behavior | `docs/internal/` | Implementation flow, internal collaborators and state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, user-facing contracts, external schemas, operator procedures, and future package plans. |
| Architectural decision history | `docs/adr/`, when repository-local decisions require records | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, and task sequencing. |
| Temporary feature roadmaps | `docs/roadmap/`, while planned work needs coordination | Proposed, accepted, deferred, or rejected work; sequencing; gates; implementation status; and task breakdowns. | Implemented behavior reference and durable decision rationale. |
| Complete copyable artifacts | `examples/` | Maintained configuration and other files intended to be copied or run. | Field-by-field reference, command reference, and prose explanation. |
Conditional owners do not require placeholder files or directories. If
Weatherreporter introduces a new public API, consumer interface, release
process, or other durable documentation responsibility, update this policy to
assign its canonical owner when that responsibility is introduced.
## Boundary Rules
### Orientation, Architecture, And Internals
The README owns product orientation. The development policy routes contributors
and owns the concise current package inventory. Architecture owns normative
structure and invariants. Focused internal documents own implementation
behavior. These documents may link to one another but must not maintain
parallel package or behavior references.
### Commands, Configuration, And Operations
CLI documentation answers how to invoke Weatherreporter and what its command
interface does. Configuration documentation answers what settings mean.
Operations answers how to handle operator-owned outputs and runtime failures,
including diagnosis, explicit debug capture, and safe legacy cleanup.
When a workflow crosses these topics, place the complete procedure with the
document that owns the task and link to the other contracts. Do not duplicate
complete flag, field, or path references to make a workflow self-contained.
### Templates, Integrations, And Implementation
Template documentation defines the maintainer-facing rendering surface.
Integration documentation defines externally observable shapes, logical paths,
protocols, and compatibility behavior. Internal documentation explains how
Weatherreporter produces, transforms, or consumes those contracts.
Internal documents may name a command, field, template value, path, or protocol
to identify a dependency, but must link to its canonical documentation for the
complete definition.
### Release Procedure And Release Notes
The release procedure owns how a maintainer prepares, publishes, verifies, and
recovers from a Weatherreporter release. Release notes under `docs/releases/`
own the concise historical summary for one version and are the checked-in
source for its generated Gitea release body.
Release notes are not current-state reference documents. They may summarize
what changed and link to durable documentation, but they must not become a
second command, configuration, operations, integration, architecture, or
internal reference. Correct the applicable canonical owner in the same change
when a release changes an implemented contract.
The release note at a published tag and the Gitea release generated from it are
historical records. Later corrections on `main` do not rewrite that published
record. Material release errors require the failure handling defined by the
release procedure rather than moving a published tag or overwriting its
release.
### Executable Authority
CLI parsing and help generation are the executable authority for accepted
commands and flags. Configuration structs, defaults, loading, and validation
are the executable authority for configuration behavior. Schemas and embedded
assets are the executable authority for validated formats and template
execution. Tests protect selected contracts and invariants but do not become a
second documentation reference merely by asserting them.
Canonical documentation must be checked against these authorities whenever the
corresponding behavior changes.
### Security Topics
This policy owns what documentation and examples may contain. Architecture owns
application security boundaries and invariants. Configuration owns
credential-supply mechanisms. Operations owns permissions and handling of
sensitive runtime artifacts. Integration documents own consumer-visible
security contracts. Internal documents own implementation mechanisms only.
## Architecture Decision Records
Use sequentially numbered ADR filenames such as
`0001-record-architecture-decisions.md`. Follow the lightweight Nygard format:
1. title;
2. status;
3. date;
4. context;
5. decision;
6. alternatives considered;
7. consequences.
Use one of these statuses:
- **Proposed:** the decision is under consideration and may change;
- **Accepted:** the decision is approved, whether or not implementation is
complete;
- **Rejected:** the proposed decision was considered and not adopted;
- **Superseded:** a later accepted ADR replaces the accepted decision.
A proposed ADR transitions to Accepted or Rejected. An Accepted ADR transitions
to Superseded only when a later Accepted ADR replaces it. An ADR may be created
as Accepted when the decision has already been made.
Treat the decision content of an Accepted ADR as immutable. A changed decision
requires a later ADR rather than a rewrite of the accepted record. A Superseded
ADR must link to its replacement, and the replacement must link back. Rejected
architectural alternatives belong in the ADR; rejected feature ideas belong in
a roadmap when they need to be retained.
## Document Lifecycle
Create durable current-state documentation with the implementation it
describes. Update its canonical owner in the same change when behavior changes.
If ownership moves, remove the old definition and leave a link where navigation
remains useful.
Roadmaps are temporary coordination documents. When their work is complete,
record completion, move any still-useful decisions or contracts to their
durable owners, update incoming links, and archive or remove the roadmap
according to repository practice. Do not preserve completed roadmaps as a
second current-state reference.
Release notes are durable historical summaries rather than temporary roadmaps.
Keep them concise, retain them after publication, and keep current contracts in
their canonical owners.
Before completing documentation work:
- verify affected behavior and examples;
- check commands, flags, fields, defaults, schemas, paths, and identifiers
against their implementation;
- keep unimplemented behavior in a roadmap, subject to the ADR exception;
- validate links and fenced examples;
- confirm non-owning documents summarize and link rather than redefine;
- remove stale or unsupported claims; and
- confirm that no secrets or sensitive private data were added.

337
docs/policy/testing.md Normal file
View File

@@ -0,0 +1,337 @@
# Testing Policy
## Purpose
Our tests exist to make **incorrect changes expensive and correct changes
cheap**.
We do not optimize for test count, line coverage, exhaustive isolation, or the
fewest possible tests. We optimize for sufficient confidence in important
behavior while imposing as little unnecessary friction as possible on future
development.
## Every Test Has A Cost
Every test has an immediate cost and a continuing lifetime cost. It must be
written, reviewed, executed, understood, diagnosed when it fails, updated when
legitimate behavior changes, and maintained as fixtures and dependencies
evolve.
Tests also create cognitive and architectural friction. They can constrain
refactoring, duplicate policy, slow feedback, add noise to failures, and cause
harmless implementation changes to require unrelated suite edits.
A test is warranted when the confidence it provides justifies those costs.
Apply that judgment at two levels:
1. **Per test:** What realistic defect does this test detect, how consequential
would it be, and is that protection worth the test's lifetime cost?
2. **Across the suite:** Does this collection provide materially more
confidence than a smaller, simpler suite would?
Prefer a lean suite that provides sufficient confidence in the risks that
matter without redundant or low-value tests. Some friction is intentional:
tests should make dangerous changes, such as corrupting state, breaking
compatibility, violating security boundaries, or reintroducing subtle defects,
require deliberate review. They should not make ordinary internal changes
needlessly expensive.
Maintenance cost is not a reason to omit testing by default. When omitting a
plausible test, be able to explain why the protected failure is low-risk,
already covered, obvious, reversible, or cheaper to detect elsewhere. Favor
testing when failure would be consequential, subtle, or difficult to observe.
## Default Testing Style
Use a classical or Detroit-style approach:
- Test observable behavior, resulting state, contracts, and invariants.
- Use real internal collaborators when they are fast and deterministic.
- Use fakes, stubs, or mocks primarily at expensive, nondeterministic,
destructive, or external boundaries.
- Prefer package-level behavioral tests over tests coupled to private helpers
or internal call sequences.
- Test exact collaborator interactions only when the interaction itself is a
requirement.
Weatherreporter's important seams include clocks, Promptkit executors, HTTP
services, Distributor uploads, filesystem roots, environment-backed secrets, and any
future source of randomness or nondeterminism.
## Execution Requirements
The [development guide](../development.md) owns baseline repository validation.
The default test suite is:
```sh
go test ./...
```
Run race-enabled tests when a change affects concurrent execution, goroutine
lifecycle, shared mutable state, or cancellation coordination. Use a focused
package command while iterating and `go test -race ./...` when the risk crosses
package boundaries.
Tests in the default suite must be deterministic, offline, and independent of
real credentials. They must not invoke live Weather API, Promptkit providers, or
Distributor services or depend on other mutable external infrastructure.
Tests that require live infrastructure must be explicitly opt-in and clearly
separated from the default suite.
Control clocks, environment variables, filesystem roots, and machine-specific
state when they affect behavior. Tests must be safe to repeat and must not
depend on execution order or state left by an earlier test. Tests that modify
process-global state may remain serial; use `t.Parallel()` only when the test
and its collaborators are actually safe to run concurrently.
## Test Types And Assets
Use each test type where it protects a distinct risk:
- Unit and package tests protect focused domain behavior and invariants through
the narrowest stable boundary.
- Contract tests protect CLI behavior, configuration, durable artifacts,
schemas, templates, integration formats, compatibility, and stable error
identity.
- Integration tests use real deterministic collaborators when correctness
depends on their interaction, while replacing live or nondeterministic
external boundaries.
- App and CLI tests protect representative assembled generation, batch, atomic
output, and notification workflows.
- Fixtures must be minimal, synthetic, versioned with the behavior they
exercise, and free of credentials or private data.
- Golden files are appropriate only when the complete output is intentionally
stable and semantic review of updates is practical.
- Failure-path tests should cover consequential malformed input, dependency
failure, cancellation, partial results, and recovery behavior.
## What Deserves Tests
Prioritize tests for:
1. CLI, configuration, artifact, template, integration, and package contracts.
2. Meteorological domain rules and important invariants.
3. Boundary conditions and malformed input.
4. Failure handling, cancellation, retries, recovery, and partial success.
5. Serialization, schemas, compatibility, and round trips.
6. Previously observed or plausible regressions.
7. Representative app and CLI workflows.
A package-level contract is behavior relied upon by another package or major
collaborator, not every observable implementation detail.
For data integrity, destructive operations, compatibility, security,
concurrency, idempotency, or recovery, presume that durable tests are required
unless the behavior is already credibly protected at another layer.
Do not add tests merely because a function, branch, or line exists. Do not add
a test when the same meaningful risk is already adequately protected
elsewhere.
## Choose The Right Boundary
Test through the narrowest stable boundary that expresses the behavior clearly.
That may be:
- a small pure function when dense domain logic is clearest there;
- a package operation when several internal collaborators jointly produce the
behavior; or
- a larger integration or app boundary when correctness emerges from
interaction.
Do not force every behavior through oversized workflow tests. Do not test every
private helper merely because it exists. Choose the boundary that provides
durable confidence with the least incidental coupling.
## Test Behavior, Not Implementation
A test should protect a decision, contract, or invariant, not memorialize the
current implementation. Before adding or retaining a test, ask:
> What realistic defect would this test catch?
A test is suspect when its main purpose is to detect that someone changed a
private constant, renamed or split a helper, reordered equivalent operations,
changed incidental formatting, replaced one correct algorithm with another, or
refactored private structure without changing behavior.
Refactoring should normally require no test edits unless the changed structure
is itself contractual. A test can be factually correct and still have negative
value when the behavior it protects is too incidental to justify its future
cost.
Use these expectations when evaluating failures:
| Change | Expected effect on tests |
| --- | --- |
| Internal refactor that preserves behavior | Existing tests should normally remain unchanged and pass. |
| Internal default change with no contractual significance | Tests should normally derive expectations from configuration or relationships rather than duplicate the old value. |
| Intentional change to user-visible behavior, policy, schema, or compatibility | Relevant tests should be reviewed and changed deliberately. |
| Accidental contract or invariant violation | Tests should fail; fix production code rather than rewriting tests to accept the defect. |
A failing test is not necessarily a test that should be edited. Many tests may
correctly fail because of one production defect. The maintenance smell is a
correct internal change that requires unrelated expectation changes throughout
the suite.
## Separate Mechanism From Policy
Do not duplicate configurable thresholds and defaults throughout the suite.
Test mechanisms relationally: a configured valid value is accepted, a value
outside the permitted relationship is rejected, and runtime behavior respects
the configured value.
Test an exact default when its literal value is itself a documented user,
operational, safety, protocol, or compatibility contract. The same distinction
applies to timeouts, capacities, retry counts, ranges, thresholds, and output
limits.
When concurrency limits are introduced, distinguish configuration enforcement
from runtime enforcement. Validate accepted and rejected settings separately
from measuring whether observed peak concurrency respects the configured
limit.
## Avoid Semantic Duplication
Each behavior should have a clear test owner:
- CLI parser tests own arguments, flags, and command construction.
- Config tests own loading, precedence, defaults, secrets, and validation.
- Domain tests own weather transformations and invariants.
- Adapter tests own HTTP, Promptkit/provider, and upload boundaries.
- Orchestrator tests own workflow ordering, output publication, partial success,
and failure propagation.
- Filesystem tests own atomic writes and destination-preservation behavior.
- Template and generated-text tests own schemas, render contexts, and rendered
output contracts.
Higher-level tests should not repeat every lower-level case. Tests that are
individually reasonable may still be collectively redundant; assess the
marginal protection of each additional test.
## Use Test Doubles Deliberately
Choose the least elaborate double that provides the required control or
observation:
1. Prefer real collaborators when they are fast and deterministic.
2. Use small in-memory fakes when realistic stateful behavior helps.
3. Use stubs when a dependency only needs controlled responses.
4. Use mocks when the interaction itself is contractual.
Mocks are appropriate for requirements such as uploading exactly once,
notifying only after output publication, propagating cancellation to Promptkit, or
avoiding an external call after an earlier workflow failure. Do not use mocks
merely to isolate every object or reproduce the implementation's call graph.
## Go-Specific Guidance
Use:
- table-driven tests for meaningful behavioral categories and boundaries;
- `t.TempDir()` for real filesystem behavior;
- `httptest.Server` for realistic Weather API interactions;
- test-controlled clocks for periods and RunIDs;
- fake Promptkit executors or provider clients for Promptkit behavior;
- fake upload clients for Distributor behavior;
- fuzz tests when parsers, normalization, or path handling have a broad and
consequential input space;
- golden files only when complete output stability is intentional; and
- a small number of representative app and CLI workflow tests.
Avoid exact error-string assertions unless wording is contractual. Prefer
`errors.Is`, `errors.As`, typed errors, structured fields, or the smallest
stable semantic fragment that identifies the failure. At CLI boundaries,
prefer structured summaries, exit behavior, and stable classifications over
snapshots of complete diagnostic wording.
Golden-file updates must require an explicit local flag. Ordinary validation
must never update golden files automatically, and maintainers must inspect the
semantic diff before accepting an update.
Keep tests readable and direct. Helpers and fixture frameworks must earn their
maintenance cost; do not build elaborate infrastructure for small or isolated
needs.
## Coverage
Coverage is a diagnostic, not a target. Use it to find untested critical
branches and unexpectedly weak packages. Do not write low-value tests solely
to increase a percentage or infer quality from coverage alone.
Pure domain logic will often warrant higher coverage than CLI wiring or thin
external adapters. Uneven coverage is acceptable when it reflects risk.
## Regression Tests
A bug fix should normally include a regression test that fails before the fix
and passes afterward. Prefer the narrowest durable test of the violated
contract or invariant.
Retain the test when the defect could realistically recur and its consequences
justify the ongoing cost. Remove or consolidate it if the design makes
recurrence implausible or a stronger invariant test subsumes it.
## Deleting Or Rewriting Tests
Tests are maintained code, not permanent historical artifacts. Delete or
rewrite a test when its maintenance cost exceeds the confidence it provides.
Candidates include tests that:
- require edits after harmless internal changes;
- assert private constants without protecting a real contract;
- duplicate the same policy across several layers;
- verify mock choreography rather than outcomes;
- snapshot large amounts of incidental output;
- protect risks already covered more effectively elsewhere; or
- are flaky, misleading, obsolete, or no longer correspond to a plausible
failure.
Test removal must be deliberate and within the scope of the change. Identify
the behavior the test protected and show that the behavior is covered more
effectively elsewhere or that the failure is no longer plausible enough to
justify durable coverage. Replace several brittle tests with one stronger
behavior or invariant test when appropriate.
Do not delete or weaken a test merely because it fails after a production
change. First determine whether the failure exposes an accidental regression,
an intentional contract change, or an implementation-coupled assertion.
## Reviewing A Proposed Test
When a proposed test's value or durability is not self-evident, ask:
1. What realistic defect would it catch, and how consequential is that defect?
2. Is the behavior already protected elsewhere?
3. Which layer should own the test?
4. Does it assert a durable contract or incidental implementation detail?
5. What should cause it to fail, and what legitimate changes should not?
6. Could a smaller or more direct test protect the same risk?
7. What ongoing maintenance, execution, and diagnostic cost will it impose?
Written answers are not required for every routine test. Do not add a test when
its expected lifetime cost exceeds its expected protective value.
## Definition Of Sufficient
A suite is sufficient when:
- important contracts and invariants are protected;
- meaningful boundaries and failure modes are exercised;
- consequential regressions are credibly protected against silent recurrence;
- data integrity, destructive operations, compatibility, security,
concurrency, idempotency, and recovery receive risk-appropriate protection;
- external boundaries have realistic local integration coverage;
- representative complete workflows are tested;
- failures provide useful signal rather than redundant noise; and
- legitimate internal changes usually do not require test edits.
Sufficiency is a risk judgment, not a coverage percentage or test count.
Reassess it as Weatherreporter, its users, and the consequences of failure
evolve.
The governing rule is:
> Test heavily where failure is consequential, subtle, or difficult to detect
> after the fact. Test lightly where failure is obvious, reversible, and
> inexpensive.

269
docs/release.md Normal file
View File

@@ -0,0 +1,269 @@
# Release Procedure
## Release Model
Weatherreporter publishes executable binaries through tagged commits on
`main`. Releases use stable semantic-version tags in the form
`vMAJOR.MINOR.PATCH`. The current pipeline does not publish prereleases.
Every release has one nonempty, version-matched note at
`docs/releases/<tag>.md`. After the tag is pushed, the Woodpecker release
pipeline validates the tagged source, builds six binaries, creates SHA-256
checksums, and creates the corresponding Gitea release. The pipeline uses the
checked-in release note as the Gitea release body and does not overwrite an
existing release.
Before `v1.0.0`, a minor release may deliberately change user-facing
interfaces when its release note explains the compatibility impact and
required operator action. Patch releases must not intentionally break the
documented CLI, configuration, durable artifact, or integration contracts in
their minor line.
Published tags and their generated releases are immutable. Never move, reuse,
or delete a published tag, and never manually overwrite the release produced
from it.
## Select The Version And Write The Release Note
Choose an unpublished version and export it as `RELEASE_VERSION`. Run the
commands in this procedure from the Weatherreporter repository root in one
POSIX shell:
```sh
export RELEASE_VERSION=vMAJOR.MINOR.PATCH
```
Create `docs/releases/$RELEASE_VERSION.md` with this structure:
```markdown
# Weatherreporter vMAJOR.MINOR.PATCH
This release ...
## Summary
Summarize the release's purpose and most important outcomes.
## Compatibility
State compatibility with the preceding release and identify any changed CLI,
configuration, durable artifact, integration, or operating contract.
## Upgrade
State the operator actions required to upgrade, or state that no special
action is required.
## Changes
Describe the material user-visible, operational, and maintainer-visible
changes. Link to canonical documentation for exact current contracts.
```
The note is a concise changelog and adoption aid, not a replacement for current
documentation. Update every affected canonical document in the same candidate
commit. Do not include credentials, private infrastructure details, or claims
that are not true of the candidate.
Require the version, path, heading, and minimum sections before continuing:
```sh
set -eu
: "${RELEASE_VERSION:?export an unpublished vMAJOR.MINOR.PATCH version}"
if ! printf '%s\n' "$RELEASE_VERSION" |
grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
then
printf '%s\n' "invalid release version: $RELEASE_VERSION" >&2
exit 1
fi
RELEASE_NOTE="docs/releases/$RELEASE_VERSION.md"
export RELEASE_NOTE
test -s "$RELEASE_NOTE"
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
grep -Fx '## Summary' "$RELEASE_NOTE"
grep -Fx '## Compatibility' "$RELEASE_NOTE"
grep -Fx '## Upgrade' "$RELEASE_NOTE"
grep -Fx '## Changes' "$RELEASE_NOTE"
```
## Validate The Candidate
Run the same substantive checks enforced by the tag pipeline before committing
the release note:
```sh
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
exit 1
fi
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
unformatted=$(
git ls-files '*.go' |
while IFS= read -r go_file
do
gofmt -l "$go_file"
done
)
test -z "$unformatted"
git diff --check
git diff --cached --check
```
Follow every added or changed Markdown link and confirm that its local target
exists. Review the candidate for generated binaries, test output, credentials,
temporary files, replacements, vendored dependencies, and other files that do
not belong in source control.
## Publish The Candidate Commit
Commit the release note and any final current-state documentation updates, then
push `main` through the ordinary repository workflow:
```sh
git add "$RELEASE_NOTE"
git commit -m "Document Weatherreporter $RELEASE_VERSION"
git push origin main
```
Do not tag an uncommitted or unpushed candidate. Record and export the exact
candidate commit after the push:
```sh
RELEASE_COMMIT=$(git rev-parse --verify 'HEAD^{commit}')
export RELEASE_COMMIT
```
## Guard And Tag The Candidate
Run this guard immediately before creating the tag. It requires a clean
checkout on synchronized `main`, valid module hygiene, the version-matched
release note, and an unpublished local and remote tag:
```sh
check_release_candidate() {
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain)"
gowork_value=$(go env GOWORK)
case "$gowork_value" in
''|off) ;;
*)
printf '%s\n' "active Go workspace: $gowork_value" >&2
return 1
;;
esac
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
if grep -Eq '^[[:space:]]*replace([[:space:]]|\()' go.mod
then
printf '%s\n' 'go.mod contains a replacement' >&2
return 1
fi
test -s "$RELEASE_NOTE"
grep -Fx "# Weatherreporter $RELEASE_VERSION" "$RELEASE_NOTE"
git fetch origin main --tags
test "$RELEASE_COMMIT" = \
"$(git rev-parse --verify 'refs/remotes/origin/main^{commit}')"
if git show-ref --verify --quiet "refs/tags/$RELEASE_VERSION"
then
printf '%s\n' "local tag already exists: $RELEASE_VERSION" >&2
return 1
fi
if test -n "$(
git ls-remote --tags origin \
"refs/tags/$RELEASE_VERSION" \
"refs/tags/$RELEASE_VERSION^{}"
)"
then
printf '%s\n' "remote tag already exists: $RELEASE_VERSION" >&2
return 1
fi
}
check_release_candidate
```
Create a lightweight tag, matching Weatherreporter's existing release tags,
and bind it explicitly to the guarded commit:
```sh
git tag "$RELEASE_VERSION" "$RELEASE_COMMIT"
test "$(git cat-file -t "refs/tags/$RELEASE_VERSION")" = commit
test "$(git rev-parse --verify "refs/tags/$RELEASE_VERSION^{commit}")" = \
"$RELEASE_COMMIT"
git show --no-patch --decorate "refs/tags/$RELEASE_VERSION"
```
If inspection finds an error, delete the unpublished local tag, correct the
candidate, and repeat the procedure. Once the tag is pushed, it is immutable.
## Publish And Verify The Release
Push only the selected tag ref. Do not use `git push --tags`:
```sh
git push origin \
"refs/tags/$RELEASE_VERSION:refs/tags/$RELEASE_VERSION"
```
The tag event starts the release pipeline. Its validation step rejects a
non-stable semantic tag, a missing release note, module or repository hygiene
violations, and any failing test, race test, vet, build, module-tidiness,
formatting, or whitespace check. Its build step also verifies that the host
binary reports `weatherreporter $RELEASE_VERSION`.
Wait for the pipeline to succeed, then confirm that the Gitea release:
- targets `RELEASE_COMMIT` through `RELEASE_VERSION`;
- is titled `Weatherreporter $RELEASE_VERSION`;
- uses `RELEASE_NOTE` from the tagged commit as its body;
- contains `SHA256SUMS`; and
- contains Linux, macOS, and Windows binaries for both `amd64` and `arm64`,
named `weatherreporter-$RELEASE_VERSION-<os>-<arch>` with `.exe` on Windows.
Compare the remote tag with the guarded commit:
```sh
remote_commit=$(
git ls-remote --tags origin "refs/tags/$RELEASE_VERSION" |
awk 'NR == 1 { print $1 }'
)
test "$remote_commit" = "$RELEASE_COMMIT"
```
Download `SHA256SUMS` and every release binary into a new temporary directory,
run `sha256sum --check SHA256SUMS`, and execute the binary for the maintainer's
host platform with `--version`. It must print exactly:
```text
weatherreporter vMAJOR.MINOR.PATCH
```
## Failed Publication And Corrections
If the tag pipeline fails after publication, preserve the tag and diagnose the
failure from the pipeline logs. Fix the cause on `main`, select a new patch
version, prepare a new release note, and repeat the complete procedure. Do not
move or recreate the failed published tag.
Do not manually edit an automatically generated Gitea release or republish its
assets. A wording-only correction may be committed to the historical document
on `main`, with an explicit correction note, but it does not alter the file at
the tag or the generated release. Publish a new patch release when the error is
material to installation, compatibility, security, or operation.

140
docs/releases/v0.10.0.md Normal file
View File

@@ -0,0 +1,140 @@
# Weatherreporter v0.10.0
Weatherreporter `v0.10.0` makes report execution stateless, adds stable
weather-specific Promptkit profiles, and turns every successful generation
into one atomic operator-owned Markdown output.
## Summary
- Ordinary generation no longer creates or depends on a managed workspace,
historical run artifacts, metadata, receipts, or prior snapshots.
- `generate` and `run` now publish directly to operator-selected paths, with
useful current-directory defaults when output flags are omitted.
- Local Recent Changes comparison and the historical `inspect` command family
have been removed.
- Promptkit `v0.5.0` and three embedded logical profiles provide a stable model
ladder with complete file- or directory-based overrides.
- Prompt input and generated-text contracts have been tightened, and output,
cancellation, batch preflight, notification, and partial-failure behavior
have focused offline coverage.
## Compatibility
This pre-`v1` minor release intentionally breaks CLI, configuration,
prompt-input, action-summary, and workspace contracts from `v0.9.0`.
- The `workspace:` and `recent_change:` configuration sections are no longer
supported. Strict configuration loading rejects them.
- The `inspect reports`, `inspect metadata`, `inspect modules`,
`inspect data-package`, `inspect prior`, and `inspect sources` commands have
been removed. Weatherreporter no longer reads V1 or V2 run metadata or other
historical workspace artifacts.
- Every successful `generate` writes exactly one Markdown file. Without
`--out`, Daily writes `daily-YYYY-MM-DD.md` and Today, Tomorrow, and Hourly
write `today.md`, `tomorrow.md`, and `hourly.md` in the invocation's current
directory. `--out` selects that file rather than creating an extra copy of a
separately managed report.
- `run` writes selected outputs beneath the current directory unless
`--out-dir` selects another directory. Successful items remain available
when another batch item fails.
- Action summaries no longer expose managed report, metadata, snapshot, data
package, prompt preparation, prompt execution, generated-text, render-context,
or notification-receipt paths. They retain the final `outputPath`, optional
`llmDebugPath`, safe effective profile/backend/model details, validation,
warnings, notification status, and safe errors.
- Batch report items no longer contain per-report notification fields. Batch
notification is represented once at the top level. The `total`, `succeeded`,
and `failed` counters describe reports only, so notification failure can
produce a failed action while `failed` remains `0`.
- The prompt data package advances from `weatherreporter.data_package.v3` to
`weatherreporter.data_package.v4` and removes `recent_changes`. All four
embedded prompts advance from `1.1.0` to `2.0.0`.
- Generated-text schemas now require string-valued `precipitation_timing`; the
model returns an empty string when there is no timing text. The unused
`confidence` field has been removed and is rejected as an unknown field.
Existing operator-owned Markdown files remain valid. Existing workspace trees
are ignored rather than migrated or deleted. Distributor continues to receive
the completed Markdown report, but its source is now the selected operator
output rather than a managed report copy.
## Upgrade
Before replacing `v0.9.0`:
1. Remove `workspace:` and `recent_change:` from configuration files.
2. Give scheduled commands a predictable working directory or explicit
`--out` or `--out-dir` destination. Confirm that these selected files may be
atomically replaced on later successful runs.
3. Remove historical `inspect` invocations and update action-summary consumers
to use `outputPath` and the remaining active-workflow fields.
4. Decide whether old workspace contents have any external retention value.
Weatherreporter no longer reads them; after review, they may be removed
manually using the narrowly scoped procedure in the operations guide.
5. Review Promptkit profile selection and credentials. Hourly defaults to
`weather-light`; Daily, Today, and Tomorrow default to `weather-balanced`.
A configured `promptkit.profile` still overrides every report in one action.
The embedded logical profiles are:
| Profile | OpenRouter model | Default use |
| --- | --- | --- |
| `weather-light` | `deepseek/deepseek-v4-flash` | Hourly |
| `weather-balanced` | `~google/gemini-flash-latest` | Daily, Today, Tomorrow |
| `weather-deep` | `~anthropic/claude-sonnet-latest` | Explicit selection |
Override a complete same-ID definition through `promptkit.profile_file` or
`promptkit.profile_dir` to use different models or a local OpenAI-compatible
endpoint. Definitions are replaced rather than field-merged, and a malformed
matching override fails instead of silently falling back.
See the [CLI reference](../cli.md), [configuration
reference](../config.md), [operations guide](../operations.md), and [Promptkit
integration](../integrations/promptkit.md) for the exact current contracts.
## Changes
### Stateless Execution And Operator-Owned Outputs
- Removed local forecast-change comparison, prior-snapshot selection, durable
module and prompt artifacts, managed reports, metadata compatibility, run
discovery, notification receipts, and the complete `internal/state`
subsystem.
- Added an Accepted architecture decision recording the stateless
transformation pipeline and operator-owned output boundary.
- Kept weather, facts, modules, prompt input, generated text, and render context
in memory during ordinary execution.
- Made output publication atomic and ensured cancellation or deadline expiry
observed before publication leaves an existing destination unchanged.
- Added complete batch-destination preflight before the first report prompt,
so a structural collision cannot leave an unreported partial batch.
- Preserved successful outputs after report or Distributor failure. Batch
notification runs only after every selected report succeeds.
### Promptkit Profiles And Prompt Contracts
- Upgraded Promptkit from `v0.4.0` to `v0.5.0`.
- Added embedded `weather-light`, `weather-balanced`, and `weather-deep`
profiles and mapped each exact prompt to its logical default.
- Added embedded-profile fallback after configured `profile_file` or
`profile_dir` lookup, allowing operators to replace a logical profile without
changing report definitions.
- Added a maintained local-endpoint example for replacing `weather-light`.
- Advanced the four prompt definitions to `2.0.0` and the curated data package
to v4 after removing Recent Changes.
- Required `precipitation_timing`, normalized whitespace-only timing to an
empty string, and removed the unused confidence value.
### CLI, Reliability, Documentation, And Testing
- Simplified action summaries to active workflow identity, output, model,
validation, warning, debug, notification, and safe error information.
- Made batch counters report-only while retaining failed action status and
non-zero exit behavior for batch notification failure.
- Kept prompt and profile inspection ahead of weather collection and validated
every batch candidate before collecting once.
- Replaced state-oriented workflow fixtures with focused generation, batch,
output, cancellation, profile-resolution, Distributor, and CLI coverage.
- Reconciled user, operator, integration, internal, policy, and ADR
documentation around the implemented stateless architecture and removed
completed temporary roadmaps.

33
docs/releases/v0.10.1.md Normal file
View File

@@ -0,0 +1,33 @@
# Weatherreporter v0.10.1
This release repairs release validation after the `v0.10.0` pipeline failed in
its privileged build container. Application behavior is unchanged from
`v0.10.0`.
## Summary
The unreadable-secret configuration test now verifies that its process is
actually subject to file permission bits before asserting that a mode-`000`
file cannot be read. This keeps the test meaningful for ordinary users while
allowing the release suite to run correctly in privileged containers.
## Compatibility
This patch release makes no changes to Weatherreporter's CLI, configuration,
report output, integrations, prompts, profiles, or operating behavior. It is
fully compatible with `v0.10.0`.
## Upgrade
No special operator action is required. Use `v0.10.1` in place of `v0.10.0`;
the `v0.10.0` tag remains immutable, but its failed pipeline did not publish
release binaries.
## Changes
- Made the unreadable-secret test capability-aware when the test process can
bypass filesystem permission bits.
- Preserved the production contract that genuinely unreadable secret files
fail configuration loading.
- Restored portable release validation in Woodpecker's privileged Go
container.

37
docs/releases/v0.11.0.md Normal file
View File

@@ -0,0 +1,37 @@
# Weatherreporter v0.11.0
This release adds a configurable default publication directory for generated
weather reports.
## Summary
Operators can now set `output.directory` once for both individual reports and
scheduled batches. Explicit `--out` and `--out-dir` destinations continue to
take precedence, while installations that omit the setting retain the existing
current-directory behavior.
## Compatibility
This release is additive and compatible with `v0.10.1`. Existing configuration
files, commands, report filenames, Promptkit behavior, and Distributor
notification behavior remain valid and unchanged.
## Upgrade
No special action is required. To use the new default destination, configure
`output.directory` as described in the [configuration
reference](../config.md). Existing deployments may continue using the current
working directory or explicit CLI output flags.
## Changes
- Added strict configuration loading and validation for the optional
`output.directory` field.
- Applied the configured directory consistently to `generate` and `run`, with
explicit CLI destinations retaining highest precedence.
- Preserved relative-path handling, absolute result paths, atomic publication,
cancellation safety, and Distributor notification ordering.
- Strengthened output preflight so existing non-directory paths, uninspectable
paths, and dangling symlink components fail before expensive report work.
- Updated the [CLI reference](../cli.md) and [operations
guide](../operations.md) for the new destination-selection behavior.

66
docs/releases/v0.12.0.md Normal file
View File

@@ -0,0 +1,66 @@
# Weatherreporter v0.12.0
This release completes a repository-wide correctness, security, efficiency,
test-durability, and documentation audit.
## Summary
Weatherreporter now applies stricter validation and bounded diagnostics across
its configuration, weather collection, Promptkit, rendering, publication,
comparison, and Distributor boundaries. Report preparation and execution carry
one reconciled identity, independent weather sources are collected
concurrently, and cancellation preserves completed report and comparison
outcomes.
The release also removes obsolete compatibility surfaces and consolidates
duplicated implementation and test policy without changing ordinary report
commands or output identities.
## Compatibility
This release is compatible with `v0.11.0` for ordinary `generate`, `run`, and
`compare` commands, configuration files, report filenames, comparison bundles,
and Distributor integration.
Sensitive prompt-debug capture through `--llm-debug-dir` is now supported only
on Unix hosts. Non-Unix hosts reject an explicit capture request before prompt
inspection, weather collection, or provider execution because the required
handle-relative, no-follow filesystem guarantees are unavailable there.
Several unused internal compatibility exports were removed. They were not part
of the documented CLI, configuration, artifact, or integration contracts.
## Upgrade
No special action is required for ordinary installations. Operators who use
`--llm-debug-dir` on Windows must run that diagnostic workflow on a Unix host.
Review any automation that depended on undocumented internal Go APIs removed by
this release.
## Changes
- Hardened configuration loading, source validation, secrets rollback,
endpoint validation, HTTP diagnostics, generated-text limits, prompt-debug
redaction, output publication, comparison replacement, and Distributor
failure reporting.
- Reconciled inspected, prepared, callback, and completed Promptkit identity
and provenance before accepting generated content.
- Preserved metric values, civil-day and daypart identity, overnight alerts,
precipitation semantics, and Markdown structure across deterministic report
preparation and rendering.
- Collected independent Weather API sources concurrently and reused readiness
data while retaining deterministic normalized results.
- Preserved completed report and comparison failures independently from shared
cancellation, stopped unfinished work, and skipped batch notification after
cancellation or partial report failure.
- Made secure prompt-debug traversal descriptor-relative on Unix and fail
closed elsewhere. See the [operations
guide](../operations.md#optional-prompt-debug-capture).
- Strengthened default test portability and determinism, including
capability-aware symbolic-link fixtures and platform-appropriate process
signal coverage.
- Removed obsolete compatibility helpers, duplicated test ownership, dormant
persistence code, and completed audit and implementation roadmaps.
- Updated the [architecture policy](../policy/architecture.md), [testing
policy](../policy/testing.md), and focused internal guides to describe the
implemented final state.

134
docs/releases/v0.9.0.md Normal file
View File

@@ -0,0 +1,134 @@
# Weatherreporter v0.9.0
Weatherreporter `v0.9.0` replaces its external Scriptorium execution path with
an in-process Promptkit integration and makes prompt preparation, execution,
validation, and failure artifacts first-class parts of each report run.
## Summary
- Promptkit `v0.4.0` now executes all generated text for Daily, Today,
Tomorrow, and Hourly reports.
- The four exact-version prompts and their JSON Schemas are embedded in the
Weatherreporter binary.
- Prompt preparation and execution have separate durable, redacted provenance
records, while sensitive prompt debugging is explicit and stored outside the
managed workspace.
- Weather API collection now performs a warmup request and retries transient
transport, read, and selected HTTP failures.
- Release binaries now report their embedded version and are published with
checksums through a guarded Woodpecker pipeline.
## Compatibility
This pre-`v1` minor release contains intentional configuration, CLI, and
artifact changes that require review when upgrading from `v0.8.0`.
- The `scriptorium:` configuration section is no longer supported. A file that
contains it fails with a migration error instead of silently ignoring it.
Use `promptkit:` configuration instead.
- The previously exposed but unfinished three-day, weekend, and storm report
surfaces have been removed. Supported report IDs and `generate` commands are
`daily`, `today`, `tomorrow`, and `hourly`. The retired `storm_id`
Distributor template variable is also no longer accepted.
- Generate and batch result items now expose `preparationPath` and
`executionPath` instead of the Scriptorium-oriented `preflightPath` and
`generatedTextResultPath`. An opt-in prompt capture may also add
`llmDebugPath`.
- New runs write `weatherreporter.metadata.v2`, which records Promptkit
preparation and execution paths. Inspection and prior-run lookup continue to
read existing `weatherreporter.metadata.v1` records.
- The built-in `weather_api.precision` default changed from `1` to `0`.
Configurations that explicitly set a value retain that value.
- Report prose may differ because the embedded prompt corpus, structured
output path, alert presentation, and SPC background context have changed.
The documented Go version remains 1.26. Distributor integration remains at
`v0.5.0`. Existing managed workspaces do not require conversion.
## Upgrade
Replace the old Scriptorium block in the Weatherreporter configuration. The
smallest equivalent Promptkit block is:
```yaml
promptkit:
timeout: 2m
```
The embedded prompts default to the Promptkit `gemini-flash-latest` profile.
Ensure that the selected profile's credential environment variable is present,
or configure `promptkit.profile`, an external `profile_file` or `profile_dir`,
or the optional `promptkit.local` backend. Direct per-request API keys are not
supported by Weatherreporter.
Before upgrading automation or downstream processing:
1. remove any `three-day`, `weekend`, or `storm` command, report override, and
`storm_id` template usage;
2. update consumers of action-summary JSON to use the new preparation and
execution path fields;
3. decide whether to retain the new precision default or explicitly configure
the previous value; and
4. preserve the existing workspace if historical V1 runs must remain
inspectable.
Scriptorium, its executable configuration, and its external prompt corpus are
no longer needed by Weatherreporter. See the
[configuration reference](../config.md), [CLI reference](../cli.md), and
[Promptkit integration](../integrations/promptkit.md) for the current
contracts.
## Changes
### Prompt Execution And Artifacts
- Added a project-owned Promptkit adapter with exact prompt and profile
inspection, prepare-once execution, error classification, and bounded
execution timeouts.
- Embedded version `1.0.0` of the Daily, Today, Tomorrow, and Hourly prompts and
their private generated-text schemas.
- Added durable preparation and execution receipts with prompt, profile,
backend, model, hashes, timings, validation status, classified failures, and
paths to every artifact reached during the run. Credentials, endpoints,
rendered messages, request parameters, and generated content are excluded
from these managed records.
- Added `--llm-debug-dir` for explicitly requested content-rich diagnostics.
Debug output must use an absolute path outside the managed workspace and is
written with restrictive filesystem permissions.
- Preflight now validates each exact prompt and selected profile before weather
collection. Batch execution validates every candidate first, collects once,
and retains independent report progress and failure artifacts.
See the [operations guide](../operations.md) for artifact layout, inspection,
debug handling, and recovery.
### Weather Collection And Report Content
- Added a `/conditions/current` warmup before source collection and automatic
retry for transient transport and response-read failures and HTTP `408`,
`429`, `500`, `502`, `503`, and `504` responses.
- Changed the default upstream precision query value to `0`.
- Added embedded background definitions for recognized SPC categorical,
tornado, wind, and hail outlook products.
- Made the Alert Digest more concise: alert descriptions are omitted, and an
SPC-only digest is rendered only for Enhanced, Moderate, or High categorical
risk.
- Removed duplicated alert detail from the prompt-facing metadata module; the
alert digest remains its single prompt-facing owner.
See the [Weather API integration](../integrations/weatherapi.md) for the request,
retry, and response contract.
### CLI, Documentation, Testing, And Releases
- Added `weatherreporter --version`; tagged binaries report `v0.9.0`, while
ordinary local builds report `development`.
- Reworked CLI summaries and inspection coverage around the Promptkit artifact
lifecycle and retained partial-result behavior.
- Reorganized contributor, policy, user, operator, integration, template, and
internal documentation around explicit canonical owners.
- Added focused single-report, batch, CLI, Promptkit adapter, durable-state,
and artifact-path coverage while simplifying orchestration internals.
- Added guarded tag validation and reproducible release builds for Linux,
macOS, and Windows on `amd64` and `arm64`, with SHA-256 checksums and
changelog-backed Gitea releases.

View File

@@ -1,612 +0,0 @@
# Code Quality And Deduplication Audit
## Executive Summary
The codebase is in good pre-release shape. The major architecture is coherent: configuration loading is centralized, external systems are behind adapters, reports and modules have explicit registries, state paths are mostly centralized, and the application orchestration is readable. I do not see a major architectural risk that requires a broad rewrite before the next release.
The top three cleanup targets are:
1. Generated-text/template dispatch is spread across `internal/report`, `internal/app`, `internal/generatedtext`, and `internal/reporttemplate`.
2. Final report completion logic is duplicated between the direct Markdown generation path and the generated-text-template path.
3. Report name, command name, config key, and alias resolution is implemented in multiple packages.
The codebase is ready for a limited cleanup pass. The highest-value work should be small, behavior-preserving centralization around catalogs, finalization helpers, and validation boundaries. Avoid a workflow engine, plugin system, or broad CLI redesign.
## Repository Map Reviewed
Reviewed source areas:
- `cmd/weatherreporter`: executable entry point.
- `internal/cli`: standard-library CLI parsing, command dispatch, inspect command setup, output handling.
- `internal/app`: end-to-end orchestration for fetch, facts, module snapshots, prompt input, generated text, report rendering, state, output copies, and notifications.
- `internal/config`: defaults, YAML loading, CLI overrides, report module overrides, secrets directory, distributor notification templates, validation.
- `internal/report`: report IDs, definitions, registry, valid-period resolution, batch definitions.
- `internal/module` and `internal/briefing`: module IDs, module registry, module builders, missing-data behavior, prompt-facing module output.
- `internal/facts`, `internal/forecast`, and `internal/weatherdata`: collected and derived fact contracts, forecast derivation, normalized upstream data.
- `internal/adapters/weatherapi`: HTTP source fan-out, source metadata, missing-source policy handling.
- `internal/adapters/scriptorium`: subprocess render/run adapter.
- `internal/adapters/distributor`: distributor upload/status adapter.
- `internal/state`: filesystem state store, artifact paths, metadata, snapshots, notification artifacts.
- `internal/generatedtext` and `internal/reporttemplate`: generated-text schemas, validation, render contexts, embedded templates.
- `internal/promptinput`, `internal/changes`, `internal/fileutil`, and `internal/timeutil`.
- `examples/config.yml`.
- Package-level tests across the inspected packages.
Reviewed documentation and intended interfaces:
- `README.md`.
- `docs/policy/architecture.md`.
- `docs/policy/development.md`.
- `docs/policy/documentation.md`.
- `docs/config.md`.
- `docs/cli.md`.
- `docs/operations.md`.
- Relevant `docs/internal/` and `docs/integrations/` documents.
- Existing roadmap files under `docs/roadmap/` for intended future interface direction.
Important areas not inspected deeply:
- Generated local `workspace/` artifacts were not audited as source code. They are useful examples of output shape, but they are not authoritative implementation.
- No nonexistent package areas such as `internal/stage`, `internal/storage`, `internal/artifacts`, `internal/manifest`, or `pkg` were reviewed because this repository does not currently define them.
Commands used for lightweight validation and repository mapping:
```sh
go list ./...
rg -n "func validateGeneratedText|func buildRenderContext|var templates|var schemas|reportKind|reportIDForCommand|reportIDForConfigKey|normalizeReportModules|ReportModuleOverrides|func \(b \*bundleBuilder\) fetch" internal cmd docs examples
```
No full test suite was run because this is a report-only task.
## High-Confidence Deduplication Opportunities
### Generated-Text And Template Dispatch Are Scattered
Affected files/packages:
- `internal/report`
- `internal/app/app.go`
- `internal/generatedtext`
- `internal/reporttemplate/reporttemplate.go`
Duplicated or near-duplicated behavior:
- Report definitions carry `GenerationMode`, `PromptID`, `GeneratedTextSchemaID`, and `TemplateID`.
- `internal/reporttemplate` has separate `templates` and `schemas` maps keyed by string IDs.
- `internal/app` switches on `GeneratedTextSchemaID` in `validateGeneratedText`.
- `internal/app` switches on `TemplateID` in `buildRenderContext`.
- `internal/generatedtext` has report-specific render-context builders that must stay aligned with the schema/template IDs.
Why it matters:
- Adding a new generated-text report requires edits in several packages. A missed registration can produce runtime failures that are hard to catch until a report is generated.
- Template ID, schema ID, generated-text type, validator, and render-context builder are a single policy decision but currently have several partial catalogs.
Recommended refactor:
- Add a narrow generated-text report catalog, likely in `internal/generatedtext` or `internal/reporttemplate`.
- The catalog should map a schema/template ID to:
- validator function;
- render-context builder;
- embedded template ID/path;
- embedded schema ID/path.
- Keep `internal/report.Definition` authoritative for which generated-text assets a report uses.
- Have `internal/app` resolve the generated-text handler once instead of owning report-specific switches.
- Keep the catalog explicit. Do not introduce reflection-heavy registration or a plugin system.
Suggested tests:
- Add a completeness test proving every report with generated-text-template mode has a registered validator, schema, template, and render-context builder.
- Keep existing hourly and tomorrow generated-text workflow tests.
- Add a negative test for an unsupported generated-text schema/template ID with a clear error.
Risk level: Medium. The refactor touches central generation flow, but it can be done with narrow adapters and existing tests.
### Final Report Completion Logic Is Duplicated Across Generation Modes
Affected files/packages:
- `internal/app/app.go`
- `internal/state`
- `internal/adapters/distributor`
Duplicated or near-duplicated behavior:
- Direct Markdown generation and generated-text-template generation both need to:
- produce a managed report artifact;
- update metadata;
- save final metadata;
- optionally copy to `--out` or `--out-dir`;
- optionally notify distributor;
- propagate notification failures as report failures;
- return the same result fields.
- The generated-text path has additional generated-text and render-context artifacts, but the finalization policy is the same after a managed Markdown path exists.
Why it matters:
- Output copy, metadata save, notification artifact, and result behavior are user-facing. If finalization changes, the fix must be made in more than one branch.
- This creates drift risk as additional template-based reports are added.
Recommended refactor:
- Add a small unexported app helper such as `finalizeRenderedReport`.
- Inputs should include the resolved report, run ID, metadata, managed report path, optional output request, notification config, and current result fields.
- The helper should not own the full workflow. It should only perform the common finalization after a report file exists.
- Preserve existing artifact paths and CLI behavior.
Suggested tests:
- Existing single-report tests for managed report path, `--out`, and `--out-dir`.
- Generated-text report tests proving metadata and notification debug artifacts are saved.
- Batch tests proving one report finalization failure does not stop independent reports, while aggregate status is nonzero.
Risk level: Medium. The behavior is central, but the target helper is small and can be tested around existing workflows.
### Report Name, Command Name, Config Key, And Alias Resolution Are Split
Affected files/packages:
- `internal/cli/root.go`
- `internal/app/app.go`
- `internal/config/reports.go`
- `internal/report`
Duplicated or near-duplicated behavior:
- CLI command names are parsed in `internal/cli`.
- App request kinds are mapped to report IDs in `internal/app`.
- Config report keys and aliases are mapped to report IDs in `internal/config`.
- Report definitions, artifact groups, batch output names, prompt IDs, and generation modes live in `internal/report`.
Why it matters:
- Public names and internal IDs are release-sensitive. Drift could make a report available through the CLI but unavailable through config, or vice versa.
- The clean break from older report IDs makes this more important because names also affect distributor paths and public URLs.
Recommended refactor:
- Keep `internal/report` as the canonical home for report identity policy.
- Add explicit report-owned helpers for canonical command/config/batch name resolution, for example:
- `ReportIDForCommandName(name string)`;
- `ReportIDForConfigKey(key string)`;
- `BatchReportsForCommandName(name string)`.
- Keep compatibility aliases only where the project intentionally supports them.
- Let `internal/cli` remain responsible for parsing flags and producing app requests, but avoid duplicating report-name policy there.
Suggested tests:
- Table tests for all supported CLI generate report names.
- Table tests for config aliases and unknown keys.
- Batch command tests for scheduled/default batch membership.
- A registry consistency test proving each CLI-exposed report maps to a registered report definition.
Risk level: Low to medium. The behavior is simple but public-facing.
### Report Module Override Normalization Runs In More Than One Place
Affected files/packages:
- `internal/config/load.go`
- `internal/config/validate.go`
- `internal/config/reports.go`
Duplicated or near-duplicated behavior:
- `Load` calls `normalizeReportModules`.
- `Validate` also calls `normalizeReportModules`.
- `ReportModuleOverrides` assumes usable report keys but silently skips unknown keys if called on a manually constructed or unvalidated `Config`.
Why it matters:
- Normalization currently appears idempotent, but it mutates config and decodes module options. Running it from both load and validation increases the chance of side effects or inconsistent future behavior.
- Silent skipping in `ReportModuleOverrides` is safe on the normal `Load` path but less safe for tests or programmatic callers that construct `Config` directly.
Recommended refactor:
- Split mutation from validation:
- `Load` should apply defaults, file contents, CLI overrides, secret loading, and normalizing mutations once.
- `Validate` should validate normalized config or call a non-mutating report-module validation helper.
- Consider changing `ReportModuleOverrides` to return `(map[report.ID][]module.ConfigItem, error)` or making it unexported behind a validated config path.
- If retaining the current method signature, add a comment documenting that it expects a validated config and add tests around invalid manual config behavior.
Suggested tests:
- Config tests proving module option normalization happens once and produces the same typed options.
- Tests for duplicate report aliases and unknown report keys.
- App-level test with manually constructed invalid report module overrides if the app continues to accept raw `Config`.
Risk level: Low. This is a contained config cleanup.
### Weather API Source Fetching Repeats The Same Lifecycle
Affected files/packages:
- `internal/adapters/weatherapi/client.go`
- `internal/weatherdata`
- `internal/forecast`
Duplicated or near-duplicated behavior:
- Each source fetch method repeats endpoint construction, query option handling, `data:null` or malformed handling, decoding, source metadata construction, and bundle assignment.
- The repeated flow is visible in observation, current, hourly, narrative, alerts, discussion, weather story, and SPC convective outlook fetch functions.
Why it matters:
- Source behavior has important differences: hourly is required, alerts treat `data:null` as checked/no active alerts, weather story has a minimal query, and SPC uses a source-specific endpoint. Those differences are valid, but the common lifecycle is still easy to drift.
- New sources are likely. Repeating source metadata, warning, and malformed-data handling for every source increases maintenance risk.
Recommended refactor:
- Introduce a small source fetch helper or source spec for the common lifecycle.
- Keep source-specific decode and special cases explicit.
- Do not build a generic ingestion framework or reflection-based decoder.
- The helper should centralize:
- source key;
- endpoint;
- query options;
- required/optional behavior;
- null-data policy;
- metadata timestamp/hash extraction;
- warning/error conversion.
Suggested tests:
- Existing fixture-server tests for source count, warnings, and endpoint paths.
- Dedicated tests for required hourly `data:null`, alerts `data:null`, optional source missing behavior, weather story metadata, and SPC overlap behavior.
- A new test proving source metadata shape remains consistent across helper-driven sources.
Risk level: Medium. The adapter is well covered, but source semantics are not all identical.
## Medium-Confidence Opportunities
### Template Module Extraction Has Repeated Optional-Stanza Plumbing
Affected files/packages:
- `internal/generatedtext/render_context.go`
Duplicated or near-duplicated behavior:
- Hourly and tomorrow render contexts both extract many of the same module stanzas from the same `module.Snapshot`.
- The repeated `optionalStanza` calls carry the same module IDs and error pattern.
Why it matters:
- This is not a major behavior risk today, but additional generated-text-template reports will repeat the same extraction pattern.
- It also makes template context changes noisier than necessary.
Recommended refactor:
- Add a small unexported snapshot lookup/cache type in `internal/generatedtext`.
- It can provide typed methods such as `CurrentConditions()`, `HourlyForecast()`, and `PrecipTiming()`.
- Keep report-specific context structs. Do not flatten all template contexts into one generic map.
Suggested tests:
- Existing render-context tests.
- A test that omitted optional modules become nil pointers and extraction errors include the module ID.
Risk level: Low.
### Generated-Text Validators Share Strict JSON Mechanics
Affected files/packages:
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
Duplicated or near-duplicated behavior:
- Report-specific validators perform strict JSON decoding, unknown-field rejection, string trimming, required-field validation, and canonical JSON output.
Why it matters:
- The report-specific validation rules should remain separate, but strict decoding mechanics should be consistent across all generated-text schemas.
- A new generated-text report will likely copy the same parsing pattern.
Recommended refactor:
- Add a small internal helper for strict single-object JSON decode and canonical re-marshal.
- Keep report-specific structs and semantic validation in each report file.
Suggested tests:
- Existing generated-text tests for unknown fields, missing required fields, and canonical output.
- Add one shared-helper test only if the helper has nontrivial behavior.
Risk level: Low.
### Distributor Notification Template Values Are Split Between Config And App
Affected files/packages:
- `internal/config/notify_templates.go`
- `internal/app/app.go`
Duplicated or near-duplicated behavior:
- Config owns template validation and rendering helpers.
- App constructs the concrete template value map, including location ID, report ID, run ID, artifact group, batch output name, bundle ID, and valid-period variables.
Why it matters:
- This split is mostly appropriate because app has report/run context. However, the list of allowed variables and the list of populated variables must stay aligned.
- Future notifier integrations could duplicate the same valid-period value construction.
Recommended refactor:
- Keep rendering/validation in `internal/config`.
- Move value construction into a named app helper with focused tests, or introduce a small config-owned `DistributorTemplateValues` constructor if it can avoid importing app concepts.
- Do not expose distributor package types outside the adapter.
Suggested tests:
- Existing config template validation tests.
- App tests for rendered pipeline ID, bundle ID, idempotency key, and bundle paths for hourly/tomorrow/daily periods.
Risk level: Low.
### Test Setup Is Repeated Across App, CLI, And Adapter Tests
Affected files/packages:
- `internal/app/*_test.go`
- `internal/cli/*_test.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/adapters/distributor/*_test.go`
Duplicated or near-duplicated behavior:
- Tests repeatedly create temporary configs, fake Scriptorium behavior, fake Weather API servers, generated workspace assertions, and notification expectations.
Why it matters:
- Future cleanup around report generation will be safer with focused helpers.
- The current duplication is not severe enough to justify a global test framework.
Recommended refactor:
- Add package-local helpers where duplication is already present:
- app test config builder;
- app fake renderer/notifier setup;
- CLI command invocation helper;
- Weather API fixture server helper.
- Avoid cross-package test utility packages unless duplication becomes materially worse.
Suggested tests:
- No new behavior tests are needed solely for helper extraction.
- Run affected package tests after mechanical helper cleanup.
Risk level: Low.
## Boundary And Responsibility Concerns
The primary package boundaries are sound:
- `internal/config` owns configuration, defaults, YAML decoding, secret loading, and validation.
- `internal/report` owns report identity and valid-period policy.
- `internal/briefing` owns module output construction.
- `internal/facts` owns collected-to-derived fact preparation.
- `internal/app` owns orchestration.
- `internal/state` owns workspace artifact paths and persistence.
- `internal/adapters/*` own external system details.
Areas with boundary ambiguity:
- Generated-text report registration is currently shared across report definitions, app switches, generated-text validators, and reporttemplate maps. A generated-text catalog would make this responsibility clearer without changing the package architecture.
- Report name resolution partly belongs to `internal/report` but is currently repeated in CLI, app, and config. The report package is the better canonical home because names, aliases, IDs, artifact groups, and batch membership are report identity policy.
- App owns distributor template value construction while config owns template validation. This is acceptable, but the value set should be explicitly named and tested because it is part of the notifier contract.
No serious external-system leakage was found:
- Distributor package types are confined to `internal/adapters/distributor`.
- Scriptorium subprocess execution is confined to `internal/adapters/scriptorium`.
- Weather API HTTP details are confined to `internal/adapters/weatherapi`.
## Path, Key, And Naming Construction Review
Path and artifact construction is mostly centralized:
- `internal/state` owns workspace artifact path construction.
- `internal/report.Definition` owns artifact groups and batch output names.
- Distributor bundle paths are rendered from validated config templates.
- `internal/fileutil` centralizes atomic file writes and copies.
Areas needing cleanup:
- Report command/config aliases should be centralized in or near `internal/report`.
- Generated-text template/schema IDs should be resolved through one catalog instead of separate maps and app switches.
- Distributor template values should be constructed in one named helper and tested as a contract.
Areas that do not need cleanup now:
- Managed workspace path shape appears clear and centralized enough.
- There is no object-store key or manifest key layer in this repository.
- Optional output copy behavior is app-level user-interface behavior and does not need to move into state.
## Resolution And Catalog Review
Strong catalogs:
- Report registry in `internal/report`.
- Module registry in `internal/briefing`.
- Module IDs in `internal/module`.
- State artifact paths in `internal/state`.
Weaker catalogs:
- Generated-text validators, schemas, render contexts, and templates are related but not registered together.
- Report public names and aliases are split between CLI, app, and config.
- Weather API sources are implemented as explicit methods without a shared source catalog. This is acceptable today but will become noisier as more sources are added.
Recommended centralization:
- First centralize generated-text/template resolution.
- Then centralize report public-name resolution.
- Consider a small Weather API source spec only when the next source is added or when changing source warning/provenance behavior.
## Config And Command-Loading Review
Config loading is mostly consistent:
- Defaults are applied before file decoding.
- CLI overrides are applied before validation.
- Secrets directory loading happens before final validation that needs environment-backed secrets.
- Validation is centralized in `internal/config`.
- CLI commands use standard-library parsing as intended.
Differences that appear intentional:
- `--out` and `--out-dir` are request-level output controls, not config defaults.
- Distributor token values come from environment/secrets rather than raw config.
- Inspect commands load config because workspace and notification-related state depend on config.
Likely accidental or cleanup-worthy differences:
- Report module normalization happens in both `Load` and `Validate`.
- `ReportModuleOverrides` silently ignores unknown report keys if it receives unvalidated config.
- Report name resolution is split across CLI/app/config instead of using report-owned name policy.
## State, Manifest, Or Progress Handling Review
The repository does not currently implement a manifest, checkpoint, resume, or progress subsystem. State is artifact-oriented:
- metadata;
- briefing snapshots;
- module snapshots;
- prompt/data packages;
- preflight output;
- generated text;
- render contexts;
- rendered reports;
- notification artifacts.
This is consistent with the current application size. I do not recommend adding a manifest/resume system before the next release.
Potential future cleanup:
- `LoadMetadataByRunID` currently discovers reports by scanning existing metadata through `ListReports`. This is acceptable for the current workspace size. If workspaces become large or inspect commands need to be faster, add a narrow run-index artifact or direct path helper then.
## Refactors To Avoid
Avoid these before the next release:
- A generic workflow engine for report generation.
- A plugin architecture for reports, modules, adapters, or notifiers.
- A Cobra migration.
- Per-module or per-report Go packages.
- A broad manifest/resume/progress subsystem.
- Reflection-heavy Weather API ingestion.
- A global test helper framework shared across all packages.
- Consolidating all adapter error handling into one generic wrapper.
- Replacing explicit report/module definitions with config-only definitions.
- Rewriting state storage around generated workspace scanning.
These would add abstraction before the implementation has enough repeated complexity to justify it.
## Recommended Implementation Sequence
1. Centralize generated-text/template catalog resolution.
- Goal: one explicit catalog for validator, schema, template, and render-context builder.
- Files: `internal/generatedtext`, `internal/reporttemplate`, `internal/app`, tests.
- Validation: generated-text package tests, reporttemplate tests, app workflow tests.
2. Extract final report finalization helper.
- Goal: one app helper for metadata save, output copy, notification, and result population after a managed Markdown report exists.
- Files: `internal/app`, app tests.
- Validation: app tests for direct Markdown and generated-text-template reports.
3. Centralize report public-name and alias resolution.
- Goal: make `internal/report` the canonical home for command/config/batch name mapping.
- Files: `internal/report`, `internal/cli`, `internal/app`, `internal/config`.
- Validation: CLI parser tests, config tests, report registry tests.
4. Split config report-module normalization from validation.
- Goal: avoid duplicate mutation and make invalid manual config behavior explicit.
- Files: `internal/config`, app config-loading tests.
- Validation: config tests and app tests using report module overrides.
5. Add a small generated-text snapshot lookup helper.
- Goal: reduce repeated optional-stanza extraction while preserving report-specific context structs.
- Files: `internal/generatedtext`.
- Validation: render-context tests.
6. Add a shared strict JSON helper for generated-text validators.
- Goal: keep JSON validation mechanics consistent without merging report semantics.
- Files: `internal/generatedtext`.
- Validation: generated-text validation tests.
7. Consider Weather API source lifecycle helper.
- Goal: centralize source metadata, null-data, warning, and decode lifecycle only if the helper remains explicit.
- Files: `internal/adapters/weatherapi`, adapter tests.
- Validation: Weather API fixture and missing-source policy tests.
8. Add package-local test helpers.
- Goal: reduce noisy repeated setup in app, CLI, and adapter tests.
- Files: test files only.
- Validation: affected package tests and full `go test ./...`.
9. Dead-code and stale-symbol sweep.
- Goal: remove any old aliases, unused helpers, or stale docs exposed by prior cleanup.
- Files: repository-wide as needed.
- Validation: `rg` stale-symbol checks, `go test ./...`, `git diff --check`.
## Test Strategy
Tests to add before refactoring:
- Generated-text catalog completeness test for all generated-text-template reports.
- Report public-name mapping tests for CLI names, config keys, aliases, and batch names.
- Config tests around duplicate/unknown report module overrides and normalized module options.
Tests to add during refactoring:
- Finalization helper tests through app workflows rather than direct private-helper tests.
- Generated-text validator tests for shared strict JSON behavior.
- Render-context tests proving omitted optional modules remain nil.
- Weather API source lifecycle tests if a source helper is introduced.
Focused commands for cleanup work:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./internal/report ./internal/cli ./internal/config
go test ./internal/adapters/weatherapi
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Manual checks:
- Confirm public CLI syntax and report IDs remain stable unless intentionally changed.
- Confirm managed workspace paths and distributor bundle paths remain stable.
- Confirm no secret values appear in errors, metadata, debug artifacts, tests, or docs.
- Confirm non-roadmap documentation describes only implemented behavior.
## Appendix: Findings Not Worth Acting On
### Standard-Library CLI Flag Parsing Has Some Repetition
The CLI repeats small flag parsing blocks, but the commands have different argument shapes and error messages. A broad command framework would obscure behavior more than it would simplify the code. Keep the standard-library parser.
### Module Files Are Numerous But Clear
One module per focused file in `internal/briefing` is appropriate. Do not collapse modules into a generic map-based engine or split them into per-module packages.
### Report Files Are Explicit By Design
One report or report family per file in `internal/report` is a good navigation pattern. Do not replace these definitions with config-only report declarations before the report set stabilizes.
### Adapter Error Handling Should Remain Local
Scriptorium, Weather API, and distributor failures have different semantics. Shared error-wrapping helpers would likely erase useful context. Keep adapter-local error handling unless exact duplication becomes obvious.
### State Scanning Is Acceptable At Current Scale
The current metadata scan approach is simple and inspectable. A manifest or run index should wait until workspace size or performance makes it necessary.
### Prompt/Data Package Category Naming Is Presentation Policy
The module output category names are prompt-facing schema policy. They should remain explicit in the prompt-input/module layer rather than being generalized into a taxonomy engine.

View File

@@ -1,618 +0,0 @@
# Cleanup Roadmap
## Purpose
This roadmap defines the staged cleanup work recommended by `docs/roadmap/audit.md`. It is written for an LLM coding agent that will implement the stages in order.
The cleanup is intended to simplify, centralize, and clarify implementation logic before the next major release without changing public CLI syntax, managed workspace paths, report IDs, prompt IDs, generated output semantics, or external integration behavior unless a stage explicitly says otherwise.
This file belongs under `docs/roadmap/` because it describes planned refactoring work. Non-roadmap documentation must not describe the planned behavior here until the corresponding code is implemented.
## Cleanup Principles
- Preserve implemented behavior unless a stage explicitly directs a change.
- Prefer narrow explicit helpers and registries over broad frameworks.
- Keep package boundaries from `docs/policy/architecture.md`:
- `internal/config` owns config loading, defaults, overrides, secrets, and validation.
- `internal/report` owns report identity, report definitions, valid periods, batches, output names, and comparison declarations.
- `internal/cli` owns command parsing and help text.
- `internal/app` owns orchestration.
- `internal/briefing` owns prompt-facing module builders and module registry.
- `internal/generatedtext` owns generated-text validation and render-context construction.
- `internal/reporttemplate` owns embedded templates and generated-text schema assets.
- external system types stay inside `internal/adapters/*`.
- Do not introduce Cobra, a workflow engine, plugin architecture, per-module packages, per-report packages, a manifest/resume system, or a global test framework.
- Keep tests focused near the package that owns the behavior.
- Run `gofmt -w` on changed Go files.
- Update implemented docs only in the stage that changes implemented behavior or package contracts.
## Decisions Locked
- Generated-text/template resolution should use one explicit catalog rather than app-owned switches plus separate template/schema maps.
- Report definitions remain authoritative for report IDs, prompt IDs, generation mode, template ID, generated-text schema ID, valid-period resolver, module composition, artifact group, and output naming.
- `internal/report` should become the canonical home for report public-name and alias policy used by CLI, config, and app request resolution.
- App orchestration should keep a direct, readable workflow. Extract only narrow helpers for repeated policies such as final report completion.
- Config report-module normalization should be a load-time mutation. Validation should not perform duplicate mutating normalization.
- `ReportModuleOverrides` should not silently ignore invalid report keys in any path an application caller can reach.
- Weather API source lifecycle cleanup should be explicit and source-aware. Do not use reflection or a generic ingestion framework.
- Test cleanup should use package-local helpers only.
- State remains artifact-oriented. Do not add a manifest, checkpoint, resume, or run-index subsystem in this cleanup sequence.
## Stage 1: Generated-Text And Template Catalog
Goal: make generated-text report asset resolution one explicit catalog so validators, schemas, templates, and render-context builders cannot drift independently.
### Scope
Create a narrow catalog for generated-text-template reports. The catalog should connect:
- generated-text schema ID;
- embedded JSON schema asset;
- generated-text validator;
- report template ID;
- embedded Markdown template asset;
- render-context builder.
### Implementation Guidance
- Prefer placing the primary catalog in `internal/generatedtext` if it owns validator and render-context function wiring.
- Keep embedded asset reading in `internal/reporttemplate`; do not move embedded template or schema files.
- Add an exported or package-internal lookup API with a small app-facing surface, for example:
- `generatedtext.LookupDefinition(def report.Definition)`;
- or `generatedtext.Lookup(schemaID, templateID string)`.
- The returned handler should expose methods or fields for:
- `Validate(data []byte) (any, []byte, error)`;
- `BuildRenderContext(metadata briefing.Metadata, snapshot module.Snapshot, facts app/report facts input, generated any) (any, error)`;
- schema/template IDs needed by `internal/reporttemplate`.
- Avoid importing `internal/app` into `internal/generatedtext`. If app-owned `ReportFacts` is currently needed, pass its constituent `facts.CollectedFacts` and `facts.DerivedFacts`.
- Remove `validateGeneratedText` and `buildRenderContext` switches from `internal/app` after the catalog owns this dispatch.
- Keep `internal/reporttemplate.Template`, `Schema`, and `Render` as small asset helpers unless the catalog cleanly replaces their maps.
- If `reporttemplate` keeps template/schema maps, add tests tying those maps to the generated-text catalog. Prefer one source of truth if this is straightforward.
### Files To Inspect
- `internal/app/app.go`
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
- `internal/generatedtext/render_context.go`
- `internal/reporttemplate/reporttemplate.go`
- `internal/report/*_report.go`
- `internal/report/definition.go`
- `internal/reporttemplate/templates/*.md.tmpl`
- `internal/reporttemplate/schemas/*.schema.json`
### Acceptance Criteria
- Adding a new generated-text-template report requires registering one generated-text catalog entry and adding the report definition/assets, not editing app-level switches.
- `internal/app` no longer switches on concrete generated-text schema IDs or template IDs.
- Unsupported schema/template combinations return actionable errors that include the report ID and the unsupported ID.
- Existing hourly and tomorrow report behavior is unchanged.
- No generated-text schema, template, prompt ID, or report ID changes are introduced.
### Tests
Add or update:
- Generated-text catalog completeness test proving every report with `GenerationGeneratedTextTemplate` has:
- a generated-text catalog entry;
- an embedded schema asset;
- an embedded template asset;
- a validator;
- a render-context builder.
- Negative test for unsupported generated-text schema/template IDs.
- Existing hourly and tomorrow validation tests.
- Existing reporttemplate render tests.
- App workflow tests for hourly and tomorrow reports.
Validation commands:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/report ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 2: Final Report Finalization Helper
Goal: centralize repeated metadata, output copy, notification, and result-population logic after a managed Markdown report exists.
### Scope
Extract a narrow app helper that runs after either generation mode has produced the managed Markdown report file. This helper should not own Weather API fetching, facts, modules, Scriptorium calls, generated-text validation, or template rendering.
### Implementation Guidance
- Add an unexported helper in `internal/app`, for example `finalizeRenderedReport`.
- Inputs should include the values already available in generation flow:
- context;
- loaded config;
- store;
- resolved report;
- RunID;
- metadata;
- managed report path;
- optional output path/output directory request;
- report result being assembled, or enough fields to return one.
- The helper should centralize:
- optional `--out` copy;
- optional `--out-dir` copy;
- final metadata updates related to report paths/output copy/notification;
- final metadata save;
- distributor notification invocation when enabled;
- notification debug artifact save behavior already implemented;
- notification failure propagation.
- Preserve current ordering:
- managed report is written before finalization;
- metadata is saved after final report path is known;
- notification happens only after successful report generation and final metadata save.
- Preserve current behavior that distributor uses the managed report path, never optional output copies.
- Keep notification request construction in app unless a later stage creates a smaller named helper for template values.
### Files To Inspect
- `internal/app/app.go`
- `internal/state/metadata.go`
- `internal/state/filesystem.go`
- `internal/adapters/distributor`
- `internal/fileutil`
- app tests covering generated reports, output copies, and notification artifacts.
### Acceptance Criteria
- Direct Markdown report generation and generated-text-template generation both use the same finalization helper.
- Managed report paths, optional output copy behavior, metadata JSON shape, notification artifacts, and batch behavior are unchanged.
- Single-report notification failure still returns an error.
- Batch generation still continues independent reports and returns aggregate failure when any report fails.
- The helper is small enough to read without becoming a workflow engine.
### Tests
Add or update:
- App test proving direct Markdown and generated-text-template reports both save final metadata consistently.
- App test proving `--out` and `--out-dir` still copy from the managed report path.
- App test proving notification receives the managed report path.
- App test proving notification failure fails a single report.
- Batch test proving notification failure marks only that report failed and the batch exits nonzero.
Validation commands:
```sh
go test ./internal/app ./internal/state ./internal/adapters/distributor
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 3: Report Public Name And Alias Resolution
Goal: make `internal/report` the canonical source for public report names, config keys, aliases, and batch command membership.
### Scope
Move report-name resolution policy out of scattered CLI/app/config helpers and into report-owned helpers.
### Implementation Guidance
- Add report-owned helpers in `internal/report`, for example:
- `IDForCommandName(name string) (ID, error)`;
- `IDForConfigKey(key string) (ID, error)`;
- `BatchForCommandName(name string) ([]ID, error)`;
- `CommandNames() []string` if helpful for CLI help tests.
- Preserve current accepted names and aliases:
- CLI report names currently accepted by `generate`;
- config aliases currently accepted by `reports.*`;
- batch names currently accepted by `run`.
- Keep user-facing error messages concise and actionable. It is acceptable for exact wording to change if tests are updated and the error remains clear.
- Update `internal/cli` to use report-owned command-name resolution or to call app request helpers that use it.
- Update `internal/app` to remove private `reportIDForCommand` and `reportBatchForCommand` mappings if report-owned helpers can replace them.
- Update `internal/config` to remove private `reportIDForConfigKey` in favor of report-owned config-key resolution.
- Avoid creating new exported app request types solely for this cleanup.
### Files To Inspect
- `internal/report/definition.go`
- `internal/report/registry.go`
- `internal/report/period.go`
- `internal/report/*_report.go`
- `internal/cli/root.go`
- `internal/app/app.go`
- `internal/config/reports.go`
- `docs/cli.md`
- `docs/config.md`
### Acceptance Criteria
- Report identity, command name, config key, and batch membership policy are discoverable from `internal/report`.
- CLI/app/config no longer maintain independent report ID switch statements for the same names.
- All existing public command names and config aliases continue to work unless explicitly documented as removed in this stage. This cleanup should not remove aliases.
- Distributor path variables that use report ID or artifact group are unchanged.
### Tests
Add or update:
- Report tests for command-name-to-ID mapping.
- Report tests for config-key-to-ID mapping and aliases.
- Report tests for batch names and report order.
- CLI parser tests for every supported `generate` report name.
- Config tests for report override aliases and unknown keys.
- App tests for run batch command mapping if not covered through CLI.
Validation commands:
```sh
go test ./internal/report ./internal/cli ./internal/config ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 4: Config Report Module Normalization
Goal: remove duplicate mutating normalization from config validation and make report module override extraction explicit and safe.
### Scope
Separate load-time config mutation from validation. Ensure report module overrides cannot silently ignore invalid report keys in app-facing paths.
### Implementation Guidance
- Keep configuration loading order:
1. built-in defaults;
2. config file;
3. CLI overrides;
4. report module normalization;
5. secrets directory loading;
6. validation.
- `Load` should continue returning a fully normalized and validated `Config`.
- Refactor `Validate` so it does not perform duplicate mutating normalization.
- Options:
- Preferred: split `normalizeReportModules` into a mutating load-time normalizer and a non-mutating validator used by `Validate`.
- Acceptable: make `Validate` require normalized config and document/test that public callers should use `Load` for full processing.
- Do not remove `config.Validate` unless all callers and tests can clearly use `Load`.
- Update `ReportModuleOverrides` so invalid report keys are not silently ignored in app paths.
- Preferred: change it to `ReportModuleOverrides() (map[report.ID][]module.ConfigItem, error)` and update callers.
- Acceptable: keep the signature only if `Config` gains an internal validated/normalized marker and the method clearly cannot be reached with invalid keys.
- Preserve module option strict YAML decoding and composition validation.
- Preserve current config file syntax.
### Files To Inspect
- `internal/config/load.go`
- `internal/config/validate.go`
- `internal/config/reports.go`
- `internal/config/config.go`
- `internal/config/config_test.go`
- `internal/app/app.go`
- `examples/config.yml`
- `docs/config.md`
### Acceptance Criteria
- Report module normalization happens once on the normal `Load` path.
- Validation no longer performs duplicate mutating normalization.
- App report registry construction handles report override errors explicitly.
- Unknown report keys in report module overrides cannot be silently skipped.
- Existing valid example config still loads.
- Existing CLI overrides still take precedence over config and defaults.
### Tests
Add or update:
- Config tests for normal load with typed module options.
- Config tests for direct validation of duplicate aliases and unknown report keys.
- Config tests proving `ReportModuleOverrides` returns or surfaces an error for invalid keys if called on invalid config.
- App test proving report registry construction fails clearly on invalid overrides when supplied programmatically.
- Existing example config load test.
Validation commands:
```sh
go test ./internal/config ./internal/app ./internal/cli
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 5: Generated-Text Internal Helper Cleanup
Goal: reduce low-risk duplication inside `internal/generatedtext` after the catalog is in place.
### Scope
Add small helpers for repeated snapshot stanza extraction and strict JSON validation mechanics while preserving report-specific context structs and validation rules.
### Implementation Guidance
Snapshot lookup:
- Add an unexported snapshot lookup/cache type, for example `moduleSnapshotLookup`.
- It should wrap `module.Snapshot` and provide typed methods for repeated module stanzas.
- Keep report-specific context structs:
- `HourlyRenderContext`;
- `TomorrowRenderContext`;
- future report contexts.
- Do not replace typed context structs with a generic `map[string]any`.
- Preserve nil pointer behavior for omitted optional modules.
Strict JSON helper:
- Add a small helper for:
- single JSON object decode;
- `DisallowUnknownFields`;
- detection of trailing JSON tokens;
- canonical re-marshal of validated output.
- Keep report-specific required-field checks in `hourly.go`, `tomorrow.go`, and future report validators.
- Keep generated-text schema files unchanged unless tests show they are out of sync with validator behavior.
### Files To Inspect
- `internal/generatedtext/render_context.go`
- `internal/generatedtext/hourly.go`
- `internal/generatedtext/tomorrow.go`
- `internal/generatedtext/*_test.go`
- `internal/module`
- `internal/briefing`
### Acceptance Criteria
- Repeated optional stanza extraction is reduced without changing render-context JSON shape.
- Strict JSON decode behavior remains the same for hourly and tomorrow generated text.
- Unknown fields, missing required fields, trailing JSON, and invalid JSON errors remain actionable.
- Existing templates render unchanged.
### Tests
Add or update:
- Generated-text validation tests for unknown fields, trailing JSON, missing required fields, and canonical output.
- Render-context tests proving key module pointers are populated when present and nil when omitted.
- Render-context tests proving extraction errors name the module ID.
Validation commands:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 6: Weather API Source Lifecycle Helper
Goal: reduce repeated Weather API source fetch boilerplate while keeping source-specific semantics explicit.
### Scope
Introduce a small helper or source spec for shared source lifecycle behavior in `internal/adapters/weatherapi`.
### Implementation Guidance
- Centralize only common lifecycle mechanics:
- endpoint and query construction;
- envelope fetch;
- absent/malformed/null data handling according to source policy;
- source metadata construction;
- warning/error conversion;
- source hash handling.
- Keep source-specific decode functions explicit.
- Preserve these source-specific behaviors:
- hourly forecast remains required for normal report generation;
- alerts `data:null` means checked successfully with no active alerts;
- optional non-alert source `data:null` follows missing-source policy;
- weather story uses its current endpoint/query behavior;
- SPC convective outlooks use the current endpoint constant and overlap filtering downstream;
- current source warning and provenance JSON shapes remain unchanged.
- Do not use reflection, generics-heavy helpers, or a broad source framework.
- If the helper makes the code less readable, stop and keep the explicit source functions.
### Files To Inspect
- `internal/adapters/weatherapi/client.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/weatherdata`
- `internal/forecast`
- `docs/integrations/weatherapi.md`
- `docs/internal/weather-data.md`
### Acceptance Criteria
- Source fetch functions are shorter but still readable and source-aware.
- Existing source warning behavior is unchanged.
- Existing source metadata fields and hashes are unchanged.
- No Weather API transport details leak outside `internal/adapters/weatherapi`.
- No prompt/data package schema changes are introduced.
### Tests
Add or update:
- Fixture-server test proving all expected endpoints are still requested.
- Required hourly `data:null` failure test.
- Alerts `data:null` no-active-alerts test.
- Optional non-alert missing-source policy tests for `error`, `warn`, and `none` where currently covered.
- Weather story metadata test.
- SPC source test if current coverage depends on source count or warning count.
Validation commands:
```sh
go test ./internal/adapters/weatherapi ./internal/weatherdata ./internal/forecast
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt, but it should be skipped if the implementing agent cannot keep the helper narrow and explicit.
## Stage 7: Package-Local Test Helper Cleanup
Goal: reduce noisy repeated test setup without creating a cross-package test framework.
### Scope
Add package-local helpers only where tests already repeat substantial setup.
### Implementation Guidance
- In `internal/app` tests, consider helpers for:
- temporary config/workspace construction;
- fake renderer setup;
- fake notifier setup;
- Weather API test server setup;
- artifact path/glob assertions.
- In `internal/cli` tests, consider a command invocation helper that captures stdout/stderr and exit status.
- In `internal/adapters/weatherapi` tests, keep fixture server helpers local to the adapter package.
- In `internal/adapters/distributor` tests, keep fake upload/status clients local to the adapter package.
- Do not create `internal/testutil` or a global test helper package in this stage.
- Do not change test behavior or remove meaningful assertions while reducing setup.
### Files To Inspect
- `internal/app/*_test.go`
- `internal/cli/*_test.go`
- `internal/adapters/weatherapi/*_test.go`
- `internal/adapters/distributor/*_test.go`
- `internal/generatedtext/*_test.go`
- `internal/reporttemplate/*_test.go`
### Acceptance Criteria
- Test setup duplication is reduced in packages that have obvious repetition.
- Tests remain readable without hiding important workflow details.
- No production code changes are required for this stage unless an existing test-only seam is missing and justified.
- No global test helper package is introduced.
### Tests
This stage changes tests only unless a small test seam is needed. Run:
```sh
go test ./internal/app ./internal/cli ./internal/adapters/weatherapi ./internal/adapters/distributor
go test ./...
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Stage 8: Documentation And Dead-Code Sweep
Goal: align implemented documentation with cleanup changes and remove stale symbols left behind by prior stages.
### Scope
Update docs only for implemented cleanup changes. Remove stale code, stale tests, and stale roadmap references that no longer describe future work.
### Implementation Guidance
- Update non-roadmap docs only where package contracts or contributor workflow changed:
- `docs/policy/development.md` if package responsibilities or validation commands changed;
- `docs/internal/app-orchestration.md` if finalization ordering is documented;
- `docs/internal/report-registry.md` if report name/catalog helpers are documented;
- `docs/internal/generated-text.md` or equivalent if generated-text catalog behavior is documented;
- `docs/internal/weather-data.md` if Weather API source lifecycle behavior changed.
- Do not document deferred or unimplemented cleanup outside `docs/roadmap/`.
- Keep `docs/roadmap/future.md` for deferred ideas only if they remain future work.
- Remove stale private helpers after all call sites are gone.
- Run stale-symbol searches for removed helpers and old dispatch names.
### Files To Inspect
- `docs/policy/development.md`
- `docs/internal/`
- `docs/config.md`
- `docs/cli.md`
- `docs/operations.md`
- `docs/roadmap/`
- production code touched by earlier stages
### Acceptance Criteria
- Non-roadmap docs describe implemented behavior only.
- No stale references remain to removed private app switches or old helper names.
- Roadmap docs do not duplicate implemented docs except to identify deferred future work.
- `go test ./...`, CLI help, and whitespace checks pass.
### Suggested Stale-Symbol Checks
Adjust names to match the actual implementation:
```sh
rg -n "validateGeneratedText|buildRenderContext|reportIDForCommand|reportBatchForCommand|reportIDForConfigKey" internal docs examples
rg -n "ReportModuleOverrides" internal docs examples
```
Validation commands:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Prompt size: This stage is suitable for one implementation prompt.
## Deferred Refactors
Do not include these in the cleanup sequence:
- Generic workflow engine.
- Plugin architecture.
- Cobra migration.
- Per-module or per-report Go packages.
- Manifest, checkpoint, resume, progress, or run-index system.
- Reflection-heavy Weather API ingestion framework.
- Global test helper package.
- Generic adapter error wrapper.
- Config-only report definitions.
- Broad state-storage redesign around workspace scanning.
These may be revisited only if concrete new requirements make the current explicit structure materially expensive.
## Global Validation Checklist
Run after each implementation stage unless the stage documents a narrower test set:
```sh
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Additional checks after the full cleanup sequence:
```sh
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
go test ./internal/report ./internal/cli ./internal/config
go test ./internal/adapters/weatherapi ./internal/adapters/distributor ./internal/adapters/scriptorium
go test ./internal/briefing ./internal/module ./internal/facts ./internal/forecast ./internal/promptinput ./internal/changes ./internal/state
rg -n "validateGeneratedText|buildRenderContext|reportIDForCommand|reportBatchForCommand|reportIDForConfigKey" internal docs examples
```
Manual review items:
- Public CLI syntax remains stable.
- Managed workspace paths remain stable.
- Distributor bundle paths remain stable.
- Report IDs, prompt IDs, template IDs, and generated-text schema IDs remain stable unless a stage explicitly changed them.
- No secret values appear in errors, logs, metadata, notification artifacts, docs, examples, or tests.
- Non-roadmap documentation describes only implemented behavior.
## Open Questions
No open questions block implementation of this cleanup roadmap.
The only discretionary item is Stage 6. Recommended approach: implement a narrow Weather API source lifecycle helper only if it remains explicit and improves readability. Viable alternative: skip Stage 6 and keep the current source-specific methods until the next Weather API source is added. The alternative is acceptable because current source functions are readable and tested, and the risk is mostly future maintenance cost rather than current behavior drift.

View File

@@ -1,22 +1,58 @@
# Future Roadmap
This roadmap contains project work that is not implemented. Current behavior is
documented outside `docs/roadmap/`.
This roadmap contains future work only. Each section identifies its planning
status; current behavior is documented outside `docs/roadmap/`.
## Upstream Forecast Change Product
Status: Proposed upstream feature request; unimplemented.
Weatherreporter's local Recent Changes feature was removed by the accepted
[stateless execution decision](../adr/0001-stateless-execution.md). Forecast
version history and comparison are better owned by the Weather API, where the
underlying forecast issuances can be retained and compared consistently for
all consumers.
A future Weather API feature should expose a structured change product with:
- explicit current and baseline forecast issuance timestamps or identifiers;
- documented baseline selection, such as a requested comparison timestamp,
preceding issuance, or fixed rolling period;
- location, timezone, and half-open valid-period identity;
- typed changed values with previous and current values and units;
- stable change categories for temperature, precipitation probability and
timing, wind gusts, alerts, and aggregate hazards;
- an API-owned significance classification or enough structured information
for a stateless consumer to apply a documented presentation threshold; and
- deterministic ordering, missing-baseline behavior, and source metadata.
The API should compare forecast versions, not track a Weatherreporter client's
"previous run." It should not require consumer identity, mutable cursors, or
Weatherreporter-managed history. A missing baseline should be a normal empty
result rather than an error.
Once a stable upstream contract exists, a separate Weatherreporter roadmap may
reintroduce change commentary by collecting that product and mapping it into a
curated prompt-facing module. There must be no local snapshot fallback. The
ordinary Weatherreporter process must remain stateless, and the upstream
feature should have deterministic fixtures before adoption.
## Automatic Storm Monitoring
Manual Storm Report generation is implemented through
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Status: Proposed and unimplemented.
Proposed direction:
Storm reporting, whether manual or automatic, is unimplemented.
1. detect candidate events deterministically from alerts, forecast discussion,
weather story context, hourly thresholds, and material forecast changes;
2. evaluate candidates through Scriptorium or another narrow evaluator adapter;
3. persist storm lifecycle state;
4. generate or update Storm Reports only when a meaningful event is present;
5. suppress ordinary low-impact thunder or rain chances.
Possible direction:
1. Detect candidate storm events from alerts, forecast discussion, weather
story context, hourly thresholds, and material forecast changes.
2. Evaluate candidates through Promptkit or another narrow evaluator adapter.
3. Keep any required storm lifecycle state in the upstream service or another
explicitly designed external owner rather than silently reintroducing a
Weatherreporter workspace.
4. Generate or update a storm report only when a meaningful event is present.
5. Suppress ordinary low-impact thunder or rain chances.
Possible lifecycle states:
@@ -27,122 +63,126 @@ Possible lifecycle states:
- `deescalating`
- `resolved`
Acceptance criteria before implementation:
Before implementation, the design must preserve scheduled report behavior,
inspectable evaluator failures, and fixture coverage for deterministic
candidate detection.
- scheduled reports and manual Storm Reports remain stable;
- candidate detection has fixture coverage;
- evaluator failures are inspectable and do not create noisy report output;
- manual Storm Report generation remains available.
## Future Report Types
## Future Report Types And Modules
The module-based prompt package architecture is implemented. Future work should
add only modules backed by implemented upstream facts and clear report needs.
Status: Proposed and unimplemented.
Possible future report types:
- another short-fuse planning report distinct from the implemented Hourly
Report, if a separate product is needed;
- event-specific reports with stable event IDs;
- storm review or yesterday-style reports using historical observations;
- a short-fuse planning report distinct from the implemented Hourly Report, if
a separate product is needed
- event-specific reports with stable event IDs
- storm review or yesterday-style reports using historical observations
- archive-focused report variants if generated report history becomes a
first-class product.
first-class product
New reports should preserve the boundaries documented in the [report registry
internals](../internal/report-registry.md).
## Future Modules
Status: Proposed and unimplemented.
Possible future modules:
- `hourly_table` for compact valid-period hourly facts;
- `forecast_delta` if a separate stanza is useful beyond current Recent
Changes;
- `hourly_table` for compact valid-period hourly facts
- `forecast_delta` after an upstream forecast-change product exists
- `weekend_planning` if weekend-specific planning guidance needs a dedicated
deterministic stanza;
- `storm_window_summary` if manual or automatic Storm Reports need a dedicated
prompt-facing storm-window module;
deterministic stanza
- `storm_window_summary` if manual or automatic storm reports need a dedicated
prompt-facing storm-window module
- separate AFD section aliases, such as `afd_key_messages`,
`afd_short_term_text`, and `afd_long_term_text`, if separate stanzas prove
more useful than `area_forecast_discussion.options.sections`;
more useful than `area_forecast_discussion.options.sections`
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
upstream sources and report requirements exist.
upstream sources and report requirements exist
QPF fields such as `measurable_qpf_total_in` and `max_hourly_qpf_in` should
remain omitted until a real upstream quantitative precipitation source is
represented in `CollectedFacts`.
Future module work should preserve these boundaries:
Future module work should preserve the boundaries documented in [fact
contracts](../internal/facts.md), [module internals](../internal/module.md), and
[briefing internals](../internal/briefing.md):
- collect upstream facts once per report run;
- keep upstream fetching out of modules;
- keep broad reusable calculations in `DerivedFacts`;
- keep prompt-facing field shape inside module builders;
- use typed options for configurable module behavior;
- keep module snapshots structured and deterministic for Recent Changes.
- keep upstream collection in app orchestration
- keep upstream collection out of modules
- keep broad reusable calculations in `DerivedFacts`
- keep prompt-facing field shape inside module builders
- use typed options for configurable module behavior
- keep module output structured and deterministic
## Distributor Notification Enhancements
Distributor notification currently uploads one managed Markdown report per
successful generated report through the configured HTTP upload pipeline.
Status: Proposed and unimplemented.
These enhancements are not current behavior:
Single-report and batch Distributor notification are implemented. Current
behavior is documented in the [Distributor adapter guide](../internal/distributor-adapter.md),
[Distributor integration guides](../integrations/distributor/), and
[operations guide](../operations.md). The following enhancements remain
unimplemented:
- `failure_policy: warn`;
- uploading metadata, module snapshots, data packages, or preflight artifacts;
- durable upload retry queues;
- distributor-specific CLI flags;
- making distributor scan the weatherreporter workspace;
- handling destination routing, Markdown-to-HTML transformation, public URLs,
or nginx layout inside weatherreporter.
- `failure_policy: warn`
- durable upload retry queues
- distributor-specific CLI flags
- destination routing, Markdown-to-HTML transformation, public URLs, or nginx
layout inside weatherreporter
Any distributor enhancement should preserve the existing adapter boundary:
Any distributor enhancement should preserve the adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Comparison Profile Diagnostics
Status: Proposed and unimplemented.
Comparison preflight failures could identify the profile being inspected and
preserve a safe, actionable Promptkit cause, such as a duplicate profile ID,
instead of reporting only a generic `profile_load` failure. Any improvement
must continue to omit credentials, endpoints, and other sensitive profile
values. Regression coverage should include a comparison that mixes built-in
and configured-directory profiles and a directory containing duplicate IDs.
## Alternate Runtime Integrations
These ideas are not current behavior:
Status: Proposed and unimplemented.
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system;
- dynamic module loading;
- user-defined module code;
- YAML-defined module schemas;
- module-owned Weather API fetching.
These ideas remain unimplemented:
- native LLM client inside `weatherreporter`
- database-backed state
- public HTTP API
- multi-location selection
- daemon mode
- multi-user authorization
- plugin system
- dynamic module loading
- user-defined module code
- YAML-defined module schemas
- module-owned Weather API fetching
Each item needs its own design note before implementation. Non-roadmap docs
must not describe these as available behavior.
## Cleanup Refactors
## Deferred Refactors
The initial cleanup pass intentionally left these refactors out because the
current implementation does not yet make them worth the added abstraction.
Status: Deferred.
Revisit these only when new source types, report types, operational
requirements, or recurring maintenance costs make the duplication materially
more expensive:
These refactors should remain deferred until new requirements or recurring
maintenance costs make the added abstraction worthwhile:
- Weather API optional-source specification/helper refactor: consider when
additional Weather API sources make per-source fan-out, policy handling, and
provenance wiring repetitive enough to obscure adapter behavior.
- Broad briefing weather-signal consolidation: consider when multiple module
builders repeatedly derive the same weather signals and tests begin to need
coordinated fixture updates.
- Generic workflow engine: defer unless generation, inspection, recovery, or
future background workflows gain enough shared step semantics to justify a
declared execution model.
- Cobra migration: defer while the standard-library CLI remains small,
explicit, and covered by parser tests.
- Manifest, resume, or progress system: defer until operators need resumable
runs, checkpoint recovery, or richer audit trails than the current durable
artifacts and metadata provide.
- Global test helper package: defer while package-local helpers keep tests
clear; revisit only if setup duplication starts to hide behavior.
- Logging subsystem: defer until there are recurring operator diagnostics that
cannot be handled with current errors, metadata, inspection commands, and
artifact output.
- broad briefing weather-signal consolidation
- generic workflow engine
- Cobra migration
- manifest, resume, or progress system
- global test helper package
- logging subsystem
Any future implementation should preserve the existing public CLI, artifact
paths, report identities, module boundaries, and adapter boundaries unless a
separate roadmap explicitly changes them.
Any future implementation should preserve the public CLI, report-output
contract, report identities, module boundaries, and adapter boundaries in
effect when that work begins unless a separate roadmap explicitly changes
them.

View File

@@ -1,441 +1,595 @@
# Tomorrow Report Implementation Roadmap
# PromptKit v0.8.0 Upgrade Implementation Plan
## Purpose
Status: Complete.
This roadmap defines the staged implementation plan for
`docs/roadmap/tomorrow.md`. It is written for an LLM coding agent that will
implement each stage in order. The conceptual target state, user intent, and
locked product decisions live in `docs/roadmap/tomorrow.md`; this file defines
the concrete implementation sequence.
Completion note: Stages 19 upgraded PromptKit, adopted inherited profiles and
current credential handling, added repair and provider-failure contracts,
enabled one corrective generation for v2.1.0 prompts, exposed repair
provenance in ordinary and comparison results, migrated comparison bundles to
v2, and added secure failure-debug capture.
This is future-work planning. Do not treat the behavior described here as
implemented until the corresponding code, tests, examples, and non-roadmap docs
are updated.
## Purpose And Authority
## Implementation Guardrails
This plan translates the accepted
[PromptKit v0.8.0 upgrade roadmap](promptkit-v0.8.0.md) into an ordered,
decision-complete implementation procedure. The feature roadmap owns purpose,
scope, policy, and the desired end state. This document owns implementation
order, concrete work allocation, stage boundaries, and verification until the
upgrade is complete.
- Preserve public CLI syntax: `weatherreporter generate tomorrow`.
- Make a clean pre-release break from report ID `daily_tomorrow`; do not add
compatibility aliases.
- Keep report identity, prompt IDs, template IDs, artifact groups, output names,
and comparison policy centralized in `internal/report`.
- Reuse the existing `generated_text_template` workflow implemented for Hourly.
- Keep Scriptorium details behind the existing adapter boundary.
- Keep Go responsible for deterministic facts, valid periods, module snapshots,
structured generated-text validation, and final Markdown template rendering.
- Keep templates responsible for wording and layout.
- Keep generated JSON schemas and Markdown templates as embedded asset files,
not inline Go strings.
- Preserve current managed artifact behavior except where report identity
intentionally changes from `daily_tomorrow` to `tomorrow`.
The implementing agent must complete the stages in numerical order. Each stage
is sized for one focused prompt handled by `gpt-5.6-terra` with high reasoning.
Do not combine stages merely because adjacent work touches the same package.
## Stage 1: Report Identity Split
## Locked Decisions
Goal: make Tomorrow an independent report ID and artifact identity while
preserving the public `generate tomorrow` command.
The following decisions are final for this implementation:
Implementation:
- upgrade directly from PromptKit `v0.5.0` to `v0.8.0`;
- declare one corrective call in each embedded prompt through
`repair_attempts: 1` rather than adding WeatherReporter repair logic;
- keep the repair budget in the exact embedded prompt definition and add no
global, per-report, CLI, profile, or operator configuration override;
- advance all four exact prompt versions from `2.0.0` to `2.1.0`;
- make `weather-light`, `weather-balanced`, and `weather-deep` minimal aliases
of the corresponding PromptKit built-ins through `base_profile`;
- retain the existing report-to-profile assignments and effective model
ladder;
- allow successfully inspected endpoint-only profiles to have an empty backend
ID while continuing to require a nonblank model;
- treat `APIKeyEnv` as an optional lookup source and reject only profiles that
report `APIKeyRequired`, because WeatherReporter supplies no direct request
credential;
- support PromptKit's built-in `rakestrawhome-gemma-4-31b` profile without
WeatherReporter-specific backend configuration;
- expose provider HTTP status through a project-owned safe generation error,
while writing provider code, type, and message only to explicit secure debug
capture;
- emit only `weatherreporter.comparison.v2`, with repair provenance, and do not
preserve v1 guarded-replacement support; and
- retain PromptKit dependency types inside the adapter and preserve all
stateless execution, atomic publication, comparison independence, and
disclosure invariants.
- Replace `report.DailyTomorrow` with `report.Tomorrow` whose value is
`"tomorrow"`.
- Rename report-definition helpers and resolvers around the new identity:
`dailyTomorrowDefinition` to `tomorrowDefinition`,
`dailyTomorrowModules` to `tomorrowModules`, and
`resolveDailyTomorrow` to `resolveTomorrow`.
- Update `report.DefaultRegistry`, `Registry.All`, batch resolution, and tests
so the built-in report order contains `tomorrow` instead of
`daily_tomorrow`.
- Update `internal/app` so `app.ReportTomorrow` resolves to `report.Tomorrow`.
- Keep the valid period as the next local civil day.
- Keep evening batch behavior: `run evening` should still generate the Tomorrow
report.
- Change Tomorrow definition identity fields to:
- `ID: report.Tomorrow`
- `Name: "Tomorrow Report"`
- `ArtifactGroup: "tomorrow"`
- `BatchOutputName: "tomorrow.md"`
- `Generated: true`
- `CompatiblePriorIDs: []report.ID{report.Tomorrow}`
- `ComparisonStrategy: report.CompareSameValidDate`
- Keep the current Tomorrow module composition initially, renamed to
`tomorrowModules`, so the prompt data package continues to include the
daypart, precipitation, alert, SPC, AFD, weather story, tomorrow planning,
and hourly facts already available.
- Update all code and tests that assert `daily_tomorrow` paths, metadata,
RunIDs, prior compatibility, or registry IDs.
## Implementation Rules
Acceptance criteria:
For every stage:
- No production-code references to `report.DailyTomorrow` or report ID
`daily_tomorrow` remain.
- `weatherreporter generate tomorrow` still parses and resolves successfully.
- Evening batch contains `tomorrow`.
- Managed workspace artifact paths and distributor identity values now use
`tomorrow`.
- read `docs/development.md`, all files under `docs/policy/`, this plan, the
feature roadmap, and the task-specific documents named by the stage;
- inspect the current code and tests before editing; use the repository's code
knowledge graph first for code discovery and fall back to text search for
literals, assets, and documentation;
- implement only the stage's scope and preserve unrelated user changes;
- keep PromptKit/provider types, client construction, YAML parsing, repair
mechanics, and provider transport inside the existing adapter boundary;
- use deterministic, offline, credential-free tests and injected clients or
synthetic fixtures rather than live OpenRouter, Rakestrawhome, or local
endpoint calls;
- add tests at the narrowest stable owner identified by the testing policy and
avoid copying PromptKit's internal test matrices;
- update the canonical documentation owners listed for that stage in the same
change as the implemented contract;
- run `gofmt` on changed Go files, the stage's focused tests,
`GOWORK=off go test -count=1 ./...`, and `git diff --check`; and
- leave the repository passing before proceeding to the next stage.
Suggested validation:
Stages affecting concurrent comparison, cancellation, or secure debug
filesystem work must also run the named focused packages with `-race`. Do not
weaken an existing assertion solely to accommodate the new dependency. When a
test encodes an intentionally changed contract, replace it with a behavioral
assertion for the accepted policy.
```bash
go test ./internal/report ./internal/app ./internal/cli ./internal/state
## Implementation Stages
### Stage 1: Upgrade The Dependency And Establish A v0.8.0 Baseline
Status: Complete.
Purpose: move to the tagged dependency and isolate compatibility changes before
adopting new WeatherReporter behavior.
Work:
1. Update `go.mod` to require
`gitea.maximumdirect.net/eric/promptkit v0.8.0` and refresh `go.sum` with
`GOWORK=off go mod tidy`. Do not add `go.work`, `vendor`, or a `replace`
directive and do not change WeatherReporter's Go version.
2. Resolve any compile failures using PromptKit's public root package only.
Keep all `Profile` and `OpenAICompatibleProfileConfig` literals keyed. Do not
register the now-reserved `rakestrawhome` backend.
3. Reconcile adapter tests that directly exercise PromptKit's changed optional
credential behavior. A profile whose only credential metadata is
`api_key_env` must reach an injected client when the environment value is
absent; it must no longer expect PromptKit to return
`ErrAPIKeyEnvMissing`. Do not change WeatherReporter's application preflight
in this stage.
4. Verify that every current embedded prompt, content file, schema, and fallback
profile inspects under v0.8.0. Verify selected invalid local endpoints and
malformed selected profile definitions still map to project-owned
configuration or profile-load categories.
5. Review the v0.6.0 compatibility corrections against supported
WeatherReporter inputs: metadata-authoritative identities, exact contained
`content_file` paths, regular embedded files, structurally valid endpoints,
bounded JSON-compatible values, cancellation identity, and strict response
framing. Add consumer tests only for a WeatherReporter boundary not already
protected by PromptKit.
Do not enable profile inheritance or output repair yet. The expected result is
the current WeatherReporter feature set running against PromptKit v0.8.0.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/adapters/promptkit ./internal/promptassets
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit
```
This stage is suitable for one implementation prompt.
### Stage 2: Adopt Profile Inheritance And Current Credential Routing
## Stage 2: Tomorrow GeneratedText Contract
Status: Complete.
Goal: add structured Tomorrow LLM output validation and schema assets.
Purpose: adopt v0.7.0 profile composition, endpoint-only routing, optional
credential semantics, and the Rakestrawhome built-in without changing the model
ladder.
Implementation:
Work:
- Add `internal/generatedtext/tomorrow.go` with:
1. Replace the three embedded profile bodies with these exact leaf/base
relationships and no duplicated execution settings:
```go
type Tomorrow struct {
Summary string `json:"summary"`
ForecastDiscussion []string `json:"forecast_discussion"`
PrecipitationTiming string `json:"precipitation_timing,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
| Leaf | Base |
| --- | --- |
| `weather-light` | `deepseek-4-flash` |
| `weather-balanced` | `gemini-flash-latest` |
| `weather-deep` | `claude-sonnet-latest` |
2. Update prompt-asset fixtures and tests to understand `base_profile`. Assert
that all three leaf IDs remain selected identities and resolve to the same
backend, model, timeout, service tier, and reasoning settings exposed by the
current standalone definitions. Test relationships and effective behavior,
not copied private constants beyond the intentional model-ladder contract.
3. Preserve source precedence. Cover a standalone same-ID operator override, a
derived operator override, a configured source that shadows a base ID, and
selected missing-base, cyclic, malformed-base, and incomplete-target
failures. Do not implement inheritance or merging in WeatherReporter; all
resolution must remain PromptKit-owned.
4. Change application profile preflight to accept a successful inspection with
a nonblank model and an empty backend ID. Trust PromptKit inspection to have
resolved either a backend or endpoint; do not add the endpoint to
`promptexec.ProfileInspection` or ordinary provenance.
5. Remove application-level environment lookup and rejection for a nonblank
`APIKeyEnv`. Remove the now-unused `LookupEnv` fields and plumbing from
prompt, batch, and comparison inspection requests. Continue rejecting
`CredentialRequired`/`APIKeyRequired` before weather collection with the
existing missing-credential category.
6. Add an end-to-end offline regression proving the maintained endpoint-only
`weather-light` example passes application inspection, retains an empty
backend ID, and does not expose its endpoint.
7. Prove `rakestrawhome-gemma-4-31b` can pass ordinary and comparison profile
inspection through the existing adapter and reports the PromptKit
`rakestrawhome` backend ID. Do not make a provider call or add
Rakestrawhome-specific configuration.
Canonical documentation in this stage:
- update `docs/policy/architecture.md` so only direct-key-required profiles
fail credential preflight and backend identity is optional for endpoint-only
profiles;
- update `docs/config.md` to distinguish same-ID source replacement from
`base_profile` chain inheritance and to describe optional environment
credentials;
- update `docs/integrations/promptkit.md` for profile composition, parent
lookup precedence, endpoint-only identity, optional credentials, and
Rakestrawhome availability; and
- update `docs/internal/promptkit-adapter.md` and focused app internals for the
implemented inspection behavior.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/promptassets ./internal/adapters/promptkit ./internal/app
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit ./internal/app
```
- Add `generatedtext.ValidateTomorrow`.
- Use `json.Decoder.DisallowUnknownFields`.
- Reject multiple JSON values.
- Trim `Summary`, `PrecipitationTiming`, `Confidence`, and each
`ForecastDiscussion` paragraph.
- Drop blank discussion paragraphs after trimming, then require at least one
remaining paragraph.
- Reject blank `Summary`.
- Return normalized JSON with the same public field names.
- Add `internal/reporttemplate/schemas/tomorrow.generated_text.schema.json`.
The schema should:
- require `summary`;
- require `forecast_discussion`;
- define `forecast_discussion` as an array of strings with at least one item;
- allow optional `precipitation_timing` and `confidence`;
- reject additional properties.
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
maintained Scriptorium prompt source asset. This file is a source contract for
out-of-band Scriptorium prompt registration; weatherreporter does not need to
load prompt Markdown at runtime.
- Add Tomorrow to `internal/reporttemplate` schema lookup.
- Update app generated-text validation dispatch so it can return either
`generatedtext.Hourly` or `generatedtext.Tomorrow`. Prefer a small generic
dispatch shape such as:
### Stage 3: Extend The Project-Owned Prompt Execution Contract
```go
func validateGeneratedText(def report.Definition, data []byte) (any, []byte, error)
Status: Complete.
Purpose: establish dependency-neutral repair and structured-generation-error
values before the adapter or application relies on them.
Work:
1. Add `RepairAttempts int` to `promptexec.OutputContract`. It is the configured
additional-call budget from the exact prompt contract.
2. Add `RepairAttempts int` to `promptexec.Validation`. It is the number of
corrective calls actually started for the completed result. Update
`NewValidation` and every caller so construction is explicit; reject or
normalize no values here because PromptKit owns output-contract validity.
3. Update all copy helpers, equality/provenance helpers, fixtures, and tests so
repair values are retained without sharing mutable state.
4. Add a project-owned immutable `promptexec.GenerationError` with unexported
status and provider-detail fields plus safe accessors:
- `StatusCode() int`
- `ProviderCode() string`
- `ProviderType() string`
- `ProviderMessage() string`
- `Category() ErrorCategory`, always returning `Generation`
- `Error()`, exposing only the WeatherReporter generation category/message
and optional HTTP status
- `GoString()`, returning the same safe representation
- `Unwrap()`, preserving a project-owned `*promptexec.Error`
5. Provide one constructor used by adapters. Defensively normalize valid UTF-8
and bound code/type to 256 Unicode code points and message to 4,096 Unicode
code points, even though PromptKit already bounds its accessors. Do not
expose fields through struct formatting, JSON tags, or exported mutable
fields. Preserve the dependency cause only behind the project-owned error so
`errors.Is`/`errors.As` identities remain available without entering error
text.
6. Add focused tests proving nil/zero safety, category and unwrap behavior,
status-only ordinary formatting, `%#v` redaction, provider-detail bounds,
and repair-value copying.
Do not import PromptKit from `internal/promptexec` and do not change CLI or
artifact schemas in this stage.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/promptexec
```
Then type-check the returned value in render-context dispatch.
### Stage 4: Map PromptKit v0.8.0 Repair And Generation Errors In The Adapter
Acceptance criteria:
Status: Complete.
- Tomorrow generated text rejects unknown fields, missing required fields,
blank summary, and no usable forecast discussion paragraphs.
- Optional `precipitation_timing` and `confidence` are trimmed and omitted from
normalized JSON when empty.
- Hourly generated text behavior is unchanged.
Purpose: make the adapter faithfully translate v0.8.0 preparation, execution,
validation, usage, and failure values into the Stage 3 contract.
Suggested validation:
Work:
```bash
go test ./internal/generatedtext ./internal/reporttemplate ./internal/app
1. Map `promptkit.OutputContract.RepairAttempts` in prompt inspection and
prepared-execution details. Map
`promptkit.ValidationResult.RepairAttempts` in completed execution.
2. Preserve PromptKit's cumulative usage exactly as reported across the initial
call and every completed correction. Continue returning only the final raw
candidate and final validation result, subject to WeatherReporter's 64 KiB
generated-output bound.
3. In adapter error classification, retain cancellation, deadline, and capacity
precedence. Before the generic `ErrLLMGenerate` branch, use `errors.As` for
`*promptkit.GenerationError` and construct the project-owned
`promptexec.GenerationError` with status, code, type, message, and hidden
cause. Initial and corrective generation failures use the same mapping.
4. Extend the injected adapter client used by tests so it can return an ordered
sequence of responses or errors and record each request safely.
5. Use a synthetic PromptKit prompt with JSON Schema validation and
`repair_attempts: 1` to cover:
- first-pass valid output with zero corrections;
- explicitly empty or invalid output followed by valid corrected output;
- one-attempt exhaustion returning a final failed validation result rather
than an operational error;
- a non-2xx-style `GenerationError` during correction;
- cumulative token usage and actual repair count; and
- the same prepared prompt/profile identity across the corrective flow.
6. Keep these tests at the adapter boundary. Do not assert PromptKit's private
corrective-message wording or reconstruct its internal repair algorithm.
Canonical documentation in this stage: update
`docs/internal/promptkit-adapter.md` for the repair/result/error mappings. Do
not yet claim that embedded WeatherReporter prompts enable repair.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/adapters/promptkit ./internal/promptexec
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit
```
This stage is suitable for one implementation prompt.
### Stage 5: Carry Repair Provenance Through Application Workflows
## Stage 3: Daypart Presentation Fields
Status: Complete.
Goal: add only the daypart fields needed to keep the Tomorrow template
composable without moving prose construction into Go.
Purpose: make application orchestration understand configured and actual repair
counts before changing the embedded prompt policy.
Implementation:
Work:
- Extend the `derived_daypart_summaries` module output with presentation
helpers that are facts, not complete sentences:
- `display_name`, for example `Morning`;
- `dominant_condition_lower`, for inline template text;
- `temperature_phrase_f`, such as `low 70s`, `upper 60s`, or
`upper 60s to mid-70s`;
- `mention_precipitation`, true when max PoP is at or above the existing
hourly forecast precipitation mention threshold, currently 20%;
- `max_pop_time_label`, using friendly local hour format such as `8:00 AM`
when max PoP time exists.
- Reuse the existing hourly forecast precipitation mention threshold constant
rather than adding a user config field in this change.
- Keep existing structured numeric fields in the module output.
- Do not add a prewritten `daypart_line` string.
- Do not add wind prose in this stage unless tests show the initial template
needs a specific structured wind fact. If wind wording is needed, add a
small structured wind field, not a full sentence.
1. Add an expected generated-text repair budget to `report.Definition` and set
it explicitly to zero for all four current `2.0.0` definitions in this
stage. Include it in report-definition validation and retained contract
tests.
2. Extend exact prompt preflight so format, validation mode, schema path, and
repair budget must all match the resolved report definition. Extend
preparation and completion provenance checks to require the same repair
budget across inspection and the opaque prepared snapshot.
3. Add `RepairAttempts *int` to application outcomes where execution may fail
before validation exists. Set it to a fresh pointer immediately after a
non-nil completed execution is returned, before WeatherReporter's secondary
generated-text validation. A pointer is required so completed first-pass
zero is distinguishable from unavailable provenance.
4. Carry independent copies through `ReportResult`, `BatchReportResult`, batch
conversion, comparison execution's internal outcome, and relevant test
fakes. Do not expose the new value in CLI or comparison JSON yet.
5. Preserve the actual count on PromptKit validation rejection and on later
WeatherReporter generated-text or render failures. Leave it unavailable on
preparation, capacity, cancellation, deadline, and generation errors that
return no completed PromptKit result.
6. Add focused tests for provenance mismatch, completed zero, completed
positive, validation rejection, later local validation failure, early
operational failure, batch copying, and independent concurrent profile
outcomes.
Acceptance criteria:
Canonical documentation in this stage: update the focused prepared-report and
app-orchestration internals to describe configured versus actual repair
provenance. Current public documents should continue to report the embedded
budget as zero until Stage 6.
- Daypart module output has enough structured fields for a readable Tomorrow
template.
- The output remains useful for YAML prompt packages.
- No generated prose sentence is hard-coded into the module.
Focused verification:
Suggested validation:
```bash
go test ./internal/briefing ./internal/promptinput
```sh
GOWORK=off go test -count=1 ./internal/report ./internal/app ./internal/cli
GOWORK=off go test -race -count=1 ./internal/app
```
This stage is suitable for one implementation prompt.
### Stage 6: Activate One Repair And Expose Ordinary Result Provenance
## Stage 4: Tomorrow Render Context And Template
Status: Complete.
Goal: render Tomorrow Markdown from structured generated text, module outputs,
and report metadata.
Purpose: switch the operational prompts to the accepted one-correction policy
and make ordinary generate/run/batch output report what occurred.
Implementation:
Work:
- Add a dedicated Tomorrow render context under `internal/generatedtext`, for
example:
1. Add `repair_attempts: 1` to the output contract of all four embedded prompt
definitions and change each exact prompt version from `2.0.0` to `2.1.0`.
Do not change prompt text or generated-text schemas solely for this upgrade.
2. Change all four report registry definitions to exact prompt version `2.1.0`
and expected repair budget one. Update exact-version fixtures and assertions
throughout adapter, app, CLI, report, and prompt-asset tests. Remove tests
that classify `repair_attempts` as a retired setting and replace them with
an exact one-attempt contract assertion.
3. Add `repairAttempts` to successful and failed generate and batch JSON result
shapes through the Stage 5 pointers. Emit integer zero for a completed
first-pass result, a positive integer for a completed repaired result, and
omit the field when no completed validation made it available.
4. Keep the existing `validationStatus` and failure categories authoritative.
A repaired valid result proceeds normally. Repair exhaustion remains
`validation_rejected`, publishes no report for that profile, and retains the
actual attempt count.
5. Add representative offline assembled tests proving first-pass success,
repaired success, exhaustion, explicit empty initial content, and batch
result propagation. Reuse the real PromptKit adapter with an injected
sequence client for at least one end-to-end repaired execution; use the
existing app fake at other boundaries where lower-level repair is already
covered.
6. Confirm no application loop, provider retry, profile fallback, or
request-level `OutputContract` override was introduced.
```go
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
Canonical documentation in this stage:
- update `docs/policy/architecture.md` with PromptKit-owned bounded repair and
failed-exhaustion invariants;
- update `docs/integrations/promptkit.md` with exact prompt version `2.1.0`, one
configured repair, actual-count semantics, cumulative usage, explicit-empty
handling, and the distinction from operational retries;
- update `docs/cli.md` for generate and batch `repairAttempts` fields;
- update `docs/internal/report-registry.md`, prepared-report internals, and app
orchestration internals for exact version and repair flow; and
- keep configuration documentation unchanged because no repair setting is
added.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/promptassets ./internal/report ./internal/adapters/promptkit ./internal/app ./internal/cli
GOWORK=off go test -race -count=1 ./internal/adapters/promptkit ./internal/app
```
- Add `TomorrowReportContext` with:
- `Title`, for example `Sunday's Weather`;
- `ForecastDate`;
- `ForecastDateLabel`, for example `Sunday, June 15, 2026`;
- `ForecastDayName`, for example `Sunday`;
- `GeneratedAt`;
- `GeneratedAtLabel`, for example
`Saturday, June 14, 2026 at 9:14 AM`;
- `ValidPeriod`;
- `Timezone`.
- Construct `Title` in Go, not in the template.
- Add `TomorrowTemplateModules` with pointer fields for the module outputs used
by the template:
- `Metadata`;
- `DerivedDailySummary`;
- `DerivedDaypartSummaries`;
- `PrecipTiming`;
- `AlertDigest`;
- `SPCConvectiveOutlooks`;
- `AreaForecastDiscussion`;
- `SPCConvectiveDiscussion`;
- `WeatherStory`;
- `TomorrowPlanning`.
- Add an ordered daypart slice for the template, derived from the configured
daypart order rather than ranging directly over a map. This can live in
`TomorrowTemplateModules`, for example `Dayparts []TomorrowDaypartContext`.
- Add `BuildTomorrowRenderContext`.
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
- Add Tomorrow to `internal/reporttemplate` template lookup.
- Template shape:
- title;
- forecast date;
- generated timestamp;
- `GeneratedText.Summary`;
- deterministic `Daypart Forecast` bullets in configured order;
- conditional `Precipitation Timing` only when precipitation windows exist;
- deterministic precipitation window bullets before optional
`GeneratedText.PrecipitationTiming`;
- `Forecast Discussion` with one paragraph per
`GeneratedText.ForecastDiscussion` item.
- Keep current conditions and hourly forecast available through the data
package and render context, but do not render them in the initial Tomorrow
template unless the template explicitly uses them.
### Stage 7: Migrate Comparison Bundles To v2
Acceptance criteria:
Status: Complete.
- The template renders without map-order nondeterminism.
- Precipitation Timing is omitted when no precipitation windows exist.
- Forecast Discussion supports multiple paragraphs.
- Missing optional modules produce clean omission or fallback behavior, not
template execution errors.
Purpose: preserve repair activity in the profile-evaluation artifact and make
the strict durable schema change explicit.
Suggested validation:
Work:
```bash
go test ./internal/generatedtext ./internal/reporttemplate ./internal/briefing
1. Change `comparison.SchemaVersion` to
`weatherreporter.comparison.v2`. Emit and recognize v2 only; do not retain a
v1 parser or guarded-replacement compatibility path.
2. Add `RepairAttempts *int` to each application comparison profile result,
CLI comparison profile summary, and durable `comparison.Result`. Propagate a
fresh copy from Stage 5's execution outcome.
3. Place `repairAttempts` immediately after `validationStatus` in the canonical
result-object JSON field order. Encode zero for completed first-pass
validation, a positive integer for completed correction, and omit it only
when no completed validation exists.
4. Tighten manifest invariants: every non-nil repair count is non-negative; a
successful result must have `validationStatus: "passed"` and a non-nil
repair count; a failed result with a completed validation status must also
have a non-nil count; and an early operational failure may omit both.
5. Update the strict token-level JSON recognizer to accept only the canonical
`repairAttempts` field at its correct object level, reject duplicate,
unknown, negative, fractional, string, overflow, and malformed values, and
continue rejecting v1 as an unsupported current bundle.
6. Update manifest construction, cloning, validation, exact serialization
tests, guarded replacement tests, malicious bundle tests, partial-success
tests, and CLI comparison summaries. Preserve flat layout, result ordering,
hashes, atomic publication, cancellation safety, and no Distributor calls.
7. Cover concurrent peers where one succeeds first-pass, one repairs, one
exhausts, and one fails operationally. The counts must remain attached to
the selected profile positions without races or cross-contamination.
Canonical documentation in this stage:
- replace the v1 contract in `docs/integrations/comparison-bundle.md` with v2,
including exact field order, presence rules, and the lack of v1 replacement
compatibility;
- update `docs/cli.md` for comparison `repairAttempts`;
- update `docs/operations.md` to tell operators to move or remove an existing
v1 bundle before replacing at the same destination; and
- update comparison execution/publication internals and architecture policy as
needed for the current-only version invariant.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/comparison ./internal/app ./internal/cli
GOWORK=off go test -race -count=1 ./internal/comparison ./internal/app
```
This stage is suitable for one implementation prompt.
### Stage 8: Add Secure Provider-Failure Debug Capture
## Stage 5: App Workflow Integration
Status: Complete.
Goal: route Tomorrow through the generated-text-template workflow end to end.
Purpose: expose useful PromptKit v0.7.0 provider diagnostics only through the
existing explicit secure debug boundary while keeping ordinary errors safe.
Implementation:
Work:
- Change Tomorrow report definition to:
- `PromptID: "weather.tomorrow_generated_text"`
- `GenerationMode: report.GenerationModeGeneratedTextTemplate`
- `TemplateID: "tomorrow"`
- `GeneratedTextSchemaID: "tomorrow"`
- Update `internal/app.buildRenderContext` dispatch:
- hourly template requires `generatedtext.Hourly`;
- tomorrow template requires `generatedtext.Tomorrow`;
- unsupported type/template combinations return actionable errors.
- Ensure Scriptorium `run` writes raw generated text to a `.json` path for
Tomorrow, matching the existing generated-text-template workflow.
- Ensure normalized generated text, render context JSON, generated Markdown,
metadata, and final report artifacts are persisted through existing state
helpers.
- Ensure app errors include report ID `tomorrow` and RunID context.
1. Extend prompt preparation debug output with configured
`repairAttempts` and advance its schema identifier from
`weatherreporter.prompt_preparation_debug.v2` to
`weatherreporter.prompt_preparation_debug.v3`.
2. Extend execution validation debug output with actual `repairAttempts` and
advance its schema identifier from
`weatherreporter.prompt_execution_debug.v2` to
`weatherreporter.prompt_execution_debug.v3`. Retain cumulative token usage.
3. Add a dedicated `failure.json` artifact with schema identifier
`weatherreporter.prompt_failure_debug.v1`. Its canonical fields are:
Acceptance criteria:
- top level: `schemaVersion`, `reportId`, `validDate`, `runId`, `failure`;
- failure object: `category`, `statusCode`, `providerCode`, `providerType`,
`providerMessage`;
- omit absent provider fields and zero status; and
- never include the raw provider body, headers, endpoint, credentials,
request, schema, rendered prompt, or generated candidate.
- `weatherreporter generate tomorrow` no longer invokes Scriptorium for full
Markdown.
- The workflow validates Scriptorium JSON output, builds a Tomorrow render
context, and renders Markdown locally.
- Hourly generated-text-template behavior remains unchanged.
4. Add `PromptDebugWriter.WriteFailure` using the existing handle-relative
secure run directory, `0700` directory and `0600` file modes, canonical JSON
encoding, and no-follow/atomic replacement behavior. Disabled writers must
perform no filesystem work.
5. When execution returns an error, use `errors.As` only against the
project-owned `*promptexec.GenerationError`. If explicit debug capture is
enabled, write `failure.json` using that profile's existing debug reference.
This applies equally to initial and corrective provider failures and keeps
comparison profile directories isolated.
6. If failure-debug writing also fails, retain the generation failure as the
primary categorized error and join the safe debug-write failure rather than
replacing or hiding the provider failure. Never place provider code, type,
or message in the joined error text.
7. Ordinary generate, batch, and comparison errors should gain only the safe
HTTP status already rendered by `promptexec.GenerationError.Error`; do not
add provider detail fields to CLI summaries, comparison manifests, logs, or
Distributor requests.
8. Add adversarial tests for formatter redaction, malicious provider strings,
JSON escaping, bounds, absent fields, file modes, symlink/path attacks,
write failure, cancellation identity, initial versus corrective failures,
and concurrent comparison captures.
Suggested validation:
Canonical documentation in this stage:
```bash
go test ./internal/app ./internal/state ./internal/adapters/scriptorium
- update `docs/operations.md` with the three debug artifact versions,
`failure.json`, sensitivity, permissions, and retention;
- update `docs/integrations/promptkit.md` with ordinary status-only disclosure
and debug-only provider detail;
- update prompt-debug, PromptKit-adapter, and app-orchestration internals; and
- ensure `docs/policy/architecture.md` explicitly prohibits provider-controlled
diagnostics from ordinary outputs.
Focused verification:
```sh
GOWORK=off go test -count=1 ./internal/promptexec ./internal/promptdebug ./internal/adapters/promptkit ./internal/app ./internal/cli
GOWORK=off go test -race -count=1 ./internal/promptdebug ./internal/adapters/promptkit ./internal/app
```
This stage is suitable for one implementation prompt.
### Stage 9: Reconcile Documentation And Perform The Final Upgrade Audit
## Stage 6: CLI, State, Distributor, And Batch Behavior
Status: Complete.
Goal: update cross-package behavior affected by the report ID clean break.
Purpose: verify the complete end state as one coherent WeatherReporter feature
and leave no stale v0.5.0, prompt v2.0.0, comparison v1, credential, profile,
repair, or debug claims.
Implementation:
Work:
- Update CLI tests and command-output expectations for `generate tomorrow`.
- Update state/path tests so managed artifacts, snapshots, data packages,
generated-text artifacts, render contexts, and report files use artifact group
`tomorrow`.
- Update batch tests:
- evening batch emits report ID `tomorrow`;
- batch output copy name remains `tomorrow.md`;
- partial-failure behavior is unchanged.
- Update Recent Changes tests:
- Tomorrow compares only against prior `tomorrow` snapshots;
- Daily Today no longer treats Tomorrow as a compatible prior unless the
implementation explicitly keeps that relationship for Daily Today only.
- Update distributor notification tests so rendered template variables use:
- `report_id=tomorrow`;
- `artifact_group=tomorrow`;
- `batch_output_name=tomorrow.md`.
- Update config tests so report module overrides use `reports.tomorrow`.
Do not accept `reports.daily_tomorrow` unless a future explicit
compatibility decision reverses the clean break.
1. Re-read the feature roadmap, `docs/development.md`, every policy document,
and every canonical document changed by Stages 1-8. Reconcile them against
executable behavior and remove duplicated or stale definitions. Keep
unimplemented future ideas in `docs/roadmap/future.md`, not current-state
documents.
2. Search code, embedded assets, examples, tests, and documentation for stale
contractual literals and review every occurrence of:
Acceptance criteria:
- PromptKit `v0.5.0`, `v0.6.0`, and `v0.7.0` as an active dependency claim;
- prompt version `2.0.0`;
- `weatherreporter.comparison.v1`;
- prompt debug schema v2 identifiers;
- claims that profile fields never inherit;
- claims that every `APIKeyEnv` must be populated;
- claims that backend ID is always required;
- claims that repair is disabled or `repair_attempts` is retired; and
- provider detail in ordinary output.
- Public CLI syntax is stable.
- Persisted artifacts and distributor request context use the new report ID.
- Batch behavior is unchanged except for the new ID.
- No tests rely on `daily_tomorrow`.
Historical release documents may retain accurate historical literals.
3. Verify canonical ownership:
Suggested validation:
- architecture owns invariants and boundaries;
- config owns operator profile and credential behavior, but no repair field;
- PromptKit integration owns logical prompt/profile/output contracts;
- CLI owns result fields;
- operations owns explicit debug handling and old comparison-bundle cleanup;
- comparison integration owns the complete v2 manifest; and
- internal documents own implementation flow without duplicating the public
references.
```bash
go test ./internal/cli ./internal/app ./internal/state ./internal/config ./internal/report
```
4. Verify maintained examples remain valid, secret-free, and tested. The local
`weather-light` example remains a standalone endpoint-only profile rather
than inheriting an OpenRouter backend it cannot clear.
5. Review the complete diff for architecture leakage. Production packages
outside `internal/adapters/promptkit` must not import PromptKit; no
application repair loop, provider client, raw provider diagnostic, profile
YAML parser, or durable application state may have appeared.
6. Review tests under the testing policy. Keep consumer contract and regression
coverage, remove accidental duplication of upstream implementation tests,
and ensure every default test is offline and repeatable.
7. Run the complete validation set:
This stage is suitable for one implementation prompt.
## Stage 7: Documentation And Examples
Goal: align implemented docs and maintained examples after the code change.
Implementation:
- Update non-roadmap docs only after the behavior is implemented.
- Inspect and update:
- `docs/cli.md`;
- `docs/config.md`;
- `docs/operations.md`;
- `docs/troubleshooting.md`, if new failure modes are introduced;
- `docs/internal/report-registry.md`;
- `docs/internal/generatedtext.md`;
- `docs/internal/reporttemplate.md`;
- `docs/internal/state.md`;
- `docs/templates.md`;
- relevant Scriptorium and distributor integration docs only if their
weatherreporter-facing contract changed.
- Update `examples/config.yml` if it references Tomorrow modules or
`reports.daily_tomorrow`.
- Keep future Today/Daily split language only under `docs/roadmap/`.
- Do not document unimplemented Today or generic Daily products as current
behavior.
Acceptance criteria:
- Non-roadmap docs describe implemented behavior only.
- Docs use report ID `tomorrow`.
- Examples load under current config validation.
- No stale user-facing references to `daily_tomorrow` remain outside historical
roadmap context.
Suggested validation:
```bash
rg -n "daily_tomorrow|Daily Tomorrow|weather.daily_report" README.md docs examples internal
git diff --check
```
This stage is suitable for one implementation prompt.
## Stage 8: Final Validation
Goal: verify the completed cutover as a coherent behavior change.
Run:
```bash
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
go test ./...
```sh
gofmt -w <all changed Go files>
GOWORK=off go test -count=1 ./...
GOWORK=off go test -race -count=1 ./...
GOWORK=off go vet ./...
GOWORK=off go build ./...
GOWORK=off go mod tidy -diff
go run ./cmd/weatherreporter --help
go run ./cmd/weatherreporter compare --help
test -z "$(git ls-files go.work go.work.sum)"
test ! -e vendor
git diff --check
```
Also run targeted stale-symbol checks:
8. Confirm `go.mod` has no `replace`, the resolved PromptKit module is exactly
v0.8.0, and no live credential or provider call occurred during validation.
9. After every check passes, update this plan's status to Completed and add a
concise completion note listing the implemented stages. Do not delete either
roadmap until the maintainer has reviewed the implementation. Do not create
a release document or tag; release preparation remains a separate maintainer
action once a version is selected.
```bash
rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples
rg -n "weather.tomorrow_generated_text|TemplateID:.*tomorrow|GeneratedTextSchemaID:.*tomorrow" internal docs
```
## Completion Standard
Acceptance criteria:
- Full test suite passes.
- Help output still shows `generate tomorrow`.
- No production-code stale `daily_tomorrow` symbols remain.
- Generated-text-template artifacts for Tomorrow are persisted in the same
categories as Hourly.
- Existing Hourly behavior still passes tests.
This stage is suitable for one implementation prompt.
## Open Questions
None block implementation. The required decisions are locked by
`docs/roadmap/tomorrow.md` and this implementation plan:
- Tomorrow uses report ID `tomorrow`.
- Tomorrow uses prompt ID `weather.tomorrow_generated_text`.
- Tomorrow uses template ID and generated-text schema ID `tomorrow`.
- Tomorrow forecast discussion is an array of paragraph strings.
- `daily_tomorrow` compatibility aliases are intentionally not preserved.
## Global Validation Checklist
- `go test ./...`
- `go run ./cmd/weatherreporter --help`
- `git diff --check`
- `rg -n "DailyTomorrow|dailyTomorrow|daily_tomorrow" internal docs examples`
- Confirm `weatherreporter generate tomorrow` uses structured JSON from
Scriptorium and renders final Markdown locally.
- Confirm distributor notification context uses `report_id=tomorrow` and
`artifact_group=tomorrow`.
- Confirm examples contain no unimplemented fields and no secrets.
The implementation is complete only when all nine stages pass their focused
and repository-wide checks, all locked decisions are observable in code and
canonical documentation, and the feature roadmap's completion criteria are
satisfied. Passing compilation alone is insufficient. The final state must
demonstrate repaired success, repair exhaustion, comparison provenance,
endpoint-only routing, optional credentials, inherited profiles, Rakestrawhome
inspection, safe ordinary provider failures, secure debug-only detail, and
unchanged publication and concurrency invariants.

View File

@@ -0,0 +1,488 @@
# PromptKit v0.8.0 Upgrade Roadmap
Status: Implemented.
## Purpose
WeatherReporter should upgrade its PromptKit dependency from `v0.5.0` to
`v0.8.0` and deliberately adopt the useful consumer-facing capabilities added
in `v0.6.0`, `v0.7.0`, and `v0.8.0`. The upgrade should improve output-contract
reliability, profile composition, local and alternate endpoint support, and
provider-failure diagnosis without moving PromptKit responsibilities into
WeatherReporter or weakening the application's stateless and security
boundaries.
This roadmap defines the intended scope, policy, and end state. The
[implementation plan](implementation.md) owns the procedure for reaching that
state.
## User Intent
The upgrade is intended to:
- use PromptKit's bounded output repair to recover from occasional malformed
structured weather prose;
- keep WeatherReporter's domain profile IDs stable while inheriting maintained
PromptKit model definitions;
- make PromptKit's additional built-in backend and profile available for
explicit generation and profile comparisons;
- support unauthenticated or optionally authenticated OpenAI-compatible
endpoints without inventing a WeatherReporter transport layer;
- make provider HTTP failures more actionable under an explicit
WeatherReporter disclosure policy; and
- receive PromptKit's intervening correctness, safety, cancellation, resource,
and efficiency improvements as part of one tested dependency upgrade.
The model ladder and report assignments do not change as part of this work:
Hourly continues to select `weather-light`; Daily, Today, and Tomorrow continue
to select `weather-balanced`; and `weather-deep` remains available for explicit
selection. This upgrade does not promote the new Rakestrawhome profile into
that default ladder.
## Current State
WeatherReporter currently depends on
`gitea.maximumdirect.net/eric/promptkit` at `v0.5.0`. The PromptKit adapter
supplies embedded prompts, JSON Schemas, and
application-fallback profiles, plus an optional configured profile source and
the conventional local backend.
The four generated-text prompts are exact version `2.0.0` JSON Schema prompts.
They omit `repair_attempts`, so execution is single-pass. The project-owned
`promptexec.OutputContract` and validation result also omit repair budgets and
actual repair counts.
The three embedded WeatherReporter profiles duplicate the effective fields of
these PromptKit built-ins:
| WeatherReporter profile | PromptKit built-in with the same target |
| --- | --- |
| `weather-light` | `deepseek-4-flash` |
| `weather-balanced` | `gemini-flash-latest` |
| `weather-deep` | `claude-sonnet-latest` |
WeatherReporter preflights any nonblank `api_key_env` as a required credential,
even though PromptKit v0.7.0 distinguishes an optional environment source from
an explicit `APIKeyRequired` target. Provider generation failures are reduced
to WeatherReporter's safe `generation` category; the PromptKit dependency error
is retained as a hidden cause, but its structured HTTP status and provider
diagnostics are not mapped into project-owned values.
The maintained `weather-light` local override is an endpoint-only profile, and
the configuration contract says endpoint-only profiles are supported. PromptKit
inspection correctly reports no backend ID for that form, but WeatherReporter
application preflight currently requires both a nonblank backend and model.
That mismatch prevents the documented example from reaching generation and
should be corrected as part of adopting the current PromptKit target contract.
## Upstream Release Assessment
### PromptKit v0.6.0
`v0.6.0` adds no public declarations, but it is a material compatibility and
safety release. It centralizes execution-setting, output-contract, endpoint,
and JSON-compatible-value validation; makes YAML metadata authoritative for
prompt and profile identity; hardens `content_file` containment and regular-file
requirements; validates OpenAI-compatible endpoints structurally; bounds JSON
trees and successful provider bodies; requires exactly one JSON value in
provider responses; preserves cancellation and transport error identities; and
reuses schema and rendered-artifact work within an operation.
WeatherReporter should receive these improvements directly from the dependency
and audit its own supported assets and configuration paths against the stricter
contracts. It should not duplicate PromptKit's internal validators or tests.
The existing embedded prompt paths, inline data-package input, generated-output
limit, and adapter boundary remain conceptually correct.
### PromptKit v0.7.0
`v0.7.0` adds four potentially useful consumer features:
- linear, cycle-safe profile inheritance through `base_profile` and
`Profile.BaseProfileID`;
- the built-in `rakestrawhome` backend and
`rakestrawhome-gemma-4-31b` profile;
- optional API-key environment sources, with `APIKeyRequired` reserved for an
explicit local credential requirement; and
- bounded structured `GenerationError` details for non-2xx responses from the
built-in OpenAI-compatible client.
WeatherReporter has no manual `rakestrawhome` registration and uses keyed
PromptKit profile literals, so the two source-compatibility hazards called out
by the release do not require migration shims. The profile, credential, and
error features do require deliberate application-policy choices described
below.
### PromptKit v0.8.0
`v0.8.0` activates the existing output-contract repair budget. A positive
`repair_attempts` value authorizes up to that many corrective model calls after
eligible `basic`, `json`, or `json_schema` validation failures. The supported
budget is zero through three. Repairs preserve the original rendered
conversation, effective target, session, structured-output contract, and
backend capacity policy. The final result reports cumulative token usage and
the number of corrective calls actually made.
Repair exhaustion is a completed generation with failed validation, not an
operational error. WeatherReporter's existing policy should continue to reject
that result and publish no report for that profile. Explicit empty provider
content now reaches output validation; for WeatherReporter's JSON Schema
prompts it is therefore eligible for repair rather than being misclassified as
a malformed provider envelope.
## Desired End State
WeatherReporter builds and tests against PromptKit `v0.8.0` with no workspace,
vendor, or module replacement dependency. Its public behavior remains
stateless, its PromptKit dependency types remain confined to the adapter, and
its ordinary summaries and logs remain safe.
The completed integration:
- benefits from the v0.6.0 safety and efficiency corrections;
- composes WeatherReporter domain profiles from PromptKit's maintained built-in
profiles while preserving WeatherReporter-owned leaf IDs and operator
override precedence;
- accepts a successfully inspected endpoint-only profile with a nonblank model
even though it has no logical backend ID;
- permits explicit use of PromptKit's Rakestrawhome profile without custom
backend wiring;
- applies an accepted bounded-repair policy to every operational structured
prompt;
- validates repair configuration during preflight and records actual repair
activity in project-owned result values;
- retains PromptKit's cumulative usage accounting in explicit debug output;
- distinguishes safe provider HTTP status from potentially sensitive provider
diagnostics; and
- documents the changed profile, credential, repair, comparison, debug, and
failure contracts in their canonical owners.
## Dependency And Compatibility Policy
The module requirement should move directly from `v0.5.0` to `v0.8.0`, followed
by a clean module tidy. WeatherReporter already requires Go 1.26 while PromptKit
`v0.8.0` requires Go 1.25.5, so no Go version change is needed for this upgrade.
Consumer validation must cover the paths called out by PromptKit v0.6.0:
- every embedded prompt, content file, schema, and fallback profile inspects
through PromptKit `v0.8.0`;
- configured single-file and directory profile sources retain their lazy,
metadata-authoritative identity and precedence behavior;
- malformed selected profiles and invalid local endpoints retain actionable
WeatherReporter categories;
- the inline YAML data package and prepared-execution path remain within the
new JSON and response bounds; and
- cancellation, deadline, and backend-capacity identities still cross the
adapter correctly.
PromptKit owns its 16 MiB successful transport-response bound and JSON framing.
WeatherReporter retains its stricter 64 KiB generated-text acceptance bound.
The consumer suite should protect that relationship without reproducing
PromptKit's lower-level transport matrix.
## Domain Profile Composition
The embedded profiles should become application-owned aliases:
```yaml
id: weather-light
base_profile: deepseek-4-flash
```
```yaml
id: weather-balanced
base_profile: gemini-flash-latest
```
```yaml
id: weather-deep
base_profile: claude-sonnet-latest
```
The effective backend, model, timeout, service tier, and reasoning settings
must initially remain identical to the current WeatherReporter definitions.
The selected leaf remains the durable logical profile identity even though its
effective target is inherited.
Profile source precedence remains:
1. explicit in-memory profiles used by tests or embedding consumers;
2. the configured `profile_file` or `profile_dir` source;
3. WeatherReporter's embedded fallback catalog; and
4. PromptKit's built-in catalog.
Sources still do not merge definitions of the same ID. Once a selected
definition names `base_profile`, however, each parent ID is resolved through
that same precedence order and the resulting linear chain is merged from root
to leaf according to PromptKit's inheritance contract. Documentation must make
that distinction explicit. A malformed leaf, missing or malformed base, cycle,
overlong chain, or incomplete resolved target fails profile inspection before
weather collection.
An operator may continue to replace `weather-light`, `weather-balanced`, or
`weather-deep` with a standalone definition. An operator may also define a
derived replacement. The maintained local endpoint example should remain
standalone because PromptKit profile inheritance has no clearing syntax: using
an OpenRouter base would retain its backend identity and capacity policy even
when the child replaces the endpoint.
WeatherReporter should treat the adapter's successful profile inspection as
authoritative that PromptKit resolved a usable route. A nonblank model remains
required, but backend ID is optional for an endpoint-only profile and should be
omitted from safe provenance where unavailable. WeatherReporter still must not
surface the endpoint outside explicit debug capture. This aligns application
preflight with PromptKit and with the existing CLI, comparison, and debug value
shapes, all of which already permit an absent backend identity.
## Rakestrawhome Availability
The reserved `rakestrawhome` backend and built-in
`rakestrawhome-gemma-4-31b` profile should be supported automatically through
ordinary PromptKit selection. Operators may choose that profile with the
existing global profile setting or as one entry in `compare`, and PromptKit's
backend capacity policy remains authoritative.
WeatherReporter should not register, wrap, or duplicate the backend or profile,
and should not add a Rakestrawhome-specific configuration field. Its canonical
PromptKit integration documentation should link to PromptKit for the current
built-in catalog and credential contract rather than copying volatile endpoint
or capacity values. Offline inspection coverage should prove that the built-in
profile crosses the WeatherReporter adapter with the expected logical backend
identity.
## Bounded Structured-Output Repair
The accepted repair budget belongs to the exact PromptKit output contract, not
to a WeatherReporter retry loop. PromptKit alone should construct corrective
messages, perform additional calls, enforce the budget, aggregate usage, and
coordinate backend capacity. WeatherReporter must not retry provider failures,
switch profiles, or layer another repair mechanism around `RunPrepared`.
All four embedded prompt definitions should declare `repair_attempts: 1`.
Because this changes prompt
execution behavior, latency, cost, hash, and provenance, each definition and
its report-registry binding should advance from exact version `2.0.0` to
`2.1.0`. Prompt text and generated-text schemas do not need to change solely
for this feature.
The embedded prompt definition is the per-report pipeline policy owner. This
upgrade should not add a global or per-report operator configuration field for
repair attempts and should not construct a request-level replacement output
contract. A future pipeline may select another budget only through a deliberate
prompt-definition and exact-version change.
The project-owned PromptKit boundary should retain:
- the configured repair budget in prompt inspection and preparation output
contracts;
- the number of corrective calls actually made in completed validation;
- cumulative PromptKit token usage across initial and corrective calls; and
- the final candidate and final validation result only, consistent with the
PromptKit contract.
Prompt inspection and preparation provenance must require the repair budget to
match the exact expected prompt definition just as they currently require the
format, validation mode, and schema path to match. A zero-attempt successful
result is normal when the first candidate passes. A repair-exhausted result
continues through WeatherReporter's ordinary `validation_rejected` failure
path, and an operational or generation failure during correction remains that
profile's ordinary operational failure.
For concurrent comparison, every profile should use the same prompt repair
budget. A corrective call remains part of that profile's one prepared
execution and uses PromptKit's existing backend capacity pool. One profile's
repair or failure must not cancel independent peers.
## Repair Observability And Comparison Contract
The actual repair count is safe operational provenance and should be visible
where WeatherReporter already reports completed validation. Generation, batch,
and comparison action summaries should expose it without exposing candidates,
schemas, or diagnostics. Explicit execution debug output should add it to the
validation object alongside PromptKit's already mapped cumulative usage.
Profile comparison needs this value in `comparison.json`: a successful result
that required correction is materially different from a first-pass success
when evaluating model reliability, latency, and cost. The manifest should
therefore advance to `weatherreporter.comparison.v2` and add a non-negative
`repairAttempts` field to each result. The field is zero when no corrective
call began, including ordinary first-pass success. A failure carries the count
when PromptKit returned a completed validation result; it is omitted only when
execution failed before a completed validation result made the value known.
The v2 manifest should remain flat, strict, deterministic, and atomically
published. WeatherReporter does not need to preserve v1 replacement
compatibility: comparison bundles are operator-owned development outputs, and
the current integration contract intentionally recognizes only its current
schema. The release notes and comparison documentation must call out the
version change so an operator can remove or relocate an older bundle before
using guarded replacement at the same destination.
## Credential Semantics
PromptKit v0.7.0 treats `APIKeyEnv` as an optional lookup source. If the
environment variable is absent or blank and no direct credential is supplied,
the built-in client omits `Authorization` and lets the endpoint respond.
`APIKeyRequired` is the distinct signal that a usable credential must be
provided locally.
WeatherReporter cannot supply PromptKit's request-scoped direct API-key value,
so a profile reporting `APIKeyRequired` remains unsupported and must fail
before weather collection. WeatherReporter should not require a nonblank value
for an optional `APIKeyEnv` during application preflight. The built-in client
should omit `Authorization` when that source is unavailable and let the
endpoint return any authentication failure through the ordinary structured
generation-error path.
## Structured Generation Failures
PromptKit v0.7.0's `GenerationError` can report a provider HTTP status plus
bounded provider code, type, and message. WeatherReporter should consume that
type only inside the PromptKit adapter and map any adopted fields into a
project-owned immutable error. PromptKit dependency types must not become app
or CLI contracts.
HTTP status is safe enough for ordinary diagnostics. Provider code, type, and
message remain untrusted and may contain request or schema fragments. They
must never enter ordinary errors, action summaries, comparison manifests,
logs, generated reports, or Distributor payloads. The full structured
diagnostic belongs only in an explicitly requested secure `--llm-debug-dir`
`failure.json` artifact. That artifact may contain PromptKit's normalized
bounded fields but never the raw provider body, headers, endpoint, credentials,
or reconstructed request.
Initial-call and corrective-call non-2xx responses should follow the same
mapping. Cancellation and deadline categories continue to take precedence over
provider classification where PromptKit preserves those identities.
## Testing Policy
The default suite must remain deterministic, offline, and credential-free.
Use injected PromptKit clients and synthetic embedded or temporary assets for
consumer behavior; do not call OpenRouter, Rakestrawhome, or a local endpoint.
Risk-based coverage should include:
- all embedded prompts and inherited domain profiles inspecting successfully
under PromptKit `v0.8.0`;
- unchanged effective targets and report-to-profile assignments after the
alias refactor;
- external standalone and derived profile precedence, plus selected missing,
cyclic, and malformed-base failures at the WeatherReporter boundary;
- end-to-end preflight and prepared execution through the maintained
endpoint-only `weather-light` override without exposing its endpoint;
- offline inspection of `rakestrawhome-gemma-4-31b`;
- a first-pass valid result with zero repairs;
- an invalid structured result repaired successfully within one corrective
call;
- one-attempt exhaustion returning failed validation and no published report;
- a corrective generation failure retaining its safe category and provider
status policy;
- cumulative usage and actual repair-count mapping;
- comparison peers remaining independent when one profile repairs, exhausts,
or fails;
- v2 comparison manifest validation and guarded replacement; and
- absent or blank optional `APIKeyEnv` values reaching the provider without an
`Authorization` header, while `APIKeyRequired` profiles fail preflight.
Do not reproduce PromptKit's internal matrices for path traversal, JSON tree
bounds, response framing, inheritance depth, repair prompt construction, or
provider-detail normalization. WeatherReporter tests should protect only its
adapter mappings, application policy, provenance, publication, and public
contracts. Run ordinary and race-enabled repository tests because the repaired
execution path participates in concurrent comparisons.
## Documentation And Release Impact
Implementation must update each canonical owner whose contract changes:
- `docs/policy/architecture.md` for prompt-execution, credential, diagnostic,
and comparison invariants;
- `docs/config.md` and the maintained local profile example for profile-source,
inheritance, and credential semantics;
- `docs/integrations/promptkit.md` for exact prompt versions, repair policy,
profile composition, Rakestrawhome availability, and safe errors;
- `docs/integrations/comparison-bundle.md` for the v2 manifest and repair count;
- `docs/cli.md` for repair-count fields in action summaries;
- `docs/operations.md` for changed failure behavior and any explicit provider
diagnostic capture;
- focused internal PromptKit adapter, app orchestration, prompt-debug, and
comparison documentation; and
- release notes for the dependency jump, prompt version change, possible
additional model call, credential behavior, profile inheritance, diagnostic
behavior, and comparison schema change.
Current-state documentation must not describe this behavior until the
implementation lands. PromptKit remains the canonical owner of its complete
built-in catalogs, YAML merge rules, transport limits, corrective-message
construction, and public Go API.
## Scope
The completed feature includes:
- the direct module upgrade and tidy dependency graph;
- a v0.6.0 compatibility audit of WeatherReporter's supported PromptKit paths;
- inherited WeatherReporter domain profile definitions with unchanged
effective targets;
- correction of application preflight so PromptKit endpoint-only profiles work
as documented while retaining a required model identity;
- ordinary access to the Rakestrawhome built-in profile;
- PromptKit's optional-credential policy, while direct-key-required profiles
remain unsupported;
- `repair_attempts` on all operational prompts and exact prompt-version bumps;
- project-owned repair budget, actual-attempt, usage, and provenance mappings;
- repair observability in action summaries, explicit debug output, and a v2
comparison manifest;
- safe provider HTTP status in ordinary errors and bounded provider detail only
in explicit secure debug capture;
- focused offline and race-enabled regression coverage; and
- canonical current-state and release documentation updated with the code.
## Non-Goals
This upgrade does not include:
- application-implemented repair prompts or provider transport;
- retries for HTTP, network, timeout, capacity, or other operational failures;
- automatic profile escalation, fallback, ranking, or resampling;
- changing the weather profile ladder, default report assignments, or concrete
model targets beyond inheriting their maintained PromptKit definitions;
- making Rakestrawhome a default or adding provider-specific configuration;
- live-provider tests or a permanent benchmark framework;
- exposing raw provider responses or sensitive diagnostics routinely;
- a general prompt-source or pipeline plugin system; or
- compatibility shims for PromptKit versions older than `v0.8.0`.
## Completion Criteria
The roadmap is complete when:
- `go.mod` and `go.sum` resolve PromptKit `v0.8.0` without a replacement,
workspace, or vendor tree;
- all PromptKit v0.6.0 compatibility points relevant to WeatherReporter have
been checked and valid supported inputs retain project-owned error identity;
- the three WeatherReporter profiles inherit the intended PromptKit built-ins,
retain their logical IDs, and inspect to the intended effective targets;
- external standalone and inherited overrides obey documented precedence and
failure behavior;
- the maintained endpoint-only local override passes application preflight,
retains an empty backend ID, and keeps its endpoint out of ordinary values;
- `rakestrawhome-gemma-4-31b` is selectable through ordinary generation and
comparison paths without WeatherReporter backend registration;
- every operational prompt has one bounded repair attempt at exact
version `2.1.0`;
- inspection, preparation, execution, debug, and comparison values accurately
preserve configured and actual repair counts;
- first-pass success, repaired success, repair exhaustion, repair generation
failure, and explicit empty content follow the documented outcomes;
- the v2 comparison bundle distinguishes first-pass and repaired results;
- credential preflight accepts absent optional environment credentials while
rejecting direct-key-required profiles, and provider-error disclosure does
not leak sensitive values;
- the default test suite is offline and deterministic, ordinary and race
validation pass, and no redundant upstream implementation suite is copied;
and
- every implemented contract is documented by its canonical current-state
owner and disclosed in the eventual release notes.

View File

@@ -1,276 +0,0 @@
# Tomorrow Report Roadmap
## Purpose
This roadmap defines the target state for making Tomorrow an independent
generated-text-template report. The feature is not implemented yet, so this
document lives under `docs/roadmap/`.
## Intent
Tomorrow should become its own report product, not a variant of the Daily
Report. The current CLI command `weatherreporter generate tomorrow` should
remain, but the internal report ID, prompt ID, template, schema, workspace
paths, and distributor identity should use `tomorrow`.
The report should combine deterministic daypart and precipitation facts with
LLM prose for the high-level summary, optional precipitation context, and
forecast discussion. The resulting Markdown should be predictable and
template-driven, similar to the implemented Hourly Report.
Longer term, Today, Tomorrow, and Daily may all become separate report products
with different prompts, templates, and deterministic sections. This roadmap
starts that split with Tomorrow.
## Target Report Shape
Example structure:
```markdown
# Sunday's Weather
**Forecast Date:** Sunday, June 15, 2026
**Generated:** Saturday, June 14, 2026 at 9:14 AM
<GeneratedText summary>
## Daypart Forecast
- **Overnight:** <deterministic daypart line>
- **Morning:** <deterministic daypart line>
- **Midday:** <deterministic daypart line>
- **Afternoon:** <deterministic daypart line>
- **Evening:** <deterministic daypart line>
## Precipitation Timing
- **1:00 AM** to **5:00 AM**: Precipitation is expected during this period. The peak precipitation chance is 59% at 2:00 AM.
- <optional GeneratedText precipitation_timing>
## Forecast Discussion
<GeneratedText forecast_discussion paragraphs>
```
`Precipitation Timing` should render only when at least one precipitation
window exists for the valid period. The threshold for precipitation windows
remains the existing precipitation-window threshold, currently 40%.
## Locked Decisions
- Replace report ID `daily_tomorrow` with `tomorrow`.
- Do not preserve compatibility aliases for `daily_tomorrow`; this is a
pre-release clean break.
- Keep public CLI syntax: `weatherreporter generate tomorrow`.
- Use generated-text-template generation for Tomorrow, not full Markdown
generation by Scriptorium.
- Use a dedicated Scriptorium prompt ID, template ID, and schema ID:
- prompt ID: `weather.tomorrow_generated_text`
- template ID: `tomorrow`
- generated-text schema ID: `tomorrow`
- Use `ArtifactGroup: "tomorrow"` and `BatchOutputName: "tomorrow.md"`.
- Use `CompatiblePriorIDs: []report.ID{report.Tomorrow}`.
- Keep valid-period behavior: Tomorrow covers the next local civil day.
- Keep Morning/Evening batch behavior unless explicitly changed later; evening
batch should still include Tomorrow.
- Future Today/Daily split is out of scope for this roadmap.
## GeneratedText Contract
Add a Tomorrow GeneratedText schema:
```json
{
"summary": "string",
"forecast_discussion": ["string"],
"precipitation_timing": "string",
"confidence": "string"
}
```
Required:
- `summary`
- `forecast_discussion`
Optional:
- `precipitation_timing`
- `confidence`
`forecast_discussion` should be an array of paragraph strings so Scriptorium
can return multi-paragraph discussion without embedding paragraph delimiters in
one string. Empty or whitespace-only discussion paragraphs should be rejected or
trimmed out during validation; after trimming, at least one paragraph is
required.
`confidence` may be validated and persisted but does not need to render in the
initial template.
## Template Context
Add a dedicated Tomorrow render context rather than reusing Hourly context
types.
Recommended top-level shape:
```go
type TomorrowRenderContext struct {
Report TomorrowReportContext
GeneratedText Tomorrow
Modules TomorrowTemplateModules
Collected facts.CollectedFacts
Derived facts.DerivedFacts
}
```
`TomorrowReportContext` should include:
- `Title`: for example `Sunday's Weather`
- `ForecastDate`: canonical local forecast date if useful
- `ForecastDateLabel`: for example `Sunday, June 15, 2026`
- `ForecastDayName`: for example `Sunday`
- `GeneratedAt`
- `GeneratedAtLabel`: for example `Saturday, June 14, 2026 at 9:14 AM`
- `ValidPeriod`
- `Timezone`
Do not derive the possessive title in the template. Go should provide `Title`
so wording is consistent and easy to test.
`TomorrowTemplateModules` should expose the module outputs needed by the
template:
- `Metadata`
- `DerivedDailySummary`
- `DerivedDaypartSummaries`
- `PrecipTiming`
- `AlertDigest`
- `SPCConvectiveOutlooks`
- `AreaForecastDiscussion`
- `SPCConvectiveDiscussion`
- `WeatherStory`
- `TomorrowPlanning`, if still useful
Current conditions and hourly forecast can remain in the module snapshot and
data package if useful for Scriptorium, but they do not need to render in the
initial Tomorrow template unless a later design calls for them.
## Daypart Forecast
The Daypart Forecast should be deterministic but composable. Avoid adding a
single prewritten Go `DaypartLine` string that makes template wording rigid.
Add presentation-friendly fields to `derived_daypart_summaries` only where they
avoid awkward template logic. Likely useful fields:
- display name, such as `Overnight` or `Morning`;
- lower-case dominant condition text for inline sentences;
- rounded temperature range phrase if available;
- precipitation mention flag using the existing hourly line mention threshold
concept, currently 20%;
- max precipitation probability and friendly max time;
- optional wind phrase or wind range only if deterministic wind wording is
clearly needed.
The initial implementation may keep daypart bullet wording simple. It should be
easy to revise the template text without editing Go unless new facts are
needed.
## Implementation Plan
1. Report identity split
- Rename `report.DailyTomorrow` to `report.Tomorrow` with ID `tomorrow`.
- Update registry order, resolver references, CLI mapping, batch selection,
state metadata expectations, docs, and tests.
- Preserve `weatherreporter generate tomorrow`.
- Accept that workspace paths, RunIDs, distributor bundle IDs, and report
URLs change from `daily_tomorrow` to `tomorrow`.
2. GeneratedText contract
- Add `generatedtext.Tomorrow`, validation, normalized JSON output, and
tests.
- Add `tomorrow.generated_text.schema.json`.
- Add `internal/reporttemplate/prompts/tomorrow.generated_text.md` as the
maintained prompt source asset.
- Update app validation dispatch to use the Tomorrow schema.
3. Template and render context
- Add `internal/reporttemplate/templates/tomorrow.md.tmpl`.
- Add Tomorrow template/schema lookup entries.
- Add `BuildTomorrowRenderContext`.
- Update app render-context dispatch for template ID `tomorrow`.
- Persist render context in the existing generated-text-template workflow.
4. Module presentation fields
- Add only the daypart presentation fields needed by the template.
- Reuse the existing precipitation window hour-label fields.
- Keep deterministic weather derivation in Go and wording/layout in the
template.
5. Report definition conversion
- Change Tomorrow report definition to:
- `PromptID: "weather.tomorrow_generated_text"`
- `GenerationMode: generated_text_template`
- `TemplateID: "tomorrow"`
- `GeneratedTextSchemaID: "tomorrow"`
- `ArtifactGroup: "tomorrow"`
- `BatchOutputName: "tomorrow.md"`
- compatible prior IDs containing only `tomorrow`
- Review module composition and keep only modules used by the prompt,
template, or future inspection value.
6. Documentation and examples
- After implementation, update non-roadmap docs for implemented behavior:
`docs/cli.md`, `docs/operations.md`, `docs/internal/report-registry.md`,
`docs/internal/generatedtext.md`, `docs/internal/reporttemplate.md`, and
`docs/templates.md`.
- Update examples that refer to `reports.tomorrow` or report module
overrides if the config key changes.
## Test Plan
- Report tests:
- registry contains `tomorrow`, not `daily_tomorrow`;
- `generate tomorrow` resolves report ID `tomorrow`;
- valid period remains next local civil day;
- evening batch still includes Tomorrow;
- RunID and artifact paths use `tomorrow`.
- GeneratedText tests:
- `summary` and non-empty `forecast_discussion` are required;
- `forecast_discussion` trims paragraph strings and rejects/omits blanks;
- optional `precipitation_timing` and `confidence` normalize correctly;
- unknown fields are rejected.
- Template tests:
- title renders as `<weekday>'s Weather`;
- forecast date and generated labels render;
- daypart bullets render in configured daypart order;
- precipitation section is omitted when no precipitation windows exist;
- precipitation section includes deterministic windows and optional LLM text
when windows exist;
- forecast discussion renders multiple paragraphs.
- App/CLI workflow tests:
- `weatherreporter generate tomorrow` uses structured Scriptorium output and
the template renderer;
- raw generated text, validated generated text, render context, report, and
metadata artifacts are persisted;
- optional `--out` behavior remains unchanged;
- distributor notification uses report ID/artifact group `tomorrow`.
Validation commands:
```bash
go test ./internal/report ./internal/generatedtext ./internal/reporttemplate ./internal/briefing ./internal/app ./internal/cli ./internal/state
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
## Open Questions
None block implementation. Recommended defaults are:
- make `forecast_discussion` an array of strings for Tomorrow;
- keep Hourly GeneratedText unchanged for now;
- do not add Daily Today or generic Daily report splits in this change;
- do not preserve `daily_tomorrow` compatibility aliases.

View File

@@ -1,301 +1,184 @@
# Report Templates
## Purpose
This guide is for maintainers editing Weatherreporter's embedded Markdown
templates. Templates format already validated report inputs; they do not select
sources, derive weather facts, or validate generated prose. For those details,
see [Generated Text internals](internal/generatedtext.md) and [Report Template
internals](internal/reporttemplate.md).
This guide describes the implemented Markdown report template surface for
`weatherreporter`. It is for maintainers editing embedded report templates,
especially generated-text-template reports.
## Template Assets
Templates are Go `text/template` files. The current implemented templates are:
Only the generated-text reports use repository-native Markdown templates.
Each report has one matching template ID, generated-text schema ID, and prompt
source:
- `internal/reporttemplate/templates/tomorrow.md.tmpl`
- `internal/reporttemplate/templates/hourly.md.tmpl`
| Report | Template | Schema | Prompt ID and source |
| --- | --- | --- | --- |
| Daily | `templates/daily.md.tmpl` (`daily`) | `daily` | `weather.daily_generated_text`; `internal/promptassets/assets/prompts/daily/` |
| Today | `templates/today.md.tmpl` (`today`) | `today` | `weather.today_generated_text`; `internal/promptassets/assets/prompts/today/` |
| Tomorrow | `templates/tomorrow.md.tmpl` (`tomorrow`) | `tomorrow` | `weather.tomorrow_generated_text`; `internal/promptassets/assets/prompts/tomorrow/` |
| Hourly | `templates/hourly.md.tmpl` (`hourly`) | `hourly` | `weather.hourly_generated_text`; `internal/promptassets/assets/prompts/hourly/` |
Templates are rendered from structured contexts such as `TomorrowRenderContext`
and `HourlyRenderContext`. Weather data collection, derivation, module
execution, generated text validation, and artifact paths are handled before
template rendering.
The matching schemas and Promptkit definitions are embedded by
`internal/promptassets`. The generated-text catalog requires each report's
exact schema/template pair; keep the matching prompt definition aligned with
that report-specific triple.
Shared partials are under `internal/reporttemplate/templates/partials/`:
| Partial | Used by |
| --- | --- |
| `alert_digest.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
| `precipitation_timing.md.tmpl` | Daily, Today, Tomorrow, and Hourly |
| `daypart_forecast.md.tmpl` | Daily and Tomorrow |
| `today_daypart_forecast.md.tmpl` | Today |
All shared partials are parsed whenever any top-level template is rendered. A
syntax error in a partial can therefore prevent every generated-text report
from rendering.
## Editing Rules
- Use Go `text/template` syntax.
- Keep templates focused on Markdown layout, headings, ordering, and simple
conditional display.
- Do not put weather derivation, source selection, or path construction logic in
templates.
- Missing template keys are errors. A misspelled variable will fail rendering.
- No custom template functions are currently registered.
- Optional module stanzas are pointers and should be guarded with
`{{ with .Modules.WeatherStory }}...{{ end }}`.
- Slices can be rendered with `{{ range .Items }}...{{ else }}...{{ end }}`.
- Use Go `text/template` syntax and keep changes to Markdown structure,
ordering, and display conditions.
- Templates use `missingkey=error`; reference only documented fields and guard
optional module pointers with `with` or `if`.
- Prefer `.Modules` for deterministic display values. Do not add weather
calculations, source selection, or prompt-input shaping to a template.
- Keep generated prose in `.GeneratedText`; do not restate deterministic facts
in generated prose merely to compensate for a template change.
- Render every `.GeneratedText` value through `plainText`. It preserves prose
and paragraph breaks while escaping Markdown and HTML syntax, removing code
indentation, and replacing control characters. Never interpolate generated
prose directly: repository templates alone own headings, lists, links, and
other Markdown structure.
- When changing the generated-prose contract, update the matching prompt,
schema, validator, render context, and template together. The validation and
catalog rules are owned by [Generated Text internals](internal/generatedtext.md).
- Use `.Modules.Dayparts` for ordered daypart output. Do not range over
`.Modules.DerivedDaypartSummaries`, which is a map. The Today partial uses
`.Modules.HasDaypartDetails` to ensure its heading has either rows or the
explicit no-details fallback.
## Hourly Context
The hourly template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | HourlyReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Hourly | Structured text returned by Scriptorium. |
| `.Modules` | HourlyTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Prefer `.Modules` for normal template edits. `.Collected` and `.Derived` are
available when a template needs lower-level facts, but templates should still
avoid nontrivial derivation.
## Report
| Variable | Type | Description |
| --- | --- | --- |
| `.Report.Title` | string | Display title. Currently `Hourly Report`. |
| `.Report.LocationName` | string | Prompt/report location label, such as `Brentwood, MO`. |
| `.Report.GeneratedAt` | time.Time | Canonical generation timestamp. |
| `.Report.GeneratedAtLabel` | string | Friendly local generation time label. |
| `.Report.ValidPeriod` | timeutil.Period | Canonical valid period. |
| `.Report.ValidPeriodLabel` | string | Friendly local valid period label, such as `2026-05-29 at 8:30 AM to 2026-05-29 at 2:30 PM`. |
| `.Report.Timezone` | string | Effective report timezone. |
## GeneratedText
These fields are written by Scriptorium as structured JSON, validated by
weatherreporter, and then inserted into the render context.
| Variable | Type | Description |
| --- | --- | --- |
| `.GeneratedText.Summary` | string | Required short prose summary. |
| `.GeneratedText.ForecastDiscussion` | string | Required prose for the Forecast Discussion section. |
| `.GeneratedText.PrecipitationTiming` | string | Optional prose rendered after deterministic precipitation windows. |
| `.GeneratedText.Confidence` | string | Optional confidence or uncertainty note. Empty when omitted by the LLM; not rendered by the current hourly template. |
Example:
```gotemplate
{{ .GeneratedText.Summary }}
## Forecast Discussion
{{ .GeneratedText.ForecastDiscussion }}
```
## Tomorrow Context
The Tomorrow template receives five top-level values:
| Variable | Type | Description |
| --- | --- | --- |
| `.Report` | TomorrowReportContext | Display metadata and friendly labels for the rendered report. |
| `.GeneratedText` | Tomorrow | Structured text returned by Scriptorium. |
| `.Modules` | TomorrowTemplateModules | Preferred deterministic template surface, keyed by module purpose. |
| `.Collected` | facts.CollectedFacts | Normalized upstream facts for advanced template use. |
| `.Derived` | facts.DerivedFacts | Shared derived facts for advanced template use. |
Tomorrow report metadata includes `.Report.Title`, `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, `.Report.ForecastDayName`,
`.Report.GeneratedAt`, `.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and
`.Report.Timezone`.
Tomorrow generated text uses the same `.GeneratedText.Summary`,
`.GeneratedText.PrecipitationTiming`, and `.GeneratedText.Confidence` fields as
Hourly. `.GeneratedText.ForecastDiscussion` is a slice of paragraphs and should
be rendered with `range`.
Tomorrow modules include the Hourly module fields plus:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.DerivedDailySummary` | *briefing.DerivedDailySummaryModule | Daily summary facts for the forecast date. |
| `.Modules.DerivedDaypartSummaries` | *map[string]briefing.DerivedDaypartSummaryModule | Raw daypart summary map, when direct keyed access is needed. |
| `.Modules.Dayparts` | []generatedtext.TomorrowDaypartContext | Ordered daypart summaries for deterministic template rendering. |
| `.Modules.TomorrowPlanning` | *briefing.TomorrowPlanningModule | Planning facts for the next local civil day. |
Prefer `.Modules.Dayparts` over ranging through
`.Modules.DerivedDaypartSummaries`; it follows configured daypart order and
falls back to sorted keys for any unmatched entries.
## Modules
`.Modules` exposes typed outputs from the same module pipeline used for the
prompt data package. Module fields are pointers because missing-data policy may
omit a stanza.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.Metadata` | *briefing.MetadataModule | Report metadata module output, when present. |
| `.Modules.CurrentConditions` | *briefing.CurrentConditionsModule | Current conditions from `/conditions/current`. |
| `.Modules.HourlyForecast` | *briefing.HourlyForecastModule | Hourly forecast periods overlapping the report valid period. |
| `.Modules.PrecipTiming` | *briefing.PrecipTimingModule | Derived precipitation timing facts and threshold windows. |
| `.Modules.AlertDigest` | *briefing.AlertDigestModule | Active alert status and relevant alert overlaps. |
| `.Modules.SPCConvectiveOutlooks` | *briefing.SPCConvectiveOutlooksModule | SPC outlooks that overlap the report valid period. |
| `.Modules.AreaForecastDiscussion` | *briefing.AreaForecastDiscussionModule | AFD key messages and configured discussion sections. |
| `.Modules.SPCConvectiveDiscussion` | *briefing.SPCConvectiveDiscussionModule | SPC discussions retained for qualifying overlapping categorical risk days. |
| `.Modules.WeatherStory` | *briefing.WeatherStoryModule | Latest NWS weather story, when available. |
### Current Conditions
Common fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.CurrentConditions.ConditionText` | string | Current condition text. |
| `.Modules.CurrentConditions.ConditionTextLower` | string | Lower-case current condition text for inline sentences. |
| `.Modules.CurrentConditions.TemperatureF` | *int | Rounded current temperature. |
| `.Modules.CurrentConditions.ApparentTemperatureF` | *int | Rounded apparent temperature. |
| `.Modules.CurrentConditions.RelativeHumidityPercent` | *int | Rounded relative humidity. |
| `.Modules.CurrentConditions.WindDirection` | string | 16-point compass wind direction. |
| `.Modules.CurrentConditions.WindDirectionText` | string | Lower-case full wind direction text, such as `northwest`. |
| `.Modules.CurrentConditions.WindSpeedMph` | *int | Rounded wind speed. |
Example:
Minimal optional-value pattern:
```gotemplate
{{ with .Modules.CurrentConditions }}
{{ .ConditionText }}{{ with .TemperatureF }}; {{ . }} F{{ end }}{{ with .WindDirection }}; wind {{ . }}{{ end }}{{ with .WindSpeedMph }} {{ . }} mph{{ end }}
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
{{ else }}
No current conditions available.
Current conditions are unavailable.
{{ end }}
```
### Hourly Forecast
Common period fields:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.HourlyForecast.Periods` | []briefing.HourlyForecastPeriod | Ordered periods for the hourly report valid period. |
| `.Modules.HourlyForecast.Periods[].HourLabel` | string | Friendly hour label such as `4:00 PM`. |
| `.Modules.HourlyForecast.Periods[].PeriodBegins` | string | Friendly local period start label. |
| `.Modules.HourlyForecast.Periods[].PeriodEnds` | string | Friendly local period end label. |
| `.Modules.HourlyForecast.Periods[].Name` | string | Source period name. |
| `.Modules.HourlyForecast.Periods[].TextDescription` | string | Hourly forecast text. |
| `.Modules.HourlyForecast.Periods[].TextDescriptionLower` | string | Lower-case hourly forecast text for inline sentences. |
| `.Modules.HourlyForecast.Periods[].TemperatureF` | *float64 | Forecast temperature. |
| `.Modules.HourlyForecast.Periods[].ProbabilityOfPrecipitationPercent` | *float64 | Forecast precipitation probability. |
| `.Modules.HourlyForecast.Periods[].MentionPrecipitation` | bool | True when precipitation probability meets the hourly mention threshold. |
| `.Modules.HourlyForecast.Periods[].WindDirection` | string | 16-point compass wind direction. |
| `.Modules.HourlyForecast.Periods[].WindSpeedMph` | *float64 | Wind speed. |
| `.Modules.HourlyForecast.Periods[].WindGustMph` | *float64 | Wind gust. |
Example:
Minimal list pattern:
```gotemplate
{{ with .Modules.HourlyForecast }}{{ range .Periods }}
- **{{ .HourLabel }}:**{{ with .TemperatureF }} {{ . }}°F{{ end }} and {{ .TextDescriptionLower }}.{{ if .MentionPrecipitation }}{{ with .ProbabilityOfPrecipitationPercent }} Probability of precipitation is {{ . }}%.{{ end }}{{ end }}
{{ else }}
- No hourly forecast rows available.
{{ end }}{{ end }}
{{ range .GeneratedText.ForecastDiscussion }}
{{ plainText . }}
{{ end }}
```
### Precipitation Timing
## Registered Functions
Common fields:
Templates have these helpers in addition to Go template built-ins:
| Variable | Type | Description |
| Function | Accepts | Returns true when |
| --- | --- | --- |
| `.Modules.PrecipTiming.MaxPopPercent` | *int | Highest hourly precipitation probability in the valid period. |
| `.Modules.PrecipTiming.MaxPopTime` | string | Friendly local time for the highest hourly precipitation probability. |
| `.Modules.PrecipTiming.ProbabilityThreshold` | float64 | Threshold used to define precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows` | []briefing.PrecipitationWindowModule | One or more threshold precipitation windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBegins` | string | Friendly local window start. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodBeginsHourLabel` | string | Friendly window start hour, such as `4:00 PM`. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEnds` | string | Friendly local window end; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].PeriodEndsHourLabel` | string | Friendly window end hour; omitted for open windows. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopPercent` | *int | Highest precipitation probability inside the window. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopTime` | string | Friendly local time for the window maximum. |
| `.Modules.PrecipTiming.PrecipitationWindows[].MaxPopHourLabel` | string | Friendly hour label for the window maximum. |
| `.Modules.PrecipTiming.ThunderMentioned` | bool | Whether thunder is mentioned in the forecast text. |
| `hasRelevantAlerts` | an alert-digest value or pointer | its `Relevant` slice is nonempty |
| `hasEnhancedOrHigherSPCRisk` | an SPC outlook value or pointer | its `RiskDigest` contains an Enhanced, Moderate, or High Risk entry |
| `isEnhancedOrHigherSPCRisk` | one SPC risk-digest entry | its `LabelText`, or fallback `RiskLabel`, is Enhanced, Moderate, or High Risk |
| `plainText` | a generated prose string | a readable plain-text rendering that preserves paragraph breaks without allowing dynamic Markdown or HTML structure |
### Alert Digest
For example, the alert partial uses the first two functions to decide whether
to render the section:
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AlertDigest.Checked` | bool | Whether alert data was checked successfully. |
| `.Modules.AlertDigest.ActiveCount` | int | Active alert count from the source. |
| `.Modules.AlertDigest.RelevantCount` | int | Alert count overlapping the report period. |
| `.Modules.AlertDigest.Missing` | bool | True when alert data is unavailable. |
| `.Modules.AlertDigest.Relevant` | []briefing.AlertSummary | Relevant alert summaries. |
| `.Modules.AlertDigest.Relevant[].Event` | string | Alert event name. |
| `.Modules.AlertDigest.Relevant[].Headline` | string | Alert headline. |
| `.Modules.AlertDigest.Relevant[].Severity` | string | Alert severity. |
```gotemplate
{{ if hasRelevantAlerts .Modules.AlertDigest }}
## Alert Digest
{{ end }}
```
### SPC Outlooks And Discussion
## Render Context
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.SPCConvectiveOutlooks.Checked` | bool | Whether SPC outlook data was checked successfully. |
| `.Modules.SPCConvectiveOutlooks.AsOf` | string | Friendly source as-of time. |
| `.Modules.SPCConvectiveOutlooks.IssuedAt` | string | Friendly source issue time. |
| `.Modules.SPCConvectiveOutlooks.Outlooks` | []briefing.SPCConvectiveOutlookRecord | Overlapping outlook records. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Day` | int | SPC day number. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].OutlookType` | string | Outlook type, such as `categorical`. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].Label` | string | Short outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].LabelText` | string | Human-readable outlook label. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodBegins` | string | Friendly outlook period start. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].PeriodEnds` | string | Friendly outlook period end. |
| `.Modules.SPCConvectiveOutlooks.Outlooks[].ImageURL` | string | Source image URL. |
| `.Modules.SPCConvectiveDiscussion.IncludedBecause` | string | Criterion used to include discussions. |
| `.Modules.SPCConvectiveDiscussion.Discussions` | []briefing.SPCConvectiveDiscussionRecord | Retained discussion records. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Headline` | string | Discussion headline. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Summary` | string | Discussion summary. |
| `.Modules.SPCConvectiveDiscussion.Discussions[].Discussion` | string | Full discussion text. |
Every rendered template receives one typed context with these three top-level
fields:
### Area Forecast Discussion
| Field | Purpose |
| --- | --- |
| `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.AreaForecastDiscussion.Product` | string | Source product identifier. |
| `.Modules.AreaForecastDiscussion.KeyMessages` | []string | AFD key messages. |
| `.Modules.AreaForecastDiscussion.ShortTerm` | string | AFD short-term section text. |
| `.Modules.AreaForecastDiscussion.LongTerm` | string | AFD long-term section text. |
### Report Metadata
### Weather Story
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
| Variable | Type | Description |
| --- | --- | --- |
| `.Modules.WeatherStory.Available` | bool | True when a story is available. |
| `.Modules.WeatherStory.OfficeID` | string | Source office ID. |
| `.Modules.WeatherStory.PeriodBegins` | string | Friendly story period start. |
| `.Modules.WeatherStory.PeriodEnds` | string | Friendly story period end. |
| `.Modules.WeatherStory.UpdatedAt` | *time.Time | Canonical update timestamp. |
| `.Modules.WeatherStory.Title` | string | Story title. |
| `.Modules.WeatherStory.Description` | string | Story description. |
| `.Modules.WeatherStory.AltText` | string | Story image alt text. |
| `.Modules.WeatherStory.Priority` | bool | Source priority flag. |
| `.Modules.WeatherStory.Order` | int | Source order. |
| `.Modules.WeatherStory.DownloadURL` | string | Source download URL. |
Hourly additionally provides `.Report.LocationName` and
`.Report.ValidPeriodLabel`.
## Collected And Derived Facts
Daily, Today, and Tomorrow additionally provide `.Report.ForecastDate`,
`.Report.ForecastDateLabel`, and `.Report.ForecastDayName`. Their valid-period
field remains canonical timing data; use the supplied display labels instead
of formatting timestamps in a template.
The template also receives the full `facts.CollectedFacts` and
`facts.DerivedFacts` structs:
### Validated GeneratedText Prose
- `.Collected` contains normalized source data and provenance from upstream
Weather API fetches.
- `.Derived` contains shared slices and calculations used across modules, such
as valid-period hourly periods, precipitation timing, alert overlaps, and SPC
filtering inputs.
GeneratedText is prose returned by Promptkit and validated before rendering.
It is not a source for deterministic weather facts.
These values are intentionally lower-level than `.Modules`. Use them when a
template needs a specific field that is not exposed by a module, but keep
calculation-heavy changes in Go.
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
| --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required; at most 4,000 characters. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; Hourly permits 12,000 characters. Day-style values permit up to 12 paragraphs of 4,000 characters each. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field, at most 4,000 characters; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
## Validation
The JSON schema rejects unknown properties and defines the required fields, but
the schema body and validation behavior are documented in [Generated Text
internals](internal/generatedtext.md). All validated generated prose together
is limited to 20,000 characters, so template edits can rely on a bounded prose
surface.
After editing a template, run:
### Deterministic Module Values
```bash
Module values are deterministic outputs built from collected and derived facts.
Module pointers can be nil when their source or policy permits omission.
| Module field | Available in |
| --- | --- |
| `.Modules.CurrentConditions`, `.Modules.HourlyForecast`, `.Modules.PrecipTiming`, `.Modules.AlertDigest`, `.Modules.SPCConvectiveOutlooks`, `.Modules.AreaForecastDiscussion`, `.Modules.SPCConvectiveDiscussion`, `.Modules.WeatherStory` | All four contexts |
| `.Modules.DerivedDailySummary`, `.Modules.DerivedDaypartSummaries`, `.Modules.Dayparts` | Daily, Today, Tomorrow |
| `.Modules.HasDaypartDetails` | Today |
| `.Modules.OutdoorWindows`, `.Modules.DailyPlanning` | Daily |
| `.Modules.TodayPlanning` | Today |
| `.Modules.TomorrowPlanning` | Tomorrow |
The repository templates currently use the following nested display values.
They are the preferred surface for comparable edits:
| Area | Values |
| --- | --- |
| Current conditions | `.TemperatureF`, `.ConditionText`, `.ConditionTextLower`, `.ApparentTemperatureF`, `.RelativeHumidityPercent`, `.WindDirectionText`, `.WindSpeedMph` |
| Hourly periods | `.Periods`, `.HourLabel`, `.Name`, `.TemperatureF`, `.TextDescription`, `.TextDescriptionLower`, `.MentionPrecipitation`, `.ProbabilityOfPrecipitationPercent` |
| Dayparts | `.Dayparts[].Key` and `.Dayparts[].Summary` fields `DisplayName`, `DominantCondition`, `DominantConditionDisplay`, `TemperatureTrend`, `TemperatureStartPhraseF`, `TemperatureEndPhraseF`, `TemperaturePeakPhraseF`, `TemperatureSteadyPhraseF`, `TemperaturePhraseF`, `MentionPrecipitation`, and `MaxPopPercent` |
| Precipitation timing | `.PrecipitationWindows`, plus each window's `PeriodBegins`, `PeriodBeginsHourLabel`, `PeriodEnds`, `PeriodEndsHourLabel`, `ExpectationPhrase`, `MaxPopPercent`, `MaxPopTime`, and `MaxPopHourLabel` |
| Alert digest | `.AlertDigest.Relevant` entries' `Event`, `Headline`, `PeriodBegins`, and `PeriodEnds` |
| SPC risk digest | `.SPCConvectiveOutlooks.RiskDigest` entries' `LabelText`, `RiskLabel`, `PeriodBegins`, and `PeriodEnds` |
Other fields on these typed modules remain available when a template has a
well-defined display need. Their module contracts and weather derivation belong
to [Module contract internals](internal/module.md), [Module builder
internals](internal/briefing.md), and [Forecast derivation
internals](internal/forecast-derivation.md).
## Validate Changes
Run the focused checks after editing templates, partials, prompts, or schemas:
```sh
go test ./internal/reporttemplate ./internal/generatedtext ./internal/app
```
For a full check, run:
```bash
go test ./...
go run ./cmd/weatherreporter --help
git diff --check
```
Template render tests currently exercise the hourly template through
`internal/generatedtext/render_context_test.go` and
`internal/reporttemplate/reporttemplate_test.go`.
The render-context and template tests cover Daily, Today, Tomorrow, and Hourly
contexts. Run the repository-wide test suite before merging a broader change.

View File

@@ -1,386 +0,0 @@
# Weatherreporter Troubleshooting
This guide lists recurring failures with likely causes, diagnostics, and safe
fixes. See [CLI reference](cli.md), [Configuration reference](config.md), and
[Operations guide](operations.md) for normal usage.
## `weather_api.base_url is required`
Symptom: a generation command fails before fetching weather data.
Likely cause: no Weather API base URL is configured.
Diagnostic:
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: add `weather_api.base_url` to the config file, or pass the intended
config path with `--config`.
Relevant docs: [Configuration reference](config.md).
## `weather_api.base_url must be an absolute URL`
Symptom: config loading fails with a base URL validation error.
Likely cause: `weather_api.base_url` is missing a scheme or host.
Diagnostic: inspect the configured value in the file passed to `--config`.
Safe fix: use an absolute URL such as `https://weather.api.example.com/`.
Relevant docs: [Configuration reference](config.md).
## Invalid Timezone
Symptom: config loading fails with `weather_api.timezone` context, or a CLI
timezone override fails.
Likely cause: `weather_api.timezone` or `--tz` is not recognized.
Diagnostic:
```sh
weatherreporter generate daily --tz America/Chicago --date 2026-05-29
```
Safe fix: use an accepted timezone value, such as an IANA timezone name,
`Chicago`, `Stl`, a US timezone abbreviation, or a UTC offset.
Relevant docs: [Configuration reference](config.md).
## Storm Command Rejects Time Bounds
Symptom: `generate storm` fails with `requires --start`, `requires --end`, or
`requires --end after --start`.
Likely cause: the manual event window is missing or invalid.
Diagnostic:
```sh
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```
Safe fix: provide both bounds. Use `YYYY-MM-DDTHH:MM` in the configured
timezone, or RFC3339 timestamps with explicit offsets.
Relevant docs: [CLI reference](cli.md).
## Weather API Fetch Fails
Symptom: generation fails with `fetch /...`, an HTTP status, or request context.
Likely cause: the configured Weather API endpoint is unreachable, returned a
non-2xx response, or returned an invalid response envelope.
Diagnostic:
```sh
weatherreporter generate daily --config ./config.yml --date 2026-05-29
```
Safe fix: verify `weather_api.base_url`, network access, and the Weather API
service response. The adapter fetches `/observations`, `/conditions/current`,
`/forecast/hourly`, `/forecast/narrative`, `/alerts/active`, and `/discussion`.
Relevant docs: [Configuration reference](config.md).
## Hourly Forecast Is Missing
Symptom: generation fails with hourly forecast context, such as missing hourly
data or an hourly forecast containing no periods.
Likely cause: hourly forecast data is required for generated reports.
Diagnostic: check the Weather API response for `/forecast/hourly`.
Safe fix: restore hourly forecast data at the Weather API. Missing-source
policy cannot make hourly optional.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## Source Warnings Appear
Symptom: generation succeeds, but metadata or `inspect sources` shows source
warnings.
Likely cause: an optional source was missing or malformed under a warning
missing-source policy.
Diagnostic:
```sh
weatherreporter inspect sources RUN_ID
weatherreporter inspect metadata RUN_ID
```
Safe fix: inspect the warning `source`, `code`, `message`, and `endpoint`. Fix
the upstream optional source, or intentionally change the relevant
`missing_source` policy.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).
## `scriptorium` Is Not Found Or Cannot Start
Symptom: generation fails with `run scriptorium render` or `run scriptorium`
and an executable or OS error.
Likely cause: the configured Scriptorium binary is unavailable or not
executable.
Diagnostic: check `scriptorium.binary` in config and run the same binary outside
`weatherreporter`.
Safe fix: install Scriptorium, update `scriptorium.binary`, or fix executable
permissions.
Relevant docs: [Configuration reference](config.md),
[Scriptorium integration](integrations/scriptorium.md).
## Render Preflight Fails
Symptom: generation fails with `scriptorium render exited with code ...`.
Likely cause: Scriptorium rejected the prompt, config, profile, or
`data_package` input before report generation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
Then read the preflight path from metadata. It contains captured stdout, stderr,
exit code, and command.
Safe fix: fix the Scriptorium configuration, prompt ID, profile, or data package
input indicated by stderr.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Scriptorium Run Fails
Symptom: generation fails with `scriptorium run exited with code ...`.
Likely cause: Scriptorium failed during report generation or validation.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
weatherreporter inspect data-package RUN_ID
```
If metadata includes a rendered report path, inspect that report as well. A
nonzero run can still leave a managed report artifact.
Safe fix: use the captured stderr and data package to fix the Scriptorium
prompt, profile, model configuration, or validation issue.
Relevant docs: [Operations guide](operations.md),
[Scriptorium integration](integrations/scriptorium.md).
## Generated Text Validation Fails
Symptom: Tomorrow or Hourly generation fails with generated-text decode,
unknown-field, required-field, or multiple-JSON-values context.
Likely cause: Scriptorium wrote structured JSON that does not match the
GeneratedText contract for the selected report.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
Then inspect the generated-text raw path recorded in metadata, if present.
Safe fix: update the Scriptorium prompt or schema configuration so the prompt
writes the expected structured JSON for the report.
Relevant docs: [Operations guide](operations.md),
[Generated Text internals](internal/generatedtext.md),
[Scriptorium integration](integrations/scriptorium.md).
## Template Rendering Fails
Symptom: Tomorrow or Hourly generation fails with report template parsing or
execution context after generated text validation succeeds.
Likely cause: an embedded template references a missing context field or
receives a value shape that does not match its typed render context.
Diagnostic:
```sh
weatherreporter inspect metadata RUN_ID
```
If metadata records generated-text and render-context paths, inspect those
artifacts along with the template named by the report definition.
Safe fix: update the embedded template or render-context builder so the
template uses the implemented typed context.
Relevant docs: [Report Templates](templates.md),
[Report Template internals](internal/reporttemplate.md).
## Batch Command Returns Nonzero
Symptom: `run morning` or `run evening` returns nonzero.
Likely cause: at least one report in the batch failed.
Diagnostic: inspect stdout for the JSON summary and stderr for compact status
lines.
Safe fix: use the failed report's artifact paths from the summary, then inspect
metadata, sources, module snapshot, and data package for that RunID.
Relevant docs: [CLI reference](cli.md), [Operations guide](operations.md).
## Invalid Secrets Directory
Symptom: config loading fails with `read secrets directory`, `secret file`, or
environment variable name context.
Likely cause: `secrets.directory` points to a missing directory or contains an
invalid entry. Secret entries must be regular files directly under the
configured directory, and file basenames must match
`[A-Za-z_][A-Za-z0-9_]*`.
Diagnostic: list the configured directory and inspect entry names and file
types. Do not print secret file contents.
Safe fix: create the directory, remove subdirectories or symlinks, fix invalid
filenames, and ensure the weatherreporter process can read each secret file.
Relevant docs: [Configuration reference](config.md).
## Distributor Token Is Missing
Symptom: notification fails with a message that the distributor token
environment variable is not set.
Likely cause: `notify.distributor.enabled` is true, but the environment
variable named by `notify.distributor.token_env` was not populated directly or
through `secrets.directory`.
Diagnostic: check `notify.distributor.token_env`, then verify a matching secret
file exists under `secrets.directory` or that the process environment includes
the variable. Do not print the token value.
Safe fix: create a readable secret file whose basename matches `token_env`, or
set the environment variable through the service manager.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md).
## Distributor Upload Conflict
Symptom: notification fails with idempotency conflict context.
Likely cause: the same idempotency key was reused for different bundle content
within the same distributor token and pipeline. By default the bundle ID is a
stable report-stream identity and the idempotency key appends RunID.
Diagnostic: inspect the failed batch JSON or stderr line for pipeline, bundle,
and idempotency context. Compare the configured templates with the report RunID
and report path.
Also inspect the notification artifact linked from metadata. It records the
rendered pipeline ID, bundle ID, idempotency key, upload result, distributor run
status, status error, and raw run report JSON when available.
Safe fix: keep idempotency templates stable for retries of the same generated
report, but do not reuse the same rendered key for different generated report
content.
Relevant docs: [Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Upload Rejected
Symptom: notification fails with distributor upload rejection, HTTP status, or
bundle validation context.
Likely cause: the distributor endpoint rejected the token, pipeline ID, bundle
ID, idempotency key, source file, or one of the rendered bundle paths.
Diagnostic: inspect stdout JSON or stderr status lines for
`notificationError`. Confirm `notify.distributor.endpoint`,
`notify.distributor.pipeline_id_template`,
`notify.distributor.report_path_templates`, and token configuration. Token
values are redacted from weatherreporter errors.
If the upload was accepted but destination output did not change, inspect the
notification artifact's `runStatus.report`. Distributor actions such as
`replace_older`, `skip_same`, `skip_destination_newer`, or `failed` explain how
the destination handled the uploaded bundle.
Safe fix: fix the endpoint, token, templates, or distributor-side upload
configuration. The weatherreporter upload source is the managed Markdown report,
not `--out` or `--out-dir` copies.
Relevant docs: [Configuration reference](config.md),
[Operations guide](operations.md),
[Distributor adapter internals](internal/distributor-adapter.md).
## Distributor Unavailable
Symptom: notification fails with network, timeout, or service unavailable
context.
Likely cause: the configured distributor endpoint is unreachable, slow, or
temporarily unavailable.
Diagnostic: check network access from the weatherreporter host to
`notify.distributor.endpoint`. For batch runs, inspect which reports have
`notificationStatus: "failed"`.
Safe fix: restore distributor service availability and rerun the affected
report or batch. Stable idempotency keys make retrying the same generated report
safe unless the distributor reports a conflict.
Relevant docs: [Operations guide](operations.md).
## Unknown RunID
Symptom: an inspect command fails with `metadata for run id ... was not found`.
Likely cause: the RunID is mistyped or the command is reading a different
workspace.
Diagnostic:
```sh
weatherreporter inspect reports --config ./config.yml --limit 20
```
Safe fix: copy a RunID from `inspect reports`, or use the same `--config` and
workspace that generated the report.
Relevant docs: [Operations guide](operations.md).
## Workspace Path Error
Symptom: startup or inspection fails with workspace path validation or
filesystem read/write context.
Likely cause: a workspace subdirectory is absolute, escapes `workspace.root`, or
the process cannot read or write the configured path.
Diagnostic: review `workspace.root`, `workspace.snapshots_dir`,
`workspace.reports_dir`, `workspace.data_packages_dir`, and
`workspace.preflight_dir`.
Safe fix: keep workspace subdirectories relative to `workspace.root`, and grant
the process appropriate filesystem permissions.
Relevant docs: [Configuration reference](config.md), [Operations guide](operations.md).

View File

@@ -1,7 +1,7 @@
weather_api:
base_url: https://weather.api.rakestrawhome.com/
base_url: https://weather.api.example.com/
timeout: 15s
precision: 1
precision: 0
units: us
timezone: "America/Chicago"
format: json
@@ -14,6 +14,9 @@ location:
secrets:
directory: ""
output:
directory: /var/lib/weatherreporter/reports
notify:
distributor:
enabled: false
@@ -24,25 +27,21 @@ notify:
pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}"
report_path_templates:
- "{valid_start_date}/{artifact_group}/{valid_start_date}-{artifact_group}-{run_id}.md"
batch:
enabled: true
pipeline_id_template: "weatherreporter"
bundle_id_template: "weatherreporter.{location_id}.{batch}"
idempotency_key_template: "{bundle_id}.{batch_run_id}"
missing_source:
default: warn
sources:
alerts: none
scriptorium:
binary: scriptorium
promptkit:
timeout: 2m
workspace:
root: workspace
snapshots_dir: snapshots
reports_dir: reports
data_packages_dir: data-packages
preflight_dir: preflight
notifications_dir: notifications
local:
concurrency_limit: 1
dayparts:
- name: overnight
@@ -61,14 +60,31 @@ dayparts:
start: "17:00"
end: "24:00"
recent_change:
temperature_degrees: 5
precip_probability_points: 20
wind_gust_miles_per_hour: 10
precip_timing_shift_minutes: 120
reports:
daily:
distributor:
path_templates:
- "daily/{valid_start_date}/{run_id}.md"
- "daily/{valid_start_date}/index.md"
deterministic_modules:
- metadata
- current_conditions
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- long_term
- spc_convective_discussion
- weather_story
- outdoor_windows
- daily_planning
- hourly_forecast
today:
deterministic_modules:
- metadata
- current_conditions
@@ -89,6 +105,7 @@ reports:
- weather_story
- outdoor_windows
- hourly_forecast
- today_planning
hourly:
deterministic_modules:
- metadata

View File

@@ -0,0 +1,4 @@
id: weather-light
endpoint: http://127.0.0.1:11434/v1
model: weather-local
timeout_seconds: 180

9
go.mod
View File

@@ -4,4 +4,11 @@ go 1.26
require gopkg.in/yaml.v3 v3.0.1
require gitea.maximumdirect.net/eric/distributor v0.5.0
require (
gitea.maximumdirect.net/eric/distributor v0.5.0
gitea.maximumdirect.net/eric/promptkit v0.8.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2
golang.org/x/sys v0.45.0
)
require golang.org/x/text v0.14.0 // indirect

8
go.sum
View File

@@ -1,5 +1,7 @@
gitea.maximumdirect.net/eric/distributor v0.5.0 h1:+al7Bw+kMv6V35a3Sm5rUtCTQhwOn5b9x3RsclPMKJk=
gitea.maximumdirect.net/eric/distributor v0.5.0/go.mod h1:G03FCFZPHpsUKC6SeMgTdbfNRpPQBdyTtDUj04e1Tu8=
gitea.maximumdirect.net/eric/promptkit v0.8.0 h1:NGd9hDLu0UMxKbvittMrqM5Ua94eFb+kOE7UIir8l08=
gitea.maximumdirect.net/eric/promptkit v0.8.0/go.mod h1:R95NM6fbMDGDC0/UomgnSBP6ui2ns+8SZb8bESNvrDQ=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
@@ -36,16 +38,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3/go.mod h1:ULe4HCzfKPiR6R3HEurE3b1upEkuk8AkMrOKtaOxKO8=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -2,10 +2,12 @@
package distributor
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
@@ -22,6 +24,7 @@ type Client struct {
TokenEnv string
Timeout time.Duration
newUploadClient uploadClientFactory
pollWait func(context.Context, time.Duration) error
}
type UploadRequest struct {
@@ -107,6 +110,10 @@ type runStatus struct {
const statusPollInterval = 250 * time.Millisecond
const maxDistributorResponseBytes int64 = 1 << 20
var errDistributorResponseTooLarge = fmt.Errorf("distributor response exceeds the %d-byte limit", maxDistributorResponseBytes)
func New(cfg config.DistributorNotifyConfig) *Client {
return newClient(cfg, newDistributorUploadClient)
}
@@ -120,6 +127,7 @@ func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory)
TokenEnv: cfg.TokenEnv,
Timeout: cfg.Timeout,
newUploadClient: factory,
pollWait: waitForPoll,
}
}
@@ -201,7 +209,12 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
Status: result.Status,
UploadStatus: result.Status,
}
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
pollWait := c.pollWait
if pollWait == nil {
pollWait = waitForPoll
}
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0, pollWait)
status = sanitizeRunStatus(status)
if status.RunID != "" || status.Status != "" {
uploadResult.RunStatus = &RunStatus{
RunID: status.RunID,
@@ -218,7 +231,7 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
}
}
if statusErr != nil {
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
uploadResult.StatusError = safeDistributorDiagnostic(statusErr, token).Error()
return uploadResult, nil
}
if status.Status == "failed" {
@@ -227,19 +240,15 @@ func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, e
return uploadResult, nil
}
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (runStatus, error) {
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool, wait func(context.Context, time.Duration) error) (runStatus, error) {
status, err := client.Status(ctx, runID)
if err != nil || terminalRunStatus(status.Status) || !poll {
return status, err
}
for {
timer := time.NewTimer(statusPollInterval)
select {
case <-ctx.Done():
timer.Stop()
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, ctx.Err())
case <-timer.C:
if err := wait(ctx, statusPollInterval); err != nil {
return status, fmt.Errorf("distributor run %q did not reach terminal status before timeout: %w", runID, err)
}
next, err := client.Status(ctx, runID)
@@ -253,6 +262,17 @@ func waitForRunStatus(ctx context.Context, client uploadClient, runID string, po
}
}
func waitForPoll(ctx context.Context, interval time.Duration) error {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func terminalRunStatus(status string) bool {
return status == "succeeded" || status == "failed"
}
@@ -261,10 +281,52 @@ type distributorUploadClient struct {
client *distributorupload.Client
}
type boundedResponseTransport struct {
base http.RoundTripper
limit int64
}
func (t boundedResponseTransport) RoundTrip(req *http.Request) (*http.Response, error) {
base := t.base
if base == nil {
base = http.DefaultTransport
}
response, err := base.RoundTrip(req)
if err != nil {
return nil, err
}
defer response.Body.Close()
data, err := io.ReadAll(io.LimitReader(response.Body, t.limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > t.limit {
return nil, errDistributorResponseTooLarge
}
response.Body = io.NopCloser(bytes.NewReader(data))
response.ContentLength = int64(len(data))
return response, nil
}
type RemoteResponseError struct {
StatusCode int
Retryable bool
}
func (e *RemoteResponseError) Error() string {
if e == nil || e.StatusCode == 0 {
return "distributor request failed"
}
return fmt.Sprintf("distributor request failed with HTTP status %d", e.StatusCode)
}
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
httpClient := (*http.Client)(nil)
httpClient := &http.Client{
Transport: boundedResponseTransport{base: http.DefaultTransport, limit: maxDistributorResponseBytes},
}
if timeout > 0 {
httpClient = &http.Client{Timeout: timeout}
httpClient.Timeout = timeout
}
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
Endpoint: endpoint,
@@ -306,7 +368,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
if err != nil {
return runStatus{}, err
}
return runStatus{
return sanitizeRunStatus(runStatus{
RunID: status.RunID,
PipelineID: status.PipelineID,
Status: status.Status,
@@ -315,7 +377,7 @@ func (c distributorUploadClient) Status(ctx context.Context, runID string) (runS
FinishedAt: status.FinishedAt,
Report: append(json.RawMessage(nil), status.Report...),
Error: status.Error,
}, nil
}), nil
}
type uploadErrorContext struct {
@@ -331,7 +393,7 @@ type uploadErrorContext struct {
func wrapUploadError(err error, ctx uploadErrorContext) error {
var conflict *distributorupload.IdempotencyConflictError
isConflict := errors.As(err, &conflict)
err = redactToken(err, ctx.Token)
err = safeDistributorDiagnostic(err, ctx.Token)
if isConflict {
return &IdempotencyConflictError{
Err: fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: idempotency conflict: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err),
@@ -340,6 +402,31 @@ func wrapUploadError(err error, ctx uploadErrorContext) error {
return fmt.Errorf("upload distributor bundle %q to pipeline %q at endpoint %q with idempotency key %q from sources %q as bundle paths %q: %w", ctx.BundleID, ctx.PipelineID, ctx.Endpoint, ctx.IdempotencyKey, ctx.SourcePaths, ctx.BundlePaths, err)
}
func safeDistributorDiagnostic(err error, token string) error {
if err == nil {
return nil
}
if errors.Is(err, errDistributorResponseTooLarge) {
return errDistributorResponseTooLarge
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return redactToken(err, token)
}
var httpErr *distributorupload.HTTPError
if errors.As(err, &httpErr) {
return &RemoteResponseError{StatusCode: httpErr.StatusCode, Retryable: httpErr.Retryable}
}
return errors.New("distributor request failed")
}
func sanitizeRunStatus(status runStatus) runStatus {
status.Report = nil
if status.Error != "" {
status.Error = "distributor reported a failed run"
}
return status
}
func uploadSourcePaths(files []UploadFile) []string {
paths := make([]string, 0, len(files))
for _, file := range files {

View File

@@ -0,0 +1,310 @@
package distributor
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
const oversizedRemoteDiagnostic = "REMOTE-DIAGNOSTIC"
func TestUploadUsesProductionHTTPBoundary(t *testing.T) {
const token = "test-upload-token"
var uploadCalls, statusCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/prefix/v1/pipelines/weather/upload":
uploadCalls++
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
t.Fatalf("authorization = %q", got)
}
if got := r.Header.Get("Idempotency-Key"); got != "bundle-key" {
t.Fatalf("idempotency key = %q", got)
}
if got := r.Header.Get("Content-Type"); got != "application/gzip" {
t.Fatalf("content type = %q", got)
}
verifyUploadedArchive(t, r.Body, "daily/report.md", "report body")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
case r.Method == http.MethodGet && r.URL.Path == "/prefix/runs/run-123":
statusCalls++
if got := r.Header.Get("Authorization"); got != "Bearer "+token {
t.Fatalf("authorization = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":{"detail":"REMOTE-DETAIL"}}`)
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
client := productionClient(t, server.URL+"/prefix", token)
result, err := client.Upload(context.Background(), productionUploadRequest(t))
if err != nil || uploadCalls != 1 || statusCalls != 1 || result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" || result.RunStatus == nil || result.RunStatus.PipelineID != "weather" || len(result.RunStatus.Report) != 0 {
t.Fatalf("result/error/calls = %#v/%v/%d/%d", result, err, uploadCalls, statusCalls)
}
}
func TestUploadClassifiesRemoteHTTPDiagnostics(t *testing.T) {
const token = "test-upload-token"
const remote = oversizedRemoteDiagnostic
for _, tt := range []struct {
name string
handle func(http.ResponseWriter, *http.Request)
check func(t *testing.T, result UploadResult, err error)
}{
{
name: "upload failure",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("method = %s", r.Method)
}
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"REMOTE-DIAGNOSTIC","retryable":true}`)
},
check: func(t *testing.T, _ UploadResult, err error) {
t.Helper()
var remoteErr *RemoteResponseError
if err == nil || !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest || !remoteErr.Retryable {
t.Fatalf("error = %T %v", err, err)
}
},
},
{
name: "status failure",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
return
}
w.WriteHeader(http.StatusInternalServerError)
_, _ = io.WriteString(w, remote)
},
check: func(t *testing.T, result UploadResult, err error) {
t.Helper()
if err != nil || result.Status != "accepted" || result.StatusError != "distributor request failed with HTTP status 500" {
t.Fatalf("result/error = %#v/%v", result, err)
}
},
},
{
name: "failed run",
handle: func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
w.WriteHeader(http.StatusAccepted)
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"accepted"}`)
return
}
_, _ = io.WriteString(w, `{"run_id":"run-123","status":"failed","error":"REMOTE-DIAGNOSTIC","report":{"detail":"REMOTE-DIAGNOSTIC"}}`)
},
check: func(t *testing.T, result UploadResult, err error) {
t.Helper()
if err == nil || result.Status != "failed" || result.RunStatus == nil || result.RunStatus.Error != "distributor reported a failed run" || len(result.RunStatus.Report) != 0 {
t.Fatalf("result/error = %#v/%v", result, err)
}
},
},
} {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(tt.handle))
defer server.Close()
result, err := productionClient(t, server.URL, token).Upload(context.Background(), productionUploadRequest(t))
tt.check(t, result, err)
for _, value := range []string{fmt.Sprint(result), fmt.Sprint(err)} {
if strings.Contains(value, remote) || strings.Contains(value, token) {
t.Fatalf("normal diagnostic leaked remote value: %q", value)
}
}
})
}
}
func TestUploadBoundsHTTPResponses(t *testing.T) {
for _, tt := range []struct {
name string
response func(size int) string
statusCode int
statusBody func(size int) string
check func(t *testing.T, result UploadResult, err error, overflow bool)
}{
{
name: "accepted response",
response: func(size int) string {
return paddedJSON(t, `{"run_id":"run-123","status":"accepted","detail":"REMOTE-DIAGNOSTIC"}`, size)
},
statusBody: func(_ int) string {
return `{"run_id":"run-123","status":"succeeded"}`
},
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
if err != nil || result.Status != "succeeded" {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
{
name: "status report",
response: func(_ int) string {
return `{"run_id":"run-123","status":"accepted"}`
},
statusBody: func(size int) string { return statusReportBody(t, size) },
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if err != nil || result.Status != "accepted" || result.StatusError != errDistributorResponseTooLarge.Error() {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
if err != nil || result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
{
name: "error response",
response: func(size int) string { return repeatedToLength(oversizedRemoteDiagnostic, size) },
statusCode: http.StatusBadRequest,
check: func(t *testing.T, result UploadResult, err error, overflow bool) {
t.Helper()
if overflow {
if !errors.Is(err, errDistributorResponseTooLarge) || result.RunID != "" {
t.Fatalf("overflow result/error = %#v/%v", result, err)
}
return
}
var remoteErr *RemoteResponseError
if !errors.As(err, &remoteErr) || remoteErr.StatusCode != http.StatusBadRequest {
t.Fatalf("bounded result/error = %#v/%v", result, err)
}
},
},
} {
for _, overflow := range []bool{false, true} {
t.Run(tt.name+"/"+map[bool]string{false: "limit", true: "over-limit"}[overflow], func(t *testing.T) {
size := int(maxDistributorResponseBytes)
if overflow {
size++
}
var uploadCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
uploadCalls++
statusCode := tt.statusCode
if statusCode == 0 {
statusCode = http.StatusAccepted
}
w.WriteHeader(statusCode)
_, _ = io.WriteString(w, tt.response(size))
return
}
_, _ = io.WriteString(w, tt.statusBody(size))
}))
defer server.Close()
result, err := productionClient(t, server.URL, "test-upload-token").Upload(context.Background(), productionUploadRequest(t))
tt.check(t, result, err, overflow)
if strings.Contains(fmt.Sprint(result), oversizedRemoteDiagnostic) || strings.Contains(fmt.Sprint(err), oversizedRemoteDiagnostic) {
t.Fatalf("result/error leaked oversized response detail: %#v/%v", result, err)
}
if uploadCalls != 1 {
t.Fatalf("upload calls = %d, want one", uploadCalls)
}
})
}
}
}
func productionClient(t *testing.T, endpoint, token string) *Client {
t.Helper()
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = endpoint
cfg.Timeout = 0
t.Setenv(cfg.TokenEnv, token)
return New(cfg)
}
func productionUploadRequest(t *testing.T) UploadRequest {
t.Helper()
path := filepath.Join(t.TempDir(), "report.md")
if err := os.WriteFile(path, []byte("report body"), 0o600); err != nil {
t.Fatal(err)
}
return UploadRequest{
PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "bundle-key",
Files: []UploadFile{{SourcePath: path, BundlePath: "daily/report.md"}},
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC),
}
}
func verifyUploadedArchive(t *testing.T, body io.Reader, wantPath, wantContents string) {
t.Helper()
reader, err := gzip.NewReader(body)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
archive := tar.NewReader(reader)
for {
header, err := archive.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatal(err)
}
if header.Name != wantPath {
continue
}
contents, err := io.ReadAll(archive)
if err != nil || string(contents) != wantContents {
t.Fatalf("archive file contents/error = %q/%v", contents, err)
}
return
}
t.Fatalf("archive did not contain %q", wantPath)
}
func paddedJSON(t *testing.T, value string, size int) string {
t.Helper()
if len(value) > size {
t.Fatalf("JSON length = %d, exceeds requested size %d", len(value), size)
}
return value + strings.Repeat(" ", size-len(value))
}
func statusReportBody(t *testing.T, size int) string {
t.Helper()
const prefix = `{"run_id":"run-123","pipeline_id":"weather","status":"succeeded","report":"`
const suffix = `"}`
if len(prefix)+len(suffix) > size {
t.Fatalf("status response exceeds requested size %d", size)
}
return prefix + repeatedToLength(oversizedRemoteDiagnostic, size-len(prefix)-len(suffix)) + suffix
}
func repeatedToLength(value string, size int) string {
return strings.Repeat(value, size/len(value)+1)[:size]
}

View File

@@ -45,8 +45,8 @@ func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
if result.RunID != "run-123" || result.Status != "succeeded" || result.UploadStatus != "accepted" {
t.Fatalf("result = %#v, want accepted run", result)
}
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
t.Fatalf("RunStatus = %#v, want parsed run report", result.RunStatus)
if result.RunStatus == nil || result.RunStatus.PipelineID != "reports" || len(result.RunStatus.Report) != 0 {
t.Fatalf("RunStatus = %#v, want safe status details", result.RunStatus)
}
if factory.endpoint != cfg.Endpoint {
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
@@ -242,13 +242,14 @@ func TestUploadPollsUntilTerminalStatus(t *testing.T) {
},
}
client := newClient(cfg, factory.newClient)
client.pollWait = func(context.Context, time.Duration) error { return nil }
result, err := client.Upload(context.Background(), validUploadRequest())
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
if result.Status != "succeeded" || result.RunStatus == nil || !strings.Contains(string(result.RunStatus.Report), "replace_older") {
t.Fatalf("result = %#v, want terminal succeeded status with run report", result)
if result.Status != "succeeded" || result.RunStatus == nil || len(result.RunStatus.Report) != 0 {
t.Fatalf("result = %#v, want terminal succeeded status without remote report", result)
}
if factory.client.statusCalls != 2 {
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
@@ -298,8 +299,8 @@ func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
if err == nil {
t.Fatal("Upload() error = nil, want failed distributor run error")
}
if result.RunStatus == nil || result.RunStatus.Status != "failed" || !strings.Contains(string(result.RunStatus.Report), "failed") {
t.Fatalf("result = %#v, want failed run status report", result)
if result.RunStatus == nil || result.RunStatus.Status != "failed" || len(result.RunStatus.Report) != 0 || result.RunStatus.Error != "distributor reported a failed run" {
t.Fatalf("result = %#v, want safe failed run status", result)
}
if strings.Contains(err.Error(), "secret-token") || strings.Contains(result.RunStatus.Error, "secret-token") {
t.Fatalf("error/result leaked token: err=%q result=%#v", err.Error(), result)

View File

@@ -0,0 +1,343 @@
// Package promptkitadapter implements promptexec with Promptkit.
package promptkitadapter
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptassets"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
// Config selects the Promptkit sources and optional local backend for one engine.
type Config struct {
ProfileDirectory string
ProfileFile string
LocalEndpoint string
LocalConcurrencyLimit int
Timeout time.Duration
}
// Adapter owns one Promptkit engine and its opaque prepared execution handles.
// It supports concurrent Execute calls on the shared executor.
type Adapter struct {
engine *promptkit.Engine
}
var _ promptexec.Executor = (*Adapter)(nil)
// New constructs a Promptkit-backed executor from Weatherreporter-owned settings.
func New(config Config) (*Adapter, error) {
return newAdapter(config)
}
func newAdapter(config Config, additionalOptions ...promptkit.Option) (*Adapter, error) {
if config.ProfileDirectory != "" && config.ProfileFile != "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "profile directory and profile file cannot both be configured", nil)
}
if config.LocalEndpoint == "" && config.LocalConcurrencyLimit != 0 {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "local concurrency requires a local endpoint", nil)
}
options := []promptkit.Option{
promptkit.WithPromptFS(promptassets.PromptFS(), "."),
promptkit.WithSchemaFS(promptassets.SchemaFS(), "."),
promptkit.WithFallbackProfileFS(promptassets.ProfileFS(), "."),
}
if config.ProfileFile != "" {
options = append(options, promptkit.WithProfileFile(config.ProfileFile))
}
if config.LocalEndpoint != "" {
options = append(options, promptkit.WithBackend(promptkit.LocalBackend(config.LocalEndpoint, config.LocalConcurrencyLimit)))
}
options = append(options, additionalOptions...)
engine, err := promptkit.NewEngine(promptkit.Config{
ProfileDir: config.ProfileDirectory,
Timeout: config.Timeout,
}, options...)
if err != nil {
return nil, classifyConfigurationError(err)
}
return &Adapter{engine: engine}, nil
}
func newAdapterForTest(config Config, client promptkit.LLMClient) (*Adapter, error) {
return newAdapter(config, promptkit.WithLLMClient(client))
}
// InspectPrompt maps an exact Promptkit prompt inspection into project-owned values.
func (adapter *Adapter) InspectPrompt(ctx context.Context, promptID string, promptVersion string) (promptexec.PromptInspection, error) {
if adapter == nil || adapter.engine == nil {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
inspection, err := adapter.engine.InspectPrompt(ctx, promptID, promptVersion)
if err != nil {
return promptexec.PromptInspection{}, classifyError(err)
}
inputs := make([]promptexec.InputDefinition, len(inspection.Inputs))
for index, input := range inspection.Inputs {
inputs[index] = promptexec.InputDefinition{
Name: input.Name,
Required: input.Required,
ContentType: input.ContentType,
Description: input.Description,
}
}
return promptexec.PromptInspection{
PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash,
DefaultProfileID: inspection.DefaultProfileID,
Inputs: inputs,
Output: outputContract(inspection.OutputContract),
}, nil
}
// InspectProfile maps one explicit Promptkit profile inspection into safe values.
func (adapter *Adapter) InspectProfile(ctx context.Context, profileID string) (promptexec.ProfileInspection, error) {
if adapter == nil || adapter.engine == nil {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
inspection, err := adapter.engine.InspectProfile(ctx, profileID)
if err != nil {
return promptexec.ProfileInspection{}, classifyError(err)
}
return promptexec.ProfileInspection{
ProfileID: inspection.ProfileID,
BackendID: inspection.EffectiveModelParams.BackendID,
ModelName: inspection.EffectiveModelParams.Model,
CredentialRequired: inspection.APIKeyRequired,
APIKeyEnv: inspection.EffectiveModelParams.APIKeyEnv,
}, nil
}
// Execute prepares one exact inline data package, invokes prepared after a
// successful preparation, and then runs the same opaque prepared handle.
func (adapter *Adapter) Execute(ctx context.Context, request promptexec.ExecuteRequest, preparedCallback promptexec.PreparationCallback) (*promptexec.Execution, error) {
if adapter == nil || adapter.engine == nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is not configured", nil)
}
prepared, err := adapter.engine.PrepareExecution(ctx, promptkit.RunRequest{
PromptID: request.PromptID,
PromptVersion: request.PromptVersion,
ProfileID: request.ProfileID,
Inputs: map[string]promptkit.ArtifactRef{"data_package": promptkit.Inline(string(append([]byte(nil), request.DataPackage...)))},
})
if err != nil {
return nil, classifyError(err)
}
defer prepared.Discard()
details := prepared.Details()
preparation, debug := preparationValues(details, request.CaptureDebug)
if preparedCallback != nil {
if err := preparedCallback(preparation, debug); err != nil {
return nil, err
}
}
result, err := adapter.engine.RunPrepared(ctx, prepared)
if err != nil {
return nil, classifyError(err)
}
return executionValue(result, request.CaptureDebug), nil
}
func outputContract(value promptkit.OutputContract) promptexec.OutputContract {
return promptexec.OutputContract{
Format: string(value.Format),
ValidationMode: string(value.ValidationMode),
SchemaPath: value.SchemaPath,
RepairAttempts: value.RepairAttempts,
}
}
func preparationValues(value promptkit.PreparedRun, captureDebug bool) (promptexec.Preparation, *promptexec.PreparationDebug) {
preparation := promptexec.Preparation{
PromptID: value.PromptID,
PromptVersion: value.PromptVersion,
PromptHash: value.PromptHash,
RenderedPromptHash: value.RenderedPromptHash,
InputHashes: copyInputHashes(value.InputHashes),
ProfileID: value.SelectedProfileID,
BackendID: value.SelectedBackendID,
ModelName: value.EffectiveModelParams.Model,
Output: outputContract(value.OutputContract),
StartedAt: value.StartTime,
EndedAt: value.EndTime,
Duration: time.Duration(value.DurationMS) * time.Millisecond,
}
if !captureDebug {
return preparation, nil
}
debug := &promptexec.PreparationDebug{
RenderedMessages: renderedMessages(value.Messages),
Endpoint: value.EffectiveModelParams.Endpoint,
ParametersJSON: marshalDebugParameters(value.EffectiveModelParams),
}
if value.StructuredOutput != nil && value.StructuredOutput.JSONSchema != nil {
debug.StructuredSchema, _ = json.Marshal(value.StructuredOutput.JSONSchema.Schema)
}
return preparation, debug
}
func executionValue(value *promptkit.RunResult, captureDebug bool) *promptexec.Execution {
if value == nil {
return nil
}
validation := promptexec.NewValidation(
promptexec.ValidationStatus(value.Validation.Status),
string(value.Validation.Mode),
value.Validation.SchemaPath,
value.Validation.RepairAttempts,
value.Validation.Errors,
)
rawOutput := []byte(nil)
if len(value.RawOutput) <= generatedtext.MaxGeneratedTextBytes {
rawOutput = []byte(value.RawOutput)
} else {
validation = promptexec.NewValidation(
promptexec.ValidationFailed,
string(value.Validation.Mode),
value.Validation.SchemaPath,
value.Validation.RepairAttempts,
[]string{"generated output exceeds the configured size limit"},
)
}
execution := &promptexec.Execution{
RunID: value.RunID,
PromptID: value.PromptID,
PromptVersion: value.PromptVersion,
PromptHash: value.PromptHash,
RenderedPromptHash: value.RenderedPromptHash,
InputHashes: copyInputHashes(value.InputHashes),
ProfileID: value.SelectedProfileID,
BackendID: value.SelectedBackendID,
ModelName: value.ModelName,
GeneratedHash: value.Artifact.Hash,
Usage: promptexec.TokenUsage{
PromptTokens: value.Usage.PromptTokens,
CompletionTokens: value.Usage.CompletionTokens,
TotalTokens: value.Usage.TotalTokens,
CachedTokens: value.Usage.CachedTokens,
CacheWriteTokens: value.Usage.CacheWriteTokens,
},
StartedAt: value.StartTime,
EndedAt: value.EndTime,
Duration: value.Duration,
Validation: validation,
RawOutput: rawOutput,
}
if captureDebug {
execution.Debug = &promptexec.ExecutionDebug{
RawOutput: append([]byte(nil), rawOutput...),
ValidationDiagnostics: append([]string(nil), validation.Diagnostics...),
}
}
return execution
}
func renderedMessages(values []promptkit.RenderedMessage) []promptexec.RenderedMessage {
messages := make([]promptexec.RenderedMessage, len(values))
for index, value := range values {
messages[index] = promptexec.RenderedMessage{Role: value.Role, Content: value.Content}
}
return messages
}
func copyInputHashes(values map[string]string) map[string]string {
if values == nil {
return nil
}
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
func marshalDebugParameters(value promptkit.ExecutionTarget) []byte {
parameters := struct {
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens"`
TopP float64 `json:"top_p"`
TimeoutSeconds int `json:"timeout_seconds"`
ServiceTier string `json:"service_tier"`
ReasoningEffort string `json:"reasoning_effort"`
}{
Temperature: value.Temperature,
MaxTokens: value.MaxTokens,
TopP: value.TopP,
TimeoutSeconds: value.TimeoutSeconds,
ServiceTier: value.ServiceTier,
ReasoningEffort: value.ReasoningEffort,
}
data, _ := json.Marshal(parameters)
return data
}
func classifyConfigurationError(err error) error {
if err == nil {
return nil
}
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
}
func classifyError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.Canceled) {
return promptexec.NewError(promptexec.Canceled, "prompt operation was canceled", err)
}
if errors.Is(err, context.DeadlineExceeded) {
return promptexec.NewError(promptexec.DeadlineExceeded, "prompt operation exceeded its deadline", err)
}
var capacityError *promptkit.CapacityError
if errors.As(err, &capacityError) {
return promptexec.NewCapacityError(capacityError.BackendID, "prompt backend capacity is unavailable", err)
}
var generationError *promptkit.GenerationError
if errors.As(err, &generationError) {
return promptexec.NewGenerationError(
generationError.StatusCode(),
generationError.ProviderCode(),
generationError.ProviderType(),
generationError.ProviderMessage(),
err,
)
}
switch {
case errors.Is(err, promptkit.ErrInvalidConfig):
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor configuration is invalid", err)
case errors.Is(err, promptkit.ErrPromptNotFound):
return promptexec.NewError(promptexec.PromptNotFound, "prompt definition was not found", err)
case errors.Is(err, promptkit.ErrPromptLoad):
return promptexec.NewError(promptexec.PromptLoad, "prompt definition could not be loaded", err)
case errors.Is(err, promptkit.ErrProfileNotFound):
return promptexec.NewError(promptexec.ProfileNotFound, "execution profile was not found", err)
case errors.Is(err, promptkit.ErrProfileLoad):
return promptexec.NewError(promptexec.ProfileLoad, "execution profile could not be loaded", err)
case errors.Is(err, promptkit.ErrAPIKeyEnvMissing):
return promptexec.NewError(promptexec.MissingCredential, "execution credential is unavailable", err)
case errors.Is(err, promptkit.ErrArtifactLoad):
return promptexec.NewError(promptexec.ArtifactLoad, "prompt input could not be loaded", err)
case errors.Is(err, promptkit.ErrPromptRender):
return promptexec.NewError(promptexec.PromptRender, "prompt could not be rendered", err)
case errors.Is(err, promptkit.ErrCapacityExceeded):
return promptexec.NewCapacityError("", "prompt backend capacity is unavailable", err)
case errors.Is(err, promptkit.ErrLLMGenerate):
return promptexec.NewError(promptexec.Generation, "prompt generation failed", err)
case errors.Is(err, promptkit.ErrValidation):
return promptexec.NewError(promptexec.OperationalValidation, "prompt output validation could not be completed", err)
case errors.Is(err, promptkit.ErrInvalidRequest), errors.Is(err, promptkit.ErrProfileRequired):
return promptexec.NewError(promptexec.InvalidRequest, "prompt execution request is invalid", err)
default:
return promptexec.NewError(promptexec.Generation, "prompt operation failed", fmt.Errorf("%w", err))
}
}

View File

@@ -0,0 +1,958 @@
package promptkitadapter
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type fakeClient struct {
mu sync.Mutex
response *promptkit.GenerateResponse
err error
outcomes []generationOutcome
next int
calls int
requests []promptkit.GenerateRequest
block bool
started chan struct{}
}
type generationOutcome struct {
response *promptkit.GenerateResponse
err error
}
type recordingReader struct {
ref promptkit.ArtifactRef
}
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
reader.ref = ref
return &promptkit.Artifact{
Name: "data_package",
ContentType: "application/yaml",
Body: []byte(ref.Body),
URI: ref.URI,
Hash: "input-hash",
}, nil
}
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
client.mu.Lock()
client.calls++
client.requests = append(client.requests, request)
block := client.block
started := client.started
response := client.response
err := client.err
if client.next < len(client.outcomes) {
outcome := client.outcomes[client.next]
client.next++
response, err = outcome.response, outcome.err
}
client.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if block {
<-ctx.Done()
return nil, ctx.Err()
}
return response, err
}
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
adapter := newTestAdapter(t, client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
executionErrors := make(chan error, 2)
for range 2 {
go func() {
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
executionErrors <- err
}()
}
for range 2 {
select {
case <-client.started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent Promptkit calls")
}
}
cancel()
for range 2 {
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
}
}
if client.callCount() != 2 {
t.Fatalf("provider calls = %d, want 2", client.callCount())
}
}
func (client *fakeClient) callCount() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.calls
}
func (client *fakeClient) request() promptkit.GenerateRequest {
client.mu.Lock()
defer client.mu.Unlock()
return client.requests[0]
}
func (client *fakeClient) allRequests() []promptkit.GenerateRequest {
client.mu.Lock()
defer client.mu.Unlock()
return append([]promptkit.GenerateRequest(nil), client.requests...)
}
func TestInspectPromptAndProfile(t *testing.T) {
adapter := newTestAdapter(t, &fakeClient{})
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.1.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.1.0" || inspection.DefaultProfileID != "weather-balanced" || inspection.Output.RepairAttempts != 1 {
t.Fatalf("inspection = %#v", inspection)
}
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
t.Fatalf("inputs = %#v", inspection.Inputs)
}
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
t.Fatalf("output = %#v", inspection.Output)
}
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
if err != nil {
t.Fatalf("InspectProfile() error = %v", err)
}
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
t.Fatalf("profile = %#v", profile)
}
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
t.Fatalf("profile leaks endpoint: %#v", profile)
}
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
if err != nil {
t.Fatalf("InspectProfile(builtin) error = %v", err)
}
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
}
func TestEmbeddedProfilesAreAvailableToProductionAndTestAdapters(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
for _, want := range []struct {
id string
backend string
model string
}{
{"weather-light", "openrouter", "deepseek/deepseek-v4-flash"},
{"weather-balanced", "openrouter", "~google/gemini-flash-latest"},
{"weather-deep", "openrouter", "~anthropic/claude-sonnet-latest"},
} {
t.Run(want.id, func(t *testing.T) {
assertProfile(t, adapter, want.id, want.backend, want.model)
})
}
testAdapter, err := newAdapterForTest(Config{}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest() error = %v", err)
}
assertProfile(t, testAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
}
func TestConfiguredProfilesOverrideEmbeddedFallbacks(t *testing.T) {
file := writeProfileFile(t, `id: weather-light
endpoint: https://local-file.example/v1
model: file-light
`)
fileAdapter, err := New(Config{ProfileFile: file})
if err != nil {
t.Fatalf("New(profile file) error = %v", err)
}
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
directory := testProfileDirectory(t, map[string]string{"profile.yml": `id: weather-light
backend: local
model: directory-light
`})
directoryAdapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
if err != nil {
t.Fatalf("New(profile directory) error = %v", err)
}
assertProfile(t, directoryAdapter, "weather-light", promptkit.BackendLocal, "directory-light")
derived := writeProfileFile(t, `id: weather-light
base_profile: gemini-flash-latest
`)
derivedAdapter, err := New(Config{ProfileFile: derived})
if err != nil {
t.Fatalf("New(derived profile) error = %v", err)
}
assertProfile(t, derivedAdapter, "weather-light", "openrouter", "~google/gemini-flash-latest")
}
func TestConfiguredBaseProfileOverridesEmbeddedProfileTarget(t *testing.T) {
directory := testProfileDirectory(t, map[string]string{
"deepseek.yml": `id: deepseek-4-flash
backend: local
model: shadowed-deepseek
`,
})
adapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
assertProfile(t, adapter, "weather-light", promptkit.BackendLocal, "shadowed-deepseek")
}
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
adapter, err := New(Config{ProfileFile: filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml")})
if err != nil {
t.Fatalf("New() error = %v", err)
}
assertProfile(t, adapter, "weather-light", "", "weather-local")
}
func TestMaintainedWeatherLightLocalProfileExampleExecutesThroughProductionClient(t *testing.T) {
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
for _, test := range []struct {
name string
credentialSource string
}{
{name: "without credential source"},
{name: "with blank optional credential source", credentialSource: "\napi_key_env: WEATHERREPORTER_TEST_MISSING_KEY\n"},
} {
t.Run(test.name, func(t *testing.T) {
var authorization string
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
authorization = request.Header.Get("Authorization")
writer.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(writer, `{"choices":[{"message":{"content":%q}}],"usage":{"prompt_tokens":12,"completion_tokens":8,"total_tokens":20}}`, validResponse().Content)
}))
defer server.Close()
example, err := os.ReadFile(filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml"))
if err != nil {
t.Fatal(err)
}
profile := strings.Replace(string(example), "http://127.0.0.1:11434/v1", server.URL+"/v1", 1) + test.credentialSource
adapter, err := New(Config{ProfileFile: writeProfileFile(t, profile)})
if err != nil {
t.Fatalf("New() error = %v", err)
}
request := testExecuteRequest()
request.ProfileID = "weather-light"
var preparation promptexec.Preparation
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
preparation = value
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if authorization != "" {
t.Fatalf("Authorization header = %q, want absent", authorization)
}
if preparation.ProfileID != "weather-light" || preparation.BackendID != "" || preparation.ModelName != "weather-local" || preparation.Output.RepairAttempts != 1 {
t.Fatalf("preparation = %#v", preparation)
}
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "" || result.ModelName != "weather-local" || result.Validation.Status != promptexec.ValidationPassed || result.Validation.RepairAttempts != 0 {
t.Fatalf("execution = %#v", result)
}
})
}
}
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `id: other-profile
backend: openrouter
model: other-model
`})})
if err != nil {
t.Fatalf("New(absent profile) error = %v", err)
}
assertProfile(t, absentAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `id: weather-light
backend: openrouter
`})})
if err != nil {
t.Fatalf("New(malformed profile) error = %v", err)
}
if _, err := malformedAdapter.InspectProfile(context.Background(), "weather-light"); err == nil {
t.Fatal("InspectProfile() error = nil, want malformed configured profile error")
}
}
func TestProfileResolutionReturnsConfiguredInheritanceFailures(t *testing.T) {
tests := []struct {
name string
profile string
profiles map[string]string
}{
{
name: "missing base",
profile: "missing-base",
profiles: map[string]string{"missing.yml": `id: missing-base
base_profile: unavailable
`},
},
{
name: "cyclic bases",
profile: "first",
profiles: map[string]string{
"first.yml": `id: first
base_profile: second
`,
"second.yml": `id: second
base_profile: first
`,
},
},
{
name: "malformed base",
profile: "child",
profiles: map[string]string{
"child.yml": `id: child
base_profile: malformed
`,
"malformed.yml": `id: malformed
base_profile: [not-a-profile]
`,
},
},
{
name: "incomplete target",
profile: "incomplete",
profiles: map[string]string{"incomplete.yml": `id: incomplete
backend: openrouter
`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, test.profiles)})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if _, err := adapter.InspectProfile(context.Background(), test.profile); err == nil {
t.Fatal("InspectProfile() error = nil, want configured inheritance error")
}
})
}
}
func TestRakestrawhomeBuiltInProfileInspectsOffline(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
profile, err := adapter.InspectProfile(context.Background(), "rakestrawhome-gemma-4-31b")
if err != nil {
t.Fatalf("InspectProfile() error = %v", err)
}
if profile.ProfileID != "rakestrawhome-gemma-4-31b" || profile.BackendID != "rakestrawhome" || profile.ModelName == "" {
t.Fatalf("profile = %#v", profile)
}
}
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
if err != nil {
t.Fatalf("InspectProfile(builtin) error = %v", err)
}
if builtin.ProfileID != "gemini-flash-latest" || builtin.BackendID != "openrouter" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
explicit, err := newAdapter(Config{}, promptkit.WithProfiles(promptkit.Profile{
ID: "weather-light",
Endpoint: "https://explicit.example/v1",
Model: "explicit-light",
}))
if err != nil {
t.Fatalf("newAdapter(explicit profile) error = %v", err)
}
assertProfile(t, explicit, "weather-light", "", "explicit-light")
}
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
callbackCalls := 0
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
callbackCalls++
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.ModelName != "test-model" {
t.Fatalf("preparation = %#v", preparation)
}
if debug != nil {
t.Fatalf("debug = %#v, want nil", debug)
}
if client.callCount() != 0 {
t.Fatal("provider called before preparation callback")
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if callbackCalls != 1 || client.callCount() != 1 {
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
}
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content {
t.Fatalf("result = %#v", result)
}
if result.Debug != nil {
t.Fatalf("debug = %#v, want nil", result.Debug)
}
providerRequest := client.request()
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
t.Fatalf("provider target = %#v", providerRequest.Target)
}
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
}
}
func TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
client := &fakeClient{response: hourlyValidResponse()}
adapter, err := newAdapter(Config{}, promptkit.WithLLMClient(client))
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
request := promptexec.ExecuteRequest{
PromptID: "weather.hourly_generated_text",
PromptVersion: "2.1.0",
ProfileID: "weather-light",
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
}
var preparation promptexec.Preparation
prepared := false
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
if client.callCount() != 0 {
t.Fatal("provider was called before preparation completed")
}
preparation = value
prepared = true
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !prepared || preparation.ProfileID != "weather-light" || preparation.BackendID != "openrouter" || preparation.ModelName != "deepseek/deepseek-v4-flash" {
t.Fatalf("preparation = %#v", preparation)
}
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "openrouter" || result.ModelName != "deepseek/deepseek-v4-flash" || result.Validation.Status != promptexec.ValidationPassed {
t.Fatalf("execution = %#v", result)
}
if client.callCount() != 1 || client.request().Target.Model != "deepseek/deepseek-v4-flash" {
t.Fatalf("provider calls/request = %d/%#v", client.callCount(), client.request())
}
}
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
client := &fakeClient{response: validResponse()}
reader := &recordingReader{}
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
request := testExecuteRequest()
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != "" || reader.ref.Body != string(request.DataPackage) {
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
}
}
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
var preparationDebug *promptexec.PreparationDebug
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
preparationDebug = debug
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
t.Fatalf("preparation debug = %#v", preparationDebug)
}
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
t.Fatalf("execution debug = %#v", result.Debug)
}
}
func TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
const marker = "private-debug-marker"
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
Temperature: 0.2,
MaxTokens: 400,
TopP: 0.9,
TimeoutSeconds: 30,
ServiceTier: "flex",
ReasoningEffort: "high",
ExtraParams: map[string]any{
"access-key": marker,
"signature": marker,
},
}))
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
}
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
if !strings.Contains(parameters, want) {
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
}
}
}
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
callbackError := errors.New("save preparation")
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
return callbackError
})
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
}
}
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
adapter := newTestAdapter(t, client)
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
t.Fatalf("result = %#v", result)
}
}
func TestExecuteMapsCorrectiveGenerationResults(t *testing.T) {
valid := `{"summary":"valid"}`
invalid := `{"summary":42}`
tests := []struct {
name string
outcomes []generationOutcome
wantStatus promptexec.ValidationStatus
wantRepairs int
wantCalls int
wantRaw string
wantUsage promptexec.TokenUsage
}{
{
name: "first pass valid",
outcomes: []generationOutcome{{response: generationResponse(valid, 2, 3, 5)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 0,
wantCalls: 1,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
},
{
name: "empty output repaired",
outcomes: []generationOutcome{{response: generationResponse("", 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
{
name: "invalid output repaired",
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
{
name: "repair budget exhausted",
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(invalid, 7, 11, 18)}},
wantStatus: promptexec.ValidationFailed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: invalid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := &fakeClient{outcomes: test.outcomes}
adapter := newRepairAdapter(t, client, "https://repair.example/v1")
var preparation promptexec.Preparation
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
preparation = value
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if preparation.Output.RepairAttempts != 1 || result == nil || result.Validation.Status != test.wantStatus || result.Validation.RepairAttempts != test.wantRepairs || string(result.RawOutput) != test.wantRaw || result.Usage != test.wantUsage {
t.Fatalf("preparation/result = %#v/%#v", preparation, result)
}
requests := client.allRequests()
if len(requests) != test.wantCalls {
t.Fatalf("provider requests = %d, want %d", len(requests), test.wantCalls)
}
if test.wantCalls == 2 && !reflect.DeepEqual(requests[0].Target, requests[1].Target) {
t.Fatalf("corrective target = %#v, want same prepared identity as %#v", requests[1].Target, requests[0].Target)
}
if result.ProfileID != preparation.ProfileID || result.BackendID != preparation.BackendID || result.ModelName != preparation.ModelName || result.PromptID != preparation.PromptID || result.PromptVersion != preparation.PromptVersion || result.PromptHash != preparation.PromptHash {
t.Fatalf("prepared/result identity = %#v/%#v", preparation, result)
}
})
}
}
func TestExecuteMapsCorrectiveGenerationError(t *testing.T) {
const providerBody = `{"error":{"code":"repair-code","type":"repair-type","message":"repair-message"}}`
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
writer.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(writer, `{"choices":[{"message":{"content":%q}}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`, `{"summary":42}`)
return
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusUnprocessableEntity)
_, _ = writer.Write([]byte(providerBody))
}))
defer server.Close()
adapter := newRepairAdapter(t, nil, server.URL)
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), nil)
if result != nil || err == nil || calls != 2 {
t.Fatalf("result/error/calls = %#v/%v/%d", result, err, calls)
}
var generationError *promptexec.GenerationError
if !errors.As(err, &generationError) || generationError.StatusCode() != http.StatusUnprocessableEntity || generationError.ProviderCode() != "repair-code" || generationError.ProviderType() != "repair-type" || generationError.ProviderMessage() != "repair-message" {
t.Fatalf("generation error = %#v", err)
}
if strings.Contains(err.Error(), "repair-message") || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("generation error = %v", err)
}
}
func TestExecuteDropsOversizedGeneratedOutput(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1)}}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
result, err := adapter.Execute(context.Background(), request, nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.RawOutput) != 0 || result.Debug == nil || len(result.Debug.RawOutput) != 0 {
t.Fatalf("execution = %#v", result)
}
if len(result.Validation.Diagnostics) != 1 || result.Validation.Diagnostics[0] != "generated output exceeds the configured size limit" {
t.Fatalf("diagnostics = %#v", result.Validation.Diagnostics)
}
}
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
tests := []struct {
name string
client *fakeClient
context func() (context.Context, context.CancelFunc)
category promptexec.ErrorCategory
}{
{
name: "generation",
client: &fakeClient{err: errors.New("provider response body")},
context: func() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
},
category: promptexec.Generation,
},
{
name: "canceled",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, func() {}
},
category: promptexec.Canceled,
},
{
name: "deadline",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Nanosecond)
},
category: promptexec.DeadlineExceeded,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := newTestAdapter(t, test.client)
ctx, cancel := test.context()
defer cancel()
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
}
if strings.Contains(err.Error(), "provider response body") {
t.Fatalf("error leaks provider detail: %v", err)
}
})
}
}
func TestClassifyPromptkitErrors(t *testing.T) {
tests := []struct {
err error
category promptexec.ErrorCategory
}{
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
{promptkit.ErrPromptRender, promptexec.PromptRender},
{promptkit.ErrLLMGenerate, promptexec.Generation},
{promptkit.ErrValidation, promptexec.OperationalValidation},
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
}
for _, test := range tests {
t.Run(string(test.category), func(t *testing.T) {
got := classifyError(test.err)
if promptexec.CategoryOf(got) != test.category {
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
}
})
}
}
func TestNewValidatesConfiguration(t *testing.T) {
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("profile source error = %v", err)
}
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local concurrency error = %v", err)
}
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local endpoint error = %v", err)
}
}
func TestLocalBackendAndOptionalCredentialSourceBehavior(t *testing.T) {
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: local-profile
backend: local
model: local-model
`})
adapter, err := newAdapterForTest(Config{
ProfileDirectory: profiles,
LocalEndpoint: "https://local.example/v1",
LocalConcurrencyLimit: 1,
}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest(local) error = %v", err)
}
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
t.Fatalf("local profile/error = %#v/%v", profile, err)
}
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
t.Fatalf("capacity classification = %v", got)
}
credentialProfiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: credential-profile
endpoint: https://profile.example/v1
model: test-model
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
`})
client := &fakeClient{response: validResponse()}
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
if err != nil {
t.Fatalf("newAdapterForTest(credential) error = %v", err)
}
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
}
request := testExecuteRequest()
request.ProfileID = "credential-profile"
result, err := credentialAdapter.Execute(context.Background(), request, nil)
if err != nil || result == nil || client.callCount() != 1 {
t.Fatalf("credential result/error/calls = %#v/%v/%d", result, err, client.callCount())
}
}
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
return newTestAdapterWithOptions(t, client)
}
func assertProfile(t *testing.T, adapter *Adapter, id string, backend string, model string) {
t.Helper()
profile, err := adapter.InspectProfile(context.Background(), id)
if err != nil {
t.Fatalf("InspectProfile(%q) error = %v", id, err)
}
if profile.ProfileID != id || profile.BackendID != backend || profile.ModelName != model {
t.Fatalf("profile = %#v, want %q with backend/model %q/%q", profile, id, backend, model)
}
}
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
t.Helper()
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: test-profile
endpoint: https://profile.example/v1
model: test-model
temperature: 0.2
max_tokens: 300
top_p: 1
timeout_seconds: 30
`})
options = append(options, promptkit.WithLLMClient(client))
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
return adapter
}
func testProfileDirectory(t *testing.T, profiles map[string]string) string {
t.Helper()
directory := t.TempDir()
for name, profile := range profiles {
if err := os.WriteFile(filepath.Join(directory, name), []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
}
return directory
}
func writeProfileFile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}
func testExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.daily_generated_text",
PromptVersion: "2.1.0",
ProfileID: "test-profile",
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
}
}
func validResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}
func generationResponse(content string, promptTokens int, completionTokens int, totalTokens int) *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: content,
Usage: promptkit.TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
},
}
}
func newRepairAdapter(t *testing.T, client promptkit.LLMClient, endpoint string) *Adapter {
t.Helper()
profiles := testProfileDirectory(t, map[string]string{"profile.yml": "id: repair-profile\nendpoint: " + endpoint + "\nmodel: repair-model\n"})
options := []promptkit.Option{
promptkit.WithPromptFS(fstest.MapFS{
"repair.yml": &fstest.MapFile{Data: []byte(`id: weather.repair
version: "1.0.0"
default_profile: repair-profile
inputs:
- name: data_package
required: true
content_type: application/yaml
messages:
- role: user
content: "{{input \"data_package\"}}"
output:
format: json
validation_mode: json_schema
schema_path: repair.schema.json
repair_attempts: 1
`)}}, "."),
promptkit.WithSchemaFS(fstest.MapFS{
"repair.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"],"additionalProperties":false}`)},
}, "."),
}
if client != nil {
options = append(options, promptkit.WithLLMClient(client))
}
adapter, err := newAdapter(Config{ProfileDirectory: profiles}, options...)
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
return adapter
}
func repairExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.repair",
PromptVersion: "1.0.0",
ProfileID: "repair-profile",
DataPackage: []byte("report: repair\n"),
}
}
func hourlyValidResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}

View File

@@ -1,301 +0,0 @@
// Package scriptorium adapts the external scriptorium CLI.
package scriptorium
import (
"context"
"fmt"
"io"
"os/exec"
"time"
)
const maxCapturedOutputBytes = 1024 * 1024
type CommandRunner interface {
Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error)
}
type CommandResult struct {
Stdout []byte
Stderr []byte
StdoutTruncated bool
StderrTruncated bool
ExitCode int
}
type ExecRunner struct{}
func (ExecRunner) Run(ctx context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
runCtx := ctx
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
}
defer cancel()
cmd := exec.CommandContext(runCtx, name, args...)
stdout := &limitedBuffer{limit: maxCapturedOutputBytes}
stderr := &limitedBuffer{limit: maxCapturedOutputBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
err := cmd.Run()
result := CommandResult{
Stdout: stdout.Bytes(),
Stderr: stderr.Bytes(),
StdoutTruncated: stdout.Truncated(),
StderrTruncated: stderr.Truncated(),
ExitCode: 0,
}
if err == nil {
return result, nil
}
if runCtx.Err() != nil {
return result, runCtx.Err()
}
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
return result, nil
}
return result, err
}
type Runner struct {
Binary string
ConfigPath string
Profile string
Timeout time.Duration
ExtraArgs []string
Commands CommandRunner
}
type RenderRequest struct {
PromptID string
DataPackagePath string
}
type RunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type StructuredRunRequest struct {
PromptID string
DataPackagePath string
OutputPath string
}
type RenderResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
}
type RunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
type StructuredRunResult struct {
Command []string `json:"command"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
StdoutTruncated bool `json:"stdoutTruncated,omitempty"`
StderrTruncated bool `json:"stderrTruncated,omitempty"`
ExitCode int `json:"exitCode"`
OutputPath string `json:"outputPath"`
}
func (r Runner) Render(ctx context.Context, req RenderRequest) (*RenderResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
execution, err := r.execute(ctx, r.renderArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium render: %w", err)
}
result := &RenderResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium render exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.runArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium: %w", err)
}
result := &RunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
func (r Runner) StructuredRun(ctx context.Context, req StructuredRunRequest) (*StructuredRunResult, error) {
if req.PromptID == "" {
return nil, fmt.Errorf("prompt id is required")
}
if req.DataPackagePath == "" {
return nil, fmt.Errorf("data package path is required")
}
if req.OutputPath == "" {
return nil, fmt.Errorf("output path is required")
}
execution, err := r.execute(ctx, r.structuredRunArgs(req))
if err != nil {
return nil, fmt.Errorf("run scriptorium structured output: %w", err)
}
result := &StructuredRunResult{
Command: execution.argv(),
Stdout: string(execution.result.Stdout),
Stderr: string(execution.result.Stderr),
StdoutTruncated: execution.result.StdoutTruncated,
StderrTruncated: execution.result.StderrTruncated,
ExitCode: execution.result.ExitCode,
OutputPath: req.OutputPath,
}
if execution.result.ExitCode != 0 {
return result, fmt.Errorf("scriptorium structured run exited with code %d: %s", execution.result.ExitCode, result.Stderr)
}
return result, nil
}
type execution struct {
binary string
args []string
result CommandResult
}
func (r Runner) execute(ctx context.Context, args []string) (execution, error) {
binary := r.Binary
if binary == "" {
binary = "scriptorium"
}
commands := r.Commands
if commands == nil {
commands = ExecRunner{}
}
result, err := commands.Run(ctx, binary, args, r.Timeout)
if err != nil {
return execution{}, err
}
return execution{binary: binary, args: args, result: result}, nil
}
func (e execution) argv() []string {
return append([]string{e.binary}, e.args...)
}
func (r Runner) renderArgs(req RenderRequest) []string {
args := []string{"render"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--format", "json",
)
args = append(args, r.ExtraArgs...)
return args
}
func (r Runner) runArgs(req RunRequest) []string {
args := []string{"run"}
if r.ConfigPath != "" {
args = append(args, "--config", r.ConfigPath)
}
if r.Profile != "" {
args = append(args, "--profile", r.Profile)
}
args = append(args,
"--prompt", req.PromptID,
"--input", "data_package="+req.DataPackagePath,
"--out", req.OutputPath,
)
args = append(args, r.ExtraArgs...)
return args
}
func (r Runner) structuredRunArgs(req StructuredRunRequest) []string {
return r.runArgs(RunRequest{
PromptID: req.PromptID,
DataPackagePath: req.DataPackagePath,
OutputPath: req.OutputPath,
})
}
type limitedBuffer struct {
data []byte
limit int
truncated bool
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
b.truncated = true
return len(p), nil
}
remaining := b.limit - len(b.data)
if remaining <= 0 {
b.truncated = true
return len(p), nil
}
if len(p) > remaining {
b.data = append(b.data, p[:remaining]...)
b.truncated = true
return len(p), nil
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *limitedBuffer) Bytes() []byte {
return append([]byte{}, b.data...)
}
func (b *limitedBuffer) Truncated() bool {
return b.truncated
}
var _ io.Writer = (*limitedBuffer)(nil)

View File

@@ -1,315 +0,0 @@
package scriptorium
import (
"context"
"reflect"
"strings"
"testing"
"time"
)
func TestRenderConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stdout: []byte(`{"ok":true}`)}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: time.Minute,
Commands: commands,
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.yaml",
})
if err != nil {
t.Fatalf("Render() error = %v", err)
}
wantArgs := []string{
"render",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.yaml",
"--format", "json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
}
func TestRenderReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stderr: []byte("missing input"),
ExitCode: 1,
},
},
}
result, err := runner.Render(context.Background(), RenderRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.yaml",
})
if err == nil {
t.Fatal("Render() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Render() result = nil, want captured result")
}
if result.ExitCode != 1 {
t.Fatalf("ExitCode = %d, want 1", result.ExitCode)
}
if !strings.Contains(err.Error(), "missing input") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestRunConstructsCommand(t *testing.T) {
commands := &fakeCommands{result: CommandResult{Stderr: []byte("wrote report")}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 45 * time.Second,
Commands: commands,
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.daily_report",
"--input", "data_package=/tmp/data_package.yaml",
"--out", "/tmp/daily.md",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
if commands.timeout != 45*time.Second {
t.Fatalf("timeout = %s, want 45s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.OutputPath != "/tmp/daily.md" {
t.Fatalf("OutputPath = %q, want /tmp/daily.md", result.OutputPath)
}
}
func TestRunReturnsResultForValidationExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte("# Daily Report\n"),
Stderr: []byte("validation failed"),
ExitCode: 2,
},
},
}
result, err := runner.Run(context.Background(), RunRequest{
PromptID: "weather.daily_report",
DataPackagePath: "/tmp/data_package.yaml",
OutputPath: "/tmp/daily.md",
})
if err == nil {
t.Fatal("Run() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("Run() result = nil, want captured result")
}
if result.ExitCode != 2 {
t.Fatalf("ExitCode = %d, want 2", result.ExitCode)
}
if !strings.Contains(err.Error(), "validation failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunConstructsCommandWithoutSchemaFlags(t *testing.T) {
commands := &fakeCommands{result: CommandResult{
Stdout: []byte(`{"summary":"ok"}`),
Stderr: []byte("wrote generated text"),
StdoutTruncated: true,
}}
runner := Runner{
Binary: "/usr/local/bin/scriptorium",
ConfigPath: "/etc/scriptorium.yml",
Profile: "weather",
Timeout: 30 * time.Second,
Commands: commands,
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err != nil {
t.Fatalf("StructuredRun() error = %v", err)
}
wantArgs := []string{
"run",
"--config", "/etc/scriptorium.yml",
"--profile", "weather",
"--prompt", "weather.hourly_generated_text",
"--input", "data_package=/tmp/hourly.data_package.yaml",
"--out", "/tmp/hourly.generated_text.raw.json",
}
if commands.name != "/usr/local/bin/scriptorium" {
t.Fatalf("command name = %q, want custom binary", commands.name)
}
if !reflect.DeepEqual(commands.args, wantArgs) {
t.Fatalf("args = %#v, want %#v", commands.args, wantArgs)
}
for _, disallowed := range []string{"--format", "--schema", "--schema-path", "--json-schema"} {
if containsArg(commands.args, disallowed) {
t.Fatalf("args = %#v, should not include %q", commands.args, disallowed)
}
}
if commands.timeout != 30*time.Second {
t.Fatalf("timeout = %s, want 30s", commands.timeout)
}
if !reflect.DeepEqual(result.Command, append([]string{"/usr/local/bin/scriptorium"}, wantArgs...)) {
t.Fatalf("result command = %#v, want full argv", result.Command)
}
if result.Stdout != `{"summary":"ok"}` || result.Stderr != "wrote generated text" || !result.StdoutTruncated {
t.Fatalf("result = %#v, want captured output and truncation flags", result)
}
if result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("OutputPath = %q, want generated text raw path", result.OutputPath)
}
}
func TestStructuredRunReturnsResultForNonzeroExit(t *testing.T) {
runner := Runner{
Commands: &fakeCommands{
result: CommandResult{
Stdout: []byte(`{"summary":"partial"}`),
Stderr: []byte("structured output failed"),
ExitCode: 3,
},
},
}
result, err := runner.StructuredRun(context.Background(), StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
})
if err == nil {
t.Fatal("StructuredRun() error = nil, want nonzero exit error")
}
if result == nil {
t.Fatal("StructuredRun() result = nil, want captured result")
}
if result.ExitCode != 3 {
t.Fatalf("ExitCode = %d, want 3", result.ExitCode)
}
if result.Stdout != `{"summary":"partial"}` || result.OutputPath != "/tmp/hourly.generated_text.raw.json" {
t.Fatalf("result = %#v, want captured result fields", result)
}
if !strings.Contains(err.Error(), "structured output failed") {
t.Fatalf("error = %q, want stderr context", err.Error())
}
}
func TestStructuredRunValidatesRequiredFieldsBeforeExecution(t *testing.T) {
tests := []struct {
name string
req StructuredRunRequest
want string
}{
{
name: "prompt id",
req: StructuredRunRequest{
DataPackagePath: "/tmp/hourly.data_package.yaml",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "prompt id is required",
},
{
name: "data package path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
OutputPath: "/tmp/hourly.generated_text.raw.json",
},
want: "data package path is required",
},
{
name: "output path",
req: StructuredRunRequest{
PromptID: "weather.hourly_generated_text",
DataPackagePath: "/tmp/hourly.data_package.yaml",
},
want: "output path is required",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
commands := &fakeCommands{}
runner := Runner{Commands: commands}
result, err := runner.StructuredRun(context.Background(), test.req)
if err == nil {
t.Fatal("StructuredRun() error = nil, want validation error")
}
if result != nil {
t.Fatalf("StructuredRun() result = %#v, want nil", result)
}
if !strings.Contains(err.Error(), test.want) {
t.Fatalf("StructuredRun() error = %v, want %q", err, test.want)
}
if commands.calls != 0 {
t.Fatalf("commands calls = %d, want no subprocess execution", commands.calls)
}
})
}
}
type fakeCommands struct {
name string
args []string
timeout time.Duration
result CommandResult
err error
calls int
}
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.calls++
f.name = name
f.args = append([]string{}, args...)
f.timeout = timeout
return f.result, f.err
}
func containsArg(args []string, want string) bool {
for _, arg := range args {
if arg == want {
return true
}
}
return false
}

View File

@@ -7,6 +7,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -14,18 +15,28 @@ import (
"path"
"strconv"
"strings"
"sync"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
currentConditionsEndpoint = "/conditions/current"
sourceSPCConvectiveOutlooks = config.MissingSourceSPCConvectiveOutlooks
defaultWarmupEndpoint = currentConditionsEndpoint
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second
maxResponseBodyBytes = 10 << 20
)
var errResponseBodyTooLarge = errors.New("response exceeds 10 MiB limit")
type Client struct {
baseURL *url.URL
httpClient *http.Client
@@ -35,6 +46,12 @@ type Client struct {
precision int
missingSource config.MissingSourceConfig
now func() time.Time
warmupEndpoint string
warmupAttempts int
warmupDelay time.Duration
fetchAttempts int
fetchRetryDelay time.Duration
}
type Option func(*Client)
@@ -63,6 +80,9 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return nil, fmt.Errorf("weather_api.base_url must be an absolute URL")
}
if !strings.EqualFold(baseURL.Scheme, "http") && !strings.EqualFold(baseURL.Scheme, "https") {
return nil, fmt.Errorf("weather_api.base_url must use http or https")
}
timeout := cfg.WeatherAPI.Timeout
if timeout <= 0 {
@@ -81,6 +101,11 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
warmupEndpoint: defaultWarmupEndpoint,
warmupAttempts: defaultWarmupAttempts,
warmupDelay: defaultWarmupDelay,
fetchAttempts: defaultFetchAttempts,
fetchRetryDelay: defaultFetchRetryDelay,
}
for _, opt := range opts {
opt(client)
@@ -89,36 +114,24 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
}
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
warmup, err := c.warmup(ctx)
if err != nil {
return nil, err
}
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
if err := builder.fetchObservation(ctx); err != nil {
for _, acquired := range builder.acquireSources(ctx, warmup) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("fetch weather API sources: %w", err)
}
if err := builder.mergeSource(acquired); err != nil {
return nil, err
}
if err := builder.fetchCurrent(ctx); err != nil {
return nil, err
}
if err := builder.fetchHourly(ctx); err != nil {
return nil, err
}
if err := builder.fetchNarrative(ctx); err != nil {
return nil, err
}
if err := builder.fetchAlerts(ctx); err != nil {
return nil, err
}
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
return builder.bundle, nil
@@ -127,59 +140,146 @@ func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
type bundleBuilder struct {
client *Client
bundle *weatherdata.Bundle
}
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json.RawMessage
source weatherdata.Source
}
type warmupResponse struct {
endpoint string
requestURL *url.URL
body []byte
fetchedAt time.Time
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
if err != nil {
type sourceAcquisition struct {
request sourceRequest
fetched fetchedSource
err error
warmup warmupResponse
usesWarmup bool
}
func (b *bundleBuilder) acquireSources(ctx context.Context, warmup warmupResponse) []sourceAcquisition {
sources := []sourceAcquisition{
{request: sourceRequest{name: config.MissingSourceObservations, endpoint: "/observations", query: queryOptions{precision: true}, missingMessage: "observation data is missing"}},
{request: currentConditionsRequest()},
{request: sourceRequest{name: "hourly", endpoint: "/forecast/hourly", query: queryOptions{precision: true, timezone: true}, missingMessage: "hourly forecast data is missing", required: true, decodeLabel: "hourly forecast"}},
{request: sourceRequest{name: config.MissingSourceNarrative, endpoint: "/forecast/narrative", query: queryOptions{precision: true, timezone: true}, missingMessage: "narrative forecast data is missing"}},
{request: sourceRequest{name: config.MissingSourceAlerts, endpoint: "/alerts/active", query: queryOptions{allowNull: true}, missingMessage: "active alerts data is missing"}},
{request: sourceRequest{name: config.MissingSourceDiscussion, endpoint: "/discussion", query: queryOptions{timezone: true}, missingMessage: "forecast discussion data is missing"}},
{request: sourceRequest{name: config.MissingSourceWeatherStory, endpoint: "/weatherstories/latest", query: queryOptions{omitUnits: true}, missingMessage: "NWS weather story data is missing"}},
{request: sourceRequest{name: sourceSPCConvectiveOutlooks, endpoint: convectiveOutlooksEndpoint, query: queryOptions{timezone: true, omitUnits: true}, missingMessage: "SPC convective outlook data is missing"}},
}
if warmup.endpoint == currentConditionsEndpoint {
sources[1].warmup = warmup
sources[1].usesWarmup = true
}
var group sync.WaitGroup
for i := range sources {
if sources[i].usesWarmup {
continue
}
group.Add(1)
go func(index int) {
defer group.Done()
request := sources[index].request
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
sources[index].fetched = fetchedSource{raw: raw, source: source}
sources[index].err = err
}(i)
}
group.Wait()
return sources
}
func (b *bundleBuilder) mergeSource(acquired sourceAcquisition) error {
switch acquired.request.name {
case config.MissingSourceObservations:
return b.fetchObservation(acquired)
case config.MissingSourceCurrent:
return b.fetchCurrent(acquired)
case "hourly":
return b.fetchHourly(acquired)
case config.MissingSourceNarrative:
return b.fetchNarrative(acquired)
case config.MissingSourceAlerts:
return b.fetchAlerts(acquired)
case config.MissingSourceDiscussion:
return b.fetchDiscussion(acquired)
case config.MissingSourceWeatherStory:
return b.fetchWeatherStory(acquired)
case sourceSPCConvectiveOutlooks:
return b.fetchSPCConvectiveOutlooks(acquired)
default:
return fmt.Errorf("merge unknown weather source %q", acquired.request.name)
}
}
func (b *bundleBuilder) fetchObservation(acquired sourceAcquisition) error {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(acquired, &observation)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation weatherdata.Observation
if err := decodeSource(raw, &observation); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &observation.Timestamp
b.bundle.Observation = &observation
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
if err != nil {
func currentConditionsRequest() sourceRequest {
return sourceRequest{
name: config.MissingSourceCurrent,
endpoint: currentConditionsEndpoint,
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}
}
func (b *bundleBuilder) fetchCurrent(acquired sourceAcquisition) error {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(acquired, &current)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current weatherdata.Current
if err := decodeSource(raw, &current); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
b.bundle.Current = &current
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "hourly", "/forecast/hourly", queryOptions{precision: true, timezone: true})
if err != nil {
func (b *bundleBuilder) fetchHourly(acquired sourceAcquisition) error {
var hourly weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(acquired, &hourly)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly weatherdata.ForecastRun
if err := decodeSource(raw, &hourly); err != nil {
return fmt.Errorf("decode hourly forecast from %s: %w", source.Endpoint, err)
}
source := fetched.source
if len(hourly.Periods) == 0 {
return fmt.Errorf("hourly forecast from %s contains no periods", source.Endpoint)
}
for i, period := range hourly.Periods {
if !period.HasUsableTimeBounds() {
return fmt.Errorf("hourly forecast from %s has unusable time bounds for period %d", source.Endpoint, i+1)
}
if !period.HasValidPrecipitationProbability() {
return fmt.Errorf("hourly forecast from %s has invalid precipitation probability for period %d", source.Endpoint, i+1)
}
}
source.IssuedAt = &hourly.IssuedAt
source.UpdatedAt = hourly.UpdatedAt
b.bundle.Hourly = &hourly
@@ -187,18 +287,13 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "narrative", "/forecast/narrative", queryOptions{precision: true, timezone: true})
if err != nil {
func (b *bundleBuilder) fetchNarrative(acquired sourceAcquisition) error {
var narrative weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(acquired, &narrative)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative weatherdata.ForecastRun
if err := decodeSource(raw, &narrative); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &narrative.IssuedAt
source.UpdatedAt = narrative.UpdatedAt
b.bundle.Narrative = &narrative
@@ -206,24 +301,21 @@ func (b *bundleBuilder) fetchNarrative(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "alerts", "/alerts/active", queryOptions{allowNull: true})
if err != nil {
func (b *bundleBuilder) fetchAlerts(acquired sourceAcquisition) error {
fetched, ok, err := b.fetchSource(acquired)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "active alerts data is missing", false)
}
raw, source := fetched.raw, fetched.source
if isJSONNull(raw) {
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.bundle.Alerts = &weatherdata.AlertRun{}
b.addSource(source)
return nil
}
var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
return b.handleMalformed(&source, err, acquired.request)
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
source.IssuedAt = alerts.AsOf
}
@@ -232,18 +324,13 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
if err != nil {
func (b *bundleBuilder) fetchDiscussion(acquired sourceAcquisition) error {
var discussion weatherdata.Discussion
fetched, ok, err := b.fetchDecodedSource(acquired, &discussion)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion weatherdata.Discussion
if err := decodeSource(raw, &discussion); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
source.IssuedAt = &discussion.IssuedAt
source.UpdatedAt = discussion.UpdatedAt
b.bundle.Discussion = &discussion
@@ -251,17 +338,15 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "weather_story", "/weatherstories/latest", queryOptions{omitUnits: true})
if err != nil {
func (b *bundleBuilder) fetchWeatherStory(acquired sourceAcquisition) error {
var story weatherdata.WeatherStory
fetched, ok, err := b.fetchDecodedSource(acquired, &story)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "NWS weather story data is missing", false)
}
var story weatherdata.WeatherStory
if err := decodeSource(raw, &story); err != nil {
return b.handleMalformed(&source, err, false)
source := fetched.source
if !story.HasUsableContent() {
return b.handleMalformed(&source, fmt.Errorf("weather story has no usable content"), acquired.request)
}
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
@@ -272,18 +357,13 @@ func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, sourceSPCConvectiveOutlooks, convectiveOutlooksEndpoint, queryOptions{timezone: true, omitUnits: true})
if err != nil {
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(acquired sourceAcquisition) error {
var run weatherdata.ConvectiveOutlookRun
fetched, ok, err := b.fetchDecodedSource(acquired, &run)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "SPC convective outlook data is missing", false)
}
var run weatherdata.ConvectiveOutlookRun
if err := decodeSource(raw, &run); err != nil {
return b.handleMalformed(&source, err, false)
}
source := fetched.source
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
@@ -295,6 +375,37 @@ func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) fetchDecodedSource(acquired sourceAcquisition, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(acquired)
if err != nil || !ok {
return fetchedSource{}, false, err
}
return b.decodeFetchedSource(fetched, acquired.request, target)
}
func (b *bundleBuilder) decodeFetchedSource(fetched fetchedSource, request sourceRequest, target any) (fetchedSource, bool, error) {
if err := decodeSource(fetched.raw, target); err != nil {
return fetchedSource{}, false, b.handleMalformed(&fetched.source, err, request)
}
return fetched, true, nil
}
func (b *bundleBuilder) fetchSource(acquired sourceAcquisition) (fetchedSource, bool, error) {
if acquired.usesWarmup {
raw, source, err := b.client.decodeSourceResponse(acquired.request.name, acquired.request.endpoint, acquired.request.query, acquired.warmup.requestURL, acquired.warmup.body, acquired.warmup.fetchedAt)
if err != nil {
return fetchedSource{}, false, err
}
acquired.fetched = fetchedSource{raw: raw, source: source}
} else if acquired.err != nil {
return fetchedSource{}, false, acquired.err
}
if acquired.fetched.raw == nil {
return fetchedSource{}, false, b.handleMissing(&acquired.fetched.source, acquired.request.missingMessage, acquired.request.required)
}
return acquired.fetched, true, nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true
if required {
@@ -303,9 +414,13 @@ func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string
return b.applyMissingPolicy(source, "missing_source", message)
}
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, required bool) error {
if required {
return fmt.Errorf("decode %s from %s: %w", source.Name, source.Endpoint, err)
func (b *bundleBuilder) handleMalformed(source *weatherdata.Source, err error, request sourceRequest) error {
if request.required {
label := request.name
if request.decodeLabel != "" {
label = request.decodeLabel
}
return fmt.Errorf("decode %s from %s: %w", label, source.Endpoint, err)
}
source.Missing = true
return b.applyMissingPolicy(source, "malformed_source", fmt.Sprintf("malformed %s data: %v", source.Name, err))
@@ -355,26 +470,14 @@ type envelope struct {
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: %w", endpoint, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, weatherdata.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
return nil, weatherdata.Source{}, err
}
return c.decodeSourceResponse(sourceName, endpoint, opts, reqURL, body, c.now())
}
func (c *Client) decodeSourceResponse(sourceName string, endpoint string, opts queryOptions, reqURL *url.URL, body []byte, fetchedAt time.Time) (json.RawMessage, weatherdata.Source, error) {
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
@@ -384,7 +487,7 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
FetchedAt: c.now(),
FetchedAt: fetchedAt,
}
if len(env.Data) == 0 || (isJSONNull(env.Data) && !opts.allowNull) {
source.Missing = true
@@ -398,6 +501,171 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) (warmupResponse, error) {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return warmupResponse{}, fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
reqURL, body, err := c.warmupOnce(ctx, endpoint)
if err != nil {
lastErr = err
lastRetryable = isRetryableRequestError(err)
} else {
return warmupResponse{endpoint: endpoint, requestURL: reqURL, body: body, fetchedAt: c.now()}, nil
}
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return warmupResponse{}, fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
if !lastRetryable {
return warmupResponse{}, lastErr
}
return warmupResponse{}, fmt.Errorf("warm up weather API via %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
func (c *Client) warmupOnce(ctx context.Context, endpoint string) (*url.URL, []byte, error) {
return c.fetchHTTPOnce(ctx, endpoint, queryOptions{precision: true})
}
func (c *Client) fetchHTTP(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
attempts := positiveAttemptCount(c.fetchAttempts)
var lastErr error
var lastRetryable bool
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, nil, fmt.Errorf("fetch %s: %w", endpoint, err)
}
reqURL, body, err := c.fetchHTTPOnce(ctx, endpoint, opts)
if err == nil {
return reqURL, body, nil
}
lastErr = err
lastRetryable = isRetryableRequestError(err)
if !lastRetryable || attempt == attempts {
break
}
if err := waitForRetry(ctx, c.fetchRetryDelay); err != nil {
return nil, nil, fmt.Errorf("fetch %s retry delay after attempt %d: %w", endpoint, attempt, err)
}
}
if lastRetryable {
return nil, nil, fmt.Errorf("fetch %s failed after %d attempts: %w", endpoint, attempts, lastErr)
}
return nil, nil, lastErr
}
func (c *Client) fetchHTTPOnce(ctx context.Context, endpoint string, opts queryOptions) (*url.URL, []byte, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, nil, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
err = fmt.Errorf("fetch %s: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
defer resp.Body.Close()
body, err := readResponseBody(resp.Body)
if err != nil {
return reqURL, nil, responseReadError(ctx, endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d", endpoint, resp.StatusCode)
if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err}
}
return reqURL, nil, err
}
return reqURL, body, nil
}
func readResponseBody(body io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, maxResponseBodyBytes+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxResponseBodyBytes {
return nil, errResponseBodyTooLarge
}
return data, nil
}
func responseReadError(ctx context.Context, endpoint string, err error) error {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if errors.Is(err, errResponseBodyTooLarge) || ctx.Err() != nil {
return err
}
return retryableRequestError{err: err}
}
type retryableRequestError struct {
err error
}
func (e retryableRequestError) Error() string {
return e.err.Error()
}
func (e retryableRequestError) Unwrap() error {
return e.err
}
func isRetryableRequestError(err error) bool {
_, ok := err.(retryableRequestError)
return ok
}
func isRetryableHTTPStatus(status int) bool {
switch status {
case http.StatusRequestTimeout,
http.StatusTooManyRequests,
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return true
default:
return false
}
}
func waitForRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return ctx.Err()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func positiveAttemptCount(attempts int) int {
if attempts < 1 {
return 1
}
return attempts
}
func isJSONNull(raw json.RawMessage) bool {
return bytes.Equal(bytes.TrimSpace(raw), []byte("null"))
}
@@ -448,10 +716,3 @@ func sourceHash(raw json.RawMessage) (string, error) {
sum := sha256.Sum256(compact.Bytes())
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err)
}
return nil
}

View File

@@ -3,11 +3,13 @@ package weatherapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -15,6 +17,12 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestFetchBundleFromFixtures(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
@@ -70,6 +78,30 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if len(bundle.Warnings) != 0 {
t.Fatalf("Warnings length = %d, want no warnings", len(bundle.Warnings))
}
wantPaths := []string{
"/observations",
"/conditions/current",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
if len(requested) != len(wantPaths) {
t.Fatalf("requested paths = %v, want %d source endpoints", requested, len(wantPaths))
}
if !strings.HasPrefix(requested[0], defaultWarmupEndpoint+"?") && requested[0] != defaultWarmupEndpoint {
t.Fatalf("first requested path = %q, want warmup endpoint %s", requested[0], defaultWarmupEndpoint)
}
for _, want := range wantPaths {
if !containsPath(requested, want) {
t.Fatalf("requested paths = %v, want %s", requested, want)
}
}
if got := countPath(requested, currentConditionsEndpoint); got != 1 {
t.Fatalf("conditions/current requests = %d, want 1; requested paths = %v", got, requested)
}
if !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
}
@@ -84,6 +116,191 @@ func TestFetchBundleFromFixtures(t *testing.T) {
}
}
func TestFetchBundleMergesConcurrentSourcesInSourceOrder(t *testing.T) {
paths := []string{
"/observations",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
started := make(chan string, len(paths))
release := make(map[string]chan struct{}, len(paths))
for _, path := range paths {
release[path] = make(chan struct{})
}
var releaseOnce sync.Once
releaseAll := func() {
releaseOnce.Do(func() {
for i := len(paths) - 1; i >= 0; i-- {
close(release[paths[i]])
}
})
}
t.Cleanup(releaseAll)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == currentConditionsEndpoint {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
return
}
ready, ok := release[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
started <- r.URL.Path
<-ready
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
type fetchResult struct {
bundle *weatherdata.Bundle
err error
}
result := make(chan fetchResult, 1)
go func() {
bundle, err := client.FetchBundle(context.Background())
result <- fetchResult{bundle: bundle, err: err}
}()
seen := make(map[string]bool, len(paths))
for range paths {
select {
case path := <-started:
seen[path] = true
case <-time.After(time.Second):
t.Fatalf("independent requests started = %v, want %v", seen, paths)
}
}
releaseAll()
select {
case got := <-result:
if got.err != nil {
t.Fatalf("FetchBundle() error = %v", got.err)
}
wantSources := []string{
config.MissingSourceObservations,
config.MissingSourceCurrent,
"hourly",
config.MissingSourceNarrative,
config.MissingSourceAlerts,
config.MissingSourceDiscussion,
config.MissingSourceWeatherStory,
sourceSPCConvectiveOutlooks,
}
gotSources := make([]string, 0, len(got.bundle.Sources))
for _, source := range got.bundle.Sources {
gotSources = append(gotSources, source.Name)
}
if strings.Join(gotSources, ",") != strings.Join(wantSources, ",") {
t.Fatalf("source order = %v, want %v", gotSources, wantSources)
}
case <-time.After(time.Second):
t.Fatal("FetchBundle() did not finish after all source responses were released")
}
}
func TestFetchBundleReportsConcurrentFailuresInSourceOrder(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusBadRequest, body: `invalid hourly request`},
"/forecast/narrative": {status: http.StatusBadRequest, body: `invalid narrative request`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want source error")
}
if !strings.Contains(err.Error(), "/forecast/hourly") {
t.Fatalf("error = %q, want the earlier hourly source failure", err.Error())
}
if !containsPath(requested, "/forecast/narrative") {
t.Fatalf("requested paths = %v, want independent narrative request", requested)
}
}
func TestFetchBundleCancelsConcurrentSourceRequests(t *testing.T) {
paths := []string{
"/observations",
"/forecast/hourly",
"/forecast/narrative",
"/alerts/active",
"/discussion",
"/weatherstories/latest",
convectiveOutlooksEndpoint,
}
started := make(chan string, len(paths))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == currentConditionsEndpoint {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
}
return
}
for _, path := range paths {
if r.URL.Path == path {
started <- path
<-r.Context().Done()
return
}
}
http.NotFound(w, r)
}))
defer server.Close()
client := newTestClient(t, server.URL+"/", nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := make(chan error, 1)
go func() {
_, err := client.FetchBundle(ctx)
result <- err
}()
for range paths {
select {
case <-started:
case <-time.After(time.Second):
cancel()
t.Fatal("not all independent requests started before cancellation")
}
}
cancel()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("FetchBundle() error = %v, want context cancellation", err)
}
case <-time.After(time.Second):
t.Fatal("FetchBundle() did not return after cancellation")
}
}
func TestFetchBundleRejectsInvalidHourlyPrecipitationProbability(t *testing.T) {
for _, probability := range []string{"-1", "101"} {
t.Run(probability, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z","probabilityOfPrecipitationPercent":` + probability + `}]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil || !strings.Contains(err.Error(), "invalid precipitation probability") {
t.Fatalf("FetchBundle() error = %v, want invalid precipitation probability", err)
}
})
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
var requested []string
server := fixtureServer(t, nil, &requested)
@@ -114,9 +331,15 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
t.Fatalf("request %q missing units=us", rawURL)
}
if strings.HasPrefix(rawURL, "/forecast/") {
if !strings.Contains(rawURL, "precision=1") || !strings.Contains(rawURL, "tz=America%2FChicago") {
if !strings.Contains(rawURL, "precision=0") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("forecast request %q missing precision or tz", rawURL)
}
continue
}
if rawURL == defaultWarmupEndpoint || strings.HasPrefix(rawURL, defaultWarmupEndpoint+"?") || strings.HasPrefix(rawURL, "/observations?") {
if !strings.Contains(rawURL, "precision=0") {
t.Fatalf("request %q missing precision=0", rawURL)
}
}
}
}
@@ -167,8 +390,9 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
}
func TestHTTPErrorIsActionable(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
"/forecast/hourly": {status: http.StatusBadGateway, body: marker + strings.Repeat("x", 4096)},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
@@ -176,15 +400,282 @@ func TestHTTPErrorIsActionable(t *testing.T) {
if err == nil {
t.Fatal("FetchBundle() error = nil, want HTTP error")
}
if !strings.Contains(err.Error(), "/conditions/current") || !strings.Contains(err.Error(), "502") {
if !strings.Contains(err.Error(), "/forecast/hourly") || !strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want endpoint and status", err.Error())
}
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
}
func TestWarmupRetriesBeforeFetchBundle(t *testing.T) {
var requested []string
var warmupCalls int
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
warmupCalls++
if warmupCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("vpn waking up"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "current.json"))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Current == nil {
t.Fatal("Current = nil, want successful fetch after warmup retry")
}
if warmupCalls != 2 {
t.Fatalf("conditions/current calls = %d, want failed and successful warmup attempts", warmupCalls)
}
if len(requested) < 2 || !containsPath(requested[:2], defaultWarmupEndpoint) {
t.Fatalf("initial requests = %v, want warmup endpoint retries", requested)
}
}
func TestWarmupFailureStopsBeforeSourceFetches(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusBadGateway, body: `vpn unavailable`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup failure")
}
if !strings.Contains(err.Error(), "warm up weather API") ||
!strings.Contains(err.Error(), defaultWarmupEndpoint) ||
!strings.Contains(err.Error(), "2 attempts") ||
!strings.Contains(err.Error(), "502") {
t.Fatalf("error = %q, want warmup endpoint, attempts, and status", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 2 {
t.Fatalf("warmup requests = %d, want 2; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestWarmupDoesNotRetryPermanentStatus(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: `not found`},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("FetchBundle() error = %v, want non-retryable warmup status", err)
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want 1; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestWarmupErrorDiagnosticsRedactResponseBody(t *testing.T) {
const marker = "upstream-secret-marker"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {status: http.StatusNotFound, body: marker + strings.Repeat("x", 4096)},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want warmup error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), "404") {
t.Fatalf("error = %q, want warmup endpoint and status", err.Error())
}
if strings.Contains(err.Error(), marker) {
t.Fatalf("error = %q, must not contain upstream response text", err.Error())
}
}
func TestFetchAcceptsResponseAtBodyLimit(t *testing.T) {
body := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes))
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
if _, err := client.FetchBundle(context.Background()); err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
}
func TestFetchRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
var narrativeCalls int
oversizedBody := paddedJSON(t, `{"data":null}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/narrative": {handler: func(w http.ResponseWriter, r *http.Request) {
narrativeCalls++
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized response error")
}
if !strings.Contains(err.Error(), "/forecast/narrative") || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want endpoint and response limit", err.Error())
}
if narrativeCalls != 1 {
t.Fatalf("narrative calls = %d, want no retry", narrativeCalls)
}
if !containsPath(requested, "/alerts/active") {
t.Fatalf("requested paths = %v, want independent source requests despite narrative failure", requested)
}
}
func TestWarmupRejectsOversizedResponseWithoutRetry(t *testing.T) {
var requested []string
oversizedBody := paddedJSON(t, `{"data":{}}`, int(maxResponseBodyBytes)) + "x"
server := fixtureServer(t, map[string]handlerOverride{
defaultWarmupEndpoint: {handler: func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(oversizedBody))
}},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
client.warmupAttempts = 2
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want oversized warmup response error")
}
if !strings.Contains(err.Error(), defaultWarmupEndpoint) || !strings.Contains(err.Error(), errResponseBodyTooLarge.Error()) {
t.Fatalf("error = %q, want warmup endpoint and response limit", err.Error())
}
if got := countPath(requested, defaultWarmupEndpoint); got != 1 {
t.Fatalf("warmup requests = %d, want no retry; all requests = %v", got, requested)
}
if containsPath(requested, "/observations") {
t.Fatalf("requested paths = %v, want warmup failure before source fetches", requested)
}
}
func TestFetchRetriesRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if hourlyCalls == 1 {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
return
}
http.ServeFile(w, r, filepath.Join("testdata", "hourly.json"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Hourly == nil {
t.Fatal("Hourly = nil, want successful fetch after retry")
}
if hourlyCalls != 2 {
t.Fatalf("hourly calls = %d, want 2", hourlyCalls)
}
}
func TestFetchDoesNotRetryNonRetryableStatus(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("not found"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want non-retryable status error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestNewValidatesWeatherAPIBaseURLSchemeWithoutRequests(t *testing.T) {
requests := 0
httpClient := &http.Client{Transport: roundTripperFunc(func(*http.Request) (*http.Response, error) {
requests++
return nil, errors.New("unexpected request")
})}
tests := []struct {
name string
baseURL string
wantErr string
}{
{name: "local HTTP", baseURL: "http://127.0.0.1:8080/weather/"},
{name: "local HTTPS", baseURL: "https://127.0.0.1:8443/weather/"},
{name: "unsupported scheme", baseURL: "ftp://weather.example.test/", wantErr: "weather_api.base_url must use http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := testConfig(tt.baseURL)
_, err := New(cfg, WithHTTPClient(httpClient))
if tt.wantErr == "" {
if err != nil {
t.Fatalf("New() error = %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("New() error = %v, want %q", err, tt.wantErr)
}
})
}
if requests != 0 {
t.Fatalf("HTTP requests = %d, want none", requests)
}
}
func TestFetchDoesNotRetryMalformedEnvelope(t *testing.T) {
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`not-json`))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want envelope decode error")
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want no retry", hourlyCalls)
}
}
func TestRequiredHourlyForecast(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
_, err := client.FetchBundle(context.Background())
@@ -194,6 +685,69 @@ func TestRequiredHourlyForecast(t *testing.T) {
if !strings.Contains(err.Error(), "hourly forecast data") {
t.Fatalf("error = %q, want hourly context", err.Error())
}
if got := countPath(requested, "/forecast/hourly"); got != 1 {
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
}
}
func TestRequiredHourlyForecastValidatesPeriodBounds(t *testing.T) {
tests := []struct {
name string
body string
wantErr bool
}{
{
name: "valid period",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T14:00:00Z"}]}}`,
},
{
name: "missing start",
body: `{"data":{"periods":[{"endTime":"2026-05-29T14:00:00Z"}]}}`,
wantErr: true,
},
{
name: "missing end",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
{
name: "empty range",
body: `{"data":{"periods":[{"startTime":"2026-05-29T13:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
{
name: "reversed range",
body: `{"data":{"periods":[{"startTime":"2026-05-29T14:00:00Z","endTime":"2026-05-29T13:00:00Z"}]}}`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var requested []string
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {status: http.StatusOK, body: tt.body},
}, &requested)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "hourly forecast") || !strings.Contains(err.Error(), "time bounds") {
t.Fatalf("FetchBundle() error = %v, want hourly time-bounds failure", err)
}
if got := countPath(requested, "/forecast/hourly"); got != 1 {
t.Fatalf("hourly requests = %d, want no retry; all requests = %v", got, requested)
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.Hourly == nil || len(bundle.Hourly.Periods) != 1 {
t.Fatalf("Hourly = %#v, want accepted hourly period", bundle.Hourly)
}
})
}
}
func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
@@ -387,6 +941,44 @@ func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
}
}
func TestEmptyWeatherStoryUsesPolicy(t *testing.T) {
for _, tt := range []struct {
name string
policy config.MissingSourcePolicy
wantErr bool
}{
{name: "warn", policy: config.MissingSourceWarn},
{name: "error", policy: config.MissingSourceError, wantErr: true},
} {
t.Run(tt.name, func(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": tt.policy,
})
bundle, err := client.FetchBundle(context.Background())
if tt.wantErr {
if err == nil || !strings.Contains(err.Error(), "weather story has no usable content") {
t.Fatalf("FetchBundle() error = %v, want unusable weather story error", err)
}
return
}
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.WeatherStory != nil {
t.Fatalf("WeatherStory = %#v, want nil for empty source", bundle.WeatherStory)
}
source := sourceByName(t, bundle.Sources, "weather_story")
if !source.Missing || len(source.Warnings) != 1 || source.Warnings[0].Code != "malformed_source" {
t.Fatalf("weather_story source = %#v, want malformed source warning", source)
}
})
}
}
func TestContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
@@ -402,6 +994,43 @@ func TestContextCancellation(t *testing.T) {
}
}
func TestRetryDelayRespectsContextCancellation(t *testing.T) {
var cancel context.CancelFunc
var hourlyCalls int
server := fixtureServer(t, map[string]handlerOverride{
"/forecast/hourly": {handler: func(w http.ResponseWriter, r *http.Request) {
hourlyCalls++
if cancel != nil {
cancel()
}
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte("temporary upstream failure"))
}},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
client.fetchRetryDelay = time.Hour
ctx, cancelFunc := context.WithCancel(context.Background())
cancel = cancelFunc
defer cancelFunc()
start := time.Now()
_, err := client.FetchBundle(ctx)
elapsed := time.Since(start)
if err == nil {
t.Fatal("FetchBundle() error = nil, want cancellation during retry delay")
}
if !strings.Contains(err.Error(), context.Canceled.Error()) {
t.Fatalf("error = %q, want context cancellation", err.Error())
}
if elapsed > time.Second {
t.Fatalf("FetchBundle() elapsed = %s, want prompt cancellation", elapsed)
}
if hourlyCalls != 1 {
t.Fatalf("hourly calls = %d, want retry delay cancellation before second attempt", hourlyCalls)
}
}
func TestHTTPTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond)
@@ -414,45 +1043,25 @@ func TestHTTPTimeout(t *testing.T) {
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
_, err = client.FetchBundle(context.Background())
if err == nil {
t.Fatal("FetchBundle() error = nil, want timeout error")
}
if !strings.Contains(err.Error(), "/observations") {
if !strings.Contains(err.Error(), defaultWarmupEndpoint) {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
func TestSaveBundle(t *testing.T) {
server := fixtureServer(t, nil, nil)
client := newTestClient(t, server.URL+"/", nil)
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
path := filepath.Join(t.TempDir(), "nested", "bundle.json")
if err := SaveBundle(path, bundle); err != nil {
t.Fatalf("SaveBundle() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read saved bundle: %v", err)
}
if !strings.Contains(string(data), `"hourly"`) {
t.Fatalf("saved bundle missing hourly source:\n%s", string(data))
}
}
type handlerOverride struct {
status int
body string
handler http.HandlerFunc
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
fixtures := map[string]string{
var weatherFixtureFiles = map[string]string{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
@@ -461,22 +1070,38 @@ func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
func serveWeatherFixture(w http.ResponseWriter, r *http.Request) bool {
name, ok := weatherFixtureFiles[r.URL.Path]
if !ok {
return false
}
http.ServeFile(w, r, filepath.Join("testdata", name))
return true
}
func fixtureServer(t *testing.T, overrides map[string]handlerOverride, requested *[]string) *httptest.Server {
t.Helper()
var requestedMu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
requestedMu.Lock()
*requested = append(*requested, r.URL.String())
requestedMu.Unlock()
}
if override, ok := overrides[r.URL.Path]; ok {
if override.handler != nil {
override.handler(w, r)
return
}
w.WriteHeader(override.status)
_, _ = w.Write([]byte(override.body))
return
}
name, ok := fixtures[r.URL.Path]
if !ok {
if !serveWeatherFixture(w, r) {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, filepath.Join("testdata", name))
}))
t.Cleanup(server.Close)
return server
@@ -492,6 +1117,8 @@ func newTestClient(t *testing.T, baseURL string, sourcePolicies map[string]confi
if err != nil {
t.Fatalf("New() error = %v", err)
}
client.warmupDelay = 0
client.fetchRetryDelay = 0
return client
}
@@ -505,6 +1132,14 @@ func fixedNow() time.Time {
return time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
}
func paddedJSON(t *testing.T, value string, size int) string {
t.Helper()
if len(value) > size {
t.Fatalf("JSON value length = %d, exceeds requested size %d", len(value), size)
}
return value + strings.Repeat(" ", size-len(value))
}
func containsPath(requested []string, path string) bool {
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
@@ -514,6 +1149,16 @@ func containsPath(requested []string, path string) bool {
return false
}
func countPath(requested []string, path string) int {
var count int
for _, rawURL := range requested {
if strings.HasPrefix(rawURL, path+"?") || rawURL == path {
count++
}
}
return count
}
func sourceByName(t *testing.T, sources []weatherdata.Source, name string) weatherdata.Source {
t.Helper()
for _, source := range sources {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,361 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestRunBatchDetailedKeepsSuccessfulOutputAndSkipsNotificationAfterPartialFailure(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{failedPrompt: generationDefinitionForPrompt("weather.tomorrow_generated_text").PromptID}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 1 || result.Canceled != 0 || result.Notification == nil || result.Notification.Status != "skipped" || notifier.batchCalls != 0 {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "failed" || result.Reports[1].OutputPath != "" {
t.Fatalf("report results = %#v", result.Reports)
}
if data, readErr := os.ReadFile(result.Reports[0].OutputPath); readErr != nil || len(data) == 0 {
t.Fatalf("successful output = %q, error = %v", data, readErr)
}
}
func TestRunBatchDetailedStopsAfterReportCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{cancelBeforeReturn: cancel}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 0 || result.Canceled != 2 || executor.executeCalls != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/executor/notifier = %#v/%v/%#v/%#v", result, err, executor, notifier)
}
for _, item := range result.Reports {
if item.Status != "canceled" || item.OutputPath != "" {
t.Fatalf("canceled report = %#v", item)
}
}
}
func TestRunBatchDetailedPreservesIndependentFailureDuringCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
executor := &generationExecutor{
executeErr: errors.New("independent report failure"),
beforeExecute: func(promptexec.ExecuteRequest) {
cancel()
},
}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: executor, Notifier: notifier,
})
if !errors.Is(err, context.Canceled) || result == nil || result.Total != 2 || result.Succeeded != 0 || result.Failed != 1 || result.Canceled != 1 || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if result.Reports[0].Status != "failed" || result.Reports[0].Error == "" || result.Reports[1].Status != "canceled" {
t.Fatalf("report results = %#v", result.Reports)
}
}
func TestNotifyBatchSkipsCancellationObservedAfterReportsComplete(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
notifier := &generationNotifier{}
result := notifyBatch(batchNotificationInput{
ctx: ctx, cfg: generationDistributorConfig(), batch: BatchMorning,
runID: "run-id", startedAt: generationTime("2026-05-29T08:30:00-05:00"),
result: &BatchResult{Total: 1, Succeeded: 1, Reports: []BatchReportResult{{Status: "succeeded"}}},
notifier: notifier,
})
if result == nil || result.Status != "skipped" || result.Reason != "batch canceled" || notifier.batchCalls != 0 {
t.Fatalf("notifyBatch() result/notifier = %#v/%#v", result, notifier)
}
}
func TestRunBatchDetailedRetainsPublishedReportBeforeCancellation(t *testing.T) {
for _, cause := range []error{context.Canceled, context.DeadlineExceeded} {
t.Run(cause.Error(), func(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
ctx := &publicationGateContext{Context: context.Background(), err: cause, afterChecks: 4}
result, err := RunBatchDetailed(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if !errors.Is(err, cause) || result == nil || result.Total != 2 || result.Succeeded != 1 || result.Failed != 0 || result.Canceled != 1 || len(result.Reports) != 2 || result.Reports[0].Status != "succeeded" || result.Reports[0].OutputPath == "" || result.Reports[1].Status != "canceled" || result.Reports[1].OutputPath != "" || notifier.batchCalls != 0 || result.Notification == nil || result.Notification.Status != "skipped" || result.Notification.Reason != "batch canceled" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if _, statErr := os.Stat(result.Reports[0].OutputPath); statErr != nil {
t.Fatalf("published report %q: %v", result.Reports[0].OutputPath, statErr)
}
})
}
}
func TestRunBatchPreservesCancellationCause(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
err := RunBatch(ctx, BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel}, Notifier: &generationNotifier{},
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("RunBatch() error = %v", err)
}
}
func TestRunBatchDetailedNotifiesOnlyAfterAllOutputsExist(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if err != nil || result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || notifier.batchCalls != 1 || result.Notification == nil || result.Notification.Status != "succeeded" {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
if len(notifier.batchRequest.Files) < 2 || len(notifier.batchRequest.IncludedReports) != 2 {
t.Fatalf("batch notification = %#v", notifier.batchRequest)
}
if result.Reports[0].OutputPath == result.Reports[1].OutputPath {
t.Fatalf("batch reports share output path %q", result.Reports[0].OutputPath)
}
for _, file := range notifier.batchRequest.Files {
if filepath.Dir(file.SourcePath) != outputDir || file.BundlePath == "" {
t.Fatalf("notification file = %#v", file)
}
if _, statErr := os.Stat(file.SourcePath); statErr != nil {
t.Fatalf("notification source %q: %v", file.SourcePath, statErr)
}
}
}
func TestRunBatchDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
outputDir := t.TempDir()
cfg := generationDistributorConfig()
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result != nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 || notifier.batchCalls != 0 {
t.Fatalf("RunBatchDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
entries, readErr := os.ReadDir(outputDir)
if readErr != nil || len(entries) != 0 {
t.Fatalf("output directory entries/error = %v/%v", entries, readErr)
}
}
func TestRunBatchDetailedUsesDefaultAndConfiguredOutputDirectories(t *testing.T) {
tests := []struct {
name string
directory func(t *testing.T, workingDir string) string
wantDir func(t *testing.T, workingDir string, configuredDir string) string
}{
{
name: "working directory default",
directory: func(_ *testing.T, _ string) string {
return ""
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return workingDir
},
},
{
name: "absolute directory",
directory: func(t *testing.T, _ string) string {
return filepath.Join(t.TempDir(), "reports")
},
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
return configuredDir
},
},
{
name: "relative directory",
directory: func(_ *testing.T, _ string) string {
return "configured/../reports"
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return filepath.Join(workingDir, "reports")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workingDir := t.TempDir()
configuredDir := tt.directory(t, workingDir)
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredDir
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
notifier := &generationNotifier{}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
wantDir := tt.wantDir(t, workingDir, configuredDir)
if err != nil || result == nil || result.Succeeded != len(result.Reports) || notifier.batchCalls != 1 {
t.Fatalf("RunBatchDetailed() result/error/notifier = %#v/%v/%#v", result, err, notifier)
}
for _, item := range result.Reports {
if filepath.Dir(item.OutputPath) != wantDir {
t.Fatalf("report output %q, want directory %q", item.OutputPath, wantDir)
}
}
for _, file := range notifier.batchRequest.Files {
if filepath.Dir(file.SourcePath) != wantDir {
t.Fatalf("notification source %q, want directory %q", file.SourcePath, wantDir)
}
}
})
}
}
func TestRunBatchDetailedExplicitOutputDirectoryIgnoresConfiguredDirectory(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
explicitDir := t.TempDir()
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: cfg, Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: explicitDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{},
})
if err != nil || result == nil || result.Succeeded != len(result.Reports) {
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
}
for _, item := range result.Reports {
if filepath.Dir(item.OutputPath) != explicitDir {
t.Fatalf("report output %q, want directory %q", item.OutputPath, explicitDir)
}
}
}
func TestRunBatchDetailedPreflightsAllOutputPaths(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
if err := os.Mkdir(filepath.Join(outputDir, "tomorrow.md"), 0o700); err != nil {
t.Fatal(err)
}
todayPath := filepath.Join(outputDir, "today.md")
const previousReport = "previous report"
if err := os.WriteFile(todayPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
executor := &generationExecutor{}
promptInspectedBeforeCollection := false
collector := &generationCollector{
bundle: &bundle,
beforeRun: func() {
promptInspectedBeforeCollection = executor.promptInspections > 0
},
}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: collector, Executor: executor, Notifier: &generationNotifier{},
})
if err == nil || result != nil || !collector.called || !promptInspectedBeforeCollection || executor.called {
t.Fatalf("RunBatchDetailed() result/error/collection/inspection/execution = %#v/%v/%t/%t/%t", result, err, collector.called, promptInspectedBeforeCollection, executor.called)
}
if data, readErr := os.ReadFile(todayPath); readErr != nil || string(data) != previousReport {
t.Fatalf("earlier output = %q, error = %v", data, readErr)
}
if info, statErr := os.Stat(filepath.Join(outputDir, "tomorrow.md")); statErr != nil || !info.IsDir() {
t.Fatalf("blocked output info/error = %#v/%v", info, statErr)
}
}
func TestRunBatchDetailedRetainsReportCountsWhenNotificationFails(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
outputDir := t.TempDir()
notifier := &generationNotifier{batchErr: errors.New("distributor unavailable")}
result, err := RunBatchDetailed(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: outputDir,
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
if err != nil || result == nil || result.Total != len(result.Reports) || result.Succeeded != len(result.Reports) || result.Failed != 0 || result.Notification == nil || result.Notification.Status != "failed" {
t.Fatalf("RunBatchDetailed() result/error = %#v/%v", result, err)
}
for _, item := range result.Reports {
if item.Status != "succeeded" || item.OutputPath == "" {
t.Fatalf("report result = %#v", item)
}
if _, statErr := os.Stat(item.OutputPath); statErr != nil {
t.Fatalf("published output %q: %v", item.OutputPath, statErr)
}
}
}
func TestRunBatchReturnsNotificationFailureWithoutReportFailureWording(t *testing.T) {
bundle := generationBundle(t)
bundle.Hourly.Periods = bundle.Hourly.Periods[:1]
err := RunBatch(context.Background(), BatchRequest{
Config: generationDistributorConfig(), Batch: BatchMorning,
Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputDir: t.TempDir(),
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: &generationNotifier{batchErr: errors.New("distributor unavailable")},
})
var batchErr BatchError
if !errors.As(err, &batchErr) || batchErr.Result == nil || batchErr.Result.Failed != 0 || batchErr.Result.Notification == nil || batchErr.Result.Notification.Status != "failed" || !strings.Contains(err.Error(), "notification failed") || strings.Contains(err.Error(), "reports failed") {
t.Fatalf("RunBatch() error/result = %v/%#v", err, batchErr.Result)
}
}
func generationDistributorConfig() config.Config {
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
return cfg
}

View File

@@ -0,0 +1,318 @@
package app
import (
"context"
"fmt"
"path/filepath"
"time"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
const runIDTimestampLayout = "20060102T150405.000000000Z"
type batchNotificationIdentity struct {
PipelineID string
BundleID string
IdempotencyKey string
}
type batchNotificationRequest struct {
Batch BatchKind
RunID string
PipelineID string
BundleID string
IdempotencyKey string
Files []batchNotificationFile
IncludedReports []BatchNotificationReport
CreatedAt time.Time
}
type batchNotificationFile struct {
ReportID report.ID
RunID string
SourcePath string
BundlePath string
}
type batchNotifier interface {
NotifyBatch(context.Context, batchNotificationRequest) (*NotificationResult, error)
}
type batchNotificationInput struct {
ctx context.Context
cancellation error
cfg config.Config
batch BatchKind
runID string
startedAt time.Time
result *BatchResult
planned []plannedBatchReport
notifier Notifier
}
func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
}
func notifyBatch(input batchNotificationInput) *BatchNotificationResult {
if !input.cfg.Notify.Distributor.Enabled {
return nil
}
if !input.cfg.Notify.Distributor.Batch.Enabled {
return nil
}
if input.result == nil {
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
}
if input.cancellation != nil || batchContextCancellationCause(input.ctx) != nil || input.result.Canceled > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "batch canceled",
}
}
if input.result.Failed > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
}
}
req, err := buildBatchNotificationRequest(input.cfg, input.batch, input.runID, input.startedAt, input.result.Reports, input.planned)
if err != nil {
return failedBatchNotificationResult(batchNotificationRequest{}, err)
}
batchNotifier, err := resolveBatchNotifier(input.cfg, input.notifier)
if err != nil {
return failedBatchNotificationResult(req, err)
}
notification, notifyErr := batchNotifier.NotifyBatch(input.ctx, req)
wrappedErr := notifyErr
if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", input.batch, input.runID, req.BundleID, notifyErr)
}
batchResult := batchNotificationResult(req, notification)
if wrappedErr != nil {
batchResult.Status = "failed"
batchResult.Error = safeDistributorNotificationFailure(wrappedErr)
return batchResult
}
return batchResult
}
func resolveBatchNotifier(cfg config.Config, notifier Notifier) (batchNotifier, error) {
if notifier != nil {
if batchNotifier, ok := notifier.(batchNotifier); ok {
return batchNotifier, nil
}
return nil, fmt.Errorf("batch distributor notifier is required")
}
return distributorNotifier{
client: distributoradapter.New(cfg.Notify.Distributor),
}, nil
}
func buildBatchNotificationRequest(cfg config.Config, batch BatchKind, runID string, startedAt time.Time, reports []BatchReportResult, planned []plannedBatchReport) (batchNotificationRequest, error) {
if len(reports) == 0 {
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one report")
}
identity, err := renderBatchNotificationIdentity(cfg, batch, runID, startedAt)
if err != nil {
return batchNotificationRequest{}, err
}
if identity.PipelineID == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification pipeline id is required")
}
if identity.BundleID == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification bundle id is required")
}
if identity.IdempotencyKey == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification idempotency key is required for bundle %q", identity.BundleID)
}
plannedByRunID, err := plannedReportsByRunID(planned)
if err != nil {
return batchNotificationRequest{}, err
}
req := batchNotificationRequest{
Batch: batch,
RunID: runID,
PipelineID: identity.PipelineID,
BundleID: identity.BundleID,
IdempotencyKey: identity.IdempotencyKey,
CreatedAt: startedAt,
}
seenBundlePaths := map[string]batchNotificationFile{}
for _, item := range reports {
plannedReport, ok := plannedByRunID[item.RunID]
if !ok {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q has no matching planned report", item.ReportID, item.RunID)
}
if item.ReportID != plannedReport.Resolved.Definition.ID {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q does not match planned report %q", item.ReportID, item.RunID, plannedReport.Resolved.Definition.ID)
}
if item.OutputPath == "" {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q is missing output path", item.ReportID, item.RunID)
}
values, err := distributorTemplateValuesForReport(cfg, plannedReport.Resolved, item.RunID, filepath.Base(item.OutputPath))
if err != nil {
return batchNotificationRequest{}, fmt.Errorf("batch notification report %q run %q source path %q: %w", item.ReportID, item.RunID, item.OutputPath, err)
}
bundlePaths, err := renderDistributorReportBundlePaths(cfg, plannedReport.Resolved, item.RunID, item.OutputPath, values)
if err != nil {
return batchNotificationRequest{}, err
}
included := BatchNotificationReport{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.OutputPath,
BundlePaths: append([]string(nil), bundlePaths...),
}
for _, bundlePath := range bundlePaths {
file := batchNotificationFile{
ReportID: item.ReportID,
RunID: item.RunID,
SourcePath: item.OutputPath,
BundlePath: bundlePath,
}
if previous, ok := seenBundlePaths[bundlePath]; ok {
return batchNotificationRequest{}, fmt.Errorf("batch notification duplicate bundle path %q for report %q run %q source path %q; already used by report %q run %q source path %q", bundlePath, item.ReportID, item.RunID, item.OutputPath, previous.ReportID, previous.RunID, previous.SourcePath)
}
seenBundlePaths[bundlePath] = file
req.Files = append(req.Files, file)
}
req.IncludedReports = append(req.IncludedReports, included)
}
if len(req.Files) == 0 {
return batchNotificationRequest{}, fmt.Errorf("batch notification requires at least one file mapping")
}
return req, nil
}
func plannedReportsByRunID(planned []plannedBatchReport) (map[string]plannedBatchReport, error) {
byRunID := make(map[string]plannedBatchReport, len(planned))
for _, item := range planned {
runID := item.Resolved.Metadata().RunID
if runID == "" {
return nil, fmt.Errorf("planned report %q has empty run id", item.Resolved.Definition.ID)
}
if previous, ok := byRunID[runID]; ok {
return nil, fmt.Errorf("planned reports %q and %q share run id %q", previous.Resolved.Definition.ID, item.Resolved.Definition.ID, runID)
}
byRunID[runID] = item
}
return byRunID, nil
}
func batchDistributorUploadRequest(req batchNotificationRequest) distributoradapter.UploadRequest {
files := make([]distributoradapter.UploadFile, 0, len(req.Files))
for _, file := range req.Files {
files = append(files, distributoradapter.UploadFile{
SourcePath: file.SourcePath,
BundlePath: file.BundlePath,
})
}
return distributoradapter.UploadRequest{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Files: files,
CreatedAt: req.CreatedAt,
}
}
func batchNotificationResult(req batchNotificationRequest, result *NotificationResult) *BatchNotificationResult {
notification := &BatchNotificationResult{
Status: "unknown",
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
IncludedReports: append([]BatchNotificationReport(nil), req.IncludedReports...),
}
if result != nil {
notification.Status = result.Status
notification.RunID = result.RunID
if result.PipelineID != "" {
notification.PipelineID = result.PipelineID
}
if result.BundleID != "" {
notification.BundleID = result.BundleID
}
if result.IdempotencyKey != "" {
notification.IdempotencyKey = result.IdempotencyKey
}
if result.Error != "" {
notification.Error = safeDistributorRunError(result.Error)
}
}
if notification.Status == "" {
notification.Status = "unknown"
}
return notification
}
func failedBatchNotificationResult(req batchNotificationRequest, err error) *BatchNotificationResult {
notification := batchNotificationResult(req, nil)
notification.Status = "failed"
if err != nil {
notification.Error = safeDistributorNotificationFailure(err)
}
return notification
}
func safeDistributorNotificationFailure(err error) string {
if err == nil {
return ""
}
return "distributor notification failed"
}
func renderBatchNotificationIdentity(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (batchNotificationIdentity, error) {
values, err := batchNotificationTemplateValues(cfg, batch, runID, startedAt)
if err != nil {
return batchNotificationIdentity{}, err
}
bundleID, err := config.RenderDistributorBatchBundleID(cfg.Notify.Distributor.Batch.BundleIDTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
values.BundleID = bundleID
pipelineID, err := config.RenderDistributorBatchPipelineID(cfg.Notify.Distributor.Batch.PipelineIDTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
idempotencyKey, err := config.RenderDistributorBatchIdempotencyKey(cfg.Notify.Distributor.Batch.IdempotencyKeyTemplate, values)
if err != nil {
return batchNotificationIdentity{}, err
}
return batchNotificationIdentity{
PipelineID: pipelineID,
BundleID: bundleID,
IdempotencyKey: idempotencyKey,
}, nil
}
func batchNotificationTemplateValues(cfg config.Config, batch BatchKind, runID string, startedAt time.Time) (config.DistributorBatchTemplateValues, error) {
location, err := timeutil.LoadLocation(cfg.WeatherAPI.Timezone)
if err != nil {
return config.DistributorBatchTemplateValues{}, fmt.Errorf("load batch notification timezone: %w", err)
}
return config.DistributorBatchTemplateValues{
LocationID: cfg.Location.ID,
Batch: string(batch),
BatchRunID: runID,
BatchStartedDate: startedAt.In(location).Format(timeutil.DateLayout),
}, nil
}

135
internal/app/batch_plan.go Normal file
View File

@@ -0,0 +1,135 @@
package app
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type plannedBatchReport struct {
Resolved report.Resolved
OutputPath string
}
func planBatchRun(req BatchRequest, now time.Time, collection collect.Result) ([]plannedBatchReport, error) {
location, err := timeutil.LoadLocation(req.Config.WeatherAPI.Timezone)
if err != nil {
return nil, err
}
batch, err := report.BatchForCommandName(string(req.Batch))
if err != nil {
return nil, err
}
registry, err := reportRegistry(req.Config)
if err != nil {
return nil, err
}
resolveReq := report.ResolveRequest{
Now: now,
Location: location,
}
var planned []plannedBatchReport
switch batch {
case report.Morning:
planned, err = appendPlannedReport(planned, registry, report.Today, resolveReq)
if err != nil {
return nil, err
}
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
if err != nil {
return nil, err
}
case report.Evening:
planned, err = appendPlannedReport(planned, registry, report.Tomorrow, resolveReq)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown batch %q", batch)
}
var hourly *weatherdata.ForecastRun
if collection.Bundle != nil {
hourly = collection.Bundle.Hourly
}
for _, date := range eligibleDailyDates(hourly, now, location) {
dailyReq := resolveReq
dailyReq.Date = date
planned, err = appendPlannedReport(planned, registry, report.Daily, dailyReq)
if err != nil {
return nil, err
}
}
return planned, nil
}
func appendPlannedReport(planned []plannedBatchReport, registry report.Registry, id report.ID, req report.ResolveRequest) ([]plannedBatchReport, error) {
resolved, err := registry.Resolve(id, req)
if err != nil {
return nil, err
}
return append(planned, plannedBatchReport{Resolved: resolved}), nil
}
func eligibleDailyDates(hourly *weatherdata.ForecastRun, now time.Time, location *time.Location) []time.Time {
if hourly == nil || location == nil || hourly.Product != "hourly" || len(hourly.Periods) == 0 {
return nil
}
hourlyStarts := make(map[time.Time]struct{}, len(hourly.Periods))
var maxLocalDate time.Time
for _, period := range hourly.Periods {
if !isHourlyPeriod(period) {
continue
}
start := period.StartTime
hourlyStarts[instantKey(start)] = struct{}{}
localDate := localDateStart(start, location)
if maxLocalDate.IsZero() || localDate.After(maxLocalDate) {
maxLocalDate = localDate
}
}
if len(hourlyStarts) == 0 || maxLocalDate.IsZero() {
return nil
}
startDate := localDateStart(now.In(location).AddDate(0, 0, 2), location)
var dates []time.Time
for candidate := startDate; !candidate.After(maxLocalDate); candidate = candidate.AddDate(0, 0, 1) {
if hasFullHourlyCoverage(candidate, location, hourlyStarts) {
dates = append(dates, candidate)
}
}
return dates
}
func isHourlyPeriod(period weatherdata.ForecastPeriod) bool {
if period.StartTime.IsZero() || period.EndTime.IsZero() {
return false
}
return period.EndTime.Equal(period.StartTime.Add(time.Hour))
}
func hasFullHourlyCoverage(date time.Time, location *time.Location, hourlyStarts map[time.Time]struct{}) bool {
day := timeutil.CivilDay(date, location)
for required := day.Start; required.Before(day.End); required = required.Add(time.Hour) {
if _, ok := hourlyStarts[instantKey(required)]; !ok {
return false
}
}
return true
}
func instantKey(value time.Time) time.Time {
return value.UTC()
}
func localDateStart(value time.Time, location *time.Location) time.Time {
local := value.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}

View File

@@ -0,0 +1,347 @@
package app
import (
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestPlanBatchRunMorningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Today, report.Tomorrow, report.Daily)
}
func TestPlanBatchRunEveningOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
assertPlannedReportIDs(t, planned, report.Tomorrow, report.Daily)
}
func TestPlanBatchRunDynamicDailyDatesStartAfterTomorrow(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-30", location)
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchMorning}, mustParse("2026-05-29T08:00:00-05:00"), collectionWithHourly(hourlyRun(periods...)))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 2 {
t.Fatalf("daily reports = %#v, want two future Daily reports", daily)
}
assertPlanningPeriod(t, daily[0].Resolved.ValidPeriod, "2026-05-31T00:00:00-05:00", "2026-06-01T00:00:00-05:00")
assertPlanningPeriod(t, daily[1].Resolved.ValidPeriod, "2026-06-01T00:00:00-05:00", "2026-06-02T00:00:00-05:00")
}
func TestPlanBatchRunUsesResolvedOutputNames(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
planned, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchEvening}, mustParse("2026-05-29T18:00:00-05:00"), collectionWithHourly(hourly))
if err != nil {
t.Fatalf("planBatchRun() error = %v", err)
}
daily := plannedDailyReports(planned)
if len(daily) != 1 {
t.Fatalf("daily reports = %#v, want one Daily report", daily)
}
outputName, err := daily[0].Resolved.OutputName()
if err != nil {
t.Fatalf("OutputName() error = %v", err)
}
if outputName != "daily-2026-05-31.md" {
t.Fatalf("Daily output name = %q, want date-qualified name", outputName)
}
outputName, err = planned[0].Resolved.OutputName()
if err != nil {
t.Fatalf("OutputName() error = %v", err)
}
if outputName != "tomorrow.md" {
t.Fatalf("Tomorrow output name = %q, want tomorrow.md", outputName)
}
}
func TestPlanBatchRunRejectsUnknownBatch(t *testing.T) {
_, err := planBatchRun(BatchRequest{Config: planningConfig(), Batch: BatchKind("hourly")}, mustParse("2026-05-29T08:00:00-05:00"), collect.Result{Bundle: &weatherdata.Bundle{}})
if err == nil || !strings.Contains(err.Error(), `unknown batch command "hourly"`) {
t.Fatalf("planBatchRun() error = %v, want unknown batch command", err)
}
}
func TestEligibleDailyDatesRequiresFullOrdinaryLocalDay(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fullDayPeriods(t, "2026-05-31", location)...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesMatchesFixedOffsetStartInstants(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
hourly := hourlyRun(fixedOffsetPeriods(t, fullDayPeriods(t, "2026-05-31", location))...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesSkipsDayWithMissingRequiredHour(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods[:12], periods[13:]...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location)
}
func TestEligibleDailyDatesSkipsPartialFinalDay(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods, partialDayPeriods(t, "2026-06-01", location, 12)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesStartsAfterTomorrow(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-29", location)
periods = append(periods, fullDayPeriods(t, "2026-05-30", location)...)
periods = append(periods, fullDayPeriods(t, "2026-05-31", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31")
}
func TestEligibleDailyDatesReturnsMultipleFutureDatesInOrder(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
periods := fullDayPeriods(t, "2026-05-31", location)
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-05-31", "2026-06-01")
}
func TestEligibleDailyDatesIgnoresNonHourlyAndInvalidPeriods(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
day := timeutil.CivilDay(mustParseLocalDate(t, "2026-05-31", location), location)
periods := []weatherdata.ForecastPeriod{
{StartTime: day.Start, EndTime: day.Start.Add(2 * time.Hour)},
{StartTime: day.Start.Add(time.Hour), EndTime: day.Start.Add(time.Hour)},
{StartTime: time.Time{}, EndTime: day.Start.Add(3 * time.Hour)},
}
periods = append(periods, fullDayPeriods(t, "2026-06-01", location)...)
hourly := hourlyRun(periods...)
got := eligibleDailyDates(hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location, "2026-06-01")
}
func TestEligibleDailyDatesUsesDSTCivilDayInstants(t *testing.T) {
location := mustLoadTestLocation(t, "America/New_York")
tests := []struct {
name string
now string
date string
}{
{
name: "spring forward",
now: "2026-03-06T08:00:00-05:00",
date: "2026-03-08",
},
{
name: "fall back",
now: "2026-10-30T08:00:00-04:00",
date: "2026-11-01",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hourly := hourlyRun(fullDayPeriods(t, tt.date, location)...)
got := eligibleDailyDates(hourly, mustParse(tt.now), location)
assertLocalDates(t, got, location, tt.date)
})
}
}
func TestEligibleDailyDatesReturnsNoneWithoutHourlyForecast(t *testing.T) {
location := mustLoadTestLocation(t, "America/Chicago")
fullDay := fullDayPeriods(t, "2026-05-31", location)
tests := []struct {
name string
hourly *weatherdata.ForecastRun
}{
{name: "nil run"},
{name: "empty periods", hourly: hourlyRun()},
{name: "non-hourly product", hourly: forecastRun("narrative", fullDay...)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := eligibleDailyDates(tt.hourly, mustParse("2026-05-29T08:00:00-05:00"), location)
assertLocalDates(t, got, location)
})
}
}
func hourlyRun(periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
return forecastRun("hourly", periods...)
}
func forecastRun(product string, periods ...weatherdata.ForecastPeriod) *weatherdata.ForecastRun {
return &weatherdata.ForecastRun{
Product: product,
Periods: periods,
}
}
func collectionWithHourly(hourly *weatherdata.ForecastRun) collect.Result {
return collect.Result{Bundle: &weatherdata.Bundle{Hourly: hourly}}
}
func planningConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone = "America/Chicago"
return cfg
}
func assertPlannedReportIDs(t *testing.T, got []plannedBatchReport, want ...report.ID) {
t.Helper()
gotIDs := make([]string, 0, len(got))
for _, item := range got {
gotIDs = append(gotIDs, string(item.Resolved.Definition.ID))
}
wantIDs := make([]string, 0, len(want))
for _, id := range want {
wantIDs = append(wantIDs, string(id))
}
if strings.Join(gotIDs, ",") != strings.Join(wantIDs, ",") {
t.Fatalf("planned report IDs = [%s], want [%s]", strings.Join(gotIDs, ","), strings.Join(wantIDs, ","))
}
}
func plannedDailyReports(planned []plannedBatchReport) []plannedBatchReport {
var daily []plannedBatchReport
for _, item := range planned {
if item.Resolved.Definition.ID == report.Daily {
daily = append(daily, item)
}
}
return daily
}
func fullDayPeriods(t *testing.T, date string, location *time.Location) []weatherdata.ForecastPeriod {
t.Helper()
day := timeutil.CivilDay(mustParseLocalDate(t, date, location), location)
var periods []weatherdata.ForecastPeriod
for start := day.Start; start.Before(day.End); start = start.Add(time.Hour) {
periods = append(periods, weatherdata.ForecastPeriod{
StartTime: start,
EndTime: start.Add(time.Hour),
})
}
return periods
}
func partialDayPeriods(t *testing.T, date string, location *time.Location, count int) []weatherdata.ForecastPeriod {
t.Helper()
periods := fullDayPeriods(t, date, location)
if count > len(periods) {
count = len(periods)
}
return periods[:count]
}
func fixedOffsetPeriods(t *testing.T, periods []weatherdata.ForecastPeriod) []weatherdata.ForecastPeriod {
t.Helper()
out := make([]weatherdata.ForecastPeriod, 0, len(periods))
for _, period := range periods {
start, err := time.Parse(time.RFC3339, period.StartTime.Format(time.RFC3339))
if err != nil {
t.Fatalf("parse fixed-offset start: %v", err)
}
end, err := time.Parse(time.RFC3339, period.EndTime.Format(time.RFC3339))
if err != nil {
t.Fatalf("parse fixed-offset end: %v", err)
}
out = append(out, weatherdata.ForecastPeriod{StartTime: start, EndTime: end})
}
return out
}
func assertLocalDates(t *testing.T, got []time.Time, location *time.Location, want ...string) {
t.Helper()
gotDates := make([]string, 0, len(got))
for _, date := range got {
gotDates = append(gotDates, date.In(location).Format(timeutil.DateLayout))
}
if strings.Join(gotDates, ",") != strings.Join(want, ",") {
t.Fatalf("eligibleDailyDates() = [%s], want [%s]", strings.Join(gotDates, ","), strings.Join(want, ","))
}
for _, date := range got {
day := timeutil.CivilDay(date, location)
if !date.Equal(day.Start) {
t.Fatalf("eligible date %s is not local civil day start %s", date, day.Start)
}
}
}
func assertPlanningPeriod(t *testing.T, period timeutil.Period, wantStart string, wantEnd string) {
t.Helper()
if !period.IsValid() {
t.Fatalf("period = %#v, want valid", period)
}
if got := period.Start.Format(time.RFC3339); got != wantStart {
t.Fatalf("Start = %s, want %s", got, wantStart)
}
if got := period.End.Format(time.RFC3339); got != wantEnd {
t.Fatalf("End = %s, want %s", got, wantEnd)
}
}
func mustLoadTestLocation(t *testing.T, name string) *time.Location {
t.Helper()
location, err := time.LoadLocation(name)
if err != nil {
t.Fatalf("LoadLocation(%q) error = %v", name, err)
}
return location
}
func mustParseLocalDate(t *testing.T, value string, location *time.Location) time.Time {
t.Helper()
parsed, err := timeutil.ParseLocalDate(value, location)
if err != nil {
t.Fatalf("ParseLocalDate(%q) error = %v", value, err)
}
return parsed
}

243
internal/app/comparison.go Normal file
View File

@@ -0,0 +1,243 @@
package app
import (
"context"
"fmt"
"path/filepath"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
// ComparisonRequest describes one explicit, multi-profile report comparison.
// It deliberately does not accept a notifier: comparison publication is local.
type ComparisonRequest struct {
Config config.Config
Report ReportKind
ProfileIDs []string
WorkingDir string
OutputDir string
Replace bool
LLMDebugDir string
Date time.Time
Clock timeutil.Clock
Collector Collector
Executor promptexec.Executor
}
// ComparisonResult records the resolved comparison and profile outcomes.
type ComparisonResult struct {
ComparisonID string
ReportID report.ID
ReportName string
PromptID string
PromptVersion string
PromptHash string
StartedAt time.Time
FinishedAt time.Time
Timezone string
ValidPeriod timeutil.Period
OutputDirectory string
ManifestPath string
DataPackagePath string
Total int
Succeeded int
Failed int
Results []ComparisonProfileResult
}
// ComparisonProfileResult records one explicitly selected profile.
type ComparisonProfileResult struct {
Position int
ProfileID string
BackendID string
ModelName string
Status string
ValidationStatus promptexec.ValidationStatus
RepairAttempts *int
ReportPath string
LLMDebugPath string
Error *comparison.SafeError
}
type comparisonPublisher func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error)
// CompareDetailed assembles, executes, and atomically publishes a comparison
// bundle. Profile failures publish a complete partial bundle. Failures before
// commit leave the destination untouched; a post-commit cleanup failure leaves
// the new bundle installed and returns its artifact paths with an error.
func CompareDetailed(ctx context.Context, req ComparisonRequest) (*ComparisonResult, error) {
return compareDetailed(ctx, req, comparison.Publish)
}
func compareDetailed(ctx context.Context, req ComparisonRequest, publish comparisonPublisher) (*ComparisonResult, error) {
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
return nil, err
}
clock := req.Clock
if clock == nil {
clock = timeutil.SystemClock{}
}
now := clock.Now()
resolved, err := ResolveGenerate(GenerateRequest{Config: req.Config, Report: req.Report, Date: req.Date}, now)
if err != nil {
return nil, err
}
metadata := resolved.Metadata()
comparisonID, err := comparison.BuildComparisonID(metadata.RunID)
if err != nil {
return nil, fmt.Errorf("build comparison identity: %w", err)
}
result := initialComparisonResult(req, resolved, comparisonID, now.UTC())
defer func() {
if result.FinishedAt.IsZero() {
finalizeComparisonResult(result, clock)
}
}()
outputName, err := resolved.OutputName()
if err != nil {
return result, fmt.Errorf("resolve comparison output name: %w", err)
}
outputDirectory, err := resolveComparisonOutputDirectory(req.WorkingDir, req.OutputDir, req.Config.Output.Directory, outputName)
if err != nil {
return result, err
}
result.OutputDirectory = outputDirectory
publicationPlan, err := comparison.PlanDestination(req.WorkingDir, outputDirectory, req.Replace)
if err != nil {
return result, fmt.Errorf("preflight comparison destination: %w", err)
}
debugWriter, err := promptdebug.NewPromptDebugWriter(req.LLMDebugDir)
if err != nil {
return result, promptexec.NewError(promptexec.InvalidConfiguration, "initialize prompt debug", err)
}
defer func() { _ = debugWriter.Close() }()
inspection, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: req.ProfileIDs, Executor: req.Executor,
})
result.PromptID, result.PromptVersion, result.PromptHash = inspection.PromptID, inspection.PromptVersion, inspection.PromptHash
if err != nil {
return result, err
}
collection, err := collectWeather(ctx, req.Config, req.Collector)
if err != nil {
return result, err
}
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: resolved, Collection: *collection, handler: inspection.handler})
if err != nil {
return result, fmt.Errorf("prepare comparison report: %w", err)
}
executed := executeComparisonProfiles(ctx, comparisonExecutionRequest{
Prepared: prepared, Inspection: inspection, ComparisonID: comparisonID, DebugWriter: debugWriter, Executor: req.Executor,
})
finalizeComparisonResult(result, clock)
copyComparisonOutcomes(result, executed.Outcomes, false)
if executed.Canceled {
return result, fmt.Errorf("comparison execution: %w", ctx.Err())
}
bundle := comparisonBundle(result, prepared.dataPackageCopy(), executed.Outcomes)
if err := bundle.Validate(); err != nil {
return result, fmt.Errorf("build comparison bundle: %w", err)
}
publication, err := publish(ctx, publicationPlan, bundle)
if publication.Committed {
result.OutputDirectory = publicationPlan.Target
result.ManifestPath = filepath.Join(publicationPlan.Target, comparison.ManifestFilename)
result.DataPackagePath = filepath.Join(publicationPlan.Target, comparison.DataPackageFilename)
copyComparisonOutcomes(result, executed.Outcomes, true)
}
if err != nil {
return result, fmt.Errorf("publish comparison bundle: %w", err)
}
if result.Failed > 0 {
return result, fmt.Errorf("comparison completed with %d failed profiles", result.Failed)
}
return result, nil
}
func finalizeComparisonResult(result *ComparisonResult, clock timeutil.Clock) {
finishedAt := clock.Now().UTC()
if finishedAt.IsZero() {
finishedAt = time.Unix(0, 1).UTC()
}
if finishedAt.Before(result.StartedAt) {
finishedAt = result.StartedAt
}
result.FinishedAt = finishedAt
}
func initialComparisonResult(req ComparisonRequest, resolved report.Resolved, comparisonID string, startedAt time.Time) *ComparisonResult {
metadata := resolved.Metadata()
return &ComparisonResult{
ComparisonID: comparisonID,
ReportID: resolved.Definition.ID,
ReportName: resolved.Definition.Name,
StartedAt: startedAt,
Timezone: req.Config.WeatherAPI.Timezone,
ValidPeriod: metadata.ValidPeriod,
}
}
func copyComparisonOutcomes(result *ComparisonResult, outcomes []comparisonProfileOutcome, published bool) {
result.Results = make([]ComparisonProfileResult, len(outcomes))
result.Total, result.Succeeded, result.Failed = len(outcomes), 0, 0
for index, outcome := range outcomes {
profile := ComparisonProfileResult{
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
Status: outcome.Status, ValidationStatus: outcome.ValidationStatus, LLMDebugPath: outcome.LLMDebugPath, Error: outcome.Error,
}
if outcome.RepairAttempts != nil {
profile.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
}
if published && outcome.Status == comparison.StatusSucceeded {
profile.ReportPath = filepath.Join(result.OutputDirectory, outcome.ReportPath)
}
result.Results[index] = profile
if outcome.Status == comparison.StatusSucceeded {
result.Succeeded++
} else {
result.Failed++
}
}
}
func comparisonBundle(result *ComparisonResult, dataPackage []byte, outcomes []comparisonProfileOutcome) comparison.LogicalBundle {
manifest := comparison.Manifest{
SchemaVersion: comparison.SchemaVersion, ComparisonID: result.ComparisonID,
StartedAt: result.StartedAt.UTC(), FinishedAt: result.FinishedAt.UTC(),
ReportID: string(result.ReportID), Timezone: result.Timezone,
ValidPeriod: comparison.ValidPeriod{Start: result.ValidPeriod.Start, End: result.ValidPeriod.End},
PromptID: result.PromptID, PromptVersion: result.PromptVersion, PromptHash: result.PromptHash,
DataPackage: comparison.DataPackageReference{Path: comparison.DataPackageFilename, SHA256: comparison.SHA256(dataPackage)},
Total: result.Total, Succeeded: result.Succeeded, Failed: result.Failed,
Results: make([]comparison.Result, len(outcomes)),
}
bundle := comparison.LogicalBundle{Manifest: manifest, DataPackage: dataPackage}
for index, outcome := range outcomes {
manifestResult := comparison.Result{
Position: outcome.Position, ProfileID: outcome.ProfileID, BackendID: outcome.BackendID, ModelName: outcome.ModelName,
Status: outcome.Status, ValidationStatus: string(outcome.ValidationStatus), Error: outcome.Error,
}
if outcome.RepairAttempts != nil {
manifestResult.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
}
if outcome.Status == comparison.StatusSucceeded {
manifestResult.ReportPath = outcome.ReportPath
bundle.Reports = append(bundle.Reports, comparison.BundleReport{Position: outcome.Position, Path: outcome.ReportPath, Markdown: outcome.Markdown})
}
bundle.Manifest.Results[index] = manifestResult
}
return bundle
}

View File

@@ -0,0 +1,186 @@
package app
import (
"context"
"errors"
"fmt"
"sync"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type comparisonExecutionRequest struct {
Prepared preparedReport
Inspection ComparisonInspectionResult
ComparisonID string
DebugWriter *promptdebug.PromptDebugWriter
Executor promptexec.Executor
}
type comparisonExecutionResult struct {
Outcomes []comparisonProfileOutcome
Canceled bool
}
type comparisonProfileOutcome struct {
Position int
ProfileID string
BackendID string
ModelName string
Status string
ValidationStatus promptexec.ValidationStatus
RepairAttempts *int
ReportPath string
Markdown []byte
LLMDebugPath string
Error *comparison.SafeError
canceled bool
}
type comparisonProfileExecutionState uint8
const (
comparisonProfilePending comparisonProfileExecutionState = iota
comparisonProfileRunning
comparisonProfileComplete
)
func executeComparisonProfiles(ctx context.Context, req comparisonExecutionRequest) comparisonExecutionResult {
profiles := req.Inspection.Profiles
result := comparisonExecutionResult{Outcomes: make([]comparisonProfileOutcome, len(profiles))}
states := make([]comparisonProfileExecutionState, len(profiles))
for index, profile := range profiles {
result.Outcomes[index] = comparisonProfileOutcome{
Position: index + 1,
ProfileID: profile.ProfileID,
BackendID: profile.BackendID,
ModelName: profile.ModelName,
Status: comparison.StatusFailed,
}
}
var waitGroup sync.WaitGroup
for index, profile := range profiles {
if err := ctx.Err(); err != nil {
result.Canceled = true
break
}
index, profile := index, profile
states[index] = comparisonProfileRunning
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
result.Outcomes[index] = executeComparisonProfile(ctx, req, index, profile)
states[index] = comparisonProfileComplete
}()
}
waitGroup.Wait()
if err := ctx.Err(); err != nil {
result.Canceled = true
for index := range result.Outcomes {
if states[index] != comparisonProfileComplete || result.Outcomes[index].canceled {
markCanceledComparisonOutcome(&result.Outcomes[index], err)
}
}
}
return result
}
func executeComparisonProfile(ctx context.Context, req comparisonExecutionRequest, index int, profile ComparisonProfileInspection) comparisonProfileOutcome {
position := index + 1
outcome := comparisonProfileOutcome{
Position: position, ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
Status: comparison.StatusFailed,
}
debugRef := promptdebug.PromptDebugRef{
ReportID: req.Prepared.resolved.Definition.ID,
ValidDate: req.Prepared.resolved.ValidPeriod.Start.Format("2006-01-02"),
RunID: comparisonDebugRunID(req.ComparisonID, position, len(req.Inspection.Profiles), profile.ProfileID),
}
execution, markdown, err := executePreparedProfile(ctx, profileExecutionRequest{
Prepared: req.Prepared,
Prompt: PromptInspectionResult{
PromptID: req.Inspection.PromptID, PromptVersion: req.Inspection.PromptVersion, PromptHash: req.Inspection.PromptHash,
},
Profile: promptexec.ProfileInspection{ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName},
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
})
outcome.ProfileID, outcome.BackendID, outcome.ModelName = execution.ProfileID, execution.BackendID, execution.ModelName
outcome.ValidationStatus = execution.ValidationStatus
if execution.RepairAttempts != nil {
outcome.RepairAttempts = repairAttemptsPointer(*execution.RepairAttempts)
}
outcome.LLMDebugPath = execution.LLMDebugPath
if err != nil {
outcome.canceled = cancellationError(err)
safe := comparisonSafeExecutionError(err)
outcome.Error = &safe
return outcome
}
reportPath, err := comparison.ReportFilename(position, len(req.Inspection.Profiles), profile.ProfileID)
if err != nil {
safe := comparison.NewSafeError("application", "derive comparison report filename failed")
outcome.Error = &safe
return outcome
}
outcome.Status = comparison.StatusSucceeded
outcome.ReportPath = reportPath
outcome.Markdown = append([]byte(nil), markdown...)
return outcome
}
func comparisonDebugRunID(comparisonID string, position, profileCount int, profileID string) string {
return fmt.Sprintf("%s_%0*d-%s", comparisonID, comparison.OrdinalWidth(profileCount), position, comparison.ProfileSlug(profileID))
}
func markCanceledComparisonOutcome(outcome *comparisonProfileOutcome, err error) {
outcome.Status = comparison.StatusFailed
outcome.ValidationStatus = promptexec.ValidationSkipped
outcome.ReportPath = ""
outcome.Markdown = nil
safe := comparisonSafeExecutionError(err)
outcome.Error = &safe
}
func cancellationError(err error) bool {
category := promptexec.CategoryOf(err)
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
category == promptexec.Canceled || category == promptexec.DeadlineExceeded
}
func comparisonSafeExecutionError(err error) comparison.SafeError {
category := promptexec.CategoryOf(err)
if category == "" {
switch {
case errors.Is(err, context.Canceled):
category = promptexec.Canceled
case errors.Is(err, context.DeadlineExceeded):
category = promptexec.DeadlineExceeded
}
}
if category == "" {
return comparison.NewSafeError("application", comparisonExecutionMessage(err))
}
return comparison.NewSafeError(string(category), comparisonExecutionMessage(err))
}
func comparisonExecutionMessage(err error) string {
if errors.Is(err, context.Canceled) {
return "profile execution canceled"
}
if errors.Is(err, context.DeadlineExceeded) {
return "profile execution deadline exceeded"
}
operation := "profile execution"
var execution *profileExecutionError
if errors.As(err, &execution) {
operation = execution.operation
}
var generation *promptexec.GenerationError
if errors.As(err, &generation) && generation.StatusCode() > 0 {
return comparison.TruncateErrorMessage(fmt.Sprintf("%s failed (HTTP %d)", operation, generation.StatusCode()))
}
return comparison.TruncateErrorMessage(operation + " failed")
}

View File

@@ -0,0 +1,401 @@
package app
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestExecuteComparisonProfilesRunsOrderedProfilesConcurrently(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(10)
executor := newBarrierExecutor(profiles)
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
if executor.maximumInFlight() < 2 {
t.Fatalf("maximum in-flight executions = %d, want overlap", executor.maximumInFlight())
}
for index := len(profiles) - 1; index >= 0; index-- {
executor.release(profiles[index].ProfileID)
}
result := <-results
if result.Canceled || len(result.Outcomes) != len(profiles) {
t.Fatalf("result = %#v", result)
}
for index, profile := range profiles {
outcome := result.Outcomes[index]
wantPath, err := comparison.ReportFilename(index+1, len(profiles), profile.ProfileID)
if err != nil {
t.Fatal(err)
}
if outcome.Position != index+1 || outcome.ProfileID != profile.ProfileID || outcome.Status != comparison.StatusSucceeded || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.ReportPath != wantPath || len(outcome.Markdown) == 0 || outcome.Error != nil {
t.Fatalf("outcome[%d] = %#v", index, outcome)
}
request, ok := executor.request(profile.ProfileID)
if !ok || request.PromptVersion != prompt.PromptVersion || !bytesEqual(request.DataPackage, prepared.dataPackage) {
t.Fatalf("request for %q = %#v, want prompt version %q and shared data package", profile.ProfileID, request, prompt.PromptVersion)
}
}
}
func TestExecuteComparisonProfilesContinuesAfterProfileFailure(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(3)
executor := newBarrierExecutor(profiles)
executor.setError(profiles[1].ProfileID, errors.New("provider response body must not escape"))
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
for _, profile := range profiles {
executor.release(profile.ProfileID)
}
result := <-results
if result.Canceled || result.Outcomes[0].Status != comparison.StatusSucceeded || result.Outcomes[1].Status != comparison.StatusFailed || result.Outcomes[2].Status != comparison.StatusSucceeded {
t.Fatalf("outcomes = %#v", result.Outcomes)
}
failure := result.Outcomes[1]
if failure.Error == nil || failure.Error.Category != string(promptexec.Generation) || failure.Error.Message != "execute prompt failed" || failure.ReportPath != "" || len(failure.Markdown) != 0 {
t.Fatalf("failure outcome = %#v", failure)
}
}
func TestExecuteComparisonProfilesPreservesIndependentRepairOutcomes(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(4)
executor := newBarrierExecutor(profiles)
executor.setValidation(profiles[0].ProfileID, promptexec.ValidationPassed, 0)
executor.setValidation(profiles[1].ProfileID, promptexec.ValidationPassed, 1)
executor.setValidation(profiles[2].ProfileID, promptexec.ValidationFailed, 1)
executor.setError(profiles[3].ProfileID, errors.New("provider failure"))
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
executor.releaseAll()
result := <-results
wantStatuses := []string{comparison.StatusSucceeded, comparison.StatusSucceeded, comparison.StatusFailed, comparison.StatusFailed}
wantValidations := []promptexec.ValidationStatus{promptexec.ValidationPassed, promptexec.ValidationPassed, promptexec.ValidationFailed, ""}
wantRepairs := []*int{intPointer(0), intPointer(1), intPointer(1), nil}
for index, outcome := range result.Outcomes {
if outcome.Status != wantStatuses[index] || outcome.ValidationStatus != wantValidations[index] || !reflect.DeepEqual(outcome.RepairAttempts, wantRepairs[index]) {
t.Fatalf("outcome[%d] = %#v, want status/validation/repairs %q/%q/%#v", index, outcome, wantStatuses[index], wantValidations[index], wantRepairs[index])
}
}
}
func TestExecuteComparisonProfilesCapturesConcurrentProviderFailures(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(2)
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
markers := []string{"first-provider-private-marker", "second-provider-private-marker"}
statuses := []int{http.StatusTooManyRequests, http.StatusServiceUnavailable}
executor := newBarrierExecutor(profiles)
for index, profile := range profiles {
executor.setError(profile.ProfileID, promptexec.NewGenerationError(statuses[index], "provider_code", "provider_type", markers[index], nil))
}
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
executor.releaseAll()
result := <-results
for index, outcome := range result.Outcomes {
if outcome.Status != comparison.StatusFailed || outcome.Error == nil || outcome.Error.Category != string(promptexec.Generation) || outcome.Error.Message != fmt.Sprintf("execute prompt failed (HTTP %d)", statuses[index]) || strings.Contains(outcome.Error.Message, markers[index]) || outcome.LLMDebugPath == "" {
t.Fatalf("outcome[%d] = %#v", index, outcome)
}
failure, readErr := os.ReadFile(filepath.Join(outcome.LLMDebugPath, "failure.json"))
if readErr != nil {
t.Fatal(readErr)
}
if !strings.Contains(string(failure), markers[index]) || strings.Contains(string(failure), markers[1-index]) {
t.Fatalf("failure[%d] = %s", index, failure)
}
}
}
func TestExecuteComparisonProfilesPropagatesCancellationAndJoins(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := comparisonProfiles(4)
executor := newBarrierExecutor(profiles)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
results := startComparisonExecution(t, ctx, comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
cancel()
result := <-results
if !result.Canceled || executor.inFlightCount() != 0 {
t.Fatalf("result/in-flight = %#v/%d", result, executor.inFlightCount())
}
for _, outcome := range result.Outcomes {
if outcome.Status != comparison.StatusFailed || outcome.Error == nil || outcome.Error.Category != string(promptexec.Canceled) || outcome.ValidationStatus != promptexec.ValidationSkipped || outcome.ReportPath != "" || len(outcome.Markdown) != 0 {
t.Fatalf("canceled outcome = %#v", outcome)
}
}
}
func TestExecuteComparisonProfilesUsesDistinctDeterministicDebugReferences(t *testing.T) {
prepared, prompt := preparedDailyProfile(t)
profiles := []ComparisonProfileInspection{
{ProfileID: "light.one", BackendID: "local", ModelName: "light"},
{ProfileID: "deep/two", BackendID: "cloud", ModelName: "deep"},
}
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
executor := newBarrierExecutor(profiles)
results := startComparisonExecution(t, context.Background(), comparisonExecutionRequest{
Prepared: prepared, Inspection: comparisonInspection(prompt, profiles), ComparisonID: "comparison_daily", DebugWriter: debugWriter, Executor: executor,
}, executor)
waitForProfileStarts(t, executor, profiles, results)
for _, profile := range profiles {
executor.release(profile.ProfileID)
}
result := <-results
paths := map[string]struct{}{}
for index, outcome := range result.Outcomes {
wantName := fmt.Sprintf("comparison_daily_%0*d-%s", comparison.OrdinalWidth(len(profiles)), index+1, comparison.ProfileSlug(outcome.ProfileID))
if filepath.Base(outcome.LLMDebugPath) != wantName {
t.Fatalf("debug path = %q, want base %q", outcome.LLMDebugPath, wantName)
}
if _, err := os.Stat(filepath.Join(outcome.LLMDebugPath, "preparation.json")); err != nil {
t.Fatalf("preparation artifact %q: %v", outcome.LLMDebugPath, err)
}
paths[outcome.LLMDebugPath] = struct{}{}
}
if len(paths) != len(profiles) {
t.Fatalf("debug paths = %#v", paths)
}
}
type barrierExecutor struct {
mu sync.Mutex
started chan string
callbackFailures chan error
releases map[string]chan struct{}
requests map[string]promptexec.ExecuteRequest
errors map[string]error
validations map[string]promptexec.ValidationStatus
repairAttempts map[string]int
profiles map[string]ComparisonProfileInspection
inFlight int
maximum int
}
func newBarrierExecutor(profiles []ComparisonProfileInspection) *barrierExecutor {
releases := make(map[string]chan struct{}, len(profiles))
identities := make(map[string]ComparisonProfileInspection, len(profiles))
for _, profile := range profiles {
releases[profile.ProfileID] = make(chan struct{})
identities[profile.ProfileID] = profile
}
return &barrierExecutor{
started: make(chan string, len(profiles)), callbackFailures: make(chan error, len(profiles)), releases: releases,
requests: make(map[string]promptexec.ExecuteRequest, len(profiles)), errors: map[string]error{}, validations: map[string]promptexec.ValidationStatus{}, repairAttempts: map[string]int{}, profiles: identities,
}
}
func (e *barrierExecutor) InspectPrompt(context.Context, string, string) (promptexec.PromptInspection, error) {
return promptexec.PromptInspection{}, errors.New("unexpected prompt inspection")
}
func (e *barrierExecutor) InspectProfile(context.Context, string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{}, errors.New("unexpected profile inspection")
}
func (e *barrierExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
e.mu.Lock()
profile := e.profiles[req.ProfileID]
e.mu.Unlock()
definition := generationDefinitionForPrompt(req.PromptID)
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts}, StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
e.callbackFailures <- err
return nil, err
}
e.mu.Lock()
e.requests[req.ProfileID] = promptexec.ExecuteRequest{PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, DataPackage: append([]byte(nil), req.DataPackage...), CaptureDebug: req.CaptureDebug}
e.inFlight++
if e.inFlight > e.maximum {
e.maximum = e.inFlight
}
release := e.releases[req.ProfileID]
e.mu.Unlock()
e.started <- req.ProfileID
select {
case <-release:
case <-ctx.Done():
e.mu.Lock()
e.inFlight--
e.mu.Unlock()
return nil, ctx.Err()
}
e.mu.Lock()
e.inFlight--
err := e.errors[req.ProfileID]
validationStatus := e.validations[req.ProfileID]
repairAttempts := e.repairAttempts[req.ProfileID]
e.mu.Unlock()
if err != nil {
return nil, err
}
if validationStatus == "" {
validationStatus = promptexec.ValidationPassed
}
return &promptexec.Execution{
PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash,
ProfileID: req.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
StartedAt: stamp, EndedAt: stamp, RawOutput: comparisonRawOutput(),
Validation: promptexec.NewValidation(validationStatus, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", repairAttempts, nil),
}, nil
}
func (e *barrierExecutor) request(profileID string) (promptexec.ExecuteRequest, bool) {
e.mu.Lock()
defer e.mu.Unlock()
request, ok := e.requests[profileID]
return request, ok
}
func (e *barrierExecutor) setError(profileID string, err error) {
e.mu.Lock()
defer e.mu.Unlock()
e.errors[profileID] = err
}
func (e *barrierExecutor) setValidation(profileID string, status promptexec.ValidationStatus, repairAttempts int) {
e.mu.Lock()
defer e.mu.Unlock()
e.validations[profileID] = status
e.repairAttempts[profileID] = repairAttempts
}
func (e *barrierExecutor) release(profileID string) {
close(e.releases[profileID])
}
func (e *barrierExecutor) releaseAll() {
for _, release := range e.releases {
select {
case <-release:
default:
close(release)
}
}
}
func (e *barrierExecutor) maximumInFlight() int {
e.mu.Lock()
defer e.mu.Unlock()
return e.maximum
}
func (e *barrierExecutor) inFlightCount() int {
e.mu.Lock()
defer e.mu.Unlock()
return e.inFlight
}
const comparisonExecutionTestTimeout = 5 * time.Second
func startComparisonExecution(t *testing.T, ctx context.Context, request comparisonExecutionRequest, executor *barrierExecutor) <-chan comparisonExecutionResult {
t.Helper()
results := make(chan comparisonExecutionResult, 1)
finished := make(chan struct{})
t.Cleanup(func() {
executor.releaseAll()
timeout := time.NewTimer(comparisonExecutionTestTimeout)
defer timeout.Stop()
select {
case <-finished:
case <-timeout.C:
t.Error("comparison execution workers did not finish after release")
}
})
go func() {
defer close(finished)
results <- executeComparisonProfiles(ctx, request)
}()
return results
}
func waitForProfileStarts(t *testing.T, executor *barrierExecutor, profiles []ComparisonProfileInspection, results <-chan comparisonExecutionResult) {
t.Helper()
timeout := time.NewTimer(comparisonExecutionTestTimeout)
defer timeout.Stop()
seen := map[string]struct{}{}
for range profiles {
var profileID string
select {
case profileID = <-executor.started:
case err := <-executor.callbackFailures:
executor.releaseAll()
select {
case result := <-results:
t.Fatalf("comparison profile preparation failed before executor entry: %v; result: %#v", err, result)
case <-timeout.C:
t.Fatalf("comparison profile preparation failed before executor entry: %v; comparison did not finish", err)
}
case result := <-results:
t.Fatalf("comparison completed before all profiles started: %#v", result)
case <-timeout.C:
t.Fatal("timed out waiting for comparison profile starts")
}
if _, duplicate := seen[profileID]; duplicate {
t.Fatalf("duplicate execution start for %q", profileID)
}
seen[profileID] = struct{}{}
}
}
func comparisonProfiles(count int) []ComparisonProfileInspection {
profiles := make([]ComparisonProfileInspection, 0, count)
for index := 1; index <= count; index++ {
profiles = append(profiles, ComparisonProfileInspection{ProfileID: fmt.Sprintf("profile.%02d", index), BackendID: "backend", ModelName: "model"})
}
return profiles
}
func comparisonInspection(prompt PromptInspectionResult, profiles []ComparisonProfileInspection) ComparisonInspectionResult {
return ComparisonInspectionResult{PromptID: prompt.PromptID, PromptVersion: prompt.PromptVersion, PromptHash: prompt.PromptHash, Profiles: profiles}
}
func comparisonRawOutput() []byte {
return []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
}
func bytesEqual(left, right []byte) bool {
return reflect.DeepEqual(left, right)
}
func intPointer(value int) *int {
return &value
}
var _ promptexec.Executor = (*barrierExecutor)(nil)

View File

@@ -0,0 +1,433 @@
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestCompareDetailedPublishesOneCoherentBundle(t *testing.T) {
cfg := comparisonConfig()
bundle := generationBundle(t)
workingDir := t.TempDir()
executor := &generationExecutor{}
inspectedBeforeCollection := false
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle, beforeRun: func() {
inspectedBeforeCollection = executor.promptInspections == 1 && executor.profileInspections == 2
}}, Executor: executor,
})
if err != nil {
t.Fatalf("CompareDetailed() error = %v", err)
}
if result == nil || result.Total != 2 || result.Succeeded != 2 || result.Failed != 0 || executor.promptInspections != 1 || executor.profileInspections != 2 || executor.executeCalls != 2 || !inspectedBeforeCollection || result.ManifestPath == "" || result.DataPackagePath == "" {
t.Fatalf("result/executor = %#v/%#v", result, executor)
}
if result.OutputDirectory != filepath.Dir(result.ManifestPath) || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
t.Fatalf("published paths = %#v", result)
}
for index, profile := range result.Results {
if profile.Position != index+1 || profile.Status != comparison.StatusSucceeded || !filepath.IsAbs(profile.ReportPath) || profile.Error != nil {
t.Fatalf("profile result = %#v", profile)
}
}
data, readErr := os.ReadFile(result.ManifestPath)
if readErr != nil {
t.Fatal(readErr)
}
var manifest comparison.Manifest
if err := json.Unmarshal(data, &manifest); err != nil {
t.Fatal(err)
}
if manifest.ComparisonID != result.ComparisonID || manifest.Total != result.Total || manifest.Succeeded != result.Succeeded || manifest.DataPackage.SHA256 == "" || len(manifest.Results) != 2 {
t.Fatalf("manifest = %#v", manifest)
}
if manifest.Results[0].ReportPath != filepath.Base(result.Results[0].ReportPath) || manifest.Results[1].ReportPath != filepath.Base(result.Results[1].ReportPath) {
t.Fatalf("manifest report paths = %#v", manifest.Results)
}
}
func TestCompareDetailedPublishesPartialBundleAndReturnsAggregateError(t *testing.T) {
cfg := comparisonConfig()
bundle := generationBundle(t)
executor := &generationExecutor{executeErrors: map[string]error{"weather-deep": errors.New("provider detail must not escape")}}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: cfg, Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep", "weather-fallback"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
if err == nil || err.Error() != "comparison completed with 1 failed profiles" || result == nil || result.Total != 3 || result.Succeeded != 2 || result.Failed != 1 {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
failure := result.Results[1]
if failure.Status != comparison.StatusFailed || failure.ReportPath != "" || failure.Error == nil || strings.Contains(failure.Error.Message, "provider detail") {
t.Fatalf("failure = %#v", failure)
}
if _, statErr := os.Stat(result.ManifestPath); statErr != nil {
t.Fatalf("partial manifest: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(result.OutputDirectory, filepath.Base(result.Results[0].ReportPath))); statErr != nil {
t.Fatalf("successful partial report: %v", statErr)
}
}
func TestCompareDetailedPublishesPostValidationProfileFailure(t *testing.T) {
bundle := generationBundle(t)
executor := &generationExecutor{complete: func(execution *promptexec.Execution) {
if execution.ProfileID == "weather-deep" {
execution.RawOutput = []byte(`{"summary":42}`)
}
}}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
if err == nil || result == nil || result.Succeeded != 1 || result.Failed != 1 || result.ManifestPath == "" {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
failure := result.Results[1]
if failure.Status != comparison.StatusFailed || failure.ValidationStatus != promptexec.ValidationPassed || failure.RepairAttempts == nil || *failure.RepairAttempts != 0 {
t.Fatalf("post-validation failure = %#v", failure)
}
data, readErr := os.ReadFile(result.ManifestPath)
if readErr != nil {
t.Fatal(readErr)
}
var manifest comparison.Manifest
if decodeErr := json.Unmarshal(data, &manifest); decodeErr != nil {
t.Fatal(decodeErr)
}
manifestFailure := manifest.Results[1]
if manifestFailure.ValidationStatus != "passed" || manifestFailure.RepairAttempts == nil || *manifestFailure.RepairAttempts != 0 {
t.Fatalf("published post-validation failure = %#v", manifestFailure)
}
}
func TestCompareDetailedRetainsCommittedPathsWhenBackupCleanupFails(t *testing.T) {
for _, test := range []struct {
name string
state comparison.BackupRecoveryState
path bool
}{
{name: "complete recovery bundle", state: comparison.BackupRecoveryComplete, path: true},
{name: "partial remnants", state: comparison.BackupRecoveryPartial, path: true},
{name: "absent backup", state: comparison.BackupRecoveryAbsent},
} {
t.Run(test.name, func(t *testing.T) {
bundle := generationBundle(t)
recoveryPath := ""
if test.path {
recoveryPath = filepath.Join(t.TempDir(), ".comparison-daily.backup-recovery")
}
cleanupCause := errors.New("backup cleanup failed")
publish := func(context.Context, comparison.DestinationPlan, comparison.LogicalBundle) (comparison.PublicationResult, error) {
return comparison.PublicationResult{Committed: true, RecoveryState: test.state, RecoveryPath: recoveryPath}, &comparison.PublicationCleanupError{RecoveryState: test.state, RecoveryPath: recoveryPath, Err: cleanupCause}
}
result, err := compareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
}, publish)
var cleanupErr *comparison.PublicationCleanupError
if result == nil || !errors.As(err, &cleanupErr) || !errors.Is(err, cleanupCause) || cleanupErr.RecoveryState != test.state || cleanupErr.RecoveryPath != recoveryPath || !filepath.IsAbs(result.ManifestPath) || !filepath.IsAbs(result.DataPackagePath) {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
for _, profile := range result.Results {
if profile.Status == comparison.StatusSucceeded && !filepath.IsAbs(profile.ReportPath) {
t.Fatalf("published profile result = %#v", profile)
}
}
})
}
}
func TestCompareDetailedPreflightsBeforePromptOrCollection(t *testing.T) {
invalidDestination := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(invalidDestination, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), OutputDir: invalidDestination, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: collector, Executor: executor,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.executeCalls != 0 || result.ManifestPath != "" {
t.Fatalf("result/error/collector/executor = %#v/%v/%#v/%#v", result, err, collector, executor)
}
}
func TestCompareDetailedFinalizesUnpublishedFailures(t *testing.T) {
for _, test := range []struct {
name string
prepare func(t *testing.T, outputDirectory string)
debugDir string
executor *generationExecutor
collector *generationCollector
wantPrompt bool
wantCollection bool
}{
{
name: "destination preflight",
prepare: func(t *testing.T, outputDirectory string) {
t.Helper()
if err := os.WriteFile(outputDirectory, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
},
executor: &generationExecutor{},
},
{
name: "debug initialization",
debugDir: "relative-debug-directory",
executor: &generationExecutor{},
collector: &generationCollector{},
},
{
name: "prompt preflight",
executor: &generationExecutor{inspectErr: promptexec.NewError(promptexec.PromptLoad, "unsafe prompt detail", errors.New("unsafe cause"))},
collector: &generationCollector{},
wantPrompt: false,
},
{
name: "profile preflight",
executor: &generationExecutor{profileInspectErrors: map[string]error{
"weather-deep": promptexec.NewError(promptexec.MissingCredential, "profile credential is unavailable", errors.New("unsafe cause")),
}},
collector: &generationCollector{},
wantPrompt: true,
},
{
name: "collection",
executor: &generationExecutor{},
collector: &generationCollector{err: errors.New("collection failed")},
wantPrompt: true,
wantCollection: true,
},
{
name: "preparation",
executor: &generationExecutor{},
collector: &generationCollector{bundle: &weatherdata.Bundle{}},
wantPrompt: true,
wantCollection: true,
},
} {
t.Run(test.name, func(t *testing.T) {
workingDirectory := t.TempDir()
outputDirectory := filepath.Join(workingDirectory, "comparison-output")
if test.prepare != nil {
test.prepare(t, outputDirectory)
}
collector := test.collector
if collector == nil {
bundle := generationBundle(t)
collector = &generationCollector{bundle: &bundle}
}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDirectory, OutputDir: outputDirectory, LLMDebugDir: test.debugDir,
Date: generationTime("2026-05-29T12:00:00-05:00"), Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: collector, Executor: test.executor,
})
if err == nil {
t.Fatal("CompareDetailed() error = nil")
}
assertUnpublishedComparisonResult(t, result, outputDirectory)
if (result.PromptID != "") != test.wantPrompt || (result.PromptHash != "") != test.wantPrompt {
t.Fatalf("prompt identity = %q/%q, want resolved=%t", result.PromptID, result.PromptHash, test.wantPrompt)
}
if collector.called != test.wantCollection || test.executor.executeCalls != 0 {
t.Fatalf("collection/execution = %t/%d, want collection=%t and no execution", collector.called, test.executor.executeCalls, test.wantCollection)
}
})
}
}
func TestCompareDetailedLeavesDestinationWhenCollectionOrPreparationFails(t *testing.T) {
collectionErr := errors.New("weather collection failed")
for _, test := range []struct {
name string
collector *generationCollector
}{
{name: "collection", collector: &generationCollector{err: collectionErr}},
{name: "preparation", collector: &generationCollector{bundle: &weatherdata.Bundle{}}},
} {
t.Run(test.name, func(t *testing.T) {
workingDir := t.TempDir()
executor := &generationExecutor{}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: test.collector, Executor: executor,
})
if err == nil || result == nil || executor.executeCalls != 0 || result.ManifestPath != "" || result.DataPackagePath != "" {
t.Fatalf("CompareDetailed() result/error/executor = %#v/%v/%#v", result, err, executor)
}
if _, statErr := os.Stat(filepath.Join(workingDir, "comparison-daily-2026-05-29")); !os.IsNotExist(statErr) {
t.Fatalf("comparison destination stat error = %v", statErr)
}
})
}
}
func TestCompareDetailedPublishesManifestWhenEveryProfileFails(t *testing.T) {
bundle := generationBundle(t)
executor := &generationExecutor{executeErrors: map[string]error{
"weather-light": errors.New("first provider failure"), "weather-deep": errors.New("second provider failure"),
}}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
if err == nil || err.Error() != "comparison completed with 2 failed profiles" || result == nil || result.Succeeded != 0 || result.Failed != 2 || result.ManifestPath == "" {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
for _, profile := range result.Results {
if profile.ReportPath != "" || profile.Error == nil {
t.Fatalf("failed profile = %#v", profile)
}
}
}
func TestCompareDetailedCancellationPreservesPublishedBundle(t *testing.T) {
workingDir := t.TempDir()
bundle := generationBundle(t)
request := ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDir, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
}
previous, err := CompareDetailed(context.Background(), request)
if err != nil {
t.Fatalf("initial CompareDetailed() error = %v", err)
}
before, err := os.ReadFile(previous.ManifestPath)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
request.Replace = true
request.Executor = &generationExecutor{cancelBeforeReturn: cancel}
result, err := CompareDetailed(ctx, request)
if !errors.Is(err, context.Canceled) || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" {
t.Fatalf("canceled CompareDetailed() result/error = %#v/%v", result, err)
}
after, readErr := os.ReadFile(previous.ManifestPath)
if readErr != nil || string(after) != string(before) {
t.Fatalf("published manifest changed = %q, error = %v", after, readErr)
}
}
func TestCompareDetailedPreservesCompletedProfileFailureWhenCanceled(t *testing.T) {
bundle := generationBundle(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
failureStarted := make(chan struct{})
var signalFailure sync.Once
executor := &generationExecutor{
validations: map[string]promptexec.ValidationStatus{"weather-light": promptexec.ValidationFailed},
waitForCancellation: map[string]bool{"weather-deep": true},
beforeExecute: func(request promptexec.ExecuteRequest) {
if request.ProfileID == "weather-light" {
signalFailure.Do(func() { close(failureStarted) })
}
},
}
results := make(chan struct {
result *ComparisonResult
err error
}, 1)
go func() {
result, err := CompareDetailed(ctx, ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: t.TempDir(), Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")},
Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
results <- struct {
result *ComparisonResult
err error
}{result: result, err: err}
}()
select {
case <-failureStarted:
cancel()
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for the completed profile failure")
}
completed := <-results
if !errors.Is(completed.err, context.Canceled) || completed.result == nil || completed.result.ManifestPath != "" || completed.result.DataPackagePath != "" || completed.result.Succeeded != 0 || completed.result.Failed != 2 {
t.Fatalf("CompareDetailed() result/error = %#v/%v", completed.result, completed.err)
}
failed, canceled := completed.result.Results[0], completed.result.Results[1]
if failed.Error == nil || failed.Error.Category != string(promptexec.ValidationRejected) || failed.ValidationStatus != promptexec.ValidationFailed || failed.ReportPath != "" {
t.Fatalf("completed failure = %#v", failed)
}
if canceled.Error == nil || canceled.Error.Category != string(promptexec.Canceled) || canceled.ValidationStatus != promptexec.ValidationSkipped || canceled.ReportPath != "" {
t.Fatalf("canceled profile = %#v", canceled)
}
}
func TestCompareDetailedLeavesExistingBundleWhenPublicationPreflightChanges(t *testing.T) {
workingDir := t.TempDir()
target := filepath.Join(workingDir, "comparison-output")
bundle := generationBundle(t)
executor := &generationExecutor{beforeExecute: func(promptexec.ExecuteRequest) {
_ = os.WriteFile(target, []byte("changed"), 0o600)
}}
result, err := CompareDetailed(context.Background(), ComparisonRequest{
Config: comparisonConfig(), Report: ReportDaily, ProfileIDs: []string{"weather-light", "weather-deep"},
WorkingDir: workingDir, OutputDir: target, Date: generationTime("2026-05-29T12:00:00-05:00"),
Clock: timeutil.FixedClock{Time: generationTime("2026-05-29T08:30:00-05:00")}, Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
if err == nil || result == nil || result.ManifestPath != "" || result.DataPackagePath != "" || result.Results[0].ReportPath != "" {
t.Fatalf("CompareDetailed() result/error = %#v/%v", result, err)
}
data, readErr := os.ReadFile(target)
if readErr != nil || string(data) != "changed" {
t.Fatalf("destination = %q, error = %v", data, readErr)
}
}
func comparisonConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
return cfg
}
func assertUnpublishedComparisonResult(t *testing.T, result *ComparisonResult, outputDirectory string) {
t.Helper()
if result == nil || result.OutputDirectory != outputDirectory || !filepath.IsAbs(result.OutputDirectory) || result.FinishedAt.IsZero() || result.FinishedAt.Location() != time.UTC || result.FinishedAt.Before(result.StartedAt) || result.ManifestPath != "" || result.DataPackagePath != "" {
t.Fatalf("unpublished comparison result = %#v", result)
}
for _, profile := range result.Results {
if profile.ReportPath != "" {
t.Fatalf("unpublished profile result = %#v", profile)
}
}
}

View File

@@ -0,0 +1,45 @@
package app
import (
"errors"
"strings"
"testing"
distributoradapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/distributor"
)
func TestNotificationResultFromUploadExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := notificationResultFromUpload("weather", "bundle", "key", distributoradapter.UploadResult{
RunID: "run-123", Status: "failed", UploadStatus: "accepted", StatusError: remote,
RunStatus: &distributoradapter.RunStatus{PipelineID: "weather", Status: "failed", Report: []byte(`{"detail":"REMOTE-DIAGNOSTIC"}`), Error: remote},
})
if notification == nil || notification.StatusError != "distributor status could not be confirmed" || notification.Error != "distributor reported a failed run" || len(notification.Report) != 0 {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.StatusError, remote) || strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}
func TestBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := batchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, &NotificationResult{Status: "failed", Error: remote})
if notification == nil || notification.Error != "distributor reported a failed run" {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}
func TestFailedBatchNotificationResultExcludesRemoteResponseDetails(t *testing.T) {
const remote = "REMOTE-DIAGNOSTIC"
notification := failedBatchNotificationResult(batchNotificationRequest{PipelineID: "weather", BundleID: "bundle", IdempotencyKey: "key"}, errors.New(remote))
if notification == nil || notification.Error != "distributor notification failed" {
t.Fatalf("notification = %#v", notification)
}
if strings.Contains(notification.Error, remote) {
t.Fatalf("notification includes remote detail: %#v", notification)
}
}

View File

@@ -0,0 +1,673 @@
package app
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type generationCollector struct {
bundle *weatherdata.Bundle
err error
called bool
calls int
beforeRun func()
}
type publicationGateContext struct {
context.Context
err error
checks int
afterChecks int
}
func (c *publicationGateContext) Err() error {
c.checks++
afterChecks := c.afterChecks
if afterChecks == 0 {
afterChecks = 2
}
if c.checks >= afterChecks {
return c.err
}
return nil
}
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
if c.beforeRun != nil {
c.beforeRun()
}
c.called = true
c.calls++
return &collect.Result{Bundle: c.bundle}, c.err
}
type generationExecutor struct {
called bool
executeCalls int
promptInspections int
profileInspections int
inspectErr error
profileInspectErrors map[string]error
executeErr error
executeErrors map[string]error
beforeExecute func(promptexec.ExecuteRequest)
cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus
repairAttempts int
validations map[string]promptexec.ValidationStatus
rawOutput []byte
waitForCancellation map[string]bool
failedPrompt string
skipPreparation bool
preparationCalls int
prepare func(*promptexec.Preparation)
complete func(*promptexec.Execution)
}
var generationExecutorMu sync.Mutex
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
generationExecutorMu.Lock()
defer generationExecutorMu.Unlock()
e.promptInspections++
if e.inspectErr != nil {
return promptexec.PromptInspection{}, e.inspectErr
}
definition := generationDefinitionForPrompt(id)
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: generationPromptHash, DefaultProfileID: "fixture", Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}}, Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts}}, nil
}
func (e *generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
generationExecutorMu.Lock()
defer generationExecutorMu.Unlock()
e.profileInspections++
if err := e.profileInspectErrors[id]; err != nil {
return promptexec.ProfileInspection{}, err
}
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
}
func (e *generationExecutor) Execute(ctx context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
generationExecutorMu.Lock()
skipPreparation := e.skipPreparation
prepare := e.prepare
preparationCalls := e.preparationCalls
generationExecutorMu.Unlock()
if !skipPreparation {
calls := preparationCalls
if calls == 0 {
calls = 1
}
for range calls {
definition := generationDefinitionForPrompt(req.PromptID)
preparation := promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts}, StartedAt: stamp, EndedAt: stamp}
if prepare != nil {
prepare(&preparation)
}
if err := callback(preparation, nil); err != nil {
return nil, err
}
}
}
generationExecutorMu.Lock()
e.called = true
e.executeCalls++
beforeExecute := e.beforeExecute
profileErr := e.executeErrors[req.ProfileID]
executeErr := e.executeErr
status := e.validation
repairAttempts := e.repairAttempts
if profileStatus, ok := e.validations[req.ProfileID]; ok {
status = profileStatus
}
rawOutput := append([]byte(nil), e.rawOutput...)
waitForCancellation := e.waitForCancellation[req.ProfileID]
failedPrompt := e.failedPrompt
cancelBeforeReturn := e.cancelBeforeReturn
complete := e.complete
generationExecutorMu.Unlock()
if beforeExecute != nil {
beforeExecute(req)
}
if waitForCancellation {
<-ctx.Done()
return nil, ctx.Err()
}
if profileErr != nil {
return nil, profileErr
}
if executeErr != nil {
return nil, executeErr
}
if status == "" {
status = promptexec.ValidationPassed
}
if failedPrompt == req.PromptID {
status = promptexec.ValidationFailed
}
if rawOutput == nil {
rawOutput = []byte(`{"summary":"Showers are possible during the selected day.","forecast_discussion":["A front will keep rain chances in the forecast."],"precipitation_timing":"Rain is most likely during the afternoon."}`)
}
if cancelBeforeReturn != nil {
cancelBeforeReturn()
}
execution := &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: generationPromptHash, RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp, RawOutput: rawOutput, Validation: promptexec.NewValidation(status, "json_schema", generationDefinitionForPrompt(req.PromptID).GeneratedTextSchemaID+".generated_text.schema.json", repairAttempts, nil)}
if complete != nil {
complete(execution)
}
return execution, nil
}
const generationPromptHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
func generationDefinitionForPrompt(promptID string) report.Definition {
for _, definition := range report.DefaultRegistry().All() {
if definition.PromptID == promptID {
return definition
}
}
panic("unknown fixture prompt " + promptID)
}
func TestGenerateDetailedPublishesOnlySelectedOutput(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
bundle := generationBundle(t)
executor := &generationExecutor{}
collector := &generationCollector{bundle: &bundle}
workingDir := t.TempDir()
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: workingDir, Collector: collector, Executor: executor})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if !executor.called || executor.executeCalls != 1 || collector.calls != 1 || result.OutputPath != filepath.Join(workingDir, "daily-2026-05-29.md") || result.ValidationStatus != promptexec.ValidationPassed || result.ProfileID == "" || result.BackendID == "" || result.ModelName == "" {
t.Fatalf("result = %#v", result)
}
if result.LLMDebugPath != "" {
t.Fatalf("unexpected debug output = %q", result.LLMDebugPath)
}
if _, err := os.Stat(filepath.Join(workingDir, "workspace")); !os.IsNotExist(err) {
t.Fatalf("unexpected default state directory: %v", err)
}
data, err := os.ReadFile(result.OutputPath)
if err != nil || len(data) == 0 {
t.Fatalf("output = %q, error = %v", data, err)
}
}
func TestGenerateDetailedUsesConfiguredOutputDirectory(t *testing.T) {
tests := []struct {
name string
directory func(t *testing.T, workingDir string) string
wantDir func(t *testing.T, workingDir string, configuredDir string) string
}{
{
name: "absolute directory",
directory: func(t *testing.T, _ string) string {
return filepath.Join(t.TempDir(), "reports")
},
wantDir: func(_ *testing.T, _ string, configuredDir string) string {
return configuredDir
},
},
{
name: "relative directory",
directory: func(_ *testing.T, _ string) string {
return "configured/../reports"
},
wantDir: func(_ *testing.T, workingDir string, _ string) string {
return filepath.Join(workingDir, "reports")
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workingDir := t.TempDir()
configuredDir := tt.directory(t, workingDir)
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredDir
bundle := generationBundle(t)
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: workingDir, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
wantPath := filepath.Join(tt.wantDir(t, workingDir, configuredDir), "daily-2026-05-29.md")
if err != nil || result == nil || result.OutputPath != wantPath || notifier.request.ReportPath != wantPath {
t.Fatalf("GenerateDetailed() result/error/notification = %#v/%v/%#v", result, err, notifier.request)
}
if info, statErr := os.Stat(filepath.Dir(wantPath)); statErr != nil || !info.IsDir() {
t.Fatalf("configured output directory info/error = %#v/%v", info, statErr)
}
if _, statErr := os.Stat(wantPath); statErr != nil {
t.Fatalf("output %q: %v", wantPath, statErr)
}
})
}
}
func TestGenerateDetailedExplicitOutputPathIgnoresConfiguredDirectory(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
explicitPath := filepath.Join(t.TempDir(), "explicit.md")
cfg := generationConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: explicitPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
if err != nil || result == nil || result.OutputPath != explicitPath {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}
if _, statErr := os.Stat(explicitPath); statErr != nil {
t.Fatalf("explicit output %q: %v", explicitPath, statErr)
}
}
func TestGenerateDetailedRejectsConfiguredNonDirectoryBeforeWork(t *testing.T) {
configuredPath := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(configuredPath, []byte("not a directory"), 0o600); err != nil {
t.Fatal(err)
}
cfg := generationDistributorConfig()
cfg.Output.Directory = configuredPath
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
if data, readErr := os.ReadFile(configuredPath); readErr != nil || string(data) != "not a directory" {
t.Fatalf("configured path = %q, error = %v", data, readErr)
}
}
func TestGenerateDetailedRejectsOverlongOutputBeforeWork(t *testing.T) {
missingDirectory := filepath.Join(t.TempDir(), "missing")
outputPath := filepath.Join(missingDirectory, strings.Repeat("a", 253)+".md")
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called {
t.Fatalf("GenerateDetailed() result/error/collector/executor = %#v/%v/%t/%#v", result, err, collector.called, executor)
}
if _, statErr := os.Stat(missingDirectory); !os.IsNotExist(statErr) {
t.Fatalf("missing output directory exists after preflight failure: %v", statErr)
}
}
func TestGenerateDetailedRejectsUnsupportedDistributorEndpointBeforeWork(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
cfg := generationDistributorConfig()
cfg.Notify.Distributor.Endpoint = "ftp://distributor.example.test"
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
})
if err == nil || result == nil || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/collector/executor/notifier = %#v/%v/%t/%#v/%#v", result, err, collector.called, executor, notifier)
}
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
t.Fatalf("output exists after endpoint preflight failure: %v", statErr)
}
}
func TestGenerateDetailedReturnsResolvedResultWhenCollectionFails(t *testing.T) {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
collectionErr := errors.New("weather source unavailable")
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), Collector: &generationCollector{err: collectionErr}, Executor: &generationExecutor{},
})
if !errors.Is(err, collectionErr) {
t.Fatalf("GenerateDetailed() error = %v, want %v", err, collectionErr)
}
if result == nil || result.ReportID != report.Daily || result.RunID == "" || result.ProfileID != "fixture" || result.BackendID != "fixture" || result.ModelName != "fixture-model" || result.OutputPath != "" {
t.Fatalf("result = %#v", result)
}
}
func TestGenerateDetailedInspectsPromptBeforeCollectingWeather(t *testing.T) {
cfg := generationConfig()
inspectionErr := errors.New("profile is invalid")
collector := &generationCollector{bundle: generationBundlePointer(t)}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), Collector: collector, Executor: &generationExecutor{inspectErr: inspectionErr}})
if !errors.Is(err, inspectionErr) || collector.called || result == nil {
t.Fatalf("GenerateDetailed() result/error/collector-called = %#v/%v/%t", result, err, collector.called)
}
}
func TestGenerateDetailedPreservesDestinationBeforePublish(t *testing.T) {
for _, scenario := range []struct {
name string
executor generationExecutor
}{
{name: "generation", executor: generationExecutor{executeErr: errors.New("provider unavailable")}},
{name: "render", executor: generationExecutor{rawOutput: []byte(`{"summary":""}`)}},
} {
t.Run(scenario.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
if err := os.WriteFile(outputPath, []byte("previous report"), 0o600); err != nil {
t.Fatal(err)
}
bundle := generationBundle(t)
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &scenario.executor})
data, readErr := os.ReadFile(outputPath)
if err == nil || result == nil || readErr != nil || string(data) != "previous report" {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
})
}
}
func TestGenerateDetailedPreservesDestinationWhenContextCancelsBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{cancelBeforeReturn: cancel},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.Canceled) || promptexec.CategoryOf(err) != promptexec.Canceled || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedPreservesDestinationWhenContextDeadlineExpiresBeforePublication(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(0, 0))
defer cancel()
bundle := generationBundle(t)
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
data, readErr := os.ReadFile(outputPath)
if !errors.Is(err, context.DeadlineExceeded) || promptexec.CategoryOf(err) != promptexec.DeadlineExceeded || result == nil || result.OutputPath != "" || readErr != nil || string(data) != previousReport {
t.Fatalf("GenerateDetailed() result/error/output = %#v/%v/%q (%v)", result, err, data, readErr)
}
}
func TestGenerateDetailedPreservesDestinationWhenContextChangesDuringPublication(t *testing.T) {
for _, tt := range []struct {
name string
err error
category promptexec.ErrorCategory
}{
{name: "canceled", err: context.Canceled, category: promptexec.Canceled},
{name: "deadline", err: context.DeadlineExceeded, category: promptexec.DeadlineExceeded},
} {
t.Run(tt.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
const previousReport = "previous report"
if err := os.WriteFile(outputPath, []byte(previousReport), 0o600); err != nil {
t.Fatal(err)
}
ctx := &publicationGateContext{Context: context.Background(), err: tt.err}
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
bundle := generationBundle(t)
notifier := &generationNotifier{}
result, err := GenerateDetailed(ctx, GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier,
})
data, readErr := os.ReadFile(outputPath)
matches, globErr := filepath.Glob(filepath.Join(filepath.Dir(outputPath), ".weatherreporter-*.tmp"))
if !errors.Is(err, tt.err) || promptexec.CategoryOf(err) != tt.category || result == nil || result.OutputPath != "" || notifier.calls != 0 || readErr != nil || string(data) != previousReport || globErr != nil || len(matches) != 0 {
t.Fatalf("GenerateDetailed() result/error/output/notification/temp = %#v/%v/%q/%#v/%v/%v", result, err, data, notifier, matches, globErr)
}
})
}
}
func TestGenerateDetailedRetainsPublishedOutputWhenNotificationFails(t *testing.T) {
cfg := generationConfig()
cfg.Notify.Distributor.Enabled = true
cfg.Notify.Distributor.PipelineIDTemplate = "weather"
bundle := generationBundle(t)
outputPath := filepath.Join(t.TempDir(), "daily.md")
notifier := &generationNotifier{err: errors.New("distributor unavailable")}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: cfg, Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{}, Notifier: notifier})
if err == nil || result == nil || result.OutputPath != outputPath || notifier.request.ReportPath != outputPath || len(notifier.request.BundlePaths) == 0 {
t.Fatalf("GenerateDetailed() result/error/request = %#v/%v/%#v", result, err, notifier.request)
}
if data, readErr := os.ReadFile(outputPath); readErr != nil || len(data) == 0 {
t.Fatalf("published output = %q, error = %v", data, readErr)
}
}
func TestGenerateDetailedDoesNotReplaceDirectoryOutput(t *testing.T) {
bundle := generationBundle(t)
outputPath := filepath.Join(t.TempDir(), "daily.md")
if err := os.Mkdir(outputPath, 0o700); err != nil {
t.Fatal(err)
}
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
info, statErr := os.Stat(outputPath)
if err == nil || result == nil || statErr != nil || !info.IsDir() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output-info/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
}
}
func TestGenerateDetailedDoesNotReplaceSymbolicLinkOutput(t *testing.T) {
dir := t.TempDir()
backing := filepath.Join(dir, "backing.md")
if err := os.WriteFile(backing, []byte("previous report"), 0o600); err != nil {
t.Fatal(err)
}
outputPath := filepath.Join(dir, "daily.md")
testutil.RequireSymlink(t, backing, outputPath)
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{Config: generationConfig(), Report: ReportDaily, Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"), WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier})
info, statErr := os.Lstat(outputPath)
data, readErr := os.ReadFile(backing)
if err == nil || result == nil || statErr != nil || info.Mode()&os.ModeSymlink == 0 || readErr != nil || string(data) != "previous report" || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output/backing/collector/executor/notifier = %#v/%v/%#v (%v)/%q (%v)/%t/%#v/%#v", result, err, info, statErr, data, readErr, collector.called, executor, notifier)
}
}
func TestGenerateDetailedWritesRequestedPromptDebugArtifacts(t *testing.T) {
bundle := generationBundle(t)
debugRoot := t.TempDir()
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: &generationExecutor{},
})
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil || result == nil || result.LLMDebugPath == "" {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}
for _, name := range []string{"preparation.json", "execution.json"} {
if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil {
t.Fatalf("debug artifact %q: %v", name, statErr)
}
}
}
func TestGenerateDetailedCapturesProviderFailureOnlyInDebugArtifacts(t *testing.T) {
bundle := generationBundle(t)
debugRoot := t.TempDir()
const marker = "provider-private-generation-marker"
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle},
Executor: &generationExecutor{executeErr: promptexec.NewGenerationError(http.StatusTooManyRequests, "rate_limit", "provider_error", marker, nil)},
})
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err == nil || result == nil || result.LLMDebugPath == "" || promptexec.CategoryOf(err) != promptexec.Generation || !strings.Contains(err.Error(), "HTTP 429") || strings.Contains(err.Error(), marker) || result.OutputPath != "" {
t.Fatalf("GenerateDetailed() result/error = %#v/%v", result, err)
}
for _, name := range []string{"preparation.json", "failure.json"} {
if _, statErr := os.Stat(filepath.Join(result.LLMDebugPath, name)); statErr != nil {
t.Fatalf("debug artifact %q: %v", name, statErr)
}
}
data, readErr := os.ReadFile(filepath.Join(result.LLMDebugPath, "failure.json"))
if readErr != nil || !strings.Contains(string(data), marker) {
t.Fatalf("failure artifact = %q, error = %v", data, readErr)
}
}
func TestGenerateDetailedPreservesProviderFailureWhenFailureDebugWriteFails(t *testing.T) {
bundle := generationBundle(t)
debugRoot := t.TempDir()
const marker = "provider-private-write-failure-marker"
var setupErr error
executor := &generationExecutor{
executeErr: promptexec.NewGenerationError(http.StatusServiceUnavailable, "unavailable", "provider_error", marker, nil),
beforeExecute: func(promptexec.ExecuteRequest) {
setupErr = filepath.Walk(debugRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Name() == "preparation.json" {
return os.Mkdir(filepath.Join(filepath.Dir(path), "failure.json"), 0o700)
}
return nil
})
},
}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), LLMDebugDir: debugRoot, Collector: &generationCollector{bundle: &bundle}, Executor: executor,
})
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
var generationError *promptexec.GenerationError
if setupErr != nil || err == nil || result == nil || result.OutputPath != "" || promptexec.CategoryOf(err) != promptexec.Generation || !errors.As(err, &generationError) || generationError.StatusCode() != http.StatusServiceUnavailable || !strings.Contains(err.Error(), "HTTP 503") || strings.Contains(err.Error(), marker) {
t.Fatalf("GenerateDetailed() setup/result/error = %v/%#v/%v", setupErr, result, err)
}
}
func generationConfig() config.Config {
cfg := config.Defaults()
cfg.WeatherAPI.Timezone, cfg.Location.ID = "America/Chicago", "home"
return cfg
}
func generationBundlePointer(t *testing.T) *weatherdata.Bundle {
bundle := generationBundle(t)
return &bundle
}
type generationNotifier struct {
err error
batchErr error
calls int
request NotificationRequest
batchRequest batchNotificationRequest
batchCalls int
}
func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) {
n.calls++
n.request = request
if n.err != nil {
return nil, n.err
}
return &NotificationResult{Status: "succeeded"}, nil
}
func (n *generationNotifier) NotifyBatch(_ context.Context, request batchNotificationRequest) (*NotificationResult, error) {
n.batchCalls++
n.batchRequest = request
for _, file := range request.Files {
if _, err := os.Stat(file.SourcePath); err != nil {
return nil, err
}
}
return &NotificationResult{Status: "succeeded", PipelineID: request.PipelineID, BundleID: request.BundleID}, n.batchErr
}
func generationBundle(t *testing.T) weatherdata.Bundle {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "forecast", "testdata", "daily_bundle.json"))
if err != nil {
t.Fatalf("read bundle fixture: %v", err)
}
var bundle weatherdata.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err)
}
return bundle
}
func generationTime(value string) time.Time {
parsed, _ := time.Parse(time.RFC3339, value)
return parsed
}
var _ promptexec.Executor = (*generationExecutor)(nil)
var _ Collector = (*generationCollector)(nil)
var _ Notifier = (*generationNotifier)(nil)
var _ = report.Daily

View File

@@ -1,126 +0,0 @@
package app
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/state"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type InspectReportsRequest struct {
Config config.Config
Limit int
}
type InspectRunRequest struct {
Config config.Config
RunID string
}
type SourceInspection struct {
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []briefing.SourceMetadata `json:"sources,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
func InspectReports(ctx context.Context, req InspectReportsRequest) ([]state.ReportRecord, error) {
store, err := defaultStore(req.Config)
if err != nil {
return nil, err
}
return store.ListReports(ctx, req.Limit)
}
func InspectMetadata(ctx context.Context, req InspectRunRequest) (state.Metadata, error) {
inspection, err := inspectRun(ctx, req)
return inspection.metadata, err
}
func InspectModules(ctx context.Context, req InspectRunRequest) (module.Snapshot, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return module.Snapshot{}, err
}
return inspection.store.LoadModuleSnapshot(ctx, inspection.metadata.ModuleSnapshotPath)
}
func InspectDataPackage(ctx context.Context, req InspectRunRequest) (promptinput.Package, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return promptinput.Package{}, err
}
return inspection.store.LoadDataPackage(ctx, inspection.metadata.DataPackagePath)
}
func InspectPriorSnapshot(ctx context.Context, req InspectRunRequest) (*state.PriorSnapshot, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return nil, err
}
resolved, err := resolvedFromMetadata(inspection.metadata)
if err != nil {
return nil, err
}
return inspection.store.FindPriorSnapshot(ctx, resolved)
}
func InspectSources(ctx context.Context, req InspectRunRequest) (SourceInspection, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return SourceInspection{}, err
}
metadata := inspection.metadata
return SourceInspection{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
SourceLocation: metadata.SourceLocation,
Sources: metadata.Sources,
Warnings: metadata.SourceWarnings,
}, nil
}
type runInspection struct {
store *state.FilesystemStore
metadata state.Metadata
}
func inspectRun(ctx context.Context, req InspectRunRequest) (runInspection, error) {
store, err := defaultStore(req.Config)
if err != nil {
return runInspection{}, err
}
metadata, _, err := store.LoadMetadataByRunID(ctx, req.RunID)
if err != nil {
return runInspection{}, err
}
return runInspection{store: store, metadata: metadata}, nil
}
func resolvedFromMetadata(metadata state.Metadata) (report.Resolved, error) {
definition, err := report.DefaultRegistry().Lookup(metadata.ReportID)
if err != nil {
return report.Resolved{}, err
}
location, err := timeutil.LoadLocation(metadata.Timezone)
if err != nil {
return report.Resolved{}, err
}
if !metadata.ValidPeriod.IsValid() {
return report.Resolved{}, fmt.Errorf("metadata valid period for run id %q is invalid", metadata.RunID)
}
return report.Resolved{
Definition: definition,
GeneratedAt: metadata.GeneratedAt,
Timezone: location.String(),
ValidPeriod: metadata.ValidPeriod,
}, nil
}

192
internal/app/output.go Normal file
View File

@@ -0,0 +1,192 @@
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func plannedBatchOutputPath(outputDir string, planned plannedBatchReport) (string, error) {
outputName, err := planned.Resolved.OutputName()
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func prepareBatchOutputs(outputDir string, plannedReports []plannedBatchReport) error {
for index := range plannedReports {
outputPath, err := plannedBatchOutputPath(outputDir, plannedReports[index])
if err != nil {
return err
}
plannedReports[index].OutputPath = outputPath
}
return nil
}
func resolveReportOutputPath(workingDir, override, configuredDir string, resolved report.Resolved) (string, error) {
outputName, err := resolved.OutputName()
if err != nil {
return "", err
}
if override != "" {
return resolveOutputPath(workingDir, override, outputName)
}
outputDir, err := resolveOutputDir(workingDir, configuredDir)
if err != nil {
return "", err
}
return validateOutputPath(filepath.Join(outputDir, outputName))
}
func resolveOutputDirWithConfigured(workingDir, override, configuredDir string) (string, error) {
directory := configuredDir
if override != "" {
directory = override
}
return resolveOutputDir(workingDir, directory)
}
func resolveComparisonOutputDirectory(workingDir, override, configuredDir, reportOutputName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
if override != "" {
return resolveComparisonDirectoryPath(workingDir, override)
}
outputDir, err := resolveOutputDir(workingDir, configuredDir)
if err != nil {
return "", err
}
name, err := comparison.DefaultDirectoryName(reportOutputName)
if err != nil {
return "", err
}
return filepath.Join(outputDir, name), nil
}
func resolveComparisonDirectoryPath(workingDir, directory string) (string, error) {
if strings.TrimSpace(directory) == "" {
return "", fmt.Errorf("comparison output directory is required")
}
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
return filepath.Clean(directory), nil
}
func resolveOutputDir(workingDir, override string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
if override == "" {
return workingDir, nil
}
if strings.TrimSpace(override) == "" {
return "", fmt.Errorf("output directory is required")
}
directory := override
if !filepath.IsAbs(directory) {
directory = filepath.Join(workingDir, directory)
}
directory = filepath.Clean(directory)
if err := preflightOutputDirectory(directory); err != nil {
return "", err
}
return directory, nil
}
func preflightOutputDirectory(directory string) error {
info, err := os.Stat(directory)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("output directory %q is not a directory", directory)
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("inspect output directory %q: %w", directory, err)
}
// A missing directory is valid, but os.Stat also reports ErrNotExist for a
// dangling symlink. Walk to the first existing component so invalid links
// fail preflight instead of being discovered only during publication.
for component := directory; ; component = filepath.Dir(component) {
componentInfo, componentErr := os.Lstat(component)
if componentErr == nil {
if componentInfo.Mode()&os.ModeSymlink != 0 {
targetInfo, targetErr := os.Stat(component)
if targetErr != nil {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, targetErr)
}
if !targetInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !componentInfo.IsDir() {
return fmt.Errorf("output directory %q has non-directory path component %q", directory, component)
}
return nil
}
if !os.IsNotExist(componentErr) {
return fmt.Errorf("inspect output directory %q at %q: %w", directory, component, componentErr)
}
if filepath.Dir(component) == component {
return fmt.Errorf("inspect output directory %q: no existing directory ancestor", directory)
}
}
}
func resolveOutputPath(workingDir, override, defaultName string) (string, error) {
workingDir, err := validateWorkingDir(workingDir)
if err != nil {
return "", err
}
path := override
if path == "" {
path = defaultName
}
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
if !filepath.IsAbs(path) {
path = filepath.Join(workingDir, path)
}
return validateOutputPath(path)
}
func validateWorkingDir(workingDir string) (string, error) {
if strings.TrimSpace(workingDir) == "" {
return "", fmt.Errorf("working directory is required")
}
if !filepath.IsAbs(workingDir) {
return "", fmt.Errorf("working directory %q must be absolute", workingDir)
}
return filepath.Clean(workingDir), nil
}
func validateOutputPath(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", fmt.Errorf("final output path is required")
}
path = filepath.Clean(path)
if !filepath.IsAbs(path) {
return "", fmt.Errorf("final output path %q must be absolute", path)
}
if filepath.Dir(path) == path {
return "", fmt.Errorf("final output path %q must not be a filesystem root", path)
}
if err := fileutil.ValidateAtomicPath(path); err != nil {
return "", fmt.Errorf("validate final output path %q: %w", path, err)
}
return path, nil
}

View File

@@ -0,0 +1,59 @@
//go:build linux
package app
import (
"context"
"net"
"os"
"path/filepath"
"syscall"
"testing"
)
func TestGenerateDetailedRejectsSpecialOutputBeforeWork(t *testing.T) {
for _, tt := range []struct {
name string
setup func(t *testing.T, path string)
}{
{
name: "named pipe",
setup: func(t *testing.T, path string) {
t.Helper()
if err := syscall.Mkfifo(path, 0o600); err != nil {
t.Fatal(err)
}
},
},
{
name: "socket",
setup: func(t *testing.T, path string) {
t.Helper()
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: path, Net: "unix"})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = listener.Close() })
},
},
} {
t.Run(tt.name, func(t *testing.T) {
outputPath := filepath.Join(t.TempDir(), "daily.md")
tt.setup(t, outputPath)
bundle := generationBundle(t)
collector := &generationCollector{bundle: &bundle}
executor := &generationExecutor{}
notifier := &generationNotifier{}
result, err := GenerateDetailed(context.Background(), GenerateRequest{
Config: generationConfig(), Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
WorkingDir: t.TempDir(), OutputPath: outputPath, Collector: collector, Executor: executor, Notifier: notifier,
})
info, statErr := os.Lstat(outputPath)
if err == nil || result == nil || statErr != nil || info.Mode().IsRegular() || collector.called || executor.promptInspections != 0 || executor.called || notifier.calls != 0 {
t.Fatalf("GenerateDetailed() result/error/output/collector/executor/notifier = %#v/%v/%#v (%v)/%t/%#v/%#v", result, err, info, statErr, collector.called, executor, notifier)
}
})
}
}

View File

@@ -0,0 +1,73 @@
package app
import (
"os"
"path/filepath"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/testutil"
)
func TestResolveComparisonOutputDirectory(t *testing.T) {
workingDir := t.TempDir()
configured := filepath.Join(workingDir, "configured")
blocked := filepath.Join(workingDir, "not-a-directory")
if err := os.WriteFile(blocked, []byte("blocked"), 0o600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
override string
configuredDir string
reportOutputName string
want string
wantErr bool
}{
{name: "working directory default", reportOutputName: "today.md", want: filepath.Join(workingDir, "comparison-today")},
{name: "configured relative directory", configuredDir: "configured", reportOutputName: "tomorrow.md", want: filepath.Join(configured, "comparison-tomorrow")},
{name: "configured absolute directory", configuredDir: configured, reportOutputName: "hourly.md", want: filepath.Join(configured, "comparison-hourly")},
{name: "relative explicit directory", override: "exact", configuredDir: blocked, reportOutputName: "daily-2026-08-24.md", want: filepath.Join(workingDir, "exact")},
{name: "absolute explicit directory", override: filepath.Join(workingDir, "absolute"), reportOutputName: "today.md", want: filepath.Join(workingDir, "absolute")},
{name: "invalid report suffix", reportOutputName: "today.txt", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := resolveComparisonOutputDirectory(workingDir, test.override, test.configuredDir, test.reportOutputName)
if (err != nil) != test.wantErr {
t.Fatalf("resolveComparisonOutputDirectory() error = %v, want error %t", err, test.wantErr)
}
if !test.wantErr && got != test.want {
t.Fatalf("resolveComparisonOutputDirectory() = %q, want %q", got, test.want)
}
})
}
}
func TestResolveOutputDirRejectsDanglingSymlinkComponents(t *testing.T) {
workingDir := t.TempDir()
dangling := filepath.Join(workingDir, "dangling")
testutil.RequireSymlink(t, filepath.Join(workingDir, "missing"), dangling)
for _, directory := range []string{dangling, filepath.Join(dangling, "reports")} {
t.Run(filepath.Base(directory), func(t *testing.T) {
if _, err := resolveOutputDir(workingDir, directory); err == nil {
t.Fatalf("resolveOutputDir(%q) error = nil, want dangling symlink error", directory)
}
})
}
}
func TestResolveOutputDirAllowsMissingDirectoryBelowValidSymlink(t *testing.T) {
workingDir := t.TempDir()
target := t.TempDir()
link := filepath.Join(workingDir, "linked")
testutil.RequireSymlink(t, target, link)
directory := filepath.Join(link, "reports")
got, err := resolveOutputDir(workingDir, directory)
if err != nil || got != directory {
t.Fatalf("resolveOutputDir() = %q, %v, want %q, nil", got, err, directory)
}
}

View File

@@ -0,0 +1,148 @@
package app
import (
"encoding/json"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
// preparedReport contains the immutable deterministic inputs shared by prompt
// executions for one resolved report.
type preparedReport struct {
resolved report.Resolved
derived facts.DerivedFacts
moduleSnapshot module.Snapshot
identity briefing.PreparedIdentity
sourceWarnings []weatherdata.SourceWarning
dataPackage []byte
handler generatedtext.Handler
}
type prepareReportRequest struct {
Config config.Config
Resolved report.Resolved
Collection collect.Result
handler generatedtext.Handler
}
type preparationError struct {
operation string
err error
}
func (e *preparationError) Error() string {
return e.operation + ": " + e.err.Error()
}
func (e *preparationError) Unwrap() error {
return e.err
}
func prepareReport(req prepareReportRequest) (preparedReport, error) {
if req.Collection.Bundle == nil {
return preparedReport{}, &preparationError{operation: "prepare report", err: fmt.Errorf("collected weather bundle is required")}
}
reportFacts, err := BuildReportFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved}, req.Collection.Bundle)
if err != nil {
return preparedReport{}, &preparationError{operation: "build report facts", err: err}
}
buildContext := briefingBuildContext(req.Config, req.Resolved, reportFacts.Collected)
identity := briefing.BuildPreparedIdentity(buildContext)
moduleSnapshot, err := BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: req.Config, Resolved: req.Resolved, Identity: identity}, reportFacts)
if err != nil {
return preparedReport{}, &preparationError{operation: "build module snapshot", err: err}
}
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(identity), Modules: moduleSnapshot})
if err != nil {
return preparedReport{}, &preparationError{operation: "build data package", err: err}
}
serializedDataPackage, err := promptinput.MarshalYAML(dataPackage)
if err != nil {
return preparedReport{}, &preparationError{operation: "marshal data package", err: err}
}
clonedDerived, err := clonePreparedValue(reportFacts.Derived)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared derived facts", err: err}
}
clonedSnapshot, err := clonePreparedValue(moduleSnapshot)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared module snapshot", err: err}
}
clonedIdentity, err := clonePreparedValue(identity)
if err != nil {
return preparedReport{}, &preparationError{operation: "copy prepared identity", err: err}
}
prepared := preparedReport{
resolved: cloneResolved(req.Resolved),
derived: clonedDerived,
moduleSnapshot: clonedSnapshot,
identity: clonedIdentity,
sourceWarnings: append([]weatherdata.SourceWarning(nil), clonedIdentity.SourceWarnings...),
dataPackage: append([]byte(nil), serializedDataPackage...),
handler: req.handler,
}
return prepared, nil
}
func cloneResolved(value report.Resolved) report.Resolved {
cloned := value
cloned.Definition.DistributorPathTemplates = append([]string(nil), value.Definition.DistributorPathTemplates...)
cloned.Definition.Modules = make([]module.ConfigItem, len(value.Definition.Modules))
for i, item := range value.Definition.Modules {
cloned.Definition.Modules[i] = item
switch options := item.Options.(type) {
case module.AreaForecastDiscussionOptions:
options.Sections = append([]string(nil), options.Sections...)
cloned.Definition.Modules[i].Options = options
}
}
return cloned
}
func (p preparedReport) dataPackageCopy() []byte {
return append([]byte(nil), p.dataPackage...)
}
func (p preparedReport) sourceWarningsCopy() []weatherdata.SourceWarning {
return append([]weatherdata.SourceWarning(nil), p.sourceWarnings...)
}
func (p preparedReport) renderInputs() (briefing.PreparedIdentity, module.Snapshot, facts.DerivedFacts, error) {
identity, err := clonePreparedValue(p.identity)
if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
snapshot, err := clonePreparedValue(p.moduleSnapshot)
if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
derived, err := clonePreparedValue(p.derived)
if err != nil {
return briefing.PreparedIdentity{}, module.Snapshot{}, facts.DerivedFacts{}, err
}
return identity, snapshot, derived, nil
}
func clonePreparedValue[T any](value T) (T, error) {
encoded, err := json.Marshal(value)
if err != nil {
var zero T
return zero, fmt.Errorf("marshal immutable prepared value: %w", err)
}
var cloned T
if err := json.Unmarshal(encoded, &cloned); err != nil {
var zero T
return zero, fmt.Errorf("unmarshal immutable prepared value: %w", err)
}
return cloned, nil
}

View File

@@ -0,0 +1,121 @@
package app
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestPrepareReportBuildsImmutableDeterministicInputs(t *testing.T) {
cfg := generationConfig()
bundle := generationBundle(t)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
}, generationTime("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
request := prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)}
prepared, err := prepareReport(request)
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
repeated, err := prepareReport(request)
if err != nil {
t.Fatalf("second prepareReport() error = %v", err)
}
if len(prepared.dataPackage) == 0 || !bytes.Equal(prepared.dataPackage, repeated.dataPackage) || !reflect.DeepEqual(prepared.identity, repeated.identity) {
t.Fatalf("prepared package and identity are not deterministic: %q/%#v", prepared.dataPackage, prepared.identity)
}
originalDataPackage := append([]byte(nil), prepared.dataPackage...)
originalIdentity := prepared.identity
originalDerived := prepared.derived
originalWarnings := append([]weatherdata.SourceWarning(nil), prepared.sourceWarnings...)
identity, snapshot, derived, err := prepared.renderInputs()
if err != nil {
t.Fatalf("renderInputs() error = %v", err)
}
identity.SourceWarnings = append(identity.SourceWarnings, weatherdata.SourceWarning{Source: "test", Message: "consumer mutation"})
snapshot.Outputs = nil
derived.PrecipTiming.ThunderMentioned = false
bundle.Hourly.Periods[0].TextDescription = "mutated after preparation"
bundle.Warnings = append(bundle.Warnings, weatherdata.SourceWarning{Source: "test", Message: "mutated warning"})
if len(bundle.Sources) > 0 {
if bundle.Sources[0].Query == nil {
bundle.Sources[0].Query = map[string]string{}
}
bundle.Sources[0].Query["mutated"] = "true"
}
if !bytes.Equal(prepared.dataPackage, originalDataPackage) || !reflect.DeepEqual(prepared.identity, originalIdentity) || !reflect.DeepEqual(prepared.derived, originalDerived) || !reflect.DeepEqual(prepared.sourceWarnings, originalWarnings) {
t.Fatalf("prepared values changed after caller mutation: %#v", prepared)
}
if len(prepared.moduleSnapshot.Outputs) == 0 {
t.Fatal("prepared report values retain consumer mutation")
}
}
func TestPrepareReportProjectsPreparedIdentity(t *testing.T) {
cfg := generationConfig()
bundle := generationBundle(t)
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
}, generationTime("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
identity := prepared.identity
renderIdentity, _, _, err := prepared.renderInputs()
if err != nil {
t.Fatalf("renderInputs() error = %v", err)
}
if !reflect.DeepEqual(renderIdentity, identity) {
t.Fatalf("render identity = %#v, want %#v", renderIdentity, identity)
}
prompt := promptMetadata(identity)
if prompt.RunID != identity.RunID || prompt.ReportID != identity.ReportID || prompt.Variant != identity.Variant || prompt.PromptID != identity.PromptID || !prompt.GeneratedAt.Equal(identity.GeneratedAt) || prompt.Timezone != identity.Timezone || prompt.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(prompt.SourceWarnings, identity.SourceWarnings) {
t.Fatalf("prompt metadata does not match prepared identity: %#v/%#v", prompt, identity)
}
moduleMetadata, found, err := module.StanzaValue[briefing.MetadataModule](prepared.moduleSnapshot, "metadata")
if err != nil || !found {
t.Fatalf("metadata stanza = %#v/%t/%v", moduleMetadata, found, err)
}
if moduleMetadata.RunID != identity.RunID || moduleMetadata.ReportID != identity.ReportID || moduleMetadata.Variant != identity.Variant || moduleMetadata.PromptID != identity.PromptID || !moduleMetadata.GeneratedAt.Equal(identity.GeneratedAt) || moduleMetadata.Units != identity.Units || moduleMetadata.Timezone != identity.Timezone || moduleMetadata.ValidPeriod != identity.ValidPeriod || !reflect.DeepEqual(moduleMetadata.Location, identity.Location) {
t.Fatalf("module metadata does not match prepared identity: %#v/%#v", moduleMetadata, identity)
}
if len(moduleMetadata.SourceWarnings) != len(identity.SourceWarnings) {
t.Fatalf("module source warnings = %#v, want %#v", moduleMetadata.SourceWarnings, identity.SourceWarnings)
}
for index, warning := range identity.SourceWarnings {
summary := moduleMetadata.SourceWarnings[index]
if summary.Source != warning.Source || summary.Code != warning.Code || summary.Severity != warning.Severity || summary.Message != warning.Message || summary.CompletenessImpact != warning.CompletenessImpact {
t.Fatalf("module source warning %d = %#v, want %#v", index, summary, warning)
}
}
}
func preparedHandler(t *testing.T, resolved report.Resolved) generatedtext.Handler {
t.Helper()
handler, err := generatedtext.LookupDefinition(resolved.Definition)
if err != nil {
t.Fatalf("LookupDefinition() error = %v", err)
}
return handler
}

View File

@@ -0,0 +1,222 @@
package app
import (
"context"
"errors"
"fmt"
"reflect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type profileExecutionRequest struct {
Prepared preparedReport
Prompt PromptInspectionResult
Profile promptexec.ProfileInspection
Executor promptexec.Executor
DebugWriter *promptdebug.PromptDebugWriter
DebugRef *promptdebug.PromptDebugRef
}
type profileExecutionOutcome struct {
ProfileID string
BackendID string
ModelName string
ValidationStatus promptexec.ValidationStatus
RepairAttempts *int
LLMDebugPath string
}
type profileExecutionError struct {
operation string
err error
callbackFailure bool
}
func (e *profileExecutionError) Error() string {
return e.operation + ": " + e.err.Error()
}
func (e *profileExecutionError) Unwrap() error {
return e.err
}
func executePreparedProfile(ctx context.Context, req profileExecutionRequest) (profileExecutionOutcome, []byte, error) {
outcome := profileExecutionOutcome{
ProfileID: req.Profile.ProfileID,
BackendID: req.Profile.BackendID,
ModelName: req.Profile.ModelName,
}
if req.Executor == nil {
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)}
}
if err := validatePreparedExecutionRequest(req); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
}
callbackFailed := false
preparationCount := 0
var preparation promptexec.Preparation
preparationCallback := func(value promptexec.Preparation, debug *promptexec.PreparationDebug) error {
preparationCount++
if preparationCount != 1 {
return promptProvenanceError()
}
if err := validatePreparationProvenance(req, value); err != nil {
return err
}
preparation = clonePreparation(value)
if req.DebugWriter == nil || !req.DebugWriter.Enabled() {
return nil
}
if req.DebugRef == nil {
callbackFailed = true
return promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))
}
path, err := req.DebugWriter.WritePreparation(*req.DebugRef, value, debug)
if err != nil {
callbackFailed = true
return promptDebugWriteError(err)
}
outcome.LLMDebugPath = path
return nil
}
captureDebug := req.DebugWriter != nil && req.DebugWriter.Enabled()
execution, err := req.Executor.Execute(ctx, promptexec.ExecuteRequest{
PromptID: req.Prompt.PromptID,
PromptVersion: req.Prompt.PromptVersion,
ProfileID: req.Profile.ProfileID,
DataPackage: req.Prepared.dataPackageCopy(),
CaptureDebug: captureDebug,
}, preparationCallback)
if err != nil {
if callbackFailed {
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: err, callbackFailure: true}
}
if req.DebugWriter != nil && req.DebugWriter.Enabled() && req.DebugRef != nil {
var generationError *promptexec.GenerationError
if errors.As(err, &generationError) {
path, debugErr := req.DebugWriter.WriteFailure(*req.DebugRef, generationError)
if path != "" {
outcome.LLMDebugPath = path
}
if debugErr != nil {
err = errors.Join(err, promptDebugWriteError(debugErr))
}
}
}
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: classifiedPromptError("prompt execution failed", err)}
}
if execution == nil {
return outcome, nil, &profileExecutionError{operation: "execute prompt", err: promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil)}
}
outcome.RepairAttempts = repairAttemptsPointer(execution.Validation.RepairAttempts)
if preparationCount != 1 {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: promptProvenanceError()}
}
if err := validateExecutionProvenance(req, preparation, *execution); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate prompt provenance", err: err}
}
outcome.ValidationStatus = execution.Validation.Status
if err := generatedtext.ValidateRawOutput(execution.RawOutput); err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
}
if req.DebugWriter != nil && req.DebugWriter.Enabled() {
if req.DebugRef == nil {
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(fmt.Errorf("prompt debug reference is required"))}
}
path, err := req.DebugWriter.WriteExecution(*req.DebugRef, *execution)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "write prompt debug", err: promptDebugWriteError(err)}
}
if path != "" {
outcome.LLMDebugPath = path
}
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil)}
}
if execution.Validation.Status == promptexec.ValidationFailed {
return outcome, nil, &profileExecutionError{operation: "validate prompt execution", err: promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil)}
}
generatedText, err := req.Prepared.handler.Validate(execution.RawOutput)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "validate generated text", err: err}
}
identity, snapshot, derived, err := req.Prepared.renderInputs()
if err != nil {
return outcome, nil, &profileExecutionError{operation: "copy prepared render inputs", err: err}
}
renderContext, err := req.Prepared.handler.BuildRenderContext(identity, snapshot, derived, generatedText)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "build render context", err: err}
}
rendered, err := req.Prepared.handler.Render(renderContext)
if err != nil {
return outcome, nil, &profileExecutionError{operation: "render template", err: err}
}
return outcome, rendered, nil
}
func validatePreparedExecutionRequest(req profileExecutionRequest) error {
definition := req.Prepared.resolved.Definition
if definition.PromptID != req.Prompt.PromptID || definition.PromptVersion != req.Prompt.PromptVersion ||
definition.GeneratedTextSchemaID != req.Prepared.handler.SchemaID() {
return promptProvenanceError()
}
if req.Prompt.ProfileID != "" && (req.Prompt.ProfileID != req.Profile.ProfileID || req.Prompt.BackendID != req.Profile.BackendID || req.Prompt.ModelName != req.Profile.ModelName) {
return promptProvenanceError()
}
if req.Prompt.PromptHash == "" || req.Profile.ProfileID == "" || req.Profile.ModelName == "" {
return promptProvenanceError()
}
return nil
}
func validatePreparationProvenance(req profileExecutionRequest, preparation promptexec.Preparation) error {
definition := req.Prepared.resolved.Definition
if preparation.PromptID != req.Prompt.PromptID || preparation.PromptVersion != req.Prompt.PromptVersion || preparation.PromptHash != req.Prompt.PromptHash ||
preparation.ProfileID != req.Profile.ProfileID || preparation.BackendID != req.Profile.BackendID || preparation.ModelName != req.Profile.ModelName ||
!validPromptOutput(definition, preparation.Output) {
return promptProvenanceError()
}
return nil
}
func validateExecutionProvenance(req profileExecutionRequest, preparation promptexec.Preparation, execution promptexec.Execution) error {
definition := req.Prepared.resolved.Definition
if execution.PromptID != preparation.PromptID || execution.PromptVersion != preparation.PromptVersion || execution.PromptHash != preparation.PromptHash ||
execution.RenderedPromptHash != preparation.RenderedPromptHash || !reflect.DeepEqual(execution.InputHashes, preparation.InputHashes) ||
execution.ProfileID != preparation.ProfileID || execution.BackendID != preparation.BackendID || execution.ModelName != preparation.ModelName ||
execution.Validation.Mode != "json_schema" || execution.Validation.SchemaPath != definition.GeneratedTextSchemaID+".generated_text.schema.json" ||
execution.Validation.RepairAttempts < 0 || execution.Validation.RepairAttempts > preparation.Output.RepairAttempts ||
preparation.Output.RepairAttempts != definition.GeneratedTextRepairAttempts {
return promptProvenanceError()
}
return nil
}
func repairAttemptsPointer(value int) *int {
copy := value
return &copy
}
func promptProvenanceError() error {
return promptexec.NewError(promptexec.InvalidConfiguration, "prompt execution provenance is inconsistent", nil)
}
func clonePreparation(value promptexec.Preparation) promptexec.Preparation {
if value.InputHashes != nil {
inputHashes := make(map[string]string, len(value.InputHashes))
for name, hash := range value.InputHashes {
inputHashes[name] = hash
}
value.InputHashes = inputHashes
}
return value
}

View File

@@ -0,0 +1,190 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
func TestExecutePreparedProfileRendersWithoutPublishing(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
executor := &generationExecutor{}
outputPath := filepath.Join(t.TempDir(), "report.md")
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
Prepared: prepared, Prompt: inspection,
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
Executor: executor,
})
if err != nil {
t.Fatalf("executePreparedProfile() error = %v", err)
}
if len(rendered) == 0 || outcome.ValidationStatus != promptexec.ValidationPassed || outcome.RepairAttempts == nil || *outcome.RepairAttempts != 0 || outcome.ProfileID != inspection.ProfileID || executor.executeCalls != 1 {
t.Fatalf("outcome/rendered/execution calls = %#v/%q/%d", outcome, rendered, executor.executeCalls)
}
if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) {
t.Fatalf("execution unexpectedly published %q: %v", outputPath, statErr)
}
}
func TestExecutePreparedProfileRetainsCompletedRepairAttemptsOnLaterFailure(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
prepared.resolved.Definition.GeneratedTextRepairAttempts = 1
executor := &generationExecutor{repairAttempts: 1, rawOutput: []byte(`{"summary":42}`), prepare: func(value *promptexec.Preparation) { value.Output.RepairAttempts = 1 }}
outcome, _, err := executePreparedProfile(context.Background(), profileExecutionRequest{
Prepared: prepared, Prompt: inspection,
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
Executor: executor,
})
if err == nil || outcome.RepairAttempts == nil || *outcome.RepairAttempts != 1 {
t.Fatalf("outcome/error = %#v/%v", outcome, err)
}
}
func TestExecutePreparedProfileKeepsDebugCallbackFailureLocal(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
debugWriter, err := promptdebug.NewPromptDebugWriter(t.TempDir())
if errors.Is(err, promptdebug.ErrSecureCaptureUnsupported) {
t.Skipf("secure prompt debug capture is unavailable: %v", err)
}
if err != nil {
t.Fatalf("NewPromptDebugWriter() error = %v", err)
}
executor := &generationExecutor{}
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{
Prepared: prepared, Prompt: inspection,
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
Executor: executor, DebugWriter: debugWriter,
DebugRef: &promptdebug.PromptDebugRef{ReportID: inspectionResolved(t).Definition.ID, ValidDate: "2026-05-29", RunID: "invalid/path"},
})
var executionErr *profileExecutionError
if err == nil || !errors.As(err, &executionErr) || !executionErr.callbackFailure || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 || executor.executeCalls != 0 || outcome.LLMDebugPath != "" {
t.Fatalf("outcome/rendered/error/execution calls = %#v/%q/%v/%d", outcome, rendered, err, executor.executeCalls)
}
}
func TestExecutePreparedProfileBoundsOversizedExecutorOutput(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
marker := "provider-controlled-marker"
executor := &generationExecutor{rawOutput: []byte(strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1) + marker)}
_, _, err := executePreparedProfile(context.Background(), profileExecutionRequest{
Prepared: prepared, Prompt: inspection,
Profile: promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName},
Executor: executor,
})
if err == nil || !strings.Contains(err.Error(), "65536-byte limit") {
t.Fatalf("executePreparedProfile() error = %v, want bounded raw size error", err)
}
if len(err.Error()) > 160 || strings.Contains(err.Error(), marker) {
t.Fatalf("ordinary error leaked provider content: %q", err)
}
}
func TestExecutePreparedProfileRejectsInconsistentProvenance(t *testing.T) {
tests := []struct {
name string
mutate func(*preparedReport, *PromptInspectionResult, *promptexec.ProfileInspection, *generationExecutor)
invoked bool
}{
{
name: "prepared definition", mutate: func(prepared *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, _ *generationExecutor) {
prepared.resolved.Definition.PromptVersion = "different-version"
},
},
{
name: "missing callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.skipPreparation = true
}, invoked: true,
},
{
name: "duplicate callback", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.preparationCalls = 2
},
},
{
name: "callback prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) { value.PromptHash = "different-hash" }
},
},
{
name: "callback output schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) { value.Output.SchemaPath = "other.generated_text.schema.json" }
},
},
{
name: "completed profile", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.ProfileID = "different-profile" }
}, invoked: true,
},
{
name: "completed rendered prompt hash", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.RenderedPromptHash = "different-rendered-hash" }
}, invoked: true,
},
{
name: "completed input hashes", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.prepare = func(value *promptexec.Preparation) {
value.InputHashes = map[string]string{"data_package": "prepared-hash"}
}
executor.complete = func(value *promptexec.Execution) {
value.InputHashes = map[string]string{"data_package": "completed-hash"}
}
}, invoked: true,
},
{
name: "completed validation mode", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.Validation.Mode = "other" }
}, invoked: true,
},
{
name: "completed validation schema", mutate: func(_ *preparedReport, _ *PromptInspectionResult, _ *promptexec.ProfileInspection, executor *generationExecutor) {
executor.complete = func(value *promptexec.Execution) { value.Validation.SchemaPath = "other.generated_text.schema.json" }
}, invoked: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prepared, inspection := preparedDailyProfile(t)
profile := promptexec.ProfileInspection{ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName}
executor := &generationExecutor{}
tt.mutate(&prepared, &inspection, &profile, executor)
outcome, rendered, err := executePreparedProfile(context.Background(), profileExecutionRequest{Prepared: prepared, Prompt: inspection, Profile: profile, Executor: executor})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration || len(rendered) != 0 {
t.Fatalf("outcome/rendered/error = %#v/%q/%v", outcome, rendered, err)
}
if outcome.ProfileID != profile.ProfileID || outcome.BackendID != profile.BackendID || outcome.ModelName != profile.ModelName || outcome.ValidationStatus != "" {
t.Fatalf("outcome retained unverified provenance: %#v", outcome)
}
if (executor.executeCalls == 1) != tt.invoked {
t.Fatalf("executor calls = %d, want invoked=%t", executor.executeCalls, tt.invoked)
}
})
}
}
func preparedDailyProfile(t *testing.T) (preparedReport, PromptInspectionResult) {
t.Helper()
cfg := generationConfig()
resolved, err := ResolveGenerate(GenerateRequest{
Config: cfg, Report: ReportDaily,
Date: generationTime("2026-05-29T12:00:00-05:00"), Now: generationTime("2026-05-29T08:30:00-05:00"),
}, generationTime("2026-05-29T08:30:00-05:00"))
if err != nil {
t.Fatalf("ResolveGenerate() error = %v", err)
}
bundle := generationBundle(t)
prepared, err := prepareReport(prepareReportRequest{Config: cfg, Resolved: resolved, Collection: collect.Result{Bundle: &bundle}, handler: preparedHandler(t, resolved)})
if err != nil {
t.Fatalf("prepareReport() error = %v", err)
}
return prepared, PromptInspectionResult{PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion, PromptHash: generationPromptHash, ProfileID: "fixture", BackendID: "fixture", ModelName: "fixture-model"}
}

View File

@@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
DebugWriter *promptdebug.PromptDebugWriter
Result *ReportResult
noNotify bool
}
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
if req.Collection.Bundle == nil {
return nil, fmt.Errorf("collected weather bundle is required")
}
result := req.Result
if result == nil {
result = initialReportResult(req.GenerateRequest, req.Resolved, req.Inspection)
}
prepared, err := prepareReport(prepareReportRequest{Config: req.Config, Resolved: req.Resolved, Collection: req.Collection, handler: req.Inspection.handler})
if err != nil {
return result, generatedPreparationError(req.Resolved, result.RunID, err)
}
result.SourceWarnings = prepared.sourceWarningsCopy()
debugRef := promptdebug.PromptDebugRef{ReportID: result.ReportID, ValidDate: prepared.resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: result.RunID}
outcome, rendered, err := executePreparedProfile(ctx, profileExecutionRequest{
Prepared: prepared,
Prompt: req.Inspection,
Profile: promptexec.ProfileInspection{
ProfileID: req.Inspection.ProfileID,
BackendID: req.Inspection.BackendID,
ModelName: req.Inspection.ModelName,
},
Executor: req.Executor, DebugWriter: req.DebugWriter, DebugRef: &debugRef,
})
result.ProfileID, result.BackendID, result.ModelName = outcome.ProfileID, outcome.BackendID, outcome.ModelName
result.ValidationStatus = outcome.ValidationStatus
if outcome.RepairAttempts != nil {
result.RepairAttempts = repairAttemptsPointer(*outcome.RepairAttempts)
}
result.LLMDebugPath = outcome.LLMDebugPath
if err != nil {
return result, generatedProfileExecutionError(req.Resolved, result.RunID, err)
}
return publishPromptReport(ctx, promptPublicationRequest{
GenerateRequest: req.GenerateRequest,
Resolved: req.Resolved,
OutputPath: req.OutputPath,
Result: result,
Markdown: rendered,
suppressNotification: req.noNotify,
})
}
func initialReportResult(req GenerateRequest, resolved report.Resolved, inspection PromptInspectionResult) *ReportResult {
metadata := resolved.Metadata()
return &ReportResult{
ReportID: resolved.Definition.ID, ReportName: resolved.Definition.Name,
PromptID: resolved.Definition.PromptID, PromptVersion: resolved.Definition.PromptVersion,
RunID: metadata.RunID, GeneratedAt: metadata.GeneratedAt, Timezone: req.Config.WeatherAPI.Timezone,
ValidPeriod: metadata.ValidPeriod,
ProfileID: inspection.ProfileID, BackendID: inspection.BackendID, ModelName: inspection.ModelName,
}
}
type promptPublicationRequest struct {
GenerateRequest
Resolved report.Resolved
OutputPath string
Result *ReportResult
Markdown []byte
suppressNotification bool
}
func publishPromptReport(ctx context.Context, req promptPublicationRequest) (*ReportResult, error) {
if err := publicationContextError(ctx); err != nil {
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", err)
}
if err := fileutil.WriteFileAtomicContext(ctx, req.OutputPath, req.Markdown); err != nil {
if contextErr := publicationContextError(ctx); contextErr != nil {
return req.Result, generatedReportError(req.Resolved, req.Result.RunID, "publish report", contextErr)
}
return req.Result, err
}
req.Result.OutputPath = req.OutputPath
if req.suppressNotification {
return req.Result, nil
}
notification, err := notifyReport(ctx, req.Config, req.Resolved, req.Result.OutputPath, req.Result.RunID, req.Result.GeneratedAt, req.Notifier)
req.Result.Notification = notification
if err != nil {
return req.Result, err
}
return req.Result, nil
}
func generatedPreparationError(resolved report.Resolved, runID string, err error) error {
var preparation *preparationError
if errors.As(err, &preparation) {
return generatedReportError(resolved, runID, preparation.operation, preparation.err)
}
return generatedReportError(resolved, runID, "prepare report", err)
}
func generatedProfileExecutionError(resolved report.Resolved, runID string, err error) error {
var execution *profileExecutionError
if errors.As(err, &execution) {
if execution.callbackFailure {
return execution.err
}
return generatedReportError(resolved, runID, execution.operation, execution.err)
}
return generatedReportError(resolved, runID, "execute prompt", err)
}
func classifiedPromptError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.Generation, operation, err)
}
func publicationContextError(ctx context.Context) error {
if err := ctx.Err(); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
return promptexec.NewError(promptexec.DeadlineExceeded, "context expired before output publication", err)
}
return promptexec.NewError(promptexec.Canceled, "context canceled before output publication", err)
}
return nil
}
func promptDebugWriteError(err error) error {
return promptexec.NewError(promptexec.InvalidConfiguration, "write requested prompt debug artifact", err)
}

View File

@@ -0,0 +1,226 @@
package app
import (
"context"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/comparison"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
// PromptInspectionRequest contains the non-executing inputs required to
// validate one report's configured prompt and profile.
type PromptInspectionRequest struct {
Resolved report.Resolved
Executor promptexec.Executor
Promptkit config.PromptkitConfig
}
// PromptInspectionResult contains only safe identity and provenance from a
// prompt/profile inspection.
type PromptInspectionResult struct {
PromptID string
PromptVersion string
PromptHash string
ProfileID string
BackendID string
ModelName string
handler generatedtext.Handler
}
// PromptExecutionsInspectionRequest validates all prompt/profile combinations
// needed by a batch before collection begins.
type PromptExecutionsInspectionRequest struct {
Resolved []report.Resolved
Executor promptexec.Executor
Promptkit config.PromptkitConfig
}
// ComparisonInspectionRequest contains the explicit profile selection for one
// resolved prompt comparison. It intentionally has no configured profile field.
type ComparisonInspectionRequest struct {
Resolved report.Resolved
ProfileIDs []string
Executor promptexec.Executor
}
// ComparisonInspectionResult contains the safe, shared prompt identity and
// ordered effective profile identities for a comparison.
type ComparisonInspectionResult struct {
PromptID string
PromptVersion string
PromptHash string
Profiles []ComparisonProfileInspection
handler generatedtext.Handler
}
// ComparisonProfileInspection contains one requested profile's safe effective
// execution identity.
type ComparisonProfileInspection struct {
ProfileID string
BackendID string
ModelName string
}
// InspectPromptExecution validates the exact prompt and profile needed for a
// report before collection, execution, or durable writes begin.
func InspectPromptExecution(ctx context.Context, req PromptInspectionRequest) (PromptInspectionResult, error) {
results, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{
Resolved: []report.Resolved{req.Resolved},
Executor: req.Executor,
Promptkit: req.Promptkit,
})
if err != nil {
return PromptInspectionResult{}, err
}
return results[req.Resolved.Definition.ID], nil
}
// InspectPromptExecutions validates exact prompt contracts and their unique
// effective profiles. It performs no collection, execution, or durable write.
func InspectPromptExecutions(ctx context.Context, req PromptExecutionsInspectionRequest) (map[report.ID]PromptInspectionResult, error) {
if req.Executor == nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
}
results := make(map[report.ID]PromptInspectionResult, len(req.Resolved))
profiles := map[string]promptexec.ProfileInspection{}
for _, resolved := range req.Resolved {
definition := resolved.Definition
handler, err := generatedtext.LookupDefinition(definition)
if err != nil {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err)
}
inspection, err := inspectPromptContract(ctx, req.Executor, definition)
if err != nil {
return nil, err
}
profileID := req.Promptkit.Profile
if profileID == "" {
profileID = inspection.DefaultProfileID
}
if strings.TrimSpace(profileID) == "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt has no execution profile", nil)
}
profile, ok := profiles[profileID]
if !ok {
profile, err = inspectPromptProfile(ctx, req.Executor, profileID)
if err != nil {
return nil, err
}
profiles[profileID] = profile
}
results[definition.ID] = PromptInspectionResult{
PromptID: inspection.PromptID, PromptVersion: inspection.PromptVersion, PromptHash: inspection.PromptHash,
ProfileID: profile.ProfileID, BackendID: profile.BackendID, ModelName: profile.ModelName,
handler: handler,
}
}
return results, nil
}
// InspectComparisonExecution validates one exact prompt and every explicitly
// requested profile before collection or model execution. Profiles are
// inspected sequentially in request order. If a profile fails, the returned
// partial result retains the prompt identity and successfully inspected prefix.
func InspectComparisonExecution(ctx context.Context, req ComparisonInspectionRequest) (ComparisonInspectionResult, error) {
if err := comparison.ValidateProfileIDs(req.ProfileIDs); err != nil {
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidRequest, "comparison profile selection is invalid", err)
}
if req.Executor == nil {
return ComparisonInspectionResult{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt executor is required", nil)
}
handler, err := generatedtext.LookupDefinition(req.Resolved.Definition)
if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison generated-text catalog inspection failed", promptexec.NewError(promptexec.InvalidConfiguration, "report generated-text catalog is incompatible", err))
}
inspection, err := inspectPromptContract(ctx, req.Executor, req.Resolved.Definition)
if err != nil {
return ComparisonInspectionResult{}, comparisonInspectionError("comparison prompt inspection failed", err)
}
result := ComparisonInspectionResult{
PromptID: inspection.PromptID,
PromptVersion: inspection.PromptVersion,
PromptHash: inspection.PromptHash,
Profiles: make([]ComparisonProfileInspection, 0, len(req.ProfileIDs)),
handler: handler,
}
for _, profileID := range req.ProfileIDs {
profile, err := inspectPromptProfile(ctx, req.Executor, profileID)
if err != nil {
return result, comparisonInspectionError("comparison profile inspection failed", err)
}
result.Profiles = append(result.Profiles, ComparisonProfileInspection{
ProfileID: profile.ProfileID,
BackendID: profile.BackendID,
ModelName: profile.ModelName,
})
}
return result, nil
}
func inspectPromptContract(ctx context.Context, executor promptexec.Executor, definition report.Definition) (promptexec.PromptInspection, error) {
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
}
inspection, err := executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
if err != nil {
return promptexec.PromptInspection{}, promptInspectionError("prompt inspection failed", err)
}
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
}
if strings.TrimSpace(inspection.PromptHash) == "" {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return a prompt hash", nil)
}
if !validPromptInput(inspection.Inputs) {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
}
if !validPromptOutput(definition, inspection.Output) {
return promptexec.PromptInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
}
return inspection, nil
}
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string) (promptexec.ProfileInspection, error) {
profile, err := executor.InspectProfile(ctx, profileID)
if err != nil {
return promptexec.ProfileInspection{}, promptInspectionError("profile inspection failed", err)
}
if profile.ProfileID != profileID {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return the selected profile", nil)
}
if profile.CredentialRequired {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile requires an unsupported direct API key", nil)
}
if strings.TrimSpace(profile.ModelName) == "" {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.InvalidConfiguration, "profile inspection did not return a complete execution identity", nil)
}
return profile, nil
}
func validPromptInput(inputs []promptexec.InputDefinition) bool {
return len(inputs) == 1 && inputs[0].Name == "data_package" && inputs[0].Required && inputs[0].ContentType == "application/yaml"
}
func validPromptOutput(definition report.Definition, output promptexec.OutputContract) bool {
return output.Format == "json" && output.ValidationMode == "json_schema" && output.SchemaPath == definition.GeneratedTextSchemaID+".generated_text.schema.json" && output.RepairAttempts == definition.GeneratedTextRepairAttempts
}
func promptInspectionError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
}
func comparisonInspectionError(operation string, err error) error {
category := promptexec.CategoryOf(err)
if category == "" {
category = promptexec.InvalidConfiguration
}
return promptexec.NewError(category, operation, err)
}

View File

@@ -0,0 +1,365 @@
package app
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestInspectPromptExecutionSelectsDefaultAndOverrideProfiles(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{
prompt: validPromptInspection(resolved.Definition),
profiles: map[string]promptexec.ProfileInspection{
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "default-model"},
"override-profile": {ProfileID: "override-profile", BackendID: "cloud", ModelName: "override-model"},
},
}
defaultResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
if err != nil {
t.Fatalf("InspectPromptExecution(default) error = %v", err)
}
if defaultResult.ProfileID != "default-profile" || defaultResult.ModelName != "default-model" {
t.Fatalf("default result = %#v", defaultResult)
}
overrideResult, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{
Resolved: resolved, Executor: executor, Promptkit: config.PromptkitConfig{Profile: "override-profile"},
})
if err != nil {
t.Fatalf("InspectPromptExecution(override) error = %v", err)
}
if overrideResult.ProfileID != "override-profile" || overrideResult.ModelName != "override-model" {
t.Fatalf("override result = %#v", overrideResult)
}
if len(executor.promptRequests) != 2 || executor.promptRequests[0].version != resolved.Definition.PromptVersion || executor.profileRequests[0] != "default-profile" || executor.profileRequests[1] != "override-profile" {
t.Fatalf("inspection requests = prompts %#v profiles %#v", executor.promptRequests, executor.profileRequests)
}
}
func TestInspectPromptExecutionRejectsInvalidContractsAndCredentials(t *testing.T) {
resolved := inspectionResolved(t)
basePrompt := validPromptInspection(resolved.Definition)
tests := []struct {
name string
prompt promptexec.PromptInspection
profile promptexec.ProfileInspection
wantCategory promptexec.ErrorCategory
}{
{
name: "extra input",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.Inputs = append(value.Inputs, promptexec.InputDefinition{Name: "unexpected"})
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "wrong schema",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.Output.SchemaPath = "unexpected.schema.json"
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "missing prompt hash",
prompt: func() promptexec.PromptInspection {
value := basePrompt
value.PromptHash = ""
return value
}(),
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "missing profile model",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", BackendID: "backend"},
wantCategory: promptexec.InvalidConfiguration,
},
{
name: "direct key",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
wantCategory: promptexec.MissingCredential,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
executor := &inspectionExecutor{prompt: test.prompt, profiles: map[string]promptexec.ProfileInspection{"default-profile": test.profile}}
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
if err == nil || promptexec.CategoryOf(err) != test.wantCategory {
t.Fatalf("error/category = %v/%q, want %q", err, promptexec.CategoryOf(err), test.wantCategory)
}
})
}
}
func TestInspectPromptExecutionReturnsSafeInspectionError(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{promptErr: errors.New("provider response contains resolved-secret-value")}
_, err := InspectPromptExecution(context.Background(), PromptInspectionRequest{Resolved: resolved, Executor: executor})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("error/category = %v/%q", err, promptexec.CategoryOf(err))
}
if strings.Contains(err.Error(), "resolved-secret-value") {
t.Fatalf("inspection error leaks provider value: %v", err)
}
}
func TestInspectPromptExecutionsReusesEffectiveProfile(t *testing.T) {
first := inspectionResolved(t)
second := inspectionResolvedFor(t, report.Today)
executor := &inspectionExecutor{
prompt: validPromptInspection(first.Definition),
profiles: map[string]promptexec.ProfileInspection{
"default-profile": {ProfileID: "default-profile", BackendID: "local", ModelName: "model"},
},
}
executor.prompts = map[string]promptexec.PromptInspection{
first.Definition.PromptID: validPromptInspection(first.Definition),
second.Definition.PromptID: validPromptInspection(second.Definition),
}
results, err := InspectPromptExecutions(context.Background(), PromptExecutionsInspectionRequest{Resolved: []report.Resolved{first, second}, Executor: executor})
if err != nil {
t.Fatalf("InspectPromptExecutions() error = %v", err)
}
if len(results) != 2 || len(executor.profileRequests) != 1 {
t.Fatalf("results/profile requests = %#v/%#v, want two results and one profile inspection", results, executor.profileRequests)
}
}
func TestPromptInspectionRejectsIncompatibleGeneratedTextCatalogBeforeExecutorWork(t *testing.T) {
base := inspectionResolved(t)
tests := []struct {
name string
resolved report.Resolved
inspect func(context.Context, report.Resolved, *inspectionExecutor) error
}{
{
name: "single report unknown template",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.TemplateID = "unknown"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecution(ctx, PromptInspectionRequest{Resolved: resolved, Executor: executor})
return err
},
},
{
name: "batch known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectPromptExecutions(ctx, PromptExecutionsInspectionRequest{Resolved: []report.Resolved{resolved}, Executor: executor})
return err
},
},
{
name: "comparison known pair for another report",
resolved: func() report.Resolved {
resolved := base
resolved.Definition.GeneratedTextSchemaID = "today"
resolved.Definition.TemplateID = "today"
return resolved
}(),
inspect: func(ctx context.Context, resolved report.Resolved, executor *inspectionExecutor) error {
_, err := InspectComparisonExecution(ctx, ComparisonInspectionRequest{Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor})
return err
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
executor := &inspectionExecutor{}
err := test.inspect(context.Background(), test.resolved, executor)
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("inspection error/category = %v/%q, want invalid configuration", err, promptexec.CategoryOf(err))
}
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
t.Fatalf("incompatible catalog performed executor work: prompts %#v profiles %#v executions %d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
})
}
}
func TestInspectComparisonExecutionPreservesOrderedExplicitProfiles(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{
prompt: validPromptInspection(resolved.Definition),
profiles: map[string]promptexec.ProfileInspection{
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
},
}
profileIDs := []string{"weather-light", "weather-deep"}
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
})
if err != nil {
t.Fatalf("InspectComparisonExecution() error = %v", err)
}
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash != "prompt-hash" {
t.Fatalf("prompt result = %#v", result)
}
if !reflect.DeepEqual(executor.profileRequests, profileIDs) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
wantProfiles := []ComparisonProfileInspection{
{ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
{ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
}
if !reflect.DeepEqual(result.Profiles, wantProfiles) {
t.Fatalf("profiles = %#v, want %#v", result.Profiles, wantProfiles)
}
}
func TestInspectComparisonExecutionRejectsInvalidProfilesBeforeInspection(t *testing.T) {
resolved := inspectionResolved(t)
for _, profileIDs := range [][]string{
{"weather-light"},
{"weather-light", " \t"},
{"weather-light", "weather-light"},
} {
t.Run(strings.Join(profileIDs, ","), func(t *testing.T) {
executor := &inspectionExecutor{prompt: validPromptInspection(resolved.Definition)}
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: profileIDs, Executor: executor,
})
if err == nil || promptexec.CategoryOf(err) != promptexec.InvalidRequest {
t.Fatalf("error/category = %v/%q, want invalid request", err, promptexec.CategoryOf(err))
}
if len(executor.promptRequests) != 0 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
t.Fatalf("invalid profile selection performed prompt/profile/execution work: %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
})
}
}
func TestInspectComparisonExecutionStopsAtFirstProfileFailure(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{
prompt: validPromptInspection(resolved.Definition),
profiles: map[string]promptexec.ProfileInspection{
"weather-light": {ProfileID: "weather-light", BackendID: "local", ModelName: "light-model"},
"missing-key": {ProfileID: "missing-key", CredentialRequired: true},
"weather-deep": {ProfileID: "weather-deep", BackendID: "cloud", ModelName: "deep-model"},
},
}
result, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: []string{"weather-light", "missing-key", "weather-deep"}, Executor: executor,
})
if err == nil || promptexec.CategoryOf(err) != promptexec.MissingCredential {
t.Fatalf("error/category = %v/%q, want missing credential", err, promptexec.CategoryOf(err))
}
if !reflect.DeepEqual(executor.profileRequests, []string{"weather-light", "missing-key"}) || len(executor.promptRequests) != 1 || executor.executeRequests != 0 {
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
if result.PromptID != resolved.Definition.PromptID || result.PromptVersion != resolved.Definition.PromptVersion || result.PromptHash == "" || len(result.Profiles) != 1 || result.Profiles[0].ProfileID != "weather-light" {
t.Fatalf("partial inspection result = %#v", result)
}
}
func TestInspectComparisonExecutionStopsBeforeProfileInspectionWhenPromptFails(t *testing.T) {
resolved := inspectionResolved(t)
executor := &inspectionExecutor{promptErr: promptexec.NewError(promptexec.PromptNotFound, "prompt is unavailable", nil)}
_, err := InspectComparisonExecution(context.Background(), ComparisonInspectionRequest{
Resolved: resolved, ProfileIDs: []string{"weather-light", "weather-deep"}, Executor: executor,
})
if err == nil || promptexec.CategoryOf(err) != promptexec.PromptNotFound || !strings.Contains(err.Error(), "comparison prompt") {
t.Fatalf("error/category = %v/%q, want prompt-context prompt not found", err, promptexec.CategoryOf(err))
}
if len(executor.promptRequests) != 1 || len(executor.profileRequests) != 0 || executor.executeRequests != 0 {
t.Fatalf("prompt/profile/execute requests = %#v/%#v/%d", executor.promptRequests, executor.profileRequests, executor.executeRequests)
}
}
type inspectionPromptRequest struct {
id string
version string
}
type inspectionExecutor struct {
prompt promptexec.PromptInspection
prompts map[string]promptexec.PromptInspection
profiles map[string]promptexec.ProfileInspection
promptErr error
promptRequests []inspectionPromptRequest
profileRequests []string
executeRequests int
}
func (e *inspectionExecutor) InspectPrompt(_ context.Context, id string, version string) (promptexec.PromptInspection, error) {
e.promptRequests = append(e.promptRequests, inspectionPromptRequest{id: id, version: version})
if e.promptErr != nil {
return promptexec.PromptInspection{}, e.promptErr
}
if prompt, ok := e.prompts[id]; ok {
return prompt, nil
}
return e.prompt, nil
}
func (e *inspectionExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
e.profileRequests = append(e.profileRequests, id)
value, ok := e.profiles[id]
if !ok {
return promptexec.ProfileInspection{}, errors.New("profile missing")
}
return value, nil
}
func (e *inspectionExecutor) Execute(context.Context, promptexec.ExecuteRequest, promptexec.PreparationCallback) (*promptexec.Execution, error) {
e.executeRequests++
return nil, errors.New("unexpected execution")
}
func inspectionResolved(t *testing.T) report.Resolved {
return inspectionResolvedFor(t, report.Daily)
}
func inspectionResolvedFor(t *testing.T, id report.ID) report.Resolved {
t.Helper()
request := report.ResolveRequest{Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC), Location: time.UTC}
if id == report.Daily {
request.Date = time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC)
}
resolved, err := report.DefaultRegistry().Resolve(id, request)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
return resolved
}
func validPromptInspection(definition report.Definition) promptexec.PromptInspection {
return promptexec.PromptInspection{
PromptID: definition.PromptID, PromptVersion: definition.PromptVersion, PromptHash: "prompt-hash", DefaultProfileID: "default-profile",
Inputs: []promptexec.InputDefinition{{Name: "data_package", Required: true, ContentType: "application/yaml"}},
Output: promptexec.OutputContract{Format: "json", ValidationMode: "json_schema", SchemaPath: definition.GeneratedTextSchemaID + ".generated_text.schema.json", RepairAttempts: definition.GeneratedTextRepairAttempts},
}
}
func logicalPromptInspection(definition report.Definition) promptexec.PromptInspection {
inspection := validPromptInspection(definition)
if definition.ID == report.Hourly {
inspection.DefaultProfileID = "weather-light"
} else {
inspection.DefaultProfileID = "weather-balanced"
}
return inspection
}

View File

@@ -0,0 +1,123 @@
package app_test
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
promptkitadapter "gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/app"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestPromptInspectionResolvesEmbeddedAndOverriddenProfilesOffline(t *testing.T) {
inspect := func(t *testing.T, adapter *promptkitadapter.Adapter, id report.ID, profile string, wantID string, wantBackend string, wantModel string) {
t.Helper()
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
Resolved: resolvedPromptProfile(t, id),
Executor: adapter,
Promptkit: config.PromptkitConfig{Profile: profile},
})
if err != nil {
t.Fatalf("InspectPromptExecution() error = %v", err)
}
if result.ProfileID != wantID || result.BackendID != wantBackend || result.ModelName != wantModel {
t.Fatalf("inspection = %#v, want profile/backend/model %q/%q/%q", result, wantID, wantBackend, wantModel)
}
}
embedded, err := promptkitadapter.New(promptkitadapter.Config{})
if err != nil {
t.Fatalf("New(embedded) error = %v", err)
}
inspect(t, embedded, report.Hourly, "", "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
inspect(t, embedded, report.Daily, "", "weather-balanced", "openrouter", "~google/gemini-flash-latest")
inspect(t, embedded, report.Daily, "weather-deep", "weather-deep", "openrouter", "~anthropic/claude-sonnet-latest")
override, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: writeProfileFile(t, `id: weather-light
endpoint: https://local.example/v1
backend: openrouter
model: local-weather
`)})
if err != nil {
t.Fatalf("New(override) error = %v", err)
}
inspect(t, override, report.Hourly, "", "weather-light", "openrouter", "local-weather")
}
func TestPromptInspectionAcceptsMaintainedEndpointOnlyProfile(t *testing.T) {
adapter, err := promptkitadapter.New(promptkitadapter.Config{ProfileFile: filepath.Join("..", "..", "examples", "weather-light-local-profile.yml")})
if err != nil {
t.Fatalf("New() error = %v", err)
}
result, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
Resolved: resolvedPromptProfile(t, report.Hourly), Executor: adapter,
})
if err != nil {
t.Fatalf("InspectPromptExecution() error = %v", err)
}
if result.ProfileID != "weather-light" || result.BackendID != "" || result.ModelName != "weather-local" {
t.Fatalf("inspection = %#v", result)
}
if strings.Contains(fmt.Sprintf("%#v", result), "127.0.0.1") {
t.Fatalf("inspection leaks endpoint: %#v", result)
}
}
func TestPromptInspectionSupportsRakestrawhomeProfileOffline(t *testing.T) {
adapter, err := promptkitadapter.New(promptkitadapter.Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
prompt, err := app.InspectPromptExecution(context.Background(), app.PromptInspectionRequest{
Resolved: resolvedPromptProfile(t, report.Hourly),
Executor: adapter,
Promptkit: config.PromptkitConfig{Profile: "rakestrawhome-gemma-4-31b"},
})
if err != nil {
t.Fatalf("InspectPromptExecution() error = %v", err)
}
if prompt.ProfileID != "rakestrawhome-gemma-4-31b" || prompt.BackendID != "rakestrawhome" || prompt.ModelName == "" {
t.Fatalf("prompt inspection = %#v", prompt)
}
comparison, err := app.InspectComparisonExecution(context.Background(), app.ComparisonInspectionRequest{
Resolved: resolvedPromptProfile(t, report.Hourly),
ProfileIDs: []string{"rakestrawhome-gemma-4-31b", "weather-deep"},
Executor: adapter,
})
if err != nil {
t.Fatalf("InspectComparisonExecution() error = %v", err)
}
if len(comparison.Profiles) != 2 || comparison.Profiles[0].ProfileID != "rakestrawhome-gemma-4-31b" || comparison.Profiles[0].BackendID != "rakestrawhome" || comparison.Profiles[0].ModelName == "" {
t.Fatalf("comparison inspection = %#v", comparison)
}
}
func resolvedPromptProfile(t *testing.T, id report.ID) report.Resolved {
t.Helper()
now := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
request := report.ResolveRequest{Now: now, Location: time.UTC}
if id == report.Daily {
request.Date = now
}
resolved, err := report.DefaultRegistry().Resolve(id, request)
if err != nil {
t.Fatalf("Resolve(%q) error = %v", id, err)
}
return resolved
}
func writeProfileFile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}

View File

@@ -0,0 +1,21 @@
package app
import (
"testing"
"time"
)
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -19,17 +19,21 @@ type AlertSummary struct {
Event string `json:"event,omitempty"`
Headline string `json:"headline,omitempty"`
Severity string `json:"severity,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Instruction string `json:"instruction,omitempty"`
Description string `json:"description,omitempty"`
}
func buildAlertDigestModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps)
value := alertDigest(ctx.Collected, ctx.Derived.AlertOverlaps, ctx.Timezone)
if value == nil {
value = &AlertDigestModule{}
}
return &module.Output{ID: module.AlertDigest, StanzaName: "alert_digest", Value: *value}, nil
}
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap) *AlertDigestModule {
func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverlap, timezone string) *AlertDigestModule {
missing := sourceMissing(collected.SourceProvenance, "alerts")
if collected.Alerts == nil && !missing {
return nil
@@ -45,6 +49,10 @@ func alertDigest(collected facts.CollectedFacts, overlaps []forecast.AlertOverla
Event: overlap.Event,
Headline: overlap.Headline,
Severity: overlap.Severity,
PeriodBegins: friendlyMonthDayTimeLabel(overlap.Period.Start, timezone),
PeriodEnds: friendlyMonthDayTimeLabel(overlap.Period.End, timezone),
Instruction: overlap.Instruction,
Description: overlap.Description,
})
}
return value

View File

@@ -0,0 +1,91 @@
{
"provenance": {
"sources": [
{
"url": "https://www.spc.noaa.gov/about/outlooks/",
"updated_on": "2026-03-03",
"applies_to": "categorical outlook descriptions"
},
{
"url": "https://www.spc.noaa.gov/exper/conditional-intensity-information",
"updated_on": "2026-02-04",
"applies_to": "conditional intensity group descriptions"
}
],
"reviewed_on": "2026-08-13",
"review_owner": "Weatherreporter maintainers",
"review_schedule": "Review annually and whenever SPC updates either referenced page."
},
"definitions": {
"categorical:TSTM": {
"plain_language": "General or non-severe thunderstorms.",
"official_description": "Encloses a 10% or higher probability of thunderstorms.",
"relative_level": "0 of 5"
},
"categorical:MRGL": {
"plain_language": "Isolated severe storms possible.",
"official_description": "Includes severe storms of either limited organization and longevity or very low coverage.",
"relative_level": "1 of 5"
},
"categorical:SLGT": {
"plain_language": "Scattered severe storms possible.",
"official_description": "Implies organized severe thunderstorms are expected, but usually in low coverage with varying levels of intensity.",
"relative_level": "2 of 5"
},
"categorical:ENH": {
"plain_language": "Numerous severe storms possible.",
"official_description": "Depicts a greater concentration of organized severe thunderstorms with varying levels of intensity.",
"relative_level": "3 of 5"
},
"categorical:MDT": {
"plain_language": "Widespread severe storms likely.",
"official_description": "Indicates potential for widespread severe weather with several tornadoes and/or numerous severe thunderstorms, some of which may be intense.",
"relative_level": "4 of 5"
},
"categorical:HIGH": {
"plain_language": "Major severe outbreak expected.",
"official_description": "Suggests a severe weather outbreak is expected from either numerous intense to violent long-track tornadoes or a long-lived derecho system with hurricane-force wind gusts producing widespread damage.",
"relative_level": "5 of 5"
},
"tornado:CIG1": {
"plain_language": "Conditional potential for significant tornadoes.",
"official_description": "Intensity Level 1: Reasonable Max EF2. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"tornado:CIG2": {
"plain_language": "Conditional potential for strong tornadoes.",
"official_description": "Intensity Level 2: Reasonable Max EF3. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"tornado:CIG3": {
"plain_language": "Conditional potential for violent tornadoes.",
"official_description": "Intensity Level 3: Reasonable Max EF4 or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"wind:CIG1": {
"plain_language": "Conditional potential for significant severe wind.",
"official_description": "Intensity Level 1: Reasonable Max wind gusts around 65 kt / 75 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 3"
},
"wind:CIG2": {
"plain_language": "Conditional potential for intense severe wind.",
"official_description": "Intensity Level 2: Reasonable Max wind gusts around 75 kt / 85 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 3"
},
"wind:CIG3": {
"plain_language": "Conditional potential for extreme severe wind.",
"official_description": "Intensity Level 3: Reasonable Max wind gusts around 100 kt / 115 mph or higher. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "3 of 3"
},
"hail:CIG1": {
"plain_language": "Conditional potential for significant hail.",
"official_description": "Intensity Level 1: Reasonable Max hail size around 2.00 to 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "1 of 2"
},
"hail:CIG2": {
"plain_language": "Conditional potential for giant hail.",
"official_description": "Intensity Level 2: Reasonable Max hail size greater than 3.75 inches. Note that this product describes the reasonable maximum intensity of a hazard if that hazard occurs. It does not by itself indicate the probability that the hazard will occur.",
"relative_level": "2 of 2"
}
}
}

View File

@@ -89,13 +89,55 @@ func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(t *testing.T) {
}
}
func TestHourlyForecastPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich hourly json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[HourlyForecastPromptExport](t, output)
if prompt.Product != "hourly" || prompt.SourceLocationID != "test-grid" || len(prompt.Periods) != 1 {
t.Fatalf("hourly prompt export = %#v, want hourly metadata and one period", prompt)
}
period := prompt.Periods[0]
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" || period.TextDescription != "Showers likely." {
t.Fatalf("hourly prompt period = %#v, want factual period fields", period)
}
if period.TemperatureF == nil || *period.TemperatureF != 76 || period.WindSpeedMph == nil || *period.WindSpeedMph != 14 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("hourly prompt period = %#v, want temperature, wind, and precip fields", period)
}
if period.WindDirection != "S" || period.RelativeHumidityPercent == nil || *period.RelativeHumidityPercent != 66 {
t.Fatalf("hourly prompt period = %#v, want wind direction and humidity", period)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"period_begins", "period_ends", "text_description", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "relative_humidity_percent"} {
if !strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"hour_label", "text_description_lower", "mention_precipitation"} {
if strings.Contains(promptText, field) {
t.Fatalf("hourly prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
periods := []weatherdata.ForecastPeriod{
{StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(19)},
{StartTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"), ProbabilityOfPrecipitationPercent: floatPtr(20)},
{StartTime: mustParseModuleTime("2026-05-29T10:00:00-05:00")},
}
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", DefaultHourlyForecastPrecipMentionProbabilityThreshold)
value := hourlyForecastPeriodsWithPrecipMentionThreshold(periods, "America/Chicago", 20)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
@@ -113,10 +155,10 @@ func TestHourlyForecastPrecipMentionThreshold(t *testing.T) {
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "hourly_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
@@ -185,10 +227,10 @@ func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Weekend)
ctx.Resolved.Definition = report.Definition{ID: report.ID("unsupported")}
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "weekend"`) {
if err == nil || !strings.Contains(err.Error(), `module "narrative_forecast" is not compatible with report "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
@@ -202,7 +244,7 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[MetadataModule](t, output)
if value.RunID == "" || value.ReportID != report.DailyToday || value.PromptID != "weather.daily_report" {
if value.RunID == "" || value.ReportID != report.Daily || value.PromptID != "weather.daily_generated_text" {
t.Fatalf("metadata = %#v, want report identity", value)
}
if value.Location == nil || value.Location.Name != "Brentwood" {
@@ -211,9 +253,6 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
if value.Alerts == nil || !value.Alerts.Checked || value.Alerts.ActiveCount != 1 || value.Alerts.RelevantCount != 1 {
t.Fatalf("Alerts = %#v, want checked alert status", value.Alerts)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
@@ -222,6 +261,9 @@ func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
if !strings.Contains(jsonText, "source_warnings") || strings.Contains(jsonText, "endpoint") || strings.Contains(jsonText, "dataSha256") {
t.Fatalf("metadata json = %s, want source warning summary without transport provenance", jsonText)
}
if strings.Contains(jsonText, `"alerts"`) {
t.Fatalf("metadata json = %s, want alert details only in alert_digest", jsonText)
}
}
func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
@@ -260,6 +302,44 @@ func TestCurrentConditionsModuleUsesSnakeCaseUnitFields(t *testing.T) {
}
}
func TestCurrentConditionsPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich current conditions json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[CurrentConditionsPromptExport](t, output)
if prompt.ConditionText != "Partly cloudy" || prompt.TemperatureF == nil || *prompt.TemperatureF != 74 {
t.Fatalf("current prompt export = %#v, want condition text and temperature", prompt)
}
if prompt.ApparentTemperatureF == nil || *prompt.ApparentTemperatureF != 76 || prompt.RelativeHumidityPercent == nil || *prompt.RelativeHumidityPercent != 71 || prompt.WindSpeedMph == nil || *prompt.WindSpeedMph != 8 {
t.Fatalf("current prompt export = %#v, want apparent temperature, humidity, and wind speed", prompt)
}
if prompt.WindDirection != "S" {
t.Fatalf("current prompt wind direction = %q, want S", prompt.WindDirection)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"condition_text", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction"} {
if !strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"condition_text_lower", "wind_direction_text"} {
if strings.Contains(promptText, field) {
t.Fatalf("current prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -287,6 +367,39 @@ func TestAlertDigestDistinguishesCheckedEmptyAndMissing(t *testing.T) {
}
}
func TestAlertDigestIncludesPeriodAndGuidance(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.Alerts = &weatherdata.AlertRun{Alerts: []json.RawMessage{json.RawMessage(`{"event":"Wind Advisory"}`)}}
ctx.Derived.AlertOverlaps = []forecast.AlertOverlap{{
Event: "Wind Advisory",
Headline: "Wind Advisory until 8 PM",
Severity: "Moderate",
Period: timeutil.Period{Start: mustParseModuleTime("2026-06-17T18:00:00Z"), End: mustParseModuleTime("2026-06-18T01:00:00Z")},
Instruction: "Secure outdoor objects.",
Description: "Gusty winds may blow around unsecured objects.",
}}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(alert digest) error = %v", err)
}
value := moduleValue[AlertDigestModule](t, output)
if len(value.Relevant) != 1 {
t.Fatalf("Relevant length = %d, want 1", len(value.Relevant))
}
alert := value.Relevant[0]
if alert.Event != "Wind Advisory" || alert.Headline != "Wind Advisory until 8 PM" || alert.Severity != "Moderate" {
t.Fatalf("alert identity = %#v, want preserved event/headline/severity", alert)
}
if alert.PeriodBegins != "June 17 at 1:00 PM" || alert.PeriodEnds != "June 17 at 8:00 PM" {
t.Fatalf("alert period = %q/%q, want friendly local labels", alert.PeriodBegins, alert.PeriodEnds)
}
if alert.Instruction != "Secure outdoor objects." || alert.Description != "Gusty winds may blow around unsecured objects." {
t.Fatalf("alert guidance = %#v, want instruction and description preserved", alert)
}
}
func TestBaseModulesOmitMissingOptionalOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
@@ -349,9 +462,17 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
for _, tt := range []struct {
name string
options any
}{
{name: "value", options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
{name: "pointer", options: &module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}}},
} {
t.Run(tt.name, func(t *testing.T) {
output, err := registry.BuildModule(ctx, module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
Options: tt.options,
})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
@@ -363,6 +484,37 @@ func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want only short_term section", afd)
}
})
}
}
func TestWeatherStoryModuleOmitsEmptyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{OfficeID: "LSX", Priority: true, Order: 1}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted weather story", output)
}
}
func TestWeatherStoryModulePreservesZeroPriorityAndOrder(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.WeatherStory = &weatherdata.WeatherStory{Title: "Rain Chances"}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
story := moduleValue[WeatherStoryModule](t, output)
if !story.Available || story.Priority || story.Order != 0 {
t.Fatalf("WeatherStory = %#v, want available story with zero priority and order", story)
}
}
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
@@ -393,9 +545,37 @@ func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
}
}
func TestAreaForecastDiscussionModuleUsesDailyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Daily)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("daily default modules missing area_forecast_discussion")
}
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("LongTerm = %q, want selected long term section", afd.LongTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.ShortTerm != "" {
t.Fatalf("AFD = %#v, want only long term section", afd)
}
}
func testModuleContext() ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(report.DailyToday)
definition := report.DefaultRegistry().MustLookup(report.Daily)
resolved := report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
@@ -420,7 +600,7 @@ func testModuleContext() ModuleContext {
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
ctx := ModuleContext{
Resolved: resolved,
Collected: facts.CollectedFacts{
Current: &weatherdata.Current{
@@ -548,6 +728,14 @@ func testModuleContext() ModuleContext {
Timezone: "America/Chicago",
},
}
ctx.Identity = BuildPreparedIdentity(BuildContext{
Resolved: ctx.Resolved,
Bundle: ctx.Collected.Bundle(),
Units: ctx.Units,
Timezone: ctx.Timezone,
Location: ctx.Location,
})
return ctx
}
func moduleValue[T any](t *testing.T, output *module.Output) T {
@@ -563,6 +751,28 @@ func moduleValue[T any](t *testing.T, output *module.Output) T {
return value
}
func moduleDataPackageValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.DataPackageValue())
if err != nil {
t.Fatalf("marshal module data package value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module data package value: %v", err)
}
return value
}
func mustMarshalModuleJSON(t *testing.T, value any) string {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
return string(data)
}
func mustParseModuleTime(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {

View File

@@ -23,6 +23,21 @@ type CurrentConditionsModule struct {
WindDirectionText string `json:"wind_direction_text,omitempty"`
}
type CurrentConditionsPromptExport struct {
ConditionText string `json:"condition_text,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TemperatureC *int `json:"temperature_c,omitempty"`
TemperatureF *int `json:"temperature_f,omitempty"`
ApparentTemperatureC *int `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *int `json:"apparent_temperature_f,omitempty"`
DewpointC *int `json:"dewpoint_c,omitempty"`
DewpointF *int `json:"dewpoint_f,omitempty"`
RelativeHumidityPercent *int `json:"relative_humidity_percent,omitempty"`
WindSpeedKmh *int `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *int `json:"wind_speed_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
}
func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, error) {
current := ctx.Collected.Current
if current == nil {
@@ -50,6 +65,27 @@ func buildCurrentConditionsModule(ctx ModuleContext, _ any) (*module.Output, err
return &module.Output{ID: module.CurrentConditions, StanzaName: "current_conditions", Value: value}, nil
}
func exportCurrentConditionsPromptValue(value any) (any, error) {
rich, ok := value.(CurrentConditionsModule)
if !ok {
return nil, unexpectedPromptExportValue(value, CurrentConditionsModule{})
}
return CurrentConditionsPromptExport{
ConditionText: rich.ConditionText,
IsDay: copyBool(rich.IsDay),
TemperatureC: copyInt(rich.TemperatureC),
TemperatureF: copyInt(rich.TemperatureF),
ApparentTemperatureC: copyInt(rich.ApparentTemperatureC),
ApparentTemperatureF: copyInt(rich.ApparentTemperatureF),
DewpointC: copyInt(rich.DewpointC),
DewpointF: copyInt(rich.DewpointF),
RelativeHumidityPercent: copyInt(rich.RelativeHumidityPercent),
WindSpeedKmh: copyInt(rich.WindSpeedKmh),
WindSpeedMph: copyInt(rich.WindSpeedMph),
WindDirection: rich.WindDirection,
}, nil
}
func (v CurrentConditionsModule) isEmpty() bool {
return v.ConditionText == "" &&
v.ConditionTextLower == "" &&

View File

@@ -0,0 +1,24 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type DailyPlanningModule struct {
MorningReadiness []string `json:"morning_readiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commute_school_workday_concerns,omitempty"`
OvernightChangeWatch []string `json:"overnight_change_watch,omitempty"`
}
func buildDailyPlanningModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: DailyPlanningModule{}}, nil
}
planning := buildMorningCommuteOvernightPlanning(summary)
value := DailyPlanningModule{}
if planning != nil {
value.MorningReadiness = append([]string(nil), planning.MorningReadiness...)
value.CommuteSchoolWorkdayConcerns = append([]string(nil), planning.CommuteSchoolWorkdayConcerns...)
value.OvernightChangeWatch = append([]string(nil), planning.OvernightChangeWatch...)
}
return &module.Output{ID: module.DailyPlanning, StanzaName: "daily_planning", Value: value}, nil
}

Some files were not shown because too many files have changed in this diff Show More