Add RAG, summary pipeline, and split transcribe/postprocess queue.
Separate ASR (2 workers) from summary/RAG post-processing, add LightRAG chat API, batch upload fixes, and local model mounts for Docker deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
6206e24af0
commit
fee9b9acb1
@ -31,6 +31,7 @@ uploads/
|
|||||||
processed/
|
processed/
|
||||||
tmp/
|
tmp/
|
||||||
output/
|
output/
|
||||||
|
models/
|
||||||
video/
|
video/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.wav
|
*.wav
|
||||||
|
|||||||
@ -12,8 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем зависимости
|
# Копируем зависимости
|
||||||
COPY requirements.txt .
|
COPY requirements.txt pip.conf ./
|
||||||
RUN pip install --no-cache-dir --timeout 300 -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt
|
RUN pip install --no-cache-dir --timeout 300 -r requirements.txt
|
||||||
|
|
||||||
# Копируем код проекта
|
# Копируем код проекта
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
9
Dockerfile.rag
Normal file
9
Dockerfile.rag
Normal file
@ -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
|
||||||
250
backend/main.py
250
backend/main.py
@ -1,9 +1,11 @@
|
|||||||
"""FastAPI backend для сервиса транскрибации."""
|
"""FastAPI backend для сервиса транскрибации."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
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 import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@ -13,8 +15,9 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from backend.queue import (
|
from backend.queue import (
|
||||||
UPLOAD_DIR,
|
UPLOAD_DIR,
|
||||||
PROCESSED_DIR,
|
PROCESSED_DIR,
|
||||||
enqueue,
|
save_upload,
|
||||||
get_all_tasks,
|
get_all_tasks,
|
||||||
|
get_queue_info,
|
||||||
get_task_status,
|
get_task_status,
|
||||||
get_processed_tree,
|
get_processed_tree,
|
||||||
read_file_content,
|
read_file_content,
|
||||||
@ -23,6 +26,13 @@ from backend.queue import (
|
|||||||
stop_workers,
|
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 менеджер
|
# WebSocket менеджер
|
||||||
class ConnectionManager:
|
class ConnectionManager:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -52,8 +62,15 @@ set_progress_callback(manager.broadcast)
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
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("🚀 Запуск рабочих процессов...")
|
print("🚀 Запуск рабочих процессов...")
|
||||||
start_workers(num_workers=1)
|
start_workers(
|
||||||
|
transcribe_workers=transcribe_workers,
|
||||||
|
postprocess_workers=postprocess_workers,
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
print("🛑 Остановка рабочих процессов...")
|
print("🛑 Остановка рабочих процессов...")
|
||||||
stop_workers()
|
stop_workers()
|
||||||
@ -89,34 +106,28 @@ async def root():
|
|||||||
@app.post("/upload")
|
@app.post("/upload")
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(file: UploadFile = File(...)):
|
||||||
"""Загружает файл и добавляет в очередь обработки."""
|
"""Загружает файл и добавляет в очередь обработки."""
|
||||||
# Сохраняем файл
|
|
||||||
file_path = UPLOAD_DIR / file.filename
|
|
||||||
with open(file_path, "wb") as f:
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
f.write(content)
|
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||||
|
|
||||||
# Добавляем в очередь
|
|
||||||
task_id = await enqueue(file_path)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"file": file.filename,
|
"file": file.filename,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"message": "Файл добавлен в очередь обработки",
|
"message": "Файл добавлен в очередь обработки",
|
||||||
|
"queue": get_queue_info(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/upload-batch")
|
@app.post("/upload-batch")
|
||||||
async def upload_batch(files: List[UploadFile] = File(...)):
|
async def upload_batch(files: List[UploadFile] = File(...)):
|
||||||
"""Загружает несколько файлов пакетно."""
|
"""Загружает несколько файлов пакетно — все ставятся в очередь."""
|
||||||
|
if not files:
|
||||||
|
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for file in files:
|
for file in files:
|
||||||
file_path = UPLOAD_DIR / file.filename
|
|
||||||
with open(file_path, "wb") as f:
|
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
f.write(content)
|
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||||
|
|
||||||
task_id = await enqueue(file_path)
|
|
||||||
results.append({
|
results.append({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"file": file.filename,
|
"file": file.filename,
|
||||||
@ -126,6 +137,8 @@ async def upload_batch(files: List[UploadFile] = File(...)):
|
|||||||
return {
|
return {
|
||||||
"uploaded": len(results),
|
"uploaded": len(results),
|
||||||
"tasks": 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({
|
await websocket.send_json({
|
||||||
"type": "tasks_list",
|
"type": "tasks_list",
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
|
"queue": get_queue_info(),
|
||||||
})
|
})
|
||||||
elif msg.get("action") == "get_tree":
|
elif msg.get("action") == "get_tree":
|
||||||
tree = get_processed_tree()
|
tree = get_processed_tree()
|
||||||
@ -151,6 +165,10 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
"type": "file_tree",
|
"type": "file_tree",
|
||||||
"tree": 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:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket)
|
manager.disconnect(websocket)
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -160,7 +178,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
@app.get("/api/tasks")
|
@app.get("/api/tasks")
|
||||||
async def 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}")
|
@app.get("/api/tasks/{task_id}")
|
||||||
@ -212,5 +230,203 @@ async def api_delete_folder(folder_name: str):
|
|||||||
return {"error": str(e)}
|
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")
|
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||||
|
|||||||
367
backend/queue.py
367
backend/queue.py
@ -1,9 +1,10 @@
|
|||||||
"""Фоновая очередь обработки аудио/видео."""
|
"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
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))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
from src.audio_utils import prepare_audio_input
|
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.document import build_document
|
||||||
from src.pipeline import run_pipeline
|
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")
|
UPLOAD_DIR = Path("uploads")
|
||||||
@ -21,21 +30,19 @@ PROCESSED_DIR = Path("processed")
|
|||||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||||
PROCESSED_DIR.mkdir(exist_ok=True)
|
PROCESSED_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Глобальное хранилище состояний задач
|
|
||||||
tasks: Dict[str, Dict[str, Any]] = {}
|
tasks: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
# Callback для отправки прогресса через WebSocket
|
|
||||||
_progress_callback: Optional[Callable] = None
|
_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):
|
def set_progress_callback(callback: Callable):
|
||||||
"""Устанавливает callback для отправки прогресса."""
|
|
||||||
global _progress_callback
|
global _progress_callback
|
||||||
_progress_callback = callback
|
_progress_callback = callback
|
||||||
|
|
||||||
|
|
||||||
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
|
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
|
||||||
"""Отправляет прогресс через callback."""
|
|
||||||
if _progress_callback:
|
if _progress_callback:
|
||||||
try:
|
try:
|
||||||
task_info = tasks.get(task_id, {})
|
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,
|
"message": message,
|
||||||
"status": status,
|
"status": status,
|
||||||
"file": task_info.get("file", ""),
|
"file": task_info.get("file", ""),
|
||||||
|
"queue_position": task_info.get("queue_position"),
|
||||||
"result": result,
|
"result": result,
|
||||||
"error": error,
|
"error": error,
|
||||||
})
|
})
|
||||||
@ -52,34 +60,47 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def process_file(file_path: Path, task_id: str):
|
def _cleanup_upload(file_path: Path):
|
||||||
"""Обрабатывает один файл и отправляет прогресс."""
|
if not file_path.exists():
|
||||||
tasks[task_id] = {
|
return
|
||||||
"task_id": task_id,
|
parent = file_path.parent
|
||||||
"status": "processing",
|
if parent != UPLOAD_DIR and parent.name.startswith("task_"):
|
||||||
"progress": 0,
|
shutil.rmtree(parent, ignore_errors=True)
|
||||||
"message": "Начало обработки...",
|
else:
|
||||||
"file": str(file_path.name),
|
file_path.unlink(missing_ok=True)
|
||||||
"result": None,
|
|
||||||
"error": None,
|
|
||||||
"started": datetime.now().isoformat(),
|
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": "Транскрибация...",
|
||||||
|
"queue_position": None,
|
||||||
|
})
|
||||||
await _send_progress(task_id, 5, "Извлечение аудио...", "processing")
|
await _send_progress(task_id, 5, "Извлечение аудио...", "processing")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Загружаем конфиг
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
profile = get_profile(config)
|
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")
|
||||||
|
|
||||||
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")
|
|
||||||
|
|
||||||
# Запуск пайплайна (в отдельном потоке)
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
run_pipeline,
|
run_pipeline,
|
||||||
input_path=str(file_path),
|
input_path=str(file_path),
|
||||||
@ -87,41 +108,56 @@ async def process_file(file_path: Path, task_id: str):
|
|||||||
config_path=None,
|
config_path=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
await _send_progress(task_id, 75, "Генерация документов...", "processing")
|
await _send_progress(task_id, 65, "Сохранение протокола...", "processing")
|
||||||
|
|
||||||
# Определяем имена выходных файлов (уникальная папка с timestamp)
|
stem = Path(display_name).stem
|
||||||
stem = file_path.stem
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
folder_name = f"{stem}_{timestamp}"
|
folder_name = f"{stem}_{timestamp}"
|
||||||
output_dir = PROCESSED_DIR / folder_name
|
output_dir = PROCESSED_DIR / folder_name
|
||||||
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Сохраняем docx и md (в отдельном потоке)
|
docx_path = output_dir / f"{stem}.docx"
|
||||||
docx_path = str(output_dir / f"{stem}.docx")
|
md_path = output_dir / f"{stem}.md"
|
||||||
md_path = str(output_dir / f"{stem}.md")
|
segments_path = output_dir / f"{stem}_segments.json"
|
||||||
|
|
||||||
await asyncio.to_thread(build_document, result["segments"], docx_path, config)
|
await asyncio.to_thread(build_document, result["segments"], str(docx_path), config)
|
||||||
await asyncio.to_thread(build_document, result["segments"], md_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",
|
||||||
|
)
|
||||||
|
|
||||||
# Удаляем исходник из uploads после обработки
|
await asyncio.to_thread(_cleanup_upload, file_path)
|
||||||
if file_path.exists():
|
|
||||||
await asyncio.to_thread(file_path.unlink)
|
|
||||||
|
|
||||||
result_data = {
|
post_position = _postprocess_queue.qsize() + 1
|
||||||
|
tasks[task_id].update({
|
||||||
|
"status": "postprocessing",
|
||||||
|
"progress": 70,
|
||||||
|
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
|
||||||
|
"result": {
|
||||||
"docx": str(docx_path),
|
"docx": str(docx_path),
|
||||||
"md": str(md_path),
|
"md": str(md_path),
|
||||||
"dir": str(output_dir),
|
"dir": str(output_dir),
|
||||||
}
|
},
|
||||||
|
|
||||||
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(),
|
|
||||||
})
|
})
|
||||||
|
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:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
@ -131,88 +167,251 @@ async def process_file(file_path: Path, task_id: str):
|
|||||||
"progress": 0,
|
"progress": 0,
|
||||||
"message": f"Ошибка: {error_msg}",
|
"message": f"Ошибка: {error_msg}",
|
||||||
"error": error_msg,
|
"error": error_msg,
|
||||||
|
"queue_position": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# Очередь задач
|
async def process_postprocessing(job: Dict[str, Any]):
|
||||||
_queue: asyncio.Queue = asyncio.Queue()
|
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
|
||||||
_workers: List[asyncio.Task] = []
|
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():
|
async def _transcribe_worker_loop(worker_id: int):
|
||||||
"""Рабочий цикл обработки."""
|
print(f"[Transcribe Worker {worker_id}] запущен")
|
||||||
print("[Worker] Рабочий процесс запущен и ждёт задачи...")
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
task_id, file_path = await _queue.get()
|
task_id, file_path = await _transcribe_queue.get()
|
||||||
print(f"[Worker] Получена задача: {task_id}")
|
print(f"[Transcribe Worker {worker_id}] задача {task_id}")
|
||||||
await process_file(file_path, task_id)
|
await process_transcription(file_path, task_id)
|
||||||
_queue.task_done()
|
_transcribe_queue.task_done()
|
||||||
print(f"[Worker] Задача завершена: {task_id}")
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
print("[Worker] Остановка рабочего процесса")
|
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Worker Error] {e}")
|
print(f"[Transcribe Worker {worker_id} Error] {e}")
|
||||||
|
|
||||||
|
|
||||||
def start_workers(num_workers: int = 1):
|
async def _postprocess_worker_loop(worker_id: int):
|
||||||
"""Запускает рабочих в текущем event loop."""
|
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
|
global _workers
|
||||||
for i in range(num_workers):
|
_workers.clear()
|
||||||
task = asyncio.create_task(_worker_loop())
|
for i in range(transcribe_workers):
|
||||||
_workers.append(task)
|
_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():
|
def stop_workers():
|
||||||
"""Останавливает рабочих."""
|
|
||||||
for w in _workers:
|
for w in _workers:
|
||||||
w.cancel()
|
w.cancel()
|
||||||
|
|
||||||
|
|
||||||
async def enqueue(file_path: Path) -> str:
|
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')}_{file_path.stem}"
|
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] = {
|
tasks[task_id] = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": "queued",
|
"status": "queued",
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
"message": "В очереди...",
|
"message": f"В очереди транскрибации (№{queue_position})",
|
||||||
"file": str(file_path.name),
|
"file": safe_name,
|
||||||
|
"queue_position": queue_position,
|
||||||
"result": None,
|
"result": None,
|
||||||
"error": None,
|
"error": None,
|
||||||
"started": datetime.now().isoformat(),
|
"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]]:
|
def get_task_status(task_id: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Возвращает статус задачи."""
|
|
||||||
return tasks.get(task_id)
|
return tasks.get(task_id)
|
||||||
|
|
||||||
|
|
||||||
def get_all_tasks() -> List[Dict[str, Any]]:
|
def get_all_tasks() -> List[Dict[str, Any]]:
|
||||||
"""Возвращает все задачи."""
|
|
||||||
return list(tasks.values())
|
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]]:
|
def get_processed_tree() -> List[Dict[str, Any]]:
|
||||||
"""Возвращает дерево обработанных файлов."""
|
|
||||||
tree = []
|
tree = []
|
||||||
if not PROCESSED_DIR.exists():
|
if not PROCESSED_DIR.exists():
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
skip_names = {"lightrag_caches", "lightrag_caches_test"}
|
||||||
|
skip_suffixes = ("_segments.json",)
|
||||||
|
|
||||||
for item in sorted(PROCESSED_DIR.iterdir()):
|
for item in sorted(PROCESSED_DIR.iterdir()):
|
||||||
if item.is_dir():
|
if not item.is_dir() or item.name in skip_names:
|
||||||
|
continue
|
||||||
files = []
|
files = []
|
||||||
for f in sorted(item.iterdir()):
|
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
|
||||||
if f.is_file():
|
if f.is_file() and not f.name.endswith(skip_suffixes):
|
||||||
files.append({
|
files.append({
|
||||||
"name": f.name,
|
"name": f.name,
|
||||||
"path": str(f.relative_to(PROCESSED_DIR)),
|
"path": str(f.relative_to(PROCESSED_DIR)),
|
||||||
"size": f.stat().st_size,
|
"size": f.stat().st_size,
|
||||||
"ext": f.suffix.lower(),
|
"ext": f.suffix.lower(),
|
||||||
|
"kind": "summary" if "_summary" in f.name else "protocol",
|
||||||
})
|
})
|
||||||
tree.append({
|
tree.append({
|
||||||
"name": item.name,
|
"name": item.name,
|
||||||
@ -224,10 +423,8 @@ def get_processed_tree() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def read_file_content(rel_path: str) -> str:
|
def read_file_content(rel_path: str) -> str:
|
||||||
"""Читает содержимое файла."""
|
|
||||||
full_path = PROCESSED_DIR / rel_path
|
full_path = PROCESSED_DIR / rel_path
|
||||||
if not full_path.exists() or not full_path.is_file():
|
if not full_path.exists() or not full_path.is_file():
|
||||||
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|||||||
@ -8,12 +8,17 @@ class TranscriptionApp {
|
|||||||
this.tasks = new Map();
|
this.tasks = new Map();
|
||||||
this.currentFile = null;
|
this.currentFile = null;
|
||||||
this.renderTimeout = null;
|
this.renderTimeout = null;
|
||||||
|
this.chatHistory = [];
|
||||||
|
this.chatProjects = [];
|
||||||
|
this.isChatThinking = false;
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
this.setupUpload();
|
this.setupUpload();
|
||||||
|
this.setupChat();
|
||||||
|
this.loadChatHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== WebSocket =====
|
// ===== WebSocket =====
|
||||||
@ -60,8 +65,13 @@ class TranscriptionApp {
|
|||||||
handleWebSocketMessage(data) {
|
handleWebSocketMessage(data) {
|
||||||
if (data.type === 'tasks_list') {
|
if (data.type === 'tasks_list') {
|
||||||
this.updateTasks(data.tasks);
|
this.updateTasks(data.tasks);
|
||||||
|
this.updateQueueSummary(data.queue);
|
||||||
} else if (data.type === 'file_tree') {
|
} else if (data.type === 'file_tree') {
|
||||||
this.renderFileTree(data.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) {
|
} else if (data.task_id) {
|
||||||
// Прогресс обработки
|
// Прогресс обработки
|
||||||
this.updateTaskProgress(data);
|
this.updateTaskProgress(data);
|
||||||
@ -134,11 +144,21 @@ class TranscriptionApp {
|
|||||||
if (result.error) {
|
if (result.error) {
|
||||||
this.showToast(`Ошибка: ${result.error}`, 'error');
|
this.showToast(`Ошибка: ${result.error}`, 'error');
|
||||||
} else {
|
} 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 => {
|
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.renderTasks();
|
||||||
|
this.updateQueueSummary(result.queue);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
|
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
|
||||||
@ -153,6 +173,17 @@ class TranscriptionApp {
|
|||||||
this.renderTasks();
|
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) {
|
updateTaskProgress(data) {
|
||||||
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
|
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@ -163,7 +194,9 @@ class TranscriptionApp {
|
|||||||
this.renderTasks();
|
this.renderTasks();
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
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();
|
this.requestTree();
|
||||||
} else if (data.status === 'error') {
|
} else if (data.status === 'error') {
|
||||||
this.showToast(`Ошибка: ${data.message}`, 'error');
|
this.showToast(`Ошибка: ${data.message}`, 'error');
|
||||||
@ -174,7 +207,10 @@ class TranscriptionApp {
|
|||||||
if (this.renderTimeout) clearTimeout(this.renderTimeout);
|
if (this.renderTimeout) clearTimeout(this.renderTimeout);
|
||||||
this.renderTimeout = setTimeout(() => {
|
this.renderTimeout = setTimeout(() => {
|
||||||
const container = document.getElementById('tasksList');
|
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) {
|
if (tasks.length === 0) {
|
||||||
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
|
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
|
||||||
@ -189,7 +225,8 @@ class TranscriptionApp {
|
|||||||
const progress = task.progress || 0;
|
const progress = task.progress || 0;
|
||||||
const statusClass = task.status === 'completed' ? 'success' :
|
const statusClass = task.status === 'completed' ? 'success' :
|
||||||
task.status === 'error' ? 'error' :
|
task.status === 'error' ? 'error' :
|
||||||
task.status === 'processing' ? 'processing' : 'queued';
|
task.status === 'processing' ? 'processing' :
|
||||||
|
task.status === 'postprocessing' ? 'postprocessing' : 'queued';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="task-item ${statusClass}">
|
<div class="task-item ${statusClass}">
|
||||||
@ -208,7 +245,8 @@ class TranscriptionApp {
|
|||||||
getStatusLabel(status) {
|
getStatusLabel(status) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'queued': 'В очереди',
|
'queued': 'В очереди',
|
||||||
'processing': 'Обработка',
|
'processing': 'Транскрибация',
|
||||||
|
'postprocessing': 'Summary/RAG',
|
||||||
'completed': 'Готово',
|
'completed': 'Готово',
|
||||||
'error': 'Ошибка',
|
'error': 'Ошибка',
|
||||||
};
|
};
|
||||||
@ -230,10 +268,13 @@ class TranscriptionApp {
|
|||||||
|
|
||||||
renderFolder(folder) {
|
renderFolder(folder) {
|
||||||
const files = folder.files.map(file => {
|
const files = folder.files.map(file => {
|
||||||
|
const isSummary = file.kind === 'summary' || file.name.includes('_summary');
|
||||||
const isMd = file.ext === '.md';
|
const isMd = file.ext === '.md';
|
||||||
const isDocx = file.ext === '.docx';
|
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 downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
|
||||||
|
|
||||||
if (isDocx) {
|
if (isDocx) {
|
||||||
return `
|
return `
|
||||||
@ -246,7 +287,7 @@ class TranscriptionApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="file-item" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}">
|
<div class="${cssClass}" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}">
|
||||||
<span class="file-icon">${icon}</span>
|
<span class="file-icon">${icon}</span>
|
||||||
<span class="file-name">${this.escapeHtml(file.name)}</span>
|
<span class="file-name">${this.escapeHtml(file.name)}</span>
|
||||||
<span class="file-size">${this.formatBytes(file.size)}</span>
|
<span class="file-size">${this.formatBytes(file.size)}</span>
|
||||||
@ -347,7 +388,7 @@ class TranscriptionApp {
|
|||||||
async loadFileContent(path, ext) {
|
async loadFileContent(path, ext) {
|
||||||
const viewer = document.getElementById('viewer');
|
const viewer = document.getElementById('viewer');
|
||||||
|
|
||||||
if (ext === '.md') {
|
if (ext === '.md' || ext === '.txt') {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@ -357,8 +398,10 @@ class TranscriptionApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render markdown
|
const bodyHtml = ext === '.md'
|
||||||
const html = marked.parse(result.content);
|
? marked.parse(result.content)
|
||||||
|
: `<pre class="txt-content">${this.escapeHtml(result.content)}</pre>`;
|
||||||
|
|
||||||
viewer.innerHTML = `
|
viewer.innerHTML = `
|
||||||
<div class="md-content">
|
<div class="md-content">
|
||||||
<div class="md-header">
|
<div class="md-header">
|
||||||
@ -366,7 +409,7 @@ class TranscriptionApp {
|
|||||||
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
||||||
class="btn-download" download>⬇️ Скачать</a>
|
class="btn-download" download>⬇️ Скачать</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="md-body">${html}</div>
|
<div class="md-body">${bodyHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.currentFile = path;
|
this.currentFile = path;
|
||||||
@ -420,6 +463,161 @@ class TranscriptionApp {
|
|||||||
minute: '2-digit',
|
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 = '<option value="">Все проекты</option>';
|
||||||
|
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 = `<div>${this.escapeHtml(answer)}</div>`;
|
||||||
|
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 = `
|
||||||
|
<div class="chat-welcome">
|
||||||
|
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
|
||||||
|
<p class="chat-hint">Выберите проект или оставьте «Все проекты» для глобального поиска.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// Initialize
|
||||||
|
|||||||
@ -34,7 +34,10 @@
|
|||||||
|
|
||||||
<!-- Queue Status -->
|
<!-- Queue Status -->
|
||||||
<div class="queue-status" id="queueStatus">
|
<div class="queue-status" id="queueStatus">
|
||||||
|
<div class="queue-status-header">
|
||||||
<h3>Очередь обработки</h3>
|
<h3>Очередь обработки</h3>
|
||||||
|
<span class="queue-summary" id="queueSummary">очередь пуста</span>
|
||||||
|
</div>
|
||||||
<div class="tasks-list" id="tasksList">
|
<div class="tasks-list" id="tasksList">
|
||||||
<p class="empty-state">Нет активных задач</p>
|
<p class="empty-state">Нет активных задач</p>
|
||||||
</div>
|
</div>
|
||||||
@ -59,6 +62,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Chat Section -->
|
||||||
|
<section class="chat-section" id="chatSection">
|
||||||
|
<div class="chat-header">
|
||||||
|
<h2>🤖 Чат с базой знаний</h2>
|
||||||
|
<div class="chat-controls">
|
||||||
|
<select id="chatProjectSelect">
|
||||||
|
<option value="">Все проекты</option>
|
||||||
|
</select>
|
||||||
|
<button id="chatClearBtn" title="Очистить историю">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-messages" id="chatMessages">
|
||||||
|
<div class="chat-welcome">
|
||||||
|
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
|
||||||
|
<p class="chat-hint">Выберите проект или оставьте «Все проекты» для глобального поиска.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input type="text" id="chatInput" placeholder="Задайте вопрос по протоколам совещаний..." autocomplete="off">
|
||||||
|
<button id="chatSendBtn">Отправить</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -109,11 +109,24 @@ header h1 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.queue-status h3 {
|
.queue-status h3 {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 0;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
color: var(--text-primary);
|
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 {
|
.tasks-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -138,6 +151,13 @@ header h1 {
|
|||||||
.task-item.success { border-left-color: var(--success); }
|
.task-item.success { border-left-color: var(--success); }
|
||||||
.task-item.error { border-left-color: var(--error); }
|
.task-item.error { border-left-color: var(--error); }
|
||||||
.task-item.processing { border-left-color: var(--warning); }
|
.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 {
|
.task-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -299,6 +319,22 @@ header h1 {
|
|||||||
cursor: pointer;
|
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 {
|
.file-icon {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
@ -472,6 +508,170 @@ header h1 {
|
|||||||
to { transform: translateX(100%); opacity: 0; }
|
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 ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.results-section {
|
.results-section {
|
||||||
@ -485,4 +685,10 @@ header h1 {
|
|||||||
.drop-zone {
|
.drop-zone {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
config.yaml
29
config.yaml
@ -42,6 +42,35 @@ output:
|
|||||||
speaker_label_style: name # name | id | none
|
speaker_label_style: name # name | id | none
|
||||||
paragraph_pause_sec: 2.0 # новый абзац, если пауза > N секунд
|
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:
|
paths:
|
||||||
output_dir: ./output
|
output_dir: ./output
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
transcription:
|
transcription:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.rag
|
||||||
image: transcription-transcription:latest
|
image: transcription-transcription:latest
|
||||||
container_name: transcription_service
|
container_name: transcription_service
|
||||||
ports:
|
ports:
|
||||||
@ -9,14 +12,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- HF_TOKEN=${HF_TOKEN}
|
- 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:
|
volumes:
|
||||||
- uploads:/app/uploads
|
- uploads:/app/uploads
|
||||||
- processed:/app/processed
|
- processed:/app/processed
|
||||||
- tmp:/app/tmp
|
- tmp:/app/tmp
|
||||||
|
- ./config.yaml:/app/config.yaml:ro
|
||||||
- ./backend:/app/backend:ro
|
- ./backend:/app/backend:ro
|
||||||
- ./src:/app/src:ro
|
- ./src:/app/src:ro
|
||||||
- ./scripts:/app/scripts: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
|
restart: unless-stopped
|
||||||
entrypoint: ["uvicorn"]
|
entrypoint: ["uvicorn"]
|
||||||
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@ -31,4 +41,3 @@ volumes:
|
|||||||
uploads:
|
uploads:
|
||||||
processed:
|
processed:
|
||||||
tmp:
|
tmp:
|
||||||
nltk_data:
|
|
||||||
|
|||||||
11
pip.conf
Normal file
11
pip.conf
Normal file
@ -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
|
||||||
@ -5,3 +5,10 @@ websockets
|
|||||||
python-docx
|
python-docx
|
||||||
pyyaml
|
pyyaml
|
||||||
whisperx
|
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
|
||||||
|
|||||||
57
scripts/setup_local_models.ps1
Normal file
57
scripts/setup_local_models.ps1
Normal file
@ -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"
|
||||||
@ -31,3 +31,15 @@ def resolve_hf_token(config: Dict[str, Any]) -> str | None:
|
|||||||
if not token:
|
if not token:
|
||||||
token = os.environ.get("HF_TOKEN")
|
token = os.environ.get("HF_TOKEN")
|
||||||
return 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
|
||||||
|
|||||||
1
src/rag/__init__.py
Normal file
1
src/rag/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""RAG core package for meeting transcription."""
|
||||||
145
src/rag/formatter.py
Normal file
145
src/rag/formatter.py
Normal file
@ -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)
|
||||||
184
src/rag/indexer.py
Normal file
184
src/rag/indexer.py
Normal file
@ -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)
|
||||||
306
src/rag/parser.py
Normal file
306
src/rag/parser.py
Normal file
@ -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", "")
|
||||||
154
src/rag/query.py
Normal file
154
src/rag/query.py
Normal file
@ -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,
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user