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
|
||||
}
|
||||
|
||||
// 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{},
|
||||
}
|
||||
return summary.Updated, summary.AlreadyOK, summary.EmptySkipped, nil
|
||||
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"
|
||||
)
|
||||
@@ -21,30 +26,63 @@ const platformDemoCompanyName = "Platform Demo"
|
||||
// RepairCategoryEnhancePromptsResult is the dry-run / apply summary for
|
||||
// RepairA1DemoCategoryEnhancePrompts.
|
||||
type RepairCategoryEnhancePromptsResult struct {
|
||||
CompaniesScanned int `json:"companies_scanned"`
|
||||
CategoriesSeen int `json:"categories_seen"`
|
||||
WouldUpdate int `json:"would_update"`
|
||||
Updated int `json:"updated"`
|
||||
AlreadyOK int `json:"already_ok"`
|
||||
EmptySkipped int `json:"empty_skipped"`
|
||||
ByCompany map[string]int `json:"by_company"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
CompaniesScanned int `json:"companies_scanned"`
|
||||
CategoriesSeen int `json:"categories_seen"`
|
||||
WouldUpdate int `json:"would_update"`
|
||||
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"])
|
||||
}
|
||||
if tpl == "" {
|
||||
return !aiprompts.CategoryEnhancePromptNeedsRepair(p)
|
||||
}
|
||||
|
||||
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,41 +482,268 @@ 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) {
|
||||
out.EmptySkipped++
|
||||
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
|
||||
}
|
||||
|
||||
cleaned, err := company.SanitizeLangPromptMap(want, MaxCategoryPromptRunes)
|
||||
if err != nil {
|
||||
return updatedHere, fmt.Errorf("sanitize prompt category=%s: %w", id, err)
|
||||
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)
|
||||
}
|
||||
encoded, err := company.EncodeLangPromptMap(cleaned)
|
||||
if err != nil {
|
||||
return updatedHere, err
|
||||
}
|
||||
ct, err := pool.Exec(ctx, `
|
||||
UPDATE categories
|
||||
SET prompt = $3::jsonb, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID, string(encoded))
|
||||
if err != nil {
|
||||
return updatedHere, fmt.Errorf("update prompt category=%s: %w", id, err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
|
||||
}
|
||||
}
|
||||
encoded, err := company.EncodeLangPromptMap(cleaned)
|
||||
if err != nil {
|
||||
return updatedHere, err
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
ct, err := pool.Exec(ctx, `
|
||||
UPDATE categories
|
||||
SET prompt = $3::jsonb, updated_at = now()
|
||||
WHERE id = $1 AND company_id = $2`, id, companyID, string(encoded))
|
||||
if err != nil {
|
||||
return updatedHere, fmt.Errorf("update prompt category=%s: %w", id, err)
|
||||
}
|
||||
if ct.RowsAffected() == 0 {
|
||||
return updatedHere, fmt.Errorf("category %s not updated (company mismatch?)", id)
|
||||
|
||||
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 {
|
||||
@@ -166,12 +173,13 @@ func (s *Server) handleListTeam(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rows.Close()
|
||||
type member struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
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;
|
||||
Reference in New Issue
Block a user