Files
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

589 lines
16 KiB
Go

package support
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
const (
maxKBSlugLen = 120
maxKBTitleLen = 200
maxKBBodyLen = 100000
maxTemplateName = 120
maxTemplateBody = 10000
maxKeywordLen = 64
maxKeywordsCount = 40
maxIntentCount = 20
maxCatSlugCount = 20
kbCacheTTL = 60 * time.Second
)
type kbCorpusCache struct {
mu sync.RWMutex
articles []KBArticle
templates []ReplyTemplate
loadedAt time.Time
}
var sharedKBCache = &kbCorpusCache{}
func invalidateKBCache() {
sharedKBCache.mu.Lock()
sharedKBCache.loadedAt = time.Time{}
sharedKBCache.articles = nil
sharedKBCache.templates = nil
sharedKBCache.mu.Unlock()
}
func normalizeSlug(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, " ", "-")
if s == "" {
return "", ErrKBSlugRequired
}
if utf8.RuneCountInString(s) > maxKBSlugLen {
return "", ErrInvalidKBSlug
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
continue
}
return "", ErrInvalidKBSlug
}
return s, nil
}
func normalizeStringList(in []string, maxItem, maxCount int) []string {
if len(in) == 0 {
return []string{}
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, raw := range in {
s := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(raw, "\x00", "")))
if s == "" {
continue
}
if utf8.RuneCountInString(s) > maxItem {
s = string([]rune(s)[:maxItem])
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
if len(out) >= maxCount {
break
}
}
return out
}
func normalizeKBArticleInput(in KBArticleInput, forUpdate bool) (KBArticleInput, error) {
out := in
slug, err := normalizeSlug(in.Slug)
if err != nil && (!forUpdate || strings.TrimSpace(in.Slug) != "") {
return KBArticleInput{}, err
}
out.Slug = slug
title := strings.TrimSpace(strings.ReplaceAll(in.Title, "\x00", ""))
if title == "" && !forUpdate {
return KBArticleInput{}, ErrKBTitleRequired
}
if title != "" && utf8.RuneCountInString(title) > maxKBTitleLen {
title = string([]rune(title)[:maxKBTitleLen])
}
out.Title = title
body := strings.TrimSpace(strings.ReplaceAll(in.BodyMD, "\x00", ""))
if body == "" && !forUpdate {
return KBArticleInput{}, ErrKBBodyRequired
}
if body != "" && utf8.RuneCountInString(body) > maxKBBodyLen {
body = string([]rune(body)[:maxKBBodyLen])
}
out.BodyMD = body
out.CategorySlugs = normalizeStringList(in.CategorySlugs, maxCategoryLen, maxCatSlugCount)
out.Keywords = normalizeStringList(in.Keywords, maxKeywordLen, maxKeywordsCount)
out.IntentKeys = normalizeStringList(in.IntentKeys, maxKeywordLen, maxIntentCount)
return out, nil
}
func normalizeTemplateInput(in ReplyTemplateInput, forUpdate bool) (ReplyTemplateInput, error) {
out := in
name := strings.TrimSpace(strings.ReplaceAll(in.Name, "\x00", ""))
if name == "" && !forUpdate {
return ReplyTemplateInput{}, ErrTemplateNameRequired
}
if name != "" && utf8.RuneCountInString(name) > maxTemplateName {
name = string([]rune(name)[:maxTemplateName])
}
out.Name = name
body := strings.TrimSpace(strings.ReplaceAll(in.Body, "\x00", ""))
if body == "" && !forUpdate {
return ReplyTemplateInput{}, ErrTemplateBodyRequired
}
if body != "" && utf8.RuneCountInString(body) > maxTemplateBody {
body = string([]rune(body)[:maxTemplateBody])
}
out.Body = body
out.CategorySlugs = normalizeStringList(in.CategorySlugs, maxCategoryLen, maxCatSlugCount)
out.Keywords = normalizeStringList(in.Keywords, maxKeywordLen, maxKeywordsCount)
out.IntentKeys = normalizeStringList(in.IntentKeys, maxKeywordLen, maxIntentCount)
return out, nil
}
func scanKBArticle(row pgx.Row) (KBArticle, error) {
var a KBArticle
err := row.Scan(
&a.ID, &a.Slug, &a.Title, &a.BodyMD, &a.CategorySlugs, &a.Keywords, &a.IntentKeys,
&a.IsPublished, &a.PriorityWeight, &a.CreatedAt, &a.UpdatedAt,
)
if a.CategorySlugs == nil {
a.CategorySlugs = []string{}
}
if a.Keywords == nil {
a.Keywords = []string{}
}
if a.IntentKeys == nil {
a.IntentKeys = []string{}
}
return a, err
}
func scanReplyTemplate(row pgx.Row) (ReplyTemplate, error) {
var t ReplyTemplate
err := row.Scan(
&t.ID, &t.Name, &t.Body, &t.CategorySlugs, &t.Keywords, &t.IntentKeys,
&t.IsActive, &t.PriorityWeight, &t.CreatedAt, &t.UpdatedAt,
)
if t.CategorySlugs == nil {
t.CategorySlugs = []string{}
}
if t.Keywords == nil {
t.Keywords = []string{}
}
if t.IntentKeys == nil {
t.IntentKeys = []string{}
}
return t, err
}
const kbArticleCols = `id, slug, title, body_md, category_slugs, keywords, intent_keys, is_published, priority_weight, created_at, updated_at`
// kbArticleListCols omits body_md blobs on index pages (detail loads full body via GetKBArticle).
const kbArticleListCols = `id, slug, title, ''::text AS body_md, category_slugs, keywords, intent_keys, is_published, priority_weight, created_at, updated_at`
const replyTemplateCols = `id, name, body, category_slugs, keywords, intent_keys, is_active, priority_weight, created_at, updated_at`
const replyTemplateListCols = `id, name, ''::text AS body, category_slugs, keywords, intent_keys, is_active, priority_weight, created_at, updated_at`
const (
kbAdminListMaxLimit = 100
kbAdminListDefault = 50
matchCorpusMaxArticles = 500
)
// KBArticleListOpts filters the admin article index (bodies omitted).
type KBArticleListOpts struct {
PublishedOnly bool
Category string
Query string
Limit int
Offset int
}
// ListKBArticles returns platform KB articles (admin index — no body_md payload).
func (s *Service) ListKBArticles(ctx context.Context, publishedOnly bool, limit, offset int) ([]KBArticle, int64, error) {
return s.ListKBArticlesOpts(ctx, KBArticleListOpts{
PublishedOnly: publishedOnly,
Limit: limit,
Offset: offset,
})
}
// ListKBArticlesOpts returns a filtered admin article index (no body_md payload).
func (s *Service) ListKBArticlesOpts(ctx context.Context, opts KBArticleListOpts) ([]KBArticle, int64, error) {
limit := opts.Limit
if limit <= 0 || limit > kbAdminListMaxLimit {
limit = kbAdminListDefault
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
where := make([]string, 0, 4)
args := make([]any, 0, 6)
where = append(where, "TRUE")
if opts.PublishedOnly {
where = append(where, "is_published = true")
}
cat := strings.ToLower(strings.TrimSpace(opts.Category))
if cat != "" {
args = append(args, cat)
where = append(where, "category_slugs @> ARRAY[$"+strconv.Itoa(len(args))+"]::text[]")
}
q := strings.TrimSpace(opts.Query)
if q != "" {
if utf8.RuneCountInString(q) > 120 {
q = string([]rune(q)[:120])
}
args = append(args, "%"+strings.ToLower(q)+"%")
n := strconv.Itoa(len(args))
where = append(where, "(lower(title) LIKE $"+n+" OR lower(slug) LIKE $"+n+" OR EXISTS (SELECT 1 FROM unnest(keywords) k WHERE lower(k) LIKE $"+n+"))")
}
whereSQL := strings.Join(where, " AND ")
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM support_kb_articles WHERE `+whereSQL, args...).Scan(&total); err != nil {
return nil, 0, err
}
limitArg := len(args) + 1
offsetArg := len(args) + 2
args = append(args, limit, offset)
rows, err := s.Pool.Query(ctx, `
SELECT `+kbArticleListCols+`
FROM support_kb_articles
WHERE `+whereSQL+`
ORDER BY priority_weight DESC, updated_at DESC
LIMIT $`+strconv.Itoa(limitArg)+` OFFSET $`+strconv.Itoa(offsetArg), args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]KBArticle, 0, limit)
for rows.Next() {
a, err := scanKBArticle(rows)
if err != nil {
return nil, 0, err
}
out = append(out, a)
}
return out, total, rows.Err()
}
// GetKBArticle loads one article by id.
func (s *Service) GetKBArticle(ctx context.Context, id uuid.UUID) (KBArticle, error) {
a, err := scanKBArticle(s.Pool.QueryRow(ctx, `
SELECT `+kbArticleCols+` FROM support_kb_articles WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return KBArticle{}, ErrKBNotFound
}
return a, err
}
// CreateKBArticle inserts a new knowledge article.
func (s *Service) CreateKBArticle(ctx context.Context, in KBArticleInput) (KBArticle, error) {
norm, err := normalizeKBArticleInput(in, false)
if err != nil {
return KBArticle{}, err
}
published := false
if in.IsPublished != nil {
published = *in.IsPublished
}
weight := 0
if in.PriorityWeight != nil {
weight = *in.PriorityWeight
}
a, err := scanKBArticle(s.Pool.QueryRow(ctx, `
INSERT INTO support_kb_articles (
slug, title, body_md, category_slugs, keywords, intent_keys, is_published, priority_weight
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING `+kbArticleCols, norm.Slug, norm.Title, norm.BodyMD, norm.CategorySlugs, norm.Keywords, norm.IntentKeys, published, weight))
if err != nil {
if isUniqueViolation(err) {
return KBArticle{}, ErrKBSlugTaken
}
return KBArticle{}, err
}
invalidateKBCache()
return a, nil
}
// UpdateKBArticle patches an existing article.
func (s *Service) UpdateKBArticle(ctx context.Context, id uuid.UUID, in KBArticleInput) (KBArticle, error) {
cur, err := s.GetKBArticle(ctx, id)
if err != nil {
return KBArticle{}, err
}
norm, err := normalizeKBArticleInput(in, true)
if err != nil {
return KBArticle{}, err
}
if norm.Slug != "" {
cur.Slug = norm.Slug
}
if norm.Title != "" {
cur.Title = norm.Title
}
if norm.BodyMD != "" {
cur.BodyMD = norm.BodyMD
}
if in.CategorySlugs != nil {
cur.CategorySlugs = norm.CategorySlugs
}
if in.Keywords != nil {
cur.Keywords = norm.Keywords
}
if in.IntentKeys != nil {
cur.IntentKeys = norm.IntentKeys
}
if in.IsPublished != nil {
cur.IsPublished = *in.IsPublished
}
if in.PriorityWeight != nil {
cur.PriorityWeight = *in.PriorityWeight
}
a, err := scanKBArticle(s.Pool.QueryRow(ctx, `
UPDATE support_kb_articles SET
slug = $2, title = $3, body_md = $4, category_slugs = $5, keywords = $6,
intent_keys = $7, is_published = $8, priority_weight = $9, updated_at = now()
WHERE id = $1
RETURNING `+kbArticleCols,
id, cur.Slug, cur.Title, cur.BodyMD, cur.CategorySlugs, cur.Keywords, cur.IntentKeys, cur.IsPublished, cur.PriorityWeight))
if err != nil {
if isUniqueViolation(err) {
return KBArticle{}, ErrKBSlugTaken
}
return KBArticle{}, err
}
invalidateKBCache()
return a, nil
}
// DeleteKBArticle removes an article.
func (s *Service) DeleteKBArticle(ctx context.Context, id uuid.UUID) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM support_kb_articles WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrKBNotFound
}
invalidateKBCache()
return nil
}
// ListReplyTemplates returns platform reply templates (admin index — no body payload).
func (s *Service) ListReplyTemplates(ctx context.Context, activeOnly bool, limit, offset int) ([]ReplyTemplate, int64, error) {
if limit <= 0 || limit > kbAdminListMaxLimit {
limit = kbAdminListDefault
}
if offset < 0 {
offset = 0
}
where := `TRUE`
if activeOnly {
where = `is_active = true`
}
var total int64
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM support_reply_templates WHERE `+where).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.Pool.Query(ctx, `
SELECT `+replyTemplateListCols+`
FROM support_reply_templates
WHERE `+where+`
ORDER BY priority_weight DESC, updated_at DESC
LIMIT $1 OFFSET $2`, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
out := make([]ReplyTemplate, 0, limit)
for rows.Next() {
t, err := scanReplyTemplate(rows)
if err != nil {
return nil, 0, err
}
out = append(out, t)
}
return out, total, rows.Err()
}
// GetReplyTemplate loads one template by id.
func (s *Service) GetReplyTemplate(ctx context.Context, id uuid.UUID) (ReplyTemplate, error) {
t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, `
SELECT `+replyTemplateCols+` FROM support_reply_templates WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return ReplyTemplate{}, ErrTemplateNotFound
}
return t, err
}
// CreateReplyTemplate inserts a canned reply template.
func (s *Service) CreateReplyTemplate(ctx context.Context, in ReplyTemplateInput) (ReplyTemplate, error) {
norm, err := normalizeTemplateInput(in, false)
if err != nil {
return ReplyTemplate{}, err
}
active := true
if in.IsActive != nil {
active = *in.IsActive
}
weight := 0
if in.PriorityWeight != nil {
weight = *in.PriorityWeight
}
t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, `
INSERT INTO support_reply_templates (
name, body, category_slugs, keywords, intent_keys, is_active, priority_weight
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING `+replyTemplateCols, norm.Name, norm.Body, norm.CategorySlugs, norm.Keywords, norm.IntentKeys, active, weight))
if err != nil {
return ReplyTemplate{}, err
}
invalidateKBCache()
return t, nil
}
// UpdateReplyTemplate patches a template.
func (s *Service) UpdateReplyTemplate(ctx context.Context, id uuid.UUID, in ReplyTemplateInput) (ReplyTemplate, error) {
cur, err := s.GetReplyTemplate(ctx, id)
if err != nil {
return ReplyTemplate{}, err
}
norm, err := normalizeTemplateInput(in, true)
if err != nil {
return ReplyTemplate{}, err
}
if norm.Name != "" {
cur.Name = norm.Name
}
if norm.Body != "" {
cur.Body = norm.Body
}
if in.CategorySlugs != nil {
cur.CategorySlugs = norm.CategorySlugs
}
if in.Keywords != nil {
cur.Keywords = norm.Keywords
}
if in.IntentKeys != nil {
cur.IntentKeys = norm.IntentKeys
}
if in.IsActive != nil {
cur.IsActive = *in.IsActive
}
if in.PriorityWeight != nil {
cur.PriorityWeight = *in.PriorityWeight
}
t, err := scanReplyTemplate(s.Pool.QueryRow(ctx, `
UPDATE support_reply_templates SET
name = $2, body = $3, category_slugs = $4, keywords = $5, intent_keys = $6,
is_active = $7, priority_weight = $8, updated_at = now()
WHERE id = $1
RETURNING `+replyTemplateCols,
id, cur.Name, cur.Body, cur.CategorySlugs, cur.Keywords, cur.IntentKeys, cur.IsActive, cur.PriorityWeight))
if err != nil {
return ReplyTemplate{}, err
}
invalidateKBCache()
return t, nil
}
// DeleteReplyTemplate removes a template.
func (s *Service) DeleteReplyTemplate(ctx context.Context, id uuid.UUID) error {
tag, err := s.Pool.Exec(ctx, `DELETE FROM support_reply_templates WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrTemplateNotFound
}
invalidateKBCache()
return nil
}
func (s *Service) loadMatchCorpus(ctx context.Context) ([]KBArticle, []ReplyTemplate, error) {
sharedKBCache.mu.RLock()
if !sharedKBCache.loadedAt.IsZero() && time.Since(sharedKBCache.loadedAt) < kbCacheTTL {
arts := sharedKBCache.articles
tmps := sharedKBCache.templates
sharedKBCache.mu.RUnlock()
return arts, tmps, nil
}
sharedKBCache.mu.RUnlock()
// Dedicated full-body load (admin List* omits bodies and caps at 100).
arts, err := s.loadPublishedKBArticlesForMatch(ctx)
if err != nil {
return nil, nil, err
}
tmps, err := s.loadActiveReplyTemplatesForMatch(ctx)
if err != nil {
return nil, nil, err
}
sharedKBCache.mu.Lock()
sharedKBCache.articles = arts
sharedKBCache.templates = tmps
sharedKBCache.loadedAt = time.Now()
sharedKBCache.mu.Unlock()
return arts, tmps, nil
}
func (s *Service) loadPublishedKBArticlesForMatch(ctx context.Context) ([]KBArticle, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+kbArticleCols+`
FROM support_kb_articles
WHERE is_published = true
ORDER BY priority_weight DESC, updated_at DESC
LIMIT $1`, matchCorpusMaxArticles)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]KBArticle, 0)
for rows.Next() {
a, err := scanKBArticle(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Service) loadActiveReplyTemplatesForMatch(ctx context.Context) ([]ReplyTemplate, error) {
rows, err := s.Pool.Query(ctx, `
SELECT `+replyTemplateCols+`
FROM support_reply_templates
WHERE is_active = true
ORDER BY priority_weight DESC, updated_at DESC
LIMIT $1`, matchCorpusMaxArticles)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ReplyTemplate, 0)
for rows.Next() {
t, err := scanReplyTemplate(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return false
}