Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

511 lines
12 KiB
Go

package db
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
const (
MaxTrendTerms = 12
MaxTrendTermVariants = 8
)
type TrendTermInput struct {
Term string `json:"term"`
Variants []string `json:"variants"`
Matchers []string `json:"-"`
}
type TrendBucket struct {
Date string `json:"date"`
MessageCount int `json:"message_count"`
}
type TrendPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
type TrendSeries struct {
Term string `json:"term"`
Variants []string `json:"variants"`
Total int `json:"total"`
Points []TrendPoint `json:"points"`
}
type TrendsTermsResponse struct {
Granularity string `json:"granularity"`
From string `json:"from"`
To string `json:"to"`
MessageCount int `json:"message_count"`
Buckets []TrendBucket `json:"buckets"`
Series []TrendSeries `json:"series"`
}
func (db *DB) GetTrendsTerms(
ctx context.Context,
f AnalyticsFilter,
terms []TrendTermInput,
granularity string,
) (TrendsTermsResponse, error) {
if granularity == "" {
granularity = "week"
}
loc := f.location()
buckets := TrendBucketRange(f.From, f.To, granularity)
bucketIndex := trendBucketIndex(buckets)
counts := make([][]int, len(terms))
for i := range counts {
counts[i] = make([]int, len(buckets))
}
messageCounts := make([]int, len(buckets))
sessionFilter := f
sessionFilter.From = ""
sessionFilter.To = ""
sessionFilter.DayOfWeek = nil
sessionFilter.Hour = nil
sessionFilter.Model = ""
where, args := sessionFilter.buildWhereWithDate("", false, "s.id")
flt := f.messageScopeFilter()
modelFiltering := len(flt.Models) > 0
query := `SELECT m.session_id, m.ordinal, m.role, m.is_system,
COALESCE(m.model, ''), m.content, COALESCE(m.timestamp, ''),
COALESCE(s.started_at, ''), s.created_at
FROM sessions s
JOIN messages m ON m.session_id = s.id
WHERE ` + where + `
AND m.role IN ('user', 'assistant')
AND m.is_system = 0
AND ` + SystemPrefixSQL("m.content", "m.role") + `
ORDER BY m.session_id, m.ordinal`
rows, err := db.getReader().QueryContext(ctx, query, args...)
if err != nil {
return TrendsTermsResponse{}, fmt.Errorf("querying trends terms: %w", err)
}
defer rows.Close()
type trendRow struct {
sessionID string
role string
isSystem bool
model string
content string
msgTS string
startedAt string
createdAt string
}
processRow := func(row trendRow) {
msgTime, ok := trendMessageLocalTime(row.msgTS, row.startedAt, row.createdAt, loc)
if !ok {
return
}
msgDate := msgTime.Format("2006-01-02")
if !inDateRange(msgDate, f.From, f.To) {
return
}
bucketDate := trendBucketDate(msgTime, loc, granularity)
bucket, ok := bucketIndex[bucketDate]
if !ok {
return
}
messageCounts[bucket]++
for i, term := range terms {
count := countTrendOccurrences(row.content, term)
if count > 0 {
counts[i][bucket] += count
}
}
}
rowStartedAt := make(map[string]string)
rowCreatedAt := make(map[string]string)
emit := func(m ScopedMessage) {
processRow(trendRow{
sessionID: m.SessionID,
role: m.Role,
isSystem: m.IsSystem,
content: m.Content,
msgTS: m.Timestamp,
startedAt: rowStartedAt[m.SessionID],
createdAt: rowCreatedAt[m.SessionID],
})
}
reducer := NewScopeReducer(flt, emit)
for rows.Next() {
var row trendRow
var ordinal int
if err := rows.Scan(
&row.sessionID, &ordinal, &row.role, &row.isSystem,
&row.model, &row.content, &row.msgTS, &row.startedAt,
&row.createdAt,
); err != nil {
return TrendsTermsResponse{}, fmt.Errorf("scanning trends term row: %w", err)
}
if !modelFiltering {
msgTime, ok := trendMessageLocalTime(row.msgTS, row.startedAt, row.createdAt, loc)
if ok && flt.MatchesDayHour(msgTime, true) {
processRow(row)
}
continue
}
rowStartedAt[row.sessionID] = row.startedAt
rowCreatedAt[row.sessionID] = row.createdAt
msgTime, has := trendMessageLocalTime(row.msgTS, row.startedAt, row.createdAt, loc)
if err := reducer.Push(MessageInput{
SessionID: row.sessionID,
Ordinal: ordinal,
Role: row.role,
Model: row.model,
IsSystem: row.isSystem,
Timestamp: row.msgTS,
LocalTime: msgTime,
HasLocalTime: has,
Content: row.content,
}); err != nil {
return TrendsTermsResponse{}, err
}
}
if err := rows.Err(); err != nil {
return TrendsTermsResponse{}, fmt.Errorf("iterating trends term rows: %w", err)
}
return BuildTrendsTermsResponse(
f.From, f.To, granularity, buckets, terms, counts, messageCounts,
), nil
}
func ParseTrendTerms(values []string) ([]TrendTermInput, error) {
terms := make([]TrendTermInput, 0, min(len(values), MaxTrendTerms))
for _, value := range values {
variants := parseTrendTermVariants(value)
if len(variants) == 0 {
continue
}
if len(variants) > MaxTrendTermVariants {
return nil, fmt.Errorf("trend terms can have at most %d variants", MaxTrendTermVariants)
}
matchers := make([]string, 0, len(variants)*3)
for _, variant := range variants {
matchers = append(matchers, variant)
if plural, ok := simpleTrendPlural(variant); ok {
matchers = append(matchers, plural)
}
matchers = append(matchers, simpleSilentEStemMatchers(variant)...)
}
matchers = dedupeCaseFolded(matchers)
terms = append(terms, TrendTermInput{
Term: variants[0],
Variants: variants,
Matchers: matchers,
})
}
if len(terms) == 0 {
return nil, fmt.Errorf("at least one trend term is required")
}
if len(terms) > MaxTrendTerms {
return nil, fmt.Errorf("trend query supports at most %d terms", MaxTrendTerms)
}
return terms, nil
}
func parseTrendTermVariants(value string) []string {
parts := strings.Split(value, "|")
variants := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
variant := strings.TrimSpace(part)
if variant == "" {
continue
}
key := strings.ToLower(variant)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
variants = append(variants, variant)
}
return variants
}
func simpleTrendPlural(value string) (string, bool) {
if value == "" || strings.ContainsAny(value, " \t\n\r") {
return "", false
}
if strings.HasSuffix(strings.ToLower(value), "s") {
return "", false
}
return value + "s", true
}
func simpleSilentEStemMatchers(value string) []string {
if len(value) < 3 || !isSingleWordMatcher(value) {
return nil
}
lower := strings.ToLower(value)
if !strings.HasSuffix(lower, "c") ||
strings.HasSuffix(lower, "e") ||
strings.HasSuffix(lower, "s") {
return nil
}
withE := value + "e"
return []string{
withE,
withE + "s",
withE + "d",
value + "ing",
}
}
func dedupeCaseFolded(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
key := strings.ToLower(value)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, value)
}
return out
}
type matchSpan struct {
start int
end int
}
func countTrendOccurrences(text string, term TrendTermInput) int {
return CountTrendOccurrences(text, term)
}
func CountTrendOccurrences(text string, term TrendTermInput) int {
spans := make([]matchSpan, 0)
for _, matcher := range term.Matchers {
matcher = strings.TrimSpace(matcher)
if matcher == "" {
continue
}
wordBounded := isSingleWordMatcher(matcher)
spans = append(spans, collectTrendSpans(text, matcher, wordBounded)...)
}
return mergeCountSpans(spans)
}
func collectTrendSpans(text string, matcher string, wordBounded bool) []matchSpan {
needle := strings.ToLower(matcher)
haystack := strings.ToLower(text)
if needle == "" || haystack == "" {
return nil
}
spans := make([]matchSpan, 0)
for offset := 0; offset < len(haystack); {
idx := strings.Index(haystack[offset:], needle)
if idx < 0 {
break
}
start := offset + idx
end := start + len(needle)
if !wordBounded || hasWordBoundaries(haystack, start, end) {
spans = append(spans, matchSpan{start: start, end: end})
}
offset = start + 1
}
return spans
}
func isSingleWordMatcher(matcher string) bool {
if matcher == "" {
return false
}
for i := 0; i < len(matcher); i++ {
if !isWordByte(matcher[i]) {
return false
}
}
return true
}
func hasWordBoundaries(text string, start, end int) bool {
if start > 0 && isWordByte(text[start-1]) {
return false
}
if end < len(text) && isWordByte(text[end]) {
return false
}
return true
}
func isWordByte(b byte) bool {
return (b >= 'a' && b <= 'z') ||
(b >= 'A' && b <= 'Z') ||
(b >= '0' && b <= '9') ||
b == '_'
}
func mergeCountSpans(spans []matchSpan) int {
if len(spans) == 0 {
return 0
}
sort.Slice(spans, func(i, j int) bool {
if spans[i].start == spans[j].start {
return spans[i].end < spans[j].end
}
return spans[i].start < spans[j].start
})
count := 0
mergedEnd := -1
for _, span := range spans {
if span.start >= mergedEnd {
count++
mergedEnd = span.end
continue
}
if span.end > mergedEnd {
mergedEnd = span.end
}
}
return count
}
func trendMessageLocalTime(
messageTS string,
startedAt string,
createdAt string,
loc *time.Location,
) (time.Time, bool) {
for _, ts := range []string{messageTS, startedAt, createdAt} {
if t, ok := localTime(ts, loc); ok {
return t, true
}
}
return time.Time{}, false
}
func trendBucketDate(t time.Time, loc *time.Location, granularity string) string {
return TrendBucketDate(t, loc, granularity)
}
func TrendBucketDate(t time.Time, loc *time.Location, granularity string) string {
local := t.In(loc)
switch granularity {
case "week":
weekday := int(local.Weekday())
if weekday == 0 {
weekday = 7
}
start := local.AddDate(0, 0, -(weekday - 1))
return time.Date(
start.Year(), start.Month(), start.Day(),
0, 0, 0, 0, loc,
).Format("2006-01-02")
case "month":
return time.Date(
local.Year(), local.Month(), 1,
0, 0, 0, 0, loc,
).Format("2006-01-02")
default:
return local.Format("2006-01-02")
}
}
func TrendBucketRange(from, to, granularity string) []TrendBucket {
if from == "" || to == "" {
return nil
}
start, err := time.Parse("2006-01-02", from)
if err != nil {
return nil
}
end, err := time.Parse("2006-01-02", to)
if err != nil {
return nil
}
startDate := trendBucketDate(start, time.UTC, granularity)
endDate := trendBucketDate(end, time.UTC, granularity)
cur, err := time.Parse("2006-01-02", startDate)
if err != nil {
return nil
}
last, err := time.Parse("2006-01-02", endDate)
if err != nil {
return nil
}
buckets := make([]TrendBucket, 0)
for !cur.After(last) {
buckets = append(buckets, TrendBucket{Date: cur.Format("2006-01-02")})
switch granularity {
case "month":
cur = cur.AddDate(0, 1, 0)
case "week":
cur = cur.AddDate(0, 0, 7)
default:
cur = cur.AddDate(0, 0, 1)
}
}
return buckets
}
func trendBucketIndex(buckets []TrendBucket) map[string]int {
index := make(map[string]int, len(buckets))
for i, bucket := range buckets {
index[bucket.Date] = i
}
return index
}
func BuildTrendsTermsResponse(
from string,
to string,
granularity string,
buckets []TrendBucket,
terms []TrendTermInput,
counts [][]int,
messageCounts []int,
) TrendsTermsResponse {
outBuckets := make([]TrendBucket, len(buckets))
totalMessages := 0
for i, bucket := range buckets {
outBuckets[i] = bucket
if i < len(messageCounts) {
outBuckets[i].MessageCount = messageCounts[i]
totalMessages += messageCounts[i]
}
}
series := make([]TrendSeries, len(terms))
for i, term := range terms {
points := make([]TrendPoint, len(buckets))
total := 0
for j, bucket := range buckets {
count := 0
if i < len(counts) && j < len(counts[i]) {
count = counts[i][j]
}
total += count
points[j] = TrendPoint{
Date: bucket.Date,
Count: count,
}
}
series[i] = TrendSeries{
Term: term.Term,
Variants: append([]string(nil), term.Variants...),
Total: total,
Points: points,
}
}
return TrendsTermsResponse{
Granularity: granularity,
From: from,
To: to,
MessageCount: totalMessages,
Buckets: outBuckets,
Series: series,
}
}