delimit-cli 4.5.2 → 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/README.md +13 -3
- package/gateway/ai/remote_resolve.py +422 -0
- package/gateway/ai/server.py +173 -111
- package/gateway/ai/social_capability/capability_validator.py +107 -13
- package/gateway/ai/social_capability/fit_floor.py +360 -0
- 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 +146 -5
- package/package.json +18 -2
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
|
@@ -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
|
+
]
|