С самого выхода ChatGPT я начал ее использовать для решения задач корректуры текста: устранения опечаток, исправления ошибок и улучшения стилистики.
Однако, при использовании веб-версии ChatGPT возникают некоторые проблемы:
Приходится вчитываться в исправленный текст, чтобы найти изменения
Не используется вся мощь API, в котором есть возможности для более тонкой настройки бота
Можно задать системное сообщение, в котором объяснить ассистенту смысл его существования
Few-shot learning: можно предоставить набор примеров коррекции сообщений
Неудобство: нужно вставлять свой текст в веб-версию, затем набирать свой промт для его улучшения (который может быть разным в зависимости от типа коррекции). Хотелось бы иметь Web UI, где нужно просто вставить текст и выбрать тип коррекции - а далее текст будет обрабатываться оптимизированным промтом
Данный проект призван устранить эти недостатки. Потыкать приложение можно здесь (для использования нужен OpenAI API-ключ).
Приложение
В веб-приложении есть поле с текстом, в которое пользователь вводит свое сообщение, которое нужно исправить. После нажатия на изображение-кнопку в поле справа постепенно выводится исправленная версия, а по окончании вывода выделяются изменения. При наведении на изменение, tooltip показывает, что конкретно было исправлено.
Поддерживается множество языков. Можно либо выбрать язык исходного сообщения вручную, либо положиться на встроенный детектор языка.
Можно выбрать используемую модель: общедоступную GPT 3.5 Turbo, либо GPT-4. Доступ к GPT-4 API сейчас по вейтлисту, сама модель более чем на порядок дороже, но зато гораздо умнее.
Поддерживаются два типа коррекции текста:
just correct grammar просто исправляет грамматические, орфографические и пунктуационные ошибки
make more natural вдобавок улучшает стилистику текста. Однако, тут изменения более кардинальны, и иногда они могут менять смысл текста
OpenAI API стоит денег. Передо мной встала задача: как сделать проект юзабельным для множества людей, если он базируется на платном API? Брать с пользователей деньги и оплачивать своим ключом - такая стратегия не подходит для небольшого пет-проекта, потому что реализовать биллинг - отдельная большая задача. Поэтому пусть пользователь сам вводит свой ключ и платит с него. При нажатии на шестеренку открывается страница с настройками, где можно ввести/изменить свой API-ключ.
В такой парадигме проект пришлось делать вообще без сервера, чисто на React (потому что ключи нельзя никуда отправлять). Кроме того, для еще большей прозрачности код я открываю: https://github.com/einhornus/language-challenge-react (данное приложение - это на самом деле только часть большого проекта, который находится в процессе разработки).
Я питонист-MLщик со специализацией в NLP, кодинг на React для меня - выход из зоны комфорта. Поэтому прошу сильно не пинать из-за кривизны UI и качества JS-кода в репе.
Фокус данного проекта - на максимизации качества коррекции текста и на скорости релиза юзабельной версии.
GPT-4 API
В этой секции я расскажу про GPT-4 API и покажу, как с его помощью можно решать задачи NLP.
В API есть метод ChatCompletion, который принимает список сообщений, который мы будем называть промтом, и возвращает следующее сообщение.
Каждое сообщение этого списка - это словарь с двумя полями: role и content.
По значению role сообщения классифицируются на 3 типа: системные сообщения (role="system"), сообщения пользователя (role="user") и сообщения ассистента (role="assistant").
Системное сообщение содержит в своем поле content высокоуровневые инструкции для ассистента - то, как он должен действовать. Если в промте несколько системных сообщений, иметь значение будет только последнее из них (новые системные сообщения переписывают старые). Как расположение системного промта относительно остальных сообщений влияет на результат - я пока не понимаю, это открытый вопрос.
Последовательностью сообщений пользователя и ассистента задается предыдущая беседа между ними. Однако, эти сообщения также можно использовать для описания примеров решения задачи (few shot learning). В каждом сообщении пользователя находится пример входных данных, а в сообщении ассистента после него - ожидаемое решение. После примеров идет сообщение пользователя, в которое записываются входные данные, для которых нужно получить решение.
Рассмотрим задачу машинного перевода с английского на русский. В этом случае промт может выглядеть следующим образом:
[
{
"role": "user",
"content": "Hello how are you?"
},
{
"role": "assistant",
"content": "Привет, как дела?"
},
{
"role": "user",
"content": "Despite the heavy rain, they decided to continue their hike through the dense forest"
},
{
"role": "assistant",
"content": "Несмотря на сильный дождь, они решили продолжить свой поход через густой лес"
},
{
"role": "user",
"content": "The chef, inspired by flavors from around the world, has created a unique fusion cuisine that attracts food enthusiasts and critics alike"
},
{
"role": "assistant",
"content": "Повар, вдохновленный вкусами со всего мира, создал уникальную фьюжн-кухню, которая привлекает как гурманов, так и критиков"
},
{
"role": "system",
"content": "You are TranslateGPT. You translate user messages from English to Russian. You are the most accurate English to Russian translator in the world."
},
{
"role": "user",
"content": "{текст, который надо перевести}"
}
]
Если послать API-запрос с таким промтом, в ответ мы получим сообщение ассистента, которое будет содержать перевод нашего текста.
Кроме промта, в запросе к API есть и другие параметры:
model - используемая модель. На данный момент поддерживаются gpt-3.5-turbo (общедоступна) и gpt-4 (пока только для тех, кому предоставили доступ). gpt-4 стоит значительно дороже: 3 цента за 1000 токенов промта + 6 центов за 1000 токенов ответа против 0.2 центов за 1000 любых токенов у gpt-3.5-turbo. Однако, результаты при решении NLP-задач у gpt-4 гораздо лучше.
temperature - число от 0 до 2, которое задает, насколько часто при генерации следующего токена модель будет предпочитать не самый вероятный вариант. Увеличение температуры повышает креативность ответов, но снижает их качество. При решении задач NLP лучше всего просто оставлять температуру равной нулю.
max_tokens - максимальное количество токенов в генерируемом сообщении. При превышении лимита сообщение обрывается.
Полную справку по параметрам в API можно посмотреть здесь. Поиграться с API можно в плейграунде.
GPT-4 API в JS
В данном проекте мне нужно вызывать GPT-4 API из реакта. Да, существует специальная либа OpenAIApi, но она пока не поддерживает стриминг частичного ответа - чтобы пользователь мог видеть частичный результат по мере генерации сообщения.
В итоге я написал функцию для обращения к API endpoint напрямую:
/**
Calls the GPT-4 API with streaming support, providing real-time partial results.
@async
@function callGPT4APIJSStreaming
@param {string} model - The model to use for the API call.
@param {string} key - The API key for authentication with the GPT-4 API.
@param {Array} prompt - The prompt to be sent to the GPT-4 API (a list of messages).
@param {number} temperature - The sampling temperature for the GPT-4 API (0 for deterministic responses).
@param {number} maxTokens - The maximum number of tokens in the generated response.
@param {Function} onFullResult - Callback function to handle the complete generated response.
@param {Function} onPartialResult - Callback function to handle partial results while streaming.
@param {Function} onError - Callback function to handle errors during the API call.
@returns {Promise<void>} - A promise that resolves when the API call and streaming are complete.
*/
async function callGPT4APIJSStreaming(model, key, prompt, temperature, maxTokens, onFullResult, onPartialResult, onError) {
// Send the request to the API
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`,
},
body: JSON.stringify({
model: model,
messages: prompt,
max_tokens: maxTokens,
n: 1,
temperature: temperature,
stream: true,
}),
});
// If the response is not ok, call the onError callback with the status code and return
if (!response.ok) {
onError(response.status);
return;
}
// Initialize an empty string to accumulate content
let content = '';
// Get a reader for the response body stream
const reader = response.body.getReader();
// Initialize a TextDecoder to decode the response stream
const decoder = new TextDecoder();
// Continuously read the response stream until done
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
// Decode the current chunk of the response and split it into lines
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
// Process each line of the response
for (const line of lines) {
if (line.startsWith('data: ')) {
// If the line contains the '[DONE]' marker, stop processing
if (line.includes('[DONE]')) {
break;
}
// Extract the data object from the line and append the content to the accumulated content
const data = JSON.parse(line.slice(6));
if (data.choices && data.choices[0].delta && data.choices[0].delta.content) {
content += data.choices[0].delta.content;
// Call the onPartialResult callback with the accumulated content
onPartialResult(content);
}
}
}
}
// Call the onFullResult callback with the final accumulated content
onFullResult(content);
} catch (error) {
// If an error occurs during the request, call the onError callback with the error
onError(error);
}
}
Промт для решение задачи
Промт для модели формируется из двух частей:
Системное сообщение, в котором меняется только название языка
Набор примеров, который прописывается отдельно для каждого языка (может быть пустым)
Функция, которая генерирует системное сообщение:
/**
* Generates a system message for a language correction task, based on the given language and correction type.
*
* @function getLanguageCorrectionSystemMessage
* @param {string} language - The language code for the target language.
* @param {string} correctionType - The type of correction to be performed, either "grammar" or "natural".
* @returns {string} - The system message to be used as a prompt for the GPT model.
*/
function getLanguageCorrectionSystemMessage(language, correctionType) {
//codeToLanguage(languageCode) just converts the language code to the language name. eg: codeToLanguage("fr") -> "French"
if (correctionType === "grammar") {
return "You're CorrectGPT.\n" +
"You fix grammar and spelling mistakes in " + codeToLanguage(language) + " texts.\n" +
"Please only fix grammar and spelling mistakes in the user message.\n" +
"Your reply should contain ONLY the corrected text, nothing else.\n" +
"Please use exactly the same formatting as the original text.\n"
}
if (correctionType === "natural") {
return "You're ImproveGPT.\n" +
"You improve the provided " + codeToLanguage(language) + " text language-wise: you fix grammar and spelling mistakes and make it sound more natural.\n" +
"Your reply should contain ONLY the corrected text, nothing else.\n"+
"Please use exactly the same formatting as the original text\n"
}
}
Функция, которая возвращает примеры:
/**
* Generates examples for a language correction task, based on the given language and correction type.
*
* @function getLanguageCorrectionSystemMessage
* @param {string} language - The language code for the target language.
* @param {string} correctionType - The type of correction to be performed, either "grammar" or "natural".
* @returns {Array} - The array of examples for the GPT model prompt.
*/
function getLanguageCorrectionExamples(language, correctionType) {
if (correctionType === "grammar") {
if (language === "en") {
return [
[
"He don't like it",
"He doesn't like it"
],
[
"GPT does supports multiple languages without any isses but problem is with search engine. The best models for saerch are English-only. Obviously, there are mulrtilingual models (including OpenAI embeddings) but they will yield more bad search quality.",
"GPT does support multiple languages without any issues but the problem is with the search engine. The best models for search are English-only. Obviously there are multilingual models (including OpenAI embeddings) but they will yield worse search quality."
]
]
}
if (language === "ru") {
return [
[
"Моё сердце болет благодаря той девушке",
"Моё сердце болит из-за той девушки"
],
[
"нм нужно уйти но она не можт найти её обувь",
"нам нужно уходить, но она не может найти свою обувь"
],
[
"эта тарлека - грязная, поэтому мне нужно её мыть",
"эта тарелка грязная, поэтому мне нужно её вымыть"
],
[
"ятромеханика, это старый вид физики но учёные больше не её исследовают",
"ятромеханика - это старый вид физики, но учёные больше ей не занимаются"
],
[
"Я очень люблю всех животных. я могу сказать что я люблю все виды животных - от хомяков, собак, кошек, попугаев, до змей. у меня есть две домашние животные. Четыре месяца назад Бабушкина кошка родила моя два кота. Они зовут Маза и Макс, им четыре месяцев. Они оба ещё маленькие, у Макса есть очень длинная и густая шерсть, хотя у Мазы короткая шерсть. Маза и Макс - умние кошки, когда я говорю с ими они меня всегда понимают. Цвет шерсти у их коричневый-белый, глаза зелёные. Когда они были ещё маленькие, они были очень шустрым, мы не могли за ними уследить. Куда бы мы ни пошли, мы всегда берем их со собой. Они появились ко мне от кошки моей бабушки. О им заботиться я и мой папа. Я их кормлю дважды в день а мой папа их выгуливает один раз в день. их любимая игрушка - пластиковая мышь. Я очень люблю своих зеленоглазых кошек",
"Я очень люблю всех животных. я могу сказать, что я люблю все виды животных - от хомяков, собак, кошек, попугаев, до змей. у меня есть двое домашних животных. Четыре месяца назад бабушкина кошка родила моих двух котов. Их зовут Маза и Макс, им четыре месяца. Они оба ещё маленькие, у Макса очень длинная и густая шерсть, хотя у Мазы короткая шерсть. Маза и Макс - умные кошки, когда я говорю с ними, они меня всегда понимают. Цвет шерсти у них коричнево-белый, глаза зелёные. Когда они были ещё маленькими, они были очень шустрыми, мы не могли за ними уследить. Куда бы мы ни пошли, мы всегда берем их с собой. Они появились у меня от кошки моей бабушки. О них заботимся я и мой папа. Я их кормлю дважды в день, а мой папа их выгуливает один раз в день. Их любимая игрушка - пластиковая мышь. Я очень люблю своих зеленоглазых кошек",
]
]
}
} else {
if (language === "en") {
return [
[
"As far as our portals are Eng-based, so I deem it is not necssary to expect some language packs over English",
"Since our portal is English-based, I don't think it's necessary to have language packs besides English."
],
[
"GPT does supports multiple languages without any isses but problem is with search engine. The best models for saerch are English-only. Obviously, there are mulrtilingual models (including OpenAI embeddings) but they will yield more bad search quality.",
"GPT is indeed capable of supporting multiple languages seamlessly, but the issue lies with the search engine. The most effective models for search are English-only. While there are multilingual models available (including OpenAI embeddings), they will yield inferior search quality."
]
]
}
if (language === "ru") {
return [
[
"нм нужно уйти но она не можт найти её обувь",
"нам нужно уходить, но она не может найти свою обувь"
],
[
"эта тарлека - грязная, поэтому мне нужно её мыть",
"эта тарелка грязная, поэтому мне нужно её вымыть"
],
[
"я хочу купить новую машину, но мне нужно продать свою старую машину",
"я хочу купить новую машину, но мне нужно сначала продать свою старую"
],
[
"кто мог продать мне ниссан фронтир по хорошая цена?",
"кто мог бы продать мне Nissan Frontier по хорошей цене?"
],
[
"Я очень люблю всех животных. я могу сказать что я люблю все виды животных - от хомяков, собак, кошек, попугаев, до змей. у меня есть две домашние животные. Четыре месяца назад Бабушкина кошка родила моя два кота. Они зовут Маза и Макс, им четыре месяцев. Они оба ещё маленькие, у Макса есть очень длинная и густая шерсть, хотя у Мазы короткая шерсть. Маза и Макс - умние кошки, когда я говорю с ими они меня всегда понимают. Цвет шерсти у их коричневый-белый, глаза зелёные. Когда они были ещё маленькие, они были очень шустрым, мы не могли за ними уследить. Куда бы мы ни пошли, мы всегда берем их со собой. Они появились ко мне от кошки моей бабушки. О им заботиться я и мой папа. Я их кормлю дважды в день а мой папа их выгуливает один раз в день. их любимая игрушка - пластиковая мышь. Я очень люблю своих зеленоглазых кошек",
"Я очень люблю всех животных. я могу сказать, что я люблю все виды животных - от хомяков, собак, кошек, попугаев, до змей. у меня есть двое домашних животных. Четыре месяца назад кошка моей бабушки родила двух котят. Их зовут Маза и Макс, им по четыре месяца. Они оба ещё маленькие. У Макса очень длинная и густая шерсть, хотя у Мазы - короткая. Маза и Макс - умные кошки, когда я говорю с ними, они всегда меня понимают. Цвет шерсти у них коричнево-белый, глаза зелёные. Когда они были ещё маленькими, они были очень шустрыми, мы не могли за ними уследить. Куда бы мы ни пошли, мы всегда берем их с собой. Их родила кошка моей бабушки. Мы с моим папой заботимся о них. Я их кормлю дважды в день, а мой папа их выгуливает один раз в день. их любимая игрушка - пластиковая мышь. Я очень люблю своих зеленоглазых кошек",
]
]
}
}
return []
}
NLP в React
Токенизация
Для токенизации текстов в JS я быстро нашел Intl.Segmenter, который превосходно справляется с задачей. Что важно, он также умеет работать с разными языками. Например, в китайском языке не ставят пробелов, токенизатор будет делить текст на логически связанные последовательности из нескольких иероглифов.
Распознавание языка
Алгоритм для коррекции текста требует идентификатор языка для извлечения примеров и токенизации текстов. Пользователь может выбрать язык напрямую, но более удобно иметь опцию автоматического распознавания языка.
С распознаванием языка возникли проблемы.
Сначала я наткнулся на либу franc, она не подошла из-за отвратительной точности распознавания.
Затем я посмотрел несколько других либ, которые у меня не получилось установить (видимо, они предназначены для Node.js). Была пара решений с внешним API, но для моего проекта это не подходит.
Потом у меня возникла идея решить эту задачу с помощью GPT-3.5 API. В целом это работало, но в сложных случаях модель начинала "ломаться" и выдавать что-то вроде "It seems to be a text in English with a couple of Russian words thrown in there as well" вместо того, чтобы просто выдать название языка в первых двух токенах. Кроме того, хотелось бы обойтись без всякой асинхронности, чтобы детекция языка происходила мгновенно. Да и вообще, использовать LLM для такой простой задачи - это оверкилл.
В итоге я быстренько навелосипедил собственный детектор. Он выбирает между 15 языками, опции "неизвестный язык" не предусмотрено.
Если у языка уникальная система письма, то распознать текст на нем несложно - нужно просто удостовериться, что процент символов из соответствующего алфавита превосходит определенный порог. С помощью такого подхода мой детектор распознаёт китайский, японский, корейский, хинди, армянский, грузинский и тайский. Есть проблема с деванагари - оно на самом деле используется некоторыми другими популярными индийскими языками, не только хинди. Этой проблемой я пока пренебрег.
Если текст прошел фильтр выше (то есть, он скорее всего написан на латинице или кириллице), я запускаю основной алгоритм классификации между 8 языками - английский, русский, испанский, немецкий, французский, португальский, итальянский, голландский.
Для каждого из этих языков я скачал частотный список слов и для 1000 самых частотных слов посчитал их нормированную частоту. Также я посчитал матожидание нормированной частоты слова, если оно отсутствует в списке 1000 самых распространенных.
Далее, я применяю метод максимального правдоподобия: для каждого языка я токенизую текст и считаю вероятность принадлежности текста к языку как произведение нормированных частот соответствующих слов (или их матожиданий для редких слов).
Поиск изменений
Пусть нам даны исходный текст и его исправленная версия (оба - в виде массива слов). Требуется найти кратчайший список операций для преобразования первого массива во второй. Операции бывают трех видов: удаление слова, вставка слова и замена слова. Для операций удаления нужно найти индекс удаляемого слова в массиве исходного текста, для операций вставки - индекс вставляемого слова в массиве исправленного текста, а для операций замены - оба индекса.
Нетрудно заметить, что длина нашего кратчайшего списка операций эквивалентна расстоянию Левенштейна. Таким образом, для решения задачи нужно восстановить редакционное предписание в алгоритме Левенштейна.
Код
/**
* Given two string arrays, computes the minimal edit sequence of substitutions, insertion and deletion operations
* required to transform the first array into the second one
*
* @param {string[]} text1 - The first input array.
* @param {string[]} text2 - The second input array.
* @returns {Object[]} operations - The array of operations to transform text1 into text2.
*/
function computeEditSequence(text1, text2) {
// Store the lengths of the two input arrays.
const m = text1.length, n = text2.length;
// Initialize a 2D matrix for dynamic programming, where dp[i][j] will
// hold the minimal edit distance between the substrings text1[0...i-1] and text2[0...j-1].
const dp = Array.from({length: m + 1}, () => Array(n + 1).fill(0));
// Set the base cases for the dynamic programming matrix.
// If text1 is empty, then the distance is the length of text2 (all insertions).
// If text2 is empty, then the distance is the length of text1 (all deletions).
for (let i = 0; i <= m; i++) dp[i][0] = i;
for (let j = 0; j <= n; j++) dp[0][j] = j;
// Iterate through the matrix, computing the minimal edit distances.
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
// If the current words are the same,
// no operation is needed, and the distance remains the same.
if (text1[i - 1] === text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
} else {
// If the current words are different, the minimal distance is
// determined by the minimum of the three previous distances, plus 1.
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1;
}
}
}
// Backtrack through the matrix to find the sequence of operations.
let i = m, j = n;
const operations = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && text1[i - 1] === text2[j - 1]) {
// If the words are the same, move diagonally up-left.
i--;
j--;
} else {
if (i > 0 && dp[i][j] === dp[i - 1][j] + 1) {
// If the minimal distance comes from the left, it's a deletion operation.
let operation = {
'type': "delete",
'word': text1[i - 1],
'index': i - 1,
}
operations.push(operation);
i--;
} else if (j > 0 && dp[i][j] === dp[i][j - 1] + 1) {
// If the minimal distance comes from above, it's an insertion operation.
let operation = {
'type': "insert",
'word': text2[j - 1],
'index': j - 1,
}
operations.push(operation);
j--;
} else {
// If the minimal distance comes from the diagonal, it's a substitution operation.
let operation = {
'type': "substitute",
'word': text2[j - 1],
'index': j - 1,
'originalIndex': i - 1,
'originalWord': text1[i - 1],
}
operations.push(operation);
i--;
j--;
}
}
}
// Return the array of operations to transform text1 into text2.
return operations;
}
Результаты
Если использовать GPT-4, то получаются просто офигенные результаты
На GPT-3.5 результаты похуже, но все равно очень даже неплохие