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
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wingdiff/server",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"type": "module",
"bin": {
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/providers/tourPrompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ describe("tour generation prompt", () => {
expect(prompt).toContain("<validated_evidence>");
expect(prompt).toContain("a2\tnew:8");
expect(prompt).toContain("<required_coverage>");
expect(prompt).toContain("<text_limits>");
expect(prompt).toContain("finding title: 160; body: 420");
expect(prompt).toContain("- src/store.ts: include at least one of [a2] in a stop.anchorIds array");
expect(prompt).toContain("verify that every required-coverage row is represented");
expect(prompt).not.toContain("line_def");
Expand All @@ -51,6 +53,9 @@ describe("tour generation prompt", () => {
expect(schema.$defs.anchorId.enum).toEqual(["a1", "a2"]);
expect(stop.properties.anchorIds.items).toEqual({ $ref: "#/$defs/anchorId" });
expect(stop.properties.claims.items.properties.anchorIds.items).toEqual({ $ref: "#/$defs/anchorId" });
expect(stop.properties.finding.anyOf[0].properties.body).toEqual({ type: "string", minLength: 1, maxLength: 420 });
expect(stop.properties.prompts.items.maxLength).toBe(220);
expect(schema.properties.summary.maxLength).toBe(500);
});
});

Expand Down
50 changes: 33 additions & 17 deletions apps/server/src/providers/tourPrompt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildTourPrompt, type TourGenerationInput } from "../tour.js";
import { buildTourPrompt, TOUR_TEXT_LIMITS, type TourGenerationInput } from "../tour.js";

export const TOUR_INSTRUCTIONS = `You are preparing a guided code review for an experienced engineer.

Expand All @@ -13,11 +13,17 @@ Write for a developer making a review decision. Be direct, specific, and compact

Return only JSON matching the supplied schema. Anchor IDs are compact request-local aliases such as a17. Reference only exact anchor IDs from the evidence.`;

