mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Merge master (Prompter #80) into lifecycle-residual-hardening
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { usePrompter } from "@/hooks/use-prompter";
|
||||
import {
|
||||
ChatMessages,
|
||||
ChatComposer,
|
||||
ConfirmDialog,
|
||||
SuccessCard,
|
||||
} from "@/components/prompter";
|
||||
|
||||
export default function PrompterPage() {
|
||||
const {
|
||||
state,
|
||||
messages,
|
||||
isSending,
|
||||
editableDraft,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepChatting,
|
||||
updateDraft,
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching,
|
||||
} = usePrompter();
|
||||
|
||||
const isComposerDisabled =
|
||||
state === "launching" || state === "success";
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3 border-b px-6 py-4">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Task Assistant</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Describe your idea and I'll help you create a structured task
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat area */}
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Success overlay in chat area */}
|
||||
{state === "success" &&
|
||||
createdTaskId &&
|
||||
createdTaskTitle &&
|
||||
createdTaskTeam ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-8 py-8">
|
||||
<div className="w-full max-w-md">
|
||||
<SuccessCard
|
||||
taskId={createdTaskId}
|
||||
taskTitle={createdTaskTitle}
|
||||
team={createdTaskTeam}
|
||||
onStartAnother={startAnother}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatMessages
|
||||
messages={messages}
|
||||
onOpenReview={openReview}
|
||||
onKeepChatting={keepChatting}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<ChatComposer
|
||||
onSend={send}
|
||||
disabled={isComposerDisabled}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog (portal) */}
|
||||
<ConfirmDialog
|
||||
open={state === "review_modal" || state === "launching"}
|
||||
draft={editableDraft}
|
||||
onClose={closeReview}
|
||||
onUpdate={updateDraft}
|
||||
onConfirm={launchTask}
|
||||
isLaunching={isLaunching}
|
||||
isValid={isValidForLaunch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
GitBranch,
|
||||
Database,
|
||||
Cpu,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -33,6 +34,7 @@ const navItems = [
|
||||
// Work Management
|
||||
{ title: "Tasks", href: "/tasks", icon: ListTodo },
|
||||
{ title: "Kanban", href: "/kanban", icon: Kanban },
|
||||
{ title: "Task Assistant", href: "/prompter", icon: Sparkles },
|
||||
|
||||
// Development
|
||||
{ title: "Projects", href: "/projects", icon: FolderGit2 },
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, KeyboardEvent } from "react";
|
||||
import { Send, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface ChatComposerProps {
|
||||
onSend: (text: string) => Promise<void> | void;
|
||||
disabled?: boolean;
|
||||
isSending?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
onSend,
|
||||
disabled = false,
|
||||
isSending = false,
|
||||
placeholder = "Describe the task you want to create… (Enter to send, Shift+Enter for newline)",
|
||||
}: ChatComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const isDisabled = disabled || isSending || !value.trim();
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = value.trim();
|
||||
if (!text || isSending || disabled) return;
|
||||
setValue("");
|
||||
await onSend(text);
|
||||
// Refocus after send
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
// Shift+Enter falls through to default (inserts newline)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 border-t bg-background px-4 py-3">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || isSending}
|
||||
rows={2}
|
||||
className="flex-1 resize-none text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSend}
|
||||
disabled={isDisabled}
|
||||
className="mb-0.5 shrink-0"
|
||||
aria-label="Send message"
|
||||
>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatMessage } from "@/hooks/use-prompter";
|
||||
import { DraftProposalCard } from "./draft-proposal-card";
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: ChatMessage[];
|
||||
onOpenReview: () => void;
|
||||
onKeepChatting: () => void;
|
||||
}
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
onOpenReview,
|
||||
onKeepChatting,
|
||||
}: ChatMessagesProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom whenever messages change
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-center text-muted-foreground px-8">
|
||||
<p className="text-lg font-semibold">What would you like to build?</p>
|
||||
<p className="text-sm max-w-md">
|
||||
Describe your task idea. I'll help you refine it into a structured task with acceptance
|
||||
criteria ready to hand off to the team.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="max-w-[70%] rounded-2xl rounded-tr-sm bg-primary px-4 py-3 text-sm text-primary-foreground">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === "error") {
|
||||
return (
|
||||
<div key={msg.id} className="flex justify-start">
|
||||
<div className="flex max-w-[70%] items-start gap-2 rounded-2xl rounded-tl-sm border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{msg.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
return (
|
||||
<div key={msg.id} className="flex flex-col gap-2">
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] rounded-2xl rounded-tl-sm bg-muted px-4 py-3 text-sm",
|
||||
msg.draft && "max-w-[85%]"
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline draft proposal card when LLM offers a draft */}
|
||||
{msg.draft && (
|
||||
<div className="flex justify-start">
|
||||
<div className="w-full max-w-[85%]">
|
||||
<DraftProposalCard
|
||||
draft={msg.draft}
|
||||
onKeepChatting={onKeepChatting}
|
||||
onOpenReview={onOpenReview}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Scroll anchor */}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { AcceptanceCriteriaEditor } from "@/components/tasks/acceptance-criteria-editor";
|
||||
import { MarkdownEditor } from "@/components/tasks/markdown-editor";
|
||||
import { Team, TaskType, Complexity } from "@/types";
|
||||
import type { EditableDraft } from "@/hooks/use-prompter";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
draft: EditableDraft;
|
||||
onClose: () => void;
|
||||
onUpdate: (updates: Partial<EditableDraft>) => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
isLaunching: boolean;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
const WARNING_BANNER_ID = "prompter-warning-banner";
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
draft,
|
||||
onClose,
|
||||
onUpdate,
|
||||
onConfirm,
|
||||
isLaunching,
|
||||
isValid,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o && !isLaunching) onClose(); }}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review & Confirm Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Warning banner */}
|
||||
<div
|
||||
id={WARNING_BANNER_ID}
|
||||
className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
This will create a real task and notify the team. It cannot be undone from this screen.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Form fields — NOT wrapped in a <form> to prevent Enter-key submission bypass */}
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="prompter-title">
|
||||
Title <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="prompter-title"
|
||||
value={draft.title}
|
||||
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||
placeholder="Task title"
|
||||
disabled={isLaunching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<MarkdownEditor
|
||||
label="Description"
|
||||
value={draft.description}
|
||||
onChange={(v) => onUpdate({ description: v })}
|
||||
placeholder="Describe what needs to be done…"
|
||||
required
|
||||
minLength={20}
|
||||
/>
|
||||
|
||||
{/* Acceptance Criteria */}
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={draft.acceptance_criteria}
|
||||
onChange={(criteria) => onUpdate({ acceptance_criteria: criteria })}
|
||||
/>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Team */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>
|
||||
Team <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={draft.team}
|
||||
onValueChange={(v) => onUpdate({ team: v as Team })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Team).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.replace("_", " ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Priority</Label>
|
||||
<Select
|
||||
value={String(draft.priority)}
|
||||
onValueChange={(v) => onUpdate({ priority: Number(v) })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">Low</SelectItem>
|
||||
<SelectItem value="1">Medium</SelectItem>
|
||||
<SelectItem value="2">High</SelectItem>
|
||||
<SelectItem value="3">Urgent</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Task Type */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Type</Label>
|
||||
<Select
|
||||
value={draft.task_type || ""}
|
||||
onValueChange={(v) => onUpdate({ task_type: v as TaskType })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(TaskType).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Complexity */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Estimated Complexity</Label>
|
||||
<Select
|
||||
value={draft.estimated_complexity || ""}
|
||||
onValueChange={(v) => onUpdate({ estimated_complexity: v as Complexity })}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Select complexity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(Complexity).map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLaunching}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={!isValid || isLaunching}
|
||||
aria-describedby={WARNING_BANNER_ID}
|
||||
>
|
||||
{isLaunching ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating…
|
||||
</>
|
||||
) : (
|
||||
"Confirm & Launch"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { MessageCircle, ClipboardCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { DraftProposal } from "@/lib/api/prompter";
|
||||
|
||||
interface DraftProposalCardProps {
|
||||
draft: DraftProposal;
|
||||
onKeepChatting: () => void;
|
||||
onOpenReview: () => void;
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<number, string> = {
|
||||
0: "Low",
|
||||
1: "Medium",
|
||||
2: "High",
|
||||
3: "Urgent",
|
||||
};
|
||||
|
||||
export function DraftProposalCard({
|
||||
draft,
|
||||
onKeepChatting,
|
||||
onOpenReview,
|
||||
}: DraftProposalCardProps) {
|
||||
const priorityLabel = PRIORITY_LABELS[draft.priority ?? 2] ?? "High";
|
||||
|
||||
return (
|
||||
<Card className="border-primary/30 bg-primary/5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold leading-tight">
|
||||
{draft.title}
|
||||
</CardTitle>
|
||||
<div className="flex flex-wrap gap-1 shrink-0">
|
||||
{draft.team && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{draft.team}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{priorityLabel}
|
||||
</Badge>
|
||||
{draft.task_type && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{draft.task_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3 space-y-3">
|
||||
{/* Description excerpt */}
|
||||
{draft.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-3">
|
||||
{draft.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Acceptance criteria */}
|
||||
{draft.acceptance_criteria.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Acceptance criteria ({draft.acceptance_criteria.length})
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{draft.acceptance_criteria.slice(0, 4).map((criterion, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-0.5 h-3 w-3 shrink-0 rounded-full border border-primary/50 flex items-center justify-center">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary/50" />
|
||||
</span>
|
||||
<span className="text-foreground line-clamp-2">{criterion}</span>
|
||||
</li>
|
||||
))}
|
||||
{draft.acceptance_criteria.length > 4 && (
|
||||
<li className="text-xs text-muted-foreground pl-5">
|
||||
+{draft.acceptance_criteria.length - 4} more…
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onKeepChatting}
|
||||
>
|
||||
<MessageCircle className="mr-1.5 h-3.5 w-3.5" />
|
||||
Keep Chatting
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={onOpenReview}
|
||||
>
|
||||
<ClipboardCheck className="mr-1.5 h-3.5 w-3.5" />
|
||||
Review & Confirm
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ChatMessages } from "./chat-messages";
|
||||
export { ChatComposer } from "./chat-composer";
|
||||
export { DraftProposalCard } from "./draft-proposal-card";
|
||||
export { ConfirmDialog } from "./confirm-dialog";
|
||||
export { SuccessCard } from "./success-card";
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { Team } from "@/types";
|
||||
|
||||
interface SuccessCardProps {
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
team: Team;
|
||||
onStartAnother: () => void;
|
||||
}
|
||||
|
||||
export function SuccessCard({
|
||||
taskId,
|
||||
taskTitle,
|
||||
team,
|
||||
onStartAnother,
|
||||
}: SuccessCardProps) {
|
||||
return (
|
||||
<Card className="border-green-500/30 bg-green-500/5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<CardTitle className="text-sm font-semibold text-green-700 dark:text-green-400">
|
||||
Task Created Successfully
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pb-3 space-y-2">
|
||||
<p className="text-sm font-medium">{taskTitle}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{team.replace("_", " ")}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">ID: {taskId.slice(0, 8)}…</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="gap-2 pt-0">
|
||||
<Button variant="outline" size="sm" asChild className="flex-1">
|
||||
<Link href={`/tasks/${taskId}`} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
View Task
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" className="flex-1" onClick={onStartAnother}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Start Another
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { prompterApi, type DraftProposal } from "@/lib/api/prompter";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useCreateTask } from "@/hooks/use-tasks";
|
||||
import type { TaskCreate, Team, TaskType, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PrompterState =
|
||||
| "empty"
|
||||
| "chatting"
|
||||
| "draft_preview"
|
||||
| "review_modal"
|
||||
| "launching"
|
||||
| "success";
|
||||
|
||||
export type MessageRole = "user" | "assistant" | "error";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
/** Present only on an assistant message that contains a draft proposal */
|
||||
draft?: DraftProposal;
|
||||
}
|
||||
|
||||
export interface EditableDraft {
|
||||
title: string;
|
||||
description: string;
|
||||
acceptance_criteria: string[];
|
||||
team: Team | "";
|
||||
priority: number;
|
||||
task_type: TaskType | "";
|
||||
estimated_complexity: Complexity | "";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function usePrompter() {
|
||||
const createTask = useCreateTask();
|
||||
|
||||
const [state, setState] = useState<PrompterState>("empty");
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [createdTaskId, setCreatedTaskId] = useState<string | null>(null);
|
||||
const [createdTaskTitle, setCreatedTaskTitle] = useState<string | null>(null);
|
||||
const [createdTaskTeam, setCreatedTaskTeam] = useState<Team | null>(null);
|
||||
|
||||
/** Draft as shown in the draft-preview card */
|
||||
const [draftProposal, setDraftProposal] = useState<DraftProposal | null>(null);
|
||||
|
||||
/** Editable copy used in the confirmation dialog */
|
||||
const [editableDraft, setEditableDraft] = useState<EditableDraft>({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
|
||||
// Keep a ref to sessionId for callbacks to avoid stale closures
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const addMessage = useCallback((msg: Omit<ChatMessage, "id">) => {
|
||||
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
setMessages((prev) => [...prev, { ...msg, id }]);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Send a chat message
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
if (!text.trim() || isSending) return;
|
||||
|
||||
setIsSending(true);
|
||||
setState("chatting");
|
||||
|
||||
// Add user message to chat
|
||||
addMessage({ role: "user", content: text.trim() });
|
||||
|
||||
try {
|
||||
let sid = sessionIdRef.current;
|
||||
|
||||
// Create session on first message
|
||||
if (!sid) {
|
||||
const { session_id } = await prompterApi.createSession();
|
||||
sid = session_id;
|
||||
sessionIdRef.current = sid;
|
||||
setSessionId(sid);
|
||||
}
|
||||
|
||||
// Send message and get reply
|
||||
const response = await prompterApi.sendMessage(sid, text.trim());
|
||||
|
||||
if (response.draft) {
|
||||
// LLM produced a draft — add assistant message with embedded draft
|
||||
addMessage({
|
||||
role: "assistant",
|
||||
content: response.reply,
|
||||
draft: response.draft,
|
||||
});
|
||||
setDraftProposal(response.draft);
|
||||
setEditableDraft({
|
||||
title: response.draft.title,
|
||||
description: response.draft.description,
|
||||
acceptance_criteria: response.draft.acceptance_criteria,
|
||||
team: response.draft.team ?? "",
|
||||
priority: response.draft.priority ?? 2,
|
||||
task_type: response.draft.task_type ?? "",
|
||||
estimated_complexity: response.draft.estimated_complexity ?? "",
|
||||
});
|
||||
setState("draft_preview");
|
||||
} else {
|
||||
// Plain text reply
|
||||
addMessage({ role: "assistant", content: response.reply });
|
||||
setState("chatting");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
addMessage({
|
||||
role: "error",
|
||||
content: msg,
|
||||
});
|
||||
setState("chatting");
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[isSending, addMessage]
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Review & Confirm actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const openReview = useCallback(() => {
|
||||
setState("review_modal");
|
||||
}, []);
|
||||
|
||||
const closeReview = useCallback(() => {
|
||||
setState("draft_preview");
|
||||
}, []);
|
||||
|
||||
const keepChatting = useCallback(() => {
|
||||
setState("chatting");
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((updates: Partial<EditableDraft>) => {
|
||||
setEditableDraft((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const isValidForLaunch = useCallback((): boolean => {
|
||||
return (
|
||||
editableDraft.title.trim().length > 0 &&
|
||||
editableDraft.description.trim().length >= 20 &&
|
||||
editableDraft.acceptance_criteria.length > 0 &&
|
||||
editableDraft.team !== ""
|
||||
);
|
||||
}, [editableDraft]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Launch (create task)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const launchTask = useCallback(async () => {
|
||||
if (!isValidForLaunch()) return;
|
||||
|
||||
setState("launching");
|
||||
|
||||
const payload: TaskCreate = {
|
||||
title: editableDraft.title.trim(),
|
||||
description: editableDraft.description.trim(),
|
||||
acceptance_criteria: editableDraft.acceptance_criteria,
|
||||
team: editableDraft.team as Team,
|
||||
priority: editableDraft.priority,
|
||||
...(editableDraft.task_type ? { task_type: editableDraft.task_type as TaskType } : {}),
|
||||
...(editableDraft.estimated_complexity
|
||||
? { estimated_complexity: editableDraft.estimated_complexity as Complexity }
|
||||
: {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const task = await createTask.mutateAsync(payload);
|
||||
setCreatedTaskId(task.id);
|
||||
setCreatedTaskTitle(task.title);
|
||||
setCreatedTaskTeam(task.team as Team);
|
||||
toast.success("Task created successfully!");
|
||||
setState("success");
|
||||
} catch (err) {
|
||||
const msg = getErrorMessage(err);
|
||||
toast.error(`Failed to create task: ${msg}`);
|
||||
setState("review_modal");
|
||||
}
|
||||
}, [editableDraft, isValidForLaunch, createTask]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reset to start another conversation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const startAnother = useCallback(() => {
|
||||
setMessages([]);
|
||||
setSessionId(null);
|
||||
sessionIdRef.current = null;
|
||||
setDraftProposal(null);
|
||||
setEditableDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
acceptance_criteria: [],
|
||||
team: "",
|
||||
priority: 2,
|
||||
task_type: "",
|
||||
estimated_complexity: "",
|
||||
});
|
||||
setCreatedTaskId(null);
|
||||
setCreatedTaskTitle(null);
|
||||
setCreatedTaskTeam(null);
|
||||
setState("empty");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
messages,
|
||||
sessionId,
|
||||
isSending,
|
||||
draftProposal,
|
||||
editableDraft,
|
||||
createdTaskId,
|
||||
createdTaskTitle,
|
||||
createdTaskTeam,
|
||||
|
||||
// Actions
|
||||
send,
|
||||
openReview,
|
||||
closeReview,
|
||||
keepChatting,
|
||||
updateDraft,
|
||||
isValidForLaunch,
|
||||
launchTask,
|
||||
startAnother,
|
||||
isLaunching: createTask.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import api from "./client";
|
||||
import type { Team, TaskType, Complexity } from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DraftProposal {
|
||||
title: string;
|
||||
description: string;
|
||||
acceptance_criteria: string[];
|
||||
team: Team;
|
||||
priority?: number;
|
||||
task_type?: TaskType;
|
||||
estimated_complexity?: Complexity;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
reply: string;
|
||||
draft?: DraftProposal | null;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
export interface CreateSessionResponse {
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const prompterApi = {
|
||||
/**
|
||||
* Create a new prompter session, returning a session ID.
|
||||
*/
|
||||
createSession: async (): Promise<CreateSessionResponse> => {
|
||||
const { data } = await api.post<CreateSessionResponse>("/prompter/sessions");
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a chat message in an existing session.
|
||||
* Returns the assistant reply and, if ready, a draft task proposal.
|
||||
*/
|
||||
sendMessage: async (
|
||||
sessionId: string,
|
||||
message: string
|
||||
): Promise<ChatResponse> => {
|
||||
const { data } = await api.post<ChatResponse>(
|
||||
`/prompter/sessions/${sessionId}/chat`,
|
||||
{ message }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the current draft for a session (if the LLM has produced one).
|
||||
*/
|
||||
getDraft: async (sessionId: string): Promise<DraftProposal | null> => {
|
||||
const { data } = await api.get<{ draft: DraftProposal | null }>(
|
||||
`/prompter/sessions/${sessionId}/draft`
|
||||
);
|
||||
return data.draft;
|
||||
},
|
||||
};
|
||||
@@ -30,6 +30,7 @@ from roboco.api.routes.optimal import router as optimal_router
|
||||
from roboco.api.routes.orchestrator import router as orchestrator_router
|
||||
from roboco.api.routes.product import router as product_router
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.api.routes.prompter import router as prompter_router
|
||||
from roboco.api.routes.provider import router as provider_router
|
||||
from roboco.api.routes.sessions import router as sessions_router
|
||||
from roboco.api.routes.stream import router as stream_router
|
||||
@@ -311,6 +312,13 @@ def create_app() -> FastAPI:
|
||||
tags=["Providers"],
|
||||
)
|
||||
|
||||
# Prompter — conversational task drafting assistant
|
||||
app.include_router(
|
||||
prompter_router,
|
||||
prefix=f"{api_prefix}/prompter",
|
||||
tags=["Prompter"],
|
||||
)
|
||||
|
||||
# Work Sessions
|
||||
app.include_router(
|
||||
work_session_router,
|
||||
|
||||
@@ -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]
|
||||
@@ -167,6 +167,14 @@ async def create_task(
|
||||
) from None
|
||||
assigned_to_uuid = cast("UUID", agent_row.id)
|
||||
|
||||
# Prompter origin tracking: enforce human confirmation gate so
|
||||
# LLM-drafted tasks cannot bypass review and enter the workflow.
|
||||
if data.source == "prompter" and not data.confirmed_by_human:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Prompter-originated tasks require human confirmation",
|
||||
)
|
||||
|
||||
service = get_task_service(db)
|
||||
req = TaskCreateRequest(
|
||||
title=data.title,
|
||||
@@ -187,6 +195,9 @@ async def create_task(
|
||||
task_type=data.task_type,
|
||||
project_id=data.project_id,
|
||||
product_id=data.product_id,
|
||||
# Prompter origin tracking
|
||||
source=data.source,
|
||||
confirmed_by_human=data.confirmed_by_human,
|
||||
)
|
||||
task = await service.create(req)
|
||||
await db.commit()
|
||||
|
||||
@@ -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)
|
||||
@@ -330,6 +330,10 @@ class TaskResponse(BaseModel):
|
||||
pr_number: int | None = None
|
||||
pr_url: str | None = None
|
||||
|
||||
# Prompter origin tracking
|
||||
source: str = "manual"
|
||||
confirmed_by_human: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -676,6 +680,8 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
|
||||
branch_name=getattr(task, "branch_name", None),
|
||||
pr_number=getattr(task, "pr_number", None),
|
||||
pr_url=getattr(task, "pr_url", None),
|
||||
source=getattr(task, "source", "manual"),
|
||||
confirmed_by_human=getattr(task, "confirmed_by_human", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -359,6 +359,14 @@ class TaskTable(Base):
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
# Prompter origin tracking: tasks drafted by the Prompter LLM assistant
|
||||
# require human confirmation before entering the workflow. The task creation
|
||||
# route enforces that prompter-originated tasks cannot bypass human review.
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False, default="manual")
|
||||
confirmed_by_human: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
creator: Mapped["AgentTable"] = relationship(
|
||||
"AgentTable", foreign_keys=[created_by], lazy="joined"
|
||||
@@ -1807,3 +1815,133 @@ class GatewayTriggerTable(Base):
|
||||
Index("ix_gateway_triggers_created_at", "created_at"),
|
||||
Index("ix_gateway_triggers_kind_decision", "trigger_kind", "decision"),
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PROMPTER TABLES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class PrompterSessionTable(Base):
|
||||
"""A Prompter conversation session owned by an agent."""
|
||||
|
||||
__tablename__ = "prompter_sessions"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
agent_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
Enum(
|
||||
"active",
|
||||
"draft_ready",
|
||||
"confirmed",
|
||||
"abandoned",
|
||||
name="promptersessionstatus",
|
||||
),
|
||||
nullable=False,
|
||||
default="active",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
messages: Mapped[list["PrompterMessageTable"]] = relationship(
|
||||
"PrompterMessageTable",
|
||||
back_populates="session",
|
||||
order_by="PrompterMessageTable.created_at",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
drafts: Mapped[list["TaskDraftTable"]] = relationship(
|
||||
"TaskDraftTable",
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_prompter_sessions_agent_id", "agent_id"),
|
||||
Index("ix_prompter_sessions_status", "status"),
|
||||
)
|
||||
|
||||
|
||||
class PrompterMessageTable(Base):
|
||||
"""A single message turn within a Prompter conversation session."""
|
||||
|
||||
__tablename__ = "prompter_messages"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
session_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
Enum("user", "assistant", "system", name="promptermessagerole"),
|
||||
nullable=False,
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["PrompterSessionTable"] = relationship(
|
||||
"PrompterSessionTable", back_populates="messages"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_prompter_messages_session_id", "session_id"),
|
||||
Index("ix_prompter_messages_session_created", "session_id", "created_at"),
|
||||
)
|
||||
|
||||
|
||||
class TaskDraftTable(Base):
|
||||
"""A structured task draft extracted from a Prompter conversation."""
|
||||
|
||||
__tablename__ = "task_drafts"
|
||||
|
||||
id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid4
|
||||
)
|
||||
session_id: Mapped[UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("prompter_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
draft_data: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
task_id: Mapped[UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=lambda: datetime.now(UTC), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
session: Mapped["PrompterSessionTable"] = relationship(
|
||||
"PrompterSessionTable", back_populates="drafts"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_task_drafts_session_id", "session_id"),
|
||||
Index("ix_task_drafts_task_id", "task_id"),
|
||||
)
|
||||
|
||||
@@ -265,6 +265,16 @@ class Task(TimestampMixin):
|
||||
description="True after QA inspects inline diff via claim_review.",
|
||||
)
|
||||
|
||||
# Prompter origin tracking
|
||||
source: str = Field(
|
||||
default="manual",
|
||||
description="Origin of the task: 'manual', 'prompter', etc.",
|
||||
)
|
||||
confirmed_by_human: bool = Field(
|
||||
default=False,
|
||||
description="Whether a human has confirmed this prompter-originated task.",
|
||||
)
|
||||
|
||||
# NOTE: Task state mutations should be performed through TaskService,
|
||||
# not directly on the model. See roboco/services/task.py for:
|
||||
# - claim(), start(), block(), pause(), resume()
|
||||
@@ -325,6 +335,10 @@ class TaskCreate(RobocoBase):
|
||||
project_id: UUID | None = None
|
||||
product_id: UUID | None = None
|
||||
|
||||
# Prompter origin tracking
|
||||
source: str = Field(default="manual")
|
||||
confirmed_by_human: bool = Field(default=False)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _project_or_product(self) -> "TaskCreate":
|
||||
if self.project_id is None and self.product_id is None:
|
||||
@@ -400,3 +414,7 @@ class TaskCreateRequest:
|
||||
# Ordering and dependencies
|
||||
sequence: int = 0 # Order within siblings (lower = first)
|
||||
dependency_ids: list[UUID] = field(default_factory=list)
|
||||
|
||||
# Prompter origin tracking
|
||||
source: str = "manual"
|
||||
confirmed_by_human: bool = False
|
||||
|
||||
@@ -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)
|
||||
@@ -568,6 +568,9 @@ class TaskService(BaseService):
|
||||
task_type=req.task_type,
|
||||
project_id=req.project_id,
|
||||
product_id=req.product_id,
|
||||
# Prompter origin tracking
|
||||
source=req.source,
|
||||
confirmed_by_human=req.confirmed_by_human,
|
||||
)
|
||||
self.session.add(task)
|
||||
await self.session.flush()
|
||||
|
||||
@@ -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
|
||||
@@ -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 == {}
|
||||
@@ -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]
|
||||
)
|
||||
Reference in New Issue
Block a user