@m13v/s4l 1.6.197-rc.10
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 +1336 -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 +513 -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,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
enrich_twitter_candidates.py
|
|
4
|
+
|
|
5
|
+
Reads raw tweet JSON from stdin (output of browser scrape),
|
|
6
|
+
enriches each tweet with follower count and view count via fxtwitter API,
|
|
7
|
+
then outputs enriched JSON to stdout for piping to score_twitter_candidates.py.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
cat /tmp/raw_tweets.json | python3 scripts/enrich_twitter_candidates.py | python3 scripts/score_twitter_candidates.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
import urllib.request
|
|
17
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fetch_fxtwitter(handle, tweet_id):
|
|
21
|
+
"""Fetch tweet data from fxtwitter API."""
|
|
22
|
+
url = f"https://api.fxtwitter.com/{handle}/status/{tweet_id}"
|
|
23
|
+
req = urllib.request.Request(url, headers={"User-Agent": "social-autoposter/1.0"})
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
26
|
+
return json.loads(resp.read())
|
|
27
|
+
except Exception as e:
|
|
28
|
+
print(f" fxtwitter error for {handle}/{tweet_id}: {e}", file=sys.stderr)
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def enrich_one(tweet):
|
|
33
|
+
url = tweet.get("tweetUrl", tweet.get("tweet_url", ""))
|
|
34
|
+
if not url:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
m = re.search(r"x\.com/([^/]+)/status/(\d+)", url)
|
|
38
|
+
if not m:
|
|
39
|
+
m = re.search(r"twitter\.com/([^/]+)/status/(\d+)", url)
|
|
40
|
+
if not m:
|
|
41
|
+
return tweet
|
|
42
|
+
|
|
43
|
+
handle = m.group(1)
|
|
44
|
+
tweet_id = m.group(2)
|
|
45
|
+
|
|
46
|
+
data = fetch_fxtwitter(handle, tweet_id)
|
|
47
|
+
if data and data.get("tweet"):
|
|
48
|
+
t = data["tweet"]
|
|
49
|
+
author = t.get("author", {})
|
|
50
|
+
tweet["author_followers"] = author.get("followers", 0)
|
|
51
|
+
tweet["views"] = t.get("views", 0)
|
|
52
|
+
tweet["likes"] = t.get("likes", tweet.get("likes", 0))
|
|
53
|
+
tweet["retweets"] = t.get("retweets", tweet.get("retweets", 0))
|
|
54
|
+
tweet["replies"] = t.get("replies", tweet.get("replies", 0))
|
|
55
|
+
tweet["bookmarks"] = t.get("bookmarks", tweet.get("bookmarks", 0))
|
|
56
|
+
tweet["handle"] = author.get("screen_name", handle)
|
|
57
|
+
|
|
58
|
+
tweet["tweet_url"] = url
|
|
59
|
+
tweet.setdefault("text", tweet.get("tweetText", tweet.get("tweet_text", "")))
|
|
60
|
+
tweet.setdefault("datetime", tweet.get("tweetPostedAt", ""))
|
|
61
|
+
tweet.setdefault("handle", handle)
|
|
62
|
+
return tweet
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def enrich(tweets):
|
|
66
|
+
with ThreadPoolExecutor(max_workers=8) as ex:
|
|
67
|
+
results = list(ex.map(enrich_one, tweets))
|
|
68
|
+
return [t for t in results if t is not None]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main():
|
|
72
|
+
raw = json.load(sys.stdin)
|
|
73
|
+
if not isinstance(raw, list):
|
|
74
|
+
raw = [raw]
|
|
75
|
+
|
|
76
|
+
result = enrich(raw)
|
|
77
|
+
json.dump(result, sys.stdout)
|
|
78
|
+
print(f"\nEnriched {len(result)} tweets", file=sys.stderr)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
main()
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Feedback digest: distill human card decisions into learned_preferences.
|
|
3
|
+
|
|
4
|
+
The scheduled half of the review-events feedback loop (see
|
|
5
|
+
scripts/learned_preferences.py for the full loop). Per run:
|
|
6
|
+
|
|
7
|
+
1. GET /api/v1/review-events?counts=true — which (project, platform) pairs
|
|
8
|
+
have unprocessed events. The API scopes to this installation, so a
|
|
9
|
+
customer box only ever digests its own user's decisions.
|
|
10
|
+
2. For each project that exists in the local config.json: fetch the
|
|
11
|
+
unprocessed events, build a conservative digest prompt (current block +
|
|
12
|
+
events + approval counter-evidence), run Claude headless via
|
|
13
|
+
run_claude.sh (script_tag feedback-digest, cost-tracked like every other
|
|
14
|
+
pipeline Claude call).
|
|
15
|
+
3. Apply the returned mutation plan through
|
|
16
|
+
learned_preferences.apply_mutations() (whitelist, flock, backup, atomic).
|
|
17
|
+
4. PATCH the events processed (processed_batch=digest-<ts>) so they are
|
|
18
|
+
never digested twice. Events are marked processed even when the plan is
|
|
19
|
+
"no changes" — a considered no-op is a completed digestion, not a retry.
|
|
20
|
+
|
|
21
|
+
Overall feedback (decision='feedback', project IS NULL; typed into the card's
|
|
22
|
+
💬 composer or the menu bar's "Send feedback…" item) is fetched once per run,
|
|
23
|
+
folded into EVERY configured project's prompt as explicit standing guidance,
|
|
24
|
+
and marked processed only after all attempted project digests succeed.
|
|
25
|
+
Loved approvals (the card's 😄 button) arrive as loved=true on approved
|
|
26
|
+
events and are surfaced to the model as strong positive evidence.
|
|
27
|
+
|
|
28
|
+
Failure handling: a Claude failure or unparseable plan leaves the events
|
|
29
|
+
unprocessed for the next run. A run-level flock prevents concurrent digests.
|
|
30
|
+
|
|
31
|
+
Stderr markers (load-bearing, dashboard-parsed; do not reformat):
|
|
32
|
+
[feedback_digest] project=<name> platform=<p> events=<n> applied=<x> dropped=<y> marked=<m>
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
python3 scripts/feedback_digest.py # digest all pending
|
|
36
|
+
python3 scripts/feedback_digest.py --project fazm # one project
|
|
37
|
+
python3 scripts/feedback_digest.py --dry-run # print plans, change nothing
|
|
38
|
+
"""
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import argparse
|
|
42
|
+
import datetime
|
|
43
|
+
import fcntl
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import subprocess
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
52
|
+
|
|
53
|
+
# SAPS_->S4L_ env mirror (brand rename 2026-07-03): old launchd plists and
|
|
54
|
+
# scheduled-task prompts still export SAPS_*; this process reads S4L_*.
|
|
55
|
+
import s4l_env # noqa: E402
|
|
56
|
+
|
|
57
|
+
s4l_env.mirror()
|
|
58
|
+
|
|
59
|
+
from http_api import api_get, api_patch, api_post # noqa: E402
|
|
60
|
+
import learned_preferences as lp # noqa: E402
|
|
61
|
+
|
|
62
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
63
|
+
RUN_CLAUDE_SH = os.path.join(REPO_DIR, "scripts", "run_claude.sh")
|
|
64
|
+
LOCK_PATH = os.path.expanduser("~/.social-autoposter-mcp/feedback-digest.lock")
|
|
65
|
+
MAX_EVENTS_PER_RUN = 200
|
|
66
|
+
CLAUDE_TIMEOUT_SEC = 180
|
|
67
|
+
|
|
68
|
+
DISALLOWED_TOOLS = (
|
|
69
|
+
"ScheduleWakeup,CronCreate,CronDelete,CronList,EnterPlanMode,EnterWorktree,"
|
|
70
|
+
"Bash,Edit,Write,Read,Grep,Glob,WebFetch,WebSearch,Agent,TodoWrite,"
|
|
71
|
+
"NotebookEdit,LSP,Monitor,PushNotification,RemoteTrigger,TaskOutput,"
|
|
72
|
+
"TaskStop,ListMcpResourcesTool,ReadMcpResourceTool"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def log(msg: str) -> None:
|
|
77
|
+
print(f"[feedback_digest] {msg}", file=sys.stderr, flush=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _now_stamp() -> str:
|
|
81
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_config():
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(Path(lp.config_path()).read_text())
|
|
87
|
+
except Exception:
|
|
88
|
+
return {"projects": []}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _event_line(e: dict) -> str:
|
|
92
|
+
"""One compact evidence line per event for the prompt."""
|
|
93
|
+
parts = [f"[{e.get('decision')}{'+loved' if e.get('loved') else ''}]"]
|
|
94
|
+
if e.get("reject_category"):
|
|
95
|
+
parts.append(f"category={e['reject_category']}")
|
|
96
|
+
if e.get("thread_author"):
|
|
97
|
+
parts.append(f"author=@{e['thread_author']}")
|
|
98
|
+
# Candidate-join context (author_followers, search_topic, tweet_text come
|
|
99
|
+
# from the LEFT JOIN in GET /api/v1/review-events): without it the model
|
|
100
|
+
# sees a bare handle and can never characterize the author TYPE behind a
|
|
101
|
+
# wrong_author reject. Older server deploys just omit the keys.
|
|
102
|
+
if e.get("author_followers") is not None:
|
|
103
|
+
parts.append(f"author_followers={e['author_followers']}")
|
|
104
|
+
if e.get("search_topic"):
|
|
105
|
+
parts.append(f"found_via_topic={e['search_topic']}")
|
|
106
|
+
inter = e.get("interactions") or []
|
|
107
|
+
kinds = sorted({str(i.get("type")) for i in inter if isinstance(i, dict) and i.get("type")})
|
|
108
|
+
if kinds:
|
|
109
|
+
parts.append(f"user_checked={'+'.join(kinds)}")
|
|
110
|
+
if e.get("dwell_ms"):
|
|
111
|
+
parts.append(f"dwell={round(e['dwell_ms'] / 1000, 1)}s")
|
|
112
|
+
if e.get("edited"):
|
|
113
|
+
parts.append("edited_before_approving")
|
|
114
|
+
line = " ".join(parts)
|
|
115
|
+
note = (e.get("reject_note") or "").strip()
|
|
116
|
+
if note:
|
|
117
|
+
line += f"\n user note: {note[:300]}"
|
|
118
|
+
tweet = (e.get("tweet_text") or "").strip()
|
|
119
|
+
if tweet:
|
|
120
|
+
line += f"\n their post was: {tweet[:200]}"
|
|
121
|
+
draft = (e.get("draft_text") or "").strip()
|
|
122
|
+
if draft:
|
|
123
|
+
line += f"\n our draft was: {draft[:200]}"
|
|
124
|
+
url = (e.get("thread_url") or "").strip()
|
|
125
|
+
if url:
|
|
126
|
+
line += f"\n thread: {url}"
|
|
127
|
+
return line
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_prompt(project: dict, events: list[dict], overall_events: list[dict] | None = None) -> str:
|
|
131
|
+
block = lp.get_block(project)
|
|
132
|
+
overall_events = overall_events or []
|
|
133
|
+
rejected = [e for e in events if e.get("decision") == "rejected"]
|
|
134
|
+
approved = [e for e in events if e.get("decision") == "approved"]
|
|
135
|
+
loved = [e for e in approved if e.get("loved")]
|
|
136
|
+
voice_never = ((project.get("voice") or {}).get("never")) or []
|
|
137
|
+
guard_do_not = ((project.get("content_guardrails") or {}).get("do_not")) or []
|
|
138
|
+
|
|
139
|
+
ev_lines = "\n".join(
|
|
140
|
+
f"{i + 1}. {_event_line(e)}" for i, e in enumerate(events)
|
|
141
|
+
) or "(none this digest)"
|
|
142
|
+
overall_block = ""
|
|
143
|
+
if overall_events:
|
|
144
|
+
notes = "\n".join(
|
|
145
|
+
f"{i + 1}. {(e.get('reject_note') or '').strip()[:500]}"
|
|
146
|
+
for i, e in enumerate(overall_events)
|
|
147
|
+
)
|
|
148
|
+
overall_block = (
|
|
149
|
+
f"\n\nOVERALL FEEDBACK from the user ({len(overall_events)} "
|
|
150
|
+
f"note{'s' if len(overall_events) != 1 else ''}, typed into the feedback box; "
|
|
151
|
+
"explicit standing guidance about the whole pipeline, NOT about any single thread):\n"
|
|
152
|
+
f"{notes}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return f"""You maintain the learned_preferences block for the project "{project.get('name')}" in a social-posting pipeline. The block distills the user's own approve/reject decisions on draft cards into short standing preferences that steer future thread selection and drafting. It is SOFT guidance read by the drafting model, not a filter.
|
|
156
|
+
|
|
157
|
+
CURRENT learned_preferences:
|
|
158
|
+
{json.dumps({k: block[k] for k in ("audience_avoid", "audience_prefer", "thread_avoid", "draft_style_notes")}, indent=2)}
|
|
159
|
+
|
|
160
|
+
CURRENT voice.never: {json.dumps(voice_never)}
|
|
161
|
+
CURRENT content_guardrails.do_not: {json.dumps(guard_do_not)}
|
|
162
|
+
|
|
163
|
+
NEW REVIEW EVENTS since the last digest ({len(rejected)} rejected, {len(approved)} approved, {len(loved)} of the approvals loved):
|
|
164
|
+
{ev_lines}{overall_block}
|
|
165
|
+
|
|
166
|
+
Categories: wrong_author = the thread's author/audience was a bad fit; off_topic = the thread itself was a bad fit; bad_draft = thread was fine but the written reply was off; other = see the note. "user_checked=profile_click" means the user opened the author's profile before deciding (a strong author-quality signal even without a note). "[approved+loved]" means the user pressed the emphatic-approve button ("this was a really good one"): strong positive evidence for audience_prefer and thread selection, worth roughly two plain approvals.
|
|
167
|
+
|
|
168
|
+
You can also block SPECIFIC authors via the plan's block_authors list. A block is a permanent hard exclusion of that one handle from all future thread selection, so it is YOUR judgment call, never automatic. Block when the evidence is strong: a wrong_author reject IS a direct human statement about that author (especially with profile_click), and the author context (author_followers, their post, found_via_topic) or the user's note confirms the account itself was the problem rather than the topic. Do NOT block when the reject looks topic-driven (off_topic/bad_draft on a reasonable account) or when you are unsure; the generalizable TYPE entry in audience_avoid is the softer tool for that.
|
|
169
|
+
|
|
170
|
+
Propose changes to the block. RULES, in priority order:
|
|
171
|
+
1. Be conservative. Prefer NO changes over speculative ones. An empty plan is a good plan when the evidence is thin.
|
|
172
|
+
2. Generalize only what the evidence supports: 2+ events agreeing justify a general entry; a single reject justifies at most one narrowly-scoped entry, and only when its note, interactions, or author context (follower count, their post, discovery topic) makes the reason explicit. Exceptions: a single loved approval can justify one audience_prefer/thread entry when the pattern it shows is clear, and OVERALL FEEDBACK lines are explicit user instructions that outrank inferred signals; reflect each one in the most fitting list even from a single line, rewritten as a standing preference.
|
|
173
|
+
3. Describe author/audience TYPES, never individual handles. "crypto/web3-native accounts shilling tokens" is right; "@someguy" is wrong. Preferences must generalize.
|
|
174
|
+
4. Approvals are counter-evidence. If approvals contradict an existing entry, propose removing or narrowing it. Also propose removing entries that events show are stale.
|
|
175
|
+
5. bad_draft events feed draft_style_notes (or, ONLY for a clearly recurring phrasing complaint, voice_never_add / guardrails_do_not_add; use those sparingly, they touch curated fields).
|
|
176
|
+
6. Each entry: one sentence, under 200 characters, plain language, no em dashes, no hashtags, understandable a month from now without these events.
|
|
177
|
+
7. Respect the cap: at most {lp.MAX_ENTRIES_PER_LIST} entries per list. If a list is full, fold the new signal into an existing entry via remove+add.
|
|
178
|
+
|
|
179
|
+
OUTPUT: a single JSON object, nothing else. Schema:
|
|
180
|
+
{{"changes": {{"audience_avoid": {{"add": [], "remove": []}}, "audience_prefer": {{"add": [], "remove": []}}, "thread_avoid": {{"add": [], "remove": []}}, "draft_style_notes": {{"add": [], "remove": []}}}}, "voice_never_add": [], "guardrails_do_not_add": [], "block_authors": [{{"handle": "somehandle", "reason": "one short sentence citing the evidence"}}], "rationale": "one short sentence"}}
|
|
181
|
+
"remove" values must match existing entries EXACTLY. Omit empty keys if you like; an all-empty plan means "no changes"."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _provider_env() -> dict:
|
|
185
|
+
"""Route the Claude turn through the local job queue (drained by the
|
|
186
|
+
saps-worker Claude Desktop scheduled task) whenever that worker is actually
|
|
187
|
+
firing; otherwise leave the provider unset so run_claude.sh execs the
|
|
188
|
+
claude CLI directly (operator Macs). An explicit S4L_CLAUDE_PROVIDER in
|
|
189
|
+
the environment always wins. This is the same queue lane the drafting
|
|
190
|
+
pipeline uses — the digest is just one more job type on it."""
|
|
191
|
+
env = dict(os.environ)
|
|
192
|
+
if env.get("S4L_CLAUDE_PROVIDER"):
|
|
193
|
+
return env
|
|
194
|
+
try:
|
|
195
|
+
import schedule_state
|
|
196
|
+
|
|
197
|
+
if schedule_state.compute() == "ok":
|
|
198
|
+
env["S4L_CLAUDE_PROVIDER"] = "queue"
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
return env
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def call_claude(prompt: str) -> tuple[bool, str, str]:
|
|
205
|
+
"""Headless Claude turn, cost-tracked via run_claude.sh (script_tag
|
|
206
|
+
feedback-digest). Queue-routed when a worker is firing (see _provider_env);
|
|
207
|
+
otherwise mirrors scripts/link_tail.py call_claude()."""
|
|
208
|
+
env = _provider_env()
|
|
209
|
+
queued = env.get("S4L_CLAUDE_PROVIDER") == "queue"
|
|
210
|
+
# Queue lane waits for the every-minute worker to claim + draft; give it
|
|
211
|
+
# the same generous budget the pipeline's queued calls get.
|
|
212
|
+
timeout_sec = 900 if queued else CLAUDE_TIMEOUT_SEC
|
|
213
|
+
if os.path.exists(RUN_CLAUDE_SH):
|
|
214
|
+
cmd = ["bash", RUN_CLAUDE_SH, "feedback-digest", "-p", prompt,
|
|
215
|
+
"--max-turns", "1", "--disallowed-tools", DISALLOWED_TOOLS]
|
|
216
|
+
else:
|
|
217
|
+
cmd = ["claude", "-p", prompt, "--max-turns", "1",
|
|
218
|
+
"--disallowed-tools", DISALLOWED_TOOLS]
|
|
219
|
+
empty_mcp = "/tmp/.feedback_digest_empty_mcp.json"
|
|
220
|
+
try:
|
|
221
|
+
if not os.path.exists(empty_mcp):
|
|
222
|
+
Path(empty_mcp).write_text('{"mcpServers": {}}')
|
|
223
|
+
cmd += ["--strict-mcp-config", "--mcp-config", empty_mcp]
|
|
224
|
+
except Exception:
|
|
225
|
+
pass
|
|
226
|
+
try:
|
|
227
|
+
r = subprocess.run(cmd, capture_output=True, text=True,
|
|
228
|
+
timeout=timeout_sec, cwd=REPO_DIR, env=env)
|
|
229
|
+
out = (r.stdout or "").strip()
|
|
230
|
+
if r.returncode != 0:
|
|
231
|
+
return False, out, f"rc={r.returncode}: {(r.stderr or '')[:300]}"
|
|
232
|
+
if not out:
|
|
233
|
+
return False, "", "empty_stdout"
|
|
234
|
+
return True, out, ""
|
|
235
|
+
except subprocess.TimeoutExpired:
|
|
236
|
+
return False, "", f"timeout_{timeout_sec}s"
|
|
237
|
+
except FileNotFoundError as e:
|
|
238
|
+
return False, "", f"claude_cli_missing: {e}"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def parse_plan(text: str):
|
|
242
|
+
"""Extract the JSON plan from model output (tolerates code fences and
|
|
243
|
+
surrounding prose). Returns dict or None."""
|
|
244
|
+
t = text.strip()
|
|
245
|
+
t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t, flags=re.MULTILINE).strip()
|
|
246
|
+
try:
|
|
247
|
+
obj = json.loads(t)
|
|
248
|
+
return obj if isinstance(obj, dict) else None
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
start = t.find("{")
|
|
252
|
+
end = t.rfind("}")
|
|
253
|
+
if start != -1 and end > start:
|
|
254
|
+
try:
|
|
255
|
+
obj = json.loads(t[start : end + 1])
|
|
256
|
+
return obj if isinstance(obj, dict) else None
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def digest_project(project: dict, platform: str, dry_run: bool,
|
|
263
|
+
overall_events: list[dict] | None = None) -> bool:
|
|
264
|
+
"""Digest one project's pending events (plus any overall-feedback notes,
|
|
265
|
+
which ride along in every project's prompt but are marked processed by
|
|
266
|
+
main(), not here). Returns True when the digest completed (or there was
|
|
267
|
+
nothing to do); False leaves the events unprocessed for the next run."""
|
|
268
|
+
name = project.get("name")
|
|
269
|
+
overall_events = overall_events or []
|
|
270
|
+
resp = api_get("/api/v1/review-events",
|
|
271
|
+
{"project": name, "platform": platform, "unprocessed": "true",
|
|
272
|
+
"limit": str(MAX_EVENTS_PER_RUN)})
|
|
273
|
+
events = ((resp or {}).get("data") or {}).get("events") or []
|
|
274
|
+
if not events and not overall_events:
|
|
275
|
+
return True
|
|
276
|
+
prompt = build_prompt(project, events, overall_events)
|
|
277
|
+
if dry_run:
|
|
278
|
+
log(f"project={name} platform={platform} events={len(events)} overall={len(overall_events)} DRY RUN prompt below")
|
|
279
|
+
print(prompt)
|
|
280
|
+
ok, out, err = call_claude(prompt)
|
|
281
|
+
if not ok:
|
|
282
|
+
log(f"project={name} platform={platform} events={len(events)} claude_failed={err} (events left unprocessed)")
|
|
283
|
+
return False
|
|
284
|
+
plan = parse_plan(out)
|
|
285
|
+
if plan is None:
|
|
286
|
+
log(f"project={name} platform={platform} events={len(events)} plan_unparseable (events left unprocessed): {out[:200]}")
|
|
287
|
+
return False
|
|
288
|
+
if dry_run:
|
|
289
|
+
print(json.dumps(plan, indent=2))
|
|
290
|
+
log(f"project={name} platform={platform} events={len(events)} DRY RUN (nothing applied/marked)")
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
# block_authors is applied through the blocklist API, not through
|
|
294
|
+
# learned_preferences: pop it before apply_mutations sees the plan.
|
|
295
|
+
block_authors = plan.pop("block_authors", None) or []
|
|
296
|
+
|
|
297
|
+
event_ids = [int(e["id"]) for e in events if str(e.get("id", "")).isdigit() or isinstance(e.get("id"), int)]
|
|
298
|
+
result = lp.apply_mutations(name, plan, source_event_ids=event_ids)
|
|
299
|
+
if not result.get("ok"):
|
|
300
|
+
log(f"project={name} platform={platform} events={len(events)} apply_failed={result.get('error')} (events left unprocessed)")
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
# Author blocks the digest agent decided on (its judgment call, never
|
|
304
|
+
# automatic; see the prompt). Applied via the blocklist API so the
|
|
305
|
+
# discovery and reply gates enforce them. Best-effort per handle: a
|
|
306
|
+
# failed POST is logged and skipped, never fails the digest.
|
|
307
|
+
blocked = []
|
|
308
|
+
for entry in block_authors[:10]: # runaway-plan cap
|
|
309
|
+
if not isinstance(entry, dict):
|
|
310
|
+
continue
|
|
311
|
+
handle = str(entry.get("handle") or "").strip().lstrip("@").lower()
|
|
312
|
+
if not handle:
|
|
313
|
+
continue
|
|
314
|
+
reason = str(entry.get("reason") or "").strip()[:500] or "feedback digest judgment call"
|
|
315
|
+
try:
|
|
316
|
+
api_post("/api/v1/blocklist", {
|
|
317
|
+
"platform": platform,
|
|
318
|
+
"handle": handle,
|
|
319
|
+
"classification": "manual_block",
|
|
320
|
+
"severity": "hard",
|
|
321
|
+
"reason": f"feedback digest: {reason}",
|
|
322
|
+
"added_by": "feedback_digest",
|
|
323
|
+
"project": name,
|
|
324
|
+
}, ok_on_conflict=True)
|
|
325
|
+
blocked.append(handle)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
log(f"project={name} block_author_failed handle={handle}: {e}")
|
|
328
|
+
if blocked:
|
|
329
|
+
log(f"project={name} blocked_authors: {', '.join(blocked)}")
|
|
330
|
+
|
|
331
|
+
marked = 0
|
|
332
|
+
if event_ids:
|
|
333
|
+
try:
|
|
334
|
+
presp = api_patch("/api/v1/review-events",
|
|
335
|
+
{"ids": event_ids, "action": "mark_processed",
|
|
336
|
+
"processed_batch": f"digest-{_now_stamp()}"})
|
|
337
|
+
marked = ((presp or {}).get("data") or {}).get("updated") or 0
|
|
338
|
+
except Exception as e:
|
|
339
|
+
log(f"project={name} mark_processed_failed={e} (idempotent: next run re-digests, apply dedups)")
|
|
340
|
+
log(
|
|
341
|
+
f"project={name} platform={platform} events={len(events)} "
|
|
342
|
+
f"applied={len(result.get('applied') or [])} dropped={len(result.get('dropped') or [])} marked={marked}"
|
|
343
|
+
)
|
|
344
|
+
for change in result.get("applied") or []:
|
|
345
|
+
log(f" {change}")
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def main() -> int:
|
|
350
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
351
|
+
ap.add_argument("--project", help="digest only this project")
|
|
352
|
+
ap.add_argument("--dry-run", action="store_true", help="print prompt+plan, change nothing")
|
|
353
|
+
ap.add_argument("--min-events", type=int,
|
|
354
|
+
default=int(os.environ.get("S4L_FEEDBACK_MIN_EVENTS", "1")),
|
|
355
|
+
help="skip a project until it has this many unprocessed events")
|
|
356
|
+
args = ap.parse_args()
|
|
357
|
+
|
|
358
|
+
Path(LOCK_PATH).parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
lock_f = open(LOCK_PATH, "w")
|
|
360
|
+
try:
|
|
361
|
+
fcntl.flock(lock_f, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
362
|
+
except BlockingIOError:
|
|
363
|
+
log("another digest run holds the lock; exiting")
|
|
364
|
+
return 0
|
|
365
|
+
|
|
366
|
+
cfg = load_config()
|
|
367
|
+
by_name = {p.get("name"): p for p in (cfg.get("projects") or [])}
|
|
368
|
+
|
|
369
|
+
resp = api_get("/api/v1/review-events", {"counts": "true"})
|
|
370
|
+
counts = ((resp or {}).get("data") or {}).get("counts") or []
|
|
371
|
+
|
|
372
|
+
# Overall feedback (decision='feedback', project IS NULL; the card's 💬
|
|
373
|
+
# button / menu bar "Send feedback…"): fetched once, folded into EVERY
|
|
374
|
+
# configured project's prompt, and marked processed only after all
|
|
375
|
+
# attempted digests succeed (apply_mutations dedups any re-digest).
|
|
376
|
+
overall_events: list[dict] = []
|
|
377
|
+
try:
|
|
378
|
+
oresp = api_get("/api/v1/review-events",
|
|
379
|
+
{"unprocessed": "true", "limit": "100"})
|
|
380
|
+
overall_events = [
|
|
381
|
+
e for e in (((oresp or {}).get("data") or {}).get("events") or [])
|
|
382
|
+
if e.get("decision") == "feedback" and not e.get("project")
|
|
383
|
+
]
|
|
384
|
+
except Exception as e:
|
|
385
|
+
log(f"overall_feedback_fetch_error={e}")
|
|
386
|
+
if overall_events:
|
|
387
|
+
log(f"overall feedback notes pending: {len(overall_events)}")
|
|
388
|
+
|
|
389
|
+
if not counts and not overall_events:
|
|
390
|
+
log("no unprocessed review events")
|
|
391
|
+
return 0
|
|
392
|
+
|
|
393
|
+
todo: dict[tuple[str, str], int] = {}
|
|
394
|
+
for row in counts:
|
|
395
|
+
name = row.get("project")
|
|
396
|
+
if not name:
|
|
397
|
+
continue # project-less rows are the overall feedback handled above
|
|
398
|
+
platform = row.get("platform") or "twitter"
|
|
399
|
+
n = int(row.get("unprocessed") or 0)
|
|
400
|
+
if args.project and name != args.project:
|
|
401
|
+
continue
|
|
402
|
+
# Explicit overall feedback shouldn't wait on the card-event threshold.
|
|
403
|
+
if n < args.min_events and not overall_events:
|
|
404
|
+
log(f"project={name} platform={platform} events={n} below_min={args.min_events}, waiting")
|
|
405
|
+
continue
|
|
406
|
+
todo[(name, platform)] = n
|
|
407
|
+
if overall_events:
|
|
408
|
+
for name in by_name:
|
|
409
|
+
if args.project and name != args.project:
|
|
410
|
+
continue
|
|
411
|
+
todo.setdefault((name, "twitter"), 0)
|
|
412
|
+
|
|
413
|
+
attempted = 0
|
|
414
|
+
failures = 0
|
|
415
|
+
for (name, platform), _n in todo.items():
|
|
416
|
+
proj = by_name.get(name)
|
|
417
|
+
if proj is None:
|
|
418
|
+
log(f"project={name} not in local config, skipping (events left for the owning install)")
|
|
419
|
+
continue
|
|
420
|
+
attempted += 1
|
|
421
|
+
try:
|
|
422
|
+
if not digest_project(proj, platform, args.dry_run, overall_events):
|
|
423
|
+
failures += 1
|
|
424
|
+
except Exception as e:
|
|
425
|
+
failures += 1
|
|
426
|
+
log(f"project={name} digest_error={e}")
|
|
427
|
+
|
|
428
|
+
if overall_events and not args.dry_run:
|
|
429
|
+
if attempted and not failures:
|
|
430
|
+
ids = [int(e["id"]) for e in overall_events
|
|
431
|
+
if str(e.get("id", "")).isdigit() or isinstance(e.get("id"), int)]
|
|
432
|
+
try:
|
|
433
|
+
presp = api_patch("/api/v1/review-events",
|
|
434
|
+
{"ids": ids, "action": "mark_processed",
|
|
435
|
+
"processed_batch": f"digest-overall-{_now_stamp()}"})
|
|
436
|
+
marked = ((presp or {}).get("data") or {}).get("updated") or 0
|
|
437
|
+
log(f"overall feedback marked processed: {marked}")
|
|
438
|
+
except Exception as e:
|
|
439
|
+
log(f"overall mark_processed_failed={e} (re-digested next run; apply dedups)")
|
|
440
|
+
elif attempted:
|
|
441
|
+
log("overall feedback left unprocessed (a project digest failed; next run retries)")
|
|
442
|
+
else:
|
|
443
|
+
log("overall feedback pending but no configured project to digest into; left unprocessed")
|
|
444
|
+
return 0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if __name__ == "__main__":
|
|
448
|
+
sys.exit(main())
|