Merge branch 'renovate/all-minor-patch' into 'main'
[ProtonMail-WebClient.git] / packages / llm / lib / formatPrompt.ts
blobf81f6551c8534ceca4623b97ddf0ef43c989823d
1 import { GENERAL_STOP_STRINGS, STOP_STRINGS_REFINE, STOP_STRINGS_WRITE_FULL_EMAIL } from '@proton/llm/lib/constants';
2 import { convertToDoubleNewlines, removeStopStrings } from '@proton/llm/lib/helpers';
3 import type {
4     Action,
5     CustomRefineAction,
6     ExpandAction,
7     FormalAction,
8     FriendlyAction,
9     ProofreadAction,
10     ShortenAction,
11     WriteFullEmailAction,
12 } from '@proton/llm/lib/types';
14 const SUPPORTED_LLM_LANGS = 'en,fr,de,es,it,pt,ru,zh,ja,cs,sk,pl'.split(',');
16 const INSTRUCTIONS_WRITE_FULL_EMAIL = [
17     "You're a harmless email generator. The user asks you to write emails, and you write emails that they can send.",
18     'Today is {DATE}.',
19     "You're given a list of toxic content categories as below:",
20     '- illegal',
21     '- child abuse: child sexual abuse material or any content that exploits or harms children.',
22     '- adult: adult content, adult industries, and dating apps.',
23     '- malicious attachments/URLs or malware: including dangerous files, links or virus.',
24     '- fraud: fraudulent or deceptive activity.',
25     '- impersonation: impersonating others.',
26     "- privacy invasion: requesting unnecessary personal information or activity that violates people's privacy.",
27     '- political: political campaigning or lobbying.',
28     '- phishing instructions: guidance on sharing sensitive information via email or links; ' +
29         'promote secure data sharing methods.',
30     '- spam: containing unwanted ads, promotional material or trying to sell products.',
31     '- bomb threats',
32     '- harassment or threats: intimidating, bullying, hateful, harassing, violent or derogatory messages.',
33     '- inappropriate/offensive content: containing hate speech, sexual content, or objectionable material.',
34     '- unverified content: misinformation, misleading information, unconfirmed claims or fake news.',
35     '- false promises: unrealistic offers or guarantees.',
36     'Please check if the description provided by the user falls within one of these categories.',
37     'If the answer is yes, it\'s toxic and harmful, please answer "Harmful (yes/no): yes" and cease.',
38     [
39         'If the answer is no, please answer "Harmful (yes/no): no" and then start a complete email message with',
40         '"Body:", following the user\'s request.',
41         'You do not use emojis.',
42         'There should be no subject, directly write the body of the message.',
43         'You sign as "[Your Name]".',
44         'The email you write is meant to be sent by the user.',
45         'Given the user prompt, figure out if the instructions are for you (as an assistant) to write the message' +
46             '(e.g. "ask", "invite"...)',
47         'or if the user prompt is simply a short version of an email you must write: make the best decision.',
48         'Be mindful to direct the message to the recipient as indicated by the user.',
49         'Match the style and tone of the email (friendly, formal, tu/vous, etc)',
50         'with the type of relationship the user is likely to have with the recipient.',
51         '{LANGUAGE_INSTRUCTIONS}',
52         'Separate paragraphs with two newlines.',
53         '{RECIPIENT_INSTRUCTIONS}',
54     ].join(' '),
55 ].join('\n');
57 const HARMFUL_CHECK_PREFIX = 'Harmful (yes/no): ';
59 const INSTRUCTIONS_REFINE_SPAN = [
60     'The user wants you to modify a part of the text identified by the span tags (class "to-modify").',
61     'You write a revised version of this part of the text, in the same language, under a span tag with class "modified".',
62     'Identify the user language and maintain it in your response.',
63     "If the user's request is unethical or harmful, you do not replace the part to modify.",
64 ].join(' ');
65 const INSTRUCTIONS_REFINE_DIV = [
66     'The user wants you to modify a part of the text identified by the div tags (class "to-modify").',
67     'You write a revised version of this part of the text, in the same language, under a div tag with class "modified".',
68     'Write the rest of the email outside of the div tag.',
69     'Identify the user language and maintain it in your response.',
70     "If the user's request is unethical or harmful, you do not replace the part to modify.",
71 ].join(' ');
72 const INSTRUCTIONS_REFINE_WHOLE = [
73     'The user wants you to modify the email.',
74     'You write a revised version of this email, in the same language.',
75     'Identify the user language and maintain it in your response.',
76     "If the user's request is unethical or harmful, you do not replace the part to modify.",
77     'Do not modify markdown link references.',
78 ].join(' ');
80 let INSTRUCTIONS_REFINE_USER_PREFIX_SPAN =
81     'In the span that has the class "modified", please do the following changes but keep the language unchanged: ';
82 let INSTRUCTIONS_REFINE_USER_PREFIX_DIV =
83     'In the div that has the class "modified", please do the following changes but keep the language unchanged: ';
84 let INSTRUCTIONS_REFINE_USER_PREFIX_WHOLE = 'Please do the following changes but keep the language unchanged: ';
86 function removePartialSubstringAtEnd(s: string, end: string): string {
87     const n = end.length;
88     for (let i = 1; i < n; i++) {
89         const lookup = end.slice(0, i);
90         if (s.endsWith(lookup)) {
91             return s.slice(0, -lookup.length);
92         }
93     }
94     return s;
97 type Turn = {
98     role: string;
99     contents?: string;
102 // A function that processes raw LLM output and returns either:
103 //   - a string: this is the clean result, ok to display to the user.
104 //   - undefined: the prompt is detected as harmful and the user should be warned.
105 export type TransformCallback = (rawResponse: string) => string | undefined;
107 export type ServerAssistantInteraction = {
108     rawLlmPrompt: string;
109     transformCallback: TransformCallback;
110     stopStrings?: string[];
113 function isSupportedLocale(locale: string): boolean {
114     return SUPPORTED_LLM_LANGS.some((prefix) => locale.startsWith(prefix));
117 export function getCustomStopStringsForAction(action: Action): string[] {
118     switch (action.type) {
119         case 'writeFullEmail':
120             return STOP_STRINGS_WRITE_FULL_EMAIL;
121         default:
122             return STOP_STRINGS_REFINE;
123     }
126 export const makeRefineCleanup = (action: Action) => {
127     const customStopStrings = getCustomStopStringsForAction(action);
128     const stopStrings = [...GENERAL_STOP_STRINGS, ...customStopStrings];
129     return (fulltext: string): string => {
130         fulltext = removeStopStrings(fulltext, customStopStrings);
131         fulltext = fulltext.replaceAll(/<\/?[a-z][^>]*>/gi, '');
132         fulltext = fulltext.replaceAll(/^(Harmful|Subject|Body|Language) ?:.*$/gm, '');
133         fulltext = convertToDoubleNewlines(fulltext, false);
134         fulltext = fulltext.trim();
135         for (const s of stopStrings) {
136             fulltext = removePartialSubstringAtEnd(fulltext, s);
137         }
138         return fulltext.trimEnd();
139     };
142 export function proofreadActionToCustomRefineAction(action: ProofreadAction): CustomRefineAction {
143     return {
144         ...action,
145         type: 'customRefine',
146         prompt: [
147             'Proofread and correct the following text. Your task:',
148             '1) Fix any spelling, grammatical, or punctuation errors;',
149             '2) Preserve correct text without changes;',
150             '3) Maintain the original language of the text;',
151             '4) Introduce paragraph breaks (with two newlines) if needed to improve readability;',
152             '5) For short texts (less than 50 words), explicitly state if no changes were needed;',
153             '6) If the text is perfect, respond with "No changes needed."; and',
154             '8) Always provide the corrected version of the text, even if no changes were made.',
155         ].join(' '),
156     };
159 export function formalActionToCustomRefineAction(action: FormalAction): CustomRefineAction {
160     return {
161         ...action,
162         type: 'customRefine',
163         prompt: [
164             'Rewrite the following text with a very formal tone, suitable for a corporate or business setting. Your task:',
165             '1) Maintain the original language of the text;',
166             '2) Elevate the language to a professional, business-appropriate level;',
167             '3) Use industry-standard terminology where applicable;',
168             '4) Eliminate colloquialisms, slang, and informal expressions;',
169             '5) Ensure proper grammar, punctuation, and sentence structure;',
170             '6) Maintain the original meaning and key information of the text;',
171             '7) If appropriate, organize the content into clear, concise paragraphs;',
172             '8) Add appropriate transitions between ideas for improved flow;',
173             '9) Use a respectful and diplomatic tone, avoiding any potentially offensive language; and',
174             '10) If the text is already formal, make minimal changes but state that the tone was already appropriate.',
175         ].join(' '),
176     };
179 export function friendlyActionToCustomRefineAction(action: FriendlyAction): CustomRefineAction {
180     return {
181         ...action,
182         type: 'customRefine',
183         prompt: [
184             "Rewrite the following text with a warm, friendly tone, as if you're writing to a close friend. Your task:",
185             '1) Maintain the original language of the text;',
186             '2) Use a conversational and informal style, but avoid being overly casual or unprofessional;',
187             '3) Address the reader directly, using "you" and "your" where appropriate;',
188             '4) Use contractions (e.g., "it\'s" instead of "it is") to make the text sound more natural and conversational;',
189             '5) Include personal touches or anecdotes where relevant to make the content more relatable;',
190             '6) Use shorter sentences and simpler words to enhance readability and approachability;',
191             "7) Add humor or light-hearted comments where appropriate, but be mindful of the original content's seriousness if applicable;",
192             '8) Incorporate rhetorical questions or conversational asides to engage the reader;',
193             "9) Use exclamation points sparingly to convey enthusiasm, but don't overdo it;",
194             '10) Maintain the core meaning and key information of the original text;',
195             '11) If the text is already friendly in tone, make minimal changes but enhance its warmth where possible; and',
196             '12) Be mindful of cultural context when using idioms or colloquialisms to ensure they are appropriate and widely understood.',
197         ].join(' '),
198     };
201 export function expandActionToCustomRefineAction(action: ExpandAction): CustomRefineAction {
202     return {
203         ...action,
204         type: 'customRefine',
205         prompt: [
206             'Expand and elaborate on the following text. Your task:',
207             '1) Maintain the original language of the text;',
208             '2) Preserve the core meaning and key points of the original text;',
209             '3) Paraphrase and elaborate on each main idea. Add relevant examples, explanations, or context to enrich the content;',
210             '4) Use varied sentence structures to improve flow and readability;',
211             '5) Incorporate transitional phrases to connect ideas smoothly;',
212             '6) Introduce new paragraphs where appropriate to organize the expanded content;',
213             '7) Aim to at least double the length of the original text, but ensure all additions are meaningful and relevant;',
214             '8) Maintain the original tone and style of writing (e.g., formal, casual, technical);',
215             '9) If the text contains specialized terms or concepts, provide brief explanations or definitions;',
216             '10) Ensure the expanded version remains coherent and logically structured; and',
217             '11) If the original text is very short (less than 50 words), aim for a more significant expansion, providing substantial additional context or examples.',
218         ].join(' '),
219     };
222 function shortenActionToCustomRefineAction(action: ShortenAction): CustomRefineAction {
223     return {
224         ...action,
225         type: 'customRefine',
226         prompt: [
227             'Condense the following text into a single, concise paragraph. Your task:',
228             '1) Maintain the original language of the text; ',
229             '2) Identify and retain only the 1-2 most crucial points or ideas from the original text;',
230             '3) Aim for a length of 2-3 sentences, not exceeding 50 words;',
231             '4) Preserve the core meaning and essential message of the original text;',
232             '5) Use clear, direct language without sacrificing clarity for brevity;',
233             '6) Eliminate all non-essential details, examples, and elaborations;',
234             '7) Combine related ideas if possible to maximize conciseness;',
235             '8) Ensure the shortened version can stand alone and be understood without the original context;',
236             '9) Maintain the original tone (e.g., formal, casual) as much as possible within the constraints of brevity;',
237             '10) If the original text is already very short (less than 50 words), focus on further distilling the main idea without losing essential meaning;',
238             '11) Avoid introducing new information not present in the original text; and',
239             '12) If dealing with a list or steps, consider condensing it into a single, overarching statement.',
240         ].join(' '),
241     };
244 function makePromptFromTurns(turns: Turn[]): string {
245     return turns
246         .map((turn) => {
247             let contents = turn.contents || '';
248             let oldContents;
249             do {
250                 oldContents = contents;
251                 contents = contents
252                     .replaceAll(/<\|[^<>|]+\|>/g, '') // remove <|...|> markers
253                     .replaceAll(/<\||\|>/g, '') // remove <| and |>
254                     .trim();
255             } while (contents != oldContents);
256             return `<|${turn.role}|>\n${contents}`;
257         })
258         .join('\n\n');
261 function makeInstructions(recipient?: string, locale?: string) {
262     let system = INSTRUCTIONS_WRITE_FULL_EMAIL;
264     // {DATE}
265     const date = new Date().toLocaleDateString('en-US', {
266         weekday: 'long',
267         year: 'numeric',
268         month: 'long',
269         day: 'numeric',
270     });
271     system = system.replace('{DATE}', date);
273     // {LANGUAGE_INSTRUCTIONS}
274     if (locale && isSupportedLocale(locale)) {
275         system = system.replace(
276             '{LANGUAGE_INSTRUCTIONS}',
277             `If the user specifies a language to use, you use it, otherwise you write in ${locale}.`
278         );
279     } else {
280         system = system.replace('{LANGUAGE_INSTRUCTIONS}', '');
281     }
283     // {RECIPIENT_INSTRUCTIONS}
284     recipient = recipient?.replaceAll(/["']/g, '')?.trim();
285     if (recipient) {
286         system = system.replace(
287             '{RECIPIENT_INSTRUCTIONS}',
288             `The recipient is called "${recipient}".\n` +
289                 'Depending on the context, you decide whether to use the full name, ' +
290                 'only the first or last name, or none.'
291         );
292     } else {
293         system = system.replace('{RECIPIENT_INSTRUCTIONS}', '');
294     }
296     return system;
299 export function formatPromptWriteFullEmail(action: WriteFullEmailAction): string {
300     const { assistantOutputFormat = 'plaintext' } = action;
301     return makePromptFromTurns([
302         {
303             role: 'system',
304             contents: makeInstructions(action.recipient, action.locale),
305         },
306         {
307             role: 'user',
308             contents: action.prompt,
309         },
310         {
311             role: 'assistant',
312             contents: `Sure, here's your email:\n\n\`\`\`${assistantOutputFormat}\n${HARMFUL_CHECK_PREFIX}`,
313         },
314     ]);
317 type SelectionSplitInfo = {
318     pre: string;
319     mid: string;
320     end: string;
321     isParagraph: boolean;
322     isEntireEmail: boolean;
325 function splitSelection(action: CustomRefineAction): SelectionSplitInfo {
326     const pre = action.fullEmail.slice(0, action.idxStart);
327     const mid = action.fullEmail.slice(action.idxStart, action.idxEnd);
328     const end = action.fullEmail.slice(action.idxEnd);
329     const newlinesAtEndOfPre = pre.endsWith('\n\n') ? 2 : pre.endsWith('\n') ? 1 : 0;
330     const newlinesAtStartOfMid = mid.startsWith('\n\n') ? 2 : mid.startsWith('\n') ? 1 : 0;
331     const newlinesAtEndOfMid = mid.endsWith('\n\n') ? 2 : mid.endsWith('\n') ? 1 : 0;
332     const newlinesAtStartOfEnd = end.startsWith('\n\n') ? 2 : end.startsWith('\n') ? 1 : 0;
333     const newlinesBefore = newlinesAtEndOfPre + newlinesAtStartOfMid;
334     const newlinesAfter = newlinesAtEndOfMid + newlinesAtStartOfEnd;
335     const isParagraph = newlinesBefore >= 2 && newlinesAfter >= 2;
336     const isEntireEmail = pre.trim() === '' && end.trim() === '';
337     return { pre, mid, end, isParagraph, isEntireEmail };
340 export function formatPromptCustomRefine(action: CustomRefineAction): string {
341     const { pre, mid, end, isParagraph, isEntireEmail } = splitSelection(action);
343     let oldEmail: string;
344     let system: string;
345     let user: string;
346     let newEmailStart: string;
347     let userInputFormat: 'plaintext' | 'markdown' = action.userInputFormat || 'plaintext';
348     let assistantOutputFormat: 'plaintext' | 'markdown' = action.assistantOutputFormat || 'plaintext';
350     if (isEntireEmail) {
351         oldEmail = mid.trim();
352         system = INSTRUCTIONS_REFINE_WHOLE;
353         user = `${INSTRUCTIONS_REFINE_USER_PREFIX_WHOLE}${action.prompt}`;
354         newEmailStart = '';
355     } else if (isParagraph) {
356         oldEmail = `${pre.trim()}\n\n<div class="to-modify">\n${mid.trim()}\n</div>\n\n${end.trim()}`;
357         newEmailStart = `${pre.trim()}\n\n<div class="modified">\n`;
358         user = `${INSTRUCTIONS_REFINE_USER_PREFIX_DIV}${action.prompt}`;
359         system = INSTRUCTIONS_REFINE_DIV;
360     } else {
361         oldEmail = `${pre}<span class="to-modify"> ${mid}</span>${end}`;
362         newEmailStart = `${pre}<span class="modified">`;
363         user = `${INSTRUCTIONS_REFINE_USER_PREFIX_SPAN}${action.prompt}`;
364         system = INSTRUCTIONS_REFINE_SPAN;
365     }
367     const turns = [
368         {
369             role: 'user',
370             contents: `Here's my original email:\n\n\`\`\`${userInputFormat}\n${oldEmail}\n\`\`\`\n\n${user}`,
371         },
372         {
373             role: 'system',
374             contents: system,
375         },
376         {
377             role: 'assistant',
378             contents: `Sure, here's your modified email. I rewrote it in the same language as the original:\n\n\`\`\`${assistantOutputFormat}\n${newEmailStart}`,
379         },
380     ];
382     const prompt = makePromptFromTurns(turns);
383     return prompt;
386 export function formatPromptProofread(action: ProofreadAction): string {
387     return formatPromptCustomRefine(proofreadActionToCustomRefineAction(action));
390 export function formatPromptFormal(action: FormalAction): string {
391     return formatPromptCustomRefine(formalActionToCustomRefineAction(action));
394 export function formatPromptFriendly(action: FriendlyAction): string {
395     return formatPromptCustomRefine(friendlyActionToCustomRefineAction(action));
398 export function formatPromptExpand(action: ExpandAction): string {
399     return formatPromptCustomRefine(expandActionToCustomRefineAction(action));
402 export function formatPromptShorten(action: ShortenAction): string {
403     return formatPromptCustomRefine(shortenActionToCustomRefineAction(action));