delimit-cli 4.5.1 → 4.5.3
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/CHANGELOG.md +87 -0
- package/README.md +15 -5
- package/bin/delimit-cli.js +109 -24
- package/gateway/ai/content_engine.py +3 -4
- package/gateway/ai/inbox_classifier.py +215 -0
- package/gateway/ai/integrations/opensage_wrapper.py +4 -1
- package/gateway/ai/ledger_manager.py +218 -38
- package/gateway/ai/license.py +26 -0
- package/gateway/ai/notify.py +68 -3
- package/gateway/ai/reddit_proxy.py +93 -15
- package/gateway/ai/reddit_scanner.py +36 -18
- package/gateway/ai/remote_resolve.py +422 -0
- package/gateway/ai/server.py +301 -117
- package/gateway/ai/social_capability/__init__.py +6 -0
- package/gateway/ai/social_capability/capability_validator.py +367 -0
- package/gateway/ai/social_capability/current_capabilities.yaml +95 -0
- package/gateway/ai/social_capability/fit_floor.py +360 -0
- package/gateway/ai/social_queue.py +307 -0
- package/gateway/ai/supabase_sync.py +14 -2
- package/gateway/ai/swarm.py +29 -11
- package/gateway/ai/tui.py +6 -2
- package/gateway/ai/vendor_news/__init__.py +14 -0
- package/gateway/ai/vendor_news/drafter.py +562 -0
- package/gateway/ai/vendor_news/sensor.py +509 -0
- package/gateway/ai/vendor_news/watchlist.yaml +71 -0
- package/gateway/ai/x_ranker.py +417 -0
- package/lib/attest-mcp.js +487 -0
- package/lib/attest-telemetry.js +48 -0
- package/lib/delimit-home.js +35 -0
- package/lib/delimit-template.js +14 -0
- package/package.json +25 -3
- package/scripts/postinstall.js +89 -40
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
- package/gateway/ai/content_grounding/__init__.py +0 -98
- package/gateway/ai/content_grounding/build.py +0 -350
- package/gateway/ai/content_grounding/consume.py +0 -280
- package/gateway/ai/content_grounding/features.py +0 -218
- package/gateway/ai/content_grounding/fixtures/fail/01_missing_evidence.json +0 -9
- package/gateway/ai/content_grounding/fixtures/fail/02_unknown_evidence_prefix.json +0 -9
- package/gateway/ai/content_grounding/fixtures/fail/03_banned_comparative.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/04_banned_adoption.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/05_aggregate_no_numeric.json +0 -17
- package/gateway/ai/content_grounding/fixtures/fail/06_unversioned_inference_rule.json +0 -18
- package/gateway/ai/content_grounding/fixtures/pass/01_feature_shipped.json +0 -18
- package/gateway/ai/content_grounding/fixtures/pass/02_aggregate_claim.json +0 -23
- package/gateway/ai/content_grounding/fixtures/pass/03_attestation.json +0 -16
- package/gateway/ai/content_grounding/schemas/claim.schema.json +0 -40
- package/gateway/ai/content_grounding/schemas/event.schema.json +0 -23
- package/gateway/ai/content_grounding/schemas.py +0 -276
- package/gateway/ai/content_grounding/telemetry.py +0 -221
- package/gateway/ai/inbox_drafts/__init__.py +0 -61
- package/gateway/ai/inbox_drafts/registry.py +0 -412
- package/gateway/ai/inbox_drafts/schema.py +0 -374
- package/gateway/ai/inbox_executor.py +0 -565
package/gateway/ai/swarm.py
CHANGED
|
@@ -946,27 +946,45 @@ def hot_reload(reason: str = "update") -> Dict[str, Any]:
|
|
|
946
946
|
# full subprocess restart. Modules with global state are skipped.
|
|
947
947
|
reloaded_modules: List[str] = []
|
|
948
948
|
reload_errors: List[str] = []
|
|
949
|
+
# LED-2071f (2026-04-30): reload LEAVES (modules with no internal
|
|
950
|
+
# `from ai.X import ...` deps) BEFORE leaves' importers, so when an
|
|
951
|
+
# importer re-runs its `from` imports during its own reload, it
|
|
952
|
+
# picks up the freshly-reloaded binding rather than the stale one.
|
|
953
|
+
# Symptom of the prior order: ai.social_target reloaded before
|
|
954
|
+
# ai.social, so social_target's `from ai.social import save_draft,
|
|
955
|
+
# generate_tailored_draft, ...` rebound to the OLD social, then
|
|
956
|
+
# ai.social reloaded but social_target kept stale fn references.
|
|
957
|
+
# Fix: ai.social and ai.deliberation (the leaves) come first;
|
|
958
|
+
# ai.social_target (which imports from social) and ai.loop_engine
|
|
959
|
+
# (which imports from social_target) come after, in dependency
|
|
960
|
+
# order.
|
|
949
961
|
HOT_RELOADABLE = [
|
|
950
|
-
"ai.loop_engine",
|
|
951
|
-
"ai.social_target",
|
|
952
962
|
"ai.social",
|
|
963
|
+
"ai.deliberation", # added 2026-04-09 per LED-805 — CLI stdin fix needed hot reload
|
|
953
964
|
"ai.reddit_scanner",
|
|
954
965
|
"ai.ledger_manager",
|
|
955
|
-
"ai.deliberation", # added 2026-04-09 per LED-805 — CLI stdin fix needed hot reload
|
|
956
966
|
"ai.backends.repo_bridge",
|
|
957
967
|
"ai.backends.tools_infra",
|
|
958
968
|
"backends.repo_bridge", # alias used by server.py lazy imports
|
|
969
|
+
"ai.social_target", # depends on ai.social
|
|
970
|
+
"ai.loop_engine", # depends on ai.social_target
|
|
959
971
|
"social", # alias
|
|
960
972
|
"ai.swarm", # self — reload last
|
|
961
973
|
]
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
974
|
+
# Two-pass reload: pass 1 establishes the new leaf modules; pass 2
|
|
975
|
+
# forces importers to rebind against the now-reloaded leaves. Cheap
|
|
976
|
+
# (in-process module reload only) but kills the entire stale-binding
|
|
977
|
+
# class of bugs in one go.
|
|
978
|
+
for _pass in range(2):
|
|
979
|
+
for modname in HOT_RELOADABLE:
|
|
980
|
+
if modname not in _sys.modules:
|
|
981
|
+
continue
|
|
982
|
+
try:
|
|
983
|
+
importlib.reload(_sys.modules[modname])
|
|
984
|
+
if _pass == 0:
|
|
985
|
+
reloaded_modules.append(modname)
|
|
986
|
+
except Exception as e:
|
|
987
|
+
reload_errors.append(f"pass{_pass} {modname}: {e}")
|
|
970
988
|
|
|
971
989
|
# 1. Capture current state for transfer
|
|
972
990
|
state = {
|
package/gateway/ai/tui.py
CHANGED
|
@@ -32,7 +32,11 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
32
32
|
|
|
33
33
|
# -- Data paths ---------------------------------------------------------------
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
# LED-1188: route through the canonical resolver so $DELIMIT_HOME /
|
|
36
|
+
# $DELIMIT_NAMESPACE_ROOT overrides apply uniformly across npm + gateway.
|
|
37
|
+
from .continuity import get_namespace_root # noqa: E402
|
|
38
|
+
|
|
39
|
+
DELIMIT_HOME = get_namespace_root()
|
|
36
40
|
LEDGER_DIR = DELIMIT_HOME / "ledger"
|
|
37
41
|
SWARM_DIR = DELIMIT_HOME / "swarm"
|
|
38
42
|
MEMORY_DIR = DELIMIT_HOME / "memory"
|
|
@@ -721,7 +725,7 @@ class GovernanceBar(Static):
|
|
|
721
725
|
bar = self.query_one("#gov-bar", Static)
|
|
722
726
|
ledger_count = len(_load_ledger_items("open", 999))
|
|
723
727
|
swarm = _load_swarm_status()
|
|
724
|
-
mode_file =
|
|
728
|
+
mode_file = DELIMIT_HOME / "enforcement_mode"
|
|
725
729
|
mode = mode_file.read_text().strip() if mode_file.exists() else "default"
|
|
726
730
|
|
|
727
731
|
# Notification badge
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Vendor-news riff system (LED-1250).
|
|
2
|
+
|
|
3
|
+
Sensor + drafter that detects high-engagement vendor announcements on X
|
|
4
|
+
and auto-drafts a brand-voice Delimit-POV riff that rides the news cycle
|
|
5
|
+
for algorithm boost.
|
|
6
|
+
|
|
7
|
+
Public surface:
|
|
8
|
+
from ai.vendor_news import scan_vendor_news, draft_vendor_riff
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ai.vendor_news.sensor import scan_vendor_news
|
|
12
|
+
from ai.vendor_news.drafter import draft_vendor_riff
|
|
13
|
+
|
|
14
|
+
__all__ = ["scan_vendor_news", "draft_vendor_riff"]
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
"""Vendor-news riff drafter (LED-1250).
|
|
2
|
+
|
|
3
|
+
Takes a triggered tweet from ``ai.vendor_news.sensor.scan_vendor_news``
|
|
4
|
+
and generates a brand-voice Delimit-POV riff that rides the news cycle
|
|
5
|
+
without @-mentioning the vendor (founder convention).
|
|
6
|
+
|
|
7
|
+
Decision flow:
|
|
8
|
+
|
|
9
|
+
triggered_tweet
|
|
10
|
+
↓ paraphrase prompt
|
|
11
|
+
generate_tailored_draft (LED-791 brand voice)
|
|
12
|
+
↓
|
|
13
|
+
capability_validator.validate_draft (LED-1240 — canonical phrase + URL anchor)
|
|
14
|
+
↓ ok
|
|
15
|
+
fit_floor.evaluate_fit (LED-1240b — selectivity bar)
|
|
16
|
+
↓ pass
|
|
17
|
+
insert at top of ~/.delimit/tweet_queue.json (P0, vendor_news_riff)
|
|
18
|
+
|
|
19
|
+
Both gates are HARD. A riff that fails either lands in
|
|
20
|
+
``~/.delimit/vendor_news_rejected.jsonl`` and the function returns
|
|
21
|
+
``decision="reject"``. No bypass — that's the contract from the
|
|
22
|
+
directive.
|
|
23
|
+
|
|
24
|
+
Per-vendor rate cap: at most 1 riff per vendor per 24h, computed by
|
|
25
|
+
walking the queue + the rejected log + the existing social_log.jsonl
|
|
26
|
+
posts. The cap is enforced BEFORE prompting the LLM so we don't burn
|
|
27
|
+
tokens on a draft we'll never queue.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import logging
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
from datetime import datetime, timedelta, timezone
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any, Dict, List, Optional
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── paths ─────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
TWEET_QUEUE_PATH = Path.home() / ".delimit" / "tweet_queue.json"
|
|
46
|
+
REJECTED_LOG_PATH = Path.home() / ".delimit" / "vendor_news_rejected.jsonl"
|
|
47
|
+
RIFF_HISTORY_PATH = Path.home() / ".delimit" / "vendor_news_history.jsonl"
|
|
48
|
+
|
|
49
|
+
DEFAULT_RATE_CAP_HOURS = 24
|
|
50
|
+
MAX_TWEET_LEN = 280
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ── helpers ───────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _now() -> datetime:
|
|
57
|
+
return datetime.now(timezone.utc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_queue(path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
61
|
+
p = Path(path) if path else TWEET_QUEUE_PATH
|
|
62
|
+
if not p.exists():
|
|
63
|
+
return []
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
66
|
+
if isinstance(data, list):
|
|
67
|
+
return data
|
|
68
|
+
return []
|
|
69
|
+
except (json.JSONDecodeError, ValueError):
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _save_queue(queue: List[Dict[str, Any]], path: Optional[Path] = None) -> None:
|
|
74
|
+
p = Path(path) if path else TWEET_QUEUE_PATH
|
|
75
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
p.write_text(json.dumps(queue, indent=2), encoding="utf-8")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
|
|
80
|
+
try:
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
83
|
+
f.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
|
84
|
+
except OSError as exc: # pragma: no cover — best-effort
|
|
85
|
+
logger.warning("vendor_news: jsonl write failed for %s: %s", path, exc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
89
|
+
if not value:
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
s = str(value)
|
|
93
|
+
if s.endswith("Z"):
|
|
94
|
+
s = s[:-1] + "+00:00"
|
|
95
|
+
return datetime.fromisoformat(s)
|
|
96
|
+
except (ValueError, TypeError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── per-vendor rate cap ──────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _recent_riffs_for_vendor(
|
|
104
|
+
vendor: str,
|
|
105
|
+
since: datetime,
|
|
106
|
+
queue_path: Optional[Path] = None,
|
|
107
|
+
history_path: Optional[Path] = None,
|
|
108
|
+
) -> List[Dict[str, Any]]:
|
|
109
|
+
"""Return riffs for ``vendor`` that landed in the queue OR the
|
|
110
|
+
history log inside the cap window.
|
|
111
|
+
|
|
112
|
+
Walks two sources because:
|
|
113
|
+
* the queue carries pending + recently-posted entries, and
|
|
114
|
+
* the history log is the audit trail when the queue rotates them
|
|
115
|
+
out (queue is mutated by the cron after post).
|
|
116
|
+
|
|
117
|
+
Vendor matching is case-insensitive on the ``riff_vendor`` field.
|
|
118
|
+
"""
|
|
119
|
+
vnorm = (vendor or "").strip().lower()
|
|
120
|
+
if not vnorm:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
out: List[Dict[str, Any]] = []
|
|
124
|
+
queue = _load_queue(queue_path)
|
|
125
|
+
for entry in queue:
|
|
126
|
+
if (entry.get("riff_vendor") or "").lower() != vnorm:
|
|
127
|
+
continue
|
|
128
|
+
added = _parse_iso(entry.get("added_at"))
|
|
129
|
+
if added is None or added >= since:
|
|
130
|
+
out.append(entry)
|
|
131
|
+
|
|
132
|
+
hp = Path(history_path) if history_path else RIFF_HISTORY_PATH
|
|
133
|
+
if hp.exists():
|
|
134
|
+
try:
|
|
135
|
+
with open(hp, "r", encoding="utf-8") as f:
|
|
136
|
+
for line in f:
|
|
137
|
+
line = line.strip()
|
|
138
|
+
if not line:
|
|
139
|
+
continue
|
|
140
|
+
try:
|
|
141
|
+
entry = json.loads(line)
|
|
142
|
+
except (json.JSONDecodeError, ValueError):
|
|
143
|
+
continue
|
|
144
|
+
if (entry.get("vendor") or "").lower() != vnorm:
|
|
145
|
+
continue
|
|
146
|
+
ts = _parse_iso(entry.get("ts"))
|
|
147
|
+
if ts is None or ts >= since:
|
|
148
|
+
out.append(entry)
|
|
149
|
+
except OSError:
|
|
150
|
+
pass
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _rate_capped(
|
|
155
|
+
vendor: str,
|
|
156
|
+
cap_hours: int = DEFAULT_RATE_CAP_HOURS,
|
|
157
|
+
queue_path: Optional[Path] = None,
|
|
158
|
+
history_path: Optional[Path] = None,
|
|
159
|
+
now: Optional[datetime] = None,
|
|
160
|
+
) -> bool:
|
|
161
|
+
cur = now or _now()
|
|
162
|
+
cutoff = cur - timedelta(hours=int(cap_hours))
|
|
163
|
+
return bool(_recent_riffs_for_vendor(vendor, cutoff, queue_path, history_path))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── prompt construction ─────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
_VENDOR_AT_RE_TEMPLATE = r"@%s\b"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _strip_at_mentions(text: str, no_at_handles: List[str]) -> str:
|
|
173
|
+
"""Defensive: even if the LLM tries to @-tag a watched handle, strip
|
|
174
|
+
it. Keeps the raw text otherwise — only collapses the leading ``@``.
|
|
175
|
+
|
|
176
|
+
Example: "Anthropic's @AnthropicAI shipped …" → "Anthropic's
|
|
177
|
+
AnthropicAI shipped …". The handle word remains so the sentence
|
|
178
|
+
still parses, but the algorithm-targeting @-tag is gone.
|
|
179
|
+
"""
|
|
180
|
+
out = text
|
|
181
|
+
for h in no_at_handles:
|
|
182
|
+
if not h:
|
|
183
|
+
continue
|
|
184
|
+
pat = re.compile(_VENDOR_AT_RE_TEMPLATE % re.escape(h), re.IGNORECASE)
|
|
185
|
+
out = pat.sub(h, out)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _build_riff_prompt(
|
|
190
|
+
triggered: Dict[str, Any],
|
|
191
|
+
no_at_mention: bool = True,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Construct the input prompt for ``generate_tailored_draft``.
|
|
194
|
+
|
|
195
|
+
The function returns *prompt text* — not a fully composed system
|
|
196
|
+
prompt. ``generate_tailored_draft`` already handles tone / brand
|
|
197
|
+
voice / style anchors / the LED-1240 ground-truth feed; we just
|
|
198
|
+
need to feed it the news context + Delimit-POV instructions so it
|
|
199
|
+
has something to riff on.
|
|
200
|
+
"""
|
|
201
|
+
vendor = triggered.get("vendor") or ""
|
|
202
|
+
products = ", ".join(triggered.get("products") or []) or "(none listed)"
|
|
203
|
+
src_url = triggered.get("url") or ""
|
|
204
|
+
raw_text = (triggered.get("text") or "").strip()
|
|
205
|
+
metrics = triggered.get("metrics") or {}
|
|
206
|
+
|
|
207
|
+
lines = [
|
|
208
|
+
"VENDOR NEWS RIFF — write a brand-voice Delimit POV that rides this news cycle.",
|
|
209
|
+
"",
|
|
210
|
+
f"Vendor: {vendor}",
|
|
211
|
+
f"Products: {products}",
|
|
212
|
+
f"Source URL: {src_url}",
|
|
213
|
+
f"Source metrics: {metrics.get('favorite_count', 0)} likes, "
|
|
214
|
+
f"{metrics.get('retweet_count', 0)} retweets, "
|
|
215
|
+
f"{metrics.get('quote_count', 0)} quotes",
|
|
216
|
+
"",
|
|
217
|
+
"What the vendor said (paraphrase only, do NOT quote verbatim):",
|
|
218
|
+
f" {raw_text[:500]}",
|
|
219
|
+
"",
|
|
220
|
+
"Write ONE original tweet (not a reply, not a quote tweet) that:",
|
|
221
|
+
f" * names the vendor by bare name only ({vendor}). "
|
|
222
|
+
f"NEVER use the @ tag."
|
|
223
|
+
if no_at_mention
|
|
224
|
+
else f" * names the vendor ({vendor}).",
|
|
225
|
+
" * paraphrases the news in your own words (one short clause).",
|
|
226
|
+
" * ties to a Delimit canonical claim — merge gate for AI-written code, "
|
|
227
|
+
"signed replayable attestation, or cross-vendor governance.",
|
|
228
|
+
" * includes a delimit.ai URL anchor (delimit.ai/methodology, "
|
|
229
|
+
"delimit.ai/reports, delimit.ai/att, or delimit.ai itself).",
|
|
230
|
+
" * stays under 280 characters.",
|
|
231
|
+
" * uses brand voice (no first person, no 'I', no 'we'). "
|
|
232
|
+
"Confident technical, NOT salesy.",
|
|
233
|
+
" * does NOT use em dashes or en dashes.",
|
|
234
|
+
"",
|
|
235
|
+
"Output ONLY the tweet text. No preamble, no labels, no quotes around it.",
|
|
236
|
+
]
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── queue insertion ─────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _insert_p0_at_top(
|
|
244
|
+
text: str,
|
|
245
|
+
*,
|
|
246
|
+
triggered: Dict[str, Any],
|
|
247
|
+
queue_path: Optional[Path] = None,
|
|
248
|
+
image_url: Optional[str] = None,
|
|
249
|
+
now: Optional[datetime] = None,
|
|
250
|
+
) -> Dict[str, Any]:
|
|
251
|
+
"""Insert a vendor_news_riff entry at the top of the tweet queue.
|
|
252
|
+
|
|
253
|
+
Returns the entry that was inserted.
|
|
254
|
+
"""
|
|
255
|
+
queue = _load_queue(queue_path)
|
|
256
|
+
cur = now or _now()
|
|
257
|
+
entry: Dict[str, Any] = {
|
|
258
|
+
"text": text,
|
|
259
|
+
"added_at": cur.isoformat(),
|
|
260
|
+
"posted": False,
|
|
261
|
+
"posted_at": None,
|
|
262
|
+
"tweet_id": None,
|
|
263
|
+
"priority": "P0",
|
|
264
|
+
"category": "vendor_news_riff",
|
|
265
|
+
"riff_source": triggered.get("id"),
|
|
266
|
+
"riff_source_url": triggered.get("url"),
|
|
267
|
+
"riff_vendor": triggered.get("vendor"),
|
|
268
|
+
"riff_products": list(triggered.get("products") or []),
|
|
269
|
+
}
|
|
270
|
+
if image_url:
|
|
271
|
+
entry["image_url"] = image_url
|
|
272
|
+
queue.insert(0, entry)
|
|
273
|
+
_save_queue(queue, queue_path)
|
|
274
|
+
return entry
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── main entry ───────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def draft_vendor_riff(
|
|
281
|
+
triggered_tweet: Dict[str, Any],
|
|
282
|
+
*,
|
|
283
|
+
no_at_mention: bool = True,
|
|
284
|
+
no_at_handles: Optional[List[str]] = None,
|
|
285
|
+
rate_cap_hours: int = DEFAULT_RATE_CAP_HOURS,
|
|
286
|
+
queue_path: Optional[Path] = None,
|
|
287
|
+
rejected_log_path: Optional[Path] = None,
|
|
288
|
+
history_log_path: Optional[Path] = None,
|
|
289
|
+
generator=None,
|
|
290
|
+
capability_validator=None,
|
|
291
|
+
fit_floor=None,
|
|
292
|
+
now: Optional[datetime] = None,
|
|
293
|
+
) -> Dict[str, Any]:
|
|
294
|
+
"""Generate a brand-voice Delimit-POV riff on a triggered vendor post.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
triggered_tweet: A dict from ``scan_vendor_news()['triggered']``.
|
|
298
|
+
no_at_mention: When True (default), strip @-tags of watched
|
|
299
|
+
handles from the generated text before validation.
|
|
300
|
+
no_at_handles: Optional list of handles to strip @-tags for. If
|
|
301
|
+
omitted, falls back to the triggered tweet's ``author``.
|
|
302
|
+
rate_cap_hours: Per-vendor cooldown window in hours.
|
|
303
|
+
queue_path / rejected_log_path / history_log_path: test hooks.
|
|
304
|
+
generator: Callable(prompt:str, platform:str, venture:str,
|
|
305
|
+
account:str) -> str. Defaults to
|
|
306
|
+
``ai.social.generate_tailored_draft``. Test hook.
|
|
307
|
+
capability_validator: Callable(text:str, platform:str) -> dict
|
|
308
|
+
with at least an ``ok`` key. Defaults to
|
|
309
|
+
``ai.social_capability.capability_validator.validate_draft``.
|
|
310
|
+
Test hook.
|
|
311
|
+
fit_floor: Callable(text:str) -> dict with at least ``passed``.
|
|
312
|
+
Defaults to ``ai.social_capability.fit_floor.evaluate_fit``.
|
|
313
|
+
Test hook.
|
|
314
|
+
now: Override "current time" (test hook).
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Dict with:
|
|
318
|
+
decision: "queue" | "reject"
|
|
319
|
+
text: generated draft text (may be empty on early reject)
|
|
320
|
+
reason: short reason tag if decision == "reject"
|
|
321
|
+
queue_entry: the inserted queue entry on decision == "queue"
|
|
322
|
+
validator_result: capability_validator return dict
|
|
323
|
+
fit_result: fit_floor return dict
|
|
324
|
+
"""
|
|
325
|
+
triggered_tweet = triggered_tweet or {}
|
|
326
|
+
cur = now or _now()
|
|
327
|
+
vendor = triggered_tweet.get("vendor") or ""
|
|
328
|
+
|
|
329
|
+
result: Dict[str, Any] = {
|
|
330
|
+
"decision": "reject",
|
|
331
|
+
"text": "",
|
|
332
|
+
"reason": "",
|
|
333
|
+
"queue_entry": None,
|
|
334
|
+
"validator_result": None,
|
|
335
|
+
"fit_result": None,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
# 1) Per-vendor rate cap. Check BEFORE prompting the LLM.
|
|
339
|
+
if vendor and _rate_capped(
|
|
340
|
+
vendor,
|
|
341
|
+
cap_hours=rate_cap_hours,
|
|
342
|
+
queue_path=queue_path,
|
|
343
|
+
history_path=history_log_path,
|
|
344
|
+
now=cur,
|
|
345
|
+
):
|
|
346
|
+
result["reason"] = "rate_capped"
|
|
347
|
+
_append_jsonl(
|
|
348
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
349
|
+
{
|
|
350
|
+
"ts": cur.isoformat(),
|
|
351
|
+
"vendor": vendor,
|
|
352
|
+
"source_id": triggered_tweet.get("id"),
|
|
353
|
+
"reason": "rate_capped",
|
|
354
|
+
},
|
|
355
|
+
)
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
# 2) Resolve dependency callables.
|
|
359
|
+
if generator is None:
|
|
360
|
+
try:
|
|
361
|
+
from ai.social import generate_tailored_draft as _generator
|
|
362
|
+
generator = _generator
|
|
363
|
+
except Exception as exc:
|
|
364
|
+
result["reason"] = f"generator_unavailable:{exc}"
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
if capability_validator is None:
|
|
368
|
+
try:
|
|
369
|
+
from ai.social_capability.capability_validator import (
|
|
370
|
+
validate_draft as _validate,
|
|
371
|
+
)
|
|
372
|
+
capability_validator = _validate
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
result["reason"] = f"validator_unavailable:{exc}"
|
|
375
|
+
return result
|
|
376
|
+
|
|
377
|
+
if fit_floor is None:
|
|
378
|
+
try:
|
|
379
|
+
from ai.social_capability.fit_floor import evaluate_fit as _evaluate
|
|
380
|
+
fit_floor = _evaluate
|
|
381
|
+
except Exception as exc:
|
|
382
|
+
result["reason"] = f"fit_floor_unavailable:{exc}"
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
# 2b) Source-post pre-filter: check the SOURCE tweet text against the
|
|
386
|
+
# fit_floor BEFORE invoking the LLM. If the vendor news is off-topic
|
|
387
|
+
# for Delimit (e.g., image generation, exec drama, marketing fluff),
|
|
388
|
+
# there is no authentic riff to write — abstain without burning tokens
|
|
389
|
+
# AND without ticking the per-vendor 24h rate cap. Founder direction
|
|
390
|
+
# 2026-05-07 after live xAI image-gen post correctly fell through to
|
|
391
|
+
# fit_floor at draft time but wasted an LLM call to get there.
|
|
392
|
+
source_text = triggered_tweet.get("text") or ""
|
|
393
|
+
try:
|
|
394
|
+
source_fit = fit_floor(source_text)
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
# Don't block the pipeline on a fit_floor bug; log and continue.
|
|
397
|
+
source_fit = {"passed": True, "reason": f"source_fit_error:{exc}"}
|
|
398
|
+
if not source_fit.get("passed"):
|
|
399
|
+
result["reason"] = "source_off_topic"
|
|
400
|
+
result["fit_result"] = source_fit
|
|
401
|
+
_append_jsonl(
|
|
402
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
403
|
+
{
|
|
404
|
+
"ts": cur.isoformat(),
|
|
405
|
+
"vendor": vendor,
|
|
406
|
+
"source_id": triggered_tweet.get("id"),
|
|
407
|
+
"source_text": source_text[:200],
|
|
408
|
+
"reason": "source_off_topic",
|
|
409
|
+
"source_fit": source_fit,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
# 3) Build the prompt + generate.
|
|
415
|
+
prompt = _build_riff_prompt(triggered_tweet, no_at_mention=no_at_mention)
|
|
416
|
+
try:
|
|
417
|
+
text = generator(
|
|
418
|
+
prompt,
|
|
419
|
+
"twitter",
|
|
420
|
+
"delimit",
|
|
421
|
+
"delimit_ai",
|
|
422
|
+
) or ""
|
|
423
|
+
except TypeError:
|
|
424
|
+
# Older signature without account kwarg — rare; fall back.
|
|
425
|
+
try:
|
|
426
|
+
text = generator(prompt, "twitter", "delimit") or ""
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
result["reason"] = f"generator_error:{exc}"
|
|
429
|
+
return result
|
|
430
|
+
except Exception as exc:
|
|
431
|
+
result["reason"] = f"generator_error:{exc}"
|
|
432
|
+
return result
|
|
433
|
+
|
|
434
|
+
text = (text or "").strip()
|
|
435
|
+
if not text:
|
|
436
|
+
result["reason"] = "empty_draft"
|
|
437
|
+
_append_jsonl(
|
|
438
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
439
|
+
{
|
|
440
|
+
"ts": cur.isoformat(),
|
|
441
|
+
"vendor": vendor,
|
|
442
|
+
"source_id": triggered_tweet.get("id"),
|
|
443
|
+
"reason": "empty_draft",
|
|
444
|
+
},
|
|
445
|
+
)
|
|
446
|
+
return result
|
|
447
|
+
|
|
448
|
+
# 4) Strip @-mentions defensively. The drafter prompt forbids them
|
|
449
|
+
# but LLMs drift; this is a belt-and-suspenders check before the
|
|
450
|
+
# capability validator sees the text.
|
|
451
|
+
if no_at_mention:
|
|
452
|
+
handles = list(no_at_handles or [])
|
|
453
|
+
if not handles and triggered_tweet.get("author"):
|
|
454
|
+
handles = [triggered_tweet["author"]]
|
|
455
|
+
text = _strip_at_mentions(text, handles)
|
|
456
|
+
|
|
457
|
+
# 5) Length cap (defensive — generator should already respect 280).
|
|
458
|
+
if len(text) > MAX_TWEET_LEN:
|
|
459
|
+
text = text[:MAX_TWEET_LEN].rstrip()
|
|
460
|
+
|
|
461
|
+
result["text"] = text
|
|
462
|
+
|
|
463
|
+
# 6) Capability validator gate (LED-1240).
|
|
464
|
+
try:
|
|
465
|
+
validator_result = capability_validator(text, platform="twitter")
|
|
466
|
+
except TypeError:
|
|
467
|
+
validator_result = capability_validator(text)
|
|
468
|
+
result["validator_result"] = validator_result
|
|
469
|
+
|
|
470
|
+
if not (validator_result or {}).get("ok"):
|
|
471
|
+
result["reason"] = "validator_failed"
|
|
472
|
+
_append_jsonl(
|
|
473
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
474
|
+
{
|
|
475
|
+
"ts": cur.isoformat(),
|
|
476
|
+
"vendor": vendor,
|
|
477
|
+
"source_id": triggered_tweet.get("id"),
|
|
478
|
+
"text": text,
|
|
479
|
+
"reason": "validator_failed",
|
|
480
|
+
"validator": {
|
|
481
|
+
"errors": validator_result.get("errors") if isinstance(validator_result, dict) else [],
|
|
482
|
+
"warnings": validator_result.get("warnings") if isinstance(validator_result, dict) else [],
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
return result
|
|
487
|
+
|
|
488
|
+
# 7) Fit-floor gate (LED-1240b).
|
|
489
|
+
try:
|
|
490
|
+
fit_result = fit_floor(text)
|
|
491
|
+
except Exception as exc:
|
|
492
|
+
fit_result = {"passed": False, "reason": f"fit_floor_error:{exc}"}
|
|
493
|
+
result["fit_result"] = fit_result
|
|
494
|
+
|
|
495
|
+
if not (fit_result or {}).get("passed"):
|
|
496
|
+
result["reason"] = "fit_floor_failed"
|
|
497
|
+
_append_jsonl(
|
|
498
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
499
|
+
{
|
|
500
|
+
"ts": cur.isoformat(),
|
|
501
|
+
"vendor": vendor,
|
|
502
|
+
"source_id": triggered_tweet.get("id"),
|
|
503
|
+
"text": text,
|
|
504
|
+
"reason": "fit_floor_failed",
|
|
505
|
+
"fit": {
|
|
506
|
+
"reason": fit_result.get("reason") if isinstance(fit_result, dict) else "",
|
|
507
|
+
"matched_signals": fit_result.get("matched_signals") if isinstance(fit_result, dict) else [],
|
|
508
|
+
},
|
|
509
|
+
},
|
|
510
|
+
)
|
|
511
|
+
return result
|
|
512
|
+
|
|
513
|
+
# human_only carve-out: do NOT auto-queue. Log and return reject so
|
|
514
|
+
# the orchestrator can surface for review later.
|
|
515
|
+
if (fit_result or {}).get("human_only"):
|
|
516
|
+
result["reason"] = "fit_floor_human_only"
|
|
517
|
+
_append_jsonl(
|
|
518
|
+
Path(rejected_log_path) if rejected_log_path else REJECTED_LOG_PATH,
|
|
519
|
+
{
|
|
520
|
+
"ts": cur.isoformat(),
|
|
521
|
+
"vendor": vendor,
|
|
522
|
+
"source_id": triggered_tweet.get("id"),
|
|
523
|
+
"text": text,
|
|
524
|
+
"reason": "fit_floor_human_only",
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
# 8) Queue insert (P0, vendor_news_riff).
|
|
530
|
+
entry = _insert_p0_at_top(
|
|
531
|
+
text,
|
|
532
|
+
triggered=triggered_tweet,
|
|
533
|
+
queue_path=queue_path,
|
|
534
|
+
now=cur,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Append to history so the rate cap survives queue rotation.
|
|
538
|
+
_append_jsonl(
|
|
539
|
+
Path(history_log_path) if history_log_path else RIFF_HISTORY_PATH,
|
|
540
|
+
{
|
|
541
|
+
"ts": cur.isoformat(),
|
|
542
|
+
"vendor": vendor,
|
|
543
|
+
"source_id": triggered_tweet.get("id"),
|
|
544
|
+
"source_url": triggered_tweet.get("url"),
|
|
545
|
+
"text": text,
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
result["decision"] = "queue"
|
|
550
|
+
result["queue_entry"] = entry
|
|
551
|
+
result["reason"] = ""
|
|
552
|
+
return result
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
__all__ = [
|
|
556
|
+
"DEFAULT_RATE_CAP_HOURS",
|
|
557
|
+
"MAX_TWEET_LEN",
|
|
558
|
+
"REJECTED_LOG_PATH",
|
|
559
|
+
"RIFF_HISTORY_PATH",
|
|
560
|
+
"TWEET_QUEUE_PATH",
|
|
561
|
+
"draft_vendor_riff",
|
|
562
|
+
]
|