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
20 changes: 11 additions & 9 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ import {
buildMaintainerCutReadiness,
buildMaintainerLaneReport,
buildPreflightResult,
buildPublicCommentSignalBundle,
buildPublicPrIntelligenceComment,
buildQueueHealth,
detectGittensorContributor,
} from "../signals/engine";
import { rewritePublicPrIntelligenceComment } from "../services/ai-summaries";
import { decidePublicSurface } from "../signals/settings-preview";
import type { LocalBranchAnalysisInput } from "../signals/local-branch";
import type { ContributorEvidenceRecord, GitHubWebhookPayload, JobMessage, JsonValue } from "../types";
Expand Down Expand Up @@ -704,15 +706,15 @@ async function maybePublishPrPublicSurface(
repoBounties,
);
if (decision.willComment) {
const body = buildPublicPrIntelligenceComment({
repo,
pr,
profile,
detection,
queueHealth,
collisions,
preflight,
settings,
const commentArgs = { repo, pr, profile, detection, queueHealth, collisions, preflight, settings };
const deterministicBody = buildPublicPrIntelligenceComment(commentArgs);
// Optional AI rewrite (issue #151): disabled by default, source-free bundle only, quota-limited,
// sanitizer-gated, and falls back to the deterministic body on any non-ok outcome.
const { body } = await rewritePublicPrIntelligenceComment(env, {
bundle: buildPublicCommentSignalBundle(commentArgs),
deterministicBody,
actor: author,
route: "github_app.pr_public_surface",
});
await createOrUpdatePrIntelligenceComment(env, installationId, repoFullName, pr.number, body);
}
Expand Down
155 changes: 155 additions & 0 deletions src/services/ai-summaries.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { recordAiUsageEvent, recordAuditEvent, sumAiEstimatedNeuronsSince } from "../db/repositories";
import { sanitizePublicComment } from "../queue-intelligence";
import type { JsonValue } from "../types";
import type { AgentRunBundle } from "./agent-orchestrator";

const PR_INTELLIGENCE_MARKER = "<!-- gittensory-pr-intelligence -->";

type AiSummaryVisibility = "private" | "public";

const PRIVATE_CONTEXT_PATTERN =
Expand Down Expand Up @@ -170,6 +173,16 @@ function sanitizeAiText(value: string, visibility: AiSummaryVisibility): string
}

function containsPublicForbiddenText(value: string): boolean {
// Route every public AI output through the canonical public/private sanitizer (issue #151).
// `sanitizePublicComment` throws on any forbidden public term (wallet, hotkey, raw trust score,
// payout, reward estimate, farming, private reviewability, public score estimate).
try {
sanitizePublicComment(value);
} catch {
return true;
}
// Defense in depth: keep the centralized local pattern, which intentionally also catches near-miss
// phrasings the canonical word list narrows (e.g. bare "estimated score" or seed-phrase wording).
return PUBLIC_FORBIDDEN_TEXT_PATTERN.test(value);
}

Expand Down Expand Up @@ -222,6 +235,148 @@ function auditOutcomeForAiStatus(status: string): "success" | "denied" | "error"
return "completed";
}

export type AiRewriteRequest = {
feature: string;
visibility: AiSummaryVisibility;
bundle: Record<string, JsonValue>;
fallbackText: string;
instructions: string;
actor?: string | null | undefined;
route?: string | null | undefined;
metadata?: Record<string, unknown> | undefined;
};

export type AiRewriteOutcome = {
status: AiSummaryResult["status"];
/** Always safe to publish/use: equals `fallbackText` on every non-`ok` path. */
text: string;
model?: string;
estimatedNeurons?: number;
reason?: string;
};

