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.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// 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])
|
||||
}
|
||||
Reference in New Issue
Block a user