From dcd0fba49cfab3ca0d42f0e6b6608136d5fc2b81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 05:12:21 +0000 Subject: [PATCH 1/2] Add interactive NIGHTWATCH live observatory demo console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade the v0.5 AI demo from a terminal printout to a browser console with voice-command NLP, live scheduling scores, offline object ID, sky briefing, and service health — still fully simulator-backed. Co-authored-by: THOClabs --- README.md | 4 + examples/demo_web/README.md | 23 + examples/demo_web/server.py | 547 +++++++++++++++++++++ examples/demo_web/static/app.css | 712 ++++++++++++++++++++++++++++ examples/demo_web/static/app.js | 369 ++++++++++++++ examples/demo_web/static/index.html | 142 ++++++ examples/v05_ai_demo.py | 109 +++-- 7 files changed, 1862 insertions(+), 44 deletions(-) create mode 100644 examples/demo_web/README.md create mode 100644 examples/demo_web/server.py create mode 100644 examples/demo_web/static/app.css create mode 100644 examples/demo_web/static/app.js create mode 100644 examples/demo_web/static/index.html 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..621ccc2 --- /dev/null +++ b/examples/demo_web/server.py @@ -0,0 +1,547 @@ +#!/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=True, + ) + 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 = [ + { + "id": getattr(opt, "id", str(i)), + "label": getattr(opt, "label", str(opt)), + } + for i, opt in enumerate(clarification.options or []) + ] + 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() + state = SkyState( + condition=SkyCondition.EXCELLENT, + visible_objects=[ + 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]) + ], + ) + desc = ai.sky_describer.describe_sky(state) + text = desc.text + + 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(state.visible_objects), + "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}/" + print("\n" + "=" * 60) + print(" NIGHTWATCH Live Observatory Demo") + print("=" * 60) + print(f"\n Open → {url}") + print(" Mode → simulator (no hardware required)\n") + 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..a6791ff --- /dev/null +++ b/examples/demo_web/static/app.css @@ -0,0 +1,712 @@ +:root { + --void: #04060c; + --deep: #0a1222; + --panel: rgba(10, 16, 30, 0.78); + --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; + --font-brand: "Syne", sans-serif; + --font-body: "Outfit", sans-serif; + --font-mono: "IBM Plex 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 { + 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); +} + +.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..b40022a --- /dev/null +++ b/examples/demo_web/static/app.js @@ -0,0 +1,369 @@ +const PROMPTS = [ + "Slew to Andromeda", + "What's the weather like?", + "Schedule tonight", + "Go to the nebula", + "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.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"); + 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/index.html b/examples/demo_web/static/index.html new file mode 100644 index 0000000..ec995a6 --- /dev/null +++ b/examples/demo_web/static/index.html @@ -0,0 +1,142 @@ + + + + + + NIGHTWATCH — Live Observatory Demo + + + + + + + + + +
+ + + NIGHTWATCH + + +
+ + SIMULATOR +
+
+ +
+
+

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. +

+
+ Open live console + +
+

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.

+
+
+
+

By coordinates

+
+ + +
+ +
+
+

By catalog ID

+ + +
+
+

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.

