6 Commits

Author SHA1 Message Date
Christopher Mayor
a273f29e07 feat: improve compare API route with searching stage and validation 2026-04-24 14:36:06 -07:00
Christopher Mayor
2f4239a83b feat: add provider fallback chain with priority system 2026-04-24 14:35:20 -07:00
Christopher Mayor
e13b1ea2d5 feat: add Perplexity Sonar provider with OpenAI fallback 2026-04-24 14:34:55 -07:00
Christopher Mayor
637f1540cf feat: update research pipeline with Tavily search and progress stages 2026-04-24 14:34:22 -07:00
Christopher Mayor
71ef567d0d feat: enhance OpenAI provider with web research context 2026-04-24 14:33:50 -07:00
Christopher Mayor
3a448a5063 feat: add Tavily search provider 2026-04-24 14:33:20 -07:00
15 changed files with 420 additions and 120 deletions

View File

@@ -1,6 +0,0 @@
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/comparaison
BETTER_AUTH_SECRET=change-me-to-random-string
OPENAI_API_KEY=
PERPLEXITY_API_KEY=
TAVILY_API_KEY=
NEXT_PUBLIC_APP_URL=http://localhost:3000

1
.gitignore vendored
View File

@@ -32,7 +32,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -1,21 +0,0 @@
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -1,30 +0,0 @@
version: "3.8"
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/comparaison
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: comparaison
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
pgdata:

View File

@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
/* config options here */
};
export default nextConfig;

View File

