feat: solution for 'Агент с RAG-памятью'
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-24 14:28:11 +03:00
parent 589a621340
commit 14ab96d098
22 changed files with 644 additions and 20 deletions
+14
View File
@@ -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,
};
+36
View File
@@ -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<MemoryEntry> {
return this.vfs.write(content);
}
async deleteMemory(id: string): Promise<boolean> {
return this.vfs.delete(id);
}
async listMemory(): Promise<MemoryEntry[]> {
return this.vfs.list();
}
async query(userQuery: string): Promise<LLMResponse> {
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);
}
}
+43
View File
@@ -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();
+27
View File
@@ -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}`);
});
+46
View File
@@ -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<LLMResponse> {
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 };
}
}
+20
View File
@@ -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();
};
}
+24
View File
@@ -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,
};
+39
View File
@@ -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;
}
+34
View File
@@ -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');
});
});
+41
View File
@@ -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');
});
});
+33
View File
@@ -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,
};
+4
View File
@@ -0,0 +1,4 @@
export function truncate(str: string, maxLength: number = 100): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength) + '...';
}
+33
View File
@@ -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;
+51
View File
@@ -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<string, MemoryEntry>;
constructor() {
this.storage = new Map();
}
async write(content: string): Promise<MemoryEntry> {
const id = uuidv4();
const entry: MemoryEntry = {
id,
content,
createdAt: new Date(),
};
this.storage.set(id, entry);
return entry;
}
async read(id: string): Promise<MemoryEntry | null> {
return this.storage.get(id) ?? null;
}
async delete(id: string): Promise<boolean> {
return this.storage.delete(id);
}
async list(): Promise<MemoryEntry[]> {
return Array.from(this.storage.values());
}
// Simple relevance search: return entries that contain any of the query words
async search(query: string): Promise<MemoryEntry[]> {
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;
}
}