+
+
+
+
+
+ +
+ NIGHTWATCH · Intes Micro MN76 · OnStepX · DGX Spark + +
+ + + + 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__": From 6ec0be732f4e6b3d7bda0682d465c6ba108edbe9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 05:54:54 +0000 Subject: [PATCH 2/2] Polish live demo: warm services, clearer UX, fix sky canvas id Eager-init AI services for a full health grid, clean sky briefing copy, clarification follow-up actions, local fonts, and rename the starfield canvas so it no longer collides with the sky panel anchor. Co-authored-by: THOClabs --- examples/demo_web/server.py | 80 +++++++++++++++++++--------- examples/demo_web/static/app.css | 16 ++++-- examples/demo_web/static/app.js | 9 ++-- examples/demo_web/static/favicon.svg | 1 + examples/demo_web/static/index.html | 10 ++-- 5 files changed, 76 insertions(+), 40 deletions(-) create mode 100644 examples/demo_web/static/favicon.svg diff --git a/examples/demo_web/server.py b/examples/demo_web/server.py index 621ccc2..2890a64 100644 --- a/examples/demo_web/server.py +++ b/examples/demo_web/server.py @@ -141,7 +141,7 @@ def ensure(self) -> AIServices: config = AIServicesConfig( latitude_deg=38.9, longitude_deg=-117.6, # central Nevada dark-sky site - lazy_init=True, + lazy_init=False, # warm every service so health is complete ) self.ai = AIServices(config) self.ai.initialize() @@ -241,13 +241,25 @@ async def api_command(request: web.Request) -> web.Response: if clarification.needs_clarification: intent = "clarify" response_text = clarification.question or "I need a bit more detail." - actions = [ - { - "id": getattr(opt, "id", str(i)), - "label": getattr(opt, "label", str(opt)), - } - for i, opt in enumerate(clarification.options or []) - ] + 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." @@ -394,21 +406,33 @@ async def api_identify(request: web.Request) -> web.Response: 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=[ - 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]) - ], + visible_objects=visible, ) - desc = ai.sky_describer.describe_sky(state) - text = desc.text + # 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) @@ -417,7 +441,7 @@ async def api_sky(_: web.Request) -> web.Response: { "description": text, "condition": _json_safe(state.condition), - "visible": _json_safe(state.visible_objects), + "visible": _json_safe(visible), "suggestions": _json_safe(suggestions), "favorites": _json_safe(favorites), "site": { @@ -535,11 +559,15 @@ def main() -> None: app = create_app() url = f"http://{args.host}:{args.port}/" - print("\n" + "=" * 60) - print(" NIGHTWATCH Live Observatory Demo") - print("=" * 60) - print(f"\n Open → {url}") - print(" Mode → simulator (no hardware required)\n") + 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) diff --git a/examples/demo_web/static/app.css b/examples/demo_web/static/app.css index a6791ff..066ba77 100644 --- a/examples/demo_web/static/app.css +++ b/examples/demo_web/static/app.css @@ -1,7 +1,7 @@ :root { --void: #04060c; --deep: #0a1222; - --panel: rgba(10, 16, 30, 0.78); + --panel: rgba(8, 12, 24, 0.92); --panel-solid: #0d1528; --line: rgba(232, 238, 248, 0.1); --ink: #e8eef8; @@ -13,9 +13,10 @@ --ok: #6fbf7a; --warn: #d4a84b; --bad: #e0725c; - --font-brand: "Syne", sans-serif; - --font-body: "Outfit", sans-serif; - --font-mono: "IBM Plex Mono", ui-monospace, monospace; + /* 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; @@ -41,7 +42,7 @@ body { overflow-x: hidden; } -#sky { +#sky-canvas { position: fixed; inset: 0; width: 100%; @@ -267,6 +268,11 @@ main, 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 { diff --git a/examples/demo_web/static/app.js b/examples/demo_web/static/app.js index b40022a..4626e37 100644 --- a/examples/demo_web/static/app.js +++ b/examples/demo_web/static/app.js @@ -1,8 +1,9 @@ const PROMPTS = [ - "Slew to Andromeda", + "Slew to M31", "What's the weather like?", "Schedule tonight", "Go to the nebula", + "Slew to Andromeda", "Nightwatch, watch for meteors", "Park the telescope", ]; @@ -41,7 +42,9 @@ function addMessage({ role, text, meta, actions }) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = action.label; - btn.addEventListener("click", () => sendCommand(action.label)); + btn.addEventListener("click", () => + sendCommand(action.command || action.label) + ); row.appendChild(btn); } el.appendChild(row); @@ -306,7 +309,7 @@ setInterval(tickClock, 1000); /* -------- Animated starfield -------- */ (function starfield() { - const canvas = document.getElementById("sky"); + const canvas = document.getElementById("sky-canvas"); const ctx = canvas.getContext("2d"); let stars = []; let w = 0; 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 index ec995a6..a6b1d70 100644 --- a/examples/demo_web/static/index.html +++ b/examples/demo_web/static/index.html @@ -4,13 +4,11 @@ NIGHTWATCH — Live Observatory Demo - - - + - +
@@ -31,7 +29,7 @@
-
+

Central Nevada · Dark Sky Site

NIGHTWATCH

Voice at the eyepiece.

@@ -137,6 +135,6 @@

Systems

- +