@@ -18,6 +18,10 @@ function slugify(text: string): string {
.slice(0, 200);
}
// TODO: Implement rate limiting per IP/user
// Example: Use Upstash Ratelimit or a simple in-memory counter
// const ratelimit = new Ratelimit({ redis, limiter: slidingWindow(5, "1m") })
export async function POST(request: Request) {
const body: { query?: string; items?: string[]; dimensions?: string[] } =
await request.json();
@@ -25,7 +29,21 @@ export async function POST(request: Request) {
if (!items || items.length < 2) {
return Response.json(
{ error: "At least 2 items are required" },
{ error: "At least 2 items are required for comparison" },
{ status: 400 }
);
}
if (items.length > 10) {
return Response.json(
{ error: "Maximum 10 items allowed per comparison" },
{ status: 400 }
);
}
if (items.some((item) => item.trim().length === 0)) {
return Response.json(
{ error: "Item names cannot be empty" },
{ status: 400 }
);
}
@@ -70,6 +88,20 @@ export async function POST(request: Request) {
);
}
if (progress.stage === "searching") {
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "researching",
message: `Searching the web for ${progress.item}... (${progress.results} results found)`,
itemsCompleted,
totalItems: items.length,
currentStep: `Searching ${progress.item}`,
})
)
);
}
if (progress.stage === "researching") {
itemsCompleted++;
controller.enqueue(
@@ -102,7 +134,17 @@ export async function POST(request: Request) {
if (progress.stage === "complete") {
const result = progress.result;
const comparisonData: Omit<ComparisonData, "id" | "userId" | "slug" | "tags" | "isPublic" | "viewCount" | "createdAt" | "updatedAt"> = {
const comparisonData: Omit<
ComparisonData,
| "id"
| "userId"
| "slug"
| "tags"
| "isPublic"
| "viewCount"
| "createdAt"
| "updatedAt"
> = {
title,
query: query || "",
status: "completed",
@@ -177,7 +219,7 @@ export async function POST(request: Request) {
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: progress.error,
message: `Comparison failed: ${progress.error}`,
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",
@@ -192,11 +234,16 @@ export async function POST(request: Request) {
.set({ status: "failed", updatedAt: new Date() })
.where(eq(comparisons.id, id));
const message =
error instanceof Error
? error.message
: "An unexpected error occurred during research";
controller.enqueue(
encoder.encode(
serializeSSE("progress", {
status: "failed",
message: error instanceof Error ? error.message : "Unknown error",
message: `Comparison failed: ${message}`,
itemsCompleted,
totalItems: items.length,
currentStep: "Failed",

View File

@@ -1,10 +1,8 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
import * as schema from "./db/schema";
export const auth = betterAuth({
database: drizzleAdapter(db, { provider: "pg", schema }),
database: drizzleAdapter(db, { provider: "pg" }),
emailAndPassword: { enabled: true },
session: { expiresIn: 60 * 60 * 24 * 7 },
});

View File

@@ -9,27 +9,6 @@ import {
index,
} from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
name: text("name"),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token").notNull().unique(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const comparisons = pgTable(
"comparisons",
{

View File

@@ -3,7 +3,9 @@ import type {
ComparisonResult,
ResearchProgress,
} from "./types";
import { generateComparison } from "./providers/openai";
import { searchItem, type SearchResult } from "./providers/tavily";
import { generateComparisonWithResearch } from "./providers/openai";
import { getActiveProvider } from "./providers";
export type {
ComparisonRequest,
@@ -24,21 +26,44 @@ export async function* runResearch(
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),
};
const provider = getActiveProvider();
const searchResults: Record<string, SearchResult[]> = {};
if (provider.hasSearch) {
for (let i = 0; i < request.items.length; i++) {
const item = request.items[i];
const results = await searchItem(item, request.query);
searchResults[item] = results;
yield {
stage: "searching",
item,
results: results.length,
};
yield {
stage: "researching",
item,
progress: Math.round(((i + 1) / request.items.length) * 50),
};
}
} else {
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...",
message: `Synthesizing research into structured comparison using ${provider.name}...`,
};
try {
const result = await generateComparison(request);
const result = await provider.synthesize(request, searchResults);
yield { stage: "complete", result };
} catch (error) {
yield {

View File

@@ -0,0 +1,63 @@
import type { ComparisonRequest, ComparisonResult } from "../types";
import type { SearchResult } from "./tavily";
import { generateComparisonWithResearch, generateComparison } from "./openai";
import { synthesizeResearch } from "./perplexity";
export interface Provider {
name: string;
hasSearch: boolean;
synthesize: (
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
) => Promise<ComparisonResult>;
}
export function getActiveProvider(): Provider {
const hasTavily = !!process.env.TAVILY_API_KEY;
const hasPerplexity = !!process.env.PERPLEXITY_API_KEY;
const hasOpenAI = !!process.env.OPENAI_API_KEY;
if (hasTavily && hasPerplexity) {
console.log("[llm] Using provider: Tavily search + Perplexity synthesis");
return {
name: "Tavily + Perplexity",
hasSearch: true,
synthesize: synthesizeResearch,
};
}
if (hasTavily && hasOpenAI) {
console.log("[llm] Using provider: Tavily search + OpenAI synthesis");
return {
name: "Tavily + OpenAI",
hasSearch: true,
synthesize: generateComparisonWithResearch,
};
}
if (hasOpenAI) {
console.log("[llm] Using provider: OpenAI only (no web search)");
return {
name: "OpenAI",
hasSearch: false,
synthesize: async (request) => generateComparison(request),
};
}
console.warn(
"[llm] No API keys configured. Research will fail at synthesis."
);
return {
name: "None",
hasSearch: false,
synthesize: async () => {
throw new Error(
"No LLM provider configured. Set OPENAI_API_KEY, TAVILY_API_KEY, or PERPLEXITY_API_KEY."
);
},
};
}
export { searchItem, type SearchResult } from "./tavily";
export { generateComparison, generateComparisonWithResearch } from "./openai";
export { synthesizeResearch } from "./perplexity";

View File

@@ -5,15 +5,11 @@ import type {
DimensionResult,
ItemResearch,
} from "../types";
import type { SearchResult } from "./tavily";
let _client: OpenAI | null = null;
function getClient(): OpenAI {
if (!_client) {
_client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
}
return _client;
}
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.
@@ -110,7 +106,7 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const response = await getClient().chat.completions.create({
const response = await client.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
@@ -145,3 +141,75 @@ Provide a comprehensive comparison with scores, pros/cons, and a recommendation.
`Failed to generate comparison after ${MAX_RETRIES} attempts: ${lastError?.message}`
);
}
export async function generateComparisonWithResearch(
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
): Promise<ComparisonResult> {
const allResults = Object.values(searchResults).flat();
if (allResults.length === 0) {
return generateComparison(request);
}
let researchContext = "Web research data:\n\n";
for (const [itemName, results] of Object.entries(searchResults)) {
if (results.length === 0) continue;
researchContext += `=== ${itemName} ===\n`;
for (const r of results) {
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
}
researchContext += "\n";
}
const userPrompt = `Compare the following items: ${request.items.join(", ")}
${request.query ? `Focus: ${request.query}` : ""}
${request.dimensions?.length ? `Specific dimensions to include: ${request.dimensions.join(", ")}` : ""}
${researchContext}
Use the web research data above to provide factual, data-driven insights. Reference specific data points in your analysis. 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: "system",
content:
"You have access to real web search results. Use them to ground your comparison in factual data. Cite specific findings from the research when scoring and analyzing items.",
},
{ 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 with research after ${MAX_RETRIES} attempts: ${lastError?.message}`
);
}

View File

@@ -0,0 +1,117 @@
import type { ComparisonRequest, ComparisonResult } from "../types";
import type { SearchResult } from "./tavily";
import { generateComparisonWithResearch } from "./openai";
const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions";
const SYSTEM_PROMPT = `You are a research synthesis engine. Given web search results for multiple items, produce a structured JSON comparison.
You MUST respond with valid JSON matching this exact structure:
{
"items": [
{
"name": "item name",
"description": "brief overview",
"overallScore": 7.5,
"dimensions": {
"Dimension Name": {
"score": 8,
"summary": "brief assessment",
"details": "detailed analysis",
"pros": ["pro 1"],
"cons": ["con 1"]
}
},
"pros": ["overall pro 1"],
"cons": ["overall con 1"],
"sources": [{ "title": "source", "url": "https://...", "snippet": "excerpt" }]
}
],
"dimensions": ["Dimension 1", "Dimension 2"],
"summary": "comparison summary",
"recommendation": "clear recommendation"
}`;
export async function synthesizeResearch(
request: ComparisonRequest,
searchResults: Record<string, SearchResult[]>
): Promise<ComparisonResult> {
const apiKey = process.env.PERPLEXITY_API_KEY;
if (!apiKey) {
return generateComparisonWithResearch(request, searchResults);
}
const allResults = Object.values(searchResults).flat();
if (allResults.length === 0) {
return generateComparisonWithResearch(request, searchResults);
}
let researchContext = "Search results for each item:\n\n";
for (const [itemName, results] of Object.entries(searchResults)) {
if (results.length === 0) continue;
researchContext += `=== ${itemName} ===\n`;
for (const r of results) {
researchContext += `- ${r.title}: ${r.content}\n Source: ${r.url}\n`;
}
researchContext += "\n";
}
const userPrompt = `Compare: ${request.items.join(", ")}
${request.query ? `Focus: ${request.query}` : ""}
${request.dimensions?.length ? `Dimensions: ${request.dimensions.join(", ")}` : ""}
${researchContext}`;
try {
const response = await fetch(PERPLEXITY_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "sonar",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userPrompt },
],
temperature: 0.3,
}),
});
if (!response.ok) {
console.error(
`Perplexity API error: ${response.status} ${response.statusText}`
);
return generateComparisonWithResearch(request, searchResults);
}
const data = await response.json();
const content = data.choices?.[0]?.message?.content;
if (!content) {
console.error("Empty response from Perplexity");
return generateComparisonWithResearch(request, searchResults);
}
const parsed: unknown = JSON.parse(content);
if (
!parsed ||
typeof parsed !== "object" ||
!Array.isArray((parsed as Record<string, unknown>).items) ||
!Array.isArray((parsed as Record<string, unknown>).dimensions)
) {
console.error("Invalid structure from Perplexity, falling back to OpenAI");
return generateComparisonWithResearch(request, searchResults);
}
return parsed as ComparisonResult;
} catch (error) {
console.error(
"Perplexity synthesis failed, falling back to OpenAI:",
error instanceof Error ? error.message : error
);
return generateComparisonWithResearch(request, searchResults);
}
}

View File

@@ -0,0 +1,69 @@
export interface SearchResult {
title: string;
url: string;
content: string;
score: number;
}
const TAVILY_API_URL = "https://api.tavily.com/search";
export async function searchItem(
itemName: string,
context: string
): Promise<SearchResult[]> {
const apiKey = process.env.TAVILY_API_KEY;
if (!apiKey) {
return [];
}
try {
const query = `${itemName} ${context}`.trim();
const response = await fetch(TAVILY_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
api_key: apiKey,
query,
max_results: 5,
include_answer: false,
search_depth: "advanced",
}),
});
if (!response.ok) {
console.error(
`Tavily API error: ${response.status} ${response.statusText}`
);
return [];
}
const data = await response.json();
if (!data.results || !Array.isArray(data.results)) {
return [];
}
return data.results.map(
(result: {
title?: string;
url?: string;
content?: string;
score?: number;
}) => ({
title: result.title ?? "",
url: result.url ?? "",
content: result.content ?? "",
score: result.score ?? 0,
})
);
} catch (error) {
console.error(
`Tavily search failed for "${itemName}":`,
error instanceof Error ? error.message : error
);
return [];
}
}

View File

@@ -31,6 +31,7 @@ export interface ComparisonResult {
export type ResearchProgress =
| { stage: "parsing"; message: string }
| { stage: "searching"; item: string; results: number }
| { stage: "researching"; item: string; progress: number }
| { stage: "synthesizing"; message: string }
| { stage: "complete"; result: ComparisonResult }

View File

@@ -1,29 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
const publicPaths = ["/", "/explore", "/sign-in", "/sign-up", "/api/auth"];
const protectedPaths = ["/compare", "/profile"];
const publicPaths = ["/sign-in", "/sign-up", "/api/auth"];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
const isPublic = publicPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"),
);
const isProtected = protectedPaths.some(
(path) => pathname === path || pathname.startsWith(path + "/"),
);
if (isPublic) {
return NextResponse.next();
}
if (isPublic && !isProtected) {
if (pathname.startsWith("/_next") || pathname.startsWith("/favicon")) {
return NextResponse.next();
}
@@ -31,7 +22,7 @@ export async function middleware(request: NextRequest) {
headers: request.headers,
});
if (!session && isProtected) {
if (!session) {
const signInUrl = new URL("/sign-in", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);