This commit is contained in:
2026-08-23 20:49:40 +02:00
parent 3b678ca857
commit f3d4fb56ed
31 changed files with 3608 additions and 111 deletions
+17
View File
@@ -113,3 +113,20 @@ Seed (from apps/api, DATABASE_URL set):
go run ./cmd/seed-support-kb
go run ./cmd/seed-support-kb -file ../../scripts/seed/support-kb-articles-tech.json
Idempotent ON CONFLICT (slug). Do not reuse product slugs for tech articles.
Platform-default category formulas (English)
--------------------------------------------
a1-category-prompts.json stays the A1 tenant's own Slovenian formula set. Every
other tenant uses the English translation of the same formulas:
apps/api/internal/aiprompts/defaults/default-category-prompts.json (embedded)
Generated from this seed by scripts/seed/build-default-category-prompts.mjs:
make seed-defaults # regenerate after editing the A1 seed or the phrase table
make check-defaults # fail when the embedded copy is stale
The generator translates a closed vocabulary (title slots, body blocks, meta rule)
and refuses to emit an entry with any untranslated Slovenian left in it. New
categories get these formulas at create time; categories with none fall back to
them at enhance time. See docs/category-formulas.md.
@@ -0,0 +1,372 @@
// Builds scripts/seed/default-category-prompts.json — the English platform-default
// category formulas — from the Slovenian A1 legacy seed (a1-category-prompts.json).
//
// The A1 tenant keeps its own Slovenian prompts (that seed is untouched). Every
// other tenant gets the same formula STYLE in English: a <name> naming formula, a
// <metaDescription> rule, and an HTML body of H2/paragraph pairs plus a technical
// specification list.
//
// Translation is phrase-level rather than per-entry: the 116 legacy prompts are
// built from a small closed vocabulary (55 title slots, 39 body blocks, 1 meta
// rule). Every phrase must be covered — the script fails if any Slovenian residue
// survives, so a new legacy phrase can never be silently emitted untranslated.
//
// node scripts/seed/build-default-category-prompts.mjs [--check]
//
// --check verifies the committed JSON matches what this script produces (CI guard).
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const SOURCE = join(here, "a1-category-prompts.json");
// Written into the Go module so aiprompts can go:embed it — the platform default
// must ship with the binary, unlike the A1 seed which is read from disk at seed time.
const TARGET = join(here, "..", "..", "apps", "api", "internal", "aiprompts", "defaults", "default-category-prompts.json");
// Category display names. Keys stay the legacy Slovenian names so the generated
// entry can carry both as match aliases (a tenant may use either spelling).
const CATEGORY_NAMES = {
"Agregati": "Generators",
"Avto oprema": "Car accessories",
"Avto polnilniki": "Car chargers",
"Avtosedeži in lupinice": "Car seats and infant carriers",
"Bazeni": "Pools",
"Bluetooth zvočniki": "Bluetooth speakers",
"Brezžični polnilniki": "Wireless chargers",
"Brivniki": "Shavers",
"Cvrtniki": "Air fryers",
"Depilatorji": "Epilators",
"Digitalna peresa": "Digital pens",
"Dodatki za dom": "Home accessories",
"Dodatki za televizorje": "TV accessories",
"Dodatki za ure": "Watch accessories",
"Dodatna oprema": "Additional equipment",
"Dodatna oprema za računalništvo": "Computer accessories",
"Droni": "Drones",
"Družabne igre": "Board games",
"Električna kolesa": "Electric bikes",
"Električne zobne ščetke": "Electric toothbrushes",
"Električni skiroji": "Electric scooters",
"Fitnes oprema": "Fitness equipment",
"Gaming monitorji": "Gaming monitors",
"Gaming pohištvo": "Gaming furniture",
"Gaming prenosniki računalniki": "Gaming laptops",
"Gaming stacionarni računalniki": "Gaming desktops",
"Giljotine in rezalniki": "Guillotines and trimmers",
"Grelniki vode": "Water heaters",
"Hišni polnilniki": "Wall chargers",
"Hladilniki": "Refrigerators",
"Igralne konzole in igre": "Game consoles and games",
"Igrače na daljinsko vodenje": "Remote-controlled toys",
"Igrače za najmlajše": "Toys for infants",
"Kabli": "Cables",
"Kamere in fotoaparati": "Cameras",
"Kamp oprema in aktivnosti na prostem": "Camping and outdoor equipment",
"Kavni aparati": "Coffee machines",
"Klasična kolesa": "Classic bikes",
"Klimatske naprave": "Air conditioners",
"Kuhalne plošče": "Hobs",
"Kuhinjske nape": "Cooker hoods",
"Kuhinjski roboti": "Food processors",
"Likalniki": "Irons",
"Masažni bazeni": "Hot tubs",
"Mešalniki in blenderji": "Mixers and blenders",
"Mikrovalovne pečice": "Microwave ovens",
"Miške": "Mice",
"Mobilne naprave": "Mobile devices",
"Monitorji": "Monitors",
"Mrežna oprema": "Networking equipment",
"Nadzorne kamere": "Security cameras",
"Namizni računalniki": "Desktop computers",
"Namizni žari in opekači": "Tabletop grills and toasters",
"Naprave za ogrevanje": "Heaters",
"Nosilci za TV": "TV mounts",
"Oblikovalci in sušilniki za lase": "Hair stylers and dryers",
"Obnovljeni prenosniki": "Refurbished laptops",
"Obnovljeni telefoni": "Refurbished phones",
"Orodje za mojstre": "Power tools",
"Osebna nega": "Personal care",
"Ostali aparati": "Other appliances",
"Ostali dodatki": "Other accessories",
"Ostali gaming dodatki": "Other gaming accessories",
"Otroška vozila": "Ride-on toys",
"Otroške pametne ure": "Kids smartwatches",
"Ovitki in mape": "Cases and folios",
"Ovitki in torbe za prenosnike": "Laptop cases and bags",
"Pametne ure": "Smartwatches",
"Pametne čelade": "Smart helmets",
"Parni čistilci": "Steam cleaners",
"Pečice": "Ovens",
"Plastifikatorji": "Laminators",
"Poganjalčki": "Balance bikes",
"Pomivalni stroji": "Dishwashers",
"Posode za kuhanje": "Cookware",
"Postelje": "Beds",
"Potrošni material": "Consumables",
"Pralni stroji": "Washing machines",
"Pralno - sušilni stroji": "Washer dryers",
"Prenosne baterije": "Power banks",
"Prenosni računalniki": "Laptops",
"Projektorji": "Projectors",
"Računalniško pohištvo": "Computer furniture",
"Robotski sesalniki": "Robot vacuums",
"Senzorji": "Sensors",
"Sesalniki": "Vacuum cleaners",
"Skiroji in rolke": "Scooters and skateboards",
"Slušalke": "Headphones",
"Sončne celice": "Solar panels",
"Soundbar zvočniki": "Soundbars",
"Stolčki": "High chairs",
"Strižniki": "Hair clippers",
"Supi": "Paddleboards",
"Sušilni stroji": "Tumble dryers",
"Tablični računalniki": "Tablets",
"Televizorji": "Televisions",
"Tipkovnice": "Keyboards",
"Tiskalniki": "Printers",
"Tiskalniki nalepk in trakov": "Label and tape printers",
"Tobogani in gugalnice": "Slides and swings",
"Trampolini": "Trampolines",
"Uničevalci dokumentov": "Shredders",
"Visokotlačni čistilci": "Pressure washers",
"Vlažilniki in čistilci zraka": "Humidifiers and air purifiers",
"Vozički": "Strollers",
"Vrtno orodje": "Garden tools",
"Vtičnice": "Smart plugs",
"Zamrzovalniki": "Freezers",
"Zaščitna stekla": "Screen protectors",
"Zimski športi": "Winter sports",
"Zobne prhe": "Water flossers",
"Čistilci oken in tal": "Window and floor cleaners",
"Športi z žogo": "Ball sports",
"Štedilniki": "Cookers",
"Žari": "Grills",
"Žepni bralniki": "E-readers"
};
// Phrase tables are applied in order and each entry is a global literal
// replacement, so a shorter phrase must never run before a longer one containing
// it. Groups matter as much as the order inside them: body-block sentences embed
// title-slot words ("dimenzije", "procesor", "tip izdelka"), so whole sentences are
// translated first and bare slot words last.
const SCAFFOLD_PHRASES = [
// ---- prompt scaffolding -------------------------------------------------
["Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:", "Create a new product description in {{language}} using the following variables:"],
["Star_opis_izdelka", "Old_product_description"],
["Staro_ime_izdelka", "Old_product_name"],
["OPIS IZDELKA", "PRODUCT DESCRIPTION"],
["STARO IME IZDELKA", "OLD PRODUCT NAME"],
["Uporabi spodnjo GPT predlogo.", "Use the product template below."],
["Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:", "Follow this product template sentence by sentence and compose a new product description:"],
["GPT predloga:", "Product template:"],
// ---- meta rule ----------------------------------------------------------
["Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki", "Start with the New product name and then continue until you have written 140 characters including spaces"],
// ---- title formula wrapper ---------------------------------------------
["Napiši novo ime izdelka po formuli:", "Write a new product name using this formula:"],
["Če je kabel za polnjenje, potem sestavi ime tako:", "If it is a charging cable, build the name like this:"],
["Ne uporabljaj vejic.", "Do not use commas."],
["Ne vključuj barve v ime.", "Do not include the colour in the name."]
];
const TITLE_SLOT_PHRASES = [
// ---- title slots (longest first) ---------------------------------------
["poln model izdelka, če lahko z besedo in ID uppercase", "full product model, with the word and the ID in uppercase when available"],
["tip izdelka (lahko tudi brez, če ni primernega izraza) sentence case", "product type in sentence case (omit when no suitable term exists)"],
["barva v eni slovenski besedi (če je možnih več barv)", "colour as a single word (when several colours are possible)"],
["barva v eni slovenski besedi (če je več barvnih variant)", "colour as a single word (when several colour variants exist)"],
["barva v eni slovenski besedi", "colour as a single word"],
["znamka in model naprave, za katere je namenjen", "brand and model of the device it is intended for"],
["model naprave za katero je namenjen", "model of the device it is intended for"],
["tip izdelka (»Giljotina« ali »Rezalnik«)", "product type (\"Guillotine\" or \"Trimmer\")"],
["znamka s pravilno kapitalizacijo", "brand with correct capitalisation"],
["diagonala v inčih in označeno z '", "screen diagonal in inches marked with '"],
["velikost zaslona v inčih - (primer 13.6'')", "screen size in inches - (example 13.6'')"],
["velikost v cm (npr. 120x200cm)", "size in cm (e.g. 120x200cm)"],
["osveževanje (če je 144Hz)", "refresh rate (when it is 144Hz)"],
["Stopnja osveževanja v Hz", "Refresh rate in Hz"],
["vrsta priključka (npr. USB C)", "connector type (e.g. USB C)"],
["vrsta vtičnice (npr. RJ45)", "socket type (e.g. RJ45)"],
["vrsta kabla (UTP/STP…)", "cable type (UTP/STP…)"],
["otroška pametna ura lowercase", "kids smartwatch in lowercase"],
["tip izdelka sentence case", "product type in sentence case"],
["vrsta izdelka lowercase", "product type in lowercase"],
["model izdelka uppercase", "product model in uppercase"],
["kapaciteta pranja [kg]", "wash capacity [kg]"],
["skupina po teži [kg]", "weight group [kg]"],
["kapaciteta v litrih", "capacity in litres"],
["tip grafične kartice", "graphics card type"],
["grafična kartica", "graphics card"],
["grelnik vode lowercase", "water heater in lowercase"],
["tip izdelka lowercase", "product type in lowercase"],
["klima z montažo", "air conditioner with installation"],
["velikost pomnilnika", "memory size"],
["moč polnjenja", "charging power"],
["polnilni kabel", "charging cable"],
["cvrtnik lowercase", "air fryer in lowercase"],
["monitor lowercase", "monitor in lowercase"],
["tip procesorja", "processor type"],
["znamka izdelka", "product brand"],
["znamka normal case", "brand in normal case"],
["model izdelka", "product model"],
["dimenzije [cm]", "dimensions [cm]"],
["širina [cm]", "width [cm]"],
["dolžina [m]", "length [m]"],
["ime igre", "game title"],
["»Uničevalec dokumentov«", "\"Shredder\""],
["»Tiskalnik nalepk«", "\"Label printer\""],
["»Plastifikator«", "\"Laminator\""],
["»postelja«", "\"bed\""],
["dimenzije", "dimensions"],
["procesor", "processor"],
["pomnilnik", "memory"],
["kapaciteta", "capacity"],
["barva", "colour"],
["kabel", "cable"],
["model", "model"]
];
const BODY_BLOCK_PHRASES = [
// ---- description body blocks (longest first) ---------------------------
["Napiši Novo ime izdelka brez procesorja, brez grafične kartice, brez pomnilnika in izpostavi en benefit", "Write the New product name without the processor, without the graphics card and without the memory, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez procesorja, brez grafične kartice in brez pomnilnika", "Write a 100-word paragraph that DOES include the New product name without the processor, without the graphics card and without the memory"],
["Napiši material postelje (npr. »Lesena postelja« ali »Kovinska postelja«) in izpostavi en benefit ter VKLJUČI Novo ime izdelka", "Write the bed material (e.g. \"Wooden bed\" or \"Metal bed\"), highlight one benefit and DO include the New product name"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez barve in brez podatka o obnovljenosti", "Write a 100-word paragraph that DOES include the New product name without the colour and without the refurbished status"],
["Napiši Novo ime izdelka brez barve in brez podatka o obnovljenosti in izpostavi en benefit", "Write the New product name without the colour and without the refurbished status, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez kapacitete pranja", "Write a 100-word paragraph that DOES include the New product name without the wash capacity"],
["Napiši Novo ime izdelka brez kapacitete pranja in izpostavi en benefit", "Write the New product name without the wash capacity, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez skupne teže", "Write a 100-word paragraph that DOES include the New product name without the total weight"],
["Napiši Novo ime izdelka brez skupne teže in izpostavi en benefit", "Write the New product name without the total weight, and highlight one benefit"],
["Napiši Novo ime izdelka brez skupine po teži in izpostavi en benefit", "Write the New product name without the weight group, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez dimenzije", "Write a 100-word paragraph that DOES include the New product name without the dimensions"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez dimenzij", "Write a 100-word paragraph that DOES include the New product name without the dimensions"],
["Napiši Novo ime izdelka brez dimenzije in izpostavi en benefit", "Write the New product name without the dimensions, and highlight one benefit"],
["Napiši Novo ime izdelka brez dimenzij in izpostavi en benefit", "Write the New product name without the dimensions, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez širine", "Write a 100-word paragraph that DOES include the New product name without the width"],
["Napiši Novo ime izdelka brez širine in izpostavi en benefit", "Write the New product name without the width, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI Novo ime izdelka brez kapacitete", "Write a 100-word paragraph that DOES include the New product name without the capacity"],
["Napiši Novo ime izdelka brez kapacitete in izpostavi en benefit", "Write the New product name without the capacity, and highlight one benefit"],
["Napiši Novo ime izdelka brez diagonale in izpostavi en benefit", "Write the New product name without the screen diagonal, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka brez barve", "Write a 100-word paragraph that DOES include the New product name without the colour"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI Novo ime izdelka brez barve", "Write a 100-word paragraph that DOES include the New product name without the colour"],
["Napiši Novo ime izdelka brez barve in izpostavi en benefit", "Write the New product name without the colour, and highlight one benefit"],
["Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.", "Write a 100-word paragraph that does NOT contain the New product name."],
["Napiši odstavek, ki je dolg 100 besed in NE vsebuje Novo ime izdelka.", "Write a 100-word paragraph that does NOT contain the New product name."],
["Napiši odstavek, ki je dolg 100 besed in NE VKLJUČI Novo ime izdelka.", "Write a 100-word paragraph that does NOT include the New product name."],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka.", "Write a 100-word paragraph that DOES include the New product name."],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI Novo ime izdelka.", "Write a 100-word paragraph that DOES include the New product name."],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka", "Write a 100-word paragraph that DOES include the New product name"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI Novo ime izdelka", "Write a 100-word paragraph that DOES include the New product name"],
["Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi tip izdelka lowercase", "Write a 100-word paragraph that DOES include the product type in lowercase"],
["Nadaljuj iz naslova in napiši 100 besed o temu benefitu, ne vključuj Novo_ime_izdelka", "Continue from the heading and write 100 words about that benefit, without including the New product name"],
["Napiši naslov, ki izpostavlja en benefit in VKLJUČI tudi tip izdelka lowercase.", "Write a heading that highlights one benefit and DOES include the product type in lowercase."],
["Naj vključuje Novo_ime_izdelka in izpostavi en benefit", "Include the New product name and highlight one benefit"],
["Napiši Novo ime izdelka in izpostavi en benefit", "Write the New product name and highlight one benefit"],
["Napiši tip izdelka sentence case in izpostavi en benefit", "Write the product type in sentence case and highlight one benefit"],
["Izpostavi en benefit, in NE napiši Novo ime izdelka", "Highlight one benefit and do NOT write the New product name"],
["Izpostavi en benefit in NE napiši Novo ime izdelka", "Highlight one benefit and do NOT write the New product name"],
["Izpostavi en benefit in VKLJUČI Novo ime izdelka", "Highlight one benefit and DO include the New product name"],
["Zapiši Novo_ime_izdelka", "Write the New product name"],
["Napiši 3-10 tehničnih specifikacij v alinejah", "Write 3-10 technical specifications as bullet points"],
["Napiši 310 tehničnih specifikacij v alinejah", "Write 3-10 technical specifications as bullet points"],
["Tehnične specifikacije", "Technical specifications"],
["Novo_ime_izdelka", "New product name"],
["Novo ime izdelka", "New product name"]
];
// Whole sentences first, bare slot words last.
const PHRASES = [...SCAFFOLD_PHRASES, ...BODY_BLOCK_PHRASES, ...TITLE_SLOT_PHRASES];
// Letters that only occur in Slovenian words — used as the residue tripwire.
const SLOVENE_LETTERS = /[čćšžđČĆŠŽĐ]/;
// Slovenian function words that survive without diacritics.
const SLOVENE_WORDS =
/\b(napi[sš]i|izpostavi|odstavek|besed|izdelka|izdelek|vklju[cč]i|brez|tudi|znamka|barva|dolg|nov[ao]?|ime|opis|predloga|uporabi|sledi|ustvari|stavek|spremenljivkami|slovensko?i?|vejic|alinejah|specifikacij|tehni[cč]n)\b/i;
function translate(text) {
let out = text;
for (const [sl, en] of PHRASES) out = out.split(sl).join(en);
return out;
}
function residue(text) {
// Ignore the placeholder tokens and HTML tags when scanning for leftovers.
const stripped = text.replace(/<[^>]*>/g, " ").replace(/\{\{[a-z_]+\}\}/g, " ");
const hits = [];
for (const line of stripped.split(/[\n;.]/)) {
const t = line.trim();
if (!t) continue;
if (SLOVENE_LETTERS.test(t) || SLOVENE_WORDS.test(t)) hits.push(t);
}
return hits;
}
function main() {
const check = process.argv.includes("--check");
const src = JSON.parse(readFileSync(SOURCE, "utf8"));
const entries = [];
const problems = [];
for (const e of src.entries) {
const slName = String(e.name || "").trim();
const enName = CATEGORY_NAMES[slName];
if (!enName) {
problems.push(`no English name mapped for category ${JSON.stringify(slName)}`);
continue;
}
const prompt = translate(String(e.prompt || ""));
const left = residue(prompt);
if (left.length) {
problems.push(`untranslated text in ${slName} -> ${enName}:\n ${left.slice(0, 3).join("\n ")}`);
continue;
}
entries.push({
name: enName,
// Legacy names stay as match aliases so a tenant importing the original
// taxonomy still resolves to the English default formula.
aliases: [slName],
...(e.unique_id ? { unique_id: e.unique_id } : {}),
prompt
});
}
if (problems.length) {
console.error(`build-default-category-prompts: ${problems.length} problem(s)`);
for (const p of problems.slice(0, 20)) console.error(` - ${p}`);
process.exit(1);
}
entries.sort((a, b) => a.name.localeCompare(b.name, "en"));
const out = {
version: 1,
source: "a1-category-prompts.json (translated by scripts/seed/build-default-category-prompts.mjs)",
language: "en",
note: "English platform-default category formulas. A1 Slovenija keeps a1-category-prompts.json.",
entries
};
const json = JSON.stringify(out, null, 2) + "\n";
if (check) {
let current = "";
try {
current = readFileSync(TARGET, "utf8");
} catch {
console.error("default-category-prompts.json is missing — run without --check");
process.exit(1);
}
if (current !== json) {
console.error("default-category-prompts.json is stale — run: node scripts/seed/build-default-category-prompts.mjs");
process.exit(1);
}
console.log(`default-category-prompts.json is up to date (${entries.length} entries)`);
return;
}
writeFileSync(TARGET, json, "utf8");
console.log(`wrote ${TARGET} (${entries.length} entries)`);
}
main();