Stephen S. Lee commited on
Commit
c1b067e
·
unverified ·
1 Parent(s): 55bb211

feat: source-derived Q&A with statewide CA ocean species coverage (#1)

Browse files

Source-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 CHANGED
@@ -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 is the planned small-model path for constrained
39
- safety-result explanation.
 
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 can answer
42
- supported questions without cloud APIs.
 
 
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 constrained wording over deterministic answers | 1.0B | Disabled/mocked fallback in current local MVP | No | OpenBMB, Tiny Titan |
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
app.py CHANGED
@@ -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
- result, _cache = refresh_official_sources(
 
524
  sources,
525
- _active_cache(),
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,
coastwise/ai.py CHANGED
@@ -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,
coastwise/locations.py CHANGED
@@ -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 combined:
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,
coastwise/qna.py CHANGED
@@ -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.locations import parse_location
 
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
- species_key = candidates[0].rule_card_key if candidates else None
129
- confidence = "high" if species_key else "medium"
 
 
 
 
 
 
 
 
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
- if not interpretation.species_or_category:
 
146
  return None
147
- for fact in cache.facts:
148
- same_species = fact.species_or_category == interpretation.species_or_category
 
 
 
 
 
 
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 same_species and (same_type or general_summary):
 
 
 
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
- selected = [chunk for score, chunk in ranked if score >= 2][:limit]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=interpretation.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=PUBLIC_SAVED_EVIDENCE_NEXT_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
- units = f" {fact.units}" if fact.units else ""
 
256
  return f"{fact.display_name} minimum size is {fact.value}{units}."
257
  if fact.fact_type is RequestedFactType.BAG_LIMIT:
258
- units = f" {fact.units}" if fact.units else ""
 
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
- packet = build_cached_evidence_packet(interpretation, cache, now)
 
 
 
 
 
 
 
 
 
 
 
 
540
  if packet is not None:
541
- return _cached_evidence_answer(packet, interpretation, cache, now, model=model)
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
coastwise/schemas.py CHANGED
@@ -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
coastwise/search.py CHANGED
@@ -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 or normalized in alias:
180
- matches.append(SpeciesMatch(key, _source_display_name(key), 0.72, "source_partial"))
 
 
 
 
 
 
 
 
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):
coastwise/source_extract.py CHANGED
@@ -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"):
coastwise/submission.py CHANGED
@@ -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.SAFETY_EXPLANATION,),
28
- runtime_mode=RuntimeMode.DISABLED_OR_MOCKED,
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
  ),
data/source-cache/seed/chunks.json CHANGED
@@ -84,14 +84,14 @@
84
  "location_terms": []
85
  },
86
  {
87
- "id": "chunk_cabezon_context",
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 Context",
93
- "text": "Saved CDFW groundfish summary information includes cabezon as a groundfish species. Restrictions may include season, depth, bag, size, gear, and area rules, but this saved cache does not include a reviewed exact cabezon limit.",
94
- "tokens": ["saved", "cdfw", "groundfish", "cabezon", "restrictions", "season", "depth", "bag", "size", "gear", "area"],
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
  },
data/source-cache/seed/facts.json CHANGED
@@ -4,7 +4,9 @@
4
  "id": "lingcod_min_size",
5
  "species_or_category": "lingcod",
6
  "display_name": "Lingcod",
7
- "aliases": ["ling cod"],
 
 
8
  "area_or_scope": "California ocean statewide groundfish regulations",
9
  "fact_type": "minimum_size",
10
  "value": "22",
@@ -20,7 +22,9 @@
20
  "id": "lingcod_bag_limit",
21
  "species_or_category": "lingcod",
22
  "display_name": "Lingcod",
23
- "aliases": ["ling cod"],
 
 
24
  "area_or_scope": "California ocean statewide groundfish regulations",
25
  "fact_type": "bag_limit",
26
  "value": "2",
@@ -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": ["dungeness crabs", "crab"],
 
 
 
40
  "area_or_scope": "California recreational crab fishery",
41
  "fact_type": "general_summary",
42
  "value": "Verify current CDFW Dungeness crab season, trap/hoop-net rules, public health closures, and crab rules before harvest.",
@@ -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": [
requirements.txt CHANGED
@@ -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
scripts/seed_expand.py ADDED
@@ -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()
scripts/seed_expand_statewide.py ADDED
@@ -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()
tests/fixtures/real_user_queries.json CHANGED
@@ -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"]
tests/test_ai.py CHANGED
@@ -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"]
tests/test_app.py CHANGED
@@ -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()
tests/test_locations.py CHANGED
@@ -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"
tests/test_qna.py CHANGED
@@ -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
- from coastwise.qna import answer_question
7
- from coastwise.schemas import AnswerStatus, EvidenceType, FreshnessStatus
 
 
 
 
 
 
 
 
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 test_structured_lingcod_answer_does_not_call_model_when_exact_fact_exists():
140
- def fail_if_called(_prompt):
141
- raise AssertionError("structured fact path should not call model")
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=fail_if_called,
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 test_harvest_question_with_typos_uses_cached_evidence_not_bare_warning():
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
- assert answer.status is AnswerStatus.PARTIAL
167
- assert answer.evidence_type is EvidenceType.SOURCE_SNIPPET
 
168
  assert answer.location_context.matched_place == "Pacifica"
169
- assert any("Pacifica" in snippet for snippet in answer.supporting_snippets)
170
- assert "saved official source text" in answer.direct_answer
171
- assert "does not have an extracted exact answer" in answer.direct_answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
tests/test_real_user_queries.py CHANGED
@@ -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=lambda _packet: None,
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(
tests/test_schemas.py CHANGED
@@ -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
+ )
tests/test_search.py CHANGED
@@ -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"
tests/test_source_extract.py CHANGED
@@ -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
tests/test_submission.py CHANGED
@@ -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