From fb06fdbce86f59bc32f2ad49dbcb2c60765fbaa0 Mon Sep 17 00:00:00 2001 From: Neal006 Date: Sun, 13 Sep 2026 07:13:54 +0530 Subject: [PATCH] feat(WP6): operator assistant - explains from live state + docs, never acts, works without a key noonshift/assist.py: snapshot() (<= 15 relevant connector rows + counts, last 20 events, signal/tariff summaries, capped ~6k tokens), ask() on claude-opus-5 with a cached SYSTEM prefix (RULES + assist_knowledge.md), adaptive thinking, effort low; errors most-specific first: bad/missing key -> fallback, RateLimitError -> 429 + Retry-After, connection/5xx -> fallback flagged degraded. Deterministic keyword-routed fallback fills templates from the snapshot so the demo needs no key or network. parse() lifts `label -> /ops/` lines and [sources: ...]. 10 req/min per IP. OPS_TOKEN bearer dependency (open when unset) on /assist and /demo/*. API: POST /assist, GET /assist/suggestions (6 starters from state); S["events"] ring buffer. Web: AssistDrawer (floating Ask, right drawer, sessionStorage history, chips, sources, action buttons, offline-answer tag); API base moved to lib/api.js. No new front-end deps. Also fixes a reporting bug found by the smoke: site_peaks()/hourly_ledger() dropped the start-minute offset of mid-slot plug-ins, so misaligned cars could sum into one slot and show a peak above the feed that never happened. Test added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017thvbGo7rTVnG2f46cLMzU --- AGENTS.md | 6 +- docs/openapi.json | 330 +++++++++++++++++++++++ noonshift/api.py | 54 +++- noonshift/assist.py | 360 +++++++++++++++++++++++++ noonshift/assist_knowledge.md | 101 +++++++ noonshift/models.py | 26 ++ requirements.txt | 1 + scripts/smoke.py | 16 ++ tests/test_assist.py | 133 +++++++++ tests/test_day.py | 8 + web/src/components/AppShell.jsx | 2 + web/src/components/AssistDrawer.jsx | 117 ++++++++ web/src/context/GlobalStateContext.jsx | 2 +- web/src/lib/api.js | 2 + 14 files changed, 1144 insertions(+), 14 deletions(-) create mode 100644 noonshift/assist.py create mode 100644 noonshift/assist_knowledge.md create mode 100644 tests/test_assist.py create mode 100644 web/src/components/AssistDrawer.jsx create mode 100644 web/src/lib/api.js diff --git a/AGENTS.md b/AGENTS.md index 8fcb9d1..f319d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,10 +25,12 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead ## File Map - `noonshift/scheduler.py` — `solve(cars, site, signal, tariff, now)`, `solve_lp()` (+info), `impact()`, `impact_detail()`, `price(slack, *, r, saving_usd, kwh, alpha, urgent)` (§7b: R − α·S/E, ≤ R), `tier_of()`; per-car optional `floor_alpha`, `priority`; constants W_CARBON, ALPHA, FLOOR_EVERY, FIRST_HOUR_KWH (3.5), SPRINT_SLOTS, BUFFER_COST, M_SHORT, M_FLOOR, ASAP_EPS, E_MIN, MIN_KW. Slot 0 carries only its remaining minutes (`dur[0]`); floor checkpoints are times since arrival, pro rata in the slot containing the mark. -- `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`; `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` (`/boost` = alias of `now`; `URGENCY_MIN` 15 min; `PRIORITY` = floor 0.9 / weight 2.0); `site_rate()` = `site.json: employee_rate_usd_per_kwh` (0 = free); `session_price()`; `car_kw(c)` = min(bay, `session.max_kw`); `need_estimate()` (bay history median → site median); `observe_caps()` (5 min under 80 % of limit → `max_kw` = metered × 1.05, event `cap_observed`); `move_by()` (waiting cars → slackest plugged cars get deadline = now + e_rem/p_max, one per waiting car); `site.json: connectors_asap` bays are always `boost`; `hourly_ledger()` → `GET /sites/{id}/impact.csv` (24 rows: day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source); `StatusOut` carries the FM's site card (`contracted_peak_kw`, `package`, `employee_rate_usd_per_kwh`, `driver_share`, `noonshift_share`, `signal_kind/source`, `tariff_name`, `ladder`). +- `noonshift/api.py` — FastAPI app, state dict `S`, `resolve()`, `step()`, `session_impact()`, `meter_history()`, ladder `pick_mode()`, `/demo/*`; `POST /sessions/{id}/urgency {level: now|soon|priority, leave_at?}` (`/boost` = alias of `now`; `URGENCY_MIN` 15 min; `PRIORITY` = floor 0.9 / weight 2.0); `site_rate()` = `site.json: employee_rate_usd_per_kwh` (0 = free); `session_price()`; `car_kw(c)` = min(bay, `session.max_kw`); `need_estimate()` (bay history median → site median); `observe_caps()` (5 min under 80 % of limit → `max_kw` = metered × 1.05, event `cap_observed`); `move_by()` (waiting cars → slackest plugged cars get deadline = now + e_rem/p_max, one per waiting car); `site.json: connectors_asap` bays are always `boost`; `POST /assist` + `GET /assist/suggestions` (`dependencies=OPS`: `ops_auth` bearer when `OPS_TOKEN` is set, also on `/demo/*`); `S["events"]` deque(20) filled by `event()`; `hourly_ledger()` → `GET /sites/{id}/impact.csv` (24 rows: day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source); `StatusOut` carries the FM's site card (`contracted_peak_kw`, `package`, `employee_rate_usd_per_kwh`, `driver_share`, `noonshift_share`, `signal_kind/source`, `tariff_name`, `ladder`). +- `noonshift/assist.py` — operator assistant: `snapshot()` (plain dict from `S` via the REST builders; ≤ 15 connector rows + counts, last 20 events, signal/tariff summaries, ≤ 24 k chars), `ask(question, history, page, snap)` → (payload, status) — `claude-opus-5`, cached `SYSTEM` = RULES + `assist_knowledge.md`, adaptive thinking, effort low; AuthenticationError/no key → `fallback()`, RateLimitError → 429, connection/5xx → fallback `degraded`; `parse()` (`label -> /ops/` lines + `[sources: …]`); `allow(ip)` 10/min; `suggestions(snap)` = 6 starters from state. `assist_knowledge.md` — static prefix, never time-varying. - `noonshift/sim.py` — `Sim`, `Connector(id, p_max_kw, safe_kw)` (taper from 80% SoC; plug-in starts on `safe_kw`; a `set_limit` expires after `DYN_VALID_MIN`=15 min back to `safe_kw`), `load_sessions()`, `safe_share_kw(site)` = min(p_max, (feed − max building)/n) = 1.833 kW on site-1. `Sim.waiting` (arrivals with no bay; plugged FIFO as bays free); a `done` car with `idle_since` ≥ `MOVE_AFTER_MIN`=10 is unplugged when someone waits (A5); `session.car_kw` = the car's physical limit (sim only; `max_kw` is the planner's belief). - `noonshift/models.py` — pydantic REST bodies + WS frames (contract with front-end). `SessionIn` optional `vehicle{model,battery_kwh,max_kw}`, `soc_now`, `target_soc`; `SessionOut` +`kwh_needed`, `need_confidence`, `max_kw`, `urgency`; `ConnectorMeter` +`idle_min`, `need_confidence`, `p_max_kw`, `cap_observed`, `asap`, `move_by`, `urgency`; `MeterMsg.waiting`; `StatusOut` +`safe_share_kw`, `waiting`, `connectors_asap`; `EventMsg.name` +`urgency|done|cap_observed|move_by`. All additive. - `web/src/context/GlobalStateContext.jsx` — ops app state: opens `/ws`, polls `/sites/site-1/impact|status` + `/grid/signal`, accumulates hourly kWh from meter frames; `data.status` (mode, modeCopy, ladder, safeShareKw, waiting, connectorsAsap, contractedPeakKw, package, rateR, α/β, signalKind/Source, tariffName), `portfolio`, `siteDetail`, `chargersList`, `alerts`, `ledgerUrl`; actions `setUrgency(sessionId, level, leaveAt)` → `/sessions/{id}/urgency`, `triggerEvent()` → `/demo/*`. `web/src/lib/ui.js` — `hhmm`, `MODE_COPY` (rung copy). `web/src/components/UrgencyControl.jsx` — Leaving now / soon (time input) / Prioritise with inline confirm. +- `web/src/components/AssistDrawer.jsx` — floating "Ask" → right drawer (360 px, full width < sm); sessionStorage history in try/catch; chips from `/assist/suggestions`; sources tags, action buttons `navigate(path)`, "offline answer" tag on fallback/degraded; 429 shows retry-after. `web/src/lib/api.js` — `API` base URL. - `web/src/pages/` — `DashboardOverview` (load vs contracted peak, CO2 first, renewable ring), `OpsDashboard` (Gantt marks urgent/asap/move-by + side panel), `SessionsView` (urgency/need/idle, High-risk highlight), `ChargersView` (plan cap + "meter" tag, static share, ASAP filter), `SchedulesView` (planned vs metered kW/h with MOER shading), `ImpactView` (KPIs, hourly bars by MOER bucket, EPA equivalents, Export ledger CSV), `TariffsView` (§7b split, package, TOU from signal), `AlertsView`. No pause/reoptimize buttons (never pause a car; the planner re-solves itself). - `.docs/ROADMAP.md` — plain-language plan (§0 status, §1 driver inputs, §3 emergency rules, §4 price flip, §5 evidence runs, §7 CI, §8 order). `.github/workflows/ci.yml` — backend job + web/wattwise matrix. - `wattwise/src/api/noonshift.ts` — driver app client + hand-copied types; `connectWs()`, `onSimDay()` (ready-by is built on the **sim** day and must be > sim now). @@ -52,6 +54,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - Every regression found by the day replay gets a unit test that fails on the old code before the fix. ## Dependencies & Gotchas +- `site_peaks()`/`hourly_ledger()` index per-minute meters by `k0 + (start.minute % 5 + i) // 5`; without the start-minute offset a mid-slot plug-in once reported a 171 kW "peak" on a 150 kW feed. `test_day.py::test_metered_peak_never_reports_above_the_feed` guards it. `anthropic` is only imported inside `assist.ask()`; no key = deterministic fallback, so CI needs neither key nor network. - **scipy must stay 1.14.x**: 1.15.2's HiGHS bindings took 66 s for any ~11k-column LP on Windows (HiGHS itself 0.01 s); 1.14.1 = 38 ms. `tests/test_perf.py` trips if the pin is lifted. - HiGHS returns "Unknown" (status 4) when the objective is only 1e-7 tie-breaks (all-ASAP baseline of nearly-full cars). Keep ASAP_EPS = 1e-3; `solve()` falls open to full power and logs ERROR if it ever happens. - Progress floor must be anchored to arrival, not `now`: a rolling re-solve otherwise defers it forever under a falling MOER. @@ -82,6 +85,7 @@ Signals (WattTime MOER; CAISO fuel-mix fallback) + tariff table + sessions (dead - 2026-09-12 — Session data = ACN 2019-04-09 re-dated onto the 2026 signal day; kwh_needed = delivered energy, stated departure = the driver's own input (early leavers kept). ## Changelog +2026-09-13 | WP6 operator assistant | noonshift/assist.py, assist_knowledge.md, api.py (/assist, ops_auth, events deque, site_peaks/hourly_ledger mid-slot offset), models.py, tests/test_assist.py, web AssistDrawer + lib/api.js, smoke.py, requirements.txt (anthropic) | explains, never acts; deterministic fallback so the demo needs no key; fixed a reporting bug where a mid-slot plug-in could show a peak above the feed 2026-09-13 | WP5 facilities-manager ops dashboard | api.py (`hourly_ledger`, `/impact.csv`, StatusOut/ImpactOut fields), models.py, web/src/{context,components/UrgencyControl,lib/ui,pages/*}, smoke.py | CO2 leads $ everywhere; every number is a live field (no Math.random); urgency is the only driver-side control the FM has; mobile = responsive layout only, not checked by eye 2026-09-13 | WP4 evidence scripts | scripts/prove.py, fetch_data.py, fit.py, replay_days.py, scheduler.impact(health=) | timer baseline is the judge's number (−56 % CO2 over a dumb timer); constrained site shows the carbon-vs-tariff conflict honestly; forecast flag wired, unverified without creds 2026-09-13 | WP3 cars, needs, queues | models.py, sim.py, api.py, data/site.json, tests/test_day.py, scripts/smoke.py | vehicle form → per-car cap + kWh from SoC; observation guard believes the meter; connectors_asap c41/c42; done event + idle_min; Sim.waiting + move-by tightening; a notified done car moves within 10 min when someone waits diff --git a/docs/openapi.json b/docs/openapi.json index 58dc647..87f5caf 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -548,11 +548,136 @@ } } }, + "/assist": { + "post": { + "summary": "Assist Ask", + "description": "The operator assistant: explains and suggests from live state + repo docs; never acts. Falls back to templates\nwithout a key; 429 with Retry-After when the model or the per-IP limit (10/min) says so.", + "operationId": "assist_ask_assist_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistIn" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssistOut" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/assist/suggestions": { + "get": { + "summary": "Assist Suggestions", + "operationId": "assist_suggestions_assist_suggestions_get", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Response Assist Suggestions Assist Suggestions Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/demo/early_unplug": { "post": { "summary": "Demo Early Unplug", "description": "Tom: told us 17:00, leaves now. Picks the charging car with the latest deadline and least energy.", "operationId": "demo_early_unplug_demo_early_unplug_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -563,6 +688,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -572,6 +707,24 @@ "summary": "Demo Boost", "description": "Sofia: 10:30 site visit. Picks the least-charged car, moves its deadline to now+1h with Boost.", "operationId": "demo_boost_demo_boost_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -582,6 +735,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -591,6 +754,24 @@ "summary": "Demo Oversubscribe", "description": "+20 late arrivals, 2 h deadlines, on the spare connectors. More demand than the feed can serve.", "operationId": "demo_oversubscribe_demo_oversubscribe_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], "responses": { "200": { "description": "Successful Response", @@ -601,6 +782,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -610,6 +801,24 @@ "summary": "Demo Signal Outage", "description": "Knock rungs out of the fail-safe ladder (default: the live signal) or restore all.", "operationId": "demo_signal_outage_demo_signal_outage_post", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], "requestBody": { "content": { "application/json": { @@ -652,6 +861,104 @@ }, "components": { "schemas": { + "AssistAction": { + "properties": { + "label": { + "type": "string", + "title": "Label" + }, + "path": { + "type": "string", + "title": "Path" + } + }, + "type": "object", + "required": [ + "label", + "path" + ], + "title": "AssistAction" + }, + "AssistIn": { + "properties": { + "question": { + "type": "string", + "maxLength": 2000, + "minLength": 1, + "title": "Question" + }, + "history": { + "items": { + "$ref": "#/components/schemas/Turn" + }, + "type": "array", + "title": "History", + "default": [] + }, + "page": { + "type": "string", + "maxLength": 200, + "title": "Page", + "default": "" + } + }, + "type": "object", + "required": [ + "question" + ], + "title": "AssistIn" + }, + "AssistOut": { + "properties": { + "answer": { + "type": "string", + "title": "Answer" + }, + "sources": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Sources", + "default": [] + }, + "suggested_actions": { + "items": { + "$ref": "#/components/schemas/AssistAction" + }, + "type": "array", + "title": "Suggested Actions", + "default": [] + }, + "fallback": { + "type": "boolean", + "title": "Fallback", + "default": false + }, + "degraded": { + "type": "boolean", + "title": "Degraded", + "default": false + }, + "usage": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Usage" + } + }, + "type": "object", + "required": [ + "answer" + ], + "title": "AssistOut" + }, "ConnectorPlan": { "properties": { "connector_id": { @@ -1449,6 +1756,29 @@ ], "title": "StatusOut" }, + "Turn": { + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant" + ], + "title": "Role" + }, + "content": { + "type": "string", + "maxLength": 4000, + "title": "Content" + } + }, + "type": "object", + "required": [ + "role", + "content" + ], + "title": "Turn" + }, "UrgencyIn": { "properties": { "level": { diff --git a/noonshift/api.py b/noonshift/api.py index c9303c0..9af6204 100644 --- a/noonshift/api.py +++ b/noonshift/api.py @@ -8,15 +8,16 @@ import os import random import statistics +from collections import deque from datetime import datetime, timedelta -from fastapi import FastAPI, HTTPException, Query, WebSocket, WebSocketDisconnect +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import PlainTextResponse +from fastapi.responses import JSONResponse, PlainTextResponse from pydantic import TypeAdapter -from . import db, scheduler, seed -from .models import (WS_FRAMES, ConnectorMeter, ConnectorPlan, DemoOut, DrEvent, EventMsg, FlexHour, ImpactOut, +from . import assist, db, scheduler, seed +from .models import (AssistIn, AssistOut, WS_FRAMES, ConnectorMeter, ConnectorPlan, DemoOut, DrEvent, EventMsg, FlexHour, ImpactOut, LiveOut, MeterMsg, OutageIn, PlanMsg, PlanWindow, PricePreview, PriceTier, SessionIn, SessionOut, SignalHour, StatusOut, UrgencyIn) from .sim import SLOT, SPEED, Connector, Sim, load_sessions, safe_share_kw @@ -156,7 +157,19 @@ async def broadcast(msg): async def event(name, detail): - await broadcast(EventMsg(sim_time=S["sim"].now, name=name, detail=detail)) + msg = EventMsg(sim_time=S["sim"].now, name=name, detail=detail) + S.setdefault("events", deque(maxlen=20)).append(msg.model_dump(mode="json")) # the assistant's "last 20 events" + await broadcast(msg) + + +def ops_auth(authorization: str | None = Header(None)): + """Bearer token for operator endpoints when OPS_TOKEN is set; unset = open (it is a demo).""" + tok = os.environ.get("OPS_TOKEN") + if tok and authorization != f"Bearer {tok}": + raise HTTPException(401, "operator token required") + + +OPS = [Depends(ops_auth)] def session_impact(h, remaining=()): @@ -327,7 +340,7 @@ async def lifespan(app): S.update(site=json.load(open("data/site.json")), signal=json.load(open("data/signal.json")), tariff=json.load(open("data/tariff.json")), plan={}, plan_at=None, baseline={}, impact={}, hist={}, mode="live", ladder={"live": True, "cached": True, "tariff": True, "deadline": True}, live_lost_at=None, - last_solve_at=None, dr=[], clients=set(), next_id=1000) + last_solve_at=None, dr=[], clients=set(), next_id=1000, events=deque(maxlen=20)) if os.environ.get("OCPP") == "1": from . import ocpp_gateway S["connector_cls"] = ocpp_gateway.OcppConnector @@ -471,7 +484,7 @@ def hourly_ledger(lo, hi): continue k0 = slot_of(h["start"]) for i, kw in enumerate(h["kw_min"]): - k = k0 + i // 5 + k = k0 + (h["start"].minute % 5 + i) // 5 # minute i of a session that began mid-slot lands in its true slot if k < 288: kwh[k] += kw / 60 out = [] @@ -505,7 +518,7 @@ def site_peaks(): for h in S.get("hist", {}).values(): k0 = slot_of(h["start"]) for i, kw in enumerate(h["kw_min"]): # per sim-minute since plug-in - k = k0 + i // 5 + k = k0 + (h["start"].minute % 5 + i) // 5 # minute i of a session that began mid-slot lands in its true slot if k < 288: metered[k] += kw / 5 for i, kw in enumerate(h["baseline"]): @@ -579,6 +592,23 @@ async def openadr_event(ev: DrEvent): return {"accepted": True, "events": len(S["dr"]), "mode": S["mode"]} +@app.post("/assist", response_model=AssistOut, dependencies=OPS) +def assist_ask(body: AssistIn, request: Request): + """The operator assistant: explains and suggests from live state + repo docs; never acts. Falls back to templates + without a key; 429 with Retry-After when the model or the per-IP limit (10/min) says so.""" + if not assist.allow(request.client.host if request.client else "?"): + return JSONResponse({"detail": "10 questions per minute; try again shortly."}, status_code=429, headers={"Retry-After": "30"}) + payload, status = assist.ask(body.question, [t.model_dump() for t in body.history], body.page) + if status != 200: + return JSONResponse({"detail": payload["detail"]}, status_code=status, headers={"Retry-After": str(payload.get("retry_after", 20))}) + return AssistOut(**payload) + + +@app.get("/assist/suggestions", response_model=list[str], dependencies=OPS) +def assist_suggestions(): + return assist.suggestions(assist.snapshot()) + + @app.websocket("/ws") async def ws(sock: WebSocket): await sock.accept() @@ -599,7 +629,7 @@ def charging_sessions(): return [c.session for c in S["sim"].active() if c.session["status"] == "charging"] -@app.post("/demo/early_unplug", response_model=DemoOut) +@app.post("/demo/early_unplug", response_model=DemoOut, dependencies=OPS) async def demo_early_unplug(): """Tom: told us 17:00, leaves now. Picks the charging car with the latest deadline and least energy.""" sim = S["sim"] @@ -619,7 +649,7 @@ async def demo_early_unplug(): return DemoOut(changed="session unplugged early; plan re-solved without it", detail=detail) -@app.post("/demo/boost", response_model=DemoOut) +@app.post("/demo/boost", response_model=DemoOut, dependencies=OPS) async def demo_boost(): """Sofia: 10:30 site visit. Picks the least-charged car, moves its deadline to now+1h with Boost.""" sim = S["sim"] @@ -641,7 +671,7 @@ async def demo_boost(): "plan": out.plan.model_dump(mode="json")}) -@app.post("/demo/oversubscribe", response_model=DemoOut) +@app.post("/demo/oversubscribe", response_model=DemoOut, dependencies=OPS) async def demo_oversubscribe(): """+20 late arrivals, 2 h deadlines, on the spare connectors. More demand than the feed can serve.""" sim = S["sim"] @@ -664,7 +694,7 @@ async def demo_oversubscribe(): return DemoOut(changed=f"{len(added)} cars plugged in with 2 h deadlines; plan re-solved (elastic shortfall)", detail=detail) -@app.post("/demo/signal_outage", response_model=DemoOut) +@app.post("/demo/signal_outage", response_model=DemoOut, dependencies=OPS) async def demo_signal_outage(body: OutageIn = OutageIn()): """Knock rungs out of the fail-safe ladder (default: the live signal) or restore all.""" sim = S["sim"] diff --git a/noonshift/assist.py b/noonshift/assist.py new file mode 100644 index 0000000..f976d52 --- /dev/null +++ b/noonshift/assist.py @@ -0,0 +1,360 @@ +"""Operator assistant (prompt.md WP6): a snapshot of the live site, one model call over a cached system prefix, and a +deterministic fallback so the demo never depends on a key or the internet. It explains and suggests; it never acts.""" +import json +import logging +import os +import re +import time +from collections import defaultdict, deque +from pathlib import Path + +log = logging.getLogger("noonshift.assist") + +MODEL = "claude-opus-5" +MAX_ROWS = 15 # connector rows in the snapshot: the most relevant ones, plus counts for the rest +SNAPSHOT_CHARS = 24_000 # ~6 k tokens +KNOWLEDGE = (Path(__file__).with_name("assist_knowledge.md")).read_text(encoding="utf-8") +RULES = """You are the Noonshift operator assistant, talking to the facilities manager of one corporate charging site. + +Answer only from the JSON in the user turn and the knowledge text below. site_state is today's live +state: simulated time, the fail-safe mode and ladder, site limits, impact so far, connector rows (the most relevant +15 plus counts), the waiting queue, the last events, and today's signal and tariff summaries. If the state does not +contain the answer, say which dashboard page or API endpoint would show it instead of guessing. + +Rules: +- Every number that is an estimate is called an estimate (saved $, saved kg, discounts, km equivalents). Metered kWh + and kW are measured. +- Never claim a certificate, renewable electrons, or physical attribution; the signal is marginal and temporal. +- Never say an action was taken. You cannot press anything. The operator clicks. End with at most two suggested + actions, each on its own line, exactly in the form: label -> /ops/. Pages: /ops/overview, /ops/sites (live + charging), /ops/sessions, /ops/chargers, /ops/schedules (plan), /ops/impact, /ops/alerts, /ops/tariffs. +- Keep answers under 120 words unless the operator asks for detail. Plain words, no headings. +- "What if X fails" -> walk the ladder from the current rung: live -> cached -> tariff -> deadline -> full (static + safe share on the charger). Say what each rung still guarantees. +- Money questions use the shared-savings formula (business 7b) with this site's R, alpha, beta and package from + site_state. When R is 0, charging is free and the driver-side incentive is non-monetary. +- Do not speculate about other sites, other days, or the grid beyond today's signal summary. +- After the suggested actions, end with one line: [sources: ]. +If no tool or state can answer what was asked, say so instead of guessing. Do not include internal XML tags in your response.""" + +SYSTEM = [ + {"type": "text", "text": RULES, "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": KNOWLEDGE, "cache_control": {"type": "ephemeral"}}, +] + +ACTION_RE = re.compile(r"^[ \t]*(?:[-*•]\s*)?\**(.+?)\**\s*(?:→|->)\s*`?(/ops/[\w/-]*)`?\**\s*$", re.M) +SOURCES_RE = re.compile(r"\[sources?:\s*([^\]]*)\]\s*", re.I) +BAY_RE = re.compile(r"\bc\d{2}\b", re.I) + + +# ---- snapshot ---- +def snapshot(): + """A plain dict from api.S, no I/O. Reuses the REST builders so every number matches the dashboard.""" + from . import api # lazy: api imports this module + S, sim = api.S, api.S["sim"] + now = sim.now + status = api.site_status(S["site"]["id"]).model_dump(mode="json") + impact = api.site_impact(S["site"]["id"], None, None).model_dump(mode="json") + k0 = int((api.slot_start(now) - api.slot_start(S["plan_at"])).total_seconds() // 300) if S["plan_at"] else 0 + rows = [] + for c in api.meter_msg().connectors: + if c.session_id is None: + continue + rem = max(0.0, c.kwh_needed - c.kwh_delivered) + mins = (c.departure_at - now).total_seconds() / 60 + rows.append({ + "connector": c.connector_id, "session": c.session_id, "status": c.status, "kw": c.kw, + "plan_kw_next_6": [round(x, 2) for x in S["plan"].get(c.connector_id, [])[k0:k0 + 6]], + "kwh_delivered": round(c.kwh_delivered, 2), "kwh_needed": c.kwh_needed, "cap_kw": c.p_max_kw, + "departure": c.departure_at.strftime("%H:%M"), "slack_h": round(mins / 60 - rem / c.p_max_kw, 2), + "urgency": c.urgency, "boost": c.boost, "asap_bay": c.asap, "move_by": c.move_by, + "risk": "high" if mins < 30 and c.kwh_delivered < 0.9 * c.kwh_needed and c.status != "done" else "low", + "idle_min": c.idle_min, "cap_observed": c.cap_observed, "need_confidence": c.need_confidence}) + counts = {"plugged": len(rows), "charging": sum(r["status"] == "charging" for r in rows), + "done": sum(r["status"] == "done" for r in rows), "at_risk": sum(r["risk"] == "high" for r in rows), + "urgent": sum(bool(r["urgency"] or r["boost"]) for r in rows), + "free_bays": status["n_connectors"] - len(rows), "waiting": status["waiting"]} + rows.sort(key=lambda r: (r["risk"] != "high", not (r["urgency"] or r["boost"]), r["status"] != "charging", + r["status"] != "done", r["slack_h"])) + moer, price = S["signal"]["moer"], S["tariff"]["price_per_kwh"] + per = len(moer) // 24 + hourly = [sum(moer[h * per:(h + 1) * per]) / per for h in range(24)] + hp = [round(sum(price[h * 12:(h + 1) * 12]) / 12, 4) for h in range(24)] + bands, start = [], 0 + for h in range(1, 25): + if h == 24 or hp[h] != hp[start]: + bands.append({"from_hour": start, "to_hour": h, "usd_per_kwh": hp[start]}) + start = h + site_keys = ("feed_kw", "block_kw", "contracted_peak_kw", "safe_share_kw", "n_connectors", "p_max_kw", "package", + "employee_rate_usd_per_kwh", "driver_share", "noonshift_share", "connectors_asap") + snap = { + "sim_time": now.isoformat(timespec="minutes"), "mode": status["mode"], "ladder": status["ladder"], + "site": {k: status[k] for k in site_keys}, "impact": impact, "status": status, + "site_kw_now": round(sum(c.kw for c in sim.connectors.values()), 2), + "building_kw_now": S["site"]["building_load_kw"][api.slot_of(now)], + "connectors": rows[:MAX_ROWS], "counts": counts, "waiting": status["waiting"], + "events": list(S.get("events", [])), + "signal": {"kind": S["signal"].get("kind"), "source": S["signal"].get("source"), "date": S["signal"].get("date"), + "moer_min": round(min(moer), 1), "moer_max": round(max(moer), 1), "moer_mean": round(sum(moer) / len(moer), 1), + "zero_hours": [h for h in range(24) if hourly[h] == 0], "hourly_gco2_per_kwh": [round(x) for x in hourly]}, + "tariff": {"name": S["tariff"].get("name"), "bands": bands, "block_kw": S["tariff"].get("block_kw"), + "block_price_usd_per_kw": S["tariff"].get("block_price"), "overage_multiplier": S["tariff"].get("overage_multiplier")}, + } + while len(json.dumps(snap)) > SNAPSHOT_CHARS and snap["events"]: # ponytail: events are the only elastic part + snap["events"] = snap["events"][1:] + return snap + + +# ---- model ---- +def parse(text): + """Split the model's answer into (answer, sources, suggested_actions) per the RULES format.""" + sources = [] + m = SOURCES_RE.search(text) + if m: + sources = [s.strip() for s in m.group(1).split(",") if s.strip()] + text = text[:m.start()] + text[m.end():] + actions = [{"label": lbl.strip(" *`"), "path": path} for lbl, path in ACTION_RE.findall(text)][:2] + text = ACTION_RE.sub("", text) + text = re.sub(r"(?im)^\s*\**suggested actions?:?\**\s*$\n?", "", text) + text = re.sub(r"", "", text) + return text.strip(), sources, actions + + +def ask(question, history=(), page="", snap=None): + """Returns (payload, http_status). The model when a key works; the deterministic fallback otherwise.""" + snap = snap or snapshot() + if not os.environ.get("ANTHROPIC_API_KEY"): + return fallback(question, snap), 200 + import anthropic + messages = [{"role": t["role"], "content": t["content"]} for t in list(history)[-6:]] + messages.append({"role": "user", "content": f"{json.dumps(snap, sort_keys=True)}\n{page}\n{question}"}) + try: + resp = anthropic.Anthropic().messages.create( + model=MODEL, max_tokens=1024, thinking={"type": "adaptive"}, output_config={"effort": "low"}, + system=SYSTEM, messages=messages) + except anthropic.AuthenticationError as e: + log.warning("assist: bad key, falling back (request_id=%s)", getattr(e, "request_id", None)) + return fallback(question, snap), 200 + except anthropic.RateLimitError as e: + log.warning("assist: model rate limit (request_id=%s)", getattr(e, "request_id", None)) + return {"detail": "The assistant is rate-limited by the model API; try again shortly.", "retry_after": 20}, 429 + except (anthropic.APIConnectionError, anthropic.APIStatusError) as e: + log.warning("assist: %s, degraded fallback (request_id=%s)", type(e).__name__, getattr(e, "request_id", None)) + return {**fallback(question, snap), "degraded": True}, 200 + answer, sources, actions = parse("".join(b.text for b in resp.content if b.type == "text")) + return {"answer": answer, "sources": sources, "suggested_actions": actions, "usage": resp.usage.model_dump()}, 200 + + +# ---- rate limit ---- +_hits = defaultdict(deque) + + +def allow(ip, limit=10, window=60.0, now=None): + """10 requests per minute per client IP. ponytail: in-process; a shared store when there is more than one worker.""" + q, now = _hits[ip], time.monotonic() if now is None else now + while q and q[0] <= now - window: + q.popleft() + if len(q) >= limit: + return False + q.append(now) + return True + + +# ---- deterministic fallback ---- +def _out(answer, sources, actions): + return {"answer": answer, "sources": sources, "suggested_actions": [{"label": l, "path": p} for l, p in actions], "fallback": True} + + +def _hhmm(iso): + return iso[11:16] if iso and len(iso) >= 16 else str(iso) + + +def _bay(q, snap): + bay = BAY_RE.search(q).group(0).lower() + row = next((r for r in snap["connectors"] if r["connector"] == bay), None) + if row is None: + n = snap["counts"]["plugged"] + return _out(f"Bay {bay} is not among the {min(n, MAX_ROWS)} most relevant plugged bays in my snapshot ({n} plugged, " + f"{snap['counts']['free_bays']} free). The Chargers page lists every bay with its live kW and plan cap.", + ["The scheduler"], [("Open Chargers", "/ops/chargers")]) + if row["status"] == "done": + why = f"finished ({row['kwh_delivered']} of {row['kwh_needed']} kWh) and has been idle {row['idle_min']} min" + elif row["cap_observed"]: + why = (f"capped by the observation guard: the car drew well under its limit for 5 minutes, so the planner now believes the " + f"meter ({row['cap_kw']} kW). That is the car, not the charger") + elif row["kw"] <= 1.5 and row["slack_h"] > 0.5: + clean = snap["signal"]["zero_hours"] + when = f" (today's zero-carbon hours are {clean[0]:02d}:00–{clean[-1] + 1:02d}:00)" if clean else "" + hold = "the 1.4 kW (6 A) floor" if row["kw"] > 0 else "an empty slot in its plan" + why = (f"deferred: {hold}. It has {row['slack_h']} h of slack before {row['departure']}, so the plan moves the bulk of its " + f"{row['kwh_needed']} kWh to cleaner or cheaper slots inside that deadline{when}; next slots {row['plan_kw_next_6']} kW. Not a fault") + elif row["urgency"] or row["boost"]: + why = f"urgent ({row['urgency'] or 'asap bay'}): first claim on site headroom at up to {row['cap_kw']} kW" + else: + why = f"in its planned window: next slots {row['plan_kw_next_6']} kW, deadline {row['departure']}, slack {row['slack_h']} h" + return _out(f"Bay {bay} is at {row['kw']} kW, {row['kwh_delivered']} of {row['kwh_needed']} kWh delivered — {why}.", + ["The scheduler"], [("Open the bay", "/ops/chargers"), ("See the plan", "/ops/schedules")]) + + +def _risk(q, snap): + hi = [r for r in snap["connectors"] if r["risk"] == "high"] + n = snap["counts"]["at_risk"] + if n == 0: + return _out("No session is at risk right now: every plugged car is on track for its stated departure (the LP treats each deadline " + "as a hard constraint with an explicit, priced shortfall, and the first-hour and progress floors keep early leavers " + "covered). If cars arrive faster than the feed allows, the shortfall is shared fairly and the Sessions page flags it.", + ["The scheduler", "Driver-side guarantees"], [("Open Sessions", "/ops/sessions")]) + lines = "; ".join(f"{r['connector']} leaves {r['departure']} with {r['kwh_delivered']}/{r['kwh_needed']} kWh" for r in hi[:5]) + return _out(f"{n} session{'s' if n > 1 else ''} at risk — departure inside 30 minutes with under 90 % of the stated need: {lines}. " + "Usual causes: a late plug-in with a short deadline, the site oversubscribed (shortfall shared by the fairness term), or an " + "observed cap on the car. A driver can press 'Leaving now' for full power at rate R; the operator cannot exceed the feed.", + ["The scheduler", "Urgency — three bands, one price"], [("Open Sessions", "/ops/sessions"), ("Live charging", "/ops/sites")]) + + +LADDER = [("live", "marginal CO₂ + tariff + deadlines"), ("cached", "the last forecast, good for 6 h"), + ("tariff", "tariff + deadlines, no carbon"), ("deadline", "earliest-deadline-first only"), + ("full", "no solve: every bay holds its static safe share")] + + +def _outage(q, snap): + mode = snap["mode"] + i = next(k for k, (m, _) in enumerate(LADDER) if m == mode) + walk = " → ".join(f"{m} ({d})" for m, d in LADDER[i:]) + share = snap["site"]["safe_share_kw"] + return _out(f"We are on the '{mode}' rung. If the grid API dies the ladder steps down: {walk}. Every rung that solves still honours " + f"deadlines and the feed; the last rung is the {share} kW static share that lives on each charger itself, so even with the " + f"backend gone the sum over {snap['site']['n_connectors']} bays stays under the {snap['site']['feed_kw']} kW feed. Nobody is stranded; " + "the plan gets less clean, not less safe.", + ["The fail-safe ladder", "OCPP"], [("Watch the mode", "/ops/overview"), ("Alerts", "/ops/alerts")]) + + +def _co2(q, snap): + im = snap["impact"] + kg = im["saved_kgco2"] + return _out(f"Today so far (estimate, vs charging at plug-in): {kg} kg CO₂ and ${im['saved_usd']} saved across {im['sessions']} sessions " + f"and {im['kwh']} kWh metered — about {kg / 0.25:.0f} km not driven or {kg / 2.31:.1f} L of petrol (EPA factors). " + f"{100 * im['renewable_share']:.0f} % of the kWh went in at zero marginal carbon (renewable-hour share). Site peak {im['peak_kw']} kW " + f"vs {im['baseline_peak_kw']} kW charge-now.", + ["Carbon into things people picture", "Renewable share"], [("Energy & Impact", "/ops/impact"), ("Download the ledger", "/ops/impact")]) + + +def _urgency(q, snap): + r = snap["site"]["employee_rate_usd_per_kwh"] + return _out(f"Three driver buttons, one price. 'Leaving now' sets the deadline to now: full power immediately, first claim on headroom. " + f"'Leaving soon' takes a time (15 min or later) and the plan fills the cleanest slots inside it. 'Prioritise' keeps the deadline " + f"but raises the car's floor to 90 % of pro-rata and doubles its weight, so it wins ties. Every urgent session pays the " + f"traditional rate R = ${r}/kWh and forfeits the shared-savings discount — that is why nobody presses it to jump the queue. " + f"{snap['counts']['urgent']} urgent right now.", + ["Urgency — three bands, one price"], [("Sessions", "/ops/sessions")]) + + +def _safe_share(q, snap): + s = snap["site"] + return _out(f"The safe share is {s['safe_share_kw']} kW per bay: min(bay max {s['p_max_kw']} kW, (feed {s['feed_kw']} kW − peak building load) / " + f"{s['n_connectors']} bays). It is the static OCPP profile (stack 0, no expiry) under the dynamic plan (stack 1, 15-min validity), " + "so a charger that stops hearing from us reverts to it by itself. An inspector accepts it because the sum over every bay never " + "exceeds the feed with no software running.", + ["The fail-safe ladder", "OCPP"], [("Chargers", "/ops/chargers")]) + + +def _load(q, snap): + s, im = snap["site"], snap["impact"] + total = snap["site_kw_now"] + snap["building_kw_now"] + return _out(f"Right now the chargers draw {snap['site_kw_now']} kW on top of {snap['building_kw_now']} kW building load = {total:.1f} kW, " + f"against a {s['block_kw']} kW tariff block, {s['contracted_peak_kw']} kW contracted peak and {s['feed_kw']} kW feed. The feed is a " + f"hard constraint; the block is priced (overage from the demand charge) and is crossed only when deadlines or floors need it. " + f"Today's managed peak {im['peak_kw']} kW vs {im['baseline_peak_kw']} kW if everyone charged at plug-in.", + ["The scheduler", "The customer"], [("Overview", "/ops/overview"), ("Plan", "/ops/schedules")]) + + +def _queue(q, snap): + w, mv = snap["waiting"], [r for r in snap["connectors"] if r["move_by"]] + done = [r for r in snap["connectors"] if r["status"] == "done"] + mvs = ", ".join(f"{r['connector']} by {r['departure']}" for r in mv[:5]) or "none yet" + return _out(f"{w} car{'s' if w != 1 else ''} waiting for a bay. Done cars idle 10 min or more are asked to move first " + f"({len(done)} done now); then, one per waiting car, the plugged car with the most slack gets a move-by time = now + time to " + f"finish at full power (move-by set: {mvs}). Never a car under 1 h of slack, never an urgent one.", + ["The scheduler"], [("Live charging", "/ops/sites")]) + + +def _money(q, snap): + s, im = snap["site"], snap["impact"] + r, a, b = s["employee_rate_usd_per_kwh"], s["driver_share"], s["noonshift_share"] + disc = a * im["saved_usd"] / im["kwh"] if im["kwh"] else 0.0 + free = " Charging is free here (R = 0), so the driver-side incentive is non-monetary: a guaranteed charge by leave time and a fair rotation." if r == 0 else "" + return _out(f"Package: {s['package']}. Drivers pay R = ${r}/kWh minus a shared-savings discount: price = R − α·S/E with α = {a}, where S is that " + f"session's measured saving vs charge-now and E its kWh; urgent sessions pay R. Today's site saving rate is about " + f"${disc:.4f}/kWh to the driver (estimate). Split: driver α = {a}, Noonshift β = {b}, the site keeps the rest — the site never " + f"earns less than today.{free}", + ["The shared-savings price", "The customer"], [("Tariffs & Package", "/ops/tariffs")]) + + +def _signal(q, snap): + sg = snap["signal"] + z = sg["zero_hours"] + win = f"{z[0]:02d}:00–{z[-1] + 1:02d}:00" if z else "none today" + return _out(f"Today's signal is {sg['kind']} carbon from {sg['source']}: {sg['moer_min']}–{sg['moer_max']} g/kWh, mean {sg['moer_mean']}. " + f"Zero-carbon hours (the marginal plant is renewable): {win}. The plan moves flexible energy into that window and charges the " + f"rest where the tariff is lowest, within every deadline. When the kind is 'average' we say 'cleaner than average', not 'renewable'.", + ["Renewable share", "The scheduler"], [("Plan", "/ops/schedules"), ("Energy & Impact", "/ops/impact")]) + + +def _trickle(q, snap): + z = snap["signal"]["zero_hours"] + when = f" Today that window is {z[0]:02d}:00–{z[-1] + 1:02d}:00." if z else "" + return _out("1.4 kW is the 6 A floor (IEC 61851): a car is never asked for a smaller trickle, so any allocation between 0 and 1.4 kW " + "rounds up to it. A deferred car draws 0 kW in the slots its plan leaves empty and resumes in its window; the bulk of its " + "energy is planned into cleaner or cheaper slots inside its deadline, and the first-hour floor (min(need, 3.5 kWh) in 60 min) " + f"and the 30-min progress floor still apply.{when} Ask about a specific bay (e.g. c07) for its numbers.", + ["The scheduler"], [("Chargers", "/ops/chargers")]) + + +def _done(q, snap): + done = [r for r in snap["connectors"] if r["status"] == "done"] + lst = ", ".join(f"{r['connector']} ({r['idle_min']} min idle)" for r in done[:8]) or "none" + return _out(f"{snap['counts']['done']} car{'s' if snap['counts']['done'] != 1 else ''} finished and still plugged: {lst}. A done car is " + f"notified; when someone is waiting it is asked to move after 10 idle minutes. Bays: {snap['counts']['free_bays']} free, " + f"{snap['waiting']} waiting.", + ["The scheduler"], [("Live charging", "/ops/sites")]) + + +ROUTES = [ + (("at risk", "risk", "miss", "short", "late", "deadline"), _risk), + (("dies", "outage", "fail", "down", "offline", "ladder", "mode", "backend", "rung"), _outage), + (("safe share", "static", "inspector", "1.83"), _safe_share), + (("co2", "co₂", "carbon", "kg", "km", "saved", "save", "impact", "renewable"), _co2), + (("boost", "urgen", "leaving", "priorit", "emergency", "sooner"), _urgency), + (("1.4", "trickle", "minimum", "paused", "pause"), _trickle), + (("done", "idle", "finished", "unplug"), _done), + (("waiting", "queue", "move", "rotate", "more cars", "than bays", "than plugs"), _queue), + (("price", "pay", "cost", "discount", "rate", "$", "money", "package", "alpha", "beta", "profit", "bill"), _money), + (("load", "block", "feed", "peak", "limit", "contracted", "overage", "kw", "overcharge", "circuit", "bug", "exceed"), _load), + (("signal", "moer", "solar", "clean", "cleanest", "when", "hour", "grid"), _signal), +] + + +def fallback(question, snap): + """No network: keyword-route to a template filled from the snapshot. Unknown -> the starter questions.""" + q = question.lower() + if BAY_RE.search(q): + return _bay(q, snap) + for keys, fn in ROUTES: + if any(k in q for k in keys): + return fn(q, snap) + return _out("I can answer about today's site state and how Noonshift works; try one of: " + " · ".join(suggestions(snap)), + [], [("Overview", "/ops/overview")]) + + +def suggestions(snap): + """Six starter questions built from the current state.""" + c, m = snap["counts"], snap["mode"] + charging = [r for r in snap["connectors"] if r["status"] == "charging"] + low = min((r for r in charging if 0 < r["kw"] <= 1.5), key=lambda r: r["kw"], default=None) or min(charging, key=lambda r: r["kw"], default=None) + return [ + f"{c['at_risk']} sessions are at risk — why?" if c["at_risk"] else "Is any car going to miss its deadline?", + f"{snap['waiting']} cars are waiting — who moves first?" if snap["waiting"] else "What happens when more cars arrive than bays?", + f"We are in {m} mode — what does that mean?" if m != "live" else "What happens if the grid API dies?", + f"Why is bay {low['connector']} only getting {low['kw']} kW?" if low else "Why would a bay sit at 0 or 1.4 kW?", + "How much CO₂ did we save today, in km?", + f"{c['done']} cars are done — should they move?" if c["done"] else "What does a driver pay today?", + ] diff --git a/noonshift/assist_knowledge.md b/noonshift/assist_knowledge.md new file mode 100644 index 0000000..82e0974 --- /dev/null +++ b/noonshift/assist_knowledge.md @@ -0,0 +1,101 @@ +# Noonshift — operator assistant knowledge + +Static text, assembled by hand from README, .docs/ARCHITECTURE.md §2–§5, .docs/business.md §0b/§4b/§7b, .docs/metrics.md §2/§3/§6 and .docs/pitch/hard-questions.md. Nothing time-varying belongs here; live numbers come from ``. + +## What Noonshift does + +1. Asks one pre-filled question at plug-in — "Leaving at 17:30?" — and works if the driver ignores it (need is then estimated from this bay's history, else the site median; `need_confidence` = declared / history / site). +2. Solves a linear program every 5 minutes across every connector at the site: minimise marginal CO₂ and tariff cost, subject to the site feed, the tariff block, and every driver's deadline. +3. Never broadcasts a signal: one solver per site, so it cannot herd a fleet the way broadcast carbon signals do. +4. Shows an honest receipt — $ and kg CO₂ saved vs charging at plug-in, labelled estimate, method one tap away. +5. Fails safe: live → cached → tariff → deadline → full. Hardware limits live on the charger; software can delay charging, never exceed a limit. + +Demo buttons on the ops dashboard (`POST /demo/*`): Boost (a driver taps "leaving now" → full power at rate R), Early unplug (progress floor means they leave with a usable charge), Oversubscribe (+20 cars; elastic LP spreads shortfall fairly), Signal outage (the ladder steps down and back). + +## The five-minute control loop (ARCHITECTURE §2) + +Every simulated minute `api.step()` ticks the simulator (plug-ins, unplugs, done, queue moves), records each car's metered kW, and runs the observation guard. On an event or at a 5-minute boundary `resolve()` picks the mode from the ladder, solves the plan **and** a charge-now baseline (every deadline = now), pushes per-connector limits, and broadcasts a `plan` frame; every minute it broadcasts a `meter` frame (site kW, per-connector kW/kWh/status). The receipt is metered-so-far + plan-ahead versus the baseline **frozen at plug-in**, otherwise a rolling re-solve would shrink every receipt to zero by unplug time. + +## The scheduler (ARCHITECTURE §3) + +Variables: `p[i,t]` kW per car per 5-min slot (288 slots), `s[i]` shortfall kWh, `over` kW above the tariff block, `z` worst shortfall fraction. + +minimise Σ p·h·(price[t] + W_CARBON·moer[t]/1000) + M_SHORT·priority_i·s[i] + overage·over + M_SHORT·mean(e_rem)·z + +Constraints and why: +- Energy by deadline (elastic): Σ p·h + s[i] ≥ e_rem — never infeasible; a shortfall is explicit, priced, reported. +- Progress floor: at every 30-min checkpoint since arrival, delivered + planned ≥ ALPHA (0.5; 0.9 for a "prioritise" car) × pro-rata need. Anchored to arrival so re-solves cannot defer it forever. +- First-hour floor: min(need, 3.5 kWh) within 60 min of arrival. A floor beats the carbon signal and the tariff block but never the feed. +- Sprint buffer: the last 30 min before a deadline carry a surcharge, so a feasible car finishes early. +- Charger cap: 0 ≤ p ≤ min(bay p_max, car max_kw), reduced above 80 % SoC by the taper model. +- Site limit: Σ p ≤ feed − building_load[t]. Never exceeded. Block: Σ p − over ≤ block_kw (overage priced from the demand charge). +- Fairness: s[i]/e_rem[i] ≤ z — the worst fraction is minimised, so shortfall is shared, nobody is starved. +- Post-step: any allocation in (0, 1.4 kW) rounds up to 1.4 kW (6 A, IEC 61851: a car is never asked for a sub-6 A trickle it cannot draw). A deferred car draws 0 kW in the slots the plan leaves empty and resumes inside its window; when rounding would exceed a limit, the cars with the most slack give way first. Results round down, never over a limit. + +Why a bay shows 0 or 1.4 kW: the LP deferred the bulk of that car's energy to cleaner or cheaper slots later inside its deadline (0 kW = this slot is empty in its plan; 1.4 kW = the 6 A floor). The first-hour floor (min(need, 3.5 kWh) in 60 min) and the 30-min progress floor still hold, so it is never far behind. It is not a fault. A bay can also be capped by the observation guard (below) or by the car itself. + +ASAP cars (deadline passed, "leaving now", or a fleet bay in `connectors_asap`) charge immediately; slot cost is only a tie-break for them. Size: ~11,500 variables for 40 cars, HiGHS solves in tens of ms on scipy 1.14. + +Observation guard (solutions.md §10): a car that draws under 80 % of its limit for 5 minutes cannot take what was planned (PHEV, cold battery, wrong form). The planner believes the meter: `max_kw` = metered × 1.05 for the rest of the session, flagged `cap_observed`. + +Queue (solutions.md §3): more cars than bays is normal at a corporate lot. Arrivals with no bay wait; a done car idle ≥ 10 min is asked to move when someone waits; per waiting car the plugged car with the most slack (≥ 1 h, not urgent) gets a move-by time = now + time to finish at full power. + +## The fail-safe ladder (ARCHITECTURE §4) + +live (marginal CO₂ + tariff + deadlines) → cached (same, on the last forecast ≤ 6 h old) → tariff (tariff + deadlines, no carbon) → deadline (earliest-deadline-first only) → full (no solve: every connector holds its static **safe share**). + +`pick_mode()` walks the rungs top-down and returns the first healthy one; `resolve()` shapes the LP inputs to match (tariff ⇒ empty moer; deadline ⇒ also empty price; full ⇒ no solve). A rung down is a degraded plan, never a stranded car: deadlines are honoured on every rung that solves, and the full rung is exactly what the chargers do by themselves when the backend disappears. + +Safe share (solutions.md §5): `min(bay p_max, (feed − max building load) / n_connectors)` — 1.833 kW on the 60-bay, 150 kW demo site. It is the profile an electrical inspector accepts: the sum over every bay never exceeds the feed even with no software running. + +## OCPP (ARCHITECTURE §5) + +OCPP 1.6J, two charging profiles per connector: stack 0 = the static safe share, sent once after BootNotification with no expiry; stack 1 = the dynamic plan limit, valid for 15 minutes and refreshed every 10. If the backend goes quiet the dynamic profile expires and the charger reverts to the safe share on its own. Hardware max current is configured on the charger; a profile can only lower it. Simulated chargers exchange the same messages a real one does (`OCPP=1`); no real hardware yet. + +## The customer (business §0b) + +A company that owns its building and chargers, pays the building's bill, and lets employees charge — usually free, sometimes at a flat employee rate R. Dwell 8–9 h. More EVs than plugs; rotation at lunch. The money is the building's demand charge and the feed, not energy arbitrage: never let the chargers create a new building peak, and fit more ports on the existing service (60 × 7 kW nameplate on a 150 kW feed). Two reports the company already files benefit: Scope 2 and Scope 3 category 7, with an hourly ledger behind them (`/sites/{id}/impact.csv`). Honest sentence: at a building with spare capacity the recurring cash value is small; at one adding chargers or near its peak it is five figures a year plus a one-time upgrade avoided. + +Packages: **capacity** (more ports on the same service, peak never raised), **clean-hours** (Scope 2/3 ledger, renewable-hour share), **pilot** (both, evaluation term). + +## Urgency — three bands, one price (business §4b) + +| Band | Driver sees | Scheduler does | Price | +|---|---|---|---| +| now | "Leaving now" | deadline = now → full power immediately, first claim on headroom; other cars keep their floors | R | +| soon | "Leaving soon" + time | deadline = that time (≥ 15 min out); the LP fills the cleanest slots inside the window | R | +| priority | "Not urgent — prioritise me" | deadline unchanged; floor 90 % of pro-rata + weight 2 in the shortfall term, so it wins ties | R | + +Every urgent session pays the traditional rate R; the §7b discount exists only for sessions never marked urgent — pressing any button forfeits the discount, so nobody presses it to game the queue. Twenty "now" at once: headroom ÷ 7 kW ≈ 17 cars at full power; the LP shares the shortfall fairly — "as fast as the site can", not "full power". Fleet bays default to now. + +## The shared-savings price (business §7b) + +R = what the driver pays per kWh today (site setting; $0 where charging is free). E_i = kWh delivered. S_i = the measured saving of that session versus charge-now (energy arbitrage + that car's slice of avoided demand charge and program payments), floored at 0. + +discount_i = α × S_i / E_i · price_i = R − discount_i · urgent or no slack ⇒ S_i = 0 ⇒ price = R. + +Profit: π_site = π_today + (1 − α − β) × S_i ≥ π_today; π_noonshift = β × S_i; driver = α × S_i. α = driver share (site knob, 0–0.5), β = Noonshift share (contract, e.g. 0.2). The site cannot lose, the driver cannot pay more than R, Noonshift is paid on results. The estimate is shown at plug-in and settled on the receipt from metered kWh. On the measured Caltech day S ≈ $2.20 for 379 kWh — pennies per driver; at evening-arrival or power-limited sites it is $18–33/day. + +## Carbon into things people picture (metrics §2, EPA factors) + +km not driven = ΔCO₂ / 0.25 kg per km · litres of petrol = ΔCO₂ / 2.31 · tree seedlings grown 10 yr = ΔCO₂ / 60 kg · cars off the road per year = ΔCO₂_yr / 4.6 t · smartphone charges = ΔCO₂ / 8.2 g. + +## Renewable share (metrics §3) + +The signal is marginal: MOER = 0 means the next kWh comes from a renewable plant that would otherwise be curtailed. Renewable-hour share = Σ_{t: MOER=0} E_t / Σ E_t. Curtailment absorbed = Σ_{MOER=0} E_t, label "at most". When the signal kind is "average" (CAISO fallback) say "cleaner than the grid average", not "renewable". + +## Driver-side guarantees (metrics §6) + +Deadline hit rate (target 100 %), shortfall kWh (0), first-hour guarantee ≥ min(need, 3.5 kWh) (test-enforced), no sub-6 A allocation (every non-zero kW ≥ 1.4), fairness z = worst shortfall fraction (0), emergency rate (< 10 %), price parity max price ≤ R (test-enforced). + +## Hard questions + +- The driver lies about departure: the stated time sets the deadline and therefore the discount; a later-than-true time earns nothing extra because the discount is on kWh actually shifted; an earlier one costs slack. Saying "now" is today's behaviour. +- The forecast is wrong: re-solved every 5 min and on every event; the receipt uses metered kWh; numbers are labelled estimates. +- The backend dies: the ladder, then the static safe share on the charger — inspector-grade, no software needed. +- Can a bug overcharge a circuit: feed and per-bay caps are hard LP constraints, allocations round down, the hardware limit stays on the charger; tests assert the feed is never exceeded even oversubscribed. +- The driver leaves early: the progress floor and first-hour floor mean a usable charge; the receipt says how many kWh short. +- More cars than chargers: queue + move-by; the room to chase the cleanest hour shrinks and we say so. +- Why not a 09–14 timer: on the real day the LP beats the dumb timer by −56 % CO₂, −28 % bill, −28 % peak, because it shares the feed, honours deadlines under contention, and follows the signal minute by minute. +- Isn't −69 % a sunny-day artefact: partly — January gas day −1.8 %, July −64.5 %, April −69 %; quote the range; the mechanism is the product. +- What we do not claim: not 100 % renewable electrons, not certificates, not DC fast charging, not V2G; attribution is temporal, not physical. diff --git a/noonshift/models.py b/noonshift/models.py index 1606d11..84bf4fe 100644 --- a/noonshift/models.py +++ b/noonshift/models.py @@ -206,3 +206,29 @@ class EventMsg(BaseModel): WS_FRAMES = PlanMsg | MeterMsg | EventMsg + + +# ---- operator assistant (WP6) ---- +class Turn(BaseModel): + role: Literal["user", "assistant"] + content: str = Field(max_length=4000) + + +class AssistIn(BaseModel): + question: str = Field(min_length=1, max_length=2000) + history: list[Turn] = [] + page: str = Field("", max_length=200) + + +class AssistAction(BaseModel): + label: str + path: str + + +class AssistOut(BaseModel): + answer: str + sources: list[str] = [] + suggested_actions: list[AssistAction] = [] + fallback: bool = False # template answer, no model + degraded: bool = False # model unreachable; template answer + usage: dict | None = None diff --git a/requirements.txt b/requirements.txt index 054911e..e87ec88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pydantic==2.* scipy==1.14.* # keep: 1.15.x HiGHS bindings took 66 s per 40-car solve on Windows (1.14.1: 38 ms), see tests/test_perf.py ocpp==2.0.* websockets>=12,<14 +anthropic==1.5.* diff --git a/scripts/smoke.py b/scripts/smoke.py index 176548f..159e689 100644 --- a/scripts/smoke.py +++ b/scripts/smoke.py @@ -135,6 +135,22 @@ async def pump(): assert ctype.startswith("text/csv") and lines[0] == "day,hour,kwh,gco2_per_kwh,kgco2,signal_kind,signal_source" and len(lines) == 25, (ctype, lines[:2]) assert sum(float(l.split(",")[2]) for l in lines[1:]) > 0, "the ledger carries today's metered kWh" print(f"[ops] /status package={st['package']} contracted peak {st['contracted_peak_kw']} kW R=${st['employee_rate_usd_per_kwh']}/kWh signal={st['signal_kind']}; /impact.csv {len(lines) - 1} hourly rows") + + # 8. the operator assistant (WP6): answers without a key, never acts, rate-limited + sugg = http("GET", "/assist/suggestions") + assert len(sugg) == 6, sugg + a = http("POST", "/assist", {"question": "what happens if the grid API dies?", "page": "/ops/overview", + "history": [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]}) + assert "ladder" in a["answer"] and a["suggested_actions"][0]["path"].startswith("/ops/"), a + assert a["fallback"] or "usage" in a, "no key -> fallback; key -> model usage" + b = http("POST", "/assist", {"question": sugg[3]}) + assert b["answer"] and "try one of" not in b["answer"], b + try: + http("POST", "/assist", {"question": ""}) + raise AssertionError("empty question accepted") + except urllib.error.HTTPError as e: + assert e.code == 422, e.code + print(f"[assist] {sugg[0]!r} -> {a['answer'][:70]!r}... fallback={a.get('fallback', False)} actions={[x['label'] for x in a['suggested_actions']]}") if imp["baseline_peak_kw"] < imp["peak_kw"]: # possible early in the day (sprints); not a failure, just say so print("[ops] note: managed peak above charge-now peak right now") task.cancel() diff --git a/tests/test_assist.py b/tests/test_assist.py new file mode 100644 index 0000000..22c4823 --- /dev/null +++ b/tests/test_assist.py @@ -0,0 +1,133 @@ +"""WP6: the operator assistant — snapshot cap and keys, the deterministic fallback for every starter and hard question, +the label -> path parser, the per-IP limit, the endpoint without a key, and (with a key) prompt-cache reuse.""" +import asyncio +import json +import os +from datetime import timedelta + +import pytest +from fastapi import HTTPException + +from noonshift import assist +from noonshift.api import S, demo_oversubscribe, ops_auth, step, urgency +from noonshift.models import UrgencyIn +from tests.test_day import fresh + +pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning") + + +async def _play(hours): + end = S["sim"].now + timedelta(hours=hours) + while S["sim"].now < end: + await step() + + +@pytest.fixture(scope="module") +def snap(): + """Late morning with a queue, an urgent car and done cars, so every template has something to say.""" + fresh() + asyncio.run(_play(4.0)) + asyncio.run(demo_oversubscribe()) + first = next(c.session["id"] for c in S["sim"].active()) + asyncio.run(urgency(first, UrgencyIn(level="priority"))) + asyncio.run(_play(1.0)) + return assist.snapshot() + + +def test_snapshot_has_the_keys_and_fits_the_cap(snap): + for k in ("sim_time", "mode", "ladder", "site", "impact", "status", "connectors", "counts", "waiting", "events", "signal", "tariff"): + assert k in snap, k + assert len(json.dumps(snap)) <= assist.SNAPSHOT_CHARS + assert 0 < len(snap["connectors"]) <= assist.MAX_ROWS + assert snap["counts"]["plugged"] >= len(snap["connectors"]) + assert snap["site"]["safe_share_kw"] == pytest.approx(1.833, abs=1e-3) + assert snap["events"] and snap["events"][-1]["name"] + row = snap["connectors"][0] + assert {"connector", "session", "kw", "plan_kw_next_6", "kwh_delivered", "kwh_needed", "departure", "slack_h", "urgency", "risk", + "idle_min", "cap_observed"} <= set(row) + + +def test_snapshot_is_json_and_sorted_rows_lead_with_the_relevant(snap): + json.dumps(snap, sort_keys=True) # what goes into the user turn + urgent = [r for r in snap["connectors"] if r["urgency"] or r["boost"]] + assert urgent, "the priority car made the cut" + + +def test_every_starter_question_gets_a_grounded_fallback(snap): + sugg = assist.suggestions(snap) + assert len(sugg) == 6 and len(set(sugg)) == 6 + for q in sugg: + out = assist.fallback(q, snap) + assert out["fallback"] is True and len(out["answer"]) > 40, q + assert all(a["path"].startswith("/ops/") for a in out["suggested_actions"]), q + assert "try one of" not in out["answer"], f"unrouted starter: {q}" + + +@pytest.mark.parametrize("q,needle", [ + ("why is bay c05 only getting 1.4 kW?", "Bay c05"), + ("what happens if the grid API dies?", "ladder"), + ("what is the safe share?", "1.833"), + ("how much co2 did we save today", "kg CO"), + ("what does boost do", "Leaving now"), + ("who is waiting for a bay", "waiting"), + ("what does a driver pay", "R = $"), + ("is site load above the block?", "tariff block"), + ("when is the cleanest hour today", "Zero-carbon hours"), + ("which cars are done", "finished"), + ("the backend dies", "safe share"), + ("can a bug overcharge the circuit", "hard constraint"), + ("why would a bay sit at 1.4 kW?", "6 A floor"), +]) +def test_hard_questions_route_to_the_right_template(snap, q, needle): + out = assist.fallback(q, snap) + assert needle in out["answer"], (q, out["answer"][:120]) + + +def test_unknown_question_offers_the_starters(snap): + out = assist.fallback("what is the meaning of life", snap) + assert "try one of" in out["answer"] and out["fallback"] + + +def test_parser_strips_actions_and_sources(): + text = ("Bay c07 is holding 1.4 kW because it has 5 h of slack.\n\n" + "Open the bay -> /ops/chargers\n- **See the plan** → `/ops/schedules`\nIgnored third -> /ops/impact\n" + "[sources: The scheduler, The fail-safe ladder]") + answer, sources, actions = assist.parse(text) + assert answer == "Bay c07 is holding 1.4 kW because it has 5 h of slack." # every action line is stripped, two are kept + assert sources == ["The scheduler", "The fail-safe ladder"] + assert actions == [{"label": "Open the bay", "path": "/ops/chargers"}, {"label": "See the plan", "path": "/ops/schedules"}] + assert assist.parse("plain answer")[0] == "plain answer" + + +def test_rate_limit_is_ten_per_minute_per_ip(): + assist._hits.clear() + assert all(assist.allow("1.2.3.4", now=100.0 + i) for i in range(10)) + assert not assist.allow("1.2.3.4", now=110.0) + assert assist.allow("5.6.7.8", now=110.0) + assert assist.allow("1.2.3.4", now=161.0) # the first hit aged out + + +def test_ask_without_a_key_is_the_fallback(snap, monkeypatch): + """The HTTP round-trip (200 + fallback flag, 422 on empty, 429 after ten) is scripts/smoke.py's job; this is the unit.""" + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + payload, status = assist.ask("what happens if the grid API dies?", [{"role": "user", "content": "hi"}], "/ops/overview", snap) + assert status == 200 and payload["fallback"] is True and "ladder" in payload["answer"] and payload["suggested_actions"] + + +def test_ops_token_guards_operator_endpoints(monkeypatch): + monkeypatch.delenv("OPS_TOKEN", raising=False) + ops_auth(None) # unset = open (demo) + monkeypatch.setenv("OPS_TOKEN", "s3cret") + with pytest.raises(HTTPException): + ops_auth(None) + with pytest.raises(HTTPException): + ops_auth("Bearer wrong") + ops_auth("Bearer s3cret") + + +@pytest.mark.skipif(not os.environ.get("ANTHROPIC_API_KEY"), reason="needs ANTHROPIC_API_KEY") +def test_prompt_cache_is_reused_on_the_second_call(snap): + """Two consecutive calls: the second must read the cached system prefix. If not, something volatile crept into SYSTEM.""" + a, _ = assist.ask("what is the safe share?", snap=snap) + b, _ = assist.ask("and what happens if the grid API dies?", snap=snap) + assert "usage" in a and b["usage"]["cache_read_input_tokens"] > 0, (a.get("usage"), b.get("usage")) diff --git a/tests/test_day.py b/tests/test_day.py index af21d43..ab727e8 100644 --- a/tests/test_day.py +++ b/tests/test_day.py @@ -335,3 +335,11 @@ def test_a_queue_tightens_the_slackest_car_and_a_bay_frees(mid_morning): dones = sink.events("done") assert dones, "a full car raises a done event (the 'please move' notification)" S["clients"].discard(sink) + + +def test_metered_peak_never_reports_above_the_feed(day): + """site_peaks() lays per-minute meters onto absolute 5-min slots. A session that began mid-slot used to be shifted by + up to 4 minutes, so two cars misaligned in opposite directions could report a slot above the feed that never + happened. The instantaneous sum is checked above; the reported KPI must agree with it.""" + assert any(h["start"].minute % 5 for h in day["hist"].values()), "the seed has mid-slot plug-ins, or this test proves nothing" + assert day["site"].peak_kw <= S["site"]["feed_kw"] + 1e-6, day["site"].peak_kw diff --git a/web/src/components/AppShell.jsx b/web/src/components/AppShell.jsx index a0cda69..e70adda 100644 --- a/web/src/components/AppShell.jsx +++ b/web/src/components/AppShell.jsx @@ -1,6 +1,7 @@ import { NavLink, Outlet } from 'react-router-dom'; import { useGlobalState } from '../context/GlobalStateContext'; import { MODE_COPY } from '../lib/ui'; +import AssistDrawer from './AssistDrawer'; const NAV = [ { path: '/ops/overview', label: 'Overview' }, @@ -78,6 +79,7 @@ export default function AppShell() { )} + ); } diff --git a/web/src/components/AssistDrawer.jsx b/web/src/components/AssistDrawer.jsx new file mode 100644 index 0000000..92c5dd9 --- /dev/null +++ b/web/src/components/AssistDrawer.jsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { API } from '../lib/api'; + +// The operator assistant (prompt.md WP6): explains and suggests from live state + repo docs; never acts. The operator clicks. +const KEY = 'noonshift.assist.history'; +const load = () => { try { return JSON.parse(sessionStorage.getItem(KEY)) || []; } catch { return []; } }; +const save = (msgs) => { try { sessionStorage.setItem(KEY, JSON.stringify(msgs.slice(-40))); } catch { /* private window etc. */ } }; + +export default function AssistDrawer() { + const [open, setOpen] = useState(false); + const [msgs, setMsgs] = useState(load); + const [input, setInput] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); // { text, retry } + const [chips, setChips] = useState([]); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const endRef = useRef(null); + + useEffect(() => { + if (!open) return; + fetch(`${API}/assist/suggestions`).then((r) => (r.ok ? r.json() : [])).then(setChips).catch(() => setChips([])); + }, [open]); + useEffect(() => { save(msgs); endRef.current?.scrollIntoView({ block: 'end' }); }, [msgs, busy]); + + const ask = async (question) => { + const q = question.trim(); + if (!q || busy) return; + const history = msgs.slice(-6).map(({ role, content }) => ({ role, content })); + setMsgs((m) => [...m, { role: 'user', content: q }]); + setInput(''); + setBusy(true); + setError(null); + try { + const r = await fetch(`${API}/assist`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question: q, history, page: pathname }) }); + if (!r.ok) { + const d = await r.json().catch(() => ({})); + throw new Error(r.status === 429 ? `${d.detail || 'Too many questions.'} (retry in ${r.headers.get('retry-after') || 20} s)` : d.detail || `Backend answered ${r.status}`); + } + const a = await r.json(); + setMsgs((m) => [...m, { role: 'assistant', content: a.answer, sources: a.sources, actions: a.suggested_actions, offline: a.fallback || a.degraded }]); + } catch (e) { + setMsgs((m) => m.slice(0, -1)); + setError({ text: e.message || 'Could not reach the backend.', retry: q }); + } finally { + setBusy(false); + } + }; + + return ( + <> + + + {open && ( + + )} + + ); +} diff --git a/web/src/context/GlobalStateContext.jsx b/web/src/context/GlobalStateContext.jsx index 7421fe1..98787ee 100644 --- a/web/src/context/GlobalStateContext.jsx +++ b/web/src/context/GlobalStateContext.jsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect, useMemo, useRef } from 'react'; import { hhmm, MODE_COPY } from '../lib/ui'; +import { API } from '../lib/api'; /* * Live state for the ops dashboard, fed by the Noonshift backend: @@ -10,7 +11,6 @@ import { hhmm, MODE_COPY } from '../lib/ui'; */ const SITE = 'site-1'; -const API = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? '/api' : 'http://localhost:8000'); const WS_URL = import.meta.env.VITE_WS_URL ?? (import.meta.env.DEV ? `ws://${location.host}/ws` : 'ws://localhost:8000/ws'); // Gantt axis in OpsDashboard runs 06:00 -> 22:00 diff --git a/web/src/lib/api.js b/web/src/lib/api.js new file mode 100644 index 0000000..cdae188 --- /dev/null +++ b/web/src/lib/api.js @@ -0,0 +1,2 @@ +// One base URL for every fetch: the Vite proxy in dev, the backend on 8000 in a static build, VITE_API_URL to override. +export const API = import.meta.env.VITE_API_URL ?? (import.meta.env.DEV ? '/api' : 'http://localhost:8000');