mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* [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:
co-authored by
Backend Developer 2
parent
d5cbbf49ee
commit
85ffec86b4
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user