278 Commits

Author SHA1 Message Date
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
ff2f8c16a3 Create a staged roadmap to address the issues identified in the code quality audit 2026-06-15 07:16:43 -05:00
ace5577402 Audit code quality and deduplication opportunities 2026-06-15 07:08:20 -05:00
473f252aee Revise the report template for the tomorrow report. 2026-06-14 23:26:45 -05:00
74bd69834c Validate Tomorrow cutover 2026-06-14 23:55:56 +00:00
98cab70b53 Document Tomorrow generated-text workflow 2026-06-14 23:53:31 +00:00
dc0172ff82 Cover Tomorrow artifact and notification behavior 2026-06-14 23:49:18 +00:00
0b41773017 Route Tomorrow through generated text rendering 2026-06-14 23:44:31 +00:00
ddda42453f Add Tomorrow render context and template 2026-06-14 23:36:47 +00:00
295f06915f Add daypart presentation facts 2026-06-14 23:30:57 +00:00
120bce3391 Add Tomorrow generated text contract 2026-06-14 23:25:55 +00:00
386263784c Split Tomorrow report identity 2026-06-14 23:22:31 +00:00
afe6803a63 Add an implementation plan to convert the tomorrow report into the new hybrid deterministic/LLM format 2026-06-14 18:17:49 -05:00
5344c7880a Updated the current_conditions module to round all numeric values to integers 2026-06-14 17:40:19 -05:00
74e32eb18e Simplify and rationalize the hourly report template 2026-06-14 12:54:32 -05:00
ceb00ad45e Bugfix in the hourly weather template 2026-06-14 09:10:28 -05:00
7cd07c429b Revise the hourly weather template 2026-06-14 09:07:10 -05:00
28b8391d53 Refactor the template variable framework 2026-06-14 08:57:53 -05:00
bb8de054dc Update documentation for templated reports 2026-06-14 08:22:09 -05:00
9d6502460e Update buildRenderContext to use Definition.TemplateID instead of GeneratedTextSchemaID 2026-06-14 12:23:50 +00:00
fcf1108641 Strengthen final hourly workflow validation 2026-06-14 05:24:25 +00:00
185605fbf0 Document hourly generated text behavior 2026-06-14 05:21:15 +00:00
662d906997 Cover hourly CLI generation workflow 2026-06-14 05:17:05 +00:00
f6e20d1412 Notify hourly generated reports after success 2026-06-14 05:13:40 +00:00
9e14a9b7c7 Preserve generated text failure artifacts 2026-06-14 05:08:26 +00:00
422430613c Wire hourly generated text rendering 2026-06-14 05:01:26 +00:00
2a4ce64d6f Add structured Scriptorium run support 2026-06-14 04:55:07 +00:00
316ab8f3fc Add generated text state artifacts 2026-06-14 04:51:52 +00:00
f6a68426e1 Add hourly generated text contract 2026-06-14 04:46:53 +00:00
bed2b84100 Add embedded hourly report template assets 2026-06-14 04:38:48 +00:00
b39ec1e4d3 Add hourly generated-text report shell 2026-06-14 04:35:52 +00:00
dd92e9b061 Add report generation mode metadata 2026-06-14 04:28:30 +00:00
8d737395dc Rename rolling report to hourly 2026-06-14 04:26:05 +00:00
b3f7c9c1f2 Add roadmap and implementation plan to move towards hybrid deterministic/llm generation of reports 2026-06-13 23:20:04 -05:00
d425132ae7 Validate near-term implementation 2026-06-12 17:48:02 +00:00
925d351341 Document near-term report behavior 2026-06-12 17:45:37 +00:00
c4107490df Add near-term report config overrides 2026-06-12 17:42:49 +00:00
b38230bc35 Add near-term generation artifact coverage 2026-06-12 17:40:54 +00:00
1f5f9964e0 Add near-term generate command 2026-06-12 17:36:58 +00:00
b0d8c6983d Support near-term derived facts 2026-06-12 17:34:40 +00:00
55f0180599 Add near-term module composition 2026-06-12 17:31:37 +00:00
5284033fb8 Add near-term report identity 2026-06-12 17:28:26 +00:00
0b1423c90d Add a roadmap and implementation plan for rolling near-term forecast reports 2026-06-12 12:24:07 -05:00
e6a4bb2d16 Clean up and normalize prompt output modules 2026-06-12 12:00:40 -05:00
c3a051d372 Validate SPC convective outlook feature 2026-06-12 15:18:38 +00:00
3482551360 Document SPC convective outlook behavior 2026-06-12 15:17:06 +00:00
d7a72f8581 Add SPC convective workflow coverage 2026-06-12 15:14:09 +00:00
c09b7410ce Add SPC convective modules to report defaults 2026-06-12 15:10:40 +00:00
96ce0edd46 Add SPC convective discussion briefing module 2026-06-12 15:05:42 +00:00
7cd68ff222 Add SPC convective outlook briefing module 2026-06-12 15:02:49 +00:00
16680e3f61 Route SPC convective stanzas in prompt packages 2026-06-12 14:58:14 +00:00
3389d4fa93 Derive report-period SPC convective outlooks 2026-06-12 14:56:17 +00:00
0041845935 Fetch SPC convective outlook data 2026-06-12 14:51:54 +00:00
3bcccb4a7b Add SPC convective outlook data contracts 2026-06-12 14:47:42 +00:00
2aba52f552 Add roadmap and staged plan for implementing SPC convective outlook support 2026-06-12 09:42:43 -05:00
f149563c68 Refactor the yaml data package to group data sources by category 2026-06-10 11:50:22 -05:00
276e4f1189 Implement friendly time formatting in select modules 2026-06-10 10:36:52 -05:00
cb42cad6a6 Add an hourly forecast module and adjust the daily summary for consistency 2026-06-10 10:00:36 -05:00
ef044327c6 Refactored the derived_daily_summary module to utilize narrative forecast data where available 2026-06-10 09:35:39 -05:00
d1d0df11a8 Update wind direction and precipitation window presentation 2026-06-10 09:24:06 -05:00
c3da3af2f4 Add a new narrative forecast module and remove the unimplemented daily forecast stub 2026-06-10 08:24:50 -05:00
7b760a0823 Refactor to separate each module and report into an individual file 2026-06-10 08:04:06 -05:00
1e9c29aa55 Cleanup after implementation of the module architecture and remove completed roadmap files 2026-06-10 07:49:29 -05:00
1ddd88231a Clarify module package documentation 2026-06-09 21:42:38 +00:00
d665049f05 Record final module cutover validation 2026-06-09 21:38:24 +00:00
1af6169999 Align docs with module prompt packages 2026-06-09 21:37:28 +00:00
468197f7e0 Remove obsolete briefing snapshot artifacts 2026-06-09 21:33:34 +00:00
816cfb24aa Compare recent changes from module snapshots 2026-06-09 21:20:53 +00:00
479d144592 Run report generation through module snapshots 2026-06-09 21:12:13 +00:00
0b516d9762 Write prompt data packages as YAML 2026-06-09 21:08:17 +00:00
2483c2362d Persist module snapshots for generated reports 2026-06-09 20:58:35 +00:00
40639309b1 Add configurable report module composition 2026-06-09 20:51:22 +00:00
eefb0681dc Implement derived forecast modules 2026-06-09 20:43:53 +00:00
9501dad1dc Implement source-oriented briefing modules 2026-06-09 20:35:57 +00:00
24dba3bd60 Add module contracts and registry validation 2026-06-09 20:30:49 +00:00
e9508089ab Add collected and derived fact contracts 2026-06-09 20:23:53 +00:00
454f47b2b5 Split weather data types from forecast derivation 2026-06-09 20:16:47 +00:00
d8b417458b Add a roadmap and a staged implementation plan to move weatherreporter toward deterministic, reusable briefing modules that can
be composed per report type
2026-06-09 15:09:31 -05:00
195a130124 Updated defaults to upload only the current generated report path 2026-06-09 11:26:50 -05:00
8577fc29e4 Updated the distributor bundle path template 2026-06-08 10:29:42 -05:00
d71c7e4d28 Implement the distributor v0.5 PipelineID update 2026-06-08 07:04:31 -05:00
9bc8156615 Update default config example to use a stable distributor bundle_id and a unique per-run idempotency_key 2026-06-07 20:46:19 -05:00
183b23cf5a Implemented debug artifacts for the distributor notification adapter 2026-06-07 20:21:26 -05:00
138bc4e7e4 Refresh distributor documentation 2026-06-07 23:50:55 +00:00
e2529cfdf2 Validate distributor notification implementation 2026-06-07 23:47:47 +00:00
b982b27f84 Document distributor notification behavior 2026-06-07 23:46:39 +00:00
c573cd5b4d Report distributor notification outcomes in batches 2026-06-07 23:42:27 +00:00
c2758d7a91 Notify distributor after report generation 2026-06-07 23:38:03 +00:00
64cae8c4d9 Add distributor upload adapter 2026-06-07 23:34:21 +00:00
a2ba6f5382 Add distributor notification config validation 2026-06-07 23:18:39 +00:00
7c8d9191c1 Add file-backed environment secrets 2026-06-07 23:13:54 +00:00
9677835d84 Add an implementation plan to support distributor uploads for produced artifacts 2026-06-07 18:04:40 -05:00
63749a9572 Implement support for NWS weather stories 2026-05-30 07:47:49 -05:00
9ff90d33fc Add Woodpecker CI support 2026-05-30 07:47:13 -05:00
210 changed files with 26630 additions and 9435 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:

97
.woodpecker/release.yml Normal file
View File

@@ -0,0 +1,97 @@
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.26.5
depends_on:
- validate-release
commands:
- |
set -eu
version="$CI_COMMIT_TAG"
dist="dist"
pkg="gitea.maximumdirect.net/eric/weatherreporter/cmd/weatherreporter"
rm -rf "$dist"
mkdir -p "$dist"
build_binary() {
goos="$1"
goarch="$2"
suffix="$3"
output="$dist/weatherreporter-$version-$goos-$goarch$suffix"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags "-s -w -X gitea.maximumdirect.net/eric/weatherreporter/internal/buildinfo.Version=$version" \
-o "$output" "$pkg"
}
build_binary linux amd64 ""
build_binary linux arm64 ""
build_binary darwin amd64 ""
build_binary darwin arm64 ""
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:0.3.1
depends_on:
- build-release-assets
settings:
api_key:
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
file-exists: skip
overwrite: false
prerelease: false

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,21 +1,27 @@
# weatherreporter
`weatherreporter` is a Go application for preparing human-facing weather
reports from normalized forecast data. It builds structured briefing packages,
runs them through `scriptorium`, and keeps inspectable artifacts under a local
workspace.
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.
## 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; use `--out` to choose another destination.
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)
- [Development guide](docs/development.md)
- [Architecture policy](docs/policy/architecture.md)
- [Development policy](docs/policy/development.md)

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,90 +1,138 @@
# Weatherreporter CLI
`weatherreporter` generates Markdown weather reports, runs scheduled report
batches, and inspects previously generated artifacts.
`weatherreporter` generates Markdown weather reports and runs report batches.
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`.
The command uses the configured Weather API and writes an atomically replaced
`today.md` in the current directory. See the [configuration reference](config.md)
to supply the required Weather API endpoint.
## 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 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 briefing [--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]
```
`generate` commands write briefing, data package, preflight, report, and
metadata artifacts under the configured workspace. `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 Planning Brief. 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.
| 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 and write each selected report beneath the current directory unless `--out-dir` selects another directory. `--out` is not accepted. |
`inspect` commands read existing workspace artifacts and emit JSON to stdout.
They do not fetch weather data or invoke `scriptorium`.
`generate` accepts the four report command names shown above. `run` accepts
only `morning` and `evening`. Batch membership and notification ordering are
described in the [operations guide](operations.md).
## Flags
## Output, Errors, And Quiet Mode
- `-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 for `generate` commands.
- `--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.
For `generate`, the default output is the report's filename in the current
directory. `--out PATH` selects one output file instead. A relative path is
resolved from the current directory; an absolute path is used as given. For a
batch, the equivalent default is the current directory and `--out-dir PATH`
selects its output directory. Successful summaries always report the resulting
absolute `outputPath` values.
Storm times accept `YYYY-MM-DDTHH:MM` in the configured timezone or RFC3339
timestamps with explicit offsets.
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.
## Common Workflows
Action commands (`generate` and `run`) 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.
`--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.0.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
`llmDebugPath`, and compact Distributor `notification` result. It 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, 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`.
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
```
## Flag Reference
| Flag | Accepted by | Meaning |
| --- | --- | --- |
| `-h`, `--help` | top level | Show help. |
| `--config PATH` | all commands | Load `PATH` instead of `/usr/local/etc/weatherreporter/config.yml`. |
| `--units VALUE` | `generate`, `run` | Override `weather_api.units` for this command. |
| `--tz NAME` | `generate`, `run` | Override `weather_api.timezone` for this command. |
| `--out PATH` | every `generate` command | Write the report to this file instead of its current-directory default. |
| `--llm-debug-dir PATH` | every `generate` and `run` command | Write requested sensitive prompt diagnostics under this absolute path. |
| `--out-dir PATH` | `run morning`, `run evening` | Write batch reports beneath this directory instead of the current directory. |
| `--quiet` | `generate`, `run` | Suppress action summaries and routine batch status output. |
| `--date YYYY-MM-DD` | `generate daily`, `generate 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 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
```
## Inspection
```sh
weatherreporter inspect reports --limit 10
weatherreporter inspect metadata 20260529T100000.000000000Z_daily_today
weatherreporter inspect briefing 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 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,128 +1,208 @@
# 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 implemented configuration overrides are `--units` and `--tz`. Output flags
control report copies for the current command but do not change configuration
files. Environment-variable configuration is not implemented.
## 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 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`.
### `location`
`location` is descriptive prompt context included in briefing 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`
`secrets.directory` defaults to empty, which disables secret loading. When it
is set, every regular file directly in that directory is loaded after the file
and command-line overrides. 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.
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.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 URL. |
| `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.
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`, `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.
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.
`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:
| 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.
`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. Supported optional
source keys are `observations`, `current`, `narrative`, `alerts`, `discussion`,
`weather_story`, and `spc_convective_outlooks`.
Hourly forecast data is required for generated reports. Optional sources and
stub source slots use the missing-source policy.
### `promptkit`
### `scriptorium`
Promptkit configuration selects the executor and prompt/profile checks for
every `generate` and `run` command. A top-level `scriptorium:` configuration
key is rejected with a migration error; it is not translated or ignored.
- `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.
Prompt debug capture has no YAML setting. Use `--llm-debug-dir PATH` on an
individual `generate` or `run` command when explicitly needed.
### `workspace`
| 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. |
- `root`: workspace root for managed artifacts. Default: `workspace`.
- `snapshots_dir`: briefing 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`.
`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.0.0` prompt
definitions.
Workspace subdirectories must be relative paths that stay inside
`workspace.root`.
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. Sources provide complete definitions; fields are never
merged. 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.
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`
### `reports`
`start` and `end` use `HH:MM`. The default entries are overnight, morning,
midday, afternoon, and evening.
`reports` optionally overrides a report's ordered deterministic modules and
Distributor path templates. Omit a report entry to retain its defaults.
### `recent_change`
Supported report keys are `daily`, `today`, `tomorrow`, and `hourly`; hyphens
and underscores are equivalent.
- `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`.
Each report entry can contain:
Recent Changes are added to prompt input when a prior comparable briefing
snapshot exists and a threshold is crossed.
- `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.
## Secrets
Configuration files should not contain secrets. The current Weather API and
Scriptorium integration settings do not require secret fields.
## 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 implemented 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).

86
docs/development.md Normal file
View File

@@ -0,0 +1,86 @@
# 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, collection, output publication, or notification workflow | [App orchestration internals](internal/app-orchestration.md) | It owns workflow ordering, 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, prompt inputs, or result handling | `internal/promptexec`, the Promptkit adapter, and [prompt-input internals](internal/prompt-input.md) | These separate the executor contract and input construction. |
| 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) and [App orchestration internals](internal/app-orchestration.md) | Operations owns operator workflows; app internals owns the implementation boundary. |
| 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, collection coordination, output publication, and notification. |
| `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,70 @@
# Distributor HTTP Upload Contract
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).
## Upload Admission
Weatherreporter uses an absolute HTTP(S) endpoint as a base URL. The client
posts a gzip-compressed source bundle to:
```text
POST /v1/pipelines/<pipeline_id>/upload
Authorization: Bearer <token>
Content-Type: application/gzip
Idempotency-Key: <key>
```
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.
The adapter requires a pipeline ID, bundle ID, idempotency key, and 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).
## Idempotency
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.
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.
## Run Status And Retention
After acceptance, Weatherreporter reads:
```text
GET /runs/<run_id>
Authorization: Bearer <token>
```
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.
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).
## Compatibility Reference
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

@@ -0,0 +1,36 @@
# Distributor Source Bundle Mapping
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.
## File Mappings
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.
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.
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.
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.
## Compatibility Reference
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

@@ -0,0 +1,51 @@
# Distributor Upload Client Contract
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.
## Client And Upload
The adapter constructs the client with the configured endpoint, bearer token,
and an HTTP client whose timeout is the configured Distributor timeout. It
passes no custom retry options, so the pinned client's defaults apply: three
attempts, 100 ms base delay, and one-second maximum delay.
For each notification, Weatherreporter calls `UploadFiles` with:
- 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.
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.
## Retry, Conflict, And Status
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.
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.
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. 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).
## Compatibility Reference
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,34 @@
# 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.0.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. The embedded definitions currently use Promptkit's `openrouter` backend:
| 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, temperature, `top_p`, and output-token limits.
## Selection And Active Execution
Before weather collection, Weatherreporter validates the exact prompt version, output contract, and selected profile. 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. Each source supplies a complete definition, so profile fields are not merged. A malformed matching operator definition is an error and does not fall back.
Profiles that require a direct API key are unsupported; a profile that reports `APIKeyEnv` requires a nonblank value in that environment variable. 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. Safe active provenance remains in memory. Content-rich diagnostics are opt-in through `--llm-debug-dir`; see [operations](../operations.md) for retention and permissions.
The generated-text schemas require `summary`, `forecast_discussion`, and `precipitation_timing`, and reject additional properties. 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,97 +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 Markdown reports. 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>
```
`weatherreporter` always passes prompt input as
`--input data_package=<path>`. The data package is structured JSON created by
`internal/promptinput`.
## 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.
`weatherreporter` persists render preflight JSON when orchestration reaches the
preflight save point. The final Markdown artifact is written by Scriptorium to
the `--out` path.
## Failure Behavior
The adapter validates required request fields before starting Scriptorium:
- prompt ID
- data package path
- output path for `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,51 @@
# 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 `forecast.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 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 warms up
`/conditions/current` with the same `format`, `units`, and `precision` query
parameters used for current conditions. The warmup only requires a readable
2xx response; its body is not decoded. Failure after its 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 after a successful
warmup, subject to retry on transient failures.
| 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,105 +53,95 @@ 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`; a
missing, malformed, or empty 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.
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.
Malformed top-level JSON envelopes and HTTP failures are direct request errors.
Malformed `data` for an optional source follows its missing-source policy.
## Query Parameters
## Payload Fields Used
The adapter sends these 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.
- `format`: from `weather_api.format`; the implemented configuration 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, and
discussion requests
### Observations And Current Conditions
Alerts do not receive `precision` or `tz`.
`/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`.
## Endpoints Used
`/conditions/current` uses `conditionText`, `isDay`,
`relativeHumidityPercent`, `windDirectionDegrees`, `temperatureC`,
`temperatureF`, `apparentTemperatureC`, `apparentTemperatureF`, `dewpointC`,
`dewpointF`, `windSpeedKmh`, and `windSpeedMph`.
The adapter fetches these endpoints once per bundle:
### Hourly And Narrative Forecasts
- `/observations`
- `/conditions/current`
- `/forecast/hourly`
- `/forecast/narrative`
- `/alerts/active`
- `/discussion`
Both forecast endpoints use run-level `locationId`, `locationName`, `issuedAt`,
`updatedAt`, `product`, `latitude`, `longitude`, `elevationMeters`,
`elevationFeet`, and `periods`.
`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.
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`.
## Required And Optional Sources
### Alerts, Discussion, And Weather Story
Hourly forecast is required:
`/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.
- `data: null` for `/forecast/hourly` fails the fetch.
- an hourly forecast with no `periods` fails the fetch.
- malformed hourly data fails the fetch.
`/discussion` uses `officeId`, `officeName`, `product`, `issuedAt`,
`updatedAt`, `keyMessages`, and the `shortTerm` and `longTerm` sections. Each
section uses `qualifier`, `text`, and `issuedAt`.
Other fetched sources are optional and follow `missing_source.default` or a
source-specific `missing_source.sources` policy:
`/weatherstories/latest` uses `officeId`, `startTime`, `endTime`, `updatedAt`,
`title`, `description`, `altText`, `priority`, `order`, and `downloadUrl`.
- `observations` for `/observations`
- `current` for `/conditions/current`
- `narrative` for `/forecast/narrative`
- `alerts` for `/alerts/active`
- `discussion` for `/discussion`
### SPC Convective Outlooks
The adapter also creates missing stub source records for `daily` and
`weather_story` because those source slots exist in the internal bundle but are
not fetched from the Weather API.
`/outlooks/convective` uses run-level `locationId`, `locationName`, `asOf`,
`issuedAt`, `updatedAt`, `product`, `outlooks`, and `discussions`.
Policy behavior:
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`.
- `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
## Timeouts, Retries, And Failures
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.
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.
## Source Identity
The adapter reads at most 10 MiB from one response body. A non-2xx response,
request construction failure, read failure, or decode failure includes endpoint
context in its error.
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 forecast bundle
types in `internal/forecast/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
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,123 +1,27 @@
# App Orchestration Internals
# Application Orchestration Internals
This document describes the implemented workflow coordinator in `internal/app`.
`internal/app` owns stateless report generation, batch execution, 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 briefing and prompt-input artifacts, invokes Scriptorium through the
adapter boundary, persists managed state, runs batches, and reads existing
artifacts for inspection.
`GenerateDetailed` resolves the requested report and output destination, then initializes an optional explicit debug writer. It validates the exact Promptkit prompt and selected profile before collecting weather data. The resolved profile, backend, and model are carried in the active result.
## Inputs And Outputs
The workflow builds facts, a module snapshot, briefing metadata, and the YAML prompt package in memory. It executes Promptkit, validates the returned generated text, builds a render context, and renders Markdown. `fileutil` atomically writes the completed Markdown to the selected output path. 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
- `BriefingRequest` and `ReportRequest` for package-level orchestration tests
and internal composition
- resolved report definitions from `internal/report`
- forecast bundles from `internal/adapters/weatherapi`
- prior snapshots loaded from `internal/state`
- optional renderer and state-store fakes for tests
## Batches
Outputs:
`RunBatchDetailed` captures one output directory, creates at most one explicit debug writer, and uses one executor. Before collection it validates the 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 briefing, data package, preflight, report,
metadata, prior snapshot, Recent Changes, and Scriptorium result details
- batch summaries with per-report status, artifact paths, and error text
- saved Weather API bundle JSON for fetch workflows
- inspection JSON values for reports, metadata, briefings, 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
## Boundaries And Verification
`internal/app` owns workflow order and request composition. It does not parse
CLI flags, load YAML files directly, implement HTTP transport, derive forecast
facts, define report periods, compare rendered Markdown, or construct
Scriptorium argv.
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.
Report selection and report identity policy come from `internal/report`.
Weather API transport stays in `internal/adapters/weatherapi`. Scriptorium
subprocess behavior stays in `internal/adapters/scriptorium`. Filesystem layout
and persisted metadata stay in `internal/state`.
Focused checks:
## Config Fields Used
- `weather_api.*` for Weather API client construction and briefing metadata
- `scriptorium.*` for renderer construction
- `workspace.*` for filesystem state
- `dayparts` for daily and outlook summarization
- `recent_change.*` for structured Recent Changes thresholds
Output copy flags are command request fields. They are not configuration
defaults.
## Generation Workflow
Single-report generation follows this order:
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 a report-specific briefing package.
6. Save the briefing snapshot.
7. Compute Recent Changes from structured prior and current briefings.
8. Build and save the Scriptorium `data_package`.
9. Run Scriptorium render preflight.
10. Save preflight JSON when a render result is available.
11. Save metadata for inspection.
12. Run Scriptorium report generation to the managed report path.
13. Copy the managed report to the requested `--out` path when provided.
14. Save metadata with the managed report 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.
## Batch Workflow
`run morning` resolves Daily Today, 3-Day Outlook, and Weekend Outlook except
on Sunday. `run evening` resolves Daily Tomorrow. 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.
## 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 briefing 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.
- 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.
- Recent Changes are computed from structured briefing snapshots.
- Metadata links artifacts produced for a run.
```sh
go test ./internal/app ./internal/collect
```

View File

@@ -1,85 +1,69 @@
# Briefing Internals
# Module Builder Internals
This document describes the implemented briefing package boundary.
`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` builds structured report-specific briefing packages from
resolved report metadata, forecast bundles, and derived forecast summaries.
Briefings are curated inputs for prompt data packages, not rendered report
prose.
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.
## Inputs And Outputs
`BuildModule` first verifies the requested module, report compatibility, and
option shape. It then applies the declared missing-data behavior:
Inputs:
- `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.
- resolved report definition, generation time, timezone, and valid period
- forecast bundle with source provenance and warnings
- derived daily or period summaries where required
- configured units, timezone, and descriptive location context
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.
Outputs:
## Built value families
- `briefing.Package` with common metadata and one report-specific content
object for Daily, 3-Day, Weekend, or Storm Report
- optional `currentConditions` prompt context from normalized
`/conditions/current` data when available
- optional JSON file written by `briefing.Save`
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.
## Boundaries
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).
- This package selects and shapes weather facts for prompts.
- It does not fetch weather data, compare prior snapshots, build
`data_package` files, invoke Scriptorium, or write workflow metadata.
`area_forecast_discussion` accepts an optional typed section filter. Planning
modules are report-specific: `daily_planning` supports Daily,
`today_planning` supports Today, and `tomorrow_planning` supports Tomorrow.
## Config Fields Used
## Missing data and boundaries
The package receives configured units and timezone from the app layer. Daypart
configuration is consumed by `internal/forecast` before briefing builders run.
Configured `location` values are prompt context only; Weather API
`sourceLocationId` and `sourceLocation` remain source provenance.
Current conditions are copied from the normalized `/conditions/current` bundle
source only; observation station and timestamp fields remain provenance.
Optional current conditions, narrative products, discussions, and weather
stories may be 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.
## External Adapters Used
Effective units, timezone, and location context arrive in `ModuleContext` from
configuration and resolved report metadata. Field defaults are owned by
[configuration](../config.md), and prompt-package layout is owned by
[prompt input](prompt-input.md).
None directly.
## Verification and invariants
## State Or Manifest Behavior
Focused tests cover source and derived values, registry validation, option
handling, prompt exporters, support rules, and missing-data behavior:
`briefing.Save` writes briefing JSON atomically. Managed workspace placement is
owned by `internal/state`.
```sh
go test ./internal/briefing
```
## Skip And Resume Behavior
None. Builders either return a complete briefing package or an error.
## Failure Behavior
- Daily briefing construction requires a Daily report definition and derived
daily summary.
- 3-Day briefing construction requires a 3-Day report definition and at least
one derived summary.
- Weekend briefing construction requires a Weekend report definition and at
least one derived summary.
- Storm briefing construction requires a Storm Report definition and forecast
bundle.
- Save failures include path and operation context.
## Tests
Inspect:
- `internal/briefing/daily_test.go`
- `internal/briefing/three_day_test.go`
- `internal/briefing/weekend_test.go`
- `internal/briefing/storm_test.go`
- `internal/app/app_test.go`
## Invariants
- Briefings 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.
- LLM prompt input packaging and Scriptorium execution remain outside this
boundary.
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,74 +0,0 @@
# Changes Internals
This document describes structured Recent Changes comparison.
## Purpose
`internal/changes` compares current and prior briefing packages and emits
compact change records for prompt input data packages.
## Inputs And Outputs
Inputs:
- prior briefing package
- current briefing package
- comparison thresholds from configuration
Outputs:
- ordered `changes.Change` items with type, message, previous value, and current
value where useful
## Boundaries
- This package compares structured briefing 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 briefing 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 both inputs to contain Daily briefing content.
- 3-Day comparison requires both inputs to contain 3-Day briefing content.
- Weekend comparison requires both inputs to contain Weekend briefing content.
- Storm Report currently has no comparison implementation, so the app leaves
Recent Changes empty for Storm reports.
## 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.

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

@@ -0,0 +1,15 @@
# 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.
For each `generate` or `run` action, `Runner` constructs one project-owned Promptkit executor after configuration loads. It captures an absolute working directory, resolves a relative output override against it, and passes the working directory, resolved override, and any `--llm-debug-dir` request to the app. With no override, the app derives the report filename in that working directory. `run` uses the same resolution rule for `--out-dir`.
The CLI dispatches only generation and batch actions. It has no persisted-run or inspection dispatch. Summaries include report identity, status, output path, effective profile/backend/model, source warnings, validation, requested debug path, and notification result when available. They 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.
CLI code owns no report policy, weather collection, output publication, provider execution, or notification policy. Focused checks:
```sh
go test ./internal/cli
```

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

@@ -0,0 +1,43 @@
# Collection Internals
`internal/collect` is the application-facing boundary for collecting the
normalized Weather API bundle. The external HTTP contract belongs in the
[Weather API integration guide](../integrations/weatherapi.md); normalized data
semantics belong in [weather-data internals](weather-data.md).
## Contract
`Run` accepts a `context.Context` and a `Request` containing effective
`config.Config`. It constructs the Weather API adapter from that configuration,
calls `FetchBundle`, and returns `Result{Bundle: *weatherdata.Bundle}`.
The package wraps adapter construction failures as weather-collection setup
errors and fetch failures as bundle-collection errors. It does not retry,
persist, select reports, derive facts, build modules, invoke Promptkit, or
notify Distributor.
## Application Composition
`internal/app` owns the narrow `Collector` interface used by workflow tests;
the production implementation delegates to `collect.Run`. Generation, batch
execution, and explicit bundle fetching all use this boundary. Application
orchestration rejects a nil collector result or a nil bundle before report work
can continue.
Single-report generation and a batch each collect once. A batch passes the same
normalized collection to planning and to every report it generates. Collection
failure prevents later workflow work for that request.
## Boundaries And Invariants
Collection owns adapter creation and retrieval of one normalized bundle. It
must not make report, period, batch, prompt, module, filesystem, or notification
decisions.
- App-facing Weather API collection always passes through this package.
- The returned value is normalized source data, not facts or prompt input.
- Context cancellation is passed to the Weather API adapter.
- Errors retain whether setup or fetching failed.
Focused tests are in `internal/collect/collect_test.go`; orchestration use is
also covered by `internal/app/app_test.go`.

View File

@@ -0,0 +1,63 @@
# Distributor Adapter Internals
`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).
## Client construction
`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.
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).
## Upload translation
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:
| 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 |
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.
## Status and errors
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, lifecycle timestamps, report,
and remote error details.
Status lookup or polling errors are preserved in `UploadResult.StatusError` so
the caller can report an accepted-but-unconfirmed delivery. 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.
## Verification
Focused tests cover configuration validation, request mapping, timeouts and
polling, status translation, conflict handling, and token redaction:
```sh
go test ./internal/adapters/distributor
```

64
docs/internal/facts.md Normal file
View File

@@ -0,0 +1,64 @@
# Fact Contracts Internals
`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).
## Collected facts
`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.
A nil bundle produces an empty collected value. Collection itself, missing
source policy, and source hashes are outside this package.
## Report-scoped derivation
`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.
Report identity controls the summary shape:
| 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 |
`DaypartSummaries` is collected from the resulting daily summaries.
The detailed grouping, daypart-window, and alert rules are owned by
[forecast derivation](forecast-derivation.md).
## Missing data and failures
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.
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.
## Verification and invariants
Focused tests cover collected-fact separation, report-period selection,
hourly behavior, daily summaries, and convective outlook selection:
```sh
go test ./internal/facts
```
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,61 @@
# 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. Its summaries are consumed by
[fact contracts](facts.md) and later module builders.
## Purpose
## Period and daypart semantics
`internal/forecast` converts normalized bundle data into daily and period
summaries used by briefing builders.
Selections use `timeutil.Period` half-open overlap: a value is selected only
when both intervals share time. `BuildDailySummary` creates one local civil
day; `BuildPeriodDailySummaries` intersects every local civil day with the
requested period, preserving partial first and last days.
## Inputs And Outputs
`ResolveDayparts` converts each configured name, start clock, and end clock
into a local window. An end clock at or before its start clock wraps into the
next civil day. The daypart and timezone defaults are defined in the
[configuration reference](../config.md), not here.
Inputs:
## Deterministic summaries
- `forecast.Bundle`
- local date or resolved report period
- timezone
- configured daypart definitions
`BuildDailySummary` requires an hourly run with at least one period. It adds
the selected narrative periods, discussion, alert overlaps, source provenance,
source warnings, and one `DaypartSummary` per resolved window. A daypart keeps
its selected hourly periods and derives temperature and apparent-temperature
ranges, timed precipitation and wind maxima, dominant and notable conditions,
and weather indicators.
Outputs:
Indicators are deterministic checks over normalized values and condition text:
heat, cold, and wind use package-owned numeric cutoffs; snow, ice, fog, and
wind text are detected from the forecast description. `BuildPrecipTiming`
sorts periods, records the maximum and first precipitation, groups contiguous
periods at or above its package-owned probability threshold, and records
thunder mentions.
- `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
Alert overlap parsing supports the normalized alert payload's available timing
fields. Unparseable alerts and invalid intervals are ignored; valid overlaps
are clipped to the requested period and ordered by alert start time.
## Boundaries
## Missing data and failures
- 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.
Empty selections yield empty summary fields rather than generated prose.
Direct daily or period-summary calls fail when their required bundle, valid
period, hourly data, or daypart definitions are invalid. A nil location uses
UTC when these APIs are called directly. Optional narrative, discussion, and
alerts remain absent when their normalized products are absent.
## Config Fields Used
Forecast thresholds used for brief indicators and precipitation timing are
implementation rules.
- `dayparts[].name`
- `dayparts[].start`
- `dayparts[].end`
## Verification and invariants
Threshold constants for basic indicators live in forecast code rather than
configuration.
Focused tests cover local civil days, clipped periods, daypart resolution,
summary metrics, precipitation windows, threshold helpers, and alert overlap:
## External Adapters Used
```sh
go test ./internal/forecast ./internal/timeutil
```
None directly. Forecast data arrives through `forecast.Bundle`.
## State Or Manifest Behavior
None. Source warnings and provenance from the bundle are carried into summaries
for later metadata and briefing 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.
The package preserves normalized inputs as inspectable structured values and
never decides report identity, delivery, or presentation wording.

View File

@@ -0,0 +1,52 @@
# Generated Text Internals
`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).
## Catalog and validation
The Daily, Today, Tomorrow, and Hourly report definitions each use structured
generated text. `LookupDefinition` rejects unknown schema or template IDs and
unsupported schema/template pairs before the run begins. A handler validates raw JSON, returns a typed
value and canonical normalized JSON, loads its canonical schema through
`internal/promptassets`, builds a render context, and renders through
`internal/reporttemplate`.
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 rejects missing required fields and unknown JSON
fields; no general-purpose JSON Schema engine is used at runtime.
## Render contexts
The catalog's report-specific builders receive briefing metadata, a rich module
snapshot, collected facts, derived facts, and the matching validated generated
text. They decode the module stanzas needed by the template and build typed
Daily, Today, Tomorrow, or Hourly contexts. Context construction validates
metadata and periods, preserves rich module values, and uses ordered slices for
template iteration rather than maps.
Optional source stanzas become nil or fallback context fields. Missing required
stanzas, type-decoding failures, 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.
## 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
```
Generated text supplies prose slots only; deterministic weather facts remain in
module and fact values. Every report definition must resolve to exactly one
supported catalog pair.

66
docs/internal/module.md Normal file
View File

