From ad9920a448e30b886ed38a40349473699c19dd17 Mon Sep 17 00:00:00 2001 From: Garret Patti <42485635+garretpatti@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:45:26 -0400 Subject: [PATCH] limit tags sent and send applied tags to ai --- src/lib/ai-tagger.ts | 62 ++++++++++++++++++++++++++++++-------------- src/lib/tags.ts | 19 ++++++++++++++ 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/lib/ai-tagger.ts b/src/lib/ai-tagger.ts index 9c7bd1d..a86587d 100644 --- a/src/lib/ai-tagger.ts +++ b/src/lib/ai-tagger.ts @@ -3,7 +3,7 @@ import path from 'path' import type { Library, Tag, TagCategory } from '@/types' import { getDb } from './db' import { getAiConfig } from './app-settings' -import { getTags, getCategories, addTagToItem } from './tags' +import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags' import { getThumbnailPath } from './thumbnails' import { findFile } from './media-utils' import { getLibrary, resolveLibraryRoot } from './libraries' @@ -89,8 +89,10 @@ function resolveItemImage(libraryRoot: string, item: MediaItemRow): string | nul /** * Build the system prompt that instructs the LLM to select matching tags. + * If currentTags are provided they are included as context to help the model + * understand the image before selecting additional tags. */ -function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string { +function buildTagPrompt(tags: Tag[], categories: TagCategory[], currentTags?: Tag[]): string { const categoryMap = new Map(categories.map((c) => [c.id, c.name])) const grouped: Record = {} @@ -105,14 +107,24 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string { lines.push(`[${catName}] ${tagList}`) } - return [ + const parts: string[] = [ 'You are an image tagger. Given the image, select which of the following tags apply.', - 'Return ONLY a JSON array of tag IDs that match the image. Do not invent new tags.', - 'If no tags match, return an empty array: []', - '', - 'Available tags:', - ...lines, - ].join('\n') + 'Return ONLY a JSON array of tag IDs that match the image (e.g., ["tag-apple", "tag-orange"]). Do not invent new tags. Do not return any text other than what is inside the JSON array.', + 'If no tags match, return an empty array (e.i., [])', + ] + + if (currentTags && currentTags.length > 0) { + const currentTagNames = currentTags.map((t) => t.name).join(', ') + parts.push('') + parts.push(`This image already has the following tags applied: ${currentTagNames}`) + parts.push('Use these as context to better understand the image when selecting tags.') + } + + parts.push('') + parts.push('Available tags:') + parts.push(...lines) + + return parts.join('\n') } /** @@ -148,7 +160,7 @@ async function callVisionApi( ], }, ], - max_tokens: 512, + max_tokens: 8192, temperature: 0.1, }), }) @@ -184,12 +196,15 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi const config = getAiConfig() if (!config.enabled || !config.endpoint || !config.model) return - const tags = getTags() - const categories = getCategories() + const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id)) + const allTags = getTags() + const allCategories = getCategories() + + const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId)) + const categories = allCategories.filter((c) => activeCategoryIds.has(c.id)) if (tags.length === 0) return const validTagIds = new Set(tags.map((t) => t.id)) - const systemPrompt = buildTagPrompt(tags, categories) const db = getDb() const untaggedItems = db @@ -227,6 +242,9 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi const thumbnailPath = await getThumbnailPath(imagePath, library.id, 'image') const base64 = fs.readFileSync(thumbnailPath, 'base64') + const { tags: currentItemTags } = getResolvedTagsForItem(item.item_key) + const systemPrompt = buildTagPrompt(tags, categories, currentItemTags) + const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt) // Filter to valid tags only @@ -263,14 +281,19 @@ export async function tagSingleItem(itemKey: string): Promise { throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) } - const tags = getTags() - const categories = getCategories() + const libraryId = itemKey.split(':')[0] + + const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId)) + const allTags = getTags() + const allCategories = getCategories() + + const tags = allTags.filter((t) => activeCategoryIds.has(t.categoryId)) + const categories = allCategories.filter((c) => activeCategoryIds.has(c.id)) if (tags.length === 0) { return [] } const validTagIds = new Set(tags.map((t) => t.id)) - const systemPrompt = buildTagPrompt(tags, categories) const db = getDb() const item = db @@ -280,8 +303,6 @@ export async function tagSingleItem(itemKey: string): Promise { if (!item) { throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' }) } - - const libraryId = itemKey.split(':')[0] const library = getLibrary(libraryId) if (!library) { throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' }) @@ -296,7 +317,10 @@ export async function tagSingleItem(itemKey: string): Promise { const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image') const base64 = fs.readFileSync(thumbnailPath, 'base64') - const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt) + const { tags: currentItemTags } = getResolvedTagsForItem(itemKey) + const systemPromptWithContext = buildTagPrompt(tags, categories, currentItemTags) + + const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPromptWithContext) const validIds = suggestedIds.filter((id) => validTagIds.has(id)) for (const tagId of validIds) { diff --git a/src/lib/tags.ts b/src/lib/tags.ts index ca5e7f0..2b9dca5 100644 --- a/src/lib/tags.ts +++ b/src/lib/tags.ts @@ -18,6 +18,25 @@ export function getCategories(): TagCategory[] { return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[] } +/** + * Returns the distinct category IDs that have at least one tag assigned to any + * item in the given library. Used by the AI tagger to restrict the tag prompt + * to categories that are actually in use within the target library. + */ +export function getActiveCategoryIdsForLibrary(libraryId: string): string[] { + const db = getDb() + const rows = db + .prepare( + `SELECT DISTINCT t.category_id + FROM tags t + JOIN media_tags mt ON mt.tag_id = t.id + JOIN media_items mi ON mi.item_key = mt.item_key + WHERE mi.library_id = ?` + ) + .all(libraryId) as { category_id: string }[] + return rows.map((r) => r.category_id) +} + export function addCategory(name: string): TagCategory { const trimmed = name.trim() if (!trimmed) throw new Error('Category name is required.')