Compare commits
2 Commits
adcea2fc1f
...
88e4eb7f3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88e4eb7f3f | ||
|
|
a69eb7e587 |
@ -262,6 +262,26 @@ func main() {
|
||||
// Create work service (for worker pool task management)
|
||||
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)
|
||||
operationRepo := postgres.NewOperationRepository(database.DB)
|
||||
operationService := service.NewOperationService(operationRepo)
|
||||
@ -358,6 +378,10 @@ func main() {
|
||||
queueHandler := handlers.NewQueueHandler(commandQueue, projectRepo)
|
||||
webhookHandler := handlers.NewWebhookHandler(webhookRepo, projectRepo)
|
||||
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
|
||||
projectDomainRepo := postgres.NewProjectDomainRepository(database.DB)
|
||||
@ -543,6 +567,10 @@ func main() {
|
||||
queueHandler.Mount(app.Router())
|
||||
webhookHandler.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())
|
||||
projectMgmtHandler.Mount(app.Router())
|
||||
if componentsHandler != nil {
|
||||
|
||||
@ -67,6 +67,7 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
||||
spec.WithTag("Webhooks", "External webhook receivers")
|
||||
spec.WithTag("Infrastructure", "Git, deployment, DNS, and CI pipeline management")
|
||||
spec.WithTag("Sagas", "Distributed workflow orchestration with compensation")
|
||||
spec.WithTag("Foundary", "Conversational project design and specification")
|
||||
|
||||
// Register all path operations
|
||||
registerSystemPaths(spec)
|
||||
@ -87,6 +88,10 @@ Command output is streamed via Server-Sent Events (SSE) at /projects/{id}/events
|
||||
registerWebhookPaths(spec)
|
||||
registerInfrastructurePaths(spec)
|
||||
registerSagaPaths(spec)
|
||||
registerConversationPaths(spec)
|
||||
registerBlueprintPaths(spec)
|
||||
registerArchitectPaths(spec)
|
||||
registerQuestionPaths(spec)
|
||||
|
||||
return spec
|
||||
}
|
||||
|
||||
@ -1503,3 +1503,304 @@ 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},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
1003
cookbooks/trees/foundary.yaml
Normal file
1003
cookbooks/trees/foundary.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
# Orchard Studio UI - ASCII Screens
|
||||
# Foundary Studio UI - ASCII Screens
|
||||
|
||||
## 1. Project Dashboard (The "Lobby")
|
||||
|
||||
@ -6,7 +6,7 @@ Entry point for the Product Owner.
|
||||
|
||||
```text
|
||||
+-----------------------------------------------------------------------------+
|
||||
| ORCHARD STUDIO [ New Project ] |
|
||||
| Foundary Studio [ New Project ] |
|
||||
+-----------------------------------------------------------------------------+
|
||||
| |
|
||||
| Active Projects |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Technical Reference: Orchard Studio UI
|
||||
# Technical Reference: Foundary Studio UI
|
||||
|
||||
This document maps the Orchard Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements.
|
||||
This document maps the Foundary Studio UI requirements to the existing `rdev` backend architecture and identifies necessary enhancements.
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Roadmap: Orchard Studio UI Implementation
|
||||
# Roadmap: Foundary Studio UI Implementation
|
||||
|
||||
This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Orchard Studio UI.
|
||||
This roadmap outlines the steps to move from the current `rdev` backend to the fully realized Foundary Studio UI.
|
||||
|
||||
## Phase 1: Foundation & Read-Only UI
|
||||
**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.
|
||||
|
||||
1. **Real-time Polish**: Replace polling with SSE/WebSockets for all status updates.
|
||||
2. **Visual Design**: Apply "Orchard" branding (dark mode, crisp typography).
|
||||
2. **Visual Design**: Apply "Foundary" branding (dark mode, crisp typography).
|
||||
3. **Mobile Responsiveness**: Ensure critical flows work on tablet/mobile.
|
||||
|
||||
176
internal/adapter/postgres/blueprint_repository.go
Normal file
176
internal/adapter/postgres/blueprint_repository.go
Normal file
@ -0,0 +1,176 @@
|
||||
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
|
||||
}
|
||||
229
internal/adapter/postgres/conversation_repository.go
Normal file
229
internal/adapter/postgres/conversation_repository.go
Normal file
@ -0,0 +1,229 @@
|
||||
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
|
||||
}
|
||||
249
internal/adapter/postgres/question_repository.go
Normal file
249
internal/adapter/postgres/question_repository.go
Normal file
@ -0,0 +1,249 @@
|
||||
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
|
||||
}
|
||||
58
internal/db/migrations/019_conversations.sql
Normal file
58
internal/db/migrations/019_conversations.sql
Normal file
@ -0,0 +1,58 @@
|
||||
-- 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';
|
||||
35
internal/db/migrations/020_blueprints.sql
Normal file
35
internal/db/migrations/020_blueprints.sql
Normal file
@ -0,0 +1,35 @@
|
||||
-- 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.';
|
||||
29
internal/db/migrations/021_questions.sql
Normal file
29
internal/db/migrations/021_questions.sql
Normal file
@ -0,0 +1,29 @@
|
||||
-- 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';
|
||||
17
internal/domain/blueprint.go
Normal file
17
internal/domain/blueprint.go
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
43
internal/domain/conversation.go
Normal file
43
internal/domain/conversation.go
Normal file
@ -0,0 +1,43 @@
|
||||
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,6 +78,16 @@ var (
|
||||
// Operation errors
|
||||
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)
|
||||
ErrDatabaseConnection = errors.New("database connection error")
|
||||
ErrKubernetesError = errors.New("kubernetes error")
|
||||
|
||||
31
internal/domain/question.go
Normal file
31
internal/domain/question.go
Normal file
@ -0,0 +1,31 @@
|
||||
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
|
||||
}
|
||||
148
internal/handlers/architect.go
Normal file
148
internal/handlers/architect.go
Normal file
@ -0,0 +1,148 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
16
internal/handlers/architect_dto.go
Normal file
16
internal/handlers/architect_dto.go
Normal file
@ -0,0 +1,16 @@
|
||||
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"`
|
||||
}
|
||||
29
internal/handlers/blueprint_dto.go
Normal file
29
internal/handlers/blueprint_dto.go
Normal file
@ -0,0 +1,29 @@
|
||||
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"),
|
||||
}
|
||||
}
|
||||
169
internal/handlers/blueprints.go
Normal file
169
internal/handlers/blueprints.go
Normal file
@ -0,0 +1,169 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
76
internal/handlers/conversation_dto.go
Normal file
76
internal/handlers/conversation_dto.go
Normal file
@ -0,0 +1,76 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
235
internal/handlers/conversations.go
Normal file
235
internal/handlers/conversations.go
Normal file
@ -0,0 +1,235 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
41
internal/handlers/question_dto.go
Normal file
41
internal/handlers/question_dto.go
Normal file
@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
211
internal/handlers/questions.go
Normal file
211
internal/handlers/questions.go
Normal file
@ -0,0 +1,211 @@
|
||||
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",
|
||||
})
|
||||
}
|
||||
25
internal/port/blueprint_repository.go
Normal file
25
internal/port/blueprint_repository.go
Normal file
@ -0,0 +1,25 @@
|
||||
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
|
||||
}
|
||||
34
internal/port/conversation_repository.go
Normal file
34
internal/port/conversation_repository.go
Normal file
@ -0,0 +1,34 @@
|
||||
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)
|
||||
}
|
||||
28
internal/port/question_repository.go
Normal file
28
internal/port/question_repository.go
Normal file
@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
303
internal/service/architect_service.go
Normal file
303
internal/service/architect_service.go
Normal file
@ -0,0 +1,303 @@
|
||||
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
|
||||
}
|
||||
119
internal/service/blueprint_service.go
Normal file
119
internal/service/blueprint_service.go
Normal file
@ -0,0 +1,119 @@
|
||||
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
|
||||
}
|
||||
117
internal/service/conversation_service.go
Normal file
117
internal/service/conversation_service.go
Normal file
@ -0,0 +1,117 @@
|
||||
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)
|
||||
}
|
||||
170
internal/service/question_service.go
Normal file
170
internal/service/question_service.go
Normal file
@ -0,0 +1,170 @@
|
||||
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