From c83c68b4ee005648b50e69ac023ca0811b5039ef Mon Sep 17 00:00:00 2001 From: Vaso73 Date: Tue, 15 Sep 2026 08:29:12 +0200 Subject: [PATCH] Preserve vzdump inventory when AI is enabled --- AppImage/scripts/notification_channels.py | 61 +++++++++++++++++++---- AppImage/scripts/notification_manager.py | 45 +++++++++-------- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/AppImage/scripts/notification_channels.py b/AppImage/scripts/notification_channels.py index 932879fd..adee2743 100644 --- a/AppImage/scripts/notification_channels.py +++ b/AppImage/scripts/notification_channels.py @@ -308,18 +308,51 @@ class TelegramChannel(NotificationChannel): return self._http_request(url, payload, {'Content-Type': 'application/json'}) def _split_message(self, text: str) -> list: + """Split Telegram HTML without cutting entities or formatting tags. + + Open formatting tags are closed at the end of a chunk and reopened in + the next one, so every API request is valid HTML on its own. + """ if len(text) <= self.MAX_LENGTH: return [text] + + token_re = re.compile( + r'&(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);|<[^<>]+>|.', + re.DOTALL, + ) + tag_re = re.compile(r'<\s*(/?)\s*([A-Za-z0-9-]+)(?:\s[^<>]*)?>') + void_tags = {'br'} + + def _advance(stack, token): + match = tag_re.fullmatch(token) + if not match: + return list(stack) + closing, name = match.groups() + name = name.lower() + next_stack = list(stack) + if closing: + if next_stack and next_stack[-1][0] == name: + next_stack.pop() + elif not token.rstrip().endswith('/>') and name not in void_tags: + next_stack.append((name, token)) + return next_stack + + def _closers(stack): + return ''.join(f'' for name, _ in reversed(stack)) + chunks = [] - while text: - if len(text) <= self.MAX_LENGTH: - chunks.append(text) - break - split_at = text.rfind('\n', 0, self.MAX_LENGTH) - if split_at == -1: - split_at = self.MAX_LENGTH - chunks.append(text[:split_at]) - text = text[split_at:].lstrip('\n') + current = '' + open_tags = [] + for token in token_re.findall(text): + next_tags = _advance(open_tags, token) + if current and len(current) + len(token) + len(_closers(next_tags)) > self.MAX_LENGTH: + chunks.append(current + _closers(open_tags)) + current = ''.join(opener for _, opener in open_tags) + current += token + open_tags = _advance(open_tags, token) + + if current: + chunks.append(current + _closers(open_tags)) return chunks @staticmethod @@ -949,6 +982,16 @@ class EmailChannel(NotificationChannel): # ── Build structured detail rows from known data fields ── detail_rows = self._build_detail_rows(data, event_type, group, html_mod) + # Vzdump bodies are authoritative multi-item inventories. Structured + # backup metadata is only a summary and previously replaced the body in + # the HTML alternative, hiding all guest rows. Render every body line + # exactly once for these events instead of mixing both representations. + if event_type in {'backup_complete', 'backup_fail'}: + detail_rows = [ + ('', html_mod.escape(line.strip())) + for line in body.split('\n') if line.strip() + ] + # ── Fallback: if no structured rows, render body text lines ── if not detail_rows: for line in body.split('\n'): diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 0a14ea05..c7d56338 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1376,27 +1376,32 @@ class NotificationManager: # raw template-formatted notification. Audit Tier 6 — # `_dispatch_to_channels`: AI failure dropped the notification. try: - enriched_context = enrich_context_for_ai( - title=ch_title, - body=ch_body, - event_type=event_type, - data=data, - journal_context=raw_journal_context, - detail_level=detail_level - ) + # Backup reports are authoritative inventories. A model can + # neither be trusted to avoid repeating all guest rows nor to + # preserve every value, so these two events bypass AI entirely. + ai_result = None + if event_type not in {'backup_complete', 'backup_fail'}: + enriched_context = enrich_context_for_ai( + title=ch_title, + body=ch_body, + event_type=event_type, + data=data, + journal_context=raw_journal_context, + detail_level=detail_level + ) - # Wrap the AI rewrite with a hard timeout so a slow Ollama - # call (90-120 s on slow CPUs) doesn't stall the dispatch - # thread and delay every other queued event. On timeout we - # ship the non-AI title/body — the user still gets the - # notification, just without LLM polish. Audit Tier 3.2 #2. - ai_result = _format_with_ai_bounded( - format_with_ai_full, - ch_title, ch_body, severity, channel_ai_config, - detail_level=detail_level, - journal_context=enriched_context, - use_emojis=use_rich_format, - ) + # Wrap the AI rewrite with a hard timeout so a slow Ollama + # call (90-120 s on slow CPUs) doesn't stall the dispatch + # thread and delay every other queued event. On timeout we + # ship the non-AI title/body — the user still gets the + # notification, just without LLM polish. Audit Tier 3.2 #2. + ai_result = _format_with_ai_bounded( + format_with_ai_full, + ch_title, ch_body, severity, channel_ai_config, + detail_level=detail_level, + journal_context=enriched_context, + use_emojis=use_rich_format, + ) if ai_result is not None: ch_title = ai_result.get('title', ch_title) ch_body = ai_result.get('body', ch_body)