From 13cb750a2a703c98a6b4cee8a166c327c26c8102 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 13:38:55 +0800 Subject: [PATCH] feat(captain): align task payload persistence --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 41 ++- internal/app/bootstrap.go | 4 +- .../api/v1/captain_task_extended_handler.go | 46 ++- .../v1/captain_task_extended_handler_test.go | 78 ++++- .../handler/api/v1/captain_task_handler.go | 29 +- .../api/v1/captain_task_handler_test.go | 103 +++++++ internal/model/copilot_models.go | 27 ++ .../repository/copilot_message_repo_test.go | 43 ++- .../service/captain_task_extended_service.go | 207 +++++++++++++- internal/service/captain_task_service.go | 267 ++++++++++++++---- internal/service/captain_task_service_test.go | 66 ++++- 11 files changed, 822 insertions(+), 89 deletions(-) create mode 100644 internal/handler/api/v1/captain_task_handler_test.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 21f968d9..47ad3035 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -16,9 +16,9 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline -- Latest implementation checkpoint: `feat(captain): gate document sync backend`. -- Latest documentation checkpoint: this checkpoint, recorded with the B11.3d/B11.3e/B12 executable landing plan. -- Worktree status at this documentation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states. Next active implementation slice is B11.3d Copilot tasks/tool-call depth, followed by B11.3e streaming/realtime compatibility and B12 reused frontend smoke. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(captain): align task payload persistence`. +- Latest documentation checkpoint: this checkpoint, recorded with the B11.3d Captain task/tool-call persistence implementation. +- Worktree status at this implementation checkpoint: B11.1a aligns Captain assistant CRUD/tools/inbox bindings; B11.1b aligns Captain scenarios and custom tools; B11.1c aligns Captain documents, assistant responses, bulk actions, and custom-tool test payloads; B11.2 aligns Copilot thread/message create/list/get/delete payloads, account/user scoping, and no-LLM fallback persistence; B11.3a aligns Captain preferences show/update payloads and account-level model/feature storage; B11.3b aligns Captain playground request/response payloads, account scoping, v2 history handling, and no-LLM fallback; B11.3c adds the fakeable Captain document sync backend gate with disabled, failed, and fake-success states; B11.3d aligns Captain task request/response payloads, no-provider disabled states, follow-up context, suggestion persistence, and Copilot message tool-call key validation. Next active implementation slice is B11.3e streaming/realtime compatibility, followed by B12 reused frontend smoke. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 830` after adding the Chatwoot-compatible applied-SLA index route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -44,9 +44,8 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | B11.3d | Copilot tasks, suggestions, and tool-call records persist enough frontend state for reloads. | Copilot task/tool-call handler/service fixtures and serializer tests. | -| 2 | B11.3e | Streaming/realtime paths have frontend-compatible non-streaming or disabled fallbacks until true streaming is wired. | Deterministic response tests and event/push payload tests. | -| 3 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | +| 1 | B11.3e | Streaming/realtime paths have frontend-compatible non-streaming or disabled fallbacks until true streaming is wired. | Deterministic response tests and event/push payload tests. | +| 2 | B12 | Reused Chatwoot frontend smoke runs repeatably against GoChat. | Checked smoke command plus gap report under `docs/parity/`. | ## Execution Snapshot @@ -56,7 +55,7 @@ Next ordered checkpoints: | Phase 1 | Meilisearch search engine | Review | B6 payload parity, optional live gate, and DB-fallback hardening are implemented; an actual live Meilisearch run is optional and environment-dependent | | Phase 2 | Route and controller parity audit | Doing | Ruby/Bundler unavailable, so Chatwoot route extraction currently uses static `routes.rb` fallback | | Phase 3 | Data and serializer parity | Doing | JSON fixture coverage is partial and still endpoint-family based | -| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources, preferences, playground, document sync gates, and Copilot thread/message payloads are landed, while Copilot tasks/tool-call depth and streaming remain active | +| Phase 4 | Enterprise feature completion | Doing | B7, B8, B9, and B10 are in Review; B11 Captain resources, preferences, playground, document sync gates, Copilot thread/message payloads, and Captain task payload persistence are landed, while streaming/realtime compatibility remains active | | Phase 5 | Background jobs and integrations | Planned | durable worker choice and job parity are open | | Phase 6 | Core placeholder burn-down | Doing | account/contact/conversation/message/inbox placeholder groups remain broad | | Phase 7 | Verification harness | Planned | search live gate and reused-frontend smoke harness are not complete | @@ -156,6 +155,7 @@ This ledger records the committed parity checkpoints that future slices should b | `feat(captain): align playground fallback` | Completes B11.3b Captain assistant playground parity: `POST /captain/assistants/:assistant_id/playground` now accepts the Chatwoot frontend top-level `message_content` and `message_history` payload, enforces account-scoped assistant lookup, returns raw legacy `{ content }` or v2 `{ response }` payloads, appends the current user message once for `captain_integration_v2`, preserves history ordering through the LLM boundary, and returns a deterministic raw disabled response when no LLM provider is configured. | `go test ./internal/handler/api/v1 -run CaptainAssistant -count=1`; `go test ./internal/service -run Captain -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/service -count=1`; `go test ./...`; `git diff --check`. Focused handler fixtures cover raw no-envelope fallback, no-history default, cross-account rejection, v2 append/no-duplicate history behavior, and fake LLM request capture. | Continue B11.3c with Captain document sync/indexing and Meilisearch/embedding gates. | | `feat(captain): gate document sync backend` | Completes B11.3c's first document sync gate: Captain document sync now has a fakeable `CaptainDocumentSyncBackend` boundary and account-scoped `SyncDocumentByAccount` worker entry that marks disabled config as `sync_disabled`, records backend errors as `sync_error`, preserves backend error codes such as `access_denied`, stores successful content/title/fingerprint updates as `synced`, and avoids external HTTP/LLM calls in default tests. | `go test ./internal/service -run CaptainDocument -count=1`; `go test ./internal/service -run 'Captain\|Copilot' -count=1`; `go test ./internal/handler/api/v1 -run 'Captain\|Copilot' -count=1`; `go test ./internal/service -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./...`; `git diff --check`. Focused service fixtures cover disabled, fake success, backend error, backend error-code, content fingerprint normalization, and account scoping. | Continue B11.3d with Copilot task/tool-call persistence and suggestion payload depth. | | `docs: land copilot task execution plan` | Documentation-only checkpoint that fully lands the B11.3d/B11.3e/B12 execution plan: Captain task routes now have exact Chatwoot request/response contracts, current Go gaps, task persistence rules, follow-up context behavior, no-provider disabled-state rules, focused verification commands, streaming/realtime fallback gates, and reused frontend smoke deliverables. | `git diff --check`; no Go code changed. | Start B11.3d implementation from the recorded task/tool-call contract before moving to streaming or frontend smoke. | +| `feat(captain): align task payload persistence` | Completes B11.3d's frontend task contract: rewrite/summarize/reply suggestion now accept Chatwoot `operation` and `conversation_display_id` payloads, return raw `{ message, follow_up_context }` or `422 { error }`, avoid no-provider panics with `Captain is disabled`, resolve conversations by account-scoped display ID, and persist task outputs into `copilot_suggestion_messages`. Label suggestion and follow-up POST routes now consume the dashboard `tasks.js` bodies, preserve/update Chatwoot follow-up context, and return raw payloads. `CopilotMessage` now validates the Chatwoot allowed JSON keys `content`, `reasoning`, `function_name`, and `reply_suggestion` for reloadable tool-call state. | `go test ./internal/handler/api/v1 -run 'CaptainTask\|Captain\|Copilot' -count=1`; `go test ./internal/service -run 'CaptainTask\|Captain\|Copilot' -count=1`; `go test ./internal/repository -run 'Copilot\|Captain' -count=1`; `go test ./internal/handler/api/v1 -count=1`; `go test ./internal/service -count=1`; `TMPDIR=/home/rogee/Projects/gochat/.tmp/test-tmp go test ./...` with existing caches; `git diff --check`. Focused fixtures cover raw no-envelope task responses, disabled state, display-ID lookup, suggestion persistence, follow-up history update, and Copilot message key validation. | Continue B11.3e streaming/realtime compatibility; B12 frontend smoke remains after B11.3e. | ## Next Slice Contract @@ -163,7 +163,7 @@ Completed implementation slice: B11.3c now adds Captain document sync backend ga Completed documentation slice: the remaining development plan is now fully landed in this tracker. B11.3 is split into concrete Captain playground, document sync/indexing, Copilot task/tool-call, and streaming/realtime checkpoints. B12 has a repeatable smoke-report contract. B9.3 delayed/durable worker scheduling and B9.4 macro attachment depth remain named Phase 5 follow-ups. -Next implementation slice: start B11.3d Copilot task/tool-call persistence and suggestion payload depth. Do not expand to streaming until rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, raw disabled states, and reloadable task/tool-call serializers are covered by tests. +Next implementation slice: start B11.3e streaming/realtime compatibility. B11.3d now covers rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, raw disabled states, persisted suggestion state, and reloadable Copilot message key validation. | Step | Required result | Reference source | Verification | | --- | --- | --- | --- | @@ -190,7 +190,7 @@ Next implementation slice: start B11.3d Copilot task/tool-call persistence and s | N21 | Keep B11.3a Captain preferences as current account-level Captain config baseline. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Done by `feat(captain): align preferences payloads`; raw model/provider/feature payloads, merge update behavior, account storage, admin gate, and exact preference routes are covered. | | N22 | Keep B11.3b Captain assistant playground payload parity as current playground baseline. | `reference/chatwoot/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb#playground`, dashboard `api/captain/assistant.js`, assistant playground component/store. | Done by `feat(captain): align playground fallback`; top-level `message_content` and `message_history` are accepted, account-scoped assistant lookup is enforced, legacy `{ content }` and v2 `{ response }` shapes are covered, and no-LLM fallback returns raw frontend-compatible JSON without 500. | | N23 | Keep B11.3c Captain document sync/indexing gates as current document-sync baseline. | Captain document sync service/jobs, document controller `sync`, existing Meilisearch engine, and any local embedding boundary. | Done by `feat(captain): gate document sync backend`; disabled config, fake successful indexing, failed sync metadata, fingerprint normalization, and account-scoped document lookup are covered without opening external network connections in default tests. | -| N24 | Implement B11.3d Copilot task/tool-call persistence. | `resource :tasks` routes, Copilot/Captain task services, dashboard Copilot clients, current local `copilot_*` models. | Rewrite/summarize/reply suggestion/label suggestion/follow-up requests persist task/tool-call state or return documented raw disabled states; serializers survive frontend reload. | +| N24 | Keep B11.3d Copilot task/tool-call persistence as current task baseline. | `resource :tasks` routes, Copilot/Captain task services, dashboard Copilot clients, current local `copilot_*` models. | Done by `feat(captain): align task payload persistence`; task routes accept frontend bodies, persist suggestions, return raw payloads/disabled states, and validate reloadable Copilot message keys. | | N25 | Implement B11.3e streaming/realtime compatibility. | Chatwoot Copilot/Captain streaming, push/event payloads, current Go channel dispatcher. | If streaming is not implemented, the endpoint returns deterministic non-streaming payloads and records the fallback; no route returns placeholder success or local envelope. | | N26 | Start B12 smoke harness after B11.3 has a tested disabled/external-provider story. | `reference/chatwoot` frontend boot scripts and GoChat dev/test boot flow. | A checked command and `docs/parity/frontend_smoke_report.md` record core and enterprise smoke status. | | N27 | Keep Hermes source plans mapped but inactive. | `.hermes/plans/2025-05-24-global-search-meilisearch.md`, `.hermes/plans/2026-05-24-automation-macro-csat.md`. | New work must update this tracker directly; Hermes files are read-only source notes unless the user asks otherwise. | @@ -789,7 +789,7 @@ B11 Captain/Copilot breakdown: | B11.3a | Align Captain preferences show/update payloads, account-level model/feature storage, and admin update gate. | `preferences_controller.rb`, `CaptainFeaturable`, `config/llm.yml`, dashboard `preferences.js` API/store. | Handler tests cover raw `{ providers, models, features }`, default selected models, merge update behavior, account persistence, invalid model rejection, admin gate, and exact preference routes. | Done by `feat(captain): align preferences payloads` | | B11.3b | Align Captain assistant playground request/response behavior and no-LLM fallback. | `assistants_controller.rb#playground`, `Captain::Llm::AssistantChatService`, `AgentRunnerService`, dashboard `api/captain/assistant.js`, playground component/store. | Handler/service tests cover top-level `message_content`, `message_history`, account-scoped assistant lookup, no-history payloads, no duplicate latest user message for v2 behavior if enabled, raw `{ content }` or `{ response }` response shape, and safe no-LLM fallback. | Done by `feat(captain): align playground fallback` | | B11.3c | Implement document sync/indexing gates for Meilisearch or the chosen embedding/search backend. | Captain document sync/indexing services, document controller `sync`, existing Meilisearch engine, local embedding/config boundaries. | Tests cover disabled config, failed sync observability, successful fake backend indexing, account-scoped document lookup, and no external network call in default test mode. | Done by `feat(captain): gate document sync backend` | -| B11.3d | Finish Copilot task, suggestion, and tool-call persistence depth. | `resource :tasks` routes in `reference/chatwoot/config/routes.rb:62-89`, Copilot/Captain task services, dashboard Copilot clients. | Tests cover rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, persisted task/tool-call records, reloadable serializers, account/user scoping, and raw disabled state when provider config is missing. | Todo | +| B11.3d | Finish Copilot task, suggestion, and tool-call persistence depth. | `resource :tasks` routes in `reference/chatwoot/config/routes.rb:62-89`, Copilot/Captain task services, dashboard Copilot clients. | Tests cover rewrite/summarize/reply suggestion/label suggestion/follow-up request payloads, persisted task/tool-call records, reloadable serializers, account/user scoping, and raw disabled state when provider config is missing. | Done by `feat(captain): align task payload persistence` | | B11.3e | Add streaming/realtime compatibility or deterministic non-streaming fallback. | Chatwoot Captain/Copilot streaming paths, push payloads, current Go dispatcher/realtime boundaries. | Tests prove endpoints never return placeholder success/local envelopes; non-streaming fallback payloads are deterministic and frontend-compatible until true streaming is implemented. | Todo | B11.1a current checkpoint: @@ -863,7 +863,7 @@ B11.3c-B11.3e follow-up contracts: | Slice | Landing rule | Status gate | | --- | --- | --- | | B11.3c document sync/indexing | Document sync must expose frontend-visible `syncing/synced/failed` states and call a fakeable Meilisearch/embedding boundary. Missing provider config must be a raw disabled/failure state, not a placeholder success. | B11 remains `Doing` until disabled, failed, and fake-success sync tests are present. | -| B11.3d Copilot tasks/tool-calls | Rewrite, summarize, reply suggestion, label suggestion, and follow-up task routes must accept Chatwoot request payloads, persist enough task/tool-call output for reloads, and serialize raw frontend payloads. | B11 remains `Doing` until task/tool-call fixtures cover account/user scoping and no-provider fallback. | +| B11.3d Copilot tasks/tool-calls | Rewrite, summarize, reply suggestion, label suggestion, and follow-up task routes must accept Chatwoot request payloads, persist enough task/tool-call output for reloads, and serialize raw frontend payloads. | Done by `feat(captain): align task payload persistence`; B11 remains `Doing` for B11.3e streaming/realtime fallback. | | B11.3e streaming/realtime | Streaming can be deferred only with a deterministic non-streaming response or explicit disabled state. Realtime push payloads should use the same thread/message serializer already landed in B11.2. | B11 moves to `Review` only after every streaming/realtime endpoint has a tested frontend-compatible fallback. | B11.3c current checkpoint: @@ -906,6 +906,18 @@ env GOCACHE=/tmp/gochat-gocache GOMODCACHE=/tmp/gochat-gomodcache go test ./... git diff --check ``` +B11.3d current checkpoint: + +- Reference files inspected for this slice: `reference/chatwoot/app/controllers/api/v1/accounts/captain/tasks_controller.rb`, dashboard `api/captain/tasks.js`, `useCaptain.js`, `Captain::BaseTaskService`, `RewriteService`, `SummaryService`, `ReplySuggestionService`, `LabelSuggestionService`, `FollowUpService`, and enterprise `CopilotMessage`. +- `rewrite`, `summarize`, and `reply_suggestion` now accept Chatwoot frontend `conversation_display_id` payloads, keep legacy local IDs only as service compatibility, and resolve conversations through account-scoped display-ID lookup. +- Task handlers now return raw Chatwoot `{ message, follow_up_context }` success payloads and `422 { error }` disabled/error payloads instead of the local `response.OK` envelope. +- Missing LLM provider returns the deterministic raw `Captain is disabled` state for task routes instead of panicking or producing `500`. +- Rewrite validates Chatwoot operations: `fix_spelling_grammar`, `improve`, `casual`, `professional`, `friendly`, `confident`, and `straightforward`; invalid operations return `422`. +- Label suggestion and follow-up POST routes now consume the reused dashboard JSON bodies. Follow-up validates allowed event names, appends user/assistant turns to `conversation_history`, updates `last_response`, and preserves `channel_type`. +- Task outputs persist to `copilot_suggestion_messages` by account and resolved conversation so reloadable frontend state survives beyond the request. +- `CopilotMessage` now rejects JSON keys outside Chatwoot's allowed set: `content`, `reasoning`, `function_name`, and `reply_suggestion`. +- Focused handler/service/repository fixtures cover raw no-envelope payloads, display-ID lookup, no-provider disabled state, suggestion persistence, follow-up history updates, and Copilot message key validation. + B11.3e streaming/realtime landing contract: | Area | Landing requirement | Done signal | @@ -1064,7 +1076,7 @@ Remaining slice landing plan: | B8 | Done: account-side CSAT list/metrics/review-note payload parity, public submit/update idempotency, resolve-triggered generic survey message send, and CSV download filters/columns. | Channel-specific WhatsApp/Twilio template send hooks and durable queueing are tracked under Phase 5. | Review after `b36cf07`; move to Done only after channel-specific send hooks are either implemented or formally split as external integration scope. | | B9 | Done: B9.1 automation rule CRUD/listener/log/external-action parity and B9.2 macro frontend CRUD/execute side effects. | Delayed actions, durable queued worker scheduling, and deeper macro attachment/file parity remain named B9.3/B9.4 follow-ups. | Review after `feat(macros): align chatwoot macro payloads`; move to Done only after durable worker/attachment gaps are implemented or formally split out. | | B10 | Done: audit list payload, audit writer boundary for representative mutating core resources, CustomRole permission-key parity, AccountUser permission resolution, admin gates, delete nullification, and account-level InboxLimit enforcement in inbox/channel creation paths. | Frontend smoke coverage for enterprise settings remains B12, not a hidden B10 blocker. | Review after B10.4; move to Done only after reused frontend smoke confirms audit/custom-role/limit settings flows or any smoke gaps are split into owned follow-ups. | -| B11 | Done through B11.3a: Captain Assistant CRUD, inbox binding, scenarios, documents, responses, custom tools, Copilot threads/messages, and Captain preferences payload fixtures. | B11.3b playground, B11.3c document sync/embedding gates, B11.3d Copilot tasks/tool-calls, and B11.3e streaming/realtime fallback. | Done only after LLM-dependent behavior is either implemented behind config or safely stubbed with frontend-compatible disabled states. | +| B11 | Done through B11.3d: Captain Assistant CRUD, inbox binding, scenarios, documents, responses, custom tools, Copilot threads/messages, Captain preferences, playground, document sync gates, and task/tool-call payload persistence fixtures. | B11.3e streaming/realtime fallback remains active. | Done only after LLM-dependent behavior is either implemented behind config or safely stubbed with frontend-compatible disabled states. | | B12 | Boot reused Chatwoot frontend against GoChat auth/profile/inbox/conversation/contact flows. | Add smoke paths for widget init/message, public CSAT, reports, and enterprise screens as B7-B11 land. | Done only after the smoke command is repeatable and writes a checked gap report. | Per-slice documentation rule: @@ -1342,7 +1354,7 @@ Enterprise tracking table: | P4.3 | Custom roles/permissions | `internal/model/custom_role.go`, `internal/service/custom_role_service.go`, `internal/middleware/role_check.go`, `internal/handler/api/v1/custom_role_handler.go` | Permission arrays, raw admin payloads, administrator gates, account-user resolution, profile permissions, and delete nullification are implemented; keep in Review until frontend smoke covers enterprise settings. | Review | | P4.4 | Agent capacity | `internal/model/agent_capacity_policy.go`, `internal/service/agent_capacity_policy_service.go`, `internal/handler/api/v1/agent_capacity_handler.go`, `internal/autoassignment/*` | First align AgentCapacityPolicy API, serializers, nested users, and `InboxCapacityLimit` data contracts; then enforce capacity in manual/auto assignment. | Review | | P4.5 | Inbox limits | `internal/model/agent_capacity_policy.go` `InboxCapacityLimit`, plus legacy `internal/model/inbox_limit.go`, `internal/service/inbox_limit_service.go`, `internal/repository/inbox_limit_repo.go`, `internal/handler/api/v1/inbox_limit_handler.go` | Chatwoot enterprise nested `agent_capacity_policies/:policy_id/inbox_limits`, assignment-path enforcement, and account-level `usage_limits[:inboxes]` create-path enforcement are covered. | Review | -| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, Captain preferences through B11.3a, Captain playground through B11.3b, and document sync gates through B11.3c are aligned; complete B11.3d Copilot task/tool-call depth and B11.3e streaming compatibility. | Doing | +| P4.6 | Captain/Copilot | `internal/model/captain_models.go`, `internal/model/copilot_models.go`, `internal/service/captain_*`, `internal/service/copilot_*`, `internal/handler/api/v1/captain_*`, `internal/handler/api/v1/copilot_handler.go` | Captain resource payloads through B11.1c, Copilot thread/message payloads through B11.2, Captain preferences through B11.3a, Captain playground through B11.3b, document sync gates through B11.3c, and task/tool-call payload persistence through B11.3d are aligned; complete B11.3e streaming compatibility. | Doing | | P4.7 | CSAT | `internal/csat/*`, `internal/automation/csat_survey_*`, `internal/handler/api/v1/csat_*`, `internal/service/csat_metrics_service.go` | Account-side list/metrics/review-note payloads, public update depth, resolve-triggered generic survey send, and download CSV are aligned; channel-specific template send hooks remain Phase 5 integration work. | Review | | P4.8 | Automation and macros | `internal/automation/*`, `internal/handler/api/v1/automation_rule_handler.go`, `internal/handler/api/v1/macro_handler.go` | Automation-rule CRUD/listener/log/external-action delivery and macro CRUD/display-ID execution parity are implemented; durable delayed actions and deeper macro attachment/file parity remain B9.3/B9.4 follow-ups. | Review | | P4.9 | Assignment policies | `internal/autoassignment/*`, `internal/automation/agent_bot_rule_listener.go` | Match Chatwoot assignment policy behavior and availability/capacity rules. | Doing | @@ -1359,7 +1371,7 @@ Enterprise work package breakdown: | Audit | Audit model parity, mutating action coverage, request metadata, filters/pagination, admin endpoint payloads. | List/admin payload parity is covered by `feat(audit): align chatwoot audit log payloads`; named representative writer coverage is covered by `feat(audit): record enterprise mutations` and `feat(audit): cover operational mutations`. | Review | | Custom roles | Permission-key parity, account-user role resolution, policy middleware, create/update/delete behavior. | Permission arrays, raw admin payloads, admin gates, RBAC/account-scope/profile resolution, and delete nullification are covered by `feat(custom-roles): align chatwoot permissions`. | Review | | Inbox limits | Account/inbox limit models, create/update enforcement, UI-readable limit responses, admin overrides. | Chatwoot nested capacity-limit API, assignment-path enforcement, and account-level create-limit `402` behavior are covered by B5/B7/B10.4. | Review | -| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, preferences, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; preferences fixtures are covered by `feat(captain): align preferences payloads`; playground fixtures are covered by `feat(captain): align playground fallback`; document sync gate fixtures are covered by `feat(captain): gate document sync backend`; B11.3d-B11.3e now track remaining Copilot task/tool-call behavior, streaming fallback, and frontend smoke screens. | Doing | +| Captain/Copilot | Assistants, inbox bindings, scenarios, responses, documents, tools, preferences, copilot threads/messages, tasks, streaming/tool-call behavior. | Assistant CRUD/tools/inbox binding fixtures are covered by `feat(captain): align assistant resources`; scenario/custom-tool fixtures are covered by `feat(captain): align scenario and tool payloads`; document/assistant-response/bulk/custom-tool-test fixtures are covered by `feat(captain): align document response actions`; Copilot thread/message fixtures are covered by `feat(copilot): align thread message payloads`; preferences fixtures are covered by `feat(captain): align preferences payloads`; playground fixtures are covered by `feat(captain): align playground fallback`; document sync gate fixtures are covered by `feat(captain): gate document sync backend`; task/tool-call payload persistence is covered by `feat(captain): align task payload persistence`; B11.3e now tracks remaining streaming fallback and frontend smoke screens. | Doing | Enterprise acceptance gates: @@ -1602,3 +1614,4 @@ Verification milestone gates: - 2026-06-05: B11.3b Captain playground checkpoint prepared as `feat(captain): align playground fallback`; playground now accepts Chatwoot top-level `message_content/message_history`, scopes assistant lookup to the account, returns raw legacy `{ content }` or v2 `{ response }`, appends current v2 user messages without duplication, preserves history into the fakeable LLM boundary, and returns deterministic no-LLM fallback JSON instead of 500. Focused CaptainAssistant/Captain tests, handler/service package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B11.3c document sync/indexing and Meilisearch/embedding gates. - 2026-06-05: B11.3c Captain document sync gate checkpoint prepared as `feat(captain): gate document sync backend`; document sync now has a fakeable backend boundary and account-scoped worker entry, disabled config is recorded as `sync_disabled`, backend failures become `sync_error` or preserve explicit backend error codes, fake success updates content/title/fingerprint/timestamps as `synced`, and default tests avoid external network/LLM calls. Focused Captain/Copilot tests, handler/service package tests, full `go test ./...`, and `git diff --check` passed. Next slice is B11.3d Copilot task/tool-call persistence. - 2026-06-05: B11.3d/B11.3e/B12 planning checkpoint prepared as `docs: land copilot task execution plan`; the active tracker now records exact Chatwoot Captain task request/response contracts, current Go handler/service gaps, follow-up context and allowed operation rules, reloadable Copilot message/tool-call persistence requirements, no-provider disabled-state expectations, streaming/realtime fallback gates, B12 smoke harness deliverables, and focused exit commands. Documentation-only checkpoint; `git diff --check` passed. Next slice is B11.3d implementation. +- 2026-06-05: B11.3d Captain task/tool-call checkpoint prepared as `feat(captain): align task payload persistence`; rewrite/summarize/reply suggestion now accept Chatwoot task payloads and return raw `{ message, follow_up_context }` or `422 { error }`, label suggestion and follow-up POST routes consume dashboard `tasks.js` bodies, no-provider paths return `Captain is disabled`, task outputs persist to `copilot_suggestion_messages`, and `CopilotMessage` validates reloadable tool-call keys. Focused CaptainTask/Captain/Copilot handler and service tests, Copilot/Captain repository tests, handler/service package tests, full `go test ./...` with workspace `TMPDIR`, and `git diff --check` passed. Next slice is B11.3e streaming/realtime compatibility. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 0db6d84e..80e7a8e9 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -565,10 +565,10 @@ func Bootstrap(env string) (*App, error) { captainCustomToolService := service.NewCaptainCustomToolService(captainCustomToolRepo) copilotService := service.NewCopilotService(copilotThreadRepo, copilotMessageRepo, copilotSuggestionRepo, llmProvider, captainAssistantRepo) copilotContextService := service.NewCopilotContextService(messageRepo, conversationRepo, contactRepo, llmProvider) - captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService) + captainTaskService := service.NewCaptainTaskService(captainAssistantRepo, captainAssistantResponseRepo, captainCustomToolRepo, conversationRepo, messageRepo, llmProvider, copilotContextService, copilotSuggestionRepo) conversationInsightService := service.NewConversationInsightService(conversationRepo, messageRepo, captainAssistantRepo, llmProvider) captainPreferenceService := service.NewCaptainPreferenceService(captainPreferenceRepo, accountRepo) - captainTaskExtendedService := service.NewCaptainTaskExtendedService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider) + captainTaskExtendedService := service.NewCaptainTaskExtendedService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, copilotSuggestionRepo) captainAssistantResponseService := service.NewCaptainAssistantResponseService(captainAssistantRepo, captainAssistantResponseRepo, conversationRepo, messageRepo, captainPreferenceRepo, llmProvider) captainBulkActionService := service.NewCaptainBulkActionService(conversationRepo, messageRepo, captainAssistantRepo, captainPreferenceRepo, llmProvider, captainTaskExtendedService, captainAssistantResponseService) captainBulkActionService.SetCaptainResourceRepos(captainAssistantResponseRepo, captainDocumentRepo) diff --git a/internal/handler/api/v1/captain_task_extended_handler.go b/internal/handler/api/v1/captain_task_extended_handler.go index 4965126d..470379e3 100644 --- a/internal/handler/api/v1/captain_task_extended_handler.go +++ b/internal/handler/api/v1/captain_task_extended_handler.go @@ -30,6 +30,22 @@ func (h *CaptainTaskExtendedHandler) LabelSuggestion(c *gin.Context) { return } + if c.Request.Method == http.MethodPost { + var req service.ChatwootLabelSuggestionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body: "+err.Error()) + return + } + result, err := h.svc.LabelSuggestion(c.Request.Context(), uint(accountID), &req) + if err != nil { + applogger.L().Errorf("Label suggestion: %v", err) + renderCaptainTaskError(c, err) + return + } + renderCaptainExtendedTaskResult(c, result) + return + } + // Parse conversation_ids from query param (comma-separated) convIDsStr := c.Query("conversation_ids") if convIDsStr == "" { @@ -69,6 +85,22 @@ func (h *CaptainTaskExtendedHandler) FollowUp(c *gin.Context) { return } + if c.Request.Method == http.MethodPost { + var req service.ChatwootFollowUpRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body: "+err.Error()) + return + } + result, err := h.svc.FollowUp(c.Request.Context(), uint(accountID), &req) + if err != nil { + applogger.L().Errorf("Follow-up: %v", err) + renderCaptainTaskError(c, err) + return + } + renderCaptainExtendedTaskResult(c, result) + return + } + convIDsStr := c.Query("conversation_ids") if convIDsStr == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "conversation_ids required") @@ -98,6 +130,18 @@ func (h *CaptainTaskExtendedHandler) FollowUp(c *gin.Context) { response.OK(c, result) } +func renderCaptainExtendedTaskResult(c *gin.Context, result *service.ChatwootTaskResult) { + if result == nil || result.Message == nil { + c.JSON(http.StatusOK, gin.H{"message": nil}) + return + } + payload := gin.H{"message": *result.Message} + if result.FollowUpContext != nil { + payload["follow_up_context"] = result.FollowUpContext + } + c.JSON(http.StatusOK, payload) +} + // parseUintSlice parses a comma-separated string of uint values. func parseUintSlice(s string) ([]uint, error) { parts := strings.Split(s, ",") @@ -114,4 +158,4 @@ func parseUintSlice(s string) ([]uint, error) { result = append(result, uint(v)) } return result, nil -} \ No newline at end of file +} diff --git a/internal/handler/api/v1/captain_task_extended_handler_test.go b/internal/handler/api/v1/captain_task_extended_handler_test.go index f3d04a3c..8f15e773 100644 --- a/internal/handler/api/v1/captain_task_extended_handler_test.go +++ b/internal/handler/api/v1/captain_task_extended_handler_test.go @@ -1,6 +1,7 @@ package v1 import ( + "bytes" "context" "encoding/json" "fmt" @@ -47,12 +48,14 @@ func setupTaskExtendedHandlerTest(t *testing.T, mockLLM llm.Provider) (*CaptainT &model.Message{}, &model.Inbox{}, &model.Contact{}, + &model.CopilotSuggestionMessage{}, )) convRepo := repository.NewConversationRepo(db) msgRepo := repository.NewMessageRepo(db) assistantRepo := repository.NewCaptainAssistantRepo(db) prefRepo := repository.NewCaptainPreferenceRepo(db) - svc := service.NewCaptainTaskExtendedService(convRepo, msgRepo, assistantRepo, prefRepo, mockLLM) + suggestionRepo := repository.NewCopilotSuggestionRepo(db) + svc := service.NewCaptainTaskExtendedService(convRepo, msgRepo, assistantRepo, prefRepo, mockLLM, suggestionRepo) handler := NewCaptainTaskExtendedHandler(svc) return handler, db } @@ -103,6 +106,40 @@ func TestCaptainTaskExtendedHandler_LabelSuggestion_MissingConversationIDs(t *te assert.Equal(t, http.StatusBadRequest, w.Code) } +func TestCaptainTaskExtendedHandler_LabelSuggestion_ChatwootPostRawPayload(t *testing.T) { + mockLLM := &mockTaskExtHandlerLLM{ + response: &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "billing, urgent"}}}}, + } + handler, db := setupTaskExtendedHandlerTest(t, mockLLM) + displayID := uint(77) + inbox := &model.Inbox{AccountID: 1, Name: "Inbox", ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox).Error) + conv := &model.Conversation{AccountID: 1, InboxID: inbox.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conv).Error) + msg := &model.Message{ConversationID: conv.ID, AccountID: 1, InboxID: inbox.ID, SenderType: "contact", Content: "billing help", ContentType: "text", MessageType: "incoming"} + require.NoError(t, db.Create(msg).Error) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/label_suggestion", bytes.NewReader([]byte(`{"conversation_display_id":77}`))) + c.Request.Header.Set("Content-Type", "application/json") + + handler.LabelSuggestion(c) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "billing, urgent", resp["message"]) + assert.NotContains(t, resp, "success") + require.Contains(t, resp, "follow_up_context") + + var stored []model.CopilotSuggestionMessage + require.NoError(t, db.Find(&stored).Error) + require.Len(t, stored, 1) + assert.Equal(t, "billing, urgent", stored[0].Content) +} + func TestCaptainTaskExtendedHandler_FollowUp(t *testing.T) { mockLLM := &mockTaskExtHandlerLLM{ response: &llm.ChatResponse{ @@ -143,3 +180,42 @@ func TestCaptainTaskExtendedHandler_FollowUp_MissingConversationIDs(t *testing.T assert.Equal(t, http.StatusBadRequest, w.Code) } + +func TestCaptainTaskExtendedHandler_FollowUp_ChatwootPostUpdatesContext(t *testing.T) { + mockLLM := &mockTaskExtHandlerLLM{ + response: &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "Refined answer"}}}}, + } + handler, db := setupTaskExtendedHandlerTest(t, mockLLM) + displayID := uint(88) + inbox := &model.Inbox{AccountID: 1, Name: "Inbox", ChannelType: "web_widget"} + require.NoError(t, db.Create(inbox).Error) + conv := &model.Conversation{AccountID: 1, InboxID: inbox.ID, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conv).Error) + + body := []byte(`{ + "conversation_display_id": 88, + "message": "Make it warmer", + "follow_up_context": { + "event_name": "professional", + "original_context": "Original draft", + "last_response": "Previous answer", + "conversation_history": [] + } + }`) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/follow_up", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + handler.FollowUp(c) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Refined answer", resp["message"]) + ctx := resp["follow_up_context"].(map[string]interface{}) + assert.Equal(t, "Refined answer", ctx["last_response"]) + history := ctx["conversation_history"].([]interface{}) + require.Len(t, history, 2) +} diff --git a/internal/handler/api/v1/captain_task_handler.go b/internal/handler/api/v1/captain_task_handler.go index 1d9f961c..8db8e65e 100644 --- a/internal/handler/api/v1/captain_task_handler.go +++ b/internal/handler/api/v1/captain_task_handler.go @@ -45,11 +45,11 @@ func (h *CaptainTaskHandler) ReplySuggestion(c *gin.Context) { result, err := h.svc.ReplySuggestion(c.Request.Context(), uint(accountID), &req) if err != nil { applogger.L().Errorf("ReplySuggestion: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to generate reply suggestions") + renderCaptainTaskError(c, err) return } - response.OK(c, result) + renderCaptainTaskPayload(c, result.Message, result.FollowUpContext) } // Summarize generates a concise summary of a conversation. @@ -70,11 +70,11 @@ func (h *CaptainTaskHandler) Summarize(c *gin.Context) { result, err := h.svc.Summarize(c.Request.Context(), uint(accountID), &req) if err != nil { applogger.L().Errorf("Summarize: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to summarize conversation") + renderCaptainTaskError(c, err) return } - response.OK(c, result) + renderCaptainTaskPayload(c, result.Message, result.FollowUpContext) } // Rewrite rewrites a draft message to improve tone, clarity, or language. @@ -95,11 +95,11 @@ func (h *CaptainTaskHandler) Rewrite(c *gin.Context) { result, err := h.svc.Rewrite(c.Request.Context(), uint(accountID), &req) if err != nil { applogger.L().Errorf("Rewrite: %v", err) - response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "failed to rewrite message") + renderCaptainTaskError(c, err) return } - response.OK(c, result) + renderCaptainTaskPayload(c, result.Message, result.FollowUpContext) } // --- SSE Streaming Endpoints (M12) --- @@ -280,3 +280,20 @@ func captainEscapeJSONString(s string) string { } return result.String() } + +func renderCaptainTaskPayload(c *gin.Context, message string, followUpContext map[string]interface{}) { + payload := gin.H{"message": message} + if followUpContext != nil { + payload["follow_up_context"] = followUpContext + } + c.JSON(http.StatusOK, payload) +} + +func renderCaptainTaskError(c *gin.Context, err error) { + status, message, ok := service.CaptainTaskErrorStatus(err) + if !ok { + status = http.StatusUnprocessableEntity + message = err.Error() + } + c.JSON(status, gin.H{"error": message}) +} diff --git a/internal/handler/api/v1/captain_task_handler_test.go b/internal/handler/api/v1/captain_task_handler_test.go new file mode 100644 index 00000000..b6c6a4ca --- /dev/null +++ b/internal/handler/api/v1/captain_task_handler_test.go @@ -0,0 +1,103 @@ +package v1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/llm" + "github.com/gochat/gochat/internal/model" + "github.com/gochat/gochat/internal/repository" + "github.com/gochat/gochat/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type mockCaptainTaskHandlerLLM struct { + response *llm.ChatResponse + err error +} + +func (m *mockCaptainTaskHandlerLLM) ChatCompletion(_ context.Context, _ llm.ChatRequest) (*llm.ChatResponse, error) { + return m.response, m.err +} +func (m *mockCaptainTaskHandlerLLM) CreateEmbedding(_ context.Context, _ llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) { + return nil, nil +} +func (m *mockCaptainTaskHandlerLLM) ChatCompletionStream(_ context.Context, _ llm.ChatRequest, _ func(llm.StreamChunk) error) error { + return nil +} + +func setupCaptainTaskHandlerTest(t *testing.T, provider llm.Provider) (*CaptainTaskHandler, *gorm.DB) { + t.Helper() + gin.SetMode(gin.TestMode) + dbName := fmt.Sprintf("file:%s?mode=memory&cache=private", t.Name()) + db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &model.Account{}, + &model.Conversation{}, + &model.Message{}, + &model.CaptainAssistant{}, + &model.CaptainAssistantResponse{}, + &model.CaptainCustomTool{}, + &model.CaptainDocument{}, + &model.CopilotSuggestionMessage{}, + )) + assistantRepo := repository.NewCaptainAssistantRepo(db) + responseRepo := repository.NewCaptainAssistantResponseRepo(db) + customToolRepo := repository.NewCaptainCustomToolRepo(db) + conversationRepo := repository.NewConversationRepo(db) + messageRepo := repository.NewMessageRepo(db) + suggestionRepo := repository.NewCopilotSuggestionRepo(db) + svc := service.NewCaptainTaskService(assistantRepo, responseRepo, customToolRepo, conversationRepo, messageRepo, provider, nil, suggestionRepo) + return NewCaptainTaskHandler(svc), db +} + +func TestCaptainTaskHandler_Summarize_ChatwootRawPayload(t *testing.T) { + provider := &mockCaptainTaskHandlerLLM{response: &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Role: "assistant", Content: "Short summary"}}}}} + handler, db := setupCaptainTaskHandlerTest(t, provider) + displayID := uint(123) + conv := &model.Conversation{AccountID: 1, DisplayID: &displayID, Status: "open", ChannelType: "web_widget", Channel: "web_widget"} + require.NoError(t, db.Create(conv).Error) + require.NoError(t, db.Create(&model.Message{ConversationID: conv.ID, AccountID: 1, SenderType: "contact", MessageType: "incoming", Content: "Need help"}).Error) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/summarize", bytes.NewReader([]byte(`{"conversation_display_id":123}`))) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Summarize(c) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Short summary", resp["message"]) + assert.NotContains(t, resp, "success") +} + +func TestCaptainTaskHandler_Rewrite_NoProviderRawDisabled(t *testing.T) { + handler, _ := setupCaptainTaskHandlerTest(t, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "id", Value: "1"}} + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/captain/tasks/rewrite", bytes.NewReader([]byte(`{"content":"hello","operation":"professional"}`))) + c.Request.Header.Set("Content-Type", "application/json") + + handler.Rewrite(c) + + require.Equal(t, http.StatusUnprocessableEntity, w.Code) + var resp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + assert.Equal(t, "Captain is disabled", resp["error"]) + assert.NotContains(t, resp, "success") +} diff --git a/internal/model/copilot_models.go b/internal/model/copilot_models.go index 21e32e05..3ea8dfc6 100644 --- a/internal/model/copilot_models.go +++ b/internal/model/copilot_models.go @@ -2,6 +2,9 @@ package model import ( "encoding/json" + "fmt" + + "gorm.io/gorm" ) // --- Copilot Thread Model --- @@ -62,6 +65,30 @@ type CopilotMessage struct { func (CopilotMessage) TableName() string { return "copilot_messages" } +// BeforeSave mirrors Chatwoot's CopilotMessage JSON key validation so +// reloadable tool-call state stays compatible with the reused frontend. +func (m *CopilotMessage) BeforeSave(tx *gorm.DB) error { + if len(m.Message) == 0 { + return nil + } + var msgMap map[string]interface{} + if err := json.Unmarshal(m.Message, &msgMap); err != nil { + return err + } + allowed := map[string]bool{ + "content": true, + "reasoning": true, + "function_name": true, + "reply_suggestion": true, + } + for key := range msgMap { + if !allowed[key] { + return fmt.Errorf("message contains invalid attribute: %s", key) + } + } + return nil +} + // GetMessageContent extracts the "content" field from the JSONB message. func (m *CopilotMessage) GetMessageContent() string { var msgMap map[string]interface{} diff --git a/internal/repository/copilot_message_repo_test.go b/internal/repository/copilot_message_repo_test.go index ed950c55..e425ea0c 100644 --- a/internal/repository/copilot_message_repo_test.go +++ b/internal/repository/copilot_message_repo_test.go @@ -53,6 +53,47 @@ func TestCopilotMessageRepo_Create(t *testing.T) { assert.Equal(t, model.CopilotMessageTypeUser, msg.MessageType) } +func TestCopilotMessageRepo_Create_AllowsChatwootMessageKeys(t *testing.T) { + db := setupTestDB(t, &model.CopilotThread{}, &model.CopilotMessage{}) + repo := NewCopilotMessageRepo(db) + thread := createTestCopilotThread(t, db, 1, 1, "Tool Thread") + message, _ := json.Marshal(map[string]interface{}{ + "content": "Looking up docs", + "reasoning": "Need product data", + "function_name": "search_documentation", + "reply_suggestion": "Try this reply", + }) + + err := repo.Create(context.Background(), &model.CopilotMessage{ + AccountID: 1, + CopilotThreadID: thread.ID, + MessageType: model.CopilotMessageTypeAssistant, + Message: message, + }) + + require.NoError(t, err) +} + +func TestCopilotMessageRepo_Create_RejectsUnknownMessageKeys(t *testing.T) { + db := setupTestDB(t, &model.CopilotThread{}, &model.CopilotMessage{}) + repo := NewCopilotMessageRepo(db) + thread := createTestCopilotThread(t, db, 1, 1, "Bad Tool Thread") + message, _ := json.Marshal(map[string]interface{}{ + "content": "hello", + "unknown": "bad", + }) + + err := repo.Create(context.Background(), &model.CopilotMessage{ + AccountID: 1, + CopilotThreadID: thread.ID, + MessageType: model.CopilotMessageTypeAssistant, + Message: message, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid attribute: unknown") +} + // --- 2. GetByID --- func TestCopilotMessageRepo_GetByID(t *testing.T) { db := setupTestDB(t, &model.CopilotThread{}, &model.CopilotMessage{}) @@ -181,4 +222,4 @@ func TestCopilotMessageRepo_DeleteByThread(t *testing.T) { require.NoError(t, errAfter) assert.Equal(t, int64(0), countAfter) assert.Len(t, msgsAfter, 0) -} \ No newline at end of file +} diff --git a/internal/service/captain_task_extended_service.go b/internal/service/captain_task_extended_service.go index ea4ab23d..0df5a5f2 100644 --- a/internal/service/captain_task_extended_service.go +++ b/internal/service/captain_task_extended_service.go @@ -7,9 +7,10 @@ import ( "strings" "github.com/gochat/gochat/internal/llm" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" - pkgvalidator "github.com/gochat/gochat/pkg/validator" applogger "github.com/gochat/gochat/pkg/logger" + pkgvalidator "github.com/gochat/gochat/pkg/validator" ) // CaptainTaskExtendedService provides label suggestion and follow-up features. @@ -20,6 +21,7 @@ type CaptainTaskExtendedService struct { messageRepo *repository.MessageRepo assistantRepo *repository.CaptainAssistantRepo preferenceRepo *repository.CaptainPreferenceRepo + suggestionRepo *repository.CopilotSuggestionRepo llmProvider llm.Provider } @@ -29,12 +31,18 @@ func NewCaptainTaskExtendedService( assistantRepo *repository.CaptainAssistantRepo, preferenceRepo *repository.CaptainPreferenceRepo, llmProvider llm.Provider, + suggestionRepos ...*repository.CopilotSuggestionRepo, ) *CaptainTaskExtendedService { + var suggestionRepo *repository.CopilotSuggestionRepo + if len(suggestionRepos) > 0 { + suggestionRepo = suggestionRepos[0] + } return &CaptainTaskExtendedService{ conversationRepo: conversationRepo, messageRepo: messageRepo, assistantRepo: assistantRepo, preferenceRepo: preferenceRepo, + suggestionRepo: suggestionRepo, llmProvider: llmProvider, } } @@ -81,6 +89,21 @@ type FollowUpResult struct { FollowUps []FollowUpTask `json:"follow_ups"` } +type ChatwootLabelSuggestionRequest struct { + ConversationDisplayID uint `json:"conversation_display_id"` +} + +type ChatwootFollowUpRequest struct { + FollowUpContext map[string]interface{} `json:"follow_up_context"` + Message string `json:"message"` + ConversationDisplayID uint `json:"conversation_display_id"` +} + +type ChatwootTaskResult struct { + Message *string `json:"message"` + FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` +} + // --- Helper: fetch conversation context for LLM --- func (s *CaptainTaskExtendedService) fetchConversationMessages(ctx context.Context, conversationID uint) (string, error) { @@ -126,6 +149,85 @@ func (s *CaptainTaskExtendedService) buildAssistantContext(ctx context.Context, return "" } +func (s *CaptainTaskExtendedService) LabelSuggestion(ctx context.Context, accountID uint, req *ChatwootLabelSuggestionRequest) (*ChatwootTaskResult, error) { + if s.llmProvider == nil { + return nil, taskError(422, "Captain is disabled") + } + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, req.ConversationDisplayID) + if err != nil { + return nil, taskError(422, "Conversation not found") + } + msgs, err := s.fetchConversationMessages(ctx, conversation.ID) + if err != nil { + return nil, err + } + if strings.TrimSpace(msgs) == "" { + return &ChatwootTaskResult{Message: nil}, nil + } + + llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ + Messages: []llm.ChatMessage{ + {Role: "system", Content: `Suggest concise labels for this customer support conversation. Return only a comma-separated label list.`}, + {Role: "user", Content: msgs}, + }, + Temperature: 0.2, + MaxTokens: 256, + }) + if err != nil { + return nil, err + } + if llmResp == nil || len(llmResp.Choices) == 0 { + return &ChatwootTaskResult{Message: nil}, nil + } + message := strings.TrimSpace(llmResp.Choices[0].Message.Content) + if message == "" { + return &ChatwootTaskResult{Message: nil}, nil + } + s.persistChatwootTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSuggestion) + return &ChatwootTaskResult{ + Message: &message, + FollowUpContext: buildTaskFollowUpContext("label_suggestion", msgs, message, conversation.ChannelType), + }, nil +} + +func (s *CaptainTaskExtendedService) FollowUp(ctx context.Context, accountID uint, req *ChatwootFollowUpRequest) (*ChatwootTaskResult, error) { + if s.llmProvider == nil { + return nil, taskError(422, "Captain is disabled") + } + if !validTaskFollowUpContext(req.FollowUpContext) { + return nil, taskError(422, "Follow-up context missing") + } + conversation, err := s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, req.ConversationDisplayID) + if err != nil { + return nil, taskError(422, "Conversation not found") + } + messages := []llm.ChatMessage{ + {Role: "system", Content: "You just performed a customer support assistant task. Refine the previous result based on the agent's feedback. Output only the refined reply."}, + {Role: "user", Content: fmt.Sprint(req.FollowUpContext["original_context"])}, + {Role: "assistant", Content: fmt.Sprint(req.FollowUpContext["last_response"])}, + } + for _, historyMessage := range followUpHistory(req.FollowUpContext) { + messages = append(messages, historyMessage) + } + messages = append(messages, llm.ChatMessage{Role: "user", Content: req.Message}) + + llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ + Messages: messages, + Temperature: 0.3, + MaxTokens: 512, + }) + if err != nil { + return nil, err + } + if llmResp == nil || len(llmResp.Choices) == 0 { + return nil, taskError(422, "No response from LLM") + } + message := llmResp.Choices[0].Message.Content + updatedContext := updateTaskFollowUpContext(req.FollowUpContext, req.Message, message, conversation.ChannelType) + s.persistChatwootTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSuggestion) + return &ChatwootTaskResult{Message: &message, FollowUpContext: updatedContext}, nil +} + // --- Label Suggestion (GET) --- // Reference: Chatwoot Captain::ConversationInsightController#suggest_labels @@ -174,7 +276,7 @@ Respond in JSON format: }` + assistantCtx + promptSuffix llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ - Model: "", + Model: "", Messages: []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msgs}, @@ -278,7 +380,7 @@ Respond in JSON format: }` + statusHint + assistantCtx + promptSuffix llmResp, err := s.llmProvider.ChatCompletion(ctx, llm.ChatRequest{ - Model: "", + Model: "", Messages: []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: msgs}, @@ -321,3 +423,102 @@ Respond in JSON format: return &FollowUpResult{FollowUps: followUps}, nil } + +func buildTaskFollowUpContext(eventName, originalContext, lastResponse, channelType string) map[string]interface{} { + ctx := map[string]interface{}{ + "event_name": eventName, + "original_context": originalContext, + "last_response": lastResponse, + "conversation_history": []map[string]string{}, + } + if channelType != "" { + ctx["channel_type"] = channelType + } + return ctx +} + +func validTaskFollowUpContext(ctx map[string]interface{}) bool { + if ctx == nil { + return false + } + eventName, _ := ctx["event_name"].(string) + if !allowedTaskFollowUpEvent(eventName) { + return false + } + return strings.TrimSpace(fmt.Sprint(ctx["original_context"])) != "" && strings.TrimSpace(fmt.Sprint(ctx["last_response"])) != "" +} + +func allowedTaskFollowUpEvent(eventName string) bool { + switch eventName { + case "professional", "casual", "friendly", "confident", "straightforward", "fix_spelling_grammar", "improve", "summarize", "reply_suggestion", "label_suggestion": + return true + default: + return false + } +} + +func followUpHistory(ctx map[string]interface{}) []llm.ChatMessage { + raw, ok := ctx["conversation_history"].([]interface{}) + if !ok { + return nil + } + messages := make([]llm.ChatMessage, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + role := fmt.Sprint(m["role"]) + content := fmt.Sprint(m["content"]) + if role == "" || content == "" { + continue + } + messages = append(messages, llm.ChatMessage{Role: role, Content: content}) + } + return messages +} + +func updateTaskFollowUpContext(ctx map[string]interface{}, userMessage, assistantMessage, channelType string) map[string]interface{} { + history := make([]map[string]string, 0) + if raw, ok := ctx["conversation_history"].([]interface{}); ok { + for _, item := range raw { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + history = append(history, map[string]string{"role": fmt.Sprint(m["role"]), "content": fmt.Sprint(m["content"])}) + } + } + history = append(history, + map[string]string{"role": "user", "content": userMessage}, + map[string]string{"role": "assistant", "content": assistantMessage}, + ) + updated := map[string]interface{}{ + "event_name": ctx["event_name"], + "original_context": ctx["original_context"], + "last_response": assistantMessage, + "conversation_history": history, + } + if channelType != "" { + updated["channel_type"] = channelType + } else if value, ok := ctx["channel_type"]; ok { + updated["channel_type"] = value + } + return updated +} + +func (s *CaptainTaskExtendedService) persistChatwootTaskSuggestion(ctx context.Context, accountID, conversationID uint, content string, suggestionType model.CopilotSuggestionType) { + if s.suggestionRepo == nil || strings.TrimSpace(content) == "" { + return + } + msg := &model.CopilotSuggestionMessage{ + AccountID: accountID, + ConversationID: conversationID, + Content: content, + SuggestionType: suggestionType, + Status: model.CopilotSuggestionStatusPending, + } + if err := s.suggestionRepo.Create(ctx, msg); err != nil { + applogger.L().Warnf("Captain extended task suggestion persistence failed: %v", err) + } +} diff --git a/internal/service/captain_task_service.go b/internal/service/captain_task_service.go index ad58af40..bdc404bd 100644 --- a/internal/service/captain_task_service.go +++ b/internal/service/captain_task_service.go @@ -8,6 +8,7 @@ import ( "github.com/pgvector/pgvector-go" "github.com/gochat/gochat/internal/llm" + "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" ) @@ -17,14 +18,15 @@ import ( // These tasks (reply_suggestion, summarize, rewrite) are independent of // CopilotThread flow — they provide one-shot AI suggestions for agents. type CaptainTaskService struct { - assistantRepo *repository.CaptainAssistantRepo - responseRepo *repository.CaptainAssistantResponseRepo - customToolRepo *repository.CaptainCustomToolRepo - conversationRepo *repository.ConversationRepo - messageRepo *repository.MessageRepo - llmProvider llm.Provider - promptBuilder *SystemPromptBuilder - copilotContextSvc *CopilotContextService // M12: context enrichment for richer prompts + assistantRepo *repository.CaptainAssistantRepo + responseRepo *repository.CaptainAssistantResponseRepo + customToolRepo *repository.CaptainCustomToolRepo + conversationRepo *repository.ConversationRepo + messageRepo *repository.MessageRepo + suggestionRepo *repository.CopilotSuggestionRepo + llmProvider llm.Provider + promptBuilder *SystemPromptBuilder + copilotContextSvc *CopilotContextService // M12: context enrichment for richer prompts } // NewCaptainTaskService creates a new CaptainTaskService. @@ -36,16 +38,22 @@ func NewCaptainTaskService( messageRepo *repository.MessageRepo, llmProvider llm.Provider, copilotContextSvc *CopilotContextService, + suggestionRepos ...*repository.CopilotSuggestionRepo, ) *CaptainTaskService { + var suggestionRepo *repository.CopilotSuggestionRepo + if len(suggestionRepos) > 0 { + suggestionRepo = suggestionRepos[0] + } return &CaptainTaskService{ - assistantRepo: assistantRepo, - responseRepo: responseRepo, - customToolRepo: customToolRepo, - conversationRepo: conversationRepo, - messageRepo: messageRepo, - llmProvider: llmProvider, - promptBuilder: NewSystemPromptBuilder(), - copilotContextSvc: copilotContextSvc, + assistantRepo: assistantRepo, + responseRepo: responseRepo, + customToolRepo: customToolRepo, + conversationRepo: conversationRepo, + messageRepo: messageRepo, + suggestionRepo: suggestionRepo, + llmProvider: llmProvider, + promptBuilder: NewSystemPromptBuilder(), + copilotContextSvc: copilotContextSvc, } } @@ -53,40 +61,71 @@ func NewCaptainTaskService( // TaskReplySuggestionRequest is the DTO for reply_suggestion task. type TaskReplySuggestionRequest struct { - AssistantID uint `json:"assistant_id" validate:"required"` - ConversationID uint `json:"conversation_id" validate:"required"` - Tone string `json:"tone"` // e.g. "friendly", "formal", "professional" + AssistantID uint `json:"assistant_id"` + ConversationID uint `json:"conversation_id"` + ConversationDisplayID uint `json:"conversation_display_id"` + Tone string `json:"tone"` // legacy local compatibility } // TaskSummarizeRequest is the DTO for summarize task. type TaskSummarizeRequest struct { - ConversationID uint `json:"conversation_id" validate:"required"` - Language string `json:"language"` // summary output language, default "zh-CN" + ConversationID uint `json:"conversation_id"` + ConversationDisplayID uint `json:"conversation_display_id"` + Language string `json:"language"` // legacy local compatibility } // TaskRewriteRequest is the DTO for rewrite task. type TaskRewriteRequest struct { - Content string `json:"content" validate:"required"` - Tone string `json:"tone"` // desired tone, e.g. "friendly", "formal" - Language string `json:"language"` // desired output language - AssistantID uint `json:"assistant_id"` // optional: use assistant style guidelines + Content string `json:"content" validate:"required"` + Operation string `json:"operation"` + ConversationDisplayID uint `json:"conversation_display_id"` + Tone string `json:"tone"` // legacy local compatibility + Language string `json:"language"` // legacy local compatibility + AssistantID uint `json:"assistant_id"` // legacy local compatibility } // TaskReplySuggestionResult holds the reply suggestion result. type TaskReplySuggestionResult struct { - Suggestions []string `json:"suggestions"` - Source string `json:"source"` // "llm", "rag+llm" + Message string `json:"message"` + FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` + Suggestions []string `json:"suggestions"` + Source string `json:"source"` // "llm", "rag+llm" } // TaskSummarizeResult holds the conversation summary result. type TaskSummarizeResult struct { - Summary string `json:"summary"` + Message string `json:"message"` + FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` + Summary string `json:"summary"` } // TaskRewriteResult holds the rewritten content result. type TaskRewriteResult struct { - RewrittenContent string `json:"rewritten_content"` - Tone string `json:"tone"` + Message string `json:"message"` + FollowUpContext map[string]interface{} `json:"follow_up_context,omitempty"` + RewrittenContent string `json:"rewritten_content"` + Tone string `json:"tone"` +} + +type CaptainTaskError struct { + Status int + Message string +} + +func (e *CaptainTaskError) Error() string { return e.Message } + +func taskError(status int, message string) error { + return &CaptainTaskError{Status: status, Message: message} +} + +func CaptainTaskErrorStatus(err error) (int, string, bool) { + if err == nil { + return 0, "", false + } + if taskErr, ok := err.(*CaptainTaskError); ok { + return taskErr.Status, taskErr.Message, true + } + return 0, "", false } // --- Reply Suggestion Task --- @@ -100,14 +139,28 @@ type TaskRewriteResult struct { // 4. Build prompt with conversation context + relevant FAQ answers // 5. Call LLM to generate reply suggestions func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint, req *TaskReplySuggestionRequest) (*TaskReplySuggestionResult, error) { - // Fetch assistant config for prompt building - assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) - if err != nil { - return nil, fmt.Errorf("assistant not found: %w", err) + if s.llmProvider == nil { + return nil, taskError(422, "Captain is disabled") } - // Fetch conversation messages as context - messages, err := s.fetchConversationContext(ctx, req.ConversationID) + var assistantCfg *model.CaptainAssistant + var err error + if req.AssistantID > 0 { + assistantCfg, err = s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) + if err != nil { + return nil, taskError(422, "assistant not found") + } + } else { + assistantCfg = &model.CaptainAssistant{AccountID: accountID, Name: "Captain"} + assistantCfg.Config = []byte(`{}`) + } + + conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) + if err != nil { + return nil, taskError(422, "Conversation not found") + } + + messages, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { applogger.L().Warnf("ReplySuggestion: conversation context fetch failed: %v, proceeding without context", err) messages = "" @@ -115,17 +168,19 @@ func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint // Search relevant documents via embedding similarity (RAG) ragContext, source := "", "llm" - docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages) - if err != nil { - applogger.L().Warnf("ReplySuggestion: document search failed: %v, proceeding without RAG", err) - } else if docContext != "" { - ragContext = docContext - source = "rag+llm" + if req.AssistantID > 0 { + docContext, err := s.searchDocumentation(ctx, req.AssistantID, messages) + if err != nil { + applogger.L().Warnf("ReplySuggestion: document search failed: %v, proceeding without RAG", err) + } else if docContext != "" { + ragContext = docContext + source = "rag+llm" + } } // Build system prompt using prompt builder - cfg, _ := assistant.GetConfig() - systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistant, cfg, req.Tone, ragContext) + cfg, _ := assistantCfg.GetConfig() + systemPrompt := s.promptBuilder.BuildReplySuggestionPrompt(assistantCfg, cfg, req.Tone, ragContext) // Build messages for LLM chatMessages := []llm.ChatMessage{ @@ -152,10 +207,17 @@ func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint // Parse suggestions from LLM response suggestions := parseSuggestions(resp.Choices[0].Message.Content) + message := strings.Join(suggestions, "\n") + if message == "" { + message = resp.Choices[0].Message.Content + } + s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeReply) return &TaskReplySuggestionResult{ - Suggestions: suggestions, - Source: source, + Message: message, + FollowUpContext: s.buildFollowUpContext("reply_suggestion", messages, message, conversation), + Suggestions: suggestions, + Source: source, }, nil } @@ -164,14 +226,21 @@ func (s *CaptainTaskService) ReplySuggestion(ctx context.Context, accountID uint // Summarize generates a concise summary of a conversation. func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req *TaskSummarizeRequest) (*TaskSummarizeResult, error) { + if s.llmProvider == nil { + return nil, taskError(422, "Captain is disabled") + } + conversation, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, req.ConversationID) + if err != nil { + return nil, taskError(422, "Conversation not found") + } // Fetch conversation messages - messages, err := s.fetchConversationContext(ctx, req.ConversationID) + messages, err := s.fetchConversationContext(ctx, conversation.ID) if err != nil { return nil, fmt.Errorf("fetch conversation context: %w", err) } if messages == "" { - return nil, fmt.Errorf("no messages found for conversation %d", req.ConversationID) + return nil, taskError(422, fmt.Sprintf("no messages found for conversation %d", conversation.ID)) } // Determine output language @@ -204,8 +273,13 @@ func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req return nil, fmt.Errorf("no response from LLM") } + message := resp.Choices[0].Message.Content + s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSummary) + return &TaskSummarizeResult{ - Summary: resp.Choices[0].Message.Content, + Message: message, + FollowUpContext: s.buildFollowUpContext("summarize", messages, message, conversation), + Summary: message, }, nil } @@ -214,9 +288,18 @@ func (s *CaptainTaskService) Summarize(ctx context.Context, accountID uint, req // Rewrite rewrites a draft message to improve tone, clarity, or language. func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *TaskRewriteRequest) (*TaskRewriteResult, error) { - tone := req.Tone - if tone == "" { - tone = "professional" + if s.llmProvider == nil { + return nil, taskError(422, "Captain is disabled") + } + operation := req.Operation + if operation == "" { + operation = req.Tone + } + if operation == "" { + operation = "professional" + } + if !isAllowedRewriteOperation(operation) { + return nil, taskError(422, "Invalid operation: "+operation) } language := req.Language @@ -224,10 +307,22 @@ func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *T language = "zh-CN" } - // Optionally use assistant guidelines for style styleGuidelines := "" + var conversation *model.Conversation + conversationContext := "" + if req.ConversationDisplayID > 0 { + resolved, err := s.resolveTaskConversation(ctx, accountID, req.ConversationDisplayID, 0) + if err != nil { + return nil, taskError(422, "Conversation not found") + } + conversation = resolved + if operation == "improve" { + conversationContext, _ = s.fetchConversationContext(ctx, conversation.ID) + } + } + if req.AssistantID > 0 { - assistant, err := s.assistantRepo.GetByID(ctx, req.AssistantID) + assistant, err := s.assistantRepo.GetByAccountAndID(ctx, accountID, req.AssistantID) if err == nil { cfg, _ := assistant.GetConfig() if cfg.Instructions != "" { @@ -238,9 +333,12 @@ func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *T } } } + if conversationContext != "" { + styleGuidelines += "\nConversation context: " + conversationContext + } // Build rewrite prompt using prompt builder - systemPrompt := s.promptBuilder.BuildRewritePrompt(tone, language, styleGuidelines) + systemPrompt := s.promptBuilder.BuildRewritePrompt(operation, language, styleGuidelines) chatMessages := []llm.ChatMessage{ {Role: "system", Content: systemPrompt}, @@ -263,9 +361,16 @@ func (s *CaptainTaskService) Rewrite(ctx context.Context, accountID uint, req *T return nil, fmt.Errorf("no response from LLM") } + message := resp.Choices[0].Message.Content + if conversation != nil { + s.persistTaskSuggestion(ctx, accountID, conversation.ID, message, model.CopilotSuggestionTypeSuggestion) + } + return &TaskRewriteResult{ - RewrittenContent: resp.Choices[0].Message.Content, - Tone: tone, + Message: message, + FollowUpContext: s.buildFollowUpContext(operation, req.Content, message, conversation), + RewrittenContent: message, + Tone: operation, }, nil } @@ -294,6 +399,54 @@ func (s *CaptainTaskService) fetchConversationContext(ctx context.Context, conve return builder.String(), nil } +func (s *CaptainTaskService) resolveTaskConversation(ctx context.Context, accountID, displayID, legacyID uint) (*model.Conversation, error) { + if displayID > 0 { + return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, displayID) + } + if legacyID > 0 { + return s.conversationRepo.FindByAccountAndDisplayIDOrID(ctx, accountID, legacyID) + } + return nil, fmt.Errorf("conversation_display_id required") +} + +func isAllowedRewriteOperation(operation string) bool { + switch operation { + case "fix_spelling_grammar", "improve", "casual", "professional", "friendly", "confident", "straightforward": + return true + default: + return false + } +} + +func (s *CaptainTaskService) buildFollowUpContext(eventName, originalContext, lastResponse string, conversation *model.Conversation) map[string]interface{} { + ctx := map[string]interface{}{ + "event_name": eventName, + "original_context": originalContext, + "last_response": lastResponse, + "conversation_history": []map[string]string{}, + } + if conversation != nil { + ctx["channel_type"] = conversation.ChannelType + } + return ctx +} + +func (s *CaptainTaskService) persistTaskSuggestion(ctx context.Context, accountID, conversationID uint, content string, suggestionType model.CopilotSuggestionType) { + if s.suggestionRepo == nil || strings.TrimSpace(content) == "" || conversationID == 0 { + return + } + msg := &model.CopilotSuggestionMessage{ + AccountID: accountID, + ConversationID: conversationID, + Content: content, + SuggestionType: suggestionType, + Status: model.CopilotSuggestionStatusPending, + } + if err := s.suggestionRepo.Create(ctx, msg); err != nil { + applogger.L().Warnf("Captain task suggestion persistence failed: %v", err) + } +} + // searchDocumentation performs embedding-based similarity search on assistant responses. // Reference: Chatwoot Captain::Llm::SearchReplyDocumentationService func (s *CaptainTaskService) searchDocumentation(ctx context.Context, assistantID uint, query string) (string, error) { diff --git a/internal/service/captain_task_service_test.go b/internal/service/captain_task_service_test.go index 749959d0..cc60bec7 100644 --- a/internal/service/captain_task_service_test.go +++ b/internal/service/captain_task_service_test.go @@ -77,6 +77,7 @@ func setupCaptainTaskService(t *testing.T) (*gorm.DB, *mockLLMProvider, *Captain &model.CaptainAssistantResponse{}, &model.CaptainCustomTool{}, &model.CaptainDocument{}, + &model.CopilotSuggestionMessage{}, ); err != nil { t.Fatalf("无法自动迁移模型: %v", err) } @@ -92,12 +93,13 @@ func setupCaptainTaskService(t *testing.T) (*gorm.DB, *mockLLMProvider, *Captain customToolRepo := repository.NewCaptainCustomToolRepo(db) conversationRepo := repository.NewConversationRepo(db) messageRepo := repository.NewMessageRepo(db) + suggestionRepo := repository.NewCopilotSuggestionRepo(db) contactRepo := repository.NewContactRepo(db) // M12: CopilotContextService for context enrichment in streaming copilotContextSvc := NewCopilotContextService(messageRepo, conversationRepo, contactRepo, mockProvider) - svc := NewCaptainTaskService(assistantRepo, responseRepo, customToolRepo, conversationRepo, messageRepo, mockProvider, copilotContextSvc) + svc := NewCaptainTaskService(assistantRepo, responseRepo, customToolRepo, conversationRepo, messageRepo, mockProvider, copilotContextSvc, suggestionRepo) return db, mockProvider, svc } @@ -121,8 +123,10 @@ func createTestAssistant(t *testing.T, db *gorm.DB, accountID uint, name string) func createTestConversationWithMessages(t *testing.T, db *gorm.DB, accountID uint, msgs []struct{ sender, content string }) *model.Conversation { t.Helper() conv := &model.Conversation{ - AccountID: accountID, - Status: "open", + AccountID: accountID, + Status: "open", + ChannelType: "web_widget", + Channel: "web_widget", } if err := db.Create(conv).Error; err != nil { t.Fatalf("无法创建测试会话: %v", err) @@ -199,6 +203,60 @@ func TestCaptainTaskService_Summarize_成功(t *testing.T) { assert.NotEmpty(t, result.Summary) } +func TestCaptainTaskService_Summarize_ChatwootDisplayIDPersistsSuggestion(t *testing.T) { + db, mockProvider, svc := setupCaptainTaskService(t) + account := createTestAccount(t, db) + displayID := uint(42) + conv := createTestConversationWithMessages(t, db, account.ID, []struct{ sender, content string }{ + {"contact", "I need help with my invoice"}, + {"agent", "I can help with billing"}, + }) + require.NoError(t, db.Model(conv).Update("display_id", displayID).Error) + + mockProvider.chatResponse = makeChatResponse("Customer needs billing help") + + result, err := svc.Summarize(context.Background(), account.ID, &TaskSummarizeRequest{ + ConversationDisplayID: displayID, + }) + + require.NoError(t, err) + assert.Equal(t, "Customer needs billing help", result.Message) + require.NotNil(t, result.FollowUpContext) + assert.Equal(t, "summarize", result.FollowUpContext["event_name"]) + + var stored []model.CopilotSuggestionMessage + require.NoError(t, db.Where("account_id = ? AND conversation_id = ?", account.ID, conv.ID).Find(&stored).Error) + require.Len(t, stored, 1) + assert.Equal(t, "Customer needs billing help", stored[0].Content) + assert.Equal(t, model.CopilotSuggestionTypeSummary, stored[0].SuggestionType) +} + +func TestCaptainTaskService_Rewrite_InvalidOperation(t *testing.T) { + _, _, svc := setupCaptainTaskService(t) + + result, err := svc.Rewrite(context.Background(), 1, &TaskRewriteRequest{Content: "hello", Operation: "pirate"}) + + assert.Nil(t, result) + status, message, ok := CaptainTaskErrorStatus(err) + require.True(t, ok) + assert.Equal(t, 422, status) + assert.Equal(t, "Invalid operation: pirate", message) +} + +func TestCaptainTaskService_Rewrite_NoProviderDisabled(t *testing.T) { + db, _, svc := setupCaptainTaskService(t) + svc.llmProvider = nil + account := createTestAccount(t, db) + + result, err := svc.Rewrite(context.Background(), account.ID, &TaskRewriteRequest{Content: "hello", Operation: "professional"}) + + assert.Nil(t, result) + status, message, ok := CaptainTaskErrorStatus(err) + require.True(t, ok) + assert.Equal(t, 422, status) + assert.Equal(t, "Captain is disabled", message) +} + func TestCaptainTaskService_Summarize_无消息(t *testing.T) { db, _, svc := setupCaptainTaskService(t) account := createTestAccount(t, db) @@ -416,4 +474,4 @@ func TestParseSuggestions_空内容(t *testing.T) { content := "" result := parseSuggestions(content) assert.Len(t, result, 1) -} \ No newline at end of file +}