/**
* Generic, reusable rewrite layer for issue #151. Turns a compact deterministic signal bundle into
* clearer prose when AI is enabled, and otherwise returns the caller's deterministic `fallbackText`.
* The returned `text` is ALWAYS safe to use: disabled, unavailable, quota-exceeded, unsafe, and error
* paths all fall back to the deterministic template, and every public `ok` result is gated by the
* canonical public/private sanitizer before it is returned.
*/
export async function rewriteSignalBundleWithAi(env: Env, req: AiRewriteRequest): Promise<AiRewriteOutcome> {
const privateEnabled = isEnabled(env.AI_SUMMARIES_ENABLED);
const publicEnabled = isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED);
if (!privateEnabled) return { status: "disabled", text: req.fallbackText, reason: "AI summaries are disabled." };
if (req.visibility === "public" && !publicEnabled) return { status: "disabled", text: req.fallbackText, reason: "Public AI summaries are disabled." };
if (!env.AI) return { status: "unavailable", text: req.fallbackText, reason: "Workers AI binding is not configured." };

const model = env.WORKERS_AI_SUMMARY_MODEL || "@cf/meta/llama-3.1-8b-instruct-fp8-fast";
const maxOutputTokens = clampNumber(Number(env.AI_MAX_OUTPUT_TOKENS || 256), 64, 512);
const prompt = buildBundlePrompt(req.bundle, req.visibility);
const estimatedNeurons = estimateNeurons(prompt, maxOutputTokens);
const budget = clampNumber(Number(env.AI_DAILY_NEURON_BUDGET || 10000), 0, 1_000_000);
const used = await sumAiEstimatedNeuronsSince(env, utcDayStartIso());
const remainingBudget = Math.max(0, budget - used);

if (estimatedNeurons > remainingBudget) {
await recordGenericAi(env, req, {
model,
status: "quota_exceeded",
estimatedNeurons: 0,
detail: `estimated ${estimatedNeurons} neurons exceeds remaining budget ${remainingBudget}`,
});
return { status: "quota_exceeded", text: req.fallbackText, model, estimatedNeurons };
}

try {
const response = await env.AI.run(model, {
messages: [
{ role: "system", content: req.instructions },
{ role: "user", content: prompt },
],
max_tokens: maxOutputTokens,
temperature: 0.1,
});
const rawText = extractAiText(response);
if (!rawText) throw new Error("empty_ai_summary");
if (req.visibility === "public" && containsPublicForbiddenText(rawText)) {
await recordGenericAi(env, req, { model, status: "unsafe", estimatedNeurons, detail: "public summary failed sanitizer" });
return { status: "unsafe", text: req.fallbackText, model, estimatedNeurons, reason: "public summary failed sanitizer" };
}
const text = sanitizeAiText(rawText, req.visibility);
await recordGenericAi(env, req, { model, status: "ok", estimatedNeurons, detail: "summary generated", metadata: { visibility: req.visibility } });
return { status: "ok", text, model, estimatedNeurons };
} catch (error) {
const reason = error instanceof Error ? error.message : "workers_ai_failed";
await recordGenericAi(env, req, { model, status: "error", estimatedNeurons: 0, detail: reason });
return { status: "error", text: req.fallbackText, model, estimatedNeurons, reason };
}
}

/**
* Public-surface wrapper used by the GitHub App PR intelligence comment. Builds the rewrite request,
* preserves the sticky-comment marker, and guarantees the deterministic body is posted whenever AI is
* disabled, over quota, unavailable, or produces unsafe output.
*/
export async function rewritePublicPrIntelligenceComment(
env: Env,
args: { bundle: Record<string, JsonValue>; deterministicBody: string; actor?: string | null | undefined; route?: string | null | undefined },
): Promise<{ body: string; outcome: AiRewriteOutcome }> {
const outcome = await rewriteSignalBundleWithAi(env, {
feature: "pr_intelligence_comment",
visibility: "public",
bundle: args.bundle,
fallbackText: args.deterministicBody,
instructions:
"Rewrite this deterministic Gittensory PR signal bundle as a short, friendly public GitHub comment with 3-5 bullet points. Only restate the facts provided. Never mention rewards, payouts, wallets, hotkeys, raw or estimated trust scores, score estimates, farming, or private reviewability, and never claim a guaranteed outcome.",
actor: args.actor,
route: args.route,
});
if (outcome.status !== "ok") return { body: args.deterministicBody, outcome };
const body = [
PR_INTELLIGENCE_MARKER,
"## Gittensory contribution context",
"",
"_AI-clarified from deterministic public GitHub metadata. Deterministic signals remain authoritative; this is not an endorsement._",
"",
outcome.text.trim(),
].join("\n");
return { body, outcome };
}

