214 lines
5.8 KiB
Go
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])
|
||
|
|
}
|