[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 <be-dev-2@agents.roboco.dev>
This commit is contained in:
Renzo F
2026-06-07 22:16:56 +02:00
committed by GitHub
co-authored by Backend Developer 2
parent d5cbbf49ee
commit 85ffec86b4
19 changed files with 2866 additions and 724 deletions
@@ -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")
+145
View File
@@ -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")
-182
View File
@@ -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 | "Couldnt draft a task right now. Keep chatting or try again." |
| Confirm & Launch API fails | Banner inside review modal | "We couldnt 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 (7681023px)**: 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
@@ -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: 5200 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` |
-141
View File
@@ -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 dont care about the model.
- Aligns with the Head of Marketing directive: "most users dont know which model drafts better tasks."
---
## Component
`Select` from `panel/src/components/ui/select.tsx`:
```tsx
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="recommended">
Recommended {dynamicLabel}
</SelectItem>
<SelectItem value="claude-sonnet-4">
Claude Sonnet 4 Balanced
</SelectItem>
<SelectItem value="gpt-4o">
GPT-4o Fast
</SelectItem>
<SelectItem value="claude-opus-4">
Claude Opus 4 Deep reasoning
</SelectItem>
</SelectContent>
</Select>
```
---
## 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.
@@ -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 dont 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.
-42
View File
@@ -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).
+8
View File
@@ -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.orchestrator import router as orchestrator_router
from roboco.api.routes.product import router as product_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.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.provider import router as provider_router
from roboco.api.routes.sessions import router as sessions_router from roboco.api.routes.sessions import router as sessions_router
from roboco.api.routes.stream import router as stream_router from roboco.api.routes.stream import router as stream_router
@@ -311,6 +312,13 @@ def create_app() -> FastAPI:
tags=["Providers"], tags=["Providers"],
) )
# Prompter — conversational task drafting assistant
app.include_router(
prompter_router,
prefix=f"{api_prefix}/prompter",
tags=["Prompter"],
)
# Work Sessions # Work Sessions
app.include_router( app.include_router(
work_session_router, work_session_router,
+287
View File
@@ -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]
+11
View File
@@ -167,6 +167,14 @@ async def create_task(
) from None ) from None
assigned_to_uuid = cast("UUID", agent_row.id) 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) service = get_task_service(db)
req = TaskCreateRequest( req = TaskCreateRequest(
title=data.title, title=data.title,
@@ -187,6 +195,9 @@ async def create_task(
task_type=data.task_type, task_type=data.task_type,
project_id=data.project_id, project_id=data.project_id,
product_id=data.product_id, product_id=data.product_id,
# Prompter origin tracking
source=data.source,
confirmed_by_human=data.confirmed_by_human,
) )
task = await service.create(req) task = await service.create(req)
await db.commit() await db.commit()
+232
View File
@@ -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)
+6
View File
@@ -325,6 +325,10 @@ class TaskResponse(BaseModel):
pr_number: int | None = None pr_number: int | None = None
pr_url: str | None = None pr_url: str | None = None
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -671,6 +675,8 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
branch_name=getattr(task, "branch_name", None), branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None), pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None), pr_url=getattr(task, "pr_url", None),
source=getattr(task, "source", "manual"),
confirmed_by_human=getattr(task, "confirmed_by_human", False),
) )
+138
View File
@@ -359,6 +359,14 @@ class TaskTable(Base):
Boolean, nullable=False, default=False 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 # Relationships
creator: Mapped["AgentTable"] = relationship( creator: Mapped["AgentTable"] = relationship(
"AgentTable", foreign_keys=[created_by], lazy="joined" "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_created_at", "created_at"),
Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"), 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"),
)
+18
View File
@@ -265,6 +265,16 @@ class Task(TimestampMixin):
description="True after QA inspects inline diff via claim_review.", 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, # NOTE: Task state mutations should be performed through TaskService,
# not directly on the model. See roboco/services/task.py for: # not directly on the model. See roboco/services/task.py for:
# - claim(), start(), block(), pause(), resume() # - claim(), start(), block(), pause(), resume()
@@ -325,6 +335,10 @@ class TaskCreate(RobocoBase):
project_id: UUID | None = None project_id: UUID | None = None
product_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") @model_validator(mode="after")
def _project_or_product(self) -> "TaskCreate": def _project_or_product(self) -> "TaskCreate":
if self.project_id is None and self.product_id is None: if self.project_id is None and self.product_id is None:
@@ -400,3 +414,7 @@ class TaskCreateRequest:
# Ordering and dependencies # Ordering and dependencies
sequence: int = 0 # Order within siblings (lower = first) sequence: int = 0 # Order within siblings (lower = first)
dependency_ids: list[UUID] = field(default_factory=list) dependency_ids: list[UUID] = field(default_factory=list)
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
+625
View File
@@ -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)
+3
View File
@@ -549,6 +549,9 @@ class TaskService(BaseService):
task_type=req.task_type, task_type=req.task_type,
project_id=req.project_id, project_id=req.project_id,
product_id=req.product_id, product_id=req.product_id,
# Prompter origin tracking
source=req.source,
confirmed_by_human=req.confirmed_by_human,
) )
self.session.add(task) self.session.add(task)
await self.session.flush() await self.session.flush()
+790
View File
@@ -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
+212
View File
@@ -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 == {}
+354
View File
@@ -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]
)