@@ -0,0 +1,66 @@
# Module Contract Internals
`internal/module` defines the stable 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).
## Outputs and snapshots
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.
`NewSnapshot` builds the ordered `weatherreporter.modules.v1` snapshot and
validates it. Its JSON representation contains 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.
Snapshots reject missing schema versions, empty IDs or stanza names, and
duplicate IDs or stanza names. Output order is caller-owned and preserved.
## Registered IDs and default composition
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`.
The registry declares these ordered default compositions:
| 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 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.
## Rich and prompt-facing values
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).
## Verification and invariants
Focused tests cover snapshot validation and order, typed stanza lookup, and
prompt-value fallback:
```sh
go test ./internal/module
```
Module IDs and stanza names are stable, every emitted output has one of each,
and this package never imports the report registry.

View File

@@ -1,69 +1,28 @@
# Prompt Input Internals
This document describes prompt input data package construction.
`internal/promptinput` converts report metadata, an ordered module snapshot, and source warnings into the YAML `data_package` supplied inline to Promptkit. It owns the package schema, grouping, serialization, loading, and validation; it does not choose an output destination, collect weather, execute a provider, or retain packages after a command ends.
## Purpose
## Package Construction
`internal/promptinput` converts a structured briefing package and optional
Recent Changes into the `data_package` JSON passed to Scriptorium prompts.
`Build` produces `weatherreporter.data_package.v4`. It copies the run ID; report ID, variant, prompt ID, generation time, timezone, local current date, and valid period; ordered briefing stanzas; and source warnings. Prompt input contains no historical comparison section.
## Inputs And Outputs
Briefing is a flat ordered set of stanza values. `Build` uses each output's `DataPackageValue`, so curated prompt exports take precedence and rich values are used only as a fallback. Prompt exports are selected by the [briefing registry](briefing.md), while the rich-versus-prompt contract is in [module internals](module.md).
Inputs:
## YAML Ordering And Validation
- `briefing.Package`
- optional `[]changes.Change`
Serialization keeps `metadata` directly under `briefing`. Every other known stanza is placed in one category and emitted in category order while preserving its original module order:
Outputs:
| Category | Current stanzas |
| --- | --- |
| `applicable_risk_products` | alert digest, SPC convective outlooks |
| `derived_summaries` | deterministic summaries, precipitation timing, outdoor windows, and planning values |
| `narrative_products` | narrative forecast, discussions, and weather story |
| `raw_data` | current conditions and hourly forecast |
- `promptinput.Package` containing schema version, RunID, report metadata,
briefing content, Recent Changes, and source warnings. Briefing content
includes configured location context, current conditions when available,
discussion key messages, and short/long-term AFD narratives when the Weather
API provides them.
- report metadata includes `currentLocalDate`, the generation date formatted as
`YYYY-MM-DD` in the effective report timezone.
- optional JSON file written by `promptinput.Save`
`LoadYAML` accepts this layout and reconstructs the flat order and values. It rejects misplaced, duplicate, unknown, or uncategorized stanzas. `Validate` requires the v4 schema version, report identity and period fields, and at least one ordered briefing stanza. `MarshalYAML` and `LoadYAML` validate their result. `Save` remains a reusable atomic-file helper for callers that explicitly need one; normal application execution passes marshalled YAML directly to Promptkit.
## Boundaries
Focused tests cover construction, curated exports, category ordering, YAML round trips, invalid layout, validation, and atomic saves:
- This package owns the prompt input schema and validation.
- It does not fetch weather data, derive forecast summaries, find prior
snapshots, compare changes, or invoke Scriptorium.
## Config Fields Used
None directly. Config-derived values, including timezone and prompt location
context, are already present in briefing metadata before this package runs.
## External Adapters Used
None.
## State Or Manifest Behavior
`promptinput.Save` writes JSON 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 or briefing
metadata fields are missing or inconsistent, or when no report content is
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 `data_package` JSON.
- Briefing metadata and top-level report metadata must agree.
- Recent Changes are not inferred from rendered report text.
```sh
go test ./internal/promptinput
```

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, the embedded fallback catalog, and its built-in catalog; the adapter does not parse profile YAML, merge sources, 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`. `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,97 +1,30 @@
# 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 report identities, valid-period resolution, exact prompt identity and version, output names, default module composition, and Distributor path declarations. Public command syntax belongs in the [CLI reference](../cli.md); configuration aliases and overrides belong in the [configuration reference](../config.md).
## Purpose
## Definitions And Resolution
`internal/report` is the canonical source for report definitions. App, state,
briefing, and CLI wiring consume resolved definitions instead of owning report
identity policy themselves.
Each `Definition` declares a stable ID and display name, prompt ID and version, template and generated-text schema IDs, valid-period resolver, default output name, Distributor path templates, module list, and fixed batch eligibility. `Resolved` combines a definition with one valid period and run identity.
## Definition Fields
Each report definition declares:
- report ID and display name
- Scriptorium prompt ID
- valid-period resolver
- comparison strategy
- managed artifact group
- batch output copy filename
- generated-report eligibility
- prior-report compatibility list
- morning or evening batch membership
## Implemented Reports
| Report | ID | Prompt | Artifact group | Batch copy | Prior compatibility |
| Report ID | Prompt version | Default profile | Period policy | Fixed batch flag | Default output |
| --- | --- | --- | --- | --- | --- |
| Daily Today | `daily_today` | `weather.daily_report` | `daily` | `daily.md` | Daily Today, Daily Tomorrow |
| Daily Tomorrow | `daily_tomorrow` | `weather.daily_report` | `daily` | `tomorrow.md` | Daily Today, Daily Tomorrow |
| 3-Day Outlook | `three_day` | `weather.three_day_outlook` | `three-day` | `three-day.md` | 3-Day Outlook |
| Weekend Outlook | `weekend` | `weather.weekend_outlook` | `weekend` | `weekend.md` | Weekend Outlook |
| Storm Report | `storm` | `weather.storm_report` | `storm` | `storm.md` | Storm Report |
| `daily` | `2.0.0` | `weather-balanced` | Explicit local civil day | Dynamic Daily inclusion is app-owned | `daily-YYYY-MM-DD.md` |
| `today` | `2.0.0` | `weather-balanced` | Selected or current local civil day | Morning | `today.md` |
| `tomorrow` | `2.0.0` | `weather-balanced` | Next local civil day | Evening | `tomorrow.md` |
| `hourly` | `2.0.0` | `weather-light` | Rolling six-hour interval | — | `hourly.md` |
All implemented report definitions are eligible for generation.
Daily derives its filename from the resolved valid-period start in the effective timezone, so multiple Daily items have distinct destinations. Exact template fields and schema assets belong to [report templates](../templates.md) and [generated-text internals](generatedtext.md). Prompt assets own default profile selection; the registry stores no provider setting.
## Valid Periods
## Collaborators And Boundaries
- Daily Today covers the selected local civil day, or the current local civil
day when no date override is supplied.
- Daily Tomorrow covers the next local civil day from generation time.
- 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.
`DefaultRegistry`, `Lookup`, `Resolve`, and report-name helpers prevent callers from duplicating report identity rules. Registry overrides clone a recognized definition and replace its module list. `DistributorPathTemplates` are consumed by app orchestration; their rendered external bundle-path contract is documented in the [Distributor bundle guide](../integrations/distributor/pkg-bundle.md).
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.
`morning` and `evening` are registry-owned batch names. Fixed flags declare Today and Tomorrow eligibility; app orchestration determines data-dependent Daily membership and the actual batch plan.
## Boundaries
The registry never collects weather data, parses CLI flags, writes output, executes Promptkit, or delivers a report.
`internal/report` defines report metadata and time coverage. It does not fetch
weather data, build briefings, compare briefing contents, write state, parse CLI
flags, or invoke Scriptorium.
Focused tests cover definition completeness, command and alias lookup, period resolution, run IDs, output names, composition defaults, and override validation:
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.
## State And App Usage
- State paths use `ArtifactGroup`.
- Batch output copies use `BatchOutputName`.
- Generation checks `Generated`.
- 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.
- Daily Today and Daily Tomorrow both use `weather.daily_report`.
- Valid periods are half-open intervals independent of rendered report text.
- Artifact grouping, batch output filenames, generated-report eligibility,
comparison compatibility, and comparison strategy are declared by report
definition.
```sh
go test ./internal/report
```

View File

@@ -0,0 +1,51 @@
# Report Template Internals
`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).
## Assets and lookup
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.
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.
## Rendering
`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.
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, and it must not reimplement weather selection or generated-text
validation.
## Boundaries and verification
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.
Focused tests cover template lookup, rendering, partial
behavior, missing keys, and malformed context:
```sh
go test ./internal/reporttemplate
```
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,100 +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. 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
- prompt input data package path
- report output path for `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`
## 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 and run operations.
State persistence uses a state-owned preflight artifact shape; app
orchestration converts render 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>
```
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 and 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 and 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`.
- Render and run preserve command-specific result structs.
- Scriptorium-specific flags stay inside adapter and config boundaries.

View File

@@ -1,117 +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
- briefing package
- prompt input data package
- preflight artifact
- rendered report path preparation request
- RunID for inspection lookups
Outputs:
- briefing snapshot JSON path
- prompt input data package JSON path
- render preflight JSON path
- managed Markdown report path
- metadata JSON path
- prior comparable snapshot metadata
- loaded briefing or data package
- 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 briefing 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 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 JSON artifacts, and the RunID.
```text
<workspace.root>/
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.briefing.json
snapshots/<artifact_group>/<YYYY-MM-DD>/<run_id>.metadata.json
data-packages/<artifact_group>/<YYYY-MM-DD>/<run_id>.data_package.json
preflight/<artifact_group>/<YYYY-MM-DD>/<run_id>.render.json
reports/<artifact_group>/<run_id>.md
```
Metadata is stored beside briefing snapshots and links the briefing, data
package, preflight, report paths, and configured prompt location. 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 and Daily Tomorrow are compatible with each other 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.
- Storm Report currently 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. 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.
Inspection helpers read existing metadata, briefing, and data package 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.
- Prior lookup is based on structured metadata, not rendered report text.

View File

@@ -1,89 +1,68 @@
# Weather Data Internals
This document describes Weather API ingestion into `forecast.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 one
configured Weather API endpoint and assembles the bundle consumed by forecast
derivation and briefing builders. Briefing builders expose normalized current
conditions as prompt context when `/conditions/current` is 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
## Source provenance
Outputs:
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.
- `forecast.Bundle` with observation, current conditions, hourly forecast,
narrative forecast, active alerts, discussion, source records, and source
warnings
- stub source records for daily forecast and weather story source slots
- optional saved bundle JSON through app fetch helpers
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).
## Boundaries
## Warning semantics
- 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 briefings, compare
snapshots, write report state, or invoke Scriptorium.
`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.
## Config Fields Used
Warnings describe data completeness, not rendering or delivery failures.
Those failures are reported by [application orchestration](app-orchestration.md).
- `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`
## Boundaries and verification
## External Adapters Used
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:
- Weather API HTTP service
See [Weather API integration](../integrations/weatherapi.md) for the external
contract used by this project.
## 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.
`app.FetchAndSaveBundle` can write bundle JSON atomically for inspection.
## 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 and stub sources follow missing-source policy.
- Explicit `data: null` from `/alerts/active` produces an empty, non-missing
alert run.
## 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,187 +1,126 @@
# Weatherreporter Operations
This guide covers normal operation, generated artifacts, inspection, recovery,
and current 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
Implemented generation commands:
After configuring a Weather API endpoint, generate one report:
```text
weatherreporter generate daily --date 2026-05-29
weatherreporter generate tomorrow
weatherreporter generate three-day
weatherreporter generate weekend
weatherreporter generate storm --start 2026-05-29T18:00 --end 2026-05-30T06:00
```sh
weatherreporter generate today
```
Each command resolves a report period, fetches a Weather API bundle, builds a
briefing, builds a prompt input data package, runs `scriptorium render`, runs
`scriptorium run`, and writes managed artifacts under the configured workspace.
`--out PATH` writes an extra Markdown copy for the current generated report.
The command writes `today.md` in the current directory. Choose a different
operator-owned file with `--out`; a relative path is resolved from the current
directory and an absolute path is used directly. 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.
Implemented batch commands:
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.
```text
weatherreporter run morning
weatherreporter run evening
## 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 Planning Brief. 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. `--out-dir PATH` writes extra Markdown copies
using report default filenames such as `daily.md`, `three-day.md`,
`weekend.md`, and `tomorrow.md`.
Without `--out-dir`, batch reports are written beneath the current directory.
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.
## 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.
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.
See the [configuration reference](config.md) for pipeline, bundle,
idempotency-key, and per-report path templates.
```text
workspace/
snapshots/
daily/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
three-day/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
weekend/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
storm/
YYYY-MM-DD/
<run_id>.briefing.json
<run_id>.metadata.json
data-packages/
daily/
YYYY-MM-DD/
<run_id>.data_package.json
three-day/
YYYY-MM-DD/
<run_id>.data_package.json
weekend/
YYYY-MM-DD/
<run_id>.data_package.json
storm/
YYYY-MM-DD/
<run_id>.data_package.json
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
storm/
YYYY-MM-DD/
<run_id>.render.json
reports/
daily/
<run_id>.md
three-day/
<run_id>.md
weekend/
<run_id>.md
storm/
<run_id>.md
## 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. 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
If capture creation or writing fails, the affected run fails rather than
silently continuing without the requested diagnostics.
RunIDs are based on generation time plus report ID:
## Diagnosing Failures
```text
20260529T100000.123456789Z_daily_today
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. 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
- briefing snapshot path
- prompt input data package path
- preflight output path
- managed Markdown report path
Batch summaries include report status, error text when applicable, valid
period, and known artifact paths for each attempted report.
## 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 briefing RUN_ID
weatherreporter inspect data-package RUN_ID
weatherreporter inspect prior RUN_ID
weatherreporter inspect sources RUN_ID
```
Use `inspect reports` to find recent RunIDs and artifact paths. Use
`inspect metadata` to see the artifact links recorded for a run. Use
`inspect briefing` and `inspect data-package` to review the exact structured
inputs 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 briefing snapshots, not rendered
Markdown text.
Daily Today and Daily Tomorrow can compare with each other when they cover 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. Storm Report currently
leaves 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.
- 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 briefing 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,119 +1,77 @@
# 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 briefing packages, compares those packages against prior snapshots, 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 briefing 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, briefing builder, 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, briefing snapshot path, and output path. Recent Changes must be based on structured snapshot comparison rather than comparison of rendered Markdown report text.
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.
`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.
## 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.
- 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; unsupported direct-key profiles and missing reported credentials fail
before collection.
- Prompt and profile validation completes before weather collection. Raw output
is validated before template rendering.
- 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.
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.
- 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.
- 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.
## 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,193 +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/weatherapi`: Weather API HTTP adapter.
- `internal/adapters/scriptorium`: Scriptorium subprocess adapter.
- `internal/forecast`: normalized bundle types and deterministic forecast
derivation.
- `internal/report`: report definitions, valid periods, batches, output names,
and comparison declarations.
- `internal/briefing`: report-specific briefing package builders.
- `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/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 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 dependency:
- `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 or Scriptorium services.
Preferred test patterns:
- fake command runners for subprocess behavior;
- `httptest.Server` for Weather API 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 or Scriptorium 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.

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,23 +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/`.
## Deferred: Automatic Storm Monitoring
## Upstream Forecast Change Product
Manual Storm Report generation is implemented through
`weatherreporter generate storm --start TIME --end TIME`. Automatic storm-event
evaluation remains deferred.
Status: Proposed upstream feature request; unimplemented.
Proposed direction:
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.
1. detect candidate events deterministically from alerts, forecast discussion,
weather story context when available, 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.
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
Status: Proposed and unimplemented.
Storm reporting, whether manual or automatic, is unimplemented.
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:
@@ -28,59 +63,115 @@ 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
## Deferred: Alternate Runtime Integrations
Status: Proposed and unimplemented.
These ideas are not current behavior:
Possible future report types:
- native LLM client inside `weatherreporter`;
- database-backed state;
- public HTTP API;
- multi-location selection;
- daemon mode;
- multi-user authorization;
- plugin system.
- 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
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` 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
- 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`
- SPC, radar, QPF, snow/rain total, or historical-observation modules once
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 the boundaries documented in [fact
contracts](../internal/facts.md), [module internals](../internal/module.md), and
[briefing internals](../internal/briefing.md):
- 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
Status: Proposed and unimplemented.
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`
- 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 adapter boundary:
weatherreporter selects explicit generated files and submits source bundles,
while distributor owns destination routing and publication behavior.
## Alternate Runtime Integrations
Status: Proposed and unimplemented.
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.
## Deferred: 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 briefing
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.
- Plugin architecture: defer until there is a concrete external extension
contract and at least one implemented extension point.
- 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, 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.

178
docs/templates.md Normal file
View File

@@ -0,0 +1,178 @@
# Report Templates
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).
## Template Assets
Only the generated-text reports use repository-native Markdown templates.
Each report has one matching template ID, generated-text schema ID, and prompt
source:
| 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/` |
The matching schemas and Promptkit definitions are embedded by
`internal/promptassets`. The generated-text catalog pairs each schema ID with
its template ID; keep the matching prompt definition aligned with that pair.
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 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.
- 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.
Minimal optional-value pattern:
```gotemplate
{{ with .Modules.CurrentConditions }}
Currently, it is {{ with .TemperatureF }}{{ . }}°F{{ end }}.
{{ else }}
Current conditions are unavailable.
{{ end }}
```
Minimal list pattern:
```gotemplate
{{ range .GeneratedText.ForecastDiscussion }}
{{ . }}
{{ end }}
```
## Registered Functions
Templates have these helpers in addition to Go template built-ins:
| Function | Accepts | Returns true when |
| --- | --- | --- |
| `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 |
For example, the alert partial uses the first two functions to decide whether
to render the section:
```gotemplate
{{ if hasRelevantAlerts .Modules.AlertDigest }}
## Alert Digest
{{ end }}
```
## Render Context
Every rendered template receives one typed context with these five top-level
fields:
| Field | Purpose |
| --- | --- |
| `.Report` | Display labels and canonical report timing metadata. |
| `.GeneratedText` | Validated prose supplied by Promptkit. |
| `.Modules` | Deterministic, typed values prepared for Markdown rendering. |
| `.Collected` | Normalized upstream facts for advanced use. |
| `.Derived` | Shared calculated facts for advanced use. |
`.Collected` and `.Derived` are available for an exceptional display need, but
they are lower-level contracts. Keep reusable weather derivation in Go and use
the module surface for normal template work.
### Report Metadata
All contexts provide `.Report.Title`, `.Report.GeneratedAt`,
`.Report.GeneratedAtLabel`, `.Report.ValidPeriod`, and `.Report.Timezone`.
Hourly additionally provides `.Report.LocationName` and
`.Report.ValidPeriodLabel`.
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.
### Validated GeneratedText Prose
GeneratedText is prose returned by Promptkit and validated before rendering.
It is not a source for deterministic weather facts.
| Field | Hourly type | Daily, Today, and Tomorrow type | Notes |
| --- | --- | --- | --- |
| `.GeneratedText.Summary` | `string` | `string` | Required. |
| `.GeneratedText.ForecastDiscussion` | `string` | `[]string` | Required; range over the day-style paragraph slice. |
| `.GeneratedText.PrecipitationTiming` | `string` | `string` | Required field; an empty string represents no supported prose. The precipitation partial uses nonempty prose only when deterministic windows exist. |
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).
### Deterministic Module Values
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.Metadata`, `.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.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
git diff --check
```
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,235 +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).
## 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, briefing, and data package for that RunID.
Relevant docs: [CLI reference](cli.md), [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
@@ -11,21 +11,34 @@ location:
name: Brentwood
region: St. Louis Metro
secrets:
directory: ""
notify:
distributor:
enabled: false
endpoint: https://distributor.example.com
token_env: DISTRIBUTOR_UPLOAD_TOKEN
timeout: 30s
failure_policy: error
pipeline_id_template: "weatherreporter.{report_id}"
bundle_id_template: "weatherreporter.{location_id}.{report_id}"
idempotency_key_template: "{bundle_id}.{run_id}"
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
local:
concurrency_limit: 1
dayparts:
- name: overnight
@@ -44,8 +57,64 @@ 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
- narrative_forecast
- derived_daily_summary
- derived_daypart_summaries
- precip_timing
- alert_digest
- spc_convective_outlooks
- id: area_forecast_discussion
options:
sections:
- product
- key_messages
- short_term
- long_term
- spc_convective_discussion
- weather_story
- outdoor_windows
- hourly_forecast
- today_planning
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

View File

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

10
go.mod
View File

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

56
go.sum
View File

@@ -1,3 +1,59 @@
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.5.0 h1:jnpazLyyNhWrB2xzwwtUkNUfktkTdkENTwuSPnKiYrc=
gitea.maximumdirect.net/eric/promptkit v0.5.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=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/config v1.32.20 h1:8VMDnWc/kEzxsI/1ngGM9mG81a8IGmIHD8KLcYGwagc=
github.com/aws/aws-sdk-go-v2/config v1.32.20/go.mod h1:PuwEpciweIXGULWeOeSTXtSbH4CW9mWdWrhdCKQI1sM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25 h1:0w6dCiO8iez+YKwRhRBlL1CH/E3GTfdkuzrwj1by8vo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.25/go.mod h1:9FDWUothyr5RCRAHc45XOiVCzUR8n/IhCYX+uVqw6vk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1 h1:1VwbP3qMNfxUDEXWki4rCE5iA+44VA1lokTz9HasGzw=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.1/go.mod h1:vUtyoSj0OPji3kjIVSc/GlKuWEiL33f/WFxl6dmpy/A=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19 h1:N6pIsdFOW1Kd9S4KyFKXdGRBojPPxkP32+uHFWLv4Hc=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.19/go.mod h1:3gt5WJArFooNmyLONS+h/R4J+o86II8du38IgCwj9dE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2 h1:hc+lBYiiTr8Zk4MTzIsQ92MeDWCIDvWGmzKUWOaBcOg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.2/go.mod h1:hU6fqB3OJA6/ePheD47LQnxvjYk6br6PtQxs+Q9ojvk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.3 h1:ErklX/7uhSbkAAeyQD/Y1OoQ9hO3SJXQNEgksORW3Js=
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

@@ -0,0 +1,371 @@
// Package distributor adapts weatherreporter report artifacts to distributor uploads.
package distributor
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
distributorbundle "gitea.maximumdirect.net/eric/distributor/pkg/bundle"
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
type Client struct {
Endpoint string
TokenEnv string
Timeout time.Duration
newUploadClient uploadClientFactory
}
type UploadRequest struct {
PipelineID string
BundleID string
IdempotencyKey string
Files []UploadFile
CreatedAt time.Time
}
type UploadFile struct {
SourcePath string
BundlePath string
}
type UploadResult struct {
RunID string
Status string
UploadStatus string
StatusError string
RunStatus *RunStatus
}
type RunStatus struct {
RunID string
PipelineID string
Status string
AcceptedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
Report json.RawMessage
Error string
}
type IdempotencyConflictError struct {
Err error
}
func (e *IdempotencyConflictError) Error() string {
if e == nil || e.Err == nil {
return "distributor idempotency conflict"
}
return e.Err.Error()
}
func (e *IdempotencyConflictError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
type uploadClientFactory func(endpoint, token string, timeout time.Duration) (uploadClient, error)
type uploadClient interface {
UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error)
Status(ctx context.Context, runID string) (runStatus, error)
}
type uploadFilesOptions struct {
PipelineID string
BundleID string
IdempotencyKey string
Files []UploadFile
CreatedAt time.Time
}
type uploadFilesResult struct {
RunID string
Status string
}
type runStatus struct {
RunID string
PipelineID string
Status string
AcceptedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
Report json.RawMessage
Error string
}
const statusPollInterval = 250 * time.Millisecond
func New(cfg config.DistributorNotifyConfig) *Client {
return newClient(cfg, newDistributorUploadClient)
}
func newClient(cfg config.DistributorNotifyConfig, factory uploadClientFactory) *Client {
if factory == nil {
factory = newDistributorUploadClient
}
return &Client{
Endpoint: cfg.Endpoint,
TokenEnv: cfg.TokenEnv,
Timeout: cfg.Timeout,
newUploadClient: factory,
}
}
func (c *Client) Upload(ctx context.Context, req UploadRequest) (UploadResult, error) {
if c == nil {
return UploadResult{}, fmt.Errorf("distributor client is nil")
}
if c.Endpoint == "" {
return UploadResult{}, fmt.Errorf("distributor endpoint is required")
}
if c.TokenEnv == "" {
return UploadResult{}, fmt.Errorf("distributor token environment variable is required")
}
if req.PipelineID == "" {
return UploadResult{}, fmt.Errorf("distributor pipeline id is required")
}
if req.BundleID == "" {
return UploadResult{}, fmt.Errorf("distributor bundle id is required")
}
if req.IdempotencyKey == "" {
return UploadResult{}, fmt.Errorf("distributor idempotency key is required for bundle %q", req.BundleID)
}
if len(req.Files) == 0 {
return UploadResult{}, fmt.Errorf("distributor upload files are required for bundle %q", req.BundleID)
}
for i, file := range req.Files {
if file.SourcePath == "" {
return UploadResult{}, fmt.Errorf("distributor source path is required for bundle %q file %d", req.BundleID, i)
}
if file.BundlePath == "" {
return UploadResult{}, fmt.Errorf("distributor bundle path is required for bundle %q file %d", req.BundleID, i)
}
}
if c.newUploadClient == nil {
return UploadResult{}, fmt.Errorf("distributor upload client factory is required for endpoint %q", c.Endpoint)
}
token := os.Getenv(c.TokenEnv)
if token == "" {
return UploadResult{}, fmt.Errorf("distributor token environment variable %q is not set", c.TokenEnv)
}
uploadClient, err := c.newUploadClient(c.Endpoint, token, c.Timeout)
if err != nil {
return UploadResult{}, fmt.Errorf("create distributor upload client for endpoint %q: %w", c.Endpoint, redactToken(err, token))
}
runCtx := ctx
if runCtx == nil {
runCtx = context.Background()
}
cancel := func() {}
if c.Timeout > 0 {
runCtx, cancel = context.WithTimeout(runCtx, c.Timeout)
}
defer cancel()
result, err := uploadClient.UploadFiles(runCtx, uploadFilesOptions{
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
Files: append([]UploadFile(nil), req.Files...),
CreatedAt: req.CreatedAt,
})
if err != nil {
return UploadResult{}, wrapUploadError(err, uploadErrorContext{
Endpoint: c.Endpoint,
PipelineID: req.PipelineID,
BundleID: req.BundleID,
IdempotencyKey: req.IdempotencyKey,
SourcePaths: uploadSourcePaths(req.Files),
BundlePaths: uploadBundlePaths(req.Files),
Token: token,
})
}
uploadResult := UploadResult{
RunID: result.RunID,
Status: result.Status,
UploadStatus: result.Status,
}
status, statusErr := waitForRunStatus(runCtx, uploadClient, result.RunID, c.Timeout > 0)
if status.RunID != "" || status.Status != "" {
uploadResult.RunStatus = &RunStatus{
RunID: status.RunID,
PipelineID: status.PipelineID,
Status: status.Status,
AcceptedAt: status.AcceptedAt,
StartedAt: status.StartedAt,
FinishedAt: status.FinishedAt,
Report: append(json.RawMessage(nil), status.Report...),
Error: redactTokenString(status.Error, token),
}
if status.Status != "" {
uploadResult.Status = status.Status
}
}
if statusErr != nil {
uploadResult.StatusError = redactTokenString(statusErr.Error(), token)
return uploadResult, nil
}
if status.Status == "failed" {
return uploadResult, fmt.Errorf("distributor run %q failed: %s", status.RunID, uploadResult.RunStatus.Error)
}
return uploadResult, nil
}
func waitForRunStatus(ctx context.Context, client uploadClient, runID string, poll bool) (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:
}
next, err := client.Status(ctx, runID)
if err != nil {
return status, err
}
status = next
if terminalRunStatus(status.Status) {
return status, nil
}
}
}
func terminalRunStatus(status string) bool {
return status == "succeeded" || status == "failed"
}
type distributorUploadClient struct {
client *distributorupload.Client
}
func newDistributorUploadClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
httpClient := (*http.Client)(nil)
if timeout > 0 {
httpClient = &http.Client{Timeout: timeout}
}
client, err := distributorupload.NewClient(distributorupload.ClientOptions{
Endpoint: endpoint,
Token: token,
HTTPClient: httpClient,
})
if err != nil {
return nil, err
}
return distributorUploadClient{client: client}, nil
}
func (c distributorUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
files := make([]distributorbundle.BundleFile, 0, len(opts.Files))
for _, file := range opts.Files {
files = append(files, distributorbundle.BundleFile{
SourcePath: file.SourcePath,
Path: file.BundlePath,
})
}
result, err := c.client.UploadFiles(ctx, distributorupload.UploadFilesOptions{
PipelineID: opts.PipelineID,
ID: opts.BundleID,
Created: opts.CreatedAt,
IdempotencyKey: opts.IdempotencyKey,
Files: files,
})
if err != nil {
return uploadFilesResult{}, err
}
return uploadFilesResult{
RunID: result.RunID,
Status: result.Status,
}, nil
}
func (c distributorUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
status, err := c.client.Status(ctx, runID)
if err != nil {
return runStatus{}, err
}
return runStatus{
RunID: status.RunID,
PipelineID: status.PipelineID,
Status: status.Status,
AcceptedAt: status.AcceptedAt,
StartedAt: status.StartedAt,
FinishedAt: status.FinishedAt,
Report: append(json.RawMessage(nil), status.Report...),
Error: status.Error,
}, nil
}
type uploadErrorContext struct {
Endpoint string
PipelineID string
BundleID string
IdempotencyKey string
SourcePaths []string
BundlePaths []string
Token string
}
func wrapUploadError(err error, ctx uploadErrorContext) error {
var conflict *distributorupload.IdempotencyConflictError
isConflict := errors.As(err, &conflict)
err = redactToken(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),
}
}
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 uploadSourcePaths(files []UploadFile) []string {
paths := make([]string, 0, len(files))
for _, file := range files {
paths = append(paths, file.SourcePath)
}
return paths
}
func uploadBundlePaths(files []UploadFile) []string {
paths := make([]string, 0, len(files))
for _, file := range files {
paths = append(paths, file.BundlePath)
}
return paths
}
func redactToken(err error, token string) error {
if err == nil || token == "" {
return err
}
return errors.New(redactTokenString(err.Error(), token))
}
func redactTokenString(value, token string) string {
if token == "" {
return value
}
return strings.ReplaceAll(value, token, "[redacted]")
}

View File

@@ -0,0 +1,406 @@
package distributor
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
func TestUploadUsesConfiguredClientAndFiles(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN"
cfg.Timeout = 15 * time.Second
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
status: runStatus{RunID: "run-123", PipelineID: "reports", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
},
}
client := newClient(cfg, factory.newClient)
result, err := client.Upload(context.Background(), UploadRequest{
PipelineID: "weatherreporter.daily",
BundleID: "weatherreporter.home.daily.run",
IdempotencyKey: "weatherreporter.home.daily.run",
Files: []UploadFile{
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
},
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
})
if err != nil {
t.Fatalf("Upload() error = %v", err)
}
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 factory.endpoint != cfg.Endpoint {
t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint)
}
if factory.token != "secret-token" {
t.Fatalf("factory token = %q, want secret-token", factory.token)
}
if factory.timeout != 15*time.Second {
t.Fatalf("factory timeout = %s, want 15s", factory.timeout)
}
got := factory.client.opts
if got.PipelineID != "weatherreporter.daily" {
t.Fatalf("PipelineID = %q, want weatherreporter.daily", got.PipelineID)
}
if got.BundleID != "weatherreporter.home.daily.run" {
t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID)
}
if got.IdempotencyKey != "weatherreporter.home.daily.run" {
t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey)
}
if len(got.Files) != 2 {
t.Fatalf("files = %#v, want two mappings", got.Files)
}
if got.Files[0].SourcePath != "/tmp/report.md" || got.Files[0].BundlePath != "2026-06-07/daily/report.md" {
t.Fatalf("first file = %#v, want archive mapping", got.Files[0])
}
if got.Files[1].SourcePath != "/tmp/report.md" || got.Files[1].BundlePath != "2026-06-07/daily/latest.md" {
t.Fatalf("second file = %#v, want latest mapping", got.Files[1])
}
if got.CreatedAt.IsZero() {
t.Fatal("CreatedAt is zero, want generated report timestamp")
}
if factory.client.statusRunID != "run-123" {
t.Fatalf("Status runID = %q, want run-123", factory.client.statusRunID)
}
}
func TestUploadRejectsMissingInputs(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
t.Setenv(cfg.TokenEnv, "secret-token")
tests := []struct {
name string
mutate func(*Client, *UploadRequest)
wantErr string
}{
{
name: "Token",
mutate: func(c *Client, req *UploadRequest) {
t.Setenv(c.TokenEnv, "")
},
wantErr: "token environment variable",
},
{
name: "PipelineID",
mutate: func(c *Client, req *UploadRequest) {
req.PipelineID = ""
},
wantErr: "pipeline id is required",
},
{
name: "Files",
mutate: func(c *Client, req *UploadRequest) {
req.Files = nil
},
wantErr: "upload files are required",
},
{
name: "SourcePath",
mutate: func(c *Client, req *UploadRequest) {
req.Files[0].SourcePath = ""
},
wantErr: "source path is required",
},
{
name: "BundlePath",
mutate: func(c *Client, req *UploadRequest) {
req.Files[0].BundlePath = ""
},
wantErr: "bundle path is required",
},
{
name: "UploadClientFactory",
mutate: func(c *Client, req *UploadRequest) {
c.newUploadClient = nil
},
wantErr: "upload client factory is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv(cfg.TokenEnv, "secret-token")
client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient)
req := validUploadRequest()
tt.mutate(client, &req)
_, err := client.Upload(context.Background(), req)
if err == nil {
t.Fatal("Upload() error = nil, want error")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr)
}
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("error = %q, want no token value", err.Error())
}
})
}
}
func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
err: fmt.Errorf("factory failed with secret-token"),
}
client := newClient(cfg, factory.newClient)
_, err := client.Upload(context.Background(), validUploadRequest())
if err == nil {
t.Fatal("Upload() error = nil, want error")
}
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("error = %q, want no token value", err.Error())
}
if !strings.Contains(err.Error(), cfg.Endpoint) {
t.Fatalf("error = %q, want endpoint context", err.Error())
}
}
func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")},
}
client := newClient(cfg, factory.newClient)
req := validUploadRequest()
_, err := client.Upload(context.Background(), req)
if err == nil {
t.Fatal("Upload() error = nil, want error")
}
for _, want := range []string{cfg.Endpoint, req.PipelineID, req.BundleID, req.IdempotencyKey, req.Files[0].SourcePath, req.Files[0].BundlePath, req.Files[1].BundlePath} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want context %q", err.Error(), want)
}
}
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("error = %q, want no token value", err.Error())
}
}
func TestUploadReturnsAcceptedWhenStatusLookupFails(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
statusErr: fmt.Errorf("status rejected secret-token"),
},
}
client := newClient(cfg, factory.newClient)
result, err := client.Upload(context.Background(), validUploadRequest())
if err != nil {
t.Fatalf("Upload() error = %v, want accepted upload despite status lookup failure", err)
}
if result.Status != "accepted" || result.StatusError == "" {
t.Fatalf("result = %#v, want accepted status with status error", result)
}
if strings.Contains(result.StatusError, "secret-token") {
t.Fatalf("StatusError = %q, want token redacted", result.StatusError)
}
}
func TestUploadPollsUntilTerminalStatus(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
cfg.Timeout = 2 * time.Second
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
statuses: []runStatus{
{RunID: "run-123", Status: "accepted"},
{RunID: "run-123", Status: "succeeded", Report: json.RawMessage(`{"actions":[{"action":"replace_older"}]}`)},
},
},
}
client := newClient(cfg, factory.newClient)
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 factory.client.statusCalls != 2 {
t.Fatalf("status calls = %d, want 2", factory.client.statusCalls)
}
}
func TestUploadReturnsLatestStatusWhenPollingTimesOut(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
cfg.Timeout = time.Millisecond
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
status: runStatus{RunID: "run-123", Status: "running"},
},
}
client := newClient(cfg, factory.newClient)
result, err := client.Upload(context.Background(), validUploadRequest())
if err != nil {
t.Fatalf("Upload() error = %v, want accepted upload with status timeout recorded", err)
}
if result.Status != "running" || result.StatusError == "" {
t.Fatalf("result = %#v, want latest status and status timeout", result)
}
}
func TestUploadFailsWhenDistributorRunFailed(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
result: uploadFilesResult{RunID: "run-123", Status: "accepted"},
status: runStatus{
RunID: "run-123",
Status: "failed",
Error: "destination rejected secret-token",
Report: json.RawMessage(`{"actions":[{"action":"failed"}]}`),
},
},
}
client := newClient(cfg, factory.newClient)
result, err := client.Upload(context.Background(), validUploadRequest())
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 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)
}
}
func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) {
cfg := config.Defaults().Notify.Distributor
cfg.Endpoint = "https://distributor.example.test"
t.Setenv(cfg.TokenEnv, "secret-token")
factory := &fakeUploadFactory{
client: &fakeUploadClient{
err: &distributorupload.IdempotencyConflictError{
HTTPError: distributorupload.HTTPError{
StatusCode: 409,
Status: "409 Conflict",
Message: "conflicting upload for secret-token",
},
},
},
}
client := newClient(cfg, factory.newClient)
_, err := client.Upload(context.Background(), validUploadRequest())
if err == nil {
t.Fatal("Upload() error = nil, want error")
}
var conflict *IdempotencyConflictError
if !errors.As(err, &conflict) {
t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err)
}
if !strings.Contains(err.Error(), "idempotency conflict") {
t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error())
}
if strings.Contains(err.Error(), "secret-token") {
t.Fatalf("error = %q, want no token value", err.Error())
}
}
func validUploadRequest() UploadRequest {
return UploadRequest{
PipelineID: "weatherreporter.daily",
BundleID: "weatherreporter.home.daily.run",
IdempotencyKey: "weatherreporter.home.daily.run",
Files: []UploadFile{
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/report.md"},
{SourcePath: "/tmp/report.md", BundlePath: "2026-06-07/daily/latest.md"},
},
CreatedAt: time.Date(2026, 6, 7, 12, 0, 0, 123, time.UTC),
}
}
type fakeUploadFactory struct {
endpoint string
token string
timeout time.Duration
client *fakeUploadClient
err error
}
func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) {
f.endpoint = endpoint
f.token = token
f.timeout = timeout
if f.err != nil {
return nil, f.err
}
return f.client, nil
}
type fakeUploadClient struct {
opts uploadFilesOptions
statusRunID string
statusCalls int
result uploadFilesResult
status runStatus
statuses []runStatus
err error
statusErr error
}
func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) {
c.opts = opts
if c.err != nil {
return uploadFilesResult{}, c.err
}
return c.result, nil
}
func (c *fakeUploadClient) Status(ctx context.Context, runID string) (runStatus, error) {
c.statusRunID = runID
c.statusCalls++
if c.statusErr != nil {
return runStatus{}, c.statusErr
}
if len(c.statuses) > 0 {
index := c.statusCalls - 1
if index >= len(c.statuses) {
index = len(c.statuses) - 1
}
return c.statuses[index], nil
}
return c.status, nil
}

View File

@@ -0,0 +1,319 @@
// 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/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.
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,
}
}
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.Errors,
)
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: []byte(value.RawOutput),
}
if captureDebug {
execution.Debug = &promptexec.ExecutionDebug{
RawOutput: append([]byte(nil), value.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"`
ExtraParams map[string]any `json:"extra_params"`
}{
Temperature: value.Temperature,
MaxTokens: value.MaxTokens,
TopP: value.TopP,
TimeoutSeconds: value.TimeoutSeconds,
ServiceTier: value.ServiceTier,
ReasoningEffort: value.ReasoningEffort,
ExtraParams: value.ExtraParams,
}
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)
}
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,548 @@
package promptkitadapter
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type fakeClient struct {
mu sync.Mutex
response *promptkit.GenerateResponse
err error
calls int
requests []promptkit.GenerateRequest
block bool
}
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
response := client.response
err := client.err
client.mu.Unlock()
if block {
<-ctx.Done()
return nil, ctx.Err()
}
return response, err
}
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 TestInspectPromptAndProfile(t *testing.T) {
adapter := newTestAdapter(t, &fakeClient{})
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.0.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.0.0" || inspection.DefaultProfileID != "weather-balanced" {
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, `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")
}
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 TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, `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, `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 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.0.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 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 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 TestLocalBackendAndMissingCredentialBehavior(t *testing.T) {
profiles := testProfileDirectory(t, `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, `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 result != nil || promptexec.CategoryOf(err) != promptexec.MissingCredential || client.callCount() != 0 {
t.Fatalf("credential result/category/calls = %#v/%q/%d", result, promptexec.CategoryOf(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, `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, profile string) string {
t.Helper()
profiles := t.TempDir()
if err := os.WriteFile(filepath.Join(profiles, "profile.yml"), []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return profiles
}
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.0.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 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,248 +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 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"`
}
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
}
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
}
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,163 +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.json",
})
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.json",
"--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.json",
})
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.json",
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.json",
"--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.json",
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())
}
}
type fakeCommands struct {
name string
args []string
timeout time.Duration
result CommandResult
err error
}
func (f *fakeCommands) Run(_ context.Context, name string, args []string, timeout time.Duration) (CommandResult, error) {
f.name = name
f.args = append([]string{}, args...)
f.timeout = timeout
return f.result, f.err
}

View File

@@ -1,4 +1,4 @@
// Package weatherapi adapts the internal weather API to forecast bundles.
// Package weatherapi adapts the internal weather API to weather data bundles.
package weatherapi
import (
@@ -18,7 +18,18 @@ import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const (
convectiveOutlooksEndpoint = "/outlooks/convective"
sourceSPCConvectiveOutlooks = "spc_convective_outlooks"
defaultWarmupEndpoint = "/conditions/current"
defaultWarmupAttempts = 3
defaultWarmupDelay = time.Second
defaultFetchAttempts = 2
defaultFetchRetryDelay = time.Second
)
type Client struct {
@@ -30,6 +41,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)
@@ -75,7 +92,12 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
Default: cfg.MissingSource.Default,
Sources: cfg.MissingSource.Sources,
},
now: time.Now,
now: time.Now,
warmupEndpoint: defaultWarmupEndpoint,
warmupAttempts: defaultWarmupAttempts,
warmupDelay: defaultWarmupDelay,
fetchAttempts: defaultFetchAttempts,
fetchRetryDelay: defaultFetchRetryDelay,
}
for _, opt := range opts {
opt(client)
@@ -83,11 +105,15 @@ func New(cfg config.Config, opts ...Option) (*Client, error) {
return client, nil
}
func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
func (c *Client) FetchBundle(ctx context.Context) (*weatherdata.Bundle, error) {
if err := c.warmup(ctx); err != nil {
return nil, err
}
fetchedAt := c.now()
builder := bundleBuilder{
client: c,
bundle: &forecast.Bundle{FetchedAt: fetchedAt},
bundle: &weatherdata.Bundle{FetchedAt: fetchedAt},
fetchedAt: fetchedAt,
}
@@ -109,10 +135,10 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
if err := builder.fetchDiscussion(ctx); err != nil {
return nil, err
}
if err := builder.addStub("daily", "daily forecast data is not available from the weather API yet"); err != nil {
if err := builder.fetchWeatherStory(ctx); err != nil {
return nil, err
}
if err := builder.addStub("weather_story", "NWS weather story is not available from the weather API yet"); err != nil {
if err := builder.fetchSPCConvectiveOutlooks(ctx); err != nil {
return nil, err
}
@@ -121,22 +147,36 @@ func (c *Client) FetchBundle(ctx context.Context) (*forecast.Bundle, error) {
type bundleBuilder struct {
client *Client
bundle *forecast.Bundle
bundle *weatherdata.Bundle
fetchedAt time.Time
}
type sourceRequest struct {
name string
endpoint string
query queryOptions
missingMessage string
required bool
decodeLabel string
}
type fetchedSource struct {
raw json.RawMessage
source weatherdata.Source
}
func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "observations", "/observations", queryOptions{precision: true})
if err != nil {
var observation weatherdata.Observation
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "observations",
endpoint: "/observations",
query: queryOptions{precision: true},
missingMessage: "observation data is missing",
}, &observation)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "observation data is missing", false)
}
var observation forecast.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)
@@ -144,34 +184,36 @@ func (b *bundleBuilder) fetchObservation(ctx context.Context) error {
}
func (b *bundleBuilder) fetchCurrent(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "current", "/conditions/current", queryOptions{precision: true})
if err != nil {
var current weatherdata.Current
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "current",
endpoint: "/conditions/current",
query: queryOptions{precision: true},
missingMessage: "current conditions data is missing",
}, &current)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "current conditions data is missing", false)
}
var current forecast.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 {
var hourly weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "hourly",
endpoint: "/forecast/hourly",
query: queryOptions{precision: true, timezone: true},
missingMessage: "hourly forecast data is missing",
required: true,
decodeLabel: "hourly forecast",
}, &hourly)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "hourly forecast data is missing", true)
}
var hourly forecast.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)
}
@@ -183,17 +225,17 @@ func (b *bundleBuilder) fetchHourly(ctx context.Context) error {
}
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 {
var narrative weatherdata.ForecastRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "narrative",
endpoint: "/forecast/narrative",
query: queryOptions{precision: true, timezone: true},
missingMessage: "narrative forecast data is missing",
}, &narrative)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "narrative forecast data is missing", false)
}
var narrative forecast.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
@@ -210,13 +252,13 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
return b.handleMissing(&source, "active alerts data is missing", false)
}
if isJSONNull(raw) {
b.bundle.Alerts = &forecast.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.bundle.Alerts = &weatherdata.AlertRun{Raw: append(json.RawMessage(nil), raw...)}
b.addSource(source)
return nil
}
var alerts forecast.AlertRun
var alerts weatherdata.AlertRun
if err := decodeSource(raw, &alerts); err != nil {
return b.handleMalformed(&source, err, false)
return b.handleMalformed(&source, err, sourceRequest{name: "alerts"})
}
alerts.Raw = append(json.RawMessage(nil), raw...)
if alerts.AsOf != nil {
@@ -228,17 +270,17 @@ func (b *bundleBuilder) fetchAlerts(ctx context.Context) error {
}
func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
raw, source, err := b.client.fetch(ctx, "discussion", "/discussion", queryOptions{timezone: true})
if err != nil {
var discussion weatherdata.Discussion
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "discussion",
endpoint: "/discussion",
query: queryOptions{timezone: true},
missingMessage: "forecast discussion data is missing",
}, &discussion)
if err != nil || !ok {
return err
}
if raw == nil {
return b.handleMissing(&source, "forecast discussion data is missing", false)
}
var discussion forecast.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
@@ -246,16 +288,73 @@ func (b *bundleBuilder) fetchDiscussion(ctx context.Context) error {
return nil
}
func (b *bundleBuilder) addStub(sourceName string, message string) error {
source := forecast.Source{
Name: sourceName,
FetchedAt: b.fetchedAt,
Missing: true,
func (b *bundleBuilder) fetchWeatherStory(ctx context.Context) error {
var story weatherdata.WeatherStory
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: "weather_story",
endpoint: "/weatherstories/latest",
query: queryOptions{omitUnits: true},
missingMessage: "NWS weather story data is missing",
}, &story)
if err != nil || !ok {
return err
}
return b.applyMissingPolicy(&source, "missing_source", message)
source := fetched.source
if !story.StartTime.IsZero() {
source.IssuedAt = &story.StartTime
}
source.UpdatedAt = story.UpdatedAt
b.bundle.WeatherStory = &story
b.addSource(source)
return nil
}
func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, required bool) error {
func (b *bundleBuilder) fetchSPCConvectiveOutlooks(ctx context.Context) error {
var run weatherdata.ConvectiveOutlookRun
fetched, ok, err := b.fetchDecodedSource(ctx, sourceRequest{
name: sourceSPCConvectiveOutlooks,
endpoint: convectiveOutlooksEndpoint,
query: queryOptions{timezone: true, omitUnits: true},
missingMessage: "SPC convective outlook data is missing",
}, &run)
if err != nil || !ok {
return err
}
source := fetched.source
if run.IssuedAt != nil {
source.IssuedAt = run.IssuedAt
} else {
source.IssuedAt = run.AsOf
}
source.UpdatedAt = run.UpdatedAt
b.bundle.SPCConvectiveOutlooks = &run
b.addSource(source)
return nil
}
func (b *bundleBuilder) fetchDecodedSource(ctx context.Context, request sourceRequest, target any) (fetchedSource, bool, error) {
fetched, ok, err := b.fetchSource(ctx, request)
if err != nil || !ok {
return fetchedSource{}, false, err
}
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(ctx context.Context, request sourceRequest) (fetchedSource, bool, error) {
raw, source, err := b.client.fetch(ctx, request.name, request.endpoint, request.query)
if err != nil {
return fetchedSource{}, false, err
}
if raw == nil {
return fetchedSource{}, false, b.handleMissing(&source, request.missingMessage, request.required)
}
return fetchedSource{raw: raw, source: source}, true, nil
}
func (b *bundleBuilder) handleMissing(source *weatherdata.Source, message string, required bool) error {
source.Missing = true
if required {
return fmt.Errorf("%s from %s is required", message, source.Endpoint)
@@ -263,21 +362,25 @@ func (b *bundleBuilder) handleMissing(source *forecast.Source, message string, r
return b.applyMissingPolicy(source, "missing_source", message)
}
func (b *bundleBuilder) handleMalformed(source *forecast.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))
}
func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string, message string) error {
func (b *bundleBuilder) applyMissingPolicy(source *weatherdata.Source, code string, message string) error {
policy := b.client.policyFor(source.Name)
if policy == config.MissingSourceError {
return fmt.Errorf("%s: %s", source.Name, message)
}
if policy == config.MissingSourceWarn {
warning := forecast.SourceWarning{
warning := weatherdata.SourceWarning{
Source: source.Name,
Code: code,
Severity: "warning",
@@ -292,7 +395,7 @@ func (b *bundleBuilder) applyMissingPolicy(source *forecast.Source, code string,
return nil
}
func (b *bundleBuilder) addSource(source forecast.Source) {
func (b *bundleBuilder) addSource(source weatherdata.Source) {
b.bundle.Sources = append(b.bundle.Sources, source)
}
@@ -307,39 +410,25 @@ type queryOptions struct {
precision bool
timezone bool
allowNull bool
omitUnits bool
}
type envelope struct {
Data json.RawMessage `json:"data"`
}
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, forecast.Source, error) {
reqURL := c.endpointURL(endpoint, opts)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string, opts queryOptions) (json.RawMessage, weatherdata.Source, error) {
reqURL, body, err := c.fetchHTTP(ctx, endpoint, opts)
if err != nil {
return nil, forecast.Source{}, fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, forecast.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, forecast.Source{}, fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, forecast.Source{}, fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
return nil, weatherdata.Source{}, err
}
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
return nil, forecast.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
return nil, weatherdata.Source{}, fmt.Errorf("decode %s envelope: %w", endpoint, err)
}
source := forecast.Source{
source := weatherdata.Source{
Name: sourceName,
Endpoint: endpoint,
Query: queryMap(reqURL.Query()),
@@ -357,6 +446,169 @@ func (c *Client) fetch(ctx context.Context, sourceName string, endpoint string,
return env.Data, source, nil
}
func (c *Client) warmup(ctx context.Context) error {
endpoint := c.warmupEndpoint
if strings.TrimSpace(endpoint) == "" {
endpoint = defaultWarmupEndpoint
}
attempts := positiveAttemptCount(c.warmupAttempts)
var lastErr error
for attempt := 1; attempt <= attempts; attempt++ {
if err := ctx.Err(); err != nil {
return fmt.Errorf("warm up weather API via %s: %w", endpoint, err)
}
if err := c.warmupOnce(ctx, endpoint); err != nil {
lastErr = err
} else {
return nil
}
if attempt == attempts {
break
}
if err := waitForRetry(ctx, c.warmupDelay); err != nil {
return fmt.Errorf("warm up weather API via %s after %d attempt(s): %w", endpoint, attempt, err)
}
}
return 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) error {
reqURL := c.endpointURL(endpoint, queryOptions{precision: true})
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return fmt.Errorf("create request for %s: %w", endpoint, err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return 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 fmt.Errorf("read %s response: %w", endpoint, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
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 := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
if err != nil {
err = fmt.Errorf("read %s response: %w", endpoint, err)
if ctx.Err() != nil {
return reqURL, nil, err
}
return reqURL, nil, retryableRequestError{err: err}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err := fmt.Errorf("fetch %s: unexpected HTTP status %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
if isRetryableHTTPStatus(resp.StatusCode) {
return reqURL, nil, retryableRequestError{err: err}
}
return reqURL, nil, err
}
return reqURL, body, nil
}
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"))
}
@@ -366,7 +618,9 @@ func (c *Client) endpointURL(endpoint string, opts queryOptions) *url.URL {
reqURL.Path = path.Join(c.baseURL.Path, endpoint)
query := reqURL.Query()
query.Set("format", c.format)
query.Set("units", c.units)
if !opts.omitUnits {
query.Set("units", c.units)
}
if opts.precision {
query.Set("precision", strconv.Itoa(c.precision))
}
@@ -406,7 +660,7 @@ func sourceHash(raw json.RawMessage) (string, error) {
return hex.EncodeToString(sum[:]), nil
}
func SaveBundle(path string, bundle *forecast.Bundle) error {
func SaveBundle(path string, bundle *weatherdata.Bundle) error {
if err := fileutil.WriteJSONAtomic(path, bundle); err != nil {
return fmt.Errorf("save bundle: %w", err)
}

View File

@@ -12,7 +12,7 @@ import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestFetchBundleFromFixtures(t *testing.T) {
@@ -49,11 +49,47 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if bundle.Discussion.LongTerm == nil || bundle.Discussion.LongTerm.Text != "Warmer temperatures and periodic rain chances continue into the weekend." {
t.Fatalf("Discussion.LongTerm = %#v, want long-term AFD text", bundle.Discussion.LongTerm)
}
if bundle.WeatherStory == nil || bundle.WeatherStory.Title != "Several Chances for Rain Through Monday" {
t.Fatalf("WeatherStory = %#v, want latest weather story", bundle.WeatherStory)
}
if bundle.WeatherStory.UpdatedAt == nil {
t.Fatalf("WeatherStory.UpdatedAt = nil, want update timestamp")
}
if bundle.SPCConvectiveOutlooks == nil || len(bundle.SPCConvectiveOutlooks.Outlooks) != 1 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want one outlook", bundle.SPCConvectiveOutlooks)
}
if len(bundle.SPCConvectiveOutlooks.Outlooks[0].Geometry) == 0 {
t.Fatalf("SPCConvectiveOutlooks.Outlooks[0].Geometry is empty, want GeoJSON")
}
if len(bundle.SPCConvectiveOutlooks.Discussions) != 1 || bundle.SPCConvectiveOutlooks.Discussions[0].Headline != "Severe storms possible" {
t.Fatalf("SPCConvectiveOutlooks.Discussions = %#v, want one discussion", bundle.SPCConvectiveOutlooks.Discussions)
}
if len(bundle.Sources) != 8 {
t.Fatalf("Sources length = %d, want 8", len(bundle.Sources))
}
if len(bundle.Warnings) != 2 {
t.Fatalf("Warnings length = %d, want daily and weather story warnings", len(bundle.Warnings))
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)+1 {
t.Fatalf("requested paths = %v, want warmup plus %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 !containsPath(requested, "/forecast/hourly") || containsPath(requested, "/forecast/hourly/today") {
t.Fatalf("requested paths = %v, want full hourly endpoint only", requested)
@@ -61,6 +97,12 @@ func TestFetchBundleFromFixtures(t *testing.T) {
if !containsPath(requested, "/forecast/narrative") || containsPath(requested, "/forecast/narrative/today") {
t.Fatalf("requested paths = %v, want full narrative endpoint only", requested)
}
if !containsPath(requested, "/weatherstories/latest") {
t.Fatalf("requested paths = %v, want weather story endpoint", requested)
}
if !containsPath(requested, convectiveOutlooksEndpoint) {
t.Fatalf("requested paths = %v, want convective outlook endpoint", requested)
}
}
func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
@@ -74,13 +116,34 @@ func TestFetchBundleBuildsExpectedQueries(t *testing.T) {
}
for _, rawURL := range requested {
if !strings.Contains(rawURL, "format=json") || !strings.Contains(rawURL, "units=us") {
t.Fatalf("request %q missing format=json or units=us", rawURL)
if !strings.Contains(rawURL, "format=json") {
t.Fatalf("request %q missing format=json", rawURL)
}
if strings.HasPrefix(rawURL, "/weatherstories/") {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || strings.Contains(rawURL, "tz=") {
t.Fatalf("weather story request %q should use format only", rawURL)
}
continue
}
if strings.HasPrefix(rawURL, convectiveOutlooksEndpoint) {
if strings.Contains(rawURL, "units=") || strings.Contains(rawURL, "precision=") || !strings.Contains(rawURL, "tz=America%2FChicago") {
t.Fatalf("convective outlook request %q should use format and tz only", rawURL)
}
continue
}
if !strings.Contains(rawURL, "units=us") {
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)
}
}
}
}
@@ -99,11 +162,40 @@ func TestFetchBundleRecordsSourceHash(t *testing.T) {
if observation.DataSHA256 != want {
t.Fatalf("DataSHA256 = %q, want %q", observation.DataSHA256, want)
}
story := sourceByName(t, bundle.Sources, "weather_story")
if story.Endpoint != "/weatherstories/latest" {
t.Fatalf("weather story endpoint = %q, want /weatherstories/latest", story.Endpoint)
}
if story.DataSHA256 != hashFixtureData(t, "weather_story.json") {
t.Fatalf("weather story DataSHA256 = %q, want fixture hash", story.DataSHA256)
}
if story.IssuedAt == nil || story.UpdatedAt == nil {
t.Fatalf("weather story source timestamps = issued %#v updated %#v, want both", story.IssuedAt, story.UpdatedAt)
}
outlooks := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if outlooks.Endpoint != convectiveOutlooksEndpoint {
t.Fatalf("convective outlook endpoint = %q, want %s", outlooks.Endpoint, convectiveOutlooksEndpoint)
}
if outlooks.Query["format"] != "json" || outlooks.Query["tz"] != "America/Chicago" || outlooks.Query["units"] != "" || outlooks.Query["precision"] != "" {
t.Fatalf("convective outlook query = %#v, want format and tz only", outlooks.Query)
}
if outlooks.DataSHA256 != hashFixtureData(t, "convective_outlooks.json") {
t.Fatalf("convective outlook DataSHA256 = %q, want fixture hash", outlooks.DataSHA256)
}
if outlooks.Missing {
t.Fatal("convective outlook source Missing = true, want false")
}
if outlooks.IssuedAt == nil || outlooks.IssuedAt.Format(time.RFC3339) != "2026-05-29T15:45:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want run issuedAt", outlooks.IssuedAt)
}
if outlooks.UpdatedAt == nil || outlooks.UpdatedAt.Format(time.RFC3339) != "2026-05-29T16:05:00Z" {
t.Fatalf("convective outlook UpdatedAt = %#v, want run updatedAt", outlooks.UpdatedAt)
}
}
func TestHTTPErrorIsActionable(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/conditions/current": {status: http.StatusBadGateway, body: `upstream failed`},
"/forecast/hourly": {status: http.StatusBadGateway, body: `upstream failed`},
}, nil)
client := newTestClient(t, server.URL+"/", nil)
@@ -111,15 +203,140 @@ 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())
}
}
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 != 3 {
t.Fatalf("conditions/current calls = %d, want failed warmup, successful warmup, and current source fetch", 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 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 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())
@@ -129,6 +346,9 @@ 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 TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
@@ -161,6 +381,59 @@ func TestNullAlertsMeansNoActiveAlerts(t *testing.T) {
}
}
func TestMissingSPCConvectiveOutlooksUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks != nil {
t.Fatalf("SPCConvectiveOutlooks = %#v, want nil for missing source", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("convective outlook source = %#v, want missing source warning", source)
}
}
func TestEmptySPCConvectiveOutlooksAreCheckedData(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
convectiveOutlooksEndpoint: {status: http.StatusOK, body: `{"data":{"asOf":"2026-05-29T16:00:00Z","outlooks":[],"discussions":[]}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
sourceSPCConvectiveOutlooks: config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.SPCConvectiveOutlooks == nil {
t.Fatal("SPCConvectiveOutlooks = nil, want checked empty run")
}
if len(bundle.SPCConvectiveOutlooks.Outlooks) != 0 || len(bundle.SPCConvectiveOutlooks.Discussions) != 0 {
t.Fatalf("SPCConvectiveOutlooks = %#v, want empty arrays", bundle.SPCConvectiveOutlooks)
}
source := sourceByName(t, bundle.Sources, sourceSPCConvectiveOutlooks)
if source.Missing || len(source.Warnings) != 0 {
t.Fatalf("convective outlook source = %#v, want non-missing source without warnings", source)
}
if source.IssuedAt == nil || source.IssuedAt.Format(time.RFC3339) != "2026-05-29T16:00:00Z" {
t.Fatalf("convective outlook IssuedAt = %#v, want fallback to asOf", source.IssuedAt)
}
for _, warning := range bundle.Warnings {
if warning.Source == sourceSPCConvectiveOutlooks {
t.Fatalf("warnings = %#v, want no convective outlook warning", bundle.Warnings)
}
}
}
func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
tests := []struct {
name string
@@ -169,7 +442,7 @@ func TestMissingSourcePolicyWarnNoneError(t *testing.T) {
wantWarns int
wantSource bool
}{
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 3, wantSource: true},
{name: "warn", policy: config.MissingSourceWarn, wantWarns: 1, wantSource: true},
{name: "none", policy: config.MissingSourceNone, wantWarns: 0, wantSource: true},
{name: "error", policy: config.MissingSourceError, wantErr: true},
}
@@ -230,6 +503,45 @@ func TestMalformedNonRequiredSourceUsesPolicy(t *testing.T) {
}
}
func TestMissingWeatherStoryUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": null}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
if bundle.WeatherStory != nil {
t.Fatalf("WeatherStory = %#v, want nil for missing source", bundle.WeatherStory)
}
source := sourceByName(t, bundle.Sources, "weather_story")
if !source.Missing || len(source.Warnings) != 1 {
t.Fatalf("weather_story source = %#v, want missing source warning", source)
}
}
func TestMalformedWeatherStoryUsesPolicy(t *testing.T) {
server := fixtureServer(t, map[string]handlerOverride{
"/weatherstories/latest": {status: http.StatusOK, body: `{"data": {"startTime": 123}}`},
}, nil)
client := newTestClient(t, server.URL+"/", map[string]config.MissingSourcePolicy{
"weather_story": config.MissingSourceWarn,
})
bundle, err := client.FetchBundle(context.Background())
if err != nil {
t.Fatalf("FetchBundle() error = %v", err)
}
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()
@@ -245,6 +557,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)
@@ -257,12 +606,14 @@ 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())
}
}
@@ -289,25 +640,32 @@ func TestSaveBundle(t *testing.T) {
}
type handlerOverride struct {
status int
body string
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{
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/observations": "observations.json",
"/conditions/current": "current.json",
"/forecast/hourly": "hourly.json",
"/forecast/narrative": "narrative.json",
"/alerts/active": "alerts.json",
"/discussion": "discussion.json",
"/weatherstories/latest": "weather_story.json",
convectiveOutlooksEndpoint: "convective_outlooks.json",
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if requested != nil {
*requested = append(*requested, r.URL.String())
}
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
@@ -333,6 +691,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
}
@@ -355,7 +715,17 @@ func containsPath(requested []string, path string) bool {
return false
}
func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast.Source {
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 {
if source.Name == name {
@@ -363,7 +733,7 @@ func sourceByName(t *testing.T, sources []forecast.Source, name string) forecast
}
}
t.Fatalf("source %q not found in %#v", name, sources)
return forecast.Source{}
return weatherdata.Source{}
}
func hashFixtureData(t *testing.T, fixture string) string {

View File

@@ -0,0 +1,50 @@
{
"data": {
"locationId": "nws-lsx-grid-90-74",
"locationName": "St. Louis, MO",
"asOf": "2026-05-29T16:00:00Z",
"issuedAt": "2026-05-29T15:45:00Z",
"updatedAt": "2026-05-29T16:05:00Z",
"product": "convective_outlook",
"outlooks": [
{
"id": "day1-categorical-slight",
"provider": "spc",
"product": "convective_outlook",
"day": 1,
"outlookType": "categorical",
"label": "SLGT",
"labelText": "Slight Risk",
"forecaster": "Smith",
"severityRank": 3,
"validFrom": "2026-05-29T13:00:00-05:00",
"validTo": "2026-05-30T07:00:00-05:00",
"issuedAt": "2026-05-29T15:45:00Z",
"expiresAt": "2026-05-30T07:00:00-05:00",
"sourceUrl": "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
"imageUrl": "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
"containsLocation": true,
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-91.0, 38.0],
[-90.0, 38.5],
[-89.5, 37.8],
[-91.0, 38.0]
]
]
}
}
],
"discussions": [
{
"day": 1,
"headline": "Severe storms possible",
"summary": "Scattered severe storms are possible.",
"discussion": "A few storms may become severe during the afternoon.",
"updatedAt": "2026-05-29T16:05:00Z"
}
]
}
}

View File

@@ -0,0 +1,14 @@
{
"data": {
"officeId": "LSX",
"startTime": "2026-05-30T08:46:00Z",
"endTime": "2026-05-31T11:00:00Z",
"updatedAt": "2026-05-30T09:00:34Z",
"title": "Several Chances for Rain Through Monday",
"description": "A stagnant weather pattern with low pressure over the Great Plains and high pressure over the Great Lakes will continue to produce scattered showers and thunderstorms, for areas mainly along and west of the Mississippi River today and Sunday.",
"altText": "This slide shows the forecast for today through Tuesday with icons for showers and thunderstorms and a picture of a cumulonimbus cloud on the right side.",
"priority": false,
"order": 1,
"downloadUrl": "https://api.weather.gov/offices/LSX/weatherstories/download/3228e499-2aae-45a8-9ff9-1c060311026f"
}
}

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,142 @@
package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
)
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.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 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 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,293 @@
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)
}
func batchRunID(startedAt time.Time, batch BatchKind) string {
return startedAt.UTC().Format(runIDTimestampLayout) + "_" + string(batch)
}
func notifyBatch(ctx context.Context, cfg config.Config, batch BatchKind, runID string, startedAt time.Time, result *BatchResult, planned []plannedBatchReport, notifier Notifier) *BatchNotificationResult {
if !cfg.Notify.Distributor.Enabled {
return nil
}
if !cfg.Notify.Distributor.Batch.Enabled {
return nil
}
if result == nil {
return failedBatchNotificationResult(batchNotificationRequest{}, fmt.Errorf("batch result is required"))
}
if result.Failed > 0 {
return &BatchNotificationResult{
Status: "skipped",
Reason: "one or more reports failed",
}
}
req, err := buildBatchNotificationRequest(cfg, batch, runID, startedAt, result.Reports, planned)
if err != nil {
return failedBatchNotificationResult(batchNotificationRequest{}, err)
}
batchNotifier, err := resolveBatchNotifier(cfg, notifier)
if err != nil {
return failedBatchNotificationResult(req, err)
}
notification, notifyErr := batchNotifier.NotifyBatch(ctx, req)
wrappedErr := notifyErr
if notifyErr != nil {
wrappedErr = fmt.Errorf("notify batch %q run %q bundle %q: %w", batch, runID, req.BundleID, notifyErr)
}
batchResult := batchNotificationResult(req, notification)
if wrappedErr != nil {
batchResult.Status = "failed"
batchResult.Error = wrappedErr.Error()
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 = 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 = err.Error()
}
return notification
}
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
}

View File

@@ -0,0 +1,288 @@
package app
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type generationCollector struct {
bundle *weatherdata.Bundle
err error
called bool
beforeRun func()
}
func (c *generationCollector) Run(context.Context, collect.Request) (*collect.Result, error) {
if c.beforeRun != nil {
c.beforeRun()
}
c.called = true
return &collect.Result{Bundle: c.bundle}, c.err
}
type generationExecutor struct {
called bool
promptInspections int
inspectErr error
executeErr error
cancelBeforeReturn context.CancelFunc
validation promptexec.ValidationStatus
rawOutput []byte
failedPrompt string
}
func (e *generationExecutor) InspectPrompt(_ context.Context, id, version string) (promptexec.PromptInspection, error) {
e.promptInspections++
if e.inspectErr != nil {
return promptexec.PromptInspection{}, e.inspectErr
}
definition := generationDefinitionForPrompt(id)
return promptexec.PromptInspection{PromptID: id, PromptVersion: version, PromptHash: "prompt-hash", 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"}}, nil
}
func (*generationExecutor) InspectProfile(_ context.Context, id string) (promptexec.ProfileInspection, error) {
return promptexec.ProfileInspection{ProfileID: id, BackendID: "fixture", ModelName: "fixture-model"}, nil
}
func (e *generationExecutor) Execute(_ context.Context, req promptexec.ExecuteRequest, callback promptexec.PreparationCallback) (*promptexec.Execution, error) {
stamp := time.Date(2026, 5, 29, 15, 0, 0, 0, time.UTC)
if err := callback(promptexec.Preparation{PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", RenderedPromptHash: "rendered-hash", ProfileID: req.ProfileID, BackendID: "fixture", ModelName: "fixture-model", StartedAt: stamp, EndedAt: stamp}, nil); err != nil {
return nil, err
}
e.called = true
if e.executeErr != nil {
return nil, e.executeErr
}
status := e.validation
if status == "" {
status = promptexec.ValidationPassed
}
if e.failedPrompt == req.PromptID {
status = promptexec.ValidationFailed
}
rawOutput := e.rawOutput
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 e.cancelBeforeReturn != nil {
e.cancelBeforeReturn()
}
return &promptexec.Execution{RunID: "provider-run", PromptID: req.PromptID, PromptVersion: req.PromptVersion, PromptHash: "prompt-hash", 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", nil)}, nil
}
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{}
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: &generationCollector{bundle: &bundle}, Executor: executor})
if err != nil {
t.Fatalf("GenerateDetailed() error = %v", err)
}
if !executor.called || 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 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 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)
}
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: &generationExecutor{}})
info, statErr := os.Stat(outputPath)
if err == nil || result == nil || statErr != nil || !info.IsDir() {
t.Fatalf("GenerateDetailed() result/error/output-info = %#v/%v/%#v (%v)", result, err, info, statErr)
}
}
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
request NotificationRequest
batchRequest batchNotificationRequest
batchCalls int
}
func (n *generationNotifier) Notify(_ context.Context, request NotificationRequest) (*NotificationResult, error) {
n.request = request
return nil, n.err
}
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,125 +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/forecast"
"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"
)
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 []forecast.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 InspectBriefing(ctx context.Context, req InspectRunRequest) (briefing.Package, error) {
inspection, err := inspectRun(ctx, req)
if err != nil {
return briefing.Package{}, err
}
return inspection.store.LoadBriefing(ctx, inspection.metadata.BriefingPath)
}
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
}

View File

@@ -0,0 +1,214 @@
package app
import (
"context"
"errors"
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/collect"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptdebug"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type promptReportRequest struct {
GenerateRequest
Resolved report.Resolved
Collection collect.Result
Inspection PromptInspectionResult
DebugWriter *promptdebug.PromptDebugWriter
Result *ReportResult
noNotify bool
}
type promptReportWorkflow struct {
ctx context.Context
req promptReportRequest
result *ReportResult
briefingMetadata briefing.Metadata
reportFacts ReportFacts
moduleSnapshot module.Snapshot
dataPackage []byte
handler generatedtext.Handler
debugRef promptdebug.PromptDebugRef
callbackFailed bool
}
func generatePromptReport(ctx context.Context, req promptReportRequest) (*ReportResult, error) {
workflow, err := newPromptReportWorkflow(ctx, req)
if err != nil {
return nil, err
}
if err := workflow.buildInputs(); err != nil {
return workflow.result, err
}
execution, err := workflow.executePrompt()
if err != nil {
if workflow.callbackFailed {
return workflow.result, err
}
return workflow.result, workflow.reportError("execute prompt", classifiedPromptError("prompt execution failed", err))
}
if execution == nil {
return workflow.result, workflow.reportError("execute prompt", promptexec.NewError(promptexec.Generation, "prompt executor returned no execution", nil))
}
workflow.result.ValidationStatus = execution.Validation.Status
if err := workflow.writeExecutionDebug(*execution); err != nil {
return workflow.result, err
}
if execution.Validation.Status != promptexec.ValidationPassed && execution.Validation.Status != promptexec.ValidationFailed {
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.OperationalValidation, "prompt execution did not complete validation", nil))
}
if execution.Validation.Status == promptexec.ValidationFailed {
return workflow.result, workflow.reportError("validate prompt execution", promptexec.NewError(promptexec.ValidationRejected, "prompt output did not satisfy its schema", nil))
}
return workflow.renderAndPublish(execution.RawOutput)
}
func newPromptReportWorkflow(ctx context.Context, req promptReportRequest) (*promptReportWorkflow, 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)
}
return &promptReportWorkflow{
ctx: ctx, req: req,
result: result,
}, nil
}
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,
}
}
func (w *promptReportWorkflow) buildInputs() error {
var err error
w.reportFacts, err = BuildReportFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.req.Collection.Bundle)
if err != nil {
return w.reportError("build report facts", err)
}
w.moduleSnapshot, err = BuildModuleSnapshotFromFacts(ModuleSnapshotRequest{Config: w.req.Config, Resolved: w.req.Resolved}, w.reportFacts)
if err != nil {
return w.reportError("build module snapshot", err)
}
w.briefingMetadata = briefing.BuildMetadata(briefingBuildContext(w.req.Config, w.req.Resolved, w.reportFacts.Collected))
w.result.SourceWarnings = append([]weatherdata.SourceWarning(nil), w.briefingMetadata.SourceWarnings...)
dataPackage, err := promptinput.Build(promptinput.BuildRequest{Metadata: promptMetadata(w.briefingMetadata), Modules: w.moduleSnapshot})
if err != nil {
return w.reportError("build data package", err)
}
w.dataPackage, err = promptinput.MarshalYAML(dataPackage)
if err != nil {
return w.reportError("marshal data package", err)
}
w.handler, err = generatedtext.LookupDefinition(w.req.Resolved.Definition)
if err != nil {
return w.reportError("lookup generated text catalog", err)
}
w.debugRef = promptdebug.PromptDebugRef{ReportID: w.result.ReportID, ValidDate: w.req.Resolved.ValidPeriod.Start.Format("2006-01-02"), RunID: w.result.RunID}
return nil
}
func (w *promptReportWorkflow) executePrompt() (*promptexec.Execution, error) {
captureDebug := w.req.DebugWriter != nil && w.req.DebugWriter.Enabled()
return w.req.Executor.Execute(w.ctx, promptexec.ExecuteRequest{PromptID: w.req.Inspection.PromptID, PromptVersion: w.req.Inspection.PromptVersion, ProfileID: w.req.Inspection.ProfileID, DataPackage: w.dataPackage, CaptureDebug: captureDebug}, w.writePreparationDebug)
}
func (w *promptReportWorkflow) writePreparationDebug(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
w.result.ProfileID, w.result.BackendID, w.result.ModelName = preparation.ProfileID, preparation.BackendID, preparation.ModelName
if w.req.DebugWriter == nil {
return nil
}
path, err := w.req.DebugWriter.WritePreparation(w.debugRef, preparation, debug)
if err != nil {
w.callbackFailed = true
return promptDebugWriteError(err)
}
w.result.LLMDebugPath = path
return nil
}
func (w *promptReportWorkflow) writeExecutionDebug(execution promptexec.Execution) error {
if w.req.DebugWriter == nil {
return nil
}
path, err := w.req.DebugWriter.WriteExecution(w.debugRef, execution)
if err != nil {
return w.reportError("write prompt debug", promptDebugWriteError(err))
}
if path != "" {
w.result.LLMDebugPath = path
}
return nil
}
func (w *promptReportWorkflow) renderAndPublish(raw []byte) (*ReportResult, error) {
generatedText, _, err := w.handler.Validate(raw)
if err != nil {
return w.result, w.reportError("validate generated text", err)
}
renderContext, err := w.handler.BuildRenderContext(w.briefingMetadata, w.moduleSnapshot, w.reportFacts.Collected, w.reportFacts.Derived, generatedText)
if err != nil {
return w.result, w.reportError("build render context", err)
}
rendered, err := w.handler.Render(renderContext)
if err != nil {
return w.result, w.reportError("render template", err)
}
if err := publicationContextError(w.ctx); err != nil {
return w.result, w.reportError("publish report", err)
}
if err := fileutil.WriteFileAtomic(w.req.OutputPath, rendered); err != nil {
return w.result, err
}
w.result.OutputPath = w.req.OutputPath
if w.req.noNotify {
return w.result, nil
}
notification, err := notifyReport(w.ctx, w.req.Config, w.req.Resolved, w.result.OutputPath, w.result.RunID, w.result.GeneratedAt, w.req.Notifier)
w.result.Notification = notification
if err != nil {
return w.result, err
}
return w.result, nil
}
func (w *promptReportWorkflow) reportError(operation string, err error) error {
return generatedReportError(w.req.Resolved, w.result.RunID, operation, 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,142 @@
package app
import (
"context"
"os"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"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
LookupEnv func(string) (string, bool)
}
// 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
}
// 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
LookupEnv func(string) (string, bool)
}
// 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,
LookupEnv: req.LookupEnv,
})
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
if strings.TrimSpace(definition.PromptID) == "" || strings.TrimSpace(definition.PromptVersion) == "" {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "report prompt identity is incomplete", nil)
}
inspection, err := req.Executor.InspectPrompt(ctx, definition.PromptID, definition.PromptVersion)
if err != nil {
return nil, promptInspectionError("prompt inspection failed", err)
}
if inspection.PromptID != definition.PromptID || inspection.PromptVersion != definition.PromptVersion {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt inspection did not return the requested prompt version", nil)
}
if !validPromptInput(inspection.Inputs) {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare exactly one required application/yaml data_package input", nil)
}
if !validPromptOutput(definition, inspection.Output) {
return nil, promptexec.NewError(promptexec.InvalidConfiguration, "prompt must declare the report JSON Schema output contract", nil)
}
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, req.LookupEnv)
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,
}
}
return results, nil
}
func inspectPromptProfile(ctx context.Context, executor promptexec.Executor, profileID string, lookupEnv func(string) (string, bool)) (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.APIKeyEnv) != "" {
if lookupEnv == nil {
lookupEnv = os.LookupEnv
}
value, present := lookupEnv(profile.APIKeyEnv)
if !present || strings.TrimSpace(value) == "" {
return promptexec.ProfileInspection{}, promptexec.NewError(promptexec.MissingCredential, "selected profile credential is unavailable", 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"
}
func promptInspectionError(operation string, err error) error {
if promptexec.CategoryOf(err) != "" {
return err
}
return promptexec.NewError(promptexec.InvalidConfiguration, operation, err)
}

View File

@@ -0,0 +1,201 @@
package app
import (
"context"
"errors"
"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
lookupEnv func(string) (string, bool)
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: "direct key",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", CredentialRequired: true},
wantCategory: promptexec.MissingCredential,
},
{
name: "missing environment credential",
prompt: basePrompt,
profile: promptexec.ProfileInspection{ProfileID: "default-profile", APIKeyEnv: "PROMPT_API_KEY"},
lookupEnv: func(string) (string, bool) { return "", false },
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, LookupEnv: test.lookupEnv})
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 := first
second.Definition.ID = report.Today
second.Definition.PromptID = "weather.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)
}
}
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
}
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) {
return nil, errors.New("unexpected execution")
}
func inspectionResolved(t *testing.T) report.Resolved {
t.Helper()
resolved, err := report.DefaultRegistry().Resolve(report.Daily, report.ResolveRequest{
Now: time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC),
Date: time.Date(2026, 5, 29, 0, 0, 0, 0, time.UTC),
Location: time.UTC,
})
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"},
}
}
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,73 @@
package app_test
import (
"context"
"os"
"path/filepath"
"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) {
lookupEnv := func(string) (string, bool) { return "test-key", true }
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},
LookupEnv: lookupEnv,
})
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
model: local-weather
`)})
if err != nil {
t.Fatalf("New(override) error = %v", err)
}
inspect(t, override, report.Hourly, "", "weather-light", "", "local-weather")
}
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

@@ -0,0 +1,68 @@
package briefing
import (
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type AlertDigestModule struct {
Checked bool `json:"checked"`
ActiveCount int `json:"active_count"`
RelevantCount int `json:"relevant_count"`
Missing bool `json:"missing,omitempty"`
Relevant []AlertSummary `json:"relevant,omitempty"`
}
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, 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, timezone string) *AlertDigestModule {
missing := sourceMissing(collected.SourceProvenance, "alerts")
if collected.Alerts == nil && !missing {
return nil
}
value := &AlertDigestModule{Missing: missing}
if collected.Alerts != nil {
value.Checked = true
value.ActiveCount = len(collected.Alerts.Alerts)
}
value.RelevantCount = len(overlaps)
for _, overlap := range overlaps {
value.Relevant = append(value.Relevant, AlertSummary{
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
}
func sourceMissing(sources []weatherdata.Source, name string) bool {
for _, source := range sources {
if source.Name == name && source.Missing {
return true
}
}
return false
}

View File

@@ -0,0 +1,67 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type AreaForecastDiscussionModule struct {
Product string `json:"product,omitempty"`
KeyMessages []string `json:"key_messages,omitempty"`
ShortTerm string `json:"short_term,omitempty"`
LongTerm string `json:"long_term,omitempty"`
}
func buildAreaForecastDiscussionModule(ctx ModuleContext, options any) (*module.Output, error) {
discussion := ctx.Collected.Discussion
if discussion == nil {
return nil, nil
}
opts, ok := options.(module.AreaForecastDiscussionOptions)
if !ok {
return nil, fmt.Errorf("area forecast discussion options have type %T", options)
}
sections, err := areaForecastDiscussionSections(opts)
if err != nil {
return nil, err
}
value := AreaForecastDiscussionModule{}
if sections["product"] {
value.Product = discussion.Product
}
if sections["key_messages"] {
value.KeyMessages = append([]string(nil), discussion.KeyMessages...)
}
if sections["short_term"] && discussion.ShortTerm != nil {
value.ShortTerm = discussion.ShortTerm.Text
}
if sections["long_term"] && discussion.LongTerm != nil {
value.LongTerm = discussion.LongTerm.Text
}
if value.Product == "" && len(value.KeyMessages) == 0 && value.ShortTerm == "" && value.LongTerm == "" {
return nil, nil
}
return &module.Output{ID: module.AreaForecastDiscussion, StanzaName: "area_forecast_discussion", Value: value}, nil
}
func areaForecastDiscussionSections(options module.AreaForecastDiscussionOptions) (map[string]bool, error) {
if len(options.Sections) == 0 {
return map[string]bool{
"product": true,
"key_messages": true,
"short_term": true,
"long_term": true,
}, nil
}
sections := map[string]bool{}
for _, section := range options.Sections {
switch section {
case "product", "key_messages", "short_term", "long_term":
sections[section] = true
default:
return nil, fmt.Errorf("area forecast discussion section %q is not supported", section)
}
}
return sections, nil
}

View File

@@ -0,0 +1,72 @@
{
"categorical:TSTM": {
"plain_language": "General or non-severe thunderstorms.",
"official_description": "No severe thunderstorms expected.",
"relative_level": "0 of 5"
},
"categorical:MRGL": {
"plain_language": "Isolated severe storms possible.",
"official_description": "Isolated severe storms may occur within the risk area, but they are expected to be limited in duration, coverage, and intensity.",
"relative_level": "1 of 5"
},
"categorical:SLGT": {
"plain_language": "Scattered severe storms possible.",
"official_description": "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread.",
"relative_level": "2 of 5"
},
"categorical:ENH": {
"plain_language": "Numerous severe storms possible.",
"official_description": "Numerous severe storms are possible within the risk area, some of which may be intense.",
"relative_level": "3 of 5"
},
"categorical:MDT": {
"plain_language": "Widespread severe storms likely.",
"official_description": "Widespread severe storms are likely within the risk area. Storms may be long-lived, widespread, and intense. This risk is usually reserved for days with several supercells producing intense tornadoes and/or very large hail, or an intense squall line with widespread damaging winds.",
"relative_level": "4 of 5"
},
"categorical:HIGH": {
"plain_language": "Major severe outbreak expected.",
"official_description": "A major severe weather outbreak is expected, with long-lived, very widespread, and particularly intense severe storms. This risk is reserved for when high confidence exists in widespread coverage of severe weather with embedded instances of extreme severity (i.e., violent tornadoes or very damaging convective wind events).",
"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

@@ -0,0 +1,735 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestBaseModulesBuildAvailableSourceOutputs(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
tests := []struct {
id module.ID
stanza string
}{
{id: module.Metadata, stanza: "metadata"},
{id: module.CurrentConditions, stanza: "current_conditions"},
{id: module.NarrativeForecast, stanza: "narrative_forecast"},
{id: module.HourlyForecast, stanza: "hourly_forecast"},
{id: module.AlertDigest, stanza: "alert_digest"},
{id: module.AreaForecastDiscussion, stanza: "area_forecast_discussion"},
{id: module.WeatherStory, stanza: "weather_story"},
}
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: tt.id})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil {
t.Fatal("BuildModule() output = nil, want stanza")
}
if output.ID != tt.id || output.StanzaName != tt.stanza {
t.Fatalf("output = %#v, want id %q stanza %q", output, tt.id, tt.stanza)
}
})
}
}
func TestHourlyForecastModuleUsesValidPeriodHourlyPeriods(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)
}
value := moduleValue[HourlyForecastModule](t, output)
if value.Product != "hourly" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("HourlyForecast = %#v, want hourly metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.HourLabel != "8:00 AM" || period.TextDescription != "Showers likely." || period.TextDescriptionLower != "showers likely." || period.TemperatureF == nil || *period.TemperatureF != 76 {
t.Fatalf("HourlyForecast period = %#v, want hourly period facts", period)
}
if period.PeriodBegins != "2026-05-29 at 8:00 AM" || period.PeriodEnds != "2026-05-29 at 9:00 AM" {
t.Fatalf("HourlyForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
}
if period.WindDirection != "S" || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 70 {
t.Fatalf("HourlyForecast period = %#v, want compass wind and precip chance", period)
}
if !period.MentionPrecipitation {
t.Fatalf("MentionPrecipitation = false, want true for default threshold")
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal hourly forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "hour_label", "period_begins", "period_ends", "text_description", "text_description_lower", "temperature_f", "wind_direction", "probability_of_precipitation_percent", "mention_precipitation", "relative_humidity_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("hourly json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") || strings.Contains(jsonText, "Tomorrow") {
t.Fatalf("hourly json = %s, want valid-period prompt fields only", jsonText)
}
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("hourly json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
}
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)
if len(value) != 3 {
t.Fatalf("periods length = %d, want 3", len(value))
}
if value[0].MentionPrecipitation {
t.Fatalf("19%% MentionPrecipitation = true, want false")
}
if !value[1].MentionPrecipitation {
t.Fatalf("20%% MentionPrecipitation = false, want true")
}
if value[2].MentionPrecipitation {
t.Fatalf("nil MentionPrecipitation = true, want false")
}
}
func TestHourlyForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
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 "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestHourlyForecastModuleBuildsForHourly(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.HourlyForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[HourlyForecastModule](t, output)
if len(value.Periods) != 1 || value.Periods[0].TextDescription != "Showers likely." {
t.Fatalf("HourlyForecast = %#v, want hourly report period", value)
}
}
func TestNarrativeForecastModuleUsesValidPeriodNarrativePeriods(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.NarrativeForecast})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[NarrativeForecastModule](t, output)
if value.Product != "narrative" || value.SourceLocationID != "test-grid" || len(value.Periods) != 1 {
t.Fatalf("NarrativeForecast = %#v, want narrative metadata and one valid-period period", value)
}
period := value.Periods[0]
if period.Name != "Today" || period.TextDescription != "Morning storms, then partly sunny." {
t.Fatalf("NarrativeForecast period = %#v, want Today narrative", period)
}
if period.PeriodBegins != "2026-05-29 at 6:00 AM" || period.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("NarrativeForecast period times = %q/%q, want friendly local time labels", period.PeriodBegins, period.PeriodEnds)
}
if period.IsDay == nil || !*period.IsDay || period.TemperatureF == nil || *period.TemperatureF != 81 || period.ProbabilityOfPrecipitationPercent == nil || *period.ProbabilityOfPrecipitationPercent != 60 {
t.Fatalf("NarrativeForecast period = %#v, want day, temperature, and precip values", period)
}
if period.WindDirection != "NE" {
t.Fatalf("NarrativeForecast period wind direction = %q, want NE", period.WindDirection)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal narrative forecast: %v", err)
}
jsonText := string(data)
for _, field := range []string{"source_location_id", "period_begins", "period_ends", "text_description", "temperature_f", "wind_speed_mph", "wind_direction", "probability_of_precipitation_percent"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("narrative json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("narrative json = %s, want compass wind_direction without degrees field", jsonText)
}
if strings.Contains(jsonText, `"start_time"`) || strings.Contains(jsonText, `"end_time"`) {
t.Fatalf("narrative json = %s, want period_begins/period_ends instead of start_time/end_time", jsonText)
}
if strings.Contains(jsonText, "Tomorrow night") {
t.Fatalf("narrative json = %s, want only valid-period narrative periods", jsonText)
}
}
func TestNarrativeForecastModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
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 "unsupported"`) {
t.Fatalf("BuildModule() error = %v, want incompatible report", err)
}
}
func TestMetadataModuleUsesPromptSafeSourceWarningSummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[MetadataModule](t, output)
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" {
t.Fatalf("Location = %#v, want configured location", value.Location)
}
if len(value.SourceWarnings) != 1 || value.SourceWarnings[0].CompletenessImpact != "source omitted" {
t.Fatalf("SourceWarnings = %#v, want warning summary", value.SourceWarnings)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal metadata: %v", err)
}
jsonText := string(data)
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) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.CurrentConditions})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[CurrentConditionsModule](t, output)
if value.ConditionText != "Partly cloudy" || value.ConditionTextLower != "partly cloudy" || value.TemperatureF == nil || *value.TemperatureF != 74 {
t.Fatalf("CurrentConditions = %#v, want rounded current condition facts", value)
}
if value.ApparentTemperatureF == nil || *value.ApparentTemperatureF != 76 || value.RelativeHumidityPercent == nil || *value.RelativeHumidityPercent != 71 || value.WindSpeedMph == nil || *value.WindSpeedMph != 8 {
t.Fatalf("CurrentConditions rounded values = %#v, want apparent 76, humidity 71, wind 8", value)
}
if value.WindDirection != "S" {
t.Fatalf("WindDirection = %q, want S", value.WindDirection)
}
if value.WindDirectionText != "south" {
t.Fatalf("WindDirectionText = %q, want south", value.WindDirectionText)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal current conditions: %v", err)
}
jsonText := string(data)
for _, field := range []string{"condition_text", "condition_text_lower", "temperature_f", "apparent_temperature_f", "relative_humidity_percent", "wind_speed_mph", "wind_direction", "wind_direction_text"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("current json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "wind_direction_degrees") {
t.Fatalf("current json = %s, want compass wind_direction without degrees field", jsonText)
}
}
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()
ctx.Collected.Alerts = &weatherdata.AlertRun{}
ctx.Derived.AlertOverlaps = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(checked empty) error = %v", err)
}
checked := moduleValue[AlertDigestModule](t, output)
if !checked.Checked || checked.ActiveCount != 0 || checked.RelevantCount != 0 || checked.Missing {
t.Fatalf("checked empty alert digest = %#v, want checked/no active", checked)
}
ctx.Collected.Alerts = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{Name: "alerts", Missing: true}}
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.AlertDigest})
if err != nil {
t.Fatalf("BuildModule(missing) error = %v", err)
}
missing := moduleValue[AlertDigestModule](t, output)
if missing.Checked || !missing.Missing {
t.Fatalf("missing alert digest = %#v, want missing unchecked source", missing)
}
}
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()
ctx.Collected.Current = nil
ctx.Collected.Narrative = nil
ctx.Collected.Hourly = nil
ctx.Derived.ValidPeriodNarrativePeriods = nil
ctx.Derived.ValidPeriodHourlyPeriods = nil
ctx.Collected.Discussion = nil
ctx.Collected.WeatherStory = nil
for _, id := range []module.ID{module.CurrentConditions, module.NarrativeForecast, module.HourlyForecast, module.AreaForecastDiscussion, module.WeatherStory} {
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: id})
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", id, err)
}
if output != nil {
t.Fatalf("BuildModule(%s) output = %#v, want omitted", id, output)
}
}
}
func TestAreaForecastDiscussionAndWeatherStoryModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
afdOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.AreaForecastDiscussion})
if err != nil {
t.Fatalf("BuildModule(afd) error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, afdOutput)
if len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." || afd.LongTerm != "Periodic rain chances continue." {
t.Fatalf("AFD = %#v, want discussion sections", afd)
}
storyOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.WeatherStory})
if err != nil {
t.Fatalf("BuildModule(weather story) error = %v", err)
}
story := moduleValue[WeatherStoryModule](t, storyOutput)
if !story.Available || story.Title != "Rain Chances" || story.Description != "Scattered showers are possible." {
t.Fatalf("WeatherStory = %#v, want structured story fields", story)
}
if story.PeriodBegins != "2026-05-29 at 6:00 AM" || story.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("WeatherStory period = %q/%q, want friendly local period labels", story.PeriodBegins, story.PeriodEnds)
}
data, err := json.Marshal(storyOutput.Value)
if err != nil {
t.Fatalf("Marshal weather story: %v", err)
}
if !strings.Contains(string(data), "download_url") || !strings.Contains(string(data), "period_begins") || !strings.Contains(string(data), "period_ends") {
t.Fatalf("weather story json = %s, want snake_case download_url", string(data))
}
if strings.Contains(string(data), "start_time") || strings.Contains(string(data), "end_time") {
t.Fatalf("weather story json = %s, want period_begins/period_ends instead of start_time/end_time", string(data))
}
}
func TestAreaForecastDiscussionModuleCanSelectSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{
ID: module.AreaForecastDiscussion,
Options: module.AreaForecastDiscussionOptions{Sections: []string{"short_term"}},
})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
afd := moduleValue[AreaForecastDiscussionModule](t, output)
if afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("ShortTerm = %q, want selected short term section", afd.ShortTerm)
}
if afd.Product != "" || len(afd.KeyMessages) != 0 || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want only short_term section", afd)
}
}
func TestAreaForecastDiscussionModuleUsesHourlyDefaultSections(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Resolved.Definition = report.DefaultRegistry().MustLookup(report.Hourly)
var item module.ConfigItem
for _, candidate := range ctx.Resolved.Definition.Modules {
if candidate.ID == module.AreaForecastDiscussion {
item = candidate
break
}
}
if item.ID == "" {
t.Fatal("hourly 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 len(afd.KeyMessages) != 1 || afd.ShortTerm != "Showers increase this afternoon." {
t.Fatalf("AFD = %#v, want key messages and short term", afd)
}
if afd.Product != "" || afd.LongTerm != "" {
t.Fatalf("AFD = %#v, want product and long term omitted", afd)
}
}
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.Daily)
resolved := report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: timeutil.Period{
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
},
}
isDay := true
tempF := 74.4
apparentF := 75.6
humidity := 70.6
windMph := 8.4
windDirection := 190.0
narrativeTempF := 81.0
narrativePop := 60.0
narrativeWind := 12.0
narrativeWindDirection := 45.0
hourlyTempF := 76.0
hourlyPop := 70.0
hourlyHumidity := 66.0
hourlyWindMph := 14.0
updatedAt := mustParseModuleTime("2026-05-29T07:30:00-05:00")
return ModuleContext{
Resolved: resolved,
Collected: facts.CollectedFacts{
Current: &weatherdata.Current{
ConditionText: "Partly cloudy",
IsDay: &isDay,
TemperatureF: &tempF,
ApparentTemperatureF: &apparentF,
RelativeHumidityPercent: &humidity,
WindSpeedMph: &windMph,
WindDirectionDegrees: &windDirection,
},
Narrative: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "narrative",
Periods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
},
Hourly: &weatherdata.ForecastRun{
LocationID: "test-grid",
LocationName: "Testville",
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
UpdatedAt: &updatedAt,
Product: "hourly",
Periods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
{
StartTime: mustParseModuleTime("2026-05-30T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T09:00:00-05:00"),
TextDescription: "Tomorrow showers.",
},
},
},
Alerts: &weatherdata.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Flood Watch","headline":"Flooding possible","severity":"Moderate"}`),
}},
Discussion: &weatherdata.Discussion{
Product: "discussion",
KeyMessages: []string{"Scattered showers are possible."},
ShortTerm: &weatherdata.DiscussionSection{Text: "Showers increase this afternoon."},
LongTerm: &weatherdata.DiscussionSection{Text: "Periodic rain chances continue."},
},
WeatherStory: &weatherdata.WeatherStory{
OfficeID: "LSX",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
UpdatedAt: &updatedAt,
Title: "Rain Chances",
Description: "Scattered showers are possible.",
AltText: "Weather story graphic with rain chances.",
Priority: true,
Order: 1,
DownloadURL: "https://example.invalid/story.png",
},
SourceProvenance: []weatherdata.Source{{Name: "alerts", FetchedAt: generatedAt}},
SourceWarnings: []weatherdata.SourceWarning{{
Source: "daily",
Code: "missing_source",
Severity: "warning",
Message: "daily source is missing",
Endpoint: "/forecast/daily",
CompletenessImpact: "source omitted",
}},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: []weatherdata.ForecastPeriod{
{
StartTime: mustParseModuleTime("2026-05-29T08:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T09:00:00-05:00"),
TextDescription: "Showers likely.",
TemperatureF: &hourlyTempF,
WindSpeedMph: &hourlyWindMph,
WindDirectionDegrees: &windDirection,
ProbabilityOfPrecipitationPercent: &hourlyPop,
RelativeHumidityPercent: &hourlyHumidity,
},
},
ValidPeriodNarrativePeriods: []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: &isDay,
TextDescription: "Morning storms, then partly sunny.",
TemperatureF: floatPtr(narrativeTempF),
WindSpeedMph: &narrativeWind,
WindDirectionDegrees: &narrativeWindDirection,
ProbabilityOfPrecipitationPercent: &narrativePop,
},
},
AlertOverlaps: []forecast.AlertOverlap{{
Event: "Flood Watch",
Headline: "Flooding possible",
Severity: "Moderate",
}},
},
Units: "us",
Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}
}
func moduleValue[T any](t *testing.T, output *module.Output) T {
t.Helper()
var value T
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal module value: %v", err)
}
if err := json.Unmarshal(data, &value); err != nil {
t.Fatalf("decode module value: %v", err)
}
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 {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,104 @@
package briefing
import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
type CurrentConditionsModule struct {
ConditionText string `json:"condition_text,omitempty"`
ConditionTextLower string `json:"condition_text_lower,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"`
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 {
return nil, nil
}
value := CurrentConditionsModule{
ConditionText: current.ConditionText,
ConditionTextLower: strings.ToLower(current.ConditionText),
IsDay: copyBool(current.IsDay),
TemperatureC: roundedInt(current.TemperatureC),
TemperatureF: roundedInt(current.TemperatureF),
ApparentTemperatureC: roundedInt(current.ApparentTemperatureC),
ApparentTemperatureF: roundedInt(current.ApparentTemperatureF),
DewpointC: roundedInt(current.DewpointC),
DewpointF: roundedInt(current.DewpointF),
RelativeHumidityPercent: roundedInt(current.RelativeHumidityPercent),
WindSpeedKmh: roundedInt(current.WindSpeedKmh),
WindSpeedMph: roundedInt(current.WindSpeedMph),
WindDirection: windDirectionLabel(current.WindDirectionDegrees),
WindDirectionText: windDirectionTextLabel(current.WindDirectionDegrees),
}
if value.isEmpty() {
return nil, nil
}
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 == "" &&
v.IsDay == nil &&
v.TemperatureC == nil &&
v.TemperatureF == nil &&
v.ApparentTemperatureC == nil &&
v.ApparentTemperatureF == nil &&
v.DewpointC == nil &&
v.DewpointF == nil &&
v.RelativeHumidityPercent == nil &&
v.WindSpeedKmh == nil &&
v.WindSpeedMph == nil &&
v.WindDirection == "" &&
v.WindDirectionText == ""
}

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
}

