diff --git a/addons/activeblue_ai/models/ab_ai_mail.py b/addons/activeblue_ai/models/ab_ai_mail.py
index 382a0c9..d612c35 100644
--- a/addons/activeblue_ai/models/ab_ai_mail.py
+++ b/addons/activeblue_ai/models/ab_ai_mail.py
@@ -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'{zip_name} (empty archive)'
- # Count by type
+ return Markup(f'{escape(zip_name)} (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'{zip_name} — {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'{escape(zip_name)} — {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 '
'.join(lines)
+ lines.append(Markup(f' … and {len(members) - 8} more'))
+ return Markup('
').join(lines)
except Exception as exc:
_logger.warning('_describe_zip failed for %s: %s', zip_name, exc)
- return f'{zip_name} (could not inspect contents)'
+ return Markup(f'{escape(zip_name)} (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
for Discuss.
+ reply_html = Markup('
').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'{name} ({label})')
+ lines.append(Markup(f'{escape(name)} ({escape(label)})'))
- file_summary = '
'.join(lines)
- question = (
- f'I received the following file(s):
{file_summary}
'
- 'What would you like me to do with them? Some options:
'
- '• Create an expense report from these receipts
'
- '• Import products from this data
'
- '• Something else — just tell me what you need'
+ file_summary = Markup('
').join(lines)
+ question = Markup(
+ 'I received the following file(s):
'
+ ) + file_summary + Markup(
+ '
'
+ 'What would you like me to do with them? Some options:
'
+ '• Create an expense report from these receipts
'
+ '• Import products from this data
'
+ '• Something else — just tell me what you need'
)
self.sudo().message_post(
body=question,
diff --git a/agent_service/agents/expenses_agent.py b/agent_service/agents/expenses_agent.py
index 0427ed4..04c2512 100644
--- a/agent_service/agents/expenses_agent.py
+++ b/agent_service/agents/expenses_agent.py
@@ -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
diff --git a/agent_service/agents/master_agent.py b/agent_service/agents/master_agent.py
index d875388..b7d4bd8 100644
--- a/agent_service/agents/master_agent.py
+++ b/agent_service/agents/master_agent.py
@@ -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,