Implement S6 doctor and A1 daily-board with fixtures.

Add operator health checks (make doctor) wrapping platform CLIs, and the fixtures-only daily board skill library with unit tests (make verify).
This commit is contained in:
Ty
2026-07-27 12:41:40 -07:00
parent 0198ab6881
commit 15f6a6c713
24 changed files with 2011 additions and 40 deletions
+28 -6
View File
@@ -1,9 +1,31 @@
# Shared skill library (scaffold)
# Shared skill library
**Status:** Empty until **build**.
**Status:** Partially implemented.
Planned packages under `providers/`:
Shared deterministic library used by Salon_Assistant skills. All code here is
**deterministic** — no model inference, no network calls. See
[design/det-vs-inf.md](../../../design/det-vs-inf.md).
- `scheduling/` — Vagaro / Square adapters
- `books/` — QuickBooks Online adapters
- `mcp/` — MCP client helpers / allowlist metadata
## Packages
| Package | Status | Purpose |
|---------|--------|---------|
| `domain.py` | ✅ | Domain types: `Appointment`, `Gap`, `DayBoard`, `AppointmentStatus` |
| `board_builder.py` | ✅ | Deterministic board builder: gaps, confirmation flags, formatting |
| `providers/scheduling/fixture_provider.py` | ✅ | Fixture JSON loader for scheduling data |
| `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) |
| `providers/books/` | ⏳ | QuickBooks Online adapters (future) |
| `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) |
## Usage
```python
from lumina_skills.domain import Appointment, AppointmentStatus
from lumina_skills.board_builder import build_board, format_board_text
from lumina_skills.providers.scheduling.fixture_provider import load_fixtures
```
## Design references
- Deterministic boundary: [design/det-vs-inf.md](../../../design/det-vs-inf.md)
- Use cases: [design/use-cases.md](../../../design/use-cases.md)
+1
View File
@@ -0,0 +1 @@
"""lumina_skills — shared deterministic library for Salon_Assistant skills."""
+279
View File
@@ -0,0 +1,279 @@
"""Deterministic board builder.
Takes a list of Appointment objects and produces a DayBoard with:
- Sorted appointments
- Computed gaps between consecutive appointments per staff member
- Confirmation flags
- Offline/fixture labeling
All logic is deterministic — no model inference.
See design/det-vs-inf.md.
"""
from __future__ import annotations
import re
import warnings
from datetime import datetime, time
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus, DayBoard, Gap
def build_board(
appointments: list[Appointment],
date: str,
salon_name: str,
source: str = "fixtures",
business_hours: dict[str, str] | None = None,
) -> DayBoard:
"""Build a complete DayBoard from a list of appointments.
Args:
appointments: Raw appointment list (from fixtures or live source).
date: The board date in YYYY-MM-DD format.
salon_name: Display name of the salon.
source: Data source label — "fixtures", "offline", "vagaro", "square".
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"} to
compute gaps at day boundaries.
Returns:
A fully populated DayBoard.
"""
is_offline = source in ("fixtures", "offline")
# Filter out cancelled appointments for the board view.
active = [a for a in appointments if a.status != AppointmentStatus.CANCELLED]
# Sort by start time.
active.sort(key=lambda a: a.start_time)
# Compute gaps per staff member.
gaps = _compute_gaps(active, date, business_hours)
# Confirmation flags: pending appointments that need confirmation.
needs_confirmation = [a for a in active if a.needs_confirmation]
# Totals.
total_booked = sum(a.duration_minutes() for a in active)
total_gap = sum(g.duration_minutes for g in gaps)
return DayBoard(
date=date,
salon_name=salon_name,
source=source,
is_offline=is_offline,
appointments=active,
gaps=gaps,
needs_confirmation=needs_confirmation,
total_booked_minutes=total_booked,
total_gap_minutes=total_gap,
)
def _compute_gaps(
appointments: list[Appointment],
date: str,
business_hours: dict[str, str] | None = None,
) -> list[Gap]:
"""Compute unbooked gaps between consecutive appointments per staff.
Gaps are computed per staff member. If business_hours is provided,
gaps from open→first appointment and last appointment→close are
included (only if >= 30 minutes).
Args:
appointments: Sorted list of active appointments.
date: Board date string (YYYY-MM-DD).
business_hours: Optional {"open": "HH:MM", "close": "HH:MM"}.
Returns:
List of Gap objects.
"""
gaps: list[Gap] = []
# Group appointments by staff.
staff_apts: dict[str, list[Appointment]] = {}
for apt in appointments:
staff_apts.setdefault(apt.staff_name, []).append(apt)
for staff_name, apts in staff_apts.items():
# apts is already sorted by start_time from the caller.
open_time = None
close_time = None
if business_hours:
open_str = business_hours.get("open", "")
close_str = business_hours.get("close", "")
if open_str:
open_time = _parse_time_str(open_str)
if open_time is None:
warnings.warn(
f"Invalid business_hours.open format: {open_str!r} "
f"(expected HH:MM). Skipping open boundary gap.",
UserWarning,
stacklevel=2,
)
if close_str:
close_time = _parse_time_str(close_str)
if close_time is None:
warnings.warn(
f"Invalid business_hours.close format: {close_str!r} "
f"(expected HH:MM). Skipping close boundary gap.",
UserWarning,
stacklevel=2,
)
# Gap from open to first appointment.
if open_time and apts:
first_start = apts[0].start_time_only()
gap_mins = _time_diff_minutes(open_time, first_start)
if gap_mins >= 30:
gaps.append(Gap(
start_time=open_time,
end_time=first_start,
duration_minutes=gap_mins,
staff_name=staff_name,
following_appointment_id=apts[0].appointment_id,
))
# Gaps between consecutive appointments.
for i in range(len(apts) - 1):
current_end = apts[i].end_time_only()
next_start = apts[i + 1].start_time_only()
gap_mins = _time_diff_minutes(current_end, next_start)
if gap_mins < 0:
warnings.warn(
f"Overlapping appointments for {staff_name}: "
f"{apts[i].appointment_id} ends at {current_end} but "
f"{apts[i + 1].appointment_id} starts at {next_start} "
f"({abs(gap_mins)} min overlap). Gap skipped.",
UserWarning,
stacklevel=2,
)
continue
if gap_mins >= 30:
gaps.append(Gap(
start_time=current_end,
end_time=next_start,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[i].appointment_id,
following_appointment_id=apts[i + 1].appointment_id,
))
# Gap from last appointment to close.
if close_time and apts:
last_end = apts[-1].end_time_only()
gap_mins = _time_diff_minutes(last_end, close_time)
if gap_mins >= 30:
gaps.append(Gap(
start_time=last_end,
end_time=close_time,
duration_minutes=gap_mins,
staff_name=staff_name,
preceding_appointment_id=apts[-1].appointment_id,
))
return gaps
def _parse_time_str(raw: str) -> time | None:
"""Parse an HH:MM string into a time object.
Returns None if the format is invalid (not HH:MM with valid ranges).
"""
m = re.fullmatch(r"(\d{2}):(\d{2})", raw)
if m is None:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h > 23 or mi > 59:
return None
return time(h, mi)
def _time_diff_minutes(start: time, end: time) -> int:
"""Minutes between two time objects (same day assumed).
Returns a negative value when *end* is before *start* (overlap).
Callers should check for negative results and warn.
"""
diff = datetime.combine(datetime.today(), end) - datetime.combine(datetime.today(), start)
return int(diff.total_seconds() // 60)
def format_board_text(board: DayBoard) -> str:
"""Format a DayBoard as structured text for chat display.
This is deterministic formatting — no model inference.
The model may rephrase when presenting to the owner, but the
facts come from this function.
"""
lines: list[str] = []
# Header with offline label.
source_label = "📋 FIXTURE DATA" if board.is_offline else "📅 LIVE DATA"
lines.append(f"═══ {board.salon_name}{board.date} ═══")
lines.append(f"[{source_label}]")
lines.append("")
# Appointments.
lines.append("── Appointments ──")
if not board.appointments:
lines.append(" No appointments.")
else:
for apt in board.appointments:
start_str = apt.start_time.strftime("%H:%M")
end_str = apt.end_time.strftime("%H:%M")
status_icon = _status_icon(apt.status)
confirm_flag = " ⚠️ CONFIRM" if apt.needs_confirmation else ""
lines.append(
f" {start_str}{end_str} {status_icon} {apt.client_name}"
f"{apt.service_name} ({apt.staff_name}){confirm_flag}"
)
if apt.notes:
lines.append(f" 📝 {apt.notes}")
lines.append("")
# Gaps.
lines.append("── Gaps (≥30 min) ──")
if not board.gaps:
lines.append(" No significant gaps.")
else:
for gap in board.gaps:
start_str = gap.start_time.strftime("%H:%M")
end_str = gap.end_time.strftime("%H:%M")
lines.append(
f" {start_str}{end_str} ({gap.duration_minutes} min) "
f"{gap.staff_name}"
)
lines.append("")
# Confirmation needed.
if board.needs_confirmation:
lines.append("── Needs Confirmation ──")
for apt in board.needs_confirmation:
start_str = apt.start_time.strftime("%H:%M")
lines.append(
f" ⚠️ {apt.client_name}{apt.service_name} at {start_str}"
)
lines.append("")
# Summary.
lines.append("── Summary ──")
lines.append(f" Booked: {board.total_booked_minutes} min | Gaps: {board.total_gap_minutes} min")
lines.append(f" Appointments: {len(board.appointments)} | "
f"Need confirmation: {len(board.needs_confirmation)}")
return "\n".join(lines)
def _status_icon(status: AppointmentStatus) -> str:
"""Emoji icon for appointment status."""
icons = {
AppointmentStatus.CONFIRMED: "",
AppointmentStatus.PENDING: "",
AppointmentStatus.COMPLETED: "✔️",
AppointmentStatus.NO_SHOW: "",
AppointmentStatus.CANCELLED: "🚫",
}
return icons.get(status, "")
+121
View File
@@ -0,0 +1,121 @@
"""Deterministic domain types for scheduling / board building.
These are pure data classes — no model inference, no network calls.
See design/det-vs-inf.md for the deterministic boundary.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from enum import Enum
from typing import Optional
class AppointmentStatus(str, Enum):
"""Standardized appointment status."""
CONFIRMED = "confirmed"
PENDING = "pending"
CANCELLED = "cancelled"
COMPLETED = "completed"
NO_SHOW = "no_show"
@dataclass(frozen=True)
class Appointment:
"""A single salon appointment — the core domain object.
Fields match what the daily-board (A1) needs to display:
time, client, service, staff, status, confirmation flag.
"""
appointment_id: str
start_time: datetime
end_time: datetime
client_name: str
service_name: str
staff_name: str
status: AppointmentStatus
notes: str = ""
# Whether the client still needs a confirmation call/message.
# Derived at build time from status + last_contact, but stored here
# for fixture convenience.
needs_confirmation: bool = False
def duration_minutes(self) -> int:
"""Appointment duration in whole minutes."""
delta = self.end_time - self.start_time
return int(delta.total_seconds() // 60)
def start_time_only(self) -> time:
return self.start_time.time()
def end_time_only(self) -> time:
return self.end_time.time()
@dataclass(frozen=True)
class Gap:
"""An unbooked time slot between two appointments (or day boundary)."""
start_time: time
end_time: time
duration_minutes: int
staff_name: str
# The appointment immediately before this gap (if any).
preceding_appointment_id: Optional[str] = None
# The appointment immediately after this gap (if any).
following_appointment_id: Optional[str] = None
@dataclass(frozen=True)
class DayBoard:
"""The complete daily board for one staff member or the whole salon.
This is the structured output that the daily-board skill presents.
All data is deterministic — no model inference.
"""
date: str # YYYY-MM-DD
salon_name: str
source: str # "fixtures" | "offline" | "vagaro" | "square" (future)
is_offline: bool # True when source is fixtures or offline
appointments: list[Appointment] = field(default_factory=list)
gaps: list[Gap] = field(default_factory=list)
needs_confirmation: list[Appointment] = field(default_factory=list)
total_booked_minutes: int = 0
total_gap_minutes: int = 0
def to_dict(self) -> dict:
"""Serialize to a plain dict for JSON output."""
return {
"date": self.date,
"salon_name": self.salon_name,
"source": self.source,
"is_offline": self.is_offline,
"appointments": [
{
"id": a.appointment_id,
"start": a.start_time.isoformat(),
"end": a.end_time.isoformat(),
"client": a.client_name,
"service": a.service_name,
"staff": a.staff_name,
"status": a.status.value,
"needs_confirmation": a.needs_confirmation,
"notes": a.notes,
}
for a in self.appointments
],
"gaps": [
{
"start": g.start_time.isoformat(),
"end": g.end_time.isoformat(),
"duration_minutes": g.duration_minutes,
"staff": g.staff_name,
}
for g in self.gaps
],
"needs_confirmation": [
a.appointment_id for a in self.needs_confirmation
],
"total_booked_minutes": self.total_booked_minutes,
"total_gap_minutes": self.total_gap_minutes,
}
@@ -0,0 +1 @@
"""Provider adapters for external data sources."""
@@ -0,0 +1 @@
"""Books provider: QuickBooks Online (future)."""
@@ -0,0 +1 @@
"""MCP client helpers and allowlist metadata (future)."""
@@ -0,0 +1 @@
"""Scheduling provider: fixtures, Vagaro, Square (future)."""
@@ -0,0 +1,125 @@
"""Fixture provider for scheduling data.
Loads appointment fixtures from JSON files under data/fixtures/scheduling/.
This is the *only* data source for the daily-board until live SaaS adapters
(Vagaro, Square) are implemented.
All output is labeled `source: fixtures` / `is_offline: True` so the owner
never sees silent fake live data.
"""
from __future__ import annotations
import json
import pathlib
from datetime import datetime
from typing import Any
from lumina_skills.domain import Appointment, AppointmentStatus
# Mapping from fixture status strings to domain enum.
_STATUS_MAP: dict[str, AppointmentStatus] = {
"confirmed": AppointmentStatus.CONFIRMED,
"pending": AppointmentStatus.PENDING,
"cancelled": AppointmentStatus.CANCELLED,
"completed": AppointmentStatus.COMPLETED,
"no_show": AppointmentStatus.NO_SHOW,
}
def _parse_status(raw: str) -> AppointmentStatus:
"""Convert a fixture status string to AppointmentStatus.
Raises:
ValueError: If the status string is not recognized.
"""
key = raw.lower()
if key not in _STATUS_MAP:
raise ValueError(
f"Unknown appointment status {raw!r}. "
f"Expected one of: {', '.join(sorted(_STATUS_MAP))}"
)
return _STATUS_MAP[key]
def _parse_datetime(raw: str) -> datetime:
"""Parse ISO-format datetime strings from fixtures."""
return datetime.fromisoformat(raw)
def load_fixtures(fixture_path: str | pathlib.Path) -> list[Appointment]:
"""Load appointments from a fixture JSON file.
Expected top-level shape:
```json
{
"salon_name": "Lumina Hair Studio & Spa",
"date": "2026-07-28",
"business_hours": {"open": "09:00", "close": "18:00"},
"staff": [{"name": "Claire Bennett", "role": "owner-stylist"}],
"appointments": [
{
"id": "APT-001",
"start": "2026-07-28T09:00:00",
"end": "2026-07-28T10:00:00",
"client_name": "Elena Rossi",
"service_name": "Balayage + Cut",
"staff_name": "Claire Bennett",
"status": "confirmed",
"needs_confirmation": false,
"notes": "Formula: 9.1 + 0-45 gloss"
}
]
}
```
Args:
fixture_path: Path to a JSON fixture file.
Returns:
List of Appointment domain objects.
Raises:
FileNotFoundError: If the fixture file does not exist.
ValueError: If the fixture JSON is malformed.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
appointments: list[Appointment] = []
for apt_raw in raw.get("appointments", []):
appointments.append(Appointment(
appointment_id=apt_raw["id"],
start_time=_parse_datetime(apt_raw["start"]),
end_time=_parse_datetime(apt_raw["end"]),
client_name=apt_raw["client_name"],
service_name=apt_raw["service_name"],
staff_name=apt_raw["staff_name"],
status=_parse_status(apt_raw.get("status", "pending")),
notes=apt_raw.get("notes", ""),
needs_confirmation=apt_raw.get("needs_confirmation", False),
))
return appointments
def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]:
"""Load non-appointment metadata from a fixture file.
Returns salon_name, date, business_hours, staff list, etc.
"""
path = pathlib.Path(fixture_path)
if not path.exists():
raise FileNotFoundError(f"Fixture not found: {path}")
raw = json.loads(path.read_text(encoding="utf-8"))
return {
"salon_name": raw.get("salon_name", "Unknown Salon"),
"date": raw.get("date", ""),
"business_hours": raw.get("business_hours", {}),
"staff": raw.get("staff", []),
}