Compare commits

...

9 Commits

Author SHA1 Message Date
github-actions[bot] 1ffd3c915b Release 0.1.41 (#248)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-22 16:34:41 +07:00
Marcus Schiesser 57e7638083 feat: Use the retrieval defaults from LlamaCloud (#247) 2024-08-22 16:30:04 +07:00
Marcus Schiesser 22ac2cae61 fix: add progress for no vecdb for Python 2024-08-22 11:22:09 +07:00
github-actions[bot] 8077195601 Release 0.1.40 (#245)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-20 14:59:24 +07:00
Huu Le 8ce4a8513d feat: use Reflex UI for structured extract template (#209)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-08-20 14:33:23 +07:00
github-actions[bot] 1d93775f04 Release 0.1.39 (#243)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-19 16:33:07 +07:00
Thuc Pham 3fb93c7939 feat: use llamacloud pipeline in TS (#236)
---------
Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
2024-08-19 15:49:51 +07:00
github-actions[bot] e248dc56bc Release 0.1.38 (#242)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2024-08-16 10:58:56 +07:00
Marcus Schiesser bd5e39a390 fix: files in sub folders of 'data' are not displayed (#241) 2024-08-16 10:57:44 +07:00
66 changed files with 1311 additions and 586 deletions
+13
View File
@@ -20,6 +20,18 @@ jobs:
os: [macos-latest, windows-latest, ubuntu-22.04]
frameworks: ["nextjs", "express", "fastapi"]
datasources: ["--no-files", "--example-file"]
templates: ["streaming", "extractor"]
exclude:
# The extractor template currently only works with FastAPI and files,
# and it's not compatible with Windows at the moment.
- templates: "extractor"
os: windows-latest
- templates: "extractor"
frameworks: "nextjs"
- templates: "extractor"
frameworks: "express"
- templates: "extractor"
datasources: "--no-files"
defaults:
run:
shell: bash
@@ -65,6 +77,7 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLAMA_CLOUD_API_KEY: ${{ secrets.LLAMA_CLOUD_API_KEY }}
TEMPLATE: ${{ matrix.templates }}
FRAMEWORK: ${{ matrix.frameworks }}
DATASOURCE: ${{ matrix.datasources }}
working-directory: .
+24
View File
@@ -1,5 +1,29 @@
# create-llama
## 0.1.41
### Patch Changes
- 57e7638: Use the retrieval defaults from LlamaCloud
## 0.1.40
### Patch Changes
- 8ce4a85: Add UI for extractor template
## 0.1.39
### Patch Changes
- 3fb93c7: Use LlamaCloud pipeline for data ingestion in TS (private file uploads and generate script)
## 0.1.38
### Patch Changes
- bd5e39a: Fix error that files in sub folders of 'data' are not displayed
## 0.1.37
### Patch Changes
-120
View File
@@ -1,120 +0,0 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { expect, test } from "@playwright/test";
import { ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import type {
TemplateFramework,
TemplatePostInstallAction,
TemplateType,
TemplateUI,
} from "../helpers";
import { createTestDir, runCreateLlama, type AppType } from "./utils";
const templateType: TemplateType = "streaming";
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
: "fastapi";
const dataSource: string = process.env.DATASOURCE
? process.env.DATASOURCE
: "--example-file";
const templateUI: TemplateUI = "shadcn";
const templatePostInstallAction: TemplatePostInstallAction = "runApp";
const llamaCloudProjectName = "create-llama";
const llamaCloudIndexName = "e2e-test";
const appType: AppType = templateFramework === "nextjs" ? "" : "--frontend";
const userMessage =
dataSource !== "--no-files" ? "Physical standard for letters" : "Hello";
test.describe(`try create-llama ${templateType} ${templateFramework} ${dataSource} ${templateUI} ${appType} ${templatePostInstallAction}`, async () => {
let port: number;
let externalPort: number;
let cwd: string;
let name: string;
let appProcess: ChildProcess;
// Only test without using vector db for now
const vectorDb = "none";
test.beforeAll(async () => {
port = Math.floor(Math.random() * 10000) + 10000;
externalPort = port + 1;
cwd = await createTestDir();
const result = await runCreateLlama(
cwd,
templateType,
templateFramework,
dataSource,
templateUI,
vectorDb,
appType,
port,
externalPort,
templatePostInstallAction,
llamaCloudProjectName,
llamaCloudIndexName,
);
name = result.projectName;
appProcess = result.appProcess;
});
test("App folder should exist", async () => {
const dirExists = fs.existsSync(path.join(cwd, name));
expect(dirExists).toBeTruthy();
});
test("Frontend should have a title", async ({ page }) => {
test.skip(templatePostInstallAction !== "runApp");
await page.goto(`http://localhost:${port}`);
await expect(page.getByText("Built by LlamaIndex")).toBeVisible();
});
test("Frontend should be able to submit a message and receive a response", async ({
page,
}) => {
test.skip(templatePostInstallAction !== "runApp");
await page.goto(`http://localhost:${port}`);
await page.fill("form input", userMessage);
const [response] = await Promise.all([
page.waitForResponse(
(res) => {
return res.url().includes("/api/chat") && res.status() === 200;
},
{
timeout: 1000 * 60,
},
),
page.click("form button[type=submit]"),
]);
const text = await response.text();
console.log("AI response when submitting message: ", text);
expect(response.ok()).toBeTruthy();
});
test("Backend frameworks should response when calling non-streaming chat API", async ({
request,
}) => {
test.skip(templatePostInstallAction !== "runApp");
test.skip(templateFramework === "nextjs");
const response = await request.post(
`http://localhost:${externalPort}/api/chat/request`,
{
data: {
messages: [
{
role: "user",
content: userMessage,
},
],
},
},
);
const text = await response.text();
console.log("AI response when calling API: ", text);
expect(response.ok()).toBeTruthy();
});
// clean processes
test.afterAll(async () => {
appProcess?.kill();
});
});
+50
View File
@@ -0,0 +1,50 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { expect, test } from "@playwright/test";
import { ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import { createTestDir, runCreateLlama } from "./utils";
if (process.env.TEMPLATE === "extractor") {
test.describe("Test extractor template", async () => {
let frontendPort: number;
let backendPort: number;
let name: string;
let appProcess: ChildProcess;
let cwd: string;
// Create extractor app
test.beforeAll(async () => {
cwd = await createTestDir();
frontendPort = Math.floor(Math.random() * 10000) + 10000;
backendPort = frontendPort + 1;
const result = await runCreateLlama(
cwd,
"extractor",
"fastapi",
"--example-file",
"none",
frontendPort,
backendPort,
"runApp",
);
name = result.projectName;
appProcess = result.appProcess;
});
test.afterAll(async () => {
appProcess.kill();
});
test("App folder should exist", async () => {
const dirExists = fs.existsSync(path.join(cwd, name));
expect(dirExists).toBeTruthy();
});
test("Frontend should have a title", async ({ page }) => {
await page.goto(`http://localhost:${frontendPort}`);
await expect(page.getByText("Built by LlamaIndex")).toBeVisible({
timeout: 2000 * 60,
});
});
});
}
+125
View File
@@ -0,0 +1,125 @@
/* eslint-disable turbo/no-undeclared-env-vars */
import { expect, test } from "@playwright/test";
import { ChildProcess } from "child_process";
import fs from "fs";
import path from "path";
import type {
TemplateFramework,
TemplatePostInstallAction,
TemplateType,
TemplateUI,
} from "../helpers";
import { createTestDir, runCreateLlama, type AppType } from "./utils";
const templateType: TemplateType | undefined = process.env.TEMPLATE
? (process.env.TEMPLATE as TemplateType)
: undefined;
const templateFramework: TemplateFramework = process.env.FRAMEWORK
? (process.env.FRAMEWORK as TemplateFramework)
: "fastapi";
const dataSource: string = process.env.DATASOURCE
? process.env.DATASOURCE
: "--example-file";
const templateUI: TemplateUI = "shadcn";
const templatePostInstallAction: TemplatePostInstallAction = "runApp";
const llamaCloudProjectName = "create-llama";
const llamaCloudIndexName = "e2e-test";
const appType: AppType = templateFramework === "nextjs" ? "" : "--frontend";
const userMessage =
dataSource !== "--no-files" ? "Physical standard for letters" : "Hello";
if (templateType === "streaming") {
test.describe(`Test streaming template ${templateFramework} ${dataSource} ${templateUI} ${appType} ${templatePostInstallAction}`, async () => {
let port: number;
let externalPort: number;
let cwd: string;
let name: string;
let appProcess: ChildProcess;
// Only test without using vector db for now
const vectorDb = "none";
test.beforeAll(async () => {
port = Math.floor(Math.random() * 10000) + 10000;
externalPort = port + 1;
cwd = await createTestDir();
const result = await runCreateLlama(
cwd,
templateType,
templateFramework,
dataSource,
vectorDb,
port,
externalPort,
templatePostInstallAction,
templateUI,
appType,
llamaCloudProjectName,
llamaCloudIndexName,
);
name = result.projectName;
appProcess = result.appProcess;
});
test("App folder should exist", async () => {
const dirExists = fs.existsSync(path.join(cwd, name));
expect(dirExists).toBeTruthy();
});
test("Frontend should have a title", async ({ page }) => {
test.skip(templatePostInstallAction !== "runApp");
await page.goto(`http://localhost:${port}`);
await expect(page.getByText("Built by LlamaIndex")).toBeVisible();
});
test("Frontend should be able to submit a message and receive a response", async ({
page,
}) => {
test.skip(templatePostInstallAction !== "runApp");
await page.goto(`http://localhost:${port}`);
await page.fill("form input", userMessage);
const [response] = await Promise.all([
page.waitForResponse(
(res) => {
return res.url().includes("/api/chat") && res.status() === 200;
},
{
timeout: 1000 * 60,
},
),
page.click("form button[type=submit]"),
]);
const text = await response.text();
console.log("AI response when submitting message: ", text);
expect(response.ok()).toBeTruthy();
});
test("Backend frameworks should response when calling non-streaming chat API", async ({
request,
}) => {
test.skip(templatePostInstallAction !== "runApp");
test.skip(templateFramework === "nextjs");
const response = await request.post(
`http://localhost:${externalPort}/api/chat/request`,
{
data: {
messages: [
{
role: "user",
content: userMessage,
},
],
},
},
);
const text = await response.text();
console.log("AI response when calling API: ", text);
expect(response.ok()).toBeTruthy();
});
// clean processes
test.afterAll(async () => {
appProcess?.kill();
});
});
}
+15 -9
View File
@@ -24,14 +24,14 @@ export async function runCreateLlama(
templateType: TemplateType,
templateFramework: TemplateFramework,
dataSource: string,
templateUI: TemplateUI,
vectorDb: TemplateVectorDB,
appType: AppType,
port: number,
externalPort: number,
postInstallAction: TemplatePostInstallAction,
llamaCloudProjectName: string,
llamaCloudIndexName: string,
templateUI?: TemplateUI,
appType?: AppType,
llamaCloudProjectName?: string,
llamaCloudIndexName?: string,
): Promise<CreateLlamaResult> {
if (!process.env.OPENAI_API_KEY || !process.env.LLAMA_CLOUD_API_KEY) {
throw new Error(
@@ -45,7 +45,7 @@ export async function runCreateLlama(
templateUI,
appType,
].join("-");
const command = [
const commandArgs = [
"create-llama",
name,
"--template",
@@ -53,13 +53,10 @@ export async function runCreateLlama(
"--framework",
templateFramework,
dataSource,
"--ui",
templateUI,
"--vector-db",
vectorDb,
"--open-ai-key",
process.env.OPENAI_API_KEY,
appType,
"--use-pnpm",
"--port",
port,
@@ -74,7 +71,16 @@ export async function runCreateLlama(
"none",
"--llama-cloud-key",
process.env.LLAMA_CLOUD_API_KEY,
].join(" ");
];
if (templateUI) {
commandArgs.push("--ui", templateUI);
}
if (appType) {
commandArgs.push(appType);
}
const command = commandArgs.join(" ");
console.log(`running command '${command}' in ${cwd}`);
const appProcess = exec(command, {
cwd,
+1 -2
View File
@@ -160,7 +160,7 @@ const getVectorDBEnvs = (
{
name: "LLAMA_CLOUD_ORGANIZATION_ID",
description:
"The organization ID for the LlamaCloud project (uses default organization if not specified - Python only)",
"The organization ID for the LlamaCloud project (uses default organization if not specified)",
},
...(framework === "nextjs"
? // activate index selector per default (not needed for non-NextJS backends as it's handled by createFrontendEnvFile)
@@ -396,7 +396,6 @@ const getEngineEnvs = (): EnvVar[] => {
name: "TOP_K",
description:
"The number of similar embeddings to return when retrieving documents.",
value: "3",
},
{
name: "STREAM_TIMEOUT",
+59 -48
View File
@@ -23,66 +23,77 @@ const createProcess = (
});
};
// eslint-disable-next-line max-params
export function runReflexApp(
appPath: string,
frontendPort?: number,
backendPort?: number,
) {
const commandArgs = ["run", "reflex", "run"];
if (frontendPort) {
commandArgs.push("--frontend-port", frontendPort.toString());
}
if (backendPort) {
commandArgs.push("--backend-port", backendPort.toString());
}
return createProcess("poetry", commandArgs, {
stdio: "inherit",
cwd: appPath,
});
}
export function runFastAPIApp(appPath: string, port: number) {
const commandArgs = ["run", "uvicorn", "main:app", "--port=" + port];
return createProcess("poetry", commandArgs, {
stdio: "inherit",
cwd: appPath,
});
}
export function runTSApp(appPath: string, port: number) {
return createProcess("npm", ["run", "dev"], {
stdio: "inherit",
cwd: appPath,
env: { ...process.env, PORT: `${port}` },
});
}
export async function runApp(
appPath: string,
template: string,
frontend: boolean,
framework: TemplateFramework,
port?: number,
externalPort?: number,
): Promise<any> {
let backendAppProcess: ChildProcess;
let frontendAppProcess: ChildProcess | undefined;
const frontendPort = port || 3000;
let backendPort = externalPort || 8000;
const processes: ChildProcess[] = [];
// Callback to kill app processes
// Callback to kill all sub processes if the main process is killed
process.on("exit", () => {
console.log("Killing app processes...");
backendAppProcess.kill();
frontendAppProcess?.kill();
processes.forEach((p) => p.kill());
});
let backendCommand = "";
let backendArgs: string[];
if (framework === "fastapi") {
backendCommand = "poetry";
backendArgs = [
"run",
"uvicorn",
"main:app",
"--host=0.0.0.0",
"--port=" + backendPort,
];
} else if (framework === "nextjs") {
backendCommand = "npm";
backendArgs = ["run", "dev"];
backendPort = frontendPort;
} else {
backendCommand = "npm";
backendArgs = ["run", "dev"];
// Default sub app paths
const backendPath = path.join(appPath, "backend");
const frontendPath = path.join(appPath, "frontend");
if (template === "extractor") {
processes.push(runReflexApp(appPath, port, externalPort));
}
if (template === "streaming") {
if (framework === "fastapi" || framework === "express") {
const backendRunner = framework === "fastapi" ? runFastAPIApp : runTSApp;
if (frontend) {
processes.push(backendRunner(backendPath, externalPort || 8000));
processes.push(runTSApp(frontendPath, port || 3000));
} else {
processes.push(backendRunner(appPath, externalPort || 8000));
}
} else if (framework === "nextjs") {
processes.push(runTSApp(appPath, port || 3000));
}
}
if (frontend) {
return new Promise((resolve, reject) => {
backendAppProcess = createProcess(backendCommand, backendArgs, {
stdio: "inherit",
cwd: path.join(appPath, "backend"),
env: { ...process.env, PORT: `${backendPort}` },
});
frontendAppProcess = createProcess("npm", ["run", "dev"], {
stdio: "inherit",
cwd: path.join(appPath, "frontend"),
env: { ...process.env, PORT: `${frontendPort}` },
});
});
} else {
return new Promise((resolve, reject) => {
backendAppProcess = createProcess(backendCommand, backendArgs, {
stdio: "inherit",
cwd: path.join(appPath),
env: { ...process.env, PORT: `${backendPort}` },
});
});
}
return Promise.all(processes);
}
+5 -1
View File
@@ -188,7 +188,10 @@ if (process.argv.includes("--tools")) {
program.tools = getTools(program.tools.split(","));
}
}
if (process.argv.includes("--no-llama-parse")) {
if (
process.argv.includes("--no-llama-parse") ||
program.template === "extractor"
) {
program.useLlamaParse = false;
}
program.askModels = process.argv.includes("--ask-models");
@@ -341,6 +344,7 @@ Please check ${cyan(
console.log(`Running app in ${root}...`);
await runApp(
root,
program.template,
program.frontend,
program.framework,
program.port,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "create-llama",
"version": "0.1.37",
"version": "0.1.41",
"description": "Create LlamaIndex-powered apps with one command",
"keywords": [
"rag",
+13 -4
View File
@@ -172,7 +172,7 @@ export const getDataSourceChoices = (
);
}
if (framework === "fastapi") {
if (framework === "fastapi" && template !== "extractor") {
choices.push({
title: "Use website content (requires Chrome)",
value: "web",
@@ -183,7 +183,7 @@ export const getDataSourceChoices = (
});
}
if (!selectedDataSource.length) {
if (!selectedDataSource.length && template !== "extractor") {
choices.push({
title: "Use managed index from LlamaCloud",
value: "llamacloud",
@@ -407,9 +407,14 @@ export const askQuestions = async (
return; // early return - no further questions needed for llamapack projects
}
if (program.template === "multiagent" || program.template === "extractor") {
if (program.template === "multiagent") {
// TODO: multi-agents currently only supports FastAPI
program.framework = preferences.framework = "fastapi";
} else if (program.template === "extractor") {
// Extractor template only supports FastAPI, empty data sources, and llamacloud
// So we just use example file for extractor template, this allows user to choose vector database later
program.dataSources = [EXAMPLE_FILE];
program.framework = preferences.framework = "fastapi";
}
if (!program.framework) {
if (ciInfo.isCI) {
@@ -652,7 +657,11 @@ export const askQuestions = async (
// default to use LlamaParse if using LlamaCloud
program.useLlamaParse = preferences.useLlamaParse = true;
} else {
if (program.useLlamaParse === undefined) {
// Extractor template doesn't support LlamaParse and LlamaCloud right now (cannot use asyncio loop in Reflex)
if (
program.useLlamaParse === undefined &&
program.template !== "extractor"
) {
// if already set useLlamaParse, don't ask again
if (program.dataSources.some((ds) => ds.type === "file")) {
if (ciInfo.isCI) {
@@ -1,21 +1,22 @@
import os
from llama_index.core.settings import Settings
from llama_index.core.agent import AgentRunner
from llama_index.core.tools.query_engine import QueryEngineTool
from app.engine.tools import ToolFactory
from app.engine.index import get_index
from app.engine.tools import ToolFactory
from llama_index.core.agent import AgentRunner
from llama_index.core.settings import Settings
from llama_index.core.tools.query_engine import QueryEngineTool
def get_chat_engine(filters=None, params=None):
system_prompt = os.getenv("SYSTEM_PROMPT")
top_k = os.getenv("TOP_K", "3")
top_k = int(os.getenv("TOP_K", 0))
tools = []
# Add query tool if index exists
index = get_index()
if index is not None:
query_engine = index.as_query_engine(
similarity_top_k=int(top_k), filters=filters
filters=filters, **({"similarity_top_k": top_k} if top_k != 0 else {})
)
query_engine_tool = QueryEngineTool.from_defaults(query_engine=query_engine)
tools.append(query_engine_tool)
@@ -9,7 +9,7 @@ from llama_index.core.chat_engine import CondensePlusContextChatEngine
def get_chat_engine(filters=None, params=None):
system_prompt = os.getenv("SYSTEM_PROMPT")
citation_prompt = os.getenv("SYSTEM_CITATION_PROMPT", None)
top_k = int(os.getenv("TOP_K", 3))
top_k = int(os.getenv("TOP_K", 0))
node_postprocessors = []
if citation_prompt:
@@ -26,8 +26,7 @@ def get_chat_engine(filters=None, params=None):
)
retriever = index.as_retriever(
similarity_top_k=top_k,
filters=filters,
filters=filters, **({"similarity_top_k": top_k} if top_k != 0 else {})
)
return CondensePlusContextChatEngine.from_defaults(
@@ -10,7 +10,7 @@ export async function createChatEngine(documentIds?: string[], params?: any) {
);
}
const retriever = index.asRetriever({
similarityTopK: process.env.TOP_K ? parseInt(process.env.TOP_K) : 3,
similarityTopK: process.env.TOP_K ? parseInt(process.env.TOP_K) : undefined,
filters: generateFilters(documentIds || []),
});
@@ -11,7 +11,20 @@ const MIME_TYPE_TO_EXT: Record<string, string> = {
const UPLOADED_FOLDER = "output/uploaded";
export async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
export async function storeAndParseFile(fileBuffer: Buffer, mimeType: string) {
const documents = await loadDocuments(fileBuffer, mimeType);
const { filename } = await saveDocument(fileBuffer, mimeType);
for (const document of documents) {
document.metadata = {
...document.metadata,
file_name: filename,
private: "true", // to separate private uploads from public documents
};
}
return documents;
}
async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
const extractors = getExtractors();
const reader = extractors[MIME_TYPE_TO_EXT[mimeType]];
@@ -22,7 +35,7 @@ export async function loadDocuments(fileBuffer: Buffer, mimeType: string) {
return await reader.loadDataAsContent(fileBuffer);
}
export async function saveDocument(fileBuffer: Buffer, mimeType: string) {
async function saveDocument(fileBuffer: Buffer, mimeType: string) {
const fileExt = MIME_TYPE_TO_EXT[mimeType];
if (!fileExt) throw new Error(`Unsupported document type: ${mimeType}`);
@@ -5,34 +5,24 @@ import {
SimpleNodeParser,
VectorStoreIndex,
} from "llamaindex";
import { LlamaCloudIndex } from "llamaindex/cloud/LlamaCloudIndex";
export async function runPipeline(
currentIndex: VectorStoreIndex | LlamaCloudIndex,
currentIndex: VectorStoreIndex,
documents: Document[],
) {
if (currentIndex instanceof LlamaCloudIndex) {
// LlamaCloudIndex processes the documents automatically
// so we don't need ingestion pipeline, just insert the documents directly
for (const document of documents) {
await currentIndex.insert(document);
}
} else {
// Use ingestion pipeline to process the documents into nodes and add them to the vector store
const pipeline = new IngestionPipeline({
transformations: [
new SimpleNodeParser({
chunkSize: Settings.chunkSize,
chunkOverlap: Settings.chunkOverlap,
}),
Settings.embedModel,
],
});
const nodes = await pipeline.run({ documents });
await currentIndex.insertNodes(nodes);
currentIndex.storageContext.docStore.persist();
console.log("Added nodes to the vector store.");
}
// Use ingestion pipeline to process the documents into nodes and add them to the vector store
const pipeline = new IngestionPipeline({
transformations: [
new SimpleNodeParser({
chunkSize: Settings.chunkSize,
chunkOverlap: Settings.chunkOverlap,
}),
Settings.embedModel,
],
});
const nodes = await pipeline.run({ documents });
await currentIndex.insertNodes(nodes);
currentIndex.storageContext.docStore.persist();
console.log("Added nodes to the vector store.");
return documents.map((document) => document.id_);
}
@@ -1,26 +1,32 @@
import { VectorStoreIndex } from "llamaindex";
import { LLamaCloudFileService, VectorStoreIndex } from "llamaindex";
import { LlamaCloudIndex } from "llamaindex/cloud/LlamaCloudIndex";
import { loadDocuments, saveDocument } from "./helper";
import { storeAndParseFile } from "./helper";
import { runPipeline } from "./pipeline";
export async function uploadDocument(
index: VectorStoreIndex | LlamaCloudIndex,
filename: string,
raw: string,
): Promise<string[]> {
const [header, content] = raw.split(",");
const mimeType = header.replace("data:", "").replace(";base64", "");
const fileBuffer = Buffer.from(content, "base64");
const documents = await loadDocuments(fileBuffer, mimeType);
const { filename } = await saveDocument(fileBuffer, mimeType);
// Update documents with metadata
for (const document of documents) {
document.metadata = {
...document.metadata,
file_name: filename,
private: "true", // to separate private uploads from public documents
};
if (index instanceof LlamaCloudIndex) {
// trigger LlamaCloudIndex API to upload the file and run the pipeline
const projectId = await index.getProjectId();
const pipelineId = await index.getPipelineId();
return [
await LLamaCloudFileService.addFileToPipeline(
projectId,
pipelineId,
new File([fileBuffer], filename, { type: mimeType }),
{ private: "true" },
),
];
}
return await runPipeline(index, documents);
// run the pipeline for other vector store indexes
const documents = await storeAndParseFile(fileBuffer, mimeType);
return runPipeline(index, documents);
}
@@ -1,13 +1,18 @@
import { StreamData } from "ai";
import {
CallbackManager,
LLamaCloudFileService,
Metadata,
MetadataMode,
NodeWithScore,
ToolCall,
ToolOutput,
} from "llamaindex";
import { LLamaCloudFileService } from "./service";
import path from "node:path";
import { DATA_DIR } from "../../engine/loader";
import { downloadFile } from "./file";
const LLAMA_CLOUD_DOWNLOAD_FOLDER = "output/llamacloud";
export function appendSourceData(
data: StreamData,
@@ -84,7 +89,7 @@ export function createCallbackManager(stream: StreamData) {
stream,
`Retrieved ${nodes.length} sources to use as context for the query`,
);
LLamaCloudFileService.downloadFiles(nodes); // don't await to avoid blocking chat streaming
downloadFilesFromNodes(nodes); // don't await to avoid blocking chat streaming
});
callbackManager.on("llm-tool-call", (event) => {
@@ -116,15 +121,71 @@ function getNodeUrl(metadata: Metadata) {
if (fileName && process.env.FILESERVER_URL_PREFIX) {
// file_name exists and file server is configured
const pipelineId = metadata["pipeline_id"];
if (pipelineId && metadata["private"] == null) {
// file is from LlamaCloud and was not ingested locally
const name = LLamaCloudFileService.toDownloadedName(pipelineId, fileName);
return `${process.env.FILESERVER_URL_PREFIX}/output/llamacloud/${name}`;
if (pipelineId) {
const name = toDownloadedName(pipelineId, fileName);
return `${process.env.FILESERVER_URL_PREFIX}/${LLAMA_CLOUD_DOWNLOAD_FOLDER}/${name}`;
}
const isPrivate = metadata["private"] === "true";
const folder = isPrivate ? "output/uploaded" : "data";
return `${process.env.FILESERVER_URL_PREFIX}/${folder}/${fileName}`;
if (isPrivate) {
return `${process.env.FILESERVER_URL_PREFIX}/output/uploaded/${fileName}`;
}
const filePath = metadata["file_path"];
const dataDir = path.resolve(DATA_DIR);
if (filePath && dataDir) {
const relativePath = path.relative(dataDir, filePath);
return `${process.env.FILESERVER_URL_PREFIX}/data/${relativePath}`;
}
}
// fallback to URL in metadata (e.g. for websites)
return metadata["URL"];
}
async function downloadFilesFromNodes(nodes: NodeWithScore<Metadata>[]) {
try {
const files = nodesToLlamaCloudFiles(nodes);
for (const { pipelineId, fileName, downloadedName } of files) {
const downloadUrl = await LLamaCloudFileService.getFileUrl(
pipelineId,
fileName,
);
if (downloadUrl) {
await downloadFile(
downloadUrl,
downloadedName,
LLAMA_CLOUD_DOWNLOAD_FOLDER,
);
}
}
} catch (error) {
console.error("Error downloading files from nodes:", error);
}
}
function nodesToLlamaCloudFiles(nodes: NodeWithScore<Metadata>[]) {
const files: Array<{
pipelineId: string;
fileName: string;
downloadedName: string;
}> = [];
for (const node of nodes) {
const pipelineId = node.node.metadata["pipeline_id"];
const fileName = node.node.metadata["file_name"];
if (!pipelineId || !fileName) continue;
const isDuplicate = files.some(
(f) => f.pipelineId === pipelineId && f.fileName === fileName,
);
if (!isDuplicate) {
files.push({
pipelineId,
fileName,
downloadedName: toDownloadedName(pipelineId, fileName),
});
}
}
return files;
}
function toDownloadedName(pipelineId: string, fileName: string) {
return `${pipelineId}$${fileName}`;
}
@@ -0,0 +1,35 @@
import fs from "node:fs";
import https from "node:https";
import path from "node:path";
export async function downloadFile(
urlToDownload: string,
filename: string,
folder = "output/uploaded",
) {
try {
const downloadedPath = path.join(folder, filename);
// Check if file already exists
if (fs.existsSync(downloadedPath)) return;
const file = fs.createWriteStream(downloadedPath);
https
.get(urlToDownload, (response) => {
response.pipe(file);
file.on("finish", () => {
file.close(() => {
console.log("File downloaded successfully");
});
});
})
.on("error", (err) => {
fs.unlink(downloadedPath, () => {
console.error("Error downloading file:", err);
throw err;
});
});
} catch (error) {
throw new Error(`Error downloading file: ${error}`);
}
}
@@ -1,187 +0,0 @@
import { Metadata, NodeWithScore } from "llamaindex";
import fs from "node:fs";
import https from "node:https";
import path from "node:path";
const LLAMA_CLOUD_OUTPUT_DIR = "output/llamacloud";
const LLAMA_CLOUD_BASE_URL = "https://cloud.llamaindex.ai/api/v1";
const FILE_DELIMITER = "$"; // delimiter between pipelineId and filename
type LlamaCloudFile = {
name: string;
file_id: string;
project_id: string;
};
type LLamaCloudProject = {
id: string;
organization_id: string;
name: string;
is_default: boolean;
};
type LLamaCloudPipeline = {
id: string;
name: string;
project_id: string;
};
export class LLamaCloudFileService {
private static readonly headers = {
Accept: "application/json",
Authorization: `Bearer ${process.env.LLAMA_CLOUD_API_KEY}`,
};
public static async getAllProjectsWithPipelines() {
try {
const projects = await LLamaCloudFileService.getAllProjects();
const pipelines = await LLamaCloudFileService.getAllPipelines();
return projects.map((project) => ({
...project,
pipelines: pipelines.filter((p) => p.project_id === project.id),
}));
} catch (error) {
console.error("Error listing projects and pipelines:", error);
return [];
}
}
public static async downloadFiles(nodes: NodeWithScore<Metadata>[]) {
const files = LLamaCloudFileService.nodesToDownloadFiles(nodes);
if (!files.length) return;
console.log("Downloading files from LlamaCloud...");
for (const file of files) {
await LLamaCloudFileService.downloadFile(file.pipelineId, file.fileName);
}
}
public static toDownloadedName(pipelineId: string, fileName: string) {
return `${pipelineId}${FILE_DELIMITER}${fileName}`;
}
/**
* This function will return an array of unique files to download from LlamaCloud
* We only download files that are uploaded directly in LlamaCloud datasources (don't have `private` in metadata)
* Files are uploaded directly in LlamaCloud datasources don't have `private` in metadata (public docs)
* Files are uploaded from local via `generate` command will have `private=false` (public docs)
* Files are uploaded from local via `/chat/upload` endpoint will have `private=true` (private docs)
*
* @param nodes
* @returns list of unique files to download
*/
private static nodesToDownloadFiles(nodes: NodeWithScore<Metadata>[]) {
const downloadFiles: Array<{
pipelineId: string;
fileName: string;
}> = [];
for (const node of nodes) {
const isLocalFile = node.node.metadata["private"] != null;
const pipelineId = node.node.metadata["pipeline_id"];
const fileName = node.node.metadata["file_name"];
if (isLocalFile || !pipelineId || !fileName) continue;
const isDuplicate = downloadFiles.some(
(f) => f.pipelineId === pipelineId && f.fileName === fileName,
);
if (!isDuplicate) {
downloadFiles.push({ pipelineId, fileName });
}
}
return downloadFiles;
}
private static async downloadFile(pipelineId: string, fileName: string) {
try {
const downloadedName = LLamaCloudFileService.toDownloadedName(
pipelineId,
fileName,
);
const downloadedPath = path.join(LLAMA_CLOUD_OUTPUT_DIR, downloadedName);
// Check if file already exists
if (fs.existsSync(downloadedPath)) return;
const urlToDownload = await LLamaCloudFileService.getFileUrlByName(
pipelineId,
fileName,
);
if (!urlToDownload) throw new Error("File not found in LlamaCloud");
const file = fs.createWriteStream(downloadedPath);
https
.get(urlToDownload, (response) => {
response.pipe(file);
file.on("finish", () => {
file.close(() => {
console.log("File downloaded successfully");
});
});
})
.on("error", (err) => {
fs.unlink(downloadedPath, () => {
console.error("Error downloading file:", err);
throw err;
});
});
} catch (error) {
throw new Error(`Error downloading file from LlamaCloud: ${error}`);
}
}
private static async getFileUrlByName(
pipelineId: string,
name: string,
): Promise<string | null> {
const files = await LLamaCloudFileService.getAllFiles(pipelineId);
const file = files.find((file) => file.name === name);
if (!file) return null;
return await LLamaCloudFileService.getFileUrlById(
file.project_id,
file.file_id,
);
}
private static async getFileUrlById(
projectId: string,
fileId: string,
): Promise<string> {
const url = `${LLAMA_CLOUD_BASE_URL}/files/${fileId}/content?project_id=${projectId}`;
const response = await fetch(url, {
method: "GET",
headers: LLamaCloudFileService.headers,
});
const data = (await response.json()) as { url: string };
return data.url;
}
private static async getAllFiles(
pipelineId: string,
): Promise<LlamaCloudFile[]> {
const url = `${LLAMA_CLOUD_BASE_URL}/pipelines/${pipelineId}/files`;
const response = await fetch(url, {
method: "GET",
headers: LLamaCloudFileService.headers,
});
const data = await response.json();
return data;
}
private static async getAllProjects(): Promise<LLamaCloudProject[]> {
const url = `${LLAMA_CLOUD_BASE_URL}/projects`;
const response = await fetch(url, {
method: "GET",
headers: LLamaCloudFileService.headers,
});
const data = (await response.json()) as LLamaCloudProject[];
return data;
}
private static async getAllPipelines(): Promise<LLamaCloudPipeline[]> {
const url = `${LLAMA_CLOUD_BASE_URL}/pipelines`;
const response = await fetch(url, {
method: "GET",
headers: LLamaCloudFileService.headers,
});
const data = (await response.json()) as LLamaCloudPipeline[];
return data;
}
}
+4 -9
View File
@@ -2,21 +2,16 @@ import os
import logging
from typing import Dict
from llama_parse import LlamaParse
from pydantic import BaseModel, validator
from pydantic import BaseModel
from app.config import DATA_DIR
logger = logging.getLogger(__name__)
class FileLoaderConfig(BaseModel):
data_dir: str = "data"
use_llama_parse: bool = False
@validator("data_dir")
def data_dir_must_exist(cls, v):
if not os.path.isdir(v):
raise ValueError(f"Directory '{v}' does not exist")
return v
def llama_parse_parser():
if os.getenv("LLAMA_CLOUD_API_KEY") is None:
@@ -54,7 +49,7 @@ def get_file_documents(config: FileLoaderConfig):
file_extractor = llama_parse_extractor()
reader = SimpleDirectoryReader(
config.data_dir,
DATA_DIR,
recursive=True,
filename_as_id=True,
raise_on_error=True,
@@ -27,6 +27,7 @@ def generate_datasource():
doc.metadata["private"] = "false"
index = VectorStoreIndex.from_documents(
documents,
show_progress=True,
)
# store it for later
index.storage_context.persist(storage_dir)
@@ -1,24 +1,44 @@
import * as dotenv from "dotenv";
import { LlamaCloudIndex } from "llamaindex";
import * as fs from "fs/promises";
import { LLamaCloudFileService } from "llamaindex";
import * as path from "path";
import { getDataSource } from "./index";
import { getDocuments } from "./loader";
import { DATA_DIR } from "./loader";
import { initSettings } from "./settings";
import { checkRequiredEnvVars } from "./shared";
dotenv.config();
async function loadAndIndex() {
const documents = await getDocuments();
async function* walk(dir: string): AsyncGenerator<string> {
const directory = await fs.opendir(dir);
await getDataSource();
await LlamaCloudIndex.fromDocuments({
documents,
name: process.env.LLAMA_CLOUD_INDEX_NAME!,
projectName: process.env.LLAMA_CLOUD_PROJECT_NAME!,
apiKey: process.env.LLAMA_CLOUD_API_KEY,
baseUrl: process.env.LLAMA_CLOUD_BASE_URL,
});
console.log(`Successfully created embeddings!`);
for await (const dirent of directory) {
const entryPath = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
yield* walk(entryPath); // Recursively walk through directories
} else if (dirent.isFile()) {
yield entryPath; // Yield file paths
}
}
}
async function loadAndIndex() {
const index = await getDataSource();
const projectId = await index.getProjectId();
const pipelineId = await index.getPipelineId();
// walk through the data directory and upload each file to LlamaCloud
for await (const filePath of walk(DATA_DIR)) {
const buffer = await fs.readFile(filePath);
const filename = path.basename(filePath);
const file = new File([buffer], filename);
await LLamaCloudFileService.addFileToPipeline(projectId, pipelineId, file, {
private: "false",
});
}
console.log(`Successfully uploaded documents to LlamaCloud!`);
}
(async () => {
@@ -18,6 +18,7 @@ export async function getDataSource(params?: LlamaCloudDataSourceParams) {
);
}
const index = new LlamaCloudIndex({
organizationId: process.env.LLAMA_CLOUD_ORGANIZATION_ID,
name: pipelineName,
projectName,
apiKey,
@@ -12,7 +12,7 @@ export function generateFilters(documentIds: string[]): MetadataFilters {
if (!documentIds.length) return { filters: [publicDocumentsFilter] };
const privateDocumentsFilter: MetadataFilter = {
key: "doc_id",
key: "file_id", // Note: LLamaCloud uses "file_id" to reference private document ids as "doc_id" is a restricted field in LlamaCloud
value: documentIds,
operator: "in",
};
@@ -1,4 +1,4 @@
This is a [LlamaIndex](https://www.llamaindex.ai/) project using [FastAPI](https://fastapi.tiangolo.com/) bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama) featuring [structured extraction](https://docs.llamaindex.ai/en/stable/examples/structured_outputs/structured_outputs/?h=structured+output).
This is a [LlamaIndex](https://www.llamaindex.ai/) project using [Reflex](https://reflex.dev/) bootstrapped with [`create-llama`](https://github.com/run-llama/LlamaIndexTS/tree/main/packages/create-llama) featuring [structured extraction](https://docs.llamaindex.ai/en/stable/examples/structured_outputs/structured_outputs/?h=structured+output) in a RAG pipeline.
## Getting Started
@@ -8,26 +8,38 @@ First, setup the environment with poetry:
```shell
poetry install
poetry shell
```
Then check the parameters that have been pre-configured in the `.env` file in this directory. (E.g. you might need to configure an `OPENAI_API_KEY` if you're using OpenAI as model provider).
Second, generate the embeddings of the documents in the `./data` directory (if this folder exists - otherwise, skip this step):
Second, generate the embeddings of the example document in the `./data` directory:
```shell
poetry run generate
```
Third, run the API in one command:
Third, start app with `reflex` command:
```shell
poetry run python main.py
poetry run reflex run
```
The example provides the `/api/extractor/query` API endpoint.
To deploy the application, refer to the Reflex deployment guide: https://reflex.dev/docs/hosting/deploy-quick-start/
This query endpoint returns structured data in the format of the [Output](./app/api/routers/output.py) class. Modify this class to change the output format.
### UI
You can now access the UI at http://localhost:3000 to test the structure extractor interactively.
It allows you to remove and add your own documents, modify the Pydantic model used for structured extraction, and test the RAG pipeline with different queries.
For example, keep the provided Pydantic model and query: "What is the maximum weight for a parcel?".
> Note: the Pydantic model used is the last element in the code provided by the user.
### API
Alternatively, check the API documentation at http://localhost:8000/docs. This example provides the `/api/extractor/query` API endpoint.
Per default, the query endpoint returns structured data in the format of the model [DEFAULT_MODEL](./app/services/model.py) class. Modify this class to change the output format.
You can test the endpoint with the following curl request:
@@ -49,15 +61,9 @@ curl --location 'localhost:8000/api/extractor/query' \
To retrieve a response with low confidence since the question is not related to the provided document in the `./data` directory.
You can start editing the API endpoint by modifying [`extractor.py`](./app/api/routers/extractor.py). The endpoints auto-update as you save the file.
### Development
Open [http://localhost:8000/docs](http://localhost:8000/docs) with your browser to see the Swagger UI of the API.
The API allows CORS for all origins to simplify development. You can change this behavior by setting the `ENVIRONMENT` environment variable to `prod`:
```
ENVIRONMENT=prod python main.py
```
You can start editing the behavior by modifying the [`ExtractorService`](./app/services/extractor.py). The app auto-updates as you save the file.
## Learn More
@@ -0,0 +1,18 @@
from pydantic import BaseModel
from app.services.model import DEFAULT_MODEL
class RequestData(BaseModel):
query: str
code: str = DEFAULT_MODEL
class Config:
json_schema_extra = {
"examples": [
{
"query": "What's the maximum weight for a parcel?",
"code": DEFAULT_MODEL,
},
],
}
@@ -1,58 +1,15 @@
import logging
import os
from fastapi import APIRouter, HTTPException
from llama_index.core.settings import Settings
from pydantic import BaseModel
from fastapi import APIRouter
from app.api.routers.output import Output
from app.engine.index import get_index
from app.api.models import RequestData
from app.services.extractor import ExtractorService
extractor_router = r = APIRouter()
logger = logging.getLogger("uvicorn")
class RequestData(BaseModel):
query: str
class Config:
json_schema_extra = {
"examples": [
{"query": "What's the maximum weight for a parcel?"},
],
}
@r.post("/query")
async def query_request(
data: RequestData,
):
# Create a query engine using that returns responses in the format of the Output class
query_engine = get_query_engine(Output)
response = await query_engine.aquery(data.query)
output_data = response.response.dict()
return Output(**output_data)
def get_query_engine(output_cls: BaseModel):
top_k = os.getenv("TOP_K", 3)
index = get_index()
if index is None:
raise HTTPException(
status_code=500,
detail=str(
"StorageContext is empty - call 'poetry run generate' to generate the storage first"
),
)
sllm = Settings.llm.as_structured_llm(output_cls)
return index.as_query_engine(
similarity_top_k=int(top_k),
llm=sllm,
response_mode="tree_summarize",
)
async def query_request(data: RequestData):
return await ExtractorService.extract(query=data.query, model_code=data.code)
@@ -0,0 +1,7 @@
from fastapi import APIRouter
from app.api.routers.extractor import extractor_router
api_router = APIRouter()
api_router.include_router(extractor_router, prefix="/api/extractor")
@@ -0,0 +1,22 @@
# flake8: noqa: E402
from dotenv import load_dotenv
load_dotenv()
import reflex as rx
from fastapi import FastAPI
from app.api.routers.extractor import extractor_router
from app.settings import init_settings
from app.ui.pages import * # Keep this import all pages in the app # noqa: F403
init_settings()
def add_routers(app: FastAPI):
app.include_router(extractor_router, prefix="/api/extractor")
app = rx.App()
add_routers(app.api)
@@ -0,0 +1 @@
DATA_DIR = "data"
@@ -0,0 +1 @@
from .engine import get_query_engine as get_query_engine
@@ -0,0 +1,27 @@
import os
from fastapi import HTTPException
from llama_index.core.settings import Settings
from app.engine.index import get_index
def get_query_engine(output_cls):
top_k = int(os.getenv("TOP_K", 0))
index = get_index()
if index is None:
raise HTTPException(
status_code=500,
detail=str(
"StorageContext is empty - call 'poetry run generate' to generate the storage first"
),
)
sllm = Settings.llm.as_structured_llm(output_cls)
return index.as_query_engine(
llm=sllm,
response_mode="tree_summarize",
**({"similarity_top_k": top_k} if top_k != 0 else {}),
)
@@ -0,0 +1,81 @@
# flake8: noqa: E402
from dotenv import load_dotenv
load_dotenv()
import logging
import os
from llama_index.core.ingestion import IngestionPipeline
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.core.storage import StorageContext
from llama_index.core.storage.docstore import SimpleDocumentStore
from app.engine.loaders import get_documents
from app.engine.vectordb import get_vector_store
from app.settings import init_settings
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger()
STORAGE_DIR = os.getenv("STORAGE_DIR", "storage")
def get_doc_store():
# If the storage directory is there, load the document store from it.
# If not, set up an in-memory document store since we can't load from a directory that doesn't exist.
if os.path.exists(STORAGE_DIR):
return SimpleDocumentStore.from_persist_dir(STORAGE_DIR)
else:
return SimpleDocumentStore()
def run_pipeline(docstore, vector_store, documents):
pipeline = IngestionPipeline(
transformations=[
SentenceSplitter(
chunk_size=Settings.chunk_size,
chunk_overlap=Settings.chunk_overlap,
),
Settings.embed_model,
],
docstore=docstore,
docstore_strategy="upserts_and_delete",
vector_store=vector_store,
)
# Run the ingestion pipeline and store the results
nodes = pipeline.run(show_progress=True, documents=documents)
return nodes
def persist_storage(docstore, vector_store):
storage_context = StorageContext.from_defaults(
docstore=docstore,
vector_store=vector_store,
)
storage_context.persist(STORAGE_DIR)
def generate_datasource():
init_settings()
logger.info("Generate index for the provided data")
# Get the stores and documents or create new ones
documents = get_documents()
docstore = get_doc_store()
vector_store = get_vector_store()
# Run the ingestion pipeline
_ = run_pipeline(docstore, vector_store, documents)
# Build the index and persist storage
persist_storage(docstore, vector_store)
logger.info("Finished generating the index")
if __name__ == "__main__":
generate_datasource()
@@ -0,0 +1,17 @@
import logging
from llama_index.core.indices import VectorStoreIndex
from app.engine.vectordb import get_vector_store
logger = logging.getLogger("uvicorn")
def get_index(params=None):
logger.info("Connecting vector store...")
store = get_vector_store()
# Load the index from the vector store
# If you are using a vector store that doesn't store text,
# you must load the index from both the vector store and the document store
index = VectorStoreIndex.from_vector_store(store)
logger.info("Finished load index from vector store.")
return index
@@ -1,7 +1,8 @@
import logging
from llama_index.core.schema import BaseModel, Field
from typing import List
from llama_index.core.schema import BaseModel, Field
logger = logging.getLogger("uvicorn")
@@ -0,0 +1,36 @@
import logging
from app.engine import get_query_engine
from app.services.model import IMPORTS
logger = logging.getLogger("uvicorn")
class InvalidModelCode(Exception):
pass
class ExtractorService:
@staticmethod
def _parse_code(model_code: str):
try:
python_code = f"{IMPORTS}\n\n{model_code}"
logger.debug(python_code)
namespace = {}
exec(python_code, namespace)
# using the last object that the user defined in `model_code` as pydantic class
pydantic_class = namespace[list(namespace.keys())[-1]]
class_name = pydantic_class.__name__
logger.info(f"Using Pydantic class {class_name} for extraction")
return pydantic_class
except Exception as e:
logger.error(e)
raise InvalidModelCode() from e
@classmethod
async def extract(cls, query: str, model_code: str) -> str:
schema_model = cls._parse_code(model_code)
# Create a query engine using that returns responses in the format of the schema
query_engine = get_query_engine(schema_model)
response = await query_engine.aquery(query)
output_data = response.response.dict()
return schema_model(**output_data).json(indent=2)
@@ -0,0 +1,22 @@
IMPORTS = """
from llama_index.core.schema import BaseModel, Field
from typing import List, Optional
from datetime import date
"""
DEFAULT_MODEL = """class Output(BaseModel):
response: str = Field(..., description="The answer to the question.")
page_numbers: List[int] = Field(
...,
description="The page numbers of the sources used to answer this question. Do not include a page number if the context is irrelevant.",
)
confidence: float = Field(
...,
ge=0,
le=1,
description="Confidence value between 0-1 of the correctness of the result.",
)
confidence_explanation: str = Field(
..., description="Explanation for the confidence score"
)
"""
@@ -0,0 +1,9 @@
from .extractor import (
StructuredQuery as StructuredQuery,
extract_data_component as extract_data_component,
)
from .schema_editor import schema_editor_component as schema_editor_component
from .upload import (
UploadedFilesState as UploadedFilesState,
upload_component as upload_component,
)
@@ -0,0 +1,87 @@
import logging
import reflex as rx
from app.services.model import DEFAULT_MODEL
from app.services.extractor import ExtractorService, InvalidModelCode
logger = logging.getLogger("uvicorn")
class StructuredQuery(rx.State):
query: str
response: str
loading: bool = False
code: str = DEFAULT_MODEL
error: str = None
@rx.background
async def handle_query(self):
async with self:
if not self.query:
self.error = "Please enter a query."
return
self.error = None
self.loading = True
# Extract data
# Await long operations outside the context to avoid blocking UI
try:
response = await ExtractorService.extract(
query=self.query, model_code=self.code
)
except InvalidModelCode:
async with self:
self.error = "Invalid Python code"
response = None
except Exception as e:
import traceback
logger.error(
f"Error occurred: {str(e)}\nStack trace:\n{traceback.format_exc()}"
)
async with self:
self.error = f"Error: {str(e)}"
response = None
async with self:
self.response = response
self.loading = False
def extract_data_component() -> rx.Component:
return rx.vstack(
rx.cond(
StructuredQuery.error,
rx.callout(
StructuredQuery.error,
icon="triangle_alert",
color_scheme="red",
role="alert",
),
),
rx.text_area(
id="query",
placeholder="Enter query",
on_change=StructuredQuery.set_query,
width="100%",
height="10vh",
),
rx.button(
"Query",
on_click=StructuredQuery.handle_query,
loading=StructuredQuery.loading,
),
rx.cond(
StructuredQuery.response,
rx.code_block(
StructuredQuery.response,
language="json",
show_line_numbers=True,
wrap_long_lines=True,
size="3",
resize="vertical",
width="100%",
height="70vh",
),
),
width="100%",
)
@@ -0,0 +1,61 @@
"""Reflex custom component Monaco."""
# For wrapping react guide, visit https://reflex.dev/docs/wrapping-react/overview/
# Taken and modified from https://github.com/Lendemor/reflex-monaco
import reflex as rx
class MonacoComponent(rx.Component):
"""Base Monaco component."""
library = "@monaco-editor/react@4.6.0"
# The language to use for the editor.
language: rx.Var[str]
# The theme to use for the editor.
theme: rx.Var[str] = rx.color_mode_cond("light", "vs-dark") # type: ignore
# The width of the editor.
line: rx.Var[int] = rx.Var.create_safe(1, _var_is_string=False)
# The height of the editor.
width: rx.Var[str]
# The height of the editor.
height: rx.Var[str]
class MonacoEditor(MonacoComponent):
"""The Monaco Editor component."""
# The React component tag.
tag = "MonacoEditor"
is_default = True
# The default value to display in the editor.
default_value: rx.Var[str]
# The default language to use for the editor.
default_language: rx.Var[str]
# The path to the default file to load in the editor.
default_path: rx.Var[str]
# The value to display in the editor.
value: rx.Var[str]
# Triggered when the editor value changes.
on_change: rx.EventHandler[lambda e: [e]]
# Triggered when the content is validated. (limited to some languages)
on_validate: rx.EventHandler[lambda e: [e]]
options = {
"minimap": {"enabled": False},
}
monaco = MonacoEditor.create
@@ -0,0 +1,18 @@
import reflex as rx
from app.ui.components.extractor import StructuredQuery
from .monaco import monaco
def schema_editor_component() -> rx.Component:
return rx.vstack(
rx.heading("Pydantic model", size="5"),
monaco(
default_language="python",
default_value=StructuredQuery.code,
width="100%",
height="50vh",
on_change=StructuredQuery.set_code,
),
width="100%",
)
@@ -0,0 +1,94 @@
import os
from typing import List
import reflex as rx
from app.engine.generate import generate_datasource
class UploadedFile(rx.Base):
file_name: str
size: int
class UploadedFilesState(rx.State):
_uploaded_dir = "data"
uploaded_files: List[UploadedFile] = []
async def handle_upload(self, files: list[rx.UploadFile]):
for file in files:
upload_data = await file.read()
outfile = os.path.join(self._uploaded_dir, file.filename)
with open(outfile, "wb") as f:
f.write(upload_data)
new_file = UploadedFile(file_name=file.filename, size=len(upload_data))
self.uploaded_files.append(new_file)
# Run indexing
try:
generate_datasource()
except Exception as e:
print("Error generating datasource", e)
os.remove(outfile)
self.uploaded_files.remove(new_file)
return rx.toast.error(
f"Error generating index for the uploaded files. {str(e)}",
position="top-center",
)
return rx.toast.success("Files uploaded successfully", position="top-center")
def has_files(self) -> bool:
return len(self.uploaded_files) > 0
def load_files(self):
self.uploaded_files = []
for file in os.listdir(self._uploaded_dir):
file_path = os.path.join(self._uploaded_dir, file)
if os.path.isfile(file_path):
self.uploaded_files.append(
UploadedFile(file_name=file, size=os.path.getsize(file_path))
)
def remove_file(self, file_name: str):
for file in self.uploaded_files:
if file.file_name == file_name:
self.uploaded_files.remove(file)
os.remove(os.path.join(self._uploaded_dir, file_name))
# Run indexing
generate_datasource()
break
def upload_component() -> rx.Component:
return rx.vstack(
rx.heading("Upload", size="5"),
rx.upload(
rx.vstack(
rx.text("Drag and drop files here or click to select files"),
),
on_drop=UploadedFilesState.handle_upload(
rx.upload_files(upload_id="upload1")
),
id="upload1",
border="1px dotted rgb(107,99,246)",
),
rx.foreach(
UploadedFilesState.uploaded_files,
lambda file: rx.card(
rx.stack(
rx.text(file.file_name, size="sm"),
rx.button(
"x",
size="sm",
on_click=UploadedFilesState.remove_file(file.file_name),
),
justify="between",
width="100%",
),
width="100%",
),
),
width="100%",
)
@@ -0,0 +1 @@
from .index import index as index
@@ -0,0 +1,49 @@
import reflex as rx
from ..components import (
UploadedFilesState,
extract_data_component,
schema_editor_component,
upload_component,
)
from ..templates import template
@template(
route="/",
title="Structure extractor",
on_load=[
UploadedFilesState.load_files,
],
)
def index() -> rx.Component:
"""The main index page."""
return rx.vstack(
rx.vstack(
rx.heading("Built by LlamaIndex", size="6"),
rx.text(
"Upload a file then enter a query. The response will be according to the provided Pydantic model."
),
background_color="var(--gray-3)",
align_items="left",
justify_content="left",
width="100%",
padding="1rem",
),
rx.stack(
rx.vstack(
upload_component(),
rx.divider(),
schema_editor_component(),
width="50%",
),
rx.divider(orientation="vertical"),
rx.stack(
extract_data_component(),
width="50%",
),
width="100%",
padding="1rem",
),
width="100%",
)
@@ -0,0 +1 @@
from .template import ThemeState as ThemeState, template as template
@@ -0,0 +1,28 @@
import reflex as rx
border_radius = "var(--radius-2)"
border = f"1px solid {rx.color('gray', 5)}"
text_color = rx.color("gray", 11)
gray_color = rx.color("gray", 11)
gray_bg_color = rx.color("gray", 3)
accent_text_color = rx.color("accent", 10)
accent_color = rx.color("accent", 1)
accent_bg_color = rx.color("accent", 3)
hover_accent_color = {"_hover": {"color": accent_text_color}}
hover_accent_bg = {"_hover": {"background_color": accent_color}}
content_width_vw = "90vw"
sidebar_width = "32em"
sidebar_content_width = "16em"
color_box_size = ["2.25rem", "2.25rem", "2.5rem"]
template_page_style = {
"padding_top": ["1em", "1em", "2em"],
"padding_x": ["auto", "auto", "2em"],
}
template_content_style = {
"padding": "1em",
"margin_bottom": "2em",
"min_height": "90vh",
}
@@ -0,0 +1,117 @@
"""Common templates used between pages in the app."""
from __future__ import annotations
from typing import Callable
import reflex as rx
from . import styles
# Meta tags for the app.
default_meta = [
{
"name": "viewport",
"content": "width=device-width, shrink-to-fit=no, initial-scale=1",
},
]
class ThemeState(rx.State):
"""The state for the theme of the app."""
accent_color: str = "crimson"
gray_color: str = "gray"
radius: str = "large"
scaling: str = "100%"
def template(
route: str | None = None,
title: str | None = None,
description: str | None = None,
meta: str | None = None,
script_tags: list[rx.Component] | None = None,
on_load: rx.event.EventHandler | list[rx.event.EventHandler] | None = None,
) -> Callable[[Callable[[], rx.Component]], rx.Component]:
"""The template for each page of the app.
Args:
route: The route to reach the page.
title: The title of the page.
description: The description of the page.
meta: Additional meta to add to the page.
on_load: The event handler(s) called when the page load.
script_tags: Scripts to attach to the page.
Returns:
The template with the page content.
"""
def decorator(page_content: Callable[[], rx.Component]) -> rx.Component:
"""The template for each page of the app.
Args:
page_content: The content of the page.
Returns:
The template with the page content.
"""
# Get the meta tags for the page.
all_meta = [*default_meta, *(meta or [])]
def templated_page():
return rx.flex(
rx.flex(
rx.vstack(
page_content(),
width="100%",
**styles.template_content_style,
),
width="100%",
**styles.template_page_style,
max_width=[
"100%",
"100%",
"100%",
"100%",
"100%",
],
),
flex_direction=[
"column",
"column",
"column",
"column",
"column",
"row",
],
width="100%",
margin="auto",
position="relative",
)
@rx.page(
route=route,
title=title,
description=description,
meta=all_meta,
script_tags=script_tags,
on_load=on_load,
)
def theme_wrap():
return rx.theme(
templated_page(),
has_background=True,
accent_color=ThemeState.accent_color,
gray_color=ThemeState.gray_color,
radius=ThemeState.radius,
scaling=ThemeState.scaling,
)
return theme_wrap
return decorator
@@ -0,0 +1,4 @@
__pycache__
storage
.env
output
-46
View File
@@ -1,46 +0,0 @@
# flake8: noqa: E402
from dotenv import load_dotenv
load_dotenv()
import logging
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from app.api.routers.extractor import extractor_router
from app.settings import init_settings
app = FastAPI()
init_settings()
environment = os.getenv("ENVIRONMENT", "dev") # Default to 'development' if not set
logger = logging.getLogger("uvicorn")
if environment == "dev":
logger.warning("Running in development mode - allowing CORS for all origins")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Redirect to documentation page when accessing base URL
@app.get("/")
async def redirect_to_docs():
return RedirectResponse(url="/docs")
app.include_router(extractor_router, prefix="/api/extractor")
if __name__ == "__main__":
app_host = os.getenv("APP_HOST", "0.0.0.0")
app_port = int(os.getenv("APP_PORT", "8000"))
reload = True if environment == "dev" else False
uvicorn.run(app="main:app", host=app_host, port=app_port, reload=reload)
@@ -15,6 +15,7 @@ uvicorn = { extras = ["standard"], version = "^0.23.2" }
python-dotenv = "^1.0.0"
llama-index = "^0.10.58"
cachetools = "^5.3.3"
reflex = "^0.5.9"
[build-system]
requires = ["poetry-core"]
@@ -0,0 +1,6 @@
import reflex as rx
config = rx.Config(
app_name="app",
telemetry_enabled=False,
)
@@ -19,7 +19,10 @@ def get_query_engine_tool() -> QueryEngineTool:
index = get_index()
if index is None:
raise ValueError("Index not found. Please create an index first.")
query_engine = index.as_query_engine(similarity_top_k=int(os.getenv("TOP_K", 3)))
top_k = int(os.getenv("TOP_K", 0))
query_engine = index.as_query_engine(
**({"similarity_top_k": top_k} if top_k != 0 else {})
)
return QueryEngineTool(
query_engine=query_engine,
metadata=ToolMetadata(
@@ -20,7 +20,7 @@
"dotenv": "^16.3.1",
"duck-duck-scrape": "^2.2.5",
"express": "^4.18.2",
"llamaindex": "0.5.17",
"llamaindex": "0.5.19",
"pdf2json": "3.0.5",
"ajv": "^8.12.0",
"@e2b/code-interpreter": "^0.0.5",
@@ -1,5 +1,5 @@
import { Request, Response } from "express";
import { LLamaCloudFileService } from "./llamaindex/streaming/service";
import { LLamaCloudFileService } from "llamaindex";
export const chatConfig = async (_req: Request, res: Response) => {
let starterQuestions = undefined;
@@ -15,6 +15,11 @@ export const chatConfig = async (_req: Request, res: Response) => {
};
export const chatLlamaCloudConfig = async (_req: Request, res: Response) => {
if (!process.env.LLAMA_CLOUD_API_KEY) {
return res.status(500).json({
error: "env variable LLAMA_CLOUD_API_KEY is required to use LlamaCloud",
});
}
const config = {
projects: await LLamaCloudFileService.getAllProjectsWithPipelines(),
pipeline: {
@@ -3,12 +3,16 @@ import { getDataSource } from "./engine";
import { uploadDocument } from "./llamaindex/documents/upload";
export const chatUpload = async (req: Request, res: Response) => {
const { base64, params }: { base64: string; params?: any } = req.body;
if (!base64) {
const {
filename,
base64,
params,
}: { filename: string; base64: string; params?: any } = req.body;
if (!base64 || !filename) {
return res.status(400).json({
error: "base64 is required in the request body",
error: "base64 and filename is required in the request body",
});
}
const index = await getDataSource(params);
return res.status(200).json(await uploadDocument(index, base64));
return res.status(200).json(await uploadDocument(index, filename, base64));
};
@@ -7,6 +7,8 @@ from llama_index.core.schema import NodeWithScore
from pydantic import BaseModel, Field, validator
from pydantic.alias_generators import to_camel
from app.config import DATA_DIR
logger = logging.getLogger("uvicorn")
@@ -175,6 +177,7 @@ class SourceNodes(BaseModel):
"Warning: FILESERVER_URL_PREFIX not set in environment variables. Can't use file server"
)
file_name = metadata.get("file_name")
if file_name and url_prefix:
# file_name exists and file server is configured
pipeline_id = metadata.get("pipeline_id")
@@ -184,11 +187,17 @@ class SourceNodes(BaseModel):
return f"{url_prefix}/output/llamacloud/{file_name}"
is_private = metadata.get("private", "false") == "true"
if is_private:
# file is a private upload
return f"{url_prefix}/output/uploaded/{file_name}"
return f"{url_prefix}/data/{file_name}"
else:
# fallback to URL in metadata (e.g. for websites)
return metadata.get("URL")
# file is from calling the 'generate' script
# Get the relative path of file_path to data_dir
file_path = metadata.get("file_path")
data_dir = os.path.abspath(DATA_DIR)
if file_path and data_dir:
relative_path = os.path.relpath(file_path, data_dir)
return f"{url_prefix}/data/{relative_path}"
# fallback to URL in metadata (e.g. for websites)
return metadata.get("URL")
@classmethod
def from_source_nodes(cls, source_nodes: List[NodeWithScore]):
@@ -0,0 +1 @@
DATA_DIR = "data"
@@ -0,0 +1 @@
from .engine import get_chat_engine as get_chat_engine
+9 -6
View File
@@ -1,6 +1,8 @@
# flake8: noqa: E402
from dotenv import load_dotenv
from app.config import DATA_DIR
load_dotenv()
import logging
@@ -43,15 +45,16 @@ if environment == "dev":
def mount_static_files(directory, path):
if os.path.exists(directory):
for dir, _, _ in os.walk(directory):
relative_path = os.path.relpath(dir, directory)
mount_path = path if relative_path == "." else f"{path}/{relative_path}"
logger.info(f"Mounting static files '{dir}' at {mount_path}")
app.mount(mount_path, StaticFiles(directory=dir), name=f"{dir}-static")
logger.info(f"Mounting static files '{directory}' at '{path}'")
app.mount(
path,
StaticFiles(directory=directory, check_dir=False),
name=f"{directory}-static",
)
# Mount the data files to serve the file viewer
mount_static_files("data", "/api/files/data")
mount_static_files(DATA_DIR, "/api/files/data")
# Mount the output files from tools
mount_static_files("output", "/api/files/output")
@@ -1,10 +1,18 @@
import { LLamaCloudFileService } from "llamaindex";
import { NextResponse } from "next/server";
import { LLamaCloudFileService } from "../../llamaindex/streaming/service";
/**
* This API is to get config from the backend envs and expose them to the frontend
*/
export async function GET() {
if (!process.env.LLAMA_CLOUD_API_KEY) {
return NextResponse.json(
{
error: "env variable LLAMA_CLOUD_API_KEY is required to use LlamaCloud",
},
{ status: 500 },
);
}
const config = {
projects: await LLamaCloudFileService.getAllProjectsWithPipelines(),
pipeline: {
@@ -10,11 +10,15 @@ export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
try {
const { base64, params }: { base64: string; params?: any } =
const {
filename,
base64,
params,
}: { filename: string; base64: string; params?: any } =
await request.json();
if (!base64) {
if (!base64 || !filename) {
return NextResponse.json(
{ error: "base64 is required in the request body" },
{ error: "base64 and filename is required in the request body" },
{ status: 400 },
);
}
@@ -24,7 +28,7 @@ export async function POST(request: NextRequest) {
`StorageContext is empty - call 'npm run generate' to generate the storage first`,
);
}
return NextResponse.json(await uploadDocument(index, base64));
return NextResponse.json(await uploadDocument(index, filename, base64));
} catch (error) {
console.error("[Upload API]", error);
return NextResponse.json(
@@ -1,6 +1,7 @@
import { readFile } from "fs/promises";
import { NextRequest, NextResponse } from "next/server";
import path from "path";
import { DATA_DIR } from "../../chat/engine/loader";
/**
* This API is to get file data from allowed folders
@@ -28,7 +29,11 @@ export async function GET(
}
try {
const filePath = path.join(process.cwd(), folder, path.join(...pathTofile));
const filePath = path.join(
process.cwd(),
folder === "data" ? DATA_DIR : folder,
path.join(...pathTofile),
);
const blob = await readFile(filePath);
return new NextResponse(blob, {
@@ -25,7 +25,7 @@
"duck-duck-scrape": "^2.2.5",
"formdata-node": "^6.0.3",
"got": "^14.4.1",
"llamaindex": "0.5.17",
"llamaindex": "0.5.19",
"lucide-react": "^0.294.0",
"next": "^14.2.4",
"react": "^18.2.0",