File size: 16,650 Bytes
c8fbdf1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
#!/usr/bin/env python3
"""Constraint tracker for cross-turn memory and constraint application.



Detects user-defined constraints (word limits, formatting rules, anchors/phrases)

in turn 1 and enforces them across subsequent turns using LoRA-backed learning.



Example:

    Turn 1: "For this session, keep answers under 15 words and remember the phrase cobalt anchor."

    Turn 2: "What should you remember?"



    Expected response: Should include "cobalt anchor" and be ≤15 words.

"""

from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List


@dataclass
class DetectedConstraint:
    """A parsed constraint from user input."""
    kind: str  # "word_limit", "sentence_limit", "anchor_phrase", "format_rule", etc.
    value: Any  # numeric (word/sentence count) or string (anchor phrase)
    raw_text: str  # original text where constraint was found
    confidence: float = 0.95


@dataclass
class SessionConstraints:
    """Container for all constraints detected in a session."""
    constraints: List[DetectedConstraint] = field(default_factory=list)
    anchor_phrases: List[str] = field(default_factory=list)
    word_limit: Optional[int] = None
    sentence_limit: Optional[int] = None
    format_rules: List[str] = field(default_factory=list)
    detected_at_turn: int = 0

    def to_dict(self) -> Dict[str, Any]:
        """Serialize for session storage."""
        return {
            "anchor_phrases": self.anchor_phrases,
            "word_limit": self.word_limit,
            "sentence_limit": self.sentence_limit,
            "format_rules": self.format_rules,
            "detected_at_turn": self.detected_at_turn,
            "raw_constraints": [
                {
                    "kind": c.kind,
                    "value": c.value,
                    "raw_text": c.raw_text,
                    "confidence": c.confidence
                }
                for c in self.constraints
            ]
        }

    @classmethod
    def from_dict(cls, data: Dict[str, Any]) -> SessionConstraints:
        """Deserialize from session storage."""
        sc = cls()
        sc.anchor_phrases = data.get("anchor_phrases", [])
        sc.word_limit = data.get("word_limit")
        sc.sentence_limit = data.get("sentence_limit")
        sc.format_rules = data.get("format_rules", [])
        sc.detected_at_turn = data.get("detected_at_turn", 0)

        # Reconstruct constraints
        for c in data.get("raw_constraints", []):
            sc.constraints.append(DetectedConstraint(
                kind=c.get("kind"),
                value=c.get("value"),
                raw_text=c.get("raw_text"),
                confidence=c.get("confidence", 0.95)
            ))
        return sc


