ai-feature-setup #20

Merged
gpatti merged 4 commits from ai-feature-setup into main 2026-04-12 21:24:57 +00:00
2 changed files with 62 additions and 19 deletions
Showing only changes of commit ad9920a448 - Show all commits

View File

@@ -3,7 +3,7 @@ import path from 'path'
import type { Library, Tag, TagCategory } from '@/types' import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { getAiConfig } from './app-settings' import { getAiConfig } from './app-settings'
import { getTags, getCategories, addTagToItem } from './tags' import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getThumbnailPath } from './thumbnails' import { getThumbnailPath } from './thumbnails'
import { findFile } from './media-utils' import { findFile } from './media-utils'
import { getLibrary, resolveLibraryRoot } from './libraries' 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. * 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 categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const grouped: Record<string, { id: string; name: string }[]> = {} const grouped: Record<string, { id: string; name: string }[]> = {}
@@ -105,14 +107,24 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[]): string {
lines.push(`[${catName}] ${tagList}`) lines.push(`[${catName}] ${tagList}`)
} }
return [ const parts: string[] = [
'You are an image tagger. Given the image, select which of the following tags apply.', '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.', '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: []', 'If no tags match, return an empty array (e.i., [])',
'', ]
'Available tags:',
...lines, if (currentTags && currentTags.length > 0) {
].join('\n') 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, temperature: 0.1,
}), }),
}) })
@@ -184,12 +196,15 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
const config = getAiConfig() const config = getAiConfig()
if (!config.enabled || !config.endpoint || !config.model) return if (!config.enabled || !config.endpoint || !config.model) return
const tags = getTags() const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(library.id))
const categories = getCategories() 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 if (tags.length === 0) return
const validTagIds = new Set(tags.map((t) => t.id)) const validTagIds = new Set(tags.map((t) => t.id))
const systemPrompt = buildTagPrompt(tags, categories)
const db = getDb() const db = getDb()
const untaggedItems = db 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 thumbnailPath = await getThumbnailPath(imagePath, library.id, 'image')
const base64 = fs.readFileSync(thumbnailPath, 'base64') 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) const suggestedIds = await callVisionApi(config.endpoint, config.model, base64, systemPrompt)
// Filter to valid tags only // Filter to valid tags only
@@ -263,14 +281,19 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
} }
const tags = getTags() const libraryId = itemKey.split(':')[0]
const categories = getCategories()
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) { if (tags.length === 0) {
return [] return []
} }
const validTagIds = new Set(tags.map((t) => t.id)) const validTagIds = new Set(tags.map((t) => t.id))
const systemPrompt = buildTagPrompt(tags, categories)
const db = getDb() const db = getDb()
const item = db const item = db
@@ -280,8 +303,6 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
if (!item) { if (!item) {
throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' }) throw Object.assign(new Error(`Item not found: ${itemKey}`), { code: 'NOT_FOUND' })
} }
const libraryId = itemKey.split(':')[0]
const library = getLibrary(libraryId) const library = getLibrary(libraryId)
if (!library) { if (!library) {
throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' }) throw Object.assign(new Error(`Library not found: ${libraryId}`), { code: 'NOT_FOUND' })
@@ -296,7 +317,10 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image') const thumbnailPath = await getThumbnailPath(imagePath, libraryId, 'image')
const base64 = fs.readFileSync(thumbnailPath, 'base64') 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)) const validIds = suggestedIds.filter((id) => validTagIds.has(id))
for (const tagId of validIds) { for (const tagId of validIds) {

View File

@@ -18,6 +18,25 @@ export function getCategories(): TagCategory[] {
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as 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 { export function addCategory(name: string): TagCategory {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) throw new Error('Category name is required.') if (!trimmed) throw new Error('Category name is required.')