function buildBundlePrompt(signalBundle: Record<string, JsonValue>, visibility: AiSummaryVisibility): string {
return [
`Visibility: ${visibility}`,
"Summarize this deterministic Gittensory signal bundle clearly and concisely.",
"Do not invent facts or claim guaranteed outcomes.",
JSON.stringify(signalBundle),
].join("\n");
}

async function recordGenericAi(
env: Env,
req: AiRewriteRequest,
event: { model: string; status: string; estimatedNeurons: number; detail?: string; metadata?: Record<string, unknown> },
): Promise<void> {
await recordAiUsageEvent(env, {
feature: req.feature,
actor: req.actor,
route: req.route,
model: event.model,
status: event.status,
estimatedNeurons: event.estimatedNeurons,
detail: event.detail,
metadata: { ...(req.metadata ?? {}), ...(event.metadata ?? {}) },
});
await recordAuditEvent(env, {
eventType: "ai.summary",
actor: req.actor,
route: req.route,
outcome: auditOutcomeForAiStatus(event.status),
detail: event.detail,
metadata: { feature: req.feature, model: event.model, estimatedNeurons: event.estimatedNeurons },
});
}

export const __aiSummaryInternals = {
compactAgentSignalBundle,
estimateNeurons,
Expand Down
50 changes: 50 additions & 0 deletions src/signals/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
CollisionEdgeRecord,
ContributorRepoStatRecord,
IssueRecord,
JsonValue,
PullRequestDetailSyncStateRecord,
PullRequestFileRecord,
PullRequestRecord,
Expand Down Expand Up @@ -3303,6 +3304,55 @@ function containsPrivatePublicTerm(value: string): boolean {
return /\b(reward|payout|farming|wallet|hotkey|trust score|raw trust|estimated score|scoreability|likely_duplicate|reviewability\s*\d|\/100)\b/i.test(value);
}

/**
* Builds the compact, source-free signal bundle that the optional AI rewrite layer (issue #151)
* may turn into clearer public prose. It carries only deterministic, public-safe structured
* signals — counts, levels, booleans, role context, and finding category titles. It deliberately
* excludes PR title/body, diffs, finding detail text, and any other source contents so the bundle
* can never leak repository source through the AI provider.
*/
export function buildPublicCommentSignalBundle(args: {
repo: RepositoryRecord | null;
pr: PullRequestRecord;
profile: ContributorProfile;
detection: ContributorDetection;
queueHealth: QueueHealth;
collisions: CollisionReport;
preflight: PreflightResult;
settings: RepositorySettings;
}): Record<string, JsonValue> {
const roleContext = buildRoleContext({
login: args.pr.authorLogin ?? args.profile.login,
repo: args.repo,
repoFullName: args.pr.repoFullName,
pullRequests: [args.pr],
issues: [],
profile: args.profile,
});
const publicFindingTitles = args.preflight.findings
.filter((finding) => finding.severity !== "critical")
.filter((finding) => args.settings.requireLinkedIssue || finding.code !== "missing_linked_issue")
.filter((finding) => !containsPrivatePublicTerm([finding.code, finding.title].filter(Boolean).join(" ")))
.slice(0, args.settings.publicSignalLevel === "minimal" ? 2 : 5)
.map((finding) => finding.title);
return {
confirmedMiner: args.detection.source === "official_gittensor_api",
minerSignalDetected: args.detection.detected,
priorPullRequests: args.detection.priorPullRequests,
priorIssues: args.detection.priorIssues,
role: roleContext.role,
maintainerLane: roleContext.maintainerLane,
linkedIssueCount: args.pr.linkedIssues.length,
requireLinkedIssue: args.settings.requireLinkedIssue,
laneSummary: buildLaneAdvice(args.repo, args.pr.repoFullName).summary,
reviewBurden: args.preflight.reviewBurden,
collisionClusters: args.collisions.clusters.length,
queueLevel: args.queueHealth.level,
topLanguages: args.profile.github.topLanguages.slice(0, 6),
publicFindingTitles,
} as Record<string, JsonValue>;
}

function issueItem(issue: IssueRecord): CollisionItem {
return {
type: "issue",
Expand Down
Loading