+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/views/settings/AI/AiAssistantSettings.vue b/frontend/src/views/settings/AI/AiAssistantSettings.vue
index ed97993..5782157 100644
--- a/frontend/src/views/settings/AI/AiAssistantSettings.vue
+++ b/frontend/src/views/settings/AI/AiAssistantSettings.vue
@@ -152,12 +152,10 @@ async function onRuleEditorClose(updated) {
border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px;
- width: 100vw;
- max-width: none;
- margin: 0;
+ max-width: 1200px;
+ margin: 32px auto 0 auto;
+ width: 100%;
position: relative;
- left: 50%;
- transform: translateX(-50%);
overflow-x: auto;
}
.close-btn {
diff --git a/md/RAG_SEARCH_SETUP_AND_TEST.md b/md/RAG_SEARCH_SETUP_AND_TEST.md
index b79e037..197ef9e 100644
--- a/md/RAG_SEARCH_SETUP_AND_TEST.md
+++ b/md/RAG_SEARCH_SETUP_AND_TEST.md
@@ -1,35 +1,35 @@
# План настройки и тестирования поиска по таблице RAG
## 1. Подготовка таблицы RAG
-- Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
-- Добавить несколько тестовых записей через UI или напрямую в базу данных.
+- [x] Убедиться, что таблица RAG создана и содержит пары "вопрос-ответ".
+- [x] Добавить несколько тестовых записей через UI или напрямую в базу данных.
## 2. Настройка провайдера эмбеддингов
-- В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
-- Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
-- Сохранить настройки.
+- [x] В настройках ассистента выбрать нужного провайдера (OpenAI, Ollama и др.).
+- [x] Ввести API-ключ и Base URL (например, для OpenAI: https://api.openai.com/v1).
+- [x] Сохранить настройки.
## 3. Проверка настроек ассистента
-- Убедиться, что выбран актуальный ID таблицы RAG.
-- Проверить выбранного провайдера эмбеддингов.
-- Установить порог релевантности (например, 0.95).
+- [x] Убедиться, что выбран актуальный ID таблицы RAG.
+- [x] Проверить выбранного провайдера эмбеддингов.
+- [x] Установить порог релевантности (например, 0.95).
## 4. Проверка backend-логики
-- Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
-- Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
-- Проверить возможность изменения порога релевантности.
+- [x] Проверить, что в backend (например, в ragService.js) реализован поиск по RAG с использованием выбранного провайдера эмбеддингов.
+- [x] Убедиться, что используется актуальный ID таблицы и динамический выбор провайдера.
+- [x] Проверить возможность изменения порога релевантности.
## 5. Тестирование через UI
-- Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
-- Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
+- [ ] Отправить ассистенту вопрос, который есть в RAG-таблице — убедиться, что ответ возвращается из базы.
+- [ ] Отправить вопрос, которого нет в таблице — убедиться, что ассистент либо не отвечает, либо использует LLM (по настройкам).
## 6. Проверка логов backend
-- Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
-- В случае ошибок — проанализировать и устранить их.
+- [ ] Проверить логи на наличие сообщений о поиске по RAG, найденных совпадениях и выбранном провайдере эмбеддингов.
+- [ ] В случае ошибок — проанализировать и устранить их.
## 7. Тестирование через API (опционально)
-- Использовать Postman/curl для отправки запросов напрямую к backend.
-- Пример запроса:
+- [ ] Использовать Postman/curl для отправки запросов напрямую к backend.
+- [ ] Пример запроса:
```http
POST /api/chat/message
{
@@ -39,13 +39,32 @@
```
## 8. Автоматизация тестирования (по желанию)
-- Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
+- [ ] Написать автотесты (например, на Mocha/Jest), которые будут отправлять вопросы и сверять ответы с ожидаемыми из RAG.
## 9. Рекомендации
-- Для тестов использовать уникальные, простые вопросы и ответы.
-- После каждого изменения настроек проводить тестовые запросы.
-- Добавить в UI индикатор источника ответа (из базы или сгенерирован).
+- [ ] Для тестов использовать уникальные, простые вопросы и ответы.
+- [ ] После каждого изменения настроек проводить тестовые запросы.
+- [ ] Добавить в UI индикатор источника ответа (из базы или сгенерирован).
---
+## Этапы внедрения сервиса векторного поиска (под ключ)
+
+- [x] 1. Проектирование и создание структуры Python-сервиса (FastAPI + FAISS)
+- [x] 2. Реализация REST API: /upsert, /search, /delete, /rebuild, /health
+- [x] 3. Интеграция с Ollama для генерации эмбеддингов
+- [x] 4. Dockerfile и docker-compose для сервиса
+- [ ] 5. Интеграция Node.js backend с новым сервисом (HTTP-клиент)
+- [ ] 6. Перенос логики поиска из ragService.js на новый сервис
+- [ ] 7. Тестирование интеграции (ручное и через API)
+- [ ] 8. Документация по запуску и использованию
+- [ ] 9. Финальное тестирование через UI и API
+- [ ] 10. Передача проекта заказчику
+
+---
+
+**Прогресс:**
+- Сервис векторного поиска реализован, поддерживает кэширование, интеграцию с Ollama, все основные REST API.
+- Следующий этап — интеграция с Node.js backend и перенос логики поиска.
+
**Если потребуется пример кода или помощь с конкретной реализацией — обращайтесь!**
\ No newline at end of file
diff --git a/md/vector-search-service.md b/md/vector-search-service.md
new file mode 100644
index 0000000..e849fee
--- /dev/null
+++ b/md/vector-search-service.md
@@ -0,0 +1,108 @@
+# Техническое задание: Векторный сервис поиска по таблице
+
+## Цель
+Реализовать отдельный микросервис для векторного поиска по данным из таблицы в базе данных. Сервис должен предоставлять REST API для добавления, поиска и обновления векторных представлений (эмбеддингов) строк таблицы.
+
+## Язык и стек
+- Язык: Python 3.10+
+- Векторный движок: FAISS
+- API: FastAPI
+- Хранение индекса: на диске (persistency)
+- Docker-образ для деплоя
+
+## API сервиса
+
+### 1. Добавление/обновление записей
+- **POST /upsert**
+- Тело запроса:
+ ```json
+ {
+ "table_id": "string", // идентификатор таблицы
+ "rows": [
+ {
+ "row_id": "string", // идентификатор строки
+ "text": "string", // текст для эмбеддинга
+ "metadata": { ... } // любые дополнительные поля
+ }
+ ]
+ }
+ ```
+- Ответ: `{ "success": true }`
+
+### 2. Поиск похожих записей
+- **POST /search**
+- Тело запроса:
+ ```json
+ {
+ "table_id": "string",
+ "query": "string", // текст запроса
+ "top_k": 3 // количество результатов
+ }
+ ```
+- Ответ:
+ ```json
+ {
+ "results": [
+ {
+ "row_id": "string",
+ "score": float,
+ "metadata": { ... }
+ }
+ ]
+ }
+ ```
+
+### 3. Удаление записей
+- **POST /delete**
+- Тело запроса:
+ ```json
+ {
+ "table_id": "string",
+ "row_ids": ["string", ...]
+ }
+ ```
+- Ответ: `{ "success": true }`
+
+### 4. Пересоздание индекса (опционально)
+- **POST /rebuild**
+- Тело запроса:
+ ```json
+ {
+ "table_id": "string"
+ }
+ ```
+- Ответ: `{ "success": true }`
+
+## Требования к эмбеддингам
+- Для генерации эмбеддингов сервис использует Ollama (через HTTP API, модель mxbai-embed-large или аналогичную).
+- Эмбеддинги кэшируются локально для ускорения поиска.
+
+## Требования к интеграции
+- Сервис не хранит бизнес-логику, только индексы и метаданные.
+- Node.js backend обращается к сервису по HTTP (localhost или через docker-compose).
+- Все операции атомарны, сервис устойчив к сбоям.
+
+## Безопасность
+- Сервис доступен только во внутренней сети (docker-compose).
+- Нет публичного доступа извне.
+
+## Мониторинг и логирование
+- Логирование всех запросов и ошибок.
+- Healthcheck endpoint: **GET /health** (ответ: `{ "status": "ok" }`)
+
+## Docker
+- Сервис должен запускаться как отдельный контейнер.
+- Все зависимости описаны в requirements.txt.
+
+## Пример docker-compose.yml (фрагмент)
+```yaml
+services:
+ vector-search:
+ build: ./vector-search
+ ports:
+ - "8001:8001"
+ environment:
+ - OLLAMA_BASE_URL=http://ollama:11434
+ depends_on:
+ - ollama
+```
\ No newline at end of file
diff --git a/vector-search/.gitignore b/vector-search/.gitignore
new file mode 100644
index 0000000..9b7e7ab
--- /dev/null
+++ b/vector-search/.gitignore
@@ -0,0 +1,5 @@
+__pycache__/
+*.pyc
+*.pkl
+*.faiss
+.env
\ No newline at end of file
diff --git a/vector-search/Dockerfile b/vector-search/Dockerfile
new file mode 100644
index 0000000..1e88369
--- /dev/null
+++ b/vector-search/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:3.10-slim
+WORKDIR /app
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+COPY . .
+EXPOSE 8001
+CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8001"]
\ No newline at end of file
diff --git a/vector-search/README.md b/vector-search/README.md
new file mode 100644
index 0000000..269fa26
--- /dev/null
+++ b/vector-search/README.md
@@ -0,0 +1,25 @@
+# Vector Search Service
+
+## Запуск локально
+```
+pip install -r requirements.txt
+uvicorn app:app --reload --host 0.0.0.0 --port 8001
+```
+
+## Запуск в Docker
+```
+docker build -t vector-search .
+docker run -p 8001:8001 vector-search
+```
+
+## Эндпоинты
+- POST /upsert — добавить/обновить строки
+- POST /search — поиск похожих
+- POST /delete — удалить строки
+- POST /rebuild — пересоздать индекс
+- GET /health — проверка статуса
+
+## Пример запроса /upsert
+```
+curl -X POST http://localhost:8001/upsert -H "Content-Type: application/json" -d '{"table_id": "t1", "rows": [{"row_id": "1", "text": "Пример", "metadata": {}}]}'
+```
\ No newline at end of file
diff --git a/vector-search/app.py b/vector-search/app.py
new file mode 100644
index 0000000..8d52827
--- /dev/null
+++ b/vector-search/app.py
@@ -0,0 +1,128 @@
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel
+from typing import List, Dict, Any
+import os
+import requests
+from vector_store import VectorStore
+
+OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://ollama:11434')
+EMBED_MODEL = os.getenv('OLLAMA_EMBED_MODEL', 'mxbai-embed-large')
+
+app = FastAPI()
+store = VectorStore()
+
+class UpsertRow(BaseModel):
+ row_id: str
+ text: str
+ metadata: Dict[str, Any] = {}
+
+class UpsertRequest(BaseModel):
+ table_id: str
+ rows: List[UpsertRow]
+
+class SearchRequest(BaseModel):
+ table_id: str
+ query: str
+ top_k: int = 3
+
+class SearchResult(BaseModel):
+ row_id: str
+ score: float
+ metadata: Dict[str, Any] = {}
+
+class SearchResponse(BaseModel):
+ results: List[SearchResult]
+
+class DeleteRequest(BaseModel):
+ table_id: str
+ row_ids: List[str]
+
+class RebuildRequest(BaseModel):
+ table_id: str
+ rows: List[UpsertRow]
+
+# --- Ollama embedding ---
+def get_embedding(text: str) -> list:
+ print(f"[DEBUG] Getting embedding for text: '{text[:50]}...' (length: {len(text)})")
+ print(f"[DEBUG] Using OLLAMA_BASE_URL: {OLLAMA_BASE_URL}")
+ print(f"[DEBUG] Using EMBED_MODEL: {EMBED_MODEL}")
+
+ try:
+ resp = requests.post(f"{OLLAMA_BASE_URL}/api/embeddings", json={
+ "model": EMBED_MODEL,
+ "prompt": text
+ }, timeout=30)
+ print(f"[DEBUG] Ollama response status: {resp.status_code}")
+
+ if not resp.ok:
+ print(f"[ERROR] Ollama API error: {resp.status_code} - {resp.text}")
+ raise HTTPException(status_code=resp.status_code, detail=f"Ollama API error: {resp.text}")
+
+ data = resp.json()
+ print(f"[DEBUG] Ollama response keys: {list(data.keys())}")
+
+ if 'embedding' in data:
+ print(f"[DEBUG] Found embedding in data['embedding'], length: {len(data['embedding'])}")
+ return data['embedding']
+ if 'data' in data and isinstance(data['data'], list) and 'embedding' in data['data'][0]:
+ print(f"[DEBUG] Found embedding in data['data'][0]['embedding'], length: {len(data['data'][0]['embedding'])}")
+ return data['data'][0]['embedding']
+
+ print(f"[ERROR] No embedding found in response: {data}")
+ raise ValueError('No embedding in Ollama response')
+ except requests.exceptions.RequestException as e:
+ print(f"[ERROR] Request exception: {e}")
+ raise HTTPException(status_code=422, detail=f"Failed to connect to Ollama: {e}")
+ except Exception as e:
+ print(f"[ERROR] Unexpected error: {e}")
+ raise HTTPException(status_code=422, detail=f"Failed to get embedding: {e}")
+
+@app.post("/upsert")
+def upsert_rows(req: UpsertRequest):
+ print(f"[DEBUG] Upsert request: table_id={req.table_id}, rows_count={len(req.rows)}")
+ rows_to_upsert = []
+ for i, row in enumerate(req.rows):
+ print(f"[DEBUG] Processing row {i}: row_id={row.row_id}, text_length={len(row.text)}")
+ try:
+ emb = get_embedding(row.text)
+ print(f"[DEBUG] Got embedding for row {i}: length={len(emb)}")
+ rows_to_upsert.append({
+ 'row_id': row.row_id,
+ 'embedding': emb,
+ 'metadata': row.metadata
+ })
+ except Exception as e:
+ print(f"[ERROR] Failed to get embedding for row {i}: {e}")
+ raise HTTPException(status_code=422, detail=f"Failed to get embedding: {e}")
+
+ print(f"[DEBUG] Upserting {len(rows_to_upsert)} rows to store")
+ store.upsert(req.table_id, rows_to_upsert)
+ return {"success": True}
+
+@app.post("/search", response_model=SearchResponse)
+def search(req: SearchRequest):
+ emb = get_embedding(req.query)
+ results = store.search(req.table_id, emb, req.top_k)
+ return {"results": results}
+
+@app.post("/delete")
+def delete(req: DeleteRequest):
+ store.delete(req.table_id, req.row_ids)
+ return {"success": True}
+
+@app.post("/rebuild")
+def rebuild(req: RebuildRequest):
+ rows_to_upsert = []
+ for row in req.rows:
+ emb = get_embedding(row.text)
+ rows_to_upsert.append({
+ 'row_id': row.row_id,
+ 'embedding': emb,
+ 'metadata': row.metadata
+ })
+ store.rebuild(req.table_id, rows_to_upsert)
+ return {"success": True}
+
+@app.get("/health")
+def health():
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/vector-search/requirements.txt b/vector-search/requirements.txt
new file mode 100644
index 0000000..0478b8f
--- /dev/null
+++ b/vector-search/requirements.txt
@@ -0,0 +1,5 @@
+fastapi
+uvicorn
+faiss-cpu
+requests
+pydantic
\ No newline at end of file
diff --git a/vector-search/schemas.py b/vector-search/schemas.py
new file mode 100644
index 0000000..d70a263
--- /dev/null
+++ b/vector-search/schemas.py
@@ -0,0 +1 @@
+# Здесь можно разместить расширенные схемы Pydantic для API
\ No newline at end of file
diff --git a/vector-search/vector_store.py b/vector-search/vector_store.py
new file mode 100644
index 0000000..e9736eb
--- /dev/null
+++ b/vector-search/vector_store.py
@@ -0,0 +1,102 @@
+# Заглушка для работы с FAISS
+
+import os
+import pickle
+import faiss
+import numpy as np
+from typing import List, Dict, Any
+
+INDEX_DIR = os.path.join(os.path.dirname(__file__), 'indexes')
+
+class VectorStore:
+ def __init__(self):
+ os.makedirs(INDEX_DIR, exist_ok=True)
+ self.index_cache = {} # table_id: (faiss_index, meta)
+
+ def _index_path(self, table_id):
+ return os.path.join(INDEX_DIR, f'table_{table_id}.faiss')
+ def _meta_path(self, table_id):
+ return os.path.join(INDEX_DIR, f'table_{table_id}_meta.pkl')
+
+ def load(self, table_id):
+ idx_path = self._index_path(table_id)
+ meta_path = self._meta_path(table_id)
+ if os.path.exists(idx_path) and os.path.exists(meta_path):
+ index = faiss.read_index(idx_path)
+ with open(meta_path, 'rb') as f:
+ meta = pickle.load(f)
+ self.index_cache[table_id] = (index, meta)
+ return index, meta
+ return None, None
+
+ def save(self, table_id, index, meta):
+ faiss.write_index(index, self._index_path(table_id))
+ with open(self._meta_path(table_id), 'wb') as f:
+ pickle.dump(meta, f)
+ self.index_cache[table_id] = (index, meta)
+
+ def upsert(self, table_id, rows: List[Dict]):
+ # rows: [{row_id, embedding, metadata}]
+ index, meta = self.load(table_id)
+ if index is None:
+ dim = len(rows[0]['embedding'])
+ index = faiss.IndexFlatL2(dim)
+ meta = []
+ # Удаляем дубликаты row_id
+ existing_ids = {m['row_id'] for m in meta}
+ new_rows = [r for r in rows if r['row_id'] not in existing_ids]
+ if not new_rows:
+ return
+ vectors = np.array([r['embedding'] for r in new_rows]).astype('float32')
+ index.add(vectors)
+ meta.extend(new_rows)
+ self.save(table_id, index, meta)
+
+ def search(self, table_id, query_embedding, top_k=3):
+ index, meta = self.load(table_id)
+ if index is None or not meta:
+ return []
+ query = np.array([query_embedding]).astype('float32')
+ D, I = index.search(query, top_k)
+ results = []
+ for idx, dist in zip(I[0], D[0]):
+ if idx < 0 or idx >= len(meta):
+ continue
+ m = meta[idx]
+ results.append({
+ 'row_id': m['row_id'],
+ 'score': float(-dist), # FAISS: чем меньше dist, тем ближе
+ 'metadata': m['metadata']
+ })
+ return results
+
+ def delete(self, table_id, row_ids: List[str]):
+ index, meta = self.load(table_id)
+ if index is None or not meta:
+ return
+ # FAISS не поддерживает удаление, пересоздаём индекс
+ new_meta = [m for m in meta if m['row_id'] not in row_ids]
+ if not new_meta:
+ # Удаляем файлы
+ try:
+ os.remove(self._index_path(table_id))
+ os.remove(self._meta_path(table_id))
+ except Exception:
+ pass
+ self.index_cache.pop(table_id, None)
+ return
+ dim = len(new_meta[0]['embedding'])
+ new_index = faiss.IndexFlatL2(dim)
+ vectors = np.array([m['embedding'] for m in new_meta]).astype('float32')
+ new_index.add(vectors)
+ self.save(table_id, new_index, new_meta)
+
+ def rebuild(self, table_id, rows: List[Dict]):
+ # rows: [{row_id, embedding, metadata}]
+ if not rows:
+ return
+ dim = len(rows[0]['embedding'])
+ index = faiss.IndexFlatL2(dim)
+ vectors = np.array([r['embedding'] for r in rows]).astype('float32')
+ index.add(vectors)
+ self.save(table_id, index, rows)
\ No newline at end of file