Spaces:
Running
feat: source-derived Q&A with statewide CA ocean species coverage (#1)
Browse filesSource-backed Q&A over the full California ocean sport-fishing species set.
- 78 distinct species with source-verified facts (size/bag/season/closure),
every value traced to a CDFW page or CCR Title 14: groundfish (RCG complex),
halibut, surfperch, seabass, sharks, sturgeon, crab, salmon, SoCal gamefish
(kelp/sand bass, sheephead, barracuda, bonito, yellowtail), nearshore species,
invertebrates (spiny lobster, scallops, squid, clams), and no-take species.
- Species resolution: model (MiniCPM) + deterministic name search with a guard
that prefers specific user-typed aliases (length-aware scoring), plus a
rule-card posture fallback for species without an extracted fact.
- "Refresh official sources" applies live snapshot freshness to the session;
structured facts stay curated and are never auto-derived from fetched HTML.
- Answers state regulations directly (source shown in the evidence section).
171 tests pass; verified end-to-end on the local MiniCPM model server.
- README.md +30 -5
- app.py +29 -6
- coastwise/ai.py +319 -0
- coastwise/locations.py +103 -1
- coastwise/qna.py +473 -19
- coastwise/schemas.py +32 -0
- coastwise/search.py +10 -2
- coastwise/source_extract.py +87 -0
- coastwise/submission.py +2 -2
- data/source-cache/seed/chunks.json +4 -4
- data/source-cache/seed/facts.json +1950 -3
- requirements.txt +2 -0
- scripts/seed_expand.py +270 -0
- scripts/seed_expand_statewide.py +248 -0
- tests/fixtures/real_user_queries.json +20 -2
- tests/test_ai.py +311 -0
- tests/test_app.py +43 -1
- tests/test_locations.py +82 -1
- tests/test_qna.py +577 -13
- tests/test_real_user_queries.py +3 -1
- tests/test_schemas.py +39 -0
- tests/test_search.py +10 -0
- tests/test_source_extract.py +24 -0
- tests/test_submission.py +10 -0
|
@@ -35,11 +35,14 @@ Target badges and awards, pending final evidence:
|
|
| 35 |
|
| 36 |
- Best Agent: source-backed question interpretation, evidence selection, and
|
| 37 |
constrained wording over deterministic CDFW/CDPH facts or snippets.
|
| 38 |
-
- OpenBMB: MiniCPM5-1B
|
| 39 |
-
|
|
|
|
| 40 |
- Tiny Titan: the submitted model inventory stays at or below 4B parameters.
|
| 41 |
-
- Off the Grid: bundled seed cache and last-good runtime cache
|
| 42 |
-
supported questions
|
|
|
|
|
|
|
| 43 |
- Off Brand: custom CoastWise Gradio interface.
|
| 44 |
- Best Demo / Field Notes: public demo video, social post, and write-up links
|
| 45 |
will be added before submission.
|
|
@@ -75,7 +78,7 @@ evidence.
|
|
| 75 |
|
| 76 |
| Model | Provider | Source URL | Role | Parameters | Runtime mode | Required for core safety | Award relevance |
|
| 77 |
|---|---|---|---|---:|---|---|---|
|
| 78 |
-
| MiniCPM5-1B | OpenBMB | https://huggingface.co/openbmb/MiniCPM5-1B | Optional
|
| 79 |
|
| 80 |
No model fine-tuning is required for the MVP. Model output, when enabled, may
|
| 81 |
only help with interpretation or wording and cannot create or override
|
|
@@ -85,6 +88,28 @@ next safe action.
|
|
| 85 |
All listed models are under the hackathon 32B cap. Tiny Titan remains eligible
|
| 86 |
only while every submitted model stays at or below 4B total parameters.
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
## Official Sources And Freshness
|
| 89 |
|
| 90 |
Cached official source metadata is bundled for offline lookup. CoastWise treats
|
|
|
|
| 35 |
|
| 36 |
- Best Agent: source-backed question interpretation, evidence selection, and
|
| 37 |
constrained wording over deterministic CDFW/CDPH facts or snippets.
|
| 38 |
+
- OpenBMB: MiniCPM5-1B (in-process GGUF or hosted, OpenAI-compatible) normalizes
|
| 39 |
+
messy questions into validated source-backed candidates; it never creates or
|
| 40 |
+
overrides facts.
|
| 41 |
- Tiny Titan: the submitted model inventory stays at or below 4B parameters.
|
| 42 |
+
- Off the Grid: the bundled seed cache and last-good runtime cache answer
|
| 43 |
+
supported questions with no model and no network. The optional MiniCPM
|
| 44 |
+
interpreter can run fully in-process from a local GGUF (no external API), or
|
| 45 |
+
via OpenBMB's hosted API; either way the core Q&A never requires it.
|
| 46 |
- Off Brand: custom CoastWise Gradio interface.
|
| 47 |
- Best Demo / Field Notes: public demo video, social post, and write-up links
|
| 48 |
will be added before submission.
|
|
|
|
| 78 |
|
| 79 |
| Model | Provider | Source URL | Role | Parameters | Runtime mode | Required for core safety | Award relevance |
|
| 80 |
|---|---|---|---|---:|---|---|---|
|
| 81 |
+
| MiniCPM5-1B | OpenBMB | https://huggingface.co/openbmb/MiniCPM5-1B | Optional source-backed question interpretation; normalizes messy questions into validated candidates and cannot create or override facts | 1.0B | Hosted OpenAI-compatible endpoint when configured, deterministic fallback otherwise | No | OpenBMB, Tiny Titan |
|
| 82 |
|
| 83 |
No model fine-tuning is required for the MVP. Model output, when enabled, may
|
| 84 |
only help with interpretation or wording and cannot create or override
|
|
|
|
| 88 |
All listed models are under the hackathon 32B cap. Tiny Titan remains eligible
|
| 89 |
only while every submitted model stays at or below 4B total parameters.
|
| 90 |
|
| 91 |
+
### Enabling The MiniCPM Interpreter
|
| 92 |
+
|
| 93 |
+
The MiniCPM interpreter is optional and off by default; the deterministic core
|
| 94 |
+
answers supported questions with no model. Two ways to enable it, tried in this
|
| 95 |
+
order (local first, then hosted, otherwise deterministic):
|
| 96 |
+
|
| 97 |
+
Local, off the grid (no API key): runs MiniCPM5-1B in-process from a GGUF via
|
| 98 |
+
`llama-cpp-python`, with no external API. Set `COASTWISE_LOCAL_MODEL=1` (the
|
| 99 |
+
`llama-cpp-python` dependency must be installed). Optional overrides:
|
| 100 |
+
`COASTWISE_LOCAL_REPO` (default `openbmb/MiniCPM5-1B-GGUF`) and
|
| 101 |
+
`COASTWISE_LOCAL_FILE` (default `*Q4_K_M.gguf`).
|
| 102 |
+
|
| 103 |
+
Hosted (OpenBMB API): set `COASTWISE_LLM_API_KEY` to an OpenBMB hosted key.
|
| 104 |
+
Optional: `COASTWISE_LLM_BASE_URL` (default `https://api.modelbest.cn/v1`) and
|
| 105 |
+
`COASTWISE_LLM_MODEL` (default `MiniCPM5-1B`).
|
| 106 |
+
|
| 107 |
+
When neither is configured, CoastWise stays fully deterministic and still
|
| 108 |
+
answers supported questions from cache. The model only normalizes the user's
|
| 109 |
+
question into validated source-backed candidates; it never creates or overrides
|
| 110 |
+
regulation facts, advisory facts, source links, dates, freshness, status, or the
|
| 111 |
+
next safe action.
|
| 112 |
+
|
| 113 |
## Official Sources And Freshness
|
| 114 |
|
| 115 |
Cached official source metadata is bundled for offline lookup. CoastWise treats
|
|
@@ -9,6 +9,8 @@ from datetime import datetime, timezone
|
|
| 9 |
import gradio as gr
|
| 10 |
|
| 11 |
from coastwise.ai import (
|
|
|
|
|
|
|
| 12 |
build_lookup_workflow_trace,
|
| 13 |
explain_safety_result,
|
| 14 |
explanation_to_dict,
|
|
@@ -20,7 +22,7 @@ from coastwise.qna import answer_question, answer_to_dict
|
|
| 20 |
from coastwise.rules import compose_safety_result, default_context, result_to_dict
|
| 21 |
from coastwise.schemas import CoastalRegion, UserIntent
|
| 22 |
from coastwise.search import search_species
|
| 23 |
-
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, load_seed_cache
|
| 24 |
from coastwise.source_refresh import fetch_official_source, refresh_official_sources
|
| 25 |
from coastwise.source_registry import load_official_sources
|
| 26 |
from coastwise.submission import default_model_inventory
|
|
@@ -30,7 +32,6 @@ APP_TITLE = "CoastWise"
|
|
| 30 |
|
| 31 |
SOURCE_BACKED_CONTROL_LABELS = (
|
| 32 |
"Question",
|
| 33 |
-
"Beach or coastal area",
|
| 34 |
"Ask",
|
| 35 |
"Refresh official sources",
|
| 36 |
"Source status",
|
|
@@ -362,8 +363,17 @@ REGION_LABELS = {
|
|
| 362 |
}
|
| 363 |
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
def _active_cache():
|
| 366 |
-
return load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 367 |
|
| 368 |
|
| 369 |
def ask_source_backed_question(question: str, beach_or_coastal_area: str = "") -> dict[str, object]:
|
|
@@ -372,6 +382,7 @@ def ask_source_backed_question(question: str, beach_or_coastal_area: str = "") -
|
|
| 372 |
beach_or_coastal_area,
|
| 373 |
_active_cache(),
|
| 374 |
datetime.now(timezone.utc),
|
|
|
|
| 375 |
)
|
| 376 |
return answer_to_dict(answer)
|
| 377 |
|
|
@@ -473,7 +484,7 @@ def render_source_backed_answer_from_cache(
|
|
| 473 |
cache,
|
| 474 |
now: datetime,
|
| 475 |
) -> str:
|
| 476 |
-
answer = answer_question(question, beach_or_coastal_area, cache, now)
|
| 477 |
return _render_source_backed_answer_payload(answer_to_dict(answer))
|
| 478 |
|
| 479 |
|
|
@@ -517,15 +528,27 @@ def _bounded_live_fetcher(sources, per_source_timeout: float = 2.5, overall_dead
|
|
| 517 |
|
| 518 |
|
| 519 |
def refresh_official_sources_status(fetcher=None) -> dict[str, object]:
|
|
|
|
| 520 |
sources = load_official_sources()
|
| 521 |
if fetcher is None:
|
| 522 |
fetcher = _bounded_live_fetcher(sources)
|
| 523 |
-
|
|
|
|
| 524 |
sources,
|
| 525 |
-
|
| 526 |
datetime.now(timezone.utc),
|
| 527 |
fetcher,
|
| 528 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
return {
|
| 530 |
"message": result.user_message,
|
| 531 |
"updated": result.updated_source_ids,
|
|
|
|
| 9 |
import gradio as gr
|
| 10 |
|
| 11 |
from coastwise.ai import (
|
| 12 |
+
build_hosted_interpreter,
|
| 13 |
+
build_local_interpreter,
|
| 14 |
build_lookup_workflow_trace,
|
| 15 |
explain_safety_result,
|
| 16 |
explanation_to_dict,
|
|
|
|
| 22 |
from coastwise.rules import compose_safety_result, default_context, result_to_dict
|
| 23 |
from coastwise.schemas import CoastalRegion, UserIntent
|
| 24 |
from coastwise.search import search_species
|
| 25 |
+
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
|
| 26 |
from coastwise.source_refresh import fetch_official_source, refresh_official_sources
|
| 27 |
from coastwise.source_registry import load_official_sources
|
| 28 |
from coastwise.submission import default_model_inventory
|
|
|
|
| 32 |
|
| 33 |
SOURCE_BACKED_CONTROL_LABELS = (
|
| 34 |
"Question",
|
|
|
|
| 35 |
"Ask",
|
| 36 |
"Refresh official sources",
|
| 37 |
"Source status",
|
|
|
|
| 363 |
}
|
| 364 |
|
| 365 |
|
| 366 |
+
SOURCE_BACKED_INTERPRETER = build_local_interpreter() or build_hosted_interpreter()
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# Holds the most recent refreshed cache for the running session, so the
|
| 370 |
+
# "Refresh official sources" button actually changes what subsequent answers
|
| 371 |
+
# read. None means "use the bundled reviewed seed".
|
| 372 |
+
_SESSION_CACHE: SourceCache | None = None
|
| 373 |
+
|
| 374 |
+
|
| 375 |
def _active_cache():
|
| 376 |
+
return _SESSION_CACHE if _SESSION_CACHE is not None else load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 377 |
|
| 378 |
|
| 379 |
def ask_source_backed_question(question: str, beach_or_coastal_area: str = "") -> dict[str, object]:
|
|
|
|
| 382 |
beach_or_coastal_area,
|
| 383 |
_active_cache(),
|
| 384 |
datetime.now(timezone.utc),
|
| 385 |
+
model=SOURCE_BACKED_INTERPRETER,
|
| 386 |
)
|
| 387 |
return answer_to_dict(answer)
|
| 388 |
|
|
|
|
| 484 |
cache,
|
| 485 |
now: datetime,
|
| 486 |
) -> str:
|
| 487 |
+
answer = answer_question(question, beach_or_coastal_area, cache, now, model=SOURCE_BACKED_INTERPRETER)
|
| 488 |
return _render_source_backed_answer_payload(answer_to_dict(answer))
|
| 489 |
|
| 490 |
|
|
|
|
| 528 |
|
| 529 |
|
| 530 |
def refresh_official_sources_status(fetcher=None) -> dict[str, object]:
|
| 531 |
+
global _SESSION_CACHE
|
| 532 |
sources = load_official_sources()
|
| 533 |
if fetcher is None:
|
| 534 |
fetcher = _bounded_live_fetcher(sources)
|
| 535 |
+
base = _active_cache()
|
| 536 |
+
result, refreshed = refresh_official_sources(
|
| 537 |
sources,
|
| 538 |
+
base,
|
| 539 |
datetime.now(timezone.utc),
|
| 540 |
fetcher,
|
| 541 |
)
|
| 542 |
+
# Apply the re-fetch to the live session as updated snapshot freshness, but
|
| 543 |
+
# keep the reviewed chunks/facts: structured regulation values stay curated
|
| 544 |
+
# and are never auto-derived from freshly fetched HTML.
|
| 545 |
+
_SESSION_CACHE = SourceCache(
|
| 546 |
+
sources=refreshed.sources,
|
| 547 |
+
snapshots=refreshed.snapshots,
|
| 548 |
+
chunks=base.chunks,
|
| 549 |
+
facts=base.facts,
|
| 550 |
+
advisory_facts=base.advisory_facts,
|
| 551 |
+
)
|
| 552 |
return {
|
| 553 |
"message": result.user_message,
|
| 554 |
"updated": result.updated_source_ids,
|
|
@@ -2,8 +2,12 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import hashlib
|
|
|
|
|
|
|
| 6 |
import re
|
|
|
|
| 7 |
from collections.abc import Callable
|
| 8 |
|
| 9 |
from coastwise.schemas import (
|
|
@@ -11,9 +15,12 @@ from coastwise.schemas import (
|
|
| 11 |
GenerativeExplanation,
|
| 12 |
LookupWorkflowTrace,
|
| 13 |
ModelInventory,
|
|
|
|
| 14 |
ModelRole,
|
|
|
|
| 15 |
SafetyResult,
|
| 16 |
SourceBackedAnswer,
|
|
|
|
| 17 |
)
|
| 18 |
|
| 19 |
FORBIDDEN_CLAIMS = (
|
|
@@ -249,6 +256,318 @@ def validate_cached_evidence_answer(packet: CachedEvidencePacket, text: str) ->
|
|
| 249 |
return len(generated_tokens & evidence_tokens) >= 4
|
| 250 |
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
def explain_safety_result(
|
| 253 |
result: SafetyResult,
|
| 254 |
inventory: ModelInventory,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import ast
|
| 6 |
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
import re
|
| 10 |
+
import urllib.request
|
| 11 |
from collections.abc import Callable
|
| 12 |
|
| 13 |
from coastwise.schemas import (
|
|
|
|
| 15 |
GenerativeExplanation,
|
| 16 |
LookupWorkflowTrace,
|
| 17 |
ModelInventory,
|
| 18 |
+
ModelQuestionInterpretation,
|
| 19 |
ModelRole,
|
| 20 |
+
RequestedFactType,
|
| 21 |
SafetyResult,
|
| 22 |
SourceBackedAnswer,
|
| 23 |
+
SourceBackedIntent,
|
| 24 |
)
|
| 25 |
|
| 26 |
FORBIDDEN_CLAIMS = (
|
|
|
|
| 256 |
return len(generated_tokens & evidence_tokens) >= 4
|
| 257 |
|
| 258 |
|
| 259 |
+
def interpret_question_with_model(
|
| 260 |
+
question: str,
|
| 261 |
+
location_text: str | None,
|
| 262 |
+
*,
|
| 263 |
+
allowed_species: set[str],
|
| 264 |
+
allowed_locations: dict[str, str],
|
| 265 |
+
allowed_region_keys: set[str],
|
| 266 |
+
generator: Callable[[str], str | None] | None = None,
|
| 267 |
+
) -> ModelQuestionInterpretation | None:
|
| 268 |
+
"""Normalize a messy source-backed question using only provided candidates."""
|
| 269 |
+
|
| 270 |
+
if generator is None:
|
| 271 |
+
return None
|
| 272 |
+
|
| 273 |
+
allowed_intents = {intent.value for intent in SourceBackedIntent}
|
| 274 |
+
allowed_fact_types = {
|
| 275 |
+
fact_type.value
|
| 276 |
+
for fact_type in RequestedFactType
|
| 277 |
+
if fact_type != RequestedFactType.UNKNOWN
|
| 278 |
+
}
|
| 279 |
+
prompt = (
|
| 280 |
+
"Interpret this CoastWise question as JSON only. "
|
| 281 |
+
"Choose species_or_category only from allowed_species. "
|
| 282 |
+
"Choose location_name only from allowed_locations. "
|
| 283 |
+
"Choose region_key only from allowed_region_keys. "
|
| 284 |
+
"Choose intent only from allowed_intents. "
|
| 285 |
+
"Choose requested_fact_types only from allowed_fact_types. "
|
| 286 |
+
"Do not return regulation facts, legal conclusions, source links, dates, "
|
| 287 |
+
"open/closed status, or food-safety status.\n"
|
| 288 |
+
f"question={question}\n"
|
| 289 |
+
f"location_text={location_text or ''}\n"
|
| 290 |
+
f"allowed_species={sorted(allowed_species)}\n"
|
| 291 |
+
f"allowed_locations={json.dumps(allowed_locations, sort_keys=True)}\n"
|
| 292 |
+
f"allowed_region_keys={sorted(allowed_region_keys)}\n"
|
| 293 |
+
f"allowed_intents={sorted(allowed_intents)}\n"
|
| 294 |
+
f"allowed_fact_types={sorted(allowed_fact_types)}"
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
try:
|
| 298 |
+
raw = generator(prompt)
|
| 299 |
+
if not isinstance(raw, str) or not raw.strip():
|
| 300 |
+
return None
|
| 301 |
+
payload = json.loads(raw)
|
| 302 |
+
except Exception:
|
| 303 |
+
return None
|
| 304 |
+
|
| 305 |
+
if not isinstance(payload, dict):
|
| 306 |
+
return None
|
| 307 |
+
|
| 308 |
+
species = payload.get("species_or_category")
|
| 309 |
+
location_name = payload.get("location_name")
|
| 310 |
+
region_key = payload.get("region_key")
|
| 311 |
+
intent = payload.get("intent")
|
| 312 |
+
requested = payload.get("requested_fact_types")
|
| 313 |
+
confidence = payload.get("confidence")
|
| 314 |
+
notes = payload.get("notes")
|
| 315 |
+
|
| 316 |
+
if species is not None and not isinstance(species, str):
|
| 317 |
+
return None
|
| 318 |
+
if species is not None and species not in allowed_species:
|
| 319 |
+
return None
|
| 320 |
+
if location_name is not None and not isinstance(location_name, str):
|
| 321 |
+
return None
|
| 322 |
+
if location_name is not None and location_name not in allowed_locations:
|
| 323 |
+
canonical = next(
|
| 324 |
+
(name for name in allowed_locations if name.casefold() == location_name.casefold()),
|
| 325 |
+
None,
|
| 326 |
+
)
|
| 327 |
+
if canonical is None:
|
| 328 |
+
return None
|
| 329 |
+
location_name = canonical
|
| 330 |
+
if region_key is not None and not isinstance(region_key, str):
|
| 331 |
+
return None
|
| 332 |
+
if region_key is not None and region_key not in allowed_region_keys:
|
| 333 |
+
return None
|
| 334 |
+
if location_name is not None and allowed_locations[location_name] != region_key:
|
| 335 |
+
return None
|
| 336 |
+
if not isinstance(intent, str):
|
| 337 |
+
return None
|
| 338 |
+
if intent not in allowed_intents:
|
| 339 |
+
return None
|
| 340 |
+
if not isinstance(confidence, str):
|
| 341 |
+
return None
|
| 342 |
+
if confidence not in {"high", "medium"}:
|
| 343 |
+
return None
|
| 344 |
+
if not isinstance(requested, list) or not requested:
|
| 345 |
+
return None
|
| 346 |
+
if any(
|
| 347 |
+
not isinstance(value, str) or value not in allowed_fact_types for value in requested
|
| 348 |
+
):
|
| 349 |
+
return None
|
| 350 |
+
|
| 351 |
+
try:
|
| 352 |
+
return ModelQuestionInterpretation(
|
| 353 |
+
species_or_category=species,
|
| 354 |
+
location_name=location_name,
|
| 355 |
+
region_key=region_key,
|
| 356 |
+
intent=intent,
|
| 357 |
+
requested_fact_types=tuple(requested),
|
| 358 |
+
confidence=confidence,
|
| 359 |
+
notes=notes if isinstance(notes, str) else None,
|
| 360 |
+
)
|
| 361 |
+
except ValueError:
|
| 362 |
+
return None
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
DEFAULT_HOSTED_LLM_BASE_URL = "https://api.modelbest.cn/v1"
|
| 366 |
+
DEFAULT_HOSTED_LLM_MODEL = "MiniCPM5-1B"
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _extract_json_object(text: str) -> str | None:
|
| 370 |
+
"""Return the outermost JSON object substring, ignoring prose or markdown fences."""
|
| 371 |
+
|
| 372 |
+
start = text.find("{")
|
| 373 |
+
end = text.rfind("}")
|
| 374 |
+
if start == -1 or end == -1 or end < start:
|
| 375 |
+
return None
|
| 376 |
+
return text[start : end + 1]
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _post_chat_completion(
|
| 380 |
+
base_url: str,
|
| 381 |
+
api_key: str,
|
| 382 |
+
model: str,
|
| 383 |
+
prompt: str,
|
| 384 |
+
timeout: float,
|
| 385 |
+
) -> str:
|
| 386 |
+
"""POST one OpenAI-compatible chat completion and return the assistant text."""
|
| 387 |
+
|
| 388 |
+
url = base_url.rstrip("/") + "/chat/completions"
|
| 389 |
+
payload = {
|
| 390 |
+
"model": model,
|
| 391 |
+
"messages": [
|
| 392 |
+
{
|
| 393 |
+
"role": "system",
|
| 394 |
+
"content": "You output only a single JSON object. No prose, no markdown.",
|
| 395 |
+
},
|
| 396 |
+
{"role": "user", "content": prompt},
|
| 397 |
+
],
|
| 398 |
+
"temperature": 0,
|
| 399 |
+
"max_tokens": 400,
|
| 400 |
+
}
|
| 401 |
+
request = urllib.request.Request(
|
| 402 |
+
url,
|
| 403 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 404 |
+
headers={
|
| 405 |
+
"Authorization": f"Bearer {api_key}",
|
| 406 |
+
"Content-Type": "application/json",
|
| 407 |
+
},
|
| 408 |
+
method="POST",
|
| 409 |
+
)
|
| 410 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 411 |
+
body = json.loads(response.read().decode("utf-8"))
|
| 412 |
+
return body["choices"][0]["message"]["content"]
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def build_hosted_interpreter(
|
| 416 |
+
*,
|
| 417 |
+
api_key: str | None = None,
|
| 418 |
+
base_url: str | None = None,
|
| 419 |
+
model: str | None = None,
|
| 420 |
+
timeout: float = 20.0,
|
| 421 |
+
post: Callable[[str, str, str, str, float], str] | None = None,
|
| 422 |
+
) -> Callable[[str], str | None] | None:
|
| 423 |
+
"""Build a fail-closed question-interpretation generator over a hosted MiniCPM endpoint.
|
| 424 |
+
|
| 425 |
+
Returns ``None`` when no API key is configured, so callers stay on the
|
| 426 |
+
deterministic interpretation path. The returned generator never raises: any
|
| 427 |
+
transport error or unparseable output yields ``None``, which the constrained
|
| 428 |
+
validator then rejects in favor of deterministic interpretation.
|
| 429 |
+
"""
|
| 430 |
+
|
| 431 |
+
api_key = api_key if api_key is not None else os.environ.get("COASTWISE_LLM_API_KEY")
|
| 432 |
+
if not api_key:
|
| 433 |
+
return None
|
| 434 |
+
base_url = base_url or os.environ.get("COASTWISE_LLM_BASE_URL") or DEFAULT_HOSTED_LLM_BASE_URL
|
| 435 |
+
model = model or os.environ.get("COASTWISE_LLM_MODEL") or DEFAULT_HOSTED_LLM_MODEL
|
| 436 |
+
send = post or _post_chat_completion
|
| 437 |
+
|
| 438 |
+
def generate(prompt: str) -> str | None:
|
| 439 |
+
try:
|
| 440 |
+
content = send(base_url, api_key, model, prompt, timeout)
|
| 441 |
+
except Exception:
|
| 442 |
+
return None
|
| 443 |
+
if not isinstance(content, str):
|
| 444 |
+
return None
|
| 445 |
+
return _extract_json_object(content)
|
| 446 |
+
|
| 447 |
+
return generate
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _parse_prompt_allowlist(prompt: str, key: str) -> list[str] | None:
|
| 451 |
+
"""Recover an allowlist (e.g. ``allowed_species=[...]``) from the interpreter prompt."""
|
| 452 |
+
|
| 453 |
+
match = re.search(rf"{re.escape(key)}=(\[[^\n]*\])", prompt)
|
| 454 |
+
if match is None:
|
| 455 |
+
return None
|
| 456 |
+
try:
|
| 457 |
+
values = ast.literal_eval(match.group(1))
|
| 458 |
+
except (ValueError, SyntaxError):
|
| 459 |
+
return None
|
| 460 |
+
return [value for value in values if isinstance(value, str)]
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def _local_interpretation_schema(prompt: str) -> dict:
|
| 464 |
+
"""Grammar schema constraining local model output to the prompt's allowlists.
|
| 465 |
+
|
| 466 |
+
Species, location, and region are optional and nullable so a location-free
|
| 467 |
+
question is not forced to invent a place; enum constraints (when the allowlist
|
| 468 |
+
is recoverable) keep the model from hallucinating out-of-allowlist values.
|
| 469 |
+
"""
|
| 470 |
+
|
| 471 |
+
def required_enum(values: list[str] | None) -> dict:
|
| 472 |
+
if not values:
|
| 473 |
+
return {"type": "string"}
|
| 474 |
+
return {"type": "string", "enum": list(values)}
|
| 475 |
+
|
| 476 |
+
def nullable_enum(values: list[str] | None) -> dict:
|
| 477 |
+
if not values:
|
| 478 |
+
return {"type": ["string", "null"]}
|
| 479 |
+
return {"type": ["string", "null"], "enum": [*values, None]}
|
| 480 |
+
|
| 481 |
+
return {
|
| 482 |
+
"type": "object",
|
| 483 |
+
"properties": {
|
| 484 |
+
"species_or_category": required_enum(_parse_prompt_allowlist(prompt, "allowed_species")),
|
| 485 |
+
"location_name": nullable_enum(_parse_prompt_allowlist(prompt, "allowed_locations")),
|
| 486 |
+
"region_key": nullable_enum(_parse_prompt_allowlist(prompt, "allowed_region_keys")),
|
| 487 |
+
"intent": {"type": "string", "enum": [intent.value for intent in SourceBackedIntent]},
|
| 488 |
+
"requested_fact_types": {
|
| 489 |
+
"type": "array",
|
| 490 |
+
"minItems": 1,
|
| 491 |
+
"maxItems": 1,
|
| 492 |
+
"items": {
|
| 493 |
+
"type": "string",
|
| 494 |
+
"enum": [fact.value for fact in RequestedFactType if fact.value != "unknown"],
|
| 495 |
+
},
|
| 496 |
+
},
|
| 497 |
+
"confidence": {"type": "string", "enum": ["high", "medium"]},
|
| 498 |
+
},
|
| 499 |
+
"required": ["species_or_category", "intent", "requested_fact_types", "confidence"],
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def build_local_interpreter(
|
| 504 |
+
*,
|
| 505 |
+
enabled: bool | None = None,
|
| 506 |
+
model_factory: Callable[[], object] | None = None,
|
| 507 |
+
repo_id: str | None = None,
|
| 508 |
+
filename: str | None = None,
|
| 509 |
+
) -> Callable[[str], str | None] | None:
|
| 510 |
+
"""Build a keyless, in-process MiniCPM interpreter backed by a local GGUF model.
|
| 511 |
+
|
| 512 |
+
Disabled by default; set ``COASTWISE_LOCAL_MODEL=1`` (or pass ``enabled=True``)
|
| 513 |
+
to turn it on. Returns ``None`` when disabled or when ``llama-cpp-python`` is
|
| 514 |
+
unavailable, so callers fall through to the hosted or deterministic path. The
|
| 515 |
+
model loads lazily on first use and is cached; the returned generator never
|
| 516 |
+
raises.
|
| 517 |
+
"""
|
| 518 |
+
|
| 519 |
+
if enabled is None:
|
| 520 |
+
flag = os.environ.get("COASTWISE_LOCAL_MODEL", "").strip().lower()
|
| 521 |
+
enabled = flag not in ("", "0", "false", "no")
|
| 522 |
+
if not enabled:
|
| 523 |
+
return None
|
| 524 |
+
|
| 525 |
+
if model_factory is None:
|
| 526 |
+
try:
|
| 527 |
+
from llama_cpp import Llama
|
| 528 |
+
except Exception:
|
| 529 |
+
return None
|
| 530 |
+
repo = repo_id or os.environ.get("COASTWISE_LOCAL_REPO") or "openbmb/MiniCPM5-1B-GGUF"
|
| 531 |
+
file = filename or os.environ.get("COASTWISE_LOCAL_FILE") or "*Q4_K_M.gguf"
|
| 532 |
+
|
| 533 |
+
def model_factory() -> object:
|
| 534 |
+
return Llama.from_pretrained(repo_id=repo, filename=file, n_ctx=2048, verbose=False)
|
| 535 |
+
|
| 536 |
+
state: dict[str, object | None] = {"model": None}
|
| 537 |
+
|
| 538 |
+
def generate(prompt: str) -> str | None:
|
| 539 |
+
try:
|
| 540 |
+
if state["model"] is None:
|
| 541 |
+
state["model"] = model_factory()
|
| 542 |
+
response = state["model"].create_chat_completion(
|
| 543 |
+
messages=[
|
| 544 |
+
{
|
| 545 |
+
"role": "system",
|
| 546 |
+
"content": (
|
| 547 |
+
"You interpret a California ocean fishing question into one JSON "
|
| 548 |
+
"object. Use only values from the allowed lists in the question; map "
|
| 549 |
+
"misspelled or informal species and place names to the closest allowed "
|
| 550 |
+
"value. Set location_name and region_key to null only when the question "
|
| 551 |
+
"names no place at all. Never invent a value outside the allowed lists. "
|
| 552 |
+
"Output only the JSON object."
|
| 553 |
+
),
|
| 554 |
+
},
|
| 555 |
+
{"role": "user", "content": prompt},
|
| 556 |
+
],
|
| 557 |
+
temperature=0,
|
| 558 |
+
max_tokens=400,
|
| 559 |
+
response_format={"type": "json_object", "schema": _local_interpretation_schema(prompt)},
|
| 560 |
+
)
|
| 561 |
+
content = response["choices"][0]["message"]["content"]
|
| 562 |
+
except Exception:
|
| 563 |
+
return None
|
| 564 |
+
if not isinstance(content, str):
|
| 565 |
+
return None
|
| 566 |
+
return _extract_json_object(content)
|
| 567 |
+
|
| 568 |
+
return generate
|
| 569 |
+
|
| 570 |
+
|
| 571 |
def explain_safety_result(
|
| 572 |
result: SafetyResult,
|
| 573 |
inventory: ModelInventory,
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import re
|
| 6 |
|
| 7 |
from coastwise.schemas import LocationContext
|
|
@@ -17,15 +18,102 @@ LOCATION_ALIASES: dict[str, tuple[str, str, str]] = {
|
|
| 17 |
"santa barbara": ("Santa Barbara", "southern", "Southern California ocean region"),
|
| 18 |
"san diego bay": ("San Diego Bay", "southern", "Southern California ocean region"),
|
| 19 |
"san francisco bay": ("San Francisco Bay", "san_francisco_bay", "San Francisco Bay region"),
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
UNSUPPORTED_LOCATION_TERMS = ("lake", "tahoe", "river", "stream", "freshwater", "inland")
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
def _normalize(value: str) -> str:
|
| 26 |
return " ".join(re.sub(r"[^a-z0-9]+", " ", value.casefold()).split())
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def parse_location(question: str, location_text: str | None = None) -> LocationContext:
|
| 30 |
"""Parse a user-facing beach or coastal-area location."""
|
| 31 |
|
|
@@ -44,8 +132,9 @@ def parse_location(question: str, location_text: str | None = None) -> LocationC
|
|
| 44 |
region_key=None,
|
| 45 |
)
|
| 46 |
|
|
|
|
| 47 |
for alias, (display, region_key, scope) in LOCATION_ALIASES.items():
|
| 48 |
-
if alias in
|
| 49 |
raw_question_location = alias if alias in normalized_question else None
|
| 50 |
return LocationContext(
|
| 51 |
raw_question_location=raw_question_location,
|
|
@@ -57,6 +146,19 @@ def parse_location(question: str, location_text: str | None = None) -> LocationC
|
|
| 57 |
region_key=region_key,
|
| 58 |
)
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if normalized_input:
|
| 61 |
return LocationContext(
|
| 62 |
raw_question_location=None,
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import difflib
|
| 6 |
import re
|
| 7 |
|
| 8 |
from coastwise.schemas import LocationContext
|
|
|
|
| 18 |
"santa barbara": ("Santa Barbara", "southern", "Southern California ocean region"),
|
| 19 |
"san diego bay": ("San Diego Bay", "southern", "Southern California ocean region"),
|
| 20 |
"san francisco bay": ("San Francisco Bay", "san_francisco_bay", "San Francisco Bay region"),
|
| 21 |
+
"sf": ("San Francisco", "san_francisco", "San Francisco coast"),
|
| 22 |
}
|
| 23 |
|
| 24 |
UNSUPPORTED_LOCATION_TERMS = ("lake", "tahoe", "river", "stream", "freshwater", "inland")
|
| 25 |
|
| 26 |
+
REGION_LOCATION_CANDIDATES: dict[str, tuple[str, str]] = {
|
| 27 |
+
"Northern": ("northern", "Northern California ocean region"),
|
| 28 |
+
"Northern Ocean Region": ("northern", "Northern California ocean region"),
|
| 29 |
+
"Mendocino": ("mendocino", "Mendocino California ocean region"),
|
| 30 |
+
"Mendocino Ocean Region": ("mendocino", "Mendocino California ocean region"),
|
| 31 |
+
"San Francisco": ("san_francisco", "San Francisco coast"),
|
| 32 |
+
"San Francisco Ocean Region": ("san_francisco", "San Francisco coast"),
|
| 33 |
+
"Central": ("central", "Central California ocean region"),
|
| 34 |
+
"Central Ocean Region": ("central", "Central California ocean region"),
|
| 35 |
+
"Southern": ("southern", "Southern California ocean region"),
|
| 36 |
+
"Southern Ocean Region": ("southern", "Southern California ocean region"),
|
| 37 |
+
"San Francisco Bay": ("san_francisco_bay", "San Francisco Bay region"),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _location_candidate_metadata() -> dict[str, tuple[str, str]]:
|
| 42 |
+
candidates = {display: (region_key, scope) for display, region_key, scope in LOCATION_ALIASES.values()}
|
| 43 |
+
candidates.update(REGION_LOCATION_CANDIDATES)
|
| 44 |
+
return candidates
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def location_candidates() -> dict[str, str]:
|
| 48 |
+
"""Return validated location names that a model may choose from."""
|
| 49 |
+
|
| 50 |
+
candidates = {name: region_key for name, (region_key, _scope) in _location_candidate_metadata().items()}
|
| 51 |
+
return dict(sorted(candidates.items()))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def location_context_from_model(
|
| 55 |
+
question: str,
|
| 56 |
+
location_text: str | None,
|
| 57 |
+
location_name: str | None,
|
| 58 |
+
region_key: str | None,
|
| 59 |
+
) -> LocationContext:
|
| 60 |
+
"""Build location context from already validated model output."""
|
| 61 |
+
|
| 62 |
+
if not location_name or not region_key:
|
| 63 |
+
return parse_location(question, location_text)
|
| 64 |
+
candidate_metadata = _location_candidate_metadata()
|
| 65 |
+
candidate = candidate_metadata.get(location_name)
|
| 66 |
+
if candidate is None or candidate[0] != region_key:
|
| 67 |
+
return parse_location(question, location_text)
|
| 68 |
+
scope = candidate[1]
|
| 69 |
+
return LocationContext(
|
| 70 |
+
raw_question_location=location_name,
|
| 71 |
+
raw_location_input=location_text or None,
|
| 72 |
+
matched_place=location_name,
|
| 73 |
+
area_or_scope=scope,
|
| 74 |
+
confidence="recognized",
|
| 75 |
+
warning=None,
|
| 76 |
+
region_key=region_key,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
|
| 80 |
def _normalize(value: str) -> str:
|
| 81 |
return " ".join(re.sub(r"[^a-z0-9]+", " ", value.casefold()).split())
|
| 82 |
|
| 83 |
|
| 84 |
+
def _fuzzy_location_match(combined: str) -> tuple[str, str, str] | None:
|
| 85 |
+
"""Recover a known coastal place from a misspelled/garbled question via fuzzy match."""
|
| 86 |
+
|
| 87 |
+
names: dict[str, tuple[str, str, str]] = {}
|
| 88 |
+
for alias, (display, region_key, scope) in LOCATION_ALIASES.items():
|
| 89 |
+
names.setdefault(_normalize(alias), (display, region_key, scope))
|
| 90 |
+
names.setdefault(_normalize(display), (display, region_key, scope))
|
| 91 |
+
for display, (region_key, scope) in REGION_LOCATION_CANDIDATES.items():
|
| 92 |
+
names.setdefault(_normalize(display), (display, region_key, scope))
|
| 93 |
+
|
| 94 |
+
place_keys = list(names)
|
| 95 |
+
tokens = combined.split()
|
| 96 |
+
windows = {
|
| 97 |
+
" ".join(tokens[index : index + size])
|
| 98 |
+
for size in (1, 2, 3)
|
| 99 |
+
for index in range(len(tokens) - size + 1)
|
| 100 |
+
}
|
| 101 |
+
best_ratio = 0.0
|
| 102 |
+
best_key: str | None = None
|
| 103 |
+
for window in windows:
|
| 104 |
+
if len(window) < 4:
|
| 105 |
+
continue
|
| 106 |
+
match = difflib.get_close_matches(window, place_keys, n=1, cutoff=0.84)
|
| 107 |
+
if not match:
|
| 108 |
+
continue
|
| 109 |
+
ratio = difflib.SequenceMatcher(None, window, match[0]).ratio()
|
| 110 |
+
if ratio > best_ratio:
|
| 111 |
+
best_ratio, best_key = ratio, match[0]
|
| 112 |
+
if best_key is None:
|
| 113 |
+
return None
|
| 114 |
+
return names[best_key]
|
| 115 |
+
|
| 116 |
+
|
| 117 |
def parse_location(question: str, location_text: str | None = None) -> LocationContext:
|
| 118 |
"""Parse a user-facing beach or coastal-area location."""
|
| 119 |
|
|
|
|
| 132 |
region_key=None,
|
| 133 |
)
|
| 134 |
|
| 135 |
+
padded = f" {combined} "
|
| 136 |
for alias, (display, region_key, scope) in LOCATION_ALIASES.items():
|
| 137 |
+
if f" {alias} " in padded:
|
| 138 |
raw_question_location = alias if alias in normalized_question else None
|
| 139 |
return LocationContext(
|
| 140 |
raw_question_location=raw_question_location,
|
|
|
|
| 146 |
region_key=region_key,
|
| 147 |
)
|
| 148 |
|
| 149 |
+
fuzzy = _fuzzy_location_match(combined)
|
| 150 |
+
if fuzzy is not None:
|
| 151 |
+
display, region_key, scope = fuzzy
|
| 152 |
+
return LocationContext(
|
| 153 |
+
raw_question_location=None,
|
| 154 |
+
raw_location_input=location_text or None,
|
| 155 |
+
matched_place=display,
|
| 156 |
+
area_or_scope=scope,
|
| 157 |
+
confidence="recognized",
|
| 158 |
+
warning=None,
|
| 159 |
+
region_key=region_key,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
if normalized_input:
|
| 163 |
return LocationContext(
|
| 164 |
raw_question_location=None,
|
|
@@ -2,12 +2,13 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from dataclasses import replace
|
| 6 |
-
from datetime import datetime
|
| 7 |
|
| 8 |
from coastwise.advisories import source_backed_advisory_facts
|
| 9 |
-
from coastwise.ai import synthesize_cached_evidence_answer
|
| 10 |
-
from coastwise.
|
|
|
|
| 11 |
from coastwise.schemas import (
|
| 12 |
AdvisoryFact,
|
| 13 |
AnswerStatus,
|
|
@@ -15,15 +16,17 @@ from coastwise.schemas import (
|
|
| 15 |
CachedEvidenceSource,
|
| 16 |
EvidenceType,
|
| 17 |
FreshnessStatus,
|
|
|
|
| 18 |
LocationContext,
|
| 19 |
QuestionInterpretation,
|
| 20 |
RefreshStatus,
|
| 21 |
RequestedFactType,
|
|
|
|
| 22 |
SourceBackedAnswer,
|
| 23 |
SourceChunk,
|
| 24 |
StructuredRegulationFact,
|
| 25 |
)
|
| 26 |
-
from coastwise.search import normalize_query, search_source_candidates
|
| 27 |
from coastwise.source_cache import SourceCache, freshness_status
|
| 28 |
from coastwise.submission import default_model_inventory
|
| 29 |
|
|
@@ -65,6 +68,23 @@ SHELLFISH_SOURCE_TERMS = (
|
|
| 65 |
"shellfish",
|
| 66 |
)
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
SOURCE_BACKED_FORBIDDEN_COPY = (
|
| 69 |
"safe to eat",
|
| 70 |
"definitely legal",
|
|
@@ -81,13 +101,21 @@ PUBLIC_SAVED_EVIDENCE_NEXT_ACTION = (
|
|
| 81 |
)
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def _requested_fact_type(question: str) -> RequestedFactType:
|
| 85 |
normalized = normalize_query(question)
|
| 86 |
if "min" in normalized or "minimum" in normalized or "size" in normalized:
|
| 87 |
return RequestedFactType.MINIMUM_SIZE
|
| 88 |
if "bag" in normalized or "limit" in normalized:
|
| 89 |
return RequestedFactType.BAG_LIMIT
|
| 90 |
-
if any(term in normalized for term in ("season", "open", "closed", "harvest", "keep", "take")):
|
| 91 |
return RequestedFactType.SEASON
|
| 92 |
if any(term in normalized for term in ("safe", "eat", "consume", "advisory", "biotoxin", "cdph", "toxin")):
|
| 93 |
return RequestedFactType.ADVISORY
|
|
@@ -103,6 +131,70 @@ def _unsupported_scope(question: str, location_context: LocationContext) -> bool
|
|
| 103 |
return location_context.confidence == "unsupported" or any(term in normalized for term in UNSUPPORTED_SCOPE_TERMS)
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def interpret_question(
|
| 107 |
question: str,
|
| 108 |
location_text: str | None,
|
|
@@ -124,9 +216,51 @@ def interpret_question(
|
|
| 124 |
model_used=False,
|
| 125 |
)
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
candidates = search_source_candidates(question, cache)
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
return QuestionInterpretation(
|
| 131 |
question=question,
|
| 132 |
species_or_category=species_key,
|
|
@@ -138,24 +272,226 @@ def interpret_question(
|
|
| 138 |
)
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def _matching_structured_fact(
|
| 142 |
interpretation: QuestionInterpretation,
|
| 143 |
cache: SourceCache,
|
| 144 |
) -> StructuredRegulationFact | None:
|
| 145 |
-
|
|
|
|
| 146 |
return None
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
same_type = fact.fact_type is interpretation.requested_fact_type
|
| 150 |
general_summary = (
|
| 151 |
interpretation.requested_fact_type is RequestedFactType.GENERAL_SUMMARY
|
| 152 |
and fact.fact_type is RequestedFactType.GENERAL_SUMMARY
|
| 153 |
)
|
| 154 |
-
if
|
|
|
|
|
|
|
|
|
|
| 155 |
return fact
|
| 156 |
return None
|
| 157 |
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
def _matching_chunk(interpretation: QuestionInterpretation, cache: SourceCache) -> SourceChunk | None:
|
| 160 |
query_tokens = set(normalize_query(interpretation.question).split())
|
| 161 |
best: tuple[int, SourceChunk] | None = None
|
|
@@ -203,18 +539,34 @@ def build_cached_evidence_packet(
|
|
| 203 |
now: datetime,
|
| 204 |
*,
|
| 205 |
limit: int = 3,
|
|
|
|
| 206 |
) -> CachedEvidencePacket | None:
|
| 207 |
"""Build a compact evidence packet from cached source chunks."""
|
| 208 |
|
| 209 |
if not interpretation.species_or_category:
|
| 210 |
return None
|
| 211 |
|
|
|
|
| 212 |
ranked = sorted(
|
| 213 |
((_chunk_relevance_score(interpretation, chunk), chunk) for chunk in cache.chunks),
|
| 214 |
key=lambda item: item[0],
|
| 215 |
reverse=True,
|
| 216 |
)
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
if not selected:
|
| 219 |
return None
|
| 220 |
|
|
@@ -233,15 +585,18 @@ def build_cached_evidence_packet(
|
|
| 233 |
)
|
| 234 |
|
| 235 |
display = interpretation.species_or_category.replace("_", " ")
|
|
|
|
|
|
|
|
|
|
| 236 |
return CachedEvidencePacket(
|
| 237 |
question=interpretation.question,
|
| 238 |
species_or_category=interpretation.species_or_category,
|
| 239 |
display_name=display[0].upper() + display[1:],
|
| 240 |
-
requested_fact_type=
|
| 241 |
matched_place=interpretation.location_context.matched_place,
|
| 242 |
region_key=interpretation.location_context.region_key,
|
| 243 |
sources=tuple(sources),
|
| 244 |
-
deterministic_next_safe_action=
|
| 245 |
)
|
| 246 |
|
| 247 |
|
|
@@ -250,13 +605,29 @@ def _source_title(source_id: str, cache: SourceCache) -> str:
|
|
| 250 |
return source.title if source is not None else source_id
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
def _structured_direct_answer(fact: StructuredRegulationFact) -> str:
|
|
|
|
| 254 |
if fact.fact_type is RequestedFactType.MINIMUM_SIZE:
|
| 255 |
-
|
|
|
|
| 256 |
return f"{fact.display_name} minimum size is {fact.value}{units}."
|
| 257 |
if fact.fact_type is RequestedFactType.BAG_LIMIT:
|
| 258 |
-
|
|
|
|
| 259 |
return f"{fact.display_name} bag limit is {fact.value}{units}."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
return f"{fact.display_name}: {fact.value}"
|
| 261 |
|
| 262 |
|
|
@@ -464,6 +835,48 @@ def _with_safe_answer_copy(answer: SourceBackedAnswer) -> SourceBackedAnswer:
|
|
| 464 |
)
|
| 465 |
|
| 466 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
def answer_question(
|
| 468 |
question: str,
|
| 469 |
location_text: str | None,
|
|
@@ -510,10 +923,26 @@ def answer_question(
|
|
| 510 |
next_safe_action="Use official CDFW/CDPH sources for the relevant location and activity.",
|
| 511 |
)
|
| 512 |
|
|
|
|
|
|
|
| 513 |
advisory_facts = _matching_advisory_facts(interpretation, cache)
|
| 514 |
if advisory_facts:
|
| 515 |
return _advisory_answer(interpretation, advisory_facts, cache, now)
|
| 516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 517 |
fact = _matching_structured_fact(interpretation, cache)
|
| 518 |
if fact is not None:
|
| 519 |
retrieved_at, status, refresh_status = _snapshot_freshness(fact.source_id, cache, now)
|
|
@@ -536,9 +965,34 @@ def answer_question(
|
|
| 536 |
next_safe_action=next_action,
|
| 537 |
)
|
| 538 |
|
| 539 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
if packet is not None:
|
| 541 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 542 |
|
| 543 |
chunk = _matching_chunk(interpretation, cache)
|
| 544 |
if chunk is not None:
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from dataclasses import dataclass, replace
|
| 6 |
+
from datetime import datetime, timezone
|
| 7 |
|
| 8 |
from coastwise.advisories import source_backed_advisory_facts
|
| 9 |
+
from coastwise.ai import interpret_question_with_model, synthesize_cached_evidence_answer
|
| 10 |
+
from coastwise.data import all_rule_cards, get_rule_card
|
| 11 |
+
from coastwise.locations import location_candidates, location_context_from_model, parse_location
|
| 12 |
from coastwise.schemas import (
|
| 13 |
AdvisoryFact,
|
| 14 |
AnswerStatus,
|
|
|
|
| 16 |
CachedEvidenceSource,
|
| 17 |
EvidenceType,
|
| 18 |
FreshnessStatus,
|
| 19 |
+
HarvestStatus,
|
| 20 |
LocationContext,
|
| 21 |
QuestionInterpretation,
|
| 22 |
RefreshStatus,
|
| 23 |
RequestedFactType,
|
| 24 |
+
SourceBackedIntent,
|
| 25 |
SourceBackedAnswer,
|
| 26 |
SourceChunk,
|
| 27 |
StructuredRegulationFact,
|
| 28 |
)
|
| 29 |
+
from coastwise.search import normalize_query, search_source_candidates, search_species
|
| 30 |
from coastwise.source_cache import SourceCache, freshness_status
|
| 31 |
from coastwise.submission import default_model_inventory
|
| 32 |
|
|
|
|
| 68 |
"shellfish",
|
| 69 |
)
|
| 70 |
|
| 71 |
+
NON_SPECIES_MODEL_ALLOWLIST_TERMS = frozenset(
|
| 72 |
+
{
|
| 73 |
+
"general",
|
| 74 |
+
"region",
|
| 75 |
+
"regional",
|
| 76 |
+
"regulation",
|
| 77 |
+
"regulations",
|
| 78 |
+
"rule",
|
| 79 |
+
"rules",
|
| 80 |
+
"source",
|
| 81 |
+
"sources",
|
| 82 |
+
"summary",
|
| 83 |
+
"topic",
|
| 84 |
+
"topics",
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
SOURCE_BACKED_FORBIDDEN_COPY = (
|
| 89 |
"safe to eat",
|
| 90 |
"definitely legal",
|
|
|
|
| 101 |
)
|
| 102 |
|
| 103 |
|
| 104 |
+
@dataclass(frozen=True)
|
| 105 |
+
class AnswerPlan:
|
| 106 |
+
interpretation: QuestionInterpretation
|
| 107 |
+
intent: SourceBackedIntent
|
| 108 |
+
requested_fact_types: tuple[RequestedFactType, ...]
|
| 109 |
+
missing_fact_label: str
|
| 110 |
+
|
| 111 |
+
|
| 112 |
def _requested_fact_type(question: str) -> RequestedFactType:
|
| 113 |
normalized = normalize_query(question)
|
| 114 |
if "min" in normalized or "minimum" in normalized or "size" in normalized:
|
| 115 |
return RequestedFactType.MINIMUM_SIZE
|
| 116 |
if "bag" in normalized or "limit" in normalized:
|
| 117 |
return RequestedFactType.BAG_LIMIT
|
| 118 |
+
if any(term in normalized for term in ("season", "open", "closed", "harvest", "keep", "take", "catch", "allowed")):
|
| 119 |
return RequestedFactType.SEASON
|
| 120 |
if any(term in normalized for term in ("safe", "eat", "consume", "advisory", "biotoxin", "cdph", "toxin")):
|
| 121 |
return RequestedFactType.ADVISORY
|
|
|
|
| 131 |
return location_context.confidence == "unsupported" or any(term in normalized for term in UNSUPPORTED_SCOPE_TERMS)
|
| 132 |
|
| 133 |
|
| 134 |
+
def _allowed_species_from_cache(cache: SourceCache) -> set[str]:
|
| 135 |
+
species = {fact.species_or_category for fact in cache.facts}
|
| 136 |
+
species.update(fact.affected_species_or_category for fact in cache.advisory_facts)
|
| 137 |
+
for chunk in cache.chunks:
|
| 138 |
+
for term in chunk.species_or_category_terms:
|
| 139 |
+
normalized = normalize_query(term)
|
| 140 |
+
if normalized:
|
| 141 |
+
species.add(normalized.replace(" ", "_"))
|
| 142 |
+
# Offer the full curated species taxonomy so the model can resolve any
|
| 143 |
+
# supported species to its exact key instead of rounding an unknown one to a
|
| 144 |
+
# wrong neighbour (e.g. "california scorpionfish" -> california_halibut).
|
| 145 |
+
species.update(card.key for card in all_rule_cards() if card.key != "unknown")
|
| 146 |
+
return {
|
| 147 |
+
value
|
| 148 |
+
for value in species
|
| 149 |
+
if value and value not in NON_SPECIES_MODEL_ALLOWLIST_TERMS
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _primary_requested_fact_type(requested: tuple[RequestedFactType, ...]) -> RequestedFactType:
|
| 154 |
+
priority = (
|
| 155 |
+
RequestedFactType.SEASON,
|
| 156 |
+
RequestedFactType.CLOSURE,
|
| 157 |
+
RequestedFactType.ADVISORY,
|
| 158 |
+
RequestedFactType.QUARANTINE,
|
| 159 |
+
RequestedFactType.BAG_LIMIT,
|
| 160 |
+
RequestedFactType.POSSESSION_LIMIT,
|
| 161 |
+
RequestedFactType.MINIMUM_SIZE,
|
| 162 |
+
RequestedFactType.GEAR_OR_METHOD,
|
| 163 |
+
RequestedFactType.GENERAL_SUMMARY,
|
| 164 |
+
)
|
| 165 |
+
for fact_type in priority:
|
| 166 |
+
if fact_type in requested:
|
| 167 |
+
return fact_type
|
| 168 |
+
return requested[0] if requested else RequestedFactType.GENERAL_SUMMARY
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _guarded_model_species(
|
| 172 |
+
question: str, model_species: str | None, cache: SourceCache
|
| 173 |
+
) -> str | None:
|
| 174 |
+
"""Correct a small-model species pick that fights deterministic name search.
|
| 175 |
+
|
| 176 |
+
The 1B model picks from a large species enum and can (a) round a rare species
|
| 177 |
+
to a wrong in-enum species with no lexical basis ("soupfin shark" -> mussels),
|
| 178 |
+
or (b) pick a substring species over the specific one the user typed
|
| 179 |
+
("kelp bass" -> kelp plant; "great white shark" -> basking shark).
|
| 180 |
+
|
| 181 |
+
A strong, specific cached-species alias the user actually typed beats the
|
| 182 |
+
model's pick. Otherwise, if the model's pick isn't among any deterministic
|
| 183 |
+
candidate, fall back to the top candidate. When deterministic search finds
|
| 184 |
+
nothing (e.g. a typo only the model resolves), keep the model's choice.
|
| 185 |
+
"""
|
| 186 |
+
|
| 187 |
+
if not model_species:
|
| 188 |
+
return model_species
|
| 189 |
+
source = search_source_candidates(question, cache)
|
| 190 |
+
if source and source[0].score >= 0.85 and source[0].rule_card_key != model_species:
|
| 191 |
+
return source[0].rule_card_key
|
| 192 |
+
candidates = source or search_species(question)
|
| 193 |
+
if candidates and model_species not in {match.rule_card_key for match in candidates}:
|
| 194 |
+
return candidates[0].rule_card_key
|
| 195 |
+
return model_species
|
| 196 |
+
|
| 197 |
+
|
| 198 |
def interpret_question(
|
| 199 |
question: str,
|
| 200 |
location_text: str | None,
|
|
|
|
| 216 |
model_used=False,
|
| 217 |
)
|
| 218 |
|
| 219 |
+
model_interpretation = interpret_question_with_model(
|
| 220 |
+
question,
|
| 221 |
+
location_text,
|
| 222 |
+
allowed_species=_allowed_species_from_cache(cache),
|
| 223 |
+
allowed_locations=location_candidates(),
|
| 224 |
+
allowed_region_keys={source.region_key for source in cache.sources if source.region_key},
|
| 225 |
+
generator=model,
|
| 226 |
+
)
|
| 227 |
+
if model_interpretation is not None:
|
| 228 |
+
model_intent: SourceBackedIntent = model_interpretation.intent
|
| 229 |
+
model_location = location_context_from_model(
|
| 230 |
+
question,
|
| 231 |
+
location_text,
|
| 232 |
+
model_interpretation.location_name,
|
| 233 |
+
model_interpretation.region_key,
|
| 234 |
+
)
|
| 235 |
+
deterministic_fact_type = _requested_fact_type(question)
|
| 236 |
+
model_species = _guarded_model_species(
|
| 237 |
+
question, model_interpretation.species_or_category, cache
|
| 238 |
+
)
|
| 239 |
+
return QuestionInterpretation(
|
| 240 |
+
question=question,
|
| 241 |
+
species_or_category=model_species,
|
| 242 |
+
requested_fact_type=deterministic_fact_type,
|
| 243 |
+
location_context=model_location,
|
| 244 |
+
scope="california_ocean",
|
| 245 |
+
confidence=model_interpretation.confidence,
|
| 246 |
+
model_used=True,
|
| 247 |
+
model_notes=(
|
| 248 |
+
f"intent={model_intent.value}; "
|
| 249 |
+
f"requested={deterministic_fact_type.value}"
|
| 250 |
+
),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
candidates = search_source_candidates(question, cache)
|
| 254 |
+
if candidates:
|
| 255 |
+
species_key = candidates[0].rule_card_key
|
| 256 |
+
confidence = "high"
|
| 257 |
+
else:
|
| 258 |
+
# No cached source fact for the species; fall back to the full curated
|
| 259 |
+
# rule-card taxonomy so any supported species still resolves (and can
|
| 260 |
+
# answer with reviewed card guidance) instead of going unsupported.
|
| 261 |
+
card_candidates = search_species(question)
|
| 262 |
+
species_key = card_candidates[0].rule_card_key if card_candidates else None
|
| 263 |
+
confidence = "low" if species_key else "medium"
|
| 264 |
return QuestionInterpretation(
|
| 265 |
question=question,
|
| 266 |
species_or_category=species_key,
|
|
|
|
| 272 |
)
|
| 273 |
|
| 274 |
|
| 275 |
+
def _intent_from_interpretation(interpretation: QuestionInterpretation) -> SourceBackedIntent:
|
| 276 |
+
notes = interpretation.model_notes or ""
|
| 277 |
+
for token in notes.split(";"):
|
| 278 |
+
token = token.strip()
|
| 279 |
+
if token.startswith("intent="):
|
| 280 |
+
try:
|
| 281 |
+
return SourceBackedIntent(token.removeprefix("intent="))
|
| 282 |
+
except ValueError:
|
| 283 |
+
break
|
| 284 |
+
if interpretation.requested_fact_type in {RequestedFactType.SEASON, RequestedFactType.CLOSURE}:
|
| 285 |
+
return SourceBackedIntent.SEASON_CLOSURE
|
| 286 |
+
if interpretation.requested_fact_type in {RequestedFactType.ADVISORY, RequestedFactType.QUARANTINE}:
|
| 287 |
+
return SourceBackedIntent.FOOD_SAFETY_ADVISORY
|
| 288 |
+
if interpretation.requested_fact_type in {RequestedFactType.MINIMUM_SIZE, RequestedFactType.BAG_LIMIT, RequestedFactType.POSSESSION_LIMIT}:
|
| 289 |
+
return SourceBackedIntent.LIMITS
|
| 290 |
+
if interpretation.requested_fact_type is RequestedFactType.GEAR_OR_METHOD:
|
| 291 |
+
return SourceBackedIntent.GEAR_METHOD
|
| 292 |
+
return SourceBackedIntent.GENERAL
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _requested_fact_types_from_interpretation(
|
| 296 |
+
interpretation: QuestionInterpretation,
|
| 297 |
+
) -> tuple[RequestedFactType, ...]:
|
| 298 |
+
notes = interpretation.model_notes or ""
|
| 299 |
+
for token in notes.split(";"):
|
| 300 |
+
token = token.strip()
|
| 301 |
+
if token.startswith("requested="):
|
| 302 |
+
values = [value for value in token.removeprefix("requested=").split(",") if value]
|
| 303 |
+
result: list[RequestedFactType] = []
|
| 304 |
+
for value in values:
|
| 305 |
+
try:
|
| 306 |
+
result.append(RequestedFactType(value))
|
| 307 |
+
except ValueError:
|
| 308 |
+
continue
|
| 309 |
+
if result:
|
| 310 |
+
return tuple(dict.fromkeys(result))
|
| 311 |
+
if interpretation.requested_fact_type is RequestedFactType.SEASON:
|
| 312 |
+
return (RequestedFactType.SEASON, RequestedFactType.CLOSURE)
|
| 313 |
+
return (interpretation.requested_fact_type,)
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _missing_fact_label(interpretation: QuestionInterpretation, requested: tuple[RequestedFactType, ...]) -> str:
|
| 317 |
+
display = (interpretation.species_or_category or "requested topic").replace("_", " ")
|
| 318 |
+
place = interpretation.location_context.matched_place or interpretation.location_context.area_or_scope
|
| 319 |
+
if RequestedFactType.SEASON in requested or RequestedFactType.CLOSURE in requested:
|
| 320 |
+
return f"{display} open/closed season or closure status for {place}"
|
| 321 |
+
if RequestedFactType.GEAR_OR_METHOD in requested:
|
| 322 |
+
return f"{display} gear or method notes for {place}"
|
| 323 |
+
if RequestedFactType.ADVISORY in requested or RequestedFactType.QUARANTINE in requested:
|
| 324 |
+
return f"{display} CDPH advisory or quarantine status for {place}"
|
| 325 |
+
return f"{display} {requested[0].value.replace('_', ' ')} for {place}"
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _build_answer_plan(interpretation: QuestionInterpretation) -> AnswerPlan:
|
| 329 |
+
requested = _requested_fact_types_from_interpretation(interpretation)
|
| 330 |
+
return AnswerPlan(
|
| 331 |
+
interpretation=interpretation,
|
| 332 |
+
intent=_intent_from_interpretation(interpretation),
|
| 333 |
+
requested_fact_types=requested,
|
| 334 |
+
missing_fact_label=_missing_fact_label(interpretation, requested),
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
def _matching_structured_fact(
|
| 339 |
interpretation: QuestionInterpretation,
|
| 340 |
cache: SourceCache,
|
| 341 |
) -> StructuredRegulationFact | None:
|
| 342 |
+
species = interpretation.species_or_category
|
| 343 |
+
if not species:
|
| 344 |
return None
|
| 345 |
+
region_key = interpretation.location_context.region_key
|
| 346 |
+
species_facts = [fact for fact in cache.facts if fact.species_or_category == species]
|
| 347 |
+
# When real facts exist, never let a generic "verify" general_summary
|
| 348 |
+
# placeholder answer a question — those flow to the species-fact bundle.
|
| 349 |
+
has_real_facts = any(
|
| 350 |
+
fact.fact_type is not RequestedFactType.GENERAL_SUMMARY for fact in species_facts
|
| 351 |
+
)
|
| 352 |
+
for fact in species_facts:
|
| 353 |
same_type = fact.fact_type is interpretation.requested_fact_type
|
| 354 |
general_summary = (
|
| 355 |
interpretation.requested_fact_type is RequestedFactType.GENERAL_SUMMARY
|
| 356 |
and fact.fact_type is RequestedFactType.GENERAL_SUMMARY
|
| 357 |
)
|
| 358 |
+
if general_summary and has_real_facts:
|
| 359 |
+
continue
|
| 360 |
+
region_ok = not fact.region_key or not region_key or fact.region_key == region_key
|
| 361 |
+
if region_ok and (same_type or general_summary):
|
| 362 |
return fact
|
| 363 |
return None
|
| 364 |
|
| 365 |
|
| 366 |
+
PERMISSION_QUESTION_TERMS = ("legal", "catch", "keep", "harvest", "take", "allowed", "can i", "may i")
|
| 367 |
+
|
| 368 |
+
PERMISSION_FACT_TYPES = (
|
| 369 |
+
RequestedFactType.SEASON,
|
| 370 |
+
RequestedFactType.CLOSURE,
|
| 371 |
+
RequestedFactType.MINIMUM_SIZE,
|
| 372 |
+
RequestedFactType.BAG_LIMIT,
|
| 373 |
+
RequestedFactType.POSSESSION_LIMIT,
|
| 374 |
+
RequestedFactType.GEAR_OR_METHOD,
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _is_permission_question(question: str) -> bool:
|
| 379 |
+
normalized = normalize_query(question)
|
| 380 |
+
return any(term in normalized for term in PERMISSION_QUESTION_TERMS)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _actionable_species_facts(
|
| 384 |
+
interpretation: QuestionInterpretation,
|
| 385 |
+
cache: SourceCache,
|
| 386 |
+
) -> list[StructuredRegulationFact]:
|
| 387 |
+
species = interpretation.species_or_category
|
| 388 |
+
if not species:
|
| 389 |
+
return []
|
| 390 |
+
region_key = interpretation.location_context.region_key
|
| 391 |
+
return [
|
| 392 |
+
fact
|
| 393 |
+
for fact in cache.facts
|
| 394 |
+
if fact.species_or_category == species
|
| 395 |
+
and fact.fact_type in PERMISSION_FACT_TYPES
|
| 396 |
+
and (not fact.region_key or not region_key or fact.region_key == region_key)
|
| 397 |
+
]
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
BUNDLE_FALLBACK_FACT_TYPES = (
|
| 401 |
+
RequestedFactType.MINIMUM_SIZE,
|
| 402 |
+
RequestedFactType.BAG_LIMIT,
|
| 403 |
+
RequestedFactType.POSSESSION_LIMIT,
|
| 404 |
+
RequestedFactType.SEASON,
|
| 405 |
+
RequestedFactType.CLOSURE,
|
| 406 |
+
RequestedFactType.GEAR_OR_METHOD,
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def _species_fact_bundle(
|
| 411 |
+
interpretation: QuestionInterpretation,
|
| 412 |
+
cache: SourceCache,
|
| 413 |
+
) -> list[StructuredRegulationFact]:
|
| 414 |
+
"""All non-advisory structured facts for the species, region-filtered.
|
| 415 |
+
|
| 416 |
+
Lets a recognized species with real facts answer a general or
|
| 417 |
+
otherwise-unmatched question instead of dropping to a snippet partial.
|
| 418 |
+
"""
|
| 419 |
+
|
| 420 |
+
species = interpretation.species_or_category
|
| 421 |
+
if not species:
|
| 422 |
+
return []
|
| 423 |
+
region_key = interpretation.location_context.region_key
|
| 424 |
+
return [
|
| 425 |
+
fact
|
| 426 |
+
for fact in cache.facts
|
| 427 |
+
if fact.species_or_category == species
|
| 428 |
+
and fact.fact_type in BUNDLE_FALLBACK_FACT_TYPES
|
| 429 |
+
and (not fact.region_key or not region_key or fact.region_key == region_key)
|
| 430 |
+
]
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _matching_structured_facts_for_plan(
|
| 434 |
+
plan: AnswerPlan,
|
| 435 |
+
cache: SourceCache,
|
| 436 |
+
) -> list[StructuredRegulationFact]:
|
| 437 |
+
species = plan.interpretation.species_or_category
|
| 438 |
+
if not species:
|
| 439 |
+
return []
|
| 440 |
+
region_key = plan.interpretation.location_context.region_key
|
| 441 |
+
matches: list[StructuredRegulationFact] = []
|
| 442 |
+
for fact in cache.facts:
|
| 443 |
+
if fact.species_or_category != species:
|
| 444 |
+
continue
|
| 445 |
+
if fact.fact_type not in plan.requested_fact_types:
|
| 446 |
+
continue
|
| 447 |
+
if fact.region_key and region_key and fact.region_key != region_key:
|
| 448 |
+
continue
|
| 449 |
+
matches.append(fact)
|
| 450 |
+
return matches
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def _structured_fact_bundle_answer(
|
| 454 |
+
facts: list[StructuredRegulationFact],
|
| 455 |
+
cache: SourceCache,
|
| 456 |
+
now: datetime,
|
| 457 |
+
interpretation: QuestionInterpretation,
|
| 458 |
+
*,
|
| 459 |
+
permission: bool = False,
|
| 460 |
+
) -> SourceBackedAnswer:
|
| 461 |
+
primary = facts[0]
|
| 462 |
+
retrieved_at, status, refresh_status = _snapshot_freshness(primary.source_id, cache, now)
|
| 463 |
+
# Lead with the regulation facts directly; the source is shown in the
|
| 464 |
+
# evidence section, so an inline "Cached ... evidence indicates" preamble is
|
| 465 |
+
# redundant clutter.
|
| 466 |
+
direct = " ".join(_structured_direct_answer(fact) for fact in facts)
|
| 467 |
+
next_action = "Verify current CDFW/CDPH guidance before harvest or consumption."
|
| 468 |
+
if status is FreshnessStatus.STALE:
|
| 469 |
+
next_action = "Source cache is stale; verify official CDFW/CDPH guidance before harvest or consumption."
|
| 470 |
+
notes: tuple[str, ...] = ()
|
| 471 |
+
if permission:
|
| 472 |
+
present = {fact.fact_type for fact in facts}
|
| 473 |
+
if RequestedFactType.SEASON not in present and RequestedFactType.CLOSURE not in present:
|
| 474 |
+
notes = (
|
| 475 |
+
"Cached structured facts do not include open/closed season or closure status; "
|
| 476 |
+
"CoastWise cannot confirm legality, so verify current CDFW openers and closures.",
|
| 477 |
+
)
|
| 478 |
+
return SourceBackedAnswer(
|
| 479 |
+
status=AnswerStatus.ANSWERED,
|
| 480 |
+
direct_answer=direct,
|
| 481 |
+
evidence_type=EvidenceType.STRUCTURED_FACT,
|
| 482 |
+
species_or_category=primary.display_name,
|
| 483 |
+
location_context=interpretation.location_context,
|
| 484 |
+
source_links=tuple(dict.fromkeys(fact.source_url for fact in facts)),
|
| 485 |
+
source_titles=tuple(dict.fromkeys(_source_title(fact.source_id, cache) for fact in facts)),
|
| 486 |
+
retrieved_at=retrieved_at or primary.retrieved_at,
|
| 487 |
+
freshness_status=status,
|
| 488 |
+
refresh_status=refresh_status,
|
| 489 |
+
supporting_snippets=tuple(dict.fromkeys(fact.supporting_snippet for fact in facts)),
|
| 490 |
+
uncertainty_notes=notes,
|
| 491 |
+
next_safe_action=next_action,
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
def _matching_chunk(interpretation: QuestionInterpretation, cache: SourceCache) -> SourceChunk | None:
|
| 496 |
query_tokens = set(normalize_query(interpretation.question).split())
|
| 497 |
best: tuple[int, SourceChunk] | None = None
|
|
|
|
| 539 |
now: datetime,
|
| 540 |
*,
|
| 541 |
limit: int = 3,
|
| 542 |
+
plan: AnswerPlan | None = None,
|
| 543 |
) -> CachedEvidencePacket | None:
|
| 544 |
"""Build a compact evidence packet from cached source chunks."""
|
| 545 |
|
| 546 |
if not interpretation.species_or_category:
|
| 547 |
return None
|
| 548 |
|
| 549 |
+
requested = plan.requested_fact_types if plan is not None else (interpretation.requested_fact_type,)
|
| 550 |
ranked = sorted(
|
| 551 |
((_chunk_relevance_score(interpretation, chunk), chunk) for chunk in cache.chunks),
|
| 552 |
key=lambda item: item[0],
|
| 553 |
reverse=True,
|
| 554 |
)
|
| 555 |
+
species_tokens = set(normalize_query(interpretation.species_or_category.replace("_", " ")).split())
|
| 556 |
+
selected = [
|
| 557 |
+
chunk
|
| 558 |
+
for score, chunk in ranked
|
| 559 |
+
if score >= 2
|
| 560 |
+
and (
|
| 561 |
+
normalize_query(interpretation.species_or_category.replace("_", " "))
|
| 562 |
+
in {normalize_query(term) for term in chunk.species_or_category_terms}
|
| 563 |
+
or bool(species_tokens & set(chunk.tokens))
|
| 564 |
+
or (
|
| 565 |
+
bool(species_tokens & set(SHELLFISH_SOURCE_TERMS))
|
| 566 |
+
and bool(set(chunk.tokens) & set(SHELLFISH_SOURCE_TERMS))
|
| 567 |
+
)
|
| 568 |
+
)
|
| 569 |
+
][:limit]
|
| 570 |
if not selected:
|
| 571 |
return None
|
| 572 |
|
|
|
|
| 585 |
)
|
| 586 |
|
| 587 |
display = interpretation.species_or_category.replace("_", " ")
|
| 588 |
+
next_action = PUBLIC_SAVED_EVIDENCE_NEXT_ACTION
|
| 589 |
+
if RequestedFactType.ADVISORY in requested or RequestedFactType.QUARANTINE in requested:
|
| 590 |
+
next_action = "Use saved official CDPH details as decision support; do not treat CoastWise as proof of food safety."
|
| 591 |
return CachedEvidencePacket(
|
| 592 |
question=interpretation.question,
|
| 593 |
species_or_category=interpretation.species_or_category,
|
| 594 |
display_name=display[0].upper() + display[1:],
|
| 595 |
+
requested_fact_type=_primary_requested_fact_type(requested),
|
| 596 |
matched_place=interpretation.location_context.matched_place,
|
| 597 |
region_key=interpretation.location_context.region_key,
|
| 598 |
sources=tuple(sources),
|
| 599 |
+
deterministic_next_safe_action=next_action,
|
| 600 |
)
|
| 601 |
|
| 602 |
|
|
|
|
| 605 |
return source.title if source is not None else source_id
|
| 606 |
|
| 607 |
|
| 608 |
+
def _is_no_limit_value(value: str) -> bool:
|
| 609 |
+
normalized = value.strip().casefold()
|
| 610 |
+
return (
|
| 611 |
+
normalized in {"none", "no limit", "unlimited"}
|
| 612 |
+
or normalized.startswith("no minimum")
|
| 613 |
+
or normalized.startswith("no daily")
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
def _structured_direct_answer(fact: StructuredRegulationFact) -> str:
|
| 618 |
+
units = f" {fact.units}" if fact.units else ""
|
| 619 |
if fact.fact_type is RequestedFactType.MINIMUM_SIZE:
|
| 620 |
+
if _is_no_limit_value(fact.value):
|
| 621 |
+
return f"{fact.display_name} has no minimum size limit."
|
| 622 |
return f"{fact.display_name} minimum size is {fact.value}{units}."
|
| 623 |
if fact.fact_type is RequestedFactType.BAG_LIMIT:
|
| 624 |
+
if _is_no_limit_value(fact.value):
|
| 625 |
+
return f"{fact.display_name} has no daily bag limit{units}."
|
| 626 |
return f"{fact.display_name} bag limit is {fact.value}{units}."
|
| 627 |
+
if fact.fact_type is RequestedFactType.POSSESSION_LIMIT:
|
| 628 |
+
return f"{fact.display_name} possession limit is {fact.value}{units}."
|
| 629 |
+
if fact.fact_type is RequestedFactType.SEASON:
|
| 630 |
+
return f"{fact.display_name} season is {fact.value}"
|
| 631 |
return f"{fact.display_name}: {fact.value}"
|
| 632 |
|
| 633 |
|
|
|
|
| 835 |
)
|
| 836 |
|
| 837 |
|
| 838 |
+
def _rule_card_fallback_answer(
|
| 839 |
+
interpretation: QuestionInterpretation,
|
| 840 |
+
now: datetime,
|
| 841 |
+
) -> SourceBackedAnswer | None:
|
| 842 |
+
"""Reviewed rule-card posture for a recognized species with no extracted fact.
|
| 843 |
+
|
| 844 |
+
Keeps every supported species answerable with its correct harvest posture
|
| 845 |
+
(e.g. "rules apply; verify") instead of an unsupported reply or a wrong
|
| 846 |
+
species, without asserting an exact regulation value.
|
| 847 |
+
"""
|
| 848 |
+
|
| 849 |
+
species_key = interpretation.species_or_category
|
| 850 |
+
if not species_key:
|
| 851 |
+
return None
|
| 852 |
+
card = get_rule_card(species_key)
|
| 853 |
+
if card.key == "unknown" or not card.source_urls:
|
| 854 |
+
return None
|
| 855 |
+
notes = " ".join(card.rule_notes)
|
| 856 |
+
direct = card.short_answer if not notes else f"{card.short_answer} {notes}"
|
| 857 |
+
cache_dt = datetime(
|
| 858 |
+
card.cache_date.year, card.cache_date.month, card.cache_date.day, tzinfo=timezone.utc
|
| 859 |
+
)
|
| 860 |
+
return SourceBackedAnswer(
|
| 861 |
+
status=AnswerStatus.PARTIAL,
|
| 862 |
+
direct_answer=direct,
|
| 863 |
+
evidence_type=EvidenceType.SOURCE_SNIPPET,
|
| 864 |
+
species_or_category=card.display_name,
|
| 865 |
+
location_context=interpretation.location_context,
|
| 866 |
+
source_links=tuple(card.source_urls),
|
| 867 |
+
source_titles=tuple(card.source_names),
|
| 868 |
+
retrieved_at=cache_dt,
|
| 869 |
+
freshness_status=FreshnessStatus.CURRENT,
|
| 870 |
+
refresh_status=None,
|
| 871 |
+
supporting_snippets=(direct,),
|
| 872 |
+
uncertainty_notes=(
|
| 873 |
+
"This is curated CoastWise rule-card guidance, not an extracted official "
|
| 874 |
+
"value; verify the exact current CDFW/CDPH rules for this species.",
|
| 875 |
+
),
|
| 876 |
+
next_safe_action="Verify current official CDFW/CDPH rules for this species before harvest or consumption.",
|
| 877 |
+
)
|
| 878 |
+
|
| 879 |
+
|
| 880 |
def answer_question(
|
| 881 |
question: str,
|
| 882 |
location_text: str | None,
|
|
|
|
| 923 |
next_safe_action="Use official CDFW/CDPH sources for the relevant location and activity.",
|
| 924 |
)
|
| 925 |
|
| 926 |
+
plan = _build_answer_plan(interpretation)
|
| 927 |
+
|
| 928 |
advisory_facts = _matching_advisory_facts(interpretation, cache)
|
| 929 |
if advisory_facts:
|
| 930 |
return _advisory_answer(interpretation, advisory_facts, cache, now)
|
| 931 |
|
| 932 |
+
if _is_permission_question(interpretation.question) and interpretation.species_or_category:
|
| 933 |
+
permission_facts = _actionable_species_facts(interpretation, cache)
|
| 934 |
+
if permission_facts:
|
| 935 |
+
return _structured_fact_bundle_answer(
|
| 936 |
+
permission_facts, cache, now, interpretation, permission=True
|
| 937 |
+
)
|
| 938 |
+
structured_facts = _matching_structured_facts_for_plan(plan, cache)
|
| 939 |
+
if structured_facts and plan.intent in {
|
| 940 |
+
SourceBackedIntent.PERMISSION_STATUS,
|
| 941 |
+
SourceBackedIntent.SEASON_CLOSURE,
|
| 942 |
+
SourceBackedIntent.REGIONAL_RULES,
|
| 943 |
+
}:
|
| 944 |
+
return _structured_fact_bundle_answer(structured_facts, cache, now, interpretation)
|
| 945 |
+
|
| 946 |
fact = _matching_structured_fact(interpretation, cache)
|
| 947 |
if fact is not None:
|
| 948 |
retrieved_at, status, refresh_status = _snapshot_freshness(fact.source_id, cache, now)
|
|
|
|
| 965 |
next_safe_action=next_action,
|
| 966 |
)
|
| 967 |
|
| 968 |
+
# General "rules / restrictions / tell me about X" questions surface the full
|
| 969 |
+
# set of real facts. A specific fact-type miss (e.g. season for an area we
|
| 970 |
+
# have no season fact for) is left to the honest partial below, not answered
|
| 971 |
+
# with unrelated facts.
|
| 972 |
+
if (
|
| 973 |
+
interpretation.requested_fact_type is RequestedFactType.GENERAL_SUMMARY
|
| 974 |
+
and not _question_needs_advisory_evidence(interpretation)
|
| 975 |
+
):
|
| 976 |
+
bundle = _species_fact_bundle(interpretation, cache)
|
| 977 |
+
if bundle:
|
| 978 |
+
return _structured_fact_bundle_answer(bundle, cache, now, interpretation)
|
| 979 |
+
|
| 980 |
+
packet = build_cached_evidence_packet(interpretation, cache, now, plan=plan)
|
| 981 |
if packet is not None:
|
| 982 |
+
packet_answer = _cached_evidence_answer(packet, interpretation, cache, now, model=model)
|
| 983 |
+
if packet_answer.status is AnswerStatus.PARTIAL:
|
| 984 |
+
packet_answer = replace(
|
| 985 |
+
packet_answer,
|
| 986 |
+
direct_answer=(
|
| 987 |
+
f"CoastWise found relevant cached official evidence, but the cache "
|
| 988 |
+
f"does not include an extracted {plan.missing_fact_label}."
|
| 989 |
+
),
|
| 990 |
+
)
|
| 991 |
+
return packet_answer
|
| 992 |
+
|
| 993 |
+
card_answer = _rule_card_fallback_answer(interpretation, now)
|
| 994 |
+
if card_answer is not None:
|
| 995 |
+
return card_answer
|
| 996 |
|
| 997 |
chunk = _matching_chunk(interpretation, cache)
|
| 998 |
if chunk is not None:
|
|
@@ -40,6 +40,7 @@ class ModelRole(StrEnum):
|
|
| 40 |
PHOTO_CANDIDATE = "photo_candidate"
|
| 41 |
EMBEDDING_OR_RANKER = "embedding_or_ranker"
|
| 42 |
DEMO_OR_SUBMISSION_SUPPORT = "demo_or_submission_support"
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
class RuntimeMode(StrEnum):
|
|
@@ -105,6 +106,16 @@ class RequestedFactType(StrEnum):
|
|
| 105 |
UNKNOWN = "unknown"
|
| 106 |
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
class AnswerStatus(StrEnum):
|
| 109 |
ANSWERED = "answered"
|
| 110 |
PARTIAL = "partial"
|
|
@@ -725,6 +736,27 @@ class QuestionInterpretation:
|
|
| 725 |
raise ValueError("question confidence is not supported")
|
| 726 |
|
| 727 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
@dataclass(frozen=True)
|
| 729 |
class SourceBackedAnswer:
|
| 730 |
status: AnswerStatus | str
|
|
|
|
| 40 |
PHOTO_CANDIDATE = "photo_candidate"
|
| 41 |
EMBEDDING_OR_RANKER = "embedding_or_ranker"
|
| 42 |
DEMO_OR_SUBMISSION_SUPPORT = "demo_or_submission_support"
|
| 43 |
+
QUESTION_INTERPRETATION = "question_interpretation"
|
| 44 |
|
| 45 |
|
| 46 |
class RuntimeMode(StrEnum):
|
|
|
|
| 106 |
UNKNOWN = "unknown"
|
| 107 |
|
| 108 |
|
| 109 |
+
class SourceBackedIntent(StrEnum):
|
| 110 |
+
PERMISSION_STATUS = "permission_status"
|
| 111 |
+
LIMITS = "limits"
|
| 112 |
+
SEASON_CLOSURE = "season_closure"
|
| 113 |
+
GEAR_METHOD = "gear_method"
|
| 114 |
+
FOOD_SAFETY_ADVISORY = "food_safety_advisory"
|
| 115 |
+
REGIONAL_RULES = "regional_rules"
|
| 116 |
+
GENERAL = "general"
|
| 117 |
+
|
| 118 |
+
|
| 119 |
class AnswerStatus(StrEnum):
|
| 120 |
ANSWERED = "answered"
|
| 121 |
PARTIAL = "partial"
|
|
|
|
| 736 |
raise ValueError("question confidence is not supported")
|
| 737 |
|
| 738 |
|
| 739 |
+
@dataclass(frozen=True)
|
| 740 |
+
class ModelQuestionInterpretation:
|
| 741 |
+
species_or_category: str | None
|
| 742 |
+
location_name: str | None
|
| 743 |
+
region_key: str | None
|
| 744 |
+
intent: SourceBackedIntent | str
|
| 745 |
+
requested_fact_types: tuple[RequestedFactType | str, ...]
|
| 746 |
+
confidence: str
|
| 747 |
+
notes: str | None = None
|
| 748 |
+
|
| 749 |
+
def __post_init__(self) -> None:
|
| 750 |
+
object.__setattr__(self, "intent", _coerce_enum(SourceBackedIntent, self.intent))
|
| 751 |
+
object.__setattr__(
|
| 752 |
+
self,
|
| 753 |
+
"requested_fact_types",
|
| 754 |
+
tuple(_coerce_enum(RequestedFactType, value) for value in self.requested_fact_types),
|
| 755 |
+
)
|
| 756 |
+
if self.confidence not in {"high", "medium", "low", "ambiguous"}:
|
| 757 |
+
raise ValueError("model interpretation confidence is not supported")
|
| 758 |
+
|
| 759 |
+
|
| 760 |
@dataclass(frozen=True)
|
| 761 |
class SourceBackedAnswer:
|
| 762 |
status: AnswerStatus | str
|
|
@@ -176,8 +176,16 @@ def search_source_candidates(query: str, cache: SourceCache, *, limit: int = 6)
|
|
| 176 |
|
| 177 |
matches: list[SpeciesMatch] = []
|
| 178 |
for alias, key in index.items():
|
| 179 |
-
if alias in normalized
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
deduped: dict[str, SpeciesMatch] = {}
|
| 183 |
for match in sorted(matches, key=lambda item: item.score, reverse=True):
|
|
|
|
| 176 |
|
| 177 |
matches: list[SpeciesMatch] = []
|
| 178 |
for alias, key in index.items():
|
| 179 |
+
if alias in normalized:
|
| 180 |
+
# The user typed this alias: a longer/more specific alias wins, so
|
| 181 |
+
# "kelp bass" beats "kelp" and "rock crab" beats "crab".
|
| 182 |
+
score = 0.7 + min(0.25, 0.02 * len(alias))
|
| 183 |
+
elif normalized in alias:
|
| 184 |
+
# The query is only a fragment of the alias: weaker signal.
|
| 185 |
+
score = 0.5
|
| 186 |
+
else:
|
| 187 |
+
continue
|
| 188 |
+
matches.append(SpeciesMatch(key, _source_display_name(key), score, "source_partial"))
|
| 189 |
|
| 190 |
deduped: dict[str, SpeciesMatch] = {}
|
| 191 |
for match in sorted(matches, key=lambda item: item.score, reverse=True):
|
|
@@ -141,6 +141,92 @@ def default_structured_validation_set() -> StructuredValidationSet:
|
|
| 141 |
)
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
def extract_structured_facts(
|
| 145 |
chunks: tuple[SourceChunk, ...] | list[SourceChunk],
|
| 146 |
validation_set: StructuredValidationSet,
|
|
@@ -211,6 +297,7 @@ def extract_structured_facts(
|
|
| 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"):
|
|
|
|
| 141 |
)
|
| 142 |
|
| 143 |
|
| 144 |
+
def _region_key_from_text(lowered: str) -> str | None:
|
| 145 |
+
if "mendocino" in lowered:
|
| 146 |
+
return "mendocino"
|
| 147 |
+
if "san francisco bay" in lowered:
|
| 148 |
+
return "san_francisco_bay"
|
| 149 |
+
if "san francisco" in lowered or "pacifica" in lowered or "half moon bay" in lowered:
|
| 150 |
+
return "san_francisco"
|
| 151 |
+
if "monterey" in lowered or "central" in lowered:
|
| 152 |
+
return "central"
|
| 153 |
+
if "northern" in lowered:
|
| 154 |
+
return "northern"
|
| 155 |
+
if "southern" in lowered:
|
| 156 |
+
return "southern"
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _append_explicit_dungeness_facts(
|
| 161 |
+
chunk: SourceChunk, facts: list[StructuredRegulationFact]
|
| 162 |
+
) -> None:
|
| 163 |
+
lowered = chunk.text.casefold()
|
| 164 |
+
if "dungeness crab" not in lowered:
|
| 165 |
+
return
|
| 166 |
+
region_key = _region_key_from_text(lowered)
|
| 167 |
+
if "season status is closed" in lowered or "season is closed" in lowered:
|
| 168 |
+
facts.append(
|
| 169 |
+
StructuredRegulationFact(
|
| 170 |
+
id=f"{chunk.id}:dungeness_crab:season",
|
| 171 |
+
species_or_category="dungeness_crab",
|
| 172 |
+
display_name="Dungeness crab",
|
| 173 |
+
aliases=("dungeness crabs",),
|
| 174 |
+
area_or_scope=chunk.heading or "California recreational crab fishery",
|
| 175 |
+
fact_type=RequestedFactType.SEASON,
|
| 176 |
+
value="season status is closed in the cached official source text",
|
| 177 |
+
units=None,
|
| 178 |
+
source_id=chunk.source_id,
|
| 179 |
+
source_url=chunk.source_url,
|
| 180 |
+
retrieved_at=chunk.retrieved_at,
|
| 181 |
+
supporting_chunk_id=chunk.id,
|
| 182 |
+
supporting_snippet=chunk.text,
|
| 183 |
+
region_key=region_key,
|
| 184 |
+
validation_set=False,
|
| 185 |
+
)
|
| 186 |
+
)
|
| 187 |
+
elif "season status is open" in lowered or "season is open" in lowered:
|
| 188 |
+
facts.append(
|
| 189 |
+
StructuredRegulationFact(
|
| 190 |
+
id=f"{chunk.id}:dungeness_crab:season",
|
| 191 |
+
species_or_category="dungeness_crab",
|
| 192 |
+
display_name="Dungeness crab",
|
| 193 |
+
aliases=("dungeness crabs",),
|
| 194 |
+
area_or_scope=chunk.heading or "California recreational crab fishery",
|
| 195 |
+
fact_type=RequestedFactType.SEASON,
|
| 196 |
+
value="season status is open in the cached official source text",
|
| 197 |
+
units=None,
|
| 198 |
+
source_id=chunk.source_id,
|
| 199 |
+
source_url=chunk.source_url,
|
| 200 |
+
retrieved_at=chunk.retrieved_at,
|
| 201 |
+
supporting_chunk_id=chunk.id,
|
| 202 |
+
supporting_snippet=chunk.text,
|
| 203 |
+
region_key=region_key,
|
| 204 |
+
validation_set=False,
|
| 205 |
+
)
|
| 206 |
+
)
|
| 207 |
+
if "trap" in lowered or "hoop net" in lowered or "hoop-net" in lowered:
|
| 208 |
+
facts.append(
|
| 209 |
+
StructuredRegulationFact(
|
| 210 |
+
id=f"{chunk.id}:dungeness_crab:gear",
|
| 211 |
+
species_or_category="dungeness_crab",
|
| 212 |
+
display_name="Dungeness crab",
|
| 213 |
+
aliases=("dungeness crabs",),
|
| 214 |
+
area_or_scope=chunk.heading or "California recreational crab fishery",
|
| 215 |
+
fact_type=RequestedFactType.GEAR_OR_METHOD,
|
| 216 |
+
value="gear or method rules are referenced in the cached official source text",
|
| 217 |
+
units=None,
|
| 218 |
+
source_id=chunk.source_id,
|
| 219 |
+
source_url=chunk.source_url,
|
| 220 |
+
retrieved_at=chunk.retrieved_at,
|
| 221 |
+
supporting_chunk_id=chunk.id,
|
| 222 |
+
supporting_snippet=chunk.text,
|
| 223 |
+
region_key=region_key,
|
| 224 |
+
gear_or_method_notes="Dungeness crab trap and hoop net rules are referenced.",
|
| 225 |
+
validation_set=False,
|
| 226 |
+
)
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
def extract_structured_facts(
|
| 231 |
chunks: tuple[SourceChunk, ...] | list[SourceChunk],
|
| 232 |
validation_set: StructuredValidationSet,
|
|
|
|
| 297 |
validation_set=True,
|
| 298 |
)
|
| 299 |
)
|
| 300 |
+
_append_explicit_dungeness_facts(chunk, facts)
|
| 301 |
|
| 302 |
fact_ids = {fact.id for fact in facts}
|
| 303 |
for required_id in ("lingcod_min_size", "lingcod_bag_limit", "dungeness_crab_bag_limit"):
|
|
@@ -24,8 +24,8 @@ def default_model_inventory() -> ModelInventory:
|
|
| 24 |
provider="OpenBMB",
|
| 25 |
source_url="https://huggingface.co/openbmb/MiniCPM5-1B",
|
| 26 |
total_parameters_b=1.0,
|
| 27 |
-
roles=(ModelRole.
|
| 28 |
-
runtime_mode=RuntimeMode.
|
| 29 |
required_for_core_safety=False,
|
| 30 |
award_relevance=(AwardTag.OPENBMB, AwardTag.TINY_TITAN),
|
| 31 |
),
|
|
|
|
| 24 |
provider="OpenBMB",
|
| 25 |
source_url="https://huggingface.co/openbmb/MiniCPM5-1B",
|
| 26 |
total_parameters_b=1.0,
|
| 27 |
+
roles=(ModelRole.QUESTION_INTERPRETATION, ModelRole.SAFETY_EXPLANATION),
|
| 28 |
+
runtime_mode=RuntimeMode.HOSTED_ENDPOINT,
|
| 29 |
required_for_core_safety=False,
|
| 30 |
award_relevance=(AwardTag.OPENBMB, AwardTag.TINY_TITAN),
|
| 31 |
),
|
|
@@ -84,14 +84,14 @@
|
|
| 84 |
"location_terms": []
|
| 85 |
},
|
| 86 |
{
|
| 87 |
-
"id": "
|
| 88 |
"source_id": "cdfw_groundfish_summary",
|
| 89 |
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 90 |
"source_title": "CDFW Groundfish Summary",
|
| 91 |
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 92 |
-
"heading": "Cabezon
|
| 93 |
-
"text": "
|
| 94 |
-
"tokens": ["
|
| 95 |
"species_or_category_terms": ["cabezon"],
|
| 96 |
"location_terms": []
|
| 97 |
},
|
|
|
|
| 84 |
"location_terms": []
|
| 85 |
},
|
| 86 |
{
|
| 87 |
+
"id": "chunk_cabezon_groundfish",
|
| 88 |
"source_id": "cdfw_groundfish_summary",
|
| 89 |
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 90 |
"source_title": "CDFW Groundfish Summary",
|
| 91 |
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 92 |
+
"heading": "Cabezon Daily Bag and Minimum Size Limits",
|
| 93 |
+
"text": "Cabezon, section 28.28, has no minimum size limit. The daily bag limit is 10 fish in combination per person as part of the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate. Open season and depth constraints vary by management area and change in-season; verify current CDFW groundfish openers and depth limits before fishing.",
|
| 94 |
+
"tokens": ["cabezon", "groundfish", "no", "minimum", "size", "limit", "daily", "bag", "10", "rcg", "complex", "rockfish", "greenling", "section"],
|
| 95 |
"species_or_category_terms": ["cabezon"],
|
| 96 |
"location_terms": []
|
| 97 |
},
|
|
@@ -4,7 +4,9 @@
|
|
| 4 |
"id": "lingcod_min_size",
|
| 5 |
"species_or_category": "lingcod",
|
| 6 |
"display_name": "Lingcod",
|
| 7 |
-
"aliases": [
|
|
|
|
|
|
|
| 8 |
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 9 |
"fact_type": "minimum_size",
|
| 10 |
"value": "22",
|
|
@@ -20,7 +22,9 @@
|
|
| 20 |
"id": "lingcod_bag_limit",
|
| 21 |
"species_or_category": "lingcod",
|
| 22 |
"display_name": "Lingcod",
|
| 23 |
-
"aliases": [
|
|
|
|
|
|
|
| 24 |
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 25 |
"fact_type": "bag_limit",
|
| 26 |
"value": "2",
|
|
@@ -32,11 +36,52 @@
|
|
| 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": [
|
|
|
|
|
|
|
|
|
|
| 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.",
|
|
@@ -46,6 +91,1908 @@
|
|
| 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": [
|
|
|
|
| 4 |
"id": "lingcod_min_size",
|
| 5 |
"species_or_category": "lingcod",
|
| 6 |
"display_name": "Lingcod",
|
| 7 |
+
"aliases": [
|
| 8 |
+
"ling cod"
|
| 9 |
+
],
|
| 10 |
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 11 |
"fact_type": "minimum_size",
|
| 12 |
"value": "22",
|
|
|
|
| 22 |
"id": "lingcod_bag_limit",
|
| 23 |
"species_or_category": "lingcod",
|
| 24 |
"display_name": "Lingcod",
|
| 25 |
+
"aliases": [
|
| 26 |
+
"ling cod"
|
| 27 |
+
],
|
| 28 |
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 29 |
"fact_type": "bag_limit",
|
| 30 |
"value": "2",
|
|
|
|
| 36 |
"supporting_snippet": "Lingcod, section 28.27, daily bag limit is 2 fish per person. Minimum size limit is 22 inches total length.",
|
| 37 |
"validation_set": true
|
| 38 |
},
|
| 39 |
+
{
|
| 40 |
+
"id": "cabezon_min_size",
|
| 41 |
+
"species_or_category": "cabezon",
|
| 42 |
+
"display_name": "Cabezon",
|
| 43 |
+
"aliases": [
|
| 44 |
+
"cabazon",
|
| 45 |
+
"cabezone"
|
| 46 |
+
],
|
| 47 |
+
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 48 |
+
"fact_type": "minimum_size",
|
| 49 |
+
"value": "no minimum size limit",
|
| 50 |
+
"units": null,
|
| 51 |
+
"source_id": "cdfw_groundfish_summary",
|
| 52 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 53 |
+
"retrieved_at": "2026-06-14T12:00:00+00:00",
|
| 54 |
+
"supporting_chunk_id": "chunk_cabezon_groundfish",
|
| 55 |
+
"supporting_snippet": "Cabezon, section 28.28, has no minimum size limit. The daily bag limit is 10 fish in combination per person as part of the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate.",
|
| 56 |
+
"validation_set": true
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"id": "cabezon_bag_limit",
|
| 60 |
+
"species_or_category": "cabezon",
|
| 61 |
+
"display_name": "Cabezon",
|
| 62 |
+
"aliases": [
|
| 63 |
+
"cabazon",
|
| 64 |
+
"cabezone"
|
| 65 |
+
],
|
| 66 |
+
"area_or_scope": "California ocean statewide groundfish regulations",
|
| 67 |
+
"fact_type": "bag_limit",
|
| 68 |
+
"value": "10",
|
| 69 |
+
"units": "fish per person in the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate",
|
| 70 |
+
"source_id": "cdfw_groundfish_summary",
|
| 71 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 72 |
+
"retrieved_at": "2026-06-14T12:00:00+00:00",
|
| 73 |
+
"supporting_chunk_id": "chunk_cabezon_groundfish",
|
| 74 |
+
"supporting_snippet": "Cabezon, section 28.28, has no minimum size limit. The daily bag limit is 10 fish in combination per person as part of the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate.",
|
| 75 |
+
"validation_set": true
|
| 76 |
+
},
|
| 77 |
{
|
| 78 |
"id": "dungeness_crab_bag_limit",
|
| 79 |
"species_or_category": "dungeness_crab",
|
| 80 |
"display_name": "Dungeness crab",
|
| 81 |
+
"aliases": [
|
| 82 |
+
"dungeness crabs",
|
| 83 |
+
"crab"
|
| 84 |
+
],
|
| 85 |
"area_or_scope": "California recreational crab fishery",
|
| 86 |
"fact_type": "general_summary",
|
| 87 |
"value": "Verify current CDFW Dungeness crab season, trap/hoop-net rules, public health closures, and crab rules before harvest.",
|
|
|
|
| 91 |
"supporting_chunk_id": "chunk_dungeness_crab",
|
| 92 |
"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.",
|
| 93 |
"validation_set": true
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"id": "rockfish_min_size",
|
| 97 |
+
"species_or_category": "rockfish",
|
| 98 |
+
"display_name": "Rockfish",
|
| 99 |
+
"aliases": [
|
| 100 |
+
"rcg complex",
|
| 101 |
+
"rock fish"
|
| 102 |
+
],
|
| 103 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 104 |
+
"fact_type": "minimum_size",
|
| 105 |
+
"value": "no minimum size limit",
|
| 106 |
+
"units": null,
|
| 107 |
+
"source_id": "cdfw_groundfish_summary",
|
| 108 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 109 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 110 |
+
"supporting_chunk_id": "",
|
| 111 |
+
"supporting_snippet": "Rockfishes (RCG Complex): No minimum size limit.",
|
| 112 |
+
"validation_set": true
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"id": "rockfish_bag_limit",
|
| 116 |
+
"species_or_category": "rockfish",
|
| 117 |
+
"display_name": "Rockfish",
|
| 118 |
+
"aliases": [],
|
| 119 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 120 |
+
"fact_type": "bag_limit",
|
| 121 |
+
"value": "10",
|
| 122 |
+
"units": "fish per person in the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate, with sub-limits: copper rockfish 1; vermilion/sunset rockfish 4 in the Northern Management Area and 2 elsewhere; canary rockfish 2",
|
| 123 |
+
"source_id": "cdfw_groundfish_summary",
|
| 124 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 125 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 126 |
+
"supporting_chunk_id": "",
|
| 127 |
+
"supporting_snippet": "10 fish in combination per person, except: Copper rockfish: 1 fish per person; Vermilion/sunset rockfish: 4 (Northern) / 2 (other areas); Canary rockfish: 2 fish per person.",
|
| 128 |
+
"validation_set": true
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"id": "greenlings_min_size",
|
| 132 |
+
"species_or_category": "greenlings",
|
| 133 |
+
"display_name": "Kelp and rock greenling",
|
| 134 |
+
"aliases": [
|
| 135 |
+
"greenling",
|
| 136 |
+
"kelp greenling",
|
| 137 |
+
"rock greenling"
|
| 138 |
+
],
|
| 139 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 140 |
+
"fact_type": "minimum_size",
|
| 141 |
+
"value": "no minimum size limit",
|
| 142 |
+
"units": null,
|
| 143 |
+
"source_id": "cdfw_groundfish_summary",
|
| 144 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 145 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 146 |
+
"supporting_chunk_id": "",
|
| 147 |
+
"supporting_snippet": "Greenlings (RCG Complex): No minimum size limit.",
|
| 148 |
+
"validation_set": true
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"id": "greenlings_bag_limit",
|
| 152 |
+
"species_or_category": "greenlings",
|
| 153 |
+
"display_name": "Kelp and rock greenling",
|
| 154 |
+
"aliases": [
|
| 155 |
+
"greenling"
|
| 156 |
+
],
|
| 157 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 158 |
+
"fact_type": "bag_limit",
|
| 159 |
+
"value": "10",
|
| 160 |
+
"units": "fish per person in the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate",
|
| 161 |
+
"source_id": "cdfw_groundfish_summary",
|
| 162 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 163 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 164 |
+
"supporting_chunk_id": "",
|
| 165 |
+
"supporting_snippet": "Greenlings are part of the RCG Complex aggregate of 10 fish in combination per person.",
|
| 166 |
+
"validation_set": true
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"id": "cabezon_season_sf",
|
| 170 |
+
"species_or_category": "cabezon",
|
| 171 |
+
"display_name": "Cabezon",
|
| 172 |
+
"aliases": [],
|
| 173 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 174 |
+
"fact_type": "season",
|
| 175 |
+
"value": "open from April 1 through December 31 at all depths, and closed January 1 through March 31. These dates apply to the Northern, Mendocino, San Francisco, and Central management areas (the Southern area differs). Shore-based anglers and spear divers are exempt from seasons and depth limits. Verify current in-season depth changes.",
|
| 176 |
+
"units": null,
|
| 177 |
+
"source_id": "cdfw_groundfish_summary",
|
| 178 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 179 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 180 |
+
"supporting_chunk_id": "",
|
| 181 |
+
"supporting_snippet": "Jan 1 - Mar 31: Closed - unlawful to possess in all waters; Apr 1 - Dec 31: Open all depths. Shore-based anglers and spear divers are exempt from seasons and depths.",
|
| 182 |
+
"validation_set": true,
|
| 183 |
+
"region_key": "san_francisco",
|
| 184 |
+
"effective_start": "2026-04-01",
|
| 185 |
+
"effective_end": "2026-12-31"
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"id": "lingcod_season_sf",
|
| 189 |
+
"species_or_category": "lingcod",
|
| 190 |
+
"display_name": "Lingcod",
|
| 191 |
+
"aliases": [
|
| 192 |
+
"ling cod"
|
| 193 |
+
],
|
| 194 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 195 |
+
"fact_type": "season",
|
| 196 |
+
"value": "open from April 1 through December 31 at all depths, and closed January 1 through March 31. These dates apply to the Northern, Mendocino, San Francisco, and Central management areas (the Southern area differs). Shore-based anglers and spear divers are exempt from seasons and depth limits. Verify current in-season depth changes.",
|
| 197 |
+
"units": null,
|
| 198 |
+
"source_id": "cdfw_groundfish_summary",
|
| 199 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 200 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 201 |
+
"supporting_chunk_id": "",
|
| 202 |
+
"supporting_snippet": "Jan 1 - Mar 31: Closed - unlawful to possess in all waters; Apr 1 - Dec 31: Open all depths. Shore-based anglers and spear divers are exempt from seasons and depths.",
|
| 203 |
+
"validation_set": true,
|
| 204 |
+
"region_key": "san_francisco",
|
| 205 |
+
"effective_start": "2026-04-01",
|
| 206 |
+
"effective_end": "2026-12-31"
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"id": "rockfish_season_sf",
|
| 210 |
+
"species_or_category": "rockfish",
|
| 211 |
+
"display_name": "Rockfish",
|
| 212 |
+
"aliases": [],
|
| 213 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 214 |
+
"fact_type": "season",
|
| 215 |
+
"value": "open from April 1 through December 31 at all depths, and closed January 1 through March 31. These dates apply to the Northern, Mendocino, San Francisco, and Central management areas (the Southern area differs). Shore-based anglers and spear divers are exempt from seasons and depth limits. Verify current in-season depth changes.",
|
| 216 |
+
"units": null,
|
| 217 |
+
"source_id": "cdfw_groundfish_summary",
|
| 218 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 219 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 220 |
+
"supporting_chunk_id": "",
|
| 221 |
+
"supporting_snippet": "Jan 1 - Mar 31: Closed - unlawful to possess in all waters; Apr 1 - Dec 31: Open all depths. Shore-based anglers and spear divers are exempt from seasons and depths.",
|
| 222 |
+
"validation_set": true,
|
| 223 |
+
"region_key": "san_francisco",
|
| 224 |
+
"effective_start": "2026-04-01",
|
| 225 |
+
"effective_end": "2026-12-31"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"id": "greenlings_season_sf",
|
| 229 |
+
"species_or_category": "greenlings",
|
| 230 |
+
"display_name": "Kelp and rock greenling",
|
| 231 |
+
"aliases": [],
|
| 232 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 233 |
+
"fact_type": "season",
|
| 234 |
+
"value": "open from April 1 through December 31 at all depths, and closed January 1 through March 31. These dates apply to the Northern, Mendocino, San Francisco, and Central management areas (the Southern area differs). Shore-based anglers and spear divers are exempt from seasons and depth limits. Verify current in-season depth changes.",
|
| 235 |
+
"units": null,
|
| 236 |
+
"source_id": "cdfw_groundfish_summary",
|
| 237 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 238 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 239 |
+
"supporting_chunk_id": "",
|
| 240 |
+
"supporting_snippet": "Jan 1 - Mar 31: Closed - unlawful to possess in all waters; Apr 1 - Dec 31: Open all depths. Shore-based anglers and spear divers are exempt from seasons and depths.",
|
| 241 |
+
"validation_set": true,
|
| 242 |
+
"region_key": "san_francisco",
|
| 243 |
+
"effective_start": "2026-04-01",
|
| 244 |
+
"effective_end": "2026-12-31"
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"id": "yelloweye_rockfish_no_retention",
|
| 248 |
+
"species_or_category": "yelloweye_rockfish",
|
| 249 |
+
"display_name": "Yelloweye rockfish",
|
| 250 |
+
"aliases": [],
|
| 251 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 252 |
+
"fact_type": "closure",
|
| 253 |
+
"value": "No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 254 |
+
"units": null,
|
| 255 |
+
"source_id": "cdfw_groundfish_summary",
|
| 256 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 257 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 258 |
+
"supporting_chunk_id": "",
|
| 259 |
+
"supporting_snippet": "Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed in California - no retention at any time, zero fish per person.",
|
| 260 |
+
"validation_set": true
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"id": "quillback_rockfish_no_retention",
|
| 264 |
+
"species_or_category": "quillback_rockfish",
|
| 265 |
+
"display_name": "Quillback rockfish",
|
| 266 |
+
"aliases": [],
|
| 267 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 268 |
+
"fact_type": "closure",
|
| 269 |
+
"value": "No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 270 |
+
"units": null,
|
| 271 |
+
"source_id": "cdfw_groundfish_summary",
|
| 272 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 273 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 274 |
+
"supporting_chunk_id": "",
|
| 275 |
+
"supporting_snippet": "Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person.",
|
| 276 |
+
"validation_set": true
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"id": "cowcod_no_retention",
|
| 280 |
+
"species_or_category": "cowcod",
|
| 281 |
+
"display_name": "Cowcod",
|
| 282 |
+
"aliases": [],
|
| 283 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 284 |
+
"fact_type": "closure",
|
| 285 |
+
"value": "No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 286 |
+
"units": null,
|
| 287 |
+
"source_id": "cdfw_groundfish_summary",
|
| 288 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 289 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 290 |
+
"supporting_chunk_id": "",
|
| 291 |
+
"supporting_snippet": "Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person.",
|
| 292 |
+
"validation_set": true
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"id": "bronzespotted_rockfish_no_retention",
|
| 296 |
+
"species_or_category": "bronzespotted_rockfish",
|
| 297 |
+
"display_name": "Bronzespotted rockfish",
|
| 298 |
+
"aliases": [],
|
| 299 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 300 |
+
"fact_type": "closure",
|
| 301 |
+
"value": "No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 302 |
+
"units": null,
|
| 303 |
+
"source_id": "cdfw_groundfish_summary",
|
| 304 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary",
|
| 305 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 306 |
+
"supporting_chunk_id": "",
|
| 307 |
+
"supporting_snippet": "Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person.",
|
| 308 |
+
"validation_set": true
|
| 309 |
+
},
|
| 310 |
+
{
|
| 311 |
+
"id": "flatfish_bag_limit",
|
| 312 |
+
"species_or_category": "flatfish",
|
| 313 |
+
"display_name": "Pacific sanddab and other flatfish",
|
| 314 |
+
"aliases": [
|
| 315 |
+
"sanddab",
|
| 316 |
+
"petrale sole",
|
| 317 |
+
"starry flounder"
|
| 318 |
+
],
|
| 319 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 320 |
+
"fact_type": "bag_limit",
|
| 321 |
+
"value": "no daily bag limit",
|
| 322 |
+
"units": "(sanddabs, petrale sole, and starry flounder)",
|
| 323 |
+
"source_id": "cdfw_ocean_regulations",
|
| 324 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 325 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 326 |
+
"supporting_chunk_id": "",
|
| 327 |
+
"supporting_snippet": "There is no limit on the following species: ... sanddabs ... petrale sole and starry flounder. (CCR T14 27.60(b))",
|
| 328 |
+
"validation_set": true
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"id": "jacksmelt_topsmelt_bag_limit",
|
| 332 |
+
"species_or_category": "jacksmelt_topsmelt",
|
| 333 |
+
"display_name": "Jacksmelt and topsmelt",
|
| 334 |
+
"aliases": [],
|
| 335 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 336 |
+
"fact_type": "bag_limit",
|
| 337 |
+
"value": "no daily bag limit",
|
| 338 |
+
"units": "(jacksmelt and topsmelt)",
|
| 339 |
+
"source_id": "cdfw_ocean_regulations",
|
| 340 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 341 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 342 |
+
"supporting_chunk_id": "",
|
| 343 |
+
"supporting_snippet": "There is no limit on the following species: ... jacksmelt, topsmelt ... (CCR T14 27.60(b))",
|
| 344 |
+
"validation_set": true
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"id": "california_halibut_min_size",
|
| 348 |
+
"species_or_category": "california_halibut",
|
| 349 |
+
"display_name": "California halibut",
|
| 350 |
+
"aliases": [
|
| 351 |
+
"halibut",
|
| 352 |
+
"ca halibut"
|
| 353 |
+
],
|
| 354 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 355 |
+
"fact_type": "minimum_size",
|
| 356 |
+
"value": "22",
|
| 357 |
+
"units": "inches total length",
|
| 358 |
+
"source_id": "cdfw_region_san_francisco",
|
| 359 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 360 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 361 |
+
"supporting_chunk_id": "",
|
| 362 |
+
"supporting_snippet": "California halibut ... 22 inches total length.",
|
| 363 |
+
"validation_set": true
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"id": "california_halibut_bag_limit",
|
| 367 |
+
"species_or_category": "california_halibut",
|
| 368 |
+
"display_name": "California halibut",
|
| 369 |
+
"aliases": [],
|
| 370 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 371 |
+
"fact_type": "bag_limit",
|
| 372 |
+
"value": "2",
|
| 373 |
+
"units": "fish (north of Point Sur, which includes the San Francisco area; the limit differs south of Point Sur)",
|
| 374 |
+
"source_id": "cdfw_region_san_francisco",
|
| 375 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 376 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 377 |
+
"supporting_chunk_id": "",
|
| 378 |
+
"supporting_snippet": "California halibut ... two fish north of Point Sur ... remains open year-round.",
|
| 379 |
+
"validation_set": true
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"id": "pacific_halibut_min_size",
|
| 383 |
+
"species_or_category": "pacific_halibut",
|
| 384 |
+
"display_name": "Pacific halibut",
|
| 385 |
+
"aliases": [],
|
| 386 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 387 |
+
"fact_type": "minimum_size",
|
| 388 |
+
"value": "no minimum size limit",
|
| 389 |
+
"units": null,
|
| 390 |
+
"source_id": "cdfw_region_san_francisco",
|
| 391 |
+
"source_url": "https://wildlife.ca.gov/Conservation/Marine/Pacific-Halibut",
|
| 392 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 393 |
+
"supporting_chunk_id": "",
|
| 394 |
+
"supporting_snippet": "There is no minimum size limit. The daily bag and possession limit for Pacific halibut is one fish.",
|
| 395 |
+
"validation_set": true
|
| 396 |
+
},
|
| 397 |
+
{
|
| 398 |
+
"id": "pacific_halibut_bag_limit",
|
| 399 |
+
"species_or_category": "pacific_halibut",
|
| 400 |
+
"display_name": "Pacific halibut",
|
| 401 |
+
"aliases": [],
|
| 402 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 403 |
+
"fact_type": "bag_limit",
|
| 404 |
+
"value": "1",
|
| 405 |
+
"units": "fish per day",
|
| 406 |
+
"source_id": "cdfw_region_san_francisco",
|
| 407 |
+
"source_url": "https://wildlife.ca.gov/Conservation/Marine/Pacific-Halibut",
|
| 408 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 409 |
+
"supporting_chunk_id": "",
|
| 410 |
+
"supporting_snippet": "The daily bag and possession limit for Pacific halibut is one fish.",
|
| 411 |
+
"validation_set": true
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"id": "pacific_halibut_season",
|
| 415 |
+
"species_or_category": "pacific_halibut",
|
| 416 |
+
"display_name": "Pacific halibut",
|
| 417 |
+
"aliases": [],
|
| 418 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 419 |
+
"fact_type": "season",
|
| 420 |
+
"value": "open from April 1 for the 2026 season; it stays open until November 15 north of Point Arena and through December 31 south of Point Arena, or until the quota is reached. Verify current status.",
|
| 421 |
+
"units": null,
|
| 422 |
+
"source_id": "cdfw_region_san_francisco",
|
| 423 |
+
"source_url": "https://wildlife.ca.gov/Conservation/Marine/Pacific-Halibut",
|
| 424 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 425 |
+
"supporting_chunk_id": "",
|
| 426 |
+
"supporting_snippet": "The 2026 recreational Pacific halibut fishery opens April 1. North of Point Arena the fishery is open until November 15; south of Point Arena it is open through December 31.",
|
| 427 |
+
"validation_set": true,
|
| 428 |
+
"effective_start": "2026-04-01"
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"id": "white_seabass_min_size",
|
| 432 |
+
"species_or_category": "white_seabass",
|
| 433 |
+
"display_name": "White seabass",
|
| 434 |
+
"aliases": [
|
| 435 |
+
"seabass",
|
| 436 |
+
"white sea bass"
|
| 437 |
+
],
|
| 438 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 439 |
+
"fact_type": "minimum_size",
|
| 440 |
+
"value": "28",
|
| 441 |
+
"units": "inches total length (or 20 inches alternate length)",
|
| 442 |
+
"source_id": "cdfw_region_san_francisco",
|
| 443 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 444 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 445 |
+
"supporting_chunk_id": "",
|
| 446 |
+
"supporting_snippet": "White Seabass ... 28 inches total length or 20 inches alternate length.",
|
| 447 |
+
"validation_set": true
|
| 448 |
+
},
|
| 449 |
+
{
|
| 450 |
+
"id": "white_seabass_bag_limit",
|
| 451 |
+
"species_or_category": "white_seabass",
|
| 452 |
+
"display_name": "White seabass",
|
| 453 |
+
"aliases": [],
|
| 454 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 455 |
+
"fact_type": "bag_limit",
|
| 456 |
+
"value": "3",
|
| 457 |
+
"units": "fish (only one fish may be taken south of Point Conception between March 15 and June 15)",
|
| 458 |
+
"source_id": "cdfw_region_san_francisco",
|
| 459 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 460 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 461 |
+
"supporting_chunk_id": "",
|
| 462 |
+
"supporting_snippet": "White Seabass ... three fish except that only one fish may be taken in waters south of Point Conception between March 15 and June 15.",
|
| 463 |
+
"validation_set": true
|
| 464 |
+
},
|
| 465 |
+
{
|
| 466 |
+
"id": "surfperch_min_size",
|
| 467 |
+
"species_or_category": "surfperch",
|
| 468 |
+
"display_name": "Surfperch",
|
| 469 |
+
"aliases": [
|
| 470 |
+
"surf perch",
|
| 471 |
+
"redtail surfperch"
|
| 472 |
+
],
|
| 473 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 474 |
+
"fact_type": "minimum_size",
|
| 475 |
+
"value": "10½",
|
| 476 |
+
"units": "inches total length for redtail surfperch (most other surfperch have no minimum size limit)",
|
| 477 |
+
"source_id": "cdfw_region_san_francisco",
|
| 478 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 479 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 480 |
+
"supporting_chunk_id": "",
|
| 481 |
+
"supporting_snippet": "Redtail Surfperch ... Minimum Size: 10½ inches total length.",
|
| 482 |
+
"validation_set": true
|
| 483 |
+
},
|
| 484 |
+
{
|
| 485 |
+
"id": "surfperch_bag_limit",
|
| 486 |
+
"species_or_category": "surfperch",
|
| 487 |
+
"display_name": "Surfperch",
|
| 488 |
+
"aliases": [],
|
| 489 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 490 |
+
"fact_type": "bag_limit",
|
| 491 |
+
"value": "20",
|
| 492 |
+
"units": "fish in combination of all species, with no more than 10 of any one species; shiner surfperch have a separate limit of 20",
|
| 493 |
+
"source_id": "cdfw_region_san_francisco",
|
| 494 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 495 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 496 |
+
"supporting_chunk_id": "",
|
| 497 |
+
"supporting_snippet": "20 fish in combination of all species (except shiner perch), with not more than 10 fish of any one species ... Shiner Perch: separate bag and possession limit of 20 fish.",
|
| 498 |
+
"validation_set": true
|
| 499 |
+
},
|
| 500 |
+
{
|
| 501 |
+
"id": "leopard_shark_min_size",
|
| 502 |
+
"species_or_category": "leopard_shark",
|
| 503 |
+
"display_name": "Leopard shark",
|
| 504 |
+
"aliases": [],
|
| 505 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 506 |
+
"fact_type": "minimum_size",
|
| 507 |
+
"value": "36",
|
| 508 |
+
"units": "inches total length",
|
| 509 |
+
"source_id": "cdfw_region_san_francisco",
|
| 510 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 511 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 512 |
+
"supporting_chunk_id": "",
|
| 513 |
+
"supporting_snippet": "Leopard Shark ... 36 inches total length ... open year-round, at all depths.",
|
| 514 |
+
"validation_set": true
|
| 515 |
+
},
|
| 516 |
+
{
|
| 517 |
+
"id": "leopard_shark_bag_limit",
|
| 518 |
+
"species_or_category": "leopard_shark",
|
| 519 |
+
"display_name": "Leopard shark",
|
| 520 |
+
"aliases": [],
|
| 521 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 522 |
+
"fact_type": "bag_limit",
|
| 523 |
+
"value": "3",
|
| 524 |
+
"units": "fish",
|
| 525 |
+
"source_id": "cdfw_region_san_francisco",
|
| 526 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 527 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 528 |
+
"supporting_chunk_id": "",
|
| 529 |
+
"supporting_snippet": "Leopard Shark ... 3 fish ... open year-round, at all depths.",
|
| 530 |
+
"validation_set": true
|
| 531 |
+
},
|
| 532 |
+
{
|
| 533 |
+
"id": "state_managed_sharks_bag_limit",
|
| 534 |
+
"species_or_category": "state_managed_sharks",
|
| 535 |
+
"display_name": "State-managed sharks",
|
| 536 |
+
"aliases": [
|
| 537 |
+
"sharks",
|
| 538 |
+
"sevengill shark",
|
| 539 |
+
"mako shark"
|
| 540 |
+
],
|
| 541 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 542 |
+
"fact_type": "bag_limit",
|
| 543 |
+
"value": "2",
|
| 544 |
+
"units": "fish per day for shortfin mako, common thresher, and blue shark (no size limit); sixgill and broadnose sevengill shark are limited to 1 fish per day",
|
| 545 |
+
"source_id": "cdfw_region_san_francisco",
|
| 546 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 547 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 548 |
+
"supporting_chunk_id": "",
|
| 549 |
+
"supporting_snippet": "The bag limits for shortfin mako shark, common thresher shark, and blue shark allow take of two fish per day with no size limit. The bag limits for sixgill shark and broadnose sevengill shark allow take of one fish per day with no size limit.",
|
| 550 |
+
"validation_set": true
|
| 551 |
+
},
|
| 552 |
+
{
|
| 553 |
+
"id": "white_sturgeon_retention",
|
| 554 |
+
"species_or_category": "white_sturgeon",
|
| 555 |
+
"display_name": "White sturgeon",
|
| 556 |
+
"aliases": [],
|
| 557 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 558 |
+
"fact_type": "closure",
|
| 559 |
+
"value": "Catch-and-release only; no retention permitted (zero fish). White sturgeon greater than 60 inches fork length may not be removed from the water.",
|
| 560 |
+
"units": null,
|
| 561 |
+
"source_id": "cdfw_region_san_francisco",
|
| 562 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 563 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 564 |
+
"supporting_chunk_id": "",
|
| 565 |
+
"supporting_snippet": "The White Sturgeon sport fishery is catch-and-release only. No harvest is permitted. White sturgeon greater than 60 inches fork length may not be removed from the water.",
|
| 566 |
+
"validation_set": true
|
| 567 |
+
},
|
| 568 |
+
{
|
| 569 |
+
"id": "green_sturgeon_retention",
|
| 570 |
+
"species_or_category": "green_sturgeon",
|
| 571 |
+
"display_name": "Green sturgeon",
|
| 572 |
+
"aliases": [],
|
| 573 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 574 |
+
"fact_type": "closure",
|
| 575 |
+
"value": "Fully protected; no take. Green sturgeon must be released immediately and may not be removed from the water.",
|
| 576 |
+
"units": null,
|
| 577 |
+
"source_id": "cdfw_region_san_francisco",
|
| 578 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 579 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 580 |
+
"supporting_chunk_id": "",
|
| 581 |
+
"supporting_snippet": "Green Sturgeon ... may not be removed from the water and must be released immediately.",
|
| 582 |
+
"validation_set": true
|
| 583 |
+
},
|
| 584 |
+
{
|
| 585 |
+
"id": "pacific_herring_bag_limit",
|
| 586 |
+
"species_or_category": "pacific_herring",
|
| 587 |
+
"display_name": "Pacific herring",
|
| 588 |
+
"aliases": [
|
| 589 |
+
"herring"
|
| 590 |
+
],
|
| 591 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 592 |
+
"fact_type": "bag_limit",
|
| 593 |
+
"value": "10",
|
| 594 |
+
"units": "gallons per day (approximately 100 lb or 520 fish)",
|
| 595 |
+
"source_id": "cdfw_region_san_francisco",
|
| 596 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 597 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 598 |
+
"supporting_chunk_id": "",
|
| 599 |
+
"supporting_snippet": "Open year-round. Ten gallons of Pacific herring may be taken per day (approximately 100 lb. or 520 fish).",
|
| 600 |
+
"validation_set": true
|
| 601 |
+
},
|
| 602 |
+
{
|
| 603 |
+
"id": "tunas_season",
|
| 604 |
+
"species_or_category": "tunas",
|
| 605 |
+
"display_name": "Tunas",
|
| 606 |
+
"aliases": [
|
| 607 |
+
"tuna",
|
| 608 |
+
"albacore"
|
| 609 |
+
],
|
| 610 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 611 |
+
"fact_type": "season",
|
| 612 |
+
"value": "open year-round. Bag, possession, and size limits apply - verify current CDFW ocean sport fishing regulations.",
|
| 613 |
+
"units": null,
|
| 614 |
+
"source_id": "cdfw_region_san_francisco",
|
| 615 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 616 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 617 |
+
"supporting_chunk_id": "",
|
| 618 |
+
"supporting_snippet": "The recreational fishery for tunas is open year-round.",
|
| 619 |
+
"validation_set": true
|
| 620 |
+
},
|
| 621 |
+
{
|
| 622 |
+
"id": "rock_crab_bag_limit",
|
| 623 |
+
"species_or_category": "rock_crab",
|
| 624 |
+
"display_name": "Rock crab",
|
| 625 |
+
"aliases": [
|
| 626 |
+
"red rock crab",
|
| 627 |
+
"yellow rock crab",
|
| 628 |
+
"brown rock crab"
|
| 629 |
+
],
|
| 630 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 631 |
+
"fact_type": "bag_limit",
|
| 632 |
+
"value": "35",
|
| 633 |
+
"units": "crab per day",
|
| 634 |
+
"source_id": "cdfw_crabs",
|
| 635 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 636 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 637 |
+
"supporting_chunk_id": "",
|
| 638 |
+
"supporting_snippet": "The recreational fishery for all rock crab species is open year-round, statewide. The daily bag limit is 35 crab.",
|
| 639 |
+
"validation_set": true
|
| 640 |
+
},
|
| 641 |
+
{
|
| 642 |
+
"id": "rock_crab_min_size",
|
| 643 |
+
"species_or_category": "rock_crab",
|
| 644 |
+
"display_name": "Rock crab",
|
| 645 |
+
"aliases": [],
|
| 646 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 647 |
+
"fact_type": "minimum_size",
|
| 648 |
+
"value": "4",
|
| 649 |
+
"units": "inches",
|
| 650 |
+
"source_id": "cdfw_crabs",
|
| 651 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 652 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 653 |
+
"supporting_chunk_id": "",
|
| 654 |
+
"supporting_snippet": "For all rock crab species ... the minimum size limit is 4 inches.",
|
| 655 |
+
"validation_set": true
|
| 656 |
+
},
|
| 657 |
+
{
|
| 658 |
+
"id": "rock_crab_season",
|
| 659 |
+
"species_or_category": "rock_crab",
|
| 660 |
+
"display_name": "Rock crab",
|
| 661 |
+
"aliases": [],
|
| 662 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 663 |
+
"fact_type": "season",
|
| 664 |
+
"value": "open year-round, statewide.",
|
| 665 |
+
"units": null,
|
| 666 |
+
"source_id": "cdfw_crabs",
|
| 667 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 668 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 669 |
+
"supporting_chunk_id": "",
|
| 670 |
+
"supporting_snippet": "The recreational fishery for all rock crab species is open year-round, statewide.",
|
| 671 |
+
"validation_set": true
|
| 672 |
+
},
|
| 673 |
+
{
|
| 674 |
+
"id": "dungeness_crab_season_sf",
|
| 675 |
+
"species_or_category": "dungeness_crab",
|
| 676 |
+
"display_name": "Dungeness crab",
|
| 677 |
+
"aliases": [
|
| 678 |
+
"dungeness crabs"
|
| 679 |
+
],
|
| 680 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 681 |
+
"fact_type": "season",
|
| 682 |
+
"value": "open from November 1, 2025 through June 30, 2026 in waters south of the Sonoma/Mendocino county line, which includes the San Francisco area. North of that line the season is open from November 1, 2025 through July 30, 2026. The 2025-26 opener was delayed by domoic-acid and whale-entanglement closures; verify current CDFW openers and health advisories before harvest.",
|
| 683 |
+
"units": null,
|
| 684 |
+
"source_id": "cdfw_crabs",
|
| 685 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 686 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 687 |
+
"supporting_chunk_id": "",
|
| 688 |
+
"supporting_snippet": "Dungeness Crab (south of the Sonoma/Mendocino county line): Open from November 1, 2025 through June 30, 2026.",
|
| 689 |
+
"validation_set": true,
|
| 690 |
+
"region_key": "san_francisco",
|
| 691 |
+
"effective_start": "2025-11-01",
|
| 692 |
+
"effective_end": "2026-06-30"
|
| 693 |
+
},
|
| 694 |
+
{
|
| 695 |
+
"id": "ocean_salmon_season_sf",
|
| 696 |
+
"species_or_category": "ocean_salmon",
|
| 697 |
+
"display_name": "Ocean salmon",
|
| 698 |
+
"aliases": [
|
| 699 |
+
"salmon"
|
| 700 |
+
],
|
| 701 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 702 |
+
"fact_type": "season",
|
| 703 |
+
"value": "open June 27 - July 22 and August 1 - 31 in the San Francisco zone (Point Arena to Pigeon Point) for the 2026 season; minimum size 20 inches total length (24 inches through May 15). Dates vary by zone and can change in-season; verify current CDFW salmon regulations.",
|
| 704 |
+
"units": null,
|
| 705 |
+
"source_id": "cdfw_ocean_regulations",
|
| 706 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Salmon",
|
| 707 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 708 |
+
"supporting_chunk_id": "",
|
| 709 |
+
"supporting_snippet": "Point Arena to Pigeon Point (San Francisco): June 27 - July 22, August 1 - 31.",
|
| 710 |
+
"validation_set": true,
|
| 711 |
+
"region_key": "san_francisco",
|
| 712 |
+
"effective_start": "2026-06-27"
|
| 713 |
+
},
|
| 714 |
+
{
|
| 715 |
+
"id": "red_abalone_closure",
|
| 716 |
+
"species_or_category": "red_abalone",
|
| 717 |
+
"display_name": "Red abalone",
|
| 718 |
+
"aliases": [
|
| 719 |
+
"abalone"
|
| 720 |
+
],
|
| 721 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 722 |
+
"fact_type": "closure",
|
| 723 |
+
"value": "The recreational red abalone fishery is closed; the closure was extended to 2036. All ocean waters are closed to the take of abalone (CCR T14 29.15).",
|
| 724 |
+
"units": null,
|
| 725 |
+
"source_id": "cdfw_ocean_regulations",
|
| 726 |
+
"source_url": "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Abalone",
|
| 727 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 728 |
+
"supporting_chunk_id": "",
|
| 729 |
+
"supporting_snippet": "The poor condition of red abalone populations led the California Fish and Game Commission to close the fishery in 2018 ... the closure was extended to 2036 at the December, 2025 Commission meeting.",
|
| 730 |
+
"validation_set": true
|
| 731 |
+
},
|
| 732 |
+
{
|
| 733 |
+
"id": "purple_urchin_bag_limit",
|
| 734 |
+
"species_or_category": "purple_urchin",
|
| 735 |
+
"display_name": "Purple sea urchin",
|
| 736 |
+
"aliases": [
|
| 737 |
+
"sea urchin",
|
| 738 |
+
"purple urchin"
|
| 739 |
+
],
|
| 740 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 741 |
+
"fact_type": "bag_limit",
|
| 742 |
+
"value": "35",
|
| 743 |
+
"units": "sea urchins per species per day (40 gallons when skin or SCUBA diving in Humboldt, Mendocino, and Sonoma counties); no possession limit (CCR T14 29.06)",
|
| 744 |
+
"source_id": "cdfw_ocean_regulations",
|
| 745 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 746 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 747 |
+
"supporting_chunk_id": "",
|
| 748 |
+
"supporting_snippet": "The daily bag limit for sea urchin is 35 individuals for each species. The daily bag limit for purple sea urchin is forty (40) gallons when taken while skin or SCUBA diving in ocean waters of Humboldt, Mendocino, and Sonoma counties.",
|
| 749 |
+
"validation_set": true
|
| 750 |
+
},
|
| 751 |
+
{
|
| 752 |
+
"id": "kelp_plants_bag_limit",
|
| 753 |
+
"species_or_category": "kelp_plants",
|
| 754 |
+
"display_name": "Kelp and marine aquatic plants",
|
| 755 |
+
"aliases": [
|
| 756 |
+
"kelp",
|
| 757 |
+
"seaweed",
|
| 758 |
+
"marine plants"
|
| 759 |
+
],
|
| 760 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 761 |
+
"fact_type": "bag_limit",
|
| 762 |
+
"value": "10",
|
| 763 |
+
"units": "pounds wet weight in the aggregate per day for marine aquatic plants for which take is authorized (CCR T14 30.00)",
|
| 764 |
+
"source_id": "cdfw_ocean_regulations",
|
| 765 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 766 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 767 |
+
"supporting_chunk_id": "",
|
| 768 |
+
"supporting_snippet": "The daily bag limit on all marine aquatic plants for which the take is authorized ... is 10 pounds wet weight in the aggregate.",
|
| 769 |
+
"validation_set": true
|
| 770 |
+
},
|
| 771 |
+
{
|
| 772 |
+
"id": "sea_palm_closure",
|
| 773 |
+
"species_or_category": "sea_palm",
|
| 774 |
+
"display_name": "Sea palm",
|
| 775 |
+
"aliases": [],
|
| 776 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 777 |
+
"fact_type": "closure",
|
| 778 |
+
"value": "Sea palm may not be cut or disturbed (protected; CCR T14 30.10).",
|
| 779 |
+
"units": null,
|
| 780 |
+
"source_id": "cdfw_ocean_regulations",
|
| 781 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 782 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 783 |
+
"supporting_chunk_id": "",
|
| 784 |
+
"supporting_snippet": "No eel grass (Zostera), surf grass (Phyllospadix) or sea palm (Postelsia) may be cut or disturbed.",
|
| 785 |
+
"validation_set": true
|
| 786 |
+
},
|
| 787 |
+
{
|
| 788 |
+
"id": "eelgrass_surfgrass_closure",
|
| 789 |
+
"species_or_category": "eelgrass_surfgrass",
|
| 790 |
+
"display_name": "Eelgrass and surfgrass",
|
| 791 |
+
"aliases": [
|
| 792 |
+
"eelgrass",
|
| 793 |
+
"surfgrass"
|
| 794 |
+
],
|
| 795 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 796 |
+
"fact_type": "closure",
|
| 797 |
+
"value": "Eelgrass and surfgrass may not be cut or disturbed (protected habitat; CCR T14 30.10).",
|
| 798 |
+
"units": null,
|
| 799 |
+
"source_id": "cdfw_ocean_regulations",
|
| 800 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 801 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 802 |
+
"supporting_chunk_id": "",
|
| 803 |
+
"supporting_snippet": "No eel grass (Zostera), surf grass (Phyllospadix) or sea palm (Postelsia) may be cut or disturbed.",
|
| 804 |
+
"validation_set": true
|
| 805 |
+
},
|
| 806 |
+
{
|
| 807 |
+
"id": "garibaldi_closure",
|
| 808 |
+
"species_or_category": "garibaldi",
|
| 809 |
+
"display_name": "Garibaldi",
|
| 810 |
+
"aliases": [],
|
| 811 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 812 |
+
"fact_type": "closure",
|
| 813 |
+
"value": "Garibaldi may not be taken or possessed (protected; CCR T14 28.05).",
|
| 814 |
+
"units": null,
|
| 815 |
+
"source_id": "cdfw_ocean_regulations",
|
| 816 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 817 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 818 |
+
"supporting_chunk_id": "",
|
| 819 |
+
"supporting_snippet": "Garibaldi. May not be taken or possessed.",
|
| 820 |
+
"validation_set": true
|
| 821 |
+
},
|
| 822 |
+
{
|
| 823 |
+
"id": "dungeness_crab_daily_bag",
|
| 824 |
+
"species_or_category": "dungeness_crab",
|
| 825 |
+
"display_name": "Dungeness crab",
|
| 826 |
+
"aliases": [
|
| 827 |
+
"dungeness crabs"
|
| 828 |
+
],
|
| 829 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 830 |
+
"fact_type": "bag_limit",
|
| 831 |
+
"value": "10",
|
| 832 |
+
"units": "crab per day",
|
| 833 |
+
"source_id": "cdfw_crabs",
|
| 834 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 835 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 836 |
+
"supporting_chunk_id": "",
|
| 837 |
+
"supporting_snippet": "Dungeness crab: the daily bag limit is 10 crab.",
|
| 838 |
+
"validation_set": true
|
| 839 |
+
},
|
| 840 |
+
{
|
| 841 |
+
"id": "dungeness_crab_min_size",
|
| 842 |
+
"species_or_category": "dungeness_crab",
|
| 843 |
+
"display_name": "Dungeness crab",
|
| 844 |
+
"aliases": [
|
| 845 |
+
"dungeness crabs"
|
| 846 |
+
],
|
| 847 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 848 |
+
"fact_type": "minimum_size",
|
| 849 |
+
"value": "5¾",
|
| 850 |
+
"units": "inches",
|
| 851 |
+
"source_id": "cdfw_crabs",
|
| 852 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco",
|
| 853 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 854 |
+
"supporting_chunk_id": "",
|
| 855 |
+
"supporting_snippet": "Dungeness crab: the minimum size limit is 5¾ inches.",
|
| 856 |
+
"validation_set": true
|
| 857 |
+
},
|
| 858 |
+
{
|
| 859 |
+
"id": "kelp_bass_min_size",
|
| 860 |
+
"species_or_category": "kelp_bass",
|
| 861 |
+
"display_name": "Kelp bass",
|
| 862 |
+
"aliases": [
|
| 863 |
+
"calico bass",
|
| 864 |
+
"calico",
|
| 865 |
+
"kelp bass"
|
| 866 |
+
],
|
| 867 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 868 |
+
"fact_type": "minimum_size",
|
| 869 |
+
"value": "14",
|
| 870 |
+
"units": "inches total length",
|
| 871 |
+
"source_id": "cdfw_region_southern",
|
| 872 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 873 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 874 |
+
"supporting_chunk_id": "",
|
| 875 |
+
"supporting_snippet": "Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length.",
|
| 876 |
+
"validation_set": false
|
| 877 |
+
},
|
| 878 |
+
{
|
| 879 |
+
"id": "kelp_bass_bag_limit",
|
| 880 |
+
"species_or_category": "kelp_bass",
|
| 881 |
+
"display_name": "Kelp bass",
|
| 882 |
+
"aliases": [
|
| 883 |
+
"calico bass"
|
| 884 |
+
],
|
| 885 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 886 |
+
"fact_type": "bag_limit",
|
| 887 |
+
"value": "5",
|
| 888 |
+
"units": "fish in any combination of kelp, barred sand, and spotted sand bass per day",
|
| 889 |
+
"source_id": "cdfw_region_southern",
|
| 890 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 891 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 892 |
+
"supporting_chunk_id": "",
|
| 893 |
+
"supporting_snippet": "Five fish in any combination of species (kelp bass, barred sand bass, spotted sand bass).",
|
| 894 |
+
"validation_set": false
|
| 895 |
+
},
|
| 896 |
+
{
|
| 897 |
+
"id": "barred_sand_bass_min_size",
|
| 898 |
+
"species_or_category": "barred_sand_bass",
|
| 899 |
+
"display_name": "Barred sand bass",
|
| 900 |
+
"aliases": [
|
| 901 |
+
"sand bass"
|
| 902 |
+
],
|
| 903 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 904 |
+
"fact_type": "minimum_size",
|
| 905 |
+
"value": "14",
|
| 906 |
+
"units": "inches total length",
|
| 907 |
+
"source_id": "cdfw_region_southern",
|
| 908 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 909 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 910 |
+
"supporting_chunk_id": "",
|
| 911 |
+
"supporting_snippet": "Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length.",
|
| 912 |
+
"validation_set": false
|
| 913 |
+
},
|
| 914 |
+
{
|
| 915 |
+
"id": "barred_sand_bass_bag_limit",
|
| 916 |
+
"species_or_category": "barred_sand_bass",
|
| 917 |
+
"display_name": "Barred sand bass",
|
| 918 |
+
"aliases": [
|
| 919 |
+
"sand bass"
|
| 920 |
+
],
|
| 921 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 922 |
+
"fact_type": "bag_limit",
|
| 923 |
+
"value": "5",
|
| 924 |
+
"units": "fish in any combination of kelp/barred sand/spotted sand bass, of which no more than 4 may be barred sand bass",
|
| 925 |
+
"source_id": "cdfw_region_southern",
|
| 926 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 927 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 928 |
+
"supporting_chunk_id": "",
|
| 929 |
+
"supporting_snippet": "Five fish in any combination of species, except no more than 4 barred sand bass may be taken. The minimum size limit is 14 inches.",
|
| 930 |
+
"validation_set": false
|
| 931 |
+
},
|
| 932 |
+
{
|
| 933 |
+
"id": "spotted_sand_bass_min_size",
|
| 934 |
+
"species_or_category": "spotted_sand_bass",
|
| 935 |
+
"display_name": "Spotted sand bass",
|
| 936 |
+
"aliases": [
|
| 937 |
+
"spotted bay bass",
|
| 938 |
+
"spotty"
|
| 939 |
+
],
|
| 940 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 941 |
+
"fact_type": "minimum_size",
|
| 942 |
+
"value": "14",
|
| 943 |
+
"units": "inches total length",
|
| 944 |
+
"source_id": "cdfw_region_southern",
|
| 945 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 946 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 947 |
+
"supporting_chunk_id": "",
|
| 948 |
+
"supporting_snippet": "Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length.",
|
| 949 |
+
"validation_set": false
|
| 950 |
+
},
|
| 951 |
+
{
|
| 952 |
+
"id": "spotted_sand_bass_bag_limit",
|
| 953 |
+
"species_or_category": "spotted_sand_bass",
|
| 954 |
+
"display_name": "Spotted sand bass",
|
| 955 |
+
"aliases": [
|
| 956 |
+
"spotted bay bass"
|
| 957 |
+
],
|
| 958 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 959 |
+
"fact_type": "bag_limit",
|
| 960 |
+
"value": "5",
|
| 961 |
+
"units": "fish in any combination of kelp/barred sand/spotted sand bass per day",
|
| 962 |
+
"source_id": "cdfw_region_southern",
|
| 963 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 964 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 965 |
+
"supporting_chunk_id": "",
|
| 966 |
+
"supporting_snippet": "Five fish in any combination of species (kelp bass, barred sand bass, spotted sand bass).",
|
| 967 |
+
"validation_set": false
|
| 968 |
+
},
|
| 969 |
+
{
|
| 970 |
+
"id": "california_sheephead_min_size",
|
| 971 |
+
"species_or_category": "california_sheephead",
|
| 972 |
+
"display_name": "California sheephead",
|
| 973 |
+
"aliases": [
|
| 974 |
+
"sheephead",
|
| 975 |
+
"sheepshead"
|
| 976 |
+
],
|
| 977 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 978 |
+
"fact_type": "minimum_size",
|
| 979 |
+
"value": "12",
|
| 980 |
+
"units": "inches total length",
|
| 981 |
+
"source_id": "cdfw_region_southern",
|
| 982 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 983 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 984 |
+
"supporting_chunk_id": "",
|
| 985 |
+
"supporting_snippet": "California sheephead: 2 fish, with a minimum size limit of 12 inches total length.",
|
| 986 |
+
"validation_set": false
|
| 987 |
+
},
|
| 988 |
+
{
|
| 989 |
+
"id": "california_sheephead_bag_limit",
|
| 990 |
+
"species_or_category": "california_sheephead",
|
| 991 |
+
"display_name": "California sheephead",
|
| 992 |
+
"aliases": [
|
| 993 |
+
"sheephead"
|
| 994 |
+
],
|
| 995 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 996 |
+
"fact_type": "bag_limit",
|
| 997 |
+
"value": "2",
|
| 998 |
+
"units": "fish per day",
|
| 999 |
+
"source_id": "cdfw_region_southern",
|
| 1000 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1001 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1002 |
+
"supporting_chunk_id": "",
|
| 1003 |
+
"supporting_snippet": "California sheephead: 2 fish, with a minimum size limit of 12 inches total length.",
|
| 1004 |
+
"validation_set": false
|
| 1005 |
+
},
|
| 1006 |
+
{
|
| 1007 |
+
"id": "california_barracuda_min_size",
|
| 1008 |
+
"species_or_category": "california_barracuda",
|
| 1009 |
+
"display_name": "California barracuda",
|
| 1010 |
+
"aliases": [
|
| 1011 |
+
"barracuda",
|
| 1012 |
+
"pacific barracuda"
|
| 1013 |
+
],
|
| 1014 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1015 |
+
"fact_type": "minimum_size",
|
| 1016 |
+
"value": "28",
|
| 1017 |
+
"units": "inches total length (or 17 inches alternate length)",
|
| 1018 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1019 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1020 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1021 |
+
"supporting_chunk_id": "",
|
| 1022 |
+
"supporting_snippet": "California barracuda minimum size: twenty-eight inches total length or seventeen inches alternate length (CCR T14 28.25).",
|
| 1023 |
+
"validation_set": false
|
| 1024 |
+
},
|
| 1025 |
+
{
|
| 1026 |
+
"id": "pacific_bonito_min_size",
|
| 1027 |
+
"species_or_category": "pacific_bonito",
|
| 1028 |
+
"display_name": "Pacific bonito",
|
| 1029 |
+
"aliases": [
|
| 1030 |
+
"bonito"
|
| 1031 |
+
],
|
| 1032 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1033 |
+
"fact_type": "minimum_size",
|
| 1034 |
+
"value": "24",
|
| 1035 |
+
"units": "inches fork length or 5 pounds, except up to 5 smaller fish may be taken",
|
| 1036 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1037 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1038 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1039 |
+
"supporting_chunk_id": "",
|
| 1040 |
+
"supporting_snippet": "Pacific bonito minimum size: twenty-four inches fork length or five pounds, except that five fish less than twenty-four inches or five pounds may be taken (CCR T14 28.32).",
|
| 1041 |
+
"validation_set": false
|
| 1042 |
+
},
|
| 1043 |
+
{
|
| 1044 |
+
"id": "pacific_bonito_bag_limit",
|
| 1045 |
+
"species_or_category": "pacific_bonito",
|
| 1046 |
+
"display_name": "Pacific bonito",
|
| 1047 |
+
"aliases": [
|
| 1048 |
+
"bonito"
|
| 1049 |
+
],
|
| 1050 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1051 |
+
"fact_type": "bag_limit",
|
| 1052 |
+
"value": "10",
|
| 1053 |
+
"units": "fish per day",
|
| 1054 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1055 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1056 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1057 |
+
"supporting_chunk_id": "",
|
| 1058 |
+
"supporting_snippet": "Pacific bonito limit: ten (CCR T14 28.32).",
|
| 1059 |
+
"validation_set": false
|
| 1060 |
+
},
|
| 1061 |
+
{
|
| 1062 |
+
"id": "california_yellowtail_min_size",
|
| 1063 |
+
"species_or_category": "california_yellowtail",
|
| 1064 |
+
"display_name": "California yellowtail",
|
| 1065 |
+
"aliases": [
|
| 1066 |
+
"yellowtail"
|
| 1067 |
+
],
|
| 1068 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1069 |
+
"fact_type": "minimum_size",
|
| 1070 |
+
"value": "24",
|
| 1071 |
+
"units": "inches fork length, except up to 5 smaller fish may be taken",
|
| 1072 |
+
"source_id": "cdfw_region_southern",
|
| 1073 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1074 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1075 |
+
"supporting_chunk_id": "",
|
| 1076 |
+
"supporting_snippet": "California yellowtail: the minimum size limit is 24 inches fork length, except that up to five fish less than 24 inches fork length may be taken.",
|
| 1077 |
+
"validation_set": false
|
| 1078 |
+
},
|
| 1079 |
+
{
|
| 1080 |
+
"id": "california_yellowtail_bag_limit",
|
| 1081 |
+
"species_or_category": "california_yellowtail",
|
| 1082 |
+
"display_name": "California yellowtail",
|
| 1083 |
+
"aliases": [
|
| 1084 |
+
"yellowtail"
|
| 1085 |
+
],
|
| 1086 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1087 |
+
"fact_type": "bag_limit",
|
| 1088 |
+
"value": "10",
|
| 1089 |
+
"units": "fish per day",
|
| 1090 |
+
"source_id": "cdfw_region_southern",
|
| 1091 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1092 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1093 |
+
"supporting_chunk_id": "",
|
| 1094 |
+
"supporting_snippet": "California yellowtail: ten fish.",
|
| 1095 |
+
"validation_set": false
|
| 1096 |
+
},
|
| 1097 |
+
{
|
| 1098 |
+
"id": "ocean_whitefish_min_size",
|
| 1099 |
+
"species_or_category": "ocean_whitefish",
|
| 1100 |
+
"display_name": "Ocean whitefish",
|
| 1101 |
+
"aliases": [
|
| 1102 |
+
"whitefish"
|
| 1103 |
+
],
|
| 1104 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1105 |
+
"fact_type": "minimum_size",
|
| 1106 |
+
"value": "no minimum size limit",
|
| 1107 |
+
"units": null,
|
| 1108 |
+
"source_id": "cdfw_region_southern",
|
| 1109 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1110 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1111 |
+
"supporting_chunk_id": "",
|
| 1112 |
+
"supporting_snippet": "Ocean whitefish: 10 fish within the general daily bag limit of 20 fish total, with no minimum size limit.",
|
| 1113 |
+
"validation_set": false
|
| 1114 |
+
},
|
| 1115 |
+
{
|
| 1116 |
+
"id": "ocean_whitefish_bag_limit",
|
| 1117 |
+
"species_or_category": "ocean_whitefish",
|
| 1118 |
+
"display_name": "Ocean whitefish",
|
| 1119 |
+
"aliases": [
|
| 1120 |
+
"whitefish"
|
| 1121 |
+
],
|
| 1122 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1123 |
+
"fact_type": "bag_limit",
|
| 1124 |
+
"value": "10",
|
| 1125 |
+
"units": "fish within the general daily bag limit of 20 finfish total",
|
| 1126 |
+
"source_id": "cdfw_region_southern",
|
| 1127 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1128 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1129 |
+
"supporting_chunk_id": "",
|
| 1130 |
+
"supporting_snippet": "Ocean whitefish: 10 fish within the general daily bag limit of 20 fish total.",
|
| 1131 |
+
"validation_set": false
|
| 1132 |
+
},
|
| 1133 |
+
{
|
| 1134 |
+
"id": "california_grunion_season",
|
| 1135 |
+
"species_or_category": "california_grunion",
|
| 1136 |
+
"display_name": "California grunion",
|
| 1137 |
+
"aliases": [
|
| 1138 |
+
"grunion"
|
| 1139 |
+
],
|
| 1140 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1141 |
+
"fact_type": "season",
|
| 1142 |
+
"value": "open to take by hand only, except closed (no take) from April 1 through June 30.",
|
| 1143 |
+
"units": null,
|
| 1144 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1145 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1146 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1147 |
+
"supporting_chunk_id": "",
|
| 1148 |
+
"supporting_snippet": "It shall be unlawful to take grunion from April 1 through June 30 (CCR T14 28.00).",
|
| 1149 |
+
"validation_set": false,
|
| 1150 |
+
"effective_start": "2026-04-01",
|
| 1151 |
+
"effective_end": "2026-06-30"
|
| 1152 |
+
},
|
| 1153 |
+
{
|
| 1154 |
+
"id": "california_grunion_bag_limit",
|
| 1155 |
+
"species_or_category": "california_grunion",
|
| 1156 |
+
"display_name": "California grunion",
|
| 1157 |
+
"aliases": [
|
| 1158 |
+
"grunion"
|
| 1159 |
+
],
|
| 1160 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1161 |
+
"fact_type": "bag_limit",
|
| 1162 |
+
"value": "30",
|
| 1163 |
+
"units": "fish per day",
|
| 1164 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1165 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1166 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1167 |
+
"supporting_chunk_id": "",
|
| 1168 |
+
"supporting_snippet": "California grunion limit: 30 (CCR T14 28.00).",
|
| 1169 |
+
"validation_set": false
|
| 1170 |
+
},
|
| 1171 |
+
{
|
| 1172 |
+
"id": "giant_sea_bass_closure",
|
| 1173 |
+
"species_or_category": "giant_sea_bass",
|
| 1174 |
+
"display_name": "Giant sea bass",
|
| 1175 |
+
"aliases": [
|
| 1176 |
+
"black sea bass",
|
| 1177 |
+
"giant black sea bass"
|
| 1178 |
+
],
|
| 1179 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1180 |
+
"fact_type": "closure",
|
| 1181 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1182 |
+
"units": null,
|
| 1183 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1184 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1185 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1186 |
+
"supporting_chunk_id": "",
|
| 1187 |
+
"supporting_snippet": "Giant sea bass may not be taken off California; fish taken incidental to other fishing must be immediately returned to the water (CCR T14 28.10).",
|
| 1188 |
+
"validation_set": false
|
| 1189 |
+
},
|
| 1190 |
+
{
|
| 1191 |
+
"id": "northern_anchovy_bag_limit",
|
| 1192 |
+
"species_or_category": "northern_anchovy",
|
| 1193 |
+
"display_name": "Northern anchovy",
|
| 1194 |
+
"aliases": [
|
| 1195 |
+
"anchovy",
|
| 1196 |
+
"anchovies"
|
| 1197 |
+
],
|
| 1198 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1199 |
+
"fact_type": "bag_limit",
|
| 1200 |
+
"value": "no daily bag limit",
|
| 1201 |
+
"units": "(CCR T14 27.60(b))",
|
| 1202 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1203 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1204 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1205 |
+
"supporting_chunk_id": "",
|
| 1206 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1207 |
+
"validation_set": false
|
| 1208 |
+
},
|
| 1209 |
+
{
|
| 1210 |
+
"id": "pacific_sardine_bag_limit",
|
| 1211 |
+
"species_or_category": "pacific_sardine",
|
| 1212 |
+
"display_name": "Pacific sardine",
|
| 1213 |
+
"aliases": [
|
| 1214 |
+
"sardine",
|
| 1215 |
+
"sardines"
|
| 1216 |
+
],
|
| 1217 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1218 |
+
"fact_type": "bag_limit",
|
| 1219 |
+
"value": "no daily bag limit",
|
| 1220 |
+
"units": "(CCR T14 27.60(b))",
|
| 1221 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1222 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1223 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1224 |
+
"supporting_chunk_id": "",
|
| 1225 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1226 |
+
"validation_set": false
|
| 1227 |
+
},
|
| 1228 |
+
{
|
| 1229 |
+
"id": "pacific_mackerel_bag_limit",
|
| 1230 |
+
"species_or_category": "pacific_mackerel",
|
| 1231 |
+
"display_name": "Pacific mackerel",
|
| 1232 |
+
"aliases": [
|
| 1233 |
+
"mackerel",
|
| 1234 |
+
"spanish mackerel"
|
| 1235 |
+
],
|
| 1236 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1237 |
+
"fact_type": "bag_limit",
|
| 1238 |
+
"value": "no daily bag limit",
|
| 1239 |
+
"units": "(CCR T14 27.60(b))",
|
| 1240 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1241 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1242 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1243 |
+
"supporting_chunk_id": "",
|
| 1244 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1245 |
+
"validation_set": false
|
| 1246 |
+
},
|
| 1247 |
+
{
|
| 1248 |
+
"id": "jack_mackerel_bag_limit",
|
| 1249 |
+
"species_or_category": "jack_mackerel",
|
| 1250 |
+
"display_name": "Jack mackerel",
|
| 1251 |
+
"aliases": [
|
| 1252 |
+
"jack mackerel"
|
| 1253 |
+
],
|
| 1254 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1255 |
+
"fact_type": "bag_limit",
|
| 1256 |
+
"value": "no daily bag limit",
|
| 1257 |
+
"units": "(CCR T14 27.60(b))",
|
| 1258 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1259 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1260 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1261 |
+
"supporting_chunk_id": "",
|
| 1262 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1263 |
+
"validation_set": false
|
| 1264 |
+
},
|
| 1265 |
+
{
|
| 1266 |
+
"id": "queenfish_bag_limit",
|
| 1267 |
+
"species_or_category": "queenfish",
|
| 1268 |
+
"display_name": "Queenfish",
|
| 1269 |
+
"aliases": [
|
| 1270 |
+
"queenfish",
|
| 1271 |
+
"herring queenfish"
|
| 1272 |
+
],
|
| 1273 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1274 |
+
"fact_type": "bag_limit",
|
| 1275 |
+
"value": "no daily bag limit",
|
| 1276 |
+
"units": "(CCR T14 27.60(b))",
|
| 1277 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1278 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1279 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1280 |
+
"supporting_chunk_id": "",
|
| 1281 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1282 |
+
"validation_set": false
|
| 1283 |
+
},
|
| 1284 |
+
{
|
| 1285 |
+
"id": "pacific_butterfish_bag_limit",
|
| 1286 |
+
"species_or_category": "pacific_butterfish",
|
| 1287 |
+
"display_name": "Pacific butterfish (pompano)",
|
| 1288 |
+
"aliases": [
|
| 1289 |
+
"butterfish",
|
| 1290 |
+
"pompano",
|
| 1291 |
+
"pacific pompano"
|
| 1292 |
+
],
|
| 1293 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1294 |
+
"fact_type": "bag_limit",
|
| 1295 |
+
"value": "no daily bag limit",
|
| 1296 |
+
"units": "(CCR T14 27.60(b))",
|
| 1297 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1298 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1299 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1300 |
+
"supporting_chunk_id": "",
|
| 1301 |
+
"supporting_snippet": "There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).",
|
| 1302 |
+
"validation_set": false
|
| 1303 |
+
},
|
| 1304 |
+
{
|
| 1305 |
+
"id": "white_croaker_bag_limit",
|
| 1306 |
+
"species_or_category": "white_croaker",
|
| 1307 |
+
"display_name": "White croaker",
|
| 1308 |
+
"aliases": [
|
| 1309 |
+
"white croaker",
|
| 1310 |
+
"kingfish",
|
| 1311 |
+
"tomcod"
|
| 1312 |
+
],
|
| 1313 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1314 |
+
"fact_type": "bag_limit",
|
| 1315 |
+
"value": "10",
|
| 1316 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1317 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1318 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1319 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1320 |
+
"supporting_chunk_id": "",
|
| 1321 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1322 |
+
"validation_set": false
|
| 1323 |
+
},
|
| 1324 |
+
{
|
| 1325 |
+
"id": "yellowfin_croaker_bag_limit",
|
| 1326 |
+
"species_or_category": "yellowfin_croaker",
|
| 1327 |
+
"display_name": "Yellowfin croaker",
|
| 1328 |
+
"aliases": [
|
| 1329 |
+
"yellowfin croaker"
|
| 1330 |
+
],
|
| 1331 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1332 |
+
"fact_type": "bag_limit",
|
| 1333 |
+
"value": "10",
|
| 1334 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1335 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1336 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1337 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1338 |
+
"supporting_chunk_id": "",
|
| 1339 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1340 |
+
"validation_set": false
|
| 1341 |
+
},
|
| 1342 |
+
{
|
| 1343 |
+
"id": "spotfin_croaker_bag_limit",
|
| 1344 |
+
"species_or_category": "spotfin_croaker",
|
| 1345 |
+
"display_name": "Spotfin croaker",
|
| 1346 |
+
"aliases": [
|
| 1347 |
+
"spotfin croaker"
|
| 1348 |
+
],
|
| 1349 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1350 |
+
"fact_type": "bag_limit",
|
| 1351 |
+
"value": "10",
|
| 1352 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1353 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1354 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1355 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1356 |
+
"supporting_chunk_id": "",
|
| 1357 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1358 |
+
"validation_set": false
|
| 1359 |
+
},
|
| 1360 |
+
{
|
| 1361 |
+
"id": "california_corbina_bag_limit",
|
| 1362 |
+
"species_or_category": "california_corbina",
|
| 1363 |
+
"display_name": "California corbina",
|
| 1364 |
+
"aliases": [
|
| 1365 |
+
"corbina",
|
| 1366 |
+
"corvina"
|
| 1367 |
+
],
|
| 1368 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1369 |
+
"fact_type": "bag_limit",
|
| 1370 |
+
"value": "10",
|
| 1371 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1372 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1373 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1374 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1375 |
+
"supporting_chunk_id": "",
|
| 1376 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1377 |
+
"validation_set": false
|
| 1378 |
+
},
|
| 1379 |
+
{
|
| 1380 |
+
"id": "opaleye_bag_limit",
|
| 1381 |
+
"species_or_category": "opaleye",
|
| 1382 |
+
"display_name": "Opaleye",
|
| 1383 |
+
"aliases": [
|
| 1384 |
+
"opaleye",
|
| 1385 |
+
"opal eye"
|
| 1386 |
+
],
|
| 1387 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1388 |
+
"fact_type": "bag_limit",
|
| 1389 |
+
"value": "10",
|
| 1390 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1391 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1392 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1393 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1394 |
+
"supporting_chunk_id": "",
|
| 1395 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1396 |
+
"validation_set": false
|
| 1397 |
+
},
|
| 1398 |
+
{
|
| 1399 |
+
"id": "halfmoon_bag_limit",
|
| 1400 |
+
"species_or_category": "halfmoon",
|
| 1401 |
+
"display_name": "Halfmoon",
|
| 1402 |
+
"aliases": [
|
| 1403 |
+
"halfmoon",
|
| 1404 |
+
"blue perch",
|
| 1405 |
+
"catalina blue perch"
|
| 1406 |
+
],
|
| 1407 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1408 |
+
"fact_type": "bag_limit",
|
| 1409 |
+
"value": "10",
|
| 1410 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1411 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1412 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1413 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1414 |
+
"supporting_chunk_id": "",
|
| 1415 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1416 |
+
"validation_set": false
|
| 1417 |
+
},
|
| 1418 |
+
{
|
| 1419 |
+
"id": "sargo_bag_limit",
|
| 1420 |
+
"species_or_category": "sargo",
|
| 1421 |
+
"display_name": "Sargo",
|
| 1422 |
+
"aliases": [
|
| 1423 |
+
"sargo",
|
| 1424 |
+
"china croaker"
|
| 1425 |
+
],
|
| 1426 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1427 |
+
"fact_type": "bag_limit",
|
| 1428 |
+
"value": "10",
|
| 1429 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1430 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1431 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1432 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1433 |
+
"supporting_chunk_id": "",
|
| 1434 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1435 |
+
"validation_set": false
|
| 1436 |
+
},
|
| 1437 |
+
{
|
| 1438 |
+
"id": "spiny_dogfish_bag_limit",
|
| 1439 |
+
"species_or_category": "spiny_dogfish",
|
| 1440 |
+
"display_name": "Spiny dogfish",
|
| 1441 |
+
"aliases": [
|
| 1442 |
+
"spiny dogfish",
|
| 1443 |
+
"dogfish",
|
| 1444 |
+
"mud shark"
|
| 1445 |
+
],
|
| 1446 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1447 |
+
"fact_type": "bag_limit",
|
| 1448 |
+
"value": "10",
|
| 1449 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1450 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1451 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1452 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1453 |
+
"supporting_chunk_id": "",
|
| 1454 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1455 |
+
"validation_set": false
|
| 1456 |
+
},
|
| 1457 |
+
{
|
| 1458 |
+
"id": "skates_bag_limit",
|
| 1459 |
+
"species_or_category": "skates",
|
| 1460 |
+
"display_name": "Skates",
|
| 1461 |
+
"aliases": [
|
| 1462 |
+
"skate",
|
| 1463 |
+
"skates",
|
| 1464 |
+
"big skate",
|
| 1465 |
+
"longnose skate"
|
| 1466 |
+
],
|
| 1467 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1468 |
+
"fact_type": "bag_limit",
|
| 1469 |
+
"value": "10",
|
| 1470 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1471 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1472 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1473 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1474 |
+
"supporting_chunk_id": "",
|
| 1475 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1476 |
+
"validation_set": false
|
| 1477 |
+
},
|
| 1478 |
+
{
|
| 1479 |
+
"id": "bat_ray_bag_limit",
|
| 1480 |
+
"species_or_category": "bat_ray",
|
| 1481 |
+
"display_name": "Bat ray",
|
| 1482 |
+
"aliases": [
|
| 1483 |
+
"bat ray",
|
| 1484 |
+
"bat stingray"
|
| 1485 |
+
],
|
| 1486 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1487 |
+
"fact_type": "bag_limit",
|
| 1488 |
+
"value": "10",
|
| 1489 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1490 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1491 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1492 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1493 |
+
"supporting_chunk_id": "",
|
| 1494 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1495 |
+
"validation_set": false
|
| 1496 |
+
},
|
| 1497 |
+
{
|
| 1498 |
+
"id": "shovelnose_guitarfish_bag_limit",
|
| 1499 |
+
"species_or_category": "shovelnose_guitarfish",
|
| 1500 |
+
"display_name": "Shovelnose guitarfish",
|
| 1501 |
+
"aliases": [
|
| 1502 |
+
"guitarfish",
|
| 1503 |
+
"shovelnose guitarfish",
|
| 1504 |
+
"shovelnose shark"
|
| 1505 |
+
],
|
| 1506 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1507 |
+
"fact_type": "bag_limit",
|
| 1508 |
+
"value": "10",
|
| 1509 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1510 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1511 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1512 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1513 |
+
"supporting_chunk_id": "",
|
| 1514 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1515 |
+
"validation_set": false
|
| 1516 |
+
},
|
| 1517 |
+
{
|
| 1518 |
+
"id": "thornback_ray_bag_limit",
|
| 1519 |
+
"species_or_category": "thornback_ray",
|
| 1520 |
+
"display_name": "Thornback ray",
|
| 1521 |
+
"aliases": [
|
| 1522 |
+
"thornback",
|
| 1523 |
+
"thornback ray"
|
| 1524 |
+
],
|
| 1525 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1526 |
+
"fact_type": "bag_limit",
|
| 1527 |
+
"value": "10",
|
| 1528 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1529 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1530 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1531 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1532 |
+
"supporting_chunk_id": "",
|
| 1533 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1534 |
+
"validation_set": false
|
| 1535 |
+
},
|
| 1536 |
+
{
|
| 1537 |
+
"id": "round_stingray_bag_limit",
|
| 1538 |
+
"species_or_category": "round_stingray",
|
| 1539 |
+
"display_name": "Round stingray",
|
| 1540 |
+
"aliases": [
|
| 1541 |
+
"round stingray",
|
| 1542 |
+
"stingray",
|
| 1543 |
+
"round ray"
|
| 1544 |
+
],
|
| 1545 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1546 |
+
"fact_type": "bag_limit",
|
| 1547 |
+
"value": "10",
|
| 1548 |
+
"units": "fish of any one species per day, within the general limit of 20 finfish in combination of all species (no species-specific minimum size limit; CCR T14 27.60)",
|
| 1549 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1550 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1551 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1552 |
+
"supporting_chunk_id": "",
|
| 1553 |
+
"supporting_snippet": "No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).",
|
| 1554 |
+
"validation_set": false
|
| 1555 |
+
},
|
| 1556 |
+
{
|
| 1557 |
+
"id": "white_shark_closure",
|
| 1558 |
+
"species_or_category": "white_shark",
|
| 1559 |
+
"display_name": "White shark (great white)",
|
| 1560 |
+
"aliases": [
|
| 1561 |
+
"great white",
|
| 1562 |
+
"white shark",
|
| 1563 |
+
"great white shark"
|
| 1564 |
+
],
|
| 1565 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1566 |
+
"fact_type": "closure",
|
| 1567 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1568 |
+
"units": null,
|
| 1569 |
+
"source_id": "cdfw_region_southern",
|
| 1570 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern",
|
| 1571 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1572 |
+
"supporting_chunk_id": "",
|
| 1573 |
+
"supporting_snippet": "White sharks may not be taken or possessed at any time (CCR T14 28.06; CDFW Southern region).",
|
| 1574 |
+
"validation_set": false
|
| 1575 |
+
},
|
| 1576 |
+
{
|
| 1577 |
+
"id": "gulf_grouper_closure",
|
| 1578 |
+
"species_or_category": "gulf_grouper",
|
| 1579 |
+
"display_name": "Gulf grouper",
|
| 1580 |
+
"aliases": [
|
| 1581 |
+
"gulf grouper"
|
| 1582 |
+
],
|
| 1583 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1584 |
+
"fact_type": "closure",
|
| 1585 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1586 |
+
"units": null,
|
| 1587 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1588 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1589 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1590 |
+
"supporting_chunk_id": "",
|
| 1591 |
+
"supporting_snippet": "Gulf grouper may not be taken or possessed (CCR T14 28.12).",
|
| 1592 |
+
"validation_set": false
|
| 1593 |
+
},
|
| 1594 |
+
{
|
| 1595 |
+
"id": "broomtail_grouper_closure",
|
| 1596 |
+
"species_or_category": "broomtail_grouper",
|
| 1597 |
+
"display_name": "Broomtail grouper",
|
| 1598 |
+
"aliases": [
|
| 1599 |
+
"broomtail grouper"
|
| 1600 |
+
],
|
| 1601 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1602 |
+
"fact_type": "closure",
|
| 1603 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1604 |
+
"units": null,
|
| 1605 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1606 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1607 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1608 |
+
"supporting_chunk_id": "",
|
| 1609 |
+
"supporting_snippet": "Broomtail grouper may not be taken or possessed (CCR T14 28.12).",
|
| 1610 |
+
"validation_set": false
|
| 1611 |
+
},
|
| 1612 |
+
{
|
| 1613 |
+
"id": "basking_shark_closure",
|
| 1614 |
+
"species_or_category": "basking_shark",
|
| 1615 |
+
"display_name": "Basking shark",
|
| 1616 |
+
"aliases": [
|
| 1617 |
+
"basking shark"
|
| 1618 |
+
],
|
| 1619 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1620 |
+
"fact_type": "closure",
|
| 1621 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1622 |
+
"units": null,
|
| 1623 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1624 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1625 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1626 |
+
"supporting_chunk_id": "",
|
| 1627 |
+
"supporting_snippet": "Basking sharks are prohibited from general retention in the U.S. West Coast HMS fishery (50 CFR 660.705).",
|
| 1628 |
+
"validation_set": false
|
| 1629 |
+
},
|
| 1630 |
+
{
|
| 1631 |
+
"id": "megamouth_shark_closure",
|
| 1632 |
+
"species_or_category": "megamouth_shark",
|
| 1633 |
+
"display_name": "Megamouth shark",
|
| 1634 |
+
"aliases": [
|
| 1635 |
+
"megamouth",
|
| 1636 |
+
"megamouth shark"
|
| 1637 |
+
],
|
| 1638 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1639 |
+
"fact_type": "closure",
|
| 1640 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1641 |
+
"units": null,
|
| 1642 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1643 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs",
|
| 1644 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1645 |
+
"supporting_chunk_id": "",
|
| 1646 |
+
"supporting_snippet": "Megamouth sharks are prohibited from general retention in the U.S. West Coast HMS fishery (50 CFR 660.705).",
|
| 1647 |
+
"validation_set": false
|
| 1648 |
+
},
|
| 1649 |
+
{
|
| 1650 |
+
"id": "california_spiny_lobster_min_size",
|
| 1651 |
+
"species_or_category": "california_spiny_lobster",
|
| 1652 |
+
"display_name": "California spiny lobster",
|
| 1653 |
+
"aliases": [
|
| 1654 |
+
"spiny lobster"
|
| 1655 |
+
],
|
| 1656 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1657 |
+
"fact_type": "minimum_size",
|
| 1658 |
+
"value": "3¼",
|
| 1659 |
+
"units": "inches carapace (straight line on the mid-line of the back, rear of eye socket to rear of body shell)",
|
| 1660 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1661 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1662 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1663 |
+
"supporting_chunk_id": "",
|
| 1664 |
+
"supporting_snippet": "California spiny lobster minimum size: 3 and 1/4 inches measured on the mid-line of the back from the rear edge of the eye socket to the rear edge of the body shell (CCR T14 29.90).",
|
| 1665 |
+
"validation_set": false
|
| 1666 |
+
},
|
| 1667 |
+
{
|
| 1668 |
+
"id": "california_spiny_lobster_bag_limit",
|
| 1669 |
+
"species_or_category": "california_spiny_lobster",
|
| 1670 |
+
"display_name": "California spiny lobster",
|
| 1671 |
+
"aliases": [
|
| 1672 |
+
"spiny lobster"
|
| 1673 |
+
],
|
| 1674 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1675 |
+
"fact_type": "bag_limit",
|
| 1676 |
+
"value": "7",
|
| 1677 |
+
"units": "lobster per day",
|
| 1678 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1679 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1680 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1681 |
+
"supporting_chunk_id": "",
|
| 1682 |
+
"supporting_snippet": "California spiny lobster daily bag limit: seven (CCR T14 29.90).",
|
| 1683 |
+
"validation_set": false
|
| 1684 |
+
},
|
| 1685 |
+
{
|
| 1686 |
+
"id": "california_spiny_lobster_season",
|
| 1687 |
+
"species_or_category": "california_spiny_lobster",
|
| 1688 |
+
"display_name": "California spiny lobster",
|
| 1689 |
+
"aliases": [
|
| 1690 |
+
"spiny lobster"
|
| 1691 |
+
],
|
| 1692 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1693 |
+
"fact_type": "season",
|
| 1694 |
+
"value": "open from 6:00 p.m. on the Friday preceding the first Wednesday in October through the first Wednesday after the 15th of March.",
|
| 1695 |
+
"units": null,
|
| 1696 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1697 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1698 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1699 |
+
"supporting_chunk_id": "",
|
| 1700 |
+
"supporting_snippet": "Open season: from 6:00 p.m. on the Friday before the first Wednesday in October through the first Wednesday after the 15th of March (CCR T14 29.90).",
|
| 1701 |
+
"validation_set": false
|
| 1702 |
+
},
|
| 1703 |
+
{
|
| 1704 |
+
"id": "rock_scallop_bag_limit",
|
| 1705 |
+
"species_or_category": "rock_scallop",
|
| 1706 |
+
"display_name": "Rock scallop",
|
| 1707 |
+
"aliases": [
|
| 1708 |
+
"rock scallop",
|
| 1709 |
+
"scallop",
|
| 1710 |
+
"scallops"
|
| 1711 |
+
],
|
| 1712 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1713 |
+
"fact_type": "bag_limit",
|
| 1714 |
+
"value": "10",
|
| 1715 |
+
"units": "scallops per day",
|
| 1716 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1717 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1718 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1719 |
+
"supporting_chunk_id": "",
|
| 1720 |
+
"supporting_snippet": "Rock scallop limit: ten (CCR T14 29.60).",
|
| 1721 |
+
"validation_set": false
|
| 1722 |
+
},
|
| 1723 |
+
{
|
| 1724 |
+
"id": "speckled_scallop_closure",
|
| 1725 |
+
"species_or_category": "speckled_scallop",
|
| 1726 |
+
"display_name": "Speckled (bay) scallop",
|
| 1727 |
+
"aliases": [
|
| 1728 |
+
"bay scallop",
|
| 1729 |
+
"speckled scallop"
|
| 1730 |
+
],
|
| 1731 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1732 |
+
"fact_type": "closure",
|
| 1733 |
+
"value": "Take and possession prohibited in California ocean waters; may not be taken or possessed.",
|
| 1734 |
+
"units": null,
|
| 1735 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1736 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1737 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1738 |
+
"supporting_chunk_id": "",
|
| 1739 |
+
"supporting_snippet": "Speckled (bay) scallops may not be taken or possessed (CCR T14 29.65).",
|
| 1740 |
+
"validation_set": false
|
| 1741 |
+
},
|
| 1742 |
+
{
|
| 1743 |
+
"id": "market_squid_bag_limit",
|
| 1744 |
+
"species_or_category": "market_squid",
|
| 1745 |
+
"display_name": "Market squid",
|
| 1746 |
+
"aliases": [
|
| 1747 |
+
"squid",
|
| 1748 |
+
"calamari",
|
| 1749 |
+
"market squid"
|
| 1750 |
+
],
|
| 1751 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1752 |
+
"fact_type": "bag_limit",
|
| 1753 |
+
"value": "no daily bag limit",
|
| 1754 |
+
"units": "(may be taken with hand-held dip nets)",
|
| 1755 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1756 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1757 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1758 |
+
"supporting_chunk_id": "",
|
| 1759 |
+
"supporting_snippet": "Squid may be taken with hand-held dip nets. There is no limit (CCR T14 29.70).",
|
| 1760 |
+
"validation_set": false
|
| 1761 |
+
},
|
| 1762 |
+
{
|
| 1763 |
+
"id": "kellets_whelk_bag_limit",
|
| 1764 |
+
"species_or_category": "kellets_whelk",
|
| 1765 |
+
"display_name": "Kellet's whelk",
|
| 1766 |
+
"aliases": [
|
| 1767 |
+
"whelk",
|
| 1768 |
+
"kellets whelk",
|
| 1769 |
+
"kellet's whelk"
|
| 1770 |
+
],
|
| 1771 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1772 |
+
"fact_type": "bag_limit",
|
| 1773 |
+
"value": "35",
|
| 1774 |
+
"units": "whelks per day",
|
| 1775 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1776 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1777 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1778 |
+
"supporting_chunk_id": "",
|
| 1779 |
+
"supporting_snippet": "Kellet's whelk: 35 whelks; season July 1 through the first Wednesday after the 15th of March (CCR T14 29.17, 29.05).",
|
| 1780 |
+
"validation_set": false
|
| 1781 |
+
},
|
| 1782 |
+
{
|
| 1783 |
+
"id": "octopus_bag_limit",
|
| 1784 |
+
"species_or_category": "octopus",
|
| 1785 |
+
"display_name": "Octopus",
|
| 1786 |
+
"aliases": [
|
| 1787 |
+
"octopus",
|
| 1788 |
+
"octopuses",
|
| 1789 |
+
"octopi"
|
| 1790 |
+
],
|
| 1791 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1792 |
+
"fact_type": "bag_limit",
|
| 1793 |
+
"value": "35",
|
| 1794 |
+
"units": "octopus per day",
|
| 1795 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1796 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1797 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1798 |
+
"supporting_chunk_id": "",
|
| 1799 |
+
"supporting_snippet": "Octopus: 35 octopus, year round (CCR T14 29.05).",
|
| 1800 |
+
"validation_set": false
|
| 1801 |
+
},
|
| 1802 |
+
{
|
| 1803 |
+
"id": "native_oyster_bag_limit",
|
| 1804 |
+
"species_or_category": "native_oyster",
|
| 1805 |
+
"display_name": "Native oyster",
|
| 1806 |
+
"aliases": [
|
| 1807 |
+
"oyster",
|
| 1808 |
+
"oysters",
|
| 1809 |
+
"native oyster"
|
| 1810 |
+
],
|
| 1811 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1812 |
+
"fact_type": "bag_limit",
|
| 1813 |
+
"value": "35",
|
| 1814 |
+
"units": "oysters per day",
|
| 1815 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1816 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1817 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1818 |
+
"supporting_chunk_id": "",
|
| 1819 |
+
"supporting_snippet": "Oysters, all: 35 oysters, year round (CCR T14 29.05).",
|
| 1820 |
+
"validation_set": false
|
| 1821 |
+
},
|
| 1822 |
+
{
|
| 1823 |
+
"id": "ghost_shrimp_bag_limit",
|
| 1824 |
+
"species_or_category": "ghost_shrimp",
|
| 1825 |
+
"display_name": "Ghost shrimp and blue mud shrimp",
|
| 1826 |
+
"aliases": [
|
| 1827 |
+
"ghost shrimp",
|
| 1828 |
+
"mud shrimp",
|
| 1829 |
+
"blue mud shrimp"
|
| 1830 |
+
],
|
| 1831 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1832 |
+
"fact_type": "bag_limit",
|
| 1833 |
+
"value": "50",
|
| 1834 |
+
"units": "in combination per day",
|
| 1835 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1836 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1837 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1838 |
+
"supporting_chunk_id": "",
|
| 1839 |
+
"supporting_snippet": "Ghost shrimp and blue mud shrimp limit: fifty in combination (CCR T14 29.87).",
|
| 1840 |
+
"validation_set": false
|
| 1841 |
+
},
|
| 1842 |
+
{
|
| 1843 |
+
"id": "coonstripe_shrimp_bag_limit",
|
| 1844 |
+
"species_or_category": "coonstripe_shrimp",
|
| 1845 |
+
"display_name": "Coonstripe shrimp",
|
| 1846 |
+
"aliases": [
|
| 1847 |
+
"coonstripe shrimp"
|
| 1848 |
+
],
|
| 1849 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1850 |
+
"fact_type": "bag_limit",
|
| 1851 |
+
"value": "20",
|
| 1852 |
+
"units": "pounds in the shell, heads on, per day",
|
| 1853 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1854 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1855 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1856 |
+
"supporting_chunk_id": "",
|
| 1857 |
+
"supporting_snippet": "Coonstripe shrimp: twenty pounds (in the shell, heads on) (CCR T14 29.88).",
|
| 1858 |
+
"validation_set": false
|
| 1859 |
+
},
|
| 1860 |
+
{
|
| 1861 |
+
"id": "sand_crab_bag_limit",
|
| 1862 |
+
"species_or_category": "sand_crab",
|
| 1863 |
+
"display_name": "Sand crab (mole crab)",
|
| 1864 |
+
"aliases": [
|
| 1865 |
+
"sand crab",
|
| 1866 |
+
"mole crab",
|
| 1867 |
+
"sand crabs"
|
| 1868 |
+
],
|
| 1869 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1870 |
+
"fact_type": "bag_limit",
|
| 1871 |
+
"value": "50",
|
| 1872 |
+
"units": "per day",
|
| 1873 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1874 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1875 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1876 |
+
"supporting_chunk_id": "",
|
| 1877 |
+
"supporting_snippet": "Sand crabs (Emerita analoga): fifty (CCR T14 29.85).",
|
| 1878 |
+
"validation_set": false
|
| 1879 |
+
},
|
| 1880 |
+
{
|
| 1881 |
+
"id": "gaper_clam_bag_limit",
|
| 1882 |
+
"species_or_category": "gaper_clam",
|
| 1883 |
+
"display_name": "Gaper clam (horseneck clam)",
|
| 1884 |
+
"aliases": [
|
| 1885 |
+
"gaper clam",
|
| 1886 |
+
"horseneck clam",
|
| 1887 |
+
"horse clam"
|
| 1888 |
+
],
|
| 1889 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1890 |
+
"fact_type": "bag_limit",
|
| 1891 |
+
"value": "10",
|
| 1892 |
+
"units": "of each species per day (Humboldt Bay: 50 in combination, max 25 gaper; Elkhorn Slough: 12 in combination)",
|
| 1893 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1894 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1895 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1896 |
+
"supporting_chunk_id": "",
|
| 1897 |
+
"supporting_snippet": "Gaper/horseneck clams: limit ten of each species, except in Humboldt Bay fifty in combination (no more than 25 gaper) and in Elkhorn Slough twelve in combination (CCR T14 29.25).",
|
| 1898 |
+
"validation_set": false
|
| 1899 |
+
},
|
| 1900 |
+
{
|
| 1901 |
+
"id": "geoduck_clam_bag_limit",
|
| 1902 |
+
"species_or_category": "geoduck_clam",
|
| 1903 |
+
"display_name": "Geoduck clam",
|
| 1904 |
+
"aliases": [
|
| 1905 |
+
"geoduck",
|
| 1906 |
+
"geoduck clam"
|
| 1907 |
+
],
|
| 1908 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1909 |
+
"fact_type": "bag_limit",
|
| 1910 |
+
"value": "3",
|
| 1911 |
+
"units": "clams per day",
|
| 1912 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1913 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1914 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1915 |
+
"supporting_chunk_id": "",
|
| 1916 |
+
"supporting_snippet": "Geoduck clams: limit three; the first three dug must be retained as the bag limit (CCR T14 29.30).",
|
| 1917 |
+
"validation_set": false
|
| 1918 |
+
},
|
| 1919 |
+
{
|
| 1920 |
+
"id": "pismo_clam_min_size",
|
| 1921 |
+
"species_or_category": "pismo_clam",
|
| 1922 |
+
"display_name": "Pismo clam",
|
| 1923 |
+
"aliases": [
|
| 1924 |
+
"pismo clam"
|
| 1925 |
+
],
|
| 1926 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1927 |
+
"fact_type": "minimum_size",
|
| 1928 |
+
"value": "5",
|
| 1929 |
+
"units": "inches greatest diameter",
|
| 1930 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1931 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1932 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1933 |
+
"supporting_chunk_id": "",
|
| 1934 |
+
"supporting_snippet": "Pismo clams: minimum size five inches in greatest diameter; limit ten (CCR T14 29.40).",
|
| 1935 |
+
"validation_set": false
|
| 1936 |
+
},
|
| 1937 |
+
{
|
| 1938 |
+
"id": "pismo_clam_bag_limit",
|
| 1939 |
+
"species_or_category": "pismo_clam",
|
| 1940 |
+
"display_name": "Pismo clam",
|
| 1941 |
+
"aliases": [
|
| 1942 |
+
"pismo clam"
|
| 1943 |
+
],
|
| 1944 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1945 |
+
"fact_type": "bag_limit",
|
| 1946 |
+
"value": "10",
|
| 1947 |
+
"units": "clams per day",
|
| 1948 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1949 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1950 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1951 |
+
"supporting_chunk_id": "",
|
| 1952 |
+
"supporting_snippet": "Pismo clams: limit ten (CCR T14 29.40).",
|
| 1953 |
+
"validation_set": false
|
| 1954 |
+
},
|
| 1955 |
+
{
|
| 1956 |
+
"id": "razor_clam_bag_limit",
|
| 1957 |
+
"species_or_category": "razor_clam",
|
| 1958 |
+
"display_name": "Razor clam",
|
| 1959 |
+
"aliases": [
|
| 1960 |
+
"razor clam",
|
| 1961 |
+
"razor clams"
|
| 1962 |
+
],
|
| 1963 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1964 |
+
"fact_type": "bag_limit",
|
| 1965 |
+
"value": "20",
|
| 1966 |
+
"units": "clams per day (the first 20 dug must be retained regardless of size or condition)",
|
| 1967 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1968 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1969 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1970 |
+
"supporting_chunk_id": "",
|
| 1971 |
+
"supporting_snippet": "Razor clams: limit twenty; the first twenty dug must be retained as the bag limit (CCR T14 29.45).",
|
| 1972 |
+
"validation_set": false
|
| 1973 |
+
},
|
| 1974 |
+
{
|
| 1975 |
+
"id": "littleneck_clam_bag_limit",
|
| 1976 |
+
"species_or_category": "littleneck_clam",
|
| 1977 |
+
"display_name": "Littleneck, cockle, and softshell clams",
|
| 1978 |
+
"aliases": [
|
| 1979 |
+
"littleneck clam",
|
| 1980 |
+
"cockle",
|
| 1981 |
+
"softshell clam",
|
| 1982 |
+
"soft-shell clam",
|
| 1983 |
+
"quahog",
|
| 1984 |
+
"chione"
|
| 1985 |
+
],
|
| 1986 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 1987 |
+
"fact_type": "bag_limit",
|
| 1988 |
+
"value": "50",
|
| 1989 |
+
"units": "in combination per day (minimum size 1½ inches, except no size limit for soft-shell clams)",
|
| 1990 |
+
"source_id": "cdfw_ocean_regulations",
|
| 1991 |
+
"source_url": "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs",
|
| 1992 |
+
"retrieved_at": "2026-06-15T12:00:00+00:00",
|
| 1993 |
+
"supporting_chunk_id": "",
|
| 1994 |
+
"supporting_snippet": "Littleneck clams, soft-shell clams, chiones, northern quahogs and cockles: limit fifty in combination; minimum size one and one-half inches, except no size limit for soft-shell clams (CCR T14 29.35).",
|
| 1995 |
+
"validation_set": false
|
| 1996 |
}
|
| 1997 |
],
|
| 1998 |
"advisory_facts": [
|
|
@@ -1,3 +1,5 @@
|
|
| 1 |
gradio>=6.0
|
| 2 |
pytest>=8.0
|
| 3 |
Pillow>=10.0
|
|
|
|
|
|
|
|
|
| 1 |
gradio>=6.0
|
| 2 |
pytest>=8.0
|
| 3 |
Pillow>=10.0
|
| 4 |
+
# Optional: in-process MiniCPM GGUF interpreter (COASTWISE_LOCAL_MODEL=1)
|
| 5 |
+
llama-cpp-python>=0.3
|
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-shot seed expansion: append source-verified structured facts.
|
| 2 |
+
|
| 3 |
+
Every value here was verified against the cited official CDFW/CCR page during
|
| 4 |
+
the 2026-06-15 research pass. species_or_category uses the curated RULE_CARD key
|
| 5 |
+
so both the model and deterministic interpreters resolve to it.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
FACTS_PATH = Path("data/source-cache/seed/facts.json")
|
| 12 |
+
|
| 13 |
+
GROUNDFISH = "cdfw_groundfish_summary"
|
| 14 |
+
GROUNDFISH_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Groundfish-Summary"
|
| 15 |
+
SF = "cdfw_region_san_francisco"
|
| 16 |
+
SF_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/San-Francisco"
|
| 17 |
+
OCEAN = "cdfw_ocean_regulations"
|
| 18 |
+
GENERAL_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs"
|
| 19 |
+
INVERT_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs"
|
| 20 |
+
ABALONE_URL = "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Abalone"
|
| 21 |
+
SALMON_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Salmon"
|
| 22 |
+
PHALIBUT_URL = "https://wildlife.ca.gov/Conservation/Marine/Pacific-Halibut"
|
| 23 |
+
CRABS = "cdfw_crabs"
|
| 24 |
+
CRABS_URL = "https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs"
|
| 25 |
+
NOW = "2026-06-15T12:00:00+00:00"
|
| 26 |
+
|
| 27 |
+
RCG_SEASON = (
|
| 28 |
+
"open from April 1 through December 31 at all depths, and closed January 1 "
|
| 29 |
+
"through March 31. These dates apply to the Northern, Mendocino, San Francisco, "
|
| 30 |
+
"and Central management areas (the Southern area differs). Shore-based anglers "
|
| 31 |
+
"and spear divers are exempt from seasons and depth limits. Verify current "
|
| 32 |
+
"in-season depth changes."
|
| 33 |
+
)
|
| 34 |
+
RCG_SEASON_QUOTE = (
|
| 35 |
+
"Jan 1 - Mar 31: Closed - unlawful to possess in all waters; Apr 1 - Dec 31: "
|
| 36 |
+
"Open all depths. Shore-based anglers and spear divers are exempt from seasons "
|
| 37 |
+
"and depths."
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def fact(
|
| 42 |
+
id, species, display, fact_type, value, *, units=None, source_id, source_url,
|
| 43 |
+
snippet, chunk="", region_key=None, aliases=(), eff_start=None, eff_end=None,
|
| 44 |
+
):
|
| 45 |
+
entry = {
|
| 46 |
+
"id": id,
|
| 47 |
+
"species_or_category": species,
|
| 48 |
+
"display_name": display,
|
| 49 |
+
"aliases": list(aliases),
|
| 50 |
+
"area_or_scope": "California ocean sport fishing regulations",
|
| 51 |
+
"fact_type": fact_type,
|
| 52 |
+
"value": value,
|
| 53 |
+
"units": units,
|
| 54 |
+
"source_id": source_id,
|
| 55 |
+
"source_url": source_url,
|
| 56 |
+
"retrieved_at": NOW,
|
| 57 |
+
"supporting_chunk_id": chunk,
|
| 58 |
+
"supporting_snippet": snippet,
|
| 59 |
+
"validation_set": True,
|
| 60 |
+
}
|
| 61 |
+
if region_key:
|
| 62 |
+
entry["region_key"] = region_key
|
| 63 |
+
if eff_start:
|
| 64 |
+
entry["effective_start"] = eff_start
|
| 65 |
+
if eff_end:
|
| 66 |
+
entry["effective_end"] = eff_end
|
| 67 |
+
return entry
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
NEW_FACTS = [
|
| 71 |
+
# --- RCG complex groundfish: rockfish + greenlings (cabezon/lingcod already seeded) ---
|
| 72 |
+
fact("rockfish_min_size", "rockfish", "Rockfish", "minimum_size", "no minimum size limit",
|
| 73 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, aliases=["rcg complex", "rock fish"],
|
| 74 |
+
snippet="Rockfishes (RCG Complex): No minimum size limit."),
|
| 75 |
+
fact("rockfish_bag_limit", "rockfish", "Rockfish", "bag_limit", "10",
|
| 76 |
+
units="fish per person in the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate, with sub-limits: copper rockfish 1; vermilion/sunset rockfish 4 in the Northern Management Area and 2 elsewhere; canary rockfish 2",
|
| 77 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL,
|
| 78 |
+
snippet="10 fish in combination per person, except: Copper rockfish: 1 fish per person; Vermilion/sunset rockfish: 4 (Northern) / 2 (other areas); Canary rockfish: 2 fish per person."),
|
| 79 |
+
fact("greenlings_min_size", "greenlings", "Kelp and rock greenling", "minimum_size", "no minimum size limit",
|
| 80 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, aliases=["greenling", "kelp greenling", "rock greenling"],
|
| 81 |
+
snippet="Greenlings (RCG Complex): No minimum size limit."),
|
| 82 |
+
fact("greenlings_bag_limit", "greenlings", "Kelp and rock greenling", "bag_limit", "10",
|
| 83 |
+
units="fish per person in the Rockfish, Cabezon, and Greenling (RCG) Complex aggregate",
|
| 84 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, aliases=["greenling"],
|
| 85 |
+
snippet="Greenlings are part of the RCG Complex aggregate of 10 fish in combination per person."),
|
| 86 |
+
# --- RCG complex season, scoped to the SF management area ---
|
| 87 |
+
fact("cabezon_season_sf", "cabezon", "Cabezon", "season", RCG_SEASON, region_key="san_francisco",
|
| 88 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, snippet=RCG_SEASON_QUOTE,
|
| 89 |
+
eff_start="2026-04-01", eff_end="2026-12-31"),
|
| 90 |
+
fact("lingcod_season_sf", "lingcod", "Lingcod", "season", RCG_SEASON, region_key="san_francisco",
|
| 91 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, snippet=RCG_SEASON_QUOTE,
|
| 92 |
+
eff_start="2026-04-01", eff_end="2026-12-31", aliases=["ling cod"]),
|
| 93 |
+
fact("rockfish_season_sf", "rockfish", "Rockfish", "season", RCG_SEASON, region_key="san_francisco",
|
| 94 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, snippet=RCG_SEASON_QUOTE,
|
| 95 |
+
eff_start="2026-04-01", eff_end="2026-12-31"),
|
| 96 |
+
fact("greenlings_season_sf", "greenlings", "Kelp and rock greenling", "season", RCG_SEASON, region_key="san_francisco",
|
| 97 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL, snippet=RCG_SEASON_QUOTE,
|
| 98 |
+
eff_start="2026-04-01", eff_end="2026-12-31"),
|
| 99 |
+
# --- No-retention rockfish exceptions (statewide closures) ---
|
| 100 |
+
fact("yelloweye_rockfish_no_retention", "yelloweye_rockfish", "Yelloweye rockfish", "closure",
|
| 101 |
+
"No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 102 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL,
|
| 103 |
+
snippet="Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed in California - no retention at any time, zero fish per person."),
|
| 104 |
+
fact("quillback_rockfish_no_retention", "quillback_rockfish", "Quillback rockfish", "closure",
|
| 105 |
+
"No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 106 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL,
|
| 107 |
+
snippet="Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person."),
|
| 108 |
+
fact("cowcod_no_retention", "cowcod", "Cowcod", "closure",
|
| 109 |
+
"No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 110 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL,
|
| 111 |
+
snippet="Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person."),
|
| 112 |
+
fact("bronzespotted_rockfish_no_retention", "bronzespotted_rockfish", "Bronzespotted rockfish", "closure",
|
| 113 |
+
"No retention at any time; may not be taken or possessed (zero fish per person).",
|
| 114 |
+
source_id=GROUNDFISH, source_url=GROUNDFISH_URL,
|
| 115 |
+
snippet="Bronzespotted Rockfish, Cowcod, Quillback Rockfish, and Yelloweye Rockfish may not be taken or possessed - no retention at any time, zero fish per person."),
|
| 116 |
+
# --- Flatfish & smelt: no daily bag limit (CCR 27.60(b)) ---
|
| 117 |
+
fact("flatfish_bag_limit", "flatfish", "Pacific sanddab and other flatfish", "bag_limit", "no daily bag limit",
|
| 118 |
+
units="(sanddabs, petrale sole, and starry flounder)", source_id=OCEAN, source_url=GENERAL_URL,
|
| 119 |
+
aliases=["sanddab", "petrale sole", "starry flounder"],
|
| 120 |
+
snippet="There is no limit on the following species: ... sanddabs ... petrale sole and starry flounder. (CCR T14 27.60(b))"),
|
| 121 |
+
fact("jacksmelt_topsmelt_bag_limit", "jacksmelt_topsmelt", "Jacksmelt and topsmelt", "bag_limit", "no daily bag limit",
|
| 122 |
+
units="(jacksmelt and topsmelt)", source_id=OCEAN, source_url=GENERAL_URL,
|
| 123 |
+
snippet="There is no limit on the following species: ... jacksmelt, topsmelt ... (CCR T14 27.60(b))"),
|
| 124 |
+
# --- California halibut ---
|
| 125 |
+
fact("california_halibut_min_size", "california_halibut", "California halibut", "minimum_size", "22",
|
| 126 |
+
units="inches total length", source_id=SF, source_url=SF_URL, aliases=["halibut", "ca halibut"],
|
| 127 |
+
snippet="California halibut ... 22 inches total length."),
|
| 128 |
+
fact("california_halibut_bag_limit", "california_halibut", "California halibut", "bag_limit", "2",
|
| 129 |
+
units="fish (north of Point Sur, which includes the San Francisco area; the limit differs south of Point Sur)",
|
| 130 |
+
source_id=SF, source_url=SF_URL,
|
| 131 |
+
snippet="California halibut ... two fish north of Point Sur ... remains open year-round."),
|
| 132 |
+
# --- Pacific halibut ---
|
| 133 |
+
fact("pacific_halibut_min_size", "pacific_halibut", "Pacific halibut", "minimum_size", "no minimum size limit",
|
| 134 |
+
source_id=SF, source_url=PHALIBUT_URL,
|
| 135 |
+
snippet="There is no minimum size limit. The daily bag and possession limit for Pacific halibut is one fish."),
|
| 136 |
+
fact("pacific_halibut_bag_limit", "pacific_halibut", "Pacific halibut", "bag_limit", "1", units="fish per day",
|
| 137 |
+
source_id=SF, source_url=PHALIBUT_URL,
|
| 138 |
+
snippet="The daily bag and possession limit for Pacific halibut is one fish."),
|
| 139 |
+
fact("pacific_halibut_season", "pacific_halibut", "Pacific halibut", "season",
|
| 140 |
+
"open from April 1 for the 2026 season; it stays open until November 15 north of Point Arena and through December 31 south of Point Arena, or until the quota is reached. Verify current status.",
|
| 141 |
+
source_id=SF, source_url=PHALIBUT_URL, eff_start="2026-04-01",
|
| 142 |
+
snippet="The 2026 recreational Pacific halibut fishery opens April 1. North of Point Arena the fishery is open until November 15; south of Point Arena it is open through December 31."),
|
| 143 |
+
# --- White seabass ---
|
| 144 |
+
fact("white_seabass_min_size", "white_seabass", "White seabass", "minimum_size", "28",
|
| 145 |
+
units="inches total length (or 20 inches alternate length)", source_id=SF, source_url=SF_URL,
|
| 146 |
+
aliases=["seabass", "white sea bass"],
|
| 147 |
+
snippet="White Seabass ... 28 inches total length or 20 inches alternate length."),
|
| 148 |
+
fact("white_seabass_bag_limit", "white_seabass", "White seabass", "bag_limit", "3",
|
| 149 |
+
units="fish (only one fish may be taken south of Point Conception between March 15 and June 15)",
|
| 150 |
+
source_id=SF, source_url=SF_URL,
|
| 151 |
+
snippet="White Seabass ... three fish except that only one fish may be taken in waters south of Point Conception between March 15 and June 15."),
|
| 152 |
+
# --- Surfperch (redtail size + general bag) ---
|
| 153 |
+
fact("surfperch_min_size", "surfperch", "Surfperch", "minimum_size", "10½",
|
| 154 |
+
units="inches total length for redtail surfperch (most other surfperch have no minimum size limit)",
|
| 155 |
+
source_id=SF, source_url=SF_URL, aliases=["surf perch", "redtail surfperch"],
|
| 156 |
+
snippet="Redtail Surfperch ... Minimum Size: 10½ inches total length."),
|
| 157 |
+
fact("surfperch_bag_limit", "surfperch", "Surfperch", "bag_limit", "20",
|
| 158 |
+
units="fish in combination of all species, with no more than 10 of any one species; shiner surfperch have a separate limit of 20",
|
| 159 |
+
source_id=SF, source_url=SF_URL,
|
| 160 |
+
snippet="20 fish in combination of all species (except shiner perch), with not more than 10 fish of any one species ... Shiner Perch: separate bag and possession limit of 20 fish."),
|
| 161 |
+
# --- Sharks ---
|
| 162 |
+
fact("leopard_shark_min_size", "leopard_shark", "Leopard shark", "minimum_size", "36",
|
| 163 |
+
units="inches total length", source_id=SF, source_url=SF_URL,
|
| 164 |
+
snippet="Leopard Shark ... 36 inches total length ... open year-round, at all depths."),
|
| 165 |
+
fact("leopard_shark_bag_limit", "leopard_shark", "Leopard shark", "bag_limit", "3", units="fish",
|
| 166 |
+
source_id=SF, source_url=SF_URL,
|
| 167 |
+
snippet="Leopard Shark ... 3 fish ... open year-round, at all depths."),
|
| 168 |
+
fact("state_managed_sharks_bag_limit", "state_managed_sharks", "State-managed sharks", "bag_limit", "2",
|
| 169 |
+
units="fish per day for shortfin mako, common thresher, and blue shark (no size limit); sixgill and broadnose sevengill shark are limited to 1 fish per day",
|
| 170 |
+
source_id=SF, source_url=SF_URL, aliases=["sharks", "sevengill shark", "mako shark"],
|
| 171 |
+
snippet="The bag limits for shortfin mako shark, common thresher shark, and blue shark allow take of two fish per day with no size limit. The bag limits for sixgill shark and broadnose sevengill shark allow take of one fish per day with no size limit."),
|
| 172 |
+
# --- Sturgeon (retention prohibited) ---
|
| 173 |
+
fact("white_sturgeon_retention", "white_sturgeon", "White sturgeon", "closure",
|
| 174 |
+
"Catch-and-release only; no retention permitted (zero fish). White sturgeon greater than 60 inches fork length may not be removed from the water.",
|
| 175 |
+
source_id=SF, source_url=SF_URL,
|
| 176 |
+
snippet="The White Sturgeon sport fishery is catch-and-release only. No harvest is permitted. White sturgeon greater than 60 inches fork length may not be removed from the water."),
|
| 177 |
+
fact("green_sturgeon_retention", "green_sturgeon", "Green sturgeon", "closure",
|
| 178 |
+
"Fully protected; no take. Green sturgeon must be released immediately and may not be removed from the water.",
|
| 179 |
+
source_id=SF, source_url=SF_URL,
|
| 180 |
+
snippet="Green Sturgeon ... may not be removed from the water and must be released immediately."),
|
| 181 |
+
# --- Pacific herring ---
|
| 182 |
+
fact("pacific_herring_bag_limit", "pacific_herring", "Pacific herring", "bag_limit", "10",
|
| 183 |
+
units="gallons per day (approximately 100 lb or 520 fish)", source_id=SF, source_url=SF_URL,
|
| 184 |
+
aliases=["herring"],
|
| 185 |
+
snippet="Open year-round. Ten gallons of Pacific herring may be taken per day (approximately 100 lb. or 520 fish)."),
|
| 186 |
+
# --- Tunas (year-round; numeric limits not fabricated) ---
|
| 187 |
+
fact("tunas_season", "tunas", "Tunas", "season",
|
| 188 |
+
"open year-round. Bag, possession, and size limits apply - verify current CDFW ocean sport fishing regulations.",
|
| 189 |
+
source_id=SF, source_url=SF_URL, aliases=["tuna", "albacore"],
|
| 190 |
+
snippet="The recreational fishery for tunas is open year-round."),
|
| 191 |
+
# --- Rock crab ---
|
| 192 |
+
fact("rock_crab_bag_limit", "rock_crab", "Rock crab", "bag_limit", "35", units="crab per day",
|
| 193 |
+
source_id=CRABS, source_url=SF_URL, aliases=["red rock crab", "yellow rock crab", "brown rock crab"],
|
| 194 |
+
snippet="The recreational fishery for all rock crab species is open year-round, statewide. The daily bag limit is 35 crab."),
|
| 195 |
+
fact("rock_crab_min_size", "rock_crab", "Rock crab", "minimum_size", "4", units="inches",
|
| 196 |
+
source_id=CRABS, source_url=SF_URL,
|
| 197 |
+
snippet="For all rock crab species ... the minimum size limit is 4 inches."),
|
| 198 |
+
fact("rock_crab_season", "rock_crab", "Rock crab", "season", "open year-round, statewide.",
|
| 199 |
+
source_id=CRABS, source_url=SF_URL,
|
| 200 |
+
snippet="The recreational fishery for all rock crab species is open year-round, statewide."),
|
| 201 |
+
# --- Dungeness crab bag + size (recreational) ---
|
| 202 |
+
fact("dungeness_crab_daily_bag", "dungeness_crab", "Dungeness crab", "bag_limit", "10", units="crab per day",
|
| 203 |
+
source_id=CRABS, source_url=SF_URL, aliases=["dungeness crabs"],
|
| 204 |
+
snippet="Dungeness crab: the daily bag limit is 10 crab."),
|
| 205 |
+
fact("dungeness_crab_min_size", "dungeness_crab", "Dungeness crab", "minimum_size", "5¾", units="inches",
|
| 206 |
+
source_id=CRABS, source_url=SF_URL, aliases=["dungeness crabs"],
|
| 207 |
+
snippet="Dungeness crab: the minimum size limit is 5¾ inches."),
|
| 208 |
+
# --- Dungeness crab season, SF / north-central area ---
|
| 209 |
+
fact("dungeness_crab_season_sf", "dungeness_crab", "Dungeness crab", "season",
|
| 210 |
+
"open from November 1, 2025 through June 30, 2026 in waters south of the Sonoma/Mendocino county line, which includes the San Francisco area. North of that line the season is open from November 1, 2025 through July 30, 2026. The 2025-26 opener was delayed by domoic-acid and whale-entanglement closures; verify current CDFW openers and health advisories before harvest.",
|
| 211 |
+
region_key="san_francisco", source_id=CRABS, source_url=SF_URL,
|
| 212 |
+
eff_start="2025-11-01", eff_end="2026-06-30", aliases=["dungeness crabs"],
|
| 213 |
+
snippet="Dungeness Crab (south of the Sonoma/Mendocino county line): Open from November 1, 2025 through June 30, 2026."),
|
| 214 |
+
# --- Ocean salmon, SF zone 2026 ---
|
| 215 |
+
fact("ocean_salmon_season_sf", "ocean_salmon", "Ocean salmon", "season",
|
| 216 |
+
"open June 27 - July 22 and August 1 - 31 in the San Francisco zone (Point Arena to Pigeon Point) for the 2026 season; minimum size 20 inches total length (24 inches through May 15). Dates vary by zone and can change in-season; verify current CDFW salmon regulations.",
|
| 217 |
+
region_key="san_francisco", source_id=OCEAN, source_url=SALMON_URL, aliases=["salmon"],
|
| 218 |
+
eff_start="2026-06-27",
|
| 219 |
+
snippet="Point Arena to Pigeon Point (San Francisco): June 27 - July 22, August 1 - 31."),
|
| 220 |
+
# --- Red abalone (closed) ---
|
| 221 |
+
fact("red_abalone_closure", "red_abalone", "Red abalone", "closure",
|
| 222 |
+
"The recreational red abalone fishery is closed; the closure was extended to 2036. All ocean waters are closed to the take of abalone (CCR T14 29.15).",
|
| 223 |
+
source_id=OCEAN, source_url=ABALONE_URL, aliases=["abalone"],
|
| 224 |
+
snippet="The poor condition of red abalone populations led the California Fish and Game Commission to close the fishery in 2018 ... the closure was extended to 2036 at the December, 2025 Commission meeting."),
|
| 225 |
+
# --- Purple sea urchin ---
|
| 226 |
+
fact("purple_urchin_bag_limit", "purple_urchin", "Purple sea urchin", "bag_limit", "35",
|
| 227 |
+
units="sea urchins per species per day (40 gallons when skin or SCUBA diving in Humboldt, Mendocino, and Sonoma counties); no possession limit (CCR T14 29.06)",
|
| 228 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["sea urchin", "purple urchin"],
|
| 229 |
+
snippet="The daily bag limit for sea urchin is 35 individuals for each species. The daily bag limit for purple sea urchin is forty (40) gallons when taken while skin or SCUBA diving in ocean waters of Humboldt, Mendocino, and Sonoma counties."),
|
| 230 |
+
# --- Kelp / marine plants ---
|
| 231 |
+
fact("kelp_plants_bag_limit", "kelp_plants", "Kelp and marine aquatic plants", "bag_limit", "10",
|
| 232 |
+
units="pounds wet weight in the aggregate per day for marine aquatic plants for which take is authorized (CCR T14 30.00)",
|
| 233 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["kelp", "seaweed", "marine plants"],
|
| 234 |
+
snippet="The daily bag limit on all marine aquatic plants for which the take is authorized ... is 10 pounds wet weight in the aggregate."),
|
| 235 |
+
# --- Protected plants & fish ---
|
| 236 |
+
fact("sea_palm_closure", "sea_palm", "Sea palm", "closure",
|
| 237 |
+
"Sea palm may not be cut or disturbed (protected; CCR T14 30.10).",
|
| 238 |
+
source_id=OCEAN, source_url=INVERT_URL,
|
| 239 |
+
snippet="No eel grass (Zostera), surf grass (Phyllospadix) or sea palm (Postelsia) may be cut or disturbed."),
|
| 240 |
+
fact("eelgrass_surfgrass_closure", "eelgrass_surfgrass", "Eelgrass and surfgrass", "closure",
|
| 241 |
+
"Eelgrass and surfgrass may not be cut or disturbed (protected habitat; CCR T14 30.10).",
|
| 242 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["eelgrass", "surfgrass"],
|
| 243 |
+
snippet="No eel grass (Zostera), surf grass (Phyllospadix) or sea palm (Postelsia) may be cut or disturbed."),
|
| 244 |
+
fact("garibaldi_closure", "garibaldi", "Garibaldi", "closure",
|
| 245 |
+
"Garibaldi may not be taken or possessed (protected; CCR T14 28.05).",
|
| 246 |
+
source_id=OCEAN, source_url=GENERAL_URL,
|
| 247 |
+
snippet="Garibaldi. May not be taken or possessed."),
|
| 248 |
+
# Note: clams (and mussels) intentionally have no structured bag/size fact -
|
| 249 |
+
# clam/mussel questions route to the CDPH shellfish advisory, which is the
|
| 250 |
+
# correct food-safety answer; a bag-limit fact would be unreachable.
|
| 251 |
+
]
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def main():
|
| 255 |
+
data = json.loads(FACTS_PATH.read_text())
|
| 256 |
+
by_id = {f["id"]: i for i, f in enumerate(data["structured_facts"])}
|
| 257 |
+
added = updated = 0
|
| 258 |
+
for entry in NEW_FACTS:
|
| 259 |
+
if entry["id"] in by_id:
|
| 260 |
+
data["structured_facts"][by_id[entry["id"]]] = entry
|
| 261 |
+
updated += 1
|
| 262 |
+
else:
|
| 263 |
+
data["structured_facts"].append(entry)
|
| 264 |
+
added += 1
|
| 265 |
+
FACTS_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
| 266 |
+
print(f"added {added}, updated {updated}; total now {len(data['structured_facts'])}")
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
if __name__ == "__main__":
|
| 270 |
+
main()
|
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase-2 seed expansion: statewide CA ocean species (esp. Southern + inverts).
|
| 2 |
+
|
| 3 |
+
Every value verified against the cited CDFW page or CCR Title 14 during the
|
| 4 |
+
2026-06-15 research pass. Species without a curated rule card are detected via
|
| 5 |
+
the fact alias index, so facts alone make them answerable.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
FACTS_PATH = Path("data/source-cache/seed/facts.json")
|
| 12 |
+
|
| 13 |
+
SOUTH = "cdfw_region_southern"
|
| 14 |
+
SOUTH_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Fishing-Map/Southern"
|
| 15 |
+
OCEAN = "cdfw_ocean_regulations"
|
| 16 |
+
GENERAL_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/General-Ocean-Fishing-Regs"
|
| 17 |
+
INVERT_URL = "https://wildlife.ca.gov/Fishing/Ocean/Regulations/Sport-Fishing/Invertebrate-Fishing-Regs"
|
| 18 |
+
NOW = "2026-06-15T12:00:00+00:00"
|
| 19 |
+
|
| 20 |
+
GENERAL_BAG = "10"
|
| 21 |
+
GENERAL_BAG_UNITS = (
|
| 22 |
+
"fish of any one species per day, within the general limit of 20 finfish in "
|
| 23 |
+
"combination of all species (no species-specific minimum size limit; CCR T14 27.60)"
|
| 24 |
+
)
|
| 25 |
+
NO_TAKE = "Take and possession prohibited in California ocean waters; may not be taken or possessed."
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def f(id, species, display, fact_type, value, *, units=None, source_id, source_url,
|
| 29 |
+
snippet, aliases=(), region_key=None, eff_start=None, eff_end=None):
|
| 30 |
+
entry = {
|
| 31 |
+
"id": id, "species_or_category": species, "display_name": display,
|
| 32 |
+
"aliases": list(aliases), "area_or_scope": "California ocean sport fishing regulations",
|
| 33 |
+
"fact_type": fact_type, "value": value, "units": units,
|
| 34 |
+
"source_id": source_id, "source_url": source_url, "retrieved_at": NOW,
|
| 35 |
+
"supporting_chunk_id": "", "supporting_snippet": snippet, "validation_set": False,
|
| 36 |
+
}
|
| 37 |
+
if region_key:
|
| 38 |
+
entry["region_key"] = region_key
|
| 39 |
+
if eff_start:
|
| 40 |
+
entry["effective_start"] = eff_start
|
| 41 |
+
if eff_end:
|
| 42 |
+
entry["effective_end"] = eff_end
|
| 43 |
+
return entry
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def general_limit(key, display, aliases):
|
| 47 |
+
return f(f"{key}_bag_limit", key, display, "bag_limit", GENERAL_BAG, units=GENERAL_BAG_UNITS,
|
| 48 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=aliases,
|
| 49 |
+
snippet="No more than 20 finfish in combination of all species, with not more than 10 of any one species, may be taken or possessed (CCR T14 27.60).")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def no_limit(key, display, aliases):
|
| 53 |
+
return f(f"{key}_bag_limit", key, display, "bag_limit", "no daily bag limit", units="(CCR T14 27.60(b))",
|
| 54 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=aliases,
|
| 55 |
+
snippet="There is no limit on the following species: anchovy, jacksmelt, topsmelt, Pacific butterfish (pompano), queenfish, sanddabs, skipjack, jack mackerel, Pacific mackerel, ... Pacific sardine ... (CCR T14 27.60(b)).")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def no_take(key, display, aliases, source_url=GENERAL_URL, source_id=OCEAN, snippet=None):
|
| 59 |
+
return f(f"{key}_closure", key, display, "closure", NO_TAKE, source_id=source_id, source_url=source_url,
|
| 60 |
+
aliases=aliases, snippet=snippet or f"{display}: may not be taken or possessed.")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
NEW = [
|
| 64 |
+
# --- Southern California bass complex (CCR 28.30 / Southern page) ---
|
| 65 |
+
f("kelp_bass_min_size", "kelp_bass", "Kelp bass", "minimum_size", "14", units="inches total length",
|
| 66 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["calico bass", "calico", "kelp bass"],
|
| 67 |
+
snippet="Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length."),
|
| 68 |
+
f("kelp_bass_bag_limit", "kelp_bass", "Kelp bass", "bag_limit", "5",
|
| 69 |
+
units="fish in any combination of kelp, barred sand, and spotted sand bass per day",
|
| 70 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["calico bass"],
|
| 71 |
+
snippet="Five fish in any combination of species (kelp bass, barred sand bass, spotted sand bass)."),
|
| 72 |
+
f("barred_sand_bass_min_size", "barred_sand_bass", "Barred sand bass", "minimum_size", "14", units="inches total length",
|
| 73 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["sand bass"],
|
| 74 |
+
snippet="Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length."),
|
| 75 |
+
f("barred_sand_bass_bag_limit", "barred_sand_bass", "Barred sand bass", "bag_limit", "5",
|
| 76 |
+
units="fish in any combination of kelp/barred sand/spotted sand bass, of which no more than 4 may be barred sand bass",
|
| 77 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["sand bass"],
|
| 78 |
+
snippet="Five fish in any combination of species, except no more than 4 barred sand bass may be taken. The minimum size limit is 14 inches."),
|
| 79 |
+
f("spotted_sand_bass_min_size", "spotted_sand_bass", "Spotted sand bass", "minimum_size", "14", units="inches total length",
|
| 80 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["spotted bay bass", "spotty"],
|
| 81 |
+
snippet="Kelp bass, barred sand bass and spotted sand bass: the minimum size limit is 14 inches total length."),
|
| 82 |
+
f("spotted_sand_bass_bag_limit", "spotted_sand_bass", "Spotted sand bass", "bag_limit", "5",
|
| 83 |
+
units="fish in any combination of kelp/barred sand/spotted sand bass per day",
|
| 84 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["spotted bay bass"],
|
| 85 |
+
snippet="Five fish in any combination of species (kelp bass, barred sand bass, spotted sand bass)."),
|
| 86 |
+
# --- California sheephead (Southern page) ---
|
| 87 |
+
f("california_sheephead_min_size", "california_sheephead", "California sheephead", "minimum_size", "12", units="inches total length",
|
| 88 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["sheephead", "sheepshead"],
|
| 89 |
+
snippet="California sheephead: 2 fish, with a minimum size limit of 12 inches total length."),
|
| 90 |
+
f("california_sheephead_bag_limit", "california_sheephead", "California sheephead", "bag_limit", "2", units="fish per day",
|
| 91 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["sheephead"],
|
| 92 |
+
snippet="California sheephead: 2 fish, with a minimum size limit of 12 inches total length."),
|
| 93 |
+
# --- California barracuda (CCR 28.25) ---
|
| 94 |
+
f("california_barracuda_min_size", "california_barracuda", "California barracuda", "minimum_size", "28",
|
| 95 |
+
units="inches total length (or 17 inches alternate length)",
|
| 96 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=["barracuda", "pacific barracuda"],
|
| 97 |
+
snippet="California barracuda minimum size: twenty-eight inches total length or seventeen inches alternate length (CCR T14 28.25)."),
|
| 98 |
+
# --- Pacific bonito (CCR 28.32) ---
|
| 99 |
+
f("pacific_bonito_min_size", "pacific_bonito", "Pacific bonito", "minimum_size", "24",
|
| 100 |
+
units="inches fork length or 5 pounds, except up to 5 smaller fish may be taken",
|
| 101 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=["bonito"],
|
| 102 |
+
snippet="Pacific bonito minimum size: twenty-four inches fork length or five pounds, except that five fish less than twenty-four inches or five pounds may be taken (CCR T14 28.32)."),
|
| 103 |
+
f("pacific_bonito_bag_limit", "pacific_bonito", "Pacific bonito", "bag_limit", "10", units="fish per day",
|
| 104 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=["bonito"],
|
| 105 |
+
snippet="Pacific bonito limit: ten (CCR T14 28.32)."),
|
| 106 |
+
# --- California yellowtail (Southern page) ---
|
| 107 |
+
f("california_yellowtail_min_size", "california_yellowtail", "California yellowtail", "minimum_size", "24",
|
| 108 |
+
units="inches fork length, except up to 5 smaller fish may be taken",
|
| 109 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["yellowtail"],
|
| 110 |
+
snippet="California yellowtail: the minimum size limit is 24 inches fork length, except that up to five fish less than 24 inches fork length may be taken."),
|
| 111 |
+
f("california_yellowtail_bag_limit", "california_yellowtail", "California yellowtail", "bag_limit", "10", units="fish per day",
|
| 112 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["yellowtail"],
|
| 113 |
+
snippet="California yellowtail: ten fish."),
|
| 114 |
+
# --- Ocean whitefish (Southern page) ---
|
| 115 |
+
f("ocean_whitefish_min_size", "ocean_whitefish", "Ocean whitefish", "minimum_size", "no minimum size limit",
|
| 116 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["whitefish"],
|
| 117 |
+
snippet="Ocean whitefish: 10 fish within the general daily bag limit of 20 fish total, with no minimum size limit."),
|
| 118 |
+
f("ocean_whitefish_bag_limit", "ocean_whitefish", "Ocean whitefish", "bag_limit", "10",
|
| 119 |
+
units="fish within the general daily bag limit of 20 finfish total",
|
| 120 |
+
source_id=SOUTH, source_url=SOUTH_URL, aliases=["whitefish"],
|
| 121 |
+
snippet="Ocean whitefish: 10 fish within the general daily bag limit of 20 fish total."),
|
| 122 |
+
# --- California grunion (CCR 28.00) ---
|
| 123 |
+
f("california_grunion_season", "california_grunion", "California grunion", "season",
|
| 124 |
+
"open to take by hand only, except closed (no take) from April 1 through June 30.",
|
| 125 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=["grunion"], eff_start="2026-04-01", eff_end="2026-06-30",
|
| 126 |
+
snippet="It shall be unlawful to take grunion from April 1 through June 30 (CCR T14 28.00)."),
|
| 127 |
+
f("california_grunion_bag_limit", "california_grunion", "California grunion", "bag_limit", "30", units="fish per day",
|
| 128 |
+
source_id=OCEAN, source_url=GENERAL_URL, aliases=["grunion"],
|
| 129 |
+
snippet="California grunion limit: 30 (CCR T14 28.00)."),
|
| 130 |
+
# --- Giant (black) sea bass: no-take off California (CCR 28.10) ---
|
| 131 |
+
no_take("giant_sea_bass", "Giant sea bass", ["black sea bass", "giant black sea bass"],
|
| 132 |
+
snippet="Giant sea bass may not be taken off California; fish taken incidental to other fishing must be immediately returned to the water (CCR T14 28.10)."),
|
| 133 |
+
# --- No-limit baitfish/pelagics (CCR 27.60(b)) ---
|
| 134 |
+
no_limit("northern_anchovy", "Northern anchovy", ["anchovy", "anchovies"]),
|
| 135 |
+
no_limit("pacific_sardine", "Pacific sardine", ["sardine", "sardines"]),
|
| 136 |
+
no_limit("pacific_mackerel", "Pacific mackerel", ["mackerel", "spanish mackerel"]),
|
| 137 |
+
no_limit("jack_mackerel", "Jack mackerel", ["jack mackerel"]),
|
| 138 |
+
no_limit("queenfish", "Queenfish", ["queenfish", "herring queenfish"]),
|
| 139 |
+
no_limit("pacific_butterfish", "Pacific butterfish (pompano)", ["butterfish", "pompano", "pacific pompano"]),
|
| 140 |
+
# --- General-limit nearshore SoCal finfish (CCR 27.60) ---
|
| 141 |
+
general_limit("white_croaker", "White croaker", ["white croaker", "kingfish", "tomcod"]),
|
| 142 |
+
general_limit("yellowfin_croaker", "Yellowfin croaker", ["yellowfin croaker"]),
|
| 143 |
+
general_limit("spotfin_croaker", "Spotfin croaker", ["spotfin croaker"]),
|
| 144 |
+
general_limit("california_corbina", "California corbina", ["corbina", "corvina"]),
|
| 145 |
+
general_limit("opaleye", "Opaleye", ["opaleye", "opal eye"]),
|
| 146 |
+
general_limit("halfmoon", "Halfmoon", ["halfmoon", "blue perch", "catalina blue perch"]),
|
| 147 |
+
general_limit("sargo", "Sargo", ["sargo", "china croaker"]),
|
| 148 |
+
# --- Sharks / rays / skates ---
|
| 149 |
+
general_limit("spiny_dogfish", "Spiny dogfish", ["spiny dogfish", "dogfish", "mud shark"]),
|
| 150 |
+
general_limit("skates", "Skates", ["skate", "skates", "big skate", "longnose skate"]),
|
| 151 |
+
general_limit("bat_ray", "Bat ray", ["bat ray", "bat stingray"]),
|
| 152 |
+
general_limit("shovelnose_guitarfish", "Shovelnose guitarfish", ["guitarfish", "shovelnose guitarfish", "shovelnose shark"]),
|
| 153 |
+
general_limit("thornback_ray", "Thornback ray", ["thornback", "thornback ray"]),
|
| 154 |
+
general_limit("round_stingray", "Round stingray", ["round stingray", "stingray", "round ray"]),
|
| 155 |
+
no_take("white_shark", "White shark (great white)", ["great white", "white shark", "great white shark"],
|
| 156 |
+
source_id=SOUTH, source_url=SOUTH_URL,
|
| 157 |
+
snippet="White sharks may not be taken or possessed at any time (CCR T14 28.06; CDFW Southern region)."),
|
| 158 |
+
no_take("gulf_grouper", "Gulf grouper", ["gulf grouper"],
|
| 159 |
+
snippet="Gulf grouper may not be taken or possessed (CCR T14 28.12)."),
|
| 160 |
+
no_take("broomtail_grouper", "Broomtail grouper", ["broomtail grouper"],
|
| 161 |
+
snippet="Broomtail grouper may not be taken or possessed (CCR T14 28.12)."),
|
| 162 |
+
no_take("basking_shark", "Basking shark", ["basking shark"],
|
| 163 |
+
snippet="Basking sharks are prohibited from general retention in the U.S. West Coast HMS fishery (50 CFR 660.705)."),
|
| 164 |
+
no_take("megamouth_shark", "Megamouth shark", ["megamouth", "megamouth shark"],
|
| 165 |
+
snippet="Megamouth sharks are prohibited from general retention in the U.S. West Coast HMS fishery (50 CFR 660.705)."),
|
| 166 |
+
# --- Invertebrates ---
|
| 167 |
+
f("california_spiny_lobster_min_size", "california_spiny_lobster", "California spiny lobster", "minimum_size",
|
| 168 |
+
"3¼", units="inches carapace (straight line on the mid-line of the back, rear of eye socket to rear of body shell)",
|
| 169 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["spiny lobster"],
|
| 170 |
+
snippet="California spiny lobster minimum size: 3 and 1/4 inches measured on the mid-line of the back from the rear edge of the eye socket to the rear edge of the body shell (CCR T14 29.90)."),
|
| 171 |
+
f("california_spiny_lobster_bag_limit", "california_spiny_lobster", "California spiny lobster", "bag_limit", "7", units="lobster per day",
|
| 172 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["spiny lobster"],
|
| 173 |
+
snippet="California spiny lobster daily bag limit: seven (CCR T14 29.90)."),
|
| 174 |
+
f("california_spiny_lobster_season", "california_spiny_lobster", "California spiny lobster", "season",
|
| 175 |
+
"open from 6:00 p.m. on the Friday preceding the first Wednesday in October through the first Wednesday after the 15th of March.",
|
| 176 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["spiny lobster"],
|
| 177 |
+
snippet="Open season: from 6:00 p.m. on the Friday before the first Wednesday in October through the first Wednesday after the 15th of March (CCR T14 29.90)."),
|
| 178 |
+
f("rock_scallop_bag_limit", "rock_scallop", "Rock scallop", "bag_limit", "10", units="scallops per day",
|
| 179 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["rock scallop", "scallop", "scallops"],
|
| 180 |
+
snippet="Rock scallop limit: ten (CCR T14 29.60)."),
|
| 181 |
+
no_take("speckled_scallop", "Speckled (bay) scallop", ["bay scallop", "speckled scallop"],
|
| 182 |
+
source_id=OCEAN, source_url=INVERT_URL,
|
| 183 |
+
snippet="Speckled (bay) scallops may not be taken or possessed (CCR T14 29.65)."),
|
| 184 |
+
f("market_squid_bag_limit", "market_squid", "Market squid", "bag_limit", "no daily bag limit", units="(may be taken with hand-held dip nets)",
|
| 185 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["squid", "calamari", "market squid"],
|
| 186 |
+
snippet="Squid may be taken with hand-held dip nets. There is no limit (CCR T14 29.70)."),
|
| 187 |
+
f("kellets_whelk_bag_limit", "kellets_whelk", "Kellet's whelk", "bag_limit", "35", units="whelks per day",
|
| 188 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["whelk", "kellets whelk", "kellet's whelk"],
|
| 189 |
+
snippet="Kellet's whelk: 35 whelks; season July 1 through the first Wednesday after the 15th of March (CCR T14 29.17, 29.05)."),
|
| 190 |
+
f("octopus_bag_limit", "octopus", "Octopus", "bag_limit", "35", units="octopus per day",
|
| 191 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["octopus", "octopuses", "octopi"],
|
| 192 |
+
snippet="Octopus: 35 octopus, year round (CCR T14 29.05)."),
|
| 193 |
+
f("native_oyster_bag_limit", "native_oyster", "Native oyster", "bag_limit", "35", units="oysters per day",
|
| 194 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["oyster", "oysters", "native oyster"],
|
| 195 |
+
snippet="Oysters, all: 35 oysters, year round (CCR T14 29.05)."),
|
| 196 |
+
f("ghost_shrimp_bag_limit", "ghost_shrimp", "Ghost shrimp and blue mud shrimp", "bag_limit", "50", units="in combination per day",
|
| 197 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["ghost shrimp", "mud shrimp", "blue mud shrimp"],
|
| 198 |
+
snippet="Ghost shrimp and blue mud shrimp limit: fifty in combination (CCR T14 29.87)."),
|
| 199 |
+
f("coonstripe_shrimp_bag_limit", "coonstripe_shrimp", "Coonstripe shrimp", "bag_limit", "20",
|
| 200 |
+
units="pounds in the shell, heads on, per day",
|
| 201 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["coonstripe shrimp"],
|
| 202 |
+
snippet="Coonstripe shrimp: twenty pounds (in the shell, heads on) (CCR T14 29.88)."),
|
| 203 |
+
f("sand_crab_bag_limit", "sand_crab", "Sand crab (mole crab)", "bag_limit", "50", units="per day",
|
| 204 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["sand crab", "mole crab", "sand crabs"],
|
| 205 |
+
snippet="Sand crabs (Emerita analoga): fifty (CCR T14 29.85)."),
|
| 206 |
+
# --- Distinct named clams (own CCR sections) ---
|
| 207 |
+
f("gaper_clam_bag_limit", "gaper_clam", "Gaper clam (horseneck clam)", "bag_limit", "10",
|
| 208 |
+
units="of each species per day (Humboldt Bay: 50 in combination, max 25 gaper; Elkhorn Slough: 12 in combination)",
|
| 209 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["gaper clam", "horseneck clam", "horse clam"],
|
| 210 |
+
snippet="Gaper/horseneck clams: limit ten of each species, except in Humboldt Bay fifty in combination (no more than 25 gaper) and in Elkhorn Slough twelve in combination (CCR T14 29.25)."),
|
| 211 |
+
f("geoduck_clam_bag_limit", "geoduck_clam", "Geoduck clam", "bag_limit", "3", units="clams per day",
|
| 212 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["geoduck", "geoduck clam"],
|
| 213 |
+
snippet="Geoduck clams: limit three; the first three dug must be retained as the bag limit (CCR T14 29.30)."),
|
| 214 |
+
f("pismo_clam_min_size", "pismo_clam", "Pismo clam", "minimum_size", "5", units="inches greatest diameter",
|
| 215 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["pismo clam"],
|
| 216 |
+
snippet="Pismo clams: minimum size five inches in greatest diameter; limit ten (CCR T14 29.40)."),
|
| 217 |
+
f("pismo_clam_bag_limit", "pismo_clam", "Pismo clam", "bag_limit", "10", units="clams per day",
|
| 218 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["pismo clam"],
|
| 219 |
+
snippet="Pismo clams: limit ten (CCR T14 29.40)."),
|
| 220 |
+
f("razor_clam_bag_limit", "razor_clam", "Razor clam", "bag_limit", "20",
|
| 221 |
+
units="clams per day (the first 20 dug must be retained regardless of size or condition)",
|
| 222 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["razor clam", "razor clams"],
|
| 223 |
+
snippet="Razor clams: limit twenty; the first twenty dug must be retained as the bag limit (CCR T14 29.45)."),
|
| 224 |
+
f("littleneck_clam_bag_limit", "littleneck_clam", "Littleneck, cockle, and softshell clams", "bag_limit", "50",
|
| 225 |
+
units="in combination per day (minimum size 1½ inches, except no size limit for soft-shell clams)",
|
| 226 |
+
source_id=OCEAN, source_url=INVERT_URL, aliases=["littleneck clam", "cockle", "softshell clam", "soft-shell clam", "quahog", "chione"],
|
| 227 |
+
snippet="Littleneck clams, soft-shell clams, chiones, northern quahogs and cockles: limit fifty in combination; minimum size one and one-half inches, except no size limit for soft-shell clams (CCR T14 29.35)."),
|
| 228 |
+
]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def main():
|
| 232 |
+
data = json.loads(FACTS_PATH.read_text())
|
| 233 |
+
by_id = {f["id"]: i for i, f in enumerate(data["structured_facts"])}
|
| 234 |
+
added = updated = 0
|
| 235 |
+
for entry in NEW:
|
| 236 |
+
if entry["id"] in by_id:
|
| 237 |
+
data["structured_facts"][by_id[entry["id"]]] = entry
|
| 238 |
+
updated += 1
|
| 239 |
+
else:
|
| 240 |
+
data["structured_facts"].append(entry)
|
| 241 |
+
added += 1
|
| 242 |
+
FACTS_PATH.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
| 243 |
+
species = {f["species_or_category"] for f in data["structured_facts"]}
|
| 244 |
+
print(f"added {added}, updated {updated}; total facts {len(data['structured_facts'])}, distinct species {len(species)}")
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
if __name__ == "__main__":
|
| 248 |
+
main()
|
|
@@ -9,6 +9,24 @@
|
|
| 9 |
"forbidden_answer_terms": [],
|
| 10 |
"forbidden_direct_answer_concepts": ["dungeness_generic_fallback"]
|
| 11 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
{
|
| 13 |
"query": "is mussel safe to eat?",
|
| 14 |
"expected_species": "mussels",
|
|
@@ -50,7 +68,7 @@
|
|
| 50 |
"query": "what are the restirctions on cabezone?",
|
| 51 |
"expected_species": "cabezon",
|
| 52 |
"expected_place": null,
|
| 53 |
-
"expected_statuses": ["partial"],
|
| 54 |
"required_source_terms": ["CDFW"],
|
| 55 |
"required_answer_terms": ["cabezon"],
|
| 56 |
"forbidden_answer_terms": ["authorized", "you may harvest"]
|
|
@@ -59,7 +77,7 @@
|
|
| 59 |
"query": "what are the restrictions on cabezon?",
|
| 60 |
"expected_species": "cabezon",
|
| 61 |
"expected_place": null,
|
| 62 |
-
"expected_statuses": ["partial"],
|
| 63 |
"required_source_terms": ["CDFW"],
|
| 64 |
"required_answer_terms": ["cabezon"],
|
| 65 |
"forbidden_answer_terms": ["authorized", "you may harvest"]
|
|
|
|
| 9 |
"forbidden_answer_terms": [],
|
| 10 |
"forbidden_direct_answer_concepts": ["dungeness_generic_fallback"]
|
| 11 |
},
|
| 12 |
+
{
|
| 13 |
+
"query": "im in mandocino. is it legal to harvest dungenese crabs?",
|
| 14 |
+
"expected_species": "dungeness_crab",
|
| 15 |
+
"expected_place": "Mendocino",
|
| 16 |
+
"expected_statuses": ["answered", "partial"],
|
| 17 |
+
"required_source_terms": ["CDFW"],
|
| 18 |
+
"required_answer_terms": ["Dungeness crab"],
|
| 19 |
+
"forbidden_answer_terms": ["you may harvest", "legal to harvest", "definitely legal"],
|
| 20 |
+
"forbidden_direct_answer_concepts": ["dungeness_generic_fallback"],
|
| 21 |
+
"model_interpretation": {
|
| 22 |
+
"species_or_category": "dungeness_crab",
|
| 23 |
+
"location_name": "Mendocino",
|
| 24 |
+
"region_key": "mendocino",
|
| 25 |
+
"intent": "permission_status",
|
| 26 |
+
"requested_fact_types": ["season", "closure"],
|
| 27 |
+
"confidence": "high"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
{
|
| 31 |
"query": "is mussel safe to eat?",
|
| 32 |
"expected_species": "mussels",
|
|
|
|
| 68 |
"query": "what are the restirctions on cabezone?",
|
| 69 |
"expected_species": "cabezon",
|
| 70 |
"expected_place": null,
|
| 71 |
+
"expected_statuses": ["answered", "partial"],
|
| 72 |
"required_source_terms": ["CDFW"],
|
| 73 |
"required_answer_terms": ["cabezon"],
|
| 74 |
"forbidden_answer_terms": ["authorized", "you may harvest"]
|
|
|
|
| 77 |
"query": "what are the restrictions on cabezon?",
|
| 78 |
"expected_species": "cabezon",
|
| 79 |
"expected_place": null,
|
| 80 |
+
"expected_statuses": ["answered", "partial"],
|
| 81 |
"required_source_terms": ["CDFW"],
|
| 82 |
"required_answer_terms": ["cabezon"],
|
| 83 |
"forbidden_answer_terms": ["authorized", "you may harvest"]
|
|
@@ -1,9 +1,13 @@
|
|
| 1 |
"""Constrained generated explanation tests."""
|
| 2 |
|
|
|
|
| 3 |
from datetime import date, datetime, timezone
|
| 4 |
|
| 5 |
from coastwise.ai import (
|
|
|
|
|
|
|
| 6 |
explain_safety_result,
|
|
|
|
| 7 |
synthesize_cached_evidence_answer,
|
| 8 |
validate_cached_evidence_answer,
|
| 9 |
validate_generated_text,
|
|
@@ -178,3 +182,310 @@ def test_cached_evidence_answer_falls_back_when_model_is_unavailable():
|
|
| 178 |
assert explanation.fallback_used
|
| 179 |
assert explanation.blocked_reason == "model_unavailable"
|
| 180 |
assert "Rock crab" in explanation.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Constrained generated explanation tests."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
from datetime import date, datetime, timezone
|
| 5 |
|
| 6 |
from coastwise.ai import (
|
| 7 |
+
build_hosted_interpreter,
|
| 8 |
+
build_local_interpreter,
|
| 9 |
explain_safety_result,
|
| 10 |
+
interpret_question_with_model,
|
| 11 |
synthesize_cached_evidence_answer,
|
| 12 |
validate_cached_evidence_answer,
|
| 13 |
validate_generated_text,
|
|
|
|
| 182 |
assert explanation.fallback_used
|
| 183 |
assert explanation.blocked_reason == "model_unavailable"
|
| 184 |
assert "Rock crab" in explanation.text
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_model_interpreter_accepts_only_allowlisted_candidates():
|
| 188 |
+
prompts = []
|
| 189 |
+
payload = {
|
| 190 |
+
"species_or_category": "dungeness_crab",
|
| 191 |
+
"location_name": "Mendocino",
|
| 192 |
+
"region_key": "mendocino",
|
| 193 |
+
"intent": "permission_status",
|
| 194 |
+
"requested_fact_types": ["season", "closure"],
|
| 195 |
+
"confidence": "high",
|
| 196 |
+
"notes": "Normalized spelling.",
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
result = interpret_question_with_model(
|
| 200 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 201 |
+
"",
|
| 202 |
+
allowed_species={"dungeness_crab", "lingcod"},
|
| 203 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 204 |
+
allowed_region_keys={"mendocino", "central"},
|
| 205 |
+
generator=lambda prompt: prompts.append(prompt) or json.dumps(payload),
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
assert result is not None
|
| 209 |
+
assert result.species_or_category == "dungeness_crab"
|
| 210 |
+
assert result.location_name == "Mendocino"
|
| 211 |
+
assert result.region_key == "mendocino"
|
| 212 |
+
assert result.intent.value == "permission_status"
|
| 213 |
+
assert [fact.value for fact in result.requested_fact_types] == ["season", "closure"]
|
| 214 |
+
assert 'allowed_locations={"Mendocino": "mendocino"}' in prompts[0]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def test_model_interpreter_rejects_unknown_candidates_and_facts():
|
| 218 |
+
payload = {
|
| 219 |
+
"species_or_category": "maine_lobster",
|
| 220 |
+
"location_name": "Mendocino",
|
| 221 |
+
"region_key": "mendocino",
|
| 222 |
+
"intent": "permission_status",
|
| 223 |
+
"requested_fact_types": ["season"],
|
| 224 |
+
"confidence": "high",
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
result = interpret_question_with_model(
|
| 228 |
+
"can I harvest lobster in mendocino?",
|
| 229 |
+
"",
|
| 230 |
+
allowed_species={"dungeness_crab"},
|
| 231 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 232 |
+
allowed_region_keys={"mendocino"},
|
| 233 |
+
generator=lambda _prompt: json.dumps(payload),
|
| 234 |
+
)
|
| 235 |
+
unknown_fact_type = interpret_question_with_model(
|
| 236 |
+
"can I harvest crab in mendocino?",
|
| 237 |
+
"",
|
| 238 |
+
allowed_species={"dungeness_crab"},
|
| 239 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 240 |
+
allowed_region_keys={"mendocino"},
|
| 241 |
+
generator=lambda _prompt: json.dumps(
|
| 242 |
+
{
|
| 243 |
+
"species_or_category": "dungeness_crab",
|
| 244 |
+
"location_name": "Mendocino",
|
| 245 |
+
"region_key": "mendocino",
|
| 246 |
+
"intent": "permission_status",
|
| 247 |
+
"requested_fact_types": ["unknown"],
|
| 248 |
+
"confidence": "high",
|
| 249 |
+
}
|
| 250 |
+
),
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
assert result is None
|
| 254 |
+
assert unknown_fact_type is None
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def test_model_interpreter_falls_back_on_invalid_json_or_low_confidence():
|
| 258 |
+
invalid = interpret_question_with_model(
|
| 259 |
+
"can I harvest crab?",
|
| 260 |
+
"",
|
| 261 |
+
allowed_species={"dungeness_crab"},
|
| 262 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 263 |
+
allowed_region_keys={"mendocino"},
|
| 264 |
+
generator=lambda _prompt: "not json",
|
| 265 |
+
)
|
| 266 |
+
low_confidence = interpret_question_with_model(
|
| 267 |
+
"can I harvest crab?",
|
| 268 |
+
"",
|
| 269 |
+
allowed_species={"dungeness_crab"},
|
| 270 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 271 |
+
allowed_region_keys={"mendocino"},
|
| 272 |
+
generator=lambda _prompt: json.dumps(
|
| 273 |
+
{
|
| 274 |
+
"species_or_category": "dungeness_crab",
|
| 275 |
+
"location_name": "Mendocino",
|
| 276 |
+
"region_key": "mendocino",
|
| 277 |
+
"intent": "permission_status",
|
| 278 |
+
"requested_fact_types": ["season"],
|
| 279 |
+
"confidence": "low",
|
| 280 |
+
}
|
| 281 |
+
),
|
| 282 |
+
)
|
| 283 |
+
malformed_confidence = interpret_question_with_model(
|
| 284 |
+
"can I harvest crab?",
|
| 285 |
+
"",
|
| 286 |
+
allowed_species={"dungeness_crab"},
|
| 287 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 288 |
+
allowed_region_keys={"mendocino"},
|
| 289 |
+
generator=lambda _prompt: json.dumps(
|
| 290 |
+
{
|
| 291 |
+
"species_or_category": "dungeness_crab",
|
| 292 |
+
"location_name": "Mendocino",
|
| 293 |
+
"region_key": "mendocino",
|
| 294 |
+
"intent": "permission_status",
|
| 295 |
+
"requested_fact_types": ["season"],
|
| 296 |
+
"confidence": ["high"],
|
| 297 |
+
}
|
| 298 |
+
),
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
assert invalid is None
|
| 302 |
+
assert low_confidence is None
|
| 303 |
+
assert malformed_confidence is None
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def test_build_hosted_interpreter_returns_none_without_api_key():
|
| 307 |
+
assert build_hosted_interpreter(api_key="") is None
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def test_hosted_interpreter_posts_openai_chat_and_returns_model_json():
|
| 311 |
+
calls = []
|
| 312 |
+
|
| 313 |
+
def fake_post(base_url, api_key, model, prompt, timeout):
|
| 314 |
+
calls.append((base_url, api_key, model, prompt, timeout))
|
| 315 |
+
return '{"species_or_category": "dungeness_crab"}'
|
| 316 |
+
|
| 317 |
+
generate = build_hosted_interpreter(
|
| 318 |
+
api_key="secret-key",
|
| 319 |
+
base_url="https://api.modelbest.cn/v1",
|
| 320 |
+
model="MiniCPM5-1B",
|
| 321 |
+
post=fake_post,
|
| 322 |
+
)
|
| 323 |
+
out = generate("interpret this question as JSON")
|
| 324 |
+
|
| 325 |
+
assert out == '{"species_or_category": "dungeness_crab"}'
|
| 326 |
+
assert len(calls) == 1
|
| 327 |
+
base_url, api_key, model, prompt, _timeout = calls[0]
|
| 328 |
+
assert base_url == "https://api.modelbest.cn/v1"
|
| 329 |
+
assert api_key == "secret-key"
|
| 330 |
+
assert model == "MiniCPM5-1B"
|
| 331 |
+
assert prompt == "interpret this question as JSON"
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def test_hosted_interpreter_extracts_json_from_fenced_or_prose_output():
|
| 335 |
+
fenced = build_hosted_interpreter(
|
| 336 |
+
api_key="k",
|
| 337 |
+
post=lambda *_a, **_k: '```json\n{"species_or_category": "lingcod"}\n```',
|
| 338 |
+
)
|
| 339 |
+
assert fenced("p") == '{"species_or_category": "lingcod"}'
|
| 340 |
+
|
| 341 |
+
prose = build_hosted_interpreter(
|
| 342 |
+
api_key="k",
|
| 343 |
+
post=lambda *_a, **_k: 'Sure: {"species_or_category": "lingcod"} hope this helps',
|
| 344 |
+
)
|
| 345 |
+
assert prose("p") == '{"species_or_category": "lingcod"}'
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def test_hosted_interpreter_fails_closed_on_transport_error_or_garbage():
|
| 349 |
+
def boom(*_a, **_k):
|
| 350 |
+
raise RuntimeError("network down")
|
| 351 |
+
|
| 352 |
+
assert build_hosted_interpreter(api_key="k", post=boom)("p") is None
|
| 353 |
+
assert build_hosted_interpreter(api_key="k", post=lambda *_a, **_k: "no json here")("p") is None
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def test_hosted_interpreter_output_drives_constrained_validation():
|
| 357 |
+
payload = {
|
| 358 |
+
"species_or_category": "dungeness_crab",
|
| 359 |
+
"location_name": "Mendocino",
|
| 360 |
+
"region_key": "mendocino",
|
| 361 |
+
"intent": "permission_status",
|
| 362 |
+
"requested_fact_types": ["season", "closure"],
|
| 363 |
+
"confidence": "high",
|
| 364 |
+
}
|
| 365 |
+
generate = build_hosted_interpreter(
|
| 366 |
+
api_key="k",
|
| 367 |
+
post=lambda *_a, **_k: f"```json\n{json.dumps(payload)}\n```",
|
| 368 |
+
)
|
| 369 |
+
result = interpret_question_with_model(
|
| 370 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 371 |
+
"",
|
| 372 |
+
allowed_species={"dungeness_crab", "lingcod"},
|
| 373 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 374 |
+
allowed_region_keys={"mendocino"},
|
| 375 |
+
generator=generate,
|
| 376 |
+
)
|
| 377 |
+
assert result is not None
|
| 378 |
+
assert result.species_or_category == "dungeness_crab"
|
| 379 |
+
assert result.location_name == "Mendocino"
|
| 380 |
+
assert result.region_key == "mendocino"
|
| 381 |
+
assert result.intent.value == "permission_status"
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def test_build_local_interpreter_disabled_returns_none():
|
| 385 |
+
assert build_local_interpreter(enabled=False) is None
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def test_local_interpreter_lazy_loads_once_and_returns_model_json():
|
| 389 |
+
loads = {"n": 0}
|
| 390 |
+
|
| 391 |
+
class FakeModel:
|
| 392 |
+
def create_chat_completion(self, messages, **kwargs):
|
| 393 |
+
return {"choices": [{"message": {"content": '{"species_or_category": "lingcod"}'}}]}
|
| 394 |
+
|
| 395 |
+
def factory():
|
| 396 |
+
loads["n"] += 1
|
| 397 |
+
return FakeModel()
|
| 398 |
+
|
| 399 |
+
generate = build_local_interpreter(enabled=True, model_factory=factory)
|
| 400 |
+
assert generate("first prompt") == '{"species_or_category": "lingcod"}'
|
| 401 |
+
assert generate("second prompt") == '{"species_or_category": "lingcod"}'
|
| 402 |
+
assert loads["n"] == 1
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def test_local_interpreter_extracts_json_and_fails_closed():
|
| 406 |
+
class FencedModel:
|
| 407 |
+
def create_chat_completion(self, messages, **kwargs):
|
| 408 |
+
return {
|
| 409 |
+
"choices": [
|
| 410 |
+
{"message": {"content": '```json\n{"species_or_category": "dungeness_crab"}\n```'}}
|
| 411 |
+
]
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
fenced = build_local_interpreter(enabled=True, model_factory=lambda: FencedModel())
|
| 415 |
+
assert fenced("p") == '{"species_or_category": "dungeness_crab"}'
|
| 416 |
+
|
| 417 |
+
def boom_factory():
|
| 418 |
+
raise RuntimeError("model load failed")
|
| 419 |
+
|
| 420 |
+
assert build_local_interpreter(enabled=True, model_factory=boom_factory)("p") is None
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
def test_model_interpreter_canonicalizes_case_insensitive_location_name():
|
| 424 |
+
payload = {
|
| 425 |
+
"species_or_category": "dungeness_crab",
|
| 426 |
+
"location_name": "mendocino",
|
| 427 |
+
"region_key": "mendocino",
|
| 428 |
+
"intent": "permission_status",
|
| 429 |
+
"requested_fact_types": ["season"],
|
| 430 |
+
"confidence": "high",
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
result = interpret_question_with_model(
|
| 434 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 435 |
+
"",
|
| 436 |
+
allowed_species={"dungeness_crab"},
|
| 437 |
+
allowed_locations={"Mendocino": "mendocino"},
|
| 438 |
+
allowed_region_keys={"mendocino"},
|
| 439 |
+
generator=lambda _prompt: json.dumps(payload),
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
assert result is not None
|
| 443 |
+
assert result.location_name == "Mendocino"
|
| 444 |
+
assert result.region_key == "mendocino"
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def test_local_interpreter_constrains_output_with_json_schema():
|
| 448 |
+
captured = {}
|
| 449 |
+
|
| 450 |
+
class RecordingModel:
|
| 451 |
+
def create_chat_completion(self, messages, **kwargs):
|
| 452 |
+
captured.update(kwargs)
|
| 453 |
+
return {"choices": [{"message": {"content": '{"species_or_category": "lingcod"}'}}]}
|
| 454 |
+
|
| 455 |
+
generate = build_local_interpreter(enabled=True, model_factory=lambda: RecordingModel())
|
| 456 |
+
generate("some prompt")
|
| 457 |
+
|
| 458 |
+
response_format = captured.get("response_format") or {}
|
| 459 |
+
assert response_format.get("type") == "json_object"
|
| 460 |
+
schema = response_format.get("schema") or {}
|
| 461 |
+
assert "confidence" in (schema.get("required") or [])
|
| 462 |
+
assert schema.get("properties", {}).get("confidence", {}).get("enum") == ["high", "medium"]
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
def test_local_interpreter_schema_constrains_to_prompt_allowlists():
|
| 466 |
+
captured = {}
|
| 467 |
+
|
| 468 |
+
class RecordingModel:
|
| 469 |
+
def create_chat_completion(self, messages, **kwargs):
|
| 470 |
+
captured.update(kwargs)
|
| 471 |
+
return {"choices": [{"message": {"content": "{}"}}]}
|
| 472 |
+
|
| 473 |
+
generate = build_local_interpreter(enabled=True, model_factory=lambda: RecordingModel())
|
| 474 |
+
generate(
|
| 475 |
+
"question=x\n"
|
| 476 |
+
"allowed_species=['dungeness_crab', 'lingcod']\n"
|
| 477 |
+
"allowed_locations=['Mendocino', 'Monterey']\n"
|
| 478 |
+
"allowed_region_keys=['central', 'mendocino']"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
schema = captured["response_format"]["schema"]
|
| 482 |
+
props = schema["properties"]
|
| 483 |
+
assert set(props["species_or_category"]["enum"]) >= {"dungeness_crab", "lingcod"}
|
| 484 |
+
assert "Mendocino" in props["location_name"]["enum"]
|
| 485 |
+
assert "mendocino" in props["region_key"]["enum"]
|
| 486 |
+
# species is required (model must pick from the allowlist, never null/omit)
|
| 487 |
+
assert "species_or_category" in schema["required"]
|
| 488 |
+
# location/region are optional so location-less questions are not forced to invent a place
|
| 489 |
+
assert "location_name" not in schema["required"]
|
| 490 |
+
assert "region_key" not in schema["required"]
|
| 491 |
+
assert None in props["location_name"]["enum"]
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""Gradio app integration tests."""
|
| 2 |
|
|
|
|
| 3 |
import time
|
| 4 |
from dataclasses import replace
|
| 5 |
from datetime import datetime, timedelta, timezone
|
|
@@ -11,6 +12,28 @@ import app
|
|
| 11 |
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
def test_app_import_exposes_coastwise_identity_without_launching():
|
| 15 |
assert app.APP_TITLE == "CoastWise"
|
| 16 |
assert isinstance(app.demo, gr.Blocks)
|
|
@@ -25,7 +48,7 @@ def test_build_app_returns_blocks_with_lookup_shell():
|
|
| 25 |
|
| 26 |
def test_source_backed_app_shell_exposes_primary_controls_without_legacy_inputs():
|
| 27 |
assert "Question" in app.SOURCE_BACKED_CONTROL_LABELS
|
| 28 |
-
assert "Beach or coastal area" in app.SOURCE_BACKED_CONTROL_LABELS
|
| 29 |
assert "Refresh official sources" in app.SOURCE_BACKED_CONTROL_LABELS
|
| 30 |
assert "Intent" not in app.SOURCE_BACKED_CONTROL_LABELS
|
| 31 |
assert "Region" not in app.SOURCE_BACKED_CONTROL_LABELS
|
|
@@ -194,3 +217,22 @@ def test_lookup_photo_candidate_returns_candidate_without_harvest_guidance():
|
|
| 194 |
assert result["candidates"][0]["rule_card_key"] == "mussels"
|
| 195 |
assert "not confirmed" in result["candidates"][0]["limitations"].lower()
|
| 196 |
assert "harvest_status" not in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Gradio app integration tests."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
import time
|
| 5 |
from dataclasses import replace
|
| 6 |
from datetime import datetime, timedelta, timezone
|
|
|
|
| 12 |
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
|
| 13 |
|
| 14 |
|
| 15 |
+
def test_manual_refresh_applies_to_active_cache_and_preserves_curated_facts():
|
| 16 |
+
from coastwise.schemas import RefreshStatus
|
| 17 |
+
|
| 18 |
+
app._SESSION_CACHE = None
|
| 19 |
+
try:
|
| 20 |
+
before = app._active_cache()
|
| 21 |
+
assert not any(s.refresh_status is RefreshStatus.UPDATED for s in before.snapshots)
|
| 22 |
+
|
| 23 |
+
app.refresh_official_sources_status(
|
| 24 |
+
fetcher=lambda source: f"refreshed official source text for {source.id} 2026"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
after = app._active_cache()
|
| 28 |
+
# Refresh now actually changes what subsequent answers read.
|
| 29 |
+
assert any(s.refresh_status is RefreshStatus.UPDATED for s in after.snapshots)
|
| 30 |
+
# Curated structured facts are preserved, never auto-derived from fetched text.
|
| 31 |
+
assert len(after.facts) == len(before.facts)
|
| 32 |
+
assert after.facts == before.facts
|
| 33 |
+
finally:
|
| 34 |
+
app._SESSION_CACHE = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def test_app_import_exposes_coastwise_identity_without_launching():
|
| 38 |
assert app.APP_TITLE == "CoastWise"
|
| 39 |
assert isinstance(app.demo, gr.Blocks)
|
|
|
|
| 48 |
|
| 49 |
def test_source_backed_app_shell_exposes_primary_controls_without_legacy_inputs():
|
| 50 |
assert "Question" in app.SOURCE_BACKED_CONTROL_LABELS
|
| 51 |
+
assert "Beach or coastal area" not in app.SOURCE_BACKED_CONTROL_LABELS
|
| 52 |
assert "Refresh official sources" in app.SOURCE_BACKED_CONTROL_LABELS
|
| 53 |
assert "Intent" not in app.SOURCE_BACKED_CONTROL_LABELS
|
| 54 |
assert "Region" not in app.SOURCE_BACKED_CONTROL_LABELS
|
|
|
|
| 217 |
assert result["candidates"][0]["rule_card_key"] == "mussels"
|
| 218 |
assert "not confirmed" in result["candidates"][0]["limitations"].lower()
|
| 219 |
assert "harvest_status" not in result
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_app_routes_questions_through_configured_interpreter(monkeypatch):
|
| 223 |
+
payload = {
|
| 224 |
+
"species_or_category": "dungeness_crab",
|
| 225 |
+
"location_name": "Mendocino",
|
| 226 |
+
"region_key": "mendocino",
|
| 227 |
+
"intent": "permission_status",
|
| 228 |
+
"requested_fact_types": ["season", "closure"],
|
| 229 |
+
"confidence": "high",
|
| 230 |
+
}
|
| 231 |
+
monkeypatch.setattr(app, "SOURCE_BACKED_INTERPRETER", lambda _prompt: json.dumps(payload))
|
| 232 |
+
|
| 233 |
+
result = app.ask_source_backed_question(
|
| 234 |
+
"im in mandocino. is it legal to harvest dungenese crabs?", ""
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
assert result["matched_place"] == "Mendocino"
|
| 238 |
+
assert "dungeness crab" in (result["species_or_category"] or "").casefold()
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""Location parsing tests."""
|
| 2 |
|
| 3 |
-
from coastwise.locations import parse_location
|
| 4 |
|
| 5 |
|
| 6 |
def test_parse_location_from_question_or_optional_beach_field():
|
|
@@ -25,3 +25,84 @@ def test_parse_location_recognizes_half_moon_bay_for_public_queries():
|
|
| 25 |
assert context.matched_place == "Half Moon Bay"
|
| 26 |
assert context.region_key == "san_francisco"
|
| 27 |
assert context.confidence == "recognized"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Location parsing tests."""
|
| 2 |
|
| 3 |
+
from coastwise.locations import location_context_from_model, location_candidates, parse_location
|
| 4 |
|
| 5 |
|
| 6 |
def test_parse_location_from_question_or_optional_beach_field():
|
|
|
|
| 25 |
assert context.matched_place == "Half Moon Bay"
|
| 26 |
assert context.region_key == "san_francisco"
|
| 27 |
assert context.confidence == "recognized"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def test_location_candidates_include_official_regions_without_typo_alias_growth():
|
| 31 |
+
candidates = location_candidates()
|
| 32 |
+
|
| 33 |
+
assert candidates["Mendocino"] == "mendocino"
|
| 34 |
+
assert candidates["Mendocino Ocean Region"] == "mendocino"
|
| 35 |
+
assert "Mandocino" not in candidates
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_location_context_from_validated_model_output():
|
| 39 |
+
context = location_context_from_model(
|
| 40 |
+
question="im in mandocino. is it legal to harvest dungenese crabs?",
|
| 41 |
+
location_text="",
|
| 42 |
+
location_name="Mendocino",
|
| 43 |
+
region_key="mendocino",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
assert context.matched_place == "Mendocino"
|
| 47 |
+
assert context.region_key == "mendocino"
|
| 48 |
+
assert context.confidence == "recognized"
|
| 49 |
+
assert context.warning is None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_location_context_from_model_preserves_existing_alias_scope():
|
| 53 |
+
context = location_context_from_model(
|
| 54 |
+
question="can I eat mussels from pacifica today?",
|
| 55 |
+
location_text="",
|
| 56 |
+
location_name="Pacifica",
|
| 57 |
+
region_key="san_francisco",
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
assert context.matched_place == "Pacifica"
|
| 61 |
+
assert context.region_key == "san_francisco"
|
| 62 |
+
assert context.area_or_scope == "San Francisco coast"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def test_location_context_from_missing_model_output_falls_back_to_parse_location():
|
| 66 |
+
context = location_context_from_model(
|
| 67 |
+
question="can I eat mussels from Pacifica today?",
|
| 68 |
+
location_text="",
|
| 69 |
+
location_name=None,
|
| 70 |
+
region_key=None,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
assert context.matched_place == "Pacifica"
|
| 74 |
+
assert context.region_key == "san_francisco"
|
| 75 |
+
assert context.area_or_scope == "San Francisco coast"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_location_context_from_model_name_region_mismatch_falls_back_to_parse_location():
|
| 79 |
+
context = location_context_from_model(
|
| 80 |
+
question="what's the lingcod size?",
|
| 81 |
+
location_text="Monterey",
|
| 82 |
+
location_name="Pacifica",
|
| 83 |
+
region_key="central",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
assert context.matched_place == "Monterey"
|
| 87 |
+
assert context.region_key == "central"
|
| 88 |
+
assert context.area_or_scope == "Central California ocean region"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_parse_location_fuzzy_matches_misspelled_mendocino():
|
| 92 |
+
context = parse_location("im in mandocino. is it legal to harvest dungenese crabs?", "")
|
| 93 |
+
|
| 94 |
+
assert context.matched_place == "Mendocino"
|
| 95 |
+
assert context.region_key == "mendocino"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_parse_location_fuzzy_does_not_false_match_plain_species_question():
|
| 99 |
+
context = parse_location("what's the min size of lingcod?", "")
|
| 100 |
+
|
| 101 |
+
assert context.matched_place is None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def test_parse_location_recognizes_sf_abbreviation():
|
| 105 |
+
context = parse_location("is it legal to catch lingcod in sf", "")
|
| 106 |
+
|
| 107 |
+
assert context.matched_place == "San Francisco"
|
| 108 |
+
assert context.region_key == "san_francisco"
|
|
@@ -1,10 +1,19 @@
|
|
| 1 |
"""Source-backed Q&A tests."""
|
| 2 |
|
|
|
|
| 3 |
from dataclasses import replace
|
| 4 |
from datetime import datetime, timedelta, timezone
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
|
| 9 |
|
| 10 |
|
|
@@ -136,16 +145,21 @@ def test_dungeness_season_answer_uses_saved_evidence_instead_of_generic_verify_f
|
|
| 136 |
assert "Dungeness crab: Verify current CDFW" not in answer.direct_answer
|
| 137 |
|
| 138 |
|
| 139 |
-
def
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
answer = answer_question(
|
| 144 |
"what's the min size of lingcod?",
|
| 145 |
"",
|
| 146 |
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 147 |
NOW,
|
| 148 |
-
model=
|
| 149 |
)
|
| 150 |
|
| 151 |
assert answer.status is AnswerStatus.ANSWERED
|
|
@@ -154,7 +168,7 @@ def test_structured_lingcod_answer_does_not_call_model_when_exact_fact_exists():
|
|
| 154 |
assert not answer.model_assisted
|
| 155 |
|
| 156 |
|
| 157 |
-
def
|
| 158 |
answer = answer_question(
|
| 159 |
"can i harvest dugenese crabs in pacifica today?",
|
| 160 |
"",
|
|
@@ -163,9 +177,559 @@ def test_harvest_question_with_typos_uses_cached_evidence_not_bare_warning():
|
|
| 163 |
model=lambda _prompt: None,
|
| 164 |
)
|
| 165 |
|
| 166 |
-
|
| 167 |
-
assert answer.
|
|
|
|
| 168 |
assert answer.location_context.matched_place == "Pacifica"
|
| 169 |
-
|
| 170 |
-
assert "
|
| 171 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""Source-backed Q&A tests."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
from dataclasses import replace
|
| 5 |
from datetime import datetime, timedelta, timezone
|
| 6 |
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from coastwise.qna import answer_question, answer_to_dict, interpret_question
|
| 10 |
+
from coastwise.schemas import (
|
| 11 |
+
AnswerStatus,
|
| 12 |
+
EvidenceType,
|
| 13 |
+
FreshnessStatus,
|
| 14 |
+
RequestedFactType,
|
| 15 |
+
StructuredRegulationFact,
|
| 16 |
+
)
|
| 17 |
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
|
| 18 |
|
| 19 |
|
|
|
|
| 145 |
assert "Dungeness crab: Verify current CDFW" not in answer.direct_answer
|
| 146 |
|
| 147 |
|
| 148 |
+
def test_structured_lingcod_answer_uses_exact_fact_after_invalid_model_interpretation():
|
| 149 |
+
payload = {
|
| 150 |
+
"species_or_category": "maine_lobster",
|
| 151 |
+
"location_name": "Mendocino",
|
| 152 |
+
"region_key": "mendocino",
|
| 153 |
+
"intent": "permission_status",
|
| 154 |
+
"requested_fact_types": ["season"],
|
| 155 |
+
"confidence": "high",
|
| 156 |
+
}
|
| 157 |
answer = answer_question(
|
| 158 |
"what's the min size of lingcod?",
|
| 159 |
"",
|
| 160 |
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 161 |
NOW,
|
| 162 |
+
model=lambda _prompt: json.dumps(payload),
|
| 163 |
)
|
| 164 |
|
| 165 |
assert answer.status is AnswerStatus.ANSWERED
|
|
|
|
| 168 |
assert not answer.model_assisted
|
| 169 |
|
| 170 |
|
| 171 |
+
def test_harvest_question_with_typos_answers_with_dated_sf_dungeness_season():
|
| 172 |
answer = answer_question(
|
| 173 |
"can i harvest dugenese crabs in pacifica today?",
|
| 174 |
"",
|
|
|
|
| 177 |
model=lambda _prompt: None,
|
| 178 |
)
|
| 179 |
|
| 180 |
+
# Pacifica resolves to the San Francisco area, which now has a dated season fact.
|
| 181 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 182 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 183 |
assert answer.location_context.matched_place == "Pacifica"
|
| 184 |
+
lowered = answer.direct_answer.casefold()
|
| 185 |
+
assert "season is open" in lowered
|
| 186 |
+
assert "november" in lowered # real SF-area Dungeness season dates
|
| 187 |
+
# States the season; never asserts personal legality to harvest.
|
| 188 |
+
assert "you may harvest" not in lowered
|
| 189 |
+
assert "legal to harvest" not in lowered
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_interpret_question_uses_model_for_messy_species_location_and_intent():
|
| 193 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 194 |
+
payload = {
|
| 195 |
+
"species_or_category": "dungeness_crab",
|
| 196 |
+
"location_name": "Mendocino",
|
| 197 |
+
"region_key": "mendocino",
|
| 198 |
+
"intent": "permission_status",
|
| 199 |
+
"requested_fact_types": ["season", "closure"],
|
| 200 |
+
"confidence": "high",
|
| 201 |
+
"notes": "Normalized misspellings.",
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
interpretation = interpret_question(
|
| 205 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 206 |
+
"",
|
| 207 |
+
cache,
|
| 208 |
+
model=lambda _prompt: json.dumps(payload),
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
assert interpretation.model_used
|
| 212 |
+
assert interpretation.species_or_category == "dungeness_crab"
|
| 213 |
+
assert interpretation.location_context.matched_place == "Mendocino"
|
| 214 |
+
assert interpretation.location_context.region_key == "mendocino"
|
| 215 |
+
assert interpretation.requested_fact_type is RequestedFactType.SEASON
|
| 216 |
+
assert "permission_status" in (interpretation.model_notes or "")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def test_interpret_question_rejects_model_generic_regional_species_term():
|
| 220 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 221 |
+
payload = {
|
| 222 |
+
"species_or_category": "regional",
|
| 223 |
+
"location_name": "Monterey",
|
| 224 |
+
"region_key": "central",
|
| 225 |
+
"intent": "regional_rules",
|
| 226 |
+
"requested_fact_types": ["general_summary"],
|
| 227 |
+
"confidence": "high",
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
interpretation = interpret_question(
|
| 231 |
+
"what should I check near Monterey?",
|
| 232 |
+
"Monterey",
|
| 233 |
+
cache,
|
| 234 |
+
model=lambda _prompt: json.dumps(payload),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
assert not interpretation.model_used
|
| 238 |
+
assert interpretation.species_or_category is None
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_interpret_question_rejects_model_unknown_species_and_uses_fallback():
|
| 242 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 243 |
+
payload = {
|
| 244 |
+
"species_or_category": "maine_lobster",
|
| 245 |
+
"location_name": "Mendocino",
|
| 246 |
+
"region_key": "mendocino",
|
| 247 |
+
"intent": "permission_status",
|
| 248 |
+
"requested_fact_types": ["season"],
|
| 249 |
+
"confidence": "high",
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
interpretation = interpret_question(
|
| 253 |
+
"can I harvest lobster in mendocino?",
|
| 254 |
+
"",
|
| 255 |
+
cache,
|
| 256 |
+
model=lambda _prompt: json.dumps(payload),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
assert not interpretation.model_used
|
| 260 |
+
assert interpretation.species_or_category is None
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_answer_question_uses_model_path_for_source_backed_dungeness_mendocino():
|
| 264 |
+
payload = {
|
| 265 |
+
"species_or_category": "dungeness_crab",
|
| 266 |
+
"location_name": "Mendocino",
|
| 267 |
+
"region_key": "mendocino",
|
| 268 |
+
"intent": "permission_status",
|
| 269 |
+
"requested_fact_types": ["season", "closure"],
|
| 270 |
+
"confidence": "high",
|
| 271 |
+
"notes": "Normalized misspellings.",
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
answer = answer_question(
|
| 275 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 276 |
+
"",
|
| 277 |
+
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 278 |
+
NOW,
|
| 279 |
+
model=lambda _prompt: json.dumps(payload),
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 283 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 284 |
+
assert answer.source_links
|
| 285 |
+
assert answer.retrieved_at is not None
|
| 286 |
+
assert answer.freshness_status is FreshnessStatus.CURRENT
|
| 287 |
+
assert answer.next_safe_action
|
| 288 |
+
assert "legal to harvest" not in answer.direct_answer.casefold()
|
| 289 |
+
assert answer.location_context.matched_place == "Mendocino"
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def test_answer_question_rejects_unknown_model_species_without_lobster_source_answer():
|
| 293 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 294 |
+
payload = {
|
| 295 |
+
"species_or_category": "maine_lobster",
|
| 296 |
+
"location_name": "Mendocino",
|
| 297 |
+
"region_key": "mendocino",
|
| 298 |
+
"intent": "permission_status",
|
| 299 |
+
"requested_fact_types": ["season"],
|
| 300 |
+
"confidence": "high",
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
interpretation = interpret_question(
|
| 304 |
+
"can I harvest maine lobster in mendocino?",
|
| 305 |
+
"",
|
| 306 |
+
cache,
|
| 307 |
+
model=lambda _prompt: json.dumps(payload),
|
| 308 |
+
)
|
| 309 |
+
answer = answer_question(
|
| 310 |
+
"can I harvest maine lobster in mendocino?",
|
| 311 |
+
"",
|
| 312 |
+
cache,
|
| 313 |
+
NOW,
|
| 314 |
+
model=lambda _prompt: json.dumps(payload),
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
assert not interpretation.model_used
|
| 318 |
+
assert interpretation.species_or_category is None
|
| 319 |
+
assert answer.status is AnswerStatus.UNSUPPORTED
|
| 320 |
+
assert answer.evidence_type is EvidenceType.UNSUPPORTED
|
| 321 |
+
assert answer.species_or_category is None
|
| 322 |
+
assert not answer.source_links
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def test_permission_question_surfaces_statewide_facts_and_flags_missing_area_season():
|
| 326 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 327 |
+
payload = {
|
| 328 |
+
"species_or_category": "dungeness_crab",
|
| 329 |
+
"location_name": "Mendocino",
|
| 330 |
+
"region_key": "mendocino",
|
| 331 |
+
"intent": "permission_status",
|
| 332 |
+
"requested_fact_types": ["season", "closure"],
|
| 333 |
+
"confidence": "high",
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
answer = answer_question(
|
| 337 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 338 |
+
"",
|
| 339 |
+
cache,
|
| 340 |
+
NOW,
|
| 341 |
+
model=lambda _prompt: json.dumps(payload),
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
# Statewide bag/size apply in Mendocino and are surfaced; the area-specific
|
| 345 |
+
# season we lack for Mendocino is flagged, and legality is never asserted.
|
| 346 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 347 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 348 |
+
assert answer.location_context.matched_place == "Mendocino"
|
| 349 |
+
assert "10 crab" in answer.direct_answer.casefold()
|
| 350 |
+
notes = " ".join(answer.uncertainty_notes).casefold()
|
| 351 |
+
assert "season" in notes or "closure" in notes
|
| 352 |
+
assert "legal to harvest" not in answer.direct_answer.casefold()
|
| 353 |
+
assert not any("cabezon" in snippet.casefold() for snippet in answer.supporting_snippets)
|
| 354 |
+
assert any("crab" in snippet.casefold() for snippet in answer.supporting_snippets)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def test_answer_question_unsupported_scope_cannot_be_overridden_by_model():
|
| 358 |
+
payload = {
|
| 359 |
+
"species_or_category": "dungeness_crab",
|
| 360 |
+
"location_name": "Mendocino",
|
| 361 |
+
"region_key": "mendocino",
|
| 362 |
+
"intent": "permission_status",
|
| 363 |
+
"requested_fact_types": ["season"],
|
| 364 |
+
"confidence": "high",
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
answer = answer_question(
|
| 368 |
+
"what is the trout limit in freshwater Lake Tahoe?",
|
| 369 |
+
"",
|
| 370 |
+
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 371 |
+
NOW,
|
| 372 |
+
model=lambda _prompt: json.dumps(payload),
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
assert answer.status is AnswerStatus.UNSUPPORTED
|
| 376 |
+
assert answer.evidence_type is EvidenceType.UNSUPPORTED
|
| 377 |
+
assert answer.location_context.confidence == "unsupported"
|
| 378 |
+
assert answer.location_context.matched_place is None
|
| 379 |
+
assert not answer.source_links
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def test_permission_question_answers_directly_when_season_fact_exists():
|
| 383 |
+
seed = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 384 |
+
season_fact = StructuredRegulationFact(
|
| 385 |
+
id="dungeness_crab_mendocino_season_status",
|
| 386 |
+
species_or_category="dungeness_crab",
|
| 387 |
+
display_name="Dungeness crab",
|
| 388 |
+
aliases=("dungeness crabs",),
|
| 389 |
+
area_or_scope="Mendocino California ocean region",
|
| 390 |
+
fact_type=RequestedFactType.SEASON,
|
| 391 |
+
value="season status is closed in the cached official source text",
|
| 392 |
+
units=None,
|
| 393 |
+
source_id="cdfw_crabs",
|
| 394 |
+
source_url="https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
|
| 395 |
+
retrieved_at=NOW,
|
| 396 |
+
supporting_chunk_id="chunk_dungeness_crab_season_context",
|
| 397 |
+
supporting_snippet="Dungeness crab season status is closed in this test source text.",
|
| 398 |
+
region_key="mendocino",
|
| 399 |
+
validation_set=True,
|
| 400 |
+
)
|
| 401 |
+
cache = SourceCache(
|
| 402 |
+
sources=seed.sources,
|
| 403 |
+
snapshots=seed.snapshots,
|
| 404 |
+
chunks=seed.chunks,
|
| 405 |
+
facts=(*seed.facts, season_fact),
|
| 406 |
+
advisory_facts=seed.advisory_facts,
|
| 407 |
+
)
|
| 408 |
+
payload = {
|
| 409 |
+
"species_or_category": "dungeness_crab",
|
| 410 |
+
"location_name": "Mendocino",
|
| 411 |
+
"region_key": "mendocino",
|
| 412 |
+
"intent": "permission_status",
|
| 413 |
+
"requested_fact_types": ["season", "closure"],
|
| 414 |
+
"confidence": "high",
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
answer = answer_question(
|
| 418 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 419 |
+
"",
|
| 420 |
+
cache,
|
| 421 |
+
NOW,
|
| 422 |
+
model=lambda _prompt: json.dumps(payload),
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 426 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 427 |
+
# Source is attributed via source_titles, not an inline preamble.
|
| 428 |
+
assert any("CDFW" in title for title in answer.source_titles)
|
| 429 |
+
assert "closed" in answer.direct_answer
|
| 430 |
+
assert "legal to harvest" not in answer.direct_answer.casefold()
|
| 431 |
+
assert answer.location_context.region_key == "mendocino"
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def test_answer_to_dict_sanitizes_structured_bundle_forbidden_copy():
|
| 435 |
+
"""A structured-fact value carrying forbidden copy is sanitized at serialization."""
|
| 436 |
+
seed = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 437 |
+
poisoned = StructuredRegulationFact(
|
| 438 |
+
id="dungeness_crab_mendocino_poisoned",
|
| 439 |
+
species_or_category="dungeness_crab",
|
| 440 |
+
display_name="Dungeness crab",
|
| 441 |
+
aliases=("dungeness crabs",),
|
| 442 |
+
area_or_scope="Mendocino California ocean region",
|
| 443 |
+
fact_type=RequestedFactType.SEASON,
|
| 444 |
+
value="legal to harvest now per cached source",
|
| 445 |
+
units=None,
|
| 446 |
+
source_id="cdfw_crabs",
|
| 447 |
+
source_url="https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
|
| 448 |
+
retrieved_at=NOW,
|
| 449 |
+
supporting_chunk_id="chunk_dungeness_crab_season_context",
|
| 450 |
+
supporting_snippet="Test poisoned snippet.",
|
| 451 |
+
region_key="mendocino",
|
| 452 |
+
validation_set=True,
|
| 453 |
+
)
|
| 454 |
+
cache = SourceCache(
|
| 455 |
+
sources=seed.sources,
|
| 456 |
+
snapshots=seed.snapshots,
|
| 457 |
+
chunks=seed.chunks,
|
| 458 |
+
facts=(*seed.facts, poisoned),
|
| 459 |
+
advisory_facts=seed.advisory_facts,
|
| 460 |
+
)
|
| 461 |
+
payload = {
|
| 462 |
+
"species_or_category": "dungeness_crab",
|
| 463 |
+
"location_name": "Mendocino",
|
| 464 |
+
"region_key": "mendocino",
|
| 465 |
+
"intent": "permission_status",
|
| 466 |
+
"requested_fact_types": ["season", "closure"],
|
| 467 |
+
"confidence": "high",
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
answer = answer_question(
|
| 471 |
+
"im in mandocino. is it legal to harvest dungenese crabs?",
|
| 472 |
+
"",
|
| 473 |
+
cache,
|
| 474 |
+
NOW,
|
| 475 |
+
model=lambda _prompt: json.dumps(payload),
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# The raw bundle answer is ANSWERED and carries the forbidden phrase verbatim...
|
| 479 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 480 |
+
assert "legal to harvest" in answer.direct_answer.casefold()
|
| 481 |
+
|
| 482 |
+
# ...but the serialization boundary the UI uses sanitizes and downgrades it.
|
| 483 |
+
serialized = answer_to_dict(answer)
|
| 484 |
+
assert serialized["status"] == "partial"
|
| 485 |
+
assert "legal to harvest" not in serialized["direct_answer"].casefold()
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def test_cabezon_evidence_excludes_unrelated_species_snippets():
|
| 489 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 490 |
+
payload = {
|
| 491 |
+
"species_or_category": "cabezon",
|
| 492 |
+
"intent": "general",
|
| 493 |
+
"requested_fact_types": ["advisory"],
|
| 494 |
+
"confidence": "high",
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
answer = answer_question(
|
| 498 |
+
"is it legal to catch cabazon in pacifica?",
|
| 499 |
+
"",
|
| 500 |
+
cache,
|
| 501 |
+
NOW,
|
| 502 |
+
model=lambda _prompt: json.dumps(payload),
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
joined = " ".join(answer.supporting_snippets).casefold()
|
| 506 |
+
assert "cabezon" in joined
|
| 507 |
+
assert "dungeness" not in joined
|
| 508 |
+
assert "quarantine" not in joined
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
def test_model_path_uses_deterministic_fact_type_not_model_advisory_misclassification():
|
| 512 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 513 |
+
payload = {
|
| 514 |
+
"species_or_category": "cabezon",
|
| 515 |
+
"intent": "general",
|
| 516 |
+
"requested_fact_types": ["advisory"],
|
| 517 |
+
"confidence": "high",
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
interpretation = interpret_question(
|
| 521 |
+
"is it legal to catch cabazon in pacifica?",
|
| 522 |
+
"",
|
| 523 |
+
cache,
|
| 524 |
+
model=lambda _prompt: json.dumps(payload),
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
assert interpretation.model_used
|
| 528 |
+
assert interpretation.species_or_category == "cabezon"
|
| 529 |
+
assert interpretation.location_context.matched_place == "Pacifica"
|
| 530 |
+
# the model mislabeled a catch question as "advisory"; deterministic wins
|
| 531 |
+
assert interpretation.requested_fact_type is RequestedFactType.SEASON
|
| 532 |
+
assert "advisory" not in (interpretation.model_notes or "")
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def test_permission_question_surfaces_all_available_species_facts():
|
| 536 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 537 |
+
|
| 538 |
+
answer = answer_question("is it legal to catch lingcod?", "", cache, NOW)
|
| 539 |
+
|
| 540 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 541 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 542 |
+
lowered = answer.direct_answer.casefold()
|
| 543 |
+
assert "22" in answer.direct_answer
|
| 544 |
+
assert "bag limit" in lowered
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def test_permission_question_answers_cabezon_with_size_bag_and_season():
|
| 548 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 549 |
+
|
| 550 |
+
answer = answer_question("is it legal to catch cabezon in pacifica?", "", cache, NOW)
|
| 551 |
+
|
| 552 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 553 |
+
assert answer.evidence_type is EvidenceType.STRUCTURED_FACT
|
| 554 |
+
assert answer.species_or_category == "Cabezon"
|
| 555 |
+
lowered = answer.direct_answer.casefold()
|
| 556 |
+
# Source-backed cabezon regulation values (CDFW Groundfish Summary).
|
| 557 |
+
assert "no minimum size limit" in lowered
|
| 558 |
+
assert "10" in answer.direct_answer # RCG-complex aggregate bag limit
|
| 559 |
+
assert "april" in lowered # RCG-complex season now encoded for the SF management area
|
| 560 |
+
assert any("groundfish" in title.casefold() for title in answer.source_titles)
|
| 561 |
+
# States facts, never a bare legality verdict.
|
| 562 |
+
assert "you may harvest" not in lowered
|
| 563 |
+
assert "authorized" not in lowered
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
EXPANDED_SEED_CASES = [
|
| 567 |
+
("what is the minimum size for california halibut?", "22"),
|
| 568 |
+
("white seabass size limit", "28"),
|
| 569 |
+
("leopard shark bag limit", "3"),
|
| 570 |
+
("rock crab bag limit", "35"),
|
| 571 |
+
("purple sea urchin limit", "35"),
|
| 572 |
+
("can i take red abalone?", "closed"),
|
| 573 |
+
("is it legal to catch white sturgeon?", "catch-and-release"),
|
| 574 |
+
("kelp harvest limit", "10 pounds"),
|
| 575 |
+
("is it legal to catch rockfish in pacifica?", "no minimum size limit"),
|
| 576 |
+
("redtail surfperch minimum size", "10"),
|
| 577 |
+
]
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
def test_general_rules_question_surfaces_real_facts_not_generic_placeholder():
|
| 581 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 582 |
+
|
| 583 |
+
answer = answer_question("dungeness crab rules", "", cache, NOW)
|
| 584 |
+
|
| 585 |
+
assert answer.status is AnswerStatus.ANSWERED
|
| 586 |
+
lowered = answer.direct_answer.casefold()
|
| 587 |
+
# Real values, not the bare "verify current CDFW Dungeness crab season..." placeholder.
|
| 588 |
+
assert "10 crab" in lowered
|
| 589 |
+
assert "5¾" in answer.direct_answer
|
| 590 |
+
assert "season is open" in lowered
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
def test_model_species_guard_overrides_lexically_implausible_resolution():
|
| 594 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 595 |
+
# The small model rounds a rare species to a wrong in-enum species (mussels)
|
| 596 |
+
# that has no lexical basis in the question.
|
| 597 |
+
payload = {
|
| 598 |
+
"species_or_category": "mussels",
|
| 599 |
+
"location_name": None,
|
| 600 |
+
"region_key": None,
|
| 601 |
+
"intent": "permission_status",
|
| 602 |
+
"requested_fact_types": ["season"],
|
| 603 |
+
"confidence": "high",
|
| 604 |
+
}
|
| 605 |
+
model = lambda _prompt: json.dumps(payload)
|
| 606 |
+
|
| 607 |
+
interpretation = interpret_question(
|
| 608 |
+
"is it legal to catch soupfin shark?", "", cache, model=model
|
| 609 |
+
)
|
| 610 |
+
assert interpretation.species_or_category != "mussels"
|
| 611 |
+
|
| 612 |
+
answer = answer_question(
|
| 613 |
+
"is it legal to catch soupfin shark?", "", cache, NOW, model=model
|
| 614 |
+
)
|
| 615 |
+
# Must never answer a shark question with the mussel food-safety advisory.
|
| 616 |
+
assert "mussel" not in answer.direct_answer.casefold()
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
def test_model_species_guard_prefers_specific_alias_over_model_substring_pick():
|
| 620 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 621 |
+
# The model rounds "kelp bass" to the plant "kelp"; the specific fact alias
|
| 622 |
+
# ("kelp bass") must win over the model's substring pick.
|
| 623 |
+
payload = {
|
| 624 |
+
"species_or_category": "kelp_plants",
|
| 625 |
+
"intent": "general",
|
| 626 |
+
"requested_fact_types": ["minimum_size"],
|
| 627 |
+
"confidence": "high",
|
| 628 |
+
}
|
| 629 |
+
interpretation = interpret_question(
|
| 630 |
+
"what is the size limit for kelp bass?",
|
| 631 |
+
"",
|
| 632 |
+
cache,
|
| 633 |
+
model=lambda _prompt: json.dumps(payload),
|
| 634 |
+
)
|
| 635 |
+
assert interpretation.species_or_category == "kelp_bass"
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def test_model_species_guard_keeps_typo_normalization_when_search_is_empty():
|
| 639 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 640 |
+
# Deterministic name search can't resolve the typo, so the model's
|
| 641 |
+
# normalization (cabazon -> cabezon) must be preserved.
|
| 642 |
+
payload = {
|
| 643 |
+
"species_or_category": "cabezon",
|
| 644 |
+
"location_name": "Pacifica",
|
| 645 |
+
"region_key": "san_francisco",
|
| 646 |
+
"intent": "permission_status",
|
| 647 |
+
"requested_fact_types": ["season"],
|
| 648 |
+
"confidence": "high",
|
| 649 |
+
}
|
| 650 |
+
interpretation = interpret_question(
|
| 651 |
+
"is it legal to catch cabazon in pacifica?",
|
| 652 |
+
"",
|
| 653 |
+
cache,
|
| 654 |
+
model=lambda _prompt: json.dumps(payload),
|
| 655 |
+
)
|
| 656 |
+
assert interpretation.species_or_category == "cabezon"
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def test_species_without_facts_falls_back_to_reviewed_rule_card_not_wrong_species():
|
| 660 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 661 |
+
|
| 662 |
+
answer = answer_question(
|
| 663 |
+
"is it legal to catch california scorpionfish?", "", cache, NOW
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
lowered = answer.direct_answer.casefold()
|
| 667 |
+
# Recognized species with no extracted fact -> reviewed card posture, not a guess.
|
| 668 |
+
assert answer.status is AnswerStatus.PARTIAL
|
| 669 |
+
assert "scorpionfish" in answer.species_or_category.casefold()
|
| 670 |
+
assert "rules apply" in lowered or "verify" in lowered
|
| 671 |
+
# Must never resolve to an unrelated species' facts.
|
| 672 |
+
assert "halibut" not in lowered
|
| 673 |
+
assert "22 inches" not in lowered
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def test_allowed_species_covers_full_rule_card_taxonomy():
|
| 677 |
+
from coastwise.data import all_rule_cards
|
| 678 |
+
from coastwise.qna import _allowed_species_from_cache
|
| 679 |
+
|
| 680 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 681 |
+
allowed = _allowed_species_from_cache(cache)
|
| 682 |
+
|
| 683 |
+
# Every curated species the deterministic path knows must also be offered to
|
| 684 |
+
# the model, so it cannot round an unknown species to a wrong neighbor.
|
| 685 |
+
for card in all_rule_cards():
|
| 686 |
+
if card.key == "unknown":
|
| 687 |
+
continue
|
| 688 |
+
assert card.key in allowed, f"{card.key} missing from allowed species"
|
| 689 |
+
|
| 690 |
+
|
| 691 |
+
STATEWIDE_SEED_CASES = [
|
| 692 |
+
("kelp bass size limit", "14"),
|
| 693 |
+
("california sheephead bag limit", "2"),
|
| 694 |
+
("pacific bonito minimum size", "24"),
|
| 695 |
+
("california yellowtail bag limit", "10"),
|
| 696 |
+
("ocean whitefish size limit", "no minimum size limit"),
|
| 697 |
+
("california spiny lobster season", "october"),
|
| 698 |
+
("market squid limit", "no daily bag limit"),
|
| 699 |
+
("geoduck clam limit", "3"),
|
| 700 |
+
("white croaker limit", "10"),
|
| 701 |
+
("is it legal to catch giant sea bass?", "prohibited"),
|
| 702 |
+
("is it legal to catch white shark?", "prohibited"),
|
| 703 |
+
("california grunion season", "april"),
|
| 704 |
+
]
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
@pytest.mark.parametrize("query, must_contain", STATEWIDE_SEED_CASES)
|
| 708 |
+
def test_statewide_seed_answers_real_values(query, must_contain):
|
| 709 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 710 |
+
|
| 711 |
+
answer = answer_question(query, "", cache, NOW)
|
| 712 |
+
|
| 713 |
+
assert answer.status is AnswerStatus.ANSWERED, f"{query!r} -> {answer.status}: {answer.direct_answer}"
|
| 714 |
+
assert (
|
| 715 |
+
must_contain.casefold() in answer.direct_answer.casefold()
|
| 716 |
+
), f"{query!r} -> {answer.direct_answer}"
|
| 717 |
+
lowered = answer.direct_answer.casefold()
|
| 718 |
+
assert "you may harvest" not in lowered
|
| 719 |
+
assert "is safe to eat" not in lowered
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
@pytest.mark.parametrize("query, must_contain", EXPANDED_SEED_CASES)
|
| 723 |
+
def test_expanded_seed_answers_real_values(query, must_contain):
|
| 724 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 725 |
+
|
| 726 |
+
answer = answer_question(query, "", cache, NOW)
|
| 727 |
+
|
| 728 |
+
assert answer.status is AnswerStatus.ANSWERED, f"{query!r} -> {answer.status}: {answer.direct_answer}"
|
| 729 |
+
assert (
|
| 730 |
+
must_contain.casefold() in answer.direct_answer.casefold()
|
| 731 |
+
), f"{query!r} -> {answer.direct_answer}"
|
| 732 |
+
# Source-backed answers never assert legality or food safety.
|
| 733 |
+
lowered = answer.direct_answer.casefold()
|
| 734 |
+
assert "you may harvest" not in lowered
|
| 735 |
+
assert "is safe to eat" not in lowered
|
|
@@ -389,12 +389,14 @@ def test_direct_answer_concept_helper_allows_concrete_dungeness_results():
|
|
| 389 |
|
| 390 |
@pytest.mark.parametrize("case", _cases(), ids=lambda case: case["query"])
|
| 391 |
def test_real_user_queries_use_saved_evidence_without_generic_answers(case):
|
|
|
|
|
|
|
| 392 |
answer = answer_question(
|
| 393 |
str(case["query"]),
|
| 394 |
"",
|
| 395 |
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 396 |
NOW,
|
| 397 |
-
model=
|
| 398 |
)
|
| 399 |
payload = answer_to_dict(answer)
|
| 400 |
source_material = " ".join(
|
|
|
|
| 389 |
|
| 390 |
@pytest.mark.parametrize("case", _cases(), ids=lambda case: case["query"])
|
| 391 |
def test_real_user_queries_use_saved_evidence_without_generic_answers(case):
|
| 392 |
+
model_payload = case.get("model_interpretation")
|
| 393 |
+
model = (lambda _prompt, payload=model_payload: json.dumps(payload)) if model_payload else (lambda _prompt: None)
|
| 394 |
answer = answer_question(
|
| 395 |
str(case["query"]),
|
| 396 |
"",
|
| 397 |
load_seed_cache(DEFAULT_SEED_CACHE_DIR),
|
| 398 |
NOW,
|
| 399 |
+
model=model,
|
| 400 |
)
|
| 401 |
payload = answer_to_dict(answer)
|
| 402 |
source_material = " ".join(
|
|
@@ -296,3 +296,42 @@ def test_query_context_and_safety_result_keep_sources_and_status_explicit():
|
|
| 296 |
retrieved_on=date(2026, 6, 1),
|
| 297 |
)
|
| 298 |
assert source.url.startswith("https://")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
retrieved_on=date(2026, 6, 1),
|
| 297 |
)
|
| 298 |
assert source.url.startswith("https://")
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def test_model_question_interpretation_validates_allowlisted_shape():
|
| 302 |
+
from coastwise.schemas import ModelQuestionInterpretation, SourceBackedIntent
|
| 303 |
+
|
| 304 |
+
interpretation = ModelQuestionInterpretation(
|
| 305 |
+
species_or_category="dungeness_crab",
|
| 306 |
+
location_name="Mendocino",
|
| 307 |
+
region_key="mendocino",
|
| 308 |
+
intent="permission_status",
|
| 309 |
+
requested_fact_types=("season", "closure"),
|
| 310 |
+
confidence="high",
|
| 311 |
+
notes="Normalized typo species and location.",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
assert interpretation.intent is SourceBackedIntent.PERMISSION_STATUS
|
| 315 |
+
assert interpretation.requested_fact_types == (
|
| 316 |
+
RequestedFactType.SEASON,
|
| 317 |
+
RequestedFactType.CLOSURE,
|
| 318 |
+
)
|
| 319 |
+
assert interpretation.confidence == "high"
|
| 320 |
+
assert interpretation.species_or_category == "dungeness_crab"
|
| 321 |
+
assert interpretation.location_name == "Mendocino"
|
| 322 |
+
assert interpretation.region_key == "mendocino"
|
| 323 |
+
assert interpretation.notes == "Normalized typo species and location."
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def test_model_question_interpretation_rejects_unsupported_confidence():
|
| 327 |
+
from coastwise.schemas import ModelQuestionInterpretation, SourceBackedIntent
|
| 328 |
+
|
| 329 |
+
with pytest.raises(ValueError, match="model interpretation confidence"):
|
| 330 |
+
ModelQuestionInterpretation(
|
| 331 |
+
species_or_category=None,
|
| 332 |
+
location_name=None,
|
| 333 |
+
region_key=None,
|
| 334 |
+
intent=SourceBackedIntent.GENERAL,
|
| 335 |
+
requested_fact_types=(RequestedFactType.GENERAL_SUMMARY,),
|
| 336 |
+
confidence="certain",
|
| 337 |
+
)
|
|
@@ -70,3 +70,13 @@ def test_source_backed_search_handles_public_typos_and_aliases():
|
|
| 70 |
matches = search_source_candidates(query, cache)
|
| 71 |
assert matches, query
|
| 72 |
assert matches[0].rule_card_key == key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
matches = search_source_candidates(query, cache)
|
| 71 |
assert matches, query
|
| 72 |
assert matches[0].rule_card_key == key
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_source_backed_typos_are_fallback_not_primary_interpretation_contract():
|
| 76 |
+
cache = load_seed_cache(DEFAULT_SEED_CACHE_DIR)
|
| 77 |
+
|
| 78 |
+
matches = search_source_candidates("dugenese crab", cache)
|
| 79 |
+
|
| 80 |
+
assert matches
|
| 81 |
+
assert matches[0].rule_card_key == "dungeness_crab"
|
| 82 |
+
assert matches[0].reason == "source_alias"
|
|
@@ -44,3 +44,27 @@ def test_extract_structured_validation_set_facts_from_seed_chunks():
|
|
| 44 |
|
| 45 |
assert {"lingcod_min_size", "lingcod_bag_limit", "dungeness_crab_bag_limit"} <= fact_ids
|
| 46 |
assert not result.issues
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
assert {"lingcod_min_size", "lingcod_bag_limit", "dungeness_crab_bag_limit"} <= fact_ids
|
| 46 |
assert not result.issues
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_extract_structured_facts_finds_explicit_season_and_gear_facts():
|
| 50 |
+
snapshot = SourceSnapshot(
|
| 51 |
+
source_id="cdfw_crabs",
|
| 52 |
+
url="https://wildlife.ca.gov/Conservation/Marine/Invertebrates/Crabs",
|
| 53 |
+
title="CDFW Crabs",
|
| 54 |
+
retrieved_at="2026-06-14T12:00:00+00:00",
|
| 55 |
+
content_hash="crabs-test",
|
| 56 |
+
raw_text=(
|
| 57 |
+
"Dungeness crab season status is closed for the Mendocino ocean region. "
|
| 58 |
+
"Dungeness crab trap and hoop net rules apply before harvest."
|
| 59 |
+
),
|
| 60 |
+
origin="seed",
|
| 61 |
+
refresh_status=RefreshStatus.UNCHANGED,
|
| 62 |
+
)
|
| 63 |
+
chunks = extract_source_chunks(snapshot)
|
| 64 |
+
result = extract_structured_facts(chunks, default_structured_validation_set())
|
| 65 |
+
|
| 66 |
+
facts = {(fact.species_or_category, fact.fact_type.value): fact for fact in result.facts}
|
| 67 |
+
|
| 68 |
+
assert facts[("dungeness_crab", "season")].region_key == "mendocino"
|
| 69 |
+
assert "closed" in facts[("dungeness_crab", "season")].value
|
| 70 |
+
assert "trap" in facts[("dungeness_crab", "gear_or_method")].gear_or_method_notes
|
|
@@ -21,6 +21,15 @@ def test_default_model_inventory_documents_minicpm_under_hackathon_caps():
|
|
| 21 |
assert not validate_model_inventory(inventory)
|
| 22 |
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
def test_submission_package_validator_flags_missing_public_evidence():
|
| 25 |
inventory = default_model_inventory()
|
| 26 |
package = SubmissionPackage(
|
|
@@ -52,6 +61,7 @@ def test_readme_documents_statewide_source_cache_policy_and_validation_limits():
|
|
| 52 |
assert "structured answers for the reviewed validation set" in readme
|
| 53 |
assert "snippet fallback" in readme
|
| 54 |
assert "questions and optional beach/coastal-area text are not saved" in readme
|
|
|
|
| 55 |
for source in load_official_sources():
|
| 56 |
if source.id in coverage.required_source_ids:
|
| 57 |
assert source.url.casefold() in readme
|
|
|
|
| 21 |
assert not validate_model_inventory(inventory)
|
| 22 |
|
| 23 |
|
| 24 |
+
def test_model_inventory_declares_hosted_minicpm_interpreter_role():
|
| 25 |
+
from coastwise.schemas import ModelRole, RuntimeMode
|
| 26 |
+
|
| 27 |
+
entry = default_model_inventory().entries[0]
|
| 28 |
+
|
| 29 |
+
assert entry.runtime_mode is RuntimeMode.HOSTED_ENDPOINT
|
| 30 |
+
assert ModelRole.QUESTION_INTERPRETATION in entry.roles
|
| 31 |
+
|
| 32 |
+
|
| 33 |
def test_submission_package_validator_flags_missing_public_evidence():
|
| 34 |
inventory = default_model_inventory()
|
| 35 |
package = SubmissionPackage(
|
|
|
|
| 61 |
assert "structured answers for the reviewed validation set" in readme
|
| 62 |
assert "snippet fallback" in readme
|
| 63 |
assert "questions and optional beach/coastal-area text are not saved" in readme
|
| 64 |
+
assert "coastwise_llm_api_key" in readme
|
| 65 |
for source in load_official_sources():
|
| 66 |
if source.id in coverage.required_source_ids:
|
| 67 |
assert source.url.casefold() in readme
|