Branch data Line data Source code
1 : : #include "data/FolderPredictor.h"
2 : : #include "data/DatabaseSecurity.h"
3 : : #include <QMutexLocker>
4 : : #include "data/MailCache.h"
5 : :
6 : : #include <QLoggingCategory>
7 : : #include <QRegularExpression>
8 : : #include <QSqlError>
9 : : #include <QSqlQuery>
10 : : #include <QUuid>
11 : : #include <QtMath>
12 : :
13 : : #include <algorithm>
14 : :
15 [ + + + - : 3 : Q_LOGGING_CATEGORY(lcFolderPredictor, "mailjd.folderpredictor")
+ - - - ]
16 : :
17 : : // German + English stop words (common words that don't help classification)
18 : : const QSet<QString> FolderPredictor::s_stopWords = {
19 : : // German
20 : : "der", "die", "das", "und", "oder", "ein", "eine", "ist", "sind", "war",
21 : : "hat", "haben", "wird", "werden", "mit", "von", "für", "auf", "aus", "bei",
22 : : "nach", "über", "unter", "vor", "zum", "zur", "dem", "den", "des", "sich",
23 : : "nicht", "auch", "noch", "nur", "wie", "aber", "als", "wenn", "ich", "wir",
24 : : "sie", "ihr", "uns", "mir", "dir", "mein", "dein", "sein", "kann", "sehr",
25 : : // English
26 : : "the", "a", "an", "and", "or", "is", "are", "was", "has", "have", "will",
27 : : "with", "from", "for", "on", "at", "by", "to", "in", "of", "not", "but",
28 : : "this", "that", "it", "be", "do", "if", "so", "no", "up", "out", "we",
29 : : "you", "he", "she", "my", "your", "his", "her", "our", "can",
30 : : // Common mail prefixes (already stripped, but just in case)
31 : : "re", "fwd", "aw", "wg",
32 : : };
33 : :
34 [ + - ]: 170 : FolderPredictor::FolderPredictor(QObject *parent) : QObject(parent) {}
35 : :
36 : 140 : FolderPredictor::~FolderPredictor() {
37 : 122 : close();
38 : 140 : }
39 : :
40 : 170 : bool FolderPredictor::open(const QString &dbPath) {
41 [ + - + + ]: 170 : if (!DatabaseSecurity::preparePath(dbPath)) {
42 [ + - + - : 2 : qCWarning(lcFolderPredictor)
+ + ]
43 [ + - + - ]: 1 : << "failed to create private database file" << dbPath;
44 : 1 : return false;
45 : : }
46 : : m_connectionName =
47 [ + - + - : 338 : QStringLiteral("FolderPredictor_") + QUuid::createUuid().toString();
+ - ]
48 [ + - + - ]: 338 : m_db = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), m_connectionName);
49 [ + - ]: 169 : m_db.setDatabaseName(dbPath);
50 : :
51 [ + - + + ]: 169 : if (!m_db.open()) {
52 [ + - + - : 2 : qCWarning(lcFolderPredictor) << "failed to open" << dbPath << ':'
+ - + - +
- + + ]
53 [ + - + - : 1 : << m_db.lastError().text();
+ - ]
54 : 1 : return false;
55 : : }
56 [ + - - + ]: 168 : if (!DatabaseSecurity::restrictExistingFile(dbPath)) {
57 [ # # # # : 0 : qCWarning(lcFolderPredictor)
# # ]
58 [ # # # # ]: 0 : << "failed to restrict database permissions for" << dbPath;
59 [ # # ]: 0 : m_db.close();
60 : 0 : return false;
61 : : }
62 : :
63 : : // WAL for fast writes
64 [ + - ]: 168 : QSqlQuery q(m_db);
65 [ + - ]: 168 : q.exec(QStringLiteral("PRAGMA journal_mode=WAL"));
66 [ + - ]: 168 : q.exec(QStringLiteral("PRAGMA synchronous=NORMAL"));
67 : : // T-619/SEC-19: Prevent SQLITE_BUSY errors during concurrent access
68 [ + - ]: 168 : q.exec(QStringLiteral("PRAGMA busy_timeout=5000"));
69 : :
70 [ + - ]: 168 : return createSchema();
71 : 168 : }
72 : :
73 : 217 : void FolderPredictor::close() {
74 [ + + ]: 217 : if (m_db.isOpen()) {
75 : 120 : m_db.close();
76 : : }
77 [ + + ]: 217 : if (!m_connectionName.isEmpty()) {
78 : : // Must drop the QSqlDatabase handle before removeDatabase —
79 : : // otherwise the member still holds a reference and Qt warns
80 : : // "connection still in use" (classic Qt gotcha).
81 : 121 : QString connName = m_connectionName;
82 : 121 : m_connectionName.clear();
83 [ + - + - ]: 121 : m_db = QSqlDatabase(); // Release reference
84 [ + - ]: 121 : QSqlDatabase::removeDatabase(connName);
85 : 121 : }
86 : 217 : }
87 : :
88 : 167 : bool FolderPredictor::isOpen() const {
89 : 167 : return m_db.isOpen();
90 : : }
91 : :
92 : 168 : bool FolderPredictor::createSchema() {
93 [ + - ]: 168 : QSqlQuery q(m_db);
94 : :
95 : : // Token counts: how often a token was seen in a specific folder
96 [ + - ]: 168 : bool ok = q.exec(QStringLiteral(
97 : : "CREATE TABLE IF NOT EXISTS token_counts ("
98 : : " id INTEGER PRIMARY KEY AUTOINCREMENT,"
99 : : " token TEXT NOT NULL,"
100 : : " folder TEXT NOT NULL,"
101 : : " count INTEGER NOT NULL DEFAULT 1,"
102 : : " UNIQUE(token, folder)"
103 : : ")"));
104 [ + + ]: 168 : if (!ok) {
105 [ + - + - : 2 : qCWarning(lcFolderPredictor)
+ + ]
106 [ + - + - : 1 : << "create token_counts:" << q.lastError().text();
+ - + - ]
107 : 1 : return false;
108 : : }
109 : :
110 [ + - ]: 167 : q.exec(QStringLiteral(
111 : : "CREATE INDEX IF NOT EXISTS idx_token ON token_counts(token)"));
112 [ + - ]: 167 : q.exec(QStringLiteral(
113 : : "CREATE INDEX IF NOT EXISTS idx_folder ON token_counts(folder)"));
114 : :
115 [ + - ]: 167 : ok = q.exec(QStringLiteral(
116 : : "CREATE TABLE IF NOT EXISTS trained_messages ("
117 : : " account TEXT NOT NULL,"
118 : : " folder TEXT NOT NULL,"
119 : : " uid INTEGER NOT NULL,"
120 : : " PRIMARY KEY(account, folder, uid)"
121 : : ")"));
122 [ - + ]: 167 : if (!ok) {
123 [ # # # # : 0 : qCWarning(lcFolderPredictor)
# # ]
124 [ # # # # : 0 : << "create trained_messages:" << q.lastError().text();
# # # # ]
125 : 0 : return false;
126 : : }
127 : :
128 : : // Folder document counts: how many mails were moved to each folder
129 [ + - ]: 167 : ok = q.exec(QStringLiteral(
130 : : "CREATE TABLE IF NOT EXISTS folder_doc_counts ("
131 : : " folder TEXT PRIMARY KEY,"
132 : : " doc_count INTEGER NOT NULL DEFAULT 0"
133 : : ")"));
134 [ - + ]: 167 : if (!ok) {
135 [ # # # # : 0 : qCWarning(lcFolderPredictor)
# # ]
136 [ # # # # : 0 : << "create folder_doc_counts:" << q.lastError().text();
# # # # ]
137 : 0 : return false;
138 : : }
139 : :
140 : : // Metadata (total_docs, pretrained flag)
141 [ + - ]: 167 : ok = q.exec(QStringLiteral(
142 : : "CREATE TABLE IF NOT EXISTS meta ("
143 : : " key TEXT PRIMARY KEY,"
144 : : " value TEXT"
145 : : ")"));
146 [ - + ]: 167 : if (!ok) {
147 [ # # # # : 0 : qCWarning(lcFolderPredictor) << "create meta:" << q.lastError().text();
# # # # #
# # # #
# ]
148 : 0 : return false;
149 : : }
150 : :
151 : : // Initialize total_docs if not present
152 [ + - ]: 167 : q.exec(QStringLiteral(
153 : : "INSERT OR IGNORE INTO meta (key, value) VALUES ('total_docs', '0')"));
154 : :
155 : : // Schema version check — if algorithm changed, force re-training
156 : : static constexpr int kSchemaVersion = 4;
157 [ + - ]: 167 : q.exec(QStringLiteral("SELECT value FROM meta WHERE key = 'schema_version'"));
158 : 167 : int storedVersion = 0;
159 [ + - + + ]: 167 : if (q.next())
160 [ + - + - ]: 96 : storedVersion = q.value(0).toInt();
161 : :
162 [ + + ]: 167 : if (storedVersion != kSchemaVersion) {
163 : : // Algorithm changed — reset all training data
164 [ + - ]: 72 : q.exec(QStringLiteral("DELETE FROM token_counts"));
165 [ + - ]: 72 : q.exec(QStringLiteral("DELETE FROM trained_messages"));
166 [ + - ]: 72 : q.exec(QStringLiteral("DELETE FROM folder_doc_counts"));
167 [ + - ]: 72 : q.exec(QStringLiteral(
168 : : "UPDATE meta SET value = '0' WHERE key = 'total_docs'"));
169 [ + - ]: 72 : q.exec(QStringLiteral("DELETE FROM meta WHERE key = 'pretrained_account'"));
170 [ + - ]: 72 : q.prepare(QStringLiteral(
171 : : "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)"));
172 [ + - + - ]: 72 : q.bindValue(0, QString::number(kSchemaVersion));
173 [ + - ]: 72 : q.exec();
174 : : }
175 : :
176 : 167 : return true;
177 : 168 : }
178 : :
179 : 5 : void FolderPredictor::resetDatabase() {
180 [ + - + + ]: 5 : if (!m_db.isOpen())
181 : 1 : return;
182 : :
183 [ + - ]: 4 : QSqlQuery q(m_db);
184 [ + - ]: 4 : q.exec(QStringLiteral("DELETE FROM token_counts"));
185 [ + - ]: 4 : q.exec(QStringLiteral("DELETE FROM trained_messages"));
186 [ + - ]: 4 : q.exec(QStringLiteral("DELETE FROM folder_doc_counts"));
187 [ + - ]: 4 : q.exec(QStringLiteral(
188 : : "UPDATE meta SET value = '0' WHERE key = 'total_docs'"));
189 [ + - ]: 4 : q.exec(QStringLiteral("DELETE FROM meta WHERE key = 'pretrained_account'"));
190 : 4 : }
191 : :
192 : : // T-287: Number of training documents for a specific folder
193 : 77 : int FolderPredictor::documentsForFolder(const QString &folderPath) const {
194 [ + - + + ]: 77 : if (!m_db.isOpen())
195 : 2 : return 0;
196 : :
197 [ + - ]: 75 : QSqlQuery q(m_db);
198 [ + - ]: 75 : q.prepare(QStringLiteral(
199 : : "SELECT doc_count FROM folder_doc_counts WHERE folder = :folder"));
200 [ + - ]: 150 : q.bindValue(QStringLiteral(":folder"), folderPath);
201 [ + - + + : 75 : if (q.exec() && q.next())
+ - + + +
+ ]
202 [ + - + - ]: 24 : return q.value(0).toInt();
203 : 51 : return 0;
204 : 75 : }
205 : :
206 : : // T-287: Whether a folder has enough training data for reliable predictions
207 : 18 : bool FolderPredictor::hasSufficientData(const QString &folderPath) const {
208 : : // Need at least 5 training documents for basic predictions
209 : 18 : return documentsForFolder(folderPath) >= 5;
210 : : }
211 : :
212 : : // T-287: Total unique tokens trained for a specific folder
213 : 26 : int FolderPredictor::tokenCountForFolder(const QString &folderPath) const {
214 [ + - + + ]: 26 : if (!m_db.isOpen())
215 : 1 : return 0;
216 : :
217 [ + - ]: 25 : QSqlQuery q(m_db);
218 [ + - ]: 25 : q.prepare(QStringLiteral(
219 : : "SELECT COUNT(*) FROM token_counts WHERE folder = :folder"));
220 [ + - ]: 50 : q.bindValue(QStringLiteral(":folder"), folderPath);
221 [ + - + + : 25 : if (q.exec() && q.next())
+ - + - +
+ ]
222 [ + - + - ]: 24 : return q.value(0).toInt();
223 : 1 : return 0;
224 : 25 : }
225 : :
226 : : // T-287: Reset training data for a single folder (not the entire database)
227 : 15 : void FolderPredictor::resetFolder(const QString &folderPath) {
228 [ + - + + ]: 15 : if (!m_db.isOpen())
229 : 1 : return;
230 : :
231 [ + - ]: 14 : m_db.transaction();
232 : :
233 [ + - ]: 14 : QSqlQuery q(m_db);
234 : :
235 : : // Get the document count for this folder to subtract from total
236 [ + - ]: 14 : int docCount = documentsForFolder(folderPath);
237 : :
238 : : // Delete token counts for this folder
239 [ + - ]: 14 : q.prepare(QStringLiteral(
240 : : "DELETE FROM token_counts WHERE folder = :folder"));
241 [ + - ]: 28 : q.bindValue(QStringLiteral(":folder"), folderPath);
242 [ + - ]: 14 : q.exec();
243 : :
244 [ + - ]: 14 : q.prepare(QStringLiteral(
245 : : "DELETE FROM trained_messages WHERE folder = :folder"));
246 [ + - ]: 28 : q.bindValue(QStringLiteral(":folder"), folderPath);
247 [ + - ]: 14 : q.exec();
248 : :
249 : : // Delete folder document count
250 [ + - ]: 14 : q.prepare(QStringLiteral(
251 : : "DELETE FROM folder_doc_counts WHERE folder = :folder"));
252 [ + - ]: 28 : q.bindValue(QStringLiteral(":folder"), folderPath);
253 [ + - ]: 14 : q.exec();
254 : :
255 : : // Subtract from total_docs
256 [ + + ]: 14 : if (docCount > 0) {
257 [ + - ]: 3 : q.prepare(QStringLiteral(
258 : : "UPDATE meta SET value = CAST(MAX(0, CAST(value AS INTEGER) - :count) "
259 : : "AS TEXT) WHERE key = 'total_docs'"));
260 [ + - ]: 6 : q.bindValue(QStringLiteral(":count"), docCount);
261 [ + - ]: 3 : q.exec();
262 : : }
263 : :
264 [ + - ]: 14 : m_db.commit();
265 : 14 : }
266 : :
267 : : // T-287: Rename folder in training data (for folder rename/move operations)
268 : 8 : void FolderPredictor::renameFolderData(const QString &oldPath,
269 : : const QString &newPath) {
270 [ + - + + ]: 8 : if (!m_db.isOpen())
271 : 1 : return;
272 : :
273 [ + - ]: 7 : m_db.transaction();
274 : :
275 [ + - ]: 7 : QSqlQuery q(m_db);
276 : :
277 : : // Update token_counts
278 [ + - ]: 7 : q.prepare(QStringLiteral(
279 : : "UPDATE token_counts SET folder = :newPath WHERE folder = :oldPath"));
280 [ + - ]: 14 : q.bindValue(QStringLiteral(":newPath"), newPath);
281 [ + - ]: 14 : q.bindValue(QStringLiteral(":oldPath"), oldPath);
282 [ + - ]: 7 : q.exec();
283 : :
284 : : // Update folder_doc_counts
285 [ + - ]: 7 : q.prepare(QStringLiteral(
286 : : "UPDATE folder_doc_counts SET folder = :newPath WHERE folder = :oldPath"));
287 [ + - ]: 14 : q.bindValue(QStringLiteral(":newPath"), newPath);
288 [ + - ]: 14 : q.bindValue(QStringLiteral(":oldPath"), oldPath);
289 [ + - ]: 7 : q.exec();
290 : :
291 : : // Update trained message identities
292 [ + - ]: 7 : q.prepare(QStringLiteral(
293 : : "UPDATE trained_messages SET folder = :newPath WHERE folder = :oldPath"));
294 [ + - ]: 14 : q.bindValue(QStringLiteral(":newPath"), newPath);
295 [ + - ]: 14 : q.bindValue(QStringLiteral(":oldPath"), oldPath);
296 [ + - ]: 7 : q.exec();
297 : :
298 [ + - ]: 7 : m_db.commit();
299 : 7 : }
300 : :
301 : : // --- Tokenizer ---
302 : :
303 : 1206 : QString FolderPredictor::extractEmail(const QString &fromField) const {
304 : : // "Max Mustermann <max@example.com>" → "max@example.com"
305 [ + + + - ]: 1224 : thread_local static QRegularExpression angleRe(QStringLiteral("<([^>]+)>"));
306 [ + - ]: 1206 : auto match = angleRe.match(fromField);
307 [ + - + + ]: 1206 : if (match.hasMatch()) {
308 [ + - + - : 55 : return match.captured(1).trimmed().toLower();
+ - ]
309 : : }
310 [ + - + - ]: 1151 : return fromField.trimmed().toLower();
311 : 1206 : }
312 : :
313 : 761 : QStringList FolderPredictor::tokenize(const QString &from,
314 : : const QString &subject,
315 : : const QString &to) const {
316 : 761 : QStringList tokens;
317 : :
318 : : // --- From tokens (boosted 3× — sender is strongest signal) ---
319 [ + - ]: 761 : QString email = extractEmail(from);
320 [ + + ]: 761 : if (!email.isEmpty()) {
321 : : // Full email address (3× boost)
322 [ + + ]: 3012 : for (int b = 0; b < 3; ++b)
323 [ + - + - ]: 2259 : tokens.append(QStringLiteral("from:") + email);
324 : :
325 : 753 : int atIdx = email.indexOf(QLatin1Char('@'));
326 [ + + ]: 753 : if (atIdx > 0) {
327 [ + - ]: 751 : QString user = email.left(atIdx);
328 [ + - ]: 751 : QString domain = email.mid(atIdx + 1);
329 [ + - + - ]: 751 : tokens.append(QStringLiteral("from:") + user);
330 : : // Domain (2× boost — most mails from same domain go to same folder)
331 [ + + ]: 2253 : for (int b = 0; b < 2; ++b) {
332 [ + - + - ]: 1502 : tokens.append(QStringLiteral("from:") + domain);
333 [ + - + - ]: 1502 : tokens.append(QStringLiteral("from_domain:") + domain);
334 : : }
335 : 751 : }
336 : : }
337 : :
338 : : // --- Subject tokens ---
339 : 761 : QString subj = subject;
340 : : // Strip Re:/Fwd:/AW:/WG: prefixes
341 : : thread_local static QRegularExpression prefixRe(
342 : 36 : QStringLiteral("^\\s*(Re|Fwd|AW|WG)\\s*:\\s*"),
343 [ + + + - ]: 797 : QRegularExpression::CaseInsensitiveOption);
344 [ + - + + ]: 796 : while (subj.contains(prefixRe)) {
345 [ + - ]: 35 : subj.replace(prefixRe, QString());
346 : : }
347 : :
348 : : // Split on whitespace + punctuation
349 [ + + + - ]: 779 : thread_local static QRegularExpression splitRe(QStringLiteral("[\\s,.;:!?()\\[\\]{}<>\"'/\\\\]+"));
350 [ + - ]: 761 : const QStringList words = subj.split(splitRe, Qt::SkipEmptyParts);
351 [ + + ]: 2519 : for (const QString &word : words) {
352 [ + - ]: 1758 : QString lower = word.toLower();
353 [ + + + + : 1758 : if (lower.size() >= 2 && !s_stopWords.contains(lower)) {
+ + ]
354 [ + - + - ]: 1505 : tokens.append(QStringLiteral("subj:") + lower);
355 : : }
356 : 1758 : }
357 : :
358 : : // --- To tokens ---
359 : : // May contain multiple addresses separated by comma/semicolon
360 [ + + + - ]: 779 : thread_local static QRegularExpression addrSplitRe(QStringLiteral("[,;]+"));
361 [ + - ]: 761 : const QStringList toAddrs = to.split(addrSplitRe, Qt::SkipEmptyParts);
362 [ + + ]: 1188 : for (const QString &addr : toAddrs) {
363 [ + - ]: 427 : QString toEmail = extractEmail(addr);
364 [ + - + - : 427 : if (!toEmail.isEmpty() && toEmail.contains(QLatin1Char('@'))) {
+ + + + ]
365 [ + - + - ]: 426 : tokens.append(QStringLiteral("to:") + toEmail);
366 : : }
367 : 427 : }
368 : :
369 : 761 : return tokens;
370 : 761 : }
371 : :
372 : : // --- Training ---
373 : :
374 : 611 : void FolderPredictor::train(const QString &from, const QString &subject,
375 : : const QString &to, const QString &targetFolder) {
376 : 611 : QMutexLocker locker(&m_mutex);
377 [ + - + + : 611 : if (!m_db.isOpen() || targetFolder.isEmpty())
+ + + + ]
378 : 2 : return;
379 : :
380 [ + - ]: 609 : const QStringList tokens = tokenize(from, subject, to);
381 [ + + ]: 609 : if (tokens.isEmpty())
382 : 1 : return;
383 : :
384 [ + - ]: 608 : QSqlQuery q(m_db);
385 [ + - ]: 608 : m_db.transaction();
386 : :
387 : : // Update token counts
388 [ + - ]: 608 : q.prepare(QStringLiteral(
389 : : "INSERT INTO token_counts (token, folder, count) VALUES (?, ?, 1) "
390 : : "ON CONFLICT(token, folder) DO UPDATE SET count = count + 1"));
391 [ + + ]: 6993 : for (const QString &token : tokens) {
392 [ + - ]: 6385 : q.bindValue(0, token);
393 [ + - ]: 6385 : q.bindValue(1, targetFolder);
394 [ + - ]: 6385 : q.exec();
395 : : }
396 : :
397 : : // Update folder document count
398 [ + - ]: 608 : q.prepare(QStringLiteral(
399 : : "INSERT INTO folder_doc_counts (folder, doc_count) VALUES (?, 1) "
400 : : "ON CONFLICT(folder) DO UPDATE SET doc_count = doc_count + 1"));
401 [ + - ]: 608 : q.bindValue(0, targetFolder);
402 [ + - ]: 608 : q.exec();
403 : :
404 : : // Increment total docs
405 [ + - ]: 608 : q.exec(QStringLiteral(
406 : : "UPDATE meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) "
407 : : "WHERE key = 'total_docs'"));
408 : :
409 [ + - ]: 608 : m_db.commit();
410 [ + + + + ]: 612 : }
411 : :
412 : 19 : void FolderPredictor::untrain(const QString &from, const QString &subject,
413 : : const QString &to, const QString &wrongFolder) {
414 : 19 : QMutexLocker locker(&m_mutex);
415 [ + - + + : 19 : if (!m_db.isOpen() || wrongFolder.isEmpty())
+ + + + ]
416 : 2 : return;
417 : :
418 [ + - ]: 17 : const QStringList tokens = tokenize(from, subject, to);
419 [ + + ]: 17 : if (tokens.isEmpty())
420 : 1 : return;
421 : :
422 [ + - ]: 16 : QSqlQuery q(m_db);
423 [ + - ]: 16 : m_db.transaction();
424 : :
425 : : // Decrement token counts (don't go below 0)
426 [ + - ]: 16 : q.prepare(QStringLiteral(
427 : : "UPDATE token_counts SET count = MAX(count - 1, 0) "
428 : : "WHERE token = ? AND folder = ?"));
429 [ + + ]: 175 : for (const QString &token : tokens) {
430 [ + - ]: 159 : q.bindValue(0, token);
431 [ + - ]: 159 : q.bindValue(1, wrongFolder);
432 [ + - ]: 159 : q.exec();
433 : : }
434 : :
435 : : // Clean up zero-count entries
436 [ + - ]: 16 : q.exec(QStringLiteral("DELETE FROM token_counts WHERE count = 0"));
437 : :
438 : : // Decrement folder doc count
439 [ + - ]: 16 : q.prepare(QStringLiteral(
440 : : "UPDATE folder_doc_counts SET doc_count = MAX(doc_count - 1, 0) "
441 : : "WHERE folder = ?"));
442 [ + - ]: 16 : q.bindValue(0, wrongFolder);
443 [ + - ]: 16 : q.exec();
444 : :
445 : : // Decrement total docs
446 [ + - ]: 16 : q.exec(QStringLiteral(
447 : : "UPDATE meta SET value = CAST(MAX(CAST(value AS INTEGER) - 1, 0) AS TEXT) "
448 : : "WHERE key = 'total_docs'"));
449 : :
450 [ + - ]: 16 : m_db.commit();
451 [ + + + + ]: 20 : }
452 : :
453 : : // --- Pre-training from cache (T-167) ---
454 : :
455 : 52 : void FolderPredictor::trainFromCache(MailCache *cache, const QString &account,
456 : : const QStringList &excludeFolders) {
457 : 52 : QMutexLocker locker(&m_mutex);
458 [ + - + + : 52 : if (!m_db.isOpen() || !cache)
+ + + + ]
459 : 3 : return;
460 : :
461 : : // Check idempotency
462 [ + - ]: 49 : QSqlQuery q(m_db);
463 [ + - ]: 49 : q.prepare(QStringLiteral(
464 : : "SELECT value FROM meta WHERE key = 'pretrained_account'"));
465 [ + - ]: 49 : q.exec();
466 [ + - + + : 49 : if (q.next() && q.value(0).toString() == account) {
+ - + - +
+ + + + +
+ + - - -
- ]
467 : 1 : return; // Already pre-trained for this account
468 : : }
469 : :
470 : : // Get all folder paths
471 [ + - ]: 48 : QStringList folderPaths = cache->allFolderPaths(account);
472 [ + + ]: 48 : if (folderPaths.isEmpty())
473 : 35 : return;
474 : :
475 : : // Filter excluded folders (case-insensitive match)
476 : 13 : QStringList trainFolders;
477 [ + - + - : 33 : for (const QString &path : folderPaths) {
+ + ]
478 : 20 : bool excluded = false;
479 [ + + ]: 41 : for (const QString &ex : excludeFolders) {
480 : 128 : if (path.compare(ex, Qt::CaseInsensitive) == 0 ||
481 [ + - + - : 64 : path.endsWith(QLatin1Char('.') + ex, Qt::CaseInsensitive) ||
+ + + + -
- ]
482 [ + - + - : 63 : path.endsWith(QLatin1Char('/') + ex, Qt::CaseInsensitive) ||
+ + + + -
- ]
483 [ + + + - : 125 : path.startsWith(ex + QLatin1Char('.'), Qt::CaseInsensitive) ||
+ - + + +
+ - - ]
484 [ + - + - : 61 : path.startsWith(ex + QLatin1Char('/'), Qt::CaseInsensitive)) {
+ + + + +
+ - - ]
485 : 12 : excluded = true;
486 : 12 : break;
487 : : }
488 : : }
489 [ + + ]: 20 : if (!excluded)
490 [ + - ]: 8 : trainFolders.append(path);
491 : : }
492 : :
493 : : // Count total headers for progress
494 : 13 : int totalHeaders = 0;
495 [ + - + - : 21 : for (const QString &folder : trainFolders) {
+ + ]
496 [ + - ]: 8 : totalHeaders += cache->headersByFolder(account, folder).size();
497 : : }
498 : :
499 [ + + ]: 13 : if (totalHeaders == 0)
500 : 7 : return;
501 : :
502 : 6 : int processed = 0;
503 [ + - + - : 13 : for (const QString &folder : trainFolders) {
+ + ]
504 [ + - ]: 7 : const QList<MailHeader> headers = cache->headersByFolder(account, folder);
505 [ + + ]: 7 : if (headers.isEmpty())
506 : 1 : continue;
507 : :
508 : : // Batch train per folder (single transaction)
509 [ + - ]: 6 : m_db.transaction();
510 [ + + ]: 18 : for (const MailHeader &h : headers) {
511 [ + - ]: 12 : const QStringList tokens = tokenize(h.from, h.subject, h.to);
512 [ + + ]: 12 : if (tokens.isEmpty()) {
513 : 1 : ++processed;
514 : 1 : continue;
515 : : }
516 : :
517 [ + - ]: 11 : QSqlQuery seen(m_db);
518 [ + - ]: 11 : seen.prepare(QStringLiteral(
519 : : "INSERT OR IGNORE INTO trained_messages(account, folder, uid) "
520 : : "VALUES(?, ?, ?)"));
521 [ + - ]: 11 : seen.bindValue(0, account);
522 [ + - ]: 11 : seen.bindValue(1, folder);
523 [ + - ]: 11 : seen.bindValue(2, h.uid);
524 [ + - + - : 11 : if (!seen.exec() || seen.numRowsAffected() == 0) {
+ - + + +
+ ]
525 : 2 : ++processed;
526 : 2 : continue;
527 : : }
528 : :
529 [ + - ]: 9 : QSqlQuery tq(m_db);
530 [ + - ]: 9 : tq.prepare(QStringLiteral(
531 : : "INSERT INTO token_counts (token, folder, count) VALUES (?, ?, 1) "
532 : : "ON CONFLICT(token, folder) DO UPDATE SET count = count + 1"));
533 [ + + ]: 105 : for (const QString &token : tokens) {
534 [ + - ]: 96 : tq.bindValue(0, token);
535 [ + - ]: 96 : tq.bindValue(1, folder);
536 [ + - ]: 96 : tq.exec();
537 : : }
538 : :
539 : : // folder doc count
540 [ + - ]: 9 : tq.prepare(QStringLiteral(
541 : : "INSERT INTO folder_doc_counts (folder, doc_count) VALUES (?, 1) "
542 : : "ON CONFLICT(folder) DO UPDATE SET doc_count = doc_count + 1"));
543 [ + - ]: 9 : tq.bindValue(0, folder);
544 [ + - ]: 9 : tq.exec();
545 : :
546 : : // total docs
547 [ + - ]: 9 : tq.exec(QStringLiteral(
548 : : "UPDATE meta SET value = CAST(CAST(value AS INTEGER) + 1 AS TEXT) "
549 : : "WHERE key = 'total_docs'"));
550 : :
551 : 9 : ++processed;
552 [ + + + + ]: 14 : }
553 [ + - ]: 6 : m_db.commit();
554 : :
555 [ + - ]: 6 : emit trainingProgress(processed, totalHeaders);
556 [ + + ]: 7 : }
557 : :
558 : : // Mark as pre-trained
559 [ + - ]: 6 : q.prepare(QStringLiteral(
560 : : "INSERT OR REPLACE INTO meta (key, value) VALUES ('pretrained_account', ?)"));
561 [ + - ]: 6 : q.bindValue(0, account);
562 [ + - ]: 6 : q.exec();
563 [ + + + + : 144 : }
+ + + + ]
564 : :
565 : : // --- Prediction ---
566 : :
567 : 120 : QMap<QString, double> FolderPredictor::computeScores(
568 : : const QStringList &tokens) const {
569 : 120 : QMap<QString, double> scores;
570 : :
571 [ + - ]: 120 : int totalDocs = totalDocuments();
572 [ + + ]: 120 : if (totalDocs < kMinDocsForPrediction)
573 : 88 : return scores;
574 : :
575 : : // Get all folders and their doc counts
576 [ + - ]: 32 : QSqlQuery q(m_db);
577 [ + - ]: 32 : q.exec(QStringLiteral("SELECT folder, doc_count FROM folder_doc_counts "
578 : : "WHERE doc_count > 0"));
579 : 32 : QMap<QString, int> folderDocs;
580 [ + - + + ]: 84 : while (q.next()) {
581 [ + - + - : 52 : folderDocs[q.value(0).toString()] = q.value(1).toInt();
+ - + - +
- ]
582 : : }
583 : :
584 [ + - + + ]: 32 : if (folderDocs.isEmpty())
585 : 2 : return scores;
586 : :
587 : : // Get vocabulary size (number of distinct tokens)
588 [ + - ]: 30 : q.exec(QStringLiteral("SELECT COUNT(DISTINCT token) FROM token_counts"));
589 : 30 : int vocabSize = 1; // minimum 1 to avoid division by zero
590 [ + - + - ]: 30 : if (q.next())
591 [ + - + - ]: 30 : vocabSize = qMax(1, q.value(0).toInt());
592 : :
593 : : // Get total tokens per folder
594 : 30 : QMap<QString, int> folderTotalTokens;
595 [ + - ]: 30 : q.exec(QStringLiteral(
596 : : "SELECT folder, SUM(count) FROM token_counts GROUP BY folder"));
597 [ + - + + ]: 81 : while (q.next()) {
598 [ + - + - : 51 : folderTotalTokens[q.value(0).toString()] = q.value(1).toInt();
+ - + - +
- ]
599 : : }
600 : :
601 : : // --- Batch query: load all matching token counts in one SQL call ---
602 : : // Build IN-clause placeholders
603 : 30 : QStringList uniqueTokens = tokens;
604 [ + - ]: 30 : uniqueTokens.removeDuplicates();
605 : 30 : QString placeholders;
606 [ + + ]: 215 : for (int i = 0; i < uniqueTokens.size(); ++i) {
607 [ + + ]: 185 : if (i > 0)
608 [ + - ]: 155 : placeholders += QLatin1Char(',');
609 [ + - ]: 185 : placeholders += QLatin1Char('?');
610 : : }
611 : :
612 [ + - ]: 30 : QSqlQuery batchQ(m_db);
613 [ + - ]: 30 : batchQ.prepare(
614 : 60 : QStringLiteral("SELECT token, folder, count FROM token_counts "
615 : : "WHERE token IN (%1)")
616 [ + - ]: 60 : .arg(placeholders));
617 [ + + ]: 215 : for (int i = 0; i < uniqueTokens.size(); ++i) {
618 [ + - + - ]: 185 : batchQ.bindValue(i, uniqueTokens[i]);
619 : : }
620 [ + - ]: 30 : batchQ.exec();
621 : :
622 : : // Build lookup map: (token, folder) → count
623 : 30 : QMap<QPair<QString, QString>, int> tokenCounts;
624 [ + - + + ]: 196 : while (batchQ.next()) {
625 [ + - + - : 166 : tokenCounts[{batchQ.value(0).toString(), batchQ.value(1).toString()}] =
+ - + - ]
626 [ + - + - : 332 : batchQ.value(2).toInt();
+ - ]
627 : : }
628 : :
629 : : // Compute log P(folder|tokens) for each folder
630 : 30 : constexpr double kAlpha = 0.1;
631 [ + - + - : 82 : for (auto it = folderDocs.constBegin(); it != folderDocs.constEnd(); ++it) {
+ + ]
632 : 52 : const QString &folder = it.key();
633 : 52 : int docCount = it.value();
634 : :
635 : : // Prior: P(folder) = doc_count / total_docs
636 : 52 : double logPrior = qLn(static_cast<double>(docCount) / totalDocs);
637 : :
638 : : // Likelihood: P(token|folder) for each token with Laplace smoothing
639 : 52 : double logLikelihood = 0.0;
640 [ + - ]: 52 : int totalTokensInFolder = folderTotalTokens.value(folder, 0);
641 : :
642 [ + + ]: 613 : for (const QString &token : tokens) {
643 [ + - ]: 561 : int tokenCount = tokenCounts.value({token, folder}, 0);
644 : :
645 : 561 : double prob = static_cast<double>(tokenCount + kAlpha) /
646 : 561 : (totalTokensInFolder + kAlpha * vocabSize);
647 : 561 : logLikelihood += qLn(prob);
648 : : }
649 : :
650 [ + - ]: 52 : scores[folder] = logPrior + logLikelihood;
651 : : }
652 : :
653 : 30 : return scores;
654 : 32 : }
655 : :
656 : 30 : QString FolderPredictor::predict(const QString &from, const QString &subject,
657 : : const QString &to,
658 : : double *confidence) const {
659 : 30 : QMutexLocker locker(&m_mutex);
660 [ + - + + ]: 30 : if (!m_db.isOpen()) {
661 [ + + ]: 2 : if (confidence)
662 : 1 : *confidence = 0.0;
663 : 2 : return {};
664 : : }
665 : :
666 [ + - ]: 28 : const QStringList tokens = tokenize(from, subject, to);
667 [ + + ]: 28 : if (tokens.isEmpty()) {
668 [ + + ]: 2 : if (confidence)
669 : 1 : *confidence = 0.0;
670 : 2 : return {};
671 : : }
672 : :
673 [ + - ]: 26 : QMap<QString, double> scores = computeScores(tokens);
674 [ + - + + ]: 26 : if (scores.isEmpty()) {
675 [ + + ]: 8 : if (confidence)
676 : 4 : *confidence = 0.0;
677 : 8 : return {};
678 : : }
679 : :
680 : : // Find best and second-best scores
681 : 18 : QString bestFolder;
682 : 18 : double bestScore = -std::numeric_limits<double>::infinity();
683 : 18 : double secondBestScore = -std::numeric_limits<double>::infinity();
684 : :
685 [ + - + - : 48 : for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
+ + ]
686 [ + + ]: 30 : if (it.value() > bestScore) {
687 : 25 : secondBestScore = bestScore;
688 : 25 : bestScore = it.value();
689 : 25 : bestFolder = it.key();
690 [ + - ]: 5 : } else if (it.value() > secondBestScore) {
691 : 5 : secondBestScore = it.value();
692 : : }
693 : : }
694 : :
695 : : // Direct-evidence check: does the best folder have actual from-token data?
696 : : // Without this, senders unknown to the model get nonsensical predictions.
697 [ + - ]: 18 : QString fullEmail = extractEmail(from);
698 [ + + ]: 18 : if (!fullEmail.isEmpty()) {
699 [ + - ]: 17 : QString fullToken = QStringLiteral("from:") + fullEmail;
700 [ + - ]: 17 : QSqlQuery evQ(m_db);
701 [ + - ]: 17 : evQ.prepare(QStringLiteral(
702 : : "SELECT count FROM token_counts WHERE token = ? AND folder = ?"));
703 [ + - ]: 17 : evQ.bindValue(0, fullToken);
704 [ + - ]: 17 : evQ.bindValue(1, bestFolder);
705 [ + - ]: 17 : evQ.exec();
706 [ + - + + : 17 : bool hasEvidence = evQ.next() && evQ.value(0).toInt() > 0;
+ - + - +
- + + -
- ]
707 : :
708 [ + + ]: 17 : if (!hasEvidence) {
709 : : // Check if ANY folder has this sender — if not, the model
710 : : // simply doesn't know this sender at all.
711 [ + - ]: 3 : evQ.bindValue(0, fullToken);
712 : : // Re-prepare for single-column query
713 [ + - ]: 3 : evQ.prepare(QStringLiteral(
714 : : "SELECT 1 FROM token_counts WHERE token = ? LIMIT 1"));
715 [ + - ]: 3 : evQ.bindValue(0, fullToken);
716 [ + - ]: 3 : evQ.exec();
717 [ + - + + ]: 3 : if (!evQ.next()) {
718 : : // Sender completely unknown — no prediction possible
719 [ + + ]: 2 : if (confidence)
720 : 1 : *confidence = 0.0;
721 : 2 : return {};
722 : : }
723 : : }
724 [ + + + + ]: 19 : }
725 : :
726 : : // Compute confidence using softmax probability
727 [ + + ]: 16 : if (confidence) {
728 [ + - + + ]: 15 : if (scores.size() <= 1) {
729 : 4 : *confidence = 1.0;
730 : : } else {
731 : 11 : double sumExp = 0.0;
732 [ + - + - : 34 : for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
+ + ]
733 : 23 : sumExp += qExp(it.value() - bestScore);
734 : : }
735 : 11 : *confidence = 1.0 / sumExp;
736 [ + - ]: 11 : *confidence = qBound(0.0, *confidence, 1.0);
737 : : }
738 : : }
739 : :
740 : 16 : return bestFolder;
741 : 30 : }
742 : :
743 : 96 : QList<QPair<QString, double>> FolderPredictor::predictTop(
744 : : const QString &from, const QString &subject, const QString &to,
745 : : int n) const {
746 : 96 : QMutexLocker locker(&m_mutex);
747 : 96 : QList<QPair<QString, double>> result;
748 : :
749 [ + - + + ]: 96 : if (!m_db.isOpen())
750 : 1 : return result;
751 : :
752 [ + - ]: 95 : const QStringList tokens = tokenize(from, subject, to);
753 [ + + ]: 95 : if (tokens.isEmpty())
754 : 1 : return result;
755 : :
756 [ + - ]: 94 : QMap<QString, double> scores = computeScores(tokens);
757 [ + - + + ]: 94 : if (scores.isEmpty())
758 : 82 : return result;
759 : :
760 : : // Convert to list and sort by score descending
761 [ + - + - : 34 : for (auto it = scores.constBegin(); it != scores.constEnd(); ++it) {
+ + ]
762 [ + - ]: 22 : result.append({it.key(), it.value()});
763 : : }
764 : :
765 [ + - + - : 12 : std::sort(result.begin(), result.end(),
+ - ]
766 : 15 : [](const QPair<QString, double> &a,
767 : : const QPair<QString, double> &b) {
768 : 15 : return a.second > b.second;
769 : : });
770 : :
771 : : // Normalize scores to [0, 1] using softmax
772 [ + - ]: 12 : if (!result.isEmpty()) {
773 [ + - ]: 12 : double maxScore = result.first().second;
774 : 12 : double sumExp = 0.0;
775 [ + - + - : 34 : for (const auto &pair : result) {
+ + ]
776 : 22 : sumExp += qExp(pair.second - maxScore);
777 : : }
778 [ + - + - : 34 : for (auto &pair : result) {
+ + ]
779 : 22 : pair.second = qExp(pair.second - maxScore) / sumExp;
780 : : }
781 : : }
782 : :
783 : : // Truncate to n entries
784 [ + + ]: 12 : if (result.size() > n) {
785 [ + - ]: 2 : result = result.mid(0, n);
786 : : }
787 : :
788 : 12 : return result;
789 : 96 : }
790 : :
791 : : // --- Statistics ---
792 : :
793 : 254 : int FolderPredictor::totalDocuments() const {
794 [ + - + + ]: 254 : if (!m_db.isOpen())
795 : 2 : return 0;
796 : :
797 [ + - ]: 252 : QSqlQuery q(m_db);
798 [ + - ]: 252 : q.exec(QStringLiteral(
799 : : "SELECT value FROM meta WHERE key = 'total_docs'"));
800 [ + - + + ]: 252 : if (q.next())
801 [ + - + - ]: 249 : return q.value(0).toInt();
802 : 3 : return 0;
803 : 252 : }
804 : :
805 : 10 : int FolderPredictor::folderCount() const {
806 [ + - + + ]: 10 : if (!m_db.isOpen())
807 : 1 : return 0;
808 : :
809 [ + - ]: 9 : QSqlQuery q(m_db);
810 [ + - ]: 9 : q.exec(QStringLiteral(
811 : : "SELECT COUNT(*) FROM folder_doc_counts WHERE doc_count > 0"));
812 [ + - + + ]: 9 : if (q.next())
813 [ + - + - ]: 8 : return q.value(0).toInt();
814 : 1 : return 0;
815 : 9 : }
816 : :
817 : 4 : QMap<QString, int> FolderPredictor::folderStats() const {
818 : 4 : QMap<QString, int> stats;
819 [ + - + + ]: 4 : if (!m_db.isOpen())
820 : 1 : return stats;
821 : :
822 [ + - ]: 3 : QSqlQuery q(m_db);
823 [ + - ]: 3 : q.exec(QStringLiteral(
824 : : "SELECT folder, doc_count FROM folder_doc_counts "
825 : : "WHERE doc_count > 0 ORDER BY doc_count DESC"));
826 [ + - + + ]: 5 : while (q.next()) {
827 [ + - + - : 2 : stats[q.value(0).toString()] = q.value(1).toInt();
+ - + - +
- ]
828 : : }
829 : 3 : return stats;
830 : 3 : }
|