nexo-brain 7.27.3 → 7.28.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/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/bin/windows-wsl-bridge.js +9 -0
- package/package.json +1 -1
- package/src/causal_graph.py +763 -0
- package/src/classifier_local.py +44 -0
- package/src/cognitive/_core.py +3 -0
- package/src/cognitive_control_observatory.py +2 -0
- package/src/db/__init__.py +8 -0
- package/src/db/_commitments.py +344 -0
- package/src/db/_entities.py +98 -11
- package/src/db/_memory_v2.py +130 -2
- package/src/db/_schema.py +565 -0
- package/src/desktop_bridge.py +1 -1
- package/src/doctor/providers/runtime.py +9 -3
- package/src/enforcement_engine.py +128 -2
- package/src/entity_live_profile.py +1073 -0
- package/src/failure_prevention.py +1052 -0
- package/src/hook_guardrails.py +104 -0
- package/src/knowledge_graph.py +46 -9
- package/src/local_context/api.py +54 -22
- package/src/local_context/usage_events.py +273 -8
- package/src/memory_executive.py +620 -0
- package/src/memory_utility.py +952 -0
- package/src/plugin_loader.py +9 -5
- package/src/plugins/entities.py +84 -7
- package/src/plugins/entity_live_profile.py +101 -0
- package/src/plugins/failure_prevention.py +162 -0
- package/src/plugins/memory_export.py +55 -18
- package/src/plugins/protocol.py +133 -0
- package/src/plugins/semantic_layers.py +138 -0
- package/src/pre_answer_router.py +622 -28
- package/src/pre_answer_runtime.py +463 -18
- package/src/r14_correction_learning.py +3 -3
- package/src/requirements.txt +5 -1
- package/src/runtime_versioning.py +11 -1
- package/src/saved_not_used_audit.py +44 -3
- package/src/scripts/nexo-followup-runner.py +194 -0
- package/src/semantic_layers.py +1153 -0
- package/src/semantic_reasoner.py +2 -2
- package/src/semantic_router.py +58 -11
- package/src/server.py +41 -3
- package/src/tools_sessions.py +88 -31
- package/src/tools_transcripts.py +38 -22
- package/src/user_state_model.py +971 -0
- package/tool-enforcement-map.json +230 -0
package/src/classifier_local.py
CHANGED
|
@@ -213,11 +213,55 @@ class LocalZeroShotClassifier:
|
|
|
213
213
|
)
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
_SHARED_CLASSIFIER_LOCK = threading.Lock()
|
|
217
|
+
_SHARED_CLASSIFIERS: dict[tuple[str, str], LocalZeroShotClassifier] = {}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def get_shared_zero_shot_classifier(
|
|
221
|
+
*,
|
|
222
|
+
model_id: str = MODEL_ID,
|
|
223
|
+
revision: str = MODEL_REVISION,
|
|
224
|
+
confidence_floor: float = DEFAULT_CONFIDENCE_FLOOR,
|
|
225
|
+
) -> LocalZeroShotClassifier:
|
|
226
|
+
"""Return the process-wide classifier wrapper for this pinned model.
|
|
227
|
+
|
|
228
|
+
The underlying transformer pipeline is still lazy-loaded, but once it is
|
|
229
|
+
warm all semantic callers share it instead of creating fresh wrappers.
|
|
230
|
+
"""
|
|
231
|
+
key = (model_id, revision)
|
|
232
|
+
with _SHARED_CLASSIFIER_LOCK:
|
|
233
|
+
classifier = _SHARED_CLASSIFIERS.get(key)
|
|
234
|
+
if classifier is None:
|
|
235
|
+
classifier = LocalZeroShotClassifier(
|
|
236
|
+
model_id=model_id,
|
|
237
|
+
revision=revision,
|
|
238
|
+
confidence_floor=confidence_floor,
|
|
239
|
+
)
|
|
240
|
+
_SHARED_CLASSIFIERS[key] = classifier
|
|
241
|
+
else:
|
|
242
|
+
classifier.confidence_floor = confidence_floor
|
|
243
|
+
return classifier
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def is_local_classifier_warm(
|
|
247
|
+
*,
|
|
248
|
+
model_id: str = MODEL_ID,
|
|
249
|
+
revision: str = MODEL_REVISION,
|
|
250
|
+
) -> bool:
|
|
251
|
+
"""Return True only when this process already has a loaded pipeline."""
|
|
252
|
+
key = (model_id, revision)
|
|
253
|
+
with _SHARED_CLASSIFIER_LOCK:
|
|
254
|
+
classifier = _SHARED_CLASSIFIERS.get(key)
|
|
255
|
+
return bool(classifier is not None and classifier._pipe is not None)
|
|
256
|
+
|
|
257
|
+
|
|
216
258
|
__all__ = [
|
|
217
259
|
"LocalZeroShotClassifier",
|
|
218
260
|
"ClassificationResult",
|
|
219
261
|
"MODEL_ID",
|
|
220
262
|
"MODEL_REVISION",
|
|
221
263
|
"DEFAULT_CONFIDENCE_FLOOR",
|
|
264
|
+
"get_shared_zero_shot_classifier",
|
|
265
|
+
"is_local_classifier_warm",
|
|
222
266
|
"is_local_classifier_available_with_install_state",
|
|
223
267
|
]
|
package/src/cognitive/_core.py
CHANGED
|
@@ -643,6 +643,9 @@ def _init_tables(conn: sqlite3.Connection):
|
|
|
643
643
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_source ON kg_edges(source_id)")
|
|
644
644
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_target ON kg_edges(target_id)")
|
|
645
645
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_relation ON kg_edges(relation)")
|
|
646
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_source_relation_active ON kg_edges(source_id, relation, valid_until)")
|
|
647
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_target_relation_active ON kg_edges(target_id, relation, valid_until)")
|
|
648
|
+
conn.execute("CREATE INDEX IF NOT EXISTS idx_kg_edges_relation_active ON kg_edges(relation, valid_until)")
|
|
646
649
|
|
|
647
650
|
conn.commit()
|
|
648
651
|
|
|
@@ -204,12 +204,14 @@ def build_cognitive_control_observatory(
|
|
|
204
204
|
"usage": local_usage,
|
|
205
205
|
"status": local_status,
|
|
206
206
|
},
|
|
207
|
+
"runtime_budgets": local_usage.get("runtime_budget_metrics") or {},
|
|
207
208
|
"learnings": learning_summary,
|
|
208
209
|
"followups": followup_summary,
|
|
209
210
|
"intraday_memory": intraday_summary,
|
|
210
211
|
}
|
|
211
212
|
payload["summary"] = {
|
|
212
213
|
"local_context_events": int(local_usage.get("total_events") or 0),
|
|
214
|
+
"runtime_budget_tiers": len((local_usage.get("runtime_budget_metrics") or {}).get("by_tier") or {}),
|
|
213
215
|
"active_learnings": int(learning_summary.get("active") or 0),
|
|
214
216
|
"active_followups": int((followup_summary.get("counts") or {}).get("active") or 0),
|
|
215
217
|
"intraday_facts": int(intraday_summary.get("intraday_facts_window") or 0),
|
package/src/db/__init__.py
CHANGED
|
@@ -56,6 +56,7 @@ _outcomes = _load_submodule("db._outcomes")
|
|
|
56
56
|
_goal_profiles = _load_submodule("db._goal_profiles")
|
|
57
57
|
_continuity = _load_submodule("db._continuity")
|
|
58
58
|
_memory_v2 = _load_submodule("db._memory_v2")
|
|
59
|
+
_commitments = _load_submodule("db._commitments")
|
|
59
60
|
|
|
60
61
|
# Core: connection, constants, init, utils
|
|
61
62
|
from db._core import (
|
|
@@ -110,6 +111,13 @@ from db._memory_v2 import (
|
|
|
110
111
|
memory_observation_stats,
|
|
111
112
|
)
|
|
112
113
|
|
|
114
|
+
from db._commitments import (
|
|
115
|
+
create_commitment,
|
|
116
|
+
list_commitments,
|
|
117
|
+
update_commitment_status,
|
|
118
|
+
resolve_matching_commitments,
|
|
119
|
+
)
|
|
120
|
+
|
|
113
121
|
# PostToolUse inbox-reminder rate limit (v6.0.1)
|
|
114
122
|
_hook_inbox_reminders = _load_submodule("db._hook_inbox_reminders")
|
|
115
123
|
from db._hook_inbox_reminders import (
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Durable commitment ledger for future-action promises.
|
|
2
|
+
|
|
3
|
+
The ledger is an index over promises and their linked action artifacts. It is
|
|
4
|
+
not a scheduler: followups, workflows, outcomes, and protocol tasks remain the
|
|
5
|
+
systems that execute work.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
import re
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from db._core import get_db, now_epoch
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ACTIVE_STATUSES = {"active", "in_progress", "pending"}
|
|
20
|
+
CLOSED_STATUSES = {"fulfilled", "missed", "cancelled", "superseded"}
|
|
21
|
+
VALID_OWNERS = {"agent", "user", "shared", "waiting"}
|
|
22
|
+
VALID_STATUSES = ACTIVE_STATUSES | CLOSED_STATUSES
|
|
23
|
+
_WORD_RE = re.compile(r"[a-z0-9_]+")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clean_text(value: Any, *, max_chars: int = 1200) -> str:
|
|
27
|
+
text = str(value or "").strip()
|
|
28
|
+
if len(text) > max_chars:
|
|
29
|
+
return text[: max(0, max_chars - 3)].rstrip() + "..."
|
|
30
|
+
return text
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _status(value: str) -> str:
|
|
34
|
+
clean = str(value or "active").strip().lower()
|
|
35
|
+
return clean if clean in VALID_STATUSES else "active"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _owner(value: str) -> str:
|
|
39
|
+
clean = str(value or "agent").strip().lower()
|
|
40
|
+
return clean if clean in VALID_OWNERS else "agent"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _json(value: dict[str, Any] | None) -> str:
|
|
44
|
+
try:
|
|
45
|
+
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
46
|
+
except Exception:
|
|
47
|
+
return "{}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _tokens(value: str) -> set[str]:
|
|
51
|
+
return {item for item in _WORD_RE.findall(str(value or "").lower()) if len(item) >= 3}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _dedupe_key(*, source_type: str, source_id: str, session_id: str, statement: str) -> str:
|
|
55
|
+
seed = "|".join(
|
|
56
|
+
[
|
|
57
|
+
_clean_text(source_type, max_chars=80).lower(),
|
|
58
|
+
_clean_text(source_id, max_chars=160).lower(),
|
|
59
|
+
_clean_text(session_id, max_chars=160).lower(),
|
|
60
|
+
_clean_text(statement, max_chars=600).lower(),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
return hashlib.sha1(seed.encode("utf-8", errors="ignore"), usedforsecurity=False).hexdigest()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _commitment_id(dedupe_key: str) -> str:
|
|
67
|
+
return f"CM-{dedupe_key[:16].upper()}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _row_dict(row: Any) -> dict[str, Any]:
|
|
71
|
+
if not row:
|
|
72
|
+
return {}
|
|
73
|
+
data = dict(row)
|
|
74
|
+
try:
|
|
75
|
+
data["metadata"] = json.loads(data.get("metadata_json") or "{}")
|
|
76
|
+
except Exception:
|
|
77
|
+
data["metadata"] = {}
|
|
78
|
+
return data
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_commitment(
|
|
82
|
+
*,
|
|
83
|
+
statement: str,
|
|
84
|
+
source_type: str = "",
|
|
85
|
+
source_id: str = "",
|
|
86
|
+
memory_event_uid: str = "",
|
|
87
|
+
session_id: str = "",
|
|
88
|
+
conversation_id: str = "",
|
|
89
|
+
project_key: str = "",
|
|
90
|
+
owner: str = "agent",
|
|
91
|
+
deadline: str = "",
|
|
92
|
+
status: str = "active",
|
|
93
|
+
confidence: float = 0.5,
|
|
94
|
+
action_ref_type: str = "",
|
|
95
|
+
action_ref_id: str = "",
|
|
96
|
+
outcome_id: int | None = None,
|
|
97
|
+
evidence_ref: str = "",
|
|
98
|
+
dedupe_key: str = "",
|
|
99
|
+
metadata: dict[str, Any] | None = None,
|
|
100
|
+
created_at: float | None = None,
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
clean_statement = _clean_text(statement)
|
|
103
|
+
if not clean_statement:
|
|
104
|
+
return {"ok": False, "error": "statement_required"}
|
|
105
|
+
stamp = float(created_at if created_at is not None else now_epoch())
|
|
106
|
+
clean_source_type = _clean_text(source_type, max_chars=80)
|
|
107
|
+
clean_source_id = _clean_text(source_id, max_chars=180)
|
|
108
|
+
clean_session_id = _clean_text(session_id, max_chars=180)
|
|
109
|
+
key = _clean_text(dedupe_key, max_chars=80) or _dedupe_key(
|
|
110
|
+
source_type=clean_source_type,
|
|
111
|
+
source_id=clean_source_id,
|
|
112
|
+
session_id=clean_session_id,
|
|
113
|
+
statement=clean_statement,
|
|
114
|
+
)
|
|
115
|
+
commitment_id = _commitment_id(key)
|
|
116
|
+
conn = get_db()
|
|
117
|
+
existing = conn.execute("SELECT * FROM commitments WHERE dedupe_key = ? LIMIT 1", (key,)).fetchone()
|
|
118
|
+
if existing:
|
|
119
|
+
result = _row_dict(existing)
|
|
120
|
+
result.update({"ok": True, "created": False})
|
|
121
|
+
return result
|
|
122
|
+
conn.execute(
|
|
123
|
+
"""
|
|
124
|
+
INSERT INTO commitments (
|
|
125
|
+
id, created_at, updated_at, closed_at, source_type, source_id,
|
|
126
|
+
memory_event_uid, session_id, conversation_id, project_key,
|
|
127
|
+
statement, owner, deadline, status, confidence, action_ref_type,
|
|
128
|
+
action_ref_id, outcome_id, evidence_ref, dedupe_key, metadata_json
|
|
129
|
+
)
|
|
130
|
+
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
131
|
+
""",
|
|
132
|
+
(
|
|
133
|
+
commitment_id,
|
|
134
|
+
stamp,
|
|
135
|
+
stamp,
|
|
136
|
+
clean_source_type,
|
|
137
|
+
clean_source_id,
|
|
138
|
+
_clean_text(memory_event_uid, max_chars=180),
|
|
139
|
+
clean_session_id,
|
|
140
|
+
_clean_text(conversation_id, max_chars=180),
|
|
141
|
+
_clean_text(project_key, max_chars=120),
|
|
142
|
+
clean_statement,
|
|
143
|
+
_owner(owner),
|
|
144
|
+
_clean_text(deadline, max_chars=80),
|
|
145
|
+
_status(status),
|
|
146
|
+
max(0.0, min(1.0, float(confidence or 0.5))),
|
|
147
|
+
_clean_text(action_ref_type, max_chars=80),
|
|
148
|
+
_clean_text(action_ref_id, max_chars=180),
|
|
149
|
+
int(outcome_id) if outcome_id is not None else None,
|
|
150
|
+
_clean_text(evidence_ref, max_chars=240),
|
|
151
|
+
key,
|
|
152
|
+
_json(metadata),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
conn.commit()
|
|
156
|
+
row = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id,)).fetchone()
|
|
157
|
+
result = _row_dict(row)
|
|
158
|
+
result.update({"ok": True, "created": True})
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def list_commitments(
|
|
163
|
+
*,
|
|
164
|
+
query: str = "",
|
|
165
|
+
status: str = "",
|
|
166
|
+
session_id: str = "",
|
|
167
|
+
project_key: str = "",
|
|
168
|
+
owner: str = "",
|
|
169
|
+
limit: int = 20,
|
|
170
|
+
) -> list[dict[str, Any]]:
|
|
171
|
+
clauses = ["1=1"]
|
|
172
|
+
params: list[Any] = []
|
|
173
|
+
if status:
|
|
174
|
+
clean_status = status.strip().lower()
|
|
175
|
+
if clean_status in {"open", "active"}:
|
|
176
|
+
clauses.append("status IN ('active','in_progress','pending')")
|
|
177
|
+
elif clean_status in {"closed", "resolved"}:
|
|
178
|
+
clauses.append("status IN ('fulfilled','missed','cancelled','superseded')")
|
|
179
|
+
else:
|
|
180
|
+
clauses.append("status = ?")
|
|
181
|
+
params.append(_status(clean_status))
|
|
182
|
+
if session_id.strip():
|
|
183
|
+
clauses.append("session_id = ?")
|
|
184
|
+
params.append(session_id.strip())
|
|
185
|
+
if project_key.strip():
|
|
186
|
+
clauses.append("project_key = ?")
|
|
187
|
+
params.append(project_key.strip())
|
|
188
|
+
if owner.strip():
|
|
189
|
+
clauses.append("owner = ?")
|
|
190
|
+
params.append(_owner(owner))
|
|
191
|
+
max_items = max(1, min(int(limit or 20), 100))
|
|
192
|
+
terms = _tokens(query)
|
|
193
|
+
# Query filtering is semantic-ish and happens in Python over multiple
|
|
194
|
+
# fields. Fetch a larger bounded window first so older relevant open
|
|
195
|
+
# commitments do not disappear merely because the caller requested a
|
|
196
|
+
# small result limit.
|
|
197
|
+
query_window = 500 if terms else max_items * 3
|
|
198
|
+
rows = get_db().execute(
|
|
199
|
+
f"""
|
|
200
|
+
SELECT * FROM commitments
|
|
201
|
+
WHERE {' AND '.join(clauses)}
|
|
202
|
+
ORDER BY
|
|
203
|
+
CASE status WHEN 'active' THEN 0 WHEN 'in_progress' THEN 1 WHEN 'pending' THEN 2 ELSE 3 END,
|
|
204
|
+
COALESCE(deadline, '') ASC,
|
|
205
|
+
updated_at DESC
|
|
206
|
+
LIMIT ?
|
|
207
|
+
""",
|
|
208
|
+
[*params, query_window],
|
|
209
|
+
).fetchall()
|
|
210
|
+
items = [_row_dict(row) for row in rows]
|
|
211
|
+
if terms:
|
|
212
|
+
filtered = []
|
|
213
|
+
for item in items:
|
|
214
|
+
haystack = _tokens(
|
|
215
|
+
" ".join(
|
|
216
|
+
str(item.get(field) or "")
|
|
217
|
+
for field in (
|
|
218
|
+
"id",
|
|
219
|
+
"statement",
|
|
220
|
+
"source_type",
|
|
221
|
+
"source_id",
|
|
222
|
+
"session_id",
|
|
223
|
+
"project_key",
|
|
224
|
+
"action_ref_type",
|
|
225
|
+
"action_ref_id",
|
|
226
|
+
"evidence_ref",
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
if terms & haystack:
|
|
231
|
+
filtered.append(item)
|
|
232
|
+
items = filtered
|
|
233
|
+
return items[:max_items]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def update_commitment_status(
|
|
237
|
+
commitment_id: str,
|
|
238
|
+
*,
|
|
239
|
+
status: str,
|
|
240
|
+
evidence_ref: str = "",
|
|
241
|
+
action_ref_type: str = "",
|
|
242
|
+
action_ref_id: str = "",
|
|
243
|
+
outcome_id: int | None = None,
|
|
244
|
+
metadata: dict[str, Any] | None = None,
|
|
245
|
+
now: float | None = None,
|
|
246
|
+
) -> dict[str, Any]:
|
|
247
|
+
clean_status = _status(status)
|
|
248
|
+
stamp = float(now if now is not None else now_epoch())
|
|
249
|
+
closed_at = stamp if clean_status in CLOSED_STATUSES else None
|
|
250
|
+
conn = get_db()
|
|
251
|
+
row = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id.strip(),)).fetchone()
|
|
252
|
+
if not row:
|
|
253
|
+
return {"ok": False, "error": f"commitment_not_found:{commitment_id}"}
|
|
254
|
+
merged_metadata = _row_dict(row).get("metadata") or {}
|
|
255
|
+
merged_metadata.update(metadata or {})
|
|
256
|
+
conn.execute(
|
|
257
|
+
"""
|
|
258
|
+
UPDATE commitments
|
|
259
|
+
SET status = ?,
|
|
260
|
+
updated_at = ?,
|
|
261
|
+
closed_at = ?,
|
|
262
|
+
evidence_ref = COALESCE(NULLIF(?, ''), evidence_ref),
|
|
263
|
+
action_ref_type = COALESCE(NULLIF(?, ''), action_ref_type),
|
|
264
|
+
action_ref_id = COALESCE(NULLIF(?, ''), action_ref_id),
|
|
265
|
+
outcome_id = COALESCE(?, outcome_id),
|
|
266
|
+
metadata_json = ?
|
|
267
|
+
WHERE id = ?
|
|
268
|
+
""",
|
|
269
|
+
(
|
|
270
|
+
clean_status,
|
|
271
|
+
stamp,
|
|
272
|
+
closed_at,
|
|
273
|
+
_clean_text(evidence_ref, max_chars=240),
|
|
274
|
+
_clean_text(action_ref_type, max_chars=80),
|
|
275
|
+
_clean_text(action_ref_id, max_chars=180),
|
|
276
|
+
int(outcome_id) if outcome_id is not None else None,
|
|
277
|
+
_json(merged_metadata),
|
|
278
|
+
commitment_id.strip(),
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
conn.commit()
|
|
282
|
+
updated = conn.execute("SELECT * FROM commitments WHERE id = ?", (commitment_id.strip(),)).fetchone()
|
|
283
|
+
result = _row_dict(updated)
|
|
284
|
+
result["ok"] = True
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def resolve_matching_commitments(
|
|
289
|
+
*,
|
|
290
|
+
session_id: str = "",
|
|
291
|
+
evidence_text: str = "",
|
|
292
|
+
action_ref_type: str = "",
|
|
293
|
+
action_ref_id: str = "",
|
|
294
|
+
evidence_ref: str = "",
|
|
295
|
+
status: str = "fulfilled",
|
|
296
|
+
limit: int = 5,
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
"""Close active commitments when completion evidence overlaps enough."""
|
|
299
|
+
terms = _tokens(evidence_text)
|
|
300
|
+
if not terms and not (action_ref_type and action_ref_id):
|
|
301
|
+
return {"ok": True, "resolved": 0, "items": [], "reason": "no_matching_signal"}
|
|
302
|
+
candidates = list_commitments(session_id=session_id, status="open", limit=max(1, min(limit, 20)))
|
|
303
|
+
resolved: list[dict[str, Any]] = []
|
|
304
|
+
for item in candidates:
|
|
305
|
+
action_match = (
|
|
306
|
+
bool(action_ref_type and action_ref_id)
|
|
307
|
+
and item.get("action_ref_type") == action_ref_type
|
|
308
|
+
and item.get("action_ref_id") == action_ref_id
|
|
309
|
+
)
|
|
310
|
+
statement_terms = _tokens(str(item.get("statement") or ""))
|
|
311
|
+
matched_terms = terms & statement_terms
|
|
312
|
+
overlap = len(matched_terms) / max(1, len(statement_terms))
|
|
313
|
+
required_matches = max(4, math.ceil(len(statement_terms) * 0.55))
|
|
314
|
+
strong_text_match = bool(
|
|
315
|
+
len(matched_terms) >= required_matches
|
|
316
|
+
and overlap >= 0.65
|
|
317
|
+
)
|
|
318
|
+
if not action_match and not strong_text_match:
|
|
319
|
+
continue
|
|
320
|
+
resolved.append(
|
|
321
|
+
update_commitment_status(
|
|
322
|
+
str(item.get("id")),
|
|
323
|
+
status=status,
|
|
324
|
+
evidence_ref=evidence_ref,
|
|
325
|
+
action_ref_type=action_ref_type,
|
|
326
|
+
action_ref_id=action_ref_id,
|
|
327
|
+
metadata={
|
|
328
|
+
"resolved_by": "action_ref" if action_match else "strong_matching_evidence",
|
|
329
|
+
"overlap": round(overlap, 4),
|
|
330
|
+
"matched_terms": sorted(matched_terms)[:12],
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
if len(resolved) >= limit:
|
|
335
|
+
break
|
|
336
|
+
return {"ok": True, "resolved": len(resolved), "items": resolved}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
__all__ = [
|
|
340
|
+
"create_commitment",
|
|
341
|
+
"list_commitments",
|
|
342
|
+
"update_commitment_status",
|
|
343
|
+
"resolve_matching_commitments",
|
|
344
|
+
]
|
package/src/db/_entities.py
CHANGED
|
@@ -1,30 +1,101 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
"""NEXO DB — Entities module."""
|
|
3
|
+
import json
|
|
3
4
|
import time
|
|
4
5
|
from db._core import get_db, _multi_word_like
|
|
5
6
|
from db._fts import fts_upsert
|
|
6
7
|
|
|
7
8
|
# ── Entities ──────────────────────────────────────────────────────
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
def _json_text(value, default):
|
|
12
|
+
if value in (None, ""):
|
|
13
|
+
value = default
|
|
14
|
+
if isinstance(value, str):
|
|
15
|
+
return value
|
|
16
|
+
try:
|
|
17
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
18
|
+
except Exception:
|
|
19
|
+
return json.dumps(default, ensure_ascii=False, sort_keys=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _entity_columns(conn) -> set[str]:
|
|
23
|
+
try:
|
|
24
|
+
return {str(row["name"]) for row in conn.execute("PRAGMA table_info(entities)").fetchall()}
|
|
25
|
+
except Exception:
|
|
26
|
+
return set()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _entity_search_columns(conn) -> list[str]:
|
|
30
|
+
columns = ["name", "value"]
|
|
31
|
+
available = _entity_columns(conn)
|
|
32
|
+
if "aliases" in available:
|
|
33
|
+
columns.append("aliases")
|
|
34
|
+
if "metadata" in available:
|
|
35
|
+
columns.append("metadata")
|
|
36
|
+
return columns
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _entity_fts_content(row: dict) -> str:
|
|
40
|
+
return " ".join(
|
|
41
|
+
str(row.get(key) or "")
|
|
42
|
+
for key in ("name", "value", "notes", "aliases", "metadata", "access_mode")
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def create_entity(
|
|
47
|
+
name: str,
|
|
48
|
+
type: str,
|
|
49
|
+
value: str,
|
|
50
|
+
notes: str = "",
|
|
51
|
+
aliases=None,
|
|
52
|
+
metadata=None,
|
|
53
|
+
source: str = "manual",
|
|
54
|
+
confidence: float = 1.0,
|
|
55
|
+
access_mode: str = "unknown",
|
|
56
|
+
) -> int:
|
|
10
57
|
"""Create a new entity. Returns the entity ID."""
|
|
11
58
|
conn = get_db()
|
|
12
59
|
now = time.time()
|
|
60
|
+
row = {
|
|
61
|
+
"name": name,
|
|
62
|
+
"type": type,
|
|
63
|
+
"value": value,
|
|
64
|
+
"notes": notes,
|
|
65
|
+
"aliases": _json_text(aliases, []),
|
|
66
|
+
"metadata": _json_text(metadata, {}),
|
|
67
|
+
"source": source or "manual",
|
|
68
|
+
"confidence": float(confidence if confidence is not None else 1.0),
|
|
69
|
+
"access_mode": access_mode or "unknown",
|
|
70
|
+
"created_at": now,
|
|
71
|
+
"updated_at": now,
|
|
72
|
+
}
|
|
73
|
+
available = _entity_columns(conn)
|
|
74
|
+
columns = [
|
|
75
|
+
key
|
|
76
|
+
for key in (
|
|
77
|
+
"name", "type", "value", "notes", "aliases", "metadata",
|
|
78
|
+
"source", "confidence", "access_mode", "created_at", "updated_at",
|
|
79
|
+
)
|
|
80
|
+
if key in available
|
|
81
|
+
]
|
|
82
|
+
if not columns:
|
|
83
|
+
columns = ["name", "type", "value", "notes", "created_at", "updated_at"]
|
|
13
84
|
cursor = conn.execute(
|
|
14
|
-
"INSERT INTO entities (
|
|
15
|
-
"VALUES (
|
|
16
|
-
(
|
|
85
|
+
f"INSERT INTO entities ({', '.join(columns)}) "
|
|
86
|
+
f"VALUES ({', '.join('?' for _ in columns)})",
|
|
87
|
+
tuple(row[key] for key in columns),
|
|
17
88
|
)
|
|
18
89
|
conn.commit()
|
|
19
90
|
eid = cursor.lastrowid
|
|
20
|
-
fts_upsert("entity", str(eid), name,
|
|
91
|
+
fts_upsert("entity", str(eid), name, _entity_fts_content(row), type or "general", commit=False)
|
|
21
92
|
return eid
|
|
22
93
|
|
|
23
94
|
|
|
24
95
|
def search_entities(query: str, type: str = "") -> list[dict]:
|
|
25
96
|
"""Search entities by name or value. Multi-word AND search."""
|
|
26
97
|
conn = get_db()
|
|
27
|
-
frag, params = _multi_word_like(query,
|
|
98
|
+
frag, params = _multi_word_like(query, _entity_search_columns(conn))
|
|
28
99
|
if type:
|
|
29
100
|
where = f"type = ? AND ({frag})"
|
|
30
101
|
params.insert(0, type)
|
|
@@ -53,12 +124,23 @@ def list_entities(type: str = "") -> list[dict]:
|
|
|
53
124
|
|
|
54
125
|
|
|
55
126
|
def update_entity(id: int, **kwargs):
|
|
56
|
-
"""Update entity fields
|
|
127
|
+
"""Update entity fields, including extended alias/metadata/access fields."""
|
|
57
128
|
conn = get_db()
|
|
58
|
-
allowed = {
|
|
129
|
+
allowed = {
|
|
130
|
+
"name", "type", "value", "notes", "aliases", "metadata",
|
|
131
|
+
"source", "confidence", "access_mode",
|
|
132
|
+
}
|
|
133
|
+
available = _entity_columns(conn)
|
|
134
|
+
allowed &= available
|
|
59
135
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
|
60
136
|
if not updates:
|
|
61
137
|
return
|
|
138
|
+
if "aliases" in updates:
|
|
139
|
+
updates["aliases"] = _json_text(updates["aliases"], [])
|
|
140
|
+
if "metadata" in updates:
|
|
141
|
+
updates["metadata"] = _json_text(updates["metadata"], {})
|
|
142
|
+
if "confidence" in updates:
|
|
143
|
+
updates["confidence"] = float(updates["confidence"] if updates["confidence"] is not None else 1.0)
|
|
62
144
|
updates["updated_at"] = time.time()
|
|
63
145
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
|
64
146
|
values = list(updates.values()) + [id]
|
|
@@ -67,7 +149,14 @@ def update_entity(id: int, **kwargs):
|
|
|
67
149
|
row = conn.execute("SELECT * FROM entities WHERE id = ?", (id,)).fetchone()
|
|
68
150
|
if row:
|
|
69
151
|
r = dict(row)
|
|
70
|
-
fts_upsert(
|
|
152
|
+
fts_upsert(
|
|
153
|
+
"entity",
|
|
154
|
+
str(id),
|
|
155
|
+
r.get("name", ""),
|
|
156
|
+
_entity_fts_content(r),
|
|
157
|
+
r.get("type", "general"),
|
|
158
|
+
commit=False,
|
|
159
|
+
)
|
|
71
160
|
|
|
72
161
|
|
|
73
162
|
def delete_entity(id: int) -> bool:
|
|
@@ -175,5 +264,3 @@ def delete_agent(id: str) -> bool:
|
|
|
175
264
|
result = conn.execute("DELETE FROM agents WHERE id = ?", (id,))
|
|
176
265
|
conn.commit()
|
|
177
266
|
return result.rowcount > 0
|
|
178
|
-
|
|
179
|
-
|