From c6e518105b446d0b2253b80fbdf31288ad3d287b Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:03:53 +0200 Subject: [PATCH] fixed rating --- backend/rating.go | 312 ++++++++++++++++++++++++++++++++++------- backend/rating_test.go | 271 +++++++++++++++++++++++++++++++++++ 2 files changed, 535 insertions(+), 48 deletions(-) create mode 100644 backend/rating_test.go diff --git a/backend/rating.go b/backend/rating.go index 315b062..f6b2c0b 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -52,11 +52,11 @@ func ratingSmoothStep(v float64) float64 { return v * v * (3 - 2*v) } -func ratingSoftCap(value, knee float64) float64 { - if value <= 0 || knee <= 0 { +func ratingSaturatingEvidence(value, scale float64) float64 { + if value <= 0 || scale <= 0 { return 0 } - return value / (value + knee) + return ratingClamp01(1 - math.Exp(-value/scale)) } func ratingEffectiveDurationSeconds(seconds float64) float64 { @@ -460,25 +460,25 @@ func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 { if set.HasPosition { // Position soll das Hauptkriterium sein. - // Kleidung/Objekte dürfen unterstützen, aber nicht das Rating tragen. + // Sexuelle Körperteile und explizite Kleidung verstärken die Position. score = 0.82*set.Position + - 0.08*set.Body + - 0.05*set.Clothing + - 0.03*set.Object + - 0.02*set.Person + 0.12*set.Body + + 0.08*set.Clothing + + 0.015*set.Object + + 0.005*set.Person if set.HasBody { - score += 0.020 + score += 0.030 } if set.HasClothing { - score += 0.015 + score += 0.020 } if set.HasObject { - score += 0.010 + score += 0.006 } if set.HasPerson { - score += 0.005 + score += 0.002 } // Standing ohne echten Kontext klar schwächer halten. @@ -829,6 +829,7 @@ func mergeRatingActivitySegments( labels map[string]bool positionWeights map[string]float64 positionBounds map[string]segmentBounds + positionRanges map[string][]segmentBounds marker float64 markerWeight float64 } @@ -836,6 +837,7 @@ func mergeRatingActivitySegments( var addRatingPositionSignal func( weights map[string]float64, bounds map[string]segmentBounds, + ranges map[string][]segmentBounds, label string, weight float64, start float64, @@ -845,6 +847,7 @@ func mergeRatingActivitySegments( addRatingPositionSignal = func( weights map[string]float64, bounds map[string]segmentBounds, + ranges map[string][]segmentBounds, label string, weight float64, start float64, @@ -858,7 +861,7 @@ func mergeRatingActivitySegments( if strings.HasPrefix(label, "combo:") { raw := strings.TrimPrefix(label, "combo:") for _, part := range strings.Split(raw, "+") { - addRatingPositionSignal(weights, bounds, part, weight, start, end) + addRatingPositionSignal(weights, bounds, ranges, part, weight, start, end) } return } @@ -891,6 +894,13 @@ func mergeRatingActivitySegments( } bounds[normalized] = b + if end > start { + ranges[normalized] = append(ranges[normalized], segmentBounds{ + start: start, + end: end, + ok: true, + }) + } } effectiveRatingLabelsForBlock := func( @@ -971,10 +981,12 @@ func mergeRatingActivitySegments( positionWeights := map[string]float64{} positionBounds := map[string]segmentBounds{} + positionRanges := map[string][]segmentBounds{} addRatingPositionSignal( positionWeights, positionBounds, + positionRanges, s.Label, conf*math.Max(1, dur), s.StartSeconds, @@ -992,11 +1004,60 @@ func mergeRatingActivitySegments( labels: labels, positionWeights: positionWeights, positionBounds: positionBounds, + positionRanges: positionRanges, marker: marker, markerWeight: markerWeight, } } + coveredDuration := func(ranges []segmentBounds, start, end float64) float64 { + if len(ranges) == 0 || end <= start { + return 0 + } + + clipped := make([]segmentBounds, 0, len(ranges)) + for _, r := range ranges { + rangeStart := math.Max(start, r.start) + rangeEnd := math.Min(end, r.end) + if rangeEnd > rangeStart { + clipped = append(clipped, segmentBounds{ + start: rangeStart, + end: rangeEnd, + ok: true, + }) + } + } + if len(clipped) == 0 { + return 0 + } + + sort.SliceStable(clipped, func(i, j int) bool { + if clipped[i].start != clipped[j].start { + return clipped[i].start < clipped[j].start + } + return clipped[i].end < clipped[j].end + }) + + total := 0.0 + currentStart := clipped[0].start + currentEnd := clipped[0].end + + for _, r := range clipped[1:] { + if r.start <= currentEnd { + if r.end > currentEnd { + currentEnd = r.end + } + continue + } + + total += currentEnd - currentStart + currentStart = r.start + currentEnd = r.end + } + + return total + currentEnd - currentStart + } + finishBlock := func(b activityBlock) (aiSegmentMeta, bool) { dur := b.end - b.start if dur <= 0 { @@ -1037,8 +1098,19 @@ func mergeRatingActivitySegments( return aiSegmentMeta{}, false } + ratingDuration := dur + if bestPosition != "" { + if evidenceDuration := coveredDuration( + b.positionRanges[bestPosition], + start, + end, + ); evidenceDuration > 0 { + ratingDuration = evidenceDuration + } + } + // Kurze Segmente dürfen bleiben, wenn sie stark genug sind. - if dur < minDurationSec && sev < 0.72 { + if ratingDuration < minDurationSec && sev < 0.72 { return aiSegmentMeta{}, false } @@ -1060,7 +1132,7 @@ func mergeRatingActivitySegments( Score: ratingClamp01(score), StartSeconds: start, EndSeconds: end, - DurationSeconds: dur, + DurationSeconds: ratingDuration, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), @@ -1076,13 +1148,15 @@ func mergeRatingActivitySegments( n := valid[i] gap := n.StartSeconds - cur.end + nextLabels := map[string]bool{} + addRatingLabelsFromSegment(nextLabels, n.Label) + positionConflict := ratingPositionSetsConflict(cur.labels, nextLabels) - shouldBridge := gap <= maxSilentGapSec - - if !shouldBridge && gap <= ratingBridgeStrongGapSec { - nextLabels := map[string]bool{} - addRatingLabelsFromSegment(nextLabels, n.Label) + // A short gap may connect supporting signals or the same position, but + // must not turn consecutive different positions into one long segment. + shouldBridge := gap <= maxSilentGapSec && !positionConflict + if !shouldBridge && !positionConflict && gap <= ratingBridgeStrongGapSec { curSet := ratingSignalSetFromLabels(cur.labels) nextSet := ratingSignalSetFromLabels(nextLabels) @@ -1128,6 +1202,7 @@ func mergeRatingActivitySegments( addRatingPositionSignal( cur.positionWeights, cur.positionBounds, + cur.positionRanges, n.Label, conf*math.Max(1, dur), n.StartSeconds, @@ -1179,6 +1254,28 @@ func ratingLinearGate(value float64, start float64, full float64) float64 { return ratingClamp01((value - start) / (full - start)) } +func ratingExplicitContextBonus( + bodyEffectiveWeighted float64, + clothingEffectiveWeighted float64, + objectEffectiveWeighted float64, + videoMinutes float64, +) float64 { + if videoMinutes <= 0 { + return 0 + } + + bodyDensity := bodyEffectiveWeighted / videoMinutes + clothingDensity := clothingEffectiveWeighted / videoMinutes + objectDensity := objectEffectiveWeighted / videoMinutes + + bonus := + 0.060*ratingSaturatingEvidence(bodyDensity, 1.8) + + 0.035*ratingSaturatingEvidence(clothingDensity, 1.5) + + 0.015*ratingSaturatingEvidence(objectDensity, 2.2) + + return math.Min(bonus, 0.10) +} + func ratingExcellenceBonus( peakQuality float64, positionEffectiveWeighted float64, @@ -1237,13 +1334,13 @@ func ratingExcellenceBonus( func starsFromHighlightScore(score float64) int { switch { - case score < 24: + case score < 22: return 1 - case score < 48: + case score < 45: return 2 - case score < 72: + case score < 68: return 3 - case score < 90: + case score < 85: return 4 default: return 5 @@ -1304,10 +1401,28 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl var positionEffectiveWeighted float64 var positionFlagged float64 var peakQuality float64 + var qualityDurationSum float64 + var qualityDurationWeight float64 + + var contextFlagged float64 + var contextWeighted float64 + var contextEffectiveWeighted float64 + var contextPeakQuality float64 + var contextQualityDurationSum float64 + var contextQualityDurationWeight float64 + var contextLongest float64 + var contextConfSum float64 + var contextConfWeightSum float64 + var contextN int + var bodyEffectiveWeighted float64 + var clothingEffectiveWeighted float64 + var objectEffectiveWeighted float64 var longest float64 var confSum float64 + var confWeightSum float64 var n int + uniquePositions := map[string]bool{} for _, s := range segments { segDur := s.DurationSeconds @@ -1341,9 +1456,27 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl effectiveWeightedDur := effectiveDur * quality set := ratingSignalSetFromLabel(s.Label) + bodyEffectiveWeighted += effectiveDur * set.Body * confWeight + clothingEffectiveWeighted += effectiveDur * set.Clothing * confWeight + objectEffectiveWeighted += effectiveDur * set.Object * confWeight + if !set.HasPosition { - // Nur echte Positions-Segmente fließen ins Rating ein. - // Nicht-Positions-Segmente dürfen weder Coverage noch Flagged-Zeit erhöhen. + contextFlagged += segDur + contextWeighted += weightedDur + contextEffectiveWeighted += effectiveWeightedDur + contextQualityDurationSum += quality * effectiveDur + contextQualityDurationWeight += effectiveDur + contextConfSum += conf * effectiveDur + contextConfWeightSum += effectiveDur + contextN++ + + if quality > contextPeakQuality { + contextPeakQuality = quality + } + if segDur > contextLongest { + contextLongest = segDur + } + continue } @@ -1352,51 +1485,133 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl positionEffectiveWeighted += effectiveWeightedDur positionFlagged += segDur + qualityDurationSum += quality * effectiveDur + qualityDurationWeight += effectiveDur if quality > peakQuality { peakQuality = quality } - confSum += conf + confSum += conf * effectiveDur + confWeightSum += effectiveDur n++ if segDur > longest { longest = segDur } + + labels := map[string]bool{} + addRatingLabelsFromSegment(labels, s.Label) + for position := range ratingPositionSetFromLabels(labels) { + uniquePositions[position] = true + } } if n == 0 { - appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username)) + if contextN == 0 { + appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username)) + return r + } + + contextCoverageRatio := ratingClamp01(contextFlagged / durationSec) + contextWeightedCoverageRatio := ratingClamp01(contextWeighted / durationSec) + contextSegmentsPerMinute := float64(contextN) / videoMinutes + contextAvgConfidence := contextConfSum / contextConfWeightSum + contextAvgQuality := contextQualityDurationSum / contextQualityDurationWeight + contextDensity := contextEffectiveWeighted / videoMinutes + + qualityNorm := ratingSmoothStep( + ratingLinearGate(contextAvgQuality, 0.08, 0.55), + ) + peakNorm := ratingSmoothStep( + ratingLinearGate(contextPeakQuality, 0.12, 0.62), + ) + durationNorm := ratingSaturatingEvidence(contextFlagged, 150.0) + longestNorm := ratingSaturatingEvidence(contextLongest, 60.0) + densityNorm := ratingSaturatingEvidence(contextDensity, 2.5) + coverageNorm := ratingSaturatingEvidence(contextWeightedCoverageRatio, 0.08) + + raw := + 0.28*qualityNorm + + 0.12*peakNorm + + 0.25*durationNorm + + 0.15*longestNorm + + 0.15*coverageNorm + + 0.05*densityNorm + + // Context can produce useful 1-3 star ratings, but never the 4-5 star + // range reserved for sustained position detections. + if contextFlagged < 20.0 { + raw = math.Min(raw, 0.44) + } + raw = math.Min(raw, 0.64) + + score := ratingRound(ratingClamp01(raw)*100, 1) + + r.Score = score + r.Stars = starsFromHighlightScore(score) + r.Segments = contextN + r.SegmentsPerMinute = ratingRound(contextSegmentsPerMinute, 2) + r.FlaggedSeconds = ratingRound(contextFlagged, 2) + r.WeightedFlaggedSeconds = ratingRound(contextWeighted, 2) + r.CoverageRatio = ratingRound(contextCoverageRatio, 4) + r.WeightedCoverageRatio = ratingRound(contextWeightedCoverageRatio, 4) + r.LongestSegmentSeconds = ratingRound(contextLongest, 2) + r.AvgConfidence = ratingRound(contextAvgConfidence, 3) + + appLogf( + "✅ %s context rating score=%.1f stars=%d segs=%d flagged=%.1f density=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", + ratingLogSubject(username), + r.Score, + r.Stars, + r.Segments, + r.FlaggedSeconds, + contextDensity, + r.WeightedCoverageRatio, + r.LongestSegmentSeconds, + r.AvgConfidence, + ) + return r } coverageRatio := ratingClamp01(totalFlagged / durationSec) weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec) segmentsPerMinute := float64(n) / videoMinutes - avgConfidence := confSum / float64(n) + avgConfidence := confSum / confWeightSum + avgPositionQuality := qualityDurationSum / qualityDurationWeight positionDensity := positionEffectiveWeighted / videoMinutes - peakNorm := ratingSmoothStep(peakQuality) - positionDensityNorm := ratingSoftCap(positionDensity, 5.5) - coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.20) - longestNorm := ratingSoftCap(longest, 24.0) - positionDurationNorm := ratingSoftCap(positionFlagged, 45.0) - confNorm := ratingSmoothStep((avgConfidence - 0.30) / 0.65) + avgQualityNorm := ratingSmoothStep( + ratingLinearGate(avgPositionQuality, 0.45, 0.95), + ) + peakNorm := ratingSmoothStep( + ratingLinearGate(peakQuality, 0.42, 0.95), + ) + positionDurationNorm := ratingSaturatingEvidence(positionFlagged, 65.0) + longestNorm := ratingSaturatingEvidence(longest, 35.0) + positionDensityNorm := ratingSaturatingEvidence(positionDensity, 3.2) + coverageNorm := ratingSaturatingEvidence(weightedCoverageRatio, 0.075) + positionVarietyNorm := ratingLinearGate(float64(len(uniquePositions)), 1, 4) - // Positionen dominieren. Kleidung/Objekte fließen durch Combo-Segment-Qualität (peakNorm) ein. - // Die Länge der erkannten Positionen wird bewusst stark gewichtet: - // längstes Positions-Segment (longestNorm) + Gesamtdauer aller Positionen (positionDurationNorm). - // Je länger eine Position erkannt wird, desto höher der Score. - // Keine Penalties, keine Caps – die Formel bewertet natürlich nach Positions-Intensität. - // Summe = 1.00. + // Position quality is the primary signal. Actual position duration and the + // longest continuous segment decide whether a detection is brief or strong. raw := - 0.34*positionDensityNorm + - 0.22*peakNorm + - 0.13*coverageNorm + + 0.30*avgQualityNorm + + 0.10*peakNorm + + 0.26*positionDurationNorm + 0.16*longestNorm + - 0.11*positionDurationNorm + - 0.04*confNorm + 0.10*positionDensityNorm + + 0.06*coverageNorm + + 0.02*positionVarietyNorm + + explicitContextBonus := ratingExplicitContextBonus( + bodyEffectiveWeighted, + clothingEffectiveWeighted, + objectEffectiveWeighted, + videoMinutes, + ) // Sehr wenig Material nicht überbewerten. if totalFlagged < 5.0 && n <= 1 { @@ -1415,7 +1630,7 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl n, ) - raw = ratingClamp01(raw + excellenceBonus) + raw = ratingClamp01(raw + explicitContextBonus + excellenceBonus) score := ratingRound(raw*100, 1) @@ -1431,10 +1646,11 @@ func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec fl r.AvgConfidence = ratingRound(avgConfidence, 3) appLogf( - "✅ %s rating score=%.1f stars=%d bonus=%.1f segs=%d flagged=%.1f posFlagged=%.1f posDensity=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", + "✅ %s rating score=%.1f stars=%d contextBonus=%.1f excellenceBonus=%.1f segs=%d flagged=%.1f posFlagged=%.1f posDensity=%.2f coverage=%.4f longest=%.1f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, + ratingRound(explicitContextBonus*100, 1), ratingRound(excellenceBonus*100, 1), r.Segments, r.FlaggedSeconds, diff --git a/backend/rating_test.go b/backend/rating_test.go new file mode 100644 index 0000000..b3310aa --- /dev/null +++ b/backend/rating_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "encoding/json" + "testing" +) + +func ratingTestSegment(label string, start, duration, confidence float64) aiSegmentMeta { + return aiSegmentMeta{ + Label: label, + StartSeconds: start, + EndSeconds: start + duration, + DurationSeconds: duration, + Score: confidence, + } +} + +func TestComputeHighlightRatingSpansAllStars(t *testing.T) { + tests := []struct { + name string + segments []aiSegmentMeta + want int + }{ + { + name: "one weak standing detection", + segments: []aiSegmentMeta{ + ratingTestSegment("position:standing", 10, 6, 0.45), + }, + want: 1, + }, + { + name: "one brief strong position", + segments: []aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 8, 0.72), + }, + want: 2, + }, + { + name: "two solid position segments", + segments: []aiSegmentMeta{ + ratingTestSegment("position:doggy", 20, 18, 0.78), + ratingTestSegment("position:cowgirl", 120, 18, 0.78), + }, + want: 3, + }, + { + name: "several long high quality positions", + segments: []aiSegmentMeta{ + ratingTestSegment("position:doggy", 20, 25, 0.82), + ratingTestSegment("position:cowgirl", 120, 25, 0.82), + ratingTestSegment("position:missionary", 220, 25, 0.82), + ratingTestSegment("position:blowjob", 320, 25, 0.82), + }, + want: 4, + }, + { + name: "exceptional sustained and varied positions", + segments: []aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 40, 0.90), + ratingTestSegment("position:cowgirl", 100, 40, 0.90), + ratingTestSegment("position:missionary", 190, 40, 0.90), + ratingTestSegment("position:blowjob", 280, 40, 0.90), + ratingTestSegment("position:cunnilingus", 370, 40, 0.90), + ratingTestSegment("position:reverse_cowgirl", 460, 40, 0.90), + }, + want: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeHighlightRating(tt.segments, 600) + if got.Stars != tt.want { + t.Fatalf("stars = %d, want %d (score %.1f)", got.Stars, tt.want, got.Score) + } + }) + } +} + +func TestComputeHighlightRatingRewardsPositionDuration(t *testing.T) { + short := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 8, 0.80), + }, 600) + medium := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 35, 0.80), + }, 600) + long := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 120, 0.80), + }, 600) + + if !(short.Score < medium.Score && medium.Score < long.Score) { + t.Fatalf( + "position duration should increase score: short=%.1f medium=%.1f long=%.1f", + short.Score, + medium.Score, + long.Score, + ) + } +} + +func TestComputeHighlightRatingRatesContextWithoutPosition(t *testing.T) { + clothingOnly := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("clothing:lingerie", 10, 60, 0.90), + ratingTestSegment("clothing:lingerie", 120, 60, 0.90), + ratingTestSegment("clothing:lingerie", 230, 60, 0.90), + }, 600) + richContext := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("combo:body:breasts+clothing:lingerie", 10, 45, 0.90), + ratingTestSegment("combo:object:vibrator+clothing:lingerie", 120, 60, 0.90), + ratingTestSegment("combo:body:penis+object:dildo", 240, 45, 0.90), + }, 600) + + if clothingOnly.Stars != 2 { + t.Fatalf( + "clothing-only stars = %d, want 2 (score %.1f)", + clothingOnly.Stars, + clothingOnly.Score, + ) + } + if richContext.Stars != 3 { + t.Fatalf( + "rich context stars = %d, want 3 (score %.1f)", + richContext.Stars, + richContext.Score, + ) + } + if richContext.Stars > 3 { + t.Fatalf("context rating must stay below 4 stars: %#v", richContext) + } +} + +func TestComputeHighlightRatingPositionsOutrankContext(t *testing.T) { + context := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("combo:body:breasts+clothing:lingerie+object:vibrator", 10, 240, 0.95), + }, 600) + positions := computeHighlightRating([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 40, 0.90), + ratingTestSegment("position:cowgirl", 100, 40, 0.90), + ratingTestSegment("position:missionary", 190, 40, 0.90), + ratingTestSegment("position:blowjob", 280, 40, 0.90), + }, 600) + + if positions.Score <= context.Score || positions.Stars <= context.Stars { + t.Fatalf( + "positions should outrank context: positions=%.1f/%d context=%.1f/%d", + positions.Score, + positions.Stars, + context.Score, + context.Stars, + ) + } +} + +func TestComputeHighlightRatingExplicitSignalsBoostPositions(t *testing.T) { + positionSegments := []aiSegmentMeta{ + ratingTestSegment("position:doggy", 20, 18, 0.78), + ratingTestSegment("position:cowgirl", 120, 18, 0.78), + } + + base := computeHighlightRating(positionSegments, 600) + withObject := computeHighlightRating(append( + append([]aiSegmentMeta{}, positionSegments...), + ratingTestSegment("object:dildo", 220, 60, 0.90), + ), 600) + withClothing := computeHighlightRating(append( + append([]aiSegmentMeta{}, positionSegments...), + ratingTestSegment("clothing:lingerie", 220, 60, 0.90), + ), 600) + withBody := computeHighlightRating(append( + append([]aiSegmentMeta{}, positionSegments...), + ratingTestSegment("body:pussy", 220, 60, 0.90), + ), 600) + + if !(base.Score < withObject.Score && + withObject.Score < withClothing.Score && + withClothing.Score < withBody.Score) { + t.Fatalf( + "explicit signal priority is wrong: base=%.1f object=%.1f clothing=%.1f body=%.1f", + base.Score, + withObject.Score, + withClothing.Score, + withBody.Score, + ) + } +} + +func TestComputeHighlightRatingExplicitContextCanElevateStrongPositions(t *testing.T) { + positions := []aiSegmentMeta{ + ratingTestSegment("position:doggy", 20, 25, 0.82), + ratingTestSegment("position:cowgirl", 120, 25, 0.82), + ratingTestSegment("position:missionary", 220, 25, 0.82), + ratingTestSegment("position:blowjob", 320, 25, 0.82), + } + positionOnly := computeHighlightRating(positions, 600) + withExplicitContext := computeHighlightRating(append( + append([]aiSegmentMeta{}, positions...), + ratingTestSegment("body:pussy", 400, 90, 0.92), + ratingTestSegment("clothing:lingerie", 500, 90, 0.92), + ), 600) + + if positionOnly.Stars != 4 { + t.Fatalf("position-only stars = %d, want 4 (score %.1f)", positionOnly.Stars, positionOnly.Score) + } + if withExplicitContext.Stars != 5 { + t.Fatalf( + "positions with explicit context stars = %d, want 5 (score %.1f)", + withExplicitContext.Stars, + withExplicitContext.Score, + ) + } +} + +func TestComputeHighlightRatingLongMixedContextRegression(t *testing.T) { + segments := []aiSegmentMeta{ + ratingTestSegment("object:dildo", 24.5, 5, 0.792783260345459), + ratingTestSegment("object:dildo", 43.5, 8, 0.923585057258606), + ratingTestSegment("clothing:lingerie", 94.5, 21, 0.9069388088058022), + ratingTestSegment("clothing:lingerie", 120.5, 33, 0.887133002281189), + ratingTestSegment("object:vibrator", 215.5, 6, 0.40453797578811646), + ratingTestSegment("combo:body:breasts+clothing:lingerie", 298.5, 8, 0.6898824721574783), + ratingTestSegment("object:vibrator", 330.5, 5, 0.6836381554603577), + ratingTestSegment("clothing:lingerie", 348.5, 59, 0.9197490215301514), + ratingTestSegment("combo:object:vibrator+object:collar+clothing:lingerie", 416.5, 36, 0.8156423893044976), + ratingTestSegment("clothing:lingerie", 465.5, 36, 0.8007044196128845), + } + + got := computeHighlightRating(segments, 504) + if got.Stars != 3 { + t.Fatalf("stars = %d, want 3 (score %.1f)", got.Stars, got.Score) + } + + encoded, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + t.Logf("rating=%s", encoded) +} + +func TestPrepareAIRatingSegmentsKeepsDifferentPositionsSeparate(t *testing.T) { + segments := prepareAIRatingSegments([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 12, 0.85), + ratingTestSegment("position:cowgirl", 23, 12, 0.85), + }) + + if len(segments) != 2 { + t.Fatalf("segments = %d, want 2: %#v", len(segments), segments) + } + if segments[0].Position == segments[1].Position { + t.Fatalf("different positions were collapsed: %#v", segments) + } +} + +func TestPrepareAIRatingSegmentsMergesSamePositionAcrossShortGap(t *testing.T) { + segments := prepareAIRatingSegments([]aiSegmentMeta{ + ratingTestSegment("position:doggy", 10, 12, 0.85), + ratingTestSegment("position:doggy", 23, 12, 0.85), + }) + + if len(segments) != 1 { + t.Fatalf("segments = %d, want 1: %#v", len(segments), segments) + } + if segments[0].Position != "doggy" { + t.Fatalf("position = %q, want doggy", segments[0].Position) + } + if segments[0].DurationSeconds != 24 { + t.Fatalf( + "duration = %.1f, want 24 seconds of actual position evidence", + segments[0].DurationSeconds, + ) + } +}