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);