Skip to content

Commit ecfa975

Browse files
committed
More or less working version
1 parent 5f22856 commit ecfa975

File tree

9 files changed

+416
-110
lines changed

9 files changed

+416
-110
lines changed

src/redis_release/bht/conversation_behaviours.py

Lines changed: 263 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,55 @@
1-
import json
1+
import asyncio
2+
import logging
3+
import threading
4+
from typing import Dict, List, Literal, Optional
25

36
from openai import OpenAI
47
from py_trees.common import Status
8+
from pydantic import BaseModel, Field
9+
from slack_sdk import WebClient
510

11+
from ..config import Config
612
from ..conversation_models import Command
13+
from ..models import ReleaseArgs, ReleaseType
714
from .behaviours import ReleaseAction
815
from .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

1155
class 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
66109
Available 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\nPrevious 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

src/redis_release/bht/conversation_state.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pydantic import BaseModel, Field
44

55
from ..conversation_models import Command
6+
from ..models import ReleaseArgs, SlackArgs, SlackFormat
67

78

89
class InboxMessage(BaseModel):
@@ -14,4 +15,8 @@ class ConversationState(BaseModel):
1415
llm_available: bool = False
1516
message: Optional[InboxMessage] = None
1617
command: Optional[Command] = None
18+
command_started: bool = False
19+
release_args: Optional[ReleaseArgs] = None
1720
reply: Optional[str] = None
21+
22+
slack_args: Optional[SlackArgs] = None

0 commit comments

Comments
 (0)