Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 36 additions & 16 deletions src/services/ai-summaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import type { AgentRunBundle } from "./agent-orchestrator";

type AiSummaryVisibility = "private" | "public";

const PRIVATE_CONTEXT_PATTERN =
/\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|raw trust scores?|trust scores?|private reviewability|private scoreability|public score estimates?)\b/gi;
const PRIVATE_OUTCOME_PATTERN = /\b(payouts?|farming|reward estimates?|reward optimization)\b/gi;
const PUBLIC_FORBIDDEN_TEXT_PATTERN =
/\b(wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|raw trust scores?|trust scores?|estimated scores?|score estimates?|public score estimates?|reward estimates?|payouts?|farming|private reviewability|private scoreability|reward optimization)\b/i;

export type AiSummaryResult =
| { status: "disabled"; reason: string }
| { status: "unavailable"; reason: string }
Expand Down Expand Up @@ -90,6 +96,7 @@ export async function summarizeAgentBundleWithAi(env: Env, bundle: AgentRunBundl
}

function compactAgentSignalBundle(bundle: AgentRunBundle, visibility: AiSummaryVisibility): Record<string, JsonValue> {
const publicMode = visibility === "public";
return {
run: {
id: bundle.run.id,
Expand All @@ -99,22 +106,35 @@ function compactAgentSignalBundle(bundle: AgentRunBundle, visibility: AiSummaryV
status: bundle.run.status,
dataQualityStatus: bundle.run.dataQualityStatus,
},
actions: bundle.actions.slice(0, 5).map((action) => ({
actionType: action.actionType,
status: action.status,
recommendation: visibility === "public" ? action.publicSafeSummary : action.recommendation,
publicSafeSummary: action.publicSafeSummary,
why: action.why.slice(0, 4),
blockedBy: action.blockedBy.slice(0, 4),
scoreabilityImpact: visibility === "public" ? undefined : action.scoreabilityImpact,
riskImpact: visibility === "public" ? undefined : action.riskImpact,
maintainerImpact: action.maintainerImpact,
rerunWhen: action.rerunWhen,
})),
actions: bundle.actions.slice(0, 5).map((action) => {
const publicSafeSummary = publicMode ? sanitizePublicPromptText(action.publicSafeSummary) : action.publicSafeSummary;
return {
actionType: action.actionType,
status: action.status,
recommendation: publicMode ? publicSafeSummary : action.recommendation,
publicSafeSummary,
why: sanitizePromptList(action.why, visibility),
blockedBy: sanitizePromptList(action.blockedBy, visibility),
scoreabilityImpact: publicMode ? undefined : action.scoreabilityImpact,
riskImpact: publicMode ? undefined : action.riskImpact,
maintainerImpact: publicMode && action.maintainerImpact ? sanitizePublicPromptText(action.maintainerImpact) : action.maintainerImpact,
rerunWhen: action.rerunWhen,
};
}),
freshnessWarnings: bundle.contextSnapshots.flatMap((snapshot) => snapshot.freshnessWarnings).slice(0, 8),
} as Record<string, JsonValue>;
}

function sanitizePromptList(values: string[], visibility: AiSummaryVisibility): string[] {
const selected = values.slice(0, 4);
if (visibility !== "public") return selected;
return selected.map((value) => sanitizePublicPromptText(value)).filter(Boolean);
}

function sanitizePublicPromptText(value: string): string {
return sanitizeAiText(value, "public");
}

function buildPrompt(signalBundle: Record<string, JsonValue>, visibility: AiSummaryVisibility): string {
return [
`Visibility: ${visibility}`,
Expand All @@ -141,16 +161,16 @@ function extractAiText(response: unknown): string {

function sanitizeAiText(value: string, visibility: AiSummaryVisibility): string {
const sanitized = value
.replace(/\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|trust score)\b/gi, "private context")
.replace(/\b(payout|farming)\b/gi, "private outcome");
.replace(PRIVATE_CONTEXT_PATTERN, "private context")
.replace(PRIVATE_OUTCOME_PATTERN, "private outcome");
if (visibility === "public") {
return sanitized.replace(/\b(estimated score|score estimate|reward estimate|reward optimization)\b/gi, "private context");
return sanitized.replace(/\b(estimated scores?|score estimates?)\b/gi, "private context").trim();
}
return sanitized.trim();
}

function containsPublicForbiddenText(value: string): boolean {
return /\b(wallet|hotkey|coldkey|seed phrase|mnemonic|raw trust score|estimated score|score estimate|reward estimate|payout|farming)\b/i.test(value);
return PUBLIC_FORBIDDEN_TEXT_PATTERN.test(value);
}

function isEnabled(value: string | undefined): boolean {
Expand Down
75 changes: 75 additions & 0 deletions test/unit/ai-summaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { __aiSummaryInternals, summarizeAgentBundleWithAi } from "../../src/serv
import type { AgentRunBundle } from "../../src/services/agent-orchestrator";
import { createTestEnv } from "../helpers/d1";

const PUBLIC_FORBIDDEN_TEXT =
/\b(wallets?|hotkeys?|raw trust scores?|trust scores?|payouts?|reward estimates?|farming|private reviewability|private scoreability|public score estimates?)\b/i;
type AiRunRequest = { messages: Array<{ role: string; content: string }> };

describe("Workers AI summaries", () => {
it("stays disabled by default and does not call Workers AI", async () => {
const run = vi.fn();
Expand Down Expand Up @@ -122,6 +126,56 @@ describe("Workers AI summaries", () => {
expect(unsafe).toMatchObject({ status: "unsafe", reason: "public summary failed sanitizer" });
});

it.each(["wallet", "hotkey", "raw trust score", "payout", "reward estimate", "farming", "private reviewability", "private scoreability", "public score estimate"])(
"rejects unsafe public AI output containing %s",
async (unsafeText) => {
const run = vi.fn(async () => ({ response: `Do the next action because ${unsafeText} changed.` }));
const env = createTestEnv({
AI: { run } as unknown as Ai,
AI_SUMMARIES_ENABLED: "true",
AI_PUBLIC_COMMENTS_ENABLED: "true",
AI_DAILY_NEURON_BUDGET: "10000",
});

const result = await summarizeAgentBundleWithAi(env, bundleFixture(), "public");

expect(result).toMatchObject({ status: "unsafe", reason: "public summary failed sanitizer" });
},
);

it("keeps private action facts out of public AI prompt context", async () => {
const run = vi.fn(async () => ({ response: "Public-safe queue summary." }));
const env = createTestEnv({
AI: { run } as unknown as Ai,
AI_SUMMARIES_ENABLED: "true",
AI_PUBLIC_COMMENTS_ENABLED: "true",
AI_DAILY_NEURON_BUDGET: "10000",
});

const publicResult = await summarizeAgentBundleWithAi(env, unsafeBundleFixture(), "public");

expect(publicResult).toMatchObject({ status: "ok" });
const publicRequest = (run.mock.calls as unknown as Array<[string, AiRunRequest]>)[0]?.[1];
const publicPrompt = publicRequest?.messages.find((message) => message.role === "user")?.content ?? "";
expect(publicPrompt).not.toMatch(PUBLIC_FORBIDDEN_TEXT);

const privateRun = vi.fn(async () => ({ response: "Private summary with authenticated context." }));
await expect(
summarizeAgentBundleWithAi(
{
...env,
AI: { run: privateRun } as unknown as Ai,
},
unsafeBundleFixture(),
"private",
),
).resolves.toMatchObject({ status: "ok" });

const privateRequest = (privateRun.mock.calls as unknown as Array<[string, AiRunRequest]>)[0]?.[1];
const privatePrompt = privateRequest?.messages.find((message) => message.role === "user")?.content ?? "";
expect(privatePrompt).toMatch(PUBLIC_FORBIDDEN_TEXT);
});

it("falls back when Workers AI returns malformed output or throws non-Error values", async () => {
const malformed = createTestEnv({
AI: { run: vi.fn(async () => ({ unknown: "shape" })) } as unknown as Ai,
Expand Down Expand Up @@ -210,3 +264,24 @@ function bundleFixture(): AgentRunBundle {
summary: "done",
};
}

function unsafeBundleFixture(): AgentRunBundle {
const bundle = bundleFixture();
const action = bundle.actions[0];
if (!action) throw new Error("missing fixture action");
return {
...bundle,
actions: [
{
...action,
recommendation: "Review wallet and hotkey evidence before discussing payout projections.",
why: ["raw trust score, farming language, and private reviewability are private context."],
blockedBy: ["private scoreability context and public score estimate are not public-safe."],
scoreabilityImpact: "Authenticated scoreability can include reward estimate details.",
riskImpact: "Private users may inspect payout evidence without public rendering.",
maintainerImpact: "Avoid publishing wallet, hotkey, or reward estimate language.",
publicSafeSummary: "Public score estimate and private reviewability should stay private.",
},
],
};
}