nsfwapp/backend/rating.go
2026-06-13 23:03:53 +02:00

1666 lines
37 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 = 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),
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 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
// 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 / 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
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.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
}