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: 3 additions & 1 deletion apps/ai/src/chat/turn-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
{ InvestigationServicesLive },
{ layerPg },
{ mapleDbConnectionLayer },
{ layerDecisionModel, layerLlm, resolveTriageModel },
{ layerDecisionModel, layerFindingEmbedder, layerLlm, resolveTriageModel },
{ McpToolExecutor },
] = await Promise.all([
import("../runtime/mcp-service-graph"),
Expand All @@ -268,6 +268,8 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
// Decisions before the clients: `layerLlm` is what answers the OpenRouter
// client the decision model runs on.
Layer.provideMerge(layerDecisionModel(input.env)),
// Read by `PrReviewService` as it is built: the review's feedback filter.
Layer.provideMerge(layerFindingEmbedder(input.env)),
Layer.provideMerge(layerLlm(input.env)),
Layer.provideMerge(layerPg),
Layer.provideMerge(mapleDbConnectionLayer(input.env)),
Expand Down
74 changes: 58 additions & 16 deletions apps/ai/src/platform/Llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* `@effect/ai-openai-compat` pointed at the account's OpenAI-compatible base URL. Both post to
* `/chat/completions`, which is what lets one shim serve the binding path.
*/
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai-compat"
import { OpenAiClient, OpenAiEmbeddingModel, OpenAiLanguageModel } from "@effect/ai-openai-compat"
import { OpenRouterClient, OpenRouterDecisionModel, OpenRouterLanguageModel } from "@effect/ai-openrouter"
import { MAPLE_NATIVE_SESSION_ID_ATTR, MAPLE_NATIVE_TURN_ID_ATTR } from "@maple/domain/gen-ai"
import { FindingEmbedder, PrReviewEmbeddingError } from "@maple/backend/services/pr-review/FindingEmbedder"
import { Effect, Layer, Option, Redacted, Schema } from "effect"
import type * as DecisionModel from "effect/unstable/ai/DecisionModel"
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
Expand Down Expand Up @@ -44,6 +45,15 @@ export const DEFAULT_DECISION_MODEL = "~typesafe/jev-latest"
const OPENROUTER_APP_URL = "https://maple.dev"
const OPENROUTER_APP_TITLE = "Maple"

/** OpenRouter's OpenAI-compatible API, which serves `/embeddings` alongside chat. */
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1"

/**
* Default embedding model for the review's feedback filter. Changing it starts the filter from an
* empty history: stored vectors are only compared with vectors from the same model.
*/
export const DEFAULT_EMBEDDING_MODEL = "openai/text-embedding-3-small"

/**
* Where a model call came from and what it is running for.
*
Expand Down Expand Up @@ -116,6 +126,8 @@ export interface LlmEnv extends Record<string, unknown> {
readonly OPENROUTER_API_KEY?: string
/** Decision model id, overriding {@link DEFAULT_DECISION_MODEL}. */
readonly MAPLE_DECISION_MODEL?: string
/** Embedding model id, overriding {@link DEFAULT_EMBEDDING_MODEL}. */
readonly MAPLE_EMBEDDING_MODEL?: string
}

/**
Expand Down Expand Up @@ -393,6 +405,19 @@ const splicePerCallFields = (
})
})

/** The HTTP client every OpenRouter call goes out on, with the app-attribution headers. */
const openRouterHttp = Layer.effect(HttpClient.HttpClient)(
Effect.map(HttpClient.HttpClient, (client) =>
HttpClient.mapRequest(
client,
HttpClientRequest.setHeaders({
"HTTP-Referer": OPENROUTER_APP_URL,
"X-Title": OPENROUTER_APP_TITLE,
}),
),
),
)

/**
* The runnable LLM stack — both provider clients, so the switch stays a pure env flip.
*
Expand All @@ -409,21 +434,7 @@ export const layerLlm = (env: LlmEnv): Layer.Layer<LlmClients> => {
OpenRouterClient.layer({
apiKey: Redacted.make(readString(env, "OPENROUTER_API_KEY") ?? ""),
transformClient: withPerCallFields,
}).pipe(
Layer.provide(
Layer.effect(HttpClient.HttpClient)(
Effect.map(HttpClient.HttpClient, (client) =>
HttpClient.mapRequest(
client,
HttpClientRequest.setHeaders({
"HTTP-Referer": OPENROUTER_APP_URL,
"X-Title": OPENROUTER_APP_TITLE,
}),
),
),
).pipe(Layer.provide(http)),
),
),
}).pipe(Layer.provide(openRouterHttp.pipe(Layer.provide(http)))),
// The URL the shim already matches: `.../ai/v1/chat/completions`.
OpenAiClient.layer({
apiKey: Redacted.make(readString(env, "CLOUDFLARE_API_KEY") ?? BINDING_PLACEHOLDER),
Expand All @@ -448,3 +459,34 @@ export const layerDecisionModel = (
/** The decision model this deploy asks, so a verdict can record what answered it. */
export const resolveDecisionModel = (env: LlmEnv): string =>
readString(env, "MAPLE_DECISION_MODEL") ?? DEFAULT_DECISION_MODEL

/**
* The embedder the PR review's feedback filter compares findings with, on OpenRouter whichever
* provider the agents run on: Workers AI's binding shim only answers chat. Its `OpenAiClient` is
* private to this layer, so it never replaces the Workers AI one `layerLlm` provides. Without an
* OpenRouter key there is no embedder, and the filter is off.
*/
export const layerFindingEmbedder = (env: LlmEnv): Layer.Layer<FindingEmbedder> | Layer.Layer<never> => {
const apiKey = readString(env, "OPENROUTER_API_KEY")
if (apiKey === undefined) return Layer.empty
const model = readString(env, "MAPLE_EMBEDDING_MODEL") ?? DEFAULT_EMBEDDING_MODEL
return Layer.effect(FindingEmbedder)(
Effect.map(OpenAiEmbeddingModel.make({ model }), (embeddings) => ({
model,
embed: (inputs: ReadonlyArray<string>) =>
embeddings.embedMany(inputs).pipe(
Effect.map((response) => response.embeddings.map((embedding) => embedding.vector)),
Effect.mapError(
(error) =>
new PrReviewEmbeddingError({ message: error.message, model, cause: error }),
),
),
})),
).pipe(
Layer.provide(
OpenAiClient.layer({ apiKey: Redacted.make(apiKey), apiUrl: OPENROUTER_API_URL }).pipe(
Layer.provide(openRouterHttp.pipe(Layer.provide(FetchHttpClient.layer))),
),
),
)
}
38 changes: 38 additions & 0 deletions apps/web/src/components/integrations/pr-review-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Exit, Schema } from "effect"
import {
GithubPrReviewConfigRequest,
PrReviewCategory,
PrReviewFeedbackScope,
PrReviewRepositoryConfig,
PrReviewSeverity,
type GithubRepoSummary,
Expand Down Expand Up @@ -67,6 +68,13 @@ const SEVERITY_LABELS = {
} satisfies Record<PrReviewSeverity, string>
const isSeverity = Schema.is(PrReviewSeverity)

const FEEDBACK_LABELS = {
organization: "Whole organization",
repository: "This repository",
off: "Off",
} satisfies Record<PrReviewFeedbackScope, string>
const isFeedbackScope = Schema.is(PrReviewFeedbackScope)

const SKIP_LABELS = {
disabled: "reviews off",
draft: "draft",
Expand Down Expand Up @@ -155,6 +163,7 @@ interface FormState {
readonly minInlineSeverity: PrReviewSeverity
readonly reviewDrafts: boolean
readonly dailyLimit: string
readonly feedbackScope: PrReviewFeedbackScope
}

const stateFromConfig = (config: PrReviewRepositoryConfig): FormState => ({
Expand All @@ -164,6 +173,7 @@ const stateFromConfig = (config: PrReviewRepositoryConfig): FormState => ({
minInlineSeverity: config.minInlineSeverity ?? "warn",
reviewDrafts: config.reviewDrafts ?? false,
dailyLimit: config.dailyLimit === undefined ? "" : String(config.dailyLimit),
feedbackScope: config.feedbackScope ?? "organization",
})

const sameState = (a: FormState, b: FormState) =>
Expand All @@ -172,6 +182,7 @@ const sameState = (a: FormState, b: FormState) =>
a.minInlineSeverity === b.minInlineSeverity &&
a.reviewDrafts === b.reviewDrafts &&
a.dailyLimit === b.dailyLimit &&
a.feedbackScope === b.feedbackScope &&
a.categories.length === b.categories.length &&
a.categories.every((category) => b.categories.includes(category))

Expand Down Expand Up @@ -212,6 +223,7 @@ const configFromState = (state: FormState) => {
...(state.minInlineSeverity === "warn" ? undefined : { minInlineSeverity: state.minInlineSeverity }),
...(state.reviewDrafts ? { reviewDrafts: true } : undefined),
...(limit === "" ? undefined : { dailyLimit: Number(limit) }),
...(state.feedbackScope === "organization" ? undefined : { feedbackScope: state.feedbackScope }),
})
}

Expand Down Expand Up @@ -361,6 +373,32 @@ function ConfigForm({ repo, config }: { repo: GithubRepoSummary; config: PrRevie
Reviews per UTC day. The organization limit still applies.
</p>
</div>

<div className="flex flex-col gap-1.5">
<Label htmlFor={`${id}-feedback`}>Learn from feedback</Label>
<Select
items={FEEDBACK_LABELS}
value={state.feedbackScope}
onValueChange={(value) => {
if (isFeedbackScope(value)) update({ feedbackScope: value })
}}
>
<SelectTrigger id={`${id}-feedback`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PrReviewFeedbackScope.literals.map((scope) => (
<SelectItem key={scope} value={scope}>
{FEEDBACK_LABELS[scope]}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Skips findings like ones your team downvoted or dismissed. Security and critical
findings are always posted.
</p>
</div>
</div>

<label htmlFor={`${id}-drafts`} className="flex items-center justify-between gap-3">
Expand Down
17 changes: 17 additions & 0 deletions docs/pr-review-agent-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,23 @@ one product behind the same `prreview` rollout flag. Observability is one lens o
- When a pull request closes, its threads are read once more for reactions and dismissals.
`maple.pr_review.reactions_up` / `reactions_down` on the span are the live precision signal.

### Learning from feedback

- Every stored finding's `category: title` and body are embedded (`FindingEmbedder`, wired in
`apps/ai/src/platform/Llm.ts`, OpenRouter `/embeddings`, `openai/text-embedding-3-small` unless
`MAPLE_EMBEDDING_MODEL` says otherwise) into `pr_review_finding_embeddings`, keyed by finding and
stamped with the model. Vectors from different models are never compared.
- A stored finding is negative when dismissed or when 👎 outnumber 👍; positive when 👍 outnumber
👎 or a later head fixed it; otherwise it does not vote.
- In `submit_review`, after ignored paths, lenses and repeats, each new finding is compared with the
2,000 newest voting findings: cosine ≥ 0.5 to at least three negatives and fewer than three
positives suppresses it. It is neither stored nor posted, and `maple.pr_review.suppressed` counts
it. Security and critical findings are never suppressed. The 0.5 bar was measured on the default
model (same kind of comment 0.52 to 0.68, different kinds at most 0.44); re-measure on a new one.
- The pool is the organization by default; `feedbackScope` in the repository settings narrows it
to the repository or turns the filter off (vectors are still stored, so turning it back on has
history). No embedder, or an embedding or read failure, posts every finding.

### Conversation (`@maple`)

- `issue_comment` and `pull_request_review_comment` webhooks become `pull-request-comment` jobs only
Expand Down
27 changes: 27 additions & 0 deletions packages/backend/src/services/pr-review/FindingEmbedder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* The port the review's feedback filter embeds finding text through. The model behind it is wired
* in `apps/ai/src/platform/Llm.ts`; where it is absent the filter is off and every finding posts.
*/
import { Context, type Effect, Schema } from "effect"

export class PrReviewEmbeddingError extends Schema.TaggedError<PrReviewEmbeddingError>()(
"@maple/backend/services/pr-review/PrReviewEmbeddingError",
{
message: Schema.String,
model: Schema.String,
cause: Schema.optionalKey(Schema.Defect()),
},
) {}

export interface FindingEmbedderApi {
/** Stored beside each vector: vectors from two models are never compared. */
readonly model: string
/** One vector per input, in input order. */
readonly embed: (
inputs: ReadonlyArray<string>,
) => Effect.Effect<ReadonlyArray<ReadonlyArray<number>>, PrReviewEmbeddingError>
}

export class FindingEmbedder extends Context.Service<FindingEmbedder, FindingEmbedderApi>()(
"@maple/backend/services/pr-review/FindingEmbedder",
) {}
Loading
Loading