printhive/lib/autotag.js
dlawler489 0f49fe8c1a AI tags and descriptions, search by meaning, and automatic tags
- AI tagging through any OpenAI-compatible API: DeepSeek by default, or
  OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI
  with presets, prices and "Save & test connection"; the key never leaves
  the server except to the provider.
- Runs over untagged models, all models, or one model from its page: live
  progress, tokens and cost, stop/resume (retrying failures), an optional
  spending limit, and paused rather than resumed after a restart. Adapts to
  models without JSON mode or image input; stops on a rejected key or no
  credit. Tags are only added; descriptions filled only where empty unless
  asked.
- Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js),
  indexed in the background whenever a model's text changes; Words/Meaning
  switch on the search box. Stored as REAL[] in Postgres, no pgvector needed.
- Automatic tags: strict whole-word rules (part types, mechanisms, materials,
  techniques) over names, file names and archive contents, plus your own
  word -> tag rules; on/off (optionally removing its tags) and re-apply.
- Tags remember where they came from (user / rule / ai); edits keep that,
  rule and AI passes never touch your own tags, and the model page marks them.
- Docker: ships the embedding model and only Linux onnxruntime binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:12:58 +10:00

155 lines
7 KiB
JavaScript

// Automatic tagging: strict whole-word rules over model names, file names, and the
// names (and immediate folders) of files inside archives. Rule tags are recorded
// as such (model_tags.source = 'rule'), so a pass only adds or removes its own
// tags; ones you typed are never touched.
const crypto = require('crypto');
const db = require('./db');
const store = require('./store');
// Bump when the built-in rules change, so every model is checked again.
const RULES_VERSION = 1;
const BUILT_IN_RULES = [
{ group: 'Part types', tag: 'bracket', phrases: ['bracket'] },
{ group: 'Part types', tag: 'holder', phrases: ['holder'] },
{ group: 'Part types', tag: 'mount', phrases: ['mount'] },
{ group: 'Part types', tag: 'hinge', phrases: ['hinge'] },
{ group: 'Part types', tag: 'drawer', phrases: ['drawer'] },
{ group: 'Part types', tag: 'baseplate', phrases: ['baseplate', 'base plate'] },
{ group: 'Part types', tag: 'hook', phrases: ['hook'] },
{ group: 'Part types', tag: 'clip', phrases: ['clip'] },
{ group: 'Part types', tag: 'organizer', phrases: ['organizer', 'organiser'] },
{ group: 'Part types', tag: 'keychain', phrases: ['keychain', 'key chain', 'keyring', 'key ring'] },
{ group: 'Part types', tag: 'gridfinity', phrases: ['gridfinity'] },
{ group: 'Mechanisms', tag: 'print-in-place', phrases: ['print in place', 'printinplace', 'pip'] },
{ group: 'Mechanisms', tag: 'articulated', phrases: ['articulated', 'flexi'] },
{ group: 'Mechanisms', tag: 'threaded', phrases: ['threaded', 'thread'] },
{ group: 'Mechanisms', tag: 'gear', phrases: ['gear'] },
{ group: 'Materials', tag: 'pla', phrases: ['pla'] },
{ group: 'Materials', tag: 'petg', phrases: ['petg'] },
{ group: 'Materials', tag: 'abs', phrases: ['abs'] },
{ group: 'Materials', tag: 'asa', phrases: ['asa'] },
{ group: 'Materials', tag: 'tpu', phrases: ['tpu'] },
{ group: 'Materials', tag: 'nylon', phrases: ['nylon'] },
{ group: 'Techniques', tag: 'vase mode', phrases: ['vase mode', 'vasemode', 'spiral vase'] },
{ group: 'Techniques', tag: 'fuzzy skin', phrases: ['fuzzy skin', 'fuzzyskin'] },
{ group: 'Techniques', tag: 'multicolor', phrases: ['multicolor', 'multicolour', 'multi color', 'multi colour'] },
];
// Words in a name: splits on punctuation, camelCase and letter/number boundaries.
// "PETGWallHook_v2.stl" -> ["petg", "wall", "hook", "v", "2", "stl"]
function tokenize(text) {
return String(text || '')
.replace(/(?<=[a-z])(?=[A-Z])/g, ' ')
.replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
.replace(/(?<=[A-Za-z])(?=\d)|(?<=\d)(?=[A-Za-z])/g, ' ')
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean);
}
const sameWord = (token, word) => token === word || token === `${word}s` || token === `${word}es`;
function hasPhrase(tokens, words) {
if (!words.length) return false;
for (let i = 0; i + words.length <= tokens.length; i++) {
if (words.every((w, j) => sameWord(tokens[i + j], w))) return true;
}
return false;
}
const normalizeTag = (tag) => String(tag).trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 40);
// rules: [{ tag, phrases }] -> compiled once for matching.
function compile(rules) {
return rules.map((r) => ({ tag: normalizeTag(r.tag), phrases: r.phrases.map(tokenize).filter((w) => w.length) }));
}
const COMPILED_BUILT_INS = compile(BUILT_IN_RULES);
// Tags for a set of names (each matched separately, so a phrase never spans two).
function matchTags(texts, customRules = []) {
const lists = (Array.isArray(texts) ? texts : [texts]).map(tokenize);
const rules = [...COMPILED_BUILT_INS, ...compile(customRules.map((r) => ({ tag: r.tag, phrases: [r.phrase] })))];
const tags = new Set();
for (const rule of rules) {
if (rule.tag && rule.phrases.some((words) => lists.some((tokens) => hasPhrase(tokens, words)))) tags.add(rule.tag);
}
return [...tags];
}
// ---------------------------------------------------------------- database
async function isEnabled() {
return (await store.getSetting('autotag_enabled')) !== 'false';
}
async function listRules() {
const { rows } = await db.query('SELECT id, phrase, tag FROM tag_rules ORDER BY id');
return rows;
}
const saltFor = (rules) => crypto.createHash('md5').update(JSON.stringify([RULES_VERSION, rules])).digest('hex');
// Models whose names, files or archive contents changed since their rule tags
// were worked out (or whose rules changed).
const PENDING = `
FROM (
SELECT m.id, m.name, m.autotag_key,
COALESCE((SELECT string_agg(f.original_name, E'\\n' ORDER BY f.id) FROM files f WHERE f.model_id = m.id), '') AS files,
COALESCE((SELECT string_agg(e.path, E'\\n' ORDER BY e.id)
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id), '') AS entries
FROM models m
) d
WHERE d.autotag_key IS DISTINCT FROM md5($1 || d.name || E'\\n' || d.files || E'\\n' || d.entries)`;
async function run() {
if (!(await isEnabled())) return false;
const rules = await listRules();
const salt = saltFor(rules.map((r) => [r.phrase, r.tag]));
const { rows } = await db.query(`SELECT d.id, d.name, d.files, d.entries ${PENDING} ORDER BY d.id LIMIT 100`, [salt]);
if (!rows.length) return false;
for (const row of rows) {
const entryNames = row.entries.split('\n').filter(Boolean).map((p) => p.split('/').slice(-2).join('/'));
const texts = [row.name, ...row.files.split('\n'), ...entryNames.flatMap((p) => p.split('/'))];
await store.replaceRuleTags(row.id, matchTags(texts, rules));
await db.query(`UPDATE models SET autotag_key = md5($2 || $3 || E'\\n' || $4 || E'\\n' || $5) WHERE id = $1`,
[row.id, salt, row.name, row.files, row.entries]);
}
return true;
}
async function pendingCount() {
if (!(await isEnabled())) return 0;
const rules = await listRules();
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`, [saltFor(rules.map((r) => [r.phrase, r.tag]))]);
return rows[0].n;
}
// Checks every model again on the next pass.
async function reapply() {
await db.query('UPDATE models SET autotag_key = NULL');
}
// Turning it off keeps the tags already applied unless removeTags is set.
async function setEnabled(enabled, { removeTags = false } = {}) {
await store.setSetting('autotag_enabled', enabled ? 'true' : 'false');
await reapply();
return !enabled && removeTags ? store.removeTagsFrom('rule') : 0;
}
async function addRule(phrase, tag) {
const cleanPhrase = String(phrase || '').trim().slice(0, 80);
const cleanTag = normalizeTag(tag || '');
if (!tokenize(cleanPhrase).length) throw Object.assign(new Error('Enter a word or phrase to look for'), { status: 400 });
if (!cleanTag) throw Object.assign(new Error('Enter the tag to add'), { status: 400 });
await db.query('INSERT INTO tag_rules (phrase, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING', [cleanPhrase, cleanTag]);
}
async function removeRule(id) {
await db.query('DELETE FROM tag_rules WHERE id = $1', [id]);
}
module.exports = {
BUILT_IN_RULES, tokenize, matchTags,
run, pendingCount, reapply, isEnabled, setEnabled, listRules, addRule, removeRule,
};