feat(translate): add per-series glossary with auto extraction
This commit was merged in pull request #2.
This commit is contained in:
1 parent
18ba92a6e3
commit
9e55dba687
14 files changed
+380
-12
No files matched your search
@@ -0,0 +1,92 @@
|
||||
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "glossarystore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSaveFile>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
QString GlossaryStore::pathForDirectory(const QString &directory)
|
||||
{
|
||||
const QString root = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + u"/glossaries"_s;
|
||||
const QByteArray digest = QCryptographicHash::hash(QFile::encodeName(QDir::cleanPath(directory)), QCryptographicHash::Sha1).toHex();
|
||||
return root + u"/"_s + QString::fromLatin1(digest) + u".json"_s;
|
||||
}
|
||||
|
||||
static QJsonObject glossaryJson(const GlossaryStore::Glossary &glossary)
|
||||
{
|
||||
QJsonArray entries;
|
||||
for (const auto &entry : glossary.entries) {
|
||||
if (!entry.source.trimmed().isEmpty() && !entry.target.trimmed().isEmpty()) {
|
||||
entries.append(QJsonObject{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
|
||||
}
|
||||
}
|
||||
return QJsonObject{{u"directory"_s, glossary.directory}, {u"entries"_s, entries}};
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::load(const QString &directory)
|
||||
{
|
||||
Glossary result{QDir::cleanPath(directory), {}};
|
||||
QFile file(pathForDirectory(result.directory));
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
return result;
|
||||
}
|
||||
const QJsonObject object = QJsonDocument::fromJson(file.readAll()).object();
|
||||
for (const QJsonValue &value : object.value(u"entries"_s).toArray()) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString source = entry.value(u"source"_s).toString().trimmed();
|
||||
const QString target = entry.value(u"target"_s).toString().trimmed();
|
||||
if (!source.isEmpty() && !target.isEmpty()) {
|
||||
result.entries.append({source, target});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool GlossaryStore::save(const Glossary &glossary, QString *error)
|
||||
{
|
||||
const QString directory = QDir::cleanPath(glossary.directory);
|
||||
const QString path = pathForDirectory(directory);
|
||||
QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
QSaveFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly) || file.write(QJsonDocument(glossaryJson({directory, glossary.entries})).toJson(QJsonDocument::Indented)) < 0 || !file.commit()) {
|
||||
if (error) {
|
||||
*error = file.errorString();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
GlossaryStore::Glossary GlossaryStore::merge(const Glossary &existing, const QList<Entry> &additions)
|
||||
{
|
||||
Glossary result{QDir::cleanPath(existing.directory), existing.entries};
|
||||
QSet<QString> sources;
|
||||
for (const Entry &entry : result.entries) {
|
||||
sources.insert(entry.source.trimmed());
|
||||
}
|
||||
for (const Entry &entry : additions) {
|
||||
const QString source = entry.source.trimmed();
|
||||
const QString target = entry.target.trimmed();
|
||||
if (!source.isEmpty() && !target.isEmpty() && !sources.contains(source)) {
|
||||
result.entries.append({source, target});
|
||||
sources.insert(source);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString GlossaryStore::serialize(const Glossary &glossary)
|
||||
{
|
||||
return QString::fromUtf8(QJsonDocument(glossaryJson(glossary)).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-FileCopyrightText: 2026 Tri Vo Khac
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
class GlossaryStore
|
||||
{
|
||||
public:
|
||||
struct Entry { QString source; QString target; };
|
||||
struct Glossary { QString directory; QList<Entry> entries; };
|
||||
|
||||
static Glossary load(const QString &directory);
|
||||
static bool save(const Glossary &glossary, QString *error = nullptr);
|
||||
static Glossary merge(const Glossary &existing, const QList<Entry> &additions);
|
||||
static QString serialize(const Glossary &glossary);
|
||||
static QString pathForDirectory(const QString &directory);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ struct ReplyDeleter {
|
||||
};
|
||||
using ReplyPtr = std::unique_ptr<QNetworkReply, ReplyDeleter>;
|
||||
|
||||
QString systemPrompt(const QString &language, const QString &presetId, const QString &customInstructions)
|
||||
QString systemPrompt(const QString &language, const QString &presetId, const QString &glossaryText, const QString &customInstructions)
|
||||
{
|
||||
const auto &preset = TranslationPresets::find(presetId);
|
||||
QString prompt = uR"(You are a professional subtitle translator. Translate subtitles into %1.
|
||||
@@ -47,6 +47,10 @@ Style guidance:
|
||||
|
||||
)"_s
|
||||
.arg(language, preset.guidance);
|
||||
if (!glossaryText.trimmed().isEmpty()) {
|
||||
prompt += u"Glossary — always use these renderings exactly:\n"_s;
|
||||
prompt += glossaryText.trimmed() + u"\n\n"_s;
|
||||
}
|
||||
if (!customInstructions.trimmed().isEmpty()) {
|
||||
prompt += u"Additional instructions from the user (follow them unless they conflict with the reply format):\n"_s;
|
||||
prompt += customInstructions.trimmed();
|
||||
@@ -149,7 +153,7 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
|
||||
{"model"_L1, m_config.model},
|
||||
{"messages"_L1,
|
||||
QJsonArray{
|
||||
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.customInstructions)}},
|
||||
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, systemPrompt(m_config.targetLanguage, m_config.translationPreset, m_config.glossaryText, m_config.customInstructions)}},
|
||||
QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}},
|
||||
}},
|
||||
};
|
||||
@@ -192,6 +196,39 @@ QCoro::Task<LlmClient::BatchResult> LlmClient::translate(QStringList lines, QStr
|
||||
co_return result;
|
||||
}
|
||||
|
||||
QCoro::Task<LlmClient::BatchResult> LlmClient::extractGlossary(QStringList lines, QString mediaTitle)
|
||||
{
|
||||
const auto &preset = TranslationPresets::find(m_config.translationPreset);
|
||||
const QString prompt = u"Identify recurring proper nouns and special terms in these subtitles. Return only JSON of the form {\"entries\":[{\"source\":\"...\",\"target\":\"...\"}]}. Include people, places, sects/clans, techniques, ranks and recurring special terms; omit ordinary words. Follow the target language and style preset.\nTarget language: "_s + m_config.targetLanguage + u"\nStyle guidance:\n"_s + preset.guidance;
|
||||
const QJsonObject userContent{{"title"_L1, mediaTitle}, {"lines"_L1, QJsonArray::fromStringList(lines)}};
|
||||
const QJsonObject body{{"model"_L1, m_config.model}, {"messages"_L1, QJsonArray{
|
||||
QJsonObject{{"role"_L1, "system"_L1}, {"content"_L1, prompt}},
|
||||
QJsonObject{{"role"_L1, "user"_L1}, {"content"_L1, QString::fromUtf8(QJsonDocument(userContent).toJson(QJsonDocument::Compact))}},
|
||||
}}};
|
||||
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/chat/completions"_s));
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, u"application/json"_s);
|
||||
if (!m_config.apiKey.isEmpty()) request.setRawHeader("Authorization", "Bearer " + m_config.apiKey.toUtf8());
|
||||
request.setTransferTimeout(RequestTimeoutMs);
|
||||
ReplyPtr reply(m_network->post(request, QJsonDocument(body).toJson(QJsonDocument::Compact)));
|
||||
m_replies.append(reply.get());
|
||||
co_await reply.get();
|
||||
m_replies.removeAll(reply.get());
|
||||
const QByteArray responseBody = reply->readAll();
|
||||
if (reply->error() != QNetworkReply::NoError) co_return BatchResult{{}, describeHttpError(reply.get(), responseBody), isRetryable(reply.get())};
|
||||
const QJsonObject response = QJsonDocument::fromJson(responseBody).object();
|
||||
const QString content = response.value("choices"_L1).toArray().at(0).toObject().value("message"_L1).toObject().value("content"_L1).toString();
|
||||
const QJsonObject object = extractJsonObject(content);
|
||||
if (!object.contains("entries"_L1)) co_return BatchResult{{}, i18n("The model did not return a glossary: %1", content.left(300).simplified()), true};
|
||||
QStringList encoded;
|
||||
for (const QJsonValue &value : object.value("entries"_L1).toArray()) {
|
||||
const QJsonObject entry = value.toObject();
|
||||
const QString source = entry.value("source"_L1).toString().trimmed();
|
||||
const QString target = entry.value("target"_L1).toString().trimmed();
|
||||
if (!source.isEmpty() && !target.isEmpty()) encoded.append(source + u"\t"_s + target);
|
||||
}
|
||||
co_return BatchResult{encoded, {}, false};
|
||||
}
|
||||
|
||||
QCoro::Task<LlmClient::ModelsResult> LlmClient::listModels()
|
||||
{
|
||||
QNetworkRequest request(QUrl(m_config.baseUrl.toString() + u"/models"_s));
|
||||
|
||||
@@ -21,6 +21,7 @@ public:
|
||||
QString targetLanguage;
|
||||
QString translationPreset;
|
||||
QString customInstructions;
|
||||
QString glossaryText;
|
||||
};
|
||||
|
||||
struct BatchResult {
|
||||
@@ -41,6 +42,7 @@ public:
|
||||
// Translates \a lines. \a context holds the source lines right before them, so the
|
||||
// model can follow the conversation; they are not translated.
|
||||
QCoro::Task<BatchResult> translate(QStringList lines, QStringList context, QString mediaTitle);
|
||||
QCoro::Task<BatchResult> extractGlossary(QStringList lines, QString mediaTitle);
|
||||
QCoro::Task<ModelsResult> listModels();
|
||||
|
||||
// Aborts every request in flight.
|
||||
|
||||
@@ -127,6 +127,48 @@ bool TranslationService::isFetchingModels() const
|
||||
return m_fetchingModels;
|
||||
}
|
||||
|
||||
QVariantList TranslationService::glossaryEntries() const
|
||||
{
|
||||
QVariantList result;
|
||||
for (const auto &entry : m_glossary.entries) result.append(QVariantMap{{u"source"_s, entry.source}, {u"target"_s, entry.target}});
|
||||
return result;
|
||||
}
|
||||
|
||||
QString TranslationService::glossaryDirectory() const
|
||||
{
|
||||
return m_glossary.directory;
|
||||
}
|
||||
|
||||
void TranslationService::loadGlossary(const QUrl &videoUrl)
|
||||
{
|
||||
const QString directory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
|
||||
m_glossary = directory.isEmpty() ? GlossaryStore::Glossary{} : GlossaryStore::load(directory);
|
||||
Q_EMIT glossaryChanged();
|
||||
}
|
||||
|
||||
void TranslationService::saveGlossary(const QVariantList &entries)
|
||||
{
|
||||
QList<GlossaryStore::Entry> result;
|
||||
for (const QVariant &value : entries) {
|
||||
const QVariantMap map = value.toMap();
|
||||
result.append({map.value(u"source"_s).toString().trimmed(), map.value(u"target"_s).toString().trimmed()});
|
||||
}
|
||||
m_glossary.entries = result;
|
||||
if (!m_glossary.directory.isEmpty()) {
|
||||
GlossaryStore::save(m_glossary);
|
||||
}
|
||||
Q_EMIT glossaryChanged();
|
||||
}
|
||||
|
||||
void TranslationService::clearGlossary()
|
||||
{
|
||||
m_glossary.entries.clear();
|
||||
if (!m_glossary.directory.isEmpty()) {
|
||||
GlossaryStore::save(m_glossary);
|
||||
}
|
||||
Q_EMIT glossaryChanged();
|
||||
}
|
||||
|
||||
void TranslationService::translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle)
|
||||
{
|
||||
cancel();
|
||||
@@ -172,6 +214,7 @@ QCoro::Task<std::pair<LlmClient::Config, QString>> TranslationService::loadConfi
|
||||
settings->targetLanguage().trimmed(),
|
||||
settings->translationPreset().trimmed(),
|
||||
settings->customInstructions(),
|
||||
{},
|
||||
};
|
||||
|
||||
if (!config.baseUrl.isValid() || config.baseUrl.scheme().isEmpty() || config.baseUrl.host().isEmpty()) {
|
||||
@@ -220,7 +263,7 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
setBusy(true);
|
||||
setStatusText(i18n("Reading subtitles…"));
|
||||
|
||||
const auto [config, configError] = co_await loadConfig();
|
||||
auto [config, configError] = co_await loadConfig();
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
}
|
||||
@@ -247,15 +290,25 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
co_return;
|
||||
}
|
||||
|
||||
// Same subtitles, model, language, and prompt settings give the same file, so it is translated once.
|
||||
QCryptographicHash hash(QCryptographicHash::Sha1);
|
||||
hash.addData(CacheVersion);
|
||||
hash.addData(config.model.toUtf8());
|
||||
hash.addData(config.targetLanguage.toUtf8());
|
||||
hash.addData(config.translationPreset.toUtf8());
|
||||
hash.addData(config.customInstructions.toUtf8());
|
||||
hash.addData(Subtitles::toSrt(cues).toUtf8());
|
||||
const QString outputPath = cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
||||
// Episodes in one folder share a glossary. Streams have no folder, so they get none.
|
||||
const QString seriesDirectory = videoUrl.isLocalFile() ? QFileInfo(videoUrl.toLocalFile()).absolutePath() : QString();
|
||||
const bool useGlossary = !seriesDirectory.isEmpty();
|
||||
GlossaryStore::Glossary glossary = useGlossary ? GlossaryStore::load(seriesDirectory) : GlossaryStore::Glossary{};
|
||||
const QString sourceSrt = Subtitles::toSrt(cues);
|
||||
|
||||
// Same subtitles, model, language, prompt settings and glossary give the same file, so it is translated once.
|
||||
const auto outputPathFor = [&](const GlossaryStore::Glossary &g) {
|
||||
QCryptographicHash hash(QCryptographicHash::Sha1);
|
||||
hash.addData(CacheVersion);
|
||||
hash.addData(config.model.toUtf8());
|
||||
hash.addData(config.targetLanguage.toUtf8());
|
||||
hash.addData(config.translationPreset.toUtf8());
|
||||
hash.addData(config.customInstructions.toUtf8());
|
||||
hash.addData(GlossaryStore::serialize(g).toUtf8());
|
||||
hash.addData(sourceSrt.toUtf8());
|
||||
return cachePath(videoUrl, QString::fromLatin1(hash.result().toHex().left(12)), config.targetLanguage);
|
||||
};
|
||||
QString outputPath = outputPathFor(glossary);
|
||||
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
m_job.reset();
|
||||
@@ -265,6 +318,55 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
co_return;
|
||||
}
|
||||
|
||||
if (useGlossary && QtPotatoSettings::self()->autoBuildGlossary()) {
|
||||
setStatusText(i18n("Building glossary…"));
|
||||
QStringList extractionLines;
|
||||
QHash<QString, bool> seen;
|
||||
for (const SubtitleCue &cue : cues) {
|
||||
if (!seen.contains(cue.text)) { seen.insert(cue.text, true); extractionLines.append(cue.text); }
|
||||
}
|
||||
constexpr qsizetype MaxGlossaryChars = 30000;
|
||||
// Long subtitles are sampled evenly so names from the whole episode are seen.
|
||||
qsizetype allChars = 0;
|
||||
for (const QString &line : std::as_const(extractionLines)) {
|
||||
allChars += line.size() + 1;
|
||||
}
|
||||
const qsizetype step = std::max<qsizetype>(1, (allChars + MaxGlossaryChars - 1) / MaxGlossaryChars);
|
||||
QStringList sampled;
|
||||
qsizetype total = 0;
|
||||
for (qsizetype i = 0; i < extractionLines.size(); i += step) {
|
||||
if (total + extractionLines.at(i).size() + 1 > MaxGlossaryChars) {
|
||||
break;
|
||||
}
|
||||
sampled.append(extractionLines.at(i));
|
||||
total += extractionLines.at(i).size() + 1;
|
||||
}
|
||||
job->client = std::make_unique<LlmClient>(m_network, config);
|
||||
const LlmClient::BatchResult extracted = co_await job->client->extractGlossary(sampled, job->mediaTitle);
|
||||
if (job->cancelled) {
|
||||
co_return;
|
||||
}
|
||||
if (extracted.error.isEmpty()) {
|
||||
QList<GlossaryStore::Entry> additions;
|
||||
for (const QString &encoded : extracted.translations) {
|
||||
const qsizetype tab = encoded.indexOf(u'\t');
|
||||
if (tab > 0) additions.append({encoded.left(tab), encoded.mid(tab + 1)});
|
||||
}
|
||||
glossary = GlossaryStore::merge(glossary, additions);
|
||||
GlossaryStore::save(glossary);
|
||||
} else {
|
||||
setStatusText(i18n("Glossary unavailable; translating without it."));
|
||||
}
|
||||
outputPath = outputPathFor(glossary);
|
||||
if (QFileInfo::exists(outputPath)) {
|
||||
m_job.reset();
|
||||
setBusy(false);
|
||||
setStatusText({});
|
||||
Q_EMIT translated(QUrl::fromLocalFile(outputPath), true);
|
||||
co_return;
|
||||
}
|
||||
}
|
||||
|
||||
// Repeated lines (songs, catchphrases) are sent once.
|
||||
QStringList uniqueLines;
|
||||
QHash<QString, qsizetype> uniqueIndex;
|
||||
@@ -278,6 +380,12 @@ QCoro::Task<> TranslationService::run(QUrl videoUrl, QVariantMap track)
|
||||
cueToUnique.append(*it);
|
||||
}
|
||||
|
||||
QStringList glossaryLines;
|
||||
for (const auto &entry : std::as_const(glossary.entries)) {
|
||||
glossaryLines.append(entry.source + u" → "_s + entry.target);
|
||||
}
|
||||
config.glossaryText = glossaryLines.join(u'\n');
|
||||
|
||||
job->client = std::make_unique<LlmClient>(m_network, config);
|
||||
auto batches = std::make_shared<Batches>();
|
||||
batches->lines = uniqueLines;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "llmclient.h"
|
||||
#include "subtitleextractor.h"
|
||||
#include "glossarystore.h"
|
||||
|
||||
#include <QCoroTask>
|
||||
|
||||
@@ -31,6 +32,8 @@ class TranslationService : public QObject
|
||||
Q_PROPERTY(QStringList availableModels READ availableModels NOTIFY availableModelsChanged)
|
||||
Q_PROPERTY(QVariantList translationPresets READ translationPresets CONSTANT)
|
||||
Q_PROPERTY(bool fetchingModels READ isFetchingModels NOTIFY fetchingModelsChanged)
|
||||
Q_PROPERTY(QVariantList glossaryEntries READ glossaryEntries NOTIFY glossaryChanged)
|
||||
Q_PROPERTY(QString glossaryDirectory READ glossaryDirectory NOTIFY glossaryChanged)
|
||||
|
||||
public:
|
||||
explicit TranslationService(QObject *parent = nullptr);
|
||||
@@ -43,6 +46,8 @@ public:
|
||||
QStringList availableModels() const;
|
||||
QVariantList translationPresets() const;
|
||||
bool isFetchingModels() const;
|
||||
QVariantList glossaryEntries() const;
|
||||
QString glossaryDirectory() const;
|
||||
|
||||
// \a track is an entry of MpvItem::subtitleTracks.
|
||||
Q_INVOKABLE void translate(const QUrl &videoUrl, const QVariantMap &track, const QString &mediaTitle);
|
||||
@@ -50,6 +55,9 @@ public:
|
||||
// Asks the API configured in the settings for its models.
|
||||
Q_INVOKABLE void fetchModels();
|
||||
Q_INVOKABLE void clearError();
|
||||
Q_INVOKABLE void loadGlossary(const QUrl &videoUrl);
|
||||
Q_INVOKABLE void saveGlossary(const QVariantList &entries);
|
||||
Q_INVOKABLE void clearGlossary();
|
||||
|
||||
Q_SIGNALS:
|
||||
void busyChanged();
|
||||
@@ -58,6 +66,7 @@ Q_SIGNALS:
|
||||
void errorTextChanged();
|
||||
void availableModelsChanged();
|
||||
void fetchingModelsChanged();
|
||||
void glossaryChanged();
|
||||
void translated(const QUrl &subtitleUrl, bool fromCache);
|
||||
|
||||
private:
|
||||
@@ -91,4 +100,5 @@ private:
|
||||
QString m_errorText;
|
||||
QStringList m_availableModels;
|
||||
bool m_fetchingModels = false;
|
||||
GlossaryStore::Glossary m_glossary;
|
||||
};
|
||||
Reference in new issue
Block a user