fix
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤t)
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
+1922
-1908
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
+2478
-2464
File diff suppressed because it is too large
Load Diff
+3235
-3221
File diff suppressed because it is too large
Load Diff
+1006
-992
File diff suppressed because it is too large
Load Diff
+5188
-5174
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+3278
-3264
File diff suppressed because it is too large
Load Diff
+2686
-2672
File diff suppressed because it is too large
Load Diff
@@ -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}, processed←mapped {categories}, mapped←processed {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.",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user