Stephen Lee commited on
Commit
a3c85ad
·
1 Parent(s): 8857545

feat: add official source cache foundation

Browse files
.gitignore CHANGED
@@ -33,6 +33,7 @@ dist/
33
  # Local runtime data
34
  uploads/
35
  data/private/
 
36
  *.sqlite
37
  *.sqlite3
38
  *.db
 
33
  # Local runtime data
34
  uploads/
35
  data/private/
36
+ data/source-cache/runtime/
37
  *.sqlite
38
  *.sqlite3
39
  *.db
coastwise/__init__.py CHANGED
@@ -1,2 +1,17 @@
1
  """CoastWise domain package."""
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """CoastWise domain package."""
2
 
3
+ __all__ = [
4
+ "advisories",
5
+ "ai",
6
+ "data",
7
+ "locations",
8
+ "qna",
9
+ "rules",
10
+ "schemas",
11
+ "search",
12
+ "source_cache",
13
+ "source_extract",
14
+ "source_refresh",
15
+ "source_registry",
16
+ "submission",
17
+ ]
coastwise/source_cache.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed and runtime cache helpers for official source snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass
7
+ from datetime import date, datetime, timezone
8
+ from enum import Enum
9
+ from pathlib import Path
10
+
11
+ from coastwise.schemas import (
12
+ AdvisoryFact,
13
+ FreshnessStatus,
14
+ OfficialSource,
15
+ RefreshStatus,
16
+ SourceChunk,
17
+ SourceSnapshot,
18
+ StatewideSourceCoverage,
19
+ StructuredRegulationFact,
20
+ ValidationIssue,
21
+ )
22
+ from coastwise.source_registry import load_official_sources
23
+
24
+ DEFAULT_SOURCE_CACHE_ROOT = Path(__file__).resolve().parent.parent / "data" / "source-cache"
25
+ DEFAULT_SEED_CACHE_DIR = DEFAULT_SOURCE_CACHE_ROOT / "seed"
26
+ DEFAULT_RUNTIME_CACHE_DIR = DEFAULT_SOURCE_CACHE_ROOT / "runtime"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SourceCache:
31
+ sources: tuple[OfficialSource, ...]
32
+ snapshots: tuple[SourceSnapshot, ...]
33
+ chunks: tuple[SourceChunk, ...]
34
+ facts: tuple[StructuredRegulationFact, ...]
35
+ advisory_facts: tuple[AdvisoryFact, ...] = ()
36
+
37
+ @property
38
+ def sources_by_id(self) -> dict[str, OfficialSource]:
39
+ return {source.id: source for source in self.sources}
40
+
41
+ @property
42
+ def snapshots_by_source_id(self) -> dict[str, SourceSnapshot]:
43
+ return {snapshot.source_id: snapshot for snapshot in self.snapshots}
44
+
45
+ @property
46
+ def chunks_by_id(self) -> dict[str, SourceChunk]:
47
+ return {chunk.id: chunk for chunk in self.chunks}
48
+
49
+ @property
50
+ def facts_by_id(self) -> dict[str, StructuredRegulationFact]:
51
+ return {fact.id: fact for fact in self.facts}
52
+
53
+ @property
54
+ def advisory_facts_by_id(self) -> dict[str, AdvisoryFact]:
55
+ return {fact.id: fact for fact in self.advisory_facts}
56
+
57
+
58
+ def _load_json(path: Path, fallback):
59
+ if not path.exists():
60
+ return fallback
61
+ with path.open("r", encoding="utf-8") as handle:
62
+ return json.load(handle)
63
+
64
+
65
+ def _json_ready(value):
66
+ if isinstance(value, Enum):
67
+ return value.value
68
+ if isinstance(value, (datetime, date)):
69
+ return value.isoformat()
70
+ if isinstance(value, tuple):
71
+ return [_json_ready(item) for item in value]
72
+ if isinstance(value, list):
73
+ return [_json_ready(item) for item in value]
74
+ if isinstance(value, dict):
75
+ return {key: _json_ready(item) for key, item in value.items()}
76
+ return value
77
+
78
+
79
+ def _source_from_dict(value: dict) -> OfficialSource:
80
+ return OfficialSource(**value)
81
+
82
+
83
+ def _snapshot_from_dict(value: dict) -> SourceSnapshot:
84
+ return SourceSnapshot(**value)
85
+
86
+
87
+ def _chunk_from_dict(value: dict) -> SourceChunk:
88
+ return SourceChunk(**value)
89
+
90
+
91
+ def _fact_from_dict(value: dict) -> StructuredRegulationFact:
92
+ return StructuredRegulationFact(**value)
93
+
94
+
95
+ def _advisory_from_dict(value: dict) -> AdvisoryFact:
96
+ return AdvisoryFact(**value)
97
+
98
+
99
+ def load_seed_cache(seed_dir: Path = DEFAULT_SEED_CACHE_DIR) -> SourceCache:
100
+ """Load reviewed bundled seed source snapshots, chunks, and facts."""
101
+
102
+ sources_data = _load_json(seed_dir / "sources.json", [])
103
+ snapshots_data = _load_json(seed_dir / "snapshots.json", [])
104
+ chunks_data = _load_json(seed_dir / "chunks.json", [])
105
+ facts_data = _load_json(seed_dir / "facts.json", {})
106
+
107
+ if isinstance(facts_data, list):
108
+ structured_data = facts_data
109
+ advisory_data = []
110
+ else:
111
+ structured_data = facts_data.get("structured_facts", [])
112
+ advisory_data = facts_data.get("advisory_facts", [])
113
+
114
+ sources = tuple(_source_from_dict(item) for item in sources_data) or tuple(load_official_sources())
115
+ return SourceCache(
116
+ sources=sources,
117
+ snapshots=tuple(_snapshot_from_dict(item) for item in snapshots_data),
118
+ chunks=tuple(_chunk_from_dict(item) for item in chunks_data),
119
+ facts=tuple(_fact_from_dict(item) for item in structured_data),
120
+ advisory_facts=tuple(_advisory_from_dict(item) for item in advisory_data),
121
+ )
122
+
123
+
124
+ def load_latest_cache(
125
+ seed_cache: SourceCache,
126
+ runtime_cache_path: Path = DEFAULT_RUNTIME_CACHE_DIR,
127
+ ) -> SourceCache:
128
+ """Overlay valid runtime cache files on top of the reviewed seed cache."""
129
+
130
+ if not runtime_cache_path.exists():
131
+ return seed_cache
132
+ runtime = load_seed_cache(runtime_cache_path)
133
+ snapshots_by_source = seed_cache.snapshots_by_source_id
134
+ snapshots_by_source.update(runtime.snapshots_by_source_id)
135
+ facts = {fact.id: fact for fact in seed_cache.facts}
136
+ facts.update({fact.id: fact for fact in runtime.facts})
137
+ advisory_facts = {fact.id: fact for fact in seed_cache.advisory_facts}
138
+ advisory_facts.update({fact.id: fact for fact in runtime.advisory_facts})
139
+ chunks = {chunk.id: chunk for chunk in seed_cache.chunks}
140
+ chunks.update({chunk.id: chunk for chunk in runtime.chunks})
141
+ return SourceCache(
142
+ sources=seed_cache.sources,
143
+ snapshots=tuple(snapshots_by_source.values()),
144
+ chunks=tuple(chunks.values()),
145
+ facts=tuple(facts.values()),
146
+ advisory_facts=tuple(advisory_facts.values()),
147
+ )
148
+
149
+
150
+ def write_runtime_cache(
151
+ runtime_cache_path: Path,
152
+ base_cache: SourceCache,
153
+ *,
154
+ snapshots: tuple[SourceSnapshot, ...] = (),
155
+ chunks: tuple[SourceChunk, ...] = (),
156
+ facts: tuple[StructuredRegulationFact, ...] = (),
157
+ advisory_facts: tuple[AdvisoryFact, ...] = (),
158
+ ) -> None:
159
+ """Persist valid refreshed public source data without user history."""
160
+
161
+ runtime_cache_path.mkdir(parents=True, exist_ok=True)
162
+ payloads = {
163
+ "sources.json": [asdict(source) for source in base_cache.sources],
164
+ "snapshots.json": [asdict(snapshot) for snapshot in snapshots],
165
+ "chunks.json": [asdict(chunk) for chunk in chunks],
166
+ "facts.json": {
167
+ "structured_facts": [asdict(fact) for fact in facts],
168
+ "advisory_facts": [asdict(fact) for fact in advisory_facts],
169
+ },
170
+ }
171
+ for filename, payload in payloads.items():
172
+ with (runtime_cache_path / filename).open("w", encoding="utf-8") as handle:
173
+ json.dump(_json_ready(payload), handle, indent=2, sort_keys=True)
174
+
175
+
176
+ def freshness_status(retrieved_at: datetime | str | None, now: datetime) -> FreshnessStatus:
177
+ if retrieved_at is None:
178
+ return FreshnessStatus.NO_CACHE
179
+ retrieved = (
180
+ retrieved_at
181
+ if isinstance(retrieved_at, datetime)
182
+ else datetime.fromisoformat(retrieved_at.replace("Z", "+00:00"))
183
+ )
184
+ if retrieved.tzinfo is None:
185
+ retrieved = retrieved.replace(tzinfo=timezone.utc)
186
+ if now.tzinfo is None:
187
+ now = now.replace(tzinfo=timezone.utc)
188
+ return FreshnessStatus.STALE if (now - retrieved).total_seconds() > 24 * 60 * 60 else FreshnessStatus.CURRENT
189
+
190
+
191
+ def validate_seed_cache(
192
+ cache: SourceCache,
193
+ sources: list[OfficialSource],
194
+ coverage: StatewideSourceCoverage,
195
+ ) -> list[ValidationIssue]:
196
+ """Validate seed cache coverage for MVP-required official sources."""
197
+
198
+ issues: list[ValidationIssue] = []
199
+ source_ids = {source.id for source in sources}
200
+ snapshot_ids = set(cache.snapshots_by_source_id)
201
+
202
+ for source_id in coverage.required_source_ids:
203
+ if source_id not in source_ids:
204
+ issues.append(ValidationIssue(source_id, "error", "Required source is missing from registry."))
205
+ if source_id not in snapshot_ids:
206
+ issues.append(ValidationIssue(source_id, "error", "Required source is missing seed snapshot."))
207
+
208
+ for fact_id in ("lingcod_min_size", "dungeness_crab_bag_limit"):
209
+ if fact_id not in cache.facts_by_id:
210
+ issues.append(ValidationIssue(fact_id, "error", "Required validation-set fact is missing."))
211
+ for advisory_id in ("cdph_annual_mussel_quarantine", "cdph_shellfish_verify"):
212
+ if advisory_id not in cache.advisory_facts_by_id:
213
+ issues.append(ValidationIssue(advisory_id, "error", "Required CDPH advisory fact is missing."))
214
+
215
+ return issues
216
+
217
+
218
+ def refresh_status_for_cache(cache: SourceCache) -> RefreshStatus:
219
+ """Return a conservative aggregate refresh status for loaded cache."""
220
+
221
+ if not cache.snapshots:
222
+ return RefreshStatus.UNAVAILABLE
223
+ if any(snapshot.refresh_status is RefreshStatus.FAILED for snapshot in cache.snapshots):
224
+ return RefreshStatus.FAILED
225
+ return RefreshStatus.UNCHANGED
226
+
227
+
228
+ def cache_status_summary(cache: SourceCache, now: datetime) -> dict[str, object]:
229
+ """Summarize current/stale/no-cache state for UI rendering."""
230
+
231
+ if not cache.snapshots:
232
+ return {
233
+ "usable_cache": False,
234
+ "freshness_status": FreshnessStatus.NO_CACHE.value,
235
+ "latest_retrieved_at": None,
236
+ "message": "No usable official source cache is available.",
237
+ }
238
+ newest = max(snapshot.retrieved_at for snapshot in cache.snapshots)
239
+ stale = any(freshness_status(snapshot.retrieved_at, now) is FreshnessStatus.STALE for snapshot in cache.snapshots)
240
+ status = FreshnessStatus.STALE if stale else FreshnessStatus.CURRENT
241
+ return {
242
+ "usable_cache": True,
243
+ "freshness_status": status.value,
244
+ "latest_retrieved_at": newest.isoformat(),
245
+ "message": (
246
+ "Answering from stale cached official source data; verify official sources before harvest."
247
+ if stale
248
+ else "Answering from current cached official source data."
249
+ ),
250
+ }
coastwise/source_extract.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official source text extraction helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import html
7
+ import re
8
+ from dataclasses import dataclass
9
+
10
+ from coastwise.schemas import (
11
+ RequestedFactType,
12
+ SourceChunk,
13
+ SourceSnapshot,
14
+ StructuredRegulationFact,
15
+ StructuredValidationSet,
16
+ ValidationIssue,
17
+ )
18
+
19
+
20
+ def normalize_source_text(value: str) -> str:
21
+ """Return compact source text suitable for deterministic cache hashing."""
22
+
23
+ without_tags = re.sub(r"<[^>]+>", " ", value)
24
+ decoded = html.unescape(without_tags)
25
+ return " ".join(decoded.split())
26
+
27
+
28
+ def content_hash(value: str) -> str:
29
+ """Return a stable content hash for normalized source text."""
30
+
31
+ return hashlib.sha256(normalize_source_text(value).encode("utf-8")).hexdigest()
32
+
33
+
34
+ def tokenize_source_text(value: str) -> tuple[str, ...]:
35
+ """Tokenize source text for simple retrieval."""
36
+
37
+ tokens = re.findall(r"[a-z0-9]+", value.casefold())
38
+ return tuple(dict.fromkeys(tokens))
39
+
40
+
41
+ def _species_terms(text: str) -> tuple[str, ...]:
42
+ known = (
43
+ "lingcod",
44
+ "dungeness crab",
45
+ "mussel",
46
+ "mussels",
47
+ "shellfish",
48
+ "rockfish",
49
+ "monterey",
50
+ "pacifica",
51
+ )
52
+ lowered = text.casefold()
53
+ return tuple(term for term in known if term in lowered)
54
+
55
+
56
+ def _location_terms(text: str) -> tuple[str, ...]:
57
+ known = ("pacifica", "ocean beach", "monterey", "crescent city", "santa barbara", "san diego bay")
58
+ lowered = text.casefold()
59
+ return tuple(term for term in known if term in lowered)
60
+
61
+
62
+ def extract_source_chunks(snapshot: SourceSnapshot, *, max_chars: int = 900) -> list[SourceChunk]:
63
+ """Split a source snapshot into traceable chunks."""
64
+
65
+ text = normalize_source_text(snapshot.raw_text)
66
+ if not text:
67
+ return []
68
+
69
+ parts = re.split(r"(?<=[.!?])\s+", text)
70
+ chunks: list[SourceChunk] = []
71
+ current = ""
72
+ index = 1
73
+ for part in parts:
74
+ candidate = f"{current} {part}".strip()
75
+ if len(candidate) > max_chars and current:
76
+ chunks.append(
77
+ SourceChunk(
78
+ id=f"{snapshot.source_id}:chunk:{index}",
79
+ source_id=snapshot.source_id,
80
+ source_url=snapshot.url,
81
+ source_title=snapshot.title,
82
+ retrieved_at=snapshot.retrieved_at,
83
+ heading=None,
84
+ text=current,
85
+ tokens=tokenize_source_text(current),
86
+ species_or_category_terms=_species_terms(current),
87
+ location_terms=_location_terms(current),
88
+ )
89
+ )
90
+ index += 1
91
+ current = part
92
+ else:
93
+ current = candidate
94
+
95
+ if current:
96
+ chunks.append(
97
+ SourceChunk(
98
+ id=f"{snapshot.source_id}:chunk:{index}",
99
+ source_id=snapshot.source_id,
100
+ source_url=snapshot.url,
101
+ source_title=snapshot.title,
102
+ retrieved_at=snapshot.retrieved_at,
103
+ heading=None,
104
+ text=current,
105
+ tokens=tokenize_source_text(current),
106
+ species_or_category_terms=_species_terms(current),
107
+ location_terms=_location_terms(current),
108
+ )
109
+ )
110
+ return chunks
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class ExtractionResult:
115
+ facts: tuple[StructuredRegulationFact, ...]
116
+ issues: tuple[ValidationIssue, ...] = ()
117
+
118
+
119
+ def default_structured_validation_set() -> StructuredValidationSet:
120
+ return StructuredValidationSet(
121
+ id="mvp_validation",
122
+ required_questions=(
123
+ "what's the min size of lingcod?",
124
+ "any size limit for lingcod?",
125
+ "bag limit for Dungeness crab?",
126
+ ),
127
+ required_species_or_categories=("lingcod", "dungeness_crab", "mussels", "shellfish"),
128
+ required_fact_types=(
129
+ RequestedFactType.MINIMUM_SIZE,
130
+ RequestedFactType.BAG_LIMIT,
131
+ RequestedFactType.QUARANTINE,
132
+ RequestedFactType.ADVISORY,
133
+ ),
134
+ required_sources=(
135
+ "cdfw_groundfish_summary",
136
+ "cdfw_crabs",
137
+ "cdph_annual_mussel_quarantine",
138
+ "cdph_shellfish_advisories",
139
+ ),
140
+ required_statewide_coverage_id="statewide_ca_ocean",
141
+ )
142
+
143
+
144
+ def extract_structured_facts(
145
+ chunks: tuple[SourceChunk, ...] | list[SourceChunk],
146
+ validation_set: StructuredValidationSet,
147
+ ) -> ExtractionResult:
148
+ """Extract the small reviewed validation-set facts from source chunks."""
149
+
150
+ facts: list[StructuredRegulationFact] = []
151
+ issues: list[ValidationIssue] = []
152
+ for chunk in chunks:
153
+ lowered = chunk.text.casefold()
154
+ if "lingcod" in lowered and "22" in lowered:
155
+ facts.append(
156
+ StructuredRegulationFact(
157
+ id="lingcod_min_size",
158
+ species_or_category="lingcod",
159
+ display_name="Lingcod",
160
+ aliases=("ling cod",),
161
+ area_or_scope="California ocean statewide groundfish regulations",
162
+ fact_type=RequestedFactType.MINIMUM_SIZE,
163
+ value="22",
164
+ units="inches total length",
165
+ source_id=chunk.source_id,
166
+ source_url=chunk.source_url,
167
+ retrieved_at=chunk.retrieved_at,
168
+ supporting_chunk_id=chunk.id,
169
+ supporting_snippet=chunk.text,
170
+ validation_set=True,
171
+ )
172
+ )
173
+ if "lingcod" in lowered and "2 fish" in lowered:
174
+ facts.append(
175
+ StructuredRegulationFact(
176
+ id="lingcod_bag_limit",
177
+ species_or_category="lingcod",
178
+ display_name="Lingcod",
179
+ aliases=("ling cod",),
180
+ area_or_scope="California ocean statewide groundfish regulations",
181
+ fact_type=RequestedFactType.BAG_LIMIT,
182
+ value="2",
183
+ units="fish per person",
184
+ source_id=chunk.source_id,
185
+ source_url=chunk.source_url,
186
+ retrieved_at=chunk.retrieved_at,
187
+ supporting_chunk_id=chunk.id,
188
+ supporting_snippet=chunk.text,
189
+ validation_set=True,
190
+ )
191
+ )
192
+ if "dungeness crab" in lowered:
193
+ facts.append(
194
+ StructuredRegulationFact(
195
+ id="dungeness_crab_bag_limit",
196
+ species_or_category="dungeness_crab",
197
+ display_name="Dungeness crab",
198
+ aliases=("dungeness crabs", "crab"),
199
+ area_or_scope="California recreational crab fishery",
200
+ fact_type=RequestedFactType.GENERAL_SUMMARY,
201
+ value=(
202
+ "Verify current CDFW Dungeness crab season, trap/hoop-net rules, "
203
+ "public health closures, and crab rules before harvest."
204
+ ),
205
+ units=None,
206
+ source_id=chunk.source_id,
207
+ source_url=chunk.source_url,
208
+ retrieved_at=chunk.retrieved_at,
209
+ supporting_chunk_id=chunk.id,
210
+ supporting_snippet=chunk.text,
211
+ validation_set=True,
212
+ )
213
+ )
214
+
215
+ fact_ids = {fact.id for fact in facts}
216
+ for required_id in ("lingcod_min_size", "lingcod_bag_limit", "dungeness_crab_bag_limit"):
217
+ if required_id not in fact_ids:
218
+ issues.append(ValidationIssue(required_id, "error", "Required structured fact was not extracted."))
219
+ return ExtractionResult(facts=tuple(facts), issues=tuple(issues))
coastwise/source_refresh.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official source refresh helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from datetime import datetime
7
+ from urllib.request import Request, urlopen
8
+
9
+ from coastwise.schemas import OfficialSource, RefreshResult, RefreshStatus, SourceSnapshot
10
+ from coastwise.source_cache import SourceCache
11
+ from coastwise.source_extract import content_hash, extract_source_chunks, normalize_source_text
12
+
13
+ Fetcher = Callable[[OfficialSource], str]
14
+
15
+
16
+ def fetch_official_source(source: OfficialSource, *, timeout: float = 8.0) -> str:
17
+ """Fetch an official source with standard-library networking."""
18
+
19
+ request = Request(source.url, headers={"User-Agent": "CoastWise/0.1"})
20
+ with urlopen(request, timeout=timeout) as response:
21
+ return response.read().decode("utf-8", errors="replace")
22
+
23
+
24
+ def refresh_official_sources(
25
+ sources: list[OfficialSource],
26
+ cache: SourceCache,
27
+ now: datetime,
28
+ fetcher: Fetcher | None = None,
29
+ ) -> tuple[RefreshResult, SourceCache]:
30
+ """Refresh official sources without replacing last-good failed snapshots."""
31
+
32
+ fetch = fetcher or fetch_official_source
33
+ source_results: dict[str, RefreshStatus] = {}
34
+ updated_ids: list[str] = []
35
+ unchanged_ids: list[str] = []
36
+ failed_ids: list[str] = []
37
+ preserved_ids: list[str] = []
38
+ snapshots_by_source = cache.snapshots_by_source_id
39
+ chunks_by_id = cache.chunks_by_id
40
+
41
+ for source in sources:
42
+ try:
43
+ raw = fetch(source)
44
+ normalized = normalize_source_text(raw)
45
+ if not normalized:
46
+ raise ValueError("empty source content")
47
+ previous = snapshots_by_source.get(source.id)
48
+ if previous is not None and normalize_source_text(previous.raw_text) == normalized:
49
+ source_results[source.id] = RefreshStatus.UNCHANGED
50
+ unchanged_ids.append(source.id)
51
+ continue
52
+
53
+ snapshot = SourceSnapshot(
54
+ source_id=source.id,
55
+ url=source.url,
56
+ title=source.title,
57
+ retrieved_at=now,
58
+ content_hash=content_hash(normalized),
59
+ raw_text=normalized,
60
+ origin="refresh",
61
+ refresh_status=RefreshStatus.UPDATED,
62
+ )
63
+ snapshots_by_source[source.id] = snapshot
64
+ for chunk in extract_source_chunks(snapshot):
65
+ chunks_by_id[chunk.id] = chunk
66
+ source_results[source.id] = RefreshStatus.UPDATED
67
+ updated_ids.append(source.id)
68
+ except Exception:
69
+ source_results[source.id] = RefreshStatus.FAILED
70
+ failed_ids.append(source.id)
71
+ if source.id in snapshots_by_source:
72
+ preserved_ids.append(source.id)
73
+
74
+ message_parts = [
75
+ f"CDFW/CDPH sources checked: {len(updated_ids)} updated",
76
+ f"{len(unchanged_ids)} unchanged",
77
+ f"{len(failed_ids)} failed",
78
+ ]
79
+ if preserved_ids:
80
+ message_parts.append("last-good cache is still being used for failed sources")
81
+
82
+ result = RefreshResult(
83
+ started_at=now,
84
+ completed_at=now,
85
+ source_results=source_results,
86
+ updated_source_ids=tuple(updated_ids),
87
+ unchanged_source_ids=tuple(unchanged_ids),
88
+ failed_source_ids=tuple(failed_ids),
89
+ cache_preserved_source_ids=tuple(preserved_ids),
90
+ user_message=", ".join(message_parts) + ".",
91
+ )
92
+ refreshed_cache = SourceCache(
93
+ sources=cache.sources,
94
+ snapshots=tuple(snapshots_by_source.values()),
95
+ chunks=tuple(chunks_by_id.values()),
96
+ facts=cache.facts,
97
+ advisory_facts=cache.advisory_facts,
98
+ )
99
+ return result, refreshed_cache
coastwise/source_registry.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Official source registry for source-backed CoastWise answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from coastwise.schemas import OfficialSource, OfficialSourceType, StatewideSourceCoverage, ValidationIssue
6
+
7
+ APPROVED_HOSTS = ("wildlife.ca.gov", "cdph.ca.gov", "www.cdph.ca.gov")
8
+
9
+ REGION_KEYS = (
10
+ "northern",
11
+ "mendocino",
12
+ "san_francisco",
13
+ "central",
14
+ "southern",
15
+ "san_francisco_bay",
16
+ )
17
+
18
+
19
+ def load_official_sources() -> list[OfficialSource]:
20
+ """Return the curated CDFW/CDPH official source allowlist."""
21
+
22
+ return [
23
+ OfficialSource(
24
+ id="cdfw_ocean_regulations",
25
+ title="CDFW Ocean Sport Fishing Regulations",
26
+ source_type=OfficialSourceType.CDFW_OCEAN_REGULATIONS,
27
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations",
28
+ scope="Statewide California ocean sport fishing regulations",
29
+ statewide_required=True,
30
+ required_for_mvp=True,
31
+ ),
32
+ OfficialSource(
33
+ id="cdfw_inseason_changes",
34
+ title="CDFW Ocean Sport Fishing In-Season Regulation Changes",
35
+ source_type=OfficialSourceType.CDFW_OCEAN_REGULATIONS,
36
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Inseason",
37
+ scope="Statewide California ocean in-season regulation changes",
38
+ statewide_required=True,
39
+ required_for_mvp=True,
40
+ ),
41
+ OfficialSource(
42
+ id="cdfw_region_northern",
43
+ title="CDFW Northern Ocean Region",
44
+ source_type=OfficialSourceType.CDFW_OCEAN_REGION_MAP,
45
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Northern",
46
+ scope="Northern California ocean region",
47
+ region_key="northern",
48
+ statewide_required=True,
49
+ required_for_mvp=True,
50
+ ),
51
+ OfficialSource(
52
+ id="cdfw_region_mendocino",
53
+ title="CDFW Mendocino Ocean Region",
54
+ source_type=OfficialSourceType.CDFW_OCEAN_REGION_MAP,
55
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Mendocino",
56
+ scope="Mendocino California ocean region",
57
+ region_key="mendocino",
58
+ statewide_required=True,
59
+ required_for_mvp=True,
60
+ ),
61
+ OfficialSource(
62
+ id="cdfw_region_san_francisco",
63
+ title="CDFW San Francisco Ocean Region",
64
+ source_type=OfficialSourceType.CDFW_OCEAN_REGION_MAP,
65
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
66
+ scope="San Francisco California ocean region",
67
+ region_key="san_francisco",
68
+ statewide_required=True,
69
+ required_for_mvp=True,
70
+ ),
71
+ OfficialSource(
72
+ id="cdfw_region_central",
73
+ title="CDFW Central Ocean Region",
74
+ source_type=OfficialSourceType.CDFW_OCEAN_REGION_MAP,
75
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Central",
76
+ scope="Central California ocean region",
77
+ region_key="central",
78
+ statewide_required=True,
79
+ required_for_mvp=True,
80
+ ),
81
+ OfficialSource(
82
+ id="cdfw_region_southern",
83
+ title="CDFW Southern Ocean Region",
84
+ source_type=OfficialSourceType.CDFW_OCEAN_REGION_MAP,
85
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
86
+ scope="Southern California ocean region",
87
+ region_key="southern",
88
+ statewide_required=True,
89
+ required_for_mvp=True,
90
+ ),
91
+ OfficialSource(
92
+ id="cdfw_region_san_francisco_bay",
93
+ title="CDFW San Francisco Bay Region",
94
+ source_type=OfficialSourceType.CDFW_REGIONAL_REGULATIONS,
95
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/SF-Bay",
96
+ scope="San Francisco Bay sport fishing region",
97
+ region_key="san_francisco_bay",
98
+ statewide_required=True,
99
+ required_for_mvp=True,
100
+ ),
101
+ OfficialSource(
102
+ id="cdfw_groundfish_summary",
103
+ title="CDFW Groundfish Summary",
104
+ source_type=OfficialSourceType.CDFW_SPECIES_REGULATIONS,
105
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
106
+ scope="California ocean groundfish summaries",
107
+ statewide_required=True,
108
+ required_for_mvp=True,
109
+ ),
110
+ OfficialSource(
111
+ id="cdfw_crabs",
112
+ title="CDFW Crabs",
113
+ source_type=OfficialSourceType.CDFW_SPECIES_REGULATIONS,
114
+ url="https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
115
+ scope="California ocean crab regulations",
116
+ statewide_required=True,
117
+ required_for_mvp=True,
118
+ ),
119
+ OfficialSource(
120
+ id="cdph_shellfish_advisories",
121
+ title="CDPH Shellfish Advisories",
122
+ source_type=OfficialSourceType.CDPH_SHELLFISH_ADVISORY,
123
+ url="https://www.cdph.ca.gov/Programs/OPA/Pages/Shellfish-Advisories.aspx",
124
+ scope="California shellfish advisories",
125
+ statewide_required=True,
126
+ required_for_mvp=True,
127
+ ),
128
+ OfficialSource(
129
+ id="cdph_annual_mussel_quarantine",
130
+ title="CDPH Annual Mussel Quarantine",
131
+ source_type=OfficialSourceType.CDPH_ANNUAL_MUSSEL_QUARANTINE,
132
+ url="https://www.cdph.ca.gov/Programs/CEH/DRSEM/Pages/EMB/Shellfish/Annual-Mussel-Quarantine.aspx",
133
+ scope="California annual mussel quarantine",
134
+ statewide_required=True,
135
+ required_for_mvp=True,
136
+ ),
137
+ ]
138
+
139
+
140
+ def statewide_source_coverage() -> StatewideSourceCoverage:
141
+ """Return the statewide California ocean source coverage guarantee."""
142
+
143
+ return StatewideSourceCoverage(
144
+ id="statewide_ca_ocean",
145
+ required_region_keys=REGION_KEYS,
146
+ required_source_ids=tuple(source.id for source in load_official_sources() if source.required_for_mvp),
147
+ general_source_ids=(
148
+ "cdfw_ocean_regulations",
149
+ "cdfw_inseason_changes",
150
+ "cdfw_groundfish_summary",
151
+ "cdfw_crabs",
152
+ ),
153
+ cdph_source_ids=("cdph_shellfish_advisories", "cdph_annual_mussel_quarantine"),
154
+ )
155
+
156
+
157
+ def source_by_id(source_id: str) -> OfficialSource | None:
158
+ return {source.id: source for source in load_official_sources()}.get(source_id)
159
+
160
+
161
+ def validate_source_registry(sources: list[OfficialSource]) -> list[ValidationIssue]:
162
+ """Validate official-only source coverage and URL policy."""
163
+
164
+ issues: list[ValidationIssue] = []
165
+ seen: set[str] = set()
166
+ for source in sources:
167
+ if source.id in seen:
168
+ issues.append(ValidationIssue("id", "error", f"Duplicate source id: {source.id}"))
169
+ seen.add(source.id)
170
+ if not source.url.startswith("https://"):
171
+ issues.append(ValidationIssue(source.id, "error", "Source URLs must use HTTPS."))
172
+ if not any(host in source.url for host in APPROVED_HOSTS):
173
+ issues.append(ValidationIssue(source.id, "error", "Source URL is not CDFW/CDPH official."))
174
+
175
+ by_region = {source.region_key for source in sources if source.region_key}
176
+ for region_key in REGION_KEYS:
177
+ if region_key not in by_region:
178
+ issues.append(ValidationIssue(region_key, "error", "Missing statewide region source."))
179
+
180
+ coverage = statewide_source_coverage()
181
+ source_ids = {source.id for source in sources}
182
+ for source_id in coverage.required_source_ids:
183
+ if source_id not in source_ids:
184
+ issues.append(ValidationIssue(source_id, "error", "Missing MVP-required source."))
185
+ return issues
data/source-cache/seed-manifest.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "coastwise-source-cache-seed-2026-06-14",
3
+ "reviewed_at": "2026-06-14T12:00:00+00:00",
4
+ "coverage_id": "statewide_ca_ocean",
5
+ "stale_after_hours": 24,
6
+ "notes": "Reviewed seed cache for source-backed MVP validation. Online refresh replaces last-good snapshots when available."
7
+ }
data/source-cache/seed/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
data/source-cache/seed/facts.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "structured_facts": [
3
+ {
4
+ "id": "lingcod_min_size",
5
+ "species_or_category": "lingcod",
6
+ "display_name": "Lingcod",
7
+ "aliases": ["ling cod"],
8
+ "area_or_scope": "California ocean statewide groundfish regulations",
9
+ "fact_type": "minimum_size",
10
+ "value": "22",
11
+ "units": "inches total length",
12
+ "source_id": "cdfw_groundfish_summary",
13
+ "source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
14
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
15
+ "supporting_chunk_id": "chunk_lingcod_groundfish",
16
+ "supporting_snippet": "Lingcod, section 28.27, daily bag limit is 2 fish per person. Minimum size limit is 22 inches total length.",
17
+ "validation_set": true
18
+ },
19
+ {
20
+ "id": "lingcod_bag_limit",
21
+ "species_or_category": "lingcod",
22
+ "display_name": "Lingcod",
23
+ "aliases": ["ling cod"],
24
+ "area_or_scope": "California ocean statewide groundfish regulations",
25
+ "fact_type": "bag_limit",
26
+ "value": "2",
27
+ "units": "fish per person",
28
+ "source_id": "cdfw_groundfish_summary",
29
+ "source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
30
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
31
+ "supporting_chunk_id": "chunk_lingcod_groundfish",
32
+ "supporting_snippet": "Lingcod, section 28.27, daily bag limit is 2 fish per person. Minimum size limit is 22 inches total length.",
33
+ "validation_set": true
34
+ },
35
+ {
36
+ "id": "dungeness_crab_bag_limit",
37
+ "species_or_category": "dungeness_crab",
38
+ "display_name": "Dungeness crab",
39
+ "aliases": ["dungeness crabs", "crab"],
40
+ "area_or_scope": "California recreational crab fishery",
41
+ "fact_type": "general_summary",
42
+ "value": "Verify current CDFW Dungeness crab season, trap/hoop-net rules, public health closures, and crab rules before harvest.",
43
+ "source_id": "cdfw_crabs",
44
+ "source_url": "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
45
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
46
+ "supporting_chunk_id": "chunk_dungeness_crab",
47
+ "supporting_snippet": "CDFW recreational crab fishery materials link Dungeness crab season, crab trap, hoop net, public health closure, and current recreational ocean fishing regulation information.",
48
+ "validation_set": true
49
+ }
50
+ ],
51
+ "advisory_facts": [
52
+ {
53
+ "id": "cdph_annual_mussel_quarantine",
54
+ "affected_species_or_category": "mussels",
55
+ "area_or_scope": "California coast statewide",
56
+ "advisory_status": "Annual mussel quarantine normally applies May 1 through October 31.",
57
+ "effective_start": "2026-05-01",
58
+ "effective_end": "2026-10-31",
59
+ "warning": "Do not harvest sport-harvested mussels during the annual quarantine. Cooking does not reliably destroy the toxins.",
60
+ "source_id": "cdph_annual_mussel_quarantine",
61
+ "source_url": "https://www.cdph.ca.gov/Programs/CEH/DRSEM/Pages/EMB/Shellfish/Annual-Mussel-Quarantine.aspx",
62
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
63
+ "supporting_chunk_id": "chunk_cdph_quarantine",
64
+ "supporting_snippet": "The annual quarantine is normally in effect from May 1 through October 31 along the California coast. Cooking does not reliably destroy the toxins."
65
+ },
66
+ {
67
+ "id": "cdph_shellfish_verify",
68
+ "affected_species_or_category": "shellfish",
69
+ "area_or_scope": "California coast statewide",
70
+ "advisory_status": "Verify current CDPH shellfish advisories before harvest or consumption.",
71
+ "warning": "CDPH verification remains necessary even when cached advisory data is current.",
72
+ "source_id": "cdph_shellfish_advisories",
73
+ "source_url": "https://www.cdph.ca.gov/Programs/OPA/Pages/Shellfish-Advisories.aspx",
74
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
75
+ "supporting_chunk_id": "chunk_cdph_shellfish_verify",
76
+ "supporting_snippet": "CDPH shellfish advisories and recreational bivalve shellfish advisory map should be checked before harvesting or consuming sport-harvested shellfish."
77
+ }
78
+ ]
79
+ }
data/source-cache/seed/snapshots.json ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "source_id": "cdfw_ocean_regulations",
4
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations",
5
+ "title": "CDFW Ocean Sport Fishing Regulations",
6
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
7
+ "content_hash": "seed-cdfw-ocean-regulations",
8
+ "raw_text": "CDFW Ocean Sport Fishing Regulations provide statewide ocean regulation links, current sport fishing regulation PDFs, in-season ocean fishing regulation changes, the interactive ocean sport fishing map, and current sport fishing regulations by species or category. Where no bag limit is listed, fish are 20 finfish in combination with not more than 10 of any one species; shellfish are 35 of any one species.",
9
+ "origin": "seed",
10
+ "refresh_status": "unchanged"
11
+ },
12
+ {
13
+ "source_id": "cdfw_inseason_changes",
14
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Inseason",
15
+ "title": "CDFW Ocean Sport Fishing In-Season Regulation Changes",
16
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
17
+ "content_hash": "seed-cdfw-inseason",
18
+ "raw_text": "CDFW in-season ocean fishing regulation changes summarize current emergency and in-season changes. Users must check current CDFW guidance before fishing or harvesting.",
19
+ "origin": "seed",
20
+ "refresh_status": "unchanged"
21
+ },
22
+ {
23
+ "source_id": "cdfw_region_northern",
24
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Northern",
25
+ "title": "CDFW Northern Ocean Region",
26
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
27
+ "content_hash": "seed-region-northern",
28
+ "raw_text": "Northern California ocean region source entry for statewide CoastWise coverage. Use official CDFW source evidence and snippet fallback when structured regional facts are unavailable.",
29
+ "origin": "seed",
30
+ "refresh_status": "unchanged"
31
+ },
32
+ {
33
+ "source_id": "cdfw_region_mendocino",
34
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Mendocino",
35
+ "title": "CDFW Mendocino Ocean Region",
36
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
37
+ "content_hash": "seed-region-mendocino",
38
+ "raw_text": "Mendocino California ocean region source entry for statewide CoastWise coverage. Use official CDFW source evidence and snippet fallback when structured regional facts are unavailable.",
39
+ "origin": "seed",
40
+ "refresh_status": "unchanged"
41
+ },
42
+ {
43
+ "source_id": "cdfw_region_san_francisco",
44
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
45
+ "title": "CDFW San Francisco Ocean Region",
46
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
47
+ "content_hash": "seed-region-san-francisco",
48
+ "raw_text": "San Francisco California ocean region source entry for statewide CoastWise coverage. Point Arena to Pigeon Point rules can differ from San Francisco Bay rules.",
49
+ "origin": "seed",
50
+ "refresh_status": "unchanged"
51
+ },
52
+ {
53
+ "source_id": "cdfw_region_central",
54
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Central",
55
+ "title": "CDFW Central Ocean Region",
56
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
57
+ "content_hash": "seed-region-central",
58
+ "raw_text": "Central California ocean region source entry for statewide CoastWise coverage, including Monterey and Santa Cruz area context. Use cited regional snippets when exact structured facts are unavailable.",
59
+ "origin": "seed",
60
+ "refresh_status": "unchanged"
61
+ },
62
+ {
63
+ "source_id": "cdfw_region_southern",
64
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
65
+ "title": "CDFW Southern Ocean Region",
66
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
67
+ "content_hash": "seed-region-southern",
68
+ "raw_text": "Southern California ocean region source entry for statewide CoastWise coverage, including Santa Barbara and San Diego area context. Use cited regional snippets when exact structured facts are unavailable.",
69
+ "origin": "seed",
70
+ "refresh_status": "unchanged"
71
+ },
72
+ {
73
+ "source_id": "cdfw_region_san_francisco_bay",
74
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/SF-Bay",
75
+ "title": "CDFW San Francisco Bay Region",
76
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
77
+ "content_hash": "seed-region-sf-bay",
78
+ "raw_text": "San Francisco Bay region source entry for statewide CoastWise coverage. Bay rules can differ from adjacent ocean coast rules; users must verify Bay-specific CDFW guidance.",
79
+ "origin": "seed",
80
+ "refresh_status": "unchanged"
81
+ },
82
+ {
83
+ "source_id": "cdfw_groundfish_summary",
84
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
85
+ "title": "CDFW Groundfish Summary",
86
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
87
+ "content_hash": "seed-groundfish-summary",
88
+ "raw_text": "CDFW Summary of Recreational Groundfish Fishing Regulations updated January 6, 2026. These regulations are subject to in-season change. Lingcod, section 28.27, daily bag limit is 2 fish per person. Minimum size limit is 22 inches total length. Filleting aboard a vessel requires minimum 14 inches in length with the entire skin attached.",
89
+ "origin": "seed",
90
+ "refresh_status": "unchanged"
91
+ },
92
+ {
93
+ "source_id": "cdfw_crabs",
94
+ "url": "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
95
+ "title": "CDFW Crabs",
96
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
97
+ "content_hash": "seed-crabs",
98
+ "raw_text": "CDFW Invertebrates of Interest: Crabs includes recreational Dungeness and rock crab regulations, public health hazard closures, trap and hoop net regulation summaries, and recreational crab fishery links. Recreational crab questions must verify current CDFW crab pages, public health closures, and gear restrictions.",
99
+ "origin": "seed",
100
+ "refresh_status": "unchanged"
101
+ },
102
+ {
103
+ "source_id": "cdph_shellfish_advisories",
104
+ "url": "https://www.cdph.ca.gov/Programs/OPA/Pages/Shellfish-Advisories.aspx",
105
+ "title": "CDPH Shellfish Advisories",
106
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
107
+ "content_hash": "seed-cdph-shellfish",
108
+ "raw_text": "CDPH shellfish advisories and the recreational bivalve shellfish advisory map provide current health advisories for shellfish, crab, and related marine biotoxin risks. Users must verify CDPH before harvesting or consuming sport-harvested shellfish.",
109
+ "origin": "seed",
110
+ "refresh_status": "unchanged"
111
+ },
112
+ {
113
+ "source_id": "cdph_annual_mussel_quarantine",
114
+ "url": "https://www.cdph.ca.gov/Programs/CEH/DRSEM/Pages/EMB/Shellfish/Annual-Mussel-Quarantine.aspx",
115
+ "title": "CDPH Annual Mussel Quarantine",
116
+ "retrieved_at": "2026-06-14T12:00:00+00:00",
117
+ "content_hash": "seed-cdph-quarantine",
118
+ "raw_text": "CDPH annual mussel quarantine applies only to sport-harvested mussels. The annual quarantine is normally in effect from May 1 through October 31. It is in effect along the California coast from the Oregon border to the Mexican border, including all bays, inlets and harbors. Cooking does not reliably destroy the toxins.",
119
+ "origin": "seed",
120
+ "refresh_status": "unchanged"
121
+ }
122
+ ]
data/source-cache/seed/sources.json ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "cdfw_ocean_regulations",
4
+ "title": "CDFW Ocean Sport Fishing Regulations",
5
+ "source_type": "cdfw_ocean_regulations",
6
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations",
7
+ "scope": "Statewide California ocean sport fishing regulations",
8
+ "statewide_required": true,
9
+ "required_for_mvp": true
10
+ },
11
+ {
12
+ "id": "cdfw_inseason_changes",
13
+ "title": "CDFW Ocean Sport Fishing In-Season Regulation Changes",
14
+ "source_type": "cdfw_ocean_regulations",
15
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Inseason",
16
+ "scope": "Statewide California ocean in-season regulation changes",
17
+ "statewide_required": true,
18
+ "required_for_mvp": true
19
+ },
20
+ {
21
+ "id": "cdfw_region_northern",
22
+ "title": "CDFW Northern Ocean Region",
23
+ "source_type": "cdfw_ocean_region_map",
24
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Northern",
25
+ "scope": "Northern California ocean region",
26
+ "region_key": "northern",
27
+ "statewide_required": true,
28
+ "required_for_mvp": true
29
+ },
30
+ {
31
+ "id": "cdfw_region_mendocino",
32
+ "title": "CDFW Mendocino Ocean Region",
33
+ "source_type": "cdfw_ocean_region_map",
34
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Mendocino",
35
+ "scope": "Mendocino California ocean region",
36
+ "region_key": "mendocino",
37
+ "statewide_required": true,
38
+ "required_for_mvp": true
39
+ },
40
+ {
41
+ "id": "cdfw_region_san_francisco",
42
+ "title": "CDFW San Francisco Ocean Region",
43
+ "source_type": "cdfw_ocean_region_map",
44
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
45
+ "scope": "San Francisco California ocean region",
46
+ "region_key": "san_francisco",
47
+ "statewide_required": true,
48
+ "required_for_mvp": true
49
+ },
50
+ {
51
+ "id": "cdfw_region_central",
52
+ "title": "CDFW Central Ocean Region",
53
+ "source_type": "cdfw_ocean_region_map",
54
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Central",
55
+ "scope": "Central California ocean region",
56
+ "region_key": "central",
57
+ "statewide_required": true,
58
+ "required_for_mvp": true
59
+ },
60
+ {
61
+ "id": "cdfw_region_southern",
62
+ "title": "CDFW Southern Ocean Region",
63
+ "source_type": "cdfw_ocean_region_map",
64
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
65
+ "scope": "Southern California ocean region",
66
+ "region_key": "southern",
67
+ "statewide_required": true,
68
+ "required_for_mvp": true
69
+ },
70
+ {
71
+ "id": "cdfw_region_san_francisco_bay",
72
+ "title": "CDFW San Francisco Bay Region",
73
+ "source_type": "cdfw_regional_regulations",
74
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/SF-Bay",
75
+ "scope": "San Francisco Bay sport fishing region",
76
+ "region_key": "san_francisco_bay",
77
+ "statewide_required": true,
78
+ "required_for_mvp": true
79
+ },
80
+ {
81
+ "id": "cdfw_groundfish_summary",
82
+ "title": "CDFW Groundfish Summary",
83
+ "source_type": "cdfw_species_regulations",
84
+ "url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
85
+ "scope": "California ocean groundfish summaries",
86
+ "statewide_required": true,
87
+ "required_for_mvp": true
88
+ },
89
+ {
90
+ "id": "cdfw_crabs",
91
+ "title": "CDFW Crabs",
92
+ "source_type": "cdfw_species_regulations",
93
+ "url": "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
94
+ "scope": "California ocean crab regulations",
95
+ "statewide_required": true,
96
+ "required_for_mvp": true
97
+ },
98
+ {
99
+ "id": "cdph_shellfish_advisories",
100
+ "title": "CDPH Shellfish Advisories",
101
+ "source_type": "cdph_shellfish_advisory",
102
+ "url": "https://www.cdph.ca.gov/Programs/OPA/Pages/Shellfish-Advisories.aspx",
103
+ "scope": "California shellfish advisories",
104
+ "statewide_required": true,
105
+ "required_for_mvp": true
106
+ },
107
+ {
108
+ "id": "cdph_annual_mussel_quarantine",
109
+ "title": "CDPH Annual Mussel Quarantine",
110
+ "source_type": "cdph_annual_mussel_quarantine",
111
+ "url": "https://www.cdph.ca.gov/Programs/CEH/DRSEM/Pages/EMB/Shellfish/Annual-Mussel-Quarantine.aspx",
112
+ "scope": "California annual mussel quarantine",
113
+ "statewide_required": true,
114
+ "required_for_mvp": true
115
+ }
116
+ ]
tests/fixtures/source-cache/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
tests/fixtures/source-pages/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
tests/test_source_extract.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source extraction tests."""
2
+
3
+ from coastwise.schemas import RefreshStatus, SourceSnapshot
4
+ from coastwise.source_extract import (
5
+ content_hash,
6
+ default_structured_validation_set,
7
+ extract_structured_facts,
8
+ extract_source_chunks,
9
+ normalize_source_text,
10
+ tokenize_source_text,
11
+ )
12
+ from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, load_seed_cache
13
+
14
+
15
+ def test_source_text_normalization_hashing_and_chunk_creation():
16
+ text = "<html><body><h1>Lingcod</h1><p>Minimum size: 22 inches total length.</p></body></html>"
17
+ normalized = normalize_source_text(text)
18
+
19
+ assert normalized == "Lingcod Minimum size: 22 inches total length."
20
+ assert content_hash(normalized) == content_hash("Lingcod Minimum size: 22 inches total length.")
21
+ assert "lingcod" in tokenize_source_text(normalized)
22
+
23
+ snapshot = SourceSnapshot(
24
+ source_id="cdfw_ocean",
25
+ url="https://wildlife.ca.gov/Fishing/Ocean/Regulations",
26
+ title="CDFW Ocean Sport Fishing",
27
+ retrieved_at="2026-06-14T12:00:00+00:00",
28
+ content_hash=content_hash(normalized),
29
+ raw_text=normalized,
30
+ origin="seed",
31
+ refresh_status=RefreshStatus.UNCHANGED,
32
+ )
33
+ chunks = extract_source_chunks(snapshot)
34
+
35
+ assert chunks
36
+ assert chunks[0].source_url == snapshot.url
37
+ assert "lingcod" in chunks[0].tokens
38
+
39
+
40
+ def test_extract_structured_validation_set_facts_from_seed_chunks():
41
+ cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
42
+ result = extract_structured_facts(cache.chunks, default_structured_validation_set())
43
+ fact_ids = {fact.id for fact in result.facts}
44
+
45
+ assert {"lingcod_min_size", "lingcod_bag_limit", "dungeness_crab_bag_limit"} <= fact_ids
46
+ assert not result.issues
tests/test_source_refresh.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source refresh tests."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from coastwise.schemas import RefreshStatus
6
+ from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, load_seed_cache
7
+ from coastwise.source_refresh import refresh_official_sources
8
+ from coastwise.source_registry import load_official_sources
9
+
10
+
11
+ NOW = datetime(2026, 6, 14, 13, tzinfo=timezone.utc)
12
+
13
+
14
+ def test_refresh_official_sources_reports_updated_unchanged_failed_and_partial():
15
+ sources = load_official_sources()[:3]
16
+ cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
17
+
18
+ def fetcher(source):
19
+ if source.id == sources[0].id:
20
+ return cache.snapshots_by_source_id[source.id].raw_text + " updated"
21
+ if source.id == sources[1].id:
22
+ return cache.snapshots_by_source_id[source.id].raw_text
23
+ raise OSError("network unavailable")
24
+
25
+ result, refreshed_cache = refresh_official_sources(sources, cache, NOW, fetcher)
26
+
27
+ assert result.source_results[sources[0].id] is RefreshStatus.UPDATED
28
+ assert result.source_results[sources[1].id] is RefreshStatus.UNCHANGED
29
+ assert result.source_results[sources[2].id] is RefreshStatus.FAILED
30
+ assert sources[2].id in result.cache_preserved_source_ids
31
+ assert refreshed_cache.snapshots_by_source_id[sources[2].id].content_hash
32
+ assert "failed" in result.user_message.lower()
tests/test_source_registry.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source registry tests."""
2
+
3
+ from coastwise.source_registry import (
4
+ load_official_sources,
5
+ statewide_source_coverage,
6
+ validate_source_registry,
7
+ )
8
+
9
+
10
+ def test_source_registry_uses_official_https_urls_only():
11
+ sources = load_official_sources()
12
+ issues = validate_source_registry(sources)
13
+
14
+ assert sources
15
+ assert not [issue for issue in issues if issue.severity == "error"]
16
+ for source in sources:
17
+ assert source.url.startswith("https://")
18
+ assert "wildlife.ca.gov" in source.url or "cdph.ca.gov" in source.url
19
+
20
+
21
+ def test_statewide_registry_includes_required_regions_and_cdph_sources():
22
+ sources = load_official_sources()
23
+ source_ids = {source.id for source in sources}
24
+ coverage = statewide_source_coverage()
25
+
26
+ assert {
27
+ "northern",
28
+ "mendocino",
29
+ "san_francisco",
30
+ "central",
31
+ "southern",
32
+ "san_francisco_bay",
33
+ } <= set(coverage.required_region_keys)
34
+ assert set(coverage.required_source_ids) <= source_ids
35
+ assert "cdfw_inseason_changes" in coverage.general_source_ids
36
+ assert {"cdph_shellfish_advisories", "cdph_annual_mussel_quarantine"} <= set(
37
+ coverage.cdph_source_ids
38
+ )