From 019ea5fd8d2ed88626601d73a0c253a3532adfc3 Mon Sep 17 00:00:00 2001 From: Workshop Helmsman Date: Mon, 20 Jul 2026 01:56:49 +0530 Subject: [PATCH 01/10] phase-1: rewrite full spec for v0.2 rebuild (Next.js + FastAPI, versioned polling, AI help-desk design) --- spec/agent.md | 89 ++++++++++- spec/api.md | 265 +++++++++++++++++++++++++++++++ spec/architecture.md | 278 ++++++++++++++++++++++---------- spec/capabilities.md | 137 ++++++++-------- spec/data-model.md | 369 ++++++++++++++++++++++++++++--------------- spec/roadmap.md | 172 ++++++++++++-------- spec/vision.md | 51 +++--- 7 files changed, 999 insertions(+), 362 deletions(-) create mode 100644 spec/api.md diff --git a/spec/agent.md b/spec/agent.md index 6c00b87..ce1f571 100644 --- a/spec/agent.md +++ b/spec/agent.md @@ -1,5 +1,88 @@ -# Agent Layer — Workshop Helmsman (v0.1) +# AI Help-Desk — Workshop Helmsman (v0.2) -**This product has no agent framework.** It is a regular web app (FastAPI + Jinja2 server-rendered + vanilla JS). No LLM in the loop, no tool-use loop, no MCP. +The AI layer arrives in **Phase 4**, but this design is fixed now: the schema (data-model.md), the API surface (api.md), and this pipeline are designed once so Phase 4 wires — it does not redesign. -If future phases add an AI Help Desk (a sub-agent that suggests fixes when a participant flags they're stuck), that will live in `capabilities.md` and `roadmap.md` — *not* here. This file exists only so the spec layout matches the boilerplate contract. \ No newline at end of file +## Framework decision: NO agent framework + +**A plain structured pipeline — no LangGraph/CrewAI/AutoGen, no tool-use loop.** Per `harness/patterns/agentic-ai.md`: "reach down only when there are no tools… if the task is a fixed transform with no branching, a prompt chain or a single call is correct — don't bolt a loop onto a one-shot." This task is a fixed transform: *deterministically gather context → one LLM call → route on confidence*. There are no open-ended tool decisions for the model to make (the system, not the model, fetches the context), no multi-turn planning, no inter-agent coordination. A framework would add dependency weight, latency, and failure modes to a three-step function. + +Patterns used (from the catalogue): **#2 Routing** (confidence-gated auto-answer vs draft), **#13 Human-in-the-Loop** (draft review; one-tap escalation), **#14 Knowledge Retrieval** (FTS5 similarity over past resolutions — keyword RAG, no vector DB), **#9 Learning & Adaptation** (the corpus grows with every facilitator resolution — this replaces canned replies), **#12 Exception Handling** (degrade to manual, never crash), **#18 Guardrails** (schema-validated JSON output, confidence clamps), **#19 Evaluation & Monitoring** (audit rows, structured logs, spend tracking). + +## Entry point + +```python +# src/helmsman/ai/pipeline.py +def run_help_desk(help_request_id: int) -> None: ... +``` + +Invoked as a FastAPI `BackgroundTask` after `POST /api/p/{token}/help` commits — the participant's request returns instantly; the AI answer appears via the normal poll. The pipeline opens its own DB session (`create_db_session`), sets `help_request.ai_state = "pending"` first, and is **re-runnable**: if the process restarts mid-pipeline, the request simply remains `open` in the facilitator queue with `ai_state = "pending"` — no data loss, no stuck state (a request is never blocked on the AI; the queue always shows it). + +**Preconditions (else `ai_state = "skipped"`, silently manual):** `OPENROUTER_API_KEY` present **and** `workshop.ai_enabled` **and** workshop status in (`live`, `grace`) **and** request status `open` and not `escalated`. Escalated requests never re-enter the pipeline — a human was demanded. With no key, nothing in the product references AI errors anywhere: air-gapped means zero errors, and the facilitator AI toggle renders as a labelled "AI off (no API key)" state. + +## Step 1 — Context gathering (deterministic, no LLM) + +Collected into the exact structure stored as `help_answer.ai_context_json` (data-model.md) — what is stored is *precisely* what the model saw, so the facilitator's expandable "context the AI used" view is honest by construction: + +1. **Participant progress:** completed count / total, titles of completed milestones (most recent 5). +2. **Current milestone:** the request's `milestone_id` — title + `content_md` (first 2000 chars). This is the instruction text the participant is stuck on. +3. **Similar past requests (cross-workshop learning):** top **5** hits from the `help_corpus` FTS5 table — message + final resolution excerpts (≤500 chars each), ranked by BM25. This corpus spans **all workshops ever run** on the instance, so every facilitator resolution teaches future triage. + +### Similarity mechanism (realistic for SQLite — no vector DB) + +- **Corpus maintenance (app code, no triggers):** when a request reaches `resolved`, or a facilitator answers it, insert one row into `help_corpus` (message, human-visible resolution, milestone title, ids). Draft-only and unanswered requests never enter the corpus. The corpus is derived data — rebuildable from `help_request` + `help_answer` by a maintenance function, so the DB-is-truth rule holds. +- **Query construction:** tokenize the incoming message + current milestone title; drop stopwords and tokens <3 chars; take the top 12 remaining tokens; build an FTS5 `OR` query of **double-quoted** tokens (quoting neutralizes FTS5 operator injection from user text). Rank with `bm25(help_corpus)`, `LIMIT 5`. +- **PostgreSQL note:** on a PostgreSQL `DATABASE_URL`, the equivalent is a plain `help_corpus` table with a generated `tsvector` column + GIN index and `ts_rank` — same interface (`corpus.search(text) -> list[Hit]`), built only if/when a PostgreSQL deployment enables Phase 4. `src/helmsman/ai/corpus.py` isolates the dialect switch. + +## Step 2 — One LLM call: triage + answer combined + +One call, not two — the triage signal (confidence) and the candidate answer come from the same completion, halving cost and latency. + +- **Provider:** OpenRouter `POST /api/v1/chat/completions` via httpx. Model: `HELMSMAN_AI_MODEL` (default `anthropic/claude-sonnet-4-6`). Timeout 30 s; **one retry** on timeout/5xx with 2 s backoff. Request includes `usage: {"include": true}` so the response carries token counts and `usage.cost`. +- **Prompts** live as markdown files in `src/helmsman/ai/prompts/` (`helpdesk_system.md`, `helpdesk_user.md` — user template interpolates the context block). System prompt outline: + - You are the AI help-desk for a live hands-on technical workshop; a participant is stuck **right now** — be concise, concrete, actionable (≤150 words), markdown with code blocks where useful, formatted code (real newlines/indentation). + - You are given: their progress, the milestone instructions they are on, and similar past requests with how a human facilitator resolved them. Prefer approaches proven in past resolutions; never invent workshop-specific facts (URLs, credentials, file names) not present in the context. + - Report honest confidence: high only when the milestone content or a past resolution clearly covers the problem. If the request needs information you don't have (machine-specific state, account issues, anything ambiguous), answer with your best guidance but report low confidence. + - Output **only** JSON: `{"confidence": 0.0–1.0, "answer_md": "…", "reasoning": "one sentence for the audit log"}`. +- **Output guardrails:** parse strictly (`json.loads` on the extracted JSON object; one re-ask on malformed output with a "return only valid JSON" nudge, then fail). Validate: `confidence` ∈ [0,1] (clamp), `answer_md` non-empty and ≤10000 chars. **Evidence cap:** if the similar-requests list was empty, cap effective confidence at **0.6** — the model cannot claim high certainty without precedent or (per the prompt) explicit grounding in milestone content; this keeps early workshops (empty corpus) human-first while the corpus grows. +- **Spend:** write one `ai_usage` row (purpose `triage_answer`, model, tokens, `cost_usd` from `usage.cost` else 0, latency). Surfaced at `GET /api/f/{t}/spend` and on the dashboard. + +## Step 3 — Route on confidence + +Threshold: `HELMSMAN_AI_CONFIDENCE` (default **0.75**), compared against post-guardrail confidence. + +**Confident (≥ threshold) → auto-answer:** +- Insert `help_answer(source="ai", draft=false, ai_confidence, ai_model, ai_context_json)`; request → `answered`, `ai_state = "answered"`; bump `state_version`. +- Audit row `ai.auto_answer` (actor `ai`) with request id, confidence, reasoning — every AI-sent answer is logged who/what/when. +- Participant sees the answer on next poll, **clearly badged "AI answer"**, with a one-tap **"Get a human"** escalation (`POST …/escalate` → `escalated=true`, status back to `open`, flagged in the queue; the AI answer stays visible — honesty over tidiness). +- Facilitators see every auto-answer in their queue (status `answered`, source badge AI) with the expandable exact context (`ai_context`). + +**Unsure (< threshold) → draft for review:** +- Insert `help_answer(source="ai", draft=true, …)`; request **stays `open`**, `ai_state = "draft"`; bump version. +- The queue shows the draft + confidence + context; the facilitator edits and sends via the normal answer endpoint (a new `facilitator` answer row; the draft remains for audit). Audit row `ai.draft`. + +**Failure (timeout ×2, HTTP error, invalid output ×2):** +- `ai_state = "failed"`, ERROR log with request id and cause; **no user-visible error anywhere** — the request simply remains `open` in the facilitator queue, indistinguishable from AI-off for the participant. The AI is an accelerant, never a gate. + +## Sequence (normative) + +``` +participant POST /help ──commit──▶ 202-style instant response + └─ BackgroundTask: run_help_desk(id) + ├─ preconditions? ──no──▶ ai_state=skipped (manual queue) + ├─ gather_context(id) [context.py — DB only] + ├─ call_openrouter(context) [openrouter.py — 30s, 1 retry] + │ └─ failure ──▶ ai_state=failed, log ERROR (manual queue) + ├─ guardrails: parse/validate/clamp/evidence-cap + └─ route: + confidence ≥ τ ──▶ answer row (draft=false) → answered → audit ai.auto_answer + confidence < τ ──▶ answer row (draft=true) → open → audit ai.draft + then: ai_usage row · state_version bump · structured log ai.* with latency/tokens/cost +``` + +## Testing (Phase 4 gate — real key, from `.env`) + +- `tests/integration/ai/` runs against the **real OpenRouter key** (`pytest.skip` only if genuinely absent — skipped is not passed; the phase gate is BLOCKED without the key). +- **Corpus-size rule (data-processing gate):** the similarity fixture seeds **25+ resolved requests across ≥3 workshops**, exactly one of which is a strong topical match for the query; the test asserts that match is retrieved at rank 1 and appears in `ai_context_json` — a corpus small enough for "any hit = the hit" proves nothing. +- Route tests assert structure, not prose: a request whose answer is verbatim in the milestone content + a matching past resolution → auto-answer path artifacts (answer row `draft=false`, status `answered`, audit row, `ai_usage` row with nonzero tokens); an ambiguous machine-specific request with an empty corpus → draft path (evidence cap enforced). +- **Air-gapped test:** with `OPENROUTER_API_KEY` unset, the full participant + facilitator journey runs with zero errors and zero AI artifacts (`ai_state="skipped"`), and the UI shows the labelled AI-off state. +- Escalation test: auto-answer → escalate → status `open` + `escalated=true` + AI answer still visible + queue flag. diff --git a/spec/api.md b/spec/api.md new file mode 100644 index 0000000..f6a6362 --- /dev/null +++ b/spec/api.md @@ -0,0 +1,265 @@ +# API Contract — Workshop Helmsman (v0.2) + +This is the contract both generators build against **in parallel** — the backend implements it exactly; the frontend consumes it exactly. Nothing here is aspirational: field names, casing, and shapes are normative. + +## Conventions (normative) + +- All endpoints are under `/api/`. All bodies and responses are JSON (UTF-8). All field names are `snake_case`. +- **Timestamps:** ISO 8601 UTC with `Z`, seconds precision — `"2026-07-20T14:03:22Z"`. +- **IDs** are integers. **Tokens/slugs** are strings. +- **Success envelope:** HTTP 200 → `{"data": , "error": null}`. +- **Error envelope:** HTTP 4xx/5xx → `{"detail": {"code": "", "message": ""}}`. Pydantic validation failures are converted by an exception handler into this same shape (`code: "validation_error"`, message = first human-readable error) — the raw FastAPI 422 array shape never reaches clients. +- **Auth:** three schemes — `X-Admin-Key` header (admin surface), `admin_token` path segment (facilitator surface), `participant_token` path segment (participant surface). See architecture.md §Auth. +- **Versioned polling:** poll endpoints take `?v=` (last-seen `state_version`; default `-1` = always changed). Unchanged → `{"changed": false, "version": N, "content_version": M}`. The content endpoint uses `?cv=` against `content_version` the same way. Every mutation response includes `"version"` (the new `state_version`) so clients re-poll immediately. +- **URL fields** (`join_url`, `facilitator_url`, `participant_url`) are absolute, built from `HELMSMAN_BASE_URL` (or the request origin) + the pretty paths `/j/{slug}`, `/f/{admin_token}`, `/p/{participant_token}`. +- **Draft AI answers (`draft: true`) appear only in facilitator payloads — never in participant payloads.** `ai_context` appears only in facilitator payloads. + +### Error code catalogue + +| HTTP | `code` | When | +|---|---|---| +| 401 | `invalid_admin_key` | Missing/wrong `X-Admin-Key` | +| 404 | `not_found` | Unknown token/slug, or an id not in the token's scope (never distinguish wrong vs missing) | +| 409 | `workshop_paused` | Completion mark/unmark while paused (Phase 2+) | +| 409 | `workshop_not_started` | Join before `starts_at` (Phase 3) | +| 409 | `undo_expired` | Undo requested after the 30 s window (Phase 2) | +| 410 | `workshop_archived` | Any mutation on an archived workshop (Phase 3) | +| 422 | `validation_error` | Body/param validation failure | +| 500 | `internal_error` | Unexpected; logged with `request_id` | + +### Pretty redirect routes (not JSON API) + +`GET /` → `/app/` · `GET /j/{join_slug}` → `/app/join/?s=…` · `GET /p/{participant_token}` → `/app/p/?t=…` · `GET /f/{admin_token}` → `/app/f/?t=…` — all 307. **Phase 1.** + +--- + +# Phase 1 endpoints + +## Health + +### `GET /api/health` — no auth +`{"data": {"status": "ok", "db": "ok"}, "error": null}` — `db` is `"ok"` after a real `SELECT 1`, else 500 `internal_error`. + +## Admin surface (header `X-Admin-Key`) + +### `GET /api/admin/workshops` +Lists all workshops (also serves as key validation for the Admin Home login). Sorted `created_at` desc. + +```json +{"data": {"workshops": [{ + "id": 1, "name": "LangGraph Lab — July", "status": "live", + "participant_count": 143, "open_help_count": 2, + "created_at": "2026-07-20T09:00:00Z", + "join_slug": "Ab3dEfGh", "join_url": "http://localhost:8001/j/Ab3dEfGh", + "facilitator_url": "http://localhost:8001/f/" +}]}, "error": null} +``` +Errors: `invalid_admin_key`. + +### `POST /api/admin/workshops` +```json +{"name": "LangGraph Lab — July", "description_md": "Welcome!…", + "milestones": [{"title": "Set up your environment", "content_md": "```bash\nuv sync\n```", "minutes": 30}]} +``` +Validation: `name` 1–120 (trimmed); `description_md` ≤10000; `milestones` 1–50 items in given order; `title` 1–200; `content_md` ≤20000; `minutes` null or 1–480. +*(Phase 3 adds optional `agenda_template_id`, `join_form_template_id`, `starts_at`.)* + +Response — the full workshop: +```json +{"data": {"workshop": { + "id": 1, "name": "…", "description_md": "…", "status": "live", "paused": false, "ai_enabled": false, + "admin_token": "<43 chars>", "join_slug": "Ab3dEfGh", + "join_url": "…/j/Ab3dEfGh", "facilitator_url": "…/f/", + "created_at": "2026-07-20T09:00:00Z" +}}, "error": null} +``` +Side effects: audit row `workshop.create`. Errors: `invalid_admin_key`, `validation_error`. + +## Facilitator surface (path `admin_token`) + +### `GET /api/f/{admin_token}/workshop` +Initial dashboard load + milestone bodies (re-fetched when the dashboard poll reports a new `content_version`). +```json +{"data": { + "content_version": 3, + "workshop": {"id": 1, "name": "…", "description_md": "…", "status": "live", "paused": false, + "ai_enabled": false, "join_slug": "Ab3dEfGh", "join_url": "…", "facilitator_url": "…", + "created_at": "…"}, + "milestones": [{"id": 11, "position": 0, "title": "…", "content_md": "…", "minutes": 30}] +}, "error": null} +``` + +### `GET /api/f/{admin_token}/dashboard?v={int}` — poll (2 s) +Unchanged → `{"data": {"changed": false, "version": 42, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": { + "changed": true, "version": 42, "content_version": 3, + "workshop": {"id": 1, "name": "…", "status": "live", "paused": false, "ai_enabled": false}, + "stats": {"participant_count": 143, "active_count": 121, "finished_count": 12, + "median_progress_pct": 40.0, "open_help_count": 3, "answered_help_count": 5, + "resolved_help_count": 17}, + "milestone_stats": [{"milestone_id": 11, "position": 0, "title": "…", + "completed_count": 130, "completed_pct": 90.9}], + "distribution": [{"completed_count": 0, "participants": 5}, {"completed_count": 1, "participants": 20}], + "participants": [{"id": 7, "name": "Priya", "joined_at": "…", "last_seen_at": "…", + "completed_milestone_ids": [11, 12], "completed_count": 2, "progress_pct": 25.0, + "current_milestone_id": 13, "open_help_count": 1, + "participant_url": "…/p/"}], + "help_queue": [{"id": 9, "participant_id": 7, "participant_name": "Priya", + "milestone_id": 13, "milestone_title": "Configure the API key", + "message": "getting a 401 from…", "status": "open", "escalated": false, + "created_at": "…", "updated_at": "…", + "answers": [{"id": 3, "source": "facilitator", "answer_md": "Check `.env`…", + "draft": false, "created_at": "…", + "ai_confidence": null, "ai_model": null, "ai_context": null}]}], + "broadcast": null, + "alerts": null, + "pulse": null, + "spend": null +}, "error": null} +``` +Semantics: `active_count` = participants with `last_seen_at` within 5 min. `distribution` covers every completed-count 0…total (zeros included). `participants` sorted `joined_at` asc (client re-sorts). `help_queue` = all `open` + `answered` requests (newest first within status, `open` block first) plus the 50 most recent `resolved`; totals live in `stats`. `broadcast`/`alerts`/`pulse` are `null` until Phase 2, `spend` `null` until Phase 4 — the frontend renders labelled stubs for them from day one. + +### `POST /api/f/{admin_token}/help/{help_request_id}/answer` +Body: `{"answer_md": "…"}` (1–10000). Effect: append `help_answer(source="facilitator")`, request → `answered`, audit `help.answer`, bump version. +Response: `{"data": {"help_request": , "version": 43}, "error": null}`. +Errors: `not_found` (id not in this workshop), `validation_error`. Answering a `resolved` request re-opens nothing — it appends the answer and leaves status `resolved`. + +### `POST /api/f/{admin_token}/help/{help_request_id}/resolve` +Facilitator closes a request. Idempotent. Audit `help.resolve`. +Response: `{"data": {"help_request": , "version": 44}, "error": null}`. + +## Participant surface + +### `GET /api/join/{join_slug}` — no auth; reads auto-resume cookie +```json +{"data": { + "workshop": {"name": "…", "description_md": "…", "status": "live", + "milestone_count": 8, "participant_count": 143}, + "me": null +}, "error": null} +``` +If the browser carries a valid `helmsman_p_{workshop_id}` cookie, `"me": {"participant_token": "<22 chars>", "name": "Priya"}` — the page then forwards to the tracker without re-joining. Errors: `not_found`. + +### `POST /api/join/{join_slug}` +Body: `{"name": "Priya"}` (1–80 trimmed; duplicates allowed). *(Phase 3 adds `"answers": {…}` for custom join fields.)* +Effect: create participant; **set cookie** `helmsman_p_{workshop_id}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000`; bump version. +```json +{"data": {"participant_token": "<22 chars>", "participant_url": "…/p/", "name": "Priya"}, "error": null} +``` +Errors: `validation_error`, `workshop_archived`, `workshop_not_started` (Phase 3). + +### `GET /api/p/{participant_token}/state?v={int}` — poll (3 s) +Unchanged → `{"data": {"changed": false, "version": 42, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": { + "changed": true, "version": 42, "content_version": 3, + "workshop": {"name": "…", "status": "live", "paused": false}, + "milestones": [{"id": 11, "position": 0, "title": "…", "minutes": 30}], + "me": {"id": 7, "name": "Priya", "completed_milestone_ids": [11], + "completed_count": 1, "total_count": 8, "progress_pct": 12.5, "rank": 37}, + "leaderboard": [{"rank": 1, "name": "Arun", "completed_count": 6, "progress_pct": 75.0, "is_me": false}], + "broadcast": null, + "help_requests": [{"id": 9, "message": "getting a 401…", "status": "answered", "escalated": false, + "milestone_id": 13, "created_at": "…", + "answers": [{"id": 3, "source": "facilitator", "answer_md": "Check `.env`…", + "created_at": "…"}]}] +}, "error": null} +``` +Semantics: `milestones` **excludes** `content_md` (bodies come from the content endpoint). `leaderboard` is the **full** ranked list (full names, always on). Ranking order: `completed_count` desc → earliest timestamp of reaching that count asc → `joined_at` asc; `rank` is the 1-based position (no tie-sharing). `help_requests` = mine only, newest first; never includes drafts or `ai_context`. Side effect: throttled `last_seen_at` touch (≥60 s since last). +Errors: `not_found`. Archived workshops still return state (read-only archive view; `workshop.status` = `"archived"`). + +### `GET /api/p/{participant_token}/content?cv={int}` +Unchanged → `{"data": {"changed": false, "content_version": 3}, "error": null}`. +Changed: +```json +{"data": {"changed": true, "content_version": 3, + "workshop": {"name": "…", "description_md": "…"}, + "milestones": [{"id": 11, "position": 0, "title": "…", "content_md": "…", "minutes": 30}] +}, "error": null} +``` + +### `POST /api/p/{participant_token}/milestones/{milestone_id}/complete` +Idempotent (unique constraint; re-complete is a no-op success). Blocked while paused/archived. +Response: `{"data": {"completed_milestone_ids": [11, 13], "completed_count": 2, "progress_pct": 25.0, "version": 43}, "error": null}`. +Errors: `not_found` (milestone not in my workshop), `workshop_paused`, `workshop_archived`. + +### `POST /api/p/{participant_token}/milestones/{milestone_id}/uncomplete` +Deletes my completion if present (idempotent). Same response shape and errors as complete. + +### `POST /api/p/{participant_token}/help` +Body: `{"message": "…"}` (1–4000; rendered as plain text everywhere). Effect: create request with `milestone_id` = my current milestone (lowest-position incomplete; null if finished); bump version. *(Phase 4: additionally triggers the AI pipeline as a background task when enabled.)* +Response: `{"data": {"help_request": , "version": 43}, "error": null}`. +Errors: `validation_error`, `workshop_archived`. + +### `POST /api/p/{participant_token}/help/{help_request_id}/resolve` +Participant marks their own request resolved. Idempotent. Errors: `not_found` (not mine). +Response: `{"data": {"help_request": , "version": 44}, "error": null}`. + +--- + +# Phase 2 endpoints — Facilitator command & proactive intelligence + +All under `/api/f/{admin_token}/…`; all bump `state_version`, write an audit row, and (where noted) record undo data. Undoable actions return `"undoable_action_id"`. + +| Endpoint | Body → Effect | +|---|---| +| `POST …/broadcast` | `{"message_md": "…"}` (1–4000) → new active broadcast (previous one cleared). Undoable (restores previous). Response: `{"broadcast": {...}, "version": N, "undoable_action_id": 77}` | +| `POST …/broadcast/clear` | `{}` → active broadcast `cleared_at` = now | +| `POST …/pause` | `{"paused": true|false}` → freeze/unfreeze completions. Undoable | +| `POST …/milestones/advance` | `{"milestone_id": 13, "participant_ids": [7, 9]}` or `"participant_ids": null` (= all) → create missing completions with `source: "facilitator"`. Undoable (deletes created rows). Response includes `"affected_count"` | +| `POST …/milestones/reorder` | `{"milestone_ids": [12, 11, 13]}` (exact permutation) → rewrite positions; bumps `content_version` too | +| `POST …/milestones` | `{"title", "content_md", "minutes"}` → append milestone; bumps `content_version` | +| `PATCH …/milestones/{id}` | any of `{"title", "content_md", "minutes"}` → edit; bumps `content_version` | +| `DELETE …/milestones/{id}` | removes milestone + its completions (confirmation is a UI concern); bumps both versions | +| `POST …/undo/{action_id}` | `{}` → apply inverse within 30 s. Errors: `undo_expired`, `not_found` | +| `GET …/audit?before_id={int}&limit={int≤100}` | → `{"actions": [{"id", "actor", "action", "detail": {…}, "created_at", "undone_at"}], "has_more": true}` newest first | +| `PATCH …/settings` | `{"stuck_minutes": 10}` (2–120) | + +Phase 2 also **activates** these dashboard-poll fields (shapes normative now): +```json +"broadcast": {"id": 5, "message_md": "…", "created_at": "…"}, +"alerts": {"stuck": [{"participant_id": 7, "name": "Priya", "minutes_inactive": 14, + "current_milestone_id": 13}], + "bottleneck": {"milestone_id": 13, "title": "Configure the API key", "waiting_count": 96}}, +"pulse": {"pace_ratio": 0.82, "on_track_pct": 61.0, "open_help_count": 3, + "projected_finish_at": "2026-07-20T17:40:00Z"} +``` +The participant poll's `broadcast` field activates with the same shape. Definitions: *stuck* = live, not paused, not finished, no completion and no help activity for ≥ `stuck_minutes`. *Bottleneck* = the milestone that is the current milestone of the largest participant group (≥25% of active participants, else null). *pace_ratio* = median actual minutes-per-milestone ÷ planned `minutes` (1.0 = on plan; >1 = slower). *on_track_pct* = % of participants whose progress ≥ elapsed-time-proportional expectation. *projected_finish_at* = now + (median remaining planned minutes × pace_ratio). + +--- + +# Phase 3 endpoints — Lifecycle, templates & re-run + +| Endpoint | Auth | Body → Effect | +|---|---|---| +| `POST /api/f/{t}/end` | token | `{"grace_hours": 24}` (0–168) → status `grace`, `ended_at`=now, `grace_until`=now+h. `0` archives immediately | +| `POST /api/f/{t}/archive` | token | `{}` → status `archived` now (terminal, read-only) | +| `POST /api/f/{t}/reopen` | token | `{}` → `grace` → `live` (mistake recovery; not allowed from `archived`) | +| `POST /api/f/{t}/clone` | token | `{"name": "…"}` → new `live` workshop, milestones copied, fresh tokens, zero participants. Response: new workshop object | +| `POST /api/f/{t}/save-as-template` | token | `{"name": "…"}` → agenda template from current milestones | +| `GET /api/admin/templates` | key | → `{"agenda_templates": [{"id","name","description_md","milestone_count","created_at","updated_at"}], "join_form_templates": [{"id","name","field_count","created_at","updated_at"}]}` | +| `POST /api/admin/templates/agenda` | key | `{"name", "description_md", "milestones": [{"title","content_md","minutes"}]}` | +| `GET /api/admin/templates/agenda/{id}` | key | full template incl. milestones | +| `PATCH /api/admin/templates/agenda/{id}` / `DELETE` | key | edit / delete (never affects existing workshops — snapshots) | +| `POST /api/admin/templates/forms` + `GET/PATCH/DELETE …/forms/{id}` | key | same pattern; body `{"name", "fields": []}` | + +`POST /api/admin/workshops` gains optional `agenda_template_id`, `join_form_template_id`, `starts_at`. `GET /api/join/{slug}` gains `"join_form": []` and `"starts_at"`; `POST /api/join/{slug}` gains `"answers": {…}` validated against the snapshot. Admin Home list gains `ended_at`/`starts_at` for Live/Upcoming/Ended grouping and an archived workshop's dashboard/tracker render read-only. + +--- + +# Phase 4 endpoints — AI help-desk + +| Endpoint | Auth | Body → Effect | +|---|---|---| +| `POST /api/f/{t}/ai` | token | `{"enabled": true|false}` → toggle; audit `ai.toggle`. When no `OPENROUTER_API_KEY` is configured, responds 200 with `{"enabled": false, "available": false}` — never an error (air-gapped) | +| `GET /api/f/{t}/spend` | token | → `{"total_cost_usd": 0.4312, "call_count": 57, "prompt_tokens": 91234, "completion_tokens": 18022, "by_purpose": [{"purpose": "triage_answer", "cost_usd": 0.4312, "count": 57}]}` | +| `POST /api/p/{token}/help/{id}/escalate` | token | `{}` → `escalated=true`, status → `open` (AI answers stay visible); bump version. Idempotent | + +Phase 4 **activates** (shapes normative now): dashboard `"spend": {"total_cost_usd": 0.43}`; help-queue answers carry real `ai_confidence`, `ai_model`, and `ai_context` (shape = `help_answer.ai_context_json`, data-model.md) plus `draft: true` rows for review; the workshop objects' `ai_enabled` goes live; tracker answers with `"source": "ai"` are badged AI client-side with the escalate action. Facilitator sending a reviewed draft uses the existing `POST …/help/{id}/answer` (the draft row stays for audit; the sent answer is a new `facilitator` row). + +--- + +*Phase 5 (deploy) adds no API surface.* diff --git a/spec/architecture.md b/spec/architecture.md index cc29c7d..c799212 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -1,106 +1,216 @@ -# Architecture — Workshop Helmsman (v0.1) +# Architecture — Workshop Helmsman (v0.2) ## Stack -- **Language:** Python 3.11 -- **Web framework:** FastAPI (with Jinja2Templates for server-rendered HTML) -- **Database:** SQLite via SQLAlchemy 2.x (dev) / PostgreSQL via `DATABASE_URL` (prod) -- **Templates:** Jinja2 (`frontend/templates/`) -- **Frontend JS:** vanilla (single file: `frontend/static/app.js`), polling every 4s -- **Styling:** single CSS file (`frontend/static/style.css`), CSS custom properties, mobile-first -- **Auth:** token-based (admin_token in URL, participant_slug in URL), HttpOnly cookie for participant session -- **Real-time:** short polling (4s) — no WebSockets. Single `/data` endpoint serves both participant tracker and admin dashboard (different shapes via query param). -- **Deploy:** Docker Compose + systemd unit. Runs on single VM (workshop.smalltech.in). -- **LLM:** Gemini via OpenRouter (`OPENROUTER_API_KEY` in `.env`). Optional — Phase 3 help-desk pre-resolution only. Graceful fallback if unavailable. +| Layer | Choice | Version / notes | +|-------|--------|-----------------| +| Language (backend) | Python | 3.12+ | +| Package manager (Python) | uv | all commands `uv run …` from repo root | +| Web framework | FastAPI | ≥0.115 | +| ASGI server | uvicorn | single process, single worker (see Process model) | +| ORM | SQLAlchemy | 2.x, sync engine, `Mapped`/`mapped_column` declarative | +| Migrations | Alembic | ≥1.13, `render_as_batch=True` for SQLite ALTER support | +| Database | SQLite (WAL) | file `data/helmsman.db`; PostgreSQL-ready via `DATABASE_URL` override — no SQLite-only SQL in app code (FTS5 corpus is the one documented exception, with a PostgreSQL note in agent.md) | +| Settings | pydantic-settings | v2, loads `.env` | +| Logging | structlog | JSON to stdout | +| HTTP client (LLM) | httpx | OpenRouter REST, 30s timeout | +| Frontend framework | Next.js | 15.x, App Router, **static export** (`output: 'export'`, `basePath: '/app'`) | +| UI library | React | 19.x | +| Language (frontend) | TypeScript | 5.x, strict | +| Styling | Tailwind CSS | v4 (via `@tailwindcss/postcss` + `@source "../";` in globals.css — never removed). Chosen for the polish bar: design tokens via `@theme`, consistent spacing/type scales, zero runtime cost in a static export | +| Markdown | react-markdown + remark-gfm + rehype-highlight | raw HTML disabled (no rehype-raw) — see Security | +| Package manager (frontend) | pnpm | frontend deps in `frontend/`, e2e deps in root `package.json` | +| E2E testing | Playwright | `@playwright/test`, chromium, config at repo root, tests in `tests/e2e/` | +| Backend testing | pytest | `tests/unit/`, `tests/integration/` | +| LLM provider | OpenRouter | key: `OPENROUTER_API_KEY` (exact name — project convention); default model `anthropic/claude-sonnet-4-6` via `HELMSMAN_AI_MODEL` | +| Deploy | Docker Compose on a VM; GitHub Actions CI/CD to GCP | Phase 5 — never blocks earlier phases | + +> **Assumed:** Node LTS (22.x) pinned via `frontend/.nvmrc` and `engines`; all frontend `dev`/`build` scripts carry `NODE_OPTIONS=--no-experimental-webstorage` (harness Node ≥25 safety rule). ## Repo layout ``` -workshop-helmsman/ -├── src/ # FastAPI app package +workshop-helmsman/ ← repo root IS the project; all commands run from here +├── src/ │ ├── __init__.py -│ ├── __main__.py # entrypoint: python -m src -│ ├── main.py # all routes (Phases 1-6 consolidated) -│ ├── models.py # SQLAlchemy models -│ ├── db.py # engine, session, init_db -│ └── security.py # token/slug generators, auth dependencies +│ ├── __main__.py ← boots uvicorn on 0.0.0.0:$PORT (default 8001) +│ └── helmsman/ +│ ├── __init__.py ← __version__ = "0.2.0" +│ ├── config/settings.py ← pydantic-settings; reads .env +│ ├── security.py ← token generation + auth dependencies +│ ├── db/ +│ │ ├── models.py ← all SQLAlchemy models (spec/data-model.md) +│ │ └── session.py ← engine, SQLite pragmas, session dependency +│ ├── api/ +│ │ ├── __init__.py ← create_app(): routers, static mount, pretty-link redirects +│ │ ├── _common.py ← ok(), api_error() +│ │ ├── health.py ← GET /api/health +│ │ ├── admin.py ← admin-key surface (create/list workshops, templates) +│ │ ├── facilitator.py ← /api/f/{admin_token}/… surface +│ │ └── participant.py ← /api/join/…, /api/p/{token}/… surface +│ ├── services/ +│ │ ├── snapshots.py ← versioned poll-payload builders + in-process cache +│ │ ├── lifecycle.py ← lazy status transitions (Phase 3) +│ │ ├── intelligence.py ← stuck / bottleneck / pulse computation (Phase 2) +│ │ ├── undo.py ← undoable-action record/replay (Phase 2) +│ │ └── templates.py ← template instantiate/save-as (Phase 3) +│ ├── ai/ ← AI help-desk pipeline (Phase 4; see spec/agent.md) +│ │ ├── pipeline.py ← run_help_desk(help_request_id) +│ │ ├── context.py ← context gathering +│ │ ├── corpus.py ← FTS5 corpus maintenance + similarity search +│ │ ├── openrouter.py ← httpx client, usage/cost capture +│ │ └── prompts/ ← *.md prompt templates +│ └── observability/ +│ └── logging.py ← structlog config + request-logging middleware ├── frontend/ -│ ├── templates/ # Jinja2 templates -│ │ ├── base.html -│ │ ├── landing.html -│ │ ├── admin_new.html -│ │ ├── admin_edit.html -│ │ ├── admin_dashboard.html -│ │ ├── admin_templates.html -│ │ ├── admin_template_edit.html -│ │ ├── admin_form.html -│ │ ├── admin_form_template.html -│ │ ├── participant_join.html -│ │ ├── participant_tracker.html -│ │ ├── participant_drilldown.html -│ │ ├── workshops_index.html -│ │ ├── workshop_archived.html -│ │ ├── workshop_expired.html -│ │ ├── _stubs.html -│ │ └── _form_field_row.html -│ └── static/ -│ ├── style.css # single stylesheet -│ ├── app.js # participant polling + UI -│ ├── help_status.js # inline help-status buttons (admin + participant) -│ └── form_editor.js # form builder drag/drop/inline edit -├── spec/ # this directory -├── data/ # sqlite file at runtime (gitignored) +│ ├── package.json ← pnpm; NODE_OPTIONS safety in scripts +│ ├── next.config.ts ← output: 'export', basePath: '/app', trailingSlash: true +│ ├── postcss.config.mjs ← @tailwindcss/postcss (never removed) +│ ├── tsconfig.json +│ ├── app/ +│ │ ├── layout.tsx ← fonts, shell +│ │ ├── globals.css ← Tailwind v4 + @source "../"; + design tokens (@theme) +│ │ ├── page.tsx ← Admin Home (facilitator access key + workshop list/create) +│ │ ├── f/page.tsx ← Facilitator Dashboard (?t=) +│ │ ├── join/page.tsx ← Participant Join (?s=) +│ │ └── p/page.tsx ← Participant Tracker (?t=) +│ ├── components/ +│ │ ├── ui/ ← shared primitives (Button, Card, Badge, Toast, Modal, Markdown, Skeleton, StubBadge) +│ │ ├── facilitator/ ← dashboard components +│ │ └── participant/ ← tracker/join components +│ └── lib/ +│ ├── api.ts ← typed API client (contract: spec/api.md) +│ └── poll.ts ← versioned polling hook (visibility-aware) +├── alembic/ ← env.py, script.py.mako, versions/ +├── tests/ +│ ├── conftest.py +│ ├── unit/ ← pytest, no network +│ ├── integration/ ← pytest, real HTTP (TestClient) + real DB file; Phase 4: tests/integration/ai against real OpenRouter key +│ └── e2e/ ← Playwright specs (TypeScript) +├── playwright.config.ts ← baseURL http://localhost:8001/app +├── package.json ← root: @playwright/test only +├── pyproject.toml ← uv-managed; testpaths = ["tests"] +├── alembic.ini ├── .env.example -├── .env # local overrides (gitignored) -├── requirements.txt -├── Dockerfile -├── docker-compose.yml -└── deploy/ - └── workshop-helmsman.service +├── data/ ← SQLite file at runtime (gitignored) +├── deploy/ ← Phase 5: Dockerfile, docker-compose.yml, runbook +└── .github/workflows/ ← Phase 5: CI/CD ``` -## Request flow (Phase 1 Core Path) +Import convention: the application package is `src.helmsman.*` (the `src` package exists so `uv run python -m src` boots — harness contract). -1. Facilitator visits `/` → clicks "Create new workshop" -2. Browser `GET /admin/new` → form with agenda template picker, form template picker, inline editors -3. Browser `POST /admin/new` → server creates `Workshop` row, generates `admin_token` + `participant_slug`, snapshots milestones + form schema, redirects to `/admin/` -4. Facilitator shares `/w/` with participants -5. Participant opens `/w/`: if no `participant_id` cookie → join form (name + custom fields). `POST /w/` creates `Participant`, sets HttpOnly cookie, redirects to `/w//me` -6. Participant tracker (`/w//me`) polls `GET /w//data?since=` every 4s → renders leaderboard, help flags, **broadcast banner**, **workshop_paused state**, milestone checklist -7. Admin dashboard (`/admin/`) polls `GET /admin//data?since=` every 4s → renders participant table, stats, cohort bar, help flags, broadcast composer, milestone controls -8. Participant clicks "Mark complete" → `POST /w//me/complete/` → inserts `MilestoneCompletion` (blocked if `workshop_paused=true`) -9. Participant submits help → `POST /w//me/help` (two-step: preview with LLM suggestion → commit) → inserts `HelpRequest` -10. Facilitator actions (broadcast, pause, advance, reorder) → POST to admin endpoints → immediate effect, next poll reflects change +## Process model & single-origin serving -## Bootstrap modes +**One process serves everything.** `uv run python -m src` starts one uvicorn worker on port **8001**: -- `python -m src` → production-style boot on `0.0.0.0:8001` -- On every boot, demo seeder runs **only if zero workshops exist** → first install is one-click testable, subsequent boots idempotent +- `/api/*` — JSON API (FastAPI routers, sync `def` handlers on the threadpool). +- `/app/*` — the built Next.js static export (`frontend/out/`), mounted with `StaticFiles(html=True)`. The canonical URL is `http://localhost:8001/app/`. +- **Pretty share links** (tiny FastAPI redirect routes, because a static export cannot serve dynamic path segments): + - `GET /j/{join_slug}` → 307 → `/app/join/?s={join_slug}` (the link on the door) + - `GET /p/{participant_token}` → 307 → `/app/p/?t={participant_token}` (personal link) + - `GET /f/{admin_token}` → 307 → `/app/f/?t={admin_token}` (facilitator link) + - `GET /` → 307 → `/app/` +- No separate Node server in production. `pnpm dev` (port 3000) is inner-loop only and is never the documented test path. -## Data sovereignty +**Single worker is a deliberate choice**, not an accident: it makes the in-process snapshot cache correct without Redis, and the load profile (below) fits comfortably in one asyncio process + threadpool. If the app ever outgrows one worker, the cache moves behind the DB (the DB is already the source of truth, so nothing breaks — the cache is only an optimization). -- All state in `data/helmsman.db` (SQLite) or PostgreSQL via `DATABASE_URL` -- `data/` directory excluded from git (`.gitignore`) -- No outbound network calls in core loop. LLM call (OpenRouter) only on facilitator "suggest reply" click — optional, never blocks. +## Live-update mechanism: versioned coalesced polling (decision + justification) -## Key architectural decisions +**Decision: short polling with per-workshop version counters and an in-process snapshot cache. No SSE, no WebSockets.** -| Decision | Rationale | -|----------|-----------| -| Server-rendered Jinja2 + polling | Zero JS build step, works on any browser, easy to audit, fast initial paint | -| Single `/data` JSON endpoint | One polling loop serves both participant and admin views; conditional fields via `?view=admin\|participant` | -| Workshop-scoped tokens in URL | No auth provider, no cookies for admin, shareable links, revocable by archive | -| Snapshot milestones/form on workshop create | Template edits never mutate historical sessions | -| `workshop_paused` + `broadcast_message` on Workshop row | Simple, no extra tables; atomic toggle; visible on next poll (≤4s) | -| Milestone order stored as JSON array of IDs | Drag-drop reorder without schema migration; stable IDs (`m0`, `m1`...) | -| CSV streaming via `StreamingResponse` | Handles 100+ participants × 10 milestones without memory spike | +Load profile: 300+ participants + 1–3 facilitator dashboards on 2 vCPU / 2–4 GB, SQLite storage, and a hard resilience rule (reconnect/restart must recover purely from the DB). + +Why polling beats SSE here: + +1. **Resilience is free.** Polling is stateless — after a server restart, every client's next poll simply succeeds with full state. SSE requires reconnect logic, `Last-Event-ID` replay, server-side fan-out state, and produces a thundering-herd reconnect after every restart or deploy. The resilience requirement ("restart loses nothing, reconnect restores exact state") is the polling model's native behavior. +2. **The arithmetic is trivial.** 300 participants polling every 3 s = 100 req/s. Each poll does **one indexed point-read** (the workshop's version row) and, when nothing changed (the overwhelming steady state), returns a ~60-byte `{"changed": false}` response. SQLite in WAL mode serves tens of thousands of point-reads/s; uvicorn serves 100 req/s of this without noticing. SSE's 300 idle connections would also fit in memory — but buys nothing over this, at real complexity cost (proxy buffering config, keepalive pings, per-event fan-out code). +3. **SQLite write contention stays negligible.** Peak write load is a completion burst (~5–10 writes/s) plus joins and help requests. WAL readers never block the writer; each write is a short transaction that also bumps the workshop's version counter. Polling adds *zero* writes (the `last_seen_at` touch is throttled to once per 60 s per participant ≈ 5 writes/s at 300 participants). +4. **Bandwidth is controlled by two version counters.** Each workshop row carries: + - `state_version` — bumped on any activity change (join, completion, help, answer, broadcast, pause, advance, reorder…) + - `content_version` — bumped only when milestone content/order changes. + Clients send their last-seen `state_version`; unchanged → tiny response. Changed → the poll payload (**without** milestone `content_md` — leaderboard, progress, help state; ~10–15 KB at 300 participants). Milestone bodies are fetched from a separate content endpoint only when `content_version` changes. Worst-case burst (a completion every second, all 300 clients refreshing full payloads) ≈ 1–1.5 MB/s — comfortably inside a modest VM's budget; steady state is ~10 KB/s total. +5. **Coalescing caps DB work independent of participant count.** The changed-payload for a given `(workshop_id, state_version)` is built once and memoized in-process (dict, TTL 2 s as a safety net); 300 pollers hitting the same version share one snapshot build. Per-request personal data ("me": my completions, my help requests) is 2–3 indexed point-queries per poll — ~250 cheap queries/s at burst, trivial for WAL SQLite. + +Poll intervals (client, via the `lib/poll.ts` hook): participant tracker **3 s** visible / **15 s** hidden (Page Visibility API); facilitator dashboard **2 s** visible / **10 s** hidden. Every mutation response includes the new `state_version`, and the client polls immediately after any of its own mutations, so the actor always sees their effect within one round-trip (plus optimistic UI, see Error handling). + +## Auth model + +No accounts anywhere. Three credentials, all generated with `secrets`: + +| Credential | Format | Entropy | Carried in | Grants | +|---|---|---|---|---| +| Facilitator access key | operator-chosen string in `.env` (`HELMSMAN_ADMIN_KEY`, recommend ≥16 chars) | n/a | `X-Admin-Key` request header (entered once on Admin Home, kept in `localStorage`) | Admin Home: list/create workshops, template library | +| Workshop admin token | `secrets.token_urlsafe(32)` → 43 chars | 256 bits | URL path (`/f/{token}`, `/api/f/{token}/…`) | Full facilitator control of one workshop; shareable to co-facilitators | +| Participant token | `secrets.token_urlsafe(16)` → 22 chars | 128 bits | URL path (`/p/{token}`, `/api/p/{token}/…`) **and** cookie | One participant's identity, cross-device | +| Join slug | `secrets.token_urlsafe(6)` → 8 chars | 48 bits (public, not a secret) | URL (`/j/{slug}`) | The join page only | + +- Key comparison uses `secrets.compare_digest`. Unknown token/key → 404 (`not_found`) for workshop tokens, 401 (`invalid_admin_key`) for the admin key — never distinguish "wrong" from "missing" for tokens. +- **Participant cookie (auto-resume):** on join, the server sets `helmsman_p_{workshop_id}={participant_token}`; `HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000` (30 days). `GET /api/join/{slug}` reads it server-side and returns the participant's token if recognized, so the join page silently forwards a returning browser to its tracker. The personal link `/p/{token}` needs no cookie — it *is* the cross-device credential. The facilitator dashboard shows each participant's personal link (copy button) — the lost-link recovery path. +- Structured logs mask all tokens to their first 6 characters. + +## Error-handling strategy + +- **Envelope (exact, both directions in spec/api.md):** success → HTTP 200, `{"data": …, "error": null}`. Failure → HTTP 4xx/5xx, `{"detail": {"code": "", "message": ""}}` (FastAPI `HTTPException` shape via `api_error()`). One catalogue of `code` values lives in api.md. +- **Fail fast at the boundary.** Request bodies are validated by Pydantic models; domain validation (lengths, state rules like "workshop is archived") raises `api_error` with a specific code. Invalid data never propagates. +- **Optimistic UI with reconciliation.** Mutations (mark complete, send answer) update the UI immediately, fire the request, and reconcile on the response + next poll. On failure: the UI reverts and shows a toast naming the cause and next step ("Couldn't save — retrying in 3 s" / "This workshop is paused"). +- **The poll loop is self-healing.** A failed poll (server restarting, network blip) shows a quiet "reconnecting…" indicator after 2 consecutive failures, backs off (3 s → 6 s → 12 s, cap 30 s), and recovers automatically — full state comes from the DB on the first successful poll. No data is ever lost because no data lives only in the client or the process. +- **Startup fails hard** on unrecoverable config (missing `HELMSMAN_ADMIN_KEY`, unreadable `DATABASE_URL`) with a clear message. A missing `OPENROUTER_API_KEY` is *not* an error — the AI surface reports "AI off (no API key)" and everything else runs (air-gapped guarantee). +- **AI failures degrade to manual.** Any AI-pipeline failure (timeout, 5xx, malformed output) logs ERROR and leaves the help request in the normal facilitator queue — the participant experience is identical to AI-off (see agent.md). + +## Observability (wired in Phase 1, never deferred) + +- **structlog → JSON on stdout.** Every line: `timestamp`, `level`, `event`, `request_id`, plus context. +- **Request middleware:** method, token-masked path, status, `duration_ms`, `request_id` for every API request (poll endpoints log at DEBUG to keep INFO readable). +- **Domain events at INFO:** `workshop.created`, `participant.joined`, `milestone.completed`, `help.created`, `help.answered`, `broadcast.sent`, `workshop.paused`, `undo.applied`, `ai.triage`, `ai.auto_answered`, `ai.draft_created`, `ai.failed` — each with ids and (for AI) latency, token counts, cost. +- **No metrics endpoint, no ops UI** (vision non-goal). `GET /api/health` returns `{"status":"ok","db":"ok"}` for machines. + +## Database configuration + +- SQLite engine (default `sqlite:///data/helmsman.db`), applied per-connection via an `connect` event listener: + `PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON;` +- SQLAlchemy: sync engine, `pool_pre_ping=True`; for SQLite `connect_args={"check_same_thread": False}`. +- `DATABASE_URL` may point at PostgreSQL; models use portable types only (Integer/Text/Boolean/DateTime/Numeric + JSON as Text). Alembic runs with `render_as_batch=True` so SQLite ALTERs work. The FTS5 help corpus (Phase 4) is created only when the dialect is SQLite; agent.md documents the PostgreSQL alternative. +- All timestamps stored UTC; serialized as ISO 8601 `Z` (`2026-07-20T14:03:22Z`). +- Transactions are short: one request = one transaction (commit in the session dependency). Any state-changing write bumps the workshop's `state_version` in the same transaction. +- **Lazy lifecycle transitions (Phase 3):** no cron/background scheduler. Every workshop-scoped request first applies due transitions (`grace_until` passed → `archived`) inside the request transaction — restart-proof by construction. ## Security notes -- `admin_token` = 32-char URL-safe secret (256 bits entropy). Treat as password. -- `participant_slug` = 8-char URL-safe (not secret; public join link). -- Participant cookie: HttpOnly, SameSite=Lax, path-scoped to `/w/`, 12h TTL. -- No password hashing (no passwords). No session table (stateless tokens). -- SQL injection: SQLAlchemy ORM + parameterized queries only. -- XSS: Jinja2 auto-escape + `escapeHtml` in JS for dynamic inserts. -- CSRF: not needed for token-in-URL flows; participant cookie is read-only for GET, POSTs are same-origin form submits. \ No newline at end of file +- **XSS:** all facilitator/AI-authored markdown (milestones, broadcasts, answers) renders through react-markdown with **raw HTML disabled** — HTML in markdown source is not injected. Participant-authored text (names, help messages) is rendered as **plain text** (React text nodes, `whitespace-pre-wrap`), never as markdown or HTML. +- **SQL injection:** SQLAlchemy ORM/parameterized queries only. The FTS5 `MATCH` query string is built from sanitized, quoted tokens (see agent.md). +- **CSRF:** state-changing endpoints are authenticated by unguessable URL tokens or the `X-Admin-Key` header — not by cookies — so cross-site form posts cannot forge them. The participant cookie is only ever *read* to resolve auto-resume on `GET /api/join/{slug}`. +- **Secrets:** only in `.env` (gitignored). Logs and API responses never contain `OPENROUTER_API_KEY` or `HELMSMAN_ADMIN_KEY`; key presence is logged as a boolean. +- **Rate limiting:** none in v0.2 (self-hosted, unguessable tokens, trusted room). > **Assumed:** acceptable for a single-team instance; revisit if ever exposed as SaaS. + +## Environment variables (complete list) + +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `DATABASE_URL` | no | `sqlite:///data/helmsman.db` | SQLAlchemy URL; PostgreSQL-ready | +| `PORT` | no | `8001` | HTTP port | +| `HELMSMAN_ADMIN_KEY` | **yes** | — | Facilitator access key for Admin Home | +| `HELMSMAN_BASE_URL` | no | derived from request | Absolute base for generated share links (set behind a proxy) | +| `HELMSMAN_LOG_LEVEL` | no | `INFO` | structlog level | +| `OPENROUTER_API_KEY` | no | — | AI help-desk; absent → fully air-gapped, zero errors | +| `HELMSMAN_AI_MODEL` | no | `anthropic/claude-sonnet-4-6` | OpenRouter model id | +| `HELMSMAN_AI_CONFIDENCE` | no | `0.75` | Auto-answer confidence threshold (see agent.md) | + +## Deploy shape (Phase 5) + +- `deploy/Dockerfile` — multi-stage: (1) Node stage builds the frontend export, (2) Python stage with uv installs the app and copies `frontend/out/`; runs `alembic upgrade head` then `python -m src`. +- `deploy/docker-compose.yml` — one service, one named volume for `data/` (SQLite + WAL files), `env_file: .env`, port 8001. Optional Caddy/nginx TLS proxy is documented, not shipped. +- `.github/workflows/ci.yml` — on PR: `uv run pytest tests/unit -q` + `pnpm build` + lint. Real-key integration tests run only where secrets exist (guarded skip). +- `.github/workflows/deploy.yml` — on main: build image, push to Artifact Registry, SSH-deploy compose to the GCP VM. +- Backup = copy the `data/` volume (documented in `deploy/RUNBOOK.md`). + +## Key architectural decisions (summary) + +| Decision | Rationale | +|---|---| +| Versioned coalesced polling, no SSE | Restart/reconnect resilience for free; ~100 req/s of point-reads is trivial; no fan-out state, no proxy pitfalls | +| Two version counters (`state_version` / `content_version`) | Keeps steady-state polls at ~60 bytes and burst payloads ~10–15 KB; milestone bodies transfer only when edited | +| Single uvicorn worker + in-process snapshot cache | Correct coalescing without Redis; load fits one process; DB remains sole source of truth | +| Milestones as a real table (not JSON blob) | Per-milestone stats for 300 participants via indexed GROUP BY; FTS/context queries for the AI phase | +| Static Next.js export served by FastAPI at `/app/` + pretty-link redirects | Single origin, one process, harness gate path; dynamic tokens carried in query params where the static router needs them, pretty in shared URLs | +| Lazy lifecycle transitions on access | No scheduler process to crash or restart; correctness derives from the DB clock check | +| Audit + undo share one table (`facilitator_action`) | One write per action serves the audit trail, the undo window, and AI-answer logging | diff --git a/spec/capabilities.md b/spec/capabilities.md index 7f41129..58d22ff 100644 --- a/spec/capabilities.md +++ b/spec/capabilities.md @@ -1,89 +1,96 @@ -# Capabilities — Workshop Helmsman (v0.1) +# Capabilities — Workshop Helmsman (v0.2) -Phased list. Each phase ships behind a Gate command that proves the slice works end-to-end before the next phase begins. +Four core capabilities carry the product; everything else is deferred to a named phase (§Deferred). API shapes live in [api.md](api.md); schema in [data-model.md](data-model.md); phase plan in [roadmap.md](roadmap.md). This file also carries the UI specification (pages, components, states, stub placements) — there is no separate ui.md. ---- +## Design system (applies to every page) + +- Tailwind v4 design tokens in `globals.css` `@theme`: brand **indigo** (`--color-brand-*`), warm neutral scale, semantic colors (success green, warning amber, danger red, AI **violet**), one radius scale (lg cards, md controls), one shadow scale, spacing on the 4px grid. Type: Inter (via `next/font`, system fallback); body ≥16px; `text-sm` only for metadata. Light theme only (vision non-goal). +- Shared primitives in `frontend/components/ui/`: `Button` (primary/secondary/ghost/danger; loading state), `Card`, `Badge` (status pills), `ProgressBar`, `Toast`, `Modal`, `Skeleton`, `EmptyState` (icon + one-line explanation + one action), `Markdown` (react-markdown + gfm + highlight; raw HTML off), `CopyButton` (copies + "Copied" feedback), **`StubBadge`** — the labelled-stub primitive: a muted card/section with a `Coming in a later phase` pill, short description of what will live there, non-interactive. Every future feature surface renders through `StubBadge` so a stub is never mistaken for a bug. +- Every view designs all four states (empty / loading / error / populated) per `harness/patterns/ui-ux.md`. Loading = skeletons with context, never blank. Errors = human sentence + next step, never a stack trace. All interactive elements keyboard-reachable with visible focus; semantic HTML; WCAG AA contrast. +- **Connection indicator:** both live pages show a quiet "reconnecting…" pill after 2 consecutive failed polls, clearing silently on recovery. Never a modal, never data loss (state is in the DB). -## Phase 1 — Core Path (this improvement cycle) +--- -**The single most important user journey:** Facilitator creates workshop from template → shares participant link → participants join and fill form → facilitator watches live dashboard → participants complete milestones → facilitator exports CSV. +## C1 — Workshop creation & facilitator access *(Phase 1)* -**What's being improved over the existing v0.1:** +Facilitators enter once with the instance access key, then create and reach workshops via token links. -| Area | Improvement | -|------|-------------| -| **Facilitator Dashboard** | Visual polish: stat cards, status pills, real-time feel with live progress bars, per-milestone completion table, cohort stacked bar, paginated help flags with status chips | -| **Participant Join** | Instant validation on name field, better mobile UX (larger touch targets, proper keyboard types), inline error/success states | -| **Agenda/Milestone Management** | Drag-and-drop reorder (Phase 1: up/down buttons), inline edit title/description, milestone order persisted per workshop | -| **Form Builder** | Visual field editor (add/remove/reorder fields), field types: text/dropdown, required toggle, template picker | -| **Broadcast Messaging** | Facilitator → all participants: pinned banner on tracker with dismiss, Markdown-lite rendering | -| **Milestone Controls** | "Advance all to next", "Advance selected", "Pause workshop" (locks completions), per-participant override | -| **Export** | One-click CSV with all data: participants × milestones + form answers + help requests | +**Pages:** +- **Admin Home — `/app/`**: first visit shows a centered access-key card (one password field, "Enter"; wrong key → inline "That key doesn't match this server's `HELMSMAN_ADMIN_KEY`"). Valid key (checked via `GET /api/admin/workshops`) is kept in `localStorage` and sent as `X-Admin-Key`. Then: workshop list grouped **Live / Upcoming / Ended** (Phase 1: everything is Live; Upcoming/Ended groups render as labelled stubs), each card showing name, participant count, open-help count, created date, buttons **Open dashboard** and **Copy join link**. Empty state: "No workshops yet — create your first." Header button **New workshop**. Stub surfaces on this page: **Template library** nav item (StubBadge, Phase 3). +- **Create workshop** (route `/app/` modal or section): name, description (markdown textarea), and a milestone builder — ordered rows of *title + markdown content editor + minutes*, add/remove/move up-down, live markdown preview per milestone (tabs Write/Preview). A **template picker** renders as a labelled stub (Phase 3). Submit → success screen presenting the two links prominently with copy buttons and a plain-language warning: *"The facilitator link is the only key to this dashboard — save it now."* -**Clearly-labelled stubs (Phase 2+):** -- Multi-workshop concurrent sessions (dashboard switcher) -- Breakout rooms / multiple facilitators per workshop -- Email/Slack notifications on help flags -- Custom domains per workshop (e.g. `acme.workshop.smalltech.in`) -- Participant messaging (chat between participants) +**Behavior:** `POST /api/admin/workshops`; workshop is `live` immediately; audit row written. Success criteria: create → dashboard reachable via returned `facilitator_url`; join link works; refresh of Admin Home lists the workshop with live counts. --- -## Phase 1 — Surfaces built - -1. `GET /admin/new` → form: workshop name, pick agenda template (or inline milestones), pick form template (or inline fields), TTL hours -2. `POST /admin/new` → creates workshop, returns redirect to `/admin/` -3. `GET /admin/` → **polished dashboard**: stat cards, participant table with progress bars + milestone chips, per-milestone completion stats, cohort stacked bar, help flags with status pills + inline status change, broadcast message composer, milestone controls (advance all, pause, reorder) -4. `GET /w/` → join page: workshop name + form fields, instant validation, mobile-optimized, submit → cookie + redirect -5. `GET /w//me` → participant tracker: milestone checklist with mark-complete, progress bar, leaderboard (top 50 + toggle), **broadcast banner**, my help requests with status pills -6. `GET /w//data` → JSON: milestones, my progress, leaderboard, help requests, **broadcast message**, **workshop_paused flag**, form schema -7. `GET /admin//data` → JSON: all participants + progress, per-milestone stats, cohort bar, help requests, broadcast message, workshop_paused, milestone order -8. `POST /w//me/complete/` → mark milestone done (idempotent; blocked if workshop_paused) -9. `POST /w//me/help` → create help request (two-step: preview → commit; LLM suggestion stub returns empty) -10. `POST /admin//broadcast` → set/clear broadcast message -11. `POST /admin//pause` → toggle workshop_paused -12. `POST /admin//advance-all` → advance all participants to next incomplete milestone -13. `POST /admin//advance-selected` → advance selected participant IDs -14. `POST /admin//milestones/reorder` → persist new milestone order -15. `GET /admin//export.csv` → streams full CSV -16. `GET /healthz` → `{"status":"ok","db":"ok"}` +## C2 — Frictionless participant identity *(Phase 1)* ---- +Join with a name; never lose your place. + +**Pages:** +- **Join — `/app/join/?s=`** (pretty link `/j/` on the door): workshop name + description (markdown), milestone count, participant count ("143 people are in"), one **name field** + **Join** button. Instant validation (1–80 chars, trimmed). If `GET /api/join/{slug}` returns `me` (cookie auto-resume), skip the form: "Welcome back, Priya" → auto-forward to the tracker. Errors: unknown slug → friendly "This workshop link isn't valid — check with your facilitator." Archived → "This workshop has ended" with a **Browse archive** link (read-only tracker). +- After join: the tracker shows a dismissible one-time callout: *"This page's link is yours — it works on any device. Save it."* with a copy button for `participant_url`. -## Phase 2 — Template Library & Multi-Session (future) +**Identity mechanics (architecture.md §Auth):** cookie `helmsman_p_{workshop_id}` (HttpOnly, 30 d) for same-browser auto-resume; the personal link `/p/` is the cross-device credential; the facilitator dashboard's participant table shows a **copy personal link** button per row — the lost-link recovery path ("what's your name? → here's your link"). Reconnect/restart restores exact state on the next poll because all state is in the DB. -- `/admin/templates` — list/create/edit/delete agenda templates & form templates -- `/admin/new` and `/admin//edit` include template pickers -- Editing a template never mutates existing workshops (snapshots on create) -- `/workshops` archive page with search/filter, per-session drill-down -- Cohort comparison across sessions of same template +**Success criteria:** join in <10 s from link tap; browser restart auto-resumes; the personal link opens the identical state on a second device; a facilitator can recover any participant's link in two clicks. --- -## Phase 3 — AI Assist & Notifications (future) +## C3 — Content-rich milestone tracking, live *(Phase 1)* -- Optional Gemini via OpenRouter for help-desk pre-resolution -- Facilitator clicks "suggest reply" on help flag → LLM returns short actionable fix → facilitator edits/sends -- Email/Slack webhook notifications when help flags accumulate -- Graceful degradation: no API key = feature silently disabled +The heartbeat: participants work milestones; the facilitator watches the room move. ---- +**Participant Tracker — `/app/p/?t=`** (pretty `/p/`): +- Sticky header: workshop name, my **progress bar** with `n / total`, connection indicator. +- **Milestone checklist**: one card per milestone in position order — checkbox + title + minutes chip + collapsible **markdown body** (instructions, links, code snippets with syntax highlighting + copy-code button). Current milestone (lowest incomplete) is auto-expanded and visually accented; completed ones collapse with a green check. **Mark complete** is optimistic (instant check, reconciled by poll); un-mark available via the checkbox ("fixed a mis-click"). Paused workshop (Phase 2) disables checkboxes with a banner — the Phase 1 payload already carries `paused`. +- **Leaderboard panel** (side on desktop, tab on mobile): full ranked list, full names always on, my row highlighted and pinned when off-screen; top-3 subtle medals. Renders all rows (virtualized past 50). +- **Help panel**: C4 below. **Broadcast banner slot**: renders active broadcast (Phase 2 data); in Phase 1 an inline StubBadge in the panel footer notes "Announcements from your facilitator will appear here." +- Data: `state` poll 3 s (visible) / 15 s (hidden) + `content` fetch keyed by `content_version`. Loading = full-page skeleton mirroring the layout. -## Phase 4 — Scale & Multi-tenancy (future) +**Facilitator Dashboard — `/app/f/?t=`** (pretty `/f/`), the mission-control view, 2 s poll: +- **Header**: workshop name, status pill, participant/active counts, **Copy join link**, connection indicator. Stub actions (StubBadge’d in Phase 1): **Broadcast**, **Pause**, **End workshop**, **AI toggle**, **Spend**. +- **Stat row**: participants, active (5 min), finished, median progress, open help — large-number cards. +- **Per-milestone completion**: horizontal bars per milestone (completed_count / participants, percentage), highlighting the milestone where the largest group currently sits. +- **Cohort distribution**: histogram of participants by completed-count (0…total) — the room's shape at a glance. +- **Participant table**: name, progress bar + count, per-milestone dots (done/current/todo), joined/last-seen, open-help badge, **copy personal link**. Client-side sort (progress/name/joined) and name filter. 300 rows: virtualized. +- **Help queue**: C4 below. **Proactive-intelligence rail**: stuck alerts / bottleneck / session pulse render as three labelled stub cards in Phase 1 (fields already `null` in the payload), real in Phase 2. **Audit trail** tab: StubBadge in Phase 1. -- PostgreSQL backend (swap SQLite via `DATABASE_URL`) -- Multiple concurrent workshops -- Per-workshop facilitator tokens (rotate, revoke) -- Custom domains / subdomains -- Team/organization grouping +**Success criteria:** a completion on a phone is visible on the dashboard within one poll cycle (≤2 s + RTT); per-milestone bars and distribution always sum consistently with the table; 300 participants render without jank; markdown code blocks are highlighted and copyable. --- -## Always-on cross-cutting concerns (any phase) +## C4 — Help desk *(Phase 1 manual; Phase 4 adds AI in front)* + +A participant raises a hand without disrupting the room; help arrives in-page. + +**Participant side (tracker help panel):** "Need help?" card — one textarea ("What are you stuck on? Your facilitator sees this immediately"), submit → optimistic entry in **My help requests**: message (plain text, `pre-wrap`), status pill (`open` = amber "Waiting", `answered` = blue "Answered", `resolved` = green), timestamps. Answers render as markdown reply bubbles; a facilitator answer flashes/highlights on arrival via poll. Buttons: **Mark resolved** (own requests). Phase 4 adds: instant AI answers badged **AI** (violet) with **Get a human** escalation; Phase 1 shows no AI chrome at all (nothing to stub on the participant side — absence is the honest state). +- Empty state: "Stuck? Ask here — answers appear right on this page." + +**Facilitator side (dashboard help queue):** cards newest-open-first — participant name, their current milestone chip, message (plain text), age ("4 m ago"), status pill, escalated flag (Phase 4). Inline **reply composer** (markdown textarea with preview, "Answer" button) + **Mark resolved**. Answer → participant sees it next poll; request → `answered`; stays visible until participant (or facilitator) resolves. Resolved section collapsed with count. Every answer writes an audit row. Phase 4 adds: AI draft block (violet, confidence %, editable, "Send") and the expandable **"Context the AI used"** disclosure on AI answers. +- Empty state: "No open help requests — the room is cruising." + +**Success criteria:** request → visible in queue within one dashboard poll; answer → visible on tracker within one tracker poll; full loop (submit → answer → see → resolve) with zero manual refreshes; markdown answers with code render correctly; every answer audited (who/what/when). + +--- -- Platform-agnostic URLs (no hardcoded host/port) -- Mobile-responsive CSS (grid/flex + viewport meta) -- Health probe at `/healthz` returning JSON -- No secrets required to boot (`.env.example` intentionally empty for core flow) -- Single CSS file, single JS file, no bundler, no node_modules -- Audit trail: every milestone completion, help request, facilitator action timestamped \ No newline at end of file +## Deferred capabilities (schema exists from Phase 1; features arrive at their phase) + +| Capability | Phase | One-line scope | +|---|---|---| +| Broadcast announcements | 2 | Markdown composer → pinned participant banner; history; clear; undoable | +| Milestone controls | 2 | Advance all/selected, pause/resume, reorder, edit/add/delete milestones mid-session | +| Undo | 2 | 30 s inverse-operation window for advance-all/selected, pause, broadcast | +| Audit trail UI | 2 | Dashboard tab over `facilitator_action` (every facilitator + AI action, who/what/when) | +| Proactive intelligence | 2 | Stuck-participant alerts (`stuck_minutes`), bottleneck detection, session pulse (pace vs plan, % on track, projected finish) | +| Lifecycle: end / grace / archive | 3 | End workshop → straggler grace window → read-only archive, browsable forever; lazy transitions | +| Clone / re-run | 3 | New workshop from an old one: milestones copied, fresh tokens, zero participants | +| Agenda template library | 3 | CRUD + create-from-template + save-workshop-as-template; instantiation snapshots | +| Join-form templates & custom fields | 3 | Extra join fields (text/dropdown) from persistent form templates; answers on the participant record | +| Admin Home lifecycle grouping | 3 | Live / Upcoming / Ended real; `starts_at` scheduling; archive browsing | +| AI triage & auto-answer | 4 | Context-gathering → confidence-routed auto-answer/draft (see [agent.md](agent.md)) | +| AI escalation & transparency | 4 | "Get a human" one-tap; AI badge; expandable exact-context view in the queue | +| Cross-workshop learning corpus | 4 | FTS5 similarity over all past resolutions — replaces canned replies, improves with use | +| AI spend & toggle & air-gap | 4 | Per-workshop cost from OpenRouter usage; per-workshop enable; keyless = zero AI chrome errors | +| Docker Compose packaging | 5 | One-container deploy, volume-backed SQLite, multi-stage build | +| CI/CD to GCP | 5 | GitHub Actions: tests on PR; image build + VM deploy on main; runbook | diff --git a/spec/data-model.md b/spec/data-model.md index 386af59..1398290 100644 --- a/spec/data-model.md +++ b/spec/data-model.md @@ -1,151 +1,268 @@ -# Data Model — Workshop Helmsman (v0.1) +# Data Model — Workshop Helmsman (v0.2) -## Overview -All state in a single SQLite file (`data/helmsman.db`) or PostgreSQL via `DATABASE_URL`. -SQLAlchemy 2.x declarative models; `Base.metadata.create_all` runs at startup (idempotent). -No migration framework in v0.1 — additive columns only. +SQLAlchemy 2.x declarative models in `src/helmsman/db/models.py`; schema managed by Alembic (the `0001_initial` migration creates the **full v0.2 schema below**, even though later phases activate some tables). All state lives here — never only in process memory (resilience rule). All `DateTime` columns are timezone-aware UTC. JSON payloads are stored as `Text` (portable across SQLite/PostgreSQL). -## Tables +**Phase activation:** the "Phase" column says when the app first *writes/uses* the field — the schema itself exists from Phase 1 so later phases never migrate-and-backfill mid-season. -### workshop -```sql -CREATE TABLE workshop ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(200) NOT NULL, - created_at DATETIME NOT NULL, - expires_at DATETIME NOT NULL, - admin_token VARCHAR(64) NOT NULL UNIQUE, - participant_slug VARCHAR(64) NOT NULL UNIQUE, - milestone_config TEXT NOT NULL, -- JSON list of {id,title,description} - archived BOOLEAN NOT NULL DEFAULT 0, - form_template_id INTEGER REFERENCES form_template(id) ON DELETE SET NULL, - form_schema_json TEXT NOT NULL DEFAULT '[]', -- JSON snapshot of form fields - help_tips_json TEXT DEFAULT '', -- facilitator FAQ tips for LLM - broadcast_message TEXT DEFAULT '', -- current broadcast announcement - workshop_paused BOOLEAN NOT NULL DEFAULT 0, - milestone_order_json TEXT DEFAULT '[]' -- explicit milestone id order (drag-drop) -); -CREATE INDEX ix_workshop_admin_token ON workshop(admin_token); -CREATE INDEX ix_workshop_participant_slug ON workshop(participant_slug); -``` +Conventions: integer autoincrement PKs; `created_at` default now; `updated_at` onupdate now where noted; FK columns always indexed. -### form_template -```sql -CREATE TABLE form_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(120) NOT NULL, - created_at DATETIME NOT NULL, - fields_json TEXT NOT NULL DEFAULT '[]' -- JSON list of field dicts -); -``` +--- -### agenda_template -```sql -CREATE TABLE agenda_template ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name VARCHAR(120) NOT NULL, - created_at DATETIME NOT NULL, - milestones_json TEXT NOT NULL DEFAULT '[]' -- JSON list of {title,description,help_tip} -); -``` +## workshop -### participant -```sql -CREATE TABLE participant ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workshop_id INTEGER NOT NULL REFERENCES workshop(id) ON DELETE CASCADE, - name VARCHAR(120) NOT NULL, - joined_at DATETIME NOT NULL, - answers_json TEXT -- JSON {field_key: value} -); -CREATE INDEX ix_participant_workshop_id ON participant(workshop_id); -``` +The unit of a live session. One row per run. -### milestone_completion -```sql -CREATE TABLE milestone_completion ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participant(id) ON DELETE CASCADE, - milestone_id VARCHAR(64) NOT NULL, - milestone_title VARCHAR(200) NOT NULL, - completed_at DATETIME NOT NULL -); -CREATE INDEX ix_milestone_completion_participant_id ON milestone_completion(participant_id); -``` +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| name | String(120) | not null | 1 | | +| description_md | Text | not null, default `''` | 1 | Shown on join page; markdown | +| admin_token | String(64) | not null, **unique, indexed** | 1 | `token_urlsafe(32)` | +| join_slug | String(16) | not null, **unique, indexed** | 1 | `token_urlsafe(6)` | +| status | String(16) | not null, default `'live'` | 1 (`live`), 3 (rest) | `upcoming` \| `live` \| `grace` \| `archived` | +| starts_at | DateTime | nullable | 3 | Set → `upcoming` until reached (lazy transition) | +| ended_at | DateTime | nullable | 3 | Facilitator pressed "End workshop" | +| grace_until | DateTime | nullable | 3 | End of straggler window; passed → `archived` (lazy) | +| grace_hours | Integer | not null, default `24` | 3 | Chosen at end time | +| paused | Boolean | not null, default false | 2 | Blocks milestone completions | +| state_version | Integer | not null, default `0` | 1 | Bumped (same transaction) on ANY change a poller can see | +| content_version | Integer | not null, default `0` | 1 | Bumped only on milestone content/order changes | +| ai_enabled | Boolean | not null, default `false` | 4 | Per-workshop AI toggle | +| join_form_json | Text | not null, default `'[]'` | 3 | Snapshot of extra join fields (see JSON shapes) | +| stuck_minutes | Integer | not null, default `10` | 2 | Stuck-alert threshold (per workshop) | +| cloned_from_id | Integer | FK workshop.id, nullable | 3 | Provenance of a clone | +| agenda_template_id | Integer | FK agenda_template.id (SET NULL), nullable | 3 | Which template instantiated it | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: unique(admin_token), unique(join_slug), ix(status). + +**Status lifecycle (Phase 3):** `upcoming` → (starts_at reached) → `live` → (End workshop) → `grace` → (grace_until passed or Archive-now) → `archived`. Transitions are applied lazily inside any workshop-scoped request; `archived` is terminal and read-only (every mutation returns `workshop_archived`). Phase 1–2 workshops are created directly `live` and stay `live`. + +--- + +## milestone + +A real table (not JSON): per-milestone stats over 300 participants need indexed GROUP BYs, and the AI phase needs per-milestone content lookup. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | | +| position | Integer | not null | 1 | 0-based display order; reordering rewrites positions | +| title | String(200) | not null | 1 | | +| content_md | Text | not null, default `''` | 1 | Rich markdown: instructions, links, code snippets. ≤20,000 chars | +| minutes | Integer | nullable | 1 | Facilitator's time estimate; feeds pulse/projection (Phase 2) | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: ix(workshop_id, position). + +**Semantics:** checklist, any order — participants may complete milestones in any sequence (no gating). A participant's **current milestone** = lowest-position incomplete milestone (used for help context, bottleneck detection). **Progress** = completed ÷ total milestones. + +--- + +## participant + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | | +| name | String(80) | not null | 1 | Trimmed; duplicates allowed (disambiguated by join time in UI) | +| token | String(32) | not null, **unique, indexed** | 1 | `token_urlsafe(16)` — personal link + cookie value | +| answers_json | Text | not null, default `'{}'` | 3 | Custom join-form answers `{field_key: value}` | +| joined_at | DateTime | not null | 1 | | +| last_seen_at | DateTime | not null | 1 (write), 2 (read) | Touched by polls, throttled to once/60s; feeds presence + stuck alerts | + +Indexes: unique(token), ix(workshop_id). + +--- + +## milestone_completion + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| participant_id | Integer | FK participant.id CASCADE, not null | 1 | | +| milestone_id | Integer | FK milestone.id CASCADE, not null | 1 | | +| source | String(16) | not null, default `'participant'` | 1 (`participant`), 2 (`facilitator`) | Who marked it (advance-all/selected writes `facilitator`) | +| completed_at | DateTime | not null | 1 | | + +Indexes: **unique(participant_id, milestone_id)** (idempotent completes), ix(milestone_id) (per-milestone stats), ix(participant_id). + +Un-marking deletes the row (participants may fix a mis-click; history of the fix lives in the log stream, not the table). + +--- + +## help_request + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 1 | Denormalized for queue + cross-workshop learning queries | +| participant_id | Integer | FK participant.id CASCADE, not null | 1 | | +| milestone_id | Integer | FK milestone.id (SET NULL), nullable | 1 | The participant's current milestone at submit time | +| message | Text | not null | 1 | 1–4000 chars; participant-authored → rendered as plain text | +| status | String(16) | not null, default `'open'` | 1 | `open` \| `answered` \| `resolved` | +| escalated | Boolean | not null, default false | 4 | Participant tapped "get a human" after an AI answer → back to `open`, flagged | +| ai_state | String(16) | nullable | 4 | `pending` \| `answered` \| `draft` \| `failed` \| `skipped` — pipeline observability | +| created_at | DateTime | not null | 1 | | +| updated_at | DateTime | not null, onupdate | 1 | | + +Indexes: ix(workshop_id, status, created_at) (queue), ix(participant_id). + +**Status semantics:** `open` = awaiting an answer (facilitator queue). `answered` = an answer is visible to the participant (AI auto-answer or facilitator reply) — stays that way until the **participant** marks `resolved` (or the facilitator closes it). `escalated=true` returns status to `open` with the flag set; prior AI answers remain visible. + +--- + +## help_answer + +One request can accumulate answers (AI draft → AI answer → facilitator follow-up), so answers are rows, not columns. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| help_request_id | Integer | FK help_request.id CASCADE, not null | 1 | | +| source | String(16) | not null | 1 (`facilitator`), 4 (`ai`) | `facilitator` \| `ai` | +| answer_md | Text | not null | 1 | Markdown; rendered with code blocks | +| draft | Boolean | not null, default false | 4 | true = AI draft awaiting facilitator review — never shown to the participant | +| ai_confidence | Numeric(4,3) | nullable | 4 | Model-reported, post-guardrail (0–1) | +| ai_model | String(120) | nullable | 4 | OpenRouter model id used | +| ai_context_json | Text | nullable | 4 | Exact context the AI used (see JSON shapes) — expandable in the queue | +| created_at | DateTime | not null | 1 | | + +Indexes: ix(help_request_id, created_at). + +--- + +## broadcast + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 2 | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | 2 | | +| message_md | Text | not null | 2 | Markdown; ≤4000 chars | +| created_at | DateTime | not null | 2 | | +| cleared_at | DateTime | nullable | 2 | null = the active pinned banner (at most one: sending a new one clears the previous) | + +Indexes: ix(workshop_id, cleared_at). + +--- + +## facilitator_action — audit trail + undo + AI answer log + +Every facilitator action and every AI-sent answer, forever. + +| Column | Type | Constraints | Phase | Notes | +|---|---|---|---|---| +| id | Integer | PK | 1 | | +| workshop_id | Integer | FK workshop.id CASCADE, nullable | 1 | null for instance-level actions (workshop.create logs on the new row) | +| actor | String(16) | not null | 1 | `facilitator` \| `ai` \| `system` | +| action | String(48) | not null | 1 | Code, e.g. `workshop.create`, `help.answer`, `help.resolve`, `broadcast.send`, `workshop.pause`, `workshop.resume`, `milestone.advance_all`, `milestone.advance_selected`, `milestone.reorder`, `milestone.edit`, `workshop.end`, `workshop.archive`, `workshop.clone`, `ai.auto_answer`, `ai.draft`, `ai.toggle`, `undo.apply` | +| detail_json | Text | not null, default `'{}'` | 1 | Human-renderable specifics (who/what: participant ids, milestone ids, message excerpt ≤200 chars) | +| undo_data_json | Text | nullable | 2 | Inverse-operation data for undoable actions (see below) | +| undone_at | DateTime | nullable | 2 | Set when undone; undoable within 30 s of created_at | +| created_at | DateTime | not null, **indexed** | 1 | | + +Indexes: ix(workshop_id, created_at). + +**Undo (Phase 2):** undoable actions = `broadcast.send` (undo_data: previous active broadcast id or null), `workshop.pause`/`workshop.resume` (previous paused state), `milestone.advance_all`/`milestone.advance_selected` (list of created milestone_completion ids → deleted on undo). Undo applies the inverse in one transaction, sets `undone_at`, logs `undo.apply`, bumps `state_version`. Window: 30 seconds, enforced server-side. + +Phase 1 writes audit rows for `workshop.create`, `help.answer`, `help.resolve` (the actions that exist); the audit-trail UI arrives in Phase 2. + +--- + +## agenda_template + agenda_template_milestone (Phase 3) + +Templates persist forever, independent of workshops. Instantiation **snapshots** milestones into the workshop — later template edits never mutate past or running sessions. + +**agenda_template:** id (PK) · name String(120) not null · description_md Text default `''` · created_at · updated_at. + +**agenda_template_milestone:** id (PK) · template_id (FK agenda_template.id CASCADE, not null) · position Integer not null · title String(200) not null · content_md Text not null default `''` · minutes Integer nullable. Index: ix(template_id, position). + +"Save current workshop as template" copies the workshop's milestones into a new template. + +--- + +## join_form_template (Phase 3) + +**join_form_template:** id (PK) · name String(120) not null · fields_json Text not null default `'[]'` · created_at · updated_at. + +Fields stay JSON (low query need; schema in JSON shapes below). Creating a workshop from a form template snapshots `fields_json` into `workshop.join_form_json`. Phase 1–2 join collects **name only** (`join_form_json = '[]'`). + +--- + +## ai_usage (Phase 4) + +One row per OpenRouter call. Per-workshop spend = `SUM(cost_usd)`. + +| Column | Type | Constraints | Notes | +|---|---|---|---| +| id | Integer | PK | | +| workshop_id | Integer | FK workshop.id CASCADE, not null | | +| help_request_id | Integer | FK help_request.id (SET NULL), nullable | | +| purpose | String(16) | not null | `triage_answer` (single combined call; see agent.md) | +| model | String(120) | not null | | +| prompt_tokens | Integer | not null, default 0 | | +| completion_tokens | Integer | not null, default 0 | | +| cost_usd | Numeric(10,6) | not null, default 0 | From OpenRouter `usage.cost` (`usage: {include: true}`); 0 if unavailable, tokens still recorded | +| latency_ms | Integer | not null, default 0 | | +| created_at | DateTime | not null | | + +Indexes: ix(workshop_id, created_at). + +--- + +## help_corpus (Phase 4 — SQLite FTS5 virtual table) + +The cross-workshop learning corpus for similarity retrieval (see agent.md). **Not** a SQLAlchemy model — created by an Alembic migration guarded by `dialect == "sqlite"`; maintained by application code (no triggers), rebuildable at any time from `help_request` + `help_answer` (it is derived data, so the resilience rule holds). -### help_request ```sql -CREATE TABLE help_request ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - participant_id INTEGER NOT NULL REFERENCES participant(id) ON DELETE CASCADE, - message TEXT NOT NULL, - created_at DATETIME NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'open' -- open | on_hold | resolved +CREATE VIRTUAL TABLE help_corpus USING fts5( + message, -- participant's help text + resolution, -- the final human-visible answer (facilitator-sent, or AI answer the participant resolved) + milestone_title, -- indexed: milestone titles carry strong topical signal + help_request_id UNINDEXED, + workshop_id UNINDEXED ); -CREATE INDEX ix_help_request_participant_id ON help_request(participant_id); -CREATE INDEX ix_help_request_created_at ON help_request(created_at); ``` -## JSON field shapes +A row is inserted when a request reaches `resolved`, or `answered` by a facilitator (facilitator answers are trusted resolutions). PostgreSQL deployments: the equivalent is a `tsvector` GIN index on the same derived table — documented in agent.md, built only if/when a PostgreSQL deployment needs Phase 4. -### Milestone (in `workshop.milestone_config`) -```json -[ - {"id": "m0", "title": "Setup", "description": "Environment ready"}, - {"id": "m1", "title": "API Key", "description": "LLM key configured"} -] -``` -`milestone_order_json` stores an array of milestone IDs in display order: `["m0","m1","m2","m3"]` +--- + +## JSON shapes (stored in Text columns) -### Form field (in `form_template.fields_json` and `workshop.form_schema_json`) +**workshop.join_form_json / join_form_template.fields_json** — array of: ```json -{ - "key": "display_name", - "type": "text", - "label": "Display name", - "placeholder": "e.g. Priya, anu from Delhi", - "required": true -} +{"key": "role", "type": "text|dropdown", "label": "Your role", "required": false, "options": ["Student", "Engineer"]} ``` -Dropdown variant: +(`options` only for `dropdown`; `key` is `^[a-z][a-z0-9_]{0,39}$`.) + +**participant.answers_json** — `{"role": "Engineer"}` (keys from the workshop's join form). + +**help_answer.ai_context_json** — exactly what the AI saw (see agent.md §Context): ```json { - "key": "role", - "type": "dropdown", - "label": "Your role", - "required": false, - "options": ["Student", "Engineer", "Manager", "Other"] + "progress": {"completed_count": 3, "total": 8, "completed_titles": ["…"]}, + "milestone": {"id": 12, "title": "Configure the API key", "content_excerpt": "first 2000 chars…"}, + "similar": [{"help_request_id": 41, "workshop_id": 2, "message_excerpt": "…", "resolution_excerpt": "…", "rank": 1}] } ``` -### Help tips (in `workshop.help_tips_json`) -Plain text, one tip per line. Optional `topic: tip` format: -``` -Setup: Make sure Docker is running before starting -API Key: Use the .env file, don't paste keys in chat -``` +**facilitator_action.detail_json** — action-specific, human-renderable, e.g. `{"help_request_id": 7, "participant_name": "Priya", "excerpt": "Answered: check your .env…"}`. -### Broadcast message (in `workshop.broadcast_message`) -Plain text shown as a pinned banner on participant tracker. Empty = no active broadcast. +**facilitator_action.undo_data_json** — inverse-op data, e.g. `{"completion_ids": [911, 912, 913]}`. -## Indexing strategy -- All FK columns indexed (`workshop_id`, `participant_id`) -- Unique constraints on `admin_token`, `participant_slug` -- `help_request.created_at` for pagination ordering -- Composite index not needed at this scale (100-200 participants/workshop) +--- -## Upgrade notes (v0.1 additive columns) -New columns added to existing `workshop` table: -- `broadcast_message` TEXT DEFAULT '' -- `workshop_paused` BOOLEAN DEFAULT 0 -- `milestone_order_json` TEXT DEFAULT '[]' -- `help_tips_json` TEXT DEFAULT '' +## Version-bump rules (the contract every write follows) -Run on upgrade: -```sql -ALTER TABLE workshop ADD COLUMN broadcast_message TEXT DEFAULT ''; -ALTER TABLE workshop ADD COLUMN workshop_paused BOOLEAN DEFAULT 0; -ALTER TABLE workshop ADD COLUMN milestone_order_json TEXT DEFAULT '[]'; -ALTER TABLE workshop ADD COLUMN help_tips_json TEXT DEFAULT ''; -``` -SQLAlchemy `create_all` handles this idempotently on next boot. \ No newline at end of file +Bump `workshop.state_version` (same transaction) on: participant join, completion create/delete, help request create/answer/resolve/escalate, broadcast send/clear, pause/resume, advance, reorder, milestone edit, AI answer/draft, undo, status transition, ai toggle. Additionally bump `content_version` on: milestone create/edit/delete/reorder (and workshop name/description edit). Nothing else writes these counters. + +## Indexing strategy for 300-participant polling + +- Poll hot path: single point-read of `workshop` by unique token (admin_token/join_slug) or `participant` by unique token → covered by the unique indexes. +- Snapshot build (per version change, coalesced): `milestone_completion` GROUP BY milestone_id filtered via ix(milestone_id) join on workshop's milestones; leaderboard from ix(workshop_id) participants + their completion counts; help queue via ix(workshop_id, status, created_at). +- "Me" section per poll: unique(token) point-read + ix(participant_id) on completions + ix(participant_id) on help_requests — 2–3 sub-ms indexed queries. +- No composite index tuning beyond the above is warranted at ≤1000 rows/table/session scale; the unique constraints double as the lookup indexes. diff --git a/spec/roadmap.md b/spec/roadmap.md index 2af3298..df7b4eb 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -1,78 +1,126 @@ -# Roadmap — Workshop Helmsman (v0.1) - -## Phase 1 — Core Path (current improvement cycle) - -**Goal:** Facilitator creates workshop from template → shares participant link → participants join and fill form → facilitator watches live dashboard → participants complete milestones → facilitator exports CSV. - -**Surfaces built in Phase 1:** - -| # | Surface | Route | Notes | -|---|---------|-------|-------| -| 1 | Create workshop | `GET/POST /admin/new` | Name, agenda template picker, form template picker, TTL | -| 2 | Admin dashboard | `GET /admin/` | **Polished**: stat cards, participant table with progress bars + milestone chips, per-milestone stats, cohort stacked bar, help flags with status pills, broadcast composer, milestone controls | -| 3 | Participant join | `GET/POST /w/` | Instant validation, mobile-optimized, accessible | -| 4 | Participant tracker | `GET /w//me` | Milestone checklist, progress bar, leaderboard, broadcast banner, my help flags | -| 5 | Participant poll data | `GET /w//data` | JSON: milestones, my progress, leaderboard, help requests, broadcast message, workshop_paused, form schema | -| 6 | Admin poll data | `GET /admin//data` | JSON: all participants, stats, cohort bar, help requests, broadcast, workshop_paused, milestone order | -| 7 | Mark milestone complete | `POST /w//me/complete/` | Idempotent, blocked if workshop_paused | -| 8 | Help flag (2-step) | `POST /w//me/help` | Preview (with LLM suggestion stub) → commit | -| 9 | Broadcast message | `POST /admin//broadcast` | Set/clear pinned announcement | -| 10 | Pause workshop | `POST /admin//pause` | Toggle workshop_paused (locks completions) | -| 11 | Advance all | `POST /admin//advance-all` | Move all participants to next incomplete milestone | -| 12 | Advance selected | `POST /admin//advance-selected` | Move selected participant IDs to next milestone | -| 13 | Reorder milestones | `POST /admin//milestones/reorder` | Persist drag-drop order | -| 14 | Export CSV | `GET /admin//export.csv` | Streamed: participants × milestones + form answers + help | -| 15 | Health | `GET /healthz` | `{"status":"ok","db":"ok"}` | - -**Stubs (Phase 2+):** -- `GET /admin/templates` — template library CRUD -- `GET /workshops` — archive with search/filter -- `POST /admin//clone` — clone workshop -- `POST /admin//archive` — soft delete +# Roadmap — Workshop Helmsman (v0.2) + +Ground-up rebuild. The old `src/` and `frontend/` are **removed at scaffold**; nothing is preserved by inertia. Canonical run path (every phase): `(cd frontend && pnpm build)` → `uv run python -m src` → open **http://localhost:8001/app/**. `pnpm dev` is inner-loop only, never the test path. + +**Shared scaffold (laid down by the orchestrator BEFORE any generator runs, so slices stay disjoint):** `pyproject.toml` (full Phase-1 dependency list from architecture.md §Stack), `alembic.ini`, `.env.example` (all vars from architecture.md §Environment variables), `README.md` skeleton, `.gitignore` (`data/`, `.env`, `frontend/out/`, `node_modules/`), `data/.gitkeep`, removal of the v0.1 app code. Generators never edit another slice's files; root scaffold files are edited only by the orchestrator between fan-outs. + +## Phases of Development + +--- + +## Phase 1 — Core Live Loop *(scope fixed; smallest user-testable win, first-time-right)* + +**Goal:** A facilitator creates a workshop, shares the join link; 300 participants join with just a name, work content-rich milestones; the dashboard is genuinely live; one help request is submitted, answered, and seen — beautiful and rock-solid, with every future surface present as a clearly-labelled stub. + +**Capabilities delivered:** C1 Workshop creation & facilitator access · C2 Frictionless participant identity (cookie auto-resume, personal links, lost-link recovery) · C3 Content-rich milestone tracking + live dashboard · C4 Help desk (manual). Plus cross-cutting: versioned coalesced polling, structured JSON logging to stdout, resilience (restart-safe, reconnect-safe), labelled stubs for broadcast/pause/AI/templates/intelligence/audit/spend. + +**Independent slices (all three fan out in parallel — disjoint paths, no build dependencies):** + +| Slice | Owns (exclusive paths) | Notes | +|---|---|---| +| **S1 backend** | `src/**`, `alembic/**`, `tests/conftest.py`, `tests/unit/**`, `tests/integration/**` | Full Phase-1 API per api.md; models are the **full v0.2 schema** (data-model.md) in migration `0001_initial`; snapshot cache + version counters; structlog middleware; static mount + pretty redirects; audit rows for `workshop.create`/`help.answer`/`help.resolve` | +| **S2 frontend** | `frontend/**` | All four pages + design system + StubBadge surfaces per capabilities.md; typed client + polling hook per api.md (contract-first — no backend needed to build) | +| **S3 e2e** | `tests/e2e/**`, `playwright.config.ts`, `package.json` (root), `pnpm-lock.yaml` (root) | Playwright specs written from api.md + capabilities.md. No build dependency; **runtime dependency on S1+S2 at gate time only** (specs run against the live server) | + +**Key surfaces/files:** see architecture.md §Repo layout — S1: `api/{admin,facilitator,participant,health}.py`, `services/snapshots.py`, `db/models.py`, `security.py`, `observability/logging.py`; S2: `app/{page,f/page,join/page,p/page}.tsx`, `components/{ui,facilitator,participant}/`, `lib/{api,poll}.ts`; S3: `tests/e2e/core-loop.spec.ts`. + +**Gate (exact commands, from repo root; once beforehand: `cp .env.example .env` and set `HELMSMAN_ADMIN_KEY`):** + +```bash +uv sync +(cd frontend && pnpm install) && pnpm install && pnpm exec playwright install chromium +uv run alembic upgrade head +uv run alembic current # MUST print a revision hash — blank output = gate failed +uv run pytest tests/unit tests/integration -q +(cd frontend && pnpm build) +grep -q '\.flex' frontend/out/_next/static/css/*.css # styled-render: real utilities present +! grep -q '@source' frontend/out/_next/static/css/*.css # …and no unexpanded Tailwind directives +uv run python -m src & # live server on :8001 (leave running) +pnpm exec playwright test tests/e2e --reporter=line +kill %1 +``` + +The e2e smoke covers the full loop against the live single-origin app: create workshop (3 milestones incl. a code block) → join as two participants in separate contexts → tracker renders styled markdown → mark complete → dashboard reflects it within one poll → help request → answer from queue → answer visible on tracker → participant resolves → **browser reload restores state (cookie)** and **the personal link opens identical state in a fresh context (cross-device)** → labelled stubs are visible and non-interactive. Integration tests additionally cover: idempotent complete/uncomplete, version-counter bumps, unchanged-poll short-circuit, server-restart state survival (new process, same DB file), and every documented error code. + +**How the user tests it:** run the three commands above (sync/build/serve), open `http://localhost:8001/app/` → enter your `HELMSMAN_ADMIN_KEY` → **New workshop** → add 3 milestones with markdown + a code snippet → create → copy the join link → open it in a private window → join as "Priya" → read a milestone, mark it complete → watch the dashboard bars move within ~2 s → submit a help request from the tracker → answer it from the dashboard queue (use markdown + code) → see the badged answer appear on the tracker → mark resolved → close and reopen the private window (auto-resume) and paste the personal link into another browser (same state). **Labelled stubs (not bugs):** Broadcast, Pause, End workshop, AI toggle, Spend, stuck/bottleneck/pulse cards, Audit tab, Template library, Upcoming/Ended groups — all carry a "Coming in a later phase" pill. + +--- + +## Phase 2 — Facilitator Command & Proactive Intelligence + +**Goal:** The facilitator can steer the room — broadcast, pause, advance, reorder/edit, undo a fat-fingered action — and the dashboard tells them proactively who is stuck, where the pile-up is, and how the session is pacing. + +**Capabilities delivered:** Broadcast announcements · Milestone controls (advance all/selected, pause/resume, reorder, edit/add/delete) · Undo (30 s window) · Audit trail UI · Proactive intelligence (stuck alerts, bottleneck, session pulse). + +**Independent slices (parallel; no intra-phase dependencies):** + +| Slice | Owns | +|---|---| +| **S1 backend** | `src/**` (facilitator endpoints, `services/{undo,intelligence}.py`, snapshot additions), `tests/unit/**`, `tests/integration/**` additions | +| **S2 frontend** | `frontend/**` — broadcast composer w/ preview + undo toast, pause states, advance controls w/ confirmation, reorder UI, audit tab, alerts/pulse cards replacing their stubs; participant banner + paused lock | +| **S3 e2e** | `tests/e2e/**` additions (command flows + undo + banner) | + +**Gate (repo root; same setup):** `uv run alembic upgrade head && uv run alembic current` · `uv run pytest tests/unit tests/integration -q` · `(cd frontend && pnpm build)` · server up → `pnpm exec playwright test tests/e2e --reporter=line`. Integration tests must cover: undo inside/after the window (`undo_expired`), advance-all creating only missing completions (`source: "facilitator"`), pause blocking complete *and* uncomplete, audit rows for every action, stuck/bottleneck/pulse computed against a seeded 40-participant fixture whose expected alert set is pre-computed (not a trivially-empty fixture). + +**How the user tests it:** with a live workshop + a few joined participants: send a markdown broadcast → banner appears on trackers within a poll → **Undo** within 30 s → banner gone. Pause → participant checkboxes lock with a banner. Advance-all on milestone 1 → bars jump → Undo → they revert. Reorder milestones → tracker order updates. Leave one participant idle past the stuck threshold → stuck alert card names them; the pulse card shows pace + projected finish. Open the Audit tab → every action listed who/what/when. --- -## Phase 2 — Template Library & Multi-Session +## Phase 3 — Lifecycle, Templates & Re-run + +**Goal:** Workshops become sessions with a life: created from persistent templates (agenda + join form), ended into a grace period, archived read-only forever, and clonable for the next run. + +**Capabilities delivered:** End/grace/archive lifecycle (lazy transitions, read-only archive views) · Clone/re-run · Agenda template library (+ save-as-template) · Join-form templates & custom join fields · Admin Home lifecycle grouping (Live/Upcoming/Ended, `starts_at`). + +**Independent slices (parallel):** + +| Slice | Owns | +|---|---| +| **S1 backend** | `src/**` (`services/{lifecycle,templates}.py`, admin/facilitator/participant endpoint additions), `tests/**` (py) additions | +| **S2 frontend** | `frontend/**` — template library page + pickers (replacing stubs), end-workshop dialog w/ grace hours, archive read-only styling, clone, join-form custom fields, Admin Home grouping | +| **S3 e2e** | `tests/e2e/**` additions | + +**Gate (repo root):** same command sequence as Phase 2. Integration tests must cover: lazy transition on access (set `grace_until` in the past → next request archives), archived rejects every mutation with `workshop_archived` while reads still serve, template edit never mutating an instantiated workshop (snapshot proof), clone = fresh tokens + zero participants, join-form validation against the snapshot. -- `/admin/templates` — list, create, edit, delete agenda templates & form templates -- Template pickers on `/admin/new` and `/admin//edit` -- Template edits never mutate existing workshops (snapshot-on-create) -- `/workshops` archive page: list all workshops, search by name, filter by status (live/expired/archived), per-session participant count -- Cohort comparison: select multiple sessions of same template → side-by-side completion rates -- Per-participant drill-down: `/admin//participant/` — timeline of completions + help requests + form answers +**How the user tests it:** save the running workshop as a template → create a new workshop from it (milestones pre-filled) → add a custom dropdown join field via a form template → join page shows it → **End workshop** with a 1-hour grace → tracker shows the grace banner, completions still work → **Archive now** → tracker and dashboard flip to read-only archive views; Admin Home shows it under Ended → **Clone** → a fresh Live workshop with the same agenda and new links. --- -## Phase 3 — AI Assist & Notifications +## Phase 4 — AI Help-Desk -- Optional LLM (Gemini via OpenRouter) for help-desk pre-resolution -- Facilitator clicks "Suggest reply" on help flag → LLM returns one short actionable paragraph → facilitator edits/sends -- Context injected: workshop milestones, facilitator help tips (`help_tips_json`), participant's message -- Email/Slack webhook on new help flags (configurable per workshop) -- Mobile-first participant tracker polish (touch targets, gesture-friendly) -- Accessibility audit (WCAG AA) +**Goal:** Help requests are triaged by AI that knows the participant's context and every past resolution: confident → instant badged auto-answer with one-tap human escalation; unsure → draft for facilitator review — fully audited, costed per workshop, per-workshop toggleable, and a zero-error no-op without an API key. + +**Capabilities delivered:** AI triage & auto-answer pipeline · Escalation & transparency (AI badge, "Get a human", exact-context disclosure) · Cross-workshop learning corpus (FTS5) · Spend tracking + per-workshop toggle + air-gapped guarantee. Design is fixed in [agent.md](agent.md). + +**Independent slices (parallel; S1↔S2 share only the `run_help_desk(help_request_id)` interface fixed in agent.md — no serialization needed):** + +| Slice | Owns | +|---|---| +| **S1 ai-pipeline** | `src/helmsman/ai/**` (pipeline, context, corpus, openrouter client, prompts), `tests/integration/ai/**`, corpus migration (`alembic/versions/` addition) | +| **S2 backend-api** | `src/helmsman/api/**` (toggle/spend/escalate endpoints, BackgroundTask hookup in participant help POST), `src/helmsman/services/snapshots.py` (spend + AI fields), `tests/unit/**` + non-AI integration additions | +| **S3 frontend** | `frontend/**` — AI badge + escalate on tracker; draft-review block, context disclosure, spend card, AI toggle (replacing stubs) | +| **S4 e2e** | `tests/e2e/**` additions (AI auto-answer flow, escalation, air-gapped run) | + +**Gate (repo root; requires the real `OPENROUTER_API_KEY` in `.env` — the gate is BLOCKED, not skipped, without it):** `uv run alembic upgrade head && uv run alembic current` · `uv run pytest tests/unit tests/integration -q` (now includes `tests/integration/ai` against the **real OpenRouter API**; the similarity fixture seeds 25+ resolved requests across ≥3 workshops with exactly one strong match asserted at rank 1 — per agent.md §Testing) · `(cd frontend && pnpm build)` · server up → `pnpm exec playwright test tests/e2e --reporter=line` (includes the real-key auto-answer journey **and** the keyless air-gapped journey with zero errors and zero AI chrome). + +**How the user tests it:** with `OPENROUTER_API_KEY` set: toggle AI on → as a participant, ask something answered by the milestone content → an **AI-badged** answer appears in seconds → tap **Get a human** → request returns to the queue flagged, AI answer still visible → in the queue, expand **Context the AI used** → see progress/milestone/similar-resolutions exactly → ask something ambiguous → a violet **draft** waits in the queue; edit and send it → spend card shows real dollars. Then remove the key, restart: everything works, AI surfaces show a labelled off state, zero errors anywhere. --- -## Phase 4 — Scale & Multi-tenancy +## Phase 5 — Ship It: Docker + CI/CD to GCP + +**Goal:** One-command production deploy and an automated path from merge to the VM. + +**Capabilities delivered:** Docker Compose packaging (multi-stage image, volume-backed SQLite, migrations on boot) · CI on GitHub Actions (tests on PR; real-key jobs guarded to runners with secrets) · CD to the GCP VM (image build → registry → compose deploy) + `deploy/RUNBOOK.md` (backup/restore of `data/`, TLS proxy note). + +**Independent slices (parallel):** **S1 deploy** — `deploy/**` (Dockerfile, docker-compose.yml, RUNBOOK.md) + README deploy section · **S2 ci-cd** — `.github/workflows/**` (ci.yml, deploy.yml). + +**Gate (repo root):** `docker compose -f deploy/docker-compose.yml up -d --build` · `curl -fsS http://localhost:8001/api/health` · `pnpm exec playwright test tests/e2e --reporter=line` against the container · `docker compose -f deploy/docker-compose.yml down` (volume persists; re-`up` retains data — verified by the e2e re-run finding the same workshop) · CI workflow green on the PR. -- PostgreSQL backend (swap via `DATABASE_URL`) -- Multiple concurrent workshops (dashboard switcher) -- Per-workshop facilitator tokens (rotate, revoke, multiple per workshop) -- Custom domains / subdomains per workshop -- Team/organization grouping (multi-tenant) -- Webhooks for external integrations (completion webhook, help-flag webhook) -- Structured logging + metrics endpoint (`/metrics` for Prometheus) -- Load test: 200+ participants, 4s poll, <200ms p99 +**How the user tests it:** `cp .env.example .env`, set the key(s), `docker compose -f deploy/docker-compose.yml up -d` → open `http://localhost:8001/app/` → run the Phase-1 manual loop → `docker compose restart` mid-session → participants reconnect with nothing lost. Merge a PR → watch Actions deploy it to the VM. --- -## Cross-cutting (every phase) +## Phase-gate constants (every phase) -- Platform-agnostic URLs (no hardcoded host/port) -- Mobile-responsive CSS (grid/flex + viewport meta) -- Health probes: `/healthz` (liveness), `/healthz/ready` (readiness) -- No secrets required to boot (`.env.example` empty for core flow) -- Single CSS file, single JS file, no bundler, no `node_modules` -- Audit trail: every milestone completion, help request, facilitator action timestamped -- SQLite `data/helmsman.db` excluded from git \ No newline at end of file +Working tree clean and pushed · README updated and its commands re-run verbatim · human test-handoff published and approved before the next phase · qa-auditor sign-off per slice · no phase starts while the previous one is red. diff --git a/spec/vision.md b/spec/vision.md index f62ecf7..3ed068d 100644 --- a/spec/vision.md +++ b/spec/vision.md @@ -1,35 +1,42 @@ -# Vision — Workshop Helmsman (v0.1) +# Vision — Workshop Helmsman (v0.2) + +> This spec supersedes v0.1 entirely, including all v0.1 non-goals (notably "no React / no build step" — v0.2 is a Next.js frontend by explicit user choice). The v0.1 code in `src/` and `frontend/` is removed at scaffold; v0.2 is built fresh from this spec. ## What it is -Workshop Helmsman is a self-hosted workshop tracker for **remote, facilitator-led sessions with 100+ participants**. It gives a **facilitator** a live dashboard of where every participant is in the curriculum and surfaces "I need help" requests in real time, while each **participant** gets a personal tracker page (progress bar, "mark milestone complete" buttons, a help form, a leaderboard of peers, and facilitator announcements). +Workshop Helmsman is a **self-hosted workshop assistant for facilitator-led, hands-on technical labs**. A facilitator creates a workshop, shares one join link, and 100–300+ participants work through a checklist of **content-rich milestones** (full markdown instructions, links, code snippets) on their own devices. The facilitator watches a **genuinely live dashboard** — per-milestone completion, cohort distribution, a help queue — and intervenes with broadcasts, milestone controls, and answers. An **AI help-desk** (OpenRouter) triages help requests: it gathers the participant's context and past resolutions, auto-answers when confident (clearly badged, one tap to escalate to a human), and drafts a reply for facilitator review when unsure. Without an API key, the entire product works air-gapped with zero errors. -It is opinionated, no-LLM-required, air-gap-friendly, and reusable: the same codebase runs any number of workshops back-to-back via unique tokens. +It is production-grade from day one: real workshops with external participants depend on it, so polish, resilience, and first-time-right behavior are the bar — not aspirations. ## Who it serves -- **Facilitators** running live online workshops who want to see at a glance which participants are stuck, in addition to seeing who is keeping pace. They need to intervene — broadcast announcements, advance milestones, pause the room, send targeted hints. -- **Participants** who need a stable URL they can pin on their phone, a clear view of "what's left", a low-friction way to ask for help without disrupting the room, and a sense of progress alongside peers. +- **Facilitators** (a small team, co-facilitating the same live workshop) who need to know *right now* which milestone the room is piled up on, who is stuck, and which help requests are open — and to act on it: broadcast, advance, pause, answer, undo a fat-fingered action. No accounts: unguessable token links. +- **Participants** (100–300+ per session, including external attendees) who join with near-zero friction (name at the door), get a personal link that survives device switches and browser restarts, see rich instructions per milestone, mark progress, watch a leaderboard, and get help fast — from the AI instantly when it's confident, from a human otherwise. + +## Session lifecycle + +Workshops are created fresh per session — from scratch or from a persistent **template library** (agenda templates + join-form templates). A live workshop ends into a **grace period** for stragglers, then becomes a **read-only archive**, browsable forever. Workshops are clonable/re-runnable. Full session archives persist indefinitely. -## Non-goals +## Quality bar -- No LLM in the core loop. No external API calls required. Everything stays on the single VM. -- No multi-tenant SaaS, no auth provider integration beyond the workshop tokens. -- No React/Vue/webpack. Server-rendered Jinja2 + vanilla JS, polling-based sync. -- No breakout rooms, no participant-to-participant chat (Phase 2+). +- **First-time-right.** Every phase's tested path works the first time the user tests it. Zero rough edges on the tested path; stubs for future features are clearly labelled and never read as bugs. +- **Resilient by construction.** All state lives in the database, never only in memory. A participant who reconnects gets their exact state back. A server restart mid-session loses nothing — every client recovers on its next poll with no user action. +- **Live at scale on modest hardware.** 300+ concurrent participants on a 2 vCPU / 2–4 GB VM, near-zero running cost (see [architecture.md](architecture.md) for the coalesced-polling design that achieves this). +- **Visually excellent.** A modern, polished UI with a consistent design system — this is the product's face to external participants. Every view designs its empty, loading, error, and populated states. +- **Honest AI.** AI answers are always badged as AI, always escapable to a human, always audited with the exact context the AI used, and always costed (per-workshop spend visible). -## Core interaction model +## Explicit non-goals (v0.2) -- **Session shape:** Facilitator creates a *new workshop per session* from a reusable template. Participants join that session's unique link. Workshop has TTL (default 8h). -- **Memory/state:** Workshop templates (agenda + form schema) persist forever. Each session gets its own snapshot. Participant progress, form answers, and help requests persist *within a session*. Templates carry across sessions; session data does not. -- **Multi-item:** Facilitator manages a library of agenda templates and form templates. Can pick one of each when creating a new session. Can also edit the session's snapshot without affecting the template. -- **Error handling:** Participant join validates name instantly. Help requests go through 2-step preview (optional LLM suggestion) then commit. Facilitator actions (advance, broadcast, pause) are immediate with optimistic UI — next poll (≤4s) confirms. +- **No participant accounts, no facilitator accounts, no auth-provider integration.** Access is via unguessable token links plus a single instance-level facilitator access key. +- **No separate concurrent-workshop isolation model.** Multiple facilitators co-run the *same* workshop; running several workshops at once is possible but not a designed-for isolation boundary (one team, one instance). +- **No lecture mode or multi-day cohort mode** — future versions, not v0.2. +- **No external integrations** (no Slack/email/webhooks/calendar). Standalone. +- **No participant-to-participant chat, no breakout rooms.** +- **No WebSockets/SSE** — live updates are versioned coalesced polling (a deliberate architecture decision, see architecture.md). +- **No dark mode in v0.2** — one polished light theme; design tokens are structured so dark mode can be added later without rework. +- **No health/ops chrome in the UI** — a plain `/api/health` endpoint exists for machines; the UI stays clean. +- **No vector database** — help-request similarity uses SQLite FTS5 keyword search (see [agent.md](agent.md)). -## Technical stack +## The one-line test -- **Backend:** Python 3.11, FastAPI, SQLAlchemy 2.x, SQLite (dev) / PostgreSQL (prod) -- **Frontend:** Jinja2 templates, vanilla JS (single `app.js`), one CSS file (`style.css`). Mobile-responsive participant view. -- **Auth:** Token-based (admin_token in URL, participant_slug in URL). HttpOnly cookie for participant session. -- **Real-time:** Short polling (4s) — no WebSockets. Single `/data` endpoint serves both participant tracker and admin dashboard (different shapes via query param). -- **Deploy:** Docker Compose + systemd unit. Runs on single VM. -- **LLM:** Optional — Google Gemini via OpenRouter (`OPENROUTER_API_KEY` in `.env`). Phase 3 help-desk pre-resolution only. Graceful fallback if unavailable. \ No newline at end of file +A facilitator with a fresh VM can go from `docker compose up` (or `uv run python -m src`) to a live workshop with 300 external participants tracking milestones and getting help — without reading anything beyond the join link they share. From 2d56f4cc57d53402c40b001428cba5d682639aad Mon Sep 17 00:00:00 2001 From: Workshop Helmsman Date: Mon, 20 Jul 2026 01:58:07 +0530 Subject: [PATCH 02/10] =?UTF-8?q?phase-1:=20scaffold=20v0.2=20=E2=80=94=20?= =?UTF-8?q?pyproject/uv,=20alembic.ini,=20env=20example,=20README;=20remov?= =?UTF-8?q?e=20v0.1=20app=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 40 +- .gitignore | 33 +- Dockerfile | 15 - README.md | 90 +- SPEC.md | 117 -- alembic.ini | 38 + data/.gitkeep | 0 deploy/docker-compose.yml | 23 - deploy/systemd/workshop-helmsman.service | 16 - frontend/static/app.js | 121 -- frontend/static/form_editor.js | 135 -- frontend/static/help_status.js | 54 - frontend/static/style.css | 851 -------- frontend/templates/_form_field_row.html | 19 - frontend/templates/_stubs.html | 12 - frontend/templates/admin_dashboard.html | 300 --- frontend/templates/admin_edit.html | 71 - frontend/templates/admin_form.html | 62 - frontend/templates/admin_form_template.html | 35 - frontend/templates/admin_new.html | 80 - frontend/templates/admin_template_edit.html | 32 - frontend/templates/admin_templates.html | 37 - frontend/templates/base.html | 30 - frontend/templates/console.html | 86 - frontend/templates/console_index.html | 61 - frontend/templates/console_workshop_new.html | 337 --- frontend/templates/landing.html | 128 -- frontend/templates/participant_drilldown.html | 73 - frontend/templates/participant_join.html | 76 - frontend/templates/participant_tracker.html | 224 -- frontend/templates/workshop_archived.html | 10 - frontend/templates/workshop_expired.html | 11 - frontend/templates/workshops_index.html | 80 - pyproject.toml | 26 + requirements.txt | 6 - src/__init__.py | 8 - src/__main__.py | 21 - src/db.py | 67 - src/main.py | 1831 ----------------- src/models.py | 370 ---- src/security.py | 68 - tools/migrate.py | 153 -- uv.lock | 565 +++++ 43 files changed, 726 insertions(+), 5686 deletions(-) delete mode 100644 Dockerfile delete mode 100644 SPEC.md create mode 100644 alembic.ini create mode 100644 data/.gitkeep delete mode 100644 deploy/docker-compose.yml delete mode 100644 deploy/systemd/workshop-helmsman.service delete mode 100644 frontend/static/app.js delete mode 100644 frontend/static/form_editor.js delete mode 100644 frontend/static/help_status.js delete mode 100644 frontend/static/style.css delete mode 100644 frontend/templates/_form_field_row.html delete mode 100644 frontend/templates/_stubs.html delete mode 100644 frontend/templates/admin_dashboard.html delete mode 100644 frontend/templates/admin_edit.html delete mode 100644 frontend/templates/admin_form.html delete mode 100644 frontend/templates/admin_form_template.html delete mode 100644 frontend/templates/admin_new.html delete mode 100644 frontend/templates/admin_template_edit.html delete mode 100644 frontend/templates/admin_templates.html delete mode 100644 frontend/templates/base.html delete mode 100644 frontend/templates/console.html delete mode 100644 frontend/templates/console_index.html delete mode 100644 frontend/templates/console_workshop_new.html delete mode 100644 frontend/templates/landing.html delete mode 100644 frontend/templates/participant_drilldown.html delete mode 100644 frontend/templates/participant_join.html delete mode 100644 frontend/templates/participant_tracker.html delete mode 100644 frontend/templates/workshop_archived.html delete mode 100644 frontend/templates/workshop_expired.html delete mode 100644 frontend/templates/workshops_index.html create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 src/__init__.py delete mode 100644 src/__main__.py delete mode 100644 src/db.py delete mode 100644 src/main.py delete mode 100644 src/models.py delete mode 100644 src/security.py delete mode 100644 tools/migrate.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example index 317ba3b..527109d 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,22 @@ -# Workshop Helmsman — environment template -# -# Copy this file to `.env` and fill in values. Nothing is strictly required -# for local dev (SQLite is the default), but the OpenRouter key enables AI assist. - -# --- Server --- -# BIND_HOST=0.0.0.0 -# BIND_PORT=8001 +# Workshop Helmsman v0.2 — copy to .env and fill in. `.env` is gitignored; never commit it. # --- Database --- -# Local dev (default): sqlite:///./data/helmsman.db -# Production (PostgreSQL): postgresql+psycopg://user:pass@host:5432/dbname -DATABASE_URL=sqlite:///./data/helmsman.db - -# --- Workshop defaults --- -# DEFAULT_WORKSHOP_TTL_HOURS=8 +# SQLAlchemy URL. Default is SQLite in data/. PostgreSQL-ready: postgresql+psycopg2://... +DATABASE_URL=sqlite:///data/helmsman.db -# --- LLM (optional) --- -# OpenRouter API key — enables facilitator help-reply suggestions. -# Get one at https://openrouter.ai/keys -# If absent or empty, AI assist is silently disabled (no errors, no fallback). -OPENROUTER_API_KEY= +# --- Server --- +PORT=8001 +# Absolute base URL for generated share links (set when running behind a proxy). +# Leave empty to derive from the request. +HELMSMAN_BASE_URL= +HELMSMAN_LOG_LEVEL=INFO -# OpenRouter primary model (default: nvidia/llama-3.3-nemotron-super-49b-v1) -# OPENROUTER_PRIMARY_MODEL=nvidia/llama-3.3-nemotron-super-49b-v1 +# --- Facilitator access (REQUIRED) --- +# The instance access key entered on the Admin Home page. Choose any strong string (>=16 chars). +HELMSMAN_ADMIN_KEY= -# OpenRouter fallback model (default: openai/gpt-4o-mini) -# OPENROUTER_FALLBACK_MODEL=openai/gpt-4o-mini \ No newline at end of file +# --- AI help-desk (Phase 4; optional) --- +# Absent => the product runs fully air-gapped with zero errors and AI surfaces show a labelled off state. +OPENROUTER_API_KEY= +HELMSMAN_AI_MODEL=anthropic/claude-sonnet-4-6 +HELMSMAN_AI_CONFIDENCE=0.75 diff --git a/.gitignore b/.gitignore index e41854e..c71f5ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,32 @@ +# secrets +.env + +# python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.pytest_cache/ + +# runtime data (SQLite lives here) +data/* +!data/.gitkeep *.db *.db-journal +*.db-wal +*.db-shm *.log -*.pyc -*.pyo + +# node / frontend +node_modules/ +frontend/.next/ +frontend/out/ + +# playwright +test-results/ +playwright-report/ + +# misc .DS_Store -.env /tmp/ -__pycache__/ -data/ -venv/ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 58bbd6c..0000000 --- a/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Local-only dependencies. No network calls required at runtime. -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt - -COPY src ./src -COPY frontend ./frontend - -ENV BIND_HOST=0.0.0.0 BIND_PORT=8001 PYTHONUNBUFFERED=1 - -EXPOSE 8001 -CMD ["python", "-m", "src"] diff --git a/README.md b/README.md index 52e7f97..d69a02a 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,68 @@ # Workshop Helmsman -A self-hosted workshop tracker for remote sessions. Facilitators stand up a -workshop, get a participant link (slug) and a separate admin link (token), -and watch participants move through milestones in real time. +Self-hosted workshop assistant for facilitator-led hands-on technical labs: a facilitator creates a workshop with content-rich milestones, participants join with just a name, and a genuinely live dashboard tracks the whole room — with a built-in help desk. -Phase 1 ships the full single-workshop happy path on a single VM with no -external services. No LLM, no external APIs — everything is local SQLite. +> **All commands run from the repo root.** Every Python command is prefixed with `uv run` — bare commands will fail. -## Stack +## Requirements -- Python 3.11 / FastAPI / Uvicorn -- SQLite via SQLAlchemy 2.x -- Jinja2 templates, vanilla JS, one CSS file -- Polling-based live updates (4 s) +- Python 3.12+ and [uv](https://docs.astral.sh/uv/) +- Node.js 22 and [pnpm](https://pnpm.io/) -## Run locally +## Setup (once) -```sh -cd /Users/sai/workshop-helmsman -python3 -m venv venv -venv/bin/pip install -r requirements.txt -venv/bin/python -m src # serves on http://localhost:8001 +```bash +# from the repo root +cp .env.example .env # then set HELMSMAN_ADMIN_KEY (any strong string, >=16 chars) +uv sync +(cd frontend && pnpm install) +pnpm install # root: Playwright for e2e tests +pnpm exec playwright install chromium # only needed to run the e2e suite ``` -The first boot creates `./data/helmsman.db` automatically. +## Database -## Endpoints (Phase 1) +```bash +# from the repo root +uv run alembic upgrade head +uv run alembic current # MUST print a revision hash — blank output means no migration was applied +``` + +## Build the UI and run + +```bash +# from the repo root +(cd frontend && pnpm build) +uv run python -m src +``` + +Open **http://localhost:8001/app/** — enter your `HELMSMAN_ADMIN_KEY` to reach the Admin Home. -| Route | Purpose | -| -------------------------------------- | -------------------------------------------- | -| `GET /` | Landing page | -| `GET /admin/new` · `POST /admin/new` | Facilitator creates a workshop | -| `GET /admin/` | Live facilitator dashboard | -| `GET /w/` | Participant join (asks for their name) | -| `GET /w//me` | Personal progress tracker + leaderboard | -| `GET /w//data?since=`| JSON poll feed (used by the participant UI) | -| `GET /healthz` | `{"status":"ok","db":"ok"}` liveness probe | +Share links: join `http://localhost:8001/j/` · participant personal link `/p/` · facilitator dashboard `/f/`. + +## Tests + +```bash +# from the repo root +uv run pytest tests/unit tests/integration -q + +# e2e (requires the server running in another terminal: uv run python -m src) +pnpm exec playwright test tests/e2e --reporter=line +``` -## Phase 1 stubs (clearly labelled) +## Environment variables -- `GET /admin//edit` → "Coming in Phase 2" -- `GET /admin//export.csv` → "Coming in Phase 2" -- `POST /admin//clone` → "Coming in Phase 2" -- `GET /workshops` → read-only archive (no admin actions) +| Variable | Required | Default | Purpose | +|---|---|---|---| +| `DATABASE_URL` | no | `sqlite:///data/helmsman.db` | SQLAlchemy URL; PostgreSQL-ready | +| `PORT` | no | `8001` | HTTP port | +| `HELMSMAN_ADMIN_KEY` | **yes** | — | Facilitator access key for Admin Home | +| `HELMSMAN_BASE_URL` | no | derived from request | Absolute base for generated share links | +| `HELMSMAN_LOG_LEVEL` | no | `INFO` | Log level (structlog, JSON to stdout) | +| `OPENROUTER_API_KEY` | no | — | AI help-desk (Phase 4); absent = fully air-gapped | +| `HELMSMAN_AI_MODEL` | no | `anthropic/claude-sonnet-4-6` | OpenRouter model id | +| `HELMSMAN_AI_CONFIDENCE` | no | `0.75` | AI auto-answer confidence threshold | -## Deploy +## Status -`deploy/systemd/workshop-helmsman.service` and `deploy/docker-compose.yml` -are both scaffolded. Pick one. The Phase-1 VM target is -`workshop.smalltech.in`; this repo boots cleanly on `:8001` for local -testing with no extra config. +Phase 1 — **Core Live Loop**: create workshop → join (cookie auto-resume + personal links) → content-rich milestones → live dashboard → manual help desk. Broadcast, Pause, End workshop, AI help-desk, Spend, stuck/bottleneck/pulse cards, Audit, Templates are visible as clearly-labelled **"Coming in a later phase"** stubs. The spec in `spec/` is the source of truth. diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index d8e7b83..0000000 --- a/SPEC.md +++ /dev/null @@ -1,117 +0,0 @@ -# Workshop Helmsman — World-Class UX Specification - -## Architecture (Clean Separation) -| URL | Purpose | -|-----|---------| -| `/` | Marketing landing | -| `/console` | **Master console** — secret URL, workshop table, "+ New Workshop" | -| `/console/workshop/new` | **Create Workshop Wizard** — 5 steps | -| `/admin/` | **Per-workshop admin** — broadcast, pause, advance, reorder, CSV | -| `/w/` | Participant join — instant validation | -| `/w//me` | Participant tracker — broadcast banner, progress, milestones, leaderboard | - ---- - -## Create Workshop Wizard — 5 Steps - -### Step 1: Basics -- Workshop name (required, max 120 chars) -- Duration: dropdown [2h, 4h, 8h, 16h, 24h, Custom] -- Template picker: dropdown [Blank, 4-Phase, 6-Phase Sprint, 3-Phase Quickstart] — pre-fills milestones - -### Step 2: Milestones (Drag-drop reorderable list) -- **Each milestone row:** drag handle | Title (inline edit on click) | Duration dropdown [15m, 30m, 45m, 60m, 90m, 120m, Custom] | Facilitator tip (icon, hover tooltip) | Description (optional, inline edit) | Category badge [Setup, Learning, Hands-on, Break, Q&A, Wrap-up] | Delete -- Add milestone button (+) -- Template picker applies preset milestones - -### Step 3: Participant Form Builder -- Always: Full Name (text, required, locked at top) -- Add field: button opens modal → Field type [Text, Email, Dropdown] | Label | Required toggle | Placeholder | Options (for dropdown, comma-separated) -- Fields list: drag to reorder | Edit | Delete -- Required fields marked with * - -### Step 4: Knowledge Base / Resources (Future Phase) -- Google Drive folder link input -- Placeholder for future: "Link Google Drive folder for slides, docs, resources" - -### Step 5: Review & Create -- Summary cards: Basics | Milestones (count, total time) | Form fields | KB link -- Edit buttons per section -- "Create Workshop" → creates workshop, redirects to `/admin/` - ---- - -## Console (Master) — `/console` -- Table: Name | Created | Status (Live/Ended/Archived) | Attendees | Actions [Admin, Participant link, Edit, Clone, Archive] -- "+ New Workshop" button → `/console/workshop/new` -- Status badges: Live (green), Ended (orange), Archived (gray) - ---- - -## Per-Workshop Admin Dashboard — `/admin/` -**Dashboard Cards (clickable):** -- Live count | Active now | Avg progress % | Help flags - -**Tabs:** -- **Participants:** Table with inline actions (Advance, Pause, Broadcast to one) -- **Milestones:** Drag-drop reorder, Advance all, Advance selected, Pause/Resume -- **Broadcast:** Composer + history -- **Help Flags:** List with status pills (Open/On Hold/Resolved) -- **Export CSV** - ---- - -## Participant Flow -- `/w/` → Join form (instant validation, autocomplete off, autocapitalize words) -- `/w//me` → Tracker: progress bar, milestone list (click to complete, disabled when paused), leaderboard, broadcast banner (dismissible), help flag 2-step (preview with LLM suggestion → commit) - ---- - -## Field Types (Form Builder) -| Type | Config | -|------|--------| -| Text | Label, Placeholder, Required | -| Email | Label, Placeholder, Required | -| Dropdown | Label, Options (comma-separated), Required | - -**Always required (locked):** Full Name (text, required) - ---- - -## Milestone Fields (Inline Edit on Click) -| Field | Type | Required | -|-------|------|----------| -| Title | Inline text | Yes | -| Description | Inline textarea (optional) | No | -| Duration | Dropdown [15m, 30m, 45m, 60m, 90m, 120m, Custom] | Yes | -| Category | Badge [Setup, Learning, Hands-on, Break, Q&A, Wrap-up] | Yes | -| Facilitator Tip | Tooltip icon (hover) | No | - ---- - -## Visual Language (World-Class) -- **Design tokens:** 8px spacing scale, 4/8/12/16px radius, elevation shadows -- **Color:** Dark default (#0b0f1a bg), accent blue (#7aa2ff), live green, warn amber, danger red -- **Typography:** System font stack, clamp() fluid sizing -- **Motion:** 120ms fast, 200ms normal, 350ms slow; prefers-reduced-motion -- **Touch targets:** 44px minimum -- **Dark default, light mode optional** - ---- - -## Data Model -```python -Workshop: id, name, created_at, expires_at, admin_token, participant_slug, - milestone_config (JSON), form_schema (JSON), kb_link, - archived, paused, broadcast_message, milestone_order - -Milestone: id, title, description, duration_min, category, facilitator_tip, order - -FormField: key, type (text|email|dropdown), label, placeholder, required, options - -Participant: id, workshop_id, name, answers (JSON), joined_at - -MilestoneCompletion: participant_id, milestone_id, milestone_title, completed_at - -HelpRequest: participant_id, message, status (open/on_hold/resolved), created_at -``` \ No newline at end of file diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..fb166b2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +# sqlalchemy.url is resolved at runtime in alembic/env.py from DATABASE_URL +# (default: sqlite:///data/helmsman.db) + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml deleted file mode 100644 index 480ffdb..0000000 --- a/deploy/docker-compose.yml +++ /dev/null @@ -1,23 +0,0 @@ -version: "3.9" - -services: - helmsman: - build: . - image: workshop-helmsman:0.1 - container_name: workshop-helmsman - restart: unless-stopped - ports: - - "8001:8001" - environment: - BIND_HOST: "0.0.0.0" - BIND_PORT: "8001" - volumes: - - helmsman-data:/app/data - healthcheck: - test: ["CMD", "curl", "-sf", "http://localhost:8001/healthz"] - interval: 30s - timeout: 5s - retries: 3 - -volumes: - helmsman-data: diff --git a/deploy/systemd/workshop-helmsman.service b/deploy/systemd/workshop-helmsman.service deleted file mode 100644 index 66c55f3..0000000 --- a/deploy/systemd/workshop-helmsman.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Workshop Helmsman — milestone tracker (FastAPI/uvicorn) -After=network.target - -[Service] -Type=simple -User=sai -WorkingDirectory=/Users/sai/workshop-helmsman -Environment="BIND_HOST=0.0.0.0" -Environment="BIND_PORT=8001" -ExecStart=/Users/sai/workshop-helmsman/venv/bin/python -m src -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=multi-user.target diff --git a/frontend/static/app.js b/frontend/static/app.js deleted file mode 100644 index 70e00ae..0000000 --- a/frontend/static/app.js +++ /dev/null @@ -1,121 +0,0 @@ -// Workshop Helmsman — participant-side polling loop. -// Re-fetches /w//data every 4 seconds and re-renders -// the leaderboard + help flags. Falls back gracefully on network errors. - -(function () { - var ctx = (typeof window !== "undefined" && window.__helmsman) || {}; - var slug = ctx.slug; - if (!slug) return; - - var leaderboardEl = document.getElementById("leaderboard-list"); - var helpEl = document.getElementById("help-list"); - var mineBar = document.getElementById("my-bar"); - var mineText = document.getElementById("my-progress-text"); - var minePct = document.getElementById("my-progress-pct"); - var milestonesEl = document.getElementById("milestones-list"); - - var POLL_MS = 4000; - var meId = ctx.meId || null; - var lastSince = null; - - function fmtTime(iso) { - try { - var d = new Date(iso); - if (isNaN(d.getTime())) return ""; - var hh = String(d.getUTCHours()).padStart(2, "0"); - var mm = String(d.getUTCMinutes()).padStart(2, "0"); - var ss = String(d.getUTCSeconds()).padStart(2, "0"); - return hh + ":" + mm + ":" + ss + " UTC"; - } catch (e) { return ""; } - } - - function escapeHtml(s) { - return String(s) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - } - - function renderLeaderboard(rows) { - if (!leaderboardEl) return; - if (!rows || !rows.length) { - leaderboardEl.innerHTML = '
  • No participants yet.
  • '; - return; - } - leaderboardEl.innerHTML = rows.map(function (r) { - var me = meId && r.id === meId ? ' leaderboard-me' : ''; - var meLabel = meId && r.id === meId ? ' (you)' : ''; - return '
  • ' + - '' + escapeHtml(r.name) + meLabel + '' + - '' + - '
    ' + - '' + r.completed_count + ' / ' + r.total + '' + - '
    ' + - '
  • '; - }).join(""); - } - - function renderHelp(rows) { - if (!helpEl) return; - if (!rows || !rows.length) { - helpEl.innerHTML = '
  • No help flags yet.
  • '; - return; - } - helpEl.innerHTML = rows.map(function (h) { - return '
  • ' + - '
    ' + escapeHtml(h.participant_name) + '' + - '· ' + escapeHtml(fmtTime(h.created_at)) + '
    ' + - '
    ' + escapeHtml(h.message) + '
    ' + - '
  • '; - }).join(""); - } - - function updateMine(payload, currentCompletedIds) { - if (mineBar && mineText && minePct) { - var me = (payload.leaderboard || []).find(function (r) { return meId && r.id === meId; }); - if (me) { - mineBar.style.width = me.pct + "%"; - mineText.textContent = me.completed_count; - minePct.textContent = me.pct; - } - } - if (milestonesEl && currentCompletedIds && payload.milestones) { - // Update chip markers on milestones when the polled data shows new completions - Array.prototype.forEach.call(milestonesEl.querySelectorAll(".milestone"), function (li) { - var mid = li.getAttribute("data-mid"); - if (currentCompletedIds[mid]) { - li.classList.add("milestone-done"); - } - }); - } - } - - function pollOnce() { - var url = "/w/" + encodeURIComponent(slug) + "/data"; - if (lastSince) url += "?since=" + encodeURIComponent(lastSince); - fetch(url, { credentials: "same-origin" }) - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (j) { - if (!j || !j.ok) return; - lastSince = j.server_time; - renderLeaderboard(j.leaderboard); - renderHelp(j.help_requests); - // We already drew state at SSR — but if completions exist server-side, derive set - var completedIds = {}; - var me = (j.leaderboard || []).find(function (r) { return meId && r.id === meId; }); - if (me) { - // we don't have individual milestone IDs from this JSON, but - // visual progress bar is enough for the live-tick (the buttons - // do a full reload and SSR the proper state). - updateMine(j, completedIds); - } - }) - .catch(function () { /* swallow — next tick will retry */ }); - } - - // Initial tick fires immediately so the user doesn't see a stale flash, - // then poll every POLL_MS. - pollOnce(); - setInterval(pollOnce, POLL_MS); -})(); diff --git a/frontend/static/form_editor.js b/frontend/static/form_editor.js deleted file mode 100644 index d11a9e1..0000000 --- a/frontend/static/form_editor.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Phase 4 — Form field editor - * Reads/writes schema to #fields-json hidden input as JSON. - * Doesn't depend on any framework. Pure DOM. - */ -(function () { - const container = document.getElementById('form-fields'); - const addTextBtn = document.getElementById('add-text'); - const addDropdownBtn = document.getElementById('add-dropdown'); - const out = document.getElementById('fields-json'); - if (!container || !out) return; - - function indexRows() { - return Array.from(container.querySelectorAll('.form-row')); - } - - function rowField(row) { - const data = {}; - row.querySelectorAll('[data-bind]').forEach((el) => { - const k = el.dataset.bind; - if (el.type === 'checkbox') data[k] = !!el.checked; - else data[k] = (el.value || '').trim(); - }); - return data; - } - - function serialize() { - const fields = indexRows().map(rowField); - out.value = JSON.stringify(fields); - } - - function toggleDropdownVisibility(row) { - const isDrop = row.querySelector('[data-bind="type"]').value === 'dropdown'; - const opts = row.querySelector('.form-row-options'); - if (opts) opts.hidden = !isDrop; - } - - function slugify(s) { - return (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '').slice(0, 40); - } - - function nextKey() { - const existing = new Set(indexRows().map(r => rowField(r).key).filter(Boolean)); - let base = 'field_1'; - let n = 1; - while (existing.has(base)) { n++; base = `field_${n}`; } - return base; - } - - function makeRow(type, label) { - const row = document.createElement('div'); - row.className = 'form-row'; - row.innerHTML = ` -
    - ${label || 'New field'} - -
    -
    - - - - -
    - - - `; - // Auto-generate a key from the labelled thing. - const labelInput = row.querySelector('[data-bind="label"]'); - const keyInput = row.querySelector('[data-bind="key"]'); - labelInput.value = label || ''; - keyInput.value = nextKey(); - toggleDropdownVisibility(row); - - // Live update row title. - labelInput.addEventListener('input', () => { - row.querySelector('.row-label-text').textContent = - labelInput.value || keyInput.value || 'New field'; - }); - - // Remove. - row.querySelector('[data-action="remove"]').addEventListener('click', () => { - row.remove(); - serialize(); - }); - - // Any input change → serialize. - row.querySelectorAll('[data-bind]').forEach((el) => { - el.addEventListener('input', serialize); - el.addEventListener('change', serialize); - }); - - return row; - } - - function add(type) { - const row = makeRow(type, type === 'dropdown' ? 'New dropdown' : 'New field'); - container.appendChild(row); - serialize(); - } - - addTextBtn.addEventListener('click', () => add('text')); - addDropdownBtn.addEventListener('click', () => add('dropdown')); - - // Existing rows: wire remove + serialize hooks. - indexRows().forEach((row) => { - const keyInput = row.querySelector('[data-bind="key"]'); - const labelInput = row.querySelector('[data-bind="label"]'); - if (labelInput) { - labelInput.addEventListener('input', () => { - row.querySelector('.row-label-text').textContent = - labelInput.value || (keyInput && keyInput.value) || 'Field'; - }); - } - row.querySelector('[data-action="remove"]').addEventListener('click', () => { - row.remove(); - serialize(); - }); - row.querySelectorAll('[data-bind]').forEach((el) => { - el.addEventListener('input', serialize); - el.addEventListener('change', serialize); - }); - toggleDropdownVisibility(row); - }); - - // Submit-time serialize is also automatic via input hooks, but ensure. - document.getElementById('form-editor').addEventListener('submit', serialize); - - // Initial serialize so hidden input reflects loaded state. - serialize(); -})(); \ No newline at end of file diff --git a/frontend/static/help_status.js b/frontend/static/help_status.js deleted file mode 100644 index 1b20ad2..0000000 --- a/frontend/static/help_status.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Phase 6 — Inline status changes for help flags - * Two targets: - * - Admin: status-btn with [data-admin][data-hid][data-status] - * - Participant: status-btn with [data-slug][data-hid][data-status] - * Both POST as application/x-www-form-urlencoded to //status. - */ -(function () { - function setActive(parent, status) { - parent.querySelectorAll('.status-btn').forEach(function (b) { - if (b.dataset.status === status) b.classList.add('active'); - else b.classList.remove('active'); - }); - var pill = parent.querySelector('.status-pill'); - if (pill) { - pill.className = 'status-pill status-' + status; - pill.textContent = status.replace('_', ' '); - } - } - - function send(payload, url) { - return fetch(url, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }).then(function (resp) { - if (!resp.ok) throw new Error('HTTP ' + resp.status); - return resp; - }); - } - - document.addEventListener('click', function (ev) { - var btn = ev.target.closest('.status-btn'); - if (!btn) return; - ev.preventDefault(); - var status = btn.dataset.status; - var card = btn.closest('.help-card'); - if (btn.dataset.admin) { - send( - { status: status }, - '/admin/' + btn.dataset.admin + '/help/' + btn.dataset.hid + '/status' - ) - .then(function () { setActive(card, status); }) - .catch(function () { alert('Could not update status.'); }); - } else if (btn.dataset.slug) { - send( - { status: status }, - '/w/' + btn.dataset.slug + '/me/help/' + btn.dataset.hid + '/status' - ) - .then(function () { setActive(card, status); }) - .catch(function () { alert('Could not update status.'); }); - } - }); -})(); \ No newline at end of file diff --git a/frontend/static/style.css b/frontend/static/style.css deleted file mode 100644 index e92843a..0000000 --- a/frontend/static/style.css +++ /dev/null @@ -1,851 +0,0 @@ -/* Workshop Helmsman — single stylesheet */ -/* Design tokens & modern visual system */ - -:root { - /* Color tokens */ - --color-bg: #0b0f1a; - --color-surface: #13182a; - --color-surface-raised: #1a2035; - --color-surface-overlay: #222842; - --color-border: #2a314a; - --color-border-strong: #3a4260; - --color-text: #e8ecf5; - --color-text-muted: #8a93b3; - --color-text-subtle: #6a729a; - --color-accent: #7aa2ff; - --color-accent-strong: #5069d6; - --color-accent-soft: rgba(122, 162, 255, 0.15); - --color-live: #44d39a; - --color-live-soft: rgba(68, 211, 154, 0.15); - --color-warn: #e4c44a; - --color-warn-soft: rgba(228, 196, 74, 0.15); - --color-danger: #ef6a6a; - --color-danger-soft: rgba(239, 106, 106, 0.15); - --color-info: #6ab8ff; - --color-info-soft: rgba(106, 184, 255, 0.15); - - /* Spacing scale (8px base) */ - --space-1: 4px; - --space-2: 8px; - --space-3: 12px; - --space-4: 16px; - --space-5: 24px; - --space-6: 32px; - --space-7: 48px; - --space-8: 64px; - - /* Border radius scale */ - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; - --radius-xl: 16px; - --radius-pill: 999px; - - /* Elevation shadows */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.4); - --shadow-xl: 0 20px 48px rgba(0, 0, 0, 0.5); - - /* Typography */ - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - --text-xs: clamp(0.7rem, 0.65rem + 0.25vw, 0.78rem); - --text-sm: clamp(0.82rem, 0.78rem + 0.2vw, 0.9rem); - --text-base: clamp(0.95rem, 0.9rem + 0.25vw, 1.05rem); - --text-lg: clamp(1.1rem, 1.02rem + 0.4vw, 1.3rem); - --text-xl: clamp(1.35rem, 1.2rem + 0.75vw, 1.75rem); - --text-2xl: clamp(1.75rem, 1.5rem + 1.25vw, 2.5rem); - - /* Transitions */ - --transition-fast: 120ms ease; - --transition-normal: 200ms ease; - --transition-slow: 350ms ease; - - /* Breakpoints */ - --bp-sm: 480px; - --bp-md: 720px; - --bp-lg: 1024px; - --bp-xl: 1440px; - - /* Focus ring */ - --focus-ring: 0 0 0 3px var(--color-accent-soft); - --focus-ring-offset: 2px; -} - -@media (prefers-reduced-motion: reduce) { - :root { - --transition-fast: 0ms; - --transition-normal: 0ms; - --transition-slow: 0ms; - } - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - scroll-behavior: auto !important; - } -} - -/* Reset & base */ -*, *::before, *::after { box-sizing: border-box; } - -html, body { - margin: 0; padding: 0; - background: var(--color-bg); - color: var(--color-text); - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: 1.55; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { color: var(--color-accent); text-decoration: none; } -a:hover { text-decoration: underline; } -a:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); } - -code, pre { - font-family: var(--font-mono); - font-size: 0.9em; - background: var(--color-surface-overlay); - border-radius: var(--radius-sm); - padding: 2px 6px; -} - -code.url-example { - display: inline-block; - padding: 6px 10px; - background: var(--color-surface-overlay); - border: 1px dashed var(--color-border); -} - -/* Topbar */ -.topbar { - display: flex; align-items: center; justify-content: space-between; - padding: var(--space-4) var(--space-5); - background: var(--color-surface); - border-bottom: 1px solid var(--color-border); - position: sticky; top: 0; z-index: 100; - gap: var(--space-4); - flex-wrap: wrap; -} -.brand { display: inline-flex; align-items: center; gap: var(--space-2); color: var(--color-text); font-weight: 600; font-size: var(--text-lg); } -.brand-mark { font-size: 1.5em; line-height: 1; } -.brand-name { letter-spacing: 0.02em; } -.topnav { display: inline-flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } - -/* Pill / button */ -.pill { - display: inline-flex; align-items: center; gap: var(--space-1); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-pill); - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - min-height: 44px; - transition: background var(--transition-fast), border-color var(--transition-fast), transform 50ms ease; -} -.pill:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } -.pill:active { transform: scale(0.98); } -.pill:focus-visible { outline: none; box-shadow: var(--focus-ring); } - -.btn { - display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: var(--text-sm); - font-weight: 500; - font-family: inherit; - cursor: pointer; - min-height: 44px; - min-width: 44px; - transition: background var(--transition-fast), border-color var(--transition-fast), transform 50ms ease, box-shadow var(--transition-fast); -} -.btn:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } -.btn:active { transform: translateY(1px); } -.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } -.btn-primary { background: var(--color-accent-strong); border-color: var(--color-accent-strong); color: white; } -.btn-primary:hover { background: var(--color-accent); border-color: var(--color-accent); } -.btn-small { padding: var(--space-1) var(--space-3); font-size: var(--text-xs); min-height: 36px; } -.btn-ghost { background: transparent; } -.btn-ghost:hover { background: var(--color-surface-overlay); } -.btn-danger { background: var(--color-danger-soft); border-color: var(--color-danger); color: var(--color-danger); } -.btn-danger:hover { background: var(--color-danger); border-color: var(--color-danger); color: white; } -.btn:disabled, .btn[disabled] { opacity: 0.5; cursor: not-allowed; transform: none; } - -/* Container & layout */ -.container { width: 100%; max-width: 100%; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); } -.hero { padding: var(--space-6) 0 var(--space-3); } -.hero h1 { font-size: var(--text-2xl); margin: 0 0 var(--space-2); font-weight: 700; } -.lede { color: var(--color-text-muted); max-width: 65ch; font-size: var(--text-base); line-height: 1.6; } - -.cta-row { display: flex; gap: var(--space-3); flex-wrap: wrap; margin-top: var(--space-4); } - -/* Cards */ -.card { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-5); - margin: var(--space-4) 0; - box-shadow: var(--shadow-md); - transition: box-shadow var(--transition-normal), border-color var(--transition-normal); -} -.card:hover { box-shadow: var(--shadow-lg); border-color: var(--color-border-strong); } -.card.narrow { max-width: 560px; margin-left: auto; margin-right: auto; } -.card-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-4); } -.card h1, .card h2, .card h3 { margin-top: 0; margin-bottom: var(--space-3); } -.card h1 { font-size: var(--text-xl); font-weight: 700; } -.card h2 { font-size: var(--text-lg); font-weight: 600; } -.card h3 { font-size: var(--text-base); font-weight: 600; } - -/* Grid system */ -.grid { display: grid; gap: var(--space-4); } -.grid.two { grid-template-columns: repeat(2, 1fr); } -@media (max-width: 720px) { .grid.two { grid-template-columns: 1fr; } } - -/* Link row */ -.link-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); margin: var(--space-3) 0; } -@media (max-width: 720px) { .link-row { grid-template-columns: 1fr; } } -.big-link { - display: inline-block; - padding: var(--space-3) var(--space-4); - font-size: var(--text-sm); - font-family: var(--font-mono); - background: var(--color-surface-raised); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - word-break: break-all; - transition: background var(--transition-fast), border-color var(--transition-fast); -} -.big-link:hover { background: var(--color-accent-soft); border-color: var(--color-accent); text-decoration: none; } - -/* Form stack */ -.stack { display: flex; flex-direction: column; gap: var(--space-4); max-width: 720px; } -.stack label { display: flex; flex-direction: column; gap: var(--space-1); } -.stack label span { font-weight: 500; font-size: var(--text-sm); } -.stack input[type="text"], -.stack input[type="number"], -.stack input[type="email"], -.stack textarea, -.stack select { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - color: var(--color-text); - padding: var(--space-3) var(--space-4); - border-radius: var(--radius-md); - font-family: inherit; - font-size: var(--text-base); - min-height: 44px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background var(--transition-fast); -} -.stack textarea { resize: vertical; font-family: var(--font-mono); min-height: 80px; } -.stack input:focus, .stack textarea:focus, .stack select:focus { - outline: none; - border-color: var(--color-accent); - box-shadow: var(--focus-ring); - background: var(--color-surface-overlay); -} -.stack input[aria-invalid="true"], -.stack textarea[aria-invalid="true"], -.stack select[aria-invalid="true"] { - border-color: var(--color-danger); - box-shadow: 0 0 0 3px var(--color-danger-soft); -} -.stack .field-hint { font-size: var(--text-xs); color: var(--color-text-subtle); } -.stack .field-error { font-size: var(--text-xs); color: var(--color-danger); display: none; } -.stack input[aria-invalid="true"] + .field-error, -.stack select[aria-invalid="true"] + .field-error, -.stack textarea[aria-invalid="true"] + .field-error { display: block; } - -/* Tables */ -.table { width: 100%; border-collapse: collapse; } -.table th, .table td { text-align: left; padding: var(--space-3) var(--space-2); border-bottom: 1px solid var(--color-border); vertical-align: top; } -.table thead th { font-size: var(--text-xs); text-transform: uppercase; color: var(--color-text-subtle); letter-spacing: 0.05em; font-weight: 600; } -.table.compact td, .table.compact th { padding: var(--space-2) var(--space-2); } -.table tbody tr { transition: background var(--transition-fast); } -.table tbody tr:hover { background: var(--color-surface-raised); } - -/* Badges */ -.badge { - display: inline-flex; align-items: center; - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 600; - background: var(--color-surface-raised); - border: 1px solid var(--color-border); -} -.badge-live { color: #062a1d; background: var(--color-live-soft); border-color: var(--color-live); } -.badge-expired { color: #2a1b07; background: rgba(214, 140, 68, 0.15); border-color: var(--color-warn); } -.badge-archived { color: var(--color-text-muted); background: var(--color-surface-raised); border-color: var(--color-border); } -.badge-paused { color: #1a2a3a; background: var(--color-info-soft); border-color: var(--color-info); } - -/* Chips */ -.chip { - display: inline-flex; align-items: center; gap: var(--space-1); - padding: var(--space-1) var(--space-2); - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - color: var(--color-text-muted); - white-space: nowrap; -} -.chip-done { background: var(--color-accent-soft); color: var(--color-accent); border-color: var(--color-accent); } -.chip-current { background: var(--color-live-soft); color: var(--color-live); border-color: var(--color-live); font-weight: 600; } - -/* Progress bars */ -.progress { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-4); } - -.bar { - width: 100%; - height: 10px; - background: var(--color-surface-overlay); - border: 1px solid var(--color-border); - border-radius: var(--radius-pill); - overflow: hidden; -} -.bar.small { height: 6px; } -.bar-fill { - height: 100%; - background: linear-gradient(90deg, var(--color-accent-strong), var(--color-accent)); - border-radius: var(--radius-pill); - transition: width var(--transition-slow); - position: relative; -} -.bar-fill::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent); - animation: shimmer 2s infinite; -} -@keyframes shimmer { - 0% { transform: translateX(-100%); } - 100% { transform: translateX(100%); } -} -@media (prefers-reduced-motion: reduce) { - .bar-fill::after { animation: none; } - .bar-fill { transition: width 0ms; } -} - -/* Milestones */ -.milestones { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-3); } -.milestone { - display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-3); - padding: var(--space-4); - border-radius: var(--radius-md); - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - transition: all var(--transition-normal); -} -.milestone:hover { border-color: var(--color-border-strong); background: var(--color-surface-overlay); } -.milestone-done { opacity: 0.85; border-color: var(--color-accent-soft); background: var(--color-accent-soft); } -.milestone-current { border-color: var(--color-live); background: var(--color-live-soft); } -.milestone-body { display: flex; flex-direction: column; gap: var(--space-1); flex: 1; min-width: 0; } -.milestone-title { font-weight: 600; font-size: var(--text-base); } -.milestone-desc { color: var(--color-text-muted); font-size: var(--text-sm); } -.milestone-time { font-size: var(--text-xs); color: var(--color-text-subtle); } -.milestone-actions { flex-shrink: 0; display: flex; gap: var(--space-2); } -.milestone-checkbox { - width: 22px; height: 22px; - border: 2px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface-overlay); - appearance: none; - cursor: pointer; - transition: all var(--transition-fast); - flex-shrink: 0; - margin-top: 2px; -} -.milestone-checkbox:checked { - background: var(--color-live); - border-color: var(--color-live); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 14px; -} -.milestone-checkbox:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); } - -/* Leaderboard */ -.leaderboard { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); } -.leaderboard-row { - display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); - padding: var(--space-3) var(--space-4); - background: var(--color-surface-raised); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - transition: all var(--transition-fast); -} -.leaderboard-row:hover { border-color: var(--color-border-strong); } -.leaderboard-me { border-color: var(--color-accent); background: var(--color-accent-soft); } -.leaderboard-name { font-weight: 500; min-width: 0; } -.leaderboard-progress { display: inline-flex; align-items: center; gap: var(--space-2); flex-shrink: 0; } -.leaderboard-progress .bar { width: 120px; } - -/* Lists */ -.plain-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); } - -/* Help cards */ -.help-card { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - transition: border-color var(--transition-fast); -} -.help-card:hover { border-color: var(--color-border-strong); } -.help-meta { display: flex; align-items: baseline; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-2); } -.help-meta strong { font-weight: 600; } -.help-meta .muted { color: var(--color-text-subtle); font-size: var(--text-xs); } -.help-body { white-space: pre-wrap; color: var(--color-text); font-size: var(--text-sm); line-height: 1.6; } -.status-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; margin-top: var(--space-2); } -.status-controls label { font-size: var(--text-xs); color: var(--color-text-subtle); } -.status-btn { - background: transparent; - border: 1px solid var(--color-border); - color: var(--color-text-muted); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - cursor: pointer; - font-size: var(--text-xs); - font-weight: 500; - transition: all var(--transition-fast); -} -.status-btn:hover { border-color: var(--color-accent); color: var(--color-accent); } -.status-btn.active.status-btn-open { background: var(--color-warn-soft); border-color: var(--color-warn); color: var(--color-warn); } -.status-btn.active.status-btn-on_hold { background: var(--color-info-soft); border-color: var(--color-info); color: var(--color-info); } -.status-btn.active.status-btn-resolved { background: var(--color-live-soft); border-color: var(--color-live); color: var(--color-live); } - -/* Stat row */ -.stat-row { display: flex; gap: var(--space-6); flex-wrap: wrap; } -.stat-num { font-size: var(--text-2xl); font-weight: 700; line-height: 1; } -.stat-label { color: var(--color-text-muted); font-size: var(--text-sm); } - -/* Filter bar */ -.filter-bar { display: flex; align-items: center; gap: var(--space-3); margin: var(--space-4) 0; flex-wrap: wrap; } -.filter-bar input[type="text"] { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - color: var(--color-text); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - font-family: inherit; - font-size: var(--text-sm); - min-width: 220px; - min-height: 44px; - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.filter-bar input[type="text"]:focus { outline: none; border-color: var(--color-accent); box-shadow: var(--focus-ring); } - -/* Cohort stacked bar */ -.stacked-bar-container { - display: flex; - height: 32px; - border-radius: var(--radius-md); - overflow: hidden; - border: 1px solid var(--color-border); - margin-bottom: var(--space-3); -} -.stacked-bar-segment { - display: flex; align-items: center; justify-content: center; - min-width: 2px; - transition: width var(--transition-slow); -} -.stacked-bar-label { - color: white; - font-size: var(--text-xs); - font-weight: 600; - text-shadow: 0 1px 2px rgba(0,0,0,0.4); - pointer-events: none; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding: 0 var(--space-2); -} -.stacked-bar-legend { - display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-4); - margin-top: var(--space-2); -} -.stacked-legend-item { - display: inline-flex; align-items: center; gap: var(--space-1); - font-size: var(--text-sm); - color: var(--color-text-muted); -} -.stacked-legend-swatch { - display: inline-block; - width: 12px; height: 12px; - border-radius: var(--radius-sm); - flex-shrink: 0; -} - -/* Timeline */ -.timeline { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0; } -.timeline-item { - display: flex; align-items: flex-start; gap: var(--space-4); - padding: var(--space-3) 0; - border-bottom: 1px solid var(--color-border); -} -.timeline-item:last-child { border-bottom: none; } -.timeline-icon { - width: 32px; height: 32px; - border-radius: 50%; - display: flex; align-items: center; justify-content: center; - font-size: var(--text-xs); - font-weight: 700; - flex-shrink: 0; - margin-top: 2px; -} -.timeline-icon-done { background: var(--color-accent-soft); color: var(--color-accent); border: 1px solid var(--color-accent); } -.timeline-icon-help { background: var(--color-warn-soft); color: var(--color-warn); border: 1px solid var(--color-warn); } -.timeline-body { display: flex; flex-direction: column; gap: var(--space-1); } - -/* Form field editor */ -.form-row { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - margin-bottom: var(--space-3); - background: var(--color-surface-raised); -} -.form-row-head { - display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--space-3); -} -.btn-row-remove { - background: transparent; border: 0; color: var(--color-danger); - cursor: pointer; font-size: 1.25rem; line-height: 1; - padding: var(--space-1); - min-height: 44px; min-width: 44px; - transition: color var(--transition-fast), transform 50ms ease; -} -.btn-row-remove:hover { color: #ff6b6b; transform: scale(1.1); } -.form-row-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); - gap: var(--space-3); -} -.form-row-grid label { display: flex; flex-direction: column; font-size: var(--text-sm); } -.form-row-grid label.chk { flex-direction: row; align-items: center; gap: var(--space-2); } -.form-row-options { margin-top: var(--space-3); } -.form-row-options textarea { width: 100%; font-family: var(--font-mono); min-height: 60px; } -.form-actions { display: flex; gap: var(--space-2); margin: var(--space-4) 0; flex-wrap: wrap; } - -/* Template grid */ -.template-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: var(--space-4); -} -.template-card { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - background: var(--color-surface-raised); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); -} -.template-card:hover { border-color: var(--color-accent); box-shadow: var(--shadow-md); } -.template-card h3 { margin: 0 0 var(--space-1); font-size: var(--text-base); } -.template-fields { - background: var(--color-surface-overlay); - border-radius: var(--radius-sm); - padding: var(--space-2) var(--space-3); - font-size: var(--text-xs); - margin: var(--space-2) 0; - white-space: pre-wrap; - max-height: 120px; - overflow: auto; -} -.template-card-actions { display: flex; gap: var(--space-2); flex-wrap: wrap; } - -/* Key-value */ -dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: var(--space-2) var(--space-4); margin: 0; } -dl.kv dt { font-weight: 600; color: var(--color-text-subtle); } -dl.kv dd { margin: 0; word-break: break-word; } - -/* Console-specific styles */ -.console-header { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: var(--space-4); - margin-bottom: var(--space-6); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--color-border); -} -.console-header-inner { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: var(--space-4); - width: 100%; -} -.console-header h1 { margin: 0; font-size: var(--text-2xl); } -.console-subtitle { - color: var(--color-text-muted); - margin: var(--space-2) 0 0; - font-size: var(--text-base); -} - -.console-workshops { margin-top: var(--space-6); } -.console-table-wrapper { - overflow-x: auto; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border); - background: var(--color-surface); -} -.console-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} -.console-table th, -.console-table td { - padding: var(--space-3) var(--space-4); - text-align: left; - border-bottom: 1px solid var(--color-border); -} -.console-table th { - background: var(--color-surface-raised); - font-weight: 600; - color: var(--color-text-muted); - font-size: var(--text-xs); - text-transform: uppercase; - letter-spacing: 0.05em; -} -.console-table tbody tr { transition: background var(--transition-fast); } -.console-table tbody tr:hover { background: var(--color-surface-overlay); } -.console-table tbody tr:last-child td { border-bottom: none; } -.console-row-archived { opacity: 0.6; } -.console-row-archived td { background: var(--color-surface-overlay) !important; } -.console-row-expired { opacity: 0.8; } - -.status-badge { - display: inline-flex; - align-items: center; - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 600; - text-transform: capitalize; -} -.status-live { background: var(--color-accent-soft); color: var(--color-accent); } -.status-expired { background: var(--color-warn-soft); color: var(--color-warn); } -.status-archived { background: var(--color-muted-soft); color: var(--color-muted); } - -.console-links { display: flex; gap: var(--space-2); } -.link-admin, .link-participant { - display: inline-flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-1) var(--space-3); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - font-weight: 500; - border: 1px solid var(--color-border); - background: var(--color-surface-raised); - color: var(--color-text); - min-height: 32px; - transition: all var(--transition-fast); -} -.link-admin:hover, .link-participant:hover { - background: var(--color-accent-soft); - border-color: var(--color-accent); - text-decoration: none; -} -.link-icon { font-size: 0.9em; } - -.console-empty { - margin-top: var(--space-8); -} -.empty-state { - text-align: center; - padding: var(--space-10) var(--space-4); - background: var(--color-surface-raised); - border: 1px dashed var(--color-border); - border-radius: var(--radius-lg); -} -.empty-icon { font-size: 4rem; line-height: 1; margin-bottom: var(--space-4); } -.empty-state h2 { margin: 0 0 var(--space-2); font-size: var(--text-xl); } -.empty-state p { color: var(--color-text-muted); margin: 0; } - -/* Console workshop new page */ -.page-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: var(--space-4); - margin-bottom: var(--space-6); -} -.page-header h1 { margin: 0; font-size: var(--text-2xl); } -.page-header .muted { margin: var(--space-1) 0 0; } - -.workshop-create-form { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-6); -} - -.form-section { - margin-bottom: var(--space-8); - padding-bottom: var(--space-6); - border-bottom: 1px solid var(--color-border); -} -.form-section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } -.form-section h2 { font-size: var(--text-lg); margin: 0 0 var(--space-1); } -.form-section .muted { font-size: var(--text-sm); margin-bottom: var(--space-4); } - -.field-row { display: flex; gap: var(--space-4); flex-wrap: wrap; } -.field-row .field { flex: 1; min-width: 200px; } -.field-narrow { max-width: 140px; } - -.template-picker { display: flex; gap: var(--space-3); align-items: flex-end; flex-wrap: wrap; margin-bottom: var(--space-4); } -.template-picker .field { flex: 1; min-width: 240px; } -.template-picker .field-select { width: 100%; } - -.milestone-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-3); } -.milestone-item { - display: flex; - align-items: flex-start; - gap: var(--space-3); - background: var(--color-card); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--space-3); -} -.milestone-item.dragging { opacity: 0.5; border-color: var(--color-accent); } -.drag-handle { - cursor: grab; - color: var(--color-muted); - font-size: 1.2rem; - line-height: 1; - padding-top: 2px; - user-select: none; -} -.drag-handle:active { cursor: grabbing; } - -.milestone-fields { flex: 1; display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; } -.field-inline { display: flex; flex-direction: column; gap: 2px; } -.field-inline .field-label { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); font-weight: 600; } -.field-inline input { width: 100%; } -.field-description input { font-size: 0.875rem; } -.field-tip input { font-size: 0.8rem; background: var(--bg-soft); } - -.btn-icon { - background: none; - border: none; - color: var(--muted); - font-size: 1.25rem; - line-height: 1; - padding: 2px 6px; - cursor: pointer; - border-radius: var(--radius); -} -.btn-icon:hover { color: var(--danger); background: var(--danger-soft); } -.milestone-item:hover .btn-delete { opacity: 1; } -.btn-delete { opacity: 0; transition: opacity 0.15s; } - -.btn-lg { padding: var(--space-3) var(--space-6); font-size: var(--text-base); min-height: 52px; } -.btn-block { width: 100%; } - -.form-actions { - display: flex; - gap: var(--space-3); - justify-content: flex-end; - padding-top: var(--space-4); - border-top: 1px solid var(--color-border); - margin-top: var(--space-4); -} - -/* Milestone drag states */ -.milestone-item.dragging { opacity: 0.4; } -.milestone-item { transition: opacity 0.15s ease; } - -@keyframes slideDown { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } -} - -/* Card aside */ -.card-aside { - border: 1px dashed var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin: var(--space-4) 0; - background: var(--color-surface-raised); -} -.warning { color: var(--color-warn); } -.required { color: var(--color-danger); font-style: normal; } - -/* Broadcast banner */ -.broadcast-banner { - background: var(--color-info-soft); - border: 1px solid var(--color-info); - border-radius: var(--radius-md); - padding: var(--space-3) var(--space-4); - margin-bottom: var(--space-4); - display: flex; align-items: flex-start; gap: var(--space-3); - animation: slideDown var(--transition-normal); -} -@keyframes slideDown { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } -} -@media (prefers-reduced-motion: reduce) { - .broadcast-banner { animation: none; } -} -.broadcast-banner .broadcast-icon { flex-shrink: 0; margin-top: 2px; color: var(--color-info); } -.broadcast-banner .broadcast-content { flex: 1; min-width: 0; } -.broadcast-banner .broadcast-title { font-weight: 600; margin-bottom: var(--space-1); } -.broadcast-banner .broadcast-message { color: var(--color-text); font-size: var(--text-sm); } -.broadcast-banner .broadcast-dismiss { - flex-shrink: 0; - background: transparent; border: 1px solid var(--color-border); - color: var(--color-text-muted); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-pill); - font-size: var(--text-xs); - cursor: pointer; - transition: all var(--transition-fast); - min-height: 36px; -} -.broadcast-banner .broadcast-dismiss:hover { border-color: var(--color-accent); color: var(--color-accent); } - -/* Help confirm (2-step) */ -.help-confirm { - background: var(--color-surface-raised); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - margin-top: var(--space-4); - animation: slideDown var(--transition-normal); -} -.help-confirm h3 { margin-top: 0; margin-bottom: var(--space-3); } -.llm-suggestion { - border-left: 3px solid var(--color-accent); - background: var(--color-accent-soft); - padding: var(--space-3) var(--space-4); - margin: var(--space-3) 0 var(--space-4); - border-radius: var(--radius-sm); - font-size: var(--text-base); - line-height: 1.6; -} -.help-confirm details { border-top: 1px solid var(--color-border); padding-top: var(--space-3); } -.help-confirm summary { cursor: pointer; font-weight: 500; color: var(--color-text-muted); } -.help-confirm summary:hover { color: var(--color-text); } -.row-actions { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; margin-top \ No newline at end of file diff --git a/frontend/templates/_form_field_row.html b/frontend/templates/_form_field_row.html deleted file mode 100644 index b5b8cd9..0000000 --- a/frontend/templates/_form_field_row.html +++ /dev/null @@ -1,19 +0,0 @@ -
    -
    - {{ f.label or f.key }} - -
    -
    - - - - -
    -
    - -
    - -
    \ No newline at end of file diff --git a/frontend/templates/_stubs.html b/frontend/templates/_stubs.html deleted file mode 100644 index 16030b3..0000000 --- a/frontend/templates/_stubs.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% block title %}Coming soon · Helmsman{% endblock %} -{% block content %} -
    - Coming in Phase 2 -

    {{ label }}

    -

    This surface is intentionally stubbed for Phase 1 to keep the demo tight. - The real implementation lands in Phase 2 alongside CSV exports, - clone-this-workshop, and edit-after-create.

    - Back to dashboard -
    -{% endblock %} diff --git a/frontend/templates/admin_dashboard.html b/frontend/templates/admin_dashboard.html deleted file mode 100644 index feee4be..0000000 --- a/frontend/templates/admin_dashboard.html +++ /dev/null @@ -1,300 +0,0 @@ -{% extends "base.html" %} -{% block title %}Admin Dashboard — {{ workshop.name }} — Workshop Helmsman{% endblock %} -{% block content %} -
    -
    -

    {{ workshop.name }}

    - -
    -

    Your workshop command center

    -
    - -
    - -
    -
    -
    -

    Participants

    -

    Joined & active

    -
    -
    -
    {{ participant_count }}
    -
    {{ active_now }} active now
    -
    -
    - -
    -
    -

    Progress

    -

    Average completion

    -
    -
    -
    {{ avg_completion }}%
    -
    of {{ milestones|length }} milestones
    -
    -
    - -
    -
    -

    Help Requests

    -

    Needing your attention

    -
    -
    -
    {{ help_requests_count }}
    -
    open requests
    -
    -
    -
    - - -
    - - - - - -
    - - -
    - - -
    -

    Participants

    -
    - - - - - - - - - - - {% for p in participants %} - - - - - - - {% endfor %} - -
    NameJoinedProgressActions
    {{ p.name }}{{ p.joined_at.strftime('%H:%M') }} -
    -
    -
    - {{ p.progress }}% -
    - - -
    -
    -
    - - -
    -

    Milestones

    -
    - - -
    -
    - {% for m in milestones %} -
    -
    -

    {{ m.title }}

    - - {{ m.category|capitalize }} - -
    -
    -

    {{ m.description }}

    - {% if m.help_tip %} -

    {{ m.help_tip }}

    - {% endif %} -
    - -
    - {% endfor %} -
    -
    - - -
    -

    Broadcast Message

    -
    -
    - - -
    -
    - -
    - -
    -
    -

    Recent Broadcasts

    -
    -

    Welcome to the workshop! Let's get started.

    - Just now -
    -
    -
    - - -
    -

    Help Requests

    -
    - {% for hr in help_requests %} -
    -
    -

    {{ hr.participant_name }}

    - {{ hr.status|upper }} -
    -
    -

    {{ hr.message }}

    -
    - -
    - {% endfor %} -
    -
    - - -
    -

    Export Data

    -

    Download participant data for analysis.

    -
    - - -
    -
    - -
    -
    - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_edit.html b/frontend/templates/admin_edit.html deleted file mode 100644 index 90ed717..0000000 --- a/frontend/templates/admin_edit.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% block title %}Edit: {{ workshop.name }}{% endblock %} -{% block content %} -
    -

    Edit workshop

    -

    Changes apply immediately. Existing milestone completion records keep the milestone title they were submitted with (audit fidelity).

    - -
    - - -
    - Agenda templates -

    Pick a template to replace the milestones below.

    - -
    - - - - - - - -
    - - Cancel -
    -
    -
    - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_form.html b/frontend/templates/admin_form.html deleted file mode 100644 index f7f8a95..0000000 --- a/frontend/templates/admin_form.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "base.html" %} -{% block title %}Form · {{ workshop.name }}{% endblock %} -{% block topnav %} - ← Back to dashboard - Load template -{% endblock %} -{% block content %} -
    -

    Participant form

    -

    Configure what attendees enter when joining {{ workshop.name }}.

    -

    Snapshot is saved with this workshop — editing a saved template later won't change historical snapshots.

    - -
    -
    - {% for f in form_schema %}{% include "_form_field_row.html" %}{% endfor %} -
    - -
    - - -
    - - - -
    - Save as template (optional) - - If filled, the field schema above is also stored as a new reusable template. -
    - - -
    -
    - -{% raw %} - -{% endraw %} - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_form_template.html b/frontend/templates/admin_form_template.html deleted file mode 100644 index 430cb10..0000000 --- a/frontend/templates/admin_form_template.html +++ /dev/null @@ -1,35 +0,0 @@ -{% extends "base.html" %} -{% block title %}Load template · {{ workshop.name }}{% endblock %} -{% block topnav %} - ← Back to dashboard - Edit form manually -{% endblock %} -{% block content %} -
    -

    Apply a saved form template

    -

    Replace this workshop's participant form with a saved template.

    -

    ⚠ This will overwrite the current form snapshot for {{ workshop.name }}. Existing join data is preserved.

    - - {% if templates %} -
    - {% for t in templates %} -
    -

    {{ t.name }}

    -

    {{ t._field_count }} fields · created {{ t.created_at.strftime('%Y-%m-%d') }}

    -
    [
    -{%- for f in t.fields() -%}
    -{{"\n"}}{% if loop.first %}"{% else %}"{% endif %}{{ f.label }} ({{ f.type }}{% if f.required %}*{% endif %}){% if not loop.last %},{% endif %}
    -{%- endfor -%}
    -]
    -
    - -
    -
    - {% endfor %} -
    - {% else %} -

    No saved templates yet. Create one on the form editor by adding fields and using the "Save as template" section.

    - {% endif %} -
    -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_new.html b/frontend/templates/admin_new.html deleted file mode 100644 index 0bcb5d6..0000000 --- a/frontend/templates/admin_new.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}New workshop{% endblock %} -{% block content %} -
    -

    Create a new workshop

    - -
    - - -
    - Agenda templates -

    Pick a template to pre-fill milestones, or write your own below.

    - -
    - - - -
    - Participant form -

    What information do you collect from each attendee?

    - -
    -
    - default - display_name (text, required) -
    -
    - - - -

    Phase 4 default form is just a display_name field. Full field editor lives on the workshop's form page after creation.

    - - - - -
    - - -
    -
    - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_template_edit.html b/frontend/templates/admin_template_edit.html deleted file mode 100644 index a6adc22..0000000 --- a/frontend/templates/admin_template_edit.html +++ /dev/null @@ -1,32 +0,0 @@ -{% extends "base.html" %} -{% block title %}Edit template · {{ template_obj.name }}{% endblock %} -{% block topnav %} - ← All templates -{% endblock %} -{% block content %} -
    -

    Template: {{ template_obj.name }}

    -

    Edited here changes this template for FUTURE workshops. Existing workshops that already loaded it keep their frozen snapshot.

    - -
    - - -

    Fields

    -
    -{% for f in form_schema %}{% include "_form_field_row.html" %}{% endfor %} -
    - -
    - - -
    - - - -
    -
    - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/admin_templates.html b/frontend/templates/admin_templates.html deleted file mode 100644 index a44dc43..0000000 --- a/frontend/templates/admin_templates.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "base.html" %} -{% block title %}Form templates · Helmsman{% endblock %} -{% block content %} -
    -

    Form templates

    -

    Reusable participant form schemas. Editing a template only affects future workshops that load it.

    - - {% if templates_list %} -
    - {% for t in templates_list %} -
    -

    {{ t.name }}

    -

    - {{ t._field_count }} fields · - created {{ t.created_at.strftime('%Y-%m-%d') }} · - used in {{ t._usage_count }} workshop(s) -

    -
    [
    -{%- for f in t.fields() -%}
    -{{"\n"}}{% if loop.first %}"{% else %}"{% endif %}{{ f.label }} ({{ f.type }}{% if f.required %}*{% endif %}){% if not loop.last %},{% endif %}
    -{%- endfor -%}
    -]
    -
    - Edit template -
    - -
    -
    -
    - {% endfor %} -
    - {% else %} -

    No form templates yet. Create one on a workshop's form editor.

    - {% endif %} -
    -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/base.html b/frontend/templates/base.html deleted file mode 100644 index 98aaf63..0000000 --- a/frontend/templates/base.html +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - {% block title %}Workshop Helmsman{% endblock %} - - - -
    - - - Workshop Helmsman - - -
    - -
    - {% block content %}{% endblock %} -
    - -
    - Workshop Helmsman · self-hosted milestone tracker · Phase 2 -
    - - {% block scripts %}{% endblock %} - - diff --git a/frontend/templates/console.html b/frontend/templates/console.html deleted file mode 100644 index f7b9387..0000000 --- a/frontend/templates/console.html +++ /dev/null @@ -1,86 +0,0 @@ -{% extends "base.html" %} -{% block title %}Console — Workshop Helmsman{% endblock %} -{% block content %} -
    -
    - - Workshop Helmsman - Console -
    - -
    - -
    -
    -

    Your workshop command center

    -

    Create workshops, manage templates, view all sessions. Bookmark this URL — it's your master key.

    -
    - -
    - -
    -
    -

    Create a new workshop

    -

    Start a fresh session from scratch or a template.

    - Create workshop → -
    - - -
    -
    📚
    -

    Agenda & Form templates

    -

    Reusable milestone lists and participant forms. Edit once, use forever.

    - Manage templates → -
    - - -
    -
    📂
    -

    All workshops

    -

    Every session you've ever run. Filter by status, search by name.

    - Browse all → -
    -
    - - -
    -

    At a glance

    -
    -
    {{ total_workshops }}Total workshops
    -
    {{ live_workshops }}Live now
    -
    {{ total_participants }}Participants ever
    -
    -
    - - - {% if recent_workshops %} -
    -
    -

    Recent workshops

    - View all -
    -
    - {% for w in recent_workshops %} -
    -
    - {{ w.name }} - - - {% if w.archived %}archived{% elif w.is_expired() %}expired{% else %}live{% endif %} - - {{ w.created_at.strftime('%b %d, %Y') }} · {{ w._participant_count }} participants - -
    - -
    - {% endfor %} -
    - {% endif %} -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/console_index.html b/frontend/templates/console_index.html deleted file mode 100644 index a5be903..0000000 --- a/frontend/templates/console_index.html +++ /dev/null @@ -1,61 +0,0 @@ -{% extends "base.html" %} -{% block title %}Workshops — Console{% endblock %} -{% block content %} -
    -
    -

    Workshops

    -

    Your workshop dashboard

    -
    - - {% if workshops|length > 0 %} -
    - - - - - - - - - - - - {% for w in workshops %} - - - - - - - - {% endfor %} - -
    WorkshopCreatedStatusAttendeesActions
    - {{ w.name }} - {{ w.created_at.strftime('%Y-%m-%d %H:%M') }} - {% if w.archived %} - Archived - {% elif w.paused %} - Paused - {% elif w.is_expired() %} - Ended - {% else %} - Live - {% endif %} - {{ w.participants|length }} - {% if not w.archived %} - Edit - Clone - Archive - {% endif %} -
    -
    - {% else %} -
    -

    No workshops yet

    -

    Create your first workshop to get started.

    - Create First Workshop -
    - {% endif %} -
    -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/console_workshop_new.html b/frontend/templates/console_workshop_new.html deleted file mode 100644 index 53ef5c4..0000000 --- a/frontend/templates/console_workshop_new.html +++ /dev/null @@ -1,337 +0,0 @@ -{% extends "base.html" %} -{% block title %}Create Workshop — Console{% endblock %} -{% block content %} -
    - ← Console -

    Create Workshop

    -

    Name it, design the agenda, set the form. Two links come out: one for you, one for attendees.

    -
    - -
    - - - -
    - - - - - -
    -
    -

    Basics

    -

    Give your workshop a name and set the duration

    -
    - -
    - -
    - -
    - - -
    - -
    - -

    Templates pre-fill milestones. You can edit, reorder, or delete them in the next step.

    -
    - -
    - -
    -
    - - -
    -
    -

    Milestones

    -

    Drag to reorder. Click title to edit. Each milestone becomes a column on the participant tracker.

    -
    - -
    - -
    - 0 milestones · 0 min total -
    -
    - -
      - -
    - -
    - - -
    -
    - - -
    -
    -

    Participant Form

    -

    Configure what info you need from each attendee. Full Name is always required.

    -
    - -
    -
    - -
    - ⋮⋮ -
    - Full Name - text - Required -
    - 🔒 -
    - -
    - - - -
    - -
    - -
    -

    Participant preview

    -
    -
    -
    - -
    - - -
    -
    - - -
    -
    -

    Resources & Knowledge Base

    -

    Link a Google Drive folder with slides, docs, and resources. Participants can access from their tracker.

    -
    - -
    - - -
    - -
    -

    🔮 Coming soon: AI-powered Q&A on your Drive docs. Participants ask questions, get answers from your resources.

    -
    - -
    - - -
    -
    - - -
    -
    -

    Review & Create

    -

    Verify everything looks good. Click a section to jump back and edit.

    -
    - -
    -
    -
    -

    Basics

    - -
    -
    -
    Name
    -
    Duration
    -
    Template
    Blank
    -
    -
    - -
    -
    -

    Milestones

    - -
    -
    -
    Count
    0
    -
    Total time
    0 min
    -
    Categories
    -
    -
      -
      - -
      -
      -

      Participant Form

      - -
      -
      -
      Fields
      1
      -
      Required
      1
      -
      -
        -
        - -
        -
        -

        Resources

        - -
        -
        -
        Drive link
        -
        Title
        -
        -
        -
        - -
        - - -
        -
        -
        -
        {% endblock %} -{% block scripts %} - - - - - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/participant_tracker.html b/frontend/templates/participant_tracker.html deleted file mode 100644 index 0aa3b06..0000000 --- a/frontend/templates/participant_tracker.html +++ /dev/null @@ -1,224 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Tracker{% endblock %} -{% block content %} - -{# Broadcast banner - shows if there's an active broadcast #} -{% if broadcast %} -
        - -
        -
        Announcement
        -
        {{ broadcast }}
        -
        - -
        -{% endif %} - -
        -
        -

        {{ workshop.name }}

        -
        -

        Hi {{ participant.name }}. Mark each milestone as you finish it. The leaderboard auto-refreshes.

        -
        - -
        -
        -

        Your milestones

        - -
        -
        -
        -
        - - {{ completed_ids|length }} / {{ milestones|length }} complete - · {{ (completed_ids|length * 100 / (milestones|length or 1))|round(0, 'floor') }}% - -
        - -
          - {% for m in milestones %} -
        1. -
          - {{ m.title }} - {% if m.description %}
          {{ m.description }}
          {% endif %} - {% if m.id in completed_ids %} - completed {{ completed_ids[m.id].strftime('%H:%M:%S UTC') }} - {% endif %} -
          -
          - {% if m.id in completed_ids %} - ✓ Done - {% else %} -
          - -
          - {% endif %} -
          -
        2. - {% endfor %} -
        -
        - -
        -

        Leaderboard

        -
          - {% for row in leaderboard %} -
        1. 50 %} style="display:none"{% endif %}> - - {{ row.name }}{% if row.is_me %} (you){% endif %} - - -
          - {{ row.completed_count }} / {{ row.total }} -
          -
        2. - {% endfor %} -
        - {% if leaderboard|length > 50 %} - - {% endif %} - Auto-refreshes every few seconds. -
        -
        - -{# My Answers Panel #} -{% if form_answers and form_answers|length > 0 %} -
        -

        Your answers

        -
        - {% for key, value in form_answers.items() %} - {% if value %} -
        {{ key.replace('_', ' ').title() }}
        -
        {{ value }}
        - {% endif %} - {% endfor %} -
        -
        -{% endif %} - -
        -

        Stuck? Get the room's attention.

        -

        Type what's blocking you. We'll try to suggest a fix before it hits the facilitator.

        - - {% if pending_message %} - {# Two-step flow: show LLM suggestion before saving #} -
        -

        Before we send — here's a possible fix

        -
        - Suggested: {{ pending_suggestion or "(no suggestion available)" }} -
        -
        - Your message (click to edit) -
        - - -
        - - Cancel -
        -
        -
        -
        - {% else %} -
        - - - Press Enter or click "Get a suggestion" to see a possible fix before sending. - -
        - {% endif %} - -

        Your help requests

        -
          - {% for h in latest_help %} -
        • -
          - You - · {{ h.created_at.strftime('%H:%M:%S UTC') }} - {{ h.status.replace('_', ' ') }} -
          -
          {{ h.message }}
          -
          - - {% for s in help_statuses %} - - {% endfor %} -
          -
        • - {% else %} -
        • No help flags yet.
        • - {% endfor %} -
        -
        -{% endblock %} -{% block scripts %} - - - - -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/workshop_archived.html b/frontend/templates/workshop_archived.html deleted file mode 100644 index 17e2da4..0000000 --- a/frontend/templates/workshop_archived.html +++ /dev/null @@ -1,10 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Archived{% endblock %} -{% block content %} -
        - Archived -

        {{ workshop.name }}

        -

        This workshop has been archived by the facilitator and is no longer accepting participants.

        - Back to home -
        -{% endblock %} \ No newline at end of file diff --git a/frontend/templates/workshop_expired.html b/frontend/templates/workshop_expired.html deleted file mode 100644 index 36e93bc..0000000 --- a/frontend/templates/workshop_expired.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ workshop.name }} · Ended{% endblock %} -{% block content %} -
        - Workshop ended -

        {{ workshop.name }}

        -

        This participant link has expired. The facilitator still has their admin link - and can export / archive the session from there.

        - Back to home -
        -{% endblock %} diff --git a/frontend/templates/workshops_index.html b/frontend/templates/workshops_index.html deleted file mode 100644 index 5c8c0a0..0000000 --- a/frontend/templates/workshops_index.html +++ /dev/null @@ -1,80 +0,0 @@ -{% extends "base.html" %} -{% block title %}Local workshops · Helmsman{% endblock %} -{% block content %} -
        -

        Local workshops

        -

        Every workshop ever created on this instance.

        - -
        - - -
        - - {% if items %} - - - - - - - - - - - - {% for w in items %} - - - - - - - - {% endfor %} - -
        NameCreatedParticipantsStatusActions
        {{ w.name }}{{ w.created_at.strftime('%Y-%m-%d %H:%M UTC') }} - {% if w._participant_count is defined %} - {{ w._participant_count }} - {% else %} - — - {% endif %} - - {% if w.archived %}archived - {% elif w.is_expired() %}expired - {% else %}live{% endif %} - - dashboard - {% if not w.archived and not w.is_expired() %} - participant - {% else %} - ended - {% endif %} -
        - {% if items|length == 50 %} -

        Showing 50 most recent. Archived older workshops from the dashboard.

        - {% endif %} - {% else %} -

        No workshops yet. Create one from the home page.

        - {% endif %} -
        -{% endblock %} -{% block scripts %} - -{% endblock %} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e6dc937 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "workshop-helmsman" +version = "0.2.0" +description = "Self-hosted workshop assistant for facilitator-led hands-on technical labs" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "alembic>=1.13", + "pydantic-settings>=2.0", + "structlog>=24.1", + "httpx>=0.27", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index cd058b1..0000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -fastapi==0.115.4 -uvicorn[standard]==0.32.0 -sqlalchemy==2.0.36 -jinja2==3.1.4 -python-multipart==0.0.17 -itsdangerous==2.2.0 diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index fa8cdc1..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Workshop Helmsman — FastAPI app entrypoint. - -Run with: venv/bin/python -m src -""" - -from .main import app # noqa: F401 (re-export) - -__all__ = ["app"] diff --git a/src/__main__.py b/src/__main__.py deleted file mode 100644 index 8eed95a..0000000 --- a/src/__main__.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Module entrypoint — boots uvicorn against 0.0.0.0:8001.""" - -import os - -import uvicorn - - -def main() -> None: - host = os.environ.get("BIND_HOST", "0.0.0.0") - port = int(os.environ.get("BIND_PORT", "8001")) - uvicorn.run( - "src.main:app", - host=host, - port=port, - log_level=os.environ.get("LOG_LEVEL", "info"), - reload=False, - ) - - -if __name__ == "__main__": - main() diff --git a/src/db.py b/src/db.py deleted file mode 100644 index 8ad2e33..0000000 --- a/src/db.py +++ /dev/null @@ -1,67 +0,0 @@ -"""SQLAlchemy engine + session bootstrap for Workshop Helmsman.""" - -from __future__ import annotations - -import os -from contextlib import contextmanager -from pathlib import Path - -from sqlalchemy import create_engine -from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker - - -def _default_url() -> str: - # data/ lives next to the repo root when running `python -m src` - here = Path(__file__).resolve().parent.parent - data_dir = here / "data" - data_dir.mkdir(parents=True, exist_ok=True) - db_path = data_dir / "helmsman.db" - return f"sqlite:///{db_path}" - - -DATABASE_URL = os.environ.get("DATABASE_URL", _default_url()) - - -class Base(DeclarativeBase): - pass - - -engine = create_engine( - DATABASE_URL, - echo=False, - future=True, - connect_args={"check_same_thread": False}, -) - -SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) - - -def init_db() -> None: - """Create all tables. Idempotent — safe to call on every boot.""" - # Imported lazily to avoid circular import (models import Base from here). - from . import models # noqa: F401 - - Base.metadata.create_all(bind=engine) - - -@contextmanager -def session_scope() -> Session: - """Context-managed session that commits on success, rolls back on error.""" - session = SessionLocal() - try: - yield session - session.commit() - except Exception: - session.rollback() - raise - finally: - session.close() - - -def get_db() -> Session: - """FastAPI dependency — yields a session, closes it after the request.""" - session = SessionLocal() - try: - yield session - finally: - session.close() diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 4d61845..0000000 --- a/src/main.py +++ /dev/null @@ -1,1831 +0,0 @@ -"""FastAPI application: all routes for Workshop Helmsman (Phases 1-6).""" - -from __future__ import annotations - -import csv -import io -import json -import logging -import os -import re -import urllib.error -import urllib.request -from copy import deepcopy -from datetime import datetime, timedelta, timezone -from pathlib import Path - -from fastapi import Body, Depends, FastAPI, Form, HTTPException, Request, status -from fastapi.responses import ( - HTMLResponse, - JSONResponse, - RedirectResponse, - StreamingResponse, -) -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from sqlalchemy import desc -from sqlalchemy.orm import Session - -from .db import get_db, init_db, session_scope -from .models import ( - DEFAULT_AGENDA_TEMPLATES, - DEFAULT_FORM_SCHEMA, - DEFAULT_MILESTONE_CONFIG, - HELP_STATUSES, - AgendaTemplate, - FormTemplate, - HelpRequest, - MilestoneCompletion, - Participant, - Workshop, -) -from .security import ( - PARTICIPANT_COOKIE, - find_participant, - find_workshop_by_admin_token, - find_workshop_by_slug, - generate_admin_token, - generate_participant_slug, - require_workshop_by_admin_token, - require_workshop_by_slug, -) - -log = logging.getLogger("helmsman") - -# --- Paths & app bootstrap --- - -HERE = Path(__file__).resolve().parent.parent -TEMPLATE_DIR = HERE / "frontend" / "templates" -STATIC_DIR = HERE / "frontend" / "static" - -app = FastAPI(title="Workshop Helmsman", version="0.1.0") -templates = Jinja2Templates(directory=str(TEMPLATE_DIR)) -app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") - - -@app.on_event("startup") -def _on_startup() -> None: - init_db() - _seed_agenda_templates() - - -# --- Helpers --- - -def _utcnow() -> datetime: - # SQLite stores naive datetimes; return naive UTC to match what comes back. - return datetime.now(timezone.utc).replace(tzinfo=None) - - -def _seed_agenda_templates() -> None: - """Seed 2-3 starter agenda templates on first run; idempotent.""" - import json - - with session_scope() as db: - existing = db.query(AgendaTemplate).count() - if existing > 0: - return - now = _utcnow() - for tpl in DEFAULT_AGENDA_TEMPLATES: - at = AgendaTemplate( - name=tpl["name"], - created_at=now, - milestones_json=json.dumps(tpl["milestones"]), - ) - db.add(at) - - -# --- Phase 6: .env loader + OpenRouter LLM helper --- - -# Tiny .env loader — we deliberately don't add python-dotenv to keep the -# dependency footprint flat. Reads KEY=VALUE lines from ./.env (next to the -# repo root) into os.environ ONLY where the key isn't already present. -# Existing shell exports always win. -def _load_dotenv_once() -> None: - if getattr(_load_dotenv_once, "_done", False): - return - _load_dotenv_once._done = True # type: ignore[attr-defined] - env_path = HERE / ".env" - if not env_path.exists(): - return - try: - for raw in env_path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - if "=" not in line: - continue - key, _, value = line.partition("=") - key = key.strip() - if not key: - continue - # Strip optional surrounding quotes on the value. - value = value.strip() - if (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ): - value = value[1:-1] - # Never overwrite an explicit shell export. - os.environ.setdefault(key, value) - except Exception as exc: - log.warning("could not read %s: %s", env_path, exc) - - -_load_dotenv_once() - - -# All values below are read lazily so tests / shells can override after import. -def _openrouter_key() -> str | None: - """Read OPENROUTER_API_KEY from env, return None if missing/empty.""" - key = os.environ.get("OPENROUTER_API_KEY") - if not key: - return None - key = key.strip() - if not key or key.startswith("sk-or-..."): - # Treat placeholder/template values as 'not configured'. - return None - return key - - -def _llm_resolve( - message: str, - milestones: list[dict], - help_tips: str, -) -> str | None: - """Ask OpenRouter for a short, actionable suggestion for a help flag. - - Returns the suggestion string (one or two short paragraphs), or None - if the API isn't configured, the network call fails, or the response - is empty. NEVER raises — Phase 6 callers must always have a graceful - fallback (no LLM → plain "send this?" confirmation). - - Uses urllib.request + stdlib json — no extra client deps. - Falls back from nvidia/llama-3.3-nemotron-super-49b-v1 to - openai/gpt-4o-mini if the primary model errors. - """ - if not message or not message.strip(): - return None - api_key = _openrouter_key() - if not api_key: - return None - - # Build a concise context block. - ctx_lines: list[str] = [] - if milestones: - ms = ", ".join((m.get("title") or "").strip() for m in milestones if (m.get("title") or "").strip()) - if ms: - ctx_lines.append("Workshop milestones: " + ms + ".") - if help_tips and help_tips.strip(): - ctx_lines.append("Facilitator tips:\n" + help_tips.strip()) - - context = "\n\n".join(ctx_lines) or "(no extra context)" - system_prompt = ( - "You are a workshop help-desk assistant. A participant just typed a " - "short message describing what's blocking them. Reply with ONE short " - "paragraph (2-4 sentences) of the most likely fix OR next step they " - "should try. Be concrete and specific. If their question is about a " - "specific tool/step not covered by the milestones or tips, give your " - "best guess based on common workshop gotchas. Do NOT ask follow-up " - "questions. Do NOT apologize. Just give the next thing to try." - ) - user_prompt = ( - f"Context:\n{context}\n\n" - f"Participant's help request:\n{message.strip()[:1500]}\n\n" - "Reply with the suggestion only." - ) - - primary = os.environ.get( - "OPENROUTER_PRIMARY_MODEL", "nvidia/llama-3.3-nemotron-super-49b-v1" - ) - fallback = os.environ.get("OPENROUTER_FALLBACK_MODEL", "openai/gpt-4o-mini") - - for model in (primary, fallback): - suggestion = _llm_call_openrouter(api_key, model, system_prompt, user_prompt) - if suggestion: - return suggestion - # If the primary returned None because of an error AND it's not the - # last attempt, the loop moves on to the fallback. If we got a real - # empty-string completion on the primary, still try fallback — feels - # safer than surfacing nothing. - return None - - -def _llm_call_openrouter( - api_key: str, - model: str, - system_prompt: str, - user_prompt: str, - timeout: float = 6.0, -) -> str | None: - """Single OpenRouter /chat/completions call. - - Returns the first choice's message content stripped, or None on any - failure. Network errors, non-2xx responses, JSON parsing errors, and - empty completions are all logged at warning/info and return None. - """ - url = "https://openrouter.ai/api/v1/chat/completions" - payload = { - "model": model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt}, - ], - "max_tokens": 220, - "temperature": 0.4, - "stream": False, - } - body = json.dumps(payload).encode("utf-8") - req = urllib.request.Request( - url, - data=body, - headers={ - "Authorization": "Bearer " + api_key, - "Content-Type": "application/json", - "HTTP-Referer": "http://localhost:8001", - "X-Title": "WorkshopHelmsman", - }, - method="POST", - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - raw = resp.read().decode("utf-8", errors="replace") - except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError) as exc: - log.warning("OpenRouter call failed for model=%s: %s", model, exc) - return None - except Exception as exc: # belt-and-braces; never crash the request - log.warning("OpenRouter unexpected error: %s", exc) - return None - - try: - data = json.loads(raw) - except Exception as exc: - log.warning("OpenRouter returned non-JSON for model=%s: %s", model, exc) - return None - - try: - choices = data.get("choices") or [] - if not choices: - return None - msg = choices[0].get("message") or {} - text = (msg.get("content") or "").strip() - return text or None - except Exception as exc: - log.warning("OpenRouter response shape unexpected for model=%s: %s", model, exc) - return None - - -def _parse_milestones(raw: str) -> list[dict]: - """Parse lines into [{id, title, description}].""" - parsed: list[dict] = [] - for idx, line in enumerate((raw or "").splitlines()): - line = line.strip() - if not line: - continue - if ":" in line: - title, description = line.split(":", 1) - title = title.strip() - description = description.strip() - else: - title = line - description = "" - if not title: - continue - parsed.append({"id": f"m{idx}", "title": title, "description": description}) - if not parsed: - # Sensible default — facilitator gets 4 phases even if they submit blank. - parsed = [ - {"id": "m0", "title": "Setup", "description": "Environment ready"}, - {"id": "m1", "title": "API Key", "description": "API key configured"}, - {"id": "m2", "title": "First Build", "description": "First working build"}, - {"id": "m3", "title": "Done", "description": "Workshop wrap-up"}, - ] - return parsed - - -# --- Phase 4: form schema helpers --- - - -_SLUG_RE = re.compile(r"[^a-z0-9]+") - - -def _slugify_key(label: str, fallback: str = "field") -> str: - """Best-effort field key derived from a label. - - "Display name" -> "display_name"; "What's your role?" -> "whats_your_role". - Used only as an auto-fill suggestion; users can override it manually. - """ - s = (label or "").strip().lower() - s = _SLUG_RE.sub("_", s).strip("_") - return (s or fallback)[:64] - - -def _normalize_field(raw: dict, idx: int) -> dict | None: - """Sanitize a single field dict from a posted form. - - Returns None if the field is unusable (no label, no key after sanitize). - """ - if not isinstance(raw, dict): - return None - label = (raw.get("label") or "").strip() - if not label: - return None - key = (raw.get("key") or "").strip() - if not key: - key = _slugify_key(label, fallback=f"field_{idx}") - key = _slugify_key(key.replace(" ", "_"), fallback=f"field_{idx}") - ftype = raw.get("type") - if ftype not in ("text", "dropdown"): - ftype = "text" - placeholder = (raw.get("placeholder") or "").strip() if ftype == "text" else "" - required = bool(raw.get("required")) - options: list[str] = [] - if ftype == "dropdown": - opts = raw.get("options") - if isinstance(opts, list): - for o in opts: - o = (str(o) if o is not None else "").strip() - if o: - options.append(o) - elif isinstance(opts, str): - for line in opts.splitlines(): - line = line.strip() - if line: - options.append(line) - # Dedupe while preserving order. - seen: set[str] = set() - deduped: list[str] = [] - for o in options: - if o not in seen: - seen.add(o) - deduped.append(o) - options = deduped - if not options: - options = ["Yes", "No"] - field: dict = { - "key": key, - "type": ftype, - "label": label[:200], - "required": required, - } - if ftype == "text": - field["placeholder"] = placeholder[:200] - else: - field["options"] = options[:32] - return field - - -def _coerce_fields_json(raw_json: str) -> list[dict]: - """Parse a JSON string of field dicts into a normalized, deduplicated list. - - Rejects malformed JSON (returns empty list — caller must decide whether - to default or error). For dicts missing a key/label, we synthesize a - stable key from the label. Duplicate keys are deduped (later wins). - """ - if not raw_json or not raw_json.strip(): - return [] - try: - data = json.loads(raw_json) - except Exception: - return [] - if not isinstance(data, list): - return [] - out: list[dict] = [] - seen_keys: dict[str, int] = {} - for idx, item in enumerate(data): - norm = _normalize_field(item, idx) - if norm is None: - continue - key = norm["key"] - if key in seen_keys: - # Replace prior occurrence (callers see the most recent). - j = seen_keys[key] - out[j] = norm - continue - seen_keys[key] = len(out) - out.append(norm) - return out - - -def _ensure_display_name_field(fields: list[dict]) -> list[dict]: - """Make sure the form has at least one text field marked as the name. - - The 'name' is the field whose key is 'display_name', OR the first field - if none is so named. If the form is empty, return the default schema. - """ - if not fields: - return list(DEFAULT_FORM_SCHEMA) - return fields - - -def _display_name_field(schema: list[dict]) -> dict | None: - """Return the field designated as the participant display name.""" - for f in schema: - if f.get("key") == "display_name": - return f - return schema[0] if schema else None - - -def _form_keys(schema: list[dict]) -> list[str]: - """Sorted-stable list of form keys for CSV columns.""" - return [f["key"] for f in schema] - - -def _collect_form_answers(schema: list[dict], form: dict) -> dict: - """Given a workshop schema + request.form, return an {key: value} dict. - - Missing required fields are silently included as empty strings so the - persistence shape is predictable. Junk keys in `form` are ignored. - """ - out: dict[str, str] = {} - for f in schema: - key = f.get("key") - if not key: - continue - # Inputs are named 'field_' so we don't clash with hidden helpers. - v = form.get(f"field_{key}") - if v is None: - v = form.get(key) - if v is None: - v = "" - out[key] = str(v)[:1000] - return out - - -def _render(request: Request, template: str, **ctx) -> HTMLResponse: - return templates.TemplateResponse(request, template, ctx) - - -def _participant_progress(participant: Participant, milestones: list[dict]) -> dict: - completed_ids = {c.milestone_id for c in participant.completions} - return { - "participant": participant, - "completed_ids": completed_ids, - "completed_count": len(completed_ids), - "total": len(milestones), - "pct": int(round(100 * len(completed_ids) / max(len(milestones), 1))), - } - - -def _leaderboard_rows( - participants: list[Participant], milestones: list[dict], my_id: int | None = None -) -> list[dict]: - """Build leaderboard rows. Call with full list; template slices to 50.""" - out = [] - total = len(milestones) - for p in participants: - done = {c.milestone_id for c in p.completions} - out.append({ - "id": p.id, - "name": p.name, - "joined_at": p.joined_at.isoformat(), - "completed_count": len(done), - "total": total, - "pct": int(round(100 * len(done) / max(total, 1))), - "is_me": p.id == my_id, - }) - return out - - -# --- Health & landing --- - -@app.get("/healthz", response_class=JSONResponse) -def healthz(db: Session = Depends(get_db)) -> dict: - try: - # Cheap query — proves DB reachable. - db.execute(__import__("sqlalchemy").text("SELECT 1")) - return {"status": "ok", "db": "ok"} - except Exception as exc: - return JSONResponse(status_code=503, content={"status": "degraded", "db": str(exc)}) - - -@app.get("/healthz/ready", response_class=JSONResponse) -def healthz_ready(db: Session = Depends(get_db)) -> dict: - """Kubernetes readiness probe: checks DB connectivity.""" - try: - db.execute(__import__("sqlalchemy").text("SELECT 1")) - return {"ok": True} - except Exception as exc: - return JSONResponse(status_code=503, content={"ok": False, "error": str(exc)}) - - -@app.get("/", response_class=HTMLResponse) -def landing(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - # Bootstrap a DEMO workshop the first time anyone hits /, so facilitators - # can poke at a working session immediately. - demo = ( - db.query(Workshop) - .filter(Workshop.admin_token == "demo-workshop-admin-token") - .first() - ) - if demo is None: - milestones = _parse_milestones( - "Setup: pick your environment\nAPI Key: configure your LLM key\nFirst Build: run your hello-world\nDone: workshop wrap-up" - ) - now = _utcnow() - demo = Workshop( - name="(demo) Quick Walkthrough", - created_at=now, - expires_at=now + timedelta(days=1), - admin_token="demo-workshop-admin-token", - participant_slug="demo-walkthrough", - milestone_config_json=json.dumps(milestones), - archived=False, - form_schema_json=json.dumps(DEFAULT_FORM_SCHEMA), - ) - db.add(demo) - db.commit() - - recent = ( - db.query(Workshop).order_by(desc(Workshop.created_at)).limit(10).all() - ) - # Category colors for milestone badges - category_colors = { - 'setup': '#7aa2ff', - 'learning': '#44d39a', - 'hands_on': '#e4c44a', - 'break': '#d68c44', - 'assessment': '#b07fdb', - 'wrap_up': '#ec8898', - } - - return _render( - request, - "landing.html", - demo_admin=demo.admin_token, - demo_slug=demo.participant_slug, - workshops=recent, - ) - - -# --- Admin: create workshop --- - -@app.get("/admin/new", response_class=HTMLResponse) -def admin_new_form(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - default_milestones = "\n".join( - [ - "Setup: pick your environment and clone the starter", - "API Key: configure your LLM provider key", - "First Build: ship a hello-world end-to-end", - "Done: present and wrap up", - ] - ) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - agenda_templates = ( - db.query(AgendaTemplate).order_by(AgendaTemplate.created_at.asc()).all() - ) - return _render( - request, - "admin_new.html", - default_milestones=default_milestones, - default_ttl=8, - templates_list=templates_list, - default_form_fields=DEFAULT_FORM_SCHEMA, - agenda_templates=agenda_templates, - ) - - -@app.post("/admin/new") -def admin_new_create( - request: Request, - name: str = Form(...), - milestones: str = Form(""), - ttl_hours: int = Form(8), - fields_json: str = Form(""), - template_id: str = Form(""), - save_as_template: str = Form(""), - template_name: str = Form(""), - agenda_template_id: str = Form(""), - db: Session = Depends(get_db), -): - name = (name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Workshop name is required") - if ttl_hours < 1 or ttl_hours > 7 * 24: - ttl_hours = 8 - - # Resolve milestones: use _parse_milestones (textarea) OR load from agenda template. - parsed: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - agenda_milestones = agenda_tpl.milestones() - if agenda_milestones: - # Assign stable ids - parsed = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(agenda_milestones) - ] - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - - # Resolve form schema: - # 1. If fields_json has any fields, use that. - # 2. Else if template_id is set and valid, deep-copy that template's fields. - # 3. Else default to DEFAULT_FORM_SCHEMA. - fields = _coerce_fields_json(fields_json) - template_obj: FormTemplate | None = None - if not fields: - if template_id and template_id.isdigit(): - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == int(template_id)).first() - ) - if template_obj is not None: - fields = deepcopy(template_obj.fields()) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - - now = _utcnow() - workshop = Workshop( - name=name, - created_at=now, - expires_at=now + timedelta(hours=ttl_hours), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(parsed), - archived=False, - form_template_id=template_obj.id if template_obj else None, - form_schema_json=json.dumps(fields), - ) - db.add(workshop) - - # Optionally save the schema as a new named template. - if save_as_template and template_name.strip(): - new_tpl = FormTemplate( - name=template_name.strip()[:120], - created_at=now, - fields_json=json.dumps(fields), - ) - db.add(new_tpl) - # No flush here — commit at the end. - - db.commit() - db.refresh(workshop) - return RedirectResponse(url=f"/admin/{workshop.admin_token}", status_code=303) - - -# --- Phase 4: global template library --- - - -@app.get("/admin/templates", response_class=HTMLResponse) -def admin_templates_index( - request: Request, db: Session = Depends(get_db) -) -> HTMLResponse: - """List all saved form templates.""" - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - # Annotate with workshop usage counts. - for t in templates_list: - t._usage_count = ( - db.query(Workshop).filter(Workshop.form_template_id == t.id).count() - ) - t._fields_summary = ", ".join( - f.get("label", "?") for f in t.fields() - )[:200] - return _render(request, "admin_templates.html", templates_list=templates_list) - - -@app.get("/admin/templates/{tid}", response_class=HTMLResponse) -def admin_templates_edit( - request: Request, tid: int, db: Session = Depends(get_db) -) -> HTMLResponse: - """Edit a single template's schema. Edits template — never touches existing workshop snapshots.""" - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - schema = template_obj.fields() - return _render( - request, - "admin_template_edit.html", - template_obj=template_obj, - form_schema=schema, - form_schema_json_str=json.dumps(schema), - ) - - -@app.post("/admin/templates/{tid}") -def admin_templates_save( - request: Request, - tid: int, - name: str = Form(""), - fields_json: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - if name.strip(): - template_obj.name = name.strip()[:120] - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - template_obj.fields_json = json.dumps(fields) - db.commit() - return RedirectResponse(url=f"/admin/templates/{tid}", status_code=303) - - -@app.post("/admin/templates/{tid}/delete") -def admin_templates_delete( - request: Request, tid: int, db: Session = Depends(get_db) -) -> RedirectResponse: - """Delete a template. Existing workshops' snapshots remain intact.""" - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - db.delete(template_obj) - db.commit() - return RedirectResponse(url="/admin/templates", status_code=303) - - -# --- Admin: dashboard --- - -@app.get("/admin/{admin_token}", response_class=HTMLResponse) -def admin_dashboard( - request: Request, admin_token: str, db: Session = Depends(get_db), page: int = 1 -) -> HTMLResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - milestones = workshop.milestones() - participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - rows = [_participant_progress(p, milestones) for p in participants] - - # Help requests: latest 20 per page (0-indexed), with ?page=N navigation. - PAGE_SIZE = 20 - page = max(1, page) - offset = (page - 1) * PAGE_SIZE - total_help = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .count() - ) - help_requests = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .offset(offset) - .limit(PAGE_SIZE) - .all() - ) - has_more_help = (offset + len(help_requests)) < total_help - # Per-milestone completion stats across all participants. - stats: list[dict] = [] - for m in milestones: - c = ( - db.query(MilestoneCompletion) - .join(Participant, MilestoneCompletion.participant_id == Participant.id) - .filter( - Participant.workshop_id == workshop.id, - MilestoneCompletion.milestone_id == m["id"], - ) - .count() - ) - stats.append({**m, "count": c, "pct": int(round(100 * c / max(len(participants), 1)))}) - # Cohort stacked-bar: how many participants have completed 0, 1, 2, ... milestones? - COHORT_COLORS = ["#ef6a6a", "#d68c44", "#e4c44a", "#44d39a", "#7aa2ff", "#b07fdb", "#ec8898", "#88d4ab"] - total = len(participants) - done_counts: dict[int, int] = {} - for r in rows: - k = r["completed_count"] - done_counts[k] = done_counts.get(k, 0) + 1 - max_milestones = len(milestones) - segments = [] - for k in range(max_milestones + 1): - cnt = done_counts.get(k, 0) - if cnt > 0 or k == 0: - segments.append({ - "label": f"{k} done" if k < max_milestones else "all done", - "count": cnt, - "color": COHORT_COLORS[k % len(COHORT_COLORS)], - }) - # Always show at least one segment - if not segments and total == 0: - segments = [{"label": "0 done", "count": 0, "color": COHORT_COLORS[0]}] - total_done = sum(r["completed_count"] for r in rows) - cohort_bar = { - "segments": segments, - "total": total, - "pct": int(round(100 * total_done / max(total * max(1, max_milestones), 1))), - } - # Define category colors for milestone badges - category_colors = { - 'setup': '#7aa2ff', - 'learning': '#44d39a', - 'hands_on': '#e4c44a', - 'break': '#d68c44', - 'assessment': '#b07fdb', - 'wrap_up': '#ec8898', - } - return _render( - request, - "admin_dashboard.html", - workshop=workshop, - rows=rows, - milestones=milestones, - stats=stats, - help_requests=help_requests, - participant_count=len(participants), - cohort_bar=cohort_bar, - help_statuses=list(HELP_STATUSES), - help_page=page, - has_more_help=has_more_help, - category_colors=category_colors, - ) - - -# --- Admin: edit workshop --- - -@app.get("/admin/{admin_token}/edit", response_class=HTMLResponse) -def admin_edit_form( - request: Request, admin_token: str, db: Session = Depends(get_db) -) -> HTMLResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - milestones = workshop.milestones() - # Build textarea text: "title: description" per line - milestones_text = "\n".join( - f"{m['title']}: {m['description']}" if m.get("description") else m["title"] - for m in milestones - ) - # TTL as hours from now (at least 1) — normalize to naive for subtraction - now = _utcnow() - if now.tzinfo is not None: - now = now.replace(tzinfo=None) - exp = workshop.expires_at - if exp.tzinfo is not None: - exp = exp.replace(tzinfo=None) - ttl_hours = max(1, round((exp - now).total_seconds() / 3600)) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - agenda_templates = ( - db.query(AgendaTemplate).order_by(AgendaTemplate.created_at.asc()).all() - ) - return _render( - request, - "admin_edit.html", - workshop=workshop, - milestones_text=milestones_text, - ttl_hours=ttl_hours, - form_schema=workshop.form_schema(), - form_schema_json_str=json.dumps(workshop.form_schema()), - form_template_id=workshop.form_template_id, - templates_list=templates_list, - agenda_templates=agenda_templates, - help_tips_text=workshop.help_tips(), - ) - - -@app.post("/admin/{admin_token}/edit") -def admin_edit_save( - request: Request, - admin_token: str, - name: str = Form(...), - milestones: str = Form(""), - ttl_hours: int = Form(8), - fields_json: str = Form(""), - agenda_template_id: str = Form(""), - help_tips_text: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - workshop = require_workshop_by_admin_token(db, admin_token) - workshop.name = (name or "").strip() or workshop.name - - # Resolve milestones: use _parse_milestones (textarea) OR load from agenda template. - parsed: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - agenda_milestones = agenda_tpl.milestones() - if agenda_milestones: - parsed = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(agenda_milestones) - ] - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - else: - parsed = _parse_milestones(milestones) - - workshop.milestone_config = json.dumps(parsed) - workshop.expires_at = _utcnow() + timedelta(hours=max(1, min(ttl_hours, 168))) - - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fields) - # Save Phase 6 help tips. - workshop.help_tips_json = (help_tips_text or "").strip() - # NOTE: editing a workshop does NOT rewrite form_template_id. The template - # is a starting point; the snapshot is what the join page uses. - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -# --- Admin: export CSV --- - -@app.get("/admin/{admin_token}/export.csv") -def admin_export_csv( - admin_token: str, - db: Session = Depends(get_db), -): - """Stream a CSV of all participants, completions, help requests, and form answers. - - Columns: - - core (always): participant_name, joined_at, milestone_title, - completed_at, help_message, help_created_at - - per-form-field (Phase 4): one column per schema key, in schema order, - named `field_` for clarity. - - Rows: one row per (participant, milestone). If a participant has no - completions but has help requests, we emit one row with empty milestone - columns and one row per help request. Helper-extra help requests beyond - the milestone count are emitted as additional rows with blank milestone - columns. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - schema = workshop.form_schema() - form_keys = _form_keys(schema) - - buffer = io.StringIO() - writer = csv.writer(buffer) - header = [ - "participant_name", - "joined_at", - "milestone_title", - "completed_at", - "help_message", - "help_created_at", - ] + [f"field_{k}" for k in form_keys] - writer.writerow(header) - - participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - for p in participants: - answers = p.answers() - # Per-participant column tuple: empty unless row is tied to a milestone slot. - help_reqs = sorted(p.help_requests, key=lambda h: h.created_at) - if not p.completions and not help_reqs: - row = [p.name, p.joined_at.isoformat(), "", "", "", ""] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - continue - comp_by_mid = {c.milestone_id: c for c in p.completions} - all_mids = sorted(comp_by_mid.keys()) - all_help_idx = 0 - for mid in all_mids: - c = comp_by_mid[mid] - help_msg = "" - help_ts = "" - if all_help_idx < len(help_reqs): - h = help_reqs[all_help_idx] - help_msg = h.message - help_ts = h.created_at.isoformat() - all_help_idx += 1 - row = [ - p.name, - p.joined_at.isoformat(), - c.milestone_title, - c.completed_at.isoformat(), - help_msg, - help_ts, - ] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - while all_help_idx < len(help_reqs): - h = help_reqs[all_help_idx] - row = [p.name, p.joined_at.isoformat(), "", "", h.message, h.created_at.isoformat()] - for k in form_keys: - row.append(answers.get(k, "")) - writer.writerow(row) - all_help_idx += 1 - - buffer.seek(0) - ts = _utcnow().strftime("%Y%m%d-%H%M%S") - filename = f"workshop-{workshop.name}-{ts}.csv" - return StreamingResponse( - iter([buffer.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="{filename}"'}, - ) - - -# --- Admin: clone workshop --- - -@app.post("/admin/{admin_token}/clone") -def admin_clone( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Create a new workshop with the same milestone config but fresh tokens.""" - src = require_workshop_by_admin_token(db, admin_token) - src_milestones = src.milestones() - src_schema = src.form_schema() - - # Compute TTL: use the same default (8h) rather than copying the absolute - # expiry timestamp, so a 10-minute-old workshop still gives full 8h. - DEFAULT_TTL_HOURS = 8 - now = _utcnow() - clone = Workshop( - name=src.name, - created_at=now, - expires_at=now + timedelta(hours=DEFAULT_TTL_HOURS), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(src_milestones), - archived=False, - # Snapshot the form schema; do NOT carry the template id (a clone - # is a fresh workshop — if the user later edits the snapshot, we - # don't muddle authorship with the original template). - form_template_id=None, - form_schema_json=json.dumps(src_schema), - ) - db.add(clone) - db.commit() - return RedirectResponse(url=f"/admin/{clone.admin_token}", status_code=303) - - -# --- Admin: archive workshop --- - -@app.post("/admin/{admin_token}/archive") -def admin_archive( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Soft-delete: mark workshop as archived, redirects back to dashboard.""" - workshop = require_workshop_by_admin_token(db, admin_token) - workshop.archived = True - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -# --- Admin: per-participant drill-down --- - -@app.get("/admin/{admin_token}/participant/{pid}", response_class=HTMLResponse) -def admin_participant_drilldown( - request: Request, - admin_token: str, - pid: int, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Full timeline for one participant: completions + help requests. - - Phase 4: also surfaces the captured join-form answers as a 'Join inputs' - panel, falling back to a sensible default if answers are missing. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - participant = find_participant(db, workshop.id, pid) - if participant is None: - raise HTTPException(status_code=404, detail="Participant not found") - completions = ( - db.query(MilestoneCompletion) - .filter(MilestoneCompletion.participant_id == pid) - .order_by(MilestoneCompletion.completed_at.asc()) - .all() - ) - help_reqs = ( - db.query(HelpRequest) - .filter(HelpRequest.participant_id == pid) - .order_by(HelpRequest.created_at.asc()) - .all() - ) - schema = workshop.form_schema() - answers = participant.answers() - # Render the answers panel — ordered by schema when possible. - # Pass pre-built (label, value) tuples to the template for simplicity. - answers_panel: list[tuple[str, str]] = [] - for f in schema: - v = answers.get(f.get("key", ""), "") - label = f.get("label") or f.get("key") or "" - answers_panel.append((label, str(v))) - # If schema is empty but participant has answers, render them raw. - if not schema and answers: - for k, v in answers.items(): - answers_panel.append((k, str(v))) - return _render( - request, - "participant_drilldown.html", - workshop=workshop, - participant=participant, - completions=completions, - help_requests=help_reqs, - answers_panel=answers_panel, - ) - - -# --- Local workshops index (Phase 3) --- - -@app.get("/workshops", response_class=HTMLResponse) -def workshops_index(request: Request, db: Session = Depends(get_db)) -> HTMLResponse: - items = ( - db.query(Workshop).order_by(desc(Workshop.created_at)).limit(50).all() - ) - # Attach participant counts. - for w in items: - w._participant_count = ( - db.query(Participant) - .filter(Participant.workshop_id == w.id) - .count() - ) - return _render(request, "workshops_index.html", items=items) - - -# --- Participant: join --- - -@app.get("/w/{slug}", response_class=HTMLResponse) -def participant_join( - request: Request, - slug: str, - db: Session = Depends(get_db), - wid: str | None = None, -) -> HTMLResponse: - workshop = find_workshop_by_slug(db, slug) - if workshop is None: - raise HTTPException(status_code=404, detail="Workshop not found") - if workshop.archived: - return _render(request, "workshop_archived.html", workshop=workshop) - if workshop.is_expired(): - return _render(request, "workshop_expired.html", workshop=workshop) - - # If `wid` cookie maps to a participant in this workshop, fast-path to tracker. - existing_pid: int | None = None - cookie_wid = request.cookies.get(PARTICIPANT_COOKIE) - if cookie_wid is not None: - try: - pid = int(cookie_wid) - p = find_participant(db, workshop.id, pid) - if p is not None: - existing_pid = pid - except ValueError: - pass - - if existing_pid is not None: - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - schema = workshop.form_schema() - name_field = _display_name_field(schema) - # Convenient view for the template: list of form fields to render. - return _render( - request, - "participant_join.html", - workshop=workshop, - form_schema=schema, - name_field=name_field, - ) - - -@app.post("/w/{slug}") -def participant_register( - request: Request, - slug: str, - # Sentinel Form(...) parameters ensure FastAPI parses the multipart body - # into request._form. We re-read it inside the handler so we can iterate - # over the *arbitrary* set of field inputs the workshop schema defines. - name: str = Form(""), - db: Session = Depends(get_db), -): - workshop = require_workshop_by_slug(db, slug) - if workshop.is_expired(): - return _render(request, "workshop_expired.html", workshop=workshop) - - schema = workshop.form_schema() - - # The display name comes from the canonical 'display_name' field, OR the - # first schema field, OR the legacy `name` POST. This makes Phase 4 - # backwards-compatible with Phase 1-3 join-page submissions. - name_field = _display_name_field(schema) - name_field_key = name_field.get("key") if name_field else "display_name" - - # We can't easily mix dynamic Form() params with a fixed signature, so - # we manually read the whole form body. FastAPI's `Depends` injection - # for Form(...) parses the body once into request._form; by using a - # hidden Form(...) sentinel below we ensure that has run before this - # code reads it. (Sentinel itself is discarded.) - form_data = getattr(request, "_form", None) - form: dict[str, str] = {} - if form_data is not None: - try: - form = {k: form_data.get(k) for k in form_data.keys()} - except Exception: - form = {} - - # Accept answers from the dynamic form. - answers = _collect_form_answers(schema, form) - - name = "" - if name_field_key in answers: - name = (answers.get(name_field_key) or "").strip() - if not name: - legacy = form.get("name") - if legacy is not None: - name = str(legacy).strip() - if not name: - for k, v in answers.items(): - v = (v or "").strip() - if v: - name = v - break - - if not name: - raise HTTPException(status_code=400, detail="Display name is required") - if len(name) > 120: - name = name[:120] - - # Persist the answers as JSON (the captured *inputs* — what they - # submitted), and ALSO write the canonical display name into the legacy - # participant.name column so dashboards keep working unchanged. - participant = Participant( - workshop_id=workshop.id, - name=name, - answers_json=json.dumps(answers) if answers else None, - ) - db.add(participant) - db.commit() - db.refresh(participant) - - response = RedirectResponse(url=f"/w/{slug}/me", status_code=303) - response.set_cookie( - key=PARTICIPANT_COOKIE, - value=str(participant.id), - max_age=60 * 60 * 12, # 12 hours — survives workshop length - httponly=True, - samesite="lax", - path=f"/w/{slug}", - ) - return response - - -# --- Participant: personal tracker --- - -def _resolve_participant( - request: Request, db: Session, slug: str -) -> tuple[Workshop, Participant]: - workshop = require_workshop_by_slug(db, slug) - if workshop.is_expired(): - raise HTTPException(status_code=404, detail="Workshop has ended") - cookie = request.cookies.get(PARTICIPANT_COOKIE) - if not cookie: - raise HTTPException(status_code=401, detail="Join the workshop first") - try: - pid = int(cookie) - except ValueError as exc: - raise HTTPException(status_code=401, detail="Invalid session") from exc - participant = find_participant(db, workshop.id, pid) - if participant is None: - raise HTTPException(status_code=401, detail="Join the workshop first") - return workshop, participant - - -@app.get("/w/{slug}/me", response_class=HTMLResponse) -def participant_me( - request: Request, slug: str, db: Session = Depends(get_db) -) -> HTMLResponse: - workshop, participant = _resolve_participant(request, db, slug) - milestones = workshop.milestones() - completed_ids: dict[str, datetime] = { - c.milestone_id: c.completed_at for c in participant.completions - } - - # Leaderboard: full list (template slices to 50 for render; JS toggles hidden rows). - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - - help_recent = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - return _render( - request, - "participant_tracker.html", - workshop=workshop, - participant=participant, - milestones=milestones, - completed_ids=completed_ids, - leaderboard=_leaderboard_rows(all_participants, milestones, participant.id), - latest_help=help_recent, - help_statuses=list(HELP_STATUSES), - pending_message=None, - pending_suggestion=None, - ) - - -@app.post("/w/{slug}/me/complete/{milestone_id}") -def participant_complete( - request: Request, - slug: str, - milestone_id: str, - db: Session = Depends(get_db), -): - workshop, participant = _resolve_participant(request, db, slug) - milestone = next((m for m in workshop.milestones() if m["id"] == milestone_id), None) - if milestone is None: - raise HTTPException(status_code=404, detail="Milestone not found") - existing = ( - db.query(MilestoneCompletion) - .filter( - MilestoneCompletion.participant_id == participant.id, - MilestoneCompletion.milestone_id == milestone_id, - ) - .first() - ) - if existing is None: - completion = MilestoneCompletion( - participant_id=participant.id, - milestone_id=milestone_id, - milestone_title=milestone["title"], - ) - db.add(completion) - db.commit() - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - -@app.post("/w/{slug}/me/help") -def participant_help( - request: Request, - slug: str, - message: str = Form(""), - step: str = Form("preview"), - llm_suggestion: str = Form(""), - db: Session = Depends(get_db), -): - """Two-step help-flag flow (Phase 6). - - Step 1 — preview (default): - Participant typed their message and hit Send. We try to draft an - LLM suggestion via OpenRouter. We render `participant_help_confirm.html` - with: - - the participant's original message (in a textarea, editable) - - the LLM suggestion (if any) so they can fold it in - - "Send it" / "Edit my message" buttons - - If the LLM call failed or isn't configured, we skip the suggestion and - just show a plain "Send this? Yes / Edit" confirmation. Same UX either - way — never blocks on the LLM. - - Step 2 — confirm (form posts back with step=confirm): - Saves the edited message to a HelpRequest and redirects to the - tracker (the regular /w//me page). - """ - workshop, participant = _resolve_participant(request, db, slug) - message = (message or "").strip() - if len(message) > 2000: - message = message[:2000] - - if step == "commit": - # Step 2 — persist the (possibly edited) help request. - if message: - req = HelpRequest( - participant_id=participant.id, - message=message, - status="open", - ) - db.add(req) - db.commit() - return RedirectResponse(url=f"/w/{slug}/me", status_code=303) - - # Step 1 — preview. Try the LLM, but never block the user if it fails. - suggestion: str | None = None - if message: - try: - suggestion = _llm_resolve( - message, - workshop.milestones(), - workshop.help_tips(), - ) - except Exception as exc: # belt-and-braces — _llm_resolve never raises - log.warning("LLM resolve raised: %s", exc) - suggestion = None - # Step 1 — preview. Render the tracker page with the suggestion inline. - # Re-use the same data the GET /w//me handler builds, scoped to - # this participant (not "latest across the workshop"). - milestones = workshop.milestones() - completed_ids_map: dict[str, datetime] = { - c.milestone_id: c.completed_at for c in participant.completions - } - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - my_help = ( - db.query(HelpRequest) - .filter(HelpRequest.participant_id == participant.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - return _render( - request, - "participant_tracker.html", - workshop=workshop, - participant=participant, - milestones=milestones, - completed_ids=completed_ids_map, - latest_help=my_help, - leaderboard=_leaderboard_rows(all_participants, milestones, participant.id), - help_statuses=list(HELP_STATUSES), - pending_message=message, - pending_suggestion=(suggestion or "").strip(), - ) - - -# --- Phase 6: status-change routes (participant + admin) --- - - -def _normalize_status(raw: str | None) -> str: - s = (raw or "").strip().lower().replace("-", "_").replace(" ", "_") - if s not in HELP_STATUSES: - # Be conservative: unknown values fall back to 'open' rather than 400, - # so a stale client never locks a help request into a bad state. - return "open" - return s - - -@app.patch("/w/{slug}/me/help/{hid}/status") -def participant_help_status( - request: Request, - slug: str, - hid: int, - payload: dict = Body(default_factory=dict), - db: Session = Depends(get_db), -): - """Participant-owned status update. JSON body: {status: 'open'|...}.""" - workshop, participant = _resolve_participant(request, db, slug) - req = ( - db.query(HelpRequest) - .filter( - HelpRequest.id == hid, - HelpRequest.participant_id == participant.id, - ) - .first() - ) - if req is None: - raise HTTPException(status_code=404, detail="Help request not found") - new_status = _normalize_status((payload or {}).get("status")) - req.status = new_status - db.commit() - return {"ok": True, "id": req.id, "status": req.status} - - -@app.patch("/admin/{admin_token}/help/{hid}/status") -def admin_help_status( - admin_token: str, - hid: int, - payload: dict = Body(default_factory=dict), - db: Session = Depends(get_db), -): - """Admin can change any help-request status in this workshop.""" - workshop = require_workshop_by_admin_token(db, admin_token) - req = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(HelpRequest.id == hid, Participant.workshop_id == workshop.id) - .first() - ) - if req is None: - raise HTTPException(status_code=404, detail="Help request not found") - new_status = _normalize_status((payload or {}).get("status")) - req.status = new_status - db.commit() - return {"ok": True, "id": req.id, "status": req.status} - - -@app.get("/w/{slug}/data", response_class=JSONResponse) -def participant_poll( - request: Request, slug: str, since: str | None = None, db: Session = Depends(get_db) -): - """JSON endpoint polled by the participant tracker every ~4s.""" - workshop = find_workshop_by_slug(db, slug) - if workshop is None or workshop.archived: - raise HTTPException(status_code=404, detail="Workshop not found") - - milestones = workshop.milestones() - since_dt = None - if since: - try: - since_dt = datetime.fromisoformat(since.replace("Z", "+00:00")) - except ValueError: - since_dt = None - - all_participants = ( - db.query(Participant) - .filter(Participant.workshop_id == workshop.id) - .order_by(Participant.joined_at.asc()) - .all() - ) - leaderboard_payload = [ - {"id": p.id, "name": p.name, "completed_count": len({c.milestone_id for c in p.completions}), "total": len(milestones), "pct": int(round(100 * len({c.milestone_id for c in p.completions}) / max(len(milestones), 1)))} - for p in all_participants - ] - - help_recent = ( - db.query(HelpRequest) - .join(Participant, HelpRequest.participant_id == Participant.id) - .filter(Participant.workshop_id == workshop.id) - .order_by(desc(HelpRequest.created_at)) - .limit(20) - .all() - ) - help_payload = [] - for h in help_recent: - help_payload.append( - { - "id": h.id, - "participant_id": h.participant_id, - "participant_name": h.participant.name, - "message": h.message, - "created_at": h.created_at.isoformat(), - "status": h.status or "open", - } - ) - - return { - "ok": True, - "server_time": _utcnow().isoformat(), - "leaderboard": leaderboard_payload, - "help_requests": help_payload, - "milestones": milestones, - # Phase 4: include the form schema so the participant tracker can - # surface captured answers if/when we later add a per-participant - # view there. (No client-side reliance on this in Phase 4.) - "form_schema": getattr(db.query(Workshop).filter(Workshop.id == workshop.id).first(), "form_schema", lambda: [])(), - } - - -# --- Phase 4: form-template management (admin) --- - - -@app.get("/admin/{admin_token}/form", response_class=HTMLResponse) -def admin_form_edit( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Edit the form schema for THIS workshop (the snapshot, not the template).""" - workshop = require_workshop_by_admin_token(db, admin_token) - schema = workshop.form_schema() - return _render( - request, - "admin_form.html", - workshop=workshop, - form_schema=schema, - form_schema_json_str=json.dumps(schema), - ) - - -@app.post("/admin/{admin_token}/form") -def admin_form_save( - request: Request, - admin_token: str, - fields_json: str = Form(""), - save_as_template: str = Form(""), - template_name: str = Form(""), - template_id: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - """Save the form schema snapshot, optionally (also) save as a new/updated template.""" - workshop = require_workshop_by_admin_token(db, admin_token) - fields = _coerce_fields_json(fields_json) - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fields) - - template_obj: FormTemplate | None = None - if save_as_template and template_name.strip(): - # If template_id was passed, update it; else create a new one. - if template_id and template_id.isdigit(): - template_obj = ( - db.query(FormTemplate) - .filter(FormTemplate.id == int(template_id)) - .first() - ) - if template_obj is not None: - template_obj.name = template_name.strip()[:120] - template_obj.fields_json = json.dumps(fields) - else: - template_obj = FormTemplate( - name=template_name.strip()[:120], - created_at=_utcnow(), - fields_json=json.dumps(fields), - ) - db.add(template_obj) - db.flush() - workshop.form_template_id = template_obj.id - - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - -@app.get("/admin/{admin_token}/form-template", response_class=HTMLResponse) -def admin_form_template_pick( - request: Request, - admin_token: str, - db: Session = Depends(get_db), -) -> HTMLResponse: - """Pick a saved template to overwrite THIS workshop's snapshot.""" - workshop = require_workshop_by_admin_token(db, admin_token) - templates_list = ( - db.query(FormTemplate).order_by(FormTemplate.created_at.desc()).all() - ) - return _render( - request, - "admin_form_template.html", - workshop=workshop, - templates_list=templates_list, - form_schema=workshop.form_schema(), - ) - - -@app.post("/admin/{admin_token}/form-template/apply/{tid}") -def admin_form_template_apply( - request: Request, - admin_token: str, - tid: int, - db: Session = Depends(get_db), -) -> RedirectResponse: - """Replace the workshop's form-schema snapshot with a deep-copy of the template. - - IMPORTANT: historical workshops that already collected answers are - unaffected — only this workshop's JOIN page gets the new fields. - """ - workshop = require_workshop_by_admin_token(db, admin_token) - template_obj = ( - db.query(FormTemplate).filter(FormTemplate.id == tid).first() - ) - if template_obj is None: - raise HTTPException(status_code=404, detail="Template not found") - fresh_fields = deepcopy(template_obj.fields()) - if not fresh_fields: - fresh_fields = list(DEFAULT_FORM_SCHEMA) - workshop.form_schema_json = json.dumps(fresh_fields) - workshop.form_template_id = template_obj.id - db.commit() - return RedirectResponse(url=f"/admin/{admin_token}", status_code=303) - - - - -# --- Console: workshop creation wizard --- - -@app.get("/console", response_class=HTMLResponse) -def console_index(request: Request) -> HTMLResponse: - """Redirect to home page for now.""" - return RedirectResponse(url="/", status_code=303) - - -@app.get("/console/workshop/new", response_class=HTMLResponse) -def console_workshop_new_get( - request: Request, db: Session = Depends(get_db) -) -> HTMLResponse: - """Show the workshop creation wizard.""" - # Get agenda templates for the dropdown - agenda_templates = ( - db.query(AgendaTemplate) - .order_by(AgendaTemplate.created_at.asc()) - .all() - ) - return _render( - request, - "console_workshop_new.html", - agenda_templates=agenda_templates, - ) - - -@app.post("/console/workshop/new") -def console_workshop_new_post( - request: Request, - name: str = Form(""), - ttl_hours: str = Form(""), # note: comes as string because of custom field - ttl_hours_custom: str = Form(""), - agenda_template_id: str = Form(""), - kb_link: str = Form(""), - kb_title: str = Form(""), - milestones_json: str = Form(""), - fields_json: str = Form(""), - db: Session = Depends(get_db), -) -> RedirectResponse: - """Process the wizard form and create a workshop.""" - # Validate name - name = (name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="Workshop name is required") - - # Determine TTL (time to live) in hours - # If custom is selected, use the custom value; otherwise use the selected preset - try: - if ttl_hours == "custom": - hours = int(ttl_hours_custom or "8") - else: - hours = int(ttl_hours or "8") - except ValueError: - hours = 8 - # Clamp between 1 and 168 hours (1 week) - hours = max(1, min(168, hours)) - - # Parse milestones JSON (if provided) or use default from template - parsed_milestones: list[dict] - if agenda_template_id and agenda_template_id.isdigit(): - # Load the selected agenda template - agenda_tpl = ( - db.query(AgendaTemplate) - .filter(AgendaTemplate.id == int(agenda_template_id)) - .first() - ) - if agenda_tpl is not None: - template_milestones = agenda_tpl.milestones() - if template_milestones: - # Assign stable ids - parsed_milestones = [ - {"id": f"m{idx}", **m} - for idx, m in enumerate(template_milestones) - ] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - else: - parsed_milestones = json.loads(milestones_json) if milestones_json else [] - - # If no milestones provided, use default - if not parsed_milestones: - parsed_milestones = [ - {"id": f"m{i}", **m} - for i, m in enumerate(DEFAULT_MILESTONE_CONFIG) - ] - - # Parse fields JSON - try: - fields = json.loads(fields_json) if fields_json else [] - except Exception: - fields = [] - fields = _ensure_display_name_field(fields) - if not fields: - fields = list(DEFAULT_FORM_SCHEMA) - - # Create the workshop - now = _utcnow() - workshop = Workshop( - name=name, - created_at=now, - expires_at=now + timedelta(hours=hours), - admin_token=generate_admin_token(), - participant_slug=generate_participant_slug(), - milestone_config_json=json.dumps(parsed_milestones), - archived=False, - form_template_id=None, # We are not using templates for now; the snapshot is stored directly - form_schema_json=json.dumps(fields), - kb_link=kb_link or None, - kb_title=kb_title or None, - ) - db.add(workshop) - db.commit() - db.refresh(workshop) - - # Redirect to the admin dashboard for the new workshop - return RedirectResponse(url=f"/admin/{workshop.admin_token}", status_code=303) diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 895257d..0000000 --- a/src/models.py +++ /dev/null @@ -1,370 +0,0 @@ -"""SQLAlchemy ORM models for Workshop Helmsman (World-Class UX).""" - -from __future__ import annotations - -from datetime import datetime, timezone -from enum import Enum -from typing import Optional - -import json - -from sqlalchemy import Boolean, DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text, JSON -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from .db import Base - - -def utcnow() -> datetime: - return datetime.now(timezone.utc) - - -def utcnow_naive() -> datetime: - return datetime.now(timezone.utc).replace(tzinfo=None) - - -class MilestoneCategory(str, Enum): - SETUP = "setup" - CORE_LEARNING = "learning" - HANDS_ON = "hands_on" - BREAK = "break" - ASSESSMENT = "assessment" - WRAP_UP = "wrap_up" - - -class FormFieldType(str, Enum): - TEXT = "text" - EMAIL = "email" - DROPDOWN = "dropdown" - MULTI_SELECT = "multi_select" - FILE = "file" - URL = "url" - TEXTAREA = "textarea" - - -# Default milestone configuration for new workshops -DEFAULT_MILESTONE_CONFIG = [ - { - "id": "m0", - "title": "Setup", - "description": "Environment ready, repo cloned", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Verify everyone can run the starter", - }, - { - "id": "m1", - "title": "API Key", - "description": "LLM provider key configured", - "duration_min": 15, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use env var, not hardcoded", - }, - { - "id": "m2", - "title": "First Build", - "description": "End-to-end hello world shipped", - "duration_min": 60, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Run the test suite after", - }, - { - "id": "m3", - "title": "Done", - "description": "Wrap-up and Q&A", - "duration_min": 15, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Capture feedback before they leave", - }, -] - -# Default form schema for new workshops -DEFAULT_FORM_SCHEMA = [ - {"key": "display_name", "type": "text", "label": "Full Name", "required": True}, - {"key": "email", "type": "email", "label": "Email Address", "required": False}, - {"key": "role", "type": "dropdown", "label": "Role", "required": False, "options": ["Student", "Developer", "Designer", "Manager", "Other"]}, - {"key": "company", "type": "text", "label": "Company", "required": False}, - {"key": "skill_level", "type": "dropdown", "label": "Skill Level", "required": False, "options": ["Beginner", "Intermediate", "Advanced"]}, - {"key": "team", "type": "text", "label": "Team", "required": False}, -] - - -# --- Phase 5: Agenda Templates --- - -DEFAULT_AGENDA_TEMPLATES = [ - { - "name": "Web Development Workshop", - "milestones": [ - { - "title": "Setup", - "description": "Install Node.js, clone repo, install dependencies", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use nvm for Node version management", - }, - { - "title": "Frontend Basics", - "description": "Learn React components and state management", - "duration_min": 60, - "category": MilestoneCategory.CORE_LEARNING.value, - "help_tip": "Start with functional components", - }, - { - "title": "Build a Todo App", - "description": "Apply learning by building a todo application", - "duration_min": 90, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Use local storage for persistence", - }, - { - "title": "Break", - "description": "Coffee and networking", - "duration_min": 15, - "category": MilestoneCategory.BREAK.value, - "help_tip": "Stay hydrated!", - }, - { - "title": "Testing & Debugging", - "description": "Write unit tests and debug common issues", - "duration_min": 45, - "category": MilestoneCategory.ASSESSMENT.value, - "help_tip": "Use Jest and React Testing Library", - }, - { - "title": "Deployment", - "description": "Deploy the app to Vercel or Netlify", - "duration_min": 30, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Set up custom domain and environment variables", - }, - ], - }, - { - "name": "Data Science Bootcamp", - "milestones": [ - { - "title": "Environment Setup", - "description": "Install Python, Jupyter, and essential libraries", - "duration_min": 30, - "category": MilestoneCategory.SETUP.value, - "help_tip": "Use conda or virtualenv", - }, - { - "title": "Data Wrangling", - "description": "Clean and explore datasets with Pandas", - "duration_min": 60, - "category": MilestoneCategory.CORE_LEARNING.value, - "help_tip": "Learn pandas DataFrame operations", - }, - { - "title": "Machine Learning Model", - "description": "Build and evaluate a predictive model", - "duration_min": 90, - "category": MilestoneCategory.HANDS_ON.value, - "help_tip": "Start with linear regression", - }, - { - "title": "Break", - "description": "Snack and stretch", - "duration_min": 10, - "category": MilestoneCategory.BREAK.value, - "help_tip": "Take a short walk", - }, - { - "title": "Model Evaluation", - "description": "Assess model performance and tune hyperparameters", - "duration_min": 45, - "category": MilestoneCategory.ASSESSMENT.value, - "help_tip": "Use cross-validation", - }, - { - "title": "Presentation", - "description": "Present findings and get feedback", - "duration_min": 30, - "category": MilestoneCategory.WRAP_UP.value, - "help_tip": "Use slides and tell a story", - }, - ], - }, -] - - -class FormTemplate(Base): - """A reusable named form schema for workshop join pages. - - `fields_json` is a JSON-encoded list of field dicts: - [{"key": "display_name", "type": "text"|"dropdown", - "label": "...", "placeholder": "..." (text only), - "required": bool, "options": ["A","B","C"] (dropdown only)}, ...] - - Saving a new Workshop records a *snapshot* of the schema on the workshop - itself (workshop.form_schema_json), so editing a template later never - mutates historical join-page data. - """ - - __tablename__ = "form_template" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - fields_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - - def fields(self) -> list[dict]: - import json - - try: - data = json.loads(self.fields_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class AgendaTemplate(Base): - """A reusable named agenda template (list of milestone configs). - - Each milestone has: ``title`` (required), ``description`` (optional), - and ``help_tip`` (optional — facilitator tip shown on tracker). - - A Workshop stores a *snapshot* of its milestones when created, so editing - a template later never mutates existing workshops. - """ - - __tablename__ = "agenda_template" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(120), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - milestones_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - - def milestones(self) -> list[dict]: - import json - - try: - data = json.loads(self.milestones_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class Workshop(Base): - __tablename__ = "workshop" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - name: Mapped[str] = mapped_column(String(200), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) - admin_token: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - participant_slug: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) - # New fields for world-class UX - difficulty_level: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) - milestone_config_json: Mapped[str] = mapped_column(Text, nullable=False, default=json.dumps(DEFAULT_MILESTONE_CONFIG)) - form_schema_json: Mapped[str] = mapped_column(Text, nullable=False, default=json.dumps(DEFAULT_FORM_SCHEMA)) - kb_link: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - kb_title: Mapped[Optional[str]] = mapped_column(String(200), nullable=True) - # Existing fields that were missing - broadcast_message: Mapped[str] = mapped_column(Text, nullable=False, default="") - paused: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - milestone_order_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") - archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - - # Relationships - form_template_id: Mapped[int | None] = mapped_column( - ForeignKey("form_template.id", ondelete="SET NULL"), - nullable=True, - index=True, - ) - participants: Mapped[list["Participant"]] = relationship(back_populates="workshop", cascade="all, delete-orphan") - - def is_expired(self) -> bool: - if self.expires_at is None: - return False - now = utcnow() - if now.tzinfo is not None: - now = now.replace(tzinfo=None) - exp = self.expires_at - if exp.tzinfo is not None: - exp = exp.replace(tzinfo=None) - return now > exp - - def milestones(self) -> list[dict]: - try: - data = json.loads(self.milestone_config_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - def ordered_milestones(self) -> list[dict]: - # For simplicity, we return milestones in the order they are stored. - # In the future, we might want to store an explicit order. - return self.milestones() - - def form_schema(self) -> list[dict]: - try: - data = json.loads(self.form_schema_json or "[]") - return data if isinstance(data, list) else [] - except Exception: - return [] - - -class Participant(Base): - __tablename__ = "participant" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - workshop_id: Mapped[int] = mapped_column( - ForeignKey("workshop.id", ondelete="CASCADE"), index=True - ) - name: Mapped[str] = mapped_column(String(120), nullable=False) - joined_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - answers_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True) - - workshop: Mapped["Workshop"] = relationship(back_populates="participants") - completions: Mapped[list["MilestoneCompletion"]] = relationship( - back_populates="participant", cascade="all, delete-orphan" - ) - help_requests: Mapped[list["HelpRequest"]] = relationship( - back_populates="participant", cascade="all, delete-orphan" - ) - - def answers(self) -> dict: - if not self.answers_json: - return {} - try: - data = json.loads(self.answers_json) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -class MilestoneCompletion(Base): - __tablename__ = "milestone_completion" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - participant_id: Mapped[int] = mapped_column( - ForeignKey("participant.id", ondelete="CASCADE"), index=True - ) - milestone_id: Mapped[str] = mapped_column(String(64), nullable=False) - milestone_title: Mapped[str] = mapped_column(String(200), nullable=False) - completed_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - - participant: Mapped["Participant"] = relationship(back_populates="completions") - - -class HelpRequest(Base): - __tablename__ = "help_request" - - id: Mapped[int] = mapped_column(Integer, primary_key=True) - participant_id: Mapped[int] = mapped_column( - ForeignKey("participant.id", ondelete="CASCADE"), index=True - ) - message: Mapped[str] = mapped_column(Text, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, nullable=False) - status: Mapped[str] = mapped_column( - SQLEnum("open", "on_hold", "resolved", name="help_status"), - default="open", - nullable=False, - ) - - participant: Mapped["Participant"] = relationship(back_populates="help_requests") - - -# Helper constants and functions -HELP_STATUSES = ("open", "on_hold", "resolved") \ No newline at end of file diff --git a/src/security.py b/src/security.py deleted file mode 100644 index 85b72c9..0000000 --- a/src/security.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Token + slug generation and participant-cookie helpers.""" - -from __future__ import annotations - -import secrets - -from fastapi import Cookie, HTTPException, status -from sqlalchemy.orm import Session - -from .models import Participant, Workshop - -PARTICIPANT_COOKIE = "wid" - - -def generate_admin_token() -> str: - """Facilitator dashboard URL token — high-entropy, URL-safe.""" - return secrets.token_urlsafe(32) - - -def generate_participant_slug() -> str: - """Shorter, share-friendly URL slug for participants.""" - # 6 bytes → ~8 url-safe chars; 16M possible values, ample for a workshop. - return secrets.token_urlsafe(6) - - -def find_workshop_by_admin_token(db: Session, token: str) -> Workshop | None: - return db.query(Workshop).filter(Workshop.admin_token == token).first() - - -def find_workshop_by_slug(db: Session, slug: str) -> Workshop | None: - return db.query(Workshop).filter(Workshop.participant_slug == slug).first() - - -def find_participant(db: Session, workshop_id: int, participant_id: int) -> Participant | None: - return ( - db.query(Participant) - .filter(Participant.id == participant_id, Participant.workshop_id == workshop_id) - .first() - ) - - -def require_workshop_by_admin_token(db: Session, token: str) -> Workshop: - w = find_workshop_by_admin_token(db, token) - if w is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workshop not found") - return w - - -def require_workshop_by_slug(db: Session, slug: str) -> Workshop: - w = find_workshop_by_slug(db, slug) - if w is None: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workshop not found") - if w.archived: - raise HTTPException(status_code=status.HTTP_410_GONE, detail="Workshop has been archived") - return w - - -def require_participant( - workshop_id: int, - wid: str | None = Cookie(default=None, alias=PARTICIPANT_COOKIE), -) -> int: - """Resolve the participant ID from the `wid` cookie. 401 if missing/invalid.""" - if not wid: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Join the workshop first") - try: - return int(wid) - except ValueError as exc: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid session") from exc diff --git a/tools/migrate.py b/tools/migrate.py deleted file mode 100644 index 9c1b9fc..0000000 --- a/tools/migrate.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Phase 4 + Phase 6 schema migrations. - -Idempotent. Run with: - - venv/bin/python tools/migrate.py - -Walks the existing SQLite DB and: - 1. Adds `form_template_id` (FK → form_template.id, ON DELETE SET NULL) - and `form_schema_json` columns to `workshop` if missing. - 2. Adds `answers_json` (NULL-able TEXT) to `participant` if missing. - 3. Creates the `form_template` and `agenda_template` tables if missing - (create_all handles it). - 4. Backfills any existing workshop rows whose `form_schema_json` IS NULL - or empty with the Phase-4 default schema (a single `display_name` - text field), so demo data from Phases 1-3 keeps working with no - manual edits. - 5. Phase 6: adds `help_request.status` column (default 'open') and - `workshop.help_tips_json` column (nullable TEXT). Backfills any - existing help_requests whose status is NULL/empty to 'open'. - -Re-running is safe — every step is a no-op when its conditions don't hold. -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path - -HERE = Path(__file__).resolve().parent.parent -sys.path.insert(0, str(HERE)) - -from src.db import engine, init_db # noqa: E402 -from src.models import DEFAULT_FORM_SCHEMA # noqa: E402 - - -def _column_exists(conn, table: str, column: str) -> bool: - rows = conn.exec_driver_sql(f"PRAGMA table_info({table})").fetchall() - return any(r[1] == column for r in rows) - - -def _table_exists(conn, table: str) -> bool: - row = conn.exec_driver_sql( - "SELECT name FROM sqlite_master WHERE type='table' AND name=?", - (table,), - ).fetchone() - return row is not None - - -def _ensure_columns(conn) -> list[str]: - """Add Phase-4 + Phase-6 columns if missing. Returns list of columns added.""" - added: list[str] = [] - # Inspect first (outside any transaction). - need_ftid = not _column_exists(conn, "workshop", "form_template_id") - need_schema = not _column_exists(conn, "workshop", "form_schema_json") - need_answers = not _column_exists(conn, "participant", "answers_json") - # Phase 6: - need_help_status = not _column_exists(conn, "help_request", "status") - need_help_tips = not _column_exists(conn, "workshop", "help_tips_json") - - if need_ftid: - conn.exec_driver_sql( - "ALTER TABLE workshop ADD COLUMN form_template_id INTEGER " - "REFERENCES form_template(id) ON DELETE SET NULL" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS ix_workshop_form_template_id " - "ON workshop(form_template_id)" - ) - added.append("workshop.form_template_id") - if need_schema: - conn.exec_driver_sql("ALTER TABLE workshop ADD COLUMN form_schema_json TEXT") - added.append("workshop.form_schema_json") - if need_answers: - conn.exec_driver_sql("ALTER TABLE participant ADD COLUMN answers_json TEXT") - added.append("participant.answers_json") - if need_help_tips: - conn.exec_driver_sql("ALTER TABLE workshop ADD COLUMN help_tips_json TEXT") - added.append("workshop.help_tips_json") - if need_help_status: - conn.exec_driver_sql( - "ALTER TABLE help_request ADD COLUMN status VARCHAR(16) " - "NOT NULL DEFAULT 'open'" - ) - added.append("help_request.status") - conn.commit() - return added - - -def _backfill_form_schemas(conn) -> int: - """Set form_schema_json to the Phase-4 default on existing workshops. - - Only touches rows where the column is NULL or empty — fresh workshops - created post-upgrade are left alone (they carry whatever the creator - set, including empty arrays if they hand-cleared the field list). - Returns the number of rows updated. - """ - rows = conn.exec_driver_sql( - "SELECT id FROM workshop WHERE form_schema_json IS NULL OR form_schema_json = ''" - ).fetchall() - default_json = json.dumps(DEFAULT_FORM_SCHEMA) - for (wid,) in rows: - conn.exec_driver_sql( - "UPDATE workshop SET form_schema_json = ? WHERE id = ?", - (default_json, wid), - ) - conn.commit() - return len(rows) - - -def _backfill_help_status(conn) -> int: - """Set status='open' on existing rows where the column came up empty. - - SQLite ALTER TABLE ADD COLUMN applies the DEFAULT to NEW rows only; - pre-existing rows get whatever default value the kernel fills in - (usually NULL for TEXT, even with NOT NULL DEFAULT in older SQLite - versions). Phase 6 help_request.status needs to be non-NULL 'open' - for every historical row. - """ - res = conn.exec_driver_sql( - "UPDATE help_request SET status = 'open' " - "WHERE status IS NULL OR status = ''" - ) - conn.commit() - return res.rowcount or 0 - - -def main() -> int: - # Ensure tables exist (idempotent). - init_db() - - added_columns: list[str] = [] - schema_backfilled = 0 - status_backfilled = 0 - - with engine.connect() as conn: - if not _table_exists(conn, "form_template"): - print("[migrate] form_template table created by create_all()") - if not _table_exists(conn, "agenda_template"): - print("[migrate] agenda_template table created by create_all()") - added_columns = _ensure_columns(conn) - schema_backfilled = _backfill_form_schemas(conn) - status_backfilled = _backfill_help_status(conn) - - print(f"[migrate] columns added: {added_columns or 'none'}") - print(f"[migrate] workshops backfilled with default form schema: {schema_backfilled}") - print(f"[migrate] help_requests backfilled with status='open': {status_backfilled}") - print("[migrate] done.") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..57ba756 --- /dev/null +++ b/uv.lock @@ -0,0 +1,565 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.18.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, + { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, + { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, + { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, + { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, + { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, + { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, + { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, + { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, + { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" }, + { url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" }, + { url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" }, + { url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "workshop-helmsman" +version = "0.2.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy" }, + { name = "structlog" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "pydantic-settings", specifier = ">=2.0" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "structlog", specifier = ">=24.1" }, + { name = "uvicorn", specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] From 086224b3b8c896f4cde030e0aaf5b28e303d11c7 Mon Sep 17 00:00:00 2001 From: Workshop Helmsman Date: Mon, 20 Jul 2026 02:21:48 +0530 Subject: [PATCH 03/10] =?UTF-8?q?wip(phase-1):=20partial=20slice-generator?= =?UTF-8?q?=20output=20=E2=80=94=20checkpoint=20before=20usage=20pause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unverified work-in-progress: backend (src/), Next.js frontend (frontend/), tests, and alembic 0001 written by the three Phase-1 slice generators, interrupted before QA gating. Spec (019ea5f) is complete and authoritative; resume by finishing/validating these slices against spec/roadmap.md Phase 1, then running qa-auditor gates. Co-Authored-By: Claude Fable 5 --- alembic/env.py | 65 + alembic/script.py.mako | 26 + alembic/versions/0001_initial.py | 270 +++ frontend/.gitignore | 4 + frontend/.nvmrc | 1 + frontend/app/f/page.tsx | 385 ++++ frontend/app/globals.css | 254 +++ frontend/app/join/page.tsx | 246 +++ frontend/app/layout.tsx | 29 + frontend/app/p/page.tsx | 390 ++++ frontend/app/page.tsx | 315 +++ .../facilitator/CreateWorkshopModal.tsx | 344 +++ .../facilitator/DistributionChart.tsx | 53 + frontend/components/facilitator/HelpQueue.tsx | 240 ++ .../components/facilitator/MilestoneBars.tsx | 57 + .../facilitator/ParticipantTable.tsx | 216 ++ frontend/components/facilitator/StatCards.tsx | 48 + .../components/facilitator/WorkshopCard.tsx | 92 + frontend/components/participant/HelpPanel.tsx | 165 ++ .../components/participant/Leaderboard.tsx | 66 + .../components/participant/MilestoneList.tsx | 127 ++ .../participant/PersonalLinkCallout.tsx | 84 + frontend/components/ui/Badge.tsx | 76 + frontend/components/ui/Button.tsx | 62 + frontend/components/ui/Card.tsx | 19 + .../components/ui/ConnectionIndicator.tsx | 21 + frontend/components/ui/CopyButton.tsx | 86 + frontend/components/ui/EmptyState.tsx | 29 + frontend/components/ui/Markdown.tsx | 83 + frontend/components/ui/Modal.tsx | 82 + frontend/components/ui/ProgressBar.tsx | 43 + frontend/components/ui/Skeleton.tsx | 14 + frontend/components/ui/StubBadge.tsx | 66 + frontend/components/ui/Toast.tsx | 76 + frontend/lib/api.ts | 489 +++++ frontend/lib/format.ts | 43 + frontend/lib/poll.ts | 145 ++ frontend/next-env.d.ts | 6 + frontend/next.config.ts | 13 + frontend/package.json | 30 + frontend/pnpm-lock.yaml | 1931 +++++++++++++++++ frontend/pnpm-workspace.yaml | 4 + frontend/postcss.config.mjs | 7 + frontend/tsconfig.json | 21 + package.json | 13 + playwright.config.ts | 38 + pnpm-lock.yaml | 67 + src/__init__.py | 0 src/__main__.py | 21 + src/helmsman/__init__.py | 1 + src/helmsman/api/__init__.py | 107 + src/helmsman/api/_common.py | 39 + src/helmsman/api/admin.py | 197 ++ src/helmsman/api/facilitator.py | 198 ++ src/helmsman/api/health.py | 17 + src/helmsman/api/participant.py | 455 ++++ src/helmsman/config/__init__.py | 0 src/helmsman/config/settings.py | 53 + src/helmsman/db/__init__.py | 0 src/helmsman/db/models.py | 256 +++ src/helmsman/db/session.py | 96 + src/helmsman/observability/__init__.py | 0 src/helmsman/observability/logging.py | 98 + src/helmsman/security.py | 63 + src/helmsman/services/__init__.py | 0 src/helmsman/services/audit.py | 24 + src/helmsman/services/snapshots.py | 406 ++++ tests/conftest.py | 100 + tests/e2e/core-loop.spec.ts | 459 ++++ tests/e2e/helpers.ts | 69 + tests/e2e/smoke.spec.ts | 40 + tests/integration/test_admin_workshops.py | 90 + tests/integration/test_dashboard_details.py | 215 ++ tests/integration/test_errors.py | 358 +++ tests/integration/test_full_loop.py | 183 ++ tests/integration/test_health.py | 24 + tests/integration/test_idempotency.py | 88 + tests/integration/test_join_and_cookies.py | 69 + tests/integration/test_migration.py | 102 + tests/integration/test_redirects.py | 16 + tests/integration/test_restart_survival.py | 78 + tests/integration/test_versioning.py | 126 ++ tests/unit/test_common.py | 35 + tests/unit/test_last_seen_throttle.py | 19 + tests/unit/test_masking.py | 34 + tests/unit/test_request_models.py | 83 + tests/unit/test_security.py | 41 + tests/unit/test_settings.py | 71 + tests/unit/test_smoke.py | 5 + tests/unit/test_snapshot_helpers.py | 112 + 90 files changed, 11389 insertions(+) create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_initial.py create mode 100644 frontend/.gitignore create mode 100644 frontend/.nvmrc create mode 100644 frontend/app/f/page.tsx create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/join/page.tsx create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/p/page.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/facilitator/CreateWorkshopModal.tsx create mode 100644 frontend/components/facilitator/DistributionChart.tsx create mode 100644 frontend/components/facilitator/HelpQueue.tsx create mode 100644 frontend/components/facilitator/MilestoneBars.tsx create mode 100644 frontend/components/facilitator/ParticipantTable.tsx create mode 100644 frontend/components/facilitator/StatCards.tsx create mode 100644 frontend/components/facilitator/WorkshopCard.tsx create mode 100644 frontend/components/participant/HelpPanel.tsx create mode 100644 frontend/components/participant/Leaderboard.tsx create mode 100644 frontend/components/participant/MilestoneList.tsx create mode 100644 frontend/components/participant/PersonalLinkCallout.tsx create mode 100644 frontend/components/ui/Badge.tsx create mode 100644 frontend/components/ui/Button.tsx create mode 100644 frontend/components/ui/Card.tsx create mode 100644 frontend/components/ui/ConnectionIndicator.tsx create mode 100644 frontend/components/ui/CopyButton.tsx create mode 100644 frontend/components/ui/EmptyState.tsx create mode 100644 frontend/components/ui/Markdown.tsx create mode 100644 frontend/components/ui/Modal.tsx create mode 100644 frontend/components/ui/ProgressBar.tsx create mode 100644 frontend/components/ui/Skeleton.tsx create mode 100644 frontend/components/ui/StubBadge.tsx create mode 100644 frontend/components/ui/Toast.tsx create mode 100644 frontend/lib/api.ts create mode 100644 frontend/lib/format.ts create mode 100644 frontend/lib/poll.ts create mode 100644 frontend/next-env.d.ts create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/tsconfig.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 src/__init__.py create mode 100644 src/__main__.py create mode 100644 src/helmsman/__init__.py create mode 100644 src/helmsman/api/__init__.py create mode 100644 src/helmsman/api/_common.py create mode 100644 src/helmsman/api/admin.py create mode 100644 src/helmsman/api/facilitator.py create mode 100644 src/helmsman/api/health.py create mode 100644 src/helmsman/api/participant.py create mode 100644 src/helmsman/config/__init__.py create mode 100644 src/helmsman/config/settings.py create mode 100644 src/helmsman/db/__init__.py create mode 100644 src/helmsman/db/models.py create mode 100644 src/helmsman/db/session.py create mode 100644 src/helmsman/observability/__init__.py create mode 100644 src/helmsman/observability/logging.py create mode 100644 src/helmsman/security.py create mode 100644 src/helmsman/services/__init__.py create mode 100644 src/helmsman/services/audit.py create mode 100644 src/helmsman/services/snapshots.py create mode 100644 tests/conftest.py create mode 100644 tests/e2e/core-loop.spec.ts create mode 100644 tests/e2e/helpers.ts create mode 100644 tests/e2e/smoke.spec.ts create mode 100644 tests/integration/test_admin_workshops.py create mode 100644 tests/integration/test_dashboard_details.py create mode 100644 tests/integration/test_errors.py create mode 100644 tests/integration/test_full_loop.py create mode 100644 tests/integration/test_health.py create mode 100644 tests/integration/test_idempotency.py create mode 100644 tests/integration/test_join_and_cookies.py create mode 100644 tests/integration/test_migration.py create mode 100644 tests/integration/test_redirects.py create mode 100644 tests/integration/test_restart_survival.py create mode 100644 tests/integration/test_versioning.py create mode 100644 tests/unit/test_common.py create mode 100644 tests/unit/test_last_seen_throttle.py create mode 100644 tests/unit/test_masking.py create mode 100644 tests/unit/test_request_models.py create mode 100644 tests/unit/test_security.py create mode 100644 tests/unit/test_settings.py create mode 100644 tests/unit/test_smoke.py create mode 100644 tests/unit/test_snapshot_helpers.py diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1039075 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,65 @@ +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from src.helmsman.config.settings import get_settings +from src.helmsman.db.models import Base +from src.helmsman.db.session import _ensure_sqlite_parent_dir + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def _database_url() -> str: + """DATABASE_URL from the environment / .env; default sqlite:///data/helmsman.db.""" + url = get_settings().database_url + if url.startswith("sqlite"): + _ensure_sqlite_parent_dir(url) + return url + + +def run_migrations_offline() -> None: + context.configure( + url=_database_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = _database_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..4ce6e9c --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,270 @@ +"""initial + +Revision ID: 0001 +Revises: +Create Date: 2026-07-20 02:12:09.786818 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0001' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('agenda_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description_md', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_agenda_template')) + ) + op.create_table('join_form_template', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('fields_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_join_form_template')) + ) + op.create_table('agenda_template_milestone', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('template_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('content_md', sa.Text(), nullable=False), + sa.Column('minutes', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['template_id'], ['agenda_template.id'], name=op.f('fk_agenda_template_milestone_template_id_agenda_template'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_agenda_template_milestone')) + ) + with op.batch_alter_table('agenda_template_milestone', schema=None) as batch_op: + batch_op.create_index('ix_agenda_template_milestone_template_position', ['template_id', 'position'], unique=False) + + op.create_table('workshop', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description_md', sa.Text(), nullable=False), + sa.Column('admin_token', sa.String(length=64), nullable=False), + sa.Column('join_slug', sa.String(length=16), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('ended_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('grace_until', sa.DateTime(timezone=True), nullable=True), + sa.Column('grace_hours', sa.Integer(), nullable=False), + sa.Column('paused', sa.Boolean(), nullable=False), + sa.Column('state_version', sa.Integer(), nullable=False), + sa.Column('content_version', sa.Integer(), nullable=False), + sa.Column('ai_enabled', sa.Boolean(), nullable=False), + sa.Column('join_form_json', sa.Text(), nullable=False), + sa.Column('stuck_minutes', sa.Integer(), nullable=False), + sa.Column('cloned_from_id', sa.Integer(), nullable=True), + sa.Column('agenda_template_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['agenda_template_id'], ['agenda_template.id'], name=op.f('fk_workshop_agenda_template_id_agenda_template'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['cloned_from_id'], ['workshop.id'], name=op.f('fk_workshop_cloned_from_id_workshop')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_workshop')) + ) + with op.batch_alter_table('workshop', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_workshop_admin_token'), ['admin_token'], unique=True) + batch_op.create_index(batch_op.f('ix_workshop_agenda_template_id'), ['agenda_template_id'], unique=False) + batch_op.create_index(batch_op.f('ix_workshop_cloned_from_id'), ['cloned_from_id'], unique=False) + batch_op.create_index(batch_op.f('ix_workshop_join_slug'), ['join_slug'], unique=True) + batch_op.create_index(batch_op.f('ix_workshop_status'), ['status'], unique=False) + + op.create_table('broadcast', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('message_md', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('cleared_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_broadcast_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_broadcast')) + ) + with op.batch_alter_table('broadcast', schema=None) as batch_op: + batch_op.create_index('ix_broadcast_workshop_cleared', ['workshop_id', 'cleared_at'], unique=False) + + op.create_table('facilitator_action', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=True), + sa.Column('actor', sa.String(length=16), nullable=False), + sa.Column('action', sa.String(length=48), nullable=False), + sa.Column('detail_json', sa.Text(), nullable=False), + sa.Column('undo_data_json', sa.Text(), nullable=True), + sa.Column('undone_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_facilitator_action_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_facilitator_action')) + ) + with op.batch_alter_table('facilitator_action', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_facilitator_action_created_at'), ['created_at'], unique=False) + batch_op.create_index('ix_facilitator_action_workshop_created', ['workshop_id', 'created_at'], unique=False) + + op.create_table('milestone', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('content_md', sa.Text(), nullable=False), + sa.Column('minutes', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_milestone_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_milestone')) + ) + with op.batch_alter_table('milestone', schema=None) as batch_op: + batch_op.create_index('ix_milestone_workshop_id_position', ['workshop_id', 'position'], unique=False) + + op.create_table('participant', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=False), + sa.Column('token', sa.String(length=32), nullable=False), + sa.Column('answers_json', sa.Text(), nullable=False), + sa.Column('joined_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_participant_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_participant')) + ) + with op.batch_alter_table('participant', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_participant_token'), ['token'], unique=True) + batch_op.create_index(batch_op.f('ix_participant_workshop_id'), ['workshop_id'], unique=False) + + op.create_table('help_request', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('milestone_id', sa.Integer(), nullable=True), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('status', sa.String(length=16), nullable=False), + sa.Column('escalated', sa.Boolean(), nullable=False), + sa.Column('ai_state', sa.String(length=16), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['milestone_id'], ['milestone.id'], name=op.f('fk_help_request_milestone_id_milestone'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], name=op.f('fk_help_request_participant_id_participant'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_help_request_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_help_request')) + ) + with op.batch_alter_table('help_request', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_help_request_milestone_id'), ['milestone_id'], unique=False) + batch_op.create_index(batch_op.f('ix_help_request_participant_id'), ['participant_id'], unique=False) + batch_op.create_index('ix_help_request_workshop_status_created', ['workshop_id', 'status', 'created_at'], unique=False) + + op.create_table('milestone_completion', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('participant_id', sa.Integer(), nullable=False), + sa.Column('milestone_id', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['milestone_id'], ['milestone.id'], name=op.f('fk_milestone_completion_milestone_id_milestone'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['participant_id'], ['participant.id'], name=op.f('fk_milestone_completion_participant_id_participant'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_milestone_completion')), + sa.UniqueConstraint('participant_id', 'milestone_id', name='uq_completion_participant_milestone') + ) + with op.batch_alter_table('milestone_completion', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_milestone_completion_milestone_id'), ['milestone_id'], unique=False) + batch_op.create_index(batch_op.f('ix_milestone_completion_participant_id'), ['participant_id'], unique=False) + + op.create_table('ai_usage', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('workshop_id', sa.Integer(), nullable=False), + sa.Column('help_request_id', sa.Integer(), nullable=True), + sa.Column('purpose', sa.String(length=16), nullable=False), + sa.Column('model', sa.String(length=120), nullable=False), + sa.Column('prompt_tokens', sa.Integer(), nullable=False), + sa.Column('completion_tokens', sa.Integer(), nullable=False), + sa.Column('cost_usd', sa.Numeric(precision=10, scale=6), nullable=False), + sa.Column('latency_ms', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['help_request_id'], ['help_request.id'], name=op.f('fk_ai_usage_help_request_id_help_request'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['workshop_id'], ['workshop.id'], name=op.f('fk_ai_usage_workshop_id_workshop'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_ai_usage')) + ) + with op.batch_alter_table('ai_usage', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_ai_usage_help_request_id'), ['help_request_id'], unique=False) + batch_op.create_index('ix_ai_usage_workshop_created', ['workshop_id', 'created_at'], unique=False) + + op.create_table('help_answer', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('help_request_id', sa.Integer(), nullable=False), + sa.Column('source', sa.String(length=16), nullable=False), + sa.Column('answer_md', sa.Text(), nullable=False), + sa.Column('draft', sa.Boolean(), nullable=False), + sa.Column('ai_confidence', sa.Numeric(precision=4, scale=3), nullable=True), + sa.Column('ai_model', sa.String(length=120), nullable=True), + sa.Column('ai_context_json', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['help_request_id'], ['help_request.id'], name=op.f('fk_help_answer_help_request_id_help_request'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_help_answer')) + ) + with op.batch_alter_table('help_answer', schema=None) as batch_op: + batch_op.create_index('ix_help_answer_request_created', ['help_request_id', 'created_at'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('help_answer', schema=None) as batch_op: + batch_op.drop_index('ix_help_answer_request_created') + + op.drop_table('help_answer') + with op.batch_alter_table('ai_usage', schema=None) as batch_op: + batch_op.drop_index('ix_ai_usage_workshop_created') + batch_op.drop_index(batch_op.f('ix_ai_usage_help_request_id')) + + op.drop_table('ai_usage') + with op.batch_alter_table('milestone_completion', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_milestone_completion_participant_id')) + batch_op.drop_index(batch_op.f('ix_milestone_completion_milestone_id')) + + op.drop_table('milestone_completion') + with op.batch_alter_table('help_request', schema=None) as batch_op: + batch_op.drop_index('ix_help_request_workshop_status_created') + batch_op.drop_index(batch_op.f('ix_help_request_participant_id')) + batch_op.drop_index(batch_op.f('ix_help_request_milestone_id')) + + op.drop_table('help_request') + with op.batch_alter_table('participant', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_participant_workshop_id')) + batch_op.drop_index(batch_op.f('ix_participant_token')) + + op.drop_table('participant') + with op.batch_alter_table('milestone', schema=None) as batch_op: + batch_op.drop_index('ix_milestone_workshop_id_position') + + op.drop_table('milestone') + with op.batch_alter_table('facilitator_action', schema=None) as batch_op: + batch_op.drop_index('ix_facilitator_action_workshop_created') + batch_op.drop_index(batch_op.f('ix_facilitator_action_created_at')) + + op.drop_table('facilitator_action') + with op.batch_alter_table('broadcast', schema=None) as batch_op: + batch_op.drop_index('ix_broadcast_workshop_cleared') + + op.drop_table('broadcast') + with op.batch_alter_table('workshop', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_workshop_status')) + batch_op.drop_index(batch_op.f('ix_workshop_join_slug')) + batch_op.drop_index(batch_op.f('ix_workshop_cloned_from_id')) + batch_op.drop_index(batch_op.f('ix_workshop_agenda_template_id')) + batch_op.drop_index(batch_op.f('ix_workshop_admin_token')) + + op.drop_table('workshop') + with op.batch_alter_table('agenda_template_milestone', schema=None) as batch_op: + batch_op.drop_index('ix_agenda_template_milestone_template_position') + + op.drop_table('agenda_template_milestone') + op.drop_table('join_form_template') + op.drop_table('agenda_template') + # ### end Alembic commands ### diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7833851 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +out/ +*.tsbuildinfo diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/app/f/page.tsx b/frontend/app/f/page.tsx new file mode 100644 index 0000000..24642e1 --- /dev/null +++ b/frontend/app/f/page.tsx @@ -0,0 +1,385 @@ +"use client"; + +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSearchParams } from "next/navigation"; +import { + ApiError, + facilitatorAnswerHelp, + facilitatorDashboard, + facilitatorResolveHelp, + facilitatorWorkshop, + type DashboardPayload, + type FacilitatorWorkshopPayload, + type HelpQueueItem, +} from "@/lib/api"; +import { usePoll } from "@/lib/poll"; +import { useNowTick } from "@/lib/format"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { CopyButton } from "@/components/ui/CopyButton"; +import { ConnectionIndicator } from "@/components/ui/ConnectionIndicator"; +import { WorkshopStatusBadge } from "@/components/ui/Badge"; +import { StubAction, StubBadge, StubCard } from "@/components/ui/StubBadge"; +import { useToast } from "@/components/ui/Toast"; +import { StatCards } from "@/components/facilitator/StatCards"; +import { MilestoneBars } from "@/components/facilitator/MilestoneBars"; +import { DistributionChart } from "@/components/facilitator/DistributionChart"; +import { ParticipantTable } from "@/components/facilitator/ParticipantTable"; +import { HelpQueue } from "@/components/facilitator/HelpQueue"; + +function DashboardSkeleton() { + return ( +
        + +
        + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
        +
        +
        + + +
        + +
        +
        + ); +} + +function ErrorScreen({ title, hint }: { title: string; hint: string }) { + return ( +
        + + +

        {title}

        +

        {hint}

        +
        +
        + ); +} + +function DashboardInner() { + const params = useSearchParams(); + const token = params.get("t"); + const toast = useToast(); + const nowMs = useNowTick(30000); + + // ---- dashboard poll (2 s visible / 10 s hidden) ------------------------- + const fetcher = useCallback( + (v: number) => facilitatorDashboard(token as string, v), + [token], + ); + const { data, contentVersion, reconnecting, fatalError, pollNow } = + usePoll(token ? fetcher : null, { + visibleMs: 2000, + hiddenMs: 10000, + }); + + // ---- workshop payload (header, links, milestone bodies) ----------------- + // Fetched on load and re-fetched whenever the poll reports a newer + // content_version (spec/api.md). + const [wsPayload, setWsPayload] = useState( + null, + ); + const [wsFatal, setWsFatal] = useState(null); + const wsCvRef = useRef(-2); // -2 = never fetched + const [wsRetry, setWsRetry] = useState(0); + useEffect(() => { + if (!token) return; + if (wsCvRef.current !== -2 && wsCvRef.current >= contentVersion) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + facilitatorWorkshop(token) + .then((payload) => { + if (cancelled) return; + wsCvRef.current = payload.content_version; + setWsPayload(payload); + }) + .catch((err) => { + if (cancelled) return; + if (err instanceof ApiError && err.status === 404) { + setWsFatal(err); + } else { + retryTimer = setTimeout(() => setWsRetry((x) => x + 1), 3000); + } + }); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [token, contentVersion, wsRetry]); + + // ---- optimistic help-queue overrides ------------------------------------ + const [overrides, setOverrides] = useState< + Record + >({}); + const [busyIds, setBusyIds] = useState>(new Set()); + + useEffect(() => { + if (!data) return; + setOverrides((prev) => { + const next: typeof prev = {}; + let changed = false; + for (const [k, v] of Object.entries(prev)) { + if (data.version >= v.minVersion) changed = true; + else next[Number(k)] = v; + } + return changed ? next : prev; + }); + }, [data]); + + const queue = useMemo( + () => + (data?.help_queue ?? []).map((item) => overrides[item.id]?.item ?? item), + [data, overrides], + ); + + const withBusy = async (id: number, fn: () => Promise) => { + setBusyIds((prev) => new Set(prev).add(id)); + try { + await fn(); + } finally { + setBusyIds((prev) => { + const copy = new Set(prev); + copy.delete(id); + return copy; + }); + } + }; + + const onAnswer = async (id: number, answerMd: string): Promise => { + if (!token) return false; + let ok = false; + await withBusy(id, async () => { + try { + const res = await facilitatorAnswerHelp(token, id, answerMd); + setOverrides((prev) => ({ + ...prev, + [id]: { item: res.help_request, minVersion: res.version }, + })); + pollNow(); + ok = true; + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't send the answer — ${err.message}` + : "Couldn't send the answer — check your connection and try again.", + "error", + ); + } + }); + return ok; + }; + + const onResolve = async (id: number) => { + if (!token) return; + await withBusy(id, async () => { + try { + const res = await facilitatorResolveHelp(token, id); + setOverrides((prev) => ({ + ...prev, + [id]: { item: res.help_request, minVersion: res.version }, + })); + pollNow(); + } catch (err) { + toast.show( + err instanceof ApiError + ? `Couldn't resolve that — ${err.message}` + : "Couldn't resolve that — check your connection and try again.", + "error", + ); + } + }); + }; + + // ---- crowd milestone: where the largest group currently sits ------------ + const crowdMilestoneId = useMemo(() => { + if (!data || data.participants.length === 0) return null; + const counts = new Map(); + for (const p of data.participants) { + if (p.current_milestone_id !== null) { + counts.set( + p.current_milestone_id, + (counts.get(p.current_milestone_id) ?? 0) + 1, + ); + } + } + let best: number | null = null; + let bestCount = 0; + for (const [id, count] of counts) { + if (count > bestCount) { + best = id; + bestCount = count; + } + } + return best; + }, [data]); + + // ---- render ------------------------------------------------------------- + if (!token) { + return ( + + ); + } + if (fatalError || wsFatal) { + return ( + + ); + } + if (!data || !wsPayload) return ; + + const ws = wsPayload.workshop; + + return ( +
        +
        +
        +
        +

        + {ws.name} +

        + + {data.workshop.paused && ( + Paused + )} + + {data.stats.participant_count} participants ·{" "} + {data.stats.active_count} active + + +
        + + {ws.join_url} + + +
        +
        +
        + + + + +
        +
        +
        + +
        + + +
        +
        + +

        + Milestone completion +

        + {data.milestone_stats.length === 0 ? ( +

        + This workshop has no milestones. +

        + ) : ( + + )} +
        + + +

        + Cohort distribution +

        + {data.stats.participant_count === 0 ? ( +

        + The room's shape appears here once people join. +

        + ) : ( + + )} +
        + + +

        + Participants +

        + +
        +
        + +
        + +
        +

        + Help queue +

        + + Audit trail + + +
        + +
        + + + + +
        +
        +
        +
        + ); +} + +export default function DashboardPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..7f5cb58 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,254 @@ +@import "tailwindcss"; +@source "../"; + +@theme { + /* Brand — indigo */ + --color-brand-50: #eef2ff; + --color-brand-100: #e0e7ff; + --color-brand-200: #c7d2fe; + --color-brand-300: #a5b4fc; + --color-brand-400: #818cf8; + --color-brand-500: #6366f1; + --color-brand-600: #4f46e5; + --color-brand-700: #4338ca; + --color-brand-800: #3730a3; + --color-brand-900: #312e81; + --color-brand-950: #1e1b4b; + + /* Type */ + --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + + /* Motion */ + --animate-flash: flash 1.8s ease-out 1; + + @keyframes flash { + 0% { + background-color: var(--color-brand-100); + } + 100% { + background-color: transparent; + } + } +} + +@layer base { + html { + scroll-behavior: smooth; + } + + body { + background-color: var(--color-stone-50); + color: var(--color-stone-900); + -webkit-font-smoothing: antialiased; + font-size: 16px; + } + + :focus-visible { + outline: 2px solid var(--color-brand-500); + outline-offset: 2px; + border-radius: 4px; + } + + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + html { + scroll-behavior: auto; + } + } +} + +/* ---------------------------------------------------------------- */ +/* Custom checkbox (milestone toggle) */ +/* ---------------------------------------------------------------- */ + +.check-input { + appearance: none; + -webkit-appearance: none; + width: 1.5rem; + height: 1.5rem; + flex-shrink: 0; + border-radius: 0.5rem; + border: 2px solid var(--color-stone-300); + background-color: white; + cursor: pointer; + transition: + background-color 120ms ease, + border-color 120ms ease, + transform 120ms ease; +} + +.check-input:hover:not(:disabled) { + border-color: var(--color-brand-400); +} + +.check-input:active:not(:disabled) { + transform: scale(0.92); +} + +.check-input:checked { + background-color: var(--color-emerald-500); + border-color: var(--color-emerald-500); + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3e%3c/svg%3e"); + background-size: 100% 100%; +} + +.check-input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---------------------------------------------------------------- */ +/* Markdown ("md") — react-markdown output styling */ +/* ---------------------------------------------------------------- */ + +.md { + line-height: 1.65; + overflow-wrap: break-word; +} + +.md > :first-child { + margin-top: 0; +} + +.md > :last-child { + margin-bottom: 0; +} + +.md p, +.md ul, +.md ol, +.md blockquote, +.md table { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.md h1, +.md h2, +.md h3, +.md h4, +.md h5, +.md h6 { + font-weight: 600; + color: var(--color-stone-900); + margin-top: 1.25rem; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.md h1 { + font-size: 1.375rem; +} + +.md h2 { + font-size: 1.2rem; +} + +.md h3 { + font-size: 1.05rem; +} + +.md h4, +.md h5, +.md h6 { + font-size: 1rem; +} + +.md a { + color: var(--color-brand-700); + text-decoration: underline; + text-underline-offset: 2px; +} + +.md a:hover { + color: var(--color-brand-800); +} + +.md ul { + list-style: disc; + padding-left: 1.5rem; +} + +.md ol { + list-style: decimal; + padding-left: 1.5rem; +} + +.md li { + margin-bottom: 0.25rem; +} + +.md li > ul, +.md li > ol { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.md code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.875em; + background-color: var(--color-stone-100); + border: 1px solid var(--color-stone-200); + border-radius: 0.375rem; + padding: 0.125rem 0.375rem; +} + +.md pre { + position: relative; + margin-top: 0; + margin-bottom: 0.75rem; + background-color: #ffffff; + border: 1px solid var(--color-stone-200); + border-radius: 0.625rem; + overflow-x: auto; +} + +.md pre code { + display: block; + background: transparent; + border: none; + padding: 0.875rem 1rem; + font-size: 0.85rem; + line-height: 1.55; +} + +.md blockquote { + border-left: 3px solid var(--color-stone-300); + padding-left: 0.875rem; + color: var(--color-stone-600); +} + +.md hr { + border: none; + border-top: 1px solid var(--color-stone-200); + margin: 1rem 0; +} + +.md table { + border-collapse: collapse; + width: 100%; + font-size: 0.9375rem; +} + +.md th, +.md td { + border: 1px solid var(--color-stone-200); + padding: 0.375rem 0.625rem; + text-align: left; +} + +.md th { + background-color: var(--color-stone-100); + font-weight: 600; +} + +.md img { + max-width: 100%; + border-radius: 0.5rem; +} diff --git a/frontend/app/join/page.tsx b/frontend/app/join/page.tsx new file mode 100644 index 0000000..3563f7b --- /dev/null +++ b/frontend/app/join/page.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { ApiError, joinInfo, joinWorkshop, type JoinInfo } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { Markdown } from "@/components/ui/Markdown"; + +function JoinSkeleton() { + return ( +
        + + + + + + +
        + ); +} + +function ErrorCard({ title, hint }: { title: string; hint: string }) { + return ( +
        + + +

        {title}

        +

        {hint}

        +
        +
        + ); +} + +function JoinInner() { + const params = useSearchParams(); + const router = useRouter(); + const slug = params.get("s"); + + const [info, setInfo] = useState(null); + const [loadError, setLoadError] = useState<"not_found" | "network" | null>(null); + const [name, setName] = useState(""); + const [nameError, setNameError] = useState(null); + const [joining, setJoining] = useState(false); + const [joinError, setJoinError] = useState(null); + + useEffect(() => { + if (!slug) return; + let cancelled = false; + joinInfo(slug) + .then((res) => { + if (!cancelled) setInfo(res); + }) + .catch((err) => { + if (cancelled) return; + setLoadError( + err instanceof ApiError && err.status === 404 ? "not_found" : "network", + ); + }); + return () => { + cancelled = true; + }; + }, [slug]); + + // Cookie auto-resume: this browser already joined — forward to the tracker. + useEffect(() => { + if (!info?.me) return; + const token = info.me.participant_token; + const timer = setTimeout(() => { + router.replace(`/p/?t=${encodeURIComponent(token)}`); + }, 1400); + return () => clearTimeout(timer); + }, [info, router]); + + if (!slug) { + return ( + + ); + } + if (loadError === "not_found") { + return ( + + ); + } + if (loadError === "network") { + return ( + + ); + } + if (!info) return ; + + const { workshop, me } = info; + + if (me) { + return ( +
        + + +

        + Welcome back, {me.name} +

        +

        + Taking you to your tracker for “{workshop.name}”… +

        + +
        +
        + ); + } + + if (workshop.status === "archived") { + return ( + + ); + } + + const submit = async () => { + const trimmed = name.trim(); + if (trimmed.length < 1) { + setNameError("Enter your name so the room knows who you are."); + return; + } + if (trimmed.length > 80) { + setNameError("That name is too long — keep it under 80 characters."); + return; + } + setNameError(null); + setJoinError(null); + setJoining(true); + try { + const res = await joinWorkshop(slug, trimmed); + router.replace(`/p/?t=${encodeURIComponent(res.participant_token)}`); + } catch (err) { + setJoining(false); + setJoinError( + err instanceof ApiError + ? err.message + : "Couldn't join — check your connection and try again.", + ); + } + }; + + return ( +
        + +

        + {workshop.name} +

        +

        + {workshop.milestone_count}{" "} + {workshop.milestone_count === 1 ? "milestone" : "milestones"} ·{" "} + {workshop.participant_count === 0 + ? "be the first to join" + : `${workshop.participant_count} ${ + workshop.participant_count === 1 ? "person is" : "people are" + } in`} +

        + + {workshop.description_md.trim() !== "" && ( +
        + {workshop.description_md} +
        + )} + +
        { + e.preventDefault(); + void submit(); + }} + > + + { + setName(e.target.value); + if (nameError) setNameError(null); + }} + placeholder="e.g. Priya" + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-brand-500" + /> + {nameError && ( +

        + {nameError} +

        + )} + {joinError && ( +

        + {joinError} +

        + )} + +
        +
        +
        + ); +} + +export default function JoinPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..46fa174 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "highlight.js/styles/github.css"; +import "./globals.css"; +import { ToastProvider } from "@/components/ui/Toast"; + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", + display: "swap", +}); + +export const metadata: Metadata = { + title: "Workshop Helmsman", + description: + "Self-hosted workshop tracker — live milestones, leaderboard, and help desk for facilitator-led labs.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/frontend/app/p/page.tsx b/frontend/app/p/page.tsx new file mode 100644 index 0000000..2d09e39 --- /dev/null +++ b/frontend/app/p/page.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSearchParams } from "next/navigation"; +import { + ApiError, + completeMilestone, + participantContent, + participantResolveHelp, + participantState, + prettyParticipantUrl, + submitHelp, + uncompleteMilestone, + type ContentPayload, + type StatePayload, + type TrackerHelpRequest, +} from "@/lib/api"; +import { usePoll } from "@/lib/poll"; +import { useNowTick } from "@/lib/format"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { ProgressBar } from "@/components/ui/ProgressBar"; +import { ConnectionIndicator } from "@/components/ui/ConnectionIndicator"; +import { useToast } from "@/components/ui/Toast"; +import { MilestoneList } from "@/components/participant/MilestoneList"; +import { Leaderboard } from "@/components/participant/Leaderboard"; +import { HelpPanel } from "@/components/participant/HelpPanel"; +import { PersonalLinkCallout } from "@/components/participant/PersonalLinkCallout"; + +function TrackerSkeleton() { + return ( +
        +
        +
        + + +
        +
        +
        +
        + + + + +
        +
        + + +
        +
        +
        + ); +} + +function ErrorScreen({ title, hint }: { title: string; hint: string }) { + return ( +
        + + +

        {title}

        +

        {hint}

        +
        +
        + ); +} + +function TrackerInner() { + const params = useSearchParams(); + const token = params.get("t"); + const toast = useToast(); + const nowMs = useNowTick(30000); + + // ---- state poll (3 s visible / 15 s hidden) ----------------------------- + const fetcher = useCallback( + (v: number) => participantState(token as string, v), + [token], + ); + const { data, contentVersion, reconnecting, fatalError, pollNow } = + usePoll(token ? fetcher : null, { + visibleMs: 3000, + hiddenMs: 15000, + }); + + // ---- milestone bodies (content endpoint, keyed by content_version) ------ + const [content, setContent] = useState(null); + const contentCvRef = useRef(-1); + const [contentRetry, setContentRetry] = useState(0); + useEffect(() => { + if (!token || contentVersion < 0) return; + if (content !== null && contentCvRef.current >= contentVersion) return; + let cancelled = false; + let retryTimer: ReturnType | null = null; + participantContent(token, contentCvRef.current) + .then((res) => { + if (cancelled) return; + contentCvRef.current = res.content_version; + if (res.changed) setContent(res); + }) + .catch(() => { + if (cancelled) return; + retryTimer = setTimeout(() => setContentRetry((x) => x + 1), 3000); + }); + return () => { + cancelled = true; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [token, contentVersion, contentRetry, content]); + + const contentById = useMemo(() => { + if (!content) return null; + return new Map(content.milestones.map((m) => [m.id, m.content_md])); + }, [content]); + + // ---- optimistic completion overlay -------------------------------------- + const [overlay, setOverlay] = useState>({}); + useEffect(() => { + if (!data) return; + setOverlay((prev) => { + const base = new Set(data.me.completed_milestone_ids); + const next: Record = {}; + let changed = false; + for (const [k, v] of Object.entries(prev)) { + if (base.has(Number(k)) === v) { + changed = true; // server agrees — drop the overlay entry + } else { + next[Number(k)] = v; + } + } + return changed ? next : prev; + }); + }, [data]); + + const completedIds = useMemo(() => { + const s = new Set(data?.me.completed_milestone_ids ?? []); + for (const [k, v] of Object.entries(overlay)) { + if (v) s.add(Number(k)); + else s.delete(Number(k)); + } + return s; + }, [data, overlay]); + + const currentId = useMemo(() => { + const ordered = [...(data?.milestones ?? [])].sort( + (a, b) => a.position - b.position, + ); + for (const m of ordered) { + if (!completedIds.has(m.id)) return m.id; + } + return null; + }, [data, completedIds]); + + const onToggle = async (milestoneId: number, next: boolean) => { + if (!token) return; + setOverlay((prev) => ({ ...prev, [milestoneId]: next })); + try { + if (next) await completeMilestone(token, milestoneId); + else await uncompleteMilestone(token, milestoneId); + pollNow(); + } catch (err) { + setOverlay((prev) => { + const copy = { ...prev }; + delete copy[milestoneId]; + return copy; + }); + toast.show( + err instanceof ApiError + ? `Couldn't save that — ${err.message}` + : "Couldn't save that — check your connection and try again.", + "error", + ); + } + }; + + // ---- optimistic help ---------------------------------------------------- + const [helpSubmitting, setHelpSubmitting] = useState(false); + const [tempHelp, setTempHelp] = useState<{ + message: string; + created_at: string; + minVersion: number | null; + } | null>(null); + const [resolvedOverride, setResolvedOverride] = useState>(new Set()); + + useEffect(() => { + if (!data) return; + // Prune the temp entry once the poll payload includes the real request. + setTempHelp((prev) => + prev && prev.minVersion !== null && data.version >= prev.minVersion + ? null + : prev, + ); + // Prune resolve overrides the server now reflects. + setResolvedOverride((prev) => { + const stillPending = new Set( + [...prev].filter((id) => + data.help_requests.some((r) => r.id === id && r.status !== "resolved"), + ), + ); + return stillPending.size === prev.size ? prev : stillPending; + }); + }, [data]); + + const helpRequests: TrackerHelpRequest[] = useMemo(() => { + let merged = (data?.help_requests ?? []).map((r) => + resolvedOverride.has(r.id) ? { ...r, status: "resolved" as const } : r, + ); + if (tempHelp) { + merged = [ + { + id: -1, + message: tempHelp.message, + status: "open" as const, + escalated: false, + milestone_id: null, + created_at: tempHelp.created_at, + answers: [], + }, + ...merged, + ]; + } + return merged; + }, [data, tempHelp, resolvedOverride]); + + const onSubmitHelp = async (message: string): Promise => { + if (!token) return false; + setHelpSubmitting(true); + setTempHelp({ + message, + created_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + minVersion: null, + }); + try { + const res = await submitHelp(token, message); + setTempHelp((prev) => (prev ? { ...prev, minVersion: res.version } : prev)); + pollNow(); + return true; + } catch (err) { + setTempHelp(null); + toast.show( + err instanceof ApiError + ? `Couldn't send that — ${err.message}` + : "Couldn't send that — check your connection and try again.", + "error", + ); + return false; + } finally { + setHelpSubmitting(false); + } + }; + + const onResolveHelp = async (id: number) => { + if (!token) return; + setResolvedOverride((prev) => new Set(prev).add(id)); + try { + await participantResolveHelp(token, id); + pollNow(); + } catch (err) { + setResolvedOverride((prev) => { + const copy = new Set(prev); + copy.delete(id); + return copy; + }); + toast.show( + err instanceof ApiError + ? `Couldn't resolve that — ${err.message}` + : "Couldn't resolve that — check your connection and try again.", + "error", + ); + } + }; + + // ---- render ------------------------------------------------------------- + if (!token) { + return ( + + ); + } + if (fatalError) { + return ( + + ); + } + if (!data) return ; + + const total = data.me.total_count; + const doneCount = completedIds.size; + const progressPct = total > 0 ? (doneCount / total) * 100 : 0; + const personalUrl = + typeof window === "undefined" ? "" : prettyParticipantUrl(token); + const archived = data.workshop.status === "archived"; + + return ( +
        +
        +
        +

        + {data.workshop.name} +

        + +
        + + + {doneCount} / {total} + +
        +
        +
        + + {data.workshop.paused && ( +
        + The facilitator paused the workshop — completions are locked for a moment. +
        + )} + {archived && ( +
        + This workshop has ended — you're browsing the archive. +
        + )} + +
        +
        + + +
        + +
        + +

        + Leaderboard +

        + +
        + + + +
        +
        +
        + ); +} + +export default function TrackerPage() { + return ( + }> + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..558d928 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + ApiError, + adminListWorkshops, + type AdminWorkshopSummary, + type WorkshopFull, +} from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Card } from "@/components/ui/Card"; +import { Skeleton } from "@/components/ui/Skeleton"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { StubBadge, StubCard } from "@/components/ui/StubBadge"; +import { useToast } from "@/components/ui/Toast"; +import { WorkshopCard } from "@/components/facilitator/WorkshopCard"; +import { CreateWorkshopModal } from "@/components/facilitator/CreateWorkshopModal"; + +const LS_ADMIN_KEY = "helmsman_admin_key"; + +type Phase = "boot" | "gate" | "authed"; + +export default function AdminHomePage() { + const toast = useToast(); + const [phase, setPhase] = useState("boot"); + const [adminKey, setAdminKey] = useState(""); + const [keyInput, setKeyInput] = useState(""); + const [gateError, setGateError] = useState(null); + const [gateChecking, setGateChecking] = useState(false); + const [workshops, setWorkshops] = useState(null); + const [listError, setListError] = useState(null); + const [refreshing, setRefreshing] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [justCreatedId, setJustCreatedId] = useState(null); + + const validateKey = useCallback( + async (key: string, fromSaved: boolean) => { + setGateChecking(true); + try { + const { workshops: list } = await adminListWorkshops(key); + try { + window.localStorage.setItem(LS_ADMIN_KEY, key); + } catch { + // Private-mode browsers: the key just won't persist. + } + setAdminKey(key); + setWorkshops(list); + setGateError(null); + setPhase("authed"); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + try { + window.localStorage.removeItem(LS_ADMIN_KEY); + } catch { + // ignore + } + setGateError( + fromSaved + ? "Your saved key no longer matches this server — enter it again." + : "invalid_key", + ); + } else { + setGateError( + "Couldn't reach the server — check it's running, then try again.", + ); + } + setPhase("gate"); + } finally { + setGateChecking(false); + } + }, + [], + ); + + useEffect(() => { + let saved: string | null = null; + try { + saved = window.localStorage.getItem(LS_ADMIN_KEY); + } catch { + // ignore + } + if (saved) { + void validateKey(saved, true); + } else { + setPhase("gate"); + } + }, [validateKey]); + + const refreshList = useCallback(async () => { + if (!adminKey) return; + setRefreshing(true); + setListError(null); + try { + const { workshops: list } = await adminListWorkshops(adminKey); + setWorkshops(list); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setPhase("gate"); + setGateError("Your key no longer matches this server — enter it again."); + } else { + setListError("Couldn't refresh the list — check your connection and try again."); + } + } finally { + setRefreshing(false); + } + }, [adminKey]); + + const onCreated = (workshop: WorkshopFull) => { + setCreateOpen(false); + setJustCreatedId(workshop.id); + toast.show(`“${workshop.name}” is live`, "success"); + void refreshList(); + }; + + const signOut = () => { + try { + window.localStorage.removeItem(LS_ADMIN_KEY); + } catch { + // ignore + } + setAdminKey(""); + setWorkshops(null); + setKeyInput(""); + setGateError(null); + setPhase("gate"); + }; + + // ------------------------------------------------------------- boot + if (phase === "boot") { + return ( +
        + +
        + + +
        +
        + ); + } + + // ------------------------------------------------------------- gate + if (phase === "gate") { + return ( +
        + +
        + +

        + Workshop Helmsman +

        +

        + Enter this server's facilitator access key to manage workshops. +

        +
        +
        { + e.preventDefault(); + const key = keyInput.trim(); + if (key === "") { + setGateError("invalid_key_empty"); + return; + } + void validateKey(key, false); + }} + > + + setKeyInput(e.target.value)} + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-900 focus:border-brand-500" + /> + {gateError && ( +

        + {gateError === "invalid_key" ? ( + <> + That key doesn't match this server's{" "} + + HELMSMAN_ADMIN_KEY + + . + + ) : gateError === "invalid_key_empty" ? ( + "Enter the access key to continue." + ) : ( + gateError + )} +

        + )} + +
        +
        +
        + ); + } + + // ----------------------------------------------------------- authed + const list = workshops ?? []; + + return ( +
        +
        +
        + +

        Workshop Helmsman

        +
        + +
        + + {listError && ( +

        + {listError} + +

        + )} + +
        +

        + Live +

        + {list.length === 0 ? ( + setCreateOpen(true)}>+ New workshop + } + /> + ) : ( +
        + {list.map((w) => ( + + ))} +
        + )} +
        + +
        + + +
        + + setCreateOpen(false)} + onCreated={onCreated} + /> +
        + ); +} diff --git a/frontend/components/facilitator/CreateWorkshopModal.tsx b/frontend/components/facilitator/CreateWorkshopModal.tsx new file mode 100644 index 0000000..35ceda7 --- /dev/null +++ b/frontend/components/facilitator/CreateWorkshopModal.tsx @@ -0,0 +1,344 @@ +"use client"; + +import { useState } from "react"; +import { + ApiError, + adminCreateWorkshop, + type MilestoneInput, + type WorkshopFull, +} from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import { Markdown } from "@/components/ui/Markdown"; +import { StubCard } from "@/components/ui/StubBadge"; +import { cn } from "@/lib/format"; + +interface MilestoneRow { + key: number; + title: string; + content: string; + minutes: string; + tab: "write" | "preview"; +} + +let rowKey = 1; + +function newRow(): MilestoneRow { + return { key: rowKey++, title: "", content: "", minutes: "", tab: "write" }; +} + +export function CreateWorkshopModal({ + open, + adminKey, + onClose, + onCreated, +}: { + open: boolean; + adminKey: string; + onClose: () => void; + onCreated: (workshop: WorkshopFull) => void; +}) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const patchRow = (key: number, patch: Partial) => + setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r))); + + const moveRow = (key: number, dir: -1 | 1) => + setRows((prev) => { + const i = prev.findIndex((r) => r.key === key); + const j = i + dir; + if (i < 0 || j < 0 || j >= prev.length) return prev; + const next = [...prev]; + [next[i], next[j]] = [next[j], next[i]]; + return next; + }); + + const removeRow = (key: number) => + setRows((prev) => prev.filter((r) => r.key !== key)); + + const reset = () => { + setName(""); + setDescription(""); + setRows([]); + setError(null); + }; + + const submit = async () => { + setError(null); + const trimmedName = name.trim(); + if (trimmedName.length < 1 || trimmedName.length > 120) { + setError("Give the workshop a name (1–120 characters)."); + return; + } + if (description.length > 10000) { + setError("The description is too long (max 10,000 characters)."); + return; + } + // Ignore rows that were added but left entirely empty. + const meaningful = rows.filter( + (r) => r.title.trim() !== "" || r.content.trim() !== "", + ); + if (meaningful.length === 0) { + setError("Add at least one milestone — participants need something to work through."); + return; + } + const milestones: MilestoneInput[] = []; + for (const [i, r] of meaningful.entries()) { + const title = r.title.trim(); + if (title.length < 1 || title.length > 200) { + setError(`Milestone ${i + 1} needs a title (1–200 characters).`); + return; + } + if (r.content.length > 20000) { + setError(`Milestone ${i + 1}'s instructions are too long (max 20,000 characters).`); + return; + } + let minutes: number | null = null; + if (r.minutes.trim() !== "") { + minutes = Number(r.minutes); + if (!Number.isInteger(minutes) || minutes < 1 || minutes > 480) { + setError(`Milestone ${i + 1}: minutes must be a whole number from 1 to 480.`); + return; + } + } + milestones.push({ title, content_md: r.content, minutes }); + } + + setSubmitting(true); + try { + const { workshop } = await adminCreateWorkshop(adminKey, { + name: trimmedName, + description_md: description, + milestones, + }); + reset(); + onCreated(workshop); + } catch (err) { + setError( + err instanceof ApiError + ? err.message + : "Something went wrong creating the workshop — try again.", + ); + } finally { + setSubmitting(false); + } + }; + + return ( + +
        +
        + + setName(e.target.value)} + placeholder="LangGraph Lab — July" + className="w-full rounded-lg border border-stone-300 bg-white px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-brand-500" + /> +
        + +
        + +