mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-05-19 16:39:50 +00:00
feat(growth): decision log for learning (#3187)
Adds decision logging to track approved/edited/skipped Reddit growth candidates. The log feeds back into the Claude prompt to improve future candidate selection based on past patterns.
This commit is contained in:
parent
0ece17d92e
commit
d42cbca525
4 changed files with 77 additions and 6 deletions
|
|
@ -6,6 +6,14 @@ Your job: from the pre-fetched Reddit posts below, find the ONE best thread wher
|
|||
|
||||
**IMPORTANT: Do NOT use any tools.** All data is provided below. Your entire response should be plain text output — no bash commands, no file reads, no tool calls. Just analyze the data and respond with your findings.
|
||||
|
||||
## Past decisions
|
||||
|
||||
The team has reviewed previous candidates. Learn from these patterns — what got approved, what got skipped, and how replies were edited. Prefer posts similar to approved ones and avoid patterns seen in skipped ones.
|
||||
|
||||
```
|
||||
DECISIONS_PLACEHOLDER
|
||||
```
|
||||
|
||||
## Pre-fetched Reddit data
|
||||
|
||||
The following posts were fetched automatically. Each post includes the title, selftext, subreddit, engagement stats, and the poster's recent comment history.
|
||||
|
|
|
|||
|
|
@ -82,10 +82,16 @@ fi
|
|||
# Inject Reddit data into prompt template
|
||||
REDDIT_JSON=$(cat "${REDDIT_DATA_FILE}")
|
||||
# Use bun for safe substitution to avoid sed escaping issues with JSON
|
||||
DECISIONS_FILE="${HOME}/.config/spawn/growth-decisions.md"
|
||||
bun -e "
|
||||
import { existsSync } from 'node:fs';
|
||||
const template = await Bun.file('${PROMPT_TEMPLATE}').text();
|
||||
const data = await Bun.file('${REDDIT_DATA_FILE}').text();
|
||||
const result = template.replace('REDDIT_DATA_PLACEHOLDER', data.trim());
|
||||
const decisionsPath = '${DECISIONS_FILE}';
|
||||
const decisions = existsSync(decisionsPath) ? await Bun.file(decisionsPath).text() : 'No past decisions yet.';
|
||||
const result = template
|
||||
.replace('REDDIT_DATA_PLACEHOLDER', data.trim())
|
||||
.replace('DECISIONS_PLACEHOLDER', decisions.trim());
|
||||
await Bun.write('${PROMPT_FILE}', result);
|
||||
"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,16 @@ import type { Result } from "@openrouter/spawn-shared";
|
|||
import type { Block } from "@slack/bolt";
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
appendFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { Err, isString, Ok, toRecord } from "@openrouter/spawn-shared";
|
||||
import { slackifyMarkdown } from "slackify-markdown";
|
||||
|
|
@ -299,9 +308,10 @@ function rowToCandidate(r: RawCandidate): CandidateRow {
|
|||
draftReply: r.draft_reply,
|
||||
slackChannel: r.slack_channel ?? undefined,
|
||||
slackTs: r.slack_ts ?? undefined,
|
||||
status: r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error"
|
||||
? r.status
|
||||
: "pending",
|
||||
status:
|
||||
r.status === "approved" || r.status === "posted" || r.status === "skipped" || r.status === "error"
|
||||
? r.status
|
||||
: "pending",
|
||||
actionedBy: r.actioned_by ?? undefined,
|
||||
actionedAt: r.actioned_at ?? undefined,
|
||||
postedReply: r.posted_reply ?? undefined,
|
||||
|
|
@ -335,7 +345,12 @@ export function upsertCandidate(db: Database, candidate: CandidateRow): void {
|
|||
/** Look up a candidate by Reddit post ID. */
|
||||
export function findCandidate(db: Database, postId: string): CandidateRow | undefined {
|
||||
const row = db
|
||||
.query<RawCandidate, [string]>("SELECT * FROM candidates WHERE post_id = ?")
|
||||
.query<
|
||||
RawCandidate,
|
||||
[
|
||||
string,
|
||||
]
|
||||
>("SELECT * FROM candidates WHERE post_id = ?")
|
||||
.get(postId);
|
||||
return row ? rowToCandidate(row) : undefined;
|
||||
}
|
||||
|
|
@ -370,6 +385,43 @@ export function updateCandidateStatus(
|
|||
);
|
||||
}
|
||||
|
||||
const DECISIONS_PATH = `${process.env.HOME ?? "/tmp"}/.config/spawn/growth-decisions.md`;
|
||||
|
||||
/** Append a decision entry to the growth decisions log. */
|
||||
export function logDecision(
|
||||
candidate: CandidateRow,
|
||||
decision: "approved" | "edited" | "skipped",
|
||||
editedReply?: string,
|
||||
): void {
|
||||
const dir = dirname(DECISIONS_PATH);
|
||||
if (!existsSync(dir))
|
||||
mkdirSync(dir, {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const reply = editedReply ?? candidate.draftReply;
|
||||
const entry = `
|
||||
## ${decision.toUpperCase()} — ${date}
|
||||
|
||||
- **Post**: [${candidate.title}](https://reddit.com${candidate.permalink})
|
||||
- **Subreddit**: r/${candidate.subreddit}
|
||||
- **Decision**: ${decision}
|
||||
${editedReply ? "- **Edited**: yes (original draft was modified)\n" : ""}\
|
||||
- **Reply**: ${reply.replace(/\n/g, " ")}
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
appendFileSync(DECISIONS_PATH, entry);
|
||||
}
|
||||
|
||||
/** Read the decisions log (returns empty string if no file). */
|
||||
export function readDecisions(): string {
|
||||
if (!existsSync(DECISIONS_PATH)) return "";
|
||||
return readFileSync(DECISIONS_PATH, "utf-8");
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Claude Code stream parsing
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import {
|
|||
findCandidate,
|
||||
findThread,
|
||||
formatToolStats,
|
||||
logDecision,
|
||||
markdownToRichTextBlocks,
|
||||
openDb,
|
||||
PR_URL_REGEX,
|
||||
parseStreamEvent,
|
||||
plainTextFallback,
|
||||
ResultSchema,
|
||||
readDecisions,
|
||||
runCleanupIfDue,
|
||||
stripMention,
|
||||
updateCandidateStatus,
|
||||
|
|
@ -1042,6 +1044,7 @@ app.action("growth_approve", async ({ ack, body, client }) => {
|
|||
postedReply: candidate.draftReply,
|
||||
redditCommentUrl: commentUrl,
|
||||
});
|
||||
logDecision(candidate, "approved");
|
||||
// Update the Slack message — replace buttons with confirmation
|
||||
if (candidate.slackChannel && candidate.slackTs) {
|
||||
await replaceButtonsWithStatus(
|
||||
|
|
@ -1184,6 +1187,7 @@ app.view("growth_edit_submit", async ({ ack, view, body, client }) => {
|
|||
postedReply: editedReply,
|
||||
redditCommentUrl: commentUrl,
|
||||
});
|
||||
logDecision(candidate, "edited", editedReply);
|
||||
if (candidate.slackChannel && candidate.slackTs) {
|
||||
await replaceButtonsWithStatus(
|
||||
client,
|
||||
|
|
@ -1230,6 +1234,7 @@ app.action("growth_skip", async ({ ack, body, client }) => {
|
|||
status: "skipped",
|
||||
actionedBy: userId,
|
||||
});
|
||||
logDecision(candidate, "skipped");
|
||||
|
||||
if (candidate.slackChannel && candidate.slackTs) {
|
||||
await replaceButtonsWithStatus(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue