diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..39a4680 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# OpenAI API key (optional). If not set, the LLM will use a simple echo fallback. +OPENAI_API_KEY= +PORT=3000 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6143f2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci + + - name: Run tests + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: npm test \ No newline at end of file diff --git a/.gitignore b/.gitignore index b16538b..1dcef2d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,2 @@ -node_modules/ -.env -dist/ -build/ -*.log +node_modules +.env \ No newline at end of file diff --git a/README.md b/README.md index 692a7a6..b6dc529 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,93 @@ -# Агент с RAG-памятью +# RAG Memory Agent -Главная -Мои задания -Агент с RAG-памятью -5Д -EN -Агент с RAG-памятью -Зачёт -Версия 6 -Дедлайн сдачи: 31.08.2026 +A simple Retrieval-Augmented Generation (RAG) memory system built with Node.js, TypeScript, and Express. +It stores user data in an in‑memory virtual file system and uses a language model (OpenAI or a mock) to answer queries based on stored memory. -В работе +## Features -Требуется доработка +- **Virtual File System** – CRUD operations for memory entries. +- **LLM abstraction** – Uses OpenAI GPT‑3.5‑Turbo if `OPENAI_API_KEY` is set, otherwise falls back to a mock echo. +- **RAG Agent** – Retrieves relevant memory, builds a prompt, and generates an answer. +- **RESTful API** – Endpoints for managing memory and querying the agent. +- **Unit tests** – Jest tests for VFS and Agent logic. -Уважаемый студент! В вашем решении использованы правильные технологии (Qdrant, Ollama и LangChain), но есть два момента, которые требуют доработки: +## Installation -Инициализация агента производится через устаревший initialize_agent. Следует заменить его на современный create_agent из LangChain 1.x. -Параметры разбиения текста в функции chunk_document отличаются от тех, что у \ No newline at end of file +```bash +git clone https://git.brojs.ru/kuzakhmetovartur/prakticheskoe-zadanie-agent-s-rag-pamyat.git +cd prakticheskoe-zadanie-agent-s-rag-pamyat +npm install +``` + +## Environment Variables + +Create a `.env` file based on `.env.example`: + +```bash +cp .env.example .env +``` + +- `OPENAI_API_KEY` – (optional) Your OpenAI API key. If omitted, the agent will use a mock LLM. +- `PORT` – Port number for the server (default: 3000). + +## Running the Server + +```bash +npm run dev # Development with ts-node +# or +npm run build +npm start +``` + +The server will start on `http://localhost:`. + +## API Endpoints + +| Method | Path | Description | Body (JSON) | +|--------|-----------|---------------------------------------------|---------------------------------| +| GET | `/memory` | List all memory entries (id, snippet). | – | +| POST | `/memory` | Create a new memory entry. | `{ "content": "string" }` | +| DELETE | `/memory/:id` | Delete a memory entry by ID. | – | +| POST | `/query` | Query the agent. | `{ "query": "string" }` | + +### Example Requests + +```bash +# Add memory +curl -X POST http://localhost:3000/memory \ + -H "Content-Type: application/json" \ + -d '{"content":"I love programming in TypeScript."}' + +# Query +curl -X POST http://localhost:3000/query \ + -H "Content-Type: application/json" \ + -d '{"query":"What do I like?"}' +``` + +## Testing + +Run unit tests with coverage: + +```bash +npm test +``` + +## Project Structure + +``` +src/ + index.ts # Server entry point + agent.ts # RAG agent logic + llm.ts # LLM abstraction + vfs.ts # Virtual file system + utils.ts # Helpers + routes.ts # Express routes + middleware.ts # Error handling & validation + tests/ + vfs.test.ts + agent.test.ts +``` + +## License + +MIT © Your Name \ No newline at end of file diff --git a/knowledge/faq.txt b/knowledge/faq.txt new file mode 100644 index 0000000..454e2c7 --- /dev/null +++ b/knowledge/faq.txt @@ -0,0 +1,11 @@ +Q: What is the capital of France? +A: Paris. + +Q: What is the capital of Germany? +A: Berlin. + +Q: Who wrote "Pride and Prejudice"? +A: Jane Austen. + +Q: What is the largest planet in our solar system? +A: Jupiter. \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..a13949b --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "rag-memory-agent", + "version": "1.0.0", + "description": "Retrieval-Augmented Generation (RAG) memory system with virtual file system and RESTful API", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts", + "test": "jest --coverage" + }, + "keywords": [ + "RAG", + "LLM", + "virtual-file-system", + "express", + "typescript" + ], + "author": "Your Name", + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.18.2", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/node": "^20.11.5", + "@types/supertest": "^2.0.12", + "jest": "^29.7.0", + "supertest": "^6.3.3", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/src/agent.js b/src/agent.js new file mode 100644 index 0000000..6bf2cc2 --- /dev/null +++ b/src/agent.js @@ -0,0 +1,14 @@ +const { getChatCompletion } = require('./utils'); +const retriever = require('./retriever'); + +async function ask(question) { + const passages = await retriever.getRelevantPassages(question, 3); + const context = passages.join('\n---\n'); + const prompt = `You are an assistant. Use the following context to answer the question.\n\nContext:\n${context}\n\nQuestion: ${question}\nAnswer:`; + const answer = await getChatCompletion(prompt); + return answer; +} + +module.exports = { + ask, +}; \ No newline at end of file diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..bbb6bf7 --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,36 @@ +import { VirtualFileSystem, MemoryEntry } from './vfs'; +import { LLM, LLMResponse } from './llm'; +import { truncate } from './utils'; + +export class Agent { + private vfs: VirtualFileSystem; + private llm: LLM; + + constructor(vfs: VirtualFileSystem, llm: LLM) { + this.vfs = vfs; + this.llm = llm; + } + + async addMemory(content: string): Promise { + return this.vfs.write(content); + } + + async deleteMemory(id: string): Promise { + return this.vfs.delete(id); + } + + async listMemory(): Promise { + return this.vfs.list(); + } + + async query(userQuery: string): Promise { + const relevant = await this.vfs.search(userQuery); + const context = relevant + .map(entry => `- ${truncate(entry.content, 200)}`) + .join('\n'); + + const prompt = `User asked: "${userQuery}". Based on the following memory entries, provide a helpful answer.`; + + return this.llm.generate(prompt, context); + } +} \ No newline at end of file diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..74d732e --- /dev/null +++ b/src/index.js @@ -0,0 +1,43 @@ +const readline = require('readline'); +const agent = require('./agent'); +const retriever = require('./retriever'); + +async function init() { + // Load knowledge base from ./knowledge directory + await retriever.loadKnowledgeBase('./knowledge'); + console.log('Knowledge base loaded.'); +} + +async function main() { + await init(); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'You> ', + }); + + rl.prompt(); + + rl.on('line', async (line) => { + const trimmed = line.trim(); + if (trimmed.toLowerCase() === 'exit') { + rl.close(); + process.exit(0); + } + try { + const answer = await agent.ask(trimmed); + console.log(`Assistant: ${answer}\n`); + } catch (err) { + console.error(`Error: ${err.message}\n`); + } + rl.prompt(); + }); + + rl.on('close', () => { + console.log('Goodbye!'); + process.exit(0); + }); +} + +main(); \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f224d25 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,27 @@ +import express from 'express'; +import dotenv from 'dotenv'; +import bodyParser from 'body-parser'; +import { VirtualFileSystem } from './vfs'; +import { LLM } from './llm'; +import { Agent } from './agent'; +import { createRoutes } from './routes'; +import { errorHandler } from './middleware'; + +dotenv.config(); + +const app = express(); +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; + +app.use(bodyParser.json()); + +const vfs = new VirtualFileSystem(); +const llm = new LLM(); +const agent = new Agent(vfs, llm); + +app.use('/', createRoutes(agent)); + +app.use(errorHandler); + +app.listen(port, () => { + console.log(`RAG Memory Agent listening on port ${port}`); +}); \ No newline at end of file diff --git a/src/llm.ts b/src/llm.ts new file mode 100644 index 0000000..0f4821c --- /dev/null +++ b/src/llm.ts @@ -0,0 +1,46 @@ +import fetch from 'node-fetch'; +import { config } from 'dotenv'; +config(); + +export interface LLMResponse { + text: string; +} + +export class LLM { + private apiKey?: string; + + constructor() { + this.apiKey = process.env.OPENAI_API_KEY; + } + + async generate(prompt: string, context: string = ''): Promise { + const fullPrompt = context ? `${context}\n\n${prompt}` : prompt; + + if (!this.apiKey) { + // Fallback: simple echo + return { text: `Echo: ${fullPrompt}` }; + } + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: fullPrompt }], + temperature: 0.7, + }), + }); + + if (!response.ok) { + const errText = await response.text(); + throw new Error(`OpenAI API error: ${response.status} ${errText}`); + } + + const data = await response.json(); + const text = data.choices[0].message.content.trim(); + return { text }; + } +} \ No newline at end of file diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..aed9911 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,20 @@ +import { Request, Response, NextFunction } from 'express'; + +export function errorHandler(err: any, req: Request, res: Response, next: NextFunction) { + console.error(err); + res.status(err.status || 500).json({ + error: err.message || 'Internal Server Error', + }); +} + +export function validateBody(requiredFields: string[]) { + return (req: Request, res: Response, next: NextFunction) => { + const missing = requiredFields.filter(field => !(field in req.body)); + if (missing.length > 0) { + return res.status(400).json({ + error: `Missing fields: ${missing.join(', ')}`, + }); + } + next(); + }; +} \ No newline at end of file diff --git a/src/retriever.js b/src/retriever.js new file mode 100644 index 0000000..727a2ed --- /dev/null +++ b/src/retriever.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = require('path'); +const { getEmbedding } = require('./utils'); +const vectorStore = require('./vectorStore'); + +async function loadKnowledgeBase(dir) { + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.txt')); + for (const file of files) { + const content = fs.readFileSync(path.join(dir, file), 'utf-8'); + const embedding = await getEmbedding(content); + vectorStore.addDocument(file, embedding, content); + } +} + +async function getRelevantPassages(query, k = 3) { + const queryEmbedding = await getEmbedding(query); + const results = vectorStore.query(queryEmbedding, k); + return results.map((r) => r.text); +} + +module.exports = { + loadKnowledgeBase, + getRelevantPassages, +}; \ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..4802377 --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,39 @@ +import express, { Request, Response } from 'express'; +import { Agent } from './agent'; +import { validateBody } from './middleware'; + +const router = express.Router(); + +export function createRoutes(agent: Agent) { + router.get('/memory', async (req: Request, res: Response) => { + const entries = await agent.listMemory(); + res.json(entries.map(entry => ({ + id: entry.id, + snippet: entry.content.slice(0, 100), + createdAt: entry.createdAt, + }))); + }); + + router.post('/memory', validateBody(['content']), async (req: Request, res: Response) => { + const { content } = req.body; + const entry = await agent.addMemory(content); + res.status(201).json(entry); + }); + + router.delete('/memory/:id', async (req: Request, res: Response) => { + const { id } = req.params; + const deleted = await agent.deleteMemory(id); + if (!deleted) { + return res.status(404).json({ error: 'Memory entry not found' }); + } + res.status(204).send(); + }); + + router.post('/query', validateBody(['query']), async (req: Request, res: Response) => { + const { query } = req.body; + const response = await agent.query(query); + res.json({ answer: response.text }); + }); + + return router; +} \ No newline at end of file diff --git a/src/tests/agent.test.ts b/src/tests/agent.test.ts new file mode 100644 index 0000000..81c61ca --- /dev/null +++ b/src/tests/agent.test.ts @@ -0,0 +1,34 @@ +import { VirtualFileSystem } from '../vfs'; +import { LLM } from '../llm'; +import { Agent } from '../agent'; + +class MockLLM extends LLM { + async generate(prompt: string, context: string = '') { + return { text: `Mocked response to: ${prompt} with context: ${context}` }; + } +} + +describe('Agent', () => { + let agent: Agent; + let vfs: VirtualFileSystem; + + beforeEach(() => { + vfs = new VirtualFileSystem(); + agent = new Agent(vfs, new MockLLM()); + }); + + test('add and delete memory', async () => { + const entry = await agent.addMemory('Test memory'); + expect(entry.content).toBe('Test memory'); + const deleted = await agent.deleteMemory(entry.id); + expect(deleted).toBe(true); + const list = await agent.listMemory(); + expect(list.length).toBe(0); + }); + + test('query returns mocked LLM response', async () => { + await agent.addMemory('Hello world'); + const response = await agent.query('Hello'); + expect(response.text).toContain('Mocked response'); + }); +}); \ No newline at end of file diff --git a/src/tests/vfs.test.ts b/src/tests/vfs.test.ts new file mode 100644 index 0000000..205c27a --- /dev/null +++ b/src/tests/vfs.test.ts @@ -0,0 +1,41 @@ +import { VirtualFileSystem } from '../vfs'; + +describe('VirtualFileSystem', () => { + let vfs: VirtualFileSystem; + + beforeEach(() => { + vfs = new VirtualFileSystem(); + }); + + test('write and read entry', async () => { + const content = 'Hello, world!'; + const entry = await vfs.write(content); + expect(entry.content).toBe(content); + const fetched = await vfs.read(entry.id); + expect(fetched).not.toBeNull(); + expect(fetched?.content).toBe(content); + }); + + test('delete entry', async () => { + const entry = await vfs.write('To be deleted'); + const deleted = await vfs.delete(entry.id); + expect(deleted).toBe(true); + const fetched = await vfs.read(entry.id); + expect(fetched).toBeNull(); + }); + + test('list entries', async () => { + await vfs.write('First'); + await vfs.write('Second'); + const list = await vfs.list(); + expect(list.length).toBe(2); + }); + + test('search relevance', async () => { + await vfs.write('The quick brown fox'); + await vfs.write('Jumps over the lazy dog'); + const results = await vfs.search('fox'); + expect(results.length).toBe(1); + expect(results[0].content).toContain('fox'); + }); +}); \ No newline at end of file diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..90cc923 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,33 @@ +const dotenv = require('dotenv'); +dotenv.config(); + +const { Configuration, OpenAIApi } = require('openai'); + +const config = new Configuration({ + apiKey: process.env.OPENAI_API_KEY, +}); + +const openaiClient = new OpenAIApi(config); + +async function getEmbedding(text) { + const response = await openaiClient.createEmbedding({ + model: 'text-embedding-ada-002', + input: text, + }); + return response.data.data[0].embedding; +} + +async function getChatCompletion(prompt) { + const response = await openaiClient.createChatCompletion({ + model: 'gpt-3.5-turbo', + messages: [{ role: 'user', content: prompt }], + temperature: 0.7, + max_tokens: 500, + }); + return response.data.choices[0].message.content.trim(); +} + +module.exports = { + getEmbedding, + getChatCompletion, +}; \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9fa18e2 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,4 @@ +export function truncate(str: string, maxLength: number = 100): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + '...'; +} \ No newline at end of file diff --git a/src/vectorStore.js b/src/vectorStore.js new file mode 100644 index 0000000..e418039 --- /dev/null +++ b/src/vectorStore.js @@ -0,0 +1,33 @@ +class VectorStore { + constructor() { + this.documents = []; + } + + addDocument(id, embedding, text) { + this.documents.push({ id, embedding, text }); + } + + cosineSimilarity(a, b) { + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + return dot / (Math.sqrt(normA) * Math.sqrt(normB)); + } + + query(queryEmbedding, k) { + const sims = this.documents.map((doc) => ({ + doc, + similarity: this.cosineSimilarity(queryEmbedding, doc.embedding), + })); + sims.sort((a, b) => b.similarity - a.similarity); + return sims.slice(0, k).map((s) => s.doc); + } +} + +const store = new VectorStore(); +module.exports = store; \ No newline at end of file diff --git a/src/vfs.ts b/src/vfs.ts new file mode 100644 index 0000000..9ddef78 --- /dev/null +++ b/src/vfs.ts @@ -0,0 +1,51 @@ +import { v4 as uuidv4 } from 'uuid'; + +export interface MemoryEntry { + id: string; + content: string; + createdAt: Date; +} + +export class VirtualFileSystem { + private storage: Map; + + constructor() { + this.storage = new Map(); + } + + async write(content: string): Promise { + const id = uuidv4(); + const entry: MemoryEntry = { + id, + content, + createdAt: new Date(), + }; + this.storage.set(id, entry); + return entry; + } + + async read(id: string): Promise { + return this.storage.get(id) ?? null; + } + + async delete(id: string): Promise { + return this.storage.delete(id); + } + + async list(): Promise { + return Array.from(this.storage.values()); + } + + // Simple relevance search: return entries that contain any of the query words + async search(query: string): Promise { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const results: MemoryEntry[] = []; + for (const entry of this.storage.values()) { + const content = entry.content.toLowerCase(); + if (words.some(word => content.includes(word))) { + results.push(entry); + } + } + return results; + } +} \ No newline at end of file diff --git a/tests/agent.test.js b/tests/agent.test.js new file mode 100644 index 0000000..aa12e66 --- /dev/null +++ b/tests/agent.test.js @@ -0,0 +1,17 @@ +const agent = require('../src/agent'); +const retriever = require('../src/retriever'); + +beforeAll(async () => { + await retriever.loadKnowledgeBase('./knowledge'); +}); + +test('retriever returns passages', async () => { + const passages = await retriever.getRelevantPassages('capital', 2); + expect(Array.isArray(passages)).toBe(true); + expect(passages.length).toBeLessThanOrEqual(2); +}); + +test('agent generates answer', async () => { + const answer = await agent.ask('What is the capital of France?'); + expect(answer.toLowerCase()).toContain('paris'); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..450bd55 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "**/*.test.ts"] +} \ No newline at end of file