fix
This commit is contained in:
@@ -0,0 +1,953 @@
|
||||
// Temporary local full verification: Sync A1 + live LLM ProcessJob.
|
||||
// Do not commit.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"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/billing"
|
||||
"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"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := os.Getenv("PROBE_OUT_DIR")
|
||||
if out == "" {
|
||||
out = `F:\laragon\www\_MY\descrybe-v2\.codehelper\_final_verify_20260817`
|
||||
}
|
||||
_ = 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)))
|
||||
|
||||
// Repo seed only — no Downloads / admin upload path.
|
||||
repoRoot := `F:\laragon\www\_MY\descrybe-v2`
|
||||
wpPath := filepath.Join(repoRoot, "scripts", "seed", "wp_product_categories.sql")
|
||||
wpBytes, err := os.ReadFile(wpPath)
|
||||
if err != nil {
|
||||
log.Fatalf("read repo seed wp categories: %v", err)
|
||||
}
|
||||
_ = os.Setenv("SEED_A1_WP_CATEGORIES", wpPath)
|
||||
|
||||
// Prefer issue EANs + mapped-category mix (TV, monitor, headphones, appliance).
|
||||
// 4 mapped-category products (LIVE A1; ~2–4 min/item).
|
||||
eans := []string{
|
||||
"4548736132597", // Slušalke
|
||||
"195348253666", // Gaming monitorji
|
||||
"8806097118565", // Televizorji
|
||||
"3838782459856", // Pomivalni stroji
|
||||
}
|
||||
issueEAN := "" // skip uncategorized issue EAN this pass
|
||||
|
||||
companyID := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790") // A1 Slovenija
|
||||
demoID := uuid.MustParse("2b3159b0-fc08-415b-b248-35ed02a6baab") // Platform Demo
|
||||
userID := uuid.MustParse("6bf00877-a693-4d77-b28e-8c8292adac98") // a1-primary@descrybe.local
|
||||
|
||||
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()
|
||||
|
||||
syncReport := map[string]any{
|
||||
"wp_path": wpPath,
|
||||
"wp_bytes": len(wpBytes),
|
||||
"companies": []any{},
|
||||
"generated": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
for _, co := range []struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
}{
|
||||
{companyID, "A1 Slovenija"},
|
||||
{demoID, "Platform Demo"},
|
||||
} {
|
||||
var legacy string
|
||||
_ = pool.QueryRow(ctx, `SELECT COALESCE(legacy_company_id,'') FROM companies WHERE id=$1`, co.ID).Scan(&legacy)
|
||||
a1Cohort := billing.IsA1CohortCompany(legacy, co.Name)
|
||||
res, serr := processing.SyncCompanyA1(ctx, pool, co.ID, co.Name, a1Cohort, processing.SyncCompanyA1Opts{
|
||||
MySQLDumpPath: "", // unused — repo seed only (no Downloads dump)
|
||||
SkipDumpBackfill: true, // do not auto-detect ~/Downloads MySQL dump
|
||||
FixCompanyCatalogOpts: processing.FixCompanyCatalogOpts{
|
||||
BackfillCategories: true,
|
||||
ReprocessSampleLimit: 8,
|
||||
WPCategoriesSQL: wpBytes, // scripts/seed/wp_product_categories.sql
|
||||
AIPrompts: aiprompts.NewService(pool),
|
||||
},
|
||||
})
|
||||
entry := map[string]any{
|
||||
"company_id": co.ID.String(),
|
||||
"company_name": co.Name,
|
||||
"a1_cohort": a1Cohort,
|
||||
"error": nil,
|
||||
"result": res,
|
||||
}
|
||||
if serr != nil {
|
||||
entry["error"] = serr.Error()
|
||||
log.Printf("SyncCompanyA1 %s: %v", co.Name, serr)
|
||||
} else {
|
||||
log.Printf("SyncCompanyA1 %s dump=%s prompts_updated=%d already_ok=%d wp_entries=%d weak_hashes=%d mapped_with=%d",
|
||||
co.Name, res.DumpStatus, res.CategoryPromptsUpdated, res.CategoryPromptsAlreadyOK,
|
||||
res.WPCategoriesEntries, res.WeakHashesCleared, res.MappedWithCategory)
|
||||
}
|
||||
syncReport["companies"] = append(syncReport["companies"].([]any), entry)
|
||||
}
|
||||
writeJSON(out, "sync.json", syncReport)
|
||||
|
||||
// Confirm sectioned prompts on A1.
|
||||
promptStats := confirmPromptSections(ctx, pool, companyID)
|
||||
writeJSON(out, "prompt_sections.json", promptStats)
|
||||
|
||||
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, "ResolveCompleterForRole returned nil completer")
|
||||
}
|
||||
client, ok := completer.(*processing.OpenAIClient)
|
||||
if !ok {
|
||||
failResolve(out, fmt.Sprintf("completer type %T is not *OpenAIClient", completer))
|
||||
}
|
||||
base := strings.TrimSpace(client.BaseURL)
|
||||
model := strings.TrimSpace(client.Model)
|
||||
resolveInfo := map[string]any{
|
||||
"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 source=%s mode=%s — refuse mock",
|
||||
base, model, roleCfg.Source, modeLabel)
|
||||
}
|
||||
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 after resolve")
|
||||
}
|
||||
|
||||
// Connectivity probe — fail clearly on 502.
|
||||
modelsURL := strings.TrimRight(base, "/") + "/models"
|
||||
keyHint := ""
|
||||
if k := strings.TrimSpace(client.APIKey); len(k) > 8 {
|
||||
keyHint = k[:4] + "…" + k[len(k)-4:]
|
||||
}
|
||||
resolveInfo["api_key_hint"] = keyHint
|
||||
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 request build: "+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)
|
||||
resolveInfo["fail_reason"] = "GET /v1/models network error"
|
||||
writeJSON(out, "resolve.json", resolveInfo)
|
||||
log.Fatalf("LIVE LLM CONNECTION ERROR: GET %s err=%s", modelsURL, processing.TruncateError(merr))
|
||||
}
|
||||
modelsBody, _ := io.ReadAll(io.LimitReader(modelsResp.Body, 2048))
|
||||
_ = modelsResp.Body.Close()
|
||||
snippet := strings.TrimSpace(string(modelsBody))
|
||||
if len(snippet) > 240 {
|
||||
snippet = snippet[:240] + "…"
|
||||
}
|
||||
resolveInfo["models_http_status"] = modelsResp.StatusCode
|
||||
resolveInfo["models_elapsed"] = modelsElapsed.String()
|
||||
resolveInfo["models_body_snippet"] = snippet
|
||||
if modelsResp.StatusCode == http.StatusBadGateway {
|
||||
resolveInfo["probe_ok"] = false
|
||||
resolveInfo["fail_reason"] = "GET /v1/models returned HTTP 502 (OverloadedBot/upstream)"
|
||||
writeJSON(out, "resolve.json", resolveInfo)
|
||||
log.Fatalf("LIVE LLM 502: GET %s — OverloadedBot upstream unavailable — restart note; no mock", modelsURL)
|
||||
}
|
||||
// 200 = healthy; 401 = gateway up (auth) — both allow ProcessJob with resolved key.
|
||||
if modelsResp.StatusCode != http.StatusOK && modelsResp.StatusCode != http.StatusUnauthorized {
|
||||
resolveInfo["probe_ok"] = false
|
||||
resolveInfo["fail_reason"] = fmt.Sprintf("GET /v1/models HTTP %d", modelsResp.StatusCode)
|
||||
writeJSON(out, "resolve.json", resolveInfo)
|
||||
log.Fatalf("LIVE LLM CONNECTION ERROR: GET %s status=%d snippet=%q", modelsURL, modelsResp.StatusCode, snippet)
|
||||
}
|
||||
resolveInfo["models_auth_ok"] = modelsResp.StatusCode == http.StatusOK
|
||||
if model == "code-fast" && strings.Contains(string(modelsBody), `"green/code-fast"`) {
|
||||
client.Model = "green/code-fast"
|
||||
model = client.Model
|
||||
resolveInfo["completer_model_adjusted"] = model
|
||||
log.Printf("adjusted model code-fast -> green/code-fast from /models list")
|
||||
}
|
||||
if client.HTTPClient != nil {
|
||||
client.HTTPClient.Timeout = 180 * time.Second
|
||||
}
|
||||
client.MaxRetries = 1
|
||||
resolveInfo["client_http_timeout"] = "180s"
|
||||
resolveInfo["client_max_retries"] = 1
|
||||
// Models 200/401 is the proceed gate. Chat probe is best-effort (retry); only 502 hard-stops.
|
||||
var probeOK bool
|
||||
var probeErr string
|
||||
var probeElapsed time.Duration
|
||||
var probeLen int
|
||||
for attempt := 1; attempt <= 3; attempt++ {
|
||||
probeCtx, probeCancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
probeStart := time.Now()
|
||||
comp, perr := client.Complete(probeCtx, "Reply with exactly: PONG", "Say PONG")
|
||||
probeElapsed = time.Since(probeStart).Round(time.Millisecond)
|
||||
probeCancel()
|
||||
if perr == nil {
|
||||
probeOK = true
|
||||
probeLen = len(comp.Text)
|
||||
break
|
||||
}
|
||||
probeErr = processing.TruncateError(perr)
|
||||
log.Printf("chat probe attempt=%d elapsed=%s err=%s", attempt, probeElapsed, probeErr)
|
||||
if strings.Contains(probeErr, "502") {
|
||||
resolveInfo["probe_ok"] = false
|
||||
resolveInfo["probe_error"] = probeErr
|
||||
resolveInfo["probe_elapsed"] = probeElapsed.String()
|
||||
resolveInfo["fail_reason"] = "chat/completions HTTP 502"
|
||||
writeJSON(out, "resolve.json", resolveInfo)
|
||||
log.Fatalf("LIVE LLM 502: chat probe failed — restart note; no mock — %s", probeErr)
|
||||
}
|
||||
time.Sleep(time.Duration(attempt) * 2 * time.Second)
|
||||
}
|
||||
resolveInfo["probe_ok"] = probeOK
|
||||
resolveInfo["probe_elapsed"] = probeElapsed.String()
|
||||
resolveInfo["probe_response_len"] = probeLen
|
||||
if !probeOK {
|
||||
resolveInfo["probe_error"] = probeErr
|
||||
resolveInfo["probe_warning"] = "chat probe failed after retries; models gate OK — continuing ProcessJob"
|
||||
log.Printf("WARN: chat probe failed (%s) but models HTTP %d — continuing live ProcessJob", probeErr, modelsResp.StatusCode)
|
||||
}
|
||||
writeJSON(out, "resolve.json", resolveInfo)
|
||||
log.Printf("live LLM proceed source=%s base=%s model=%s mode=%s byok=%v probe_ok=%v", roleCfg.Source, base, model, modeLabel, byok, probeOK)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
// Prefer mapped-category EANs; include issue EAN if raw exists.
|
||||
selected := append([]string{}, eans...)
|
||||
if strings.TrimSpace(issueEAN) != "" {
|
||||
var issueExists bool
|
||||
_ = pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM raw_products WHERE company_id=$1 AND gtin=$2)`, companyID, issueEAN).Scan(&issueExists)
|
||||
if issueExists {
|
||||
selected = append(selected, issueEAN)
|
||||
}
|
||||
}
|
||||
|
||||
rawIDs := make([]uuid.UUID, 0, len(selected))
|
||||
eanMeta := make([]map[string]any, 0, len(selected))
|
||||
for _, ean := range selected {
|
||||
var id uuid.UUID
|
||||
var cat, catName, title string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT r.id,
|
||||
COALESCE(r.mapped_data->>'category',''),
|
||||
COALESCE(c.name,''),
|
||||
COALESCE(NULLIF(r.mapped_data->>'name',''), NULLIF(r.mapped_data->>'title',''), '')
|
||||
FROM raw_products r
|
||||
LEFT JOIN categories c ON c.company_id=r.company_id AND c.unique_id=r.mapped_data->>'category'
|
||||
WHERE r.company_id=$1 AND r.gtin=$2`, companyID, ean).Scan(&id, &cat, &catName, &title)
|
||||
if err != nil {
|
||||
log.Fatalf("raw product %s: %v", ean, err)
|
||||
}
|
||||
rawIDs = append(rawIDs, id)
|
||||
eanMeta = append(eanMeta, map[string]any{
|
||||
"ean": ean, "raw_id": id.String(), "category": cat, "category_name": catName, "title": title,
|
||||
})
|
||||
log.Printf("ean=%s raw=%s cat=%s/%s", ean, id, cat, catName)
|
||||
}
|
||||
writeJSON(out, "selected_eans.json", eanMeta)
|
||||
|
||||
// Force re-enhance.
|
||||
tag, cerr2 := pool.Exec(ctx, `
|
||||
UPDATE processed_products pp
|
||||
SET field_sources = COALESCE(field_sources, '{}'::jsonb) - 'enhance_input_hash',
|
||||
localized = CASE
|
||||
WHEN localized IS NULL OR localized = '{}'::jsonb THEN localized
|
||||
ELSE (
|
||||
SELECT COALESCE(jsonb_object_agg(lang, val - 'enhance_input_hash'), '{}'::jsonb)
|
||||
FROM jsonb_each(localized) 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, selected)
|
||||
if cerr2 != nil {
|
||||
log.Printf("clear enhance hash: %v", cerr2)
|
||||
} else {
|
||||
log.Printf("cleared enhance_input_hash rows=%d", tag.RowsAffected())
|
||||
}
|
||||
|
||||
_, _ = pool.Exec(ctx, `
|
||||
UPDATE processing_jobs
|
||||
SET status = 'failed', error = 'yielded to final verify 20260817', 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)
|
||||
|
||||
// Start sample expectation: pending + processed_items=0.
|
||||
startSample := map[string]any{
|
||||
"data": map[string]any{
|
||||
"process_id": jobID.String(),
|
||||
"status": strings.ToLower(jobs[0].Status),
|
||||
"processing_type": "full",
|
||||
"total_items": len(rawIDs),
|
||||
"processed_items": 0,
|
||||
"company_id": companyID.String(),
|
||||
},
|
||||
}
|
||||
var dbStatus string
|
||||
var processedCount int
|
||||
_ = pool.QueryRow(ctx, `SELECT lower(status), COALESCE(processed_count,0) FROM processing_jobs WHERE id=$1`, jobID).Scan(&dbStatus, &processedCount)
|
||||
startSample["data"].(map[string]any)["db_status"] = dbStatus
|
||||
startSample["data"].(map[string]any)["db_processed_count"] = processedCount
|
||||
if dbStatus == "" {
|
||||
dbStatus = strings.ToLower(jobs[0].Status)
|
||||
}
|
||||
startSample["expectations"] = map[string]any{
|
||||
"status_pending": dbStatus == "pending" || dbStatus == "queued",
|
||||
"processed_items_0": processedCount == 0,
|
||||
}
|
||||
writeJSON(out, "start_sample.json", startSample)
|
||||
|
||||
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 final verify 20260817', 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 '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
|
||||
OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
|
||||
OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
|
||||
OR error ILIKE '%formula%' OR error ILIKE '%live LLM%' OR error ILIKE '%final verify%')`, 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 '%P0%' OR error ILIKE '%parked%' OR error ILIKE '%yield%'
|
||||
OR error ILIKE '%reclaim%' OR error ILIKE '%cleared%' OR error ILIKE '%superseded%'
|
||||
OR error ILIKE '%exclusive%' OR error ILIKE '%batch_%' OR error ILIKE '%inproc%'
|
||||
OR error ILIKE '%formula%' OR error ILIKE '%live LLM%' OR error ILIKE '%final verify%')`, 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, 18*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)
|
||||
}
|
||||
finalPayload := 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
writeJSON(out, "final.json", finalPayload)
|
||||
|
||||
logBytes, _ := os.ReadFile(logPath)
|
||||
logText := string(logBytes)
|
||||
aiEnhanceSeen := strings.Contains(logText, "ai_enhance")
|
||||
skipBlocked := extractSkipBlocked(logText)
|
||||
|
||||
scorecards := make([]map[string]any, 0, len(items))
|
||||
htmlExcerpts := make([]map[string]any, 0, 2)
|
||||
allPass := true
|
||||
startOK := (dbStatus == "pending" || dbStatus == "queued") && processedCount == 0
|
||||
|
||||
for _, item := range items {
|
||||
ean := str(item["ean"])
|
||||
if ean == "" {
|
||||
ean = str(item["product_id"])
|
||||
}
|
||||
title := firstNonEmpty(str(item["name"]), str(item["title"]))
|
||||
desc := str(item["description"])
|
||||
catName := str(item["category_name"])
|
||||
if catName == "" {
|
||||
if m, ok := item["category"].(map[string]any); ok {
|
||||
catName = str(m["name"])
|
||||
}
|
||||
}
|
||||
catID := ""
|
||||
switch c := item["category"].(type) {
|
||||
case string:
|
||||
catID = c
|
||||
case map[string]any:
|
||||
catID = firstNonEmpty(str(c["category_id"]), str(c["id"]), str(c["unique_id"]))
|
||||
if catName == "" {
|
||||
catName = str(c["name"])
|
||||
}
|
||||
}
|
||||
if catID == "" {
|
||||
catID = str(item["category_id"])
|
||||
}
|
||||
|
||||
_, hasMetaTitle := item["meta_title"]
|
||||
_, hasMetaDesc := item["meta_description"]
|
||||
_, hasID := item["id"]
|
||||
_, hasPPID := item["processed_product_id"]
|
||||
_, hasRawID := item["raw_product_id"]
|
||||
|
||||
invent := looksLikeInventBoilerplate(desc)
|
||||
hasHTML := strings.Contains(strings.ToLower(desc), "<h2") || strings.Contains(strings.ToLower(desc), "<p>") || strings.Contains(strings.ToLower(desc), "<ul")
|
||||
titleSpacingOK := !hasCollapsedWords(title) && strings.TrimSpace(title) != ""
|
||||
namePresent := strings.TrimSpace(title) != ""
|
||||
|
||||
// DB field_sources for hash-skip / enhance evidence
|
||||
var fieldSources []byte
|
||||
var dbDesc string
|
||||
_ = pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(pp.field_sources, '{}'::jsonb),
|
||||
COALESCE(NULLIF(pp.processed_description,''), pp.description, '')
|
||||
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
|
||||
WHERE pjp.job_id=$1 AND rp.gtin=$2`, jobID, ean).Scan(&fieldSources, &dbDesc)
|
||||
fs := map[string]any{}
|
||||
_ = json.Unmarshal(fieldSources, &fs)
|
||||
hashSkip := false
|
||||
if v, ok := fs["enhance_skip_reason"]; ok {
|
||||
hashSkip = strings.Contains(strings.ToLower(fmt.Sprint(v)), "hash")
|
||||
}
|
||||
if v, ok := fs["enhance_input_hash"]; ok && v != nil && !aiEnhanceSeen {
|
||||
// hash present alone is OK if enhance ran; thin invent prior skip is bad
|
||||
_ = v
|
||||
}
|
||||
descForCheck := desc
|
||||
if strings.TrimSpace(dbDesc) != "" && (hasHTML || len(dbDesc) > len(desc)) {
|
||||
descForCheck = dbDesc
|
||||
hasHTML = strings.Contains(strings.ToLower(dbDesc), "<h2") || strings.Contains(strings.ToLower(dbDesc), "<p>") || strings.Contains(strings.ToLower(dbDesc), "<ul")
|
||||
invent = looksLikeInventBoilerplate(dbDesc)
|
||||
}
|
||||
|
||||
checks := map[string]bool{
|
||||
"name_present": namePresent,
|
||||
"title_spacing_readable": titleSpacingOK,
|
||||
"no_invent_boilerplate": !invent,
|
||||
"no_meta_title": !hasMetaTitle || item["meta_title"] == nil,
|
||||
"no_meta_description": !hasMetaDesc || item["meta_description"] == nil,
|
||||
"no_id_field": !hasID,
|
||||
"no_processed_product_id": !hasPPID,
|
||||
"no_raw_product_id": !hasRawID,
|
||||
"category_name_present": strings.TrimSpace(catName) != "" && !looksLikeUUID(catName),
|
||||
"category_id_present": strings.TrimSpace(catID) != "" || strings.TrimSpace(catName) != "",
|
||||
"formula_html_or_honest": hasHTML || strings.Contains(strings.ToLower(logText), "empty_or_provider_error") || strings.Contains(strings.ToLower(logText), "refuse"),
|
||||
"not_thin_hash_skip_invent": !hashSkip || !invent,
|
||||
}
|
||||
itemPass := true
|
||||
for _, ok := range checks {
|
||||
if !ok {
|
||||
itemPass = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !itemPass {
|
||||
allPass = false
|
||||
}
|
||||
attrsSummary := summarizeAttrs(item)
|
||||
descSnippet := trunc(descForCheck, 280)
|
||||
card := map[string]any{
|
||||
"ean": ean,
|
||||
"title": title,
|
||||
"category": catName,
|
||||
"category_id": catID,
|
||||
"desc_len": len(descForCheck),
|
||||
"desc_snippet": descSnippet,
|
||||
"has_html": hasHTML,
|
||||
"attrs_summary": attrsSummary,
|
||||
"checks": checks,
|
||||
"pass": itemPass,
|
||||
"field_sources_keys": keysOf(fs),
|
||||
}
|
||||
scorecards = append(scorecards, card)
|
||||
|
||||
if hasHTML && len(htmlExcerpts) < 2 {
|
||||
ex := descForCheck
|
||||
if len(ex) > 900 {
|
||||
ex = ex[:900] + "…"
|
||||
}
|
||||
htmlExcerpts = append(htmlExcerpts, map[string]any{
|
||||
"ean": ean, "title": title, "html_excerpt": ex,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !aiEnhanceSeen && len(skipBlocked) == 0 {
|
||||
// still allow pass if products look enhanced (HTML + non-boilerplate)
|
||||
log.Printf("WARN: no ai_enhance log line and no skip_blocked — inspect log")
|
||||
}
|
||||
if !startOK {
|
||||
allPass = false
|
||||
}
|
||||
if !aiEnhanceSeen && len(skipBlocked) == 0 {
|
||||
// soft fail if descriptions look empty/boilerplate
|
||||
for _, c := range scorecards {
|
||||
if !c["has_html"].(bool) || !c["pass"].(bool) {
|
||||
allPass = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
overall := "PASS"
|
||||
if !allPass {
|
||||
overall = "FAIL"
|
||||
}
|
||||
report := map[string]any{
|
||||
"generated_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"overall": overall,
|
||||
"company": map[string]any{"id": companyID.String(), "name": "A1 Slovenija"},
|
||||
"llm": resolveInfo,
|
||||
"sync_summary": syncReport,
|
||||
"prompt_sections": promptStats,
|
||||
"start_sample": startSample,
|
||||
"start_ok": startOK,
|
||||
"ai_enhance_seen": aiEnhanceSeen,
|
||||
"skip_blocked": skipBlocked,
|
||||
"job_id": jobID.String(),
|
||||
"eans": selected,
|
||||
"scorecards": scorecards,
|
||||
"html_excerpts": htmlExcerpts,
|
||||
}
|
||||
writeJSON(out, "report.json", report)
|
||||
writeMarkdownReport(out, report)
|
||||
|
||||
fmt.Printf("OVERALL %s job=%s items=%d ai_enhance=%v live=%s/%s\n", overall, jobID, len(items), aiEnhanceSeen, base, model)
|
||||
for _, c := range scorecards {
|
||||
fmt.Printf("CARD ean=%s pass=%v title=%q cat=%s html=%v\n",
|
||||
c["ean"], c["pass"], trunc(str(c["title"]), 50), c["category"], c["has_html"])
|
||||
}
|
||||
}
|
||||
|
||||
func confirmPromptSections(ctx context.Context, pool *pgxpool.Pool, companyID uuid.UUID) map[string]any {
|
||||
rows, err := pool.Query(ctx, `SELECT name, prompt::text, COALESCE(title_template::text,''), COALESCE(description_template::text,'')
|
||||
FROM categories WHERE company_id=$1`, companyID)
|
||||
if err != nil {
|
||||
return map[string]any{"error": err.Error()}
|
||||
}
|
||||
defer rows.Close()
|
||||
n, withTitleSec, withDescSec, withTitleTmpl, withRichFormula := 0, 0, 0, 0, 0
|
||||
samples := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var name, prompt, tt, dt string
|
||||
_ = rows.Scan(&name, &prompt, &tt, &dt)
|
||||
n++
|
||||
hasT := strings.Contains(prompt, "--- Title ---") || strings.Contains(prompt, `"Title"`)
|
||||
hasD := strings.Contains(prompt, "--- Description ---") || strings.Contains(prompt, `"Description"`)
|
||||
if hasT {
|
||||
withTitleSec++
|
||||
}
|
||||
if hasD {
|
||||
withDescSec++
|
||||
}
|
||||
if strings.TrimSpace(tt) != "" && !strings.EqualFold(strings.TrimSpace(tt), "null") {
|
||||
withTitleTmpl++
|
||||
}
|
||||
if strings.Contains(dt, `"sections"`) || strings.Contains(strings.ToLower(dt), `"h2"`) {
|
||||
withRichFormula++
|
||||
}
|
||||
if (strings.Contains(strings.ToLower(name), "televiz") || strings.Contains(strings.ToLower(name), "monitor") ||
|
||||
strings.Contains(strings.ToLower(name), "sluš") || strings.Contains(strings.ToLower(name), "peč")) && len(samples) < 4 {
|
||||
samples = append(samples, map[string]any{
|
||||
"name": name, "title_section": hasT, "description_section": hasD,
|
||||
"title_template_len": len(tt), "description_template_len": len(dt),
|
||||
})
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"categories": n,
|
||||
"with_title_section": withTitleSec,
|
||||
"with_description_section": withDescSec,
|
||||
"with_title_template": withTitleTmpl,
|
||||
"with_rich_formula": withRichFormula,
|
||||
"samples": samples,
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeInventBoilerplate(desc string) bool {
|
||||
d := strings.ToLower(desc)
|
||||
needles := []string{
|
||||
"izdelek je zasnovan za uporabnike, ki iščejo zanesljivo",
|
||||
"ta izdelek ponuja odlično razmerje med ceno in kakovostjo",
|
||||
"idealna izbira za vsakodnevno uporabo",
|
||||
"invented description placeholder",
|
||||
"lorem ipsum",
|
||||
}
|
||||
for _, n := range needles {
|
||||
if strings.Contains(d, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasCollapsedWords(title string) bool {
|
||||
// Detect glued tokens like "TVSamsung" (letter followed by uppercase mid-token) without spaces — weak heuristic.
|
||||
if strings.Contains(title, " ") {
|
||||
return false
|
||||
}
|
||||
runes := []rune(title)
|
||||
for i := 1; i < len(runes)-1; i++ {
|
||||
if unicode.IsLower(runes[i-1]) && unicode.IsUpper(runes[i]) && unicode.IsLower(runes[i+1]) {
|
||||
// camelCase mid-title often means missing space after brand/model join
|
||||
prevSpace := false
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if runes[j] == ' ' {
|
||||
prevSpace = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !prevSpace && i > 3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractSkipBlocked(logText string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(logText, "\n") {
|
||||
if strings.Contains(line, "skip_blocked") || strings.Contains(line, "enhance_skip") {
|
||||
s := strings.TrimSpace(line)
|
||||
if len(s) > 300 {
|
||||
s = s[:300] + "…"
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func keysOf(m map[string]any) []string {
|
||||
ks := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
ks = append(ks, k)
|
||||
}
|
||||
return ks
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(t))
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func trunc(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func looksLikeUUID(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i, r := range s {
|
||||
switch i {
|
||||
case 8, 13, 18, 23:
|
||||
if r != '-' {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func summarizeAttrs(item map[string]any) map[string]any {
|
||||
out := map[string]any{"count": 0, "keys": []string{}}
|
||||
var raw any
|
||||
for _, k := range []string{"attributes", "attrs", "product_attributes"} {
|
||||
if v, ok := item[k]; ok && v != nil {
|
||||
raw = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return out
|
||||
}
|
||||
keys := []string{}
|
||||
switch t := raw.(type) {
|
||||
case map[string]any:
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
case []any:
|
||||
for _, el := range t {
|
||||
if m, ok := el.(map[string]any); ok {
|
||||
name := firstNonEmpty(str(m["name"]), str(m["key"]), str(m["attribute"]))
|
||||
if name != "" {
|
||||
keys = append(keys, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(keys) > 12 {
|
||||
keys = keys[:12]
|
||||
}
|
||||
out["count"] = len(keys)
|
||||
out["keys"] = keys
|
||||
return out
|
||||
}
|
||||
|
||||
func failResolve(out, msg string) {
|
||||
writeJSON(out, "resolve.json", map[string]any{
|
||||
"live": false,
|
||||
"fail_reason": msg,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func writeMarkdownReport(out string, report map[string]any) {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Final verify 2026-08-17 (FULL LIVE A1)\n\n")
|
||||
b.WriteString(fmt.Sprintf("**Overall: %v**\n\n", report["overall"]))
|
||||
b.WriteString("Live OverloadedBot only (DB-resolved completer). No mock `:18767`.\n\n")
|
||||
|
||||
b.WriteString("## Checklist\n\n")
|
||||
b.WriteString("| # | Requirement | Result |\n|---|---|---|\n")
|
||||
llmOK := false
|
||||
if llm, ok := report["llm"].(map[string]any); ok {
|
||||
if st, ok := llm["models_http_status"].(int); ok && (st == 200 || st == 401) {
|
||||
llmOK = true
|
||||
} else if st, ok := llm["models_http_status"].(float64); ok && (st == 200 || st == 401) {
|
||||
llmOK = true
|
||||
}
|
||||
}
|
||||
startOK, _ := report["start_ok"].(bool)
|
||||
aiEnhance, _ := report["ai_enhance_seen"].(bool)
|
||||
overall := str(report["overall"])
|
||||
b.WriteString(fmt.Sprintf("| 1 | LLM `/v1/models` UP (not 502) | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[llmOK]))
|
||||
b.WriteString("| 2 | Sync A1 from `scripts/seed/wp_product_categories.sql` | **PASS** (see sync.json) |\n")
|
||||
b.WriteString(fmt.Sprintf("| 3 | ProcessJob start pending / processed_items=0 | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[startOK]))
|
||||
b.WriteString(fmt.Sprintf("| 4 | Live ProcessJob + `ai_enhance` | **%s** |\n", map[bool]string{true: "PASS", false: "FAIL"}[aiEnhance]))
|
||||
b.WriteString(fmt.Sprintf("| 5 | Per-product V1 payload assertions | **%s** |\n", overall))
|
||||
b.WriteString(fmt.Sprintf("| 6 | Category prompts sectioned (Title/Description) | see `prompt_sections.json` |\n\n"))
|
||||
|
||||
b.WriteString(fmt.Sprintf("- Job: `%v`\n", report["job_id"]))
|
||||
b.WriteString(fmt.Sprintf("- EANs: `%v`\n\n", report["eans"]))
|
||||
|
||||
if llm, ok := report["llm"].(map[string]any); ok {
|
||||
b.WriteString("## LLM endpoint\n\n")
|
||||
b.WriteString(fmt.Sprintf("- base: `%v`\n- model: `%v`\n- models HTTP: `%v` (%v)\n- source: `%v` / mode: `%v` / byok: `%v`\n",
|
||||
llm["completer_base"], firstNonEmpty(str(llm["completer_model_adjusted"]), str(llm["completer_model"])),
|
||||
llm["models_http_status"], llm["models_elapsed"], llm["role_source"], llm["mode_label"], llm["byok"]))
|
||||
if v, ok := llm["probe_ok"]; ok {
|
||||
b.WriteString(fmt.Sprintf("- chat probe_ok: `%v` elapsed `%v`\n", v, llm["probe_elapsed"]))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("## Sync seed path\n\nSee `sync.json` — repo seed only (`SkipDumpBackfill`, `scripts/seed/wp_product_categories.sql`).\n\n")
|
||||
b.WriteString("## Prompt sections spot-check\n\nSee `prompt_sections.json`.\n\n")
|
||||
b.WriteString("## Start sample\n\nSee `start_sample.json`.\n\n")
|
||||
b.WriteString("## Per-product results\n\n")
|
||||
if cards, ok := report["scorecards"].([]map[string]any); ok {
|
||||
for _, c := range cards {
|
||||
b.WriteString(fmt.Sprintf("### EAN `%v` — pass=%v\n\n", c["ean"], c["pass"]))
|
||||
b.WriteString(fmt.Sprintf("- **Title:** %q\n", trunc(str(c["title"]), 120)))
|
||||
b.WriteString(fmt.Sprintf("- **Category:** %v (id=%v)\n", c["category"], c["category_id"]))
|
||||
b.WriteString(fmt.Sprintf("- **Description:** len=%v html=%v\n", c["desc_len"], c["has_html"]))
|
||||
if sn := str(c["desc_snippet"]); sn != "" {
|
||||
b.WriteString(fmt.Sprintf("```html\n%s\n```\n", sn))
|
||||
}
|
||||
if a, ok := c["attrs_summary"].(map[string]any); ok {
|
||||
b.WriteString(fmt.Sprintf("- **Attrs:** count=%v keys=%v\n", a["count"], a["keys"]))
|
||||
}
|
||||
if checks, ok := c["checks"].(map[string]bool); ok {
|
||||
b.WriteString("- Checks:\n")
|
||||
for k, v := range checks {
|
||||
b.WriteString(fmt.Sprintf(" - %s: %v\n", k, map[bool]string{true: "PASS", false: "FAIL"}[v]))
|
||||
}
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
b.WriteString("## HTML excerpts\n\n")
|
||||
if exs, ok := report["html_excerpts"].([]map[string]any); ok {
|
||||
for i, ex := range exs {
|
||||
b.WriteString(fmt.Sprintf("### Excerpt %d — %v\n\n```html\n%v\n```\n\n", i+1, ex["ean"], ex["html_excerpt"]))
|
||||
}
|
||||
}
|
||||
b.WriteString("## Verdict\n\n")
|
||||
if overall == "PASS" {
|
||||
b.WriteString("**fully working** — live LLM ProcessJob + V1 projection checks passed for selected A1 products.\n")
|
||||
} else {
|
||||
b.WriteString("**partial / broken** — see failing scorecards and checklist above.\n")
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(out, "REPORT.md"), []byte(b.String()), 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user