File size: 3,893 Bytes
af222c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56a15bc
 
 
af222c8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69345ca
 
 
af222c8
69345ca
af222c8
69345ca
 
 
 
 
 
9ad188a
 
 
 
 
 
 
 
 
af222c8
69345ca
af222c8
 
69345ca
 
 
 
 
 
 
af222c8
 
69345ca
 
 
 
 
 
af222c8
69345ca
 
 
 
 
 
 
af222c8
9ad188a
af222c8
 
 
 
69345ca
9ad188a
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import re

_POSITIVE = {
    "glad",
    "love",
    "lucky",
    "happy",
    "great",
    "grateful",
    "fun",
    "wonderful",
    "nice",
    "amazing",
    "delighted",
    "pleased",
    "yes",
    "solid",
}
_NEGATIVE = {
    "tired",
    "hard",
    "sorry",
    "unfortunately",
    "bad",
    "awful",
    "regrettably",
    "difficult",
    "frustrating",
    "no",
    "stop",
}

_AFFECT_TARGET = {
    "HAPPY": 1.0,
    "FRUSTRATED": -0.5,
    "NEUTRAL": 0.0,
    "SURPRISED": 0.0,
}

_GESTURE_OPENER_PATTERNS = {
    "THUMBS_UP": re.compile(r"^\s*(yes|yeah|totally|for sure|absolutely|sure)\b", re.I),
    "THUMBS_DOWN": re.compile(r"^\s*(no|nah|not really|i'd rather not)\b", re.I),
    "OPEN_PALM": re.compile(r"^\s*(hi|hey|hello)\b", re.I),
    "VICTORY": re.compile(r"^\s*(yes|awesome|great|fantastic|amazing|woo)\b", re.I),
    "I_LOVE_YOU": re.compile(r"^\s*(love|i love|adore|care)\b", re.I),
}


def _tokens(text: str) -> set[str]:
    return set(re.findall(r"\b[a-z]+\b", text.lower()))


def _sentiment_score(text: str) -> float:
    toks = _tokens(text)
    pos = len(toks & _POSITIVE)
    neg = len(toks & _NEGATIVE)
    if pos == 0 and neg == 0:
        return 0.0
    return (pos - neg) / (pos + neg)


def _affect_alignment(response: str, affect: str | None) -> float:
    if not affect:
        return 0.0
    target = _AFFECT_TARGET.get(affect, 0.0)
    score = _sentiment_score(response)
    # distance in [0, 2] → similarity in [0, 1]
    return max(0.0, 1.0 - abs(score - target) / 2.0)


def _gesture_alignment(response: str, gesture_tag: str | None) -> float:
    if not gesture_tag:
        return 0.0
    pattern = _GESTURE_OPENER_PATTERNS.get(gesture_tag)
    if pattern is None:
        return 0.5  # gesture has no testable opener; give partial credit
    return 1.0 if pattern.search(response) else 0.0


def _gaze_alignment(
    chunks: list[dict], gaze_bucket: str | None
) -> tuple[float, int, int]:
    if not gaze_bucket or not chunks:
        return 0.0, 0, len(chunks) if chunks else 0
    matches = sum(1 for c in chunks if c.get("bucket") == gaze_bucket)
    return matches / len(chunks), matches, len(chunks)


def _affect_breakdown(response: str) -> tuple[int, int]:
    toks = _tokens(response)
    return len(toks & _POSITIVE), len(toks & _NEGATIVE)


def compute_multimodal_alignment(
    response: str,
    affect: str | None,
    gesture_tag: str | None,
    gaze_bucket: str | None,
    chunks: list[dict],
) -> dict:
    scores: dict[str, float] = {}
    explain: dict[str, dict] = {}
    if affect:
        scores["affect_alignment"] = _affect_alignment(response, affect)
        pos, neg = _affect_breakdown(response)
        explain["affect"] = {
            "target": affect,
            "pos_words": pos,
            "neg_words": neg,
            "sentiment": round(_sentiment_score(response), 4),
        }
    if gesture_tag:
        scores["gesture_alignment"] = _gesture_alignment(response, gesture_tag)
        pattern = _GESTURE_OPENER_PATTERNS.get(gesture_tag)
        explain["gesture"] = {
            "tag": gesture_tag,
            "has_pattern": pattern is not None,
            "matched": bool(pattern.search(response)) if pattern else None,
        }
    if gaze_bucket:
        score, matches, total = _gaze_alignment(chunks, gaze_bucket)
        scores["gaze_alignment"] = score
        explain["gaze"] = {
            "bucket": gaze_bucket,
            "matched_chunks": matches,
            "total_chunks": total,
        }
    overall = sum(scores.values()) / len(scores) if scores else 0.0
    return {
        "overall_score": round(overall, 4),
        "affect_alignment": round(scores.get("affect_alignment", 0.0), 4),
        "gesture_alignment": round(scores.get("gesture_alignment", 0.0), 4),
        "gaze_alignment": round(scores.get("gaze_alignment", 0.0), 4),
        "explain": explain,
    }