Files
descrybe/apps/api/cmd/seed-support-kb/main.go
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

214 lines
5.8 KiB
Go

// Command seed-support-kb upserts platform Support Knowledge articles from JSON.
//
// Targets table support_kb_articles (migration 032). Idempotent on slug.
//
// Usage (from apps/api, DATABASE_URL set or passed):
//
// go run ./cmd/seed-support-kb -postgres "$DATABASE_URL"
// go run ./cmd/seed-support-kb -file ../../scripts/seed/support-kb-articles.json
// go run ./cmd/seed-support-kb -file ../../scripts/seed/support-kb-articles-tech.json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
maxSlugLen = 120
maxTitleLen = 200
maxBodyLen = 20000
maxKeywordLen = 64
maxKeywords = 40
maxIntents = 20
maxCatSlugs = 20
)
type seedFile struct {
Version int `json:"version"`
Articles []seedArticle `json:"articles"`
}
type seedArticle struct {
Slug string `json:"slug"`
Title string `json:"title"`
BodyMD string `json:"body_md"`
CategorySlugs []string `json:"category_slugs"`
Keywords []string `json:"keywords"`
IntentKeys []string `json:"intent_keys"`
IsPublished bool `json:"is_published"`
PriorityWeight int `json:"priority_weight"`
}
func main() {
postgresURL := flag.String("postgres", os.Getenv("DATABASE_URL"), "Postgres URL")
filePath := flag.String("file", "", "Path to support-kb-articles.json (default: repo scripts/seed/...)")
flag.Parse()
if strings.TrimSpace(*postgresURL) == "" {
log.Fatal("-postgres / DATABASE_URL is required")
}
path := strings.TrimSpace(*filePath)
if path == "" {
path = defaultArticlesPath()
}
raw, err := os.ReadFile(path)
if err != nil {
log.Fatalf("read %s: %v", path, err)
}
var sf seedFile
if err := json.Unmarshal(raw, &sf); err != nil {
log.Fatalf("parse json: %v", err)
}
if len(sf.Articles) == 0 {
log.Fatal("no articles in seed file")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
pool, err := pgxpool.New(ctx, *postgresURL)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pool.Close()
var tableOK bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'support_kb_articles'
)`).Scan(&tableOK); err != nil {
log.Fatalf("check table: %v", err)
}
if !tableOK {
log.Fatal("support_kb_articles missing — run goose migrations through 032_support_kb_auto_reply first")
}
upserted := 0
for i, a := range sf.Articles {
slug, err := normalizeSlug(a.Slug)
if err != nil {
log.Fatalf("article[%d] slug: %v", i, err)
}
title := clipRunes(strings.TrimSpace(a.Title), maxTitleLen)
body := clipRunes(strings.TrimSpace(a.BodyMD), maxBodyLen)
if title == "" || body == "" {
log.Fatalf("article[%d] (%s): title and body_md are required", i, slug)
}
cats := normalizeList(a.CategorySlugs, maxKeywordLen, maxCatSlugs)
keywords := normalizeList(a.Keywords, maxKeywordLen, maxKeywords)
intents := normalizeList(a.IntentKeys, maxKeywordLen, maxIntents)
tag, err := pool.Exec(ctx, `
INSERT INTO support_kb_articles (
slug, title, body_md, category_slugs, keywords, intent_keys,
is_published, priority_weight, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8, now())
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
body_md = EXCLUDED.body_md,
category_slugs = EXCLUDED.category_slugs,
keywords = EXCLUDED.keywords,
intent_keys = EXCLUDED.intent_keys,
is_published = EXCLUDED.is_published,
priority_weight = EXCLUDED.priority_weight,
updated_at = now()`,
slug, title, body, cats, keywords, intents, a.IsPublished, a.PriorityWeight)
if err != nil {
log.Fatalf("upsert %s: %v", slug, err)
}
if tag.RowsAffected() > 0 {
upserted++
fmt.Printf("upserted %s (%s)\n", slug, title)
}
}
var published, total int64
_ = pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles`).Scan(&total)
_ = pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles WHERE is_published`).Scan(&published)
fmt.Printf("done: %d articles from file; table total=%d published=%d\n", upserted, total, published)
}
func defaultArticlesPath() string {
// Prefer repo-relative path when run from apps/api.
candidates := []string{
filepath.Join("..", "..", "scripts", "seed", "support-kb-articles.json"),
filepath.Join("scripts", "seed", "support-kb-articles.json"),
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates,
filepath.Join(wd, "scripts", "seed", "support-kb-articles.json"),
filepath.Join(wd, "..", "..", "scripts", "seed", "support-kb-articles.json"),
)
}
for _, c := range candidates {
if st, err := os.Stat(c); err == nil && !st.IsDir() {
return c
}
}
return candidates[0]
}
func normalizeSlug(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, " ", "-")
if s == "" {
return "", fmt.Errorf("empty slug")
}
if utf8.RuneCountInString(s) > maxSlugLen {
return "", fmt.Errorf("slug too long")
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
continue
}
return "", fmt.Errorf("invalid slug char %q", r)
}
return s, nil
}
func normalizeList(in []string, maxItem, maxCount int) []string {
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, raw := range in {
s := strings.ToLower(strings.TrimSpace(raw))
if s == "" {
continue
}
s = clipRunes(s, maxItem)
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
if len(out) >= maxCount {
break
}
}
if out == nil {
return []string{}
}
return out
}
func clipRunes(s string, max int) string {
if max <= 0 || utf8.RuneCountInString(s) <= max {
return s
}
return string([]rune(s)[:max])
}