support_desk 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +197 -0
  3. data/README.md +1131 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +21 -3
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/create_support_desk_message_registrations.rb.erb +42 -0
  30. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  31. data/lib/generators/support_desk/upgrade_generator.rb +25 -7
  32. data/lib/support_desk/assistant_policy.rb +213 -0
  33. data/lib/support_desk/brief.rb +283 -0
  34. data/lib/support_desk/configuration.rb +552 -2
  35. data/lib/support_desk/console.rb +290 -8
  36. data/lib/support_desk/context_card.rb +10 -1
  37. data/lib/support_desk/doctor.rb +171 -1
  38. data/lib/support_desk/engine.rb +16 -1
  39. data/lib/support_desk/errors.rb +37 -0
  40. data/lib/support_desk/events.rb +12 -5
  41. data/lib/support_desk/macros.rb +14 -1
  42. data/lib/support_desk/message_signatures.rb +20 -0
  43. data/lib/support_desk/models/assistant.rb +188 -0
  44. data/lib/support_desk/models/concerns/requester.rb +18 -0
  45. data/lib/support_desk/models/desk.rb +26 -3
  46. data/lib/support_desk/models/draft.rb +275 -0
  47. data/lib/support_desk/models/event.rb +15 -1
  48. data/lib/support_desk/models/message_registration.rb +12 -0
  49. data/lib/support_desk/models/ticket/assistance.rb +899 -0
  50. data/lib/support_desk/models/ticket.rb +406 -58
  51. data/lib/support_desk/outcome.rb +38 -0
  52. data/lib/support_desk/queue.rb +41 -5
  53. data/lib/support_desk/test_helpers.rb +152 -0
  54. data/lib/support_desk/timeline.rb +40 -11
  55. data/lib/support_desk/topic.rb +22 -0
  56. data/lib/support_desk/topic_tree.rb +7 -1
  57. data/lib/support_desk/transcript.rb +234 -0
  58. data/lib/support_desk/version.rb +1 -1
  59. data/lib/support_desk.rb +151 -0
  60. data/lib/tasks/support_desk.rake +57 -0
  61. metadata +33 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74ee479ece9f12d0c47800369c872e8d15f7535a40665592618dc95fc88b7f10
4
- data.tar.gz: 66cba52c0c8ffdba13d921d831a804b170887cbc25e3048d9b748404a80573db
3
+ metadata.gz: 66ba1a32f86acca1b4068bbca4091fcb16c4e6840a9d57ae5dc44bea25572ea2
4
+ data.tar.gz: 2433e492636e42eeffde1652a2c7d21915267c6f0a9180bea8bb010d902cdfad
5
5
  SHA512:
