1- import json
1+ import asyncio
2+ import logging
3+ import threading
4+ from typing import Dict , List , Literal , Optional
25
36from openai import OpenAI
47from py_trees .common import Status
8+ from pydantic import BaseModel , Field
9+ from slack_sdk import WebClient
510
11+ from ..config import Config
612from ..conversation_models import Command
13+ from ..models import ReleaseArgs , ReleaseType
714from .behaviours import ReleaseAction
815from .conversation_state import ConversationState
16+ from .tree import async_tick_tock , initialize_tree_and_state
17+
18+ logger = logging .getLogger (__name__ )
19+
20+
21+ class LLMReleaseArgs (BaseModel ):
22+ """Simplified release arguments for LLM structured output."""
23+
24+ release_tag : str = Field (description = "The release tag (e.g., '8.4-m01', '7.2.5')" )
25+ force_rebuild : List [str ] = Field (
26+ default_factory = list ,
27+ description = "List of package names to force rebuild, or ['all'] for all packages" ,
28+ )
29+ only_packages : List [str ] = Field (
30+ default_factory = list ,
31+ description = "List of specific packages to process (e.g., ['docker', 'debian'])" ,
32+ )
33+
34+
35+ class CommandDetectionResult (BaseModel ):
36+ """Structured output for command detection."""
37+
38+ intent : Literal ["command" , "info" , "clarification" ] = Field (
39+ description = "Whether user wants to run a command, get info, or needs clarification"
40+ )
41+ confidence : float = Field (
42+ ge = 0.0 , le = 1.0 , description = "Confidence score between 0 and 1"
43+ )
44+ command : Optional [str ] = Field (
45+ None , description = "Detected command name (release, status, custom_build, etc.)"
46+ )
47+ release_args : Optional [LLMReleaseArgs ] = Field (
48+ None , description = "Release arguments for command execution"
49+ )
50+ reply : Optional [str ] = Field (
51+ None , description = "Natural language reply to send back to user"
52+ )
953
1054
1155class SimpleCommandClassifier (ReleaseAction ):
@@ -60,79 +104,236 @@ def update(self) -> Status:
60104 # Prepare prompt with available commands
61105 commands_list = "\n " .join ([f"- { cmd .value } " for cmd in Command ])
62106
63- system_prompt = f"""You are a command detector for a Redis release automation system.
64- Your task is to analyze user messages and detect which command they want to execute.
107+ instructions = f"""You are a router for a Redis release automation CLI assistant.
65108
66109Available commands:
67110{ commands_list }
68111
69- Respond with a JSON object containing :
70- - "command": the detected command value (one of the available commands, or null if uncertain)
71- - "confidence": a number between 0 and 1 indicating your confidence in the detection
72- - "reasoning": brief explanation of your decision
112+ Given the user message (and optional history), decide :
113+ - Does the user want to run a command?
114+ - If yes, which command and with what args?
115+ - Otherwise, are they just asking for help/info or do they need clarification?
73116
74- Example response:
75- {{"command": "release", "confidence": 0.95, "reasoning": "User explicitly mentioned releasing version 8.2.0"}}
117+ For command intent with non-help commands (release, status, custom_build, unstable_build), extract:
118+ - release_tag: The release tag (e.g., "8.4-m01-int1", "7.2.5")
119+ - force_rebuild: List of package names to force rebuild, or ["all"] to rebuild all packages
120+ - only_packages: List of specific packages to process (e.g., ["docker", "debian"])
76121
77- If the message doesn't clearly match any command, set command to null and explain why."""
122+ Available package types: docker, debian, rpm, homebrew, snap
78123
79- user_message = self .state .message .message
124+ Output using the provided JSON schema fields:
125+ - intent: "command" | "info" | "clarification"
126+ - confidence: float between 0 and 1
127+ - command: optional command name (release, status, custom_build, unstable_build, help)
128+ - release_args: optional ReleaseArgs for command execution
129+ - reply: optional natural language text to send back"""
130+
131+ # Build context history
132+ history_text = ""
133+ if self .state .message .context :
134+ history_text = "\n \n Previous messages:\n " + "\n " .join (
135+ self .state .message .context [:- 1 ]
136+ )
80137
81138 try :
82- # Call LLM
83- response = self .llm .chat .completions .create (
84- model = "gpt-4o-mini" ,
85- messages = [
86- {"role" : "system" , "content" : system_prompt },
87- {"role" : "user" , "content" : user_message },
139+ # Call LLM with structured outputs
140+ response = self .llm .responses .parse (
141+ model = "gpt-4o-2024-08-06" ,
142+ input = [
143+ {"role" : "system" , "content" : instructions },
144+ {
145+ "role" : "user" ,
146+ "content" : f"{ self .state .message .message } { history_text } " ,
147+ },
88148 ],
89- response_format = {"type" : "json_object" },
90- temperature = 0.3 ,
149+ text_format = CommandDetectionResult ,
91150 )
92151
93152 self .logger .debug (f"LLM response: { response } " )
94153
95- # Parse response
96- content = response .choices [ 0 ]. message . content
97- if not content :
154+ # Extract parsed result from structured output
155+ result = response .output_parsed
156+ if not result :
98157 self .feedback_message = "LLM returned empty response"
158+ self .state .command = Command .HELP
159+ self .state .reply = "I couldn't process your request. Please try again."
99160 return Status .FAILURE
100161
101- result = json .loads (content )
102- command_value = result .get ("command" )
103- confidence = result .get ("confidence" , 0.0 )
104- reasoning = result .get ("reasoning" , "" )
162+ # Extract fields from structured result
163+ intent = result .intent
164+ confidence = result .confidence
165+ command_value = result .command
166+ reply = result .reply
105167
106168 # Log the detection
107- self .feedback_message = (
108- f"LLM detected: { command_value } (confidence: { confidence :.2f} )"
109- )
169+ self .feedback_message = f"LLM detected: intent={ intent } , command={ command_value } (confidence: { confidence :.2f} )"
110170
111171 # Check confidence threshold
112172 if confidence < self .confidence_threshold :
113173 self .feedback_message += (
114174 f" [Below threshold { self .confidence_threshold } ]"
115175 )
116- self .state .reply = reasoning
176+ self .state .command = Command .HELP
177+ self .state .reply = (
178+ reply
179+ or f"I'm not confident enough (confidence: { confidence :.2f} ). Please clarify your request."
180+ )
117181 return Status .FAILURE
118182
119- # Validate and set command
120- if command_value :
183+ # Handle based on intent
184+ if intent == "info" or intent == "clarification" :
185+ self .state .command = Command .HELP
186+ self .state .reply = (
187+ reply or "How can I help you with Redis release automation?"
188+ )
189+ return Status .SUCCESS
190+
191+ # For command intent, validate and set command
192+ if intent == "command" and command_value :
121193 try :
122- self .state .command = Command (command_value )
123- return Status .SUCCESS
194+ command = Command (command_value )
195+ self .state .command = command
196+
197+ # If help command, set reply
198+ if command == Command .HELP :
199+ self .state .reply = (
200+ reply or "How can I help you with Redis release automation?"
201+ )
202+ return Status .SUCCESS
203+
204+ # For non-help commands, use release_args from structured output
205+ if result .release_args :
206+ # Convert LLMReleaseArgs to ReleaseArgs
207+ llm_args = result .release_args
208+
209+ # Create ReleaseArgs with converted types
210+ self .state .release_args = ReleaseArgs (
211+ release_tag = llm_args .release_tag ,
212+ force_rebuild = llm_args .force_rebuild ,
213+ only_packages = llm_args .only_packages ,
214+ )
215+ if self .state .slack_args :
216+ self .state .release_args .slack_args = self .state .slack_args
217+
218+ self .logger .info (
219+ f"Parsed ReleaseArgs: { self .state .release_args .model_dump_json ()} "
220+ )
221+ return Status .SUCCESS
222+ else :
223+ # Non-help command without release_args
224+ self .feedback_message = (
225+ "Missing release_args for non-help command"
226+ )
227+ self .state .command = Command .HELP
228+ self .state .reply = (
229+ reply
230+ or "Please provide release details (e.g., version tag)"
231+ )
232+ return Status .FAILURE
233+
124234 except ValueError :
125235 self .feedback_message = f"Invalid command value: { command_value } "
126- self .state .reply = self .feedback_message
236+ self .state .command = Command .HELP
237+ self .state .reply = reply or f"Unknown command: { command_value } "
127238 return Status .FAILURE
128239 else :
240+ self .state .command = Command .HELP
241+ self .state .reply = reply or "I couldn't understand your request."
129242 return Status .FAILURE
130243
131244 except Exception as e :
132245 self .feedback_message = f"LLM command detection failed: { str (e )} "
133- self .state .reply = self .feedback_message
246+ self .state .command = Command .HELP
247+ self .state .reply = f"An error occurred: { str (e )} "
248+ return Status .FAILURE
249+
250+
251+ class RunCommand (ReleaseAction ):
252+ def __init__ (
253+ self ,
254+ name : str ,
255+ state : ConversationState ,
256+ config : Config ,
257+ log_prefix : str = "" ,
258+ ) -> None :
259+ self .state = state
260+ self .config = config
261+ super ().__init__ (name , log_prefix )
262+
263+ def update (self ) -> Status :
264+ self .logger .debug ("RunCommand - starting release execution" )
265+
266+ # Check if we have release args
267+ if not self .state .release_args :
268+ self .feedback_message = "No release args available"
134269 return Status .FAILURE
135270
271+ # Mark command as started
272+ self .state .command_started = True
273+
274+ # Get release args
275+ release_args = self .state .release_args
276+
277+ self .logger .info (
278+ f"Starting release for tag { release_args .release_tag } in background thread"
279+ )
280+
281+ # Start release in a separate thread
282+ def run_release_in_thread () -> None :
283+ """Run release in a separate thread with its own event loop."""
284+ # Create new event loop for this thread
285+ loop = asyncio .new_event_loop ()
286+ asyncio .set_event_loop (loop )
287+ client : Optional [WebClient ] = None
288+ if self .state .slack_args and self .state .slack_args .bot_token :
289+ client = WebClient (self .state .slack_args .bot_token )
290+
291+ try :
292+ # Run the release
293+ with initialize_tree_and_state (self .config , release_args ) as (
294+ tree ,
295+ _ ,
296+ ):
297+ loop .run_until_complete (async_tick_tock (tree , cutoff = 2000 ))
298+
299+ self .logger .info (f"Release { release_args .release_tag } completed" )
300+
301+ except Exception as e :
302+ self .logger ._logger .error (
303+ f"Error running release { release_args .release_tag } : { e } " ,
304+ exc_info = True ,
305+ )
306+ if (
307+ client
308+ and self .state .slack_args
309+ and self .state .slack_args .channel_id
310+ and self .state .slack_args .thread_ts
311+ ):
312+ client .chat_postMessage (
313+ channel = self .state .slack_args .channel_id ,
314+ thread_ts = self .state .slack_args .thread_ts ,
315+ text = f"Release `{ release_args .release_tag } ` failed with error: { str (e )} " ,
316+ )
317+ finally :
318+ loop .close ()
319+
320+ # Start the thread
321+ release_thread = threading .Thread (
322+ target = run_release_in_thread ,
323+ name = f"release-{ release_args .release_tag } " ,
324+ daemon = True ,
325+ )
326+ release_thread .start ()
327+ self .logger .info (f"Started release thread for tag { release_args .release_tag } " )
328+
329+ # Set reply to inform user
330+ self .state .reply = (
331+ f"Starting release for tag `{ release_args .release_tag } `... "
332+ "I'll post updates as the release progresses."
333+ )
334+
335+ return Status .SUCCESS
336+
136337
137338# Conditions
138339
@@ -148,3 +349,29 @@ def update(self) -> Status:
148349 if self .state .llm_available :
149350 return Status .SUCCESS
150351 return Status .FAILURE
352+
353+
354+ class HasReleaseArgs (ReleaseAction ):
355+ def __init__ (
356+ self , name : str , state : ConversationState , log_prefix : str = ""
357+ ) -> None :
358+ self .state = state
359+ super ().__init__ (name = name , log_prefix = log_prefix )
360+
361+ def update (self ) -> Status :
362+ if self .state .release_args :
363+ return Status .SUCCESS
364+ return Status .FAILURE
365+
366+
367+ class IsCommandStarted (ReleaseAction ):
368+ def __init__ (
369+ self , name : str , state : ConversationState , log_prefix : str = ""
370+ ) -> None :
371+ self .state = state
372+ super ().__init__ (name = name , log_prefix = log_prefix )
373+
374+ def update (self ) -> Status :
375+ if self .state .command_started :
376+ return Status .SUCCESS
377+ return Status .FAILURE
0 commit comments