diff --git a/llm/prompts.go b/llm/prompts.go index 749b984ee..d87c64f64 100644 --- a/llm/prompts.go +++ b/llm/prompts.go @@ -7,14 +7,17 @@ import ( "errors" "fmt" "io/fs" + "path" "strings" "text/template" + "unicode" "github.com/mattermost/mattermost-plugin-agents/v2/format" ) type Prompts struct { templates *template.Template + locales map[string]*template.Template } const PromptExtension = "tmpl" @@ -27,21 +30,105 @@ func EscapePromptContent(s string) string { return s } -func NewPrompts(input fs.FS) (*Prompts, error) { - funcMap := template.FuncMap{ +func promptFuncMap() template.FuncMap { + return template.FuncMap{ "escapeContent": EscapePromptContent, "formatTime": format.TimeFromMillis, } +} + +func NewPrompts(input fs.FS) (*Prompts, error) { + funcMap := promptFuncMap() templates, err := template.New("").Funcs(funcMap).ParseFS(input, "*.tmpl") if err != nil { return nil, fmt.Errorf("unable to parse prompt templates: %w", err) } + locales, err := parseLocalizedPrompts(input, templates, funcMap) + if err != nil { + return nil, err + } + return &Prompts{ templates: templates, + locales: locales, }, nil } +func parseLocalizedPrompts(input fs.FS, base *template.Template, funcMap template.FuncMap) (map[string]*template.Template, error) { + entries, err := fs.ReadDir(input, ".") + if err != nil { + return nil, fmt.Errorf("unable to list prompt templates: %w", err) + } + + locales := make(map[string]*template.Template) + for _, entry := range entries { + if !entry.IsDir() || !isPromptLanguageDir(entry.Name()) { + continue + } + + cloned, cloneErr := base.Clone() + if cloneErr != nil { + return nil, fmt.Errorf("unable to clone prompt templates for %s: %w", entry.Name(), cloneErr) + } + cloned.Funcs(funcMap) + + walkErr := fs.WalkDir(input, entry.Name(), func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || !strings.HasSuffix(p, "."+PromptExtension) { + return nil + } + content, readErr := fs.ReadFile(input, p) + if readErr != nil { + return readErr + } + _, parseErr := cloned.New(path.Base(p)).Parse(string(content)) + return parseErr + }) + if walkErr != nil { + return nil, fmt.Errorf("unable to parse %s prompt templates: %w", entry.Name(), walkErr) + } + + locales[entry.Name()] = cloned + } + + return locales, nil +} + +func isPromptLanguageDir(name string) bool { + if len(name) < 2 || len(name) > 3 { + return false + } + for _, r := range name { + if r < 'a' || r > 'z' { + return false + } + } + return true +} + +// PromptLanguage returns the BCP 47 primary language subtag from the +// requesting user's Mattermost locale (fr from fr_FR). Empty means English defaults. +func PromptLanguage(ctx *Context) string { + if ctx == nil || ctx.RequestingUser == nil { + return "" + } + loc := strings.ToLower(strings.ReplaceAll(ctx.RequestingUser.Locale, "-", "_")) + loc = strings.TrimSpace(loc) + if loc == "" { + return "" + } + lang, _, _ := strings.Cut(loc, "_") + for _, r := range lang { + if !unicode.IsLetter(r) { + return "" + } + } + return lang +} + func withPromptExtension(filename string) string { return filename + "." + PromptExtension } @@ -66,8 +153,20 @@ func (p *Prompts) FormatString(templateCode string, data any) (string, error) { return strings.TrimSpace(out.String()), nil } +func (p *Prompts) lookup(templateName string, context *Context) *template.Template { + name := withPromptExtension(templateName) + if lang := PromptLanguage(context); lang != "" { + if localized := p.locales[lang]; localized != nil { + if tmpl := localized.Lookup(name); tmpl != nil { + return tmpl + } + } + } + return p.templates.Lookup(name) +} + func (p *Prompts) Format(templateName string, context *Context) (string, error) { - tmpl := p.templates.Lookup(withPromptExtension(templateName)) + tmpl := p.lookup(templateName, context) if tmpl == nil { return "", errors.New("template not found") } diff --git a/llm/prompts_test.go b/llm/prompts_test.go index 755f05543..6c577f03e 100644 --- a/llm/prompts_test.go +++ b/llm/prompts_test.go @@ -7,6 +7,7 @@ import ( "testing" "testing/fstest" + "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -125,3 +126,70 @@ func TestEscapePromptContent(t *testing.T) { }) } } + +func TestPromptLanguage(t *testing.T) { + assert.Equal(t, "", PromptLanguage(nil)) + assert.Equal(t, "", PromptLanguage(NewContext())) + assert.Equal(t, "fr", PromptLanguage(NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr"} + }))) + assert.Equal(t, "fr", PromptLanguage(NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr_FR"} + }))) + assert.Equal(t, "fr", PromptLanguage(NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr-FR"} + }))) + assert.Equal(t, "de", PromptLanguage(NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "de_DE"} + }))) +} + +func TestFormatUsesLocalizedTemplates(t *testing.T) { + prompts, err := NewPrompts(fstest.MapFS{ + "meeting_summary_system.tmpl": &fstest.MapFile{Data: []byte("EN summary")}, + "fr/meeting_summary_system.tmpl": &fstest.MapFile{Data: []byte("FR summary ## Résumé")}, + }) + require.NoError(t, err) + + en, err := prompts.Format("meeting_summary_system", NewContext()) + require.NoError(t, err) + assert.Equal(t, "EN summary", en) + + fr, err := prompts.Format("meeting_summary_system", NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr"} + })) + require.NoError(t, err) + assert.Equal(t, "FR summary ## Résumé", fr) + + frFR, err := prompts.Format("meeting_summary_system", NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr_FR"} + })) + require.NoError(t, err) + assert.Equal(t, "FR summary ## Résumé", frFR) + + de, err := prompts.Format("meeting_summary_system", NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "de"} + })) + require.NoError(t, err) + assert.Equal(t, "EN summary", de) +} + +func TestFormatLocalizedNestedTemplates(t *testing.T) { + prompts, err := NewPrompts(fstest.MapFS{ + "locale.tmpl": &fstest.MapFile{Data: []byte("EN locale")}, + "meeting_summary_system.tmpl": &fstest.MapFile{Data: []byte("body {{template \"locale.tmpl\" .}}")}, + "fr/locale.tmpl": &fstest.MapFile{Data: []byte("FR locale")}, + "fr/meeting_summary_system.tmpl": &fstest.MapFile{Data: []byte("corps {{template \"locale.tmpl\" .}}")}, + }) + require.NoError(t, err) + + en, err := prompts.Format("meeting_summary_system", NewContext()) + require.NoError(t, err) + assert.Equal(t, "body EN locale", en) + + fr, err := prompts.Format("meeting_summary_system", NewContext(func(c *Context) { + c.RequestingUser = &model.User{Locale: "fr"} + })) + require.NoError(t, err) + assert.Equal(t, "corps FR locale", fr) +} diff --git a/prompts/fr/direct_message_question_system.tmpl b/prompts/fr/direct_message_question_system.tmpl new file mode 100644 index 000000000..d2179543a --- /dev/null +++ b/prompts/fr/direct_message_question_system.tmpl @@ -0,0 +1,2 @@ +{{template "standard_personality_without_locale.tmpl" .}} +{{template "locale.tmpl" .}} diff --git a/prompts/fr/find_action_items_system.tmpl b/prompts/fr/find_action_items_system.tmpl new file mode 100644 index 000000000..d3def566c --- /dev/null +++ b/prompts/fr/find_action_items_system.tmpl @@ -0,0 +1,13 @@ +{{template "standard_personality.tmpl" .}} +Analyse le fil pour trouver les actions. Une action n'existe QUE si quelqu'un : +- S'engage explicitement : « Je vais corriger ça », « Je relis cette PR » +- Assigne une tâche : « Tu peux mettre à jour la doc ? », « @user merci de déployer » +- Fixe une échéance ou une prochaine étape : « Il faut livrer vendredi » + +Discussions, opinions, débats, suggestions et questions ne sont PAS des actions. + +La plupart des conversations n'ont pas d'actions. Si le fil n'est que des opinions ou un débat sans engagement, réponds UNIQUEMENT : + +« Il n'y a pas d'actions dans ce fil. » + +Ne liste des actions que si quelqu'un s'est engagé ou s'est vu assigner une tâche précise. diff --git a/prompts/fr/find_action_items_user.tmpl b/prompts/fr/find_action_items_user.tmpl new file mode 100644 index 000000000..72abaa3d8 --- /dev/null +++ b/prompts/fr/find_action_items_user.tmpl @@ -0,0 +1,7 @@ +Les messages sont ci-dessous : + +---- Début des messages ---- +{{.Parameters.Thread}} +---- Fin des messages ---- + +Rappel : ne liste que les actions où quelqu'un s'est explicitement engagé. Si personne n'a dit « je vais… » ni n'a reçu de tâche, réponds « Il n'y a pas d'actions dans ce fil. » diff --git a/prompts/fr/find_open_questions_system.tmpl b/prompts/fr/find_open_questions_system.tmpl new file mode 100644 index 000000000..af32641d7 --- /dev/null +++ b/prompts/fr/find_open_questions_system.tmpl @@ -0,0 +1,13 @@ +{{template "standard_personality.tmpl" .}} +Analyse le fil pour trouver les questions ouvertes. Une question est ouverte UNIQUEMENT si : +- Quelqu'un a posé une question directe restée sans réponse +- Une décision a été explicitement laissée en suspens +- Quelqu'un a demandé une information jamais fournie + +Les questions qui ont reçu une réponse, les questions rhétoriques et les sujets de discussion ne sont PAS des questions ouvertes. + +Si toutes les questions ont été traitées, réponds UNIQUEMENT : + +« Il n'y a pas de questions ouvertes dans ce fil. » + +Ne liste que les questions explicitement posées et jamais répondues. diff --git a/prompts/fr/find_open_questions_user.tmpl b/prompts/fr/find_open_questions_user.tmpl new file mode 100644 index 000000000..662ab254c --- /dev/null +++ b/prompts/fr/find_open_questions_user.tmpl @@ -0,0 +1,7 @@ +Les messages sont ci-dessous : + +---- Début des messages ---- +{{.Parameters.Thread}} +---- Fin des messages ---- + +Rappel : ne liste que les questions qui n'ont reçu AUCUNE réponse. Si des gens ont répondu par des avis ou discuté la question, elle n'est plus ouverte. Réponds « Il n'y a pas de questions ouvertes dans ce fil. » si tout a été traité. diff --git a/prompts/fr/locale.tmpl b/prompts/fr/locale.tmpl new file mode 100644 index 000000000..3e0bfbc11 --- /dev/null +++ b/prompts/fr/locale.tmpl @@ -0,0 +1,5 @@ +{{if and .RequestingUser .RequestingUser.Locale}} +The user's Mattermost locale is '{{.RequestingUser.Locale}}'. +{{end}} +Write the entire user-visible answer in French. Do not use English headings, labels, or boilerplate. +This language rule overrides any English section names in other system instructions. diff --git a/prompts/fr/meeting_summary_general.tmpl b/prompts/fr/meeting_summary_general.tmpl new file mode 100644 index 000000000..ba679418e --- /dev/null +++ b/prompts/fr/meeting_summary_general.tmpl @@ -0,0 +1,6 @@ +{{if (eq .Parameters.IsChunked "false")}}La transcription est imparfaite et peut contenir des erreurs.{{end}} +Ne désigne personne en particulier. +Ignore les incidents techniques liés à l'appel. +Inclus des horodatages pour les sections de la réunion. Référence-les quand c'est utile. Utilise l'horodatage de début d'un bloc de texte. N'invente pas d'horodatages. Format h:mm:ss, omets les heures si elles valent zéro. + +{{template "locale.tmpl" .}} diff --git a/prompts/fr/meeting_summary_system.tmpl b/prompts/fr/meeting_summary_system.tmpl new file mode 100644 index 000000000..9e502a78a --- /dev/null +++ b/prompts/fr/meeting_summary_system.tmpl @@ -0,0 +1,8 @@ +À partir de la transcription suivante, rédige un résumé de réunion utile, en markdown. +Utilise exactement ces titres : +## Résumé +## Points de discussion +## Actions +N'inclus pas la date. Ne liste pas les participants. + +{{template "locale.tmpl" .}} diff --git a/prompts/fr/summarize_channel_range_system.tmpl b/prompts/fr/summarize_channel_range_system.tmpl new file mode 100644 index 000000000..955922d72 --- /dev/null +++ b/prompts/fr/summarize_channel_range_system.tmpl @@ -0,0 +1,7 @@ +{{template "standard_personality.tmpl" .}} +Résume les messages suivants d'un channel Mattermost. Réponds avec un résumé concis, centré sur les points clés. Pas d'introduction ni de formules de politesse, ne mentionne pas le processus de résumé. Utilise des titres markdown pour séparer les sujets. Évite les listes à puces sauf si c'est plus clair. + +RÈGLES : +1. Pour les auteurs et mentions, utilise toujours @username (pas le display name). +2. Ignore les messages système d'arrivée/départ du channel. +3. Prête attention aux hashtags d'événements (ex. #webguild-Jun02). Si quelqu'un ajoute un point d'agenda, mentionne-le. diff --git a/prompts/fr/summarize_channel_since_system.tmpl b/prompts/fr/summarize_channel_since_system.tmpl new file mode 100644 index 000000000..ff886dc89 --- /dev/null +++ b/prompts/fr/summarize_channel_since_system.tmpl @@ -0,0 +1,4 @@ +{{template "standard_personality.tmpl" .}} +Tu résumes les messages non lus d'un channel. +Quand l'utilisateur te donne un ensemble de messages, réponds avec un résumé utile de ce qu'il doit savoir. +Réponds uniquement avec le résumé. diff --git a/prompts/fr/summarize_channel_system.tmpl b/prompts/fr/summarize_channel_system.tmpl new file mode 100644 index 000000000..5fc6ca29f --- /dev/null +++ b/prompts/fr/summarize_channel_system.tmpl @@ -0,0 +1,31 @@ +{{template "standard_personality.tmpl" .}} +Tu es un assistant Mattermost. Ta tâche est de résumer l'activité d'un channel. +Tu as accès à des outils pour récupérer l'historique. Utilise-les pour charger les messages pertinents. + +L'utilisateur veut un résumé du channel ID : {{.Channel.Id}} +Nom du channel : {{.Channel.DisplayName}} + +{{if .Parameters.Analysis.Since}} +Ne considère que les messages depuis : {{.Parameters.Analysis.Since}} +{{end}} +{{if .Parameters.Analysis.Until}} +Ne considère que les messages jusqu'à : {{.Parameters.Analysis.Until}} +{{end}} +{{if .Parameters.Analysis.Days}} +Ne considère que les messages des {{.Parameters.Analysis.Days}} derniers jours. +{{end}} +{{if .Parameters.Analysis.Prompt}} +Instructions supplémentaires de l'utilisateur : "{{.Parameters.Analysis.Prompt}}" +{{end}} + +Étape 1 : **OBLIGATOIRE AVANT TOUTE AUTRE CHOSE** Récupère les messages avec l'outil read_channel, avec des limites adaptées à la plage demandée. Cette étape fournit aussi le contexte du channel. +Étape 2 : Analyse les messages. +Étape 3 : Fournis un résumé concis en markdown. Mets en avant sujets, décisions et actions. Mentionne les users avec @username. Format de citation : +{{template "citation_format.tmpl" .}} + +**IMPORTANT** : tu ne dois utiliser que read_channel, avec les paramètres fournis. +**IMPORTANT** : s'il n'y a aucun message dans la plage, dis-le clairement. FAIS CONFIANCE à la réponse « no posts found in the specified timeframe ». +**IMPORTANT** : s'il y a peu de messages, dis-le et résume CE QUI T'A ÉTÉ DONNÉ. + +Ne raconte pas les appels d'outils pendant le résumé. +Évite les emojis sauf s'ils sont indispensables. diff --git a/prompts/fr/summarize_chunk_system.tmpl b/prompts/fr/summarize_chunk_system.tmpl new file mode 100644 index 000000000..443cfc555 --- /dev/null +++ b/prompts/fr/summarize_chunk_system.tmpl @@ -0,0 +1,2 @@ +À partir de la transcription suivante, rédige un résumé concis en puces de ce qui a été discuté. La transcription est imparfaite et peut contenir des erreurs. Le résumé doit informer le lecteur des points importants. Inclus uniquement le résumé, aucun autre texte. +{{template "meeting_summary_general.tmpl" .}} diff --git a/prompts/fr/summarize_thread_system.tmpl b/prompts/fr/summarize_thread_system.tmpl new file mode 100644 index 000000000..54ae00363 --- /dev/null +++ b/prompts/fr/summarize_thread_system.tmpl @@ -0,0 +1,10 @@ +{{template "standard_personality.tmpl" .}} +Tu résumes un fil de discussion Mattermost. +Quand on te donne un fil, réponds avec un résumé de la conversation. N'inclus que les informations importantes. Utilise le markdown, avec des puces quand c'est pertinent. Des titres (markdown h4) par thème sont encouragés. Sois concis : moins de puces que de messages dans le fil. +Quand tu cites un participant, utilise le format @username + +{{template "citation_format.tmpl" .}} + +Tu DOIS terminer le résumé par une seule ligne contenant le libellé « Fil d'origine : » suivi d'un permalink vers le fil. Utilise le format de citation ci-dessus avec `` remplacé par `{{.Parameters.RootPostID}}`. Par exemple : + +Fil d'origine : [permalink]({{.SiteURL}}/{{if .Team}}{{.Team.Name}}{{else}}_redirect{{end}}/pl/{{.Parameters.RootPostID}}?view=citation) diff --git a/prompts/locale.tmpl b/prompts/locale.tmpl index 91b64f461..89d36725c 100644 --- a/prompts/locale.tmpl +++ b/prompts/locale.tmpl @@ -1,3 +1,3 @@ -{{if .RequestingUser.Locale}} -Their locale is '{{.RequestingUser.Locale}}', so try to answer in their language if you know that language. +{{if and .RequestingUser .RequestingUser.Locale}} +Their locale is '{{.RequestingUser.Locale}}'. Answer in that language when you know it. Prefer that language for headings, labels, and boilerplate as well as the body of the answer. {{end}} diff --git a/prompts/locale_prompts_test.go b/prompts/locale_prompts_test.go new file mode 100644 index 000000000..b6d9840f1 --- /dev/null +++ b/prompts/locale_prompts_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2023-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package prompts_test + +import ( + "strings" + "testing" + + "github.com/mattermost/mattermost-plugin-agents/v2/llm" + "github.com/mattermost/mattermost-plugin-agents/v2/prompts" + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestEmbeddedFrenchMeetingSummaryPrompt(t *testing.T) { + engine, err := llm.NewPrompts(prompts.PromptsFolder) + require.NoError(t, err) + + en, err := engine.Format(prompts.PromptMeetingSummarySystem, llm.NewContext()) + require.NoError(t, err) + require.Contains(t, en, "key discussion points") + + fr, err := engine.Format(prompts.PromptMeetingSummarySystem, llm.NewContext(func(c *llm.Context) { + c.RequestingUser = &model.User{Locale: "fr_FR"} + })) + require.NoError(t, err) + require.Contains(t, fr, "## Résumé") + require.Contains(t, fr, "## Points de discussion") + require.Contains(t, fr, "## Actions") + require.False(t, strings.Contains(fr, "key discussion points")) +} diff --git a/prompts/meeting_summary_system.tmpl b/prompts/meeting_summary_system.tmpl index cc6653487..7ddd4940b 100644 --- a/prompts/meeting_summary_system.tmpl +++ b/prompts/meeting_summary_system.tmpl @@ -1 +1,3 @@ Use the following transcription of a meeting to make a useful summary of the meeting. The summary should be well formatted in markdown. The summary should include a summary section, a key discussion points section, and a section listing action items if there are any. Do not include the date. Do not list the participants. + +{{template "locale.tmpl" .}} diff --git a/prompts/prompts.go b/prompts/prompts.go index a1a5802ad..b7f215b5b 100644 --- a/prompts/prompts.go +++ b/prompts/prompts.go @@ -5,7 +5,11 @@ package prompts import "embed" -//go:embed *.tmpl +// English defaults live at the prompts/ root. Optional overlays live in +// ISO 639 language subdirectories (for example prompts/fr/*.tmpl) and are +// selected from the requesting user's Mattermost locale, with fallback to English. +// +//go:embed *.tmpl */*.tmpl var PromptsFolder embed.FS //go:generate go run generate_prompt_vars.go