delimit-cli 4.5.2 → 4.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ ]