nexo-brain 4.1.0 → 5.0.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.
@@ -31,20 +31,20 @@
31
31
  <!-- Stats row -->
32
32
  <div class="grid grid-cols-4 gap-4 mb-6">
33
33
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
34
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Total Decisions</p>
34
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Alt Evaluations</p>
35
35
  <p class="text-2xl font-display font-bold text-slate-100" id="stat-decisions">--</p>
36
36
  </div>
37
37
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
38
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Avg Confidence</p>
38
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Accept Rate</p>
39
39
  <p class="text-2xl font-display font-bold text-nexo-400" id="stat-confidence">--</p>
40
40
  </div>
41
41
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
42
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Cortex Logs</p>
42
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Linked Outcomes</p>
43
43
  <p class="text-2xl font-display font-bold text-slate-100" id="stat-logs">--</p>
44
44
  </div>
45
45
  <div class="bg-slate-900/50 border border-slate-800/50 rounded-xl p-5 card">
46
- <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Success Rate</p>
47
- <p class="text-2xl font-display font-bold text-emerald-400" id="stat-success">--</p>
46
+ <p class="text-xs uppercase tracking-wider text-slate-500 font-semibold mb-1">Override Rate</p>
47
+ <p class="text-2xl font-display font-bold text-amber-400" id="stat-success">--</p>
48
48
  </div>
49
49
  </div>
50
50
 
@@ -77,7 +77,7 @@
77
77
  <span class="text-[10px] text-slate-600 font-mono" id="decisions-count">--</span>
78
78
  </div>
79
79
  <div id="decisions-list" class="space-y-3 max-h-[600px] overflow-y-auto pr-1">
80
- <div class="py-12 text-center text-sm text-slate-500">Loading decisions...</div>
80
+ <div class="py-12 text-center text-sm text-slate-500">Loading decision support...</div>
81
81
  </div>
82
82
  </div>
83
83
  </div>
@@ -94,6 +94,7 @@
94
94
  <script>
95
95
  let allLogs = [];
96
96
  let allDecisions = [];
97
+ let allEvaluations = [];
97
98
 
98
99
  const modeColors = {
99
100
  conservative: { bg: 'bg-blue-500/15', text: 'text-blue-400', border: 'border-blue-500/30' },
@@ -156,16 +157,69 @@
156
157
  }).join('');
157
158
  }
158
159
 
