From e7b06654001ec4a8258fa4baa9f1e8b8422508c8 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Mon, 26 Oct 2026 09:51:53 +1100 Subject: [PATCH 1/4] fix(zendesk): surface live chats that keep a customer's message off the ticket Zendesk writes a messaging chat into the ticket only when the chat ends, and a chat that starts while the ticket has an assignee is never ended. Until then the customer's newest message is readable by the assignee alone. The digest now sweeps unsolved Twitter DM tickets for an open chat, lists them on their own line, and leaves them unrecorded so they are reported again once the message lands. note_reply refuses draft, reply and english on such a ticket rather than act on a conversation missing its newest message. --- src/session_ops/zendesk/api.py | 33 ++++++++++++++++ src/session_ops/zendesk/note_reply.py | 13 +++++++ src/session_ops/zendesk/triage.py | 54 ++++++++++++++++++++++++--- tests/zendesk/test_api.py | 41 ++++++++++++++++++++ tests/zendesk/test_note_reply.py | 29 ++++++++++++-- tests/zendesk/test_triage.py | 33 ++++++++++++++++ 6 files changed, 195 insertions(+), 8 deletions(-) diff --git a/src/session_ops/zendesk/api.py b/src/session_ops/zendesk/api.py index df5e2a9..368db7b 100644 --- a/src/session_ops/zendesk/api.py +++ b/src/session_ops/zendesk/api.py @@ -201,6 +201,39 @@ def hydrate_requester_activity(session, subdomain, tickets): return hydrated +MESSAGING_CHANNEL_PREFIX = "sunshine_conversations" +CHAT_EVENTS = ("ChatStartedEvent", "ChatEndedEvent") + + +def is_messaging(ticket): + return ((ticket.get("via") or {}).get("channel") or "").startswith(MESSAGING_CHANNEL_PREFIX) + + +def has_open_chat(session, subdomain, ticket_id): + """Whether the ticket's newest live chat has not ended; None when it cannot be read. + + Zendesk writes a chat into the ticket as a comment only when it ends, so until then + the customer's newest messages are on no comment, and only the assignee can open + the chat. One that starts while the ticket has an assignee is never ended for it. + """ + url = f"https://{subdomain}.zendesk.com/api/v2/tickets/{ticket_id}/audits.json" + try: + resp = session.request("GET", url, attempts=2, + params={"sort_order": "desc", "per_page": 100}) + if resp.status_code >= 400: + print(f"Note: audits for #{ticket_id} returned {resp.status_code}.") + return None + audits = resp.json().get("audits", []) + except (requests.RequestException, ValueError) as exc: + print(f"Note: could not read the audits for #{ticket_id} ({exc}).") + return None + for audit in audits: + for event in reversed(audit.get("events") or []): + if event.get("type") in CHAT_EVENTS: + return event["type"] == "ChatStartedEvent" + return False + + STAR_SUBJECT = re.compile(r"^\s*([β˜…β˜†]{1,10})") diff --git a/src/session_ops/zendesk/note_reply.py b/src/session_ops/zendesk/note_reply.py index dd7bf8b..8fb2c47 100644 --- a/src/session_ops/zendesk/note_reply.py +++ b/src/session_ops/zendesk/note_reply.py @@ -1248,6 +1248,15 @@ def say(session, subdomain, ticket_id, comment_id, text, dry_run, error=True): # ---- Entry point ------------------------------------------------------------ +# A live chat keeps the customer's newest messages off the ticket, so these would +# answer, translate or send against a conversation that has moved on. +READS_CUSTOMER = ("draft", "reply", "english") +OPEN_CHAT_REFUSAL = ( + "The customer's newest message is still in a live chat, so it is not on this ticket " + "yet and nothing was done. Zendesk adds it once the chat ends, about 10 minutes after " + "the ticket has no assignee. Then add your note again.") + + def latest_command(comments, api_user, session, subdomain): """The newest private note that is a command from someone allowed to give one. @@ -1309,6 +1318,10 @@ def main(argv=None): print(f"#{args.ticket}: this command was already handled; nothing written.") clear_queued(session, subdomain, args.ticket, args.dry_run) return + if (command["action"] in READS_CUSTOMER and api.is_messaging(ticket) + and api.has_open_chat(session, subdomain, args.ticket)): + say(session, subdomain, args.ticket, command["id"], OPEN_CHAT_REFUSAL, args.dry_run) + return if command["action"] == "draft": run_draft(session, subdomain, ticket, comments, command, diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index 9346897..dedc59e 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -74,7 +74,7 @@ from session_ops.shared.env import get_env from session_ops.shared.text import squash from session_ops.zendesk.api import (REVIEW_CHANNEL, SEARCH_RESULT_LIMIT, fetch_comments, - fetch_tickets, + fetch_tickets, has_open_chat, hydrate_requester_activity, is_store_review, review_platform, review_stars, ticket_url, zendesk_session) @@ -498,6 +498,25 @@ def hydrate_descriptions(session, subdomain, tickets): return hydrated +# Twitter DM is the only messaging channel connected to the account. +OPEN_CHAT_QUERY = "type:ticket status Date: Mon, 2 Nov 2026 15:27:06 +1100 Subject: [PATCH 2/4] feat(zendesk): draft the known answers before the digest posts The digest now writes a private draft note on each ticket it reports that nobody has answered yet, when support already knows the answer: - a police or legal data request gets the standard reply, word for word - an abuse report is composed from a standing brief, in the reporter's language: we can block the ID from Session-operated communities, no more - anything else reuses a `claude: draft` brief that went out unamended as a first reply on the same house-answer group, when Claude judges it fully answers the ticket and holds no expired or ticket-specific fact Those briefs are cached beside the dedup state and topped up from `tags:claude-sent` each run. A draft is only ever sent by a human's `claude: reply N`; a `claude: draft` after it starts over instead of amending wording nobody approved. The digest lists the drafted tickets. --- docs/jobs/zendesk-digest.md | 17 ++ docs/jobs/zendesk-relay.md | 22 ++ src/session_ops/zendesk/auto_draft.py | 384 ++++++++++++++++++++++++++ src/session_ops/zendesk/digest_job.py | 7 +- src/session_ops/zendesk/note_reply.py | 68 +++-- src/session_ops/zendesk/triage.py | 47 +++- tests/zendesk/test_auto_draft.py | 269 ++++++++++++++++++ tests/zendesk/test_digest_job.py | 5 + 8 files changed, 795 insertions(+), 24 deletions(-) create mode 100644 src/session_ops/zendesk/auto_draft.py create mode 100644 tests/zendesk/test_auto_draft.py diff --git a/docs/jobs/zendesk-digest.md b/docs/jobs/zendesk-digest.md index 76b52c1..fcdcfd5 100644 --- a/docs/jobs/zendesk-digest.md +++ b/docs/jobs/zendesk-digest.md @@ -85,6 +85,23 @@ Two limits bound a message and whichever binds first splits it β€” 40 components **That is what lets everything here go over one incoming webhook** β€” the digest, the positive-review tally and the failure alerts alike. A webhook no application owns may send non-interactive components and nothing else, so adding an interactive one means moving the digest back to a bot token and a channel id. The request needs `?with_components=true` (`digest_webhook_url`): Discord ignores the `components` field on a webhook post without it, and the digest is nothing but components. +## Drafting the known answers + +Before posting, the digest drafts a private reply note on each ticket it reports that +support has not answered yet and that a brief support already sent answers; see +[Drafts nobody asked for](zendesk-relay.md#drafts-nobody-asked-for). A πŸ“ line lists +them under the header. Up to three Claude calls a ticket: placement in the house +answers (skipped when the ticket is already tagged), the choice of brief, and the draft. + +| | | +| --- | --- | +| Enabled by | `--known-briefs PATH`, which `digest_job.py` sets beside `--state`, and `ZENDESK_HOUSE_ANSWERS` | +| Cache | `known_briefs.json`: briefs read from `tags:claude-sent`, topped up each run, dropped after 180 days. Losing it costs one backfill | +| Fixed answers | a law-enforcement request gets the standard reply word for word; an abuse report is composed from a standing brief | +| Not drafted | spam and positive reviews, tickets with a live chat open, tickets already tagged `claude-drafted`, `claude-queued` or `claude-sent`, and any ticket with a public reply from support or a `claude:` note | +| Bounds | 20 minutes of drafting a run, highest priority first; stops after three failures. A failure never fails the digest | +| Dry run | `--dry-run` and a rehearsal compose but write no note and no cache; `--no-discord` drafts nothing | + ## Deduplication The window is 72h against runs a day apart, so consecutive runs overlap. A state file (`--state`) records each reported ticket's `requester_updated_at`, giving three outcomes per ticket: diff --git a/docs/jobs/zendesk-relay.md b/docs/jobs/zendesk-relay.md index cf94bbf..3d8df1c 100644 --- a/docs/jobs/zendesk-relay.md +++ b/docs/jobs/zendesk-relay.md @@ -36,6 +36,28 @@ which publishes that option **verbatim** and moves the ticket to `pending`. A ba `claude: reply` sends the only option when there is one, and refuses to guess when there are several. +## Drafts nobody asked for + +The weekday digest writes the same draft note on its own, before it posts, when a +ticket nobody has answered matches an answer support already gave. A known answer is a +`claude: draft` brief that went out as a ticket's first reply with no amendment, from +the same house-answer group. Claude picks the one that fully answers the ticket, or +none, and rejects briefs that name another ticket, promise an action, or carry a fact +that has since expired, like a pending release. + +A law-enforcement request gets no brief: it gets the standard reply in +`STANDARD_REPLIES` ([auto_draft.py](../../src/session_ops/zendesk/auto_draft.py)), word +for word and untranslated, because what Session tells an authority must not vary. + +An abuse report, by the digest's own category, is composed from the standing brief in +`STANDING_BRIEFS`, in the reporter's language: we can block the reported Session ID +from the communities Session operates, and that is all. + +The note says which ticket the brief came from and carries `[claude:auto:]` +instead of a command's markers. Send it with `claude: reply N`. A `claude: draft` after +it starts over from the new brief rather than amending it: amending keeps wording an +agent approved, and nobody approved this one. + ## The commands | Command | What it does | Touches the customer | diff --git a/src/session_ops/zendesk/auto_draft.py b/src/session_ops/zendesk/auto_draft.py new file mode 100644 index 0000000..9a24a14 --- /dev/null +++ b/src/session_ops/zendesk/auto_draft.py @@ -0,0 +1,384 @@ +""" +Draft a reply, unprompted, on the digest's tickets that support already knows how to answer. + +"Knows" means a human wrote that answer before: a `claude: draft` brief, for the same +problem group in the house answers, that went out as the ticket's first reply with no +amendment. Those briefs are cached in a file beside the digest's state and topped up +from `tags:claude-sent` on every run. + +For each new ticket nobody has answered, Claude places it in a group, picks the past +brief that fully answers it (or none), and composes from that brief exactly as +`claude: draft` would. A group in STANDARD_REPLIES gets its fixed text instead, +uncomposed, and a category in STANDING_BRIEFS is composed from its fixed brief. +Each is the same private draft note, so an agent sends it with `claude: reply N`, or +replaces it with `claude: draft `. + +Nothing here writes a public comment. Like the rest of the Zendesk jobs, nothing is +printed but ticket ids and counts: this repo and its logs are public. +""" +import json +import re +import sys +import textwrap +import time +from datetime import date, timedelta + +from session_ops.shared.discord import clip +from session_ops.shared.state import write_json +from session_ops.zendesk import api, claude_api, note_reply + +CACHE_VERSION = 1 +# Older briefs drop out: a fact support stated half a year ago is likelier to be stale. +BRIEF_MAX_AGE_DAYS = 180 +# Zendesk's date search is day-granular in the account's timezone, and `updated>` is +# exclusive, so each run re-reads two days to be sure nothing slips between runs. +RESCAN_DAYS = 2 +MAX_CANDIDATES = 6 +SELECT_TIMEOUT_SECONDS = 120 +# Bounds the digest's runtime: a ticket costs up to three Claude calls. +BUDGET_SECONDS = 20 * 60 +# A dead key or an exhausted balance fails every ticket the same way. +MAX_FAILURES = 3 + +SKIP_CATEGORIES = frozenset({"spam_or_solicitation", "positive_review"}) + +# House-answer groups answered with one fixed text, never composed or translated: what +# Session says to an authority has to be the same every time. Sent only by a human's +# `claude: reply`, like every other draft. +STANDARD_REPLIES = { + "law-enforcement-requests": ("law-enforcement requests", """\ +We’ve received your request. + +Here’s what we can share about Session and legal requests: +Session is a decentralized, privacy-focused messenger, and we are not the sole operators of the network. +The Session Technology Foundation does not collect or store personal data such as phone numbers, email addresses, or identifying metadata like IP addresses. +Because of how Session is built, we don’t have the technical ability to preserve or provide information about user identities, connections, or data. That includes IP logs or timestamps for messages, calls, video calls, or media. + +What we can do: +If you provide the relevant Session ID, we can block that ID from accessing Session-operated communities, which are mainly used for Session updates and information. If you’d like us to take that step, share the ID and we’ll proceed. + +Legal process: +As a Swiss organization, legal inquiries must be processed via the Swiss Post and Telecommunications Surveillance Service (PTSS) under Art. 26 para. 2 VÜPF. +For foreign authorities, requests need to come through mutual legal assistance channels under the Swiss Federal Act on International Mutual Assistance in Criminal Matters. We cannot act on foreign production or preservation orders sent directly to us. + +If you need anything clarified or want us to action a community block with a Session ID, please reply here."""), +} + + +# Triage categories answered from a standing brief, composed in the reporter's language. +STANDING_BRIEFS = { + "abuse_report": ("the usual reply to abuse reports", """\ +Thank them for the report and say we take it seriously. +Session is decentralized: nobody, Session included, can ban, remove or deactivate a Session ID across the network, or see what it sends. +The one thing we can do is block the reported Session ID from the communities Session itself operates. If they gave the full Session ID, say we can do that with it; if not, ask for it. +If the account or the content also appears on another website or app, they should report it to that platform too. +They should not send us the illegal content itself."""), +} + + +def standard_result(text): + """A fixed reply in the shape compose returns, as its only option.""" + return {"language": "English", "language_code": "en", "is_english": True, + "options": [{"approach": "the standard reply", "reply_en": text, + "translated": text, "back_translation": ""}]} + + +# ---- What support already answered ------------------------------------------- + + +def sent_first_brief(comments, api_user): + """(brief, sent_at) when the ticket's first reply went out from one unamended + `claude: draft`, else None. `comments` oldest first. + + Amended or redrafted briefs are left out: each is a change to a draft, not an answer. + """ + draft = None + for comment in comments: + author = comment.get("author_id") + if comment.get("public"): + if author != api_user: + continue + if draft and draft["replied"] and draft["brief"]: + return draft["brief"], comment.get("created_at") + return None + if author == api_user: + continue + parsed = note_reply.parse_command(note_reply.comment_text(comment)) + if not parsed: + continue + action, brief = parsed + if action == "draft": + if draft: + return None + draft = {"brief": brief, "replied": False} + elif action == "reply" and draft: + draft["replied"] = True + return None + + +def load_cache(path): + """The brief cache, or an empty one when it is missing or unreadable: rebuilding it + costs one backfill of `tags:claude-sent`, never a wrong draft.""" + try: + with open(path, encoding="utf-8") as handle: + cache = json.load(handle) + if cache.get("version") == CACHE_VERSION and isinstance(cache.get("briefs"), dict): + return cache + except (OSError, ValueError, AttributeError): + pass + return {"version": CACHE_VERSION, "scanned_through": None, "briefs": {}} + + +def refresh_cache(session, subdomain, api_user, cache, today): + """Add the briefs sent since the last scan, and age out the old ones.""" + since = cache.get("scanned_through") + query = "type:ticket tags:claude-sent" + (f" updated>{since}" if since else "") + tickets, _ = api.fetch_tickets(session, subdomain, query, api.SEARCH_RESULT_LIMIT) + added = 0 + for ticket in tickets: + group, platform = note_reply.tagged_placement(ticket) + if not group: + continue + comments = api.fetch_comments(session, subdomain, ticket["id"], order="asc", + optional=True) + found = sent_first_brief(comments or [], api_user) + if found: + cache["briefs"][str(ticket["id"])] = { + "group": group, "platform": platform or "unknown", + "brief": found[0], "sent_at": found[1]} + added += 1 + cutoff = (today - timedelta(days=BRIEF_MAX_AGE_DAYS)).isoformat() + cache["briefs"] = {tid: b for tid, b in cache["briefs"].items() + if (b.get("sent_at") or "") >= cutoff} + cache["scanned_through"] = (today - timedelta(days=RESCAN_DAYS)).isoformat() + print(f"Known briefs: read {len(tickets)} sent ticket(s), {added} first-reply " + f"brief(s); {len(cache['briefs'])} cached.") + return cache + + +def candidates(cache, group): + """The group's distinct briefs, most used then most recent first. + + Each is {brief, uses, latest, platforms, source}, `source` being the newest ticket + it was sent on. + """ + merged = {} + for tid, entry in cache["briefs"].items(): + if entry.get("group") != group: + continue + key = re.sub(r"\s+", " ", entry["brief"]).strip().lower() + row = merged.setdefault(key, {"brief": entry["brief"], "uses": 0, "latest": "", + "platforms": set(), "source": tid}) + row["uses"] += 1 + row["platforms"].add(entry.get("platform") or "unknown") + if (entry.get("sent_at") or "") > row["latest"]: + row.update(latest=entry["sent_at"], source=tid, brief=entry["brief"]) + rows = sorted(merged.values(), key=lambda r: (r["uses"], r["latest"]), reverse=True) + return rows[:MAX_CANDIDATES] + + +# ---- Choosing a brief ------------------------------------------------------- + +SELECT_SCHEMA = { + "type": "object", "additionalProperties": False, + "required": ["choice", "reason"], + "properties": { + "choice": {"type": "integer", + "description": "The number of the brief that answers this ticket, " + "or 0 when none does."}, + "reason": {"type": "string", "description": "One short sentence, in English."}, + }, +} + +SELECT_SYSTEM = textwrap.dedent( + """ + You decide whether an answer Session support already gave other customers also + answers this one, unchanged. Session is a private messenger. + + You are given one new ticket and numbered BRIEFS: the substance of a first reply + support wrote for the same kind of problem, each one reviewed by a human and sent. + Pick the brief that, on its own, answers what this customer actually asks. Answer + 0 when none does. + + Pick a brief only when all of these hold: + - It answers this customer's actual question or problem, not just its topic. + - It needs nothing it does not say: it names no other ticket, person or + conversation, and does not rely on details only that other customer gave. + - Every fact in it is still true TODAY. A brief that mentions a pending or + upcoming release, a fix being prepared, "this week", "soon" or an ETA was true + when it was sent; reject it unless it was sent within the last 14 days. + - It does not say an action was or will be taken on that customer's case: an + account banned, a bug filed, a refund, an escalation. Nobody has acted on this + ticket. + + Asking for logs or missing details is a valid answer when the ticket lacks what + support needs to help. A brief is an agent's shorthand: judge what it says, not + how it reads. Prefer one sent several times. + + When in doubt, answer 0. A missing draft costs an agent one note; a wrong one + costs a reviewer's time and may be sent. + """ +).strip() + + +def build_select_prompt(ticket, sample, rows, today): + parts = [f"TODAY: {today.isoformat()}", "", + f"THE TICKET: {(ticket.get('subject') or '')[:200]}", sample, "", "BRIEFS:"] + for number, row in enumerate(rows, 1): + parts += [f"{number}. sent {row['uses']} time(s), most recently " + f"{(row['latest'] or '')[:10]}, platforms: " + f"{', '.join(sorted(row['platforms']))}", + row["brief"], ""] + return "\n".join(parts) + + +def choose_brief(ticket, sample, rows, today): + """The chosen row, or None.""" + found = claude_api.claude_json( + "medium", SELECT_SYSTEM, SELECT_SCHEMA, + build_select_prompt(ticket, sample, rows, today), + SELECT_TIMEOUT_SECONDS, f"the brief choice for #{ticket['id']}") + choice = found.get("choice") + if isinstance(choice, int) and 1 <= choice <= len(rows): + return rows[choice - 1] + return None + + +# ---- Drafting --------------------------------------------------------------- + + +def eligible(ticket, comments, authors, api_user): + """Whether nobody has answered or picked up this ticket yet. + + Only a first reply: a brief that answered someone's opening message does not answer + their follow-up. + """ + tags = set(ticket.get("tags") or []) + if tags & {note_reply.TAG_DRAFTED, note_reply.TAG_QUEUED, note_reply.TAG_SENT}: + return False + for comment in comments: + if comment.get("public"): + if comment.get("author_id") not in authors: + return False + elif comment.get("author_id") != api_user and \ + note_reply.parse_command(note_reply.comment_text(comment)): + return False + return True + + +def write_draft(session, subdomain, ticket_id, result, brief, unprompted, dry_run, + cell=None, title=None, covering=None, tags=()): + if dry_run: + return + note_reply.write_to_ticket( + session, subdomain, ticket_id, + note_reply.build_draft_note(result, brief, None, cell=cell, title=title, + covering=covering, unprompted=unprompted), + public=False, as_html=True, add_tags=[note_reply.TAG_DRAFTED, *tags]) + + +def draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_run, + category=None): + """Draft one ticket. Returns the source ticket id whose brief was reused, the group + or category of a standard reply or standing brief, or None. + + `category` is the digest's: an abuse report is known from it alone, with no + placement call. + """ + ticket_id = ticket["id"] + comments = api.fetch_comments(session, subdomain, ticket_id) + public = [c for c in comments if c.get("public")] + authors = api.customer_authors(session, subdomain, ticket, public) + if not eligible(ticket, comments, authors, api_user): + return None + sample = api.customer_text(session, subdomain, ticket, comments, + note_reply.CUSTOMER_SAMPLE_CHARS, authors) + if category in STANDING_BRIEFS: + name, brief = STANDING_BRIEFS[category] + result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), brief) + print(f"#{ticket_id}: {category}, drafted {len(result['options'])} option(s) " + f"from the standing brief.") + write_draft(session, subdomain, ticket_id, result, brief, + (name, "Standing brief:", category), dry_run) + return category + group, cell, covering, title, precedent, new_tags = note_reply.ground( + ticket, sample, book) + if group in STANDARD_REPLIES: + name, text = STANDARD_REPLIES[group] + print(f"#{ticket_id}: {group}, drafted the standard reply.") + write_draft(session, subdomain, ticket_id, standard_result(text), None, + (f"the standard reply to {name}, word for word", None, group), + dry_run, tags=new_tags) + return group + rows = candidates(cache, group) if group else [] + if not rows: + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None + row = choose_brief(ticket, sample, rows, today) + if not row: + print(f"#{ticket_id}: {group}, no known brief answers it.") + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None + result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), + row["brief"], precedent=precedent) + print(f"#{ticket_id}: {group}, drafted {len(result['options'])} option(s) from the " + f"brief sent on #{row['source']}.") + write_draft(session, subdomain, ticket_id, result, row["brief"], + (f"a reply unprompted, reusing the brief support sent on #{row['source']}", + f"Brief reused from #{row['source']}:", row["source"]), + dry_run, cell, title, covering, new_tags) + return row["source"] + + +def draft_known(session, subdomain, tickets, findings, cache_path, skip_ids=(), + dry_run=False): + """Draft every ticket in `tickets` a known brief answers. Returns the drafted ids. + + `findings` is the digest's classification, read for the category; `skip_ids` are + tickets whose newest message is not on the ticket yet. A dry run writes neither + notes nor the cache. Every failure here is reported and survived: the digest is + the job, and these drafts are a head start on it. + """ + book = note_reply.load_house() + if not book: + print(f"Note: no house answers ({note_reply.HOUSE_ENV}), so no ticket can be " + f"matched to a known brief; drafting skipped.") + return [] + today = date.today() + started = time.monotonic() + try: + api_user = note_reply.api_user_id(session, subdomain) + cache = refresh_cache(session, subdomain, api_user, load_cache(cache_path), today) + except SystemExit as exc: + print(f"Note: could not read the known briefs ({exc}); drafting skipped.") + return [] + if not dry_run: + write_json(cache_path, cache) + + category = {f.get("id"): f.get("category") for f in findings} + rank = {f.get("id"): f.get("priority_rank", 9999) for f in findings} + queue = sorted((t for t in tickets + if t["id"] in category and t["id"] not in skip_ids + and category[t["id"]] not in SKIP_CATEGORIES), + key=lambda t: rank[t["id"]]) + drafted, failures = [], 0 + for position, ticket in enumerate(queue): + if time.monotonic() - started > BUDGET_SECONDS: + print(f"Drafting stopped at its time budget; {len(queue) - position} " + f"ticket(s) not considered.") + break + try: + if draft_ticket(session, subdomain, ticket, cache, book, api_user, today, + dry_run, category[ticket["id"]]): + drafted.append(ticket["id"]) + except SystemExit as exc: + failures += 1 + print(f"#{ticket['id']}: not drafted ({exc}).", file=sys.stderr) + if failures >= MAX_FAILURES: + print(f"Drafting stopped after {failures} failures.") + break + print(f"Drafted {len(drafted)} of {len(queue)} ticket(s) from known briefs" + + (" (dry run, nothing written)." if dry_run else ".")) + return drafted diff --git a/src/session_ops/zendesk/digest_job.py b/src/session_ops/zendesk/digest_job.py index 8db05b5..c954e14 100644 --- a/src/session_ops/zendesk/digest_job.py +++ b/src/session_ops/zendesk/digest_job.py @@ -1,5 +1,8 @@ """The weekday Zendesk run: solve positive app-store reviews, then post the digest. +The digest drafts a private reply note on the tickets a brief support already sent +answers, before it posts; the brief cache sits beside its dedup state. + In that order because a solved review leaves the digest's `status" + markers = auto_marker(unprompted[2]) if unprompted \ + else f"{done_marker(comment_id)} {draft_marker(comment_id)}" out += [para(f"To send it exactly as above, {send}"), para("To change it, add a private note reading claude: draft "), - para(f"{done_marker(comment_id)} {draft_marker(comment_id)}")] + para(markers)] return "".join(out) @@ -883,24 +922,15 @@ def run_draft(session, subdomain, ticket, comments, command, api_user, dry_run): if any(c.get("public") and c.get("author_id") in authors | {api_user} for c in newer): shown = {} + # Amending keeps what the agent approved, and nobody approved an unprompted draft. + if shown and AUTO_MARKER_PREFIX in comment_text(comments[len(newer)]): + shown = {} previous = "\n\n".join(f"Option {n}:\n{shown[n]}" for n in sorted(shown)) or None thread = follow_up(comments, authors) sample = api.customer_text(session, subdomain, ticket, comments, CUSTOMER_SAMPLE_CHARS, authors) - book = load_house() - group, platform = tagged_placement(ticket) - new_tags = [] - if book and not group: - group, platform = place_ticket(book, ticket, sample) - # Cached on the ticket so a revision does not pay for the same call again, - # and so the placement is visible to a human who disagrees with it. - new_tags = ([f"{TAG_GROUP_PREFIX}{group}"] if group else []) + \ - ([f"{TAG_PLATFORM_PREFIX}{platform}"] if platform else []) - cell, covering = house_cell(book, group, platform) - title = next((g["title"] for g in (book or {}).get("groups", []) - if g["key"] == group), group) - precedent = render_precedent(cell, title, covering) if cell else None + group, cell, covering, title, precedent, new_tags = ground(ticket, sample, load_house()) if cell: print(f"#{ticket_id}: grounded in {group}/{covering} " f"({cell['n']} solved, {cell['consistency']} consistency).") diff --git a/src/session_ops/zendesk/triage.py b/src/session_ops/zendesk/triage.py index dedc59e..db69fd5 100644 --- a/src/session_ops/zendesk/triage.py +++ b/src/session_ops/zendesk/triage.py @@ -71,13 +71,14 @@ from session_ops.shared import discord, http, state as dedup from session_ops.shared.discord import clip, post_to_discord -from session_ops.shared.env import get_env +from session_ops.shared.env import get_env, rehearsing from session_ops.shared.text import squash from session_ops.zendesk.api import (REVIEW_CHANNEL, SEARCH_RESULT_LIMIT, fetch_comments, fetch_tickets, has_open_chat, hydrate_requester_activity, is_store_review, review_platform, review_stars, ticket_url, zendesk_session) +from session_ops.zendesk import auto_draft from session_ops.zendesk.claude_api import claude_json, model as newest_opus # Never analyzed. A store review cannot be answered the way a ticket can: it takes @@ -684,6 +685,7 @@ def analyze(model, effort, compact_tickets): UNFLAGGED_MARKER = "πŸ’€" NON_ENGLISH_MARKER = "🌐" OPEN_CHAT_MARKER = "πŸ’¬" +DRAFTED_MARKER = "πŸ“" # ---- Components V2 --------------------------------------------------------- # @@ -853,6 +855,30 @@ def build_open_chat_line(ticket_ids, subdomain): f"newest message not on the ticket until an admin unassigns it: {links}", set()) +def build_drafted_line(ticket_ids, subdomain): + """The tickets that got an unprompted draft from a known brief, waiting on a human. + None when there are none. + + πŸ“ **2** drafted from a known answer, to check and send with claude: reply N + β€” #28891, #28880 + + Covers no ticket: each is accounted for by its own line elsewhere in the digest. + """ + if not ticket_ids: + return None + links, used = [], 0 + for tid in ticket_ids: + link = f"[#{tid}]({ticket_url(subdomain, tid)})" + used += len(link) + 2 + if used > COLLAPSED_LINKS_CHARS: + break + links.append(link) + if len(ticket_ids) > len(links): + links.append(f"+{len(ticket_ids) - len(links)} more") + return (f"{DRAFTED_MARKER} **{len(ticket_ids)}** drafted from a known answer, to " + f"check and send with claude: reply N β€” {', '.join(links)}", set()) + + def build_header(findings, highlights, stats=None): """The lead lines: what was looked at, the category tally, duplicate clusters. @@ -968,8 +994,9 @@ def build_messages(findings, subdomain, stats=None, updated_ids=None): f"**{len(shown) + len(omitted)}** worth looking into.") open_chats = build_open_chat_line((stats or {}).get("open_chats"), subdomain) non_english = build_non_english_line(findings, subdomain, updated_ids) - # First, under the header: the run's to-do lists, for an admin and for `claude: english`. - entries = [line for line in (open_chats, non_english) if line] + drafted = build_drafted_line((stats or {}).get("drafted"), subdomain) + # First, under the header: the run's to-do lists, for an admin and for the `claude:` notes. + entries = [line for line in (open_chats, non_english, drafted) if line] entries += [ (build_ticket_line(f, subdomain, is_update=f.get("id") in updated_ids), {f.get("id")}) @@ -1029,6 +1056,11 @@ def main(argv=None): "real run, after Discord accepts the post.") parser.add_argument("--state-retention-days", type=int, default=30, metavar="N", help="Forget state entries older than N days (default: 30).") + parser.add_argument("--known-briefs", metavar="PATH", + help="Cache of briefs support already sent. When set, tickets one " + "of them answers get a private draft note before the digest " + "is posted. Needs ZENDESK_HOUSE_ANSWERS. Writes to Zendesk " + "except under --dry-run; skipped under --no-discord.") parser.add_argument("--dump-batch", metavar="PATH", help="Write the batch (tickets, prompt, schema) to PATH and exit, for " "hand-classification. WARNING: writes ticket content to disk.") @@ -1180,10 +1212,17 @@ def main(argv=None): print(f"{len(findings)} tickets classified; {len(shown) + len(omitted)} worth looking into" + (f" ({len(omitted)} beyond the display cap)." if omitted else ".")) - messages, coverage = build_messages(findings, subdomain, stats, updated_ids) # Classified from a conversation missing its newest message, so left unrecorded: # once the chat ends the ticket is reported again with that message in it. open_chats = stats.get("open_chats") or set() + # Before the post, so the digest can say which tickets already carry a draft. Not + # under --no-discord: a counts-only run must not write to tickets. + if args.known_briefs and zd and classified and not args.no_discord: + stats["drafted"] = auto_draft.draft_known( + zd, subdomain, classified, findings, args.known_briefs, open_chats, + dry_run=args.dry_run or rehearsing()) + + messages, coverage = build_messages(findings, subdomain, stats, updated_ids) if args.dry_run: print(json.dumps(messages, indent=2, ensure_ascii=False)) if args.state: diff --git a/tests/zendesk/test_auto_draft.py b/tests/zendesk/test_auto_draft.py new file mode 100644 index 0000000..f3f7774 --- /dev/null +++ b/tests/zendesk/test_auto_draft.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Tests for the digest's unprompted drafts from briefs support already sent. + +Stdlib unittest, offline, same stubs as the rest. Run from anywhere: + + uv run python -m unittest tests.zendesk.test_auto_draft +""" +import os +import tempfile +import unittest +from datetime import date + +from session_ops.zendesk import auto_draft, claude_api, note_reply, triage +from tests.zendesk.test_note_reply import ( + AGENT, API_USER, GERMAN, comment, draft_note, fake_session, posted_note) +from tests.zendesk.test_triage import FakeResponse, Patched + +TODAY = date(2026, 11, 2) +CUSTOMER = 42 +BOOK = {"groups": [{"key": "calls", "title": "Calls failing"}], + "cells": {"calls|any": {"answer": "Ask for logs.", "n": 30, + "consistency": "high"}}} + + +def public(body, author=CUSTOMER, cid=1, at="2026-10-01T00:00:00Z"): + return dict(comment(body, author=author, public=True, cid=cid), created_at=at) + + +def cmd(body, cid): + return dict(comment(body, author=AGENT, cid=cid), created_at="2026-10-01T00:00:00Z") + + +def cache(*entries): + return {"version": auto_draft.CACHE_VERSION, "scanned_through": None, + "briefs": {str(tid): {"group": group, "platform": "unknown", "brief": brief, + "sent_at": at} + for tid, group, brief, at in entries}} + + +class SentFirstBrief(unittest.TestCase): + def test_an_unamended_brief_that_was_sent_is_known(self): + found = auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2), cmd("claude: reply 1", 3), + public("Please send logs", author=API_USER, cid=4, at="2026-10-02T00:00:00Z")], + API_USER) + self.assertEqual(found, ("need the logs", "2026-10-02T00:00:00Z")) + + def test_an_amended_brief_is_not_an_answer(self): + """The second brief is a change to the first draft, not an answer on its own.""" + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2), + cmd("claude: draft - also ask the version", 3), cmd("claude: reply", 4), + public("Please send logs", author=API_USER, cid=5)], API_USER)) + + def test_a_draft_nobody_sent_is_not_an_answer(self): + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), cmd("claude: draft - need the logs", 2)], API_USER)) + + def test_a_follow_up_brief_is_not_a_first_answer(self): + self.assertIsNone(auto_draft.sent_first_brief( + [public("help"), public("first reply", author=API_USER, cid=2), + cmd("claude: draft - thanks for the logs", 3), cmd("claude: reply", 4), + public("second reply", author=API_USER, cid=5)], API_USER)) + + +class Candidates(unittest.TestCase): + def test_the_same_brief_is_merged_and_counted_most_used_first(self): + rows = auto_draft.candidates(cache( + (1, "calls", "need the logs", "2026-10-01T00:00:00Z"), + (2, "calls", "Need the logs", "2026-10-05T00:00:00Z"), + (3, "calls", "we have no ETA", "2026-10-20T00:00:00Z"), + (4, "other", "unrelated", "2026-10-20T00:00:00Z")), "calls") + self.assertEqual([(r["uses"], r["source"]) for r in rows], [(2, "2"), (1, "3")]) + + def test_old_briefs_age_out_and_the_scan_moves_on(self): + stale = cache((1, "calls", "old", "2026-01-01T00:00:00Z"), + (2, "calls", "new", "2026-10-01T00:00:00Z")) + stale["scanned_through"] = "2026-10-30" + session = fake_session(FakeResponse({"results": [], "count": 0, "next_page": None})) + refreshed = auto_draft.refresh_cache(session, "sub", API_USER, stale, TODAY) + self.assertEqual(list(refreshed["briefs"]), ["2"]) + self.assertEqual(refreshed["scanned_through"], "2026-10-31") + self.assertIn("updated>2026-10-30", str(session.calls[0])) + + def test_an_unreadable_cache_starts_over(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "k.json") + with open(path, "w") as handle: + handle.write("{not json") + self.assertEqual(auto_draft.load_cache(path)["briefs"], {}) + + +class Eligible(unittest.TestCase): + def test_an_unanswered_ticket_is_eligible(self): + self.assertTrue(auto_draft.eligible({"tags": []}, [public("help")], {CUSTOMER}, + API_USER)) + + def test_a_ticket_support_answered_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [public("reply", author=API_USER, cid=2), public("help")], + {CUSTOMER}, API_USER)) + + def test_a_ticket_an_agent_is_already_on_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [cmd("claude: english", 2), public("help")], {CUSTOMER}, + API_USER)) + + def test_a_ticket_already_drafted_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": [note_reply.TAG_DRAFTED]}, [public("help")], {CUSTOMER}, API_USER)) + + +class DraftTicket(unittest.TestCase): + TICKET = {"id": 7, "requester_id": CUSTOMER, "subject": "calls fail", + "tags": ["grp-calls", "plat-android"]} + KNOWN = cache((99, "calls", "need the logs of both devices", "2026-10-20T00:00:00Z")) + + def run_draft(self, choice, dry_run=False): + seen = {} + + def fake_compose(sample, brief, previous=None, precedent=None, thread=None): + seen.update(brief=brief, previous=previous, precedent=precedent) + return GERMAN + + session = fake_session(FakeResponse({"comments": [public("Anrufe gehen nicht")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=lambda *a, **k: {"choice": choice, + "reason": "x"}), \ + Patched(note_reply, compose=fake_compose): + source = auto_draft.draft_ticket(session, "sub", self.TICKET, self.KNOWN, + BOOK, API_USER, TODAY, dry_run) + return source, seen, session + + def test_a_known_brief_is_drafted_as_a_private_note(self): + source, seen, session = self.run_draft(1) + self.assertEqual(source, "99") + self.assertEqual(seen["brief"], "need the logs of both devices") + self.assertIsNone(seen["previous"]) + self.assertIn("Ask for logs.", seen["precedent"]) + writes = [kw["json"] for m, u, kw in session.calls if m == "PUT"] + note = writes[0]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertNotIn("status", writes[0]["ticket"]) + self.assertIn(note_reply.auto_marker("99"), note["html_body"]) + self.assertIn("Nobody has reviewed it yet", note["html_body"]) + self.assertIn(note_reply.TAG_DRAFTED, writes[1]["tags"]) + + def test_no_matching_brief_writes_nothing(self): + source, seen, session = self.run_draft(0) + self.assertIsNone(source) + self.assertEqual(seen, {}) + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + + def test_a_dry_run_writes_nothing(self): + source, _, session = self.run_draft(1, dry_run=True) + self.assertEqual(source, "99") + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + + +class StandardReply(unittest.TestCase): + TICKET = {"id": 8, "requester_id": CUSTOMER, "subject": "Police Request", + "tags": ["grp-law-enforcement-requests"]} + + def run_draft(self): + def no_claude(*a, **k): + raise AssertionError("a standard reply is never composed") + session = fake_session(FakeResponse({"comments": [public("Dear Sir or Madam")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=no_claude): + source = auto_draft.draft_ticket(session, "sub", self.TICKET, cache(), BOOK, + API_USER, TODAY, dry_run=False) + return source, session + + def test_it_is_drafted_word_for_word_as_one_private_option(self): + source, session = self.run_draft() + self.assertEqual(source, "law-enforcement-requests") + note = session.calls[1][2]["json"]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertIn("Nobody has reviewed it yet", note["html_body"]) + _, text = auto_draft.STANDARD_REPLIES["law-enforcement-requests"] + self.assertEqual(note_reply.find_draft([posted_note(note["html_body"])], API_USER), + {1: text}) + + +class AbuseReport(unittest.TestCase): + def test_it_is_composed_from_the_standing_brief_without_placement(self): + seen = {} + + def fake_compose(sample, brief, previous=None, precedent=None, thread=None): + seen.update(brief=brief) + return GERMAN + + def no_claude(*a, **k): + raise AssertionError("an abuse report needs no placement or brief choice") + session = fake_session(FakeResponse({"comments": [public("Bitte sperren")]}), + FakeResponse({"ticket": {}})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=fake_compose): + source = auto_draft.draft_ticket( + session, "sub", {"id": 9, "requester_id": CUSTOMER, "tags": []}, cache(), + BOOK, API_USER, TODAY, False, category="abuse_report") + self.assertEqual(source, "abuse_report") + self.assertIn("communities Session itself operates", seen["brief"]) + note = session.calls[1][2]["json"]["ticket"]["comment"] + self.assertIs(note["public"], False) + self.assertIn("Standing brief:", note["html_body"]) + self.assertIn(note_reply.auto_marker("abuse_report"), note["html_body"]) + + +class AfterAnUnpromptedDraft(unittest.TestCase): + def auto_note(self): + note = note_reply.build_draft_note(GERMAN, "need the logs", None, + unprompted=("a reply", "Brief:", 99)) + return posted_note(note, cid=10) + + def test_the_note_is_not_a_command(self): + text = note_reply.comment_text(self.auto_note()) + self.assertIsNone(note_reply.parse_command(text)) + + def test_reply_sends_its_option(self): + self.assertEqual(note_reply.find_draft([self.auto_note()], API_USER), + {1: GERMAN["options"][0]["translated"]}) + + def test_a_new_brief_replaces_it_rather_than_amending_it(self): + """Amending keeps the wording an agent approved, and nobody approved this one.""" + seen = {} + + def fake(sample, brief, previous=None, precedent=None, thread=None): + seen.update(previous=previous) + return GERMAN + with Patched(note_reply, compose=fake): + note_reply.run_draft( + fake_session(FakeResponse({"ticket": {}})), "sub", + {"id": 7, "requester_id": CUSTOMER}, + [comment("claude: draft - x", cid=11), self.auto_note()], + {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, + API_USER, dry_run=False) + self.assertIsNone(seen["previous"]) + + def test_a_reviewed_draft_is_still_amended(self): + seen = {} + + def fake(sample, brief, previous=None, precedent=None, thread=None): + seen.update(previous=previous) + return GERMAN + with Patched(note_reply, compose=fake): + note_reply.run_draft( + fake_session(FakeResponse({"ticket": {}})), "sub", + {"id": 7, "requester_id": CUSTOMER}, + [comment("claude: draft - x", cid=11), draft_note("Erster", cid=10)], + {"id": 11, "author": AGENT, "action": "draft", "brief": "x"}, + API_USER, dry_run=False) + self.assertIn("Erster", seen["previous"]) + + +class DigestLine(unittest.TestCase): + def test_the_digest_lists_the_drafted_tickets(self): + line, covers = triage.build_drafted_line([5, 6], "sub") + self.assertIn("**2** drafted", line) + self.assertIn("claude: reply N", line) + self.assertEqual(covers, set()) + + def test_no_drafts_no_line(self): + self.assertIsNone(triage.build_drafted_line([], "sub")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/zendesk/test_digest_job.py b/tests/zendesk/test_digest_job.py index 34667dd..2f53eed 100644 --- a/tests/zendesk/test_digest_job.py +++ b/tests/zendesk/test_digest_job.py @@ -41,6 +41,11 @@ def test_the_resolver_runs_before_the_digest(self): self.assertEqual(calls[0][1], ["--apply"]) self.assertIn("--state", calls[1][1]) + def test_the_brief_cache_sits_beside_the_dedup_state(self): + _, calls = self.run_job(["--state", "/s/seen.json"]) + argv = calls[1][1] + self.assertEqual(argv[argv.index("--known-briefs") + 1], "/s/known_briefs.json") + def test_a_failed_resolve_is_reported_and_does_not_cost_the_digest(self): outcome, calls = self.run_job([], resolver=SystemExit("Zendesk returned 500")) self.assertEqual(len(calls), 2) From da24fc01185a7fa0e5f3985337cb52fd7fd3fb05 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Nov 2026 13:14:54 +1100 Subject: [PATCH 3/4] fix(zendesk): draft only the settled groups, and never block the digest - Only tickets placed in DRAFT_GROUPS get an unprompted draft. - Placement runs before the abuse standing brief, so a police request classified as an abuse report still gets the fixed legal text. - Any error while drafting is reported per ticket and survived, so the digest always posts. - The brief backfill reads the 180-day window past the 1000-result cap, and the scan only moves on when every search page and comment read succeeded. - A ticket with a private note from a person is left alone. --- docs/jobs/zendesk-digest.md | 5 +- docs/jobs/zendesk-relay.md | 11 ++- src/session_ops/zendesk/auto_draft.py | 111 +++++++++++++++------- tests/zendesk/test_auto_draft.py | 131 +++++++++++++++++++++++--- 4 files changed, 206 insertions(+), 52 deletions(-) diff --git a/docs/jobs/zendesk-digest.md b/docs/jobs/zendesk-digest.md index fcdcfd5..d508ed1 100644 --- a/docs/jobs/zendesk-digest.md +++ b/docs/jobs/zendesk-digest.md @@ -92,13 +92,14 @@ support has not answered yet and that a brief support already sent answers; see [Drafts nobody asked for](zendesk-relay.md#drafts-nobody-asked-for). A πŸ“ line lists them under the header. Up to three Claude calls a ticket: placement in the house answers (skipped when the ticket is already tagged), the choice of brief, and the draft. +Only a ticket placed in one of the groups in `DRAFT_GROUPS` gets past placement. | | | | --- | --- | | Enabled by | `--known-briefs PATH`, which `digest_job.py` sets beside `--state`, and `ZENDESK_HOUSE_ANSWERS` | -| Cache | `known_briefs.json`: briefs read from `tags:claude-sent`, topped up each run, dropped after 180 days. Losing it costs one backfill | +| Cache | `known_briefs.json`: briefs read from `tags:claude-sent`, topped up each run, dropped after 180 days. Losing it costs one backfill of those 180 days; a truncated search or a failed comment read rescans from the same point next run | | Fixed answers | a law-enforcement request gets the standard reply word for word; an abuse report is composed from a standing brief | -| Not drafted | spam and positive reviews, tickets with a live chat open, tickets already tagged `claude-drafted`, `claude-queued` or `claude-sent`, and any ticket with a public reply from support or a `claude:` note | +| Not drafted | spam and positive reviews, groups outside `DRAFT_GROUPS`, tickets with a live chat open, tickets already tagged `claude-drafted`, `claude-queued` or `claude-sent`, and any ticket with a public reply from support or a private note from anyone but the API user | | Bounds | 20 minutes of drafting a run, highest priority first; stops after three failures. A failure never fails the digest | | Dry run | `--dry-run` and a rehearsal compose but write no note and no cache; `--no-discord` drafts nothing | diff --git a/docs/jobs/zendesk-relay.md b/docs/jobs/zendesk-relay.md index 3d8df1c..211ac84 100644 --- a/docs/jobs/zendesk-relay.md +++ b/docs/jobs/zendesk-relay.md @@ -45,13 +45,18 @@ the same house-answer group. Claude picks the one that fully answers the ticket, none, and rejects briefs that name another ticket, promise an action, or carry a fact that has since expired, like a pending release. +Only the groups in `DRAFT_GROUPS` are drafted: those whose first reply support has +settled on, like reports of illegal content, lost recovery phrases, unrecoverable data +and the common delivery, notification, connection and call failures. A ticket placed +anywhere else, or nowhere, is left to an agent. + A law-enforcement request gets no brief: it gets the standard reply in `STANDARD_REPLIES` ([auto_draft.py](../../src/session_ops/zendesk/auto_draft.py)), word for word and untranslated, because what Session tells an authority must not vary. -An abuse report, by the digest's own category, is composed from the standing brief in -`STANDING_BRIEFS`, in the reporter's language: we can block the reported Session ID -from the communities Session operates, and that is all. +An abuse report, by the digest's own category and in one of those groups, is composed +from the standing brief in `STANDING_BRIEFS`, in the reporter's language: we can block +the reported Session ID from the communities Session operates, and that is all. The note says which ticket the brief came from and carries `[claude:auto:]` instead of a command's markers. Send it with `claude: reply N`. A `claude: draft` after diff --git a/src/session_ops/zendesk/auto_draft.py b/src/session_ops/zendesk/auto_draft.py index 9a24a14..9c6223b 100644 --- a/src/session_ops/zendesk/auto_draft.py +++ b/src/session_ops/zendesk/auto_draft.py @@ -6,10 +6,11 @@ amendment. Those briefs are cached in a file beside the digest's state and topped up from `tags:claude-sent` on every run. -For each new ticket nobody has answered, Claude places it in a group, picks the past -brief that fully answers it (or none), and composes from that brief exactly as -`claude: draft` would. A group in STANDARD_REPLIES gets its fixed text instead, -uncomposed, and a category in STANDING_BRIEFS is composed from its fixed brief. +For each new ticket nobody has answered, Claude places it in a group; only a group in +DRAFT_GROUPS is drafted. Claude picks the past brief that fully answers it (or none), +and composes from that brief exactly as `claude: draft` would. A group in +STANDARD_REPLIES gets its fixed text instead, uncomposed, and a category in +STANDING_BRIEFS is composed from its fixed brief. Each is the same private draft note, so an agent sends it with `claude: reply N`, or replaces it with `claude: draft `. @@ -39,9 +40,31 @@ BUDGET_SECONDS = 20 * 60 # A dead key or an exhausted balance fails every ticket the same way. MAX_FAILURES = 3 +# A runaway guard on the backfill, far above the sent tickets one retention window holds. +MAX_SENT_TICKETS = 20000 SKIP_CATEGORIES = frozenset({"spam_or_solicitation", "positive_review"}) +# The house-answer groups whose first reply support settled on; the rest vary too much +# per ticket for an unprompted draft to be worth a reviewer's time. +DRAFT_GROUPS = frozenset({ + "reporting-illegal-content", + "needs-more-detail", + "account-unrecoverable-without-recovery-phrase", + "recovery-phrase-location-and-login-basics", + "deleted-data-not-recoverable", + "lost-contacts-not-restorable", + "law-enforcement-requests", + "push-notifications-not-arriving", + "messages-failing-to-send", + "connection-path-errors", + "attachments-failing-to-send", + "call-failures-and-quality", + "messages-notified-but-not-appearing", + "delete-account-and-data", + "leaked-recovery-phrase", +}) + # House-answer groups answered with one fixed text, never composed or translated: what # Session says to an authority has to be the same every time. Sent only by a human's # `claude: reply`, like every other draft. @@ -130,10 +153,17 @@ def load_cache(path): def refresh_cache(session, subdomain, api_user, cache, today): - """Add the briefs sent since the last scan, and age out the old ones.""" - since = cache.get("scanned_through") - query = "type:ticket tags:claude-sent" + (f" updated>{since}" if since else "") - tickets, _ = api.fetch_tickets(session, subdomain, query, api.SEARCH_RESULT_LIMIT) + """Add the briefs sent since the last scan, and age out the old ones. + + The scan only moves on when it read everything: a truncated search or a failed + comment read is retried from the same point next run. + """ + cutoff = (today - timedelta(days=BRIEF_MAX_AGE_DAYS)).isoformat() + since = cache.get("scanned_through") or cutoff + query = (f"type:ticket tags:claude-sent updated>{since} " + "order_by:created_at sort:desc") + tickets, matched = api.fetch_every_ticket(session, subdomain, query, MAX_SENT_TICKETS) + complete = matched is None or len(tickets) >= matched added = 0 for ticket in tickets: group, platform = note_reply.tagged_placement(ticket) @@ -141,18 +171,22 @@ def refresh_cache(session, subdomain, api_user, cache, today): continue comments = api.fetch_comments(session, subdomain, ticket["id"], order="asc", optional=True) - found = sent_first_brief(comments or [], api_user) + if comments is None: + complete = False + continue + found = sent_first_brief(comments, api_user) if found: cache["briefs"][str(ticket["id"])] = { "group": group, "platform": platform or "unknown", "brief": found[0], "sent_at": found[1]} added += 1 - cutoff = (today - timedelta(days=BRIEF_MAX_AGE_DAYS)).isoformat() cache["briefs"] = {tid: b for tid, b in cache["briefs"].items() if (b.get("sent_at") or "") >= cutoff} - cache["scanned_through"] = (today - timedelta(days=RESCAN_DAYS)).isoformat() - print(f"Known briefs: read {len(tickets)} sent ticket(s), {added} first-reply " - f"brief(s); {len(cache['briefs'])} cached.") + if complete: + cache["scanned_through"] = (today - timedelta(days=RESCAN_DAYS)).isoformat() + print(f"Known briefs: read {len(tickets)} of {matched} sent ticket(s), {added} " + f"first-reply brief(s); {len(cache['briefs'])} cached" + + ("." if complete else "; incomplete, rescanned next run.")) return cache @@ -251,17 +285,17 @@ def eligible(ticket, comments, authors, api_user): """Whether nobody has answered or picked up this ticket yet. Only a first reply: a brief that answered someone's opening message does not answer - their follow-up. + their follow-up. Any private note from a person means someone is on it. """ tags = set(ticket.get("tags") or []) if tags & {note_reply.TAG_DRAFTED, note_reply.TAG_QUEUED, note_reply.TAG_SENT}: return False for comment in comments: + author = comment.get("author_id") if comment.get("public"): - if comment.get("author_id") not in authors: + if author not in authors: return False - elif comment.get("author_id") != api_user and \ - note_reply.parse_command(note_reply.comment_text(comment)): + elif author != api_user and author not in authors: return False return True @@ -282,8 +316,8 @@ def draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_r """Draft one ticket. Returns the source ticket id whose brief was reused, the group or category of a standard reply or standing brief, or None. - `category` is the digest's: an abuse report is known from it alone, with no - placement call. + `category` is the digest's. Placement runs first even for a category with a + standing brief: a police request about abuse has to get the fixed legal text. """ ticket_id = ticket["id"] comments = api.fetch_comments(session, subdomain, ticket_id) @@ -293,16 +327,12 @@ def draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_r return None sample = api.customer_text(session, subdomain, ticket, comments, note_reply.CUSTOMER_SAMPLE_CHARS, authors) - if category in STANDING_BRIEFS: - name, brief = STANDING_BRIEFS[category] - result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), brief) - print(f"#{ticket_id}: {category}, drafted {len(result['options'])} option(s) " - f"from the standing brief.") - write_draft(session, subdomain, ticket_id, result, brief, - (name, "Standing brief:", category), dry_run) - return category group, cell, covering, title, precedent, new_tags = note_reply.ground( ticket, sample, book) + if group not in DRAFT_GROUPS: + if new_tags and not dry_run: + note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) + return None if group in STANDARD_REPLIES: name, text = STANDARD_REPLIES[group] print(f"#{ticket_id}: {group}, drafted the standard reply.") @@ -310,7 +340,15 @@ def draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_r (f"the standard reply to {name}, word for word", None, group), dry_run, tags=new_tags) return group - rows = candidates(cache, group) if group else [] + if category in STANDING_BRIEFS: + name, brief = STANDING_BRIEFS[category] + result = note_reply.compose(clip(sample, note_reply.CUSTOMER_SAMPLE_CHARS), brief) + print(f"#{ticket_id}: {category}, drafted {len(result['options'])} option(s) " + f"from the standing brief.") + write_draft(session, subdomain, ticket_id, result, brief, + (name, "Standing brief:", category), dry_run, tags=new_tags) + return category + rows = candidates(cache, group) if not rows: if new_tags and not dry_run: note_reply.change_tags(session, subdomain, ticket_id, add=new_tags) @@ -351,11 +389,17 @@ def draft_known(session, subdomain, tickets, findings, cache_path, skip_ids=(), try: api_user = note_reply.api_user_id(session, subdomain) cache = refresh_cache(session, subdomain, api_user, load_cache(cache_path), today) - except SystemExit as exc: - print(f"Note: could not read the known briefs ({exc}); drafting skipped.") + # Broad on purpose, here and per ticket: drafting runs before the digest posts, so + # anything it raises would cost the digest itself. + except (Exception, SystemExit) as exc: + print(f"Note: could not read the known briefs ({type(exc).__name__}: {exc}); " + f"drafting skipped.") return [] if not dry_run: - write_json(cache_path, cache) + try: + write_json(cache_path, cache) + except OSError as exc: + print(f"Note: could not save the known briefs ({exc}).", file=sys.stderr) category = {f.get("id"): f.get("category") for f in findings} rank = {f.get("id"): f.get("priority_rank", 9999) for f in findings} @@ -373,9 +417,10 @@ def draft_known(session, subdomain, tickets, findings, cache_path, skip_ids=(), if draft_ticket(session, subdomain, ticket, cache, book, api_user, today, dry_run, category[ticket["id"]]): drafted.append(ticket["id"]) - except SystemExit as exc: + except (Exception, SystemExit) as exc: failures += 1 - print(f"#{ticket['id']}: not drafted ({exc}).", file=sys.stderr) + print(f"#{ticket['id']}: not drafted ({type(exc).__name__}: {exc}).", + file=sys.stderr) if failures >= MAX_FAILURES: print(f"Drafting stopped after {failures} failures.") break diff --git a/tests/zendesk/test_auto_draft.py b/tests/zendesk/test_auto_draft.py index f3f7774..027c48c 100644 --- a/tests/zendesk/test_auto_draft.py +++ b/tests/zendesk/test_auto_draft.py @@ -10,15 +10,17 @@ import unittest from datetime import date -from session_ops.zendesk import auto_draft, claude_api, note_reply, triage +import requests + +from session_ops.zendesk import api, auto_draft, claude_api, note_reply, triage from tests.zendesk.test_note_reply import ( AGENT, API_USER, GERMAN, comment, draft_note, fake_session, posted_note) from tests.zendesk.test_triage import FakeResponse, Patched TODAY = date(2026, 11, 2) CUSTOMER = 42 -BOOK = {"groups": [{"key": "calls", "title": "Calls failing"}], - "cells": {"calls|any": {"answer": "Ask for logs.", "n": 30, +BOOK = {"groups": [{"key": "call-failures-and-quality", "title": "Calls failing"}], + "cells": {"call-failures-and-quality|any": {"answer": "Ask for logs.", "n": 30, "consistency": "high"}}} @@ -66,15 +68,15 @@ def test_a_follow_up_brief_is_not_a_first_answer(self): class Candidates(unittest.TestCase): def test_the_same_brief_is_merged_and_counted_most_used_first(self): rows = auto_draft.candidates(cache( - (1, "calls", "need the logs", "2026-10-01T00:00:00Z"), - (2, "calls", "Need the logs", "2026-10-05T00:00:00Z"), - (3, "calls", "we have no ETA", "2026-10-20T00:00:00Z"), - (4, "other", "unrelated", "2026-10-20T00:00:00Z")), "calls") + (1, "call-failures-and-quality", "need the logs", "2026-10-01T00:00:00Z"), + (2, "call-failures-and-quality", "Need the logs", "2026-10-05T00:00:00Z"), + (3, "call-failures-and-quality", "we have no ETA", "2026-10-20T00:00:00Z"), + (4, "other", "unrelated", "2026-10-20T00:00:00Z")), "call-failures-and-quality") self.assertEqual([(r["uses"], r["source"]) for r in rows], [(2, "2"), (1, "3")]) def test_old_briefs_age_out_and_the_scan_moves_on(self): - stale = cache((1, "calls", "old", "2026-01-01T00:00:00Z"), - (2, "calls", "new", "2026-10-01T00:00:00Z")) + stale = cache((1, "call-failures-and-quality", "old", "2026-01-01T00:00:00Z"), + (2, "call-failures-and-quality", "new", "2026-10-01T00:00:00Z")) stale["scanned_through"] = "2026-10-30" session = fake_session(FakeResponse({"results": [], "count": 0, "next_page": None})) refreshed = auto_draft.refresh_cache(session, "sub", API_USER, stale, TODAY) @@ -82,6 +84,48 @@ def test_old_briefs_age_out_and_the_scan_moves_on(self): self.assertEqual(refreshed["scanned_through"], "2026-10-31") self.assertIn("updated>2026-10-30", str(session.calls[0])) + def refresh(self, tickets, matched, comments): + start = cache() + start["scanned_through"] = "2026-10-30" + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return tickets, matched + with Patched(api, fetch_every_ticket=fake_search, + fetch_comments=lambda *a, **k: comments): + return auto_draft.refresh_cache(None, "sub", API_USER, start, TODAY), seen + + def test_a_first_run_reads_only_the_retention_window(self): + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return [], 0 + with Patched(api, fetch_every_ticket=fake_search): + auto_draft.refresh_cache(None, "sub", API_USER, cache(), TODAY) + self.assertIn("updated>2026-05-06", seen["query"]) + self.assertIn("order_by:created_at sort:desc", seen["query"]) + + def test_a_failed_comment_read_is_retried_next_run(self): + refreshed, _ = self.refresh([{"id": 5, "tags": ["grp-call-failures-and-quality"]}], + 1, None) + self.assertEqual(refreshed["scanned_through"], "2026-10-30") + + def test_a_truncated_search_is_retried_next_run(self): + refreshed, _ = self.refresh([], 1500, []) + self.assertEqual(refreshed["scanned_through"], "2026-10-30") + + def test_a_complete_scan_moves_on(self): + refreshed, seen = self.refresh( + [{"id": 5, "tags": ["grp-call-failures-and-quality"]}], 1, + [public("help"), cmd("claude: draft - need the logs", 2), + cmd("claude: reply", 3), public("logs?", author=API_USER, cid=4, + at="2026-10-31T00:00:00Z")]) + self.assertEqual(refreshed["scanned_through"], "2026-10-31") + self.assertEqual(refreshed["briefs"]["5"]["brief"], "need the logs") + self.assertIn("updated>2026-10-30", seen["query"]) + def test_an_unreadable_cache_starts_over(self): with tempfile.TemporaryDirectory() as tmp: path = os.path.join(tmp, "k.json") @@ -105,6 +149,16 @@ def test_a_ticket_an_agent_is_already_on_is_not(self): {"tags": []}, [cmd("claude: english", 2), public("help")], {CUSTOMER}, API_USER)) + def test_a_ticket_a_person_left_a_note_on_is_not(self): + self.assertFalse(auto_draft.eligible( + {"tags": []}, [cmd("escalated to devs", 2), public("help")], {CUSTOMER}, + API_USER)) + + def test_a_note_from_the_api_user_does_not_count(self): + self.assertTrue(auto_draft.eligible( + {"tags": []}, [comment("translation", author=API_USER, cid=2), + public("help")], {CUSTOMER}, API_USER)) + def test_a_ticket_already_drafted_is_not(self): self.assertFalse(auto_draft.eligible( {"tags": [note_reply.TAG_DRAFTED]}, [public("help")], {CUSTOMER}, API_USER)) @@ -112,8 +166,8 @@ def test_a_ticket_already_drafted_is_not(self): class DraftTicket(unittest.TestCase): TICKET = {"id": 7, "requester_id": CUSTOMER, "subject": "calls fail", - "tags": ["grp-calls", "plat-android"]} - KNOWN = cache((99, "calls", "need the logs of both devices", "2026-10-20T00:00:00Z")) + "tags": ["grp-call-failures-and-quality", "plat-android"]} + KNOWN = cache((99, "call-failures-and-quality", "need the logs of both devices", "2026-10-20T00:00:00Z")) def run_draft(self, choice, dry_run=False): seen = {} @@ -151,6 +205,20 @@ def test_no_matching_brief_writes_nothing(self): self.assertEqual(seen, {}) self.assertFalse([c for c in session.calls if c[0] != "GET"]) + def test_a_group_not_in_the_list_is_not_drafted(self): + def no_claude(*a, **k): + raise AssertionError("an unlisted group is never drafted") + session = fake_session(FakeResponse({"comments": [public("add dark mode")]})) + with Patched(claude_api, claude_json=no_claude), \ + Patched(note_reply, compose=no_claude): + source = auto_draft.draft_ticket( + session, "sub", {"id": 7, "requester_id": CUSTOMER, + "tags": ["grp-feature-requests"]}, + cache((99, "feature-requests", "noted", "2026-10-20T00:00:00Z")), + BOOK, API_USER, TODAY, False) + self.assertIsNone(source) + self.assertFalse([c for c in session.calls if c[0] != "GET"]) + def test_a_dry_run_writes_nothing(self): source, _, session = self.run_draft(1, dry_run=True) self.assertEqual(source, "99") @@ -184,7 +252,7 @@ def test_it_is_drafted_word_for_word_as_one_private_option(self): class AbuseReport(unittest.TestCase): - def test_it_is_composed_from_the_standing_brief_without_placement(self): + def run_draft(self, group): seen = {} def fake_compose(sample, brief, previous=None, precedent=None, thread=None): @@ -192,14 +260,24 @@ def fake_compose(sample, brief, previous=None, precedent=None, thread=None): return GERMAN def no_claude(*a, **k): - raise AssertionError("an abuse report needs no placement or brief choice") + raise AssertionError("an abuse report needs no brief choice") session = fake_session(FakeResponse({"comments": [public("Bitte sperren")]}), FakeResponse({"ticket": {}})) with Patched(claude_api, claude_json=no_claude), \ Patched(note_reply, compose=fake_compose): source = auto_draft.draft_ticket( - session, "sub", {"id": 9, "requester_id": CUSTOMER, "tags": []}, cache(), + session, "sub", {"id": 9, "requester_id": CUSTOMER, + "tags": [f"grp-{group}"]}, cache(), BOOK, API_USER, TODAY, False, category="abuse_report") + return source, seen, session + + def test_a_police_request_about_abuse_gets_the_fixed_legal_text(self): + source, seen, _ = self.run_draft("law-enforcement-requests") + self.assertEqual(source, "law-enforcement-requests") + self.assertEqual(seen, {}) + + def test_it_is_composed_from_the_standing_brief(self): + source, seen, session = self.run_draft("reporting-illegal-content") self.assertEqual(source, "abuse_report") self.assertIn("communities Session itself operates", seen["brief"]) note = session.calls[1][2]["json"]["ticket"]["comment"] @@ -208,6 +286,31 @@ def no_claude(*a, **k): self.assertIn(note_reply.auto_marker("abuse_report"), note["html_body"]) +class DraftKnown(unittest.TestCase): + def test_a_transport_error_never_stops_the_digest(self): + def down(*a, **k): + raise requests.ConnectionError("down") + tickets = [{"id": n, "tags": []} for n in (1, 2, 3, 4)] + findings = [{"id": n, "category": "bug_report"} for n in (1, 2, 3, 4)] + with tempfile.TemporaryDirectory() as tmp, \ + Patched(note_reply, load_house=lambda: BOOK, + api_user_id=lambda *a: API_USER), \ + Patched(auto_draft, refresh_cache=lambda s, sd, u, c, t: c), \ + Patched(api, fetch_comments=down): + drafted = auto_draft.draft_known(None, "sub", tickets, findings, + os.path.join(tmp, "k.json")) + self.assertEqual(drafted, []) + + def test_a_failed_cache_refresh_skips_drafting(self): + def down(*a, **k): + raise requests.ConnectionError("down") + with Patched(note_reply, load_house=lambda: BOOK, + api_user_id=lambda *a: API_USER), \ + Patched(auto_draft, refresh_cache=down): + self.assertEqual(auto_draft.draft_known(None, "sub", [], [], "/nonexistent"), + []) + + class AfterAnUnpromptedDraft(unittest.TestCase): def auto_note(self): note = note_reply.build_draft_note(GERMAN, "need the logs", None, From fc6d303b7e97d95448c7ee8ea39a649f4fcfdb80 Mon Sep 17 00:00:00 2001 From: Audric Ackermann Date: Tue, 3 Nov 2026 14:06:14 +1100 Subject: [PATCH 4/4] Clamp the brief rescan to the retention window --- src/session_ops/zendesk/auto_draft.py | 3 ++- tests/zendesk/test_auto_draft.py | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/session_ops/zendesk/auto_draft.py b/src/session_ops/zendesk/auto_draft.py index 9c6223b..f864ad2 100644 --- a/src/session_ops/zendesk/auto_draft.py +++ b/src/session_ops/zendesk/auto_draft.py @@ -159,7 +159,8 @@ def refresh_cache(session, subdomain, api_user, cache, today): comment read is retried from the same point next run. """ cutoff = (today - timedelta(days=BRIEF_MAX_AGE_DAYS)).isoformat() - since = cache.get("scanned_through") or cutoff + # A scan stuck incomplete would otherwise reread a window growing past the retention. + since = max(cache.get("scanned_through") or cutoff, cutoff) query = (f"type:ticket tags:claude-sent updated>{since} " "order_by:created_at sort:desc") tickets, matched = api.fetch_every_ticket(session, subdomain, query, MAX_SENT_TICKETS) diff --git a/tests/zendesk/test_auto_draft.py b/tests/zendesk/test_auto_draft.py index 027c48c..3df11da 100644 --- a/tests/zendesk/test_auto_draft.py +++ b/tests/zendesk/test_auto_draft.py @@ -107,6 +107,18 @@ def fake_search(session, subdomain, query, max_tickets): self.assertIn("updated>2026-05-06", seen["query"]) self.assertIn("order_by:created_at sort:desc", seen["query"]) + def test_a_stuck_scan_never_reaches_past_the_retention_window(self): + seen = {} + + def fake_search(session, subdomain, query, max_tickets): + seen["query"] = query + return [], 0 + stuck = cache() + stuck["scanned_through"] = "2026-01-01" + with Patched(api, fetch_every_ticket=fake_search): + auto_draft.refresh_cache(None, "sub", API_USER, stuck, TODAY) + self.assertIn("updated>2026-05-06", seen["query"]) + def test_a_failed_comment_read_is_retried_next_run(self): refreshed, _ = self.refresh([{"id": 5, "tags": ["grp-call-failures-and-quality"]}], 1, None)