class ConstraintDetector:
    """Detect constraints from user input."""

    # Patterns for detecting various constraint types
    WORD_LIMIT_PATTERNS = [
        r"keep\s+answers?\s+(?:under|below|within|to)\s+(\d+)\s+words?",
        r"(?:answer|respond)\s+in\s+(?:under|fewer than)\s+(\d+)\s+words?",
        r"(\d+)\s+words?\s+(?:max|maximum|or\s+less)",
        r"limit\s+(?:your\s+)?answers?\s+to\s+(\d+)\s+words?",
    ]

    SENTENCE_LIMIT_PATTERNS = [
        r"keep\s+(?:answers?|responses?)\s+to\s+(\d+)\s+sentences?",
        r"(?:answer|respond)\s+in\s+(\d+)\s+sentences?\s+(?:or\s+less)?",
        r"(\d+)\s+sentences?\s+(?:max|maximum)",
    ]

    ANCHOR_PHRASE_PATTERNS = [
        # Quoted phrases: remember "phrase" or remember the phrase "phrase"
        r"remember\s+(?:the\s+phrase\s+)?['\"]([^'\"]+)['\"]",
        # Unquoted phrase: remember the phrase X (where X doesn't start a new sentence)
        r"remember\s+the\s+phrase\s+([a-z][a-z\s]+?)(?:\s+and\s+|\s+or\s+|\.|\s*$)",
        # Generic remember without phrase keyword
        r"remember\s+['\"]?([a-z][a-z\s]*?)['\"]?(?:\s+(?:and|or)|\.)",
        # use/include/mention with optional quotes
        r"remember\s+(?:to\s+)?(?:use|include|mention)\s+['\"]?([^'\"\.]+?)['\"]?(?:\s|\.)",
        # anchor/key phrase with colon (matches multi-word phrases)
        r"anchor\s*(?:phrase|word|term)?\s*:\s*([a-z][a-z\s]*?)(?:\s*\.|\s*$)",
        r"(?:key\s+phrase):\s+([a-z][a-z\s]*?)(?:\s*\.|\s*$)",
        # ── Informal phrasings ──────────────────────────────────────────
        # "don't forget X" / "don't forget the phrase X"
        r"don'?t\s+forget\s+(?:the\s+(?:phrase|word|term)\s+)?['\"]?([a-z][a-z\s]+?)['\"]?(?:[.,;]|\s+and\s+|\s*$)",
        # "keep in mind X" / "keep in mind the phrase X"
        r"keep\s+in\s+mind\s+(?:the\s+(?:phrase|word|term)\s+)?['\"]?([a-z][a-z\s]+?)['\"]?(?:[.,;]|\s+and\s+|\s*$)",
        # "call it/this X" / "refer to it/this as X"
        r"(?:call\s+(?:it|this)\s+|refer\s+to\s+(?:it|this)\s+as\s+)['\"]?([a-z][a-z\s]+?)['\"]?(?:[.,;]|\s+and\s+|\s*$)",
    ]

    FORMAT_RULE_PATTERNS = [
        r"(use\s+(?:bullet\s+)?points?)",
        r"(format\s+as\s+(?:json|markdown|yaml))",
        # Negated formatting rules — restricted to real formatting targets so
        # ordinary negations ("no word constraint", "no constraints needed",
        # "no problem", "no idea") are NOT captured as constraints.
        r"((?:no|avoid|without|don'?t\s+use|do\s+not\s+use)\s+"
        r"(?:bullet\s*points?|bullets?|numbered\s+lists?|lists?|markdown|json|"
        r"yaml|xml|code\s*blocks?|headers?|headings?|emojis?|emoji|jargon|"
        r"tables?|formatting|prose|paragraphs?))",
    ]

    # Phrases that explicitly DECLINE constraints — when present, the query is
    # asking for NO restrictions, so we must not derive constraints from it.
    CONSTRAINT_NEGATION_PATTERNS = [
        r"\bno\s+(?:word|sentence|length|format(?:ting)?|character)?\s*constraints?\b",
        r"\bno\s+constraints?\s+(?:needed|required|please)\b",
        r"\bno\s+(?:word|character|length)\s+limit\b",
        r"\bwithout\s+(?:any\s+)?constraints?\b",
        r"\bignore\s+(?:the\s+|any\s+|previous\s+)?constraints?\b",
        r"\bno\s+restrictions?\b",
    ]

    def detect(self, query: str, turn_num: int = 1) -> SessionConstraints:
        """Detect all constraints in a query.



        Args:

            query: User input text

            turn_num: Turn number (used to track when constraints were set)



        Returns:

            SessionConstraints with detected constraints

        """
        sc = SessionConstraints(detected_at_turn=turn_num)

        # If the user explicitly declines constraints, derive none from this turn.
        for neg in self.CONSTRAINT_NEGATION_PATTERNS:
            if re.search(neg, query, re.IGNORECASE):
                return sc

        # Detect word limits
        for pattern in self.WORD_LIMIT_PATTERNS:
            match = re.search(pattern, query, re.IGNORECASE)
            if match:
                try:
                    limit = int(match.group(1))
                    sc.word_limit = limit
                    sc.constraints.append(DetectedConstraint(
                        kind="word_limit",
                        value=limit,
                        raw_text=match.group(0),
                        confidence=0.95
                    ))
                    break
                except (ValueError, IndexError):
                    pass

        # Detect sentence limits
        for pattern in self.SENTENCE_LIMIT_PATTERNS:
            match = re.search(pattern, query, re.IGNORECASE)
            if match:
                try:
                    limit = int(match.group(1))
                    sc.sentence_limit = limit
                    sc.constraints.append(DetectedConstraint(
                        kind="sentence_limit",
                        value=limit,
                        raw_text=match.group(0),
                        confidence=0.95
                    ))
                    break
                except (ValueError, IndexError):
                    pass

        # Detect anchor phrases
        for pattern in self.ANCHOR_PHRASE_PATTERNS:
            matches = re.finditer(pattern, query, re.IGNORECASE)
            for match in matches:
                try:
                    phrase = match.group(1).strip()
                    if phrase and len(phrase) > 2:  # At least 3 chars
                        sc.anchor_phrases.append(phrase)
                        sc.constraints.append(DetectedConstraint(
                            kind="anchor_phrase",
                            value=phrase,
                            raw_text=match.group(0),
                            confidence=0.90
                        ))
                except IndexError:
                    pass

        # Detect format rules
        for pattern in self.FORMAT_RULE_PATTERNS:
            matches = re.finditer(pattern, query, re.IGNORECASE)
            for match in matches:
                try:
                    rule = match.group(1).lower().strip()
                    if rule not in sc.format_rules:
                        sc.format_rules.append(rule)
                        sc.constraints.append(DetectedConstraint(
                            kind="format_rule",
                            value=rule,
                            raw_text=match.group(0),
                            confidence=0.85
                        ))
                except IndexError:
                    pass

        return sc


