summaryrefslogtreecommitdiff
path: root/makima/src/server/handlers/contract_discuss.rs
diff options
context:
space:
mode:
Diffstat (limited to 'makima/src/server/handlers/contract_discuss.rs')
-rw-r--r--makima/src/server/handlers/contract_discuss.rs592
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,
- }
- }
- }
- }
- }
-}