mirror of
https://github.com/run-llama/llamaextract-10k-demo.git
synced 2026-07-01 21:04:05 -04:00
Initial commit
This commit is contained in:
@@ -39,3 +39,5 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
credentials.json
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) Laurie Voss
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -1,36 +1,34 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
# 10K multi-year Risk Summarizer
|
||||
|
||||
This is a quick-and-dirty web app that summarizes how the risks identified by a corporation in its annual 10K filings have changed over time. It use [LlamaExtract](https://docs.cloud.llamaindex.ai/llamaextract/getting_started), part of [LlamaCloud](https://cloud.llamaindex.ai/), and of the course the LlamaIndex framework. It expects to work on 10K filings; you can find [Apple's 10K filings for many years](https://investor.apple.com/sec-filings/default.aspx) on their investor relations site.
|
||||
|
||||
We hope this gives you a useful jumping-off point for your own projects using LlamaExtract!
|
||||
|
||||
## Setup
|
||||
|
||||
You'll need a few values in your `.env.local` file to get this working:
|
||||
|
||||
* `NEXTAUTH_URL` which can be set to `http://localhost:3000` for testing
|
||||
* `NEXTAUTH_SECRET` which can be any random string
|
||||
* `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` which you can get from the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) by creating a new OAuth client.
|
||||
* `LLAMA_CLOUD_API_KEY` which you can get from the [LlamaCloud Console](https://cloud.llamaindex.ai/api-keys) for free.
|
||||
* `LLAMA_EXTRACT_AGENT_ID` which you can get from the "Extraction" screen of your LlamaCloud project. You should use the UI to create your schema.
|
||||
* `ANTHROPIC_API_KEY` which you can get from the [Anthropic Console](https://console.anthropic.com/settings/keys).
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Under the hood
|
||||
|
||||
The web app has only a handful of API routes:
|
||||
|
||||
* `/api/auth` for authentication (handled by NextAuth)
|
||||
* `/api/process` accepts the uploaded file and kicks off the extraction job
|
||||
* `/api/status` is a polling endpoint for the client to check on the status of the job
|
||||
* `/api/result` fetches the results of the extraction once ready
|
||||
* `/api/summarize` takes multiple years of risks and summarizes them, then returns the summary as HTML
|
||||
|
||||
|
||||
Generated
+1366
-24
File diff suppressed because it is too large
Load Diff
+14
-5
@@ -9,19 +9,28 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.1",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@llamaindex/anthropic": "^0.3.3",
|
||||
"@tanstack/react-query": "^5.74.3",
|
||||
"axios": "^1.8.4",
|
||||
"llamaindex": "^0.10.0",
|
||||
"next": "15.3.0",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.3.0"
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"tailwindcss": "^4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.0",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import NextAuth from "next-auth";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
|
||||
export const authOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
},
|
||||
};
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
|
||||
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
|
||||
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
|
||||
const LLAMA_EXTRACT_AGENT_ID = process.env.LLAMA_EXTRACT_AGENT_ID;
|
||||
|
||||
async function uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append("upload_file", file);
|
||||
|
||||
const response = await fetch(`${LLAMA_CLOUD_API_URL}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload file: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function createExtractionJob(fileId: string) {
|
||||
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
extraction_agent_id: LLAMA_EXTRACT_AGENT_ID,
|
||||
file_id: fileId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create extraction job: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "No file provided" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Upload file to LlamaExtract
|
||||
const uploadResponse = await uploadFile(file);
|
||||
const fileId = uploadResponse.id;
|
||||
|
||||
// Create extraction job
|
||||
const jobResponse = await createExtractionJob(fileId);
|
||||
|
||||
return NextResponse.json({
|
||||
filename: file.name,
|
||||
jobId: jobResponse.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Error processing file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
|
||||
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
|
||||
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
|
||||
|
||||
async function getJobResult(jobId: string) {
|
||||
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs/${jobId}/result`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get job result: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const jobId = searchParams.get('jobId');
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No jobId provided" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await getJobResult(jobId);
|
||||
|
||||
return NextResponse.json({ result });
|
||||
} catch (error) {
|
||||
console.error("Error getting job result:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Error getting job result" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
|
||||
const LLAMA_CLOUD_API_URL = "https://api.cloud.llamaindex.ai/api/v1";
|
||||
const LLAMA_CLOUD_API_KEY = process.env.LLAMA_CLOUD_API_KEY;
|
||||
|
||||
async function getJobStatus(jobId: string) {
|
||||
const response = await fetch(`${LLAMA_CLOUD_API_URL}/extraction/jobs/${jobId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${LLAMA_CLOUD_API_KEY}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get job status: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const jobId = searchParams.get('jobId');
|
||||
|
||||
if (!jobId) {
|
||||
return NextResponse.json(
|
||||
{ error: "No jobId provided" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const jobStatus = await getJobStatus(jobId);
|
||||
|
||||
console.log(jobStatus);
|
||||
|
||||
return NextResponse.json({
|
||||
status: jobStatus.status,
|
||||
result: jobStatus.status === "SUCCESS" ? jobStatus.result : null,
|
||||
error: jobStatus.status === "FAILED" ? "Extraction job failed" : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error getting job status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Error getting job status" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "../auth/[...nextauth]/route";
|
||||
import { Anthropic } from "@llamaindex/anthropic";
|
||||
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY!;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { years } = await request.json();
|
||||
|
||||
if (!years || typeof years !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input format" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Format the risks data for the prompt
|
||||
const risksText = Object.entries(years)
|
||||
.map(([year, risks]) => {
|
||||
const risksList = (risks as Array<{ category: string; description: string }>)
|
||||
.map(risk => `- ${risk.category}: ${risk.description}`)
|
||||
.join('\n');
|
||||
return `Year ${year}:\n${risksList}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
const prompt = `
|
||||
Here are the risks identified in various fiscal years:
|
||||
|
||||
${risksText}
|
||||
|
||||
Please analyze these risks and provide a summary of the the risks have changed over time.
|
||||
|
||||
1. Identify risks that have stayed consistently present
|
||||
2. Identify risks that are no longer mentioned
|
||||
3. Mention new risks that have emerged
|
||||
|
||||
Be very specific about the risks and also very concise in the description of each risk,
|
||||
something like:
|
||||
|
||||
<h2>Ongoing risks:</h2>
|
||||
<ul>
|
||||
<li>Legal regulations</li>
|
||||
<li>Supply chain disruptions</li>
|
||||
</ul>
|
||||
|
||||
<h2>Risks no longer mentioned:</h2>
|
||||
<ul>
|
||||
<li>....</li>
|
||||
</ul>
|
||||
|
||||
<h2>New risks that have emerged:</h2>
|
||||
<ul>
|
||||
<li>....</li>
|
||||
</ul>
|
||||
|
||||
The summary should be in basic HTML as shown here, so that we can display it directly without having to parse Markdown.
|
||||
`;
|
||||
|
||||
const llm = new Anthropic({
|
||||
apiKey: ANTHROPIC_API_KEY,
|
||||
model: "claude-3-7-sonnet-latest"
|
||||
});
|
||||
|
||||
let response = await llm.complete({
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
const summary = response.text
|
||||
|
||||
console.log(summary);
|
||||
|
||||
return NextResponse.json({ summary });
|
||||
} catch (error) {
|
||||
console.error("Error generating summary:", error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Error generating summary" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { FcGoogle } from "react-icons/fc";
|
||||
|
||||
export default function SignIn() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-8 space-y-6">
|
||||
<button
|
||||
onClick={() => signIn("google", { callbackUrl: "/" })}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<FcGoogle className="h-5 w-5" />
|
||||
</span>
|
||||
Sign in with Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,17 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--primary: #2563EB;
|
||||
--primary-hover: #1D4ED8;
|
||||
--gray-light: #F9FAFB;
|
||||
--gray-medium: #D1D5DB;
|
||||
--gray-dark: #4B5563;
|
||||
--success-light: #DCFCE7;
|
||||
--success-dark: #166534;
|
||||
--error-light: #FEE2E2;
|
||||
--error-dark: #991B1B;
|
||||
--warning-light: #FEF9C3;
|
||||
--warning-dark: #854D0E;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -24,3 +35,250 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Auth page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* Document processing page */
|
||||
.document-page {
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.document-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.document-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: var(--gray-dark);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.document-dropzone {
|
||||
border: 2px dashed var(--gray-medium);
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.document-dropzone-active {
|
||||
border-color: var(--primary);
|
||||
background-color: #EFF6FF;
|
||||
}
|
||||
|
||||
.document-table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.document-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.document-table th,
|
||||
.document-table td {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.document-table th {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nested-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nested-table th,
|
||||
.nested-table td {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nested-table th {
|
||||
background-color: #f1f5f9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nested-table td[data-full-description] {
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-dark);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background-color: var(--error-light);
|
||||
color: var(--error-dark);
|
||||
}
|
||||
|
||||
.status-badge.processing {
|
||||
background-color: var(--warning-light);
|
||||
color: var(--warning-dark);
|
||||
}
|
||||
|
||||
.json-result {
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.summarize-button {
|
||||
position: relative;
|
||||
padding: 0.5rem 2.5rem;
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.summarize-button:hover {
|
||||
background-color: var(--primary-hover);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #ffffff;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.json-details {
|
||||
background-color: #f5f5f5;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
max-width: 600px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
details {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
details summary {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
details summary:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
background-color: var(--gray-light);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.summary-box h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.summary-box ul {
|
||||
margin-left: 1.5rem;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.summary-box li {
|
||||
list-style-type: disc;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-left: 16px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background-color: #b91c1c;
|
||||
}
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "10K multi-year Risk Summarizer",
|
||||
description: "",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,7 +28,7 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+285
-95
@@ -1,103 +1,293 @@
|
||||
import Image from "next/image";
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import "./globals.css";
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
filename: string;
|
||||
status: "processing" | "completed" | "error";
|
||||
jobId?: string;
|
||||
result?: any;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const { data: session, status } = useSession();
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [summary, setSummary] = useState<string>("");
|
||||
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
const onDrop = useCallback(async (acceptedFiles: File[]) => {
|
||||
const newDocuments = acceptedFiles.map((file) => ({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
filename: file.name,
|
||||
status: "processing" as const,
|
||||
}));
|
||||
|
||||
setDocuments((prev) => [...prev, ...newDocuments]);
|
||||
|
||||
// Process each file
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await axios.post("/api/process", formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
|
||||
const jobId = response.data.jobId;
|
||||
|
||||
// Update document with jobId
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.filename === file.name ? { ...doc, jobId } : doc
|
||||
)
|
||||
);
|
||||
|
||||
// Poll for results
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const resultResponse = await axios.get(`/api/status?jobId=${jobId}`);
|
||||
const { status, error } = resultResponse.data;
|
||||
|
||||
if (status === "SUCCESS") {
|
||||
clearInterval(pollInterval);
|
||||
// Fetch the actual results from the result endpoint
|
||||
const resultsResponse = await axios.get(`/api/result?jobId=${jobId}`);
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.jobId === jobId
|
||||
? { ...doc, status: "completed", result: resultsResponse.data.result }
|
||||
: doc
|
||||
)
|
||||
);
|
||||
} else if (status === "FAILED" || error) {
|
||||
clearInterval(pollInterval);
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.jobId === jobId ? { ...doc, status: "error" } : doc
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
clearInterval(pollInterval);
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.jobId === jobId ? { ...doc, status: "error" } : doc
|
||||
)
|
||||
);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
|
||||
// Cleanup interval after 5 minutes
|
||||
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
|
||||
} catch (error) {
|
||||
setDocuments((prev) =>
|
||||
prev.map((doc) =>
|
||||
doc.filename === file.name ? { ...doc, status: "error" } : doc
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
"application/pdf": [".pdf"],
|
||||
"application/msword": [".doc"],
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
|
||||
".docx",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (status === "loading") {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<a href="/auth/signin" className="auth-button">
|
||||
Sign in to continue
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="document-page">
|
||||
<div className="container">
|
||||
<div className="document-header">
|
||||
<h1 className="document-title">Document Processing</h1>
|
||||
<div className="user-info">
|
||||
<span className="user-name">
|
||||
Welcome, {session.user?.name}
|
||||
</span>
|
||||
<img
|
||||
src={session.user?.image || ""}
|
||||
alt="Profile"
|
||||
className="user-avatar"
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<button
|
||||
onClick={() => window.location.href = '/api/auth/signout'}
|
||||
className="logout-button"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`document-dropzone ${isDragActive ? "document-dropzone-active" : ""}`}
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
<input {...getInputProps()} />
|
||||
{isDragActive ? (
|
||||
<p>Drop the files here ...</p>
|
||||
) : (
|
||||
<p>Drag and drop files here, or click to select files</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="document-title">Processing Status</h2>
|
||||
<div className="document-table-container">
|
||||
<table className="document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Status</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td>{doc.filename}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${doc.status}`}>
|
||||
{doc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{doc.status === "completed" && doc.result && (
|
||||
<details>
|
||||
<summary>View JSON</summary>
|
||||
<pre className="json-details">
|
||||
{JSON.stringify(doc.result, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{documents.some(doc => doc.status === "completed" && doc.result) && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="document-title">Risks</h2>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const risksByYear = documents
|
||||
.filter(doc => doc.status === "completed" && doc.result)
|
||||
.reduce((acc, doc) => {
|
||||
const year = doc.result.data.filingInfo.fiscalYear;
|
||||
acc[year] = doc.result.data.keyRisks.map((risk: any) => ({
|
||||
category: risk.category,
|
||||
description: risk.description
|
||||
}));
|
||||
return acc;
|
||||
}, {} as Record<string, Array<{ category: string; description: string }>>);
|
||||
|
||||
try {
|
||||
setIsSummarizing(true);
|
||||
const response = await axios.post('/api/summarize', {
|
||||
years: risksByYear
|
||||
});
|
||||
setSummary(response.data.summary);
|
||||
} catch (error) {
|
||||
console.error('Error summarizing risks:', error);
|
||||
} finally {
|
||||
setIsSummarizing(false);
|
||||
}
|
||||
}}
|
||||
className="summarize-button"
|
||||
disabled={isSummarizing}
|
||||
>
|
||||
Summarize
|
||||
{isSummarizing && (
|
||||
<span className="spinner" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="summary-box" dangerouslySetInnerHTML={{ __html: summary }} />
|
||||
)}
|
||||
<div className="document-table-container">
|
||||
<table className="document-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fiscal Year</th>
|
||||
<th>Risks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents
|
||||
.filter(doc => doc.status === "completed" && doc.result)
|
||||
.sort((a, b) => {
|
||||
const yearA = parseInt(a.result.data.filingInfo.fiscalYear);
|
||||
const yearB = parseInt(b.result.data.filingInfo.fiscalYear);
|
||||
return yearB - yearA;
|
||||
})
|
||||
.map(doc => (
|
||||
<tr key={doc.id}>
|
||||
<td>{doc.result.data.filingInfo.fiscalYear}</td>
|
||||
<td>
|
||||
<table className="nested-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{doc.result.data.keyRisks.map((risk: any, index: number) => (
|
||||
<tr key={index}>
|
||||
<td>{risk.category}</td>
|
||||
<td
|
||||
title={risk.description}
|
||||
data-full-description={risk.description}
|
||||
>
|
||||
{risk.description.length > 100
|
||||
? `${risk.description.substring(0, 100)}...`
|
||||
: risk.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user