Los prompts, dentro de la ficha del género y con la forma que dice su modelo

Estaban modelados desde la fase 1 y no existían en el panel: `modelo_ia`,
`animo` y `prompt`, cero filas las tres y ni siquiera una línea en la portada.

Y la primera versión no respetaba el modelo. `modelo_ia.esquema` dice esto:

> JSON Schema con la forma que toman los prompts de este modelo. Se validan
> contra él al guardar, y el panel puede pintar el formulario a partir de él sin
> una pantalla por cada IA.

Había puesto un `textarea` único guardando `{ texto }` y forzado
`estado: 'publicado'` al crear, cuando el modelo dice borrador por defecto. Las
dos cosas van ahora como estaban escritas: el formulario del prompt sale del
esquema de su modelo —un campo por propiedad, con su etiqueta, su ayuda, su
lista cerrada y su rango— y lo que se guarda se comprueba contra él.

`$lib/esquema-prompt` es el lector, y dice en su cabecera qué subconjunto
entiende: objeto en la raíz con `properties` y `required`; por campo string,
number, integer o boolean, más `enum`, `maxLength`, `minimum`, `maximum`,
`title` y `description`. Lo que no esté se ignora; un esquema roto se rechaza al
guardarlo, no al usarlo. Un modelo sin esquema sigue funcionando con un bloque
de texto.

Y `x-order`, que no estaba previsto y hace falta: la base guarda el esquema como
`jsonb` y ahí Postgres reordena las claves por longitud, así que el campo
obligatorio salía el último del formulario. Se vio en la captura.

Los prompts van **dentro de la ficha del género**, no en una pestaña aparte: un
prompt es de ese género y escribir la ficha y sacar la música son la misma
sesión. Fuera de su formulario, porque cada prompt se guarda por su cuenta.

Los modelos se dan de alta a mano, con su versión: Suno v4 y v3.5 son dos
modelos, y una lista sembrada en un script envejecería en un mes. Los nueve
ánimos de la GEMS sí se siembran, con `npm run db:animos`.

Comprobado contra el panel de verdad: esquema aceptado y esquema roto
rechazado, formulario pintado desde el esquema —lista cerrada, número con
rango, letra en bloque—, campo obligatorio reclamado, un solo preferido por
combinación y el preferido el primero de la lista.

Falta `prompt.ejemplo_id`, el audio del resultado: pide los medios, que todavía
no se editan aquí.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 3c09592e11
commit 1d287e3788

@ -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

@ -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

@ -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"
},

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

@ -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');
});
});