View File

@@ -1,327 +0,0 @@
package briefing
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestDailyBriefingFromRepresentativeFixture(t *testing.T) {
bundle := loadBundleFixture(t)
currentIsDay := true
currentTemp := 75.9
currentFeelsLike := 76.1
currentHumidity := 56.0
currentWind := 10.7
bundle.Current = &forecast.Current{
ConditionText: "Partly cloudy",
IsDay: &currentIsDay,
TemperatureF: &currentTemp,
ApparentTemperatureF: &currentFeelsLike,
RelativeHumidityPercent: &currentHumidity,
WindSpeedMph: &currentWind,
}
bundle.Sources[0].DataSHA256 = "abc123"
bundle.Warnings = []forecast.SourceWarning{{Source: "daily", Code: "missing_source", Severity: "warning"}}
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{
Resolved: resolved,
Bundle: bundle,
Units: "us",
Timezone: "America/Chicago",
Location: &LocationContext{
ID: "home",
Name: "Brentwood",
Region: "St. Louis Metro",
Timezone: "America/Chicago",
},
}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Metadata.SchemaVersion != SchemaVersion {
t.Fatalf("SchemaVersion = %q, want %q", pkg.Metadata.SchemaVersion, SchemaVersion)
}
if !strings.Contains(pkg.Metadata.RunID, "daily_today") {
t.Fatalf("RunID = %q, want report id", pkg.Metadata.RunID)
}
if pkg.Metadata.ReportID != report.DailyToday {
t.Fatalf("ReportID = %q, want daily_today", pkg.Metadata.ReportID)
}
if pkg.Metadata.Units != "us" || pkg.Metadata.Timezone != "America/Chicago" {
t.Fatalf("metadata units/timezone = %q/%q", pkg.Metadata.Units, pkg.Metadata.Timezone)
}
if pkg.Metadata.Location == nil || pkg.Metadata.Location.ID != "home" || pkg.Metadata.Location.Name != "Brentwood" || pkg.Metadata.Location.Region != "St. Louis Metro" || pkg.Metadata.Location.Timezone != "America/Chicago" {
t.Fatalf("metadata location = %#v, want configured prompt location", pkg.Metadata.Location)
}
if pkg.CurrentConditions == nil || pkg.CurrentConditions.ConditionText != "Partly cloudy" || pkg.CurrentConditions.TemperatureF == nil || *pkg.CurrentConditions.TemperatureF != currentTemp || pkg.CurrentConditions.RelativeHumidityPercent == nil || *pkg.CurrentConditions.RelativeHumidityPercent != currentHumidity {
t.Fatalf("CurrentConditions = %#v, want current conditions from bundle", pkg.CurrentConditions)
}
if len(pkg.Metadata.Sources) != 1 || pkg.Metadata.Sources[0].DataSHA256 != "abc123" {
t.Fatalf("Sources = %#v, want source hash", pkg.Metadata.Sources)
}
if len(pkg.Metadata.SourceWarnings) != 1 {
t.Fatalf("SourceWarnings length = %d, want 1", len(pkg.Metadata.SourceWarnings))
}
if pkg.Daily == nil {
t.Fatal("Daily = nil")
}
if len(pkg.Daily.Dayparts) != 5 {
t.Fatalf("Dayparts length = %d, want 5", len(pkg.Daily.Dayparts))
}
if len(pkg.Daily.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.Daily.RelevantAlerts))
}
if len(pkg.Daily.NarrativePeriods) != 1 {
t.Fatalf("NarrativePeriods length = %d, want 1", len(pkg.Daily.NarrativePeriods))
}
if len(pkg.Daily.Discussion.KeyMessages) != 1 {
t.Fatalf("Discussion key messages length = %d, want 1", len(pkg.Daily.Discussion.KeyMessages))
}
if pkg.Daily.Discussion.ShortTerm != "Morning showers taper as a weak boundary shifts east." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Daily.Discussion.ShortTerm)
}
if pkg.Daily.Discussion.LongTerm != "Warmer and more humid conditions return with periodic rain chances." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Daily.Discussion.LongTerm)
}
if pkg.Daily.OutdoorWindows.Best == nil || pkg.Daily.OutdoorWindows.Worst == nil {
t.Fatalf("OutdoorWindows = %#v, want best and worst", pkg.Daily.OutdoorWindows)
}
if pkg.Daily.BottomLine.Summary == "" {
t.Fatal("BottomLine summary is empty")
}
if _, err := json.Marshal(pkg); err != nil {
t.Fatalf("briefing package is not JSON inspectable: %v", err)
}
}
func TestDailyBriefingQuietWeather(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{
quietHour("2026-05-29T09:00:00-05:00", "2026-05-29T10:00:00-05:00", 72),
}},
Alerts: &forecast.AlertRun{},
Sources: []forecast.Source{
{Name: "hourly", FetchedAt: time.Now()},
{Name: "alerts", Endpoint: "/alerts/active", FetchedAt: time.Now()},
{Name: "current", Endpoint: "/conditions/current", FetchedAt: time.Now(), Missing: true},
},
Warnings: []forecast.SourceWarning{{Source: "current", Code: "missing_source", Severity: "warning"}},
}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Daily.BottomLine.Summary != "Conditions: Clear." {
t.Fatalf("BottomLine summary = %q, want clear conditions", pkg.Daily.BottomLine.Summary)
}
if pkg.CurrentConditions != nil {
t.Fatalf("CurrentConditions = %#v, want nil when current conditions are missing", pkg.CurrentConditions)
}
if len(pkg.Metadata.SourceWarnings) != 1 || pkg.Metadata.SourceWarnings[0].Source != "current" {
t.Fatalf("SourceWarnings = %#v, want current missing-source warning", pkg.Metadata.SourceWarnings)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
if pkg.Metadata.Alerts == nil {
t.Fatal("Metadata.Alerts = nil, want checked no-active-alerts status")
}
if !pkg.Metadata.Alerts.Checked || pkg.Metadata.Alerts.ActiveCount != 0 || pkg.Metadata.Alerts.RelevantCount != 0 || pkg.Metadata.Alerts.Missing {
t.Fatalf("Metadata.Alerts = %#v, want checked no-active-alerts status", pkg.Metadata.Alerts)
}
data, err := json.Marshal(pkg.Metadata.Alerts)
if err != nil {
t.Fatalf("marshal alert metadata: %v", err)
}
if strings.Contains(string(data), `"missing"`) {
t.Fatalf("alert metadata includes missing for checked empty alerts:\n%s", string(data))
}
}
func TestDailyBriefingAlertExclusion(t *testing.T) {
location := mustLocation(t)
resolved := mustResolveDaily(t, location)
bundle := loadBundleFixture(t)
bundle.Alerts = &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Future Watch","effective":"2026-06-01T00:00:00-05:00","expires":"2026-06-01T06:00:00-05:00"}`),
}}
summary, err := forecast.BuildDailySummary(bundle, resolved.ValidPeriod.Start, location, defaultDayparts())
if err != nil {
t.Fatalf("BuildDailySummary() error = %v", err)
}
pkg, err := BuildDaily(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if len(pkg.Daily.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Daily.RelevantAlerts))
}
}
func TestTomorrowBriefingIncludesPlanningInputs(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.DailyTomorrow, report.ResolveRequest{
Now: mustParse("2026-05-29T18:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve tomorrow: %v", err)
}
precip := 70.0
wind := 34.0
summary := &forecast.DailySummary{
Date: "2026-05-30",
Period: resolved.ValidPeriod,
Dayparts: []forecast.DaypartSummary{
{
Name: "overnight",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-30T06:00:00-05:00"),
},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: 40,
Time: mustParse("2026-05-30T03:00:00-05:00"),
},
},
{
Name: "morning",
Period: timeutil.Period{
Start: mustParse("2026-05-30T06:00:00-05:00"),
End: mustParse("2026-05-30T12:00:00-05:00"),
},
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-30T08:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: wind,
Time: mustParse("2026-05-30T09:00:00-05:00"),
},
Indicators: forecast.Indicators{Snow: true},
},
},
}
pkg, err := BuildDaily(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summary)
if err != nil {
t.Fatalf("BuildDaily() error = %v", err)
}
if pkg.Metadata.ReportID != report.DailyTomorrow || pkg.Metadata.Variant != "tomorrow" {
t.Fatalf("metadata report/variant = %q/%q, want tomorrow", pkg.Metadata.ReportID, pkg.Metadata.Variant)
}
if pkg.Daily.ForecastSummaryDate != "2026-05-30" {
t.Fatalf("ForecastSummaryDate = %q, want 2026-05-30", pkg.Daily.ForecastSummaryDate)
}
if pkg.Daily.Planning == nil {
t.Fatal("Planning = nil, want tomorrow planning inputs")
}
if len(pkg.Daily.Planning.MorningReadiness) == 0 || len(pkg.Daily.Planning.CommuteSchoolWorkdayConcerns) == 0 || len(pkg.Daily.Planning.OvernightChangeWatch) == 0 {
t.Fatalf("Planning = %#v, want populated planning inputs", pkg.Daily.Planning)
}
if !strings.Contains(strings.Join(pkg.Daily.Planning.MorningReadiness, " "), "precipitation") {
t.Fatalf("MorningReadiness = %#v, want precipitation note", pkg.Daily.Planning.MorningReadiness)
}
}
func TestSaveBriefingPackage(t *testing.T) {
pkg := Package{Metadata: Metadata{SchemaVersion: SchemaVersion}}
path := filepath.Join(t.TempDir(), "nested", "briefing.json")
if err := Save(path, pkg); err != nil {
t.Fatalf("Save() error = %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read briefing: %v", err)
}
if !strings.Contains(string(data), SchemaVersion) {
t.Fatalf("saved briefing missing schema version:\n%s", string(data))
}
}
func loadBundleFixture(t *testing.T) *forecast.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 forecast.Bundle
if err := json.Unmarshal(data, &bundle); err != nil {
t.Fatalf("decode bundle fixture: %v", err)
}
return &bundle
}
func mustResolveDaily(t *testing.T, location *time.Location) report.Resolved {
t.Helper()
resolved, err := report.Resolve(report.DailyToday, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve daily: %v", err)
}
return resolved
}
func defaultDayparts() []forecast.DaypartDefinition {
return []forecast.DaypartDefinition{
{Name: "overnight", Start: "00:00", End: "06:00"},
{Name: "morning", Start: "06:00", End: "10:00"},
{Name: "midday", Start: "10:00", End: "15:00"},
{Name: "afternoon", Start: "15:00", End: "17:00"},
{Name: "evening", Start: "17:00", End: "24:00"},
}
}
func quietHour(start string, end string, temperature float64) forecast.ForecastPeriod {
return forecast.ForecastPeriod{
StartTime: mustParse(start),
EndTime: mustParse(end),
TextDescription: "Clear",
TemperatureF: &temperature,
}
}
func mustLocation(t *testing.T) *time.Location {
t.Helper()
location, err := time.LoadLocation("America/Chicago")
if err != nil {
t.Fatalf("load location: %v", err)
}
return location
}
func mustParse(value string) time.Time {
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
panic(err)
}
return parsed
}

View File

