Files
Enofirm-Temp/extra-addons/AI/llm_assistant/models/llm_thread.py
T
2026-09-18 13:55:25 +07:00

562 lines
21 KiB
Python

import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class LLMThread(models.Model):
_inherit = "llm.thread"
assistant_id = fields.Many2one(
"llm.assistant",
string="Assistant",
ondelete="restrict",
help="The assistant used for this thread",
)
prompt_id = fields.Many2one(
"llm.prompt",
string="Prompt for workflow",
ondelete="restrict",
tracking=True,
help="Prompt to use for workflow",
)
@api.onchange("assistant_id")
def _onchange_assistant_id(self):
"""Update provider, model and tools when assistant changes"""
if self.assistant_id:
self.provider_id = self.assistant_id.provider_id
self.model_id = self.assistant_id.model_id
self.tool_ids = self.assistant_id.tool_ids
self.prompt_id = self.assistant_id.prompt_id
else:
# Clear prompt when assistant is cleared
self.prompt_id = False
def set_assistant(self, assistant_id):
"""Set the assistant for this thread and update related fields
Args:
assistant_id (int): The ID of the assistant to set
Returns:
bool: True if successful, False otherwise
"""
self.ensure_one()
# If assistant_id is False or 0, clear the assistant and its prompt
if not assistant_id:
return self.write({"assistant_id": False, "prompt_id": False})
# Get the assistant record
assistant = self.env["llm.assistant"].browse(assistant_id)
if not assistant.exists():
return False
# Update the thread with the assistant and related fields
update_vals = {
"assistant_id": assistant_id,
"tool_ids": [(6, 0, assistant.tool_ids.ids)],
}
if assistant.provider_id.id:
update_vals["provider_id"] = assistant.provider_id.id
if assistant.model_id.id:
update_vals["model_id"] = assistant.model_id.id
if assistant.prompt_id.id:
update_vals["prompt_id"] = assistant.prompt_id.id
return self.write(update_vals)
def action_open_thread(self):
"""Open the thread in the chat client interface
Returns:
dict: Action to open the thread in the chat client
"""
self.ensure_one()
return {
"type": "ir.actions.client",
"tag": "llm_thread.chat_client_action",
"params": {
"default_active_id": self.id,
},
"context": {
"active_id": self.id,
},
"target": "current",
}
def get_context(self, base_context=None):
"""
Get the context to pass to prompt rendering with thread-specific enhancements.
This is the canonical method for creating prompt context in both production and testing.
Args:
base_context (dict): Additional context from caller (optional)
Returns:
dict: Context ready for prompt rendering
"""
context = super().get_context(base_context or {})
# If we have an assistant with default values, add them to the context
if self.assistant_id:
# Get assistant's evaluated default values using the current context
assistant_defaults = self.assistant_id.get_evaluated_default_values(context)
# Merge assistant defaults into context
# Assistant defaults are added first, so thread context takes precedence
if assistant_defaults:
context = {**assistant_defaults, **context}
return context
@api.model
def get_thread_by_id(self, thread_id):
"""Get a thread record by its ID
Args:
thread_id (int): ID of the thread
Returns:
tuple: (thread, error_response)
If successful, error_response will be None
If error, thread will be None
"""
thread = self.browse(int(thread_id))
if not thread.exists():
return None, {"success": False, "error": "Thread not found"}
return thread, None
@api.model
def get_thread_and_assistant(self, thread_id, assistant_id=False):
"""Get thread and assistant records by their IDs
Args:
thread_id (int): ID of the thread
assistant_id (int, optional): ID of the assistant, or False to clear
Returns:
tuple: (thread, assistant, error_response)
If successful, error_response will be None
If error, thread and/or assistant will be None
"""
# Get thread
thread, error = self.get_thread_by_id(thread_id)
if error:
return None, None, error
# If no assistant_id, return just the thread
if not assistant_id:
return thread, None, None
# Get assistant from the assistant model
assistant, error = self.env["llm.assistant"].get_assistant_by_id(assistant_id)
if error:
return thread, None, error
return thread, assistant, None
def _thread_to_store(self, store, **kwargs):
"""Extend base _thread_to_store to include assistant_id and prompt_id."""
super()._thread_to_store(store, **kwargs)
# Always add assistant_id and prompt_id to thread data (either value or False)
for thread in self:
thread_data = {
"id": thread.id,
"model": "llm.thread",
"assistant_id": {
"id": thread.assistant_id.id,
"name": thread.assistant_id.name,
"model": "llm.assistant",
}
if thread.assistant_id
else False,
# prompt_id is defined in this module, so handle it here
"prompt_id": {
"id": thread.prompt_id.id,
"name": thread.prompt_id.name,
"model": "llm.prompt",
}
if thread.prompt_id
else False,
}
store.add("mail.thread", thread_data)
def _extract_message_content(self, message):
"""Extract text content from a message regardless of format"""
content = message.get("content", "")
if isinstance(content, list) and len(content) > 0:
return content[0].get("text", "")
if isinstance(content, str):
return content
return ""
def get_prepend_messages(self):
"""Hook: return a list of formatted messages to prepend to the conversation."""
self.ensure_one()
if self.prompt_id:
try:
# Get messages from the prompt with enhanced context
return self.prompt_id.get_messages(self.get_context())
except Exception as e:
_logger.error(
"Error getting messages from prompt '%s': %s",
self.prompt_id.name,
e,
)
# Continue without prompt messages rather than failing completely
# Post a user-friendly warning to the thread
self.message_post(
body=_(
"Note: The prompt '%s' could not be loaded. "
"Continuing without it. (Error: %s)",
)
% (self.prompt_id.name, str(e)),
)
return []
def generate_messages(self, last_message):
"""Generate messages with actual AI intelligence."""
self.ensure_one()
# Get last message if not provided
if not last_message:
try:
last_message = self.get_latest_llm_message()
except UserError:
# No DB messages found - check if prepended messages have a user message
prepend_msgs = self.get_prepend_messages()
user_msg = next(
(msg for msg in prepend_msgs if msg.get("role") == "user"),
None,
)
if user_msg:
# Extract content from prepended user message
content = user_msg.get("content", [])
if isinstance(content, list) and content:
body = content[0].get("text", "")
else:
body = str(content)
# Create actual user message from prepended content
last_message = self.message_post(
body=body,
llm_role="user",
author_id=self.env.user.partner_id.id,
)
else:
# No user message in prepended messages either
raise
# Continue generation loop
while self._should_continue(last_message):
if last_message.llm_role in ("user", "tool"):
if self.model_id.model_use in ("image_generation", "generation"):
last_message = yield from self._generate_response(last_message)
else:
# Generate assistant response
last_message = yield from self._generate_assistant_response()
elif last_message.llm_role == "assistant" and last_message.has_tool_calls():
# Execute ALL tool calls from assistant message
tool_calls = last_message.get_tool_calls()
for tool_call in tool_calls:
tool_message = yield from self._execute_tool_call(
tool_call,
last_message,
)
last_message = tool_message
self.env.cr.commit()
else:
_logger.info(
f"Breaking loop. Last message role: {last_message.llm_role}, "
f"has_tool_calls: {last_message.has_tool_calls()}",
)
break
return last_message
def _generate_response(self, last_message):
raise NotImplementedError
def _generate_assistant_response(self):
"""Generate assistant response and handle tool calls.
Catches LLM API errors and posts them as error messages in the thread
so users can see what went wrong without checking server logs.
"""
# Flush any pending writes to ensure latest messages are visible
self.env.flush_all()
# Use the new optimized method for LLM context
message_history = self.get_llm_messages()
# Determine if we should use streaming
use_streaming = getattr(self.model_id, "supports_streaming", True)
chat_kwargs = self._prepare_chat_kwargs(message_history, use_streaming)
try:
if use_streaming:
# Handle streaming response - process tool calls directly from stream
stream_response = self.sudo().model_id.chat(**chat_kwargs)
assistant_message = yield from self._handle_streaming_response(
stream_response,
)
else:
# Handle non-streaming response
response = self.sudo().model_id.chat(**chat_kwargs)
assistant_message = yield from self._handle_non_streaming_response(
response,
)
except Exception as e:
# Post error message to thread so user can see it
_logger.exception("LLM API error in thread %s", self.id)
error_message, event = self._post_error_message(
e,
title=_("LLM API Error"),
)
yield event
return error_message
return assistant_message
def _prepare_chat_kwargs(self, message_history, use_streaming):
"""Prepare chat kwargs for provider. Can be overridden by extensions."""
return {
"messages": message_history,
"tools": self.tool_ids,
"stream": use_streaming,
"prepend_messages": self.get_prepend_messages(),
}
def get_llm_messages(self, limit=25):
"""Get the most recent LLM messages in chronological order.
This method is optimized for LLM context preparation:
- Always returns messages in chronological order (ASC)
- Limits to the most recent N messages for context window management
- Uses efficient database queries with proper indexing
- Excludes error messages (is_error=True) from context
Args:
limit (int): Maximum number of recent messages to retrieve (default: 25)
Returns:
mail.message recordset: Recent LLM messages in chronological order
"""
self.ensure_one()
# Domain for filtering LLM messages only (excluding error messages)
domain = [
("model", "=", self._name),
("res_id", "=", self.id),
("llm_role", "!=", False), # Only messages with LLM roles
("is_error", "=", False), # Exclude error messages from LLM context
]
if limit:
# Two-step approach for efficiency:
# 1. Get the N most recent messages (DESC order)
recent_messages = self.env["mail.message"].search(
domain,
order="create_date DESC, write_date DESC, id DESC",
limit=limit,
)
# 2. Sort them chronologically for LLM context (ASC order)
return recent_messages.sorted(lambda m: (m.create_date, m.write_date, m.id))
# If no limit, get all messages in chronological order
return self.env["mail.message"].search(
domain,
order="create_date ASC, write_date ASC, id ASC",
)
def get_latest_llm_message(self):
"""Get the most recent LLM message for flow control.
Returns:
mail.message: The latest LLM message
Raises:
UserError: If no LLM messages exist
"""
self.ensure_one()
domain = [
("model", "=", self._name),
("res_id", "=", self.id),
("llm_role", "!=", False),
]
result = self.env["mail.message"].search(
domain,
order="create_date DESC, write_date DESC, id DESC",
limit=1,
)
if not result:
raise UserError("No LLM messages found in this thread.")
return result[0]
def _should_continue(self, last_message):
"""Simplified continue logic based on message history."""
if not last_message:
return False
# Continue if:
# 1. Last message is user message → generate assistant response
# 2. Last message is tool message → generate assistant response
# 3. Last message is assistant with tool calls → execute tools
if last_message.llm_role in ("user", "tool") or (
last_message.llm_role == "assistant" and last_message.has_tool_calls()
):
return True
return False
def _handle_streaming_response(self, stream_response):
"""Handle streaming response from LLM provider with tool call processing."""
message = None
accumulated_content = ""
collected_tool_calls = []
for chunk in stream_response:
# Initialize message on first content
if message is None and chunk.get("content"):
message = self.message_post(
body="Thinking...",
llm_role="assistant",
author_id=False,
)
yield {"type": "message_create", "message": message.to_store_format()}
# Handle content streaming
if chunk.get("content"):
accumulated_content += chunk["content"]
message.write({"body": self._process_llm_body(accumulated_content)})
yield {"type": "message_chunk", "message": message.to_store_format()}
# Collect tool calls for processing
if chunk.get("tool_calls"):
collected_tool_calls.extend(chunk["tool_calls"])
_logger.debug(
f"Collected {len(chunk['tool_calls'])} tool calls from chunk",
)
# Handle errors
if chunk.get("error"):
yield {"type": "error", "error": chunk["error"]}
return message
# CRITICAL FIX: Create assistant message IMMEDIATELY if we have tool calls
if collected_tool_calls:
body_json = {"tool_calls": collected_tool_calls}
if not message:
# Create assistant message with body_json (handled by message_post override)
message = self.message_post(
body="", # Empty body for tool-only responses
body_json=body_json,
llm_role="assistant",
author_id=False,
)
# Commit to ensure message is saved before tool execution
self.env.cr.commit()
yield {"type": "message_create", "message": message.to_store_format()}
else:
# Update existing message with tool calls
message.write({"body_json": body_json})
# Commit to ensure update is saved
self.env.cr.commit()
yield {"type": "message_update", "message": message.to_store_format()}
elif message and accumulated_content:
# Final update for assistant message without tool calls
message.write({"body": self._process_llm_body(accumulated_content)})
yield {"type": "message_update", "message": message.to_store_format()}
return message
def _handle_non_streaming_response(self, response):
"""Handle non-streaming response from LLM provider."""
# Extract content and tool calls from response
content = response.get("content", "")
tool_calls = response.get("tool_calls", [])
if not content and not tool_calls:
content = "No response from model"
# Prepare body_json with tool calls if present
body_json = {"tool_calls": tool_calls} if tool_calls else None
# Create assistant message with body_json (handled by message_post override)
assistant_message = self.message_post(
body=self._process_llm_body(content) if content else "",
body_json=body_json,
llm_role="assistant",
author_id=False,
)
yield {
"type": "message_create",
"message": assistant_message.to_store_format(),
}
return assistant_message
def _execute_tool_call(self, tool_call, assistant_message):
"""Execute a single tool call and return the tool message.
Args:
tool_call (dict): Tool call data from assistant message
assistant_message (mail.message): The assistant message that contains the tool calls
Yields:
dict: Status updates for streaming
Returns:
mail.message: The tool message with execution result
"""
try:
# Create tool message using the post_tool_call method
tool_msg = self.env["mail.message"].post_tool_call(
tool_call,
thread_model=self,
)
yield {"type": "message_create", "message": tool_msg.to_store_format()}
# Execute the tool call
result_msg = yield from tool_msg.execute_tool_call(thread_model=self)
return result_msg
except Exception as e:
_logger.error(f"Error executing tool call: {e}")
# Create error tool message using the new method
try:
error_msg = self.env["mail.message"].create_tool_error_message(
tool_call,
str(e),
thread_model=self,
)
yield {
"type": "message_create",
"message": error_msg.to_store_format(),
}
return error_msg
except Exception as e2:
_logger.error(f"Failed to create error message: {e2}")
# Yield error event so frontend knows something went wrong
yield {
"type": "error",
"error": f"Tool execution failed: {e!s}",
}
# Re-raise the original exception - don't silently return None
raise e from e2