Repository: neondatabase/yc-idea-matcher Branch: main Commit: 752dd374c8e7 Files: 22 Total size: 35.2 KB Directory structure: gitextract_o4qu7qo7/ ├── .eslintrc.json ├── .gitignore ├── README.md ├── generate-embeddings.ts ├── next.config.js ├── package.json ├── postcss.config.js ├── src/ │ ├── app/ │ │ ├── api/ │ │ │ └── idea/ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── badge.tsx │ │ ├── company.tsx │ │ ├── hero.tsx │ │ ├── how-it-works.tsx │ │ ├── providers.tsx │ │ └── ui/ │ │ ├── button.tsx │ │ └── input.tsx │ ├── lib/ │ │ └── query-client.ts │ ├── styles/ │ │ └── globals.css │ └── utils/ │ └── index.ts ├── tailwind.config.js └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.json ================================================ { "extends": "next/core-web-vitals" } ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env*.local # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: README.md ================================================ # YC idea matcher ![Screenshot of the app UI](ui.png) This project allows you Submit your idea and get a list of similar ideas that YCombinator has invested in before. The project is built using the following technologies: - [Neon](https://neon.tech): Serverless Postgres - [pgvector](https://github.com/pgvector/pgvector): open-source Postgres extension for vector storage and similarity search - [Neon Serverless Driver](https://github.com/neondatabase/serverless) - [Next.js](https://nextjs.org): Fullstack framework for React - [Vercel](https://vercel.com): deployment platform - [OpenAI API](https://openai.com): generating vector embeddings - [TailwindCSS](https://tailwindcss.com): Utility-first CSS framework - [Upstash Redis](https://upstash.com): serverless Redis for rate limiting - [Zod](https://zod.dev): TypeScript-first schema validation - [React Query](https://react-query.tanstack.com): data fetching and caching library - [Vaul](https://vaul.emilkowal.ski/): Drawer component for React. ## How the app works You will find a script called `generate-embeddings.ts` located in the root directory of this project. After running `npm run generate-embeddings`, the script does the following: 1. It creates the database schema and installs the `pgvector` extension 2. It goes through the YCombinator API 'https://api.ycombinator.com/v0.1/companies?page=1' and gets all the companies 3. For each company it generates embeddings using the long description and then stores the company data in the database. > Some companies don't have a long description, so we needed to manually remove those from the database by running `delete from companies WHERE embedding = ARRAY[]::real[];` The app itself is a Next.js app with an API route located at `/api/idea`. Whenever a user submits an idea, the following happens: 1. The idea is sent to the OpenAI API to generate an embedding 2. We then use pgvector to retrieve the top 3 most similar ideas ================================================ FILE: generate-embeddings.ts ================================================ import { Pool } from 'pg'; import axios from 'axios'; const DATABASE_URL = ''; const OPENAI_API_KEY = ''; const pool = new Pool({ connectionString: DATABASE_URL, }); async function createCompaniesTable() { const client = await pool.connect(); try { await client.query(` CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE IF NOT EXISTS companies ( id SERIAL PRIMARY KEY, name TEXT, slug TEXT, website TEXT, "smallLogoUrl" TEXT, "oneLiner" TEXT, "longDescription" TEXT, "teamSize" INTEGER, url TEXT, batch TEXT, tags TEXT[], status TEXT, industries TEXT[], regions TEXT[], locations TEXT[], badges TEXT[], embedding VECTOR(1536) ); `); console.log('Companies table created successfully'); } catch (error) { console.error('Error creating companies table:', error); } finally { client.release(); } } async function scrapeCompanies(url: string) { try { const response = await axios.get(url); const { companies, nextPage } = response.data; for (const company of companies) { const { longDescription } = company; const embedding = await generateEmbedding(longDescription); await storeCompany(company, embedding); } if (nextPage) { await scrapeCompanies(nextPage); } } catch (error) { console.error('Error scraping companies:', error); } } async function generateEmbedding(text: string): Promise { try { const response = await axios.post( 'https://api.openai.com/v1/embeddings', { input: text, model: 'text-embedding-ada-002', }, { headers: { Authorization: `Bearer ${OPENAI_API_KEY}`, 'Content-Type': 'application/json', }, } ); const { data } = response.data; return data[0].embedding; } catch (error) { console.error('Error generating embedding:', error); } return []; } async function storeCompany(company: any, embedding: number[]) { const { name, slug, website, smallLogoUrl, oneLiner, longDescription, teamSize, url, batch, tags, status, industries, regions, locations, badges, } = company; const client = await pool.connect(); try { await client.query( ` INSERT INTO companies ( name, slug, website, "smallLogoUrl", "oneLiner", "longDescription", "teamSize", url, batch, tags, status, industries, regions, locations, badges, embedding ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) `, [ name, slug, website, smallLogoUrl, oneLiner, longDescription, teamSize, url, batch, tags, status, industries, regions, locations, badges, embedding, ] ); console.log(`Company '${name}' stored successfully`); } catch (error) { console.error(`Error storing company '${name}':`, error); } finally { client.release(); } } async function runScript() { await createCompaniesTable(); await scrapeCompanies('https://api.ycombinator.com/v0.1/companies?page=1'); await pool.end(); } runScript(); ================================================ FILE: next.config.js ================================================ /** @type {import('next').NextConfig} */ const nextConfig = {} module.exports = nextConfig ================================================ FILE: package.json ================================================ { "name": "yc-idea-matcher", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", "generate-embeddings": "tsx generate-embeddings.ts" }, "dependencies": { "@neondatabase/serverless": "^0.5.6", "@tanstack/react-query": "^4.32.1", "@upstash/ratelimit": "^0.4.3", "@upstash/redis": "^1.22.0", "@vercel/analytics": "^1.0.1", "clsx": "^2.0.0", "next": "13.4.12", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.45.2", "tailwind-merge": "^1.14.0", "usehooks-ts": "^2.9.1", "vaul": "^0.1.1", "zod": "^3.21.4" }, "devDependencies": { "@tailwindcss/forms": "^0.5.4", "@tailwindcss/typography": "^0.5.9", "@types/node": "20.4.5", "@types/pg": "^8.10.2", "@types/react": "18.2.18", "@types/react-dom": "18.2.7", "autoprefixer": "10.4.14", "axios": "^1.4.0", "eslint": "8.46.0", "eslint-config-next": "13.4.12", "pg": "^8.11.2", "postcss": "8.4.27", "tailwindcss": "3.3.3", "tsx": "^3.12.7", "typescript": "5.1.6" } } ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: src/app/api/idea/route.ts ================================================ import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; import { neon, neonConfig } from '@neondatabase/serverless'; import { NextRequest, NextResponse } from 'next/server'; import { generateEmbeddings } from '~/utils'; import { z } from 'zod'; neonConfig.fetchConnectionCache = true; export const runtime = 'edge'; export const preferredRegion = 'iad1'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), analytics: true, limiter: Ratelimit.slidingWindow(2, '5s'), }); const sql = neon(process.env.DATABASE_URL!); export async function POST(request: NextRequest) { const id = request.ip ?? 'anonymous'; const limit = await ratelimit.limit(id ?? 'anonymous'); if (!limit.success) { return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); } const body = await request.json(); const schema = z.object({ idea: z .string() .min(20, { message: 'Idea must be at least 20 characters.', }) .max(140, { message: 'Idea should be at most 140 characters.', }), }); const validated = schema.safeParse(body); if (!validated.success) { return NextResponse.json( { error: `Invalid request ${validated.error.message}` }, { status: 400 } ); } const { idea } = validated.data; try { const embedding = await generateEmbeddings(idea); const result = await sql( `SELECT id, name, "smallLogoUrl", website, "oneLiner", "longDescription", batch, url, status, industries FROM companies ORDER BY embedding::VECTOR <=> '[${embedding}]' LIMIT 5;` ); return NextResponse.json({ data: result }); } catch (error) { return NextResponse.json({ error: error }, { status: 500 }); } } ================================================ FILE: src/app/layout.tsx ================================================ import '../styles/globals.css'; import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import Link from 'next/link'; import Providers from '~/components/providers'; import { cn } from '~/utils'; const inter = Inter({ subsets: ['latin'] }); export const metadata: Metadata = { title: 'YC Idea Matcher', description: 'Submit your idea and get a list of similar ideas that YCombinator has invested in in the past.', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( {children} ); } ================================================ FILE: src/app/page.tsx ================================================ 'use client'; import { useMutation } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { Company } from '~/components/company'; import { Hero } from '~/components/hero'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import va from '@vercel/analytics'; type FormValues = { idea: string; }; export default function Home() { const { handleSubmit, register, formState: { errors }, } = useForm(); const { mutate, isLoading, data } = useMutation( async (values: FormValues) => { va.track('Submit'); const res = await fetch('/api/idea', { method: 'POST', body: JSON.stringify(values), }); if (!res.ok) { throw new Error('Something went wrong...'); } const data = await res.json(); return data; } ); const onSubmit = (values: FormValues) => mutate(values); return (
{' '}
{errors && (

{errors.idea?.message}

)}
{isLoading ? ( <>
{' '}
) : ( data && data.data.map((company: Company) => ( )) )}
); } ================================================ FILE: src/components/badge.tsx ================================================ import { cn } from '~/utils'; export type Status = 'Public' | 'Active' | 'Acquired' | 'Inactive'; type Props = { status: Status; }; export const Badge = ({ status }: Props) => { const styles: Record = { Public: { text: 'Public', className: 'text-indigo-1100 border-indigo-700', }, Acquired: { text: 'Acquired', className: 'border-orange-700 text-orange-1100', }, Active: { text: 'Active', className: 'border-green-700 text-green-1100', }, Inactive: { text: 'Inactive', className: 'border-gray-700 text-gray-1100', }, }; return ( {styles[status].text} ); }; ================================================ FILE: src/components/company.tsx ================================================ import { Badge, Status } from './badge'; export type Company = { id: number; name: string; slug: string; website: string; smallLogoUrl?: string; oneLiner: string; longDescription: string; teamSize: number; url: string; batch: string; tags: string[]; status: Status; industries: string[]; badges: string[]; }; export const Company = ({ id, website, smallLogoUrl, name, oneLiner, longDescription, industries, batch, status, }: Company) => { return (
{smallLogoUrl ? ( Company logo ) : ( Company logo )}

{name}

{oneLiner}

{longDescription}

{batch} batch

{industries.map((industry) => (

{industry}

))}
); }; ================================================ FILE: src/components/hero.tsx ================================================ import React from 'react'; import { HowItWorks } from './how-it-works'; export const Hero = () => { return ( <> {' '}

YC Idea Matcher{' '}

Submit your idea and get a list of similar ideas that YCombinator has invested in before.

); }; ================================================ FILE: src/components/how-it-works.tsx ================================================ 'use client'; import { useLocalStorage } from 'usehooks-ts'; import { Drawer } from 'vaul'; export const HowItWorks = () => { const codeString = `SELECT * FROM companies ORDER BY embedding <=> '[0.3, 0.8, -0.9]' LIMIT 5;`; const [open, setOpen] = useLocalStorage('show banner', true); return (
How this app works

This project uses semantic search, an advanced search technique that aims to understand the intent and context behind a search query instead of just matching keywords.

The first step was to collect company data from the YCombinator public API:{' '} https://api.ycombinator.com/v0.1/companies

Next, for each company description we generated an embedding, which is a vector (list) of floating-point numbers. For example, the word “Car” can be represented using the following vector: [0.3, 0.8, -0.9].

We then used{' '} Neon {' '} with{' '} pgvector , which makes it possible to store and retrieve vector embeddings in Postgres.

When a user submits a query, we convert it into a vector embedding and find the 5 most similar results. Here is an example query:

                  {codeString}
                

You can find the{' '} source code on GitHub.

); }; ================================================ FILE: src/components/providers.tsx ================================================ 'use client'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from '~/lib/query-client'; import { Analytics } from '@vercel/analytics/react'; type Props = { children: React.ReactNode; }; export default function Providers({ children }: Props) { return ( {children} ); } ================================================ FILE: src/components/ui/button.tsx ================================================ 'use client'; import * as React from 'react'; import type { HTMLAttributes } from 'react'; import { cn } from '~/utils'; export interface ButtonProps extends HTMLAttributes { loading?: boolean; disabled?: boolean; size?: ButtonSize; appearance?: ButtonAppearance; leadingIcon?: React.ReactNode; trailingIcon?: React.ReactNode; children?: React.ReactNode; type?: 'button' | 'submit' | 'reset'; link?: string; } export type ButtonSize = 'small' | 'medium' | 'large' | 'xlarge'; export type ButtonAppearance = 'primary' | 'secondary' | 'outlined' | 'danger'; export const Button = React.forwardRef( function Btn( { disabled = false, loading = false, size = 'medium', appearance = 'primary', children, className, leadingIcon, trailingIcon, type = 'button', ...props }: ButtonProps, ref ) { const computeStyles = (appearance: ButtonAppearance) => { switch (appearance) { case 'primary': return 'text-white bg-green-900 hover:bg-green-1000 border-transparent border'; case 'secondary': return 'text-green-1100 bg-green-400 hover:bg-green-500 border-2 border-transparent'; case 'danger': return 'text-gray-1200 bg-red-900 hover:bg-red-1000 border-2 border-transparent'; case 'outlined': return 'bg-gray-200 border border-gray-700 text-gray-1100 hover:bg-gray-300 hover:text-gray-1200 hover:border-gray-800 transition-colors'; } }; return ( ); } ); ================================================ FILE: src/components/ui/input.tsx ================================================ 'use client'; import type { InputHTMLAttributes } from 'react'; import * as React from 'react'; import { cn } from '~/utils'; interface InputProps extends InputHTMLAttributes { type?: 'text' | 'email' | 'password' | 'hidden' | 'number' | 'search'; className?: string; id: string; name: string; placeholder?: string; value?: string; autoComplete?: string; error?: string; } export const Input = React.forwardRef( function textInput( { type = 'text', id, name, className, placeholder, value, autoComplete = 'on', error, ...props }: InputProps, ref ) { return ( <> ); } ); ================================================ FILE: src/lib/query-client.ts ================================================ import { QueryClient } from '@tanstack/react-query'; export const queryClient = new QueryClient(); ================================================ FILE: src/styles/globals.css ================================================ @tailwind base; /* Firefox */ * { scrollbar-width: thin; scrollbar-color: #2b2f31 #151718; } /* Chrome, Edge, and Safari */ *::-webkit-scrollbar { width: 15px; } *::-webkit-scrollbar-track { background: #151718; border-radius: 5px; } *::-webkit-scrollbar-thumb { background-color: #2b2f31; border-radius: 14px; border: 3px solid #151718; } @tailwind components; @tailwind utilities; ================================================ FILE: src/utils/index.ts ================================================ import clsx, { type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } export const generateEmbeddings = async (prompt: string) => { const res = await fetch('https://api.openai.com/v1/embeddings', { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${process.env.OPENAI_API_KEY}`, }, method: 'POST', body: JSON.stringify({ model: 'text-embedding-ada-002', input: prompt, }), }); const embeddings = await res.json(); return embeddings.data[0].embedding; }; ================================================ FILE: tailwind.config.js ================================================ /** @type {import('tailwindcss').Config} */ // radix-ui.com/colors /** | Step | Use Case | | ---- | --------------------------------------- | | 100 | App background | | 200 | Subtle background | | 300 | UI element background | | 400 | Hovered UI element background | | 500 | Active / Selected UI element background | | 600 | Subtle borders and separators | | 700 | UI element border and focus rings | | 800 | Hovered UI element border | | 900 | Solid backgrounds | | 1000 | Hovered solid backgrounds | | 1100 | Low-contrast text | | 1200 | High-contrast text | */ module.exports = { content: [ './src/components/**/*.{js,ts,jsx,tsx,mdx}', './src/app/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: { colors: { indigo: { 100: '#131620', 200: '#15192d', 400: '#192140', 400: '#1c274f', 500: '#1f2c5c', 600: '#22346e', 700: '#273e89', 800: '#2f4eb2', 900: '#3e63dd', 1000: '#5373e7', 1100: '#849dff', 1200: '#eef1fd', }, orange: { 100: '#1f1206', 200: '#2b1400', 300: '#391a03', 400: '#441f04', 500: '#4f2305', 600: '#5f2a06', 700: '#763205', 800: '#943e00', 900: '#f76808', 1000: '#ff802b', 1100: '#ff8b3e', 1200: '#feeadd', }, red: { 100: '#1f1315', 200: '#291415', 300: '#3c181a', 400: '#481a1d', 500: '#541b1f', 600: '#671e22', 700: '#822025', 800: '#aa2429', 900: '#e5484d', 1000: '#f2555a', 1100: '#ff6369', 1200: '#feecee', }, gray: { 100: '#151718', 200: '#1a1d1e', 300: '#202425', 400: '#26292b', 500: '#2b2f31', 600: '#313538', 700: '#3a3f42', 800: '#4c5155', 900: '#697177', 1000: '#787f85', 1100: '#9ba1a6', 1200: '#ecedee', }, green: { 100: '#0d1912', 200: '#0f1e13', 300: '#132819', 400: '#16301d', 500: '#193921', 600: '#1d4427', 700: '#245530', 800: '#2f6e3b', 900: '#46a758', 1000: '#55b467', 1100: '#63c174', 1200: '#e5fbeb', }, }, }, }, plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')], }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "~/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] }