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-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
@@ -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
@@ -1,6 +1,6 @@
|
||||
# Install
|
||||
|
||||
**Status:** Stages S0–S5 implemented. S6–S7 pending.
|
||||
**Status:** Stages S0–S6 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 (S0–S5)
|
||||
## After install (S0–S6)
|
||||
|
||||
- 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) (S1–S5) for operational scenarios.
|
||||
- See [OPERATIONS.md](OPERATIONS.md) for day-2 operator commands.
|
||||
|
||||
## UAT host notes
|
||||
|
||||
|
||||
+43
-3
@@ -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
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
- `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)
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
@@ -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 |
|
||||
|------|---------|
|
||||
| `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.
|
||||
|
||||
@@ -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