---
title: "Generate Structured Reviews"
description: "Hand `generateText` an `Output.object` built from the schema in 3.2, and watch the model return a typed object instead of a wall of advice. Same model, same source, dramatically more useful output."
canonical_url: "https://vercel.com/academy/vercel-sandbox/generate-structured-reviews"
md_url: "https://vercel.com/academy/vercel-sandbox/generate-structured-reviews.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-10-29T03:19:31.525Z"
content_type: "lesson"
course: "vercel-sandbox"
course_title: "Vercel Sandbox"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Generate Structured Reviews

# Generate Structured Reviews

Same model. Same code under review. New output shape.

In 3.1 we got back a paragraph. In this lesson we get back a typed object with severity levels, file paths, and concrete recommendations. We're still calling `generateText`. The only thing that changed is what we handed it.

## Outcome

Add `analyzeRepository(files)` to `src/analyze.ts`. It calls `generateText` with an `Output.object` built from the `reviewSchema` in 3.2, takes an array of `{ path, content }` files, and returns a typed `Review`.

## Fast Track

1. Import `Output` from `ai`.
2. Write `analyzeRepository(files)` that calls `generateText` with reviewer `instructions`, the files as the `prompt`, and `output: Output.object({ schema: reviewSchema })`.
3. Return `result.output` (typed as `Review`).

## Hands-on exercise

Open `src/analyze.ts` and add the new function. The schemas and `analyzeWithPromptV1` stay where they are:

```ts
import { generateText, Output } from 'ai';
import { z } from 'zod';

export const findingSchema = z.object({
  severity: z.enum(['low', 'medium', 'high', 'critical']),
  category: z.enum(['security', 'quality', 'performance', 'reliability']),
  file: z.string(),
  summary: z.string(),
  recommendation: z.string()
});

export const reviewSchema = z.object({
  overallRisk: z.enum(['low', 'medium', 'high']),
  findings: z.array(findingSchema)
});

export type Finding = z.infer<typeof findingSchema>;
export type Review = z.infer<typeof reviewSchema>;

export async function analyzeWithPromptV1(source: string): Promise<string> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    prompt: `Review this code and tell me what is wrong:\n\n${source}`
  });

  return result.text;
}

export async function analyzeRepository(
  files: Array<{ path: string; content: string }>
): Promise<Review> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    instructions: [
      'You are a senior application security and code quality reviewer.',
      'Return only findings that are directly supported by the provided source.',
      'Prefer precise, actionable recommendations over generic advice.',
      'If there are no findings, return an empty findings array.'
    ].join('\n'),
    output: Output.object({ schema: reviewSchema }),
    prompt: files.map((f) => `FILE: ${f.path}\n${f.content}`).join('\n')
  });

  return result.output;
}
```

Two things to point out.

The instructions are doing work the schema can't. The schema enforces the *shape* of the output, but it can't tell the model "be specific" or "don't make things up." That's what `instructions` is for, and it stays separate from the repository files we pass as `prompt`. We're telling the model who it is, what to return, and what not to return. Those three sentences raise the quality of findings by a lot.

The file format inside the prompt (`FILE: path\ncontent\n`) is intentionally plain. We're not using JSON or YAML or anything fancy. Models are good at reading "FILE: x" headers because they look like a lot of the training data.

To verify, replace the temporary `main()` test caller at the bottom of `src/analyze.ts` with one that calls the new function:

```ts
async function main() {
  const files = [
    {
      path: 'src/auth.ts',
      content: `
        export function login(user: string, password: string) {
          if (password === 'admin') return true;
          return false;
        }
      `
    }
  ];

  const review = await analyzeRepository(files);
  console.log(JSON.stringify(review, null, 2));
}

main();
```

\*\*Warning: Troubleshooting: response did not match schema\*\*

If `generateText` throws "No object generated: response did not match schema", the model returned something that didn't match the shape. Usually this means tightening the prompt ("only use the listed categories") or loosening the schema (allow more enum values).

\*\*Note: AI SDK version note\*\*

This course targets AI SDK 7. Older examples use `generateObject` and read `result.object`; AI SDK 7 marks `generateObject` deprecated in favor of `generateText` with an `output` setting, which returns the parsed result on `result.output`. If you're porting older code, follow the [migration guide](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0) instead of mixing examples across versions.

\*\*Warning: Repository text is untrusted model input\*\*

A source file can contain prompt-injection text aimed at the reviewer. Treat model findings as claims to verify, never as instructions to execute, and keep repository content in `prompt`, out of `instructions`, and away from credentials.

\*\*Note: Troubleshooting: empty findings array\*\*

An empty array is a valid response. If the code you're reviewing actually has no issues, `findings: []` is what we want. Don't read it as a bug.

## Try It

```bash
pnpm tsx --env-file=.env src/analyze.ts
```

Expected output (specific findings will vary, but the shape is fixed):

```json
{
  "overallRisk": "high",
  "findings": [
    {
      "severity": "critical",
      "category": "security",
      "file": "src/auth.ts",
      "summary": "Hardcoded admin password in login function",
      "recommendation": "Replace the hardcoded check with a lookup against a securely hashed password store (bcrypt or argon2) and load the comparison value from environment configuration."
    },
    {
      "severity": "high",
      "category": "quality",
      "file": "src/auth.ts",
      "summary": "Function returns boolean instead of a typed user record",
      "recommendation": "Return a discriminated union like { ok: true, user } | { ok: false, reason } so callers can react to specific failure modes."
    }
  ]
}
```

Put that next to the prose blob from 3.1 and the difference is obvious. Each finding has a severity you can sort by, a file you can jump to, a recommendation specific enough to act on. The schema did that.

## Commit

```bash
git add src/analyze.ts
git commit -m "feat(analyze): generate structured reviews with Output.object"
```

## Done-When

- [ ] `analyzeRepository(files)` is exported from `src/analyze.ts`
- [ ] It calls `generateText` with `output: Output.object({ schema: reviewSchema })`
- [ ] Output is a typed `Review` object
- [ ] Running against a known-bad file produces concrete, file-anchored findings

## Solution

```ts title="src/analyze.ts"
import { generateText, Output } from 'ai';
import { z } from 'zod';

export const findingSchema = z.object({
  severity: z.enum(['low', 'medium', 'high', 'critical']),
  category: z.enum(['security', 'quality', 'performance', 'reliability']),
  file: z.string(),
  summary: z.string(),
  recommendation: z.string()
});

export const reviewSchema = z.object({
  overallRisk: z.enum(['low', 'medium', 'high']),
  findings: z.array(findingSchema)
});

export type Finding = z.infer<typeof findingSchema>;
export type Review = z.infer<typeof reviewSchema>;

export async function analyzeWithPromptV1(source: string): Promise<string> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    prompt: `Review this code and tell me what is wrong:\n\n${source}`
  });

  return result.text;
}

export async function analyzeRepository(
  files: Array<{ path: string; content: string }>
): Promise<Review> {
  const result = await generateText({
    model: 'openai/gpt-5.3-codex',
    instructions: [
      'You are a senior application security and code quality reviewer.',
      'Return only findings that are directly supported by the provided source.',
      'Prefer precise, actionable recommendations over generic advice.',
      'If there are no findings, return an empty findings array.'
    ].join('\n'),
    output: Output.object({ schema: reviewSchema }),
    prompt: files.map((f) => `FILE: ${f.path}\n${f.content}`).join('\n')
  });

  return result.output;
}
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
