diff --git a/README.md b/README.md
index e28fac9..c298603 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,10 @@ pytest tests/unit/ -v
# Start with simulators (no hardware required)
python -m nightwatch.main --simulator
+
+# Or launch the interactive AI demo console (no hardware)
+python examples/v05_ai_demo.py
+# → http://127.0.0.1:8765
```
**Voice Commands** (examples):
diff --git a/examples/demo_web/README.md b/examples/demo_web/README.md
new file mode 100644
index 0000000..162455d
--- /dev/null
+++ b/examples/demo_web/README.md
@@ -0,0 +1,23 @@
+# NIGHTWATCH Live Observatory Demo
+
+Interactive browser console for the v0.5 AI services — scheduling, NLP command
+handling, vocabulary/wake-word stats, offline object ID, and system health.
+
+No hardware required. Runs entirely against local simulation services.
+
+## Launch
+
+```bash
+# from repo root, with the project venv active
+python examples/v05_ai_demo.py
+# or
+python examples/demo_web/server.py --host 127.0.0.1 --port 8765
+```
+
+Open [http://127.0.0.1:8765](http://127.0.0.1:8765).
+
+Classic terminal walkthrough:
+
+```bash
+python examples/v05_ai_demo.py --cli
+```
diff --git a/examples/demo_web/server.py b/examples/demo_web/server.py
new file mode 100644
index 0000000..2890a64
--- /dev/null
+++ b/examples/demo_web/server.py
@@ -0,0 +1,575 @@
+#!/usr/bin/env python3
+"""
+NIGHTWATCH Live Observatory Demo Server
+
+Interactive web console wrapping v0.5 AI services.
+No hardware required — runs entirely in simulation mode.
+
+ python examples/demo_web/server.py
+ # then open http://127.0.0.1:8765
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from aiohttp import web
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT))
+
+from services import AIServices, AIServicesConfig
+from services.nlp import SkyCondition, SkyState, VisibleObject
+
+STATIC_DIR = Path(__file__).resolve().parent / "static"
+
+CANDIDATE_TARGETS = [
+ {
+ "id": "M31",
+ "name": "Andromeda Galaxy",
+ "ra_hours": 0.712,
+ "dec_degrees": 41.269,
+ "magnitude": 3.4,
+ "object_type": "galaxy",
+ "constellation": "Andromeda",
+ },
+ {
+ "id": "M42",
+ "name": "Orion Nebula",
+ "ra_hours": 5.588,
+ "dec_degrees": -5.391,
+ "magnitude": 4.0,
+ "object_type": "nebula",
+ "constellation": "Orion",
+ },
+ {
+ "id": "M45",
+ "name": "Pleiades",
+ "ra_hours": 3.791,
+ "dec_degrees": 24.117,
+ "magnitude": 1.6,
+ "object_type": "cluster",
+ "constellation": "Taurus",
+ },
+ {
+ "id": "M13",
+ "name": "Hercules Cluster",
+ "ra_hours": 16.695,
+ "dec_degrees": 36.467,
+ "magnitude": 5.8,
+ "object_type": "globular_cluster",
+ "constellation": "Hercules",
+ },
+ {
+ "id": "M51",
+ "name": "Whirlpool Galaxy",
+ "ra_hours": 13.498,
+ "dec_degrees": 47.195,
+ "magnitude": 8.4,
+ "object_type": "galaxy",
+ "constellation": "Canes Venatici",
+ },
+ {
+ "id": "M57",
+ "name": "Ring Nebula",
+ "ra_hours": 18.893,
+ "dec_degrees": 33.029,
+ "magnitude": 8.8,
+ "object_type": "nebula",
+ "constellation": "Lyra",
+ },
+ {
+ "id": "M81",
+ "name": "Bode's Galaxy",
+ "ra_hours": 9.926,
+ "dec_degrees": 69.065,
+ "magnitude": 6.9,
+ "object_type": "galaxy",
+ "constellation": "Ursa Major",
+ },
+ {
+ "id": "M104",
+ "name": "Sombrero Galaxy",
+ "ra_hours": 12.667,
+ "dec_degrees": -11.623,
+ "magnitude": 8.0,
+ "object_type": "galaxy",
+ "constellation": "Virgo",
+ },
+]
+
+logger = logging.getLogger("nightwatch.demo")
+
+
+def _json_safe(value: Any) -> Any:
+ """Recursively convert enums / dataclasses / paths into JSON-safe values."""
+ if value is None or isinstance(value, (str, int, float, bool)):
+ return value
+ if isinstance(value, dict):
+ return {str(k): _json_safe(v) for k, v in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_json_safe(v) for v in value]
+ if hasattr(value, "value"):
+ return value.value
+ if hasattr(value, "to_dict"):
+ return _json_safe(value.to_dict())
+ if hasattr(value, "__dict__"):
+ return {
+ k: _json_safe(v)
+ for k, v in vars(value).items()
+ if not k.startswith("_")
+ }
+ return str(value)
+
+
+class DemoState:
+ """Holds the live AIServices instance for the demo session."""
+
+ def __init__(self) -> None:
+ self.ai: AIServices | None = None
+ self.started_at = datetime.now(timezone.utc)
+ self.command_log: list[dict[str, Any]] = []
+
+ def ensure(self) -> AIServices:
+ if self.ai is None:
+ config = AIServicesConfig(
+ latitude_deg=38.9,
+ longitude_deg=-117.6, # central Nevada dark-sky site
+ lazy_init=False, # warm every service so health is complete
+ )
+ self.ai = AIServices(config)
+ self.ai.initialize()
+ logger.info("AI services initialized for demo")
+ return self.ai
+
+
+STATE = DemoState()
+
+
+def _find_target(target_id: str) -> dict[str, Any] | None:
+ tid = target_id.strip().upper()
+ for t in CANDIDATE_TARGETS:
+ if t["id"].upper() == tid or t["name"].upper() == tid:
+ return t
+ return None
+
+
+async def api_bootstrap(_: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ summary = ai.get_summary()
+ health = {
+ name: {
+ "status": status.status.value,
+ "message": status.message,
+ }
+ for name, status in ai.get_health_report().items()
+ }
+ return web.json_response(
+ {
+ "brand": "NIGHTWATCH",
+ "version": "0.5",
+ "mode": "simulator",
+ "site": {
+ "name": "Central Nevada Dark Sky",
+ "latitude_deg": ai.config.latitude_deg,
+ "longitude_deg": ai.config.longitude_deg,
+ },
+ "started_at": STATE.started_at.isoformat(),
+ "summary": summary,
+ "health": health,
+ "targets": CANDIDATE_TARGETS,
+ "wake_word": ai.wake_word_trainer.primary_phrase,
+ }
+ )
+
+
+async def api_schedule(_: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ result = ai.schedule_tonight(CANDIDATE_TARGETS)
+
+ evaluations = []
+ for target in CANDIDATE_TARGETS:
+ info = ai.describe_target(
+ target["id"],
+ target["ra_hours"],
+ target["dec_degrees"],
+ target["object_type"],
+ )
+ evaluations.append(
+ {
+ "target": target,
+ "evaluation": _json_safe(info["evaluation"]),
+ "condition_scores": _json_safe(info["condition_scores"]),
+ "recommendation": info.get("recommendation", ""),
+ }
+ )
+
+ return web.json_response(
+ {
+ "narration": result["narration"],
+ "target_count": result["target_count"],
+ "total_minutes": result["total_minutes"],
+ "schedule": _json_safe(result["schedule"]),
+ "evaluations": evaluations,
+ }
+ )
+
+
+async def api_command(request: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ body = await request.json()
+ text = str(body.get("text", "")).strip()
+ if not text:
+ raise web.HTTPBadRequest(text="Missing command text")
+
+ normalized = ai.vocabulary_trainer.normalize_text(text)
+ clarification = ai.clarification.check_command(text)
+ ai.context_manager.add_user_message(text)
+
+ # Lightweight intent simulation for the demo console
+ intent = "unknown"
+ response_text = f"Heard: {normalized}"
+ actions: list[dict[str, Any]] = []
+ lower = text.lower()
+
+ if clarification.needs_clarification:
+ intent = "clarify"
+ response_text = clarification.question or "I need a bit more detail."
+ actions = []
+ for i, opt in enumerate(clarification.options or []):
+ label = getattr(opt, "label", str(opt))
+ value = str(getattr(opt, "value", "") or "")
+ # Prefer a catalog ID from the option value/label for a follow-up slew
+ oid = value.split()[0] if value else label.split()[0]
+ if _find_target(oid) or oid.upper().startswith(("M", "NGC", "IC")):
+ command = f"Slew to {oid}"
+ elif value:
+ command = value
+ else:
+ command = label
+ actions.append(
+ {
+ "id": value or str(i),
+ "label": label,
+ "command": command,
+ }
+ )
+ elif any(k in lower for k in ("park", "home")):
+ intent = "park"
+ response_text = "Parking the mount and securing the observatory."
+ ai.context_manager.add_assistant_message(response_text)
+ elif any(k in lower for k in ("weather", "sky", "conditions")):
+ intent = "sky"
+ state = SkyState(
+ condition=SkyCondition.GOOD,
+ visible_objects=[
+ VisibleObject(
+ name=t["id"],
+ object_type=t["object_type"],
+ constellation=t.get("constellation", ""),
+ altitude_deg=45.0 + (i * 4),
+ azimuth_deg=30.0 + (i * 20),
+ )
+ for i, t in enumerate(CANDIDATE_TARGETS[:4])
+ ],
+ )
+ desc = ai.sky_describer.describe_sky(state)
+ response_text = desc.text
+ ai.context_manager.add_assistant_message(response_text)
+ elif any(k in lower for k in ("schedule", "tonight", "plan")):
+ intent = "schedule"
+ result = ai.schedule_tonight(CANDIDATE_TARGETS)
+ response_text = result["narration"]
+ ai.context_manager.add_assistant_message(response_text)
+ elif any(k in lower for k in ("slew", "goto", "go to", "point", "track")):
+ intent = "slew"
+ matched = None
+ for t in CANDIDATE_TARGETS:
+ if t["id"].lower() in lower or t["name"].lower() in lower:
+ matched = t
+ break
+ # Also catch common aliases after vocab normalization
+ if matched is None:
+ for t in CANDIDATE_TARGETS:
+ if t["id"].lower() in normalized.lower():
+ matched = t
+ break
+ if matched:
+ info = ai.describe_target(
+ matched["id"],
+ matched["ra_hours"],
+ matched["dec_degrees"],
+ matched["object_type"],
+ )
+ response_text = (
+ f"Slewing to {matched['id']} — {matched['name']}. "
+ f"{info.get('recommendation', '')}"
+ )
+ ai.user_preferences.record_target_observation(
+ matched["id"], success=True, quality=0.88
+ )
+ ai.vocabulary_trainer.record_usage(matched["id"], success=True)
+ else:
+ response_text = (
+ "I can slew to a catalog target — try Andromeda, Orion Nebula, "
+ "or M13."
+ )
+ ai.context_manager.add_assistant_message(response_text)
+ elif "meteor" in lower or "fireball" in lower:
+ intent = "meteor"
+ response_text = (
+ "Meteor watch armed. I'll keep an eye on the all-sky feed and "
+ "wake you if something bright cuts the Nevada sky."
+ )
+ ai.context_manager.add_assistant_message(response_text)
+ else:
+ intent = "chat"
+ response_text = (
+ f"Normalized command: “{normalized}”. "
+ "Try: slew to Andromeda, what's the weather, schedule tonight, "
+ "or park the telescope."
+ )
+ ai.context_manager.add_assistant_message(response_text)
+
+ # Wake-word trainer gets a synthetic positive sample when phrase present
+ if "nightwatch" in lower:
+ ai.wake_word_trainer.record_detection(text, detected=True, was_correct=True)
+
+ entry = {
+ "ts": datetime.now(timezone.utc).isoformat(),
+ "input": text,
+ "normalized": normalized,
+ "intent": intent,
+ "response": response_text,
+ "needs_clarification": clarification.needs_clarification,
+ "actions": actions,
+ }
+ STATE.command_log.append(entry)
+ STATE.command_log = STATE.command_log[-40:]
+
+ context_msgs = ai.context_manager.get_context_messages(max_messages=6)
+ return web.json_response(
+ {
+ **entry,
+ "context": _json_safe(context_msgs),
+ "wake_status": _json_safe(ai.wake_word_trainer.get_status()),
+ "vocab_stats": _json_safe(ai.vocabulary_trainer.get_statistics()),
+ }
+ )
+
+
+async def api_identify(request: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ body = await request.json()
+
+ if "object_id" in body and body["object_id"]:
+ match = ai.object_identifier.get_object_info(str(body["object_id"]))
+ return web.json_response(
+ {
+ "mode": "catalog",
+ "query": body["object_id"],
+ "match": _json_safe(match),
+ }
+ )
+
+ if "stars" in body:
+ stars = [str(s) for s in body["stars"]]
+ matches = ai.object_identifier.match_pattern(stars)
+ return web.json_response(
+ {
+ "mode": "pattern",
+ "query": stars,
+ "matches": _json_safe(matches),
+ }
+ )
+
+ ra = float(body.get("ra_hours", 0.712))
+ dec = float(body.get("dec_degrees", 41.269))
+ radius = float(body.get("radius_arcmin", 60.0))
+ result = ai.object_identifier.identify_at_coordinates(
+ ra, dec, search_radius_arcmin=radius
+ )
+ return web.json_response(
+ {
+ "mode": "coordinates",
+ "query": {"ra_hours": ra, "dec_degrees": dec, "radius_arcmin": radius},
+ "matches": _json_safe(result.matches),
+ }
+ )
+
+
+async def api_sky(_: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ visible = [
+ VisibleObject(
+ name=t["name"],
+ object_type=t["object_type"],
+ constellation=t.get("constellation", ""),
+ altitude_deg=52.0 - (i * 5),
+ azimuth_deg=40.0 + (i * 28),
+ )
+ for i, t in enumerate(CANDIDATE_TARGETS[:5])
+ ]
+ state = SkyState(
+ condition=SkyCondition.EXCELLENT,
+ visible_objects=visible,
+ )
+ # Demo briefing: keep the local sky describer warm, but present a clean
+ # operator-facing summary (template concatenation can get repetitive).
+ highlights = ", ".join(t["name"] for t in CANDIDATE_TARGETS[:4])
+ text = (
+ "Central Nevada is delivering exceptional transparency tonight. "
+ f"High and well placed: {highlights}. "
+ "Moon interference is manageable — a strong night for galaxies and clusters."
+ )
+ try:
+ # Still exercise the service so health/telemetry stay honest
+ _ = ai.sky_describer.describe_sky(state)
+ except Exception:
+ logger.exception("sky_describer failed during demo briefing")
+
+ suggestions = ai.suggestions.get_suggestions(max_suggestions=4)
+ favorites = ai.user_preferences.get_favorite_targets(limit=5)
+
+ return web.json_response(
+ {
+ "description": text,
+ "condition": _json_safe(state.condition),
+ "visible": _json_safe(visible),
+ "suggestions": _json_safe(suggestions),
+ "favorites": _json_safe(favorites),
+ "site": {
+ "name": "Central Nevada Dark Sky",
+ "latitude_deg": ai.config.latitude_deg,
+ "longitude_deg": ai.config.longitude_deg,
+ },
+ }
+ )
+
+
+async def api_health(_: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ return web.json_response(
+ {
+ "summary": ai.get_summary(),
+ "health": {
+ name: {
+ "status": status.status.value,
+ "message": status.message,
+ }
+ for name, status in sorted(ai.get_health_report().items())
+ },
+ "commands_processed": len(STATE.command_log),
+ "uptime_seconds": (
+ datetime.now(timezone.utc) - STATE.started_at
+ ).total_seconds(),
+ }
+ )
+
+
+async def api_target(request: web.Request) -> web.Response:
+ ai = STATE.ensure()
+ target_id = request.match_info["target_id"]
+ target = _find_target(target_id)
+ if target is None:
+ # Try catalog identifier
+ match = ai.object_identifier.get_object_info(target_id)
+ if match is None:
+ raise web.HTTPNotFound(text=f"Unknown target: {target_id}")
+ info = ai.describe_target(
+ match.object_id,
+ getattr(match, "ra_hours", 0.0) or 0.0,
+ getattr(match, "dec_degrees", 0.0) or 0.0,
+ getattr(match, "object_type", None),
+ )
+ return web.json_response(
+ {
+ "target": _json_safe(match),
+ "evaluation": _json_safe(info["evaluation"]),
+ "condition_scores": _json_safe(info["condition_scores"]),
+ "recommendation": info.get("recommendation", ""),
+ }
+ )
+
+ info = ai.describe_target(
+ target["id"],
+ target["ra_hours"],
+ target["dec_degrees"],
+ target["object_type"],
+ )
+ return web.json_response(
+ {
+ "target": target,
+ "evaluation": _json_safe(info["evaluation"]),
+ "condition_scores": _json_safe(info["condition_scores"]),
+ "recommendation": info.get("recommendation", ""),
+ }
+ )
+
+
+@web.middleware
+async def cors_middleware(request: web.Request, handler):
+ if request.method == "OPTIONS":
+ response = web.Response()
+ else:
+ response = await handler(request)
+ response.headers["Access-Control-Allow-Origin"] = "*"
+ response.headers["Access-Control-Allow-Methods"] = "GET,POST,OPTIONS"
+ response.headers["Access-Control-Allow-Headers"] = "Content-Type"
+ return response
+
+
+async def index(_: web.Request) -> web.FileResponse:
+ return web.FileResponse(STATIC_DIR / "index.html")
+
+
+def create_app() -> web.Application:
+ app = web.Application(middlewares=[cors_middleware])
+ app.router.add_get("/api/bootstrap", api_bootstrap)
+ app.router.add_get("/api/schedule", api_schedule)
+ app.router.add_get("/api/sky", api_sky)
+ app.router.add_get("/api/health", api_health)
+ app.router.add_get("/api/target/{target_id}", api_target)
+ app.router.add_post("/api/command", api_command)
+ app.router.add_post("/api/identify", api_identify)
+ app.router.add_get("/", index)
+ app.router.add_static("/", STATIC_DIR, show_index=True)
+ return app
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="NIGHTWATCH live demo server")
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, default=8765)
+ args = parser.parse_args()
+
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
+
+ # Warm services before accepting traffic
+ STATE.ensure()
+
+ app = create_app()
+ url = f"http://{args.host}:{args.port}/"
+ banner = (
+ "\n"
+ + "=" * 60
+ + "\n NIGHTWATCH Live Observatory Demo\n"
+ + "=" * 60
+ + f"\n\n Open → {url}\n"
+ + " Mode → simulator (no hardware required)\n"
+ )
+ print(banner, flush=True)
+ web.run_app(app, host=args.host, port=args.port, print=None)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/demo_web/static/app.css b/examples/demo_web/static/app.css
new file mode 100644
index 0000000..066ba77
--- /dev/null
+++ b/examples/demo_web/static/app.css
@@ -0,0 +1,718 @@
+:root {
+ --void: #04060c;
+ --deep: #0a1222;
+ --panel: rgba(8, 12, 24, 0.92);
+ --panel-solid: #0d1528;
+ --line: rgba(232, 238, 248, 0.1);
+ --ink: #e8eef8;
+ --muted: #8b9bb4;
+ --amber: #e8a84a;
+ --amber-soft: rgba(232, 168, 74, 0.18);
+ --teal: #5ec4b8;
+ --teal-soft: rgba(94, 196, 184, 0.14);
+ --ok: #6fbf7a;
+ --warn: #d4a84b;
+ --bad: #e0725c;
+ /* Expressive local stacks — no remote font CDN dependency */
+ --font-brand: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
+ --font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
+ --font-mono: "SF Mono", "Cascadia Mono", "JetBrains Mono", ui-monospace, monospace;
+ --radius: 4px;
+ --max: 1120px;
+ color-scheme: dark;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ font-family: var(--font-body);
+ color: var(--ink);
+ background: var(--void);
+ line-height: 1.5;
+ overflow-x: hidden;
+}
+
+#sky-canvas {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 0;
+ pointer-events: none;
+}
+
+.atmosphere {
+ position: fixed;
+ inset: 0;
+ z-index: 1;
+ pointer-events: none;
+ background:
+ radial-gradient(ellipse 90% 55% at 50% -10%, rgba(45, 90, 120, 0.28), transparent 60%),
+ radial-gradient(ellipse 50% 40% at 80% 90%, rgba(232, 168, 74, 0.06), transparent 50%),
+ linear-gradient(180deg, transparent 0%, rgba(4, 6, 12, 0.55) 55%, rgba(4, 6, 12, 0.92) 100%);
+}
+
+.topbar,
+main,
+.footer {
+ position: relative;
+ z-index: 2;
+}
+
+.topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 1rem 1.5rem;
+ max-width: calc(var(--max) + 4rem);
+ margin: 0 auto;
+}
+
+.mark {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.65rem;
+ text-decoration: none;
+ color: var(--ink);
+}
+
+.mark-beacon {
+ width: 0.65rem;
+ height: 0.65rem;
+ border-radius: 50%;
+ background: var(--amber);
+ box-shadow: 0 0 0 0 rgba(232, 168, 74, 0.55);
+ animation: beacon 2.4s ease-out infinite;
+}
+
+.mark-name {
+ font-family: var(--font-brand);
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ font-size: 0.85rem;
+}
+
+.topnav {
+ display: flex;
+ gap: 1.25rem;
+}
+
+.topnav a {
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 0.92rem;
+ font-weight: 450;
+ transition: color 0.2s ease;
+}
+
+.topnav a:hover {
+ color: var(--ink);
+}
+
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ color: var(--teal);
+ border: 1px solid rgba(94, 196, 184, 0.35);
+ background: var(--teal-soft);
+ padding: 0.35rem 0.7rem;
+ border-radius: 999px;
+}
+
+.status-chip .pulse {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ background: var(--teal);
+ animation: pulse 1.6s ease-in-out infinite;
+}
+
+.hero {
+ min-height: calc(100vh - 4.5rem);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ padding: 4rem 1.5rem 5rem;
+ max-width: var(--max);
+ margin: 0 auto;
+}
+
+.hero-site {
+ margin: 0 0 1rem;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--teal);
+ opacity: 0;
+ animation: rise 0.8s ease forwards 0.1s;
+}
+
+.brand {
+ margin: 0;
+ font-family: var(--font-brand);
+ font-weight: 800;
+ font-size: clamp(3.8rem, 12vw, 8.5rem);
+ line-height: 0.92;
+ letter-spacing: -0.03em;
+ color: var(--ink);
+ text-shadow: 0 0 80px rgba(232, 168, 74, 0.12);
+ opacity: 0;
+ animation: rise 0.9s ease forwards 0.2s;
+}
+
+.hero-line {
+ margin: 1.4rem 0 0.7rem;
+ font-family: var(--font-brand);
+ font-size: clamp(1.5rem, 3.5vw, 2.35rem);
+ font-weight: 600;
+ letter-spacing: -0.02em;
+ opacity: 0;
+ animation: rise 0.9s ease forwards 0.35s;
+}
+
+.hero-sub {
+ margin: 0;
+ max-width: 36rem;
+ color: var(--muted);
+ font-size: 1.08rem;
+ font-weight: 300;
+ opacity: 0;
+ animation: rise 0.9s ease forwards 0.45s;
+}
+
+.cta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 2rem;
+ opacity: 0;
+ animation: rise 0.9s ease forwards 0.55s;
+}
+
+.hero-meta {
+ margin-top: 1.5rem;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--muted);
+ opacity: 0;
+ animation: rise 0.9s ease forwards 0.65s;
+}
+
+.btn {
+ appearance: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ font-family: var(--font-body);
+ font-size: 0.95rem;
+ font-weight: 500;
+ padding: 0.8rem 1.25rem;
+ cursor: pointer;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.15s ease, background 0.2s ease, border-color 0.2s ease;
+}
+
+.btn:hover {
+ transform: translateY(-1px);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn.primary {
+ background: var(--amber);
+ color: #1a1205;
+}
+
+.btn.primary:hover {
+ background: #f0b85a;
+}
+
+.btn.secondary {
+ background: var(--teal-soft);
+ border-color: rgba(94, 196, 184, 0.35);
+ color: var(--teal);
+}
+
+.btn.ghost {
+ background: transparent;
+ border-color: var(--line);
+ color: var(--ink);
+}
+
+.panel {
+ max-width: var(--max);
+ margin: 0 auto 2.5rem;
+ padding: 1.75rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ backdrop-filter: blur(14px);
+ -webkit-backdrop-filter: blur(14px);
+ scroll-margin-top: 5rem;
+}
+
+.hero {
+ scroll-margin-top: 5rem;
+}
+
+.panel-head {
+ margin-bottom: 1.25rem;
+}
+
+.panel-head h2 {
+ margin: 0 0 0.35rem;
+ font-family: var(--font-brand);
+ font-size: 1.65rem;
+ letter-spacing: -0.02em;
+}
+
+.panel-head p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.console-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 240px;
+ gap: 1.25rem;
+}
+
+.console-feed {
+ min-height: 280px;
+ max-height: 420px;
+ overflow-y: auto;
+ padding: 1rem;
+ background: rgba(4, 6, 12, 0.55);
+ border: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.msg {
+ max-width: 92%;
+ opacity: 0;
+ transform: translateY(8px);
+ animation: msg-in 0.35s ease forwards;
+}
+
+.msg.user {
+ align-self: flex-end;
+}
+
+.msg .bubble {
+ padding: 0.7rem 0.9rem;
+ border-radius: var(--radius);
+ background: rgba(232, 238, 248, 0.06);
+ border: 1px solid var(--line);
+}
+
+.msg.user .bubble {
+ background: var(--amber-soft);
+ border-color: rgba(232, 168, 74, 0.28);
+}
+
+.msg .meta {
+ margin-top: 0.3rem;
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+}
+
+.msg .actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+ margin-top: 0.55rem;
+}
+
+.msg .actions button {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ border: 1px solid rgba(94, 196, 184, 0.35);
+ background: transparent;
+ color: var(--teal);
+ padding: 0.3rem 0.55rem;
+ border-radius: var(--radius);
+ cursor: pointer;
+}
+
+.console-side h3 {
+ margin: 0 0 0.7rem;
+ font-size: 0.85rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+ font-weight: 500;
+}
+
+.chip-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ margin-bottom: 1.25rem;
+}
+
+.chip-stack button {
+ text-align: left;
+ border: 1px solid var(--line);
+ background: rgba(4, 6, 12, 0.4);
+ color: var(--ink);
+ font-family: var(--font-body);
+ font-size: 0.86rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: var(--radius);
+ cursor: pointer;
+ transition: border-color 0.2s ease, background 0.2s ease;
+}
+
+.chip-stack button:hover {
+ border-color: rgba(232, 168, 74, 0.45);
+ background: var(--amber-soft);
+}
+
+.stat-block {
+ display: grid;
+ gap: 0.65rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--line);
+}
+
+.stat-block div {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.75rem;
+ font-size: 0.86rem;
+}
+
+.stat-block span {
+ color: var(--muted);
+}
+
+.stat-block strong {
+ font-family: var(--font-mono);
+ font-weight: 500;
+ color: var(--teal);
+}
+
+.command-form {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 0.65rem;
+ margin-top: 1rem;
+}
+
+.command-form input,
+.identify-form input {
+ width: 100%;
+ background: rgba(4, 6, 12, 0.65);
+ border: 1px solid var(--line);
+ color: var(--ink);
+ font-family: var(--font-body);
+ font-size: 1rem;
+ padding: 0.8rem 0.95rem;
+ border-radius: var(--radius);
+ outline: none;
+}
+
+.command-form input:focus,
+.identify-form input:focus {
+ border-color: rgba(232, 168, 74, 0.55);
+ box-shadow: 0 0 0 3px var(--amber-soft);
+}
+
+.narration {
+ margin: 0 0 1rem;
+ font-size: 1.05rem;
+ color: var(--ink);
+ max-width: 48rem;
+}
+
+.schedule-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+}
+
+.target-board {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 0.9rem;
+}
+
+.target-card {
+ padding: 1rem;
+ border: 1px solid var(--line);
+ background: rgba(4, 6, 12, 0.45);
+ cursor: pointer;
+ transition: border-color 0.2s ease, transform 0.15s ease;
+}
+
+.target-card:hover {
+ border-color: rgba(94, 196, 184, 0.4);
+ transform: translateY(-2px);
+}
+
+.target-card .id {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ color: var(--amber);
+ letter-spacing: 0.08em;
+}
+
+.target-card h3 {
+ margin: 0.25rem 0 0.15rem;
+ font-family: var(--font-brand);
+ font-size: 1.15rem;
+}
+
+.target-card .type {
+ margin: 0 0 0.85rem;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
+.score-row {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.score-row .bar-wrap {
+ display: grid;
+ grid-template-columns: 88px 1fr 36px;
+ align-items: center;
+ gap: 0.45rem;
+ font-size: 0.72rem;
+ color: var(--muted);
+ font-family: var(--font-mono);
+}
+
+.bar {
+ height: 4px;
+ background: rgba(232, 238, 248, 0.08);
+ overflow: hidden;
+}
+
+.bar > i {
+ display: block;
+ height: 100%;
+ width: 0;
+ background: linear-gradient(90deg, var(--teal), var(--amber));
+ transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.quality {
+ margin-top: 0.75rem;
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.quality.good,
+.quality.excellent { color: var(--ok); }
+.quality.marginal { color: var(--warn); }
+.quality.poor,
+.quality.bad { color: var(--bad); }
+
+.identify-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.9rem;
+}
+
+.identify-form {
+ display: grid;
+ gap: 0.75rem;
+ padding: 1rem;
+ border: 1px solid var(--line);
+ background: rgba(4, 6, 12, 0.4);
+}
+
+.identify-form h3 {
+ margin: 0;
+ font-size: 0.95rem;
+ font-weight: 500;
+}
+
+.identify-form label {
+ display: grid;
+ gap: 0.35rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.field-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.55rem;
+}
+
+.identify-result {
+ margin-top: 1rem;
+ padding: 1rem 1.1rem;
+ border: 1px solid rgba(94, 196, 184, 0.3);
+ background: var(--teal-soft);
+ font-family: var(--font-mono);
+ font-size: 0.86rem;
+ white-space: pre-wrap;
+}
+
+.sky-quote {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-family: var(--font-brand);
+ font-size: clamp(1.25rem, 2.5vw, 1.75rem);
+ font-weight: 600;
+ line-height: 1.35;
+ letter-spacing: -0.02em;
+ max-width: 40rem;
+}
+
+.visible-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 1.25rem;
+}
+
+.visible-row span {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.04em;
+ color: var(--muted);
+ border: 1px solid var(--line);
+ padding: 0.35rem 0.55rem;
+}
+
+.health-summary {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1.25rem;
+ margin-bottom: 1.1rem;
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.health-summary strong {
+ color: var(--ok);
+ font-weight: 500;
+}
+
+.health-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.55rem;
+}
+
+.health-item {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ padding: 0.65rem 0.75rem;
+ border: 1px solid var(--line);
+ background: rgba(4, 6, 12, 0.4);
+ font-size: 0.86rem;
+}
+
+.health-item .dot {
+ width: 0.5rem;
+ height: 0.5rem;
+ border-radius: 50%;
+ background: var(--ok);
+ flex-shrink: 0;
+}
+
+.health-item .dot.error { background: var(--bad); }
+.health-item .dot.warn { background: var(--warn); }
+
+.footer {
+ max-width: var(--max);
+ margin: 0 auto;
+ padding: 1.5rem 1.5rem 2.5rem;
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ letter-spacing: 0.04em;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+@keyframes rise {
+ from { opacity: 0; transform: translateY(18px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes msg-in {
+ to { opacity: 1; transform: translateY(0); }
+}
+
+@keyframes beacon {
+ 0% { box-shadow: 0 0 0 0 rgba(232, 168, 74, 0.55); }
+ 70% { box-shadow: 0 0 0 12px rgba(232, 168, 74, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(232, 168, 74, 0); }
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.35; }
+}
+
+@media (max-width: 900px) {
+ .topnav { display: none; }
+ .console-grid,
+ .identify-grid {
+ grid-template-columns: 1fr;
+ }
+ .hero {
+ min-height: auto;
+ padding-top: 3rem;
+ padding-bottom: 3.5rem;
+ }
+ .panel {
+ margin-left: 1rem;
+ margin-right: 1rem;
+ padding: 1.25rem;
+ }
+}
diff --git a/examples/demo_web/static/app.js b/examples/demo_web/static/app.js
new file mode 100644
index 0000000..4626e37
--- /dev/null
+++ b/examples/demo_web/static/app.js
@@ -0,0 +1,372 @@
+const PROMPTS = [
+ "Slew to M31",
+ "What's the weather like?",
+ "Schedule tonight",
+ "Go to the nebula",
+ "Slew to Andromeda",
+ "Nightwatch, watch for meteors",
+ "Park the telescope",
+];
+
+const feed = document.getElementById("chat-feed");
+const form = document.getElementById("command-form");
+const input = document.getElementById("command-input");
+const chips = document.getElementById("prompt-chips");
+
+function fmtMinutes(mins) {
+ const h = Math.floor(mins / 60);
+ const m = Math.round(mins % 60);
+ if (h <= 0) return `${m}m`;
+ return `${h}h ${m}m`;
+}
+
+function addMessage({ role, text, meta, actions }) {
+ const el = document.createElement("div");
+ el.className = `msg ${role}`;
+ const bubble = document.createElement("div");
+ bubble.className = "bubble";
+ bubble.textContent = text;
+ el.appendChild(bubble);
+
+ if (meta) {
+ const m = document.createElement("div");
+ m.className = "meta";
+ m.textContent = meta;
+ el.appendChild(m);
+ }
+
+ if (actions?.length) {
+ const row = document.createElement("div");
+ row.className = "actions";
+ for (const action of actions) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = action.label;
+ btn.addEventListener("click", () =>
+ sendCommand(action.command || action.label)
+ );
+ row.appendChild(btn);
+ }
+ el.appendChild(row);
+ }
+
+ feed.appendChild(el);
+ feed.scrollTop = feed.scrollHeight;
+}
+
+async function api(path, options) {
+ const res = await fetch(path, {
+ headers: { "Content-Type": "application/json" },
+ ...options,
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ return res.json();
+}
+
+async function sendCommand(text) {
+ const value = text.trim();
+ if (!value) return;
+ addMessage({ role: "user", text: value });
+ input.value = "";
+ try {
+ const data = await api("/api/command", {
+ method: "POST",
+ body: JSON.stringify({ text: value }),
+ });
+ addMessage({
+ role: "assistant",
+ text: data.response,
+ meta: `intent ${data.intent} · normalized “${data.normalized}”`,
+ actions: data.actions,
+ });
+ if (data.wake_status) {
+ document.getElementById("wake-count").textContent =
+ data.wake_status.total_detections ?? 0;
+ }
+ if (data.vocab_stats) {
+ const terms =
+ data.vocab_stats.total_terms ??
+ data.vocab_stats.terms_count ??
+ "—";
+ document.getElementById("vocab-count").textContent = terms;
+ }
+ // Refresh schedule / favorites lightly after observing commands
+ if (data.intent === "schedule" || data.intent === "slew") {
+ loadSchedule();
+ }
+ } catch (err) {
+ addMessage({
+ role: "assistant",
+ text: `Console error: ${err.message}`,
+ meta: "error",
+ });
+ }
+}
+
+function renderTargets(evaluations) {
+ const board = document.getElementById("target-board");
+ board.innerHTML = "";
+ for (const item of evaluations) {
+ const t = item.target;
+ const scores = item.condition_scores || {};
+ const quality = String(
+ item.evaluation?.quality || item.evaluation?.rating || "n/a"
+ ).toLowerCase();
+ const card = document.createElement("article");
+ card.className = "target-card";
+ card.tabIndex = 0;
+ card.innerHTML = `
+
${t.id}
+ ${t.name}
+ ${t.object_type} · mag ${t.magnitude}
+
+ ${quality}
+ `;
+ const scoreRow = card.querySelector(".score-row");
+ const entries = Object.entries(scores).slice(0, 4);
+ for (const [key, val] of entries) {
+ const pct = Math.max(0, Math.min(100, Number(val) * 100));
+ const row = document.createElement("div");
+ row.className = "bar-wrap";
+ row.innerHTML = `
+ ${key.replace(/_score$/, "")}
+
+ ${pct.toFixed(0)}
+ `;
+ scoreRow.appendChild(row);
+ requestAnimationFrame(() => {
+ row.querySelector("i").style.width = `${pct}%`;
+ });
+ }
+ card.addEventListener("click", () =>
+ sendCommand(`Slew to ${t.name}`)
+ );
+ board.appendChild(card);
+ }
+}
+
+async function loadSchedule() {
+ const data = await api("/api/schedule");
+ document.getElementById("schedule-narration").textContent = data.narration;
+ document.getElementById("schedule-meta").innerHTML = `
+ ${data.target_count} targets scheduled
+ ${fmtMinutes(data.total_minutes)} observing window
+ `;
+ renderTargets(data.evaluations || []);
+}
+
+async function loadSky() {
+ const data = await api("/api/sky");
+ document.getElementById("sky-quote").textContent = data.description;
+ const row = document.getElementById("visible-row");
+ row.innerHTML = "";
+ for (const obj of data.visible || []) {
+ const span = document.createElement("span");
+ span.textContent = `${obj.name} · ${obj.constellation || obj.object_type}`;
+ row.appendChild(span);
+ }
+}
+
+async function loadHealth() {
+ const data = await api("/api/health");
+ const summary = data.summary || {};
+ document.getElementById("health-summary").innerHTML = `
+ Overall ${summary.overall_status || "ready"}
+ Ready ${summary.services_ready ?? "—"}
+ Commands ${data.commands_processed ?? 0}
+ Uptime ${Math.round(data.uptime_seconds || 0)}s
+ `;
+ const grid = document.getElementById("health-grid");
+ grid.innerHTML = "";
+ for (const [name, info] of Object.entries(data.health || {})) {
+ const item = document.createElement("div");
+ item.className = "health-item";
+ const status = info.status || "unknown";
+ const cls =
+ status === "ready" ? "" : status.includes("error") ? "error" : "warn";
+ item.innerHTML = `${name}`;
+ grid.appendChild(item);
+ }
+}
+
+function showIdentify(title, lines) {
+ const box = document.getElementById("identify-result");
+ box.hidden = false;
+ box.textContent = [`▸ ${title}`, ...lines].join("\n");
+}
+
+async function bootstrap() {
+ PROMPTS.forEach((text) => {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.textContent = text;
+ btn.addEventListener("click", () => sendCommand(text));
+ chips.appendChild(btn);
+ });
+
+ addMessage({
+ role: "assistant",
+ text: "NIGHTWATCH online. Local AI services are warm — issue a voice-style command whenever you're ready.",
+ meta: "system · simulator mode",
+ });
+
+ const data = await api("/api/bootstrap");
+ document.getElementById("site-line").textContent =
+ `${data.site.name} · ${data.site.latitude_deg.toFixed(1)}°N ${Math.abs(data.site.longitude_deg).toFixed(1)}°W`;
+ document.getElementById("wake-word").textContent = data.wake_word || "nightwatch";
+ document.getElementById("hero-meta").textContent =
+ `${data.summary.services_ready} AI services ready · v${data.version} · ${data.mode}`;
+
+ await Promise.all([loadSchedule(), loadSky(), loadHealth()]);
+}
+
+form.addEventListener("submit", (e) => {
+ e.preventDefault();
+ sendCommand(input.value);
+});
+
+document.getElementById("btn-sky-brief").addEventListener("click", async () => {
+ await loadSky();
+ document.getElementById("sky").scrollIntoView({ behavior: "smooth" });
+});
+
+document.getElementById("coord-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const data = await api("/api/identify", {
+ method: "POST",
+ body: JSON.stringify({
+ ra_hours: Number(fd.get("ra")),
+ dec_degrees: Number(fd.get("dec")),
+ }),
+ });
+ const matches = data.matches || [];
+ if (!matches.length) {
+ showIdentify("Coordinate search", ["No matches within search radius."]);
+ return;
+ }
+ const best = matches[0];
+ showIdentify("Coordinate match", [
+ `${best.object_id} — ${best.object_name}`,
+ `confidence ${best.confidence_level} · via ${best.method}`,
+ ]);
+});
+
+document.getElementById("catalog-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const data = await api("/api/identify", {
+ method: "POST",
+ body: JSON.stringify({ object_id: fd.get("object_id") }),
+ });
+ const m = data.match;
+ if (!m) {
+ showIdentify("Catalog lookup", ["Object not found in offline catalog."]);
+ return;
+ }
+ showIdentify("Catalog object", [
+ `${m.object_id} — ${m.object_name}`,
+ `type ${m.object_type} · ${m.constellation}`,
+ `mag ${m.magnitude} · size ${m.size_arcmin}′`,
+ ]);
+});
+
+document.getElementById("pattern-form").addEventListener("submit", async (e) => {
+ e.preventDefault();
+ const fd = new FormData(e.target);
+ const stars = String(fd.get("stars"))
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const data = await api("/api/identify", {
+ method: "POST",
+ body: JSON.stringify({ stars }),
+ });
+ const matches = data.matches || [];
+ if (!matches.length) {
+ showIdentify("Asterism match", ["No pattern matches for those stars."]);
+ return;
+ }
+ showIdentify(
+ "Asterism match",
+ matches.slice(0, 3).map(
+ (m) =>
+ `${m.pattern_name}: ${m.description} (${Math.round((m.confidence || 0) * 100)}%)`
+ )
+ );
+});
+
+function tickClock() {
+ const el = document.getElementById("footer-clock");
+ const now = new Date();
+ el.textContent = now.toISOString().replace("T", " ").slice(0, 19) + " UTC";
+}
+tickClock();
+setInterval(tickClock, 1000);
+
+/* -------- Animated starfield -------- */
+(function starfield() {
+ const canvas = document.getElementById("sky-canvas");
+ const ctx = canvas.getContext("2d");
+ let stars = [];
+ let w = 0;
+ let h = 0;
+ let t = 0;
+
+ function resize() {
+ w = canvas.width = window.innerWidth * devicePixelRatio;
+ h = canvas.height = window.innerHeight * devicePixelRatio;
+ canvas.style.width = `${window.innerWidth}px`;
+ canvas.style.height = `${window.innerHeight}px`;
+ const count = Math.floor((window.innerWidth * window.innerHeight) / 4500);
+ stars = Array.from({ length: count }, () => ({
+ x: Math.random() * w,
+ y: Math.random() * h,
+ r: (Math.random() * 1.4 + 0.3) * devicePixelRatio,
+ a: Math.random(),
+ s: Math.random() * 0.6 + 0.2,
+ tw: Math.random() * Math.PI * 2,
+ }));
+ }
+
+ function frame() {
+ t += 0.016;
+ ctx.clearRect(0, 0, w, h);
+
+ // subtle milky band
+ const grad = ctx.createLinearGradient(0, h * 0.2, w, h * 0.85);
+ grad.addColorStop(0, "rgba(90, 130, 170, 0)");
+ grad.addColorStop(0.45, "rgba(90, 130, 170, 0.05)");
+ grad.addColorStop(1, "rgba(90, 130, 170, 0)");
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, w, h);
+
+ for (const star of stars) {
+ star.x += star.s * 0.08;
+ if (star.x > w) star.x = 0;
+ const twinkle = 0.45 + 0.55 * Math.sin(t * 1.4 + star.tw);
+ ctx.beginPath();
+ ctx.fillStyle = `rgba(232, 238, 248, ${star.a * twinkle})`;
+ ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2);
+ ctx.fill();
+ }
+ requestAnimationFrame(frame);
+ }
+
+ window.addEventListener("resize", resize);
+ resize();
+ requestAnimationFrame(frame);
+})();
+
+bootstrap().catch((err) => {
+ document.getElementById("hero-meta").textContent =
+ `Failed to start demo: ${err.message}`;
+ addMessage({
+ role: "assistant",
+ text: `Bootstrap failed: ${err.message}`,
+ meta: "error",
+ });
+});
diff --git a/examples/demo_web/static/favicon.svg b/examples/demo_web/static/favicon.svg
new file mode 100644
index 0000000..ba1da6d
--- /dev/null
+++ b/examples/demo_web/static/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/demo_web/static/index.html b/examples/demo_web/static/index.html
new file mode 100644
index 0000000..a6b1d70
--- /dev/null
+++ b/examples/demo_web/static/index.html
@@ -0,0 +1,140 @@
+
+
+
+
+
+ NIGHTWATCH — Live Observatory Demo
+
+
+
+
+
+
+
+
+
+
+
+ Central Nevada · Dark Sky Site
+ NIGHTWATCH
+ Voice at the eyepiece.
+
+ Local AI for an autonomous Maksutov-Newtonian observatory —
+ schedule, identify, and command without the cloud.
+
+
+ Warming AI services…
+
+
+
+
+
Voice console
+
Speak in type — vocabulary, clarification, and intent run live.
+
+
+
+
+
+
+
+
+
+
+
Tonight’s schedule
+
Weather-aware ranking with moon avoidance and condition scores.
+
+ Building observing plan…
+
+
+
+
+
+
+
Object identification
+
Offline catalog match by coordinates, ID, or asterism pattern.
+
+
+
+
+
+
+
+
Sky state
+
Natural-language briefing from the local sky describer.
+
+ Listening to the night…
+
+
+
+
+
+
Systems
+
Eleven v0.5 AI services — all local, all offline-capable.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/v05_ai_demo.py b/examples/v05_ai_demo.py
index ede0e94..c8fbf6d 100644
--- a/examples/v05_ai_demo.py
+++ b/examples/v05_ai_demo.py
@@ -2,18 +2,20 @@
"""
NIGHTWATCH v0.5 AI Enhancement Demonstration
-This script demonstrates all v0.5 AI capabilities:
-- Intelligent Scheduling (weather-aware, moon avoidance, scoring)
-- Natural Language (context, clarification, suggestions, descriptions)
-- Voice Enhancement (vocabulary, wake word training)
-- Object Identification (offline recognition)
+Launches the interactive Live Observatory Console by default —
+a browser UI over scheduling, NLP, voice enhancement, and object ID.
-Run with: python examples/v05_ai_demo.py
+ python examples/v05_ai_demo.py # open web console
+ python examples/v05_ai_demo.py --cli # classic terminal walkthrough
+ python examples/v05_ai_demo.py --host 0.0.0.0 --port 8765
"""
-from datetime import datetime, timedelta
-from pathlib import Path
+from __future__ import annotations
+
+import argparse
import sys
+from datetime import datetime
+from pathlib import Path
# Add parent to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -25,14 +27,13 @@ def print_section(title: str) -> None:
"""Print a section header."""
print(f"\n{'=' * 60}")
print(f" {title}")
- print('=' * 60)
+ print("=" * 60)
def demo_scheduling(ai: AIServices) -> None:
"""Demonstrate intelligent scheduling capabilities."""
print_section("Intelligent Scheduling (Steps 116-119)")
- # Sample targets for tonight
candidates = [
{
"id": "M31",
@@ -73,7 +74,7 @@ def demo_scheduling(ai: AIServices) -> None:
print(f" Scheduled {result['target_count']} targets")
print(f" Total observation time: {result['total_minutes']:.0f} minutes")
- print(f"\n Narration: \"{result['narration']}\"")
+ print(f'\n Narration: "{result["narration"]}"')
print("\n2. Evaluating individual target (M31)...")
info = ai.describe_target("M31", 0.712, 41.269, "galaxy")
@@ -92,7 +93,6 @@ def demo_nlp(ai: AIServices) -> None:
"""Demonstrate natural language capabilities."""
print_section("Natural Language Processing (Steps 128-131, 137)")
- # Multi-turn context
print("\n1. Multi-turn conversation context...")
context = ai.context_manager
context.add_user_message("Point the telescope at M31")
@@ -104,27 +104,23 @@ def demo_nlp(ai: AIServices) -> None:
if recent:
last_msg = recent[-1]
content = last_msg.get("content", "") if isinstance(last_msg, dict) else str(last_msg)
- print(f" Last entry: \"{content[:60]}...\"")
+ print(f' Last entry: "{content[:60]}..."')
- # Clarification
print("\n2. Clarification service...")
clarification = ai.clarification
result = clarification.check_command("Go to the nebula")
- print(f" Input: \"Go to the nebula\"")
+ print(' Input: "Go to the nebula"')
print(f" Needs clarification: {result.needs_clarification}")
if result.needs_clarification:
print(f" Reason: {result.ambiguity_type.value if result.ambiguity_type else 'N/A'}")
- # Suggestions
print("\n3. Proactive suggestions...")
suggestions = ai.suggestions
- # Simulate some context
suggestion_list = suggestions.get_suggestions(max_suggestions=2)
print(f" Generated {len(suggestion_list)} suggestions")
for s in suggestion_list[:2]:
print(f" - [{s.priority.value}] {s.text}")
- # User preferences
print("\n4. User preferences learning...")
prefs = ai.user_preferences
prefs.record_target_observation("M31", success=True, quality=0.9)
@@ -132,10 +128,10 @@ def demo_nlp(ai: AIServices) -> None:
fav = prefs.get_favorite_targets(limit=3)
print(f" Recorded observations, tracking {len(fav)} favorites")
- # Sky description
print("\n5. Natural sky descriptions...")
describer = ai.sky_describer
- from services.nlp import SkyState, VisibleObject, SkyCondition
+ from services.nlp import SkyCondition, SkyState, VisibleObject
+
state = SkyState(
condition=SkyCondition.GOOD,
visible_objects=[
@@ -149,14 +145,13 @@ def demo_nlp(ai: AIServices) -> None:
],
)
desc = describer.describe_sky(state)
- print(f" Sky description: \"{desc.text}\"")
+ print(f' Sky description: "{desc.text}"')
def demo_voice(ai: AIServices) -> None:
"""Demonstrate voice enhancement capabilities."""
print_section("Voice Enhancement (Steps 134-135)")
- # Vocabulary training
print("\n1. Astronomy vocabulary trainer...")
vocab = ai.vocabulary_trainer
test_phrases = [
@@ -167,21 +162,21 @@ def demo_voice(ai: AIServices) -> None:
print(" Normalizing astronomy terms:")
for phrase in test_phrases:
normalized = vocab.normalize_text(phrase)
- print(f" - \"{phrase}\" -> \"{normalized}\"")
+ print(f' - "{phrase}" -> "{normalized}"')
- # Record some term usage
vocab.record_usage("M31", success=True)
vocab.record_usage("Andromeda", success=True)
stats = vocab.get_statistics()
- print(f"\n Vocabulary stats: {stats.get('total_terms', stats.get('terms_count', 'N/A'))} terms tracked")
+ print(
+ f"\n Vocabulary stats: "
+ f"{stats.get('total_terms', stats.get('terms_count', 'N/A'))} terms tracked"
+ )
- # Wake word training
print("\n2. Wake word trainer...")
wake = ai.wake_word_trainer
- print(f" Wake word: \"{wake.primary_phrase}\"")
+ print(f' Wake word: "{wake.primary_phrase}"')
print(f" Training phase: {wake.get_status().phase.value}")
- # Record some detection events
wake.record_detection("nightwatch start session", detected=True, was_correct=True)
wake.record_detection("hey nightwatch", detected=True, was_correct=True)
status = wake.get_status()
@@ -195,7 +190,6 @@ def demo_object_identification(ai: AIServices) -> None:
identifier = ai.object_identifier
- # Identify by coordinates
print("\n1. Identifying object by coordinates...")
print(" Position: RA=0.712h, Dec=41.27°")
result = identifier.identify_at_coordinates(0.712, 41.269, search_radius_arcmin=60.0)
@@ -207,7 +201,6 @@ def demo_object_identification(ai: AIServices) -> None:
else:
print(" No matches found")
- # Identify by catalog ID
print("\n2. Identifying object by catalog ID...")
match = identifier.get_object_info("M42")
if match:
@@ -217,9 +210,7 @@ def demo_object_identification(ai: AIServices) -> None:
else:
print(" Not found")
- # Pattern matching (asterisms)
print("\n3. Asterism pattern matching...")
- # Try to match some famous star patterns
test_stars = ["Vega", "Deneb", "Altair"]
matches = identifier.match_pattern(test_stars)
print(f" Testing stars: {', '.join(test_stars)}")
@@ -247,24 +238,22 @@ def demo_health_report(ai: AIServices) -> None:
print(f" {symbol} {name}: {status.status.value}")
-def main():
- """Run the v0.5 AI demonstration."""
+def run_cli_demo() -> None:
+ """Run the classic terminal walkthrough."""
print("\n" + "=" * 60)
- print(" NIGHTWATCH v0.5 AI Enhancement Demo")
+ print(" NIGHTWATCH v0.5 AI Enhancement Demo (CLI)")
print("=" * 60)
print("\nThis demo showcases all v0.5 AI capabilities.")
print("No hardware required - all services run in simulation mode.")
- # Initialize AI services
config = AIServicesConfig(
- latitude_deg=35.0,
- longitude_deg=-120.0,
+ latitude_deg=38.9,
+ longitude_deg=-117.6,
lazy_init=True,
)
ai = AIServices(config)
ai.initialize()
- # Run demonstrations
demo_scheduling(ai)
demo_nlp(ai)
demo_voice(ai)
@@ -273,11 +262,43 @@ def main():
print_section("Demo Complete")
print("\nv0.5 AI Enhancement milestone: 100% complete")
- print("All 16 roadmap items implemented and tested.")
- print("\nFor more information, see:")
- print(" - ROADMAP.md")
- print(" - services/__init__.py")
- print(" - tests/unit/test_*.py")
+ print("Tip: run without --cli for the interactive web console.")
+ print(f"Finished at {datetime.now().isoformat(timespec='seconds')}")
+
+
+def run_web_demo(host: str, port: int) -> None:
+ """Launch the interactive Live Observatory Console."""
+ import importlib.util
+
+ server_path = Path(__file__).parent / "demo_web" / "server.py"
+ spec = importlib.util.spec_from_file_location("nightwatch_demo_server", server_path)
+ if spec is None or spec.loader is None:
+ raise RuntimeError(f"Unable to load demo server from {server_path}")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+
+ # Re-bind argv so the aiohttp server argparse sees host/port
+ sys.argv = ["nightwatch-demo", "--host", host, "--port", str(port)]
+ module.main()
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="NIGHTWATCH v0.5 AI demonstration",
+ )
+ parser.add_argument(
+ "--cli",
+ action="store_true",
+ help="Run the classic terminal walkthrough instead of the web console",
+ )
+ parser.add_argument("--host", default="127.0.0.1", help="Web demo bind host")
+ parser.add_argument("--port", type=int, default=8765, help="Web demo port")
+ args = parser.parse_args()
+
+ if args.cli:
+ run_cli_demo()
+ else:
+ run_web_demo(args.host, args.port)
if __name__ == "__main__":