// 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 = 4.5 // Größere Lücken dürfen überbrückt werden, // wenn beide Seiten stark genug / thematisch ähnlich sind. ratingBridgeStrongGapSec = 12.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", "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": 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 ratingPositionSetFromLabels(labels map[string]bool) map[string]bool { out := map[string]bool{} for label := range labels { normalized := normalizeRatingSignalLabel(label) if strings.HasPrefix(normalized, "position:") { out[normalized] = true } } return out } func ratingPositionSetsConflict(a map[string]bool, b map[string]bool) bool { ap := ratingPositionSetFromLabels(a) bp := ratingPositionSetFromLabels(b) if len(ap) == 0 || len(bp) == 0 { return false } for label := range ap { if bp[label] { return false } } return true } func ratingLabelsBridgeCompatible(a map[string]bool, b map[string]bool) bool { if ratingPositionSetsConflict(a, b) { return false } 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) // Wichtig: // Unterschiedliche Positionen NICHT pauschal verbinden. // Vorher hat "Doggy" + "Reverse Cowgirl" als kompatibel gegolten, // nur weil beide irgendeine Position waren. 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 segmentBounds struct { start float64 end float64 ok bool } type activityBlock struct { start float64 end float64 scoreSum float64 scoreWeight float64 labels map[string]bool positionWeights map[string]float64 positionBounds map[string]segmentBounds marker float64 markerWeight float64 } var addRatingPositionSignal func( weights map[string]float64, bounds map[string]segmentBounds, label string, weight float64, start float64, end float64, ) addRatingPositionSignal = func( weights map[string]float64, bounds map[string]segmentBounds, label string, weight float64, start float64, end float64, ) { 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, "+") { addRatingPositionSignal(weights, bounds, part, weight, start, end) } return } normalized := normalizeRatingSignalLabel(label) if !strings.HasPrefix(normalized, "position:") { return } if end < start { end = start } weights[normalized] += weight b := bounds[normalized] if !b.ok { b = segmentBounds{ start: start, end: end, ok: true, } } else { if start < b.start { b.start = start } if end > b.end { b.end = end } } bounds[normalized] = b } effectiveRatingLabelsForBlock := func( labels map[string]bool, positionWeights map[string]float64, ) (map[string]bool, string) { out := map[string]bool{} bestPosition := "" bestWeight := -1.0 for label := range labels { normalized := normalizeRatingSignalLabel(label) if normalized == "" { continue } if strings.HasPrefix(normalized, "position:") { w := positionWeights[normalized] if w <= 0 { w = ratingSegmentSeverity(normalized) } if w > bestWeight { bestWeight = w bestPosition = normalized } continue } out[normalized] = true } if bestPosition != "" { out[bestPosition] = true } return out, bestPosition } segmentMarker := func(s aiSegmentMeta) float64 { if s.PreviewSeconds > 0 { return s.PreviewSeconds } if s.MarkerSeconds > 0 { return s.MarkerSeconds } if s.EndSeconds > s.StartSeconds { return (s.StartSeconds + s.EndSeconds) / 2 } return s.StartSeconds } segmentMarkerWeight := func(s aiSegmentMeta) float64 { conf := ratingClamp01(s.Score) if conf <= 0 { conf = 0.50 } sev := ratingSegmentSeverity(s.Label) if sev <= 0 { sev = 0.50 } return conf * sev } 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 } positionWeights := map[string]float64{} positionBounds := map[string]segmentBounds{} addRatingPositionSignal( positionWeights, positionBounds, s.Label, conf*math.Max(1, dur), s.StartSeconds, s.EndSeconds, ) marker := segmentMarker(s) markerWeight := segmentMarkerWeight(s) return activityBlock{ start: s.StartSeconds, end: s.EndSeconds, scoreSum: conf * dur, scoreWeight: dur, labels: labels, positionWeights: positionWeights, positionBounds: positionBounds, marker: marker, markerWeight: markerWeight, } } finishBlock := func(b activityBlock) (aiSegmentMeta, bool) { dur := b.end - b.start if dur <= 0 { return aiSegmentMeta{}, false } effectiveLabels, bestPosition := effectiveRatingLabelsForBlock(b.labels, b.positionWeights) label := buildRatingComboLabel(effectiveLabels) if label == "" { return aiSegmentMeta{}, false } sev := ratingSegmentSeverity(label) start := b.start // Wenn der finale Block eine Position trägt, soll die Anzeige/der Klick // beim ersten echten Auftreten dieser Position starten — nicht schon bei // früheren Body-/Object-Signalen aus demselben Aktivitätsblock. if bestPosition != "" { if bounds, ok := b.positionBounds[bestPosition]; ok && bounds.ok { if bounds.start > start { start = bounds.start } } } dur = b.end - start if dur <= 0 { return aiSegmentMeta{}, false } // 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: start, EndSeconds: b.end, DurationSeconds: dur, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), MarkerSeconds: b.marker, PreviewSeconds: b.marker, }, 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 addRatingPositionSignal( cur.positionWeights, cur.positionBounds, n.Label, conf*math.Max(1, dur), n.StartSeconds, n.EndSeconds, ) } nextMarkerWeight := segmentMarkerWeight(n) if nextMarkerWeight > cur.markerWeight { cur.markerWeight = nextMarkerWeight cur.marker = segmentMarker(n) } 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 ratingLinearGate(value float64, start float64, full float64) float64 { if full <= start { if value >= full { return 1 } return 0 } return ratingClamp01((value - start) / (full - start)) } func ratingExcellenceBonus( peakQuality float64, positionEffectiveWeighted float64, positionDensity float64, weightedCoverageRatio float64, totalFlagged float64, longest float64, avgConfidence float64, segmentsPerMinute float64, n int, ) float64 { // Kein pauschaler Score-Shift: // Der Bonus darf nur greifen, wenn wirklich mehrere starke Signale vorhanden sind. if n < 2 { return 0 } if positionEffectiveWeighted <= 0 { return 0 } if totalFlagged < 12.0 { return 0 } if peakQuality < 0.68 { return 0 } if avgConfidence < 0.42 { return 0 } peakGate := ratingLinearGate(peakQuality, 0.68, 0.90) positionGate := ratingLinearGate(positionDensity, 2.20, 7.00) coverageGate := ratingLinearGate(weightedCoverageRatio, 0.045, 0.160) durationGate := ratingLinearGate(totalFlagged, 12.0, 90.0) longestGate := ratingLinearGate(longest, 8.0, 45.0) confGate := ratingLinearGate(avgConfidence, 0.42, 0.78) frequencyGate := ratingLinearGate(segmentsPerMinute, 0.25, 1.00) bonus := 0.030*peakGate + 0.022*positionGate + 0.016*coverageGate + 0.012*durationGate + 0.012*longestGate + 0.010*confGate + 0.006*frequencyGate // Maximal +8.5 Scorepunkte. // Dadurch werden starke 72-79 Scores 5-Sterne-fähig, // aber mittelmäßige Scores springen nicht einfach pauschal hoch. if bonus > 0.085 { bonus = 0.085 } return bonus } 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) } excellenceBonus := ratingExcellenceBonus( peakQuality, positionEffectiveWeighted, positionDensity, weightedCoverageRatio, totalFlagged, longest, avgConfidence, segmentsPerMinute, n, ) raw = ratingClamp01(raw + excellenceBonus) score := ratingRound(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 bonus=%.1f segments=%d flagged=%.2f weighted=%.2f coverage=%.4f weightedCoverage=%.4f longest=%.2f avgConf=%.3f", ratingLogSubject(username), r.Score, r.Stars, ratingRound(excellenceBonus*100, 1), r.Segments, r.FlaggedSeconds, r.WeightedFlaggedSeconds, r.CoverageRatio, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, ) return r }