From a4650991c432754325c96868dc50ac44f55ef0cc Mon Sep 17 00:00:00 2001 From: Cody Hart <157865967+HartBrook@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:00:21 -0400 Subject: [PATCH 1/2] fix: constrain generated tour text --- apps/server/src/providers/tourPrompt.test.ts | 5 ++ apps/server/src/providers/tourPrompt.ts | 50 +++++++++++++------- apps/server/src/tour.ts | 42 +++++++++++----- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/apps/server/src/providers/tourPrompt.test.ts b/apps/server/src/providers/tourPrompt.test.ts index 70aaa81..e415199 100644 --- a/apps/server/src/providers/tourPrompt.test.ts +++ b/apps/server/src/providers/tourPrompt.test.ts @@ -31,6 +31,8 @@ describe("tour generation prompt", () => { expect(prompt).toContain(""); expect(prompt).toContain("a2\tnew:8"); expect(prompt).toContain(""); + expect(prompt).toContain(""); + 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"); @@ -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); }); }); diff --git a/apps/server/src/providers/tourPrompt.ts b/apps/server/src/providers/tourPrompt.ts index 3afec32..b47d711 100644 --- a/apps/server/src/providers/tourPrompt.ts +++ b/apps/server/src/providers/tourPrompt.ts @@ -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. @@ -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) => ({ @@ -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; @@ -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, @@ -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", @@ -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" }] }, }, }, @@ -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), }, }, @@ -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 ` +- 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} +`; } function coverageChecklist(input: TourGenerationInput): string { diff --git a/apps/server/src/tour.ts b/apps/server/src/tour.ts index 715b47f..e3367ed 100644 --- a/apps/server/src/tour.ts +++ b/apps/server/src/tour.ts @@ -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; @@ -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])); @@ -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); @@ -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, @@ -284,7 +300,7 @@ function validateFindingRevisions( const seen = new Set(); 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); @@ -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, }; }); @@ -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), }; } From f7ea8d9062f1811a3cafb4bf098974b675cc0e45 Mon Sep 17 00:00:00 2001 From: Cody Hart <157865967+HartBrook@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:00:28 -0400 Subject: [PATCH 2/2] release: 0.2.5 --- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index e0f10c4..9bc8eca 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@wingdiff/server", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "bin": { diff --git a/apps/web/package.json b/apps/web/package.json index 4ce3f9d..146a5dc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@wingdiff/web", - "version": "0.2.4", + "version": "0.2.5", "private": true, "type": "module", "engines": { diff --git a/package-lock.json b/package-lock.json index 5a30613..57a1192 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wingdiff", - "version": "0.2.4", + "version": "0.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wingdiff", - "version": "0.2.4", + "version": "0.2.5", "license": "MIT", "workspaces": [ "apps/*" @@ -36,7 +36,7 @@ }, "apps/server": { "name": "@wingdiff/server", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@anthropic-ai/sdk": "^0.131.0", "express": "^5.2.1", @@ -59,7 +59,7 @@ }, "apps/web": { "name": "@wingdiff/web", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/package.json b/package.json index c907f4f..281617c 100644 --- a/package.json +++ b/package.json @@ -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",