From 170fcd026d231a886e0edf8bb5e98be28dfda93b Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 Oct 2026 20:57:12 +0530 Subject: [PATCH 1/9] add help article citations to AI replies Link cited public help articles in chat replies and emails. Batch reference lookups and keep source metadata out of customer replies. --- cmd/main.go | 1 + cmd/messages.go | 2 + cmd/upgrade.go | 2 +- .../src/features/admin/ai/AssistantForm.vue | 15 + .../views/admin/ai/CreateOrEditAssistant.vue | 22 ++ i18n/en-US.json | 3 + internal/aiagent/aiagent.go | 4 +- internal/aiagent/citations.go | 70 +++++ internal/aiagent/models/models.go | 38 +-- internal/aiagent/prompt.go | 3 + internal/aiagent/queries.sql | 12 +- internal/aiagent/tools.go | 12 +- internal/aiagent/worker.go | 26 +- internal/aiagent/worker_test.go | 58 ++++ internal/conversation/citations.go | 148 +++++++++ internal/conversation/continuity.go | 24 +- internal/conversation/conversation.go | 8 + internal/conversation/message.go | 38 ++- internal/conversation/message_test.go | 297 ++++++++++++++++++ internal/conversation/transcript.go | 7 +- internal/conversation/transcript_test.go | 28 ++ internal/helpcenter/helpcenter.go | 14 + internal/helpcenter/models/models.go | 26 ++ internal/helpcenter/queries.sql | 21 ++ internal/migrations/v2.9.0-rc.9.go | 3 + internal/migrations/v2.9.0.go | 2 +- internal/migrations/v2.9.0_test.go | 12 +- schema.sql | 1 + 28 files changed, 840 insertions(+), 57 deletions(-) create mode 100644 internal/aiagent/citations.go create mode 100644 internal/conversation/citations.go diff --git a/cmd/main.go b/cmd/main.go index b658badf..152390bb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -289,6 +289,7 @@ func main() { waClient := initWhatsAppClient() waTemplates := initWhatsAppTemplates(db, i18n, waClient, inbox) conversation.SetWhatsAppTemplateStore(waTemplates) + conversation.SetArticleReferenceStore(helpCenter) go automation.Run(ctx, automationWorkers) go autoassigner.Run(ctx, autoAssignInterval) diff --git a/cmd/messages.go b/cmd/messages.go index c6be334c..391ab0a0 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -71,6 +71,7 @@ func handleGetMessages(r *fastglue.Request) error { } rootURL, _ := app.setting.GetAppRootURL() + app.conversation.RenderMessagesArticleReferences(messages) for i := range messages { total = messages[i].Total app.conversation.SignAttachmentURLs(messages[i].Attachments) @@ -138,6 +139,7 @@ func handleGetMessage(r *fastglue.Request) error { } rootURL, _ := app.setting.GetAppRootURL() + app.conversation.RenderArticleReferences(&message) app.conversation.SignAttachmentURLs(message.Attachments) resolveQuotedCIDs(app, &message) resolveAttachmentCIDs(&message, rootURL) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 99e928e2..f4b2e762 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -47,7 +47,7 @@ var migList = []migFunc{ {"v2.5.0", migrations.V2_5_0}, {"v2.6.0", migrations.V2_6_0}, {"v2.8.0", migrations.V2_8_0}, - {"v2.9.0-rc.8", migrations.V2_9_0}, + {"v2.9.0-rc.8", migrations.V2_9_0_RC8}, {"v2.9.0-rc.9", migrations.V2_9_0_RC9}, } diff --git a/frontend/apps/main/src/features/admin/ai/AssistantForm.vue b/frontend/apps/main/src/features/admin/ai/AssistantForm.vue index d4cc4edb..b8678825 100644 --- a/frontend/apps/main/src/features/admin/ai/AssistantForm.vue +++ b/frontend/apps/main/src/features/admin/ai/AssistantForm.vue @@ -134,6 +134,17 @@ + + + + + + { ? Number(values.fallback_team_id) : null, handoff_enabled: !!values.handoff_enabled, + citations_enabled: !!values.citations_enabled, languages: values.languages || [], instructions: values.instructions || '', guardrails: values.guardrails || '', diff --git a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue index 8481ef0b..f17e8230 100644 --- a/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue +++ b/frontend/apps/main/src/views/admin/ai/CreateOrEditAssistant.vue @@ -67,6 +67,25 @@ +
+
+ {{ t('globals.terms.articleReference', 2) }} +
+
    +
  • + +
  • +
+
{{ t('admin.ai.assistant.preview.sources') }} @@ -189,6 +208,9 @@ const previewMessage = ref('') const previewReply = ref('') const previewSources = ref([]) const previewLoading = ref(false) +const previewReferences = computed(() => + previewSources.value.filter((source) => source.url) +) const fmtNumber = (value) => Number(value ?? 0).toLocaleString() const fmtPercent = (value) => `${Number(value ?? 0).toFixed(1)}%` diff --git a/i18n/en-US.json b/i18n/en-US.json index 339689f3..50c81fea 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -71,6 +71,8 @@ "admin.ai.assistant.guardrails": "Guardrails", "admin.ai.assistant.guardrailsHint": "Rules the assistant must never break.", "admin.ai.assistant.guardrailsPlaceholder": "Never issue a refund without a human agent's approval. Never share other customers' order details.", + "admin.ai.assistant.includeArticleReferences": "Include article references", + "admin.ai.assistant.includeArticleReferencesHint": "Link to public help center articles used in chat and email replies. Private knowledge snippets are never listed.", "admin.ai.assistant.instructions": "Instructions", "admin.ai.assistant.instructionsHint": "Custom system prompt that guides how this assistant responds.", "admin.ai.assistant.instructionsPlaceholder": "Always confirm the order ID before looking anything up. Keep replies under 3 sentences.", @@ -1079,6 +1081,7 @@ "globals.terms.appliesTo": "Applies to", "globals.terms.arguments": "Arguments", "globals.terms.article": "Article | Articles", + "globals.terms.articleReference": "Article reference | Article references", "globals.terms.ascending": "Ascending", "globals.terms.assignedTeam": "Assigned team", "globals.terms.assignee": "Assignee", diff --git a/internal/aiagent/aiagent.go b/internal/aiagent/aiagent.go index 7885ac0d..2c01a30e 100644 --- a/internal/aiagent/aiagent.go +++ b/internal/aiagent/aiagent.go @@ -280,7 +280,7 @@ func (m *Manager) CreateAssistant(a models.Assistant) (models.Assistant, error) } var id int - if err := tx.Stmtx(m.q.InsertAssistant).QueryRow(userID, a.Description, a.Instructions, a.Guardrails, a.Tone, a.ResponseLength, a.MaxTurns, a.FallbackTeamID, a.Enabled, a.Expectation, a.HandoffEnabled, a.Languages).Scan(&id); err != nil { + if err := tx.Stmtx(m.q.InsertAssistant).QueryRow(userID, a.Description, a.Instructions, a.Guardrails, a.Tone, a.ResponseLength, a.MaxTurns, a.FallbackTeamID, a.Enabled, a.Expectation, a.HandoffEnabled, a.Languages, a.CitationsEnabled).Scan(&id); err != nil { m.lo.Error("error creating assistant", "error", err) return models.Assistant{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } @@ -317,7 +317,7 @@ func (m *Manager) UpdateAssistant(id int, a models.Assistant) (models.Assistant, } defer tx.Rollback() - if _, err := tx.Stmtx(m.q.UpdateAssistant).Exec(id, a.Description, a.Instructions, a.Guardrails, a.Tone, a.ResponseLength, a.MaxTurns, a.FallbackTeamID, a.Enabled, a.Expectation, a.HandoffEnabled, a.Languages); err != nil { + if _, err := tx.Stmtx(m.q.UpdateAssistant).Exec(id, a.Description, a.Instructions, a.Guardrails, a.Tone, a.ResponseLength, a.MaxTurns, a.FallbackTeamID, a.Enabled, a.Expectation, a.HandoffEnabled, a.Languages, a.CitationsEnabled); err != nil { m.lo.Error("error updating assistant", "error", err) return models.Assistant{}, envelope.NewError(envelope.GeneralError, m.i18n.T("globals.messages.somethingWentWrong"), nil) } diff --git a/internal/aiagent/citations.go b/internal/aiagent/citations.go new file mode 100644 index 00000000..f6165057 --- /dev/null +++ b/internal/aiagent/citations.go @@ -0,0 +1,70 @@ +package aiagent + +import ( + "encoding/json" + "strings" + + aimodels "github.com/abhinavxd/libredesk/internal/ai/models" +) + +func splitArticleSources(answer string, hits []aimodels.SearchResult, enabled bool) (string, []int) { + allowed := map[int]bool{} + if enabled { + for _, hit := range hits { + if hit.SourceType == aimodels.SourceHelpArticle && hit.SourceID > 0 { + allowed[hit.SourceID] = true + } + } + } + var articleIDs []int + var body strings.Builder + for { + before, payload, found := strings.Cut(answer, sourcesMarker) + if !found { + body.WriteString(answer) + break + } + body.WriteString(before) + payload = strings.TrimLeft(payload, " \t\r\n") + decoder := json.NewDecoder(strings.NewReader(payload)) + var ids []int + err := decoder.Decode(&ids) + end := int(decoder.InputOffset()) + if end == 0 { + end = len(payload) + if strings.HasPrefix(payload, "[") { + if close := strings.IndexByte(payload, ']'); close >= 0 { + end = close + 1 + } + } else if newline := strings.IndexByte(payload, '\n'); newline >= 0 { + end = newline + } + for _, marker := range []string{confirmMarker, suggestionsMarker, sourcesMarker} { + if next := strings.Index(payload, marker); next >= 0 { + end = min(end, next) + } + } + } + if err == nil { + for _, id := range ids { + if allowed[id] { + articleIDs = append(articleIDs, id) + delete(allowed, id) + } + } + } + answer = strings.TrimLeft(payload[end:], " \t\r") + if strings.HasSuffix(strings.TrimRight(before, " \t\r"), "\n") { + answer = strings.TrimPrefix(answer, "\n") + } + } + return strings.TrimSpace(body.String()), articleIDs +} + +func replyMeta(suggestions []string, articleIDs []int) map[string]any { + meta := suggestedRepliesMeta(suggestions) + if len(articleIDs) > 0 { + meta["ai_article_ids"] = articleIDs + } + return meta +} diff --git a/internal/aiagent/models/models.go b/internal/aiagent/models/models.go index 65566a5c..1228ac97 100644 --- a/internal/aiagent/models/models.go +++ b/internal/aiagent/models/models.go @@ -22,24 +22,25 @@ var ( // Assistant is one AI assistant: a persona plus the ai_assistant user that carries its identity. type Assistant struct { - ID int `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - UserID int `db:"user_id" json:"user_id"` - Name string `db:"name" json:"name"` - AvatarURL null.String `db:"avatar_url" json:"avatar_url"` - Description string `db:"description" json:"description"` - Instructions string `db:"instructions" json:"instructions"` - Guardrails string `db:"guardrails" json:"guardrails"` - Expectation string `db:"expectation" json:"expectation"` - Tone string `db:"tone" json:"tone"` - ResponseLength string `db:"response_length" json:"response_length"` - MaxTurns int `db:"max_turns" json:"max_turns"` - FallbackTeamID null.Int `db:"fallback_team_id" json:"fallback_team_id"` - HandoffEnabled bool `db:"handoff_enabled" json:"handoff_enabled"` - Languages pq.StringArray `db:"languages" json:"languages"` - Enabled bool `db:"enabled" json:"enabled"` - ToolIDs []int `db:"-" json:"tool_ids"` + ID int `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + UserID int `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + AvatarURL null.String `db:"avatar_url" json:"avatar_url"` + Description string `db:"description" json:"description"` + Instructions string `db:"instructions" json:"instructions"` + Guardrails string `db:"guardrails" json:"guardrails"` + Expectation string `db:"expectation" json:"expectation"` + Tone string `db:"tone" json:"tone"` + ResponseLength string `db:"response_length" json:"response_length"` + MaxTurns int `db:"max_turns" json:"max_turns"` + FallbackTeamID null.Int `db:"fallback_team_id" json:"fallback_team_id"` + CitationsEnabled bool `db:"citations_enabled" json:"citations_enabled"` + HandoffEnabled bool `db:"handoff_enabled" json:"handoff_enabled"` + Languages pq.StringArray `db:"languages" json:"languages"` + Enabled bool `db:"enabled" json:"enabled"` + ToolIDs []int `db:"-" json:"tool_ids"` // RemoveAvatar, when set on a save request, clears the assistant's current avatar. RemoveAvatar bool `db:"-" json:"remove_avatar"` @@ -51,6 +52,7 @@ type PreviewSource struct { Type string `json:"type"` Title string `json:"title"` Score float64 `json:"score"` + URL string `json:"url,omitempty"` } // RecentConversation is a summary row of a contact's past conversation, fed to the assistant as context. diff --git a/internal/aiagent/prompt.go b/internal/aiagent/prompt.go index 1fd1a533..58d3806d 100644 --- a/internal/aiagent/prompt.go +++ b/internal/aiagent/prompt.go @@ -167,6 +167,9 @@ func buildSystemPrompt(a models.Assistant) string { b.WriteString("\n\nInstructions from the workspace admin (follow these):\n") b.WriteString(instr) } + if a.CitationsEnabled { + b.WriteString("\n\nWhen your answer uses help center articles from this turn's search results, add a separate line in this exact form: [[sources]] [12,34]. Include only the article_id values of articles you actually used, once each. Never cite snippets or articles from earlier turns, and never invent an article ID or URL. Put this line before [[confirm]] or [[suggestions]] when present. Omit it when no help center article supports the answer, including greetings, clarifying questions, refusals and handoffs. The customer never sees this line. Article links are added by the application.") + } if guard := strings.TrimSpace(a.Guardrails); guard != "" { b.WriteString("\n\nGuardrails (never violate these):\n") b.WriteString(guard) diff --git a/internal/aiagent/queries.sql b/internal/aiagent/queries.sql index b31044c7..e46f092a 100644 --- a/internal/aiagent/queries.sql +++ b/internal/aiagent/queries.sql @@ -1,20 +1,20 @@ -- name: get-assistants SELECT a.id, a.created_at, a.updated_at, a.user_id, u.first_name AS name, u.avatar_url, - a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.languages, a.enabled + a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.citations_enabled, a.languages, a.enabled FROM ai_assistants a JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL ORDER BY a.updated_at DESC; -- name: get-assistant SELECT a.id, a.created_at, a.updated_at, a.user_id, u.first_name AS name, u.avatar_url, - a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.languages, a.enabled + a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.citations_enabled, a.languages, a.enabled FROM ai_assistants a JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL WHERE a.id = $1; -- name: get-assistant-by-user-id SELECT a.id, a.created_at, a.updated_at, a.user_id, u.first_name AS name, u.avatar_url, - a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.languages, a.enabled + a.description, a.instructions, a.guardrails, a.expectation, a.tone, a.response_length, a.max_turns, a.fallback_team_id, a.handoff_enabled, a.citations_enabled, a.languages, a.enabled FROM ai_assistants a JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL WHERE a.user_id = $1; @@ -29,13 +29,13 @@ VALUES ('ai_assistant', $1, '', true) RETURNING id; -- name: insert-assistant -INSERT INTO ai_assistants (user_id, description, instructions, guardrails, tone, response_length, max_turns, fallback_team_id, enabled, expectation, handoff_enabled, languages) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +INSERT INTO ai_assistants (user_id, description, instructions, guardrails, tone, response_length, max_turns, fallback_team_id, enabled, expectation, handoff_enabled, languages, citations_enabled) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id; -- name: update-assistant UPDATE ai_assistants -SET description = $2, instructions = $3, guardrails = $4, tone = $5, response_length = $6, max_turns = $7, fallback_team_id = $8, enabled = $9, expectation = $10, handoff_enabled = $11, languages = $12, updated_at = now() +SET description = $2, instructions = $3, guardrails = $4, tone = $5, response_length = $6, max_turns = $7, fallback_team_id = $8, enabled = $9, expectation = $10, handoff_enabled = $11, languages = $12, citations_enabled = $13, updated_at = now() WHERE id = $1; -- name: get-assistant-expectation-by-user-id diff --git a/internal/aiagent/tools.go b/internal/aiagent/tools.go index b7510a20..63f311a6 100644 --- a/internal/aiagent/tools.go +++ b/internal/aiagent/tools.go @@ -70,9 +70,9 @@ type runOutcome struct { } type searchKnowledgeTool struct { - m *Manager - // collect, when set, receives the results each search actually used (preview source attribution). - collect func([]aimodels.SearchResult) + m *Manager + collect func([]aimodels.SearchResult) + citationsEnabled bool } func (t *searchKnowledgeTool) Name() string { return "search_knowledge_base" } @@ -115,7 +115,11 @@ func (t *searchKnowledgeTool) Execute(ctx context.Context, args string) (string, if t.collect != nil { used = append(used, r) } - fmt.Fprintf(&b, "<>\n%s\n<>\n\n", i+1, neutralizeMarkers(r.ChunkText), i+1) + fmt.Fprintf(&b, "<>\n", i+1) + if t.citationsEnabled && r.SourceType == aimodels.SourceHelpArticle { + fmt.Fprintf(&b, "article_id: %d\n", r.SourceID) + } + fmt.Fprintf(&b, "%s\n<>\n\n", neutralizeMarkers(r.ChunkText), i+1) } if t.collect != nil { t.collect(used) diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index 1b76dc44..bd058608 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -35,6 +35,7 @@ const ( // can be split off and sent as a separate message. confirmMarker = "[[confirm]]" suggestionsMarker = "[[suggestions]]" + sourcesMarker = "[[sources]]" // typingRefreshInterval must stay under the widget's 5s typing expiry (TYPING_RECEIVE_TIMEOUT). typingRefreshInterval = 3 * time.Second @@ -285,8 +286,9 @@ func (m *Manager) handle(ctx context.Context, convID int) { m.lo.Debug("ai agent verification state", "conversation_uuid", conv.UUID, "channel", conv.InboxChannel, "contact_type", conv.Contact.Type, "has_email", conv.Contact.Email.String != "", "verified", runVerified) outcome := &runOutcome{} + var hits []aimodels.SearchResult tools := []ai.Tool{ - &searchKnowledgeTool{m: m}, + &searchKnowledgeTool{m: m, citationsEnabled: assistant.CitationsEnabled, collect: func(rs []aimodels.SearchResult) { hits = append(hits, rs...) }}, &resolveTool{m: m, conv: conv, outcome: outcome}, } if assistant.HandoffEnabled { @@ -351,6 +353,7 @@ func (m *Manager) handle(ctx context.Context, convID int) { return } // The model's text answer is the reply to the customer. Handoff and resolve are separate tool actions. + answer, articleIDs := splitArticleSources(answer, hits, assistant.CitationsEnabled) answer, confirm := splitConfirmation(strings.TrimSpace(answer)) answer, answerSuggestions := splitSuggestions(answer) confirm, confirmSuggestions := splitSuggestions(confirm) @@ -361,7 +364,7 @@ func (m *Manager) handle(ctx context.Context, convID int) { } if answer != "" { m.lo.Debug("ai agent replying", "conversation_uuid", conv.UUID, "reply_len", len(answer), "resolved", outcome.resolved) - if err := m.postReply(conv, assistant, answer, suggestedRepliesMeta(answerSuggestions)); err != nil { + if err := m.postReply(conv, assistant, answer, replyMeta(answerSuggestions, articleIDs)); err != nil { m.handoff(conv, assistant, m.i18n.T("ai.agent.handoffError")) return } @@ -545,7 +548,7 @@ func (m *Manager) PreviewReply(ctx context.Context, assistantID int, message str } history := []aimodels.ChatMessage{{Role: aimodels.RoleUser, Content: message}} var hits []aimodels.SearchResult - tools := []ai.Tool{&searchKnowledgeTool{m: m, collect: func(rs []aimodels.SearchResult) { hits = append(hits, rs...) }}} + tools := []ai.Tool{&searchKnowledgeTool{m: m, citationsEnabled: a.CitationsEnabled, collect: func(rs []aimodels.SearchResult) { hits = append(hits, rs...) }}} runCtx, cancel := context.WithTimeout(ctx, livechatRunTimeout) defer cancel() // Preview is search-only: no custom tools (empty allowed set), no built-in, no side effects. @@ -553,11 +556,26 @@ func (m *Manager) PreviewReply(ctx context.Context, assistantID int, message str if err != nil { return "", nil, err } + answer, articleIDs := splitArticleSources(answer, hits, a.CitationsEnabled) main, confirm := splitConfirmation(strings.TrimSpace(answer)) if confirm != "" { main += "\n\n" + confirm } - return main, m.previewSources(hits), nil + sources := m.previewSources(hits) + references, err := m.convo.GetArticleReferences(articleIDs) + if err != nil { + m.lo.Error("error loading preview article references", "error", err) + } + urlByID := make(map[int]string, len(references)) + for _, reference := range references { + urlByID[reference.ID] = reference.URL + } + for i := range sources { + if sources[i].Type == aimodels.SourceHelpArticle { + sources[i].URL = urlByID[sources[i].ID] + } + } + return main, sources, nil } // previewSources dedupes search hits by source, keeping each one's best score. Snippets and help diff --git a/internal/aiagent/worker_test.go b/internal/aiagent/worker_test.go index 2a3e62a3..31017048 100644 --- a/internal/aiagent/worker_test.go +++ b/internal/aiagent/worker_test.go @@ -2,7 +2,11 @@ package aiagent import ( "reflect" + "strings" "testing" + + aimodels "github.com/abhinavxd/libredesk/internal/ai/models" + "github.com/abhinavxd/libredesk/internal/aiagent/models" ) func TestSplitSuggestions(t *testing.T) { @@ -22,3 +26,57 @@ func TestSplitSuggestionsRejectsInvalidPayload(t *testing.T) { t.Fatalf("unexpected result: %q %#v", body, suggestions) } } + +func TestSplitArticleSources(t *testing.T) { + hits := []aimodels.SearchResult{ + {SourceID: 12, SourceType: aimodels.SourceHelpArticle}, + {SourceID: 34, SourceType: aimodels.SourceHelpArticle}, + {SourceID: 34, SourceType: aimodels.SourceHelpArticle}, + {SourceID: 56, SourceType: aimodels.SourceSnippet}, + } + tests := []struct { + name string + answer string + enabled bool + body string + ids []int + }{ + {"used articles", "Answer.\n[[sources]] [34,12]", true, "Answer.", []int{34, 12}}, + {"duplicates and unknown IDs", "Answer.\n[[sources]] [12,12,0,-1,999,56,34]", true, "Answer.", []int{12, 34}}, + {"multiple searches and lines", "Answer.\n[[sources]] [12]\n[[sources]] [12,34]", true, "Answer.", []int{12, 34}}, + {"disabled", "Answer.\n[[sources]] [12]", false, "Answer.", nil}, + {"invalid payload", "Answer.\n[[sources]] [12,", true, "Answer.", nil}, + {"noninteger IDs", "Answer.\n[[sources]] [12,1.5]", true, "Answer.", nil}, + {"empty list", "Answer.\n[[sources]] []", true, "Answer.", nil}, + {"null", "Answer.\n[[sources]] null", true, "Answer.", nil}, + {"no marker", "Hello!", true, "Hello!", nil}, + {"inline marker", "Answer. [[sources]] [12]", true, "Answer.", []int{12}}, + {"next line array", "Answer.\n[[sources]]\n[12,34]", true, "Answer.", []int{12, 34}}, + {"multiline array", "Answer.\n[[sources]] [\n12,\n34\n]\nFollowup.", true, "Answer.\nFollowup.", []int{12, 34}}, + {"same line confirmation", "Answer.\n[[sources]] [12] [[confirm]] Did that help?", true, "Answer.\n[[confirm]] Did that help?", []int{12}}, + {"same line suggestions", "Answer.\n[[sources]] [12] [[suggestions]] [\"Yes\",\"No\"]", true, "Answer.\n[[suggestions]] [\"Yes\",\"No\"]", []int{12}}, + {"invalid array with confirmation", "Answer.\n[[sources]] [12, [[confirm]] Did that help?", true, "Answer.\n[[confirm]] Did that help?", nil}, + {"invalid payload with suggestions", "Answer.\n[[sources]] garbage [[suggestions]] [\"Yes\",\"No\"]", true, "Answer.\n[[suggestions]] [\"Yes\",\"No\"]", nil}, + {"noninteger multiline array", "Answer.\n[[sources]]\n[12,1.5]\n[[confirm]] Did that help?", true, "Answer.\n[[confirm]] Did that help?", nil}, + {"multiple markers on one line", "Answer.\n[[sources]] [12] [[sources]] [12,34] [[confirm]] Did that help?", true, "Answer.\n[[confirm]] Did that help?", []int{12, 34}}, + {"disabled multiline", "Answer.\n[[sources]]\n[12,34] [[confirm]] Did that help?", false, "Answer.\n[[confirm]] Did that help?", nil}, + {"with confirmation", "Answer.\n[[sources]] [12]\n[[confirm]]\nDid that help?\n[[suggestions]] [\"Yes\",\"No\"]", true, "Answer.\n[[confirm]]\nDid that help?\n[[suggestions]] [\"Yes\",\"No\"]", []int{12}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body, ids := splitArticleSources(tt.answer, hits, tt.enabled) + if body != tt.body || !reflect.DeepEqual(ids, tt.ids) { + t.Fatalf("got %q, %v, want %q, %v", body, ids, tt.body, tt.ids) + } + }) + } +} + +func TestArticleCitationPrompt(t *testing.T) { + if strings.Contains(buildSystemPrompt(models.Assistant{}), "[[sources]]") { + t.Fatal("disabled assistant requests article references") + } + if !strings.Contains(buildSystemPrompt(models.Assistant{CitationsEnabled: true}), "[[sources]]") { + t.Fatal("enabled assistant does not request article references") + } +} diff --git a/internal/conversation/citations.go b/internal/conversation/citations.go new file mode 100644 index 00000000..b0653c93 --- /dev/null +++ b/internal/conversation/citations.go @@ -0,0 +1,148 @@ +package conversation + +import ( + "encoding/json" + "fmt" + "html" + "strings" + + "github.com/abhinavxd/libredesk/internal/conversation/models" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" +) + +func (m *Manager) SetArticleReferenceStore(store articleReferenceStore) { + m.articleReferenceStore = store +} + +func (m *Manager) GetArticleReferences(ids []int) ([]hcmodels.ArticleReference, error) { + if m.articleReferenceStore == nil || len(ids) == 0 { + return nil, nil + } + references, err := m.articleReferenceStore.GetArticleReferences(ids) + if err != nil { + return nil, err + } + rootURL, err := m.settingsStore.GetAppRootURL() + if err != nil { + return nil, err + } + resolved := make([]hcmodels.ArticleReference, 0, len(references)) + for _, reference := range references { + reference.URL = reference.PublicURL(rootURL) + if reference.URL != "" { + resolved = append(resolved, reference) + } + } + return resolved, nil +} + +func (m *Manager) RenderArticleReferences(message *models.Message) { + message.Content += m.chatArticleReferencesHTML(m.lookupArticleReferences(message.Meta)[0]) +} + +func (m *Manager) RenderMessagesArticleReferences(messages []models.Message) { + for i, references := range m.lookupArticleReferences(messageMetas(messages)...) { + messages[i].Content += m.chatArticleReferencesHTML(references) + } +} + +func (m *Manager) chatArticleReferencesHTML(references []hcmodels.ArticleReference) string { + return articleReferencesHTML(references, m.i18n.Tc("globals.terms.articleReference", 2 /* n */)) +} + +func (m *Manager) emailArticleReferences(message *models.Message) string { + return emailArticleReferencesHTML(m.lookupArticleReferences(message.Meta)[0]) +} + +// lookupArticleReferences returns each message's references, resolved with one query for all of them. +func (m *Manager) lookupArticleReferences(metas ...json.RawMessage) [][]hcmodels.ArticleReference { + out := make([][]hcmodels.ArticleReference, len(metas)) + idsByMessage := make([][]int, len(metas)) + var ids []int + for i, meta := range metas { + idsByMessage[i] = messageArticleIDs(meta) + ids = append(ids, idsByMessage[i]...) + } + if len(ids) == 0 { + return out + } + references, err := m.GetArticleReferences(ids) + if err != nil { + m.lo.Error("error rendering article references", "error", err) + return out + } + byID := make(map[int]hcmodels.ArticleReference, len(references)) + for _, reference := range references { + byID[reference.ID] = reference + } + for i, messageIDs := range idsByMessage { + for _, id := range messageIDs { + if reference, ok := byID[id]; ok { + out[i] = append(out[i], reference) + } + } + } + return out +} + +func messageMetas(messages []models.Message) []json.RawMessage { + metas := make([]json.RawMessage, len(messages)) + for i := range messages { + metas[i] = messages[i].Meta + } + return metas +} + +func messageArticleIDs(meta json.RawMessage) []int { + var data struct { + AssistantID int `json:"ai_assistant_id"` + ArticleIDs []int `json:"ai_article_ids"` + } + if json.Unmarshal(meta, &data) != nil || data.AssistantID <= 0 { + return nil + } + ids := make([]int, 0, len(data.ArticleIDs)) + seen := map[int]bool{} + for _, id := range data.ArticleIDs { + if id > 0 && !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + return ids +} + +func articleReferencesHTML(references []hcmodels.ArticleReference, label string) string { + if len(references) == 0 { + return "" + } + var b strings.Builder + b.WriteString("

") + b.WriteString(html.EscapeString(label)) + b.WriteString("

") + return b.String() +} + +func emailArticleReferencesHTML(references []hcmodels.ArticleReference) string { + if len(references) == 0 { + return "" + } + var b strings.Builder + b.WriteString("

") + for i, reference := range references { + if i > 0 { + b.WriteByte(' ') + } + fmt.Fprintf(&b, `(%d)`, html.EscapeString(reference.URL), i+1) + } + b.WriteString("

") + return b.String() +} diff --git a/internal/conversation/continuity.go b/internal/conversation/continuity.go index 0230c349..eb4837a5 100644 --- a/internal/conversation/continuity.go +++ b/internal/conversation/continuity.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "html" + "slices" "strings" "time" @@ -202,7 +203,8 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes // Build References and In-Reply-To headers for email threading. references, inReplyTo := m.BuildEmailThreadingHeaders(conv.ID, sourceID) - // Render message template + content, hasReferences := m.renderContinuityEmailContent(unreadMessages, websiteURL) + message.Content = content if err := m.RenderMessageInTemplate(linkedEmailInbox.Channel(), &message); err != nil { // Clean up the inserted message on failure cleanUp = true @@ -241,6 +243,9 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes Meta: message.Meta, CreatedAt: message.CreatedAt, } + if hasReferences { + outbound.AltContent = stringutil.HTML2TextMarkdownLinks(message.Content) + } // Send the email if err := linkedEmailInbox.Send(outbound); err != nil { @@ -270,7 +275,22 @@ func (m *Manager) sendContinuityEmail(conv models.ContinuityConversation, maxMes return nil } -// buildContinuityEmailContent creates email content with conversation summary and unread messages +func (m *Manager) renderContinuityEmailContent(unreadMessages []models.ContinuityUnreadMessage, websiteURL string) (string, bool) { + messages := slices.Clone(unreadMessages) + metas := make([]json.RawMessage, len(messages)) + for i := range messages { + metas[i] = messages[i].Meta + } + hasReferences := false + for i, references := range m.lookupArticleReferences(metas...) { + if len(references) > 0 { + messages[i].Content += emailArticleReferencesHTML(references) + hasReferences = true + } + } + return m.buildContinuityEmailContent(messages, websiteURL), hasReferences +} + func (m *Manager) buildContinuityEmailContent(unreadMessages []models.ContinuityUnreadMessage, websiteURL string) string { var content strings.Builder diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 8524f44c..15ebd06f 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -29,6 +29,7 @@ import ( csatModels "github.com/abhinavxd/libredesk/internal/csat/models" "github.com/abhinavxd/libredesk/internal/dbutil" "github.com/abhinavxd/libredesk/internal/envelope" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" "github.com/abhinavxd/libredesk/internal/inbox" imodels "github.com/abhinavxd/libredesk/internal/inbox/models" mmodels "github.com/abhinavxd/libredesk/internal/media/models" @@ -91,6 +92,10 @@ type notificationDispatcher interface { Send(nmodels.Notification) ([]nmodels.DeliveryResult, error) } +type articleReferenceStore interface { + GetArticleReferences([]int) ([]hcmodels.ArticleReference, error) +} + // Manager handles the operations related to conversations type Manager struct { q queries @@ -101,6 +106,7 @@ type Manager struct { statusStore statusStore priorityStore priorityStore slaStore slaStore + articleReferenceStore articleReferenceStore settingsStore settingsStore csatStore csatStore webhookStore webhookStore @@ -556,6 +562,7 @@ func (c *Manager) GetContactUnreadPreviewMessages(contactID, inboxID, limit int) return nil, envelope.NewError(envelope.GeneralError, c.i18n.T("globals.messages.somethingWentWrong"), nil) } + c.RenderMessagesArticleReferences(messages) previews := make([]models.ChatMessage, 0, len(messages)) for _, message := range messages { c.SignAvatarURL(&message.Author.AvatarURL) @@ -2201,6 +2208,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati m.ProcessCSATStatus(messages) // Generate signed URLs for all attachments. + m.RenderMessagesArticleReferences(messages) chatMessages := make([]models.ChatMessage, 0, len(messages)) for _, msg := range messages { m.SignAvatarURL(&msg.Author.AvatarURL) diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 720e2670..ee5c8e6c 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -188,6 +188,9 @@ func (m *Manager) sendOutgoingMessage(message models.Message) { return } outbound.From = m.emailFromAddress(inb, message, selected) + if len(messageArticleIDs(message.Meta)) > 0 { + outbound.AltContent = stringutil.HTML2TextMarkdownLinks(message.Content) + } // Set "In-Reply-To" and "References" headers for email threading. outbound.References, outbound.InReplyTo = m.BuildEmailThreadingHeaders(message.ConversationID, outbound.SourceID) @@ -304,7 +307,6 @@ func (m *Manager) BuildTemplateData(conversationUUID string, senderID int) (map[ return data, nil } -// RenderMessageInTemplate renders message content in the email base template for sending. func (m *Manager) RenderMessageInTemplate(channel string, message *models.Message) error { switch channel { case inbox.ChannelEmail: @@ -313,22 +315,14 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag return err } - // Expose message meta flags to the template. - var ( - isContinuity bool - meta map[string]any - ) - if len(message.Meta) > 0 && json.Unmarshal(message.Meta, &meta) == nil { - isContinuity, _ = meta["continuity_email"].(bool) - } - data["IsContinuityEmail"] = isContinuity - - message.Content, err = m.template.RenderEmailWithTemplate(data, message.Content) + content := m.emailTemplateContent(message, data) + message.Content, err = m.template.RenderEmailWithTemplate(data, content) if err != nil { m.lo.Error("could not render email content using template", "id", message.ID, "error", err) return fmt.Errorf("could not render email content using template: %w", err) } case inbox.ChannelLiveChat, inbox.ChannelWhatsApp: + m.RenderArticleReferences(message) return nil default: m.lo.Warn("unknown message channel", "channel", channel) @@ -337,6 +331,26 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag return nil } +func (m *Manager) emailTemplateContent(message *models.Message, data map[string]any) string { + var ( + isContinuity bool + meta map[string]any + ) + if len(message.Meta) > 0 && json.Unmarshal(message.Meta, &meta) == nil { + isContinuity, _ = meta["continuity_email"].(bool) + } + data["IsContinuityEmail"] = isContinuity + if isContinuity { + data["ContinuityContent"] = message.Content + return "{{ .ContinuityContent }}" + } + if references := m.emailArticleReferences(message); references != "" { + data["ArticleReferences"] = references + return message.Content + "{{ .ArticleReferences }}" + } + return message.Content +} + // GetConversationMessages retrieves messages for a specific conversation. func (m *Manager) GetConversationMessages(conversationUUID string, page, pageSize int, private *bool, msgTypes []string) ([]models.Message, int, error) { var ( diff --git a/internal/conversation/message_test.go b/internal/conversation/message_test.go index 1ec6a25c..3e6fb16c 100644 --- a/internal/conversation/message_test.go +++ b/internal/conversation/message_test.go @@ -1,11 +1,20 @@ package conversation import ( + "encoding/json" + "errors" + "reflect" "strings" "testing" + "text/template" + "time" "github.com/abhinavxd/libredesk/internal/conversation/models" + hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" "github.com/abhinavxd/libredesk/internal/inbox" + "github.com/abhinavxd/libredesk/internal/stringutil" + "github.com/abhinavxd/libredesk/internal/testutil" + "github.com/zerodha/logf" ) const testUUID = "d0355103-455f-4c7d-b9c7-86e9254fe119" @@ -21,6 +30,32 @@ func (f fromTestInbox) FromAddress() string { return f.from } func (f fromTestInbox) PrimaryAddress() string { return f.primary } func (f fromTestInbox) FromNameTemplate() string { return "" } +type citationArticleStore struct { + references []hcmodels.ArticleReference + err error + lookups *int +} + +type citationSettingsStore struct { + settingsStore + rootURL string + lookups *int +} + +func (s citationArticleStore) GetArticleReferences(ids []int) ([]hcmodels.ArticleReference, error) { + if s.lookups != nil { + (*s.lookups)++ + } + return s.references, s.err +} + +func (s citationSettingsStore) GetAppRootURL() (string, error) { + if s.lookups != nil { + (*s.lookups)++ + } + return s.rootURL, nil +} + func TestImgSrcUploadsPattern(t *testing.T) { tests := []struct { name string @@ -630,3 +665,265 @@ func TestEmailFromAddress(t *testing.T) { }) } } + +func TestMessageArticleIDs(t *testing.T) { + tests := []struct { + meta string + want []int + }{ + {`{"ai_assistant_id":1,"ai_article_ids":[12,12,0,-1,34]}`, []int{12, 34}}, + {`{"ai_assistant_id":1,"ai_article_ids":[]}`, []int{}}, + {`{"ai_article_ids":[12]}`, nil}, + {`{"ai_assistant_id":0,"ai_article_ids":[12]}`, nil}, + {`{"ai_assistant_id":1,"ai_article_ids":["12"]}`, nil}, + {`not-json`, nil}, + } + for _, tt := range tests { + if got := messageArticleIDs(json.RawMessage(tt.meta)); !reflect.DeepEqual(got, tt.want) { + t.Errorf("messageArticleIDs(%s) = %v, want %v", tt.meta, got, tt.want) + } + } +} + +func TestRenderArticleReferences(t *testing.T) { + lo := logf.New(logf.Opts{}) + m := Manager{ + articleReferenceStore: citationArticleStore{references: []hcmodels.ArticleReference{ + {ID: 12, Title: `Refunds + + diff --git a/internal/aiagent/citations.go b/internal/aiagent/citations.go index f6165057..47d87792 100644 --- a/internal/aiagent/citations.go +++ b/internal/aiagent/citations.go @@ -2,11 +2,17 @@ package aiagent import ( "encoding/json" + "regexp" + "strconv" "strings" aimodels "github.com/abhinavxd/libredesk/internal/ai/models" + "github.com/abhinavxd/libredesk/internal/stringutil" + "golang.org/x/net/html" ) +var inlineCitationRegexp = regexp.MustCompile(`\[\[cite:([^\]]*)\]\]`) + func splitArticleSources(answer string, hits []aimodels.SearchResult, enabled bool) (string, []int) { allowed := map[int]bool{} if enabled { @@ -17,6 +23,18 @@ func splitArticleSources(answer string, hits []aimodels.SearchResult, enabled bo } } var articleIDs []int + cited := map[int]bool{} + answer = inlineCitationRegexp.ReplaceAllStringFunc(answer, func(marker string) string { + id, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(marker, "[[cite:"), "]]")) + if err != nil || !allowed[id] { + return "" + } + if !cited[id] { + articleIDs = append(articleIDs, id) + cited[id] = true + } + return marker + }) var body strings.Builder for { before, payload, found := strings.Cut(answer, sourcesMarker) @@ -47,9 +65,9 @@ func splitArticleSources(answer string, hits []aimodels.SearchResult, enabled bo } if err == nil { for _, id := range ids { - if allowed[id] { + if allowed[id] && !cited[id] { articleIDs = append(articleIDs, id) - delete(allowed, id) + cited[id] = true } } } @@ -61,6 +79,31 @@ func splitArticleSources(answer string, hits []aimodels.SearchResult, enabled bo return strings.TrimSpace(body.String()), articleIDs } +func articleCitationHTML(text string, articleIDs []int) string { + allowed := make(map[int]bool, len(articleIDs)) + for _, id := range articleIDs { + allowed[id] = true + } + tokens := html.NewTokenizer(strings.NewReader(stringutil.Markdown2HTML(text))) + var content strings.Builder + for { + kind := tokens.Next() + if kind == html.ErrorToken { + break + } + raw := string(tokens.Raw()) + raw = inlineCitationRegexp.ReplaceAllStringFunc(raw, func(marker string) string { + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(marker, "[[cite:"), "]]")) + if kind != html.TextToken || !allowed[id] { + return "" + } + return "" + }) + content.WriteString(raw) + } + return content.String() +} + func replyMeta(suggestions []string, articleIDs []int) map[string]any { meta := suggestedRepliesMeta(suggestions) if len(articleIDs) > 0 { @@ -68,3 +111,17 @@ func replyMeta(suggestions []string, articleIDs []int) map[string]any { } return meta } + +func numberedCitationText(text string, articleIDs []int) string { + numbers := make(map[int]int, len(articleIDs)) + for i, id := range articleIDs { + numbers[id] = i + 1 + } + return inlineCitationRegexp.ReplaceAllStringFunc(text, func(marker string) string { + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(marker, "[[cite:"), "]]")) + if number := numbers[id]; number > 0 { + return "(" + strconv.Itoa(number) + ")" + } + return "" + }) +} diff --git a/internal/aiagent/prompt.go b/internal/aiagent/prompt.go index 58d3806d..0dd30777 100644 --- a/internal/aiagent/prompt.go +++ b/internal/aiagent/prompt.go @@ -168,7 +168,7 @@ func buildSystemPrompt(a models.Assistant) string { b.WriteString(instr) } if a.CitationsEnabled { - b.WriteString("\n\nWhen your answer uses help center articles from this turn's search results, add a separate line in this exact form: [[sources]] [12,34]. Include only the article_id values of articles you actually used, once each. Never cite snippets or articles from earlier turns, and never invent an article ID or URL. Put this line before [[confirm]] or [[suggestions]] when present. Omit it when no help center article supports the answer, including greetings, clarifying questions, refusals and handoffs. The customer never sees this line. Article links are added by the application.") + b.WriteString("\n\nCite help center articles from this turn's search results directly after the sentence they support, using [[cite:12]] where 12 is the article_id. Example: You can use JWT authentication.[[cite:12]] You can also log out.[[cite:34]] Repeat a marker when the same article supports another sentence. Cite only articles you actually used. Never cite snippets or articles from earlier turns, and never invent an article ID or URL. Do not put citation markers inside links, code, [[confirm]] or [[suggestions]]. Omit citations for greetings, clarifying questions, refusals and handoffs. The application replaces each marker with a numbered article link.") } if guard := strings.TrimSpace(a.Guardrails); guard != "" { b.WriteString("\n\nGuardrails (never violate these):\n") diff --git a/internal/aiagent/worker.go b/internal/aiagent/worker.go index bd058608..7f6b62a8 100644 --- a/internal/aiagent/worker.go +++ b/internal/aiagent/worker.go @@ -438,7 +438,9 @@ func (m *Manager) postReply(conv cmodels.Conversation, assistant models.Assistan meta = map[string]any{} } meta["ai_assistant_id"] = assistant.ID - if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, stringutil.Markdown2HTML(text), to, nil, nil, "" /** sendFrom **/, meta); err != nil { + articleIDs, _ := meta["ai_article_ids"].([]int) + content := articleCitationHTML(text, articleIDs) + if _, err := m.convo.QueueReply(nil, conv.InboxID, assistant.UserID, conv.ContactID, conv.UUID, content, to, nil, nil, "" /** sendFrom **/, meta); err != nil { m.lo.Error("error sending assistant reply", "conversation_uuid", conv.UUID, "error", err) return err } @@ -575,7 +577,13 @@ func (m *Manager) PreviewReply(ctx context.Context, assistantID int, message str sources[i].URL = urlByID[sources[i].ID] } } - return main, sources, nil + var resolvedIDs []int + for _, id := range articleIDs { + if urlByID[id] != "" { + resolvedIDs = append(resolvedIDs, id) + } + } + return numberedCitationText(main, resolvedIDs), sources, nil } // previewSources dedupes search hits by source, keeping each one's best score. Snippets and help diff --git a/internal/aiagent/worker_test.go b/internal/aiagent/worker_test.go index 31017048..318d194b 100644 --- a/internal/aiagent/worker_test.go +++ b/internal/aiagent/worker_test.go @@ -7,6 +7,7 @@ import ( aimodels "github.com/abhinavxd/libredesk/internal/ai/models" "github.com/abhinavxd/libredesk/internal/aiagent/models" + "github.com/abhinavxd/libredesk/internal/stringutil" ) func TestSplitSuggestions(t *testing.T) { @@ -41,6 +42,11 @@ func TestSplitArticleSources(t *testing.T) { body string ids []int }{ + {"inline claims", "Use JWT.[[cite:12]] Log out.[[cite:34]]", true, "Use JWT.[[cite:12]] Log out.[[cite:34]]", []int{12, 34}}, + {"repeated inline article", "Use JWT.[[cite:34]] More JWT.[[cite:34]]", true, "Use JWT.[[cite:34]] More JWT.[[cite:34]]", []int{34}}, + {"invalid inline articles", "Answer.[[cite:999]][[cite:56]][[cite:0]][[cite:-1]][[cite:no]]", true, "Answer.", nil}, + {"inline disabled", "Answer.[[cite:12]]", false, "Answer.", nil}, + {"mixed citation styles", "Answer.[[cite:34]]\n[[sources]] [12,34]", true, "Answer.[[cite:34]]", []int{34, 12}}, {"used articles", "Answer.\n[[sources]] [34,12]", true, "Answer.", []int{34, 12}}, {"duplicates and unknown IDs", "Answer.\n[[sources]] [12,12,0,-1,999,56,34]", true, "Answer.", []int{12, 34}}, {"multiple searches and lines", "Answer.\n[[sources]] [12]\n[[sources]] [12,34]", true, "Answer.", []int{12, 34}}, @@ -73,10 +79,33 @@ func TestSplitArticleSources(t *testing.T) { } func TestArticleCitationPrompt(t *testing.T) { - if strings.Contains(buildSystemPrompt(models.Assistant{}), "[[sources]]") { + if strings.Contains(buildSystemPrompt(models.Assistant{}), "[[cite:") { t.Fatal("disabled assistant requests article references") } - if !strings.Contains(buildSystemPrompt(models.Assistant{CitationsEnabled: true}), "[[sources]]") { + if !strings.Contains(buildSystemPrompt(models.Assistant{CitationsEnabled: true}), "[[cite:") { t.Fatal("enabled assistant does not request article references") } } + +func TestArticleCitationHTML(t *testing.T) { + text := "Use **JWT**.[[cite:12]]\nDéconnectez-vous.[[cite:34]][[cite:999]]" + content := articleCitationHTML(text, []int{12, 34}) + want := "

Use JWT.
\nDéconnectez-vous.

\n" + if content != want { + t.Fatalf("got %q, want %q", content, want) + } + plain := stringutil.HTML2Text(content) + if strings.Contains(plain, "cite") || !strings.Contains(plain, "Déconnectez-vous.") { + t.Fatalf("citation markers changed stored plain text: %q", plain) + } + if got := articleCitationHTML("Answer.[[cite:12]]", nil /* articleIDs */); got != "

Answer.

\n" { + t.Fatalf("uncited reply retained a marker: %q", got) + } + link := articleCitationHTML(`[Article](https://example.com "[[cite:12]]")`, []int{12}) + if strings.Contains(link, "ld-cite") || strings.Contains(link, "[[cite:") { + t.Fatalf("citation inserted inside an HTML attribute: %q", link) + } + if got := numberedCitationText("JWT.[[cite:12]] Logout.[[cite:34]][[cite:999]]", []int{12, 34}); got != "JWT.(1) Logout.(2)" { + t.Fatalf("unexpected preview: %q", got) + } +} diff --git a/internal/conversation/citations.go b/internal/conversation/citations.go index b0653c93..cc8088f4 100644 --- a/internal/conversation/citations.go +++ b/internal/conversation/citations.go @@ -4,12 +4,16 @@ import ( "encoding/json" "fmt" "html" + "regexp" + "strconv" "strings" "github.com/abhinavxd/libredesk/internal/conversation/models" hcmodels "github.com/abhinavxd/libredesk/internal/helpcenter/models" ) +var articleCitationRegexp = regexp.MustCompile(``) + func (m *Manager) SetArticleReferenceStore(store articleReferenceStore) { m.articleReferenceStore = store } @@ -37,19 +41,15 @@ func (m *Manager) GetArticleReferences(ids []int) ([]hcmodels.ArticleReference, } func (m *Manager) RenderArticleReferences(message *models.Message) { - message.Content += m.chatArticleReferencesHTML(m.lookupArticleReferences(message.Meta)[0]) + message.Content = chatArticleReferencesHTML(message.Content, m.lookupArticleReferences(message.Meta)[0]) } func (m *Manager) RenderMessagesArticleReferences(messages []models.Message) { for i, references := range m.lookupArticleReferences(messageMetas(messages)...) { - messages[i].Content += m.chatArticleReferencesHTML(references) + messages[i].Content = chatArticleReferencesHTML(messages[i].Content, references) } } -func (m *Manager) chatArticleReferencesHTML(references []hcmodels.ArticleReference) string { - return articleReferencesHTML(references, m.i18n.Tc("globals.terms.articleReference", 2 /* n */)) -} - func (m *Manager) emailArticleReferences(message *models.Message) string { return emailArticleReferencesHTML(m.lookupArticleReferences(message.Meta)[0]) } @@ -112,6 +112,40 @@ func messageArticleIDs(meta json.RawMessage) []int { return ids } +func chatArticleReferencesHTML(content string, references []hcmodels.ArticleReference) string { + byID := make(map[int]int, len(references)) + for i, reference := range references { + byID[reference.ID] = i + } + used := map[int]bool{} + content = articleCitationRegexp.ReplaceAllStringFunc(content, func(marker string) string { + id, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(marker, "")) + i, ok := byID[id] + if !ok { + return "" + } + used[id] = true + return articleReferenceLinkHTML(references[i], i+1) + }) + var remaining strings.Builder + for i, reference := range references { + if !used[reference.ID] { + if remaining.Len() > 0 { + remaining.WriteByte(' ') + } + remaining.WriteString(articleReferenceLinkHTML(reference, i+1)) + } + } + if remaining.Len() > 0 { + content += "

" + remaining.String() + "

" + } + return content +} + +func articleReferenceLinkHTML(reference hcmodels.ArticleReference, number int) string { + return fmt.Sprintf(`(%d)`, html.EscapeString(reference.URL), html.EscapeString(reference.Title), html.EscapeString(reference.Title), number) +} + func articleReferencesHTML(references []hcmodels.ArticleReference, label string) string { if len(references) == 0 { return "" diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 15ebd06f..9aaf8cb6 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -581,6 +581,7 @@ func (c *Manager) GetContactUnreadPreviewMessages(contactID, inboxID, limit int) CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, + ContentType: message.ContentType, Author: author, Attachments: message.Attachments, Meta: message.Meta, @@ -2230,6 +2231,7 @@ func (m *Manager) BuildWidgetConversationResponse(conversation models.Conversati CreatedAt: msg.CreatedAt, Content: msg.Content, TextContent: msg.TextContent, + ContentType: msg.ContentType, ConversationUUID: msg.ConversationUUID, Meta: msg.Meta, Author: author, diff --git a/internal/conversation/message.go b/internal/conversation/message.go index ee5c8e6c..0e0b3147 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -332,6 +332,7 @@ func (m *Manager) RenderMessageInTemplate(channel string, message *models.Messag } func (m *Manager) emailTemplateContent(message *models.Message, data map[string]any) string { + content := articleCitationRegexp.ReplaceAllString(message.Content, "") var ( isContinuity bool meta map[string]any @@ -341,14 +342,14 @@ func (m *Manager) emailTemplateContent(message *models.Message, data map[string] } data["IsContinuityEmail"] = isContinuity if isContinuity { - data["ContinuityContent"] = message.Content + data["ContinuityContent"] = content return "{{ .ContinuityContent }}" } if references := m.emailArticleReferences(message); references != "" { data["ArticleReferences"] = references - return message.Content + "{{ .ArticleReferences }}" + return content + "{{ .ArticleReferences }}" } - return message.Content + return content } // GetConversationMessages retrieves messages for a specific conversation. @@ -1694,6 +1695,7 @@ func (m *Manager) broadcastMessageToWidgetClients(message *models.Message) { CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, + ContentType: message.ContentType, Author: message.Author, Attachments: message.Attachments, Meta: message.Meta, diff --git a/internal/conversation/message_test.go b/internal/conversation/message_test.go index 3e6fb16c..d2f84534 100644 --- a/internal/conversation/message_test.go +++ b/internal/conversation/message_test.go @@ -699,7 +699,7 @@ func TestRenderArticleReferences(t *testing.T) { meta := json.RawMessage(`{"ai_assistant_id":1,"ai_article_ids":[12,34]}`) message := models.Message{Content: "

Answer.

", Meta: meta} m.RenderArticleReferences(&message) - for _, part := range []string{"Article references", "https://desk.example.com/hc/support/en/articles/refunds", "https://help.example.com/fr/articles/delivery", "Refunds <script> & "policy""} { + for _, part := range []string{"(1)", "(2)", "https://desk.example.com/hc/support/en/articles/refunds", "https://help.example.com/fr/articles/delivery", "Refunds <script> & "policy""} { if !strings.Contains(message.Content, part) { t.Errorf("rendered references missing %q: %s", part, message.Content) } @@ -732,6 +732,52 @@ func TestRenderArticleReferences(t *testing.T) { } } +func TestInlineArticleReferences(t *testing.T) { + references := []hcmodels.ArticleReference{ + {ID: 12, Title: "JWT {{name}}", URL: "https://help.example.com/jwt"}, + {ID: 34, Title: "Logout", URL: "https://help.example.com/logout"}, + } + content := "

Use JWT. Log out. JWT again.

" + rendered := chatArticleReferencesHTML(content, references) + for _, part := range []string{ + "Use JWT.", + "Log out.", + "JWT again.", + `title="JWT {{name}}"`, + ">(2)", + } { + if !strings.Contains(rendered, part) { + t.Errorf("inline references missing %q: %s", part, rendered) + } + } + if strings.Count(rendered, ">(1)") != 2 || strings.Contains(rendered, "ld-cite:") || strings.Contains(rendered, "
    ") { + t.Fatalf("unexpected inline references: %s", rendered) + } + if got := chatArticleReferencesHTML(content, nil /* references */); got != "

    Use JWT. Log out. JWT again.

    " { + t.Fatalf("unavailable references changed the answer: %s", got) + } + if got := chatArticleReferencesHTML("

    Answer.

    ", references[:1]); strings.Contains(got, "ld-cite:") || !strings.Contains(got, "

    ") { + t.Fatalf("missing placement did not produce a numbered footer: %s", got) + } + lo := logf.New(logf.Opts{}) + m := Manager{ + articleReferenceStore: citationArticleStore{references: []hcmodels.ArticleReference{ + {ID: 12, Title: "JWT", HelpCenterSlug: "support", Locale: "en", Slug: "jwt"}, + }}, + settingsStore: citationSettingsStore{rootURL: "https://desk.example.com"}, + lo: &lo, + } + message := models.Message{Content: content, Meta: json.RawMessage(`{"ai_assistant_id":1,"ai_article_ids":[12,34]}`)} + data := map[string]any{} + mailContent := m.emailTemplateContent(&message, data) + if strings.Contains(mailContent, "ld-cite:") || strings.Contains(mailContent, "") { + t.Fatalf("email retained widget markers: %s", mailContent) + } + if !strings.Contains(data["ArticleReferences"].(string), ">(1)") { + t.Fatal("email lost its numbered references") + } +} + func TestArticleReferencePublicURL(t *testing.T) { reference := hcmodels.ArticleReference{HelpCenterSlug: "support", Locale: "en", Slug: "refund policy"} for _, tt := range []struct{ root, custom, want string }{ diff --git a/internal/conversation/models/models.go b/internal/conversation/models/models.go index 92c2aac9..59c120cb 100644 --- a/internal/conversation/models/models.go +++ b/internal/conversation/models/models.go @@ -117,6 +117,7 @@ type ChatMessage struct { CreatedAt time.Time `json:"created_at"` Content string `json:"content"` TextContent string `json:"text_content"` + ContentType string `json:"content_type"` Author MessageAuthor `json:"author"` Attachments attachment.Attachments `json:"attachments"` Meta json.RawMessage `json:"meta"` diff --git a/internal/inbox/channel/livechat/livechat.go b/internal/inbox/channel/livechat/livechat.go index c4a9385c..a8d0b64d 100644 --- a/internal/inbox/channel/livechat/livechat.go +++ b/internal/inbox/channel/livechat/livechat.go @@ -442,6 +442,7 @@ func (lc *LiveChat) Send(message models.OutboundMessage) error { CreatedAt: message.CreatedAt, Content: message.Content, TextContent: message.TextContent, + ContentType: message.ContentType, Meta: message.Meta, Author: models.MessageAuthor{ ID: message.SenderID, From b04590929cf0528ff0dcc6cd34fff68283817634 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 Oct 2026 21:37:03 +0530 Subject: [PATCH 3/9] improve widget citations and typing dots --- .../src/components/CitedMessageContent.vue | 65 ++++++++++++------- .../TypingIndicator/TypingIndicator.vue | 33 +++++++++- i18n/en-US.json | 1 + 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/frontend/apps/widget/src/components/CitedMessageContent.vue b/frontend/apps/widget/src/components/CitedMessageContent.vue index c6ac7cf0..7f88e5b1 100644 --- a/frontend/apps/widget/src/components/CitedMessageContent.vue +++ b/frontend/apps/widget/src/components/CitedMessageContent.vue @@ -4,12 +4,7 @@ import { useI18n } from 'vue-i18n' import { sanitize, allowedCssProperties } from 'lettersanitizer' import { BookOpen } from 'lucide-vue-next' import { Button } from '@shared-ui/components/ui/button' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from '@shared-ui/components/ui/tooltip' +import { HoverCardRoot, HoverCardContent, HoverCardPortal, HoverCardTrigger } from 'reka-ui' const props = defineProps({ html: { type: String, required: true } @@ -46,12 +41,12 @@ const renderNode = (node) => { title ) { return h( - Tooltip, - {}, + HoverCardRoot, + { openDelay: 150, closeDelay: 150 }, { default: () => [ h( - TooltipTrigger, + HoverCardTrigger, { asChild: true }, { default: () => @@ -59,35 +54,61 @@ const renderNode = (node) => { Button, { as: 'a', - variant: 'ghost', + variant: 'secondary', href: attributes.href, target: '_blank', rel: 'noopener noreferrer', 'aria-label': `${node.textContent} ${title}`, class: - 'h-auto px-0.5 py-0 text-[10px] leading-none !text-muted-foreground hover:!text-foreground' + 'mx-0.5 h-auto rounded-full bg-foreground/10 px-1.5 py-0.5 text-sm leading-none !text-foreground hover:bg-foreground/15' }, { default: () => node.textContent } ) } ), h( - TooltipContent, - { class: 'max-w-60 whitespace-normal break-words' }, + HoverCardPortal, + {}, { - default: () => [ - h('span', { class: 'mb-1 flex items-center gap-1 opacity-70' }, [ - h(BookOpen, { class: 'h-3 w-3', 'aria-hidden': true }), - t('globals.terms.article') - ]), - h('span', { class: 'font-medium' }, title) - ] + default: () => + h( + HoverCardContent, + { asChild: true, side: 'top', sideOffset: 6, class: 'z-50' }, + { + default: () => + h( + Button, + { + as: 'a', + variant: 'secondary', + href: attributes.href, + target: '_blank', + rel: 'noopener noreferrer', + 'aria-label': title, + class: + 'h-auto max-w-60 flex-col items-start gap-1 whitespace-normal break-words p-3 text-left text-xs shadow-md' + }, + { + default: () => [ + h('span', { class: 'flex items-center gap-1 opacity-70' }, [ + h(BookOpen, { class: 'h-3 w-3', 'aria-hidden': true }), + t('globals.terms.helpCenterArticle') + ]), + h('span', { class: 'font-medium' }, title) + ] + } + ) + } + ) } ) ] } ) } + if (node.localName === 'sup' && node.querySelector('a[class$="_ld-article-citation"]')) { + return h('span', attributes, [...node.childNodes].map(renderNode)) + } if (attributes.target === '_blank') attributes.rel = 'noopener noreferrer' return h(node.localName, attributes, [...node.childNodes].map(renderNode)) } @@ -101,7 +122,5 @@ const ContentNodes = () => diff --git a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue index de273d53..048e560b 100644 --- a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue +++ b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue @@ -4,9 +4,13 @@ :class="bubble ? 'rounded-2xl bg-muted' : ''" >

    -
    -
    -
    +
@@ -16,3 +20,26 @@ defineProps({ bubble: { type: Boolean, default: false } }) + + diff --git a/i18n/en-US.json b/i18n/en-US.json index 50c81fea..0e910492 100644 --- a/i18n/en-US.json +++ b/i18n/en-US.json @@ -1183,6 +1183,7 @@ "globals.terms.headerType": "Header type", "globals.terms.help": "Help", "globals.terms.helpCenter": "Help center | Help centers", + "globals.terms.helpCenterArticle": "Help center article | Help center articles", "globals.terms.home": "Home", "globals.terms.homeScreen": "Home screen", "globals.terms.homeScreenApp": "Home screen app | Home screen apps", From 08f2cc9c356e59f0911a33a81fba85825e73022a Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 Oct 2026 21:38:27 +0530 Subject: [PATCH 4/9] remove parentheses from widget citation badges --- frontend/apps/widget/src/components/CitedMessageContent.vue | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/apps/widget/src/components/CitedMessageContent.vue b/frontend/apps/widget/src/components/CitedMessageContent.vue index 7f88e5b1..c929dcc5 100644 --- a/frontend/apps/widget/src/components/CitedMessageContent.vue +++ b/frontend/apps/widget/src/components/CitedMessageContent.vue @@ -40,6 +40,7 @@ const renderNode = (node) => { [...node.classList].some((name) => name.endsWith('_ld-article-citation')) && title ) { + const number = node.textContent.replace(/^\((\d+)\)$/, '$1') return h( HoverCardRoot, { openDelay: 150, closeDelay: 150 }, @@ -58,11 +59,11 @@ const renderNode = (node) => { href: attributes.href, target: '_blank', rel: 'noopener noreferrer', - 'aria-label': `${node.textContent} ${title}`, + 'aria-label': `${number} ${title}`, class: 'mx-0.5 h-auto rounded-full bg-foreground/10 px-1.5 py-0.5 text-sm leading-none !text-foreground hover:bg-foreground/15' }, - { default: () => node.textContent } + { default: () => number } ) } ), From 22b3591adc4e11c032516c5c30ea325569615b51 Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 Oct 2026 21:41:43 +0530 Subject: [PATCH 5/9] open citation articles through the popup --- .../apps/widget/src/components/CitedMessageContent.vue | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frontend/apps/widget/src/components/CitedMessageContent.vue b/frontend/apps/widget/src/components/CitedMessageContent.vue index c929dcc5..813807d3 100644 --- a/frontend/apps/widget/src/components/CitedMessageContent.vue +++ b/frontend/apps/widget/src/components/CitedMessageContent.vue @@ -54,11 +54,9 @@ const renderNode = (node) => { h( Button, { - as: 'a', + as: 'button', + type: 'button', variant: 'secondary', - href: attributes.href, - target: '_blank', - rel: 'noopener noreferrer', 'aria-label': `${number} ${title}`, class: 'mx-0.5 h-auto rounded-full bg-foreground/10 px-1.5 py-0.5 text-sm leading-none !text-foreground hover:bg-foreground/15' @@ -87,7 +85,7 @@ const renderNode = (node) => { rel: 'noopener noreferrer', 'aria-label': title, class: - 'h-auto max-w-60 flex-col items-start gap-1 whitespace-normal break-words p-3 text-left text-xs shadow-md' + 'h-auto max-w-60 flex-col items-start gap-1 whitespace-normal break-words p-3 text-left text-xs shadow-md hover:bg-accent' }, { default: () => [ From 00064990f30f163fbfd6114c55968068ab4527bc Mon Sep 17 00:00:00 2001 From: Abhinav Raut Date: Sat, 10 Oct 2026 21:47:28 +0530 Subject: [PATCH 6/9] adjust citation links and typing bubble height --- .../src/components/CitedMessageContent.vue | 28 ++++++++++--------- .../TypingIndicator/TypingIndicator.vue | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/frontend/apps/widget/src/components/CitedMessageContent.vue b/frontend/apps/widget/src/components/CitedMessageContent.vue index 813807d3..aac5ed48 100644 --- a/frontend/apps/widget/src/components/CitedMessageContent.vue +++ b/frontend/apps/widget/src/components/CitedMessageContent.vue @@ -72,31 +72,33 @@ const renderNode = (node) => { default: () => h( HoverCardContent, - { asChild: true, side: 'top', sideOffset: 6, class: 'z-50' }, { - default: () => + side: 'top', + sideOffset: 6, + class: + 'z-50 flex max-w-60 flex-col items-start gap-1 rounded-md border bg-popover p-3 text-xs text-popover-foreground shadow-md' + }, + { + default: () => [ + h('span', { class: 'flex items-center gap-1 text-muted-foreground' }, [ + h(BookOpen, { class: 'h-3 w-3', 'aria-hidden': true }), + t('globals.terms.helpCenterArticle') + ]), h( Button, { as: 'a', - variant: 'secondary', + variant: 'link', href: attributes.href, target: '_blank', rel: 'noopener noreferrer', 'aria-label': title, class: - 'h-auto max-w-60 flex-col items-start gap-1 whitespace-normal break-words p-3 text-left text-xs shadow-md hover:bg-accent' + 'h-auto justify-start whitespace-normal break-words p-0 text-left text-xs text-popover-foreground no-underline hover:text-popover-foreground hover:underline' }, - { - default: () => [ - h('span', { class: 'flex items-center gap-1 opacity-70' }, [ - h(BookOpen, { class: 'h-3 w-3', 'aria-hidden': true }), - t('globals.terms.helpCenterArticle') - ]), - h('span', { class: 'font-medium' }, title) - ] - } + { default: () => title } ) + ] } ) } diff --git a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue index 048e560b..be290e76 100644 --- a/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue +++ b/frontend/shared-ui/components/TypingIndicator/TypingIndicator.vue @@ -1,7 +1,7 @@