This commit is contained in:
2026-08-17 00:39:25 +02:00
parent 92f046b542
commit 93dc70123c
54 changed files with 25528 additions and 20666 deletions
+29 -2
View File
@@ -270,6 +270,8 @@ export type SyncA1Result = {
dump_mapped_updated?: number;
dump_processed_updated?: number;
dump_status?: string;
wp_categories_path?: string;
wp_categories_entries?: number;
};
export type SyncA1Response = {
@@ -283,11 +285,36 @@ export type FixCatalogResult = SyncA1Result;
/** @deprecated Use SyncA1Response */
export type FixCatalogResponse = SyncA1Response;
/** Sync A1: dump category backfill (when dump on API host) + Fix hygiene. No mass reprocess. */
/** Sync A1: optional wp_product_categories.sql upload + dump category backfill + Fix hygiene. */
export async function syncAdminCompanyA1(
companyId: string,
opts?: { backfillCategories?: boolean; reprocessSampleLimit?: number; skipDumpBackfill?: boolean }
opts?: {
backfillCategories?: boolean;
reprocessSampleLimit?: number;
skipDumpBackfill?: boolean;
wpProductCategoriesFile?: File | null;
}
): Promise<SyncA1Response> {
const file = opts?.wpProductCategoriesFile ?? null;
if (file) {
const body = new FormData();
body.append("confirm", "true");
if (opts?.backfillCategories === false) {
body.append("backfill_categories", "false");
}
if (typeof opts?.reprocessSampleLimit === "number") {
body.append("reprocess_sample_limit", String(opts.reprocessSampleLimit));
}
if (opts?.skipDumpBackfill) {
body.append("skip_dump_backfill", "true");
}
body.append("wp_product_categories", file, file.name || "wp_product_categories.sql");
return api<SyncA1Response>(ADMIN_SYNC_A1_PATH(companyId), {
method: "POST",
body
});
}
const body: {
confirm: true;
backfill_categories?: boolean;
@@ -0,0 +1,203 @@
/**
* Parse / compose category enhance USER prompts that use role section markers
* (mirrors apps/api/internal/aiprompts/roles.go Section* constants).
*
* Storage remains a single language string with --- Title --- markers so
* enhance one-shot JSON parse (name, description, meta_*, attrs) keeps working.
*/
export type EnhancePromptSectionId = "title" | "description" | "meta" | "attributes";
export const ENHANCE_PROMPT_SECTIONS: readonly EnhancePromptSectionId[] = [
"title",
"description",
"meta",
"attributes"
] as const;
/** Markers must stay in sync with aiprompts.Section* constants. */
export const SECTION_MARKERS: Record<
EnhancePromptSectionId,
{ start: string; end: string }
> = {
title: { start: "--- Title ---", end: "--- End Title ---" },
description: { start: "--- Description ---", end: "--- End Description ---" },
meta: { start: "--- Meta ---", end: "--- End Meta ---" },
attributes: { start: "--- Attributes ---", end: "--- End Attributes ---" }
};
/** Canonical shared intro (same idea as CategoryEnhanceUserTemplate preamble). */
export const DEFAULT_ENHANCE_PREAMBLE =
'Your reply is parsed as JSON {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}} only (system schema). Write all string fields in {{language}} (do not hardcode a language).';
/** Minimal role bodies used when a section is empty on compose (keeps markers valid). */
export const DEFAULT_SECTION_BODIES: Record<EnhancePromptSectionId, string> = {
title:
'Role: title. Build JSON "name": short retail title from Title formula + Attrs — never brand-only. Include product type and full model when evidence exists; follow any Title formula constraints that follow; use Attrs.\nName: {{name}}',
description:
'Role: description. Build JSON "description": product body HTML only (not SEO meta). When a Description formula follows, emit ONE HTML string covering each section in order; otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (<h2><p><ul><li>).\nDescription: {{description}}',
meta: 'Role: meta. Build JSON "meta_title" and "meta_description" as plain SEO text (never HTML). meta_title: 50-60 chars; meta_description: 120-155 chars; follow any SEO meta formula that follows; never copy the full description HTML into meta_description.',
attributes:
'Role: attributes. Build JSON "attrs" as an object of attribute_key → value strings. Prefer Allowed attribute keys / Title formula attr slots that follow; remap near-miss labels onto those keys; fill missing keys only from Name/Description/Category/Attrs evidence; never invent specs; omit unknown keys; never invent dimensions.\nCategory: {{category}}\nAttrs: {{attrs}}'
};
export type SectionSchemaHint = {
/** JSON keys this section is responsible for in the one-shot enhance reply. */
jsonKeys: string[];
/** Short example fragment for the UI. */
example: string;
/** Vars that are most useful in this section. */
suggestedVars: readonly string[];
};
export const SECTION_SCHEMA_HINTS: Record<EnhancePromptSectionId, SectionSchemaHint> = {
title: {
jsonKeys: ["name"],
example: '{"name":"Acme Widget Pro"}',
suggestedVars: ["name", "attrs", "brand_voice", "language"]
},
description: {
jsonKeys: ["description"],
example: '{"description":"<p>…</p>"}',
suggestedVars: ["description", "name", "category", "attrs", "brand_voice", "language"]
},
meta: {
jsonKeys: ["meta_title", "meta_description"],
example: '{"meta_title":"…","meta_description":"…"}',
suggestedVars: ["name", "description", "category", "brand_voice", "language"]
},
attributes: {
jsonKeys: ["attrs"],
example: '{"attrs":{"brand":"Acme","product_model":"Widget Pro"}}',
suggestedVars: ["attrs", "category", "name", "description", "gtin", "language"]
}
};
export type ParsedEnhancePrompt = {
/** True when all four role markers were present. */
hasRoleSections: boolean;
/** Text above the first --- Section --- (JSON schema intro). */
preamble: string;
sections: Record<EnhancePromptSectionId, string>;
};
function normalizeNewlines(s: string): string {
return s.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
}
function findMarkerIndex(haystackLower: string, marker: string): number {
return haystackLower.indexOf(marker.toLowerCase());
}
/** Extract body between start/end markers (case-insensitive markers, body preserved). */
export function extractSectionBody(prompt: string, id: EnhancePromptSectionId): string | null {
const raw = normalizeNewlines(prompt);
const lower = raw.toLowerCase();
const { start, end } = SECTION_MARKERS[id];
const startIdx = findMarkerIndex(lower, start);
if (startIdx < 0) return null;
const bodyStart = startIdx + start.length;
const endIdx = findMarkerIndex(lower.slice(bodyStart), end);
if (endIdx < 0) {
return raw.slice(bodyStart).replace(/^\n+/, "").trimEnd();
}
return raw
.slice(bodyStart, bodyStart + endIdx)
.replace(/^\n+/, "")
.replace(/\n+$/, "");
}
export function hasEnhanceRoleSections(prompt: string): boolean {
const lower = normalizeNewlines(prompt).toLowerCase();
return (
lower.includes(SECTION_MARKERS.title.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.description.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.meta.start.toLowerCase()) &&
lower.includes(SECTION_MARKERS.attributes.start.toLowerCase())
);
}
function extractPreamble(prompt: string): string {
const raw = normalizeNewlines(prompt);
const lower = raw.toLowerCase();
let first = -1;
for (const id of ENHANCE_PROMPT_SECTIONS) {
const idx = findMarkerIndex(lower, SECTION_MARKERS[id].start);
if (idx >= 0 && (first < 0 || idx < first)) first = idx;
}
if (first <= 0) return "";
return raw.slice(0, first).trim();
}
/**
* Decompose a stored categories.prompt string into preamble + per-role bodies.
* Unstructured blobs land in description so content is not lost on first save.
*/
export function parseEnhancePrompt(prompt: string): ParsedEnhancePrompt {
const raw = normalizeNewlines(prompt).trim();
if (!raw) {
return {
hasRoleSections: false,
preamble: DEFAULT_ENHANCE_PREAMBLE,
sections: {
title: "",
description: "",
meta: "",
attributes: ""
}
};
}
const structured = hasEnhanceRoleSections(raw);
if (!structured) {
return {
hasRoleSections: false,
preamble: DEFAULT_ENHANCE_PREAMBLE,
sections: {
title: "",
description: raw,
meta: "",
attributes: ""
}
};
}
const sections = {} as Record<EnhancePromptSectionId, string>;
for (const id of ENHANCE_PROMPT_SECTIONS) {
sections[id] = extractSectionBody(raw, id) ?? "";
}
const preamble = extractPreamble(raw) || DEFAULT_ENHANCE_PREAMBLE;
return { hasRoleSections: true, preamble, sections };
}
/**
* Compose preamble + section bodies back into the stored enhance USER template shape.
* Empty section bodies fall back to DEFAULT_SECTION_BODIES so markers stay valid.
*/
export function composeEnhancePrompt(
preamble: string,
sections: Partial<Record<EnhancePromptSectionId, string>>
): string {
const intro = (preamble.trim() || DEFAULT_ENHANCE_PREAMBLE).trim();
const parts: string[] = [intro, ""];
for (const id of ENHANCE_PROMPT_SECTIONS) {
const { start, end } = SECTION_MARKERS[id];
const body = (sections[id] ?? "").trim() || DEFAULT_SECTION_BODIES[id];
parts.push(start, body, end, "");
}
return parts.join("\n").trim();
}
/** True when every section (and optional preamble) is empty / default-cleared. */
export function isEnhancePromptEmpty(
preamble: string,
sections: Partial<Record<EnhancePromptSectionId, string>>
): boolean {
const hasCustomPreamble =
preamble.trim() !== "" && preamble.trim() !== DEFAULT_ENHANCE_PREAMBLE.trim();
if (hasCustomPreamble) return false;
return ENHANCE_PROMPT_SECTIONS.every((id) => !(sections[id] ?? "").trim());
}
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
composeEnhancePrompt,
DEFAULT_ENHANCE_PREAMBLE,
hasEnhanceRoleSections,
isEnhancePromptEmpty,
parseEnhancePrompt,
SECTION_MARKERS
} from "./categories/prompt-sections.ts";
const sample = [
DEFAULT_ENHANCE_PREAMBLE,
"",
SECTION_MARKERS.title.start,
"Title body with {{name}}",
SECTION_MARKERS.title.end,
"",
SECTION_MARKERS.description.start,
"Desc body with {{description}}",
SECTION_MARKERS.description.end,
"",
SECTION_MARKERS.meta.start,
"Meta body",
SECTION_MARKERS.meta.end,
"",
SECTION_MARKERS.attributes.start,
"Attrs body {{attrs}}",
SECTION_MARKERS.attributes.end
].join("\n");
describe("category prompt sections", () => {
it("detects role section markers", () => {
assert.equal(hasEnhanceRoleSections(sample), true);
assert.equal(hasEnhanceRoleSections("plain marketing blob"), false);
});
it("parses and round-trips structured prompts", () => {
const parsed = parseEnhancePrompt(sample);
assert.equal(parsed.hasRoleSections, true);
assert.match(parsed.preamble, /meta_title/);
assert.equal(parsed.sections.title, "Title body with {{name}}");
assert.equal(parsed.sections.description, "Desc body with {{description}}");
assert.equal(parsed.sections.meta, "Meta body");
assert.equal(parsed.sections.attributes, "Attrs body {{attrs}}");
const again = composeEnhancePrompt(parsed.preamble, parsed.sections);
assert.equal(hasEnhanceRoleSections(again), true);
const reparsed = parseEnhancePrompt(again);
assert.equal(reparsed.sections.title, parsed.sections.title);
assert.equal(reparsed.sections.attributes, parsed.sections.attributes);
});
it("keeps unstructured prompts in description", () => {
const legacy = "Ustvari nov opis… {{description}}";
const parsed = parseEnhancePrompt(legacy);
assert.equal(parsed.hasRoleSections, false);
assert.equal(parsed.sections.description, legacy);
assert.equal(parsed.sections.title, "");
});
it("compose fills empty sections with defaults so markers remain", () => {
const out = composeEnhancePrompt(DEFAULT_ENHANCE_PREAMBLE, {
title: "Custom title rules",
description: "",
meta: "",
attributes: ""
});
assert.equal(hasEnhanceRoleSections(out), true);
assert.match(out, /Custom title rules/);
assert.match(out, /Role: description/);
assert.match(out, /\{\{attrs\}\}/);
});
it("isEnhancePromptEmpty ignores default preamble", () => {
assert.equal(isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, {}), true);
assert.equal(
isEnhancePromptEmpty(DEFAULT_ENHANCE_PREAMBLE, { title: "x" }),
false
);
});
});
File diff suppressed because it is too large Load Diff
+40 -2
View File
@@ -211,6 +211,13 @@ export const en: MessageDict = {
"settings.walletRemainingHint": "remaining",
"settings.role.member": "Member",
"settings.role.admin": "Admin",
"settings.cannotRemoveOwner": "Transfer ownership before removing the company owner.",
"settings.transferFailed": "Could not transfer ownership",
"settings.ownershipTransferred": "{email} is now the company owner.",
"settings.transferOwnershipConfirm": "Make {email} the company owner? They will handle billing for this company.",
"settings.transferOwnership": "Transfer ownership",
"settings.ownerBadge": "Owner",
"settings.role.owner": "Owner",
"settings.teamHeading": "Team Members",
"settings.inviteUser": "Invite user",
"settings.teamAdminOnly": "Only company admins can invite, promote, demote, or remove teammates.",
@@ -820,6 +827,10 @@ export const en: MessageDict = {
"admin.users.reissueInvite": "Re-issue invite",
"admin.users.setLocalPassword": "Set password",
"admin.users.setPasswordTitle": "Set password",
"admin.users.copyInviteLink": "Copy link",
"admin.users.inviteLinkLabel": "One-time set-password / accept invite link",
"admin.users.inviteLinkCopied": "Set-password link copied.",
"admin.users.inviteLinkReady": "Copy the set-password link below and share it securely.",
"admin.users.setPasswordDesc": "Force-set a login password for this user (works for legacy or fake emails that cannot receive invites).",
"admin.users.newPassword": "New password",
"admin.users.setPasswordSubmit": "Set password",
@@ -845,11 +856,14 @@ export const en: MessageDict = {
"admin.users.syncA1": "Sync A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sync A1 catalog",
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
"admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
"admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Cancel",
"admin.users.syncA1Confirm": "Sync A1",
"admin.users.syncA1WpUpload": "wp_product_categories.sql",
"admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
"admin.users.syncA1WpUploadClear": "Clear file",
"admin.users.noUsers": "No users match this filter.",
"admin.users.noCompanies": "No companies match this filter.",
"admin.users.assignRoleTitle": "Assign staff role",
@@ -4422,6 +4436,30 @@ export const en: MessageDict = {
"categories.usingCompanyDefaultForLang": "· empty for this language — company / built-in default applies",
"categories.aiPromptSavedLang": "AI prompt saved for {lang}.",
"categories.aiPromptClearedLang": "AI prompt cleared for {lang} (company default will apply).",
"categories.promptSection.navLabel": "Prompt sections",
"categories.promptSection.navHeading": "Sections",
"categories.promptSection.navHint": "Edit one part of the enhance reply at a time.",
"categories.promptSection.title": "Title",
"categories.promptSection.titleHelp": "Instructions for the product title (JSON name).",
"categories.promptSection.titleFormulaNote": "Title formula slots (type + brand + model) are appended automatically when set on this category.",
"categories.promptSection.description": "Description",
"categories.promptSection.descriptionHelp": "Instructions for the HTML product body (JSON description - not SEO meta).",
"categories.promptSection.descriptionFormulaNote": "Description formula sections are appended at render time when configured.",
"categories.promptSection.meta": "Meta",
"categories.promptSection.metaHelp": "Instructions for SEO meta_title and meta_description (plain text).",
"categories.promptSection.metaFormulaNote": "SEO meta formulas from the description template are appended when present.",
"categories.promptSection.attributes": "Attributes",
"categories.promptSection.attributesHelp": "Instructions for the attrs object (allowlisted keys).",
"categories.promptSection.attributesFormulaNote": "Allowed attribute keys and title-formula attr slots are appended at render time.",
"categories.promptSection.schemaHeading": "One-shot JSON",
"categories.promptSection.schemaHelp": "Enhance returns a single JSON object. This section owns the keys below.",
"categories.promptSection.contentLabel": "Section instructions",
"categories.promptSection.contentPlaceholder": "Role guidance and placeholders for this section...",
"categories.promptSection.preambleToggle": "Shared intro (all sections)",
"categories.promptSection.preambleHelp": "Shown once above the section markers. Keep the JSON schema reminder so the model replies correctly.",
"categories.promptSection.clearLanguage": "Clear language override",
"categories.promptSection.unstructuredHint": "This prompt is not sectioned yet. Content is shown under Description - edit the sections you need and save to store the standard Title / Description / Meta / Attributes layout.",
"categories.promptSection.categorizeNote": "Categorize (taxonomy pick) is a separate pipeline step - not part of this enhance prompt.",
"contentLang.switcherLabel": "Content language",
"contentLang.primary": "primary",
"contentLang.hasOverride": "custom",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,6 +1,6 @@
import type { MessageDict } from "./types.ts";
/** Slovenian (sl) UI strings for admin Fix A1 †ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
/** Slovenian (sl) UI strings for admin Fix A1 — ready pack. Not registered in UI_LOCALES yet (avoid inventing locale switcher). */
export const sl: MessageDict = {
"admin.users.cloneCatalog": "Copy to my company",
"admin.users.cloneCatalogAria": "Copy {name} catalog into your sandbox company",
@@ -18,12 +18,15 @@ export const sl: MessageDict = {
"admin.users.syncA1": "Sinhroniziraj A1",
"admin.users.syncA1Aria": "Sync A1 categories from dump and repair catalog hygiene for {name}",
"admin.users.syncA1Title": "Sinhroniziraj katalog A1",
"admin.users.syncA1Desc": "Backfills mapped categories from the MySQL dump when present on the API host, then runs catalog hygiene (prompts, weak hashes, bidirectional category backfill, attribute sanitize). Does not wipe or reimport the full catalog.",
"admin.users.syncA1Desc": "Uploads wp_product_categories.sql to force-apply Title/Description/Meta/Attributes category prompts, optionally backfills mapped categories from a MySQL product dump on the API host, then runs catalog hygiene. Does not wipe or reimport the full catalog.",
"admin.users.syncA1Company": "Company: {name}",
"admin.users.syncA1Warning": "Requires descrybe_new.sql on the API server (scripts/seed/ or SEED_A1_MYSQL_DUMP) for dump category coverage. Without a dump, only DB hygiene runs. No mass reprocess.",
"admin.users.syncA1Cancel": "Prekliči",
"admin.users.syncA1Warning": "Upload wp_product_categories.sql below (primary). Auto-detect on the API host is a fallback when no file is chosen. Optional: place descrybe_new.sql on the API server for product-dump category coverage. No mass reprocess.",
"admin.users.syncA1Cancel": "Prekliči",
"admin.users.syncA1Confirm": "Sinhroniziraj A1",
"admin.users.syncA1WpUpload": "wp_product_categories.sql",
"admin.users.syncA1WpUploadHint": "Choose the WordPress category prompts dump. Max 16 MiB. When uploaded, Sync A1 force-applies sectioned prompts for title and description enhance.",
"admin.users.syncA1WpUploadClear": "Počisti datoteko",
"flash.admin.catalogCloned": "Copied {source} into {dest}: {products} products, {categories} taxonomy categories, {mapped_with} with mapped category.",
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processedmapped {categories}, mappedprocessed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
"flash.admin.syncA1Success": "Synced A1 for {name}: dump_mapped {dump_mapped} ({dump_status}), prompts {prompts}, hashes {hashes}, processed←mapped {categories}, mapped←processed {mapped_backfilled}. Coverage: {mapped_with}/{mapped_total} ({taxonomy} taxonomy).",
"flash.admin.syncA1Error": "Sinhronizacija A1 za {name} ni uspela.",
};
+4 -1
View File
@@ -1,4 +1,4 @@
/**
/**
* Focused check: admin Sync A1 i18n keys exist in every UI_LOCALES pack.
*/
import assert from "node:assert/strict";
@@ -16,6 +16,9 @@ const SYNC_A1_KEYS = [
"admin.users.syncA1Desc",
"admin.users.syncA1Company",
"admin.users.syncA1Warning",
"admin.users.syncA1WpUpload",
"admin.users.syncA1WpUploadHint",
"admin.users.syncA1WpUploadClear",
"admin.users.syncA1Cancel",
"admin.users.syncA1Confirm",
"flash.admin.syncA1Success",
+4
View File
@@ -20,6 +20,7 @@ export type Company = {
language?: string;
content_languages?: string[];
merge_products_by_gtin?: boolean;
owner_user_id?: string | null;
};
export type CreditBalance = {
@@ -94,6 +95,8 @@ export type MeResponse = {
companies?: Company[];
active_company_id?: string;
membership?: { role: string; status: string; staff_override?: boolean } | null;
/** True when the signed-in user is companies.owner_user_id for the active company. */
is_owner?: boolean;
credits?: CreditBalance | null;
staff_access?: StaffAccess | null;
staff_capabilities?: string[];
@@ -234,6 +237,7 @@ export type TeamMember = {
role?: string | null;
status?: string | null;
created_at?: string | null;
is_owner?: boolean;
};
export type ChannelSyncSummary = {