@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,1462 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Standalone topic-invention job — runs OUTSIDE the post-comments cycle.
|
|
3
|
+
|
|
4
|
+
Architectural split (2026-05-28): in-cycle EXPLORE_INVENT was removed
|
|
5
|
+
from pick_search_topic.py. Topic invention is now a separate, deliberate
|
|
6
|
+
background job:
|
|
7
|
+
|
|
8
|
+
- Picks ONE project per run using the same `pick_projects()` weighting
|
|
9
|
+
the cycle uses (inverse-recent-share, dampens active projects).
|
|
10
|
+
- Reads that project's per-topic funnel from
|
|
11
|
+
GET /api/v1/topic-funnel?project=<name> — server-side aggregation,
|
|
12
|
+
no local-file state (replaces ~/social-autoposter/state/topic_ledger.json
|
|
13
|
+
from earlier draft of this job, 2026-05-28).
|
|
14
|
+
- Asks Claude to propose 3-5 NEW search_topic candidates given the
|
|
15
|
+
project's description, the existing universe, the strong/decent
|
|
16
|
+
performers, the duds, and the untried tail.
|
|
17
|
+
- For each proposal, computes the closest existing neighbor in the
|
|
18
|
+
project's universe via token-Jaccard similarity (cheap, no
|
|
19
|
+
embeddings needed at our scale).
|
|
20
|
+
- Drops proposals that are exact-match dupes or near-dupes
|
|
21
|
+
(Jaccard >= SIMILARITY_THRESHOLD against any existing topic).
|
|
22
|
+
- POSTs survivors to /api/v1/project-search-topics with
|
|
23
|
+
source='invented', status='active'.
|
|
24
|
+
- POSTs an audit row to /api/v1/invented-topics-audit so invention
|
|
25
|
+
quality is reviewable offline (no local file).
|
|
26
|
+
|
|
27
|
+
Cadence: hourly via launchd com.m13v.social-invent-topics.
|
|
28
|
+
Project budget: one per run (n=1). Knob is PROJECTS_PER_RUN below.
|
|
29
|
+
|
|
30
|
+
CLI:
|
|
31
|
+
python3 scripts/invent_topics.py # pick a project, invent, commit
|
|
32
|
+
python3 scripts/invent_topics.py --project studyly # force a specific project
|
|
33
|
+
python3 scripts/invent_topics.py --dry-run # log plan, do not commit or audit
|
|
34
|
+
python3 scripts/invent_topics.py --proposals 5 # ask Claude for N proposals
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import socket
|
|
43
|
+
import subprocess
|
|
44
|
+
import sys
|
|
45
|
+
import tempfile
|
|
46
|
+
import time
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
|
|
49
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
50
|
+
|
|
51
|
+
from http_api import api_get, api_post # noqa: E402
|
|
52
|
+
from pick_project import load_config, pick_projects # noqa: E402
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
PROJECTS_PER_RUN = 1
|
|
56
|
+
DEFAULT_PROPOSALS = 1 # topics invented per loop iteration (one-topic-at-a-time)
|
|
57
|
+
SIMILARITY_THRESHOLD = 0.6 # Jaccard threshold above which we reject as near-dupe
|
|
58
|
+
WINDOW_DAYS = 30 # ledger window the picker reads from
|
|
59
|
+
|
|
60
|
+
# Retry-loop knobs. Each loop iteration invents ONE topic, drafts queries for
|
|
61
|
+
# it, supply-tests them, logs every attempt, and commits the topic. The loop
|
|
62
|
+
# stops as soon as a single topic clears the supply floor (sum of fresh tweets
|
|
63
|
+
# across its queries >= SUPPLY_FLOOR), or MAX_ATTEMPTS iterations are exhausted.
|
|
64
|
+
DEFAULT_TARGET = 1 # qualifying topics wanted per run (one is enough; supply is the real target)
|
|
65
|
+
DEFAULT_MAX_ATTEMPTS = 5 # hard cap on loop iterations per run (cost guard)
|
|
66
|
+
|
|
67
|
+
# Supply-test knobs (2026-05-28: invent loop now scans drafted queries before
|
|
68
|
+
# committing, mirroring the cycle's Phase 1 freshness gate).
|
|
69
|
+
QUERIES_PER_TOPIC = 5 # distinct queries drafted + scanned per invented topic
|
|
70
|
+
SUPPLY_FLOOR = 3 # min SUM of fresh tweets across a topic's queries to "qualify"
|
|
71
|
+
FRESHNESS_HOURS = 6 # freshness window each query is scanned at (matches discover)
|
|
72
|
+
CDP_PORT = 9555 # managed Chrome the twitter-harness drives
|
|
73
|
+
LOCK_TIMEOUT_SEC = 600 # how long the supply-test helper waits for twitter-browser lock
|
|
74
|
+
|
|
75
|
+
_REPO_DIR = os.path.expanduser("~/social-autoposter")
|
|
76
|
+
_SUPPLY_TEST_SH = os.path.join(_REPO_DIR, "skill", "invent-supply-test.sh")
|
|
77
|
+
_LOG_ATTEMPTS_PY = os.path.join(_REPO_DIR, "scripts", "log_twitter_search_attempts.py")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- Query normalization (copied verbatim from qualified_query_bank.normalize
|
|
81
|
+
# so this module needs NO direct DB import — all reads go through the API).
|
|
82
|
+
# Strips per-cycle operators so phrasings that differ only by freshness or
|
|
83
|
+
# min_faves collapse to one core for dedup. -------------------------------
|
|
84
|
+
|
|
85
|
+
def normalize_query(q: str) -> str:
|
|
86
|
+
"""Strip per-cycle operators so two queries that differ only by
|
|
87
|
+
since:/min_faves:/filter: collapse to the same core for dedup."""
|
|
88
|
+
q = (q or "").lower()
|
|
89
|
+
for pat in (
|
|
90
|
+
r"\bsince:\S+", r"\buntil:\S+",
|
|
91
|
+
r"\bsince_time:\S+", r"\buntil_time:\S+",
|
|
92
|
+
r"\bmin_faves:\d+", r"\bmin_retweets:\d+", r"\bmin_replies:\d+",
|
|
93
|
+
r"\b-?filter:\S+", r"\blang:\S+",
|
|
94
|
+
):
|
|
95
|
+
q = re.sub(pat, "", q)
|
|
96
|
+
q = re.sub(r'[()"]', "", q)
|
|
97
|
+
q = re.sub(r"\s+", " ", q).strip()
|
|
98
|
+
return q
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- Tokenization for cheap similarity --------------------------------------
|
|
102
|
+
|
|
103
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _tokens(text: str) -> set[str]:
|
|
107
|
+
"""Lowercased word-token set used for Jaccard similarity.
|
|
108
|
+
|
|
109
|
+
Strips punctuation and case. Stopword removal is intentionally
|
|
110
|
+
minimal — for short topic strings even small filler words carry
|
|
111
|
+
enough signal to differentiate genuine paraphrases.
|
|
112
|
+
"""
|
|
113
|
+
return set(_TOKEN_RE.findall((text or "").lower()))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _jaccard(a: str, b: str) -> float:
|
|
117
|
+
"""Token Jaccard similarity in [0, 1]. Empty inputs return 0.
|
|
118
|
+
|
|
119
|
+
Cheap, deterministic, no embedding cost. Good enough at our scale
|
|
120
|
+
(<200 topics per project) to catch obvious paraphrases like
|
|
121
|
+
'voice coding agent' vs 'voice AI coding agent' without false
|
|
122
|
+
positives across genuinely different concepts.
|
|
123
|
+
"""
|
|
124
|
+
ta, tb = _tokens(a), _tokens(b)
|
|
125
|
+
if not ta or not tb:
|
|
126
|
+
return 0.0
|
|
127
|
+
inter = ta & tb
|
|
128
|
+
union = ta | tb
|
|
129
|
+
return len(inter) / len(union)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --- Ledger / universe loading via API --------------------------------------
|
|
133
|
+
|
|
134
|
+
def load_project_topics(project_name: str,
|
|
135
|
+
window_days: int = WINDOW_DAYS) -> list[dict]:
|
|
136
|
+
"""Fetch the per-topic funnel from /api/v1/topic-funnel for one project.
|
|
137
|
+
|
|
138
|
+
Server-side aggregation (no direct DB access from this client).
|
|
139
|
+
Returns rows already enriched with `verdict` and `clicks_per_post`.
|
|
140
|
+
Hard-fails on network errors — invention without ledger signal
|
|
141
|
+
would be uninformed and risks producing dupes-by-accident.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
resp = api_get(
|
|
145
|
+
"/api/v1/topic-funnel",
|
|
146
|
+
query={
|
|
147
|
+
"project": project_name,
|
|
148
|
+
"window_days": str(window_days),
|
|
149
|
+
"platform": "twitter",
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
except Exception as exc:
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
f"topic-funnel API failed for project={project_name!r}: {exc}"
|
|
155
|
+
) from exc
|
|
156
|
+
data = (resp or {}).get("data") or {}
|
|
157
|
+
rows = data.get("rows") or []
|
|
158
|
+
# Server returns rows sorted by clicks_total DESC. Re-sort by verdict
|
|
159
|
+
# for the prompt's table so strong/decent appear first regardless of
|
|
160
|
+
# raw click counts (a strong topic with low absolute clicks is still
|
|
161
|
+
# a quality signal we want to highlight).
|
|
162
|
+
verdict_rank = {"strong": 0, "decent": 1, "weak": 2, "untried": 3, "dud": 4}
|
|
163
|
+
rows.sort(key=lambda r: (
|
|
164
|
+
verdict_rank.get(r.get("verdict", "untried"), 5),
|
|
165
|
+
-(r.get("clicks_per_post") or 0),
|
|
166
|
+
-(r.get("posted_n") or 0),
|
|
167
|
+
))
|
|
168
|
+
return rows
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def project_universe_strings(project_name: str) -> set[str]:
|
|
172
|
+
"""Full active universe for the project from project_search_topics.
|
|
173
|
+
|
|
174
|
+
Read from /api/v1/project-search-topics for the freshest read at
|
|
175
|
+
invent time. Lowercased for case-insensitive matching against
|
|
176
|
+
proposals.
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
resp = api_get(
|
|
180
|
+
"/api/v1/project-search-topics",
|
|
181
|
+
query={"project": project_name, "status": "active"},
|
|
182
|
+
)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
raise SystemExit(
|
|
185
|
+
f"could not fetch active universe for project={project_name!r}: {exc}"
|
|
186
|
+
) from exc
|
|
187
|
+
rows = ((resp or {}).get("data") or {}).get("topics") or []
|
|
188
|
+
return {(r.get("topic") or "").strip().lower() for r in rows if r.get("topic")}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --- Prompt building --------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _format_topic_table(rows: list[dict], max_per_bucket: int = 12) -> str:
|
|
194
|
+
"""Compact markdown table of topics grouped by verdict.
|
|
195
|
+
|
|
196
|
+
Caps each bucket at max_per_bucket so the prompt stays bounded
|
|
197
|
+
even when the project has hundreds of untried seeds. The visible
|
|
198
|
+
slice is intentionally sorted by clicks_per_post DESC within each
|
|
199
|
+
bucket so the model sees what's actually converting.
|
|
200
|
+
"""
|
|
201
|
+
buckets: dict[str, list[dict]] = {}
|
|
202
|
+
for r in rows:
|
|
203
|
+
buckets.setdefault(r.get("verdict", "untried"), []).append(r)
|
|
204
|
+
|
|
205
|
+
parts: list[str] = []
|
|
206
|
+
for verdict in ("strong", "decent", "weak", "dud", "untried"):
|
|
207
|
+
bucket = buckets.get(verdict, [])
|
|
208
|
+
if not bucket:
|
|
209
|
+
continue
|
|
210
|
+
parts.append(f"\n### {verdict.upper()} ({len(bucket)} total, showing top {min(len(bucket), max_per_bucket)})")
|
|
211
|
+
for r in bucket[:max_per_bucket]:
|
|
212
|
+
cpp = r.get("clicks_per_post")
|
|
213
|
+
cpp_str = f"{cpp:.2f}" if cpp is not None else "—"
|
|
214
|
+
parts.append(
|
|
215
|
+
f"- **{r['search_topic']}** "
|
|
216
|
+
f"(attempts {r['attempts_n']}, "
|
|
217
|
+
f"candidates {r['candidates_n']}, "
|
|
218
|
+
f"posted {r['posted_n']}, "
|
|
219
|
+
f"clicks/post {cpp_str}, "
|
|
220
|
+
f"supply {r['tweets_found_total']})"
|
|
221
|
+
)
|
|
222
|
+
if len(bucket) > max_per_bucket:
|
|
223
|
+
parts.append(f" …and {len(bucket) - max_per_bucket} more in this bucket.")
|
|
224
|
+
return "\n".join(parts)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def build_prompt(
|
|
228
|
+
project: dict,
|
|
229
|
+
topics: list[dict],
|
|
230
|
+
n_proposals: int,
|
|
231
|
+
avoid_topics: set[str] | None = None,
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Assemble the Claude prompt for one tool-using topic invention session.
|
|
234
|
+
|
|
235
|
+
This version of the prompt gives Claude the invent-tools MCP suite
|
|
236
|
+
(search_topics / get_topic_stats / submit_topic) so dedup runs IN-SESSION
|
|
237
|
+
instead of post-hoc. The ledger snapshot is still included as a quick
|
|
238
|
+
overview, but Claude is expected to use the tools to verify against the
|
|
239
|
+
full universe before submitting. submit_topic returns a tool-call error
|
|
240
|
+
on near-dupes, so Claude can react and propose a different angle without
|
|
241
|
+
a new session.
|
|
242
|
+
|
|
243
|
+
`avoid_topics` carries topics already proposed earlier in THIS run.
|
|
244
|
+
Empty/None when the outer loop is on its first session.
|
|
245
|
+
"""
|
|
246
|
+
name = project.get("name", "")
|
|
247
|
+
description = project.get("description", "")
|
|
248
|
+
voice = project.get("voice_relationship", "")
|
|
249
|
+
|
|
250
|
+
table = _format_topic_table(topics)
|
|
251
|
+
|
|
252
|
+
avoid_block = ""
|
|
253
|
+
if avoid_topics:
|
|
254
|
+
avoid_lines = "\n".join(f"- {t}" for t in sorted(avoid_topics))
|
|
255
|
+
avoid_block = (
|
|
256
|
+
"\n## Already proposed earlier this run — DO NOT repeat or paraphrase\n\n"
|
|
257
|
+
"An earlier session this run already suggested the topics below "
|
|
258
|
+
"(committed OR rejected as dupes). Stay clear of these:\n\n"
|
|
259
|
+
f"{avoid_lines}\n"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return f"""You are inventing ONE new Twitter search_topic seed for project **{name}**.
|
|
263
|
+
|
|
264
|
+
A topic is a short concept phrase (2-6 words typically) used to draft Twitter search queries downstream. Good topics surface fresh, on-topic threads where our reply has product fit. Bad topics are too generic (noise), too narrow (zero supply), or paraphrases of existing topics.
|
|
265
|
+
|
|
266
|
+
## Project context
|
|
267
|
+
- Name: {name}
|
|
268
|
+
- Description: {description}
|
|
269
|
+
- Voice: {voice}
|
|
270
|
+
|
|
271
|
+
## Performance ledger snapshot (top 12 per bucket; full universe has {len(topics)} topics)
|
|
272
|
+
|
|
273
|
+
{table}
|
|
274
|
+
|
|
275
|
+
## Tools available — USE THEM before submitting
|
|
276
|
+
|
|
277
|
+
You have THREE tools exposed by the `invent-tools` MCP server. The ledger above is a snapshot; for ground truth and full coverage, call the tools.
|
|
278
|
+
|
|
279
|
+
1. **search_topics(project, q="", limit=200)** — search the FULL active topic universe. Use a short substring (e.g. `q="agent"`) to scan everything adjacent to your candidate before you commit. Empty `q` returns the whole list.
|
|
280
|
+
2. **get_topic_stats(project, topic)** — pull the topic-funnel row for any specific topic (attempts, supply, candidates, posted, likes, clicks, verdict). Use this when search_topics shows an adjacent topic and you need to know if it's STRONG (propose ADJACENT angle) or WEAK/DUD (AVOID neighborhood).
|
|
281
|
+
3. **submit_topic(project, topic, rationale)** — submit your final topic. The tool runs Jaccard dedup against the full universe ON THE SERVER. If your topic is a near-dupe it returns `ok: false` with the offending neighbor and similarity score. PROPOSE A DIFFERENT ANGLE and try again. When it returns `ok: true`, the topic is committed.
|
|
282
|
+
|
|
283
|
+
## Ledger bucket guide
|
|
284
|
+
- **STRONG** (clicks_per_post >= 1.0): audience converts. Invent ADJACENT angles, not paraphrases.
|
|
285
|
+
- **DECENT** (>= 0.3): solid signal. Same as strong, lower peak.
|
|
286
|
+
- **WEAK** (posted but few clicks): audience doesn't convert. AVOID this neighborhood.
|
|
287
|
+
- **DUD** (>=3 attempts, zero candidates): no Twitter supply. DON'T paraphrase — they fail at the source level.
|
|
288
|
+
- **UNTRIED** (no attempts in 30d): unknown. Read carefully before proposing nearby.
|
|
289
|
+
{avoid_block}
|
|
290
|
+
## Workflow (follow strictly)
|
|
291
|
+
1. Pick a candidate gap angle you think is genuinely new.
|
|
292
|
+
2. Call `search_topics(project="{name}", q="<one-word probe from your candidate>")` to scan the neighborhood.
|
|
293
|
+
3. For the 1-2 closest existing topics, call `get_topic_stats(project="{name}", topic="<the topic>")` to read their verdict.
|
|
294
|
+
4. Based on what you find: keep your candidate if the gap is real, or pivot to a different angle.
|
|
295
|
+
5. Call `submit_topic(project="{name}", topic="<2-6 words, lowercase>", rationale="<≤30 words, why this gap>")`.
|
|
296
|
+
6. If `ok: false` (dupe error): use the `neighbor` field to pivot, then call submit_topic again with a genuinely different topic. Up to 5 submission attempts per session.
|
|
297
|
+
7. When a submission returns `ok: true`, STOP and answer with the final JSON envelope below.
|
|
298
|
+
|
|
299
|
+
## Required final answer
|
|
300
|
+
|
|
301
|
+
After a successful submit_topic call, end your response with EXACTLY this JSON (no prose around it):
|
|
302
|
+
|
|
303
|
+
```json
|
|
304
|
+
{{"submitted_topic": "the topic you successfully submitted, verbatim", "rationale": "your rationale, verbatim"}}
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
If you cannot find a non-dupe topic after several submit attempts and the project is genuinely saturated, end with:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{{"submitted_topic": null, "reason": "short explanation, e.g. 'tried X, Y, Z all dupes; project saturated this hour'"}}
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Strict topic constraints (enforced by submit_topic too):
|
|
314
|
+
- Lowercase, 2-6 words
|
|
315
|
+
- No quotes inside topic strings
|
|
316
|
+
- NO Twitter operators (no `min_faves:`, `-filter:replies`, `since:` — those are added at query-draft time downstream)"""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --- Claude invocation ------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
# Path to the MCP server that exposes search/stats/submit tools for the
|
|
322
|
+
# in-session topic + query lookups. Kept beside this file so the launchd job
|
|
323
|
+
# always finds it.
|
|
324
|
+
_MCP_SERVER_PY = os.path.join(_REPO_DIR, "scripts", "invent_mcp_server.py")
|
|
325
|
+
|
|
326
|
+
# Tool names the topic round is allowed to call. Claude Code's --allowed-tools
|
|
327
|
+
# accepts the `mcp__<server-name>__<tool>` form for MCP-provided tools.
|
|
328
|
+
# `invent-tools` is the name we passed to FastMCP() in the server.
|
|
329
|
+
_TOPIC_TOOLS = [
|
|
330
|
+
"mcp__invent-tools__search_topics",
|
|
331
|
+
"mcp__invent-tools__get_topic_stats",
|
|
332
|
+
"mcp__invent-tools__submit_topic",
|
|
333
|
+
]
|
|
334
|
+
# Query round may also probe history if it wants to (read-only).
|
|
335
|
+
_QUERY_TOOLS = [
|
|
336
|
+
"mcp__invent-tools__search_queries",
|
|
337
|
+
"mcp__invent-tools__get_query_stats",
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _write_mcp_config_file() -> str:
|
|
342
|
+
"""Write a temporary MCP config JSON pointing at our invent-tools server
|
|
343
|
+
and return its path. claude -p --mcp-config <file> will spawn the server
|
|
344
|
+
as a subprocess over stdio."""
|
|
345
|
+
cfg = {
|
|
346
|
+
"mcpServers": {
|
|
347
|
+
"invent-tools": {
|
|
348
|
+
"command": "/opt/homebrew/bin/python3.11",
|
|
349
|
+
"args": [_MCP_SERVER_PY],
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
fd, path = tempfile.mkstemp(prefix="invent-mcp-", suffix=".json")
|
|
354
|
+
with os.fdopen(fd, "w") as f:
|
|
355
|
+
json.dump(cfg, f)
|
|
356
|
+
return path
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def call_claude(prompt: str, timeout_sec: int = 300,
|
|
360
|
+
allowed_tools: list[str] | None = None) -> str:
|
|
361
|
+
"""Run a single `claude -p` invocation and return its text output.
|
|
362
|
+
|
|
363
|
+
When `allowed_tools` is non-empty the call goes through the invent-tools
|
|
364
|
+
MCP server (search/stats/submit) and the model can call those tools
|
|
365
|
+
in-session before producing its final response. The Jaccard dedup runs
|
|
366
|
+
on the SERVER inside submit_topic, so a near-dupe surfaces as a
|
|
367
|
+
tool-call error Claude can react to instead of a silent post-hoc kill.
|
|
368
|
+
|
|
369
|
+
Inherits the global model from ~/.claude/settings.json per the
|
|
370
|
+
project's "single source of truth" convention (do NOT hardcode
|
|
371
|
+
--model here). The CLAUDE_MODEL env var, if set, is forwarded.
|
|
372
|
+
"""
|
|
373
|
+
cmd = ["claude", "-p", "--output-format", "json"]
|
|
374
|
+
model = os.environ.get("CLAUDE_MODEL")
|
|
375
|
+
if model:
|
|
376
|
+
cmd += ["--model", model]
|
|
377
|
+
# When the parent process is in dry-run mode it sets INVENT_DRY_RUN=1
|
|
378
|
+
# in its own env at startup; claude -p inherits it, which propagates to
|
|
379
|
+
# the MCP server, which short-circuits submit_topic without POSTing.
|
|
380
|
+
mcp_cfg_path = None
|
|
381
|
+
if allowed_tools:
|
|
382
|
+
mcp_cfg_path = _write_mcp_config_file()
|
|
383
|
+
# --strict-mcp-config: ignore any user-level MCP config so test runs
|
|
384
|
+
# never pick up the operator's personal MCP servers by accident.
|
|
385
|
+
# --allowed-tools: explicit allow-list so Claude can't reach for any
|
|
386
|
+
# other tool (Read/Bash/etc) it might infer from its default kit.
|
|
387
|
+
cmd += ["--mcp-config", mcp_cfg_path,
|
|
388
|
+
"--strict-mcp-config",
|
|
389
|
+
"--allowed-tools", ",".join(allowed_tools)]
|
|
390
|
+
try:
|
|
391
|
+
proc = subprocess.run(
|
|
392
|
+
cmd,
|
|
393
|
+
input=prompt,
|
|
394
|
+
text=True,
|
|
395
|
+
capture_output=True,
|
|
396
|
+
timeout=timeout_sec,
|
|
397
|
+
)
|
|
398
|
+
finally:
|
|
399
|
+
if mcp_cfg_path:
|
|
400
|
+
try:
|
|
401
|
+
os.remove(mcp_cfg_path)
|
|
402
|
+
except OSError:
|
|
403
|
+
pass
|
|
404
|
+
if proc.returncode != 0:
|
|
405
|
+
raise SystemExit(
|
|
406
|
+
f"claude -p exited {proc.returncode}: {proc.stderr[:500]}"
|
|
407
|
+
)
|
|
408
|
+
# claude -p --output-format json wraps the model's text in
|
|
409
|
+
# {"result": "...", ...}. Extract the result string.
|
|
410
|
+
try:
|
|
411
|
+
envelope = json.loads(proc.stdout)
|
|
412
|
+
except json.JSONDecodeError as exc:
|
|
413
|
+
raise SystemExit(
|
|
414
|
+
f"could not parse claude envelope: {exc}\nstdout head: {proc.stdout[:500]}"
|
|
415
|
+
) from exc
|
|
416
|
+
return envelope.get("result") or ""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# --- Output parsing ---------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def extract_proposals(claude_text: str) -> list[dict]:
|
|
422
|
+
"""Pull the proposals[] array out of Claude's output.
|
|
423
|
+
|
|
424
|
+
Defensive: handles plain JSON, JSON in fenced code blocks, and the
|
|
425
|
+
occasional preamble. Returns [] on any parse failure; the caller
|
|
426
|
+
audit-logs the raw text so we can debug offline.
|
|
427
|
+
"""
|
|
428
|
+
text = (claude_text or "").strip()
|
|
429
|
+
if not text:
|
|
430
|
+
return []
|
|
431
|
+
# Try fenced JSON first
|
|
432
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
433
|
+
candidate = m.group(1) if m else None
|
|
434
|
+
if candidate is None:
|
|
435
|
+
# Try the first balanced top-level {...} block
|
|
436
|
+
start = text.find("{")
|
|
437
|
+
if start >= 0:
|
|
438
|
+
candidate = text[start:]
|
|
439
|
+
if not candidate:
|
|
440
|
+
return []
|
|
441
|
+
try:
|
|
442
|
+
env = json.loads(candidate)
|
|
443
|
+
except json.JSONDecodeError:
|
|
444
|
+
# Trailing prose can break json.loads; try to find the matching brace
|
|
445
|
+
try:
|
|
446
|
+
depth = 0
|
|
447
|
+
for i, ch in enumerate(candidate):
|
|
448
|
+
if ch == "{":
|
|
449
|
+
depth += 1
|
|
450
|
+
elif ch == "}":
|
|
451
|
+
depth -= 1
|
|
452
|
+
if depth == 0:
|
|
453
|
+
env = json.loads(candidate[: i + 1])
|
|
454
|
+
break
|
|
455
|
+
else:
|
|
456
|
+
return []
|
|
457
|
+
except Exception:
|
|
458
|
+
return []
|
|
459
|
+
props = env.get("proposals") or []
|
|
460
|
+
cleaned: list[dict] = []
|
|
461
|
+
for p in props:
|
|
462
|
+
if not isinstance(p, dict):
|
|
463
|
+
continue
|
|
464
|
+
topic = (p.get("topic") or "").strip().lower()
|
|
465
|
+
rationale = (p.get("rationale") or "").strip()
|
|
466
|
+
if not topic:
|
|
467
|
+
continue
|
|
468
|
+
cleaned.append({"topic": topic, "rationale": rationale})
|
|
469
|
+
return cleaned
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def extract_submitted_topic(claude_text: str) -> dict | None:
|
|
473
|
+
"""Parse Claude's final envelope from the tool-using topic session.
|
|
474
|
+
|
|
475
|
+
Looks for the LAST JSON object in the response containing a
|
|
476
|
+
`submitted_topic` field. Returns:
|
|
477
|
+
- {"topic": "...", "rationale": "..."} on a successful submission
|
|
478
|
+
- {"topic": None, "reason": "..."} on a saturated session
|
|
479
|
+
- None if the envelope is missing or malformed (caller treats as bailout)
|
|
480
|
+
"""
|
|
481
|
+
text = (claude_text or "").strip()
|
|
482
|
+
if not text:
|
|
483
|
+
return None
|
|
484
|
+
# Walk all fenced JSON blocks first, then any bare {...} blocks; take the
|
|
485
|
+
# LAST that contains "submitted_topic" so trailing prose from the tool
|
|
486
|
+
# iteration doesn't shadow the final answer.
|
|
487
|
+
candidates: list[str] = []
|
|
488
|
+
for m in re.finditer(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL):
|
|
489
|
+
candidates.append(m.group(1))
|
|
490
|
+
# Fallback: scan balanced braces for any unfenced JSON
|
|
491
|
+
depth, start = 0, -1
|
|
492
|
+
for i, ch in enumerate(text):
|
|
493
|
+
if ch == "{":
|
|
494
|
+
if depth == 0:
|
|
495
|
+
start = i
|
|
496
|
+
depth += 1
|
|
497
|
+
elif ch == "}":
|
|
498
|
+
if depth > 0:
|
|
499
|
+
depth -= 1
|
|
500
|
+
if depth == 0 and start >= 0:
|
|
501
|
+
candidates.append(text[start:i + 1])
|
|
502
|
+
start = -1
|
|
503
|
+
last_ok: dict | None = None
|
|
504
|
+
for blob in candidates:
|
|
505
|
+
try:
|
|
506
|
+
env = json.loads(blob)
|
|
507
|
+
except json.JSONDecodeError:
|
|
508
|
+
continue
|
|
509
|
+
if not isinstance(env, dict) or "submitted_topic" not in env:
|
|
510
|
+
continue
|
|
511
|
+
topic_val = env.get("submitted_topic")
|
|
512
|
+
if topic_val is None:
|
|
513
|
+
last_ok = {"topic": None,
|
|
514
|
+
"reason": (env.get("reason") or "").strip()}
|
|
515
|
+
else:
|
|
516
|
+
topic = (str(topic_val) or "").strip().lower()
|
|
517
|
+
if not topic:
|
|
518
|
+
continue
|
|
519
|
+
last_ok = {"topic": topic,
|
|
520
|
+
"rationale": (env.get("rationale") or "").strip()}
|
|
521
|
+
return last_ok
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# --- Validation -------------------------------------------------------------
|
|
525
|
+
|
|
526
|
+
def find_closest_neighbor(
|
|
527
|
+
proposal: str,
|
|
528
|
+
universe: set[str],
|
|
529
|
+
) -> tuple[str | None, float]:
|
|
530
|
+
"""Return the closest universe topic by Jaccard similarity.
|
|
531
|
+
|
|
532
|
+
Used both to reject near-dupes (Jaccard >= SIMILARITY_THRESHOLD)
|
|
533
|
+
and to attach context to each proposal in the audit log.
|
|
534
|
+
"""
|
|
535
|
+
best_topic: str | None = None
|
|
536
|
+
best_sim = 0.0
|
|
537
|
+
for u in universe:
|
|
538
|
+
sim = _jaccard(proposal, u)
|
|
539
|
+
if sim > best_sim:
|
|
540
|
+
best_sim = sim
|
|
541
|
+
best_topic = u
|
|
542
|
+
return best_topic, best_sim
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def validate_proposals(
|
|
546
|
+
proposals: list[dict],
|
|
547
|
+
universe: set[str],
|
|
548
|
+
) -> tuple[list[dict], list[dict]]:
|
|
549
|
+
"""Split proposals into (committed_ok, rejected) by dedupe rule.
|
|
550
|
+
|
|
551
|
+
A proposal is rejected when:
|
|
552
|
+
- Its lowercased topic exactly matches an existing universe entry
|
|
553
|
+
- Its highest Jaccard similarity to any universe entry meets or
|
|
554
|
+
exceeds SIMILARITY_THRESHOLD (near-dupe)
|
|
555
|
+
|
|
556
|
+
The reason for rejection plus the offending neighbor is attached
|
|
557
|
+
to each rejected entry for audit-logging.
|
|
558
|
+
"""
|
|
559
|
+
committed: list[dict] = []
|
|
560
|
+
rejected: list[dict] = []
|
|
561
|
+
# Build a working set of "already committed this run" so two
|
|
562
|
+
# near-duplicate proposals in the same batch don't both land.
|
|
563
|
+
working_universe = set(universe)
|
|
564
|
+
|
|
565
|
+
for prop in proposals:
|
|
566
|
+
topic = prop["topic"]
|
|
567
|
+
if topic in working_universe:
|
|
568
|
+
rejected.append({
|
|
569
|
+
**prop,
|
|
570
|
+
"reject_reason": "exact_dupe",
|
|
571
|
+
"neighbor": topic,
|
|
572
|
+
"similarity": 1.0,
|
|
573
|
+
})
|
|
574
|
+
continue
|
|
575
|
+
neighbor, sim = find_closest_neighbor(topic, working_universe)
|
|
576
|
+
if neighbor is not None and sim >= SIMILARITY_THRESHOLD:
|
|
577
|
+
rejected.append({
|
|
578
|
+
**prop,
|
|
579
|
+
"reject_reason": "near_dupe",
|
|
580
|
+
"neighbor": neighbor,
|
|
581
|
+
"similarity": round(sim, 3),
|
|
582
|
+
})
|
|
583
|
+
continue
|
|
584
|
+
committed.append({
|
|
585
|
+
**prop,
|
|
586
|
+
"neighbor": neighbor,
|
|
587
|
+
"similarity": round(sim, 3) if neighbor else 0.0,
|
|
588
|
+
})
|
|
589
|
+
working_universe.add(topic)
|
|
590
|
+
|
|
591
|
+
return committed, rejected
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
# --- Commit + audit (both via API) ------------------------------------------
|
|
595
|
+
|
|
596
|
+
def commit_topic(project_name: str, topic: str, dry_run: bool = False) -> bool:
|
|
597
|
+
"""POST a new topic to project_search_topics with source='invented'.
|
|
598
|
+
|
|
599
|
+
Returns True on success. Idempotent in the API layer: re-POSTing
|
|
600
|
+
an existing (project, topic) pair is a no-op upstream. We still
|
|
601
|
+
rely on the local validation step to dedupe so the audit log is
|
|
602
|
+
accurate about which proposals were genuinely new.
|
|
603
|
+
"""
|
|
604
|
+
if dry_run:
|
|
605
|
+
print(f"[dry-run] would commit project={project_name!r} topic={topic!r}",
|
|
606
|
+
file=sys.stderr)
|
|
607
|
+
return True
|
|
608
|
+
try:
|
|
609
|
+
api_post(
|
|
610
|
+
"/api/v1/project-search-topics",
|
|
611
|
+
body={
|
|
612
|
+
"project": project_name,
|
|
613
|
+
"topic": topic,
|
|
614
|
+
"source": "invented",
|
|
615
|
+
"status": "active",
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
return True
|
|
619
|
+
except SystemExit as exc:
|
|
620
|
+
print(f"[invent_topics] commit FAILED project={project_name!r} "
|
|
621
|
+
f"topic={topic!r} error={exc}", file=sys.stderr)
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def write_audit(payload: dict, dry_run: bool = False) -> None:
|
|
626
|
+
"""POST one audit row to /api/v1/invented-topics-audit.
|
|
627
|
+
|
|
628
|
+
No local file: persistence is server-side via the API. Failures
|
|
629
|
+
are logged but never raise — losing one audit row is preferable
|
|
630
|
+
to crashing the invocation that just successfully committed topics.
|
|
631
|
+
"""
|
|
632
|
+
if dry_run:
|
|
633
|
+
print(f"[dry-run] would write audit row (project={payload.get('project')!r})",
|
|
634
|
+
file=sys.stderr)
|
|
635
|
+
return
|
|
636
|
+
try:
|
|
637
|
+
api_post("/api/v1/invented-topics-audit", body=payload)
|
|
638
|
+
except SystemExit as exc:
|
|
639
|
+
print(f"[invent_topics] audit POST failed: {exc}", file=sys.stderr)
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
print(f"[invent_topics] audit POST exception: {exc}", file=sys.stderr)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# --- Harness liveness -------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
def harness_alive(port: int = CDP_PORT, timeout: float = 2.0) -> bool:
|
|
647
|
+
"""Cheap TCP probe of the managed Chrome's CDP port.
|
|
648
|
+
|
|
649
|
+
The supply test is meaningless if the browser the harness drives isn't up:
|
|
650
|
+
every scan would return 0 and we'd commit real topics as false 'duds'. So
|
|
651
|
+
the loop checks this BEFORE spending Claude tokens on query drafting, and
|
|
652
|
+
treats a mid-run drop as 'untested' (abort, retry next hour) rather than
|
|
653
|
+
'zero supply'.
|
|
654
|
+
"""
|
|
655
|
+
try:
|
|
656
|
+
with socket.create_connection(("127.0.0.1", port), timeout=timeout):
|
|
657
|
+
return True
|
|
658
|
+
except OSError:
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# --- Query drafting (Claude) ------------------------------------------------
|
|
663
|
+
|
|
664
|
+
# Per-project ledger cache so build_query_prompt doesn't fetch top-queries +
|
|
665
|
+
# dud-queries + invented-queries from scratch on every topic in a single run.
|
|
666
|
+
_QUERY_LEDGER_CACHE: dict[str, str] = {}
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _build_query_ledger(project_name: str) -> str:
|
|
670
|
+
"""Fetch the per-query performance ledger for the project and format it
|
|
671
|
+
as a markdown table bucketed STRONG / DECENT / WEAK / INVENTED / DUD.
|
|
672
|
+
|
|
673
|
+
Sources:
|
|
674
|
+
- /api/v1/twitter-search-attempts/top-queries → posted-engagement winners
|
|
675
|
+
- /api/v1/twitter-search-attempts/invented-queries → supply-only winners
|
|
676
|
+
- /api/v1/twitter-search-attempts/dud-queries → zero-supply queries
|
|
677
|
+
(avoid paraphrasing — they fail at the source, not the framing)
|
|
678
|
+
|
|
679
|
+
Cached per project for the lifetime of the process so multiple topics in
|
|
680
|
+
one run share one fetch. Returns "" on fetch failure (best-effort
|
|
681
|
+
enrichment; the prompt still works without it).
|
|
682
|
+
"""
|
|
683
|
+
if project_name in _QUERY_LEDGER_CACHE:
|
|
684
|
+
return _QUERY_LEDGER_CACHE[project_name]
|
|
685
|
+
|
|
686
|
+
try:
|
|
687
|
+
top_resp = api_get("/api/v1/twitter-search-attempts/top-queries",
|
|
688
|
+
{"project": project_name, "limit": "50"})
|
|
689
|
+
invent_resp = api_get("/api/v1/twitter-search-attempts/invented-queries",
|
|
690
|
+
{"project": project_name, "min_supply": "1",
|
|
691
|
+
"limit": "30"})
|
|
692
|
+
dud_resp = api_get("/api/v1/twitter-search-attempts/dud-queries",
|
|
693
|
+
{"project": project_name, "limit": "20"})
|
|
694
|
+
except SystemExit as exc:
|
|
695
|
+
print(f"[invent_topics] query-ledger fetch failed: {exc}",
|
|
696
|
+
file=sys.stderr)
|
|
697
|
+
_QUERY_LEDGER_CACHE[project_name] = ""
|
|
698
|
+
return ""
|
|
699
|
+
|
|
700
|
+
top_rows = ((top_resp or {}).get("data") or {}).get("rows") or []
|
|
701
|
+
invent_rows = ((invent_resp or {}).get("data") or {}).get("queries") or []
|
|
702
|
+
dud_rows = ((dud_resp or {}).get("data") or {}).get("rows") or []
|
|
703
|
+
|
|
704
|
+
# Bucket top rows by clicks_per_post — same verdict the topic ledger uses.
|
|
705
|
+
strong, decent, weak = [], [], []
|
|
706
|
+
for r in top_rows:
|
|
707
|
+
posts = r.get("posts") or r.get("posted_n") or 0
|
|
708
|
+
clicks = r.get("clicks_total") or 0
|
|
709
|
+
if posts <= 0:
|
|
710
|
+
continue
|
|
711
|
+
cpp = clicks / posts
|
|
712
|
+
if cpp >= 1.0:
|
|
713
|
+
strong.append(r)
|
|
714
|
+
elif cpp >= 0.3:
|
|
715
|
+
decent.append(r)
|
|
716
|
+
else:
|
|
717
|
+
weak.append(r)
|
|
718
|
+
|
|
719
|
+
def _fmt_top(r: dict) -> str:
|
|
720
|
+
q = (r.get("query") or "")[:110]
|
|
721
|
+
return (f"- `{q}` posts {r.get('posts', r.get('posted_n', 0))}, "
|
|
722
|
+
f"likes {r.get('likes_total', 0)}, "
|
|
723
|
+
f"clicks {r.get('clicks_total', 0)}")
|
|
724
|
+
|
|
725
|
+
parts: list[str] = []
|
|
726
|
+
if strong:
|
|
727
|
+
parts.append(f"\n### STRONG queries ({len(strong)} total, "
|
|
728
|
+
f"showing top {min(len(strong), 10)}; clicks_per_post >= 1.0)")
|
|
729
|
+
for r in strong[:10]:
|
|
730
|
+
parts.append(_fmt_top(r))
|
|
731
|
+
if decent:
|
|
732
|
+
parts.append(f"\n### DECENT queries ({len(decent)} total, "
|
|
733
|
+
f"showing top {min(len(decent), 10)}; clicks_per_post >= 0.3)")
|
|
734
|
+
for r in decent[:10]:
|
|
735
|
+
parts.append(_fmt_top(r))
|
|
736
|
+
if weak:
|
|
737
|
+
parts.append(f"\n### WEAK queries ({len(weak)} total, "
|
|
738
|
+
f"showing top {min(len(weak), 6)}; posted but low engagement)")
|
|
739
|
+
for r in weak[:6]:
|
|
740
|
+
parts.append(_fmt_top(r))
|
|
741
|
+
if invent_rows:
|
|
742
|
+
parts.append(f"\n### INVENTED queries ({len(invent_rows)} total; "
|
|
743
|
+
f"surfaced supply but no posts yet)")
|
|
744
|
+
for r in invent_rows[:10]:
|
|
745
|
+
q = (r.get("query") or "")[:110]
|
|
746
|
+
parts.append(f"- `{q}` supply {r.get('supply', 0)}, "
|
|
747
|
+
f"attempts {r.get('attempts', 0)}")
|
|
748
|
+
if dud_rows:
|
|
749
|
+
parts.append(f"\n### DUD queries ({len(dud_rows)} total, "
|
|
750
|
+
f"showing top {min(len(dud_rows), 10)}) — DO NOT paraphrase")
|
|
751
|
+
for r in dud_rows[:10]:
|
|
752
|
+
q = (r.get("query") or "")[:110]
|
|
753
|
+
parts.append(f"- `{q}` attempts {r.get('attempts', 0)}")
|
|
754
|
+
|
|
755
|
+
out = "\n".join(parts) if parts else ""
|
|
756
|
+
_QUERY_LEDGER_CACHE[project_name] = out
|
|
757
|
+
return out
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def build_query_prompt(
|
|
761
|
+
project: dict,
|
|
762
|
+
topic: str,
|
|
763
|
+
n_queries: int,
|
|
764
|
+
avoid_queries: set[str] | None = None,
|
|
765
|
+
) -> str:
|
|
766
|
+
"""Prompt Claude to draft N distinct X/Twitter advanced-search queries for
|
|
767
|
+
one invented topic. Includes a per-query performance ledger (STRONG /
|
|
768
|
+
DECENT / WEAK / INVENTED / DUD with posts/clicks/likes/supply stats) so
|
|
769
|
+
the model can pattern-match against what's working for this project
|
|
770
|
+
instead of drafting in the dark. `avoid_queries` carries cores already
|
|
771
|
+
drafted/tried this run so a refill steers away from them."""
|
|
772
|
+
name = project.get("name", "")
|
|
773
|
+
description = project.get("description", "")
|
|
774
|
+
excludes = project.get("excludes_for_search") or project.get("excludes") or []
|
|
775
|
+
excludes_block = ""
|
|
776
|
+
if isinstance(excludes, list) and excludes:
|
|
777
|
+
excludes_block = (
|
|
778
|
+
"\n## Mandatory exclude terms for this project\n\n"
|
|
779
|
+
"Append these as `-term` to EVERY query (they filter known noise):\n"
|
|
780
|
+
f"{' '.join('-' + str(e) for e in excludes)}\n"
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
avoid_block = ""
|
|
784
|
+
if avoid_queries:
|
|
785
|
+
avoid_lines = "\n".join(f"- {q}" for q in sorted(avoid_queries))
|
|
786
|
+
avoid_block = (
|
|
787
|
+
"\n## Already tried — do NOT repeat or trivially re-phrase these\n\n"
|
|
788
|
+
f"{avoid_lines}\n"
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
ledger = _build_query_ledger(name)
|
|
792
|
+
ledger_block = ""
|
|
793
|
+
if ledger:
|
|
794
|
+
ledger_block = (
|
|
795
|
+
"\n## Per-query performance ledger for this project\n\n"
|
|
796
|
+
"The queries below have been tried; use their performance to "
|
|
797
|
+
"shape your N drafts. Mimic the operator structure of STRONG/"
|
|
798
|
+
"DECENT queries when the angle fits the topic; pattern-match "
|
|
799
|
+
"INVENTED queries that already surfaced supply; AVOID the "
|
|
800
|
+
"phrasings in WEAK and DUD.\n"
|
|
801
|
+
f"{ledger}\n"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
return f"""You are drafting X (Twitter) advanced-search queries to find FRESH threads where project **{name}** could reply with product fit.
|
|
805
|
+
|
|
806
|
+
## Project
|
|
807
|
+
- Name: {name}
|
|
808
|
+
- Description: {description}
|
|
809
|
+
|
|
810
|
+
## Topic to cover
|
|
811
|
+
**{topic}**
|
|
812
|
+
|
|
813
|
+
Draft **exactly {n_queries}** DISTINCT search queries that probe this topic from different angles, so together they maximize the chance of surfacing fresh, on-topic tweets.
|
|
814
|
+
{excludes_block}{avoid_block}{ledger_block}
|
|
815
|
+
## Query rules
|
|
816
|
+
- Each query targets the topic above but varies the angle/phrasing/breadth so the {n_queries} don't overlap.
|
|
817
|
+
- You MAY use X operators: `min_faves:N`, `OR` (inside parentheses), quoted phrases, `-excludeterm`, `lang:en`.
|
|
818
|
+
- Do NOT include `since:`, `until:`, `since_time:`, or `until_time:` — the freshness window ({FRESHNESS_HOURS}h) is applied automatically downstream.
|
|
819
|
+
- Mix breadth: at least one broad query (few/no operators) and at least one tighter query (e.g. `min_faves:5` or a quoted phrase) so we measure supply at multiple precision levels.
|
|
820
|
+
- Keep each query realistic — phrasing real users would actually tweet, not keyword salad.
|
|
821
|
+
|
|
822
|
+
## Output format
|
|
823
|
+
Return STRICT JSON only, no prose:
|
|
824
|
+
|
|
825
|
+
```json
|
|
826
|
+
{{"queries": ["query one", "query two", "... exactly {n_queries} total ..."]}}
|
|
827
|
+
```"""
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def extract_queries(claude_text: str, n_expected: int) -> list[str]:
|
|
831
|
+
"""Pull the queries[] array out of Claude's output (fenced or bare JSON)."""
|
|
832
|
+
text = (claude_text or "").strip()
|
|
833
|
+
if not text:
|
|
834
|
+
return []
|
|
835
|
+
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
|
836
|
+
candidate = m.group(1) if m else None
|
|
837
|
+
if candidate is None:
|
|
838
|
+
start = text.find("{")
|
|
839
|
+
candidate = text[start:] if start >= 0 else None
|
|
840
|
+
if not candidate:
|
|
841
|
+
return []
|
|
842
|
+
env = None
|
|
843
|
+
try:
|
|
844
|
+
env = json.loads(candidate)
|
|
845
|
+
except json.JSONDecodeError:
|
|
846
|
+
# Trim trailing prose to the matching brace.
|
|
847
|
+
depth = 0
|
|
848
|
+
for i, ch in enumerate(candidate):
|
|
849
|
+
if ch == "{":
|
|
850
|
+
depth += 1
|
|
851
|
+
elif ch == "}":
|
|
852
|
+
depth -= 1
|
|
853
|
+
if depth == 0:
|
|
854
|
+
try:
|
|
855
|
+
env = json.loads(candidate[: i + 1])
|
|
856
|
+
except json.JSONDecodeError:
|
|
857
|
+
env = None
|
|
858
|
+
break
|
|
859
|
+
if not isinstance(env, dict):
|
|
860
|
+
return []
|
|
861
|
+
out: list[str] = []
|
|
862
|
+
seen: set[str] = set()
|
|
863
|
+
for q in env.get("queries") or []:
|
|
864
|
+
if not isinstance(q, str):
|
|
865
|
+
continue
|
|
866
|
+
q = q.strip()
|
|
867
|
+
core = normalize_query(q)
|
|
868
|
+
if not q or not core or core in seen:
|
|
869
|
+
continue
|
|
870
|
+
seen.add(core)
|
|
871
|
+
out.append(q)
|
|
872
|
+
return out[:n_expected] if n_expected else out
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# --- Query dedup against history (via API) ----------------------------------
|
|
876
|
+
|
|
877
|
+
def load_existing_query_cores(project_name: str) -> set[str]:
|
|
878
|
+
"""Normalized cores of every query ever attempted for this project.
|
|
879
|
+
|
|
880
|
+
Reads /api/v1/twitter-search-attempts/distinct-queries (no direct DB).
|
|
881
|
+
Returns an empty set on API failure so a transient read error degrades to
|
|
882
|
+
'no dedup' rather than crashing the invocation (we'd rather re-test a
|
|
883
|
+
duplicate query than skip inventing entirely)."""
|
|
884
|
+
try:
|
|
885
|
+
resp = api_get(
|
|
886
|
+
"/api/v1/twitter-search-attempts/distinct-queries",
|
|
887
|
+
query={"project": project_name},
|
|
888
|
+
)
|
|
889
|
+
except SystemExit as exc:
|
|
890
|
+
print(f"[invent_topics] distinct-queries read failed for "
|
|
891
|
+
f"{project_name!r}: {exc} (proceeding without query dedup)",
|
|
892
|
+
file=sys.stderr)
|
|
893
|
+
return set()
|
|
894
|
+
queries = ((resp or {}).get("data") or {}).get("queries") or []
|
|
895
|
+
return {normalize_query(q) for q in queries if q}
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def dedup_queries(
|
|
899
|
+
drafted: list[str],
|
|
900
|
+
existing_cores: set[str],
|
|
901
|
+
) -> tuple[list[str], list[str]]:
|
|
902
|
+
"""Split drafted queries into (new, already_tried) by normalized core."""
|
|
903
|
+
new: list[str] = []
|
|
904
|
+
dupes: list[str] = []
|
|
905
|
+
seen = set(existing_cores)
|
|
906
|
+
for q in drafted:
|
|
907
|
+
core = normalize_query(q)
|
|
908
|
+
if core in seen:
|
|
909
|
+
dupes.append(q)
|
|
910
|
+
else:
|
|
911
|
+
new.append(q)
|
|
912
|
+
seen.add(core)
|
|
913
|
+
return new, dupes
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# --- Supply test (browser-harness via lock helper) --------------------------
|
|
917
|
+
|
|
918
|
+
def supply_test(
|
|
919
|
+
project_name: str,
|
|
920
|
+
topic: str,
|
|
921
|
+
queries: list[str],
|
|
922
|
+
freshness_hours: int = FRESHNESS_HOURS,
|
|
923
|
+
lock_timeout: int = LOCK_TIMEOUT_SEC,
|
|
924
|
+
) -> tuple[bool, list[dict]]:
|
|
925
|
+
"""Scan each query at `freshness_hours` via the lock+harness helper.
|
|
926
|
+
|
|
927
|
+
Returns (tested, results) where results is
|
|
928
|
+
[{"query": q, "tweets_found": n}, ...] in the SAME order as `queries`.
|
|
929
|
+
|
|
930
|
+
tested=False means the helper produced NO scan records (lock timeout, or
|
|
931
|
+
the browser went down) — the caller must NOT treat that as zero supply.
|
|
932
|
+
"""
|
|
933
|
+
if not queries:
|
|
934
|
+
return True, []
|
|
935
|
+
qpayload = [
|
|
936
|
+
{"project": project_name, "query": q, "search_topic": topic}
|
|
937
|
+
for q in queries
|
|
938
|
+
]
|
|
939
|
+
with tempfile.NamedTemporaryFile(
|
|
940
|
+
"w", suffix=".json", prefix="invent-queries-", delete=False
|
|
941
|
+
) as qf:
|
|
942
|
+
json.dump(qpayload, qf)
|
|
943
|
+
queries_path = qf.name
|
|
944
|
+
scan_out = tempfile.NamedTemporaryFile(
|
|
945
|
+
suffix=".jsonl", prefix="invent-scan-", delete=False
|
|
946
|
+
).name
|
|
947
|
+
|
|
948
|
+
try:
|
|
949
|
+
subprocess.run(
|
|
950
|
+
["bash", _SUPPLY_TEST_SH, queries_path, scan_out,
|
|
951
|
+
str(freshness_hours), str(lock_timeout)],
|
|
952
|
+
check=False,
|
|
953
|
+
timeout=lock_timeout + 600, # helper's own lock wait + scan headroom
|
|
954
|
+
)
|
|
955
|
+
except subprocess.TimeoutExpired:
|
|
956
|
+
print(f"[invent_topics] supply-test helper timed out for topic "
|
|
957
|
+
f"{topic!r}", file=sys.stderr)
|
|
958
|
+
_safe_unlink(queries_path)
|
|
959
|
+
_safe_unlink(scan_out)
|
|
960
|
+
return False, []
|
|
961
|
+
|
|
962
|
+
# Parse per-query scan records. scan() writes one record per call even on
|
|
963
|
+
# zero tweets, so an empty file means the scan loop never ran (untested).
|
|
964
|
+
found_by_core: dict[str, int] = {}
|
|
965
|
+
records = 0
|
|
966
|
+
try:
|
|
967
|
+
with open(scan_out) as f:
|
|
968
|
+
for ln in f:
|
|
969
|
+
ln = ln.strip()
|
|
970
|
+
if not ln:
|
|
971
|
+
continue
|
|
972
|
+
try:
|
|
973
|
+
rec = json.loads(ln)
|
|
974
|
+
except json.JSONDecodeError:
|
|
975
|
+
continue
|
|
976
|
+
records += 1
|
|
977
|
+
core = normalize_query(rec.get("query", ""))
|
|
978
|
+
found_by_core[core] = len(rec.get("tweets") or [])
|
|
979
|
+
except OSError:
|
|
980
|
+
records = 0
|
|
981
|
+
|
|
982
|
+
_safe_unlink(queries_path)
|
|
983
|
+
_safe_unlink(scan_out)
|
|
984
|
+
|
|
985
|
+
if records == 0:
|
|
986
|
+
return False, []
|
|
987
|
+
|
|
988
|
+
results = [
|
|
989
|
+
{"query": q, "tweets_found": int(found_by_core.get(normalize_query(q), 0))}
|
|
990
|
+
for q in queries
|
|
991
|
+
]
|
|
992
|
+
return True, results
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _safe_unlink(path: str) -> None:
|
|
996
|
+
try:
|
|
997
|
+
os.unlink(path)
|
|
998
|
+
except OSError:
|
|
999
|
+
pass
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
# --- Attempt logging (via log_twitter_search_attempts.py -> route) ----------
|
|
1003
|
+
|
|
1004
|
+
def log_attempts(
|
|
1005
|
+
project_name: str,
|
|
1006
|
+
topic: str,
|
|
1007
|
+
results: list[dict],
|
|
1008
|
+
batch_id: str,
|
|
1009
|
+
dry_run: bool = False,
|
|
1010
|
+
) -> None:
|
|
1011
|
+
"""Log every scanned query (dud + hit) to twitter_search_attempts via the
|
|
1012
|
+
existing logger script, which POSTs to /api/v1/twitter-search-attempts.
|
|
1013
|
+
|
|
1014
|
+
All attempts are logged on purpose — duds are the anti-list signal and the
|
|
1015
|
+
topic's supply record, per the user's 'log all attempts' rule."""
|
|
1016
|
+
if not results:
|
|
1017
|
+
return
|
|
1018
|
+
rows = [
|
|
1019
|
+
{
|
|
1020
|
+
"query": r["query"],
|
|
1021
|
+
"project": project_name,
|
|
1022
|
+
"tweets_found": r["tweets_found"],
|
|
1023
|
+
"search_topic": topic,
|
|
1024
|
+
}
|
|
1025
|
+
for r in results
|
|
1026
|
+
]
|
|
1027
|
+
if dry_run:
|
|
1028
|
+
print(f"[dry-run] would log {len(rows)} attempts for topic {topic!r} "
|
|
1029
|
+
f"(batch={batch_id})", file=sys.stderr)
|
|
1030
|
+
return
|
|
1031
|
+
try:
|
|
1032
|
+
subprocess.run(
|
|
1033
|
+
["python3", _LOG_ATTEMPTS_PY, "--batch-id", batch_id, "--kind", "invent"],
|
|
1034
|
+
input=json.dumps(rows),
|
|
1035
|
+
text=True,
|
|
1036
|
+
check=False,
|
|
1037
|
+
timeout=120,
|
|
1038
|
+
)
|
|
1039
|
+
except subprocess.TimeoutExpired:
|
|
1040
|
+
print(f"[invent_topics] log_attempts timed out for topic {topic!r}",
|
|
1041
|
+
file=sys.stderr)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
# --- Per-topic pipeline: draft -> dedup -> scan -> log ----------------------
|
|
1045
|
+
|
|
1046
|
+
def process_topic(
|
|
1047
|
+
project: dict,
|
|
1048
|
+
topic: str,
|
|
1049
|
+
existing_query_cores: set[str],
|
|
1050
|
+
batch_id: str,
|
|
1051
|
+
dry_run: bool = False,
|
|
1052
|
+
) -> dict:
|
|
1053
|
+
"""Run the full draft->dedup->supply-test->log pipeline for one topic.
|
|
1054
|
+
|
|
1055
|
+
Returns a result dict with: queries_drafted, queries_tested, attempts
|
|
1056
|
+
(list of {query, tweets_found}), supply_total, tested (bool), qualifies
|
|
1057
|
+
(bool). `tested=False` signals the browser was unavailable — the caller
|
|
1058
|
+
should abort the run rather than treat the topic as a dud.
|
|
1059
|
+
"""
|
|
1060
|
+
project_name = project.get("name", "")
|
|
1061
|
+
|
|
1062
|
+
# 1. Draft queries.
|
|
1063
|
+
qprompt = build_query_prompt(project, topic, QUERIES_PER_TOPIC)
|
|
1064
|
+
raw = call_claude(qprompt)
|
|
1065
|
+
drafted = extract_queries(raw, QUERIES_PER_TOPIC)
|
|
1066
|
+
print(f" [{topic}] drafted {len(drafted)} queries", file=sys.stderr)
|
|
1067
|
+
|
|
1068
|
+
# 2. Dedup against history; refill ONCE if dedup drops us below target.
|
|
1069
|
+
new_q, dupes = dedup_queries(drafted, existing_query_cores)
|
|
1070
|
+
if dupes:
|
|
1071
|
+
print(f" [{topic}] dropped {len(dupes)} already-tried queries",
|
|
1072
|
+
file=sys.stderr)
|
|
1073
|
+
if len(new_q) < QUERIES_PER_TOPIC and drafted:
|
|
1074
|
+
tried_cores = existing_query_cores | {normalize_query(q) for q in drafted}
|
|
1075
|
+
refill_prompt = build_query_prompt(
|
|
1076
|
+
project, topic, QUERIES_PER_TOPIC,
|
|
1077
|
+
avoid_queries={normalize_query(q) for q in drafted},
|
|
1078
|
+
)
|
|
1079
|
+
refill_raw = call_claude(refill_prompt)
|
|
1080
|
+
refill = extract_queries(refill_raw, QUERIES_PER_TOPIC)
|
|
1081
|
+
more_new, _ = dedup_queries(refill, tried_cores)
|
|
1082
|
+
for q in more_new:
|
|
1083
|
+
if len(new_q) >= QUERIES_PER_TOPIC:
|
|
1084
|
+
break
|
|
1085
|
+
new_q.append(q)
|
|
1086
|
+
print(f" [{topic}] refill added {min(len(more_new), QUERIES_PER_TOPIC)} "
|
|
1087
|
+
f"queries (now {len(new_q)})", file=sys.stderr)
|
|
1088
|
+
|
|
1089
|
+
queries = new_q[:QUERIES_PER_TOPIC]
|
|
1090
|
+
|
|
1091
|
+
# 3. Supply-test.
|
|
1092
|
+
if dry_run:
|
|
1093
|
+
# No browser work in dry-run; report the plan only.
|
|
1094
|
+
print(f" [dry-run] [{topic}] would scan {len(queries)} queries at "
|
|
1095
|
+
f"{FRESHNESS_HOURS}h", file=sys.stderr)
|
|
1096
|
+
return {
|
|
1097
|
+
"topic": topic, "queries_drafted": len(drafted),
|
|
1098
|
+
"queries_tested": len(queries), "attempts": [],
|
|
1099
|
+
"supply_total": 0, "tested": True, "qualifies": False,
|
|
1100
|
+
"queries": queries,
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
tested, results = supply_test(project_name, topic, queries)
|
|
1104
|
+
if not tested:
|
|
1105
|
+
return {
|
|
1106
|
+
"topic": topic, "queries_drafted": len(drafted),
|
|
1107
|
+
"queries_tested": len(queries), "attempts": [],
|
|
1108
|
+
"supply_total": 0, "tested": False, "qualifies": False,
|
|
1109
|
+
"queries": queries,
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
supply_total = sum(r["tweets_found"] for r in results)
|
|
1113
|
+
qualifies = supply_total >= SUPPLY_FLOOR
|
|
1114
|
+
for r in results:
|
|
1115
|
+
print(f" scan q={r['query'][:48]!r} -> {r['tweets_found']} fresh",
|
|
1116
|
+
file=sys.stderr)
|
|
1117
|
+
print(f" [{topic}] supply_total={supply_total} "
|
|
1118
|
+
f"(floor={SUPPLY_FLOOR}) qualifies={qualifies}", file=sys.stderr)
|
|
1119
|
+
|
|
1120
|
+
# 4. Log all attempts (dud + hit).
|
|
1121
|
+
log_attempts(project_name, topic, results, batch_id, dry_run=dry_run)
|
|
1122
|
+
|
|
1123
|
+
return {
|
|
1124
|
+
"topic": topic, "queries_drafted": len(drafted),
|
|
1125
|
+
"queries_tested": len(queries), "attempts": results,
|
|
1126
|
+
"supply_total": supply_total, "tested": True, "qualifies": qualifies,
|
|
1127
|
+
"queries": queries,
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# --- Main -------------------------------------------------------------------
|
|
1132
|
+
|
|
1133
|
+
def main():
|
|
1134
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
1135
|
+
ap.add_argument("--project", default=None,
|
|
1136
|
+
help="Force a specific project (skips pick_projects)")
|
|
1137
|
+
ap.add_argument("--proposals", type=int, default=DEFAULT_PROPOSALS,
|
|
1138
|
+
help="How many candidate topics to ask Claude for per attempt")
|
|
1139
|
+
ap.add_argument("--target", type=int, default=DEFAULT_TARGET,
|
|
1140
|
+
help="Loop until this many NEW non-dupe topics are committed")
|
|
1141
|
+
ap.add_argument("--max-attempts", type=int, default=DEFAULT_MAX_ATTEMPTS,
|
|
1142
|
+
help="Hard cap on Claude calls per run (cost guard)")
|
|
1143
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
1144
|
+
help="Print plan; do not commit to project_search_topics")
|
|
1145
|
+
ap.add_argument("--window-days", type=int, default=WINDOW_DAYS,
|
|
1146
|
+
help="Ledger window passed to /api/v1/topic-funnel")
|
|
1147
|
+
args = ap.parse_args()
|
|
1148
|
+
|
|
1149
|
+
# Propagate dry-run into the spawned claude -p / MCP server subprocess
|
|
1150
|
+
# tree so submit_topic short-circuits without POSTing during smoke tests.
|
|
1151
|
+
if args.dry_run:
|
|
1152
|
+
os.environ["INVENT_DRY_RUN"] = "1"
|
|
1153
|
+
|
|
1154
|
+
started_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
1155
|
+
t0 = time.time()
|
|
1156
|
+
|
|
1157
|
+
# --- Pick a project (same code path as the cycle) ---
|
|
1158
|
+
config = load_config()
|
|
1159
|
+
if args.project:
|
|
1160
|
+
forced = None
|
|
1161
|
+
for p in config.get("projects", []):
|
|
1162
|
+
if p.get("name", "").lower() == args.project.lower():
|
|
1163
|
+
forced = p
|
|
1164
|
+
break
|
|
1165
|
+
if not forced:
|
|
1166
|
+
raise SystemExit(f"unknown project {args.project!r}")
|
|
1167
|
+
project = forced
|
|
1168
|
+
pick_method = "forced"
|
|
1169
|
+
else:
|
|
1170
|
+
picks = pick_projects(config, platform="twitter", n=PROJECTS_PER_RUN)
|
|
1171
|
+
if not picks:
|
|
1172
|
+
raise SystemExit("pick_projects returned no eligible project")
|
|
1173
|
+
project = picks[0]
|
|
1174
|
+
pick_method = "weighted"
|
|
1175
|
+
|
|
1176
|
+
project_name = project.get("name", "")
|
|
1177
|
+
print(f"[invent_topics] project={project_name!r} (pick_method={pick_method})",
|
|
1178
|
+
file=sys.stderr)
|
|
1179
|
+
|
|
1180
|
+
# --- Load the ledger via API ---
|
|
1181
|
+
topics_for_project = load_project_topics(project_name, args.window_days)
|
|
1182
|
+
print(f"[invent_topics] ledger rows for {project_name}: {len(topics_for_project)}",
|
|
1183
|
+
file=sys.stderr)
|
|
1184
|
+
|
|
1185
|
+
# --- Read fresh universe via API ---
|
|
1186
|
+
universe = project_universe_strings(project_name)
|
|
1187
|
+
universe_size_before = len(universe)
|
|
1188
|
+
print(f"[invent_topics] active universe size: {universe_size_before}",
|
|
1189
|
+
file=sys.stderr)
|
|
1190
|
+
|
|
1191
|
+
# --- Probe the managed Chrome BEFORE spending Claude tokens. A down
|
|
1192
|
+
# browser would make every supply scan return 0 and we'd commit real
|
|
1193
|
+
# topics as false 'duds'. Dry-run skips the probe (no scans run). ----
|
|
1194
|
+
if not args.dry_run and not harness_alive():
|
|
1195
|
+
print(f"[invent_topics] managed Chrome CDP port {CDP_PORT} is not "
|
|
1196
|
+
f"answering; skipping this run (no tokens spent).",
|
|
1197
|
+
file=sys.stderr)
|
|
1198
|
+
return
|
|
1199
|
+
|
|
1200
|
+
# --- Query-dedup corpus: every distinct query ever tried for this project,
|
|
1201
|
+
# normalized to cores. Loaded once; process_topic dedups against it and
|
|
1202
|
+
# we fold each tested topic's queries back in so a later attempt this
|
|
1203
|
+
# run won't re-draft the same cores. -------------------------------
|
|
1204
|
+
existing_query_cores = load_existing_query_cores(project_name)
|
|
1205
|
+
print(f"[invent_topics] existing query cores for {project_name}: "
|
|
1206
|
+
f"{len(existing_query_cores)}", file=sys.stderr)
|
|
1207
|
+
|
|
1208
|
+
# --- batch_id ties every attempt logged this run together in
|
|
1209
|
+
# twitter_search_attempts, mirroring the cycle's batch convention. ---
|
|
1210
|
+
batch_id = f"invent-{project_name}-{int(time.time())}"
|
|
1211
|
+
|
|
1212
|
+
# --- Retry loop: invent ONE topic, draft + dedup + supply-test its
|
|
1213
|
+
# queries, log ALL attempts (dud + hit), ALWAYS commit the topic (even
|
|
1214
|
+
# at 0 supply — the topic is a real concept; its supply record lives in
|
|
1215
|
+
# twitter_search_attempts), and count it toward TARGET only if its
|
|
1216
|
+
# queries cleared SUPPLY_FLOOR. Stop when TARGET qualifying topics land
|
|
1217
|
+
# or MAX_ATTEMPTS iterations run out. A mid-run browser drop ('tested'
|
|
1218
|
+
# False) aborts the run rather than committing false duds. -----------
|
|
1219
|
+
# working_universe grows as we commit so later attempts dedupe against
|
|
1220
|
+
# both the original universe AND topics minted earlier this run.
|
|
1221
|
+
working_universe = set(universe)
|
|
1222
|
+
# avoid_topics carries every proposal seen so far back into the next prompt
|
|
1223
|
+
# as an explicit do-not-repeat list, so each retry explores new ground.
|
|
1224
|
+
avoid_topics: set[str] = set()
|
|
1225
|
+
|
|
1226
|
+
processed: list[dict] = [] # one entry per topic we supply-tested
|
|
1227
|
+
all_rejected: list[dict] = [] # filled by submit_topic dupe errors reported by Claude
|
|
1228
|
+
total_proposals_parsed = 0 # kept for audit compatibility; counts successful submits
|
|
1229
|
+
last_raw = ""
|
|
1230
|
+
attempts_used = 0 # SCANS performed (the only thing that ticks up to max_attempts)
|
|
1231
|
+
claude_calls = 0 # ALL Claude sessions (tool calls hidden inside each session)
|
|
1232
|
+
dupe_retries_total = 0 # kept for audit compatibility; always 0 in MCP-session mode
|
|
1233
|
+
aborted_untested = False
|
|
1234
|
+
saturated_bailout = False
|
|
1235
|
+
|
|
1236
|
+
def n_qualifying() -> int:
|
|
1237
|
+
return sum(1 for p in processed if p.get("qualifies"))
|
|
1238
|
+
|
|
1239
|
+
while attempts_used < args.max_attempts:
|
|
1240
|
+
if n_qualifying() >= args.target:
|
|
1241
|
+
break
|
|
1242
|
+
|
|
1243
|
+
# --- ONE tool-using Claude session per scan slot. Inside this session
|
|
1244
|
+
# Claude can call search_topics / get_topic_stats to explore, and
|
|
1245
|
+
# submit_topic to commit. submit_topic runs Jaccard dedup on the
|
|
1246
|
+
# server, so near-dupes surface as in-session tool errors Claude
|
|
1247
|
+
# reacts to — no post-hoc kill, no dupe-retry-doesn't-count
|
|
1248
|
+
# bookkeeping needed in Python. The session ends when Claude
|
|
1249
|
+
# emits the final JSON envelope with `submitted_topic`. -----------
|
|
1250
|
+
prompt = build_prompt(project, topics_for_project, args.proposals,
|
|
1251
|
+
avoid_topics=avoid_topics)
|
|
1252
|
+
last_raw = call_claude(prompt, allowed_tools=_TOPIC_TOOLS)
|
|
1253
|
+
claude_calls += 1
|
|
1254
|
+
|
|
1255
|
+
envelope = extract_submitted_topic(last_raw)
|
|
1256
|
+
if envelope is None:
|
|
1257
|
+
print(f"[invent_topics] session returned no parseable envelope; "
|
|
1258
|
+
f"raw head: {(last_raw or '')[:200]!r}", file=sys.stderr)
|
|
1259
|
+
saturated_bailout = True
|
|
1260
|
+
break
|
|
1261
|
+
|
|
1262
|
+
if envelope.get("topic") is None:
|
|
1263
|
+
# Claude self-reported saturation (tried N submits, all dupes).
|
|
1264
|
+
reason = envelope.get("reason") or "(no reason given)"
|
|
1265
|
+
print(f"[invent_topics] saturated: claude session reports "
|
|
1266
|
+
f"no non-dupe available — {reason}",
|
|
1267
|
+
file=sys.stderr)
|
|
1268
|
+
saturated_bailout = True
|
|
1269
|
+
break
|
|
1270
|
+
|
|
1271
|
+
topic = envelope["topic"]
|
|
1272
|
+
rationale = envelope.get("rationale", "")
|
|
1273
|
+
total_proposals_parsed += 1
|
|
1274
|
+
avoid_topics.add(topic)
|
|
1275
|
+
working_universe.add(topic)
|
|
1276
|
+
print(f"[invent_topics] scan {attempts_used+1}/{args.max_attempts}: "
|
|
1277
|
+
f"submitted {topic!r} (qualifying so far={n_qualifying()}/{args.target})",
|
|
1278
|
+
file=sys.stderr)
|
|
1279
|
+
print(f" rationale: {rationale[:120]}", file=sys.stderr)
|
|
1280
|
+
|
|
1281
|
+
# The topic is ALREADY in project_search_topics via the submit_topic
|
|
1282
|
+
# tool — do not re-commit. Now draft queries + supply-test.
|
|
1283
|
+
result = process_topic(project, topic, existing_query_cores,
|
|
1284
|
+
batch_id, dry_run=args.dry_run)
|
|
1285
|
+
|
|
1286
|
+
# A False 'tested' means the browser dropped mid-run; abort the run
|
|
1287
|
+
# and let the next hourly retry it. The topic stays committed (it's a
|
|
1288
|
+
# real concept either way), but we don't fabricate a supply verdict.
|
|
1289
|
+
if not result.get("tested", False):
|
|
1290
|
+
print(f"[invent_topics] supply test UNTESTED for {topic!r} "
|
|
1291
|
+
f"(browser unavailable); aborting run.", file=sys.stderr)
|
|
1292
|
+
aborted_untested = True
|
|
1293
|
+
break
|
|
1294
|
+
|
|
1295
|
+
# This was a real supply test — count it against max_attempts.
|
|
1296
|
+
attempts_used += 1
|
|
1297
|
+
|
|
1298
|
+
# Fold this topic's tested query cores into the dedup corpus.
|
|
1299
|
+
for q in result.get("queries", []):
|
|
1300
|
+
existing_query_cores.add(normalize_query(q))
|
|
1301
|
+
|
|
1302
|
+
processed.append({
|
|
1303
|
+
"topic": topic,
|
|
1304
|
+
"rationale": rationale,
|
|
1305
|
+
**result,
|
|
1306
|
+
"committed": True, # submit_topic already wrote it
|
|
1307
|
+
"attempt": attempts_used,
|
|
1308
|
+
})
|
|
1309
|
+
|
|
1310
|
+
print(f" supply={result['supply_total']} qualifies={result['qualifies']}",
|
|
1311
|
+
file=sys.stderr)
|
|
1312
|
+
|
|
1313
|
+
if n_qualifying() >= args.target:
|
|
1314
|
+
break
|
|
1315
|
+
|
|
1316
|
+
target_met = n_qualifying() >= args.target
|
|
1317
|
+
|
|
1318
|
+
# --- Audit row (via API; no local file) ---
|
|
1319
|
+
elapsed = round(time.time() - t0, 2)
|
|
1320
|
+
audit_payload = {
|
|
1321
|
+
"ts": started_at,
|
|
1322
|
+
"elapsed_sec": elapsed,
|
|
1323
|
+
"project": project_name,
|
|
1324
|
+
"pick_method": pick_method,
|
|
1325
|
+
"batch_id": batch_id,
|
|
1326
|
+
"ledger_rows_for_project": len(topics_for_project),
|
|
1327
|
+
"universe_size_before": universe_size_before,
|
|
1328
|
+
"proposals_requested": args.proposals,
|
|
1329
|
+
"target": args.target,
|
|
1330
|
+
"max_attempts": args.max_attempts,
|
|
1331
|
+
"attempts_used": attempts_used,
|
|
1332
|
+
"claude_calls": claude_calls,
|
|
1333
|
+
"dupe_retries_total": dupe_retries_total,
|
|
1334
|
+
"saturated_bailout": saturated_bailout,
|
|
1335
|
+
"target_met": target_met,
|
|
1336
|
+
"aborted_untested": aborted_untested,
|
|
1337
|
+
"proposals_parsed": total_proposals_parsed,
|
|
1338
|
+
"supply_floor": SUPPLY_FLOOR,
|
|
1339
|
+
"queries_per_topic": QUERIES_PER_TOPIC,
|
|
1340
|
+
"freshness_hours": FRESHNESS_HOURS,
|
|
1341
|
+
"processed": [
|
|
1342
|
+
{
|
|
1343
|
+
"topic": p["topic"],
|
|
1344
|
+
"committed": p.get("committed"),
|
|
1345
|
+
"qualifies": p.get("qualifies"),
|
|
1346
|
+
"supply_total": p.get("supply_total"),
|
|
1347
|
+
"queries_drafted": p.get("queries_drafted"),
|
|
1348
|
+
"queries_tested": p.get("queries_tested"),
|
|
1349
|
+
"attempt": p.get("attempt"),
|
|
1350
|
+
"neighbor": p.get("neighbor"),
|
|
1351
|
+
"similarity": p.get("similarity"),
|
|
1352
|
+
"attempts": p.get("attempts"),
|
|
1353
|
+
}
|
|
1354
|
+
for p in processed
|
|
1355
|
+
],
|
|
1356
|
+
"rejected": all_rejected,
|
|
1357
|
+
"dry_run": args.dry_run,
|
|
1358
|
+
"raw_response_head": (last_raw or "")[:500],
|
|
1359
|
+
}
|
|
1360
|
+
write_audit(audit_payload, dry_run=args.dry_run)
|
|
1361
|
+
|
|
1362
|
+
n_qual = n_qualifying()
|
|
1363
|
+
n_committed_topics = sum(1 for p in processed if p.get("committed"))
|
|
1364
|
+
print(f"[invent_topics] done. project={project_name!r} "
|
|
1365
|
+
f"scans={attempts_used}/{args.max_attempts} "
|
|
1366
|
+
f"claude_calls={claude_calls} dupe_retries={dupe_retries_total} "
|
|
1367
|
+
f"target={args.target} target_met={target_met} "
|
|
1368
|
+
f"saturated_bailout={saturated_bailout} "
|
|
1369
|
+
f"aborted_untested={aborted_untested} "
|
|
1370
|
+
f"proposals={total_proposals_parsed} "
|
|
1371
|
+
f"topics_committed={n_committed_topics} qualifying={n_qual} "
|
|
1372
|
+
f"rejected={len(all_rejected)} elapsed={elapsed}s",
|
|
1373
|
+
file=sys.stderr)
|
|
1374
|
+
|
|
1375
|
+
# --- Surface this run in the dashboard's Status > Job History tab via
|
|
1376
|
+
# log_run.py + run_monitor.log. Skip on dry-run so smoke tests don't
|
|
1377
|
+
# leak fake rows into the dashboard. ----------------------------------
|
|
1378
|
+
if not args.dry_run:
|
|
1379
|
+
_emit_run_monitor_row(
|
|
1380
|
+
project_name=project_name,
|
|
1381
|
+
processed=processed,
|
|
1382
|
+
attempts_used=attempts_used,
|
|
1383
|
+
aborted_untested=aborted_untested,
|
|
1384
|
+
saturated_bailout=saturated_bailout,
|
|
1385
|
+
elapsed_sec=elapsed,
|
|
1386
|
+
)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _emit_run_monitor_row(
|
|
1390
|
+
project_name: str,
|
|
1391
|
+
processed: list[dict],
|
|
1392
|
+
attempts_used: int,
|
|
1393
|
+
aborted_untested: bool,
|
|
1394
|
+
saturated_bailout: bool,
|
|
1395
|
+
elapsed_sec: float,
|
|
1396
|
+
) -> None:
|
|
1397
|
+
"""Call scripts/log_run.py so the invent run lands in run_monitor.log and
|
|
1398
|
+
the dashboard's Status > Job History tab surfaces it under the
|
|
1399
|
+
'Invent Topics' filter pill. Best-effort: any failure is logged and
|
|
1400
|
+
swallowed — we never want a dashboard-write hiccup to mask the actual
|
|
1401
|
+
run output."""
|
|
1402
|
+
# Per-topic query counts (parallel array to topic_names; joined with '+').
|
|
1403
|
+
qpt = [int(p.get("queries_tested", 0)) for p in processed]
|
|
1404
|
+
queries_total = sum(qpt)
|
|
1405
|
+
queries_w_supply = sum(
|
|
1406
|
+
1
|
|
1407
|
+
for p in processed
|
|
1408
|
+
for attempt in (p.get("attempts") or [])
|
|
1409
|
+
if (attempt.get("tweets_found") or 0) > 0
|
|
1410
|
+
)
|
|
1411
|
+
topics_invented = len(processed)
|
|
1412
|
+
n_qual = sum(1 for p in processed if p.get("qualifies"))
|
|
1413
|
+
# Skipped = topics tested but didn't qualify. Failed = 1 iff the run
|
|
1414
|
+
# aborted partway (browser drop); harmless 0 otherwise.
|
|
1415
|
+
skipped = max(topics_invented - n_qual, 0)
|
|
1416
|
+
failed = 1 if aborted_untested else 0
|
|
1417
|
+
|
|
1418
|
+
# topic_names: parallel-to-qpt array of the actual topics committed this
|
|
1419
|
+
# run. Encode each name so it can't break the run_monitor.log segment
|
|
1420
|
+
# parser: replace spaces with '+', strip the four chars that have
|
|
1421
|
+
# structural meaning in the log line (',', ';', '=', '|'). Decoded
|
|
1422
|
+
# client-side in server.js. Empty list = no topics committed = no per-
|
|
1423
|
+
# topic pills get rendered (e.g. saturated runs).
|
|
1424
|
+
def _encode_topic_name(t: str) -> str:
|
|
1425
|
+
encoded = (t or "").strip().replace(" ", "+")
|
|
1426
|
+
for ch in (",", ";", "=", "|"):
|
|
1427
|
+
encoded = encoded.replace(ch, "")
|
|
1428
|
+
return encoded
|
|
1429
|
+
|
|
1430
|
+
topic_names = [_encode_topic_name(p.get("topic", "")) for p in processed]
|
|
1431
|
+
topic_names = [t for t in topic_names if t]
|
|
1432
|
+
topic_names_segment = (
|
|
1433
|
+
f",topic_names={';'.join(topic_names)}" if topic_names else ""
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
invent_kv = ",".join([
|
|
1437
|
+
f"project={project_name}",
|
|
1438
|
+
f"topics={topics_invented}",
|
|
1439
|
+
f"queries={queries_total}",
|
|
1440
|
+
f"queries_w_supply={queries_w_supply}",
|
|
1441
|
+
f"qpt={'+'.join(str(x) for x in qpt) if qpt else '0'}",
|
|
1442
|
+
]) + topic_names_segment
|
|
1443
|
+
log_run_py = os.path.join(_REPO_DIR, "scripts", "log_run.py")
|
|
1444
|
+
cmd = [
|
|
1445
|
+
"/opt/homebrew/bin/python3.11", log_run_py,
|
|
1446
|
+
"--script", "invent_topics",
|
|
1447
|
+
"--posted", str(topics_invented),
|
|
1448
|
+
"--skipped", str(skipped),
|
|
1449
|
+
"--failed", str(failed),
|
|
1450
|
+
"--cost", "0", # claude -p inherits cost tracking; not surfaced per-call
|
|
1451
|
+
"--elapsed", str(int(elapsed_sec)),
|
|
1452
|
+
"--invent", invent_kv,
|
|
1453
|
+
]
|
|
1454
|
+
try:
|
|
1455
|
+
subprocess.run(cmd, check=False, timeout=30,
|
|
1456
|
+
capture_output=True, text=True)
|
|
1457
|
+
except (subprocess.TimeoutExpired, OSError) as exc:
|
|
1458
|
+
print(f"[invent_topics] log_run.py emit failed: {exc}", file=sys.stderr)
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
if __name__ == "__main__":
|
|
1462
|
+
main()
|