From fd1c4d7b4822034459167e9dc3a770084892e597 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:46:27 -0500 Subject: [PATCH 1/2] fix(analysis): file archives by their contents, report filename guesses honestly, drop prompt echoes From a review of 85 real results (33 landed in Uncategorized): - Archives: the category was hardcoded to 'archive' although the entry listing was read, so every zip of .stl parts reached Uncategorized. The dominant entry type (at least half the files) now picks the folder via getIntelligentCategory, taken only when it lands on one of the user's folders. - Filename fallback matched folder words as substrings ("Work" in "network", "code" in "barcode"). Folder words now match whole words, plural-tolerant. - Filename-only results reported 65-75%, above real content analyses. createFallbackAnalysis, the Office fallback and an unmatched archive now report FILENAME_ONLY_CONFIDENCE (50). - dropPromptEchoes clears summary/purpose/project/reasoning/type/entity when the model copied a schema instruction or a folder description into them, and caps confidence at 50. Run over 1,164 stored results it clears 64 files, all genuine copies, no false positives. Applied in the document, image and media paths. - calibrateConfidence caps at 65 (below auto-organize's 75) when the LLM's folder is kept although the embedding confidently prefers another. - Organize's "Create folder for " grouped on one field by exact text, which for a fallback analysis is the file's own name, so it never fired. findRecurringTheme groups on named entities and shared keywords (one tally per term), skipping file-name keys and form words; still 3+ files. checkjs floor 493 -> 494 for the new uncategorizedThemes.js. --- CLAUDE.md | 2 +- scripts/verify-checkjs-ratchet.js | 2 +- src/main/analysis/documentAnalysis.js | 73 ++++++- src/main/analysis/fallbackUtils.js | 66 ++++++- src/main/analysis/imageAnalysis.js | 4 +- src/main/analysis/mediaAnalysis.js | 19 +- src/main/analysis/semanticFolderMatcher.js | 26 ++- src/main/analysis/utils.js | 96 +++++++++- .../organize/UncategorizedActionsBar.jsx | 71 +------ .../organize/uncategorizedThemes.js | 180 ++++++++++++++++++ src/shared/performanceConstants.js | 8 +- test/analysis-fallbackUtils.test.js | 53 ++++++ ...nalysis-pipeline-bugfix-regression.test.js | 3 +- test/analysis-utils.test.js | 69 +++++++ test/components/uncategorizedThemes.test.js | 85 +++++++++ test/confidenceCalibration.test.js | 32 ++++ test/documentAnalysis.behavior.test.js | 4 +- test/documentAnalysis.extractionGaps.test.js | 45 ++++- 18 files changed, 740 insertions(+), 98 deletions(-) create mode 100644 src/renderer/components/organize/uncategorizedThemes.js create mode 100644 test/components/uncategorizedThemes.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 5a40e78..320e017 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ tree. `ci`) enforces a monotonic floor and blocks new .js under `COVERED_DIRS` (`src/shared`, `src/preload`, `src/main/{ipc,services,analysis,errors,utils,workers,transports,core}`, `src/renderer/{services,hooks,phases,store,utils,components}` today (every `.js` under `src/` is - now opted in — 493/493 as of 2026-11-09, and `GLOBAL_DIRECTIVE_FLOOR` matches at 493) — see + now opted in — 494/494 as of 2026-11-11, and `GLOBAL_DIRECTIVE_FLOOR` matches at 494) — see `scripts/verify-checkjs-ratchet.js` for the authoritative list) from landing without the directive. When adding a file to a covered dir: put `// @ts-check` on line 1 and fix the errors before commit. When opting in a new file elsewhere: bump `GLOBAL_DIRECTIVE_FLOOR` in diff --git a/scripts/verify-checkjs-ratchet.js b/scripts/verify-checkjs-ratchet.js index d80eb91..b7db8e0 100644 --- a/scripts/verify-checkjs-ratchet.js +++ b/scripts/verify-checkjs-ratchet.js @@ -43,7 +43,7 @@ const COVERED_DIRS = [ 'src/renderer/utils', 'src/renderer/components' ]; -const GLOBAL_DIRECTIVE_FLOOR = 493; +const GLOBAL_DIRECTIVE_FLOOR = 494; // ──────────────────────────────────────────────────────────────────────── const DIRECTIVE_SCAN_LINES = 5; diff --git a/src/main/analysis/documentAnalysis.js b/src/main/analysis/documentAnalysis.js index f50880d..588a723 100644 --- a/src/main/analysis/documentAnalysis.js +++ b/src/main/analysis/documentAnalysis.js @@ -50,9 +50,10 @@ const { const { analyzeTextWithLlama, normalizeCategoryToSmartFolders } = require('./documentLlm'); const { getOrganizationGuidance } = require('./organizationGuidance'); const { getAnalysisLimits } = require('./analysisLimits'); -const { normalizeAnalysisResult } = require('./utils'); +const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils'); const { normalizeExtractedTextForStorage } = require('./analysisTextUtils'); const { + FILENAME_ONLY_CONFIDENCE, getIntelligentCategory, getIntelligentKeywords, safeSuggestedName, @@ -733,7 +734,8 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) { const intelligentKeywords = getIntelligentKeywords(fileName, fileExtension); let purpose = 'Office document (content extraction failed)'; - const confidence = 70; + // The contents could not be read: a filename guess, flagged for review. + const confidence = FILENAME_ONLY_CONFIDENCE; // A reader this build lacks, or a password, is not a capability a // model install restores: stamp a method the watcher never // retries (analysisProvenance.format_unsupported). @@ -848,7 +850,17 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) { ? listedKeywords : getIntelligentKeywords(fileName, fileExtension); - const category = normalizeCategoryToSmartFolders('archive', smartFolders); + // File an archive by what it holds: a zip of .stl parts belongs with + // 3D prints, not under the generic 'archive' (which only ever reached + // Uncategorized). Taken only when the entries' dominant type lands on + // one of the user's own folders. + const contentFolder = archiveInfo.dominantExtension + ? findSmartFolderName( + getIntelligentCategory(fileName, `.${archiveInfo.dominantExtension}`, smartFolders), + smartFolders + ) + : null; + const category = contentFolder || normalizeCategoryToSmartFolders('archive', smartFolders); return { purpose: archiveInfo.summary || 'Archive file', @@ -856,7 +868,9 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) { category, date: fileDate, keywords, - confidence: 70, + // Only the entry names were read. A folder matched on them is a fair + // guess; the generic 'archive' bucket is not one. + confidence: contentFolder ? 70 : FILENAME_ONLY_CONFIDENCE, suggestedName: safeSuggestedName(fileName, fileExtension), extractionMethod: 'archive' }; @@ -1032,7 +1046,7 @@ async function analyzeDocumentFile(filePath, smartFolders = [], options = {}) { }); const normalized = normalizeAnalysisResult( { - ...analysis, + ...dropPromptEchoes(analysis, smartFolders), llmSuggestedName: analysis.suggestedName || null, contentLength: extractedTextLength, extractionMethod: 'content', @@ -1226,6 +1240,7 @@ async function tryExtractArchiveMetadata(filePath) { const listing = await describeArchive(filePath); if (listing) { info.keywords = deriveKeywordsFromFilenames(listing.entryNames); + info.dominantExtension = dominantEntryExtension(listing.entryNames); info.summary = listing.summary; if (typeof listing.totalEntries === 'number') info.totalEntries = listing.totalEntries; info.processedEntries = listing.entryNames.length; @@ -1251,6 +1266,7 @@ async function tryExtractArchiveMetadata(filePath) { const sampledEntries = allEntries.slice(0, 50); const names = sampledEntries.map((/** @type {any} */ e) => e.entryName); info.keywords = deriveKeywordsFromFilenames(names); + info.dominantExtension = dominantEntryExtension(names); info.totalEntries = totalEntries; info.processedEntries = sampledEntries.length; info.summary = `ZIP archive with ${totalEntries} entries`; @@ -1267,6 +1283,53 @@ async function tryExtractArchiveMetadata(filePath) { return info; } +/** + * The extension that at least half of an archive's file entries share, or + * null when no single type dominates. Folder entries are ignored. + * @param {string[]} names + * @returns {string|null} + */ +function dominantEntryExtension(names) { + /** @type {Record} */ + const counts = {}; + let files = 0; + for (const name of names || []) { + const base = String(name).split(/[\\/]/).pop() || ''; + if (!base) continue; // folder entry ("parts/") + files += 1; + const dot = base.lastIndexOf('.'); + if (dot <= 0 || dot === base.length - 1) continue; + const ext = base.slice(dot + 1).toLowerCase(); + counts[ext] = (counts[ext] || 0) + 1; + } + let best = null; + let bestCount = 0; + for (const [ext, count] of Object.entries(counts)) { + if (count > bestCount) { + best = ext; + bestCount = count; + } + } + return best && bestCount * 2 >= files ? best : null; +} + +/** + * The user's folder whose name equals `candidate` (case-insensitive), or null. + * Uncategorized does not count: it is the absence of a match. + * @param {string|null|undefined} candidate + * @param {any[]} smartFolders + * @returns {string|null} + */ +function findSmartFolderName(candidate, smartFolders) { + if (typeof candidate !== 'string' || !candidate) return null; + const wanted = candidate.trim().toLowerCase(); + if (!wanted || wanted === 'uncategorized') return null; + const folder = (smartFolders || []).find( + (f) => typeof f?.name === 'string' && f.name.trim().toLowerCase() === wanted + ); + return folder ? folder.name : null; +} + /** @param {string[]} names */ function deriveKeywordsFromFilenames(names) { /** @type {Record} */ diff --git a/src/main/analysis/fallbackUtils.js b/src/main/analysis/fallbackUtils.js index ef7d071..bb707ae 100644 --- a/src/main/analysis/fallbackUtils.js +++ b/src/main/analysis/fallbackUtils.js @@ -22,6 +22,47 @@ const FolderMatchingService = require('../services/FolderMatchingService'); const { createLogger } = require('../../shared/logger'); const logger = createLogger('FallbackUtils'); +/** + * Confidence for a result built from the file name alone (nothing inside the + * file was read). Below auto-organize's default threshold (75%) and in the + * history view's warning band (50-69%), so these files ask for a look instead + * of reading as settled. They were reported at 65-75, above real content + * analyses that scored lower. + */ +const FILENAME_ONLY_CONFIDENCE = 50; + +/** + * Lowercased words of a file name, space-joined and space-padded so a phrase + * can be found as whole words: "AR+15_foregrip-stls.zip" -> " ar 15 foregrip stls zip ". + * @param {string} fileName + * @returns {string} + */ +function toWordString(fileName) { + const words = String(fileName || '') + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); + return ` ${words.join(' ')} `; +} + +/** + * Whether `phrase` appears in the file name as whole words, allowing a plural + * on its last word ("invoice" matches "invoices"). Substring matching filed + * "network" under a "Work" folder and "barcode" under "code". + * @param {string} wordString - From toWordString + * @param {string} phrase - Lowercased word or phrase + * @returns {boolean} + */ +function nameHasWords(wordString, phrase) { + const words = String(phrase || '') + .toLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .filter(Boolean); + if (words.length === 0) return false; + const joined = words.join(' '); + return [joined, `${joined}s`, `${joined}es`].some((form) => wordString.includes(` ${form} `)); +} + const normalizeCategoryToSmartFolders = typeof FolderMatchingService?.matchCategoryToFolder === 'function' ? FolderMatchingService.matchCategoryToFolder.bind(FolderMatchingService) @@ -67,6 +108,9 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { const extNoDot = lowerExtension.replace(/^\./, ''); if (smartFolders && smartFolders.length > 0) { + // Folder words are matched against the name's whole words (see + // nameHasWords); the generic pattern table further down is unchanged. + const nameWords = toWordString(fileName); const validFolders = smartFolders.filter( (f) => f && f.name && typeof f.name === 'string' && f.name.trim().length > 0 ); @@ -75,11 +119,11 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { for (const folder of validFolders) { const folderNameLower = folder.name.toLowerCase(); let score = 0; - if (lowerFileName.includes(folderNameLower)) score += 10; + if (nameHasWords(nameWords, folderNameLower)) score += 10; const folderWords = folderNameLower .split(/[\s_-]+/) .filter((/** @type {string} */ w) => w.length > 2); - for (const word of folderWords) if (lowerFileName.includes(word)) score += 8; + for (const word of folderWords) if (nameHasWords(nameWords, word)) score += 8; // SEMANTIC EXTENSION MATCHING: Understand that "3D printing" folder means .stl files belong there // This checks folder NAME for semantic concepts (e.g., "3D Prints" folder → .stl files) @@ -95,7 +139,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { const descWords = descLower .split(/[\s,.-]+/) .filter((/** @type {string} */ w) => w.length > 3); - for (const word of descWords) if (lowerFileName.includes(word)) score += 6; + for (const word of descWords) if (nameHasWords(nameWords, word)) score += 6; // SEMANTIC EXTENSION MATCHING: Understand that "models for my Ender 3" means .stl files // This checks folder DESCRIPTION for semantic concepts @@ -118,7 +162,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { if (Array.isArray(folder.semanticTags)) { for (const tag of folder.semanticTags) { const tagLower = String(tag).toLowerCase(); - if (lowerFileName.includes(tagLower)) score += 5; + if (nameHasWords(nameWords, tagLower)) score += 5; // Extension match in semantic tags (e.g., semanticTags: ["stl", "3d-model"]) if (extNoDot && (tagLower === lowerExtension || tagLower === extNoDot)) { score += 10; // High score for explicit extension in semantic tags @@ -129,7 +173,7 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { if (Array.isArray(folder.keywords)) { for (const kw of folder.keywords) { const kwLower = String(kw).toLowerCase(); - if (lowerFileName.includes(kwLower)) score += 4; + if (nameHasWords(nameWords, kwLower)) score += 4; // Extension match in keywords (e.g., keywords: [".stl", "3mf", "gcode"]) if (extNoDot && (kwLower === lowerExtension || kwLower === extNoDot)) { score += 12; // High score for explicit extension in keywords @@ -142,18 +186,18 @@ function getIntelligentCategory(fileName, extension, smartFolders = []) { if (Array.isArray(pathParts)) { const parts = pathParts.filter((p) => p && p.length > 2); for (const part of parts) { - if (lowerFileName.includes(part)) score += 3; + if (nameHasWords(nameWords, part)) score += 3; } } } if (folder.category) { for (const word of folder.category.toLowerCase().split(/[\s_-]+/)) { - if (word.length > 2 && lowerFileName.includes(word)) score += 2; + if (word.length > 2 && nameHasWords(nameWords, word)) score += 2; } } if (Array.isArray(folder.relatedFolders)) { for (const relatedName of folder.relatedFolders) - if (lowerFileName.includes(String(relatedName).toLowerCase())) score += 1; + if (nameHasWords(nameWords, String(relatedName))) score += 1; } if (folder.confidenceScore && folder.confidenceScore > 0.8) score *= 1.2; if (score > bestScore) { @@ -633,10 +677,13 @@ function createFallbackAnalysis(params) { fileExtension, reason, smartFolders = [], - confidence = 65, + confidence: requestedConfidence = FILENAME_ONLY_CONFIDENCE, type = 'document', options = {} } = params; + // Nothing but the name was read, so the result never claims more than a + // filename guess is worth, whatever the caller asked for. + const confidence = Math.min(requestedConfidence, FILENAME_ONLY_CONFIDENCE); const { extractionMethod = 'filename_fallback', @@ -698,6 +745,7 @@ function createFallbackAnalysis(params) { } module.exports = { + FILENAME_ONLY_CONFIDENCE, getIntelligentCategory, getIntelligentKeywords, safeSuggestedName, diff --git a/src/main/analysis/imageAnalysis.js b/src/main/analysis/imageAnalysis.js index 2cbda8d..2e2cc7a 100644 --- a/src/main/analysis/imageAnalysis.js +++ b/src/main/analysis/imageAnalysis.js @@ -29,7 +29,7 @@ const { formatOrganizationGuidanceBlock } = require('./organizationGuidance'); const { recognizeIfAvailable } = require('../utils/tesseractUtils'); -const { normalizeAnalysisResult } = require('./utils'); +const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils'); const { normalizeExtractedTextForStorage } = require('./analysisTextUtils'); const { getIntelligentCategory: getIntelligentImageCategory, @@ -2268,7 +2268,7 @@ async function runImageAnalysis(filePath, smartFolders, options) { const normalizedCategory = categoryMatch.folder; const normalized = normalizeAnalysisResult( { - ...analysis, + ...dropPromptEchoes(analysis, smartFolders), category: normalizedCategory || analysis.category, suggestedNewFolder: categoryMatch.suggestedNewFolder || analysis.suggestedNewFolder || undefined, diff --git a/src/main/analysis/mediaAnalysis.js b/src/main/analysis/mediaAnalysis.js index 512ca29..aa70d9f 100644 --- a/src/main/analysis/mediaAnalysis.js +++ b/src/main/analysis/mediaAnalysis.js @@ -28,7 +28,7 @@ const crypto = require('crypto'); const { createLogger } = require('../../shared/logger'); const { ERROR_CODES } = require('../../shared/errorCodes'); const { attachErrorCode } = require('../../shared/errorHandlingUtils'); -const { normalizeAnalysisResult } = require('./utils'); +const { normalizeAnalysisResult, dropPromptEchoes } = require('./utils'); const { isMemoryBudgetExceeded } = require('../../shared/analysisProvenance'); const { analyzeVideoWithCloud, resolveCloudFrameProvider } = require('./cloudMedia'); @@ -686,17 +686,20 @@ async function analyzeMediaFile(filePath, dependencies = {}, options = {}) { ? 'whisper-transcription' : 'no-speech'; + // Fields the model copied out of its prompt are cleared (and the + // confidence capped) before anything below reads them. + const answer = dropPromptEchoes(llmResult, smartFolders); const normalized = normalizeAnalysisResult( { - ...llmResult, + ...answer, type: kind, - category: llmResult?.category || baseFallback.category, - keywords: llmResult?.keywords || baseFallback.keywords, - confidence: llmResult?.confidence ?? baseFallback.confidence, - llmSuggestedName: llmResult?.suggestedName || null, - suggestedName: llmResult?.suggestedName, + category: answer?.category || baseFallback.category, + keywords: answer?.keywords || baseFallback.keywords, + confidence: answer?.confidence ?? baseFallback.confidence, + llmSuggestedName: answer?.suggestedName || null, + suggestedName: answer?.suggestedName, purpose: - llmResult?.purpose || + answer?.purpose || (usedCloudFrames ? 'Spoken content transcribed locally; video frames analyzed by the selected cloud provider' : hasUsableTranscript diff --git a/src/main/analysis/semanticFolderMatcher.js b/src/main/analysis/semanticFolderMatcher.js index ae3edfc..1e2a94b 100644 --- a/src/main/analysis/semanticFolderMatcher.js +++ b/src/main/analysis/semanticFolderMatcher.js @@ -220,9 +220,17 @@ function normalizeLlmConfidence(raw) { * @param {number|null} params.topScore - top folder cosine similarity in [0, 1], or null * @param {number|null} params.runnerUpScore - second-best folder cosine in [0, 1], or null * @param {boolean} params.llmCategoryUnmatchable - LLM picked a label that maps to no folder + * @param {boolean} [params.signalsDisagree] - The LLM's folder was kept although the + * embedding's best folder is a different one * @returns {{confidence: number, cap: number, reason: string|null, original: number, spread: number|null}} */ -function calibrateConfidence({ rawConfidence, topScore, runnerUpScore, llmCategoryUnmatchable }) { +function calibrateConfidence({ + rawConfidence, + topScore, + runnerUpScore, + llmCategoryUnmatchable, + signalsDisagree = false +}) { const original = typeof rawConfidence === 'number' && Number.isFinite(rawConfidence) ? Math.max(0, Math.min(100, Math.round(rawConfidence))) @@ -273,6 +281,10 @@ function calibrateConfidence({ rawConfidence, topScore, runnerUpScore, llmCatego setCap(CAL.UNMATCHABLE_CATEGORY_CAP ?? 75, 'unmatchable_category'); } + if (signalsDisagree) { + setCap(CAL.DISAGREEMENT_CAP ?? 65, 'llm_embedding_disagree'); + } + const confidence = Math.min(original, cap); return { confidence, @@ -522,6 +534,9 @@ async function applySemanticFolderMatching(params) { // Hoisted so the calibration block at the bottom of this function can // read it regardless of which override branch fired. let llmCategoryUnmatchable = false; + // Set when the LLM's folder is kept although the embedding confidently + // prefers another one; the calibration below caps the claim. + let signalsDisagree = false; // Process candidates and potentially override category if (Array.isArray(liveCandidates) && liveCandidates.length > 0) { @@ -595,6 +610,12 @@ async function applySemanticFolderMatching(params) { } } + signalsDisagree = + !shouldOverride && + categoryMatchesFolder && + !vectorAgrees && + top.score >= THRESHOLDS.FOLDER_MATCH_CONFIDENCE; + if (shouldOverride) { logger.info('[FolderMatcher] Embedding override - folder match exceeds LLM confidence', { type, @@ -683,7 +704,8 @@ async function applySemanticFolderMatching(params) { rawConfidence: preCalibrationPercent, topScore: typeof topCandidate?.score === 'number' ? topCandidate.score : null, runnerUpScore: typeof runnerUpCandidate?.score === 'number' ? runnerUpCandidate.score : null, - llmCategoryUnmatchable + llmCategoryUnmatchable, + signalsDisagree }); const confidencePercent = calibration.confidence; if (calibration.reason) { diff --git a/src/main/analysis/utils.js b/src/main/analysis/utils.js index ec50fb7..fa5cdf8 100644 --- a/src/main/analysis/utils.js +++ b/src/main/analysis/utils.js @@ -1,5 +1,6 @@ // @ts-check /** @typedef {import('../../shared/analysisSchema').AnalysisResult} AnalysisResult */ +const { ANALYSIS_SCHEMA_PROMPT } = require('../../shared/analysisSchema'); // Coerce a non-numeric confidence shape into a number on the caller's // existing scale. Models routinely return numeric strings ("0.85"), // percent strings ("85%"), or categorical tokens ("high"/"medium"/"low") @@ -87,6 +88,95 @@ function normalizeAnalysisResult(raw, fallback = {}) { return { ...result, ...normalized }; } +// Filler words left out when comparing a model's answer with the prompt. +const ECHO_STOPWORDS = new Set( + 'a an and are as at be by e g for from in into is it its of on or that the this to with'.split( + ' ' + ) +); +const ECHO_FIELDS = ['summary', 'purpose', 'project', 'reasoning', 'type', 'entity']; +// A value is an echo when nearly all of its words come from one source text +// AND it reproduces a good part of that text. The second test keeps a real +// answer that merely shares vocabulary ("A logo for a company or +// organization") from reading as a copy of a longer instruction. +const ECHO_CONTAINMENT = 0.8; +const ECHO_SOURCE_COVERAGE = 0.4; +const ECHO_MIN_WORDS = 4; +// Same ceiling as a filename-only guess (fallbackUtils FILENAME_ONLY_CONFIDENCE). +const ECHO_CONFIDENCE_CAP = 50; + +/** @param {unknown} text @returns {string[]} */ +function echoWords(text) { + return String(text ?? '') + .toLowerCase() + .replace(/['’]s\b/g, '') + .split(/[^\p{L}\p{N}]+/u) + .filter((w) => w && !ECHO_STOPWORDS.has(w)); +} + +/** + * Drop answers a model copied out of its own prompt instead of writing. + * + * Small vision models in particular fill a field with the schema's + * instruction for it ("A concise summary of the document's content.") or + * with a smart folder's description ("Art, graphic design, images not + * related to other categories, logos"). Kept, those read as real summaries, + * feed the embedding, and the category picked alongside them looked certain + * (one such answer filed a banner under Research at 90%). Copied fields are + * cleared; when any was copied, the confidence is capped like a filename + * guess so the file is flagged for review. Same idea as + * normalizeImageContentType, applied to the free-text fields. + * + * @param {Record} result - Model output (mutated copy returned) + * @param {Array<{ description?: string }>} [smartFolders] + * @returns {Record} + */ +function dropPromptEchoes(result, smartFolders = []) { + if (!result || typeof result !== 'object') return result; + const sources = [ + // The instructions without their "(e.g., ...)" examples: an example like + // 'sunset_beach' is a legitimate thing for a picture to show. + ...Object.values(ANALYSIS_SCHEMA_PROMPT) + .filter((v) => typeof v === 'string') + .map((v) => v.replace(/\(e\.g\.[^)]*\)/gi, ' ')), + ...(Array.isArray(smartFolders) ? smartFolders : []) + .map((f) => f?.description) + .filter((d) => typeof d === 'string' && d.trim()) + ] + .map((text) => new Set(echoWords(text))) + .filter((source) => source.size > 0); + + /** @param {unknown} value */ + const isEcho = (value) => { + if (typeof value !== 'string') return false; + // Compared without "(e.g., ...)" on both sides: a copied instruction + // carries its examples along. + const words = new Set(echoWords(value.replace(/\(e\.g\.[^)]*\)/gi, ' '))); + if (words.size < ECHO_MIN_WORDS) return false; + return sources.some((source) => { + let shared = 0; + for (const word of words) if (source.has(word)) shared += 1; + return ( + shared / words.size >= ECHO_CONTAINMENT && shared / source.size >= ECHO_SOURCE_COVERAGE + ); + }); + }; + + const echoed = ECHO_FIELDS.filter((field) => isEcho(result[field])); + if (echoed.length === 0) return result; + + const cleaned = { ...result }; + for (const field of echoed) cleaned[field] = null; + const confidence = _coerceConfidence(cleaned.confidence); + if (confidence !== null) { + // Callers use both scales; cap on the one the value is on. + cleaned.confidence = + confidence > 1 ? Math.min(confidence, ECHO_CONFIDENCE_CAP) : Math.min(confidence, 0.5); + } + cleaned.promptEchoFields = echoed; + return cleaned; +} + /** * Read the STRATOSORT_ANALYSIS_EMBEDDING_QUEUE_WAIT env flag in a single * canonical way. Three analysis modules (image, media, semanticFolderMatcher) @@ -105,4 +195,8 @@ function isPipelineEmbeddingQueueWaitEnabled() { ); } -module.exports = { normalizeAnalysisResult, isPipelineEmbeddingQueueWaitEnabled }; +module.exports = { + normalizeAnalysisResult, + dropPromptEchoes, + isPipelineEmbeddingQueueWaitEnabled +}; diff --git a/src/renderer/components/organize/UncategorizedActionsBar.jsx b/src/renderer/components/organize/UncategorizedActionsBar.jsx index 3a0825d..f949604 100644 --- a/src/renderer/components/organize/UncategorizedActionsBar.jsx +++ b/src/renderer/components/organize/UncategorizedActionsBar.jsx @@ -8,41 +8,10 @@ import { AddSmartFolderModal } from '../setup'; import { createLogger } from '../../../shared/logger'; import { smartFoldersIpc } from '../../services/ipc'; import { mapErrorToNotification } from '../../utils/errorMapping'; +import { findRecurringTheme } from './uncategorizedThemes'; const logger = createLogger('UncategorizedActionsBar'); -/** - * Threshold above which a recurring entity in uncategorized files becomes a - * Smart Folder suggestion. Picked so the user sees the suggestion early enough - * to be useful (after the third file) but not so eagerly that one-off entities - * trigger noise. - */ -const PATTERN_SUGGESTION_THRESHOLD = 3; - -/** - * Heuristic: extract a normalized "entity" key from a file's analysis result - * for grouping. Falls back through the most-specific to least-specific fields - * so we group on whatever signal the LLM produced. Returns null when no - * entity can be derived (file shouldn't contribute to pattern detection). - */ -function deriveEntityKey(file) { - const analysis = file?.analysis; - if (!analysis || typeof analysis !== 'object') return null; - const candidate = - analysis.vendor || - analysis.organization || - analysis.entity || - analysis.sender || - analysis.project || - analysis.category || - null; - if (!candidate || typeof candidate !== 'string') return null; - const trimmed = candidate.trim(); - if (!trimmed) return null; - // Lowercase + collapse whitespace so "Acme Inc" and "acme inc " group together. - return trimmed.toLowerCase().replace(/\s+/g, ' '); -} - /** * Determine whether a file is currently routed to the Uncategorized bucket — * either because no Smart Folder matched any of its analysis fields, or @@ -84,38 +53,9 @@ function UncategorizedActionsBar({ ((file) => findSmartFolderForCategory(file?.analysis?.category)); const uncategorized = (unprocessedFiles || []).filter((f) => isUncategorized(f, matcher)); - // Group by derived entity to find recurring patterns. - const counts = new Map(); - for (const file of uncategorized) { - const key = deriveEntityKey(file); - if (!key) continue; - const entry = counts.get(key) || { displayName: null, files: [] }; - // Preserve the first non-lowercased label we see for display. - if (!entry.displayName) { - const raw = - file?.analysis?.vendor || - file?.analysis?.organization || - file?.analysis?.entity || - file?.analysis?.sender || - file?.analysis?.project || - file?.analysis?.category || - key; - entry.displayName = String(raw).trim(); - } - entry.files.push(file); - counts.set(key, entry); - } - - // Find the top suggestion: largest group above threshold. We surface only - // ONE suggestion at a time to avoid stacking banners — once the user - // creates that folder (or dismisses), the next pattern will surface. - let topGroup = null; - for (const entry of counts.values()) { - if (entry.files.length < PATTERN_SUGGESTION_THRESHOLD) continue; - if (!topGroup || entry.files.length > topGroup.files.length) topGroup = entry; - } - - return { uncategorizedFiles: uncategorized, suggestion: topGroup }; + // One suggestion at a time, the largest recurring theme: once the user + // creates that folder, its files leave this list and the next theme shows. + return { uncategorizedFiles: uncategorized, suggestion: findRecurringTheme(uncategorized) }; }, [unprocessedFiles, findSmartFolderForFile, findSmartFolderForCategory, resolveDestination]); const openCreateModal = useCallback((seedName = '') => { @@ -247,7 +187,8 @@ function UncategorizedActionsBar({ - {suggestion.files.length} are from{' '} + {suggestion.files.length}{' '} + {suggestion.source === 'entity' ? 'are from' : 'share the theme'}{' '} {suggestion.displayName} — want a Smart Folder for them? diff --git a/src/renderer/components/organize/uncategorizedThemes.js b/src/renderer/components/organize/uncategorizedThemes.js new file mode 100644 index 0000000..56a19f6 --- /dev/null +++ b/src/renderer/components/organize/uncategorizedThemes.js @@ -0,0 +1,180 @@ +// @ts-check +/** + * Find a theme that recurs among uncategorized files, so the Organize screen + * can offer "Create a Smart Folder for ". + * + * The first version grouped on one field (vendor, organization, entity, + * sender, project or category) by exact text. For a file whose contents could + * not be read that field is the file's own name, so every file formed its own + * group and no suggestion ever appeared. Files are now grouped on two + * signals: a named entity the analysis found (skipping ones that are just the + * file name or a generic category), and a keyword several files share. + */ + +/** A group needs at least this many files before it is offered. */ +export const THEME_MIN_FILES = 3; + +// Words that describe a file's form, not its subject: grouping on them would +// offer folders like "Pdf" or "Document". +const GENERIC_WORDS = new Set([ + 'archive', + 'document', + 'documents', + 'file', + 'files', + 'image', + 'images', + 'photo', + 'picture', + 'graphic', + 'text', + 'misc', + 'other', + 'unknown', + 'uncategorized', + 'default', + 'zip', + 'pdf', + 'png', + 'jpg', + 'jpeg', + 'svg', + 'gif', + 'webp', + 'docx', + 'doc', + 'xlsx', + 'pptx', + 'txt', + 'md', + 'html', + 'mp3', + 'mp4', + 'mov', + 'wav' +]); + +/** @param {unknown} value */ +function normalize(value) { + return typeof value === 'string' ? value.trim().toLowerCase().replace(/\s+/g, ' ') : ''; +} + +/** "Tiller Template (3).xlsx" -> "tiller template 3" */ +function fileStem(/** @type {any} */ file) { + const name = String(file?.name || file?.path || '') + .split(/[\\/]/) + .pop(); + return normalize(String(name || '').replace(/\.[^.]+$/, '')).replace(/[^\p{L}\p{N}]+/gu, ' '); +} + +/** @param {string} key */ +function isUsableKey(key) { + return key.length >= 3 && !GENERIC_WORDS.has(key) && !/^\d+$/.test(key); +} + +/** @param {string} label */ +function toDisplayName(label) { + const trimmed = String(label).trim(); + // Keep the analysis's own casing ("SCANUE", "Brittney"); capitalize a + // lowercase keyword so it reads as a folder name. + return trimmed === trimmed.toLowerCase() + ? trimmed.charAt(0).toUpperCase() + trimmed.slice(1) + : trimmed; +} + +/** + * The entity the analysis named for a file, or null when it only restates + * the file's name or a generic category. + * @param {any} file + * @returns {{ key: string, label: string } | null} + */ +function entityOf(file) { + const analysis = file?.analysis; + if (!analysis || typeof analysis !== 'object') return null; + const stem = fileStem(file); + for (const field of ['vendor', 'organization', 'entity', 'sender', 'project']) { + const raw = analysis[field]; + const key = normalize(raw); + if (!key || !isUsableKey(key)) continue; + // A fallback analysis sets project to the file's own name. + if (key.replace(/[^\p{L}\p{N}]+/gu, ' ') === stem) continue; + return { key, label: String(raw) }; + } + return null; +} + +/** + * @param {any} file + * @returns {Map} keyword key -> label, once per file + */ +function keywordsOf(file) { + const analysis = file?.analysis; + const list = [ + ...(Array.isArray(analysis?.keywords) ? analysis.keywords : []), + ...(Array.isArray(analysis?.tags) ? analysis.tags : []) + ]; + /** @type {Map} */ + const found = new Map(); + for (const raw of list) { + const key = normalize(raw); + if (key && isUsableKey(key) && !found.has(key)) found.set(key, String(raw)); + } + return found; +} + +/** + * The largest recurring theme among `files`, or null when none reaches + * THEME_MIN_FILES. A named entity wins a tie with a keyword: it is the more + * specific signal. + * + * @param {any[]} files - Uncategorized files with their `analysis` + * @returns {{ displayName: string, files: any[], source: 'entity' | 'keyword' } | null} + */ +export function findRecurringTheme(files) { + /** @type {Map} */ + const groups = new Map(); + /** + * @param {string} id + * @param {string} label + * @param {any} file + * @param {'entity' | 'keyword'} source + */ + const add = (id, label, file, source) => { + const group = groups.get(id) || { label, files: [], source }; + if (!group.files.includes(file)) group.files.push(file); + // An entity's spelling ("Stratosort") reads better than a keyword's. + if (source === 'entity' && group.source !== 'entity') { + group.source = 'entity'; + group.label = label; + } + groups.set(id, group); + }; + + // One tally per term, whichever field named it: "Stratosort" as one file's + // entity and another's keyword is the same theme. + for (const file of Array.isArray(files) ? files : []) { + const entity = entityOf(file); + if (entity) add(entity.key, entity.label, file, 'entity'); + for (const [key, label] of keywordsOf(file)) { + if (key !== entity?.key) add(key, label, file, 'keyword'); + } + } + + /** @type {{ label: string, files: any[], source: 'entity' | 'keyword' } | null} */ + let best = null; + for (const group of groups.values()) { + if (group.files.length < THEME_MIN_FILES) continue; + if ( + !best || + group.files.length > best.files.length || + (group.files.length === best.files.length && + group.source === 'entity' && + best.source === 'keyword') + ) { + best = group; + } + } + return best + ? { displayName: toDisplayName(best.label), files: best.files, source: best.source } + : null; +} diff --git a/src/shared/performanceConstants.js b/src/shared/performanceConstants.js index 0d0f9c1..9ab8905 100644 --- a/src/shared/performanceConstants.js +++ b/src/shared/performanceConstants.js @@ -276,7 +276,13 @@ const THRESHOLDS = { NARROW_SPREAD_THRESHOLD: 0.07, NARROW_SPREAD_CAP: 75, MODERATE_SPREAD_THRESHOLD: 0.12, - MODERATE_SPREAD_CAP: 85 + MODERATE_SPREAD_CAP: 85, + // The LLM's folder and the embedding's best folder differ and the LLM's + // pick was kept. Two signals that disagree are not a settled decision; + // below the auto-organize default (75) so it is not moved unreviewed. + // (A banner filed under Research at 90% while the vectors pointed + // elsewhere.) + DISAGREEMENT_CAP: 65 }, QUEUE_HIGH_WATERMARK: 0.75, QUEUE_CRITICAL_WATERMARK: 0.9, diff --git a/test/analysis-fallbackUtils.test.js b/test/analysis-fallbackUtils.test.js index b910762..1d6a8ea 100644 --- a/test/analysis-fallbackUtils.test.js +++ b/test/analysis-fallbackUtils.test.js @@ -376,3 +376,56 @@ describe('Analysis Fallback Utils', () => { }); }); }); + +// Folder words are matched against the name's whole words. Substring +// matching filed "network" under a "Work" folder and "barcode" under "code". +describe('getIntelligentCategory — whole-word folder matching', () => { + const { getIntelligentCategory } = require('../src/main/analysis/fallbackUtils'); + const work = { name: 'Work', description: 'Project management, resumes and task lists.' }; + const dev = { name: 'Development', description: 'Source code and scripts.' }; + + test('a folder word inside a longer word is not a match', () => { + expect(getIntelligentCategory('network-diagram.vsdx', '.vsdx', [work])).not.toBe('Work'); + expect(getIntelligentCategory('barcode-labels.pdf', '.pdf', [dev])).not.toBe('Development'); + }); + + test('the folder word on its own still matches', () => { + expect(getIntelligentCategory('work-plan-2026.docx', '.docx', [work])).toBe('Work'); + }); + + test('a plural in the file name still matches a singular folder word', () => { + const invoices = { name: 'Invoice', description: '' }; + expect(getIntelligentCategory('invoices-march.pdf', '.pdf', [invoices])).toBe('Invoice'); + }); + + test('a multi-word folder name matches as a phrase', () => { + const prints = { name: '3D print', description: '' }; + expect(getIntelligentCategory('my_3d_print_parts.txt', '.txt', [prints])).toBe('3D print'); + }); +}); + +describe('createFallbackAnalysis — a filename guess is not reported as confident', () => { + const { + createFallbackAnalysis, + FILENAME_ONLY_CONFIDENCE + } = require('../src/main/analysis/fallbackUtils'); + + test('the confidence is capped at FILENAME_ONLY_CONFIDENCE whatever the caller asks', () => { + const result = createFallbackAnalysis({ + fileName: 'report.pdf', + fileExtension: '.pdf', + confidence: 75 + }); + expect(result.confidence).toBe(FILENAME_ONLY_CONFIDENCE); + expect(FILENAME_ONLY_CONFIDENCE).toBe(50); + }); + + test('a lower requested confidence is kept', () => { + const result = createFallbackAnalysis({ + fileName: 'report.pdf', + fileExtension: '.pdf', + confidence: 40 + }); + expect(result.confidence).toBe(40); + }); +}); diff --git a/test/analysis-pipeline-bugfix-regression.test.js b/test/analysis-pipeline-bugfix-regression.test.js index 5eb469d..9cbaabd 100644 --- a/test/analysis-pipeline-bugfix-regression.test.js +++ b/test/analysis-pipeline-bugfix-regression.test.js @@ -468,7 +468,8 @@ describe('BUG-9: _fileMovedDuringAnalysis flag on result', () => { normalizeAnalysisResult: jest.fn((raw, fb) => ({ ...raw, category: raw.category || fb.category - })) + })), + dropPromptEchoes: jest.fn((result) => result) })); const { analyzeDocumentFile } = require('../src/main/analysis/documentAnalysis'); diff --git a/test/analysis-utils.test.js b/test/analysis-utils.test.js index 9007ed8..16d892a 100644 --- a/test/analysis-utils.test.js +++ b/test/analysis-utils.test.js @@ -212,3 +212,72 @@ describe('Analysis Utils', () => { }); }); }); + +// Small models sometimes answer with the prompt instead of the file: a schema +// instruction or a smart folder's description, copied word for word. The +// examples below are values found in a real analysis history. +describe('dropPromptEchoes', () => { + const { dropPromptEchoes } = require('../src/main/analysis/utils'); + const folders = [ + { + name: 'Logos', + description: 'Art, graphic design, images not related to other categories, logos.' + }, + { + name: 'Research', + description: + 'Anything to do with topics like SCAN, SCANUE, STAC, SCANAQ code related to neuromorphic or agent systems and code related to that topic.' + } + ]; + + test.each([ + [ + 'a folder description', + 'purpose', + 'Art, graphic design, images not related to other categories, logos' + ], + [ + 'a paraphrased description', + 'summary', + 'Research on SCANUE, a code related to neuromorphic or agent systems.' + ], + [ + 'the summary instruction', + 'summary', + "A concise 1-2 sentence summary of the document's content." + ], + ['a shortened instruction', 'summary', "A concise summary of the document's content."], + [ + 'the naming instruction, examples and all', + 'summary', + "A short, concise topic-based filename (max 3 words) using underscores (e.g., 'budget_report', 'sunset_beach'). Do NOT include the extension." + ] + ])('clears %s and caps the confidence', (_label, field, value) => { + const result = dropPromptEchoes({ category: 'Logos', confidence: 90, [field]: value }, folders); + expect(result[field]).toBeNull(); + expect(result.confidence).toBe(50); + expect(result.promptEchoFields).toEqual([field]); + }); + + test.each([ + 'A colorful image of a sunset beach landscape.', + 'A logo for a company or organization.', + 'A study on neuromorphic agent systems.', + 'The study examines the detection of simulator authenticity using a tabula rasa recurrent network.' + ])('keeps a real answer that only shares words: %s', (summary) => { + const input = { category: 'Research', confidence: 90, summary }; + expect(dropPromptEchoes(input, folders)).toBe(input); + }); + + test('caps on the 0-1 scale when the confidence is on it', () => { + const result = dropPromptEchoes( + { confidence: 0.9, summary: "A concise 1-2 sentence summary of the document's content." }, + folders + ); + expect(result.confidence).toBe(0.5); + }); + + test('passes a missing answer through', () => { + expect(dropPromptEchoes(null, folders)).toBeNull(); + }); +}); diff --git a/test/components/uncategorizedThemes.test.js b/test/components/uncategorizedThemes.test.js new file mode 100644 index 0000000..5b868b6 --- /dev/null +++ b/test/components/uncategorizedThemes.test.js @@ -0,0 +1,85 @@ +/** + * Recurring themes among uncategorized files, offered as "Create a Smart + * Folder for ". The first version grouped on one field by exact text; + * for a file whose contents could not be read that field was its own name, + * so no group ever reached the threshold and the suggestion never showed. + */ +import { + findRecurringTheme, + THEME_MIN_FILES +} from '../../src/renderer/components/organize/uncategorizedThemes'; + +const file = (name, analysis) => ({ name, path: `/downloads/${name}`, analysis }); + +describe('findRecurringTheme', () => { + test('files whose only "entity" is their own name do not form groups', () => { + // What a filename-only analysis looks like: project = the file's stem. + const files = ['fw4.pdf', 'scan-2025-03-19.docx', 'sales-deck.pptx'].map((name) => + file(name, { project: name.replace(/\.[^.]+$/, ''), category: 'Uncategorized' }) + ); + expect(findRecurringTheme(files)).toBeNull(); + }); + + test('a shared keyword forms a theme once enough files carry it', () => { + const files = [ + file('ARO+killflash+_stls.zip', { keywords: ['stl', 'killflash'] }), + file('AR+15+foregrip_stls.zip', { keywords: ['stl', 'foregrip'] }), + file('porta+spruzzino_stls.zip', { keywords: ['stl', 'porta'] }), + file('notes.txt', { keywords: ['notes'] }) + ]; + const theme = findRecurringTheme(files); + expect(theme).toEqual(expect.objectContaining({ displayName: 'Stl', source: 'keyword' })); + expect(theme.files).toHaveLength(3); + }); + + test('a named entity and the same word as a keyword count together', () => { + const files = [ + file('0-steps.png', { entity: 'Stratosort', keywords: ['screenshot'] }), + file('how-to-use.mp4', { keywords: ['stratosort', 'navigation'] }), + file('1-folders.png', { keywords: ['StratoSort', 'configure'] }) + ]; + const theme = findRecurringTheme(files); + expect(theme.files).toHaveLength(3); + // The entity's spelling is the one shown. + expect(theme).toEqual(expect.objectContaining({ displayName: 'Stratosort', source: 'entity' })); + }); + + test('form words such as file types and "document" never become a theme', () => { + const files = ['a', 'b', 'c', 'd'].map((n) => + file(`${n}.pdf`, { keywords: ['pdf', 'document', 'Uncategorized', '2026'] }) + ); + expect(findRecurringTheme(files)).toBeNull(); + }); + + test(`fewer than ${THEME_MIN_FILES} files is not a theme`, () => { + const files = [ + file('letter.pdf', { keywords: ['relationship'] }), + file('photo.jpg', { keywords: ['relationship'] }) + ]; + expect(findRecurringTheme(files)).toBeNull(); + }); + + test('the largest theme wins', () => { + const files = [ + file('a.pdf', { keywords: ['music', 'tax'] }), + file('b.pdf', { keywords: ['music', 'tax'] }), + file('c.pdf', { keywords: ['music', 'tax'] }), + file('d.pdf', { keywords: ['tax'] }) + ]; + expect(findRecurringTheme(files)).toEqual(expect.objectContaining({ displayName: 'Tax' })); + }); + + test('a named entity wins a tie with a keyword', () => { + const files = ['a', 'b', 'c'].map((n) => + file(`${n}.pdf`, { vendor: 'Mercury', keywords: ['backup'] }) + ); + expect(findRecurringTheme(files)).toEqual( + expect.objectContaining({ displayName: 'Mercury', source: 'entity' }) + ); + }); + + test('no files, no theme', () => { + expect(findRecurringTheme([])).toBeNull(); + expect(findRecurringTheme(undefined)).toBeNull(); + }); +}); diff --git a/test/confidenceCalibration.test.js b/test/confidenceCalibration.test.js index c59657e..a67ae73 100644 --- a/test/confidenceCalibration.test.js +++ b/test/confidenceCalibration.test.js @@ -156,3 +156,35 @@ describe('calibrateConfidence — caps that prevent false-precision routing', () expect(calibrateConfidence({ rawConfidence: -10 }).original).toBe(0); // clamped }); }); + +// A banner was filed under Research at 90% while the vectors pointed at a +// different folder: the LLM's pick was kept and nothing said the two signals +// disagreed. Disagreement now caps the claim below the auto-organize default. +describe('calibrateConfidence — LLM and embedding disagree', () => { + test('a kept LLM folder the embedding does not back is capped at DISAGREEMENT_CAP (65)', () => { + const result = calibrateConfidence({ + ...baseRequest, + rawConfidence: 90, + signalsDisagree: true + }); + expect(result.confidence).toBe(65); + expect(result.reason).toBe('llm_embedding_disagree'); + }); + + test('agreement leaves the decisive case untouched', () => { + const result = calibrateConfidence({ ...baseRequest, signalsDisagree: false }); + expect(result.confidence).toBe(95); + expect(result.reason).toBeNull(); + }); + + test('a tighter cap still wins over the disagreement cap', () => { + const result = calibrateConfidence({ + ...baseRequest, + topScore: 0.62, + runnerUpScore: 0.61, + signalsDisagree: true + }); + expect(result.confidence).toBe(60); + expect(result.reason).toBe('no_spread'); + }); +}); diff --git a/test/documentAnalysis.behavior.test.js b/test/documentAnalysis.behavior.test.js index 95e5a27..79fe1e0 100644 --- a/test/documentAnalysis.behavior.test.js +++ b/test/documentAnalysis.behavior.test.js @@ -102,7 +102,9 @@ jest.mock('../src/main/analysis/utils', () => ({ category: raw.category || fallback.category, confidence: raw.confidence !== undefined ? raw.confidence : fallback.confidence }; - }) + }), + // The prompt-echo guard has its own tests (analysis-utils.test.js). + dropPromptEchoes: jest.fn((result) => result) })); describe('documentAnalysis (behavior)', () => { diff --git a/test/documentAnalysis.extractionGaps.test.js b/test/documentAnalysis.extractionGaps.test.js index c067621..011baa5 100644 --- a/test/documentAnalysis.extractionGaps.test.js +++ b/test/documentAnalysis.extractionGaps.test.js @@ -169,7 +169,8 @@ describe('documentAnalysis dispatch for the restored file types', () => { expect(documentLlm.analyzeTextWithLlama).not.toHaveBeenCalled(); expect(result.extractionMethod).toBe('filename'); expect(result.fallbackReason).toMatch(/no PSD reader/); - expect(result.confidence).toBe(70); + // Only the name was read: reported as a filename guess (50), not 70. + expect(result.confidence).toBe(50); }); test('an empty metadata read falls back to the filename instead of calling the model', async () => { @@ -209,6 +210,48 @@ describe('documentAnalysis dispatch for the restored file types', () => { expect(documentLlm.analyzeTextWithLlama).not.toHaveBeenCalled(); }); + // A zip of .stl parts went to Uncategorized: the category was hardcoded + // to 'archive' even though the listing showed what the archive held. + test('an archive of 3D parts is filed with the folder that takes those parts', async () => { + archives.describeArchive.mockResolvedValue({ + format: 'tar', + summary: 'TAR archive with 3 entries', + entryNames: ['parts/', 'parts/grip.stl', 'parts/mount.stl', 'README.txt'], + totalEntries: 4, + truncated: false + }); + const folders = [ + { name: '3D print', description: 'Any .stl, .3mf, .obj or other 3D print file.' }, + { name: 'Financial', description: 'Invoices and statements.' } + ]; + + const result = await analyzeDocumentFile('/downloads/AR+15+foregrip_stls.tar', folders, { + bypassCache: true + }); + + expect(result.extractionMethod).toBe('archive'); + expect(result.category).toBe('3D print'); + expect(result.confidence).toBe(70); + }); + + test('an archive with no dominant type keeps the generic bucket at filename confidence', async () => { + archives.describeArchive.mockResolvedValue({ + format: 'tar', + summary: 'TAR archive with 4 entries', + entryNames: ['a.stl', 'b.pdf', 'c.png', 'd.txt'], + totalEntries: 4, + truncated: false + }); + const folders = [{ name: '3D print', description: 'Any .stl file.' }]; + + const result = await analyzeDocumentFile('/downloads/mixed.tar', folders, { + bypassCache: true + }); + + expect(result.category).toBe('archive'); // normalizeCategoryToSmartFolders is identity here + expect(result.confidence).toBe(50); + }); + test('an unreadable tar still returns an archive result instead of failing', async () => { archives.describeArchive.mockResolvedValue(null); From 7d2413163f742875b2a0a47fef2f7ca30d3d8468 Mon Sep 17 00:00:00 2001 From: Levy Tate <78818969+iLevyTate@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:11:01 -0500 Subject: [PATCH 2/2] feat(organize): themes across earlier uncategorized files, re-rank on folder edits, remember description changes - The 'Create folder for ' offer counts files analyzed in earlier runs that are still uncategorized (new analysis-history:get-uncategorized channel), as long as the theme includes a file on screen. - Organize re-ranks pending files when a folder's description or keywords change, from stored vectors, with no reanalysis. - Editing a folder description drops keywords derived from the old wording and records the change as a memory aimed at that folder, with the earlier wordings kept. - Settings > Performance shows the Memory & Rules panel so learned memories can be reviewed, edited or deleted. --- src/main/ipc/analysisHistory.js | 24 +++++ src/main/ipc/smartFolders.js | 43 +++++++++ src/main/ipc/validationSchemas.js | 8 ++ .../AnalysisHistoryServiceCore.js | 6 ++ src/main/services/analysisHistory/queries.js | 44 +++++++++ .../OrganizationSuggestionServiceCore.js | 52 ++++++++++ src/preload/preload.js | 7 +- src/renderer/components/SettingsPanel.jsx | 2 + .../organize/FeedbackMemoryPanel.jsx | 15 +++ .../organize/UncategorizedActionsBar.jsx | 72 +++++++++++++- .../phases/organize/useOrganizeSuggestions.js | 12 ++- src/shared/constants.js | 3 +- test/OrganizationSuggestionService.test.js | 66 +++++++++++++ test/analysis-history-timeline-ipc.test.js | 28 ++++++ test/analysisHistory-queries.test.js | 47 +++++++++ test/components/FeedbackMemoryPanel.test.js | 17 ++++ .../UncategorizedActionsBar.test.js | 95 +++++++++++++++++++ test/smartFoldersIpc.test.js | 42 ++++++++ test/useOrganizeSuggestions.test.js | 17 ++++ 19 files changed, 591 insertions(+), 9 deletions(-) create mode 100644 test/components/UncategorizedActionsBar.test.js diff --git a/src/main/ipc/analysisHistory.js b/src/main/ipc/analysisHistory.js index 08680ed..6241d1f 100644 --- a/src/main/ipc/analysisHistory.js +++ b/src/main/ipc/analysisHistory.js @@ -556,6 +556,30 @@ function registerAnalysisHistoryIpc(servicesOrParams) { } }) ); + + // Lean list of every analyzed file still filed as Uncategorized, so the + // Organize screen can spot themes across past runs, not just the open one. + safeHandle( + ipcMain, + IPC_CHANNELS.ANALYSIS_HISTORY.GET_UNCATEGORIZED, + createHandler({ + logger, + context, + schema: schemas?.uncategorizedOptions, + serviceName: 'analysisHistory', + getService: getHistoryService, + fallbackResponse: { items: [] }, + handler: async (event, options = {}, service) => { + try { + const items = await service.getUncategorizedItems(options || {}); + return { items: Array.isArray(items) ? items : [] }; + } catch (error) { + logger.error('Failed to get uncategorized analysis entries:', error); + return { items: [] }; + } + } + }) + ); } module.exports = registerAnalysisHistoryIpc; diff --git a/src/main/ipc/smartFolders.js b/src/main/ipc/smartFolders.js index 364eea3..d53e962 100644 --- a/src/main/ipc/smartFolders.js +++ b/src/main/ipc/smartFolders.js @@ -1412,6 +1412,35 @@ async function remapKnowledgeUnderRenamedFolder({ } } +/** + * Tell the suggestion service what a folder is now for, so later files follow + * the new wording and Settings can show the change. Never fails the edit. + * @param {{ logger: LoggerLike, getServiceIntegration?: () => any, folder: Record, previous: Record }} params + */ +async function rememberFolderDescriptionChange({ + logger, + getServiceIntegration, + folder, + previous +}) { + try { + const service = + typeof getServiceIntegration === 'function' + ? getServiceIntegration()?.suggestionService + : null; + if (typeof service?.recordFolderDescriptionChange !== 'function') return; + await service.recordFolderDescriptionChange({ + folderId: folder.id, + name: folder.name, + previousName: previous.name, + description: folder.description, + previousDescription: previous.description + }); + } catch (error) { + logger.warn('[SMART-FOLDERS] Could not record the description change:', error?.message); + } +} + /** * @param {SmartFoldersHandlerContext} ctx * @param {IpcMainInvokeEvent} event @@ -1643,9 +1672,17 @@ async function handleSmartFoldersEdit( }; } } + const descriptionChanged = + typeof updatedFolder.description === 'string' && + updatedFolder.description.trim() !== String(originalFolder.description || '').trim(); try { customFolders[folderIndex] = { ...customFolders[folderIndex], + // Keywords were derived from the old description. Once the user says + // what the folder is for, their wording alone shapes its matching. + ...(descriptionChanged && !Array.isArray(updatedFolder.keywords) + ? { keywords: [], semanticTags: [] } + : {}), ...updatedFolder, updatedAt: new Date().toISOString() }; @@ -1670,6 +1707,12 @@ async function handleSmartFoldersEdit( scanPaths: pathChanged ? [customFolders[folderIndex].path] : [], getSmartFolderWatcher }); + await rememberFolderDescriptionChange({ + logger, + getServiceIntegration, + folder: customFolders[folderIndex], + previous: originalFolder + }); logger.info('[SMART-FOLDERS] Edited Smart Folder:', folderId); return { success: true, diff --git a/src/main/ipc/validationSchemas.js b/src/main/ipc/validationSchemas.js index 3118a90..a5b87d9 100644 --- a/src/main/ipc/validationSchemas.js +++ b/src/main/ipc/validationSchemas.js @@ -1494,6 +1494,13 @@ if (!z) { offset: z.number().int().min(0).optional() }); + /** + * Uncategorized-files request (analysis-history:get-uncategorized) + */ + const uncategorizedOptionsSchema = z.object({ + limit: z.number().int().min(1).max(2000).optional() + }); + const embeddingsFullRebuildSchema = z .object({ modelOverride: z.string().min(1).max(500).optional() @@ -1678,6 +1685,7 @@ if (!z) { analysisExportFormat: analysisExportFormatSchema, transcriptExport: transcriptExportSchema, timelineOptions: timelineOptionsSchema, + uncategorizedOptions: uncategorizedOptionsSchema, // Backup backupPath: backupPathSchema, diff --git a/src/main/services/analysisHistory/AnalysisHistoryServiceCore.js b/src/main/services/analysisHistory/AnalysisHistoryServiceCore.js index bf708a6..9dc7ea0 100644 --- a/src/main/services/analysisHistory/AnalysisHistoryServiceCore.js +++ b/src/main/services/analysisHistory/AnalysisHistoryServiceCore.js @@ -67,6 +67,7 @@ const { getRecentAnalysis: getRecentAnalysisHelper, getAnalysisByDateRange: getAnalysisByDateRangeHelper, buildTimelineItems, + buildUncategorizedItems, getCategories: getCategoriesHelper, getTags: getTagsHelper } = require('./queries'); @@ -1158,6 +1159,11 @@ class AnalysisHistoryServiceCore { return buildTimelineItems(this.analysisHistory, options); } + async getUncategorizedItems(options = {}) { + await this.initialize(); + return buildUncategorizedItems(this.analysisHistory, options); + } + async getCategories() { await this.initialize(); return getCategoriesHelper(this.analysisIndex); diff --git a/src/main/services/analysisHistory/queries.js b/src/main/services/analysisHistory/queries.js index a8bb762..c21fb0b 100644 --- a/src/main/services/analysisHistory/queries.js +++ b/src/main/services/analysisHistory/queries.js @@ -15,6 +15,7 @@ const { toLocalCalendarDay } = require('../../../shared/contentDateSanity'); const { normalizePathForIndex } = require('../../../shared/pathSanitization'); +const { isDefaultCategoryName } = require('../../../shared/categoryFolderMatching'); // Lookup normalizer must match the write side in `indexManager.js`, which uses // `normalizePathForIndex` (NFC unicode + win32/darwin lowercase + forward-slash @@ -456,6 +457,48 @@ function buildTimelineItems(analysisHistory, options = {}) { return { items: page, total, hasMore: offset + page.length < total }; } +/** + * Every analyzed file whose category is still the default one, newest first. + * Scans the entries rather than `categoryIndex`: a move rewrites an entry's + * category without re-keying that index. Items are lean (no extracted text) + * and shaped like the Organize screen's files. + * @param {any} analysisHistory + * @param {{limit?: number}} [options] + * @returns {Array<{path: string, name: string, extension: string, analysis: {category: string, tags: string[], entity?: string, project?: string}}>} + */ +function buildUncategorizedItems(analysisHistory, options = {}) { + const entries = analysisHistory?.entries; + if (!entries || typeof entries !== 'object') return []; + const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 500; + + const matched = []; + for (const entry of Object.values(entries)) { + const analysis = /** @type {any} */ (entry)?.analysis; + if (!analysis || !isDefaultCategoryName(analysis.category)) continue; + const path = + typeof entry.organization?.actual === 'string' && entry.organization.actual + ? entry.organization.actual + : entry.originalPath; + if (typeof path !== 'string' || !path) continue; + matched.push({ + path, + name: typeof entry.fileName === 'string' ? entry.fileName : '', + extension: typeof entry.fileExtension === 'string' ? entry.fileExtension : '', + timestamp: typeof entry.timestamp === 'string' ? entry.timestamp : '', + analysis: { + category: analysis.category, + tags: Array.isArray(analysis.tags) + ? analysis.tags.filter((/** @type {any} */ t) => typeof t === 'string').slice(0, 20) + : [], + ...(typeof analysis.entity === 'string' ? { entity: analysis.entity } : {}), + ...(typeof analysis.project === 'string' ? { project: analysis.project } : {}) + } + }); + } + matched.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0)); + return matched.slice(0, limit).map(({ timestamp: _timestamp, ...item }) => item); +} + /** * Get all available categories with counts * Performance: Uses index directly, no iteration over entries @@ -489,6 +532,7 @@ module.exports = { getRecentAnalysis, getAnalysisByDateRange, buildTimelineItems, + buildUncategorizedItems, getCategories, getTags }; diff --git a/src/main/services/organization/OrganizationSuggestionServiceCore.js b/src/main/services/organization/OrganizationSuggestionServiceCore.js index b36f553..c9f4546 100644 --- a/src/main/services/organization/OrganizationSuggestionServiceCore.js +++ b/src/main/services/organization/OrganizationSuggestionServiceCore.js @@ -40,6 +40,9 @@ const { const { PatternMatcher } = require('./patternMatcher'); const { FeedbackMemoryStore, getMetrics: getFeedbackMetrics } = require('./feedbackMemoryStore'); + +/** Earlier wordings kept per folder for the Settings history. */ +const FOLDER_DESCRIPTION_HISTORY_LIMIT = 5; const { buildMemoryEntry, applyMemoryRuleAdjustments, @@ -1678,6 +1681,55 @@ class OrganizationSuggestionServiceCore { return result; } + /** + * A user rewording what a folder is for is the plainest statement of what + * belongs there. Keep it as one memory per folder, so matching files lean + * toward that folder and Settings shows what was changed and when. + * @param {{ folderId?: string, name: string, previousName?: string, description?: string, previousDescription?: string }} change + * @returns {Promise} the stored memory, or null when nothing changed + */ + async recordFolderDescriptionChange({ + folderId, + name, + previousName, + description, + previousDescription + }) { + const next = String(description || '').trim(); + const previous = String(previousDescription || '').trim(); + if (!name || !next) return null; + + await this._ensureFeedbackMemoryLoaded(); + const autoKey = `folder-description:${folderId || name.toLowerCase()}`; + const existing = (await this.feedbackMemoryStore.list()).find( + (entry) => entry?.autoKey === autoKey + ); + const renamed = Boolean(previousName) && previousName !== name; + if (next === previous && !(existing && renamed)) return null; + + const text = normalizeText(`"${name}" is for: ${next}`); + const metadata = { + source: 'folder_description', + targetFolder: name, + scope: { type: 'folder', value: name } + }; + const entry = existing + ? await this.updateFeedbackMemory(existing.id, text, metadata) + : await this.addFeedbackMemory(text, metadata); + if (!entry) return null; + + const history = Array.isArray(existing?.history) ? [...existing.history] : []; + if (previous && previous !== next) { + history.unshift({ description: previous, changedAt: new Date().toISOString() }); + } + return this.feedbackMemoryStore.update( + entry.id, + // A description is prose, not a "X goes to Y" rule: keep none parsed from it. + { autoKey, rules: [], history: history.slice(0, FOLDER_DESCRIPTION_HISTORY_LIMIT) }, + {} + ); + } + async addFeedbackMemory(/** @type {any} */ text, metadata = {}) { await this._ensureFeedbackMemoryLoaded(); const entry = buildMemoryEntry(text, metadata); diff --git a/src/preload/preload.js b/src/preload/preload.js index ae80c12..a91e30a 100644 --- a/src/preload/preload.js +++ b/src/preload/preload.js @@ -139,7 +139,8 @@ const IPC_CHANNELS = { CLEAR: 'analysis-history:clear', EXPORT: 'analysis-history:export', EXPORT_TRANSCRIPT: 'analysis-history:export-transcript', - GET_TIMELINE: 'analysis-history:get-timeline' + GET_TIMELINE: 'analysis-history:get-timeline', + GET_UNCATEGORIZED: 'analysis-history:get-uncategorized' }, // EMBEDDINGS @@ -1914,7 +1915,9 @@ contextBridge.exposeInMainWorld('electronAPI', { format }), getTimeline: (/** @type {any} */ options) => - secureIPC.safeInvoke(IPC_CHANNELS.ANALYSIS_HISTORY.GET_TIMELINE, options || {}) + secureIPC.safeInvoke(IPC_CHANNELS.ANALYSIS_HISTORY.GET_TIMELINE, options || {}), + getUncategorized: (/** @type {any} */ options) => + secureIPC.safeInvoke(IPC_CHANNELS.ANALYSIS_HISTORY.GET_UNCATEGORIZED, options || {}) }, // AI Provider Configuration diff --git a/src/renderer/components/SettingsPanel.jsx b/src/renderer/components/SettingsPanel.jsx index 70eefbc..c91a536 100644 --- a/src/renderer/components/SettingsPanel.jsx +++ b/src/renderer/components/SettingsPanel.jsx @@ -69,6 +69,7 @@ import { lockAppScroll, unlockAppScroll } from '../utils/scrollLock'; import { isTopOverlay, registerOverlay, unregisterOverlay } from '../utils/overlayStack'; import { toErrorMessage } from '../utils/errorMapping'; import AutoOrganizeSection from './settings/AutoOrganizeSection'; +import FeedbackMemoryPanel from './organize/FeedbackMemoryPanel'; import BackgroundModeSection from './settings/BackgroundModeSection'; import NotificationSettingsSection from './settings/NotificationSettingsSection'; import LlamaConfigSection from './settings/LlamaConfigSection'; @@ -1755,6 +1756,7 @@ const SettingsPanel = React.memo(function SettingsPanel() { > + {effectiveAdvancedMode && ( + + What StratoSort has learned from your corrections and folder descriptions. Future + suggestions follow these; edit or delete any that are wrong. + +