Spaces:
Running
Running
Stephen Lee commited on
Commit ·
a3c85ad
1
Parent(s): 8857545
feat: add official source cache foundation
Browse files- .gitignore +1 -0
- coastwise/__init__.py +15 -0
- coastwise/source_cache.py +250 -0
- coastwise/source_extract.py +219 -0
- coastwise/source_refresh.py +99 -0
- coastwise/source_registry.py +185 -0
- data/source-cache/seed-manifest.json +7 -0
- data/source-cache/seed/.gitkeep +1 -0
- data/source-cache/seed/facts.json +79 -0
- data/source-cache/seed/snapshots.json +122 -0
- data/source-cache/seed/sources.json +116 -0
- tests/fixtures/source-cache/.gitkeep +1 -0
- tests/fixtures/source-pages/.gitkeep +1 -0
- tests/test_source_extract.py +46 -0
- tests/test_source_refresh.py +32 -0
- tests/test_source_registry.py +38 -0
.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 |
+
)
|