Implement S6 doctor and A1 daily-board with fixtures.

Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
This commit is contained in:
Ty
2026-07-27 12:41:40 -07:00
parent 0198ab6881
commit 15f6a6c713
24 changed files with 2011 additions and 40 deletions
+18 -5
View File
@@ -12,6 +12,8 @@ help:
@echo " make install - full install S0bS5"
@echo " make install-s0-s2 - install stages S0b through S2"
@echo " make install-s3-s5 - install stages S3 through S5 (stack, sandbox, policy, skills)"
@echo " make doctor - health checks (S6)"
@echo " make verify - unit tests (fixtures, no model)"
@echo ""
@echo "Staged install:"
@echo " make install-s0b - S0b only: Docker bootstrap"
@@ -23,8 +25,6 @@ help:
@echo ""
@echo "Not yet implemented (stubbed):"
@echo " make upgrade - product upgrade"
@echo " make doctor - health checks"
@echo " make verify - lint + tests + smoke (fixtures)"
@echo " make sync-design - list design pack paths"
# ── Implemented targets ────────────────────────────────────────────────────
@@ -61,10 +61,23 @@ install-s4:
install-s5:
@bash scripts/install.sh --stage s5
# ── Stubbed targets (S6+ not yet implemented) ──────────────────────────────
# ── S6: Doctor ─────────────────────────────────────────────────────────────
upgrade doctor verify:
@echo "not implemented — S6+ stages pending" >&2; exit 1
doctor:
@bash scripts/doctor.sh
# ── Stubbed targets ────────────────────────────────────────────────────────
upgrade:
@echo "not implemented — upgrade pending" >&2; exit 1
# ── Verify ─────────────────────────────────────────────────────────────────
verify:
@echo "Running unit tests..."
@python3 -m pytest tests/unit/ -v
@echo ""
@echo "verify: OK"
sync-design:
@echo "Design SSOT:"
+37 -4
View File
@@ -1,11 +1,44 @@
# Fixtures (scaffold)
# Fixtures
Demo persona when implemented: **Claire Bennett**, **Lumina Hair Studio & Spa**.
Demo persona: **Claire Bennett**, **Lumina Hair Studio & Spa**.
All fixture data is clearly labeled in skill output — never silent fake live data.
| Path | Purpose |
|------|---------|
| `books/` | Sample QBO-shaped JSON |
| `scheduling/` | Sample appointment data for daily-board |
| `books/` | Sample QBO-shaped JSON (future) |
| `media/` | Sample social media assets for vision tests |
| `../recorded/` | Optional recorded responses (local only; do not commit secrets) |
No fixture JSON committed until **build** unless explicitly requested.
## Scheduling fixtures
| File | Description |
|------|-------------|
| `scheduling/claire_bennett_2026-07-28.json` | Sample salon day: 7 appointments (2 staff, 1 cancelled) |
### Fixture schema
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {"open": "09:00", "close": "18:00"},
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:30:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss"
}
]
}
```
**Status values:** `confirmed`, `pending`, `cancelled`, `completed`, `no_show`
@@ -0,0 +1,97 @@
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {
"open": "09:00",
"close": "18:00"
},
"staff": [
{
"name": "Claire Bennett",
"role": "owner-stylist"
},
{
"name": "Maya Torres",
"role": "colorist"
}
],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:30:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss. Allergic to ammonia."
},
{
"id": "APT-002",
"start": "2026-07-28T10:30:00",
"end": "2026-07-28T11:30:00",
"client_name": "Sarah Kim",
"service_name": "Blowout + Updo",
"staff_name": "Claire Bennett",
"status": "pending",
"needs_confirmation": true,
"notes": "Wedding guest — updo reference photo sent."
},
{
"id": "APT-003",
"start": "2026-07-28T11:00:00",
"end": "2026-07-28T12:30:00",
"client_name": "Jasmine Patel",
"service_name": "Root Touch-Up",
"staff_name": "Maya Torres",
"status": "confirmed",
"needs_confirmation": false,
"notes": "2B dark brown. Regular client."
},
{
"id": "APT-004",
"start": "2026-07-28T13:00:00",
"end": "2026-07-28T14:00:00",
"client_name": "Chris Nguyen",
"service_name": "Men's Cut",
"staff_name": "Claire Bennett",
"status": "pending",
"needs_confirmation": true,
"notes": "Running late from work — may be 15 min behind."
},
{
"id": "APT-005",
"start": "2026-07-28T14:00:00",
"end": "2026-07-28T15:30:00",
"client_name": "Priya Sharma",
"service_name": "Deep Conditioning Treatment",
"staff_name": "Maya Torres",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Post-color repair. Keratin-safe product only."
},
{
"id": "APT-006",
"start": "2026-07-28T15:30:00",
"end": "2026-07-28T17:00:00",
"client_name": "Aisha Williams",
"service_name": "Full Color + Style",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "First visit — consultation included."
},
{
"id": "APT-007",
"start": "2026-07-28T12:30:00",
"end": "2026-07-28T13:00:00",
"client_name": "Tom Bradley",
"service_name": "Quick Trim",
"staff_name": "Maya Torres",
"status": "cancelled",
"needs_confirmation": false,
"notes": "Client cancelled — rescheduled to next week."
}
]
}
+5 -4
View File
@@ -1,6 +1,6 @@
# Install
**Status:** Stages S0S5 implemented. S6S7 pending.
**Status:** Stages S0S6 implemented. S7 pending.
## Stages
@@ -13,7 +13,7 @@
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ Implemented |
| S6 | Host script | Doctor green | ⏳ Pending |
| S6 | Host script | Doctor green | ✅ Implemented |
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending |
## Platform commands (normative)
@@ -156,13 +156,14 @@ make install
make install-s3-s5
```
## After install (S0S5)
## After install (S0S6)
- Verify `.env` values are correct for your environment.
- Check policy: `nemohermes <name> policy-list`
- Continue with S6 (doctor) when implemented.
- Run health checks: `make doctor`
- See [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install.
- See [design/scenarios.md](../design/scenarios.md) (S1S5) for operational scenarios.
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
## UAT host notes
+43 -3
View File
@@ -1,14 +1,54 @@
# Operations (day-2)
**Status:** Outline.
**Status:** Doctor (S6) implemented.
## Operator commands (when implemented)
## Health checks (doctor)
```bash
# Run all health checks (human-readable)
make doctor
# or
./scripts/doctor.sh
# Machine-readable JSON summary
./scripts/doctor.sh --json
```
**What doctor checks:**
| Check | What it validates | Severity |
|-------|-------------------|----------|
| Docker | Daemon running and accessible | Critical |
| nemohermes CLI | Installed and versioned | Critical |
| openshell CLI | Installed and versioned | Critical |
| Sandbox | `nemohermes <name> doctor` healthy | Critical |
| Policy | `lumina-inference` preset applied | Warning |
| Skills | Skill directories with SKILL.md present | Warning |
| Inference endpoint | `/v1/models` reachable from .env URL | Critical |
| Inference gateway | `openshell inference get` configured | Warning |
**Exit codes:** `0` = all critical checks passed; `1` = one or more critical failures.
## Platform diagnostics
```bash
# Sandbox status
nemohermes <sandbox-name> status
# Sandbox doctor (platform-level)
nemohermes <sandbox-name> doctor
# Sandbox logs
nemohermes <sandbox-name> logs --follow
docker compose -f deploy/compose/docker-compose.yml logs
# Gateway status
openshell status
# Inference config
openshell inference get
# Policy presets
nemohermes <sandbox-name> policy-list
```
## Log levels
+7 -2
View File
@@ -1,6 +1,6 @@
# Host scripts
**Status:** S0bS5 implemented. S6S7 pending.
**Status:** S0bS6 implemented. S7 pending.
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
@@ -14,8 +14,8 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
| `install/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 |
| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 |
| `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 |
| `doctor.sh` | Health checks (Docker, CLIs, sandbox, policy, skills, inference) | ✅ S6 |
| `upgrade.sh` | Snapshot, pull pins, migrate, re-apply policy, doctor | ⏳ Pending |
| `doctor.sh` | Health checks | ⏳ Pending |
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending |
## Shared library
@@ -42,6 +42,10 @@ All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control AP
./scripts/install.sh --stage s5 # policy + skills
./scripts/install.sh --stage s3-s5 # S3 through S5
# Health checks (S6)
./scripts/doctor.sh # human-readable
./scripts/doctor.sh --json # machine-readable
# Or via Make
make bootstrap
make install
@@ -49,6 +53,7 @@ make install-s1
make install-s2
make install-s3-s5
make install-s5
make doctor
```
## Design reference
+355
View File
@@ -0,0 +1,355 @@
#!/usr/bin/env bash
# scripts/doctor.sh — S6: Lumina product health checks
#
# Composes platform-layer health checks:
# Docker · nemohermes · openshell · sandbox · policy · skills · inference
#
# Platform-first: wraps nemohermes / openshell / Docker. No parallel control API.
#
# Usage:
# ./scripts/doctor.sh # full check
# ./scripts/doctor.sh --json # machine-readable summary
# ./scripts/doctor.sh --help
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Source shared helpers
# shellcheck source=lib/common.sh
source "$SCRIPT_DIR/lib/common.sh"
# shellcheck source=lib/env.sh
source "$SCRIPT_DIR/lib/env.sh"
# ── Defaults ───────────────────────────────────────────────────────────────
JSON_OUTPUT=0
# ── Parse args ─────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
S6: Lumina product health checks.
Checks:
Docker daemon, nemohermes CLI, openshell CLI, sandbox status,
policy overlays, skills directory, inference endpoint.
Options:
--json Machine-readable JSON summary
--help Show this help
Exit codes:
0 All critical checks passed (warnings are non-fatal)
1 One or more critical checks failed
EOF
exit 0
;;
--json)
JSON_OUTPUT=1
shift
;;
*)
log_error "Unknown argument: $1"
exit 1
;;
esac
done
# ── State tracking ─────────────────────────────────────────────────────────
CRITICAL_FAIL=0
WARN_COUNT=0
declare -a CHECK_RESULTS=()
# Strip ANSI escape codes from a string
strip_ansi() {
sed 's/\x1b\[[0-9;]*m//g' <<< "$1"
}
# Record a check result: "group|label|status|detail"
record_check() {
local group="$1" label="$2" status="$3" detail="$4"
# Strip any ANSI codes that may have leaked from CLI output
detail="$(strip_ansi "$detail")"
CHECK_RESULTS+=("${group}|${label}|${status}|${detail}")
if [[ "$status" == "FAIL" ]]; then
CRITICAL_FAIL=1
elif [[ "$status" == "WARN" ]]; then
WARN_COUNT=$((WARN_COUNT + 1))
fi
}
# ── Load .env (best-effort; warn if missing) ───────────────────────────────
if [[ $JSON_OUTPUT -eq 1 ]]; then
load_env >/dev/null 2>&1 || true
else
load_env 2>/dev/null || true
fi
SANDBOX_NAME="$(get_sandbox_name)"
SKILLS_DIR="$REPO_ROOT/skills"
POLICY_DIR="$REPO_ROOT/policy/openshell/overlays"
# ── Check: Docker ──────────────────────────────────────────────────────────
check_docker() {
if ! cmd_exists docker; then
record_check "Docker" "CLI" "FAIL" "docker command not found"
return
fi
if ! docker info &>/dev/null; then
record_check "Docker" "Daemon" "FAIL" "docker daemon not running or not accessible"
return
fi
local version
version="$(docker --version 2>/dev/null | sed 's/^Docker version //' | cut -d',' -f1 | tr -d ' ')"
record_check "Docker" "Daemon" "OK" "running ($version)"
}
# ── Check: nemohermes CLI ──────────────────────────────────────────────────
check_nemohermes() {
if ! cmd_exists nemohermes; then
record_check "CLI" "nemohermes" "FAIL" "nemohermes not found — install NemoClaw platform"
return
fi
local version
version="$(nemohermes --version 2>/dev/null | head -1 | grep -oP 'v[\d.]+' || echo 'unknown')"
record_check "CLI" "nemohermes" "OK" "$version"
}
# ── Check: openshell CLI ───────────────────────────────────────────────────
check_openshell() {
if ! cmd_exists openshell; then
record_check "CLI" "openshell" "FAIL" "openshell not found — install OpenShell"
return
fi
local version
version="$(openshell --version 2>/dev/null | head -1 | grep -oP '[\d.]+' || echo 'unknown')"
record_check "CLI" "openshell" "OK" "$version"
}
# ── Check: Sandbox status ──────────────────────────────────────────────────
check_sandbox() {
# Skip if nemohermes is missing (already flagged)
if ! cmd_exists nemohermes; then
record_check "Sandbox" "Status" "FAIL" "skipped — nemohermes not available"
return
fi
# Run nemohermes doctor for the sandbox — this is the authoritative platform check
local doctor_output
doctor_output="$(nemohermes "$SANDBOX_NAME" doctor 2>&1)" || true
# Check for summary line
if echo "$doctor_output" | grep -qi "Summary: healthy"; then
record_check "Sandbox" "Doctor" "OK" "$SANDBOX_NAME healthy"
elif echo "$doctor_output" | grep -qi "Summary:.*warning"; then
record_check "Sandbox" "Doctor" "WARN" "$SANDBOX_NAME has warnings"
elif echo "$doctor_output" | grep -qi "Summary:.*unhealthy\|Summary:.*critical"; then
record_check "Sandbox" "Doctor" "FAIL" "$SANDBOX_NAME unhealthy"
else
# Fallback: check status command
if nemohermes "$SANDBOX_NAME" status &>/dev/null; then
record_check "Sandbox" "Status" "OK" "$SANDBOX_NAME reachable"
else
record_check "Sandbox" "Status" "FAIL" "$SANDBOX_NAME not found or not reachable"
fi
fi
}
# ── Check: Policy overlays ─────────────────────────────────────────────────
check_policy() {
# Skip if nemohermes is missing
if ! cmd_exists nemohermes; then
record_check "Policy" "Overlays" "FAIL" "skipped — nemohermes not available"
return
fi
# Check that policy overlay files exist in the repo
local inference_policy="$POLICY_DIR/inference.yaml"
if [[ ! -f "$inference_policy" ]]; then
record_check "Policy" "Overlay files" "WARN" "inference.yaml not found in $POLICY_DIR"
fi
# Check that lumina-inference preset is applied in the sandbox
local policy_list
policy_list="$(nemohermes "$SANDBOX_NAME" policy-list 2>&1)" || {
record_check "Policy" "policy-list" "FAIL" "could not list policy presets"
return
}
if echo "$policy_list" | grep -q "lumina-inference"; then
record_check "Policy" "lumina-inference" "OK" "preset applied"
else
record_check "Policy" "lumina-inference" "WARN" "preset not found in sandbox — run S5 or: nemohermes $SANDBOX_NAME policy-add --from-file $inference_policy --yes"
fi
}
# ── Check: Skills ──────────────────────────────────────────────────────────
check_skills() {
if [[ ! -d "$SKILLS_DIR" ]]; then
record_check "Skills" "Directory" "FAIL" "skills directory not found at $SKILLS_DIR"
return
fi
# Count skill directories (exclude _lib and hidden)
local skill_count=0
local skill_with_md=0
for skill_dir in "$SKILLS_DIR"/*/; do
[[ -d "$skill_dir" ]] || continue
local name
name="$(basename "$skill_dir")"
[[ "$name" == "_lib" ]] && continue
[[ "$name" == "README.md" ]] && continue
skill_count=$((skill_count + 1))
if [[ -f "$skill_dir/SKILL.md" ]]; then
skill_with_md=$((skill_with_md + 1))
fi
done
if [[ $skill_count -eq 0 ]]; then
record_check "Skills" "Pack" "WARN" "no skill directories found in $SKILLS_DIR"
elif [[ $skill_with_md -lt $skill_count ]]; then
record_check "Skills" "Pack" "WARN" "$skill_with_md/$skill_count skills have SKILL.md"
else
record_check "Skills" "Pack" "OK" "$skill_count skills with SKILL.md"
fi
}
# ── Check: Inference endpoint ──────────────────────────────────────────────
check_inference() {
# Check .env has the required keys
local env_file="${REPO_ROOT}/.env"
if [[ ! -f "$env_file" ]]; then
record_check "Inference" "Config" "FAIL" ".env not found — run S1 first"
return
fi
# Source .env to get variables (already done by load_env, but verify)
local base_url="${LUMINA_INFERENCE_BASE_URL:-}"
local model="${LUMINA_INFERENCE_MODEL:-}"
if [[ -z "$base_url" ]]; then
record_check "Inference" "Endpoint URL" "FAIL" "LUMINA_INFERENCE_BASE_URL not set in .env"
return
fi
if [[ -z "$model" ]]; then
record_check "Inference" "Model" "FAIL" "LUMINA_INFERENCE_MODEL not set in .env"
return
fi
# Check endpoint reachability
local url="$base_url"
# Ensure URL ends with /v1 for the models endpoint
if [[ "$url" != */v1 && "$url" != */v1/* ]]; then
url="${url%/}/v1"
fi
if curl -sf --max-time 15 "${url}/models" &>/dev/null; then
record_check "Inference" "Endpoint" "OK" "reachable ($base_url)"
else
record_check "Inference" "Endpoint" "FAIL" "unreachable at $base_url"
fi
# Check openshell inference config (optional — gateway may not be connected)
if cmd_exists openshell; then
local inf_output
inf_output="$(openshell inference get 2>&1)" || true
if echo "$inf_output" | grep -q "Provider:"; then
local provider
# Strip ANSI escape codes and whitespace
provider="$(echo "$inf_output" | grep "Provider:" | head -1 | sed 's/.*Provider: *//' | sed 's/\x1b\[[0-9;]*m//g' | tr -d '[:space:]')"
record_check "Inference" "Gateway route" "OK" "configured ($provider)"
else
record_check "Inference" "Gateway route" "WARN" "not configured via openshell"
fi
fi
}
# ── Run all checks ─────────────────────────────────────────────────────────
if [[ $JSON_OUTPUT -eq 0 ]]; then
log_section "Lumina Doctor (S6)"
fi
check_docker
check_nemohermes
check_openshell
check_sandbox
check_policy
check_skills
check_inference
# ── Output results ─────────────────────────────────────────────────────────
if [[ $JSON_OUTPUT -eq 1 ]]; then
# Machine-readable JSON summary
checks_json="["
first=1
for entry in "${CHECK_RESULTS[@]}"; do
IFS='|' read -r group label status detail <<< "$entry"
if [[ $first -eq 1 ]]; then
first=0
else
checks_json+=","
fi
# Escape backslashes first, then double quotes (JSON-safe)
detail="${detail//\\/\\\\}"
detail="${detail//\"/\\\"}"
checks_json+="{\"group\":\"$group\",\"label\":\"$label\",\"status\":\"$status\",\"detail\":\"$detail\"}"
done
checks_json+="]"
overall="healthy"
[[ $CRITICAL_FAIL -eq 1 ]] && overall="unhealthy"
cat <<EOF
{
"product": "Lumina",
"stage": "S6",
"sandbox": "$SANDBOX_NAME",
"overall": "$overall",
"critical_failures": $CRITICAL_FAIL,
"warnings": $WARN_COUNT,
"checks": $checks_json
}
EOF
else
# Human-readable summary
log_section "Results"
ok_count=0
warn_count=0
fail_count=0
for entry in "${CHECK_RESULTS[@]}"; do
IFS='|' read -r group label status detail <<< "$entry"
case "$status" in
OK) printf " \033[0;32m[OK]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
WARN) printf " \033[1;33m[WARN]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
FAIL) printf " \033[0;31m[FAIL]\033[0m %-12s %s — %s\n" "$group" "$label" "$detail" ;;
esac
case "$status" in
OK) ok_count=$((ok_count + 1)) ;;
WARN) warn_count=$((warn_count + 1)) ;;
FAIL) fail_count=$((fail_count + 1)) ;;
esac
done
log_section "Summary"
printf " Checks: %d OK, %d WARN, %d FAIL\n" "$ok_count" "$warn_count" "$fail_count"
if [[ $CRITICAL_FAIL -eq 1 ]]; then
printf " Overall: \033[0;31mUNHEALTHY\033[0m\n"
elif [[ $WARN_COUNT -gt 0 ]]; then
printf " Overall: \033[1;33mHEALTHY (with warnings)\033[0m\n"
else
printf " Overall: \033[0;32mHEALTHY\033[0m\n"
fi
fi
# ── Exit code ──────────────────────────────────────────────────────────────
if [[ $CRITICAL_FAIL -eq 1 ]]; then
exit 1
fi
exit 0
+28 -6
View File
@@ -1,9 +1,31 @@
# Shared skill library (scaffold)
# Shared skill library
**Status:** Empty until **build**.
**Status:** Partially implemented.
Planned packages under `providers/`:
Shared deterministic library used by Salon_Assistant skills. All code here is
**deterministic** — no model inference, no network calls. See
[design/det-vs-inf.md](../../../design/det-vs-inf.md).
- `scheduling/` — Vagaro / Square adapters
- `books/` — QuickBooks Online adapters
- `mcp/` — MCP client helpers / allowlist metadata
## Packages
| Package | Status | Purpose |
|---------|--------|---------|
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
## Usage
```python
from lumina_skills.domain import Appointment, AppointmentStatus
from lumina_skills.board_builder import build_board, format_board_text
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
```
## Design references
- Deterministic boundary: [design/det-vs-inf.md](../../../design/det-vs-inf.md)
- Use cases: [design/use-cases.md](../../../design/use-cases.md)
+1
View File
@@ -0,0 +1 @@
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
+279
View File
@@ -0,0 +1,279 @@
"""Deterministic board builder.
Takes a list of Appointment objects and produces a DayBoard with:
- Sorted appointments
- Computed gaps between consecutive appointments per staff member
- Confirmation flags
- Offline/fixture labeling
All logic is deterministic — no model inference.
See design/det-vs-inf.md.
"""
from __future__ import annotations
import re
import warnings
from datetime import datetime, time
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus, DayBoard, Gap
def build_board(
appointments: list[Appointment],
date: str,
salon_name: str,
source: str = "fixtures",
business_hours: dict[str, str] | None = None,
) -> DayBoard:
"""Build a complete DayBoard from a list of appointments.
Args:
appointments: Raw appointment list (from fixtures or live source).
date: The board date in YYYY-MM-DD format.
salon_name: Display name of the salon.
source: Data source label — "fixtures", "offline", "vagaro", "square".
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"} to
compute gaps at day boundaries.
Returns:
A fully populated DayBoard.
"""
is_offline = source in ("fixtures", "offline")
# Filter out cancelled appointments for the board view.
active = [a for a in appointments if a.status != AppointmentStatus.CANCELLED]
# Sort by start time.
active.sort(key=lambda a: a.start_time)
# Compute gaps per staff member.
gaps = _compute_gaps(active, date, business_hours)
# Confirmation flags: pending appointments that need confirmation.
needs_confirmation = [a for a in active if a.needs_confirmation]
# Totals.
total_booked = sum(a.duration_minutes() for a in active)
total_gap = sum(g.duration_minutes for g in gaps)
return DayBoard(
date=date,
salon_name=salon_name,
source=source,
is_offline=is_offline,
appointments=active,
gaps=gaps,
needs_confirmation=needs_confirmation,
total_booked_minutes=total_booked,
total_gap_minutes=total_gap,
)
def _compute_gaps(
appointments: list[Appointment],
date: str,
business_hours: dict[str, str] | None = None,
) -> list[Gap]:
"""Compute unbooked gaps between consecutive appointments per staff.
Gaps are computed per staff member. If business_hours is provided,
gaps from open→first appointment and last appointment→close are
included (only if >= 30 minutes).
Args:
appointments: Sorted list of active appointments.
date: Board date string (YYYY-MM-DD).
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"}.
Returns:
List of Gap objects.
"""
gaps: list[Gap] = []
# Group appointments by staff.
staff_apts: dict[str, list[Appointment]] = {}
for apt in appointments:
staff_apts.setdefault(apt.staff_name, []).append(apt)
for staff_name, apts in staff_apts.items():
# apts is already sorted by start_time from the caller.
open_time = None
close_time = None
if business_hours:
open_str = business_hours.get("open", "")
close_str = business_hours.get("close", "")
if open_str:
open_time = _parse_time_str(open_str)
if open_time is None:
warnings.warn(
f"Invalid business_hours.open format: {open_str!r} "
f"(expected HH:MM). Skipping open boundary gap.",
UserWarning,
stacklevel=2,
)
if close_str:
close_time = _parse_time_str(close_str)
if close_time is None:
warnings.warn(
f"Invalid business_hours.close format: {close_str!r} "
f"(expected HH:MM). Skipping close boundary gap.",
UserWarning,
stacklevel=2,
)
# Gap from open to first appointment.
if open_time and apts:
first_start = apts[0].start_time_only()
gap_mins = _time_diff_minutes(open_time, first_start)
if gap_mins >= 30:
gaps.append(Gap(
start_time=open_time,
end_time=first_start,
duration_minutes=gap_mins,
staff_name=staff_name,
following_appointment_id=apts[0].appointment_id,
))
# Gaps between consecutive appointments.
for i in range(len(apts) - 1):
current_end = apts[i].end_time_only()
next_start = apts[i + 1].start_time_only()
gap_mins = _time_diff_minutes(current_end, next_start)
if gap_mins < 0:
warnings.warn(
f"Overlapping appointments for {staff_name}: "
f"{apts[i].appointment_id} ends at {current_end} but "
f"{apts[i + 1].appointment_id} starts at {next_start} "
f"({abs(gap_mins)} min overlap). Gap skipped.",
UserWarning,
stacklevel=2,
)
continue
if gap_mins >= 30:
gaps.append(Gap(
start_time=current_end,
end_time=next_start,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[i].appointment_id,
following_appointment_id=apts[i + 1].appointment_id,
))
# Gap from last appointment to close.
if close_time and apts:
last_end = apts[-1].end_time_only()
gap_mins = _time_diff_minutes(last_end, close_time)
if gap_mins >= 30:
gaps.append(Gap(
start_time=last_end,
end_time=close_time,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[-1].appointment_id,
))
return gaps
def _parse_time_str(raw: str) -> time | None:
"""Parse an HH:MM string into a time object.
Returns None if the format is invalid (not HH:MM with valid ranges).
"""
m = re.fullmatch(r"(\d{2}):(\d{2})", raw)
if m is None:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h > 23 or mi > 59:
return None
return time(h, mi)
def _time_diff_minutes(start: time, end: time) -> int:
"""Minutes between two time objects (same day assumed).
Returns a negative value when *end* is before *start* (overlap).
Callers should check for negative results and warn.
"""
diff = datetime.combine(datetime.today(), end) - datetime.combine(datetime.today(), start)
return int(diff.total_seconds() // 60)
def format_board_text(board: DayBoard) -> str:
"""Format a DayBoard as structured text for chat display.
This is deterministic formatting — no model inference.
The model may rephrase when presenting to the owner, but the
facts come from this function.
"""
lines: list[str] = []
# Header with offline label.
source_label = "📋 FIXTURE DATA" if board.is_offline else "📅 LIVE DATA"
lines.append(f"═══ {board.salon_name}{board.date} ═══")
lines.append(f"[{source_label}]")
lines.append("")
# Appointments.
lines.append("── Appointments ──")
if not board.appointments:
lines.append(" No appointments.")
else:
for apt in board.appointments:
start_str = apt.start_time.strftime("%H:%M")
end_str = apt.end_time.strftime("%H:%M")
status_icon = _status_icon(apt.status)
confirm_flag = " ⚠️ CONFIRM" if apt.needs_confirmation else ""
lines.append(
f" {start_str}{end_str} {status_icon} {apt.client_name}"
f"{apt.service_name} ({apt.staff_name}){confirm_flag}"
)
if apt.notes:
lines.append(f" 📝 {apt.notes}")
lines.append("")
# Gaps.
lines.append("── Gaps (≥30 min) ──")
if not board.gaps:
lines.append(" No significant gaps.")
else:
for gap in board.gaps:
start_str = gap.start_time.strftime("%H:%M")
end_str = gap.end_time.strftime("%H:%M")
lines.append(
f" {start_str}{end_str} ({gap.duration_minutes} min) "
f"{gap.staff_name}"
)
lines.append("")
# Confirmation needed.
if board.needs_confirmation:
lines.append("── Needs Confirmation ──")
for apt in board.needs_confirmation:
start_str = apt.start_time.strftime("%H:%M")
lines.append(
f" ⚠️ {apt.client_name}{apt.service_name} at {start_str}"
)
lines.append("")
# Summary.
lines.append("── Summary ──")
lines.append(f" Booked: {board.total_booked_minutes} min | Gaps: {board.total_gap_minutes} min")
lines.append(f" Appointments: {len(board.appointments)} | "
f"Need confirmation: {len(board.needs_confirmation)}")
return "\n".join(lines)
def _status_icon(status: AppointmentStatus) -> str:
"""Emoji icon for appointment status."""
icons = {
AppointmentStatus.CONFIRMED: "",
AppointmentStatus.PENDING: "",
AppointmentStatus.COMPLETED: "✔️",
AppointmentStatus.NO_SHOW: "",
AppointmentStatus.CANCELLED: "🚫",
}
return icons.get(status, "")
+121
View File
@@ -0,0 +1,121 @@
"""Deterministic domain types for scheduling / board building.
These are pure data classes — no model inference, no network calls.
See design/det-vs-inf.md for the deterministic boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from enum import Enum
from typing import Optional
class AppointmentStatus(str, Enum):
"""Standardized appointment status."""
CONFIRMED = "confirmed"
PENDING = "pending"
CANCELLED = "cancelled"
COMPLETED = "completed"
NO_SHOW = "no_show"
@dataclass(frozen=True)
class Appointment:
"""A single salon appointment — the core domain object.
Fields match what the daily-board (A1) needs to display:
time, client, service, staff, status, confirmation flag.
"""
appointment_id: str
start_time: datetime
end_time: datetime
client_name: str
service_name: str
staff_name: str
status: AppointmentStatus
notes: str = ""
# Whether the client still needs a confirmation call/message.
# Derived at build time from status + last_contact, but stored here
# for fixture convenience.
needs_confirmation: bool = False
def duration_minutes(self) -> int:
"""Appointment duration in whole minutes."""
delta = self.end_time - self.start_time
return int(delta.total_seconds() // 60)
def start_time_only(self) -> time:
return self.start_time.time()
def end_time_only(self) -> time:
return self.end_time.time()
@dataclass(frozen=True)
class Gap:
"""An unbooked time slot between two appointments (or day boundary)."""
start_time: time
end_time: time
duration_minutes: int
staff_name: str
# The appointment immediately before this gap (if any).
preceding_appointment_id: Optional[str] = None
# The appointment immediately after this gap (if any).
following_appointment_id: Optional[str] = None
@dataclass(frozen=True)
class DayBoard:
"""The complete daily board for one staff member or the whole salon.
This is the structured output that the daily-board skill presents.
All data is deterministic — no model inference.
"""
date: str # YYYY-MM-DD
salon_name: str
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
is_offline: bool # True when source is fixtures or offline
appointments: list[Appointment] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
needs_confirmation: list[Appointment] = field(default_factory=list)
total_booked_minutes: int = 0
total_gap_minutes: int = 0
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON output."""
return {
"date": self.date,
"salon_name": self.salon_name,
"source": self.source,
"is_offline": self.is_offline,
"appointments": [
{
"id": a.appointment_id,
"start": a.start_time.isoformat(),
"end": a.end_time.isoformat(),
"client": a.client_name,
"service": a.service_name,
"staff": a.staff_name,
"status": a.status.value,
"needs_confirmation": a.needs_confirmation,
"notes": a.notes,
}
for a in self.appointments
],
"gaps": [
{
"start": g.start_time.isoformat(),
"end": g.end_time.isoformat(),
"duration_minutes": g.duration_minutes,
"staff": g.staff_name,
}
for g in self.gaps
],
"needs_confirmation": [
a.appointment_id for a in self.needs_confirmation
],
"total_booked_minutes": self.total_booked_minutes,
"total_gap_minutes": self.total_gap_minutes,
}
@@ -0,0 +1 @@
"""Provider adapters for external data sources."""
@@ -0,0 +1 @@
"""Books provider: QuickBooks Online (future)."""
@@ -0,0 +1 @@
"""MCP client helpers and allowlist metadata (future)."""
@@ -0,0 +1 @@
"""Scheduling provider: fixtures, Vagaro, Square (future)."""
@@ -0,0 +1,125 @@
"""Fixture provider for scheduling data.
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
This is the *only* data source for the daily-board until live SaaS adapters
(Vagaro, Square) are implemented.
All output is labeled `source: fixtures` / `is_offline: True` so the owner
never sees silent fake live data.
"""
from __future__ import annotations
import json
import pathlib
from datetime import datetime
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus
# Mapping from fixture status strings to domain enum.
_STATUS_MAP: dict[str, AppointmentStatus] = {
"confirmed": AppointmentStatus.CONFIRMED,
"pending": AppointmentStatus.PENDING,
"cancelled": AppointmentStatus.CANCELLED,
"completed": AppointmentStatus.COMPLETED,
"no_show": AppointmentStatus.NO_SHOW,
}
def _parse_status(raw: str) -> AppointmentStatus:
"""Convert a fixture status string to AppointmentStatus.
Raises:
ValueError: If the status string is not recognized.
"""
key = raw.lower()
if key not in _STATUS_MAP:
raise ValueError(
f"Unknown appointment status {raw!r}. "
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
)
return _STATUS_MAP[key]
def _parse_datetime(raw: str) -> datetime:
"""Parse ISO-format datetime strings from fixtures."""
return datetime.fromisoformat(raw)
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
"""Load appointments from a fixture JSON file.
Expected top-level shape:
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {"open": "09:00", "close": "18:00"},
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:00:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss"
}
]
}
```
Args:
fixture_path: Path to a JSON fixture file.
Returns:
List of Appointment domain objects.
Raises:
FileNotFoundError: If the fixture file does not exist.
ValueError: If the fixture JSON is malformed.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
appointments: list[Appointment] = []
for apt_raw in raw.get("appointments", []):
appointments.append(Appointment(
appointment_id=apt_raw["id"],
start_time=_parse_datetime(apt_raw["start"]),
end_time=_parse_datetime(apt_raw["end"]),
client_name=apt_raw["client_name"],
service_name=apt_raw["service_name"],
staff_name=apt_raw["staff_name"],
status=_parse_status(apt_raw.get("status", "pending")),
notes=apt_raw.get("notes", ""),
needs_confirmation=apt_raw.get("needs_confirmation", False),
))
return appointments
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
"""Load non-appointment metadata from a fixture file.
Returns salon_name, date, business_hours, staff list, etc.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
return {
"salon_name": raw.get("salon_name", "Unknown Salon"),
"date": raw.get("date", ""),
"business_hours": raw.get("business_hours", {}),
"staff": raw.get("staff", []),
}
+45 -3
View File
@@ -1,5 +1,47 @@
# `daily-board` (scaffold)
# `daily-board`
**Status:** Not implemented. Implementation requires explicit **build**.
**Status:** Implemented (fixtures only).
Intent: see [design/use-cases.md](../../design/use-cases.md) and [design/scenarios.md](../../design/scenarios.md).
Builds a structured daily board from scheduling data: appointments, gaps, confirmation flags, and summary.
## What it does
- Loads appointment data from fixture JSON files
- Computes gaps between appointments (≥30 min)
- Flags appointments needing client confirmation
- Labels all output as `📋 FIXTURE DATA` — never silent fake live data
- Outputs structured text or JSON
## Quick start
```bash
# Text output (default)
python skills/daily-board/scripts/build_board.py
# JSON output
python skills/daily-board/scripts/build_board.py --format json
```
## Files
| Path | Purpose |
|------|---------|
| `SKILL.md` | Skill spec and usage |
| `scripts/build_board.py` | CLI entrypoint |
| `../../skills/_lib/lumina_skills/domain.py` | Domain types |
| `../../skills/_lib/lumina_skills/board_builder.py` | Board builder logic |
| `../../skills/_lib/lumina_skills/providers/scheduling/fixture_provider.py` | Fixture loader |
| `../../data/fixtures/scheduling/` | Fixture JSON files |
## Design references
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
## Future
- Live Vagaro adapter (S5)
- Live Square adapter (S3)
- Staff filtering
- Multi-day boards
+81 -4
View File
@@ -1,18 +1,95 @@
---
name: daily-board
description: "Today's salon board: appointments, tasks, and priorities"
description: "Today's salon board: appointments, gaps, and confirmation flags"
domain: operations
---
# daily-board
Today's salon board: appointments, tasks, and priorities.
Today's salon board: appointments, gaps, and confirmation flags.
## Description
Pulls together the day's schedule, pending tasks, and key metrics into a single board view for the salon owner.
Builds a structured daily board from scheduling data showing:
- **Appointments** — time, client, service, staff, status
- **Gaps** — unbooked slots ≥30 minutes between appointments
- **Confirmation flags** — appointments that still need client confirmation
- **Summary** — total booked time, gap time, appointment count
All data is labeled with its source. When using fixtures, output is clearly
marked `📋 FIXTURE DATA` so the owner never sees silent fake live data.
## Data sources
| Source | Status | Label in output |
|--------|--------|-----------------|
| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` |
| Vagaro | Not yet | `📅 LIVE DATA` (future) |
| Square | Not yet | `📅 LIVE DATA` (future) |
## Constraints
- Deterministic facts from tools; inference for ranking and wording only.
- Deterministic facts from fixtures; no model inference for board data.
- No silent send or publish.
- Cancelled appointments are excluded from the board view.
- Gaps under 30 minutes are not shown (too short for a meaningful slot).
- Output always labels fixture/offline — never silent fake live data.
## Usage
### CLI
```bash
# Build board from fixtures (default demo data)
python skills/daily-board/scripts/build_board.py
# Build board for a specific fixture file
python skills/daily-board/scripts/build_board.py \
--fixtures data/fixtures/scheduling/claire_bennett_2026-07-28.json
# Output as JSON
python skills/daily-board/scripts/build_board.py --format json
# Specify date and salon name explicitly
python skills/daily-board/scripts/build_board.py \
--date 2026-07-28 --salon "Lumina Hair Studio & Spa"
```
### Programmatic
```python
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
from lumina_skills.board_builder import build_board, format_board_text
appointments = load_fixtures("data/fixtures/scheduling/claire_bennett_2026-07-28.json")
board = build_board(appointments, date="2026-07-28", salon_name="Lumina Hair Studio & Spa")
print(format_board_text(board))
```
## Output format
### Text (default)
Structured text with sections for appointments, gaps, confirmation flags, and summary.
### JSON
```json
{
"date": "2026-07-28",
"salon_name": "Lumina Hair Studio & Spa",
"source": "fixtures",
"is_offline": true,
"appointments": [...],
"gaps": [...],
"needs_confirmation": [...],
"total_booked_minutes": 420,
"total_gap_minutes": 120
}
```
## Design references
- Use case: [A1 — Morning / day board](../../design/use-cases.md)
- Scenario: [S8 — Morning board on WhatsApp](../../design/scenarios.md)
- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md)
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Build a daily board from scheduling fixtures.
Usage:
python build_board.py [--fixtures PATH] [--date YYYY-MM-DD] [--salon NAME] [--format text|json]
All data is labeled as fixture/offline — never silent fake live data.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
# Ensure the _lib package is importable regardless of cwd.
_REPO_ROOT = pathlib.Path(__file__).resolve().parents[3]
sys.path.insert(0, str(_REPO_ROOT / "skills" / "_lib"))
from lumina_skills.providers.scheduling.fixture_provider import (
load_fixtures,
load_fixture_metadata,
)
from lumina_skills.board_builder import build_board, format_board_text
# Default fixture: Claire Bennett sample day.
_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
def main() -> int:
parser = argparse.ArgumentParser(
description="Build a daily salon board from fixture data.",
)
parser.add_argument(
"--fixtures",
type=pathlib.Path,
default=_DEFAULT_FIXTURE,
help="Path to fixture JSON file (default: Claire Bennett sample day).",
)
parser.add_argument(
"--date",
type=str,
default=None,
help="Override board date (YYYY-MM-DD). Defaults to fixture date.",
)
parser.add_argument(
"--salon",
type=str,
default=None,
help="Override salon name. Defaults to fixture salon_name.",
)
parser.add_argument(
"--format",
choices=["text", "json"],
default="text",
help="Output format (default: text).",
)
args = parser.parse_args()
# Load fixture data.
try:
appointments = load_fixtures(args.fixtures)
except FileNotFoundError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
except (json.JSONDecodeError, KeyError, ValueError) as exc:
print(f"Error parsing fixture: {exc}", file=sys.stderr)
return 1
# Load metadata for defaults.
try:
meta = load_fixture_metadata(args.fixtures)
except (json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
print(f"Warning: could not parse fixture metadata: {exc}", file=sys.stderr)
meta = {}
date = args.date or meta.get("date", "unknown")
salon_name = args.salon or meta.get("salon_name", "Unknown Salon")
business_hours = meta.get("business_hours")
# Build the board.
board = build_board(
appointments=appointments,
date=date,
salon_name=salon_name,
source="fixtures",
business_hours=business_hours,
)
# Output.
if args.format == "json":
print(json.dumps(board.to_dict(), indent=2))
else:
print(format_board_text(board))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+19 -9
View File
@@ -1,12 +1,22 @@
# Tests (scaffold)
# Tests
**Status:** Structure only — no product tests until **build**.
| Path | Purpose | Status |
|------|---------|--------|
| `unit/` | Deterministic domain/skill logic (no live model required) | ✅ Partial |
| `contract/` | Provider/MCP allow-deny contracts | ⏳ |
| `integration/` | Optional cheap-model dialogue paths | ⏳ |
| `fixtures/` | Test-only fixtures | ⏳ |
| Path | Purpose |
|------|---------|
| `unit/` | Deterministic domain/skill logic (no live model required) |
| `contract/` | Provider/MCP allow-deny contracts |
| `integration/` | Optional cheap-model dialogue paths |
| `fixtures/` | Test-only fixtures |
## Running tests
Design boundary: [design/det-vs-inf.md](../design/det-vs-inf.md).
```bash
# All unit tests
python -m pytest tests/unit/ -v
# Specific test file
python -m pytest tests/unit/test_board_builder.py -v
```
## Design boundary
[design/det-vs-inf.md](../design/det-vs-inf.md) — unit tests cover the deterministic column without a live model.
+1
View File
@@ -0,0 +1 @@
"""Unit tests for Salon_Assistant deterministic logic."""
+306
View File
@@ -0,0 +1,306 @@
"""Tests for the deterministic board builder."""
from __future__ import annotations
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
from lumina_skills.board_builder import (
build_board,
format_board_text,
)
def _apt(
apt_id: str,
start_h: int,
start_m: int,
end_h: int,
end_m: int,
client: str = "Client",
service: str = "Service",
staff: str = "Staff",
status: AppointmentStatus = AppointmentStatus.CONFIRMED,
needs_confirmation: bool = False,
notes: str = "",
) -> Appointment:
"""Helper to create an Appointment quickly."""
return Appointment(
appointment_id=apt_id,
start_time=datetime(2026, 7, 28, start_h, start_m),
end_time=datetime(2026, 7, 28, end_h, end_m),
client_name=client,
service_name=service,
staff_name=staff,
status=status,
notes=notes,
needs_confirmation=needs_confirmation,
)
# ── build_board ────────────────────────────────────────────────────────────
def test_build_board_basic():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon", source="fixtures")
assert len(board.appointments) == 2
assert board.total_booked_minutes == 120
assert board.is_offline is True
def test_build_board_excludes_cancelled():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", status=AppointmentStatus.CANCELLED),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.appointments) == 2 # Cancelled excluded
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A3"
def test_build_board_sorts_by_time():
apts = [
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"),
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert board.appointments[0].appointment_id == "A1"
assert board.appointments[1].appointment_id == "A2"
def test_build_board_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=False),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire", needs_confirmation=True),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.needs_confirmation) == 2
assert board.needs_confirmation[0].appointment_id == "A2"
assert board.needs_confirmation[1].appointment_id == "A3"
def test_build_board_gaps_between_apts():
"""Gap between two appointments on the same staff."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
assert len(board.gaps) == 1
gap = board.gaps[0]
assert gap.start_time == time(10, 0)
assert gap.end_time == time(11, 0)
assert gap.duration_minutes == 60
assert gap.staff_name == "Claire"
def test_build_board_no_small_gaps():
"""Gaps under 30 minutes are not included."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 10, 15, 11, 15, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# 15-minute gap should be excluded.
assert len(board.gaps) == 0
def test_build_board_overlapping_appointments_warns():
"""Overlapping appointments emit a warning and skip the negative gap."""
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"),
_apt("A2", 10, 0, 11, 0, "Bob", "Color", "Claire"), # starts 30 min before A1 ends
]
with pytest.warns(UserWarning, match="Overlapping appointments"):
board = build_board(apts, "2026-07-28", "Test Salon")
# The negative gap should not appear in the board.
assert all(g.duration_minutes >= 0 for g in board.gaps)
# Both appointments still appear (overlap is a data issue, not a filter).
assert len(board.appointments) == 2
def test_build_board_boundary_gaps():
"""Gaps from open→first and last→close when business_hours provided."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
_apt("A2", 15, 0, 16, 0, "Bob", "Color", "Claire"),
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
# Should have: 09:00-10:00 (60 min), 11:00-15:00 (240 min), 16:00-18:00 (120 min)
assert len(board.gaps) == 3
assert board.gaps[0].start_time == time(9, 0)
assert board.gaps[0].end_time == time(10, 0)
assert board.gaps[2].start_time == time(16, 0)
assert board.gaps[2].end_time == time(18, 0)
def test_build_board_multi_staff_gaps():
"""Gaps are computed per staff member."""
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 9, 0, 10, 0, "Bob", "Color", "Maya"),
_apt("A3", 11, 0, 12, 0, "Carol", "Style", "Claire"),
_apt("A4", 11, 0, 12, 0, "Dave", "Trim", "Maya"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
# Each staff has a 60-min gap.
assert len(board.gaps) == 2
staff_gaps = {g.staff_name: g.duration_minutes for g in board.gaps}
assert staff_gaps["Claire"] == 60
assert staff_gaps["Maya"] == 60
def test_build_board_invalid_business_hours_warns():
"""Malformed business_hours values warn and skip boundary gaps."""
apts = [
_apt("A1", 10, 0, 11, 0, "Alice", "Cut", "Claire"),
]
with pytest.warns(UserWarning, match="Invalid business_hours"):
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "nine", "close": "18:00"},
)
# Only the close boundary gap should appear (open was invalid).
assert len(board.gaps) == 1
assert board.gaps[0].start_time == time(11, 0)
assert board.gaps[0].end_time == time(18, 0)
def test_build_board_empty():
board = build_board([], "2026-07-28", "Empty Salon")
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
assert board.total_gap_minutes == 0
def test_build_board_source_labeling():
"""Source is correctly set and is_offline derived."""
board = build_board([], "2026-07-28", "Test", source="fixtures")
assert board.source == "fixtures"
assert board.is_offline is True
board2 = build_board([], "2026-07-28", "Test", source="offline")
assert board2.is_offline is True
board3 = build_board([], "2026-07-28", "Test", source="vagaro")
assert board3.is_offline is False
def test_build_board_totals():
apts = [
_apt("A1", 9, 0, 10, 30, "Alice", "Cut", "Claire"), # 90 min
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"), # 60 min
]
board = build_board(
apts, "2026-07-28", "Test Salon",
business_hours={"open": "09:00", "close": "18:00"},
)
assert board.total_booked_minutes == 150
# Gaps: 10:30-11:00 (30 min), 12:00-18:00 (360 min)
assert board.total_gap_minutes == 390
# ── format_board_text ──────────────────────────────────────────────────────
def test_format_text_includes_offline_label():
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "FIXTURE DATA" in text
def test_format_text_includes_appointments():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Alice" in text
assert "Cut" in text
assert "Claire" in text
assert "09:00" in text
def test_format_text_includes_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
_apt("A2", 11, 0, 12, 0, "Bob", "Color", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Gaps" in text
assert "60 min" in text
def test_format_text_includes_confirmation_flags():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", needs_confirmation=True),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "CONFIRM" in text
assert "Needs Confirmation" in text
def test_format_text_includes_summary():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Summary" in text
assert "60 min" in text
def test_format_text_no_appointments():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="fixtures",
is_offline=True,
)
text = format_board_text(board)
assert "No appointments" in text
def test_format_text_no_gaps():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "No significant gaps" in text
def test_format_text_notes():
apts = [
_apt("A1", 9, 0, 10, 0, "Alice", "Cut", "Claire", notes="Allergic to ammonia"),
]
board = build_board(apts, "2026-07-28", "Test Salon")
text = format_board_text(board)
assert "Allergic to ammonia" in text
+182
View File
@@ -0,0 +1,182 @@
"""Tests for lumina_skills.domain types."""
from __future__ import annotations
import json
from datetime import datetime, time
import pytest
import sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import (
Appointment,
AppointmentStatus,
DayBoard,
Gap,
)
# ── Appointment ────────────────────────────────────────────────────────────
def test_appointment_duration():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 30),
client_name="Elena Rossi",
service_name="Balayage + Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
)
assert apt.duration_minutes() == 90
def test_appointment_duration_one_hour():
apt = Appointment(
appointment_id="APT-002",
start_time=datetime(2026, 7, 28, 13, 0),
end_time=datetime(2026, 7, 28, 14, 0),
client_name="Chris Nguyen",
service_name="Men's Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.PENDING,
)
assert apt.duration_minutes() == 60
def test_appointment_time_only():
apt = Appointment(
appointment_id="APT-003",
start_time=datetime(2026, 7, 28, 11, 15),
end_time=datetime(2026, 7, 28, 12, 45),
client_name="Jasmine Patel",
service_name="Root Touch-Up",
staff_name="Maya Torres",
status=AppointmentStatus.CONFIRMED,
)
assert apt.start_time_only() == time(11, 15)
assert apt.end_time_only() == time(12, 45)
def test_appointment_frozen():
"""Appointment is immutable."""
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.CONFIRMED,
)
with pytest.raises(Exception): # FrozenInstanceError
apt.client_name = "Hacker"
def test_appointment_needs_confirmation_default():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Test",
service_name="Test",
staff_name="Test",
status=AppointmentStatus.PENDING,
)
assert apt.needs_confirmation is False
def test_appointment_status_enum():
assert AppointmentStatus.CONFIRMED.value == "confirmed"
assert AppointmentStatus.PENDING.value == "pending"
assert AppointmentStatus.CANCELLED.value == "cancelled"
assert AppointmentStatus.COMPLETED.value == "completed"
assert AppointmentStatus.NO_SHOW.value == "no_show"
# ── Gap ────────────────────────────────────────────────────────────────────
def test_gap_creation():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 30),
duration_minutes=90,
staff_name="Claire Bennett",
)
assert gap.duration_minutes == 90
assert gap.staff_name == "Claire Bennett"
def test_gap_with_references():
gap = Gap(
start_time=time(12, 0),
end_time=time(13, 0),
duration_minutes=60,
staff_name="Claire Bennett",
preceding_appointment_id="APT-001",
following_appointment_id="APT-002",
)
assert gap.preceding_appointment_id == "APT-001"
assert gap.following_appointment_id == "APT-002"
# ── DayBoard ───────────────────────────────────────────────────────────────
def test_dayboard_to_dict():
apt = Appointment(
appointment_id="APT-001",
start_time=datetime(2026, 7, 28, 9, 0),
end_time=datetime(2026, 7, 28, 10, 0),
client_name="Elena Rossi",
service_name="Cut",
staff_name="Claire Bennett",
status=AppointmentStatus.CONFIRMED,
needs_confirmation=False,
)
board = DayBoard(
date="2026-07-28",
salon_name="Lumina Hair Studio & Spa",
source="fixtures",
is_offline=True,
appointments=[apt],
gaps=[],
needs_confirmation=[],
total_booked_minutes=60,
total_gap_minutes=0,
)
d = board.to_dict()
assert d["date"] == "2026-07-28"
assert d["salon_name"] == "Lumina Hair Studio & Spa"
assert d["source"] == "fixtures"
assert d["is_offline"] is True
assert len(d["appointments"]) == 1
assert d["appointments"][0]["client"] == "Elena Rossi"
assert d["total_booked_minutes"] == 60
def test_dayboard_to_dict_serializable():
"""to_dict output must be JSON-serializable."""
board = DayBoard(
date="2026-07-28",
salon_name="Test Salon",
source="fixtures",
is_offline=True,
)
d = board.to_dict()
# Should not raise.
json.dumps(d)
def test_dayboard_empty():
board = DayBoard(
date="2026-07-28",
salon_name="Empty Salon",
source="offline",
is_offline=True,
)
assert len(board.appointments) == 0
assert len(board.gaps) == 0
assert board.total_booked_minutes == 0
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the scheduling fixture provider."""
from __future__ import annotations
import json
import pathlib
import tempfile
import pytest
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib"))
from lumina_skills.domain import Appointment, AppointmentStatus
from lumina_skills.providers.scheduling.fixture_provider import (
load_fixtures,
load_fixture_metadata,
)
# Path to the real fixture file.
_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "scheduling" / "claire_bennett_2026-07-28.json"
def _make_fixture_file(tmp_path: pathlib.Path, data: dict) -> pathlib.Path:
"""Write a fixture dict to a temp JSON file."""
p = tmp_path / "test_fixture.json"
p.write_text(json.dumps(data), encoding="utf-8")
return p
# ── load_fixtures ──────────────────────────────────────────────────────────
def test_load_real_fixture():
"""Load the Claire Bennett fixture file."""
apts = load_fixtures(_FIXTURE_PATH)
assert len(apts) == 7 # 7 appointments in the fixture
assert all(isinstance(a, Appointment) for a in apts)
def test_load_fixture_statuses():
"""Fixture statuses are correctly parsed."""
apts = load_fixtures(_FIXTURE_PATH)
statuses = {a.appointment_id: a.status for a in apts}
assert statuses["APT-001"] == AppointmentStatus.CONFIRMED
assert statuses["APT-002"] == AppointmentStatus.PENDING
assert statuses["APT-007"] == AppointmentStatus.CANCELLED
def test_load_fixture_needs_confirmation():
"""needs_confirmation flag is loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
flags = {a.appointment_id: a.needs_confirmation for a in apts}
assert flags["APT-001"] is False
assert flags["APT-002"] is True
assert flags["APT-004"] is True
def test_load_fixture_notes():
"""Notes are loaded from fixture."""
apts = load_fixtures(_FIXTURE_PATH)
notes = {a.appointment_id: a.notes for a in apts}
assert "ammonia" in notes["APT-001"].lower()
assert "Wedding" in notes["APT-002"]
def test_load_fixture_missing_file(tmp_path: pathlib.Path):
"""FileNotFoundError for missing fixture."""
with pytest.raises(FileNotFoundError):
load_fixtures(tmp_path / "nonexistent.json")
def test_load_fixture_empty_appointments(tmp_path: pathlib.Path):
"""Empty appointments list returns empty list."""
data = {"salon_name": "Test", "date": "2026-01-01", "appointments": []}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts == []
def test_load_fixture_default_status(tmp_path: pathlib.Path):
"""Missing status field defaults to PENDING (via .get default)."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
# No status field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].status == AppointmentStatus.PENDING
def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path):
"""Unknown status string raises ValueError."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "typo_status",
}]
}
path = _make_fixture_file(tmp_path, data)
with pytest.raises(ValueError, match="Unknown appointment status"):
load_fixtures(path)
def test_load_fixture_default_needs_confirmation(tmp_path: pathlib.Path):
"""Missing needs_confirmation defaults to False."""
data = {
"appointments": [{
"id": "APT-X",
"start": "2026-01-01T09:00:00",
"end": "2026-01-01T10:00:00",
"client_name": "Test",
"service_name": "Test",
"staff_name": "Test",
"status": "confirmed",
# No needs_confirmation field.
}]
}
path = _make_fixture_file(tmp_path, data)
apts = load_fixtures(path)
assert apts[0].needs_confirmation is False
def test_load_fixture_malformed_json(tmp_path: pathlib.Path):
"""ValueError for invalid JSON."""
p = tmp_path / "bad.json"
p.write_text("{not valid json}", encoding="utf-8")
with pytest.raises(ValueError):
load_fixtures(p)
# ── load_fixture_metadata ─────────────────────────────────────────────────
def test_load_metadata():
meta = load_fixture_metadata(_FIXTURE_PATH)
assert meta["salon_name"] == "Lumina Hair Studio & Spa"
assert meta["date"] == "2026-07-28"
assert meta["business_hours"]["open"] == "09:00"
assert meta["business_hours"]["close"] == "18:00"
assert len(meta["staff"]) == 2
def test_load_metadata_missing_file(tmp_path: pathlib.Path):
with pytest.raises(FileNotFoundError):
load_fixture_metadata(tmp_path / "nonexistent.json")