6
- metadata.gz: 305686f2b7f606e94fdb78230991992407408d4603677935d44b7e366792692d99f79f74ddb8e631bed6633a678bb35241d9955cd73c0c52fd7fdaf6985da687
7
- data.tar.gz: 6d4e3a9e84e9e14979bea658e686b29f6df00064bf1f18f80f14a1c75911f9c2a9f86ab2b6be90aa005040b6503acf485e3bc6beae5add62447918621f8fe285
6
+ metadata.gz: ac1246120f4c2aa2763f81866006f054e0dfd14901d42120ddc25d754b08dad350b1627552faaa9e4fd62d37a50d785eeae9abe6dab1c5dc9adbb9f9bb44acd1
7
+ data.tar.gz: 66fca7c4ddad8c9b41358448ec24beda2a3dcd9bade8bd373fe7433a565700d500c212f863c1755048faa7dd803c79c1fa92d3fb0b029a072322d1eff6672994
data/CHANGELOG.md CHANGED
@@ -4,6 +4,203 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.2] - Unreleased
8
+
9
+ ### Fixed
10
+
11
+ - Persist message-registration receipts atomically with clocks and turn revisions.
12
+ A new message with an earlier timestamp or smaller UUID is no longer dropped
13
+ as a replay. It invalidates the turn and triggers requester notifications and
14
+ handoff detection once, without rewinding clocks. This also fixes human-only
15
+ desks; no assistant configuration is needed to encounter the old defect.
16
+ - Recovery discovers unregistered replies on closed cases and honors the desk's
17
+ reopen/locked policy. Repair also runs when the assistant is disabled; only
18
+ actual assistant dispatch requires one to be configured.
19
+ - Public assistant assignment, release and reply refuse `turn: :current`.
20
+ Internal outreach and seat-taking use private paths and actual tokens.
21
+ - Default Chats signatures render assistant messages using per-message
22
+ disclosure snapshots, including old 0.3.0 messages with provenance but no
23
+ assistant-row snapshot. Rename, mode change and flag-off do not erase old
24
+ disclosure. Human replies and approved drafts keep human signatures, and host
25
+ signature overrides remain authoritative. Transcript names are snapshotted
26
+ for new messages; older ones use their stored display name.
27
+ - Correct the README signature override to preserve the human fallback.
28
+
29
+ ### Upgrade
30
+
31
+ Run `rails generate support_desk:upgrade` and migrate **with support writes paused
32
+ and all old web/jobs drained**. The new receipt table follows the existing ticket
33
+ and message ID types and seeds historical messages up to the old role clocks.
34
+ Old clocks cannot reveal lost callbacks behind that baseline: review suspect
35
+ historical cases before resuming. Only 0.3.2 processes may write after cutover.
36
+ See README for recovery, rollback limitations and the unchanged SQLite caveat.
37
+ No gem publication or deployment is performed by this change.
38
+
39
+ ## [0.3.1] - 2026-09-19
40
+
41
+ **Hardening the assistant, after an adversarial review of 0.3.0.** Nine
42
+ defects, every one of them in the concurrency, recovery and kill-switch
43
+ guarantees the release advertised rather than in what a green suite
44
+ covered. Nothing here changes what an assistant may do; it changes what
45
+ can happen to a case while she is doing it. `assign!` and `release!` take
46
+ a `turn:` when `by:` is an assistant — the one API change — and a host
47
+ with no assistant is untouched.
48
+
49
+ ### Fixed
50
+
51
+ - **A customer's next question can no longer be answered around (R1).**
52
+ Reconciliation is a `SELECT`, and a requester never takes the case's row
53
+ lock, so a question could commit between the answer reading the case and
54
+ the answer writing to it: she replied to the previous one, and a human
55
+ approved a proposal from a page that predated the new one — `seen_turn`
56
+ was not evidence that the reviewer had seen the last committed message.
57
+ Every speaking path now takes the ticket's row lock and then the
58
+ conversation row chats updates inside every message insert, before
59
+ reconciling. The lock order is ticket → conversation everywhere, and it
60
+ is written down. **The guarantee is a row lock, so it holds on PostgreSQL
61
+ and MySQL and not on SQLite**, which has no row locks and whose WAL
62
+ snapshot reads straight through an open write transaction: a desk with an
63
+ assistant belongs on PostgreSQL or MySQL, and `doctor` now warns when it
64
+ is not.
65
+ - **A seat is decided under the lock (R2).** `assign!` asked whether
66
+ anybody held the case, and whether she could hold it, *before* the
67
+ transition acquired the row. No threads were needed to break it: load an
68
+ unassigned case into one instance, give it to a person through another,
69
+ and the first instance took the seat from them. A topic cap or a pause
70
+ committed while a take waited for the lock was read too late in the same
71
+ way.
72
+ - **A lost registration is repairable (R3).** A requester message that
73
+ committed while the process that should have registered it died left a
74
+ turn nothing could ever consume: reconciliation happened inside the
75
+ candidate answer's savepoint, the bumped revision made that very answer
76
+ stale, and the `StaleTurn` rolled the repair back with it — for ever,
77
+ while the clocks said `awaiting_requester` so no idle query could see the
78
+ case. The fold is now its own committed step, and
79
+ `redispatch_assistant_turns!` looks for unregistered messages before it
80
+ looks at clocks.
81
+ - **Messages that share an instant no longer deadlock the turn (R4).** The
82
+ unseen-message test was "its id is not the one the clock was set from",
83
+ so every other message on that timestamp read as new: reconciliation
84
+ folded an already-registered one in again, bumped the revision, and made
85
+ the answer that discovered it stale. The retry did the same thing. "Seen"
86
+ is now the pair `(created_at, id)` compared in chats' own transcript
87
+ order, by the same rule in Ruby and in SQL.
88
+ - **The silent sweep rechecks before it escalates (R5).** Its predicates
89
+ were true when it SELECTED its candidates. A person who answered, took
90
+ the case or reset the clock in between lost anyway: the case was marked
91
+ human-required, its priority went up, and the customer was told a person
92
+ was coming — on a case that already had one.
93
+ - **Stranded seats come back (R6).** `deactivate!` promised the sweep would
94
+ release her cases; the sweep only ever visited configured assistants with
95
+ a `responds_within`, and only cases already overdue. An assistant with no
96
+ promise kept her seat for ever, and one removed by the documented kill
97
+ switch — which takes her configuration away — was not visited at all,
98
+ while the doctor's assistant checks returned early on exactly that state.
99
+ `SupportDesk.reclaim_assistant_seats!` (and `rake
100
+ support_desk:reclaim_assistant_seats`) reads the seats that exist.
101
+ - **Running out of turns tells the host (R7).** The budget branch wrote the
102
+ `escalated` event, released her seat and posted the public hand-off line,
103
+ but only the outer `escalate!` published `:ticket_escalated` — so a
104
+ host's "a person is needed here" notifier never heard about a
105
+ conversation that ran out of turns. One private method now publishes it
106
+ for every hand-off.
107
+ - **Disclosure on old messages stops moving (R8).** The requester-facing
108
+ signature resolved live configuration, so taking her out of the
109
+ initializer rewrote "— Rose · asistente virtual" into "— Rose" on
110
+ messages nobody had touched. Her name and mode are snapshotted onto her
111
+ own row and read back from it when nothing declares her any more. A
112
+ rename still renames history, deliberately; the README shows the
113
+ `Chats.config.message_signature` lambda for hosts that want each bubble
114
+ frozen.
115
+ - **The kill switch is read after the wait (R9).** Eligibility was checked
116
+ before the case's row lock, and the policy then read `active?` off that
117
+ same stale copy, so an already-seated assistant whose job queued behind a
118
+ busy case could still speak after `deactivate!` had committed. Every verb
119
+ resolves her under the lock now, from the row. The ordinary in-flight
120
+ window — a switch that commits after that read — is documented rather
121
+ than claimed away.
122
+
123
+ ### Changed
124
+
125
+ - `Ticket#assign!` and `Ticket#release!` take `turn:`. It is REQUIRED when
126
+ `by:` is an assistant (`ArgumentError` when omitted, `StaleTurn` when
127
+ stale) and ignored for a person, whose calls are unchanged.
128
+ - `SupportDesk.release_silent_assistants!` runs
129
+ `reclaim_assistant_seats!` first, and its count includes what that
130
+ reclaimed.
131
+ - The doctor's assistant invariants run whenever an assistant ROW exists,
132
+ not only while one is configured.
133
+ - `Ticket.with_unregistered_requester_messages` — the scope behind the
134
+ repair half of redispatch.
135
+
136
+ ## [0.3.0] - 2026-09-19
137
+
138
+ **A machine can answer, and a person still owns every word it sends.** An
139
+ assistant is an agent with a policy: she has a seat, a name, a turn budget
140
+ and a level that says what she may produce here — and at the default level
141
+ that is a proposal a human reads and sends. Nothing in this release turns
142
+ itself on. Without `config.assistant` the gem behaves exactly as 0.2 did.
143
+
144
+ ### Added
145
+
146
+ - `config.assistant :rose do |rose| … end` — one block per assistant, with `name`, `avatar`, `autonomy`, `disclosure`, `max_turns`, `responds_within`, `may_open_conversations`, the three system lines (`hand_off_line`, `human_requested_line`, `disclosure_line`) and the two hooks (`hand_off_when`, `cap`). `config.assistants`, `config.assistant?(key)`, `config.default_assistant`, and `desk.assistant = :rose` (an explicit `nil` disables one desk rather than inheriting).
147
+ - **`disclosure` is required and has no default.** Four modes — `:signature_and_notice`, `:signature`, `:notice`, `:none` — and boot fails until one is chosen. Whether a customer is told they are talking to a machine is a legal decision in a jurisdiction the gem knows nothing about.
148
+ - `SupportDesk::AssistantPolicy` — what she may do on ONE case, right now, and why. `LEVELS` (`off observe draft reply resolve`), `VERBS_BY_LEVEL`, `level` / `because` / `ceilings` / `floors`, `may?` / `may_observe?` / `may_draft?` / `may_reply?` / `may_hold?` / `may_close?`, `allowed_verbs` / `forbidden_verbs` / `to_h`. Ceilings lower what she may ever produce (her autonomy, the topic's cap, the host's `cap` block, the case's cap, a pause); floors lower it because of the case's state. `because` names the one rule that decided it.
149
+ - `topic :payments, assistant: :draft` — a topic cap, and it only ever tightens: `Topic#assistant_cap` is the minimum over the node and every ancestor.
150
+ - `SupportDesk::Assistant` — the record a message can be authored by, an assignment can point at and an audit log can name. `SupportDesk.assistant(key = nil)`, `SupportDesk.reset_assistants!`, `SupportDesk.ai_actor?(record)`; `deactivate!(by:)` / `activate!(by:)` is the cross-process kill switch, read fresh from the row before every write.
151
+ - `Ticket#respond!(body, by:, turn:, …)` — the one verb a harness needs. Policy decides whether it is sent, proposed as a draft, or withheld with the reason on the record; the answer is a `SupportDesk::Outcome` (`sent?` / `drafted?` / `withheld?` / `escalated?` / `turn`).
152
+ - `Ticket#draft!`, `Ticket#escalate!(reason:, summary:)`, `Ticket#request_human!(by: requester)`, `Ticket#pause_assistant!` / `#resume_assistant!`.
153
+ - **The turn.** `assistant_revision` is an integer bumped by every registered message and every transition; `ticket.assistant_turn` spells it, and EVERY assistant action requires it and consumes it. A late, retried or redelivered action raises `SupportDesk::StaleTurn` and writes nothing — which is why this gem has no idempotency keys, claim rows or leases.
154
+ - `SupportDesk::Draft` — a reply she proposes and a person sends. `send!(by:, seen_turn:, body:)` posts it as the HUMAN's message (they read it, they own it, the requester sees their signature) and keeps the original; `reject!(by:, reason:)` records why, which is the number that tells you whether she is ready for a higher level. `stale?`, `edited?`, `final_body`, `confidence_percent`, and the `reviewed` / `verbatim` / `edited` scopes.
155
+ - `SupportDesk::Transcript` — the conversation as ordered turns (`role`, `name`, `body`, `at`, `attachments`, `assisted`), with four roles rather than the clocks' three. `to_a` / `to_h` / `to_text` / `last(n)` / `since(message)` / `size`, and `ticket.transcript(limit:)`.
156
+ - `SupportDesk::Brief` — everything a machine needs to answer one case, as versioned data. `ticket.brief(include_internal: false, transcript_limit: 50)`, `to_h` / `to_text` / `policy`. Facts only: `may` / `may_not` are the authorization, and there is not one imperative sentence in it.
157
+ - `Requester#support_context` — key/value pairs about the PERSON, the way `Supportable#support_context` is pairs about the thing they are asking about. Empty by default; rendered by `ContextCard#requester_pairs` and included in the brief.
158
+ - Readers and scopes for the new state: `Ticket#assistant`, `#assistant_policy`, `#assistant_turn`, `#assistant_in_play?`, `#held_by_assistant?`, `#human_required?`, `#assistant_paused?`, `#assistant_turns_left`, `#assistant_message?(message)`, `#drafts` / `#pending_draft`; and `held_by_assistants`, `held_by_humans`, `needs_human`, `assistant_paused`, `assistant_capped`, `resolved_by_assistant`, `with_pending_draft`, `assistant_idle_since(time)`. `Desk#assistant` / `#assistant?` name the one that works a desk.
159
+ - Console: `send_draft`, `reject_draft`, `pause_assistant`, `resume_assistant`, the proposal card (Enviar / Editar / Descartar) with the confidence pill, the sources and a staleness warning, the helpers `support_pending_draft` and `support_assistant`, `"no_pending_draft"` as an `unavailable_reason`, and the `needs_human` queue tab (`Queue#needs_human`, `#visible_tabs`).
160
+ - Requester engine: `POST /tickets/:id/request_human`, the `support_desk/tickets/_human_door` partial and the `support_human_door(ticket)` helper. The door is offered whenever an assistant is or has been in play on the case — history counts, so it can never vanish because somebody edited an initializer after she answered.
161
+ - Events: `assistant_turn` (the only one a harness subscribes to; it carries the turn), `draft_proposed`, `draft_sent`, `draft_rejected`, `assistant_withheld`, `ticket_escalated`, `human_requested`, `assistant_paused`, `assistant_resumed`. The matching `Event::KINDS` are new too; the five internal ones join `note` and `drop_in` in `Event::INTERNAL_KINDS`, which is what `Event.requester_visible` excludes, and `Event#summary` reads the paragraph an escalation left.
162
+ - Spanish and English copy for all of it — the system lines, the console's proposal card and flashes, the queue's new tab and the requester's door — with the suite asserting both files carry the same keys.
163
+ - Errors: `SupportDesk::NotAnAssistant`, `SupportDesk::AssistantNotAllowed` (carrying the `policy` and the `verb`), `SupportDesk::StaleTurn`.
164
+ - `rake support_desk:release_silent_assistants` (every minute) and `rake support_desk:redispatch_assistant_turns` (every five) — the net under a dead harness and the at-least-once retry of a dropped turn. `rake support_desk:assistant_status` reads and writes nothing.
165
+ - Doctor: `assistants (config)`, `assistant turn subscriber`, `assistant authorship`, `assistant silence`, `assistant seats`, `assistant idle turns`, `drafts`, `ai agents without policy`.
166
+ - `SupportDesk::TestHelpers`: `support_assistant`, `respond_as`, `draft_as`, `assert_pending_draft` / `refute_pending_draft`, `assert_needs_human` / `refute_needs_human`, `assert_held_by_assistant`, `refute_assistant_spoke`, `assert_assistant_policy`, `with_assistant_config`, `with_topic_assistant_cap`.
167
+ - Generators: `rails g support_desk:assistant Rose --disclosure MODE [--desk KEY] [--autonomy LEVEL]` writes the turn job, the service and a job test, and PRINTS the initializer stanza, the subscription and the two scheduler lines. It never edits an initializer, never overwrites a file and copies no locales. `install` and `upgrade` both copy the additive assistants migration.
168
+
169
+ ### Changed — regardless of configuration
170
+
171
+ These land whether or not you declare an assistant:
172
+
173
+ - **A host model declared `acts_as_support_agent kind: :ai` is now refused for every support write, by it or to it** (`NotAnAssistant`). It used to be treated as a human. `kind:` is validated at declaration (`:human` or `:ai`), and `SupportDesk.doctor` warns about such classes. Only the desk's own `SupportDesk::Assistant` has machine authority.
174
+ - **Console picker values are `SupportDesk.actor_key(agent)`**, not bare ids — an assistant and a user can share an integer id. A bare id is still accepted for one release, and only when exactly one pool member matches it.
175
+ - `Queue::TABS` gains `:needs_human`. It is hidden unless the desk has an assistant or the count is non-zero, so no existing queue grows a tab it has no use for.
176
+ - Every registration and every transition writes `assistant_revision`. Additive; a 0.2 process reading those rows is unaffected.
177
+ - The schema adds `support_desk_assistants` and `support_desk_drafts`, and nine columns on `support_desk_tickets` — two defaulted integers (`assistant_revision`, `assistant_turns_count`), the rest nullable.
178
+
179
+ ### Changed — only when an assistant is configured
180
+
181
+ - `Desk#agents` is now an **Array** (humans plus her), and `Desk#humans` is the human-only pool. Page people with `ticket.agents_to_notify` or `desk.humans`: notifying a machine is notifying nobody.
182
+ - `announce_assignments` never announces her — "Rose is taking care of your request" is not a thing to say.
183
+ - **A human reply on a case she holds takes it over under every `reply_policy`**, and supersedes the pending proposal. `:assignee_only` exists so two people don't answer at once, and she is not one.
184
+ - `close!` expires pending proposals; reopening a case she closed leaves it unassigned and caps her at `:draft` for the rest of its life.
185
+ - `Ticket#reply!` and `post_agent_message!` accept `metadata:` (provenance) and `turn:`.
186
+
187
+ ### Upgrading from 0.2
188
+
189
+ Additive and rolling-safe — unlike the 0.2 cutover, no drain is needed.
190
+
191
+ 1. `bundle update support_desk` (0.3.0).
192
+ 2. `rails generate support_desk:upgrade` then `rails db:migrate`. The migration only adds tables and nullable columns.
193
+ 3. **Deploy every process to 0.3.0 before adding `config.assistant`.** A 0.2 worker cannot honour a turn it does not know about.
194
+ 4. `rails g support_desk:assistant Rose --disclosure …`, paste the printed stanza, wire the job to `:assistant_turn`, schedule the two rake tasks, and subscribe your notifiers to `draft_proposed`, `ticket_escalated` and `human_requested`.
195
+ 5. Render `support_desk/tickets/_human_door` in your thread.
196
+ 6. `SupportDesk.doctor` green.
197
+ 7. **Start at `:draft`.** Every word goes through a person until the acceptance rate on reviewed proposals says otherwise, and keep topic caps on money and identity when you raise it.
198
+
199
+ Rolling back: `rose.deactivate!` is the kill switch and needs no deploy. A
200
+ code rollback to 0.2 keeps the schema — the columns are nullable and the two
201
+ tables are ignored — but loses the provenance rendering of her messages, so
202
+ do it only if 0.3 itself is broken.
203
+
7
204
  ## [0.2.0] - 2026-09-17
8
205
 
9
206
  **The desk can write first.** Until now a case could only start with somebody