eq-layer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eq_layer/__init__.py +68 -0
- eq_layer/ab_eval.py +302 -0
- eq_layer/actions.py +242 -0
- eq_layer/affect.py +227 -0
- eq_layer/auditor.py +321 -0
- eq_layer/cli.py +174 -0
- eq_layer/coarse_discourse.py +107 -0
- eq_layer/data/intent_train.jsonl +299 -0
- eq_layer/dbdc3.py +197 -0
- eq_layer/dialogbank_iso.py +272 -0
- eq_layer/dialogue_reference.py +393 -0
- eq_layer/intent.py +199 -0
- eq_layer/intent_belief.py +276 -0
- eq_layer/interaction.py +314 -0
- eq_layer/policies.py +295 -0
- eq_layer/preferences.py +125 -0
- eq_layer/response_experiment.py +606 -0
- eq_layer/steer.py +281 -0
- eq_layer/tracker.py +111 -0
- eq_layer/trained_affect.py +246 -0
- eq_layer/trained_breakdown.py +143 -0
- eq_layer/trained_disagreement.py +148 -0
- eq_layer/trained_intent.py +291 -0
- eq_layer/trained_subtext.py +436 -0
- eq_layer-0.1.0.dist-info/METADATA +527 -0
- eq_layer-0.1.0.dist-info/RECORD +29 -0
- eq_layer-0.1.0.dist-info/WHEEL +4 -0
- eq_layer-0.1.0.dist-info/entry_points.txt +2 -0
- eq_layer-0.1.0.dist-info/licenses/LICENSE +21 -0
eq_layer/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .affect import AffectAdapter, HeuristicAffect, load_cases
|
|
4
|
+
from .auditor import AuditContext, AuditIssue, ResponseAudit, ResponseAuditor, audit_from_generation_metadata
|
|
5
|
+
from .actions import FactoredAction, RealizationControls, compose_action
|
|
6
|
+
from .intent import HeuristicIntent, IntentAdapter, IntentState
|
|
7
|
+
from .intent_belief import IntentBelief, IntentRiskDecision, decide_intent_action, expected_loss
|
|
8
|
+
from .interaction import InteractionQualityState, RepairState, infer_interaction_quality, infer_repair_state
|
|
9
|
+
from .policies import POLICIES, REGISTRY, SUBTEXTS, AffectState, Policy, Register, Selector
|
|
10
|
+
from .preferences import SessionPreferences, infer_session_preferences
|
|
11
|
+
from .response_experiment import CommandModel, FullEQPipeline, generate_pairs, validate_experiment_cases
|
|
12
|
+
from .steer import Steer, aggregate, score_case
|
|
13
|
+
from .trained_affect import DimensionalAffect, TrainedAffect
|
|
14
|
+
from .trained_intent import TrainedIntent
|
|
15
|
+
from .trained_subtext import DialogueSignals, SubtextDecision, TrainedSubtext
|
|
16
|
+
from .tracker import AnnotationStanceResolver, ConversationTracker, StanceDecision, TrackingResult
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
"AffectAdapter",
|
|
21
|
+
"AffectState",
|
|
22
|
+
"audit_from_generation_metadata",
|
|
23
|
+
"ResponseAuditor",
|
|
24
|
+
"ResponseAudit",
|
|
25
|
+
"AuditIssue",
|
|
26
|
+
"AuditContext",
|
|
27
|
+
"compose_action",
|
|
28
|
+
"RealizationControls",
|
|
29
|
+
"FactoredAction",
|
|
30
|
+
"HeuristicAffect",
|
|
31
|
+
"HeuristicIntent",
|
|
32
|
+
"IntentAdapter",
|
|
33
|
+
"IntentState",
|
|
34
|
+
"expected_loss",
|
|
35
|
+
"decide_intent_action",
|
|
36
|
+
"IntentRiskDecision",
|
|
37
|
+
"IntentBelief",
|
|
38
|
+
"infer_repair_state",
|
|
39
|
+
"infer_interaction_quality",
|
|
40
|
+
"RepairState",
|
|
41
|
+
"InteractionQualityState",
|
|
42
|
+
"POLICIES",
|
|
43
|
+
"Policy",
|
|
44
|
+
"REGISTRY",
|
|
45
|
+
"Register",
|
|
46
|
+
"SUBTEXTS",
|
|
47
|
+
"Selector",
|
|
48
|
+
"infer_session_preferences",
|
|
49
|
+
"SessionPreferences",
|
|
50
|
+
"Steer",
|
|
51
|
+
"TrainedIntent",
|
|
52
|
+
"TrainedAffect",
|
|
53
|
+
"DimensionalAffect",
|
|
54
|
+
"aggregate",
|
|
55
|
+
"load_cases",
|
|
56
|
+
"TrainedSubtext",
|
|
57
|
+
"DialogueSignals",
|
|
58
|
+
"SubtextDecision",
|
|
59
|
+
"ConversationTracker",
|
|
60
|
+
"TrackingResult",
|
|
61
|
+
"StanceDecision",
|
|
62
|
+
"AnnotationStanceResolver",
|
|
63
|
+
"CommandModel",
|
|
64
|
+
"FullEQPipeline",
|
|
65
|
+
"generate_pairs",
|
|
66
|
+
"validate_experiment_cases",
|
|
67
|
+
"score_case",
|
|
68
|
+
]
|
eq_layer/ab_eval.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
import random
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DIMENSIONS = (
|
|
11
|
+
"intent_fidelity",
|
|
12
|
+
"appropriateness",
|
|
13
|
+
"actionability",
|
|
14
|
+
"non_patronizing",
|
|
15
|
+
"overall",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_jsonl(path: str | Path) -> list[dict]:
|
|
20
|
+
with open(path, encoding="utf-8") as fh:
|
|
21
|
+
return [json.loads(line) for line in fh if line.strip()]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_jsonl(path: str | Path, rows: list[dict]) -> None:
|
|
25
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
26
|
+
for row in rows:
|
|
27
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def prepare_blinded(
|
|
31
|
+
pairs: list[dict],
|
|
32
|
+
*,
|
|
33
|
+
seed: int = 0,
|
|
34
|
+
) -> tuple[list[dict], dict]:
|
|
35
|
+
"""Randomize baseline/EQ sides and return ballot + separate decode key."""
|
|
36
|
+
rng = random.Random(seed)
|
|
37
|
+
ballot: list[dict] = []
|
|
38
|
+
key: dict[str, dict[str, str]] = {}
|
|
39
|
+
|
|
40
|
+
seen: set[str] = set()
|
|
41
|
+
for row in pairs:
|
|
42
|
+
case_id = str(row["id"])
|
|
43
|
+
if case_id in seen:
|
|
44
|
+
raise ValueError(f"Duplicate A/B case id: {case_id}")
|
|
45
|
+
seen.add(case_id)
|
|
46
|
+
|
|
47
|
+
baseline = str(row["baseline"])
|
|
48
|
+
eq_response = str(row["eq"])
|
|
49
|
+
if not baseline.strip() or not eq_response.strip():
|
|
50
|
+
raise ValueError(f"Empty response in A/B case: {case_id}")
|
|
51
|
+
|
|
52
|
+
eq_is_a = bool(rng.getrandbits(1))
|
|
53
|
+
response_a = eq_response if eq_is_a else baseline
|
|
54
|
+
response_b = baseline if eq_is_a else eq_response
|
|
55
|
+
key[case_id] = {
|
|
56
|
+
"A": "eq" if eq_is_a else "baseline",
|
|
57
|
+
"B": "baseline" if eq_is_a else "eq",
|
|
58
|
+
}
|
|
59
|
+
ballot.append(
|
|
60
|
+
{
|
|
61
|
+
"id": case_id,
|
|
62
|
+
"context": row.get("context", []),
|
|
63
|
+
"response_A": response_a,
|
|
64
|
+
"response_B": response_b,
|
|
65
|
+
"ratings": {dimension: "" for dimension in DIMENSIONS},
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return ballot, key
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class PreferenceSummary:
|
|
74
|
+
wins: int
|
|
75
|
+
losses: int
|
|
76
|
+
ties: int
|
|
77
|
+
win_rate_non_tie: float | None
|
|
78
|
+
wilson_low: float | None
|
|
79
|
+
wilson_high: float | None
|
|
80
|
+
exact_p_two_sided: float | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _wilson_interval(successes: int, total: int, z: float = 1.959963984540054) -> tuple[float, float]:
|
|
84
|
+
if total <= 0:
|
|
85
|
+
raise ValueError("Wilson interval requires total > 0.")
|
|
86
|
+
p = successes / total
|
|
87
|
+
denom = 1.0 + (z * z) / total
|
|
88
|
+
centre = (p + (z * z) / (2.0 * total)) / denom
|
|
89
|
+
margin = (
|
|
90
|
+
z
|
|
91
|
+
* math.sqrt((p * (1.0 - p) + (z * z) / (4.0 * total)) / total)
|
|
92
|
+
/ denom
|
|
93
|
+
)
|
|
94
|
+
return max(0.0, centre - margin), min(1.0, centre + margin)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _binom_two_sided_half(successes: int, total: int) -> float:
|
|
98
|
+
"""Exact two-sided sign test under p=0.5, summing equally/less likely outcomes."""
|
|
99
|
+
if total <= 0:
|
|
100
|
+
raise ValueError("Binomial test requires total > 0.")
|
|
101
|
+
observed = math.comb(total, successes)
|
|
102
|
+
numerator = 0
|
|
103
|
+
for k in range(total + 1):
|
|
104
|
+
ways = math.comb(total, k)
|
|
105
|
+
if ways <= observed:
|
|
106
|
+
numerator += ways
|
|
107
|
+
return min(1.0, numerator / (2 ** total))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summarize(wins: int, losses: int, ties: int) -> PreferenceSummary:
|
|
111
|
+
non_ties = wins + losses
|
|
112
|
+
if non_ties == 0:
|
|
113
|
+
return PreferenceSummary(
|
|
114
|
+
wins=wins,
|
|
115
|
+
losses=losses,
|
|
116
|
+
ties=ties,
|
|
117
|
+
win_rate_non_tie=None,
|
|
118
|
+
wilson_low=None,
|
|
119
|
+
wilson_high=None,
|
|
120
|
+
exact_p_two_sided=None,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
low, high = _wilson_interval(wins, non_ties)
|
|
124
|
+
return PreferenceSummary(
|
|
125
|
+
wins=wins,
|
|
126
|
+
losses=losses,
|
|
127
|
+
ties=ties,
|
|
128
|
+
win_rate_non_tie=round(wins / non_ties, 4),
|
|
129
|
+
wilson_low=round(low, 4),
|
|
130
|
+
wilson_high=round(high, 4),
|
|
131
|
+
exact_p_two_sided=round(_binom_two_sided_half(wins, non_ties), 6),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def score_blinded(rated_ballot: list[dict], key: dict) -> dict:
|
|
136
|
+
counts = {
|
|
137
|
+
dimension: {"wins": 0, "losses": 0, "ties": 0}
|
|
138
|
+
for dimension in DIMENSIONS
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for row in rated_ballot:
|
|
142
|
+
case_id = str(row["id"])
|
|
143
|
+
if case_id not in key:
|
|
144
|
+
raise ValueError(f"Missing decode key for case: {case_id}")
|
|
145
|
+
|
|
146
|
+
ratings = row.get("ratings") or {}
|
|
147
|
+
for dimension in DIMENSIONS:
|
|
148
|
+
vote = str(ratings.get(dimension, "")).strip().upper()
|
|
149
|
+
if vote not in {"A", "B", "TIE"}:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"{case_id}/{dimension}: expected A, B, or tie; got {vote!r}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if vote == "TIE":
|
|
155
|
+
counts[dimension]["ties"] += 1
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
winner = key[case_id][vote]
|
|
159
|
+
if winner == "eq":
|
|
160
|
+
counts[dimension]["wins"] += 1
|
|
161
|
+
elif winner == "baseline":
|
|
162
|
+
counts[dimension]["losses"] += 1
|
|
163
|
+
else:
|
|
164
|
+
raise ValueError(f"Invalid decode key target: {winner}")
|
|
165
|
+
|
|
166
|
+
report = {
|
|
167
|
+
"design": "blind-pairwise-human-evaluation",
|
|
168
|
+
"unit": "case-level paired preference",
|
|
169
|
+
"dimensions": {},
|
|
170
|
+
}
|
|
171
|
+
for dimension, values in counts.items():
|
|
172
|
+
summary = summarize(**values)
|
|
173
|
+
report["dimensions"][dimension] = {
|
|
174
|
+
"eq_wins": summary.wins,
|
|
175
|
+
"eq_losses": summary.losses,
|
|
176
|
+
"ties": summary.ties,
|
|
177
|
+
"eq_win_rate_non_tie": summary.win_rate_non_tie,
|
|
178
|
+
"wilson_95_low": summary.wilson_low,
|
|
179
|
+
"wilson_95_high": summary.wilson_high,
|
|
180
|
+
"exact_sign_test_p_two_sided": summary.exact_p_two_sided,
|
|
181
|
+
}
|
|
182
|
+
return report
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _decoded_vote(row: dict, key: dict, dimension: str) -> str:
|
|
186
|
+
case_id = str(row["id"])
|
|
187
|
+
if case_id not in key:
|
|
188
|
+
raise ValueError(f"Missing decode key for case: {case_id}")
|
|
189
|
+
ratings = row.get("ratings") or {}
|
|
190
|
+
vote = str(ratings.get(dimension, "")).strip().upper()
|
|
191
|
+
if vote == "TIE":
|
|
192
|
+
return "tie"
|
|
193
|
+
if vote not in {"A", "B"}:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"{case_id}/{dimension}: expected A, B, or tie; got {vote!r}"
|
|
196
|
+
)
|
|
197
|
+
winner = key[case_id][vote]
|
|
198
|
+
if winner not in {"eq", "baseline"}:
|
|
199
|
+
raise ValueError(f"Invalid decode key target: {winner}")
|
|
200
|
+
return winner
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _fleiss_kappa(case_votes: list[list[str]]) -> float | None:
|
|
204
|
+
if not case_votes:
|
|
205
|
+
return None
|
|
206
|
+
n_raters = len(case_votes[0])
|
|
207
|
+
if n_raters < 2 or any(len(votes) != n_raters for votes in case_votes):
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
categories = ("eq", "baseline", "tie")
|
|
211
|
+
p_i = []
|
|
212
|
+
totals = {category: 0 for category in categories}
|
|
213
|
+
|
|
214
|
+
for votes in case_votes:
|
|
215
|
+
counts = {category: votes.count(category) for category in categories}
|
|
216
|
+
for category in categories:
|
|
217
|
+
totals[category] += counts[category]
|
|
218
|
+
agreement = (
|
|
219
|
+
sum(count * count for count in counts.values()) - n_raters
|
|
220
|
+
) / (n_raters * (n_raters - 1))
|
|
221
|
+
p_i.append(agreement)
|
|
222
|
+
|
|
223
|
+
p_bar = sum(p_i) / len(p_i)
|
|
224
|
+
denom = len(case_votes) * n_raters
|
|
225
|
+
marginals = {
|
|
226
|
+
category: totals[category] / denom
|
|
227
|
+
for category in categories
|
|
228
|
+
}
|
|
229
|
+
p_e = sum(value * value for value in marginals.values())
|
|
230
|
+
if math.isclose(1.0 - p_e, 0.0):
|
|
231
|
+
return None
|
|
232
|
+
return round((p_bar - p_e) / (1.0 - p_e), 4)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def score_multiple_blinded(
|
|
236
|
+
rated_ballots: dict[str, list[dict]],
|
|
237
|
+
key: dict,
|
|
238
|
+
) -> dict:
|
|
239
|
+
"""Aggregate multiple blinded raters without treating rater-votes as cases."""
|
|
240
|
+
if len(rated_ballots) < 2:
|
|
241
|
+
raise ValueError("Multi-rater scoring requires at least two raters.")
|
|
242
|
+
|
|
243
|
+
case_ids: list[str] | None = None
|
|
244
|
+
rows_by_rater: dict[str, dict[str, dict]] = {}
|
|
245
|
+
|
|
246
|
+
for rater_id, ballot in rated_ballots.items():
|
|
247
|
+
mapping = {str(row["id"]): row for row in ballot}
|
|
248
|
+
if len(mapping) != len(ballot):
|
|
249
|
+
raise ValueError(f"Duplicate case id in rater ballot: {rater_id}")
|
|
250
|
+
current_ids = sorted(mapping)
|
|
251
|
+
if case_ids is None:
|
|
252
|
+
case_ids = current_ids
|
|
253
|
+
elif current_ids != case_ids:
|
|
254
|
+
raise ValueError("All raters must score the same case ids.")
|
|
255
|
+
rows_by_rater[rater_id] = mapping
|
|
256
|
+
|
|
257
|
+
assert case_ids is not None
|
|
258
|
+
report = {
|
|
259
|
+
"design": "blind-pairwise-multi-rater-human-evaluation",
|
|
260
|
+
"n_raters": len(rated_ballots),
|
|
261
|
+
"n_cases": len(case_ids),
|
|
262
|
+
"dimensions": {},
|
|
263
|
+
"rater_reports": {},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for rater_id, ballot in rated_ballots.items():
|
|
267
|
+
report["rater_reports"][rater_id] = score_blinded(ballot, key)
|
|
268
|
+
|
|
269
|
+
for dimension in DIMENSIONS:
|
|
270
|
+
majority_wins = 0
|
|
271
|
+
majority_losses = 0
|
|
272
|
+
majority_ties = 0
|
|
273
|
+
per_case_votes: list[list[str]] = []
|
|
274
|
+
|
|
275
|
+
for case_id in case_ids:
|
|
276
|
+
votes = [
|
|
277
|
+
_decoded_vote(rows_by_rater[rater_id][case_id], key, dimension)
|
|
278
|
+
for rater_id in sorted(rows_by_rater)
|
|
279
|
+
]
|
|
280
|
+
per_case_votes.append(votes)
|
|
281
|
+
eq_votes = votes.count("eq")
|
|
282
|
+
baseline_votes = votes.count("baseline")
|
|
283
|
+
if eq_votes > baseline_votes:
|
|
284
|
+
majority_wins += 1
|
|
285
|
+
elif baseline_votes > eq_votes:
|
|
286
|
+
majority_losses += 1
|
|
287
|
+
else:
|
|
288
|
+
majority_ties += 1
|
|
289
|
+
|
|
290
|
+
summary = summarize(majority_wins, majority_losses, majority_ties)
|
|
291
|
+
report["dimensions"][dimension] = {
|
|
292
|
+
"case_majority_eq_wins": summary.wins,
|
|
293
|
+
"case_majority_eq_losses": summary.losses,
|
|
294
|
+
"case_majority_ties": summary.ties,
|
|
295
|
+
"case_majority_eq_win_rate_non_tie": summary.win_rate_non_tie,
|
|
296
|
+
"wilson_95_low": summary.wilson_low,
|
|
297
|
+
"wilson_95_high": summary.wilson_high,
|
|
298
|
+
"exact_sign_test_p_two_sided": summary.exact_p_two_sided,
|
|
299
|
+
"fleiss_kappa": _fleiss_kappa(per_case_votes),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return report
|
eq_layer/actions.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
|
|
5
|
+
from .dialogue_reference import DialogueReferenceState
|
|
6
|
+
from .intent import IntentState
|
|
7
|
+
from .interaction import InteractionQualityState, RepairState
|
|
8
|
+
from .policies import Policy, Register
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class RealizationControls:
|
|
13
|
+
"""Surface-form controls kept separate from task/social/repair decisions."""
|
|
14
|
+
|
|
15
|
+
verbosity: str = "normal"
|
|
16
|
+
directness: str = "normal"
|
|
17
|
+
warmth: str = "normal"
|
|
18
|
+
question_budget: int = 0
|
|
19
|
+
scope_limited: bool = False
|
|
20
|
+
no_guess: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FactoredAction:
|
|
25
|
+
"""Inspectable response action compiled from intent and dialogue state.
|
|
26
|
+
|
|
27
|
+
task_move is driven primarily by the user's explicit goal.
|
|
28
|
+
social_move and repair_move are driven by conversational state/policy.
|
|
29
|
+
This prevents affective adaptation from silently replacing the task.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
task_move: str
|
|
33
|
+
social_move: str
|
|
34
|
+
repair_move: str
|
|
35
|
+
realization: RealizationControls
|
|
36
|
+
source_policy: str
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _task_move(
|
|
43
|
+
intent: IntentState | None,
|
|
44
|
+
policy: Policy,
|
|
45
|
+
repair: RepairState | None = None,
|
|
46
|
+
reference: DialogueReferenceState | None = None,
|
|
47
|
+
) -> str:
|
|
48
|
+
if repair is not None and repair.active:
|
|
49
|
+
if repair.replacement_explicit:
|
|
50
|
+
return "resume_prior_task_with_correction"
|
|
51
|
+
return "clarify_correction"
|
|
52
|
+
|
|
53
|
+
if reference is not None and reference.active:
|
|
54
|
+
if not reference.resolved:
|
|
55
|
+
return "clarify_reference"
|
|
56
|
+
return {
|
|
57
|
+
"continue": "continue_referenced_goal",
|
|
58
|
+
"execute_reference": "execute_referenced_goal",
|
|
59
|
+
"revise_reference": "revise_referenced_output",
|
|
60
|
+
}.get(reference.form, "respond_contextually")
|
|
61
|
+
|
|
62
|
+
if intent is not None:
|
|
63
|
+
if intent.needs_clarification:
|
|
64
|
+
return "clarify_goal"
|
|
65
|
+
return {
|
|
66
|
+
"action_request": "execute_request",
|
|
67
|
+
"status_check": "report_status",
|
|
68
|
+
"explanation": "explain",
|
|
69
|
+
"question": "answer_question",
|
|
70
|
+
"statement": "respond_contextually",
|
|
71
|
+
"unknown": "clarify_goal",
|
|
72
|
+
}.get(intent.kind, "respond_contextually")
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
"execute_request": "execute_request",
|
|
76
|
+
"report_status": "report_status",
|
|
77
|
+
"explain_request": "explain",
|
|
78
|
+
"clarify_request": "clarify_goal",
|
|
79
|
+
"direct_no_padding": "answer_question",
|
|
80
|
+
"boundary": "set_boundary",
|
|
81
|
+
}.get(policy.name, "respond_contextually")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
SOCIAL_MOVES = {
|
|
85
|
+
"mirror_specific": "mirror_specific",
|
|
86
|
+
"validate_then_redirect": "validate_specific",
|
|
87
|
+
"deflate_tension": "deflate_tension",
|
|
88
|
+
"hold": "minimal_presence",
|
|
89
|
+
"boundary": "boundary_neutral",
|
|
90
|
+
"hold_sustained_escalation": "low_warmth",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
REPAIR_MOVES = {
|
|
94
|
+
"clarify_request": "clarify_goal",
|
|
95
|
+
"ask_one_question": "clarify_one",
|
|
96
|
+
"repair_hold_position": "hold_position",
|
|
97
|
+
"repair_interrogation": "stop_restatement",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _repair_move(policy: Policy, repair: RepairState | None) -> str:
|
|
102
|
+
base = REPAIR_MOVES.get(policy.name, "none")
|
|
103
|
+
if repair is None or not repair.active:
|
|
104
|
+
return base
|
|
105
|
+
if repair.replacement_explicit:
|
|
106
|
+
if repair.repeated:
|
|
107
|
+
return "stop_restatement_and_apply_correction"
|
|
108
|
+
return "apply_correction"
|
|
109
|
+
if repair.repeated:
|
|
110
|
+
return "stop_restatement_and_repair"
|
|
111
|
+
if base != "none":
|
|
112
|
+
return base
|
|
113
|
+
return "clarify_one"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _realization(
|
|
117
|
+
policy: Policy,
|
|
118
|
+
intent: IntentState | None,
|
|
119
|
+
*,
|
|
120
|
+
repair_move: str,
|
|
121
|
+
repair: RepairState | None = None,
|
|
122
|
+
interaction_quality: InteractionQualityState | None = None,
|
|
123
|
+
reference: DialogueReferenceState | None = None,
|
|
124
|
+
) -> RealizationControls:
|
|
125
|
+
by_register = {
|
|
126
|
+
Register.MIRROR: ("normal", "normal", "normal"),
|
|
127
|
+
Register.VALIDATE_REDIRECT: ("normal", "high", "normal"),
|
|
128
|
+
Register.DIRECT: ("normal", "high", "low"),
|
|
129
|
+
Register.ASK: ("low", "high", "normal"),
|
|
130
|
+
Register.DEFLATE: ("low", "high", "low"),
|
|
131
|
+
Register.REPAIR: ("low", "high", "low"),
|
|
132
|
+
Register.HOLD: ("low", "normal", "low"),
|
|
133
|
+
Register.BOUNDARY: ("low", "high", "low"),
|
|
134
|
+
}
|
|
135
|
+
verbosity, directness, warmth = by_register[policy.register]
|
|
136
|
+
|
|
137
|
+
constraints = set(intent.constraints) if intent is not None else set()
|
|
138
|
+
if "brief" in constraints:
|
|
139
|
+
verbosity = "low"
|
|
140
|
+
if "detailed" in constraints:
|
|
141
|
+
verbosity = "high"
|
|
142
|
+
if "direct" in constraints:
|
|
143
|
+
directness = "high"
|
|
144
|
+
|
|
145
|
+
# Interaction quality is not affect. Only a strong structural failure
|
|
146
|
+
# signal changes realization, and it does so conservatively.
|
|
147
|
+
if interaction_quality is not None and interaction_quality.current <= 0.55:
|
|
148
|
+
verbosity = "low"
|
|
149
|
+
directness = "high"
|
|
150
|
+
warmth = "low"
|
|
151
|
+
|
|
152
|
+
explicit_replacement = (
|
|
153
|
+
repair is not None
|
|
154
|
+
and repair.active
|
|
155
|
+
and repair.replacement_explicit
|
|
156
|
+
)
|
|
157
|
+
repair_needs_clarification = (
|
|
158
|
+
repair is not None
|
|
159
|
+
and repair.active
|
|
160
|
+
and not repair.replacement_explicit
|
|
161
|
+
)
|
|
162
|
+
reference_needs_clarification = (
|
|
163
|
+
reference is not None
|
|
164
|
+
and reference.active
|
|
165
|
+
and not reference.resolved
|
|
166
|
+
)
|
|
167
|
+
question_budget = 1 if (
|
|
168
|
+
explicit_replacement
|
|
169
|
+
or repair_needs_clarification
|
|
170
|
+
or reference_needs_clarification
|
|
171
|
+
or repair_move in {"clarify_goal", "clarify_one"}
|
|
172
|
+
or (
|
|
173
|
+
intent is not None
|
|
174
|
+
and intent.needs_clarification
|
|
175
|
+
and not explicit_replacement
|
|
176
|
+
)
|
|
177
|
+
) else 0
|
|
178
|
+
|
|
179
|
+
no_guess = (
|
|
180
|
+
"no_guess" in constraints
|
|
181
|
+
or repair_needs_clarification
|
|
182
|
+
or reference_needs_clarification
|
|
183
|
+
or (
|
|
184
|
+
intent is not None
|
|
185
|
+
and intent.needs_clarification
|
|
186
|
+
and not explicit_replacement
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# An active correction identifies a repair target, but does not prove the
|
|
191
|
+
# user's factual stance. Do not promote it to user_right/user_wrong.
|
|
192
|
+
if repair is not None and repair.active and repair_move == "clarify_one":
|
|
193
|
+
no_guess = True
|
|
194
|
+
|
|
195
|
+
return RealizationControls(
|
|
196
|
+
verbosity=verbosity,
|
|
197
|
+
directness=directness,
|
|
198
|
+
warmth=warmth,
|
|
199
|
+
question_budget=question_budget,
|
|
200
|
+
scope_limited="scope_limited" in constraints,
|
|
201
|
+
no_guess=no_guess,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def compose_action(
|
|
206
|
+
policy: Policy,
|
|
207
|
+
intent: IntentState | None = None,
|
|
208
|
+
*,
|
|
209
|
+
repair: RepairState | None = None,
|
|
210
|
+
interaction_quality: InteractionQualityState | None = None,
|
|
211
|
+
reference: DialogueReferenceState | None = None,
|
|
212
|
+
) -> FactoredAction:
|
|
213
|
+
"""Compile policy + intent + dialogue-health state into orthogonal controls."""
|
|
214
|
+
|
|
215
|
+
task_move = _task_move(intent, policy, repair, reference)
|
|
216
|
+
if policy.name == "boundary":
|
|
217
|
+
task_move = "set_boundary"
|
|
218
|
+
|
|
219
|
+
repair_move = _repair_move(policy, repair)
|
|
220
|
+
social_move = SOCIAL_MOVES.get(policy.name, "neutral")
|
|
221
|
+
|
|
222
|
+
if (
|
|
223
|
+
interaction_quality is not None
|
|
224
|
+
and interaction_quality.current <= 0.55
|
|
225
|
+
and social_move == "neutral"
|
|
226
|
+
):
|
|
227
|
+
social_move = "low_warmth"
|
|
228
|
+
|
|
229
|
+
return FactoredAction(
|
|
230
|
+
task_move=task_move,
|
|
231
|
+
social_move=social_move,
|
|
232
|
+
repair_move=repair_move,
|
|
233
|
+
realization=_realization(
|
|
234
|
+
policy,
|
|
235
|
+
intent,
|
|
236
|
+
repair_move=repair_move,
|
|
237
|
+
repair=repair,
|
|
238
|
+
interaction_quality=interaction_quality,
|
|
239
|
+
reference=reference,
|
|
240
|
+
),
|
|
241
|
+
source_policy=policy.name,
|
|
242
|
+
)
|