class ConstraintEnforcer:
    """Enforce detected constraints on responses."""

    @staticmethod
    def word_count(text: str) -> int:
        """Count words in text (roughly)."""
        return len([w for w in text.split() if w.strip()])

    @staticmethod
    def sentence_count(text: str) -> int:
        """Count sentences (roughly)."""
        sentences = re.split(r'[.!?]+', text.strip())
        return len([s for s in sentences if s.strip()])

    @staticmethod
    def has_anchor_phrases(text: str, phrases: List[str]) -> bool:
        """Check if all anchor phrases are present."""
        text_lower = text.lower()
        return all(phrase.lower() in text_lower for phrase in phrases)

    @staticmethod
    def build_constraint_reminder(constraints: SessionConstraints) -> str:
        """Build a constraint reminder string for the system prompt."""
        if not constraints.constraints:
            return ""

        lines = ["[SESSION CONSTRAINTS]"]

        if constraints.word_limit:
            lines.append(f"- Keep your response to {constraints.word_limit} words or fewer")

        if constraints.sentence_limit:
            lines.append(f"- Keep your response to {constraints.sentence_limit} sentences or fewer")

        if constraints.anchor_phrases:
            phrases_str = ", ".join(f'"{p}"' for p in constraints.anchor_phrases)
            lines.append(f"- IMPORTANT: Include these anchor phrases in your response: {phrases_str}")

        if constraints.format_rules:
            for rule in constraints.format_rules:
                lines.append(f"- Format: {rule}")

        lines.append("")
        return "\n".join(lines)


class ConstraintTracker:
    """Main tracker for managing constraints across a session."""

    def __init__(self):
        self.detector = ConstraintDetector()
        self.enforcer = ConstraintEnforcer()
        self.session_constraints: Optional[SessionConstraints] = None
        self.turn_count = 0

    def process_turn(self, query: str, is_first_turn: bool = False) -> SessionConstraints:
        """Process a turn and detect/retrieve constraints.



        Always scans the current query for new constraints. On the first turn the

        session constraints are replaced; on subsequent turns newly-found anchors,

        limits, and format rules are merged in without clobbering what was already set.



        Args:

            query: User input

            is_first_turn: Whether this is the first turn (resets constraints)



        Returns:

            SessionConstraints for this turn

        """
        self.turn_count += 1

        if is_first_turn:
            # First turn: full reset — detect fresh from this query
            self.session_constraints = self.detector.detect(query, turn_num=1)
        else:
            # Fast-path: skip regex work entirely when the query has no constraint
            # keywords. "What is the weather?" never contains anchors or limits —
            # the keyword scan is O(n) and avoids 20+ regex compilations per turn.
            _CONSTRAINT_SIGNALS = (
                'remember', 'anchor', 'phrase', 'keyword', 'keep', 'limit',
                'word', 'sentence', 'format', 'avoid', 'under', 'within', 'maximum',
                'forget', 'note', 'call', 'refer',
            )
            q_lower = query.lower()
            if not any(kw in q_lower for kw in _CONSTRAINT_SIGNALS):
                return self.session_constraints or SessionConstraints()

            # Mid-session: detect new constraints and merge (never clobber existing)
            new_sc = self.detector.detect(query, turn_num=self.turn_count)
            if new_sc.constraints:
                if not self.session_constraints:
                    self.session_constraints = new_sc
                else:
                    self._merge_into(new_sc)

        return self.session_constraints or SessionConstraints()

    def _merge_into(self, new_sc: SessionConstraints) -> None:
        """Merge new_sc into self.session_constraints without overwriting set values."""
        sc = self.session_constraints
        for c in new_sc.constraints:
            if c.kind == "anchor_phrase" and c.value not in sc.anchor_phrases:
                sc.anchor_phrases.append(c.value)
                sc.constraints.append(c)
            elif c.kind == "word_limit" and sc.word_limit is None:
                sc.word_limit = c.value
                sc.constraints.append(c)
            elif c.kind == "sentence_limit" and sc.sentence_limit is None:
                sc.sentence_limit = c.value
                sc.constraints.append(c)
            elif c.kind == "format_rule" and c.value not in sc.format_rules:
                sc.format_rules.append(c.value)
                sc.constraints.append(c)

    def get_constraint_reminder(self) -> str:
        """Get the constraint reminder to inject into system prompt."""
        if not self.session_constraints or not self.session_constraints.constraints:
            return ""
        return self.enforcer.build_constraint_reminder(self.session_constraints)

    def check_constraint_compliance(self, response: str) -> Dict[str, Any]:
        """Check if response meets constraints.



        Returns:

            Dict with compliance status and violations.

        """
        if not self.session_constraints or not self.session_constraints.constraints:
            return {"compliant": True, "violations": []}

        violations = []

        if self.session_constraints.word_limit:
            wc = self.enforcer.word_count(response)
            if wc > self.session_constraints.word_limit:
                violations.append({
                    "kind": "word_limit",
                    "expected": self.session_constraints.word_limit,
                    "actual": wc
                })

        if self.session_constraints.sentence_limit:
            sc = self.enforcer.sentence_count(response)
            if sc > self.session_constraints.sentence_limit:
                violations.append({
                    "kind": "sentence_limit",
                    "expected": self.session_constraints.sentence_limit,
                    "actual": sc
                })

        if self.session_constraints.anchor_phrases:
            if not self.enforcer.has_anchor_phrases(response, self.session_constraints.anchor_phrases):
                violations.append({
                    "kind": "missing_anchor_phrases",
                    "expected": self.session_constraints.anchor_phrases
                })

        return {
            "compliant": len(violations) == 0,
            "violations": violations
        }

    def reset(self):
        """Reset tracker for new session."""
        self.session_constraints = None
        self.turn_count = 0