From f8d1a8e647a30083e2c4adcea3c1784795ef3120 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Wed, 14 Oct 2026 13:27:19 +1100 Subject: [PATCH] feat(zendesk): link every unflagged ticket on one digest line A ticket the model did not mark worth looking into was only counted in the header's category tally, then recorded as reported, so it never reached Discord as a link at all. #28891, a lost recovery phrase, went that way. They now get one line under the abuse reports, grouped by category, most common first, with the same link budget and a count that covers them all. Claude-Session: https://claude.ai/code/session_019cnvrsCXsqLykDyQburD2f --- src/session_ops/zendesk/triage.py | 46 +++++++++++++++++++++--- tests/zendesk/test_triage.py | 60 +++++++++++++++++++++++++++++-- 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index edb79ee..0b8bb01 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -670,6 +670,9 @@ def analyze(model, effort, compact_tickets): # Character budget for the links on that line, so a day of nothing but abuse reports # can't push it past MAX_MESSAGE_CHARS. Past it the rest are counted, not linked. COLLAPSED_LINKS_CHARS = 900 +# Every other ticket the model did not flag goes on one line under it, linked and +# grouped by category: counted only in the tally, a ticket left the digest unseen. +UNFLAGGED_MARKER = "๐Ÿ’ค" # ---- Components V2 --------------------------------------------------------- # @@ -756,6 +759,36 @@ def build_collapsed_line(collapsed, subdomain): f"actionable ({', '.join(links)})") +def build_unflagged_line(unflagged, subdomain): + """The tickets nobody is asked to look at, as one line under the abuse reports: + + ๐Ÿ’ค **5** not flagged โ€” ๐Ÿ”‘ #28891, #28880 ยท โ“ #28870, #28866 ยท ๐Ÿ’ก #28900 + + Grouped by category, most common first, so the emoji says what each run is + without a summary per ticket. Links share COLLAPSED_LINKS_CHARS the same way, and + the count always covers every one of them. + """ + by_category = {} + for f in unflagged: + by_category.setdefault(f.get("category"), []).append(f) + groups, used, unlisted = [], 0, 0 + for category, members in sorted(by_category.items(), key=lambda kv: -len(kv[1])): + links = [] + for f in members: + link = f"[#{f.get('id')}]({ticket_url(subdomain, f.get('id'))})" + used += len(link) + 2 + if used > COLLAPSED_LINKS_CHARS: + unlisted += 1 + continue + links.append(link) + if links: + groups.append(f"{CATEGORY_EMOJI.get(category, 'โ€ข')} {', '.join(links)}") + if unlisted: + groups.append(f"+{unlisted} more") + return (f"{UNFLAGGED_MARKER} **{len(unflagged)}** not flagged โ€” " + + " ยท ".join(groups)) + + def build_header(findings, highlights, stats=None): """The lead lines: what was looked at, the category tally, duplicate clusters. @@ -861,13 +894,14 @@ def build_messages(findings, subdomain, stats=None, updated_ids=None): omitted_ids = {f.get("id") for f in omitted} collapsed = [f for f in findings if is_collapsed(f)] collapsed_ids = {f.get("id") for f in collapsed} + unflagged = [f for f in findings if f.get("id") not in shown_ids | omitted_ids | collapsed_ids] + unflagged_ids = {f.get("id") for f in unflagged} - # The header accounts for every classified ticket except the highlights that - # didn't fit; those are covered by no message and stay eligible next run. The - # collapsed line accounts for the abuse reports, links or not โ€” its count covers - # every one of them, so re-reporting them next run would be noise. + # The highlights that didn't fit are covered by no message and stay eligible next + # run. The two summary lines account for every ticket they count, links or not, + # so re-reporting those next run would be noise. header_ids = ({f.get("id") for f in findings} - - shown_ids - omitted_ids - collapsed_ids) + - shown_ids - omitted_ids - collapsed_ids - unflagged_ids) header = build_header(findings, shown + omitted, stats) if omitted: header += (f"\nShowing the top **{len(shown)}** of " @@ -880,6 +914,8 @@ def build_messages(findings, subdomain, stats=None, updated_ids=None): # Last, under the individual tickets: lowest position for the lowest priority. if collapsed: entries.append((build_collapsed_line(collapsed, subdomain), collapsed_ids)) + if unflagged: + entries.append((build_unflagged_line(unflagged, subdomain), unflagged_ids)) messages, coverage = discord.messages_from_entries( header, entries, MAX_ENTRIES_PER_MESSAGE, MAX_COMPONENT_CHARS) diff --git a/tests/zendesk/test_triage.py b/tests/zendesk/test_triage.py index bf6010e..6274399 100644 --- a/tests/zendesk/test_triage.py +++ b/tests/zendesk/test_triage.py @@ -558,8 +558,8 @@ class TestBuildMessages(unittest.TestCase): def test_only_tickets_worth_looking_into_get_a_line(self): findings = [finding(1), finding(2, worth_looking_into=False)] text = digest_text(build_messages(findings, "acme")) - self.assertEqual(sum(1 for line in text.splitlines() if "[#" in line), 1) - self.assertIn("[#1]", text) + self.assertIn("| [#1]", text) + self.assertNotIn("| [#2]", text) def test_highlights_are_ordered_by_priority_rank(self): findings = [finding(1, priority_rank=3), finding(2, priority_rank=1)] @@ -714,6 +714,62 @@ def test_nothing_is_clipped_when_it_fits(self): self.assertNotIn("more", line) +class TestUnflaggedLine(unittest.TestCase): + """Every ticket the model did not flag is still linked once, so a lost recovery + phrase the model judged routine can be found from the digest.""" + + def quiet(self, ticket_id, category="account_access"): + return finding(ticket_id, category=category, worth_looking_into=False) + + def test_an_unflagged_ticket_is_linked(self): + text = digest_text(build_messages([finding(1), self.quiet(2)], "acme")) + self.assertIn("[#2](https://acme.zendesk.com/agent/tickets/2)", text) + self.assertIn("**1** not flagged", text) + + def test_it_sits_below_the_abuse_reports(self): + findings = [self.quiet(2), finding(3, category="abuse_report", + severity="not_applicable"), finding(1)] + lines = [l for l in digest_text(build_messages(findings, "acme")).splitlines() + if "[#" in l] + self.assertIn("abuse report", lines[-2]) + self.assertIn("not flagged", lines[-1]) + + def test_tickets_are_grouped_by_category_most_common_first(self): + findings = [self.quiet(1, "question"), self.quiet(2, "account_access"), + self.quiet(3, "account_access")] + line = triage.build_unflagged_line(findings, "acme") + recovery, question = (triage.CATEGORY_EMOJI[c] for c in ("account_access", "question")) + self.assertLess(line.index(recovery), line.index(question)) + self.assertEqual(line.count(recovery), 1) + + def test_abuse_reports_and_highlights_are_not_repeated_on_it(self): + findings = [finding(1), finding(2, category="abuse_report", severity="not_applicable"), + self.quiet(3)] + line = [l for l in digest_text(build_messages(findings, "acme")).splitlines() + if "not flagged" in l][0] + self.assertNotIn("[#1]", line) + self.assertNotIn("[#2]", line) + self.assertIn("**1** not flagged", line) + + def test_highlights_beyond_the_cap_stay_off_it(self): + """Those stay eligible for the next run rather than being marked seen.""" + findings = [finding(i, priority_rank=i) for i in range(triage.MAX_HIGHLIGHTS + 2)] + self.assertNotIn("not flagged", digest_text(build_messages(findings, "acme"))) + + def test_it_covers_the_tickets_it_counts(self): + messages, coverage = triage.build_messages([finding(1), self.quiet(2)], "acme") + self.assertIn(2, set().union(*coverage)) + + def test_a_long_run_of_links_is_clipped_but_fully_counted(self): + line = triage.build_unflagged_line([self.quiet(27000 + i) for i in range(200)], "acme") + self.assertLess(len(line), triage.MAX_MESSAGE_CHARS) + self.assertIn("**200** not flagged", line) + self.assertRegex(line, r"\+\d+ more$") + + def test_no_line_when_every_ticket_is_flagged(self): + self.assertNotIn("not flagged", digest_text(build_messages([finding(1)], "acme"))) + + # ---- Parsing helpers -------------------------------------------------------