@@ -0,0 +1,153 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DerivedDailySummaryModule struct {
Date string `json:"date,omitempty"`
HighTempF *int `json:"high_temp_f,omitempty"`
LowTempF *int `json:"low_temp_f,omitempty"`
DailyPrecipitationProbability *int `json:"daily_precipitation_probability,omitempty"`
MostLikelyPrecipitationHour string `json:"most_likely_precipitation_hour,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
HeatIndexMaxF *int `json:"heat_index_max_f,omitempty"`
DominantConditions []string `json:"dominant_conditions,omitempty"`
Hazards []string `json:"hazards,omitempty"`
}
func buildDerivedDailySummaryModule(ctx ModuleContext, _ any) (*module.Output, error) {
summary := ctx.Derived.FirstDailySummary()
if summary == nil {
return nil, fmt.Errorf("daily summary facts are required")
}
value, err := derivedDailySummaryValue(*summary, ctx.Derived.PrecipTiming, ctx.Timezone)
if err != nil {
return nil, err
}
return &module.Output{ID: module.DerivedDailySummary, StanzaName: "derived_daily_summary", Value: value}, nil
}
func derivedDailySummaryValue(summary forecast.DailySummary, timing forecast.PrecipTiming, timezone string) (DerivedDailySummaryModule, error) {
value := DerivedDailySummaryModule{
Date: friendlyDateLabel(summary.Date, timezone),
ThunderMentioned: timing.ThunderMentioned,
}
conditions := map[string]struct{}{}
hazards := map[string]struct{}{}
var temperature forecast.Range
var apparent forecast.Range
var maxPop *forecast.TimedValue
var maxGust *forecast.TimedValue
for _, daypart := range summary.Dayparts {
addRange(&temperature, daypart.Temperature)
addRange(&apparent, daypart.ApparentTemperature)
maxTimedValue(&maxPop, daypart.MaxPrecipitationProbability)
maxTimedValue(&maxGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
hazards[hazard] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
narrativeTemperature := narrativeTemperatureRange(summary.NarrativePeriods)
if narrativeTemperature.Max != nil {
value.HighTempF = roundedInt(narrativeTemperature.Max)
} else {
value.HighTempF = roundedInt(temperature.Max)
}
if narrativeTemperature.Min != nil {
value.LowTempF = roundedInt(narrativeTemperature.Min)
} else {
value.LowTempF = roundedInt(temperature.Min)
}
value.HeatIndexMaxF = roundedInt(apparent.Max)
narrativePrecipitation := narrativeMaxPrecipitation(summary.NarrativePeriods)
if narrativePrecipitation != nil {
value.DailyPrecipitationProbability = roundedInt(&narrativePrecipitation.Value)
} else if maxPop != nil {
value.DailyPrecipitationProbability = roundedInt(&maxPop.Value)
}
if maxPop != nil {
value.MostLikelyPrecipitationHour = mostLikelyPrecipitationHour(maxPop, timezone)
}
if maxGust != nil {
value.MaxWindGustMph = roundedInt(&maxGust.Value)
}
value.DominantConditions = sortedSet(conditions)
value.Hazards = sortedSet(hazards)
return value, nil
}
func narrativeTemperatureRange(periods []weatherdata.ForecastPeriod) forecast.Range {
var out forecast.Range
for _, period := range periods {
addNarrativeHigh(&out, period.TemperatureFMax)
addNarrativeLow(&out, period.TemperatureFMin)
if period.TemperatureF != nil && period.IsDay != nil {
if *period.IsDay {
addNarrativeHigh(&out, period.TemperatureF)
} else {
addNarrativeLow(&out, period.TemperatureF)
}
}
}
return out
}
func addNarrativeHigh(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Max == nil || *value > *target.Max {
copied := *value
target.Max = &copied
}
}
func addNarrativeLow(target *forecast.Range, value *float64) {
if value == nil {
return
}
if target.Min == nil || *value < *target.Min {
copied := *value
target.Min = &copied
}
}
func narrativeMaxPrecipitation(periods []weatherdata.ForecastPeriod) *forecast.TimedValue {
var maxPop *forecast.TimedValue
for _, period := range periods {
if period.ProbabilityOfPrecipitationPercent == nil {
continue
}
value := forecast.TimedValue{
Value: *period.ProbabilityOfPrecipitationPercent,
Time: period.StartTime,
}
maxTimedValue(&maxPop, &value)
}
return maxPop
}
func mostLikelyPrecipitationHour(maxPop *forecast.TimedValue, timezone string) string {
if maxPop == nil || maxPop.Value <= 0 {
return ""
}
percent := roundedInt(&maxPop.Value)
if percent == nil {
return ""
}
return fmt.Sprintf("%d%% at %s", *percent, clockLabel(maxPop.Time, timezone))
}

View File

@@ -0,0 +1,411 @@
package briefing
import (
"fmt"
"sort"
"strings"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type DerivedDaypartSummaryModule struct {
Date string `json:"date,omitempty"`
DisplayName string `json:"display_name,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
TemperaturePhraseF string `json:"temperature_phrase_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopTimeLabel string `json:"max_pop_time_label,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
DominantConditionLower string `json:"dominant_condition_lower,omitempty"`
DominantConditionDisplay string `json:"dominant_condition_display,omitempty"`
TemperatureTrend string `json:"temperature_trend,omitempty"`
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
type DerivedDaypartSummaryPromptExport struct {
Date string `json:"date,omitempty"`
DisplayName string `json:"display_name,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
TempRangeF string `json:"temp_range_f,omitempty"`
ApparentTempRangeF string `json:"apparent_temp_range_f,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
MaxWindGustMph *int `json:"max_wind_gust_mph,omitempty"`
MaxWindGustTime string `json:"max_wind_gust_time,omitempty"`
DominantCondition string `json:"dominant_condition,omitempty"`
TemperatureTrend string `json:"temperature_trend,omitempty"`
TemperatureStartPhraseF string `json:"temperature_start_phrase_f,omitempty"`
TemperatureEndPhraseF string `json:"temperature_end_phrase_f,omitempty"`
TemperaturePeakPhraseF string `json:"temperature_peak_phrase_f,omitempty"`
TemperatureSteadyPhraseF string `json:"temperature_steady_phrase_f,omitempty"`
NotableConditions []string `json:"notable_conditions,omitempty"`
Snow bool `json:"snow,omitempty"`
Ice bool `json:"ice,omitempty"`
Fog bool `json:"fog,omitempty"`
Heat bool `json:"heat,omitempty"`
Cold bool `json:"cold,omitempty"`
Wind bool `json:"wind,omitempty"`
RelevantAlertCount int `json:"relevant_alert_count,omitempty"`
}
func buildDerivedDaypartSummariesModule(ctx ModuleContext, _ any) (*module.Output, error) {
if len(ctx.Derived.DaypartSummaries) == 0 {
return nil, fmt.Errorf("daypart summary facts are required")
}
value := map[string]DerivedDaypartSummaryModule{}
prefixDates := multipleSummaryDates(ctx.Derived.DailySummaries)
for _, daypart := range ctx.Derived.DaypartSummaries {
key := daypartKey(daypart, prefixDates)
value[key] = derivedDaypartSummaryValue(daypart, ctx.Timezone)
}
return &module.Output{ID: module.DerivedDaypartSummaries, StanzaName: "derived_daypart_summaries", Value: value}, nil
}
func exportDerivedDaypartSummariesPromptValue(value any) (any, error) {
rich, ok := value.(map[string]DerivedDaypartSummaryModule)
if !ok {
return nil, unexpectedPromptExportValue(value, map[string]DerivedDaypartSummaryModule{})
}
out := make(map[string]DerivedDaypartSummaryPromptExport, len(rich))
for key, daypart := range rich {
out[key] = derivedDaypartSummaryPromptValue(daypart)
}
return out, nil
}
func derivedDaypartSummaryPromptValue(rich DerivedDaypartSummaryModule) DerivedDaypartSummaryPromptExport {
maxPopTime := rich.MaxPopTime
if rich.MaxPopTimeLabel != "" {
maxPopTime = rich.MaxPopTimeLabel
}
return DerivedDaypartSummaryPromptExport{
Date: rich.Date,
DisplayName: rich.DisplayName,
PeriodBegins: rich.PeriodBegins,
PeriodEnds: rich.PeriodEnds,
TempRangeF: rich.TempRangeF,
ApparentTempRangeF: rich.ApparentTempRangeF,
MaxPopPercent: copyInt(rich.MaxPopPercent),
MaxPopTime: maxPopTime,
MentionPrecipitation: rich.MentionPrecipitation,
MaxWindGustMph: copyInt(rich.MaxWindGustMph),
MaxWindGustTime: rich.MaxWindGustTime,
DominantCondition: rich.DominantCondition,
TemperatureTrend: rich.TemperatureTrend,
TemperatureStartPhraseF: rich.TemperatureStartPhraseF,
TemperatureEndPhraseF: rich.TemperatureEndPhraseF,
TemperaturePeakPhraseF: rich.TemperaturePeakPhraseF,
TemperatureSteadyPhraseF: rich.TemperatureSteadyPhraseF,
NotableConditions: append([]string(nil), rich.NotableConditions...),
Snow: rich.Snow,
Ice: rich.Ice,
Fog: rich.Fog,
Heat: rich.Heat,
Cold: rich.Cold,
Wind: rich.Wind,
RelevantAlertCount: rich.RelevantAlertCount,
}
}
func derivedDaypartSummaryValue(daypart forecast.DaypartSummary, timezone string) DerivedDaypartSummaryModule {
temperature := daypartTemperatureDisplay(daypart)
value := DerivedDaypartSummaryModule{
Date: localDateLabel(daypart.Period.Start, timezone),
DisplayName: titleWord(strings.TrimSpace(daypart.Name)),
PeriodBegins: friendlyPeriodBeginsLabel(daypart.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(daypart.Period, timezone),
TempRangeF: rangeLabel(daypart.Temperature),
TemperaturePhraseF: temperaturePhraseF(daypart.Temperature),
TemperatureTrend: temperature.Trend,
TemperatureStartPhraseF: temperature.StartPhrase,
TemperatureEndPhraseF: temperature.EndPhrase,
TemperaturePeakPhraseF: temperature.PeakPhrase,
TemperatureSteadyPhraseF: temperature.SteadyPhrase,
ApparentTempRangeF: daypartApparentRangeLabel(daypart.ApparentTemperature),
DominantCondition: daypart.DominantCondition,
DominantConditionLower: strings.ToLower(daypart.DominantCondition),
DominantConditionDisplay: sentenceCase(daypart.DominantCondition),
NotableConditions: append([]string(nil), daypart.NotableConditions...),
Snow: daypart.Indicators.Snow,
Ice: daypart.Indicators.Ice,
Fog: daypart.Indicators.Fog,
Heat: daypart.Indicators.Heat,
Cold: daypart.Indicators.Cold,
Wind: daypart.Indicators.Wind,
RelevantAlertCount: len(daypart.AlertOverlaps),
}
if daypart.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&daypart.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MaxPopTimeLabel = hourMinuteLabel(daypart.MaxPrecipitationProbability.Time, timezone)
value.MentionPrecipitation = mentionHourlyForecastPrecipitation(&daypart.MaxPrecipitationProbability.Value, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
}
if daypart.PeakWindGust != nil {
value.MaxWindGustMph = roundedInt(&daypart.PeakWindGust.Value)
value.MaxWindGustTime = clockLabel(daypart.PeakWindGust.Time, timezone)
}
return value
}
type daypartTemperaturePresentation struct {
Trend string
StartPhrase string
EndPhrase string
PeakPhrase string
SteadyPhrase string
}
type temperaturePoint struct {
value int
bandIndex int
phrase string
}
const (
temperatureTrendRising = "rising"
temperatureTrendFalling = "falling"
temperatureTrendPeaking = "peaking"
temperatureTrendSteady = "steady"
)
func daypartTemperatureDisplay(daypart forecast.DaypartSummary) daypartTemperaturePresentation {
points := daypartTemperaturePoints(daypart.HourlyPeriods)
if len(points) == 0 {
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
}
if len(points) == 1 {
return daypartSteadyTemperatureDisplay(points[0].phrase)
}
first := points[0]
last := points[len(points)-1]
peak, peakIndex := peakTemperaturePoint(points)
if peakIndex > 0 && peakIndex < len(points)-1 && peak.bandIndex > first.bandIndex && peak.bandIndex > last.bandIndex {
return daypartTemperaturePresentation{
Trend: temperatureTrendPeaking,
PeakPhrase: peak.phrase,
}
}
switch {
case first.bandIndex < last.bandIndex:
return daypartTemperaturePresentation{
Trend: temperatureTrendRising,
StartPhrase: first.phrase,
EndPhrase: last.phrase,
}
case first.bandIndex > last.bandIndex:
return daypartTemperaturePresentation{
Trend: temperatureTrendFalling,
StartPhrase: first.phrase,
EndPhrase: last.phrase,
}
default:
return daypartSteadyTemperatureDisplay(temperaturePhraseF(daypart.Temperature))
}
}
func daypartSteadyTemperatureDisplay(phrase string) daypartTemperaturePresentation {
if phrase == "" {
return daypartTemperaturePresentation{}
}
return daypartTemperaturePresentation{
Trend: temperatureTrendSteady,
SteadyPhrase: phrase,
}
}
func daypartTemperaturePoints(periods []weatherdata.ForecastPeriod) []temperaturePoint {
sorted := append([]weatherdata.ForecastPeriod(nil), periods...)
sort.SliceStable(sorted, func(i int, j int) bool {
return sorted[i].StartTime.Before(sorted[j].StartTime)
})
points := make([]temperaturePoint, 0, len(sorted))
for _, period := range sorted {
temperature := forecastPeriodTemperatureF(period)
if temperature == nil {
continue
}
rounded := roundedInt(temperature)
if rounded == nil {
continue
}
points = append(points, temperaturePoint{
value: *rounded,
bandIndex: temperatureBandIndex(*rounded),
phrase: temperatureBandPhrase(*rounded),
})
}
return points
}
func peakTemperaturePoint(points []temperaturePoint) (temperaturePoint, int) {
peak := points[0]
peakIndex := 0
for index, point := range points[1:] {
if point.value > peak.value {
peak = point
peakIndex = index + 1
}
}
return peak, peakIndex
}
func forecastPeriodTemperatureF(period weatherdata.ForecastPeriod) *float64 {
switch {
case period.TemperatureF != nil:
return period.TemperatureF
case period.TemperatureFMax != nil:
return period.TemperatureFMax
case period.TemperatureFMin != nil:
return period.TemperatureFMin
case period.TemperatureC != nil:
value := celsiusToFahrenheit(*period.TemperatureC)
return &value
case period.TemperatureCMax != nil:
value := celsiusToFahrenheit(*period.TemperatureCMax)
return &value
case period.TemperatureCMin != nil:
value := celsiusToFahrenheit(*period.TemperatureCMin)
return &value
default:
return nil
}
}
func celsiusToFahrenheit(value float64) float64 {
return value*9/5 + 32
}
func temperatureBandIndex(value int) int {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
band := 1
switch {
case remainder <= 3:
band = 0
case remainder >= 7:
band = 2
}
return decade*3 + band
}
func temperaturePhraseF(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
if value.Min != nil && value.Max != nil {
low := roundedInt(value.Min)
high := roundedInt(value.Max)
if low == nil || high == nil {
return ""
}
lowPhrase := temperatureBandPhrase(*low)
highPhrase := temperatureBandPhrase(*high)
if lowPhrase == highPhrase {
return lowPhrase
}
return lowPhrase + " to " + highPhrase
}
if value.Min != nil {
low := roundedInt(value.Min)
if low == nil {
return ""
}
return temperatureBandPhrase(*low)
}
high := roundedInt(value.Max)
if high == nil {
return ""
}
return temperatureBandPhrase(*high)
}
func temperatureBandPhrase(value int) string {
decade := (value / 10) * 10
remainder := value - decade
if remainder < 0 {
remainder = -remainder
}
qualifier := "mid"
switch {
case remainder <= 3:
qualifier = "low"
case remainder >= 7:
qualifier = "upper"
}
return fmt.Sprintf("%s %ds", qualifier, decade)
}
func sentenceCase(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func multipleSummaryDates(summaries []forecast.DailySummary) bool {
seen := map[string]struct{}{}
for _, summary := range summaries {
seen[summary.Date] = struct{}{}
}
return len(seen) > 1
}
func daypartKey(daypart forecast.DaypartSummary, prefixDate bool) string {
key := normalizedKey(daypart.Name)
if key == "" {
key = "unnamed"
}
if !prefixDate {
return key
}
return daypart.Period.Start.Format(timeutil.DateLayout) + "_" + key
}
func normalizedKey(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
var out strings.Builder
lastUnderscore := false
for _, r := range lower {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
out.WriteRune(r)
lastUnderscore = false
continue
}
if !lastUnderscore {
out.WriteByte('_')
lastUnderscore = true
}
}
return strings.Trim(out.String(), "_")
}

View File

@@ -0,0 +1,876 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDerivedDailySummaryModulePackagesOrdinaryForecast(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.Date != "Friday, May 29, 2026" {
t.Fatalf("Date = %q, want friendly local date", value.Date)
}
if value.HighTempF == nil || *value.HighTempF != 88 || value.LowTempF == nil || *value.LowTempF != 64 {
t.Fatalf("daily temperatures = %#v/%#v, want narrative 88/64", value.HighTempF, value.LowTempF)
}
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 55 {
t.Fatalf("DailyPrecipitationProbability = %#v, want narrative 55", value.DailyPrecipitationProbability)
}
if value.MostLikelyPrecipitationHour != "80% at 12 PM" || !value.ThunderMentioned {
t.Fatalf("precip timing = %#v, want most likely hour and thunder", value)
}
if !containsString(value.DominantConditions, "Thunderstorms with gusty wind") || containsString(value.DominantConditions, "Morning storms, then partly sunny.") {
t.Fatalf("DominantConditions = %#v, want daypart conditions rather than narrative conditions", value.DominantConditions)
}
if value.MaxWindGustMph == nil || *value.MaxWindGustMph != 42 {
t.Fatalf("MaxWindGustMph = %#v, want 42", value.MaxWindGustMph)
}
if value.HeatIndexMaxF == nil || *value.HeatIndexMaxF != 101 {
t.Fatalf("HeatIndexMaxF = %#v, want 101", value.HeatIndexMaxF)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily summary: %v", err)
}
jsonText := string(data)
for _, field := range []string{"high_temp_f", "low_temp_f", "daily_precipitation_probability", "most_likely_precipitation_hour", "heat_index_max_f"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily json = %s, want field %s", jsonText, field)
}
}
for _, removed := range []string{"max_pop_percent", "max_pop_window", "first_precip_hour", "last_precip_hour"} {
if strings.Contains(jsonText, removed) {
t.Fatalf("daily json = %s, want removed field %s omitted", jsonText, removed)
}
}
if strings.Contains(jsonText, "qpf") {
t.Fatalf("daily json = %s, want no QPF fields without upstream QPF facts", jsonText)
}
}
func TestDerivedDailySummaryModuleFallsBackWithoutNarrativeFacts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries[0].NarrativePeriods = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[DerivedDailySummaryModule](t, output)
if value.HighTempF == nil || *value.HighTempF != 96 || value.LowTempF == nil || *value.LowTempF != 31 {
t.Fatalf("daily temperatures = %#v/%#v, want fallback 96/31", value.HighTempF, value.LowTempF)
}
if value.DailyPrecipitationProbability == nil || *value.DailyPrecipitationProbability != 80 {
t.Fatalf("DailyPrecipitationProbability = %#v, want hourly fallback 80", value.DailyPrecipitationProbability)
}
if len(value.DominantConditions) == 0 || !containsString(value.DominantConditions, "Thunderstorms with gusty wind") {
t.Fatalf("DominantConditions = %#v, want fallback daypart conditions", value.DominantConditions)
}
}
func TestPrecipTimingModuleHandlesRainyAndDryForecasts(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule(rainy) error = %v", err)
}
rainy := moduleValue[PrecipTimingModule](t, output)
if rainy.MaxPopPercent == nil || *rainy.MaxPopPercent != 80 || rainy.MaxPopTime != "12 PM" || rainy.ProbabilityThreshold != forecast.DefaultPrecipWindowProbabilityThreshold || !rainy.ThunderMentioned {
t.Fatalf("rainy precip timing = %#v, want peak, threshold, and thunder", rainy)
}
if len(rainy.PrecipitationWindows) != 2 {
t.Fatalf("rainy precipitation windows = %#v, want two windows", rainy.PrecipitationWindows)
}
if rainy.PrecipitationWindows[0].PeriodBegins != "2026-05-29 at 8:00 AM" || rainy.PrecipitationWindows[0].PeriodBeginsHourLabel != "8:00 AM" || rainy.PrecipitationWindows[0].PeriodEnds != "2026-05-29 at 9:00 AM" || rainy.PrecipitationWindows[0].PeriodEndsHourLabel != "9:00 AM" || rainy.PrecipitationWindows[0].MaxPopPercent == nil || *rainy.PrecipitationWindows[0].MaxPopPercent != 60 || rainy.PrecipitationWindows[0].MaxPopHourLabel != "8:00 AM" {
t.Fatalf("first precipitation window = %#v, want 8-9 AM at 60%%", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[0].PrecipitationType != "showers" || rainy.PrecipitationWindows[0].ExpectationPhrase != "Showers likely." {
t.Fatalf("first precipitation window phrase = %#v, want showers likely", rainy.PrecipitationWindows[0])
}
if rainy.PrecipitationWindows[1].PeriodBegins != "2026-05-29 at 12:00 PM" || rainy.PrecipitationWindows[1].PeriodBeginsHourLabel != "12:00 PM" || rainy.PrecipitationWindows[1].PeriodEnds != "2026-05-29 at 2:00 PM" || rainy.PrecipitationWindows[1].PeriodEndsHourLabel != "2:00 PM" || rainy.PrecipitationWindows[1].MaxPopPercent == nil || *rainy.PrecipitationWindows[1].MaxPopPercent != 80 || rainy.PrecipitationWindows[1].MaxPopHourLabel != "12:00 PM" {
t.Fatalf("second precipitation window = %#v, want noon-2 PM at 80%%", rainy.PrecipitationWindows[1])
}
if rainy.PrecipitationWindows[1].PrecipitationType != "showers and thunderstorms" || rainy.PrecipitationWindows[1].ExpectationPhrase != "Expect showers and thunderstorms." {
t.Fatalf("second precipitation window phrase = %#v, want expect showers and thunderstorms", rainy.PrecipitationWindows[1])
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal precip timing: %v", err)
}
if !strings.Contains(string(data), "precipitation_windows") || !strings.Contains(string(data), "probability_threshold") || !strings.Contains(string(data), "period_begins_hour_label") || !strings.Contains(string(data), "max_pop_hour_label") {
t.Fatalf("precip timing json = %s, want threshold and windows", string(data))
}
if !strings.Contains(string(data), "precipitation_type") || !strings.Contains(string(data), "expectation_phrase") {
t.Fatalf("precip timing json = %s, want precipitation type and expectation phrase", string(data))
}
if strings.Contains(string(data), `"start"`) || strings.Contains(string(data), `"end"`) {
t.Fatalf("precip timing json = %s, want period_begins/period_ends instead of start/end", string(data))
}
if strings.Contains(string(data), "first_precip_hour") || strings.Contains(string(data), "last_precip_hour") {
t.Fatalf("precip timing json = %s, want no ambiguous first/last fields", string(data))
}
ctx.Derived.PrecipTiming = forecast.BuildPrecipTiming([]weatherdata.ForecastPeriod{derivedHour("2026-05-29T10:00:00-05:00", "Sunny", 0, 70, nil, 5)})
output, err = registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule(dry) error = %v", err)
}
dry := moduleValue[PrecipTimingModule](t, output)
if len(dry.PrecipitationWindows) != 0 || dry.ThunderMentioned {
t.Fatalf("dry precip timing = %#v, want no precip windows and no thunder", dry)
}
if dry.MaxPopPercent == nil || *dry.MaxPopPercent != 0 {
t.Fatalf("dry MaxPopPercent = %#v, want checked zero", dry.MaxPopPercent)
}
}
func TestPrecipTimingModuleBuildsExpectationPhrases(t *testing.T) {
now := mustParseModuleTime("2026-05-29T08:00:00-05:00")
tests := []struct {
name string
maxPop float64
descriptions []string
wantType string
wantPhrase string
}{
{
name: "chance lower bound",
maxPop: 40,
descriptions: []string{"Scattered showers"},
wantType: "showers",
wantPhrase: "Chance of showers.",
},
{
name: "chance upper bound",
maxPop: 49,
descriptions: []string{"Rain possible"},
wantType: "rain",
wantPhrase: "Chance of rain.",
},
{
name: "likely lower bound",
maxPop: 50,
descriptions: []string{"Drizzle"},
wantType: "drizzle",
wantPhrase: "Drizzle likely.",
},
{
name: "likely upper bound",
maxPop: 69,
descriptions: []string{"Freezing rain"},
wantType: "freezing rain",
wantPhrase: "Freezing rain likely.",
},
{
name: "expect lower bound",
maxPop: 70,
descriptions: []string{"Snow"},
wantType: "snow",
wantPhrase: "Expect snow.",
},
{
name: "showers and thunderstorms preferred",
maxPop: 100,
descriptions: []string{"Showers likely", "Thunderstorms possible"},
wantType: "showers and thunderstorms",
wantPhrase: "Expect showers and thunderstorms.",
},
{
name: "thunderstorms only",
maxPop: 80,
descriptions: []string{"Thunderstorms"},
wantType: "thunderstorms",
wantPhrase: "Expect thunderstorms.",
},
{
name: "unknown fallback",
maxPop: 95,
descriptions: []string{"Unsettled conditions"},
wantType: "precipitation",
wantPhrase: "Expect precipitation.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
value := precipTimingValue(forecast.PrecipTiming{
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
PrecipitationWindows: []forecast.PrecipitationWindow{
{
Start: now,
MaxPrecipitationProbability: forecast.TimedValue{
Value: tt.maxPop,
Time: now,
},
ProbabilityThreshold: forecast.DefaultPrecipWindowProbabilityThreshold,
TextDescriptions: tt.descriptions,
},
},
}, "America/Chicago")
if len(value.PrecipitationWindows) != 1 {
t.Fatalf("PrecipitationWindows = %#v, want one window", value.PrecipitationWindows)
}
window := value.PrecipitationWindows[0]
if window.PrecipitationType != tt.wantType || window.ExpectationPhrase != tt.wantPhrase {
t.Fatalf("window = %#v, want type %q and phrase %q", window, tt.wantType, tt.wantPhrase)
}
})
}
}
func TestPrecipTimingModuleUsesDerivedTimingWithoutDaypartSummaries(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Hourly)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.PrecipTiming})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[PrecipTimingModule](t, output)
if value.MaxPopPercent == nil || *value.MaxPopPercent != 80 || len(value.PrecipitationWindows) != 2 {
t.Fatalf("precip timing = %#v, want derived timing without daypart summaries", value)
}
}
func TestDerivedDaypartSummariesExposeConfiguredKeysAndHazards(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[map[string]DerivedDaypartSummaryModule](t, output)
morning, ok := value["morning"]
if !ok {
t.Fatalf("daypart keys = %#v, want configured morning key", value)
}
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 {
t.Fatalf("morning = %#v, want temp range and precip peak", morning)
}
if morning.DisplayName != "Morning" || morning.DominantConditionLower != "showers" || morning.DominantConditionDisplay != "Showers" || morning.TemperaturePhraseF != "upper 50s" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" || !morning.MentionPrecipitation || morning.MaxPopTimeLabel != "6:00 AM" {
t.Fatalf("morning presentation fields = %#v, want display facts for template composition", morning)
}
if morning.Date != "2026-05-29" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning period = %q/%q/%q, want friendly local date and period labels", morning.Date, morning.PeriodBegins, morning.PeriodEnds)
}
overnight := value["overnight"]
if overnight.MentionPrecipitation {
t.Fatalf("overnight MentionPrecipitation = true, want false below threshold")
}
afternoon := value["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 {
t.Fatalf("afternoon = %#v, want heat and wind hazard values", afternoon)
}
if !overnight.Cold {
t.Fatalf("overnight = %#v, want cold hazard", overnight)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daypart summaries: %v", err)
}
jsonText := string(data)
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "temperature_phrase_f", "temperature_trend", "temperature_steady_phrase_f", "max_pop_percent", "max_pop_time_label", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "dominant_condition_lower", "dominant_condition_display"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daypart json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, `"period":`) || strings.Contains(jsonText, `T06:00:00`) {
t.Fatalf("daypart json = %s, want friendly period label instead of raw timestamps", jsonText)
}
}
func TestDerivedDaypartSummariesPromptExportOmitsTemplateHelpers(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
richText := mustMarshalModuleJSON(t, output.Value)
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
if !strings.Contains(richText, field) {
t.Fatalf("rich daypart json = %s, want helper field %s", richText, field)
}
}
prompt := moduleDataPackageValue[map[string]DerivedDaypartSummaryPromptExport](t, output)
morning, ok := prompt["morning"]
if !ok {
t.Fatalf("daypart prompt keys = %#v, want morning", prompt)
}
if morning.Date != "2026-05-29" || morning.DisplayName != "Morning" || morning.PeriodBegins != "2026-05-29 at 6:00 AM" || morning.PeriodEnds != "2026-05-29 at 12:00 PM" {
t.Fatalf("morning prompt period = %#v, want date/display/period labels", morning)
}
if morning.TempRangeF != "58" || morning.MaxPopPercent == nil || *morning.MaxPopPercent != 60 || morning.MaxPopTime != "6:00 AM" || !morning.MentionPrecipitation {
t.Fatalf("morning prompt precip/temp = %#v, want factual prompt fields with friendly max pop time", morning)
}
if morning.DominantCondition != "Showers" || morning.TemperatureTrend != "steady" || morning.TemperatureSteadyPhraseF != "upper 50s" {
t.Fatalf("morning prompt condition/trend = %#v, want condition and trend fields", morning)
}
if len(morning.NotableConditions) == 0 || morning.NotableConditions[0] != "Showers" {
t.Fatalf("morning prompt notable conditions = %#v, want copied conditions", morning.NotableConditions)
}
afternoon := prompt["afternoon"]
if !afternoon.Heat || !afternoon.Wind || afternoon.MaxWindGustMph == nil || *afternoon.MaxWindGustMph != 42 || afternoon.RelevantAlertCount != 1 {
t.Fatalf("afternoon prompt = %#v, want hazard, wind, and alert fields", afternoon)
}
promptText := mustMarshalModuleJSON(t, output.DataPackageValue())
for _, field := range []string{"date", "display_name", "period_begins", "period_ends", "temp_range_f", "max_pop_percent", "max_pop_time", "mention_precipitation", "max_wind_gust_mph", "dominant_condition", "temperature_trend", "temperature_steady_phrase_f", "notable_conditions", "relevant_alert_count"} {
if !strings.Contains(promptText, field) {
t.Fatalf("daypart prompt json = %s, want field %s", promptText, field)
}
}
for _, field := range []string{"temperature_phrase_f", "dominant_condition_lower", "dominant_condition_display", "max_pop_time_label"} {
if strings.Contains(promptText, field) {
t.Fatalf("daypart prompt json = %s, want omitted helper field %s", promptText, field)
}
}
}
func TestDerivedDaypartPromptExportTemperatureTrends(t *testing.T) {
tests := []struct {
name string
temps []float64
wantTrend string
wantStart string
wantEnd string
wantPeak string
wantSteady string
}{
{
name: "rising",
temps: []float64{58, 68},
wantTrend: "rising",
wantStart: "upper 50s",
wantEnd: "upper 60s",
},
{
name: "falling",
temps: []float64{65, 58},
wantTrend: "falling",
wantStart: "mid 60s",
wantEnd: "upper 50s",
},
{
name: "peaking",
temps: []float64{62, 78, 65},
wantTrend: "peaking",
wantPeak: "upper 70s",
},
{
name: "steady",
temps: []float64{77, 78},
wantTrend: "steady",
wantSteady: "upper 70s",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rich := derivedDaypartSummaryValue(derivedDaypartWithTemperatures(test.name, "2026-05-29T12:00:00-05:00", "sunny", test.temps...), "America/Chicago")
prompt := derivedDaypartSummaryPromptValue(rich)
if prompt.TemperatureTrend != test.wantTrend ||
prompt.TemperatureStartPhraseF != test.wantStart ||
prompt.TemperatureEndPhraseF != test.wantEnd ||
prompt.TemperaturePeakPhraseF != test.wantPeak ||
prompt.TemperatureSteadyPhraseF != test.wantSteady {
t.Fatalf("prompt temperature presentation = %#v", prompt)
}
})
}
}
func TestDerivedDaypartPromptExportMaxPopTimeFallback(t *testing.T) {
withLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
MaxPopTime: "6 AM",
MaxPopTimeLabel: "6:00 AM",
})
if withLabel.MaxPopTime != "6:00 AM" {
t.Fatalf("MaxPopTime with label = %q, want friendly label", withLabel.MaxPopTime)
}
withoutLabel := derivedDaypartSummaryPromptValue(DerivedDaypartSummaryModule{
MaxPopTime: "6 AM",
})
if withoutLabel.MaxPopTime != "6 AM" {
t.Fatalf("MaxPopTime without label = %q, want fallback time", withoutLabel.MaxPopTime)
}
}
func TestDerivedDaypartTemperaturePresentationFields(t *testing.T) {
tests := []struct {
name string
temps []float64
wantTrend string
wantStart string
wantEnd string
wantPeak string
wantSteady string
}{
{
name: "rising",
temps: []float64{58, 68},
wantTrend: "rising",
wantStart: "upper 50s",
wantEnd: "upper 60s",
},
{
name: "falling",
temps: []float64{65, 58},
wantTrend: "falling",
wantStart: "mid 60s",
wantEnd: "upper 50s",
},
{
name: "peaking",
temps: []float64{62, 78, 65},
wantTrend: "peaking",
wantPeak: "upper 70s",
},
{
name: "steady same band",
temps: []float64{77, 78},
wantTrend: "steady",
wantSteady: "upper 70s",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
summary := derivedDaypartWithTemperatures("afternoon", "2026-05-29T12:00:00-05:00", "sunny", test.temps...)
value := derivedDaypartSummaryValue(summary, "America/Chicago")
if value.DominantConditionDisplay != "Sunny" {
t.Fatalf("DominantConditionDisplay = %q, want Sunny", value.DominantConditionDisplay)
}
if value.TemperatureTrend != test.wantTrend ||
value.TemperatureStartPhraseF != test.wantStart ||
value.TemperatureEndPhraseF != test.wantEnd ||
value.TemperaturePeakPhraseF != test.wantPeak ||
value.TemperatureSteadyPhraseF != test.wantSteady {
t.Fatalf("temperature presentation = %#v", value)
}
})
}
}
func TestTemperaturePhraseF(t *testing.T) {
tests := []struct {
name string
value forecast.Range
want string
}{
{
name: "single low band",
value: forecast.Range{Min: floatPtr(71), Max: floatPtr(73)},
want: "low 70s",
},
{
name: "single upper value",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(68)},
want: "upper 60s",
},
{
name: "range across bands",
value: forecast.Range{Min: floatPtr(68), Max: floatPtr(75)},
want: "upper 60s to mid 70s",
},
{
name: "max only",
value: forecast.Range{Max: floatPtr(84)},
want: "mid 80s",
},
{
name: "empty",
value: forecast.Range{},
want: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := temperaturePhraseF(test.value); got != test.want {
t.Fatalf("temperaturePhraseF() = %q, want %q", got, test.want)
}
})
}
}
func TestOutdoorWindowsAndTomorrowPlanningModulesPreserveDailyContent(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Tomorrow)
outdoorOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil {
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
}
outdoor := moduleValue[OutdoorWindowsModule](t, outdoorOutput)
if outdoor.Best == nil || outdoor.Worst == nil {
t.Fatalf("outdoor windows = %#v, want best and worst", outdoor)
}
if outdoor.Best.Daypart != "overnight" || outdoor.Worst.Daypart != "afternoon" {
t.Fatalf("outdoor windows = %#v, want quiet overnight and stormy afternoon", outdoor)
}
if outdoor.Best.PeriodBegins != "2026-05-29 at 12:00 AM" || outdoor.Best.PeriodEnds != "2026-05-29 at 6:00 AM" {
t.Fatalf("best outdoor period = %#v, want overnight period labels", outdoor.Best)
}
if outdoor.Worst.PeriodBegins != "2026-05-29 at 12:00 PM" || outdoor.Worst.PeriodEnds != "2026-05-29 at 6:00 PM" {
t.Fatalf("worst outdoor period = %#v, want afternoon period labels", outdoor.Worst)
}
planningOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TomorrowPlanning})
if err != nil {
t.Fatalf("BuildModule(tomorrow planning) error = %v", err)
}
planning := moduleValue[TomorrowPlanningModule](t, planningOutput)
if len(planning.MorningReadiness) == 0 || len(planning.CommuteSchoolWorkdayConcerns) == 0 || len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("tomorrow planning = %#v, want daily planning notes", planning)
}
data, err := json.Marshal(planningOutput.Value)
if err != nil {
t.Fatalf("marshal tomorrow planning: %v", err)
}
if !strings.Contains(string(data), "morning_readiness") || strings.Contains(string(data), "morningReadiness") {
t.Fatalf("tomorrow planning json = %s, want snake_case fields", string(data))
}
}
func TestDailyPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
if output.ID != module.DailyPlanning || output.StanzaName != "daily_planning" {
t.Fatalf("output = %#v, want daily planning stanza", output)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OvernightChangeWatch) == 0 {
t.Fatalf("daily planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.CommuteSchoolWorkdayConcerns, "Afternoon alert overlap needs attention.") {
t.Fatalf("CommuteSchoolWorkdayConcerns = %#v, want alert-overlap concern", planning.CommuteSchoolWorkdayConcerns)
}
if !containsString(planning.OvernightChangeWatch, "Watch for forecast timing or intensity adjustments overnight.") {
t.Fatalf("OvernightChangeWatch = %#v, want overnight fallback note", planning.OvernightChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal daily planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "overnight_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("daily planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "tomorrow_planning") || strings.Contains(jsonText, "morningReadiness") {
t.Fatalf("daily planning json = %s, want daily snake_case fields only", jsonText)
}
}
func TestDailyPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestDailyPlanningModuleHandlesMissingDailySummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := dailyModuleContext()
ctx.Derived.DailySummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DailyPlanning})
if err != nil {
t.Fatalf("BuildModule(daily planning) error = %v", err)
}
planning := moduleValue[DailyPlanningModule](t, output)
if len(planning.MorningReadiness) != 0 ||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
len(planning.OvernightChangeWatch) != 0 {
t.Fatalf("daily planning = %#v, want empty output without daily summary", planning)
}
}
func TestTodayPlanningModulePackagesPlanningFields(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := todayModuleContext()
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err != nil {
t.Fatalf("BuildModule(today planning) error = %v", err)
}
if output.ID != module.TodayPlanning || output.StanzaName != "today_planning" {
t.Fatalf("output = %#v, want today planning stanza", output)
}
planning := moduleValue[TodayPlanningModule](t, output)
if len(planning.MorningReadiness) == 0 ||
len(planning.CommuteSchoolWorkdayConcerns) == 0 ||
len(planning.OutdoorPlanning) == 0 ||
len(planning.LateDayChangeWatch) == 0 {
t.Fatalf("today planning = %#v, want populated planning fields", planning)
}
if !containsString(planning.MorningReadiness, "Morning precipitation chance peaks near 60%.") {
t.Fatalf("MorningReadiness = %#v, want precipitation readiness note", planning.MorningReadiness)
}
if !containsString(planning.OutdoorPlanning, "Best outdoor window: Overnight (cold risk).") ||
!containsString(planning.OutdoorPlanning, "Toughest outdoor window: Afternoon (high precipitation chance, gusty wind, alert overlap, heat risk).") {
t.Fatalf("OutdoorPlanning = %#v, want deterministic best and toughest windows", planning.OutdoorPlanning)
}
if !containsString(planning.LateDayChangeWatch, "Afternoon precipitation timing may shift; current peak is near 80%.") {
t.Fatalf("LateDayChangeWatch = %#v, want late-day change note", planning.LateDayChangeWatch)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("marshal today planning: %v", err)
}
jsonText := string(data)
for _, field := range []string{"morning_readiness", "commute_school_workday_concerns", "outdoor_planning", "late_day_change_watch"} {
if !strings.Contains(jsonText, field) {
t.Fatalf("today planning json = %s, want field %s", jsonText, field)
}
}
if strings.Contains(jsonText, "morningReadiness") || strings.Contains(jsonText, "lateDayChangeWatch") {
t.Fatalf("today planning json = %s, want snake_case fields", jsonText)
}
}
func TestTodayPlanningModuleRejectsUnsupportedReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) {
ctx := derivedModuleContext(id)
_, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
t.Fatalf("BuildModule(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestTodayPlanningModuleHandlesMissingDailySummary(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := todayModuleContext()
ctx.Derived.DailySummaries = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.TodayPlanning})
if err != nil {
t.Fatalf("BuildModule(today planning) error = %v", err)
}
planning := moduleValue[TodayPlanningModule](t, output)
if len(planning.MorningReadiness) != 0 ||
len(planning.CommuteSchoolWorkdayConcerns) != 0 ||
len(planning.OutdoorPlanning) != 0 ||
len(planning.LateDayChangeWatch) != 0 {
t.Fatalf("today planning = %#v, want empty output without daily summary", planning)
}
}
func TestDerivedModulesHandleMissingData(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := derivedModuleContext(report.Daily)
ctx.Derived.DailySummaries = nil
ctx.Derived.DaypartSummaries = nil
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDailySummary}); err == nil {
t.Fatal("BuildModule(derived daily summary) error = nil, want required facts error")
}
if _, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.DerivedDaypartSummaries}); err == nil {
t.Fatal("BuildModule(daypart summaries) error = nil, want required facts error")
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.OutdoorWindows})
if err != nil {
t.Fatalf("BuildModule(outdoor windows) error = %v", err)
}
windows := moduleValue[OutdoorWindowsModule](t, output)
if windows.Best != nil || windows.Worst != nil {
t.Fatalf("outdoor windows = %#v, want empty output with missing dayparts", windows)
}
}
func derivedModuleContext(id report.ID) ModuleContext {
generatedAt := mustParseModuleTime("2026-05-29T08:00:00-05:00")
definition := report.DefaultRegistry().MustLookup(id)
summary := forecast.DailySummary{
Date: "2026-05-29",
Period: timeutil.Period{
Start: mustParseModuleTime("2026-05-29T00:00:00-05:00"),
End: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
derivedDaypart("overnight", "2026-05-29T00:00:00-05:00", "2026-05-29T06:00:00-05:00", "Clear and cold", 31, nil, 0, 5),
derivedDaypart("morning", "2026-05-29T06:00:00-05:00", "2026-05-29T12:00:00-05:00", "Showers", 58, nil, 60, 15),
derivedDaypart("afternoon", "2026-05-29T12:00:00-05:00", "2026-05-29T18:00:00-05:00", "Thunderstorms with gusty wind", 96, floatPtr(101), 80, 42),
},
}
hours := []weatherdata.ForecastPeriod{
derivedHour("2026-05-29T00:00:00-05:00", "Clear and cold", 0, 31, nil, 5),
derivedHour("2026-05-29T08:00:00-05:00", "Showers", 60, 58, nil, 15),
derivedHour("2026-05-29T09:00:00-05:00", "Dry break", 20, 62, nil, 10),
derivedHour("2026-05-29T12:00:00-05:00", "Thunderstorms with gusty wind", 80, 96, floatPtr(101), 42),
derivedHour("2026-05-29T13:00:00-05:00", "Heavy rain", 70, 82, nil, 30),
derivedHour("2026-05-29T14:00:00-05:00", "Drying out", 20, 78, nil, 12),
}
narrative := []weatherdata.ForecastPeriod{
{
Name: "Today",
StartTime: mustParseModuleTime("2026-05-29T06:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
IsDay: boolPtr(true),
TextDescription: "Morning storms, then partly sunny.",
TemperatureFMax: floatPtr(88),
ProbabilityOfPrecipitationPercent: floatPtr(55),
},
{
Name: "Tonight",
StartTime: mustParseModuleTime("2026-05-29T18:00:00-05:00"),
EndTime: mustParseModuleTime("2026-05-30T00:00:00-05:00"),
IsDay: boolPtr(false),
TextDescription: "Clouds linger tonight.",
TemperatureFMin: floatPtr(64),
ProbabilityOfPrecipitationPercent: floatPtr(30),
},
}
summary.Dayparts[2].AlertOverlaps = []forecast.AlertOverlap{{Event: "Severe Thunderstorm Watch"}}
summary.NarrativePeriods = append([]weatherdata.ForecastPeriod(nil), narrative...)
return ModuleContext{
Resolved: report.Resolved{
Definition: definition,
GeneratedAt: generatedAt,
Timezone: "America/Chicago",
ValidPeriod: summary.Period,
},
Collected: facts.CollectedFacts{
Narrative: &weatherdata.ForecastRun{
IssuedAt: mustParseModuleTime("2026-05-29T10:30:00-05:00"),
Product: "narrative",
Periods: append([]weatherdata.ForecastPeriod(nil), narrative...),
},
},
Derived: facts.DerivedFacts{
ValidPeriodHourlyPeriods: hours,
ValidPeriodNarrativePeriods: narrative,
DailySummaries: []forecast.DailySummary{summary},
DaypartSummaries: append([]forecast.DaypartSummary(nil), summary.Dayparts...),
PrecipTiming: forecast.BuildPrecipTiming(hours),
},
Units: "us",
Timezone: "America/Chicago",
}
}
func todayModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Daily)
ctx.Resolved.Definition = report.Definition{
ID: report.Today,
Name: "Today Report",
PromptID: "weather.today_generated_text",
}
return ctx
}
func dailyModuleContext() ModuleContext {
ctx := derivedModuleContext(report.Tomorrow)
ctx.Resolved.Definition = report.Definition{
ID: report.Daily,
Name: "Daily Report",
PromptID: "weather.daily_generated_text",
}
return ctx
}
func derivedDaypart(name string, start string, end string, text string, temperature float64, apparent *float64, precip float64, gust float64) forecast.DaypartSummary {
hour := derivedHour(start, text, precip, temperature, apparent, gust)
return forecast.SummarizeDaypart(name, timeutil.Period{
Start: mustParseModuleTime(start),
End: mustParseModuleTime(end),
}, []weatherdata.ForecastPeriod{hour})
}
func derivedDaypartWithTemperatures(name string, start string, text string, temperatures ...float64) forecast.DaypartSummary {
startTime := mustParseModuleTime(start)
periods := make([]weatherdata.ForecastPeriod, 0, len(temperatures))
for index, temperature := range temperatures {
periodStart := startTime.Add(time.Duration(index) * time.Hour)
periods = append(periods, weatherdata.ForecastPeriod{
StartTime: periodStart,
EndTime: periodStart.Add(time.Hour),
TextDescription: text,
TemperatureF: floatPtr(temperature),
})
}
return forecast.SummarizeDaypart(name, timeutil.Period{
Start: startTime,
End: startTime.Add(time.Duration(len(temperatures)) * time.Hour),
}, periods)
}
func derivedHour(start string, text string, precip float64, temperature float64, apparent *float64, gust float64) weatherdata.ForecastPeriod {
startTime := mustParseModuleTime(start)
endTime := startTime.Add(time.Hour)
return weatherdata.ForecastPeriod{
StartTime: startTime,
EndTime: endTime,
TextDescription: text,
TemperatureF: floatPtr(temperature),
ApparentTemperatureF: apparent,
ProbabilityOfPrecipitationPercent: floatPtr(precip),
WindGustMph: floatPtr(gust),
}
}
func floatPtr(value float64) *float64 {
return &value
}
func boolPtr(value bool) *bool {
return &value
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,247 @@
package briefing
import (
"strings"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const DefaultHourlyForecastPrecipMentionProbabilityThreshold = 20
type HourlyForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPeriod `json:"periods,omitempty"`
}
type HourlyForecastPromptExport struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []HourlyForecastPromptPeriod `json:"periods,omitempty"`
}
type HourlyForecastPeriod struct {
HourLabel string `json:"hour_label,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TextDescriptionLower string `json:"text_description_lower,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
MentionPrecipitation bool `json:"mention_precipitation,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
UVIndex *float64 `json:"uv_index,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
}
type HourlyForecastPromptPeriod struct {
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Name string `json:"name,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
ConditionCode *int `json:"condition_code,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
DewpointC *float64 `json:"dewpoint_c,omitempty"`
DewpointF *float64 `json:"dewpoint_f,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
BarometricPressurePa *float64 `json:"barometric_pressure_pa,omitempty"`
BarometricPressureInHg *float64 `json:"barometric_pressure_in_hg,omitempty"`
VisibilityMeters *float64 `json:"visibility_meters,omitempty"`
VisibilityMiles *float64 `json:"visibility_miles,omitempty"`
ApparentTemperatureC *float64 `json:"apparent_temperature_c,omitempty"`
ApparentTemperatureF *float64 `json:"apparent_temperature_f,omitempty"`
CloudCoverPercent *float64 `json:"cloud_cover_percent,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
PrecipitationAmountMm *float64 `json:"precipitation_amount_mm,omitempty"`
PrecipitationAmountIn *float64 `json:"precipitation_amount_in,omitempty"`
SnowfallDepthMM *float64 `json:"snowfall_depth_mm,omitempty"`
SnowfallDepthIn *float64 `json:"snowfall_depth_in,omitempty"`
UVIndex *float64 `json:"uv_index,omitempty"`
RelativeHumidityPercent *float64 `json:"relative_humidity_percent,omitempty"`
}
func buildHourlyForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
hourly := ctx.Collected.Hourly
if hourly == nil || len(ctx.Derived.ValidPeriodHourlyPeriods) == 0 {
return nil, nil
}
value := HourlyForecastModule{
Product: hourly.Product,
IssuedAt: hourly.IssuedAt,
UpdatedAt: copyTime(hourly.UpdatedAt),
SourceLocation: hourly.LocationName,
SourceLocationID: hourly.LocationID,
Periods: hourlyForecastPeriods(ctx.Derived.ValidPeriodHourlyPeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.HourlyForecast, StanzaName: "hourly_forecast", Value: value}, nil
}
func exportHourlyForecastPromptValue(value any) (any, error) {
rich, ok := value.(HourlyForecastModule)
if !ok {
return nil, unexpectedPromptExportValue(value, HourlyForecastModule{})
}
return HourlyForecastPromptExport{
Product: rich.Product,
IssuedAt: rich.IssuedAt,
UpdatedAt: copyTime(rich.UpdatedAt),
SourceLocation: rich.SourceLocation,
SourceLocationID: rich.SourceLocationID,
Periods: hourlyForecastPromptPeriods(rich.Periods),
}, nil
}
func hourlyForecastPromptPeriods(periods []HourlyForecastPeriod) []HourlyForecastPromptPeriod {
if len(periods) == 0 {
return nil
}
out := make([]HourlyForecastPromptPeriod, 0, len(periods))
for _, period := range periods {
out = append(out, HourlyForecastPromptPeriod{
PeriodBegins: period.PeriodBegins,
PeriodEnds: period.PeriodEnds,
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
DewpointC: copyFloat(period.DewpointC),
DewpointF: copyFloat(period.DewpointF),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: period.WindDirection,
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
VisibilityMeters: copyFloat(period.VisibilityMeters),
VisibilityMiles: copyFloat(period.VisibilityMiles),
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
UVIndex: copyFloat(period.UVIndex),
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
})
}
return out
}
func hourlyForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []HourlyForecastPeriod {
return hourlyForecastPeriodsWithPrecipMentionThreshold(periods, timezone, DefaultHourlyForecastPrecipMentionProbabilityThreshold)
}
func hourlyForecastPeriodsWithPrecipMentionThreshold(periods []weatherdata.ForecastPeriod, timezone string, threshold float64) []HourlyForecastPeriod {
out := make([]HourlyForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, HourlyForecastPeriod{
HourLabel: hourMinuteLabel(period.StartTime, timezone),
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
Name: period.Name,
IsDay: copyBool(period.IsDay),
ConditionCode: copyInt(period.ConditionCode),
TextDescription: period.TextDescription,
TextDescriptionLower: strings.ToLower(period.TextDescription),
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
DewpointC: copyFloat(period.DewpointC),
DewpointF: copyFloat(period.DewpointF),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
BarometricPressurePa: copyFloat(period.BarometricPressurePa),
BarometricPressureInHg: copyFloat(period.BarometricPressureInHg),
VisibilityMeters: copyFloat(period.VisibilityMeters),
VisibilityMiles: copyFloat(period.VisibilityMiles),
ApparentTemperatureC: copyFloat(period.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(period.ApparentTemperatureF),
CloudCoverPercent: copyFloat(period.CloudCoverPercent),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
MentionPrecipitation: mentionHourlyForecastPrecipitation(period.ProbabilityOfPrecipitationPercent, threshold),
PrecipitationAmountMm: copyFloat(period.PrecipitationAmountMm),
PrecipitationAmountIn: copyFloat(period.PrecipitationAmountIn),
SnowfallDepthMM: copyFloat(period.SnowfallDepthMM),
SnowfallDepthIn: copyFloat(period.SnowfallDepthIn),
UVIndex: copyFloat(period.UVIndex),
RelativeHumidityPercent: copyFloat(period.RelativeHumidityPercent),
})
}
return out
}
func mentionHourlyForecastPrecipitation(probability *float64, threshold float64) bool {
return probability != nil && *probability >= threshold
}
func (v HourlyForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View File

@@ -0,0 +1,62 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type MetadataModule struct {
RunID string `json:"run_id"`
ReportID report.ID `json:"report_id"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"prompt_id"`
GeneratedAt time.Time `json:"generated_at"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"valid_period"`
Location *LocationContext `json:"location,omitempty"`
SourceWarnings []SourceWarningSummary `json:"source_warnings,omitempty"`
}
type SourceWarningSummary struct {
Source string `json:"source"`
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message"`
CompletenessImpact string `json:"completeness_impact,omitempty"`
}
func buildMetadataModule(ctx ModuleContext, _ any) (*module.Output, error) {
metadata := ctx.Resolved.Metadata()
value := MetadataModule{
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
PromptID: metadata.PromptID,
GeneratedAt: metadata.GeneratedAt,
Units: ctx.Units,
Timezone: ctx.Timezone,
ValidPeriod: metadata.ValidPeriod,
Location: copyLocation(ctx.Location),
SourceWarnings: sourceWarningSummaries(ctx.Collected.SourceWarnings),
}
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: value}, nil
}
func sourceWarningSummaries(warnings []weatherdata.SourceWarning) []SourceWarningSummary {
out := make([]SourceWarningSummary, 0, len(warnings))
for _, warning := range warnings {
out = append(out, SourceWarningSummary{
Source: warning.Source,
Code: warning.Code,
Severity: warning.Severity,
Message: warning.Message,
CompletenessImpact: warning.CompletenessImpact,
})
}
return out
}

View File

@@ -0,0 +1,177 @@
package briefing
import (
"fmt"
"math"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func rangeLabel(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
if value.Min != nil && value.Max != nil {
low := roundedInt(value.Min)
high := roundedInt(value.Max)
if low != nil && high != nil && *low == *high {
return fmt.Sprintf("%d", *low)
}
return fmt.Sprintf("%d-%d", *low, *high)
}
if value.Min != nil {
low := roundedInt(value.Min)
return fmt.Sprintf("%d", *low)
}
high := roundedInt(value.Max)
return fmt.Sprintf("%d", *high)
}
func daypartApparentRangeLabel(value forecast.Range) string {
if value.Min == nil && value.Max == nil {
return ""
}
return rangeLabel(value)
}
func roundedInt(value *float64) *int {
if value == nil {
return nil
}
rounded := int(*value + 0.5)
if *value < 0 {
rounded = int(*value - 0.5)
}
return &rounded
}
func windDirectionLabel(degrees *float64) string {
if degrees == nil {
return ""
}
labels := []string{"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"}
normalized := math.Mod(*degrees, 360)
if normalized < 0 {
normalized += 360
}
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
return labels[sector]
}
func windDirectionTextLabel(degrees *float64) string {
if degrees == nil {
return ""
}
labels := []string{
"north",
"north-northeast",
"northeast",
"east-northeast",
"east",
"east-southeast",
"southeast",
"south-southeast",
"south",
"south-southwest",
"southwest",
"west-southwest",
"west",
"west-northwest",
"northwest",
"north-northwest",
}
normalized := math.Mod(*degrees, 360)
if normalized < 0 {
normalized += 360
}
sector := int(math.Floor((normalized+11.25)/22.5)) % len(labels)
return labels[sector]
}
func timedClockLabel(value *forecast.TimedValue, timezone string) string {
if value == nil {
return ""
}
return clockLabel(value.Time, timezone)
}
func friendlyPeriodBeginsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return friendlyDateTimeLabel(period.Start, timezone)
}
func friendlyPeriodEndsLabel(period timeutil.Period, timezone string) string {
if !period.IsValid() {
return ""
}
return friendlyDateTimeLabel(period.End, timezone)
}
func friendlyDateTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("2006-01-02 at 3:04 PM")
}
func friendlyMonthDayTimeLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("January 2 at 3:04 PM")
}
func friendlyDateLabel(date string, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
parsed, err := time.ParseInLocation(timeutil.DateLayout, date, location)
if err != nil {
return date
}
return parsed.Format("Monday, January 2, 2006")
}
func localDateLabel(value time.Time, timezone string) string {
if value.IsZero() {
return ""
}
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format(timeutil.DateLayout)
}
func clockLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
label := value.In(location).Format("3 PM")
if label == "12 AM" && value.In(location).Minute() == 0 {
return "12 AM"
}
return label
}
func hourMinuteLabel(value time.Time, timezone string) string {
location, err := timeutil.LoadLocation(timezone)
if err != nil {
location = time.UTC
}
return value.In(location).Format("3:04 PM")
}

View File

@@ -0,0 +1,51 @@
package briefing
import "testing"
func TestWindDirectionLabelUsesSixteenPointCompass(t *testing.T) {
tests := []struct {
name string
degrees *float64
want string
}{
{name: "nil", degrees: nil, want: ""},
{name: "north", degrees: floatPtr(0), want: "N"},
{name: "below first boundary", degrees: floatPtr(11.24), want: "N"},
{name: "at first boundary", degrees: floatPtr(11.25), want: "NNE"},
{name: "northeast", degrees: floatPtr(45), want: "NE"},
{name: "south", degrees: floatPtr(180), want: "S"},
{name: "wrap to north", degrees: floatPtr(348.75), want: "N"},
{name: "full rotation", degrees: floatPtr(360), want: "N"},
{name: "negative normalizes", degrees: floatPtr(-45), want: "NW"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := windDirectionLabel(tt.degrees); got != tt.want {
t.Fatalf("windDirectionLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
}
})
}
}
func TestWindDirectionTextLabelUsesLowercaseCompassText(t *testing.T) {
tests := []struct {
name string
degrees *float64
want string
}{
{name: "nil", degrees: nil, want: ""},
{name: "north", degrees: floatPtr(0), want: "north"},
{name: "north northeast", degrees: floatPtr(11.25), want: "north-northeast"},
{name: "northwest", degrees: floatPtr(315), want: "northwest"},
{name: "negative normalizes", degrees: floatPtr(-45), want: "northwest"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := windDirectionTextLabel(tt.degrees); got != tt.want {
t.Fatalf("windDirectionTextLabel(%v) = %q, want %q", tt.degrees, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,424 @@
package briefing
import (
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type ModuleContext struct {
Resolved report.Resolved
Collected facts.CollectedFacts
Derived facts.DerivedFacts
Units string
Timezone string
Location *LocationContext
}
type ModuleBuilder func(ModuleContext, any) (*module.Output, error)
type ModulePromptExporter func(value any) (any, error)
type ModuleDefinition struct {
ID module.ID
StanzaName string
DefaultOptions any
RequiredCollected []module.FactRequirement
RequiredDerived []module.FactRequirement
SupportedReports []report.ID
MissingData module.MissingDataBehavior
AllowDuplicate bool
Builder ModuleBuilder
PromptExporter ModulePromptExporter
}
type ModuleRegistry struct {
definitions map[module.ID]ModuleDefinition
}
func unexpectedPromptExportValue(got any, want any) error {
return fmt.Errorf("value has type %T, want %T", got, want)
}
func DefaultModuleRegistry() (ModuleRegistry, error) {
return NewModuleRegistry(defaultModuleDefinitions())
}
func MustDefaultModuleRegistry() ModuleRegistry {
registry, err := DefaultModuleRegistry()
if err != nil {
panic(err)
}
return registry
}
func NewModuleRegistry(definitions []ModuleDefinition) (ModuleRegistry, error) {
registry := ModuleRegistry{definitions: map[module.ID]ModuleDefinition{}}
seenStanzas := map[string]module.ID{}
for i, definition := range definitions {
if definition.ID == "" {
return ModuleRegistry{}, fmt.Errorf("module definition[%d].id is required", i)
}
if definition.StanzaName == "" {
return ModuleRegistry{}, fmt.Errorf("module %q stanza name is required", definition.ID)
}
if _, ok := registry.definitions[definition.ID]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate module definition %q", definition.ID)
}
if definition.Builder == nil {
return ModuleRegistry{}, fmt.Errorf("module %q has no builder", definition.ID)
}
if definition.MissingData == module.MissingDataWarn {
return ModuleRegistry{}, fmt.Errorf("module %q uses unsupported missing data behavior %q", definition.ID, definition.MissingData)
}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return ModuleRegistry{}, fmt.Errorf("duplicate stanza name %q for modules %q and %q", definition.StanzaName, existingID, definition.ID)
}
seenStanzas[definition.StanzaName] = definition.ID
registry.definitions[definition.ID] = definition
}
return registry, nil
}
func (r ModuleRegistry) Lookup(id module.ID) (ModuleDefinition, error) {
definition, ok := r.definitions[id]
if !ok {
return ModuleDefinition{}, fmt.Errorf("unknown module %q", id)
}
return definition, nil
}
func (r ModuleRegistry) BuildModule(ctx ModuleContext, item module.ConfigItem) (*module.Output, error) {
definition, err := r.Lookup(item.ID)
if err != nil {
return nil, err
}
if !definition.SupportsReport(ctx.Resolved.Definition.ID) {
return nil, fmt.Errorf("module %q is not compatible with report %q", item.ID, ctx.Resolved.Definition.ID)
}
if err := definition.ValidateOptions(item.Options); err != nil {
return nil, err
}
if definition.Builder == nil {
return nil, fmt.Errorf("module %q has no builder", item.ID)
}
missing := missingRequirements(definition, ctx)
if len(missing) > 0 {
switch definition.MissingData {
case module.MissingDataOmit:
return nil, nil
case module.MissingDataError:
return nil, fmt.Errorf("module %q missing required facts: %s", item.ID, strings.Join(missing, ", "))
case module.MissingDataEmpty:
case module.MissingDataWarn:
return nil, fmt.Errorf("module %q uses unsupported missing data behavior %q", item.ID, definition.MissingData)
default:
return nil, fmt.Errorf("module %q has unknown missing data behavior %q", item.ID, definition.MissingData)
}
}
options := item.Options
if options == nil {
options = definition.DefaultOptions
}
output, err := definition.Builder(ctx, options)
if err != nil {
return nil, err
}
if output == nil {
return nil, nil
}
if output.ID != definition.ID {
return nil, fmt.Errorf("module %q produced output id %q", definition.ID, output.ID)
}
if output.StanzaName != definition.StanzaName {
return nil, fmt.Errorf("module %q produced stanza %q, want %q", definition.ID, output.StanzaName, definition.StanzaName)
}
if definition.PromptExporter == nil {
output.PromptValue = output.Value
return output, nil
}
promptValue, err := definition.PromptExporter(output.Value)
if err != nil {
return nil, fmt.Errorf("module %q stanza %q prompt export: %w", definition.ID, definition.StanzaName, err)
}
output.PromptValue = promptValue
return output, nil
}
func missingRequirements(definition ModuleDefinition, ctx ModuleContext) []string {
var missing []string
for _, requirement := range definition.RequiredCollected {
if !collectedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
}
}
for _, requirement := range definition.RequiredDerived {
if !derivedFactAvailable(requirement, ctx) {
missing = append(missing, string(requirement))
}
}
return missing
}
func collectedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.CollectedCurrentConditions:
return ctx.Collected.Current != nil
case module.CollectedNarrativeForecast:
return ctx.Collected.Narrative != nil
case module.CollectedHourlyForecast:
return ctx.Collected.Hourly != nil
case module.CollectedAlerts:
return ctx.Collected.Alerts != nil
case module.CollectedDiscussion:
return ctx.Collected.Discussion != nil
case module.CollectedWeatherStory:
return ctx.Collected.WeatherStory != nil
case module.CollectedSPCConvectiveOutlooks:
return ctx.Collected.SPCConvectiveOutlooks != nil
case module.CollectedSourceMetadata:
return len(ctx.Collected.SourceProvenance) > 0 || len(ctx.Collected.SourceWarnings) > 0
default:
return false
}
}
func derivedFactAvailable(requirement module.FactRequirement, ctx ModuleContext) bool {
switch requirement {
case module.RequiresDerivedHourlyPeriods:
return len(ctx.Derived.ValidPeriodHourlyPeriods) > 0
case module.RequiresDerivedNarrativePeriods:
return len(ctx.Derived.ValidPeriodNarrativePeriods) > 0
case module.RequiresDerivedAlertOverlaps:
return true
case module.RequiresDerivedDailySummaries:
return len(ctx.Derived.DailySummaries) > 0
case module.RequiresDerivedDaypartSummaries:
return len(ctx.Derived.DaypartSummaries) > 0
case module.RequiresDerivedPrecipTiming:
return true
case module.RequiresDerivedSPCConvectiveOutlooks:
return ctx.Derived.SPCConvectiveOutlooks != nil
default:
return false
}
}
func (r ModuleRegistry) ValidateComposition(reportID report.ID, items []module.ConfigItem) error {
seenModules := map[module.ID]struct{}{}
seenStanzas := map[string]module.ID{}
for i, item := range items {
definition, err := r.Lookup(item.ID)
if err != nil {
return fmt.Errorf("modules[%d]: %w", i, err)
}
if _, ok := seenModules[item.ID]; ok && !definition.AllowDuplicate {
return fmt.Errorf("modules[%d]: duplicate module %q", i, item.ID)
}
seenModules[item.ID] = struct{}{}
if existingID, ok := seenStanzas[definition.StanzaName]; ok {
return fmt.Errorf("modules[%d]: duplicate stanza name %q for modules %q and %q", i, definition.StanzaName, existingID, item.ID)
}
seenStanzas[definition.StanzaName] = item.ID
if !definition.SupportsReport(reportID) {
return fmt.Errorf("modules[%d]: module %q is not compatible with report %q", i, item.ID, reportID)
}
if err := definition.ValidateOptions(item.Options); err != nil {
return fmt.Errorf("modules[%d]: %w", i, err)
}
}
return nil
}
func (d ModuleDefinition) SupportsReport(id report.ID) bool {
if len(d.SupportedReports) == 0 {
return true
}
for _, supported := range d.SupportedReports {
if supported == id {
return true
}
}
return false
}
func (d ModuleDefinition) ValidateOptions(options any) error {
if options == nil {
return nil
}
if d.DefaultOptions == nil {
return fmt.Errorf("module %q does not accept options", d.ID)
}
want := reflect.TypeOf(d.DefaultOptions)
got := reflect.TypeOf(options)
if got == want {
return nil
}
if got.Kind() == reflect.Pointer && got.Elem() == want {
return nil
}
return fmt.Errorf("module %q options have type %s, want %s", d.ID, got, want)
}
func defaultModuleDefinitions() []ModuleDefinition {
allReports := []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly}
daypartReports := []report.ID{report.Daily, report.Today, report.Tomorrow}
return []ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
DefaultOptions: module.MetadataOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSourceMetadata},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildMetadataModule,
},
{
ID: module.CurrentConditions,
StanzaName: "current_conditions",
DefaultOptions: module.CurrentConditionsOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedCurrentConditions},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildCurrentConditionsModule,
PromptExporter: exportCurrentConditionsPromptValue,
},
{
ID: module.NarrativeForecast,
StanzaName: "narrative_forecast",
DefaultOptions: module.NarrativeForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedNarrativeForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedNarrativePeriods},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataOmit,
Builder: buildNarrativeForecastModule,
},
{
ID: module.HourlyForecast,
StanzaName: "hourly_forecast",
DefaultOptions: module.HourlyForecastOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedHourlyForecast},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedHourlyPeriods},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow, report.Hourly},
MissingData: module.MissingDataOmit,
Builder: buildHourlyForecastModule,
PromptExporter: exportHourlyForecastPromptValue,
},
{
ID: module.DerivedDailySummary,
StanzaName: "derived_daily_summary",
DefaultOptions: module.DerivedDailySummaryOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries, module.RequiresDerivedPrecipTiming},
SupportedReports: []report.ID{report.Daily, report.Today, report.Tomorrow},
MissingData: module.MissingDataError,
Builder: buildDerivedDailySummaryModule,
},
{
ID: module.DerivedDaypartSummaries,
StanzaName: "derived_daypart_summaries",
DefaultOptions: module.DerivedDaypartSummariesOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
SupportedReports: daypartReports,
MissingData: module.MissingDataError,
Builder: buildDerivedDaypartSummariesModule,
PromptExporter: exportDerivedDaypartSummariesPromptValue,
},
{
ID: module.PrecipTiming,
StanzaName: "precip_timing",
DefaultOptions: module.PrecipTimingOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedPrecipTiming},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildPrecipTimingModule,
},
{
ID: module.AlertDigest,
StanzaName: "alert_digest",
DefaultOptions: module.AlertDigestOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedAlerts},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedAlertOverlaps},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildAlertDigestModule,
},
{
ID: module.SPCConvectiveOutlooks,
StanzaName: string(module.SPCConvectiveOutlooks),
DefaultOptions: module.SPCConvectiveOutlooksOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataEmpty,
Builder: buildSPCConvectiveOutlooksModule,
},
{
ID: module.AreaForecastDiscussion,
StanzaName: "area_forecast_discussion",
DefaultOptions: module.AreaForecastDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedDiscussion},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildAreaForecastDiscussionModule,
},
{
ID: module.SPCConvectiveDiscussion,
StanzaName: string(module.SPCConvectiveDiscussion),
DefaultOptions: module.SPCConvectiveDiscussionOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedSPCConvectiveOutlooks},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedSPCConvectiveOutlooks},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildSPCConvectiveDiscussionModule,
},
{
ID: module.WeatherStory,
StanzaName: "weather_story",
DefaultOptions: module.WeatherStoryOptions{},
RequiredCollected: []module.FactRequirement{module.CollectedWeatherStory},
SupportedReports: allReports,
MissingData: module.MissingDataOmit,
Builder: buildWeatherStoryModule,
},
{
ID: module.OutdoorWindows,
StanzaName: "outdoor_windows",
DefaultOptions: module.OutdoorWindowsOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDaypartSummaries},
SupportedReports: daypartReports,
MissingData: module.MissingDataEmpty,
Builder: buildOutdoorWindowsModule,
},
{
ID: module.TodayPlanning,
StanzaName: "today_planning",
DefaultOptions: module.TodayPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Today},
MissingData: module.MissingDataEmpty,
Builder: buildTodayPlanningModule,
},
{
ID: module.TomorrowPlanning,
StanzaName: "tomorrow_planning",
DefaultOptions: module.TomorrowPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Tomorrow},
MissingData: module.MissingDataEmpty,
Builder: buildTomorrowPlanningModule,
},
{
ID: module.DailyPlanning,
StanzaName: "daily_planning",
DefaultOptions: module.DailyPlanningOptions{},
RequiredDerived: []module.FactRequirement{module.RequiresDerivedDailySummaries},
SupportedReports: []report.ID{report.Daily},
MissingData: module.MissingDataEmpty,
Builder: buildDailyPlanningModule,
},
}
}

View File

