arkaos 2.31.0 → 2.40.0
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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +76 -0
- package/arka/skills/checkpoint/SKILL.md +79 -0
- package/config/constitution.yaml +193 -0
- package/config/hooks/pre-tool-use.sh +18 -2
- package/config/hooks/session-start.sh +7 -0
- package/config/hooks/stop.sh +72 -0
- package/config/hooks/user-prompt-submit.sh +25 -0
- package/core/cognition/__pycache__/dreaming.cpython-313.pyc +0 -0
- package/core/cognition/__pycache__/dreams_reader.cpython-313.pyc +0 -0
- package/core/cognition/scheduler/__pycache__/daemon.cpython-313.pyc +0 -0
- package/core/governance/__init__.py +7 -1
- package/core/governance/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/dod_gate.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/kb_cite_check.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/learning_detector.cpython-313.pyc +0 -0
- package/core/governance/__pycache__/sycophancy_detector.cpython-313.pyc +0 -0
- package/core/governance/dod_gate.py +192 -0
- package/core/governance/kb_cite_check.py +129 -0
- package/core/governance/learning_detector.py +221 -0
- package/core/governance/sycophancy_detector.py +169 -0
- package/core/orchestration/__pycache__/checkpoint.cpython-313.pyc +0 -0
- package/core/orchestration/checkpoint.py +157 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_provider.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/flow_enforcer.py +144 -3
- package/knowledge/commands-registry.json +1 -1
- package/package.json +2 -2
- package/pyproject.toml +1 -1
- package/knowledge/commands-registry.json.bak +0 -2791
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Definition of Done gate for ArkaOS deliveries (PR14 v2.36.0).
|
|
2
|
+
|
|
3
|
+
Implements the `definition-of-done-per-domain` NON-NEGOTIABLE rule from
|
|
4
|
+
PR10 constitution. Loads the per-domain checklists from
|
|
5
|
+
``config/constitution.yaml`` (`definition_of_done` section) and evaluates
|
|
6
|
+
an agent-supplied status report against them.
|
|
7
|
+
|
|
8
|
+
The gate does NOT execute the underlying checks itself (e.g. it does not
|
|
9
|
+
run Playwright or lint). It validates that the agent has reported a
|
|
10
|
+
status for each item in the relevant domain — passed, skipped, failed,
|
|
11
|
+
or not-applicable — and produces a verdict.
|
|
12
|
+
|
|
13
|
+
Hard items: ALL must be ``passed`` for the verdict to be APPROVED.
|
|
14
|
+
Soft items: may be ``skipped`` without rejection, but a ``failed``
|
|
15
|
+
soft item is still recorded.
|
|
16
|
+
|
|
17
|
+
This mirrors the audit pattern used by Marta in QG: the agent presents
|
|
18
|
+
its work + status claims, and the gate verifies completeness against
|
|
19
|
+
the canonical checklist. False reporting is a different problem (the
|
|
20
|
+
sycophancy detector + critic pass in QG catch that).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Literal
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
|
|
31
|
+
ItemStatus = Literal["passed", "skipped", "failed", "not-applicable"]
|
|
32
|
+
|
|
33
|
+
_CONSTITUTION_PATH = (
|
|
34
|
+
Path(__file__).resolve().parents[2] / "config" / "constitution.yaml"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_VALID_STATUSES: frozenset[str] = frozenset({
|
|
38
|
+
"passed", "skipped", "failed", "not-applicable",
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class DODItem:
|
|
44
|
+
"""One Definition-of-Done checklist item from the constitution."""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
rule: str
|
|
48
|
+
hard: bool
|
|
49
|
+
conditional: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class DODVerdict:
|
|
54
|
+
"""Structured outcome of a DOD evaluation."""
|
|
55
|
+
|
|
56
|
+
domain: str
|
|
57
|
+
approved: bool
|
|
58
|
+
failed_hard_items: list[str] = field(default_factory=list)
|
|
59
|
+
failed_soft_items: list[str] = field(default_factory=list)
|
|
60
|
+
skipped_hard_items: list[str] = field(default_factory=list)
|
|
61
|
+
skipped_soft_items: list[str] = field(default_factory=list)
|
|
62
|
+
unreported_hard_items: list[str] = field(default_factory=list)
|
|
63
|
+
unreported_soft_items: list[str] = field(default_factory=list)
|
|
64
|
+
not_applicable: list[str] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return asdict(self)
|
|
68
|
+
|
|
69
|
+
def summary(self) -> str:
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
if self.approved:
|
|
72
|
+
parts.append("APPROVED")
|
|
73
|
+
else:
|
|
74
|
+
parts.append("REJECTED")
|
|
75
|
+
if self.failed_hard_items:
|
|
76
|
+
parts.append(f"failed-hard={','.join(self.failed_hard_items)}")
|
|
77
|
+
if self.unreported_hard_items:
|
|
78
|
+
parts.append(f"unreported-hard={','.join(self.unreported_hard_items)}")
|
|
79
|
+
if self.skipped_hard_items:
|
|
80
|
+
parts.append(f"skipped-hard={','.join(self.skipped_hard_items)}")
|
|
81
|
+
return " | ".join(parts)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_definition_of_done(
|
|
85
|
+
domain: str, constitution_path: Path | None = None
|
|
86
|
+
) -> list[DODItem]:
|
|
87
|
+
"""Return the DOD items for *domain* (universal items + domain-specific).
|
|
88
|
+
|
|
89
|
+
``domain`` is one of: ``frontend``, ``backend``, ``content``. Other
|
|
90
|
+
names raise ``ValueError`` (no implicit fallback — agents must opt
|
|
91
|
+
in to a known domain). Universal items are always merged in front
|
|
92
|
+
of the domain-specific items.
|
|
93
|
+
"""
|
|
94
|
+
path = constitution_path or _CONSTITUTION_PATH
|
|
95
|
+
with open(path, encoding="utf-8") as fh:
|
|
96
|
+
data = yaml.safe_load(fh)
|
|
97
|
+
dod = data.get("definition_of_done") or {}
|
|
98
|
+
if domain not in dod or domain == "description" or domain == "universal":
|
|
99
|
+
valid = [
|
|
100
|
+
k for k in dod.keys()
|
|
101
|
+
if k not in {"description", "universal"}
|
|
102
|
+
]
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"unknown DOD domain {domain!r}; choose from {sorted(valid)}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
items: list[DODItem] = []
|
|
108
|
+
for raw in (dod.get("universal", {}).get("items") or []):
|
|
109
|
+
items.append(_to_item(raw))
|
|
110
|
+
for raw in (dod[domain].get("items") or []):
|
|
111
|
+
items.append(_to_item(raw))
|
|
112
|
+
return items
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def evaluate_dod(
|
|
116
|
+
domain: str,
|
|
117
|
+
item_statuses: dict[str, str],
|
|
118
|
+
constitution_path: Path | None = None,
|
|
119
|
+
) -> DODVerdict:
|
|
120
|
+
"""Validate *item_statuses* against the DOD checklist for *domain*.
|
|
121
|
+
|
|
122
|
+
Inputs:
|
|
123
|
+
domain — one of "frontend", "backend", "content"
|
|
124
|
+
item_statuses — mapping {item_id: "passed"|"skipped"|"failed"|
|
|
125
|
+
"not-applicable"} reported by the agent
|
|
126
|
+
constitution_path — optional override (test injection)
|
|
127
|
+
|
|
128
|
+
Returns a :class:`DODVerdict`. Verdict is APPROVED iff:
|
|
129
|
+
- every hard item is reported AND status == "passed"
|
|
130
|
+
(or "not-applicable" with explicit justification — recorded
|
|
131
|
+
but does not block)
|
|
132
|
+
|
|
133
|
+
Soft items: never block. Their statuses are recorded for telemetry.
|
|
134
|
+
Unknown status values raise ValueError.
|
|
135
|
+
"""
|
|
136
|
+
items = load_definition_of_done(domain, constitution_path)
|
|
137
|
+
for status in item_statuses.values():
|
|
138
|
+
if status not in _VALID_STATUSES:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"invalid status {status!r}; must be one of {sorted(_VALID_STATUSES)}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
verdict = DODVerdict(domain=domain, approved=True)
|
|
144
|
+
for item in items:
|
|
145
|
+
reported = item_statuses.get(item.id)
|
|
146
|
+
if reported is None:
|
|
147
|
+
if item.hard:
|
|
148
|
+
verdict.unreported_hard_items.append(item.id)
|
|
149
|
+
verdict.approved = False
|
|
150
|
+
else:
|
|
151
|
+
verdict.unreported_soft_items.append(item.id)
|
|
152
|
+
continue
|
|
153
|
+
if reported == "passed":
|
|
154
|
+
continue
|
|
155
|
+
if reported == "not-applicable":
|
|
156
|
+
verdict.not_applicable.append(item.id)
|
|
157
|
+
continue
|
|
158
|
+
if reported == "skipped":
|
|
159
|
+
if item.hard:
|
|
160
|
+
verdict.skipped_hard_items.append(item.id)
|
|
161
|
+
verdict.approved = False
|
|
162
|
+
else:
|
|
163
|
+
verdict.skipped_soft_items.append(item.id)
|
|
164
|
+
continue
|
|
165
|
+
if reported == "failed":
|
|
166
|
+
if item.hard:
|
|
167
|
+
verdict.failed_hard_items.append(item.id)
|
|
168
|
+
verdict.approved = False
|
|
169
|
+
else:
|
|
170
|
+
verdict.failed_soft_items.append(item.id)
|
|
171
|
+
return verdict
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def list_supported_domains(constitution_path: Path | None = None) -> list[str]:
|
|
175
|
+
"""Enumerate the domains the constitution defines DOD checklists for."""
|
|
176
|
+
path = constitution_path or _CONSTITUTION_PATH
|
|
177
|
+
with open(path, encoding="utf-8") as fh:
|
|
178
|
+
data = yaml.safe_load(fh)
|
|
179
|
+
dod = data.get("definition_of_done") or {}
|
|
180
|
+
return sorted(
|
|
181
|
+
k for k in dod.keys()
|
|
182
|
+
if k not in {"description", "universal"}
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _to_item(raw: dict) -> DODItem:
|
|
187
|
+
return DODItem(
|
|
188
|
+
id=str(raw["id"]),
|
|
189
|
+
rule=str(raw["rule"]),
|
|
190
|
+
hard=bool(raw.get("hard", True)),
|
|
191
|
+
conditional=raw.get("conditional"),
|
|
192
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""KB citation check for ArkaOS responses (PR18 v2.40.0).
|
|
2
|
+
|
|
3
|
+
Soft-block classifier. Inspects an assistant response for KB
|
|
4
|
+
citations (`[[wikilink]]`, `[knowledge:` marker, `file.ext:line`
|
|
5
|
+
code references) and reports whether the response honored the
|
|
6
|
+
KB-first contract on an ArkaOS topic.
|
|
7
|
+
|
|
8
|
+
Non-blocking by design — hooks consume CitationResult and decide
|
|
9
|
+
whether to surface a nudge to the next turn's additionalContext.
|
|
10
|
+
This module never raises; on malformed input it returns a passed
|
|
11
|
+
"trivial" verdict so the caller never crashes the hook pipeline.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
# ─── Patterns ───────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
_WIKILINK_PATTERN: re.Pattern[str] = re.compile(r"\[\[([^\[\]]+)\]\]")
|
|
22
|
+
_KNOWLEDGE_PATTERN: re.Pattern[str] = re.compile(r"\[knowledge:", re.IGNORECASE)
|
|
23
|
+
# File:line — requires at least one path separator. Quantifiers are bounded
|
|
24
|
+
# to prevent catastrophic backtracking on pathological input (PR18 security
|
|
25
|
+
# review: unbounded variant blew past Stop's 5s budget by ~8x on 100KB of
|
|
26
|
+
# `a/a/a/...` with no trailing extension).
|
|
27
|
+
_FILE_LINE_PATTERN: re.Pattern[str] = re.compile(
|
|
28
|
+
r"(?:[\w._-]{1,64}/){1,8}[\w._-]{1,64}\.\w{1,8}:\d{1,6}"
|
|
29
|
+
)
|
|
30
|
+
# Hard upper bound on input scanned for citations. Anything beyond is
|
|
31
|
+
# almost certainly a code dump / log paste and not human prose — skip the
|
|
32
|
+
# more expensive file-line regex entirely above this threshold.
|
|
33
|
+
_MAX_SCAN_CHARS: int = 50_000
|
|
34
|
+
|
|
35
|
+
# ArkaOS topic keywords. Match is case-insensitive substring.
|
|
36
|
+
_ARKAOS_KEYWORDS: frozenset[str] = frozenset({
|
|
37
|
+
"arkaos", "arka", "constitution", "quality gate", "synapse",
|
|
38
|
+
"conclave", "forge", "dreaming", "marta", "eduardo", "francisca",
|
|
39
|
+
"marco", "helena", "sofia", "paulo", "luna", "valentina",
|
|
40
|
+
"tomas", "ricardo", "clara", "daniel", "carolina", "tiago",
|
|
41
|
+
"ines", "rafael", "beatriz", "miguel", "rodrigo",
|
|
42
|
+
"non-negotiable", "tier 0", "squad lead",
|
|
43
|
+
"core/governance", "core/synapse", "core/cognition",
|
|
44
|
+
"core/workflow", "mcp__obsidian", "kb-first",
|
|
45
|
+
"departments/", "agent yaml", "obsidian vault",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
_BYPASS_DEFAULTS: tuple[str, ...] = ("[arka:trivial]",)
|
|
49
|
+
_TRIVIAL_WORD_THRESHOLD: int = 15
|
|
50
|
+
_TOPIC_THRESHOLD: float = 0.4
|
|
51
|
+
_SUGGESTION_TEXT: str = (
|
|
52
|
+
"KB-first — last response had no citation on ArkaOS topic. "
|
|
53
|
+
"Use @[[note-name]], /kb search, or mcp__obsidian__search_notes "
|
|
54
|
+
"to ground the next answer."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ─── Result ─────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class CitationResult:
|
|
63
|
+
"""Verdict of a citation check. Immutable; safe to log as JSON."""
|
|
64
|
+
passed: bool
|
|
65
|
+
reason: str
|
|
66
|
+
suggestion: str | None
|
|
67
|
+
citation_count: int
|
|
68
|
+
topic_score: float
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ─── Public API ─────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def check_citation(
|
|
75
|
+
response_text: str,
|
|
76
|
+
*,
|
|
77
|
+
topic_keywords: frozenset[str] | None = None,
|
|
78
|
+
bypass_markers: tuple[str, ...] = _BYPASS_DEFAULTS,
|
|
79
|
+
) -> CitationResult:
|
|
80
|
+
"""Classify an assistant response for KB citation discipline."""
|
|
81
|
+
text = response_text or ""
|
|
82
|
+
citation_count = _count_citations(text)
|
|
83
|
+
topic_score = _compute_topic_score(text, topic_keywords or _ARKAOS_KEYWORDS)
|
|
84
|
+
|
|
85
|
+
if _has_bypass_marker(text, bypass_markers):
|
|
86
|
+
return CitationResult(True, "trivial", None, citation_count, topic_score)
|
|
87
|
+
|
|
88
|
+
if _is_trivial_length(text):
|
|
89
|
+
return CitationResult(True, "trivial", None, citation_count, topic_score)
|
|
90
|
+
|
|
91
|
+
if citation_count > 0:
|
|
92
|
+
return CitationResult(True, "cited", None, citation_count, topic_score)
|
|
93
|
+
|
|
94
|
+
if topic_score < _TOPIC_THRESHOLD:
|
|
95
|
+
return CitationResult(True, "off-topic", None, 0, topic_score)
|
|
96
|
+
|
|
97
|
+
return CitationResult(False, "missing", _SUGGESTION_TEXT, 0, topic_score)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ─── Helpers (private) ──────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _has_bypass_marker(text: str, markers: tuple[str, ...]) -> bool:
|
|
104
|
+
return any(marker in text for marker in markers)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_trivial_length(text: str) -> bool:
|
|
108
|
+
return len(text.split()) < _TRIVIAL_WORD_THRESHOLD
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _count_citations(text: str) -> int:
|
|
112
|
+
# Truncate to _MAX_SCAN_CHARS first; the file-line regex is the only
|
|
113
|
+
# backtracking-prone pattern and the bounded quantifiers (capped at 8
|
|
114
|
+
# segments × 64 chars each) combined with this slice make all three
|
|
115
|
+
# patterns safe to run unconditionally below.
|
|
116
|
+
scan = text if len(text) <= _MAX_SCAN_CHARS else text[:_MAX_SCAN_CHARS]
|
|
117
|
+
wikilinks = len(_WIKILINK_PATTERN.findall(scan))
|
|
118
|
+
knowledge = 1 if _KNOWLEDGE_PATTERN.search(scan) else 0
|
|
119
|
+
files = len(_FILE_LINE_PATTERN.findall(scan))
|
|
120
|
+
return wikilinks + knowledge + files
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _compute_topic_score(text: str, keywords: frozenset[str]) -> float:
|
|
124
|
+
if not keywords:
|
|
125
|
+
return 0.0
|
|
126
|
+
lower = text.lower()
|
|
127
|
+
hits = sum(1 for k in keywords if k.lower() in lower)
|
|
128
|
+
denom = max(1, len(keywords) // 10)
|
|
129
|
+
return min(1.0, hits / denom)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Correction-signal detector for ArkaOS hybrid learning (PR16 v2.38.0).
|
|
2
|
+
|
|
3
|
+
Implements the `hybrid-learning` NON-NEGOTIABLE rule from PR10
|
|
4
|
+
constitution. The detector scans a user message for signs that the user
|
|
5
|
+
is establishing a permanent rule (rather than a one-off comment), and
|
|
6
|
+
classifies the signal so the orchestrator can:
|
|
7
|
+
|
|
8
|
+
* IMPLICIT — low-leverage correction, auto-save with low confidence
|
|
9
|
+
* EXPLICIT — high-leverage correction, surface Marta confirmation
|
|
10
|
+
question before saving
|
|
11
|
+
|
|
12
|
+
The detector is heuristic (regex + magnitude). It never writes memory
|
|
13
|
+
files itself — it only emits a structured verdict the Stop hook records
|
|
14
|
+
and the orchestrator acts upon.
|
|
15
|
+
|
|
16
|
+
Conclave 2026-05-13 brainstorm rule:
|
|
17
|
+
* Implicit auto-detect with confidence scoring (default)
|
|
18
|
+
* Explicit Marta-led confirmation for high-leverage rules
|
|
19
|
+
* Marta is the owner of the learning loop
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
CorrectionMode = Literal["explicit", "implicit", "none"]
|
|
29
|
+
CorrectionKind = Literal["rule", "preference", "one-off", "none"]
|
|
30
|
+
|
|
31
|
+
# Absolute-language cues — strong signal that user is asserting a rule.
|
|
32
|
+
_ABSOLUTE_CUES: tuple[re.Pattern, ...] = tuple(
|
|
33
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
34
|
+
r"\bsempre\b",
|
|
35
|
+
r"\bnunca\b",
|
|
36
|
+
r"\btodas?\s+as?\s+vezes\b",
|
|
37
|
+
r"\bem\s+todos?\s+os?\s+casos\b",
|
|
38
|
+
r"\bsem\s+exce[cç][oõ]es\b",
|
|
39
|
+
r"\bno\s+exceptions\b",
|
|
40
|
+
r"\balways\b",
|
|
41
|
+
r"\bnever\b",
|
|
42
|
+
r"\bevery\s+time\b",
|
|
43
|
+
r"\bin\s+all\s+cases\b",
|
|
44
|
+
r"\bNON-NEGOTIABLE\b",
|
|
45
|
+
r"\bn[aã]o[\s-]+negoci[aá]vel\b",
|
|
46
|
+
r"\bmandatory\b",
|
|
47
|
+
r"\bobrigat[oó]rio\b",
|
|
48
|
+
r"\bregra\s+permanente\b",
|
|
49
|
+
r"\bpermanent\s+rule\b",
|
|
50
|
+
r"\bguarda\s+isto\b",
|
|
51
|
+
r"\bsave\s+this\b",
|
|
52
|
+
r"\bencode\s+this\b",
|
|
53
|
+
r"\benforce\s+this\b",
|
|
54
|
+
r"\b(daqui\s+)?para\s+a\s+frente\b",
|
|
55
|
+
r"\bgoing\s+forward\b",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Correction verbs — "stop doing X" / "I want Y instead".
|
|
60
|
+
_CORRECTION_VERBS: tuple[re.Pattern, ...] = tuple(
|
|
61
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
62
|
+
r"\bn[aã]o\s+(quero|fa[cç]as?|continues?)\b",
|
|
63
|
+
r"\bp[aá]ra\s+de\b",
|
|
64
|
+
r"\bdeixa\s+de\b",
|
|
65
|
+
r"\bevita\b",
|
|
66
|
+
r"\bstop\s+(doing|saying)\b",
|
|
67
|
+
r"\bdon[' ]t\s+(do|say|use|assume)\b",
|
|
68
|
+
r"\bavoid\b",
|
|
69
|
+
r"\bem\s+vez\s+de\b",
|
|
70
|
+
r"\binstead\s+of\b",
|
|
71
|
+
r"\bn[aã]o\s+é\s+(isso|assim)\b",
|
|
72
|
+
r"\bwrong\b",
|
|
73
|
+
r"\bnot\s+like\s+that\b",
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Self-affirmation cues — "I prefer X" / "what I want is".
|
|
78
|
+
_PREFERENCE_CUES: tuple[re.Pattern, ...] = tuple(
|
|
79
|
+
re.compile(p, re.IGNORECASE) for p in [
|
|
80
|
+
r"\b(eu\s+)?prefiro\b",
|
|
81
|
+
r"\b(eu\s+)?gosto\s+(de|mais)\b",
|
|
82
|
+
r"\bI\s+prefer\b",
|
|
83
|
+
r"\bI\s+like\b",
|
|
84
|
+
r"\bI\s+want\b",
|
|
85
|
+
r"\bo\s+que\s+(eu\s+)?quero\s+é\b",
|
|
86
|
+
r"\bwhat\s+I\s+want\s+is\b",
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Magnitude thresholds.
|
|
91
|
+
_MIN_CORRECTION_CHARS = 60
|
|
92
|
+
_HIGH_LEVERAGE_CHARS = 200
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class CorrectionSignal:
|
|
97
|
+
"""Structured verdict on a potential correction message."""
|
|
98
|
+
|
|
99
|
+
mode: CorrectionMode
|
|
100
|
+
kind: CorrectionKind
|
|
101
|
+
confidence: float # 0.0 .. 1.0
|
|
102
|
+
is_high_leverage: bool
|
|
103
|
+
signals: list[str] = field(default_factory=list)
|
|
104
|
+
suggested_memory_type: str = "" # "feedback" | "preference" | ""
|
|
105
|
+
message_length: int = 0
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict:
|
|
108
|
+
return asdict(self)
|
|
109
|
+
|
|
110
|
+
def should_save(self) -> bool:
|
|
111
|
+
"""Whether the orchestrator should write a memory file."""
|
|
112
|
+
return self.confidence >= 0.5
|
|
113
|
+
|
|
114
|
+
def should_confirm(self) -> bool:
|
|
115
|
+
"""Whether Marta should ask the user to confirm before saving."""
|
|
116
|
+
return self.is_high_leverage or self.confidence >= 0.85
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_correction_signal(text: str) -> CorrectionSignal:
|
|
120
|
+
"""Inspect *text* and return a CorrectionSignal verdict.
|
|
121
|
+
|
|
122
|
+
Confidence scale (cumulative across signal types, capped at 1.0):
|
|
123
|
+
0.0 no signal
|
|
124
|
+
0.4 short message with correction verb
|
|
125
|
+
0.55 absolute-language cue present (sempre/nunca/etc.)
|
|
126
|
+
0.7 correction verb + absolute-language cue
|
|
127
|
+
0.85 long message with multiple cue types
|
|
128
|
+
1.0 explicit "guarda isto / save this / encode this"
|
|
129
|
+
"""
|
|
130
|
+
stripped = (text or "").strip()
|
|
131
|
+
if not stripped:
|
|
132
|
+
return CorrectionSignal(
|
|
133
|
+
mode="none", kind="none", confidence=0.0, is_high_leverage=False
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
signals: list[str] = []
|
|
137
|
+
confidence = 0.0
|
|
138
|
+
kind: CorrectionKind = "none"
|
|
139
|
+
|
|
140
|
+
# Absolute-language cues (strong rule signal).
|
|
141
|
+
abs_hits = [p.pattern for p in _ABSOLUTE_CUES if p.search(stripped)]
|
|
142
|
+
if abs_hits:
|
|
143
|
+
signals.append("absolute-language")
|
|
144
|
+
confidence = max(confidence, 0.55)
|
|
145
|
+
kind = "rule"
|
|
146
|
+
|
|
147
|
+
# Correction verbs.
|
|
148
|
+
correction_hits = [p.pattern for p in _CORRECTION_VERBS if p.search(stripped)]
|
|
149
|
+
if correction_hits:
|
|
150
|
+
signals.append("correction-verb")
|
|
151
|
+
confidence = max(confidence, 0.4)
|
|
152
|
+
if kind == "none":
|
|
153
|
+
kind = "rule"
|
|
154
|
+
|
|
155
|
+
# Preference cues — softer signal, "preference" not "rule".
|
|
156
|
+
pref_hits = [p.pattern for p in _PREFERENCE_CUES if p.search(stripped)]
|
|
157
|
+
if pref_hits:
|
|
158
|
+
signals.append("preference-cue")
|
|
159
|
+
confidence = max(confidence, 0.35)
|
|
160
|
+
if kind == "none":
|
|
161
|
+
kind = "preference"
|
|
162
|
+
|
|
163
|
+
# Combination boosts confidence.
|
|
164
|
+
if abs_hits and correction_hits:
|
|
165
|
+
signals.append("rule-plus-correction-combo")
|
|
166
|
+
confidence = max(confidence, 0.7)
|
|
167
|
+
|
|
168
|
+
# Explicit save-this verbs are 1.0.
|
|
169
|
+
save_now = re.search(
|
|
170
|
+
r"\b(guarda\s+isto|save\s+this|encode\s+this|enforce\s+this|"
|
|
171
|
+
r"regra\s+permanente|permanent\s+rule|going\s+forward)\b",
|
|
172
|
+
stripped, re.IGNORECASE,
|
|
173
|
+
)
|
|
174
|
+
if save_now:
|
|
175
|
+
signals.append("explicit-save-verb")
|
|
176
|
+
confidence = 1.0
|
|
177
|
+
kind = "rule"
|
|
178
|
+
|
|
179
|
+
# Magnitude boost — long substantive corrections are more likely rules.
|
|
180
|
+
msg_len = len(stripped)
|
|
181
|
+
is_high_leverage = False
|
|
182
|
+
if msg_len >= _HIGH_LEVERAGE_CHARS and (abs_hits or correction_hits):
|
|
183
|
+
signals.append("high-magnitude")
|
|
184
|
+
confidence = max(confidence, 0.85)
|
|
185
|
+
is_high_leverage = True
|
|
186
|
+
|
|
187
|
+
if msg_len < _MIN_CORRECTION_CHARS and not save_now and not abs_hits:
|
|
188
|
+
# Too short to be a substantive rule, unless it explicitly says "save"
|
|
189
|
+
# OR contains an absolute-language cue (e.g. "always", "never",
|
|
190
|
+
# "NON-NEGOTIABLE"). Those are intentional declarative rules even
|
|
191
|
+
# when phrased compactly — capping their confidence would silence
|
|
192
|
+
# the user's clear intent.
|
|
193
|
+
confidence = min(confidence, 0.4)
|
|
194
|
+
|
|
195
|
+
# NON-NEGOTIABLE keyword always escalates to high-leverage.
|
|
196
|
+
if re.search(r"\bNON-NEGOTIABLE\b|\bn[aã]o[\s-]+negoci[aá]vel\b", stripped, re.IGNORECASE):
|
|
197
|
+
is_high_leverage = True
|
|
198
|
+
|
|
199
|
+
# Mode selection.
|
|
200
|
+
if confidence < 0.35:
|
|
201
|
+
mode: CorrectionMode = "none"
|
|
202
|
+
elif is_high_leverage or confidence >= 0.85:
|
|
203
|
+
mode = "explicit"
|
|
204
|
+
else:
|
|
205
|
+
mode = "implicit"
|
|
206
|
+
|
|
207
|
+
suggested = ""
|
|
208
|
+
if kind == "rule" and confidence >= 0.5:
|
|
209
|
+
suggested = "feedback"
|
|
210
|
+
elif kind == "preference" and confidence >= 0.35:
|
|
211
|
+
suggested = "preference"
|
|
212
|
+
|
|
213
|
+
return CorrectionSignal(
|
|
214
|
+
mode=mode,
|
|
215
|
+
kind=kind,
|
|
216
|
+
confidence=round(confidence, 2),
|
|
217
|
+
is_high_leverage=is_high_leverage,
|
|
218
|
+
signals=signals,
|
|
219
|
+
suggested_memory_type=suggested,
|
|
220
|
+
message_length=msg_len,
|
|
221
|
+
)
|