scaffold: Next.js 15 + Drizzle + Better Auth + OpenAI + Recharts base

This commit is contained in:
Christopher Mayor
2026-04-24 14:29:47 -07:00
parent 858f7264ce
commit d13780931e
45 changed files with 9036 additions and 121 deletions

65
src/lib/llm/index.ts Normal file
View File

@@ -0,0 +1,65 @@
import type {
ComparisonRequest,
ComparisonResult,
ResearchProgress,
} from "./types";
import { generateComparison } from "./providers/openai";
export type {
ComparisonRequest,
ComparisonResult,
ResearchProgress,
} from "./types";
export async function* runResearch(
request: ComparisonRequest
): AsyncGenerator<ResearchProgress> {
yield { stage: "parsing", message: "Analyzing comparison request..." };
if (!request.items || request.items.length < 2) {
yield {
stage: "error",
error: "At least 2 items are required for comparison",
};
return;
}
for (let i = 0; i < request.items.length; i++) {
yield {
stage: "researching",
item: request.items[i],
progress: Math.round(((i + 0.5) / request.items.length) * 80),
};
}
yield {
stage: "synthesizing",
message: "Synthesizing research into structured comparison...",
};
try {
const result = await generateComparison(request);
yield { stage: "complete", result };
} catch (error) {
yield {
stage: "error",
error:
error instanceof Error ? error.message : "Research failed unexpectedly",
};
}
}
export async function executeResearch(
request: ComparisonRequest
): Promise<ComparisonResult> {
for await (const progress of runResearch(request)) {
if (progress.stage === "complete") {
return progress.result;
}
if (progress.stage === "error") {
throw new Error(progress.error);
}
}
throw new Error("Research completed without producing a result");
}

View File

@@ -0,0 +1,142 @@
import OpenAI from "openai";
import type {
ComparisonRequest,
ComparisonResult,
DimensionResult,
ItemResearch,
} from "../types";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const SYSTEM_PROMPT = `You are an expert research analyst. Your job is to compare items across multiple dimensions and produce structured, insightful comparison data.
When given a list of items to compare:
1. Identify 5-8 relevant comparison dimensions (e.g., price, performance, ease of use, ecosystem, community support, scalability, documentation, etc.)
2. Research each item across each dimension
3. Score each item 1-10 per dimension (10 = best)
4. Generate pros and cons lists for each item
5. Write a concise summary comparison
6. Provide a clear recommendation
You MUST respond with valid JSON matching this exact structure:
{
"items": [
{
"name": "item name",
"description": "brief overview of this item in context of the comparison",
"overallScore": 7.5,
"dimensions": {
"Dimension Name": {
"score": 8,
"summary": "brief assessment",
"details": "detailed analysis with specific data points",
"pros": ["pro 1", "pro 2"],
"cons": ["con 1", "con 2"]
}
},
"pros": ["overall pro 1", "overall pro 2"],
"cons": ["overall con 1", "overall con 2"],
"sources": [
{ "title": "source title", "url": "https://...", "snippet": "relevant excerpt" }
]
}
],
"dimensions": ["Dimension 1", "Dimension 2", ...],
"summary": "overall comparison summary highlighting key differences and trade-offs",
"recommendation": "clear recommendation with reasoning"
}
Important:
- Be factual and specific. Include real data where possible.
- Scores should be on a 1-10 scale where 10 is best.
- Provide at least 2-3 pros and cons per item.
- The top-level pros/cons for each item should be the most significant overall points.
- Dimension-level pros/cons should be specific to that dimension.
- Sources should be realistic URLs to relevant documentation or resources.
- The recommendation should consider different use cases when appropriate.`;
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 1000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function validateComparisonResult(data: unknown): data is ComparisonResult {
if (!data || typeof data !== "object") return false;
const result = data as Record<string, unknown>;
if (!Array.isArray(result.items)) return false;
if (!Array.isArray(result.dimensions)) return false;
if (typeof result.summary !== "string") return false;
if (typeof result.recommendation !== "string") return false;
for (const item of result.items as ItemResearch[]) {
if (typeof item.name !== "string") return false;
if (typeof item.description !== "string") return false;
if (typeof item.overallScore !== "number") return false;
if (!Array.isArray(item.pros)) return false;
if (!Array.isArray(item.cons)) return false;
if (typeof item.dimensions !== "object") return false;
for (const dim of Object.values(item.dimensions) as DimensionResult[]) {
if (typeof dim.score !== "number") return false;
if (typeof dim.summary !== "string") return false;
if (typeof dim.details !== "string") return false;
if (!Array.isArray(dim.pros)) return false;
if (!Array.isArray(dim.cons)) return false;
}
}
return true;
}
export async function generateComparison(
request: ComparisonRequest
): Promise<ComparisonResult> {
const userPrompt = `Compare the following items: ${request.items.join(", ")}
${request.query ? `Focus: ${request.query}` : ""}
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
Provide a comprehensive comparison with scores, pros/cons, and a recommendation.`;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
temperature: 0.3,
});
const content = response.choices[0]?.message?.content;
if (!content) {
throw new Error("Empty response from OpenAI");
}
const parsed: unknown = JSON.parse(content);
if (!validateComparisonResult(parsed)) {
throw new Error("Invalid comparison result structure from OpenAI");
}
return parsed;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < MAX_RETRIES) {
await sleep(RETRY_DELAY_MS * attempt);
}
}
}
throw new Error(
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
);
}

37
src/lib/llm/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface ComparisonRequest {
query: string;
items: string[];
dimensions?: string[];
}
export interface DimensionResult {
score: number;
summary: string;
details: string;
pros: string[];
cons: string[];
}
export interface ItemResearch {
name: string;
description: string;
overallScore: number;
dimensions: Record<string, DimensionResult>;
pros: string[];
cons: string[];
sources: { title: string; url: string; snippet: string }[];
}
export interface ComparisonResult {
items: ItemResearch[];
dimensions: string[];
summary: string;
recommendation: string;
}
export type ResearchProgress =
| { stage: "parsing"; message: string }
| { stage: "researching"; item: string; progress: number }
| { stage: "synthesizing"; message: string }
| { stage: "complete"; result: ComparisonResult }
| { stage: "error"; error: string };