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,588 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user