From d5cbbf49ee64119c82a6fafa3705e98c6909a89d Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Sun, 7 Jun 2026 04:50:38 +0200 Subject: [PATCH 1/4] [50e80c55] Prompter interaction design and confirmation flow spec (#74) (#75) * [50e80c55] docs(design): create Prompter interaction design directory and main spec * [50e80c55] docs(design): add confirmation interstitial spec with un-bypassable guardrails * [50e80c55] docs(design): add model-selector drawer UX with Recommended default and complexity mapping * [50e80c55] docs(design): add naming and navigation spec with sidebar placement and Task Assistant recommendation --------- Co-authored-by: UX/UI Developer 1 --- docs/ux_ui/design/01-interaction-spec.md | 182 ++++++++++++++++ .../design/02-confirmation-interstitial.md | 206 ++++++++++++++++++ docs/ux_ui/design/03-model-selector-ux.md | 141 ++++++++++++ docs/ux_ui/design/04-naming-and-navigation.md | 153 +++++++++++++ docs/ux_ui/design/README.md | 42 ++++ 5 files changed, 724 insertions(+) create mode 100644 docs/ux_ui/design/01-interaction-spec.md create mode 100644 docs/ux_ui/design/02-confirmation-interstitial.md create mode 100644 docs/ux_ui/design/03-model-selector-ux.md create mode 100644 docs/ux_ui/design/04-naming-and-navigation.md create mode 100644 docs/ux_ui/design/README.md diff --git a/docs/ux_ui/design/01-interaction-spec.md b/docs/ux_ui/design/01-interaction-spec.md new file mode 100644 index 00000000..5268f24e --- /dev/null +++ b/docs/ux_ui/design/01-interaction-spec.md @@ -0,0 +1,182 @@ +# Prompter — Interaction Specification + +## Overview + +The Prompter is a first-class Panel page that lets users author tasks via conversational LLM assistance rather than hand-writing specs. The journey is intentionally linear and un-bypassable: + +``` +Chat → Draft → Review → Confirm → Launch → Success +``` + +Each transition is explicit. There is no hidden auto-launch; the human is always the final gate before a task enters the system. + +--- + +## Screen States + +### 1. Chat Screen (Default) + +**Purpose**: The user describes what they need in natural language. The LLM responds with clarifying questions and, when enough context is gathered, offers to generate a draft. + +**Layout** (mapped to existing components): +- **Container**: Full page inside the dashboard layout (`(dashboard)/layout.tsx`) +- **Header row**: + - Page title (H1): see [`04-naming-and-navigation.md`](04-naming-and-navigation.md) + - Subtitle: "Describe what you need. The assistant will ask questions and draft a task for your team." +- **Chat area** (`Card` + custom flex column): + - **Message list** (`ScrollArea`): user messages right-aligned, assistant messages left-aligned. + - **Message bubbles**: `Card` with `py-3 px-4` and subtle background differentiation: + - User: `bg-primary/10` + - Assistant: `bg-muted` + - **Typing indicator**: `Skeleton` pulse (3 lines) when assistant is generating. +- **Composer bar** (fixed to bottom of chat area): + - `Textarea` (auto-resize, max 4 lines) with placeholder: "Describe the task you want to create..." + - `Button` (primary, icon `Send`) labeled "Send" + - Keyboard: `Enter` sends; `Shift+Enter` adds newline. +- **Empty state** (first visit): + - Centered `Card` with illustration placeholder + suggested prompts: + - "Add a dark-mode toggle to the panel" + - "Write a design spec for a confirmation flow" + - "Create a backend task to add OAuth2 login" + +**Component references**: +- `panel/src/components/ui/card.tsx` — message bubbles +- `panel/src/components/ui/textarea.tsx` — composer input +- `panel/src/components/ui/button.tsx` — send button +- `panel/src/components/ui/scroll-area.tsx` — scrollable message list +- `panel/src/components/ui/skeleton.tsx` — typing indicator + +**Accessibility**: +- `aria-live="polite"` on the message list so screen readers announce new assistant messages. +- Composer `Textarea` has `aria-label="Task description"`. + +**Error states**: +- LLM error: assistant message styled as `Alert` variant `destructive` with text: "Something went wrong. Try rephrasing or try again later." +- Network error: toast via `sonner` (already in panel globals). + +--- + +### 2. Draft Preview (Inline Transition) + +**Purpose**: Once the LLM has enough context, it generates a structured task draft. The draft is presented inline in the chat as a special "proposal" message, not a separate page. This preserves conversational context. + +**Trigger**: Assistant message ends with: *"I can draft a task based on what we discussed. Would you like to review it?"* + two quick actions. + +**Layout**: +- **Proposal card** (full-width, `Card` with `border-primary`): + - Header: `CardHeader` with `CardTitle` "Draft Task" and `Badge` showing suggested team. + - Body (`CardContent`): + - **Title** (bold, `text-lg`) + - **Description** (truncated to 4 lines with fade-out; `Button` "Expand" to show full text in a `Dialog`) + - **Acceptance criteria** (`ScrollArea`, max height 160px): + - Numbered list using `Badge` variant `outline` for each item number. + - **Metadata row** (flex, gap-4, `text-sm text-muted-foreground`): + - Team: `Badge` + - Complexity: `Badge` variant `secondary` + - Nature: `Badge` variant `secondary` + - Footer (`CardFooter`, justify-between): + - `Button` variant `outline`: "Keep Chatting" (returns to free chat) + - `Button` (primary): "Review & Confirm" (advances to confirmation interstitial) + +**Component references**: +- `panel/src/components/ui/card.tsx` — proposal card +- `panel/src/components/ui/badge.tsx` — team, complexity, nature labels +- `panel/src/components/ui/dialog.tsx` — expand description +- `panel/src/components/ui/scroll-area.tsx` — criteria list + +**Accessibility**: +- Proposal card is focusable (`tabIndex={0}`) and announces via `aria-live`. +- "Review & Confirm" button has `aria-describedby` pointing to a hidden span summarizing the draft title. + +--- + +### 3. Confirmation Interstitial (Modal) + +**Purpose**: The un-bypassable human gate. The user sees the full draft, can edit fields inline, and must explicitly confirm before the task is created. + +**Behavior**: Opens as a **full-size Dialog** (`DialogContent` with `sm:max-w-3xl lg:max-w-4xl max-h-[90vh]`) so the user cannot miss it. This is not a sidebar or inline form; it interrupts the flow by design. + +**Detailed spec**: see [`02-confirmation-interstitial.md`](02-confirmation-interstitial.md). + +--- + +### 4. Launch / Success + +**Purpose**: Provide clear feedback that the task has entered the system and give the user a next step. + +**Layout** (inline in chat, replacing the proposal card): +- **Success card** (`Card` with `border-green-600` or `border-success` token if available): + - Header: `CardHeader` with `CardTitle` "Task launched" + `Badge` "Pending" + - Body: one-sentence summary: "Your task '*{title}*' has been created and routed to the **{team}** cell." + - Footer (`CardFooter`, gap-2): + - `Button` variant `ghost` + `Link` to `/tasks/{taskId}`: "View Task →" + - `Button` variant `outline`: "Start Another" (resets chat to empty state) + +**Component references**: +- `panel/src/components/ui/card.tsx` +- `panel/src/components/ui/badge.tsx` +- `panel/src/components/ui/button.tsx` + +--- + +## State Machine + +```text +[Empty] --user types--> [Chatting] + +[Chatting] --assistant offers draft--> [DraftPreview] +[Chatting] --user keeps typing--> [Chatting] +[Chatting] --LLM error--> [Chatting] (error bubble appended) + +[DraftPreview] --"Keep Chatting"--> [Chatting] +[DraftPreview] --"Review & Confirm"--> [ReviewModal] + +[ReviewModal] --"Cancel"--> [DraftPreview] (modal closes, chat scrolls to proposal) +[ReviewModal] --user edits fields--> [ReviewModal] (dirty state) +[ReviewModal] --"Confirm & Launch"--> [Launching] (button loading) + +[Launching] --API success--> [Success] +[Launching] --API error--> [ReviewModal] (error banner + button enabled) + +[Success] --"Start Another"--> [Empty] +[Success] --"View Task"--> (navigate away) +``` + +--- + +## Loading Patterns + +| State | Visual | +|-------|--------| +| Assistant thinking | `Skeleton` 3-line pulse inside assistant bubble | +| Draft generating | `Skeleton` card (title + 4 lines + criteria placeholder) | +| Launching | Primary button shows spinner + "Launching..." text | +| Navigating to task | Page transition handled by Next.js; no extra UX needed | + +--- + +## Error Patterns + +| Scenario | UI | Copy | +|----------|-----|------| +| LLM stream fails mid-chat | Toast + inline retry button | " assistant had a hiccup. [Retry]" | +| Draft generation fails | Inline alert inside chat | "Couldn’t draft a task right now. Keep chatting or try again." | +| Confirm & Launch API fails | Banner inside review modal | "We couldn’t create the task. Check your connection and try again." | +| Validation error (e.g. title too short) | Field-level error text | Same rules as `CreateTaskDialog` | + +--- + +## Responsive Behavior + +- **Desktop (>= 1024px)**: Chat and composer are centered in a max-width 880px column inside the dashboard layout. +- **Tablet (768–1023px)**: Same, max-width 720px. +- **Mobile (< 768px)**: Composer bar becomes sticky at bottom of viewport; chat scrolls above it. Review modal becomes bottom sheet (`Sheet` component) instead of center Dialog if needed, but since this is a dashboard application primarily used on desktop, Dialog is acceptable for MVP. + +--- + +## Out of Scope (Phase 2) + +- Conversation history / "My Prompts" list +- Persistent drafts across sessions +- Advanced model comparison side-by-side +- Rich media uploads in chat diff --git a/docs/ux_ui/design/02-confirmation-interstitial.md b/docs/ux_ui/design/02-confirmation-interstitial.md new file mode 100644 index 00000000..c2abd773 --- /dev/null +++ b/docs/ux_ui/design/02-confirmation-interstitial.md @@ -0,0 +1,206 @@ +# Prompter — Confirmation Interstitial Specification + +## Principle + +> **You decide what gets sent to the team.** + +The confirmation interstitial is the un-bypassable human gate before any Prompter-generated task enters the RoboCo system. It must be impossible to skip by accident, by keyboard shortcut, or by API manipulation. The user must read, review, and explicitly confirm. + +--- + +## Pattern: Review & Confirm Dialog + +### Component + +`Dialog` from `panel/src/components/ui/dialog.tsx` — **not** `AlertDialog`. We need the full content flexibility of `Dialog` (close button, scrollable body, custom footer) rather than the simplified action/cancel binary of `AlertDialog`. + +**Size**: `DialogContent` with classes `sm:max-w-3xl lg:max-w-4xl max-h-[90vh] overflow-y-auto`. + +This is the same large-dialog pattern used by `CreateTaskDialog` in `panel/src/components/tasks/create-task-dialog.tsx`. + +--- + +## Layout + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Review & Confirm Task [×] │ +│ You decide what gets sent to the team. │ +├──────────────────────────────────────────────────────────────┤ +│ │ +│ Title * │ +│ [________________________________________] │ +│ │ +│ Description * │ +│ [ ] │ +│ [ Markdown editor with preview toggle ] │ +│ [ ] │ +│ │ +│ Acceptance Criteria * │ +│ ┌────────────────────────────────────────┐ │ +│ │ 1. [Criterion text………] [×] │ │ +│ │ 2. [Criterion text………] [×] │ │ +│ │ 3. [Criterion text………] [×] │ │ +│ │ [+ Add Criterion] │ │ +│ └────────────────────────────────────────┘ │ +│ │ +│ ┌──────────┬──────────┬──────────┬──────────┐ │ +│ │ Team │ Status │ Priority │ Complex. │ │ +│ │ [Select] │ [Select] │ [Select] │ [Select] │ │ +│ └──────────┴──────────┴──────────┴──────────┘ │ +│ │ +│ ┌─ Advanced Options ──────────────────────┐ │ +│ │ Model selector (see model-selector-ux.md) │ │ +│ │ Assign to, Parent task, Project, Product │ │ +│ └──────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ ⚠️ This will create a real task and notify the team. │ │ +│ │ It cannot be undone from this screen. │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +├──────────────────────────────────────────────────────────────┤ +│ [Cancel] [Confirm & Launch]│ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Sections (Detailed) + +### 1. Dialog Header + +- **Title**: `DialogTitle` — "Review & Confirm Task" +- **Description**: `DialogDescription` — "You decide what gets sent to the team." + +Both are required and always visible. The description is the human-agency anchor copy mandated by the Head of Marketing. + +### 2. Title Field + +- `Label` + `Input` +- Required (`*` indicator, `text-destructive` color) +- Validation: 5–200 characters (same rule as `CreateTaskDialog`) +- Error state: `border-destructive` + `text-xs text-destructive` message +- Pre-filled by LLM draft; user can edit inline. + +### 3. Description Field + +- Reuse `MarkdownEditor` from `panel/src/components/tasks/markdown-editor.tsx` +- Required, min 20 characters +- Preview toggle (Edit / Preview tabs using `Tabs` component) +- Pre-filled by LLM draft; user can edit inline. + +### 4. Acceptance Criteria + +- Reuse `AcceptanceCriteriaEditor` from `panel/src/components/tasks/acceptance-criteria-editor.tsx` +- Required, at least one criterion +- Numbered list with drag handles (if the existing editor supports reordering) or simple add/remove +- Pre-filled by LLM draft; user can add, edit, remove. + +### 5. Metadata Grid + +A 4-column grid (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`) using `Select` components: + +| Field | Component | Options | Default (from draft) | +|-------|-----------|---------|----------------------| +| Team | `Select` | `Object.values(Team)` | LLM suggestion | +| Status | `Select` | `PENDING`, `BACKLOG` | `PENDING` | +| Priority | `Select` | `P0`–`P3` | `P2` (Medium) | +| Complexity | `Select` | `LOW`, `MEDIUM`, `HIGH` | LLM suggestion | + +All wrapped in `Label` + `SelectTrigger` + `SelectContent` + `SelectItem`. + +### 6. Advanced Options Drawer + +- `Collapsible` from `panel/src/components/ui/collapsible.tsx` +- Trigger: `Button` variant `ghost` with `ChevronRight` / `ChevronDown` icons +- Contents: + - **Model selector** — see [`03-model-selector-ux.md`](03-model-selector-ux.md) + - **Assign To** — `AgentSelector` (`panel/src/components/agents/agent-selector.tsx`) + - **Parent Task** — `TaskSelector` (`panel/src/components/tasks/task-selector.tsx`) + - **Project** — `ProjectSelector` (`panel/src/components/projects/project-selector.tsx`) + - **Product** — `Select` from `CreateTaskDialog` product list + +### 7. Warning Banner + +A full-width `Alert` (if available; otherwise a `Card` with `border-destructive` or `bg-destructive/10`): + +- Icon: `AlertTriangle` from `lucide-react` +- Text: "This will create a real task and notify the team. It cannot be undone from this screen." +- Purpose: prevents the "I thought this was just a preview" error. + +### 8. Footer Actions + +- `DialogFooter` with `flex-col-reverse sm:flex-row sm:justify-end gap-2` +- **Cancel** (`Button` variant `outline`): closes dialog, returns to chat draft preview. Does **not** discard the draft. +- **Confirm & Launch** (`Button` primary): submits to the task-creation API. + - On click: button enters `disabled` state, text changes to "Launching…", spinner (use `Loader2` icon with `animate-spin`) + - On success: dialog closes, chat shows success card + - On error: button re-enables, error banner appears above footer + +--- + +## Un-bypassable Guardrails + +### UI Guardrails + +1. **No keyboard shortcut** launches the task. `Enter` inside any field does **not** submit the form; only the explicit footer button does. +2. **No click-outside dismissal** when dirty. If the user has edited any field, clicking the overlay shows a secondary confirmation: "You have unsaved changes. Discard them?" (`AlertDialog` with "Keep Editing" / "Discard"). +3. **Scroll requirement**: The dialog is tall enough that the footer may be below the fold on small screens. The warning banner is positioned **above** the footer so the user must scroll past it to reach the confirm button. + +### API Guardrails (Frontend Contract) + +- The frontend must **not** call the task-creation endpoint directly from the chat state. The only valid call path is: + ``` + Chat → Review Modal (user opens) → Confirm Button (user clicks) → POST /tasks + ``` +- There is no `?skip_review=true` query param, no hidden route, and no keyboard bypass. +- Backend should reject any Prompter-originated task creation that does not include a `confirmed_by_human: true` flag in the payload (enforced by the Backend Cell; noted here for cross-cell alignment). + +--- + +## Accessibility + +- Focus trap: when dialog opens, focus moves to the Title `Input`. +- `aria-describedby` on the Confirm button pointing to the warning banner text. +- All `Select` triggers have visible `Label` associations (`htmlFor` + `id`). +- Error messages use `aria-live="assertive"` so screen readers announce validation failures immediately. + +--- + +## Copy Reference + +| Element | Copy | +|---------|------| +| Dialog title | "Review & Confirm Task" | +| Dialog subtitle | "You decide what gets sent to the team." | +| Warning banner | "This will create a real task and notify the team. It cannot be undone from this screen." | +| Cancel button | "Cancel" | +| Confirm button (idle) | "Confirm & Launch" | +| Confirm button (loading) | "Launching…" | +| Dirty-state discard prompt title | "Discard changes?" | +| Dirty-state discard prompt body | "You have unsaved changes. If you cancel, your edits will be lost." | +| Dirty-state keep button | "Keep Editing" | +| Dirty-state discard button | "Discard" | + +--- + +## Component Inventory + +| UI Element | File Path | +|------------|-----------| +| Dialog shell | `panel/src/components/ui/dialog.tsx` | +| Alert (warning banner) | `panel/src/components/ui/alert-dialog.tsx` or custom `Card` | +| Card | `panel/src/components/ui/card.tsx` | +| Input | `panel/src/components/ui/input.tsx` | +| Textarea / MarkdownEditor | `panel/src/components/tasks/markdown-editor.tsx` | +| Select | `panel/src/components/ui/select.tsx` | +| Label | `panel/src/components/ui/label.tsx` | +| Button | `panel/src/components/ui/button.tsx` | +| Collapsible | `panel/src/components/ui/collapsible.tsx` | +| Badge | `panel/src/components/ui/badge.tsx` | +| Tabs | `panel/src/components/ui/tabs.tsx` | +| AcceptanceCriteriaEditor | `panel/src/components/tasks/acceptance-criteria-editor.tsx` | +| AgentSelector | `panel/src/components/agents/agent-selector.tsx` | +| TaskSelector | `panel/src/components/tasks/task-selector.tsx` | +| ProjectSelector | `panel/src/components/projects/project-selector.tsx` | diff --git a/docs/ux_ui/design/03-model-selector-ux.md b/docs/ux_ui/design/03-model-selector-ux.md new file mode 100644 index 00000000..dd3d74ff --- /dev/null +++ b/docs/ux_ui/design/03-model-selector-ux.md @@ -0,0 +1,141 @@ +# Prompter — Model Selector UX Specification + +## Principle + +Most users do not know which model drafts better tasks. The model selector should be available for power users but invisible for everyone else. The default must be safe, fast, and context-aware. + +--- + +## Placement + +**Inside the Advanced Options drawer** of the confirmation interstitial. + +- `Collapsible` trigger label: "Advanced Options" +- When expanded, the first item in the drawer is the model selector. + +This follows the existing `CreateTaskDialog` pattern where advanced fields (parent task, assignee, git config) are tucked behind a `Collapsible` with `ChevronRight` / `ChevronDown` icons. + +Rationale: +- Reduces cognitive load on the primary review screen. +- Prevents choice paralysis for users who don’t care about the model. +- Aligns with the Head of Marketing directive: "most users don’t know which model drafts better tasks." + +--- + +## Component + +`Select` from `panel/src/components/ui/select.tsx`: + +```tsx + +``` + +--- + +## Defaults + +### Default Selection: "Recommended" + +The `recommended` value is not a real model ID; it is a frontend alias that resolves to a model based on the draft's estimated complexity: + +| Draft Complexity | Resolved Model | Rationale | +|----------------|----------------|-----------| +| `LOW` | `gpt-4o` or lightweight equivalent | Fast, cheap, good enough for simple tasks | +| `MEDIUM` | `claude-sonnet-4` | Balanced quality and speed | +| `HIGH` | `claude-opus-4` | Deep reasoning for complex specs | + +**Dynamic label**: the Select trigger should display the resolved model name in the description: + +> ⭐ Recommended — Claude Sonnet 4 + +This gives transparency without requiring the user to make a manual choice. + +### How Complexity Is Determined + +1. **LLM suggestion**: the drafting LLM outputs an `estimated_complexity` field as part of the structured draft. +2. **User override**: if the user changes the Complexity `Select` in the review modal, the recommended model re-evaluates automatically. +3. **No user model preference persistence in Phase 1** — each session starts fresh with `recommended`. + +--- + +## Option Descriptions + +Each `SelectItem` should have a one-line subtitle explaining when to choose it: + +``` +⭐ Recommended — Claude Sonnet 4 + (Best balance for this task's complexity) + +Claude Sonnet 4 — Balanced + (Good for most tasks) + +GPT-4o — Fast + (Quick drafts, simpler specs) + +Claude Opus 4 — Deep reasoning + (Complex architecture or security tasks) +``` + +Implementation note: if `SelectItem` does not natively support subtitles, append the subtitle as muted text inside the item using a nested `span` with `text-muted-foreground text-xs`. + +--- + +## Visual Hierarchy + +``` +┌─ Advanced Options ──────────────────────┐ +│ │ +│ Model │ +│ [⭐ Recommended — Claude Sonnet 4 ▼] │ +│ ├─ ⭐ Recommended — Claude Sonnet 4 │ +│ ├─ Claude Sonnet 4 — Balanced │ +│ ├─ GPT-4o — Fast │ +│ └─ Claude Opus 4 — Deep reasoning │ +│ │ +│ Assign To … │ +│ Parent Task … │ +│ … │ +└─────────────────────────────────────────┘ +``` + +--- + +## Accessibility + +- `Label` with `htmlFor` tied to the `SelectTrigger` id. +- `aria-describedby` on the trigger pointing to a helper paragraph: "The model used to draft this task. 'Recommended' picks the best fit automatically." +- `SelectContent` should trap focus while open; `Esc` closes the dropdown. + +--- + +## Out of Scope (Phase 2) + +- Model comparison side-by-side +- User-level default model preference +- Cost/usage indicators per model +- Temperature / max-tokens sliders +- Custom system prompt editing + +--- + +## Cross-Cell Note + +The frontend sends the resolved model ID (not the alias) to the backend chat endpoint. The backend endpoint in `roboco/services/llm.py` already handles multi-provider routing; the frontend only needs to pass the model string in the payload. diff --git a/docs/ux_ui/design/04-naming-and-navigation.md b/docs/ux_ui/design/04-naming-and-navigation.md new file mode 100644 index 00000000..1ff727c1 --- /dev/null +++ b/docs/ux_ui/design/04-naming-and-navigation.md @@ -0,0 +1,153 @@ +# Prompter — Naming & Navigation Specification + +## Principle + +The existing Panel uses plain, descriptive nouns for navigation: +- "Overview", "Tasks", "Kanban" +- "Projects", "Products", "Git" +- "Agents", "Knowledge Base", "Auditor" + +A branded product name like "Prompter" risks feeling like a third-party plugin. The name should fit the existing vocabulary and signal value immediately. + +--- + +## Naming Options + +### Option A: Task Assistant *(Recommended)* + +- **Label**: "Task Assistant" +- **Rationale**: + - Plain noun + descriptor pattern (matches "Knowledge Base", "AI Providers") + - Immediately communicates value: it helps you with tasks. + - Does not over-promise autonomy — "assistant" implies human control. + - Works in sentence case naturally: "Open the Task Assistant." +- **Subtitle copy**: "Draft tasks with your AI teammate." +- **Concerns**: Slightly longer than other nav items; may truncate in collapsed sidebar. +- **Mitigation**: Collapsed sidebar uses icon + tooltip; length is fine in expanded view. + +### Option B: Draft + +- **Label**: "Draft" + +- **Rationale**: + - Single word, action-oriented, fits the existing terse style. + - Signals the core output: a draft task. + - Human-centric verb — you draft, the AI helps. +- **Subtitle copy**: "Draft tasks with your AI teammate." +- **Concerns**: + - Ambiguous: could be confused with "draft tasks" as a filter state in the Tasks page. + - Less discoverable for users who don’t already know the feature exists. + +### Option C: Composer + +- **Label**: "Composer" + +- **Rationale**: + - Evokes creation and authoring. + - Familiar to developers (IDE composers, email composers). +- **Subtitle copy**: "Compose tasks with your AI teammate." +- **Concerns**: + - Slightly more abstract than "Task Assistant". + - May imply musical or creative composition rather than structured task specs. + +### Internal Name + +"Prompter" remains the **internal engineering and marketing codename**. It is acceptable in internal documentation, Slack, and code comments. The UI label is the user-facing name chosen above. + +--- + +## Sidebar Placement + +### Recommended Placement + +Insert under the **Work Management** section, between "Kanban" and "Projects": + +```typescript +const navItems = [ + // Dashboard + { title: "Overview", href: "/overview", icon: LayoutDashboard }, + + // Work Management + { title: "Tasks", href: "/tasks", icon: ListTodo }, + { title: "Kanban", href: "/kanban", icon: Kanban }, + { title: "Task Assistant", href: "/prompter", icon: Sparkles }, // NEW + + // Development + { title: "Projects", href: "/projects", icon: FolderGit2 }, + { title: "Products", href: "/products", icon: Boxes }, + { title: "Git", href: "/git", icon: GitBranch }, + + // ... rest unchanged +]; +``` + +### Icon + +Use `Sparkles` from `lucide-react` (not currently imported in `sidebar.tsx`). + +- Rationale: universally understood as "AI / magic / assistance" without being overly literal. +- Alternative: `MessageSquarePlus` — more literal (chat + create), but `Sparkles` is more distinctive among existing icons. + +### Active State + +Same as existing nav items: +- Active: `bg-primary text-primary-foreground` +- Inactive: `text-muted-foreground hover:bg-muted hover:text-foreground` + +--- + +## Page Title & Meta + +| Surface | Copy (Option A) | +|---------|-----------------| +| Sidebar nav item | "Task Assistant" | +| Browser tab title | "Task Assistant — RoboCo Panel" | +| Page H1 | "Task Assistant" | +| Page subtitle | "Describe what you need. The assistant will ask questions and draft a task for your team." | +| Empty-state heading | "What do you want to build?" | +| Empty-state subtext | "Describe the task in plain language. The assistant will clarify and draft a spec you can review before sending it to the team." | + +--- + +## URL + +`/prompter` — keep the engineering slug regardless of display name. This avoids routing churn if the display name changes later. + +- Redirects: none needed for MVP. +- Deep-linking: `/prompter` always loads the empty/chat state; there is no persisted session ID in the URL for Phase 1. + +--- + +## Discoverability + +### Primary +- Sidebar entry at all times (not hidden behind permissions or feature flags for Phase 1). + +### Secondary +- Quick Actions bar on Overview dashboard: add a "New Task (AI-assisted)" button that links to `/prompter`. + - Uses existing `QuickActionsBar` pattern in `panel/src/components/dashboard/quick-actions-bar.tsx`. + - Icon: `Sparkles` next to the existing "New Task" button. + +--- + +## Decision Matrix + +| Criterion | Task Assistant | Draft | Composer | +|-----------|---------------|-------|----------| +| Fits existing panel vocabulary | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | +| Communicates value immediately | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | +| Does not over-promise autonomy | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | +| Short enough for sidebar | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | +| Distinct from other pages | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | +| Works in marketing copy | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | +| **Total** | **16** | **13** | **12** | + +**UX/UI Cell recommendation**: **Task Assistant** (Option A). + +--- + +## Cross-Cell Handoff + +- **Frontend**: implement route `/prompter`, sidebar entry with `Sparkles`, page layout. +- **Backend**: no API changes needed for naming; slug remains `prompter` in code. +- **Marketing**: "Draft tasks with your AI teammate" is the recommended tagline; aligns with "Task Assistant" label. diff --git a/docs/ux_ui/design/README.md b/docs/ux_ui/design/README.md new file mode 100644 index 00000000..f79180c7 --- /dev/null +++ b/docs/ux_ui/design/README.md @@ -0,0 +1,42 @@ +# Prompter — UX/UI Design Deliverables + +This directory contains the interaction design and confirmation flow specification for the **Prompter** feature (Phase 1). All patterns are mapped to the existing Panel design system so the Frontend Cell can implement them in parallel without inventing new visual language. + +## Contents + +| Document | Purpose | +|----------|---------| +| [`01-interaction-spec.md`](01-interaction-spec.md) | End-to-end chat → draft → review → confirm → launch flow, state machine, component mappings, error/loading patterns | +| [`02-confirmation-interstitial.md`](02-confirmation-interstitial.md) | Mandatory human-in-the-loop review modal: layout, copy, actions, un-bypassable guardrails | +| [`03-model-selector-ux.md`](03-model-selector-ux.md) | Model selector placement, defaults, and cognitive-load reduction | +| [`04-naming-and-navigation.md`](04-naming-and-navigation.md) | Naming alternatives to "Prompter" and sidebar nav placement | + +## Design System Baseline + +All screens are built from components already present in `panel/src/components/ui/` and `panel/src/components/layout/`: + +- **Dialog** — `panel/src/components/ui/dialog.tsx` (Radix-based, animates in/out) +- **AlertDialog** — `panel/src/components/ui/alert-dialog.tsx` (for destructive/breaking confirmations) +- **Card** — `panel/src/components/ui/card.tsx` (sections, draft preview) +- **Tabs** — `panel/src/components/ui/tabs.tsx` (chat vs. draft review) +- **Select** — `panel/src/components/ui/select.tsx` (team, model, status) +- **Collapsible** — `panel/src/components/ui/collapsible.tsx` (advanced options drawer) +- **Button** — `panel/src/components/ui/button.tsx` (primary, outline, ghost, destructive) +- **Input / Textarea** — `panel/src/components/ui/input.tsx`, `panel/src/components/ui/textarea.tsx` +- **Badge** — `panel/src/components/ui/badge.tsx` (team labels, complexity indicators) +- **ScrollArea** — `panel/src/components/ui/scroll-area.tsx` (chat history, criteria list) +- **Skeleton** — `panel/src/components/ui/skeleton.tsx` (loading states) +- **Sidebar** — `panel/src/components/layout/sidebar.tsx` (navigation structure) + +> **Rule**: No new visual language. Reuse existing tokens, spacing, and color variables (`bg-background`, `text-muted-foreground`, `border`, `shadow-sm`, etc.). + +## Accessibility Baseline + +- Focus trap inside dialogs on open (`focus-visible:ring-ring`) +- `aria-live="polite"` on chat message list for screen-reader announcements +- Keyboard: `Enter` to send, `Esc` to close modals, `Tab` cycles focus +- All icon-only buttons need `sr-only` text labels + +## Version + +Phase 1 — chat + draft-review + create/launch (no persistence/history). From 85ffec86b4ada5031abfb409cbb265fdc3eb1dee Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:16:56 +0200 Subject: [PATCH 2/4] [529f579a] Implement Prompter chat endpoint and structured task drafting (#76) (#77) * [529f579a] feat(prompter): add PrompterService with chat and draft generation endpoints * [529f579a] feat(prompter): add PrompterService, schemas, routes, and integration tests * [529f579a] feat(prompter): implement session-based prompter chat endpoints with DB persistence Add full session-based Prompter chat system with: - Alembic migration 024 creating prompter_sessions, prompter_messages, and task_drafts tables with proper foreign keys, indexes, and enum columns - Three new SQLAlchemy ORM table classes in roboco/db/tables.py - Pydantic schemas: PrompterSessionCreateRequest, PrompterMessageRequest, PrompterSessionResponse, PrompterMessageResponse, TaskDraftResponse, TaskConfirmRequest - Four new session-based FastAPI routes: POST /sessions, POST /sessions/{id}/messages, GET /sessions/{id}/draft, POST /sessions/{id}/confirm - PrompterService with DB-backed session, message, and draft persistence; LLM-driven draft generation; ConfirmOverrides dataclass to stay under PLR0913 - Legacy stateless /chat and /draft endpoints retained for backward compatibility - Unit tests for schemas (test_schemas_prompter.py), service pure functions and DB logic (test_prompter.py) with mocked LLM calls - Integration tests for full happy path and legacy endpoints (test_prompter_routes.py) - All ruff format, ruff check, mypy (changed files), and pytest checks passing * [529f579a] fix(prompter): correct test assertion for confirmed_at field nesting The test test_get_draft_generates_from_conversation incorrectly accessed body['draft']['confirmed_at'] but confirmed_at is a field on the outer TaskDraftResponse, not on the nested PrompterDraftTask. Fixed to body['confirmed_at']. --------- Co-authored-by: Backend Developer 2 --- .../023_add_prompter_tracking_columns.py | 37 + alembic/versions/024_add_prompter_tables.py | 145 ++++ docs/ux_ui/design/01-interaction-spec.md | 182 ---- .../design/02-confirmation-interstitial.md | 206 ----- docs/ux_ui/design/03-model-selector-ux.md | 141 ---- docs/ux_ui/design/04-naming-and-navigation.md | 153 ---- docs/ux_ui/design/README.md | 42 - roboco/api/app.py | 8 + roboco/api/routes/prompter.py | 287 +++++++ roboco/api/routes/tasks.py | 11 + roboco/api/schemas/prompter.py | 232 +++++ roboco/api/schemas/tasks.py | 6 + roboco/db/tables.py | 138 +++ roboco/models/task.py | 18 + roboco/services/prompter.py | 625 ++++++++++++++ roboco/services/task.py | 3 + tests/integration/test_prompter_routes.py | 790 ++++++++++++++++++ tests/unit/api/test_schemas_prompter.py | 212 +++++ tests/unit/services/test_prompter.py | 354 ++++++++ 19 files changed, 2866 insertions(+), 724 deletions(-) create mode 100644 alembic/versions/023_add_prompter_tracking_columns.py create mode 100644 alembic/versions/024_add_prompter_tables.py delete mode 100644 docs/ux_ui/design/01-interaction-spec.md delete mode 100644 docs/ux_ui/design/02-confirmation-interstitial.md delete mode 100644 docs/ux_ui/design/03-model-selector-ux.md delete mode 100644 docs/ux_ui/design/04-naming-and-navigation.md delete mode 100644 docs/ux_ui/design/README.md create mode 100644 roboco/api/routes/prompter.py create mode 100644 roboco/api/schemas/prompter.py create mode 100644 roboco/services/prompter.py create mode 100644 tests/integration/test_prompter_routes.py create mode 100644 tests/unit/api/test_schemas_prompter.py create mode 100644 tests/unit/services/test_prompter.py diff --git a/alembic/versions/023_add_prompter_tracking_columns.py b/alembic/versions/023_add_prompter_tracking_columns.py new file mode 100644 index 00000000..7f1ffee9 --- /dev/null +++ b/alembic/versions/023_add_prompter_tracking_columns.py @@ -0,0 +1,37 @@ +"""Add prompter origin tracking columns to tasks table. + +Adds `source` (varchar 50, default 'manual') and `confirmed_by_human` +(boolean, default false) to support the Prompter conversational assistant +feature. Prompter-originated tasks require human confirmation before entering +the workflow. + +Revision ID: 023_add_prompter_tracking_columns +Revises: 022_default_branch_master +Create Date: 2026-06-07 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "023_add_prompter_tracking_columns" +down_revision = "022_default_branch_master" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("source", sa.String(length=50), server_default="manual", nullable=False), + ) + op.add_column( + "tasks", + sa.Column("confirmed_by_human", sa.Boolean(), server_default=sa.false(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "confirmed_by_human") + op.drop_column("tasks", "source") diff --git a/alembic/versions/024_add_prompter_tables.py b/alembic/versions/024_add_prompter_tables.py new file mode 100644 index 00000000..3e9df406 --- /dev/null +++ b/alembic/versions/024_add_prompter_tables.py @@ -0,0 +1,145 @@ +"""Add prompter_sessions, prompter_messages, and task_drafts tables. + +Adds three new tables to support the Prompter conversational assistant +feature with DB-persisted conversation history and task draft tracking: + +- prompter_sessions: links a conversation session to an authenticated agent +- prompter_messages: stores the full message history (user + assistant turns) +- task_drafts: stores structured task drafts extracted from conversations; + links to a real Task once the human confirms via /confirm endpoint + +Revision ID: 024_add_prompter_tables +Revises: 023_add_prompter_tracking_columns +Create Date: 2026-06-07 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import JSONB, UUID + +revision = "024_add_prompter_tables" +down_revision = "023_add_prompter_tracking_columns" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- prompter_sessions ----------------------------------------------- + op.create_table( + "prompter_sessions", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "agent_id", + UUID(as_uuid=True), + sa.ForeignKey("agents.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "status", + sa.Enum( + "active", + "draft_ready", + "confirmed", + "abandoned", + name="promptersessionstatus", + ), + nullable=False, + server_default="active", + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=True, + onupdate=sa.func.now(), + ), + ) + op.create_index("ix_prompter_sessions_agent_id", "prompter_sessions", ["agent_id"]) + op.create_index( + "ix_prompter_sessions_status", "prompter_sessions", ["status"] + ) + + # --- prompter_messages ----------------------------------------------- + op.create_table( + "prompter_messages", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "session_id", + UUID(as_uuid=True), + sa.ForeignKey("prompter_sessions.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "role", + sa.Enum("user", "assistant", "system", name="promptermessagerole"), + nullable=False, + ), + sa.Column("content", sa.Text, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + op.create_index( + "ix_prompter_messages_session_id", "prompter_messages", ["session_id"] + ) + op.create_index( + "ix_prompter_messages_session_created", + "prompter_messages", + ["session_id", "created_at"], + ) + + # --- task_drafts ----------------------------------------------------- + op.create_table( + "task_drafts", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column( + "session_id", + UUID(as_uuid=True), + sa.ForeignKey("prompter_sessions.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("draft_data", JSONB, nullable=False), + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "task_id", + UUID(as_uuid=True), + sa.ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=True, + onupdate=sa.func.now(), + ), + ) + op.create_index("ix_task_drafts_session_id", "task_drafts", ["session_id"]) + op.create_index("ix_task_drafts_task_id", "task_drafts", ["task_id"]) + + +def downgrade() -> None: + op.drop_table("task_drafts") + op.drop_index("ix_prompter_messages_session_created", "prompter_messages") + op.drop_index("ix_prompter_messages_session_id", "prompter_messages") + op.drop_table("prompter_messages") + op.drop_index("ix_prompter_sessions_status", "prompter_sessions") + op.drop_index("ix_prompter_sessions_agent_id", "prompter_sessions") + op.drop_table("prompter_sessions") + op.execute("DROP TYPE IF EXISTS promptersessionstatus") + op.execute("DROP TYPE IF EXISTS promptermessagerole") diff --git a/docs/ux_ui/design/01-interaction-spec.md b/docs/ux_ui/design/01-interaction-spec.md deleted file mode 100644 index 5268f24e..00000000 --- a/docs/ux_ui/design/01-interaction-spec.md +++ /dev/null @@ -1,182 +0,0 @@ -# Prompter — Interaction Specification - -## Overview - -The Prompter is a first-class Panel page that lets users author tasks via conversational LLM assistance rather than hand-writing specs. The journey is intentionally linear and un-bypassable: - -``` -Chat → Draft → Review → Confirm → Launch → Success -``` - -Each transition is explicit. There is no hidden auto-launch; the human is always the final gate before a task enters the system. - ---- - -## Screen States - -### 1. Chat Screen (Default) - -**Purpose**: The user describes what they need in natural language. The LLM responds with clarifying questions and, when enough context is gathered, offers to generate a draft. - -**Layout** (mapped to existing components): -- **Container**: Full page inside the dashboard layout (`(dashboard)/layout.tsx`) -- **Header row**: - - Page title (H1): see [`04-naming-and-navigation.md`](04-naming-and-navigation.md) - - Subtitle: "Describe what you need. The assistant will ask questions and draft a task for your team." -- **Chat area** (`Card` + custom flex column): - - **Message list** (`ScrollArea`): user messages right-aligned, assistant messages left-aligned. - - **Message bubbles**: `Card` with `py-3 px-4` and subtle background differentiation: - - User: `bg-primary/10` - - Assistant: `bg-muted` - - **Typing indicator**: `Skeleton` pulse (3 lines) when assistant is generating. -- **Composer bar** (fixed to bottom of chat area): - - `Textarea` (auto-resize, max 4 lines) with placeholder: "Describe the task you want to create..." - - `Button` (primary, icon `Send`) labeled "Send" - - Keyboard: `Enter` sends; `Shift+Enter` adds newline. -- **Empty state** (first visit): - - Centered `Card` with illustration placeholder + suggested prompts: - - "Add a dark-mode toggle to the panel" - - "Write a design spec for a confirmation flow" - - "Create a backend task to add OAuth2 login" - -**Component references**: -- `panel/src/components/ui/card.tsx` — message bubbles -- `panel/src/components/ui/textarea.tsx` — composer input -- `panel/src/components/ui/button.tsx` — send button -- `panel/src/components/ui/scroll-area.tsx` — scrollable message list -- `panel/src/components/ui/skeleton.tsx` — typing indicator - -**Accessibility**: -- `aria-live="polite"` on the message list so screen readers announce new assistant messages. -- Composer `Textarea` has `aria-label="Task description"`. - -**Error states**: -- LLM error: assistant message styled as `Alert` variant `destructive` with text: "Something went wrong. Try rephrasing or try again later." -- Network error: toast via `sonner` (already in panel globals). - ---- - -### 2. Draft Preview (Inline Transition) - -**Purpose**: Once the LLM has enough context, it generates a structured task draft. The draft is presented inline in the chat as a special "proposal" message, not a separate page. This preserves conversational context. - -**Trigger**: Assistant message ends with: *"I can draft a task based on what we discussed. Would you like to review it?"* + two quick actions. - -**Layout**: -- **Proposal card** (full-width, `Card` with `border-primary`): - - Header: `CardHeader` with `CardTitle` "Draft Task" and `Badge` showing suggested team. - - Body (`CardContent`): - - **Title** (bold, `text-lg`) - - **Description** (truncated to 4 lines with fade-out; `Button` "Expand" to show full text in a `Dialog`) - - **Acceptance criteria** (`ScrollArea`, max height 160px): - - Numbered list using `Badge` variant `outline` for each item number. - - **Metadata row** (flex, gap-4, `text-sm text-muted-foreground`): - - Team: `Badge` - - Complexity: `Badge` variant `secondary` - - Nature: `Badge` variant `secondary` - - Footer (`CardFooter`, justify-between): - - `Button` variant `outline`: "Keep Chatting" (returns to free chat) - - `Button` (primary): "Review & Confirm" (advances to confirmation interstitial) - -**Component references**: -- `panel/src/components/ui/card.tsx` — proposal card -- `panel/src/components/ui/badge.tsx` — team, complexity, nature labels -- `panel/src/components/ui/dialog.tsx` — expand description -- `panel/src/components/ui/scroll-area.tsx` — criteria list - -**Accessibility**: -- Proposal card is focusable (`tabIndex={0}`) and announces via `aria-live`. -- "Review & Confirm" button has `aria-describedby` pointing to a hidden span summarizing the draft title. - ---- - -### 3. Confirmation Interstitial (Modal) - -**Purpose**: The un-bypassable human gate. The user sees the full draft, can edit fields inline, and must explicitly confirm before the task is created. - -**Behavior**: Opens as a **full-size Dialog** (`DialogContent` with `sm:max-w-3xl lg:max-w-4xl max-h-[90vh]`) so the user cannot miss it. This is not a sidebar or inline form; it interrupts the flow by design. - -**Detailed spec**: see [`02-confirmation-interstitial.md`](02-confirmation-interstitial.md). - ---- - -### 4. Launch / Success - -**Purpose**: Provide clear feedback that the task has entered the system and give the user a next step. - -**Layout** (inline in chat, replacing the proposal card): -- **Success card** (`Card` with `border-green-600` or `border-success` token if available): - - Header: `CardHeader` with `CardTitle` "Task launched" + `Badge` "Pending" - - Body: one-sentence summary: "Your task '*{title}*' has been created and routed to the **{team}** cell." - - Footer (`CardFooter`, gap-2): - - `Button` variant `ghost` + `Link` to `/tasks/{taskId}`: "View Task →" - - `Button` variant `outline`: "Start Another" (resets chat to empty state) - -**Component references**: -- `panel/src/components/ui/card.tsx` -- `panel/src/components/ui/badge.tsx` -- `panel/src/components/ui/button.tsx` - ---- - -## State Machine - -```text -[Empty] --user types--> [Chatting] - -[Chatting] --assistant offers draft--> [DraftPreview] -[Chatting] --user keeps typing--> [Chatting] -[Chatting] --LLM error--> [Chatting] (error bubble appended) - -[DraftPreview] --"Keep Chatting"--> [Chatting] -[DraftPreview] --"Review & Confirm"--> [ReviewModal] - -[ReviewModal] --"Cancel"--> [DraftPreview] (modal closes, chat scrolls to proposal) -[ReviewModal] --user edits fields--> [ReviewModal] (dirty state) -[ReviewModal] --"Confirm & Launch"--> [Launching] (button loading) - -[Launching] --API success--> [Success] -[Launching] --API error--> [ReviewModal] (error banner + button enabled) - -[Success] --"Start Another"--> [Empty] -[Success] --"View Task"--> (navigate away) -``` - ---- - -## Loading Patterns - -| State | Visual | -|-------|--------| -| Assistant thinking | `Skeleton` 3-line pulse inside assistant bubble | -| Draft generating | `Skeleton` card (title + 4 lines + criteria placeholder) | -| Launching | Primary button shows spinner + "Launching..." text | -| Navigating to task | Page transition handled by Next.js; no extra UX needed | - ---- - -## Error Patterns - -| Scenario | UI | Copy | -|----------|-----|------| -| LLM stream fails mid-chat | Toast + inline retry button | " assistant had a hiccup. [Retry]" | -| Draft generation fails | Inline alert inside chat | "Couldn’t draft a task right now. Keep chatting or try again." | -| Confirm & Launch API fails | Banner inside review modal | "We couldn’t create the task. Check your connection and try again." | -| Validation error (e.g. title too short) | Field-level error text | Same rules as `CreateTaskDialog` | - ---- - -## Responsive Behavior - -- **Desktop (>= 1024px)**: Chat and composer are centered in a max-width 880px column inside the dashboard layout. -- **Tablet (768–1023px)**: Same, max-width 720px. -- **Mobile (< 768px)**: Composer bar becomes sticky at bottom of viewport; chat scrolls above it. Review modal becomes bottom sheet (`Sheet` component) instead of center Dialog if needed, but since this is a dashboard application primarily used on desktop, Dialog is acceptable for MVP. - ---- - -## Out of Scope (Phase 2) - -- Conversation history / "My Prompts" list -- Persistent drafts across sessions -- Advanced model comparison side-by-side -- Rich media uploads in chat diff --git a/docs/ux_ui/design/02-confirmation-interstitial.md b/docs/ux_ui/design/02-confirmation-interstitial.md deleted file mode 100644 index c2abd773..00000000 --- a/docs/ux_ui/design/02-confirmation-interstitial.md +++ /dev/null @@ -1,206 +0,0 @@ -# Prompter — Confirmation Interstitial Specification - -## Principle - -> **You decide what gets sent to the team.** - -The confirmation interstitial is the un-bypassable human gate before any Prompter-generated task enters the RoboCo system. It must be impossible to skip by accident, by keyboard shortcut, or by API manipulation. The user must read, review, and explicitly confirm. - ---- - -## Pattern: Review & Confirm Dialog - -### Component - -`Dialog` from `panel/src/components/ui/dialog.tsx` — **not** `AlertDialog`. We need the full content flexibility of `Dialog` (close button, scrollable body, custom footer) rather than the simplified action/cancel binary of `AlertDialog`. - -**Size**: `DialogContent` with classes `sm:max-w-3xl lg:max-w-4xl max-h-[90vh] overflow-y-auto`. - -This is the same large-dialog pattern used by `CreateTaskDialog` in `panel/src/components/tasks/create-task-dialog.tsx`. - ---- - -## Layout - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Review & Confirm Task [×] │ -│ You decide what gets sent to the team. │ -├──────────────────────────────────────────────────────────────┤ -│ │ -│ Title * │ -│ [________________________________________] │ -│ │ -│ Description * │ -│ [ ] │ -│ [ Markdown editor with preview toggle ] │ -│ [ ] │ -│ │ -│ Acceptance Criteria * │ -│ ┌────────────────────────────────────────┐ │ -│ │ 1. [Criterion text………] [×] │ │ -│ │ 2. [Criterion text………] [×] │ │ -│ │ 3. [Criterion text………] [×] │ │ -│ │ [+ Add Criterion] │ │ -│ └────────────────────────────────────────┘ │ -│ │ -│ ┌──────────┬──────────┬──────────┬──────────┐ │ -│ │ Team │ Status │ Priority │ Complex. │ │ -│ │ [Select] │ [Select] │ [Select] │ [Select] │ │ -│ └──────────┴──────────┴──────────┴──────────┘ │ -│ │ -│ ┌─ Advanced Options ──────────────────────┐ │ -│ │ Model selector (see model-selector-ux.md) │ │ -│ │ Assign to, Parent task, Project, Product │ │ -│ └──────────────────────────────────────────┘ │ -│ │ -│ ┌────────────────────────────────────────────────────────┐ │ -│ │ ⚠️ This will create a real task and notify the team. │ │ -│ │ It cannot be undone from this screen. │ │ -│ └────────────────────────────────────────────────────────┘ │ -│ │ -├──────────────────────────────────────────────────────────────┤ -│ [Cancel] [Confirm & Launch]│ -└──────────────────────────────────────────────────────────────┘ -``` - ---- - -## Sections (Detailed) - -### 1. Dialog Header - -- **Title**: `DialogTitle` — "Review & Confirm Task" -- **Description**: `DialogDescription` — "You decide what gets sent to the team." - -Both are required and always visible. The description is the human-agency anchor copy mandated by the Head of Marketing. - -### 2. Title Field - -- `Label` + `Input` -- Required (`*` indicator, `text-destructive` color) -- Validation: 5–200 characters (same rule as `CreateTaskDialog`) -- Error state: `border-destructive` + `text-xs text-destructive` message -- Pre-filled by LLM draft; user can edit inline. - -### 3. Description Field - -- Reuse `MarkdownEditor` from `panel/src/components/tasks/markdown-editor.tsx` -- Required, min 20 characters -- Preview toggle (Edit / Preview tabs using `Tabs` component) -- Pre-filled by LLM draft; user can edit inline. - -### 4. Acceptance Criteria - -- Reuse `AcceptanceCriteriaEditor` from `panel/src/components/tasks/acceptance-criteria-editor.tsx` -- Required, at least one criterion -- Numbered list with drag handles (if the existing editor supports reordering) or simple add/remove -- Pre-filled by LLM draft; user can add, edit, remove. - -### 5. Metadata Grid - -A 4-column grid (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`) using `Select` components: - -| Field | Component | Options | Default (from draft) | -|-------|-----------|---------|----------------------| -| Team | `Select` | `Object.values(Team)` | LLM suggestion | -| Status | `Select` | `PENDING`, `BACKLOG` | `PENDING` | -| Priority | `Select` | `P0`–`P3` | `P2` (Medium) | -| Complexity | `Select` | `LOW`, `MEDIUM`, `HIGH` | LLM suggestion | - -All wrapped in `Label` + `SelectTrigger` + `SelectContent` + `SelectItem`. - -### 6. Advanced Options Drawer - -- `Collapsible` from `panel/src/components/ui/collapsible.tsx` -- Trigger: `Button` variant `ghost` with `ChevronRight` / `ChevronDown` icons -- Contents: - - **Model selector** — see [`03-model-selector-ux.md`](03-model-selector-ux.md) - - **Assign To** — `AgentSelector` (`panel/src/components/agents/agent-selector.tsx`) - - **Parent Task** — `TaskSelector` (`panel/src/components/tasks/task-selector.tsx`) - - **Project** — `ProjectSelector` (`panel/src/components/projects/project-selector.tsx`) - - **Product** — `Select` from `CreateTaskDialog` product list - -### 7. Warning Banner - -A full-width `Alert` (if available; otherwise a `Card` with `border-destructive` or `bg-destructive/10`): - -- Icon: `AlertTriangle` from `lucide-react` -- Text: "This will create a real task and notify the team. It cannot be undone from this screen." -- Purpose: prevents the "I thought this was just a preview" error. - -### 8. Footer Actions - -- `DialogFooter` with `flex-col-reverse sm:flex-row sm:justify-end gap-2` -- **Cancel** (`Button` variant `outline`): closes dialog, returns to chat draft preview. Does **not** discard the draft. -- **Confirm & Launch** (`Button` primary): submits to the task-creation API. - - On click: button enters `disabled` state, text changes to "Launching…", spinner (use `Loader2` icon with `animate-spin`) - - On success: dialog closes, chat shows success card - - On error: button re-enables, error banner appears above footer - ---- - -## Un-bypassable Guardrails - -### UI Guardrails - -1. **No keyboard shortcut** launches the task. `Enter` inside any field does **not** submit the form; only the explicit footer button does. -2. **No click-outside dismissal** when dirty. If the user has edited any field, clicking the overlay shows a secondary confirmation: "You have unsaved changes. Discard them?" (`AlertDialog` with "Keep Editing" / "Discard"). -3. **Scroll requirement**: The dialog is tall enough that the footer may be below the fold on small screens. The warning banner is positioned **above** the footer so the user must scroll past it to reach the confirm button. - -### API Guardrails (Frontend Contract) - -- The frontend must **not** call the task-creation endpoint directly from the chat state. The only valid call path is: - ``` - Chat → Review Modal (user opens) → Confirm Button (user clicks) → POST /tasks - ``` -- There is no `?skip_review=true` query param, no hidden route, and no keyboard bypass. -- Backend should reject any Prompter-originated task creation that does not include a `confirmed_by_human: true` flag in the payload (enforced by the Backend Cell; noted here for cross-cell alignment). - ---- - -## Accessibility - -- Focus trap: when dialog opens, focus moves to the Title `Input`. -- `aria-describedby` on the Confirm button pointing to the warning banner text. -- All `Select` triggers have visible `Label` associations (`htmlFor` + `id`). -- Error messages use `aria-live="assertive"` so screen readers announce validation failures immediately. - ---- - -## Copy Reference - -| Element | Copy | -|---------|------| -| Dialog title | "Review & Confirm Task" | -| Dialog subtitle | "You decide what gets sent to the team." | -| Warning banner | "This will create a real task and notify the team. It cannot be undone from this screen." | -| Cancel button | "Cancel" | -| Confirm button (idle) | "Confirm & Launch" | -| Confirm button (loading) | "Launching…" | -| Dirty-state discard prompt title | "Discard changes?" | -| Dirty-state discard prompt body | "You have unsaved changes. If you cancel, your edits will be lost." | -| Dirty-state keep button | "Keep Editing" | -| Dirty-state discard button | "Discard" | - ---- - -## Component Inventory - -| UI Element | File Path | -|------------|-----------| -| Dialog shell | `panel/src/components/ui/dialog.tsx` | -| Alert (warning banner) | `panel/src/components/ui/alert-dialog.tsx` or custom `Card` | -| Card | `panel/src/components/ui/card.tsx` | -| Input | `panel/src/components/ui/input.tsx` | -| Textarea / MarkdownEditor | `panel/src/components/tasks/markdown-editor.tsx` | -| Select | `panel/src/components/ui/select.tsx` | -| Label | `panel/src/components/ui/label.tsx` | -| Button | `panel/src/components/ui/button.tsx` | -| Collapsible | `panel/src/components/ui/collapsible.tsx` | -| Badge | `panel/src/components/ui/badge.tsx` | -| Tabs | `panel/src/components/ui/tabs.tsx` | -| AcceptanceCriteriaEditor | `panel/src/components/tasks/acceptance-criteria-editor.tsx` | -| AgentSelector | `panel/src/components/agents/agent-selector.tsx` | -| TaskSelector | `panel/src/components/tasks/task-selector.tsx` | -| ProjectSelector | `panel/src/components/projects/project-selector.tsx` | diff --git a/docs/ux_ui/design/03-model-selector-ux.md b/docs/ux_ui/design/03-model-selector-ux.md deleted file mode 100644 index dd3d74ff..00000000 --- a/docs/ux_ui/design/03-model-selector-ux.md +++ /dev/null @@ -1,141 +0,0 @@ -# Prompter — Model Selector UX Specification - -## Principle - -Most users do not know which model drafts better tasks. The model selector should be available for power users but invisible for everyone else. The default must be safe, fast, and context-aware. - ---- - -## Placement - -**Inside the Advanced Options drawer** of the confirmation interstitial. - -- `Collapsible` trigger label: "Advanced Options" -- When expanded, the first item in the drawer is the model selector. - -This follows the existing `CreateTaskDialog` pattern where advanced fields (parent task, assignee, git config) are tucked behind a `Collapsible` with `ChevronRight` / `ChevronDown` icons. - -Rationale: -- Reduces cognitive load on the primary review screen. -- Prevents choice paralysis for users who don’t care about the model. -- Aligns with the Head of Marketing directive: "most users don’t know which model drafts better tasks." - ---- - -## Component - -`Select` from `panel/src/components/ui/select.tsx`: - -```tsx - -``` - ---- - -## Defaults - -### Default Selection: "Recommended" - -The `recommended` value is not a real model ID; it is a frontend alias that resolves to a model based on the draft's estimated complexity: - -| Draft Complexity | Resolved Model | Rationale | -|----------------|----------------|-----------| -| `LOW` | `gpt-4o` or lightweight equivalent | Fast, cheap, good enough for simple tasks | -| `MEDIUM` | `claude-sonnet-4` | Balanced quality and speed | -| `HIGH` | `claude-opus-4` | Deep reasoning for complex specs | - -**Dynamic label**: the Select trigger should display the resolved model name in the description: - -> ⭐ Recommended — Claude Sonnet 4 - -This gives transparency without requiring the user to make a manual choice. - -### How Complexity Is Determined - -1. **LLM suggestion**: the drafting LLM outputs an `estimated_complexity` field as part of the structured draft. -2. **User override**: if the user changes the Complexity `Select` in the review modal, the recommended model re-evaluates automatically. -3. **No user model preference persistence in Phase 1** — each session starts fresh with `recommended`. - ---- - -## Option Descriptions - -Each `SelectItem` should have a one-line subtitle explaining when to choose it: - -``` -⭐ Recommended — Claude Sonnet 4 - (Best balance for this task's complexity) - -Claude Sonnet 4 — Balanced - (Good for most tasks) - -GPT-4o — Fast - (Quick drafts, simpler specs) - -Claude Opus 4 — Deep reasoning - (Complex architecture or security tasks) -``` - -Implementation note: if `SelectItem` does not natively support subtitles, append the subtitle as muted text inside the item using a nested `span` with `text-muted-foreground text-xs`. - ---- - -## Visual Hierarchy - -``` -┌─ Advanced Options ──────────────────────┐ -│ │ -│ Model │ -│ [⭐ Recommended — Claude Sonnet 4 ▼] │ -│ ├─ ⭐ Recommended — Claude Sonnet 4 │ -│ ├─ Claude Sonnet 4 — Balanced │ -│ ├─ GPT-4o — Fast │ -│ └─ Claude Opus 4 — Deep reasoning │ -│ │ -│ Assign To … │ -│ Parent Task … │ -│ … │ -└─────────────────────────────────────────┘ -``` - ---- - -## Accessibility - -- `Label` with `htmlFor` tied to the `SelectTrigger` id. -- `aria-describedby` on the trigger pointing to a helper paragraph: "The model used to draft this task. 'Recommended' picks the best fit automatically." -- `SelectContent` should trap focus while open; `Esc` closes the dropdown. - ---- - -## Out of Scope (Phase 2) - -- Model comparison side-by-side -- User-level default model preference -- Cost/usage indicators per model -- Temperature / max-tokens sliders -- Custom system prompt editing - ---- - -## Cross-Cell Note - -The frontend sends the resolved model ID (not the alias) to the backend chat endpoint. The backend endpoint in `roboco/services/llm.py` already handles multi-provider routing; the frontend only needs to pass the model string in the payload. diff --git a/docs/ux_ui/design/04-naming-and-navigation.md b/docs/ux_ui/design/04-naming-and-navigation.md deleted file mode 100644 index 1ff727c1..00000000 --- a/docs/ux_ui/design/04-naming-and-navigation.md +++ /dev/null @@ -1,153 +0,0 @@ -# Prompter — Naming & Navigation Specification - -## Principle - -The existing Panel uses plain, descriptive nouns for navigation: -- "Overview", "Tasks", "Kanban" -- "Projects", "Products", "Git" -- "Agents", "Knowledge Base", "Auditor" - -A branded product name like "Prompter" risks feeling like a third-party plugin. The name should fit the existing vocabulary and signal value immediately. - ---- - -## Naming Options - -### Option A: Task Assistant *(Recommended)* - -- **Label**: "Task Assistant" -- **Rationale**: - - Plain noun + descriptor pattern (matches "Knowledge Base", "AI Providers") - - Immediately communicates value: it helps you with tasks. - - Does not over-promise autonomy — "assistant" implies human control. - - Works in sentence case naturally: "Open the Task Assistant." -- **Subtitle copy**: "Draft tasks with your AI teammate." -- **Concerns**: Slightly longer than other nav items; may truncate in collapsed sidebar. -- **Mitigation**: Collapsed sidebar uses icon + tooltip; length is fine in expanded view. - -### Option B: Draft - -- **Label**: "Draft" - -- **Rationale**: - - Single word, action-oriented, fits the existing terse style. - - Signals the core output: a draft task. - - Human-centric verb — you draft, the AI helps. -- **Subtitle copy**: "Draft tasks with your AI teammate." -- **Concerns**: - - Ambiguous: could be confused with "draft tasks" as a filter state in the Tasks page. - - Less discoverable for users who don’t already know the feature exists. - -### Option C: Composer - -- **Label**: "Composer" - -- **Rationale**: - - Evokes creation and authoring. - - Familiar to developers (IDE composers, email composers). -- **Subtitle copy**: "Compose tasks with your AI teammate." -- **Concerns**: - - Slightly more abstract than "Task Assistant". - - May imply musical or creative composition rather than structured task specs. - -### Internal Name - -"Prompter" remains the **internal engineering and marketing codename**. It is acceptable in internal documentation, Slack, and code comments. The UI label is the user-facing name chosen above. - ---- - -## Sidebar Placement - -### Recommended Placement - -Insert under the **Work Management** section, between "Kanban" and "Projects": - -```typescript -const navItems = [ - // Dashboard - { title: "Overview", href: "/overview", icon: LayoutDashboard }, - - // Work Management - { title: "Tasks", href: "/tasks", icon: ListTodo }, - { title: "Kanban", href: "/kanban", icon: Kanban }, - { title: "Task Assistant", href: "/prompter", icon: Sparkles }, // NEW - - // Development - { title: "Projects", href: "/projects", icon: FolderGit2 }, - { title: "Products", href: "/products", icon: Boxes }, - { title: "Git", href: "/git", icon: GitBranch }, - - // ... rest unchanged -]; -``` - -### Icon - -Use `Sparkles` from `lucide-react` (not currently imported in `sidebar.tsx`). - -- Rationale: universally understood as "AI / magic / assistance" without being overly literal. -- Alternative: `MessageSquarePlus` — more literal (chat + create), but `Sparkles` is more distinctive among existing icons. - -### Active State - -Same as existing nav items: -- Active: `bg-primary text-primary-foreground` -- Inactive: `text-muted-foreground hover:bg-muted hover:text-foreground` - ---- - -## Page Title & Meta - -| Surface | Copy (Option A) | -|---------|-----------------| -| Sidebar nav item | "Task Assistant" | -| Browser tab title | "Task Assistant — RoboCo Panel" | -| Page H1 | "Task Assistant" | -| Page subtitle | "Describe what you need. The assistant will ask questions and draft a task for your team." | -| Empty-state heading | "What do you want to build?" | -| Empty-state subtext | "Describe the task in plain language. The assistant will clarify and draft a spec you can review before sending it to the team." | - ---- - -## URL - -`/prompter` — keep the engineering slug regardless of display name. This avoids routing churn if the display name changes later. - -- Redirects: none needed for MVP. -- Deep-linking: `/prompter` always loads the empty/chat state; there is no persisted session ID in the URL for Phase 1. - ---- - -## Discoverability - -### Primary -- Sidebar entry at all times (not hidden behind permissions or feature flags for Phase 1). - -### Secondary -- Quick Actions bar on Overview dashboard: add a "New Task (AI-assisted)" button that links to `/prompter`. - - Uses existing `QuickActionsBar` pattern in `panel/src/components/dashboard/quick-actions-bar.tsx`. - - Icon: `Sparkles` next to the existing "New Task" button. - ---- - -## Decision Matrix - -| Criterion | Task Assistant | Draft | Composer | -|-----------|---------------|-------|----------| -| Fits existing panel vocabulary | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | -| Communicates value immediately | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | -| Does not over-promise autonomy | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | -| Short enough for sidebar | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | -| Distinct from other pages | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | -| Works in marketing copy | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | -| **Total** | **16** | **13** | **12** | - -**UX/UI Cell recommendation**: **Task Assistant** (Option A). - ---- - -## Cross-Cell Handoff - -- **Frontend**: implement route `/prompter`, sidebar entry with `Sparkles`, page layout. -- **Backend**: no API changes needed for naming; slug remains `prompter` in code. -- **Marketing**: "Draft tasks with your AI teammate" is the recommended tagline; aligns with "Task Assistant" label. diff --git a/docs/ux_ui/design/README.md b/docs/ux_ui/design/README.md deleted file mode 100644 index f79180c7..00000000 --- a/docs/ux_ui/design/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Prompter — UX/UI Design Deliverables - -This directory contains the interaction design and confirmation flow specification for the **Prompter** feature (Phase 1). All patterns are mapped to the existing Panel design system so the Frontend Cell can implement them in parallel without inventing new visual language. - -## Contents - -| Document | Purpose | -|----------|---------| -| [`01-interaction-spec.md`](01-interaction-spec.md) | End-to-end chat → draft → review → confirm → launch flow, state machine, component mappings, error/loading patterns | -| [`02-confirmation-interstitial.md`](02-confirmation-interstitial.md) | Mandatory human-in-the-loop review modal: layout, copy, actions, un-bypassable guardrails | -| [`03-model-selector-ux.md`](03-model-selector-ux.md) | Model selector placement, defaults, and cognitive-load reduction | -| [`04-naming-and-navigation.md`](04-naming-and-navigation.md) | Naming alternatives to "Prompter" and sidebar nav placement | - -## Design System Baseline - -All screens are built from components already present in `panel/src/components/ui/` and `panel/src/components/layout/`: - -- **Dialog** — `panel/src/components/ui/dialog.tsx` (Radix-based, animates in/out) -- **AlertDialog** — `panel/src/components/ui/alert-dialog.tsx` (for destructive/breaking confirmations) -- **Card** — `panel/src/components/ui/card.tsx` (sections, draft preview) -- **Tabs** — `panel/src/components/ui/tabs.tsx` (chat vs. draft review) -- **Select** — `panel/src/components/ui/select.tsx` (team, model, status) -- **Collapsible** — `panel/src/components/ui/collapsible.tsx` (advanced options drawer) -- **Button** — `panel/src/components/ui/button.tsx` (primary, outline, ghost, destructive) -- **Input / Textarea** — `panel/src/components/ui/input.tsx`, `panel/src/components/ui/textarea.tsx` -- **Badge** — `panel/src/components/ui/badge.tsx` (team labels, complexity indicators) -- **ScrollArea** — `panel/src/components/ui/scroll-area.tsx` (chat history, criteria list) -- **Skeleton** — `panel/src/components/ui/skeleton.tsx` (loading states) -- **Sidebar** — `panel/src/components/layout/sidebar.tsx` (navigation structure) - -> **Rule**: No new visual language. Reuse existing tokens, spacing, and color variables (`bg-background`, `text-muted-foreground`, `border`, `shadow-sm`, etc.). - -## Accessibility Baseline - -- Focus trap inside dialogs on open (`focus-visible:ring-ring`) -- `aria-live="polite"` on chat message list for screen-reader announcements -- Keyboard: `Enter` to send, `Esc` to close modals, `Tab` cycles focus -- All icon-only buttons need `sr-only` text labels - -## Version - -Phase 1 — chat + draft-review + create/launch (no persistence/history). diff --git a/roboco/api/app.py b/roboco/api/app.py index 9f28eaa5..7d0e20c6 100644 --- a/roboco/api/app.py +++ b/roboco/api/app.py @@ -30,6 +30,7 @@ from roboco.api.routes.optimal import router as optimal_router from roboco.api.routes.orchestrator import router as orchestrator_router from roboco.api.routes.product import router as product_router from roboco.api.routes.project import router as project_router +from roboco.api.routes.prompter import router as prompter_router from roboco.api.routes.provider import router as provider_router from roboco.api.routes.sessions import router as sessions_router from roboco.api.routes.stream import router as stream_router @@ -311,6 +312,13 @@ def create_app() -> FastAPI: tags=["Providers"], ) + # Prompter — conversational task drafting assistant + app.include_router( + prompter_router, + prefix=f"{api_prefix}/prompter", + tags=["Prompter"], + ) + # Work Sessions app.include_router( work_session_router, diff --git a/roboco/api/routes/prompter.py b/roboco/api/routes/prompter.py new file mode 100644 index 00000000..786ee8bd --- /dev/null +++ b/roboco/api/routes/prompter.py @@ -0,0 +1,287 @@ +""" +Prompter API Routes + +Session-based conversational assistant endpoints for drafting tasks: +- POST /api/prompter/sessions : create a new session +- POST /api/prompter/sessions/{id}/messages : send user message, get AI reply +- GET /api/prompter/sessions/{id}/draft : get structured task draft +- POST /api/prompter/sessions/{id}/confirm : confirm draft → create real task + +Legacy stateless endpoints (retained for backward compatibility): +- POST /api/prompter/chat : back-and-forth conversation (stateless) +- POST /api/prompter/draft : structured task draft generation (stateless) +""" + +from uuid import UUID + +from fastapi import APIRouter, HTTPException, status + +from roboco.api.deps import CurrentAgentContext, DbSession +from roboco.api.schemas.prompter import ( + ChatMessage, + PrompterChatRequest, + PrompterChatResponse, + PrompterDraftRequest, + PrompterDraftResponse, + PrompterDraftTask, + PrompterMessageRequest, + PrompterMessageResponse, + PrompterSessionCreateRequest, + PrompterSessionResponse, + TaskConfirmRequest, + TaskDraftResponse, +) +from roboco.services.base import NotFoundError, ServiceError, ValidationError +from roboco.services.prompter import ConfirmOverrides, get_prompter_service + +router = APIRouter() + + +def _translate_error(e: ServiceError) -> HTTPException: + """Service errors → HTTP status.""" + if isinstance(e, NotFoundError): + return HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error": "not_found", "message": e.message}, + ) + if isinstance(e, ValidationError): + return HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "validation_error", + "message": e.message, + "field": e.field, + }, + ) + return HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": "internal_error", "message": e.message}, + ) + + +# ============================================================================= +# SESSION-BASED ENDPOINTS +# ============================================================================= + + +@router.post( + "/sessions", + response_model=PrompterSessionResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_session( + data: PrompterSessionCreateRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> PrompterSessionResponse: + """Create a new Prompter conversation session linked to the authenticated agent.""" + service = get_prompter_service(db) + try: + session = await service.create_session( + agent_id=agent.agent_id, + context=data.context, + ) + except ServiceError as e: + raise _translate_error(e) from e + + return PrompterSessionResponse( + id=session.id, # type: ignore[arg-type] + agent_id=session.agent_id, # type: ignore[arg-type] + status=session.status, + created_at=session.created_at, + updated_at=session.updated_at, + ) + + +@router.post( + "/sessions/{session_id}/messages", + response_model=list[PrompterMessageResponse], +) +async def send_message( + session_id: UUID, + data: PrompterMessageRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> list[PrompterMessageResponse]: + """ + Accept a user message, append it and an AI assistant response to the + conversation, and return the updated message list. + """ + service = get_prompter_service(db) + try: + messages = await service.send_message( + session_id=session_id, + agent_id=agent.agent_id, + content=data.content, + context=data.context, + ) + except ServiceError as e: + raise _translate_error(e) from e + + return [ + PrompterMessageResponse( + id=msg.id, # type: ignore[arg-type] + session_id=msg.session_id, # type: ignore[arg-type] + role=msg.role, + content=msg.content, + created_at=msg.created_at, + ) + for msg in messages + ] + + +@router.get( + "/sessions/{session_id}/draft", + response_model=TaskDraftResponse, +) +async def get_draft( + session_id: UUID, + db: DbSession, + agent: CurrentAgentContext, +) -> TaskDraftResponse: + """ + Return a structured task draft extracted from conversation history via LLM. + + The draft contains: title, description, acceptance_criteria, team, + task_type, nature, and estimated_complexity. + """ + service = get_prompter_service(db) + try: + draft_record = await service.get_or_generate_draft( + session_id=session_id, + agent_id=agent.agent_id, + ) + except ServiceError as e: + raise _translate_error(e) from e + + # Parse the stored draft_data into PrompterDraftTask for validation + try: + draft_task = PrompterDraftTask(**draft_record.draft_data) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "draft_schema_error", + "message": f"Stored draft did not match schema: {exc}", + "raw_draft": draft_record.draft_data, + }, + ) from exc + + return TaskDraftResponse( + id=draft_record.id, # type: ignore[arg-type] + session_id=draft_record.session_id, # type: ignore[arg-type] + draft=draft_task, + confirmed_at=draft_record.confirmed_at, + task_id=draft_record.task_id, # type: ignore[arg-type] + created_at=draft_record.created_at, + ) + + +@router.post( + "/sessions/{session_id}/confirm", + response_model=dict, + status_code=status.HTTP_201_CREATED, +) +async def confirm_draft( + session_id: UUID, + data: TaskConfirmRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> dict: + """ + Validate the draft and create a real Task using the existing TaskService. + + Returns the created task ID. + """ + service = get_prompter_service(db) + try: + task_id = await service.confirm_draft( + session_id=session_id, + agent_id=agent.agent_id, + confirm_overrides=ConfirmOverrides( + project_id=data.project_id, + product_id=data.product_id, + assigned_to=data.assigned_to, + extra=data.overrides, + ), + ) + except ServiceError as e: + raise _translate_error(e) from e + + return {"task_id": str(task_id)} + + +# ============================================================================= +# LEGACY STATELESS ENDPOINTS (backward compatibility) +# ============================================================================= + + +@router.post("/chat", response_model=PrompterChatResponse) +async def prompter_chat( + data: PrompterChatRequest, + _agent: CurrentAgentContext, +) -> PrompterChatResponse: + """ + Continue a Prompter conversation (stateless). + + The frontend sends the full conversation history (including the new user + message). The assistant replies, optionally signalling that enough context + has been gathered to generate a draft (`draft_ready=True`). + """ + service = get_prompter_service() + try: + result = await service.chat( + messages=[msg.model_dump() for msg in data.messages], + context=data.context, + ) + except ServiceError as e: + raise _translate_error(e) from e + + return PrompterChatResponse( + message=result["message"], + draft_ready=result["draft_ready"], + ) + + +@router.post("/draft", response_model=PrompterDraftResponse) +async def prompter_draft( + data: PrompterDraftRequest, + _agent: CurrentAgentContext, +) -> PrompterDraftResponse: + """ + Generate a structured task draft from conversation context (stateless). + + The frontend sends the full conversation history. The backend calls the + LLM to produce a JSON draft conforming to the TaskCreate schema. + """ + service = get_prompter_service() + try: + result = await service.draft( + messages=[msg.model_dump() for msg in data.messages], + context=data.context, + ) + except ServiceError as e: + raise _translate_error(e) from e + + draft_raw = result["draft"] + try: + draft = PrompterDraftTask(**draft_raw) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "error": "draft_schema_error", + "message": f"Generated draft did not match schema: {e}", + "raw_draft": draft_raw, + }, + ) from e + + return PrompterDraftResponse( + draft=draft, + reasoning=result["reasoning"], + ) + + +def _messages_to_dicts(messages: list[ChatMessage]) -> list[dict[str, str]]: + """Convert ChatMessage list to dict list (internal helper).""" + return [msg.model_dump() for msg in messages] diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index 2d001e2e..c83de488 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -167,6 +167,14 @@ async def create_task( ) from None assigned_to_uuid = cast("UUID", agent_row.id) + # Prompter origin tracking: enforce human confirmation gate so + # LLM-drafted tasks cannot bypass review and enter the workflow. + if data.source == "prompter" and not data.confirmed_by_human: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Prompter-originated tasks require human confirmation", + ) + service = get_task_service(db) req = TaskCreateRequest( title=data.title, @@ -187,6 +195,9 @@ async def create_task( task_type=data.task_type, project_id=data.project_id, product_id=data.product_id, + # Prompter origin tracking + source=data.source, + confirmed_by_human=data.confirmed_by_human, ) task = await service.create(req) await db.commit() diff --git a/roboco/api/schemas/prompter.py b/roboco/api/schemas/prompter.py new file mode 100644 index 00000000..98db4b01 --- /dev/null +++ b/roboco/api/schemas/prompter.py @@ -0,0 +1,232 @@ +""" +Prompter API Schemas + +Request/response models for the conversational Prompter assistant +that helps users draft tasks through natural language. + +Includes both the session-based schemas (for the DB-persisted approach) +and the legacy stateless schemas retained for backward compatibility. +""" + +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from roboco.models.base import ( + Complexity, + TaskNature, + TaskType, + Team, +) + +# ============================================================================= +# SHARED MESSAGE SCHEMA +# ============================================================================= + + +class ChatMessage(BaseModel): + """A single message in the Prompter conversation.""" + + role: str = Field(..., description="One of: user, assistant, system") + content: str = Field(..., min_length=1, description="Message text") + + @field_validator("role") + @classmethod + def _valid_role(cls, v: str) -> str: + if v not in {"user", "assistant", "system"}: + raise ValueError("role must be one of: user, assistant, system") + return v + + +# ============================================================================= +# SESSION-BASED SCHEMAS (acceptance-criteria-required names) +# ============================================================================= + + +class PrompterSessionCreateRequest(BaseModel): + """Request body for POST /api/prompter/sessions.""" + + context: dict[str, Any] = Field( + default_factory=dict, + description="Optional bootstrap context (project_id, team, etc.)", + ) + + +class PrompterSessionResponse(BaseModel): + """Response for session creation and retrieval.""" + + id: UUID + agent_id: UUID + status: str + created_at: datetime + updated_at: datetime | None = None + + model_config = ConfigDict(from_attributes=True) + + +class PrompterMessageRequest(BaseModel): + """Request body for POST /api/prompter/sessions/{id}/messages.""" + + content: str = Field(..., min_length=1, description="The user's message text") + context: dict[str, Any] = Field( + default_factory=dict, + description="Optional per-turn context overrides", + ) + + +class PrompterMessageResponse(BaseModel): + """A single message record returned to the client.""" + + id: UUID + session_id: UUID + role: str + content: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TaskConfirmRequest(BaseModel): + """Request body for POST /api/prompter/sessions/{id}/confirm. + + Allows the frontend to pass overrides that should be applied + to the draft before the real task is created. + """ + + project_id: UUID | None = Field( + default=None, + description="Override project_id from the draft (required if draft omits it)", + ) + product_id: UUID | None = Field( + default=None, + description="Override product_id from the draft", + ) + assigned_to: str | None = Field( + default=None, + description="Agent slug or UUID to assign the task to", + ) + overrides: dict[str, Any] = Field( + default_factory=dict, + description="Additional fields to override in the draft before task creation", + ) + + +# ============================================================================= +# DRAFT TASK SCHEMA (shared between session and legacy paths) +# ============================================================================= + + +class PrompterDraftTask(BaseModel): + """A task draft produced by the Prompter. + + Mirrors TaskCreate fields so the frontend can POST /api/tasks + with confirmed_by_human=True after human review. + """ + + title: str = Field(..., min_length=1, max_length=200) + description: str = Field(..., min_length=20) + acceptance_criteria: list[str] = Field(..., min_length=1) + team: Team = Field(...) + priority: int = Field(default=2, ge=0, le=3) + task_type: TaskType = Field(...) + nature: TaskNature = Field(...) + estimated_complexity: Complexity = Field(...) + project_id: str | None = Field( + default=None, + description=( + "Project UUID as string; exactly one of project_id or " + "product_id must be set" + ), + ) + product_id: str | None = Field( + default=None, + description=( + "Product UUID as string; exactly one of project_id or " + "product_id must be set" + ), + ) + assigned_to: str | None = Field( + default=None, + description="Agent slug or UUID to assign the task to", + ) + target_date: str | None = Field( + default=None, + description="ISO-8601 target completion date", + ) + + # Provenance — always set by the prompter backend + source: str = "prompter" + confirmed_by_human: bool = False + + +class TaskDraftResponse(BaseModel): + """Response for GET /api/prompter/sessions/{id}/draft.""" + + id: UUID + session_id: UUID + draft: PrompterDraftTask + confirmed_at: datetime | None = None + task_id: UUID | None = None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +# ============================================================================= +# LEGACY STATELESS SCHEMAS (retained for backward compatibility) +# ============================================================================= + + +class PrompterChatRequest(BaseModel): + """Request to continue a Prompter conversation (stateless).""" + + messages: list[ChatMessage] = Field( + ..., + min_length=1, + description="Conversation history including the new user message", + ) + context: dict[str, Any] = Field( + default_factory=dict, + description="Optional context (project_id, team, prior drafts, etc.)", + ) + + +class PrompterChatResponse(BaseModel): + """Response from the Prompter chat endpoint (stateless).""" + + message: str = Field(..., description="Assistant's reply") + conversation_id: str | None = Field( + default=None, description="Client-managed conversation identifier" + ) + draft_ready: bool = Field( + default=False, + description=( + "True when the assistant believes enough context exists to draft a task" + ), + ) + + +class PrompterDraftRequest(BaseModel): + """Request to generate a task draft from conversation context (stateless).""" + + messages: list[ChatMessage] = Field( + ..., min_length=1, description="Full conversation used as drafting context" + ) + context: dict[str, Any] = Field( + default_factory=dict, + description="Optional overrides (project_id, team, assigned_to, etc.)", + ) + + +class PrompterDraftResponse(BaseModel): + """Response from the Prompter draft endpoint (stateless).""" + + draft: PrompterDraftTask = Field(..., description="Structured task draft") + reasoning: str = Field( + default="", + description="Assistant's explanation of how the draft was derived", + ) + + model_config = ConfigDict(from_attributes=True) diff --git a/roboco/api/schemas/tasks.py b/roboco/api/schemas/tasks.py index 795b2e79..1ffdfd38 100644 --- a/roboco/api/schemas/tasks.py +++ b/roboco/api/schemas/tasks.py @@ -325,6 +325,10 @@ class TaskResponse(BaseModel): pr_number: int | None = None pr_url: str | None = None + # Prompter origin tracking + source: str = "manual" + confirmed_by_human: bool = False + model_config = ConfigDict(from_attributes=True) @@ -671,6 +675,8 @@ def task_to_response(task: "TaskTable") -> TaskResponse: branch_name=getattr(task, "branch_name", None), pr_number=getattr(task, "pr_number", None), pr_url=getattr(task, "pr_url", None), + source=getattr(task, "source", "manual"), + confirmed_by_human=getattr(task, "confirmed_by_human", False), ) diff --git a/roboco/db/tables.py b/roboco/db/tables.py index a655cf78..4fb17145 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -359,6 +359,14 @@ class TaskTable(Base): Boolean, nullable=False, default=False ) + # Prompter origin tracking: tasks drafted by the Prompter LLM assistant + # require human confirmation before entering the workflow. The task creation + # route enforces that prompter-originated tasks cannot bypass human review. + source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual") + confirmed_by_human: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + # Relationships creator: Mapped["AgentTable"] = relationship( "AgentTable", foreign_keys=[created_by], lazy="joined" @@ -1807,3 +1815,133 @@ class GatewayTriggerTable(Base): Index("ix_gateway_triggers_created_at", "created_at"), Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"), ) + + +# ============================================================================= +# PROMPTER TABLES +# ============================================================================= + + +class PrompterSessionTable(Base): + """A Prompter conversation session owned by an agent.""" + + __tablename__ = "prompter_sessions" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + agent_id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("agents.id", ondelete="CASCADE"), + nullable=False, + ) + status: Mapped[str] = mapped_column( + Enum( + "active", + "draft_ready", + "confirmed", + "abandoned", + name="promptersessionstatus", + ), + nullable=False, + default="active", + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True + ) + + # Relationships + messages: Mapped[list["PrompterMessageTable"]] = relationship( + "PrompterMessageTable", + back_populates="session", + order_by="PrompterMessageTable.created_at", + cascade="all, delete-orphan", + lazy="select", + ) + drafts: Mapped[list["TaskDraftTable"]] = relationship( + "TaskDraftTable", + back_populates="session", + cascade="all, delete-orphan", + lazy="select", + ) + + __table_args__ = ( + Index("ix_prompter_sessions_agent_id", "agent_id"), + Index("ix_prompter_sessions_status", "status"), + ) + + +class PrompterMessageTable(Base): + """A single message turn within a Prompter conversation session.""" + + __tablename__ = "prompter_messages" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + session_id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("prompter_sessions.id", ondelete="CASCADE"), + nullable=False, + ) + role: Mapped[str] = mapped_column( + Enum("user", "assistant", "system", name="promptermessagerole"), + nullable=False, + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + + # Relationships + session: Mapped["PrompterSessionTable"] = relationship( + "PrompterSessionTable", back_populates="messages" + ) + + __table_args__ = ( + Index("ix_prompter_messages_session_id", "session_id"), + Index("ix_prompter_messages_session_created", "session_id", "created_at"), + ) + + +class TaskDraftTable(Base): + """A structured task draft extracted from a Prompter conversation.""" + + __tablename__ = "task_drafts" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + session_id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("prompter_sessions.id", ondelete="CASCADE"), + nullable=False, + ) + draft_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + confirmed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + task_id: Mapped[UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True + ) + + # Relationships + session: Mapped["PrompterSessionTable"] = relationship( + "PrompterSessionTable", back_populates="drafts" + ) + + __table_args__ = ( + Index("ix_task_drafts_session_id", "session_id"), + Index("ix_task_drafts_task_id", "task_id"), + ) diff --git a/roboco/models/task.py b/roboco/models/task.py index 9cc1bdea..29c5f33e 100644 --- a/roboco/models/task.py +++ b/roboco/models/task.py @@ -265,6 +265,16 @@ class Task(TimestampMixin): description="True after QA inspects inline diff via claim_review.", ) + # Prompter origin tracking + source: str = Field( + default="manual", + description="Origin of the task: 'manual', 'prompter', etc.", + ) + confirmed_by_human: bool = Field( + default=False, + description="Whether a human has confirmed this prompter-originated task.", + ) + # NOTE: Task state mutations should be performed through TaskService, # not directly on the model. See roboco/services/task.py for: # - claim(), start(), block(), pause(), resume() @@ -325,6 +335,10 @@ class TaskCreate(RobocoBase): project_id: UUID | None = None product_id: UUID | None = None + # Prompter origin tracking + source: str = Field(default="manual") + confirmed_by_human: bool = Field(default=False) + @model_validator(mode="after") def _project_or_product(self) -> "TaskCreate": if self.project_id is None and self.product_id is None: @@ -400,3 +414,7 @@ class TaskCreateRequest: # Ordering and dependencies sequence: int = 0 # Order within siblings (lower = first) dependency_ids: list[UUID] = field(default_factory=list) + + # Prompter origin tracking + source: str = "manual" + confirmed_by_human: bool = False diff --git a/roboco/services/prompter.py b/roboco/services/prompter.py new file mode 100644 index 00000000..90d3c678 --- /dev/null +++ b/roboco/services/prompter.py @@ -0,0 +1,625 @@ +""" +Prompter Service + +Conversational LLM assistant that helps users draft tasks. +Uses Anthropic Claude for natural-language interaction and +structured JSON draft generation. + +Provides both a session-based approach (DB-persisted) and a +legacy stateless interface for backward compatibility. +""" + +from __future__ import annotations + +import contextlib +import json +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any +from uuid import UUID, uuid4 + +import structlog +from anthropic import AsyncAnthropic +from sqlalchemy import select + +from roboco.config import settings +from roboco.db.tables import ( + PrompterMessageTable, + PrompterSessionTable, + TaskDraftTable, + TaskTable, +) +from roboco.models.base import Complexity, TaskNature, TaskType, Team +from roboco.models.task import TaskCreateRequest +from roboco.services.base import NotFoundError, ServiceError, ValidationError + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = structlog.get_logger() + + +# --------------------------------------------------------------------------- +# Input types +# --------------------------------------------------------------------------- + + +@dataclass +class ConfirmOverrides: + """Optional overrides applied when confirming a draft to create a task.""" + + project_id: UUID | None = None + product_id: UUID | None = None + assigned_to: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Prompts +# --------------------------------------------------------------------------- + +_PROMPTER_SYSTEM_PROMPT = ( + "You are the RoboCo Prompter — a conversational assistant that helps " + "users draft tasks for an AI agentic company.\n\n" + "Your job is to:\n" + "1. Ask clarifying questions to gather requirements.\n" + "2. Keep the conversation focused on producing a well-scoped task.\n" + "3. When you believe you have enough context, signal that a draft is " + "ready.\n" + "4. Never create the task yourself — only help the user articulate what " + "needs to be built.\n\n" + "Key rules:\n" + "- Be concise but thorough.\n" + "- Always ask for acceptance criteria if the user hasn't provided them.\n" + "- Suggest a team (backend, frontend, ux_ui) based on the work " + "described.\n" + "- Estimate complexity (low, medium, high) and task type (code, " + "documentation, research, planning, design, administrative).\n" + "- Determine nature (technical vs non_technical).\n" + "- If the user describes a bug, suggest a code task with technical " + "nature.\n" + "- If the user describes a feature, determine whether it's backend, " + "frontend, or UX/UI work.\n\n" + "When you have enough information to produce a complete draft, say so " + "explicitly with 'I have enough information to draft a task' or " + "'ready to draft'." +) + +_DRAFT_SYSTEM_PROMPT = ( + "You are the RoboCo Prompter — an expert at converting conversations " + "into structured task drafts.\n\n" + "Given a conversation between a user and the Prompter assistant, " + "produce a JSON task draft that conforms to the RoboCo task schema.\n\n" + "Required fields:\n" + "- title: concise, actionable task title (max 200 chars)\n" + "- description: detailed description, min 20 chars, explaining what " + "needs to be done\n" + "- acceptance_criteria: list of strings, each a verifiable criterion " + "(min 1)\n" + "- team: one of backend, frontend, ux_ui\n" + "- task_type: one of code, documentation, research, planning, design, " + "administrative\n" + "- nature: one of technical, non_technical\n" + "- estimated_complexity: one of low, medium, high\n" + "- priority: integer 0-3 (0=P0 highest, 3=P3 lowest)\n\n" + "Optional fields:\n" + "- project_id: UUID string if known from context\n" + "- product_id: UUID string if known from context (only one of " + "project_id/product_id should be set)\n" + "- assigned_to: agent slug or UUID if the user specified one\n" + "- target_date: ISO-8601 date string if mentioned\n\n" + 'Always set source="prompter" and confirmed_by_human=false.\n\n' + "Return ONLY valid JSON matching the PrompterDraftTask schema. No " + "markdown, no preamble." +) + + +class PrompterService: + """Service for Prompter chat, session management, and structured draft generation. + + Accepts an optional SQLAlchemy ``AsyncSession`` for the session-based + (DB-persisted) interface. When no session is provided, only the legacy + stateless ``chat()`` and ``draft()`` methods are available. + """ + + def __init__(self, db: AsyncSession | None = None) -> None: + self.log = logger.bind(component="prompter_service") + self._client: AsyncAnthropic | None = None + self._db = db + + def _get_client(self) -> AsyncAnthropic: + """Lazy-init Anthropic client.""" + if self._client is None: + api_key = settings.anthropic_api_key + if not api_key: + raise ServiceError("Anthropic API key not configured") + self._client = AsyncAnthropic(api_key=api_key) + return self._client + + @property + def _session(self) -> AsyncSession: + """Return DB session, raising if not configured.""" + if self._db is None: + raise ServiceError( + "PrompterService was created without a DB session; " + "session-based methods are unavailable" + ) + return self._db + + # ----------------------------------------------------------------------- + # Session-based interface + # ----------------------------------------------------------------------- + + async def create_session( + self, + agent_id: UUID, + context: dict[str, Any] | None = None, # noqa: ARG002 + ) -> PrompterSessionTable: + """Create a new Prompter conversation session.""" + session = PrompterSessionTable( + id=uuid4(), + agent_id=agent_id, + status="active", + created_at=datetime.now(UTC), + ) + self._session.add(session) + await self._session.flush() + self.log.info("Prompter session created", session_id=str(session.id)) + return session + + async def send_message( + self, + session_id: UUID, + agent_id: UUID, + content: str, + context: dict[str, Any] | None = None, + ) -> list[PrompterMessageTable]: + """ + Append a user message, call the LLM for a reply, persist both, + and return all messages in the session. + """ + session = await self._get_session(session_id, agent_id) + + # Persist the user message first + user_msg = PrompterMessageTable( + id=uuid4(), + session_id=session_id, + role="user", + content=content, + created_at=datetime.now(UTC), + ) + self._session.add(user_msg) + await self._session.flush() + + # Load full conversation history for the LLM call + history = await self._load_messages(session_id) + chat_messages = [{"role": m.role, "content": m.content} for m in history] + + # Call the LLM + llm_reply = await self._llm_chat( + messages=chat_messages, + context=context, + ) + + # Persist the assistant reply + assistant_msg = PrompterMessageTable( + id=uuid4(), + session_id=session_id, + role="assistant", + content=llm_reply["message"], + created_at=datetime.now(UTC), + ) + self._session.add(assistant_msg) + + # Update session status if draft is ready + if llm_reply["draft_ready"] and session.status == "active": + session.status = "draft_ready" + + await self._session.flush() + self.log.info( + "Message processed", + session_id=str(session_id), + draft_ready=llm_reply["draft_ready"], + ) + + # Return all messages in order + return await self._load_messages(session_id) + + async def get_or_generate_draft( + self, + session_id: UUID, + agent_id: UUID, + ) -> TaskDraftTable: + """ + Return an existing draft for the session, or generate one via LLM + if none exists yet. + """ + await self._get_session(session_id, agent_id) + + # Check for an existing draft + result = await self._session.execute( + select(TaskDraftTable) + .where(TaskDraftTable.session_id == session_id) + .order_by(TaskDraftTable.created_at.desc()) + .limit(1) + ) + existing = result.scalar_one_or_none() + if existing is not None: + return existing + + # No draft yet — generate one from conversation history + history = await self._load_messages(session_id) + if not history: + raise ValidationError( + message=( + "Cannot generate a draft from an empty conversation; " + "send at least one message first." + ), + field="messages", + ) + + chat_messages = [{"role": m.role, "content": m.content} for m in history] + draft_result = await self._llm_draft( + messages=chat_messages, + ) + + draft_record = TaskDraftTable( + id=uuid4(), + session_id=session_id, + draft_data=draft_result["draft"], + created_at=datetime.now(UTC), + ) + self._session.add(draft_record) + await self._session.flush() + return draft_record + + async def confirm_draft( + self, + session_id: UUID, + agent_id: UUID, + confirm_overrides: ConfirmOverrides | None = None, + ) -> UUID: + """ + Validate the draft and create a real Task via the TaskService. + + Returns the newly created task's UUID. + """ + session_rec = await self._get_session(session_id, agent_id) + ov = confirm_overrides or ConfirmOverrides() + + # Get or generate the draft + draft_record = await self.get_or_generate_draft(session_id, agent_id) + draft_data: dict[str, Any] = dict(draft_record.draft_data) + + # Apply overrides + if ov.project_id is not None: + draft_data["project_id"] = str(ov.project_id) + if ov.product_id is not None: + draft_data["product_id"] = str(ov.product_id) + if ov.assigned_to is not None: + draft_data["assigned_to"] = ov.assigned_to + if ov.extra: + draft_data.update(ov.extra) + + # Resolve project/product IDs + resolved_project_id: UUID | None = None + resolved_product_id: UUID | None = None + if draft_data.get("project_id"): + try: + resolved_project_id = UUID(str(draft_data["project_id"])) + except ValueError as exc: + raise ValidationError( + message=f"Invalid project_id UUID: {draft_data['project_id']}", + field="project_id", + ) from exc + if draft_data.get("product_id"): + try: + resolved_product_id = UUID(str(draft_data["product_id"])) + except ValueError as exc: + raise ValidationError( + message=f"Invalid product_id UUID: {draft_data['product_id']}", + field="product_id", + ) from exc + + if resolved_project_id is None and resolved_product_id is None: + raise ValidationError( + message=( + "The draft must have either project_id or product_id set. " + "Pass one via the confirm request body." + ), + field="project_id", + ) + + # Validate and coerce required fields + try: + team = Team(draft_data["team"]) + task_type = TaskType(draft_data["task_type"]) + nature = TaskNature(draft_data["nature"]) + complexity = Complexity(draft_data["estimated_complexity"]) + except (KeyError, ValueError) as exc: + raise ValidationError( + message=f"Draft has invalid or missing required fields: {exc}", + field="draft", + ) from exc + + # Resolve assigned_to as UUID if possible + resolved_assigned_to: UUID | None = None + if draft_data.get("assigned_to"): + with contextlib.suppress(ValueError): + resolved_assigned_to = UUID(str(draft_data["assigned_to"])) + + req = TaskCreateRequest( + title=draft_data["title"], + description=draft_data["description"], + acceptance_criteria=draft_data["acceptance_criteria"], + team=team, + created_by=agent_id, + task_type=task_type, + nature=nature, + estimated_complexity=complexity, + priority=int(draft_data.get("priority", 2)), + assigned_to=resolved_assigned_to, + project_id=resolved_project_id, + product_id=resolved_product_id, + source="prompter", + confirmed_by_human=True, + ) + + # Import TaskService lazily to avoid circular imports + from roboco.services.task import get_task_service + + task_service = get_task_service(self._session) + task: TaskTable = await task_service.create(req) + + # Mark draft as confirmed + now = datetime.now(UTC) + draft_record.confirmed_at = now + draft_record.task_id = task.id + session_rec.status = "confirmed" + await self._session.flush() + + self.log.info( + "Draft confirmed — task created", + session_id=str(session_id), + task_id=str(task.id), + ) + return task.id # type: ignore[return-value] + + # ----------------------------------------------------------------------- + # Private helpers (session-based) + # ----------------------------------------------------------------------- + + async def _get_session( + self, session_id: UUID, agent_id: UUID + ) -> PrompterSessionTable: + """Load and authorize a PrompterSession.""" + result = await self._session.execute( + select(PrompterSessionTable).where(PrompterSessionTable.id == session_id) + ) + rec = result.scalar_one_or_none() + if rec is None: + raise NotFoundError(f"Prompter session {session_id} not found") + if rec.agent_id != agent_id: + raise ServiceError( + f"Session {session_id} does not belong to agent {agent_id}" + ) + return rec + + async def _load_messages(self, session_id: UUID) -> list[PrompterMessageTable]: + """Return all messages for a session ordered by creation time.""" + result = await self._session.execute( + select(PrompterMessageTable) + .where(PrompterMessageTable.session_id == session_id) + .order_by(PrompterMessageTable.created_at) + ) + return list(result.scalars().all()) + + # ----------------------------------------------------------------------- + # Shared LLM helpers + # ----------------------------------------------------------------------- + + async def _llm_chat( + self, + messages: list[dict[str, str]], + context: dict[str, Any] | None = None, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 2048, + ) -> dict[str, Any]: + """Call the LLM for a chat response. Returns {message, draft_ready}.""" + client = self._get_client() + user_prompt = _build_chat_prompt(messages, context) + try: + response = await client.messages.create( + model=model, + max_tokens=max_tokens, + system=_PROMPTER_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_prompt}], + ) + except Exception as e: + self.log.error("Prompter chat LLM call failed", error=str(e)) + raise ServiceError(f"LLM chat failed: {e}") from e + + content = _extract_text(response) + if not content: + raise ServiceError("LLM returned empty content") + + return { + "message": content, + "draft_ready": _detect_draft_ready(content), + } + + async def _llm_draft( + self, + messages: list[dict[str, str]], + context: dict[str, Any] | None = None, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 4096, + ) -> dict[str, Any]: + """Call the LLM to generate a structured draft. Returns {draft, reasoning}.""" + client = self._get_client() + user_prompt = _build_draft_prompt(messages, context) + try: + response = await client.messages.create( + model=model, + max_tokens=max_tokens, + system=_DRAFT_SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_prompt}], + ) + except Exception as e: + self.log.error("Prompter draft LLM call failed", error=str(e)) + raise ServiceError(f"LLM draft generation failed: {e}") from e + + content = _extract_text(response) + if not content: + raise ServiceError("LLM returned empty content for draft") + + try: + draft_data = json.loads(content) + except json.JSONDecodeError as e: + self.log.warning("Draft JSON parse failed", content_preview=content[:200]) + raise ValidationError( + message=f"Draft response was not valid JSON: {e}", + field="draft", + ) from e + + draft_data["source"] = "prompter" + draft_data["confirmed_by_human"] = False + return { + "draft": draft_data, + "reasoning": _build_reasoning(messages, draft_data), + } + + # ----------------------------------------------------------------------- + # Legacy stateless interface + # ----------------------------------------------------------------------- + + async def chat( + self, + messages: list[dict[str, str]], + context: dict[str, Any] | None = None, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 2048, + ) -> dict[str, Any]: + """Continue a Prompter conversation (stateless).""" + return await self._llm_chat( + messages=messages, + context=context, + model=model, + max_tokens=max_tokens, + ) + + async def draft( + self, + messages: list[dict[str, str]], + context: dict[str, Any] | None = None, + model: str = "claude-3-5-sonnet-20241022", + max_tokens: int = 4096, + ) -> dict[str, Any]: + """Generate a structured task draft from conversation context (stateless).""" + return await self._llm_draft( + messages=messages, + context=context, + model=model, + max_tokens=max_tokens, + ) + + +# --------------------------------------------------------------------------- +# Module-level helpers (pure functions, no state) +# --------------------------------------------------------------------------- + + +def _build_chat_prompt( + messages: list[dict[str, str]], + context: dict[str, Any] | None, +) -> str: + lines: list[str] = [] + if context: + lines.append("Context:") + for key, value in context.items(): + lines.append(f" {key}: {value}") + lines.append("") + lines.append("Conversation:") + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + lines.append(f"{role}: {content}") + lines.append("") + lines.append( + "Continue the conversation as the Prompter assistant. " + "If you have enough information to draft a complete task, " + "say so explicitly." + ) + return "\n".join(lines) + + +def _build_draft_prompt( + messages: list[dict[str, str]], + context: dict[str, Any] | None, +) -> str: + lines: list[str] = [] + lines.append( + "Produce a JSON task draft from the following conversation. " + "Return ONLY valid JSON — no markdown, no preamble." + ) + if context: + lines.append("") + lines.append("Overrides:") + for key, value in context.items(): + lines.append(f" {key}: {value}") + lines.append("") + lines.append("Conversation:") + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + lines.append(f"{role}: {content}") + return "\n".join(lines) + + +def _extract_text(response: Any) -> str: + text_parts: list[str] = [] + for block in getattr(response, "content", []): + if hasattr(block, "text"): + text_parts.append(block.text) + return "\n".join(text_parts).strip() + + +def _detect_draft_ready(content: str) -> bool: + signals = [ + "i have enough information", + "ready to generate a draft", + "ready to draft", + "i can now draft", + "draft_ready=true", + "draft ready", + ] + lower = content.lower() + return any(sig in lower for sig in signals) + + +def _build_reasoning( + messages: list[dict[str, str]], + draft_data: dict[str, Any], +) -> str: + title = draft_data.get("title", "Untitled") + team = draft_data.get("team", "unknown") + complexity = draft_data.get("estimated_complexity", "unknown") + return ( + f"Draft generated from conversation of {len(messages)} messages. " + f"Proposed task '{title}' for team {team} " + f"with complexity {complexity}." + ) + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + + +def get_prompter_service(db: AsyncSession | None = None) -> PrompterService: + """Create a PrompterService instance. + + Pass ``db`` for the session-based interface; omit for the stateless + legacy interface. + """ + return PrompterService(db=db) diff --git a/roboco/services/task.py b/roboco/services/task.py index 9e50fbcd..cb4f51de 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -549,6 +549,9 @@ class TaskService(BaseService): task_type=req.task_type, project_id=req.project_id, product_id=req.product_id, + # Prompter origin tracking + source=req.source, + confirmed_by_human=req.confirmed_by_human, ) self.session.add(task) await self.session.flush() diff --git a/tests/integration/test_prompter_routes.py b/tests/integration/test_prompter_routes.py new file mode 100644 index 00000000..42d181f0 --- /dev/null +++ b/tests/integration/test_prompter_routes.py @@ -0,0 +1,790 @@ +"""Prompter API route integration tests. + +Covers both the new session-based endpoints: + POST /sessions, POST /sessions/{id}/messages, GET /sessions/{id}/draft, + POST /sessions/{id}/confirm + +And the legacy stateless endpoints: + POST /chat, POST /draft +""" + +from __future__ import annotations + +import json +from http import HTTPStatus +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from roboco.api.deps import get_agent_context, get_db +from roboco.api.routes.prompter import router as prompter_router +from roboco.db.tables import AgentTable, ProjectTable +from roboco.models.base import AgentRole, AgentStatus +from roboco.models.permissions import AgentContext + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + + from sqlalchemy.ext.asyncio import AsyncSession + +# Expected message counts in multi-turn tests +_SINGLE_TURN_MSGS = 2 # 1 user + 1 assistant +_DOUBLE_TURN_MSGS = 4 # 2 user + 2 assistant + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def prompter_client( + db_session: AsyncSession, +) -> AsyncIterator[dict[str, Any]]: + agent = AgentTable( + id=uuid4(), + name="DevAgent", + slug=f"dev-agent-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(agent) + await db_session.flush() + + app = FastAPI() + app.include_router(prompter_router, prefix="/api/prompter") + + async def _override_db() -> AsyncIterator[AsyncSession]: + yield db_session + + async def _override_agent() -> AgentContext: + return AgentContext( + agent_id=agent.id, # type: ignore[arg-type] + role=AgentRole.DEVELOPER, + team=None, + ) + + app.dependency_overrides[get_db] = _override_db + app.dependency_overrides[get_agent_context] = _override_agent + + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + yield {"client": client, "agent": agent, "db": db_session} + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def project_fixture(db_session: AsyncSession) -> ProjectTable: + """Create a minimal project for task creation in confirm tests.""" + project = ProjectTable( + id=uuid4(), + name="Test Project", + slug=f"test-project-{uuid4().hex[:8]}", + git_url="https://github.com/test/repo.git", + git_branch="main", + ) + db_session.add(project) + await db_session.flush() + return project + + +_HDR = {"X-Agent-ID": "be-dev-1", "X-Agent-Role": "developer"} + + +# ============================================================================= +# Session-based endpoint tests +# ============================================================================= + + +@pytest.mark.asyncio +async def test_create_session_success(prompter_client: dict) -> None: + """POST /sessions creates a new session linked to the agent.""" + client = prompter_client["client"] + response = await client.post( + "/api/prompter/sessions", + json={}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.CREATED + body = response.json() + assert "id" in body + assert body["status"] == "active" + assert "agent_id" in body + assert "created_at" in body + + +@pytest.mark.asyncio +async def test_create_session_with_context(prompter_client: dict) -> None: + """POST /sessions accepts optional bootstrap context.""" + client = prompter_client["client"] + response = await client.post( + "/api/prompter/sessions", + json={"context": {"team": "backend", "project_id": str(uuid4())}}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.CREATED + body = response.json() + assert body["status"] == "active" + + +@pytest.mark.asyncio +async def test_send_message_success(prompter_client: dict) -> None: + """POST /sessions/{id}/messages appends user+assistant messages.""" + client = prompter_client["client"] + + # Create session + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Great! Let's gather requirements.")] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a new feature"}, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + messages = response.json() + assert len(messages) == _SINGLE_TURN_MSGS + roles = [m["role"] for m in messages] + assert "user" in roles + assert "assistant" in roles + assert messages[-1]["content"] == "Great! Let's gather requirements." + + +@pytest.mark.asyncio +async def test_send_message_marks_draft_ready(prompter_client: dict) -> None: + """draft_ready signal in LLM response updates session status.""" + client = prompter_client["client"] + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + mock_response = MagicMock() + mock_response.content = [ + MagicMock( + text=( + "I have enough information to draft a task now. " + "Ready to draft when you are." + ) + ) + ] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "Add a login page with MFA support"}, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + messages = response.json() + assert len(messages) == _SINGLE_TURN_MSGS + + +@pytest.mark.asyncio +async def test_send_message_not_found(prompter_client: dict) -> None: + """POST /sessions/{id}/messages with unknown session → 404.""" + client = prompter_client["client"] + response = await client.post( + f"/api/prompter/sessions/{uuid4()}/messages", + json={"content": "Hello"}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_get_draft_generates_from_conversation(prompter_client: dict) -> None: + """GET /sessions/{id}/draft generates a draft via LLM.""" + client = prompter_client["client"] + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + draft_json = { + "title": "Add login page", + "description": "Implement a secure login page with email and password", + "acceptance_criteria": [ + "User can enter email and password", + "Invalid credentials show error message", + ], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "priority": 2, + } + + chat_response = MagicMock() + chat_response.content = [MagicMock(text="Tell me more about the requirements.")] + + draft_response = MagicMock() + draft_response.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_response, + ): + await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a login page"}, + headers=_HDR, + ) + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=draft_response, + ): + response = await client.get( + f"/api/prompter/sessions/{session_id}/draft", + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["draft"]["title"] == "Add login page" + assert body["draft"]["source"] == "prompter" + assert body["confirmed_at"] is None + assert body["draft"]["confirmed_by_human"] is False + assert body["session_id"] == session_id + + +@pytest.mark.asyncio +async def test_get_draft_cached(prompter_client: dict) -> None: + """GET /sessions/{id}/draft returns the cached draft on subsequent calls.""" + client = prompter_client["client"] + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + draft_json = { + "title": "Add login page", + "description": "Implement a secure login page with email and password", + "acceptance_criteria": ["User can enter credentials"], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "priority": 2, + } + chat_response = MagicMock() + chat_response.content = [MagicMock(text="Got it.")] + draft_response = MagicMock() + draft_response.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_response, + ): + await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a login page"}, + headers=_HDR, + ) + + call_count = 0 + + async def _mock_create(**_kwargs: Any) -> MagicMock: + nonlocal call_count + call_count += 1 + return draft_response + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + side_effect=_mock_create, + ): + await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) + second_response = await client.get( + f"/api/prompter/sessions/{session_id}/draft", headers=_HDR + ) + + assert second_response.status_code == HTTPStatus.OK + # LLM should only be called once (draft is cached) + assert call_count == 1 + + +@pytest.mark.asyncio +async def test_get_draft_empty_session_returns_400(prompter_client: dict) -> None: + """GET /sessions/{id}/draft with no messages → 400.""" + client = prompter_client["client"] + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + response = await client.get( + f"/api/prompter/sessions/{session_id}/draft", + headers=_HDR, + ) + assert response.status_code == HTTPStatus.BAD_REQUEST + + +@pytest.mark.asyncio +async def test_confirm_draft_creates_task( + prompter_client: dict, project_fixture: ProjectTable +) -> None: + """POST /sessions/{id}/confirm validates draft and creates a real task.""" + client = prompter_client["client"] + project_id = str(project_fixture.id) + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + draft_json = { + "title": "Add login page", + "description": "Implement a secure login page with email and password", + "acceptance_criteria": ["User can enter credentials"], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "priority": 2, + } + + chat_response = MagicMock() + chat_response.content = [MagicMock(text="Got it.")] + draft_response = MagicMock() + draft_response.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_response, + ): + await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a login page"}, + headers=_HDR, + ) + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=draft_response, + ): + await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) + + confirm_response = await client.post( + f"/api/prompter/sessions/{session_id}/confirm", + json={"project_id": project_id}, + headers=_HDR, + ) + + assert confirm_response.status_code == HTTPStatus.CREATED + body = confirm_response.json() + assert "task_id" in body + assert body["task_id"] is not None + + +@pytest.mark.asyncio +async def test_confirm_draft_requires_project_or_product( + prompter_client: dict, +) -> None: + """POST /sessions/{id}/confirm without project_id/product_id → 400.""" + client = prompter_client["client"] + + session_resp = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + session_id = session_resp.json()["id"] + + draft_json = { + "title": "Add login page", + "description": "Implement a secure login page with email and password", + "acceptance_criteria": ["User can enter credentials"], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "priority": 2, + } + + chat_response = MagicMock() + chat_response.content = [MagicMock(text="Got it.")] + draft_response = MagicMock() + draft_response.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_response, + ): + await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a login page"}, + headers=_HDR, + ) + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=draft_response, + ): + await client.get(f"/api/prompter/sessions/{session_id}/draft", headers=_HDR) + + confirm_response = await client.post( + f"/api/prompter/sessions/{session_id}/confirm", + json={}, + headers=_HDR, + ) + + assert confirm_response.status_code == HTTPStatus.BAD_REQUEST + + +# ============================================================================= +# Full happy path integration test +# ============================================================================= + + +@pytest.mark.asyncio +async def test_full_happy_path( + prompter_client: dict, project_fixture: ProjectTable +) -> None: + """Full happy path: create session → send messages → get draft → confirm task.""" + client = prompter_client["client"] + project_id = str(project_fixture.id) + + # Step 1: Create session + step1 = await client.post("/api/prompter/sessions", json={}, headers=_HDR) + assert step1.status_code == HTTPStatus.CREATED + session_id = step1.json()["id"] + + # Step 2: Send messages + chat_mock = MagicMock() + chat_mock.content = [ + MagicMock(text="Please describe the acceptance criteria for this feature.") + ] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_mock, + ): + step2a = await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "I need a dark mode toggle for the UI"}, + headers=_HDR, + ) + assert step2a.status_code == HTTPStatus.OK + + chat_mock2 = MagicMock() + chat_mock2.content = [ + MagicMock(text="I have enough information to draft a task now.") + ] + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=chat_mock2, + ): + step2b = await client.post( + f"/api/prompter/sessions/{session_id}/messages", + json={"content": "Preference is persisted across sessions"}, + headers=_HDR, + ) + assert step2b.status_code == HTTPStatus.OK + messages = step2b.json() + assert len(messages) == _DOUBLE_TURN_MSGS + + # Step 3: Get draft + draft_json = { + "title": "Add dark mode toggle", + "description": "Implement a dark mode toggle so users can switch themes", + "acceptance_criteria": [ + "User can toggle light/dark mode", + "Preference is persisted across sessions", + ], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "low", + "priority": 2, + } + draft_mock = MagicMock() + draft_mock.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=draft_mock, + ): + step3 = await client.get( + f"/api/prompter/sessions/{session_id}/draft", headers=_HDR + ) + assert step3.status_code == HTTPStatus.OK + draft_body = step3.json() + assert draft_body["draft"]["title"] == "Add dark mode toggle" + + # Step 4: Confirm draft → creates task + step4 = await client.post( + f"/api/prompter/sessions/{session_id}/confirm", + json={"project_id": project_id}, + headers=_HDR, + ) + assert step4.status_code == HTTPStatus.CREATED + task_body = step4.json() + assert "task_id" in task_body + assert task_body["task_id"] is not None + + +# ============================================================================= +# Legacy stateless endpoint tests (backward compatibility) +# ============================================================================= + + +@pytest.mark.asyncio +async def test_prompter_chat_success(prompter_client: dict) -> None: + client = prompter_client["client"] + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Great! Let's gather requirements.")] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + "/api/prompter/chat", + json={ + "messages": [{"role": "user", "content": "I need a new feature"}], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["message"] == "Great! Let's gather requirements." + assert body["draft_ready"] is False + + +@pytest.mark.asyncio +async def test_prompter_chat_draft_ready(prompter_client: dict) -> None: + client = prompter_client["client"] + + mock_response = MagicMock() + mock_response.content = [ + MagicMock( + text=( + "I have enough information. draft_ready=true." + " Ready to generate a draft." + ) + ) + ] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + "/api/prompter/chat", + json={ + "messages": [ + {"role": "user", "content": "I need a new feature"}, + {"role": "assistant", "content": "Tell me more"}, + {"role": "user", "content": "Add a login page"}, + ], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["draft_ready"] is True + + +@pytest.mark.asyncio +async def test_prompter_chat_llm_failure(prompter_client: dict) -> None: + client = prompter_client["client"] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + side_effect=Exception("Anthropic API unavailable"), + ): + response = await client.post( + "/api/prompter/chat", + json={ + "messages": [{"role": "user", "content": "Hello"}], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + body = response.json() + assert "LLM chat failed" in body["detail"]["message"] + + +@pytest.mark.asyncio +async def test_prompter_draft_success(prompter_client: dict) -> None: + client = prompter_client["client"] + + draft_json = { + "title": "Add login page", + "description": "Implement a secure login page with email and password", + "acceptance_criteria": [ + "User can enter email and password", + "Invalid credentials show error message", + ], + "team": "frontend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + "priority": 2, + } + + mock_response = MagicMock() + mock_response.content = [MagicMock(text=json.dumps(draft_json))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + "/api/prompter/draft", + json={ + "messages": [ + {"role": "user", "content": "I need a login page"}, + ], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.OK + body = response.json() + assert body["draft"]["title"] == "Add login page" + assert body["draft"]["source"] == "prompter" + assert body["draft"]["confirmed_by_human"] is False + assert "reasoning" in body + + +@pytest.mark.asyncio +async def test_prompter_draft_invalid_json_from_llm(prompter_client: dict) -> None: + client = prompter_client["client"] + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="not valid json")] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + "/api/prompter/draft", + json={ + "messages": [{"role": "user", "content": "Hello"}], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.BAD_REQUEST + body = response.json() + assert "Draft response was not valid JSON" in body["detail"]["message"] + + +@pytest.mark.asyncio +async def test_prompter_draft_schema_mismatch(prompter_client: dict) -> None: + client = prompter_client["client"] + + bad_draft = { + "title": "x", + "description": "too short", + } + + mock_response = MagicMock() + mock_response.content = [MagicMock(text=json.dumps(bad_draft))] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + return_value=mock_response, + ): + response = await client.post( + "/api/prompter/draft", + json={ + "messages": [{"role": "user", "content": "Hello"}], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + body = response.json() + assert "draft_schema_error" in body["detail"]["error"] + + +@pytest.mark.asyncio +async def test_prompter_draft_llm_failure(prompter_client: dict) -> None: + client = prompter_client["client"] + + with patch( + "roboco.services.prompter.AsyncAnthropic.messages.create", + new_callable=AsyncMock, + side_effect=Exception("Anthropic API unavailable"), + ): + response = await client.post( + "/api/prompter/draft", + json={ + "messages": [{"role": "user", "content": "Hello"}], + }, + headers=_HDR, + ) + + assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + body = response.json() + assert "LLM draft generation failed" in body["detail"]["message"] + + +@pytest.mark.asyncio +async def test_prompter_chat_empty_messages(prompter_client: dict) -> None: + client = prompter_client["client"] + response = await client.post( + "/api/prompter/chat", + json={"messages": []}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_prompter_chat_invalid_role(prompter_client: dict) -> None: + client = prompter_client["client"] + response = await client.post( + "/api/prompter/chat", + json={"messages": [{"role": "invalid", "content": "hi"}]}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_prompter_draft_empty_messages(prompter_client: dict) -> None: + client = prompter_client["client"] + response = await client.post( + "/api/prompter/draft", + json={"messages": []}, + headers=_HDR, + ) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY diff --git a/tests/unit/api/test_schemas_prompter.py b/tests/unit/api/test_schemas_prompter.py new file mode 100644 index 00000000..0df65997 --- /dev/null +++ b/tests/unit/api/test_schemas_prompter.py @@ -0,0 +1,212 @@ +"""Unit tests for Prompter API schemas. + +Covers schema validation for both the session-based and legacy schemas. +""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from pydantic import ValidationError as PydanticValidationError +from roboco.api.schemas.prompter import ( + ChatMessage, + PrompterChatRequest, + PrompterDraftTask, + PrompterMessageRequest, + PrompterSessionCreateRequest, + TaskConfirmRequest, +) + +# ============================================================================= +# ChatMessage +# ============================================================================= + + +def test_chat_message_valid_roles() -> None: + for role in ("user", "assistant", "system"): + msg = ChatMessage(role=role, content="Hello") + assert msg.role == role + + +def test_chat_message_invalid_role() -> None: + with pytest.raises(PydanticValidationError) as exc_info: + ChatMessage(role="admin", content="Hello") + assert "role must be one of" in str(exc_info.value) + + +def test_chat_message_empty_content() -> None: + with pytest.raises(PydanticValidationError): + ChatMessage(role="user", content="") + + +# ============================================================================= +# PrompterSessionCreateRequest +# ============================================================================= + + +def test_session_create_request_defaults() -> None: + req = PrompterSessionCreateRequest() + assert req.context == {} + + +def test_session_create_request_with_context() -> None: + req = PrompterSessionCreateRequest(context={"team": "backend"}) + assert req.context == {"team": "backend"} + + +# ============================================================================= +# PrompterMessageRequest +# ============================================================================= + + +def test_message_request_valid() -> None: + req = PrompterMessageRequest(content="I need a feature") + assert req.content == "I need a feature" + assert req.context == {} + + +def test_message_request_empty_content() -> None: + with pytest.raises(PydanticValidationError): + PrompterMessageRequest(content="") + + +def test_message_request_with_context() -> None: + req = PrompterMessageRequest(content="Hello", context={"key": "value"}) + assert req.context["key"] == "value" + + +# ============================================================================= +# TaskConfirmRequest +# ============================================================================= + + +def test_task_confirm_request_all_optional() -> None: + req = TaskConfirmRequest() + assert req.project_id is None + assert req.product_id is None + assert req.assigned_to is None + assert req.overrides == {} + + +def test_task_confirm_request_with_project() -> None: + pid = uuid4() + req = TaskConfirmRequest(project_id=pid) + assert req.project_id == pid + + +# ============================================================================= +# PrompterDraftTask +# ============================================================================= + + +def test_draft_task_valid() -> None: + draft = PrompterDraftTask( + title="Add login page", + description="Implement a secure login page with email and password", + acceptance_criteria=["User can log in"], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + ) + assert draft.title == "Add login page" + assert draft.source == "prompter" + assert draft.confirmed_by_human is False + + +def test_draft_task_title_too_long() -> None: + with pytest.raises(PydanticValidationError): + PrompterDraftTask( + title="x" * 201, + description="Implement a secure login page with email and password", + acceptance_criteria=["User can log in"], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + ) + + +def test_draft_task_description_too_short() -> None: + with pytest.raises(PydanticValidationError): + PrompterDraftTask( + title="Add login page", + description="short", # <20 chars + acceptance_criteria=["User can log in"], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + ) + + +def test_draft_task_empty_acceptance_criteria() -> None: + with pytest.raises(PydanticValidationError): + PrompterDraftTask( + title="Add login page", + description="Implement a secure login page with email and password", + acceptance_criteria=[], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + ) + + +def test_draft_task_invalid_team() -> None: + with pytest.raises(PydanticValidationError): + PrompterDraftTask( + title="Add login page", + description="Implement a secure login page with email and password", + acceptance_criteria=["User can log in"], + team="infra", # invalid + task_type="code", + nature="technical", + estimated_complexity="medium", + ) + + +def test_draft_task_priority_bounds() -> None: + # Valid bounds + for p in (0, 1, 2, 3): + d = PrompterDraftTask( + title="Add login page", + description="Implement a secure login page with email and password", + acceptance_criteria=["User can log in"], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + priority=p, + ) + assert d.priority == p + + # Out of bounds + with pytest.raises(PydanticValidationError): + PrompterDraftTask( + title="Add login page", + description="Implement a secure login page with email and password", + acceptance_criteria=["User can log in"], + team="frontend", + task_type="code", + nature="technical", + estimated_complexity="medium", + priority=4, + ) + + +# ============================================================================= +# PrompterChatRequest (legacy) +# ============================================================================= + + +def test_chat_request_requires_messages() -> None: + with pytest.raises(PydanticValidationError): + PrompterChatRequest(messages=[]) + + +def test_chat_request_valid() -> None: + req = PrompterChatRequest(messages=[ChatMessage(role="user", content="Hello")]) + assert len(req.messages) == 1 + assert req.context == {} diff --git a/tests/unit/services/test_prompter.py b/tests/unit/services/test_prompter.py new file mode 100644 index 00000000..d288e896 --- /dev/null +++ b/tests/unit/services/test_prompter.py @@ -0,0 +1,354 @@ +"""Unit tests for PrompterService. + +Tests the service layer logic with mocked LLM calls. Uses an in-memory +async session (via conftest fixtures) for DB-backed tests. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest +from roboco.db.tables import AgentTable +from roboco.models.base import AgentRole, AgentStatus +from roboco.services.base import NotFoundError, ServiceError, ValidationError +from roboco.services.prompter import ( + PrompterService, + _build_chat_prompt, + _build_draft_prompt, + _build_reasoning, + _detect_draft_ready, + _extract_text, + get_prompter_service, +) + +# ============================================================================= +# Pure function tests (no DB) +# ============================================================================= + + +def test_detect_draft_ready_signals() -> None: + signals = [ + "I have enough information to proceed", + "Ready to generate a draft now.", + "ready to draft the task", + "i can now draft this.", + "draft_ready=true", + "The task is draft ready", + ] + for text in signals: + assert _detect_draft_ready(text), f"Expected True for: {text!r}" + + +def test_detect_draft_ready_negative() -> None: + not_signals = [ + "Tell me more about the feature.", + "Could you clarify the acceptance criteria?", + "Let's continue the conversation.", + ] + for text in not_signals: + assert not _detect_draft_ready(text), f"Expected False for: {text!r}" + + +def test_extract_text_with_blocks() -> None: + block1 = MagicMock() + block1.text = "Hello, " + block2 = MagicMock() + block2.text = "world!" + response = MagicMock() + response.content = [block1, block2] + result = _extract_text(response) + assert result == "Hello, \nworld!" + + +def test_extract_text_empty_response() -> None: + response = MagicMock() + response.content = [] + assert _extract_text(response) == "" + + +def test_extract_text_no_text_attr() -> None: + block = MagicMock(spec=[]) # no 'text' attribute + response = MagicMock() + response.content = [block] + assert _extract_text(response) == "" + + +def test_build_chat_prompt_basic() -> None: + messages = [ + {"role": "user", "content": "I need a feature"}, + {"role": "assistant", "content": "Tell me more"}, + ] + prompt = _build_chat_prompt(messages, None) + assert "user: I need a feature" in prompt + assert "assistant: Tell me more" in prompt + assert "Continue the conversation" in prompt + + +def test_build_chat_prompt_with_context() -> None: + messages = [{"role": "user", "content": "hello"}] + prompt = _build_chat_prompt(messages, {"team": "backend"}) + assert "Context:" in prompt + assert "team: backend" in prompt + + +def test_build_draft_prompt() -> None: + messages = [{"role": "user", "content": "I need a login page"}] + prompt = _build_draft_prompt(messages, None) + assert "valid JSON" in prompt + assert "user: I need a login page" in prompt + + +def test_build_reasoning() -> None: + messages = [{"role": "user", "content": "Hello"}] * 3 + draft = {"title": "My Task", "team": "backend", "estimated_complexity": "medium"} + reasoning = _build_reasoning(messages, draft) + assert "My Task" in reasoning + assert "backend" in reasoning + assert "medium" in reasoning + assert "3 messages" in reasoning + + +# ============================================================================= +# Factory +# ============================================================================= + + +def test_get_prompter_service_no_db() -> None: + service = get_prompter_service() + assert isinstance(service, PrompterService) + assert service._db is None + + +def test_get_prompter_service_raises_without_db_for_session_methods() -> None: + service = get_prompter_service() + with pytest.raises(ServiceError, match="DB session"): + _ = service._session + + +# ============================================================================= +# Stateless chat / draft (with mocked LLM) +# ============================================================================= + + +@pytest.mark.asyncio +async def test_chat_success_with_mock_llm() -> None: + service = get_prompter_service() + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Great, let's continue!")] + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + result = await service.chat( + messages=[{"role": "user", "content": "I need a feature"}] + ) + + assert result["message"] == "Great, let's continue!" + assert result["draft_ready"] is False + + +@pytest.mark.asyncio +async def test_chat_draft_ready_signal() -> None: + service = get_prompter_service() + + mock_response = MagicMock() + mock_response.content = [ + MagicMock(text="I have enough information. Ready to draft.") + ] + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + result = await service.chat( + messages=[{"role": "user", "content": "I need a feature"}] + ) + + assert result["draft_ready"] is True + + +@pytest.mark.asyncio +async def test_chat_raises_on_empty_response() -> None: + service = get_prompter_service() + + mock_response = MagicMock() + mock_response.content = [] # Empty content blocks + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + with pytest.raises(ServiceError, match="LLM returned empty content"): + await service.chat(messages=[{"role": "user", "content": "Hello"}]) + + +@pytest.mark.asyncio +async def test_chat_raises_on_llm_error() -> None: + service = get_prompter_service() + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock( + side_effect=Exception("API unavailable") + ) + mock_get_client.return_value = mock_client + + with pytest.raises(ServiceError, match="LLM chat failed"): + await service.chat(messages=[{"role": "user", "content": "Hello"}]) + + +@pytest.mark.asyncio +async def test_draft_success_with_mock_llm() -> None: + service = get_prompter_service() + + draft_data = { + "title": "Add login", + "description": "Implement login functionality with JWT tokens", + "acceptance_criteria": ["User can log in"], + "team": "backend", + "task_type": "code", + "nature": "technical", + "estimated_complexity": "medium", + } + + mock_response = MagicMock() + mock_response.content = [MagicMock(text=json.dumps(draft_data))] + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + result = await service.draft( + messages=[{"role": "user", "content": "I need a login feature"}] + ) + + assert result["draft"]["title"] == "Add login" + assert result["draft"]["source"] == "prompter" + assert result["draft"]["confirmed_by_human"] is False + assert "reasoning" in result + + +@pytest.mark.asyncio +async def test_draft_raises_on_invalid_json() -> None: + service = get_prompter_service() + + mock_response = MagicMock() + mock_response.content = [MagicMock(text="Not JSON at all")] + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock(return_value=mock_response) + mock_get_client.return_value = mock_client + + with pytest.raises(ValidationError, match="not valid JSON"): + await service.draft(messages=[{"role": "user", "content": "Hello"}]) + + +@pytest.mark.asyncio +async def test_draft_raises_on_llm_error() -> None: + service = get_prompter_service() + + with patch.object(service, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.messages.create = AsyncMock( + side_effect=Exception("API unavailable") + ) + mock_get_client.return_value = mock_client + + with pytest.raises(ServiceError, match="LLM draft generation failed"): + await service.draft(messages=[{"role": "user", "content": "Hello"}]) + + +# ============================================================================= +# API key validation +# ============================================================================= + + +def test_get_client_raises_without_api_key() -> None: + service = get_prompter_service() + service._client = None # Force fresh init + + with patch("roboco.services.prompter.settings") as mock_settings: + mock_settings.anthropic_api_key = None + with pytest.raises(ServiceError, match="Anthropic API key not configured"): + service._get_client() + + +# ============================================================================= +# Session-based: create_session (DB-backed via conftest) +# ============================================================================= + + +@pytest.mark.asyncio +async def test_create_session_db(db_session: Any) -> None: + """create_session persists a PrompterSessionTable row.""" + service = get_prompter_service(db=db_session) + + agent = AgentTable( + id=uuid4(), + name="TestAgent", + slug=f"test-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(agent) + await db_session.flush() + + session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type] + assert session.id is not None + assert session.status == "active" + assert session.agent_id == agent.id + + +@pytest.mark.asyncio +async def test_get_session_not_found(db_session: Any) -> None: + """_get_session raises NotFoundError for unknown session ID.""" + service = get_prompter_service(db=db_session) + with pytest.raises(NotFoundError): + await service._get_session(uuid4(), uuid4()) + + +@pytest.mark.asyncio +async def test_get_draft_empty_session_raises(db_session: Any) -> None: + """get_or_generate_draft raises ValidationError if no messages exist.""" + service = get_prompter_service(db=db_session) + + agent = AgentTable( + id=uuid4(), + name="TestAgent", + slug=f"test-{uuid4().hex[:8]}", + role=AgentRole.DEVELOPER, + team=None, + status=AgentStatus.ACTIVE, + model_config={}, + system_prompt="dev", + capabilities=[], + permissions={}, + metrics={}, + ) + db_session.add(agent) + await db_session.flush() + + session = await service.create_session(agent_id=agent.id) # type: ignore[arg-type] + + with pytest.raises(ValidationError, match="empty conversation"): + await service.get_or_generate_draft( + session_id=session.id, # type: ignore[arg-type] + agent_id=agent.id, # type: ignore[arg-type] + ) From 5a99140a553f841bc15da0d38f16bd2bec9cf6b0 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:38:36 +0200 Subject: [PATCH 3/4] [9ef46be7] feat(prompter): implement Prompter page, nav entry, chat UI, and task creation wiring (#78) (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Task Assistant nav entry (Sparkles icon) to sidebar between Kanban and Projects - Create lib/api/prompter.ts with createSession, sendMessage, getDraft API functions - Create hooks/use-prompter.ts with 6-state machine (empty→chatting→draft_preview→review_modal→launching→success) - Create ChatMessages with auto-scroll, user/assistant/error rendering, inline DraftProposalCard - Create ChatComposer with Enter-to-send and Shift+Enter for newline - Create DraftProposalCard with title, description, acceptance criteria, metadata badges, Keep Chatting/Review & Confirm actions - Create ConfirmDialog (Radix Dialog, no form element) with MarkdownEditor, AcceptanceCriteriaEditor, metadata selects, warning banner, disabled Confirm & Launch until valid - Create SuccessCard with task title, team, View Task link, Start Another button - Create /prompter route page composing all components via usePrompter hook - TypeScript and ESLint pass with zero errors Co-authored-by: Frontend Developer 2 --- panel/src/app/(dashboard)/prompter/page.tsx | 93 ++++++ panel/src/components/layout/sidebar.tsx | 2 + .../src/components/prompter/chat-composer.tsx | 70 +++++ .../src/components/prompter/chat-messages.tsx | 97 +++++++ .../components/prompter/confirm-dialog.tsx | 212 ++++++++++++++ .../prompter/draft-proposal-card.tsx | 108 +++++++ panel/src/components/prompter/index.ts | 5 + .../src/components/prompter/success-card.tsx | 58 ++++ panel/src/hooks/use-prompter.ts | 264 ++++++++++++++++++ panel/src/lib/api/prompter.ts | 65 +++++ 10 files changed, 974 insertions(+) create mode 100644 panel/src/app/(dashboard)/prompter/page.tsx create mode 100644 panel/src/components/prompter/chat-composer.tsx create mode 100644 panel/src/components/prompter/chat-messages.tsx create mode 100644 panel/src/components/prompter/confirm-dialog.tsx create mode 100644 panel/src/components/prompter/draft-proposal-card.tsx create mode 100644 panel/src/components/prompter/index.ts create mode 100644 panel/src/components/prompter/success-card.tsx create mode 100644 panel/src/hooks/use-prompter.ts create mode 100644 panel/src/lib/api/prompter.ts diff --git a/panel/src/app/(dashboard)/prompter/page.tsx b/panel/src/app/(dashboard)/prompter/page.tsx new file mode 100644 index 00000000..a9641a45 --- /dev/null +++ b/panel/src/app/(dashboard)/prompter/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Sparkles } from "lucide-react"; +import { usePrompter } from "@/hooks/use-prompter"; +import { + ChatMessages, + ChatComposer, + ConfirmDialog, + SuccessCard, +} from "@/components/prompter"; + +export default function PrompterPage() { + const { + state, + messages, + isSending, + editableDraft, + createdTaskId, + createdTaskTitle, + createdTaskTeam, + send, + openReview, + closeReview, + keepChatting, + updateDraft, + isValidForLaunch, + launchTask, + startAnother, + isLaunching, + } = usePrompter(); + + const isComposerDisabled = + state === "launching" || state === "success"; + + return ( +
+ {/* Page header */} +
+ +
+

Task Assistant

+

+ Describe your idea and I'll help you create a structured task +

+
+
+ + {/* Chat area */} +
+ {/* Success overlay in chat area */} + {state === "success" && + createdTaskId && + createdTaskTitle && + createdTaskTeam ? ( +
+
+ +
+
+ ) : ( + + )} + + {/* Composer */} + +
+ + {/* Confirmation dialog (portal) */} + +
+ ); +} diff --git a/panel/src/components/layout/sidebar.tsx b/panel/src/components/layout/sidebar.tsx index 96f8d6d6..bc5ff1c9 100644 --- a/panel/src/components/layout/sidebar.tsx +++ b/panel/src/components/layout/sidebar.tsx @@ -21,6 +21,7 @@ import { GitBranch, Database, Cpu, + Sparkles, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -33,6 +34,7 @@ const navItems = [ // Work Management { title: "Tasks", href: "/tasks", icon: ListTodo }, { title: "Kanban", href: "/kanban", icon: Kanban }, + { title: "Task Assistant", href: "/prompter", icon: Sparkles }, // Development { title: "Projects", href: "/projects", icon: FolderGit2 }, diff --git a/panel/src/components/prompter/chat-composer.tsx b/panel/src/components/prompter/chat-composer.tsx new file mode 100644 index 00000000..6eb36556 --- /dev/null +++ b/panel/src/components/prompter/chat-composer.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState, useRef, KeyboardEvent } from "react"; +import { Send, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface ChatComposerProps { + onSend: (text: string) => Promise | void; + disabled?: boolean; + isSending?: boolean; + placeholder?: string; +} + +export function ChatComposer({ + onSend, + disabled = false, + isSending = false, + placeholder = "Describe the task you want to create… (Enter to send, Shift+Enter for newline)", +}: ChatComposerProps) { + const [value, setValue] = useState(""); + const textareaRef = useRef(null); + + const isDisabled = disabled || isSending || !value.trim(); + + const handleSend = async () => { + const text = value.trim(); + if (!text || isSending || disabled) return; + setValue(""); + await onSend(text); + // Refocus after send + textareaRef.current?.focus(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + // Shift+Enter falls through to default (inserts newline) + }; + + return ( +
+