diff --git a/README.md b/README.md index 875ea55..090cf64 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,28 @@ columna que ya no está. La portada del panel dice qué hay y qué falta —capítulos sin publicar, géneros sin ficha, marcas del glosario sin confirmar—, que es la pregunta que se hace -quien entra. +quien entra. Desde ahí se editan los capítulos, las fichas de género y los +prompts de IA. + +### Los prompts + +Un prompt es de un **modelo concreto**: Suno v4 no es Suno v3.5, porque cambian +la sintaxis y lo que el modelo ignora. Por eso `modelo_ia` lleva una fila por +versión y no por marca, y los modelos se dan de alta a mano —una lista escrita +en un script envejecería en un mes—. + +De cada combinación de modelo, género y ánimo hay **varios prompts con uno +marcado**, porque esto se itera y no se acierta: la lista ordena por preferido y +luego por valoración, que es como se busca «el que funcionó». + +Los nueve ánimos salen de la escala **GEMS**, que está hecha para emoción +musical y no para emoción en general, y cada uno lleva valencia y energía para +poder redactarlo: «alta energía, valencia baja» es una instrucción que una IA +entiende; «tensión» a secas, no. + +```bash +npm run db:animos # siembra los nueve ánimos; idempotente +``` Antes había un `/admin` dentro de la web con [Sveltia CMS](https://sveltiacms.app), un editor sobre git que escribía los Markdown de `src/content/`. Se quitó al diff --git a/docs/modelo-de-datos.md b/docs/modelo-de-datos.md index ea16ea3..cd986c6 100644 --- a/docs/modelo-de-datos.md +++ b/docs/modelo-de-datos.md @@ -557,8 +557,20 @@ no cambia nada en pantalla**. `$lib/content` solo lo usan los scripts. «1 género sin ficha» obliga a ir a buscar cuál, y esa búsqueda ya la ha hecho la consulta. - Faltan las pantallas de editar: glosario —confirmar las acepciones marcadas, - que es lo que tiene `capitulo_acepcion` esperando—, capítulos y géneros. + **Se editan** los capítulos —los principios y las fichas, que son la misma + tabla— y las fichas de género enteras: sus datos, los países que las ponen en + el mapa y la prosa por secciones. Y los **prompts de IA**, con sus modelos: + una fila por versión, no por marca, y varios prompts por combinación con uno + marcado como el que funcionó. + + Los nueve **ánimos** de la GEMS se siembran con `npm run db:animos`. Los + modelos no: los nombres y las versiones los pone quien los usa, y una lista + escrita en un script envejecería en un mes. + + Faltan: el glosario —confirmar las acepciones marcadas, que es lo que tiene + `capitulo_acepcion` esperando—, las entradas del blog, el catálogo, y de la + ficha de género los alias, la temática, la instrumentación, el linaje, las + personas, las obras de referencia y los versos. ## Decisiones tomadas por defecto diff --git a/package.json b/package.json index 40ec522..9f5e88e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "db:generos": "node --env-file=.env scripts/db/sembrar-generos.mjs", "db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs", "db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs", + "db:animos": "node --env-file=.env scripts/db/sembrar-animos.mjs", "db:completar": "node --env-file=.env scripts/db/completar-catalogo.mjs", "db:comparar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/comparar-catalogo.mjs" }, diff --git a/scripts/db/sembrar-animos.mjs b/scripts/db/sembrar-animos.mjs new file mode 100644 index 0000000..63c08ce --- /dev/null +++ b/scripts/db/sembrar-animos.mjs @@ -0,0 +1,119 @@ +/** + * Siembra los ánimos. + * + * npm run db:animos + * + * Son los nueve factores de la **GEMS** (*Geneva Emotional Music Scale*), que es + * una escala hecha para emoción musical y no para emoción en general: por eso + * tiene «trascendencia» y «nostalgia» y no tiene «miedo» ni «asco». + * + * Cada uno lleva además los dos ejes del modelo circumplejo de Russell: + * valencia —si tira a agradable o a desagradable— y energía. Sirven para + * ordenar, para interpolar entre dos ánimos, y sobre todo porque son + * redactables: «alta energía, valencia baja» es una instrucción que una IA + * entiende; «tensión» a secas, no. + * + * Idempotente por código: volver a lanzarlo actualiza y no duplica. + */ + +import pg from 'pg'; + +/** `valencia` va de −1 a 1 y `energia` de 0 a 100, como dice el esquema. */ +const ANIMOS = [ + { + codigo: 'asombro', + nombre: 'Asombro', + gems: 'wonder', + valencia: 1, + energia: 60, + descripcion: 'Lo que abre los ojos. Deslumbra sin arrollar.' + }, + { + codigo: 'trascendencia', + nombre: 'Trascendencia', + gems: 'transcendence', + valencia: 1, + energia: 45, + descripcion: 'Sacar a alguien de sí mismo. Lo solemne sin lo grandilocuente.' + }, + { + codigo: 'ternura', + nombre: 'Ternura', + gems: 'tenderness', + valencia: 1, + energia: 30, + descripcion: 'El cariño dicho de cerca y en voz baja.' + }, + { + codigo: 'nostalgia', + nombre: 'Nostalgia', + gems: 'nostalgia', + valencia: 0, + energia: 30, + descripcion: + 'Lo que se recuerda con gusto y duele un poco. La valencia va en medio a propósito.' + }, + { + codigo: 'serenidad', + nombre: 'Serenidad', + gems: 'peacefulness', + valencia: 1, + energia: 20, + descripcion: 'Calma sin sueño. Nada que resolver.' + }, + { + codigo: 'fuerza', + nombre: 'Fuerza', + gems: 'power', + valencia: 1, + energia: 85, + descripcion: 'Empuje, triunfo, ganas. Lo que se canta de pie.' + }, + { + codigo: 'alegria-vital', + nombre: 'Alegría vital', + gems: 'joyful activation', + valencia: 1, + energia: 80, + descripcion: 'Alegría que mueve el cuerpo. Es la de bailar, no la de sonreír.' + }, + { + codigo: 'tension', + nombre: 'Tensión', + gems: 'tension', + valencia: -1, + energia: 75, + descripcion: 'Nervio, inquietud, algo a punto de pasar.' + }, + { + codigo: 'tristeza', + nombre: 'Tristeza', + gems: 'sadness', + valencia: -1, + energia: 20, + descripcion: 'La pena quieta. En un bolero, la de siempre.' + } +]; + +const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 }); +await c.connect(); + +let nuevos = 0; +for (const a of ANIMOS) { + const { rows } = await c.query( + `insert into animo (id, codigo, nombre, gems, valencia, energia, descripcion) + values (gen_random_uuid(), $1, $2, $3, $4, $5, $6) + on conflict (codigo) do update set + nombre = excluded.nombre, + gems = excluded.gems, + valencia = excluded.valencia, + energia = excluded.energia, + descripcion = excluded.descripcion + returning (xmax = 0) as insertado`, + [a.codigo, a.nombre, a.gems, a.valencia, a.energia, a.descripcion] + ); + if (rows[0].insertado) nuevos++; +} + +console.log(`${ANIMOS.length} ánimos: ${nuevos} nuevos, ${ANIMOS.length - nuevos} actualizados.`); +await c.end(); diff --git a/src/lib/esquema-prompt.spec.ts b/src/lib/esquema-prompt.spec.ts new file mode 100644 index 0000000..62dbb1c --- /dev/null +++ b/src/lib/esquema-prompt.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { camposDe, comoTexto, revisarContra, revisarEsquema } from './esquema-prompt'; + +const ESQUEMA = { + type: 'object', + required: ['estilo'], + 'x-order': ['estilo', 'titulo', 'letra', 'voz', 'peso', 'instrumental'], + properties: { + instrumental: { type: 'boolean', title: 'Sin voz' }, + peso: { type: 'number', title: 'Influencia', minimum: 0, maximum: 1 }, + voz: { type: 'string', title: 'Voz', enum: ['masculina', 'femenina'] }, + letra: { type: 'string', title: 'Letra' }, + titulo: { type: 'string', title: 'Título', maxLength: 60 }, + estilo: { type: 'string', title: 'Estilo', maxLength: 200, description: 'Con comas.' } + } +}; + +describe('camposDe', () => { + it('lee los campos con su etiqueta, su ayuda y si hacen falta', () => { + const campos = camposDe(ESQUEMA); + const estilo = campos.find((c) => c.nombre === 'estilo')!; + + expect(estilo.etiqueta).toBe('Estilo'); + expect(estilo.ayuda).toBe('Con comas.'); + expect(estilo.requerido).toBe(true); + expect(estilo.maxLength).toBe(200); + }); + + it('respeta el orden de «x-order»', () => { + // Y hace falta: la base guarda el esquema como `jsonb`, que reordena las + // claves por longitud. Sin esto, el campo obligatorio salía el último. + expect(camposDe(ESQUEMA).map((c) => c.nombre)).toEqual([ + 'estilo', + 'titulo', + 'letra', + 'voz', + 'peso', + 'instrumental' + ]); + }); + + it('pinta en varias líneas lo que no cabe cómodo en una', () => { + const campos = camposDe(ESQUEMA); + const largo = (nombre: string) => campos.find((c) => c.nombre === nombre)!.largo; + + // Sin `maxLength` puede ser una letra entera. + expect(largo('letra')).toBe(true); + // Doscientos caracteres caben en un `input`, pero no se ven: mejor bloque. + expect(largo('estilo')).toBe(true); + // Sesenta sí: un título es una línea. + expect(largo('titulo')).toBe(false); + // Y una lista cerrada nunca es un bloque de texto. + expect(largo('voz')).toBe(false); + }); + + it('ignora lo que no entiende en vez de fallar', () => { + const raro = { + type: 'object', + properties: { + bueno: { type: 'string' }, + anidado: { type: 'object', properties: {} }, + lista: { type: 'array' }, + sinTipo: { title: 'Sin tipo' } + } + }; + expect(camposDe(raro).map((c) => c.nombre)).toEqual(['bueno']); + }); + + it('sin esquema no hay campos, que es lo que deja el bloque de texto', () => { + expect(camposDe(null)).toEqual([]); + expect(camposDe({})).toEqual([]); + expect(camposDe('lo que sea')).toEqual([]); + }); +}); + +describe('revisarEsquema', () => { + it('acepta uno bueno, y el vacío', () => { + expect(revisarEsquema(JSON.stringify(ESQUEMA))).toBeNull(); + expect(revisarEsquema(' ')).toBeNull(); + }); + + it('rechaza lo que no es JSON', () => { + expect(revisarEsquema('{ esto no')).toBeTruthy(); + }); + + it('rechaza uno sin campos aprovechables', () => { + // Guardarlo dejaría el formulario de ese modelo sin nada que pintar, y el + // fallo aparecería lejos de donde se cometió. + expect(revisarEsquema('{"type":"object","properties":{}}')).toBeTruthy(); + expect(revisarEsquema('{"type":"array"}')).toBeTruthy(); + }); +}); + +describe('revisarContra', () => { + const campos = camposDe(ESQUEMA); + + it('deja pasar lo que cuadra', () => { + expect(revisarContra(campos, { estilo: 'bolero lento', peso: 0.7, voz: 'masculina' })).toEqual( + {} + ); + }); + + it('reclama lo obligatorio', () => { + expect(revisarContra(campos, {}).estilo).toBeTruthy(); + }); + + it('respeta el largo máximo', () => { + expect(revisarContra(campos, { estilo: 'x'.repeat(201) }).estilo).toBeTruthy(); + }); + + it('y el rango de los números', () => { + expect(revisarContra(campos, { estilo: 'x', peso: 2 }).peso).toBeTruthy(); + expect(revisarContra(campos, { estilo: 'x', peso: -1 }).peso).toBeTruthy(); + }); + + it('y las opciones cerradas', () => { + expect(revisarContra(campos, { estilo: 'x', voz: 'silbando' }).voz).toBeTruthy(); + }); +}); + +describe('comoTexto', () => { + it('enseña una línea por campo con valor', () => { + const texto = comoTexto(camposDe(ESQUEMA), { estilo: 'bolero', voz: 'masculina', letra: '' }); + expect(texto).toBe('Estilo: bolero\nVoz: masculina'); + }); + + it('sin esquema, el contenido es un bloque y se enseña tal cual', () => { + expect(comoTexto([], { texto: 'un prompt suelto' })).toBe('un prompt suelto'); + }); +}); diff --git a/src/lib/esquema-prompt.ts b/src/lib/esquema-prompt.ts new file mode 100644 index 0000000..639320e --- /dev/null +++ b/src/lib/esquema-prompt.ts @@ -0,0 +1,220 @@ +/** + * El esquema de los prompts de un modelo. + * + * `modelo_ia.esquema` guarda un JSON Schema con la forma que toman los prompts + * de ese modelo, y el modelo de datos le da dos trabajos: **validar al guardar** + * y **pintar el formulario**, para que añadir una IA nueva no cueste una + * pantalla. Aquí están los dos. + * + * ## Qué subconjunto se entiende + * + * No es un validador de JSON Schema completo, y decirlo importa: un prompt de + * Suno son cuatro campos —estilo, letra, exclusiones, un peso—, no un documento + * anidado. Se entiende esto y nada más: + * + * - `type: "object"` en la raíz, con `properties` y `required`. + * - Por campo, `type` `string`, `number`, `integer` o `boolean`. + * - `enum` para elegir de una lista. + * - `maxLength` en las cadenas; `minimum` y `maximum` en los números. + * - `title` como etiqueta y `description` como ayuda, que son las dos claves + * estándar para eso. + * + * Lo que no esté aquí —anidamiento, `oneOf`, `pattern`, `$ref`— se ignora en + * vez de fallar: un esquema con algo de más sigue sirviendo para lo que sí + * entiende. Lo que **no** se ignora es un esquema roto: ese se rechaza al + * guardarlo, no al usarlo. + * + * Una cadena se pinta en varias líneas si no dice cuánto ocupa o si admite más + * de ciento veinte caracteres. Una letra de canción no cabe en un `input`. + */ + +export type TipoDeCampo = 'string' | 'number' | 'integer' | 'boolean'; + +export interface CampoDeEsquema { + nombre: string; + tipo: TipoDeCampo; + /** Lo que se lee encima del campo. */ + etiqueta: string; + ayuda: string | null; + requerido: boolean; + opciones: string[] | null; + maxLength: number | null; + minimo: number | null; + maximo: number | null; + /** Si se pinta como `textarea` en vez de como `input`. */ + largo: boolean; +} + +/** A partir de aquí una cadena deja de caber cómoda en una línea. */ +const LARGO_DESDE = 120; + +const esObjeto = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +const TIPOS: TipoDeCampo[] = ['string', 'number', 'integer', 'boolean']; + +/** + * Los campos declarados por un esquema, en el orden que pida `x-order`. + * + * Hace falta decir el orden porque `jsonb` **no guarda el de las claves**: + * Postgres las reordena por longitud y luego alfabéticamente, así que un + * esquema escrito como estilo · letra · voz vuelve como voz · letra · estilo. + * Se vio en pantalla, con el campo obligatorio al final del formulario. + * + * Sin `x-order` se respeta lo que devuelva la base, que es predecible aunque no + * sea lo que se escribió. `x-` es el prefijo convenido para lo que un esquema + * añade por su cuenta. + * + * Devuelve una lista vacía si el esquema no dice nada aprovechable, que es lo + * que hace que un modelo sin esquema siga funcionando con un campo de texto. + */ +export function camposDe(esquema: unknown): CampoDeEsquema[] { + if (!esObjeto(esquema) || !esObjeto(esquema.properties)) return []; + + const requeridos = new Set( + Array.isArray(esquema.required) ? esquema.required.filter((r) => typeof r === 'string') : [] + ); + + const campos: CampoDeEsquema[] = []; + + for (const [nombre, bruto] of Object.entries(esquema.properties)) { + if (!esObjeto(bruto)) continue; + + const tipo = TIPOS.find((t) => t === bruto.type); + if (!tipo) continue; + + const maxLength = typeof bruto.maxLength === 'number' ? bruto.maxLength : null; + const opciones = Array.isArray(bruto.enum) + ? bruto.enum.filter((o): o is string => typeof o === 'string') + : null; + + campos.push({ + nombre, + tipo, + etiqueta: typeof bruto.title === 'string' && bruto.title.trim() !== '' ? bruto.title : nombre, + ayuda: typeof bruto.description === 'string' ? bruto.description : null, + requerido: requeridos.has(nombre), + opciones: opciones && opciones.length > 0 ? opciones : null, + maxLength, + minimo: typeof bruto.minimum === 'number' ? bruto.minimum : null, + maximo: typeof bruto.maximum === 'number' ? bruto.maximum : null, + largo: tipo === 'string' && !opciones && (maxLength === null || maxLength > LARGO_DESDE) + }); + } + + const orden = esquema['x-order']; + if (Array.isArray(orden)) { + const puesto = new Map(orden.map((n, i) => [String(n), i])); + // Lo que `x-order` no nombra va detrás, en el orden que traía. + const alFinal = campos.length; + campos.sort((a, b) => (puesto.get(a.nombre) ?? alFinal) - (puesto.get(b.nombre) ?? alFinal)); + } + + return campos; +} + +/** Qué está mal en el propio esquema. Vacío si se puede usar. */ +export function revisarEsquema(texto: string): string | null { + if (texto.trim() === '') return null; + + let leido: unknown; + try { + leido = JSON.parse(texto); + } catch { + return 'Eso no es JSON válido.'; + } + + if (!esObjeto(leido)) return 'El esquema tiene que ser un objeto.'; + if (leido.type !== undefined && leido.type !== 'object') { + return 'La raíz del esquema tiene que ser de tipo «object».'; + } + if (!esObjeto(leido.properties)) { + return 'Falta «properties»: es donde se declaran los campos del prompt.'; + } + if (camposDe(leido).length === 0) { + return 'Ningún campo es de un tipo que se entienda: string, number, integer o boolean.'; + } + + return null; +} + +/** Lo que se guarda: un valor por campo, ya con su tipo. */ +export type ContenidoDePrompt = Record; + +/** + * Comprueba los valores contra el esquema. + * + * Devuelve un mensaje por campo. Un campo que no está en el esquema no se + * comprueba y tampoco se guarda: el formulario solo pinta los declarados, y lo + * que llegue de más viene de alguien trasteando. + */ +export function revisarContra( + campos: CampoDeEsquema[], + valores: ContenidoDePrompt +): Record { + const errores: Record = {}; + + for (const campo of campos) { + const valor = valores[campo.nombre]; + const vacio = valor === undefined || valor === '' || valor === null; + + if (campo.requerido && vacio && campo.tipo !== 'boolean') { + errores[campo.nombre] = `«${campo.etiqueta}» hace falta.`; + continue; + } + if (vacio) continue; + + if (campo.tipo === 'string') { + const texto = String(valor); + if (campo.opciones && !campo.opciones.includes(texto)) { + errores[campo.nombre] = + `«${campo.etiqueta}» tiene que ser uno de: ${campo.opciones.join(', ')}.`; + } else if (campo.maxLength !== null && texto.length > campo.maxLength) { + errores[campo.nombre] = + `«${campo.etiqueta}» admite ${campo.maxLength} caracteres y lleva ${texto.length}.`; + } + continue; + } + + if (campo.tipo === 'number' || campo.tipo === 'integer') { + const numero = Number(valor); + if (!Number.isFinite(numero)) { + errores[campo.nombre] = `«${campo.etiqueta}» es un número.`; + } else if (campo.tipo === 'integer' && !Number.isInteger(numero)) { + errores[campo.nombre] = `«${campo.etiqueta}» es un número entero.`; + } else if (campo.minimo !== null && numero < campo.minimo) { + errores[campo.nombre] = `«${campo.etiqueta}» no baja de ${campo.minimo}.`; + } else if (campo.maximo !== null && numero > campo.maximo) { + errores[campo.nombre] = `«${campo.etiqueta}» no pasa de ${campo.maximo}.`; + } + } + } + + return errores; +} + +/** + * Cómo se enseña un prompt guardado: una línea por campo con valor. + * + * Sirve para la tarjeta de la lista y para copiarlo entero. Los campos vacíos + * no salen: un prompt con la mitad de las etiquetas en blanco se lee peor que + * uno corto. + */ +export function comoTexto(campos: CampoDeEsquema[], contenido: unknown): string { + if (!esObjeto(contenido)) return typeof contenido === 'string' ? contenido : ''; + + // Sin esquema, el contenido es `{ texto }` y se enseña tal cual. + if (campos.length === 0) { + return typeof contenido.texto === 'string' + ? contenido.texto + : JSON.stringify(contenido, null, 2); + } + + return campos + .filter((c) => { + const v = contenido[c.nombre]; + return v !== undefined && v !== null && v !== ''; + }) + .map((c) => `${c.etiqueta}: ${contenido[c.nombre]}`) + .join('\n'); +} diff --git a/src/lib/server/panel-prompts.ts b/src/lib/server/panel-prompts.ts new file mode 100644 index 0000000..86c8b9b --- /dev/null +++ b/src/lib/server/panel-prompts.ts @@ -0,0 +1,376 @@ +/** + * Los prompts de IA generativa, para el panel. + * + * Un prompt es de un **modelo concreto** —Suno v4 no es Suno v3.5: cambian la + * sintaxis y lo que el modelo ignora—, de un género y de un ánimo. Y hay varios + * por combinación con uno marcado como preferido, porque esto se itera: no se + * acierta a la primera y el que funcionó hay que poder encontrarlo. + * + * El contenido va en `jsonb` y no en columnas: la forma la define un tercero y + * cambia sin avisarnos. Pero abierto no es sin validar. Cada modelo declara en + * `modelo_ia.esquema` un JSON Schema con la forma de sus prompts, y ese esquema + * hace dos trabajos —validar al guardar y pintar el formulario—, que es lo que + * evita una pantalla por cada IA. Están los dos en `$lib/esquema-prompt`. + */ + +import { and, asc, desc, eq, sql } from 'drizzle-orm'; +import { db } from './db'; +import { animo, genero, modeloIa, prompt } from './db/schema'; +import { + camposDe, + revisarContra, + revisarEsquema, + type CampoDeEsquema, + type ContenidoDePrompt +} from '$lib/esquema-prompt'; + +export type Visibilidad = 'privado' | 'taller' | 'publico'; +export type Estado = 'borrador' | 'publicado' | 'retirado'; + +/* ------------------------------------------------------------------------- + * Modelos + * ---------------------------------------------------------------------- */ + +export interface ModeloEnLista { + id: string; + codigo: string; + familia: string; + version: string; + nombre: string; + vigente: boolean; + notaSintaxis: string | null; + /** El JSON Schema tal cual, para pintarlo en su formulario. */ + esquema: unknown; + /** Los campos que declara, ya leídos: es lo que pinta el formulario. */ + campos: CampoDeEsquema[]; + prompts: number; +} + +export async function listaDeModelos(): Promise { + const filas = await db + .select({ + id: modeloIa.id, + codigo: modeloIa.codigo, + familia: modeloIa.familia, + version: modeloIa.version, + nombre: modeloIa.nombre, + vigente: modeloIa.vigente, + notaSintaxis: modeloIa.notaSintaxis, + esquema: modeloIa.esquema, + prompts: sql`( + select count(*)::int from prompt p where p.modelo_ia_id = ${modeloIa.id} + )` + }) + .from(modeloIa) + .orderBy(asc(modeloIa.familia), asc(modeloIa.version)); + + return filas.map((f) => ({ ...f, campos: camposDe(f.esquema) })); +} + +export interface CambiosDelModelo { + familia: string; + version: string; + nombre: string; + notaSintaxis: string; + /** El JSON Schema como texto, tal cual se escribe. */ + esquema: string; + vigente: boolean; +} + +export type ErroresDelModelo = Partial< + Record<'familia' | 'version' | 'nombre' | 'esquema', string> +>; + +export function revisarModelo(cambios: CambiosDelModelo): ErroresDelModelo { + const errores: ErroresDelModelo = {}; + + if (cambios.familia.trim() === '') errores.familia = 'La familia es «Suno», «Udio», la que sea.'; + if (cambios.version.trim() === '') { + errores.version = 'La versión importa: v4 no escribe como v3.5.'; + } + if (cambios.nombre.trim() === '') errores.nombre = 'Ponle el nombre con el que lo reconoces.'; + + /* + * Un esquema roto se rechaza aquí y no al usarlo. Guardarlo dejaría el + * formulario de prompts de ese modelo sin campos, y el fallo aparecería + * lejos de donde se cometió. + */ + const malEsquema = revisarEsquema(cambios.esquema); + if (malEsquema) errores.esquema = malEsquema; + + return errores; +} + +/** El código sale de familia y versión: `suno-v4`, `udio-1-5`. */ +export function codigoDeModelo(familia: string, version: string): string { + const limpio = (t: string) => + t + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + return `${limpio(familia)}-${limpio(version)}`; +} + +/** Un esquema en blanco se guarda como nulo: «este modelo no declara forma». */ +const comoEsquema = (texto: string): unknown => (texto.trim() === '' ? null : JSON.parse(texto)); + +function valoresDelModelo(cambios: CambiosDelModelo) { + return { + codigo: codigoDeModelo(cambios.familia, cambios.version), + familia: cambios.familia.trim(), + version: cambios.version.trim(), + nombre: cambios.nombre.trim(), + notaSintaxis: cambios.notaSintaxis.trim() || null, + esquema: comoEsquema(cambios.esquema), + vigente: cambios.vigente + }; +} + +export async function crearModelo(cambios: CambiosDelModelo): Promise { + const [fila] = await db + .insert(modeloIa) + .values(valoresDelModelo(cambios)) + .returning({ id: modeloIa.id }); + return fila.id; +} + +export async function guardarModelo(id: string, cambios: CambiosDelModelo): Promise { + const hechas = await db + .update(modeloIa) + .set(valoresDelModelo(cambios)) + .where(eq(modeloIa.id, id)) + .returning({ id: modeloIa.id }); + return hechas.length > 0; +} + +/* ------------------------------------------------------------------------- + * Prompts + * ---------------------------------------------------------------------- */ + +export interface PromptEnLista { + id: string; + animoId: string | null; + animoNombre: string | null; + modeloId: string; + modeloNombre: string; + idioma: string; + valoracion: number | null; + preferido: boolean; + visibilidad: Visibilidad; + estado: Estado; + notas: string | null; + /** Los valores tal cual, para rellenar el formulario del modelo. */ + contenido: Record; +} + +/** Los de un género, ordenados por lo que funcionó. */ +export async function promptsDelGenero(generoId: string): Promise { + const filas = await db + .select({ + id: prompt.id, + animoId: prompt.animoId, + animoNombre: animo.nombre, + modeloId: prompt.modeloIaId, + modeloNombre: modeloIa.nombre, + idioma: prompt.idioma, + valoracion: prompt.valoracion, + preferido: prompt.preferido, + visibilidad: prompt.visibilidad, + estado: prompt.estado, + notas: prompt.notas, + contenido: prompt.contenido + }) + .from(prompt) + .leftJoin(animo, eq(prompt.animoId, animo.id)) + .innerJoin(modeloIa, eq(prompt.modeloIaId, modeloIa.id)) + .where(eq(prompt.generoId, generoId)) + /* + * El preferido primero y luego el mejor valorado: al abrir un género lo + * que se busca es «el que funcionó», no el último que se escribió. + */ + .orderBy(desc(prompt.preferido), desc(prompt.valoracion), asc(prompt.creadoEn)); + + return filas.map((f) => ({ + ...f, + contenido: (f.contenido ?? {}) as Record + })); +} + +/** Los ánimos, para el desplegable. De más energía a menos. */ +export async function animosParaElegir() { + return db + .select({ id: animo.id, nombre: animo.nombre, descripcion: animo.descripcion }) + .from(animo) + .orderBy(desc(animo.energia)); +} + +export interface CambiosDelPrompt { + modeloIaId: string; + generoId: string | null; + animoId: string | null; + idioma: string; + /** Un valor por campo del esquema del modelo. */ + contenido: ContenidoDePrompt; + notas: string; + valoracion: number | null; + preferido: boolean; + visibilidad: Visibilidad; + estado: Estado; +} + +export interface ErroresDelPrompt { + /** Lo que está mal fuera del contenido. */ + general?: string; + /** Y lo que está mal dentro, por campo del esquema. */ + campos?: Record; +} + +const VISIBILIDADES: Visibilidad[] = ['privado', 'taller', 'publico']; +const ESTADOS_PROMPT: Estado[] = ['borrador', 'publicado', 'retirado']; + +/** + * Comprueba el prompt contra el esquema de su modelo. + * + * `campos` sale del modelo elegido. Si está vacío —un modelo que no declara + * forma— se comprueba el único campo que hay entonces, `texto`, y basta con que + * no esté en blanco. + */ +export function revisarPrompt( + cambios: CambiosDelPrompt, + campos: CampoDeEsquema[] +): ErroresDelPrompt { + const errores: ErroresDelPrompt = {}; + + if (!cambios.modeloIaId) { + errores.general = 'Un prompt es de un modelo concreto: elige cuál.'; + return errores; + } + + if (cambios.valoracion !== null) { + if (!Number.isInteger(cambios.valoracion) || cambios.valoracion < 1 || cambios.valoracion > 5) { + errores.general = 'La valoración va de 1 a 5, o se deja sin poner.'; + return errores; + } + } + + if (!VISIBILIDADES.includes(cambios.visibilidad)) { + errores.general = 'Esa visibilidad no existe.'; + return errores; + } + if (!ESTADOS_PROMPT.includes(cambios.estado)) { + errores.general = 'Ese estado no existe.'; + return errores; + } + + if (campos.length === 0) { + if (String(cambios.contenido.texto ?? '').trim() === '') { + errores.campos = { texto: 'El prompt no puede estar vacío.' }; + } + return errores; + } + + const malos = revisarContra(campos, cambios.contenido); + if (Object.keys(malos).length > 0) errores.campos = malos; + + /* + * Un prompt con todos los campos en blanco pasa la validación del esquema + * —si ninguno es obligatorio— y no es un prompt. Se comprueba aparte. + */ + const algoEscrito = campos.some((c) => { + const v = cambios.contenido[c.nombre]; + return v !== undefined && v !== '' && v !== false; + }); + if (!algoEscrito) errores.general = 'El prompt está vacío: rellena al menos un campo.'; + + return errores; +} + +/** + * Solo un preferido por combinación de modelo, género y ánimo. + * + * Lo quita de los demás en la misma transacción. Sin esto, marcar el segundo + * dejaría dos preferidos y la lista mentiría sobre cuál funcionó. + */ +async function soloUnPreferido( + tx: Parameters[0]>[0], + cambios: CambiosDelPrompt, + salvo: string +) { + await tx + .update(prompt) + .set({ preferido: false }) + .where( + and( + eq(prompt.modeloIaId, cambios.modeloIaId), + cambios.generoId ? eq(prompt.generoId, cambios.generoId) : sql`genero_id is null`, + cambios.animoId ? eq(prompt.animoId, cambios.animoId) : sql`animo_id is null`, + sql`${prompt.id} <> ${salvo}` + ) + ); +} + +export async function crearPrompt(cambios: CambiosDelPrompt): Promise { + return db.transaction(async (tx) => { + const [fila] = await tx + .insert(prompt) + .values({ + modeloIaId: cambios.modeloIaId, + generoId: cambios.generoId, + animoId: cambios.animoId, + idioma: cambios.idioma, + contenido: cambios.contenido, + notas: cambios.notas.trim() || null, + valoracion: cambios.valoracion, + preferido: cambios.preferido, + visibilidad: cambios.visibilidad, + // Nace en borrador, como todo lo publicable de este modelo. + estado: cambios.estado + }) + .returning({ id: prompt.id }); + + if (cambios.preferido) await soloUnPreferido(tx, cambios, fila.id); + return fila.id; + }); +} + +export async function guardarPrompt(id: string, cambios: CambiosDelPrompt): Promise { + return db.transaction(async (tx) => { + const hechas = await tx + .update(prompt) + .set({ + modeloIaId: cambios.modeloIaId, + animoId: cambios.animoId, + idioma: cambios.idioma, + contenido: cambios.contenido, + notas: cambios.notas.trim() || null, + valoracion: cambios.valoracion, + preferido: cambios.preferido, + visibilidad: cambios.visibilidad, + estado: cambios.estado, + actualizadoEn: new Date() + }) + .where(eq(prompt.id, id)) + .returning({ id: prompt.id }); + + if (hechas.length === 0) return false; + if (cambios.preferido) await soloUnPreferido(tx, cambios, id); + return true; + }); +} + +export async function borrarPrompt(id: string): Promise { + const hechas = await db.delete(prompt).where(eq(prompt.id, id)).returning({ id: prompt.id }); + return hechas.length > 0; +} + +/** El identificador del género por su slug, que es lo que trae la dirección. */ +export async function idDelGenero(slug: string): Promise { + const [fila] = await db + .select({ id: genero.id }) + .from(genero) + .where(eq(genero.slug, slug)) + .limit(1); + return fila?.id ?? null; +} diff --git a/src/lib/server/panel.ts b/src/lib/server/panel.ts index dd5319d..5cb8281 100644 --- a/src/lib/server/panel.ts +++ b/src/lib/server/panel.ts @@ -33,7 +33,7 @@ export interface FilaDelPanel { * `resolve()`, contra su propio mapa de rutas, y así una que se escriba mal * deja de compilar en vez de dar un 404 al pulsarla. */ - pantalla: 'capitulos' | 'generos' | null; + pantalla: 'capitulos' | 'generos' | 'prompts' | 'modelos' | null; } export interface GrupoDelPanel { @@ -104,6 +104,13 @@ export async function estadoDelContenido(): Promise { (select count(*) from video)::int as videos, (select count(*) from medio)::int as medios, + (select count(*) from prompt)::int as prompts, + (select count(*) from prompt where preferido)::int as prompts_preferidos, + (select count(*) from prompt where valoracion is null)::int as prompts_sin_probar, + (select count(*) from modelo_ia)::int as modelos, + (select count(*) from modelo_ia where vigente)::int as modelos_vigentes, + (select count(*) from animo)::int as animos, + (select count(*) from entrada)::int as entradas, (select count(*) from entrada where estado <> 'publicado')::int as entradas_a_medias, (select count(*) from pagina)::int as paginas, @@ -317,6 +324,43 @@ export async function estadoDelContenido(): Promise { } ] }, + { + titulo: 'IA generativa', + nota: 'Los prompts con los que se saca música, por modelo, género y ánimo.', + filas: [ + { + titulo: 'Prompts', + cuantas: n.prompts, + unidad: 'prompts', + detalle: + n.prompts > 0 + ? `${n.prompts_preferidos} marcados como el que funcionó` + : 'Varios por combinación, con uno marcado: esto se itera', + pendiente: vacia(n.prompts) ?? falta(n.prompts_sin_probar, 'sin probar'), + pantalla: 'prompts' + }, + { + titulo: 'Modelos', + cuantas: n.modelos, + unidad: 'modelos', + // Una versión, no una marca: un prompt de Suno v4 no es uno de v3.5. + detalle: + n.modelos > 0 + ? `${n.modelos_vigentes} en uso · una fila por versión, no por marca` + : 'Una fila por versión: Suno v4 y v3.5 son dos modelos', + pendiente: vacia(n.modelos), + pantalla: 'modelos' + }, + { + titulo: 'Ánimos', + cuantas: n.animos, + unidad: 'ánimos', + detalle: 'La escala GEMS, con valencia y energía para poder redactarlos', + pendiente: vacia(n.animos), + pantalla: null + } + ] + }, { titulo: 'Editorial', nota: 'El blog, las páginas sueltas y los textos legales.', diff --git a/src/panel/rutas/(dentro)/+page.svelte b/src/panel/rutas/(dentro)/+page.svelte index 404eea7..7ba3de0 100644 --- a/src/panel/rutas/(dentro)/+page.svelte +++ b/src/panel/rutas/(dentro)/+page.svelte @@ -22,7 +22,9 @@ */ const PANTALLAS = { capitulos: resolve('/(dentro)/capitulos'), - generos: resolve('/(dentro)/generos') + generos: resolve('/(dentro)/generos'), + prompts: resolve('/(dentro)/generos'), + modelos: resolve('/(dentro)/prompts/modelos') } as const; diff --git a/src/panel/rutas/(dentro)/generos/[slug]/+page.server.ts b/src/panel/rutas/(dentro)/generos/[slug]/+page.server.ts index e16c9df..8e1c9d3 100644 --- a/src/panel/rutas/(dentro)/generos/[slug]/+page.server.ts +++ b/src/panel/rutas/(dentro)/generos/[slug]/+page.server.ts @@ -7,13 +7,40 @@ import { type Estado, type Relacion } from '$lib/server/panel-generos'; +import { + animosParaElegir, + borrarPrompt, + crearPrompt, + guardarPrompt, + idDelGenero, + listaDeModelos, + promptsDelGenero, + revisarPrompt, + type CambiosDelPrompt, + type ErroresDelPrompt, + type Visibilidad +} from '$lib/server/panel-prompts'; +import { camposDe, type ContenidoDePrompt } from '$lib/esquema-prompt'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ params }) => { const genero = await generoParaEditar(params.slug); if (!genero) error(404, 'No hay ningún género con ese slug.'); - return { genero }; + const id = await idDelGenero(params.slug); + + /* + * Los prompts salen aquí y no en una pantalla aparte: un prompt es de un + * género, y tenerlos en otra pestaña obliga a saltar de una a otra para + * escribir la ficha y sacar la música de la misma cosa. + */ + const [prompts, modelos, animos] = await Promise.all([ + id ? promptsDelGenero(id) : Promise.resolve([]), + listaDeModelos(), + animosParaElegir() + ]); + + return { genero, prompts, modelos, animos }; }; /** Un año vacío es «no se sabe», no un cero. */ @@ -31,8 +58,54 @@ function lineasDe(valor: FormDataEntryValue | null): string[] { .filter((l) => l !== ''); } +/** + * Los valores del prompt, uno por campo del esquema de su modelo. + * + * Se leen los campos que el modelo declara y no los que trae el formulario: + * así lo que llegue de más —de alguien trasteando con la petición— no acaba en + * la base, y un campo que el esquema pide y el formulario no manda queda como + * ausente y lo caza la validación. + */ +function contenidoDe(datos: FormData, campos: ReturnType): ContenidoDePrompt { + if (campos.length === 0) return { texto: String(datos.get('campo:texto') ?? '') }; + + const contenido: ContenidoDePrompt = {}; + for (const campo of campos) { + const bruto = datos.get(`campo:${campo.nombre}`); + + if (campo.tipo === 'boolean') { + contenido[campo.nombre] = bruto !== null; + continue; + } + + const texto = String(bruto ?? ''); + if (texto.trim() === '') continue; + + contenido[campo.nombre] = campo.tipo === 'string' ? texto : Number(texto); + } + return contenido; +} + +function leerPrompt(datos: FormData, campos: ReturnType, generoId: string) { + const valoracion = String(datos.get('valoracion') ?? '').trim(); + + return { + modeloIaId: String(datos.get('modeloIaId') ?? ''), + generoId, + animoId: String(datos.get('animoId') ?? '') || null, + idioma: String(datos.get('idioma') ?? 'en'), + contenido: contenidoDe(datos, campos), + notas: String(datos.get('notas') ?? ''), + // Sin valorar es «todavía no lo sé», no un cero. + valoracion: valoracion === '' ? null : Number(valoracion), + preferido: datos.get('preferido') !== null, + visibilidad: String(datos.get('visibilidad') ?? 'privado') as Visibilidad, + estado: String(datos.get('estado') ?? 'borrador') as Estado + } satisfies CambiosDelPrompt; +} + export const actions: Actions = { - default: async ({ request, params }) => { + guardar: async ({ request, params }) => { const datos = await request.formData(); /* @@ -93,5 +166,55 @@ export const actions: Actions = { if (!guardado) error(404, 'Ese género ha dejado de existir mientras lo editabas.'); return { guardado: true }; + }, + + crearPrompt: async ({ request, params }) => { + const datos = await request.formData(); + const generoId = await idDelGenero(params.slug); + if (!generoId) error(404, 'Ese género ya no existe.'); + + const modelos = await listaDeModelos(); + const modelo = modelos.find((m) => m.id === String(datos.get('modeloIaId') ?? '')); + const campos = modelo?.campos ?? []; + + const cambios = leerPrompt(datos, campos, generoId); + const malos = revisarPrompt(cambios, campos); + if (malos.general || malos.campos) { + return fail(400, { promptErrores: malos, promptDonde: 'nuevo' }); + } + + await crearPrompt(cambios); + return { promptGuardado: true }; + }, + + guardarPrompt: async ({ request, params }) => { + const datos = await request.formData(); + const generoId = await idDelGenero(params.slug); + if (!generoId) error(404, 'Ese género ya no existe.'); + + const id = String(datos.get('id') ?? ''); + const modelos = await listaDeModelos(); + const modelo = modelos.find((m) => m.id === String(datos.get('modeloIaId') ?? '')); + const campos = modelo?.campos ?? []; + + const cambios = leerPrompt(datos, campos, generoId); + const malos = revisarPrompt(cambios, campos); + if (malos.general || malos.campos) { + return fail(400, { promptErrores: malos, promptDonde: id }); + } + + const guardado = await guardarPrompt(id, cambios); + if (!guardado) { + const perdido: ErroresDelPrompt = { general: 'Ese prompt ya no existe.' }; + return fail(404, { promptErrores: perdido, promptDonde: id }); + } + + return { promptGuardado: true }; + }, + + borrarPrompt: async ({ request }) => { + const datos = await request.formData(); + await borrarPrompt(String(datos.get('id') ?? '')); + return { promptBorrado: true }; } }; diff --git a/src/panel/rutas/(dentro)/generos/[slug]/+page.svelte b/src/panel/rutas/(dentro)/generos/[slug]/+page.svelte index c335c12..5ed2e8f 100644 --- a/src/panel/rutas/(dentro)/generos/[slug]/+page.svelte +++ b/src/panel/rutas/(dentro)/generos/[slug]/+page.svelte @@ -14,9 +14,50 @@ import { enhance } from '$app/forms'; import { resolve } from '$app/paths'; import { site } from '$lib/site'; + import { comoTexto } from '$lib/esquema-prompt'; let { data, form } = $props(); + /* --- Los prompts de este género ------------------------------------- */ + + /** Cuál está abierto para editar. Cerrados, la lista se lee de un vistazo. */ + let abierto = $state(''); + let copiado = $state(''); + + /** + * El modelo elegido en el formulario del prompt nuevo. + * + * Vive aquí y no dentro del `select` porque cambiarlo cambia los campos del + * formulario: los declara el esquema de cada modelo, y esa es justo la razón + * de que el esquema exista. + */ + let modeloNuevo = $state( + untrack(() => data.modelos.find((m) => m.vigente)?.id ?? data.modelos[0]?.id ?? '') + ); + + async function copiar(id: string, texto: string) { + try { + await navigator.clipboard.writeText(texto); + copiado = id; + setTimeout(() => (copiado = copiado === id ? '' : copiado), 2000); + } catch { + // Sin permiso de portapapeles no hay nada útil que hacer, y un aviso de + // error solo estorbaría: el texto está a la vista para copiarlo a mano. + } + } + + const VISIBILIDAD: Record = { + privado: 'Privado', + taller: 'Solo en el taller', + publico: 'Público' + }; + + const ESTADO: Record = { + borrador: 'Borrador', + publicado: 'Publicado', + retirado: 'Retirado' + }; + const copia = () => ({ nombre: data.genero.nombre, resumen: data.genero.resumen, @@ -74,6 +115,7 @@
{ guardando = true; @@ -328,6 +370,301 @@ {/if}
+ +
+ + + {#if data.modelos.length === 0} +

+ Un prompt es de un modelo concreto —Suno v4 no escribe como v3.5—, así que lo primero es + dar de alta el primero. Ahí se declara + también qué campos tiene un prompt suyo, y este formulario sale de esa declaración. +

+ {:else} +

+ Varios por combinación de modelo y ánimo, con uno marcado como el que funcionó: esto se itera, + no se acierta. +

+ + {#each data.prompts as p (p.id)} + {@const suyos = data.modelos.find((m) => m.id === p.modeloId)?.campos ?? []} +
+
+

+ {p.animoNombre ?? 'Sin ánimo'} + · {p.modeloNombre} +

+ +
+ {#if p.preferido}Preferido{/if} + {#if p.valoracion}{p.valoracion}/5{/if} + {VISIBILIDAD[p.visibilidad] ?? p.visibilidad} + {#if p.estado !== 'publicado'}{ESTADO[p.estado]}{/if} +
+
+ +
{comoTexto(suyos, p.contenido)}
+ + {#if p.notas}

{p.notas}

{/if} + +
+ + +
+ + {#if abierto === p.id} +
+ + {@render camposDelPrompt(p.id, { + modeloIaId: p.modeloId, + animoId: p.animoId ?? '', + idioma: p.idioma, + contenido: p.contenido, + notas: p.notas ?? '', + valoracion: p.valoracion, + preferido: p.preferido, + visibilidad: p.visibilidad, + estado: p.estado + })} + +
+ + +
+
+ {/if} +
+ {/each} + +
+

Escribir uno

+ +
+ {@render camposDelPrompt('nuevo', { + modeloIaId: modeloNuevo, + animoId: '', + idioma: 'en', + contenido: {}, + notas: '', + valoracion: null, + preferido: false, + visibilidad: 'privado', + estado: 'borrador' + })} + +
+ +
+
+
+ {/if} +
+ +{#snippet camposDelPrompt( + id: string, + v: { + modeloIaId: string; + animoId: string; + idioma: string; + contenido: Record; + notas: string; + valoracion: number | null; + preferido: boolean; + visibilidad: string; + estado: string; + } +)} + {@const elegido = id === 'nuevo' ? modeloNuevo : v.modeloIaId} + {@const suyos = data.modelos.find((m) => m.id === elegido)?.campos ?? []} + {@const e = id === form?.promptDonde ? (form?.promptErrores ?? {}) : {}} + + {#if e.general}

{e.general}

{/if} + +
+

+ + +

+ +

+ + +

+ +

+ + +

+
+ + {#if suyos.length === 0} +

+ + + Este modelo no declara qué campos tiene un prompt suyo, así que va en un bloque. Declararlo + en su esquema parte el formulario en los campos que de verdad usa. + + + {#if e.campos?.texto}{e.campos.texto}{/if} +

+ {:else} + {#each suyos as campo (campo.nombre)} + {@const valor = v.contenido[campo.nombre]} +

+ + {#if campo.ayuda}{campo.ayuda}{/if} + + {#if campo.tipo === 'boolean'} + + {:else if campo.opciones} + + {:else if campo.largo} + + {:else} + + {/if} + + {#if e.campos?.[campo.nombre]}{e.campos[campo.nombre]}{/if} +

+ {/each} + {/if} + +

+ + + Qué salió y qué hubo que cambiar. Es lo que sirve dentro de un mes. + + +

+ +
+

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + + Solo uno por modelo y ánimo: marcarlo se lo quita a los demás. + +

+
+{/snippet} + diff --git a/src/panel/rutas/(dentro)/prompts/modelos/+page.server.ts b/src/panel/rutas/(dentro)/prompts/modelos/+page.server.ts new file mode 100644 index 0000000..a3b5ef2 --- /dev/null +++ b/src/panel/rutas/(dentro)/prompts/modelos/+page.server.ts @@ -0,0 +1,79 @@ +import { fail } from '@sveltejs/kit'; +import { + crearModelo, + guardarModelo, + listaDeModelos, + revisarModelo, + type CambiosDelModelo, + type ErroresDelModelo +} from '$lib/server/panel-prompts'; + +/** + * Todas las ramas de fallo devuelven lo mismo. + * + * Si cada una tuviera sus claves, la página tendría que comprobar cuál le ha + * llegado antes de poder leer un error. + */ +const repetido: ErroresDelModelo = { + version: 'Ya existe un modelo con esa familia y esa versión.' +}; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + return { modelos: await listaDeModelos() }; +}; + +function leer(datos: FormData): CambiosDelModelo { + return { + familia: String(datos.get('familia') ?? ''), + version: String(datos.get('version') ?? ''), + nombre: String(datos.get('nombre') ?? ''), + notaSintaxis: String(datos.get('notaSintaxis') ?? ''), + esquema: String(datos.get('esquema') ?? ''), + vigente: datos.get('vigente') !== null + }; +} + +export const actions: Actions = { + crear: async ({ request }) => { + const datos = await request.formData(); + const cambios = leer(datos); + + const errores = revisarModelo(cambios); + if (Object.keys(errores).length > 0) return fail(400, { errores, cambios, donde: 'nuevo' }); + + try { + await crearModelo(cambios); + } catch { + /* + * El código sale de familia y versión y es único: dar de alta «Suno v4» + * dos veces choca en la base. Se cuenta como lo que es, en vez de + * enseñar el error de Postgres. + */ + return fail(409, { errores: repetido, cambios, donde: 'nuevo' }); + } + + return { guardado: true }; + }, + + guardar: async ({ request }) => { + const datos = await request.formData(); + const id = String(datos.get('id') ?? ''); + const cambios = leer(datos); + + const errores = revisarModelo(cambios); + if (Object.keys(errores).length > 0) return fail(400, { errores, cambios, donde: id }); + + try { + const guardado = await guardarModelo(id, cambios); + if (!guardado) { + const perdido: ErroresDelModelo = { nombre: 'Ese modelo ya no existe.' }; + return fail(404, { errores: perdido, cambios, donde: id }); + } + } catch { + return fail(409, { errores: repetido, cambios, donde: id }); + } + + return { guardado: true }; + } +}; diff --git a/src/panel/rutas/(dentro)/prompts/modelos/+page.svelte b/src/panel/rutas/(dentro)/prompts/modelos/+page.svelte new file mode 100644 index 0000000..335aeb4 --- /dev/null +++ b/src/panel/rutas/(dentro)/prompts/modelos/+page.svelte @@ -0,0 +1,424 @@ + + + + Modelos de IA · Panel + + + +

+ ← Géneros +

+ +
+

Modelos de IA

+

+ Una fila por versión, no por marca: Suno v4 y Suno v3.5 son dos modelos. Retirar uno no borra + sus prompts, solo lo saca de los que se ofrecen al escribir uno nuevo. +

+
+ +{#if data.modelos.length > 0} +
    + {#each data.modelos as m (m.id)} +
  • +
    +

    + {m.nombre} + {m.codigo} +

    + +

    + {m.prompts} + {m.prompts === 1 ? 'prompt' : 'prompts'} + {#if !m.vigente}· retirado{/if} +

    +
    + + {#if m.notaSintaxis}

    {m.notaSintaxis}

    {/if} + + + + {#if abierto === m.id} +
    + + {@render campos(m.id, { + familia: m.familia, + version: m.version, + nombre: m.nombre, + notaSintaxis: m.notaSintaxis ?? '', + esquema: comoTexto(m.esquema), + vigente: m.vigente + })} + +
    + {/if} +
  • + {/each} +
+{/if} + +
+

{data.modelos.length === 0 ? 'El primero' : 'Añadir otro'}

+ +
+ {@render campos('nuevo', { + familia: nuevo?.familia ?? '', + version: nuevo?.version ?? '', + nombre: nuevo?.nombre ?? '', + notaSintaxis: nuevo?.notaSintaxis ?? '', + esquema: nuevo?.esquema ?? '', + vigente: nuevo?.vigente ?? true + })} + +
+
+ +{#snippet campos( + id: string, + v: { + familia: string; + version: string; + nombre: string; + notaSintaxis: string; + esquema: string; + vigente: boolean; + } +)} + {@const e = erroresDe(id)} + +
+

+ + + {#if e.familia}{e.familia}{/if} +

+ +

+ + + {#if e.version}{e.version}{/if} +

+ +

+ + Con el que lo reconoces en un desplegable. + + {#if e.nombre}{e.nombre}{/if} +

+
+ +

+ + + Lo que hay que recordar de su sintaxis: qué etiquetas entiende, qué ignora, cuánto texto + admite. Es lo que se olvida entre una sesión y la siguiente. + + +

+ +

+ + + Un JSON Schema. De aquí sale el formulario del prompt —un campo por propiedad— y con esto se + comprueba lo que se guarda, que es lo que evita una pantalla por cada IA. En blanco, sus + prompts se escriben en un bloque de texto. + + + Se entiende type: "object" con properties y + required; por campo, type string, number, integer o boolean, más + enum, maxLength, minimum, maximum, + title como etiqueta y description como ayuda. Lo demás se ignora. + + + Y x-order, la lista de campos en el orden en que se quieren ver. Hace falta + decirlo: la base guarda el esquema como jsonb y ahí las claves se reordenan solas, + así que sin esto el campo obligatorio puede acabar el último del formulario. + + + {#if e.esquema}{e.esquema}{/if} +

+ +

+ + + Al desmarcarlo deja de ofrecerse para prompts nuevos, y los que ya tiene se quedan. + +

+{/snippet} + +