diff options
| author | soryu <soryu@soryu.co> | 2026-02-03 23:48:41 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-03 23:48:41 +0000 |
| commit | 9ebc9724afcc0482a8e7cd2369c06208fedbcbd1 (patch) | |
| tree | 53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/src/server/handlers/contract_discuss.rs | |
| parent | dcbf8c834626870a43b633b099f409d69d4f9b87 (diff) | |
| download | soryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.tar.gz soryu-9ebc9724afcc0482a8e7cd2369c06208fedbcbd1.zip | |
Add 'Discuss Contract' feature to listen page (#57)
Diffstat (limited to 'makima/src/server/handlers/contract_discuss.rs')
| -rw-r--r-- | makima/src/server/handlers/contract_discuss.rs | 592 |
1 files changed, 592 insertions, 0 deletions
diff --git a/makima/src/server/handlers/contract_discuss.rs b/makima/src/server/handlers/contract_discuss.rs new file mode 100644 index 0000000..1f98f53 --- /dev/null +++ b/makima/src/server/handlers/contract_discuss.rs @@ -0,0 +1,592 @@ +//! 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, + } + } + } + } + } +} |
