diff --git a/.dockerignore b/.dockerignore index c0cc70f..4efb037 100644 --- a/.dockerignore +++ b/.dockerignore @@ -31,6 +31,7 @@ uploads/ processed/ tmp/ output/ +models/ video/ *.mp4 *.wav diff --git a/Dockerfile b/Dockerfile index 5d257cd..119d28a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app # Копируем зависимости -COPY requirements.txt . -RUN pip install --no-cache-dir --timeout 300 -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt +COPY requirements.txt pip.conf ./ +RUN pip install --no-cache-dir --timeout 300 -r requirements.txt # Копируем код проекта COPY . . diff --git a/Dockerfile.rag b/Dockerfile.rag new file mode 100644 index 0000000..afca770 --- /dev/null +++ b/Dockerfile.rag @@ -0,0 +1,9 @@ +# Быстрое обновление: добавляет RAG-зависимости к уже собранному образу с Whisper/PyTorch. +FROM transcription-transcription:latest + +COPY pip.conf /etc/pip.conf +RUN pip install --no-cache-dir --timeout 300 \ + lightrag-hku>=1.4.0 \ + openai>=1.0.0 \ + python-dotenv>=1.0.0 \ + sentence-transformers>=3.0.0 diff --git a/backend/main.py b/backend/main.py index 40c4b50..bd30eb7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,9 +1,11 @@ """FastAPI backend для сервиса транскрибации.""" import json +import os +import sys from contextlib import asynccontextmanager from pathlib import Path -from typing import List, Optional +from typing import Any, Dict, List, Optional from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware @@ -13,8 +15,9 @@ from fastapi.staticfiles import StaticFiles from backend.queue import ( UPLOAD_DIR, PROCESSED_DIR, - enqueue, + save_upload, get_all_tasks, + get_queue_info, get_task_status, get_processed_tree, read_file_content, @@ -23,6 +26,13 @@ from backend.queue import ( stop_workers, ) +# Добавляем корень проекта в путь, чтобы импортировать src.rag +sys.path.insert(0, str(Path(__file__).parent.parent)) +from src.config import load_config, resolve_opencode_credentials +from src.rag.indexer import get_project_names +from src.rag.parser import parse_project_from_filename +from src.rag.query import rag_chat, retrieve_context + # WebSocket менеджер class ConnectionManager: def __init__(self): @@ -52,8 +62,15 @@ set_progress_callback(manager.broadcast) @asynccontextmanager async def lifespan(app: FastAPI): """Управление жизненным циклом приложения.""" + config = load_config() + queue_cfg = config.get("queue", {}) + transcribe_workers = int(queue_cfg.get("transcribe_workers", 2)) + postprocess_workers = int(queue_cfg.get("postprocess_workers", 1)) print("🚀 Запуск рабочих процессов...") - start_workers(num_workers=1) + start_workers( + transcribe_workers=transcribe_workers, + postprocess_workers=postprocess_workers, + ) yield print("🛑 Остановка рабочих процессов...") stop_workers() @@ -89,43 +106,39 @@ async def root(): @app.post("/upload") async def upload_file(file: UploadFile = File(...)): """Загружает файл и добавляет в очередь обработки.""" - # Сохраняем файл - file_path = UPLOAD_DIR / file.filename - with open(file_path, "wb") as f: - content = await file.read() - f.write(content) - - # Добавляем в очередь - task_id = await enqueue(file_path) - + content = await file.read() + task_id, _ = await save_upload(content, file.filename or "upload.bin") + return { "task_id": task_id, "file": file.filename, "status": "queued", "message": "Файл добавлен в очередь обработки", + "queue": get_queue_info(), } @app.post("/upload-batch") async def upload_batch(files: List[UploadFile] = File(...)): - """Загружает несколько файлов пакетно.""" + """Загружает несколько файлов пакетно — все ставятся в очередь.""" + if not files: + return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []} + results = [] for file in files: - file_path = UPLOAD_DIR / file.filename - with open(file_path, "wb") as f: - content = await file.read() - f.write(content) - - task_id = await enqueue(file_path) + content = await file.read() + task_id, _ = await save_upload(content, file.filename or "upload.bin") results.append({ "task_id": task_id, "file": file.filename, "status": "queued", }) - + return { "uploaded": len(results), "tasks": results, + "queue": get_queue_info(), + "message": f"{len(results)} файл(ов) добавлено в очередь", } @@ -144,6 +157,7 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.send_json({ "type": "tasks_list", "tasks": tasks, + "queue": get_queue_info(), }) elif msg.get("action") == "get_tree": tree = get_processed_tree() @@ -151,6 +165,10 @@ async def websocket_endpoint(websocket: WebSocket): "type": "file_tree", "tree": tree, }) + elif msg.get("action") == "rag_query": + await _handle_rag_query(websocket, msg) + elif msg.get("action") == "rag_query_global": + await _handle_rag_query(websocket, msg) except WebSocketDisconnect: manager.disconnect(websocket) except Exception: @@ -160,7 +178,7 @@ async def websocket_endpoint(websocket: WebSocket): @app.get("/api/tasks") async def api_tasks(): """Возвращает список всех задач.""" - return {"tasks": get_all_tasks()} + return {"tasks": get_all_tasks(), "queue": get_queue_info()} @app.get("/api/tasks/{task_id}") @@ -212,5 +230,203 @@ async def api_delete_folder(folder_name: str): return {"error": str(e)} +# === RAG / Chat API === + +@app.get("/api/rag/projects") +async def api_rag_projects(): + """Возвращает список проектов с RAG-индексами.""" + try: + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + projects = await get_project_names(index_dir) + return {"projects": projects} + except Exception as e: + return {"error": str(e), "projects": []} + + +@app.post("/api/rag/query") +async def api_rag_query(payload: dict): + """Запрос к чат-боту по конкретному проекту.""" + try: + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + api_key, base_url = resolve_opencode_credentials(config) + chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + mode = payload.get("mode", "hybrid") + + result = await rag_chat( + question=payload.get("question", ""), + working_dir_base=index_dir, + history=payload.get("history", []), + api_key=api_key, + project_name=payload.get("project"), + base_url=base_url, + chat_model=chat_model, + mode=mode, + index_model=index_model, + ) + return { + "answer": result["answer"], + "context": result["context"], + "project": result["project"], + } + except Exception as e: + return {"error": str(e)} + + +@app.post("/api/rag/query-global") +async def api_rag_query_global(payload: dict): + """Глобальный запрос ко всем проектам.""" + try: + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + api_key, base_url = resolve_opencode_credentials(config) + chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + mode = payload.get("mode", "hybrid") + + result = await rag_chat( + question=payload.get("question", ""), + working_dir_base=index_dir, + history=payload.get("history", []), + api_key=api_key, + project_name=None, + base_url=base_url, + chat_model=chat_model, + mode=mode, + index_model=index_model, + ) + return { + "answer": result["answer"], + "context": result["context"], + "project": None, + } + except Exception as e: + return {"error": str(e)} + + +@app.get("/api/rag/tasks") +async def api_rag_tasks(project: Optional[str] = None): + """Возвращает action items из RAG.""" + try: + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + api_key, base_url = resolve_opencode_credentials(config) + chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + + question = "Перечисли все action items, задачи и ответственных из протоколов." + if project: + question = f"Перечисли все action items, задачи и ответственных по проекту {project}." + + result = await rag_chat( + question=question, + working_dir_base=index_dir, + history=[], + api_key=api_key, + project_name=project, + base_url=base_url, + chat_model=chat_model, + mode="hybrid", + index_model=index_model, + ) + return { + "tasks": result["answer"], + "project": project, + } + except Exception as e: + return {"error": str(e)} + + +@app.post("/api/rag/index/{folder_name}") +async def api_rag_index_folder(folder_name: str): + """Принудительная переиндексация папки с обработанным совещанием.""" + try: + folder_path = PROCESSED_DIR / folder_name + if not folder_path.exists(): + return {"error": "Folder not found"} + + # Ищем .txt файл с протоколом + txt_files = list(folder_path.glob("*.txt")) + if not txt_files: + return {"error": "No .txt protocol found in folder"} + + txt_path = txt_files[0] + doc_text = txt_path.read_text(encoding="utf-8") + + # Определяем проект из имени папки + project = parse_project_from_filename(folder_name) + + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + api_key, base_url = resolve_opencode_credentials(config) + + from src.rag.indexer import index_meeting + from src.rag.formatter import format_global_document + + # Для переиндексации используем простую заглушку метаданных + metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"} + global_doc_text = format_global_document(doc_text, metadata) + + await index_meeting( + doc_text=doc_text, + global_doc_text=global_doc_text, + project_name=project, + working_dir_base=index_dir, + model=index_model, + api_key=api_key, + base_url=base_url, + ) + + return {"indexed": folder_name, "project": project} + except Exception as e: + return {"error": str(e)} + + +# === WebSocket Chat Actions === + +async def _handle_rag_query(websocket: WebSocket, msg: dict): + """Обрабатывает rag_query через WebSocket.""" + try: + config = load_config() + rag_cfg = config.get("rag", {}) + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + api_key, base_url = resolve_opencode_credentials(config) + chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + mode = msg.get("mode", "hybrid") + + result = await rag_chat( + question=msg.get("question", ""), + working_dir_base=index_dir, + history=msg.get("history", []), + api_key=api_key, + project_name=msg.get("project"), + base_url=base_url, + chat_model=chat_model, + mode=mode, + index_model=index_model, + ) + + await websocket.send_json({ + "type": "rag_response", + "answer": result["answer"], + "context": result["context"], + "project": result["project"], + }) + except Exception as e: + await websocket.send_json({ + "type": "rag_error", + "error": str(e), + }) + + # Статические файлы app.mount("/static", StaticFiles(directory="backend/static"), name="static") diff --git a/backend/queue.py b/backend/queue.py index b742f53..3817890 100644 --- a/backend/queue.py +++ b/backend/queue.py @@ -1,9 +1,10 @@ -"""Фоновая очередь обработки аудио/видео.""" +"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены.""" import asyncio import json -import os +import shutil import sys +import uuid from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional @@ -11,9 +12,17 @@ from typing import Any, Callable, Dict, List, Optional sys.path.insert(0, str(Path(__file__).parent.parent)) from src.audio_utils import prepare_audio_input -from src.config import get_profile, load_config, resolve_hf_token +from src.config import load_config, resolve_opencode_credentials from src.document import build_document from src.pipeline import run_pipeline +from src.rag.formatter import ( + build_meeting_text_only, + format_global_document, + format_meeting_document, + format_summary_markdown, +) +from src.rag.indexer import index_meeting +from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename UPLOAD_DIR = Path("uploads") @@ -21,21 +30,19 @@ PROCESSED_DIR = Path("processed") UPLOAD_DIR.mkdir(exist_ok=True) PROCESSED_DIR.mkdir(exist_ok=True) -# Глобальное хранилище состояний задач tasks: Dict[str, Dict[str, Any]] = {} - -# Callback для отправки прогресса через WebSocket _progress_callback: Optional[Callable] = None +_transcribe_queue: asyncio.Queue = asyncio.Queue() +_postprocess_queue: asyncio.Queue = asyncio.Queue() +_workers: List[asyncio.Task] = [] def set_progress_callback(callback: Callable): - """Устанавливает callback для отправки прогресса.""" global _progress_callback _progress_callback = callback async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None): - """Отправляет прогресс через callback.""" if _progress_callback: try: task_info = tasks.get(task_id, {}) @@ -45,6 +52,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str, "message": message, "status": status, "file": task_info.get("file", ""), + "queue_position": task_info.get("queue_position"), "result": result, "error": error, }) @@ -52,77 +60,105 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str, pass -async def process_file(file_path: Path, task_id: str): - """Обрабатывает один файл и отправляет прогресс.""" - tasks[task_id] = { - "task_id": task_id, +def _cleanup_upload(file_path: Path): + if not file_path.exists(): + return + parent = file_path.parent + if parent != UPLOAD_DIR and parent.name.startswith("task_"): + shutil.rmtree(parent, ignore_errors=True) + else: + file_path.unlink(missing_ok=True) + + +def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str, Any]: + return { + "project": project, + "section": "Общие вопросы", + "topic": "Не определена", + "date": None, + "participants": sorted({seg.get("speaker", "UNKNOWN") for seg in segments}), + "problems": [], + "summary": "", + "key_decisions": [], + "action_items": [], + } + + +async def process_transcription(file_path: Path, task_id: str): + """Этап 1: WhisperX + docx/md. После — в очередь summary/RAG.""" + display_name = tasks.get(task_id, {}).get("file", file_path.name) + tasks[task_id].update({ "status": "processing", "progress": 0, - "message": "Начало обработки...", - "file": str(file_path.name), - "result": None, - "error": None, - "started": datetime.now().isoformat(), - } - + "message": "Транскрибация...", + "queue_position": None, + }) await _send_progress(task_id, 5, "Извлечение аудио...", "processing") - + try: - # Загружаем конфиг config = load_config() - profile = get_profile(config) - - await _send_progress(task_id, 15, "Загрузка моделей ИИ...", "processing") - - # Подготовка аудио (в отдельном потоке, чтобы не блокировать event loop) - audio_path = await asyncio.to_thread(prepare_audio_input, str(file_path)) - - await _send_progress(task_id, 25, "Транскрибация (распознавание речи)...", "processing") - - # Запуск пайплайна (в отдельном потоке) + await _send_progress(task_id, 15, "Загрузка моделей Whisper...", "processing") + await asyncio.to_thread(prepare_audio_input, str(file_path)) + await _send_progress(task_id, 25, "Распознавание речи...", "processing") + result = await asyncio.to_thread( run_pipeline, input_path=str(file_path), profile_name=None, config_path=None, ) - - await _send_progress(task_id, 75, "Генерация документов...", "processing") - - # Определяем имена выходных файлов (уникальная папка с timestamp) - stem = file_path.stem + + await _send_progress(task_id, 65, "Сохранение протокола...", "processing") + + stem = Path(display_name).stem timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") folder_name = f"{stem}_{timestamp}" output_dir = PROCESSED_DIR / folder_name await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) - - # Сохраняем docx и md (в отдельном потоке) - docx_path = str(output_dir / f"{stem}.docx") - md_path = str(output_dir / f"{stem}.md") - - await asyncio.to_thread(build_document, result["segments"], docx_path, config) - await asyncio.to_thread(build_document, result["segments"], md_path, config) - - # Удаляем исходник из uploads после обработки - if file_path.exists(): - await asyncio.to_thread(file_path.unlink) - - result_data = { - "docx": str(docx_path), - "md": str(md_path), - "dir": str(output_dir), - } - - await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data) - + + docx_path = output_dir / f"{stem}.docx" + md_path = output_dir / f"{stem}.md" + segments_path = output_dir / f"{stem}_segments.json" + + await asyncio.to_thread(build_document, result["segments"], str(docx_path), config) + await asyncio.to_thread(build_document, result["segments"], str(md_path), config) + await asyncio.to_thread( + segments_path.write_text, + json.dumps(result["segments"], ensure_ascii=False), + encoding="utf-8", + ) + + await asyncio.to_thread(_cleanup_upload, file_path) + + post_position = _postprocess_queue.qsize() + 1 tasks[task_id].update({ - "status": "completed", - "progress": 100, - "message": "Обработка завершена", - "result": result_data, - "finished": datetime.now().isoformat(), + "status": "postprocessing", + "progress": 70, + "message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", + "result": { + "docx": str(docx_path), + "md": str(md_path), + "dir": str(output_dir), + }, }) - + await _send_progress( + task_id, 70, + f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", + "postprocessing", + result=tasks[task_id]["result"], + ) + + await _postprocess_queue.put({ + "task_id": task_id, + "display_name": display_name, + "output_dir": str(output_dir), + "stem": stem, + "segments_path": str(segments_path), + "docx_path": str(docx_path), + "md_path": str(md_path), + }) + print(f"[Transcribe] Задача {task_id} передана в post-processing") + except Exception as e: error_msg = str(e) await _send_progress(task_id, 0, f"Ошибка: {error_msg}", "error", error=error_msg) @@ -131,103 +167,264 @@ async def process_file(file_path: Path, task_id: str): "progress": 0, "message": f"Ошибка: {error_msg}", "error": error_msg, + "queue_position": None, }) -# Очередь задач -_queue: asyncio.Queue = asyncio.Queue() -_workers: List[asyncio.Task] = [] +async def process_postprocessing(job: Dict[str, Any]): + """Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию.""" + task_id = job["task_id"] + display_name = job["display_name"] + stem = job["stem"] + output_dir = Path(job["output_dir"]) + + summary_path = output_dir / f"{stem}_summary.md" + txt_path = output_dir / f"{stem}.txt" + + tasks[task_id].update({ + "status": "postprocessing", + "message": "Summary и индексация...", + }) + await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing") + + result_data = dict(tasks[task_id].get("result") or {}) + + try: + segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8")) + config = load_config() + rag_cfg = config.get("rag", {}) + api_key, base_url = resolve_opencode_credentials(config) + project = parse_project_from_filename(display_name) + meeting_text = build_meeting_text_only(segments) + metadata = _default_metadata(project, segments) + + if api_key: + try: + sections = rag_cfg.get("sections", ["Общие вопросы"]) + index_model = rag_cfg.get("index_model", "mimo-v2.5-free") + summary_model = rag_cfg.get("summary_model", "deepseek-v4-flash-free") + summary_auto = rag_cfg.get("summary_auto", True) + classify_chunk_size = int(rag_cfg.get("classify_chunk_size", 7000)) + summary_chunk_size = int(rag_cfg.get("summary_chunk_size", 10000)) + + await _send_progress(task_id, 80, "Анализ совещания...", "postprocessing") + metadata = await classify_meeting( + text=meeting_text, + project=project, + sections=sections, + api_key=api_key, + base_url=base_url, + model=index_model, + chunk_size=classify_chunk_size, + ) + + if summary_auto: + await _send_progress(task_id, 85, "Формирование краткого содержания...", "postprocessing") + brief = await generate_meeting_brief( + text=meeting_text, + metadata=metadata, + api_key=api_key, + base_url=base_url, + model=summary_model, + chunk_size=summary_chunk_size, + ) + summary_md = format_summary_markdown(metadata, brief, display_name) + await asyncio.to_thread( + summary_path.write_text, + summary_md, + encoding="utf-8", + ) + result_data["summary"] = str(summary_path) + + doc_text = format_meeting_document(segments, metadata, display_name) + await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8") + result_data["txt"] = str(txt_path) + result_data["metadata"] = metadata + result_data["project"] = project + + if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True): + await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing") + index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) + global_doc_text = format_global_document(doc_text, metadata) + await index_meeting( + doc_text=doc_text, + global_doc_text=global_doc_text, + project_name=project, + working_dir_base=index_dir, + model=index_model, + api_key=api_key, + base_url=base_url, + ) + except Exception as e: + print(f"[Postprocess Warning] {task_id}: {e}") + if not result_data.get("txt"): + doc_text = format_meeting_document(segments, metadata, display_name) + await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8") + result_data["txt"] = str(txt_path) + else: + doc_text = format_meeting_document(segments, metadata, display_name) + await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8") + result_data["txt"] = str(txt_path) + + await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data) + tasks[task_id].update({ + "status": "completed", + "progress": 100, + "message": "Обработка завершена", + "result": result_data, + "finished": datetime.now().isoformat(), + "queue_position": None, + }) + + except Exception as e: + error_msg = str(e) + await _send_progress(task_id, 70, f"Ошибка summary/RAG: {error_msg}", "error", error=error_msg) + tasks[task_id].update({ + "status": "error", + "message": f"Ошибка summary/RAG: {error_msg}", + "error": error_msg, + }) -async def _worker_loop(): - """Рабочий цикл обработки.""" - print("[Worker] Рабочий процесс запущен и ждёт задачи...") +async def _transcribe_worker_loop(worker_id: int): + print(f"[Transcribe Worker {worker_id}] запущен") while True: try: - task_id, file_path = await _queue.get() - print(f"[Worker] Получена задача: {task_id}") - await process_file(file_path, task_id) - _queue.task_done() - print(f"[Worker] Задача завершена: {task_id}") + task_id, file_path = await _transcribe_queue.get() + print(f"[Transcribe Worker {worker_id}] задача {task_id}") + await process_transcription(file_path, task_id) + _transcribe_queue.task_done() except asyncio.CancelledError: - print("[Worker] Остановка рабочего процесса") break except Exception as e: - print(f"[Worker Error] {e}") + print(f"[Transcribe Worker {worker_id} Error] {e}") -def start_workers(num_workers: int = 1): - """Запускает рабочих в текущем event loop.""" +async def _postprocess_worker_loop(worker_id: int): + print(f"[Postprocess Worker {worker_id}] запущен") + while True: + try: + job = await _postprocess_queue.get() + print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}") + await process_postprocessing(job) + _postprocess_queue.task_done() + except asyncio.CancelledError: + break + except Exception as e: + print(f"[Postprocess Worker {worker_id} Error] {e}") + + +def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1): + """Запускает пулы воркеров транскрибации и post-processing.""" global _workers - for i in range(num_workers): - task = asyncio.create_task(_worker_loop()) - _workers.append(task) + _workers.clear() + for i in range(transcribe_workers): + _workers.append(asyncio.create_task(_transcribe_worker_loop(i + 1))) + for i in range(postprocess_workers): + _workers.append(asyncio.create_task(_postprocess_worker_loop(i + 1))) + print(f"[Queue] transcribe_workers={transcribe_workers}, postprocess_workers={postprocess_workers}") def stop_workers(): - """Останавливает рабочих.""" for w in _workers: w.cancel() -async def enqueue(file_path: Path) -> str: - """Добавляет файл в очередь.""" - task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file_path.stem}" +async def save_upload(content: bytes, filename: str) -> tuple[str, Path]: + safe_name = Path(filename).name or "upload.bin" + task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" + task_dir = UPLOAD_DIR / task_id + task_dir.mkdir(parents=True, exist_ok=True) + file_path = task_dir / safe_name + + await asyncio.to_thread(file_path.write_bytes, content) + + queue_position = _transcribe_queue.qsize() + 1 tasks[task_id] = { "task_id": task_id, "status": "queued", "progress": 0, - "message": "В очереди...", - "file": str(file_path.name), + "message": f"В очереди транскрибации (№{queue_position})", + "file": safe_name, + "queue_position": queue_position, "result": None, "error": None, "started": datetime.now().isoformat(), } - await _queue.put((task_id, file_path)) - return task_id + + await _transcribe_queue.put((task_id, file_path)) + await _send_progress(task_id, 0, f"В очереди транскрибации (№{queue_position})", "queued") + return task_id, file_path + + +def get_queue_info() -> Dict[str, Any]: + queued = sum(1 for t in tasks.values() if t.get("status") == "queued") + processing = sum(1 for t in tasks.values() if t.get("status") == "processing") + postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing") + return { + "queued": queued, + "processing": processing, + "postprocessing": postprocessing, + "pending_transcribe": _transcribe_queue.qsize(), + "pending_postprocess": _postprocess_queue.qsize(), + "pending_in_queue": _transcribe_queue.qsize(), + } def get_task_status(task_id: str) -> Optional[Dict[str, Any]]: - """Возвращает статус задачи.""" return tasks.get(task_id) def get_all_tasks() -> List[Dict[str, Any]]: - """Возвращает все задачи.""" return list(tasks.values()) +def _file_sort_key(file_info: Dict[str, Any]) -> tuple: + name = file_info.get("name", "").lower() + if "_summary." in name: + return (0, name) + if name.endswith(".md") and "_summary" not in name: + return (1, name) + if name.endswith(".txt"): + return (2, name) + if name.endswith(".docx"): + return (3, name) + return (4, name) + + def get_processed_tree() -> List[Dict[str, Any]]: - """Возвращает дерево обработанных файлов.""" tree = [] if not PROCESSED_DIR.exists(): return tree - + + skip_names = {"lightrag_caches", "lightrag_caches_test"} + skip_suffixes = ("_segments.json",) + for item in sorted(PROCESSED_DIR.iterdir()): - if item.is_dir(): - files = [] - for f in sorted(item.iterdir()): - if f.is_file(): - files.append({ - "name": f.name, - "path": str(f.relative_to(PROCESSED_DIR)), - "size": f.stat().st_size, - "ext": f.suffix.lower(), - }) - tree.append({ - "name": item.name, - "path": str(item.relative_to(PROCESSED_DIR)), - "files": files, - "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(), - }) + if not item.is_dir() or item.name in skip_names: + continue + files = [] + for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})): + if f.is_file() and not f.name.endswith(skip_suffixes): + files.append({ + "name": f.name, + "path": str(f.relative_to(PROCESSED_DIR)), + "size": f.stat().st_size, + "ext": f.suffix.lower(), + "kind": "summary" if "_summary" in f.name else "protocol", + }) + tree.append({ + "name": item.name, + "path": str(item.relative_to(PROCESSED_DIR)), + "files": files, + "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(), + }) return tree def read_file_content(rel_path: str) -> str: - """Читает содержимое файла.""" full_path = PROCESSED_DIR / rel_path if not full_path.exists() or not full_path.is_file(): raise FileNotFoundError(f"Файл не найден: {rel_path}") - with open(full_path, "r", encoding="utf-8") as f: return f.read() diff --git a/backend/static/app.js b/backend/static/app.js index fb1e977..f62085a 100644 --- a/backend/static/app.js +++ b/backend/static/app.js @@ -8,12 +8,17 @@ class TranscriptionApp { this.tasks = new Map(); this.currentFile = null; this.renderTimeout = null; + this.chatHistory = []; + this.chatProjects = []; + this.isChatThinking = false; this.init(); } init() { this.connectWebSocket(); this.setupUpload(); + this.setupChat(); + this.loadChatHistory(); } // ===== WebSocket ===== @@ -60,8 +65,13 @@ class TranscriptionApp { handleWebSocketMessage(data) { if (data.type === 'tasks_list') { this.updateTasks(data.tasks); + this.updateQueueSummary(data.queue); } else if (data.type === 'file_tree') { this.renderFileTree(data.tree); + } else if (data.type === 'rag_response') { + this.handleChatResponse(data); + } else if (data.type === 'rag_error') { + this.handleChatError(data.error); } else if (data.task_id) { // Прогресс обработки this.updateTaskProgress(data); @@ -134,11 +144,21 @@ class TranscriptionApp { if (result.error) { this.showToast(`Ошибка: ${result.error}`, 'error'); } else { - this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success'); + const queueInfo = result.queue || {}; + const queued = queueInfo.pending_in_queue ?? result.uploaded; + this.showToast( + `${result.uploaded} файл(ов) в очереди. В ожидании: ${queued}`, + 'success' + ); result.tasks.forEach(task => { - this.tasks.set(task.task_id, task); + this.tasks.set(task.task_id, { + ...task, + message: task.message || 'В очереди...', + progress: 0, + }); }); this.renderTasks(); + this.updateQueueSummary(result.queue); } } catch (error) { this.showToast(`Ошибка загрузки: ${error.message}`, 'error'); @@ -153,6 +173,17 @@ class TranscriptionApp { this.renderTasks(); } + updateQueueSummary(queue) { + const el = document.getElementById('queueSummary'); + if (!el || !queue) return; + const parts = []; + if (queue.processing) parts.push(`транскрибация: ${queue.processing}`); + if (queue.pending_transcribe) parts.push(`в очереди ASR: ${queue.pending_transcribe}`); + if (queue.postprocessing) parts.push(`summary/RAG: ${queue.postprocessing}`); + if (queue.pending_postprocess) parts.push(`в очереди post: ${queue.pending_postprocess}`); + el.textContent = parts.length ? parts.join(' · ') : 'очередь пуста'; + } + updateTaskProgress(data) { const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id); if (existing) { @@ -163,7 +194,9 @@ class TranscriptionApp { this.renderTasks(); if (data.status === 'completed') { - this.showToast(`Готово: ${data.message}`, 'success'); + this.showToast(`Готово: ${data.file || data.message}`, 'success'); + this.requestTree(); + } else if (data.status === 'postprocessing' && data.progress === 70) { this.requestTree(); } else if (data.status === 'error') { this.showToast(`Ошибка: ${data.message}`, 'error'); @@ -174,7 +207,10 @@ class TranscriptionApp { if (this.renderTimeout) clearTimeout(this.renderTimeout); this.renderTimeout = setTimeout(() => { const container = document.getElementById('tasksList'); - const tasks = Array.from(this.tasks.values()); + const tasks = Array.from(this.tasks.values()).sort((a, b) => { + const order = { processing: 0, postprocessing: 1, queued: 2, completed: 3, error: 4 }; + return (order[a.status] ?? 9) - (order[b.status] ?? 9); + }); if (tasks.length === 0) { container.innerHTML = '

