feat(help-center): queue article translations
This commit is contained in:
@@ -37,10 +37,10 @@ Hermes plan landing map:
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(inboxes): align agent bot member actions`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(inboxes): align agent bot member actions`.
|
||||
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(help-center): queue article translations`.
|
||||
- Latest implementation checkpoint: this checkpoint, prepared as `feat(help-center): queue article translations`.
|
||||
- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: expand help center route parity tracking`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`.
|
||||
- Worktree status at this implementation checkpoint: P6.5 inbox agent-bot member actions now match Chatwoot `InboxesController#agent_bot`/`#set_agent_bot` and the reused dashboard `agentBots` store path: `set_agent_bot` accepts the frontend `{ agent_bot: id|null }` body, returns empty `200 OK`, disconnect destroys the `agent_bot_inboxes` row, reassign updates the inbox's single binding, and `agent_bot` returns the Chatwoot `{ agent_bot: ... }` partial shape with `{}` when disconnected. P6 message retry parity already matches Chatwoot `MessagesController#retry`: retry sets the message status back to `sent`, clears provider failure data from `content_attributes`, and queues the existing durable `SendReplyJob` path when a worker pool is configured. P6 conversation custom-attribute response parity already matches Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard conversation store action. Route parity tracking includes the Chatwoot help-center portal/category/article route family from `reference/chatwoot/config/routes.rb:385-404`, so the audited critical set covers portal CRUD/member actions, nested categories, nested articles, and article bulk actions. P3.7c help center article parity matches the reused dashboard `helpCenter/articles.js` client and Chatwoot `ArticlesController`/`Articles::BulkActionsController`/Jbuilder surface. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
|
||||
- Worktree status at this implementation checkpoint: P3.7d enterprise help-center article translation now matches the reused dashboard `bulkTranslate` route and Chatwoot enterprise `Articles::BulkActionsController#translate`/`Captain::Articles::TranslateJob` boundary: `POST /articles/bulk_actions/translate` validates `captain_tasks`, allowed portal locale, optional locale-scoped category, and article IDs; returns `409 { duplicate_articles }` unless `force` is true; enqueues durable low-priority `captain:article_translate` jobs; and replays translation through an LLM backend using Chatwoot's title/content prompts before creating or updating draft translated articles. P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help center portal/category/article payload parity remain in Review. Campaign create/update already mirrors Chatwoot `Campaign#ensure_correct_campaign_attributes`; account campaign CRUD already matches the reused dashboard `campaigns` store and Chatwoot Jbuilder contract. Agent/settings/profile/notification/contact/custom-field/report enterprise drifts listed below remain at their prior checkpoint states. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
|
||||
- `go test ./...` passes.
|
||||
- Route dump succeeds with `TOTAL: 860` after adding no-trailing-slash article index/create aliases, frontend-used article PATCH update route, and frontend article bulk-action routes.
|
||||
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
|
||||
@@ -86,7 +86,7 @@ Open work after the current checkpoint:
|
||||
| --- | --- | --- | --- |
|
||||
| Phase 5 jobs | Keep B9.3 in Review and open only evidence-backed drift slices for new async gaps. | `Phase 5: Background Jobs And Integrations` | Automation tests prove remaining action side effects and reference inspection proves no delayed action params remain. |
|
||||
| B12 smoke | Run optional live API/browser/enterprise smoke in a full PostgreSQL/Redis/Meilisearch/Vite environment and convert failures into named slices. | `B12 reused frontend verification breakdown` | `docs/parity/frontend_smoke_report.md` records checked pass/fail results and maps failures to slices. |
|
||||
| P3.7 help center | Portal, category, and article route/payload parity are in Review for the inspected dashboard client paths. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs without adapters. |
|
||||
| P3.7 help center | Portal, category, article route/payload parity, and enterprise article bulk translation are in Review for the inspected dashboard client paths. | `Open Checkpoint Contracts`, `Phase 3` | Reused Chatwoot help-center dashboard clients can call portal/category/article APIs and bulk article translation without adapters. |
|
||||
| Phase 2/3 drift | Help-center portal/category/article routes from `routes.rb:385-404` are now in the tracked critical set; expand the next route/serializer fixtures only from B12 failures or fresh reference/frontend inspection. | `Phase 2`, `Phase 3`, `docs/parity/` | Route parity remains 0 missing for tracked frontend routes; serializers have reference fixtures. |
|
||||
| Phase 6 placeholders | Re-run placeholder audit and burn down any frontend-reachable stub. | `Phase 6: Core Product Placeholder Burn-down` | Stub list has no reused-frontend critical path without a named owner. |
|
||||
|
||||
@@ -130,7 +130,8 @@ These rows are the executable development plan from this point forward. A checkp
|
||||
| P3.6 custom filters/custom attribute definitions payload parity | `internal/handler/api/v1/custom_filter_handler.go`, `internal/service/custom_filter_service.go`, `internal/repository/custom_filter_repo.go`, `internal/handler/api/v1/custom_attribute_definition_handler.go`, `internal/service/custom_attribute_definition_service.go`, `internal/service/custom_attribute_value_service.go`, handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/custom_filters_controller.rb`, `app/views/api/v1/accounts/custom_filters/*.json.jbuilder`, `app/views/api/v1/models/_custom_filter.json.jbuilder`, dashboard `api/customViews.js`, `store/modules/customViews.js`, `custom_attribute_definitions_controller.rb`, `_custom_attribute_definition.json.jbuilder`, dashboard `api/attributes.js`, `store/modules/attributes.js`, settings attributes screens/constants | `/custom_filters` and `/custom_attribute_definitions` now return raw Chatwoot arrays/objects instead of local envelopes, accept raw reused-frontend bodies plus Rails-style wrappers, use `200 OK` mutation responses, preserve `204` deletes, scope saved views by current user and default `filter_type=conversation`, normalize custom attribute enum integers to Chatwoot string values, and keep custom attribute value validation compatible with normalized `*_attribute` definitions plus legacy rows. | Review by `feat(custom-fields): align chatwoot saved view payloads`; focused handler/service/repository tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P3.7a help center portal route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/portal_handler.go`, `internal/service/portal_service.go`, `internal/repository/portal_repo.go`, portal handler/service/repository tests | `reference/chatwoot/app/controllers/api/v1/accounts/portals_controller.rb`, `app/views/api/v1/accounts/portals/*.json.jbuilder`, dashboard `api/helpCenter/portals.js`, help-center portal store/actions | Frontend no-trailing-slash `GET/POST /portals`, `PATCH /portals/:portal_id`, and PATCH archive routes are registered; `:portal_id` resolves as Chatwoot portal slug with legacy numeric fallback; raw portal bodies plus `{ portal: ... }` wrappers are accepted; portal index/show/create/update return raw Jbuilder-style payloads; delete/logo/archive return empty `200 OK`; `send_instructions` returns `{ message }`; and `ssl_status` returns enterprise `{ status, verification_errors }`/`422` semantics. | Review by `feat(help-center): align portal payloads`; focused portal handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7b help center category route/payload parity | `internal/router/router.go`, `internal/handler/api/v1/category_handler.go`, `internal/service/category_service.go`, `internal/repository/category_repo.go`, category tests | `reference/chatwoot/app/controllers/api/v1/accounts/categories_controller.rb`, `app/views/api/v1/accounts/categories/*.json.jbuilder`, dashboard `api/helpCenter/categories.js` | Category APIs now resolve portal slugs account-scoped for `/portals/:portal_id/categories`, accept raw category bodies plus wrappers, expose frontend no-trailing-slash and `PATCH` routes, return Chatwoot `{ payload, meta }` and `{ payload: category }` serializers with locale filtering and article counts, make delete/reorder empty `200 OK`, accept Chatwoot `positions_hash`, and keep category mutations scoped to the resolved portal. | Review by `feat(help-center): align category payloads`; focused category handler/service/repository tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes plus `translate` `501`. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7c help center article and bulk-action parity | `internal/router/router.go`, `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, article tests | `reference/chatwoot/app/controllers/api/v1/accounts/articles_controller.rb`, `app/controllers/api/v1/accounts/articles/bulk_actions_controller.rb`, `app/views/api/v1/accounts/articles/*.json.jbuilder`, dashboard `api/helpCenter/articles.js` | Article APIs now resolve portal slugs account-scoped with numeric fallback, accept raw article bodies plus wrappers, expose no-trailing-slash index/create plus frontend `PATCH`, return Chatwoot `{ payload, meta }` list/search payloads and `{ payload: article }` show/create/update/edit payloads, serialize category/author/views/Unix updated-at/meta/associated articles, make delete/reorder/bulk actions empty `200 OK`, accept Chatwoot `positions_hash`, scope mutations to the resolved portal, and implement frontend bulk update-status/update-category/delete routes. | Review by `feat(help-center): align article payloads`; focused article handler tests, service/repository article tests, router test, route dump/parity regeneration, escalated full `go test ./...`, and `git diff --check` passed. |
|
||||
| P3.7d enterprise article bulk translation | `internal/handler/api/v1/article_handler.go`, `internal/service/article_service.go`, `internal/repository/article_repo.go`, `internal/app/bootstrap.go`, article handler/service tests | `reference/chatwoot/enterprise/app/controllers/enterprise/api/v1/accounts/articles/bulk_actions_controller.rb`, `enterprise/app/jobs/captain/articles/translate_job.rb`, `enterprise/app/services/captain/llm/article_translation_service.rb`, dashboard `api/helpCenter/articles.js`, `BulkTranslateDialog.vue` | Article bulk translate now validates `captain_tasks`, portal allowed locales, optional target-locale category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; maps locale codes to English language names; and creates or updates draft translated articles through the wired LLM translation backend using Chatwoot's title/content prompts. | Review by `feat(help-center): queue article translations`; focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed. No route changes. |
|
||||
| P6 conversation transcript response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, dashboard `api/inbox/conversation.js` | Account conversation transcript now follows Chatwoot's controller contract: missing email returns `422 { error: "email param missing" }`, nonblank email schedules through the existing service boundary and returns empty `200 OK`, and invalid-looking but nonblank email values are not rejected by local email format validation. | Review by `feat(conversations): align transcript responses`; focused transcript handler/service tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
| P6 conversation custom attributes response parity | `internal/handler/api/v1/conversation_handler.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb`, `app/views/api/v1/accounts/conversations/custom_attributes.json.jbuilder`, dashboard `api/inbox/conversation.js`, conversation store action | Account conversation custom attribute updates now return Chatwoot `{ custom_attributes: ... }` only, matching the store action that reads `response.data.custom_attributes`, instead of returning the full conversation serializer with unrelated fields. Empty/null JSON serializes as `{}`. | Review by `feat(conversations): align custom attribute response`; focused handler tests, combined handler/service/router tests, full `go test ./...`, and `git diff --check` passed. |
|
||||
| P6 message retry status parity | `internal/service/message_service.go`, `internal/handler/api/v1/message_handler.go`, message service/handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations/messages_controller.rb`, `app/views/api/v1/accounts/conversations/messages/retry.json.jbuilder`, dashboard `api/inbox/message.js` | Account message retry now follows Chatwoot: status resets to `sent`, `content_attributes` is cleared to `{}`, response returns the message serializer, and the existing durable SendReplyJob path is queued when a worker pool is configured. | Review by `feat(messages): align retry status parity`; focused retry service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` must pass. |
|
||||
@@ -169,6 +170,7 @@ This ledger records the committed parity checkpoints that future slices should b
|
||||
|
||||
| Commit | Scope | Verification summary | Follow-up state |
|
||||
| --- | --- | --- | --- |
|
||||
| `feat(help-center): queue article translations` | Advances P3.7d enterprise help-center parity by matching Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, and the reused dashboard `bulkTranslate` flow. Bulk translate now enforces `captain_tasks`, portal allowed locales, optional locale-matched categories, and article selection; returns `409 { duplicate_articles }` for existing translations unless `force` is true; enqueues low-priority durable `captain:article_translate` jobs; registers replay in bootstrap; and uses a wired LLM-backed article translator with Chatwoot title/content prompts and locale-to-English target language names before creating or updating draft translated articles. | `go test ./internal/service -run 'ArticleService_.*Translate\|LLMArticleTranslationBackend\|ArticleService_BulkActions\|ArticleService_BulkUpdateStatus\|ArticleService_BulkDelete' -count=1`; `go test ./internal/handler/api/v1 -run 'ArticleHandlerSuite/TestBulkTranslate\|ArticleHandlerSuite/TestBulkActions' -count=1`; `go test ./internal/service ./internal/handler/api/v1 ./internal/repository ./internal/app -run 'Article\|Bootstrap\|App' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P3.7d moves to Review; continue Phase 6 placeholder burn-down or the next evidence-backed Phase 2/3 drift slice. |
|
||||
| `feat(inboxes): align agent bot member actions` | Advances P6.5 inbox API parity by aligning `GET /inboxes/:id/agent_bot` and `POST /inboxes/:id/set_agent_bot` with Chatwoot `InboxesController#agent_bot`/`#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API path. The setter accepts Chatwoot `{ agent_bot: id|null }` bodies, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null params as disconnect, returns empty `200 OK`, destroys disconnected bindings instead of leaving inactive local rows, and updates the inbox's single binding on reassignment. The getter now returns `{ agent_bot: {} }` when disconnected and serializes assigned bots with Chatwoot fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url`. | `go test ./internal/service -run 'InboxService_SetAgentBot' -count=1`; `go test ./internal/handler/api/v1 -run 'InboxHandler_Set.*AgentBot\|InboxHandler_GetAgentBot\|InboxSetAgentBot' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Inbox.*AgentBot\|SetAgentBot' -count=1`; `go test ./...`; `git diff --check`. No route changes. | P6.5 remains Doing for additional inbox/channel drift; continue Phase 6 placeholder audit or the next evidence-backed inbox member action gap. |
|
||||
| `feat(messages): align retry status parity` | Advances P6 message API parity by matching Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and the reused dashboard `MessageApi.retry` path. Message retry now resets failed messages to `sent`, clears `content_attributes` to `{}`, returns the Chatwoot message serializer, and queues the existing durable `message:send_reply` path when a WorkerPool is configured instead of leaving local `retrying` state/provider failure details visible. | `go test ./internal/service -run MessageService_Retry -count=1`; `go test ./internal/handler/api/v1 -run 'MessageHandlerTestSuite/TestRetry' -count=1`; `go test ./internal/handler/api/v1 ./internal/service -run 'Message.*Retry' -count=1`; `go test ./...`; `git diff --check`. | P6.4 message retry drift moves to Review; continue Phase 6 placeholder burn-down or fresh evidence-backed Phase 2/3 drift. |
|
||||
| `feat(conversations): align custom attribute response` | Advances P6 conversation response parity by matching Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, and the reused dashboard `ConversationApi.updateCustomAttributes` store action. The account conversation custom-attributes route now returns only `{ custom_attributes: ... }`, excludes local conversation/payload/meta wrappers, and converts empty/null JSON to `{}` for frontend merge stability. The placeholder audit was rerun and still shows only webhook handler-not-injected fallbacks calling `chatwootParityStub`. | `go test ./internal/handler/api/v1 -run 'ConversationHandler.*UpdateCustomAttributes\|TestConversationHandler' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/router -run 'UpdateCustomAttributes\|ConversationHandler\|ConversationService\|Router' -count=1`; `rg -n "chatwootParityStub\|TODO.*placeholder\|placeholder\|stub" internal/handler internal/service internal/router -g'*.go'`; `go test ./...`; `git diff --check`. | P6 conversation custom-attribute response drift moves to Review; continue Phase 6 placeholder burn-down from the remaining webhook nil-handler fallbacks or fresh reference/smoke evidence. |
|
||||
@@ -2124,6 +2126,7 @@ Verification milestone gates:
|
||||
|
||||
## Progress Log
|
||||
|
||||
- 2026-06-06: P3.7d enterprise article translation checkpoint prepared as `feat(help-center): queue article translations`; audited Chatwoot enterprise `Articles::BulkActionsController#translate`, `Captain::Articles::TranslateJob`, `Captain::Llm::ArticleTranslationService`, reused dashboard `api/helpCenter/articles.js`, and `BulkTranslateDialog.vue`. Account article bulk translate now validates `captain_tasks`, allowed portal locale, optional locale-scoped category, and selected articles; detects existing root-article translations and returns `409 { duplicate_articles }` unless `force` is true; queues durable low-priority `captain:article_translate` jobs; replays jobs by creating or updating draft translated articles; and wires an LLM-backed translation backend in bootstrap with Chatwoot title/content prompts and English target language names. Focused service/handler tests, combined service/handler/repository/app tests, full `go test ./...`, and `git diff --check` passed; no route artifacts changed.
|
||||
- 2026-06-06: P6.5 inbox agent-bot checkpoint prepared as `feat(inboxes): align agent bot member actions`; audited Chatwoot `InboxesController#agent_bot`, `InboxesController#set_agent_bot`, `inboxes/agent_bot.json.jbuilder`, `_agent_bot.json.jbuilder`, and the reused dashboard `agentBots` store/API. `POST /inboxes/:id/set_agent_bot` now accepts the frontend `{ agent_bot: id|null }` body, keeps legacy `{ agent_bot_id }` compatibility, treats omitted/null bodies as disconnect, returns empty `200 OK`, destroys disconnected bindings like Chatwoot `@inbox.agent_bot_inbox.destroy!`, and updates the inbox's single binding when reassigning bots. `GET /inboxes/:id/agent_bot` now returns `{ agent_bot: {} }` when disconnected and Chatwoot partial fields such as `thumbnail`, `bot_type`, `bot_config`, and `outgoing_url` when assigned. Focused inbox service/handler tests, combined handler/service tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or the next evidence-backed inbox drift.
|
||||
- 2026-06-06: P6 message retry checkpoint prepared as `feat(messages): align retry status parity`; audited Chatwoot `MessagesController#retry`, `messages/retry.json.jbuilder`, and reused dashboard `api/inbox/message.js`. Account message retry now resets status to `sent`, clears `content_attributes` to `{}`, returns the normal Chatwoot message serializer, and queues the existing durable send-reply job path when a WorkerPool is configured instead of exposing local `retrying` state/provider failure details. Focused retry service/handler tests, combined handler/service retry tests, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice.
|
||||
- 2026-06-06: P6 conversation custom-attribute response checkpoint prepared as `feat(conversations): align custom attribute response`; audited Chatwoot `ConversationsController#custom_attributes`, `custom_attributes.json.jbuilder`, reused dashboard `api/inbox/conversation.js`, and the conversation store action that reads `response.data.custom_attributes`. Account conversation custom-attribute updates now return only `{ custom_attributes: ... }`, exclude local conversation/payload/meta wrappers, and serialize empty/null custom attributes as `{}` for frontend merge stability. Focused handler tests, combined handler/service/router tests, placeholder audit, full `go test ./...`, and `git diff --check` passed; continue Phase 6 placeholder burn-down or a fresh evidence-backed Phase 2/3 drift slice.
|
||||
|
||||
@@ -632,6 +632,8 @@ func Bootstrap(env string) (*App, error) {
|
||||
portalService := service.NewPortalService(portalRepo)
|
||||
categoryService := service.NewCategoryService(categoryRepo, relatedCategoryRepo)
|
||||
articleService := service.NewArticleService(articleRepo)
|
||||
articleService.SetWorkerPool(workerPool)
|
||||
articleService.SetArticleTranslationBackend(service.NewLLMArticleTranslationBackend(llmProvider))
|
||||
folderService := service.NewFolderService(folderRepo)
|
||||
portalMemberService := service.NewPortalMemberService(portalMemberRepo)
|
||||
|
||||
|
||||
@@ -386,7 +386,25 @@ func (h *ArticleHandler) BulkDelete(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *ArticleHandler) BulkTranslate(c *gin.Context) {
|
||||
c.Status(http.StatusNotImplemented)
|
||||
portal, ok := h.resolvePortalForRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req service.BulkTranslateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.BulkTranslate(c.Request.Context(), portal.AccountID, portal, currentUserID(c), req); err != nil {
|
||||
var conflict *service.ArticleTranslationConflictError
|
||||
if errors.As(err, &conflict) {
|
||||
c.JSON(http.StatusConflict, gin.H{"duplicate_articles": conflict.Duplicates})
|
||||
return
|
||||
}
|
||||
renderArticleBulkError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// BulkActions performs a bulk operation (publish/archive/delete) on multiple articles.
|
||||
@@ -595,6 +613,10 @@ func renderArticleBulkError(c *gin.Context, err error) {
|
||||
message = "Invalid status"
|
||||
case errors.Is(err, service.ErrArticleBulkCategoryNotFound):
|
||||
message = "Category not found"
|
||||
case errors.Is(err, service.ErrArticleBulkCaptainNotAvailable):
|
||||
message = "Captain is not available"
|
||||
case errors.Is(err, service.ErrArticleBulkLocaleNotAvailable):
|
||||
message = "Locale is not available"
|
||||
default:
|
||||
message = err.Error()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -38,11 +39,13 @@ func (s *ArticleHandlerTestSuite) SetupSuite() {
|
||||
&model.Account{}, &model.User{}, &model.Portal{},
|
||||
&model.Category{}, &model.Folder{}, &model.Article{},
|
||||
&model.PortalMember{},
|
||||
&model.BackgroundJob{},
|
||||
))
|
||||
s.db = db
|
||||
|
||||
repo := repository.NewArticleRepo(db)
|
||||
svc := service.NewArticleService(repo)
|
||||
svc.SetWorkerPool(worker.NewWorkerPool(db))
|
||||
portalRepo := repository.NewPortalRepo(db)
|
||||
s.handler = NewArticleHandler(svc, service.NewPortalService(portalRepo))
|
||||
|
||||
@@ -387,6 +390,71 @@ func (s *ArticleHandlerTestSuite) TestBulkActions_FrontendRoutes() {
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestBulkTranslate_FrontendRouteQueuesAndReturnsConflict() {
|
||||
account := &model.Account{Name: "translate-account", FeatureFlags: `{"captain_tasks":true}`}
|
||||
s.Require().NoError(s.db.Create(account).Error)
|
||||
portal := &model.Portal{AccountID: account.ID, Name: "translate-portal", Slug: "translate-portal", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "French", Slug: "french", Locale: "fr"}
|
||||
s.Require().NoError(s.db.Create(category).Error)
|
||||
article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Returns", Slug: "returns-translate", Status: "published", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(article).Error)
|
||||
duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Retours", Slug: "retours-translate", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID}
|
||||
s.Require().NoError(s.db.Create(duplicate).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate)
|
||||
|
||||
body := fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d}`, article.ID, category.ID)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusConflict, w.Code)
|
||||
var conflict map[string][]map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &conflict))
|
||||
s.Require().Len(conflict["duplicate_articles"], 1)
|
||||
assert.Equal(s.T(), float64(duplicate.ID), conflict["duplicate_articles"][0]["id"])
|
||||
|
||||
body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":%d,"force":true}`, article.ID, category.ID)
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-portal/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
assert.Empty(s.T(), w.Body.String())
|
||||
|
||||
var jobs int64
|
||||
s.Require().NoError(s.db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", service.TaskTypeCaptainArticleTranslate, "low").Count(&jobs).Error)
|
||||
assert.GreaterOrEqual(s.T(), jobs, int64(1))
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestBulkTranslate_ValidatesCaptainLocaleAndCategory() {
|
||||
account := &model.Account{Name: "translate-invalid-account", FeatureFlags: `{"captain_tasks":true}`}
|
||||
s.Require().NoError(s.db.Create(account).Error)
|
||||
portal := &model.Portal{AccountID: account.ID, Name: "translate-invalid", Slug: "translate-invalid", Locale: "en", PortalConfiguration: json.RawMessage(`{"allowed_locales":["en","fr"]}`)}
|
||||
s.Require().NoError(s.db.Create(portal).Error)
|
||||
article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Billing", Slug: "billing-translate-invalid", Status: "published", Locale: "en"}
|
||||
s.Require().NoError(s.db.Create(article).Error)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/portals/:portal_id/articles/bulk_actions/translate", s.handler.BulkTranslate)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := fmt.Sprintf(`{"ids":[%d],"locale":"es"}`, article.ID)
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
body = fmt.Sprintf(`{"ids":[%d],"locale":"fr","category_id":999999}`, article.ID)
|
||||
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/portals/translate-invalid/articles/bulk_actions/translate", account.ID), bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *ArticleHandlerTestSuite) TestBulkUpdateStatus_InvalidStatusReturnsChatwootError() {
|
||||
article := &model.Article{AccountID: s.account.ID, PortalID: s.portal.ID, Title: "bulk-invalid", Slug: "bulk-invalid", Status: "draft"}
|
||||
s.Require().NoError(s.db.Create(article).Error)
|
||||
|
||||
@@ -18,6 +18,8 @@ func NewArticleRepo(db *gorm.DB) *ArticleRepo {
|
||||
return &ArticleRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) DB() *gorm.DB { return r.db }
|
||||
|
||||
func (r *ArticleRepo) Create(ctx context.Context, article *model.Article) error {
|
||||
return r.db.WithContext(ctx).Create(article).Error
|
||||
}
|
||||
@@ -38,6 +40,14 @@ func (r *ArticleRepo) GetByPortalAndID(ctx context.Context, portalID, id uint) (
|
||||
return &article, nil
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) GetByAccountAndID(ctx context.Context, accountID, id uint) (*model.Article, error) {
|
||||
var article model.Article
|
||||
if err := r.articlePreloads(r.db.WithContext(ctx)).Where("account_id = ?", accountID).First(&article, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &article, nil
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) Update(ctx context.Context, article *model.Article) error {
|
||||
return r.db.WithContext(ctx).Save(article).Error
|
||||
}
|
||||
@@ -303,6 +313,44 @@ func (r *ArticleRepo) CountByPortalAndIDs(ctx context.Context, portalID uint, id
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) FindByPortalAndIDs(ctx context.Context, portalID uint, ids []uint) ([]model.Article, error) {
|
||||
var articles []model.Article
|
||||
if len(ids) == 0 {
|
||||
return articles, nil
|
||||
}
|
||||
err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND id IN ?", portalID, ids).Order("id ASC").Find(&articles).Error
|
||||
return articles, err
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) CategoryExistsForPortalAndLocale(ctx context.Context, portalID uint, categoryID uint, locale string) (bool, error) {
|
||||
var count int64
|
||||
db := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID)
|
||||
if locale != "" {
|
||||
db = db.Where("locale = ?", locale)
|
||||
}
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) FindTranslationsByRootIDsAndLocale(ctx context.Context, portalID uint, rootIDs []uint, locale string) ([]model.Article, error) {
|
||||
var articles []model.Article
|
||||
if len(rootIDs) == 0 || locale == "" {
|
||||
return articles, nil
|
||||
}
|
||||
err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id IN ? AND locale = ?", portalID, rootIDs, locale).Order("id ASC").Find(&articles).Error
|
||||
return articles, err
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) FindTranslationByRootAndLocale(ctx context.Context, portalID uint, rootID uint, locale string) (*model.Article, error) {
|
||||
var article model.Article
|
||||
if err := r.articlePreloads(r.db.WithContext(ctx)).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portalID, rootID, locale).First(&article).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &article, nil
|
||||
}
|
||||
|
||||
func (r *ArticleRepo) CategoryExistsForPortal(ctx context.Context, portalID uint, categoryID uint) (bool, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.Category{}).Where("portal_id = ? AND id = ?", portalID, categoryID).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -3,20 +3,65 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
applogger "github.com/gochat/gochat/pkg/logger"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/language/display"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const TaskTypeCaptainArticleTranslate = "captain:article_translate"
|
||||
|
||||
// ArticleService implements business logic for Article CRUD.
|
||||
type ArticleService struct {
|
||||
repo *repository.ArticleRepo
|
||||
searchIndexer SearchIndexer
|
||||
worker *worker.WorkerPool
|
||||
translator ArticleTranslationBackend
|
||||
}
|
||||
|
||||
type ArticleTranslationBackend interface {
|
||||
TranslateArticleText(ctx context.Context, account *model.Account, text string, targetLanguage string, kind string) (string, error)
|
||||
}
|
||||
|
||||
type LLMArticleTranslationBackend struct {
|
||||
provider llm.Provider
|
||||
}
|
||||
|
||||
func NewLLMArticleTranslationBackend(provider llm.Provider) *LLMArticleTranslationBackend {
|
||||
return &LLMArticleTranslationBackend{provider: provider}
|
||||
}
|
||||
|
||||
func (b *LLMArticleTranslationBackend) TranslateArticleText(ctx context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) {
|
||||
if b == nil || b.provider == nil {
|
||||
return "", ErrArticleBulkTranslationWorkerUnavailable
|
||||
}
|
||||
kind = strings.ToLower(strings.TrimSpace(kind))
|
||||
if kind != "title" && kind != "content" {
|
||||
return "", fmt.Errorf("invalid article translation type: %s", kind)
|
||||
}
|
||||
resp, err := b.provider.ChatCompletion(ctx, llm.ChatRequest{
|
||||
Messages: []llm.ChatMessage{
|
||||
{Role: "system", Content: articleTranslationSystemPrompt(kind, languageNameForLocale(targetLanguage))},
|
||||
{Role: "user", Content: text},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("article translation llm: %w", err)
|
||||
}
|
||||
if resp == nil || len(resp.Choices) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(resp.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
func NewArticleService(repo *repository.ArticleRepo) *ArticleService {
|
||||
@@ -27,6 +72,15 @@ func (s *ArticleService) SetSearchIndexer(indexer SearchIndexer) {
|
||||
s.searchIndexer = indexer
|
||||
}
|
||||
|
||||
func (s *ArticleService) SetWorkerPool(wp *worker.WorkerPool) {
|
||||
s.worker = wp
|
||||
RegisterArticleTranslationJobs(wp, s)
|
||||
}
|
||||
|
||||
func (s *ArticleService) SetArticleTranslationBackend(backend ArticleTranslationBackend) {
|
||||
s.translator = backend
|
||||
}
|
||||
|
||||
func (s *ArticleService) indexArticle(ctx context.Context, article *model.Article) {
|
||||
if s.searchIndexer != nil {
|
||||
logSearchIndexError("article", article.ID, s.searchIndexer.IndexArticle(ctx, article))
|
||||
@@ -463,6 +517,81 @@ type BulkActionsRequest struct {
|
||||
IDs []uint `json:"ids" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type BulkTranslateRequest struct {
|
||||
IDs []uint `json:"ids"`
|
||||
Locale string `json:"locale"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type ArticleTranslationDuplicate struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ArticleTranslationConflictError struct {
|
||||
Duplicates []ArticleTranslationDuplicate
|
||||
}
|
||||
|
||||
func (e *ArticleTranslationConflictError) Error() string { return "article translations already exist" }
|
||||
|
||||
func (s *ArticleService) BulkTranslate(ctx context.Context, accountID uint, portal *model.Portal, userID uint, req BulkTranslateRequest) error {
|
||||
if portal == nil {
|
||||
return fmt.Errorf("portal not found")
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.repo.DB().WithContext(ctx).First(&account, accountID).Error; err != nil {
|
||||
return fmt.Errorf("account not found: %w", err)
|
||||
}
|
||||
if !featureFlagStringEnabled(account.FeatureFlags, "captain_tasks") {
|
||||
return ErrArticleBulkCaptainNotAvailable
|
||||
}
|
||||
if !portalAllowsLocale(portal, req.Locale) {
|
||||
return ErrArticleBulkLocaleNotAvailable
|
||||
}
|
||||
if req.CategoryID != nil {
|
||||
exists, err := s.repo.CategoryExistsForPortalAndLocale(ctx, portal.ID, *req.CategoryID, req.Locale)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find category: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrArticleBulkCategoryNotFound
|
||||
}
|
||||
}
|
||||
articles, err := s.repo.FindByPortalAndIDs(ctx, portal.ID, req.IDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find articles: %w", err)
|
||||
}
|
||||
if len(articles) == 0 {
|
||||
return ErrArticleBulkNoArticles
|
||||
}
|
||||
rootIDs := articleRootIDs(articles)
|
||||
duplicates, err := s.repo.FindTranslationsByRootIDsAndLocale(ctx, portal.ID, rootIDs, req.Locale)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find translations: %w", err)
|
||||
}
|
||||
if len(duplicates) > 0 && !req.Force {
|
||||
return &ArticleTranslationConflictError{Duplicates: articleTranslationDuplicates(duplicates)}
|
||||
}
|
||||
if s.worker == nil {
|
||||
return ErrArticleBulkTranslationWorkerUnavailable
|
||||
}
|
||||
for _, article := range articles {
|
||||
payload := captainArticleTranslateJob{AccountID: accountID, ArticleID: article.ID, Locale: req.Locale, UserID: userID}
|
||||
if req.CategoryID != nil {
|
||||
payload.CategoryID = *req.CategoryID
|
||||
}
|
||||
if _, err := s.worker.Enqueue(ctx, TaskTypeCaptainArticleTranslate, payload,
|
||||
worker.WithQueue("low"),
|
||||
worker.WithMaxAttempts(3),
|
||||
worker.WithIdempotencyKey(fmt.Sprintf("captain:article_translate:%d:%d:%s", accountID, article.ID, req.Locale)),
|
||||
); err != nil {
|
||||
return fmt.Errorf("enqueue article translation: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BulkActions dispatches a bulk operation (publish/archive/delete) on the given article IDs.
|
||||
// Reference: Chatwoot articles_controller#bulk_actions
|
||||
func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsRequest) error {
|
||||
@@ -479,11 +608,217 @@ func (s *ArticleService) BulkActions(ctx context.Context, req *BulkActionsReques
|
||||
}
|
||||
|
||||
var (
|
||||
ErrArticleBulkNoArticles = fmt.Errorf("no articles found")
|
||||
ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status")
|
||||
ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found")
|
||||
ErrArticleBulkNoArticles = fmt.Errorf("no articles found")
|
||||
ErrArticleBulkInvalidStatus = fmt.Errorf("invalid status")
|
||||
ErrArticleBulkCategoryNotFound = fmt.Errorf("category not found")
|
||||
ErrArticleBulkCaptainNotAvailable = fmt.Errorf("captain not available")
|
||||
ErrArticleBulkLocaleNotAvailable = fmt.Errorf("locale not available")
|
||||
ErrArticleBulkTranslationWorkerUnavailable = fmt.Errorf("article translation worker unavailable")
|
||||
)
|
||||
|
||||
type captainArticleTranslateJob struct {
|
||||
AccountID uint `json:"account_id"`
|
||||
ArticleID uint `json:"article_id"`
|
||||
Locale string `json:"locale"`
|
||||
CategoryID uint `json:"category_id,omitempty"`
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
|
||||
func RegisterArticleTranslationJobs(wp *worker.WorkerPool, svc *ArticleService) {
|
||||
if wp == nil || svc == nil {
|
||||
return
|
||||
}
|
||||
wp.Register(TaskTypeCaptainArticleTranslate, svc.performArticleTranslateJob)
|
||||
}
|
||||
|
||||
func (s *ArticleService) performArticleTranslateJob(ctx context.Context, job *model.BackgroundJob) error {
|
||||
var payload captainArticleTranslateJob
|
||||
if len(job.Payload) > 0 {
|
||||
if err := json.Unmarshal(job.Payload, &payload); err != nil {
|
||||
return fmt.Errorf("unmarshal article translate job: %w", err)
|
||||
}
|
||||
}
|
||||
if payload.AccountID == 0 || payload.ArticleID == 0 || payload.Locale == "" {
|
||||
return fmt.Errorf("invalid article translate job payload: %#v", payload)
|
||||
}
|
||||
if s.translator == nil {
|
||||
return ErrArticleBulkTranslationWorkerUnavailable
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.repo.DB().WithContext(ctx).First(&account, payload.AccountID).Error; err != nil {
|
||||
return fmt.Errorf("account not found: %w", err)
|
||||
}
|
||||
source, err := s.repo.GetByAccountAndID(ctx, payload.AccountID, payload.ArticleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("source article not found: %w", err)
|
||||
}
|
||||
targetLanguage := languageNameForLocale(payload.Locale)
|
||||
title, err := s.translator.TranslateArticleText(ctx, &account, source.Title, targetLanguage, "title")
|
||||
if err != nil {
|
||||
return fmt.Errorf("translate article title: %w", err)
|
||||
}
|
||||
content := source.Content
|
||||
if strings.TrimSpace(source.Content) != "" {
|
||||
content, err = s.translator.TranslateArticleText(ctx, &account, source.Content, targetLanguage, "content")
|
||||
if err != nil {
|
||||
return fmt.Errorf("translate article content: %w", err)
|
||||
}
|
||||
}
|
||||
rootID := articleRootID(*source)
|
||||
existing, err := s.repo.FindTranslationByRootAndLocale(ctx, source.PortalID, rootID, payload.Locale)
|
||||
if err == nil && existing != nil {
|
||||
existing.Title = title
|
||||
existing.Content = content
|
||||
existing.Description = source.Description
|
||||
if err := s.repo.Update(ctx, existing); err != nil {
|
||||
return fmt.Errorf("update article translation: %w", err)
|
||||
}
|
||||
s.indexArticle(ctx, existing)
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("find article translation: %w", err)
|
||||
}
|
||||
var categoryID *uint
|
||||
if payload.CategoryID != 0 {
|
||||
categoryID = &payload.CategoryID
|
||||
}
|
||||
translated := &model.Article{
|
||||
AccountID: source.AccountID,
|
||||
PortalID: source.PortalID,
|
||||
CategoryID: categoryID,
|
||||
AuthorID: &payload.UserID,
|
||||
Title: title,
|
||||
Slug: articleSlug(fmt.Sprintf("%s-%s-%d", title, payload.Locale, source.ID)),
|
||||
Description: source.Description,
|
||||
Content: content,
|
||||
Status: string(model.ArticleStatusDraft),
|
||||
Locale: payload.Locale,
|
||||
AssociatedArticleID: &rootID,
|
||||
}
|
||||
if err := s.repo.Create(ctx, translated); err != nil {
|
||||
return fmt.Errorf("create article translation: %w", err)
|
||||
}
|
||||
s.indexArticle(ctx, translated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func articleRootID(article model.Article) uint {
|
||||
if article.AssociatedArticleID != nil && *article.AssociatedArticleID != 0 {
|
||||
return *article.AssociatedArticleID
|
||||
}
|
||||
return article.ID
|
||||
}
|
||||
|
||||
func articleRootIDs(articles []model.Article) []uint {
|
||||
seen := map[uint]bool{}
|
||||
ids := make([]uint, 0, len(articles))
|
||||
for _, article := range articles {
|
||||
id := articleRootID(article)
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func articleTranslationDuplicates(articles []model.Article) []ArticleTranslationDuplicate {
|
||||
duplicates := make([]ArticleTranslationDuplicate, 0, len(articles))
|
||||
for _, article := range articles {
|
||||
duplicates = append(duplicates, ArticleTranslationDuplicate{ID: article.ID, Title: article.Title})
|
||||
}
|
||||
return duplicates
|
||||
}
|
||||
|
||||
func articleTranslationSystemPrompt(kind string, targetLanguage string) string {
|
||||
if kind == "title" {
|
||||
return fmt.Sprintf(`You are a professional translator.
|
||||
Translate the following text to %s.
|
||||
Return only the translated text, no explanations or extra formatting.`, targetLanguage)
|
||||
}
|
||||
return fmt.Sprintf(`You are a professional translator. Translate the following content to %s.
|
||||
The content is markdown that may contain embedded HTML blocks.
|
||||
Rules:
|
||||
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
|
||||
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
|
||||
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
|
||||
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
|
||||
- Keep all image tags (both markdown  and HTML <img>), iframes, and embedded media completely unchanged.
|
||||
- Preserve all line breaks, blank lines, and whitespace patterns.
|
||||
- Return ONLY the translated content, no wrapping or explanations.`, targetLanguage)
|
||||
}
|
||||
|
||||
func languageNameForLocale(localeCode string) string {
|
||||
localeCode = strings.TrimSpace(localeCode)
|
||||
if localeCode == "" {
|
||||
return localeCode
|
||||
}
|
||||
if name, ok := articleTranslationLanguageNames[localeCode]; ok {
|
||||
return name
|
||||
}
|
||||
normalized := strings.ReplaceAll(localeCode, "_", "-")
|
||||
if tag, err := language.Parse(normalized); err == nil {
|
||||
name := display.English.Tags().Name(tag)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return localeCode
|
||||
}
|
||||
|
||||
var articleTranslationLanguageNames = map[string]string{
|
||||
"ar": "Arabic",
|
||||
"de": "German",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fr": "French",
|
||||
"hi": "Hindi",
|
||||
"id": "Indonesian",
|
||||
"it": "Italian",
|
||||
"ja": "Japanese",
|
||||
"ko": "Korean",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"pt": "Portuguese",
|
||||
"pt_BR": "Portuguese (Brazil)",
|
||||
"ru": "Russian",
|
||||
"th": "Thai",
|
||||
"tr": "Turkish",
|
||||
"vi": "Vietnamese",
|
||||
"zh": "Chinese",
|
||||
"zh_CN": "Chinese (China)",
|
||||
"zh_TW": "Chinese (Taiwan)",
|
||||
}
|
||||
|
||||
func portalAllowsLocale(portal *model.Portal, locale string) bool {
|
||||
if portal == nil || strings.TrimSpace(locale) == "" {
|
||||
return false
|
||||
}
|
||||
var config map[string]any
|
||||
if len(portal.PortalConfiguration) > 0 {
|
||||
_ = json.Unmarshal(portal.PortalConfiguration, &config)
|
||||
}
|
||||
if raw, ok := config["allowed_locales"]; ok {
|
||||
switch values := raw.(type) {
|
||||
case []any:
|
||||
for _, value := range values {
|
||||
if fmt.Sprint(value) == locale {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, value := range values {
|
||||
if value == locale {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return portal.Locale == locale
|
||||
}
|
||||
|
||||
func validateArticleBulkStatus(status string) error {
|
||||
validStatuses := map[model.ArticleStatus]bool{
|
||||
model.ArticleStatusDraft: true,
|
||||
|
||||
@@ -2,15 +2,41 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/gochat/gochat/internal/llm"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
"github.com/gochat/gochat/internal/worker"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeArticleTranslator struct{}
|
||||
|
||||
func (fakeArticleTranslator) TranslateArticleText(_ context.Context, _ *model.Account, text string, targetLanguage string, kind string) (string, error) {
|
||||
return fmt.Sprintf("%s[%s:%s]", text, targetLanguage, kind), nil
|
||||
}
|
||||
|
||||
type recordingArticleLLM struct {
|
||||
requests []llm.ChatRequest
|
||||
}
|
||||
|
||||
func (m *recordingArticleLLM) ChatCompletion(_ context.Context, req llm.ChatRequest) (*llm.ChatResponse, error) {
|
||||
m.requests = append(m.requests, req)
|
||||
return &llm.ChatResponse{Choices: []llm.ChatChoice{{Message: llm.ChatMessage{Content: " Texte traduit "}}}}, nil
|
||||
}
|
||||
|
||||
func (m *recordingArticleLLM) CreateEmbedding(context.Context, llm.EmbeddingRequest) (*llm.EmbeddingResponse, error) {
|
||||
return nil, fmt.Errorf("not used")
|
||||
}
|
||||
|
||||
func (m *recordingArticleLLM) ChatCompletionStream(context.Context, llm.ChatRequest, func(llm.StreamChunk) error) error {
|
||||
return fmt.Errorf("not used")
|
||||
}
|
||||
|
||||
// ========== Article test helpers ==========
|
||||
|
||||
// setupArticleService creates ArticleRepo + ArticleService test instances.
|
||||
@@ -137,4 +163,94 @@ func TestArticleService_BulkDelete_Success(t *testing.T) {
|
||||
var found model.Article
|
||||
assert.ErrorIs(t, db.First(&found, a1.ID).Error, gorm.ErrRecordNotFound)
|
||||
assert.ErrorIs(t, db.First(&found, a2.ID).Error, gorm.ErrRecordNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleService_BulkTranslate_QueuesCaptainJobsAndDetectsDuplicates(t *testing.T) {
|
||||
db, _, svc := setupArticleService(t)
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
account := createTestAccount(t, db)
|
||||
account.FeatureFlags = `{"captain_tasks":true}`
|
||||
require.NoError(t, db.Save(account).Error)
|
||||
portal := &model.Portal{AccountID: account.ID, Name: "Translate Portal", Slug: "translate-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)}
|
||||
require.NoError(t, db.Create(portal).Error)
|
||||
article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-service", Content: "Ship fast", Status: "published", Locale: "en"}
|
||||
require.NoError(t, db.Create(article).Error)
|
||||
duplicate := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Expedition", Slug: "expedition-service", Status: "draft", Locale: "fr", AssociatedArticleID: &article.ID}
|
||||
require.NoError(t, db.Create(duplicate).Error)
|
||||
|
||||
err := svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr"})
|
||||
var conflict *ArticleTranslationConflictError
|
||||
require.ErrorAs(t, err, &conflict)
|
||||
require.Len(t, conflict.Duplicates, 1)
|
||||
assert.Equal(t, duplicate.ID, conflict.Duplicates[0].ID)
|
||||
|
||||
err = svc.BulkTranslate(context.Background(), account.ID, portal, 42, BulkTranslateRequest{IDs: []uint{article.ID}, Locale: "fr", Force: true})
|
||||
require.NoError(t, err)
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.BackgroundJob{}).Where("job_type = ? AND queue = ?", TaskTypeCaptainArticleTranslate, "low").Count(&count).Error)
|
||||
assert.Equal(t, int64(1), count)
|
||||
}
|
||||
|
||||
func TestArticleService_ArticleTranslateJobCreatesAndUpdatesTranslations(t *testing.T) {
|
||||
db, _, svc := setupArticleService(t)
|
||||
wp := worker.NewWorkerPool(db)
|
||||
svc.SetWorkerPool(wp)
|
||||
svc.SetArticleTranslationBackend(fakeArticleTranslator{})
|
||||
account := createTestAccount(t, db)
|
||||
portal := &model.Portal{AccountID: account.ID, Name: "Translate Job Portal", Slug: "translate-job-service", Locale: "en", PortalConfiguration: []byte(`{"allowed_locales":["en","fr"]}`)}
|
||||
require.NoError(t, db.Create(portal).Error)
|
||||
category := &model.Category{AccountID: account.ID, PortalID: portal.ID, Name: "Francais", Slug: "fr-service", Locale: "fr"}
|
||||
require.NoError(t, db.Create(category).Error)
|
||||
article := &model.Article{AccountID: account.ID, PortalID: portal.ID, Title: "Shipping", Slug: "shipping-job-service", Description: "Delivery", Content: "Ship fast", Status: "published", Locale: "en"}
|
||||
require.NoError(t, db.Create(article).Error)
|
||||
|
||||
_, err := wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low"))
|
||||
require.NoError(t, err)
|
||||
processed, err := wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, processed)
|
||||
|
||||
var translated model.Article
|
||||
require.NoError(t, db.Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").First(&translated).Error)
|
||||
assert.Equal(t, "Shipping[French:title]", translated.Title)
|
||||
assert.Equal(t, "Ship fast[French:content]", translated.Content)
|
||||
assert.Equal(t, "Delivery", translated.Description)
|
||||
require.NotNil(t, translated.CategoryID)
|
||||
assert.Equal(t, category.ID, *translated.CategoryID)
|
||||
assert.Equal(t, string(model.ArticleStatusDraft), translated.Status)
|
||||
|
||||
article.Title = "Updated Shipping"
|
||||
article.Content = "Ship faster"
|
||||
require.NoError(t, db.Save(article).Error)
|
||||
_, err = wp.Enqueue(context.Background(), TaskTypeCaptainArticleTranslate, captainArticleTranslateJob{AccountID: account.ID, ArticleID: article.ID, Locale: "fr", CategoryID: category.ID, UserID: 7}, worker.WithQueue("low"))
|
||||
require.NoError(t, err)
|
||||
processed, err = wp.ProcessOne(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.True(t, processed)
|
||||
|
||||
var translations int64
|
||||
require.NoError(t, db.Model(&model.Article{}).Where("portal_id = ? AND associated_article_id = ? AND locale = ?", portal.ID, article.ID, "fr").Count(&translations).Error)
|
||||
assert.Equal(t, int64(1), translations)
|
||||
require.NoError(t, db.First(&translated, translated.ID).Error)
|
||||
assert.Equal(t, "Updated Shipping[French:title]", translated.Title)
|
||||
assert.Equal(t, "Ship faster[French:content]", translated.Content)
|
||||
}
|
||||
|
||||
func TestLLMArticleTranslationBackend_UsesChatwootPromptsAndLanguageNames(t *testing.T) {
|
||||
provider := &recordingArticleLLM{}
|
||||
backend := NewLLMArticleTranslationBackend(provider)
|
||||
|
||||
translated, err := backend.TranslateArticleText(context.Background(), &model.Account{}, "# Hello", "fr", "content")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Texte traduit", translated)
|
||||
require.Len(t, provider.requests, 1)
|
||||
req := provider.requests[0]
|
||||
require.Len(t, req.Messages, 2)
|
||||
assert.Contains(t, req.Messages[0].Content, "Translate the following content to French")
|
||||
assert.Contains(t, req.Messages[0].Content, "Preserve ALL markdown formatting exactly")
|
||||
assert.Equal(t, "# Hello", req.Messages[1].Content)
|
||||
|
||||
_, err = backend.TranslateArticleText(context.Background(), &model.Account{}, "Hello", "fr", "summary")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user