nexo-brain 7.30.22 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/db/_schema.py +161 -0
- package/src/opportunity_orchestrator.py +933 -0
- package/src/scripts/nexo-email-monitor.py +71 -5
- package/src/scripts/nexo-send-reply.py +15 -2
- package/src/server.py +58 -0
- package/templates/core-prompts/email-monitor.md +6 -1
- package/tool-enforcement-map.json +75 -0
|
@@ -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
|
+
)
|