const stringArray = (minimum: number, maximum: number) => ({
const boundedString = (maxLength: number) => ({
type: "string",
minLength: 1,
maxLength,
}) as const;

const stringArray = (minimum: number, maximum: number, maxLength: number) => ({
type: "array",
minItems: minimum,
maxItems: maximum,
items: { type: "string" },
items: boundedString(maxLength),
}) as const;

const anchorArray = (minimum: number, maximum: number) => ({
Expand All @@ -33,12 +39,12 @@ export function tourJsonSchema(input: TourGenerationInput) {
additionalProperties: false,
required: ["title", "body", "severity", "category", "anchorIds", "suggestedComment"],
properties: {
title: { type: "string" },
body: { type: "string" },
title: boundedString(TOUR_TEXT_LIMITS.findingTitle),
body: boundedString(TOUR_TEXT_LIMITS.findingBody),
severity: { type: "string", enum: ["high", "medium", "low"] },
category: { type: "string" },
category: boundedString(TOUR_TEXT_LIMITS.findingCategory),
anchorIds: anchorArray(1, 8),
suggestedComment: { type: "string" },
suggestedComment: boundedString(TOUR_TEXT_LIMITS.suggestedComment),
},
} as const;

Expand All @@ -50,7 +56,7 @@ export function tourJsonSchema(input: TourGenerationInput) {
},
required: ["summary", "stops", "findingRevisions"],
properties: {
summary: { type: "string" },
summary: boundedString(TOUR_TEXT_LIMITS.summary),
stops: {
type: "array",
minItems: 1,
Expand All @@ -60,10 +66,10 @@ export function tourJsonSchema(input: TourGenerationInput) {
additionalProperties: false,
required: ["id", "title", "summary", "purpose", "anchorIds", "claims", "prompts", "finding"],
properties: {
id: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
title: { type: "string" },
summary: { type: "string" },
purpose: { type: "string" },
id: { ...boundedString(TOUR_TEXT_LIMITS.stopId), pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" },
title: boundedString(TOUR_TEXT_LIMITS.stopTitle),
summary: boundedString(TOUR_TEXT_LIMITS.stopSummary),
purpose: boundedString(TOUR_TEXT_LIMITS.stopPurpose),
anchorIds: anchorArray(1, 24),
claims: {
type: "array",
Expand All @@ -74,14 +80,14 @@ export function tourJsonSchema(input: TourGenerationInput) {
additionalProperties: false,
required: ["text", "kind", "confidence", "anchorIds"],
properties: {
text: { type: "string" },
text: boundedString(TOUR_TEXT_LIMITS.claimText),
kind: { type: "string", enum: ["fact", "inference", "unknown"] },
confidence: { type: "string", enum: ["high", "medium", "low"] },
anchorIds: anchorArray(1, 8),
},
},
},
prompts: stringArray(0, 4),
prompts: stringArray(0, 4, TOUR_TEXT_LIMITS.prompt),
finding: { anyOf: [findingSchema, { type: "null" }] },
},
},
Expand All @@ -94,9 +100,9 @@ export function tourJsonSchema(input: TourGenerationInput) {
additionalProperties: false,
required: ["findingId", "state", "summary", "anchorIds"],
properties: {
findingId: { type: "string" },
findingId: boundedString(TOUR_TEXT_LIMITS.findingRevisionId),
state: { type: "string", enum: ["still-applies", "appears-addressed", "recheck", "superseded"] },
summary: { type: "string" },
summary: boundedString(TOUR_TEXT_LIMITS.findingRevisionSummary),
anchorIds: anchorArray(0, 8),
},
},
Expand All @@ -106,7 +112,17 @@ export function tourJsonSchema(input: TourGenerationInput) {
}

export function buildTourRequestPrompt(input: TourGenerationInput): string {
return `${buildTourPrompt(input)}\n\n${coverageChecklist(input)}\n\nCreate the guided review tour. Before returning JSON, verify that every required-coverage row is represented in at least one stop.anchorIds array. Use null for finding when the evidence does not support a concrete issue.`;
return `${buildTourPrompt(input)}\n\n${coverageChecklist(input)}\n\n${textLimitChecklist()}\n\nCreate the guided review tour. Before returning JSON, verify that every required-coverage row is represented in at least one stop.anchorIds array and every string stays within its character limit. Use null for finding when the evidence does not support a concrete issue.`;
}

function textLimitChecklist(): string {
return `<text_limits>
- tour summary: ${TOUR_TEXT_LIMITS.summary}
- stop id: ${TOUR_TEXT_LIMITS.stopId}; title: ${TOUR_TEXT_LIMITS.stopTitle}; summary: ${TOUR_TEXT_LIMITS.stopSummary}; purpose: ${TOUR_TEXT_LIMITS.stopPurpose}
- claim text: ${TOUR_TEXT_LIMITS.claimText}; prompt: ${TOUR_TEXT_LIMITS.prompt}
- finding title: ${TOUR_TEXT_LIMITS.findingTitle}; body: ${TOUR_TEXT_LIMITS.findingBody}; category: ${TOUR_TEXT_LIMITS.findingCategory}; suggestedComment: ${TOUR_TEXT_LIMITS.suggestedComment}
- finding revision id: ${TOUR_TEXT_LIMITS.findingRevisionId}; summary: ${TOUR_TEXT_LIMITS.findingRevisionSummary}
</text_limits>`;
}

function coverageChecklist(input: TourGenerationInput): string {
Expand Down
42 changes: 29 additions & 13 deletions apps/server/src/tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ export type TourConfidence = "high" | "medium" | "low";
export type TourSeverity = "high" | "medium" | "low";
export type FindingContinuityState = "still-applies" | "appears-addressed" | "recheck" | "superseded";

export const TOUR_TEXT_LIMITS = {
summary: 500,
stopId: 64,
stopTitle: 140,
stopSummary: 360,
stopPurpose: 280,
claimText: 280,
prompt: 220,
findingRevisionId: 128,
findingRevisionSummary: 360,
findingTitle: 160,
findingBody: 420,
findingCategory: 80,
suggestedComment: 500,
} as const;

export interface TourEvidenceAnchor {
id: string;
path: string;
Expand Down Expand Up @@ -154,7 +170,7 @@ export function prepareModelTourInput(input: TourGenerationInput): ModelTourGene

export function validateGeneratedTour(raw: unknown, input: TourGenerationInput): GeneratedTour {
const value = record(raw, "tour");
const summary = boundedText(value.summary, "tour summary", 500);
const summary = boundedText(value.summary, "tour summary", TOUR_TEXT_LIMITS.summary);
const rawStops = list(value.stops, "tour stops", 1, 12);
const knownAnchors = new Set(input.anchors.map((anchor) => anchor.id));
const anchorToPath = new Map(input.anchors.map((anchor) => [anchor.id, anchor.path]));
Expand All @@ -167,7 +183,7 @@ export function validateGeneratedTour(raw: unknown, input: TourGenerationInput):

const stops = rawStops.map((candidate, index): GeneratedTourStop => {
const stop = record(candidate, `tour stop ${index + 1}`);
const id = boundedText(stop.id, `tour stop ${index + 1} id`, 64);
const id = boundedText(stop.id, `tour stop ${index + 1} id`, TOUR_TEXT_LIMITS.stopId);
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error(`Tour stop id must be a lowercase slug: ${id}`);
if (ids.has(id)) throw new Error(`Tour stop id is duplicated: ${id}`);
ids.add(id);
Expand All @@ -183,23 +199,23 @@ export function validateGeneratedTour(raw: unknown, input: TourGenerationInput):
const claimAnchorIds = anchors(claim.anchorIds, `${id} claim anchors`, knownAnchors, 1, 8);
if (kind !== "unknown") requirePreciseTextAnchor(claimAnchorIds, `${id} claim`, anchorById, pathsWithLineAnchors);
return {
text: boundedText(claim.text, `${id} claim text`, 280),
text: boundedText(claim.text, `${id} claim text`, TOUR_TEXT_LIMITS.claimText),
kind,
confidence: choice(claim.confidence, `${id} claim confidence`, ["high", "medium", "low"] as const),
anchorIds: claimAnchorIds,
};
});
const prompts = list(stop.prompts, `${id} prompts`, 0, 4)
.map((prompt, promptIndex) => boundedText(prompt, `${id} prompt ${promptIndex + 1}`, 220));
.map((prompt, promptIndex) => boundedText(prompt, `${id} prompt ${promptIndex + 1}`, TOUR_TEXT_LIMITS.prompt));
const finding = stop.finding === undefined || stop.finding === null
? undefined
: validateFinding(stop.finding, id, knownAnchors, anchorById, pathsWithLineAnchors);

return {
id,
title: boundedText(stop.title, `${id} title`, 140),
summary: boundedText(stop.summary, `${id} summary`, 360),
purpose: boundedText(stop.purpose, `${id} purpose`, 280),
title: boundedText(stop.title, `${id} title`, TOUR_TEXT_LIMITS.stopTitle),
summary: boundedText(stop.summary, `${id} summary`, TOUR_TEXT_LIMITS.stopSummary),
purpose: boundedText(stop.purpose, `${id} purpose`, TOUR_TEXT_LIMITS.stopPurpose),
anchorIds,
claims,
prompts,
Expand Down Expand Up @@ -284,7 +300,7 @@ function validateFindingRevisions(
const seen = new Set<string>();
const result = revisions.map((candidate, index): GeneratedFindingRevision => {
const revision = record(candidate, `finding revision ${index + 1}`);
const findingId = boundedText(revision.findingId, `finding revision ${index + 1} id`, 128);
const findingId = boundedText(revision.findingId, `finding revision ${index + 1} id`, TOUR_TEXT_LIMITS.findingRevisionId);
if (!priorIds.has(findingId)) throw new Error(`Finding revision references unknown prior finding ${findingId}.`);
if (seen.has(findingId)) throw new Error(`Finding revision is duplicated: ${findingId}`);
seen.add(findingId);
Expand All @@ -293,7 +309,7 @@ function validateFindingRevisions(
return {
findingId,
state,
summary: boundedText(revision.summary, `${findingId} revision summary`, 360),
summary: boundedText(revision.summary, `${findingId} revision summary`, TOUR_TEXT_LIMITS.findingRevisionSummary),
anchorIds,
};
});
Expand All @@ -314,12 +330,12 @@ function validateFinding(
const anchorIds = anchors(finding.anchorIds, `${stopId} finding anchors`, knownAnchors, 1, 8);
requirePreciseTextAnchor(anchorIds, `${stopId} finding`, anchorById, pathsWithLineAnchors);
return {
title: boundedText(finding.title, `${stopId} finding title`, 160),
body: boundedText(finding.body, `${stopId} finding body`, 420),
title: boundedText(finding.title, `${stopId} finding title`, TOUR_TEXT_LIMITS.findingTitle),
body: boundedText(finding.body, `${stopId} finding body`, TOUR_TEXT_LIMITS.findingBody),
severity: choice(finding.severity, `${stopId} finding severity`, ["high", "medium", "low"] as const),
category: boundedText(finding.category, `${stopId} finding category`, 80),
category: boundedText(finding.category, `${stopId} finding category`, TOUR_TEXT_LIMITS.findingCategory),
anchorIds,
suggestedComment: boundedText(finding.suggestedComment, `${stopId} suggested comment`, 500),
suggestedComment: boundedText(finding.suggestedComment, `${stopId} suggested comment`, TOUR_TEXT_LIMITS.suggestedComment),
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wingdiff/web",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"type": "module",
"engines": {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "wingdiff",
"version": "0.2.4",
"version": "0.2.5",
"description": "Understand a pull request before you approve it. A local, open source guided review tour where every claim links to the diff and you publish the review.",
"keywords": [
"code-review",
Expand Down
Loading