@m13v/s4l 1.6.197-rc.7
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 +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1314 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +497 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared engagement style definitions for all platforms.
|
|
3
|
+
|
|
4
|
+
Centralizes style taxonomy, platform-specific guidance, content rules,
|
|
5
|
+
and prompt generation so every pipeline (post_reddit, engage_reddit,
|
|
6
|
+
run-twitter-cycle, run-linkedin, engage-twitter, engage-linkedin) references
|
|
7
|
+
a single source of truth.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from engagement_styles import VALID_STYLES, REPLY_STYLES, get_styles_prompt, get_content_rules, get_anti_patterns
|
|
11
|
+
|
|
12
|
+
Style universe (post 2026-05-22 cleanup, second pass):
|
|
13
|
+
The hardcoded STYLES dict is the curated baseline kept in-process so
|
|
14
|
+
the picker still works on a cold-start machine with no DB access. The
|
|
15
|
+
live "universe" is the union of STYLES + every row in the Postgres
|
|
16
|
+
table `engagement_styles_registry` (read via the s4l.ai API).
|
|
17
|
+
|
|
18
|
+
The registry table now carries THREE flavors discriminated by a `kind`
|
|
19
|
+
column:
|
|
20
|
+
- 'seed' : curated, ships with the repo
|
|
21
|
+
- 'model_invented' : created by register_style() when the orchestrator
|
|
22
|
+
proposes a new style inline via `new_style` JSON
|
|
23
|
+
- 'human_derived' : created once a day per platform by
|
|
24
|
+
scripts/generate_daily_human_style.py, distilled
|
|
25
|
+
from the top human replies in thread_top_replies
|
|
26
|
+
|
|
27
|
+
The picker bypasses score-based selection with HUMAN_DERIVED_RATE
|
|
28
|
+
probability per platform and asks the registry route for the latest
|
|
29
|
+
active human-derived row on that platform.
|
|
30
|
+
|
|
31
|
+
All reads/writes go through the s4l.ai /api/v1/engagement-styles/registry
|
|
32
|
+
route. We never touch the DB directly from this module.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import random
|
|
38
|
+
import sys as _sys_mod
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
|
|
41
|
+
# ── Style taxonomy ──────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
STYLES = {
|
|
44
|
+
"critic": {
|
|
45
|
+
"description": "Point out what's missing, flawed, or naive. Reframe the problem.",
|
|
46
|
+
"example": "the missing piece is eval. without a way to catch regressions, every 'improvement' is just vibes",
|
|
47
|
+
"best_in": {
|
|
48
|
+
"reddit": ["r/Entrepreneur", "r/smallbusiness", "r/startups"],
|
|
49
|
+
"twitter": ["tech", "startup", "business"],
|
|
50
|
+
"linkedin": ["strategy", "leadership", "operations"],
|
|
51
|
+
},
|
|
52
|
+
"note": "NEVER just nitpick; offer a non-obvious insight.",
|
|
53
|
+
"target_chars": 96,
|
|
54
|
+
},
|
|
55
|
+
"storyteller": {
|
|
56
|
+
"description": (
|
|
57
|
+
"Narrative-driven comment. Per the GROUNDING RULE, every "
|
|
58
|
+
"storyteller comment picks ONE of two mutually exclusive lanes: "
|
|
59
|
+
"Lane 1 (DISCLOSED STORY) opens with a hedge like "
|
|
60
|
+
"'hypothetically', 'imagine someone running this', 'scenario:', "
|
|
61
|
+
"'say a friend tried' and is then free to invent any specifics; "
|
|
62
|
+
"Lane 2 (NO FABRICATION) keeps the narrative plain-voiced but "
|
|
63
|
+
"every specific (numbers, durations, places, course names, "
|
|
64
|
+
"brands, headcount) must appear verbatim in the matched "
|
|
65
|
+
"project's content_angle / voice / messaging in config.json, "
|
|
66
|
+
"otherwise drop the specifics or pattern-frame "
|
|
67
|
+
"('the part that breaks down is...', 'the typical failure mode "
|
|
68
|
+
"is...'). Lead with failure or surprise, not success. Whose "
|
|
69
|
+
"voice tells the story (maker vs outside observer) is set by "
|
|
70
|
+
"the VOICE RELATIONSHIP rule, not by this style."
|
|
71
|
+
),
|
|
72
|
+
"example": (
|
|
73
|
+
"hypothetically, ran this for a few lecture blocks: recorder "
|
|
74
|
+
"into whisper into gpt into anki. raw prompts got a third "
|
|
75
|
+
"usable cards before duplicates took over. card gen, not "
|
|
76
|
+
"the pipeline, was the bottleneck."
|
|
77
|
+
),
|
|
78
|
+
"best_in": {
|
|
79
|
+
"reddit": ["r/startups", "r/Meditation", "r/vipassana"],
|
|
80
|
+
"twitter": ["personal growth", "founder stories"],
|
|
81
|
+
"linkedin": ["career", "leadership", "lessons learned"],
|
|
82
|
+
},
|
|
83
|
+
"note": (
|
|
84
|
+
"NEVER pivot to a product pitch. NEVER mix lanes: presenting an "
|
|
85
|
+
"invented specific as a lived fact ('ran this exact pipeline "
|
|
86
|
+
"last semester for two anatomy blocks', 'ran 22 cameras across "
|
|
87
|
+
"three properties for 8 months', 'sat 6 courses across three "
|
|
88
|
+
"centers') without a Lane 1 opener and without config.json "
|
|
89
|
+
"grounding is the exact failure mode the GROUNDING RULE forbids."
|
|
90
|
+
),
|
|
91
|
+
"target_chars": 206,
|
|
92
|
+
},
|
|
93
|
+
"pattern_recognizer": {
|
|
94
|
+
"description": "Name the pattern or phenomenon. Authority through pattern recognition, not credentials.",
|
|
95
|
+
"example": "This is called X / I've seen this play out dozens of times across Y.",
|
|
96
|
+
"best_in": {
|
|
97
|
+
"reddit": ["r/ExperiencedDevs", "r/programming", "r/webdev"],
|
|
98
|
+
"twitter": ["dev", "engineering", "tech trends"],
|
|
99
|
+
"linkedin": ["industry analysis", "tech leadership"],
|
|
100
|
+
},
|
|
101
|
+
"note": "Authority through pattern recognition, not credentials.",
|
|
102
|
+
"target_chars": 68,
|
|
103
|
+
},
|
|
104
|
+
"curious_probe": {
|
|
105
|
+
"description": "One specific follow-up question about the most interesting detail. Include 'curious because...' context.",
|
|
106
|
+
"example": "how are you handling two agents writing at once? curious because we hit silent overwrites and only a lock fixed it",
|
|
107
|
+
"best_in": {
|
|
108
|
+
"reddit": ["r/startups", "r/SaaS", "niche subs"],
|
|
109
|
+
"twitter": ["niche topics", "founder discussions"],
|
|
110
|
+
"linkedin": ["thought leadership", "niche B2B"],
|
|
111
|
+
},
|
|
112
|
+
"note": "ONE question only. Never multiple.",
|
|
113
|
+
"target_chars": 114,
|
|
114
|
+
},
|
|
115
|
+
"contrarian": {
|
|
116
|
+
"description": "Take a clear opposing position backed by experience.",
|
|
117
|
+
"example": "Everyone recommends X. I've done X for Y years and it's wrong.",
|
|
118
|
+
"best_in": {
|
|
119
|
+
"reddit": ["r/Entrepreneur", "r/ExperiencedDevs"],
|
|
120
|
+
"twitter": ["hot takes", "industry debates"],
|
|
121
|
+
"linkedin": ["industry debates", "contrarian leadership"],
|
|
122
|
+
},
|
|
123
|
+
"note": "Must have credible evidence. Empty hot takes get destroyed.",
|
|
124
|
+
"target_chars": 62,
|
|
125
|
+
},
|
|
126
|
+
"data_point_drop": {
|
|
127
|
+
"description": "Share one specific, believable metric. Let the number do the talking.",
|
|
128
|
+
"example": "$12k in a month (not 'a lot of money')",
|
|
129
|
+
"best_in": {
|
|
130
|
+
"reddit": ["r/Entrepreneur", "r/startups", "r/SaaS"],
|
|
131
|
+
"twitter": ["growth", "revenue", "metrics"],
|
|
132
|
+
"linkedin": ["results", "case studies"],
|
|
133
|
+
},
|
|
134
|
+
"note": "No links. Numbers must be believable, not impressive.",
|
|
135
|
+
"target_chars": 38,
|
|
136
|
+
},
|
|
137
|
+
"snarky_oneliner": {
|
|
138
|
+
"description": "Short, sharp, emotionally resonant observation (1 sentence max). Validates a shared frustration.",
|
|
139
|
+
"example": "the demo always works. that's the whole problem.",
|
|
140
|
+
"best_in": {
|
|
141
|
+
"reddit": ["large subs (500k+ members)"],
|
|
142
|
+
"twitter": ["viral threads", "tech complaints", "industry snark"],
|
|
143
|
+
"linkedin": [], # never on LinkedIn
|
|
144
|
+
},
|
|
145
|
+
"note": "NEVER in small/serious subs like r/vipassana. NEVER on LinkedIn.",
|
|
146
|
+
"target_chars": 48,
|
|
147
|
+
},
|
|
148
|
+
# ── Instagram-native caption styles (2026-05-21) ──
|
|
149
|
+
# Distinct from the reply/comment styles above: these describe the
|
|
150
|
+
# structural ARCHETYPE of a long-form IG caption (1400-2150 chars) +
|
|
151
|
+
# the matching 4-5 card overlay. Manually classified from the first
|
|
152
|
+
# 50 posted reels; the defeat-flip arc owns the viral lane (4 of top 5
|
|
153
|
+
# all-time hits, 1.14M peak). Walkin/studyly are product-gated.
|
|
154
|
+
"ig_defeat_flip_arc": {
|
|
155
|
+
"description": (
|
|
156
|
+
"8-beat first-person caption: 'i was [role] for N years. i posted a "
|
|
157
|
+
"confident take. last [time], [agent/junior] did [my job] in [short "
|
|
158
|
+
"time]. i sat at the kitchen counter at midnight with a coffee that "
|
|
159
|
+
"had gone cold. i changed what i sell. the lesson is [skill] was "
|
|
160
|
+
"never the job. [skill] was the typing, typing is free now. stop "
|
|
161
|
+
"[old behavior]. start [new behavior].' Self-deprecating founder "
|
|
162
|
+
"voice; specific numbers (ages, dollar amounts, dates, view counts); "
|
|
163
|
+
"lowercase throughout. Top performer for organic IG posts."
|
|
164
|
+
),
|
|
165
|
+
"example": (
|
|
166
|
+
"i was 33. nine years writing typescript. fast hands, faster "
|
|
167
|
+
"opinions. last tuesday a 26-year-old shipped my roadmap in 3 days "
|
|
168
|
+
"with claude code. i sat in my kitchen at 1am with a coffee that "
|
|
169
|
+
"had gone cold. ... the lesson is the typing was the job. typing is "
|
|
170
|
+
"free now. stop defending your seat. start running the review."
|
|
171
|
+
),
|
|
172
|
+
"best_in": {
|
|
173
|
+
"instagram": ["matt_diak", "matthewheartful", "organic AI-lesson reels"],
|
|
174
|
+
},
|
|
175
|
+
"note": (
|
|
176
|
+
"Caption MUST be 1400-2150 chars; overlay is 4 hook-arc cards (2s "
|
|
177
|
+
"each, white bg, black text). Open 'here is a story.'; close with "
|
|
178
|
+
"lesson + 'stop X, start Y' imperative. NO product mention (no "
|
|
179
|
+
"Fazm/Mediar/AppMaker/mk0r/studyly): organic only."
|
|
180
|
+
),
|
|
181
|
+
"target_chars": 1800,
|
|
182
|
+
},
|
|
183
|
+
"ig_walkin_storefront_playbook": {
|
|
184
|
+
"description": (
|
|
185
|
+
"Product demo for mk0r: show mk0r building a REAL website for a "
|
|
186
|
+
"local business that has none. 'i noticed [a local spot] near me "
|
|
187
|
+
"with no website, just a maps pin and one blurry photo. i opened "
|
|
188
|
+
"mk0r.com, described the place in one prompt, and watched it build "
|
|
189
|
+
"a real site, hero, services, hours, a call button, live in "
|
|
190
|
+
"minutes.' Focus on the CAPABILITY and the speed, and what it "
|
|
191
|
+
"means for a small business to finally have a real site online. NO "
|
|
192
|
+
"earnings, NO 'they paid me', NO recurring-revenue or 'signed N "
|
|
193
|
+
"clients' totals. Reference mk0r.com plainly."
|
|
194
|
+
),
|
|
195
|
+
"example": (
|
|
196
|
+
"there's a tire shop two blocks from me thats been open fifteen "
|
|
197
|
+
"years. i went to send a friend the link and there was no link, "
|
|
198
|
+
"just a maps pin and one blurry photo someone else uploaded. so i "
|
|
199
|
+
"opened mk0r.com and described the shop in one prompt, brakes, "
|
|
200
|
+
"tires, the hours, the phone number. it built the whole site while "
|
|
201
|
+
"i watched, a hero, a services list, a map, one big call button. "
|
|
202
|
+
"fifteen years open and it finally has a front door online. mk0r.com"
|
|
203
|
+
),
|
|
204
|
+
"best_in": {
|
|
205
|
+
"instagram": [
|
|
206
|
+
"matt_diak / mk0r product reels",
|
|
207
|
+
"spa", "auto shops", "hotel", "retail", "motel",
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
"note": (
|
|
211
|
+
"Caption is the product-demo arc: a real local business with no "
|
|
212
|
+
"website, mk0r builds a real site from one prompt, what it means "
|
|
213
|
+
"for the owner to finally be online. NO income/earnings/'they paid "
|
|
214
|
+
"me'/recurring-revenue/'signed N clients' framing -- that arc "
|
|
215
|
+
"tripped a Meta fraud-and-deceptive-practices restriction on "
|
|
216
|
+
"2026-06-02 (matt_diak link-sharing restricted 30d). Reference "
|
|
217
|
+
"'mk0r.com' plainly in the caption. project_name='mk0r' on the "
|
|
218
|
+
"row. Fires when TARGET=product AND selected_project=mk0r."
|
|
219
|
+
),
|
|
220
|
+
"target_chars": 1800,
|
|
221
|
+
},
|
|
222
|
+
"ig_studyly_failing_student_arc": {
|
|
223
|
+
"description": (
|
|
224
|
+
"Study-method arc for studyly: 'i was [age], [program]. i was "
|
|
225
|
+
"[rereading/highlighting/making flashcards] for hours and still "
|
|
226
|
+
"blanking the moment the wording changed. a friend sent me "
|
|
227
|
+
"studyly.io. i pasted my [notes/chapter] in and it quizzed me on "
|
|
228
|
+
"my own material until i could answer without looking, rewording "
|
|
229
|
+
"each question so i couldnt pattern-match. for the first time i "
|
|
230
|
+
"could tell what i actually knew from what i only recognized.' "
|
|
231
|
+
"Focus on the METHOD shift (active recall vs rereading, testing on "
|
|
232
|
+
"your own deck). Closes with the rereading-is-theater lesson + "
|
|
233
|
+
"studyly.io footer. Do NOT promise or fabricate specific "
|
|
234
|
+
"before/after exam scores as a typical result."
|
|
235
|
+
),
|
|
236
|
+
"example": (
|
|
237
|
+
"i was 19. premed, third semester, organic chemistry. i had reread "
|
|
238
|
+
"the chapter four times and could still blank on a mechanism the "
|
|
239
|
+
"second the wording changed. i pasted my notes into studyly.io at "
|
|
240
|
+
"2am and it asked me the things i thought i knew, rewording each "
|
|
241
|
+
"one so i couldnt coast on the first three words. that was the "
|
|
242
|
+
"first night i could actually tell what i knew from what i only "
|
|
243
|
+
"recognized. the lesson is rereading is recognizing. close the "
|
|
244
|
+
"book. let something ask you. studyly.io"
|
|
245
|
+
),
|
|
246
|
+
"best_in": {
|
|
247
|
+
"instagram": [
|
|
248
|
+
"matt_diak / matthewheartful studyly product reels",
|
|
249
|
+
"premed", "MCAT", "nursing pharm",
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
"note": (
|
|
253
|
+
"Caption is shorter than mk0r (1400-1900 chars). 'here is a story.' "
|
|
254
|
+
"opener optional. MUST include 'studyly.io' footer. "
|
|
255
|
+
"project_name='studyly'. Lesson is always rereading-is-theater. NO "
|
|
256
|
+
"specific before/after exam scores or 'failed -> passed/topped the "
|
|
257
|
+
"class' miracle jumps presented as a typical/guaranteed outcome -- "
|
|
258
|
+
"exaggerated-results claims are a deceptive-practices signal (same "
|
|
259
|
+
"Meta rail that restricted mk0r 2026-06-02); keep outcomes "
|
|
260
|
+
"qualitative and personal. Fires when TARGET=product AND "
|
|
261
|
+
"selected_project=studyly."
|
|
262
|
+
),
|
|
263
|
+
"target_chars": 1650,
|
|
264
|
+
},
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# Valid tone styles. Same set for posting and replying: tone is a separate
|
|
268
|
+
# dimension from project-recommendation intent, which is now tracked on its
|
|
269
|
+
# own boolean column (posts.is_recommendation / replies.is_recommendation).
|
|
270
|
+
# REPLY_STYLES is kept as an alias for backwards compatibility with callers
|
|
271
|
+
# that historically treated it as a superset.
|
|
272
|
+
VALID_STYLES = set(STYLES.keys())
|
|
273
|
+
REPLY_STYLES = VALID_STYLES
|
|
274
|
+
|
|
275
|
+
# ── Registry-backed style universe (DB, not JSON) ──────────────────
|
|
276
|
+
#
|
|
277
|
+
# Cleanup 2026-05-22: every model-invented style lands in the Postgres
|
|
278
|
+
# table `engagement_styles_registry` via POST
|
|
279
|
+
# /api/v1/engagement-styles/registry. The legacy file-based sidecar
|
|
280
|
+
# (scripts/engagement_styles_extra.json) and the two-tier
|
|
281
|
+
# candidate→active promoter are GONE. Every install sees every other
|
|
282
|
+
# install's registered styles, and a new invention is live for the next
|
|
283
|
+
# picker tick on every install (no JSON file to ship).
|
|
284
|
+
#
|
|
285
|
+
# DB registry row shape (engagement_styles_registry):
|
|
286
|
+
# {
|
|
287
|
+
# "name": str (PK), "description": str, "example": str, "note": str,
|
|
288
|
+
# "best_in": dict, # {platform: hint|bool|[..]}
|
|
289
|
+
# "status": "active" | "retired", # 'active' on every new row
|
|
290
|
+
# "why_existing_didnt_fit": str | None,
|
|
291
|
+
# "first_post_url": str | None,
|
|
292
|
+
# "first_post_id": int | None,
|
|
293
|
+
# "first_post_platform": str | None,
|
|
294
|
+
# "invented_by_model": str | None,
|
|
295
|
+
# "invented_at": ISO-8601 UTC,
|
|
296
|
+
# "promoted_at": ISO-8601 UTC, # set = invented_at on new rows
|
|
297
|
+
# "created_at" / "updated_at": ISO-8601 UTC,
|
|
298
|
+
# }
|
|
299
|
+
#
|
|
300
|
+
# The seeds in STYLES{} are kept in-process as a cold-start fallback so
|
|
301
|
+
# the picker works on a machine with no DB access; they're also seeded
|
|
302
|
+
# into the table via scripts/migrate_engagement_styles_to_db.py.
|
|
303
|
+
|
|
304
|
+
_REQUIRED_NEW_STYLE_FIELDS = ("description", "example", "why_existing_didnt_fit")
|
|
305
|
+
|
|
306
|
+
# In-process cache for registry reads. ~5 min keeps the picker from
|
|
307
|
+
# hammering the API on every pick (a Twitter cycle picks ~20 times per
|
|
308
|
+
# 15-minute window) while still surfacing a newly-invented style from
|
|
309
|
+
# another install within one window.
|
|
310
|
+
_REGISTRY_CACHE = {"ts": 0.0, "rows": None}
|
|
311
|
+
_REGISTRY_CACHE_TTL_SEC = 300
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_entry(entry, default_status="active", default_kind="seed"):
|
|
315
|
+
"""Ensure a STYLES-style dict has the fields callers expect."""
|
|
316
|
+
out = dict(entry) if isinstance(entry, dict) else {}
|
|
317
|
+
out.setdefault("status", default_status)
|
|
318
|
+
out.setdefault("description", "")
|
|
319
|
+
out.setdefault("example", "")
|
|
320
|
+
out.setdefault("note", "")
|
|
321
|
+
out.setdefault("best_in", {})
|
|
322
|
+
# Authoritative per-style target comment length. Falls back to the
|
|
323
|
+
# short-biased default for legacy rows / cold-start entries that predate
|
|
324
|
+
# the target_chars column.
|
|
325
|
+
try:
|
|
326
|
+
out["target_chars"] = int(out.get("target_chars") or DEFAULT_TARGET_CHARS)
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
out["target_chars"] = DEFAULT_TARGET_CHARS
|
|
329
|
+
# kind discriminates origin: 'seed' (hardcoded/top-performer), 'model_invented'
|
|
330
|
+
# (Claude proposed during a posting run), 'human_derived' (synthesized from
|
|
331
|
+
# the daily human-reply digest). Surfaced in the dashboard as a bracket
|
|
332
|
+
# next to the style name.
|
|
333
|
+
out.setdefault("kind", default_kind)
|
|
334
|
+
return out
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _fetch_registry_styles(force_refresh=False):
|
|
338
|
+
"""Pull every active row in engagement_styles_registry via the API.
|
|
339
|
+
|
|
340
|
+
Returns {name: {description, example, note, best_in, status, ...}}.
|
|
341
|
+
Cached for _REGISTRY_CACHE_TTL_SEC; pass force_refresh=True to bust.
|
|
342
|
+
|
|
343
|
+
Best-effort: returns {} on any error (API unreachable, missing env,
|
|
344
|
+
cold start) so callers can fall back to the in-process STYLES dict.
|
|
345
|
+
"""
|
|
346
|
+
import time as _time
|
|
347
|
+
now = _time.time()
|
|
348
|
+
if (
|
|
349
|
+
not force_refresh
|
|
350
|
+
and _REGISTRY_CACHE["rows"] is not None
|
|
351
|
+
and (now - _REGISTRY_CACHE["ts"]) < _REGISTRY_CACHE_TTL_SEC
|
|
352
|
+
):
|
|
353
|
+
return _REGISTRY_CACHE["rows"]
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
import sys as _sys
|
|
357
|
+
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
358
|
+
from http_api import api_get
|
|
359
|
+
resp = api_get("/api/v1/engagement-styles/registry", {"status": "active"})
|
|
360
|
+
data = (resp or {}).get("data") or {}
|
|
361
|
+
rows = data.get("styles") or []
|
|
362
|
+
except Exception:
|
|
363
|
+
# Don't poison the cache with an empty on transient failure: if we
|
|
364
|
+
# had data before, keep serving it.
|
|
365
|
+
if _REGISTRY_CACHE["rows"] is not None:
|
|
366
|
+
return _REGISTRY_CACHE["rows"]
|
|
367
|
+
return {}
|
|
368
|
+
|
|
369
|
+
out = {}
|
|
370
|
+
for r in rows:
|
|
371
|
+
name = r.get("name")
|
|
372
|
+
if not name:
|
|
373
|
+
continue
|
|
374
|
+
best_in = r.get("best_in") or {}
|
|
375
|
+
if isinstance(best_in, str):
|
|
376
|
+
try:
|
|
377
|
+
best_in = json.loads(best_in)
|
|
378
|
+
except Exception:
|
|
379
|
+
best_in = {}
|
|
380
|
+
# Default kind for legacy rows pre-consolidation is 'seed'; the
|
|
381
|
+
# 2026-05-22 migration backfilled invented_by_model<>null rows to
|
|
382
|
+
# 'model_invented' and the human_derived migration inserted those
|
|
383
|
+
# explicitly. Trust the column.
|
|
384
|
+
out[name] = {
|
|
385
|
+
"description": r.get("description") or "",
|
|
386
|
+
"example": r.get("example") or "",
|
|
387
|
+
"note": r.get("note") or "",
|
|
388
|
+
"best_in": best_in,
|
|
389
|
+
"status": r.get("status") or "active",
|
|
390
|
+
"kind": r.get("kind") or "seed",
|
|
391
|
+
"invented_by_model": r.get("invented_by_model"),
|
|
392
|
+
"invented_at": r.get("invented_at"),
|
|
393
|
+
"promoted_at": r.get("promoted_at"),
|
|
394
|
+
"first_post_url": r.get("first_post_url"),
|
|
395
|
+
"first_post_platform": r.get("first_post_platform"),
|
|
396
|
+
"why_existing_didnt_fit": r.get("why_existing_didnt_fit") or "",
|
|
397
|
+
"target_chars": r.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
398
|
+
}
|
|
399
|
+
_REGISTRY_CACHE["rows"] = out
|
|
400
|
+
_REGISTRY_CACHE["ts"] = now
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_all_styles():
|
|
405
|
+
"""Merged universe: hardcoded STYLES + registry rows + human-derived rows.
|
|
406
|
+
|
|
407
|
+
Reads pull from the live Postgres registry (cached briefly), so a
|
|
408
|
+
style invented by any install is visible to every other install on
|
|
409
|
+
the next picker tick. STYLES{} is the cold-start fallback when the
|
|
410
|
+
API is unreachable.
|
|
411
|
+
|
|
412
|
+
Merge order (later wins on duplicate name):
|
|
413
|
+
1. Hardcoded STYLES (cold-start floor)
|
|
414
|
+
2. engagement_styles_registry rows (the live source of truth,
|
|
415
|
+
includes kind in {'seed','model_invented','human_derived'})
|
|
416
|
+
3. Same registry filtered to kind='human_derived' (only for names
|
|
417
|
+
not already in 1/2; pure defense-in-depth — under normal
|
|
418
|
+
operation step 2 already returned every row, so step 3 is a
|
|
419
|
+
no-op. Kept for the case where _fetch_registry_styles failed
|
|
420
|
+
but _load_human_derived_styles succeeded.)
|
|
421
|
+
|
|
422
|
+
Caller MUST treat the returned dict as read-only.
|
|
423
|
+
"""
|
|
424
|
+
merged = {
|
|
425
|
+
name: _normalize_entry(meta, "active", "seed")
|
|
426
|
+
for name, meta in STYLES.items()
|
|
427
|
+
}
|
|
428
|
+
for name, meta in _fetch_registry_styles().items():
|
|
429
|
+
if not isinstance(meta, dict):
|
|
430
|
+
continue
|
|
431
|
+
# Trust the kind column the registry already set; fall back to 'seed'.
|
|
432
|
+
merged[name] = _normalize_entry(meta, "active", meta.get("kind") or "seed")
|
|
433
|
+
for name, meta in _load_human_derived_styles().items():
|
|
434
|
+
if name in merged:
|
|
435
|
+
# Don't clobber a curated/registry entry if the synthesizer
|
|
436
|
+
# happens to pick a colliding snake_case name.
|
|
437
|
+
continue
|
|
438
|
+
merged[name] = _normalize_entry(meta, "active", "human_derived")
|
|
439
|
+
return merged
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _load_human_derived_styles():
|
|
443
|
+
"""Map of {name: {description, example, note, best_in}} for every
|
|
444
|
+
active human_derived row in engagement_styles_registry.
|
|
445
|
+
|
|
446
|
+
Reads via the /api/v1/engagement-styles/registry route filtered by
|
|
447
|
+
kind=human_derived. Best-effort; returns {} on any failure so callers
|
|
448
|
+
don't have to wrap in try/except. Defense-in-depth alongside
|
|
449
|
+
_fetch_registry_styles(): if the synthesizer ever names a row the same
|
|
450
|
+
as an existing seed, get_all_styles() will already have the seed and
|
|
451
|
+
skip the human_derived entry.
|
|
452
|
+
"""
|
|
453
|
+
try:
|
|
454
|
+
import sys as _sys
|
|
455
|
+
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
456
|
+
from http_api import api_get
|
|
457
|
+
resp = api_get(
|
|
458
|
+
"/api/v1/engagement-styles/registry",
|
|
459
|
+
{"status": "active", "kind": "human_derived"},
|
|
460
|
+
)
|
|
461
|
+
data = (resp or {}).get("data") or {}
|
|
462
|
+
rows = data.get("styles") or []
|
|
463
|
+
except Exception:
|
|
464
|
+
return {}
|
|
465
|
+
out = {}
|
|
466
|
+
for r in rows:
|
|
467
|
+
name = r.get("name")
|
|
468
|
+
if not name:
|
|
469
|
+
continue
|
|
470
|
+
best_in = r.get("best_in") or {}
|
|
471
|
+
if isinstance(best_in, str):
|
|
472
|
+
try:
|
|
473
|
+
best_in = json.loads(best_in)
|
|
474
|
+
except Exception:
|
|
475
|
+
best_in = {}
|
|
476
|
+
out[name] = {
|
|
477
|
+
"description": r.get("description") or "",
|
|
478
|
+
"example": r.get("example") or "",
|
|
479
|
+
"note": r.get("note") or "",
|
|
480
|
+
"best_in": best_in,
|
|
481
|
+
"target_chars": r.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
482
|
+
}
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def register_style(name, meta, source_post=None):
|
|
487
|
+
"""Register a model-invented style into engagement_styles_registry.
|
|
488
|
+
|
|
489
|
+
Called when an orchestrator parses a decision JSON whose
|
|
490
|
+
engagement_style is not in get_all_styles() and whose `new_style`
|
|
491
|
+
block is well-formed.
|
|
492
|
+
|
|
493
|
+
POSTs to /api/v1/engagement-styles/registry; the server upserts the
|
|
494
|
+
row (ON CONFLICT DO NOTHING on name). Concurrency is handled at the
|
|
495
|
+
Postgres layer (PK uniqueness), so we don't need a file lock anymore.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
name: the style name the model picked.
|
|
499
|
+
meta: dict with at least description/example/why_existing_didnt_fit
|
|
500
|
+
(and optionally note). Anything else is preserved verbatim.
|
|
501
|
+
source_post: optional dict {platform, post_url, post_id, model}
|
|
502
|
+
describing the post that birthed this style. Recorded only
|
|
503
|
+
the FIRST time a name is registered (server-side ON CONFLICT
|
|
504
|
+
keeps the original values).
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
(status_str, entry_dict): status in {"new", "existing", "rejected"}.
|
|
508
|
+
On "rejected", entry_dict carries an "error" key describing why.
|
|
509
|
+
"""
|
|
510
|
+
if not name or not isinstance(name, str):
|
|
511
|
+
return "rejected", {"error": "name must be a non-empty string"}
|
|
512
|
+
if not isinstance(meta, dict):
|
|
513
|
+
return "rejected", {"error": "new_style block must be an object"}
|
|
514
|
+
missing = [f for f in _REQUIRED_NEW_STYLE_FIELDS
|
|
515
|
+
if not (isinstance(meta.get(f), str) and meta[f].strip())]
|
|
516
|
+
if missing:
|
|
517
|
+
return "rejected", {"error": f"new_style missing fields: {missing}"}
|
|
518
|
+
if name in STYLES:
|
|
519
|
+
# The model picked a hardcoded name and *also* shipped a new_style
|
|
520
|
+
# block. Treat as "existing"; never overwrite the curated entry.
|
|
521
|
+
return "existing", _normalize_entry(STYLES[name], "active")
|
|
522
|
+
|
|
523
|
+
# Cheap local short-circuit: if our cached registry already has this
|
|
524
|
+
# name, skip the network call and return existing immediately. The
|
|
525
|
+
# cache is shared across calls within the same process so this saves
|
|
526
|
+
# one HTTP round-trip per duplicate invention attempt.
|
|
527
|
+
cached = _fetch_registry_styles()
|
|
528
|
+
if name in cached:
|
|
529
|
+
return "existing", cached[name]
|
|
530
|
+
|
|
531
|
+
src = source_post or {}
|
|
532
|
+
# Coerce the model-declared target length. The invent prompt requires a
|
|
533
|
+
# target_chars in the new_style block, but we default gracefully rather
|
|
534
|
+
# than reject an otherwise-valid invention just because the length is
|
|
535
|
+
# missing or garbage. Clamp to a sane 20..2200 band.
|
|
536
|
+
try:
|
|
537
|
+
_tc = int(meta.get("target_chars"))
|
|
538
|
+
target_chars = max(20, min(2200, _tc))
|
|
539
|
+
except (TypeError, ValueError):
|
|
540
|
+
target_chars = DEFAULT_TARGET_CHARS
|
|
541
|
+
# 2026-05-25: explicitly stamp kind='model_invented' on the payload.
|
|
542
|
+
# The server route (social-autoposter-website/src/app/api/v1/engagement-styles/
|
|
543
|
+
# registry/route.ts:220-234) defaults kind to 'seed' when invented_by_model
|
|
544
|
+
# is empty, and to 'model_invented' otherwise. Most callers above don't
|
|
545
|
+
# populate source_post["model"] (it's optional), so the server fallback
|
|
546
|
+
# silently buries every invention under kind='seed' — making the
|
|
547
|
+
# model_invented bucket forever empty. Sending kind explicitly bypasses
|
|
548
|
+
# the heuristic entirely; register_style() is ONLY called from the
|
|
549
|
+
# invent path (see validate_or_register at line ~542), so the label is
|
|
550
|
+
# always correct here.
|
|
551
|
+
payload = {
|
|
552
|
+
"name": name,
|
|
553
|
+
"kind": "model_invented",
|
|
554
|
+
"description": meta["description"].strip(),
|
|
555
|
+
"example": meta["example"].strip(),
|
|
556
|
+
"note": (meta.get("note") or "").strip(),
|
|
557
|
+
"why_existing_didnt_fit": meta["why_existing_didnt_fit"].strip(),
|
|
558
|
+
"first_post_url": src.get("post_url"),
|
|
559
|
+
"first_post_id": src.get("post_id"),
|
|
560
|
+
"first_post_platform": src.get("platform"),
|
|
561
|
+
"invented_by_model": src.get("model"),
|
|
562
|
+
"best_in": {},
|
|
563
|
+
"target_chars": target_chars,
|
|
564
|
+
}
|
|
565
|
+
try:
|
|
566
|
+
import sys as _sys
|
|
567
|
+
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
568
|
+
from http_api import api_post
|
|
569
|
+
resp = api_post("/api/v1/engagement-styles/registry", payload)
|
|
570
|
+
except SystemExit as e:
|
|
571
|
+
return "rejected", {"error": f"registry POST failed: {e}"}
|
|
572
|
+
except Exception as e:
|
|
573
|
+
return "rejected", {"error": f"registry POST raised: {e}"}
|
|
574
|
+
|
|
575
|
+
data = (resp or {}).get("data") or {}
|
|
576
|
+
style_row = data.get("style") or {}
|
|
577
|
+
created = bool(data.get("created"))
|
|
578
|
+
|
|
579
|
+
# Bust the local cache so the very next get_all_styles() includes this
|
|
580
|
+
# row (otherwise the picker's coerce-or-validate pass would still
|
|
581
|
+
# reject it for the next ~5 minutes).
|
|
582
|
+
_REGISTRY_CACHE["ts"] = 0.0
|
|
583
|
+
_REGISTRY_CACHE["rows"] = None
|
|
584
|
+
|
|
585
|
+
entry = _normalize_entry(
|
|
586
|
+
{
|
|
587
|
+
"description": style_row.get("description") or payload["description"],
|
|
588
|
+
"example": style_row.get("example") or payload["example"],
|
|
589
|
+
"note": style_row.get("note") or payload["note"],
|
|
590
|
+
"best_in": style_row.get("best_in") or {},
|
|
591
|
+
"status": style_row.get("status") or "active",
|
|
592
|
+
"target_chars": style_row.get("target_chars") or payload["target_chars"],
|
|
593
|
+
},
|
|
594
|
+
"active",
|
|
595
|
+
)
|
|
596
|
+
return ("new" if created else "existing"), entry
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def validate_or_register(decision, source_post=None, context="posting",
|
|
600
|
+
assigned_style=None, assigned_mode=None):
|
|
601
|
+
"""One-shot helper for orchestrators that parse a decision JSON.
|
|
602
|
+
|
|
603
|
+
Reads decision["engagement_style"] (and optional decision["new_style"]).
|
|
604
|
+
Returns (style_or_None, action) where action is one of:
|
|
605
|
+
"valid" → style is in the universe, accept it
|
|
606
|
+
"coerced" → USE-mode picker assigned a specific style and the
|
|
607
|
+
model drifted to something else; we silently coerce
|
|
608
|
+
back to the assigned style (drift-protection)
|
|
609
|
+
"registered" → INVENT-mode + well-formed new_style → registered
|
|
610
|
+
in the DB registry, accept it
|
|
611
|
+
"rejected" → unknown style and no usable new_style, OR drift in
|
|
612
|
+
a context where the assigned style is known but the
|
|
613
|
+
model neither used it nor shipped a valid new_style
|
|
614
|
+
"passthrough"→ no assignment context (legacy caller); same as
|
|
615
|
+
valid/registered/rejected branches but logged
|
|
616
|
+
distinctly
|
|
617
|
+
|
|
618
|
+
`assigned_style` / `assigned_mode` (added 2026-05-22): when the caller
|
|
619
|
+
used pick_style_for_post() it now passes the assignment back in. We
|
|
620
|
+
use it to (a) coerce drift in USE mode back to the assigned name
|
|
621
|
+
(eliminating the "model picks pattern_recognizer because it's
|
|
622
|
+
generic" bias), and (b) only allow invention when the picker
|
|
623
|
+
actually asked for it (INVENT mode). This closes the enforcement gap
|
|
624
|
+
where any rail could silently invent a style outside the assigned
|
|
625
|
+
path.
|
|
626
|
+
|
|
627
|
+
Logs the action to stdout for the orchestrator's run log.
|
|
628
|
+
"""
|
|
629
|
+
style = decision.get("engagement_style") if isinstance(decision, dict) else None
|
|
630
|
+
new_style = decision.get("new_style") if isinstance(decision, dict) else None
|
|
631
|
+
|
|
632
|
+
# USE-mode drift protection: picker assigned a style, model picked
|
|
633
|
+
# something else. Don't trust the model's "improvement" — coerce
|
|
634
|
+
# back. Inventions in USE mode are not allowed; the assigned style
|
|
635
|
+
# already exists, so any new_style block here is the model
|
|
636
|
+
# over-reaching.
|
|
637
|
+
if assigned_mode == "use" and assigned_style:
|
|
638
|
+
if style == assigned_style:
|
|
639
|
+
return style, "valid"
|
|
640
|
+
universe = get_all_styles()
|
|
641
|
+
if style and style in universe and style != assigned_style:
|
|
642
|
+
print(
|
|
643
|
+
f"[engagement_styles] DRIFT in USE mode: model returned "
|
|
644
|
+
f"{style!r} but picker assigned {assigned_style!r}; "
|
|
645
|
+
f"coercing back."
|
|
646
|
+
)
|
|
647
|
+
return assigned_style, "coerced"
|
|
648
|
+
# Unknown style or no style — also coerce back to assigned. The
|
|
649
|
+
# assigned style is guaranteed to be in the universe (picker
|
|
650
|
+
# built it from get_all_styles()).
|
|
651
|
+
print(
|
|
652
|
+
f"[engagement_styles] DRIFT in USE mode: model returned "
|
|
653
|
+
f"{style!r} (not in universe); coercing to assigned "
|
|
654
|
+
f"{assigned_style!r}."
|
|
655
|
+
)
|
|
656
|
+
return assigned_style, "coerced"
|
|
657
|
+
|
|
658
|
+
# INVENT-mode: picker explicitly asked for a new style. Require a
|
|
659
|
+
# well-formed new_style block; if model returned an existing style
|
|
660
|
+
# name, accept it as if INVENT had landed on something already in
|
|
661
|
+
# the registry (rare but harmless).
|
|
662
|
+
if assigned_mode == "invent":
|
|
663
|
+
if style and style in get_all_styles() and not isinstance(new_style, dict):
|
|
664
|
+
return style, "valid"
|
|
665
|
+
if not style or not isinstance(new_style, dict):
|
|
666
|
+
print(
|
|
667
|
+
f"[engagement_styles] INVENT mode but model returned "
|
|
668
|
+
f"style={style!r} new_style_block={bool(new_style)}; "
|
|
669
|
+
f"rejecting."
|
|
670
|
+
)
|
|
671
|
+
return None, "rejected"
|
|
672
|
+
status, entry = register_style(style, new_style, source_post)
|
|
673
|
+
if status == "rejected":
|
|
674
|
+
print(
|
|
675
|
+
f"[engagement_styles] new_style for {style!r} rejected: "
|
|
676
|
+
f"{entry.get('error')}"
|
|
677
|
+
)
|
|
678
|
+
return None, "rejected"
|
|
679
|
+
if status == "new":
|
|
680
|
+
src_url = (source_post or {}).get("post_url", "?")
|
|
681
|
+
print(
|
|
682
|
+
f"[engagement_styles] REGISTERED new style {style!r} "
|
|
683
|
+
f"from {src_url}"
|
|
684
|
+
)
|
|
685
|
+
return style, "registered"
|
|
686
|
+
|
|
687
|
+
# Legacy callers (no assignment context): behave as before, with the
|
|
688
|
+
# caveat that any silent invention will create an `active` registry
|
|
689
|
+
# row instead of a `candidate` sidecar entry.
|
|
690
|
+
if style and style in get_all_styles():
|
|
691
|
+
return style, "valid"
|
|
692
|
+
|
|
693
|
+
if not style:
|
|
694
|
+
return None, "rejected"
|
|
695
|
+
|
|
696
|
+
if not isinstance(new_style, dict):
|
|
697
|
+
print(f"[engagement_styles] unknown style {style!r} and no new_style block; rejecting")
|
|
698
|
+
return None, "rejected"
|
|
699
|
+
|
|
700
|
+
status, entry = register_style(style, new_style, source_post)
|
|
701
|
+
if status == "rejected":
|
|
702
|
+
print(f"[engagement_styles] new_style for {style!r} rejected: {entry.get('error')}")
|
|
703
|
+
return None, "rejected"
|
|
704
|
+
if status == "new":
|
|
705
|
+
src_url = (source_post or {}).get("post_url", "?")
|
|
706
|
+
print(f"[engagement_styles] REGISTERED new style {style!r} from {src_url}")
|
|
707
|
+
return style, "registered"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
# ── Platform-specific policy overlay ────────────────────────────────
|
|
711
|
+
#
|
|
712
|
+
# Tier assignment (dominant / secondary / rare) is DB-driven — see
|
|
713
|
+
# get_dynamic_tiers() below. This dict only stores static policy that
|
|
714
|
+
# is not a performance judgment:
|
|
715
|
+
# - `never`: tone/brand constraints (e.g. no snark on LinkedIn). Even
|
|
716
|
+
# if the data showed high upvotes, we still do not want this style.
|
|
717
|
+
# - `note`: per-platform tone/length hint shown at the top of the
|
|
718
|
+
# styles prompt.
|
|
719
|
+
|
|
720
|
+
PLATFORM_POLICY = {
|
|
721
|
+
"reddit": {
|
|
722
|
+
"never": ["curious_probe"],
|
|
723
|
+
"note": "Short wins. 1 punchy sentence or 4-5 of real substance. Start with 'I' or 'my'. Match style to subreddit culture.",
|
|
724
|
+
},
|
|
725
|
+
"twitter": {
|
|
726
|
+
"never": [],
|
|
727
|
+
"note": "Brevity wins. Direct product mentions OK (unlike Reddit). 1-2 sentences max.",
|
|
728
|
+
},
|
|
729
|
+
"linkedin": {
|
|
730
|
+
"never": ["snarky_oneliner"],
|
|
731
|
+
"note": "Professional but human. Softer critic framing. No snark. 2-4 sentences.",
|
|
732
|
+
},
|
|
733
|
+
"github": {
|
|
734
|
+
"never": ["snarky_oneliner"],
|
|
735
|
+
"note": "Technical and specific. Lead with the pain, then the fix. 400-600 chars.",
|
|
736
|
+
},
|
|
737
|
+
"moltbook": {
|
|
738
|
+
"never": [],
|
|
739
|
+
"note": "Agent voice ('my human'). Conversational but substantive. 2-4 sentences.",
|
|
740
|
+
},
|
|
741
|
+
"instagram": {
|
|
742
|
+
# Reply/comment styles don't apply to long-form IG captions.
|
|
743
|
+
# Product styles are project-gated and assigned by the render
|
|
744
|
+
# script directly (see skill/run-instagram-render.sh) so the
|
|
745
|
+
# picker can't accidentally roll a "walkin" style for an organic
|
|
746
|
+
# matt_diak post.
|
|
747
|
+
"never": [
|
|
748
|
+
"critic", "storyteller", "pattern_recognizer", "curious_probe",
|
|
749
|
+
"contrarian", "data_point_drop", "snarky_oneliner",
|
|
750
|
+
"ig_walkin_storefront_playbook",
|
|
751
|
+
"ig_studyly_failing_student_arc",
|
|
752
|
+
],
|
|
753
|
+
"note": (
|
|
754
|
+
"IG captions are long-form ORIGINAL posts (1400-2150 chars), "
|
|
755
|
+
"lowercase, 8-beat story arc. Overlay is 4-5 short cards "
|
|
756
|
+
"(2s each, white bg, black text). Voice is self-deprecating "
|
|
757
|
+
"founder confession. NO em/en dashes. The picker only fires "
|
|
758
|
+
"on TARGET=organic; product posts assign style directly from "
|
|
759
|
+
"selected_project."
|
|
760
|
+
),
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
# Minimum sample size to count a style as trusted. n=1 means "at least one
|
|
765
|
+
# real post in the 30-day window". 2026-05-25: lowered from 5 → 1 so an
|
|
766
|
+
# invented style that produced even a single post (small batch, partial-batch
|
|
767
|
+
# failure, etc.) competes on per-post score from day one. The 30-day recency
|
|
768
|
+
# window is the only freshness gate; ghost styles (n=0 registry rows) stay
|
|
769
|
+
# excluded from the use_pool by this floor.
|
|
770
|
+
MIN_SAMPLE_SIZE = 1
|
|
771
|
+
|
|
772
|
+
# Legacy target-distribution knobs. UNUSED since 2026-05-29: the picker
|
|
773
|
+
# (pick_style_for_post) samples on raw _picker_score with no exponent / floor /
|
|
774
|
+
# cap, and compute_target_distribution now reports that same true pick
|
|
775
|
+
# probability instead of a sharpened-floored-capped target. These only ever
|
|
776
|
+
# shaped the DISPLAY, which diverged hard from what actually got picked. Kept
|
|
777
|
+
# defined (not deleted) so any external import doesn't break.
|
|
778
|
+
WEIGHT_EXPONENT = 2.0 # (legacy, unused)
|
|
779
|
+
STYLE_FLOOR_PCT = 5.0 # (legacy, unused)
|
|
780
|
+
STYLE_CAP_PCT = 50.0 # (legacy, unused)
|
|
781
|
+
|
|
782
|
+
# Picker weight floor: mirrors max(_picker_score(r), 0.01) in
|
|
783
|
+
# pick_style_for_post so a score-0 trusted style still draws a tiny nonzero
|
|
784
|
+
# pick chance instead of being frozen out entirely.
|
|
785
|
+
PICK_FLOOR = 0.01
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# Recency window for the picker target distribution. Lifetime aggregation
|
|
789
|
+
# drifted too far from current performance reality (e.g. 2025 wins from
|
|
790
|
+
# pattern_recognizer kept it in the pool even after 2026's audience shift).
|
|
791
|
+
# 2026-05-29: tightened 30 -> 7 days across all platforms (per user). The 30d
|
|
792
|
+
# window lagged badly: pattern_recognizer showed 963 posts / 24% volume at 30d
|
|
793
|
+
# but only 6 posts at 7d because the picker had already abandoned it; the 30d
|
|
794
|
+
# snapshot kept dragging dead historical volume. 7 days tracks the live picker
|
|
795
|
+
# much more tightly. Tradeoff: tail styles drop below the n>=50 density the 30d
|
|
796
|
+
# window held, so they fall back on the explore floor sooner, and low-volume /
|
|
797
|
+
# scrape-lagged platforms (LinkedIn) may cold-start to an equal split until 7d
|
|
798
|
+
# of data accumulates. Set RECENCY_DAYS=0 to fall back to lifetime.
|
|
799
|
+
RECENCY_DAYS = 7
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _fetch_style_stats(platform, days=None):
|
|
803
|
+
"""Query the autoposter API for per-engagement_style performance.
|
|
804
|
+
|
|
805
|
+
Returns a dict:
|
|
806
|
+
{style_name: {"n": int, "avg_up": float, "avg_cm": float,
|
|
807
|
+
"avg_clicks": float}}
|
|
808
|
+
avg_up is NET (Reddit/Moltbook self-upvote stripped); avg_clicks is the
|
|
809
|
+
bot-filtered click count. The three combine into the same composite the
|
|
810
|
+
top_performers report ranks on. Returns {} on any error (API unreachable,
|
|
811
|
+
missing env, cold start).
|
|
812
|
+
|
|
813
|
+
Recency: `days` overrides the module-level RECENCY_DAYS (default 7).
|
|
814
|
+
Pass days=0 for lifetime aggregation.
|
|
815
|
+
|
|
816
|
+
Routes through the social-autoposter-website API (no direct DB access)
|
|
817
|
+
so VMs / sandboxes without a DATABASE_URL still get live weights.
|
|
818
|
+
"""
|
|
819
|
+
try:
|
|
820
|
+
import os
|
|
821
|
+
import sys as _sys
|
|
822
|
+
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
823
|
+
from http_api import api_get
|
|
824
|
+
eff_days = RECENCY_DAYS if days is None else int(days)
|
|
825
|
+
resp = api_get(
|
|
826
|
+
"/api/v1/engagement-styles/style-stats",
|
|
827
|
+
{"platform": platform, "days": str(eff_days)},
|
|
828
|
+
)
|
|
829
|
+
data = (resp or {}).get("data") or {}
|
|
830
|
+
stats = data.get("stats") or {}
|
|
831
|
+
return {
|
|
832
|
+
name: {
|
|
833
|
+
"n": int(v.get("n", 0)),
|
|
834
|
+
"avg_up": float(v.get("avg_up", 0.0)),
|
|
835
|
+
"avg_cm": float(v.get("avg_cm", 0.0)),
|
|
836
|
+
"avg_clicks": float(v.get("avg_clicks", 0.0)),
|
|
837
|
+
}
|
|
838
|
+
for name, v in stats.items()
|
|
839
|
+
if isinstance(v, dict)
|
|
840
|
+
}
|
|
841
|
+
except Exception:
|
|
842
|
+
return {}
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# Composite style score, mirroring scripts/top_performers.py SCORE_SQL:
|
|
846
|
+
# a real human click outweighs 10 upvotes of vibes, comments sit in the
|
|
847
|
+
# middle. The picker weights styles LINEARLY by this score (no exponent,
|
|
848
|
+
# no shrinkage) so the style that actually drives clicks wins proportionally
|
|
849
|
+
# more picks, not the one that merely accumulates passive likes.
|
|
850
|
+
CLICK_WEIGHT = 10.0
|
|
851
|
+
COMMENT_WEIGHT = 3.0
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def _style_score(stat):
|
|
855
|
+
"""Composite per-style score from a _fetch_style_stats() row.
|
|
856
|
+
|
|
857
|
+
stat is {"avg_up", "avg_cm", "avg_clicks", ...}. avg_up is already net.
|
|
858
|
+
"""
|
|
859
|
+
return (
|
|
860
|
+
float(stat.get("avg_clicks", 0.0)) * CLICK_WEIGHT
|
|
861
|
+
+ float(stat.get("avg_cm", 0.0)) * COMMENT_WEIGHT
|
|
862
|
+
+ float(stat.get("avg_up", 0.0))
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def get_dynamic_tiers(platform, context="posting"):
|
|
867
|
+
"""Rank styles for `platform` by avg_upvotes from the posts table.
|
|
868
|
+
|
|
869
|
+
Returns (dominant, secondary, rare) tuple of style-name lists.
|
|
870
|
+
|
|
871
|
+
Policy:
|
|
872
|
+
- Styles in PLATFORM_POLICY[platform].never are excluded entirely.
|
|
873
|
+
- Styles with N < MIN_SAMPLE_SIZE are placed in `secondary` (explore),
|
|
874
|
+
regardless of their noisy avg_up.
|
|
875
|
+
- Styles with N >= MIN_SAMPLE_SIZE are sorted by avg_up DESC and split:
|
|
876
|
+
top third -> dominant
|
|
877
|
+
middle -> secondary
|
|
878
|
+
bottom third (or single worst) -> rare
|
|
879
|
+
- Any style with zero samples (never logged yet) is added to
|
|
880
|
+
`secondary` so the LLM still explores it.
|
|
881
|
+
- Cold start (no data at all): every non-never style becomes secondary.
|
|
882
|
+
"""
|
|
883
|
+
never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
|
|
884
|
+
universe = get_all_styles()
|
|
885
|
+
candidate_styles = [s for s in universe.keys() if s not in never]
|
|
886
|
+
|
|
887
|
+
stats = _fetch_style_stats(platform)
|
|
888
|
+
|
|
889
|
+
trusted = [] # (style, avg_up) with N >= MIN_SAMPLE_SIZE
|
|
890
|
+
explore = [] # styles with N < MIN_SAMPLE_SIZE (incl. zero samples)
|
|
891
|
+
|
|
892
|
+
for style in candidate_styles:
|
|
893
|
+
s = stats.get(style)
|
|
894
|
+
# Every style with N >= MIN_SAMPLE_SIZE can be trusted. The legacy
|
|
895
|
+
# two-tier candidate→active gate was removed 2026-05-22 and the
|
|
896
|
+
# picker's sample-size shrinkage was removed 2026-05-25: invented
|
|
897
|
+
# styles now compete on raw per-post score from day one, protected
|
|
898
|
+
# only by the MIN_SAMPLE_SIZE=1 existence floor (excludes n=0 ghost
|
|
899
|
+
# styles) and the 30-day recency window.
|
|
900
|
+
if s and s["n"] >= MIN_SAMPLE_SIZE:
|
|
901
|
+
trusted.append((style, s["avg_up"]))
|
|
902
|
+
else:
|
|
903
|
+
explore.append(style)
|
|
904
|
+
|
|
905
|
+
trusted.sort(key=lambda x: x[1], reverse=True)
|
|
906
|
+
|
|
907
|
+
if not trusted:
|
|
908
|
+
# Cold start: no trusted performance data for this platform yet.
|
|
909
|
+
return [], explore, []
|
|
910
|
+
|
|
911
|
+
# Split trusted into thirds. Small lists (1-2 items) go entirely to dominant.
|
|
912
|
+
t = len(trusted)
|
|
913
|
+
if t <= 2:
|
|
914
|
+
dominant = [s for s, _ in trusted]
|
|
915
|
+
rare = []
|
|
916
|
+
else:
|
|
917
|
+
third = max(1, t // 3)
|
|
918
|
+
dominant = [s for s, _ in trusted[:third]]
|
|
919
|
+
rare = [s for s, _ in trusted[-third:]]
|
|
920
|
+
secondary = [s for s, _ in trusted if s not in dominant and s not in rare]
|
|
921
|
+
secondary = secondary + explore # untrusted styles always explore
|
|
922
|
+
return dominant, secondary, rare
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
# ── Target distribution ─────────────────────────────────────────────
|
|
926
|
+
# (_last_picks helper removed 2026-05-19 alongside the legacy
|
|
927
|
+
# "show all 9 styles" prompt block it served. The picker doesn't use
|
|
928
|
+
# recent-pick history; it samples weighted-random from the top-N each
|
|
929
|
+
# turn. The `/api/v1/engagement-styles/last-picks` endpoint is still
|
|
930
|
+
# live for the dashboard's audit surface.)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def compute_target_distribution(platform, context="posting"):
|
|
934
|
+
"""Per-style pick probability, mirroring the live picker exactly.
|
|
935
|
+
|
|
936
|
+
Returns a list of dicts sorted by pct DESC:
|
|
937
|
+
[{"style", "pct", "n", "avg_up", "avg_cm", "avg_clicks", "score",
|
|
938
|
+
"trusted", "is_candidate", "weight"}]
|
|
939
|
+
|
|
940
|
+
`pct` is the probability that pick_style_for_post() assigns this style to a
|
|
941
|
+
given post, so the UI / snapshot now matches what actually happens:
|
|
942
|
+
- Styles in PLATFORM_POLICY[platform].never are excluded.
|
|
943
|
+
- score = clicks*10 + comments*3 + upvotes_net (the top_performers
|
|
944
|
+
composite). A real human click is the ground-truth conversion signal;
|
|
945
|
+
upvotes are passive vibes.
|
|
946
|
+
- The scored-use path (probability = 1 - INVENT_RATE - human_derived_rate)
|
|
947
|
+
samples across TRUSTED styles weighted LINEARLY by max(score,PICK_FLOOR)
|
|
948
|
+
— the exact weights pick_style_for_post() builds. So a trusted style's
|
|
949
|
+
pct = scored_use_fraction * max(score,PICK_FLOOR) / sum_trusted_weights.
|
|
950
|
+
- Non-trusted styles (n=0 ghost registry rows) are never on the use path,
|
|
951
|
+
so pct = 0. They can still surface via the invent-mode reference list.
|
|
952
|
+
- The leftover INVENT_RATE + human_derived_rate (~10%) is NOT attributed
|
|
953
|
+
to any fixed style (invent mints a new one, human_derived picks the
|
|
954
|
+
latest synthesized row), so trusted pcts sum to scored_use_fraction*100.
|
|
955
|
+
- Cold start (no trusted data): the picker always invents, so we show an
|
|
956
|
+
equal share across the non-never explore universe.
|
|
957
|
+
|
|
958
|
+
2026-05-29: replaced the legacy floor/cap/exponent target math
|
|
959
|
+
(WEIGHT_EXPONENT / STYLE_FLOOR_PCT / STYLE_CAP_PCT). That predated the
|
|
960
|
+
2026-05-28 switch to raw linear score sampling in pick_style_for_post and
|
|
961
|
+
diverged hard: with ~56 styles the 5% floor over-subscribed to ~245% and
|
|
962
|
+
zeroed the real winners, so the displayed % bore no relation to picks.
|
|
963
|
+
"""
|
|
964
|
+
never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
|
|
965
|
+
universe = get_all_styles()
|
|
966
|
+
candidates = [s for s in universe.keys() if s not in never]
|
|
967
|
+
stats = _fetch_style_stats(platform)
|
|
968
|
+
|
|
969
|
+
rows = []
|
|
970
|
+
trusted_weight_sum = 0.0
|
|
971
|
+
for style in candidates:
|
|
972
|
+
s = stats.get(style)
|
|
973
|
+
n = int(s["n"]) if s else 0
|
|
974
|
+
avg_up = float(s["avg_up"]) if s else 0.0
|
|
975
|
+
avg_cm = float(s.get("avg_cm", 0.0)) if s else 0.0
|
|
976
|
+
avg_clicks = float(s.get("avg_clicks", 0.0)) if s else 0.0
|
|
977
|
+
score = _style_score(s) if s else 0.0
|
|
978
|
+
# Trusted = at least one real post in the recency window
|
|
979
|
+
# (MIN_SAMPLE_SIZE=1 excludes only n=0 ghost registry rows). The
|
|
980
|
+
# picker's use-path weight is max(_picker_score, PICK_FLOOR) with NO
|
|
981
|
+
# exponent and NO sample-size shrinkage, so we mirror it verbatim.
|
|
982
|
+
trusted = (s is not None and n >= MIN_SAMPLE_SIZE)
|
|
983
|
+
weight = max(score, PICK_FLOOR) if trusted else 0.0
|
|
984
|
+
if trusted:
|
|
985
|
+
trusted_weight_sum += weight
|
|
986
|
+
rows.append({"style": style, "n": n, "avg_up": avg_up,
|
|
987
|
+
"avg_cm": avg_cm, "avg_clicks": avg_clicks,
|
|
988
|
+
"score": score, "trusted": trusted,
|
|
989
|
+
"weight": weight, "pct": 0.0,
|
|
990
|
+
"is_candidate": False})
|
|
991
|
+
|
|
992
|
+
if not rows:
|
|
993
|
+
return []
|
|
994
|
+
|
|
995
|
+
# Cold start: no trusted data -> the picker always invents. Show an equal
|
|
996
|
+
# share across the explore universe (the invent-mode reference pool).
|
|
997
|
+
if trusted_weight_sum <= 0:
|
|
998
|
+
share = 100.0 / len(rows)
|
|
999
|
+
for r in rows:
|
|
1000
|
+
r["pct"] = share
|
|
1001
|
+
rows.sort(key=lambda r: r["style"])
|
|
1002
|
+
return rows
|
|
1003
|
+
|
|
1004
|
+
# Scored-use fraction: the picker spends INVENT_RATE on invention and
|
|
1005
|
+
# _human_derived_rate(platform) on the latest human-derived style BEFORE
|
|
1006
|
+
# the scored sample runs, so trusted styles share only the remainder.
|
|
1007
|
+
scored_use_fraction = max(
|
|
1008
|
+
0.0, 1.0 - INVENT_RATE - _human_derived_rate(platform)
|
|
1009
|
+
)
|
|
1010
|
+
for r in rows:
|
|
1011
|
+
if r["trusted"]:
|
|
1012
|
+
r["pct"] = (
|
|
1013
|
+
(r["weight"] / trusted_weight_sum)
|
|
1014
|
+
* scored_use_fraction * 100.0
|
|
1015
|
+
)
|
|
1016
|
+
else:
|
|
1017
|
+
r["pct"] = 0.0
|
|
1018
|
+
|
|
1019
|
+
rows.sort(key=lambda r: r["pct"], reverse=True)
|
|
1020
|
+
return rows
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
# ── Programmatic picker (2026-05-19) ────────────────────────────────
|
|
1024
|
+
#
|
|
1025
|
+
# The old flow ("show the model 9 styles + target % and let it pick") was
|
|
1026
|
+
# soft: the model anchored on the most-generic-fit style (pattern_recognizer)
|
|
1027
|
+
# and over-picked it ~30% of posts even when its target % was the 5% floor.
|
|
1028
|
+
# This picker flips the contract: code picks ONE style by weighted sample
|
|
1029
|
+
# across all trusted styles (weighted by composite score), the prompt assigns
|
|
1030
|
+
# that style, and the model only authors the comment. Higher-scoring styles
|
|
1031
|
+
# win proportionally more picks while the whole pool stays eligible, so styles
|
|
1032
|
+
# auto-rotate as their click-weighted score shifts. The
|
|
1033
|
+
# model can still invent: with probability INVENT_RATE the picker returns
|
|
1034
|
+
# mode="invent" and the prompt hands the model the top N as reference
|
|
1035
|
+
# material to derive a new style from.
|
|
1036
|
+
|
|
1037
|
+
INVENT_RATE = 0.05 # ~1 in 20 posts forces a new-style invention
|
|
1038
|
+
CURATED_TOP_N = 5 # size of the invent-mode reference list (top 5 by score)
|
|
1039
|
+
|
|
1040
|
+
# Fallback target comment length (chars) for any style that lacks an explicit
|
|
1041
|
+
# target_chars (legacy DB rows, cold-start before the registry is reachable).
|
|
1042
|
+
# Set just above the top-human-reply median (~74) so the long tail of styles
|
|
1043
|
+
# defaults SHORT, not to our historical ~215-char bloat. Mirrors the DB column
|
|
1044
|
+
# default in migrations/2026-05-30-engagement-styles-target-chars.sql.
|
|
1045
|
+
DEFAULT_TARGET_CHARS = 80
|
|
1046
|
+
|
|
1047
|
+
# Additive ~5% branch per platform (2026-05-22, second pass): with this
|
|
1048
|
+
# probability, the picker bypasses score-based selection and assigns the
|
|
1049
|
+
# most recently synthesized "human-derived" style on the calling platform.
|
|
1050
|
+
# Those rows are distilled by scripts/generate_daily_human_style.py from
|
|
1051
|
+
# the previous 24h of top-performing HUMAN replies in thread_top_replies
|
|
1052
|
+
# and live in engagement_styles_registry with kind='human_derived' (one
|
|
1053
|
+
# row per platform per day). Goal: keep our voice continuously calibrated
|
|
1054
|
+
# to whatever rhetorical move is winning on each platform right now,
|
|
1055
|
+
# without waiting for the historical scoring window to accumulate enough
|
|
1056
|
+
# samples to surface it naturally.
|
|
1057
|
+
#
|
|
1058
|
+
# Distribution per platform: HUMAN_DERIVED_RATE_BY_PLATFORM[platform] +
|
|
1059
|
+
# INVENT_RATE + scored-use (defaults: 5% + 5% + 90%).
|
|
1060
|
+
#
|
|
1061
|
+
# Rate is a per-platform dict so we can tune individually. A platform
|
|
1062
|
+
# missing from the dict defaults to HUMAN_DERIVED_RATE_DEFAULT. Set the
|
|
1063
|
+
# entry to 0 to disable the branch for one platform (e.g. while the
|
|
1064
|
+
# synthesizer is bootstrapping data for that platform).
|
|
1065
|
+
HUMAN_DERIVED_RATE_DEFAULT = 0.05
|
|
1066
|
+
HUMAN_DERIVED_RATE_BY_PLATFORM = {
|
|
1067
|
+
"twitter": 0.05,
|
|
1068
|
+
"reddit": 0.05,
|
|
1069
|
+
"github": 0.05,
|
|
1070
|
+
"moltbook": 0.05,
|
|
1071
|
+
"linkedin": 0.05,
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _human_derived_rate(platform):
|
|
1076
|
+
"""Per-platform rate; falls back to HUMAN_DERIVED_RATE_DEFAULT."""
|
|
1077
|
+
return HUMAN_DERIVED_RATE_BY_PLATFORM.get(platform, HUMAN_DERIVED_RATE_DEFAULT)
|
|
1078
|
+
|
|
1079
|
+
# Credibility shrinkage was removed 2026-05-25 (per user instruction): we
|
|
1080
|
+
# don't favor or penalize styles by post-count, only by per-post performance.
|
|
1081
|
+
# Existence floor is MIN_SAMPLE_SIZE=1 (defined above): n=0 ghost registry
|
|
1082
|
+
# rows are excluded, single-post inventions count. RECENCY_DAYS=7 remains
|
|
1083
|
+
# the only freshness gate.
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _picker_score(row):
|
|
1087
|
+
"""Picker score = raw composite score (clicks*10 + comments*3 + upvotes).
|
|
1088
|
+
|
|
1089
|
+
No sample-size shrinkage. A style with n=1 averaging 50 clicks/post
|
|
1090
|
+
competes head-to-head with a style at n=400 averaging 5 clicks/post,
|
|
1091
|
+
and the better per-post style wins the slot. The MIN_SAMPLE_SIZE=1
|
|
1092
|
+
floor inside compute_target_distribution only excludes n=0 ghosts."""
|
|
1093
|
+
return float(row.get("score", 0.0))
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _fetch_latest_human_derived(platform):
|
|
1097
|
+
"""Return the most recently synthesized active human-derived style for
|
|
1098
|
+
`platform`, or None if the registry has none for that platform / the
|
|
1099
|
+
API is unreachable.
|
|
1100
|
+
|
|
1101
|
+
Reads via /api/v1/engagement-styles/registry?kind=human_derived
|
|
1102
|
+
&platform=<platform>&latest=1. The route returns 0 or 1 rows ordered
|
|
1103
|
+
by generated_at DESC.
|
|
1104
|
+
|
|
1105
|
+
Network failures are swallowed silently and return None so the picker
|
|
1106
|
+
falls through to the normal scored path. This branch is best-effort,
|
|
1107
|
+
never load-bearing.
|
|
1108
|
+
"""
|
|
1109
|
+
try:
|
|
1110
|
+
import sys as _sys
|
|
1111
|
+
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
1112
|
+
from http_api import api_get
|
|
1113
|
+
resp = api_get(
|
|
1114
|
+
"/api/v1/engagement-styles/registry",
|
|
1115
|
+
{
|
|
1116
|
+
"status": "active",
|
|
1117
|
+
"kind": "human_derived",
|
|
1118
|
+
"platform": platform,
|
|
1119
|
+
"latest": "1",
|
|
1120
|
+
},
|
|
1121
|
+
)
|
|
1122
|
+
data = (resp or {}).get("data") or {}
|
|
1123
|
+
rows = data.get("styles") or []
|
|
1124
|
+
except Exception:
|
|
1125
|
+
return None
|
|
1126
|
+
if not rows:
|
|
1127
|
+
return None
|
|
1128
|
+
row = rows[0]
|
|
1129
|
+
best_in = row.get("best_in") or {}
|
|
1130
|
+
if isinstance(best_in, str):
|
|
1131
|
+
try:
|
|
1132
|
+
best_in = json.loads(best_in)
|
|
1133
|
+
except Exception:
|
|
1134
|
+
best_in = {}
|
|
1135
|
+
return {
|
|
1136
|
+
# Registry rows use `name` as the primary key, so there's no
|
|
1137
|
+
# standalone numeric id; expose name as the stable identifier.
|
|
1138
|
+
# Callers used to expect `human_derived_id` for log attribution.
|
|
1139
|
+
"id": row.get("name"),
|
|
1140
|
+
"name": row.get("name"),
|
|
1141
|
+
"description": row.get("description") or "",
|
|
1142
|
+
"example": row.get("example") or "",
|
|
1143
|
+
"best_in": best_in,
|
|
1144
|
+
"note": row.get("note") or "",
|
|
1145
|
+
"target_chars": row.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
1146
|
+
"generated_at": row.get("generated_at"),
|
|
1147
|
+
"platform": row.get("platform"),
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def pick_style_for_post(platform, context="posting",
|
|
1152
|
+
top_n=CURATED_TOP_N, invent_rate=INVENT_RATE,
|
|
1153
|
+
rng=None):
|
|
1154
|
+
"""Programmatically pick ONE engagement style for the model to use.
|
|
1155
|
+
|
|
1156
|
+
Replaces the legacy "show all styles, model picks" flow. Returns a
|
|
1157
|
+
dict that get_assigned_style_prompt() turns into a compact prompt
|
|
1158
|
+
block (one style + description + example + note, or invent + top-N
|
|
1159
|
+
reference). The caller also passes the picked style downstream to
|
|
1160
|
+
log_post / validate_or_register so we can detect drift between the
|
|
1161
|
+
assignment and the final logged style.
|
|
1162
|
+
|
|
1163
|
+
The scored-use pick samples across ALL trusted styles, weighted by raw
|
|
1164
|
+
composite score (clicks*10 + comments*3 + upvotes_net), so every style
|
|
1165
|
+
keeps a performance-proportional chance and none is frozen out. top_n
|
|
1166
|
+
bounds only the invent-mode reference list. Styles in
|
|
1167
|
+
PLATFORM_POLICY.never are excluded.
|
|
1168
|
+
Sidecar candidate styles (status="candidate") are excluded from the
|
|
1169
|
+
use path until the promoter graduates them, but stay available as
|
|
1170
|
+
invent-mode references once graduated.
|
|
1171
|
+
|
|
1172
|
+
Args:
|
|
1173
|
+
platform: "reddit" | "twitter" | "linkedin" | "github" | "moltbook"
|
|
1174
|
+
context: "posting" | "replying"
|
|
1175
|
+
top_n: size of the invent-mode reference list (top N by score). The
|
|
1176
|
+
scored-use pick samples across all trusted styles, not just N.
|
|
1177
|
+
invent_rate: probability of returning mode="invent" so the model
|
|
1178
|
+
creates a new style from the top N references.
|
|
1179
|
+
Set to 0 to disable invention entirely.
|
|
1180
|
+
rng: optional random.Random for deterministic tests.
|
|
1181
|
+
|
|
1182
|
+
Returns:
|
|
1183
|
+
{
|
|
1184
|
+
"mode": "use" | "invent",
|
|
1185
|
+
"style": str | None, # the assigned style, None on invent
|
|
1186
|
+
"description": str | None,
|
|
1187
|
+
"example": str | None,
|
|
1188
|
+
"note": str | None,
|
|
1189
|
+
"target_chars": int | None, # authoritative length; None on invent
|
|
1190
|
+
"reference_styles": [ # top-N meta (always populated)
|
|
1191
|
+
{"style", "description", "example", "note", "target_chars",
|
|
1192
|
+
"score", "pct", "n", "avg_clicks", "avg_cm", "avg_up"},
|
|
1193
|
+
...
|
|
1194
|
+
],
|
|
1195
|
+
"distribution_snapshot": list, # full target distribution at pick time
|
|
1196
|
+
"picked_at": ISO-8601 UTC,
|
|
1197
|
+
}
|
|
1198
|
+
"""
|
|
1199
|
+
rnd = rng or random
|
|
1200
|
+
|
|
1201
|
+
# Human-derived branch (2026-05-22, second pass): on every platform,
|
|
1202
|
+
# with HUMAN_DERIVED_RATE_BY_PLATFORM[platform] probability, bypass the
|
|
1203
|
+
# score-based path entirely and assign the most recently synthesized
|
|
1204
|
+
# human_derived style for that platform from engagement_styles_registry
|
|
1205
|
+
# (read via the s4l.ai /api/v1/engagement-styles/registry route).
|
|
1206
|
+
#
|
|
1207
|
+
# ADDITIVE to the existing INVENT branch — both rates coexist on a
|
|
1208
|
+
# platform-by-platform basis, leaving the remainder for normal
|
|
1209
|
+
# scored-use. Fails open: if the route returns no active row for this
|
|
1210
|
+
# platform, we fall through to the normal flow as if this branch
|
|
1211
|
+
# didn't exist.
|
|
1212
|
+
_hd_rate = _human_derived_rate(platform)
|
|
1213
|
+
if _hd_rate > 0 and rnd.random() < _hd_rate:
|
|
1214
|
+
hd = _fetch_latest_human_derived(platform)
|
|
1215
|
+
if hd:
|
|
1216
|
+
return {
|
|
1217
|
+
"mode": "use",
|
|
1218
|
+
"style": hd["name"],
|
|
1219
|
+
"description": hd["description"],
|
|
1220
|
+
"example": hd["example"],
|
|
1221
|
+
"note": hd["note"],
|
|
1222
|
+
"target_chars": hd.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
1223
|
+
"source": "human_derived",
|
|
1224
|
+
"human_derived_id": hd["id"],
|
|
1225
|
+
"reference_styles": [],
|
|
1226
|
+
"distribution_snapshot": [],
|
|
1227
|
+
"picked_at": datetime.now(timezone.utc).isoformat(
|
|
1228
|
+
timespec="seconds"
|
|
1229
|
+
),
|
|
1230
|
+
}
|
|
1231
|
+
# else fall through to normal scored path.
|
|
1232
|
+
|
|
1233
|
+
never = set(PLATFORM_POLICY.get(platform, {}).get("never", []))
|
|
1234
|
+
rows = compute_target_distribution(platform, context=context)
|
|
1235
|
+
rows = [r for r in rows if r["style"] not in never]
|
|
1236
|
+
|
|
1237
|
+
# Trust-filter first so n=0 ghost registry rows can't claim a top
|
|
1238
|
+
# reference slot. They stay in distribution_snapshot for audit but not
|
|
1239
|
+
# in use_pool or reference_pool. (MIN_SAMPLE_SIZE=1 since 2026-05-25;
|
|
1240
|
+
# single-post inventions are trusted from day one.)
|
|
1241
|
+
#
|
|
1242
|
+
# Ranking uses raw `score` (clicks*10 + comments*3 + upvotes) with no
|
|
1243
|
+
# sample-size shrinkage — a genuinely better per-post style outranks
|
|
1244
|
+
# established ones from its first post.
|
|
1245
|
+
trusted_rows = [r for r in rows if r.get("trusted")]
|
|
1246
|
+
trusted_sorted = sorted(trusted_rows, key=_picker_score, reverse=True)
|
|
1247
|
+
# Use path samples across ALL trusted styles, weighted by raw score
|
|
1248
|
+
# (2026-05-28, per user request). The old top_n cutoff froze out every
|
|
1249
|
+
# style ranked beyond top_n: it could never earn a post back, so its
|
|
1250
|
+
# 30-day sample aged out to n=0 and it dropped from the pool for good.
|
|
1251
|
+
# Now every trusted style keeps a performance-proportional chance; the
|
|
1252
|
+
# highest scorer still wins the most picks, while score-0 styles draw
|
|
1253
|
+
# ~0 via the 0.01 floor below. top_n now bounds ONLY the invent-mode
|
|
1254
|
+
# reference list (reference_pool), not the scored-use pool.
|
|
1255
|
+
use_pool = trusted_sorted
|
|
1256
|
+
reference_pool = trusted_sorted[:top_n]
|
|
1257
|
+
|
|
1258
|
+
universe = get_all_styles()
|
|
1259
|
+
|
|
1260
|
+
def _meta_for(row):
|
|
1261
|
+
m = universe.get(row["style"], {})
|
|
1262
|
+
return {
|
|
1263
|
+
"style": row["style"],
|
|
1264
|
+
"description": m.get("description", ""),
|
|
1265
|
+
"example": m.get("example", ""),
|
|
1266
|
+
"note": m.get("note", ""),
|
|
1267
|
+
"target_chars": m.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
1268
|
+
"score": round(row.get("score", 0.0), 3),
|
|
1269
|
+
"pct": round(row.get("pct", 0.0), 1),
|
|
1270
|
+
"n": row.get("n", 0),
|
|
1271
|
+
"avg_clicks": round(row.get("avg_clicks", 0.0), 3),
|
|
1272
|
+
"avg_cm": round(row.get("avg_cm", 0.0), 3),
|
|
1273
|
+
"avg_up": round(row.get("avg_up", 0.0), 3),
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
reference_styles = [_meta_for(r) for r in reference_pool]
|
|
1277
|
+
distribution_snapshot = [
|
|
1278
|
+
{"style": r["style"], "score": round(r.get("score", 0.0), 3),
|
|
1279
|
+
"pct": round(r.get("pct", 0.0), 1), "n": r.get("n", 0),
|
|
1280
|
+
"trusted": bool(r.get("trusted"))}
|
|
1281
|
+
for r in rows
|
|
1282
|
+
]
|
|
1283
|
+
picked_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
1284
|
+
|
|
1285
|
+
# Invent path. Also fires as fallback when no trusted style exists yet
|
|
1286
|
+
# (cold start), because we'd rather the model invent something fresh
|
|
1287
|
+
# than be assigned a noisy n=1 outlier.
|
|
1288
|
+
invent = (not use_pool) or (
|
|
1289
|
+
invent_rate > 0 and rnd.random() < invent_rate
|
|
1290
|
+
)
|
|
1291
|
+
if invent:
|
|
1292
|
+
return {
|
|
1293
|
+
"mode": "invent",
|
|
1294
|
+
"style": None,
|
|
1295
|
+
"description": None,
|
|
1296
|
+
"example": None,
|
|
1297
|
+
"note": None,
|
|
1298
|
+
"target_chars": None,
|
|
1299
|
+
"reference_styles": reference_styles,
|
|
1300
|
+
"distribution_snapshot": distribution_snapshot,
|
|
1301
|
+
"picked_at": picked_at,
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
# Use path: weighted random sample by raw score across ALL trusted styles
|
|
1305
|
+
# (filtered by MIN_SAMPLE_SIZE=1 and the 30-day recency window). Linear
|
|
1306
|
+
# score weighting, so each style competes on per-post performance: the
|
|
1307
|
+
# head wins most picks while the tail keeps a proportional, nonzero chance.
|
|
1308
|
+
weights = [max(_picker_score(r), 0.01) for r in use_pool]
|
|
1309
|
+
total = sum(weights) or 1.0
|
|
1310
|
+
pick = rnd.uniform(0.0, total)
|
|
1311
|
+
cum = 0.0
|
|
1312
|
+
chosen_row = use_pool[0]
|
|
1313
|
+
for r, w in zip(use_pool, weights):
|
|
1314
|
+
cum += w
|
|
1315
|
+
if pick <= cum:
|
|
1316
|
+
chosen_row = r
|
|
1317
|
+
break
|
|
1318
|
+
|
|
1319
|
+
meta = _meta_for(chosen_row)
|
|
1320
|
+
return {
|
|
1321
|
+
"mode": "use",
|
|
1322
|
+
"style": chosen_row["style"],
|
|
1323
|
+
"description": meta["description"],
|
|
1324
|
+
"example": meta["example"],
|
|
1325
|
+
"note": meta["note"],
|
|
1326
|
+
"target_chars": meta.get("target_chars") or DEFAULT_TARGET_CHARS,
|
|
1327
|
+
"reference_styles": reference_styles,
|
|
1328
|
+
"distribution_snapshot": distribution_snapshot,
|
|
1329
|
+
"picked_at": picked_at,
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def get_assigned_style_prompt(platform, assignment, context="posting"):
|
|
1334
|
+
"""Compact prompt block built from a pick_style_for_post() assignment.
|
|
1335
|
+
|
|
1336
|
+
Replaces get_styles_prompt() for callers that have flipped to the
|
|
1337
|
+
programmatic picker. Two shapes:
|
|
1338
|
+
|
|
1339
|
+
USE mode (the common case):
|
|
1340
|
+
One style is assigned. The block shows description / example / note
|
|
1341
|
+
plus the platform tone hint and the grounding rule. No list of other
|
|
1342
|
+
styles, no target %, no over/under-used hints. Decision was already
|
|
1343
|
+
made; the model only needs to know what the assigned style means.
|
|
1344
|
+
|
|
1345
|
+
INVENT mode (~5% of posts):
|
|
1346
|
+
No style assigned. The block shows the top N curated styles as
|
|
1347
|
+
reference (each with description + example + score) and instructs
|
|
1348
|
+
the model to invent a fresh style. Output JSON must set
|
|
1349
|
+
engagement_style=<new_snake_case_name> AND include a new_style block;
|
|
1350
|
+
validate_or_register handles the rest.
|
|
1351
|
+
"""
|
|
1352
|
+
policy = PLATFORM_POLICY.get(platform, PLATFORM_POLICY["reddit"])
|
|
1353
|
+
lines = []
|
|
1354
|
+
|
|
1355
|
+
if assignment["mode"] == "use":
|
|
1356
|
+
lines.append(f"## Your assigned engagement style: **{assignment['style']}**")
|
|
1357
|
+
lines.append("")
|
|
1358
|
+
lines.append(
|
|
1359
|
+
f"This style was selected by the picker (weighted by live "
|
|
1360
|
+
f"click-driven performance across {platform}). Use it. Do not "
|
|
1361
|
+
f"swap it for a different listed style."
|
|
1362
|
+
)
|
|
1363
|
+
lines.append("")
|
|
1364
|
+
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1365
|
+
lines.append("")
|
|
1366
|
+
lines.append(f"**{assignment['style']}**: {assignment.get('description', '')}")
|
|
1367
|
+
if assignment.get("example"):
|
|
1368
|
+
lines.append(f' Example: "{assignment["example"]}"')
|
|
1369
|
+
if assignment.get("note"):
|
|
1370
|
+
lines.append(f" Note: {assignment['note']}")
|
|
1371
|
+
# LENGTH A/B CONCLUDED 2026-06-04: control won, so the prompt always
|
|
1372
|
+
# uses the legacy generic length guidance. The treatment's per-style
|
|
1373
|
+
# target prompt remains preserved only in the shipped experiment card.
|
|
1374
|
+
lines.append("")
|
|
1375
|
+
lines.append(
|
|
1376
|
+
"**LENGTH: keep it tight.** One or two sentences, well under the "
|
|
1377
|
+
"250-character Twitter limit. A short, sharp reply almost always "
|
|
1378
|
+
"beats a paragraph. This applies to the comment text only; any "
|
|
1379
|
+
"link/CTA the system appends afterward is separate."
|
|
1380
|
+
)
|
|
1381
|
+
lines.append("")
|
|
1382
|
+
lines.append(
|
|
1383
|
+
'In your output JSON, set "engagement_style" to exactly '
|
|
1384
|
+
f'"{assignment["style"]}" and leave "new_style" as null. '
|
|
1385
|
+
'Do not substitute a different style. The picker has already '
|
|
1386
|
+
'made the choice based on live performance data; your job is '
|
|
1387
|
+
'to author a great comment in that style, not to second-guess '
|
|
1388
|
+
'the assignment. If you return any other style name, the '
|
|
1389
|
+
'orchestrator silently coerces it back to '
|
|
1390
|
+
f'"{assignment["style"]}" before logging.'
|
|
1391
|
+
)
|
|
1392
|
+
else:
|
|
1393
|
+
lines.append("## Invent a new engagement style for this post")
|
|
1394
|
+
lines.append("")
|
|
1395
|
+
lines.append(
|
|
1396
|
+
"The picker is asking you to derive a fresh style for this "
|
|
1397
|
+
f"thread (~{int(INVENT_RATE * 100)}% of posts get the invent path). "
|
|
1398
|
+
"Look at our top performers below as reference for what already "
|
|
1399
|
+
"works on this platform, then pick a fresh angle that none of "
|
|
1400
|
+
"them captures. Set `engagement_style` to your snake_case name "
|
|
1401
|
+
"AND include a full `new_style` block in the same JSON."
|
|
1402
|
+
)
|
|
1403
|
+
lines.append("")
|
|
1404
|
+
lines.append(f"Platform tone: {policy.get('note', '')}")
|
|
1405
|
+
lines.append("")
|
|
1406
|
+
lines.append(f"### Top {len(assignment.get('reference_styles', []))} reference styles on {platform}")
|
|
1407
|
+
lines.append("")
|
|
1408
|
+
for ref in assignment.get("reference_styles", []):
|
|
1409
|
+
lines.append(
|
|
1410
|
+
f"- **{ref['style']}** "
|
|
1411
|
+
f"(score {ref['score']:.2f}, clicks {ref['avg_clicks']:.2f}, "
|
|
1412
|
+
f"cm {ref['avg_cm']:.2f}, up {ref['avg_up']:.2f}, n={ref['n']}, "
|
|
1413
|
+
f"target ~{ref.get('target_chars') or DEFAULT_TARGET_CHARS} chars)"
|
|
1414
|
+
)
|
|
1415
|
+
lines.append(f" {ref['description']}")
|
|
1416
|
+
if ref.get("example"):
|
|
1417
|
+
lines.append(f' Example: "{ref["example"]}"')
|
|
1418
|
+
lines.append("")
|
|
1419
|
+
lines.append(
|
|
1420
|
+
"Your new style should be a real third option — not a rename "
|
|
1421
|
+
"of one above. Set the new_style block fields:"
|
|
1422
|
+
)
|
|
1423
|
+
lines.append(" - description: one sentence")
|
|
1424
|
+
lines.append(" - example: short utterance demonstrating the style")
|
|
1425
|
+
lines.append(" - note: when to use / when not to")
|
|
1426
|
+
lines.append(" - why_existing_didnt_fit: why none of the above worked here")
|
|
1427
|
+
lines.append(
|
|
1428
|
+
f" - target_chars: integer, the comment length this style wins "
|
|
1429
|
+
f"at. Bias SHORT; the top human replies cluster near {DEFAULT_TARGET_CHARS} "
|
|
1430
|
+
f"chars and below. Only go high (150+) if the style is genuinely "
|
|
1431
|
+
f"narrative; never propose a target just to fill space."
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
lines.append("")
|
|
1435
|
+
lines.append(
|
|
1436
|
+
'AVOID the "pleaser/validator" style ("this is great", "had similar '
|
|
1437
|
+
'results", "100% agree"). Consistently the lowest engagement on every '
|
|
1438
|
+
'platform.'
|
|
1439
|
+
)
|
|
1440
|
+
lines.append("")
|
|
1441
|
+
lines.append(get_grounding_rule())
|
|
1442
|
+
return "\n".join(lines)
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
# ── Prompt generators ───────────────────────────────────────────────
|
|
1446
|
+
|
|
1447
|
+
def get_styles_prompt(platform, context="posting", assignment=None):
|
|
1448
|
+
"""Generate the engagement-styles prompt block for a platform.
|
|
1449
|
+
|
|
1450
|
+
Always routes through the picker: one style is assigned (weighted by
|
|
1451
|
+
live click-driven performance over the recent window) and the prompt
|
|
1452
|
+
embeds that single style's description + example + note + grounding
|
|
1453
|
+
rule. The model is told to use it, not to choose from a menu.
|
|
1454
|
+
|
|
1455
|
+
Args:
|
|
1456
|
+
platform: "reddit", "twitter", "linkedin", "github", "moltbook"
|
|
1457
|
+
context: "posting" (new posts) or "replying" (engagement replies)
|
|
1458
|
+
assignment: an optional pre-computed pick_style_for_post() result.
|
|
1459
|
+
Orchestrators that need to know the picked style (to
|
|
1460
|
+
filter top_performers, pin the literal style name into
|
|
1461
|
+
their JSON output example, etc.) pick once, then pass
|
|
1462
|
+
the assignment in here to avoid double-picking. Callers
|
|
1463
|
+
that don't care can omit it and get a fresh pick.
|
|
1464
|
+
"""
|
|
1465
|
+
if assignment is None:
|
|
1466
|
+
assignment = pick_style_for_post(platform, context=context)
|
|
1467
|
+
return get_assigned_style_prompt(platform, assignment, context=context)
|
|
1468
|
+
|
|
1469
|
+
|
|
1470
|
+
def get_grounding_rule():
|
|
1471
|
+
"""The two-lane grounding rule, shared across every pipeline.
|
|
1472
|
+
|
|
1473
|
+
Purely about FABRICATION of specifics, not about whose voice is
|
|
1474
|
+
speaking. Voice positioning (whether the narrator is the maker of
|
|
1475
|
+
the matched project or an outside observer) is governed separately
|
|
1476
|
+
by get_voice_relationship_rule(), which reads the matched project's
|
|
1477
|
+
voice_relationship field in config.json.
|
|
1478
|
+
|
|
1479
|
+
Every comment picks ONE of two mutually exclusive lanes. They cannot
|
|
1480
|
+
be combined: a comment can disclose a worked example OR keep its
|
|
1481
|
+
specifics grounded, never present a fabricated specific as a lived
|
|
1482
|
+
fact.
|
|
1483
|
+
|
|
1484
|
+
LANE 1 - DISCLOSED STORY: invent freely, but flag the frame.
|
|
1485
|
+
Open with a hedge that signals the story is illustration, not
|
|
1486
|
+
testimony ('hypothetically', 'imagine someone running this',
|
|
1487
|
+
'say a friend tried', 'as a thought experiment', 'scenario:').
|
|
1488
|
+
Once the frame is set, free to use any names, durations, counts,
|
|
1489
|
+
places. The reader can tell from the first phrase that this is
|
|
1490
|
+
a worked example, not a lived claim.
|
|
1491
|
+
|
|
1492
|
+
LANE 2 - NO FABRICATION: stay plain-voiced, but every specific
|
|
1493
|
+
(number, duration, date, place, course/program, headcount,
|
|
1494
|
+
named tool, named person) must appear verbatim in the matched
|
|
1495
|
+
project's content_angle / voice / messaging in config.json. If a
|
|
1496
|
+
specific is not in config: drop it, generalize ('a few months',
|
|
1497
|
+
'a handful of cameras'), or pattern-frame ('the part that breaks
|
|
1498
|
+
down is...'). Pattern-frame counts as observation, not lived
|
|
1499
|
+
testimony, so no disclosure is needed.
|
|
1500
|
+
|
|
1501
|
+
Pick the lane that fits the thread. Niche / expert subs reward Lane 2;
|
|
1502
|
+
casual / advice subs tolerate Lane 1 when the disclosure feels natural.
|
|
1503
|
+
"""
|
|
1504
|
+
return """## GROUNDING RULE (highest priority; overrides any other style guidance)
|
|
1505
|
+
|
|
1506
|
+
This rule is about FABRICATION of specifics. Whose voice is speaking
|
|
1507
|
+
(maker vs outside observer) is governed by the VOICE RELATIONSHIP rule,
|
|
1508
|
+
not here.
|
|
1509
|
+
|
|
1510
|
+
Every comment picks ONE lane. The two lanes are mutually exclusive: you
|
|
1511
|
+
cannot present an invented detail as a lived fact. Choose:
|
|
1512
|
+
|
|
1513
|
+
LANE 1 - DISCLOSED STORY (invent freely, but flag it)
|
|
1514
|
+
Open with a phrase that signals the story is illustration, not lived
|
|
1515
|
+
testimony. Once the frame is set, use whatever names / durations /
|
|
1516
|
+
counts / places fit the point.
|
|
1517
|
+
Acceptable openers: "hypothetically", "imagine someone running this",
|
|
1518
|
+
"say a friend tried", "as a thought experiment", "scenario:",
|
|
1519
|
+
"to make this concrete, picture", "made-up example but".
|
|
1520
|
+
After the opener, full creative license on the details.
|
|
1521
|
+
|
|
1522
|
+
LANE 2 - NO FABRICATION (specifics must be real)
|
|
1523
|
+
Stay plain-voiced. Any specific (number, duration, date, place name,
|
|
1524
|
+
course/program, headcount, named tool, named person) is allowed ONLY
|
|
1525
|
+
if it appears verbatim in the matched project's content_angle, voice
|
|
1526
|
+
(tone/examples/examples_good), or messaging (lead_with_pain / solution
|
|
1527
|
+
/ proof) in config.json. If a specific is not in config: drop it,
|
|
1528
|
+
generalize ("a few months", "a handful of cameras"), or pattern-frame
|
|
1529
|
+
("the part that breaks down is...", "the typical failure mode is...").
|
|
1530
|
+
Pattern-frame counts as observation, not lived testimony, so no
|
|
1531
|
+
disclosure is needed.
|
|
1532
|
+
|
|
1533
|
+
NEVER MIX: do not write "ran 22 cameras for 8 months" without either
|
|
1534
|
+
(a) a Lane 1 opener in front of it, or (b) those numbers being in
|
|
1535
|
+
config.json. That is the failure mode this rule exists to kill.
|
|
1536
|
+
|
|
1537
|
+
Worked examples (drawn from real posts in our DB):
|
|
1538
|
+
|
|
1539
|
+
BAD (fabricated anecdote, no disclosure, no config anchor):
|
|
1540
|
+
"ran this exact pipeline last semester for two anatomy blocks,
|
|
1541
|
+
cheap recorder into whisper into gpt into anki, raw gpt got
|
|
1542
|
+
about 35% usable cards..."
|
|
1543
|
+
LANE 1 REWRITE (same details, but disclosed):
|
|
1544
|
+
"hypothetically, imagine running this for a couple of lecture
|
|
1545
|
+
blocks: cheap recorder into whisper into gpt into anki. raw
|
|
1546
|
+
prompts get you somewhere around a third usable cards before
|
|
1547
|
+
duplicate distractors and trivial restatements take over."
|
|
1548
|
+
LANE 2 REWRITE (pattern-frame, no invented specifics):
|
|
1549
|
+
"the whisper-to-gpt-to-anki setup isn't where this breaks. card
|
|
1550
|
+
generation is. raw prompts produce roughly a third usable before
|
|
1551
|
+
duplicate distractors and trivial restatements take over."
|
|
1552
|
+
|
|
1553
|
+
BAD (fabricated rig, no disclosure):
|
|
1554
|
+
"ran 22 cameras across three properties for about 8 months and
|
|
1555
|
+
we were getting 400+ 'person detected' pings a night..."
|
|
1556
|
+
LANE 1 REWRITE (disclosed scenario):
|
|
1557
|
+
"scenario: 20-something cameras across a few properties, motion
|
|
1558
|
+
alerts firing 400+ times a night. by week two everyone has
|
|
1559
|
+
notifications muted and the system is dead..."
|
|
1560
|
+
LANE 2 REWRITE (no invented numbers):
|
|
1561
|
+
"the issue with motion alerts isn't reliability, it's signal to
|
|
1562
|
+
noise. once cameras start firing on every shadow and leaf blower,
|
|
1563
|
+
everyone mutes notifications inside a week. the fix is filtering
|
|
1564
|
+
on intent (vehicle at gate after midnight, person near door,
|
|
1565
|
+
loiter over a minute), not more cameras."
|
|
1566
|
+
|
|
1567
|
+
GOOD LANE 2 (config-grounded, specifics are real):
|
|
1568
|
+
"on a 90-slide deck the eval scored 81.3 vs the field average
|
|
1569
|
+
around 68; the cards weren't the bottleneck, the rubric was."
|
|
1570
|
+
(The 81.3 / 68 / 90-slide numbers come from the matched project's
|
|
1571
|
+
messaging.proof and voice.examples.)
|
|
1572
|
+
|
|
1573
|
+
This rule outranks "specificity is the #1 authenticity signal" wherever
|
|
1574
|
+
they conflict. Specificity still wins, but only via Lane 1 disclosure
|
|
1575
|
+
or Lane 2 config grounding."""
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def get_voice_relationship_rule():
|
|
1579
|
+
"""Voice positioning rule, driven by each project's voice_relationship.
|
|
1580
|
+
|
|
1581
|
+
Communicates how the narrator should relate to the matched project's
|
|
1582
|
+
product. The relationship is a structural property of the project
|
|
1583
|
+
(whether @m13v_ / @matt_diak speaks AS the maker or as an outside
|
|
1584
|
+
observer), declared in config.json per project; the prompt does not
|
|
1585
|
+
hardcode it.
|
|
1586
|
+
|
|
1587
|
+
This rule is independent of the GROUNDING RULE: grounding governs
|
|
1588
|
+
whether SPECIFICS are real, this rule governs whose VOICE is speaking
|
|
1589
|
+
when those specifics are stated.
|
|
1590
|
+
"""
|
|
1591
|
+
return """## VOICE RELATIONSHIP (highest priority; overrides any default voice in the styles block)
|
|
1592
|
+
|
|
1593
|
+
For EACH candidate, look up the matched project's `voice_relationship`
|
|
1594
|
+
field in its config.json entry. The field is always one of two values
|
|
1595
|
+
and dictates whose voice you write in. There is no third option and no
|
|
1596
|
+
default; every project declares the field explicitly.
|
|
1597
|
+
|
|
1598
|
+
- `voice_relationship: "first_party"`
|
|
1599
|
+
You speak AS the builder / maker of this product. First-person plural
|
|
1600
|
+
about the product ("we ship X", "our Y", "we built Z") is allowed
|
|
1601
|
+
when grounded in config.json. The audience expects to hear from the
|
|
1602
|
+
maker. Recommendation-style framing is also fine ("a thing we ship
|
|
1603
|
+
is...", "this is why we built...").
|
|
1604
|
+
|
|
1605
|
+
- `voice_relationship: "third_party"`
|
|
1606
|
+
You are an OUTSIDE OBSERVER of this product, NOT its team. Never use
|
|
1607
|
+
first-person plural about the product. Always refer to it in
|
|
1608
|
+
third-person, by name or by 'their': "Agora ships X", "their EAS
|
|
1609
|
+
variant", "the OODAO product", "the Studyly team rolled out Y". The
|
|
1610
|
+
narrator is a tech-curious user / builder who has noticed and
|
|
1611
|
+
recommends the project, NOT its marketer or staff. Acceptable narrator
|
|
1612
|
+
framings: "I tried [project]", "I noticed [project] does X", "[project]
|
|
1613
|
+
ships Y". Banned framings: "we ship", "our product", "we built", "I
|
|
1614
|
+
built this", "what we offer".
|
|
1615
|
+
|
|
1616
|
+
The narrator's own life (the @m13v_ / @matt_diak persona's own voice,
|
|
1617
|
+
"I", "my") is still available in BOTH modes. The rule only restricts
|
|
1618
|
+
"we" / "our" / "we built" CLAIMS ABOUT THE PROJECT'S PRODUCT. For
|
|
1619
|
+
first_party projects those claims are accurate; for third_party
|
|
1620
|
+
projects they impersonate the client and are banned.
|
|
1621
|
+
|
|
1622
|
+
Fabrication of specifics (numbers, dates, places, named tools) is
|
|
1623
|
+
governed by the GROUNDING RULE above; that rule is orthogonal to this
|
|
1624
|
+
one. A grounded specific stated in the wrong voice still violates this
|
|
1625
|
+
rule. An invented specific stated in the right voice still violates the
|
|
1626
|
+
GROUNDING RULE."""
|
|
1627
|
+
|
|
1628
|
+
|
|
1629
|
+
def get_content_rules(platform):
|
|
1630
|
+
"""Generate platform-specific content rules.
|
|
1631
|
+
|
|
1632
|
+
Args:
|
|
1633
|
+
platform: "reddit", "twitter", or "linkedin"
|
|
1634
|
+
|
|
1635
|
+
Returns:
|
|
1636
|
+
Multi-line string of content rules.
|
|
1637
|
+
"""
|
|
1638
|
+
common = [
|
|
1639
|
+
"NO em dashes. Use commas, periods, or regular dashes (-).",
|
|
1640
|
+
"Never mention any project by name unless recommending. Product voice (claims about the matched project) is governed by the VOICE RELATIONSHIP rule, not here.",
|
|
1641
|
+
'Never start with "exactly", "yeah totally", "100%", "that\'s smart".',
|
|
1642
|
+
"Specificity is the #1 authenticity signal, but only for specifics you have license to use. See GROUNDING RULE: pick LANE 1 (open with a disclosure phrase like 'hypothetically' or 'scenario:' and then invent freely) OR LANE 2 (no fabricated specifics; every specific must come verbatim from the matched project's config.json). Specifics that pass the GROUNDING RULE still have to be stated in the voice required by the VOICE RELATIONSHIP rule.",
|
|
1643
|
+
"Include imperfections: contractions, casual asides, occasional lowercase.",
|
|
1644
|
+
]
|
|
1645
|
+
|
|
1646
|
+
platform_rules = {
|
|
1647
|
+
"reddit": [
|
|
1648
|
+
"Go BIMODAL: either 1 punchy sentence (<100 chars, highest avg upvotes) or 4-5 sentences of real substance. AVOID the 2-3 sentence dead zone.",
|
|
1649
|
+
"Lead with personal experience or observation ('I tried X' / 'I noticed X' / 'the part that broke for me was X') rather than instructional advice ('you should X'). Whether you can speak AS the project's maker is governed by the VOICE RELATIONSHIP rule.",
|
|
1650
|
+
"No markdown in Reddit (no ##, **, numbered lists). Casual tone, lowercase OK, fragments OK.",
|
|
1651
|
+
"NEVER mention product names (fazm, assrt, pieline, cyrano, terminator, mk0r, s4l). NEVER include URLs or links.",
|
|
1652
|
+
"Statements beat questions. Be authoritative, not inquisitive. No 'anyone else experience this?'",
|
|
1653
|
+
],
|
|
1654
|
+
"twitter": [
|
|
1655
|
+
"Keep it short: 1-2 sentences max. Fragments and lowercase OK.",
|
|
1656
|
+
"Direct product mentions OK when relevant (unlike Reddit).",
|
|
1657
|
+
"No hashtags. No threads. No 'RT if you agree' bait.",
|
|
1658
|
+
"Punch line first, context second.",
|
|
1659
|
+
],
|
|
1660
|
+
"linkedin": [
|
|
1661
|
+
"Professional but casual tone. 2-4 sentences.",
|
|
1662
|
+
"Softer framing for critic style (constructive, not combative).",
|
|
1663
|
+
"No snark. No sarcasm. Earnest insights land better here.",
|
|
1664
|
+
"Line breaks between thoughts for readability.",
|
|
1665
|
+
],
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
rules = platform_rules.get(platform, platform_rules["reddit"]) + common
|
|
1669
|
+
return "\n".join(f"- {r}" for r in rules)
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def get_anti_patterns():
|
|
1673
|
+
"""Content anti-patterns shared across all platforms."""
|
|
1674
|
+
return """## Anti-patterns
|
|
1675
|
+
- NEVER start with "exactly", "yeah totally", "100%", "that's smart". Vary first words.
|
|
1676
|
+
- NEVER claim authorship or operational control of a product whose voice_relationship is "third_party" (see VOICE RELATIONSHIP rule). For first_party projects, prefer recommendation framing over bare "I built it" self-promotion even though the voice is yours to use.
|
|
1677
|
+
- NEVER suggest calls, meetings, demos.
|
|
1678
|
+
- NEVER promise to share links/files not in config.json.
|
|
1679
|
+
- NEVER offer to DM. NEVER make time-bound promises.
|
|
1680
|
+
- Some replies should be 1 sentence. Not everything needs 3-4 sentences."""
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
def get_valid_styles(context="posting"):
|
|
1684
|
+
"""Return the set of valid style names.
|
|
1685
|
+
|
|
1686
|
+
Args:
|
|
1687
|
+
context: "posting" for new posts, "replying" for engagement replies.
|
|
1688
|
+
"""
|
|
1689
|
+
if context == "replying":
|
|
1690
|
+
return REPLY_STYLES
|
|
1691
|
+
return VALID_STYLES
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def validate_style(style, context="posting"):
|
|
1695
|
+
"""Check if a style name is valid. Returns the style or None.
|
|
1696
|
+
|
|
1697
|
+
Consults the live universe (hardcoded STYLES + sidecar candidates) so
|
|
1698
|
+
a candidate registered in this process or by another agent passes.
|
|
1699
|
+
"""
|
|
1700
|
+
if not style:
|
|
1701
|
+
return None
|
|
1702
|
+
if style in get_all_styles():
|
|
1703
|
+
return style
|
|
1704
|
+
# Backwards path: a few callers (like locked octolens scripts) only
|
|
1705
|
+
# know the hardcoded set. Keep that path working for them.
|
|
1706
|
+
valid = get_valid_styles(context)
|
|
1707
|
+
if style in valid:
|
|
1708
|
+
return style
|
|
1709
|
+
return None
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def target_distribution_snapshot(platform, context="posting"):
|
|
1713
|
+
"""Compact, JSON-serializable snapshot of the current target distribution.
|
|
1714
|
+
|
|
1715
|
+
This is what the picker would tell the model to aim for RIGHT NOW.
|
|
1716
|
+
Persisted into generation_trace.extras / the daily snapshot log so the
|
|
1717
|
+
"did the clicks-weighted reweight actually shift picks" audit can replay
|
|
1718
|
+
point-in-time targets — clicks accrue retroactively, so the live numbers
|
|
1719
|
+
cannot be reconstructed cleanly from posts after the fact.
|
|
1720
|
+
"""
|
|
1721
|
+
rows = compute_target_distribution(platform, context=context)
|
|
1722
|
+
return [
|
|
1723
|
+
{
|
|
1724
|
+
"style": r["style"],
|
|
1725
|
+
"pct": round(r["pct"], 1),
|
|
1726
|
+
"score": round(r.get("score", 0.0), 3),
|
|
1727
|
+
"avg_clicks": round(r.get("avg_clicks", 0.0), 3),
|
|
1728
|
+
"avg_cm": round(r.get("avg_cm", 0.0), 3),
|
|
1729
|
+
"avg_up": round(r.get("avg_up", 0.0), 3),
|
|
1730
|
+
"n": r["n"],
|
|
1731
|
+
"trusted": bool(r["trusted"]),
|
|
1732
|
+
}
|
|
1733
|
+
for r in rows
|
|
1734
|
+
]
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
if __name__ == "__main__":
|
|
1738
|
+
import argparse
|
|
1739
|
+
import json as _json
|
|
1740
|
+
|
|
1741
|
+
_parser = argparse.ArgumentParser(
|
|
1742
|
+
description="Engagement styles CLI (target distribution inspection)"
|
|
1743
|
+
)
|
|
1744
|
+
_sub = _parser.add_subparsers(dest="cmd")
|
|
1745
|
+
_td = _sub.add_parser(
|
|
1746
|
+
"target-distribution",
|
|
1747
|
+
help="Print the current per-style target pick distribution as JSON",
|
|
1748
|
+
)
|
|
1749
|
+
_td.add_argument("--platform", required=True)
|
|
1750
|
+
_td.add_argument("--context", default="posting", choices=["posting", "replying"])
|
|
1751
|
+
|
|
1752
|
+
_pk = _sub.add_parser(
|
|
1753
|
+
"pick",
|
|
1754
|
+
help="Run pick_style_for_post() and print the assignment + prompt block",
|
|
1755
|
+
)
|
|
1756
|
+
_pk.add_argument("--platform", required=True)
|
|
1757
|
+
_pk.add_argument("--context", default="posting", choices=["posting", "replying"])
|
|
1758
|
+
_pk.add_argument("--top-n", type=int, default=CURATED_TOP_N)
|
|
1759
|
+
_pk.add_argument("--invent-rate", type=float, default=INVENT_RATE)
|
|
1760
|
+
_pk.add_argument("--seed", type=int, default=None,
|
|
1761
|
+
help="Deterministic seed for the picker RNG")
|
|
1762
|
+
_pk.add_argument("--show-prompt", action="store_true",
|
|
1763
|
+
help="Also print the compact prompt block the model would see")
|
|
1764
|
+
|
|
1765
|
+
_args = _parser.parse_args()
|
|
1766
|
+
|
|
1767
|
+
if _args.cmd == "target-distribution":
|
|
1768
|
+
print(_json.dumps(
|
|
1769
|
+
target_distribution_snapshot(_args.platform, context=_args.context),
|
|
1770
|
+
ensure_ascii=False,
|
|
1771
|
+
))
|
|
1772
|
+
elif _args.cmd == "pick":
|
|
1773
|
+
_rng = random.Random(_args.seed) if _args.seed is not None else random
|
|
1774
|
+
_assignment = pick_style_for_post(
|
|
1775
|
+
_args.platform, context=_args.context,
|
|
1776
|
+
top_n=_args.top_n, invent_rate=_args.invent_rate, rng=_rng,
|
|
1777
|
+
)
|
|
1778
|
+
print(_json.dumps(_assignment, ensure_ascii=False, indent=2))
|
|
1779
|
+
if _args.show_prompt:
|
|
1780
|
+
print()
|
|
1781
|
+
print("=" * 60)
|
|
1782
|
+
print("PROMPT BLOCK")
|
|
1783
|
+
print("=" * 60)
|
|
1784
|
+
print(get_assigned_style_prompt(
|
|
1785
|
+
_args.platform, _assignment, context=_args.context))
|
|
1786
|
+
else:
|
|
1787
|
+
_parser.print_help()
|