From a990da957bd737e18e335c25bb9750f7456e5447 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 10 Jun 2026 19:54:39 -0500 Subject: [PATCH] Finalize SPC outlook feature addition and clean up implemented roadmap documentation --- docs/integrations/postgres.md | 2 + docs/roadmap/future.md | 60 +++ docs/roadmap/implementation.md | 103 ------ docs/roadmap/spc.md | 349 ------------------ .../normalizers/spc/convective_outlook.go | 3 +- .../spc/convective_outlook_test.go | 3 + internal/providers/spc/discussion.go | 32 +- internal/providers/spc/discussion_test.go | 39 +- internal/providers/spc/testdata/day1_prt.html | 5 +- .../providers/spc/testdata/day2_prt_corr.html | 4 + internal/providers/spc/testdata/day3_prt.html | 5 +- internal/sinks/postgres/doc.go | 2 + internal/sinks/postgres/map.go | 2 + internal/sinks/postgres/map_test.go | 57 +++ internal/sinks/postgres/schema.go | 2 + internal/sinks/postgres/schema_test.go | 2 +- internal/sources/spc/convective_outlook.go | 6 +- 17 files changed, 203 insertions(+), 473 deletions(-) create mode 100644 docs/roadmap/future.md delete mode 100644 docs/roadmap/implementation.md delete mode 100644 docs/roadmap/spc.md diff --git a/docs/integrations/postgres.md b/docs/integrations/postgres.md index 767510c..0a1d894 100644 --- a/docs/integrations/postgres.md +++ b/docs/integrations/postgres.md @@ -429,6 +429,8 @@ Indexes: | `run_event_id` | `TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE` | no | Parent event ID. | | `outlook_index` | `INTEGER` | no | `payload.outlooks[]` index. | | `as_of` | `TIMESTAMPTZ` | no | Parent `payload.asOf` | +| `outlook_id` | `TEXT` | no | `payload.outlooks[].id` | +| `provider` | `TEXT` | no | `payload.outlooks[].provider` | | `product` | `TEXT` | no | `payload.outlooks[].product` | | `day` | `INTEGER` | no | `payload.outlooks[].day` | | `outlook_type` | `TEXT` | no | `payload.outlooks[].outlookType` | diff --git a/docs/roadmap/future.md b/docs/roadmap/future.md new file mode 100644 index 0000000..d5426a1 --- /dev/null +++ b/docs/roadmap/future.md @@ -0,0 +1,60 @@ +# Future Work + +## Purpose + +This document is the catch-all roadmap for planned, deferred, aspirational, experimental, or unimplemented weatherfeeder work. Current behavior belongs in the canonical docs outside `docs/roadmap/`. + +## SPC Convective Outlook Follow-Ups + +### Weatherapi Outlook Endpoints + +Expose persisted SPC convective outlooks through `weatherapi` after the weatherfeeder storage contract is stable. + +Likely endpoints: + +- `GET /outlooks/convective` +- `GET /outlooks/convective/active` +- `GET /outlooks/convective/location` + +Recommended behavior: + +- Return the latest outlook run by default. +- Support active outlook filtering by current time and `containsLocation=true`. +- Consider optional query filters for `day`, `outlookType`, and `containsLocation`. +- Preserve canonical outlook geometry for downstream display and audit use. + +### SPC Day 4-8 Outlooks + +Add SPC Day 4-8 convective outlook support as a schema-compatible extension only after Day 1-3 operation is proven. + +Notes: + +- Day 4-8 products have different semantics from Day 1-3 categorical/tornado/hail/wind products. +- Avoid forcing Day 4-8 assumptions into the current Day 1-3 model until the source shapes and consumer needs are reviewed. +- Prefer reusing `weather.outlook.v1` if the fields remain accurate; otherwise write a separate roadmap before changing the canonical contract. + +### Degraded SPC Bundle Mode + +Evaluate whether the SPC source should support degraded partial bundles when one required upstream product fails. + +Current behavior should remain atomic: if a required GeoJSON or print-page fetch fails, emit no event for that poll. + +Future degraded mode would need a clear contract for: + +- distinguishing "no risk polygon" from "product missing"; +- exposing per-product fetch errors without leaking raw provider internals into canonical events; +- deciding whether downstream sinks and APIs should store or serve partial snapshots. + +### Richer SPC Page Assets And Tables + +Evaluate whether to parse additional SPC print-page metadata beyond the current discussion text. + +Possible additions: + +- archive GeoJSON/shapefile/KML links; +- image URLs; +- page risk tables; +- city tables; +- richer discussion section metadata. + +Keep GeoJSON products authoritative for polygons, validity windows, and point matching unless a future roadmap explicitly changes that contract. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md deleted file mode 100644 index 1490b4e..0000000 --- a/docs/roadmap/implementation.md +++ /dev/null @@ -1,103 +0,0 @@ -# SPC Convective Outlook Remaining Work Plan - -## Summary - -The SPC convective outlook feature is mostly implemented. This roadmap now tracks only the remaining remediation work needed to align the implementation with the intended contract and live SPC print-page behavior. - -Do not use this document as the original full feature implementation plan. It is a focused completion plan for the outstanding gaps below. - -## Remaining Work - -### Postgres Persistence Gap - -Canonical `model.WeatherOutlook` includes required `id` and `provider` fields, but the current Postgres `outlooks` table and mapper do not persist them. This makes Postgres persistence lossy for `weather.outlook.v1`. - -Required changes: - -- Add `outlook_id TEXT NOT NULL` to the `outlooks` table contract. -- Add `provider TEXT NOT NULL` to the `outlooks` table contract. -- Map `model.WeatherOutlook.ID` to `outlook_id`. -- Map `model.WeatherOutlook.Provider` to `provider`. -- Keep existing required-field validation for empty `id` and `provider`. -- Update Postgres schema, mapper, schema tests, mapper tests, internal Postgres docs, and Postgres integration docs. -- Do not include manual database migration commands in this roadmap; keep this plan focused on schema/code/docs expectations. - -### SPC Print-Page Parsing Gap - -The current parser fixtures do not match the live SPC print-page structure closely enough. Live SPC print pages currently place the page-level `Updated:` row outside the `
` product text, and the product text inside `
` begins with an SPC product code line such as `SPC AC 101959` before the human title line.
-
-Required changes:
-
-- Update SPC print-page fixtures to match live shape:
-  - page-level `Updated:` appears outside the `
` block
-  - `
` content begins with an `SPC AC ...` product code line
-  - the human outlook title appears on the following line, such as `Day 1 Convective Outlook`
-- Parse page-level `Updated:` timestamps from the full HTML document, not only from extracted product text.
-- Keep cleaned product text extraction focused on the `
` block.
-- Preserve the leading `SPC AC ...` line in canonical `discussion` text.
-- Update product-title/headline parsing to skip product code lines like `SPC AC 101959` and choose the human outlook title line.
-- Preserve correction markers such as `CORR 1` in the selected headline/title when present.
-- Continue stripping scripts and HTML tags from extracted product text.
-
-### Source Effective-Time Correction
-
-The source should continue to prefer GeoJSON issue timestamps, but the print-page fallback should use the page-level `Updated:` parser described above.
-
-Effective-time order should remain:
-
-1. latest valid GeoJSON `ISSUE_ISO`
-2. latest valid print-page `Updated:` timestamp parsed from full HTML
-3. RSS `lastBuildDate`, when optional RSS is configured and parseable
-4. fetch time
-
-Required changes:
-
-- Update source discussion timestamp extraction to parse from full HTML.
-- Keep RSS optional and supplemental only.
-- Keep unchanged-response hashing, atomic required fetch behavior, and fetch-time fallback unchanged.
-
-## Test Plan
-
-Provider parser tests:
-
-- Fixture covers live print-page shape with `Updated:` outside `
`.
-- Fixture product text begins with `SPC AC ...`.
-- Headline resolves to `Day X Convective Outlook`, not `SPC AC ...`.
-- Page-level `Updated:` parses to UTC.
-- Full cleaned discussion text preserves the `SPC AC ...` line.
-- Script and tag cleanup remains covered.
-- `CORR 1` remains preserved in headline/title and discussion text.
-
-Source tests:
-
-- `effectiveAt` falls back to page-level `Updated:` when GeoJSON issue times are unavailable.
-- Existing atomic fetch, unchanged-response, required URL failure, and optional RSS tests remain passing.
-
-Normalizer tests:
-
-- Canonical `headline` uses the human outlook title.
-- Canonical `discussion` preserves full cleaned product text.
-- Existing `CORR 1`, summary, and day-to-discussion mapping tests remain passing.
-
-Postgres tests:
-
-- Schema includes `outlook_id` and `provider` on `outlooks`.
-- Mapper writes `outlook_id` from `WeatherOutlook.ID`.
-- Mapper writes `provider` from `WeatherOutlook.Provider`.
-- Required-field validation still rejects empty `id` and `provider`.
-- Existing compact geometry and `contains_location=false` tests remain passing.
-
-Verification commands:
-
-```sh
-go test ./internal/providers/spc ./internal/sources/spc ./internal/normalizers/spc ./internal/sinks/postgres
-go test ./...
-```
-
-## Assumptions
-
-- The canonical API shape remains unchanged: `WeatherOutlook.id` and `WeatherOutlook.provider` are already required fields.
-- Postgres persistence should be lossless for required canonical outlook fields.
-- Existing database migration commands are intentionally omitted from this roadmap.
-- RSS remains optional supplemental metadata only.
-- This remediation plan does not include weatherapi changes.
diff --git a/docs/roadmap/spc.md b/docs/roadmap/spc.md
deleted file mode 100644
index a288c5b..0000000
--- a/docs/roadmap/spc.md
+++ /dev/null
@@ -1,349 +0,0 @@
-# SPC Convective Outlook Support Roadmap
-
-## Summary
-
-Add `weatherfeeder` support for Storm Prediction Center convective outlooks as a new canonical outlook domain. The feature should poll SPC GeoJSON outlook products, enrich them with Day 1-3 print-page discussion text, compute whether the configured forecast point is inside each active outlook polygon, normalize the result into a provider-independent schema, and persist it through existing sinks.
-
-This is a new domain, not an extension of `weather.alert.v1`. SPC outlooks describe probabilistic/categorical risk areas over a validity window; alerts describe active actionable hazard messages.
-
-## Public Contract
-
-Add schema constants:
-
-- Raw schema: `raw.spc.convective_outlook.v1`
-- Canonical schema: `weather.outlook.v1`
-
-Add source driver:
-
-- `spc_convective_outlook`
-
-Add event kind:
-
-- `outlook`
-
-Add canonical model types:
-
-- `model.WeatherOutlookRun`
-- `model.WeatherOutlook`
-
-Recommended canonical run fields:
-
-- `locationId`, `locationName`
-- `latitude`, `longitude`
-- `asOf`
-- `issuedAt`
-- `outlooks`
-
-Recommended canonical outlook fields:
-
-- `id`
-- `provider`
-- `product`
-- `day`
-- `outlookType`
-- `label`
-- `labelText`
-- `severityRank`
-- `validFrom`
-- `validTo`
-- `issuedAt`
-- `expiresAt`
-- `forecaster`
-- `headline`
-- `summary`
-- `discussion`
-- `sourceUrl`
-- `imageUrl`
-- `containsLocation`
-- `geometry`
-
-Contract defaults:
-
-- `product` should be `convective`.
-- `outlookType` should be one of `categorical`, `tornado`, `hail`, `wind`.
-- `day` should be `1`, `2`, or `3` for this first implementation.
-- `containsLocation` is computed against configured forecast coordinates.
-- `geometry` should preserve compact GeoJSON geometry for auditability and future API use.
-- Day 4-8 outlooks are out of scope for this first implementation. They have different product semantics and should be added in a later schema-compatible extension only after Day 1-3 support is proven.
-
-## Source Scope
-
-The source should fetch a bundle of SPC products in one poll cycle and emit one raw event containing fetched GeoJSON products, fetched Day 1-3 print pages, optional RSS metadata, configured point metadata, and per-product fetch metadata. The source may decode minimal timestamps for event identity/effective time, but provider-to-canonical mapping belongs in the normalizer.
-
-Poll these GeoJSON URLs:
-
-- `https://www.spc.noaa.gov/products/outlook/day1otlk_cat.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day1otlk_torn.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day1otlk_hail.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day1otlk_wind.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day2otlk_cat.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day2otlk_torn.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day2otlk_hail.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day2otlk_wind.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day3otlk_cat.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day3otlk_torn.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day3otlk_hail.nolyr.geojson`
-- `https://www.spc.noaa.gov/products/outlook/day3otlk_wind.nolyr.geojson`
-
-Implementation note: the initial candidate list duplicated Day 3 wind and omitted Day 2 wind. Use the corrected 12-product matrix above.
-
-Poll these print-page discussion URLs:
-
-- `https://www.spc.noaa.gov/products/outlook/day1otlk_prt.html`
-- `https://www.spc.noaa.gov/products/outlook/day2otlk_prt.html`
-- `https://www.spc.noaa.gov/products/outlook/day3otlk_prt.html`
-
-Print-page usage:
-
-- Use print pages as the authoritative source for Day 1-3 outlook discussion text.
-- Preserve source URL, page title/header where available, page-level `Updated:` timestamp where parseable, and the raw HTML or extracted `
` product block in the raw bundle.
-- Extract canonical `headline`, `summary`, `discussion`, and discussion metadata in the normalizer or shared SPC provider helper.
-- Treat the GeoJSON files, not print pages, as authoritative for polygons and validity windows.
-- Ignore page risk tables and image assets for v1 unless they are needed to support discussion parsing.
-
-Optionally poll:
-
-- `https://www.spc.noaa.gov/products/spcacrss.xml`
-
-RSS usage:
-
-- Use RSS as supplemental discovery or health metadata, not as the primary discussion or geometry source.
-- Preserve item title, link, description text, pubDate, and guid where useful.
-- Do not depend on RSS completeness for Day 1-3 discussion text. The RSS feed can omit current outlook products, including a current Day 2 discussion.
-- Do not attempt to parse polygons from RSS HTML.
-
-Polling cadence:
-
-- Use a default cadence of `30m`, not daily/twice-daily. SPC current outlook files can update several times per day, and a 30-minute poll with HTTP caching is low cost and less likely to miss updates.
-- Rely on ETag/Last-Modified handling from the HTTP source layer where available.
-- Keep the cadence configurable via `every`.
-
-Recommended config shape:
-
-```yaml
-- name: SPCConvectiveOutlookSTL
-  mode: poll
-  kinds: ["outlook"]
-  driver: spc_convective_outlook
-  every: 30m
-  params:
-    latitude: 38.6239
-    longitude: -90.3571
-    location_id: "stl"
-    location_name: "St. Louis, MO"
-    user_agent: "HomeOps (eric@maximumdirect.net)"
-```
-
-The source should own default SPC URLs, with optional params to override GeoJSON URLs, print-page URLs, and the RSS URL for tests or future provider changes.
-
-## Implementation Stages
-
-### Stage 1: Raw Source and Schema
-
-- Add schema constants.
-- Add `internal/sources/spc` package.
-- Implement `spc_convective_outlook` as a poll source.
-- Fetch all configured GeoJSON products and Day 1-3 print pages during a poll cycle.
-- Fetch RSS only when configured or retained as an optional supplemental source.
-- Emit one raw event with kind `outlook` and schema `raw.spc.convective_outlook.v1`.
-- Use the latest valid `ISSUE_ISO`, print-page `Updated:` timestamp, RSS `lastBuildDate`, or fetch time for `effectiveAt`, in that order.
-- Preserve partial fetch errors as source errors for the first implementation; do not emit incomplete outlook bundles unless a later explicit degraded-mode policy is added.
-- Keep source tests deterministic with local test servers and fixtures; do not call live SPC services from unit tests.
-
-Tests:
-
-- source driver builds as a `PollSource`
-- source advertises kind `outlook`
-- source emits one raw bundled event
-- source chooses expected effective time
-- source uses configured location metadata
-- source fails clearly on missing latitude/longitude
-- source fails clearly when a configured GeoJSON or print-page fetch fails
-
-### Stage 2: GeoJSON and Spatial Matching
-
-- Add an internal geometry helper package, for example `internal/geo`.
-- Support GeoJSON `Polygon` and `MultiPolygon`.
-- Implement point-in-polygon with ring support:
-  - first ring is the exterior
-  - subsequent rings are holes
-  - boundary counts as inside
-  - GeoJSON coordinate order is `[longitude, latitude]`
-- Use planar ray casting for this operational point-in-polygon check.
-
-Tests:
-
-- point inside polygon
-- point outside polygon
-- point on polygon boundary
-- point inside a hole is outside
-- point inside one multipolygon member is inside
-- longitude/latitude order regression test
-
-### Stage 3: Normalizer and Canonical Mapping
-
-- Add `model.WeatherOutlookRun` and `model.WeatherOutlook`.
-- Add `SPCConvectiveOutlookNormalizer`.
-- Match only `raw.spc.convective_outlook.v1`.
-- Decode the raw source bundle.
-- Map each GeoJSON feature to one `WeatherOutlook`.
-- Preserve SPC feature order within each product, then order products by day and type.
-- Map properties:
-  - `VALID_ISO` -> `validFrom`
-  - `EXPIRE_ISO` -> `validTo` and `expiresAt`
-  - `ISSUE_ISO` -> `issuedAt`
-  - `FORECASTER` -> `forecaster`
-  - `LABEL` -> `label`
-  - `LABEL2` -> `labelText`
-  - `DN` -> `severityRank`
-- Derive:
-  - `day` from product key or URL
-  - `outlookType` from product key or URL
-  - `id` from day, type, label, issuedAt, validFrom, and feature index
-  - `containsLocation` from configured point and GeoJSON geometry
-- Enrich narrative fields from the matching Day 1-3 print page:
-  - parse the main product text from the `
` block
-  - strip embedded tags and scripts before text parsing
-  - preserve line breaks in discussion text
-  - parse page/title metadata, product title, issued line, valid line, correction markers such as `CORR 1`, forecaster, summary, and full discussion where practical
-  - use print-page text for canonical `headline`, `summary`, and `discussion`
-- Use RSS only as optional supplemental metadata when present; missing or incomplete RSS must not prevent GeoJSON plus print-page normalization.
-- Set run `asOf` to the latest valid `issuedAt` across features, falling back to print-page `Updated:` timestamps, RSS `lastBuildDate`, then input event time.
-- Set run `issuedAt` to the latest valid `issuedAt` across features.
-- Set output event `effectiveAt` to run `asOf`.
-
-Tests:
-
-- normalizer routes only raw SPC schema
-- categorical and probabilistic products map expected fields
-- `containsLocation` is true for a known point inside a fixture polygon
-- `containsLocation` is false outside
-- print-page discussion text maps to canonical headline, summary, and discussion
-- correction markers such as `CORR 1` are preserved where present
-- missing optional RSS still permits GeoJSON and print-page normalization
-- malformed required GeoJSON timestamps fail with useful context
-- canonical JSON shape does not expose raw-provider-only bundle internals
-
-### Stage 4: Postgres Sink
-
-Add tables:
-
-- `outlook_runs`
-- `outlooks`
-
-Suggested `outlook_runs` columns:
-
-- event envelope columns
-- `location_id`
-- `location_name`
-- `latitude`
-- `longitude`
-- `as_of`
-- `issued_at`
-- `outlook_count`
-
-Suggested `outlooks` columns:
-
-- `run_event_id`
-- `outlook_index`
-- `as_of`
-- `product`
-- `day`
-- `outlook_type`
-- `label`
-- `label_text`
-- `severity_rank`
-- `valid_from`
-- `valid_to`
-- `issued_at`
-- `expires_at`
-- `forecaster`
-- `headline`
-- `summary`
-- `discussion`
-- `source_url`
-- `image_url`
-- `contains_location`
-- `geometry_json`
-
-Suggested indexes:
-
-- `outlook_runs(location_id, as_of)`
-- `outlooks(contains_location, valid_from, valid_to)`
-- `outlooks(day, outlook_type, label)`
-- `outlooks(valid_from, valid_to)`
-
-Mapping rules:
-
-- Store `geometry` as compact GeoJSON text in `geometry_json`.
-- Require run `asOf`.
-- Require outlook `validFrom`, `validTo`, `issuedAt`, `day`, `outlookType`, and `label`.
-- Preserve all outlook polygons, not only polygons containing the configured point.
-
-Tests:
-
-- schema includes outlook tables and indexes
-- mapper writes one run row plus one row per outlook
-- mapper stores compact geometry JSON
-- mapper rejects missing required run/outlook fields
-
-### Stage 5: Config and Documentation
-
-- Update sample config with `SPCConvectiveOutlookSTL`.
-- Add `outlook` to route examples.
-- Update current-behavior docs in the implementation change, not before behavior exists.
-- Update `docs/config.md` with source params.
-- Update `docs/integrations/events.md` with `weather.outlook.v1`.
-- Update `docs/integrations/postgres.md` with outlook table contract.
-- Update relevant internal docs under `docs/internal/` if source, normalizer, or sink behavior needs maintainer context.
-- Update `README.md` only with brief implemented-provider capability and links to canonical docs.
-
-Docs should clearly state:
-
-- Print pages are used for Day 1-3 narrative/discussion text.
-- RSS, if retained, is supplemental and not required for complete discussion coverage.
-- GeoJSON is used for polygons, validity windows, and point matching.
-- `containsLocation` is computed by weatherfeeder at ingestion time.
-- Geometry is stored for downstream audit/display.
-- Day 4-8 outlooks are not included in the initial implementation.
-
-### Stage 6: Weatherapi Follow-Up
-
-Do not include weatherapi changes in the first weatherfeeder implementation unless explicitly requested.
-
-Likely future weatherapi endpoints:
-
-- `GET /outlooks/convective`
-- `GET /outlooks/convective/active`
-- `GET /outlooks/convective/location`
-
-Recommended API behavior:
-
-- latest run by default
-- active outlooks filtered by current time and `containsLocation=true`
-- optional filter query params for `day`, `outlookType`, and `containsLocation`
-
-## Verification Commands
-
-Run focused tests:
-
-```sh
-go test ./internal/sources ./internal/normalizers/... ./internal/sinks/postgres ./model
-```
-
-Run full weatherfeeder tests:
-
-```sh
-go test ./...
-```
-
-## Acceptance Criteria
-
-- A configured SPC source emits raw outlook bundles.
-- Normalization produces `weather.outlook.v1` events.
-- Each outlook indicates whether the configured forecast point is inside its polygon.
-- All current Day 1-3 categorical/tornado/hail/wind products are represented.
-- Day 1-3 print-page discussion text is preserved where available.
-- RSS metadata is optional and supplemental if retained.
-- Postgres sink persists outlook runs and outlook rows.
-- Sample config and public docs describe the new kind, driver, schema, and storage contract.
diff --git a/internal/normalizers/spc/convective_outlook.go b/internal/normalizers/spc/convective_outlook.go
index de5481c..d6681c3 100644
--- a/internal/normalizers/spc/convective_outlook.go
+++ b/internal/normalizers/spc/convective_outlook.go
@@ -138,11 +138,10 @@ func parseDiscussions(pages []spcprovider.RawDiscussionPage) (map[int]parsedDisc
 	out := map[int]parsedDiscussion{}
 	var latestUpdated time.Time
 	for _, page := range pages {
-		text, err := spcprovider.ExtractProductText(page.Body)
+		parsed, err := spcprovider.ParseDiscussionHTML(page.Body)
 		if err != nil {
 			return nil, time.Time{}, fmt.Errorf("discussion %s: %w", page.Key, err)
 		}
-		parsed := spcprovider.ParseDiscussionText(text)
 		day := page.Day
 		if day == 0 {
 			if meta, ok := spcprovider.DiscussionProductByKey(page.Key); ok {
diff --git a/internal/normalizers/spc/convective_outlook_test.go b/internal/normalizers/spc/convective_outlook_test.go
index 0ba86e7..aa59322 100644
--- a/internal/normalizers/spc/convective_outlook_test.go
+++ b/internal/normalizers/spc/convective_outlook_test.go
@@ -99,6 +99,9 @@ func TestConvectiveOutlookNormalizerProducesCanonicalSchemaAndMapsSample(t *test
 	if !strings.Contains(got.Discussion, "...DISCUSSION...") {
 		t.Fatalf("Discussion missing product text: %q", got.Discussion)
 	}
+	if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
+		t.Fatalf("Discussion = %q, want SPC product code prefix", got.Discussion)
+	}
 	if got.ID != "spc-convective-day1-categorical-slgt-2026-06-11T12:34:56Z-2026-06-11T13:00:00Z-0" {
 		t.Fatalf("ID = %q", got.ID)
 	}
diff --git a/internal/providers/spc/discussion.go b/internal/providers/spc/discussion.go
index ae79230..9227c7b 100644
--- a/internal/providers/spc/discussion.go
+++ b/internal/providers/spc/discussion.go
@@ -13,6 +13,8 @@ var (
 	preBlockRE    = regexp.MustCompile(`(?is)]*>(.*?)
`) tagRE = regexp.MustCompile(`(?is)<[^>]+>`) updatedRE = regexp.MustCompile(`(?im)^\s*Updated:\s*(.+?)\s*$`) + pageUpdatedRE = regexp.MustCompile(`(?i)\bUpdated:\s*((?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(?:[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+UTC\s+\d{4})|(?:\d{4}\s+UTC\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4})|(?:\d{4}Z\s+[A-Z][a-z]{2}\s+[A-Z][a-z]{2}\s+\d{1,2}\s+\d{4}))`) + productCodeRE = regexp.MustCompile(`(?i)^SPC\s+AC\s+\d+\s*$`) sectionRE = regexp.MustCompile(`^\s*\.\.\.[A-Z0-9 /-]+\.{3}\s*$`) ) @@ -41,6 +43,20 @@ func ExtractProductText(rawHTML string) (string, error) { return "", fmt.Errorf("no useful pre block found") } +// ParseDiscussionHTML extracts SPC product text and page-level metadata from a +// print-page HTML document. +func ParseDiscussionHTML(rawHTML string) (DiscussionText, error) { + text, err := ExtractProductText(rawHTML) + if err != nil { + return DiscussionText{}, err + } + parsed := ParseDiscussionText(text) + if updatedAt := ParsePageUpdatedTimestamp(rawHTML); updatedAt != nil { + parsed.UpdatedAt = updatedAt + } + return parsed, nil +} + // ParseDiscussionText extracts common SPC narrative metadata from cleaned // product text. func ParseDiscussionText(text string) DiscussionText { @@ -55,6 +71,19 @@ func ParseDiscussionText(text string) DiscussionText { } } +// ParsePageUpdatedTimestamp parses the page-level Updated row from an SPC print +// page. SPC currently places this outside the product
 block.
+func ParsePageUpdatedTimestamp(rawHTML string) *time.Time {
+	text := cleanHTMLText(rawHTML)
+	text = strings.ReplaceAll(text, "\u00a0", " ")
+	text = strings.Join(strings.Fields(text), " ")
+	match := pageUpdatedRE.FindStringSubmatch(text)
+	if len(match) != 2 {
+		return nil
+	}
+	return parseUpdatedValue(match[1])
+}
+
 // ParseUpdatedTimestamp parses an SPC print-page Updated line when present.
 func ParseUpdatedTimestamp(text string) *time.Time {
 	match := updatedRE.FindStringSubmatch(normalizeNewlines(text))
@@ -68,7 +97,7 @@ func ParseUpdatedTimestamp(text string) *time.Time {
 func ParseProductTitle(text string) string {
 	for _, line := range strings.Split(normalizeNewlines(text), "\n") {
 		line = strings.TrimSpace(line)
-		if line == "" || strings.HasPrefix(line, "Updated:") {
+		if line == "" || strings.HasPrefix(line, "Updated:") || productCodeRE.MatchString(line) {
 			continue
 		}
 		return line
@@ -142,6 +171,7 @@ func parseUpdatedValue(value string) *time.Time {
 		return t
 	}
 	for _, layout := range []string{
+		"Mon Jan 2 15:04:05 UTC 2006",
 		"1504 UTC Mon Jan 2 2006",
 		"1504Z Mon Jan 2 2006",
 		"3:04 PM UTC Mon Jan 2 2006",
diff --git a/internal/providers/spc/discussion_test.go b/internal/providers/spc/discussion_test.go
index 1640da8..b8c9ae9 100644
--- a/internal/providers/spc/discussion_test.go
+++ b/internal/providers/spc/discussion_test.go
@@ -22,18 +22,19 @@ func TestExtractProductTextCleansPreBlock(t *testing.T) {
 	if !strings.Contains(got, "Day 1 Convective Outlook") {
 		t.Fatalf("ExtractProductText() missing headline: %q", got)
 	}
+	if !strings.HasPrefix(got, "SPC AC 111234") {
+		t.Fatalf("ExtractProductText() = %q, want product code prefix", got)
+	}
 	if strings.HasPrefix(got, "\n") || strings.HasSuffix(got, "\n") {
 		t.Fatalf("ExtractProductText() retained surrounding blank lines: %q", got)
 	}
 }
 
-func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
-	text, err := ExtractProductText(string(readTestFile(t, "day1_prt.html")))
+func TestParseDiscussionHTMLExtractsHeadlineSummaryAndUpdated(t *testing.T) {
+	got, err := ParseDiscussionHTML(string(readTestFile(t, "day1_prt.html")))
 	if err != nil {
-		t.Fatalf("ExtractProductText() error = %v", err)
+		t.Fatalf("ParseDiscussionHTML() error = %v", err)
 	}
-
-	got := ParseDiscussionText(text)
 	if got.ProductTitle != "Day 1 Convective Outlook" {
 		t.Fatalf("ProductTitle = %q", got.ProductTitle)
 	}
@@ -47,27 +48,37 @@ func TestParseDiscussionTextExtractsHeadlineSummaryAndUpdated(t *testing.T) {
 	if !strings.Contains(got.Discussion, "...DISCUSSION...") {
 		t.Fatalf("Discussion missing full text: %q", got.Discussion)
 	}
+	if !strings.HasPrefix(got.Discussion, "SPC AC 111234") {
+		t.Fatalf("Discussion = %q, want product code prefix", got.Discussion)
+	}
 	wantUpdated := time.Date(2026, 6, 11, 12, 45, 0, 0, time.UTC)
 	if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
 		t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
 	}
 }
 
+func TestParseProductTitleSkipsSPCProductCode(t *testing.T) {
+	got := ParseProductTitle("SPC AC 101959\nDay 1 Convective Outlook\nNWS Storm Prediction Center Norman OK")
+	if got != "Day 1 Convective Outlook" {
+		t.Fatalf("ParseProductTitle() = %q, want Day 1 Convective Outlook", got)
+	}
+}
+
 func TestParseDiscussionTextPreservesCorrectionMarker(t *testing.T) {
-	text, err := ExtractProductText(string(readTestFile(t, "day2_prt_corr.html")))
+	got, err := ParseDiscussionHTML(string(readTestFile(t, "day2_prt_corr.html")))
 	if err != nil {
-		t.Fatalf("ExtractProductText() error = %v", err)
+		t.Fatalf("ParseDiscussionHTML() error = %v", err)
 	}
 
-	got := ParseDiscussionText(text)
 	if !strings.Contains(got.Headline, "CORR 1") {
 		t.Fatalf("Headline = %q, want correction marker", got.Headline)
 	}
 	if !strings.Contains(got.Discussion, "CORR 1") {
 		t.Fatalf("Discussion = %q, want correction marker", got.Discussion)
 	}
-	if got.UpdatedAt != nil {
-		t.Fatalf("UpdatedAt = %v, want nil", got.UpdatedAt)
+	wantUpdated := time.Date(2026, 6, 11, 17, 30, 0, 0, time.UTC)
+	if got.UpdatedAt == nil || !got.UpdatedAt.Equal(wantUpdated) {
+		t.Fatalf("UpdatedAt = %v, want %s", got.UpdatedAt, wantUpdated)
 	}
 }
 
@@ -88,3 +99,11 @@ func TestParseUpdatedTimestampAcceptsSPCUTCFormat(t *testing.T) {
 		t.Fatalf("ParseUpdatedTimestamp() = %v, want %s", got, want)
 	}
 }
+
+func TestParsePageUpdatedTimestampAcceptsLiveSPCShape(t *testing.T) {
+	got := ParsePageUpdatedTimestamp(string(readTestFile(t, "day3_prt.html")))
+	want := time.Date(2026, 6, 11, 20, 0, 0, 0, time.UTC)
+	if got == nil || !got.Equal(want) {
+		t.Fatalf("ParsePageUpdatedTimestamp() = %v, want %s", got, want)
+	}
+}
diff --git a/internal/providers/spc/testdata/day1_prt.html b/internal/providers/spc/testdata/day1_prt.html
index 954a232..f811388 100644
--- a/internal/providers/spc/testdata/day1_prt.html
+++ b/internal/providers/spc/testdata/day1_prt.html
@@ -2,11 +2,14 @@
 
 Day 1 Convective Outlook
 
+
+
+
Updated: Thu Jun 11 12:45:00 UTC 2026 (geojson)
 
+SPC AC 111234
 Day 1 Convective Outlook
 NWS Storm Prediction Center Norman OK
-Updated: 2026-06-11T12:45:00Z
 
 ...SUMMARY...
 Severe thunderstorms are possible across parts of the central Plains
diff --git a/internal/providers/spc/testdata/day2_prt_corr.html b/internal/providers/spc/testdata/day2_prt_corr.html
index fe9ec52..030a7cf 100644
--- a/internal/providers/spc/testdata/day2_prt_corr.html
+++ b/internal/providers/spc/testdata/day2_prt_corr.html
@@ -1,7 +1,11 @@
 
 
 
+
+
+
Updated: Thu Jun 11 17:30:00 UTC 2026 
+SPC AC 111730
 Day 2 Convective Outlook CORR 1
 NWS Storm Prediction Center Norman OK
 
diff --git a/internal/providers/spc/testdata/day3_prt.html b/internal/providers/spc/testdata/day3_prt.html
index 20b2051..a8544aa 100644
--- a/internal/providers/spc/testdata/day3_prt.html
+++ b/internal/providers/spc/testdata/day3_prt.html
@@ -1,10 +1,13 @@
 
 
 
+
+
+
Updated: Thu Jun 11 20:00:00 UTC 2026 
+SPC AC 112000
 Day 3 Convective Outlook
 NWS Storm Prediction Center Norman OK
-Updated: 2026-06-11T20:00:00Z
 
 ...SUMMARY...
 A corridor of strong to severe storms may develop near a frontal zone.
diff --git a/internal/sinks/postgres/doc.go b/internal/sinks/postgres/doc.go
index c0a5998..8c0d094 100644
--- a/internal/sinks/postgres/doc.go
+++ b/internal/sinks/postgres/doc.go
@@ -233,6 +233,8 @@
 //     - run_event_id TEXT -> outlook_runs.event_id / payload.outlooks[i]
 //     - outlook_index INTEGER -> i (array position in payload.outlooks)
 //     - as_of TIMESTAMPTZ -> payload.asOf (copied from parent)
+//     - outlook_id TEXT -> payload.outlooks[i].id
+//     - provider TEXT -> payload.outlooks[i].provider
 //     - product TEXT -> payload.outlooks[i].product
 //     - day INTEGER -> payload.outlooks[i].day
 //     - outlook_type TEXT -> payload.outlooks[i].outlookType
diff --git a/internal/sinks/postgres/map.go b/internal/sinks/postgres/map.go
index 02afddd..e6adebd 100644
--- a/internal/sinks/postgres/map.go
+++ b/internal/sinks/postgres/map.go
@@ -404,6 +404,8 @@ func mapOutlookEvent(e fkevent.Event) ([]fksinks.PostgresWrite, error) {
 				"run_event_id":      e.ID,
 				"outlook_index":     i,
 				"as_of":             asOf,
+				"outlook_id":        outlook.ID,
+				"provider":          outlook.Provider,
 				"product":           outlook.Product,
 				"day":               outlook.Day,
 				"outlook_type":      outlook.OutlookType,
diff --git a/internal/sinks/postgres/map_test.go b/internal/sinks/postgres/map_test.go
index 15b550a..570d08b 100644
--- a/internal/sinks/postgres/map_test.go
+++ b/internal/sinks/postgres/map_test.go
@@ -312,6 +312,12 @@ func TestMapPostgresEventOutlookStructPayload(t *testing.T) {
 	if got := writes[1].Values["outlook_index"]; got != 0 {
 		t.Fatalf("first outlook index = %#v, want 0", got)
 	}
+	if got := writes[1].Values["outlook_id"]; got != "outlook-1" {
+		t.Fatalf("first outlook_id = %#v, want outlook-1", got)
+	}
+	if got := writes[1].Values["provider"]; got != "spc" {
+		t.Fatalf("first provider = %#v, want spc", got)
+	}
 	if got := writes[1].Values["valid_from"]; got != run.Outlooks[0].ValidFrom.UTC() {
 		t.Fatalf("first valid_from = %#v, want UTC %s", got, run.Outlooks[0].ValidFrom.UTC())
 	}
@@ -335,6 +341,57 @@ func TestMapPostgresEventOutlookRejectsMissingAsOf(t *testing.T) {
 	}
 }
 
+func TestMapPostgresEventOutlookRejectsMissingIDAndProvider(t *testing.T) {
+	base := model.WeatherOutlook{
+		ID:          "outlook-1",
+		Provider:    "spc",
+		Product:     "convective",
+		Day:         1,
+		OutlookType: "categorical",
+		Label:       "SLGT",
+		ValidFrom:   time.Date(2026, 6, 11, 13, 0, 0, 0, time.UTC),
+		ValidTo:     time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
+		IssuedAt:    time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
+		ExpiresAt:   time.Date(2026, 6, 12, 12, 0, 0, 0, time.UTC),
+		Geometry:    json.RawMessage(`{"type":"Polygon","coordinates":[[[-91,38],[-90,38],[-90,39],[-91,39],[-91,38]]]}`),
+	}
+
+	tests := []struct {
+		name    string
+		mutate  func(*model.WeatherOutlook)
+		wantErr string
+	}{
+		{
+			name:    "missing id",
+			mutate:  func(outlook *model.WeatherOutlook) { outlook.ID = "" },
+			wantErr: "outlooks[0].id is required",
+		},
+		{
+			name:    "missing provider",
+			mutate:  func(outlook *model.WeatherOutlook) { outlook.Provider = "" },
+			wantErr: "outlooks[0].provider is required",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			outlook := base
+			tt.mutate(&outlook)
+			run := model.WeatherOutlookRun{
+				AsOf:     time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
+				Outlooks: []model.WeatherOutlook{outlook},
+			}
+			_, err := mapPostgresEvent(context.Background(), testEvent(standards.SchemaWeatherOutlookV1, "outlook", run))
+			if err == nil {
+				t.Fatalf("mapPostgresEvent() error = nil, want %q", tt.wantErr)
+			}
+			if !strings.Contains(err.Error(), tt.wantErr) {
+				t.Fatalf("error = %q, want %q", err, tt.wantErr)
+			}
+		})
+	}
+}
+
 func TestMapPostgresEventOutlookRejectsMissingRequiredTimes(t *testing.T) {
 	run := model.WeatherOutlookRun{
 		AsOf: time.Date(2026, 6, 11, 19, 45, 0, 0, time.UTC),
diff --git a/internal/sinks/postgres/schema.go b/internal/sinks/postgres/schema.go
index d6c5560..8c1a9a2 100644
--- a/internal/sinks/postgres/schema.go
+++ b/internal/sinks/postgres/schema.go
@@ -329,6 +329,8 @@ func PostgresSchema() fksinks.PostgresSchema {
 					{Name: "run_event_id", Type: "TEXT REFERENCES outlook_runs(event_id) ON DELETE CASCADE", Nullable: false},
 					{Name: "outlook_index", Type: "INTEGER", Nullable: false},
 					{Name: "as_of", Type: "TIMESTAMPTZ", Nullable: false},
+					{Name: "outlook_id", Type: "TEXT", Nullable: false},
+					{Name: "provider", Type: "TEXT", Nullable: false},
 					{Name: "product", Type: "TEXT", Nullable: false},
 					{Name: "day", Type: "INTEGER", Nullable: false},
 					{Name: "outlook_type", Type: "TEXT", Nullable: false},
diff --git a/internal/sinks/postgres/schema_test.go b/internal/sinks/postgres/schema_test.go
index 32f775d..0e92c88 100644
--- a/internal/sinks/postgres/schema_test.go
+++ b/internal/sinks/postgres/schema_test.go
@@ -60,7 +60,7 @@ func TestWeatherPostgresSchemaIncludesOutlookTables(t *testing.T) {
 	assertTableIndex(t, tableOutlookRuns, "idx_wf_outlook_run_as_of", []string{"as_of"})
 
 	outlookColumns := columnsForTable(t, tableOutlooks)
-	for _, col := range []string{"run_event_id", "outlook_index", "as_of", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
+	for _, col := range []string{"run_event_id", "outlook_index", "as_of", "outlook_id", "provider", "product", "day", "outlook_type", "label", "label_text", "severity_rank", "valid_from", "valid_to", "issued_at", "expires_at", "forecaster", "headline", "summary", "discussion", "source_url", "image_url", "contains_location", "geometry_json"} {
 		if !outlookColumns[col] {
 			t.Fatalf("%s missing %s column", tableOutlooks, col)
 		}
diff --git a/internal/sources/spc/convective_outlook.go b/internal/sources/spc/convective_outlook.go
index 89c2c44..c3a1833 100644
--- a/internal/sources/spc/convective_outlook.go
+++ b/internal/sources/spc/convective_outlook.go
@@ -377,11 +377,7 @@ func latestIssueTime(raw []byte) time.Time {
 }
 
 func discussionUpdatedTime(rawHTML string) time.Time {
-	text, err := spcprovider.ExtractProductText(rawHTML)
-	if err != nil {
-		return time.Time{}
-	}
-	t := spcprovider.ParseUpdatedTimestamp(text)
+	t := spcprovider.ParsePageUpdatedTimestamp(rawHTML)
 	if t == nil {
 		return time.Time{}
 	}