nexo-brain 7.30.21 → 7.30.23

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.
@@ -0,0 +1,933 @@
1
+ from __future__ import annotations
2
+
3
+ """Opportunity Orchestrator MVP.
4
+
5
+ This module builds a sparse, evidence-backed proactive queue. It is deliberately
6
+ not a psychological profile layer: it never ranks proposals from mood labels,
7
+ sentiment labels, or personality inferences, and Phase 1 never performs external
8
+ actions.
9
+ """
10
+
11
+ import datetime as _dt
12
+ import hashlib
13
+ import json
14
+ import re
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+
19
+ NORMAL_PROPOSAL_LIMIT = 3
20
+ DEFAULT_PROPOSAL_THRESHOLD = 2.45
21
+ SAFE_ACTION_CLASSES = {"read_only", "prepare_artifact", "local_reversible"}
22
+ VALID_FEEDBACK = {
23
+ "accepted",
24
+ "ignored",
25
+ "snoozed",
26
+ "dismissed",
27
+ "false_positive",
28
+ "useful_but_later",
29
+ }
30
+ FORBIDDEN_VISIBLE_TERMS = {
31
+ "anxious",
32
+ "depressed",
33
+ "vulnerable",
34
+ "unstable",
35
+ "burnout",
36
+ "manipulable",
37
+ "compliance_likely",
38
+ "frustrated",
39
+ "mood",
40
+ "tension",
41
+ }
42
+
43
+
44
+ def _now_iso() -> str:
45
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
46
+
47
+
48
+ def _today() -> str:
49
+ return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d")
50
+
51
+
52
+ def _expires(days: int = 14) -> str:
53
+ return (
54
+ _dt.datetime.now(_dt.timezone.utc)
55
+ + _dt.timedelta(days=max(1, int(days or 14)))
56
+ ).replace(microsecond=0).isoformat().replace("+00:00", "Z")
57
+
58
+
59
+ def _hash_id(prefix: str, value: str, length: int = 20) -> str:
60
+ digest = hashlib.sha256(str(value).encode("utf-8", errors="ignore")).hexdigest()[:length]
61
+ return f"{prefix}-{digest}"
62
+
63
+
64
+ def _safe_json(payload: Any) -> str:
65
+ try:
66
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
67
+ except Exception:
68
+ return "{}"
69
+
70
+
71
+ def _parse_json(value: str | None, default: Any) -> Any:
72
+ try:
73
+ parsed = json.loads(value or "")
74
+ return parsed if parsed is not None else default
75
+ except Exception:
76
+ return default
77
+
78
+
79
+ def _row(row: Any) -> dict[str, Any]:
80
+ if row is None:
81
+ return {}
82
+ if hasattr(row, "keys"):
83
+ return {key: row[key] for key in row.keys()}
84
+ return dict(row)
85
+
86
+
87
+ def _parse_time(value: Any) -> _dt.datetime | None:
88
+ if value in ("", None):
89
+ return None
90
+ if isinstance(value, (int, float)):
91
+ try:
92
+ return _dt.datetime.fromtimestamp(float(value), tz=_dt.timezone.utc)
93
+ except Exception:
94
+ return None
95
+ text = str(value).strip()
96
+ if not text:
97
+ return None
98
+ for candidate in (text, text.replace("Z", "+00:00")):
99
+ try:
100
+ parsed = _dt.datetime.fromisoformat(candidate)
101
+ if parsed.tzinfo is None:
102
+ parsed = parsed.replace(tzinfo=_dt.timezone.utc)
103
+ return parsed
104
+ except Exception:
105
+ pass
106
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
107
+ try:
108
+ parsed = _dt.datetime.strptime(text[:19] if fmt != "%Y-%m-%d" else text[:10], fmt)
109
+ return parsed.replace(tzinfo=_dt.timezone.utc)
110
+ except Exception:
111
+ continue
112
+ return None
113
+
114
+
115
+ def _expired(value: Any) -> bool:
116
+ parsed = _parse_time(value)
117
+ return bool(parsed and parsed < _dt.datetime.now(_dt.timezone.utc))
118
+
119
+
120
+ def _clamp(value: Any, default: float = 0.0) -> float:
121
+ try:
122
+ raw = float(value)
123
+ except Exception:
124
+ raw = default
125
+ return max(0.0, min(1.0, raw))
126
+
127
+
128
+ def _sanitize_text(value: Any, limit: int = 360) -> str:
129
+ text = re.sub(r"\s+", " ", str(value or "")).strip()
130
+ for term in sorted(FORBIDDEN_VISIBLE_TERMS, key=len, reverse=True):
131
+ text = re.sub(rf"\b{re.escape(term)}\b", "operational signal", text, flags=re.IGNORECASE)
132
+ if len(text) > limit:
133
+ text = text[: max(0, limit - 1)].rstrip() + "..."
134
+ return text
135
+
136
+
137
+ def _source_hash(payload: Any) -> str:
138
+ return hashlib.sha256(_safe_json(payload).encode("utf-8", errors="ignore")).hexdigest()
139
+
140
+
141
+ def _score(payload: dict[str, Any]) -> float:
142
+ score = (
143
+ float(payload.get("impact") or 0)
144
+ + float(payload.get("urgency") or 0)
145
+ + float(payload.get("confidence") or 0)
146
+ + float(payload.get("readiness") or 0)
147
+ + float(payload.get("user_burden_reduction") or 0)
148
+ + float(payload.get("strategic_alignment") or 0)
149
+ - float(payload.get("risk") or 0)
150
+ - float(payload.get("interruption_cost") or 0)
151
+ - float(payload.get("repetition_penalty") or 0)
152
+ )
153
+ return round(max(0.0, score), 4)
154
+
155
+
156
+ def _table_exists(conn, table: str) -> bool:
157
+ row = conn.execute(
158
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
159
+ (table,),
160
+ ).fetchone()
161
+ return row is not None
162
+
163
+
164
+ def _opportunity_type_from_closure(item: dict[str, Any]) -> str:
165
+ source = str(item.get("source_primary") or "").lower()
166
+ kind = str(item.get("kind") or "").lower()
167
+ if "debt" in source or "blocked" in kind:
168
+ return "remediation"
169
+ if "outcome" in source:
170
+ return "closure"
171
+ if "followup_due" in kind:
172
+ return "deadline"
173
+ if "mcp_write_queue" in source:
174
+ return "remediation"
175
+ if "release" in (str(item.get("title") or "") + " " + str(item.get("summary") or "")).lower():
176
+ return "product"
177
+ return "closure"
178
+
179
+
180
+ def _domain_from_item(item: dict[str, Any]) -> str:
181
+ text = (str(item.get("title") or "") + " " + str(item.get("summary") or "")).lower()
182
+ if "desktop" in text:
183
+ return "desktop"
184
+ if "release" in text or "publish" in text:
185
+ return "release"
186
+ if "email" in text or "correo" in text:
187
+ return "email"
188
+ if "ads" in text or "google" in text:
189
+ return "ads"
190
+ if "nexo" in text or "brain" in text:
191
+ return "brain"
192
+ return "general"
193
+
194
+
195
+ def _authorization_status(action_class: str) -> str:
196
+ clean = str(action_class or "read_only").strip() or "read_only"
197
+ return "not_required" if clean in SAFE_ACTION_CLASSES else "needs_permission"
198
+
199
+
200
+ def _candidate_from_closure(item: dict[str, Any]) -> dict[str, Any]:
201
+ source_id = str(item.get("id") or item.get("dedupe_key") or "")
202
+ source_type = "closure_items"
203
+ title_source = _sanitize_text(item.get("title") or "Operational opportunity", 160)
204
+ opportunity_type = _opportunity_type_from_closure(item)
205
+ domain = _domain_from_item(item)
206
+ impact = _clamp(item.get("impact_score"), 0.55)
207
+ urgency = _clamp(item.get("urgency_score"), 0.45)
208
+ confidence = max(0.45, _clamp(item.get("confidence_score"), 0.75))
209
+ risk = _clamp(item.get("risk_score"), 0.15)
210
+ readiness = 0.72 if str(item.get("evidence_required") or "").strip() else 0.58
211
+ burden = 0.68 if str(item.get("next_action") or "").strip() else 0.52
212
+ strategic = 0.6 if domain in {"release", "desktop", "brain"} else 0.42
213
+ interruption_cost = 0.32
214
+ action_class = "read_only"
215
+ dedupe_key = f"{source_type}:{source_id}"
216
+ opportunity_id = _hash_id("OPP", dedupe_key)
217
+ now = _now_iso()
218
+ source_payload = {
219
+ "source_primary": item.get("source_primary"),
220
+ "kind": item.get("kind"),
221
+ "state": item.get("state"),
222
+ "deadline_at": item.get("deadline_at"),
223
+ }
224
+ score_payload = {
225
+ "impact": impact,
226
+ "urgency": urgency,
227
+ "confidence": confidence,
228
+ "readiness": readiness,
229
+ "user_burden_reduction": burden,
230
+ "strategic_alignment": strategic,
231
+ "risk": risk,
232
+ "interruption_cost": interruption_cost,
233
+ "repetition_penalty": 0.0,
234
+ }
235
+ opportunity = {
236
+ "id": opportunity_id,
237
+ "title": f"Prepared review: {title_source}",
238
+ "hypothesis": "This open operational item has enough evidence for a read-only preparation.",
239
+ "domain": domain,
240
+ "opportunity_type": opportunity_type,
241
+ "dedupe_key": dedupe_key,
242
+ "impact": impact,
243
+ "urgency": urgency,
244
+ "confidence": confidence,
245
+ "risk": risk,
246
+ "effort": 0.25,
247
+ "readiness": readiness,
248
+ "user_burden_reduction": burden,
249
+ "interruption_cost": interruption_cost,
250
+ "strategic_alignment": strategic,
251
+ "repetition_penalty": 0.0,
252
+ "score": _score(score_payload),
253
+ "state": "candidate",
254
+ "owner": "nero",
255
+ "why_now": _sanitize_text(
256
+ item.get("blocker_reason")
257
+ or item.get("evidence_required")
258
+ or "The source remains open and can be reduced to a short review.",
259
+ 360,
260
+ ),
261
+ "next_action": _sanitize_text(
262
+ item.get("next_action")
263
+ or "Inspect evidence and choose accept, snooze, or suppress.",
264
+ 360,
265
+ ),
266
+ "action_class": action_class,
267
+ "authorization_status": _authorization_status(action_class),
268
+ "created_at": now,
269
+ "updated_at": now,
270
+ "expires_at": item.get("deadline_at") or _expires(14),
271
+ "last_proposed_at": "",
272
+ "source_payload_json": _safe_json(source_payload),
273
+ }
274
+ signal = {
275
+ "id": _hash_id("SIG", f"{dedupe_key}:signal"),
276
+ "source_type": source_type,
277
+ "source_id": source_id,
278
+ "entity_ref": _sanitize_text(domain, 80),
279
+ "summary": _sanitize_text(item.get("title") or item.get("summary") or source_id, 280),
280
+ "signal_kind": _sanitize_text(item.get("kind") or opportunity_type, 80),
281
+ "urgency": urgency,
282
+ "confidence": confidence,
283
+ "privacy_level": "normal",
284
+ "source_hash": _source_hash(source_payload),
285
+ "created_at": now,
286
+ "expires_at": opportunity["expires_at"],
287
+ }
288
+ evidence = {
289
+ "id": _hash_id("OPE", f"{opportunity_id}:{source_type}:{source_id}"),
290
+ "opportunity_id": opportunity_id,
291
+ "source_type": source_type,
292
+ "source_id": source_id,
293
+ "evidence_summary": _sanitize_text(item.get("summary") or item.get("evidence_required") or item.get("title"), 360),
294
+ "confidence": confidence,
295
+ "created_at": now,
296
+ }
297
+ preparation = {
298
+ "id": _hash_id("PREP", f"{opportunity_id}:decision_card"),
299
+ "opportunity_id": opportunity_id,
300
+ "artifact_type": "decision_card",
301
+ "artifact_ref": f"nexo://opportunity/{opportunity_id}",
302
+ "safe_mode": 1,
303
+ "approval_required": 0,
304
+ "status": "ready",
305
+ "created_at": now,
306
+ "expires_at": opportunity["expires_at"],
307
+ }
308
+ return {
309
+ "signal": signal,
310
+ "opportunity": opportunity,
311
+ "evidence": [evidence],
312
+ "preparations": [preparation],
313
+ }
314
+
315
+
316
+ def _collect_from_closure(conn, limit_per_source: int = 250) -> list[dict[str, Any]]:
317
+ from closure_plane import closure_next, refresh_closure_items
318
+
319
+ refresh_closure_items(conn, limit_per_adapter=limit_per_source)
320
+ items = closure_next(conn, limit=limit_per_source, include_waiting=True)
321
+ return [_candidate_from_closure(item) for item in items]
322
+
323
+
324
+ def _filtered_sources(sources: str) -> set[str]:
325
+ return {part.strip().lower() for part in str(sources or "").split(",") if part.strip()}
326
+
327
+
328
+ def collect_candidates(conn=None, *, sources: str = "", limit_per_source: int = 250) -> list[dict[str, Any]]:
329
+ if conn is None:
330
+ from db import get_db
331
+ from db._schema import run_migrations
332
+
333
+ conn = get_db()
334
+ run_migrations(conn)
335
+ selected = _filtered_sources(sources)
336
+ candidates: list[dict[str, Any]] = []
337
+ if not selected or "closure" in selected or "closure_items" in selected:
338
+ candidates.extend(_collect_from_closure(conn, limit_per_source=limit_per_source))
339
+ return candidates
340
+
341
+
342
+ def _upsert_signal(conn, signal: dict[str, Any]) -> None:
343
+ conn.execute(
344
+ """
345
+ INSERT INTO nexo_signals (
346
+ id, source_type, source_id, entity_ref, summary, signal_kind,
347
+ urgency, confidence, privacy_level, source_hash, created_at, expires_at
348
+ ) VALUES (
349
+ :id, :source_type, :source_id, :entity_ref, :summary, :signal_kind,
350
+ :urgency, :confidence, :privacy_level, :source_hash, :created_at, :expires_at
351
+ )
352
+ ON CONFLICT(source_type, source_id, signal_kind) DO UPDATE SET
353
+ entity_ref = excluded.entity_ref,
354
+ summary = excluded.summary,
355
+ urgency = excluded.urgency,
356
+ confidence = excluded.confidence,
357
+ privacy_level = excluded.privacy_level,
358
+ source_hash = excluded.source_hash,
359
+ expires_at = excluded.expires_at
360
+ """,
361
+ signal,
362
+ )
363
+
364
+
365
+ def _upsert_opportunity(conn, opportunity: dict[str, Any]) -> None:
366
+ conn.execute(
367
+ """
368
+ INSERT INTO nexo_opportunities (
369
+ id, title, hypothesis, domain, opportunity_type, dedupe_key,
370
+ impact, urgency, confidence, risk, effort, readiness,
371
+ user_burden_reduction, interruption_cost, strategic_alignment,
372
+ repetition_penalty, score, state, owner, why_now, next_action,
373
+ action_class, authorization_status, created_at, updated_at,
374
+ expires_at, last_proposed_at, source_payload_json
375
+ ) VALUES (
376
+ :id, :title, :hypothesis, :domain, :opportunity_type, :dedupe_key,
377
+ :impact, :urgency, :confidence, :risk, :effort, :readiness,
378
+ :user_burden_reduction, :interruption_cost, :strategic_alignment,
379
+ :repetition_penalty, :score, :state, :owner, :why_now, :next_action,
380
+ :action_class, :authorization_status, :created_at, :updated_at,
381
+ :expires_at, :last_proposed_at, :source_payload_json
382
+ )
383
+ ON CONFLICT(dedupe_key) DO UPDATE SET
384
+ title = excluded.title,
385
+ hypothesis = excluded.hypothesis,
386
+ domain = excluded.domain,
387
+ opportunity_type = excluded.opportunity_type,
388
+ impact = excluded.impact,
389
+ urgency = excluded.urgency,
390
+ confidence = excluded.confidence,
391
+ risk = excluded.risk,
392
+ effort = excluded.effort,
393
+ readiness = excluded.readiness,
394
+ user_burden_reduction = excluded.user_burden_reduction,
395
+ interruption_cost = excluded.interruption_cost,
396
+ strategic_alignment = excluded.strategic_alignment,
397
+ score = excluded.score,
398
+ why_now = excluded.why_now,
399
+ next_action = excluded.next_action,
400
+ action_class = excluded.action_class,
401
+ authorization_status = excluded.authorization_status,
402
+ updated_at = excluded.updated_at,
403
+ expires_at = excluded.expires_at,
404
+ source_payload_json = excluded.source_payload_json
405
+ WHERE nexo_opportunities.state NOT IN ('closed', 'discarded', 'suppressed', 'stale')
406
+ """,
407
+ opportunity,
408
+ )
409
+
410
+
411
+ def _upsert_evidence(conn, evidence: dict[str, Any]) -> None:
412
+ conn.execute(
413
+ """
414
+ INSERT INTO nexo_opportunity_evidence (
415
+ id, opportunity_id, source_type, source_id, evidence_summary, confidence, created_at
416
+ ) VALUES (
417
+ :id, :opportunity_id, :source_type, :source_id, :evidence_summary, :confidence, :created_at
418
+ )
419
+ ON CONFLICT(opportunity_id, source_type, source_id) DO UPDATE SET
420
+ evidence_summary = excluded.evidence_summary,
421
+ confidence = excluded.confidence
422
+ """,
423
+ evidence,
424
+ )
425
+
426
+
427
+ def _upsert_preparation(conn, preparation: dict[str, Any]) -> None:
428
+ conn.execute(
429
+ """
430
+ INSERT INTO nexo_preparations (
431
+ id, opportunity_id, artifact_type, artifact_ref, safe_mode,
432
+ approval_required, status, created_at, expires_at
433
+ ) VALUES (
434
+ :id, :opportunity_id, :artifact_type, :artifact_ref, :safe_mode,
435
+ :approval_required, :status, :created_at, :expires_at
436
+ )
437
+ ON CONFLICT(opportunity_id, artifact_type, artifact_ref) DO UPDATE SET
438
+ safe_mode = excluded.safe_mode,
439
+ approval_required = excluded.approval_required,
440
+ status = excluded.status,
441
+ expires_at = excluded.expires_at
442
+ """,
443
+ preparation,
444
+ )
445
+
446
+
447
+ def _persist_candidates(conn, candidates: list[dict[str, Any]]) -> dict[str, int]:
448
+ counts = {"signals": 0, "opportunities": 0, "evidence": 0, "preparations": 0}
449
+ for candidate in candidates:
450
+ _upsert_signal(conn, candidate["signal"])
451
+ counts["signals"] += 1
452
+ _upsert_opportunity(conn, candidate["opportunity"])
453
+ counts["opportunities"] += 1
454
+ for evidence in candidate.get("evidence") or []:
455
+ _upsert_evidence(conn, evidence)
456
+ counts["evidence"] += 1
457
+ for preparation in candidate.get("preparations") or []:
458
+ _upsert_preparation(conn, preparation)
459
+ counts["preparations"] += 1
460
+ conn.commit()
461
+ return counts
462
+
463
+
464
+ def _active_suppression(conn, scope_type: str, scope_key: str) -> dict[str, Any] | None:
465
+ if not scope_key:
466
+ return None
467
+ rows = conn.execute(
468
+ """
469
+ SELECT *
470
+ FROM nexo_suppression_rules
471
+ WHERE scope_type = ? AND scope_key = ?
472
+ ORDER BY created_at DESC
473
+ """,
474
+ (scope_type, scope_key),
475
+ ).fetchall()
476
+ for raw in rows:
477
+ row = _row(raw)
478
+ if not _expired(row.get("expires_at")):
479
+ return row
480
+ return None
481
+
482
+
483
+ def _opportunity_evidence(conn, opportunity_id: str) -> list[dict[str, Any]]:
484
+ rows = conn.execute(
485
+ """
486
+ SELECT *
487
+ FROM nexo_opportunity_evidence
488
+ WHERE opportunity_id = ?
489
+ ORDER BY confidence DESC, created_at DESC
490
+ """,
491
+ (opportunity_id,),
492
+ ).fetchall()
493
+ return [_row(row) for row in rows]
494
+
495
+
496
+ def _opportunity_preparations(conn, opportunity_id: str) -> list[dict[str, Any]]:
497
+ rows = conn.execute(
498
+ """
499
+ SELECT *
500
+ FROM nexo_preparations
501
+ WHERE opportunity_id = ? AND status NOT IN ('stale', 'deleted')
502
+ ORDER BY created_at DESC
503
+ """,
504
+ (opportunity_id,),
505
+ ).fetchall()
506
+ return [_row(row) for row in rows]
507
+
508
+
509
+ def _proposal_copy(opportunity: dict[str, Any], evidence: list[dict[str, Any]]) -> str:
510
+ evidence_count = len(evidence)
511
+ return _sanitize_text(
512
+ f"{opportunity.get('title')}. Evidence refs: {evidence_count}. "
513
+ f"Why now: {opportunity.get('why_now')}",
514
+ 520,
515
+ )
516
+
517
+
518
+ def _create_or_update_proposal(conn, opportunity: dict[str, Any], surface: str, evidence: list[dict[str, Any]]) -> dict[str, Any]:
519
+ now = _now_iso()
520
+ proposal_id = _hash_id("PROP", f"{opportunity['id']}:{surface}")
521
+ payload = {
522
+ "id": proposal_id,
523
+ "opportunity_id": opportunity["id"],
524
+ "surface": surface,
525
+ "copy": _proposal_copy(opportunity, evidence),
526
+ "cta_primary": "Inspect evidence",
527
+ "cta_secondary": "Snooze",
528
+ "shown_at": "",
529
+ "feedback": "",
530
+ "created_at": now,
531
+ }
532
+ conn.execute(
533
+ """
534
+ INSERT INTO nexo_proposals (
535
+ id, opportunity_id, surface, copy, cta_primary, cta_secondary,
536
+ shown_at, feedback, created_at
537
+ ) VALUES (
538
+ :id, :opportunity_id, :surface, :copy, :cta_primary, :cta_secondary,
539
+ :shown_at, :feedback, :created_at
540
+ )
541
+ ON CONFLICT(opportunity_id, surface) DO UPDATE SET
542
+ copy = excluded.copy,
543
+ cta_primary = excluded.cta_primary,
544
+ cta_secondary = excluded.cta_secondary
545
+ """,
546
+ payload,
547
+ )
548
+ conn.execute(
549
+ "UPDATE nexo_opportunities SET state = 'proposed', last_proposed_at = ?, updated_at = ? WHERE id = ?",
550
+ (now, now, opportunity["id"]),
551
+ )
552
+ conn.commit()
553
+ row = conn.execute("SELECT * FROM nexo_proposals WHERE id = ?", (proposal_id,)).fetchone()
554
+ return _row(row)
555
+
556
+
557
+ def _proposal_payload(conn, proposal: dict[str, Any]) -> dict[str, Any]:
558
+ opportunity = get_opportunity(proposal.get("opportunity_id") or "", include_evidence=True, conn=conn)
559
+ item = {
560
+ "id": proposal.get("id"),
561
+ "surface": proposal.get("surface") or "",
562
+ "copy": _sanitize_text(proposal.get("copy"), 600),
563
+ "cta_primary": proposal.get("cta_primary") or "",
564
+ "cta_secondary": proposal.get("cta_secondary") or "",
565
+ "feedback": proposal.get("feedback") or "",
566
+ "created_at": proposal.get("created_at") or "",
567
+ "opportunity": opportunity.get("opportunity"),
568
+ }
569
+ if item["opportunity"]:
570
+ item["confidence"] = item["opportunity"].get("confidence")
571
+ item["evidence_refs"] = [
572
+ f"{ev.get('source_type')}:{ev.get('source_id')}"
573
+ for ev in item["opportunity"].get("evidence", [])
574
+ ]
575
+ else:
576
+ item["confidence"] = 0
577
+ item["evidence_refs"] = []
578
+ return item
579
+
580
+
581
+ def _eligible_opportunities(conn, *, include_snoozed: bool = False) -> list[dict[str, Any]]:
582
+ rows = conn.execute(
583
+ """
584
+ SELECT *
585
+ FROM nexo_opportunities
586
+ WHERE state IN ('candidate', 'prepared', 'proposed')
587
+ AND score >= ?
588
+ AND authorization_status IN ('not_required', 'needs_permission')
589
+ ORDER BY score DESC, urgency DESC, updated_at DESC
590
+ LIMIT 100
591
+ """,
592
+ (DEFAULT_PROPOSAL_THRESHOLD,),
593
+ ).fetchall()
594
+ results: list[dict[str, Any]] = []
595
+ for raw in rows:
596
+ opportunity = _row(raw)
597
+ if _expired(opportunity.get("expires_at")):
598
+ continue
599
+ if _active_suppression(conn, "opportunity", opportunity.get("id") or ""):
600
+ continue
601
+ if _active_suppression(conn, "domain", opportunity.get("domain") or ""):
602
+ continue
603
+ if _active_suppression(conn, "type", opportunity.get("opportunity_type") or ""):
604
+ continue
605
+ if not include_snoozed and _active_suppression(conn, "snooze", opportunity.get("id") or ""):
606
+ continue
607
+ evidence = _opportunity_evidence(conn, opportunity["id"])
608
+ if not evidence:
609
+ continue
610
+ opportunity["evidence"] = evidence
611
+ results.append(opportunity)
612
+ return results
613
+
614
+
615
+ def opportunity_queue(
616
+ conn=None,
617
+ *,
618
+ surface: str = "home",
619
+ limit: int = NORMAL_PROPOSAL_LIMIT,
620
+ refresh: bool = False,
621
+ include_snoozed: bool = False,
622
+ ) -> dict[str, Any]:
623
+ if conn is None:
624
+ from db import get_db
625
+ from db._schema import run_migrations
626
+
627
+ conn = get_db()
628
+ run_migrations(conn)
629
+ if refresh:
630
+ refresh_opportunities(conn, dry_run=False)
631
+ clean_surface = _sanitize_text(surface or "home", 80) or "home"
632
+ clean_limit = min(NORMAL_PROPOSAL_LIMIT, max(0, int(limit or NORMAL_PROPOSAL_LIMIT)))
633
+ if clean_limit <= 0:
634
+ return {"ok": True, "schema": "nexo.opportunity.queue.v1", "surface": clean_surface, "proposals": []}
635
+ selected = _eligible_opportunities(conn, include_snoozed=include_snoozed)[:clean_limit]
636
+ proposals = [
637
+ _proposal_payload(conn, _create_or_update_proposal(conn, opportunity, clean_surface, opportunity["evidence"]))
638
+ for opportunity in selected
639
+ ]
640
+ return {
641
+ "ok": True,
642
+ "schema": "nexo.opportunity.queue.v1",
643
+ "surface": clean_surface,
644
+ "proposal_limit": NORMAL_PROPOSAL_LIMIT,
645
+ "proposals": proposals,
646
+ "zero_proposals_ok": len(proposals) == 0,
647
+ }
648
+
649
+
650
+ def refresh_opportunities(
651
+ conn=None,
652
+ *,
653
+ dry_run: bool = True,
654
+ sources: str = "",
655
+ limit_per_source: int = 250,
656
+ write_report: bool = False,
657
+ ) -> dict[str, Any]:
658
+ if conn is None:
659
+ from db import get_db
660
+ from db._schema import run_migrations
661
+
662
+ conn = get_db()
663
+ run_migrations(conn)
664
+ clean_limit = max(1, min(int(limit_per_source or 250), 500))
665
+ candidates = collect_candidates(conn, sources=sources, limit_per_source=clean_limit)
666
+ persisted = {"signals": 0, "opportunities": 0, "evidence": 0, "preparations": 0}
667
+ if not dry_run:
668
+ persisted = _persist_candidates(conn, candidates)
669
+ candidate_summary = [
670
+ {
671
+ "opportunity_id": item["opportunity"]["id"],
672
+ "title": item["opportunity"]["title"],
673
+ "score": item["opportunity"]["score"],
674
+ "evidence_refs": [
675
+ f"{ev.get('source_type')}:{ev.get('source_id')}"
676
+ for ev in item.get("evidence", [])
677
+ ],
678
+ "selected": item["opportunity"]["score"] >= DEFAULT_PROPOSAL_THRESHOLD,
679
+ }
680
+ for item in candidates
681
+ ]
682
+ result = {
683
+ "ok": True,
684
+ "schema": "nexo.opportunity.refresh.v1",
685
+ "dry_run": bool(dry_run),
686
+ "sources": sorted(_filtered_sources(sources)) if _filtered_sources(sources) else ["closure_items"],
687
+ "observed_candidates": len(candidates),
688
+ "persisted": persisted,
689
+ "candidates": candidate_summary,
690
+ "zero_proposals_ok": True,
691
+ }
692
+ if write_report:
693
+ result["report"] = write_daily_report(conn, refresh_result=result)
694
+ return result
695
+
696
+
697
+ def get_opportunity(opportunity_id: str, *, include_evidence: bool = True, conn=None) -> dict[str, Any]:
698
+ if conn is None:
699
+ from db import get_db
700
+ from db._schema import run_migrations
701
+
702
+ conn = get_db()
703
+ run_migrations(conn)
704
+ clean_id = str(opportunity_id or "").strip()
705
+ if not clean_id:
706
+ return {"ok": False, "error": "opportunity_id is required"}
707
+ row = conn.execute(
708
+ "SELECT * FROM nexo_opportunities WHERE id = ? OR dedupe_key = ?",
709
+ (clean_id, clean_id),
710
+ ).fetchone()
711
+ if not row:
712
+ return {"ok": False, "error": "opportunity not found"}
713
+ opportunity = _row(row)
714
+ opportunity["source_payload"] = _parse_json(opportunity.pop("source_payload_json", "{}"), {})
715
+ if include_evidence:
716
+ opportunity["evidence"] = _opportunity_evidence(conn, opportunity["id"])
717
+ opportunity["preparations"] = _opportunity_preparations(conn, opportunity["id"])
718
+ return {"ok": True, "opportunity": opportunity}
719
+
720
+
721
+ def opportunity_feedback(
722
+ proposal_id: str,
723
+ feedback: str,
724
+ *,
725
+ note: str = "",
726
+ snooze_until: str = "",
727
+ conn=None,
728
+ ) -> dict[str, Any]:
729
+ if conn is None:
730
+ from db import get_db
731
+ from db._schema import run_migrations
732
+
733
+ conn = get_db()
734
+ run_migrations(conn)
735
+ clean_feedback = str(feedback or "").strip()
736
+ if clean_feedback not in VALID_FEEDBACK:
737
+ return {"ok": False, "error": f"invalid feedback: {clean_feedback}"}
738
+ proposal = _row(conn.execute("SELECT * FROM nexo_proposals WHERE id = ?", (str(proposal_id or ""),)).fetchone())
739
+ if not proposal:
740
+ return {"ok": False, "error": "proposal not found"}
741
+ now = _now_iso()
742
+ conn.execute("UPDATE nexo_proposals SET feedback = ? WHERE id = ?", (clean_feedback, proposal["id"]))
743
+ event_id = _hash_id("OPEV", f"{proposal['id']}:{clean_feedback}:{now}:{note}", 24)
744
+ conn.execute(
745
+ """
746
+ INSERT INTO nexo_proposal_events (
747
+ id, proposal_id, event_type, feedback, note, metadata_json, created_at
748
+ ) VALUES (?, ?, 'feedback', ?, ?, ?, ?)
749
+ """,
750
+ (
751
+ event_id,
752
+ proposal["id"],
753
+ clean_feedback,
754
+ _sanitize_text(note, 300),
755
+ _safe_json({"snooze_until": snooze_until}),
756
+ now,
757
+ ),
758
+ )
759
+ suppression = None
760
+ if clean_feedback in {"dismissed", "false_positive", "ignored", "snoozed"}:
761
+ expires_at = snooze_until if clean_feedback == "snoozed" and snooze_until else _expires(7 if clean_feedback != "false_positive" else 30)
762
+ suppression = suppress(
763
+ "snooze" if clean_feedback == "snoozed" else "opportunity",
764
+ proposal["opportunity_id"],
765
+ reason=clean_feedback,
766
+ expires_at=expires_at,
767
+ conn=conn,
768
+ )
769
+ conn.commit()
770
+ return {
771
+ "ok": True,
772
+ "proposal_id": proposal["id"],
773
+ "feedback": clean_feedback,
774
+ "event_id": event_id,
775
+ "suppression": suppression,
776
+ }
777
+
778
+
779
+ def suppress(
780
+ scope_type: str,
781
+ scope_key: str,
782
+ *,
783
+ reason: str = "",
784
+ expires_at: str = "",
785
+ conn=None,
786
+ ) -> dict[str, Any]:
787
+ if conn is None:
788
+ from db import get_db
789
+ from db._schema import run_migrations
790
+
791
+ conn = get_db()
792
+ run_migrations(conn)
793
+ clean_type = _sanitize_text(scope_type, 80)
794
+ clean_key = _sanitize_text(scope_key, 160)
795
+ if not clean_type or not clean_key:
796
+ return {"ok": False, "error": "scope_type and scope_key are required"}
797
+ clean_reason = _sanitize_text(reason or "manual", 240)
798
+ clean_expires = str(expires_at or _expires(14)).strip()
799
+ row_id = _hash_id("OSR", f"{clean_type}:{clean_key}:{clean_reason}", 24)
800
+ conn.execute(
801
+ """
802
+ INSERT INTO nexo_suppression_rules (
803
+ id, scope_type, scope_key, reason, expires_at, created_at
804
+ ) VALUES (?, ?, ?, ?, ?, ?)
805
+ ON CONFLICT(scope_type, scope_key, reason) DO UPDATE SET
806
+ expires_at = excluded.expires_at
807
+ """,
808
+ (row_id, clean_type, clean_key, clean_reason, clean_expires, _now_iso()),
809
+ )
810
+ conn.commit()
811
+ return {
812
+ "ok": True,
813
+ "id": row_id,
814
+ "scope_type": clean_type,
815
+ "scope_key": clean_key,
816
+ "reason": clean_reason,
817
+ "expires_at": clean_expires,
818
+ }
819
+
820
+
821
+ def write_daily_report(conn=None, *, refresh_result: dict[str, Any] | None = None) -> dict[str, Any]:
822
+ if conn is None:
823
+ from db import get_db
824
+ from db._schema import run_migrations
825
+
826
+ conn = get_db()
827
+ run_migrations(conn)
828
+ from paths import operations_dir
829
+
830
+ queue = opportunity_queue(conn, surface="morning_briefing", limit=NORMAL_PROPOSAL_LIMIT, refresh=False)
831
+ root = operations_dir() / "opportunity-orchestrator"
832
+ root.mkdir(parents=True, exist_ok=True)
833
+ json_path = root / f"{_today()}-opportunities.json"
834
+ md_path = root / f"{_today()}-opportunities.md"
835
+ payload = {
836
+ "schema": "nexo.opportunity.report.v1",
837
+ "generated_at": _now_iso(),
838
+ "refresh": refresh_result or {},
839
+ "queue": queue,
840
+ }
841
+ json_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
842
+ lines = [
843
+ f"# Opportunity Orchestrator -- {_today()}",
844
+ "",
845
+ f"- Generated at: {payload['generated_at']}",
846
+ f"- Proposals: {len(queue.get('proposals') or [])}",
847
+ "- Zero proposals is valid when evidence is not strong enough.",
848
+ "",
849
+ ]
850
+ for proposal in queue.get("proposals") or []:
851
+ opportunity = proposal.get("opportunity") or {}
852
+ lines.extend([
853
+ f"## {opportunity.get('title', proposal.get('id'))}",
854
+ "",
855
+ f"- Score: {opportunity.get('score')}",
856
+ f"- Confidence: {opportunity.get('confidence')}",
857
+ f"- Why now: {opportunity.get('why_now')}",
858
+ f"- Next action: {opportunity.get('next_action')}",
859
+ f"- Evidence: {', '.join(proposal.get('evidence_refs') or [])}",
860
+ "",
861
+ ])
862
+ md_path.write_text("\n".join(lines), encoding="utf-8")
863
+ return {"json": str(json_path), "markdown": str(md_path)}
864
+
865
+
866
+ def handle_opportunity_refresh(
867
+ dry_run: bool = True,
868
+ sources: str = "",
869
+ limit_per_source: int = 250,
870
+ write_report: bool = False,
871
+ ) -> str:
872
+ return json.dumps(
873
+ refresh_opportunities(
874
+ dry_run=bool(dry_run),
875
+ sources=sources,
876
+ limit_per_source=limit_per_source,
877
+ write_report=bool(write_report),
878
+ ),
879
+ indent=2,
880
+ ensure_ascii=False,
881
+ )
882
+
883
+
884
+ def handle_opportunity_queue(
885
+ surface: str = "home",
886
+ limit: int = NORMAL_PROPOSAL_LIMIT,
887
+ refresh: bool = False,
888
+ include_snoozed: bool = False,
889
+ ) -> str:
890
+ return json.dumps(
891
+ opportunity_queue(
892
+ surface=surface,
893
+ limit=limit,
894
+ refresh=bool(refresh),
895
+ include_snoozed=bool(include_snoozed),
896
+ ),
897
+ indent=2,
898
+ ensure_ascii=False,
899
+ )
900
+
901
+
902
+ def handle_opportunity_get(opportunity_id: str, include_evidence: bool = True) -> str:
903
+ return json.dumps(
904
+ get_opportunity(opportunity_id, include_evidence=bool(include_evidence)),
905
+ indent=2,
906
+ ensure_ascii=False,
907
+ )
908
+
909
+
910
+ def handle_opportunity_feedback(
911
+ proposal_id: str,
912
+ feedback: str,
913
+ note: str = "",
914
+ snooze_until: str = "",
915
+ ) -> str:
916
+ return json.dumps(
917
+ opportunity_feedback(proposal_id, feedback, note=note, snooze_until=snooze_until),
918
+ indent=2,
919
+ ensure_ascii=False,
920
+ )
921
+
922
+
923
+ def handle_opportunity_suppress(
924
+ scope_type: str,
925
+ scope_key: str,
926
+ reason: str = "",
927
+ expires_at: str = "",
928
+ ) -> str:
929
+ return json.dumps(
930
+ suppress(scope_type, scope_key, reason=reason, expires_at=expires_at),
931
+ indent=2,
932
+ ensure_ascii=False,
933
+ )