@ -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<string, unknown> =>
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<string, string | number | boolean>;
/**
* 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<string, string> {
const errores: Record<string, string> = {};
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');
}

@ -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<ModeloEnLista[]> {
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<number>`(
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<string> {
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<boolean> {
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<string, unknown>;
}
/** Los de un género, ordenados por lo que funcionó. */
export async function promptsDelGenero(generoId: string): Promise<PromptEnLista[]> {
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<string, unknown>
}));
}
/** 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<string, string>;
}
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<Parameters<typeof db.transaction>[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<string> {
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<boolean> {
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<boolean> {
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<string | null> {
const [fila] = await db
.select({ id: genero.id })
.from(genero)
.where(eq(genero.slug, slug))
.limit(1);
return fila?.id ?? null;
}

@ -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<GrupoDelPanel[]> {
(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<GrupoDelPanel[]> {
}
]
},
{
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.',

@ -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;
</script>

@ -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<typeof camposDe>): 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<typeof camposDe>, 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 };
}
};

@ -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<string, string> = {
privado: 'Privado',
taller: 'Solo en el taller',
publico: 'Público'
};
const ESTADO: Record<string, string> = {
borrador: 'Borrador',
publicado: 'Publicado',
retirado: 'Retirado'
};
const copia = () => ({
nombre: data.genero.nombre,
resumen: data.genero.resumen,
@ -74,6 +115,7 @@
<form
method="POST"
action="?/guardar"
bind:this={formulario}
use:enhance={() => {
guardando = true;
@ -328,6 +370,301 @@
{/if}
</form>
<!--
Los prompts, en la misma pantalla que la ficha y fuera de su formulario.
En la misma pantalla porque un prompt es de este género: escribir la ficha y
sacar la música son la misma sesión de trabajo. Y fuera del formulario porque
cada prompt se guarda por su cuenta —un formulario no puede llevar otro
dentro—, y guardar un prompt no debe arrastrar cambios de la ficha a medias.
-->
<section class="prompts">
<div class="titular">
<h2>Prompts</h2>
<a href={resolve('/(dentro)/prompts/modelos')}>Modelos de IA ({data.modelos.length})</a>
</div>
{#if data.modelos.length === 0}
<p class="sin-modelos">
Un prompt es de un modelo concreto —Suno v4 no escribe como v3.5—, así que lo primero es
<a href={resolve('/(dentro)/prompts/modelos')}>dar de alta el primero</a>. Ahí se declara
también qué campos tiene un prompt suyo, y este formulario sale de esa declaración.
</p>
{:else}
<p class="ayuda muted">
Varios por combinación de modelo y ánimo, con uno marcado como el que funcionó: esto se itera,
no se acierta.
</p>
{#each data.prompts as p (p.id)}
{@const suyos = data.modelos.find((m) => m.id === p.modeloId)?.campos ?? []}
<article class="prompt" class:destacado={p.preferido}>
<div class="cabecera">
<p class="quees">
<strong>{p.animoNombre ?? 'Sin ánimo'}</strong>
<span class="modelo muted">· {p.modeloNombre}</span>
</p>
<div class="marcas">
{#if p.preferido}<span class="pastilla" data-tipo="preferido">Preferido</span>{/if}
{#if p.valoracion}<span class="pastilla">{p.valoracion}/5</span>{/if}
<span class="pastilla">{VISIBILIDAD[p.visibilidad] ?? p.visibilidad}</span>
{#if p.estado !== 'publicado'}<span class="pastilla">{ESTADO[p.estado]}</span>{/if}
</div>
</div>
<pre class="texto">{comoTexto(suyos, p.contenido)}</pre>
{#if p.notas}<p class="notas muted">{p.notas}</p>{/if}
<div class="botones">
<button
type="button"
class="enlace"
onclick={() => copiar(p.id, comoTexto(suyos, p.contenido))}
>
{copiado === p.id ? 'Copiado' : 'Copiar'}
</button>
<button
type="button"
class="enlace"
onclick={() => (abierto = abierto === p.id ? '' : p.id)}
>
{abierto === p.id ? 'Cerrar' : 'Editar'}
</button>
</div>
{#if abierto === p.id}
<form method="POST" action="?/guardarPrompt" class="editor" use:enhance>
<input type="hidden" name="id" value={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
})}
<div class="acciones">
<button type="submit" class="btn btn--primary">Guardar el prompt</button>
<button
type="submit"
formaction="?/borrarPrompt"
class="enlace peligro"
onclick={(ev) => {
if (!confirm('¿Borrar este prompt?')) ev.preventDefault();
}}
>
Borrar
</button>
</div>
</form>
{/if}
</article>
{/each}
<article class="prompt nuevo">
<h3>Escribir uno</h3>
<form method="POST" action="?/crearPrompt" use:enhance>
{@render camposDelPrompt('nuevo', {
modeloIaId: modeloNuevo,
animoId: '',
idioma: 'en',
contenido: {},
notas: '',
valoracion: null,
preferido: false,
visibilidad: 'privado',
estado: 'borrador'
})}
<div class="acciones">
<button type="submit" class="btn btn--primary">Guardar el prompt</button>
</div>
</form>
</article>
{/if}
</section>
{#snippet camposDelPrompt(
id: string,
v: {
modeloIaId: string;
animoId: string;
idioma: string;
contenido: Record<string, unknown>;
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}<p class="fallo">{e.general}</p>{/if}
<div class="rejilla">
<p class="campo">
<label for="modelo-{id}">Modelo</label>
<select
id="modelo-{id}"
name="modeloIaId"
value={elegido}
onchange={(ev) => {
// Cambiar de modelo cambia los campos: los declara su esquema.
if (id === 'nuevo') modeloNuevo = ev.currentTarget.value;
}}
>
{#each data.modelos as m (m.id)}
<option value={m.id}>{m.nombre}{m.vigente ? '' : ' (retirado)'}</option>
{/each}
</select>
</p>
<p class="campo">
<label for="animo-{id}">Ánimo</label>
<select id="animo-{id}" name="animoId">
<option value="" selected={v.animoId === ''}>Ninguno</option>
{#each data.animos as a (a.id)}
<option value={a.id} selected={a.id === v.animoId} title={a.descripcion ?? ''}>
{a.nombre}
</option>
{/each}
</select>
</p>
<p class="campo">
<label for="idioma-{id}">Idioma</label>
<select id="idioma-{id}" name="idioma">
<!-- En inglés suelen funcionar mejor, incluso para música en español. -->
<option value="en" selected={v.idioma === 'en'}>Inglés</option>
<option value="es" selected={v.idioma === 'es'}>Español</option>
</select>
</p>
</div>
{#if suyos.length === 0}
<p class="campo">
<label for="campo-texto-{id}">El prompt</label>
<span class="ayuda muted">
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.
</span>
<textarea id="campo-texto-{id}" name="campo:texto" rows="5" aria-invalid={!!e.campos?.texto}
>{String(v.contenido.texto ?? '')}</textarea
>
{#if e.campos?.texto}<span class="fallo">{e.campos.texto}</span>{/if}
</p>
{:else}
{#each suyos as campo (campo.nombre)}
{@const valor = v.contenido[campo.nombre]}
<p class="campo">
<label for="campo-{campo.nombre}-{id}">
{campo.etiqueta}{#if campo.requerido}<span class="pide" title="Hace falta">*</span>{/if}
</label>
{#if campo.ayuda}<span class="ayuda muted">{campo.ayuda}</span>{/if}
{#if campo.tipo === 'boolean'}
<label class="marcar">
<input
type="checkbox"
id="campo-{campo.nombre}-{id}"
name="campo:{campo.nombre}"
checked={valor === true}
/>
Sí
</label>
{:else if campo.opciones}
<select id="campo-{campo.nombre}-{id}" name="campo:{campo.nombre}">
<option value="">Sin elegir</option>
{#each campo.opciones as opcion (opcion)}
<option value={opcion} selected={valor === opcion}>{opcion}</option>
{/each}
</select>
{:else if campo.largo}
<textarea
id="campo-{campo.nombre}-{id}"
name="campo:{campo.nombre}"
rows="4"
maxlength={campo.maxLength ?? undefined}
aria-invalid={!!e.campos?.[campo.nombre]}>{String(valor ?? '')}</textarea
>
{:else}
<input
id="campo-{campo.nombre}-{id}"
name="campo:{campo.nombre}"
type={campo.tipo === 'string' ? 'text' : 'number'}
value={valor ?? ''}
maxlength={campo.tipo === 'string' ? (campo.maxLength ?? undefined) : undefined}
min={campo.minimo ?? undefined}
max={campo.maximo ?? undefined}
step={campo.tipo === 'integer' ? 1 : 'any'}
aria-invalid={!!e.campos?.[campo.nombre]}
/>
{/if}
{#if e.campos?.[campo.nombre]}<span class="fallo">{e.campos[campo.nombre]}</span>{/if}
</p>
{/each}
{/if}
<p class="campo">
<label for="notas-{id}">Notas</label>
<span class="ayuda muted">
Qué salió y qué hubo que cambiar. Es lo que sirve dentro de un mes.
</span>
<textarea id="notas-{id}" name="notas" rows="2">{v.notas}</textarea>
</p>
<div class="rejilla">
<p class="campo">
<label for="valoracion-{id}">Valoración</label>
<select id="valoracion-{id}" name="valoracion">
<option value="" selected={v.valoracion === null}>Sin probar</option>
{#each [1, 2, 3, 4, 5] as n (n)}
<option value={n} selected={v.valoracion === n}>{n} de 5</option>
{/each}
</select>
</p>
<p class="campo">
<label for="visibilidad-{id}">Visibilidad</label>
<select id="visibilidad-{id}" name="visibilidad">
<option value="privado" selected={v.visibilidad === 'privado'}>Privado</option>
<option value="taller" selected={v.visibilidad === 'taller'}>Solo en el taller</option>
<option value="publico" selected={v.visibilidad === 'publico'}>Público</option>
</select>
</p>
<p class="campo">
<label for="estado-prompt-{id}">Estado</label>
<select id="estado-prompt-{id}" name="estado">
<option value="borrador" selected={v.estado === 'borrador'}>Borrador</option>
<option value="publicado" selected={v.estado === 'publicado'}>Publicado</option>
<option value="retirado" selected={v.estado === 'retirado'}>Retirado</option>
</select>
</p>
<p class="campo">
<label class="marcar">
<input type="checkbox" name="preferido" checked={v.preferido} />
El que funcionó
</label>
<span class="ayuda muted">
Solo uno por modelo y ánimo: marcarlo se lo quita a los demás.
</span>
</p>
</div>
{/snippet}
<style>
.volver {
margin: 0 0 var(--sp-4);
@ -618,4 +955,183 @@
color: var(--c-danger);
font-size: var(--fs-sm);
}
/* --- Los prompts ----------------------------------------------------- */
.prompts {
display: grid;
gap: var(--sp-4);
padding-top: var(--sp-5);
margin-top: var(--sp-6);
border-top: 1px solid var(--c-border);
}
.titular {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
flex-wrap: wrap;
}
.titular h2 {
margin: 0;
font-size: var(--fs-xl);
}
.titular a {
font-size: var(--fs-sm);
}
.sin-modelos {
margin: 0;
padding: var(--sp-4);
border: 1px dashed var(--c-border);
border-radius: var(--radius-tarjeta);
max-width: 70ch;
line-height: 1.55;
}
.prompt {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
/* El que funcionó, marcado en el borde: es lo que se busca al entrar. */
.prompt.destacado {
border-color: color-mix(in oklab, var(--c-accent) 45%, transparent);
}
.prompt.nuevo {
border-style: dashed;
background: none;
}
.prompt h3 {
margin: 0;
font-size: 1rem;
}
.cabecera {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
flex-wrap: wrap;
}
.quees {
margin: 0;
}
.modelo {
font-size: var(--fs-sm);
}
.marcas {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.pastilla {
display: inline-block;
padding: 0.15rem 0.55rem;
border: 1px solid var(--c-border);
border-radius: 999px;
font-size: var(--fs-xs);
white-space: nowrap;
color: var(--c-text-muted);
}
.pastilla[data-tipo='preferido'] {
border-color: color-mix(in oklab, var(--c-accent) 55%, transparent);
color: var(--c-accent);
}
/*
* El prompt entero a la vista y sin recortar. Es lo que se viene a leer, y un
* «ver más» obliga a un clic para lo único que interesa de la tarjeta.
*/
.texto {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
font-family: var(--font-mono);
font-size: var(--fs-sm);
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notas {
margin: 0;
font-size: var(--fs-sm);
line-height: 1.5;
}
.botones {
display: flex;
gap: var(--sp-4);
}
.enlace {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-sm);
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.enlace:hover {
color: var(--c-accent);
}
.peligro:hover {
color: var(--c-danger);
}
.editor,
.prompt.nuevo form {
display: grid;
gap: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
/*
* La casilla, pegada a la izquierda. Dentro de una rejilla de una columna se
* estiraba a todo lo ancho y el «Sí» acababa al otro extremo de la pantalla,
* a un palmo de su casilla.
*/
.marcar {
display: inline-flex;
justify-self: start;
align-items: center;
gap: 0.5rem;
color: var(--c-text);
cursor: pointer;
}
/* El asterisco de «hace falta», en el color de lo que reclama atención. */
.pide {
margin-left: 0.2rem;
color: var(--c-dato);
}
.acciones {
display: flex;
align-items: center;
gap: var(--sp-4);
}
</style>

@ -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 };
}
};

@ -0,0 +1,424 @@
<script lang="ts">
/**
* Los modelos de IA.
*
* **Un modelo es una versión concreta, no una marca.** Un prompt de Suno v4
* no es uno de v3.5: cambian la sintaxis, las etiquetas y lo que el modelo
* ignora. Si esto apuntara a «Suno» a secas, el día que salga la versión
* siguiente quedaría una biblioteca de prompts sin saber para qué servía cada
* uno.
*
* Por eso no vienen sembrados: los nombres y las versiones los pone quien los
* usa, y una lista escrita aquí envejecería en un mes.
*/
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
let { data, form } = $props();
let abierto = $state('');
const nuevo = $derived(form?.donde === 'nuevo' ? form.cambios : null);
const erroresDe = (id: string) => (form?.donde === id ? (form?.errores ?? {}) : {});
/** El esquema guardado, otra vez como texto para poder editarlo. */
const comoTexto = (esquema: unknown) => (esquema ? JSON.stringify(esquema, null, 2) : '');
/**
* Un ejemplo en el marcador de posición.
*
* No se rellena con él: sería inventarle a alguien la forma de sus prompts, y
* la sintaxis de cada modelo la sabe quien lo usa. Puesto de fondo enseña la
* forma sin decidir por nadie.
*/
const EJEMPLO = `{
"type": "object",
"required": ["estilo"],
"x-order": ["estilo", "letra", "excluir", "instrumental"],
"properties": {
"estilo": {
"type": "string",
"title": "Estilo",
"maxLength": 200,
"description": "Los descriptores separados por comas."
},
"letra": { "type": "string", "title": "Letra" },
"excluir": { "type": "string", "title": "Qué evitar", "maxLength": 200 },
"instrumental": { "type": "boolean", "title": "Sin voz" }
}
}`;
</script>
<svelte:head>
<title>Modelos de IA · Panel</title>
</svelte:head>
<!-- Se vuelve a los géneros: los prompts se escriben dentro de su ficha. -->
<p class="volver">
<a href={resolve('/(dentro)/generos')}>← Géneros</a>
</p>
<header>
<h1>Modelos de IA</h1>
<p class="entradilla">
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.
</p>
</header>
{#if data.modelos.length > 0}
<ul class="modelos">
{#each data.modelos as m (m.id)}
<li class="modelo" class:retirado={!m.vigente}>
<div class="cabecera">
<p class="quees">
<strong>{m.nombre}</strong>
<span class="codigo muted">{m.codigo}</span>
</p>
<p class="cuantos muted">
{m.prompts}
{m.prompts === 1 ? 'prompt' : 'prompts'}
{#if !m.vigente}· retirado{/if}
</p>
</div>
{#if m.notaSintaxis}<p class="nota muted">{m.notaSintaxis}</p>{/if}
<button
type="button"
class="enlace"
onclick={() => (abierto = abierto === m.id ? '' : m.id)}
>
{abierto === m.id ? 'Cerrar' : 'Editar'}
</button>
{#if abierto === m.id}
<form method="POST" action="?/guardar" class="editor" use:enhance>
<input type="hidden" name="id" value={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
})}
<button type="submit" class="btn btn--primary">Guardar</button>
</form>
{/if}
</li>
{/each}
</ul>
{/if}
<section class="alta">
<h2>{data.modelos.length === 0 ? 'El primero' : 'Añadir otro'}</h2>
<form method="POST" action="?/crear" use:enhance>
{@render campos('nuevo', {
familia: nuevo?.familia ?? '',
version: nuevo?.version ?? '',
nombre: nuevo?.nombre ?? '',
notaSintaxis: nuevo?.notaSintaxis ?? '',
esquema: nuevo?.esquema ?? '',
vigente: nuevo?.vigente ?? true
})}
<button type="submit" class="btn btn--primary">Dar de alta</button>
</form>
</section>
{#snippet campos(
id: string,
v: {
familia: string;
version: string;
nombre: string;
notaSintaxis: string;
esquema: string;
vigente: boolean;
}
)}
{@const e = erroresDe(id)}
<div class="rejilla">
<p class="campo">
<label for="familia-{id}">Familia</label>
<input
id="familia-{id}"
name="familia"
value={v.familia}
placeholder="Suno"
aria-invalid={!!e.familia}
/>
{#if e.familia}<span class="fallo">{e.familia}</span>{/if}
</p>
<p class="campo">
<label for="version-{id}">Versión</label>
<input
id="version-{id}"
name="version"
value={v.version}
placeholder="v4"
aria-invalid={!!e.version}
/>
{#if e.version}<span class="fallo">{e.version}</span>{/if}
</p>
<p class="campo">
<label for="nombre-{id}">Nombre</label>
<span class="ayuda muted">Con el que lo reconoces en un desplegable.</span>
<input
id="nombre-{id}"
name="nombre"
value={v.nombre}
placeholder="Suno v4"
aria-invalid={!!e.nombre}
/>
{#if e.nombre}<span class="fallo">{e.nombre}</span>{/if}
</p>
</div>
<p class="campo">
<label for="nota-{id}">Cómo escribe este modelo</label>
<span class="ayuda muted">
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.
</span>
<textarea id="nota-{id}" name="notaSintaxis" rows="3">{v.notaSintaxis}</textarea>
</p>
<p class="campo">
<label for="esquema-{id}">Qué campos tiene un prompt suyo</label>
<span class="ayuda muted">
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.
</span>
<span class="ayuda muted">
Se entiende <code>type: "object"</code> con <code>properties</code> y
<code>required</code>; por campo, <code>type</code> string, number, integer o boolean, más
<code>enum</code>, <code>maxLength</code>, <code>minimum</code>, <code>maximum</code>,
<code>title</code> como etiqueta y <code>description</code> como ayuda. Lo demás se ignora.
</span>
<span class="ayuda muted">
Y <code>x-order</code>, la lista de campos en el orden en que se quieren ver. Hace falta
decirlo: la base guarda el esquema como <code>jsonb</code> y ahí las claves se reordenan solas,
así que sin esto el campo obligatorio puede acabar el último del formulario.
</span>
<textarea
id="esquema-{id}"
name="esquema"
rows="10"
spellcheck="false"
class="codigo"
aria-invalid={!!e.esquema}
placeholder={EJEMPLO}>{v.esquema}</textarea
>
{#if e.esquema}<span class="fallo">{e.esquema}</span>{/if}
</p>
<p class="campo marcar">
<label>
<input type="checkbox" name="vigente" checked={v.vigente} />
En uso
</label>
<span class="ayuda muted">
Al desmarcarlo deja de ofrecerse para prompts nuevos, y los que ya tiene se quedan.
</span>
</p>
{/snippet}
<style>
.volver {
margin: 0 0 var(--sp-4);
font-size: var(--fs-sm);
}
header {
max-width: 62ch;
padding-bottom: var(--sp-5);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-2xl);
}
.entradilla {
margin: 0;
color: var(--c-text-muted);
line-height: 1.55;
}
.modelos {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-4);
}
.modelo {
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
}
/* Retirado: sigue estando, pero no se ofrece. Se ve sin gritar. */
.modelo.retirado {
opacity: 0.6;
}
.cabecera {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-4);
flex-wrap: wrap;
}
.quees,
.cuantos,
.nota {
margin: 0;
}
.codigo {
margin-left: var(--sp-3);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
.cuantos {
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.nota {
font-size: var(--fs-sm);
line-height: 1.55;
max-width: 70ch;
}
.enlace {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--c-text-muted);
font: inherit;
font-size: var(--fs-sm);
text-decoration: underline;
text-underline-offset: 0.2em;
cursor: pointer;
}
.enlace:hover {
color: var(--c-accent);
}
.editor,
.alta form {
display: grid;
gap: var(--sp-4);
justify-items: start;
padding-top: var(--sp-4);
border-top: 1px solid var(--c-border);
}
.alta {
padding-top: var(--sp-7);
}
.alta h2 {
margin: 0;
font-size: var(--fs-xl);
}
.rejilla {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--sp-4);
width: 100%;
}
.campo {
display: grid;
gap: 0.25rem;
margin: 0;
min-width: 0;
width: 100%;
}
.marcar label {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--c-text);
cursor: pointer;
}
label {
font-size: var(--fs-sm);
color: var(--c-text-muted);
}
.ayuda {
font-size: var(--fs-xs);
line-height: 1.45;
max-width: 62ch;
}
input,
textarea {
width: 100%;
padding: var(--boton-relleno);
border: 1px solid var(--c-border);
border-radius: var(--radius-boton);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
font-size: var(--boton-texto);
}
input[type='checkbox'] {
width: auto;
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 1px;
}
[aria-invalid='true'] {
border-color: var(--c-danger);
}
textarea {
resize: vertical;
line-height: 1.55;
}
/* El esquema es JSON: se escribe y se lee con letra de ancho fijo. */
.codigo {
font-family: var(--font-mono);
font-size: var(--fs-sm);
line-height: 1.6;
tab-size: 2;
}
.ayuda code {
font-size: 0.95em;
}
.fallo {
color: var(--c-danger);
font-size: var(--fs-sm);
}
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.