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:
@@ -12,6 +12,8 @@ help:
|
|||||||
@echo " make install - full install S0b–S5"
|
@echo " make install - full install S0b–S5"
|
||||||
@echo " make install-s0-s2 - install stages S0b through S2"
|
@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 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 ""
|
||||||
@echo "Staged install:"
|
@echo "Staged install:"
|
||||||
@echo " make install-s0b - S0b only: Docker bootstrap"
|
@echo " make install-s0b - S0b only: Docker bootstrap"
|
||||||
@@ -23,8 +25,6 @@ help:
|
|||||||
@echo ""
|
@echo ""
|
||||||
@echo "Not yet implemented (stubbed):"
|
@echo "Not yet implemented (stubbed):"
|
||||||
@echo " make upgrade - product upgrade"
|
@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"
|
@echo " make sync-design - list design pack paths"
|
||||||
|
|
||||||
# ── Implemented targets ────────────────────────────────────────────────────
|
# ── Implemented targets ────────────────────────────────────────────────────
|
||||||
@@ -61,10 +61,23 @@ install-s4:
|
|||||||
install-s5:
|
install-s5:
|
||||||
@bash scripts/install.sh --stage s5
|
@bash scripts/install.sh --stage s5
|
||||||
|
|
||||||
# ── Stubbed targets (S6+ not yet implemented) ──────────────────────────────
|
# ── S6: Doctor ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
upgrade doctor verify:
|
doctor:
|
||||||
@echo "not implemented — S6+ stages pending" >&2; exit 1
|
@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:
|
sync-design:
|
||||||
@echo "Design SSOT:"
|
@echo "Design SSOT:"
|
||||||
|
|||||||
+37
-4
@@ -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 |
|
| 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 |
|
| `media/` | Sample social media assets for vision tests |
|
||||||
| `../recorded/` | Optional recorded responses (local only; do not commit secrets) |
|
| `../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
@@ -1,6 +1,6 @@
|
|||||||
# Install
|
# Install
|
||||||
|
|
||||||
**Status:** Stages S0–S5 implemented. S6–S7 pending.
|
**Status:** Stages S0–S6 implemented. S7 pending.
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
|
| S3 | Host script | Stack alignment (compose docs; OpenShell owns sandbox) | ✅ Implemented |
|
||||||
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
| S4 | Host script | Sandbox verify (attach) or onboard (clean host) | ✅ Implemented |
|
||||||
| S5 | Host script | Policy overlays + skills sync via nemohermes | ✅ 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 |
|
| S7 | Owner + operator connect helpers | Name assistant; connect **their** SaaS/channels | ⏳ Pending |
|
||||||
|
|
||||||
## Platform commands (normative)
|
## Platform commands (normative)
|
||||||
@@ -156,13 +156,14 @@ make install
|
|||||||
make install-s3-s5
|
make install-s3-s5
|
||||||
```
|
```
|
||||||
|
|
||||||
## After install (S0–S5)
|
## After install (S0–S6)
|
||||||
|
|
||||||
- Verify `.env` values are correct for your environment.
|
- Verify `.env` values are correct for your environment.
|
||||||
- Check policy: `nemohermes <name> policy-list`
|
- 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 [SETUP_UX.md](SETUP_UX.md) for owner-facing setup after full install.
|
||||||
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
- See [design/scenarios.md](../design/scenarios.md) (S1–S5) for operational scenarios.
|
||||||
|
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
|
||||||
|
|
||||||
## UAT host notes
|
## UAT host notes
|
||||||
|
|
||||||
|
|||||||
+43
-3
@@ -1,14 +1,54 @@
|
|||||||
# Operations (day-2)
|
# Operations (day-2)
|
||||||
|
|
||||||
**Status:** Outline.
|
**Status:** Doctor (S6) implemented.
|
||||||
|
|
||||||
## Operator commands (when implemented)
|
## Health checks (doctor)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Run all health checks (human-readable)
|
||||||
|
make doctor
|
||||||
|
# or
|
||||||
./scripts/doctor.sh
|
./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
|
nemohermes <sandbox-name> status
|
||||||
|
|
||||||
|
# Sandbox doctor (platform-level)
|
||||||
|
nemohermes <sandbox-name> doctor
|
||||||
|
|
||||||
|
# Sandbox logs
|
||||||
nemohermes <sandbox-name> logs --follow
|
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
|
## Log levels
|
||||||
|
|||||||
+7
-2
@@ -1,6 +1,6 @@
|
|||||||
# Host scripts
|
# Host scripts
|
||||||
|
|
||||||
**Status:** S0b–S5 implemented. S6–S7 pending.
|
**Status:** S0b–S6 implemented. S7 pending.
|
||||||
|
|
||||||
All scripts wrap **`nemohermes` / `openshell` / Docker**. No parallel control API.
|
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/s2-models.sh` | S2: model + vision config + smoke | ✅ S2 |
|
||||||
| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 |
|
| `install/s4-sandbox.sh` | S4: sandbox verify (attach) or onboard | ✅ S4 |
|
||||||
| `install/s5-policy-skills.sh` | S5: policy overlays + skills sync | ✅ S5 |
|
| `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 |
|
| `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 |
|
| `connect/*.sh` | Operator connect helpers (Square, QBO, Vagaro, channels) | ⏳ Pending |
|
||||||
|
|
||||||
## Shared library
|
## 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 s5 # policy + skills
|
||||||
./scripts/install.sh --stage s3-s5 # S3 through S5
|
./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
|
# Or via Make
|
||||||
make bootstrap
|
make bootstrap
|
||||||
make install
|
make install
|
||||||
@@ -49,6 +53,7 @@ make install-s1
|
|||||||
make install-s2
|
make install-s2
|
||||||
make install-s3-s5
|
make install-s3-s5
|
||||||
make install-s5
|
make install-s5
|
||||||
|
make doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
## Design reference
|
## Design reference
|
||||||
|
|||||||
Executable
+355
@@ -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
|
||||||
@@ -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
|
## Packages
|
||||||
- `books/` — QuickBooks Online adapters
|
|
||||||
- `mcp/` — MCP client helpers / allowlist metadata
|
| 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)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
|
||||||
@@ -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, "❓")
|
||||||
@@ -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", []),
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,18 +1,95 @@
|
|||||||
---
|
---
|
||||||
name: daily-board
|
name: daily-board
|
||||||
description: "Today's salon board: appointments, tasks, and priorities"
|
description: "Today's salon board: appointments, gaps, and confirmation flags"
|
||||||
domain: operations
|
domain: operations
|
||||||
---
|
---
|
||||||
|
|
||||||
# daily-board
|
# daily-board
|
||||||
|
|
||||||
Today's salon board: appointments, tasks, and priorities.
|
Today's salon board: appointments, gaps, and confirmation flags.
|
||||||
|
|
||||||
## Description
|
## 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
|
## 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.
|
- 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)
|
||||||
|
|||||||
@@ -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
@@ -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 |
|
## Running tests
|
||||||
|------|---------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Unit tests for Salon_Assistant deterministic logic."""
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user