"""CoastWise Gradio application."""
from __future__ import annotations
import html
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
import gradio as gr
from coastwise.ai import (
build_hosted_interpreter,
build_local_interpreter,
build_lookup_workflow_trace,
explain_safety_result,
explanation_to_dict,
workflow_trace_to_dict,
)
from coastwise.data import get_rule_card
from coastwise.photo import candidate_from_photo, photo_result_to_dict
from coastwise.qna import answer_question, answer_to_dict
from coastwise.rules import compose_safety_result, default_context, result_to_dict
from coastwise.schemas import CoastalRegion, UserIntent
from coastwise.search import search_species
from coastwise.source_cache import DEFAULT_SEED_CACHE_DIR, SourceCache, load_seed_cache
from coastwise.source_refresh import fetch_official_source, refresh_official_sources
from coastwise.source_registry import load_official_sources
from coastwise.submission import default_model_inventory
from coastwise.webui import (
APP_CSS,
EMPTY_STATE_HTML,
FOOTER_NOTE,
build_header_html,
build_theme,
human_retrieved,
render_source_backed_answer_payload,
render_source_bar_html,
)
APP_TITLE = "CoastWise"
SOURCE_BACKED_CONTROL_LABELS = (
"Question",
"Ask",
"Refresh official sources",
"Source status",
)
INTRO = """
California CDFW/CDPH Q&A.
"""
EXAMPLE_QUESTIONS = (
"what's the min size of lingcod?",
"can I eat mussels from Pacifica today?",
"Dungeness crab rules",
)
# Ocean & Trust theme + brand CSS now live in coastwise.webui so the server app
# and the in-browser offline (Gradio-Lite) build render identically.
THEME = build_theme()
LIMITS = """
Conservative decision-support only.
Does not certify species ID or legality.
Does not authorize harvest or food safety.
Verify CDFW/CDPH before harvest.
"""
SOURCE_STATUS_DEFAULT = (
"**Source status** — seed cache loaded and offline-ready. "
"Refresh re-checks the official CDFW/CDPH sources."
)
HEADER_HTML = build_header_html()
INTENT_LABELS = {
"Just curious": UserIntent.JUST_CURIOUS,
"Catch and release": UserIntent.CATCH_AND_RELEASE,
"Thinking of harvest": UserIntent.THINKING_OF_HARVEST,
}
REGION_LABELS = {
"Point Arena to Pigeon Point": CoastalRegion.POINT_ARENA_TO_PIGEON_POINT,
"San Francisco Bay": CoastalRegion.SAN_FRANCISCO_BAY,
"Unknown": CoastalRegion.UNKNOWN,
}
SOURCE_BACKED_INTERPRETER = build_local_interpreter() or build_hosted_interpreter()
# Holds the most recent refreshed cache for the running session, so the
# "Refresh official sources" button actually changes what subsequent answers
# read. None means "use the bundled reviewed seed".
_SESSION_CACHE: SourceCache | None = None
def _active_cache():
return _SESSION_CACHE if _SESSION_CACHE is not None else load_seed_cache(DEFAULT_SEED_CACHE_DIR)
def ask_source_backed_question(question: str, beach_or_coastal_area: str = "") -> dict[str, object]:
answer = answer_question(
question,
beach_or_coastal_area,
_active_cache(),
datetime.now(timezone.utc),
model=SOURCE_BACKED_INTERPRETER,
)
return answer_to_dict(answer)
def render_source_backed_answer_from_cache(
question: str,
beach_or_coastal_area: str,
cache,
now: datetime,
) -> str:
answer = answer_question(question, beach_or_coastal_area, cache, now, model=SOURCE_BACKED_INTERPRETER)
return render_source_backed_answer_payload(answer_to_dict(answer))
def render_source_backed_answer(question: str, beach_or_coastal_area: str = "") -> str:
result = ask_source_backed_question(question, beach_or_coastal_area)
return render_source_backed_answer_payload(result)
def _answer_from_example(example: str) -> tuple[str, str]:
"""Populate the question box with an example and render its answer in one click."""
return example, render_source_backed_answer(example, "")
def _bounded_live_fetcher(sources, per_source_timeout: float = 2.5, overall_deadline: float = 3.0):
"""Fetch sources concurrently within a tight overall deadline so the live Refresh
never hangs the UI on slow or unreachable networks (offline-first). Returns a
fetcher that serves pre-fetched text; misses raise so refresh_official_sources
keeps the last-good cache for them."""
fetched: dict[str, str] = {}
executor = ThreadPoolExecutor(max_workers=min(8, max(1, len(sources))))
futures = {
executor.submit(fetch_official_source, source, timeout=per_source_timeout): source
for source in sources
}
try:
for future in as_completed(futures, timeout=overall_deadline):
try:
fetched[futures[future].id] = future.result()
except Exception:
pass
except Exception:
pass # overall deadline reached — proceed with whatever completed
executor.shutdown(wait=False, cancel_futures=True)
def fetcher(source):
if source.id in fetched:
return fetched[source.id]
raise RuntimeError("source unavailable within refresh deadline")
return fetcher
def refresh_official_sources_status(fetcher=None) -> dict[str, object]:
global _SESSION_CACHE
sources = load_official_sources()
if fetcher is None:
fetcher = _bounded_live_fetcher(sources)
base = _active_cache()
result, refreshed = refresh_official_sources(
sources,
base,
datetime.now(timezone.utc),
fetcher,
)
# Apply the re-fetch to the live session as updated snapshot freshness, but
# keep the reviewed chunks/facts: structured regulation values stay curated
# and are never auto-derived from freshly fetched HTML.
_SESSION_CACHE = SourceCache(
sources=refreshed.sources,
snapshots=refreshed.snapshots,
chunks=base.chunks,
facts=base.facts,
advisory_facts=base.advisory_facts,
)
return {
"message": result.user_message,
"updated": result.updated_source_ids,
"unchanged": result.unchanged_source_ids,
"failed": result.failed_source_ids,
"cache_preserved": result.cache_preserved_source_ids,
}
def render_source_bar() -> str:
"""Top-of-app source freshness for the running session cache."""
return render_source_bar_html(_active_cache(), datetime.now(timezone.utc))
def _refresh_pending() -> str:
return (
''
'Checking official sources…'
)
def render_refresh_status() -> str:
status = refresh_official_sources_status()
cache = _active_cache()
retrieved = max((snapshot.retrieved_at for snapshot in cache.snapshots), default=None)
when = human_retrieved(retrieved) if retrieved else "just now"
reachable = len(status["updated"]) + len(status["unchanged"])
if reachable == 0 and status["failed"]:
# Offline / unreachable: the last-good cache still answers (offline-first).
variant = "warn"
text = f"Couldn't reach official sources just now — using last-good cache from {when}."
else:
variant = "ok"
text = (
f"Checked just now — {len(status['updated'])} updated, "
f"{len(status['unchanged'])} unchanged. Last good: {when}."
)
return (
f''
f'{html.escape(text)}'
)
def lookup_species(
query: str,
intent_label: str,
region_label: str,
county_or_beach: str,
) -> dict[str, object]:
matches = search_species(query)
card = get_rule_card(matches[0].rule_card_key) if matches else get_rule_card("unknown")
context = default_context(
intent=INTENT_LABELS.get(intent_label, UserIntent.JUST_CURIOUS),
region=REGION_LABELS.get(region_label, CoastalRegion.UNKNOWN),
county_or_beach=county_or_beach.strip() or None,
)
result = compose_safety_result(card, context)
explanation = explain_safety_result(result, default_model_inventory(), generator=None)
trace = build_lookup_workflow_trace(
"name_search",
candidate_steps=(
f"Normalized query: {query.strip()}",
f"Candidate matches: {', '.join(match.display_name for match in matches) or 'none'}",
),
rule_steps=(
f"Selected rule card: {card.key}",
f"Deterministic status: {result.status.value}",
"Generated explanation was created after deterministic safety composition.",
),
result=result,
explanation=explanation,
)
payload = result_to_dict(result)
payload["matches"] = tuple(match.display_name for match in matches)
payload["generated_explanation"] = explanation_to_dict(explanation)
payload["workflow_trace"] = workflow_trace_to_dict(trace)
return payload
def render_lookup_result(
query: str,
intent_label: str,
region_label: str,
county_or_beach: str,
) -> str:
result = lookup_species(query, intent_label, region_label, county_or_beach)
source_lines = "\n".join(f"- {url}" for url in result["source_links"]) or "- No source-backed card shown."
cache_lines = "\n".join(f"- Cache date: {cache_date}" for cache_date in result["cache_dates"])
notes = "\n".join(f"- {note}" for note in result["rule_notes"]) or "- Search by known name or verify with official sources."
warnings = "\n".join(f"- {warning}" for warning in result["advisory_warnings"]) or "- No cached advisory overlay shown."
explanation = result["generated_explanation"]["text"]
return f"""
## {result["display_name"]}
**Status:** `{result["status"]}`
{result["summary"]}
**Advisory and location warnings**
{warnings}
**Rule notes**
{notes}
**Sources**
{source_lines}
**Source freshness**
{cache_lines}
**Next safe action:** {result["next_safe_action"]}
**Safety limits**
CoastWise does not certify species identification, legality, harvest authorization, or food safety. Verify current official guidance before harvest or consumption.
**Generated explanation**
{explanation}
"""
def lookup_photo_candidate(image) -> dict[str, object]:
if image is None:
return {
"message": "Observe only: upload a demo photo to compare with curated references.",
"fallback_status": "observe_only",
"reference_set_keys": (),
"image_retained": False,
"candidates": (),
}
return photo_result_to_dict(candidate_from_photo(image))
def render_photo_candidate(image) -> str:
result = lookup_photo_candidate(image)
if not result["candidates"]:
return f"**Photo candidate:** {result['message']}"
rows = []
for candidate in result["candidates"]:
rows.append(
f"- {candidate['display_name']} ({candidate['confidence']}): "
f"{candidate['limitations']}"
)
return (
"**Photo candidates are visually similar possibilities, not confirmed ID.**\n\n"
+ "\n".join(rows)
)
def build_app() -> gr.Blocks:
# Gradio 6 applies theme/css at launch() (see __main__), not on Blocks.
with gr.Blocks(title=APP_TITLE) as demo:
gr.Markdown(HEADER_HTML, sanitize_html=False, elem_classes=["cw-header-wrap"])
with gr.Row(elem_classes=["cw-source-row"]):
source_status = gr.Markdown(
render_source_bar(),
sanitize_html=False,
elem_classes=["cw-source-status"],
scale=5,
)
refresh = gr.Button(
"Refresh",
variant="secondary",
elem_classes=["cw-refresh-top"],
scale=1,
min_width=132,
)
with gr.Group(elem_classes=["cw-search"]):
query = gr.Textbox(
label="Question",
show_label=False,
lines=1,
max_lines=1,
placeholder="Ask about a species or rule — e.g. what's the min size of lingcod?",
elem_classes=["cw-question"],
)
lookup = gr.Button("Ask", variant="primary", elem_classes=["cw-ask"])
with gr.Row(elem_classes=["cw-chips"]):
chips = [
gr.Button(example, variant="secondary", size="sm")
for example in EXAMPLE_QUESTIONS
]
output = gr.Markdown(EMPTY_STATE_HTML, sanitize_html=False, elem_classes=["cw-answer"])
gr.Markdown(FOOTER_NOTE, elem_classes=["cw-footer"])
lookup.click(render_source_backed_answer, inputs=[query], outputs=output)
query.submit(render_source_backed_answer, inputs=[query], outputs=output)
for chip, example in zip(chips, EXAMPLE_QUESTIONS, strict=False):
chip.click(
lambda example=example: _answer_from_example(example),
outputs=[query, output],
)
refresh.click(
_refresh_pending, outputs=source_status, show_progress="hidden"
).then(
render_refresh_status, outputs=source_status, show_progress="hidden"
)
return demo
demo = build_app()
def _warm_up_local_model() -> None:
"""Preload the in-process MiniCPM model off the request path.
The interpreter loads its GGUF lazily on first use; warming it here in a
daemon thread keeps Space startup non-blocking while making the first real
question fast. No-ops in deterministic mode (no model configured) and never
raises.
"""
if SOURCE_BACKED_INTERPRETER is None:
return
try:
SOURCE_BACKED_INTERPRETER("what is the minimum size for lingcod?")
except Exception:
pass
threading.Thread(
target=_warm_up_local_model, name="coastwise-model-warmup", daemon=True
).start()
if __name__ == "__main__":
demo.launch(theme=THEME, css=APP_CSS)