MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - data - FolderPredictor.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 97.4 % 466 454
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 25 25
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 59.7 % 1014 605

             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 : }
        

Generated by: LCOV version 2.0-1