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
@@ -0,0 +1,662 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"unicode/utf8"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/db"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
"github.com/descrybe/descrybe-v2/apps/api/internal/logredact"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Batch A: rich h1/h2/p/ul category formulas (incl. Slusalke/48).
var eans = []string{
"195348253666", // 7 Gaming monitorji
"4548736132597", // 48 Slusalke
"3045380024786", // 36 Pecice
"3838782459856", // 38 Pomivalni stroji
"1200130000638", // 3 Bluetooth zvocniki
}
var eanLabels = map[string]string{
"195348253666": "Gaming monitorji (rich formula)",
"4548736132597": "Slusalke cat48 (rich formula)",
"3045380024786": "Pecice (rich formula)",
"3838782459856": "Pomivalni stroji (rich formula)",
"1200130000638": "Bluetooth zvocniki (rich formula)",
}
var poisonMeta = regexp.MustCompile(`\|\s*\d+\s*$`)
var brandOnlyish = regexp.MustCompile(`(?i)^(bosch|gorenje|gigabyte|xiaomi|lenovo|samsung|lg|anker|jbl|vogels|ostalo)$`)
type dbProduct struct {
EAN string `json:"ean"`
Label string `json:"label"`
ProductID string `json:"product_id"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
MetaTitle string `json:"meta_title"`
MetaDescription string `json:"meta_description"`
Attributes json.RawMessage `json:"attributes"`
AIProviderMode string `json:"ai_provider_mode"`
DescTemplate any `json:"description_template"`
TitleTemplate any `json:"title_template"`
FormulaConstraint string `json:"formula_constraint"`
FieldSources json.RawMessage `json:"field_sources"`
}
func main() {
out := os.Getenv("PROBE_OUT_DIR")
if out == "" {
out = `F:\laragon\www\_MY\descrybe-v2\.codehelper\_a1_enhance_examples_A_20260816`
}
_ = os.MkdirAll(out, 0o755)
logPath := filepath.Join(out, "inproc_run.log")
logFile, err := os.Create(logPath)
if err != nil {
log.Fatalf("log file: %v", err)
}
defer logFile.Close()
log.SetOutput(logredact.Writer(io.MultiWriter(os.Stderr, logFile)))
companyID := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab")
userID := uuid.MustParse("0ce3305c-d810-4b56-b1d4-3c1ed510db76")
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
MaxConns: int32(cfg.DBMaxConns),
MinConns: int32(cfg.DBMinConns),
MaxConnLifetime: cfg.DBMaxConnLifetime,
MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
MaxConnIdleTime: cfg.DBMaxConnIdleTime,
HealthCheckPeriod: cfg.DBHealthCheckPeriod,
StatementTimeout: cfg.DBStatementTimeout,
})
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
waitForeignJobs(ctx, pool, companyID)
platEnv := platformsettings.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
OpenAIEmbeddingAPIKey: cfg.OpenAIEmbeddingAPIKey,
OpenAIEmbeddingBaseURL: cfg.OpenAIEmbeddingBaseURL,
OpenAIEmbeddingModel: cfg.OpenAIEmbeddingModel,
}
platSettings := platformsettings.NewService(pool, platEnv)
aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
ProcessingRPM: cfg.ProcessingRPM,
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
})
aiSvc.Platform = platSettings
roleCfg, rerr := platSettings.ResolveAIConfig(ctx, platformsettings.AIRoleProcessing)
if rerr != nil {
failResolve(out, fmt.Sprintf("ResolveAIConfig: %v", rerr))
}
completer, modeLabel, byok, cerr := aiSvc.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
if cerr != nil {
failResolve(out, fmt.Sprintf("ResolveCompleterForRole: %v", cerr))
}
if completer == nil {
failResolve(out, "nil completer")
}
client, ok := completer.(*processing.OpenAIClient)
if !ok {
failResolve(out, fmt.Sprintf("completer type %T", completer))
}
base := strings.TrimSpace(client.BaseURL)
model := strings.TrimSpace(client.Model)
resolveInfo := map[string]any{
"batch": "A",
"eans": eans,
"role_source": roleCfg.Source,
"role_provider": roleCfg.Provider,
"role_base_url": roleCfg.BaseURL,
"role_model": roleCfg.Model,
"role_enabled": roleCfg.Enabled,
"completer_base": base,
"completer_model": model,
"mode_label": modeLabel,
"byok": byok,
"env_base_url": cfg.OpenAIBaseURL,
"env_model": cfg.OpenAIModel,
"live": true,
}
if processing.IsMockOrLoopbackBaseURL(base) || strings.Contains(strings.ToLower(base), "18767") {
resolveInfo["live"] = false
resolveInfo["fail_reason"] = "resolved completer is mock/loopback — refuse silent mock fallback"
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM REQUIRED: resolved base=%s model=%s — refuse mock", base, model)
}
if strings.TrimSpace(client.APIKey) == "" {
resolveInfo["live"] = false
resolveInfo["fail_reason"] = "empty API key"
writeJSON(out, "resolve.json", resolveInfo)
log.Fatal("LIVE LLM REQUIRED: API key empty")
}
keyHint := ""
if k := strings.TrimSpace(client.APIKey); len(k) > 8 {
keyHint = k[:4] + "…" + k[len(k)-4:]
}
resolveInfo["api_key_hint"] = keyHint
modelsURL := strings.TrimRight(base, "/") + "/models"
modelsCtx, modelsCancel := context.WithTimeout(ctx, 30*time.Second)
defer modelsCancel()
modelsReq, merr := http.NewRequestWithContext(modelsCtx, http.MethodGet, modelsURL, nil)
if merr != nil {
failResolve(out, "models req: "+merr.Error())
}
modelsReq.Header.Set("Authorization", "Bearer "+client.APIKey)
modelsReq.Header.Set("Accept", "application/json")
modelsStart := time.Now()
modelsResp, merr := http.DefaultClient.Do(modelsReq)
modelsElapsed := time.Since(modelsStart).Round(time.Millisecond)
if merr != nil {
resolveInfo["probe_ok"] = false
resolveInfo["models_error"] = processing.TruncateError(merr)
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: GET models: %v", merr)
}
modelsBody, _ := io.ReadAll(io.LimitReader(modelsResp.Body, 2048))
_ = modelsResp.Body.Close()
resolveInfo["models_url"] = modelsURL
resolveInfo["models_http_status"] = modelsResp.StatusCode
resolveInfo["models_elapsed"] = modelsElapsed.String()
snippet := strings.TrimSpace(string(modelsBody))
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
resolveInfo["models_body_snippet"] = snippet
if modelsResp.StatusCode != http.StatusOK {
resolveInfo["probe_ok"] = false
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: models HTTP %d", modelsResp.StatusCode)
}
probeCtx, probeCancel := context.WithTimeout(ctx, 3*time.Minute)
defer probeCancel()
probeStart := time.Now()
comp, perr := client.Complete(probeCtx, "Reply with exactly: PONG", "Say PONG")
probeElapsed := time.Since(probeStart).Round(time.Millisecond)
if perr != nil {
// One soft retry — OverloadedBot often needs a cool-down after a prior batch.
log.Printf("chat probe first attempt failed elapsed=%s err=%s — retrying once after 20s", probeElapsed, processing.TruncateError(perr))
select {
case <-ctx.Done():
resolveInfo["probe_ok"] = false
resolveInfo["probe_error"] = processing.TruncateError(perr)
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: chat probe: %v", perr)
case <-time.After(20 * time.Second):
}
probeCtx2, probeCancel2 := context.WithTimeout(ctx, 3*time.Minute)
defer probeCancel2()
probeStart = time.Now()
comp, perr = client.Complete(probeCtx2, "Reply with exactly: PONG", "Say PONG")
probeElapsed = time.Since(probeStart).Round(time.Millisecond)
if perr != nil {
resolveInfo["probe_ok"] = false
resolveInfo["probe_error"] = processing.TruncateError(perr)
resolveInfo["models_http_status"] = modelsResp.StatusCode
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: chat probe (after retry): %v", perr)
}
}
resolveInfo["probe_ok"] = true
resolveInfo["probe_elapsed"] = probeElapsed.String()
resolveInfo["probe_response_len"] = len(comp.Text)
resolveInfo["started_at_utc"] = time.Now().UTC().Format(time.RFC3339)
writeJSON(out, "resolve.json", resolveInfo)
log.Printf("live resolve ok source=%s base=%s model=%s mode=%s byok=%v", roleCfg.Source, base, model, modeLabel, byok)
pipeline := processing.NewPipeline(pool)
pipeline.BatchSize = cfg.ProcessingBatchSize
pipeline.AI = aiSvc
pipeline.Prompts = aiprompts.NewService(pool)
eprelClient := eprel.NewClient(eprel.Options{Enabled: true, Timeout: 20 * time.Second})
pipeline.Engine = &processing.Engine{
Completer: nil,
Vector: processing.NoopVectorCategorizer{},
EPREL: eprelClient,
ProviderMode: processing.AIProviderInternal,
}
rawIDs := make([]uuid.UUID, 0, len(eans))
for _, ean := range eans {
var id uuid.UUID
if err := pool.QueryRow(ctx, `
SELECT id FROM raw_products
WHERE company_id = $1 AND gtin = $2`, companyID, ean).Scan(&id); err != nil {
log.Fatalf("raw product %s: %v", ean, err)
}
rawIDs = append(rawIDs, id)
log.Printf("ean=%s raw_id=%s label=%s", ean, id, eanLabels[ean])
}
tag, cerr2 := pool.Exec(ctx, `
UPDATE processed_products pp
SET field_sources = COALESCE(field_sources, '{}'::jsonb) - 'enhance_input_hash',
localized_content = CASE
WHEN localized_content IS NULL OR localized_content = '{}'::jsonb THEN localized_content
ELSE (
SELECT COALESCE(jsonb_object_agg(lang, val - 'enhance_input_hash'), '{}'::jsonb)
FROM jsonb_each(localized_content) AS t(lang, val)
)
END,
updated_at = now()
FROM raw_products rp
WHERE pp.company_id = $1
AND pp.raw_product_id = rp.id
AND rp.gtin = ANY($2::text[])`, companyID, eans)
if cerr2 != nil {
log.Printf("clear enhance hash: %v", cerr2)
} else {
log.Printf("cleared enhance_input_hash rows=%d", tag.RowsAffected())
}
if n, err := processing.BackfillMissingMeta(ctx, pool, companyID); err != nil {
log.Printf("BackfillMissingMeta: %v", err)
} else {
log.Printf("BackfillMissingMeta updated=%d", n)
}
_, _ = pool.Exec(ctx, `
UPDATE processing_jobs
SET status = 'failed', error = 'yielded to live enhance examples batch A', updated_at = now()
WHERE company_id = $1 AND status IN ('pending','running','processing')`, companyID)
jobs, err := pipeline.StartJob(ctx, companyID, userID, rawIDs, "full")
if err != nil {
log.Fatalf("StartJob: %v", err)
}
if len(jobs) == 0 {
log.Fatal("StartJob returned no jobs")
}
jobID := jobs[0].ID
log.Printf("job_id=%s chunks=%d", jobID, len(jobs))
_ = os.WriteFile(filepath.Join(out, "process_id.txt"), []byte(jobID.String()+"\n"), 0o644)
defend := make(chan struct{})
go func() {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-defend:
return
case <-ctx.Done():
return
case <-t.C:
_, _ = pool.Exec(context.Background(), `
UPDATE processing_jobs
SET status = 'failed', error = 'yielded to live enhance examples batch A', updated_at = now()
WHERE company_id = $1 AND status IN ('pending','running','processing') AND id <> $2`, companyID, jobID)
_, _ = pool.Exec(context.Background(), `
UPDATE processing_jobs
SET status = 'running', error = NULL, updated_at = now()
WHERE id = $1 AND status IN ('failed','cancelled')
AND (error ILIKE '%yield%' OR error ILIKE '%parked%' OR error ILIKE '%reclaim%'
OR error ILIKE '%batch_%' OR error ILIKE '%enhance examples%' OR error ILIKE '%formula%')`, jobID)
_, _ = pool.Exec(context.Background(), `
UPDATE processing_job_products
SET status = 'pending', error = NULL, updated_at = now()
WHERE job_id = $1 AND status IN ('failed','cancelled')
AND processed_product_id IS NULL
AND (error ILIKE '%yield%' OR error ILIKE '%parked%' OR error ILIKE '%reclaim%'
OR error ILIKE '%batch_%' OR error ILIKE '%enhance examples%' OR error ILIKE '%formula%')`, jobID)
}
}
}()
_, _ = pool.Exec(ctx, `
UPDATE processing_jobs
SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()
WHERE id = $1`, jobID)
runCtx, runCancel := context.WithTimeout(ctx, 25*time.Minute)
defer runCancel()
if err := pipeline.ProcessJob(runCtx, jobID); err != nil {
close(defend)
log.Fatalf("ProcessJob: %v", err)
}
close(defend)
items, err := pipeline.LoadV1ProcessJobItems(ctx, companyID, jobID, "full")
if err != nil {
log.Fatalf("LoadV1ProcessJobItems: %v", err)
}
writeJSON(out, "final.json", map[string]any{
"data": map[string]any{
"process_id": jobID.String(),
"status": "COMPLETED",
"processing_type": "full",
"total_items": len(items),
"items": items,
"llm": map[string]any{
"base": base,
"model": model,
"source": roleCfg.Source,
"mode": modeLabel,
"byok": byok,
},
},
})
rows, err := pool.Query(ctx, `
SELECT rp.gtin,
COALESCE(pp.processed_name, pp.name, ''),
COALESCE(NULLIF(pp.processed_description, ''), pp.description, ''),
COALESCE(pp.category, ''),
COALESCE(c.name, ''),
COALESCE(pp.meta_title, ''),
COALESCE(pp.meta_description, ''),
COALESCE(pp.processed_attributes, pp.attributes, '{}'::jsonb),
COALESCE(pp.ai_provider_mode, ''),
c.description_template,
c.title_template,
pp.id,
COALESCE(pp.field_sources, '{}'::jsonb)
FROM processing_job_products pjp
JOIN raw_products rp ON rp.id = pjp.raw_product_id
LEFT JOIN processed_products pp ON pp.id = pjp.processed_product_id
LEFT JOIN categories c ON c.company_id = pp.company_id AND c.unique_id = pp.category
WHERE pjp.job_id = $1
ORDER BY rp.gtin`, jobID)
if err != nil {
log.Fatalf("db products: %v", err)
}
defer rows.Close()
dbProducts := make([]dbProduct, 0)
for rows.Next() {
var p dbProduct
var ppID *uuid.UUID
var attrs, sources []byte
if err := rows.Scan(&p.EAN, &p.Title, &p.Description, &p.Category, &p.CategoryName,
&p.MetaTitle, &p.MetaDescription, &attrs, &p.AIProviderMode,
&p.DescTemplate, &p.TitleTemplate, &ppID, &sources); err != nil {
log.Fatal(err)
}
if ppID != nil {
p.ProductID = ppID.String()
}
p.Label = eanLabels[p.EAN]
p.Attributes = attrs
p.FieldSources = sources
p.FormulaConstraint = processing.FormatDescriptionFormulaConstraint(p.DescTemplate)
dbProducts = append(dbProducts, p)
}
writeJSON(out, "db_products.json", dbProducts)
scoreAndWrite(out, jobID.String(), resolveInfo, dbProducts)
fmt.Printf("BATCH_A JOB %s products=%d live_base=%s model=%s\n", jobID, len(dbProducts), base, model)
}
func waitForeignJobs(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) {
deadline := time.Now().Add(18 * time.Minute)
for time.Now().Before(deadline) {
var n int
err := pool.QueryRow(ctx, `
SELECT count(*) FROM processing_jobs
WHERE company_id = $1 AND status IN ('pending','running','processing')`, companyID).Scan(&n)
if err != nil {
log.Printf("wait foreign jobs: %v", err)
return
}
if n == 0 {
log.Printf("queue clear — starting batch A")
return
}
log.Printf("waiting for %d foreign Demo job(s) before batch A…", n)
select {
case <-ctx.Done():
return
case <-time.After(15 * time.Second):
}
}
log.Printf("wait deadline reached — proceeding (will yield foreign jobs)")
}
func scoreAndWrite(out, jobID string, resolve map[string]any, products []dbProduct) {
scored := make([]map[string]any, 0, len(products))
pass, fail := 0, 0
var md strings.Builder
md.WriteString("# A1 enhance examples — batch A (rich category formulas; includes Slusalke/48)\n\n")
md.WriteString(fmt.Sprintf("- scored_at_utc: `%s`\n", time.Now().UTC().Format(time.RFC3339)))
md.WriteString(fmt.Sprintf("- job_id: `%s`\n", jobID))
md.WriteString(fmt.Sprintf("- live_base: `%v` model: `%v` source: `%v`\n", resolve["completer_base"], resolve["completer_model"], resolve["role_source"]))
md.WriteString(fmt.Sprintf("- mock_fallback: **false** (env mock ignored when DB admin AI resolves)\n\n"))
md.WriteString("Scoring: title quality · HTML formula tags (h1/h2/p/ul) · meta · attrs\n\n")
for _, p := range products {
title := strings.TrimSpace(p.Title)
desc := p.Description
metaT := strings.TrimSpace(p.MetaTitle)
metaD := strings.TrimSpace(p.MetaDescription)
has := map[string]bool{
"h1": strings.Contains(strings.ToLower(desc), "<h1"),
"h2": strings.Contains(strings.ToLower(desc), "<h2"),
"p": strings.Contains(strings.ToLower(desc), "<p"),
"ul": strings.Contains(strings.ToLower(desc), "<ul"),
}
htmlOK := has["h1"] && has["h2"] && has["p"] && has["ul"]
titleOK := title != "" && utf8.RuneCountInString(title) >= 8 && !brandOnlyish.MatchString(title)
titleNotes := []string{}
if title == "" {
titleNotes = append(titleNotes, "empty")
} else if brandOnlyish.MatchString(title) {
titleNotes = append(titleNotes, "brand_only")
} else if utf8.RuneCountInString(title) < 8 {
titleNotes = append(titleNotes, "too_short")
}
if strings.Contains(strings.ToLower(title), "title formula") || strings.Contains(strings.ToLower(title), "category:") {
titleOK = false
titleNotes = append(titleNotes, "prompt_leakage")
}
metaOK := metaT != "" && metaD != "" && !poisonMeta.MatchString(metaT) &&
!strings.Contains(strings.ToLower(metaT), "short retail title")
metaNotes := []string{}
if metaT == "" {
metaNotes = append(metaNotes, "empty_meta_title")
}
if metaD == "" {
metaNotes = append(metaNotes, "empty_meta_description")
}
if poisonMeta.MatchString(metaT) {
metaNotes = append(metaNotes, "poison_|digits")
}
attrMap := map[string]any{}
_ = json.Unmarshal(p.Attributes, &attrMap)
attrKeys := make([]string, 0, len(attrMap))
dirty := []string{}
for k, v := range attrMap {
attrKeys = append(attrKeys, k)
sv := strings.TrimSpace(fmt.Sprint(v))
if sv == "" || strings.EqualFold(sv, "null") || strings.EqualFold(sv, "n/a") {
dirty = append(dirty, k+":empty")
}
if strings.Contains(strings.ToLower(k), "ean") || strings.Contains(strings.ToLower(k), "gtin") {
dirty = append(dirty, k+":id_like_key")
}
}
attrsOK := len(attrKeys) > 0 && len(dirty) == 0
checks := map[string]string{
"title": passFail(titleOK),
"html_tags": passFail(htmlOK),
"meta": passFail(metaOK),
"attrs": passFail(attrsOK),
"category": passFail(p.Category != "" && p.CategoryName != ""),
"desc_ne_title": passFail(strings.TrimSpace(stripTags(desc)) != "" && strings.TrimSpace(stripTags(desc)) != title),
"ai_internal": passFail(strings.EqualFold(p.AIProviderMode, "internal") || p.AIProviderMode == ""),
}
failFlags := []string{}
for k, v := range checks {
if v == "FAIL" {
failFlags = append(failFlags, k)
}
}
verdict := "PASS"
if len(failFlags) > 0 {
verdict = "FAIL"
fail++
} else {
pass++
}
row := map[string]any{
"ean": p.EAN,
"label": p.Label,
"product_id": p.ProductID,
"title": title,
"category": p.Category,
"category_name": p.CategoryName,
"desc_len": len(desc),
"desc_preview": truncate(stripTags(desc), 140),
"meta_title": metaT,
"meta_description": truncate(metaD, 160),
"attr_keys": attrKeys,
"dirty_attrs": dirty,
"attributes": attrMap,
"has_tags": has,
"formula_constraint": p.FormulaConstraint,
"formula_override_hint": strings.Contains(strings.ToLower(fmt.Sprint(p.FieldSources)), "formula"),
"ai_provider_mode": p.AIProviderMode,
"title_notes": titleNotes,
"meta_notes": metaNotes,
"checks": checks,
"fail_flags": failFlags,
"verdict": verdict,
}
scored = append(scored, row)
md.WriteString(fmt.Sprintf("## %s — %s (`%s`)\n\n", p.EAN, p.Label, verdict))
md.WriteString(fmt.Sprintf("- **title:** %s\n", title))
md.WriteString(fmt.Sprintf("- **category:** %s / %s\n", p.Category, p.CategoryName))
md.WriteString(fmt.Sprintf("- **meta_title:** %s\n", metaT))
md.WriteString(fmt.Sprintf("- **meta_description:** %s\n", truncate(metaD, 200)))
md.WriteString(fmt.Sprintf("- **attrs:** %s\n", strings.Join(attrKeys, ", ")))
md.WriteString(fmt.Sprintf("- **HTML tags:** h1=%v h2=%v p=%v ul=%v\n", has["h1"], has["h2"], has["p"], has["ul"]))
md.WriteString(fmt.Sprintf("- **checks:** title=%s html=%s meta=%s attrs=%s\n\n", checks["title"], checks["html_tags"], checks["meta"], checks["attrs"]))
md.WriteString("### Description HTML (raw)\n\n```html\n")
if len(desc) > 4096 {
md.WriteString(desc[:4096])
md.WriteString("\n<!-- truncated -->\n")
} else {
md.WriteString(desc)
md.WriteString("\n")
}
md.WriteString("```\n\n")
}
total := len(products)
rate := 0
if total > 0 {
rate = (pass * 100) / total
}
scorecard := map[string]any{
"batch": "A",
"date": "2026-08-16",
"scored_at_utc": time.Now().UTC().Format(time.RFC3339),
"company_id": "2b3159b0-fc08-415b-b248-35ed02a6baab",
"company_name": "Platform Demo",
"job_id": jobID,
"selection": eanLabels,
"live_llm": resolve,
"mock_as_live": false,
"asserts": []string{"title_quality", "html_h1_h2_p_ul", "meta", "attrs", "category", "desc_ne_title"},
"summary": map[string]any{"pass": pass, "fail": fail, "total": total, "pass_rate": rate},
"verdict": map[bool]string{true: "PASS", false: "FAIL"}[fail == 0 && total > 0],
"products": scored,
"max_tokens_note": fmt.Sprintf("MaxTokensEnhance=%d MaxTokensEnhanceRetry=%d", processing.MaxTokensEnhance, processing.MaxTokensEnhanceRetry),
}
writeJSON(out, "scorecard.json", scorecard)
_ = os.WriteFile(filepath.Join(out, "examples.md"), []byte(md.String()), 0o644)
summary := fmt.Sprintf("# Batch A summary\n\n- verdict: **%s** (%d/%d)\n- live: `%v` / `%v`\n- mock_as_live: false\n- artifacts: scorecard.json, examples.md, db_products.json, final.json, resolve.json\n",
scorecard["verdict"], pass, total, resolve["completer_base"], resolve["completer_model"])
_ = os.WriteFile(filepath.Join(out, "summary.md"), []byte(summary), 0o644)
}
func passFail(ok bool) string {
if ok {
return "PASS"
}
return "FAIL"
}
func stripTags(s string) string {
re := regexp.MustCompile(`<[^>]+>`)
return strings.TrimSpace(re.ReplaceAllString(s, " "))
}
func truncate(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
func failResolve(out, msg string) {
writeJSON(out, "resolve.json", map[string]any{"live": false, "fail_reason": msg, "batch": "A"})
log.Fatalf("LIVE LLM REQUIRED: %s", msg)
}
func writeJSON(out, name string, v any) {
raw, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("marshal %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(out, name), raw, 0o644); err != nil {
log.Fatalf("write %s: %v", name, err)
}
}
@@ -0,0 +1,664 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"unicode/utf8"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
"github.com/descrybe/descrybe-v2/apps/api/internal/config"
"github.com/descrybe/descrybe-v2/apps/api/internal/db"
"github.com/descrybe/descrybe-v2/apps/api/internal/eprel"
"github.com/descrybe/descrybe-v2/apps/api/internal/logredact"
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// Batch B: appliances + monitors + mounts — independent of batch A.
var eans = []string{
"4242005342488", // 41 washer-dryer
"4242005382330", // 38 dishwasher
"4242005327065", // 36 oven
"4719331854218", // 7 monitor GIGABYTE
"6941948703193", // 7 monitor Xiaomi
"8712285326882", // 28 TV mount
}
var eanLabels = map[string]string{
"4242005342488": "appliance washer-dryer",
"4242005382330": "appliance dishwasher",
"4242005327065": "appliance oven",
"4719331854218": "monitor GIGABYTE",
"6941948703193": "monitor Xiaomi",
"8712285326882": "mount Vogels",
}
var poisonMeta = regexp.MustCompile(`\|\s*\d+\s*$`)
var brandOnlyish = regexp.MustCompile(`(?i)^(bosch|gorenje|gigabyte|xiaomi|lenovo|samsung|lg|anker|jbl|vogels|ostalo)$`)
type dbProduct struct {
EAN string `json:"ean"`
Label string `json:"label"`
ProductID string `json:"product_id"`
Title string `json:"title"`
Description string `json:"description"`
Category string `json:"category"`
CategoryName string `json:"category_name"`
MetaTitle string `json:"meta_title"`
MetaDescription string `json:"meta_description"`
Attributes json.RawMessage `json:"attributes"`
AIProviderMode string `json:"ai_provider_mode"`
DescTemplate any `json:"description_template"`
TitleTemplate any `json:"title_template"`
FormulaConstraint string `json:"formula_constraint"`
FieldSources json.RawMessage `json:"field_sources"`
}
func main() {
out := os.Getenv("PROBE_OUT_DIR")
if out == "" {
out = `F:\laragon\www\_MY\descrybe-v2\.codehelper\_a1_enhance_examples_B_20260816`
}
_ = os.MkdirAll(out, 0o755)
logPath := filepath.Join(out, "inproc_run.log")
logFile, err := os.Create(logPath)
if err != nil {
log.Fatalf("log file: %v", err)
}
defer logFile.Close()
log.SetOutput(logredact.Writer(io.MultiWriter(os.Stderr, logFile)))
companyID := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab")
userID := uuid.MustParse("0ce3305c-d810-4b56-b1d4-3c1ed510db76")
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
pool, err := db.NewPool(ctx, cfg.DatabaseURL, db.PoolOptions{
MaxConns: int32(cfg.DBMaxConns),
MinConns: int32(cfg.DBMinConns),
MaxConnLifetime: cfg.DBMaxConnLifetime,
MaxConnLifetimeJitter: cfg.DBMaxConnLifetimeJitter,
MaxConnIdleTime: cfg.DBMaxConnIdleTime,
HealthCheckPeriod: cfg.DBHealthCheckPeriod,
StatementTimeout: cfg.DBStatementTimeout,
})
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
waitForeignJobs(ctx, pool, companyID)
platEnv := platformsettings.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
OpenAIEmbeddingAPIKey: cfg.OpenAIEmbeddingAPIKey,
OpenAIEmbeddingBaseURL: cfg.OpenAIEmbeddingBaseURL,
OpenAIEmbeddingModel: cfg.OpenAIEmbeddingModel,
}
platSettings := platformsettings.NewService(pool, platEnv)
aiSvc := aiprovider.NewService(pool, aiprovider.EnvConfig{
AppEncryptionKey: cfg.AppEncryptionKey,
CredentialsEncryptionKey: cfg.CredentialsEncryptionKey,
TokenSigningSecret: cfg.TokenSigningSecret,
DatabaseURL: cfg.DatabaseURL,
OpenAIAPIKey: cfg.OpenAIAPIKey,
OpenAIBaseURL: cfg.OpenAIBaseURL,
OpenAIModel: cfg.OpenAIModel,
ProcessingRPM: cfg.ProcessingRPM,
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
})
aiSvc.Platform = platSettings
roleCfg, rerr := platSettings.ResolveAIConfig(ctx, platformsettings.AIRoleProcessing)
if rerr != nil {
failResolve(out, fmt.Sprintf("ResolveAIConfig: %v", rerr))
}
completer, modeLabel, byok, cerr := aiSvc.ResolveCompleterForRole(ctx, companyID, aiprovider.RoleProcessing)
if cerr != nil {
failResolve(out, fmt.Sprintf("ResolveCompleterForRole: %v", cerr))
}
if completer == nil {
failResolve(out, "nil completer")
}
client, ok := completer.(*processing.OpenAIClient)
if !ok {
failResolve(out, fmt.Sprintf("completer type %T", completer))
}
base := strings.TrimSpace(client.BaseURL)
model := strings.TrimSpace(client.Model)
resolveInfo := map[string]any{
"batch": "B",
"eans": eans,
"role_source": roleCfg.Source,
"role_provider": roleCfg.Provider,
"role_base_url": roleCfg.BaseURL,
"role_model": roleCfg.Model,
"role_enabled": roleCfg.Enabled,
"completer_base": base,
"completer_model": model,
"mode_label": modeLabel,
"byok": byok,
"env_base_url": cfg.OpenAIBaseURL,
"env_model": cfg.OpenAIModel,
"live": true,
}
if processing.IsMockOrLoopbackBaseURL(base) || strings.Contains(strings.ToLower(base), "18767") {
resolveInfo["live"] = false
resolveInfo["fail_reason"] = "resolved completer is mock/loopback — refuse silent mock fallback"
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM REQUIRED: resolved base=%s model=%s — refuse mock", base, model)
}
if strings.TrimSpace(client.APIKey) == "" {
resolveInfo["live"] = false
resolveInfo["fail_reason"] = "empty API key"
writeJSON(out, "resolve.json", resolveInfo)
log.Fatal("LIVE LLM REQUIRED: API key empty")
}
keyHint := ""
if k := strings.TrimSpace(client.APIKey); len(k) > 8 {
keyHint = k[:4] + "…" + k[len(k)-4:]
}
resolveInfo["api_key_hint"] = keyHint
modelsURL := strings.TrimRight(base, "/") + "/models"
modelsCtx, modelsCancel := context.WithTimeout(ctx, 30*time.Second)
defer modelsCancel()
modelsReq, merr := http.NewRequestWithContext(modelsCtx, http.MethodGet, modelsURL, nil)
if merr != nil {
failResolve(out, "models req: "+merr.Error())
}
modelsReq.Header.Set("Authorization", "Bearer "+client.APIKey)
modelsReq.Header.Set("Accept", "application/json")
modelsStart := time.Now()
modelsResp, merr := http.DefaultClient.Do(modelsReq)
modelsElapsed := time.Since(modelsStart).Round(time.Millisecond)
if merr != nil {
resolveInfo["probe_ok"] = false
resolveInfo["models_error"] = processing.TruncateError(merr)
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: GET models: %v", merr)
}
modelsBody, _ := io.ReadAll(io.LimitReader(modelsResp.Body, 2048))
_ = modelsResp.Body.Close()
resolveInfo["models_url"] = modelsURL
resolveInfo["models_http_status"] = modelsResp.StatusCode
resolveInfo["models_elapsed"] = modelsElapsed.String()
snippet := strings.TrimSpace(string(modelsBody))
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
resolveInfo["models_body_snippet"] = snippet
if modelsResp.StatusCode != http.StatusOK {
resolveInfo["probe_ok"] = false
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: models HTTP %d", modelsResp.StatusCode)
}
probeCtx, probeCancel := context.WithTimeout(ctx, 3*time.Minute)
defer probeCancel()
probeStart := time.Now()
comp, perr := client.Complete(probeCtx, "Reply with exactly: PONG", "Say PONG")
probeElapsed := time.Since(probeStart).Round(time.Millisecond)
if perr != nil {
// One soft retry — OverloadedBot often needs a cool-down after a prior batch.
log.Printf("chat probe first attempt failed elapsed=%s err=%s — retrying once after 20s", probeElapsed, processing.TruncateError(perr))
select {
case <-ctx.Done():
resolveInfo["probe_ok"] = false
resolveInfo["probe_error"] = processing.TruncateError(perr)
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: chat probe: %v", perr)
case <-time.After(20 * time.Second):
}
probeCtx2, probeCancel2 := context.WithTimeout(ctx, 3*time.Minute)
defer probeCancel2()
probeStart = time.Now()
comp, perr = client.Complete(probeCtx2, "Reply with exactly: PONG", "Say PONG")
probeElapsed = time.Since(probeStart).Round(time.Millisecond)
if perr != nil {
resolveInfo["probe_ok"] = false
resolveInfo["probe_error"] = processing.TruncateError(perr)
resolveInfo["models_http_status"] = modelsResp.StatusCode
writeJSON(out, "resolve.json", resolveInfo)
log.Fatalf("LIVE LLM CONNECTION ERROR: chat probe (after retry): %v", perr)
}
}
resolveInfo["probe_ok"] = true
resolveInfo["probe_elapsed"] = probeElapsed.String()
resolveInfo["probe_response_len"] = len(comp.Text)
resolveInfo["started_at_utc"] = time.Now().UTC().Format(time.RFC3339)
writeJSON(out, "resolve.json", resolveInfo)
log.Printf("live resolve ok source=%s base=%s model=%s mode=%s byok=%v", roleCfg.Source, base, model, modeLabel, byok)
pipeline := processing.NewPipeline(pool)
pipeline.BatchSize = cfg.ProcessingBatchSize
pipeline.AI = aiSvc
pipeline.Prompts = aiprompts.NewService(pool)
eprelClient := eprel.NewClient(eprel.Options{Enabled: true, Timeout: 20 * time.Second})
pipeline.Engine = &processing.Engine{
Completer: nil,
Vector: processing.NoopVectorCategorizer{},
EPREL: eprelClient,
ProviderMode: processing.AIProviderInternal,
}
rawIDs := make([]uuid.UUID, 0, len(eans))
for _, ean := range eans {
var id uuid.UUID
if err := pool.QueryRow(ctx, `
SELECT id FROM raw_products
WHERE company_id = $1 AND gtin = $2`, companyID, ean).Scan(&id); err != nil {
log.Fatalf("raw product %s: %v", ean, err)
}
rawIDs = append(rawIDs, id)
log.Printf("ean=%s raw_id=%s label=%s", ean, id, eanLabels[ean])
}
tag, cerr2 := pool.Exec(ctx, `
UPDATE processed_products pp
SET field_sources = COALESCE(field_sources, '{}'::jsonb) - 'enhance_input_hash',
localized_content = CASE
WHEN localized_content IS NULL OR localized_content = '{}'::jsonb THEN localized_content
ELSE (
SELECT COALESCE(jsonb_object_agg(lang, val - 'enhance_input_hash'), '{}'::jsonb)
FROM jsonb_each(localized_content) AS t(lang, val)
)
END,
updated_at = now()
FROM raw_products rp
WHERE pp.company_id = $1
AND pp.raw_product_id = rp.id
AND rp.gtin = ANY($2::text[])`, companyID, eans)
if cerr2 != nil {
log.Printf("clear enhance hash: %v", cerr2)
} else {
log.Printf("cleared enhance_input_hash rows=%d", tag.RowsAffected())
}
if n, err := processing.BackfillMissingMeta(ctx, pool, companyID); err != nil {
log.Printf("BackfillMissingMeta: %v", err)
} else {
log.Printf("BackfillMissingMeta updated=%d", n)
}
_, _ = pool.Exec(ctx, `
UPDATE processing_jobs
SET status = 'failed', error = 'yielded to live enhance examples batch B', updated_at = now()
WHERE company_id = $1 AND status IN ('pending','running','processing')`, companyID)
jobs, err := pipeline.StartJob(ctx, companyID, userID, rawIDs, "full")
if err != nil {
log.Fatalf("StartJob: %v", err)
}
if len(jobs) == 0 {
log.Fatal("StartJob returned no jobs")
}
jobID := jobs[0].ID
log.Printf("job_id=%s chunks=%d", jobID, len(jobs))
_ = os.WriteFile(filepath.Join(out, "process_id.txt"), []byte(jobID.String()+"\n"), 0o644)
defend := make(chan struct{})
go func() {
t := time.NewTicker(500 * time.Millisecond)
defer t.Stop()
for {
select {
case <-defend:
return
case <-ctx.Done():
return
case <-t.C:
_, _ = pool.Exec(context.Background(), `
UPDATE processing_jobs
SET status = 'failed', error = 'yielded to live enhance examples batch B', updated_at = now()
WHERE company_id = $1 AND status IN ('pending','running','processing') AND id <> $2`, companyID, jobID)
_, _ = pool.Exec(context.Background(), `
UPDATE processing_jobs
SET status = 'running', error = NULL, updated_at = now()
WHERE id = $1 AND status IN ('failed','cancelled')
AND (error ILIKE '%yield%' OR error ILIKE '%parked%' OR error ILIKE '%reclaim%'
OR error ILIKE '%batch_%' OR error ILIKE '%enhance examples%' OR error ILIKE '%formula%')`, jobID)
_, _ = pool.Exec(context.Background(), `
UPDATE processing_job_products
SET status = 'pending', error = NULL, updated_at = now()
WHERE job_id = $1 AND status IN ('failed','cancelled')
AND processed_product_id IS NULL
AND (error ILIKE '%yield%' OR error ILIKE '%parked%' OR error ILIKE '%reclaim%'
OR error ILIKE '%batch_%' OR error ILIKE '%enhance examples%' OR error ILIKE '%formula%')`, jobID)
}
}
}()
_, _ = pool.Exec(ctx, `
UPDATE processing_jobs
SET status = 'running', started_at = COALESCE(started_at, now()), updated_at = now()
WHERE id = $1`, jobID)
runCtx, runCancel := context.WithTimeout(ctx, 25*time.Minute)
defer runCancel()
if err := pipeline.ProcessJob(runCtx, jobID); err != nil {
close(defend)
log.Fatalf("ProcessJob: %v", err)
}
close(defend)
items, err := pipeline.LoadV1ProcessJobItems(ctx, companyID, jobID, "full")
if err != nil {
log.Fatalf("LoadV1ProcessJobItems: %v", err)
}
writeJSON(out, "final.json", map[string]any{
"data": map[string]any{
"process_id": jobID.String(),
"status": "COMPLETED",
"processing_type": "full",
"total_items": len(items),
"items": items,
"llm": map[string]any{
"base": base,
"model": model,
"source": roleCfg.Source,
"mode": modeLabel,
"byok": byok,
},
},
})
rows, err := pool.Query(ctx, `
SELECT rp.gtin,
COALESCE(pp.processed_name, pp.name, ''),
COALESCE(NULLIF(pp.processed_description, ''), pp.description, ''),
COALESCE(pp.category, ''),
COALESCE(c.name, ''),
COALESCE(pp.meta_title, ''),
COALESCE(pp.meta_description, ''),
COALESCE(pp.processed_attributes, pp.attributes, '{}'::jsonb),
COALESCE(pp.ai_provider_mode, ''),
c.description_template,
c.title_template,
pp.id,
COALESCE(pp.field_sources, '{}'::jsonb)
FROM processing_job_products pjp
JOIN raw_products rp ON rp.id = pjp.raw_product_id
LEFT JOIN processed_products pp ON pp.id = pjp.processed_product_id
LEFT JOIN categories c ON c.company_id = pp.company_id AND c.unique_id = pp.category
WHERE pjp.job_id = $1
ORDER BY rp.gtin`, jobID)
if err != nil {
log.Fatalf("db products: %v", err)
}
defer rows.Close()
dbProducts := make([]dbProduct, 0)
for rows.Next() {
var p dbProduct
var ppID *uuid.UUID
var attrs, sources []byte
if err := rows.Scan(&p.EAN, &p.Title, &p.Description, &p.Category, &p.CategoryName,
&p.MetaTitle, &p.MetaDescription, &attrs, &p.AIProviderMode,
&p.DescTemplate, &p.TitleTemplate, &ppID, &sources); err != nil {
log.Fatal(err)
}
if ppID != nil {
p.ProductID = ppID.String()
}
p.Label = eanLabels[p.EAN]
p.Attributes = attrs
p.FieldSources = sources
p.FormulaConstraint = processing.FormatDescriptionFormulaConstraint(p.DescTemplate)
dbProducts = append(dbProducts, p)
}
writeJSON(out, "db_products.json", dbProducts)
scoreAndWrite(out, jobID.String(), resolveInfo, dbProducts)
fmt.Printf("BATCH_B JOB %s products=%d live_base=%s model=%s\n", jobID, len(dbProducts), base, model)
}
func waitForeignJobs(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) {
deadline := time.Now().Add(18 * time.Minute)
for time.Now().Before(deadline) {
var n int
err := pool.QueryRow(ctx, `
SELECT count(*) FROM processing_jobs
WHERE company_id = $1 AND status IN ('pending','running','processing')`, companyID).Scan(&n)
if err != nil {
log.Printf("wait foreign jobs: %v", err)
return
}
if n == 0 {
log.Printf("queue clear — starting batch B")
return
}
log.Printf("waiting for %d foreign Demo job(s) before batch B…", n)
select {
case <-ctx.Done():
return
case <-time.After(15 * time.Second):
}
}
log.Printf("wait deadline reached — proceeding (will yield foreign jobs)")
}
func scoreAndWrite(out, jobID string, resolve map[string]any, products []dbProduct) {
scored := make([]map[string]any, 0, len(products))
pass, fail := 0, 0
var md strings.Builder
md.WriteString("# A1 enhance examples — batch B (appliances / monitors / mounts)\n\n")
md.WriteString(fmt.Sprintf("- scored_at_utc: `%s`\n", time.Now().UTC().Format(time.RFC3339)))
md.WriteString(fmt.Sprintf("- job_id: `%s`\n", jobID))
md.WriteString(fmt.Sprintf("- live_base: `%v` model: `%v` source: `%v`\n", resolve["completer_base"], resolve["completer_model"], resolve["role_source"]))
md.WriteString(fmt.Sprintf("- mock_fallback: **false** (env mock ignored when DB admin AI resolves)\n\n"))
md.WriteString("Scoring: title quality · HTML formula tags (h1/h2/p/ul) · meta · attrs\n\n")
for _, p := range products {
title := strings.TrimSpace(p.Title)
desc := p.Description
metaT := strings.TrimSpace(p.MetaTitle)
metaD := strings.TrimSpace(p.MetaDescription)
has := map[string]bool{
"h1": strings.Contains(strings.ToLower(desc), "<h1"),
"h2": strings.Contains(strings.ToLower(desc), "<h2"),
"p": strings.Contains(strings.ToLower(desc), "<p"),
"ul": strings.Contains(strings.ToLower(desc), "<ul"),
}
htmlOK := has["h1"] && has["h2"] && has["p"] && has["ul"]
titleOK := title != "" && utf8.RuneCountInString(title) >= 8 && !brandOnlyish.MatchString(title)
titleNotes := []string{}
if title == "" {
titleNotes = append(titleNotes, "empty")
} else if brandOnlyish.MatchString(title) {
titleNotes = append(titleNotes, "brand_only")
} else if utf8.RuneCountInString(title) < 8 {
titleNotes = append(titleNotes, "too_short")
}
if strings.Contains(strings.ToLower(title), "title formula") || strings.Contains(strings.ToLower(title), "category:") {
titleOK = false
titleNotes = append(titleNotes, "prompt_leakage")
}
metaOK := metaT != "" && metaD != "" && !poisonMeta.MatchString(metaT) &&
!strings.Contains(strings.ToLower(metaT), "short retail title")
metaNotes := []string{}
if metaT == "" {
metaNotes = append(metaNotes, "empty_meta_title")
}
if metaD == "" {
metaNotes = append(metaNotes, "empty_meta_description")
}
if poisonMeta.MatchString(metaT) {
metaNotes = append(metaNotes, "poison_|digits")
}
attrMap := map[string]any{}
_ = json.Unmarshal(p.Attributes, &attrMap)
attrKeys := make([]string, 0, len(attrMap))
dirty := []string{}
for k, v := range attrMap {
attrKeys = append(attrKeys, k)
sv := strings.TrimSpace(fmt.Sprint(v))
if sv == "" || strings.EqualFold(sv, "null") || strings.EqualFold(sv, "n/a") {
dirty = append(dirty, k+":empty")
}
if strings.Contains(strings.ToLower(k), "ean") || strings.Contains(strings.ToLower(k), "gtin") {
dirty = append(dirty, k+":id_like_key")
}
}
attrsOK := len(attrKeys) > 0 && len(dirty) == 0
checks := map[string]string{
"title": passFail(titleOK),
"html_tags": passFail(htmlOK),
"meta": passFail(metaOK),
"attrs": passFail(attrsOK),
"category": passFail(p.Category != "" && p.CategoryName != ""),
"desc_ne_title": passFail(strings.TrimSpace(stripTags(desc)) != "" && strings.TrimSpace(stripTags(desc)) != title),
"ai_internal": passFail(strings.EqualFold(p.AIProviderMode, "internal") || p.AIProviderMode == ""),
}
failFlags := []string{}
for k, v := range checks {
if v == "FAIL" {
failFlags = append(failFlags, k)
}
}
verdict := "PASS"
if len(failFlags) > 0 {
verdict = "FAIL"
fail++
} else {
pass++
}
row := map[string]any{
"ean": p.EAN,
"label": p.Label,
"product_id": p.ProductID,
"title": title,
"category": p.Category,
"category_name": p.CategoryName,
"desc_len": len(desc),
"desc_preview": truncate(stripTags(desc), 140),
"meta_title": metaT,
"meta_description": truncate(metaD, 160),
"attr_keys": attrKeys,
"dirty_attrs": dirty,
"attributes": attrMap,
"has_tags": has,
"formula_constraint": p.FormulaConstraint,
"formula_override_hint": strings.Contains(strings.ToLower(fmt.Sprint(p.FieldSources)), "formula"),
"ai_provider_mode": p.AIProviderMode,
"title_notes": titleNotes,
"meta_notes": metaNotes,
"checks": checks,
"fail_flags": failFlags,
"verdict": verdict,
}
scored = append(scored, row)
md.WriteString(fmt.Sprintf("## %s — %s (`%s`)\n\n", p.EAN, p.Label, verdict))
md.WriteString(fmt.Sprintf("- **title:** %s\n", title))
md.WriteString(fmt.Sprintf("- **category:** %s / %s\n", p.Category, p.CategoryName))
md.WriteString(fmt.Sprintf("- **meta_title:** %s\n", metaT))
md.WriteString(fmt.Sprintf("- **meta_description:** %s\n", truncate(metaD, 200)))
md.WriteString(fmt.Sprintf("- **attrs:** %s\n", strings.Join(attrKeys, ", ")))
md.WriteString(fmt.Sprintf("- **HTML tags:** h1=%v h2=%v p=%v ul=%v\n", has["h1"], has["h2"], has["p"], has["ul"]))
md.WriteString(fmt.Sprintf("- **checks:** title=%s html=%s meta=%s attrs=%s\n\n", checks["title"], checks["html_tags"], checks["meta"], checks["attrs"]))
md.WriteString("### Description HTML (raw)\n\n```html\n")
if len(desc) > 4096 {
md.WriteString(desc[:4096])
md.WriteString("\n<!-- truncated -->\n")
} else {
md.WriteString(desc)
md.WriteString("\n")
}
md.WriteString("```\n\n")
}
total := len(products)
rate := 0
if total > 0 {
rate = (pass * 100) / total
}
scorecard := map[string]any{
"batch": "B",
"date": "2026-08-16",
"scored_at_utc": time.Now().UTC().Format(time.RFC3339),
"company_id": "2b3159b0-fc08-415b-b248-35ed02a6baab",
"company_name": "Platform Demo",
"job_id": jobID,
"selection": eanLabels,
"live_llm": resolve,
"mock_as_live": false,
"asserts": []string{"title_quality", "html_h1_h2_p_ul", "meta", "attrs", "category", "desc_ne_title"},
"summary": map[string]any{"pass": pass, "fail": fail, "total": total, "pass_rate": rate},
"verdict": map[bool]string{true: "PASS", false: "FAIL"}[fail == 0 && total > 0],
"products": scored,
"max_tokens_note": fmt.Sprintf("MaxTokensEnhance=%d MaxTokensEnhanceRetry=%d", processing.MaxTokensEnhance, processing.MaxTokensEnhanceRetry),
}
writeJSON(out, "scorecard.json", scorecard)
_ = os.WriteFile(filepath.Join(out, "examples.md"), []byte(md.String()), 0o644)
summary := fmt.Sprintf("# Batch B summary\n\n- verdict: **%s** (%d/%d)\n- live: `%v` / `%v`\n- mock_as_live: false\n- artifacts: scorecard.json, examples.md, db_products.json, final.json, resolve.json\n",
scorecard["verdict"], pass, total, resolve["completer_base"], resolve["completer_model"])
_ = os.WriteFile(filepath.Join(out, "summary.md"), []byte(summary), 0o644)
}
func passFail(ok bool) string {
if ok {
return "PASS"
}
return "FAIL"
}
func stripTags(s string) string {
re := regexp.MustCompile(`<[^>]+>`)
return strings.TrimSpace(re.ReplaceAllString(s, " "))
}
func truncate(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n]) + "…"
}
func failResolve(out, msg string) {
writeJSON(out, "resolve.json", map[string]any{"live": false, "fail_reason": msg, "batch": "B"})
log.Fatalf("LIVE LLM REQUIRED: %s", msg)
}
func writeJSON(out, name string, v any) {
raw, err := json.MarshalIndent(v, "", " ")
if err != nil {
log.Fatalf("marshal %s: %v", name, err)
}
if err := os.WriteFile(filepath.Join(out, name), raw, 0o644); err != nil {
log.Fatalf("write %s: %v", name, err)
}
}
+19 -7
View File
@@ -1,13 +1,18 @@
// Command repair-category-prompts replaces legacy A1 / Platform Demo categories.prompt
// HTML marketing formulas with aiprompts.CategoryEnhanceUserTemplate (JSON-compatible
// user overlay: {{name}} {{description}} {{attrs}} {{category}} {{language}}; stores
// both prompt->>'sl' and "*"). Does not touch title_template / description_template
// (formulas are appended as plain-text instructions at enhance render time).
// Command repair-category-prompts rewrites A1 / Platform Demo categories.prompt
// values into aiprompts role-sectioned overlays (Title / Description / Meta /
// Attributes). Prefers wp_product_categories.sql (SEED_A1_WP_CATEGORIES /
// Downloads) as source of truth, else a1-category-prompts.json. Legacy combined
// Slovenian name+description blobs are split so naming rules land under Title
// and HTML under Description; otherwise CategoryEnhanceUserTemplate is used.
// Also repairs empty or brand-only title_template and empty description_template
// from legacy <name> / HTML / meta rules (never brand-only).
//
// Usage (from apps/api):
//
// go run ./cmd/repair-category-prompts -dry-run
// go run ./cmd/repair-category-prompts -apply
// go run ./cmd/repair-category-prompts -apply -wp-categories "D:/Users/.../Downloads/wp_product_categories.sql"
// go run ./cmd/repair-category-prompts -apply -prompts ../../scripts/seed/a1-category-prompts.json
//
// DATABASE_URL / -postgres required. Default is dry-run (count only).
package main
@@ -30,6 +35,8 @@ func main() {
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL (DATABASE_URL)")
dryRun := flag.Bool("dry-run", true, "count rows that would update (default true)")
apply := flag.Bool("apply", false, "write updates (implies not dry-run)")
promptsPath := flag.String("prompts", "", "path to a1-category-prompts.json (optional fallback)")
wpPath := flag.String("wp-categories", os.Getenv("SEED_A1_WP_CATEGORIES"), "path to wp_product_categories.sql (preferred source of truth)")
flag.Parse()
if strings.TrimSpace(*postgresURL) == "" {
@@ -49,8 +56,13 @@ func main() {
}
defer pg.Close()
opts := catalog.RepairA1DemoOptions{
SeedPromptsPath: strings.TrimSpace(*promptsPath),
WPCategoriesPath: strings.TrimSpace(*wpPath),
}
// Always print dry-run counts first when applying, so operators see the delta.
preview, err := catalog.RepairA1DemoCategoryEnhancePrompts(ctx, pg, true)
preview, err := catalog.RepairA1DemoCategoryEnhancePromptsWithOptions(ctx, pg, true, opts)
if err != nil {
log.Fatalf("dry-run: %v", err)
}
@@ -60,7 +72,7 @@ func main() {
return
}
res, err := catalog.RepairA1DemoCategoryEnhancePrompts(ctx, pg, false)
res, err := catalog.RepairA1DemoCategoryEnhancePromptsWithOptions(ctx, pg, false, opts)
if err != nil {
log.Fatalf("apply: %v", err)
}
+136
View File
@@ -0,0 +1,136 @@
// Command seed-a1-teammate upserts a second A1 company member (not the owner)
// with must_set_password=true and prints a one-time accept-invite URL.
//
// Usage:
//
// go run ./cmd/seed-a1-teammate -postgres "$DATABASE_URL"
// go run ./cmd/seed-a1-teammate -email a1-other@descrybe.local -role member
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL")
email := flag.String("email", "a1-other@descrybe.local", "Teammate email to upsert")
name := flag.String("name", "A1 teammate", "Display name")
role := flag.String("role", "member", "Membership role: member|admin")
webOrigin := flag.String("web-origin", firstNonEmpty(os.Getenv("WEB_ORIGIN"), "http://localhost:28472"), "Web origin for accept-invite URL")
flag.Parse()
if strings.TrimSpace(*postgresURL) == "" {
log.Fatal("-postgres / DATABASE_URL is required")
}
emailNorm := strings.ToLower(strings.TrimSpace(*email))
if emailNorm == "" {
log.Fatal("-email is required")
}
if emailNorm == "a1-primary@descrybe.local" {
log.Fatal("-email must not be a1-primary@descrybe.local (that user remains the company owner)")
}
memRole := auth.NormalizeMembershipRole(*role)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
pg, err := pgxpool.New(ctx, *postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
var companyID uuid.UUID
var companyName string
err = pg.QueryRow(ctx, `
SELECT id, name FROM companies
WHERE legacy_company_id = $1
ORDER BY created_at ASC
LIMIT 1`, billing.A1LegacyCompanyID).Scan(&companyID, &companyName)
if err != nil {
log.Fatalf("find A1 company (legacy_company_id=%s): %v", billing.A1LegacyCompanyID, err)
}
tx, err := pg.Begin(ctx)
if err != nil {
log.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
var userID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO users (email, name, must_set_password, is_platform_admin, is_active, updated_at)
VALUES ($1, $2, true, false, true, now())
ON CONFLICT (email) DO UPDATE SET
name = EXCLUDED.name,
must_set_password = true,
password_hash = NULL,
is_active = true,
updated_at = now()
RETURNING id`, emailNorm, strings.TrimSpace(*name)).Scan(&userID)
if err != nil {
log.Fatalf("upsert user: %v", err)
}
_, err = tx.Exec(ctx, `
INSERT INTO memberships (company_id, user_id, role, status)
VALUES ($1, $2, $3, 'active')
ON CONFLICT (company_id, user_id) DO UPDATE SET
role = EXCLUDED.role,
status = 'active',
updated_at = now()`, companyID, userID, memRole)
if err != nil {
log.Fatalf("upsert membership: %v", err)
}
// Ensure a1-primary remains owner when present.
_, _ = tx.Exec(ctx, `
UPDATE companies c
SET owner_user_id = u.id, updated_at = now()
FROM users u
JOIN memberships m ON m.user_id = u.id AND m.company_id = c.id AND m.status = 'active'
WHERE c.id = $1
AND lower(u.email) = 'a1-primary@descrybe.local'`, companyID)
if err := tx.Commit(ctx); err != nil {
log.Fatalf("commit: %v", err)
}
svc := &auth.Service{Pool: pg}
inv, err := svc.ReissueSetPasswordInvite(ctx, userID, 0)
if err != nil {
log.Fatalf("issue set-password invite: %v", err)
}
acceptURL := mail.AcceptInviteURL(strings.TrimSpace(*webOrigin), inv.Token)
fmt.Printf("A1 teammate ready\n")
fmt.Printf(" company: %s (%s)\n", companyName, companyID)
fmt.Printf(" email: %s\n", emailNorm)
fmt.Printf(" user_id: %s\n", userID)
fmt.Printf(" role: %s\n", memRole)
fmt.Printf(" expires_at: %s\n", inv.ExpiresAt.UTC().Format(time.RFC3339))
fmt.Printf(" accept_url: %s\n", acceptURL)
fmt.Printf("\nShare accept_url with the teammate (or Admin → Users → Send set-password for this user).\n")
fmt.Printf("While impersonating a1-primary, Settings → Team → Invite also returns a copyable link when SMTP is off.\n")
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
+59 -8
View File
@@ -13,6 +13,7 @@ import (
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
@@ -41,6 +42,10 @@ var (
)
func defaultCategoryPromptsPath(archivePath string) string {
// Prefer live WP dump when present (Sync A1 / seed share the same source of truth).
if p := catalog.ResolveWPCategoryPromptsPath(""); p != "" {
return p
}
if strings.TrimSpace(archivePath) != "" {
return filepath.Join(filepath.Dir(archivePath), "a1-category-prompts.json")
}
@@ -52,6 +57,10 @@ func loadCategoryPromptsFile(path string) (categoryPromptsFile, error) {
if path == "" || path == "." {
return categoryPromptsFile{}, fmt.Errorf("category prompts path is empty")
}
base := strings.ToLower(filepath.Base(path))
if strings.HasPrefix(base, "wp_product_categories") && strings.HasSuffix(base, ".sql") {
return loadCategoryPromptsFromWPSQL(path)
}
// Allowlist the committed seed filename (blocks accidental reads of unrelated dumps).
if filepath.Base(path) != "a1-category-prompts.json" {
return categoryPromptsFile{}, fmt.Errorf("refusing unexpected category prompts file name %q", filepath.Base(path))
@@ -76,6 +85,36 @@ func loadCategoryPromptsFile(path string) (categoryPromptsFile, error) {
return f, nil
}
func loadCategoryPromptsFromWPSQL(path string) (categoryPromptsFile, error) {
raw, err := os.ReadFile(path)
if err != nil {
return categoryPromptsFile{}, fmt.Errorf("read wp category prompts: %w", err)
}
if len(raw) > 16<<20 {
return categoryPromptsFile{}, fmt.Errorf("wp category prompts file too large (%d bytes)", len(raw))
}
parsed, err := catalog.ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return categoryPromptsFile{}, err
}
entries := make([]categoryPromptEntry, 0, len(parsed))
for _, e := range parsed {
entries = append(entries, categoryPromptEntry{
Name: e.Name,
UniqueID: e.UniqueID,
Prompt: e.Prompt,
})
}
if len(entries) == 0 {
return categoryPromptsFile{}, fmt.Errorf("wp category prompts file has no entries")
}
return categoryPromptsFile{
Version: 1,
Source: filepath.Base(path),
Entries: entries,
}, nil
}
// modernizeLegacyPromptPlaceholders rewrites v1 {""OPIS IZDELKA""} tokens to {{description}} / {{name}}.
func modernizeLegacyPromptPlaceholders(prompt string) string {
prompt = reLegacyDesc.ReplaceAllString(prompt, "{{description}}")
@@ -148,16 +187,11 @@ func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.
byNorm := make(map[string]string, len(file.Entries))
byUnique := make(map[string]string, len(file.Entries))
// Overlay sets the shared role-sectioned enhance user template
// (aiprompts.CategoryEnhanceUserTemplate — title/description/meta/attributes).
// Seed JSON selects which categories get a prompt (prefer unique_id, else name).
// Unique title/description/meta formulas stay in title_template / description_template.
// Overlay: split each legacy combined Name+Description prompt into role-sectioned
// enhance USER text (Title / Description / Meta / Attributes). Unique title /
// description / meta formulas stay in title_template / description_template.
// Stored under "sl" + "*" so prompt->>'sl' and LangPromptAny both resolve;
// language stays via {{language}}.
canonical := prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate)
if canonical == "" {
return out, fmt.Errorf("CategoryEnhanceUserTemplate sanitized to empty")
}
for _, e := range file.Entries {
uid := strings.TrimSpace(e.UniqueID)
name := strings.TrimSpace(e.Name)
@@ -165,6 +199,23 @@ func applyCategoryPrompts(ctx context.Context, pg *pgxpool.Pool, companyID uuid.
out.Skipped++
continue
}
raw := strings.TrimSpace(e.Prompt)
var canonical string
if raw == "" {
out.Skipped++
continue
}
if aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
canonical = prepareCategoryPrompt(aiprompts.SplitLegacyCombinedEnhancePrompt(raw))
} else if aiprompts.CategoryEnhanceHasRoleSections(raw) {
canonical = prepareCategoryPrompt(raw)
} else {
canonical = prepareCategoryPrompt(aiprompts.CategoryEnhanceUserTemplate)
}
if canonical == "" {
out.Skipped++
continue
}
if uid != "" {
byUnique[strings.ToLower(uid)] = canonical
}
@@ -0,0 +1,152 @@
package aiprompts
import (
"encoding/json"
"regexp"
"strings"
)
var (
reLegacyHTMLBlock = regexp.MustCompile(`(?is)<\s*(h[1-4]|p|ul|ol|li|b|strong)\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*(?:h[1-4]|p|ul|ol|li|b|strong)\s*>`)
reLegacyBoldUL = regexp.MustCompile(`(?is)<\s*b\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*b\s*>\s*<\s*ul\s*>\s*<\s*li\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*li\s*>\s*<\s*/\s*ul\s*>`)
)
// DescriptionFormulaSection is one ordered block in categories.description_template.
type DescriptionFormulaSection struct {
Type string `json:"type"`
Instructions string `json:"instructions"`
}
// DescriptionFormula is the JSON shape stored in categories.description_template
// (sections + optional SEO meta instruction strings).
type DescriptionFormula struct {
Sections []DescriptionFormulaSection `json:"sections,omitempty"`
MetaTitle string `json:"metaTitle,omitempty"`
MetaDescription string `json:"metaDescription,omitempty"`
}
// DescriptionTemplateNeedsRepair is true when template is missing or has no sections
// (and no usable meta instructions).
func DescriptionTemplateNeedsRepair(template any) bool {
f, ok := parseDescriptionFormula(template)
if !ok {
return true
}
if len(f.Sections) > 0 {
return false
}
return strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == ""
}
// DeriveDescriptionFormulaFromLegacyParts builds description_template from split
// legacy DescriptionRules HTML + MetaRules.
func DeriveDescriptionFormulaFromLegacyParts(parts LegacyEnhanceParts) DescriptionFormula {
out := DescriptionFormula{
MetaDescription: strings.TrimSpace(parts.MetaRules),
}
body := strings.TrimSpace(parts.DescriptionRules)
if body == "" {
return out
}
// Walk left-to-right so section order matches the legacy HTML template.
remaining := body
for remaining != "" {
boldUL := reLegacyBoldUL.FindStringSubmatchIndex(remaining)
block := reLegacyHTMLBlock.FindStringSubmatchIndex(remaining)
switch {
case boldUL != nil && (block == nil || boldUL[0] <= block[0]):
m := reLegacyBoldUL.FindStringSubmatch(remaining[boldUL[0]:boldUL[1]])
heading, instr := "", ""
if len(m) >= 3 {
heading = cleanLegacyInstruction(m[1])
instr = cleanLegacyInstruction(m[2])
}
if heading != "" && instr != "" && !strings.Contains(strings.ToLower(instr), strings.ToLower(heading)) {
instr = heading + ": " + instr
} else if instr == "" {
instr = heading
}
if strings.TrimSpace(instr) != "" {
out.Sections = append(out.Sections, DescriptionFormulaSection{Type: "ul", Instructions: instr})
}
remaining = remaining[boldUL[1]:]
case block != nil:
m := reLegacyHTMLBlock.FindStringSubmatch(remaining[block[0]:block[1]])
if len(m) >= 3 {
typ := strings.ToLower(strings.TrimSpace(m[1]))
instr := cleanLegacyInstruction(m[2])
if instr != "" {
switch typ {
case "b", "strong":
typ = "h2"
case "li", "ol":
typ = "ul"
}
out.Sections = append(out.Sections, DescriptionFormulaSection{Type: typ, Instructions: instr})
}
}
remaining = remaining[block[1]:]
default:
return out
}
}
return out
}
// DeriveDescriptionTemplateJSON encodes DeriveDescriptionFormulaFromLegacyParts for DB write.
func DeriveDescriptionTemplateJSON(parts LegacyEnhanceParts) string {
f := DeriveDescriptionFormulaFromLegacyParts(parts)
if len(f.Sections) == 0 && strings.TrimSpace(f.MetaTitle) == "" && strings.TrimSpace(f.MetaDescription) == "" {
return ""
}
b, err := json.Marshal(f)
if err != nil {
return ""
}
return string(b)
}
func parseDescriptionFormula(template any) (DescriptionFormula, bool) {
if template == nil {
return DescriptionFormula{}, false
}
switch t := template.(type) {
case DescriptionFormula:
return t, true
case *DescriptionFormula:
if t == nil {
return DescriptionFormula{}, false
}
return *t, true
case []byte:
if len(t) == 0 {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal(t, &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
case string:
s := strings.TrimSpace(t)
if s == "" || s == "null" || s == "{}" {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal([]byte(s), &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
default:
b, err := json.Marshal(t)
if err != nil {
return DescriptionFormula{}, false
}
var f DescriptionFormula
if err := json.Unmarshal(b, &f); err != nil {
return DescriptionFormula{}, false
}
return f, true
}
}
@@ -0,0 +1,56 @@
package aiprompts
import (
"strings"
"testing"
)
func TestDeriveDescriptionFormulaFromLegacyParts(t *testing.T) {
t.Parallel()
legacy := `Ustvari nov opis
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: "znamka", "tip izdelka lowercase". Ne uporabljaj vejic.}</name>
<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov}</metaDescription>
<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>
<p>{Napiši odstavek, ki je dolg 100 besed.}</p>
<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>`
parts := ParseLegacyCombinedEnhancePrompt(legacy)
if !parts.WasLegacy {
t.Fatal("expected legacy parse")
}
f := DeriveDescriptionFormulaFromLegacyParts(parts)
if strings.TrimSpace(f.MetaDescription) == "" || !strings.Contains(f.MetaDescription, "140") {
t.Fatalf("metaDescription=%q", f.MetaDescription)
}
if len(f.Sections) < 3 {
t.Fatalf("want >=3 sections, got %#v", f.Sections)
}
if f.Sections[0].Type != "h2" {
t.Fatalf("first section should be h2 (document order), got %#v", f.Sections[0])
}
last := f.Sections[len(f.Sections)-1]
if last.Type != "ul" {
t.Fatalf("last section should be ul specs, got %#v", last)
}
var sawH2, sawP, sawUL bool
for _, s := range f.Sections {
switch s.Type {
case "h2":
sawH2 = true
case "p":
sawP = true
case "ul":
sawUL = true
}
}
if !sawH2 || !sawP || !sawUL {
t.Fatalf("missing section types: %#v", f.Sections)
}
raw := DeriveDescriptionTemplateJSON(parts)
if raw == "" || DescriptionTemplateNeedsRepair([]byte(raw)) {
t.Fatalf("derived template should be OK: %s", raw)
}
}
+13 -4
View File
@@ -64,7 +64,7 @@ type DefaultTemplate struct {
const CategoryEnhanceUserTemplate = `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).
--- Title ---
Role: title. Build JSON "name": short retail title; follow any Title formula constraints that follow; use Attrs.
` + TitleRoleInstruction + `
Name: {{name}}
--- End Title ---
@@ -84,13 +84,22 @@ Attrs: {{attrs}}
--- End Attributes ---`
// CategoryEnhancePromptNeedsRepair reports whether a stored categories.prompt value
// should be replaced by CategoryEnhanceUserTemplate (idempotent equality check).
// should be rewritten into role-sectioned enhance overlay form. Empty prompts are
// left alone. Canonical CategoryEnhanceUserTemplate and per-category overlays that
// already carry Title/Description/Meta/Attributes markers (+ {{attrs}}) are OK —
// equality with the shared template is not required (legacy splits keep Slovenian rules).
func CategoryEnhancePromptNeedsRepair(prompt string) bool {
p := strings.TrimSpace(prompt)
if p == "" {
return false
}
return p != strings.TrimSpace(CategoryEnhanceUserTemplate)
if IsLegacyCombinedEnhancePrompt(p) {
return true
}
if CategoryEnhanceHasRoleSections(p) && strings.Contains(p, "{{attrs}}") {
return false
}
return true
}
// BuiltInDefaults match the previous hardcoded system prompts, with structured user templates.
@@ -103,7 +112,7 @@ var BuiltInDefaults = []DefaultTemplate{
Rules:
- Reply with ONLY JSON (no markdown)
- Schema: {"name":"string","description":"string","meta_title":"string","meta_description":"string","attrs":{}}
- name: short retail title
- name: short retail title — never brand-only; follow any Title formula (type + brand + model); use Attrs
- description: product body HTML only — follow any Description formula in the user message (structured HTML sections); otherwise 1-3 factual paragraphs; never copy name as description; never put SEO meta here
- meta_title: plain SEO title 50-60 chars (follow any SEO meta formula)
- meta_description: plain SEO snippet 120-155 chars (follow any SEO meta formula); never HTML
@@ -51,6 +51,14 @@ func TestCategoryEnhancePromptNeedsRepair(t *testing.T) {
if !CategoryEnhancePromptNeedsRepair(legacy) {
t.Fatal("legacy HTML prompt should need repair")
}
// Per-category sectioned overlay (Slovenian rules) must not force re-repair.
split := SplitLegacyCombinedEnhancePrompt(`GPT predloga:
<name>{Napiši tip izdelka}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`)
if CategoryEnhancePromptNeedsRepair(split) {
t.Fatal("sectioned split overlay should be idempotent")
}
}
func TestBuiltInProductEnhanceUsesSharedUserTemplate(t *testing.T) {
+177
View File
@@ -0,0 +1,177 @@
package aiprompts
import (
"regexp"
"strings"
)
var (
reLegacyNameTag = regexp.MustCompile(`(?is)<\s*name\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*name\s*>`)
reLegacyMetaTag = regexp.MustCompile(`(?is)<\s*metaDescription\s*>\s*\{?\s*(.*?)\s*\}?\s*<\s*/\s*metaDescription\s*>`)
reLegacyDescPH = regexp.MustCompile(`(?i)\{\s*""?\s*OPIS\s+IZDELKA\s*""?\s*\}`)
reLegacyNamePH = regexp.MustCompile(`(?i)\{\s*""?\s*STARO\s+IME\s+IZDELKA\s*""?\s*\}`)
reDoubledQuotes = regexp.MustCompile(`"{2,}`)
)
// LegacyEnhanceParts holds extracted role content from a combined PHP/A1 category Prompt.
type LegacyEnhanceParts struct {
TitleRules string
DescriptionRules string
MetaRules string
WasLegacy bool
}
// IsLegacyCombinedEnhancePrompt reports whether prompt looks like the old A1/PHP
// combined name+description(+meta) marketing blob (<name>/<metaDescription>/GPT predloga).
func IsLegacyCombinedEnhancePrompt(prompt string) bool {
p := strings.TrimSpace(prompt)
if p == "" {
return false
}
// Require real tag wrappers — not instructional mentions like
// `do NOT … <name>/<metaDescription> tags` in CategoryEnhanceUserTemplate.
hasNameTag := reLegacyNameTag.MatchString(p)
hasMetaTag := reLegacyMetaTag.MatchString(p)
if hasNameTag && (hasMetaTag || strings.Contains(strings.ToLower(p), "gpt predloga")) {
return true
}
if hasNameTag && (strings.Contains(p, "STARO IME IZDELKA") || strings.Contains(p, "OPIS IZDELKA")) {
return true
}
if strings.Contains(p, "STARO IME IZDELKA") && strings.Contains(p, "OPIS IZDELKA") && strings.Contains(strings.ToLower(p), "gpt predloga") {
return true
}
return false
}
// ParseLegacyCombinedEnhancePrompt extracts title / description / meta instruction
// blobs from a legacy combined prompt. Placeholders are modernized to {{name}} /
// {{description}}. WasLegacy is false when no combined markers are found.
func ParseLegacyCombinedEnhancePrompt(prompt string) LegacyEnhanceParts {
raw := strings.TrimSpace(prompt)
out := LegacyEnhanceParts{}
if raw == "" || !IsLegacyCombinedEnhancePrompt(raw) {
return out
}
out.WasLegacy = true
if m := reLegacyNameTag.FindStringSubmatch(raw); len(m) == 2 {
out.TitleRules = cleanLegacyInstruction(m[1])
}
if m := reLegacyMetaTag.FindStringSubmatch(raw); len(m) == 2 {
out.MetaRules = cleanLegacyInstruction(m[1])
}
rest := reLegacyNameTag.ReplaceAllString(raw, "")
rest = reLegacyMetaTag.ReplaceAllString(rest, "")
if idx := strings.Index(strings.ToLower(rest), "gpt predloga:"); idx >= 0 {
rest = rest[idx+len("gpt predloga:"):]
}
// Drop Slovenian boilerplate intro lines that are not body structure.
rest = stripLegacyIntroNoise(rest)
out.DescriptionRules = cleanLegacyInstruction(rest)
return out
}
// SplitLegacyCombinedEnhancePrompt rewrites a legacy combined name+description(+meta)
// prompt into CategoryEnhanceUserTemplate role sections, preserving Slovenian
// naming / HTML / meta intent as category-specific rules under Title / Description / Meta.
// Non-legacy input returns CategoryEnhanceUserTemplate (canonical shared overlay).
func SplitLegacyCombinedEnhancePrompt(prompt string) string {
parts := ParseLegacyCombinedEnhancePrompt(prompt)
if !parts.WasLegacy {
return strings.TrimSpace(CategoryEnhanceUserTemplate)
}
return BuildCategoryEnhanceOverlay(parts)
}
// BuildCategoryEnhanceOverlay composes a role-sectioned enhance USER overlay from
// extracted legacy parts (or empty parts → canonical template text with no extra rules).
func BuildCategoryEnhanceOverlay(parts LegacyEnhanceParts) string {
var b strings.Builder
b.WriteString(`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).`)
b.WriteString("\n\n")
b.WriteString(SectionTitleStart)
b.WriteByte('\n')
b.WriteString(TitleRoleInstruction)
b.WriteByte('\n')
if rules := strings.TrimSpace(parts.TitleRules); rules != "" {
b.WriteString("Category naming rules (preserve intent): ")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString("Name: {{name}}\n")
b.WriteString(SectionTitleEnd)
b.WriteString("\n\n")
b.WriteString(SectionDescriptionStart)
b.WriteString("\nRole: 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 (tags matching type: h1/h2/h3/h4, p, ul); otherwise prefer 1-3 factual paragraphs as ONE string with limited HTML (<h2><p><ul><li>) — do NOT emit a competing full HTML document or wrap the whole reply in <name>/<metaDescription> tags.\n")
if rules := strings.TrimSpace(parts.DescriptionRules); rules != "" {
b.WriteString("Category HTML structure (preserve intent; emit as ONE description HTML string, not tagged name/meta blocks):\n")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString("Description: {{description}}\n")
b.WriteString(SectionDescriptionEnd)
b.WriteString("\n\n")
b.WriteString(SectionMetaStart)
b.WriteString("\nRole: 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.\n")
if rules := strings.TrimSpace(parts.MetaRules); rules != "" {
b.WriteString("Category SEO rules (preserve intent; plain text only): ")
b.WriteString(rules)
b.WriteByte('\n')
}
b.WriteString(SectionMetaEnd)
b.WriteString("\n\n")
b.WriteString(SectionAttributesStart)
b.WriteString("\nRole: 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.\n")
b.WriteString("Category: {{category}}\n")
b.WriteString("Attrs: {{attrs}}\n")
b.WriteString(SectionAttributesEnd)
return strings.TrimSpace(b.String())
}
func cleanLegacyInstruction(s string) string {
s = modernizeLegacyPlaceholders(s)
s = reDoubledQuotes.ReplaceAllString(s, `"`)
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.TrimSpace(s)
// Collapse excessive blank lines.
for strings.Contains(s, "\n\n\n") {
s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
}
return s
}
func modernizeLegacyPlaceholders(prompt string) string {
prompt = reLegacyDescPH.ReplaceAllString(prompt, "{{description}}")
prompt = reLegacyNamePH.ReplaceAllString(prompt, "{{name}}")
return prompt
}
func stripLegacyIntroNoise(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, line := range lines {
trim := strings.TrimSpace(line)
lower := strings.ToLower(trim)
switch {
case trim == "":
if len(out) > 0 {
out = append(out, "")
}
case strings.HasPrefix(lower, "ustvari nov opis"),
strings.HasPrefix(lower, "star_opis_izdelka"),
strings.HasPrefix(lower, "staro_ime_izdelka"),
strings.HasPrefix(lower, "uporabi spodnjo gpt"),
strings.HasPrefix(lower, "sledi tej gpt"):
continue
default:
out = append(out, trim)
}
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
@@ -0,0 +1,71 @@
package aiprompts
import (
"strings"
"testing"
)
func TestSplitLegacyCombinedEnhancePrompt(t *testing.T) {
t.Parallel()
legacy := `Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:
Star_opis_izdelka: {""OPIS IZDELKA""};
Staro_ime_izdelka: {""STARO IME IZDELKA""};
Uporabi spodnjo GPT predlogo.
Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: ""tip izdelka sentence case"", ""znamka s pravilno kapitalizacijo"". Ne uporabljaj vejic.}</name>
<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>
<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>
<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p><b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>`
got := SplitLegacyCombinedEnhancePrompt(legacy)
if !CategoryEnhanceHasRoleSections(got) {
t.Fatal("split output must be role-sectioned")
}
if CategoryEnhancePromptNeedsRepair(got) {
t.Fatal("split output should not need further repair")
}
if reLegacyNameTag.MatchString(got) || reLegacyMetaTag.MatchString(got) {
t.Fatal("split must not keep legacy <name>/<metaDescription> wrapper tags")
}
if !strings.Contains(got, "tip izdelka sentence case") {
t.Fatal("title section must preserve Slovenian naming formula intent")
}
if !strings.Contains(got, "140 znakov") {
t.Fatal("meta section must preserve Slovenian SEO intent")
}
if !strings.Contains(got, "Tehnične specifikacije") && !strings.Contains(got, "tehničnih specifikacij") {
t.Fatal("description section must preserve HTML body intent")
}
if !strings.Contains(got, "{{attrs}}") || !strings.Contains(got, "{{language}}") {
t.Fatal("overlay must keep {{attrs}} and {{language}}")
}
if strings.Contains(got, "OPIS IZDELKA") || strings.Contains(got, "STARO IME IZDELKA") {
t.Fatal("legacy placeholders must be modernized")
}
}
func TestSplitLegacyNonLegacyFallsBackToTemplate(t *testing.T) {
t.Parallel()
got := SplitLegacyCombinedEnhancePrompt("short retail overlay")
if strings.TrimSpace(got) != strings.TrimSpace(CategoryEnhanceUserTemplate) {
t.Fatalf("non-legacy should fall back to canonical template")
}
}
func TestIsLegacyCombinedEnhancePrompt(t *testing.T) {
t.Parallel()
if IsLegacyCombinedEnhancePrompt(CategoryEnhanceUserTemplate) {
t.Fatal("canonical template is not legacy combined")
}
if !IsLegacyCombinedEnhancePrompt("GPT predloga:\n<name>{x}</name><metaDescription>{y}</metaDescription>") {
t.Fatal("expected legacy detection")
}
}
@@ -0,0 +1,298 @@
package aiprompts
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
var (
reQuotedNameSlot = regexp.MustCompile(`"([^"]+)"`)
reNameFormulaTail = regexp.MustCompile(`(?is)formuli\s*:\s*(.*?)(?:\.?\s*Ne uporabljaj|\.?$)`)
)
// TitleFormulaElement is one ordered slot in categories.title_template.
type TitleFormulaElement struct {
ID string `json:"id"`
Type string `json:"type"` // "text" | "variable"
Label string `json:"label,omitempty"`
Value string `json:"value"`
Description string `json:"description,omitempty"`
Example string `json:"example,omitempty"`
}
// TitleFormula is the JSON shape stored in categories.title_template.
type TitleFormula struct {
Separator string `json:"separator"`
Elements []TitleFormulaElement `json:"elements"`
}
// DefaultRetailTitleFormula matches the dominant old A1/PHP <name> pattern:
// product type + brand + full model (never brand alone).
func DefaultRetailTitleFormula() TitleFormula {
return TitleFormula{
Separator: " ",
Elements: []TitleFormulaElement{
{ID: "0-variable-product_type", Type: "variable", Label: "Product type", Value: "product_type", Description: "Specific product type (sentence case)", Example: "Gaming monitor"},
{ID: "1-variable-brand", Type: "variable", Label: "Brand", Value: "brand", Description: "Brand with correct capitalization", Example: "Samsung"},
{ID: "2-variable-product_model", Type: "variable", Label: "Model", Value: "product_model", Description: "Full product model / ID", Example: "Odyssey G5"},
},
}
}
// TitleRoleInstruction is the shared Title-section body (CategoryEnhanceUserTemplate
// and BuildCategoryEnhanceOverlay). Drives enhance JSON "name"; never brand-only.
const TitleRoleInstruction = `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.`
// TitleTemplateIsBrandOnly reports stubs that only encode brand (the migrated A1
// default) so enhance collapses to brand-only names.
func TitleTemplateIsBrandOnly(template any) bool {
els, ok := titleFormulaElements(template)
if !ok || len(els) == 0 {
return false
}
vars := 0
brandOnly := true
for _, el := range els {
switch strings.ToLower(strings.TrimSpace(el.Type)) {
case "variable":
vars++
v := strings.ToLower(strings.TrimSpace(el.Value))
v = strings.ReplaceAll(v, "-", "_")
if v != "brand" && v != "znamka" {
brandOnly = false
}
case "text":
// Free-form naming-rule blobs are not brand-only stubs.
if strings.TrimSpace(el.Value) != "" {
brandOnly = false
}
}
}
return vars > 0 && brandOnly
}
// TitleTemplateNeedsRepair is true when template is missing, unparseable, or
// brand-only (insufficient vs old name/title rules).
func TitleTemplateNeedsRepair(template any) bool {
els, ok := titleFormulaElements(template)
if !ok || len(els) == 0 {
return true
}
return TitleTemplateIsBrandOnly(template)
}
// DeriveTitleFormulaFromLegacyNameRules turns PHP/A1 <name> formula text
// (e.g. "tip izdelka …", "znamka …", "poln model …") into a structured
// title_template. Falls back to DefaultRetailTitleFormula when parsing yields
// fewer than two variable slots.
func DeriveTitleFormulaFromLegacyNameRules(titleRules string) TitleFormula {
slots := extractLegacyNameSlots(titleRules)
out := TitleFormula{Separator: " ", Elements: make([]TitleFormulaElement, 0, len(slots))}
seen := map[string]struct{}{}
for _, slot := range slots {
el, ok := mapLegacyNameSlot(slot)
if !ok {
continue
}
key := el.Type + ":" + strings.ToLower(el.Value)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
el.ID = fmt.Sprintf("%d-%s-%s", len(out.Elements), el.Type, sanitizeFormulaID(el.Value))
out.Elements = append(out.Elements, el)
}
if countFormulaVariables(out) < 2 {
return ensureRetailTitleCoverage(out)
}
return ensureRetailTitleCoverage(out)
}
// DeriveTitleTemplateJSON is the JSON encoding used when repairing categories.title_template.
func DeriveTitleTemplateJSON(titleRules string) string {
f := DeriveTitleFormulaFromLegacyNameRules(titleRules)
b, err := json.Marshal(f)
if err != nil {
def := DefaultRetailTitleFormula()
b, _ = json.Marshal(def)
}
return string(b)
}
func titleFormulaElements(template any) ([]TitleFormulaElement, bool) {
if template == nil {
return nil, false
}
switch t := template.(type) {
case TitleFormula:
return t.Elements, len(t.Elements) > 0
case *TitleFormula:
if t == nil {
return nil, false
}
return t.Elements, len(t.Elements) > 0
case []byte:
if len(t) == 0 {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(t, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
case string:
s := strings.TrimSpace(t)
if s == "" || s == "null" || s == "{}" {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal([]byte(s), &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
case map[string]any:
b, err := json.Marshal(t)
if err != nil {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(b, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
default:
b, err := json.Marshal(t)
if err != nil {
return nil, false
}
var f TitleFormula
if err := json.Unmarshal(b, &f); err != nil {
return nil, false
}
return f.Elements, len(f.Elements) > 0
}
}
func extractLegacyNameSlots(rules string) []string {
rules = strings.TrimSpace(rules)
if rules == "" {
return nil
}
body := rules
if m := reNameFormulaTail.FindStringSubmatch(rules); len(m) == 2 {
body = m[1]
}
raw := reQuotedNameSlot.FindAllStringSubmatch(body, -1)
out := make([]string, 0, len(raw))
for _, m := range raw {
s := strings.TrimSpace(m[1])
s = strings.Trim(s, `",. `)
if s == "" || s == "," {
continue
}
out = append(out, s)
}
return out
}
func mapLegacyNameSlot(slot string) (TitleFormulaElement, bool) {
s := strings.TrimSpace(slot)
if s == "" {
return TitleFormulaElement{}, false
}
low := strings.ToLower(s)
switch {
case strings.Contains(low, "znamka"):
return TitleFormulaElement{Type: "variable", Label: "Brand", Value: "brand", Description: s, Example: "Samsung"}, true
case strings.Contains(low, "poln model"), strings.Contains(low, "model izdelka"),
strings.HasPrefix(low, "model "), low == "model", strings.Contains(low, "model uppercase"):
return TitleFormulaElement{Type: "variable", Label: "Model", Value: "product_model", Description: s, Example: "EHT6020"}, true
case strings.Contains(low, "tip izdelka"):
return TitleFormulaElement{Type: "variable", Label: "Product type", Value: "product_type", Description: s, Example: "Bluetooth speaker"}, true
case strings.HasPrefix(low, "barva"):
return TitleFormulaElement{Type: "variable", Label: "Color", Value: "barva", Description: s}, true
case strings.Contains(low, "dimenzij"):
return TitleFormulaElement{Type: "variable", Label: "Dimensions", Value: "dimensions", Description: s}, true
case strings.Contains(low, "diagonala"):
return TitleFormulaElement{Type: "variable", Label: "Diagonal", Value: "diagonala_zaslona", Description: s}, true
case strings.Contains(low, "pomnilnik"):
return TitleFormulaElement{Type: "variable", Label: "Memory", Value: "kapaciteta_ram_pomnilnika", Description: s}, true
case strings.Contains(low, "procesor"):
return TitleFormulaElement{Type: "variable", Label: "CPU", Value: "procesor", Description: s}, true
case strings.Contains(low, "grafi"):
return TitleFormulaElement{Type: "variable", Label: "GPU", Value: "graficna_kartica", Description: s}, true
case strings.Contains(low, "kapaciteta"):
return TitleFormulaElement{Type: "variable", Label: "Capacity", Value: "kapaciteta", Description: s}, true
case strings.Contains(low, "skupina po te") || strings.Contains(low, "teži") || strings.Contains(low, "tezi"):
return TitleFormulaElement{Type: "variable", Label: "Weight group", Value: "weight_group", Description: s}, true
case strings.Contains(low, "priklju"):
return TitleFormulaElement{Type: "variable", Label: "Connector", Value: "connector", Description: s}, true
case strings.Contains(low, "osvež") || strings.Contains(low, "osvez") || strings.Contains(low, "hz"):
return TitleFormulaElement{Type: "variable", Label: "Refresh rate", Value: "refresh_rate", Description: s}, true
case strings.Contains(low, "lowercase") || strings.Contains(low, "sentence case") || strings.Contains(low, "uppercase"):
// Category-specific type word ("cvrtnik lowercase", "monitor lowercase").
return TitleFormulaElement{Type: "variable", Label: "Product type", Value: "product_type", Description: s}, true
default:
// Keep unrecognized instructional slots as text so order/intent survive.
if len([]rune(s)) > 80 {
s = string([]rune(s)[:80])
}
return TitleFormulaElement{Type: "text", Label: "Naming detail", Value: s, Description: "Legacy name-formula slot"}, true
}
}
func countFormulaVariables(f TitleFormula) int {
n := 0
for _, el := range f.Elements {
if el.Type == "variable" {
n++
}
}
return n
}
func ensureRetailTitleCoverage(f TitleFormula) TitleFormula {
has := map[string]bool{}
for _, el := range f.Elements {
if el.Type != "variable" {
continue
}
k := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(el.Value), "-", "_"))
has[k] = true
}
def := DefaultRetailTitleFormula()
for _, el := range def.Elements {
k := strings.ToLower(el.Value)
if has[k] {
continue
}
// Only inject core retail slots when missing.
if k == "brand" || k == "product_model" || (k == "product_type" && countFormulaVariables(f) < 2) {
el.ID = fmt.Sprintf("%d-%s-%s", len(f.Elements), el.Type, el.Value)
f.Elements = append(f.Elements, el)
has[k] = true
}
}
if countFormulaVariables(f) < 2 {
return def
}
if f.Separator == "" {
f.Separator = " "
}
return f
}
func sanitizeFormulaID(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
v = strings.ReplaceAll(v, " ", "_")
v = strings.ReplaceAll(v, "-", "_")
if v == "" {
return "slot"
}
if len(v) > 40 {
v = v[:40]
}
return v
}
@@ -0,0 +1,107 @@
package aiprompts
import (
"strings"
"testing"
)
func TestDeriveTitleFormulaFromLegacyNameRules_Agregati(t *testing.T) {
t.Parallel()
rules := `Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka, če lahko z besedo in ID uppercase". Ne uporabljaj vejic.`
f := DeriveTitleFormulaFromLegacyNameRules(rules)
if len(f.Elements) < 3 {
t.Fatalf("want >=3 elements, got %#v", f.Elements)
}
vals := make([]string, 0, len(f.Elements))
for _, el := range f.Elements {
if el.Type == "variable" {
vals = append(vals, el.Value)
}
}
wantOrder := []string{"product_type", "brand", "product_model"}
for i, w := range wantOrder {
if i >= len(vals) || vals[i] != w {
t.Fatalf("vars=%v want prefix %v", vals, wantOrder)
}
}
if TitleTemplateIsBrandOnly(f) {
t.Fatal("derived formula must not be brand-only")
}
}
func TestDeriveTitleFormulaFromLegacyNameRules_CarSeatOrder(t *testing.T) {
t.Parallel()
rules := `Napiši novo ime izdelka po formuli: "znamka s pravilno kapitalizacijo", "tip izdelka lowercase", "poln model izdelka, če lahko z besedo in ID uppercase", "skupina po teži [kg]". Ne uporabljaj vejic.`
f := DeriveTitleFormulaFromLegacyNameRules(rules)
vars := []string{}
for _, el := range f.Elements {
if el.Type == "variable" {
vars = append(vars, el.Value)
}
}
if len(vars) < 3 || vars[0] != "brand" || vars[1] != "product_type" || vars[2] != "product_model" {
t.Fatalf("unexpected order: %v", vars)
}
foundWeight := false
for _, v := range vars {
if v == "weight_group" {
foundWeight = true
}
}
if !foundWeight {
t.Fatalf("missing weight_group in %v", vars)
}
}
func TestTitleTemplateIsBrandOnly(t *testing.T) {
t.Parallel()
brandOnly := map[string]any{
"separator": " ",
"elements": []any{
map[string]any{"type": "variable", "value": "brand", "label": "Znamka"},
},
}
if !TitleTemplateIsBrandOnly(brandOnly) {
t.Fatal("single brand variable should be brand-only")
}
if !TitleTemplateNeedsRepair(brandOnly) {
t.Fatal("brand-only needs repair")
}
if TitleTemplateIsBrandOnly(DefaultRetailTitleFormula()) {
t.Fatal("default retail must not be brand-only")
}
if TitleTemplateNeedsRepair(DefaultRetailTitleFormula()) {
t.Fatal("default retail must not need repair")
}
if !TitleTemplateNeedsRepair(nil) {
t.Fatal("nil needs repair")
}
}
func TestTitleRoleInstructionNeverBrandOnly(t *testing.T) {
t.Parallel()
if !strings.Contains(strings.ToLower(TitleRoleInstruction), "never brand-only") {
t.Fatal("TitleRoleInstruction must forbid brand-only names")
}
if !strings.Contains(CategoryEnhanceUserTemplate, TitleRoleInstruction) {
t.Fatal("CategoryEnhanceUserTemplate must embed TitleRoleInstruction")
}
split := SplitLegacyCombinedEnhancePrompt(`GPT predloga:
<name>{Napiši tip izdelka, znamka, poln model}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`)
if !strings.Contains(split, "never brand-only") {
t.Fatalf("split Title section missing never brand-only: %s", split)
}
}
func TestDeriveTitleTemplateJSON_roundTrip(t *testing.T) {
t.Parallel()
raw := DeriveTitleTemplateJSON(`formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka"`)
if TitleTemplateNeedsRepair(raw) {
t.Fatalf("derived JSON should be OK: %s", raw)
}
if !strings.Contains(raw, `"product_type"`) || !strings.Contains(raw, `"brand"`) || !strings.Contains(raw, `"product_model"`) {
t.Fatalf("missing core vars: %s", raw)
}
}
+5 -1
View File
@@ -34,7 +34,11 @@ func ClientError(err error) (msg string, ok bool) {
errors.Is(err, ErrEmailRequired),
errors.Is(err, ErrSyntheticEmail),
errors.Is(err, ErrNotEligibleSetPassword),
errors.Is(err, ErrEmailMismatch):
errors.Is(err, ErrEmailMismatch),
errors.Is(err, ErrNotCompanyOwner),
errors.Is(err, ErrCannotRemoveOwner),
errors.Is(err, ErrTransferSelf),
errors.Is(err, ErrOwnerRequired):
return err.Error(), true
default:
return "", false
+95
View File
@@ -0,0 +1,95 @@
package auth
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
var (
ErrNotCompanyOwner = errors.New("company owner required")
ErrCannotRemoveOwner = errors.New("transfer ownership before removing the company owner")
ErrTransferSelf = errors.New("user is already the company owner")
ErrOwnerRequired = errors.New("new owner must be an active company member")
)
// CompanyOwnerID returns the billing representative for the company, if set.
func (s *Service) CompanyOwnerID(ctx context.Context, companyID uuid.UUID) (uuid.UUID, bool, error) {
var owner *uuid.UUID
err := s.Pool.QueryRow(ctx, `SELECT owner_user_id FROM companies WHERE id = $1`, companyID).Scan(&owner)
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, false, ErrCompanyNotFound
}
if err != nil {
return uuid.Nil, false, err
}
if owner == nil || *owner == uuid.Nil {
return uuid.Nil, false, nil
}
return *owner, true, nil
}
// IsCompanyOwner reports whether userID is the company's owner_user_id.
func (s *Service) IsCompanyOwner(ctx context.Context, companyID, userID uuid.UUID) (bool, error) {
ownerID, ok, err := s.CompanyOwnerID(ctx, companyID)
if err != nil {
return false, err
}
return ok && ownerID == userID, nil
}
// TransferOwnership sets a new company owner. The target must be an active member.
// The new owner is promoted to membership admin so they retain team powers.
func (s *Service) TransferOwnership(ctx context.Context, companyID, newOwnerID uuid.UUID) error {
tx, err := s.Pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var current *uuid.UUID
err = tx.QueryRow(ctx, `SELECT owner_user_id FROM companies WHERE id = $1 FOR UPDATE`, companyID).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
return ErrCompanyNotFound
}
if err != nil {
return err
}
if current != nil && *current == newOwnerID {
return ErrTransferSelf
}
var status string
err = tx.QueryRow(ctx, `
SELECT status FROM memberships
WHERE company_id = $1 AND user_id = $2`, companyID, newOwnerID).Scan(&status)
if errors.Is(err, pgx.ErrNoRows) {
return ErrOwnerRequired
}
if err != nil {
return err
}
if status != "active" {
return ErrOwnerRequired
}
_, err = tx.Exec(ctx, `
UPDATE memberships
SET role = 'admin', status = 'active', updated_at = now()
WHERE company_id = $1 AND user_id = $2`, companyID, newOwnerID)
if err != nil {
return err
}
tag, err := tx.Exec(ctx, `
UPDATE companies SET owner_user_id = $2, updated_at = now() WHERE id = $1`, companyID, newOwnerID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrCompanyNotFound
}
return tx.Commit(ctx)
}
+20
View File
@@ -0,0 +1,20 @@
package auth
import (
"testing"
)
func TestOwnershipErrorClientFacing(t *testing.T) {
t.Parallel()
for _, err := range []error{
ErrNotCompanyOwner,
ErrCannotRemoveOwner,
ErrTransferSelf,
ErrOwnerRequired,
} {
msg, ok := ClientError(err)
if !ok || msg == "" {
t.Fatalf("expected client error for %v", err)
}
}
}
+2 -1
View File
@@ -102,7 +102,8 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (LoginResult,
var companyID uuid.UUID
err = tx.QueryRow(ctx, `
INSERT INTO companies (name) VALUES ($1) RETURNING id`, strings.TrimSpace(in.CompanyName)).Scan(&companyID)
INSERT INTO companies (name, owner_user_id) VALUES ($1, $2) RETURNING id`,
strings.TrimSpace(in.CompanyName), userID).Scan(&companyID)
if err != nil {
return LoginResult{}, err
}
+35 -11
View File
@@ -41,22 +41,46 @@ func EnsureCategoryAttributeLinks(ctx context.Context, pool *pgxpool.Pool, compa
}
// RepairCompanyCategoryEnhancePrompts is the company-scoped variant of
// RepairA1DemoCategoryEnhancePrompts: same repairedCategoryEnhancePromptMap
// (sl + "*" with CategoryEnhanceUserTemplate / {{attrs}}), applied to one company.
// Idempotent — already-OK categories count as already_ok, not updated.
// RepairA1DemoCategoryEnhancePrompts: loads wp_product_categories.sql when
// available (upload bytes, SEED_A1_WP_CATEGORIES / Downloads), else
// a1-category-prompts.json, splits legacy combined overlays into role sections,
// and force-applies WP dump matches. Falls back to CategoryEnhanceUserTemplate.
// Idempotent when unchanged.
func RepairCompanyCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) (updated, alreadyOK, emptySkipped int, err error) {
if pool == nil {
return 0, 0, 0, fmt.Errorf("nil pool")
}
want, err := repairedCategoryEnhancePromptMap()
res, err := RepairCompanyCategoryEnhancePromptsWithOptions(ctx, pool, companyID, RepairA1DemoOptions{})
if err != nil {
return 0, 0, 0, err
}
summary := &RepairCategoryEnhancePromptsResult{ByCompany: map[string]int{}}
if _, err := repairCompanyCategoryEnhancePrompts(ctx, pool, companyID, companyID.String(), want, false, summary); err != nil {
return 0, 0, 0, err
return res.Updated, res.AlreadyOK, res.EmptySkipped, nil
}
return summary.Updated, summary.AlreadyOK, summary.EmptySkipped, nil
// RepairCompanyCategoryEnhancePromptsWithOptions applies category enhance prompt
// repair for one company. Pass WPCategoriesSQL to force-apply an uploaded dump
// (Admin Sync A1); empty opts keep auto-detect fallback.
func RepairCompanyCategoryEnhancePromptsWithOptions(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID, opts RepairA1DemoOptions) (RepairCategoryEnhancePromptsResult, error) {
out := RepairCategoryEnhancePromptsResult{
ByCompany: map[string]int{},
}
if pool == nil {
return out, fmt.Errorf("nil pool")
}
if len(opts.WPCategoriesSQL) > 0 {
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL); err != nil {
return out, fmt.Errorf("wp_product_categories upload: %w", err)
}
}
seedByNorm, seedByUID, seedMeta := resolveCategoryPromptOverlays(opts)
out.SeedPath = seedMeta.Path
out.WPCategoriesPath = seedMeta.WPPath
out.SeedEntries = seedMeta.Entries
forceFromSeed := seedMeta.ForceFromSeed
if opts.ForceFromSeed != nil {
forceFromSeed = *opts.ForceFromSeed
}
if _, err := repairCompanyCategoryEnhancePrompts(ctx, pool, companyID, companyID.String(), seedByNorm, seedByUID, false, forceFromSeed, &out); err != nil {
return out, err
}
return out, nil
}
// ResolvePlatformDemoCompanyID finds the Platform Demo sandbox (never A1 cohort).
@@ -46,3 +46,54 @@ func TestRepairedCategoryEnhancePromptMapMatchesA1Demo(t *testing.T) {
t.Fatal("template must include {{attrs}}")
}
}
func TestCategoryEnhancePromptMapOKAcceptsSplitOverlay(t *testing.T) {
t.Parallel()
legacy := `GPT predloga:
<name>{Napiši tip izdelka sentence case}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`
split := aiprompts.SplitLegacyCombinedEnhancePrompt(legacy)
m := company.LangPromptMap{"sl": split, company.LangPromptAny: split}
if !categoryEnhancePromptMapOK(m) {
t.Fatal("split overlay should count as OK")
}
}
func TestJsonbTemplatePresent(t *testing.T) {
t.Parallel()
if jsonbTemplatePresent(nil) || jsonbTemplatePresent([]byte("null")) || jsonbTemplatePresent([]byte("{}")) {
t.Fatal("empty templates must be absent")
}
if !jsonbTemplatePresent([]byte(`{"elements":[{"type":"variable","value":"brand"}]}`)) {
t.Fatal("title elements should count")
}
if !jsonbTemplatePresent([]byte(`{"sections":[{"type":"p","instructions":"x"}]}`)) {
t.Fatal("description sections should count")
}
}
func TestDeriveTitleTemplateJSON(t *testing.T) {
t.Parallel()
got := aiprompts.DeriveTitleTemplateJSON(`Napiši novo ime po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka"`)
if aiprompts.TitleTemplateNeedsRepair(got) {
t.Fatalf("derived formula still needs repair: %s", got)
}
if !strings.Contains(got, "product_type") || !strings.Contains(got, "brand") || !strings.Contains(got, "product_model") {
t.Fatalf("expected type+brand+model: %s", got)
}
fallback := aiprompts.DeriveTitleTemplateJSON("")
if !strings.Contains(fallback, "brand") || !strings.Contains(fallback, "product_model") {
t.Fatalf("fallback title template: %s", fallback)
}
if aiprompts.TitleTemplateIsBrandOnly([]byte(`{"elements":[{"type":"variable","value":"brand"}]}`)) != true {
t.Fatal("single brand stub must be brand-only")
}
}
func TestNormalizeCategoryPromptName(t *testing.T) {
t.Parallel()
if got := normalizeCategoryPromptName(" Avtosedeži in lupinice "); got != "avtosedezi in lupinice" {
t.Fatalf("got %q", got)
}
}
+563 -35
View File
@@ -2,12 +2,17 @@ package catalog
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
"github.com/descrybe/descrybe-v2/apps/api/internal/company"
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,24 +32,57 @@ type RepairCategoryEnhancePromptsResult struct {
Updated int `json:"updated"`
AlreadyOK int `json:"already_ok"`
EmptySkipped int `json:"empty_skipped"`
SeedMatched int `json:"seed_matched"`
SplitFromLegacy int `json:"split_from_legacy"`
FallbackTemplate int `json:"fallback_template"`
TitleTemplateBackfill int `json:"title_template_backfill"`
DescTemplateBackfill int `json:"description_template_backfill"`
ByCompany map[string]int `json:"by_company"`
DryRun bool `json:"dry_run"`
SeedPath string `json:"seed_path,omitempty"`
// WPCategoriesPath is set when overlays came from wp_product_categories.sql.
WPCategoriesPath string `json:"wp_categories_path,omitempty"`
SeedEntries int `json:"seed_entries,omitempty"`
}
// RepairA1DemoCategoryEnhancePrompts replaces legacy HTML marketing categories.prompt
// values for A1 Slovenija + Platform Demo with aiprompts.CategoryEnhanceUserTemplate
// (role-sectioned title/description/meta/attributes USER overlay).
// RepairA1DemoOptions configures optional seed overlay path for legacy splits.
type RepairA1DemoOptions struct {
// SeedPromptsPath points at a1-category-prompts.json OR wp_product_categories.sql.
// Empty → auto-detect WP SQL (SEED_A1_WP_CATEGORIES / Downloads), else JSON seed.
SeedPromptsPath string
// WPCategoriesPath forces wp_product_categories.sql (overrides SeedPromptsPath when set).
WPCategoriesPath string
// WPCategoriesSQL is uploaded dump bytes (primary for Admin Sync A1 in prod).
// When non-empty, takes precedence over filesystem auto-detect / path options.
WPCategoriesSQL []byte
// ForceFromSeed overwrites already-sectioned prompts when a seed match exists
// (WP dump / JSON is source of truth). Default true when WP SQL is resolved.
ForceFromSeed *bool
}
// RepairA1DemoCategoryEnhancePrompts replaces non-sectioned / legacy combined
// categories.prompt values for A1 Slovenija + Platform Demo with role-sectioned
// overlays (Title / Description / Meta / Attributes). Prefers wp_product_categories.sql
// (SEED_A1_WP_CATEGORIES / Downloads) as source of truth, then a1-category-prompts.json,
// splitting combined Name+Description prompts so naming rules land under Title and
// HTML body under Description; otherwise fall back to CategoryEnhanceUserTemplate.
//
// LOCAL repair only (idempotent):
// Also repairs empty or brand-only title_template and empty description_template
// from legacy <name> / HTML / meta blocks so enhance uses real A1 formulas.
//
// LOCAL repair only (idempotent when seed unchanged):
// - Writes JSON-compatible user overlays under both "sl" (prompt->>'sl') and "*"
// (LangPromptAny) so language stays via {{language}}, not hardcoded-only copy.
// - Touches ONLY categories.prompt — never title_template / description_template
// (unique name/description/meta formulas stay intact; AppendFormulaConstraints
// encodes them as plain-text instructions at enhance render time).
// (LangPromptAny) so language stays via {{language}}.
// - dryRun=true: count WouldUpdate only; dryRun=false: apply and set Updated.
//
// Entrypoint: go run ./cmd/repair-category-prompts (-dry-run | -apply).
func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool, dryRun bool) (RepairCategoryEnhancePromptsResult, error) {
return RepairA1DemoCategoryEnhancePromptsWithOptions(ctx, pool, dryRun, RepairA1DemoOptions{})
}
// RepairA1DemoCategoryEnhancePromptsWithOptions is RepairA1DemoCategoryEnhancePrompts
// with an explicit seed / WP dump path.
func RepairA1DemoCategoryEnhancePromptsWithOptions(ctx context.Context, pool *pgxpool.Pool, dryRun bool, opts RepairA1DemoOptions) (RepairCategoryEnhancePromptsResult, error) {
out := RepairCategoryEnhancePromptsResult{
DryRun: dryRun,
ByCompany: map[string]int{},
@@ -53,6 +91,12 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, fmt.Errorf("postgres pool is required")
}
if len(opts.WPCategoriesSQL) > 0 {
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL); err != nil {
return out, fmt.Errorf("wp_product_categories upload: %w", err)
}
}
a1ID, err := uuid.Parse(a1CompanyID)
if err != nil {
return out, fmt.Errorf("a1 company id: %w", err)
@@ -90,13 +134,17 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, fmt.Errorf("no A1 / Platform Demo companies found")
}
want, err := repairedCategoryEnhancePromptMap()
if err != nil {
return out, err
seedByNorm, seedByUID, seedMeta := resolveCategoryPromptOverlays(opts)
out.SeedPath = seedMeta.Path
out.WPCategoriesPath = seedMeta.WPPath
out.SeedEntries = seedMeta.Entries
forceFromSeed := seedMeta.ForceFromSeed
if opts.ForceFromSeed != nil {
forceFromSeed = *opts.ForceFromSeed
}
for _, c := range companies {
n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, want, dryRun, &out)
n, err := repairCompanyCategoryEnhancePrompts(ctx, pool, c.id, c.name, seedByNorm, seedByUID, dryRun, forceFromSeed, &out)
if err != nil {
return out, err
}
@@ -107,9 +155,190 @@ func RepairA1DemoCategoryEnhancePrompts(ctx context.Context, pool *pgxpool.Pool,
return out, nil
}
// repairedCategoryEnhancePromptMap is the canonical stored shape: "sl" (prompt->>'sl'
// for A1/Demo) plus LangPromptAny ("*") so PromptForLanguage resolves for any content
// language. Copy stays language-agnostic via {{language}} — not hardcoded Slovenian.
type categoryPromptOverlayMeta struct {
Path string
WPPath string
Entries int
ForceFromSeed bool
}
func resolveCategoryPromptOverlays(opts RepairA1DemoOptions) (byNorm, byUID map[string]string, meta categoryPromptOverlayMeta) {
byNorm = map[string]string{}
byUID = map[string]string{}
if len(opts.WPCategoriesSQL) > 0 {
n, u, err := LoadWPCategoryPromptOverlaysFromBytes(opts.WPCategoriesSQL)
if err == nil {
meta.Path = "upload:wp_product_categories.sql"
meta.WPPath = "upload:wp_product_categories.sql"
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
wpPath := strings.TrimSpace(opts.WPCategoriesPath)
if wpPath == "" {
wpPath = ResolveWPCategoryPromptsPath("")
} else {
wpPath = ResolveWPCategoryPromptsPath(wpPath)
}
if wpPath != "" {
n, u, err := loadWPCategoryPromptOverlays(wpPath)
if err == nil {
meta.Path = wpPath
meta.WPPath = wpPath
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
seedPath := strings.TrimSpace(opts.SeedPromptsPath)
if seedPath == "" {
seedPath = resolveA1CategoryPromptsPath()
}
if seedPath != "" && isWPCategoryPromptsSQLPath(seedPath) {
n, u, err := loadWPCategoryPromptOverlays(seedPath)
if err == nil {
meta.Path = seedPath
meta.WPPath = seedPath
meta.Entries = len(n)
meta.ForceFromSeed = true
return n, u, meta
}
}
if seedPath != "" {
n, u, err := loadA1CategoryPromptOverlays(seedPath)
if err == nil {
meta.Path = seedPath
meta.Entries = len(n)
meta.ForceFromSeed = false
return n, u, meta
}
}
return byNorm, byUID, meta
}
func resolveA1CategoryPromptsPath() string {
candidates := []string{
filepath.Join("scripts", "seed", "a1-category-prompts.json"),
filepath.Join("..", "..", "scripts", "seed", "a1-category-prompts.json"),
filepath.Join("..", "..", "..", "scripts", "seed", "a1-category-prompts.json"),
}
// Walk up from cwd looking for scripts/seed/a1-category-prompts.json.
if wd, err := os.Getwd(); err == nil {
dir := wd
for i := 0; i < 8; i++ {
candidates = append(candidates, filepath.Join(dir, "scripts", "seed", "a1-category-prompts.json"))
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
}
for _, p := range candidates {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
type a1CategoryPromptFile struct {
Entries []a1CategoryPromptEntry `json:"entries"`
}
type a1CategoryPromptEntry struct {
Name string `json:"name"`
UniqueID string `json:"unique_id,omitempty"`
Prompt string `json:"prompt"`
}
func loadA1CategoryPromptOverlays(path string) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return byNorm, byUID, fmt.Errorf("seed prompts path empty")
}
if filepath.Base(path) != "a1-category-prompts.json" {
return byNorm, byUID, fmt.Errorf("refusing unexpected category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return byNorm, byUID, err
}
if len(raw) > 8<<20 {
return byNorm, byUID, fmt.Errorf("category prompts file too large (%d bytes)", len(raw))
}
var f a1CategoryPromptFile
if err := json.Unmarshal(raw, &f); err != nil {
return byNorm, byUID, err
}
for _, e := range f.Entries {
p := strings.TrimSpace(e.Prompt)
if p == "" {
continue
}
if uid := strings.ToLower(strings.TrimSpace(e.UniqueID)); uid != "" {
byUID[uid] = p
}
if key := normalizeCategoryPromptName(e.Name); key != "" {
byNorm[key] = p
}
}
if len(byNorm) == 0 && len(byUID) == 0 {
return byNorm, byUID, fmt.Errorf("no usable seed prompt entries")
}
return byNorm, byUID, nil
}
func normalizeCategoryPromptName(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
s = strings.ToLower(s)
var b strings.Builder
b.Grow(len(s))
prevSpace := false
for _, r := range s {
r = foldSlovenePromptRune(r)
if unicode.IsSpace(r) {
if prevSpace || b.Len() == 0 {
continue
}
b.WriteByte(' ')
prevSpace = true
continue
}
prevSpace = false
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '/' || r == '&' || r == '+' {
b.WriteRune(r)
}
}
return strings.TrimSpace(b.String())
}
func foldSlovenePromptRune(r rune) rune {
switch r {
case 'č', 'ć':
return 'c'
case 'š':
return 's'
case 'ž':
return 'z'
case 'đ':
return 'd'
default:
return r
}
}
// repairedCategoryEnhancePromptMap is the shared fallback overlay (no per-category
// Slovenian rules): "sl" + LangPromptAny ("*").
func repairedCategoryEnhancePromptMap() (company.LangPromptMap, error) {
tpl := strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate)
if tpl == "" {
@@ -121,33 +350,96 @@ func repairedCategoryEnhancePromptMap() (company.LangPromptMap, error) {
}, nil
}
func categoryEnhancePromptMapOK(m company.LangPromptMap, want company.LangPromptMap) bool {
if !company.HasAnyPrompt(m) || !company.HasAnyPrompt(want) {
func categoryEnhancePromptValueOK(p string) bool {
p = strings.TrimSpace(p)
if p == "" {
return false
}
tpl := strings.TrimSpace(want[company.LangPromptAny])
if tpl == "" {
tpl = strings.TrimSpace(want["sl"])
return !aiprompts.CategoryEnhancePromptNeedsRepair(p)
}
if tpl == "" {
func categoryEnhancePromptMapOK(m company.LangPromptMap) bool {
if !company.HasAnyPrompt(m) {
return false
}
// Accept already-repaired maps: every non-empty value equals the shared template
// and LangPromptAny (or legacy sl-only) is present.
hasKey := false
for lang, p := range m {
for _, p := range m {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if p != tpl {
if !categoryEnhancePromptValueOK(p) {
return false
}
if lang == company.LangPromptAny || lang == "sl" {
hasKey = true
}
// Require sl or * present.
if strings.TrimSpace(m[company.LangPromptAny]) == "" && strings.TrimSpace(m["sl"]) == "" {
return false
}
return true
}
func pickSeedPrompt(uniqueID, name string, byNorm, byUID map[string]string) string {
if uid := strings.ToLower(strings.TrimSpace(uniqueID)); uid != "" {
if p, ok := byUID[uid]; ok {
return p
}
}
return hasKey
if key := normalizeCategoryPromptName(name); key != "" {
if p, ok := byNorm[key]; ok {
return p
}
}
return ""
}
func resolveRepairedEnhancePrompt(current company.LangPromptMap, seedLegacy string, preferSeed bool, out *RepairCategoryEnhancePromptsResult) string {
prompt, fromSeed, fromLegacy, fallback := computeRepairedEnhancePrompt(current, seedLegacy, preferSeed)
if fromSeed {
out.SeedMatched++
}
if fromLegacy {
out.SplitFromLegacy++
}
if fallback {
out.FallbackTemplate++
}
return prompt
}
func computeRepairedEnhancePrompt(current company.LangPromptMap, seedLegacy string, preferSeed bool) (prompt string, fromSeed, fromLegacy, fallback bool) {
trySeed := func(raw string) (string, bool, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false, false
}
if aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(raw), true, false
}
if aiprompts.CategoryEnhanceHasRoleSections(raw) {
return security.SanitizePrompt(raw, MaxCategoryPromptRunes), false, false
}
return strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate), false, true
}
if preferSeed && seedLegacy != "" {
p, leg, fb := trySeed(seedLegacy)
return p, true, leg, fb
}
for _, lang := range []string{"sl", company.LangPromptAny} {
if cur := strings.TrimSpace(current[lang]); aiprompts.IsLegacyCombinedEnhancePrompt(cur) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(cur), false, true, false
}
}
for _, cur := range current {
if aiprompts.IsLegacyCombinedEnhancePrompt(cur) {
return aiprompts.SplitLegacyCombinedEnhancePrompt(cur), false, true, false
}
}
if seedLegacy != "" {
p, leg, fb := trySeed(seedLegacy)
return p, true, leg, fb
}
return strings.TrimSpace(aiprompts.CategoryEnhanceUserTemplate), false, false, true
}
func repairCompanyCategoryEnhancePrompts(
@@ -155,12 +447,19 @@ func repairCompanyCategoryEnhancePrompts(
pool *pgxpool.Pool,
companyID uuid.UUID,
companyName string,
want company.LangPromptMap,
seedByNorm map[string]string,
seedByUID map[string]string,
dryRun bool,
forceFromSeed bool,
out *RepairCategoryEnhancePromptsResult,
) (int, error) {
rows, err := pool.Query(ctx, `
SELECT id, COALESCE(prompt, '{}'::jsonb)
SELECT id,
COALESCE(unique_id, ''),
name,
COALESCE(prompt, '{}'::jsonb),
title_template,
description_template
FROM categories
WHERE company_id = $1`, companyID)
if err != nil {
@@ -171,8 +470,10 @@ func repairCompanyCategoryEnhancePrompts(
updatedHere := 0
for rows.Next() {
var id uuid.UUID
var uniqueID, name string
var raw []byte
if err := rows.Scan(&id, &raw); err != nil {
var titleTpl, descTpl []byte
if err := rows.Scan(&id, &uniqueID, &name, &raw, &titleTpl, &descTpl); err != nil {
return updatedHere, err
}
out.CategoriesSeen++
@@ -181,21 +482,100 @@ func repairCompanyCategoryEnhancePrompts(
if err != nil {
return updatedHere, fmt.Errorf("decode prompt category=%s company=%s: %w", id, companyName, err)
}
seedLegacy := pickSeedPrompt(uniqueID, name, seedByNorm, seedByUID)
needPrompt := company.HasAnyPrompt(m) && !categoryEnhancePromptMapOK(m)
// Empty prompt → write sectioned overlay (seed split when available, else shared template).
if !company.HasAnyPrompt(m) {
needPrompt = true
}
var wantPrompt string
if seedLegacy != "" && (forceFromSeed || needPrompt) {
wantPrompt, _, _, _ = computeRepairedEnhancePrompt(m, seedLegacy, forceFromSeed)
wantPrompt = security.SanitizePrompt(wantPrompt, MaxCategoryPromptRunes)
if cur := currentEnhancePrompt(m); forceFromSeed && cur != "" && cur == wantPrompt && categoryEnhancePromptMapOK(m) {
needPrompt = false
wantPrompt = ""
} else if wantPrompt != "" {
needPrompt = true
}
}
titleRules := legacyTitleRules(m, seedLegacy)
needTitle := aiprompts.TitleTemplateNeedsRepair(titleTpl)
legacyParts := legacyEnhanceParts(m, seedLegacy)
needDesc := aiprompts.DescriptionTemplateNeedsRepair(descTpl)
var wantTitleTpl, wantDescTpl string
if forceFromSeed && seedLegacy != "" {
if strings.TrimSpace(titleRules) != "" {
wantTitleTpl = aiprompts.DeriveTitleTemplateJSON(titleRules)
if !jsonbEqual(titleTpl, []byte(wantTitleTpl)) {
needTitle = true
} else {
needTitle = false
wantTitleTpl = ""
}
}
if legacyParts.WasLegacy && (strings.TrimSpace(legacyParts.DescriptionRules) != "" || strings.TrimSpace(legacyParts.MetaRules) != "") {
wantDescTpl = aiprompts.DeriveDescriptionTemplateJSON(legacyParts)
if strings.TrimSpace(wantDescTpl) != "" && !jsonbEqual(descTpl, []byte(wantDescTpl)) {
needDesc = true
} else if strings.TrimSpace(wantDescTpl) == "" || jsonbEqual(descTpl, []byte(wantDescTpl)) {
needDesc = false
wantDescTpl = ""
}
}
}
if needDesc && strings.TrimSpace(legacyParts.DescriptionRules) == "" && strings.TrimSpace(legacyParts.MetaRules) == "" {
needDesc = false
}
if !needPrompt && !needTitle && !needDesc {
if company.HasAnyPrompt(m) {
out.AlreadyOK++
} else {
out.EmptySkipped++
}
continue
}
if categoryEnhancePromptMapOK(m, want) {
out.AlreadyOK++
continue
if needPrompt && wantPrompt == "" {
wantPrompt = resolveRepairedEnhancePrompt(m, seedLegacy, forceFromSeed && seedLegacy != "", out)
wantPrompt = security.SanitizePrompt(wantPrompt, MaxCategoryPromptRunes)
if wantPrompt == "" {
return updatedHere, fmt.Errorf("repaired prompt empty category=%s", id)
}
} else if needPrompt {
// Record seed/split stats for the prompt we already computed.
_, fromSeed, fromLegacy, fallback := computeRepairedEnhancePrompt(m, seedLegacy, forceFromSeed && seedLegacy != "")
if fromSeed {
out.SeedMatched++
}
if fromLegacy {
out.SplitFromLegacy++
}
if fallback {
out.FallbackTemplate++
}
}
out.WouldUpdate++
if dryRun {
if needTitle {
out.TitleTemplateBackfill++
}
if needDesc {
out.DescTemplateBackfill++
}
updatedHere++
continue
}
if needPrompt {
want := company.LangPromptMap{
"sl": wantPrompt,
company.LangPromptAny: wantPrompt,
}
cleaned, err := company.SanitizeLangPromptMap(want, MaxCategoryPromptRunes)
if err != nil {
return updatedHere, fmt.Errorf("sanitize prompt category=%s: %w", id, err)
@@ -214,8 +594,156 @@ func repairCompanyCategoryEnhancePrompts(
if ct.RowsAffected() == 0 {
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
}
}
if needTitle {
tpl := wantTitleTpl
if tpl == "" {
tpl = aiprompts.DeriveTitleTemplateJSON(titleRules)
}
ct, err := pool.Exec(ctx, `
UPDATE categories
SET title_template = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, tpl)
if err != nil {
return updatedHere, fmt.Errorf("backfill title_template category=%s: %w", id, err)
}
if ct.RowsAffected() > 0 {
out.TitleTemplateBackfill++
}
}
if needDesc {
tpl := wantDescTpl
if tpl == "" {
tpl = aiprompts.DeriveDescriptionTemplateJSON(legacyParts)
}
if strings.TrimSpace(tpl) != "" {
ct, err := pool.Exec(ctx, `
UPDATE categories
SET description_template = $3::jsonb, updated_at = now()
WHERE id = $1 AND company_id = $2`, id, companyID, tpl)
if err != nil {
return updatedHere, fmt.Errorf("backfill description_template category=%s: %w", id, err)
}
if ct.RowsAffected() > 0 {
out.DescTemplateBackfill++
}
}
}
out.Updated++
updatedHere++
}
return updatedHere, rows.Err()
}
func currentEnhancePrompt(m company.LangPromptMap) string {
for _, lang := range []string{"sl", company.LangPromptAny} {
if p := strings.TrimSpace(m[lang]); p != "" {
return p
}
}
for _, p := range m {
if t := strings.TrimSpace(p); t != "" {
return t
}
}
return ""
}
func legacyEnhanceParts(current company.LangPromptMap, seedLegacy string) aiprompts.LegacyEnhanceParts {
if seedLegacy != "" {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(seedLegacy)
if parts.WasLegacy {
return parts
}
}
for _, lang := range []string{"sl", company.LangPromptAny} {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(current[lang])
if parts.WasLegacy {
return parts
}
}
for _, cur := range current {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(cur)
if parts.WasLegacy {
return parts
}
}
return aiprompts.LegacyEnhanceParts{}
}
func jsonbTemplatePresent(raw []byte) bool {
s := strings.TrimSpace(string(raw))
if s == "" || s == "null" || s == "{}" || s == "[]" {
return false
}
var obj map[string]any
if err := json.Unmarshal(raw, &obj); err != nil {
return true // non-empty non-object still counts as present
}
if elems, ok := obj["elements"].([]any); ok && len(elems) > 0 {
return true
}
if sections, ok := obj["sections"].([]any); ok && len(sections) > 0 {
return true
}
if mt, ok := obj["metaTitle"].(string); ok && strings.TrimSpace(mt) != "" {
return true
}
if md, ok := obj["metaDescription"].(string); ok && strings.TrimSpace(md) != "" {
return true
}
// Any other non-empty keys.
return len(obj) > 0
}
func jsonbEqual(a, b []byte) bool {
as := strings.TrimSpace(string(a))
bs := strings.TrimSpace(string(b))
if as == "" || as == "null" {
as = ""
}
if bs == "" || bs == "null" {
bs = ""
}
if as == bs {
return true
}
var ao, bo any
if err := json.Unmarshal([]byte(as), &ao); err != nil {
return false
}
if err := json.Unmarshal([]byte(bs), &bo); err != nil {
return false
}
ab, err1 := json.Marshal(ao)
bb, err2 := json.Marshal(bo)
if err1 != nil || err2 != nil {
return false
}
return string(ab) == string(bb)
}
func legacyTitleRules(current company.LangPromptMap, seedLegacy string) string {
if seedLegacy != "" {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(seedLegacy)
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
for _, lang := range []string{"sl", company.LangPromptAny} {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(current[lang])
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
for _, cur := range current {
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(cur)
if parts.WasLegacy && strings.TrimSpace(parts.TitleRules) != "" {
return parts.TitleRules
}
}
return ""
}
@@ -21,19 +21,19 @@ func TestRepairedCategoryEnhancePromptMap(t *testing.T) {
if want[company.LangPromptAny] != tpl {
t.Fatalf("want * = shared template")
}
if !categoryEnhancePromptMapOK(want, want) {
if !categoryEnhancePromptMapOK(want) {
t.Fatal("canonical map should be OK")
}
legacy := company.LangPromptMap{"sl": "<H2>legacy HTML marketing</H2>"}
if categoryEnhancePromptMapOK(legacy, want) {
if categoryEnhancePromptMapOK(legacy) {
t.Fatal("legacy HTML must need repair")
}
slOnly := company.LangPromptMap{"sl": tpl}
if !categoryEnhancePromptMapOK(slOnly, want) {
if !categoryEnhancePromptMapOK(slOnly) {
t.Fatal("sl-only repaired map should be OK (idempotent)")
}
starOnly := company.LangPromptMap{company.LangPromptAny: tpl}
if !categoryEnhancePromptMapOK(starOnly, want) {
if !categoryEnhancePromptMapOK(starOnly) {
t.Fatal("*-only repaired map should be OK (idempotent)")
}
}
@@ -46,7 +46,28 @@ func TestRepairTargetsUseSharedTemplate(t *testing.T) {
t.Fatalf("shared template missing {{%s}}", v)
}
}
if !strings.Contains(strings.ToLower(tpl), "never brand-only") {
t.Fatal("Title section must forbid brand-only names")
}
if a1CompanyID == "" || platformDemoCompanyName == "" {
t.Fatal("missing company target constants")
}
}
func TestLegacyTitleRulesFromSeedShape(t *testing.T) {
t.Parallel()
seed := `Ustvari nov opis
GPT predloga:
<name>{Napiši novo ime izdelka po formuli: "tip izdelka sentence case", "znamka s pravilno kapitalizacijo", "poln model izdelka". Ne uporabljaj vejic.}</name>
<metaDescription>{140 znakov}</metaDescription>
<H2>{benefit}</H2>`
rules := legacyTitleRules(nil, seed)
if !strings.Contains(strings.ToLower(rules), "znamka") {
t.Fatalf("expected title rules from seed, got %q", rules)
}
raw := aiprompts.DeriveTitleTemplateJSON(rules)
if aiprompts.TitleTemplateNeedsRepair(raw) {
t.Fatalf("derived title_template still needs repair: %s", raw)
}
}
@@ -0,0 +1,313 @@
package catalog
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"unicode/utf8"
)
const (
wpProductCategoriesFile = "wp_product_categories.sql"
envSeedA1WPCategories = "SEED_A1_WP_CATEGORIES"
// MaxWPCategorySQLBytes caps uploaded / on-disk wp_product_categories.sql size (16 MiB).
MaxWPCategorySQLBytes = 16 << 20
)
// ResolveWPCategoryPromptsPath picks an explicit path, else the first readable
// wp_product_categories.sql candidate (SEED_A1_WP_CATEGORIES + Downloads + scripts/seed).
// Used by Sync A1 / RepairCompanyCategoryEnhancePrompts as the A1 prompt source of truth.
func ResolveWPCategoryPromptsPath(explicit string) string {
if p := strings.TrimSpace(explicit); p != "" {
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
log.Printf("warning: wp category prompts not found at %q — trying auto-detect", p)
}
if v := strings.TrimSpace(os.Getenv(envSeedA1WPCategories)); v != "" {
if st, err := os.Stat(v); err == nil && !st.IsDir() {
return v
}
log.Printf("warning: %s=%q not readable — trying Downloads / scripts/seed", envSeedA1WPCategories, v)
}
for _, c := range WPCategoryPromptsCandidates() {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return ""
}
// WPCategoryPromptsCandidates lists local paths Sync A1 / repair try when
// SEED_A1_WP_CATEGORIES is unset. First readable file wins via ResolveWPCategoryPromptsPath.
func WPCategoryPromptsCandidates() []string {
var out []string
if v := strings.TrimSpace(os.Getenv(envSeedA1WPCategories)); v != "" {
out = append(out, v)
}
names := []string{
wpProductCategoriesFile,
"wp_product_categories (1).sql",
"wp_product_categories(1).sql",
}
home, _ := os.UserHomeDir()
if home != "" {
for _, n := range names {
out = append(out, filepath.Join(home, "Downloads", n))
out = append(out, filepath.Join(home, "downloads", n))
}
// Windows secondary profile Downloads (e.g. D:\Users\…\Downloads).
for _, driveRoot := range []string{`D:\`, `C:\`} {
alt := filepath.Join(driveRoot, "Users", filepath.Base(home), "Downloads")
for _, n := range names {
out = append(out, filepath.Join(alt, n))
}
}
}
for _, n := range names {
out = append(out,
n,
filepath.Join("..", "..", n),
filepath.Join("scripts", "seed", n),
filepath.Join("..", "..", "scripts", "seed", n),
)
}
if root, ok := findMonorepoRootFromCwd(); ok {
for _, n := range names {
out = append(out, filepath.Join(root, "scripts", "seed", n))
}
}
return out
}
func findMonorepoRootFromCwd() (string, bool) {
cwd, err := os.Getwd()
if err != nil {
return "", false
}
dir := cwd
for {
api := filepath.Join(dir, "apps", "api")
web := filepath.Join(dir, "apps", "web")
if st, err := os.Stat(api); err == nil && st.IsDir() {
if st, err := os.Stat(web); err == nil && st.IsDir() {
return dir, true
}
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
dir = parent
}
}
func isWPCategoryPromptsSQLPath(path string) bool {
base := strings.ToLower(filepath.Base(strings.TrimSpace(path)))
if base == "" {
return false
}
if base == wpProductCategoriesFile {
return true
}
return strings.HasPrefix(base, "wp_product_categories") && strings.HasSuffix(base, ".sql")
}
// loadWPCategoryPromptOverlays parses Name→Prompt pairs from a wp_product_categories.sql dump.
func loadWPCategoryPromptOverlays(path string) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
path = filepath.Clean(strings.TrimSpace(path))
if path == "" || path == "." {
return byNorm, byUID, fmt.Errorf("wp category prompts path empty")
}
if !isWPCategoryPromptsSQLPath(path) {
return byNorm, byUID, fmt.Errorf("refusing unexpected wp category prompts file name %q", filepath.Base(path))
}
raw, err := os.ReadFile(path)
if err != nil {
return byNorm, byUID, err
}
return LoadWPCategoryPromptOverlaysFromBytes(raw)
}
// LoadWPCategoryPromptOverlaysFromBytes parses an uploaded or in-memory
// wp_product_categories.sql dump. Enforces MaxWPCategorySQLBytes and UTF-8.
func LoadWPCategoryPromptOverlaysFromBytes(raw []byte) (byNorm map[string]string, byUID map[string]string, err error) {
byNorm = map[string]string{}
byUID = map[string]string{}
if len(raw) == 0 {
return byNorm, byUID, fmt.Errorf("empty wp_product_categories sql")
}
if len(raw) > MaxWPCategorySQLBytes {
return byNorm, byUID, fmt.Errorf("wp category prompts file too large (%d bytes)", len(raw))
}
if !utf8.Valid(raw) {
return byNorm, byUID, fmt.Errorf("wp category prompts file is not valid UTF-8")
}
entries, err := ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return byNorm, byUID, err
}
for _, e := range entries {
p := strings.TrimSpace(e.Prompt)
if p == "" {
continue
}
if uid := strings.ToLower(strings.TrimSpace(e.UniqueID)); uid != "" {
byUID[uid] = p
}
if key := normalizeCategoryPromptName(e.Name); key != "" {
byNorm[key] = p
}
}
if len(byNorm) == 0 && len(byUID) == 0 {
return byNorm, byUID, fmt.Errorf("no usable wp_product_categories prompt entries")
}
return byNorm, byUID, nil
}
// ParseWPProductCategoriesSQL extracts (Name, Prompt) rows from a mysqldump-style
// INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES … statement.
func ParseWPProductCategoriesSQL(sql string) ([]a1CategoryPromptEntry, error) {
sql = strings.TrimSpace(sql)
if sql == "" {
return nil, fmt.Errorf("empty wp_product_categories sql")
}
lower := strings.ToLower(sql)
if !strings.Contains(lower, "wp_product_categories") {
return nil, fmt.Errorf("sql does not reference wp_product_categories")
}
out := make([]a1CategoryPromptEntry, 0, 128)
i := 0
for i < len(sql) {
name, prompt, next, ok := scanWPCategoryRow(sql, i)
if !ok {
break
}
i = next
name = strings.TrimSpace(name)
prompt = strings.TrimSpace(prompt)
if name == "" || prompt == "" {
continue
}
out = append(out, a1CategoryPromptEntry{Name: name, Prompt: prompt})
}
if len(out) == 0 {
return nil, fmt.Errorf("no Name/Prompt rows parsed from wp_product_categories sql")
}
if len(out) > 5000 {
return nil, fmt.Errorf("too many wp_product_categories rows (%d)", len(out))
}
return out, nil
}
// scanWPCategoryRow finds the next ('Name', 'Prompt') tuple starting at from.
func scanWPCategoryRow(sql string, from int) (name, prompt string, next int, ok bool) {
// Locate opening (' after from.
i := from
for i < len(sql) {
if sql[i] == '(' && i+1 < len(sql) && sql[i+1] == '\'' {
break
}
i++
}
if i >= len(sql) {
return "", "", from, false
}
i += 2 // past ('
nameRaw, i2, err := scanMySQLQuotedString(sql, i)
if err != nil {
return "", "", from, false
}
i = i2
// Expect , then optional whitespace then '
for i < len(sql) && (sql[i] == ' ' || sql[i] == '\t' || sql[i] == '\n' || sql[i] == '\r') {
i++
}
if i >= len(sql) || sql[i] != ',' {
return "", "", from, false
}
i++
for i < len(sql) && (sql[i] == ' ' || sql[i] == '\t' || sql[i] == '\n' || sql[i] == '\r') {
i++
}
if i >= len(sql) || sql[i] != '\'' {
return "", "", from, false
}
i++
promptRaw, i3, err := scanMySQLQuotedString(sql, i)
if err != nil {
return "", "", from, false
}
return nameRaw, promptRaw, i3, true
}
// scanMySQLQuotedString reads a mysqldump string whose opening quote was already consumed.
// Handles ”, \', \", \\, \n, \r, \t, \0, and leaves the index after the closing quote.
func scanMySQLQuotedString(s string, start int) (string, int, error) {
var b strings.Builder
b.Grow(256)
i := start
for i < len(s) {
c := s[i]
if c == '\\' && i+1 < len(s) {
n := s[i+1]
switch n {
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case '0':
b.WriteByte(0)
case 'b':
b.WriteByte('\b')
case 'Z':
b.WriteByte(0x1a)
case '\'', '"', '\\':
b.WriteByte(n)
default:
// MySQL keeps the second char for unknown escapes.
b.WriteByte(n)
}
i += 2
continue
}
if c == '\'' {
if i+1 < len(s) && s[i+1] == '\'' {
b.WriteByte('\'')
i += 2
continue
}
return b.String(), i + 1, nil
}
b.WriteByte(c)
i++
}
return "", start, fmt.Errorf("unterminated mysql string")
}
// ReadWPCategoryPromptOverlaysFromReader is a test helper around ParseWPProductCategoriesSQL.
func ReadWPCategoryPromptOverlaysFromReader(r io.Reader) (byNorm map[string]string, err error) {
raw, err := io.ReadAll(r)
if err != nil {
return nil, err
}
entries, err := ParseWPProductCategoriesSQL(string(raw))
if err != nil {
return nil, err
}
byNorm = map[string]string{}
for _, e := range entries {
if key := normalizeCategoryPromptName(e.Name); key != "" && strings.TrimSpace(e.Prompt) != "" {
byNorm[key] = e.Prompt
}
}
return byNorm, nil
}
@@ -0,0 +1,166 @@
package catalog
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
)
func TestParseWPProductCategoriesSQL_SlusalkeSplit(t *testing.T) {
t.Parallel()
sql := "SET NAMES utf8mb4;\n\n" +
"INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n" +
"('Slušalke',\t'Ustvari nov opis izdelka v Slovenščini z naslednjimi spremenljivkami:\\n\\n" +
"Star_opis_izdelka: {\\\"\\\"OPIS IZDELKA\\\"\\\"};\\n" +
"Staro_ime_izdelka: {\\\"\\\"STARO IME IZDELKA\\\"\\\"};\\n\\n" +
"Uporabi spodnjo GPT predlogo. \\n\\n" +
"Sledi tej GPT predlogi stavek po stavek in sestavi nov opis izdelka:\\n\\n" +
"GPT predloga:\\n\\n\\n" +
"<name>{Napiši novo ime izdelka po formuli: \\\"\\\"znamka s pravilno kapitalizacijo\\\"\\\", \\\"\\\"tip izdelka lowercase\\\"\\\", \\\"\\\"\\\"poln model izdelka, če lahko z besedo in ID uppercase\\\"\\\"\\\". Ne uporabljaj vejic.}</name>\\n" +
"<metaDescription>{Najprej napiši Novo_ime_izdelka in potem nadaljuj dokler nisi zapisal 140 znakov vključno s presledki}</metaDescription>\\n\\n" +
"<H2>{Napiši Novo ime izdelka in izpostavi en benefit}</H2>\\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed, ki NE vsebuje Novo ime izdelka.}</p>\\n" +
"<H2>{Izpostavi en benefit, in NE napiši Novo ime izdelka}</H2>\\n" +
"<p>{Napiši odstavek, ki je dolg 100 besed in VKLJUČI tudi Novo ime izdelka.}</p>" +
"<b>{Tehnične specifikacije}</b><ul><li>{Napiši 3-10 tehničnih specifikacij v alinejah}</li></ul>');\n"
entries, err := ParseWPProductCategoriesSQL(sql)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("want 1 entry, got %d", len(entries))
}
if entries[0].Name != "Slušalke" {
t.Fatalf("name=%q", entries[0].Name)
}
raw := entries[0].Prompt
if strings.Contains(raw, `\"`) {
t.Fatalf("mysql escapes should be unescaped, still has backslash-quote: %q", raw[:80])
}
if !aiprompts.IsLegacyCombinedEnhancePrompt(raw) {
t.Fatal("parsed prompt should look like legacy combined enhance")
}
split := aiprompts.SplitLegacyCombinedEnhancePrompt(raw)
if !aiprompts.CategoryEnhanceHasRoleSections(split) {
t.Fatal("split must produce role sections")
}
if !strings.Contains(split, aiprompts.SectionTitleStart) || !strings.Contains(split, aiprompts.SectionDescriptionStart) {
t.Fatal("split missing Title/Description section markers")
}
if !strings.Contains(split, "znamka s pravilno kapitalizacijo") {
t.Fatal("Title section must keep naming formula")
}
if !strings.Contains(split, "Tehnične specifikacije") && !strings.Contains(split, "tehničnih specifikacij") {
t.Fatal("Description section must keep HTML body intent")
}
if !strings.Contains(split, "140 znakov") {
t.Fatal("Meta section must keep SEO intent")
}
if aiprompts.IsLegacyCombinedEnhancePrompt(split) {
t.Fatal("split output must not still look like legacy combined prompt")
}
if strings.Contains(split, "<name>{") || strings.Contains(split, "<metaDescription>{") {
t.Fatal("split must not keep legacy wrapper tag bodies")
}
parts := aiprompts.ParseLegacyCombinedEnhancePrompt(raw)
descJSON := aiprompts.DeriveDescriptionTemplateJSON(parts)
if descJSON == "" {
t.Fatal("expected description_template JSON from legacy HTML")
}
if !strings.Contains(descJSON, `"type":"h2"`) && !strings.Contains(descJSON, `"type":"p"`) {
t.Fatalf("description_template should include h2/p sections: %s", descJSON)
}
if !strings.Contains(descJSON, "140 znakov") {
t.Fatalf("description_template should carry metaDescription: %s", descJSON)
}
}
func TestParseWPProductCategoriesSQL_RealDownloadsFile(t *testing.T) {
path := ResolveWPCategoryPromptsPath(`d:\Users\Green Eclipse\Downloads\wp_product_categories.sql`)
if path == "" {
// Also try env / auto-detect without failing CI machines that lack the dump.
path = ResolveWPCategoryPromptsPath("")
}
if path == "" {
t.Skip("wp_product_categories.sql not available on this machine")
}
byNorm, _, err := loadWPCategoryPromptOverlays(path)
if err != nil {
t.Fatal(err)
}
if len(byNorm) < 50 {
t.Fatalf("expected dozens of categories, got %d from %s", len(byNorm), path)
}
key := normalizeCategoryPromptName("Slušalke")
raw, ok := byNorm[key]
if !ok {
t.Fatalf("Slušalke missing from %s (keys=%d)", path, len(byNorm))
}
split := aiprompts.SplitLegacyCombinedEnhancePrompt(raw)
if aiprompts.CategoryEnhancePromptNeedsRepair(split) {
t.Fatal("split of real dump Slušalke should not need repair")
}
if !strings.Contains(split, "tip izdelka lowercase") && !strings.Contains(split, "znamka") {
t.Fatalf("unexpected Title rules in split: %s", split[:min(400, len(split))])
}
}
func TestResolveWPCategoryPromptsPath_Explicit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
p := filepath.Join(dir, "wp_product_categories.sql")
if err := os.WriteFile(p, []byte("INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('X','GPT predloga:\\n<name>{a}</name><metaDescription>{b}</metaDescription>');\n"), 0o644); err != nil {
t.Fatal(err)
}
got := ResolveWPCategoryPromptsPath(p)
if got != p {
t.Fatalf("got %q want %q", got, p)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func TestLoadWPCategoryPromptOverlaysFromBytes(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('Slusalke','GPT predloga:\\n<name>{tip}</name><metaDescription>{140}</metaDescription><H2>{benefit}</H2>');\n"
byNorm, _, err := LoadWPCategoryPromptOverlaysFromBytes([]byte(sql))
if err != nil {
t.Fatal(err)
}
if len(byNorm) != 1 {
t.Fatalf("entries=%d", len(byNorm))
}
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(nil); err == nil {
t.Fatal("expected empty error")
}
huge := make([]byte, MaxWPCategorySQLBytes+1)
if _, _, err := LoadWPCategoryPromptOverlaysFromBytes(huge); err == nil {
t.Fatal("expected too-large error")
}
}
func TestResolveCategoryPromptOverlaysPrefersUploadBytes(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('UploadCat','GPT predloga:\\n<name>{a}</name><metaDescription>{b}</metaDescription>');\n"
byNorm, _, meta := resolveCategoryPromptOverlays(RepairA1DemoOptions{WPCategoriesSQL: []byte(sql)})
if meta.WPPath != "upload:wp_product_categories.sql" {
t.Fatalf("WPPath=%q", meta.WPPath)
}
if !meta.ForceFromSeed {
t.Fatal("upload must force from seed")
}
if byNorm[normalizeCategoryPromptName("UploadCat")] == "" {
t.Fatal("missing upload category")
}
}
@@ -0,0 +1,16 @@
package httpapi
import (
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/mail"
)
func TestAcceptInviteURL(t *testing.T) {
t.Parallel()
got := mail.AcceptInviteURL("http://localhost:28472/", "abc123")
want := "http://localhost:28472/accept-invite?token=abc123"
if got != want {
t.Fatalf("AcceptInviteURL = %q want %q", got, want)
}
}
@@ -1,7 +1,10 @@
package httpapi
import (
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
@@ -12,6 +15,10 @@ import (
"github.com/google/uuid"
)
// syncA1MaxBodyBytes caps JSON or multipart Sync A1 payloads (dump + form fields).
// Slightly above MaxWPCategorySQLBytes for multipart overhead / base64 inflation.
const syncA1MaxBodyBytes = catalog.MaxWPCategorySQLBytes + (2 << 20)
// handleAdminSyncCompanyA1 runs dump category backfill (when dump is on the API
// host filesystem) plus FixCompanyCatalog hygiene. Does not wipe/reimport.
//
@@ -20,9 +27,13 @@ import (
// POST /api/admin/companies/{id}/sync-a1
// POST /api/admin/companies/{id}/fix-catalog (compat alias)
//
// Body: confirm=true required; backfill_categories (default true);
// Body (JSON): confirm=true required; backfill_categories (default true);
// reprocess_sample_limit (default 25, max 200); mysql_dump optional path;
// skip_dump_backfill (default false).
// skip_dump_backfill (default false); wp_product_categories_sql_b64 optional
// base64 of wp_product_categories.sql (primary for category prompts).
//
// Body (multipart/form-data): same fields as form values; file field
// wp_product_categories or wp_categories_sql for the SQL dump upload.
//
// Flash (UI): result → flash.admin.syncA1Success.
func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request) {
@@ -37,18 +48,14 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
return
}
var body struct {
Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
MySQLDump string `json:"mysql_dump"`
SkipDumpBackfill bool `json:"skip_dump_backfill"`
}
if err := DecodeJSON(r, &body); err != nil {
Error(w, http.StatusBadRequest, "invalid json")
r.Body = http.MaxBytesReader(w, r.Body, syncA1MaxBodyBytes)
parsed, err := parseSyncA1Request(r)
if err != nil {
Error(w, http.StatusBadRequest, err.Error())
return
}
if !body.Confirm {
if !parsed.Confirm {
Error(w, http.StatusBadRequest, "confirm must be true")
return
}
@@ -67,12 +74,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
}
backfill := true
if body.BackfillCategories != nil {
backfill = *body.BackfillCategories
if parsed.BackfillCategories != nil {
backfill = *parsed.BackfillCategories
}
sampleLimit := 25
if body.ReprocessSampleLimit != nil {
sampleLimit = *body.ReprocessSampleLimit
if parsed.ReprocessSampleLimit != nil {
sampleLimit = *parsed.ReprocessSampleLimit
}
if sampleLimit < 0 {
sampleLimit = 0
@@ -92,9 +99,10 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
BackfillCategories: backfill,
ReprocessSampleLimit: sampleLimit,
AIPrompts: s.AIPrompts,
WPCategoriesSQL: parsed.WPCategoriesSQL,
},
MySQLDumpPath: strings.TrimSpace(body.MySQLDump),
SkipDumpBackfill: body.SkipDumpBackfill,
MySQLDumpPath: strings.TrimSpace(parsed.MySQLDump),
SkipDumpBackfill: parsed.SkipDumpBackfill,
},
)
if err != nil {
@@ -104,7 +112,12 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
note := "Synced in place (dump categories when available + hygiene); reprocess recommended products manually (no mass reprocess)."
if !result.DumpFound {
note = "Hygiene completed without dump backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy dump."
note = "Hygiene completed without product-dump category backfill. Place descrybe_new.sql on the API host (scripts/seed/ or SEED_A1_MYSQL_DUMP) for mapped category coverage from the legacy product dump."
}
if len(parsed.WPCategoriesSQL) > 0 {
note = "Applied uploaded wp_product_categories.sql (Title/Description/Meta/Attributes sections) + catalog hygiene. Reprocess recommended products manually (no mass reprocess)."
} else if strings.TrimSpace(result.WPCategoriesPath) == "" {
note += " Upload wp_product_categories.sql on Sync A1 for force-applied category prompts when the dump is not on the API host."
}
JSON(w, http.StatusOK, map[string]any{
@@ -118,3 +131,135 @@ func (s *Server) handleAdminSyncCompanyA1(w http.ResponseWriter, r *http.Request
func (s *Server) handleAdminFixCompanyCatalog(w http.ResponseWriter, r *http.Request) {
s.handleAdminSyncCompanyA1(w, r)
}
type syncA1Request struct {
Confirm bool
BackfillCategories *bool
ReprocessSampleLimit *int
MySQLDump string
SkipDumpBackfill bool
WPCategoriesSQL []byte
}
func parseSyncA1Request(r *http.Request) (syncA1Request, error) {
ct := strings.ToLower(strings.TrimSpace(r.Header.Get("Content-Type")))
if strings.HasPrefix(ct, "multipart/form-data") {
return parseSyncA1Multipart(r)
}
return parseSyncA1JSON(r)
}
func parseSyncA1JSON(r *http.Request) (syncA1Request, error) {
var body struct {
Confirm bool `json:"confirm"`
BackfillCategories *bool `json:"backfill_categories"`
ReprocessSampleLimit *int `json:"reprocess_sample_limit"`
MySQLDump string `json:"mysql_dump"`
SkipDumpBackfill bool `json:"skip_dump_backfill"`
WPProductCategoriesB64 string `json:"wp_product_categories_sql_b64"`
WPProductCategoriesPath string `json:"wp_product_categories"` // path-only; ignored for apply (upload required in prod)
}
if err := DecodeJSON(r, &body); err != nil {
return syncA1Request{}, errInvalidJSON
}
out := syncA1Request{
Confirm: body.Confirm,
BackfillCategories: body.BackfillCategories,
ReprocessSampleLimit: body.ReprocessSampleLimit,
MySQLDump: body.MySQLDump,
SkipDumpBackfill: body.SkipDumpBackfill,
}
_ = body.WPProductCategoriesPath // path paste is not enough for prod — ignore
b64 := strings.TrimSpace(body.WPProductCategoriesB64)
if b64 == "" {
return out, nil
}
raw, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
// Allow URL-safe / raw without padding for convenience.
raw, err = base64.RawStdEncoding.DecodeString(b64)
if err != nil {
raw, err = base64.URLEncoding.DecodeString(b64)
}
if err != nil {
return syncA1Request{}, errWPCategoriesB64
}
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
return out, nil
}
func parseSyncA1Multipart(r *http.Request) (syncA1Request, error) {
if err := r.ParseMultipartForm(syncA1MaxBodyBytes); err != nil {
return syncA1Request{}, errInvalidMultipart
}
out := syncA1Request{
Confirm: parseFormBool(r.FormValue("confirm")),
MySQLDump: strings.TrimSpace(r.FormValue("mysql_dump")),
SkipDumpBackfill: parseFormBool(r.FormValue("skip_dump_backfill")),
}
if v := strings.TrimSpace(r.FormValue("backfill_categories")); v != "" {
b := parseFormBool(v)
out.BackfillCategories = &b
}
if v := strings.TrimSpace(r.FormValue("reprocess_sample_limit")); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return syncA1Request{}, errInvalidSampleLimit
}
out.ReprocessSampleLimit = &n
}
file, _, err := r.FormFile("wp_product_categories")
if err != nil {
file, _, err = r.FormFile("wp_categories_sql")
}
if err == nil {
defer file.Close()
raw, readErr := io.ReadAll(io.LimitReader(file, int64(catalog.MaxWPCategorySQLBytes)+1))
if readErr != nil {
return syncA1Request{}, errWPCategoriesRead
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
}
if b64 := strings.TrimSpace(r.FormValue("wp_product_categories_sql_b64")); b64 != "" && len(out.WPCategoriesSQL) == 0 {
raw, decErr := base64.StdEncoding.DecodeString(b64)
if decErr != nil {
return syncA1Request{}, errWPCategoriesB64
}
if len(raw) > catalog.MaxWPCategorySQLBytes {
return syncA1Request{}, errWPCategoriesTooLarge
}
out.WPCategoriesSQL = raw
}
return out, nil
}
func parseFormBool(v string) bool {
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
type syncA1ParseError string
func (e syncA1ParseError) Error() string { return string(e) }
var (
errInvalidJSON = syncA1ParseError("invalid json")
errInvalidMultipart = syncA1ParseError("invalid multipart form")
errInvalidSampleLimit = syncA1ParseError("invalid reprocess_sample_limit")
errWPCategoriesB64 = syncA1ParseError("invalid wp_product_categories_sql_b64")
errWPCategoriesTooLarge = syncA1ParseError("wp_product_categories.sql too large")
errWPCategoriesRead = syncA1ParseError("could not read wp_product_categories upload")
)
@@ -216,6 +216,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
skippedRateLimited := 0
skippedSend := 0
var singleToken string
var singleMode string
singleUser := body.UserID != nil
for _, uid := range targets {
@@ -251,6 +252,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
if err := s.Mail.Send(msg); err != nil {
log.Printf("admin set-password send failed user_id=%s", uid)
skippedSend++
if singleUser {
// Still return the one-time link so admins can share it while impersonating / offline SMTP.
singleToken = token
singleMode = mode
}
continue
}
if smtpOn {
@@ -258,6 +264,7 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
} else if singleUser {
// Share token only for single-user reissue when SMTP is off (no email in response).
singleToken = token
singleMode = mode
}
}
@@ -275,6 +282,11 @@ func (s *Server) handleAdminSendSetPasswordEmails(w http.ResponseWriter, r *http
}
if singleToken != "" {
resp["token"] = singleToken
if singleMode == "hmac" {
resp["accept_url"] = mail.SetPasswordURL(s.Config.WebOrigin, singleToken)
} else {
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, singleToken)
}
}
JSON(w, http.StatusOK, resp)
}
@@ -0,0 +1,78 @@
package httpapi
import (
"bytes"
"encoding/base64"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/descrybe/descrybe-v2/apps/api/internal/catalog"
)
func TestParseSyncA1JSON_Base64Upload(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('A','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
b64 := base64.StdEncoding.EncodeToString([]byte(sql))
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
parsed, err := parseSyncA1JSON(req)
if err != nil {
t.Fatal(err)
}
if !parsed.Confirm {
t.Fatal("confirm")
}
if string(parsed.WPCategoriesSQL) != sql {
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
}
}
func TestParseSyncA1JSON_RejectsOversizedBase64(t *testing.T) {
t.Parallel()
raw := make([]byte, catalog.MaxWPCategorySQLBytes+1)
b64 := base64.StdEncoding.EncodeToString(raw)
body := `{"confirm":true,"wp_product_categories_sql_b64":"` + b64 + `"}`
req := httptest.NewRequest(http.MethodPost, "/sync", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
_, err := parseSyncA1JSON(req)
if err == nil {
t.Fatal("expected too-large error")
}
}
func TestParseSyncA1Multipart_FileUpload(t *testing.T) {
t.Parallel()
sql := "INSERT INTO `wp_product_categories` (`Name`, `Prompt`) VALUES\n('B','GPT predloga:\\n<name>{x}</name><metaDescription>{y}</metaDescription>');\n"
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
if err := w.WriteField("confirm", "true"); err != nil {
t.Fatal(err)
}
part, err := w.CreateFormFile("wp_product_categories", "wp_product_categories.sql")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(sql)); err != nil {
t.Fatal(err)
}
ct := w.FormDataContentType()
if err := w.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(buf.Bytes()))
req.Header.Set("Content-Type", ct)
parsed, err := parseSyncA1Multipart(req)
if err != nil {
t.Fatal(err)
}
if !parsed.Confirm {
t.Fatal("confirm")
}
if string(parsed.WPCategoriesSQL) != sql {
t.Fatalf("sql mismatch len=%d", len(parsed.WPCategoriesSQL))
}
}
@@ -347,9 +347,13 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
}
if m, err := s.Auth.EnsureMembership(r.Context(), uid, cid); err == nil {
out["membership"] = map[string]string{"role": m.Role, "status": m.Status}
if isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid); oerr == nil {
out["is_owner"] = isOwner
}
} else if staffTenantSwitch && errors.Is(err, auth.ErrNotCompanyMember) {
staffOverride = true
out["membership"] = map[string]any{"role": "admin", "status": "active", "staff_override": true}
out["is_owner"] = false
}
}
if staffTenantSwitch && homeStr != "" {
+72 -5
View File
@@ -20,20 +20,25 @@ func (s *Server) handleGetCompany(w http.ResponseWriter, r *http.Request) {
name, language string
merge bool
contentLangs []string
ownerUserID *uuid.UUID
)
err := s.Pool.QueryRow(r.Context(), `
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}')
SELECT id, name, language, merge_products_by_gtin, COALESCE(content_languages, '{}'), owner_user_id
FROM companies WHERE id = $1`, cid).
Scan(&id, &name, &language, &merge, &contentLangs)
Scan(&id, &name, &language, &merge, &contentLangs, &ownerUserID)
if err != nil {
Error(w, http.StatusNotFound, "company not found")
return
}
parsed, _ := company.ParseContentLanguages(contentLangs, language)
JSON(w, http.StatusOK, map[string]any{
out := map[string]any{
"id": id, "name": name, "language": language,
"content_languages": parsed, "merge_products_by_gtin": merge,
})
}
if ownerUserID != nil {
out["owner_user_id"] = *ownerUserID
}
JSON(w, http.StatusOK, out)
}
func (s *Server) handleUpdateCompany(w http.ResponseWriter, r *http.Request) {
@@ -150,6 +155,8 @@ func (s *Server) handlePutCompanySettings(w http.ResponseWriter, r *http.Request
func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
limit, offset := ParseLimitOffset(r)
var ownerUserID *uuid.UUID
_ = s.Pool.QueryRow(r.Context(), `SELECT owner_user_id FROM companies WHERE id = $1`, cid).Scan(&ownerUserID)
var total int64
if err := s.Pool.QueryRow(r.Context(), `
SELECT count(*) FROM memberships m WHERE m.company_id = $1 AND m.status = 'active'`, cid).Scan(&total); err != nil {
@@ -172,6 +179,7 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"`
Email string `json:"email"`
Name *string `json:"name"`
IsOwner bool `json:"is_owner"`
}
out := make([]member, 0)
for rows.Next() {
@@ -180,9 +188,14 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusInternalServerError, "scan failed")
return
}
m.IsOwner = ownerUserID != nil && *ownerUserID == m.UserID
out = append(out, m)
}
JSON(w, http.StatusOK, map[string]any{"members": out, "total": total, "limit": limit, "offset": offset})
resp := map[string]any{"members": out, "total": total, "limit": limit, "offset": offset}
if ownerUserID != nil {
resp["owner_user_id"] = *ownerUserID
}
JSON(w, http.StatusOK, resp)
}
func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
@@ -222,6 +235,7 @@ func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) {
// Token returned when email was not delivered so operators can share the accept link.
if includeToken {
resp["token"] = token
resp["accept_url"] = mail.AcceptInviteURL(s.Config.WebOrigin, token)
}
JSON(w, http.StatusCreated, resp)
}
@@ -243,6 +257,13 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
Error(w, http.StatusBadRequest, "invalid user id")
return
}
if ownerID, hasOwner, oerr := s.Auth.CompanyOwnerID(r.Context(), cid); oerr != nil {
Error(w, http.StatusInternalServerError, "lookup failed")
return
} else if hasOwner && ownerID == userID {
Error(w, http.StatusConflict, auth.ErrCannotRemoveOwner.Error())
return
}
var currentRole, status string
err = s.Pool.QueryRow(r.Context(), `
SELECT role, status FROM memberships
@@ -361,3 +382,49 @@ func (s *Server) handleUpdateMemberRole(w http.ResponseWriter, r *http.Request)
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "role": newRole, "user_id": userID})
}
func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return
}
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return
}
if !platformAdmin {
isOwner, oerr := s.Auth.IsCompanyOwner(r.Context(), cid, uid)
if oerr != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return
}
if !isOwner {
Error(w, http.StatusForbidden, auth.ErrNotCompanyOwner.Error())
return
}
}
var body struct {
UserID uuid.UUID `json:"user_id"`
}
if err := DecodeJSON(r, &body); err != nil || body.UserID == uuid.Nil {
Error(w, http.StatusBadRequest, "invalid json")
return
}
if err := s.Auth.TransferOwnership(r.Context(), cid, body.UserID); err != nil {
switch {
case errors.Is(err, auth.ErrTransferSelf):
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
case errors.Is(err, auth.ErrOwnerRequired), errors.Is(err, auth.ErrNotCompanyMember):
Error(w, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrCompanyNotFound):
Error(w, http.StatusNotFound, err.Error())
default:
ClientOrLog(w, http.StatusBadRequest, "could not transfer ownership", err, auth.ClientError)
}
return
}
JSON(w, http.StatusOK, map[string]any{"status": "ok", "owner_user_id": body.UserID})
}
+43
View File
@@ -89,6 +89,49 @@ func (s *Server) allowCompanyAdminOrPlatform(w http.ResponseWriter, r *http.Requ
return false
}
// allowCompanyOwnerOrPlatform allows the company billing owner or a platform admin.
// When owner_user_id is unset (pre-backfill), falls back to company admin so billing is not locked out.
func (s *Server) allowCompanyOwnerOrPlatform(w http.ResponseWriter, r *http.Request) bool {
uid, ok := UserIDFromContext(r.Context())
if !ok {
Error(w, http.StatusUnauthorized, "unauthorized")
return false
}
isAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if isAdmin {
return true
}
cid, ok := CompanyIDFromContext(r.Context())
if !ok {
Error(w, http.StatusForbidden, "company required")
return false
}
if s.Auth != nil {
ownerID, hasOwner, err := s.Auth.CompanyOwnerID(r.Context(), cid)
if err != nil {
Error(w, http.StatusInternalServerError, "authorization check failed")
return false
}
if hasOwner {
if ownerID == uid {
return true
}
Error(w, http.StatusForbidden, "company owner required")
return false
}
}
// Legacy fallback before owner backfill, or unit tests without Auth wired.
if CompanyAdminAllowed(r.Context()) {
return true
}
Error(w, http.StatusForbidden, "company owner required")
return false
}
func (s *Server) RequireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uidStr := s.Sessions.GetString(r.Context(), auth.SessionUserIDKey)
+1
View File
@@ -447,6 +447,7 @@ func (s *Server) Router() http.Handler {
r.Post("/team/invites", s.handleCreateInvite)
r.Get("/team/invites", s.handleListInvites)
r.Delete("/team/invites/{inviteID}", s.handleRevokeInvite)
r.Post("/team/transfer-ownership", s.handleTransferOwnership)
r.Patch("/team/{userID}", s.handleUpdateMemberRole)
r.Delete("/team/{userID}", s.handleRemoveMember)
+2 -6
View File
@@ -40,9 +40,7 @@ func (s *Server) handleStripeStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
uid, _ := UserIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
if !s.allowCompanyOwnerOrPlatform(w, r) {
return
}
platformAdmin, err := s.checkPlatformAdmin(r.Context(), uid)
@@ -73,9 +71,7 @@ func (s *Server) handleStripeCheckout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStripePortal(w http.ResponseWriter, r *http.Request) {
cid, _ := CompanyIDFromContext(r.Context())
role, _ := RoleFromContext(r.Context())
if role != "admin" {
Error(w, http.StatusForbidden, "admin required")
if !s.allowCompanyOwnerOrPlatform(w, r) {
return
}
res, err := s.stripeSvc().CreatePortalSession(r.Context(), cid)
+7 -2
View File
@@ -107,7 +107,7 @@ func hasHeaderBreak(v string) bool {
}
func InviteMessage(webOrigin, email, token, companyName string) Message {
link := strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token
link := AcceptInviteURL(webOrigin, token)
text := fmt.Sprintf("You have been invited to %s on Descrybe.\n\nAccept: %s\n", companyName, link)
html := fmt.Sprintf(
`<p>You have been invited to <strong>%s</strong> on Descrybe.</p><p><a href="%s">Accept invite</a></p>`,
@@ -116,6 +116,11 @@ func InviteMessage(webOrigin, email, token, companyName string) Message {
return Message{To: email, Subject: "You are invited to Descrybe", Text: text, HTML: html}
}
// AcceptInviteURL builds the durable invite / set-password accept link (hashed invite tokens).
func AcceptInviteURL(webOrigin, token string) string {
return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token
}
// SetPasswordURL builds the HMAC set-password accept-invite link.
func SetPasswordURL(webOrigin, token string) string {
return strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token + "&mode=set-password"
@@ -133,7 +138,7 @@ func SetPasswordMessage(webOrigin, email, token string) Message {
// MigratedSetPasswordMessage uses migrator invite tokens (accept-invite flow).
func MigratedSetPasswordMessage(webOrigin, email, token string) Message {
link := strings.TrimRight(webOrigin, "/") + "/accept-invite?token=" + token
link := AcceptInviteURL(webOrigin, token)
text := fmt.Sprintf(
"Your Descrybe account was migrated. Set your password here:\n\n%s\n\nIf you did not expect this email, ignore it.\n",
link,
@@ -20,6 +20,10 @@ type FixCompanyCatalogOpts struct {
ReprocessSampleLimit int
// AIPrompts optionally re-applies BuiltInDefaults product_enhance per content language.
AIPrompts *aiprompts.Service
// WPCategoriesSQL is an uploaded wp_product_categories.sql dump. When set,
// RepairCompanyCategoryEnhancePrompts force-applies overlays from these bytes
// (Admin Sync A1 primary path). Empty keeps filesystem auto-detect fallback.
WPCategoriesSQL []byte
}
// FixCompanyCatalogResult is the idempotent admin Fix A1 report.
@@ -36,6 +40,10 @@ type FixCompanyCatalogResult struct {
CategoryPromptsEmpty int `json:"category_prompts_empty_skipped"`
// Prompts aliases category_prompts_updated for flash.admin.fixA1Success {prompts}.
Prompts int `json:"prompts"`
// WPCategoriesPath is set when prompts came from wp_product_categories.sql
// (upload:… or a resolved filesystem path).
WPCategoriesPath string `json:"wp_categories_path,omitempty"`
WPCategoriesEntries int `json:"wp_categories_entries,omitempty"`
ProductEnhanceLanguages int `json:"product_enhance_languages"`
@@ -88,13 +96,17 @@ func FixCompanyCatalog(ctx context.Context, pool *pgxpool.Pool, companyID uuid.U
out.CategoryAttributeOrphansRemoved = orphans
out.CategoryAttributeLinks = links
updated, alreadyOK, emptySkipped, err := catalog.RepairCompanyCategoryEnhancePrompts(ctx, pool, companyID)
updatedRes, err := catalog.RepairCompanyCategoryEnhancePromptsWithOptions(ctx, pool, companyID, catalog.RepairA1DemoOptions{
WPCategoriesSQL: opts.WPCategoriesSQL,
})
if err != nil {
return out, err
}
out.CategoryPromptsUpdated = updated
out.CategoryPromptsAlreadyOK = alreadyOK
out.CategoryPromptsEmpty = emptySkipped
out.CategoryPromptsUpdated = updatedRes.Updated
out.CategoryPromptsAlreadyOK = updatedRes.AlreadyOK
out.CategoryPromptsEmpty = updatedRes.EmptySkipped
out.WPCategoriesPath = updatedRes.WPCategoriesPath
out.WPCategoriesEntries = updatedRes.SeedEntries
if opts.AIPrompts != nil {
n, err := opts.AIPrompts.ApplyBuiltInProductEnhance(ctx, companyID)
@@ -174,7 +174,7 @@ func FormatTitleFormulaConstraint(template any) string {
continue
}
}
b.WriteString("Prefer Attrs values for [attr] slots; keep literal text as written; write name in {{language}}.")
b.WriteString("Prefer Attrs values for [attr] slots; keep literal text as written; write name in {{language}}; never emit brand-only when product_type or product_model slots exist.")
return strings.TrimSpace(b.String())
}
@@ -255,7 +255,7 @@ func AppendDescriptionFormulaSystemOverride(systemTpl string, descriptionTemplat
// titleFormulaSystemOverride is appended to the enhance system template when a
// category title_template is present so company/built-in "short retail title"
// rules cannot ignore the Title formula (mirrors description formula override).
const titleFormulaSystemOverride = `When the user message includes a "Title formula", obey that structure for "name" over any shorter "short retail title" guidance: build name from Attrs in the given element order, keep literal text as written, write name in {{language}}. Never ignore the Title formula.`
const titleFormulaSystemOverride = `When the user message includes a "Title formula", obey that structure for "name" over any shorter "short retail title" guidance: build name from Attrs in the given element order, keep literal text as written, write name in {{language}}. Never ignore the Title formula. Never emit brand-only when the formula includes product_type or product_model.`
// AppendTitleFormulaSystemOverride strengthens the system prompt when a title
// formula is active. No-op when template is empty/unparseable.
+35
View File
@@ -0,0 +1,35 @@
-- +goose Up
-- Company owner is the billing representative (distinct from membership admin|member).
-- Platform staff remain above all tenants.
ALTER TABLE companies
ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS companies_owner_user_id_idx ON companies(owner_user_id);
-- Backfill: prefer earliest active admin, else earliest active member.
UPDATE companies c
SET owner_user_id = sub.user_id
FROM (
SELECT DISTINCT ON (m.company_id) m.company_id, m.user_id
FROM memberships m
WHERE m.status = 'active'
ORDER BY m.company_id,
CASE WHEN m.role = 'admin' THEN 0 ELSE 1 END,
m.created_at ASC,
m.user_id ASC
) sub
WHERE c.id = sub.company_id
AND c.owner_user_id IS NULL;
-- Prefer a1-primary as owner on the A1 cohort tenant when present.
UPDATE companies c
SET owner_user_id = u.id
FROM users u
JOIN memberships m ON m.user_id = u.id AND m.company_id = c.id AND m.status = 'active'
WHERE c.legacy_company_id = '97e1a309-3d23-4aa2-b518-8e8d7afdfec7'
AND lower(u.email) = 'a1-primary@descrybe.local';
-- +goose Down
DROP INDEX IF EXISTS companies_owner_user_id_idx;
ALTER TABLE companies DROP COLUMN IF EXISTS owner_user_id;
+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 = {
+81 -4
View File
@@ -66,6 +66,7 @@
let busyUserId = $state<string | null>(null);
let error = $state("");
let success = $state("");
let inviteAcceptLink = $state<string | null>(null);
let tab = $state<TabKey>("users");
let search = $state("");
let staffOnly = $state(false);
@@ -99,6 +100,7 @@
let syncOpen = $state(false);
let syncCompany = $state<AdminOrgCompany | null>(null);
let syncWpCategoriesFile = $state<File | null>(null);
const cloneDestLabel = $derived.by(() => {
const selected = cloneDestOptions.find((c) => c.id === cloneDestId);
@@ -407,6 +409,7 @@
function openSyncDialog(company: AdminOrgCompany) {
syncCompany = company;
syncWpCategoriesFile = null;
syncOpen = true;
error = "";
success = "";
@@ -468,7 +471,8 @@
const targetName = syncCompany.name;
try {
const res = await syncAdminCompanyA1(syncCompany.id, {
reprocessSampleLimit: 25
reprocessSampleLimit: 25,
wpProductCategoriesFile: syncWpCategoriesFile
});
const r = res.result;
const mappedWith = Number(r.mapped_with_category ?? 0);
@@ -489,6 +493,7 @@
});
syncOpen = false;
syncCompany = null;
syncWpCategoriesFile = null;
} catch (err) {
error = failureMessage(err, i18n.t("flash.admin.syncA1Error", { name: targetName }));
} finally {
@@ -500,6 +505,7 @@
busy = true;
error = "";
success = "";
inviteAcceptLink = null;
try {
const body = userId ? { user_id: userId } : {};
const res = await api<{
@@ -510,6 +516,7 @@
skipped_rate_limited?: number;
smtp_enabled: boolean;
token?: string;
accept_url?: string;
}>("/api/admin/emails/set-password", { method: "POST", body });
const parts = [
`sent ${res.sent}`,
@@ -518,9 +525,18 @@
];
if (res.skipped_synthetic) parts.push(`skipped test accounts ${res.skipped_synthetic}`);
if (res.skipped_rate_limited) parts.push(`rate-limited ${res.skipped_rate_limited}`);
success = i18n.t("flash.admin.invitesSummary", { parts: parts.join(", "), smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff") });
if (res.token) {
success += " Invite link available — copy and share it securely (email delivery is off).";
success = i18n.t("flash.admin.invitesSummary", {
parts: parts.join(", "),
smtp: res.smtp_enabled ? i18n.t("flash.admin.smtpOn") : i18n.t("flash.admin.smtpOff")
});
const link =
res.accept_url?.trim() ||
(res.token
? `${window.location.origin}/accept-invite?token=${encodeURIComponent(res.token)}`
: "");
if (link) {
inviteAcceptLink = link;
success += " " + i18n.t("admin.users.inviteLinkReady");
}
} catch (err) {
error = failureMessage(err, "Send failed");
@@ -529,6 +545,17 @@
}
}
async function copyInviteLink() {
if (!inviteAcceptLink) return;
try {
await navigator.clipboard.writeText(inviteAcceptLink);
success = i18n.t("admin.users.inviteLinkCopied");
error = "";
} catch {
error = i18n.t("flash.settings.copyFailed");
}
}
function openPasswordDialog(user: AdminOrgUser) {
passwordUser = user;
passwordValue = "";
@@ -613,6 +640,22 @@
{:else}
<Alert message={error} />
<Alert tone="success" message={success} />
{#if inviteAcceptLink}
<div class="mb-4 flex flex-col gap-2 rounded-md border border-border bg-muted/30 p-3 sm:flex-row sm:items-center">
<Input
value={inviteAcceptLink}
readonly
autocomplete="off"
spellcheck={false}
class="font-mono text-xs"
aria-label={i18n.t("admin.users.inviteLinkLabel")}
/>
<Button type="button" variant="outline" size="sm" onclick={() => copyInviteLink()}>
<Copy class="mr-2 h-4 w-4" />
{i18n.t("admin.users.copyInviteLink")}
</Button>
</div>
{/if}
{#if !staffRoleApiOk}
<Alert
tone="info"
@@ -1087,6 +1130,39 @@
{i18n.t("admin.users.syncA1Company", { name: syncCompany.name })}
</p>
{/if}
<div class="space-y-2">
<label class="block text-sm font-medium text-foreground" for="sync-a1-wp-categories">
{i18n.t("admin.users.syncA1WpUpload")}
</label>
<input
id="sync-a1-wp-categories"
type="file"
accept=".sql,text/plain,application/sql"
class="block w-full text-sm text-foreground file:mr-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium"
disabled={busy}
onchange={(e) => {
const input = e.currentTarget;
syncWpCategoriesFile = input.files?.[0] ?? null;
}}
/>
<p class="text-xs text-muted-foreground">{i18n.t("admin.users.syncA1WpUploadHint")}</p>
{#if syncWpCategoriesFile}
<div class="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{syncWpCategoriesFile.name}</span>
<Button
type="button"
variant="outline"
size="sm"
disabled={busy}
onclick={() => {
syncWpCategoriesFile = null;
}}
>
{i18n.t("admin.users.syncA1WpUploadClear")}
</Button>
</div>
{/if}
</div>
<p class="text-sm text-muted-foreground">
{i18n.t("admin.users.syncA1Warning")}
</p>
@@ -1098,6 +1174,7 @@
onclick={() => {
syncOpen = false;
syncCompany = null;
syncWpCategoriesFile = null;
}}
>
{i18n.t("admin.users.syncA1Cancel")}
@@ -6,11 +6,21 @@
import { api, ApiError, failureMessage } from "$lib/api";
import type { Cat } from "$lib/categories/types";
import { findCategoryIdByUniqueId, listAllCategories, resolveCategory } from "$lib/categories/resolve";
import {
composeEnhancePrompt,
DEFAULT_ENHANCE_PREAMBLE,
ENHANCE_PROMPT_SECTIONS,
isEnhancePromptEmpty,
parseEnhancePrompt,
SECTION_SCHEMA_HINTS,
type EnhancePromptSectionId
} from "$lib/categories/prompt-sections";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import ContentLanguageSwitcher from "$lib/components/ContentLanguageSwitcher.svelte";
import {
Badge,
Button,
Card,
CardContent,
@@ -20,7 +30,16 @@
Label,
Textarea
} from "$lib/components/ui";
import { ArrowLeft, Share2, Sparkles, X } from "@lucide/svelte";
import {
ArrowLeft,
FileText,
Search,
Share2,
Sparkles,
Tags,
Type,
X
} from "@lucide/svelte";
import TreeSelectDialog from "$lib/components/categories/TreeSelectDialog.svelte";
import {
CONTENT_LANGUAGES,
@@ -31,16 +50,42 @@
const categoryParam = $derived(String(page.params.categoryId ?? ""));
const PROMPT_VARS = [
{ name: "name", label: i18n.t("categories.productName") },
{ name: "description", label: i18n.t("products.enrichment.piece.description") },
{ name: "category", label: i18n.t("products.enrichment.piece.category") },
{ name: "attrs", label: i18n.t("products.enrichment.piece.attributes") },
{ name: "gtin", label: i18n.t("categories.gtin") },
{ name: "brand_voice", label: i18n.t("categories.brandVoice") },
{ name: "language", label: i18n.t("settings.contentLanguage") }
const ALL_PROMPT_VARS = [
{ name: "name", labelKey: "categories.productName" },
{ name: "description", labelKey: "products.enrichment.piece.description" },
{ name: "category", labelKey: "products.enrichment.piece.category" },
{ name: "attrs", labelKey: "products.enrichment.piece.attributes" },
{ name: "gtin", labelKey: "categories.gtin" },
{ name: "brand_voice", labelKey: "categories.brandVoice" },
{ name: "language", labelKey: "settings.contentLanguage" }
] as const;
const SECTION_META: Record<
EnhancePromptSectionId,
{ labelKey: string; helpKey: string; formulaNoteKey: string }
> = {
title: {
labelKey: "categories.promptSection.title",
helpKey: "categories.promptSection.titleHelp",
formulaNoteKey: "categories.promptSection.titleFormulaNote"
},
description: {
labelKey: "categories.promptSection.description",
helpKey: "categories.promptSection.descriptionHelp",
formulaNoteKey: "categories.promptSection.descriptionFormulaNote"
},
meta: {
labelKey: "categories.promptSection.meta",
helpKey: "categories.promptSection.metaHelp",
formulaNoteKey: "categories.promptSection.metaFormulaNote"
},
attributes: {
labelKey: "categories.promptSection.attributes",
helpKey: "categories.promptSection.attributesHelp",
formulaNoteKey: "categories.promptSection.attributesFormulaNote"
}
};
let loading = $state(true);
let saving = $state(false);
let assigning = $state(false);
@@ -49,6 +94,7 @@
let success = $state("");
let category = $state<Cat | null>(null);
let allCategories = $state<Cat[]>([]);
/** Stored combined prompt per language (API shape). */
let promptsByLang = $state<Record<string, string>>({});
let selectedLang = $state(DEFAULT_CONTENT_LANGUAGE);
let extraLangs = $state<string[]>([]);
@@ -56,11 +102,28 @@
let primaryLang = $state(DEFAULT_CONTENT_LANGUAGE);
let assignOpen = $state(false);
const prompt = $derived(promptsByLang[selectedLang] ?? "");
let activeSection = $state<EnhancePromptSectionId>("title");
let preamble = $state(DEFAULT_ENHANCE_PREAMBLE);
let sectionBodies = $state<Record<EnhancePromptSectionId, string>>({
title: "",
description: "",
meta: "",
attributes: ""
});
let unstructuredHint = $state(false);
const langLabel = $derived(
CONTENT_LANGUAGES.find((l) => l.value === selectedLang)?.label ?? selectedLang
);
const schemaHint = $derived(SECTION_SCHEMA_HINTS[activeSection]);
const sectionMeta = $derived(SECTION_META[activeSection]);
const sectionBody = $derived(sectionBodies[activeSection] ?? "");
const sectionVars = $derived(
ALL_PROMPT_VARS.filter((v) => schemaHint.suggestedVars.includes(v.name))
);
const matchingUniqueIds = $derived.by(() => {
if (!category) return [] as string[];
const uid = String(category.unique_id ?? "");
@@ -73,6 +136,29 @@
return [];
});
function syncEditorFromStored(raw: string) {
const parsed = parseEnhancePrompt(raw);
preamble = parsed.preamble || DEFAULT_ENHANCE_PREAMBLE;
sectionBodies = { ...parsed.sections };
unstructuredHint = Boolean(raw.trim()) && !parsed.hasRoleSections;
}
function currentCombined(): string {
if (isEnhancePromptEmpty(preamble, sectionBodies)) return "";
return composeEnhancePrompt(preamble, sectionBodies);
}
function flushEditorToLang() {
const combined = currentCombined();
if (combined) {
promptsByLang = { ...promptsByLang, [selectedLang]: combined };
} else {
const next = { ...promptsByLang };
delete next[selectedLang];
promptsByLang = next;
}
}
function applyCategory(cat: Cat) {
category = cat;
const map: Record<string, string> = {};
@@ -90,6 +176,14 @@
if (!selectedLang || (!(selectedLang in map) && !contentLanguages.includes(selectedLang))) {
selectedLang = primaryLang;
}
syncEditorFromStored(map[selectedLang] ?? "");
}
function onLangChange(code: string) {
if (code === selectedLang) return;
flushEditorToLang();
selectedLang = code;
syncEditorFromStored(promptsByLang[code] ?? "");
}
async function load() {
@@ -125,21 +219,22 @@
void load();
});
function setPrompt(next: string) {
promptsByLang = { ...promptsByLang, [selectedLang]: next };
function setSectionBody(next: string) {
sectionBodies = { ...sectionBodies, [activeSection]: next };
unstructuredHint = false;
}
function insertVariable(name: string) {
const token = `{{${name}}}`;
const el = document.getElementById("category-prompt") as HTMLTextAreaElement | null;
const current = promptsByLang[selectedLang] ?? "";
const el = document.getElementById("category-prompt-section") as HTMLTextAreaElement | null;
const current = sectionBodies[activeSection] ?? "";
if (!el) {
setPrompt(`${current}${token}`);
setSectionBody(`${current}${token}`);
return;
}
const start = el.selectionStart ?? current.length;
const end = el.selectionEnd ?? current.length;
setPrompt(`${current.slice(0, start)}${token}${current.slice(end)}`);
setSectionBody(`${current.slice(0, start)}${token}${current.slice(end)}`);
queueMicrotask(() => {
el.focus();
const pos = start + token.length;
@@ -147,12 +242,27 @@
});
}
function clearActiveSection() {
setSectionBody("");
}
function clearLanguageOverride() {
preamble = DEFAULT_ENHANCE_PREAMBLE;
sectionBodies = { title: "", description: "", meta: "", attributes: "" };
unstructuredHint = false;
const next = { ...promptsByLang };
delete next[selectedLang];
promptsByLang = next;
}
async function savePrompt() {
if (!category) return;
saving = true;
error = "";
success = "";
try {
flushEditorToLang();
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
@@ -177,6 +287,7 @@
}
async function openAssignDialog() {
flushEditorToLang();
assignOpen = true;
if (allCategories.length > 0) return;
try {
@@ -193,6 +304,7 @@
error = "";
success = "";
try {
flushEditorToLang();
const bodyPrompts: Record<string, string> = {};
for (const [k, v] of Object.entries(promptsByLang)) {
if (v.trim()) bodyPrompts[k] = v;
@@ -225,6 +337,10 @@
assignProgress = 0;
}
}
function sectionFilled(id: EnhancePromptSectionId): boolean {
return Boolean((sectionBodies[id] ?? "").trim());
}
</script>
<PageShell
@@ -234,13 +350,13 @@
{#if loading}
<div class="flex justify-center p-12"><Spinner label={i18n.t("categories.loadingPrompt")} /></div>
{:else if category}
<div class="mx-auto max-w-4xl space-y-6">
<div class="mx-auto max-w-6xl space-y-6">
<Alert message={error} />
<Alert tone="success" message={success} />
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-1">
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" onclick={() => void goto("/categories")}>
<ArrowLeft class="h-4 w-4" />
{i18n.t("common.back")}
@@ -251,12 +367,14 @@
{i18n.t("categories.aiPromptOverrides", { name: category.name ?? "" })}
</p>
</div>
<div class="flex shrink-0 gap-2">
<div class="flex shrink-0 flex-wrap gap-2">
<Button variant="outline" onclick={() => void openAssignDialog()}>
<Share2 class="h-4 w-4" />
{i18n.t("categories.assign")}
</Button>
<Button variant="outline" onclick={() => void goto("/categories")}>{i18n.t("common.cancel")}</Button>
<Button variant="outline" onclick={() => void goto("/categories")}
>{i18n.t("common.cancel")}</Button
>
<Button onclick={savePrompt} loading={saving}>
<Sparkles class="h-4 w-4" />
{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}
@@ -264,62 +382,169 @@
</div>
</div>
<Card>
<CardHeader>
<CardTitle>{i18n.t("categories.promptTemplate")}</CardTitle>
<CardDescription>
{i18n.t("categories.promptTemplateHelp")}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<ContentLanguageSwitcher
bind:value={selectedLang}
value={selectedLang}
bind:languages={extraLangs}
configured={contentLanguages}
primary={primaryLang}
hasOverride={(code) => Boolean((promptsByLang[code] ?? "").trim())}
onChange={onLangChange}
/>
<div class="flex flex-wrap gap-2">
{#each PROMPT_VARS as v}
<Button type="button" variant="outline" size="sm" onclick={() => insertVariable(v.name)}>
{v.label}
<span class="font-mono text-xs text-muted-foreground">{"{{"}{v.name}{"}}"}</span>
</Button>
{/each}
{#if prompt.trim()}
<Button
type="button"
variant="ghost"
size="sm"
onclick={() => setPrompt("")}
onclick={clearLanguageOverride}
disabled={!Object.values(sectionBodies).some((v) => v.trim()) &&
!(promptsByLang[selectedLang] ?? "").trim()}
>
<X class="h-4 w-4" />
{i18n.t("categories.promptSection.clearLanguage")}
</Button>
</div>
{#if unstructuredHint}
<Alert tone="info" message={i18n.t("categories.promptSection.unstructuredHint")} />
{/if}
<div class="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<aside class="space-y-3 rounded-lg border bg-card/40 p-3" aria-label={i18n.t("categories.promptSection.navLabel")}>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{i18n.t("categories.promptSection.navHeading")}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{i18n.t("categories.promptSection.navHint")}
</p>
</div>
<ul class="space-y-0.5">
{#each ENHANCE_PROMPT_SECTIONS as id (id)}
{@const meta = SECTION_META[id]}
<li>
<button
type="button"
class="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm {activeSection ===
id
? 'bg-muted font-medium'
: 'hover:bg-muted/60'}"
onclick={() => (activeSection = id)}
>
{#if id === "title"}
<Type class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else if id === "description"}
<FileText class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else if id === "meta"}
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
{:else}
<Tags class="h-4 w-4 shrink-0 text-muted-foreground" />
{/if}
<span class="min-w-0 flex-1 truncate">{i18n.t(meta.labelKey)}</span>
{#if sectionFilled(id)}
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-foreground/60" aria-hidden="true"
></span>
{/if}
</button>
</li>
{/each}
</ul>
<p class="border-t pt-3 text-xs text-muted-foreground">
{i18n.t("categories.promptSection.categorizeNote")}
</p>
</aside>
<div class="min-w-0 space-y-4">
<Card>
<CardHeader>
<CardTitle>{i18n.t(sectionMeta.labelKey)}</CardTitle>
<CardDescription>{i18n.t(sectionMeta.helpKey)}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-md border bg-muted/30 p-3 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{i18n.t("categories.promptSection.schemaHeading")}
</span>
{#each schemaHint.jsonKeys as key}
<Badge variant="secondary" class="font-mono text-xs">{key}</Badge>
{/each}
</div>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.promptSection.schemaHelp")}
</p>
<pre class="overflow-x-auto rounded bg-background/80 px-2 py-1.5 font-mono text-xs text-foreground/90">{schemaHint.example}</pre>
<p class="text-xs text-muted-foreground">{i18n.t(sectionMeta.formulaNoteKey)}</p>
</div>
<div class="flex flex-wrap gap-2">
{#each sectionVars as v}
<Button
type="button"
variant="outline"
size="sm"
onclick={() => insertVariable(v.name)}
>
{i18n.t(v.labelKey)}
<span class="font-mono text-xs text-muted-foreground"
>{"{{"}{v.name}{"}}"}</span
>
</Button>
{/each}
{#if sectionBody.trim()}
<Button type="button" variant="ghost" size="sm" onclick={clearActiveSection}>
<X class="h-4 w-4" />
{i18n.t("categories.clear")}
</Button>
{/if}
</div>
<div class="space-y-2">
<Label for="category-prompt">{i18n.t("categories.promptLabel")} ({langLabel})</Label>
<Label for="category-prompt-section">
{i18n.t("categories.promptSection.contentLabel")} ({langLabel})
</Label>
<Textarea
id="category-prompt"
value={prompt}
oninput={(e) => setPrompt((e.currentTarget as HTMLTextAreaElement).value)}
rows={18}
class="min-h-[280px] font-mono text-sm"
placeholder={'Ustvari nov opis… Star_opis_izdelka: {{description}}; …'}
id="category-prompt-section"
value={sectionBody}
oninput={(e) => setSectionBody((e.currentTarget as HTMLTextAreaElement).value)}
rows={14}
class="min-h-[220px] font-mono text-sm"
placeholder={i18n.t("categories.promptSection.contentPlaceholder")}
/>
<p class="text-xs text-muted-foreground">
{i18n.t("categories.charactersCount", { count: prompt.length.toLocaleString() })}
{#if prompt.trim()}
{i18n.t("categories.charactersCount", {
count: sectionBody.length.toLocaleString()
})}
{#if (promptsByLang[selectedLang] ?? "").trim() || Object.values(sectionBodies).some((v) => v.trim())}
{i18n.t("categories.activeOverride")}
{:else}
{i18n.t("categories.usingCompanyDefaultForLang")}
{/if}
</p>
</div>
<details class="rounded-md border p-3">
<summary class="cursor-pointer text-sm font-medium">
{i18n.t("categories.promptSection.preambleToggle")}
</summary>
<div class="mt-3 space-y-2">
<p class="text-xs text-muted-foreground">
{i18n.t("categories.promptSection.preambleHelp")}
</p>
<Textarea
id="category-prompt-preamble"
value={preamble}
oninput={(e) =>
(preamble = (e.currentTarget as HTMLTextAreaElement).value)}
rows={4}
class="font-mono text-sm"
/>
</div>
</details>
</CardContent>
</Card>
</div>
</div>
</div>
<TreeSelectDialog
bind:open={assignOpen}
+51 -3
View File
@@ -185,6 +185,7 @@
let apiKeys = $state<ApiKey[]>([]);
let pendingInvites = $state<PendingInvite[]>([]);
let canAdmin = $state(false);
let canOwner = $state(false);
let accessDenied = $state(false);
let teamForbidden = $state(false);
let apiKeysForbidden = $state(false);
@@ -261,6 +262,7 @@
if (ac.signal.aborted) return;
authSession.setMe(me);
canAdmin = canManageCompany(me);
canOwner = Boolean(me.is_owner) || Boolean(me.staff_access?.full_admin);
user = me.user;
company = me.company ?? null;
credits = me.credits ?? null;
@@ -573,6 +575,7 @@
id: string;
email: string;
token?: string;
accept_url?: string;
mail_sent?: boolean;
smtp_enabled?: boolean;
}>("/api/team/invites", {
@@ -591,7 +594,7 @@
mail_sent: Boolean(created.mail_sent)
});
if (created.token) {
inviteAcceptLink = `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`;
inviteAcceptLink = created.accept_url?.trim() || `${window.location.origin}/accept-invite?token=${encodeURIComponent(created.token)}`;
inviteOpen = false;
success = i18n.t("settings.inviteCreatedNoMail", { email: emailed, role: roleName });
} else {
@@ -727,6 +730,35 @@
}
}
async function transferOwnership(member: TeamMember) {
const userId = member.user_id ?? member.id;
if (!userId) return;
if (!confirm(i18n.t("settings.transferOwnershipConfirm", { email: member.email }))) {
return;
}
saving = true;
clearFormFeedback();
try {
await api("/api/team/transfer-ownership", {
method: "POST",
body: { user_id: userId }
});
team = team.map((m) => ({
...m,
is_owner: (m.user_id ?? m.id) === userId,
role: (m.user_id ?? m.id) === userId ? "admin" : m.role
}));
canOwner = Boolean(user && user.id === userId);
success = i18n.t("settings.ownershipTransferred", { email: member.email });
notifySuccess(success);
} catch (err) {
error = applyApiFormError(err, i18n.t("settings.transferFailed"), "transfer ownership");
} finally {
saving = false;
}
}
async function copyText(text: string, okMessage = i18n.t("common.copied")) {
try {
await navigator.clipboard.writeText(text);
@@ -1528,7 +1560,14 @@
{#each team as member}
<TableRow>
<TableCell>{member.email}</TableCell>
<TableCell>{roleLabel(member.role)}</TableCell>
<TableCell>
<div class="flex flex-wrap items-center gap-2">
<span>{roleLabel(member.role)}</span>
{#if member.is_owner}
<Badge variant="secondary">{i18n.t("settings.ownerBadge")}</Badge>
{/if}
</div>
</TableCell>
<TableCell>
<Badge variant="default">{i18n.t("common.active")}</Badge>
</TableCell>
@@ -1581,9 +1620,18 @@
{i18n.t("settings.makeAdmin")}
</DropdownMenuItem>
{/if}
{#if canOwner && !member.is_owner}
<DropdownMenuItem
disabled={saving}
onclick={() => transferOwnership(member)}
>
{i18n.t("settings.transferOwnership")}
</DropdownMenuItem>
{/if}
<DropdownMenuItem
class="text-destructive focus:text-destructive"
disabled={saving}
disabled={saving || Boolean(member.is_owner)}
title={member.is_owner ? i18n.t("settings.cannotRemoveOwner") : undefined}
onclick={() => removeMember(member)}
>
{i18n.t("settings.removeMember")}