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,