nsfwapp/backend/rating.go
2026-05-12 09:25:27 +02:00

1186 lines
25 KiB
Go

// backend\rating.go
package main
import (
"math"
"sort"
"strings"
)
type aiRatingMeta struct {
Score float64 `json:"score"`
Stars int `json:"stars"`
Segments int `json:"segments"`
SegmentsPerMinute float64 `json:"segmentsPerMinute"`
FlaggedSeconds float64 `json:"flaggedSeconds"`
WeightedFlaggedSeconds float64 `json:"weightedFlaggedSeconds"`
CoverageRatio float64 `json:"coverageRatio"`
WeightedCoverageRatio float64 `json:"weightedCoverageRatio"`
LongestSegmentSeconds float64 `json:"longestSegmentSeconds"`
AvgConfidence float64 `json:"avgConfidence"`
}
const (
// Normales Zusammenziehen sehr kurzer Pausen.
ratingMaxSilentGapSec = 6.5
// Größere Lücken dürfen überbrückt werden,
// wenn beide Seiten stark genug / thematisch ähnlich sind.
ratingBridgeStrongGapSec = 35.0
ratingMinSegmentDurationSec = 2.5
)
func ratingClamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func ratingRound(v float64, places int) float64 {
p := math.Pow(10, float64(places))
return math.Round(v*p) / p
}
func ratingSmoothStep(v float64) float64 {
v = ratingClamp01(v)
return v * v * (3 - 2*v)
}
func ratingSoftCap(value, knee float64) float64 {
if value <= 0 || knee <= 0 {
return 0
}
return value / (value + knee)
}
func ratingEffectiveDurationSeconds(seconds float64) float64 {
if seconds <= 0 {
return 0
}
// Lange Segmente zählen, aber nicht linear unendlich stark.
const kneeSeconds = 26.0
return kneeSeconds * math.Log1p(seconds/kneeSeconds)
}
func normalizeSegmentLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
label = strings.TrimPrefix(label, "detector:")
label = strings.TrimPrefix(label, "body:")
label = strings.TrimPrefix(label, "object:")
label = strings.TrimPrefix(label, "clothing:")
label = strings.TrimPrefix(label, "position:")
label = strings.TrimPrefix(label, "person:")
return strings.TrimSpace(label)
}
func isPersonSegmentLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
label = strings.TrimPrefix(label, "detector:")
label = strings.TrimPrefix(label, "body:")
label = strings.TrimPrefix(label, "object:")
label = strings.TrimPrefix(label, "clothing:")
label = strings.TrimPrefix(label, "position:")
label = strings.TrimPrefix(label, "person:")
switch label {
case "person",
"person_unknown",
"person_male",
"person_female",
"male_person",
"female_person",
"people_male",
"people_female":
return true
default:
return false
}
}
func isKnownPositionLabel(label string) bool {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "unknown",
"missionary",
"doggy",
"doggystyle",
"cowgirl",
"reverse_cowgirl",
"cunnilingus",
"prone_bone",
"standing",
"standing_doggy",
"spooning",
"sitting",
"facesitting",
"handjob",
"blowjob",
"toy_play",
"fingering",
"69":
return true
default:
return false
}
}
func positionSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "doggy", "doggystyle", "standing_doggy":
return 1.00
case "cowgirl", "reverse_cowgirl":
return 0.98
case "missionary", "prone_bone":
return 0.95
case "blowjob", "cunnilingus", "69", "facesitting":
return 0.94
case "toy_play":
return 0.88
case "handjob", "fingering":
return 0.84
case "spooning":
return 0.78
case "standing", "sitting":
return 0.42
default:
return 0.00
}
}
func bodyPartSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "pussy", "vulva":
return 1.00
case "penis":
return 0.95
case "anus":
return 0.90
case "breasts":
return 0.80
case "ass", "buttocks":
return 0.66
case "tongue":
return 0.46
default:
return 0.00
}
}
func objectSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "dildo", "vibrator", "strapon", "buttplug":
return 0.86
case "handcuffs", "blindfold", "collar":
return 0.58
case "shower":
return 0.42
case "towel":
return 0.28
default:
return 0.00
}
}
func clothingSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
switch label {
case "lingerie":
return 0.64
case "panties", "bra":
return 0.58
case "bikini":
return 0.42
case "stockings", "heels":
return 0.40
case "skirt", "dress", "hotpants", "croptop":
return 0.34
default:
return 0.00
}
}
func personContextWeight(label string) float64 {
if !isPersonSegmentLabel(label) {
return 0
}
// Personen sind Kontext, aber nie alleiniger Rating-Treiber.
return 0.18
}
func detectorSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
if isPersonSegmentLabel(label) {
return personContextWeight(label)
}
if w := bodyPartSeverityWeight(label); w > 0 {
return w
}
if w := objectSeverityWeight(label); w > 0 {
return w
}
if w := clothingSeverityWeight(label); w > 0 {
return w
}
if isKnownPositionLabel(label) {
return positionSeverityWeight(label)
}
return 0.00
}
type ratingSignalSet struct {
Position float64
Body float64
Object float64
Clothing float64
Person float64
HasPosition bool
HasBody bool
HasObject bool
HasClothing bool
HasPerson bool
}
func normalizeRatingSignalLabel(label string) string {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" || label == "unknown" {
return ""
}
if strings.HasPrefix(label, "combo:") {
return label
}
if strings.HasPrefix(label, "detector:") {
return normalizeRatingSignalLabel(strings.TrimPrefix(label, "detector:"))
}
if strings.HasPrefix(label, "position:") {
raw := strings.TrimPrefix(label, "position:")
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
return "position:" + raw
}
return ""
}
if strings.HasPrefix(label, "body:") {
raw := strings.TrimPrefix(label, "body:")
if bodyPartSeverityWeight(raw) > 0 {
return "body:" + raw
}
return ""
}
if strings.HasPrefix(label, "object:") {
raw := strings.TrimPrefix(label, "object:")
if objectSeverityWeight(raw) > 0 {
return "object:" + raw
}
return ""
}
if strings.HasPrefix(label, "clothing:") {
raw := strings.TrimPrefix(label, "clothing:")
if clothingSeverityWeight(raw) > 0 {
return "clothing:" + raw
}
return ""
}
if strings.HasPrefix(label, "person:") {
raw := strings.TrimPrefix(label, "person:")
if isPersonSegmentLabel(raw) {
return "person:" + normalizeSegmentLabel(raw)
}
return ""
}
raw := normalizeSegmentLabel(label)
if raw == "" || raw == "unknown" {
return ""
}
if isPersonSegmentLabel(raw) {
return "person:" + raw
}
if isKnownPositionLabel(raw) && positionSeverityWeight(raw) > 0 {
return "position:" + raw
}
if bodyPartSeverityWeight(raw) > 0 {
return "body:" + raw
}
if objectSeverityWeight(raw) > 0 {
return "object:" + raw
}
if clothingSeverityWeight(raw) > 0 {
return "clothing:" + raw
}
return ""
}
func ratingSignalSetAddLabel(set *ratingSignalSet, label string) {
label = normalizeRatingSignalLabel(label)
if label == "" {
return
}
switch {
case strings.HasPrefix(label, "position:"):
raw := strings.TrimPrefix(label, "position:")
w := positionSeverityWeight(raw)
if w > set.Position {
set.Position = w
}
if w > 0 {
set.HasPosition = true
}
case strings.HasPrefix(label, "body:"):
raw := strings.TrimPrefix(label, "body:")
w := bodyPartSeverityWeight(raw)
if w > set.Body {
set.Body = w
}
if w > 0 {
set.HasBody = true
}
case strings.HasPrefix(label, "object:"):
raw := strings.TrimPrefix(label, "object:")
w := objectSeverityWeight(raw)
if w > set.Object {
set.Object = w
}
if w > 0 {
set.HasObject = true
}
case strings.HasPrefix(label, "clothing:"):
raw := strings.TrimPrefix(label, "clothing:")
w := clothingSeverityWeight(raw)
if w > set.Clothing {
set.Clothing = w
}
if w > 0 {
set.HasClothing = true
}
case strings.HasPrefix(label, "person:"):
raw := strings.TrimPrefix(label, "person:")
w := personContextWeight(raw)
if w > set.Person {
set.Person = w
}
if w > 0 {
set.HasPerson = true
}
}
}
func ratingSignalSetFromLabel(label string) ratingSignalSet {
label = strings.ToLower(strings.TrimSpace(label))
var set ratingSignalSet
if strings.HasPrefix(label, "combo:") {
raw := strings.TrimPrefix(label, "combo:")
for _, part := range strings.Split(raw, "+") {
ratingSignalSetAddLabel(&set, part)
}
return set
}
ratingSignalSetAddLabel(&set, label)
return set
}
func ratingSignalSetHasInterestingContent(set ratingSignalSet) bool {
return set.HasPosition || set.HasBody || set.HasObject || set.HasClothing
}
func contextualSegmentSeverityWeight(label string) float64 {
set := ratingSignalSetFromLabel(label)
if !ratingSignalSetHasInterestingContent(set) {
return 0
}
var score float64
if set.HasPosition {
score =
0.66*set.Position +
0.17*set.Body +
0.10*set.Object +
0.05*set.Clothing +
0.02*set.Person
if set.HasBody {
score += 0.055
}
if set.HasObject {
score += 0.040
}
if set.HasClothing {
score += 0.020
}
if set.HasPerson {
score += 0.015
}
// Standing/Sitting ohne Kontext bleibt schwach.
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
score = math.Min(score, 0.42)
}
return ratingClamp01(score)
}
score =
0.54*set.Body +
0.30*set.Object +
0.14*set.Clothing +
0.02*set.Person
if set.HasBody && set.HasObject {
score += 0.09
}
if set.HasBody && set.HasClothing {
score += 0.04
}
if set.HasObject && set.HasClothing {
score += 0.03
}
if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) {
score += 0.015
}
// Kleidung alleine soll interessant sein können, aber nicht stark.
if set.HasClothing && !set.HasBody && !set.HasObject {
score = math.Min(score, 0.38)
}
// Ohne Position nicht zu aggressiv.
score = math.Min(score, 0.72)
return ratingClamp01(score)
}
func comboSegmentSeverityWeight(label string) float64 {
set := ratingSignalSetFromLabel(label)
return contextualSegmentSeverityWeightFromSet(set)
}
func contextualSegmentSeverityWeightFromSet(set ratingSignalSet) float64 {
if !ratingSignalSetHasInterestingContent(set) {
return 0
}
var labelParts []string
if set.HasPosition {
labelParts = append(labelParts, "position:x")
}
if set.HasBody {
labelParts = append(labelParts, "body:x")
}
if set.HasObject {
labelParts = append(labelParts, "object:x")
}
if set.HasClothing {
labelParts = append(labelParts, "clothing:x")
}
if set.HasPerson {
labelParts = append(labelParts, "person:x")
}
_ = labelParts
var score float64
if set.HasPosition {
score =
0.66*set.Position +
0.17*set.Body +
0.10*set.Object +
0.05*set.Clothing +
0.02*set.Person
if set.HasBody {
score += 0.055
}
if set.HasObject {
score += 0.040
}
if set.HasClothing {
score += 0.020
}
if set.HasPerson {
score += 0.015
}
if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing {
score = math.Min(score, 0.42)
}
return ratingClamp01(score)
}
score =
0.54*set.Body +
0.30*set.Object +
0.14*set.Clothing +
0.02*set.Person
if set.HasBody && set.HasObject {
score += 0.09
}
if set.HasBody && set.HasClothing {
score += 0.04
}
if set.HasObject && set.HasClothing {
score += 0.03
}
if set.HasPerson && (set.HasBody || set.HasObject || set.HasClothing) {
score += 0.015
}
if set.HasClothing && !set.HasBody && !set.HasObject {
score = math.Min(score, 0.38)
}
score = math.Min(score, 0.72)
return ratingClamp01(score)
}
func segmentSeverityWeight(label string) float64 {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return 0
}
if strings.HasPrefix(label, "combo:") {
return comboSegmentSeverityWeight(label)
}
normalized := normalizeRatingSignalLabel(label)
if normalized == "" {
return 0
}
set := ratingSignalSetFromLabel(normalized)
sev := contextualSegmentSeverityWeightFromSet(set)
if sev > 0 {
return sev
}
raw := normalizeSegmentLabel(normalized)
if raw == "" {
return 0
}
return detectorSeverityWeight(raw)
}
func ratingSegmentSeverity(label string) float64 {
return segmentSeverityWeight(label)
}
func ratingSignalPriority(label string) int {
label = normalizeRatingSignalLabel(label)
switch {
case strings.HasPrefix(label, "position:"):
return 100
case strings.HasPrefix(label, "body:"):
return 80
case strings.HasPrefix(label, "object:"):
return 60
case strings.HasPrefix(label, "clothing:"):
return 40
case strings.HasPrefix(label, "person:"):
return 20
default:
return 0
}
}
func buildRatingComboLabel(labels map[string]bool) string {
if len(labels) == 0 {
return ""
}
parts := make([]string, 0, len(labels))
set := ratingSignalSet{}
for label := range labels {
normalized := normalizeRatingSignalLabel(label)
if normalized == "" {
continue
}
ratingSignalSetAddLabel(&set, normalized)
parts = append(parts, normalized)
}
if !ratingSignalSetHasInterestingContent(set) {
return ""
}
sort.SliceStable(parts, func(i, j int) bool {
pi := ratingSignalPriority(parts[i])
pj := ratingSignalPriority(parts[j])
if pi != pj {
return pi > pj
}
wi := ratingSegmentSeverity(parts[i])
wj := ratingSegmentSeverity(parts[j])
if wi != wj {
return wi > wj
}
return parts[i] < parts[j]
})
deduped := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, part := range parts {
if part == "" || seen[part] {
continue
}
seen[part] = true
deduped = append(deduped, part)
}
if len(deduped) == 0 {
return ""
}
if len(deduped) == 1 {
// Person alleine wird oben schon verhindert.
return deduped[0]
}
return "combo:" + strings.Join(deduped, "+")
}
func addRatingLabelsFromSegment(labels map[string]bool, label string) {
label = strings.ToLower(strings.TrimSpace(label))
if label == "" {
return
}
if strings.HasPrefix(label, "combo:") {
raw := strings.TrimPrefix(label, "combo:")
for _, part := range strings.Split(raw, "+") {
addRatingLabelsFromSegment(labels, part)
}
return
}
normalized := normalizeRatingSignalLabel(label)
if normalized == "" {
return
}
labels[normalized] = true
}
func ratingSignalSetFromLabels(labels map[string]bool) ratingSignalSet {
var set ratingSignalSet
for label := range labels {
ratingSignalSetAddLabel(&set, label)
}
return set
}
func ratingBridgeStrengthFromSet(set ratingSignalSet) float64 {
strength := 0.0
if set.HasPosition && set.Position > strength {
strength = set.Position
}
if set.HasBody && set.Body > strength {
strength = set.Body
}
if set.HasObject && set.Object > strength {
strength = set.Object
}
if set.HasClothing && set.Clothing > strength {
strength = set.Clothing
}
return strength
}
func ratingLabelsBridgeCompatible(a map[string]bool, b map[string]bool) bool {
for la := range a {
na := normalizeRatingSignalLabel(la)
if na == "" {
continue
}
for lb := range b {
nb := normalizeRatingSignalLabel(lb)
if nb == "" {
continue
}
if na == nb {
return true
}
}
}
as := ratingSignalSetFromLabels(a)
bs := ratingSignalSetFromLabels(b)
if as.HasPosition && bs.HasPosition {
return true
}
if as.HasBody && bs.HasBody {
return true
}
if as.HasObject && bs.HasObject {
return true
}
if as.HasClothing && bs.HasClothing {
return true
}
return false
}
func mergeRatingActivitySegments(
segments []aiSegmentMeta,
maxSilentGapSec float64,
minDurationSec float64,
) []aiSegmentMeta {
if len(segments) == 0 {
return nil
}
valid := make([]aiSegmentMeta, 0, len(segments))
for _, s := range segments {
if strings.TrimSpace(s.Label) == "" {
continue
}
start := s.StartSeconds
end := s.EndSeconds
if end <= start && s.DurationSeconds > 0 {
end = start + s.DurationSeconds
}
if end <= start {
continue
}
labels := map[string]bool{}
addRatingLabelsFromSegment(labels, s.Label)
if buildRatingComboLabel(labels) == "" {
continue
}
s.StartSeconds = start
s.EndSeconds = end
s.DurationSeconds = end - start
if ratingSegmentSeverity(s.Label) <= 0 {
continue
}
valid = append(valid, s)
}
if len(valid) == 0 {
return nil
}
sort.SliceStable(valid, func(i, j int) bool {
if valid[i].StartSeconds != valid[j].StartSeconds {
return valid[i].StartSeconds < valid[j].StartSeconds
}
if valid[i].EndSeconds != valid[j].EndSeconds {
return valid[i].EndSeconds < valid[j].EndSeconds
}
return valid[i].Label < valid[j].Label
})
type activityBlock struct {
start float64
end float64
scoreSum float64
scoreWeight float64
labels map[string]bool
}
newBlock := func(s aiSegmentMeta) activityBlock {
labels := map[string]bool{}
addRatingLabelsFromSegment(labels, s.Label)
dur := s.EndSeconds - s.StartSeconds
conf := ratingClamp01(s.Score)
if conf <= 0 {
conf = 0.50
}
return activityBlock{
start: s.StartSeconds,
end: s.EndSeconds,
scoreSum: conf * dur,
scoreWeight: dur,
labels: labels,
}
}
finishBlock := func(b activityBlock) (aiSegmentMeta, bool) {
dur := b.end - b.start
if dur <= 0 {
return aiSegmentMeta{}, false
}
label := buildRatingComboLabel(b.labels)
if label == "" {
return aiSegmentMeta{}, false
}
sev := ratingSegmentSeverity(label)
// Kurze Segmente dürfen bleiben, wenn sie stark genug sind.
if dur < minDurationSec && sev < 0.72 {
return aiSegmentMeta{}, false
}
score := 0.0
if b.scoreWeight > 0 {
score = b.scoreSum / b.scoreWeight
}
if score <= 0 {
score = 0.50
}
return aiSegmentMeta{
Label: label,
Score: ratingClamp01(score),
StartSeconds: b.start,
EndSeconds: b.end,
DurationSeconds: dur,
AutoSelected: true,
Position: segmentPositionFromAnalyzeLabel(label),
Tags: segmentTagsFromAnalyzeLabel(label),
}, true
}
out := make([]aiSegmentMeta, 0, len(valid))
cur := newBlock(valid[0])
for i := 1; i < len(valid); i++ {
n := valid[i]
gap := n.StartSeconds - cur.end
shouldBridge := gap <= maxSilentGapSec
if !shouldBridge && gap <= ratingBridgeStrongGapSec {
nextLabels := map[string]bool{}
addRatingLabelsFromSegment(nextLabels, n.Label)
curSet := ratingSignalSetFromLabels(cur.labels)
nextSet := ratingSignalSetFromLabels(nextLabels)
curBridgeStrength := ratingBridgeStrengthFromSet(curSet)
nextBridgeStrength := ratingBridgeStrengthFromSet(nextSet)
curDur := cur.end - cur.start
nextDur := n.EndSeconds - n.StartSeconds
shouldBridge =
ratingLabelsBridgeCompatible(cur.labels, nextLabels) &&
curBridgeStrength >= 0.65 &&
nextBridgeStrength >= 0.65 &&
(curDur >= 8 || nextDur >= 8)
}
if !shouldBridge {
if finished, ok := finishBlock(cur); ok {
out = append(out, finished)
}
cur = newBlock(n)
continue
}
if n.StartSeconds < cur.start {
cur.start = n.StartSeconds
}
if n.EndSeconds > cur.end {
cur.end = n.EndSeconds
}
dur := n.EndSeconds - n.StartSeconds
if dur > 0 {
conf := ratingClamp01(n.Score)
if conf <= 0 {
conf = 0.50
}
cur.scoreSum += conf * dur
cur.scoreWeight += dur
}
addRatingLabelsFromSegment(cur.labels, n.Label)
}
if finished, ok := finishBlock(cur); ok {
out = append(out, finished)
}
return out
}
func prepareAIRatingSegments(segments []aiSegmentMeta) []aiSegmentMeta {
return mergeRatingActivitySegments(
segments,
ratingMaxSilentGapSec,
ratingMinSegmentDurationSec,
)
}
func ratingConfidenceWeight(conf float64) float64 {
conf = ratingClamp01(conf)
// Confidence soll relevant sein, aber schlechte Scores nicht komplett töten.
n := ratingSmoothStep((conf - 0.25) / 0.70)
return 0.58 + 0.42*n
}
func starsFromHighlightScore(score float64) int {
switch {
case score < 18:
return 1
case score < 38:
return 2
case score < 60:
return 3
case score < 80:
return 4
default:
return 5
}
}
func ratingUsernameFromVideoPath(videoPath string) string {
id := strings.TrimSpace(assetIDFromVideoPath(videoPath))
if id == "" {
return ""
}
user := strings.ToLower(strings.TrimSpace(modelNameFromFilename(id)))
if user != "" && user != "unknown" {
return user
}
// Fallback nur, wenn die ID nicht wie ein kompletter Dateiname mit Timestamp aussieht.
if !strings.Contains(id, "__") {
return strings.ToLower(strings.TrimSpace(id))
}
return ""
}
func ratingLogSubject(username string) string {
username = strings.ToLower(strings.TrimSpace(username))
if username == "" || username == "unknown" {
return "[rating]"
}
return "[" + username + "]"
}
func computeHighlightRating(segments []aiSegmentMeta, durationSec float64) *aiRatingMeta {
return computeHighlightRatingWithUsername(segments, durationSec, "")
}
func computeHighlightRatingForVideo(segments []aiSegmentMeta, durationSec float64, videoPath string) *aiRatingMeta {
return computeHighlightRatingWithUsername(segments, durationSec, ratingUsernameFromVideoPath(videoPath))
}
func computeHighlightRatingWithUsername(segments []aiSegmentMeta, durationSec float64, username string) *aiRatingMeta {
r := &aiRatingMeta{
Score: 0,
Stars: 1,
}
if durationSec <= 0 || len(segments) == 0 {
return r
}
videoMinutes := math.Max(durationSec/60.0, 0.25)
var totalFlagged float64
var totalWeighted float64
var positionEffectiveWeighted float64
var contextEffectiveWeighted float64
var peakQuality float64
var longest float64
var confSum float64
var n int
for _, s := range segments {
segDur := s.DurationSeconds
if segDur <= 0 {
segDur = s.EndSeconds - s.StartSeconds
}
if segDur <= 0 {
continue
}
sev := ratingSegmentSeverity(s.Label)
if sev <= 0 {
appLogf("🧪 [rating] skip zero severity label=%q normalized=%q", s.Label, normalizeRatingSignalLabel(s.Label))
continue
}
conf := ratingClamp01(s.Score)
if conf <= 0 {
conf = 0.50
}
confWeight := ratingConfidenceWeight(conf)
quality := sev * confWeight
if quality <= 0 {
continue
}
effectiveDur := ratingEffectiveDurationSeconds(segDur)
weightedDur := segDur * quality
effectiveWeightedDur := effectiveDur * quality
totalFlagged += segDur
totalWeighted += weightedDur
set := ratingSignalSetFromLabel(s.Label)
if set.HasPosition {
positionEffectiveWeighted += effectiveWeightedDur
} else {
contextEffectiveWeighted += effectiveWeightedDur
}
if quality > peakQuality {
peakQuality = quality
}
confSum += conf
n++
if segDur > longest {
longest = segDur
}
}
if n == 0 {
appLogf("⚠️ %s rating result is zero because all segments were skipped", ratingLogSubject(username))
return r
}
coverageRatio := ratingClamp01(totalFlagged / durationSec)
weightedCoverageRatio := ratingClamp01(totalWeighted / durationSec)
segmentsPerMinute := float64(n) / videoMinutes
avgConfidence := confSum / float64(n)
positionDensity := positionEffectiveWeighted / videoMinutes
contextDensity := contextEffectiveWeighted / videoMinutes
peakNorm := ratingSmoothStep(peakQuality)
positionDensityNorm := ratingSoftCap(positionDensity, 5.5)
contextDensityNorm := ratingSoftCap(contextDensity, 8.5)
coverageNorm := ratingSoftCap(weightedCoverageRatio, 0.20)
frequencyNorm := ratingSoftCap(segmentsPerMinute, 1.25)
longestNorm := ratingSoftCap(longest, 24.0)
confNorm := ratingSmoothStep((avgConfidence - 0.30) / 0.65)
raw :=
0.32*peakNorm +
0.25*positionDensityNorm +
0.17*contextDensityNorm +
0.12*coverageNorm +
0.06*longestNorm +
0.04*frequencyNorm +
0.04*confNorm
// Ohne Position darf es trotzdem gut werden, aber nicht automatisch 5 Sterne.
if positionEffectiveWeighted <= 0 {
raw = math.Min(raw, 0.76)
}
// Sehr wenig Material nicht überbewerten.
if totalFlagged < 5.0 && n <= 1 {
raw = math.Min(raw, 0.44)
}
score := ratingRound(ratingClamp01(raw)*100, 1)
r.Score = score
r.Stars = starsFromHighlightScore(score)
r.Segments = n
r.SegmentsPerMinute = ratingRound(segmentsPerMinute, 2)
r.FlaggedSeconds = ratingRound(totalFlagged, 2)
r.WeightedFlaggedSeconds = ratingRound(totalWeighted, 2)
r.CoverageRatio = ratingRound(coverageRatio, 4)
r.WeightedCoverageRatio = ratingRound(weightedCoverageRatio, 4)
r.LongestSegmentSeconds = ratingRound(longest, 2)
r.AvgConfidence = ratingRound(avgConfidence, 3)
appLogf(
"✅ %s rating result score=%.1f stars=%d segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f",
ratingLogSubject(username),
r.Score,
r.Stars,
r.Segments,
r.FlaggedSeconds,
r.WeightedFlaggedSeconds,
r.CoverageRatio,
r.WeightedCoverageRatio,
r.LongestSegmentSeconds,
r.AvgConfidence,
)
return r
}