159
- function renderDecisions(decisions) {
160
+ function renderDecisions(evaluations, decisions) {
160
161
  const container = document.getElementById('decisions-list');
161
- document.getElementById('decisions-count').textContent = `${decisions.length} decisions`;
162
+ document.getElementById('decisions-count').textContent = `${evaluations.length} evaluations / ${decisions.length} decisions`;
162
163
 
163
- if (!decisions.length) {
164
- container.innerHTML = '<div class="py-12 text-center text-sm text-slate-500">No decisions recorded</div>';
164
+ if (!evaluations.length && !decisions.length) {
165
+ container.innerHTML = '<div class="py-12 text-center text-sm text-slate-500">No decision support recorded</div>';
165
166
  return;
166
167
  }
167
168
 
168
- container.innerHTML = decisions.slice(0, 50).map((d, i) => `
169
+ const evaluationCards = evaluations.slice(0, 25).map((item, i) => {
170
+ const scores = Array.isArray(item.scores) ? item.scores : JSON.parse(item.scores || '[]');
171
+ const top = scores[0] || null;
172
+ const selectedChanged = item.selection_source === 'override';
173
+ return `
174
+ <div class="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors border border-transparent hover:border-slate-700/50 animate-fade-in" style="animation-delay: ${i * 35}ms">
175
+ <div class="flex items-center justify-between mb-2 gap-2">
176
+ <div class="flex items-center gap-2 flex-wrap">
177
+ ${item.impact_level ? `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold bg-red-500/15 text-red-300 uppercase tracking-wider">${escapeHtml(item.impact_level)}</span>` : ''}
178
+ ${item.area ? `<span class="inline-flex items-center px-2 py-0.5 rounded-md text-[10px] font-semibold bg-nexo-500/15 text-nexo-400 uppercase tracking-wider">${escapeHtml(item.area)}</span>` : ''}
179
+ </div>
180
+ <span class="text-[10px] text-slate-600">${relativeTime(item.created_at)}</span>
181
+ </div>
182
+ <p class="text-sm text-slate-200 mb-2">${escapeHtml(item.goal || '--')}</p>
183
+ <div class="grid grid-cols-2 gap-2 mb-2">
184
+ <div class="bg-slate-900/40 rounded-md p-2">
185
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Recommended</p>
186
+ <p class="text-xs text-emerald-400 font-semibold">${escapeHtml(item.recommended_choice || '--')}</p>
187
+ </div>
188
+ <div class="bg-slate-900/40 rounded-md p-2">
189
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Selected</p>
190
+ <p class="text-xs ${selectedChanged ? 'text-amber-400' : 'text-slate-200'} font-semibold">${escapeHtml(item.selected_choice || '--')}</p>
191
+ </div>
192
+ </div>
193
+ ${item.recommended_reasoning ? `<p class="text-xs text-slate-400 mb-2">${escapeHtml(item.recommended_reasoning)}</p>` : ''}
194
+ ${item.goal_profile_id ? `
195
+ <div class="mt-2 bg-slate-900/40 rounded px-2 py-1.5">
196
+ <p class="text-[10px] text-slate-500 uppercase tracking-wider font-semibold mb-0.5">Goal Profile</p>
197
+ <p class="text-xs text-nexo-300 font-semibold">${escapeHtml(item.goal_profile_id)}</p>
198
+ ${Array.isArray(item.goal_profile_labels) && item.goal_profile_labels.length ? `<p class="text-[10px] text-slate-400 mt-1">${escapeHtml(item.goal_profile_labels.join(' · '))}</p>` : ''}
199
+ </div>
200
+ ` : ''}
201
+ ${item.linked_outcome_id ? `
202
+ <div class="mt-2 text-[10px] text-slate-500">
203
+ outcome #${escapeHtml(String(item.linked_outcome_id))}
204
+ ${item.linked_outcome?.status ? `· <span class="text-slate-300">${escapeHtml(String(item.linked_outcome.status))}</span>` : ''}
205
+ </div>
206
+ ` : ''}
207
+ ${selectedChanged && item.selection_reason ? `
208
+ <div class="mt-2 bg-amber-500/10 rounded px-2 py-1.5">
209
+ <p class="text-[10px] text-amber-400 font-semibold uppercase tracking-wider mb-0.5">Override</p>
210
+ <p class="text-xs text-amber-200/90">${escapeHtml(item.selection_reason)}</p>
211
+ </div>
212
+ ` : ''}
213
+ ${top ? `
214
+ <div class="mt-2 text-[10px] text-slate-500 font-mono">
215
+ top=${Number(top.total_score || 0).toFixed(2)} impact=${Number(top.impact || 0).toFixed(1)} success=${Number(top.success_probability || 0).toFixed(1)} risk=${Number(top.risk_level || 0).toFixed(1)}
216
+ </div>
217
+ ` : ''}
218
+ </div>
219
+ `;
220
+ }).join('');
221
+
222
+ const decisionCards = decisions.slice(0, 20).map((d, i) => `
169
223
  <div class="bg-slate-800/30 rounded-lg p-4 hover:bg-slate-800/50 transition-colors border border-transparent hover:border-slate-700/50 animate-fade-in" style="animation-delay: ${i * 40}ms">
170
224
  <div class="flex items-center justify-between mb-2">
171
225
  <div class="flex items-center gap-2">
@@ -190,6 +244,15 @@
190
244
  </div>
191
245
  </div>
192
246
  `).join('');
247
+
248
+ const decisionSection = decisionCards ? `
249
+ <div class="pt-3 mt-3 border-t border-slate-800/80">
250
+ <p class="text-[10px] uppercase tracking-wider text-slate-500 font-semibold mb-3">Decision Log</p>
251
+ <div class="space-y-3">${decisionCards}</div>
252
+ </div>
253
+ ` : '';
254
+
255
+ container.innerHTML = evaluationCards + decisionSection;
193
256
  }
194
257
 
195
258
  function renderConfidenceChart(decisions) {
@@ -217,9 +280,10 @@
217
280
 
218
281
  const filteredLogs = modeFilter ? allLogs.filter(l => l.mode === modeFilter) : allLogs;
219
282
  const filteredDecisions = domainFilter ? allDecisions.filter(d => d.domain === domainFilter) : allDecisions;
283
+ const filteredEvaluations = domainFilter ? allEvaluations.filter(e => e.area === domainFilter) : allEvaluations;
220
284
 
221
285
  renderLogs(filteredLogs);
222
- renderDecisions(filteredDecisions);
286
+ renderDecisions(filteredEvaluations, filteredDecisions);
223
287
  }
224
288
 
225
289
  async function loadData() {
@@ -228,29 +292,24 @@
228
292
 
229
293
  allLogs = data.cortex_logs || [];
230
294
  allDecisions = data.decisions || [];
295
+ allEvaluations = data.evaluations || [];
296
+ const summary = data.summary || {};
231
297
 
232
298
  // Populate domain filter
233
- const domains = [...new Set(allDecisions.map(d => d.domain).filter(Boolean))];
299
+ const domains = [...new Set([
300
+ ...allDecisions.map(d => d.domain).filter(Boolean),
301
+ ...allEvaluations.map(e => e.area).filter(Boolean),
302
+ ])];
234
303
  const domainSelect = document.getElementById('filter-domain');
235
304
  const currentDomain = domainSelect.value;
236
305
  domainSelect.innerHTML = '<option value="">All Domains</option>' +
237
306
  domains.map(d => `<option value="${escapeHtml(d)}" ${d === currentDomain ? 'selected' : ''}>${escapeHtml(d)}</option>`).join('');
238
307
 
239
308
  // Stats
240
- document.getElementById('stat-decisions').textContent = formatNumber(allDecisions.length);
241
- document.getElementById('stat-logs').textContent = formatNumber(allLogs.length);
242
-
243
- const confidences = allDecisions.filter(d => d.confidence != null).map(d => {
244
- const v = typeof d.confidence === 'number' ? (d.confidence > 1 ? d.confidence : d.confidence * 100) : parseFloat(d.confidence);
245
- return v;
246
- });
247
- const avgConf = confidences.length ? (confidences.reduce((a, b) => a + b, 0) / confidences.length) : 0;
248
- document.getElementById('stat-confidence').textContent = avgConf.toFixed(0) + '%';
249
-
250
- const outcomes = allDecisions.filter(d => d.outcome);
251
- const successes = outcomes.filter(d => d.outcome === 'success' || d.outcome === 'positive' || d.outcome === 'good');
252
- const successRate = outcomes.length ? ((successes.length / outcomes.length) * 100) : 0;
253
- document.getElementById('stat-success').textContent = successRate.toFixed(0) + '%';
309
+ document.getElementById('stat-decisions').textContent = formatNumber(allEvaluations.length);
310
+ document.getElementById('stat-confidence').textContent = `${Number(summary.recommendation_accept_rate || 0).toFixed(0)}%`;
311
+ document.getElementById('stat-logs').textContent = formatNumber(summary.linked_outcomes_total || 0);
312
+ document.getElementById('stat-success').textContent = `${Number(summary.override_rate || 0).toFixed(0)}%`;
254
313
 
255
314
  renderConfidenceChart(allDecisions);
256
315
  applyFilters();
@@ -52,6 +52,8 @@ _personal_scripts = _load_submodule("db._personal_scripts")
52
52
  _skills = _load_submodule("db._skills")
53
53
  _hot_context = _load_submodule("db._hot_context")
54
54
  _drive = _load_submodule("db._drive")
55
+ _outcomes = _load_submodule("db._outcomes")
56
+ _goal_profiles = _load_submodule("db._goal_profiles")
55
57
 
56
58
  # Core: connection, constants, init, utils
57
59
  from db._core import (
@@ -88,6 +90,7 @@ from db._reminders import (
88
90
  create_followup, update_followup, complete_followup, delete_followup,
89
91
  restore_followup, add_followup_note, get_followups, get_followup, get_followup_history,
90
92
  find_similar_followups,
93
+ compute_followup_impact, score_followup, score_active_followups,
91
94
  add_item_history, get_item_history, validate_item_read_token,
92
95
  )
93
96
 
@@ -147,6 +150,10 @@ from db._protocol import (
147
150
  create_protocol_task, get_protocol_task, close_protocol_task,
148
151
  create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
149
152
  protocol_compliance_summary,
153
+ create_cortex_evaluation, get_cortex_evaluation, list_cortex_evaluations,
154
+ cortex_evaluation_summary,
155
+ latest_cortex_evaluation_for_task, task_has_cortex_evaluation,
156
+ override_cortex_evaluation,
150
157
  )
151
158
 
152
159
  # Durable workflow runtime
@@ -185,6 +192,7 @@ from db._skills import (
185
192
  validate_skill_params, render_command_template, sync_skill_directories,
186
193
  import_skill_from_directory, approve_skill, collect_scriptable_skill_candidates,
187
194
  collect_skill_improvement_candidates, materialize_personal_skill_definition,
195
+ get_skill_outcome_evidence, list_skill_outcome_reviews,
188
196
  get_skill_health_report,
189
197
  )
190
198
 
@@ -206,6 +214,23 @@ from db._hot_context import (
206
214
  resolve_hot_context,
207
215
  )
208
216
 
217
+ # Outcomes
218
+ from db._outcomes import (
219
+ VALID_METRIC_SOURCES as OUTCOME_METRIC_SOURCES,
220
+ VALID_TARGET_OPERATORS as OUTCOME_TARGET_OPERATORS,
221
+ create_outcome, get_outcome, list_outcomes,
222
+ cancel_outcome, evaluate_outcome, pending_outcomes_due,
223
+ find_pending_outcomes_by_action, set_linked_outcomes_met,
224
+ list_outcome_pattern_candidates, capture_outcome_pattern,
225
+ )
226
+
227
+ # Goal Engine v1
228
+ from db._goal_profiles import (
229
+ DEFAULT_GOAL_PROFILES,
230
+ ensure_default_goal_profiles, get_goal_profile, list_goal_profiles,
231
+ upsert_goal_profile, resolve_goal_profile,
232
+ )
233
+
209
234
 
210
235
  def get_db():
211
236
  return _module("db._core").get_db()
@@ -0,0 +1,376 @@
1
+ from __future__ import annotations
2
+ """Goal Engine v1 — explicit optimization profiles for durable goals and decisions."""
3
+
4
+ import json
5
+
6
+ from db._core import get_db
7
+ from db._workflow import get_workflow_goal
8
+
9
+ VALID_SCOPE_TYPES = {"default", "area", "task_type", "goal_id"}
10
+ VALID_STATUSES = {"active", "disabled"}
11
+ WEIGHT_KEYS = ("impact", "success", "risk", "somatic")
12
+ DEFAULT_WEIGHTS = {
13
+ "impact": 0.35,
14
+ "success": 0.30,
15
+ "risk": 0.20,
16
+ "somatic": 0.15,
17
+ }
18
+ DEFAULT_GOAL_PROFILES = (
19
+ {
20
+ "profile_id": "default_balanced",
21
+ "profile_name": "Balanced default",
22
+ "description": "Balancea impacto, exito, riesgo y huella somatica para decisiones generales.",
23
+ "scope_type": "default",
24
+ "scope_value": "",
25
+ "goal_labels": ["maximise_success", "minimise_risk", "preserve_trust"],
26
+ "weights": DEFAULT_WEIGHTS,
27
+ "status": "active",
28
+ "source": "system",
29
+ },
30
+ {
31
+ "profile_id": "release_safety",
32
+ "profile_name": "Release safety",
33
+ "description": "Favorece decisiones reversibles y verificadas en release, deploy y cambios publicos.",
34
+ "scope_type": "area",
35
+ "scope_value": "release",
36
+ "goal_labels": ["minimise_risk", "preserve_trust", "maximise_success"],
37
+ "weights": {
38
+ "impact": 0.24,
39
+ "success": 0.28,
40
+ "risk": 0.30,
41
+ "somatic": 0.18,
42
+ },
43
+ "status": "active",
44
+ "source": "system",
45
+ },
46
+ {
47
+ "profile_id": "customer_trust",
48
+ "profile_name": "Customer trust",
49
+ "description": "Favorece decisiones que preservan confianza y reducen friccion con clientes.",
50
+ "scope_type": "area",
51
+ "scope_value": "customer",
52
+ "goal_labels": ["preserve_trust", "maximise_success", "minimise_risk"],
53
+ "weights": {
54
+ "impact": 0.25,
55
+ "success": 0.31,
56
+ "risk": 0.26,
57
+ "somatic": 0.18,
58
+ },
59
+ "status": "active",
60
+ "source": "system",
61
+ },
62
+ {
63
+ "profile_id": "ops_efficiency",
64
+ "profile_name": "Operations efficiency",
65
+ "description": "Favorece throughput operativo manteniendo riesgo contenido en ejecucion rutinaria.",
66
+ "scope_type": "task_type",
67
+ "scope_value": "execute",
68
+ "goal_labels": ["maximise_efficiency", "maximise_success", "minimise_risk"],
69
+ "weights": {
70
+ "impact": 0.38,
71
+ "success": 0.28,
72
+ "risk": 0.20,
73
+ "somatic": 0.14,
74
+ },
75
+ "status": "active",
76
+ "source": "system",
77
+ },
78
+ {
79
+ "profile_id": "business_growth",
80
+ "profile_name": "Business growth",
81
+ "description": "Da mas peso a impacto y exito cuando el contexto busca crecimiento o revenue.",
82
+ "scope_type": "area",
83
+ "scope_value": "business",
84
+ "goal_labels": ["maximise_business_impact", "maximise_success"],
85
+ "weights": {
86
+ "impact": 0.56,
87
+ "success": 0.22,
88
+ "risk": 0.14,
89
+ "somatic": 0.08,
90
+ },
91
+ "status": "active",
92
+ "source": "system",
93
+ },
94
+ )
95
+
96
+
97
+ def _parse_json(value, default):
98
+ if value in (None, ""):
99
+ return default
100
+ if isinstance(value, (dict, list)):
101
+ return value
102
+ try:
103
+ return json.loads(value)
104
+ except Exception:
105
+ return default
106
+
107
+
108
+ def _normalize_goal_labels(labels) -> list[str]:
109
+ parsed = _parse_json(labels, labels if isinstance(labels, list) else [])
110
+ if not isinstance(parsed, list):
111
+ return []
112
+ seen: set[str] = set()
113
+ result: list[str] = []
114
+ for item in parsed:
115
+ clean = str(item or "").strip()
116
+ if not clean or clean in seen:
117
+ continue
118
+ seen.add(clean)
119
+ result.append(clean)
120
+ return result
121
+
122
+
123
+ def _normalize_weights(weights) -> dict:
124
+ parsed = _parse_json(weights, weights if isinstance(weights, dict) else {})
125
+ if not isinstance(parsed, dict):
126
+ parsed = {}
127
+ collected: dict[str, float] = {}
128
+ for key in WEIGHT_KEYS:
129
+ try:
130
+ value = float(parsed.get(key, DEFAULT_WEIGHTS[key]))
131
+ except (TypeError, ValueError):
132
+ value = DEFAULT_WEIGHTS[key]
133
+ collected[key] = max(0.01, value)
134
+ total = sum(collected.values())
135
+ if total <= 0:
136
+ collected = dict(DEFAULT_WEIGHTS)
137
+ total = sum(collected.values())
138
+ return {key: round(collected[key] / total, 4) for key in WEIGHT_KEYS}
139
+
140
+
141
+ def _row_to_goal_profile(row, *, resolved_by: str = "") -> dict | None:
142
+ if not row:
143
+ return None
144
+ profile = dict(row)
145
+ profile["goal_labels"] = _normalize_goal_labels(profile.get("goal_labels"))
146
+ profile["weights"] = _normalize_weights(profile.get("weights"))
147
+ if resolved_by:
148
+ profile["resolved_by"] = resolved_by
149
+ return profile
150
+
151
+
152
+ def ensure_default_goal_profiles() -> None:
153
+ conn = get_db()
154
+ for profile in DEFAULT_GOAL_PROFILES:
155
+ conn.execute(
156
+ """INSERT OR IGNORE INTO goal_profiles (
157
+ profile_id, profile_name, description, scope_type, scope_value,
158
+ goal_labels, weights, status, source
159
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
160
+ (
161
+ profile["profile_id"],
162
+ profile["profile_name"],
163
+ profile["description"],
164
+ profile["scope_type"],
165
+ profile["scope_value"],
166
+ json.dumps(profile["goal_labels"], ensure_ascii=False),
167
+ json.dumps(_normalize_weights(profile["weights"]), ensure_ascii=False),
168
+ profile["status"],
169
+ profile["source"],
170
+ ),
171
+ )
172
+ conn.commit()
173
+
174
+
175
+ def get_goal_profile(profile_id: str) -> dict | None:
176
+ ensure_default_goal_profiles()
177
+ conn = get_db()
178
+ row = conn.execute(
179
+ "SELECT * FROM goal_profiles WHERE profile_id = ?",
180
+ ((profile_id or "").strip(),),
181
+ ).fetchone()
182
+ return _row_to_goal_profile(row)
183
+
184
+
185
+ def list_goal_profiles(*, scope_type: str = "", status: str = "active", limit: int = 50) -> list[dict]:
186
+ ensure_default_goal_profiles()
187
+ conn = get_db()
188
+ clauses = []
189
+ params: list[object] = []
190
+ clean_scope = (scope_type or "").strip()
191
+ clean_status = (status or "").strip()
192
+ if clean_scope:
193
+ clauses.append("scope_type = ?")
194
+ params.append(clean_scope)
195
+ if clean_status:
196
+ clauses.append("status = ?")
197
+ params.append(clean_status)
198
+ where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
199
+ rows = conn.execute(
200
+ f"""SELECT * FROM goal_profiles
201
+ {where}
202
+ ORDER BY
203
+ CASE scope_type
204
+ WHEN 'default' THEN 0
205
+ WHEN 'area' THEN 1
206
+ WHEN 'task_type' THEN 2
207
+ WHEN 'goal_id' THEN 3
208
+ ELSE 9
209
+ END,
210
+ profile_id ASC
211
+ LIMIT ?""",
212
+ params + [max(1, int(limit))],
213
+ ).fetchall()
214
+ return [_row_to_goal_profile(row) for row in rows if row]
215
+
216
+
217
+ def upsert_goal_profile(
218
+ *,
219
+ profile_id: str,
220
+ profile_name: str = "",
221
+ description: str = "",
222
+ scope_type: str = "default",
223
+ scope_value: str = "",
224
+ goal_labels=None,
225
+ weights=None,
226
+ status: str = "active",
227
+ source: str = "manual",
228
+ ) -> dict:
229
+ ensure_default_goal_profiles()
230
+ clean_id = (profile_id or "").strip()
231
+ if not clean_id:
232
+ raise ValueError("profile_id is required")
233
+ clean_scope = (scope_type or "default").strip()
234
+ if clean_scope not in VALID_SCOPE_TYPES:
235
+ raise ValueError(f"scope_type must be one of: {', '.join(sorted(VALID_SCOPE_TYPES))}")
236
+ clean_status = (status or "active").strip().lower()
237
+ if clean_status not in VALID_STATUSES:
238
+ raise ValueError(f"status must be one of: {', '.join(sorted(VALID_STATUSES))}")
239
+
240
+ normalized_weights = _normalize_weights(weights)
241
+ normalized_labels = _normalize_goal_labels(goal_labels)
242
+ conn = get_db()
243
+ existing = conn.execute(
244
+ "SELECT * FROM goal_profiles WHERE profile_id = ?",
245
+ (clean_id,),
246
+ ).fetchone()
247
+ if existing:
248
+ current = dict(existing)
249
+ conn.execute(
250
+ """UPDATE goal_profiles
251
+ SET profile_name = ?,
252
+ description = ?,
253
+ scope_type = ?,
254
+ scope_value = ?,
255
+ goal_labels = ?,
256
+ weights = ?,
257
+ status = ?,
258
+ source = ?,
259
+ updated_at = datetime('now')
260
+ WHERE profile_id = ?""",
261
+ (
262
+ (profile_name or current.get("profile_name") or clean_id).strip(),
263
+ (description or current.get("description") or "").strip(),
264
+ clean_scope,
265
+ (scope_value or current.get("scope_value") or "").strip().lower(),
266
+ json.dumps(normalized_labels or _normalize_goal_labels(current.get("goal_labels")), ensure_ascii=False),
267
+ json.dumps(normalized_weights, ensure_ascii=False),
268
+ clean_status,
269
+ (source or current.get("source") or "manual").strip(),
270
+ clean_id,
271
+ ),
272
+ )
273
+ else:
274
+ conn.execute(
275
+ """INSERT INTO goal_profiles (
276
+ profile_id, profile_name, description, scope_type, scope_value,
277
+ goal_labels, weights, status, source
278
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
279
+ (
280
+ clean_id,
281
+ (profile_name or clean_id).strip(),
282
+ (description or "").strip(),
283
+ clean_scope,
284
+ (scope_value or "").strip().lower(),
285
+ json.dumps(normalized_labels, ensure_ascii=False),
286
+ json.dumps(normalized_weights, ensure_ascii=False),
287
+ clean_status,
288
+ (source or "manual").strip(),
289
+ ),
290
+ )
291
+ conn.commit()
292
+ return get_goal_profile(clean_id) or {}
293
+
294
+
295
+ def resolve_goal_profile(
296
+ *,
297
+ profile_id: str = "",
298
+ area: str = "",
299
+ task_type: str = "",
300
+ goal_id: str = "",
301
+ ) -> dict:
302
+ ensure_default_goal_profiles()
303
+ conn = get_db()
304
+ explicit_id = (profile_id or "").strip()
305
+ if explicit_id:
306
+ explicit = get_goal_profile(explicit_id)
307
+ if not explicit:
308
+ raise ValueError(f"Unknown goal profile: {explicit_id}")
309
+ if explicit.get("status") != "active":
310
+ raise ValueError(f"Goal profile {explicit_id} is not active")
311
+ explicit["resolved_by"] = "explicit"
312
+ return explicit
313
+
314
+ clean_goal_id = (goal_id or "").strip()
315
+ if clean_goal_id:
316
+ workflow_goal = get_workflow_goal(clean_goal_id)
317
+ if workflow_goal:
318
+ shared_state = workflow_goal.get("shared_state") or {}
319
+ shared_profile_id = str(shared_state.get("goal_profile_id", "")).strip()
320
+ if shared_profile_id:
321
+ linked = get_goal_profile(shared_profile_id)
322
+ if linked and linked.get("status") == "active":
323
+ linked["resolved_by"] = "workflow_goal.shared_state"
324
+ return linked
325
+ row = conn.execute(
326
+ """SELECT * FROM goal_profiles
327
+ WHERE scope_type = 'goal_id' AND scope_value = ? AND status = 'active'
328
+ ORDER BY updated_at DESC, profile_id ASC
329
+ LIMIT 1""",
330
+ (clean_goal_id,),
331
+ ).fetchone()
332
+ if row:
333
+ return _row_to_goal_profile(row, resolved_by="goal_id") or {}
334
+
335
+ clean_area = (area or "").strip().lower()
336
+ if clean_area:
337
+ row = conn.execute(
338
+ """SELECT * FROM goal_profiles
339
+ WHERE scope_type = 'area' AND scope_value = ? AND status = 'active'
340
+ ORDER BY updated_at DESC, profile_id ASC
341
+ LIMIT 1""",
342
+ (clean_area,),
343
+ ).fetchone()
344
+ if row:
345
+ return _row_to_goal_profile(row, resolved_by="area") or {}
346
+
347
+ clean_type = (task_type or "").strip().lower()
348
+ if clean_type:
349
+ row = conn.execute(
350
+ """SELECT * FROM goal_profiles
351
+ WHERE scope_type = 'task_type' AND scope_value = ? AND status = 'active'
352
+ ORDER BY updated_at DESC, profile_id ASC
353
+ LIMIT 1""",
354
+ (clean_type,),
355
+ ).fetchone()
356
+ if row:
357
+ return _row_to_goal_profile(row, resolved_by="task_type") or {}
358
+
359
+ row = conn.execute(
360
+ """SELECT * FROM goal_profiles
361
+ WHERE scope_type = 'default' AND status = 'active'
362
+ ORDER BY updated_at DESC, profile_id ASC
363
+ LIMIT 1"""
364
+ ).fetchone()
365
+ return _row_to_goal_profile(row, resolved_by="default") or {
366
+ "profile_id": "default_balanced",
367
+ "profile_name": "Balanced default",
368
+ "description": DEFAULT_GOAL_PROFILES[0]["description"],
369
+ "scope_type": "default",
370
+ "scope_value": "",
371
+ "goal_labels": list(DEFAULT_GOAL_PROFILES[0]["goal_labels"]),
372
+ "weights": dict(DEFAULT_WEIGHTS),
373
+ "status": "active",
374
+ "source": "system",
375
+ "resolved_by": "fallback_default",
376
+ }