// backend\rating.go package main import ( "math" "sort" "strings" ) type aiRatingMeta struct { Score float64 `json:"score"` ActionScore float64 `json:"actionScore"` 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 ratingSaturatingEvidence(value, scale float64) float64 { if value <= 0 || scale <= 0 { return 0 } return ratingClamp01(1 - math.Exp(-value/scale)) } 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", "boobjob", "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", "boobjob": 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.50 case "panties", "bra": return 0.44 case "bikini": return 0.30 case "stockings", "heels": return 0.28 case "skirt", "dress", "hotpants", "croptop": return 0.22 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) return contextualSegmentSeverityWeightFromSet(set) } 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 { // Position soll das Hauptkriterium sein. // Sexuelle Körperteile und explizite Kleidung verstärken die Position. score = 0.82*set.Position + 0.12*set.Body + 0.08*set.Clothing + 0.015*set.Object + 0.005*set.Person if set.HasBody { score += 0.030 } if set.HasClothing { score += 0.020 } if set.HasObject { score += 0.006 } if set.HasPerson { score += 0.002 } // Standing ohne echten Kontext klar schwächer halten. if set.Position < 0.60 && !set.HasBody && !set.HasObject && !set.HasClothing { score = math.Min(score, 0.32) } return ratingClamp01(score) } score = 0.54*set.Body + 0.28*set.Clothing + 0.16*set.Object + 0.02*set.Person if set.HasBody && set.HasClothing { score += 0.05 } if set.HasBody && set.HasObject { score += 0.04 } if set.HasClothing && set.HasObject { 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 positionRanges map[string][]segmentBounds marker float64 markerWeight float64 } var addRatingPositionSignal func( weights map[string]float64, bounds map[string]segmentBounds, ranges map[string][]segmentBounds, label string, weight float64, start float64, end float64, ) addRatingPositionSignal = func( weights map[string]float64, bounds map[string]segmentBounds, ranges 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, ranges, 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 if end > start { ranges[normalized] = append(ranges[normalized], segmentBounds{ start: start, end: end, ok: true, }) } } 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{} positionRanges := map[string][]segmentBounds{} addRatingPositionSignal( positionWeights, positionBounds, positionRanges, 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, 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 { return aiSegmentMeta{}, false } effectiveLabels, bestPosition := effectiveRatingLabelsForBlock(b.labels, b.positionWeights) label := buildRatingComboLabel(effectiveLabels) if label == "" { return aiSegmentMeta{}, false } sev := ratingSegmentSeverity(label) start := b.start end := b.end // Wenn der finale Block eine Position trägt, soll der Block // wirklich auf die echte Positions-Zeit gekürzt werden — // nicht nur am Anfang, sondern auch am Ende. if bestPosition != "" { if bounds, ok := b.positionBounds[bestPosition]; ok && bounds.ok { if bounds.start > start { start = bounds.start } if bounds.end < end { end = bounds.end } } } if end < start { end = start } dur = end - start if dur <= 0 { 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 ratingDuration < 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 } marker := b.marker if marker < start || marker > end { marker = (start + end) / 2 } return aiSegmentMeta{ Label: label, Score: ratingClamp01(score), RatingIntensity: ratingClamp01(sev * ratingConfidenceWeight(score)), StartSeconds: start, EndSeconds: end, DurationSeconds: ratingDuration, AutoSelected: true, Position: segmentPositionFromAnalyzeLabel(label), Tags: segmentTagsFromAnalyzeLabel(label), MarkerSeconds: marker, PreviewSeconds: 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 nextLabels := map[string]bool{} addRatingLabelsFromSegment(nextLabels, n.Label) positionConflict := ratingPositionSetsConflict(cur.labels, nextLabels) // 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) 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, cur.positionRanges, 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 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 ratingBlendWithAction(base, action, actionWeight float64) float64 { actionWeight = ratingClamp01(actionWeight) return ratingClamp01((1-actionWeight)*base + actionWeight*action) } func ratingPositionActionScore( durationNorm float64, longestNorm float64, densityNorm float64, coverageNorm float64, peakNorm float64, ) float64 { return ratingClamp01( 0.38*durationNorm + 0.24*longestNorm + 0.18*densityNorm + 0.12*coverageNorm + 0.08*peakNorm, ) } func ratingContextActionScore( durationNorm float64, longestNorm float64, coverageNorm float64, densityNorm float64, peakNorm float64, ) float64 { return ratingClamp01( 0.40*durationNorm + 0.25*longestNorm + 0.20*coverageNorm + 0.10*densityNorm + 0.05*peakNorm, ) } 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 < 22: return 1 case score < 45: return 2 case score < 68: return 3 case score < 85: 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 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 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 set := ratingSignalSetFromLabel(s.Label) bodyEffectiveWeighted += effectiveDur * set.Body * confWeight clothingEffectiveWeighted += effectiveDur * set.Clothing * confWeight objectEffectiveWeighted += effectiveDur * set.Object * confWeight if !set.HasPosition { 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 } totalFlagged += segDur totalWeighted += weightedDur positionEffectiveWeighted += effectiveWeightedDur positionFlagged += segDur qualityDurationSum += quality * effectiveDur qualityDurationWeight += effectiveDur if quality > peakQuality { peakQuality = quality } 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 { 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 actionRaw := ratingContextActionScore( durationNorm, longestNorm, coverageNorm, densityNorm, peakNorm, ) raw = ratingBlendWithAction(raw, actionRaw, 0.35) // 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.ActionScore = ratingRound(actionRaw*100, 1) 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 / confWeightSum avgPositionQuality := qualityDurationSum / qualityDurationWeight positionDensity := positionEffectiveWeighted / videoMinutes 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) // Position quality is the primary signal. Actual position duration and the // longest continuous segment decide whether a detection is brief or strong. raw := 0.30*avgQualityNorm + 0.10*peakNorm + 0.26*positionDurationNorm + 0.16*longestNorm + 0.10*positionDensityNorm + 0.06*coverageNorm + 0.02*positionVarietyNorm actionRaw := ratingPositionActionScore( positionDurationNorm, longestNorm, positionDensityNorm, coverageNorm, peakNorm, ) raw = ratingBlendWithAction(raw, actionRaw, 0.30) explicitContextBonus := ratingExplicitContextBonus( bodyEffectiveWeighted, clothingEffectiveWeighted, objectEffectiveWeighted, videoMinutes, ) // 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 + explicitContextBonus + excellenceBonus) score := ratingRound(raw*100, 1) r.Score = score r.ActionScore = ratingRound(actionRaw*100, 1) 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 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, ratingRound(positionFlagged, 1), positionDensity, r.WeightedCoverageRatio, r.LongestSegmentSeconds, r.AvgConfidence, ) return r }