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 = `
`;
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,
+ }