[529f579a] Implement Prompter chat endpoint and structured task drafting (#76) (#77)

* [529f579a] feat(prompter): add PrompterService with chat and draft generation endpoints

* [529f579a] feat(prompter): add PrompterService, schemas, routes, and integration tests

* [529f579a] feat(prompter): implement session-based prompter chat endpoints with DB persistence

Add full session-based Prompter chat system with:
- Alembic migration 024 creating prompter_sessions, prompter_messages, and task_drafts tables with proper foreign keys, indexes, and enum columns
- Three new SQLAlchemy ORM table classes in roboco/db/tables.py
- Pydantic schemas: PrompterSessionCreateRequest, PrompterMessageRequest, PrompterSessionResponse, PrompterMessageResponse, TaskDraftResponse, TaskConfirmRequest
- Four new session-based FastAPI routes: POST /sessions, POST /sessions/{id}/messages, GET /sessions/{id}/draft, POST /sessions/{id}/confirm
- PrompterService with DB-backed session, message, and draft persistence; LLM-driven draft generation; ConfirmOverrides dataclass to stay under PLR0913
- Legacy stateless /chat and /draft endpoints retained for backward compatibility
- Unit tests for schemas (test_schemas_prompter.py), service pure functions and DB logic (test_prompter.py) with mocked LLM calls
- Integration tests for full happy path and legacy endpoints (test_prompter_routes.py)
- All ruff format, ruff check, mypy (changed files), and pytest checks passing

* [529f579a] fix(prompter): correct test assertion for confirmed_at field nesting

The test test_get_draft_generates_from_conversation incorrectly
accessed body['draft']['confirmed_at'] but confirmed_at is a field
on the outer TaskDraftResponse, not on the nested PrompterDraftTask.
Fixed to body['confirmed_at'].

---------

Co-authored-by: Backend Developer 2 <be-dev-2@agents.roboco.dev>
This commit is contained in:
Renzo F
2026-06-07 22:16:56 +02:00
committed by GitHub
co-authored by Backend Developer 2
parent d5cbbf49ee
commit 85ffec86b4
19 changed files with 2866 additions and 724 deletions
+8
View File
@@ -30,6 +30,7 @@ from roboco.api.routes.optimal import router as optimal_router
from roboco.api.routes.orchestrator import router as orchestrator_router
from roboco.api.routes.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,
+287
View File
@@ -0,0 +1,287 @@
"""
Prompter API Routes
Session-based conversational assistant endpoints for drafting tasks:
- POST /api/prompter/sessions : create a new session
- POST /api/prompter/sessions/{id}/messages : send user message, get AI reply
- GET /api/prompter/sessions/{id}/draft : get structured task draft
- POST /api/prompter/sessions/{id}/confirm : confirm draft → create real task
Legacy stateless endpoints (retained for backward compatibility):
- POST /api/prompter/chat : back-and-forth conversation (stateless)
- POST /api/prompter/draft : structured task draft generation (stateless)
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from roboco.api.deps import CurrentAgentContext, DbSession
from roboco.api.schemas.prompter import (
ChatMessage,
PrompterChatRequest,
PrompterChatResponse,
PrompterDraftRequest,
PrompterDraftResponse,
PrompterDraftTask,
PrompterMessageRequest,
PrompterMessageResponse,
PrompterSessionCreateRequest,
PrompterSessionResponse,
TaskConfirmRequest,
TaskDraftResponse,
)
from roboco.services.base import NotFoundError, ServiceError, ValidationError
from roboco.services.prompter import ConfirmOverrides, get_prompter_service
router = APIRouter()
def _translate_error(e: ServiceError) -> HTTPException:
"""Service errors → HTTP status."""
if isinstance(e, NotFoundError):
return HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": "not_found", "message": e.message},
)
if isinstance(e, ValidationError):
return HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": "validation_error",
"message": e.message,
"field": e.field,
},
)
return HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "internal_error", "message": e.message},
)
# =============================================================================
# SESSION-BASED ENDPOINTS
# =============================================================================
@router.post(
"/sessions",
response_model=PrompterSessionResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_session(
data: PrompterSessionCreateRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> PrompterSessionResponse:
"""Create a new Prompter conversation session linked to the authenticated agent."""
service = get_prompter_service(db)
try:
session = await service.create_session(
agent_id=agent.agent_id,
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return PrompterSessionResponse(
id=session.id, # type: ignore[arg-type]
agent_id=session.agent_id, # type: ignore[arg-type]
status=session.status,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.post(
"/sessions/{session_id}/messages",
response_model=list[PrompterMessageResponse],
)
async def send_message(
session_id: UUID,
data: PrompterMessageRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> list[PrompterMessageResponse]:
"""
Accept a user message, append it and an AI assistant response to the
conversation, and return the updated message list.
"""
service = get_prompter_service(db)
try:
messages = await service.send_message(
session_id=session_id,
agent_id=agent.agent_id,
content=data.content,
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return [
PrompterMessageResponse(
id=msg.id, # type: ignore[arg-type]
session_id=msg.session_id, # type: ignore[arg-type]
role=msg.role,
content=msg.content,
created_at=msg.created_at,
)
for msg in messages
]
@router.get(
"/sessions/{session_id}/draft",
response_model=TaskDraftResponse,
)
async def get_draft(
session_id: UUID,
db: DbSession,
agent: CurrentAgentContext,
) -> TaskDraftResponse:
"""
Return a structured task draft extracted from conversation history via LLM.
The draft contains: title, description, acceptance_criteria, team,
task_type, nature, and estimated_complexity.
"""
service = get_prompter_service(db)
try:
draft_record = await service.get_or_generate_draft(
session_id=session_id,
agent_id=agent.agent_id,
)
except ServiceError as e:
raise _translate_error(e) from e
# Parse the stored draft_data into PrompterDraftTask for validation
try:
draft_task = PrompterDraftTask(**draft_record.draft_data)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": "draft_schema_error",
"message": f"Stored draft did not match schema: {exc}",
"raw_draft": draft_record.draft_data,
},
) from exc
return TaskDraftResponse(
id=draft_record.id, # type: ignore[arg-type]
session_id=draft_record.session_id, # type: ignore[arg-type]
draft=draft_task,
confirmed_at=draft_record.confirmed_at,
task_id=draft_record.task_id, # type: ignore[arg-type]
created_at=draft_record.created_at,
)
@router.post(
"/sessions/{session_id}/confirm",
response_model=dict,
status_code=status.HTTP_201_CREATED,
)
async def confirm_draft(
session_id: UUID,
data: TaskConfirmRequest,
db: DbSession,
agent: CurrentAgentContext,
) -> dict:
"""
Validate the draft and create a real Task using the existing TaskService.
Returns the created task ID.
"""
service = get_prompter_service(db)
try:
task_id = await service.confirm_draft(
session_id=session_id,
agent_id=agent.agent_id,
confirm_overrides=ConfirmOverrides(
project_id=data.project_id,
product_id=data.product_id,
assigned_to=data.assigned_to,
extra=data.overrides,
),
)
except ServiceError as e:
raise _translate_error(e) from e
return {"task_id": str(task_id)}
# =============================================================================
# LEGACY STATELESS ENDPOINTS (backward compatibility)
# =============================================================================
@router.post("/chat", response_model=PrompterChatResponse)
async def prompter_chat(
data: PrompterChatRequest,
_agent: CurrentAgentContext,
) -> PrompterChatResponse:
"""
Continue a Prompter conversation (stateless).
The frontend sends the full conversation history (including the new user
message). The assistant replies, optionally signalling that enough context
has been gathered to generate a draft (`draft_ready=True`).
"""
service = get_prompter_service()
try:
result = await service.chat(
messages=[msg.model_dump() for msg in data.messages],
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
return PrompterChatResponse(
message=result["message"],
draft_ready=result["draft_ready"],
)
@router.post("/draft", response_model=PrompterDraftResponse)
async def prompter_draft(
data: PrompterDraftRequest,
_agent: CurrentAgentContext,
) -> PrompterDraftResponse:
"""
Generate a structured task draft from conversation context (stateless).
The frontend sends the full conversation history. The backend calls the
LLM to produce a JSON draft conforming to the TaskCreate schema.
"""
service = get_prompter_service()
try:
result = await service.draft(
messages=[msg.model_dump() for msg in data.messages],
context=data.context,
)
except ServiceError as e:
raise _translate_error(e) from e
draft_raw = result["draft"]
try:
draft = PrompterDraftTask(**draft_raw)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"error": "draft_schema_error",
"message": f"Generated draft did not match schema: {e}",
"raw_draft": draft_raw,
},
) from e
return PrompterDraftResponse(
draft=draft,
reasoning=result["reasoning"],
)
def _messages_to_dicts(messages: list[ChatMessage]) -> list[dict[str, str]]:
"""Convert ChatMessage list to dict list (internal helper)."""
return [msg.model_dump() for msg in messages]
+11
View File
@@ -167,6 +167,14 @@ async def create_task(
) from None
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()
+232
View File
@@ -0,0 +1,232 @@
"""
Prompter API Schemas
Request/response models for the conversational Prompter assistant
that helps users draft tasks through natural language.
Includes both the session-based schemas (for the DB-persisted approach)
and the legacy stateless schemas retained for backward compatibility.
"""
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from roboco.models.base import (
Complexity,
TaskNature,
TaskType,
Team,
)
# =============================================================================
# SHARED MESSAGE SCHEMA
# =============================================================================
class ChatMessage(BaseModel):
"""A single message in the Prompter conversation."""
role: str = Field(..., description="One of: user, assistant, system")
content: str = Field(..., min_length=1, description="Message text")
@field_validator("role")
@classmethod
def _valid_role(cls, v: str) -> str:
if v not in {"user", "assistant", "system"}:
raise ValueError("role must be one of: user, assistant, system")
return v
# =============================================================================
# SESSION-BASED SCHEMAS (acceptance-criteria-required names)
# =============================================================================
class PrompterSessionCreateRequest(BaseModel):
"""Request body for POST /api/prompter/sessions."""
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional bootstrap context (project_id, team, etc.)",
)
class PrompterSessionResponse(BaseModel):
"""Response for session creation and retrieval."""
id: UUID
agent_id: UUID
status: str
created_at: datetime
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
class PrompterMessageRequest(BaseModel):
"""Request body for POST /api/prompter/sessions/{id}/messages."""
content: str = Field(..., min_length=1, description="The user's message text")
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional per-turn context overrides",
)
class PrompterMessageResponse(BaseModel):
"""A single message record returned to the client."""
id: UUID
session_id: UUID
role: str
content: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class TaskConfirmRequest(BaseModel):
"""Request body for POST /api/prompter/sessions/{id}/confirm.
Allows the frontend to pass overrides that should be applied
to the draft before the real task is created.
"""
project_id: UUID | None = Field(
default=None,
description="Override project_id from the draft (required if draft omits it)",
)
product_id: UUID | None = Field(
default=None,
description="Override product_id from the draft",
)
assigned_to: str | None = Field(
default=None,
description="Agent slug or UUID to assign the task to",
)
overrides: dict[str, Any] = Field(
default_factory=dict,
description="Additional fields to override in the draft before task creation",
)
# =============================================================================
# DRAFT TASK SCHEMA (shared between session and legacy paths)
# =============================================================================
class PrompterDraftTask(BaseModel):
"""A task draft produced by the Prompter.
Mirrors TaskCreate fields so the frontend can POST /api/tasks
with confirmed_by_human=True after human review.
"""
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=20)
acceptance_criteria: list[str] = Field(..., min_length=1)
team: Team = Field(...)
priority: int = Field(default=2, ge=0, le=3)
task_type: TaskType = Field(...)
nature: TaskNature = Field(...)
estimated_complexity: Complexity = Field(...)
project_id: str | None = Field(
default=None,
description=(
"Project UUID as string; exactly one of project_id or "
"product_id must be set"
),
)
product_id: str | None = Field(
default=None,
description=(
"Product UUID as string; exactly one of project_id or "
"product_id must be set"
),
)
assigned_to: str | None = Field(
default=None,
description="Agent slug or UUID to assign the task to",
)
target_date: str | None = Field(
default=None,
description="ISO-8601 target completion date",
)
# Provenance — always set by the prompter backend
source: str = "prompter"
confirmed_by_human: bool = False
class TaskDraftResponse(BaseModel):
"""Response for GET /api/prompter/sessions/{id}/draft."""
id: UUID
session_id: UUID
draft: PrompterDraftTask
confirmed_at: datetime | None = None
task_id: UUID | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# =============================================================================
# LEGACY STATELESS SCHEMAS (retained for backward compatibility)
# =============================================================================
class PrompterChatRequest(BaseModel):
"""Request to continue a Prompter conversation (stateless)."""
messages: list[ChatMessage] = Field(
...,
min_length=1,
description="Conversation history including the new user message",
)
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional context (project_id, team, prior drafts, etc.)",
)
class PrompterChatResponse(BaseModel):
"""Response from the Prompter chat endpoint (stateless)."""
message: str = Field(..., description="Assistant's reply")
conversation_id: str | None = Field(
default=None, description="Client-managed conversation identifier"
)
draft_ready: bool = Field(
default=False,
description=(
"True when the assistant believes enough context exists to draft a task"
),
)
class PrompterDraftRequest(BaseModel):
"""Request to generate a task draft from conversation context (stateless)."""
messages: list[ChatMessage] = Field(
..., min_length=1, description="Full conversation used as drafting context"
)
context: dict[str, Any] = Field(
default_factory=dict,
description="Optional overrides (project_id, team, assigned_to, etc.)",
)
class PrompterDraftResponse(BaseModel):
"""Response from the Prompter draft endpoint (stateless)."""
draft: PrompterDraftTask = Field(..., description="Structured task draft")
reasoning: str = Field(
default="",
description="Assistant's explanation of how the draft was derived",
)
model_config = ConfigDict(from_attributes=True)
+6
View File
@@ -325,6 +325,10 @@ class TaskResponse(BaseModel):
pr_number: int | None = None
pr_url: str | None = None
# Prompter origin tracking
source: str = "manual"
confirmed_by_human: bool = False
model_config = ConfigDict(from_attributes=True)
@@ -671,6 +675,8 @@ def task_to_response(task: "TaskTable") -> TaskResponse:
branch_name=getattr(task, "branch_name", None),
pr_number=getattr(task, "pr_number", None),
pr_url=getattr(task, "pr_url", None),
source=getattr(task, "source", "manual"),
confirmed_by_human=getattr(task, "confirmed_by_human", False),
)
+138
View File
@@ -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"),
)
+18
View File
@@ -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
+625
View File
@@ -0,0 +1,625 @@
"""
Prompter Service
Conversational LLM assistant that helps users draft tasks.
Uses Anthropic Claude for natural-language interaction and
structured JSON draft generation.
Provides both a session-based approach (DB-persisted) and a
legacy stateless interface for backward compatibility.
"""
from __future__ import annotations
import contextlib
import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from uuid import UUID, uuid4
import structlog
from anthropic import AsyncAnthropic
from sqlalchemy import select
from roboco.config import settings
from roboco.db.tables import (
PrompterMessageTable,
PrompterSessionTable,
TaskDraftTable,
TaskTable,
)
from roboco.models.base import Complexity, TaskNature, TaskType, Team
from roboco.models.task import TaskCreateRequest
from roboco.services.base import NotFoundError, ServiceError, ValidationError
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
logger = structlog.get_logger()
# ---------------------------------------------------------------------------
# Input types
# ---------------------------------------------------------------------------
@dataclass
class ConfirmOverrides:
"""Optional overrides applied when confirming a draft to create a task."""
project_id: UUID | None = None
product_id: UUID | None = None
assigned_to: str | None = None
extra: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Prompts
# ---------------------------------------------------------------------------
_PROMPTER_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — a conversational assistant that helps "
"users draft tasks for an AI agentic company.\n\n"
"Your job is to:\n"
"1. Ask clarifying questions to gather requirements.\n"
"2. Keep the conversation focused on producing a well-scoped task.\n"
"3. When you believe you have enough context, signal that a draft is "
"ready.\n"
"4. Never create the task yourself — only help the user articulate what "
"needs to be built.\n\n"
"Key rules:\n"
"- Be concise but thorough.\n"
"- Always ask for acceptance criteria if the user hasn't provided them.\n"
"- Suggest a team (backend, frontend, ux_ui) based on the work "
"described.\n"
"- Estimate complexity (low, medium, high) and task type (code, "
"documentation, research, planning, design, administrative).\n"
"- Determine nature (technical vs non_technical).\n"
"- If the user describes a bug, suggest a code task with technical "
"nature.\n"
"- If the user describes a feature, determine whether it's backend, "
"frontend, or UX/UI work.\n\n"
"When you have enough information to produce a complete draft, say so "
"explicitly with 'I have enough information to draft a task' or "
"'ready to draft'."
)
_DRAFT_SYSTEM_PROMPT = (
"You are the RoboCo Prompter — an expert at converting conversations "
"into structured task drafts.\n\n"
"Given a conversation between a user and the Prompter assistant, "
"produce a JSON task draft that conforms to the RoboCo task schema.\n\n"
"Required fields:\n"
"- title: concise, actionable task title (max 200 chars)\n"
"- description: detailed description, min 20 chars, explaining what "
"needs to be done\n"
"- acceptance_criteria: list of strings, each a verifiable criterion "
"(min 1)\n"
"- team: one of backend, frontend, ux_ui\n"
"- task_type: one of code, documentation, research, planning, design, "
"administrative\n"
"- nature: one of technical, non_technical\n"
"- estimated_complexity: one of low, medium, high\n"
"- priority: integer 0-3 (0=P0 highest, 3=P3 lowest)\n\n"
"Optional fields:\n"
"- project_id: UUID string if known from context\n"
"- product_id: UUID string if known from context (only one of "
"project_id/product_id should be set)\n"
"- assigned_to: agent slug or UUID if the user specified one\n"
"- target_date: ISO-8601 date string if mentioned\n\n"
'Always set source="prompter" and confirmed_by_human=false.\n\n'
"Return ONLY valid JSON matching the PrompterDraftTask schema. No "
"markdown, no preamble."
)
class PrompterService:
"""Service for Prompter chat, session management, and structured draft generation.
Accepts an optional SQLAlchemy ``AsyncSession`` for the session-based
(DB-persisted) interface. When no session is provided, only the legacy
stateless ``chat()`` and ``draft()`` methods are available.
"""
def __init__(self, db: AsyncSession | None = None) -> None:
self.log = logger.bind(component="prompter_service")
self._client: AsyncAnthropic | None = None
self._db = db
def _get_client(self) -> AsyncAnthropic:
"""Lazy-init Anthropic client."""
if self._client is None:
api_key = settings.anthropic_api_key
if not api_key:
raise ServiceError("Anthropic API key not configured")
self._client = AsyncAnthropic(api_key=api_key)
return self._client
@property
def _session(self) -> AsyncSession:
"""Return DB session, raising if not configured."""
if self._db is None:
raise ServiceError(
"PrompterService was created without a DB session; "
"session-based methods are unavailable"
)
return self._db
# -----------------------------------------------------------------------
# Session-based interface
# -----------------------------------------------------------------------
async def create_session(
self,
agent_id: UUID,
context: dict[str, Any] | None = None, # noqa: ARG002
) -> PrompterSessionTable:
"""Create a new Prompter conversation session."""
session = PrompterSessionTable(
id=uuid4(),
agent_id=agent_id,
status="active",
created_at=datetime.now(UTC),
)
self._session.add(session)
await self._session.flush()
self.log.info("Prompter session created", session_id=str(session.id))
return session
async def send_message(
self,
session_id: UUID,
agent_id: UUID,
content: str,
context: dict[str, Any] | None = None,
) -> list[PrompterMessageTable]:
"""
Append a user message, call the LLM for a reply, persist both,
and return all messages in the session.
"""
session = await self._get_session(session_id, agent_id)
# Persist the user message first
user_msg = PrompterMessageTable(
id=uuid4(),
session_id=session_id,
role="user",
content=content,
created_at=datetime.now(UTC),
)
self._session.add(user_msg)
await self._session.flush()
# Load full conversation history for the LLM call
history = await self._load_messages(session_id)
chat_messages = [{"role": m.role, "content": m.content} for m in history]
# Call the LLM
llm_reply = await self._llm_chat(
messages=chat_messages,
context=context,
)
# Persist the assistant reply
assistant_msg = PrompterMessageTable(
id=uuid4(),
session_id=session_id,
role="assistant",
content=llm_reply["message"],
created_at=datetime.now(UTC),
)
self._session.add(assistant_msg)
# Update session status if draft is ready
if llm_reply["draft_ready"] and session.status == "active":
session.status = "draft_ready"
await self._session.flush()
self.log.info(
"Message processed",
session_id=str(session_id),
draft_ready=llm_reply["draft_ready"],
)
# Return all messages in order
return await self._load_messages(session_id)
async def get_or_generate_draft(
self,
session_id: UUID,
agent_id: UUID,
) -> TaskDraftTable:
"""
Return an existing draft for the session, or generate one via LLM
if none exists yet.
"""
await self._get_session(session_id, agent_id)
# Check for an existing draft
result = await self._session.execute(
select(TaskDraftTable)
.where(TaskDraftTable.session_id == session_id)
.order_by(TaskDraftTable.created_at.desc())
.limit(1)
)
existing = result.scalar_one_or_none()
if existing is not None:
return existing
# No draft yet — generate one from conversation history
history = await self._load_messages(session_id)
if not history:
raise ValidationError(
message=(
"Cannot generate a draft from an empty conversation; "
"send at least one message first."
),
field="messages",
)
chat_messages = [{"role": m.role, "content": m.content} for m in history]
draft_result = await self._llm_draft(
messages=chat_messages,
)
draft_record = TaskDraftTable(
id=uuid4(),
session_id=session_id,
draft_data=draft_result["draft"],
created_at=datetime.now(UTC),
)
self._session.add(draft_record)
await self._session.flush()
return draft_record
async def confirm_draft(
self,
session_id: UUID,
agent_id: UUID,
confirm_overrides: ConfirmOverrides | None = None,
) -> UUID:
"""
Validate the draft and create a real Task via the TaskService.
Returns the newly created task's UUID.
"""
session_rec = await self._get_session(session_id, agent_id)
ov = confirm_overrides or ConfirmOverrides()
# Get or generate the draft
draft_record = await self.get_or_generate_draft(session_id, agent_id)
draft_data: dict[str, Any] = dict(draft_record.draft_data)
# Apply overrides
if ov.project_id is not None:
draft_data["project_id"] = str(ov.project_id)
if ov.product_id is not None:
draft_data["product_id"] = str(ov.product_id)
if ov.assigned_to is not None:
draft_data["assigned_to"] = ov.assigned_to
if ov.extra:
draft_data.update(ov.extra)
# Resolve project/product IDs
resolved_project_id: UUID | None = None
resolved_product_id: UUID | None = None
if draft_data.get("project_id"):
try:
resolved_project_id = UUID(str(draft_data["project_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid project_id UUID: {draft_data['project_id']}",
field="project_id",
) from exc
if draft_data.get("product_id"):
try:
resolved_product_id = UUID(str(draft_data["product_id"]))
except ValueError as exc:
raise ValidationError(
message=f"Invalid product_id UUID: {draft_data['product_id']}",
field="product_id",
) from exc
if resolved_project_id is None and resolved_product_id is None:
raise ValidationError(
message=(
"The draft must have either project_id or product_id set. "
"Pass one via the confirm request body."
),
field="project_id",
)
# Validate and coerce required fields
try:
team = Team(draft_data["team"])
task_type = TaskType(draft_data["task_type"])
nature = TaskNature(draft_data["nature"])
complexity = Complexity(draft_data["estimated_complexity"])
except (KeyError, ValueError) as exc:
raise ValidationError(
message=f"Draft has invalid or missing required fields: {exc}",
field="draft",
) from exc
# Resolve assigned_to as UUID if possible
resolved_assigned_to: UUID | None = None
if draft_data.get("assigned_to"):
with contextlib.suppress(ValueError):
resolved_assigned_to = UUID(str(draft_data["assigned_to"]))
req = TaskCreateRequest(
title=draft_data["title"],
description=draft_data["description"],
acceptance_criteria=draft_data["acceptance_criteria"],
team=team,
created_by=agent_id,
task_type=task_type,
nature=nature,
estimated_complexity=complexity,
priority=int(draft_data.get("priority", 2)),
assigned_to=resolved_assigned_to,
project_id=resolved_project_id,
product_id=resolved_product_id,
source="prompter",
confirmed_by_human=True,
)
# Import TaskService lazily to avoid circular imports
from roboco.services.task import get_task_service
task_service = get_task_service(self._session)
task: TaskTable = await task_service.create(req)
# Mark draft as confirmed
now = datetime.now(UTC)
draft_record.confirmed_at = now
draft_record.task_id = task.id
session_rec.status = "confirmed"
await self._session.flush()
self.log.info(
"Draft confirmed — task created",
session_id=str(session_id),
task_id=str(task.id),
)
return task.id # type: ignore[return-value]
# -----------------------------------------------------------------------
# Private helpers (session-based)
# -----------------------------------------------------------------------
async def _get_session(
self, session_id: UUID, agent_id: UUID
) -> PrompterSessionTable:
"""Load and authorize a PrompterSession."""
result = await self._session.execute(
select(PrompterSessionTable).where(PrompterSessionTable.id == session_id)
)
rec = result.scalar_one_or_none()
if rec is None:
raise NotFoundError(f"Prompter session {session_id} not found")
if rec.agent_id != agent_id:
raise ServiceError(
f"Session {session_id} does not belong to agent {agent_id}"
)
return rec
async def _load_messages(self, session_id: UUID) -> list[PrompterMessageTable]:
"""Return all messages for a session ordered by creation time."""
result = await self._session.execute(
select(PrompterMessageTable)
.where(PrompterMessageTable.session_id == session_id)
.order_by(PrompterMessageTable.created_at)
)
return list(result.scalars().all())
# -----------------------------------------------------------------------
# Shared LLM helpers
# -----------------------------------------------------------------------
async def _llm_chat(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048,
) -> dict[str, Any]:
"""Call the LLM for a chat response. Returns {message, draft_ready}."""
client = self._get_client()
user_prompt = _build_chat_prompt(messages, context)
try:
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
system=_PROMPTER_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
except Exception as e:
self.log.error("Prompter chat LLM call failed", error=str(e))
raise ServiceError(f"LLM chat failed: {e}") from e
content = _extract_text(response)
if not content:
raise ServiceError("LLM returned empty content")
return {
"message": content,
"draft_ready": _detect_draft_ready(content),
}
async def _llm_draft(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096,
) -> dict[str, Any]:
"""Call the LLM to generate a structured draft. Returns {draft, reasoning}."""
client = self._get_client()
user_prompt = _build_draft_prompt(messages, context)
try:
response = await client.messages.create(
model=model,
max_tokens=max_tokens,
system=_DRAFT_SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}],
)
except Exception as e:
self.log.error("Prompter draft LLM call failed", error=str(e))
raise ServiceError(f"LLM draft generation failed: {e}") from e
content = _extract_text(response)
if not content:
raise ServiceError("LLM returned empty content for draft")
try:
draft_data = json.loads(content)
except json.JSONDecodeError as e:
self.log.warning("Draft JSON parse failed", content_preview=content[:200])
raise ValidationError(
message=f"Draft response was not valid JSON: {e}",
field="draft",
) from e
draft_data["source"] = "prompter"
draft_data["confirmed_by_human"] = False
return {
"draft": draft_data,
"reasoning": _build_reasoning(messages, draft_data),
}
# -----------------------------------------------------------------------
# Legacy stateless interface
# -----------------------------------------------------------------------
async def chat(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 2048,
) -> dict[str, Any]:
"""Continue a Prompter conversation (stateless)."""
return await self._llm_chat(
messages=messages,
context=context,
model=model,
max_tokens=max_tokens,
)
async def draft(
self,
messages: list[dict[str, str]],
context: dict[str, Any] | None = None,
model: str = "claude-3-5-sonnet-20241022",
max_tokens: int = 4096,
) -> dict[str, Any]:
"""Generate a structured task draft from conversation context (stateless)."""
return await self._llm_draft(
messages=messages,
context=context,
model=model,
max_tokens=max_tokens,
)
# ---------------------------------------------------------------------------
# Module-level helpers (pure functions, no state)
# ---------------------------------------------------------------------------
def _build_chat_prompt(
messages: list[dict[str, str]],
context: dict[str, Any] | None,
) -> str:
lines: list[str] = []
if context:
lines.append("Context:")
for key, value in context.items():
lines.append(f" {key}: {value}")
lines.append("")
lines.append("Conversation:")
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
lines.append(f"{role}: {content}")
lines.append("")
lines.append(
"Continue the conversation as the Prompter assistant. "
"If you have enough information to draft a complete task, "
"say so explicitly."
)
return "\n".join(lines)
def _build_draft_prompt(
messages: list[dict[str, str]],
context: dict[str, Any] | None,
) -> str:
lines: list[str] = []
lines.append(
"Produce a JSON task draft from the following conversation. "
"Return ONLY valid JSON — no markdown, no preamble."
)
if context:
lines.append("")
lines.append("Overrides:")
for key, value in context.items():
lines.append(f" {key}: {value}")
lines.append("")
lines.append("Conversation:")
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
lines.append(f"{role}: {content}")
return "\n".join(lines)
def _extract_text(response: Any) -> str:
text_parts: list[str] = []
for block in getattr(response, "content", []):
if hasattr(block, "text"):
text_parts.append(block.text)
return "\n".join(text_parts).strip()
def _detect_draft_ready(content: str) -> bool:
signals = [
"i have enough information",
"ready to generate a draft",
"ready to draft",
"i can now draft",
"draft_ready=true",
"draft ready",
]
lower = content.lower()
return any(sig in lower for sig in signals)
def _build_reasoning(
messages: list[dict[str, str]],
draft_data: dict[str, Any],
) -> str:
title = draft_data.get("title", "Untitled")
team = draft_data.get("team", "unknown")
complexity = draft_data.get("estimated_complexity", "unknown")
return (
f"Draft generated from conversation of {len(messages)} messages. "
f"Proposed task '{title}' for team {team} "
f"with complexity {complexity}."
)
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def get_prompter_service(db: AsyncSession | None = None) -> PrompterService:
"""Create a PrompterService instance.
Pass ``db`` for the session-based interface; omit for the stateless
legacy interface.
"""
return PrompterService(db=db)
+3
View File
@@ -549,6 +549,9 @@ class TaskService(BaseService):
task_type=req.task_type,
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()