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/policies.py ADDED
@@ -0,0 +1,295 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+ from .intent import IntentState
7
+
8
+
9
+ class Register(Enum):
10
+ MIRROR = "mirror"
11
+ VALIDATE_REDIRECT = "validate_redirect"
12
+ DIRECT = "direct"
13
+ ASK = "ask"
14
+ DEFLATE = "deflate"
15
+ REPAIR = "repair"
16
+ HOLD = "hold"
17
+ BOUNDARY = "boundary"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class AffectState:
22
+ valence: float
23
+ arousal: float
24
+ escalation_delta: float
25
+ stance: str
26
+ subtext: str
27
+ turn_index: int = 0
28
+ history: tuple["AffectState", ...] = ()
29
+
30
+ @property
31
+ def escalating(self) -> bool:
32
+ return self.escalation_delta > 0.15
33
+
34
+ @property
35
+ def escalating_past_n(self) -> bool:
36
+ """Three consecutive turns before this one that all moved upward.
37
+
38
+ The window deliberately excludes the current turn. Snapshot
39
+ classification cannot see this: one polite message after three bad
40
+ ones reads as calm, and that is exactly when the register must not
41
+ return to warm."""
42
+ recent = self.history[-4:-1]
43
+ return len(recent) >= 3 and all(s.escalation_delta > 0.05 for s in recent)
44
+
45
+ @property
46
+ def user_is_right(self) -> bool:
47
+ return self.stance == "user_right"
48
+
49
+ @property
50
+ def user_is_wrong(self) -> bool:
51
+ return self.stance == "user_wrong"
52
+
53
+ @property
54
+ def stance_unknown(self) -> bool:
55
+ return self.stance == "unknown"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Policy:
60
+ name: str
61
+ register: Register
62
+ summary: str
63
+ preconditions: tuple[str, ...]
64
+ priority: int = 0
65
+ avoid: tuple[str, ...] = ()
66
+ example_opener: str = ""
67
+
68
+ @property
69
+ def specificity(self) -> int:
70
+ """How many conditions must hold. Selection is not first-match:
71
+ the most constrained applicable policy wins, so adding a policy can
72
+ never silently change what a looser one does."""
73
+ return len(self.preconditions)
74
+
75
+ def applies(self, state: AffectState, intent: IntentState | None = None) -> bool:
76
+ return all(PREDICATES[name](state, intent) for name in self.preconditions)
77
+
78
+
79
+ PREDICATES = {
80
+ "any": lambda s, i: True,
81
+ "escalating": lambda s, i: s.escalating,
82
+ "escalating_past_n": lambda s, i: s.escalating_past_n,
83
+ "not_escalating": lambda s, i: not s.escalating,
84
+ "not_escalating_past_n": lambda s, i: not s.escalating_past_n,
85
+ "user_is_right": lambda s, i: s.user_is_right,
86
+ "user_is_wrong": lambda s, i: s.user_is_wrong,
87
+ "stance_unknown": lambda s, i: s.stance_unknown,
88
+ "low_arousal": lambda s, i: s.arousal < 0.4,
89
+ "high_arousal": lambda s, i: s.arousal >= 0.5,
90
+ "high_arousal_run": lambda s, i: len(s.history) >= 2 and all(
91
+ x.arousal >= 0.5 for x in s.history[-2:]
92
+ ),
93
+ "not_high_arousal_run": lambda s, i: not (
94
+ len(s.history) >= 2 and all(x.arousal >= 0.5 for x in s.history[-2:])
95
+ ),
96
+ }
97
+
98
+ SUBTEXTS = (
99
+ "correction",
100
+ "disclosure_request",
101
+ "exhaustion",
102
+ "resignation",
103
+ "escalating",
104
+ "demand",
105
+ "challenge",
106
+ "question",
107
+ "statement",
108
+ )
109
+
110
+ for _subtext in SUBTEXTS:
111
+ PREDICATES[f"subtext:{_subtext}"] = (
112
+ lambda t: lambda s, i: s.subtext == t
113
+ )(_subtext)
114
+
115
+ PREDICATES["subtext_set:drained"] = lambda s, i: s.subtext in {"exhaustion", "resignation"}
116
+ PREDICATES["subtext_set:plain"] = lambda s, i: s.subtext in {"question", "statement"}
117
+ PREDICATES["intent:explicit"] = lambda s, i: i is not None and i.explicit
118
+ PREDICATES["intent:clarify"] = lambda s, i: i is not None and i.needs_clarification
119
+ PREDICATES["intent:action_request"] = lambda s, i: i is not None and i.kind == "action_request"
120
+ PREDICATES["intent:status_check"] = lambda s, i: i is not None and i.kind == "status_check"
121
+ PREDICATES["intent:explanation"] = lambda s, i: i is not None and i.kind == "explanation"
122
+
123
+
124
+ POLICIES: tuple[Policy, ...] = (
125
+ Policy(
126
+ name="execute_request",
127
+ register=Register.DIRECT,
128
+ summary="Act on the explicit request first. Do not paraphrase it back instead of doing it.",
129
+ preconditions=("intent:action_request", "intent:explicit", "subtext_set:plain", "not_escalating_past_n", "not_high_arousal_run"),
130
+ avoid=("Restating the request as if that were progress.", "Inventing missing details."),
131
+ example_opener="İstenen işlemi yap ve sonucu doğrudan bildir.",
132
+ ),
133
+ Policy(
134
+ name="report_status",
135
+ register=Register.DIRECT,
136
+ summary="Check the current state and report the observed result, not a guess.",
137
+ preconditions=("intent:status_check", "intent:explicit", "subtext_set:plain", "not_escalating_past_n", "not_high_arousal_run"),
138
+ avoid=("Guessing the status.", "Repeating the plan instead of checking."),
139
+ example_opener="Güncel durum: kontrol edilen sonuç burada.",
140
+ ),
141
+ Policy(
142
+ name="explain_request",
143
+ register=Register.DIRECT,
144
+ summary="Explain the requested point directly, preserving the user's actual question.",
145
+ preconditions=("intent:explanation", "intent:explicit", "subtext_set:plain", "not_escalating_past_n", "not_high_arousal_run"),
146
+ avoid=("Turning an explanation request into emotional mirroring.",),
147
+ example_opener="Nedeni şu:",
148
+ ),
149
+ Policy(
150
+ name="clarify_request",
151
+ register=Register.ASK,
152
+ summary="The requested action is not identifiable. Ask one question instead of guessing.",
153
+ preconditions=("intent:clarify", "subtext_set:plain", "not_escalating", "not_escalating_past_n"),
154
+ avoid=("Guessing what an unresolved pronoun refers to.", "Question stacking."),
155
+ example_opener="Tam olarak hangi şeyi kastediyorsun?",
156
+ ),
157
+ Policy(
158
+ name="mirror_specific",
159
+ register=Register.MIRROR,
160
+ summary="Name the concrete thing the user said, not a category of difficulty.",
161
+ preconditions=("subtext:statement", "not_escalating", "not_escalating_past_n"),
162
+ avoid=("That sounds really tough.", "I understand how you feel."),
163
+ example_opener="Randevunuz üç gündür iki saat ileriye alınıyor, sonra da kırk dakikalık yol var.",
164
+ ),
165
+ Policy(
166
+ name="validate_then_redirect",
167
+ register=Register.VALIDATE_REDIRECT,
168
+ summary="The user is right and heated: acknowledge, then move to one concrete step.",
169
+ preconditions=("subtext:challenge", "user_is_right"),
170
+ avoid=("Validating and stopping there.", "Apologising again."),
171
+ example_opener="Rehber değişti, üstüne müdahale edildi. Sonraki adım: yeni metni karşılaştırıp farkı tek cümlede yazalım.",
172
+ ),
173
+ Policy(
174
+ name="deflate_tension",
175
+ register=Register.DEFLATE,
176
+ summary="Stacked demands: lower the temperature, do not lower the seriousness.",
177
+ preconditions=("subtext:demand",),
178
+ avoid=("Jokes that minimise a real problem.", "Answering a different demand than the one asked."),
179
+ example_opener="Üç şey soruyorsun, tek tek gideyim. Önce şu: değişen kısım hangi satır?",
180
+ ),
181
+ Policy(
182
+ name="direct_no_padding",
183
+ register=Register.DIRECT,
184
+ summary="No warmth before the answer when the user asked a plain question.",
185
+ preconditions=("subtext:question", "not_escalating"),
186
+ avoid=("Opening with empathy before the answer.", "Restating the question."),
187
+ example_opener="Hayır, o dosya senin durumunda çalışmaz.",
188
+ ),
189
+ Policy(
190
+ name="ask_one_question",
191
+ register=Register.ASK,
192
+ summary=(
193
+ "After a correction, apply an explicit replacement immediately and resume "
194
+ "the prior task. Ask at most one targeted question only when the replacement "
195
+ "is missing or ambiguous."
196
+ ),
197
+ preconditions=("subtext:correction",),
198
+ avoid=(
199
+ "Asking the user to repeat or reconfirm a replacement they already supplied.",
200
+ "Three or more questions.",
201
+ "Questions stacked into an interrogation.",
202
+ ),
203
+ example_opener="Düzeltmeyi aldım; verdiğin yeni değeri önceki adıma uyguluyorum.",
204
+ ),
205
+ Policy(
206
+ name="repair_hold_position",
207
+ register=Register.REPAIR,
208
+ summary="Pushed repeatedly: keep the position. Neither capitulate nor argue.",
209
+ preconditions=("subtext:challenge", "stance_unknown"),
210
+ avoid=("Total capitulation.", "Defending why you were right.", "Re-explaining the same thing."),
211
+ example_opener="İtirazını gördüm. Önerim yine aynı; gerekçesi değişmedi.",
212
+ ),
213
+ Policy(
214
+ name="repair_interrogation",
215
+ register=Register.REPAIR,
216
+ summary="The same question three turns running is not curiosity. Stop restating.",
217
+ preconditions=("subtext:escalating",),
218
+ avoid=("A fourth restatement of the explanation.", "Asking what the user actually needs."),
219
+ example_opener="Üç kez aynı şeyi sordun. Sebebi şu ve değişmiyor: geçersiz tarih.",
220
+ ),
221
+ Policy(
222
+ name="hold",
223
+ register=Register.HOLD,
224
+ summary="Energy is spent rather than rising. Say less.",
225
+ preconditions=("subtext_set:drained",),
226
+ avoid=("Continuing to generate reassurance.", "Cheerfulness.",),
227
+ example_opener="Tamam. Düşüneyim.",
228
+ ),
229
+ Policy(
230
+ name="boundary",
231
+ register=Register.BOUNDARY,
232
+ summary="Set a privacy boundary without pretending the system can guarantee confidentiality.",
233
+ preconditions=("subtext:disclosure_request",),
234
+ avoid=("Lecturing.", "Promising absolute secrecy or confidentiality."),
235
+ example_opener="Bunu gereksiz yere tekrarlamam; ama mutlak gizlilik garantisi veremem.",
236
+ ),
237
+ Policy(
238
+ name="hold_sustained_escalation",
239
+ register=Register.HOLD,
240
+ summary="Three rising turns then a calm one: the calm turn is not a reason to warm back up.",
241
+ preconditions=("escalating_past_n", "not_escalating"),
242
+ avoid=("Returning to a warm register because the last message was polite.",),
243
+ example_opener="Buradayım.",
244
+ ),
245
+ )
246
+
247
+ REGISTRY: dict[str, Policy] = {p.name: p for p in POLICIES}
248
+
249
+
250
+ @dataclass
251
+ class Selection:
252
+ policy: Policy
253
+ state: AffectState
254
+ intent: IntentState | None = None
255
+ rationale: str = ""
256
+ runner_up: tuple[str, ...] = ()
257
+
258
+
259
+ @dataclass
260
+ class Selector:
261
+ overrides: dict[str, str] = field(default_factory=dict)
262
+ default: str = "mirror_specific"
263
+
264
+ def select(self, state: AffectState, intent: IntentState | None = None) -> Selection:
265
+ forced = self.overrides.get(state.subtext)
266
+ if forced:
267
+ return Selection(policy=REGISTRY[forced], state=state, intent=intent, rationale=f"override:{forced}")
268
+
269
+ applicable = [p for p in POLICIES if p.applies(state, intent)]
270
+ if not applicable:
271
+ return Selection(policy=REGISTRY[self.default], state=state, intent=intent, rationale="default")
272
+
273
+ best_specificity = max(p.specificity for p in applicable)
274
+ specific = [p for p in applicable if p.specificity == best_specificity]
275
+ best_priority = max(p.priority for p in specific)
276
+ winners = [p for p in specific if p.priority == best_priority]
277
+
278
+ if len(winners) != 1:
279
+ names = ", ".join(sorted(p.name for p in winners))
280
+ raise ValueError(
281
+ "Ambiguous policy selection: equal specificity and priority "
282
+ f"for {names}. Assign an explicit priority or refine preconditions."
283
+ )
284
+
285
+ winner = winners[0]
286
+ return Selection(
287
+ policy=winner,
288
+ state=state,
289
+ intent=intent,
290
+ rationale=(
291
+ f"specificity={best_specificity} priority={best_priority} "
292
+ f"matched={','.join(p.name for p in applicable)}"
293
+ ),
294
+ runner_up=tuple(p.name for p in applicable if p is not winner),
295
+ )
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import asdict, dataclass, replace
5
+
6
+
7
+ SESSION_SCOPE_PATTERNS = (
8
+ r"\bbundan sonra\b",
9
+ r"\bbu sohbet(?:te| boyunca)\b",
10
+ r"\bher zaman\b",
11
+ r"\bdaima\b",
12
+ r"\bfrom now on\b",
13
+ r"\bfor this conversation\b",
14
+ r"\bthroughout this conversation\b",
15
+ r"\balways\b",
16
+ )
17
+
18
+ STRONG_TURKISH_STYLE_PATTERN = re.compile(
19
+ r"\bbenimle\b.*\bkonuş\b",
20
+ re.IGNORECASE,
21
+ )
22
+ STRONG_ENGLISH_STYLE_PATTERN = re.compile(
23
+ r"\b(?:keep|make)\s+(?:your\s+)?responses?\b|\brespond\b.*\b(?:brief|concise|direct)\b",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SessionPreferences:
30
+ """Explicit session-scoped preferences, separate from affect.
31
+
32
+ The state is recomputed from the visible transcript. It does not infer
33
+ personality, emotional traits, or cross-session user properties.
34
+ """
35
+
36
+ verbosity: str | None = None
37
+ directness: str | None = None
38
+ no_guess: bool = False
39
+ scope_limited: bool = False
40
+ source_turns: tuple[int, ...] = ()
41
+ evidence: tuple[str, ...] = ()
42
+
43
+ def to_dict(self) -> dict:
44
+ return asdict(self)
45
+
46
+
47
+ def _normalise(text: str) -> str:
48
+ return " ".join(text.lower().split())
49
+
50
+
51
+ def _session_scoped(text: str) -> bool:
52
+ low = _normalise(text)
53
+ if any(re.search(pattern, low) for pattern in SESSION_SCOPE_PATTERNS):
54
+ return True
55
+ return bool(
56
+ STRONG_TURKISH_STYLE_PATTERN.search(low)
57
+ or STRONG_ENGLISH_STYLE_PATTERN.search(low)
58
+ )
59
+
60
+
61
+ def _updates(text: str) -> dict:
62
+ low = _normalise(text)
63
+ updates: dict[str, object] = {}
64
+
65
+ if re.search(r"\b(kısa|öz|brief|concise)\b", low):
66
+ updates["verbosity"] = "low"
67
+ if re.search(r"\b(detaylı|ayrıntılı|detailed)\b|\bin detail\b", low):
68
+ updates["verbosity"] = "high"
69
+
70
+ if re.search(r"\b(net|direkt|doğrudan|direct)\b|\bstraight to the point\b", low):
71
+ updates["directness"] = "high"
72
+
73
+ if (
74
+ "tahmin etme" in low
75
+ or "uydurma" in low
76
+ or "don't guess" in low
77
+ or "do not guess" in low
78
+ ):
79
+ updates["no_guess"] = True
80
+
81
+ if (
82
+ "sadece sorduğuma cevap ver" in low
83
+ or "sadece istediğimi yap" in low
84
+ or "only answer what i ask" in low
85
+ or "only do what i ask" in low
86
+ or "stay within scope" in low
87
+ ):
88
+ updates["scope_limited"] = True
89
+
90
+ return updates
91
+
92
+
93
+ def infer_session_preferences(messages: list[dict]) -> SessionPreferences:
94
+ """Infer only explicit, session-scoped style/control preferences.
95
+
96
+ Later explicit session preferences override earlier conflicting values.
97
+ Ordinary one-turn constraints are intentionally not persisted.
98
+ """
99
+
100
+ state = SessionPreferences()
101
+ source_turns: list[int] = []
102
+ evidence: list[str] = []
103
+
104
+ for index, message in enumerate(messages):
105
+ if message.get("role") != "user":
106
+ continue
107
+
108
+ text = str(message.get("content", "")).strip()
109
+ if not text or not _session_scoped(text):
110
+ continue
111
+
112
+ updates = _updates(text)
113
+ if not updates:
114
+ continue
115
+
116
+ state = replace(state, **updates)
117
+ source_turns.append(index)
118
+ for key, value in updates.items():
119
+ evidence.append(f"turn:{index}:{key}={value}")
120
+
121
+ return replace(
122
+ state,
123
+ source_turns=tuple(source_turns),
124
+ evidence=tuple(evidence),
125
+ )