nexo-brain 5.3.13 → 5.3.15
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/bin/nexo-brain.js +52 -1
- package/package.json +1 -1
- package/src/crons/sync.py +18 -4
- package/src/dashboard/static/favicon 2.svg +32 -0
- package/src/dashboard/static/nexo-logo 2.png +0 -0
- package/src/dashboard/static/nexo-logo 2.svg +40 -0
- package/src/dashboard/static/style 2.css +2458 -0
- package/src/dashboard/templates/adaptive 2.html +118 -0
- package/src/dashboard/templates/artifacts 2.html +133 -0
- package/src/dashboard/templates/backups 2.html +136 -0
- package/src/dashboard/templates/base 2.html +417 -0
- package/src/dashboard/templates/calendar 2.html +591 -0
- package/src/dashboard/templates/chat 2.html +356 -0
- package/src/dashboard/templates/claims 2.html +259 -0
- package/src/dashboard/templates/cortex 2.html +321 -0
- package/src/dashboard/templates/credentials 2.html +128 -0
- package/src/dashboard/templates/crons 2.html +370 -0
- package/src/dashboard/templates/dashboard 2.html +494 -0
- package/src/dashboard/templates/dreams 2.html +252 -0
- package/src/dashboard/templates/email 2.html +160 -0
- package/src/dashboard/templates/evolution 2.html +189 -0
- package/src/dashboard/templates/feed 2.html +249 -0
- package/src/dashboard/templates/followup_health 2.html +170 -0
- package/src/dashboard/templates/graph 2.html +201 -0
- package/src/dashboard/templates/guard 2.html +259 -0
- package/src/dashboard/templates/inbox 2.html +251 -0
- package/src/dashboard/templates/memory 2.html +420 -0
- package/src/dashboard/templates/operations 2.html +608 -0
- package/src/dashboard/templates/plugins 2.html +185 -0
- package/src/dashboard/templates/protocol 2.html +199 -0
- package/src/dashboard/templates/rules 2.html +246 -0
- package/src/dashboard/templates/sentiment 2.html +247 -0
- package/src/dashboard/templates/sessions 2.html +218 -0
- package/src/dashboard/templates/skills 2.html +329 -0
- package/src/dashboard/templates/somatic 2.html +73 -0
- package/src/dashboard/templates/triggers 2.html +133 -0
- package/src/dashboard/templates/trust 2.html +360 -0
- package/src/db/__init__ 2.py +259 -0
- package/src/db/_core 2.py +437 -0
- package/src/db/_credentials 2.py +124 -0
- package/src/db/_entities.py +1 -1
- package/src/db/_episodic 2.py +762 -0
- package/src/db/_evolution 2.py +54 -0
- package/src/db/_fts 2.py +406 -0
- package/src/db/_goal_profiles 2.py +376 -0
- package/src/db/_hot_context 2.py +660 -0
- package/src/db/_outcomes 2.py +800 -0
- package/src/db/_personal_scripts 2.py +582 -0
- package/src/db/_sessions 2.py +330 -0
- package/src/db/_tasks 2.py +91 -0
- package/src/db/_watchers 2.py +173 -0
- package/src/doctor/formatters 2.py +52 -0
- package/src/doctor/models 2.py +69 -0
- package/src/doctor/planes 2.py +87 -0
- package/src/doctor/providers/__init__ 2.py +1 -0
- package/src/doctor/providers/deep 2.py +367 -0
- package/src/evolution_cycle 2.py +519 -0
- package/src/hooks/auto_capture 2.py +208 -0
- package/src/hooks/caffeinate-guard 2.sh +8 -0
- package/src/hooks/capture-session 2.sh +21 -0
- package/src/hooks/capture-tool-logs 2.sh +158 -0
- package/src/hooks/daily-briefing-check 2.sh +33 -0
- package/src/hooks/heartbeat-enforcement 2.py +90 -0
- package/src/hooks/heartbeat-posttool 2.sh +18 -0
- package/src/hooks/inbox-hook 2.sh +76 -0
- package/src/hooks/post-compact 2.sh +152 -0
- package/src/hooks/pre-compact 2.sh +169 -0
- package/src/hooks/protocol-guardrail 2.sh +10 -0
- package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
- package/src/hooks/session-stop 2.sh +52 -0
- package/src/kg_populate 2.py +292 -0
- package/src/maintenance 2.py +53 -0
- package/src/memory_backends 2.py +71 -0
- package/src/migrate_embeddings 2.py +124 -0
- package/src/nexo_sdk 2.py +103 -0
- package/src/observability 2.py +199 -0
- package/src/plugin_loader 2.py +217 -0
- package/src/plugins/__init__ 2.py +0 -0
- package/src/plugins/agents.py +10 -3
- package/src/plugins/artifact_registry 2.py +450 -0
- package/src/plugins/backup 2.py +127 -0
- package/src/plugins/claims_tools 2.py +119 -0
- package/src/plugins/cognitive_memory 2.py +609 -0
- package/src/plugins/core_rules 2.py +252 -0
- package/src/plugins/cortex 2.py +1155 -0
- package/src/plugins/entities 2.py +67 -0
- package/src/plugins/episodic_memory 2.py +560 -0
- package/src/plugins/evolution 2.py +167 -0
- package/src/plugins/goal_engine 2.py +142 -0
- package/src/plugins/guard 2.py +862 -0
- package/src/plugins/impact 2.py +29 -0
- package/src/plugins/knowledge_graph_tools 2.py +137 -0
- package/src/plugins/media_memory_tools 2.py +98 -0
- package/src/plugins/memory_export 2.py +196 -0
- package/src/plugins/outcomes 2.py +130 -0
- package/src/plugins/personal_scripts 2.py +117 -0
- package/src/plugins/preferences 2.py +47 -0
- package/src/plugins/protocol 2.py +1449 -0
- package/src/plugins/schedule.py +2 -1
- package/src/plugins/simple_api 2.py +106 -0
- package/src/plugins/skills 2.py +341 -0
- package/src/plugins/state_watchers 2.py +79 -0
- package/src/plugins/update 2.py +986 -0
- package/src/plugins/user_state_tools 2.py +43 -0
- package/src/plugins/workflow 2.py +588 -0
- package/src/protocol_settings 2.py +59 -0
- package/src/public_contribution 2.py +466 -0
- package/src/public_evolution_queue 2.py +241 -0
- package/src/requirements 2.txt +14 -0
- package/src/requirements.txt +1 -1
- package/src/retroactive_learnings 2.py +373 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/core-rules 2.json +331 -0
- package/src/rules/migrate 2.py +207 -0
- package/src/runtime_power 2.py +874 -0
- package/src/runtime_power.py +18 -1
- package/src/script_registry 2.py +1559 -0
- package/src/scripts/check-context 2.py +272 -0
- package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
- package/src/scripts/deep-sleep/collect 2.py +928 -0
- package/src/scripts/deep-sleep/extract 2.py +330 -0
- package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
- package/src/scripts/deep-sleep/synthesize 2.py +312 -0
- package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
- package/src/scripts/nexo-agent-run 2.py +75 -0
- package/src/scripts/nexo-auto-update 2.py +6 -0
- package/src/scripts/nexo-backup 2.sh +25 -0
- package/src/scripts/nexo-brain-activation 2.sh +140 -0
- package/src/scripts/nexo-catchup 2.py +300 -0
- package/src/scripts/nexo-cognitive-decay 2.py +257 -0
- package/src/scripts/nexo-cortex-cycle 2.py +293 -0
- package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
- package/src/scripts/nexo-cron-wrapper.sh +7 -0
- package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
- package/src/scripts/nexo-dashboard 2.sh +29 -0
- package/src/scripts/nexo-deep-sleep 2.sh +86 -0
- package/src/scripts/nexo-evolution-run 2.py +1664 -0
- package/src/scripts/nexo-followup-hygiene 2.py +139 -0
- package/src/scripts/nexo-hook-record 2.py +42 -0
- package/src/scripts/nexo-immune 2.py +936 -0
- package/src/scripts/nexo-impact-scorer 2.py +117 -0
- package/src/scripts/nexo-inbox-hook 2.sh +74 -0
- package/src/scripts/nexo-install 2.py +6 -0
- package/src/scripts/nexo-learning-housekeep 2.py +401 -0
- package/src/scripts/nexo-learning-validator 2.py +266 -0
- package/src/scripts/nexo-migrate 2.py +260 -0
- package/src/scripts/nexo-outcome-checker 2.py +127 -0
- package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
- package/src/scripts/nexo-pre-commit 2.py +120 -0
- package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
- package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
- package/src/scripts/nexo-reflection 2.py +256 -0
- package/src/scripts/nexo-runtime-preflight 2.py +274 -0
- package/src/scripts/nexo-sleep 2.py +631 -0
- package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
- package/src/scripts/nexo-sync-clients 2.py +16 -0
- package/src/scripts/nexo-synthesis 2.py +475 -0
- package/src/scripts/nexo-tcc-approve 2.sh +79 -0
- package/src/scripts/nexo-update 2.sh +306 -0
- package/src/scripts/nexo-watchdog 2.sh +1207 -0
- package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
- package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
- package/src/server 2.py +1296 -0
- package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
- package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
- package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
- package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
- package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
- package/src/skills/run-release-final-audit/guide 2.md +16 -0
- package/src/skills/run-release-final-audit/script 2.py +259 -0
- package/src/skills/run-release-final-audit/skill 2.json +77 -0
- package/src/skills/run-runtime-doctor/guide 2.md +12 -0
- package/src/skills/run-runtime-doctor/script 2.py +21 -0
- package/src/skills/run-runtime-doctor/skill 2.json +25 -0
- package/src/skills_runtime 2.py +932 -0
- package/src/state_watchers_runtime 2.py +475 -0
- package/src/storage_router 2.py +32 -0
- package/src/system_catalog 2.py +786 -0
- package/src/tools_coordination 2.py +103 -0
- package/src/tools_credentials 2.py +68 -0
- package/src/tools_drive 2.py +487 -0
- package/src/tools_hot_context 2.py +163 -0
- package/src/tools_learnings 2.py +612 -0
- package/src/tools_menu 2.py +229 -0
- package/src/tools_reminders 2.py +88 -0
- package/src/tools_reminders_crud 2.py +363 -0
- package/src/tools_sessions 2.py +1054 -0
- package/src/tools_system_catalog 2.py +19 -0
- package/src/tools_task_history 2.py +57 -0
- package/src/tools_transcripts 2.py +98 -0
- package/src/transcript_utils 2.py +412 -0
- package/src/user_context 2.py +46 -0
- package/src/user_data_portability 2.py +328 -0
- package/src/user_state_model 2.py +170 -0
- package/templates/CLAUDE.md 2.template +108 -0
- package/templates/CODEX.AGENTS.md 2.template +66 -0
- package/templates/launchagents/README 2.md +132 -0
- package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
- package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
- package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
- package/templates/launchagents/com.nexo.catchup.plist +1 -1
- package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
- package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
- package/templates/launchagents/com.nexo.dashboard.plist +1 -1
- package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
- package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
- package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
- package/templates/launchagents/com.nexo.evolution.plist +1 -1
- package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
- package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
- package/templates/launchagents/com.nexo.immune 2.plist +41 -0
- package/templates/launchagents/com.nexo.immune.plist +1 -1
- package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
- package/templates/launchagents/com.nexo.postmortem.plist +1 -1
- package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
- package/templates/launchagents/com.nexo.self-audit.plist +1 -1
- package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
- package/templates/launchagents/com.nexo.synthesis.plist +1 -1
- package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
- package/templates/launchagents/com.nexo.watchdog.plist +1 -1
- package/templates/nexo_helper 2.py +301 -0
- package/templates/openclaw 2.json +13 -0
- package/templates/plugin-template 2.py +40 -0
- package/templates/script-template 2.py +59 -0
- package/templates/script-template 2.sh +13 -0
- package/templates/script-template.py +5 -4
- package/templates/skill-script-template 2.py +48 -0
- package/templates/skill-script-template.py +2 -1
- package/templates/skill-template 2.md +33 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO DB — Outcome tracker v1."""
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from db._core import get_db
|
|
9
|
+
|
|
10
|
+
VALID_METRIC_SOURCES = {
|
|
11
|
+
"manual",
|
|
12
|
+
"followup_status",
|
|
13
|
+
"decision_outcome",
|
|
14
|
+
"protocol_task_status",
|
|
15
|
+
"nexo_sqlite",
|
|
16
|
+
}
|
|
17
|
+
VALID_TARGET_OPERATORS = {"gte", "lte", "eq"}
|
|
18
|
+
OUTCOME_PATTERN_MIN_RESOLVED = 3
|
|
19
|
+
OUTCOME_PATTERN_MAX_EVIDENCE = 5
|
|
20
|
+
OUTCOME_PATTERN_LEARNING_SUCCESS_BOOST = 0.9
|
|
21
|
+
OUTCOME_PATTERN_LEARNING_RISK_REDUCTION = 0.6
|
|
22
|
+
OUTCOME_PATTERN_LEARNING_SUCCESS_PENALTY = -1.1
|
|
23
|
+
OUTCOME_PATTERN_LEARNING_RISK_PENALTY = 0.8
|
|
24
|
+
OUTCOME_PATTERN_MIN_SUCCESS_RATE = 0.75
|
|
25
|
+
OUTCOME_PATTERN_MAX_FAILURE_RATE = 0.25
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utcnow_iso() -> str:
|
|
29
|
+
return datetime.datetime.now().isoformat(timespec="seconds")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _normalize_deadline(deadline: str = "", *, default_days: int = 7) -> str:
|
|
33
|
+
clean = (deadline or "").strip()
|
|
34
|
+
if clean:
|
|
35
|
+
return clean
|
|
36
|
+
return (datetime.datetime.now() + datetime.timedelta(days=default_days)).isoformat(timespec="seconds")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_source(metric_source: str) -> str:
|
|
40
|
+
clean = (metric_source or "manual").strip().lower()
|
|
41
|
+
if clean not in VALID_METRIC_SOURCES:
|
|
42
|
+
raise ValueError(f"metric_source must be one of: {', '.join(sorted(VALID_METRIC_SOURCES))}")
|
|
43
|
+
return clean
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_operator(target_operator: str) -> str:
|
|
47
|
+
clean = (target_operator or "gte").strip().lower()
|
|
48
|
+
if clean not in VALID_TARGET_OPERATORS:
|
|
49
|
+
raise ValueError(f"target_operator must be one of: {', '.join(sorted(VALID_TARGET_OPERATORS))}")
|
|
50
|
+
return clean
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_float(value: Any) -> float | None:
|
|
54
|
+
if value is None or value == "":
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
return float(value)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _append_note(existing: str | None, extra: str) -> str:
|
|
63
|
+
extra = (extra or "").strip()
|
|
64
|
+
if not extra:
|
|
65
|
+
return (existing or "").strip()
|
|
66
|
+
existing = (existing or "").strip()
|
|
67
|
+
if not existing:
|
|
68
|
+
return extra
|
|
69
|
+
if extra in existing:
|
|
70
|
+
return existing
|
|
71
|
+
return f"{existing}\n{extra}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _format_scalar(value: Any) -> str:
|
|
75
|
+
if value is None:
|
|
76
|
+
return ""
|
|
77
|
+
if isinstance(value, float):
|
|
78
|
+
return f"{value:.4f}".rstrip("0").rstrip(".")
|
|
79
|
+
return str(value)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _pattern_key(*, area: str, task_type: str, goal_profile_id: str, selected_choice: str) -> str:
|
|
83
|
+
return json.dumps(
|
|
84
|
+
{
|
|
85
|
+
"area": (area or "").strip(),
|
|
86
|
+
"task_type": (task_type or "").strip(),
|
|
87
|
+
"goal_profile_id": (goal_profile_id or "").strip(),
|
|
88
|
+
"selected_choice": (selected_choice or "").strip(),
|
|
89
|
+
},
|
|
90
|
+
ensure_ascii=False,
|
|
91
|
+
sort_keys=True,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _context_label(*, area: str, task_type: str, goal_profile_id: str) -> str:
|
|
96
|
+
bits = []
|
|
97
|
+
if (area or "").strip():
|
|
98
|
+
bits.append(f"area={area.strip()}")
|
|
99
|
+
if (task_type or "").strip():
|
|
100
|
+
bits.append(f"task_type={task_type.strip()}")
|
|
101
|
+
if (goal_profile_id or "").strip():
|
|
102
|
+
bits.append(f"profile={goal_profile_id.strip()}")
|
|
103
|
+
return ", ".join(bits) if bits else "contexto general"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compare(actual_value: float, target_value: float, operator: str) -> bool:
|
|
107
|
+
if operator == "gte":
|
|
108
|
+
return actual_value >= target_value
|
|
109
|
+
if operator == "lte":
|
|
110
|
+
return actual_value <= target_value
|
|
111
|
+
return actual_value == target_value
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _get_outcome_row(conn, outcome_id: int):
|
|
115
|
+
return conn.execute("SELECT * FROM outcomes WHERE id = ?", (int(outcome_id),)).fetchone()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _update_outcome(
|
|
119
|
+
outcome_id: int,
|
|
120
|
+
*,
|
|
121
|
+
status: str | None = None,
|
|
122
|
+
actual_value: float | None = None,
|
|
123
|
+
actual_value_text: str | None = None,
|
|
124
|
+
checked_at: str | None = None,
|
|
125
|
+
notes: str | None = None,
|
|
126
|
+
learning_id: int | None = None,
|
|
127
|
+
) -> dict:
|
|
128
|
+
conn = get_db()
|
|
129
|
+
row = _get_outcome_row(conn, outcome_id)
|
|
130
|
+
if not row:
|
|
131
|
+
return {"error": f"Outcome {outcome_id} not found"}
|
|
132
|
+
|
|
133
|
+
updates: list[str] = []
|
|
134
|
+
params: list[Any] = []
|
|
135
|
+
|
|
136
|
+
if status is not None:
|
|
137
|
+
updates.append("status = ?")
|
|
138
|
+
params.append(status)
|
|
139
|
+
if actual_value is not None:
|
|
140
|
+
updates.append("actual_value = ?")
|
|
141
|
+
params.append(actual_value)
|
|
142
|
+
if actual_value_text is not None:
|
|
143
|
+
updates.append("actual_value_text = ?")
|
|
144
|
+
params.append(actual_value_text.strip())
|
|
145
|
+
if checked_at is not None:
|
|
146
|
+
updates.append("checked_at = ?")
|
|
147
|
+
params.append(checked_at)
|
|
148
|
+
if notes is not None:
|
|
149
|
+
updates.append("notes = ?")
|
|
150
|
+
params.append(notes)
|
|
151
|
+
if learning_id is not None:
|
|
152
|
+
updates.append("learning_id = ?")
|
|
153
|
+
params.append(learning_id)
|
|
154
|
+
|
|
155
|
+
updates.append("updated_at = datetime('now')")
|
|
156
|
+
params.append(int(outcome_id))
|
|
157
|
+
conn.execute(f"UPDATE outcomes SET {', '.join(updates)} WHERE id = ?", params)
|
|
158
|
+
conn.commit()
|
|
159
|
+
row = _get_outcome_row(conn, outcome_id)
|
|
160
|
+
return dict(row) if row else {"error": f"Outcome {outcome_id} not found after update"}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def create_outcome(
|
|
164
|
+
action_type: str,
|
|
165
|
+
description: str,
|
|
166
|
+
expected_result: str,
|
|
167
|
+
*,
|
|
168
|
+
metric_source: str = "manual",
|
|
169
|
+
metric_query: str = "",
|
|
170
|
+
baseline_value: float | None = None,
|
|
171
|
+
target_value: float | None = None,
|
|
172
|
+
target_operator: str = "gte",
|
|
173
|
+
deadline: str = "",
|
|
174
|
+
action_id: str = "",
|
|
175
|
+
session_id: str = "",
|
|
176
|
+
notes: str = "",
|
|
177
|
+
) -> dict:
|
|
178
|
+
conn = get_db()
|
|
179
|
+
clean_action_type = (action_type or "").strip()
|
|
180
|
+
clean_description = (description or "").strip()
|
|
181
|
+
clean_expected = (expected_result or "").strip()
|
|
182
|
+
if not clean_action_type:
|
|
183
|
+
return {"error": "action_type is required"}
|
|
184
|
+
if not clean_description:
|
|
185
|
+
return {"error": "description is required"}
|
|
186
|
+
if not clean_expected:
|
|
187
|
+
return {"error": "expected_result is required"}
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
clean_source = _normalize_source(metric_source)
|
|
191
|
+
clean_operator = _normalize_operator(target_operator)
|
|
192
|
+
except ValueError as exc:
|
|
193
|
+
return {"error": str(exc)}
|
|
194
|
+
|
|
195
|
+
clean_query = (metric_query or "").strip()
|
|
196
|
+
if clean_source in {"followup_status", "decision_outcome", "protocol_task_status"} and not (action_id or "").strip():
|
|
197
|
+
return {"error": f"action_id is required for metric_source='{clean_source}'"}
|
|
198
|
+
if clean_source == "nexo_sqlite":
|
|
199
|
+
if not clean_query:
|
|
200
|
+
return {"error": "metric_query is required for metric_source='nexo_sqlite'"}
|
|
201
|
+
query_upper = clean_query.upper()
|
|
202
|
+
if ";" in clean_query.rstrip(";"):
|
|
203
|
+
return {"error": "metric_query must be a single SELECT statement"}
|
|
204
|
+
if not query_upper.startswith("SELECT "):
|
|
205
|
+
return {"error": "metric_query must start with SELECT"}
|
|
206
|
+
for forbidden in ("INSERT ", "UPDATE ", "DELETE ", "DROP ", "ALTER ", "ATTACH ", "DETACH ", "PRAGMA "):
|
|
207
|
+
if forbidden in query_upper:
|
|
208
|
+
return {"error": "metric_query must be read-only"}
|
|
209
|
+
|
|
210
|
+
cursor = conn.execute(
|
|
211
|
+
"""INSERT INTO outcomes (
|
|
212
|
+
action_type, action_id, session_id, description, expected_result,
|
|
213
|
+
metric_source, metric_query, baseline_value, target_value,
|
|
214
|
+
target_operator, deadline, notes
|
|
215
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
216
|
+
(
|
|
217
|
+
clean_action_type,
|
|
218
|
+
(action_id or "").strip(),
|
|
219
|
+
(session_id or "").strip(),
|
|
220
|
+
clean_description,
|
|
221
|
+
clean_expected,
|
|
222
|
+
clean_source,
|
|
223
|
+
clean_query,
|
|
224
|
+
baseline_value,
|
|
225
|
+
target_value,
|
|
226
|
+
clean_operator,
|
|
227
|
+
_normalize_deadline(deadline),
|
|
228
|
+
(notes or "").strip(),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
conn.commit()
|
|
232
|
+
row = _get_outcome_row(conn, cursor.lastrowid)
|
|
233
|
+
return dict(row) if row else {"error": "Outcome insert failed"}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def get_outcome(outcome_id: int) -> dict | None:
|
|
237
|
+
conn = get_db()
|
|
238
|
+
row = _get_outcome_row(conn, outcome_id)
|
|
239
|
+
return dict(row) if row else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def list_outcomes(status: str = "", action_type: str = "", limit: int = 50) -> list[dict]:
|
|
243
|
+
conn = get_db()
|
|
244
|
+
clauses = []
|
|
245
|
+
params: list[Any] = []
|
|
246
|
+
if status:
|
|
247
|
+
clauses.append("status = ?")
|
|
248
|
+
params.append((status or "").strip().lower())
|
|
249
|
+
if action_type:
|
|
250
|
+
clauses.append("action_type = ?")
|
|
251
|
+
params.append((action_type or "").strip())
|
|
252
|
+
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
|
253
|
+
rows = conn.execute(
|
|
254
|
+
f"""SELECT * FROM outcomes
|
|
255
|
+
{where}
|
|
256
|
+
ORDER BY
|
|
257
|
+
CASE status
|
|
258
|
+
WHEN 'pending' THEN 0
|
|
259
|
+
WHEN 'missed' THEN 1
|
|
260
|
+
WHEN 'met' THEN 2
|
|
261
|
+
ELSE 3
|
|
262
|
+
END,
|
|
263
|
+
deadline ASC,
|
|
264
|
+
created_at DESC
|
|
265
|
+
LIMIT ?""",
|
|
266
|
+
params + [max(1, int(limit))],
|
|
267
|
+
).fetchall()
|
|
268
|
+
return [dict(row) for row in rows]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def pending_outcomes_due(deadline_before: str | None = None, limit: int = 100) -> list[dict]:
|
|
272
|
+
conn = get_db()
|
|
273
|
+
cutoff = deadline_before or _utcnow_iso()
|
|
274
|
+
rows = conn.execute(
|
|
275
|
+
"""SELECT * FROM outcomes
|
|
276
|
+
WHERE status = 'pending' AND deadline <= ?
|
|
277
|
+
ORDER BY deadline ASC, id ASC
|
|
278
|
+
LIMIT ?""",
|
|
279
|
+
(cutoff, max(1, int(limit))),
|
|
280
|
+
).fetchall()
|
|
281
|
+
return [dict(row) for row in rows]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def find_pending_outcomes_by_action(action_type: str, action_id: str, *, metric_source: str = "") -> list[dict]:
|
|
285
|
+
conn = get_db()
|
|
286
|
+
clauses = [
|
|
287
|
+
"status = 'pending'",
|
|
288
|
+
"action_type = ?",
|
|
289
|
+
"action_id = ?",
|
|
290
|
+
]
|
|
291
|
+
params: list[Any] = [(action_type or "").strip(), (action_id or "").strip()]
|
|
292
|
+
if metric_source:
|
|
293
|
+
clauses.append("metric_source = ?")
|
|
294
|
+
params.append((metric_source or "").strip().lower())
|
|
295
|
+
rows = conn.execute(
|
|
296
|
+
f"SELECT * FROM outcomes WHERE {' AND '.join(clauses)} ORDER BY deadline ASC, id ASC",
|
|
297
|
+
params,
|
|
298
|
+
).fetchall()
|
|
299
|
+
return [dict(row) for row in rows]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cancel_outcome(outcome_id: int, reason: str = "") -> dict:
|
|
303
|
+
conn = get_db()
|
|
304
|
+
row = _get_outcome_row(conn, outcome_id)
|
|
305
|
+
if not row:
|
|
306
|
+
return {"error": f"Outcome {outcome_id} not found"}
|
|
307
|
+
notes = _append_note(row["notes"], f"Cancelled: {(reason or '').strip() or 'no reason provided'}")
|
|
308
|
+
return _update_outcome(
|
|
309
|
+
int(outcome_id),
|
|
310
|
+
status="cancelled",
|
|
311
|
+
checked_at=_utcnow_iso(),
|
|
312
|
+
notes=notes,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _create_miss_learning(row: dict, actual_value: float | None, actual_value_text: str, note: str) -> int | None:
|
|
317
|
+
if row.get("learning_id"):
|
|
318
|
+
return int(row["learning_id"])
|
|
319
|
+
from db._learnings import create_learning
|
|
320
|
+
|
|
321
|
+
summary_bits = [
|
|
322
|
+
f"Outcome #{row['id']} missed.",
|
|
323
|
+
f"Action type: {row.get('action_type', '')}.",
|
|
324
|
+
f"Action id: {row.get('action_id', '') or 'N/A'}.",
|
|
325
|
+
f"Description: {row.get('description', '')}.",
|
|
326
|
+
f"Expected: {row.get('expected_result', '')}.",
|
|
327
|
+
]
|
|
328
|
+
if actual_value is not None:
|
|
329
|
+
summary_bits.append(f"Actual numeric value: {actual_value}.")
|
|
330
|
+
if actual_value_text:
|
|
331
|
+
summary_bits.append(f"Actual evidence: {actual_value_text}.")
|
|
332
|
+
if note:
|
|
333
|
+
summary_bits.append(f"Why missed: {note}.")
|
|
334
|
+
learning = create_learning(
|
|
335
|
+
category="outcomes",
|
|
336
|
+
title=f"Outcome missed: {str(row.get('description', ''))[:80]}",
|
|
337
|
+
content=" ".join(summary_bits),
|
|
338
|
+
reasoning=f"Auto-created from missed outcome #{row['id']}.",
|
|
339
|
+
prevention="Review the action, expected result, target, or deadline before repeating the same move.",
|
|
340
|
+
applies_to=f"outcome:{row['id']}",
|
|
341
|
+
)
|
|
342
|
+
return int(learning["id"]) if learning and learning.get("id") else None
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _status_from_protocol_task(task_row: dict | None, *, deadline_passed: bool) -> tuple[str, float | None, str, str]:
|
|
346
|
+
if not task_row:
|
|
347
|
+
if deadline_passed:
|
|
348
|
+
return "missed", None, "protocol task missing", "Linked protocol task not found."
|
|
349
|
+
return "pending", None, "", ""
|
|
350
|
+
status = str(task_row.get("status") or "").strip().lower()
|
|
351
|
+
if status == "done":
|
|
352
|
+
evidence = (task_row.get("outcome_notes") or task_row.get("close_evidence") or task_row.get("goal") or "").strip()
|
|
353
|
+
return "met", 1.0, evidence, "Linked protocol task closed as done."
|
|
354
|
+
if status in {"failed", "cancelled"}:
|
|
355
|
+
evidence = (task_row.get("outcome_notes") or task_row.get("close_evidence") or status).strip()
|
|
356
|
+
return "missed", 0.0, evidence, f"Linked protocol task closed as {status}."
|
|
357
|
+
if deadline_passed:
|
|
358
|
+
return "missed", None, status or "open", f"Deadline passed while linked protocol task remained {status or 'open'}."
|
|
359
|
+
return "pending", None, status, ""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def evaluate_outcome(
|
|
363
|
+
outcome_id: int,
|
|
364
|
+
*,
|
|
365
|
+
actual_value: float | None = None,
|
|
366
|
+
actual_value_text: str = "",
|
|
367
|
+
create_learning_on_miss: bool = True,
|
|
368
|
+
) -> dict:
|
|
369
|
+
conn = get_db()
|
|
370
|
+
row = _get_outcome_row(conn, outcome_id)
|
|
371
|
+
if not row:
|
|
372
|
+
return {"error": f"Outcome {outcome_id} not found"}
|
|
373
|
+
row_d = dict(row)
|
|
374
|
+
if row_d.get("status") != "pending":
|
|
375
|
+
row_d["evaluation"] = "skipped_non_pending"
|
|
376
|
+
return row_d
|
|
377
|
+
|
|
378
|
+
now_iso = _utcnow_iso()
|
|
379
|
+
deadline_passed = str(row_d.get("deadline") or "") <= now_iso
|
|
380
|
+
source = (row_d.get("metric_source") or "manual").strip().lower()
|
|
381
|
+
target = _as_float(row_d.get("target_value"))
|
|
382
|
+
operator = (row_d.get("target_operator") or "gte").strip().lower()
|
|
383
|
+
|
|
384
|
+
status = "pending"
|
|
385
|
+
actual_num = _as_float(actual_value)
|
|
386
|
+
actual_text = (actual_value_text or "").strip()
|
|
387
|
+
note = ""
|
|
388
|
+
|
|
389
|
+
if source == "manual":
|
|
390
|
+
if actual_num is not None:
|
|
391
|
+
actual_text = actual_text or _format_scalar(actual_num)
|
|
392
|
+
if target is None:
|
|
393
|
+
status = "met"
|
|
394
|
+
note = "Manual evidence recorded."
|
|
395
|
+
elif _compare(actual_num, target, operator):
|
|
396
|
+
status = "met"
|
|
397
|
+
note = f"Manual check met target via operator '{operator}'."
|
|
398
|
+
elif deadline_passed:
|
|
399
|
+
status = "missed"
|
|
400
|
+
note = f"Manual check below target and deadline passed (actual={actual_num}, target={target}, op={operator})."
|
|
401
|
+
else:
|
|
402
|
+
note = f"Manual check recorded current value (actual={actual_num}, target={target}, op={operator})."
|
|
403
|
+
elif actual_text:
|
|
404
|
+
status = "met"
|
|
405
|
+
note = "Manual textual evidence recorded."
|
|
406
|
+
elif deadline_passed:
|
|
407
|
+
status = "missed"
|
|
408
|
+
note = "Deadline passed with no manual evidence recorded."
|
|
409
|
+
|
|
410
|
+
elif source == "followup_status":
|
|
411
|
+
followup = conn.execute(
|
|
412
|
+
"SELECT status, description, verification FROM followups WHERE id = ?",
|
|
413
|
+
(row_d.get("action_id", ""),),
|
|
414
|
+
).fetchone()
|
|
415
|
+
followup_status = str(followup["status"]) if followup else ""
|
|
416
|
+
if followup and followup_status.upper().startswith("COMPLETED"):
|
|
417
|
+
status = "met"
|
|
418
|
+
actual_num = 1.0
|
|
419
|
+
actual_text = (followup["verification"] or followup["description"] or followup_status or "").strip()
|
|
420
|
+
note = "Linked followup completed."
|
|
421
|
+
elif deadline_passed:
|
|
422
|
+
status = "missed"
|
|
423
|
+
actual_text = actual_text or (followup_status or "missing")
|
|
424
|
+
note = f"Deadline passed while linked followup remained {followup_status or 'missing'}."
|
|
425
|
+
|
|
426
|
+
elif source == "decision_outcome":
|
|
427
|
+
decision = conn.execute(
|
|
428
|
+
"SELECT outcome, status, decision FROM decisions WHERE id = ?",
|
|
429
|
+
(row_d.get("action_id", ""),),
|
|
430
|
+
).fetchone()
|
|
431
|
+
decision_outcome = (decision["outcome"] or "").strip() if decision else ""
|
|
432
|
+
if decision and decision_outcome:
|
|
433
|
+
status = "met"
|
|
434
|
+
actual_num = 1.0
|
|
435
|
+
actual_text = decision_outcome
|
|
436
|
+
note = "Linked decision outcome recorded."
|
|
437
|
+
elif deadline_passed:
|
|
438
|
+
status = "missed"
|
|
439
|
+
actual_text = actual_text or (str(decision["status"]) if decision else "missing")
|
|
440
|
+
note = f"Deadline passed with no linked decision outcome for decision {row_d.get('action_id', '') or 'N/A'}."
|
|
441
|
+
|
|
442
|
+
elif source == "protocol_task_status":
|
|
443
|
+
task = conn.execute(
|
|
444
|
+
"SELECT status, goal, close_evidence, outcome_notes FROM protocol_tasks WHERE task_id = ?",
|
|
445
|
+
(row_d.get("action_id", ""),),
|
|
446
|
+
).fetchone()
|
|
447
|
+
status, actual_num, actual_text, note = _status_from_protocol_task(dict(task) if task else None, deadline_passed=deadline_passed)
|
|
448
|
+
|
|
449
|
+
elif source == "nexo_sqlite":
|
|
450
|
+
query = (row_d.get("metric_query") or "").strip()
|
|
451
|
+
if not query:
|
|
452
|
+
return {"error": f"Outcome {outcome_id} has empty metric_query"}
|
|
453
|
+
if ";" in query.rstrip(";") or not query.upper().startswith("SELECT "):
|
|
454
|
+
return {"error": "Outcome metric_query must be a single SELECT statement"}
|
|
455
|
+
fetched = conn.execute(query).fetchone()
|
|
456
|
+
scalar = fetched[0] if fetched else None
|
|
457
|
+
actual_num = _as_float(scalar)
|
|
458
|
+
actual_text = _format_scalar(scalar)
|
|
459
|
+
if actual_num is None:
|
|
460
|
+
if deadline_passed:
|
|
461
|
+
status = "missed"
|
|
462
|
+
note = f"SQLite query did not return a numeric scalar before deadline. Got: {actual_text or 'empty'}."
|
|
463
|
+
else:
|
|
464
|
+
note = f"SQLite query returned non-numeric scalar: {actual_text or 'empty'}."
|
|
465
|
+
elif target is None:
|
|
466
|
+
if actual_num:
|
|
467
|
+
status = "met"
|
|
468
|
+
note = f"SQLite query returned truthy scalar {actual_num}."
|
|
469
|
+
elif deadline_passed:
|
|
470
|
+
status = "missed"
|
|
471
|
+
note = "SQLite query returned falsy scalar and deadline passed."
|
|
472
|
+
else:
|
|
473
|
+
note = f"SQLite query current scalar is {actual_num}."
|
|
474
|
+
elif _compare(actual_num, target, operator):
|
|
475
|
+
status = "met"
|
|
476
|
+
note = f"SQLite scalar met target (actual={actual_num}, target={target}, op={operator})."
|
|
477
|
+
elif deadline_passed:
|
|
478
|
+
status = "missed"
|
|
479
|
+
note = f"SQLite scalar missed target at deadline (actual={actual_num}, target={target}, op={operator})."
|
|
480
|
+
else:
|
|
481
|
+
note = f"SQLite scalar recorded but target not reached yet (actual={actual_num}, target={target}, op={operator})."
|
|
482
|
+
|
|
483
|
+
learning_id = row_d.get("learning_id")
|
|
484
|
+
combined_notes = _append_note(row_d.get("notes"), note)
|
|
485
|
+
if status == "missed" and create_learning_on_miss:
|
|
486
|
+
learning_id = _create_miss_learning(row_d, actual_num, actual_text, note)
|
|
487
|
+
|
|
488
|
+
updated = _update_outcome(
|
|
489
|
+
int(outcome_id),
|
|
490
|
+
status=status,
|
|
491
|
+
actual_value=actual_num,
|
|
492
|
+
actual_value_text=actual_text,
|
|
493
|
+
checked_at=now_iso,
|
|
494
|
+
notes=combined_notes,
|
|
495
|
+
learning_id=int(learning_id) if learning_id else None,
|
|
496
|
+
)
|
|
497
|
+
if "error" not in updated:
|
|
498
|
+
updated["evaluation"] = status
|
|
499
|
+
return updated
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def set_linked_outcomes_met(
|
|
503
|
+
action_type: str,
|
|
504
|
+
action_id: str,
|
|
505
|
+
*,
|
|
506
|
+
metric_source: str = "",
|
|
507
|
+
actual_value: float | None = 1.0,
|
|
508
|
+
actual_value_text: str = "",
|
|
509
|
+
note: str = "",
|
|
510
|
+
) -> list[dict]:
|
|
511
|
+
rows = find_pending_outcomes_by_action(action_type, action_id, metric_source=metric_source)
|
|
512
|
+
updated: list[dict] = []
|
|
513
|
+
for row in rows:
|
|
514
|
+
updated.append(
|
|
515
|
+
_update_outcome(
|
|
516
|
+
int(row["id"]),
|
|
517
|
+
status="met",
|
|
518
|
+
actual_value=actual_value,
|
|
519
|
+
actual_value_text=actual_value_text or row.get("actual_value_text", ""),
|
|
520
|
+
checked_at=_utcnow_iso(),
|
|
521
|
+
notes=_append_note(row.get("notes"), note or "Linked action reached success state."),
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
return updated
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def list_outcome_pattern_candidates(
|
|
528
|
+
*,
|
|
529
|
+
min_resolved: int = OUTCOME_PATTERN_MIN_RESOLVED,
|
|
530
|
+
min_success_rate: float = OUTCOME_PATTERN_MIN_SUCCESS_RATE,
|
|
531
|
+
max_failure_rate: float = OUTCOME_PATTERN_MAX_FAILURE_RATE,
|
|
532
|
+
limit: int = 20,
|
|
533
|
+
) -> list[dict]:
|
|
534
|
+
conn = get_db()
|
|
535
|
+
if not conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='cortex_evaluations'").fetchone():
|
|
536
|
+
return []
|
|
537
|
+
|
|
538
|
+
rows = conn.execute(
|
|
539
|
+
"""SELECT
|
|
540
|
+
e.area,
|
|
541
|
+
e.task_type,
|
|
542
|
+
e.goal_profile_id,
|
|
543
|
+
e.selected_choice,
|
|
544
|
+
SUM(CASE WHEN o.status = 'met' THEN 1 ELSE 0 END) AS met,
|
|
545
|
+
SUM(CASE WHEN o.status = 'missed' THEN 1 ELSE 0 END) AS missed,
|
|
546
|
+
COUNT(*) AS resolved_outcomes,
|
|
547
|
+
MAX(e.created_at) AS last_seen_at
|
|
548
|
+
FROM cortex_evaluations e
|
|
549
|
+
JOIN outcomes o ON o.id = e.linked_outcome_id
|
|
550
|
+
WHERE (e.selected_choice IS NOT NULL AND trim(e.selected_choice) != '')
|
|
551
|
+
AND o.status IN ('met', 'missed')
|
|
552
|
+
GROUP BY e.area, e.task_type, e.goal_profile_id, e.selected_choice
|
|
553
|
+
HAVING COUNT(*) >= ?
|
|
554
|
+
ORDER BY resolved_outcomes DESC, last_seen_at DESC
|
|
555
|
+
LIMIT ?""",
|
|
556
|
+
(max(1, int(min_resolved)), max(1, int(limit * 3))),
|
|
557
|
+
).fetchall()
|
|
558
|
+
|
|
559
|
+
candidates: list[dict] = []
|
|
560
|
+
for row in rows:
|
|
561
|
+
resolved = int(row["resolved_outcomes"] or 0)
|
|
562
|
+
met = int(row["met"] or 0)
|
|
563
|
+
missed = int(row["missed"] or 0)
|
|
564
|
+
if resolved <= 0:
|
|
565
|
+
continue
|
|
566
|
+
success_rate = round(met / resolved, 3)
|
|
567
|
+
candidate_type = ""
|
|
568
|
+
if success_rate >= float(min_success_rate):
|
|
569
|
+
candidate_type = "reinforce_strategy"
|
|
570
|
+
elif success_rate <= float(max_failure_rate):
|
|
571
|
+
candidate_type = "avoid_strategy"
|
|
572
|
+
if not candidate_type:
|
|
573
|
+
continue
|
|
574
|
+
|
|
575
|
+
evidence_rows = conn.execute(
|
|
576
|
+
"""SELECT e.id AS evaluation_id, o.id AS outcome_id, o.status, o.description, e.created_at
|
|
577
|
+
FROM cortex_evaluations e
|
|
578
|
+
JOIN outcomes o ON o.id = e.linked_outcome_id
|
|
579
|
+
WHERE e.area = ?
|
|
580
|
+
AND e.task_type = ?
|
|
581
|
+
AND e.goal_profile_id = ?
|
|
582
|
+
AND e.selected_choice = ?
|
|
583
|
+
AND o.status IN ('met', 'missed')
|
|
584
|
+
ORDER BY e.created_at DESC, e.id DESC
|
|
585
|
+
LIMIT ?""",
|
|
586
|
+
(
|
|
587
|
+
row["area"] or "",
|
|
588
|
+
row["task_type"] or "",
|
|
589
|
+
row["goal_profile_id"] or "",
|
|
590
|
+
row["selected_choice"] or "",
|
|
591
|
+
OUTCOME_PATTERN_MAX_EVIDENCE,
|
|
592
|
+
),
|
|
593
|
+
).fetchall()
|
|
594
|
+
evidence = [dict(item) for item in evidence_rows]
|
|
595
|
+
context = _context_label(
|
|
596
|
+
area=row["area"] or "",
|
|
597
|
+
task_type=row["task_type"] or "",
|
|
598
|
+
goal_profile_id=row["goal_profile_id"] or "",
|
|
599
|
+
)
|
|
600
|
+
selected_choice = (row["selected_choice"] or "").strip()
|
|
601
|
+
if candidate_type == "reinforce_strategy":
|
|
602
|
+
rationale = (
|
|
603
|
+
f"La estrategia '{selected_choice}' acumula {met}/{resolved} outcomes met en {context}."
|
|
604
|
+
)
|
|
605
|
+
else:
|
|
606
|
+
rationale = (
|
|
607
|
+
f"La estrategia '{selected_choice}' acumula {missed}/{resolved} outcomes missed en {context}."
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
pattern_key = _pattern_key(
|
|
611
|
+
area=row["area"] or "",
|
|
612
|
+
task_type=row["task_type"] or "",
|
|
613
|
+
goal_profile_id=row["goal_profile_id"] or "",
|
|
614
|
+
selected_choice=selected_choice,
|
|
615
|
+
)
|
|
616
|
+
candidates.append(
|
|
617
|
+
{
|
|
618
|
+
"pattern_key": pattern_key,
|
|
619
|
+
"candidate_type": candidate_type,
|
|
620
|
+
"area": row["area"] or "",
|
|
621
|
+
"task_type": row["task_type"] or "",
|
|
622
|
+
"goal_profile_id": row["goal_profile_id"] or "",
|
|
623
|
+
"selected_choice": selected_choice,
|
|
624
|
+
"resolved_outcomes": resolved,
|
|
625
|
+
"met": met,
|
|
626
|
+
"missed": missed,
|
|
627
|
+
"success_rate": success_rate,
|
|
628
|
+
"last_seen_at": row["last_seen_at"],
|
|
629
|
+
"context_label": context,
|
|
630
|
+
"rationale": rationale,
|
|
631
|
+
"evidence": evidence,
|
|
632
|
+
"suggested_skill_candidate": (
|
|
633
|
+
candidate_type == "reinforce_strategy" and resolved >= max(4, int(min_resolved))
|
|
634
|
+
),
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
candidates.sort(
|
|
639
|
+
key=lambda item: (
|
|
640
|
+
0 if item["candidate_type"] == "avoid_strategy" else 1,
|
|
641
|
+
-item["resolved_outcomes"],
|
|
642
|
+
item["selected_choice"],
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
return candidates[: max(1, int(limit))]
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def capture_outcome_pattern(
|
|
649
|
+
pattern_key: str,
|
|
650
|
+
*,
|
|
651
|
+
target: str = "learning",
|
|
652
|
+
category: str = "outcomes",
|
|
653
|
+
) -> dict:
|
|
654
|
+
clean_target = (target or "learning").strip().lower()
|
|
655
|
+
if clean_target != "learning":
|
|
656
|
+
return {"error": f"Unsupported target: {target}"}
|
|
657
|
+
|
|
658
|
+
clean_key = (pattern_key or "").strip()
|
|
659
|
+
if not clean_key:
|
|
660
|
+
return {"error": "pattern_key is required"}
|
|
661
|
+
|
|
662
|
+
candidates = list_outcome_pattern_candidates(limit=200)
|
|
663
|
+
candidate = next((item for item in candidates if item["pattern_key"] == clean_key), None)
|
|
664
|
+
if not candidate:
|
|
665
|
+
return {"error": "Pattern candidate not found or no longer qualifies"}
|
|
666
|
+
|
|
667
|
+
selected_choice = candidate["selected_choice"]
|
|
668
|
+
context = candidate["context_label"]
|
|
669
|
+
applies_to = f"outcome-pattern:{clean_key}"
|
|
670
|
+
if candidate["candidate_type"] == "reinforce_strategy":
|
|
671
|
+
title = f"Prefer {selected_choice} in {context}"
|
|
672
|
+
prevention = (
|
|
673
|
+
f"When a comparable context appears, default to '{selected_choice}' unless fresh evidence or constraints override it."
|
|
674
|
+
)
|
|
675
|
+
else:
|
|
676
|
+
title = f"Avoid {selected_choice} in {context}"
|
|
677
|
+
prevention = (
|
|
678
|
+
f"When a comparable context appears, do not default to '{selected_choice}' until the evidence base changes."
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
conn = get_db()
|
|
682
|
+
existing = conn.execute(
|
|
683
|
+
"""SELECT * FROM learnings
|
|
684
|
+
WHERE status = 'active' AND applies_to = ?
|
|
685
|
+
ORDER BY updated_at DESC, id DESC
|
|
686
|
+
LIMIT 1""",
|
|
687
|
+
(applies_to,),
|
|
688
|
+
).fetchone()
|
|
689
|
+
if existing:
|
|
690
|
+
return {
|
|
691
|
+
"ok": True,
|
|
692
|
+
"created": False,
|
|
693
|
+
"target": clean_target,
|
|
694
|
+
"candidate": candidate,
|
|
695
|
+
"learning": dict(existing),
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
evidence_refs = ", ".join(
|
|
699
|
+
f"eval#{item['evaluation_id']}/outcome#{item['outcome_id']}:{item['status']}"
|
|
700
|
+
for item in candidate["evidence"][:OUTCOME_PATTERN_MAX_EVIDENCE]
|
|
701
|
+
)
|
|
702
|
+
content = (
|
|
703
|
+
f"{candidate['rationale']} "
|
|
704
|
+
f"Success rate: {candidate['success_rate']:.3f}. "
|
|
705
|
+
f"Resolved outcomes: {candidate['resolved_outcomes']} "
|
|
706
|
+
f"(met={candidate['met']}, missed={candidate['missed']}). "
|
|
707
|
+
f"Evidence: {evidence_refs or 'none'}."
|
|
708
|
+
)
|
|
709
|
+
reasoning = (
|
|
710
|
+
"Structured outcome pattern captured from repeated resolved cortex-linked outcomes. "
|
|
711
|
+
f"Pattern key: {clean_key}."
|
|
712
|
+
)
|
|
713
|
+
from db._learnings import create_learning
|
|
714
|
+
|
|
715
|
+
learning = create_learning(
|
|
716
|
+
category=(category or "outcomes").strip(),
|
|
717
|
+
title=title,
|
|
718
|
+
content=content,
|
|
719
|
+
reasoning=reasoning,
|
|
720
|
+
prevention=prevention,
|
|
721
|
+
applies_to=applies_to,
|
|
722
|
+
)
|
|
723
|
+
return {
|
|
724
|
+
"ok": True,
|
|
725
|
+
"created": True,
|
|
726
|
+
"target": clean_target,
|
|
727
|
+
"candidate": candidate,
|
|
728
|
+
"learning": learning,
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def get_outcome_pattern_learning_signal(
|
|
733
|
+
*,
|
|
734
|
+
area: str = "",
|
|
735
|
+
task_type: str = "",
|
|
736
|
+
goal_profile_id: str = "",
|
|
737
|
+
selected_choice: str = "",
|
|
738
|
+
) -> dict:
|
|
739
|
+
clean_choice = (selected_choice or "").strip()
|
|
740
|
+
if not clean_choice:
|
|
741
|
+
return {
|
|
742
|
+
"active": False,
|
|
743
|
+
"pattern_key": "",
|
|
744
|
+
"learning_id": 0,
|
|
745
|
+
"mode": "",
|
|
746
|
+
"title": "",
|
|
747
|
+
"success_adjustment": 0.0,
|
|
748
|
+
"risk_adjustment": 0.0,
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
pattern_key = _pattern_key(
|
|
752
|
+
area=area or "",
|
|
753
|
+
task_type=task_type or "",
|
|
754
|
+
goal_profile_id=goal_profile_id or "",
|
|
755
|
+
selected_choice=clean_choice,
|
|
756
|
+
)
|
|
757
|
+
applies_to = f"outcome-pattern:{pattern_key}"
|
|
758
|
+
conn = get_db()
|
|
759
|
+
row = conn.execute(
|
|
760
|
+
"""SELECT id, title
|
|
761
|
+
FROM learnings
|
|
762
|
+
WHERE status = 'active' AND applies_to = ?
|
|
763
|
+
ORDER BY updated_at DESC, id DESC
|
|
764
|
+
LIMIT 1""",
|
|
765
|
+
(applies_to,),
|
|
766
|
+
).fetchone()
|
|
767
|
+
if not row:
|
|
768
|
+
return {
|
|
769
|
+
"active": False,
|
|
770
|
+
"pattern_key": pattern_key,
|
|
771
|
+
"learning_id": 0,
|
|
772
|
+
"mode": "",
|
|
773
|
+
"title": "",
|
|
774
|
+
"success_adjustment": 0.0,
|
|
775
|
+
"risk_adjustment": 0.0,
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
title = str(row["title"] or "").strip()
|
|
779
|
+
if title.startswith("Prefer "):
|
|
780
|
+
mode = "prefer"
|
|
781
|
+
success_adjustment = OUTCOME_PATTERN_LEARNING_SUCCESS_BOOST
|
|
782
|
+
risk_adjustment = -OUTCOME_PATTERN_LEARNING_RISK_REDUCTION
|
|
783
|
+
elif title.startswith("Avoid "):
|
|
784
|
+
mode = "avoid"
|
|
785
|
+
success_adjustment = OUTCOME_PATTERN_LEARNING_SUCCESS_PENALTY
|
|
786
|
+
risk_adjustment = OUTCOME_PATTERN_LEARNING_RISK_PENALTY
|
|
787
|
+
else:
|
|
788
|
+
mode = "observe"
|
|
789
|
+
success_adjustment = 0.0
|
|
790
|
+
risk_adjustment = 0.0
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
"active": mode in {"prefer", "avoid"},
|
|
794
|
+
"pattern_key": pattern_key,
|
|
795
|
+
"learning_id": int(row["id"] or 0),
|
|
796
|
+
"mode": mode,
|
|
797
|
+
"title": title,
|
|
798
|
+
"success_adjustment": round(success_adjustment, 2),
|
|
799
|
+
"risk_adjustment": round(risk_adjustment, 2),
|
|
800
|
+
}
|