966 lines
28 KiB
Go
966 lines
28 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
log "github.com/nacorid/logger"
|
|
"github.com/nacorid/naco-api/internal/clustering"
|
|
"github.com/nacorid/naco-api/internal/store"
|
|
"github.com/nacorid/naco-api/internal/utils"
|
|
)
|
|
|
|
const (
|
|
defaultAnalyticsLimit = 50
|
|
maxAnalyticsLimit = 200
|
|
defaultAnalyticsDays = 30
|
|
maxTopicCount = 10
|
|
minTopicCount = 3
|
|
sentimentBucketDuration = 24 * time.Hour
|
|
)
|
|
|
|
// ---- Analytics request/response helpers ----
|
|
|
|
func parseAnalyticsTimeRange(startStr, endStr string) (time.Time, time.Time, error) {
|
|
now := time.Now().UTC()
|
|
end := now
|
|
start := now.AddDate(0, 0, -defaultAnalyticsDays)
|
|
|
|
if endStr != "" {
|
|
t, err := time.Parse(time.RFC3339, endStr)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid endTime %q: %w", endStr, err)
|
|
}
|
|
end = t
|
|
}
|
|
if startStr != "" {
|
|
t, err := time.Parse(time.RFC3339, startStr)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid startTime %q: %w", startStr, err)
|
|
}
|
|
start = t
|
|
}
|
|
if start.After(end) {
|
|
start, end = end, start
|
|
}
|
|
return start, end, nil
|
|
}
|
|
|
|
func hashTimeRange(start, end time.Time, limit int) string {
|
|
data := fmt.Sprintf("%s|%s|%d", start.Format(time.RFC3339), end.Format(time.RFC3339), limit)
|
|
h := sha256.Sum256([]byte(data))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
|
|
func (s *Server) getAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string) (json.RawMessage, bool) {
|
|
result, err := s.db.GetAnalyticsCache(ctx, store.GetAnalyticsCacheParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
ToolName: toolName,
|
|
TimeRangeHash: timeRangeHash,
|
|
})
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return result, true
|
|
}
|
|
|
|
func (s *Server) setAnalyticsCache(ctx context.Context, author, platform, toolName, timeRangeHash string, result any, ttl time.Duration) error {
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expiresAt := time.Now().Add(ttl)
|
|
return s.db.UpsertAnalyticsCache(ctx, store.UpsertAnalyticsCacheParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
ToolName: toolName,
|
|
TimeRangeHash: timeRangeHash,
|
|
Result: data,
|
|
ExpiresAt: expiresAt,
|
|
})
|
|
}
|
|
|
|
// ---- Main analytics entry points ----
|
|
|
|
func (s *Server) getEngagementMetrics(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args AnalyticsRequest
|
|
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return s.computeAnalytics(ctx, args, "engagementMetrics", s.computeEngagementMetrics)
|
|
}
|
|
|
|
func (s *Server) getPostingPatterns(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args AnalyticsRequest
|
|
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return s.computeAnalytics(ctx, args, "postingPatterns", s.computePostingPatterns)
|
|
}
|
|
|
|
func (s *Server) getTopicModeling(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args AnalyticsRequest
|
|
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return s.computeAnalytics(ctx, args, "topicModeling", s.computeTopicModeling)
|
|
}
|
|
|
|
func (s *Server) getSentimentTrends(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args AnalyticsRequest
|
|
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return s.computeAnalytics(ctx, args, "sentimentTrends", s.computeSentimentTrends)
|
|
}
|
|
|
|
func (s *Server) getEngagementDrivers(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var args AnalyticsRequest
|
|
if err := json.Unmarshal(request.Params.Arguments, &args); err != nil {
|
|
return nil, fmt.Errorf("invalid arguments: %w", err)
|
|
}
|
|
return s.computeAnalytics(ctx, args, "engagementDrivers", s.computeEngagementDrivers)
|
|
}
|
|
|
|
type analyticsComputer func(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error)
|
|
|
|
func (s *Server) computeAnalytics(ctx context.Context, args AnalyticsRequest, toolName string, compute analyticsComputer) (*mcp.CallToolResult, error) {
|
|
if args.Author == "" {
|
|
return nil, fmt.Errorf("author must be a valid did or handle")
|
|
}
|
|
|
|
// Normalize args
|
|
limit := args.Limit
|
|
if limit <= 0 {
|
|
limit = defaultAnalyticsLimit
|
|
}
|
|
if limit > maxAnalyticsLimit {
|
|
limit = maxAnalyticsLimit
|
|
}
|
|
|
|
start, end, err := parseAnalyticsTimeRange(args.StartTime, args.EndTime)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid time range: %w", err)
|
|
}
|
|
|
|
var platforms []string
|
|
if args.Platform != "" {
|
|
platforms = []string{args.Platform}
|
|
} else {
|
|
// Twitter is included by default only when the API is enabled: the API
|
|
// costs it incurs are passed on to the caller through the upto pricing
|
|
// of these tools (see registerTools), so aggregating over it does not
|
|
// run the server at a loss. When no bearer token is configured there is
|
|
// no Twitter to aggregate over.
|
|
platforms = []string{"Bluesky", "ActivityPub", "Nostr"}
|
|
if s.twitterEnabled {
|
|
platforms = append(platforms, "Twitter")
|
|
}
|
|
}
|
|
|
|
timeRangeHash := hashTimeRange(start, end, limit)
|
|
|
|
// The analytics result cache is keyed by the scope of the computation: a
|
|
// single platform when one is requested, or the "all" sentinel when the
|
|
// result aggregates across all platforms. This keeps aggregate results from
|
|
// being mixed with single-platform results.
|
|
cachePlatform := "all"
|
|
if args.Platform != "" {
|
|
cachePlatform = args.Platform
|
|
}
|
|
|
|
if cached, ok := s.getAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash); ok {
|
|
var cachedResult json.RawMessage
|
|
_ = json.Unmarshal(cached, &cachedResult)
|
|
return s.toToolResult(cachedResult)
|
|
}
|
|
|
|
// Fetch posts from all platforms
|
|
var allPosts []clustering.Item
|
|
twitterPosts := 0
|
|
for _, p := range platforms {
|
|
plat, ok := platformFromString(p)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported platform %q", p)
|
|
}
|
|
posts, err := s.fetchAnalyticsPosts(ctx, plat, args.Author, start, end, limit)
|
|
if err != nil {
|
|
log.WarnWithContext(ctx, "failed to fetch posts for analytics", "platform", p, "error", err)
|
|
continue
|
|
}
|
|
allPosts = append(allPosts, posts...)
|
|
if plat == Twitter {
|
|
twitterPosts += len(posts)
|
|
}
|
|
}
|
|
|
|
if len(allPosts) == 0 {
|
|
return nil, fmt.Errorf("no posts found for author %s in time range", args.Author)
|
|
}
|
|
|
|
// Compute
|
|
result, err := compute(ctx, args, allPosts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = setAnalyticsTwitterPostCount(result, twitterPosts)
|
|
|
|
// Cache the result under the matching scope key.
|
|
ttl := s.getCacheTTL(toolName)
|
|
_ = s.setAnalyticsCache(ctx, args.Author, cachePlatform, toolName, timeRangeHash, result, ttl)
|
|
|
|
return s.toToolResult(result)
|
|
}
|
|
|
|
// setAnalyticsTwitterPostCount records how many posts in an analytics result
|
|
// were sourced from the Twitter API, so upto-scheme settlement can pass the
|
|
// realized Twitter costs on to the caller. Unknown result types are returned
|
|
// unchanged.
|
|
func setAnalyticsTwitterPostCount(result any, count int) any {
|
|
switch v := result.(type) {
|
|
case EngagementMetricsResponse:
|
|
v.TwitterPostCount = count
|
|
return v
|
|
case PostingPatternsResponse:
|
|
v.TwitterPostCount = count
|
|
return v
|
|
case TopicModelingResponse:
|
|
v.TwitterPostCount = count
|
|
return v
|
|
case SentimentTrendsResponse:
|
|
v.TwitterPostCount = count
|
|
return v
|
|
case EngagementDriversResponse:
|
|
v.TwitterPostCount = count
|
|
return v
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *Server) fetchAnalyticsPosts(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
|
posts, err := s.fetchPostsInTimeForAnalytics(ctx, platform, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return posts, nil
|
|
}
|
|
|
|
func (s *Server) getCachedAnalyticsPosts(ctx context.Context, author, platform string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
|
rows, err := s.db.GetAnalyticsPostsInRange(ctx, store.GetAnalyticsPostsInRangeParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
PostTimestamp: start,
|
|
PostTimestamp_2: end,
|
|
Limit: int32(limit),
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]clustering.Item, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, clustering.Item{
|
|
ID: r.PostID,
|
|
Text: r.PostText,
|
|
Timestamp: r.PostTimestamp,
|
|
Likes: r.Likes,
|
|
Reposts: r.Reposts,
|
|
Replies: r.Replies,
|
|
Quotes: r.Quotes,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *Server) persistAnalyticsPosts(ctx context.Context, author, platform string, posts []clustering.Item) error {
|
|
for _, p := range posts {
|
|
if p.ID == "" {
|
|
continue
|
|
}
|
|
if err := s.db.UpsertAnalyticsPosts(ctx, store.UpsertAnalyticsPostsParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
PostID: p.ID,
|
|
PostText: p.Text,
|
|
PostTimestamp: p.Timestamp,
|
|
Likes: p.Likes,
|
|
Reposts: p.Reposts,
|
|
Replies: p.Replies,
|
|
Quotes: p.Quotes,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) fetchPostsInTimeForAnalytics(ctx context.Context, platform platform, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
|
var items []clustering.Item
|
|
switch platform {
|
|
case Bluesky:
|
|
res, err := s.bskyClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = blueskyFeedViewsToAnalyticsItems(res)
|
|
case ActivityPub:
|
|
res, err := s.apClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = apPostsToAnalyticsItems(res)
|
|
case Nostr:
|
|
res, err := s.nostrClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = nostrPostsToAnalyticsItems(res)
|
|
case Twitter:
|
|
// Twitter API calls are expensive (paid). Reuse persisted post data and
|
|
// only call the API for time ranges that are not yet covered by the
|
|
// cache, using a coverage watermark to guarantee broad coverage.
|
|
fetched, err := s.fetchTwitterAnalyticsPosts(ctx, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, fetched...)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// fetchTwitterAnalyticsPosts returns posts in [start, end] for the author,
|
|
// fetching from the Twitter API only the sub-ranges not already covered by the
|
|
// persistent post cache, and storing any newly fetched posts for future use.
|
|
//
|
|
// Coverage is tracked as a set of discrete intervals, so disjoint fetched
|
|
// ranges (e.g. [Dec 1, Dec 15] and [Dec 20, Dec 31]) are not merged into one
|
|
// continuous window. A later request that lands in the never-fetched gap
|
|
// (e.g. [Dec 15, Dec 20]) is therefore fetched from the API rather than being
|
|
// wrongly served from an incomplete cache.
|
|
func (s *Server) fetchTwitterAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
|
covered, err := s.getAnalyticsCoverageRanges(ctx, author, "Twitter")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If the entire requested range falls within already-covered intervals,
|
|
// serve from the cache without incurring a paid Twitter request.
|
|
if len(uncoveredRanges(start, end, covered)) == 0 {
|
|
return s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
|
}
|
|
|
|
// Determine the sub-ranges of [start, end] not yet covered, and fetch each
|
|
// from the API. Fetching per uncovered range keeps costs bounded while
|
|
// expanding coverage on every call.
|
|
ranges := uncoveredRanges(start, end, covered)
|
|
|
|
var fetched []clustering.Item
|
|
for _, r := range ranges {
|
|
posts, err := s.fetchTwitterFromAPI(ctx, author, r.start, r.end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fetched = append(fetched, posts...)
|
|
}
|
|
|
|
// Persist newly fetched posts permanently so repeat calls never re-pay.
|
|
if len(fetched) > 0 {
|
|
if err := s.persistAnalyticsPosts(ctx, author, "Twitter", fetched); err != nil {
|
|
log.WarnWithContext(ctx, "failed to persist analytics posts", "platform", "Twitter", "error", err)
|
|
}
|
|
}
|
|
|
|
// Record the newly fetched intervals as covered so future calls can reuse
|
|
// them without hitting the paid API.
|
|
for _, r := range ranges {
|
|
if err := s.addAnalyticsCoverageRange(ctx, author, "Twitter", r.start, r.end); err != nil {
|
|
log.WarnWithContext(ctx, "failed to add analytics coverage range", "platform", "Twitter", "range", r, "error", err)
|
|
}
|
|
}
|
|
|
|
// Return the full set: posts from cache plus anything newly fetched, merged
|
|
// and de-duplicated by post ID.
|
|
return s.mergeAnalyticsPosts(ctx, author, start, end, limit, fetched)
|
|
}
|
|
|
|
type timeRange struct {
|
|
start, end time.Time
|
|
}
|
|
|
|
// uncoveredRanges returns the sub-ranges of [start, end] that are not covered
|
|
// by any of the given intervals. Intervals are assumed sorted by their start,
|
|
// and no coverage (empty slice) yields the whole range as uncovered.
|
|
func uncoveredRanges(start, end time.Time, covered []timeRange) []timeRange {
|
|
var ranges []timeRange
|
|
cursor := start
|
|
for _, c := range covered {
|
|
if c.end.Before(cursor) || c.end.Equal(cursor) {
|
|
continue
|
|
}
|
|
if c.start.After(end) {
|
|
break
|
|
}
|
|
if c.start.After(cursor) {
|
|
ranges = append(ranges, timeRange{start: cursor, end: c.start})
|
|
}
|
|
if c.end.After(cursor) {
|
|
cursor = c.end
|
|
}
|
|
}
|
|
if cursor.Before(end) {
|
|
ranges = append(ranges, timeRange{start: cursor, end: end})
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
func (s *Server) getAnalyticsCoverageRanges(ctx context.Context, author, platform string) ([]timeRange, error) {
|
|
rows, err := s.db.GetAnalyticsCoverageRanges(ctx, store.GetAnalyticsCoverageRangesParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ranges := make([]timeRange, 0, len(rows))
|
|
for _, r := range rows {
|
|
ranges = append(ranges, timeRange{start: r.CoveredStart, end: r.CoveredEnd})
|
|
}
|
|
return ranges, nil
|
|
}
|
|
|
|
func (s *Server) addAnalyticsCoverageRange(ctx context.Context, author, platform string, start, end time.Time) error {
|
|
return s.db.InsertAnalyticsCoverageRange(ctx, store.InsertAnalyticsCoverageRangeParams{
|
|
UserID: author,
|
|
Platform: platform,
|
|
CoveredStart: start,
|
|
CoveredEnd: end,
|
|
})
|
|
}
|
|
|
|
func (s *Server) fetchTwitterFromAPI(ctx context.Context, author string, start, end time.Time, limit int) ([]clustering.Item, error) {
|
|
res, err := s.twitterClient.GetPostsInTimeframe(ctx, author, start, end, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return twitterPostsToAnalyticsItems(res), nil
|
|
}
|
|
|
|
// mergeAnalyticsPosts combines cached posts in [start, end] with newly fetched
|
|
// posts, de-duplicating by post ID and capping the result at limit. The cached
|
|
// query and each fetched sub-range individually honor limit, so the merged set
|
|
// can otherwise exceed it when the range is split into multiple fetch calls.
|
|
func (s *Server) mergeAnalyticsPosts(ctx context.Context, author string, start, end time.Time, limit int, fetched []clustering.Item) ([]clustering.Item, error) {
|
|
merged := make([]clustering.Item, 0, len(fetched))
|
|
seen := make(map[string]struct{}, len(fetched))
|
|
for _, p := range fetched {
|
|
if len(merged) >= limit {
|
|
break
|
|
}
|
|
if _, ok := seen[p.ID]; ok {
|
|
continue
|
|
}
|
|
seen[p.ID] = struct{}{}
|
|
merged = append(merged, p)
|
|
}
|
|
|
|
cached, err := s.getCachedAnalyticsPosts(ctx, author, "Twitter", start, end, limit)
|
|
if err != nil {
|
|
return merged, nil
|
|
}
|
|
for _, p := range cached {
|
|
if len(merged) >= limit {
|
|
break
|
|
}
|
|
if _, ok := seen[p.ID]; ok {
|
|
continue
|
|
}
|
|
seen[p.ID] = struct{}{}
|
|
merged = append(merged, p)
|
|
}
|
|
return merged, nil
|
|
}
|
|
|
|
func (s *Server) toToolResult(result any) (*mcp.CallToolResult, error) {
|
|
data, err := json.Marshal(result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mcp.CallToolResult{
|
|
Content: []mcp.Content{
|
|
&mcp.TextContent{Text: string(data)},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) getCacheTTL(toolName string) time.Duration {
|
|
switch toolName {
|
|
case "postingPatterns":
|
|
return s.analyticsTTLPatterns
|
|
case "topicModeling":
|
|
return s.analyticsTTLTopics
|
|
case "sentimentTrends":
|
|
return s.analyticsTTLSentiment
|
|
case "engagementDrivers":
|
|
return s.analyticsTTLDrivers
|
|
case "engagementMetrics":
|
|
fallthrough
|
|
default:
|
|
return s.analyticsTTLEngagement
|
|
}
|
|
}
|
|
|
|
// ---- Computation implementations ----
|
|
|
|
func (s *Server) computeEngagementMetrics(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
|
var metrics []EngagementMetricPoint
|
|
for _, post := range posts {
|
|
total := post.Likes + post.Reposts + post.Replies + post.Quotes
|
|
// Rate is a simple relative measure (interactions per post). Without a
|
|
// follower/fan base to normalize against, report total interactions.
|
|
metrics = append(metrics, EngagementMetricPoint{
|
|
PostID: post.ID,
|
|
Timestamp: post.Timestamp.Format(time.RFC3339),
|
|
Likes: post.Likes,
|
|
Reposts: post.Reposts,
|
|
Replies: post.Replies,
|
|
Quotes: post.Quotes,
|
|
EngagementRate: float64(total),
|
|
})
|
|
}
|
|
|
|
return EngagementMetricsResponse{
|
|
Author: args.Author,
|
|
Metrics: metrics,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) computePostingPatterns(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
|
// Heatmap: 24 hours x 7 days
|
|
heatmap := make([][]int, 24)
|
|
for i := range heatmap {
|
|
heatmap[i] = make([]int, 7)
|
|
}
|
|
dayCounts := make(map[int]int)
|
|
hourCounts := make(map[int]int)
|
|
dateSet := make(map[string]bool)
|
|
|
|
for _, post := range posts {
|
|
t := post.Timestamp
|
|
if t.IsZero() {
|
|
continue
|
|
}
|
|
hour := t.Hour()
|
|
weekday := int(t.Weekday())
|
|
heatmap[hour][weekday]++
|
|
hourCounts[hour]++
|
|
dayCounts[weekday]++
|
|
dateSet[t.Format("2006-01-02")] = true
|
|
}
|
|
|
|
// Find peak hours (top 3)
|
|
type hourCount struct {
|
|
Hour int
|
|
Count int
|
|
}
|
|
var hours []hourCount
|
|
for h, c := range hourCounts {
|
|
hours = append(hours, hourCount{h, c})
|
|
}
|
|
sort.Slice(hours, func(i, j int) bool { return hours[i].Count > hours[j].Count })
|
|
peakHours := make([]int, 0, 3)
|
|
for i := 0; i < len(hours) && i < 3; i++ {
|
|
peakHours = append(peakHours, hours[i].Hour)
|
|
}
|
|
|
|
// Find peak days (top 3)
|
|
type dayCount struct {
|
|
Day int
|
|
Count int
|
|
}
|
|
var days []dayCount
|
|
for d, c := range dayCounts {
|
|
days = append(days, dayCount{d, c})
|
|
}
|
|
sort.Slice(days, func(i, j int) bool { return days[i].Count > days[j].Count })
|
|
peakDays := make([]int, 0, 3)
|
|
for i := 0; i < len(days) && i < 3; i++ {
|
|
peakDays = append(peakDays, days[i].Day)
|
|
}
|
|
|
|
totalPosts := len(posts)
|
|
postingDays := len(dateSet)
|
|
avgPostsPerDay := 0.0
|
|
if postingDays > 0 {
|
|
avgPostsPerDay = float64(totalPosts) / float64(postingDays)
|
|
}
|
|
|
|
return PostingPatternsResponse{
|
|
Author: args.Author,
|
|
Heatmap: heatmap,
|
|
PeakHours: peakHours,
|
|
PeakDays: peakDays,
|
|
AvgPostsPerDay: avgPostsPerDay,
|
|
TotalPosts: totalPosts,
|
|
PostingDays: postingDays,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) computeTopicModeling(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
|
topics, _, err := s.clusterPosts(ctx, posts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return TopicModelingResponse{
|
|
Author: args.Author,
|
|
Topics: topics,
|
|
}, nil
|
|
}
|
|
|
|
// clusterPosts runs topic clustering over the posts and returns both the
|
|
// resulting topics and a map from post ID to the label of the topic the post
|
|
// belongs to. The membership map lets callers correlate topics with per-post
|
|
// attributes (e.g. engagement) without repeating the clustering.
|
|
func (s *Server) clusterPosts(ctx context.Context, posts []clustering.Item) ([]TopicCluster, map[string]string, error) {
|
|
if len(posts) < minTopicCount {
|
|
return []TopicCluster{}, map[string]string{}, nil
|
|
}
|
|
|
|
items := posts
|
|
embeddings, err := s.ollama.EmbedTexts(ctx, items)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to compute embeddings: %w", err)
|
|
}
|
|
|
|
for i := range items {
|
|
items[i].Embedding = embeddings[i]
|
|
}
|
|
|
|
topicCount := maxTopicCount
|
|
if len(items) < maxTopicCount {
|
|
topicCount = len(items)
|
|
}
|
|
if topicCount < minTopicCount {
|
|
topicCount = minTopicCount
|
|
}
|
|
|
|
results, err := clustering.KMeans(items, topicCount, 50, 42)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("kmeans failed: %w", err)
|
|
}
|
|
|
|
membership := make(map[string]string)
|
|
labelByCluster := make(map[int]string)
|
|
var topics []TopicCluster
|
|
for _, cluster := range results {
|
|
if len(cluster.Items) == 0 {
|
|
continue
|
|
}
|
|
|
|
var samples []string
|
|
for _, item := range cluster.Items {
|
|
if len(samples) >= 3 {
|
|
break
|
|
}
|
|
samples = append(samples, item.Text)
|
|
}
|
|
|
|
// Generate label using Ollama
|
|
label := s.generateTopicLabel(ctx, samples)
|
|
labelByCluster[cluster.Cluster] = label
|
|
keywords := s.extractKeywords(cluster.Items)
|
|
|
|
topics = append(topics, TopicCluster{
|
|
TopicID: cluster.Cluster,
|
|
Label: label,
|
|
Keywords: keywords,
|
|
PostCount: len(cluster.Items),
|
|
Percentage: float64(len(cluster.Items)) / float64(len(items)) * 100,
|
|
SamplePosts: samples,
|
|
})
|
|
}
|
|
|
|
// Map each post to the label of the cluster it belongs to.
|
|
for _, cluster := range results {
|
|
label := labelByCluster[cluster.Cluster]
|
|
for _, item := range cluster.Items {
|
|
if item.ID != "" {
|
|
membership[item.ID] = label
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort by post count descending
|
|
sort.Slice(topics, func(i, j int) bool { return topics[i].PostCount > topics[j].PostCount })
|
|
|
|
return topics, membership, nil
|
|
}
|
|
|
|
func (s *Server) computeSentimentTrends(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
|
// Bucket posts by day
|
|
bucketMap := make(map[string][]string)
|
|
for _, post := range posts {
|
|
if post.Timestamp.IsZero() {
|
|
continue
|
|
}
|
|
bucket := post.Timestamp.Truncate(sentimentBucketDuration).Format(time.RFC3339)
|
|
bucketMap[bucket] = append(bucketMap[bucket], post.Text)
|
|
}
|
|
|
|
var timeSeries []SentimentPoint
|
|
overallPos, overallNeg, overallNeu := 0.0, 0.0, 0.0
|
|
emotionCounts := make(map[string]int)
|
|
var shifts []SentimentShift
|
|
var prevSentiment string
|
|
var prevScores [3]float64
|
|
|
|
// Only posts with a timestamp are bucketed and contribute to sentiment, so
|
|
// the overall ratios are normalized by that same count (not the total
|
|
// number of fetched posts, some of which may lack a timestamp).
|
|
timestampedPosts := 0
|
|
for _, post := range posts {
|
|
if !post.Timestamp.IsZero() {
|
|
timestampedPosts++
|
|
}
|
|
}
|
|
|
|
// Process buckets in chronological order so sentiment shifts are detected correctly.
|
|
buckets := make([]string, 0, len(bucketMap))
|
|
for b := range bucketMap {
|
|
buckets = append(buckets, b)
|
|
}
|
|
sort.Strings(buckets)
|
|
|
|
for _, bucket := range buckets {
|
|
texts := bucketMap[bucket]
|
|
pos, neg, neu, emotions := s.analyzeSentiment(ctx, texts)
|
|
timeSeries = append(timeSeries, SentimentPoint{
|
|
Timestamp: bucket,
|
|
Positive: pos,
|
|
Negative: neg,
|
|
Neutral: neu,
|
|
PostCount: len(texts),
|
|
})
|
|
|
|
overallPos += pos * float64(len(texts))
|
|
overallNeg += neg * float64(len(texts))
|
|
overallNeu += neu * float64(len(texts))
|
|
|
|
for e, c := range emotions {
|
|
emotionCounts[e] += c
|
|
}
|
|
|
|
// Detect sentiment shifts
|
|
dominant := maxSentiment(pos, neg, neu)
|
|
if prevSentiment != "" && dominant != prevSentiment {
|
|
mag := math.Abs(pos-prevScores[0]) + math.Abs(neg-prevScores[1]) + math.Abs(neu-prevScores[2])
|
|
shifts = append(shifts, SentimentShift{
|
|
Timestamp: bucket,
|
|
FromSentiment: prevSentiment,
|
|
ToSentiment: dominant,
|
|
Magnitude: mag,
|
|
})
|
|
}
|
|
prevSentiment = dominant
|
|
prevScores = [3]float64{pos, neg, neu}
|
|
}
|
|
|
|
totalPosts := float64(timestampedPosts)
|
|
if totalPosts > 0 {
|
|
overallPos /= totalPosts
|
|
overallNeg /= totalPosts
|
|
overallNeu /= totalPosts
|
|
}
|
|
|
|
// Top emotions
|
|
var topEmotions []EmotionScore
|
|
type emoCount struct {
|
|
Emotion string
|
|
Count int
|
|
}
|
|
var emos []emoCount
|
|
for e, c := range emotionCounts {
|
|
emos = append(emos, emoCount{e, c})
|
|
}
|
|
sort.Slice(emos, func(i, j int) bool { return emos[i].Count > emos[j].Count })
|
|
for i := 0; i < len(emos) && i < 5; i++ {
|
|
topEmotions = append(topEmotions, EmotionScore{
|
|
Emotion: emos[i].Emotion,
|
|
Score: float64(emos[i].Count) / totalPosts,
|
|
})
|
|
}
|
|
|
|
return SentimentTrendsResponse{
|
|
Author: args.Author,
|
|
TimeSeries: timeSeries,
|
|
OverallSentiment: SentimentSummary{
|
|
Positive: overallPos,
|
|
Negative: overallNeg,
|
|
Neutral: overallNeu,
|
|
},
|
|
DominantEmotions: topEmotions,
|
|
SentimentShifts: shifts,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Server) computeEngagementDrivers(ctx context.Context, args AnalyticsRequest, posts []clustering.Item) (any, error) {
|
|
// Topic modeling first, getting post->topic membership for correlation.
|
|
topics, membership, err := s.clusterPosts(ctx, posts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Group engagement by topic label. Each post contributes its total
|
|
// engagement (likes + reposts + replies + quotes) to its topic.
|
|
topicEngagement := make(map[string][]float64)
|
|
for _, post := range posts {
|
|
label, ok := membership[post.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
total := float64(post.Likes + post.Reposts + post.Replies + post.Quotes)
|
|
topicEngagement[label] = append(topicEngagement[label], total)
|
|
}
|
|
|
|
// Baseline is the mean engagement across all posts, for comparison.
|
|
var allEngagement []float64
|
|
for _, post := range posts {
|
|
allEngagement = append(allEngagement, float64(post.Likes+post.Reposts+post.Replies+post.Quotes))
|
|
}
|
|
baseline := mean(allEngagement)
|
|
|
|
var drivers []EngagementDriver
|
|
for _, topic := range topics {
|
|
values := topicEngagement[topic.Label]
|
|
topicMean := mean(values)
|
|
correlation := 0.0
|
|
if denom := topicMean + baseline; denom > 0 {
|
|
correlation = (topicMean - baseline) / denom
|
|
if correlation > 1 {
|
|
correlation = 1
|
|
} else if correlation < -1 {
|
|
correlation = -1
|
|
}
|
|
}
|
|
drivers = append(drivers, EngagementDriver{
|
|
Topic: topic.Label,
|
|
AvgEngagement: topicMean,
|
|
Correlation: correlation,
|
|
PostCount: topic.PostCount,
|
|
SamplePosts: topic.SamplePosts[:min(2, len(topic.SamplePosts))],
|
|
})
|
|
}
|
|
|
|
sort.Slice(drivers, func(i, j int) bool {
|
|
return math.Abs(drivers[i].Correlation) > math.Abs(drivers[j].Correlation)
|
|
})
|
|
|
|
return EngagementDriversResponse{
|
|
Author: args.Author,
|
|
Drivers: drivers,
|
|
}, nil
|
|
}
|
|
|
|
func mean(values []float64) float64 {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
sum := 0.0
|
|
for _, v := range values {
|
|
sum += v
|
|
}
|
|
return sum / float64(len(values))
|
|
}
|
|
|
|
// ---- Helper functions ----
|
|
|
|
func (s *Server) generateTopicLabel(ctx context.Context, samples []string) string {
|
|
if len(samples) == 0 {
|
|
return "General"
|
|
}
|
|
prompt := fmt.Sprintf(`Given these sample posts, provide a concise 1-3 word topic label:
|
|
|
|
Posts:
|
|
%s
|
|
|
|
Label only (no explanation):`, strings.Join(samples, "\n---\n"))
|
|
|
|
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
|
|
if err != nil {
|
|
return "General"
|
|
}
|
|
return strings.TrimSpace(resp)
|
|
}
|
|
|
|
func (s *Server) extractKeywords(items []clustering.Item) []string {
|
|
var allText []string
|
|
for _, item := range items {
|
|
allText = append(allText, item.Text)
|
|
}
|
|
return utils.TopWords(allText, make(map[string]struct{}), 5)
|
|
}
|
|
|
|
func (s *Server) analyzeSentiment(ctx context.Context, texts []string) (pos, neg, neu float64, emotions map[string]int) {
|
|
if len(texts) == 0 {
|
|
return 0, 0, 0, nil
|
|
}
|
|
|
|
// Use Ollama for sentiment analysis
|
|
prompt := fmt.Sprintf(`Analyze sentiment and emotions in these texts. Return JSON only:
|
|
{
|
|
"positive": 0.0-1.0,
|
|
"negative": 0.0-1.0,
|
|
"neutral": 0.0-1.0,
|
|
"emotions": {"emotion": count}
|
|
}
|
|
|
|
Texts:
|
|
%s`, strings.Join(texts, "\n---\n"))
|
|
|
|
resp, err := s.ollama.SummarizeStyle(ctx, []clustering.Item{{Text: prompt}})
|
|
if err != nil {
|
|
return 0.33, 0.33, 0.34, nil
|
|
}
|
|
|
|
var result struct {
|
|
Positive float64 `json:"positive"`
|
|
Negative float64 `json:"negative"`
|
|
Neutral float64 `json:"neutral"`
|
|
Emotions map[string]int `json:"emotions"`
|
|
}
|
|
_ = json.Unmarshal([]byte(resp), &result)
|
|
|
|
// Normalize
|
|
sum := result.Positive + result.Negative + result.Neutral
|
|
if sum > 0 {
|
|
result.Positive /= sum
|
|
result.Negative /= sum
|
|
result.Neutral /= sum
|
|
}
|
|
|
|
return result.Positive, result.Negative, result.Neutral, result.Emotions
|
|
}
|
|
|
|
func maxSentiment(pos, neg, neu float64) string {
|
|
if pos >= neg && pos >= neu {
|
|
return "positive"
|
|
}
|
|
if neg >= pos && neg >= neu {
|
|
return "negative"
|
|
}
|
|
return "neutral"
|
|
}
|
|
|
|
func int64Val(p *int64) int64 {
|
|
if p == nil {
|
|
return 0
|
|
}
|
|
return *p
|
|
}
|