social-autoposter 1.3.4 → 1.3.5
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/bin/server.js +128 -3
- package/package.json +1 -1
- package/scripts/active_campaigns.py +43 -3
- package/scripts/campaign_bump.py +34 -11
- package/scripts/check_external_pool_depth.py +256 -0
- package/scripts/get_run_cost.py +56 -27
- package/scripts/top_performers.py +122 -62
package/bin/server.js
CHANGED
|
@@ -5438,20 +5438,44 @@ async function handleApi(req, res) {
|
|
|
5438
5438
|
};
|
|
5439
5439
|
}).sort((a, b) => b.weight - a.weight || a.name.localeCompare(b.name));
|
|
5440
5440
|
// Surface any posts that didn't match a weighted project, so the matrix adds up.
|
|
5441
|
+
// configured = the project name is in config.json (just at weight 0), so the
|
|
5442
|
+
// weight editor stays available. unconfigured rows come from stale project
|
|
5443
|
+
// names found in post rows only and aren't editable here.
|
|
5441
5444
|
const knownNames = new Set(weighted.map(p => p.name));
|
|
5445
|
+
const configuredNames = new Set(configuredProjects.map(p => p.name));
|
|
5446
|
+
const configuredByName = Object.fromEntries(configuredProjects.map(p => [p.name, p]));
|
|
5442
5447
|
const unassigned = Object.entries(byProject)
|
|
5443
5448
|
.filter(([name]) => !knownNames.has(name))
|
|
5444
5449
|
.map(([name, stats]) => ({
|
|
5445
5450
|
name,
|
|
5446
|
-
weight: 0,
|
|
5451
|
+
weight: Number((configuredByName[name] || {}).weight) || 0,
|
|
5447
5452
|
target_share: 0,
|
|
5448
5453
|
total: stats.total,
|
|
5449
5454
|
actual_share: grandTotal > 0 ? stats.total / grandTotal : 0,
|
|
5450
5455
|
deficit: -(grandTotal > 0 ? stats.total / grandTotal : 0),
|
|
5451
5456
|
by_platform: Object.fromEntries(platforms.map(pl => [pl, stats.by_platform[pl] || 0])),
|
|
5452
|
-
website: null,
|
|
5457
|
+
website: (configuredByName[name] && configuredByName[name].website) || null,
|
|
5453
5458
|
unassigned: true,
|
|
5459
|
+
configured: configuredNames.has(name),
|
|
5454
5460
|
}));
|
|
5461
|
+
// Also include configured projects with weight=0 and zero posts in the
|
|
5462
|
+
// window, so an operator can lift them back up from the table.
|
|
5463
|
+
configuredProjects.forEach(cp => {
|
|
5464
|
+
if (knownNames.has(cp.name)) return;
|
|
5465
|
+
if (byProject[cp.name]) return;
|
|
5466
|
+
unassigned.push({
|
|
5467
|
+
name: cp.name,
|
|
5468
|
+
weight: Number(cp.weight) || 0,
|
|
5469
|
+
target_share: 0,
|
|
5470
|
+
total: 0,
|
|
5471
|
+
actual_share: 0,
|
|
5472
|
+
deficit: 0,
|
|
5473
|
+
by_platform: Object.fromEntries(platforms.map(pl => [pl, 0])),
|
|
5474
|
+
website: cp.website || null,
|
|
5475
|
+
unassigned: true,
|
|
5476
|
+
configured: true,
|
|
5477
|
+
});
|
|
5478
|
+
});
|
|
5455
5479
|
// Per-project Claude cost in the same window. Mirrors /api/cost/stats
|
|
5456
5480
|
// attribution: per_row_cost = COALESCE(orchestrator_cost_usd,
|
|
5457
5481
|
// total_cost_usd) / rows_in_session, summed across the activity rows
|
|
@@ -5572,11 +5596,44 @@ async function handleApi(req, res) {
|
|
|
5572
5596
|
grand_cost_usd_orchestrator: grandCostOrch,
|
|
5573
5597
|
grand_cost_usd_estimated: grandCostEst,
|
|
5574
5598
|
cost_available: !!(req.user && req.user.admin),
|
|
5599
|
+
can_edit_weight: !auth.CLIENT_MODE && !!(req.user && req.user.admin),
|
|
5575
5600
|
projects,
|
|
5576
5601
|
unassigned,
|
|
5577
5602
|
});
|
|
5578
5603
|
}
|
|
5579
5604
|
|
|
5605
|
+
// POST /api/project/weight - update one project's weight in config.json.
|
|
5606
|
+
// Admin only and disabled in CLIENT_MODE (no config.json on Cloud Run).
|
|
5607
|
+
// Body: { name: string, weight: number }. Weight must be a non-negative
|
|
5608
|
+
// finite number. The picker reads config.json directly on each pick, so no
|
|
5609
|
+
// restart or cache bust is needed.
|
|
5610
|
+
if (p === '/api/project/weight' && req.method === 'POST') {
|
|
5611
|
+
if (auth.CLIENT_MODE) return json(res, { error: 'config_readonly_in_client_mode' }, 405);
|
|
5612
|
+
if (!req.user || !req.user.admin) return json(res, { error: 'forbidden' }, 403);
|
|
5613
|
+
return readBody(req).then(body => {
|
|
5614
|
+
let payload;
|
|
5615
|
+
try { payload = JSON.parse(body); }
|
|
5616
|
+
catch { return json(res, { error: 'invalid_json' }, 400); }
|
|
5617
|
+
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
|
5618
|
+
const weight = Number(payload.weight);
|
|
5619
|
+
if (!name) return json(res, { error: 'name_required' }, 400);
|
|
5620
|
+
if (!Number.isFinite(weight) || weight < 0 || weight > 1e6) {
|
|
5621
|
+
return json(res, { error: 'invalid_weight' }, 400);
|
|
5622
|
+
}
|
|
5623
|
+
let config;
|
|
5624
|
+
try { config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
|
|
5625
|
+
catch (e) { return json(res, { error: 'config_read_failed', detail: e.message }, 500); }
|
|
5626
|
+
const projects = Array.isArray(config.projects) ? config.projects : [];
|
|
5627
|
+
const target = projects.find(pr => pr && pr.name === name);
|
|
5628
|
+
if (!target) return json(res, { error: 'project_not_found', name }, 404);
|
|
5629
|
+
const previous = Number(target.weight) || 0;
|
|
5630
|
+
target.weight = weight;
|
|
5631
|
+
try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n'); }
|
|
5632
|
+
catch (e) { return json(res, { error: 'config_write_failed', detail: e.message }, 500); }
|
|
5633
|
+
return json(res, { saved: true, name, weight, previous });
|
|
5634
|
+
}).catch(e => json(res, { error: e.message }, 400));
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5580
5637
|
// GET /api/deploy/status - latest Vercel production deploy per project.
|
|
5581
5638
|
// Written every ~5 min to skill/cache/deploy_status.json by launchd
|
|
5582
5639
|
// com.m13v.social-deploy-status (scripts/project_deploy_status.py). If the
|
|
@@ -13529,6 +13586,7 @@ function renderProjectStatus(data) {
|
|
|
13529
13586
|
const grandTotal = Number(data && data.grand_total) || 0;
|
|
13530
13587
|
const totals = (data && data.platform_totals) || {};
|
|
13531
13588
|
const costAvailable = !!(data && data.cost_available);
|
|
13589
|
+
const canEditWeight = !!(data && data.can_edit_weight);
|
|
13532
13590
|
const grandCost = Number(data && data.grand_cost_usd) || 0;
|
|
13533
13591
|
const grandCostOrch = Number(data && data.grand_cost_usd_orchestrator) || 0;
|
|
13534
13592
|
const grandCostEst = Number(data && data.grand_cost_usd_estimated) || 0;
|
|
@@ -13635,9 +13693,21 @@ function renderProjectStatus(data) {
|
|
|
13635
13693
|
const costCellHtml = costAvailable
|
|
13636
13694
|
? costCell(Number(r.cost_usd) || 0, Number(r.cost_usd_orchestrator) || 0, Number(r.cost_usd_estimated) || 0, { extra: 'color:var(--text-secondary);' })
|
|
13637
13695
|
: '';
|
|
13696
|
+
const weightVal = Number(r.weight) || 0;
|
|
13697
|
+
const editable = canEditWeight && (!r.unassigned || r.configured);
|
|
13698
|
+
const weightCellHtml = editable
|
|
13699
|
+
? '<td style="text-align:right;font-variant-numeric:tabular-nums;">' +
|
|
13700
|
+
'<input type="number" min="0" step="1" value="' + weightVal + '" ' +
|
|
13701
|
+
'data-project-weight-input="' + escapeHtml(r.name) + '" ' +
|
|
13702
|
+
'data-original-weight="' + weightVal + '" ' +
|
|
13703
|
+
'class="project-weight-input" ' +
|
|
13704
|
+
'style="width:60px;text-align:right;font-variant-numeric:tabular-nums;padding:2px 6px;border:1px solid var(--border);background:var(--bg-subtle,transparent);color:var(--text);border-radius:4px;font-size:13px;font-weight:600;" ' +
|
|
13705
|
+
'title="Edit and press Enter or blur to save" />' +
|
|
13706
|
+
'</td>'
|
|
13707
|
+
: '<td style="text-align:right;font-variant-numeric:tabular-nums;">' + weightVal + '</td>';
|
|
13638
13708
|
return '<tr>' +
|
|
13639
13709
|
'<td style="text-align:left;font-weight:600;">' + nameLabel + '</td>' +
|
|
13640
|
-
|
|
13710
|
+
weightCellHtml +
|
|
13641
13711
|
'<td style="text-align:right;font-variant-numeric:tabular-nums;color:var(--text-muted);">' + (r.unassigned ? '—' : formatPct(r.target_share)) + '</td>' +
|
|
13642
13712
|
platformCells +
|
|
13643
13713
|
totalCell +
|
|
@@ -13668,6 +13738,61 @@ function renderProjectStatus(data) {
|
|
|
13668
13738
|
'<tbody>' + bodyRows + footerHtml + '</tbody>' +
|
|
13669
13739
|
'</table>' +
|
|
13670
13740
|
'</div>' + legend;
|
|
13741
|
+
if (canEditWeight) {
|
|
13742
|
+
body.querySelectorAll('input.project-weight-input').forEach(inp => {
|
|
13743
|
+
inp.addEventListener('keydown', e => {
|
|
13744
|
+
if (e.key === 'Enter') { e.preventDefault(); inp.blur(); }
|
|
13745
|
+
else if (e.key === 'Escape') {
|
|
13746
|
+
inp.value = inp.dataset.originalWeight || '0';
|
|
13747
|
+
inp.blur();
|
|
13748
|
+
}
|
|
13749
|
+
});
|
|
13750
|
+
inp.addEventListener('blur', () => saveProjectWeight(inp));
|
|
13751
|
+
});
|
|
13752
|
+
}
|
|
13753
|
+
}
|
|
13754
|
+
async function saveProjectWeight(inp) {
|
|
13755
|
+
const name = inp.dataset.projectWeightInput;
|
|
13756
|
+
const original = Number(inp.dataset.originalWeight) || 0;
|
|
13757
|
+
const raw = inp.value.trim();
|
|
13758
|
+
const next = Number(raw);
|
|
13759
|
+
if (!name) return;
|
|
13760
|
+
if (raw === '' || !Number.isFinite(next) || next < 0) {
|
|
13761
|
+
inp.value = String(original);
|
|
13762
|
+
return;
|
|
13763
|
+
}
|
|
13764
|
+
if (Math.trunc(next) === Math.trunc(original) && next === original) return;
|
|
13765
|
+
inp.disabled = true;
|
|
13766
|
+
const prevBorder = inp.style.borderColor;
|
|
13767
|
+
inp.style.borderColor = 'var(--text-muted)';
|
|
13768
|
+
try {
|
|
13769
|
+
const res = await fetch('/api/project/weight', {
|
|
13770
|
+
method: 'POST',
|
|
13771
|
+
headers: { 'Content-Type': 'application/json' },
|
|
13772
|
+
body: JSON.stringify({ name, weight: next }),
|
|
13773
|
+
});
|
|
13774
|
+
const data = await res.json().catch(() => ({}));
|
|
13775
|
+
if (!res.ok || data.error) {
|
|
13776
|
+
inp.value = String(original);
|
|
13777
|
+
inp.style.borderColor = '#b91c1c';
|
|
13778
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
|
|
13779
|
+
console.error('[project-weight] save failed', data);
|
|
13780
|
+
return;
|
|
13781
|
+
}
|
|
13782
|
+
inp.dataset.originalWeight = String(next);
|
|
13783
|
+
inp.style.borderColor = '#15803d';
|
|
13784
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 800);
|
|
13785
|
+
try { window.posthog && window.posthog.capture('project_weight_edit', { project: name, weight: next, previous: original }); } catch (er) {}
|
|
13786
|
+
_projectStatusLoading = false;
|
|
13787
|
+
loadProjectStatus(true);
|
|
13788
|
+
} catch (e) {
|
|
13789
|
+
inp.value = String(original);
|
|
13790
|
+
inp.style.borderColor = '#b91c1c';
|
|
13791
|
+
setTimeout(() => { inp.style.borderColor = prevBorder; }, 1500);
|
|
13792
|
+
console.error('[project-weight] save error', e);
|
|
13793
|
+
} finally {
|
|
13794
|
+
inp.disabled = false;
|
|
13795
|
+
}
|
|
13671
13796
|
}
|
|
13672
13797
|
async function refreshAllData() {
|
|
13673
13798
|
const icon = document.getElementById('global-refresh-icon');
|
package/package.json
CHANGED
|
@@ -20,10 +20,39 @@ import os
|
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
22
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
-
import db
|
|
24
23
|
|
|
25
24
|
|
|
26
|
-
def
|
|
25
|
+
def _get_active_via_api(platform):
|
|
26
|
+
from http_api import api_get
|
|
27
|
+
resp = api_get(
|
|
28
|
+
"/api/v1/campaigns",
|
|
29
|
+
query={
|
|
30
|
+
"status": "active",
|
|
31
|
+
"platform": platform,
|
|
32
|
+
"with_budget_remaining": "true",
|
|
33
|
+
"limit": 500,
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
rows = ((resp or {}).get("data") or {}).get("campaigns") or []
|
|
37
|
+
out = []
|
|
38
|
+
for r in rows:
|
|
39
|
+
max_total = r.get("max_posts_total")
|
|
40
|
+
posts_made = r.get("posts_made") or 0
|
|
41
|
+
if max_total is None or posts_made >= max_total:
|
|
42
|
+
continue
|
|
43
|
+
out.append({
|
|
44
|
+
"id": int(r["id"]),
|
|
45
|
+
"name": r.get("name"),
|
|
46
|
+
"prompt": r.get("prompt"),
|
|
47
|
+
"max_posts_total": int(max_total),
|
|
48
|
+
"posts_made": int(posts_made),
|
|
49
|
+
"remaining": int(max_total) - int(posts_made),
|
|
50
|
+
})
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_active_via_neon(platform):
|
|
55
|
+
import db
|
|
27
56
|
conn = db.get_conn()
|
|
28
57
|
try:
|
|
29
58
|
cur = conn.execute(
|
|
@@ -41,7 +70,6 @@ def get_active_campaigns(platform):
|
|
|
41
70
|
rows = cur.fetchall()
|
|
42
71
|
finally:
|
|
43
72
|
conn.close()
|
|
44
|
-
|
|
45
73
|
return [
|
|
46
74
|
{
|
|
47
75
|
"id": r[0],
|
|
@@ -55,6 +83,18 @@ def get_active_campaigns(platform):
|
|
|
55
83
|
]
|
|
56
84
|
|
|
57
85
|
|
|
86
|
+
def get_active_campaigns(platform):
|
|
87
|
+
"""Active campaigns for `platform` with budget remaining.
|
|
88
|
+
|
|
89
|
+
Routes through /api/v1/campaigns by default so VMs without
|
|
90
|
+
DATABASE_URL still get the active list. Set
|
|
91
|
+
SOCIAL_AUTOPOSTER_LEGACY_NEON=1 for the direct-Neon path.
|
|
92
|
+
"""
|
|
93
|
+
if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
|
|
94
|
+
return _get_active_via_neon(platform)
|
|
95
|
+
return _get_active_via_api(platform)
|
|
96
|
+
|
|
97
|
+
|
|
58
98
|
def format_prompt_block(campaigns, repo_dir):
|
|
59
99
|
if not campaigns:
|
|
60
100
|
return ""
|
package/scripts/campaign_bump.py
CHANGED
|
@@ -9,6 +9,9 @@ Usage:
|
|
|
9
9
|
The named row's campaign_id column is set to the given campaign, and the
|
|
10
10
|
campaign's posts_made counter advances by one. Idempotent: if the row already
|
|
11
11
|
references this campaign, no counter bump happens.
|
|
12
|
+
|
|
13
|
+
Routes through /api/v1/campaigns/bump by default. Set
|
|
14
|
+
SOCIAL_AUTOPOSTER_LEGACY_NEON=1 to use direct Neon.
|
|
12
15
|
"""
|
|
13
16
|
|
|
14
17
|
import argparse
|
|
@@ -16,37 +19,57 @@ import os
|
|
|
16
19
|
import sys
|
|
17
20
|
|
|
18
21
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
-
import db
|
|
20
22
|
|
|
21
23
|
ALLOWED_TABLES = {"posts", "replies", "dm_messages"}
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
def _bump_via_api(table, row_id, campaign_id):
|
|
27
|
+
from http_api import api_post
|
|
28
|
+
resp = api_post(
|
|
29
|
+
"/api/v1/campaigns/bump",
|
|
30
|
+
{"table": table, "id": int(row_id), "campaign_id": int(campaign_id)},
|
|
31
|
+
)
|
|
32
|
+
data = (resp or {}).get("data") or {}
|
|
33
|
+
bumped = bool(data.get("bumped"))
|
|
34
|
+
return bumped
|
|
30
35
|
|
|
36
|
+
|
|
37
|
+
def _bump_via_neon(table, row_id, campaign_id):
|
|
38
|
+
import db
|
|
31
39
|
conn = db.get_conn()
|
|
32
40
|
try:
|
|
33
41
|
cur = conn.execute(
|
|
34
|
-
f"UPDATE {
|
|
42
|
+
f"UPDATE {table} SET campaign_id = %s "
|
|
35
43
|
f"WHERE id = %s AND (campaign_id IS NULL OR campaign_id <> %s) "
|
|
36
44
|
f"RETURNING id",
|
|
37
|
-
[
|
|
45
|
+
[campaign_id, row_id, campaign_id],
|
|
38
46
|
)
|
|
39
47
|
bumped = cur.fetchone() is not None
|
|
40
48
|
if bumped:
|
|
41
49
|
conn.execute(
|
|
42
50
|
"UPDATE campaigns SET posts_made = posts_made + 1, updated_at = NOW() "
|
|
43
51
|
"WHERE id = %s",
|
|
44
|
-
[
|
|
52
|
+
[campaign_id],
|
|
45
53
|
)
|
|
46
54
|
conn.commit()
|
|
47
|
-
|
|
55
|
+
return bumped
|
|
48
56
|
finally:
|
|
49
57
|
conn.close()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main():
|
|
61
|
+
ap = argparse.ArgumentParser()
|
|
62
|
+
ap.add_argument("--table", required=True, choices=sorted(ALLOWED_TABLES))
|
|
63
|
+
ap.add_argument("--id", type=int, required=True)
|
|
64
|
+
ap.add_argument("--campaign-id", type=int, required=True)
|
|
65
|
+
args = ap.parse_args()
|
|
66
|
+
|
|
67
|
+
if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
|
|
68
|
+
bumped = _bump_via_neon(args.table, args.id, args.campaign_id)
|
|
69
|
+
else:
|
|
70
|
+
bumped = _bump_via_api(args.table, args.id, args.campaign_id)
|
|
71
|
+
|
|
72
|
+
print(f"table={args.table} id={args.id} campaign={args.campaign_id} bumped={bumped}")
|
|
50
73
|
return 0
|
|
51
74
|
|
|
52
75
|
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Email-alert when any external_short_links pool runs low.
|
|
3
|
+
|
|
4
|
+
For every project with `external_short_links: true` in config.json, checks the
|
|
5
|
+
(project, platform) pool depth against two thresholds:
|
|
6
|
+
|
|
7
|
+
WARN -- available / total <= 0.20 (i.e., 80% of the pool has been claimed)
|
|
8
|
+
CRITICAL -- available == 0 (pool exhausted, next post returns
|
|
9
|
+
{ok: false, error: 'pool_exhausted'})
|
|
10
|
+
|
|
11
|
+
Emails go to i@m13v.com via the Gmail DWD lane. State lives in the
|
|
12
|
+
`external_pool_alerts` table so we don't spam: same (project, platform,
|
|
13
|
+
severity) is suppressed for 24h after a send.
|
|
14
|
+
|
|
15
|
+
Designed to run on launchd every 30 min. The 20% threshold gives 7-30 days of
|
|
16
|
+
runway warning before a CRITICAL fires (at typical 5-15 posts/day burn). The
|
|
17
|
+
CRITICAL alert is the "on error" case the user asked for; it fires at most
|
|
18
|
+
once per 24h per (project, platform).
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
python3 scripts/check_external_pool_depth.py # check + alert
|
|
22
|
+
python3 scripts/check_external_pool_depth.py --dry-run # report only, no email/state writes
|
|
23
|
+
python3 scripts/check_external_pool_depth.py --force # ignore 24h cooldown
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from email.message import EmailMessage
|
|
32
|
+
import base64
|
|
33
|
+
|
|
34
|
+
REPO_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
35
|
+
sys.path.insert(0, os.path.join(REPO_DIR, 'scripts'))
|
|
36
|
+
sys.path.insert(0, os.path.expanduser('~/gmail-api'))
|
|
37
|
+
|
|
38
|
+
import db as dbmod # noqa: E402
|
|
39
|
+
|
|
40
|
+
WARN_REMAINING_RATIO = 0.20
|
|
41
|
+
COOLDOWN_HOURS = 24
|
|
42
|
+
NOTIFICATION_EMAIL = os.environ.get('NOTIFICATION_EMAIL', 'i@m13v.com')
|
|
43
|
+
PLATFORMS = ['reddit', 'twitter', 'linkedin', 'github_issues', 'moltbook']
|
|
44
|
+
CONFIG_PATH = os.path.join(REPO_DIR, 'config.json')
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _scrub_dashes(s: str) -> str:
|
|
48
|
+
return s.replace('—', ',').replace('–', ',') if s else s
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_external_projects() -> list[dict]:
|
|
52
|
+
with open(CONFIG_PATH) as f:
|
|
53
|
+
cfg = json.load(f)
|
|
54
|
+
return [p for p in cfg.get('projects', []) if p.get('external_short_links')]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pool_depth(conn, project: str, platform: str) -> tuple[int, int]:
|
|
58
|
+
cur = conn.execute(
|
|
59
|
+
"SELECT "
|
|
60
|
+
" COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
|
|
61
|
+
" COUNT(*) AS total "
|
|
62
|
+
"FROM post_links "
|
|
63
|
+
"WHERE minted_session LIKE %s AND project_name = %s AND platform = %s",
|
|
64
|
+
('pool:%', project, platform),
|
|
65
|
+
)
|
|
66
|
+
r = dict(cur.fetchone())
|
|
67
|
+
return int(r['available'] or 0), int(r['total'] or 0)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _recent_alert_exists(conn, project: str, platform: str, severity: str) -> bool:
|
|
71
|
+
cur = conn.execute(
|
|
72
|
+
"SELECT 1 FROM external_pool_alerts "
|
|
73
|
+
"WHERE project_name=%s AND platform=%s AND severity=%s "
|
|
74
|
+
" AND sent_at > NOW() - INTERVAL %s "
|
|
75
|
+
"LIMIT 1",
|
|
76
|
+
(project, platform, severity, f"{COOLDOWN_HOURS} hours"),
|
|
77
|
+
)
|
|
78
|
+
return cur.fetchone() is not None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _record_alert(conn, project: str, platform: str, severity: str,
|
|
82
|
+
available: int, total: int, ratio: float) -> None:
|
|
83
|
+
conn.execute(
|
|
84
|
+
"INSERT INTO external_pool_alerts "
|
|
85
|
+
" (project_name, platform, severity, available, total, ratio) "
|
|
86
|
+
"VALUES (%s, %s, %s, %s, %s, %s)",
|
|
87
|
+
(project, platform, severity, available, total, ratio),
|
|
88
|
+
)
|
|
89
|
+
conn.commit()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _gmail_send(subject: str, body: str) -> None:
|
|
93
|
+
from gmail_dwd_client import gmail_for
|
|
94
|
+
msg = EmailMessage()
|
|
95
|
+
msg['Subject'] = _scrub_dashes(subject)
|
|
96
|
+
msg['From'] = 'social-autoposter <i@m13v.com>'
|
|
97
|
+
msg['To'] = NOTIFICATION_EMAIL
|
|
98
|
+
msg.set_content(_scrub_dashes(body))
|
|
99
|
+
raw = base64.urlsafe_b64encode(msg.as_bytes()).decode('ascii')
|
|
100
|
+
client = gmail_for('i@m13v.com')
|
|
101
|
+
client.service.users().messages().send(userId='me', body={'raw': raw}).execute()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _format_subject(project: str, platform: str, severity: str,
|
|
105
|
+
available: int, total: int) -> str:
|
|
106
|
+
pct = f"{(available / total * 100):.0f}%" if total else "0%"
|
|
107
|
+
return f"[POOL {severity}] {project}/{platform}: {available}/{total} left ({pct})"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _format_body(project: str, platform: str, severity: str,
|
|
111
|
+
available: int, total: int, ratio: float,
|
|
112
|
+
destinations: list[dict]) -> str:
|
|
113
|
+
lines = [
|
|
114
|
+
f"Severity: {severity}",
|
|
115
|
+
f"Project: {project}",
|
|
116
|
+
f"Platform: {platform}",
|
|
117
|
+
f"Available: {available}",
|
|
118
|
+
f"Total minted: {total}",
|
|
119
|
+
f"Remaining: {ratio*100:.1f}%",
|
|
120
|
+
"",
|
|
121
|
+
]
|
|
122
|
+
if severity == 'CRITICAL':
|
|
123
|
+
lines += [
|
|
124
|
+
"The pool is exhausted. The next post for this (project, platform) will",
|
|
125
|
+
"return {ok: false, error: 'pool_exhausted'} and skip. Refill IMMEDIATELY:",
|
|
126
|
+
"",
|
|
127
|
+
]
|
|
128
|
+
else:
|
|
129
|
+
lines += [
|
|
130
|
+
f"Pool has dropped under {int(WARN_REMAINING_RATIO*100)}% remaining. Schedule a refill",
|
|
131
|
+
"in the next few days to avoid hitting pool_exhausted on the next cycle.",
|
|
132
|
+
"",
|
|
133
|
+
]
|
|
134
|
+
lines += [
|
|
135
|
+
"Refill commands (in ~/social-autoposter):",
|
|
136
|
+
" python3 scripts/mint_kent_pool.py # Kent clients (Runner/Agora/Podlog)",
|
|
137
|
+
" # for other external clients: extend mint_kent_pool.py SITE_CONFIG first",
|
|
138
|
+
"",
|
|
139
|
+
"Pool status snapshot:",
|
|
140
|
+
" python3 scripts/mint_kent_pool.py --status",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
if destinations:
|
|
144
|
+
lines += ["Per-destination breakdown for this slice:"]
|
|
145
|
+
for d in destinations[:20]:
|
|
146
|
+
lines.append(
|
|
147
|
+
f" {d['minted_session'][-65:]:<65} "
|
|
148
|
+
f"avail={d['available']:>5} claimed={d['claimed']:>5}"
|
|
149
|
+
)
|
|
150
|
+
if len(destinations) > 20:
|
|
151
|
+
lines.append(f" ... and {len(destinations) - 20} more destinations")
|
|
152
|
+
lines.append("")
|
|
153
|
+
lines += [
|
|
154
|
+
f"Cooldown: {COOLDOWN_HOURS}h per (project, platform, severity)",
|
|
155
|
+
f"Re-fire: python3 scripts/check_external_pool_depth.py --force",
|
|
156
|
+
]
|
|
157
|
+
return "\n".join(lines)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _destinations_for_slice(conn, project: str, platform: str) -> list[dict]:
|
|
161
|
+
cur = conn.execute(
|
|
162
|
+
"SELECT minted_session, "
|
|
163
|
+
" COUNT(*) FILTER (WHERE post_id IS NULL AND reply_id IS NULL) AS available, "
|
|
164
|
+
" COUNT(*) FILTER (WHERE post_id IS NOT NULL OR reply_id IS NOT NULL) AS claimed "
|
|
165
|
+
"FROM post_links "
|
|
166
|
+
"WHERE minted_session LIKE %s AND project_name = %s AND platform = %s "
|
|
167
|
+
"GROUP BY minted_session ORDER BY available ASC",
|
|
168
|
+
('pool:%', project, platform),
|
|
169
|
+
)
|
|
170
|
+
return [dict(r) for r in cur.fetchall()]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def check(dry_run: bool = False, force: bool = False,
|
|
174
|
+
warn_ratio: float = WARN_REMAINING_RATIO,
|
|
175
|
+
limit: int | None = None) -> dict:
|
|
176
|
+
projects = _load_external_projects()
|
|
177
|
+
conn = dbmod.get_conn()
|
|
178
|
+
fired: list[dict] = []
|
|
179
|
+
skipped_cooldown: list[dict] = []
|
|
180
|
+
healthy: list[dict] = []
|
|
181
|
+
try:
|
|
182
|
+
for p in projects:
|
|
183
|
+
project_name = p['name']
|
|
184
|
+
for platform in PLATFORMS:
|
|
185
|
+
available, total = _pool_depth(conn, project_name, platform)
|
|
186
|
+
if total == 0:
|
|
187
|
+
continue
|
|
188
|
+
ratio = available / total if total > 0 else 0.0
|
|
189
|
+
if available == 0:
|
|
190
|
+
severity = 'CRITICAL'
|
|
191
|
+
elif ratio <= warn_ratio:
|
|
192
|
+
severity = 'WARN'
|
|
193
|
+
else:
|
|
194
|
+
healthy.append({
|
|
195
|
+
'project': project_name, 'platform': platform,
|
|
196
|
+
'available': available, 'total': total, 'ratio': ratio,
|
|
197
|
+
})
|
|
198
|
+
continue
|
|
199
|
+
key = (project_name, platform, severity)
|
|
200
|
+
if not force and _recent_alert_exists(conn, *key):
|
|
201
|
+
skipped_cooldown.append({
|
|
202
|
+
'project': project_name, 'platform': platform,
|
|
203
|
+
'severity': severity, 'available': available, 'total': total,
|
|
204
|
+
})
|
|
205
|
+
continue
|
|
206
|
+
fired_row = {
|
|
207
|
+
'project': project_name, 'platform': platform,
|
|
208
|
+
'severity': severity, 'available': available, 'total': total,
|
|
209
|
+
'ratio': ratio,
|
|
210
|
+
}
|
|
211
|
+
fired.append(fired_row)
|
|
212
|
+
if dry_run:
|
|
213
|
+
continue
|
|
214
|
+
if limit is not None and len(fired) > limit:
|
|
215
|
+
continue
|
|
216
|
+
destinations = _destinations_for_slice(conn, project_name, platform)
|
|
217
|
+
subject = _format_subject(project_name, platform, severity, available, total)
|
|
218
|
+
body = _format_body(project_name, platform, severity, available, total,
|
|
219
|
+
ratio, destinations)
|
|
220
|
+
try:
|
|
221
|
+
_gmail_send(subject, body)
|
|
222
|
+
_record_alert(conn, project_name, platform, severity,
|
|
223
|
+
available, total, ratio)
|
|
224
|
+
except Exception as e:
|
|
225
|
+
fired_row['send_error'] = str(e)
|
|
226
|
+
print(f"[pool-check] email send failed for {project_name}/{platform}: {e}",
|
|
227
|
+
file=sys.stderr)
|
|
228
|
+
return {
|
|
229
|
+
'checked_at': datetime.now(timezone.utc).isoformat(),
|
|
230
|
+
'fired': fired,
|
|
231
|
+
'skipped_cooldown': skipped_cooldown,
|
|
232
|
+
'healthy_count': len(healthy),
|
|
233
|
+
'dry_run': dry_run,
|
|
234
|
+
}
|
|
235
|
+
finally:
|
|
236
|
+
conn.close()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main():
|
|
240
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
241
|
+
ap.add_argument('--dry-run', action='store_true',
|
|
242
|
+
help='compute and report, do not email or write state')
|
|
243
|
+
ap.add_argument('--force', action='store_true',
|
|
244
|
+
help='ignore 24h cooldown, re-fire matching alerts')
|
|
245
|
+
ap.add_argument('--warn-ratio', type=float, default=WARN_REMAINING_RATIO,
|
|
246
|
+
help=f'WARN threshold for available/total (default {WARN_REMAINING_RATIO})')
|
|
247
|
+
ap.add_argument('--limit', type=int, default=None,
|
|
248
|
+
help='cap the number of alert emails per run (smoke testing)')
|
|
249
|
+
args = ap.parse_args()
|
|
250
|
+
result = check(dry_run=args.dry_run, force=args.force,
|
|
251
|
+
warn_ratio=args.warn_ratio, limit=args.limit)
|
|
252
|
+
print(json.dumps(result, indent=2, default=str))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == '__main__':
|
|
256
|
+
main()
|
package/scripts/get_run_cost.py
CHANGED
|
@@ -70,37 +70,15 @@ def main():
|
|
|
70
70
|
return
|
|
71
71
|
|
|
72
72
|
try:
|
|
73
|
-
import psycopg2 # noqa: F401 (psycopg2 only needed via dbmod transitively)
|
|
74
73
|
sys.path.insert(0, os.path.join(ROOT_DIR, 'scripts'))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
cur = conn.execute(
|
|
79
|
-
"""SELECT COALESCE(SUM(total_cost_usd), 0),
|
|
80
|
-
COALESCE(SUM(subagent_cost_usd), 0),
|
|
81
|
-
COALESCE(SUM(task_call_count), 0),
|
|
82
|
-
COALESCE(SUM(subagent_count), 0)
|
|
83
|
-
FROM claude_sessions
|
|
84
|
-
WHERE cycle_id = %s""",
|
|
85
|
-
[cycle_id],
|
|
74
|
+
if os.environ.get('SOCIAL_AUTOPOSTER_LEGACY_NEON') == '1':
|
|
75
|
+
parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_neon(
|
|
76
|
+
cycle_id=cycle_id, since=args.since, scripts=args.scripts,
|
|
86
77
|
)
|
|
87
78
|
else:
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
cur = conn.execute(
|
|
91
|
-
f"""SELECT COALESCE(SUM(total_cost_usd), 0),
|
|
92
|
-
COALESCE(SUM(subagent_cost_usd), 0),
|
|
93
|
-
COALESCE(SUM(task_call_count), 0),
|
|
94
|
-
COALESCE(SUM(subagent_count), 0)
|
|
95
|
-
FROM claude_sessions
|
|
96
|
-
WHERE script IN ({placeholders}) AND started_at >= %s""",
|
|
97
|
-
args.scripts + [since_ts],
|
|
79
|
+
parent_cost, subagent_cost, task_count, subagent_count = _fetch_via_api(
|
|
80
|
+
cycle_id=cycle_id, since=args.since, scripts=args.scripts,
|
|
98
81
|
)
|
|
99
|
-
row = cur.fetchone()
|
|
100
|
-
parent_cost = float(row[0] or 0)
|
|
101
|
-
subagent_cost = float(row[1] or 0)
|
|
102
|
-
task_count = int(row[2] or 0)
|
|
103
|
-
subagent_count = int(row[3] or 0)
|
|
104
82
|
if args.breakdown:
|
|
105
83
|
print(f"{parent_cost:.4f} {subagent_cost:.4f} {task_count} {subagent_count}")
|
|
106
84
|
else:
|
|
@@ -109,5 +87,56 @@ def main():
|
|
|
109
87
|
print("0.0000")
|
|
110
88
|
|
|
111
89
|
|
|
90
|
+
def _fetch_via_api(*, cycle_id, since, scripts):
|
|
91
|
+
from http_api import api_get
|
|
92
|
+
if cycle_id:
|
|
93
|
+
query = {"cycle_id": cycle_id}
|
|
94
|
+
else:
|
|
95
|
+
query = {"since_ts": str(int(since)), "scripts": ",".join(scripts)}
|
|
96
|
+
resp = api_get("/api/v1/claude-sessions/cost", query=query)
|
|
97
|
+
data = (resp or {}).get("data") or {}
|
|
98
|
+
return (
|
|
99
|
+
float(data.get("parent_cost") or 0),
|
|
100
|
+
float(data.get("subagent_cost") or 0),
|
|
101
|
+
int(data.get("task_count") or 0),
|
|
102
|
+
int(data.get("subagent_count") or 0),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fetch_via_neon(*, cycle_id, since, scripts):
|
|
107
|
+
import psycopg2 # noqa: F401
|
|
108
|
+
import db as dbmod
|
|
109
|
+
conn = dbmod.get_conn()
|
|
110
|
+
if cycle_id:
|
|
111
|
+
cur = conn.execute(
|
|
112
|
+
"""SELECT COALESCE(SUM(total_cost_usd), 0),
|
|
113
|
+
COALESCE(SUM(subagent_cost_usd), 0),
|
|
114
|
+
COALESCE(SUM(task_call_count), 0),
|
|
115
|
+
COALESCE(SUM(subagent_count), 0)
|
|
116
|
+
FROM claude_sessions
|
|
117
|
+
WHERE cycle_id = %s""",
|
|
118
|
+
[cycle_id],
|
|
119
|
+
)
|
|
120
|
+
else:
|
|
121
|
+
since_ts = datetime.fromtimestamp(since, tz=timezone.utc).isoformat()
|
|
122
|
+
placeholders = ','.join(['%s'] * len(scripts))
|
|
123
|
+
cur = conn.execute(
|
|
124
|
+
f"""SELECT COALESCE(SUM(total_cost_usd), 0),
|
|
125
|
+
COALESCE(SUM(subagent_cost_usd), 0),
|
|
126
|
+
COALESCE(SUM(task_call_count), 0),
|
|
127
|
+
COALESCE(SUM(subagent_count), 0)
|
|
128
|
+
FROM claude_sessions
|
|
129
|
+
WHERE script IN ({placeholders}) AND started_at >= %s""",
|
|
130
|
+
list(scripts) + [since_ts],
|
|
131
|
+
)
|
|
132
|
+
row = cur.fetchone()
|
|
133
|
+
return (
|
|
134
|
+
float(row[0] or 0),
|
|
135
|
+
float(row[1] or 0),
|
|
136
|
+
int(row[2] or 0),
|
|
137
|
+
int(row[3] or 0),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
112
141
|
if __name__ == '__main__':
|
|
113
142
|
main()
|
|
@@ -536,6 +536,117 @@ def format_report(summary, top, bottom, project=None, platform=None,
|
|
|
536
536
|
return "\n".join(lines)
|
|
537
537
|
|
|
538
538
|
|
|
539
|
+
def _apply_top_filter(rows, limit):
|
|
540
|
+
"""Anti-pattern filter applied to top-N candidates.
|
|
541
|
+
|
|
542
|
+
PRODUCT_NAMES: hard-drop self-promotional examples regardless of
|
|
543
|
+
clicks (don't teach Claude to namedrop). URL/www. mention: only
|
|
544
|
+
drop when clicks==0 (a URL-bearing post with real human clicks IS
|
|
545
|
+
the gold example by definition; see 2026-05-12 click-aware fix).
|
|
546
|
+
Caller passes overfetched rows; we trim to `limit` after filter.
|
|
547
|
+
"""
|
|
548
|
+
clean = []
|
|
549
|
+
for r in rows:
|
|
550
|
+
content = (r[5] or "")
|
|
551
|
+
clicks = r[11] if len(r) > 11 and r[11] is not None else 0
|
|
552
|
+
lower = content.lower()
|
|
553
|
+
if any(name in lower for name in PRODUCT_NAMES):
|
|
554
|
+
continue
|
|
555
|
+
has_url = ("http://" in lower or "https://" in lower or "www." in lower)
|
|
556
|
+
if has_url and clicks == 0:
|
|
557
|
+
continue
|
|
558
|
+
clean.append(r)
|
|
559
|
+
return clean[:limit]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _fetch_report_via_api(*, platform, project, top, bottom):
|
|
563
|
+
"""Pull all 6 SQL aggregations in one call via the v1 route.
|
|
564
|
+
|
|
565
|
+
Returns (summary, style_perf, top_posts, bottom_posts,
|
|
566
|
+
fallback_top|None, top_by_group|None). Row shapes match
|
|
567
|
+
the column order format_post / format_report expect.
|
|
568
|
+
"""
|
|
569
|
+
from http_api import api_get
|
|
570
|
+
resp = api_get(
|
|
571
|
+
"/api/v1/posts/top-performers-report",
|
|
572
|
+
query={
|
|
573
|
+
"platform": platform or "",
|
|
574
|
+
"project": project or "",
|
|
575
|
+
"top": str(top),
|
|
576
|
+
"bottom": str(bottom),
|
|
577
|
+
},
|
|
578
|
+
)
|
|
579
|
+
data = (resp or {}).get("data") or {}
|
|
580
|
+
summary = data.get("summary") or []
|
|
581
|
+
style_perf = data.get("style_perf") or []
|
|
582
|
+
raw_top = data.get("top_posts") or []
|
|
583
|
+
raw_bottom = data.get("bottom_posts") or []
|
|
584
|
+
raw_fallback = data.get("fallback_top") or []
|
|
585
|
+
raw_group = data.get("top_by_group") or {}
|
|
586
|
+
|
|
587
|
+
top_filtered = _apply_top_filter(raw_top, top) if raw_top else []
|
|
588
|
+
fallback_filtered = None
|
|
589
|
+
if project and not top_filtered and raw_fallback:
|
|
590
|
+
fallback_filtered = _apply_top_filter(raw_fallback, top)
|
|
591
|
+
top_by_group = None
|
|
592
|
+
if not project:
|
|
593
|
+
top_by_group = {
|
|
594
|
+
proj: _apply_top_filter(rows, 5)
|
|
595
|
+
for proj, rows in raw_group.items()
|
|
596
|
+
}
|
|
597
|
+
return summary, style_perf, top_filtered, raw_bottom, fallback_filtered, top_by_group
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _fetch_report_via_neon(*, platform, project, top, bottom):
|
|
601
|
+
conn = dbmod.get_conn()
|
|
602
|
+
try:
|
|
603
|
+
summary = get_project_platform_summary(conn, project=project, platform=platform)
|
|
604
|
+
style_perf = get_style_performance(conn, platform=platform)
|
|
605
|
+
top_posts = get_top_posts(conn, project=project, platform=platform, limit=top)
|
|
606
|
+
bottom_posts = get_bottom_posts(conn, project=project, platform=platform, limit=bottom)
|
|
607
|
+
fallback_top = None
|
|
608
|
+
if project and not top_posts:
|
|
609
|
+
fallback_top = get_top_posts(conn, project=None, platform=platform, limit=top)
|
|
610
|
+
top_by_group = None
|
|
611
|
+
if not project:
|
|
612
|
+
top_by_group = {}
|
|
613
|
+
min_score = min_score_for(platform)
|
|
614
|
+
platform_filter = "AND platform = %s" if platform else ""
|
|
615
|
+
platform_params = [platform] if platform else []
|
|
616
|
+
cur = conn.execute(
|
|
617
|
+
f"{POSTS_WITH_CLICKS_CTE}"
|
|
618
|
+
f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
|
|
619
|
+
f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
|
|
620
|
+
f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
|
|
621
|
+
f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
|
|
622
|
+
f"{platform_filter} "
|
|
623
|
+
f"ORDER BY 1",
|
|
624
|
+
[MIN_CONTENT_LEN, min_score] + platform_params,
|
|
625
|
+
)
|
|
626
|
+
projects = [row[0] for row in cur.fetchall()]
|
|
627
|
+
for proj in projects:
|
|
628
|
+
proj_filter = proj if proj != "(no project)" else None
|
|
629
|
+
where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
|
|
630
|
+
params = ([proj_filter] if proj_filter else []) + platform_params
|
|
631
|
+
cur = conn.execute(
|
|
632
|
+
f"{POSTS_WITH_CLICKS_CTE}"
|
|
633
|
+
f"SELECT id, platform, upvotes, comments_count, views, "
|
|
634
|
+
f"our_content, thread_title, thread_content, "
|
|
635
|
+
f"project_name, posted_at::date, our_account, clicks "
|
|
636
|
+
f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
|
|
637
|
+
f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
|
|
638
|
+
f"AND platform NOT IN ('github_issues') "
|
|
639
|
+
f"{where_extra} {platform_filter} "
|
|
640
|
+
f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
|
|
641
|
+
f" {UPVOTES_NET_SQL} DESC LIMIT 5",
|
|
642
|
+
params,
|
|
643
|
+
)
|
|
644
|
+
top_by_group[proj] = cur.fetchall()
|
|
645
|
+
return summary, style_perf, top_posts, bottom_posts, fallback_top, top_by_group
|
|
646
|
+
finally:
|
|
647
|
+
conn.close()
|
|
648
|
+
|
|
649
|
+
|
|
539
650
|
def main():
|
|
540
651
|
parser = argparse.ArgumentParser(description="Generate top performers feedback report")
|
|
541
652
|
parser.add_argument("--platform", default=None, help="Filter to specific platform")
|
|
@@ -545,71 +656,20 @@ def main():
|
|
|
545
656
|
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
|
546
657
|
args = parser.parse_args()
|
|
547
658
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
# If project was specified but no posts met the threshold, fall back to
|
|
556
|
-
# general high-performing posts on the same platform
|
|
557
|
-
fallback_top = None
|
|
558
|
-
if args.project and not top:
|
|
559
|
-
fallback_top = get_top_posts(conn, project=None, platform=args.platform, limit=args.top)
|
|
560
|
-
|
|
561
|
-
# When no project filter, also get top 5 per project for focused examples.
|
|
562
|
-
# Uses the same SCORE_SQL composite as get_top_posts so ranking is consistent.
|
|
563
|
-
top_by_group = None
|
|
564
|
-
if not args.project:
|
|
565
|
-
top_by_group = {}
|
|
566
|
-
min_score = min_score_for(args.platform)
|
|
567
|
-
platform_filter = "AND platform = %s" if args.platform else ""
|
|
568
|
-
platform_params = [args.platform] if args.platform else []
|
|
569
|
-
# Get distinct projects (respecting platform filter). Uses the
|
|
570
|
-
# CTE so the SCORE_SQL >= threshold expression resolves `clicks`.
|
|
571
|
-
cur = conn.execute(
|
|
572
|
-
f"{POSTS_WITH_CLICKS_CTE}"
|
|
573
|
-
f"SELECT DISTINCT COALESCE(project_name, '(no project)') FROM posts_w_clicks "
|
|
574
|
-
f"WHERE status = 'active' AND platform NOT IN ('github_issues') "
|
|
575
|
-
f"AND our_content IS NOT NULL AND LENGTH(our_content) >= %s "
|
|
576
|
-
f"AND upvotes IS NOT NULL AND {SCORE_SQL} >= %s "
|
|
577
|
-
f"{platform_filter} "
|
|
578
|
-
f"ORDER BY 1",
|
|
579
|
-
[MIN_CONTENT_LEN, min_score] + platform_params
|
|
580
|
-
)
|
|
581
|
-
projects = [row[0] for row in cur.fetchall()]
|
|
582
|
-
for proj in projects:
|
|
583
|
-
proj_filter = proj if proj != "(no project)" else None
|
|
584
|
-
where_extra = "AND project_name = %s" if proj_filter else "AND project_name IS NULL"
|
|
585
|
-
params = ([proj_filter] if proj_filter else []) + platform_params
|
|
586
|
-
# Per-project top-5 examples — these are what Claude actually
|
|
587
|
-
# sees as the few-shot prompt. Click DESC tiebreaker means two
|
|
588
|
-
# posts at the same composite score sort the higher-click one
|
|
589
|
-
# first, putting the better conversion proof in front of Claude.
|
|
590
|
-
cur = conn.execute(
|
|
591
|
-
f"{POSTS_WITH_CLICKS_CTE}"
|
|
592
|
-
f"SELECT id, platform, upvotes, comments_count, views, "
|
|
593
|
-
f"our_content, thread_title, thread_content, "
|
|
594
|
-
f"project_name, posted_at::date, our_account, clicks "
|
|
595
|
-
f"FROM posts_w_clicks WHERE status = 'active' AND {SCORE_SQL} >= {min_score} "
|
|
596
|
-
f"AND our_content IS NOT NULL AND LENGTH(our_content) >= {MIN_CONTENT_LEN} "
|
|
597
|
-
f"AND platform NOT IN ('github_issues') "
|
|
598
|
-
f"{where_extra} {platform_filter} "
|
|
599
|
-
f"ORDER BY {SCORE_SQL} DESC, COALESCE(clicks,0) DESC, "
|
|
600
|
-
f" {UPVOTES_NET_SQL} DESC LIMIT 5",
|
|
601
|
-
params
|
|
602
|
-
)
|
|
603
|
-
top_by_group[proj] = cur.fetchall()
|
|
604
|
-
|
|
605
|
-
conn.close()
|
|
659
|
+
if os.environ.get("SOCIAL_AUTOPOSTER_LEGACY_NEON") == "1":
|
|
660
|
+
fetch = _fetch_report_via_neon
|
|
661
|
+
else:
|
|
662
|
+
fetch = _fetch_report_via_api
|
|
663
|
+
summary, style_perf, top, bottom, fallback_top, top_by_group = fetch(
|
|
664
|
+
platform=args.platform, project=args.project, top=args.top, bottom=args.bottom,
|
|
665
|
+
)
|
|
606
666
|
|
|
607
667
|
if args.json:
|
|
608
668
|
output = {
|
|
609
|
-
"summary": [
|
|
610
|
-
"top_posts": [
|
|
611
|
-
"bottom_posts": [
|
|
612
|
-
"fallback_top": [
|
|
669
|
+
"summary": [list(row) for row in summary],
|
|
670
|
+
"top_posts": [list(row) for row in top],
|
|
671
|
+
"bottom_posts": [list(row) for row in bottom],
|
|
672
|
+
"fallback_top": [list(row) for row in fallback_top] if fallback_top else [],
|
|
613
673
|
}
|
|
614
674
|
print(json.dumps(output, indent=2, default=str))
|
|
615
675
|
else:
|