tercul-backend/internal/app/analytics/service.go
google-labs-jules[bot] 4c2f20c33d feat: Implement blog schema and example content
This commit introduces a new blog feature by implementing a JSON schema for blog posts and providing five example content files.

Key changes:
- Created a new directory structure for schemas and content (`schemas/`, `content/blog/`).
- Implemented a JSON schema for blog posts, split into `blog.json` and `_defs.json` for reusability.
- Added five example blog post files with full, realistic content.
- Included a Python script (`validate.py`) to validate the example content against the schema.
2025-09-07 23:22:36 +00:00

302 lines
9.1 KiB
Go

package analytics
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"tercul/internal/domain"
"tercul/internal/jobs/linguistics"
"tercul/internal/platform/log"
"time"
)
type Service interface {
IncrementWorkViews(ctx context.Context, workID uint) error
IncrementWorkLikes(ctx context.Context, workID uint) error
IncrementWorkComments(ctx context.Context, workID uint) error
IncrementWorkBookmarks(ctx context.Context, workID uint) error
IncrementWorkShares(ctx context.Context, workID uint) error
IncrementWorkTranslationCount(ctx context.Context, workID uint) error
IncrementTranslationViews(ctx context.Context, translationID uint) error
IncrementTranslationLikes(ctx context.Context, translationID uint) error
IncrementTranslationComments(ctx context.Context, translationID uint) error
IncrementTranslationShares(ctx context.Context, translationID uint) error
GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error)
GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error)
UpdateWorkReadingTime(ctx context.Context, workID uint) error
UpdateWorkComplexity(ctx context.Context, workID uint) error
UpdateWorkSentiment(ctx context.Context, workID uint) error
UpdateTranslationReadingTime(ctx context.Context, translationID uint) error
UpdateTranslationSentiment(ctx context.Context, translationID uint) error
UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error
UpdateTrending(ctx context.Context) error
GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error)
}
type service struct {
repo domain.AnalyticsRepository
analysisRepo linguistics.AnalysisRepository
translationRepo domain.TranslationRepository
workRepo domain.WorkRepository
sentimentProvider linguistics.SentimentProvider
}
func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service {
return &service{
repo: repo,
analysisRepo: analysisRepo,
translationRepo: translationRepo,
workRepo: workRepo,
sentimentProvider: sentimentProvider,
}
}
func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "views", 1)
}
func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1)
}
func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1)
}
func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1)
}
func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1)
}
func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error {
return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1)
}
func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error {
return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1)
}
func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error {
return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1)
}
func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error {
return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1)
}
func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error {
return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1)
}
func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) {
return s.repo.GetOrCreateWorkStats(ctx, workID)
}
func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) {
return s.repo.GetOrCreateTranslationStats(ctx, translationID)
}
func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error {
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
return err
}
if textMetadata == nil {
return errors.New("text metadata not found")
}
readingTime := 0
if textMetadata.WordCount > 0 {
readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error {
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err))
return nil
}
if readabilityScore == nil {
return errors.New("readability score not found")
}
stats.Complexity = readabilityScore.Score
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error {
stats, err := s.repo.GetOrCreateWorkStats(ctx, workID)
if err != nil {
return err
}
_, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID)
if err != nil {
log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err))
return nil
}
if languageAnalysis == nil {
return errors.New("language analysis not found")
}
sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64)
if !ok {
return errors.New("sentiment score not found in language analysis")
}
stats.Sentiment = sentiment
return s.repo.UpdateWorkStats(ctx, workID, *stats)
}
func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error {
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
wordCount := len(strings.Fields(translation.Content))
readingTime := 0
if wordCount > 0 {
readingTime = (wordCount + 199) / 200 // Ceil division
}
stats.ReadingTime = readingTime
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error {
stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID)
if err != nil {
return err
}
translation, err := s.translationRepo.GetByID(ctx, translationID)
if err != nil {
return err
}
if translation == nil {
return errors.New("translation not found")
}
sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language)
if err != nil {
return err
}
stats.Sentiment = sentiment
return s.repo.UpdateTranslationStats(ctx, translationID, *stats)
}
func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error {
today := time.Now().UTC().Truncate(24 * time.Hour)
engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today)
if err != nil {
return err
}
switch eventType {
case "work_read":
engagement.WorksRead++
case "comment_made":
engagement.CommentsMade++
case "like_given":
engagement.LikesGiven++
case "bookmark_made":
engagement.BookmarksMade++
case "translation_made":
engagement.TranslationsMade++
default:
return errors.New("invalid engagement event type")
}
return s.repo.UpdateUserEngagement(ctx, engagement)
}
func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) {
return s.repo.GetTrendingWorks(ctx, timePeriod, limit)
}
func (s *service) UpdateTrending(ctx context.Context) error {
log.LogInfo("Updating trending works")
works, err := s.workRepo.ListAll(ctx)
if err != nil {
return fmt.Errorf("failed to list works: %w", err)
}
trendingWorks := make([]*domain.Trending, 0, len(works))
for _, work := range works {
stats, err := s.repo.GetOrCreateWorkStats(ctx, work.ID)
if err != nil {
log.LogWarn("failed to get work stats", log.F("workID", work.ID), log.F("error", err))
continue
}
score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3)
trendingWorks = append(trendingWorks, &domain.Trending{
EntityType: "Work",
EntityID: work.ID,
Score: score,
TimePeriod: "daily", // Hardcoded for now
Date: time.Now().UTC(),
})
}
// Sort by score
sort.Slice(trendingWorks, func(i, j int) bool {
return trendingWorks[i].Score > trendingWorks[j].Score
})
// Get top 10
if len(trendingWorks) > 10 {
trendingWorks = trendingWorks[:10]
}
// Set ranks
for i := range trendingWorks {
trendingWorks[i].Rank = i + 1
}
return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks)
}