diff --git a/data/fixtures/setup/capability_matrix.json b/data/fixtures/setup/capability_matrix.json new file mode 100644 index 0000000..7852645 --- /dev/null +++ b/data/fixtures/setup/capability_matrix.json @@ -0,0 +1,62 @@ +{ + "salon_name": "Lumina Hair Studio & Spa", + "is_fixture": true, + "generated_at": "2026-07-27T00:00:00Z", + "note": "Fixture data for demo capability report — not live connection state", + "capabilities": [ + { + "area": "identity", + "provider": "assistant_name", + "status": "connected", + "details": "Assistant named 'Lumina'" + }, + { + "area": "profile", + "provider": "owner_profile", + "status": "connected", + "details": "Business name, timezone, hours, and hard rules configured" + }, + { + "area": "channels", + "provider": "whatsapp", + "status": "connected", + "details": "WhatsApp channel active" + }, + { + "area": "channels", + "provider": "email", + "status": "skipped", + "details": "Owner chose to skip email channel for now" + }, + { + "area": "channels", + "provider": "telegram", + "status": "later", + "details": "Planned for future setup" + }, + { + "area": "scheduling", + "provider": "vagaro", + "status": "offline", + "details": "Not yet connected — using fixture appointment data" + }, + { + "area": "scheduling", + "provider": "square", + "status": "skipped", + "details": "Owner uses Vagaro, not Square" + }, + { + "area": "books", + "provider": "quickbooks_online", + "status": "offline", + "details": "Not yet connected — using fixture financial data" + }, + { + "area": "expectations", + "provider": "boundaries", + "status": "connected", + "details": "Owner reviewed draft-only, no-pay, no-publish boundaries" + } + ] +} diff --git a/data/fixtures/setup/capability_matrix_all_connected.json b/data/fixtures/setup/capability_matrix_all_connected.json new file mode 100644 index 0000000..b9540c6 --- /dev/null +++ b/data/fixtures/setup/capability_matrix_all_connected.json @@ -0,0 +1,56 @@ +{ + "salon_name": "Lumina Hair Studio & Spa", + "is_fixture": true, + "generated_at": "2026-07-27T00:00:00Z", + "note": "Fixture data — all capabilities connected (demo scenario)", + "capabilities": [ + { + "area": "identity", + "provider": "assistant_name", + "status": "connected", + "details": "Assistant named 'Lumina'" + }, + { + "area": "profile", + "provider": "owner_profile", + "status": "connected", + "details": "Full profile configured" + }, + { + "area": "channels", + "provider": "whatsapp", + "status": "connected", + "details": "WhatsApp channel active" + }, + { + "area": "channels", + "provider": "email", + "status": "connected", + "details": "Email channel active" + }, + { + "area": "channels", + "provider": "telegram", + "status": "connected", + "details": "Telegram channel active" + }, + { + "area": "scheduling", + "provider": "vagaro", + "status": "connected", + "details": "Vagaro API connected" + }, + { + "area": "books", + "provider": "quickbooks_online", + "status": "connected", + "details": "QuickBooks Online read-only connected" + }, + { + "area": "expectations", + "provider": "boundaries", + "status": "connected", + "details": "Boundaries reviewed and confirmed" + } + ] +} diff --git a/data/fixtures/setup/capability_matrix_with_errors.json b/data/fixtures/setup/capability_matrix_with_errors.json new file mode 100644 index 0000000..9bbbd62 --- /dev/null +++ b/data/fixtures/setup/capability_matrix_with_errors.json @@ -0,0 +1,44 @@ +{ + "salon_name": "Lumina Hair Studio & Spa", + "is_fixture": true, + "generated_at": "2026-07-27T00:00:00Z", + "note": "Fixture data — includes error state for testing", + "capabilities": [ + { + "area": "identity", + "provider": "assistant_name", + "status": "connected", + "details": "Assistant named 'Lumina'" + }, + { + "area": "profile", + "provider": "owner_profile", + "status": "connected", + "details": "Profile configured" + }, + { + "area": "channels", + "provider": "whatsapp", + "status": "error", + "details": "WhatsApp webhook not responding — operator needs to check" + }, + { + "area": "channels", + "provider": "email", + "status": "skipped", + "details": "Skipped" + }, + { + "area": "scheduling", + "provider": "vagaro", + "status": "offline", + "details": "Not yet connected" + }, + { + "area": "books", + "provider": "quickbooks_online", + "status": "error", + "details": "QBO OAuth token expired — operator needs to refresh" + } + ] +} diff --git a/docs/SETUP_UX.md b/docs/SETUP_UX.md index f0fd971..f8a8cd5 100644 --- a/docs/SETUP_UX.md +++ b/docs/SETUP_UX.md @@ -1,6 +1,6 @@ # Owner Setup UX (post-install) -**Status:** Outline. Scenarios: [design/scenarios.md](../design/scenarios.md). +**Status:** Skill implemented (fixtures only). See [skills/setup-education/](../skills/setup-education/). Scenarios: [design/scenarios.md](../design/scenarios.md). ## Prerequisite diff --git a/skills/_lib/lumina_skills/README.md b/skills/_lib/lumina_skills/README.md index 3cc33bc..b678c1f 100644 --- a/skills/_lib/lumina_skills/README.md +++ b/skills/_lib/lumina_skills/README.md @@ -13,6 +13,9 @@ Shared deterministic library used by Salon_Assistant skills. All code here is | `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 | +| `setup/capability_report.py` | ✅ | Capability report domain model + builder (E6) | +| `setup/lesson_catalog.py` | ✅ | Static setup education lessons (E1) | +| `providers/setup/fixture_provider.py` | ✅ | Fixture JSON loader for capability state | | `providers/scheduling/` | ⏳ | Vagaro / Square adapters (future) | | `providers/books/` | ⏳ | QuickBooks Online adapters (future) | | `providers/mcp/` | ⏳ | MCP client helpers / allowlist metadata (future) | diff --git a/skills/_lib/lumina_skills/providers/setup/__init__.py b/skills/_lib/lumina_skills/providers/setup/__init__.py new file mode 100644 index 0000000..f98cae4 --- /dev/null +++ b/skills/_lib/lumina_skills/providers/setup/__init__.py @@ -0,0 +1 @@ +"""Setup education providers — fixture loaders for capability state.""" diff --git a/skills/_lib/lumina_skills/providers/setup/fixture_provider.py b/skills/_lib/lumina_skills/providers/setup/fixture_provider.py new file mode 100644 index 0000000..745f6ce --- /dev/null +++ b/skills/_lib/lumina_skills/providers/setup/fixture_provider.py @@ -0,0 +1,125 @@ +"""Fixture provider for setup/capability data. + +Loads capability state fixtures from JSON files under data/fixtures/setup/. +All output is labeled `is_fixture: True` so the owner never sees +silent fake live data. + +This is the *only* data source for the capability report until live +connection state is available. +""" + +from __future__ import annotations + +import json +import pathlib +from typing import Any + +from lumina_skills.setup.capability_report import ( + CapabilityEntry, + CapabilityReport, + ConnectionStatus, + build_capability_report, +) + +# Mapping from fixture status strings to domain enum. +_STATUS_MAP: dict[str, ConnectionStatus] = { + "connected": ConnectionStatus.CONNECTED, + "skipped": ConnectionStatus.SKIPPED, + "later": ConnectionStatus.LATER, + "error": ConnectionStatus.ERROR, + "offline": ConnectionStatus.OFFLINE, +} + + +def _parse_status(raw: str) -> ConnectionStatus: + """Convert a fixture status string to ConnectionStatus. + + Raises: + ValueError: If the status string is not recognized. + """ + key = raw.lower().strip() + if key not in _STATUS_MAP: + raise ValueError( + f"Unknown capability status {raw!r}. " + f"Expected one of: {', '.join(sorted(_STATUS_MAP))}" + ) + return _STATUS_MAP[key] + + +def load_capability_fixture(fixture_path: str | pathlib.Path) -> CapabilityReport: + """Load a capability report from a fixture JSON file. + + Expected top-level shape: + ```json + { + "salon_name": "Lumina Hair Studio & Spa", + "is_fixture": true, + "capabilities": [ + { + "area": "channels", + "provider": "whatsapp", + "status": "connected", + "details": "WhatsApp channel active" + } + ] + } + ``` + + Args: + fixture_path: Path to a JSON fixture file. + + Returns: + A fully populated CapabilityReport. + + Raises: + FileNotFoundError: If the fixture file does not exist. + ValueError: If the fixture JSON is malformed or has unknown status. + """ + 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")) + + salon_name = raw.get("salon_name", "Unknown Salon") + is_fixture = raw.get("is_fixture", True) + + capabilities: list[CapabilityEntry] = [] + for i, cap_raw in enumerate(raw.get("capabilities", [])): + # Validate required fields with clear error messages. + for required_field in ("area", "provider", "status"): + if required_field not in cap_raw: + raise ValueError( + f"Capability entry {i} missing required field {required_field!r}. " + f"Each entry must have 'area', 'provider', and 'status'." + ) + capabilities.append(CapabilityEntry( + area=cap_raw["area"], + provider=cap_raw["provider"], + status=_parse_status(cap_raw["status"]), + details=cap_raw.get("details", ""), + )) + + return build_capability_report( + capabilities=capabilities, + salon_name=salon_name, + is_fixture=is_fixture, + ) + + +def load_fixture_metadata(fixture_path: str | pathlib.Path) -> dict[str, Any]: + """Load non-capability metadata from a fixture file. + + Returns salon_name, is_fixture, generated_at, note, 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"), + "is_fixture": raw.get("is_fixture", True), + "generated_at": raw.get("generated_at", ""), + "note": raw.get("note", ""), + } diff --git a/skills/_lib/lumina_skills/setup/__init__.py b/skills/_lib/lumina_skills/setup/__init__.py new file mode 100644 index 0000000..b243467 --- /dev/null +++ b/skills/_lib/lumina_skills/setup/__init__.py @@ -0,0 +1 @@ +"""Setup education — deterministic modules for E1 setup-education and E6 capability report.""" diff --git a/skills/_lib/lumina_skills/setup/capability_report.py b/skills/_lib/lumina_skills/setup/capability_report.py new file mode 100644 index 0000000..ef28a13 --- /dev/null +++ b/skills/_lib/lumina_skills/setup/capability_report.py @@ -0,0 +1,223 @@ +"""Deterministic capability report domain model and builder. + +Covers use cases E6 (Capability report) and E7 (Degraded mode). +All logic is deterministic — 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 enum import Enum +from typing import Optional + + +# ── Status enum ──────────────────────────────────────────────────────────── + +class ConnectionStatus(str, Enum): + """Plain-language connection status for a capability area. + + These map directly to what the owner sees in the capability report. + """ + CONNECTED = "connected" + SKIPPED = "skipped" + LATER = "later" + ERROR = "error" + OFFLINE = "offline" # fixtures/demo mode — never silent as live + + +# ── Plain-language labels ────────────────────────────────────────────────── + +_STATUS_LABELS: dict[ConnectionStatus, str] = { + ConnectionStatus.CONNECTED: "✅ Connected", + ConnectionStatus.SKIPPED: "⏭️ Skipped", + ConnectionStatus.LATER: "⏳ Set up later", + ConnectionStatus.ERROR: "❌ Error — needs attention", + ConnectionStatus.OFFLINE: "📋 Offline / fixtures", +} + + +def status_label(status: ConnectionStatus) -> str: + """Return the owner-facing emoji label for a connection status.""" + return _STATUS_LABELS[status] + + +# ── Domain types ─────────────────────────────────────────────────────────── + +@dataclass(frozen=True) +class CapabilityEntry: + """A single capability area in the report (e.g., scheduling, books, channels). + + Attributes: + area: Category — "scheduling", "books", "channels", "profile", "identity". + provider: Specific provider name — "vagaro", "square", "qbo", "whatsapp", etc. + status: Current connection status. + details: Optional additional context for the owner. + """ + area: str + provider: str + status: ConnectionStatus + details: str = "" + + @property + def label(self) -> str: + """Owner-facing status label with emoji.""" + return status_label(self.status) + + +@dataclass(frozen=True) +class CapabilityReport: + """The complete capability report for a salon. + + This is the structured output that the setup-education skill presents. + All data is deterministic — no model inference. + + Attributes: + salon_name: Display name of the salon. + is_fixture: True when data comes from fixtures (demo mode). + capabilities: List of capability entries. + """ + salon_name: str + is_fixture: bool + capabilities: list[CapabilityEntry] = field(default_factory=list) + + # ── Aggregation helpers ────────────────────────────────────────────── + + def connected_count(self) -> int: + """Number of capabilities with CONNECTED status.""" + return sum(1 for c in self.capabilities if c.status == ConnectionStatus.CONNECTED) + + def offline_count(self) -> int: + """Number of capabilities with OFFLINE status.""" + return sum(1 for c in self.capabilities if c.status == ConnectionStatus.OFFLINE) + + def skipped_count(self) -> int: + """Number of capabilities with SKIPPED or LATER status.""" + return sum( + 1 for c in self.capabilities + if c.status in (ConnectionStatus.SKIPPED, ConnectionStatus.LATER) + ) + + def error_count(self) -> int: + """Number of capabilities with ERROR status.""" + return sum(1 for c in self.capabilities if c.status == ConnectionStatus.ERROR) + + def all_connected(self) -> bool: + """True if there is at least one capability and every capability is CONNECTED. + + Returns False for an empty report — a salon with zero capabilities + is not "all connected." + """ + if not self.capabilities: + return False + return all(c.status == ConnectionStatus.CONNECTED for c in self.capabilities) + + def has_errors(self) -> bool: + """True if any capability has ERROR status.""" + return any(c.status == ConnectionStatus.ERROR for c in self.capabilities) + + def to_dict(self) -> dict: + """Serialize to a plain dict for JSON output.""" + return { + "salon_name": self.salon_name, + "is_fixture": self.is_fixture, + "capabilities": [ + { + "area": c.area, + "provider": c.provider, + "status": c.status.value, + "label": c.label, + "details": c.details, + } + for c in self.capabilities + ], + "summary": { + "connected": self.connected_count(), + "offline": self.offline_count(), + "skipped_or_later": self.skipped_count(), + "errors": self.error_count(), + "all_connected": self.all_connected(), + }, + } + + +# ── Builder ──────────────────────────────────────────────────────────────── + +def build_capability_report( + capabilities: list[CapabilityEntry], + salon_name: str, + is_fixture: bool = False, +) -> CapabilityReport: + """Build a CapabilityReport from a list of CapabilityEntry objects. + + Args: + capabilities: List of capability entries (from fixtures or live state). + salon_name: Display name of the salon. + is_fixture: True when data comes from fixtures (demo mode). + + Returns: + A fully populated CapabilityReport. + """ + return CapabilityReport( + salon_name=salon_name, + is_fixture=is_fixture, + capabilities=list(capabilities), + ) + + +def format_capability_report_text(report: CapabilityReport) -> str: + """Format a CapabilityReport 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. + + Output is owner-safe: no terminal, docker, nano, or shell instructions. + """ + lines: list[str] = [] + + # Header. + fixture_tag = " [📋 FIXTURE DATA]" if report.is_fixture else "" + lines.append(f"═══ Capability Report — {report.salon_name}{fixture_tag} ═══") + lines.append("") + + # Group by area. + areas: dict[str, list[CapabilityEntry]] = {} + for cap in report.capabilities: + areas.setdefault(cap.area, []).append(cap) + + # Define display order. + area_order = ["identity", "profile", "channels", "scheduling", "books"] + ordered_areas = [a for a in area_order if a in areas] + # Append any areas not in the predefined order. + for a in areas: + if a not in ordered_areas: + ordered_areas.append(a) + + for area in ordered_areas: + entries = areas[area] + area_display = area.replace("_", " ").title() + lines.append(f"── {area_display} ──") + for entry in entries: + provider_display = entry.provider.replace("_", " ").title() + lines.append(f" {entry.label} {provider_display}") + if entry.details: + lines.append(f" {entry.details}") + lines.append("") + + # Summary. + lines.append("── Summary ──") + lines.append(f" Connected: {report.connected_count()} | " + f"Offline/fixtures: {report.offline_count()} | " + f"Skipped/later: {report.skipped_count()} | " + f"Errors: {report.error_count()}") + + if report.has_errors(): + lines.append("") + lines.append("⚠️ Some connections need attention. Ask your operator to check the error details.") + + if report.is_fixture: + lines.append("") + lines.append("📋 This report uses fixture (demo) data. Real statuses appear after connections are live.") + + return "\n".join(lines) diff --git a/skills/_lib/lumina_skills/setup/lesson_catalog.py b/skills/_lib/lumina_skills/setup/lesson_catalog.py new file mode 100644 index 0000000..b04bcf9 --- /dev/null +++ b/skills/_lib/lumina_skills/setup/lesson_catalog.py @@ -0,0 +1,310 @@ +"""Static lesson catalog for setup education (E1). + +Each lesson corresponds to a step in docs/SETUP_UX.md. +All text is owner-safe: browser/vendor UI steps only. +No terminal, docker, nano, or shell instructions. + +This is deterministic data — no model inference. +See design/det-vs-inf.md. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Optional + + +# ── Forbidden keywords ───────────────────────────────────────────────────── + +# Owner-safe text must NEVER contain these keywords. +# +# Single-word keywords are matched with word boundaries (\b...\b). +# Multi-word phrases are matched as literal phrases with word boundaries. +# This prevents false positives like "brew installation" matching "brew install". +FORBIDDEN_KEYWORDS = frozenset([ + "terminal", + "docker", + "nano", + "vim", + "emacs", + "shell", + "bash", + "sudo", + "apt-get", + "brew install", + "pip install", + "npm install", + "curl", + "wget", + "chmod", + "ssh", + "rsync", + "scp", + "docker-compose", + "docker run", + "docker exec", + "kubectl", + "make install", + "git clone", + "git push", + "git pull", +]) + + +def _keyword_matches(text_lower: str, keyword: str) -> bool: + """Check if a keyword matches in text using word-boundary-aware matching. + + Single-word keywords use \\b boundaries. Multi-word phrases use + \\b at the start and end of the full phrase to avoid partial matches + like "brew installation" matching "brew install". + + Args: + text_lower: Lowercased text to search. + keyword: The forbidden keyword or phrase to search for. + + Returns: + True if the keyword was found with proper word boundaries. + """ + escaped = re.escape(keyword) + pattern = r'\b' + escaped + r'\b' + return bool(re.search(pattern, text_lower)) + + +@dataclass(frozen=True) +class Lesson: + """A single setup education lesson. + + Attributes: + step: Step number (1–7, matching SETUP_UX.md). + title: Short title for the lesson. + description: What this step accomplishes. + instructions: Owner-safe instructions (browser/vendor UI only). + what_it_enables: What capabilities become available after this step. + possible_outcomes: List of possible end states. + area: Capability area this lesson belongs to. + """ + step: int + title: str + description: str + instructions: str + what_it_enables: str + possible_outcomes: list[str] = field(default_factory=list) + area: str = "" + + +# ── Lesson catalog ───────────────────────────────────────────────────────── + +LESSON_CATALOG: list[Lesson] = [ + Lesson( + step=1, + title="Name the assistant", + description="Choose a name for your salon assistant. This becomes how the assistant identifies itself in conversations.", + instructions=( + "Tell the assistant what you'd like to call it — for example 'Lumina', " + "'Salon Helper', or any name you prefer. The assistant will use this name " + "in all conversations going forward." + ), + what_it_enables="Personalized assistant identity across all channels.", + possible_outcomes=["connected", "skipped"], + area="identity", + ), + Lesson( + step=2, + title="Profile intake", + description="Share your business details so the assistant can tailor its help to your salon.", + instructions=( + "Answer a few questions about your salon: business name, timezone, " + "business hours, staff names, and any hard rules you want the assistant " + "to follow (e.g., 'never send messages without my approval')." + ), + what_it_enables="Context-aware responses, correct timezone handling, staff-aware boards.", + possible_outcomes=["connected", "skipped", "later"], + area="profile", + ), + Lesson( + step=3, + title="Connect channels", + description="Choose which messaging channels you want to use with the assistant.", + instructions=( + "Decide which channels to use:\n" + " • WhatsApp — chat with the assistant on your phone\n" + " • Email — thread-based conversations\n" + " • Telegram — bot-style chat\n\n" + "Your operator will configure the channels you choose. You can skip any " + "channel now and add it later." + ), + what_it_enables="Talk to the assistant on your preferred messaging apps.", + possible_outcomes=["connected", "skipped", "later", "error"], + area="channels", + ), + Lesson( + step=4, + title="Connect scheduling", + description="Link your scheduling system (Vagaro and/or Square) so the assistant can read your appointments.", + instructions=( + "If you use Vagaro:\n" + " 1. Log in to your Vagaro account in your browser.\n" + " 2. Go to Settings → Integrations and generate an API key.\n" + " 3. Share the API key with your operator.\n\n" + "If you use Square:\n" + " 1. Log in to the Square Developer Portal in your browser.\n" + " 2. Create an application and generate an access token.\n" + " 3. Share the token with your operator.\n\n" + "The assistant reads your schedule — it never modifies bookings or charges clients." + ), + what_it_enables="Daily board, appointment gaps, confirmation flags, client prep cards.", + possible_outcomes=["connected", "skipped", "later", "error"], + area="scheduling", + ), + Lesson( + step=5, + title="Connect books", + description="Link QuickBooks Online so the assistant can show you a read-only financial picture.", + instructions=( + "1. Log in to QuickBooks Online in your browser.\n" + "2. Authorize the assistant's read-only access when prompted.\n" + "3. Your operator will complete the connection on their end.\n\n" + "The assistant reads your books — it never pays bills, creates charges, " + "or modifies financial records." + ), + what_it_enables="Books snapshot, open invoices, bills due, vendor spend lookup.", + possible_outcomes=["connected", "skipped", "later", "error"], + area="books", + ), + Lesson( + step=6, + title="Set expectations", + description="Understand what the assistant can and cannot do.", + instructions=( + "The assistant is designed with these boundaries:\n" + " • Drafts messages — you send them (no silent auto-send)\n" + " • Drafts social posts — you publish them (no auto-publish)\n" + " • Reads your books — never pays bills or charges cards\n" + " • Reads your schedule — never modifies bookings\n" + " • Labels demo data clearly — never shows fake data as real\n\n" + "These boundaries are built in and cannot be turned off." + ), + what_it_enables="Clear understanding of assistant capabilities and safety boundaries.", + possible_outcomes=["connected"], + area="expectations", + ), + Lesson( + step=7, + title="Review capability report", + description="See a summary of what is connected, what is offline, and what was skipped.", + instructions=( + "Ask the assistant for a capability report. It will show:\n" + " • ✅ Connected — working integrations\n" + " • 📋 Offline/fixtures — demo data (not live)\n" + " • ⏭️ Skipped — you chose to skip this step\n" + " • ⏳ Set up later — planned for future\n" + " • ❌ Error — needs operator attention\n\n" + "This report updates as you connect more services." + ), + what_it_enables="Clear picture of what works and what needs attention.", + possible_outcomes=["connected"], + area="report", + ), +] + + +def get_lesson(step: int) -> Optional[Lesson]: + """Get a lesson by step number (1–7). + + Returns None if the step number is not found. + """ + for lesson in LESSON_CATALOG: + if lesson.step == step: + return lesson + return None + + +def get_lessons_by_area(area: str) -> list[Lesson]: + """Get all lessons for a given capability area.""" + return [l for l in LESSON_CATALOG if l.area == area] + + +def get_all_lessons() -> list[Lesson]: + """Return the full lesson catalog in step order.""" + return list(LESSON_CATALOG) + + +def format_lesson_text(lesson: Lesson) -> str: + """Format a single lesson as structured text for chat display. + + This is deterministic formatting — no model inference. + """ + lines: list[str] = [] + lines.append(f"Step {lesson.step}: {lesson.title}") + lines.append("") + lines.append(lesson.description) + lines.append("") + lines.append("What to do:") + lines.append(lesson.instructions) + lines.append("") + lines.append(f"This enables: {lesson.what_it_enables}") + lines.append("") + lines.append(f"Possible outcomes: {', '.join(lesson.possible_outcomes)}") + return "\n".join(lines) + + +def format_all_lessons_text() -> str: + """Format the full lesson catalog as structured text.""" + lines: list[str] = [] + lines.append("═══ Setup Education — All Steps ═══") + lines.append("") + for lesson in LESSON_CATALOG: + lines.append(format_lesson_text(lesson)) + lines.append("") + lines.append("─" * 50) + lines.append("") + return "\n".join(lines) + + +def is_owner_safe(text: str) -> bool: + """Check that text does not contain forbidden keywords. + + Owner-safe text must never contain terminal, docker, nano, or shell + instructions. This is a deterministic check using word-boundary-aware + matching to avoid false positives on multi-word phrases. + + Args: + text: Text to validate. + + Returns: + True if the text is owner-safe (no forbidden keywords found). + """ + text_lower = text.lower() + found = [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)] + return len(found) == 0 + + +def validate_lesson_owner_safe(lesson: Lesson) -> list[str]: + """Validate that a lesson contains no forbidden keywords. + + Checks title, description, instructions, what_it_enables, and + possible_outcomes. + + Returns a list of forbidden keywords found (empty if clean). + """ + all_text = ( + f"{lesson.title} {lesson.description} {lesson.instructions} " + f"{lesson.what_it_enables} {' '.join(lesson.possible_outcomes)}" + ) + text_lower = all_text.lower() + return [kw for kw in FORBIDDEN_KEYWORDS if _keyword_matches(text_lower, kw)] + + +def validate_catalog_owner_safe() -> dict[int, list[str]]: + """Validate the entire lesson catalog for owner-safe text. + + Returns a dict mapping step numbers to lists of forbidden keywords found. + Empty dict means all lessons are clean. + """ + violations: dict[int, list[str]] = {} + for lesson in LESSON_CATALOG: + found = validate_lesson_owner_safe(lesson) + if found: + violations[lesson.step] = found + return violations diff --git a/skills/setup-education/README.md b/skills/setup-education/README.md index 8d59333..d51e919 100644 --- a/skills/setup-education/README.md +++ b/skills/setup-education/README.md @@ -1,5 +1,46 @@ -# `setup-education` (scaffold) +# `setup-education` -**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). +Owner-safe connect education and capability report for use cases E1, E6, and E7. + +## What it does + +- **7 setup lessons** — step-by-step education for connecting the assistant + (name, profile, channels, scheduling, books, expectations, capability report) +- **Capability report** — plain-language summary of what is connected, offline, + skipped, or in error +- **Owner-safe** — all education text validated to never contain terminal, + docker, nano, or shell instructions +- **Fixture-labeled** — all demo data clearly marked `📋 FIXTURE DATA` + +## Quick start + +```bash +# Capability report (default demo data) +python skills/setup-education/scripts/build_capability_report.py + +# JSON output +python skills/setup-education/scripts/build_capability_report.py --format json + +# Show lesson 4 (connect scheduling) +python skills/setup-education/scripts/build_capability_report.py --lesson 4 + +# Show all lessons +python skills/setup-education/scripts/build_capability_report.py --all-lessons +``` + +## Fixtures + +| File | Description | +|------|-------------| +| `data/fixtures/setup/capability_matrix.json` | Demo: mixed states (connected, offline, skipped, later) | +| `data/fixtures/setup/capability_matrix_all_connected.json` | Demo: all connected | +| `data/fixtures/setup/capability_matrix_with_errors.json` | Demo: includes error states | + +## Design references + +- Use case: [E1 — Educational setup](../../design/use-cases.md) +- Use case: [E6 — Capability report](../../design/use-cases.md) +- Use case: [E7 — Degraded mode](../../design/use-cases.md) +- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md) diff --git a/skills/setup-education/SKILL.md b/skills/setup-education/SKILL.md index a84b55b..e98970d 100644 --- a/skills/setup-education/SKILL.md +++ b/skills/setup-education/SKILL.md @@ -10,9 +10,126 @@ Owner-safe connect education and capability report. ## Description -Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) and messaging channels. Provides a capability report showing what is connected. +Guides the owner through connecting their SaaS integrations (Square, QBO, Vagaro) +and messaging channels. Provides a capability report showing what is connected, +what is offline/fixtures, and what was skipped. + +All education text is **owner-safe**: browser/vendor UI steps only. Never +terminal, docker, nano, or shell instructions. + +## What it does + +- Presents 7 setup education lessons (matching `docs/SETUP_UX.md` steps 1–7) +- Builds a capability report from fixture or live connection state +- Labels all fixture data as `📋 FIXTURE DATA` — never silent fake live data +- Validates that education text never contains forbidden keywords +- Outputs structured text or JSON + +## Data sources + +| Source | Status | Label in output | +|--------|--------|-----------------| +| Fixtures (JSON) | ✅ Implemented | `📋 FIXTURE DATA` | +| Live connection state | Not yet | `LIVE DATA` (future) | ## Constraints -- Owner-safe: no terminal instructions. -- Browser/vendor UI steps only. +- Deterministic facts from fixtures; no model inference for capability data. +- Owner-safe: no terminal/docker/nano/shell instructions in education text. +- Fixtures/stubs only — no real OAuth, no live secrets. +- Fixture `is_fixture` flag always `true` — never silent as live. + +## Usage + +### CLI + +```bash +# Build capability report from fixtures (default demo data) +python skills/setup-education/scripts/build_capability_report.py + +# JSON output +python skills/setup-education/scripts/build_capability_report.py --format json + +# Show a specific lesson +python skills/setup-education/scripts/build_capability_report.py --lesson 4 + +# Show all lessons +python skills/setup-education/scripts/build_capability_report.py --all-lessons + +# Use a different fixture +python skills/setup-education/scripts/build_capability_report.py \ + --fixtures data/fixtures/setup/capability_matrix_all_connected.json +``` + +### Programmatic + +```python +from lumina_skills.providers.setup.fixture_provider import load_capability_fixture +from lumina_skills.setup.capability_report import format_capability_report_text +from lumina_skills.setup.lesson_catalog import get_lesson, format_lesson_text + +# Capability report +report = load_capability_fixture("data/fixtures/setup/capability_matrix.json") +print(format_capability_report_text(report)) + +# Individual lesson +lesson = get_lesson(4) +print(format_lesson_text(lesson)) +``` + +## Output format + +### Text (default) + +Structured text grouped by area (identity, profile, channels, scheduling, books) +with emoji status labels and a summary line. + +### JSON + +```json +{ + "salon_name": "Lumina Hair Studio & Spa", + "is_fixture": true, + "capabilities": [ + { + "area": "channels", + "provider": "whatsapp", + "status": "connected", + "label": "✅ Connected", + "details": "WhatsApp channel active" + } + ], + "summary": { + "connected": 4, + "offline": 2, + "skipped_or_later": 2, + "errors": 0, + "all_connected": false + } +} +``` + +## Files + +| Path | Purpose | +|------|---------| +| `SKILL.md` | Skill spec and usage | +| `scripts/build_capability_report.py` | CLI entrypoint | +| `../../skills/_lib/lumina_skills/setup/capability_report.py` | Domain model + builder | +| `../../skills/_lib/lumina_skills/setup/lesson_catalog.py` | Static lesson steps | +| `../../skills/_lib/lumina_skills/providers/setup/fixture_provider.py` | Fixture loader | +| `../../data/fixtures/setup/` | Fixture JSON files | + +## Design references + +- Use case: [E1 — Educational setup](../../design/use-cases.md) +- Use case: [E6 — Capability report](../../design/use-cases.md) +- Use case: [E7 — Degraded mode](../../design/use-cases.md) +- Setup UX: [docs/SETUP_UX.md](../../docs/SETUP_UX.md) +- Deterministic boundary: [design/det-vs-inf.md](../../design/det-vs-inf.md) + +## Future + +- Live connection state provider (replaces fixtures) +- Per-lesson progress tracking +- Automated lesson sequencing diff --git a/skills/setup-education/scripts/build_capability_report.py b/skills/setup-education/scripts/build_capability_report.py new file mode 100644 index 0000000..ae2ab85 --- /dev/null +++ b/skills/setup-education/scripts/build_capability_report.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Build a capability report from setup fixtures. + +Usage: + python build_capability_report.py [--fixtures PATH] [--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.setup.fixture_provider import ( + load_capability_fixture, + load_fixture_metadata, +) +from lumina_skills.setup.capability_report import format_capability_report_text +from lumina_skills.setup.lesson_catalog import ( + format_all_lessons_text, + format_lesson_text, + get_lesson, + get_all_lessons, +) + +# Default fixture: Claire Bennett demo capability matrix. +_DEFAULT_FIXTURE = _REPO_ROOT / "data" / "fixtures" / "setup" / "capability_matrix.json" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Build a capability report from setup fixture data.", + ) + parser.add_argument( + "--fixtures", + type=pathlib.Path, + default=_DEFAULT_FIXTURE, + help="Path to capability fixture JSON file.", + ) + parser.add_argument( + "--salon", + type=str, + default=None, + help="Override salon name.", + ) + parser.add_argument( + "--format", + choices=["text", "json"], + default="text", + help="Output format (default: text).", + ) + parser.add_argument( + "--lesson", + type=int, + default=None, + help="Show a specific setup lesson (1-7) instead of the capability report.", + ) + parser.add_argument( + "--all-lessons", + action="store_true", + help="Show all setup lessons instead of the capability report.", + ) + args = parser.parse_args() + + # Lesson mode. + if args.lesson is not None: + lesson = get_lesson(args.lesson) + if lesson is None: + print(f"Error: No lesson found for step {args.lesson}", file=sys.stderr) + return 1 + print(format_lesson_text(lesson)) + return 0 + + if args.all_lessons: + print(format_all_lessons_text()) + return 0 + + # Capability report mode. + try: + report = load_capability_fixture(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 + + # Override salon name if requested. + if args.salon: + from lumina_skills.setup.capability_report import build_capability_report + report = build_capability_report( + capabilities=report.capabilities, + salon_name=args.salon, + is_fixture=report.is_fixture, + ) + + # Output. + if args.format == "json": + print(json.dumps(report.to_dict(), indent=2)) + else: + print(format_capability_report_text(report)) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_capability_report.py b/tests/unit/test_capability_report.py new file mode 100644 index 0000000..5998d1e --- /dev/null +++ b/tests/unit/test_capability_report.py @@ -0,0 +1,300 @@ +"""Tests for lumina_skills.setup.capability_report domain model and builder.""" + +from __future__ import annotations + +import json +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib")) + +from lumina_skills.setup.capability_report import ( + CapabilityEntry, + CapabilityReport, + ConnectionStatus, + build_capability_report, + format_capability_report_text, + status_label, +) + + +# ── ConnectionStatus ─────────────────────────────────────────────────────── + +def test_connection_status_values(): + """All status enum values are correct.""" + assert ConnectionStatus.CONNECTED.value == "connected" + assert ConnectionStatus.SKIPPED.value == "skipped" + assert ConnectionStatus.LATER.value == "later" + assert ConnectionStatus.ERROR.value == "error" + assert ConnectionStatus.OFFLINE.value == "offline" + + +def test_status_labels(): + """Each status has an emoji label.""" + assert "✅" in status_label(ConnectionStatus.CONNECTED) + assert "⏭️" in status_label(ConnectionStatus.SKIPPED) + assert "⏳" in status_label(ConnectionStatus.LATER) + assert "❌" in status_label(ConnectionStatus.ERROR) + assert "📋" in status_label(ConnectionStatus.OFFLINE) + + +# ── CapabilityEntry ──────────────────────────────────────────────────────── + +def test_capability_entry_label(): + """Entry label uses status_label.""" + entry = CapabilityEntry( + area="channels", + provider="whatsapp", + status=ConnectionStatus.CONNECTED, + ) + assert "✅" in entry.label + + +def test_capability_entry_frozen(): + """CapabilityEntry is immutable.""" + entry = CapabilityEntry( + area="channels", + provider="whatsapp", + status=ConnectionStatus.CONNECTED, + ) + try: + entry.status = ConnectionStatus.ERROR + assert False, "Should not be able to modify frozen dataclass" + except Exception: + pass # Expected + + +# ── CapabilityReport ─────────────────────────────────────────────────────── + +def _make_entries(*statuses: ConnectionStatus) -> list[CapabilityEntry]: + """Helper to create CapabilityEntry objects with given statuses.""" + providers = ["whatsapp", "email", "telegram", "vagaro", "qbo"] + areas = ["channels", "channels", "channels", "scheduling", "books"] + entries = [] + for i, status in enumerate(statuses): + entries.append(CapabilityEntry( + area=areas[i], + provider=providers[i], + status=status, + )) + return entries + + +def test_report_connected_count(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ConnectionStatus.SKIPPED, + ConnectionStatus.OFFLINE, + ConnectionStatus.ERROR, + ) + report = build_capability_report(entries, "Test Salon") + assert report.connected_count() == 2 + + +def test_report_offline_count(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.OFFLINE, + ConnectionStatus.OFFLINE, + ConnectionStatus.SKIPPED, + ConnectionStatus.ERROR, + ) + report = build_capability_report(entries, "Test Salon") + assert report.offline_count() == 2 + + +def test_report_skipped_count(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.SKIPPED, + ConnectionStatus.LATER, + ConnectionStatus.OFFLINE, + ConnectionStatus.ERROR, + ) + report = build_capability_report(entries, "Test Salon") + assert report.skipped_count() == 2 # SKIPPED + LATER + + +def test_report_error_count(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.ERROR, + ConnectionStatus.ERROR, + ConnectionStatus.SKIPPED, + ConnectionStatus.OFFLINE, + ) + report = build_capability_report(entries, "Test Salon") + assert report.error_count() == 2 + + +def test_report_all_connected(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ) + report = build_capability_report(entries, "Test Salon") + assert report.all_connected() is True + + +def test_report_not_all_connected(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ConnectionStatus.SKIPPED, + ConnectionStatus.CONNECTED, + ConnectionStatus.CONNECTED, + ) + report = build_capability_report(entries, "Test Salon") + assert report.all_connected() is False + + +def test_report_has_errors(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.ERROR, + ConnectionStatus.SKIPPED, + ) + report = build_capability_report(entries, "Test Salon") + assert report.has_errors() is True + + +def test_report_no_errors(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.SKIPPED, + ConnectionStatus.OFFLINE, + ) + report = build_capability_report(entries, "Test Salon") + assert report.has_errors() is False + + +def test_report_empty(): + report = build_capability_report([], "Empty Salon") + assert report.connected_count() == 0 + assert report.offline_count() == 0 + assert report.skipped_count() == 0 + assert report.error_count() == 0 + assert report.all_connected() is False # empty report is not "all connected" + assert report.has_errors() is False + + +# ── to_dict ──────────────────────────────────────────────────────────────── + +def test_report_to_dict(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.OFFLINE, + ConnectionStatus.SKIPPED, + ) + report = build_capability_report(entries, "Test Salon", is_fixture=True) + d = report.to_dict() + + assert d["salon_name"] == "Test Salon" + assert d["is_fixture"] is True + assert len(d["capabilities"]) == 3 + assert d["capabilities"][0]["status"] == "connected" + assert d["capabilities"][1]["status"] == "offline" + assert d["capabilities"][2]["status"] == "skipped" + assert d["summary"]["connected"] == 1 + assert d["summary"]["offline"] == 1 + assert d["summary"]["skipped_or_later"] == 1 + assert d["summary"]["errors"] == 0 + assert d["summary"]["all_connected"] is False + + +def test_report_to_dict_serializable(): + """to_dict output must be JSON-serializable.""" + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.OFFLINE, + ) + report = build_capability_report(entries, "Test Salon") + d = report.to_dict() + # Should not raise. + json.dumps(d) + + +# ── format_capability_report_text ────────────────────────────────────────── + +def test_format_text_includes_salon_name(): + entries = _make_entries(ConnectionStatus.CONNECTED) + report = build_capability_report(entries, "Lumina Hair Studio & Spa") + text = format_capability_report_text(report) + assert "Lumina Hair Studio & Spa" in text + + +def test_format_text_fixture_label(): + entries = _make_entries(ConnectionStatus.OFFLINE) + report = build_capability_report(entries, "Test Salon", is_fixture=True) + text = format_capability_report_text(report) + assert "FIXTURE DATA" in text + + +def test_format_text_no_fixture_label_when_live(): + entries = _make_entries(ConnectionStatus.CONNECTED) + report = build_capability_report(entries, "Test Salon", is_fixture=False) + text = format_capability_report_text(report) + assert "FIXTURE DATA" not in text + + +def test_format_text_includes_summary(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.OFFLINE, + ConnectionStatus.SKIPPED, + ) + report = build_capability_report(entries, "Test Salon") + text = format_capability_report_text(report) + assert "Summary" in text + assert "Connected:" in text + + +def test_format_text_error_warning(): + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.ERROR, + ) + report = build_capability_report(entries, "Test Salon") + text = format_capability_report_text(report) + assert "needs attention" in text + + +def test_format_text_fixture_disclaimer(): + entries = _make_entries(ConnectionStatus.OFFLINE) + report = build_capability_report(entries, "Test Salon", is_fixture=True) + text = format_capability_report_text(report) + assert "fixture" in text.lower() or "demo" in text.lower() + + +def test_format_text_groups_by_area(): + entries = [ + CapabilityEntry("channels", "whatsapp", ConnectionStatus.CONNECTED), + CapabilityEntry("channels", "email", ConnectionStatus.SKIPPED), + CapabilityEntry("scheduling", "vagaro", ConnectionStatus.OFFLINE), + CapabilityEntry("books", "quickbooks_online", ConnectionStatus.OFFLINE), + ] + report = build_capability_report(entries, "Test Salon") + text = format_capability_report_text(report) + assert "Channels" in text + assert "Scheduling" in text + assert "Books" in text + + +def test_format_text_owner_safe(): + """Formatted text must never contain forbidden keywords.""" + forbidden = ["terminal", "docker", "nano", "shell", "bash", "sudo"] + entries = _make_entries( + ConnectionStatus.CONNECTED, + ConnectionStatus.OFFLINE, + ConnectionStatus.SKIPPED, + ConnectionStatus.ERROR, + ConnectionStatus.LATER, + ) + report = build_capability_report(entries, "Test Salon", is_fixture=True) + text = format_capability_report_text(report).lower() + for kw in forbidden: + assert kw not in text, f"Formatted text contains forbidden keyword: {kw}" diff --git a/tests/unit/test_lesson_catalog.py b/tests/unit/test_lesson_catalog.py new file mode 100644 index 0000000..dfa07cf --- /dev/null +++ b/tests/unit/test_lesson_catalog.py @@ -0,0 +1,297 @@ +"""Tests for lumina_skills.setup.lesson_catalog.""" + +from __future__ import annotations + +import sys +import pathlib + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "skills" / "_lib")) + +from lumina_skills.setup.lesson_catalog import ( + FORBIDDEN_KEYWORDS, + Lesson, + LESSON_CATALOG, + format_lesson_text, + format_all_lessons_text, + get_lesson, + get_lessons_by_area, + get_all_lessons, + is_owner_safe, + validate_lesson_owner_safe, + validate_catalog_owner_safe, +) + + +# ── Lesson catalog structure ────────────────────────────────────────────── + +def test_catalog_has_seven_lessons(): + """Catalog must have exactly 7 lessons (matching SETUP_UX steps 1-7).""" + assert len(LESSON_CATALOG) == 7 + + +def test_lesson_steps_are_sequential(): + """Lesson steps must be 1 through 7.""" + steps = [l.step for l in LESSON_CATALOG] + assert steps == list(range(1, 8)) + + +def test_all_lessons_returns_all(): + assert len(get_all_lessons()) == 7 + + +def test_get_lesson_by_step(): + lesson = get_lesson(1) + assert lesson is not None + assert lesson.step == 1 + assert "name" in lesson.title.lower() or "assistant" in lesson.title.lower() + + +def test_get_lesson_out_of_range(): + assert get_lesson(0) is None + assert get_lesson(8) is None + assert get_lesson(-1) is None + + +def test_get_lessons_by_area(): + channel_lessons = get_lessons_by_area("channels") + assert len(channel_lessons) >= 1 + assert all(l.area == "channels" for l in channel_lessons) + + +def test_get_lessons_by_area_empty(): + assert get_lessons_by_area("nonexistent_area") == [] + + +# ── Lesson content ──────────────────────────────────────────────────────── + +def test_lesson_1_identity(): + lesson = get_lesson(1) + assert lesson is not None + assert lesson.area == "identity" + + +def test_lesson_2_profile(): + lesson = get_lesson(2) + assert lesson is not None + assert lesson.area == "profile" + + +def test_lesson_3_channels(): + lesson = get_lesson(3) + assert lesson is not None + assert lesson.area == "channels" + + +def test_lesson_4_scheduling(): + lesson = get_lesson(4) + assert lesson is not None + assert lesson.area == "scheduling" + + +def test_lesson_5_books(): + lesson = get_lesson(5) + assert lesson is not None + assert lesson.area == "books" + + +def test_lesson_6_expectations(): + lesson = get_lesson(6) + assert lesson is not None + assert lesson.area == "expectations" + + +def test_lesson_7_report(): + lesson = get_lesson(7) + assert lesson is not None + assert lesson.area == "report" + + +def test_lessons_have_non_empty_fields(): + """All lessons must have non-empty title, description, and instructions.""" + for lesson in LESSON_CATALOG: + assert lesson.title.strip(), f"Lesson {lesson.step} has empty title" + assert lesson.description.strip(), f"Lesson {lesson.step} has empty description" + assert lesson.instructions.strip(), f"Lesson {lesson.step} has empty instructions" + assert lesson.what_it_enables.strip(), f"Lesson {lesson.step} has empty what_it_enables" + + +def test_lessons_have_possible_outcomes(): + """All lessons must have at least one possible outcome.""" + for lesson in LESSON_CATALOG: + assert len(lesson.possible_outcomes) >= 1, f"Lesson {lesson.step} has no outcomes" + + +# ── Owner-safe validation ───────────────────────────────────────────────── + +def test_forbidden_keywords_not_empty(): + """FORBIDDEN_KEYWORDS must contain expected keywords.""" + assert "terminal" in FORBIDDEN_KEYWORDS + assert "docker" in FORBIDDEN_KEYWORDS + assert "nano" in FORBIDDEN_KEYWORDS + assert "bash" in FORBIDDEN_KEYWORDS + assert "sudo" in FORBIDDEN_KEYWORDS + assert "shell" in FORBIDDEN_KEYWORDS + + +def test_is_owner_safe_clean_text(): + assert is_owner_safe("Log in to your Vagaro account in your browser.") is True + assert is_owner_safe("Choose a name for your assistant.") is True + assert is_owner_safe("Share the API key with your operator.") is True + + +def test_is_owner_safe_forbidden_text(): + assert is_owner_safe("Run docker-compose up") is False + assert is_owner_safe("Open a terminal and type") is False + assert is_owner_safe("Edit with nano") is False + assert is_owner_safe("Execute the bash script") is False + + +def test_is_owner_safe_multi_word_no_false_positive(): + """Multi-word keywords must not false-positive on unrelated text.""" + # "brew install" should NOT match "brew installation" or "homebrew installed" + assert is_owner_safe("We use homebrew installed packages") is True + assert is_owner_safe("The brew installation completed") is True + assert is_owner_safe("I will install the app manually") is True + # "pip install" should NOT match "pip installed" or "install pip" + assert is_owner_safe("pip installed successfully") is True + assert is_owner_safe("install pip from the store") is True + # "git clone" should NOT match "clone git" (reversed) + assert is_owner_safe("clone git repository") is True + # "docker run" should NOT match "docker running" — but "docker" alone + # IS a single-word forbidden keyword, so we test the multi-word phrase + # in isolation by checking the phrase itself doesn't match a variant: + assert is_owner_safe("the container is running in background") is True + # "make install" should NOT match "make installation" + assert is_owner_safe("make installation directory") is True + # "npm install" should NOT match "npm installed" + assert is_owner_safe("npm installed globally") is True + + +def test_is_owner_safe_multi_word_true_positive(): + """Multi-word keywords must still match the exact phrase.""" + assert is_owner_safe("brew install python") is False + assert is_owner_safe("pip install requests") is False + assert is_owner_safe("npm install express") is False + assert is_owner_safe("git clone https://example.com") is False + assert is_owner_safe("docker run nginx") is False + assert is_owner_safe("make install all") is False + assert is_owner_safe("docker exec container") is False + assert is_owner_safe("git push origin main") is False + assert is_owner_safe("git pull origin main") is False + + +def test_is_owner_safe_single_word_boundaries(): + """Single-word keywords use word boundaries.""" + # "docker" should match standalone but not inside unrelated words + assert is_owner_safe("Use docker to containerize") is False + # "nano" should match standalone + assert is_owner_safe("Edit with nano") is False + # "bash" should match standalone + assert is_owner_safe("Run in bash") is False + + +def test_is_owner_safe_case_insensitive(): + assert is_owner_safe("Use DOCKER to run") is False + assert is_owner_safe("Open TERMINAL") is False + + +def test_validate_lesson_owner_safe_clean(): + """All catalog lessons must pass owner-safe validation.""" + for lesson in LESSON_CATALOG: + violations = validate_lesson_owner_safe(lesson) + assert violations == [], ( + f"Lesson {lesson.step} ({lesson.title}) contains forbidden keywords: {violations}" + ) + + +def test_validate_catalog_owner_safe(): + """Full catalog validation must return empty dict.""" + violations = validate_catalog_owner_safe() + assert violations == {}, f"Catalog has violations: {violations}" + + +def test_validate_lesson_owner_safe_detects_forbidden(): + """Validation correctly detects forbidden keywords.""" + bad_lesson = Lesson( + step=99, + title="Bad Lesson", + description="Run docker-compose up in your terminal", + instructions="Open bash and type sudo nano config.yml", + what_it_enables="Nothing", + ) + violations = validate_lesson_owner_safe(bad_lesson) + assert "docker" in violations + assert "terminal" in violations + assert "bash" in violations + assert "sudo" in violations + assert "nano" in violations + + +def test_validate_lesson_owner_safe_includes_possible_outcomes(): + """Validation checks possible_outcomes field for forbidden keywords.""" + lesson_with_bad_outcome = Lesson( + step=99, + title="Good Lesson", + description="Clean description", + instructions="Clean instructions", + what_it_enables="Clean enables", + possible_outcomes=["connected", "docker"], # "docker" in outcomes + ) + violations = validate_lesson_owner_safe(lesson_with_bad_outcome) + assert "docker" in violations, "possible_outcomes should be checked" + + +def test_validate_lesson_owner_safe_clean_outcomes(): + """Validation passes when possible_outcomes are clean.""" + clean_lesson = Lesson( + step=99, + title="Good Lesson", + description="Clean description", + instructions="Clean instructions", + what_it_enables="Clean enables", + possible_outcomes=["connected", "skipped", "later", "error"], + ) + violations = validate_lesson_owner_safe(clean_lesson) + assert violations == [], f"Clean lesson should have no violations: {violations}" + + +# ── Formatting ───────────────────────────────────────────────────────────── + +def test_format_lesson_text_includes_step(): + lesson = get_lesson(1) + text = format_lesson_text(lesson) + assert "Step 1" in text + + +def test_format_lesson_text_includes_title(): + lesson = get_lesson(1) + text = format_lesson_text(lesson) + assert lesson.title in text + + +def test_format_lesson_text_includes_instructions(): + lesson = get_lesson(4) + text = format_lesson_text(lesson) + assert "What to do:" in text + assert lesson.instructions in text + + +def test_format_lesson_text_owner_safe(): + """Formatted lesson text must be owner-safe.""" + for lesson in LESSON_CATALOG: + text = format_lesson_text(lesson) + assert is_owner_safe(text), ( + f"Lesson {lesson.step} formatted text contains forbidden keywords" + ) + + +def test_format_all_lessons_text_owner_safe(): + """Full lessons text must be owner-safe.""" + text = format_all_lessons_text() + assert is_owner_safe(text) + + +def test_format_all_lessons_text_includes_all_steps(): + text = format_all_lessons_text() + for i in range(1, 8): + assert f"Step {i}" in text diff --git a/tests/unit/test_setup_fixture_provider.py b/tests/unit/test_setup_fixture_provider.py new file mode 100644 index 0000000..7c2acf0 --- /dev/null +++ b/tests/unit/test_setup_fixture_provider.py @@ -0,0 +1,254 @@ +"""Tests for lumina_skills.providers.setup.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.setup.capability_report import ( + CapabilityReport, + ConnectionStatus, +) +from lumina_skills.providers.setup.fixture_provider import ( + load_capability_fixture, + load_fixture_metadata, +) + +# Paths to real fixture files. +_FIXTURE_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix.json" +_ALL_CONNECTED_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_all_connected.json" +_ERRORS_PATH = pathlib.Path(__file__).resolve().parents[2] / "data" / "fixtures" / "setup" / "capability_matrix_with_errors.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_capability_fixture ─────────────────────────────────────────────── + +def test_load_default_fixture(): + """Load the default capability matrix fixture.""" + report = load_capability_fixture(_FIXTURE_PATH) + assert isinstance(report, CapabilityReport) + assert report.salon_name == "Lumina Hair Studio & Spa" + assert report.is_fixture is True + assert len(report.capabilities) > 0 + + +def test_load_fixture_statuses(): + """Fixture statuses are correctly parsed.""" + report = load_capability_fixture(_FIXTURE_PATH) + statuses = {(c.area, c.provider): c.status for c in report.capabilities} + assert statuses[("identity", "assistant_name")] == ConnectionStatus.CONNECTED + assert statuses[("channels", "whatsapp")] == ConnectionStatus.CONNECTED + assert statuses[("channels", "email")] == ConnectionStatus.SKIPPED + assert statuses[("channels", "telegram")] == ConnectionStatus.LATER + assert statuses[("scheduling", "vagaro")] == ConnectionStatus.OFFLINE + + +def test_load_fixture_all_connected(): + """All-connected fixture has all CONNECTED statuses.""" + report = load_capability_fixture(_ALL_CONNECTED_PATH) + assert report.all_connected() is True + assert report.is_fixture is True + + +def test_load_fixture_with_errors(): + """Error fixture has ERROR statuses and reports has_errors.""" + report = load_capability_fixture(_ERRORS_PATH) + assert report.has_errors() is True + assert report.error_count() >= 1 + + +def test_load_fixture_is_fixture_flag(): + """Fixture reports always have is_fixture=True.""" + report = load_capability_fixture(_FIXTURE_PATH) + assert report.is_fixture is True + + +def test_load_fixture_missing_file(tmp_path: pathlib.Path): + """FileNotFoundError for missing fixture.""" + with pytest.raises(FileNotFoundError): + load_capability_fixture(tmp_path / "nonexistent.json") + + +def test_load_fixture_empty_capabilities(tmp_path: pathlib.Path): + """Empty capabilities list returns empty report.""" + data = { + "salon_name": "Empty Salon", + "is_fixture": True, + "capabilities": [], + } + path = _make_fixture_file(tmp_path, data) + report = load_capability_fixture(path) + assert len(report.capabilities) == 0 + assert report.salon_name == "Empty Salon" + + +def test_load_fixture_unknown_status_raises(tmp_path: pathlib.Path): + """Unknown status string raises ValueError.""" + data = { + "salon_name": "Test", + "is_fixture": True, + "capabilities": [{ + "area": "channels", + "provider": "whatsapp", + "status": "typo_status", + }], + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Unknown capability status"): + load_capability_fixture(path) + + +def test_load_fixture_missing_area_field(tmp_path: pathlib.Path): + """Missing 'area' field raises ValueError with clear message.""" + data = { + "salon_name": "Test", + "is_fixture": True, + "capabilities": [{ + "provider": "whatsapp", + "status": "connected", + # Missing "area" + }], + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Capability entry 0 missing required field 'area'"): + load_capability_fixture(path) + + +def test_load_fixture_missing_provider_field(tmp_path: pathlib.Path): + """Missing 'provider' field raises ValueError with clear message.""" + data = { + "salon_name": "Test", + "is_fixture": True, + "capabilities": [{ + "area": "channels", + "status": "connected", + # Missing "provider" + }], + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Capability entry 0 missing required field 'provider'"): + load_capability_fixture(path) + + +def test_load_fixture_missing_status_field(tmp_path: pathlib.Path): + """Missing 'status' field raises ValueError with clear message.""" + data = { + "salon_name": "Test", + "is_fixture": True, + "capabilities": [{ + "area": "channels", + "provider": "whatsapp", + # Missing "status" + }], + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Capability entry 0 missing required field 'status'"): + load_capability_fixture(path) + + +def test_load_fixture_missing_field_reports_index(tmp_path: pathlib.Path): + """Missing field error message includes the entry index.""" + data = { + "salon_name": "Test", + "is_fixture": True, + "capabilities": [ + { + "area": "channels", + "provider": "whatsapp", + "status": "connected", + }, + { + "area": "books", + # Missing "provider" and "status" in entry 1 + }, + ], + } + path = _make_fixture_file(tmp_path, data) + with pytest.raises(ValueError, match="Capability entry 1 missing required field 'provider'"): + load_capability_fixture(path) + + +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_capability_fixture(p) + + +def test_load_fixture_default_salon_name(tmp_path: pathlib.Path): + """Missing salon_name defaults to 'Unknown Salon'.""" + data = { + "is_fixture": True, + "capabilities": [], + } + path = _make_fixture_file(tmp_path, data) + report = load_capability_fixture(path) + assert report.salon_name == "Unknown Salon" + + +def test_load_fixture_details(): + """Details field is loaded from fixture.""" + report = load_capability_fixture(_FIXTURE_PATH) + details = {(c.area, c.provider): c.details for c in report.capabilities} + assert "fixture" in details[("scheduling", "vagaro")].lower() or "not yet" in details[("scheduling", "vagaro")].lower() + + +# ── Fixture labeling: offline/fixture never silent as live ───────────────── + +def test_fixture_never_silent_as_live(): + """Fixture reports must have is_fixture=True — never silent as live.""" + report = load_capability_fixture(_FIXTURE_PATH) + assert report.is_fixture is True, "Fixture report must always be labeled as fixture" + + report2 = load_capability_fixture(_ALL_CONNECTED_PATH) + assert report2.is_fixture is True + + report3 = load_capability_fixture(_ERRORS_PATH) + assert report3.is_fixture is True + + +def test_fixture_explicit_false_still_respected(): + """If fixture explicitly sets is_fixture=false, it is loaded as-is.""" + with tempfile.TemporaryDirectory() as tmp: + tmp_path = pathlib.Path(tmp) + data = { + "salon_name": "Test", + "is_fixture": False, + "capabilities": [{ + "area": "channels", + "provider": "whatsapp", + "status": "connected", + }], + } + path = _make_fixture_file(tmp_path, data) + report = load_capability_fixture(path) + # The loader respects the explicit value. + assert report.is_fixture is False + + +# ── load_fixture_metadata ───────────────────────────────────────────────── + +def test_load_metadata(): + meta = load_fixture_metadata(_FIXTURE_PATH) + assert meta["salon_name"] == "Lumina Hair Studio & Spa" + assert meta["is_fixture"] is True + assert "generated_at" in meta + assert "note" in meta + + +def test_load_metadata_missing_file(tmp_path: pathlib.Path): + with pytest.raises(FileNotFoundError): + load_fixture_metadata(tmp_path / "nonexistent.json")