diff options
Diffstat (limited to 'makima/src/server/handlers/contract_discuss.rs')
| -rw-r--r-- | makima/src/server/handlers/contract_discuss.rs | 592 |
1 files changed, 0 insertions, 592 deletions
diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs deleted file mode 100644 index 1f98f53..0000000 --- a/makima/src/server/handlers/contract_discuss.rs +++ /dev/null @@ -1,592 +0,0 @@ -//! Discussion endpoint for LLM-powered contract creation. -//! -//! This handler provides an ephemeral conversation with Makima to help users -//! define and create contracts through natural dialogue. - -use axum::{ - extract::State, - http::StatusCode, - response::IntoResponse, - Json, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::db::{models::CreateContractRequest, repository}; -use crate::llm::{ - claude::{self, ClaudeClient, ClaudeError, ClaudeModel}, - groq::{GroqClient, GroqError, Message, ToolCallResponse}, - discuss_tools::{parse_discuss_tool_call, DiscussToolRequest, DISCUSS_TOOLS}, - LlmModel, ToolCall, ToolResult, UserQuestion, -}; -use crate::server::auth::Authenticated; -use crate::server::state::SharedState; - -/// Maximum number of tool-calling rounds to prevent infinite loops -const MAX_TOOL_ROUNDS: usize = 10; - -/// System prompt for Makima character in contract discussions -const DISCUSS_SYSTEM_PROMPT: &str = r#" -You are Makima, an AI assistant on the makima.jp platform. You help users define and create contracts for their projects through natural conversation. - -## Your Personality -- Professional yet personable -- Focused on understanding the user's actual needs -- Ask clarifying questions when requirements are vague -- Guide the conversation toward actionable outcomes -- Comfortable making recommendations based on experience - -## Your Goal -Help the user flesh out their project idea into a well-defined contract. A contract on makima.jp includes: -- A clear name and description -- The right contract type (simple, specification, or execute) -- Understanding of the scope and requirements - -## Contract Types -- **simple**: Quick tasks with minimal planning (plan -> execute phases only) -- **specification**: Full lifecycle projects (research -> specify -> plan -> execute -> review) -- **execute**: Direct implementation when requirements are already clear (execute phase only) - -## Guidelines -1. **Start by understanding**: Ask about what they want to build -2. **Clarify scope**: Is this a quick fix, a new feature, or a full project? -3. **Gather requirements**: What are the must-haves vs nice-to-haves? -4. **Identify context**: Is there existing code? Which repository? -5. **Recommend type**: Suggest the appropriate contract type -6. **Confirm and create**: When the user is satisfied, create the contract - -## When to Create the Contract -Create the contract when: -- You have a clear understanding of what the user wants -- The user has confirmed they're ready to proceed -- You've gathered enough information for a meaningful contract - -Do NOT create the contract if: -- The user is still exploring ideas -- Key information is missing -- The user hasn't indicated readiness - -{transcript_context} -"#; - -/// Chat message in history -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ChatMessage { - /// Role: "user" or "assistant" - pub role: String, - /// Message content - pub content: String, -} - -/// Request to discuss a potential contract -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractRequest { - /// The user's message - pub message: String, - /// Optional model selection (default: claude-sonnet) - #[serde(default)] - pub model: Option<String>, - /// Conversation history for context continuity - #[serde(default)] - pub history: Option<Vec<ChatMessage>>, - /// Optional transcript context from current session - #[serde(default)] - pub transcript_context: Option<String>, -} - -/// Response from the discussion endpoint -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct DiscussContractResponse { - /// Makima's response message - pub response: String, - /// Tool calls that were executed (e.g., create_contract) - pub tool_calls: Vec<ToolCallInfo>, - /// If a contract was created, its details - #[serde(skip_serializing_if = "Option::is_none")] - pub created_contract: Option<CreatedContractInfo>, - /// Pending questions (if LLM needs clarification) - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_questions: Option<Vec<UserQuestion>>, -} - -/// Information about a tool call that was executed -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ToolCallInfo { - pub name: String, - pub result: ToolResult, -} - -/// Information about a created contract -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreatedContractInfo { - pub id: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - pub contract_type: String, - pub initial_phase: String, -} - -/// Enum to hold LLM clients -enum LlmClient { - Groq(GroqClient), - Claude(ClaudeClient), -} - -/// Unified result from LLM call -struct LlmResult { - content: Option<String>, - tool_calls: Vec<ToolCall>, - raw_tool_calls: Vec<ToolCallResponse>, - finish_reason: String, -} - -/// Discuss a potential contract with Makima -#[utoipa::path( - post, - path = "/api/v1/contracts/discuss", - request_body = DiscussContractRequest, - responses( - (status = 200, description = "Discussion completed successfully", body = DiscussContractResponse), - (status = 401, description = "Unauthorized"), - (status = 500, description = "Internal server error") - ), - security( - ("bearer_auth" = []), - ("api_key" = []) - ), - tag = "Contracts" -)] -pub async fn discuss_contract_handler( - State(state): State<SharedState>, - Authenticated(auth): Authenticated, - Json(request): Json<DiscussContractRequest>, -) -> impl IntoResponse { - // Check if database is configured - let Some(ref pool) = state.db_pool else { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "Database not configured" })), - ) - .into_response(); - }; - - // Parse model selection (default to Claude Sonnet) - let model = request - .model - .as_ref() - .and_then(|m| LlmModel::from_str(m)) - .unwrap_or(LlmModel::ClaudeSonnet); - - tracing::info!("Contract discussion using LLM model: {:?}", model); - - // Initialize the appropriate LLM client - let llm_client = match model { - LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) { - Ok(client) => LlmClient::Claude(client), - Err(ClaudeError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Claude client error: {}", e) })), - ) - .into_response(); - } - }, - LlmModel::GroqKimi => match GroqClient::from_env() { - Ok(client) => LlmClient::Groq(client), - Err(GroqError::MissingApiKey) => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ "error": "GROQ_API_KEY not configured" })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("Groq client error: {}", e) })), - ) - .into_response(); - } - }, - }; - - // Build system prompt with optional transcript context - let transcript_section = match &request.transcript_context { - Some(ctx) => format!( - "\n## Current Session Context\nThe user has been recording a session. Here's the transcript:\n\n{}\n", - ctx - ), - None => String::new(), - }; - - let system_prompt = DISCUSS_SYSTEM_PROMPT.replace("{transcript_context}", &transcript_section); - - // Run the discussion agentic loop - run_discuss_agentic_loop( - pool, - &llm_client, - system_prompt, - &request, - auth.owner_id, - ) - .await -} - -/// Run the agentic loop for contract discussion -async fn run_discuss_agentic_loop( - pool: &sqlx::PgPool, - llm_client: &LlmClient, - system_prompt: String, - request: &DiscussContractRequest, - owner_id: Uuid, -) -> axum::response::Response { - // Build initial messages - let mut messages = vec![Message { - role: "system".to_string(), - content: Some(system_prompt), - tool_calls: None, - tool_call_id: None, - }]; - - // Add conversation history if provided - if let Some(history) = &request.history { - for msg in history { - messages.push(Message { - role: msg.role.clone(), - content: Some(msg.content.clone()), - tool_calls: None, - tool_call_id: None, - }); - } - } - - // Add current user message - messages.push(Message { - role: "user".to_string(), - content: Some(request.message.clone()), - tool_calls: None, - tool_call_id: None, - }); - - // State for tracking - let mut all_tool_call_infos: Vec<ToolCallInfo> = Vec::new(); - let mut final_response: Option<String> = None; - let mut created_contract: Option<CreatedContractInfo> = None; - let mut pending_questions: Option<Vec<UserQuestion>> = None; - - // Multi-turn agentic tool calling loop - for round in 0..MAX_TOOL_ROUNDS { - tracing::info!( - round = round, - total_tool_calls = all_tool_call_infos.len(), - "Contract discussion loop iteration" - ); - - // Call the appropriate LLM API - let result = match llm_client { - LlmClient::Groq(groq) => { - match groq.chat_with_tools(messages.clone(), &DISCUSS_TOOLS).await { - Ok(r) => LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls: r.raw_tool_calls, - finish_reason: r.finish_reason, - }, - Err(e) => { - tracing::error!("Groq API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - LlmClient::Claude(claude_client) => { - let claude_messages = claude::groq_messages_to_claude(&messages); - match claude_client - .chat_with_tools(claude_messages, &DISCUSS_TOOLS) - .await - { - Ok(r) => { - let raw_tool_calls: Vec<ToolCallResponse> = r - .tool_calls - .iter() - .map(|tc| ToolCallResponse { - id: tc.id.clone(), - call_type: "function".to_string(), - function: crate::llm::groq::FunctionCall { - name: tc.name.clone(), - arguments: tc.arguments.to_string(), - }, - }) - .collect(); - - LlmResult { - content: r.content, - tool_calls: r.tool_calls, - raw_tool_calls, - finish_reason: r.stop_reason, - } - } - Err(e) => { - tracing::error!("Claude API error: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("LLM API error: {}", e) })), - ) - .into_response(); - } - } - } - }; - - // Check if there are tool calls to execute - if result.tool_calls.is_empty() { - final_response = result.content; - break; - } - - // Add assistant message with tool calls to conversation - messages.push(Message { - role: "assistant".to_string(), - content: result.content.clone(), - tool_calls: Some(result.raw_tool_calls.clone()), - tool_call_id: None, - }); - - // Execute each tool call - for (i, tool_call) in result.tool_calls.iter().enumerate() { - tracing::info!(tool = %tool_call.name, round = round, "Executing discussion tool call"); - - // Parse the tool call - let mut execution_result = parse_discuss_tool_call(tool_call); - - // Handle async discussion tool requests - if let Some(discuss_request) = execution_result.request.take() { - let async_result = - handle_discuss_request(pool, discuss_request, owner_id).await; - execution_result.success = async_result.success; - execution_result.message = async_result.message; - execution_result.data = async_result.data; - - // Check if a contract was created - if let Some(ref data) = execution_result.data { - if let Some(contract_info) = data.get("createdContract") { - created_contract = Some(CreatedContractInfo { - id: contract_info["id"].as_str().unwrap_or("").to_string(), - name: contract_info["name"].as_str().unwrap_or("").to_string(), - description: contract_info["description"].as_str().map(|s| s.to_string()), - contract_type: contract_info["contractType"].as_str().unwrap_or("").to_string(), - initial_phase: contract_info["initialPhase"].as_str().unwrap_or("").to_string(), - }); - } - } - } - - // Check for pending user questions - if let Some(questions) = execution_result.pending_questions { - tracing::info!( - question_count = questions.len(), - "Discussion LLM requesting user input" - ); - pending_questions = Some(questions); - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message.clone(), - }, - }); - break; - } - - // Build tool result message - let result_content = if let Some(data) = &execution_result.data { - json!({ - "success": execution_result.success, - "message": execution_result.message, - "data": data - }) - .to_string() - } else { - json!({ - "success": execution_result.success, - "message": execution_result.message - }) - .to_string() - }; - - // Add tool result message - let tool_call_id = match llm_client { - LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(), - LlmClient::Claude(_) => tool_call.id.clone(), - }; - - messages.push(Message { - role: "tool".to_string(), - content: Some(result_content), - tool_calls: None, - tool_call_id: Some(tool_call_id), - }); - - // Track for response - all_tool_call_infos.push(ToolCallInfo { - name: tool_call.name.clone(), - result: ToolResult { - success: execution_result.success, - message: execution_result.message, - }, - }); - } - - // If user questions are pending, pause - if pending_questions.is_some() { - final_response = result.content; - break; - } - - // If finish reason indicates completion, exit loop - let finish_lower = result.finish_reason.to_lowercase(); - if finish_lower == "stop" || finish_lower == "end_turn" { - final_response = result.content; - break; - } - } - - // Build response - let response_text = final_response.unwrap_or_else(|| { - if all_tool_call_infos.is_empty() { - "I couldn't understand your request. Please try rephrasing.".to_string() - } else { - "Done!".to_string() - } - }); - - ( - StatusCode::OK, - Json(DiscussContractResponse { - response: response_text, - tool_calls: all_tool_call_infos, - created_contract, - pending_questions, - }), - ) - .into_response() -} - -/// Result from handling an async discussion tool request -struct DiscussRequestResult { - success: bool, - message: String, - data: Option<serde_json::Value>, -} - -/// Handle async discussion tool requests that require database access -async fn handle_discuss_request( - pool: &sqlx::PgPool, - request: DiscussToolRequest, - owner_id: Uuid, -) -> DiscussRequestResult { - match request { - DiscussToolRequest::CreateContract { - name, - description, - contract_type, - repository_url, - local_only, - } => { - // Create the contract request - let create_req = CreateContractRequest { - name: name.clone(), - description: Some(description.clone()), - contract_type: Some(contract_type.clone()), - template_id: None, - initial_phase: None, - autonomous_loop: None, - phase_guard: None, - local_only: Some(local_only), - auto_merge_local: None, - }; - - match repository::create_contract_for_owner(pool, owner_id, create_req).await { - Ok(contract) => { - // If repository URL was provided, try to add it - if let Some(repo_url) = repository_url { - // Try to add as remote repository - let add_result = repository::add_remote_repository( - pool, - contract.id, - &format!("{} Repository", name), - &repo_url, - true, // is_primary - ) - .await; - - if let Err(e) = add_result { - tracing::warn!( - "Failed to add repository to contract {}: {}", - contract.id, - e - ); - } - } - - DiscussRequestResult { - success: true, - message: format!("Contract '{}' created successfully!", contract.name), - data: Some(json!({ - "createdContract": { - "id": contract.id.to_string(), - "name": contract.name, - "description": contract.description, - "contractType": contract.contract_type, - "initialPhase": contract.phase, - } - })), - } - } - Err(e) => { - tracing::error!("Failed to create contract: {}", e); - DiscussRequestResult { - success: false, - message: format!("Failed to create contract: {}", e), - data: None, - } - } - } - } - } -} |
