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
4 changes: 4 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -7975,6 +7975,9 @@
"slopGateMinScore": {
"type": "number",
"nullable": true
},
"slopAiAdvisory": {
"type": "boolean"
}
},
"required": [
Expand All @@ -7990,6 +7993,7 @@
"duplicatePrGateMode",
"qualityGateMode",
"slopGateMode",
"slopAiAdvisory",
"autoLabelEnabled",
"gittensorLabel",
"createMissingLabel",
Expand Down
5 changes: 5 additions & 0 deletions migrations/0034_slop_ai_advisory.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Opt-in AI-assisted slop advisory (the `slopAiAdvisory` capability). When 1 AND slop_gate_mode != 'off', a
-- free Workers-AI pass adds an ADVISORY-only `ai_slop_advisory` finding for semantic slop the deterministic
-- detector cannot quantify. It NEVER feeds slopRisk or the gate (only the deterministic core can block).
-- Default 0 (off) preserves existing behavior for every current repo; opt-in via `.gittensory.yml`.
ALTER TABLE repository_settings ADD COLUMN slop_ai_advisory INTEGER NOT NULL DEFAULT 0;
9 changes: 9 additions & 0 deletions src/db/repositories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMinScore: null,
slopGateMode: "off",
slopGateMinScore: null,
slopAiAdvisory: false,
aiReviewMode: "off",
aiReviewByok: false,
aiReviewProvider: null,
Expand Down Expand Up @@ -430,6 +431,7 @@ export async function getRepositorySettings(env: Env, fullName: string): Promise
qualityGateMinScore: normalizeQualityGateMinScore(row.qualityGateMinScore),
slopGateMode: parseGateRuleMode(row.slopGateMode),
slopGateMinScore: normalizeQualityGateMinScore(row.slopGateMinScore),
slopAiAdvisory: row.slopAiAdvisory,
aiReviewMode: parseGateRuleMode(row.aiReviewMode),
aiReviewByok: row.aiReviewByok,
aiReviewProvider: normalizeAiReviewProvider(row.aiReviewProvider),
Expand Down Expand Up @@ -464,6 +466,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: normalizeQualityGateMinScore(settings.qualityGateMinScore),
slopGateMode: settings.slopGateMode ?? "off",
slopGateMinScore: normalizeQualityGateMinScore(settings.slopGateMinScore),
slopAiAdvisory: settings.slopAiAdvisory ?? false,
aiReviewMode: settings.aiReviewMode ?? "off",
aiReviewByok: settings.aiReviewByok ?? false,
aiReviewProvider: normalizeAiReviewProvider(settings.aiReviewProvider),
Expand Down Expand Up @@ -496,6 +499,7 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
qualityGateMinScore: resolved.qualityGateMinScore,
slopGateMode: resolved.slopGateMode,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
aiReviewProvider: resolved.aiReviewProvider,
Expand Down Expand Up @@ -525,6 +529,11 @@ export async function upsertRepositorySettings(env: Env, settings: Partial<Repos
duplicatePrGateMode: resolved.duplicatePrGateMode,
qualityGateMode: resolved.qualityGateMode,
qualityGateMinScore: resolved.qualityGateMinScore,
// slop_* were previously absent from the UPDATE branch (only INSERT), so slop settings did not
// persist on update of an existing row. Restored here alongside the new slopAiAdvisory field.
slopGateMode: resolved.slopGateMode,
slopGateMinScore: resolved.slopGateMinScore,
slopAiAdvisory: resolved.slopAiAdvisory,
aiReviewMode: resolved.aiReviewMode,
aiReviewByok: resolved.aiReviewByok,
aiReviewProvider: resolved.aiReviewProvider,
Expand Down
1 change: 1 addition & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const repositorySettings = sqliteTable("repository_settings", {
qualityGateMinScore: integer("quality_gate_min_score"),
slopGateMode: text("slop_gate_mode").notNull().default("off"),
slopGateMinScore: integer("slop_gate_min_score"),
slopAiAdvisory: integer("slop_ai_advisory", { mode: "boolean" }).notNull().default(false),
aiReviewMode: text("ai_review_mode").notNull().default("off"),
aiReviewByok: integer("ai_review_byok", { mode: "boolean" }).notNull().default(false),
aiReviewProvider: text("ai_review_provider"),
Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export const RepositorySettingsSchema = z
qualityGateMinScore: z.number().nullable().optional(),
slopGateMode: z.enum(["off", "advisory", "block"]),
slopGateMinScore: z.number().nullable().optional(),
slopAiAdvisory: z.boolean(),
autoLabelEnabled: z.boolean(),
gittensorLabel: z.string(),
createMissingLabel: z.boolean(),
Expand Down
42 changes: 41 additions & 1 deletion src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ import {
PR_PANEL_RETRIGGER_MARKER,
unionScopedOverlapClusters,
} from "../signals/engine";
import { buildSlopAssessment } from "../signals/slop";
import { buildSlopAssessment, type SlopBand } from "../signals/slop";
import { runGittensoryAiSlopAdvisory } from "../services/ai-slop";
import { decidePublicSurface } from "../signals/settings-preview";
import { loadRepoFocusManifest } from "../signals/focus-manifest-loader";
import { resolveEffectiveSettings } from "../signals/focus-manifest";
Expand Down Expand Up @@ -925,6 +926,40 @@ export async function runAiReviewForAdvisory(
}
}

/**
* AI-assisted slop advisory (opt-in `slopAiAdvisory`). Appends at most one ADVISORY-only `ai_slop_advisory`
* finding to the advisory; NEVER touches slopRisk or the gate (only the deterministic core can block). The
* caller gates on `settings.slopAiAdvisory` and reuses the already-fetched changed files. Fail-safe: any AI
* error is swallowed so the gate still finalizes.
*/
export async function runAiSlopForAdvisory(
env: Env,
args: {
advisory: Awaited<ReturnType<typeof buildPullRequestAdvisory>>;
repoFullName: string;
pr: { number: number; title: string; body?: string | null | undefined };
author: string | null;
files: Awaited<ReturnType<typeof listPullRequestFiles>>;
deterministicBand: SlopBand;
},
): Promise<void> {
if (!args.advisory.headSha) return;
try {
const result = await runGittensoryAiSlopAdvisory(env, {
repoFullName: args.repoFullName,
prNumber: args.pr.number,
title: args.pr.title,
body: args.pr.body ?? undefined,
diff: buildAiReviewDiff(args.files),
actor: args.author,
deterministicBand: args.deterministicBand,
});
if (result.status === "ok" && result.finding) args.advisory.findings.push(result.finding);
} catch (error) {
console.error(JSON.stringify({ level: "warn", event: "ai_slop_failed", repository: args.repoFullName, pullNumber: args.pr.number, error: errorMessage(error) }));
}
}

function linkedIssueDuplicatePullRequestsForGate(pr: PullRequestRecord, pullRequests: PullRequestRecord[]): number[] {
const linkedIssues = new Set(pr.linkedIssues);
if (linkedIssues.size === 0) return [];
Expand Down Expand Up @@ -1117,6 +1152,11 @@ async function maybePublishPrPublicSurface(
});
slopRisk = slop.slopRisk;
advisory.findings.push(...slop.findings);
// AI-assisted slop advisory (#533, opt-in). Reuses the already-fetched files; appends at most one
// advisory-only finding. Deliberately does NOT update slopRisk — only the deterministic core blocks.
if (settings.slopAiAdvisory) {
await runAiSlopForAdvisory(env, { advisory, repoFullName, pr, author, files: slopFiles, deterministicBand: slop.band });
}
}

if (gateEnabled && author && !publicSurfaceSkipped && !official) {
Expand Down
10 changes: 6 additions & 4 deletions src/services/ai-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,21 +87,23 @@ type ModelReview = {
type AiGatewayOptions = { gateway?: { id: string } };
type AiRunner = { run?: (model: string, options: Record<string, unknown>, extra?: AiGatewayOptions) => Promise<unknown> };

function isEnabled(value: string | undefined): boolean {
// Exported so the sibling AI-advisory features (e.g. the slop advisory in `./ai-slop`) share ONE budget
// window + neuron estimator and never drift from the review path's accounting.
export function isEnabled(value: string | undefined): boolean {
return /^(1|true|yes|on)$/i.test(value ?? "");
}

function clampNumber(value: number, min: number, max: number): number {
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(max, Math.max(min, Math.floor(value)));
}

function utcDayStartIso(): string {
export function utcDayStartIso(): string {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString();
}

function estimateNeurons(promptChars: number, maxOutputTokens: number, calls: number): number {
export function estimateNeurons(promptChars: number, maxOutputTokens: number, calls: number): number {
const inputTokens = Math.ceil(promptChars / 4);
return Math.max(1, Math.ceil((inputTokens + maxOutputTokens) * 0.035) * Math.max(1, calls));
}
Expand Down
207 changes: 207 additions & 0 deletions src/services/ai-slop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Gittensory AI-assisted slop advisory (the `slopAiAdvisory` capability).
//
// An ADVISORY-ONLY second opinion that augments the deterministic slop detector (src/signals/slop.ts).
// It exists to catch the SEMANTIC slop the deterministic rules cannot quantify — generated boilerplate
// that does not match the stated intent, comments that merely restate code, cosmetic churn dressed up as
// substantive work, a description that does not correspond to the diff.
//
// Hard guarantees (so AI assistance never changes who can be blocked — only the deterministic core blocks):
// • It NEVER feeds `slopRisk` or the gate. Its output is a single advisory `SignalFinding` with the code
// `ai_slop_advisory`, which `isConfiguredGateBlocker` does not recognise, so it can never be a blocker.
// • Severity is at most `warning` (never `critical`), so it cannot be mistaken for a consensus defect.
// • Fail-safe on every path: AI off / no binding / over-budget / unparseable / unsafe text → no finding.
// • Opt-in: only runs when the repo set `gate.slop.aiAdvisory: true` on top of `gate.slop.mode != off`.
//
// Free Cloudflare Workers AI only (one call, metered against the shared daily neuron budget). BYOK is a
// possible later enhancement; slop assessment does not need a frontier model. Every public string is
// forced through `toPublicSafe`; anything tripping the public/private boundary is dropped, not published.
import type { SignalFinding } from "../signals/engine";
import type { SlopBand } from "../signals/slop";
import { recordAiUsageEvent, sumAiEstimatedNeuronsSince } from "../db/repositories";
import {
BEST_REVIEW_MODELS,
RELIABLE_FALLBACK_MODELS,
clampNumber,
coerceAiText,
estimateNeurons,
isEnabled,
toPublicSafe,
utcDayStartIso,
} from "./ai-review";

/** The finding code carried by the AI slop advisory. Deliberately NOT recognised by the gate's
* `isConfiguredGateBlocker`, which is what guarantees this advisory can never block. */
export const AI_SLOP_FINDING_CODE = "ai_slop_advisory";

const SLOP_SYSTEM_PROMPT = [
"You are a senior open-source maintainer giving a SECOND OPINION on whether a pull request shows signs of",
"low-effort, automated, or padding-style contribution ('slop'). Deterministic checks already ran; you add",
"judgement they cannot, focusing on semantics.",
"Judge ONLY the diff and context provided. Be conservative and fair — most pull requests are genuine.",
"Reserve 'elevated' or 'high' for clear, evidence-backed cases: generated boilerplate that does not match",
"the stated intent, comments that merely restate the code, no-op or cosmetic churn presented as",
"substantive, or a description that does not correspond to the diff. When in doubt, choose 'clean' or 'low'.",
"Never accuse; describe the observable characteristics constructively so the maintainer can decide.",
"Never mention rewards, rankings, payouts, wallets, hotkeys, coldkeys, trust scores, scoreability,",
"reviewability, or farming.",
"Respond with ONLY a JSON object of this exact shape (no prose, no code fence):",
'{"band": "clean"|"low"|"elevated"|"high", "rationale": string, "signals": string[]}',
].join(" ");

export type AiSlopInput = {
repoFullName: string;
prNumber: number;
title: string;
body?: string | null | undefined;
/** A bounded unified-diff-ish string (filenames + patches), built by the caller. */
diff: string;
actor?: string | null | undefined;
/** The deterministic band already computed for this PR — passed as context so the model can corroborate
* or temper it. Never used to override the model's own judgement. */
deterministicBand?: SlopBand | undefined;
};

export type AiSlopResult =
| { status: "disabled"; reason: string }
| { status: "unavailable"; reason: string }
| { status: "quota_exceeded"; estimatedNeurons: number; remainingBudget: number }
| { status: "ok"; finding: SignalFinding | null; band: SlopBand | null; estimatedNeurons: number };

type SlopOpinion = { band: SlopBand; rationale: string; signals: string[] };

const SLOP_BANDS: readonly SlopBand[] = ["clean", "low", "elevated", "high"];

function isSlopBand(value: unknown): value is SlopBand {
return typeof value === "string" && (SLOP_BANDS as readonly string[]).includes(value);
}

type AiGatewayOptions = { gateway?: { id: string } };
type AiRunner = { run?: (model: string, options: Record<string, unknown>, extra?: AiGatewayOptions) => Promise<unknown> };

/** Parse a model's JSON slop opinion into a normalized {@link SlopOpinion}, or null when unusable. */
export function parseSlopOpinion(text: string): SlopOpinion | null {
const match = text
.replace(/^```(?:json)?\s*/i, "")
.replace(/```$/i, "")
.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
const obj = JSON.parse(match[0]) as Record<string, unknown>;
if (!isSlopBand(obj.band)) return null;
const rationale = typeof obj.rationale === "string" ? obj.rationale.trim().slice(0, 400) : "";
const signals = Array.isArray(obj.signals)
? obj.signals.filter((x): x is string => typeof x === "string").map((x) => x.trim()).filter(Boolean).slice(0, 4)
: [];
if (!rationale && signals.length === 0) return null;
return { band: obj.band, rationale, signals };
} catch {
return null;
}
}

/**
* Convert a parsed opinion into a public-safe advisory finding, or null to add nothing. Returns null for a
* `clean` band (no noise) and whenever the public text does not survive the public/private sanitizer.
*/
export function slopFindingFromOpinion(opinion: SlopOpinion): SignalFinding | null {
if (opinion.band === "clean") return null;
const safeRationale = toPublicSafe(opinion.rationale);
const safeSignals = opinion.signals.map((s) => toPublicSafe(s)).filter((s): s is string => Boolean(s));
// Nothing publishable survived sanitization → drop the advisory entirely (fail-safe, never publish).
if (!safeRationale && safeSignals.length === 0) return null;
const detailBody = safeRationale ?? "An AI maintainer-assist pass flagged possible low-effort patterns in this change.";
const detail = safeSignals.length > 0 ? `${detailBody} Observations: ${safeSignals.join("; ")}.` : detailBody;
const publicText = `AI maintainer-assist (advisory): ${detail}`;
return {
code: AI_SLOP_FINDING_CODE,
title: `AI maintainer-assist flagged possible low-effort patterns (${opinion.band})`,
// `elevated`/`high` read as a warning; `low` as an informational note. NEVER `critical` (never a blocker).
severity: opinion.band === "elevated" || opinion.band === "high" ? "warning" : "info",
detail,
action: "Advisory only — review the noted patterns; this AI assist never blocks the gate.",
publicText,
};
}

/** One free Workers-AI slop opinion with a reliable per-slot fallback and a 3× retry on the primary. */
async function runWorkersSlopOpinion(env: Env, system: string, user: string, maxTokens: number): Promise<SlopOpinion | null> {
const ai = env.AI as unknown as AiRunner | undefined;
if (!ai || typeof ai.run !== "function") return null;
const gatewayId = env.AI_GATEWAY_ID?.trim();
const extra: AiGatewayOptions | undefined = gatewayId ? { gateway: { id: gatewayId } } : undefined;
// Primary then a reliable per-slot fallback (distinct model families), 3× retry each before giving up.
for (const model of [BEST_REVIEW_MODELS[0], RELIABLE_FALLBACK_MODELS[0]]) {
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
const result = await ai.run(
model,
{ max_tokens: maxTokens, temperature: 0, messages: [{ role: "system", content: system }, { role: "user", content: user }] },
extra,
);
const parsed = parseSlopOpinion(coerceAiText(result));
if (parsed) return parsed;
} catch {
/* retry / fall through to fallback */
}
}
}
return null;
}

function buildUserPrompt(input: AiSlopInput): string {
return [
`Repository: ${input.repoFullName}`,
`Pull request #${input.prNumber}: ${input.title}`,
input.body ? `Description:\n${input.body.slice(0, 2000)}` : "Description: (none)",
input.deterministicBand ? `Deterministic slop band (for reference): ${input.deterministicBand}` : "",
"",
"Unified diff (truncated if large):",
input.diff.slice(0, 60000),
]
.filter(Boolean)
.join("\n");
}

/**
* Run the AI slop advisory. Returns a single advisory finding (or null) plus the model's band. Fail-safe on
* every path: no finding and no thrown error ever reaches the caller.
*/
export async function runGittensoryAiSlopAdvisory(env: Env, input: AiSlopInput): Promise<AiSlopResult> {
if (!isEnabled(env.AI_SUMMARIES_ENABLED)) return { status: "disabled", reason: "AI summaries are disabled." };
if (!isEnabled(env.AI_PUBLIC_COMMENTS_ENABLED)) return { status: "disabled", reason: "Public AI comments are disabled." };
if (!env.AI) return { status: "unavailable", reason: "Workers AI binding is not configured." };

const maxTokens = clampNumber(Number(env.AI_MAX_OUTPUT_TOKENS || 256), 256, 1024);
const user = buildUserPrompt(input);
const estimatedNeurons = estimateNeurons(SLOP_SYSTEM_PROMPT.length + user.length, maxTokens, 1);
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 record(env, input, "quota_exceeded", 0, `estimated ${estimatedNeurons} neurons exceeds remaining ${remainingBudget}`);
return { status: "quota_exceeded", estimatedNeurons, remainingBudget };
}

const opinion = await runWorkersSlopOpinion(env, SLOP_SYSTEM_PROMPT, user, maxTokens);
const finding = opinion ? slopFindingFromOpinion(opinion) : null;
await record(env, input, "ok", estimatedNeurons, finding ? `advisory finding (${opinion?.band})` : opinion ? `clean/no-op (${opinion.band})` : "no usable output", {
band: opinion?.band ?? null,
surfaced: Boolean(finding),
});
return { status: "ok", finding, band: opinion?.band ?? null, estimatedNeurons };
}

async function record(env: Env, input: AiSlopInput, status: string, estimatedNeurons: number, detail: string, metadata?: Record<string, unknown>): Promise<void> {
await recordAiUsageEvent(env, {
feature: "ai_slop_pr",
actor: input.actor ?? null,
route: "github_app.ai_slop",
model: BEST_REVIEW_MODELS.join("+"),
status,
estimatedNeurons,
detail,
metadata: { repoFullName: input.repoFullName, pullNumber: input.prNumber, ...(metadata ?? {}) },
});
}

export const __aiSlopInternals = { parseSlopOpinion, slopFindingFromOpinion, buildUserPrompt };
Loading
Loading