Fix dup approval flow: preserve raw message, force expenses routing, fix HTML rendering
- master_agent: thread raw user message into extra_context and peer_data so expenses_agent can check it directly without relying on LLM intent_summary - master_agent: when receipts are in extra_context always route to expenses_agent, so replies like 'skip duplicates' still trigger expense processing - expenses_agent: _plan() checks peer_data raw_message alongside task so skip/keep keywords are detected even when master rewrites the intent - ab_ai_mail: wrap clarification message HTML in Markup() so Odoo does not re-escape the tags; use <br> instead of <br/> - ab_ai_mail: convert agent plain-text replies newlines to <br> for proper line-break rendering in Discuss Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import logging
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from markupsafe import Markup, escape
|
||||
from odoo import models, api
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -32,29 +33,30 @@ def _ext(filename: str) -> str:
|
||||
return filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||
|
||||
|
||||
def _describe_zip(datas_b64: str, zip_name: str) -> str:
|
||||
"""Return a short HTML summary of a ZIP's contents without extracting data."""
|
||||
def _describe_zip(datas_b64: str, zip_name: str) -> Markup:
|
||||
"""Return a safe HTML summary of a ZIP's contents without extracting data."""
|
||||
try:
|
||||
raw = base64.b64decode(datas_b64)
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
members = [m for m in zf.namelist() if not m.endswith('/')]
|
||||
if not members:
|
||||
return f'<b>{zip_name}</b> (empty archive)'
|
||||
# Count by type
|
||||
return Markup(f'<b>{escape(zip_name)}</b> (empty archive)')
|
||||
counts: dict[str, int] = {}
|
||||
for m in members:
|
||||
label = _EXT_LABELS.get(_ext(m), 'file')
|
||||
counts[label] = counts.get(label, 0) + 1
|
||||
type_summary = ', '.join(f'{n} {t}(s)' for t, n in counts.items())
|
||||
lines = [f'<b>{zip_name}</b> — {len(members)} item(s): {type_summary}']
|
||||
type_summary = escape(', '.join(f'{n} {t}(s)' for t, n in counts.items()))
|
||||
lines: list[Markup] = [
|
||||
Markup(f'<b>{escape(zip_name)}</b> — {len(members)} item(s): {type_summary}')
|
||||
]
|
||||
for m in members[:8]:
|
||||
lines.append(f' • {m}')
|
||||
lines.append(Markup(f' • {escape(m)}'))
|
||||
if len(members) > 8:
|
||||
lines.append(f' … and {len(members) - 8} more')
|
||||
return '<br/>'.join(lines)
|
||||
lines.append(Markup(f' … and {len(members) - 8} more'))
|
||||
return Markup('<br>').join(lines)
|
||||
except Exception as exc:
|
||||
_logger.warning('_describe_zip failed for %s: %s', zip_name, exc)
|
||||
return f'<b>{zip_name}</b> (could not inspect contents)'
|
||||
return Markup(f'<b>{escape(zip_name)}</b> (could not inspect contents)')
|
||||
|
||||
|
||||
class DiscussChannel(models.Model):
|
||||
@@ -143,10 +145,12 @@ class DiscussChannel(models.Model):
|
||||
context={'channel_id': self.id, 'source': 'discuss'},
|
||||
)
|
||||
|
||||
reply = (response or {}).get('reply') or (response or {}).get('message') or \
|
||||
'I could not process your request right now.'
|
||||
reply_text = ((response or {}).get('reply') or (response or {}).get('message') or
|
||||
'I could not process your request right now.')
|
||||
# Agent replies are plain text; convert newlines to <br> for Discuss.
|
||||
reply_html = Markup('<br>').join(Markup(escape(ln)) for ln in reply_text.split('\n'))
|
||||
self.sudo().message_post(
|
||||
body=reply,
|
||||
body=reply_html,
|
||||
author_id=bot_partner.id,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment',
|
||||
@@ -158,7 +162,7 @@ class DiscussChannel(models.Model):
|
||||
|
||||
def _post_file_clarification(self, attachments, bot_partner):
|
||||
"""Describe the uploaded file(s) and ask the user what to do with them."""
|
||||
lines = []
|
||||
lines: list[Markup] = []
|
||||
for att in attachments:
|
||||
name = att.name or 'file'
|
||||
ext = _ext(name)
|
||||
@@ -166,15 +170,17 @@ class DiscussChannel(models.Model):
|
||||
lines.append(_describe_zip(att.datas, name))
|
||||
else:
|
||||
label = _EXT_LABELS.get(ext, 'file')
|
||||
lines.append(f'<b>{name}</b> ({label})')
|
||||
lines.append(Markup(f'<b>{escape(name)}</b> ({escape(label)})'))
|
||||
|
||||
file_summary = '<br/>'.join(lines)
|
||||
question = (
|
||||
f'I received the following file(s):<br/>{file_summary}<br/><br/>'
|
||||
'What would you like me to do with them? Some options:<br/>'
|
||||
'• <b>Create an expense report</b> from these receipts<br/>'
|
||||
'• <b>Import products</b> from this data<br/>'
|
||||
'• <b>Something else</b> — just tell me what you need'
|
||||
file_summary = Markup('<br>').join(lines)
|
||||
question = Markup(
|
||||
'I received the following file(s):<br>'
|
||||
) + file_summary + Markup(
|
||||
'<br><br>'
|
||||
'What would you like me to do with them? Some options:<br>'
|
||||
'• <b>Create an expense report</b> from these receipts<br>'
|
||||
'• <b>Import products</b> from this data<br>'
|
||||
'• <b>Something else</b> — just tell me what you need'
|
||||
)
|
||||
self.sudo().message_post(
|
||||
body=question,
|
||||
|
||||
@@ -58,12 +58,20 @@ class ExpensesAgent(BaseAgent):
|
||||
task = (self._directive.task if self._directive else '').lower()
|
||||
receipts = getattr(self._directive.context, 'receipts', []) if self._directive else []
|
||||
|
||||
# The master LLM rewrites the user message into intent_summary (task).
|
||||
# Also check the original raw_message threaded through peer_data so
|
||||
# short replies like "skip duplicates" are detected even when rewritten.
|
||||
raw_msg = ''
|
||||
if self._directive and self._directive.context:
|
||||
raw_msg = (self._directive.context.peer_data.get('raw_message') or '').lower()
|
||||
combined = task + ' ' + raw_msg
|
||||
|
||||
# Detect whether the user is responding to a duplicate-approval request
|
||||
skip_keywords = ('skip', 'yes', 'remove duplicate', 'exclude duplicate', 'drop duplicate')
|
||||
keep_keywords = ('keep all', 'keep both', 'include all', 'no skip', "don't skip")
|
||||
if any(k in task for k in skip_keywords):
|
||||
if any(k in combined for k in skip_keywords):
|
||||
user_dup_decision = 'skip'
|
||||
elif any(k in task for k in keep_keywords):
|
||||
elif any(k in combined for k in keep_keywords):
|
||||
user_dup_decision = 'keep_all'
|
||||
else:
|
||||
user_dup_decision = 'none' # first time through — will ask if dups found
|
||||
|
||||
@@ -101,6 +101,11 @@ class MasterAgent:
|
||||
await self._log_directive_complete(directive_id, 'complete', response_text)
|
||||
return MasterResponse(directive_id=directive_id, response=response_text,
|
||||
status='complete')
|
||||
# When receipts are present (upload flow), always dispatch expenses_agent
|
||||
# even if the user's message is a one-word reply like "skip duplicates".
|
||||
if (extra_context or {}).get('receipts') and 'expenses_agent' not in intent.agents:
|
||||
intent.agents.append('expenses_agent')
|
||||
intent.needs_clarification = False
|
||||
access = await self._check_access(user_id, intent.agents)
|
||||
if not access.allowed:
|
||||
denied = ', '.join(access.denied_agents)
|
||||
@@ -108,6 +113,10 @@ class MasterAgent:
|
||||
await self._memory.append_message(user_id, 'assistant', msg, directive_id)
|
||||
await self._log_directive_complete(directive_id, 'failed', msg)
|
||||
return MasterResponse(directive_id=directive_id, response=msg, status='failed')
|
||||
# Thread the raw user message into extra_context so agents can
|
||||
# check it directly without relying on the LLM's intent_summary.
|
||||
extra_context = dict(extra_context or {})
|
||||
extra_context.setdefault('raw_message', message or '')
|
||||
directives = await self._build_directives(intent, context, directive_id,
|
||||
user_id=user_id, extra_context=extra_context)
|
||||
reports = await self._dispatch_agents(directives)
|
||||
@@ -221,7 +230,10 @@ class MasterAgent:
|
||||
recent_findings=context.operational_findings,
|
||||
conversation_summary=chr(10).join(
|
||||
m['content'] for m in context.conversation[-5:] if m['role'] == 'assistant'),
|
||||
peer_data={'requesting_user_id': user_id},
|
||||
peer_data={
|
||||
'requesting_user_id': user_id,
|
||||
'raw_message': (extra_context or {}).get('raw_message', ''),
|
||||
},
|
||||
receipts=receipts)
|
||||
d = AgentDirective(
|
||||
directive_id=directive_id, agent=agent_key, task=intent.intent_summary,
|
||||
|
||||
Reference in New Issue
Block a user