Compare commits
No commits in common. "88e4eb7f3f8c02a11e372f794e49f716b2a2c2ab" and "adcea2fc1f172096f09e19b8f8b55ef673b05a36" have entirely different histories.
88e4eb7f3f
...
adcea2fc1f
@ -262,26 +262,6 @@ func main() {
|
|||||||
// Create work service (for worker pool task management)
|
// Create work service (for worker pool task management)
|
||||||
workService := service.NewWorkService(workQueueRepo).WithWebhookDispatcher(webhookDispatcher)
|
workService := service.NewWorkService(workQueueRepo).WithWebhookDispatcher(webhookDispatcher)
|
||||||
|
|
||||||
// Create conversation service (for Foundary chat persistence)
|
|
||||||
conversationRepo := postgres.NewConversationRepository(database.DB)
|
|
||||||
conversationService := service.NewConversationService(conversationRepo)
|
|
||||||
|
|
||||||
// Create blueprint service (for Foundary structured specs)
|
|
||||||
blueprintRepo := postgres.NewBlueprintRepository(database.DB)
|
|
||||||
blueprintService := service.NewBlueprintService(blueprintRepo)
|
|
||||||
|
|
||||||
// Create architect service (for Foundary conversational orchestration)
|
|
||||||
architectService := service.NewArchitectService(
|
|
||||||
conversationService,
|
|
||||||
blueprintService,
|
|
||||||
agentRegistry,
|
|
||||||
projectRepo,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create question service (for Foundary structured questions)
|
|
||||||
questionRepo := postgres.NewQuestionRepository(database.DB)
|
|
||||||
questionService := service.NewQuestionService(questionRepo)
|
|
||||||
|
|
||||||
// Initialize operation tracking (for debugging project failures)
|
// Initialize operation tracking (for debugging project failures)
|
||||||
operationRepo := postgres.NewOperationRepository(database.DB)
|
operationRepo := postgres.NewOperationRepository(database.DB)
|
||||||
operationService := service.NewOperationService(operationRepo)
|
operationService := service.NewOperationService(operationRepo)
|
||||||
@ -378,10 +358,6 @@ func main() {
|
|||||||
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
||||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||||
workHandler := handlers.NewWorkHandler(workService)
|
workHandler := handlers.NewWorkHandler(workService)
|
||||||
conversationsHandler := handlers.NewConversationsHandler(conversationService)
|
|
||||||
blueprintsHandler := handlers.NewBlueprintsHandler(blueprintService)
|
|
||||||
architectHandler := handlers.NewArchitectHandler(architectService)
|
|
||||||
questionsHandler := handlers.NewQuestionsHandler(questionService)
|
|
||||||
|
|
||||||
// Initialize domain and slug repositories
|
// Initialize domain and slug repositories
|
||||||
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
||||||
@ -567,10 +543,6 @@ func main() {
|
|||||||
queueHandler.Mount(app.Router())
|
queueHandler.Mount(app.Router())
|
||||||
webhookHandler.Mount(app.Router())
|
webhookHandler.Mount(app.Router())
|
||||||
workHandler.Mount(app.Router())
|
workHandler.Mount(app.Router())
|
||||||
conversationsHandler.Mount(app.Router())
|
|
||||||
blueprintsHandler.Mount(app.Router())
|
|
||||||
architectHandler.Mount(app.Router())
|
|
||||||
questionsHandler.Mount(app.Router())
|
|
||||||
infraHandler.Mount(app.Router())
|
infraHandler.Mount(app.Router())
|
||||||
projectMgmtHandler.Mount(app.Router())
|
projectMgmtHandler.Mount(app.Router())
|
||||||
if componentsHandler != nil {
|
if componentsHandler != nil {
|
||||||
|
|||||||
@ -67,7 +67,6 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
|||||||
spec.WithTag("Webhooks", "External webhook receivers")
|
spec.WithTag("Webhooks", "External webhook receivers")
|
||||||
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
||||||
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
||||||
spec.WithTag("Foundary", "Conversational project design and specification")
|
|
||||||
|
|
||||||
// Register all path operations
|
// Register all path operations
|
||||||
registerSystemPaths(spec)
|
registerSystemPaths(spec)
|
||||||
@ -88,10 +87,6 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
|||||||
registerWebhookPaths(spec)
|
registerWebhookPaths(spec)
|
||||||
registerInfrastructurePaths(spec)
|
registerInfrastructurePaths(spec)
|
||||||
registerSagaPaths(spec)
|
registerSagaPaths(spec)
|
||||||
registerConversationPaths(spec)
|
|
||||||
registerBlueprintPaths(spec)
|
|
||||||
registerArchitectPaths(spec)
|
|
||||||
registerQuestionPaths(spec)
|
|
||||||
|
|
||||||
return spec
|
return spec
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1503,304 +1503,3 @@ Marks step as skipped and allows dependent steps to proceed.`,
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerConversationPaths(spec *api.OpenAPISpec) {
|
|
||||||
spec.AddPath("/projects/{id}/conversations", "post", withAuthBodyAndParams(
|
|
||||||
"Create conversation",
|
|
||||||
`Creates a new conversation for conversational project design.
|
|
||||||
|
|
||||||
Part of the Foundary Studio system for interactive requirements gathering.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
`{"title": "Landing page design discussion"}`,
|
|
||||||
`{
|
|
||||||
"id": "conv-abc123",
|
|
||||||
"project_id": "my-project",
|
|
||||||
"title": "Landing page design discussion",
|
|
||||||
"created_at": "2026-02-09T00:00:00Z",
|
|
||||||
"updated_at": "2026-02-09T00:00:00Z"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/conversations", "get", withAuthAndParams(
|
|
||||||
"List conversations",
|
|
||||||
`Returns all conversations for a project.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/conversations/{conversationId}", "get", withAuthAndParams(
|
|
||||||
"Get conversation",
|
|
||||||
`Returns a single conversation with all messages.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/conversations/{conversationId}/messages", "post", withAuthBodyAndParams(
|
|
||||||
"Add message",
|
|
||||||
`Adds a message to a conversation.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
`{"role": "user", "content": "I need a modern landing page with dark mode"}`,
|
|
||||||
`{
|
|
||||||
"id": "msg-abc123",
|
|
||||||
"conversation_id": "conv-abc123",
|
|
||||||
"role": "user",
|
|
||||||
"content": "I need a modern landing page with dark mode",
|
|
||||||
"created_at": "2026-02-09T00:00:00Z"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/conversations/{conversationId}/messages", "get", withAuthAndParams(
|
|
||||||
"List messages",
|
|
||||||
`Returns all messages in a conversation.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/conversations/{conversationId}", "delete", withAuthAndParams(
|
|
||||||
"Delete conversation",
|
|
||||||
`Deletes a conversation and all its messages.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerBlueprintPaths(spec *api.OpenAPISpec) {
|
|
||||||
spec.AddPath("/projects/{id}/blueprints", "post", withAuthBodyAndParams(
|
|
||||||
"Create blueprint",
|
|
||||||
`Creates a structured project blueprint with JSONB spec storage.
|
|
||||||
|
|
||||||
Blueprints capture the technical specification extracted from conversations.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
`{
|
|
||||||
"name": "Landing Page v1",
|
|
||||||
"description": "Modern landing page with Astro and Tailwind",
|
|
||||||
"spec": {
|
|
||||||
"stack": ["astro", "tailwind", "typescript"],
|
|
||||||
"components": ["hero", "features", "cta"],
|
|
||||||
"features": {"dark_mode": true, "responsive": true}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
`{
|
|
||||||
"id": "bp-abc123",
|
|
||||||
"project_id": "my-project",
|
|
||||||
"name": "Landing Page v1",
|
|
||||||
"description": "Modern landing page with Astro and Tailwind",
|
|
||||||
"spec": {
|
|
||||||
"stack": ["astro", "tailwind", "typescript"],
|
|
||||||
"components": ["hero", "features", "cta"],
|
|
||||||
"features": {"dark_mode": true, "responsive": true}
|
|
||||||
},
|
|
||||||
"created_at": "2026-02-09T00:00:00Z",
|
|
||||||
"updated_at": "2026-02-09T00:00:00Z"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/blueprints", "get", withAuthAndParams(
|
|
||||||
"List blueprints",
|
|
||||||
`Returns all blueprints for a project.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "get", withAuthAndParams(
|
|
||||||
"Get blueprint",
|
|
||||||
`Returns a single blueprint with full spec.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "put", withAuthBodyAndParams(
|
|
||||||
"Update blueprint",
|
|
||||||
`Updates a blueprint's metadata or spec.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true},
|
|
||||||
},
|
|
||||||
`{
|
|
||||||
"name": "Landing Page v2",
|
|
||||||
"spec": {
|
|
||||||
"stack": ["astro", "tailwind", "typescript"],
|
|
||||||
"features": {"dark_mode": true, "animations": true}
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
`{"message": "blueprint updated"}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/blueprints/{blueprintId}", "delete", withAuthAndParams(
|
|
||||||
"Delete blueprint",
|
|
||||||
`Deletes a blueprint permanently.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "blueprintId", In: "path", Description: "Blueprint ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerArchitectPaths(spec *api.OpenAPISpec) {
|
|
||||||
spec.AddPath("/projects/{id}/architect/start", "post", withAuthBodyAndParams(
|
|
||||||
"Start architect conversation",
|
|
||||||
`Starts a new conversational design session with the AI architect.
|
|
||||||
|
|
||||||
The architect asks clarifying questions and guides requirements gathering.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
`{
|
|
||||||
"title": "E-commerce platform",
|
|
||||||
"initial_message": "I want to build a marketplace for handmade goods"
|
|
||||||
}`,
|
|
||||||
`{
|
|
||||||
"conversation_id": "conv-abc123",
|
|
||||||
"title": "E-commerce platform",
|
|
||||||
"response": "Great! A marketplace for handmade goods. Let me ask some questions to understand your vision better. What scale are you targeting? Will this be for local artisans or a global marketplace?"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/architect/continue/{conversationId}", "post", withAuthBodyAndParams(
|
|
||||||
"Continue architect conversation",
|
|
||||||
`Continues an existing conversation with the AI architect.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
`{"message": "Global marketplace, starting with 100 sellers"}`,
|
|
||||||
`{
|
|
||||||
"conversation_id": "conv-abc123",
|
|
||||||
"response": "Understood. For a global marketplace with 100 sellers initially, we'll need to consider international payments, multi-currency support, and shipping logistics. What's your timeline for launch?"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/architect/generate-blueprint/{conversationId}", "post", withAuthAndParams(
|
|
||||||
"Generate blueprint from conversation",
|
|
||||||
`Extracts a structured blueprint from the conversation history.
|
|
||||||
|
|
||||||
Uses AI to parse requirements into a technical specification.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerQuestionPaths(spec *api.OpenAPISpec) {
|
|
||||||
spec.AddPath("/projects/{id}/questions", "post", withAuthBodyAndParams(
|
|
||||||
"Create question",
|
|
||||||
`Creates a structured question for the architect to ask the user.
|
|
||||||
|
|
||||||
Supports text, choice, multichoice, and yes/no question types.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
`{
|
|
||||||
"conversation_id": "conv-abc123",
|
|
||||||
"type": "choice",
|
|
||||||
"text": "What authentication method would you prefer?",
|
|
||||||
"choices": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link", "Phone/SMS"],
|
|
||||||
"metadata": {"category": "auth"}
|
|
||||||
}`,
|
|
||||||
`{
|
|
||||||
"id": "q-abc123",
|
|
||||||
"conversation_id": "conv-abc123",
|
|
||||||
"project_id": "my-project",
|
|
||||||
"type": "choice",
|
|
||||||
"text": "What authentication method would you prefer?",
|
|
||||||
"choices": ["Email/Password", "OAuth (Google, GitHub)", "Magic Link", "Phone/SMS"],
|
|
||||||
"metadata": {"category": "auth"},
|
|
||||||
"created_at": "2026-02-09T00:00:00Z"
|
|
||||||
}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/questions", "get", withAuthAndParams(
|
|
||||||
"List unanswered questions",
|
|
||||||
`Returns all unanswered questions for a project.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{{Name: "id", In: "path", Description: "Project ID", Required: true}},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/questions/{questionId}", "get", withAuthAndParams(
|
|
||||||
"Get question",
|
|
||||||
`Returns a single question by ID.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "questionId", In: "path", Description: "Question ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/questions/conversation/{conversationId}", "get", withAuthAndParams(
|
|
||||||
"List questions by conversation",
|
|
||||||
`Returns all questions (answered and unanswered) for a conversation.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:read",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "conversationId", In: "path", Description: "Conversation ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/questions/{questionId}/answer", "post", withAuthBodyAndParams(
|
|
||||||
"Answer question",
|
|
||||||
`Records an answer to a question.
|
|
||||||
|
|
||||||
Answer format depends on question type:
|
|
||||||
- text/yesno: single string in "answer" field
|
|
||||||
- choice: single string in "answer" field (must match a choice)
|
|
||||||
- multichoice: array of strings in "answer_choices" field`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "questionId", In: "path", Description: "Question ID", Required: true},
|
|
||||||
},
|
|
||||||
`{"answer": "OAuth (Google, GitHub)"}`,
|
|
||||||
`{"message": "question answered"}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
spec.AddPath("/projects/{id}/questions/{questionId}", "delete", withAuthAndParams(
|
|
||||||
"Delete question",
|
|
||||||
`Deletes a question permanently.`,
|
|
||||||
"Foundary",
|
|
||||||
"projects:execute",
|
|
||||||
[]param{
|
|
||||||
{Name: "id", In: "path", Description: "Project ID", Required: true},
|
|
||||||
{Name: "questionId", In: "path", Description: "Question ID", Required: true},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
|||||||
# Foundary Studio UI - ASCII Screens
|
# Orchard Studio UI - ASCII Screens
|
||||||
|
|
||||||
## 1. Project Dashboard (The "Lobby")
|
## 1. Project Dashboard (The "Lobby")
|
||||||
|
|
||||||
@ -6,7 +6,7 @@ Entry point for the Product Owner.
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| Foundary Studio [ New Project ] |
|
| ORCHARD STUDIO [ New Project ] |
|
||||||
+-----------------------------------------------------------------------------+
|
+-----------------------------------------------------------------------------+
|
||||||
| |
|
| |
|
||||||
| Active Projects |
|
| Active Projects |
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Technical Reference: Foundary Studio UI
|
# Technical Reference: Orchard Studio UI
|
||||||
|
|
||||||
This document maps the Foundary Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements.
|
This document maps the Orchard Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements.
|
||||||
|
|
||||||
## 1. Architecture Overview
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Roadmap: Foundary Studio UI Implementation
|
# Roadmap: Orchard Studio UI Implementation
|
||||||
|
|
||||||
This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Foundary Studio UI.
|
This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Orchard Studio UI.
|
||||||
|
|
||||||
## Phase 1: Foundation & Read-Only UI
|
## Phase 1: Foundation & Read-Only UI
|
||||||
**Goal:** Visualize the current state of `rdev` projects and work queues.
|
**Goal:** Visualize the current state of `rdev` projects and work queues.
|
||||||
@ -57,5 +57,5 @@ This roadmap outlines the steps to move from the current `rdev` backend to the f
|
|||||||
**Goal:** Production readiness.
|
**Goal:** Production readiness.
|
||||||
|
|
||||||
1. **Real-time Polish**: Replace polling with SSE/WebSockets for all status updates.
|
1. **Real-time Polish**: Replace polling with SSE/WebSockets for all status updates.
|
||||||
2. **Visual Design**: Apply "Foundary" branding (dark mode, crisp typography).
|
2. **Visual Design**: Apply "Orchard" branding (dark mode, crisp typography).
|
||||||
3. **Mobile Responsiveness**: Ensure critical flows work on tablet/mobile.
|
3. **Mobile Responsiveness**: Ensure critical flows work on tablet/mobile.
|
||||||
|
|||||||
@ -1,176 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BlueprintRepository implements port.BlueprintRepository using PostgreSQL.
|
|
||||||
type BlueprintRepository struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBlueprintRepository creates a new PostgreSQL blueprint repository.
|
|
||||||
func NewBlueprintRepository(db *sql.DB) *BlueprintRepository {
|
|
||||||
return &BlueprintRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure BlueprintRepository implements port.BlueprintRepository at compile time.
|
|
||||||
var _ port.BlueprintRepository = (*BlueprintRepository)(nil)
|
|
||||||
|
|
||||||
// CreateBlueprint creates a new blueprint.
|
|
||||||
func (r *BlueprintRepository) CreateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error {
|
|
||||||
specJSON, err := json.Marshal(blueprint.Spec)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = r.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO blueprints (project_id, name, description, spec)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, blueprint.ProjectID, blueprint.Name, blueprint.Description, specJSON).Scan(
|
|
||||||
&blueprint.ID,
|
|
||||||
&blueprint.CreatedAt,
|
|
||||||
&blueprint.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create blueprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBlueprint retrieves a blueprint by ID.
|
|
||||||
func (r *BlueprintRepository) GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error) {
|
|
||||||
var blueprint domain.Blueprint
|
|
||||||
var specJSON []byte
|
|
||||||
var description sql.NullString
|
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id, project_id, name, description, spec, created_at, updated_at
|
|
||||||
FROM blueprints
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&blueprint.ID,
|
|
||||||
&blueprint.ProjectID,
|
|
||||||
&blueprint.Name,
|
|
||||||
&description,
|
|
||||||
&specJSON,
|
|
||||||
&blueprint.CreatedAt,
|
|
||||||
&blueprint.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, domain.ErrBlueprintNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get blueprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if description.Valid {
|
|
||||||
blueprint.Description = description.String
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(specJSON, &blueprint.Spec); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &blueprint, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBlueprints returns all blueprints for a project.
|
|
||||||
func (r *BlueprintRepository) ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error) {
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
|
||||||
SELECT id, project_id, name, description, spec, created_at, updated_at
|
|
||||||
FROM blueprints
|
|
||||||
WHERE project_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list blueprints: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var blueprints []*domain.Blueprint
|
|
||||||
for rows.Next() {
|
|
||||||
var blueprint domain.Blueprint
|
|
||||||
var specJSON []byte
|
|
||||||
var description sql.NullString
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&blueprint.ID,
|
|
||||||
&blueprint.ProjectID,
|
|
||||||
&blueprint.Name,
|
|
||||||
&description,
|
|
||||||
&specJSON,
|
|
||||||
&blueprint.CreatedAt,
|
|
||||||
&blueprint.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan blueprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if description.Valid {
|
|
||||||
blueprint.Description = description.String
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(specJSON, &blueprint.Spec); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
blueprints = append(blueprints, &blueprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
return blueprints, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBlueprint updates a blueprint's metadata and spec.
|
|
||||||
func (r *BlueprintRepository) UpdateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error {
|
|
||||||
specJSON, err := json.Marshal(blueprint.Spec)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
UPDATE blueprints
|
|
||||||
SET name = $1, description = $2, spec = $3
|
|
||||||
WHERE id = $4
|
|
||||||
`, blueprint.Name, blueprint.Description, specJSON, blueprint.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update blueprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrBlueprintNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteBlueprint deletes a blueprint.
|
|
||||||
func (r *BlueprintRepository) DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error {
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM blueprints WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete blueprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrBlueprintNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConversationRepository implements port.ConversationRepository using PostgreSQL.
|
|
||||||
type ConversationRepository struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConversationRepository creates a new PostgreSQL conversation repository.
|
|
||||||
func NewConversationRepository(db *sql.DB) *ConversationRepository {
|
|
||||||
return &ConversationRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure ConversationRepository implements port.ConversationRepository at compile time.
|
|
||||||
var _ port.ConversationRepository = (*ConversationRepository)(nil)
|
|
||||||
|
|
||||||
// CreateConversation creates a new conversation.
|
|
||||||
func (r *ConversationRepository) CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) {
|
|
||||||
var conv domain.Conversation
|
|
||||||
var lastMessage sql.NullTime
|
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO conversations (project_id, title)
|
|
||||||
VALUES ($1, $2)
|
|
||||||
RETURNING id, project_id, title, created_at, updated_at, last_message_at
|
|
||||||
`, projectID, title).Scan(
|
|
||||||
&conv.ID,
|
|
||||||
&conv.ProjectID,
|
|
||||||
&conv.Title,
|
|
||||||
&conv.CreatedAt,
|
|
||||||
&conv.UpdatedAt,
|
|
||||||
&lastMessage,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create conversation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastMessage.Valid {
|
|
||||||
conv.LastMessage = &lastMessage.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
return &conv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConversation retrieves a conversation by ID.
|
|
||||||
func (r *ConversationRepository) GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) {
|
|
||||||
var conv domain.Conversation
|
|
||||||
var lastMessage sql.NullTime
|
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id, project_id, title, created_at, updated_at, last_message_at
|
|
||||||
FROM conversations
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&conv.ID,
|
|
||||||
&conv.ProjectID,
|
|
||||||
&conv.Title,
|
|
||||||
&conv.CreatedAt,
|
|
||||||
&conv.UpdatedAt,
|
|
||||||
&lastMessage,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, domain.ErrConversationNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get conversation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if lastMessage.Valid {
|
|
||||||
conv.LastMessage = &lastMessage.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
return &conv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListConversations returns all conversations for a project.
|
|
||||||
func (r *ConversationRepository) ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) {
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
|
||||||
SELECT id, project_id, title, created_at, updated_at, last_message_at
|
|
||||||
FROM conversations
|
|
||||||
WHERE project_id = $1
|
|
||||||
ORDER BY last_message_at DESC NULLS LAST, created_at DESC
|
|
||||||
`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list conversations: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var convs []*domain.Conversation
|
|
||||||
for rows.Next() {
|
|
||||||
var conv domain.Conversation
|
|
||||||
var lastMessage sql.NullTime
|
|
||||||
if err := rows.Scan(
|
|
||||||
&conv.ID,
|
|
||||||
&conv.ProjectID,
|
|
||||||
&conv.Title,
|
|
||||||
&conv.CreatedAt,
|
|
||||||
&conv.UpdatedAt,
|
|
||||||
&lastMessage,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan conversation: %w", err)
|
|
||||||
}
|
|
||||||
if lastMessage.Valid {
|
|
||||||
conv.LastMessage = &lastMessage.Time
|
|
||||||
}
|
|
||||||
convs = append(convs, &conv)
|
|
||||||
}
|
|
||||||
|
|
||||||
return convs, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConversationTitle updates the conversation title.
|
|
||||||
func (r *ConversationRepository) UpdateConversationTitle(ctx context.Context, id domain.ConversationID, title string) error {
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
UPDATE conversations
|
|
||||||
SET title = $1
|
|
||||||
WHERE id = $2
|
|
||||||
`, title, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update conversation title: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrConversationNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteConversation deletes a conversation and all its messages.
|
|
||||||
func (r *ConversationRepository) DeleteConversation(ctx context.Context, id domain.ConversationID) error {
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM conversations WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete conversation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrConversationNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMessage adds a message to a conversation.
|
|
||||||
func (r *ConversationRepository) AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) {
|
|
||||||
var msg domain.Message
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO messages (conversation_id, role, content)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, conversation_id, role, content, created_at
|
|
||||||
`, conversationID, role, content).Scan(
|
|
||||||
&msg.ID,
|
|
||||||
&msg.ConversationID,
|
|
||||||
&msg.Role,
|
|
||||||
&msg.Content,
|
|
||||||
&msg.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("add message: %w", err)
|
|
||||||
}
|
|
||||||
return &msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages retrieves all messages for a conversation.
|
|
||||||
func (r *ConversationRepository) GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) {
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
|
||||||
SELECT id, conversation_id, role, content, created_at
|
|
||||||
FROM messages
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
`, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get messages: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var messages []*domain.Message
|
|
||||||
for rows.Next() {
|
|
||||||
var msg domain.Message
|
|
||||||
if err := rows.Scan(
|
|
||||||
&msg.ID,
|
|
||||||
&msg.ConversationID,
|
|
||||||
&msg.Role,
|
|
||||||
&msg.Content,
|
|
||||||
&msg.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan message: %w", err)
|
|
||||||
}
|
|
||||||
messages = append(messages, &msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConversationWithMessages retrieves a conversation with all messages.
|
|
||||||
func (r *ConversationRepository) GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) {
|
|
||||||
conv, err := r.GetConversation(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
messages, err := r.GetMessages(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &domain.ConversationWithMessages{
|
|
||||||
Conversation: *conv,
|
|
||||||
Messages: messages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lib/pq"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QuestionRepository implements port.QuestionRepository using PostgreSQL.
|
|
||||||
type QuestionRepository struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQuestionRepository creates a new PostgreSQL question repository.
|
|
||||||
func NewQuestionRepository(db *sql.DB) *QuestionRepository {
|
|
||||||
return &QuestionRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure QuestionRepository implements port.QuestionRepository at compile time.
|
|
||||||
var _ port.QuestionRepository = (*QuestionRepository)(nil)
|
|
||||||
|
|
||||||
// CreateQuestion creates a new question.
|
|
||||||
func (r *QuestionRepository) CreateQuestion(ctx context.Context, question *domain.Question) error {
|
|
||||||
metadataJSON, err := json.Marshal(question.Metadata)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = r.db.QueryRowContext(ctx, `
|
|
||||||
INSERT INTO questions (conversation_id, project_id, question_type, text, choices, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING id, created_at
|
|
||||||
`, question.ConversationID, question.ProjectID, question.Type, question.Text, pq.Array(question.Choices), metadataJSON).Scan(
|
|
||||||
&question.ID,
|
|
||||||
&question.CreatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetQuestion retrieves a question by ID.
|
|
||||||
func (r *QuestionRepository) GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) {
|
|
||||||
var question domain.Question
|
|
||||||
var answer sql.NullString
|
|
||||||
var answeredAt sql.NullTime
|
|
||||||
var metadataJSON []byte
|
|
||||||
|
|
||||||
err := r.db.QueryRowContext(ctx, `
|
|
||||||
SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at
|
|
||||||
FROM questions
|
|
||||||
WHERE id = $1
|
|
||||||
`, id).Scan(
|
|
||||||
&question.ID,
|
|
||||||
&question.ConversationID,
|
|
||||||
&question.ProjectID,
|
|
||||||
&question.Type,
|
|
||||||
&question.Text,
|
|
||||||
pq.Array(&question.Choices),
|
|
||||||
&answer,
|
|
||||||
pq.Array(&question.AnswerChoices),
|
|
||||||
&metadataJSON,
|
|
||||||
&question.CreatedAt,
|
|
||||||
&answeredAt,
|
|
||||||
)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, domain.ErrQuestionNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if answer.Valid {
|
|
||||||
question.Answer = &answer.String
|
|
||||||
}
|
|
||||||
if answeredAt.Valid {
|
|
||||||
question.AnsweredAt = &answeredAt.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metadataJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &question, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUnansweredQuestions returns all unanswered questions for a project.
|
|
||||||
func (r *QuestionRepository) ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) {
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
|
||||||
SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at
|
|
||||||
FROM questions
|
|
||||||
WHERE project_id = $1 AND answered_at IS NULL
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, projectID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list unanswered questions: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var questions []*domain.Question
|
|
||||||
for rows.Next() {
|
|
||||||
var question domain.Question
|
|
||||||
var answer sql.NullString
|
|
||||||
var answeredAt sql.NullTime
|
|
||||||
var metadataJSON []byte
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&question.ID,
|
|
||||||
&question.ConversationID,
|
|
||||||
&question.ProjectID,
|
|
||||||
&question.Type,
|
|
||||||
&question.Text,
|
|
||||||
pq.Array(&question.Choices),
|
|
||||||
&answer,
|
|
||||||
pq.Array(&question.AnswerChoices),
|
|
||||||
&metadataJSON,
|
|
||||||
&question.CreatedAt,
|
|
||||||
&answeredAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if answer.Valid {
|
|
||||||
question.Answer = &answer.String
|
|
||||||
}
|
|
||||||
if answeredAt.Valid {
|
|
||||||
question.AnsweredAt = &answeredAt.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metadataJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
questions = append(questions, &question)
|
|
||||||
}
|
|
||||||
|
|
||||||
return questions, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListQuestionsByConversation returns all questions for a conversation.
|
|
||||||
func (r *QuestionRepository) ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) {
|
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
|
||||||
SELECT id, conversation_id, project_id, question_type, text, choices, answer, answer_choices, metadata, created_at, answered_at
|
|
||||||
FROM questions
|
|
||||||
WHERE conversation_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
`, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("list questions by conversation: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var questions []*domain.Question
|
|
||||||
for rows.Next() {
|
|
||||||
var question domain.Question
|
|
||||||
var answer sql.NullString
|
|
||||||
var answeredAt sql.NullTime
|
|
||||||
var metadataJSON []byte
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&question.ID,
|
|
||||||
&question.ConversationID,
|
|
||||||
&question.ProjectID,
|
|
||||||
&question.Type,
|
|
||||||
&question.Text,
|
|
||||||
pq.Array(&question.Choices),
|
|
||||||
&answer,
|
|
||||||
pq.Array(&question.AnswerChoices),
|
|
||||||
&metadataJSON,
|
|
||||||
&question.CreatedAt,
|
|
||||||
&answeredAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if answer.Valid {
|
|
||||||
question.Answer = &answer.String
|
|
||||||
}
|
|
||||||
if answeredAt.Valid {
|
|
||||||
question.AnsweredAt = &answeredAt.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(metadataJSON) > 0 {
|
|
||||||
if err := json.Unmarshal(metadataJSON, &question.Metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal metadata: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
questions = append(questions, &question)
|
|
||||||
}
|
|
||||||
|
|
||||||
return questions, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerQuestion records an answer to a question.
|
|
||||||
func (r *QuestionRepository) AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error {
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
UPDATE questions
|
|
||||||
SET answer = $1, answer_choices = $2, answered_at = $3
|
|
||||||
WHERE id = $4
|
|
||||||
`, answer, pq.Array(answerChoices), now, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("answer question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrQuestionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteQuestion deletes a question.
|
|
||||||
func (r *QuestionRepository) DeleteQuestion(ctx context.Context, id domain.QuestionID) error {
|
|
||||||
result, err := r.db.ExecContext(ctx, `
|
|
||||||
DELETE FROM questions WHERE id = $1
|
|
||||||
`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete question: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if rows == 0 {
|
|
||||||
return domain.ErrQuestionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
-- Conversations table for chat persistence
|
|
||||||
CREATE TABLE IF NOT EXISTS conversations (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
project_id TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_message_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Messages table for conversation history
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
||||||
role VARCHAR(20) NOT NULL, -- 'user', 'assistant', 'system'
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for efficient querying
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_project
|
|
||||||
ON conversations(project_id, last_message_at DESC NULLS LAST);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation
|
|
||||||
ON messages(conversation_id, created_at ASC);
|
|
||||||
|
|
||||||
-- Update trigger for conversations.updated_at
|
|
||||||
CREATE OR REPLACE FUNCTION update_conversations_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER conversations_updated_at
|
|
||||||
BEFORE UPDATE ON conversations
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_conversations_updated_at();
|
|
||||||
|
|
||||||
-- Trigger to update last_message_at when a message is added
|
|
||||||
CREATE OR REPLACE FUNCTION update_conversation_last_message()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
UPDATE conversations
|
|
||||||
SET last_message_at = NEW.created_at
|
|
||||||
WHERE id = NEW.conversation_id;
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER message_updates_conversation
|
|
||||||
AFTER INSERT ON messages
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_conversation_last_message();
|
|
||||||
|
|
||||||
COMMENT ON TABLE conversations IS 'Chat conversations for project architect service';
|
|
||||||
COMMENT ON TABLE messages IS 'Messages within conversations';
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
-- Blueprints table for structured project specifications
|
|
||||||
CREATE TABLE IF NOT EXISTS blueprints (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
project_id TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
spec JSONB NOT NULL DEFAULT '{}', -- Structured specification
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Index for project lookups
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blueprints_project
|
|
||||||
ON blueprints(project_id, created_at DESC);
|
|
||||||
|
|
||||||
-- GIN index for efficient JSONB queries
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_blueprints_spec_gin
|
|
||||||
ON blueprints USING GIN (spec);
|
|
||||||
|
|
||||||
-- Update trigger for blueprints.updated_at
|
|
||||||
CREATE OR REPLACE FUNCTION update_blueprints_updated_at()
|
|
||||||
RETURNS TRIGGER AS $$
|
|
||||||
BEGIN
|
|
||||||
NEW.updated_at = NOW();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
|
||||||
$$ LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER blueprints_updated_at
|
|
||||||
BEFORE UPDATE ON blueprints
|
|
||||||
FOR EACH ROW
|
|
||||||
EXECUTE FUNCTION update_blueprints_updated_at();
|
|
||||||
|
|
||||||
COMMENT ON TABLE blueprints IS 'Project blueprints with structured specifications';
|
|
||||||
COMMENT ON COLUMN blueprints.spec IS 'JSONB specification with project requirements, architecture, components, etc.';
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
-- Questions table for structured architect questions
|
|
||||||
CREATE TABLE IF NOT EXISTS questions (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
|
|
||||||
project_id TEXT NOT NULL,
|
|
||||||
question_type VARCHAR(20) NOT NULL, -- 'text', 'choice', 'multichoice', 'yesno'
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
choices TEXT[], -- For choice/multichoice types
|
|
||||||
answer TEXT, -- User's text answer
|
|
||||||
answer_choices TEXT[], -- For multichoice answers
|
|
||||||
metadata JSONB DEFAULT '{}',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
answered_at TIMESTAMPTZ
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_questions_conversation
|
|
||||||
ON questions(conversation_id, created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_questions_project
|
|
||||||
ON questions(project_id, created_at DESC);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_questions_unanswered
|
|
||||||
ON questions(project_id, answered_at) WHERE answered_at IS NULL;
|
|
||||||
|
|
||||||
COMMENT ON TABLE questions IS 'Structured questions for architect service';
|
|
||||||
COMMENT ON COLUMN questions.question_type IS 'Question type: text, choice, multichoice, yesno';
|
|
||||||
COMMENT ON COLUMN questions.choices IS 'Available choices for choice/multichoice questions';
|
|
||||||
COMMENT ON COLUMN questions.answer_choices IS 'Selected choices for multichoice answers';
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// BlueprintID is a strongly-typed identifier for blueprints.
|
|
||||||
type BlueprintID string
|
|
||||||
|
|
||||||
// Blueprint represents a structured project specification.
|
|
||||||
type Blueprint struct {
|
|
||||||
ID BlueprintID
|
|
||||||
ProjectID string
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Spec map[string]any // JSONB - flexible structured data
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ConversationID is a strongly-typed identifier for conversations.
|
|
||||||
type ConversationID string
|
|
||||||
|
|
||||||
// MessageID is a strongly-typed identifier for messages.
|
|
||||||
type MessageID string
|
|
||||||
|
|
||||||
// MessageRole represents who sent the message.
|
|
||||||
type MessageRole string
|
|
||||||
|
|
||||||
const (
|
|
||||||
MessageRoleUser MessageRole = "user"
|
|
||||||
MessageRoleAssistant MessageRole = "assistant"
|
|
||||||
MessageRoleSystem MessageRole = "system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Conversation represents a chat conversation for a project.
|
|
||||||
type Conversation struct {
|
|
||||||
ID ConversationID
|
|
||||||
ProjectID string
|
|
||||||
Title string
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
LastMessage *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message represents a single message in a conversation.
|
|
||||||
type Message struct {
|
|
||||||
ID MessageID
|
|
||||||
ConversationID ConversationID
|
|
||||||
Role MessageRole
|
|
||||||
Content string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConversationWithMessages combines a conversation with its messages.
|
|
||||||
type ConversationWithMessages struct {
|
|
||||||
Conversation
|
|
||||||
Messages []*Message
|
|
||||||
}
|
|
||||||
@ -78,16 +78,6 @@ var (
|
|||||||
// Operation errors
|
// Operation errors
|
||||||
ErrOperationNotFound = errors.New("operation not found")
|
ErrOperationNotFound = errors.New("operation not found")
|
||||||
|
|
||||||
// Conversation errors
|
|
||||||
ErrConversationNotFound = errors.New("conversation not found")
|
|
||||||
ErrMessageNotFound = errors.New("message not found")
|
|
||||||
|
|
||||||
// Blueprint errors
|
|
||||||
ErrBlueprintNotFound = errors.New("blueprint not found")
|
|
||||||
|
|
||||||
// Question errors
|
|
||||||
ErrQuestionNotFound = errors.New("question not found")
|
|
||||||
|
|
||||||
// Infrastructure errors (should typically be wrapped)
|
// Infrastructure errors (should typically be wrapped)
|
||||||
ErrDatabaseConnection = errors.New("database connection error")
|
ErrDatabaseConnection = errors.New("database connection error")
|
||||||
ErrKubernetesError = errors.New("kubernetes error")
|
ErrKubernetesError = errors.New("kubernetes error")
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// QuestionID is a strongly-typed identifier for questions.
|
|
||||||
type QuestionID string
|
|
||||||
|
|
||||||
// QuestionType represents different types of questions.
|
|
||||||
type QuestionType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
QuestionTypeText QuestionType = "text"
|
|
||||||
QuestionTypeChoice QuestionType = "choice" // Single choice
|
|
||||||
QuestionTypeMultiChoice QuestionType = "multichoice" // Multiple choices
|
|
||||||
QuestionTypeYesNo QuestionType = "yesno"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Question represents a structured question in the architect flow.
|
|
||||||
type Question struct {
|
|
||||||
ID QuestionID
|
|
||||||
ConversationID ConversationID
|
|
||||||
ProjectID string
|
|
||||||
Type QuestionType
|
|
||||||
Text string
|
|
||||||
Choices []string // For choice/multichoice types
|
|
||||||
Answer *string // User's text answer
|
|
||||||
AnswerChoices []string // For multichoice type
|
|
||||||
Metadata map[string]string // Additional context
|
|
||||||
CreatedAt time.Time
|
|
||||||
AnsweredAt *time.Time
|
|
||||||
}
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/service"
|
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ArchitectHandler handles architect orchestration endpoints.
|
|
||||||
type ArchitectHandler struct {
|
|
||||||
architectService *service.ArchitectService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewArchitectHandler creates a new architect handler.
|
|
||||||
func NewArchitectHandler(architectService *service.ArchitectService) *ArchitectHandler {
|
|
||||||
return &ArchitectHandler{
|
|
||||||
architectService: architectService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount registers architect routes.
|
|
||||||
func (h *ArchitectHandler) Mount(r api.Router) {
|
|
||||||
r.Route("/projects/{id}/architect", func(r chi.Router) {
|
|
||||||
// All architect operations require execute scope
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/start", h.StartConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/continue/{conversationId}", h.ContinueConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/generate-blueprint/{conversationId}", h.GenerateBlueprint)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartConversationRequest is the request body for POST /projects/{id}/architect/start.
|
|
||||||
type StartConversationRequest struct {
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartConversation begins a new architectural conversation.
|
|
||||||
// POST /projects/{id}/architect/start
|
|
||||||
func (h *ArchitectHandler) StartConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req StartConversationRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Prompt, "prompt")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conv, err := h.architectService.StartConversation(r.Context(), projectID, req.Prompt)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to start conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, StartConversationResponse{
|
|
||||||
ConversationWithMessagesDTO: *toConversationWithMessagesDTO(conv),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContinueConversationRequest is the request body for POST /projects/{id}/architect/continue/{conversationId}.
|
|
||||||
type ContinueConversationRequest struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContinueConversation continues an existing architectural conversation.
|
|
||||||
// POST /projects/{id}/architect/continue/{conversationId}
|
|
||||||
func (h *ArchitectHandler) ContinueConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
var req ContinueConversationRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Message, "message")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := h.architectService.ContinueConversation(r.Context(), conversationID, req.Message)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to continue conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, ContinueConversationResponse{
|
|
||||||
Message: toMessageDTO(msg),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlueprintRequest is the request body for POST /projects/{id}/architect/generate-blueprint/{conversationId}.
|
|
||||||
type GenerateBlueprintRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlueprint generates a structured blueprint from a conversation.
|
|
||||||
// POST /projects/{id}/architect/generate-blueprint/{conversationId}
|
|
||||||
func (h *ArchitectHandler) GenerateBlueprint(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
var req GenerateBlueprintRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Name, "name")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
blueprint, err := h.architectService.GenerateBlueprint(r.Context(), conversationID, req.Name)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to generate blueprint")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, GenerateBlueprintResponse{
|
|
||||||
Blueprint: toBlueprintDTO(blueprint),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
// StartConversationResponse is the response for starting an architectural conversation.
|
|
||||||
type StartConversationResponse struct {
|
|
||||||
ConversationWithMessagesDTO
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContinueConversationResponse is the response for continuing a conversation.
|
|
||||||
type ContinueConversationResponse struct {
|
|
||||||
Message *MessageDTO `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlueprintResponse is the response for generating a blueprint from a conversation.
|
|
||||||
type GenerateBlueprintResponse struct {
|
|
||||||
Blueprint *BlueprintDTO `json:"blueprint"`
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "github.com/orchard9/rdev/internal/domain"
|
|
||||||
|
|
||||||
// BlueprintDTO is the data transfer object for blueprints.
|
|
||||||
type BlueprintDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Spec map[string]any `json:"spec"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toBlueprintDTO(b *domain.Blueprint) *BlueprintDTO {
|
|
||||||
if b == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &BlueprintDTO{
|
|
||||||
ID: string(b.ID),
|
|
||||||
ProjectID: b.ProjectID,
|
|
||||||
Name: b.Name,
|
|
||||||
Description: b.Description,
|
|
||||||
Spec: b.Spec,
|
|
||||||
CreatedAt: b.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: b.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/service"
|
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BlueprintsHandler handles blueprint endpoints.
|
|
||||||
type BlueprintsHandler struct {
|
|
||||||
blueprintService *service.BlueprintService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBlueprintsHandler creates a new blueprints handler.
|
|
||||||
func NewBlueprintsHandler(blueprintService *service.BlueprintService) *BlueprintsHandler {
|
|
||||||
return &BlueprintsHandler{
|
|
||||||
blueprintService: blueprintService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount registers blueprint routes.
|
|
||||||
func (h *BlueprintsHandler) Mount(r api.Router) {
|
|
||||||
r.Route("/projects/{id}/blueprints", func(r chi.Router) {
|
|
||||||
// Read operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/", h.ListBlueprints)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/{blueprintId}", h.GetBlueprint)
|
|
||||||
|
|
||||||
// Write operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/", h.CreateBlueprint)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Put("/{blueprintId}", h.UpdateBlueprint)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Delete("/{blueprintId}", h.DeleteBlueprint)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBlueprintRequest is the request body for POST /projects/{id}/blueprints.
|
|
||||||
type CreateBlueprintRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Spec map[string]any `json:"spec"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBlueprint creates a new blueprint.
|
|
||||||
// POST /projects/{id}/blueprints
|
|
||||||
func (h *BlueprintsHandler) CreateBlueprint(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req CreateBlueprintRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Name, "name")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
blueprint, err := h.blueprintService.CreateBlueprint(r.Context(), projectID, req.Name, req.Description, req.Spec)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to create blueprint")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, toBlueprintDTO(blueprint))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBlueprints returns all blueprints for a project.
|
|
||||||
// GET /projects/{id}/blueprints
|
|
||||||
func (h *BlueprintsHandler) ListBlueprints(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
blueprints, err := h.blueprintService.ListBlueprints(r.Context(), projectID)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to list blueprints")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos := make([]*BlueprintDTO, len(blueprints))
|
|
||||||
for i, b := range blueprints {
|
|
||||||
dtos[i] = toBlueprintDTO(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"blueprints": dtos,
|
|
||||||
"total": len(dtos),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBlueprint retrieves a blueprint by ID.
|
|
||||||
// GET /projects/{id}/blueprints/{blueprintId}
|
|
||||||
func (h *BlueprintsHandler) GetBlueprint(w http.ResponseWriter, r *http.Request) {
|
|
||||||
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
|
|
||||||
|
|
||||||
blueprint, err := h.blueprintService.GetBlueprint(r.Context(), blueprintID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrBlueprintNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "blueprint not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to get blueprint")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, toBlueprintDTO(blueprint))
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBlueprintRequest is the request body for PUT /projects/{id}/blueprints/{blueprintId}.
|
|
||||||
type UpdateBlueprintRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Description string `json:"description,omitempty"`
|
|
||||||
Spec map[string]any `json:"spec"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBlueprint updates a blueprint.
|
|
||||||
// PUT /projects/{id}/blueprints/{blueprintId}
|
|
||||||
func (h *BlueprintsHandler) UpdateBlueprint(w http.ResponseWriter, r *http.Request) {
|
|
||||||
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
|
|
||||||
|
|
||||||
var req UpdateBlueprintRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.blueprintService.UpdateBlueprint(r.Context(), blueprintID, req.Name, req.Description, req.Spec); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrBlueprintNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "blueprint not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to update blueprint")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "blueprint updated",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteBlueprint deletes a blueprint.
|
|
||||||
// DELETE /projects/{id}/blueprints/{blueprintId}
|
|
||||||
func (h *BlueprintsHandler) DeleteBlueprint(w http.ResponseWriter, r *http.Request) {
|
|
||||||
blueprintID := domain.BlueprintID(chi.URLParam(r, "blueprintId"))
|
|
||||||
|
|
||||||
if err := h.blueprintService.DeleteBlueprint(r.Context(), blueprintID); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrBlueprintNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "blueprint not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to delete blueprint")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "blueprint deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "github.com/orchard9/rdev/internal/domain"
|
|
||||||
|
|
||||||
// ConversationDTO is the data transfer object for conversations.
|
|
||||||
type ConversationDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
LastMessageAt *string `json:"last_message_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessageDTO is the data transfer object for messages.
|
|
||||||
type MessageDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ConversationID string `json:"conversation_id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConversationWithMessagesDTO combines a conversation with its messages.
|
|
||||||
type ConversationWithMessagesDTO struct {
|
|
||||||
ConversationDTO
|
|
||||||
Messages []*MessageDTO `json:"messages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toConversationDTO(c *domain.Conversation) *ConversationDTO {
|
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dto := &ConversationDTO{
|
|
||||||
ID: string(c.ID),
|
|
||||||
ProjectID: c.ProjectID,
|
|
||||||
Title: c.Title,
|
|
||||||
CreatedAt: c.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
UpdatedAt: c.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
if c.LastMessage != nil {
|
|
||||||
ts := c.LastMessage.Format("2006-01-02T15:04:05Z07:00")
|
|
||||||
dto.LastMessageAt = &ts
|
|
||||||
}
|
|
||||||
return dto
|
|
||||||
}
|
|
||||||
|
|
||||||
func toMessageDTO(m *domain.Message) *MessageDTO {
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &MessageDTO{
|
|
||||||
ID: string(m.ID),
|
|
||||||
ConversationID: string(m.ConversationID),
|
|
||||||
Role: string(m.Role),
|
|
||||||
Content: m.Content,
|
|
||||||
CreatedAt: m.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toConversationWithMessagesDTO(c *domain.ConversationWithMessages) *ConversationWithMessagesDTO {
|
|
||||||
if c == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
messages := make([]*MessageDTO, len(c.Messages))
|
|
||||||
for i, m := range c.Messages {
|
|
||||||
messages[i] = toMessageDTO(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
dto := toConversationDTO(&c.Conversation)
|
|
||||||
return &ConversationWithMessagesDTO{
|
|
||||||
ConversationDTO: *dto,
|
|
||||||
Messages: messages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/service"
|
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConversationsHandler handles conversation endpoints.
|
|
||||||
type ConversationsHandler struct {
|
|
||||||
conversationService *service.ConversationService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConversationsHandler creates a new conversations handler.
|
|
||||||
func NewConversationsHandler(conversationService *service.ConversationService) *ConversationsHandler {
|
|
||||||
return &ConversationsHandler{
|
|
||||||
conversationService: conversationService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount registers conversation routes.
|
|
||||||
func (h *ConversationsHandler) Mount(r api.Router) {
|
|
||||||
r.Route("/projects/{id}/conversations", func(r chi.Router) {
|
|
||||||
// Read operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/", h.ListConversations)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/{conversationId}", h.GetConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/{conversationId}/messages", h.GetMessages)
|
|
||||||
|
|
||||||
// Write operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/", h.CreateConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Patch("/{conversationId}", h.UpdateConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Delete("/{conversationId}", h.DeleteConversation)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/{conversationId}/messages", h.AddMessage)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateConversationRequest is the request body for POST /projects/{id}/conversations.
|
|
||||||
type CreateConversationRequest struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateConversation creates a new conversation.
|
|
||||||
// POST /projects/{id}/conversations
|
|
||||||
func (h *ConversationsHandler) CreateConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req CreateConversationRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
conv, err := h.conversationService.CreateConversation(r.Context(), projectID, req.Title)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to create conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, toConversationDTO(conv))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListConversations returns all conversations for a project.
|
|
||||||
// GET /projects/{id}/conversations
|
|
||||||
func (h *ConversationsHandler) ListConversations(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
convs, err := h.conversationService.ListConversations(r.Context(), projectID)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to list conversations")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos := make([]*ConversationDTO, len(convs))
|
|
||||||
for i, c := range convs {
|
|
||||||
dtos[i] = toConversationDTO(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"conversations": dtos,
|
|
||||||
"total": len(dtos),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConversation retrieves a conversation with all messages.
|
|
||||||
// GET /projects/{id}/conversations/{conversationId}
|
|
||||||
func (h *ConversationsHandler) GetConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
conv, err := h.conversationService.GetConversationWithMessages(r.Context(), conversationID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to get conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, toConversationWithMessagesDTO(conv))
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConversationRequest is the request body for PATCH /projects/{id}/conversations/{conversationId}.
|
|
||||||
type UpdateConversationRequest struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateConversation updates conversation metadata.
|
|
||||||
// PATCH /projects/{id}/conversations/{conversationId}
|
|
||||||
func (h *ConversationsHandler) UpdateConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
var req UpdateConversationRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Title, "title")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.conversationService.UpdateTitle(r.Context(), conversationID, req.Title); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to update conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "conversation updated",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteConversation deletes a conversation.
|
|
||||||
// DELETE /projects/{id}/conversations/{conversationId}
|
|
||||||
func (h *ConversationsHandler) DeleteConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
if err := h.conversationService.DeleteConversation(r.Context(), conversationID); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to delete conversation")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "conversation deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMessageRequest is the request body for POST /projects/{id}/conversations/{conversationId}/messages.
|
|
||||||
type AddMessageRequest struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMessage adds a message to a conversation.
|
|
||||||
// POST /projects/{id}/conversations/{conversationId}/messages
|
|
||||||
func (h *ConversationsHandler) AddMessage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
var req AddMessageRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.Role, "role")
|
|
||||||
v.Required(req.Content, "content")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
role := domain.MessageRole(req.Role)
|
|
||||||
if role != domain.MessageRoleUser && role != domain.MessageRoleAssistant && role != domain.MessageRoleSystem {
|
|
||||||
api.WriteBadRequest(w, r, "role must be 'user', 'assistant', or 'system'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, err := h.conversationService.AddMessage(r.Context(), conversationID, role, req.Content)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrConversationNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "conversation not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to add message")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, toMessageDTO(msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages retrieves all messages for a conversation.
|
|
||||||
// GET /projects/{id}/conversations/{conversationId}/messages
|
|
||||||
func (h *ConversationsHandler) GetMessages(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
messages, err := h.conversationService.GetMessages(r.Context(), conversationID)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to get messages")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos := make([]*MessageDTO, len(messages))
|
|
||||||
for i, m := range messages {
|
|
||||||
dtos[i] = toMessageDTO(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"messages": dtos,
|
|
||||||
"total": len(dtos),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import "github.com/orchard9/rdev/internal/domain"
|
|
||||||
|
|
||||||
// QuestionDTO is the data transfer object for questions.
|
|
||||||
type QuestionDTO struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ConversationID string `json:"conversation_id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Choices []string `json:"choices,omitempty"`
|
|
||||||
Answer *string `json:"answer,omitempty"`
|
|
||||||
AnswerChoices []string `json:"answer_choices,omitempty"`
|
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
AnsweredAt *string `json:"answered_at,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toQuestionDTO(q *domain.Question) *QuestionDTO {
|
|
||||||
if q == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
dto := &QuestionDTO{
|
|
||||||
ID: string(q.ID),
|
|
||||||
ConversationID: string(q.ConversationID),
|
|
||||||
ProjectID: q.ProjectID,
|
|
||||||
Type: string(q.Type),
|
|
||||||
Text: q.Text,
|
|
||||||
Choices: q.Choices,
|
|
||||||
Answer: q.Answer,
|
|
||||||
AnswerChoices: q.AnswerChoices,
|
|
||||||
Metadata: q.Metadata,
|
|
||||||
CreatedAt: q.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
}
|
|
||||||
if q.AnsweredAt != nil {
|
|
||||||
ts := q.AnsweredAt.Format("2006-01-02T15:04:05Z07:00")
|
|
||||||
dto.AnsweredAt = &ts
|
|
||||||
}
|
|
||||||
return dto
|
|
||||||
}
|
|
||||||
@ -1,211 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/orchard9/rdev/internal/auth"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/service"
|
|
||||||
"github.com/orchard9/rdev/internal/validate"
|
|
||||||
"github.com/orchard9/rdev/pkg/api"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QuestionsHandler handles question endpoints.
|
|
||||||
type QuestionsHandler struct {
|
|
||||||
questionService *service.QuestionService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQuestionsHandler creates a new questions handler.
|
|
||||||
func NewQuestionsHandler(questionService *service.QuestionService) *QuestionsHandler {
|
|
||||||
return &QuestionsHandler{
|
|
||||||
questionService: questionService,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount registers question routes.
|
|
||||||
func (h *QuestionsHandler) Mount(r api.Router) {
|
|
||||||
r.Route("/projects/{id}/questions", func(r chi.Router) {
|
|
||||||
// Read operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/", h.ListUnansweredQuestions)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/{questionId}", h.GetQuestion)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsRead, auth.ScopeAdmin)).
|
|
||||||
Get("/conversation/{conversationId}", h.ListQuestionsByConversation)
|
|
||||||
|
|
||||||
// Write operations
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/", h.CreateQuestion)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Post("/{questionId}/answer", h.AnswerQuestion)
|
|
||||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
|
||||||
Delete("/{questionId}", h.DeleteQuestion)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateQuestionRequest is the request body for POST /projects/{id}/questions.
|
|
||||||
type CreateQuestionRequest struct {
|
|
||||||
ConversationID string `json:"conversation_id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Choices []string `json:"choices,omitempty"`
|
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateQuestion creates a new question.
|
|
||||||
// POST /projects/{id}/questions
|
|
||||||
func (h *QuestionsHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req CreateQuestionRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
v := validate.New()
|
|
||||||
v.Required(req.ConversationID, "conversation_id")
|
|
||||||
v.Required(req.Type, "type")
|
|
||||||
v.Required(req.Text, "text")
|
|
||||||
if err := v.Error(); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
questionType := domain.QuestionType(req.Type)
|
|
||||||
if questionType != domain.QuestionTypeText && questionType != domain.QuestionTypeChoice &&
|
|
||||||
questionType != domain.QuestionTypeMultiChoice && questionType != domain.QuestionTypeYesNo {
|
|
||||||
api.WriteBadRequest(w, r, "type must be 'text', 'choice', 'multichoice', or 'yesno'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
question, err := h.questionService.CreateQuestion(
|
|
||||||
r.Context(),
|
|
||||||
domain.ConversationID(req.ConversationID),
|
|
||||||
projectID,
|
|
||||||
questionType,
|
|
||||||
req.Text,
|
|
||||||
req.Choices,
|
|
||||||
req.Metadata,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to create question")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteCreated(w, r, toQuestionDTO(question))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUnansweredQuestions returns all unanswered questions for a project.
|
|
||||||
// GET /projects/{id}/questions
|
|
||||||
func (h *QuestionsHandler) ListUnansweredQuestions(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
questions, err := h.questionService.ListUnansweredQuestions(r.Context(), projectID)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to list questions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos := make([]*QuestionDTO, len(questions))
|
|
||||||
for i, q := range questions {
|
|
||||||
dtos[i] = toQuestionDTO(q)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"questions": dtos,
|
|
||||||
"total": len(dtos),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetQuestion retrieves a question by ID.
|
|
||||||
// GET /projects/{id}/questions/{questionId}
|
|
||||||
func (h *QuestionsHandler) GetQuestion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
questionID := domain.QuestionID(chi.URLParam(r, "questionId"))
|
|
||||||
|
|
||||||
question, err := h.questionService.GetQuestion(r.Context(), questionID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, domain.ErrQuestionNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "question not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to get question")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, toQuestionDTO(question))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListQuestionsByConversation returns all questions for a conversation.
|
|
||||||
// GET /projects/{id}/questions/conversation/{conversationId}
|
|
||||||
func (h *QuestionsHandler) ListQuestionsByConversation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
conversationID := domain.ConversationID(chi.URLParam(r, "conversationId"))
|
|
||||||
|
|
||||||
questions, err := h.questionService.ListQuestionsByConversation(r.Context(), conversationID)
|
|
||||||
if err != nil {
|
|
||||||
api.WriteInternalError(w, r, "failed to list questions")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dtos := make([]*QuestionDTO, len(questions))
|
|
||||||
for i, q := range questions {
|
|
||||||
dtos[i] = toQuestionDTO(q)
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"questions": dtos,
|
|
||||||
"total": len(dtos),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerQuestionRequest is the request body for POST /projects/{id}/questions/{questionId}/answer.
|
|
||||||
type AnswerQuestionRequest struct {
|
|
||||||
Answer *string `json:"answer,omitempty"`
|
|
||||||
AnswerChoices []string `json:"answer_choices,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerQuestion records an answer to a question.
|
|
||||||
// POST /projects/{id}/questions/{questionId}/answer
|
|
||||||
func (h *QuestionsHandler) AnswerQuestion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
questionID := domain.QuestionID(chi.URLParam(r, "questionId"))
|
|
||||||
|
|
||||||
var req AnswerQuestionRequest
|
|
||||||
if err := api.DecodeJSON(r, &req); err != nil {
|
|
||||||
api.WriteBadRequest(w, r, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.questionService.AnswerQuestion(r.Context(), questionID, req.Answer, req.AnswerChoices); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrQuestionNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "question not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteBadRequest(w, r, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "question answered",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteQuestion deletes a question.
|
|
||||||
// DELETE /projects/{id}/questions/{questionId}
|
|
||||||
func (h *QuestionsHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
questionID := domain.QuestionID(chi.URLParam(r, "questionId"))
|
|
||||||
|
|
||||||
if err := h.questionService.DeleteQuestion(r.Context(), questionID); err != nil {
|
|
||||||
if errors.Is(err, domain.ErrQuestionNotFound) {
|
|
||||||
api.WriteNotFound(w, r, "question not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.WriteInternalError(w, r, "failed to delete question")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api.WriteSuccess(w, r, map[string]any{
|
|
||||||
"message": "question deleted",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BlueprintRepository defines operations for blueprint persistence.
|
|
||||||
type BlueprintRepository interface {
|
|
||||||
// CreateBlueprint creates a new blueprint.
|
|
||||||
CreateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error
|
|
||||||
|
|
||||||
// GetBlueprint retrieves a blueprint by ID.
|
|
||||||
GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error)
|
|
||||||
|
|
||||||
// ListBlueprints returns all blueprints for a project.
|
|
||||||
ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error)
|
|
||||||
|
|
||||||
// UpdateBlueprint updates a blueprint's metadata and spec.
|
|
||||||
UpdateBlueprint(ctx context.Context, blueprint *domain.Blueprint) error
|
|
||||||
|
|
||||||
// DeleteBlueprint deletes a blueprint.
|
|
||||||
DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConversationRepository defines operations for conversation persistence.
|
|
||||||
type ConversationRepository interface {
|
|
||||||
// CreateConversation creates a new conversation.
|
|
||||||
CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error)
|
|
||||||
|
|
||||||
// GetConversation retrieves a conversation by ID.
|
|
||||||
GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error)
|
|
||||||
|
|
||||||
// ListConversations returns all conversations for a project.
|
|
||||||
ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error)
|
|
||||||
|
|
||||||
// UpdateConversationTitle updates the conversation title.
|
|
||||||
UpdateConversationTitle(ctx context.Context, id domain.ConversationID, title string) error
|
|
||||||
|
|
||||||
// DeleteConversation deletes a conversation and all its messages.
|
|
||||||
DeleteConversation(ctx context.Context, id domain.ConversationID) error
|
|
||||||
|
|
||||||
// AddMessage adds a message to a conversation.
|
|
||||||
AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error)
|
|
||||||
|
|
||||||
// GetMessages retrieves all messages for a conversation.
|
|
||||||
GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error)
|
|
||||||
|
|
||||||
// GetConversationWithMessages retrieves a conversation with all messages.
|
|
||||||
GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error)
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
package port
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QuestionRepository defines operations for question persistence.
|
|
||||||
type QuestionRepository interface {
|
|
||||||
// CreateQuestion creates a new question.
|
|
||||||
CreateQuestion(ctx context.Context, question *domain.Question) error
|
|
||||||
|
|
||||||
// GetQuestion retrieves a question by ID.
|
|
||||||
GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error)
|
|
||||||
|
|
||||||
// ListUnansweredQuestions returns all unanswered questions for a project.
|
|
||||||
ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error)
|
|
||||||
|
|
||||||
// ListQuestionsByConversation returns all questions for a conversation.
|
|
||||||
ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error)
|
|
||||||
|
|
||||||
// AnswerQuestion records an answer to a question.
|
|
||||||
AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error
|
|
||||||
|
|
||||||
// DeleteQuestion deletes a question.
|
|
||||||
DeleteQuestion(ctx context.Context, id domain.QuestionID) error
|
|
||||||
}
|
|
||||||
@ -1,303 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/logging"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ArchitectService orchestrates conversational project design with Claude.
|
|
||||||
type ArchitectService struct {
|
|
||||||
conversationService *ConversationService
|
|
||||||
blueprintService *BlueprintService
|
|
||||||
agentRegistry port.CodeAgentRegistry
|
|
||||||
projectRepo port.ProjectRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewArchitectService creates a new architect service.
|
|
||||||
func NewArchitectService(
|
|
||||||
conversationService *ConversationService,
|
|
||||||
blueprintService *BlueprintService,
|
|
||||||
agentRegistry port.CodeAgentRegistry,
|
|
||||||
projectRepo port.ProjectRepository,
|
|
||||||
) *ArchitectService {
|
|
||||||
return &ArchitectService{
|
|
||||||
conversationService: conversationService,
|
|
||||||
blueprintService: blueprintService,
|
|
||||||
agentRegistry: agentRegistry,
|
|
||||||
projectRepo: projectRepo,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartConversation begins a new architectural conversation.
|
|
||||||
func (s *ArchitectService) StartConversation(ctx context.Context, projectID, initialPrompt string) (*domain.ConversationWithMessages, error) {
|
|
||||||
// Create conversation
|
|
||||||
conv, err := s.conversationService.CreateConversation(ctx, projectID, "Architectural Design")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create conversation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user's initial message
|
|
||||||
_, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleUser, initialPrompt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("add user message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get agent response
|
|
||||||
response, err := s.askArchitect(ctx, projectID, conv.ID, initialPrompt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ask architect: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add assistant's response
|
|
||||||
_, err = s.conversationService.AddMessage(ctx, conv.ID, domain.MessageRoleAssistant, response)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("add assistant message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("architectural conversation started",
|
|
||||||
"conversation_id", conv.ID,
|
|
||||||
logging.FieldProjectID, projectID,
|
|
||||||
logging.FieldOperation, "start_architect_conversation",
|
|
||||||
)
|
|
||||||
|
|
||||||
return s.conversationService.GetConversationWithMessages(ctx, conv.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ContinueConversation adds a message and gets agent response.
|
|
||||||
func (s *ArchitectService) ContinueConversation(ctx context.Context, conversationID domain.ConversationID, userMessage string) (*domain.Message, error) {
|
|
||||||
// Get conversation to find project
|
|
||||||
conv, err := s.conversationService.GetConversation(ctx, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user message
|
|
||||||
_, err = s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleUser, userMessage)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("add user message: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get conversation history for context
|
|
||||||
messages, err := s.conversationService.GetMessages(ctx, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build context from history
|
|
||||||
context := s.buildConversationContext(messages)
|
|
||||||
|
|
||||||
// Get agent response
|
|
||||||
response, err := s.askArchitect(ctx, conv.ProjectID, conversationID, context)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("ask architect: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add assistant's response
|
|
||||||
return s.conversationService.AddMessage(ctx, conversationID, domain.MessageRoleAssistant, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateBlueprint creates a blueprint from a conversation.
|
|
||||||
func (s *ArchitectService) GenerateBlueprint(ctx context.Context, conversationID domain.ConversationID, blueprintName string) (*domain.Blueprint, error) {
|
|
||||||
conv, err := s.conversationService.GetConversation(ctx, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
messages, err := s.conversationService.GetMessages(ctx, conversationID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract structured spec from conversation
|
|
||||||
spec, err := s.extractSpecFromMessages(ctx, conv.ProjectID, messages)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("extract spec: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create blueprint
|
|
||||||
return s.blueprintService.CreateBlueprint(ctx, conv.ProjectID, blueprintName, "Generated from architectural conversation", spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// askArchitect sends a prompt to Claude and gets the response.
|
|
||||||
func (s *ArchitectService) askArchitect(ctx context.Context, projectID string, conversationID domain.ConversationID, prompt string) (string, error) {
|
|
||||||
agent := s.agentRegistry.Default()
|
|
||||||
if agent == nil {
|
|
||||||
return "", fmt.Errorf("no agent available")
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("resolve project: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare architect-specific system prompt
|
|
||||||
systemPrompt := `You are an expert software architect helping design a new project.
|
|
||||||
|
|
||||||
Your role is to:
|
|
||||||
1. Ask clarifying questions to understand requirements deeply
|
|
||||||
2. Identify technical architecture and stack choices
|
|
||||||
3. Recommend component structure (monorepo, microservices, etc.)
|
|
||||||
4. Define infrastructure needs (database, cache, storage, messaging)
|
|
||||||
5. Consider scalability, maintainability, and team capabilities
|
|
||||||
|
|
||||||
Guidelines:
|
|
||||||
- Ask one focused question at a time
|
|
||||||
- Provide specific recommendations with trade-offs
|
|
||||||
- Think about the full system: data models, APIs, UI components, infrastructure
|
|
||||||
- When you have enough information, summarize the architecture clearly
|
|
||||||
|
|
||||||
Current conversation context:`
|
|
||||||
|
|
||||||
fullPrompt := systemPrompt + "\n\n" + prompt
|
|
||||||
|
|
||||||
agentReq := &domain.AgentRequest{
|
|
||||||
Prompt: fullPrompt,
|
|
||||||
ProjectID: project.ID,
|
|
||||||
Timeout: 2 * time.Minute,
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"conversation_id": string(conversationID),
|
|
||||||
"purpose": "architect",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
_, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) {
|
|
||||||
if event.Type == domain.AgentEventOutput {
|
|
||||||
output.WriteString(event.Content)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("agent execution: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return output.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildConversationContext combines message history into a single context string.
|
|
||||||
func (s *ArchitectService) buildConversationContext(messages []*domain.Message) string {
|
|
||||||
var context strings.Builder
|
|
||||||
for _, msg := range messages {
|
|
||||||
role := string(msg.Role)
|
|
||||||
context.WriteString(fmt.Sprintf("%s: %s\n\n", role, msg.Content))
|
|
||||||
}
|
|
||||||
return context.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractSpecFromMessages parses conversation to extract structured blueprint spec.
|
|
||||||
func (s *ArchitectService) extractSpecFromMessages(ctx context.Context, projectID string, messages []*domain.Message) (map[string]any, error) {
|
|
||||||
// Use Claude to analyze conversation and extract structured data
|
|
||||||
agent := s.agentRegistry.Default()
|
|
||||||
if agent == nil {
|
|
||||||
return nil, fmt.Errorf("no agent available")
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := s.projectRepo.Get(ctx, domain.ProjectID(projectID))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("resolve project: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build conversation transcript
|
|
||||||
transcript := s.buildConversationContext(messages)
|
|
||||||
|
|
||||||
// Prompt to extract structured spec
|
|
||||||
extractionPrompt := fmt.Sprintf(`Analyze this architectural conversation and extract a structured JSON specification.
|
|
||||||
|
|
||||||
Conversation transcript:
|
|
||||||
%s
|
|
||||||
|
|
||||||
Extract and return ONLY a valid JSON object with this structure:
|
|
||||||
{
|
|
||||||
"version": "1.0",
|
|
||||||
"architecture": {
|
|
||||||
"type": "monorepo|microservices|monolith",
|
|
||||||
"description": "...",
|
|
||||||
"components": [
|
|
||||||
{"name": "...", "type": "service|app|worker|cli", "description": "..."}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"data_models": [
|
|
||||||
{"name": "...", "description": "...", "fields": [...]}
|
|
||||||
],
|
|
||||||
"api_endpoints": [
|
|
||||||
{"path": "...", "method": "GET|POST|PUT|DELETE", "description": "..."}
|
|
||||||
],
|
|
||||||
"infrastructure": {
|
|
||||||
"database": {"type": "postgres|mysql|mongo", "required": true|false},
|
|
||||||
"cache": {"type": "redis|memcached", "required": true|false},
|
|
||||||
"storage": {"type": "s3|gcs", "required": true|false},
|
|
||||||
"messaging": {"type": "rabbitmq|kafka", "required": true|false}
|
|
||||||
},
|
|
||||||
"features": [
|
|
||||||
{"name": "...", "description": "...", "priority": "high|medium|low"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Return ONLY the JSON, no other text.`, transcript)
|
|
||||||
|
|
||||||
agentReq := &domain.AgentRequest{
|
|
||||||
Prompt: extractionPrompt,
|
|
||||||
ProjectID: project.ID,
|
|
||||||
Timeout: 2 * time.Minute,
|
|
||||||
Metadata: map[string]string{
|
|
||||||
"purpose": "spec-extraction",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var output strings.Builder
|
|
||||||
_, err = agent.Execute(ctx, agentReq, func(event domain.AgentEvent) {
|
|
||||||
if event.Type == domain.AgentEventOutput {
|
|
||||||
output.WriteString(event.Content)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("agent execution: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse JSON response
|
|
||||||
response := output.String()
|
|
||||||
|
|
||||||
// Extract JSON from markdown code blocks if present
|
|
||||||
if strings.Contains(response, "```json") {
|
|
||||||
start := strings.Index(response, "```json") + 7
|
|
||||||
end := strings.Index(response[start:], "```")
|
|
||||||
if end != -1 {
|
|
||||||
response = response[start : start+end]
|
|
||||||
}
|
|
||||||
} else if strings.Contains(response, "```") {
|
|
||||||
start := strings.Index(response, "```") + 3
|
|
||||||
end := strings.Index(response[start:], "```")
|
|
||||||
if end != -1 {
|
|
||||||
response = response[start : start+end]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
response = strings.TrimSpace(response)
|
|
||||||
|
|
||||||
var spec map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(response), &spec); err != nil {
|
|
||||||
// Fallback: create basic spec if parsing fails
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Warn("failed to parse agent spec extraction, using fallback",
|
|
||||||
logging.FieldError, err,
|
|
||||||
logging.FieldOperation, "extract_blueprint_spec",
|
|
||||||
"response_length", len(response),
|
|
||||||
"message_count", len(messages),
|
|
||||||
)
|
|
||||||
spec = map[string]any{
|
|
||||||
"version": "1.0",
|
|
||||||
"generated_at": time.Now().Format(time.RFC3339),
|
|
||||||
"message_count": len(messages),
|
|
||||||
"extraction_failed": true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return spec, nil
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/logging"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BlueprintService orchestrates blueprint operations.
|
|
||||||
type BlueprintService struct {
|
|
||||||
repo port.BlueprintRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBlueprintService creates a new blueprint service.
|
|
||||||
func NewBlueprintService(repo port.BlueprintRepository) *BlueprintService {
|
|
||||||
return &BlueprintService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateBlueprint creates a new blueprint.
|
|
||||||
func (s *BlueprintService) CreateBlueprint(ctx context.Context, projectID, name, description string, spec map[string]any) (*domain.Blueprint, error) {
|
|
||||||
if projectID == "" {
|
|
||||||
return nil, fmt.Errorf("project_id is required")
|
|
||||||
}
|
|
||||||
if name == "" {
|
|
||||||
return nil, fmt.Errorf("name is required")
|
|
||||||
}
|
|
||||||
if spec == nil {
|
|
||||||
spec = make(map[string]any)
|
|
||||||
}
|
|
||||||
|
|
||||||
blueprint := &domain.Blueprint{
|
|
||||||
ID: domain.BlueprintID(uuid.New().String()),
|
|
||||||
ProjectID: projectID,
|
|
||||||
Name: name,
|
|
||||||
Description: description,
|
|
||||||
Spec: spec,
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.CreateBlueprint(ctx, blueprint); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("blueprint created",
|
|
||||||
"blueprint_id", blueprint.ID,
|
|
||||||
logging.FieldProjectID, projectID,
|
|
||||||
logging.FieldOperation, "create_blueprint",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
"name", name,
|
|
||||||
"has_spec", len(spec) > 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
return blueprint, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetBlueprint retrieves a blueprint by ID.
|
|
||||||
func (s *BlueprintService) GetBlueprint(ctx context.Context, id domain.BlueprintID) (*domain.Blueprint, error) {
|
|
||||||
return s.repo.GetBlueprint(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBlueprints returns all blueprints for a project.
|
|
||||||
func (s *BlueprintService) ListBlueprints(ctx context.Context, projectID string) ([]*domain.Blueprint, error) {
|
|
||||||
return s.repo.ListBlueprints(ctx, projectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateBlueprint updates a blueprint's metadata and spec.
|
|
||||||
func (s *BlueprintService) UpdateBlueprint(ctx context.Context, id domain.BlueprintID, name, description string, spec map[string]any) error {
|
|
||||||
// Get existing blueprint first
|
|
||||||
blueprint, err := s.repo.GetBlueprint(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update fields
|
|
||||||
if name != "" {
|
|
||||||
blueprint.Name = name
|
|
||||||
}
|
|
||||||
blueprint.Description = description
|
|
||||||
if spec != nil {
|
|
||||||
blueprint.Spec = spec
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.UpdateBlueprint(ctx, blueprint); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("blueprint updated",
|
|
||||||
"blueprint_id", id,
|
|
||||||
logging.FieldProjectID, blueprint.ProjectID,
|
|
||||||
logging.FieldOperation, "update_blueprint",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteBlueprint deletes a blueprint.
|
|
||||||
func (s *BlueprintService) DeleteBlueprint(ctx context.Context, id domain.BlueprintID) error {
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.DeleteBlueprint(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("blueprint deleted",
|
|
||||||
"blueprint_id", id,
|
|
||||||
logging.FieldOperation, "delete_blueprint",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/logging"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConversationService orchestrates conversation operations.
|
|
||||||
type ConversationService struct {
|
|
||||||
repo port.ConversationRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewConversationService creates a new conversation service.
|
|
||||||
func NewConversationService(repo port.ConversationRepository) *ConversationService {
|
|
||||||
return &ConversationService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateConversation creates a new conversation.
|
|
||||||
func (s *ConversationService) CreateConversation(ctx context.Context, projectID, title string) (*domain.Conversation, error) {
|
|
||||||
if projectID == "" {
|
|
||||||
return nil, fmt.Errorf("project_id is required")
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
title = "New Conversation"
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
conv, err := s.repo.CreateConversation(ctx, projectID, title)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("conversation created",
|
|
||||||
"conversation_id", conv.ID,
|
|
||||||
logging.FieldProjectID, projectID,
|
|
||||||
logging.FieldOperation, "create_conversation",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
"title", title,
|
|
||||||
)
|
|
||||||
|
|
||||||
return conv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConversation retrieves a conversation by ID.
|
|
||||||
func (s *ConversationService) GetConversation(ctx context.Context, id domain.ConversationID) (*domain.Conversation, error) {
|
|
||||||
return s.repo.GetConversation(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListConversations returns all conversations for a project.
|
|
||||||
func (s *ConversationService) ListConversations(ctx context.Context, projectID string) ([]*domain.Conversation, error) {
|
|
||||||
return s.repo.ListConversations(ctx, projectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateTitle updates the conversation title.
|
|
||||||
func (s *ConversationService) UpdateTitle(ctx context.Context, id domain.ConversationID, title string) error {
|
|
||||||
if title == "" {
|
|
||||||
return fmt.Errorf("title is required")
|
|
||||||
}
|
|
||||||
return s.repo.UpdateConversationTitle(ctx, id, title)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteConversation deletes a conversation and all its messages.
|
|
||||||
func (s *ConversationService) DeleteConversation(ctx context.Context, id domain.ConversationID) error {
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.DeleteConversation(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("conversation deleted",
|
|
||||||
"conversation_id", id,
|
|
||||||
logging.FieldOperation, "delete_conversation",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddMessage adds a message to a conversation.
|
|
||||||
func (s *ConversationService) AddMessage(ctx context.Context, conversationID domain.ConversationID, role domain.MessageRole, content string) (*domain.Message, error) {
|
|
||||||
if content == "" {
|
|
||||||
return nil, fmt.Errorf("message content is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
msg, err := s.repo.AddMessage(ctx, conversationID, role, content)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("message added",
|
|
||||||
"conversation_id", conversationID,
|
|
||||||
"message_id", msg.ID,
|
|
||||||
"role", role,
|
|
||||||
logging.FieldOperation, "add_message",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
"content_length", len(content),
|
|
||||||
)
|
|
||||||
|
|
||||||
return msg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMessages retrieves all messages for a conversation.
|
|
||||||
func (s *ConversationService) GetMessages(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Message, error) {
|
|
||||||
return s.repo.GetMessages(ctx, conversationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetConversationWithMessages retrieves a conversation with all messages.
|
|
||||||
func (s *ConversationService) GetConversationWithMessages(ctx context.Context, id domain.ConversationID) (*domain.ConversationWithMessages, error) {
|
|
||||||
return s.repo.GetConversationWithMessages(ctx, id)
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/orchard9/rdev/internal/domain"
|
|
||||||
"github.com/orchard9/rdev/internal/logging"
|
|
||||||
"github.com/orchard9/rdev/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// QuestionService orchestrates question operations.
|
|
||||||
type QuestionService struct {
|
|
||||||
repo port.QuestionRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewQuestionService creates a new question service.
|
|
||||||
func NewQuestionService(repo port.QuestionRepository) *QuestionService {
|
|
||||||
return &QuestionService{repo: repo}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateQuestion creates a new question.
|
|
||||||
func (s *QuestionService) CreateQuestion(ctx context.Context, conversationID domain.ConversationID, projectID string, questionType domain.QuestionType, text string, choices []string, metadata map[string]string) (*domain.Question, error) {
|
|
||||||
if projectID == "" {
|
|
||||||
return nil, fmt.Errorf("project_id is required")
|
|
||||||
}
|
|
||||||
if text == "" {
|
|
||||||
return nil, fmt.Errorf("text is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate choices for choice-based questions
|
|
||||||
if (questionType == domain.QuestionTypeChoice || questionType == domain.QuestionTypeMultiChoice) && len(choices) == 0 {
|
|
||||||
return nil, fmt.Errorf("choices are required for choice-based questions")
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata == nil {
|
|
||||||
metadata = make(map[string]string)
|
|
||||||
}
|
|
||||||
|
|
||||||
question := &domain.Question{
|
|
||||||
ID: domain.QuestionID(uuid.New().String()),
|
|
||||||
ConversationID: conversationID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
Type: questionType,
|
|
||||||
Text: text,
|
|
||||||
Choices: choices,
|
|
||||||
Metadata: metadata,
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.CreateQuestion(ctx, question); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("question created",
|
|
||||||
"question_id", question.ID,
|
|
||||||
logging.FieldProjectID, projectID,
|
|
||||||
logging.FieldOperation, "create_question",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
"question_type", questionType,
|
|
||||||
"conversation_id", conversationID,
|
|
||||||
"choice_count", len(choices),
|
|
||||||
)
|
|
||||||
|
|
||||||
return question, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetQuestion retrieves a question by ID.
|
|
||||||
func (s *QuestionService) GetQuestion(ctx context.Context, id domain.QuestionID) (*domain.Question, error) {
|
|
||||||
return s.repo.GetQuestion(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUnansweredQuestions returns all unanswered questions for a project.
|
|
||||||
func (s *QuestionService) ListUnansweredQuestions(ctx context.Context, projectID string) ([]*domain.Question, error) {
|
|
||||||
return s.repo.ListUnansweredQuestions(ctx, projectID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListQuestionsByConversation returns all questions for a conversation.
|
|
||||||
func (s *QuestionService) ListQuestionsByConversation(ctx context.Context, conversationID domain.ConversationID) ([]*domain.Question, error) {
|
|
||||||
return s.repo.ListQuestionsByConversation(ctx, conversationID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AnswerQuestion records an answer to a question.
|
|
||||||
func (s *QuestionService) AnswerQuestion(ctx context.Context, id domain.QuestionID, answer *string, answerChoices []string) error {
|
|
||||||
// Get question to validate answer type
|
|
||||||
question, err := s.repo.GetQuestion(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate answer based on question type
|
|
||||||
switch question.Type {
|
|
||||||
case domain.QuestionTypeText:
|
|
||||||
if answer == nil || *answer == "" {
|
|
||||||
return fmt.Errorf("text answer is required")
|
|
||||||
}
|
|
||||||
case domain.QuestionTypeYesNo:
|
|
||||||
if answer == nil || (*answer != "yes" && *answer != "no") {
|
|
||||||
return fmt.Errorf("answer must be 'yes' or 'no'")
|
|
||||||
}
|
|
||||||
case domain.QuestionTypeChoice:
|
|
||||||
if answer == nil || *answer == "" {
|
|
||||||
return fmt.Errorf("choice answer is required")
|
|
||||||
}
|
|
||||||
// Validate choice is in the available choices
|
|
||||||
valid := false
|
|
||||||
for _, choice := range question.Choices {
|
|
||||||
if *answer == choice {
|
|
||||||
valid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return fmt.Errorf("answer must be one of the available choices")
|
|
||||||
}
|
|
||||||
case domain.QuestionTypeMultiChoice:
|
|
||||||
if len(answerChoices) == 0 {
|
|
||||||
return fmt.Errorf("at least one choice must be selected")
|
|
||||||
}
|
|
||||||
// Validate all choices are in the available choices
|
|
||||||
for _, selected := range answerChoices {
|
|
||||||
valid := false
|
|
||||||
for _, choice := range question.Choices {
|
|
||||||
if selected == choice {
|
|
||||||
valid = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return fmt.Errorf("answer '%s' is not one of the available choices", selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.AnswerQuestion(ctx, id, answer, answerChoices); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("question answered",
|
|
||||||
"question_id", id,
|
|
||||||
logging.FieldProjectID, question.ProjectID,
|
|
||||||
logging.FieldOperation, "answer_question",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
"question_type", question.Type,
|
|
||||||
"answer_choice_count", len(answerChoices),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteQuestion deletes a question.
|
|
||||||
func (s *QuestionService) DeleteQuestion(ctx context.Context, id domain.QuestionID) error {
|
|
||||||
startTime := time.Now()
|
|
||||||
if err := s.repo.DeleteQuestion(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log := logging.FromContext(ctx)
|
|
||||||
log.Info("question deleted",
|
|
||||||
"question_id", id,
|
|
||||||
logging.FieldOperation, "delete_question",
|
|
||||||
logging.FieldDuration, time.Since(startTime).Milliseconds(),
|
|
||||||
)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user