@@ -0,0 +1,505 @@
package briefing
import (
"encoding/json"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/facts"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestDefaultModuleRegistryValidatesReportDefaults(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
if err := registry.ValidateComposition(definition.ID, definition.Modules); err != nil {
t.Fatalf("ValidateComposition(%s) error = %v", definition.ID, err)
}
for _, item := range definition.Modules {
moduleDefinition, err := registry.Lookup(item.ID)
if err != nil {
t.Fatalf("Lookup(%s) error = %v", item.ID, err)
}
if moduleDefinition.Builder == nil {
t.Fatalf("report %s module %s has no builder", definition.ID, item.ID)
}
}
}
}
func TestDefaultReportModulesBuildSnapshots(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, definition := range report.DefaultRegistry().All() {
t.Run(string(definition.ID), func(t *testing.T) {
ctx := derivedModuleContext(definition.ID)
var outputs []module.Output
for _, item := range definition.Modules {
output, err := registry.BuildModule(ctx, item)
if err != nil {
t.Fatalf("BuildModule(%s) error = %v", item.ID, err)
}
if output != nil {
if output.DataPackageValue() == nil {
t.Fatalf("BuildModule(%s) data package value = nil", item.ID)
}
outputs = append(outputs, *output)
}
}
snapshot, err := module.NewSnapshot(outputs)
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
if len(snapshot.Outputs) == 0 {
t.Fatal("snapshot outputs = 0, want default report modules")
}
})
}
}
func TestDefaultModuleDefinitionsDeclarePromptExportPolicy(t *testing.T) {
customExporters := map[module.ID]struct{}{
module.CurrentConditions: {},
module.HourlyForecast: {},
module.DerivedDaypartSummaries: {},
}
passThroughExporters := map[module.ID]struct{}{
module.Metadata: {},
module.NarrativeForecast: {},
module.DerivedDailySummary: {},
module.PrecipTiming: {},
module.AlertDigest: {},
module.SPCConvectiveOutlooks: {},
module.AreaForecastDiscussion: {},
module.SPCConvectiveDiscussion: {},
module.WeatherStory: {},
module.OutdoorWindows: {},
module.TodayPlanning: {},
module.TomorrowPlanning: {},
module.DailyPlanning: {},
}
for _, definition := range defaultModuleDefinitions() {
_, custom := customExporters[definition.ID]
_, passThrough := passThroughExporters[definition.ID]
if custom == passThrough {
t.Fatalf("module %q exporter policy custom=%v passThrough=%v, want exactly one policy", definition.ID, custom, passThrough)
}
if custom && definition.PromptExporter == nil {
t.Fatalf("module %q PromptExporter = nil, want custom prompt exporter", definition.ID)
}
if passThrough && definition.PromptExporter != nil {
t.Fatalf("module %q PromptExporter is set, want default pass-through", definition.ID)
}
}
}
func TestModuleRegistryAddsPassThroughPromptValue(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: testRegistryValue{Message: "rich"},
}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil {
t.Fatal("BuildModule() output = nil, want output")
}
if output.PromptValue != output.Value {
t.Fatalf("PromptValue = %#v, want pass-through rich value %#v", output.PromptValue, output.Value)
}
if output.DataPackageValue() != output.Value {
t.Fatalf("DataPackageValue() = %#v, want rich value", output.DataPackageValue())
}
}
func TestModuleRegistryAddsCustomPromptValue(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{
ID: module.Metadata,
StanzaName: "metadata",
Value: testRegistryValue{Message: "rich"},
}, nil
},
PromptExporter: func(value any) (any, error) {
rich, ok := value.(testRegistryValue)
if !ok {
return nil, errors.New("unexpected rich value type")
}
return testRegistryValue{Message: rich.Message + " prompt"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
got, ok := output.PromptValue.(testRegistryValue)
if !ok {
t.Fatalf("PromptValue type = %T, want testRegistryValue", output.PromptValue)
}
if got.Message != "rich prompt" {
t.Fatalf("PromptValue = %#v, want custom prompt value", got)
}
if output.DataPackageValue() != output.PromptValue {
t.Fatalf("DataPackageValue() = %#v, want custom prompt value", output.DataPackageValue())
}
if output.Value.(testRegistryValue).Message != "rich" {
t.Fatalf("Value = %#v, want rich value unchanged", output.Value)
}
}
func TestModuleRegistryWrapsPromptExporterErrors(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
return nil, errors.New("unsupported value")
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err == nil ||
!strings.Contains(err.Error(), `module "metadata" stanza "metadata" prompt export`) ||
!strings.Contains(err.Error(), "unsupported value") {
t.Fatalf("BuildModule() error = %v, want wrapped exporter error", err)
}
}
func TestModuleRegistryValidatesOutputBeforePromptExport(t *testing.T) {
called := false
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.CurrentConditions, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
called = true
return testRegistryValue{Message: "prompt"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
_, err = registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err == nil || !strings.Contains(err.Error(), `module "metadata" produced output id "current_conditions"`) {
t.Fatalf("BuildModule() error = %v, want output id validation error", err)
}
if called {
t.Fatal("PromptExporter called before output validation")
}
}
func TestModuleRegistryPromptValueIsNotPersistedInSnapshotJSON(t *testing.T) {
registry, err := NewModuleRegistry([]ModuleDefinition{
{
ID: module.Metadata,
StanzaName: "metadata",
Builder: func(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: testRegistryValue{Message: "rich"}}, nil
},
PromptExporter: func(any) (any, error) {
return testRegistryValue{Message: "prompt-only"}, nil
},
},
})
if err != nil {
t.Fatalf("NewModuleRegistry() error = %v", err)
}
output, err := registry.BuildModule(testRegistryModuleContext(), module.ConfigItem{ID: module.Metadata})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
snapshot, err := module.NewSnapshot([]module.Output{*output})
if err != nil {
t.Fatalf("NewSnapshot() error = %v", err)
}
data, err := json.Marshal(snapshot)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
if !strings.Contains(text, `"message":"rich"`) {
t.Fatalf("snapshot JSON missing rich value: %s", text)
}
if strings.Contains(text, "prompt-only") || strings.Contains(text, "promptValue") || strings.Contains(text, "PromptValue") {
t.Fatalf("snapshot JSON includes runtime-only prompt value: %s", text)
}
}
func TestDefaultAreaForecastDiscussionModuleOptions(t *testing.T) {
tests := []struct {
id report.ID
wantSections string
}{
{id: report.Daily, wantSections: "long_term"},
{id: report.Hourly, wantSections: "key_messages,short_term"},
}
registry := report.DefaultRegistry()
for _, tt := range tests {
t.Run(string(tt.id), func(t *testing.T) {
definition := registry.MustLookup(tt.id)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
options, ok := item.Options.(module.AreaForecastDiscussionOptions)
if !ok {
t.Fatalf("AFD options type = %T, want AreaForecastDiscussionOptions", item.Options)
}
if strings.Join(options.Sections, ",") != tt.wantSections {
t.Fatalf("AFD sections = %#v, want %s", options.Sections, tt.wantSections)
}
}
if !found {
t.Fatal("default modules missing area_forecast_discussion")
}
})
}
for _, id := range []report.ID{report.Today, report.Tomorrow} {
t.Run(string(id), func(t *testing.T) {
definition := registry.MustLookup(id)
var found bool
for _, item := range definition.Modules {
if item.ID != module.AreaForecastDiscussion {
continue
}
found = true
if item.Options != nil {
t.Fatalf("AFD options = %#v, want default all sections", item.Options)
}
}
if !found {
t.Fatal("default modules missing area_forecast_discussion")
}
})
}
}
func TestModuleRegistryRejectsUnknownModule(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.ID("unknown")}})
if err == nil || !strings.Contains(err.Error(), `unknown module "unknown"`) {
t.Fatalf("error = %v, want unknown module", err)
}
}
func TestModuleRegistryRejectsDuplicateModuleIDs(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata},
{ID: module.Metadata},
})
if err == nil || !strings.Contains(err.Error(), `duplicate module "metadata"`) {
t.Fatalf("error = %v, want duplicate module", err)
}
}
func TestModuleRegistryRejectsDuplicateStanzaNames(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, Builder: noopModuleBuilder},
{ID: module.CurrentConditions, StanzaName: "metadata", DefaultOptions: module.CurrentConditionsOptions{}, Builder: noopModuleBuilder},
})
if err == nil || !strings.Contains(err.Error(), `duplicate stanza name "metadata"`) {
t.Fatalf("error = %v, want duplicate stanza name", err)
}
}
func TestModuleRegistryRejectsIncompatibleReports(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.TomorrowPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "tomorrow_planning" is not compatible with report "daily"`) {
t.Fatalf("error = %v, want incompatible report", err)
}
}
func TestModuleRegistryValidatesTodayPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Today, []module.ConfigItem{{ID: module.TodayPlanning}}); err != nil {
t.Fatalf("ValidateComposition(today) error = %v", err)
}
for _, id := range []report.ID{report.Tomorrow, report.Daily} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.TodayPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "today_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistryValidatesDailyPlanningSupport(t *testing.T) {
registry := MustDefaultModuleRegistry()
if err := registry.ValidateComposition(report.Daily, []module.ConfigItem{{ID: module.DailyPlanning}}); err != nil {
t.Fatalf("ValidateComposition(daily) error = %v", err)
}
for _, id := range []report.ID{report.Today, report.Tomorrow, report.Hourly} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(id, []module.ConfigItem{{ID: module.DailyPlanning}})
if err == nil || !strings.Contains(err.Error(), `module "daily_planning" is not compatible with report`) {
t.Fatalf("ValidateComposition(%s) error = %v, want incompatible report", id, err)
}
})
}
}
func TestModuleRegistrySupportsTodayEligibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Today, []module.ConfigItem{
{ID: module.Metadata},
{ID: module.CurrentConditions},
{ID: module.NarrativeForecast},
{ID: module.HourlyForecast},
{ID: module.DerivedDailySummary},
{ID: module.DerivedDaypartSummaries},
{ID: module.PrecipTiming},
{ID: module.AlertDigest},
{ID: module.SPCConvectiveOutlooks},
{ID: module.AreaForecastDiscussion},
{ID: module.SPCConvectiveDiscussion},
{ID: module.WeatherStory},
{ID: module.OutdoorWindows},
})
if err != nil {
t.Fatalf("ValidateComposition(today eligible modules) error = %v", err)
}
}
func TestModuleRegistryRejectsHourlyIncompatibleModules(t *testing.T) {
registry := MustDefaultModuleRegistry()
for _, id := range []module.ID{
module.NarrativeForecast,
module.DerivedDailySummary,
module.DerivedDaypartSummaries,
module.OutdoorWindows,
module.TodayPlanning,
module.TomorrowPlanning,
module.DailyPlanning,
} {
t.Run(string(id), func(t *testing.T) {
err := registry.ValidateComposition(report.Hourly, []module.ConfigItem{{ID: id}})
if err == nil || !strings.Contains(err.Error(), `not compatible with report "hourly"`) {
t.Fatalf("ValidateComposition() error = %v, want incompatible hourly module", err)
}
})
}
}
func TestModuleRegistryRejectsDefinitionsWithoutBuilders(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}},
})
if err == nil || !strings.Contains(err.Error(), `module "metadata" has no builder`) {
t.Fatalf("error = %v, want missing builder", err)
}
}
func TestModuleRegistryRejectsUnsupportedMissingDataWarn(t *testing.T) {
_, err := NewModuleRegistry([]ModuleDefinition{
{ID: module.Metadata, StanzaName: "metadata", DefaultOptions: module.MetadataOptions{}, MissingData: module.MissingDataWarn, Builder: noopModuleBuilder},
})
if err == nil || !strings.Contains(err.Error(), `unsupported missing data behavior`) {
t.Fatalf("error = %v, want unsupported missing-data behavior", err)
}
}
func TestModuleRegistryRejectsInvalidOptionShapes(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.CurrentConditionsOptions{}},
})
if err == nil || !strings.Contains(err.Error(), `module "metadata" options have type module.CurrentConditionsOptions, want module.MetadataOptions`) {
t.Fatalf("error = %v, want invalid option shape", err)
}
}
func TestModuleRegistryAcceptsTypedOptions(t *testing.T) {
registry := MustDefaultModuleRegistry()
err := registry.ValidateComposition(report.Daily, []module.ConfigItem{
{ID: module.Metadata, Options: module.MetadataOptions{}},
{ID: module.CurrentConditions, Options: &module.CurrentConditionsOptions{}},
})
if err != nil {
t.Fatalf("ValidateComposition() error = %v", err)
}
}
func TestSPCConvectiveOutlookCollectedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = true, want false without source")
}
ctx.Collected = facts.CollectedFacts{SPCConvectiveOutlooks: &weatherdata.ConvectiveOutlookRun{}}
if !collectedFactAvailable(module.CollectedSPCConvectiveOutlooks, ctx) {
t.Fatal("collectedFactAvailable() = false, want true with checked source")
}
}
func TestSPCConvectiveOutlookDerivedRequirementAvailability(t *testing.T) {
ctx := ModuleContext{}
if derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = true, want false without derived outlooks")
}
ctx.Derived = facts.DerivedFacts{SPCConvectiveOutlooks: []weatherdata.ConvectiveOutlook{}}
if !derivedFactAvailable(module.RequiresDerivedSPCConvectiveOutlooks, ctx) {
t.Fatal("derivedFactAvailable() = false, want true for checked empty derived outlooks")
}
}
func noopModuleBuilder(ModuleContext, any) (*module.Output, error) {
return &module.Output{ID: module.Metadata, StanzaName: "metadata", Value: struct{}{}}, nil
}
type testRegistryValue struct {
Message string `json:"message"`
}
func testRegistryModuleContext() ModuleContext {
return ModuleContext{
Resolved: report.Resolved{
Definition: report.DefaultRegistry().MustLookup(report.Daily),
},
}
}

View File

@@ -0,0 +1,93 @@
package briefing
import (
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
type NarrativeForecastModule struct {
Product string `json:"product,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
SourceLocation string `json:"source_location,omitempty"`
SourceLocationID string `json:"source_location_id,omitempty"`
Periods []NarrativeForecastPeriod `json:"periods,omitempty"`
}
type NarrativeForecastPeriod struct {
Name string `json:"name,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IsDay *bool `json:"is_day,omitempty"`
TextDescription string `json:"text_description,omitempty"`
TemperatureC *float64 `json:"temperature_c,omitempty"`
TemperatureF *float64 `json:"temperature_f,omitempty"`
TemperatureCMin *float64 `json:"temperature_c_min,omitempty"`
TemperatureFMin *float64 `json:"temperature_f_min,omitempty"`
TemperatureCMax *float64 `json:"temperature_c_max,omitempty"`
TemperatureFMax *float64 `json:"temperature_f_max,omitempty"`
WindSpeedKmh *float64 `json:"wind_speed_kmh,omitempty"`
WindSpeedMph *float64 `json:"wind_speed_mph,omitempty"`
WindGustKmh *float64 `json:"wind_gust_kmh,omitempty"`
WindGustMph *float64 `json:"wind_gust_mph,omitempty"`
WindDirection string `json:"wind_direction,omitempty"`
ProbabilityOfPrecipitationPercent *float64 `json:"probability_of_precipitation_percent,omitempty"`
}
func buildNarrativeForecastModule(ctx ModuleContext, _ any) (*module.Output, error) {
narrative := ctx.Collected.Narrative
if narrative == nil || len(ctx.Derived.ValidPeriodNarrativePeriods) == 0 {
return nil, nil
}
value := NarrativeForecastModule{
Product: narrative.Product,
IssuedAt: narrative.IssuedAt,
UpdatedAt: copyTime(narrative.UpdatedAt),
SourceLocation: narrative.LocationName,
SourceLocationID: narrative.LocationID,
Periods: narrativeForecastPeriods(ctx.Derived.ValidPeriodNarrativePeriods, ctx.Timezone),
}
if value.isEmpty() {
return nil, nil
}
return &module.Output{ID: module.NarrativeForecast, StanzaName: "narrative_forecast", Value: value}, nil
}
func narrativeForecastPeriods(periods []weatherdata.ForecastPeriod, timezone string) []NarrativeForecastPeriod {
out := make([]NarrativeForecastPeriod, 0, len(periods))
for _, period := range periods {
validPeriod := timeutil.Period{Start: period.StartTime, End: period.EndTime}
out = append(out, NarrativeForecastPeriod{
Name: period.Name,
PeriodBegins: friendlyPeriodBeginsLabel(validPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(validPeriod, timezone),
IsDay: copyBool(period.IsDay),
TextDescription: period.TextDescription,
TemperatureC: copyFloat(period.TemperatureC),
TemperatureF: copyFloat(period.TemperatureF),
TemperatureCMin: copyFloat(period.TemperatureCMin),
TemperatureFMin: copyFloat(period.TemperatureFMin),
TemperatureCMax: copyFloat(period.TemperatureCMax),
TemperatureFMax: copyFloat(period.TemperatureFMax),
WindSpeedKmh: copyFloat(period.WindSpeedKmh),
WindSpeedMph: copyFloat(period.WindSpeedMph),
WindGustKmh: copyFloat(period.WindGustKmh),
WindGustMph: copyFloat(period.WindGustMph),
WindDirection: windDirectionLabel(period.WindDirectionDegrees),
ProbabilityOfPrecipitationPercent: copyFloat(period.ProbabilityOfPrecipitationPercent),
})
}
return out
}
func (v NarrativeForecastModule) isEmpty() bool {
return v.Product == "" &&
v.IssuedAt.IsZero() &&
v.UpdatedAt == nil &&
v.SourceLocation == "" &&
v.SourceLocationID == "" &&
len(v.Periods) == 0
}

View File

@@ -0,0 +1,38 @@
package briefing
import "gitea.maximumdirect.net/eric/weatherreporter/internal/module"
type OutdoorWindowsModule struct {
Best *OutdoorWindowModule `json:"best,omitempty"`
Worst *OutdoorWindowModule `json:"worst,omitempty"`
}
type OutdoorWindowModule struct {
Daypart string `json:"daypart"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"`
}
func buildOutdoorWindowsModule(ctx ModuleContext, _ any) (*module.Output, error) {
windows := buildOutdoorWindows(ctx.Derived.DaypartSummaries)
value := OutdoorWindowsModule{
Best: outdoorWindowValue(windows.Best, ctx.Timezone),
Worst: outdoorWindowValue(windows.Worst, ctx.Timezone),
}
return &module.Output{ID: module.OutdoorWindows, StanzaName: "outdoor_windows", Value: value}, nil
}
func outdoorWindowValue(window *OutdoorWindow, timezone string) *OutdoorWindowModule {
if window == nil {
return nil
}
return &OutdoorWindowModule{
Daypart: window.Daypart,
PeriodBegins: friendlyPeriodBeginsLabel(window.Period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(window.Period, timezone),
Reasons: append([]string(nil), window.Reasons...),
Score: window.Score,
}
}

View File

@@ -1,43 +1,29 @@
// Package briefing builds report-specific structured briefing packages.
// Package briefing builds prompt-facing module values.
package briefing
import (
"fmt"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/fileutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const SchemaVersion = "weatherreporter.briefing.v1"
type Package struct {
Metadata Metadata `json:"metadata"`
CurrentConditions *CurrentConditionsContext `json:"currentConditions,omitempty"`
Daily *Daily `json:"daily,omitempty"`
ThreeDay *ThreeDay `json:"threeDay,omitempty"`
Weekend *Weekend `json:"weekend,omitempty"`
Storm *Storm `json:"storm,omitempty"`
}
type Metadata struct {
SchemaVersion string `json:"schemaVersion"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []forecast.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
RunID string `json:"runId"`
ReportID report.ID `json:"reportId"`
Variant string `json:"variant,omitempty"`
PromptID string `json:"promptId"`
GeneratedAt time.Time `json:"generatedAt"`
Units string `json:"units"`
Timezone string `json:"timezone"`
ValidPeriod timeutil.Period `json:"validPeriod"`
Location *LocationContext `json:"location,omitempty"`
SourceLocationID string `json:"sourceLocationId,omitempty"`
SourceLocation string `json:"sourceLocation,omitempty"`
Sources []SourceMetadata `json:"sources,omitempty"`
SourceWarnings []weatherdata.SourceWarning `json:"sourceWarnings,omitempty"`
Alerts *AlertStatus `json:"alerts,omitempty"`
}
type LocationContext struct {
@@ -47,30 +33,15 @@ type LocationContext struct {
Timezone string `json:"timezone,omitempty"`
}
type CurrentConditionsContext struct {
ConditionText string `json:"conditionText,omitempty"`
IsDay *bool `json:"isDay,omitempty"`
TemperatureC *float64 `json:"temperatureC,omitempty"`
TemperatureF *float64 `json:"temperatureF,omitempty"`
ApparentTemperatureC *float64 `json:"apparentTemperatureC,omitempty"`
ApparentTemperatureF *float64 `json:"apparentTemperatureF,omitempty"`
DewpointC *float64 `json:"dewpointC,omitempty"`
DewpointF *float64 `json:"dewpointF,omitempty"`
RelativeHumidityPercent *float64 `json:"relativeHumidityPercent,omitempty"`
WindSpeedKmh *float64 `json:"windSpeedKmh,omitempty"`
WindSpeedMph *float64 `json:"windSpeedMph,omitempty"`
WindDirectionDegrees *float64 `json:"windDirectionDegrees,omitempty"`
}
type SourceMetadata struct {
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []forecast.SourceWarning `json:"warnings,omitempty"`
Name string `json:"name"`
Endpoint string `json:"endpoint,omitempty"`
FetchedAt time.Time `json:"fetchedAt"`
IssuedAt *time.Time `json:"issuedAt,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
DataSHA256 string `json:"dataSha256,omitempty"`
Missing bool `json:"missing,omitempty"`
Warnings []weatherdata.SourceWarning `json:"warnings,omitempty"`
}
type AlertStatus struct {
@@ -82,7 +53,7 @@ type AlertStatus struct {
type BuildContext struct {
Resolved report.Resolved
Bundle *forecast.Bundle
Bundle *weatherdata.Bundle
Units string
Timezone string
Location *LocationContext
@@ -92,7 +63,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
metadata := ctx.Resolved.Metadata()
sourceLocationID, sourceLocation := sourceLocation(ctx.Bundle)
return Metadata{
SchemaVersion: SchemaVersion,
RunID: metadata.RunID,
ReportID: metadata.ReportID,
Variant: variantForReport(metadata.ReportID),
@@ -110,13 +80,6 @@ func BuildMetadata(ctx BuildContext) Metadata {
}
}
func buildPackage(ctx BuildContext) Package {
return Package{
Metadata: BuildMetadata(ctx),
CurrentConditions: currentConditions(ctx.Bundle),
}
}
func copyLocation(location *LocationContext) *LocationContext {
if location == nil {
return nil
@@ -125,43 +88,15 @@ func copyLocation(location *LocationContext) *LocationContext {
return &copied
}
func currentConditions(bundle *forecast.Bundle) *CurrentConditionsContext {
if bundle == nil || bundle.Current == nil {
func copyBool(value *bool) *bool {
if value == nil {
return nil
}
current := bundle.Current
context := CurrentConditionsContext{
ConditionText: current.ConditionText,
IsDay: copyBool(current.IsDay),
TemperatureC: copyFloat(current.TemperatureC),
TemperatureF: copyFloat(current.TemperatureF),
ApparentTemperatureC: copyFloat(current.ApparentTemperatureC),
ApparentTemperatureF: copyFloat(current.ApparentTemperatureF),
DewpointC: copyFloat(current.DewpointC),
DewpointF: copyFloat(current.DewpointF),
RelativeHumidityPercent: copyFloat(current.RelativeHumidityPercent),
WindSpeedKmh: copyFloat(current.WindSpeedKmh),
WindSpeedMph: copyFloat(current.WindSpeedMph),
WindDirectionDegrees: copyFloat(current.WindDirectionDegrees),
}
if context.ConditionText == "" &&
context.IsDay == nil &&
context.TemperatureC == nil &&
context.TemperatureF == nil &&
context.ApparentTemperatureC == nil &&
context.ApparentTemperatureF == nil &&
context.DewpointC == nil &&
context.DewpointF == nil &&
context.RelativeHumidityPercent == nil &&
context.WindSpeedKmh == nil &&
context.WindSpeedMph == nil &&
context.WindDirectionDegrees == nil {
return nil
}
return &context
copied := *value
return &copied
}
func copyBool(value *bool) *bool {
func copyInt(value *int) *int {
if value == nil {
return nil
}
@@ -177,18 +112,19 @@ func copyFloat(value *float64) *float64 {
return &copied
}
func Save(path string, pkg Package) error {
if err := fileutil.WriteJSONAtomic(path, pkg); err != nil {
return fmt.Errorf("save briefing package: %w", err)
func copyTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
return nil
copied := *value
return &copied
}
func sourceLocation(bundle *forecast.Bundle) (string, string) {
func sourceLocation(bundle *weatherdata.Bundle) (string, string) {
if bundle == nil {
return "", ""
}
for _, run := range []*forecast.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
for _, run := range []*weatherdata.ForecastRun{bundle.Hourly, bundle.Narrative, bundle.Daily} {
if run == nil {
continue
}
@@ -199,7 +135,7 @@ func sourceLocation(bundle *forecast.Bundle) (string, string) {
return "", ""
}
func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
func sourceMetadata(bundle *weatherdata.Bundle) []SourceMetadata {
if bundle == nil {
return nil
}
@@ -219,14 +155,14 @@ func sourceMetadata(bundle *forecast.Bundle) []SourceMetadata {
return out
}
func sourceWarnings(bundle *forecast.Bundle) []forecast.SourceWarning {
func sourceWarnings(bundle *weatherdata.Bundle) []weatherdata.SourceWarning {
if bundle == nil {
return nil
}
return bundle.Warnings
}
func alertStatus(bundle *forecast.Bundle) *AlertStatus {
func alertStatus(bundle *weatherdata.Bundle) *AlertStatus {
if bundle == nil {
return nil
}
@@ -247,21 +183,11 @@ func alertStatus(bundle *forecast.Bundle) *AlertStatus {
return status
}
func setRelevantAlertCount(metadata *Metadata, count int) {
if metadata.Alerts == nil {
if count == 0 {
return
}
metadata.Alerts = &AlertStatus{}
}
metadata.Alerts.RelevantCount = count
}
func variantForReport(id report.ID) string {
switch id {
case report.DailyToday:
case report.Daily, report.Today:
return "today"
case report.DailyTomorrow:
case report.Tomorrow:
return "tomorrow"
default:
return ""

View File

@@ -0,0 +1,109 @@
package briefing
import (
"fmt"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
)
const (
precipTimingChanceLowerBound = 40
precipTimingLikelyLowerBound = 50
precipTimingExpectLowerBound = 70
)
type PrecipTimingModule struct {
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
ProbabilityThreshold float64 `json:"probability_threshold"`
PrecipitationWindows []PrecipitationWindowModule `json:"precipitation_windows,omitempty"`
ThunderMentioned bool `json:"thunder_mentioned"`
}
type PrecipitationWindowModule struct {
PeriodBegins string `json:"period_begins"`
PeriodBeginsHourLabel string `json:"period_begins_hour_label,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
PeriodEndsHourLabel string `json:"period_ends_hour_label,omitempty"`
MaxPopPercent *int `json:"max_pop_percent,omitempty"`
MaxPopTime string `json:"max_pop_time,omitempty"`
MaxPopHourLabel string `json:"max_pop_hour_label,omitempty"`
PrecipitationType string `json:"precipitation_type,omitempty"`
ExpectationPhrase string `json:"expectation_phrase,omitempty"`
}
func buildPrecipTimingModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := precipTimingValue(ctx.Derived.PrecipTiming, ctx.Timezone)
return &module.Output{ID: module.PrecipTiming, StanzaName: "precip_timing", Value: value}, nil
}
func precipTimingValue(timing forecast.PrecipTiming, timezone string) PrecipTimingModule {
value := PrecipTimingModule{
ProbabilityThreshold: timing.ProbabilityThreshold,
ThunderMentioned: timing.ThunderMentioned,
}
if timing.MaxPrecipitationProbability != nil {
value.MaxPopPercent = roundedInt(&timing.MaxPrecipitationProbability.Value)
value.MaxPopTime = clockLabel(timing.MaxPrecipitationProbability.Time, timezone)
}
for _, window := range timing.PrecipitationWindows {
item := PrecipitationWindowModule{
PeriodBegins: friendlyDateTimeLabel(window.Start, timezone),
PeriodBeginsHourLabel: hourMinuteLabel(window.Start, timezone),
}
if window.End != nil {
item.PeriodEnds = friendlyDateTimeLabel(*window.End, timezone)
item.PeriodEndsHourLabel = hourMinuteLabel(*window.End, timezone)
}
item.MaxPopPercent = roundedInt(&window.MaxPrecipitationProbability.Value)
item.MaxPopTime = clockLabel(window.MaxPrecipitationProbability.Time, timezone)
item.MaxPopHourLabel = hourMinuteLabel(window.MaxPrecipitationProbability.Time, timezone)
item.PrecipitationType = precipitationWindowType(window.TextDescriptions)
if item.MaxPopPercent != nil {
item.ExpectationPhrase = precipitationWindowExpectationPhrase(*item.MaxPopPercent, item.PrecipitationType)
}
value.PrecipitationWindows = append(value.PrecipitationWindows, item)
}
return value
}
func precipitationWindowType(descriptions []string) string {
combined := strings.ToLower(strings.Join(descriptions, " "))
switch {
case (strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm")) &&
(strings.Contains(combined, "shower") || strings.Contains(combined, "rain")):
return "showers and thunderstorms"
case strings.Contains(combined, "freezing rain"):
return "freezing rain"
case strings.Contains(combined, "thunderstorm") || strings.Contains(combined, "t-storm"):
return "thunderstorms"
case strings.Contains(combined, "shower"):
return "showers"
case strings.Contains(combined, "snow"):
return "snow"
case strings.Contains(combined, "drizzle"):
return "drizzle"
case strings.Contains(combined, "rain"):
return "rain"
default:
return "precipitation"
}
}
func precipitationWindowExpectationPhrase(maxPopPercent int, precipitationType string) string {
if precipitationType == "" {
precipitationType = "precipitation"
}
switch {
case maxPopPercent >= precipTimingExpectLowerBound:
return fmt.Sprintf("Expect %s.", precipitationType)
case maxPopPercent >= precipTimingLikelyLowerBound:
return fmt.Sprintf("%s likely.", sentenceCase(precipitationType))
case maxPopPercent >= precipTimingChanceLowerBound:
return fmt.Sprintf("Chance of %s.", precipitationType)
default:
return ""
}
}

View File

@@ -0,0 +1,94 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const defaultSPCConvectiveDiscussionMinimumSeverityRank = defaultSPCRiskDigestMinimumSeverityRank
const spcCategoricalOutlookType = defaultSPCRiskDigestOutlookType
type SPCConvectiveDiscussionModule struct {
IncludedBecause string `json:"included_because"`
Discussions []SPCConvectiveDiscussionRecord `json:"discussions"`
}
type SPCConvectiveDiscussionRecord struct {
Day int `json:"day,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
Headline string `json:"headline,omitempty"`
Summary string `json:"summary,omitempty"`
Discussion string `json:"discussion,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func buildSPCConvectiveDiscussionModule(ctx ModuleContext, _ any) (*module.Output, error) {
qualifyingPeriods := spcConvectiveDiscussionQualifyingPeriods(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod)
if len(qualifyingPeriods) == 0 {
return nil, nil
}
records := spcConvectiveDiscussionRecords(qualifyingPeriods, ctx.Derived.SPCConvectiveDiscussions, ctx.Timezone)
if len(records) == 0 {
return nil, nil
}
value := SPCConvectiveDiscussionModule{
IncludedBecause: fmt.Sprintf("%s severity_rank >= %d", spcCategoricalOutlookType, defaultSPCConvectiveDiscussionMinimumSeverityRank),
Discussions: records,
}
return &module.Output{ID: module.SPCConvectiveDiscussion, StanzaName: string(module.SPCConvectiveDiscussion), Value: value}, nil
}
func spcConvectiveDiscussionQualifyingPeriods(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period) map[int]timeutil.Period {
periods := map[int]timeutil.Period{}
for _, outlook := range outlooks {
if outlook.OutlookType != spcCategoricalOutlookType {
continue
}
if outlook.SeverityRank == nil || *outlook.SeverityRank < defaultSPCConvectiveDiscussionMinimumSeverityRank {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
if existing, ok := periods[outlook.Day]; ok {
if outlookPeriod.Start.Before(existing.Start) {
existing.Start = outlookPeriod.Start
}
if outlookPeriod.End.After(existing.End) {
existing.End = outlookPeriod.End
}
periods[outlook.Day] = existing
continue
}
periods[outlook.Day] = outlookPeriod
}
return periods
}
func spcConvectiveDiscussionRecords(qualifyingPeriods map[int]timeutil.Period, discussions []weatherdata.ConvectiveOutlookDiscussion, timezone string) []SPCConvectiveDiscussionRecord {
records := make([]SPCConvectiveDiscussionRecord, 0, len(discussions))
for _, discussion := range discussions {
period, ok := qualifyingPeriods[discussion.Day]
if !ok {
continue
}
if discussion.Discussion == "" {
continue
}
records = append(records, SPCConvectiveDiscussionRecord{
Day: discussion.Day,
PeriodBegins: friendlyPeriodBeginsLabel(period, timezone),
PeriodEnds: friendlyPeriodEndsLabel(period, timezone),
Headline: discussion.Headline,
Summary: discussion.Summary,
Discussion: discussion.Discussion,
UpdatedAt: friendlyOptionalTime(discussion.UpdatedAt, timezone),
})
}
return records
}

View File

@@ -0,0 +1,317 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveDiscussionModuleOmitsBelowThresholdButOutlookRemains(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(2, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Lower risk", "General thunderstorms.", "No organized severe weather is expected.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule(discussion) error = %v", err)
}
if output != nil {
t.Fatalf("discussion output = %#v, want omitted below threshold", output)
}
outlookOutput, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule(outlooks) error = %v", err)
}
outlookValue := moduleValue[SPCConvectiveOutlooksModule](t, outlookOutput)
if !outlookValue.Checked || outlookValue.OutlookCount != 1 {
t.Fatalf("outlook value = %#v, want lower-risk outlook still emitted", outlookValue)
}
}
func TestSPCConvectiveDiscussionModuleIncludesEqualThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Severe storms possible", "Scattered severe storms are possible.", "A few storms may become severe during the afternoon.", "2026-05-29T08:30:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveDiscussion || output.StanzaName != "spc_convective_discussion" {
t.Fatalf("output = %#v, want spc convective discussion stanza", output)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if value.IncludedBecause != "categorical severity_rank >= 3" || len(value.Discussions) != 1 {
t.Fatalf("value = %#v, want threshold reason and one discussion", value)
}
discussion := value.Discussions[0]
if discussion.Day != 1 || discussion.Headline != "Severe storms possible" || discussion.Summary == "" || discussion.Discussion == "" {
t.Fatalf("discussion = %#v, want prompt-facing discussion fields", discussion)
}
if discussion.PeriodBegins != "2026-05-29 at 11:00 AM" || discussion.PeriodEnds != "2026-05-30 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying outlook valid period", discussion.PeriodBegins, discussion.PeriodEnds)
}
if discussion.UpdatedAt != "2026-05-29 at 8:30 AM" {
t.Fatalf("UpdatedAt = %q, want friendly local time", discussion.UpdatedAt)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"included_because", "discussions", "period_begins", "period_ends", "headline", "summary", "discussion", "updated_at"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
if strings.Contains(text, `"period":`) {
t.Fatalf("json = %s, want period_begins/period_ends instead of period", text)
}
}
func TestSPCConvectiveDiscussionModuleIncludesAboveThresholdDiscussion(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(4, []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Enhanced severe risk", "Numerous severe storms are possible.", "Severe storms may produce damaging winds.", "2026-05-29T09:15:00-05:00"),
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 || value.Discussions[0].Headline != "Enhanced severe risk" {
t.Fatalf("value = %#v, want above-threshold discussion", value)
}
}
func TestSPCConvectiveDiscussionModuleIgnoresHighRankNonCategoricalOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 30
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Wind risk discussion", "High wind probabilities.", "This discussion should not be emitted from wind severity rank.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-categorical outlook ignored for discussion threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleRequiresCategoricalThresholdForMixedSameDayOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank30 := 30
ctx := testModuleContext()
categorical := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
wind := weatherdata.ConvectiveOutlook{
ID: "day1-wind-30",
Day: 1,
OutlookType: "wind",
Label: "30%",
LabelText: "30% Wind Risk",
SeverityRank: &rank30,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{categorical, wind},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{categorical, wind}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Mixed risk discussion", "Only wind is high.", "This discussion should not be emitted without categorical threshold.", "2026-05-29T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want mixed day omitted when categorical outlook is below threshold", output)
}
}
func TestSPCConvectiveDiscussionModuleIncludesOnlyQualifyingDays(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank2 := 2
rank4 := 4
ctx := testModuleContext()
ctx.Resolved.ValidPeriod.Start = mustParseModuleTime("2026-05-30T00:00:00-05:00")
ctx.Resolved.ValidPeriod.End = mustParseModuleTime("2026-05-31T00:00:00-05:00")
day1Outlook := weatherdata.ConvectiveOutlook{
ID: "day1-marginal",
Day: 1,
OutlookType: "categorical",
Label: "MRGL",
LabelText: "Marginal Risk",
SeverityRank: &rank2,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
day2Outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank4,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{day1Outlook, day2Outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(1, "Day 1 regional discussion", "Marginal risk discussion.", "Day 1 text should not be emitted.", "2026-05-29T08:30:00-05:00"),
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should be emitted.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveDiscussionModule](t, output)
if len(value.Discussions) != 1 {
t.Fatalf("Discussions = %#v, want only the qualifying day discussion", value.Discussions)
}
if value.Discussions[0].Day != 2 || value.Discussions[0].Headline != "Day 2 regional discussion" {
t.Fatalf("Discussions[0] = %#v, want day 2 discussion only", value.Discussions[0])
}
if value.Discussions[0].PeriodBegins != "2026-05-30 at 7:00 AM" || value.Discussions[0].PeriodEnds != "2026-05-31 at 7:00 AM" {
t.Fatalf("Period = %q/%q, want qualifying day 2 outlook period", value.Discussions[0].PeriodBegins, value.Discussions[0].PeriodEnds)
}
}
func TestSPCConvectiveDiscussionModuleOmitsNonOverlappingQualifyingOutlook(t *testing.T) {
registry := MustDefaultModuleRegistry()
rank := 5
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day2-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = []weatherdata.ConvectiveOutlookDiscussion{
spcDiscussion(2, "Day 2 regional discussion", "Enhanced risk discussion.", "Day 2 text should not be emitted for today.", "2026-05-30T08:30:00-05:00"),
}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want non-overlapping discussion omitted", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingDiscussionText(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := spcConvectiveDiscussionContext(3, []weatherdata.ConvectiveOutlookDiscussion{
{Day: 1, Headline: "Severe storms possible", Summary: "Scattered severe storms are possible.", UpdatedAt: ptrModuleTime("2026-05-29T08:30:00-05:00")},
})
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted when discussion text is missing", output)
}
}
func TestSPCConvectiveDiscussionModuleOmitsMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveOutlooks = nil
ctx.Derived.SPCConvectiveDiscussions = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveDiscussion})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output != nil {
t.Fatalf("output = %#v, want omitted for missing optional source", output)
}
}
func spcConvectiveDiscussionContext(rank int, discussions []weatherdata.ConvectiveOutlookDiscussion) ModuleContext {
ctx := testModuleContext()
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T08:00:00-05:00"),
IssuedAt: ptrModuleTime("2026-05-29T07:45:00-05:00"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
ctx.Derived.SPCConvectiveDiscussions = discussions
return ctx
}
func spcDiscussion(day int, headline string, summary string, discussion string, updatedAt string) weatherdata.ConvectiveOutlookDiscussion {
return weatherdata.ConvectiveOutlookDiscussion{
Day: day,
Headline: headline,
Summary: summary,
Discussion: discussion,
UpdatedAt: ptrModuleTime(updatedAt),
}
}
func ptrModuleTime(value string) *time.Time {
parsed := mustParseModuleTime(value)
return &parsed
}

View File

@@ -0,0 +1,37 @@
package briefing
import (
"embed"
"encoding/json"
"fmt"
"strings"
)
//go:embed assets/spc_convective_outlook_definitions.json
var spcConvectiveOutlookDefinitionAssets embed.FS
var spcOutlookBackgroundDefinitions = mustLoadSPCOutlookBackgroundDefinitions()
func mustLoadSPCOutlookBackgroundDefinitions() map[string]SPCOutlookBackgroundDefinition {
data, err := spcConvectiveOutlookDefinitionAssets.ReadFile("assets/spc_convective_outlook_definitions.json")
if err != nil {
panic(fmt.Sprintf("read embedded SPC outlook definitions: %v", err))
}
var definitions map[string]SPCOutlookBackgroundDefinition
if err := json.Unmarshal(data, &definitions); err != nil {
panic(fmt.Sprintf("decode embedded SPC outlook definitions: %v", err))
}
return definitions
}
func spcOutlookBackgroundDefinition(outlookType string, label string) *SPCOutlookBackgroundDefinition {
definition, ok := spcOutlookBackgroundDefinitions[spcOutlookDefinitionKey(outlookType, label)]
if !ok {
return nil
}
return &definition
}
func spcOutlookDefinitionKey(outlookType string, label string) string {
return strings.ToLower(strings.TrimSpace(outlookType)) + ":" + strings.ToUpper(strings.TrimSpace(label))
}

View File

@@ -0,0 +1,162 @@
package briefing
import (
"strings"
"time"
"unicode"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
const defaultSPCRiskDigestOutlookType = "categorical"
const defaultSPCRiskDigestMinimumSeverityRank = 3
type SPCConvectiveOutlooksModule struct {
Checked bool `json:"checked"`
AsOf string `json:"as_of,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
LocationID string `json:"location_id,omitempty"`
LocationName string `json:"location_name,omitempty"`
OutlookCount int `json:"outlook_count"`
Outlooks []SPCConvectiveOutlookRecord `json:"outlooks,omitempty"`
RiskDigest []SPCConvectiveOutlookDigest `json:"risk_digest,omitempty"`
}
type SPCConvectiveOutlookRecord struct {
Day int `json:"day,omitempty"`
OutlookType string `json:"outlook_type,omitempty"`
Label string `json:"label,omitempty"`
LabelText string `json:"label_text,omitempty"`
BackgroundDefinition *SPCOutlookBackgroundDefinition `json:"background_definition,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
IssuedAt string `json:"issued_at,omitempty"`
ContainsLocation bool `json:"contains_location"`
ImageURL string `json:"image_url,omitempty"`
}
type SPCOutlookBackgroundDefinition struct {
PlainLanguage string `json:"plain_language,omitempty"`
OfficialDescription string `json:"official_description,omitempty"`
RelativeLevel string `json:"relative_level,omitempty"`
}
type SPCConvectiveOutlookDigest struct {
LabelText string `json:"label_text,omitempty"`
RiskLabel string `json:"risk_label,omitempty"`
PeriodBegins string `json:"period_begins,omitempty"`
PeriodEnds string `json:"period_ends,omitempty"`
}
func buildSPCConvectiveOutlooksModule(ctx ModuleContext, _ any) (*module.Output, error) {
value := SPCConvectiveOutlooksModule{}
run := ctx.Collected.SPCConvectiveOutlooks
if run != nil {
value.Checked = true
value.AsOf = friendlyOptionalTime(run.AsOf, ctx.Timezone)
value.IssuedAt = friendlyOptionalTime(run.IssuedAt, ctx.Timezone)
value.LocationID = run.LocationID
value.LocationName = run.LocationName
}
source, ok := sourceByName(ctx.Collected.SourceProvenance, string(module.SPCConvectiveOutlooks))
if value.Checked && value.AsOf == "" && ok && !source.FetchedAt.IsZero() {
value.AsOf = friendlyDateTimeLabel(source.FetchedAt, ctx.Timezone)
}
if value.Checked && value.IssuedAt == "" && ok {
value.IssuedAt = friendlyOptionalTime(source.IssuedAt, ctx.Timezone)
}
value.Outlooks = spcConvectiveOutlookRecords(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone)
value.RiskDigest = spcConvectiveOutlookRiskDigest(ctx.Derived.SPCConvectiveOutlooks, ctx.Resolved.ValidPeriod, ctx.Timezone, defaultSPCRiskDigestPolicy())
value.OutlookCount = len(value.Outlooks)
return &module.Output{ID: module.SPCConvectiveOutlooks, StanzaName: string(module.SPCConvectiveOutlooks), Value: value}, nil
}
type spcRiskDigestPolicy struct {
OutlookType string
MinimumSeverityRank int
}
func defaultSPCRiskDigestPolicy() spcRiskDigestPolicy {
return spcRiskDigestPolicy{
OutlookType: defaultSPCRiskDigestOutlookType,
MinimumSeverityRank: defaultSPCRiskDigestMinimumSeverityRank,
}
}
func spcConvectiveOutlookRecords(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string) []SPCConvectiveOutlookRecord {
records := make([]SPCConvectiveOutlookRecord, 0, len(outlooks))
for _, outlook := range outlooks {
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookRecord{
Day: outlook.Day,
OutlookType: outlook.OutlookType,
Label: outlook.Label,
LabelText: outlook.LabelText,
BackgroundDefinition: spcOutlookBackgroundDefinition(outlook.OutlookType, outlook.Label),
PeriodBegins: friendlyPeriodBeginsLabel(outlookPeriod, timezone),
PeriodEnds: friendlyPeriodEndsLabel(outlookPeriod, timezone),
IssuedAt: friendlyOptionalTime(outlook.IssuedAt, timezone),
ContainsLocation: outlook.ContainsLocation,
ImageURL: outlook.ImageURL,
})
}
return records
}
func spcConvectiveOutlookRiskDigest(outlooks []weatherdata.ConvectiveOutlook, reportPeriod timeutil.Period, timezone string, policy spcRiskDigestPolicy) []SPCConvectiveOutlookDigest {
records := make([]SPCConvectiveOutlookDigest, 0, len(outlooks))
for _, outlook := range outlooks {
if outlook.OutlookType != policy.OutlookType {
continue
}
if outlook.SeverityRank == nil || *outlook.SeverityRank < policy.MinimumSeverityRank {
continue
}
if !outlook.ContainsLocation {
continue
}
outlookPeriod := timeutil.Period{Start: outlook.ValidFrom, End: outlook.ValidTo}
if !outlookPeriod.IsValid() || !outlookPeriod.Overlaps(reportPeriod) {
continue
}
records = append(records, SPCConvectiveOutlookDigest{
LabelText: outlook.LabelText,
RiskLabel: spcRiskDigestLabel(outlook.LabelText),
PeriodBegins: friendlyMonthDayTimeLabel(outlookPeriod.Start, timezone),
PeriodEnds: friendlyMonthDayTimeLabel(outlookPeriod.End, timezone),
})
}
return records
}
func spcRiskDigestLabel(labelText string) string {
label := strings.TrimSpace(labelText)
if label == "" {
return ""
}
runes := []rune(strings.ToLower(label))
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}
func friendlyOptionalTime(value *time.Time, timezone string) string {
if value == nil {
return ""
}
return friendlyDateTimeLabel(*value, timezone)
}
func sourceByName(sources []weatherdata.Source, name string) (weatherdata.Source, bool) {
for _, source := range sources {
if source.Name == name {
return source, true
}
}
return weatherdata.Source{}, false
}

View File

@@ -0,0 +1,355 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/module"
"gitea.maximumdirect.net/eric/weatherreporter/internal/weatherdata"
)
func TestSPCConvectiveOutlooksModuleBuildsPromptSafeRiskProduct(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 3
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
issuedAt := mustParseModuleTime("2026-05-29T13:45:00Z")
expiresAt := mustParseModuleTime("2026-05-30T07:00:00-05:00")
outlook := weatherdata.ConvectiveOutlook{
ID: "day1-categorical-slight",
Provider: "spc",
Product: "convective_outlook",
Day: 1,
OutlookType: "categorical",
Label: "SLGT",
LabelText: "Slight Risk",
Forecaster: "Smith",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-29T11:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
IssuedAt: &issuedAt,
ExpiresAt: &expiresAt,
SourceURL: "https://www.spc.noaa.gov/products/outlook/day1otlk.html",
ImageURL: "https://www.spc.noaa.gov/products/outlook/day1probotlk_2000_torn.gif",
ContainsLocation: true,
Geometry: json.RawMessage(`{"type":"Polygon","coordinates":[]}`),
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
IssuedAt: &issuedAt,
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
if output == nil || output.ID != module.SPCConvectiveOutlooks || output.StanzaName != "spc_convective_outlooks" {
t.Fatalf("output = %#v, want spc convective outlook output", output)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 1 || value.AsOf != "2026-05-29 at 9:00 AM" || value.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("SPCConvectiveOutlooksModule = %#v, want checked source timing and one outlook", value)
}
if value.LocationID != "nws-lsx-grid-90-74" || value.LocationName != "St. Louis, MO" {
t.Fatalf("source location = %q/%q, want Weather API location", value.LocationID, value.LocationName)
}
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
got := value.Outlooks[0]
if got.Day != 1 || got.OutlookType != "categorical" || got.Label != "SLGT" || got.LabelText != "Slight Risk" {
t.Fatalf("outlook = %#v, want categorical slight risk fields", got)
}
if got.BackgroundDefinition == nil ||
got.BackgroundDefinition.PlainLanguage != "Scattered severe storms possible." ||
got.BackgroundDefinition.OfficialDescription != "Isolated intense storms are possible within the risk area, but severe weather is generally expected to be short-lived and/or not widespread." ||
got.BackgroundDefinition.RelativeLevel != "2 of 5" {
t.Fatalf("background definition = %#v, want Slight Risk helper", got.BackgroundDefinition)
}
if got.PeriodBegins != "2026-05-29 at 11:00 AM" || got.PeriodEnds != "2026-05-30 at 7:00 AM" || got.IssuedAt != "2026-05-29 at 8:45 AM" {
t.Fatalf("outlook times = %#v, want friendly local labels", got)
}
if !got.ContainsLocation || got.ImageURL == "" {
t.Fatalf("outlook = %#v, want location flag and image URL", got)
}
if len(value.RiskDigest) != 1 {
t.Fatalf("RiskDigest length = %d, want 1", len(value.RiskDigest))
}
digest := value.RiskDigest[0]
if digest.LabelText != "Slight Risk" || digest.RiskLabel != "Slight risk" || digest.PeriodBegins != "May 29 at 11:00 AM" || digest.PeriodEnds != "May 30 at 7:00 AM" {
t.Fatalf("risk digest = %#v, want prompt-facing slight risk record", digest)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
text := string(data)
for _, field := range []string{"checked", "as_of", "issued_at", "location_id", "location_name", "outlook_count", "outlooks", "risk_digest", "background_definition", "plain_language", "official_description", "relative_level", "period_begins", "period_ends", "contains_location", "image_url"} {
if !strings.Contains(text, field) {
t.Fatalf("json = %s, want field %s", text, field)
}
}
for _, omitted := range []string{"geometry", "coordinates", "forecaster", "provider", "severity_rank", "expires_at", "source_url", "valid_start", "valid_end", `"period":`} {
if strings.Contains(text, omitted) {
t.Fatalf("json = %s, want prompt-safe outlook without %s", text, omitted)
}
}
}
func TestSPCOutlookBackgroundDefinitionLookup(t *testing.T) {
tests := []struct {
name string
outlookType string
label string
want bool
}{
{name: "exact slight risk", outlookType: "categorical", label: "SLGT", want: true},
{name: "normalized slight risk", outlookType: " Categorical ", label: "slgt", want: true},
{name: "expanded marginal risk", outlookType: "categorical", label: "MRGL", want: true},
{name: "expanded conditional tornado risk", outlookType: "tornado", label: "CIG3", want: true},
{name: "unknown risk", outlookType: "categorical", label: "FOO"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
definition := spcOutlookBackgroundDefinition(tt.outlookType, tt.label)
if tt.want && definition == nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = nil, want definition", tt.outlookType, tt.label)
}
if !tt.want && definition != nil {
t.Fatalf("spcOutlookBackgroundDefinition(%q, %q) = %#v, want nil", tt.outlookType, tt.label, definition)
}
})
}
}
func TestSPCConvectiveOutlooksModuleOmitsUnknownBackgroundDefinition(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
outlook := spcRiskDigestTestOutlook("categorical", "Unknown Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00")
outlook.Label = "FOO"
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if len(value.Outlooks) != 1 {
t.Fatalf("Outlooks length = %d, want 1", len(value.Outlooks))
}
if value.Outlooks[0].BackgroundDefinition != nil {
t.Fatalf("BackgroundDefinition = %#v, want nil for undefined risk", value.Outlooks[0].BackgroundDefinition)
}
}
func TestSPCOutlookBackgroundDefinitionsAssetHasUsableEntries(t *testing.T) {
wantKeys := []string{
"categorical:TSTM",
"categorical:MRGL",
"categorical:SLGT",
"categorical:ENH",
"categorical:MDT",
"categorical:HIGH",
"tornado:CIG1",
"tornado:CIG2",
"tornado:CIG3",
"wind:CIG1",
"wind:CIG2",
"wind:CIG3",
"hail:CIG1",
"hail:CIG2",
}
if len(spcOutlookBackgroundDefinitions) != len(wantKeys) {
t.Fatalf("embedded SPC outlook background definitions length = %d, want %d", len(spcOutlookBackgroundDefinitions), len(wantKeys))
}
for _, key := range wantKeys {
if _, ok := spcOutlookBackgroundDefinitions[key]; !ok {
t.Fatalf("embedded SPC outlook background definitions missing %q", key)
}
}
for key, definition := range spcOutlookBackgroundDefinitions {
if strings.TrimSpace(key) == "" {
t.Fatal("embedded SPC outlook background definitions contain empty key")
}
if definition.PlainLanguage == "" || definition.OfficialDescription == "" || definition.RelativeLevel == "" {
t.Fatalf("embedded SPC outlook background definition %q is incomplete: %#v", key, definition)
}
if strings.Contains(definition.OfficialDescription, ".Note") || strings.Contains(definition.OfficialDescription, "higher.Note") {
t.Fatalf("embedded SPC outlook background definition %q has missing sentence spacing: %q", key, definition.OfficialDescription)
}
if strings.Contains(definition.OfficialDescription, "by themselves") {
t.Fatalf("embedded SPC outlook background definition %q has singular grammar issue: %q", key, definition.OfficialDescription)
}
}
}
func TestSPCRiskDigestDefaultPolicyConstants(t *testing.T) {
if defaultSPCRiskDigestOutlookType != "categorical" {
t.Fatalf("defaultSPCRiskDigestOutlookType = %q, want categorical", defaultSPCRiskDigestOutlookType)
}
if defaultSPCRiskDigestMinimumSeverityRank != 3 {
t.Fatalf("defaultSPCRiskDigestMinimumSeverityRank = %d, want 3", defaultSPCRiskDigestMinimumSeverityRank)
}
}
func TestSPCConvectiveOutlooksRiskDigestFilters(t *testing.T) {
tests := []struct {
name string
outlook weatherdata.ConvectiveOutlook
wantRisk bool
}{
{
name: "categorical slight risk included",
outlook: spcRiskDigestTestOutlook("categorical", "Slight Risk", 3, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
wantRisk: true,
},
{
name: "marginal risk excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Marginal Risk", 2, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non categorical high rank excluded",
outlook: spcRiskDigestTestOutlook("wind", "30% Wind Risk", 30, true,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
{
name: "non overlapping excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Enhanced Risk", 4, true,
"2026-05-30T07:00:00-05:00", "2026-05-31T07:00:00-05:00"),
},
{
name: "location miss excluded",
outlook: spcRiskDigestTestOutlook("categorical", "Moderate Risk", 5, false,
"2026-05-29T11:00:00-05:00", "2026-05-30T07:00:00-05:00"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
Outlooks: []weatherdata.ConvectiveOutlook{tt.outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{tt.outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
gotRisk := len(value.RiskDigest) > 0
if gotRisk != tt.wantRisk {
t.Fatalf("RiskDigest = %#v, want included=%v", value.RiskDigest, tt.wantRisk)
}
})
}
}
func TestSPCConvectiveOutlooksModuleSkipsNonOverlappingOutlooks(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
rank := 5
outlook := weatherdata.ConvectiveOutlook{
ID: "tomorrow-enhanced",
Day: 2,
OutlookType: "categorical",
Label: "ENH",
LabelText: "Enhanced Risk",
SeverityRank: &rank,
ValidFrom: mustParseModuleTime("2026-05-30T07:00:00-05:00"),
ValidTo: mustParseModuleTime("2026-05-31T07:00:00-05:00"),
ContainsLocation: true,
}
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
AsOf: ptrModuleTime("2026-05-29T14:00:00Z"),
Outlooks: []weatherdata.ConvectiveOutlook{outlook},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{outlook}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("value = %#v, want non-overlapping outlook omitted", value)
}
if len(value.RiskDigest) != 0 {
t.Fatalf("RiskDigest = %#v, want non-overlapping outlook omitted", value.RiskDigest)
}
}
func TestSPCConvectiveOutlooksModuleBuildsCheckedEmptyStanza(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
asOf := mustParseModuleTime("2026-05-29T14:00:00Z")
ctx.Collected.SPCConvectiveOutlooks = &weatherdata.ConvectiveOutlookRun{
LocationID: "nws-lsx-grid-90-74",
LocationName: "St. Louis, MO",
AsOf: &asOf,
Outlooks: []weatherdata.ConvectiveOutlook{},
}
ctx.Derived.SPCConvectiveOutlooks = []weatherdata.ConvectiveOutlook{}
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if !value.Checked || value.OutlookCount != 0 || len(value.Outlooks) != 0 {
t.Fatalf("checked empty value = %#v, want checked source with no retained outlooks", value)
}
data, err := json.Marshal(output.Value)
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
if strings.Contains(string(data), "outlooks") {
t.Fatalf("json = %s, want empty outlook list omitted", string(data))
}
}
func TestSPCConvectiveOutlooksModuleBuildsUncheckedStanzaForMissingSource(t *testing.T) {
registry := MustDefaultModuleRegistry()
ctx := testModuleContext()
ctx.Collected.SPCConvectiveOutlooks = nil
ctx.Collected.SourceProvenance = []weatherdata.Source{{
Name: string(module.SPCConvectiveOutlooks),
Missing: true,
}}
ctx.Derived.SPCConvectiveOutlooks = nil
output, err := registry.BuildModule(ctx, module.ConfigItem{ID: module.SPCConvectiveOutlooks})
if err != nil {
t.Fatalf("BuildModule() error = %v", err)
}
value := moduleValue[SPCConvectiveOutlooksModule](t, output)
if value.Checked || value.OutlookCount != 0 || value.AsOf != "" || value.IssuedAt != "" {
t.Fatalf("missing source value = %#v, want unchecked empty stanza", value)
}
}
func spcRiskDigestTestOutlook(outlookType string, labelText string, rank int, containsLocation bool, validFrom string, validTo string) weatherdata.ConvectiveOutlook {
return weatherdata.ConvectiveOutlook{
ID: labelText,
Day: 1,
OutlookType: outlookType,
LabelText: labelText,
SeverityRank: &rank,
ValidFrom: mustParseModuleTime(validFrom),
ValidTo: mustParseModuleTime(validTo),
ContainsLocation: containsLocation,
}
}

View File

@@ -1,197 +0,0 @@
package briefing
import (
"fmt"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Storm struct {
TimingWindow timeutil.Period `json:"timingWindow"`
EventHeadlines []string `json:"eventHeadlines,omitempty"`
Hazards []string `json:"hazards,omitempty"`
MostLikelyScenario []string `json:"mostLikelyScenario,omitempty"`
ReasonableWorstCase []string `json:"reasonableWorstCase,omitempty"`
ConfidenceInputs []string `json:"confidenceInputs,omitempty"`
WhatToWatchNext []string `json:"whatToWatchNext,omitempty"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
HourlyPeriods []forecast.ForecastPeriod `json:"hourlyPeriods,omitempty"`
DailyPeriods []forecast.ForecastPeriod `json:"dailyPeriods,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
WindowSummary forecast.DaypartSummary `json:"windowSummary"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
func BuildStorm(ctx BuildContext) (Package, error) {
if ctx.Resolved.Definition.ID != report.Storm {
return Package{}, fmt.Errorf("storm briefing requires a storm report definition")
}
if ctx.Bundle == nil {
return Package{}, fmt.Errorf("forecast bundle is required")
}
period := ctx.Resolved.ValidPeriod
hourly := forecast.SelectHourlyPeriods(ctx.Bundle.Hourly, period)
narrative := forecast.SelectNarrativePeriods(ctx.Bundle, period)
daily := forecast.SelectHourlyPeriods(ctx.Bundle.Daily, period)
alerts := forecast.AlertOverlaps(ctx.Bundle.Alerts, period)
summary := forecast.SummarizeDaypart("storm window", period, hourly)
summary.AlertOverlaps = alerts
storm := &Storm{
TimingWindow: period,
EventHeadlines: stormHeadlines(alerts),
Hazards: stormHazards(alerts, summary),
MostLikelyScenario: mostLikelyStormScenario(hourly, narrative, summary),
ReasonableWorstCase: reasonableWorstCase(alerts, summary),
ConfidenceInputs: stormConfidenceInputs(ctx.Bundle),
WhatToWatchNext: stormWatchItems(alerts, summary, ctx.Bundle),
RelevantAlerts: alerts,
HourlyPeriods: hourly,
DailyPeriods: daily,
NarrativePeriods: narrative,
WindowSummary: summary,
Discussion: buildDiscussion(ctx.Bundle.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
pkg := buildPackage(ctx)
pkg.Storm = storm
setRelevantAlertCount(&pkg.Metadata, len(alerts))
return pkg, nil
}
func stormHeadlines(alerts []forecast.AlertOverlap) []string {
var headlines []string
for _, alert := range alerts {
if alert.Headline != "" {
headlines = appendUnique(headlines, alert.Headline)
continue
}
if alert.Event != "" {
headlines = appendUnique(headlines, alert.Event)
}
}
if len(headlines) == 0 {
return []string{"No active alert headline overlaps the selected storm window."}
}
return headlines
}
func stormHazards(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
hazards := map[string]struct{}{}
for _, alert := range alerts {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
for _, hazard := range hazardsForIndicators(summary.Indicators) {
hazards[hazard] = struct{}{}
}
if summary.MaxPrecipitationProbability != nil && summary.MaxPrecipitationProbability.Value >= 50 {
hazards["precipitation"] = struct{}{}
}
if summary.PeakWindGust != nil && summary.PeakWindGust.Value >= 30 {
hazards["wind"] = struct{}{}
}
out := sortedSet(hazards)
if len(out) == 0 {
return []string{"No storm-specific hazard signal stands out in the selected source data."}
}
return out
}
func mostLikelyStormScenario(hourly []forecast.ForecastPeriod, narrative []forecast.ForecastPeriod, summary forecast.DaypartSummary) []string {
var items []string
if summary.DominantCondition != "" {
items = append(items, "Dominant hourly condition: "+summary.DominantCondition+".")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, fmt.Sprintf("Peak precipitation chance is near %.0f%% around %s.", summary.MaxPrecipitationProbability.Value, summary.MaxPrecipitationProbability.Time.Format("15:04")))
}
if summary.PeakWindGust != nil {
items = append(items, fmt.Sprintf("Peak wind gust is near %.0f mph around %s.", summary.PeakWindGust.Value, summary.PeakWindGust.Time.Format("15:04")))
}
for _, period := range narrative {
if period.TextDescription != "" {
items = append(items, "Narrative guidance: "+period.TextDescription)
break
}
}
if len(items) == 0 && len(hourly) > 0 {
items = append(items, "Hourly forecast periods are available, but no focused storm signal is prominent.")
}
if len(items) == 0 {
items = append(items, "No active storm signal is evident from the selected forecast window.")
}
return items
}
func reasonableWorstCase(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary) []string {
var items []string
for _, alert := range alerts {
label := alert.Event
if label == "" {
label = alert.Headline
}
if label != "" {
items = appendUnique(items, "Alert scenario to consider: "+label+".")
}
}
if summary.Indicators.Wind {
items = appendUnique(items, "Wind impacts could be higher where stronger gusts occur.")
}
if summary.Indicators.Snow || summary.Indicators.Ice {
items = appendUnique(items, "Wintry precipitation could create travel impacts if it overlaps the event window.")
}
if len(items) == 0 {
items = append(items, "No clear reasonable worst-case signal is represented in the selected data.")
}
return items
}
func stormConfidenceInputs(bundle *forecast.Bundle) []string {
var items []string
if bundle == nil {
return []string{"No source bundle was available for confidence context."}
}
if bundle.Discussion != nil {
items = appendUnique(items, bundle.Discussion.KeyMessages...)
if bundle.Discussion.ShortTerm != nil && bundle.Discussion.ShortTerm.Text != "" {
items = appendUnique(items, "Short-term discussion is available for confidence context.")
}
}
if bundle.WeatherStory != nil && len(bundle.WeatherStory.Raw) > 0 {
items = appendUnique(items, "Weather story source is available.")
}
for _, warning := range bundle.Warnings {
if warning.Code != "" {
items = appendUnique(items, "Source warning: "+warning.Code+".")
}
}
if len(items) == 0 {
items = append(items, "No explicit confidence or uncertainty signal was available from the selected source context.")
}
return items
}
func stormWatchItems(alerts []forecast.AlertOverlap, summary forecast.DaypartSummary, bundle *forecast.Bundle) []string {
var items []string
if len(alerts) > 0 {
items = append(items, "Watch for alert extensions, cancellations, or upgrades.")
}
if summary.MaxPrecipitationProbability != nil {
items = append(items, "Watch precipitation timing and probability trends.")
}
if summary.PeakWindGust != nil {
items = append(items, "Watch wind gust trends.")
}
if bundle != nil && bundle.Discussion != nil {
items = append(items, "Watch the next forecast discussion update for confidence changes.")
}
if len(items) == 0 {
items = append(items, "Watch for new alerts or stronger wording if the weather pattern changes.")
}
return appendUnique(nil, items...)
}

View File

@@ -1,158 +0,0 @@
package briefing
import (
"encoding/json"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
func TestStormBriefingWithActiveAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
precip := 80.0
gust := 42.0
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T07:00:00-05:00"),
EndTime: mustParse("2026-05-29T08:00:00-05:00"),
TextDescription: "Severe thunderstorms and gusty wind",
ProbabilityOfPrecipitationPercent: &precip,
WindGustMph: &gust,
}}},
Daily: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Storms likely.",
}}},
Narrative: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{
StartTime: mustParse("2026-05-29T06:00:00-05:00"),
EndTime: mustParse("2026-05-29T18:00:00-05:00"),
TextDescription: "Damaging wind possible in stronger storms.",
}}},
Alerts: &forecast.AlertRun{Alerts: []json.RawMessage{
json.RawMessage(`{"event":"Severe Thunderstorm Warning","headline":"Severe storms near Testville","severity":"Severe","effective":"2026-05-29T06:30:00-05:00","expires":"2026-05-29T08:30:00-05:00"}`),
}},
Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Storms may intensify quickly."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term storm coverage peaks this morning."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term pattern stays unsettled after the event."},
},
WeatherStory: &forecast.WeatherStory{Raw: json.RawMessage(`{"headline":"Storm risk"}`)},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if pkg.Metadata.ReportID != report.Storm {
t.Fatalf("ReportID = %q, want storm", pkg.Metadata.ReportID)
}
if pkg.Storm == nil {
t.Fatal("Storm = nil")
}
if len(pkg.Storm.RelevantAlerts) != 1 || len(pkg.Storm.EventHeadlines) != 1 {
t.Fatalf("alerts/headlines = %#v/%#v, want alert inputs", pkg.Storm.RelevantAlerts, pkg.Storm.EventHeadlines)
}
if !pkg.Storm.TimingWindow.Start.Equal(resolved.ValidPeriod.Start) || !pkg.Storm.TimingWindow.End.Equal(resolved.ValidPeriod.End) {
t.Fatalf("TimingWindow = %#v, want resolved valid period %#v", pkg.Storm.TimingWindow, resolved.ValidPeriod)
}
if !strings.Contains(strings.Join(pkg.Storm.Hazards, ","), "Severe Thunderstorm Warning") {
t.Fatalf("Hazards = %#v, want alert event", pkg.Storm.Hazards)
}
if len(pkg.Storm.HourlyPeriods) != 1 || len(pkg.Storm.DailyPeriods) != 1 || len(pkg.Storm.NarrativePeriods) != 1 {
t.Fatalf("selected periods hourly/daily/narrative = %d/%d/%d, want selected source periods", len(pkg.Storm.HourlyPeriods), len(pkg.Storm.DailyPeriods), len(pkg.Storm.NarrativePeriods))
}
if pkg.Storm.WeatherStory == nil {
t.Fatal("WeatherStory = nil, want available story context")
}
if pkg.Storm.Discussion.ShortTerm != "Short-term storm coverage peaks this morning." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.Storm.Discussion.ShortTerm)
}
if pkg.Storm.Discussion.LongTerm != "Long-term pattern stays unsettled after the event." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.Storm.Discussion.LongTerm)
}
if len(pkg.Storm.WhatToWatchNext) == 0 {
t.Fatal("WhatToWatchNext length = 0, want watch inputs")
}
}
func TestStormBriefingWithDiscussionButNoAlert(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Showers"}}},
Alerts: &forecast.AlertRun{},
Discussion: &forecast.Discussion{Product: "discussion", KeyMessages: []string{"Confidence is moderate."}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.RelevantAlerts) != 0 {
t.Fatalf("RelevantAlerts length = %d, want 0", len(pkg.Storm.RelevantAlerts))
}
if !strings.Contains(strings.Join(pkg.Storm.EventHeadlines, " "), "No active alert") {
t.Fatalf("EventHeadlines = %#v, want no-alert fallback", pkg.Storm.EventHeadlines)
}
if !strings.Contains(strings.Join(pkg.Storm.ConfidenceInputs, " "), "Confidence is moderate") {
t.Fatalf("ConfidenceInputs = %#v, want discussion key message", pkg.Storm.ConfidenceInputs)
}
if len(pkg.Storm.MostLikelyScenario) == 0 {
t.Fatal("MostLikelyScenario length = 0, want forecast scenario inputs")
}
}
func TestStormBriefingQuietWindow(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.Storm, report.ResolveRequest{
Now: mustParse("2026-05-29T05:00:00-05:00"),
Location: location,
StormStart: mustParse("2026-05-29T06:00:00-05:00"),
StormEnd: mustParse("2026-05-29T12:00:00-05:00"),
})
if err != nil {
t.Fatalf("resolve storm: %v", err)
}
bundle := &forecast.Bundle{
Hourly: &forecast.ForecastRun{Periods: []forecast.ForecastPeriod{{StartTime: mustParse("2026-05-29T07:00:00-05:00"), EndTime: mustParse("2026-05-29T08:00:00-05:00"), TextDescription: "Clear"}}},
Sources: []forecast.Source{{Name: "hourly", FetchedAt: time.Now()}},
}
pkg, err := BuildStorm(BuildContext{Resolved: resolved, Bundle: bundle, Units: "us", Timezone: "America/Chicago"})
if err != nil {
t.Fatalf("BuildStorm() error = %v", err)
}
if len(pkg.Storm.Hazards) != 1 || !strings.Contains(pkg.Storm.Hazards[0], "No storm-specific") {
t.Fatalf("Hazards = %#v, want quiet hazard fallback", pkg.Storm.Hazards)
}
if !strings.Contains(strings.Join(pkg.Storm.WhatToWatchNext, " "), "new alerts") {
t.Fatalf("WhatToWatchNext = %#v, want watch fallback", pkg.Storm.WhatToWatchNext)
}
}

View File

@@ -7,108 +7,38 @@ import (
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type Daily struct {
BottomLine BottomLine `json:"bottomLine"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
Planning *TomorrowPlanning `json:"planning,omitempty"`
NarrativePeriods []forecast.ForecastPeriod `json:"narrativePeriods,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
ForecastSummaryDate string `json:"forecastSummaryDate"`
}
type BottomLine struct {
Summary string `json:"summary"`
Hazards []string `json:"hazards,omitempty"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
}
type OutdoorWindows struct {
Best *OutdoorWindow `json:"best,omitempty"`
Worst *OutdoorWindow `json:"worst,omitempty"`
Best *OutdoorWindow
Worst *OutdoorWindow
}
type OutdoorWindow struct {
Daypart string `json:"daypart"`
Start string `json:"start"`
End string `json:"end"`
Reasons []string `json:"reasons,omitempty"`
Score float64 `json:"score"`
Daypart string
Period timeutil.Period
Reasons []string
Score float64
}
type TomorrowPlanning struct {
MorningReadiness []string `json:"morningReadiness,omitempty"`
CommuteSchoolWorkdayConcerns []string `json:"commuteSchoolWorkdayConcerns,omitempty"`
OvernightChangeWatch []string `json:"overnightChangeWatch,omitempty"`
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type DiscussionContext struct {
Product string `json:"product,omitempty"`
KeyMessages []string `json:"keyMessages,omitempty"`
ShortTerm string `json:"shortTerm,omitempty"`
LongTerm string `json:"longTerm,omitempty"`
type morningCommuteOvernightPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OvernightChangeWatch []string
}
type WeatherStoryContext struct {
Available bool `json:"available"`
Summary string `json:"summary,omitempty"`
}
func BuildDaily(ctx BuildContext, summary *forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.DailyToday && ctx.Resolved.Definition.ID != report.DailyTomorrow {
return Package{}, fmt.Errorf("daily briefing requires a daily report definition")
}
if summary == nil {
return Package{}, fmt.Errorf("daily forecast summary is required")
}
pkg := buildPackage(ctx)
pkg.Daily = &Daily{
BottomLine: buildBottomLine(summary),
Dayparts: summary.Dayparts,
RelevantAlerts: summary.AlertOverlaps,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
NarrativePeriods: summary.NarrativePeriods,
Discussion: buildDiscussion(summary.Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
ForecastSummaryDate: summary.Date,
}
setRelevantAlertCount(&pkg.Metadata, len(summary.AlertOverlaps))
if ctx.Resolved.Definition.ID == report.DailyTomorrow {
pkg.Daily.Planning = buildTomorrowPlanning(summary)
}
return pkg, nil
}
func buildBottomLine(summary *forecast.DailySummary) BottomLine {
bottomLine := BottomLine{}
conditions := map[string]struct{}{}
hazards := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&bottomLine.Temperature, daypart.Temperature)
maxTimedValue(&bottomLine.MaxPrecipProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&bottomLine.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, hazard := range hazardsForIndicators(daypart.Indicators) {
hazards[hazard] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
hazards[alert.Event] = struct{}{}
}
}
bottomLine.Hazards = sortedSet(hazards)
bottomLine.Summary = bottomLineText(sortedSet(conditions), bottomLine.Hazards)
return bottomLine
type TodayPlanning struct {
MorningReadiness []string
CommuteSchoolWorkdayConcerns []string
OutdoorPlanning []string
LateDayChangeWatch []string
}
func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
@@ -131,8 +61,60 @@ func buildOutdoorWindows(dayparts []forecast.DaypartSummary) OutdoorWindows {
return OutdoorWindows{Best: best, Worst: worst}
}
func buildTodayPlanning(summary *forecast.DailySummary) *TodayPlanning {
planning := &TodayPlanning{}
morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
}
if len(planning.MorningReadiness) == 0 {
planning.MorningReadiness = append(planning.MorningReadiness, "Morning weather looks routine based on the available hourly forecast.")
}
for _, daypart := range summary.Dayparts {
if daypart.Name == "overnight" || daypart.Name == "evening" {
continue
}
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, concernNotes(daypart)...)
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
planning.CommuteSchoolWorkdayConcerns = appendUnique(planning.CommuteSchoolWorkdayConcerns, "Active alert to plan around: "+alert.Event+".")
}
}
if len(planning.CommuteSchoolWorkdayConcerns) == 0 {
planning.CommuteSchoolWorkdayConcerns = append(planning.CommuteSchoolWorkdayConcerns, "No major commute, school, or workday weather concerns stand out in the available forecast.")
}
planning.OutdoorPlanning = append(planning.OutdoorPlanning, outdoorPlanningNotes(summary.Dayparts)...)
if len(planning.OutdoorPlanning) == 0 {
planning.OutdoorPlanning = append(planning.OutdoorPlanning, "No standout outdoor weather constraints are evident in the available forecast.")
}
for _, name := range []string{"afternoon", "evening"} {
daypart := daypartNamed(summary.Dayparts, name)
if daypart != nil {
planning.LateDayChangeWatch = appendUnique(planning.LateDayChangeWatch, lateDayWatchNotes(*daypart)...)
}
}
if len(planning.LateDayChangeWatch) == 0 {
planning.LateDayChangeWatch = append(planning.LateDayChangeWatch, "Watch for forecast timing or intensity adjustments later today.")
}
return planning
}
func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
planning := &TomorrowPlanning{}
base := buildMorningCommuteOvernightPlanning(summary)
return &TomorrowPlanning{
MorningReadiness: append([]string(nil), base.MorningReadiness...),
CommuteSchoolWorkdayConcerns: append([]string(nil), base.CommuteSchoolWorkdayConcerns...),
OvernightChangeWatch: append([]string(nil), base.OvernightChangeWatch...),
}
}
func buildMorningCommuteOvernightPlanning(summary *forecast.DailySummary) *morningCommuteOvernightPlanning {
planning := &morningCommuteOvernightPlanning{}
morning := daypartNamed(summary.Dayparts, "morning")
if morning != nil {
planning.MorningReadiness = append(planning.MorningReadiness, readinessNotes(*morning)...)
@@ -167,6 +149,18 @@ func buildTomorrowPlanning(summary *forecast.DailySummary) *TomorrowPlanning {
return planning
}
func outdoorPlanningNotes(dayparts []forecast.DaypartSummary) []string {
windows := buildOutdoorWindows(dayparts)
var notes []string
if windows.Best != nil {
notes = append(notes, fmt.Sprintf("Best outdoor window: %s (%s).", titleWord(windows.Best.Daypart), strings.Join(windows.Best.Reasons, ", ")))
}
if windows.Worst != nil && (windows.Best == nil || windows.Worst.Daypart != windows.Best.Daypart) {
notes = append(notes, fmt.Sprintf("Toughest outdoor window: %s (%s).", titleWord(windows.Worst.Daypart), strings.Join(windows.Worst.Reasons, ", ")))
}
return appendUnique(nil, notes...)
}
func readinessNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
@@ -187,6 +181,27 @@ func readinessNotes(daypart forecast.DaypartSummary) []string {
return appendUnique(nil, notes...)
}
func lateDayWatchNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
if prefix == "" {
prefix = "Late-day"
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s precipitation timing may shift; current peak is near %.0f%%.", prefix, daypart.MaxPrecipitationProbability.Value))
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
notes = append(notes, fmt.Sprintf("%s gusts may reach %.0f mph.", prefix, daypart.PeakWindGust.Value))
}
if daypart.Indicators.Snow || daypart.Indicators.Ice {
notes = append(notes, prefix+" wintry weather could affect late-day travel.")
}
if len(daypart.AlertOverlaps) > 0 {
notes = append(notes, prefix+" alert timing could affect late-day plans.")
}
return appendUnique(nil, notes...)
}
func concernNotes(daypart forecast.DaypartSummary) []string {
notes := []string{}
prefix := titleWord(daypart.Name)
@@ -240,30 +255,6 @@ func daypartNamed(dayparts []forecast.DaypartSummary, name string) *forecast.Day
return nil
}
func buildDiscussion(discussion *forecast.Discussion) DiscussionContext {
if discussion == nil {
return DiscussionContext{}
}
ctx := DiscussionContext{
Product: discussion.Product,
KeyMessages: discussion.KeyMessages,
}
if discussion.ShortTerm != nil {
ctx.ShortTerm = discussion.ShortTerm.Text
}
if discussion.LongTerm != nil {
ctx.LongTerm = discussion.LongTerm.Text
}
return ctx
}
func buildWeatherStory(bundle *forecast.Bundle) *WeatherStoryContext {
if bundle == nil || bundle.WeatherStory == nil || len(bundle.WeatherStory.Raw) == 0 {
return nil
}
return &WeatherStoryContext{Available: true, Summary: string(bundle.WeatherStory.Raw)}
}
func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
score := 0.0
reasons := []string{}
@@ -297,27 +288,12 @@ func scoreOutdoorWindow(daypart forecast.DaypartSummary) OutdoorWindow {
}
return OutdoorWindow{
Daypart: daypart.Name,
Start: daypart.Period.Start.Format("15:04"),
End: daypart.Period.End.Format("15:04"),
Period: daypart.Period,
Reasons: dedupe(reasons),
Score: math.Round(score*10) / 10,
}
}
func bottomLineText(conditions []string, hazards []string) string {
if len(conditions) == 0 && len(hazards) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, "Conditions: "+strings.Join(conditions, "; "))
}
if len(hazards) > 0 {
parts = append(parts, "Watch points: "+strings.Join(hazards, "; "))
}
return strings.Join(parts, ". ") + "."
}
func hazardsForIndicators(indicators forecast.Indicators) []string {
var hazards []string
if indicators.Snow {

View File

@@ -1,128 +0,0 @@
package briefing
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
type ThreeDay struct {
Days []OutlookDay `json:"days"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Discussion DiscussionContext `json:"discussion,omitempty"`
WeatherStory *WeatherStoryContext `json:"weatherStory,omitempty"`
}
type OutlookDay struct {
Date string `json:"date"`
Period timeutil.Period `json:"period"`
OverallCharacter string `json:"overallCharacter"`
Temperature forecast.Range `json:"temperature,omitempty"`
MaxPrecipitationProbability *forecast.TimedValue `json:"maxPrecipitationProbability,omitempty"`
PeakWindGust *forecast.TimedValue `json:"peakWindGust,omitempty"`
Risks []string `json:"risks,omitempty"`
OutdoorWindows OutdoorWindows `json:"outdoorWindows"`
RelevantAlerts []forecast.AlertOverlap `json:"relevantAlerts,omitempty"`
Dayparts []forecast.DaypartSummary `json:"dayparts"`
}
func BuildThreeDay(ctx BuildContext, summaries []forecast.DailySummary) (Package, error) {
if ctx.Resolved.Definition.ID != report.ThreeDay {
return Package{}, fmt.Errorf("3-day briefing requires a 3-day report definition")
}
if len(summaries) == 0 {
return Package{}, fmt.Errorf("3-day forecast summaries are required")
}
pkg := buildPackage(ctx)
pkg.ThreeDay = &ThreeDay{
Discussion: buildDiscussion(summaries[0].Discussion),
WeatherStory: buildWeatherStory(ctx.Bundle),
}
for _, summary := range summaries {
day := buildOutlookDay(summary)
pkg.ThreeDay.Days = append(pkg.ThreeDay.Days, day)
}
pkg.ThreeDay.RelevantAlerts = collectOutlookAlerts(pkg.ThreeDay.Days)
setRelevantAlertCount(&pkg.Metadata, len(pkg.ThreeDay.RelevantAlerts))
return pkg, nil
}
func collectOutlookAlerts(days []OutlookDay) []forecast.AlertOverlap {
alerts := map[string]forecast.AlertOverlap{}
for _, day := range days {
for _, alert := range day.RelevantAlerts {
key := alert.Event
if key == "" {
key = alert.Headline
}
if key != "" {
alerts[key] = alert
}
}
}
keys := make([]string, 0, len(alerts))
for key := range alerts {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]forecast.AlertOverlap, 0, len(keys))
for _, key := range keys {
out = append(out, alerts[key])
}
return out
}
func buildOutlookDay(summary forecast.DailySummary) OutlookDay {
day := OutlookDay{
Date: summary.Date,
Period: summary.Period,
OutdoorWindows: buildOutdoorWindows(summary.Dayparts),
RelevantAlerts: summary.AlertOverlaps,
Dayparts: summary.Dayparts,
}
conditions := map[string]struct{}{}
risks := map[string]struct{}{}
for _, daypart := range summary.Dayparts {
addRange(&day.Temperature, daypart.Temperature)
maxTimedValue(&day.MaxPrecipitationProbability, daypart.MaxPrecipitationProbability)
maxTimedValue(&day.PeakWindGust, daypart.PeakWindGust)
if daypart.DominantCondition != "" {
conditions[daypart.DominantCondition] = struct{}{}
}
for _, risk := range hazardsForIndicators(daypart.Indicators) {
risks[risk] = struct{}{}
}
if daypart.MaxPrecipitationProbability != nil && daypart.MaxPrecipitationProbability.Value >= 50 {
risks["precipitation"] = struct{}{}
}
if daypart.PeakWindGust != nil && daypart.PeakWindGust.Value >= 30 {
risks["wind"] = struct{}{}
}
}
for _, alert := range summary.AlertOverlaps {
if alert.Event != "" {
risks[alert.Event] = struct{}{}
}
}
day.Risks = sortedSet(risks)
day.OverallCharacter = outlookCharacter(sortedSet(conditions), day.Risks)
return day
}
func outlookCharacter(conditions []string, risks []string) string {
if len(conditions) == 0 && len(risks) == 0 {
return "Quiet weather is expected."
}
parts := []string{}
if len(conditions) > 0 {
parts = append(parts, strings.Join(conditions, "; "))
}
if len(risks) > 0 {
parts = append(parts, "risks: "+strings.Join(risks, "; "))
}
return strings.Join(parts, ". ") + "."
}

View File

@@ -1,94 +0,0 @@
package briefing
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/forecast"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
"gitea.maximumdirect.net/eric/weatherreporter/internal/timeutil"
)
func TestThreeDayBriefingBuildsOutlookDays(t *testing.T) {
location := mustLocation(t)
resolved, err := report.Resolve(report.ThreeDay, report.ResolveRequest{
Now: mustParse("2026-05-29T06:00:00-05:00"),
Location: location,
})
if err != nil {
t.Fatalf("resolve 3-day: %v", err)
}
precip := 70.0
gust := 35.0
summaries := []forecast.DailySummary{
{
Date: "2026-05-29",
Period: timeutil.Period{
Start: mustParse("2026-05-29T06:00:00-05:00"),
End: mustParse("2026-05-30T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{
{
Name: "morning",
DominantCondition: "Showers and thunderstorms",
MaxPrecipitationProbability: &forecast.TimedValue{
Value: precip,
Time: mustParse("2026-05-29T09:00:00-05:00"),
},
PeakWindGust: &forecast.TimedValue{
Value: gust,
Time: mustParse("2026-05-29T10:00:00-05:00"),
},
Indicators: forecast.Indicators{Wind: true},
},
},
AlertOverlaps: []forecast.AlertOverlap{{Event: "Flood Watch"}},
Discussion: &forecast.Discussion{
Product: "discussion",
KeyMessages: []string{"Unsettled stretch."},
ShortTerm: &forecast.DiscussionSection{Text: "Short-term rain chances remain focused today."},
LongTerm: &forecast.DiscussionSection{Text: "Long-term warmth builds into the weekend."},
},
},
{
Date: "2026-05-30",
Period: timeutil.Period{
Start: mustParse("2026-05-30T00:00:00-05:00"),
End: mustParse("2026-05-31T00:00:00-05:00"),
},
Dayparts: []forecast.DaypartSummary{{Name: "afternoon", DominantCondition: "Clear"}},
},
}
pkg, err := BuildThreeDay(BuildContext{
Resolved: resolved,
Units: "us",
Timezone: "America/Chicago",
}, summaries)
if err != nil {
t.Fatalf("BuildThreeDay() error = %v", err)
}
if pkg.Metadata.ReportID != report.ThreeDay {
t.Fatalf("ReportID = %q, want three_day", pkg.Metadata.ReportID)
}
if pkg.ThreeDay == nil {
t.Fatal("ThreeDay = nil")
}
if len(pkg.ThreeDay.Days) != 2 {
t.Fatalf("Days length = %d, want 2", len(pkg.ThreeDay.Days))
}
first := pkg.ThreeDay.Days[0]
if !strings.Contains(first.OverallCharacter, "Showers") || !strings.Contains(strings.Join(first.Risks, ","), "wind") {
t.Fatalf("first day = %#v, want conditions and risks", first)
}
if len(pkg.ThreeDay.RelevantAlerts) != 1 {
t.Fatalf("RelevantAlerts length = %d, want 1", len(pkg.ThreeDay.RelevantAlerts))
}
if pkg.ThreeDay.Discussion.ShortTerm != "Short-term rain chances remain focused today." {
t.Fatalf("Discussion.ShortTerm = %q, want short-term AFD narrative", pkg.ThreeDay.Discussion.ShortTerm)
}
if pkg.ThreeDay.Discussion.LongTerm != "Long-term warmth builds into the weekend." {
t.Fatalf("Discussion.LongTerm = %q, want long-term AFD narrative", pkg.ThreeDay.Discussion.LongTerm)
}
}

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