Нет активных задач

'; @@ -189,7 +225,8 @@ class TranscriptionApp { const progress = task.progress || 0; const statusClass = task.status === 'completed' ? 'success' : task.status === 'error' ? 'error' : - task.status === 'processing' ? 'processing' : 'queued'; + task.status === 'processing' ? 'processing' : + task.status === 'postprocessing' ? 'postprocessing' : 'queued'; return `
@@ -208,7 +245,8 @@ class TranscriptionApp { getStatusLabel(status) { const labels = { 'queued': 'В очереди', - 'processing': 'Обработка', + 'processing': 'Транскрибация', + 'postprocessing': 'Summary/RAG', 'completed': 'Готово', 'error': 'Ошибка', }; @@ -230,10 +268,13 @@ class TranscriptionApp { renderFolder(folder) { const files = folder.files.map(file => { + const isSummary = file.kind === 'summary' || file.name.includes('_summary'); const isMd = file.ext === '.md'; const isDocx = file.ext === '.docx'; - const icon = isMd ? '📝' : isDocx ? '📄' : '📎'; + const isTxt = file.ext === '.txt'; + const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎'; const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`; + const cssClass = isSummary ? 'file-item file-summary' : 'file-item'; if (isDocx) { return ` @@ -246,7 +287,7 @@ class TranscriptionApp { } return ` -
+
${icon} ${this.escapeHtml(file.name)} ${this.formatBytes(file.size)} @@ -347,7 +388,7 @@ class TranscriptionApp { async loadFileContent(path, ext) { const viewer = document.getElementById('viewer'); - if (ext === '.md') { + if (ext === '.md' || ext === '.txt') { try { const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); const result = await response.json(); @@ -357,8 +398,10 @@ class TranscriptionApp { return; } - // Render markdown - const html = marked.parse(result.content); + const bodyHtml = ext === '.md' + ? marked.parse(result.content) + : `
${this.escapeHtml(result.content)}
`; + viewer.innerHTML = `
@@ -366,7 +409,7 @@ class TranscriptionApp { ⬇️ Скачать
-
${html}
+
${bodyHtml}
`; this.currentFile = path; @@ -420,6 +463,161 @@ class TranscriptionApp { minute: '2-digit', }); } + + // ===== Chat ===== + setupChat() { + const input = document.getElementById('chatInput'); + const sendBtn = document.getElementById('chatSendBtn'); + const clearBtn = document.getElementById('chatClearBtn'); + + sendBtn.addEventListener('click', () => this.sendChatMessage()); + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.sendChatMessage(); + }); + clearBtn.addEventListener('click', () => this.clearChatHistory()); + + this.loadChatProjects(); + } + + async loadChatProjects() { + try { + const response = await fetch('/api/rag/projects'); + const result = await response.json(); + const select = document.getElementById('chatProjectSelect'); + select.innerHTML = ''; + if (result.projects) { + result.projects.forEach(p => { + const opt = document.createElement('option'); + opt.value = p; + opt.textContent = p; + select.appendChild(opt); + }); + } + } catch (e) { + console.error('Failed to load projects:', e); + } + } + + sendChatMessage() { + if (this.isChatThinking) return; + + const input = document.getElementById('chatInput'); + const question = input.value.trim(); + if (!question) return; + + const select = document.getElementById('chatProjectSelect'); + const project = select.value; + + input.value = ''; + this.addChatBubble('user', question); + this.setChatThinking(true); + + const action = project ? 'rag_query' : 'rag_query_global'; + this.sendWS({ + action: action, + question: question, + project: project || undefined, + history: this.chatHistory.slice(-6), // последние 6 пар + mode: 'hybrid', + }); + } + + handleChatResponse(data) { + this.setChatThinking(false); + const answer = data.answer || 'Нет ответа'; + const project = data.project ? `Проект: ${data.project}` : 'Все проекты'; + const sources = ''; // можно добавить data.context, но он может быть слишком длинным + const html = `
${this.escapeHtml(answer)}
`; + this.addChatBubble('bot', html, { isHtml: true, meta: project }); + + // Сохраняем в историю + const lastUserMsg = this.getLastUserMessage(); + if (lastUserMsg) { + this.chatHistory.push({ question: lastUserMsg, answer: answer }); + this.saveChatHistory(); + } + } + + handleChatError(error) { + this.setChatThinking(false); + this.addChatBubble('bot', `Ошибка: ${this.escapeHtml(error || 'Неизвестная ошибка')}`); + } + + getLastUserMessage() { + const container = document.getElementById('chatMessages'); + const bubbles = container.querySelectorAll('.chat-bubble.user'); + if (bubbles.length) { + return bubbles[bubbles.length - 1].textContent.trim(); + } + return null; + } + + setChatThinking(thinking) { + this.isChatThinking = thinking; + const container = document.getElementById('chatMessages'); + const existing = container.querySelector('.chat-thinking'); + if (thinking) { + if (!existing) { + const div = document.createElement('div'); + div.className = 'chat-thinking'; + div.textContent = 'Думаю...'; + container.appendChild(div); + container.scrollTop = container.scrollHeight; + } + } else { + if (existing) existing.remove(); + } + } + + addChatBubble(role, content, options = {}) { + const container = document.getElementById('chatMessages'); + const bubble = document.createElement('div'); + bubble.className = `chat-bubble ${role}`; + if (options.isHtml) { + bubble.innerHTML = content; + } else { + bubble.textContent = content; + } + if (options.meta) { + const meta = document.createElement('div'); + meta.className = 'bubble-sources'; + meta.textContent = options.meta; + bubble.appendChild(meta); + } + container.appendChild(bubble); + container.scrollTop = container.scrollHeight; + } + + clearChatHistory() { + this.chatHistory = []; + localStorage.removeItem('transcriba_chat_history'); + const container = document.getElementById('chatMessages'); + container.innerHTML = ` +
+

Здравствуйте! Я помогу найти информацию в протоколах совещаний.

+

Выберите проект или оставьте «Все проекты» для глобального поиска.

+
+ `; + } + + saveChatHistory() { + try { + localStorage.setItem('transcriba_chat_history', JSON.stringify(this.chatHistory)); + } catch (e) { + // ignore + } + } + + loadChatHistory() { + try { + const raw = localStorage.getItem('transcriba_chat_history'); + if (raw) { + this.chatHistory = JSON.parse(raw); + } + } catch (e) { + this.chatHistory = []; + } + } } // Initialize diff --git a/backend/static/index.html b/backend/static/index.html index 85d1cdc..8bfdf1b 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -34,7 +34,10 @@
-

Очередь обработки

+
+

Очередь обработки

+ очередь пуста +

Нет активных задач

@@ -59,6 +62,29 @@
+ + +
+
+

🤖 Чат с базой знаний

+
+ + +
+
+
+
+

Здравствуйте! Я помогу найти информацию в протоколах совещаний.

+

Выберите проект или оставьте «Все проекты» для глобального поиска.

+
+
+
+ + +
+
diff --git a/backend/static/styles.css b/backend/static/styles.css index dc7e4b3..1a5d50a 100644 --- a/backend/static/styles.css +++ b/backend/static/styles.css @@ -109,11 +109,24 @@ header h1 { } .queue-status h3 { - margin-bottom: 15px; + margin-bottom: 0; font-size: 1.1rem; color: var(--text-primary); } +.queue-status-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 15px; +} + +.queue-summary { + font-size: 0.85rem; + color: var(--text-secondary); +} + .tasks-list { display: flex; flex-direction: column; @@ -138,6 +151,13 @@ header h1 { .task-item.success { border-left-color: var(--success); } .task-item.error { border-left-color: var(--error); } .task-item.processing { border-left-color: var(--warning); } +.task-item.postprocessing { border-left-color: #8b5cf6; } +.task-item.queued { border-left-color: var(--text-secondary); opacity: 0.95; } + +.task-status-badge.postprocessing { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; +} .task-header { display: flex; @@ -299,6 +319,22 @@ header h1 { cursor: pointer; } +.file-item.file-summary { + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.2); +} + +.file-item.file-summary:hover { + background: rgba(99, 102, 241, 0.15); +} + +.txt-content { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + line-height: 1.6; +} + .file-icon { font-size: 1rem; } @@ -472,6 +508,170 @@ header h1 { to { transform: translateX(100%); opacity: 0; } } +/* ===== Chat Section ===== */ +.chat-section { + margin-top: 30px; + background: var(--bg-secondary); + border-radius: var(--radius); + display: flex; + flex-direction: column; + max-height: 600px; + overflow: hidden; + border: 1px solid var(--border); +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.chat-header h2 { + font-size: 1.1rem; + margin: 0; +} + +.chat-controls { + display: flex; + align-items: center; + gap: 10px; +} + +#chatProjectSelect { + background: var(--bg-hover); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 12px; + font-size: 0.9rem; +} + +#chatClearBtn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.1rem; + padding: 4px 8px; + border-radius: var(--radius); + transition: background 0.2s; +} + +#chatClearBtn:hover { + background: rgba(248, 113, 113, 0.2); +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 300px; +} + +.chat-welcome { + text-align: center; + color: var(--text-secondary); + padding: 30px 20px; +} + +.chat-welcome p { + margin-bottom: 8px; +} + +.chat-hint { + font-size: 0.85rem; + opacity: 0.8; +} + +.chat-bubble { + max-width: 80%; + padding: 12px 16px; + border-radius: var(--radius); + line-height: 1.5; + word-wrap: break-word; +} + +.chat-bubble.user { + align-self: flex-end; + background: var(--accent); + color: white; +} + +.chat-bubble.bot { + align-self: flex-start; + background: var(--bg-tertiary); + border: 1px solid var(--border); +} + +.chat-bubble .bubble-sources { + font-size: 0.75rem; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.1); + opacity: 0.9; +} + +.chat-bubble.bot .bubble-sources { + border-top-color: var(--border); + color: var(--text-secondary); +} + +.chat-thinking { + align-self: flex-start; + color: var(--text-secondary); + font-style: italic; + padding: 8px 12px; +} + +.chat-input-row { + display: flex; + gap: 10px; + padding: 16px 20px; + background: var(--bg-tertiary); + border-top: 1px solid var(--border); +} + +#chatInput { + flex: 1; + background: var(--bg-hover); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 14px; + font-size: 0.95rem; +} + +#chatInput:focus { + outline: none; + border-color: var(--accent); +} + +#chatSendBtn { + background: var(--accent); + color: white; + border: none; + border-radius: var(--radius); + padding: 10px 20px; + font-size: 0.95rem; + cursor: pointer; + transition: background 0.2s; +} + +#chatSendBtn:hover { + background: var(--accent-hover); +} + +#chatSendBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + /* ===== Responsive ===== */ @media (max-width: 768px) { .results-section { @@ -485,4 +685,10 @@ header h1 { .drop-zone { padding: 20px; } + + .chat-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } } diff --git a/config.yaml b/config.yaml index 468ae1a..2cf7d2a 100644 --- a/config.yaml +++ b/config.yaml @@ -42,6 +42,35 @@ output: speaker_label_style: name # name | id | none paragraph_pause_sec: 2.0 # новый абзац, если пауза > N секунд +# Настройки RAG (LightRAG для протоколов совещаний) +rag: + enabled: true + auto_index: true + sections: + - Планировка + - Конструкции + - MEP + - Отделка + - Общие вопросы + - Согласование + - Контроль качества + index_backend: opencode + index_model: mimo-v2.5-free + chat_backend: opencode + chat_model: deepseek-v4-flash-free + summary_auto: true + summary_model: deepseek-v4-flash-free + summary_chunk_size: 10000 + classify_chunk_size: 7000 + opencode_api_key: null # Или через env: OPENCODE_API_KEY + opencode_url: "https://opencode.ai/zen/v1" + project_index_dir: ./processed/lightrag_caches + +# Очередь обработки +queue: + transcribe_workers: 2 + postprocess_workers: 1 + # Пути paths: output_dir: ./output diff --git a/docker-compose.yml b/docker-compose.yml index 59bdb4c..8b5bd17 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,8 @@ services: transcription: + build: + context: . + dockerfile: Dockerfile.rag image: transcription-transcription:latest container_name: transcription_service ports: @@ -9,14 +12,21 @@ services: environment: - PYTHONUNBUFFERED=1 - HF_TOKEN=${HF_TOKEN} + - OPENCODE_API_KEY=${OPENCODE_API_KEY} + - OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1} + - HF_HOME=/root/.cache/huggingface + - NLTK_DATA=/root/nltk_data + - TRANSFORMERS_OFFLINE=0 volumes: - uploads:/app/uploads - processed:/app/processed - tmp:/app/tmp + - ./config.yaml:/app/config.yaml:ro - ./backend:/app/backend:ro - ./src:/app/src:ro - ./scripts:/app/scripts:ro - - nltk_data:/root/nltk_data + - ./models/huggingface:/root/.cache/huggingface + - ./models/nltk_data:/root/nltk_data:ro restart: unless-stopped entrypoint: ["uvicorn"] command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"] @@ -31,4 +41,3 @@ volumes: uploads: processed: tmp: - nltk_data: diff --git a/pip.conf b/pip.conf new file mode 100644 index 0000000..44686b1 --- /dev/null +++ b/pip.conf @@ -0,0 +1,11 @@ +[global] +# Yandex — основной (HTTP, иначе SSL WRONG_VERSION_NUMBER на HTTPS) +index-url = http://mirror.yandex.ru/pypi/simple/ +# Fallback, пока simple-индекс Yandex не синхронизирован +extra-index-url = https://pypi.org/simple/ +trusted-host = mirror.yandex.ru + pypi.org + files.pythonhosted.org + pypi.python.org +timeout = 300 +retries = 5 diff --git a/requirements.txt b/requirements.txt index 7f1d3b5..fcc189f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,10 @@ websockets python-docx pyyaml whisperx + +# RAG / LightRAG +lightrag-hku>=1.4.0 +openai>=1.0.0 +python-dotenv>=1.0.0 +sentence-transformers>=3.0.0 +numpy>=1.24.0 diff --git a/scripts/setup_local_models.ps1 b/scripts/setup_local_models.ps1 new file mode 100644 index 0000000..0f608c3 --- /dev/null +++ b/scripts/setup_local_models.ps1 @@ -0,0 +1,57 @@ +# Подготовка локальной папки models/ для офлайн-развёртывания. +# Использование: .\scripts\setup_local_models.ps1 + +$ErrorActionPreference = "Stop" +$ProjectRoot = Split-Path $PSScriptRoot -Parent +$ModelsDir = Join-Path $ProjectRoot "models" +$NltkBackup = Join-Path (Split-Path $ProjectRoot -Parent) ".nltk_data_backup" + +Write-Host "==> Папка моделей: $ModelsDir" + +New-Item -ItemType Directory -Force -Path $ModelsDir | Out-Null + +# 1. NLTK (punkt, punkt_tab) +$NltkTarget = Join-Path $ModelsDir "nltk_data" +if (-not (Test-Path "$NltkTarget\tokenizers\punkt")) { + if (Test-Path $NltkBackup) { + Write-Host "==> Копирование NLTK data из $NltkBackup" + New-Item -ItemType Directory -Force -Path $NltkTarget | Out-Null + Copy-Item -Recurse -Force "$NltkBackup\*" $NltkTarget + } else { + Write-Warning "NLTK backup не найден: $NltkBackup" + } +} else { + Write-Host "==> NLTK data уже на месте" +} + +# 2. HuggingFace модели из Docker-образа (если есть) +$HubTarget = Join-Path $ModelsDir "huggingface\hub" +$Marker = Join-Path $HubTarget "models--Systran--faster-whisper-large-v3" +if (-not (Test-Path $Marker)) { + $Image = "transcription-transcription:latest" + $Exists = docker images -q $Image 2>$null + if ($Exists) { + Write-Host "==> Извлечение моделей из Docker-образа $Image (~5 GB, несколько минут)..." + docker rm -f model_extract 2>$null | Out-Null + docker create --name model_extract $Image | Out-Null + New-Item -ItemType Directory -Force -Path (Split-Path $HubTarget -Parent) | Out-Null + docker cp model_extract:/root/.cache/huggingface (Join-Path $ModelsDir "huggingface") + docker rm model_extract | Out-Null + Write-Host "==> Модели Whisper/Alignment/Diarization скопированы" + } else { + Write-Warning "Образ $Image не найден. Сначала соберите: docker compose build" + Write-Warning "Или запустите: python scripts/download_models.py (скачает модели из интернета)" + } +} else { + Write-Host "==> HuggingFace модели уже на месте" +} + +# 3. Sentence-transformers для RAG (скачивается при первом запуске, опционально предзагрузка) +Write-Host "" +Write-Host "Готово. Структура:" +Get-ChildItem $ModelsDir -Directory | ForEach-Object { + $size = (Get-ChildItem $_.FullName -Recurse -File -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum + Write-Host (" {0}: {1:N2} GB" -f $_.Name, ($size / 1GB)) +} +Write-Host "" +Write-Host "Запуск сервиса: docker compose up --build -d" diff --git a/src/config.py b/src/config.py index 1e6f880..8432f36 100644 --- a/src/config.py +++ b/src/config.py @@ -31,3 +31,15 @@ def resolve_hf_token(config: Dict[str, Any]) -> str | None: if not token: token = os.environ.get("HF_TOKEN") return token + + +def resolve_opencode_credentials(config: Dict[str, Any] | None = None) -> tuple[str, str]: + """Возвращает (api_key, base_url) для OpenCode/DeepSeek API.""" + if config is None: + config = load_config() + rag = config.get("rag", {}) + api_key = rag.get("opencode_api_key") or os.environ.get("OPENCODE_API_KEY", "") + base_url = rag.get("opencode_url") or os.environ.get( + "OPENCODE_URL", "https://opencode.ai/zen/v1" + ) + return api_key, base_url diff --git a/src/rag/__init__.py b/src/rag/__init__.py new file mode 100644 index 0000000..977fb10 --- /dev/null +++ b/src/rag/__init__.py @@ -0,0 +1 @@ +"""RAG core package for meeting transcription.""" diff --git a/src/rag/formatter.py b/src/rag/formatter.py new file mode 100644 index 0000000..d94d611 --- /dev/null +++ b/src/rag/formatter.py @@ -0,0 +1,145 @@ +"""Форматирование документа совещания для индексации в LightRAG.""" + +import json +from datetime import datetime +from typing import Any, Dict, List + +from src.document import format_time + + +def format_meeting_document( + segments: List[Dict[str, Any]], + metadata: Dict[str, Any], + source_filename: str, +) -> str: + """Собирает текстовый документ для вставки в LightRAG. + + Сохраняет полную расшифровку + метаданные + извлечённые сущности. + """ + lines = [] + lines.append("=== СОВЕЩАНИЕ ===") + lines.append(f"ID: {metadata.get('project', 'unknown')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}") + lines.append(f"Проект: {metadata.get('project', 'unknown')}") + lines.append(f"Раздел: {metadata.get('section', 'Общие вопросы')}") + lines.append(f"Тема: {metadata.get('topic', 'Не определена')}") + lines.append(f"Дата: {metadata.get('date', 'Не указана')}") + lines.append(f"Источник: {source_filename}") + + participants = metadata.get("participants", []) + if participants: + lines.append(f"Участники: {', '.join(participants)}") + else: + # fallback — извлечь уникальных спикеров из сегментов + speakers = sorted({seg.get("speaker", "UNKNOWN") for seg in segments}) + lines.append(f"Участники: {', '.join(speakers)}") + + lines.append("") + lines.append("--- Метаданные ---") + summary = metadata.get("summary", "") + if summary: + lines.append(f"Summary: {summary}") + + decisions = metadata.get("key_decisions", []) + if decisions: + lines.append("Решения:") + for i, d in enumerate(decisions, 1): + lines.append(f" {i}. {d}") + + actions = metadata.get("action_items", []) + if actions: + lines.append("Action items:") + for a in actions: + who = a.get("who", "?") + what = a.get("what", "") + deadline = a.get("deadline", "") + dl = f" (до {deadline})" if deadline else "" + lines.append(f" - {who}: {what}{dl}") + + lines.append("") + lines.append("--- Полная расшифровка ---") + for seg in segments: + ts = format_time(seg.get("start", 0.0)) + speaker = seg.get("speaker", "UNKNOWN") + text = seg.get("text", "").strip() + if text: + lines.append(f"[{ts}] {speaker}: {text}") + + return "\n".join(lines) + + +def format_summary_markdown( + metadata: Dict[str, Any], + brief: str, + source_filename: str, +) -> str: + """Формирует markdown-файл краткого содержания совещания.""" + lines = [ + "# Краткое содержание совещания", + "", + f"**Проект:** {metadata.get('project', '—')} ", + f"**Раздел:** {metadata.get('section', '—')} ", + f"**Тема:** {metadata.get('topic', '—')} ", + f"**Дата:** {metadata.get('date') or '—'} ", + f"**Источник:** {source_filename}", + "", + ] + + participants = metadata.get("participants") or [] + if participants: + lines.append(f"**Участники:** {', '.join(participants)}") + lines.append("") + + lines.extend(["## Суть", "", brief.strip(), ""]) + + problems = metadata.get("problems") or [] + if problems: + lines.append("## Ключевые вопросы и проблемы") + lines.append("") + for item in problems: + lines.append(f"- {item}") + lines.append("") + + decisions = metadata.get("key_decisions") or [] + if decisions: + lines.append("## Принятые решения") + lines.append("") + for item in decisions: + lines.append(f"- {item}") + lines.append("") + + actions = metadata.get("action_items") or [] + if actions: + lines.append("## Поручения") + lines.append("") + for action in actions: + who = action.get("who", "?") + what = action.get("what", "") + deadline = action.get("deadline") + dl = f" (до {deadline})" if deadline else "" + lines.append(f"- **{who}:** {what}{dl}") + lines.append("") + + return "\n".join(lines).strip() + "\n" + + +def format_global_document(doc_text: str, metadata: Dict[str, Any]) -> str: + """Формирует версию документа для глобального (межпроектного) индекса. + + Добавляет явное указание проекта в начало, чтобы глобальный граф знал связь. + """ + header = f"""=== СОВЕЩАНИЕ (Проект: {metadata.get('project', 'unknown')}) === +Раздел: {metadata.get('section', 'Общие вопросы')} +Тема: {metadata.get('topic', 'Не определена')} +""" + return header + "\n" + doc_text + + +def build_meeting_text_only(segments: List[Dict[str, Any]]) -> str: + """Собирает plain text из сегментов (для отправки в classify_meeting).""" + lines = [] + for seg in segments: + speaker = seg.get("speaker", "UNKNOWN") + text = seg.get("text", "").strip() + if text: + lines.append(f"{speaker}: {text}") + return "\n".join(lines) diff --git a/src/rag/indexer.py b/src/rag/indexer.py new file mode 100644 index 0000000..f1a86e6 --- /dev/null +++ b/src/rag/indexer.py @@ -0,0 +1,184 @@ +"""Индексация протоколов совещаний в LightRAG (проектные индексы + глобальный).""" + +import os +from pathlib import Path +from typing import Optional + +from dotenv import load_dotenv +from lightrag import LightRAG +from lightrag.utils import EmbeddingFunc +from openai import AsyncOpenAI +from sentence_transformers import SentenceTransformer + +load_dotenv() + +# ------------------------------------------------------------------ +# Shared embedding model (lazy-loaded) +# ------------------------------------------------------------------ +_embed_model_instance: Optional[SentenceTransformer] = None + + +def _get_embed_model() -> SentenceTransformer: + global _embed_model_instance + if _embed_model_instance is None: + _embed_model_instance = SentenceTransformer( + "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" + ) + return _embed_model_instance + + +async def _embed_func(texts: list[str]): + model = _get_embed_model() + embeddings = model.encode(texts, convert_to_numpy=True) + return embeddings + + +EMBED_CONFIG = EmbeddingFunc( + embedding_dim=384, + max_token_size=512, + func=_embed_func, +) + + +# ------------------------------------------------------------------ +# LLM for indexing (mimo-v2.5-free via OpenCode) +# ------------------------------------------------------------------ +def _get_opencode_llm_func( + model: str = "mimo-v2.5-free", + api_key: str = "", + base_url: str = "https://opencode.ai/zen/v1", +): + if not api_key: + raise ValueError( + "OPENCODE_API_KEY не задан. Укажите rag.opencode_api_key в config.yaml " + "или переменную окружения OPENCODE_API_KEY." + ) + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + + async def llm_func(prompt, system_prompt=None, history_messages=[], **kwargs): + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + if history_messages: + messages.extend(history_messages) + messages.append({"role": "user", "content": prompt}) + + response = await client.chat.completions.create( + model=model, + messages=messages, + temperature=kwargs.get("temperature", 0.3), + max_tokens=kwargs.get("max_tokens", 1024), + ) + content = response.choices[0].message.content + return content if content is not None else "" + + return llm_func + + +# ------------------------------------------------------------------ +# Project index helpers +# ------------------------------------------------------------------ +def _normalize_project_name(name: str) -> str: + """Нормализует имя проекта для использования в пути.""" + import re + name = re.sub(r'[^\w\-_]', '_', name) + return name.strip('_') or "default" + + +def get_project_index_dir(working_dir_base: Path, project_name: str) -> Path: + """Возвращает путь к кэшу индекса проекта.""" + norm = _normalize_project_name(project_name) + return working_dir_base / norm + + +def get_global_index_dir(working_dir_base: Path) -> Path: + """Возвращает путь к глобальному индексу.""" + return working_dir_base / "_global" + + +async def get_project_rag( + project_name: str, + working_dir_base: Path, + model: str = "mimo-v2.5-free", + api_key: str = "", + base_url: str = "https://opencode.ai/zen/v1", +) -> LightRAG: + """Возвращает инициализированный LightRAG для проекта.""" + cache_dir = get_project_index_dir(working_dir_base, project_name) + cache_dir.mkdir(parents=True, exist_ok=True) + + llm_func = _get_opencode_llm_func(model, api_key=api_key, base_url=base_url) + + rag = LightRAG( + working_dir=str(cache_dir), + llm_model_func=llm_func, + embedding_func=EMBED_CONFIG, + ) + await rag.initialize_storages() + return rag + + +async def get_global_rag( + working_dir_base: Path, + model: str = "mimo-v2.5-free", + api_key: str = "", + base_url: str = "https://opencode.ai/zen/v1", +) -> LightRAG: + """Возвращает инициализированный LightRAG для глобального индекса.""" + cache_dir = get_global_index_dir(working_dir_base) + cache_dir.mkdir(parents=True, exist_ok=True) + + llm_func = _get_opencode_llm_func(model, api_key=api_key, base_url=base_url) + + rag = LightRAG( + working_dir=str(cache_dir), + llm_model_func=llm_func, + embedding_func=EMBED_CONFIG, + ) + await rag.initialize_storages() + return rag + + +# ------------------------------------------------------------------ +# Indexing API +# ------------------------------------------------------------------ +async def index_meeting( + doc_text: str, + global_doc_text: str, + project_name: str, + working_dir_base: Path, + model: str = "mimo-v2.5-free", + api_key: str = "", + base_url: str = "https://opencode.ai/zen/v1", +): + """Индексирует документ в проектный и глобальный индексы. + + Args: + doc_text: текст для проектного индекса. + global_doc_text: текст для глобального индекса (с явным project). + project_name: имя проекта. + working_dir_base: базовая директория для lightrag_caches. + model: модель LLM для индексации. + """ + # Проектный индекс + rag_project = await get_project_rag( + project_name, working_dir_base, model, api_key=api_key, base_url=base_url + ) + await rag_project.ainsert(doc_text) + + # Глобальный индекс + rag_global = await get_global_rag( + working_dir_base, model, api_key=api_key, base_url=base_url + ) + await rag_global.ainsert(global_doc_text) + + +async def get_project_names(working_dir_base: Path) -> list[str]: + """Возвращает список проектов, для которых есть индекс.""" + if not working_dir_base.exists(): + return [] + projects = [] + for item in working_dir_base.iterdir(): + if item.is_dir() and item.name != "_global": + projects.append(item.name) + return sorted(projects) diff --git a/src/rag/parser.py b/src/rag/parser.py new file mode 100644 index 0000000..7505ec7 --- /dev/null +++ b/src/rag/parser.py @@ -0,0 +1,306 @@ +"""Парсинг имени файла и классификация совещания через LLM.""" + +import json +import re +from typing import Any, Dict, List + +from openai import AsyncOpenAI + +# Лимиты для одного запроса (если текст длиннее — map-reduce по частям) +DEFAULT_CLASSIFY_CHUNK_SIZE = 7000 +DEFAULT_SUMMARY_CHUNK_SIZE = 10000 +CHUNK_OVERLAP = 300 + +CLASSIFY_PROMPT_TEMPLATE = """Ты — ассистент по анализу протоколов совещаний. + +Задача: проанализировать расшифровку совещания проекта "{project}" и извлечь структурированные данные. + +Выбери раздел из списка: {sections}. +Определи тему совещания (1 короткое предложение). +Выдели ключевые проблемы/вопросы, обсуждавшиеся на совещании. +Извлеки ключевые решения и action items. + +Верни результат СТРОГО в формате JSON (без markdown-форматирования, без ```): +{{ + "project": "{project}", + "section": "раздел из списка", + "topic": "тема совещания", + "date": "YYYY-MM-DD или null", + "participants": ["спикер1", "спикер2"], + "problems": ["проблема или открытый вопрос 1", "проблема 2"], + "summary": "2-3 предложения о чем шла речь", + "key_decisions": ["решение 1", "решение 2"], + "action_items": [ + {{"who": "ответственный", "what": "задача", "deadline": "YYYY-MM-DD или null"}} + ] +}} + +Расшифровка совещания: +--- +{text} +--- +""" + +BRIEF_SUMMARY_PROMPT = """Ты — секретарь совещания строительной компании. + +Напиши краткое содержание совещания ОДНИМ связным абзацем на русском (8–14 предложений). +Обязательно включи: тему встречи; ключевые проблемы и вопросы; принятые решения; поручения (кто, что, срок — если озвучены). +Пиши плотно и по делу, без заголовков и списков. Не выдумывай — только факты из расшифровки. + +Проект: {project} +Тема: {topic} + +Расшифровка: +--- +{text} +--- +""" + +CHUNK_BRIEF_PROMPT = """Ты — секретарь совещания. + +Кратко опиши содержание ФРАГМЕНТА совещания (3–5 предложений): темы, проблемы, решения, поручения. +Только факты из текста, без заголовков. + +Фрагмент {part} из {total}: +--- +{text} +--- +""" + +REDUCE_BRIEF_PROMPT = """Ты — секретарь совещания строительной компании. + +Ниже — описания частей одного совещания. Объедини их в ОДИН связный абзац на русском (8–14 предложений). +Включи все важные проблемы, решения и поручения. Не повторяйся. Не выдумывай. + +Проект: {project} +Тема: {topic} + +Части совещания: +--- +{partials} +--- +""" + + +def parse_project_from_filename(filename: str) -> str: + """Извлекает имя проекта из имени файла.""" + stem = filename.split(".")[0] + parts = re.split(r"[_\-\s]+", stem) + project = parts[0] if parts and parts[0] else stem + project = re.sub(r"\d{4}-\d{2}-\d{2}", "", project) + project = re.sub(r"\d{6,}", "", project) + project = project.strip("_- ") + return project or "default" + + +def split_text_chunks(text: str, chunk_size: int, overlap: int = CHUNK_OVERLAP) -> List[str]: + """Разбивает длинный текст на части с перекрытием по границам строк.""" + text = text.strip() + if not text: + return [""] + if len(text) <= chunk_size: + return [text] + + chunks: List[str] = [] + start = 0 + while start < len(text): + end = min(start + chunk_size, len(text)) + if end < len(text): + boundary = text.rfind("\n", start + chunk_size // 2, end) + if boundary > start: + end = boundary + 1 + chunk = text[start:end].strip() + if chunk: + chunks.append(chunk) + if end >= len(text): + break + start = max(end - overlap, start + 1) + + return chunks or [text] + + +def _empty_metadata(project: str) -> Dict[str, Any]: + return { + "project": project, + "section": "Общие вопросы", + "topic": "Не определена", + "date": None, + "participants": [], + "problems": [], + "summary": "", + "key_decisions": [], + "action_items": [], + } + + +def _normalize_metadata(metadata: Dict[str, Any], project: str) -> Dict[str, Any]: + base = _empty_metadata(project) + merged = {**base, **(metadata or {})} + for key in ["project", "section", "topic", "date", "participants", "problems", "summary", "key_decisions", "action_items"]: + if key not in merged: + merged[key] = base[key] + merged["project"] = merged.get("project") or project + return merged + + +def _parse_json_response(content: str, project: str) -> Dict[str, Any]: + content = re.sub(r"```json\s*", "", content or "") + content = re.sub(r"```\s*$", "", content) + content = content.strip() + try: + return _normalize_metadata(json.loads(content), project) + except json.JSONDecodeError: + return _empty_metadata(project) + + +def _dedupe_strings(items: List[str]) -> List[str]: + seen = set() + result = [] + for item in items: + key = re.sub(r"\s+", " ", (item or "").strip().lower()) + if key and key not in seen: + seen.add(key) + result.append(item.strip()) + return result + + +def _merge_metadata(parts: List[Dict[str, Any]], project: str) -> Dict[str, Any]: + """Объединяет метаданные из нескольких фрагментов совещания.""" + merged = _empty_metadata(project) + if not parts: + return merged + + merged["section"] = next((p.get("section") for p in parts if p.get("section")), merged["section"]) + merged["topic"] = next((p.get("topic") for p in parts if p.get("topic") and p.get("topic") != "Не определена"), merged["topic"]) + merged["date"] = next((p.get("date") for p in parts if p.get("date")), None) + + participants: List[str] = [] + problems: List[str] = [] + decisions: List[str] = [] + actions: List[Dict[str, Any]] = [] + summaries: List[str] = [] + + for part in parts: + participants.extend(part.get("participants") or []) + problems.extend(part.get("problems") or []) + decisions.extend(part.get("key_decisions") or []) + actions.extend(part.get("action_items") or []) + if part.get("summary"): + summaries.append(part["summary"]) + + merged["participants"] = _dedupe_strings(participants) + merged["problems"] = _dedupe_strings(problems) + merged["key_decisions"] = _dedupe_strings(decisions) + merged["summary"] = " ".join(summaries[:3]).strip() + + seen_actions = set() + unique_actions = [] + for action in actions: + if not isinstance(action, dict): + continue + key = ( + re.sub(r"\s+", " ", str(action.get("who", "")).strip().lower()), + re.sub(r"\s+", " ", str(action.get("what", "")).strip().lower()), + ) + if key[1] and key not in seen_actions: + seen_actions.add(key) + unique_actions.append(action) + merged["action_items"] = unique_actions + + return merged + + +async def _llm_complete( + client: AsyncOpenAI, + model: str, + prompt: str, + temperature: float = 0.2, + max_tokens: int = 1024, +) -> str: + response = await client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + temperature=temperature, + max_tokens=max_tokens, + ) + return (response.choices[0].message.content or "").strip() + + +async def _classify_chunk( + client: AsyncOpenAI, + text: str, + project: str, + sections: List[str], + model: str, +) -> Dict[str, Any]: + prompt = CLASSIFY_PROMPT_TEMPLATE.format( + project=project, + sections=", ".join(sections), + text=text, + ) + content = await _llm_complete(client, model, prompt, temperature=0.2, max_tokens=1024) + return _parse_json_response(content, project) + + +async def classify_meeting( + text: str, + project: str, + sections: List[str], + api_key: str, + base_url: str = "https://opencode.ai/zen/v1", + model: str = "mimo-v2.5-free", + chunk_size: int = DEFAULT_CLASSIFY_CHUNK_SIZE, +) -> Dict[str, Any]: + """Классифицирует совещание через LLM. Длинные тексты обрабатываются по частям.""" + if not api_key: + return _empty_metadata(project) + + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + chunks = split_text_chunks(text, chunk_size) + + if len(chunks) == 1: + return await _classify_chunk(client, chunks[0], project, sections, model) + + partials = [] + for chunk in chunks: + partials.append(await _classify_chunk(client, chunk, project, sections, model)) + return _merge_metadata(partials, project) + + +async def generate_meeting_brief( + text: str, + metadata: Dict[str, Any], + api_key: str, + base_url: str = "https://opencode.ai/zen/v1", + model: str = "deepseek-v4-flash-free", + chunk_size: int = DEFAULT_SUMMARY_CHUNK_SIZE, +) -> str: + """Генерирует краткое содержание одним абзацем. Длинные тексты — map-reduce.""" + if not api_key: + return metadata.get("summary") or "Краткое содержание не сформировано: не задан API-ключ." + + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + project = metadata.get("project", "unknown") + topic = metadata.get("topic", "Не определена") + chunks = split_text_chunks(text, chunk_size) + + if len(chunks) == 1: + prompt = BRIEF_SUMMARY_PROMPT.format(project=project, topic=topic, text=chunks[0]) + content = await _llm_complete(client, model, prompt, temperature=0.3, max_tokens=1024) + return content or metadata.get("summary", "") + + partials: List[str] = [] + total = len(chunks) + for idx, chunk in enumerate(chunks, start=1): + prompt = CHUNK_BRIEF_PROMPT.format(part=idx, total=total, text=chunk) + partial = await _llm_complete(client, model, prompt, temperature=0.3, max_tokens=512) + if partial: + partials.append(f"[Часть {idx}/{total}]\n{partial}") + + reduce_prompt = REDUCE_BRIEF_PROMPT.format( + project=project, + topic=topic, + partials="\n\n".join(partials), + ) + content = await _llm_complete(client, model, reduce_prompt, temperature=0.3, max_tokens=1024) + return content or metadata.get("summary", "") diff --git a/src/rag/query.py b/src/rag/query.py new file mode 100644 index 0000000..8e94a75 --- /dev/null +++ b/src/rag/query.py @@ -0,0 +1,154 @@ +"""Запросы к RAG и генерация ответов чат-бота через DeepSeek.""" + +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from lightrag import QueryParam +from openai import AsyncOpenAI + +from src.rag.indexer import get_global_rag, get_project_rag + + +async def retrieve_context( + question: str, + working_dir_base: Path, + project_name: Optional[str] = None, + mode: str = "hybrid", + api_key: str = "", + base_url: str = "https://opencode.ai/zen/v1", + index_model: str = "mimo-v2.5-free", +) -> str: + """Извлекает релевантный контекст из LightRAG. + + Args: + question: вопрос пользователя. + working_dir_base: базовая директория индексов. + project_name: если None — ищет в глобальном индексе. + mode: режим поиска LightRAG (naive, local, global, hybrid). + + Returns: + Строка с найденным контекстом. + """ + if project_name: + rag = await get_project_rag( + project_name, + working_dir_base, + model=index_model, + api_key=api_key, + base_url=base_url, + ) + else: + rag = await get_global_rag( + working_dir_base, + model=index_model, + api_key=api_key, + base_url=base_url, + ) + + # only_need_context=True — возвращает только найденные фрагменты без генерации ответа + param = QueryParam(mode=mode, only_need_context=True) + context = await rag.aquery(question, param=param) + return context if context else "" + + +async def generate_chat_response( + question: str, + context: str, + history: List[Dict[str, str]], + api_key: str, + base_url: str = "https://opencode.ai/zen/v1", + model: str = "deepseek-v4-flash-free", +) -> str: + """Генерирует ответ чат-бота через DeepSeek (или другую модель). + + Args: + question: вопрос пользователя. + context: контекст из RAG. + history: список {"question": ..., "answer": ...} предыдущих сообщений. + api_key: API ключ. + base_url: base URL OpenCode. + model: модель для чата. + + Returns: + Ответ ассистента. + """ + if not api_key: + raise ValueError( + "OPENCODE_API_KEY не задан. Укажите rag.opencode_api_key в config.yaml " + "или переменную окружения OPENCODE_API_KEY." + ) + client = AsyncOpenAI(base_url=base_url, api_key=api_key) + + system_prompt = ( + "Ты — ассистент по протоколам совещаний строительной компании. " + "Отвечай на основе предоставленного контекста из протоколов. " + "Если в контексте нет ответа — так и скажи, не выдумывай. " + "Отвечай кратко и по делу." + ) + + messages = [{"role": "system", "content": system_prompt}] + + for h in history: + messages.append({"role": "user", "content": h["question"]}) + messages.append({"role": "assistant", "content": h["answer"]}) + + user_prompt = f"""Контекст из протоколов совещаний: +--- +{context} +--- + +Вопрос: {question} +""" + messages.append({"role": "user", "content": user_prompt}) + + response = await client.chat.completions.create( + model=model, + messages=messages, + temperature=0.4, + max_tokens=2048, + ) + content = response.choices[0].message.content + return content if content is not None else "" + + +async def rag_chat( + question: str, + working_dir_base: Path, + history: List[Dict[str, str]], + api_key: str, + project_name: Optional[str] = None, + base_url: str = "https://opencode.ai/zen/v1", + chat_model: str = "deepseek-v4-flash-free", + mode: str = "hybrid", + index_model: str = "mimo-v2.5-free", +) -> Dict[str, Any]: + """Полный цикл RAG-чата: retrieval + generation. + + Returns: + {"answer": str, "context": str, "project": str | None} + """ + context = await retrieve_context( + question=question, + working_dir_base=working_dir_base, + project_name=project_name, + mode=mode, + api_key=api_key, + base_url=base_url, + index_model=index_model, + ) + + answer = await generate_chat_response( + question=question, + context=context, + history=history, + api_key=api_key, + base_url=base_url, + model=chat_model, + ) + + return { + "answer": answer, + "context": context, + "project": project_name, + }