Compare commits
2 Commits
6206e24af0
...
e9f5b80e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f5b80e23 | ||
|
|
fee9b9acb1 |
@ -31,6 +31,7 @@ uploads/
|
||||
processed/
|
||||
tmp/
|
||||
output/
|
||||
models/
|
||||
video/
|
||||
*.mp4
|
||||
*.wav
|
||||
|
||||
2
.env
2
.env
@ -1 +1,3 @@
|
||||
HF_TOKEN=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
|
||||
OPENCODE_API_KEY=sk-4jJBUMS7WJyBOtZZAexsSy6aT4NKOYp2gA19WLlaux8jHMw0HvyCl1V45Jf8SONz
|
||||
OPENCODE_URL=https://opencode.ai/zen/v1
|
||||
|
||||
@ -12,8 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем зависимости
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --timeout 300 -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt
|
||||
COPY requirements.txt pip.conf ./
|
||||
RUN pip install --no-cache-dir --timeout 300 -r requirements.txt
|
||||
|
||||
# Копируем код проекта
|
||||
COPY . .
|
||||
|
||||
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 для сервиса транскрибации."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@ -13,8 +15,9 @@ from fastapi.staticfiles import StaticFiles
|
||||
from backend.queue import (
|
||||
UPLOAD_DIR,
|
||||
PROCESSED_DIR,
|
||||
enqueue,
|
||||
save_upload,
|
||||
get_all_tasks,
|
||||
get_queue_info,
|
||||
get_task_status,
|
||||
get_processed_tree,
|
||||
read_file_content,
|
||||
@ -23,6 +26,13 @@ from backend.queue import (
|
||||
stop_workers,
|
||||
)
|
||||
|
||||
# Добавляем корень проекта в путь, чтобы импортировать src.rag
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from src.config import load_config, resolve_opencode_credentials
|
||||
from src.rag.indexer import get_project_names
|
||||
from src.rag.parser import parse_project_from_filename
|
||||
from src.rag.query import rag_chat, retrieve_context
|
||||
|
||||
# WebSocket менеджер
|
||||
class ConnectionManager:
|
||||
def __init__(self):
|
||||
@ -52,8 +62,15 @@ set_progress_callback(manager.broadcast)
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Управление жизненным циклом приложения."""
|
||||
config = load_config()
|
||||
queue_cfg = config.get("queue", {})
|
||||
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
|
||||
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
|
||||
print("🚀 Запуск рабочих процессов...")
|
||||
start_workers(num_workers=1)
|
||||
start_workers(
|
||||
transcribe_workers=transcribe_workers,
|
||||
postprocess_workers=postprocess_workers,
|
||||
)
|
||||
yield
|
||||
print("🛑 Остановка рабочих процессов...")
|
||||
stop_workers()
|
||||
@ -89,34 +106,28 @@ async def root():
|
||||
@app.post("/upload")
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
"""Загружает файл и добавляет в очередь обработки."""
|
||||
# Сохраняем файл
|
||||
file_path = UPLOAD_DIR / file.filename
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
# Добавляем в очередь
|
||||
task_id = await enqueue(file_path)
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"file": file.filename,
|
||||
"status": "queued",
|
||||
"message": "Файл добавлен в очередь обработки",
|
||||
"queue": get_queue_info(),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/upload-batch")
|
||||
async def upload_batch(files: List[UploadFile] = File(...)):
|
||||
"""Загружает несколько файлов пакетно."""
|
||||
"""Загружает несколько файлов пакетно — все ставятся в очередь."""
|
||||
if not files:
|
||||
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
|
||||
|
||||
results = []
|
||||
for file in files:
|
||||
file_path = UPLOAD_DIR / file.filename
|
||||
with open(file_path, "wb") as f:
|
||||
content = await file.read()
|
||||
f.write(content)
|
||||
|
||||
task_id = await enqueue(file_path)
|
||||
task_id, _ = await save_upload(content, file.filename or "upload.bin")
|
||||
results.append({
|
||||
"task_id": task_id,
|
||||
"file": file.filename,
|
||||
@ -126,6 +137,8 @@ async def upload_batch(files: List[UploadFile] = File(...)):
|
||||
return {
|
||||
"uploaded": len(results),
|
||||
"tasks": results,
|
||||
"queue": get_queue_info(),
|
||||
"message": f"{len(results)} файл(ов) добавлено в очередь",
|
||||
}
|
||||
|
||||
|
||||
@ -144,6 +157,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
await websocket.send_json({
|
||||
"type": "tasks_list",
|
||||
"tasks": tasks,
|
||||
"queue": get_queue_info(),
|
||||
})
|
||||
elif msg.get("action") == "get_tree":
|
||||
tree = get_processed_tree()
|
||||
@ -151,6 +165,10 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
"type": "file_tree",
|
||||
"tree": tree,
|
||||
})
|
||||
elif msg.get("action") == "rag_query":
|
||||
await _handle_rag_query(websocket, msg)
|
||||
elif msg.get("action") == "rag_query_global":
|
||||
await _handle_rag_query(websocket, msg)
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket)
|
||||
except Exception:
|
||||
@ -160,7 +178,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||
@app.get("/api/tasks")
|
||||
async def api_tasks():
|
||||
"""Возвращает список всех задач."""
|
||||
return {"tasks": get_all_tasks()}
|
||||
return {"tasks": get_all_tasks(), "queue": get_queue_info()}
|
||||
|
||||
|
||||
@app.get("/api/tasks/{task_id}")
|
||||
@ -212,5 +230,203 @@ async def api_delete_folder(folder_name: str):
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# === RAG / Chat API ===
|
||||
|
||||
@app.get("/api/rag/projects")
|
||||
async def api_rag_projects():
|
||||
"""Возвращает список проектов с RAG-индексами."""
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
projects = await get_project_names(index_dir)
|
||||
return {"projects": projects}
|
||||
except Exception as e:
|
||||
return {"error": str(e), "projects": []}
|
||||
|
||||
|
||||
@app.post("/api/rag/query")
|
||||
async def api_rag_query(payload: dict):
|
||||
"""Запрос к чат-боту по конкретному проекту."""
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = payload.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=payload.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=payload.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=payload.get("project"),
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
)
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": result["project"],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/rag/query-global")
|
||||
async def api_rag_query_global(payload: dict):
|
||||
"""Глобальный запрос ко всем проектам."""
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = payload.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=payload.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=payload.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=None,
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
)
|
||||
return {
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": None,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/rag/tasks")
|
||||
async def api_rag_tasks(project: Optional[str] = None):
|
||||
"""Возвращает action items из RAG."""
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
|
||||
question = "Перечисли все action items, задачи и ответственных из протоколов."
|
||||
if project:
|
||||
question = f"Перечисли все action items, задачи и ответственных по проекту {project}."
|
||||
|
||||
result = await rag_chat(
|
||||
question=question,
|
||||
working_dir_base=index_dir,
|
||||
history=[],
|
||||
api_key=api_key,
|
||||
project_name=project,
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode="hybrid",
|
||||
index_model=index_model,
|
||||
)
|
||||
return {
|
||||
"tasks": result["answer"],
|
||||
"project": project,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@app.post("/api/rag/index/{folder_name}")
|
||||
async def api_rag_index_folder(folder_name: str):
|
||||
"""Принудительная переиндексация папки с обработанным совещанием."""
|
||||
try:
|
||||
folder_path = PROCESSED_DIR / folder_name
|
||||
if not folder_path.exists():
|
||||
return {"error": "Folder not found"}
|
||||
|
||||
# Ищем .txt файл с протоколом
|
||||
txt_files = list(folder_path.glob("*.txt"))
|
||||
if not txt_files:
|
||||
return {"error": "No .txt protocol found in folder"}
|
||||
|
||||
txt_path = txt_files[0]
|
||||
doc_text = txt_path.read_text(encoding="utf-8")
|
||||
|
||||
# Определяем проект из имени папки
|
||||
project = parse_project_from_filename(folder_name)
|
||||
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
|
||||
from src.rag.indexer import index_meeting
|
||||
from src.rag.formatter import format_global_document
|
||||
|
||||
# Для переиндексации используем простую заглушку метаданных
|
||||
metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"}
|
||||
global_doc_text = format_global_document(doc_text, metadata)
|
||||
|
||||
await index_meeting(
|
||||
doc_text=doc_text,
|
||||
global_doc_text=global_doc_text,
|
||||
project_name=project,
|
||||
working_dir_base=index_dir,
|
||||
model=index_model,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
return {"indexed": folder_name, "project": project}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
# === WebSocket Chat Actions ===
|
||||
|
||||
async def _handle_rag_query(websocket: WebSocket, msg: dict):
|
||||
"""Обрабатывает rag_query через WebSocket."""
|
||||
try:
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
mode = msg.get("mode", "hybrid")
|
||||
|
||||
result = await rag_chat(
|
||||
question=msg.get("question", ""),
|
||||
working_dir_base=index_dir,
|
||||
history=msg.get("history", []),
|
||||
api_key=api_key,
|
||||
project_name=msg.get("project"),
|
||||
base_url=base_url,
|
||||
chat_model=chat_model,
|
||||
mode=mode,
|
||||
index_model=index_model,
|
||||
)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "rag_response",
|
||||
"answer": result["answer"],
|
||||
"context": result["context"],
|
||||
"project": result["project"],
|
||||
})
|
||||
except Exception as e:
|
||||
await websocket.send_json({
|
||||
"type": "rag_error",
|
||||
"error": str(e),
|
||||
})
|
||||
|
||||
|
||||
# Статические файлы
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
|
||||
367
backend/queue.py
367
backend/queue.py
@ -1,9 +1,10 @@
|
||||
"""Фоновая очередь обработки аудио/видео."""
|
||||
"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
@ -11,9 +12,17 @@ from typing import Any, Callable, Dict, List, Optional
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.audio_utils import prepare_audio_input
|
||||
from src.config import get_profile, load_config, resolve_hf_token
|
||||
from src.config import load_config, resolve_opencode_credentials
|
||||
from src.document import build_document
|
||||
from src.pipeline import run_pipeline
|
||||
from src.rag.formatter import (
|
||||
build_meeting_text_only,
|
||||
format_global_document,
|
||||
format_meeting_document,
|
||||
format_summary_markdown,
|
||||
)
|
||||
from src.rag.indexer import index_meeting
|
||||
from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename
|
||||
|
||||
|
||||
UPLOAD_DIR = Path("uploads")
|
||||
@ -21,21 +30,19 @@ PROCESSED_DIR = Path("processed")
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
PROCESSED_DIR.mkdir(exist_ok=True)
|
||||
|
||||
# Глобальное хранилище состояний задач
|
||||
tasks: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Callback для отправки прогресса через WebSocket
|
||||
_progress_callback: Optional[Callable] = None
|
||||
_transcribe_queue: asyncio.Queue = asyncio.Queue()
|
||||
_postprocess_queue: asyncio.Queue = asyncio.Queue()
|
||||
_workers: List[asyncio.Task] = []
|
||||
|
||||
|
||||
def set_progress_callback(callback: Callable):
|
||||
"""Устанавливает callback для отправки прогресса."""
|
||||
global _progress_callback
|
||||
_progress_callback = callback
|
||||
|
||||
|
||||
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
|
||||
"""Отправляет прогресс через callback."""
|
||||
if _progress_callback:
|
||||
try:
|
||||
task_info = tasks.get(task_id, {})
|
||||
@ -45,6 +52,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
|
||||
"message": message,
|
||||
"status": status,
|
||||
"file": task_info.get("file", ""),
|
||||
"queue_position": task_info.get("queue_position"),
|
||||
"result": result,
|
||||
"error": error,
|
||||
})
|
||||
@ -52,34 +60,47 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
|
||||
pass
|
||||
|
||||
|
||||
async def process_file(file_path: Path, task_id: str):
|
||||
"""Обрабатывает один файл и отправляет прогресс."""
|
||||
tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"status": "processing",
|
||||
"progress": 0,
|
||||
"message": "Начало обработки...",
|
||||
"file": str(file_path.name),
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started": datetime.now().isoformat(),
|
||||
def _cleanup_upload(file_path: Path):
|
||||
if not file_path.exists():
|
||||
return
|
||||
parent = file_path.parent
|
||||
if parent != UPLOAD_DIR and parent.name.startswith("task_"):
|
||||
shutil.rmtree(parent, ignore_errors=True)
|
||||
else:
|
||||
file_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
return {
|
||||
"project": project,
|
||||
"section": "Общие вопросы",
|
||||
"topic": "Не определена",
|
||||
"date": None,
|
||||
"participants": sorted({seg.get("speaker", "UNKNOWN") for seg in segments}),
|
||||
"problems": [],
|
||||
"summary": "",
|
||||
"key_decisions": [],
|
||||
"action_items": [],
|
||||
}
|
||||
|
||||
|
||||
async def process_transcription(file_path: Path, task_id: str):
|
||||
"""Этап 1: WhisperX + docx/md. После — в очередь summary/RAG."""
|
||||
display_name = tasks.get(task_id, {}).get("file", file_path.name)
|
||||
tasks[task_id].update({
|
||||
"status": "processing",
|
||||
"progress": 0,
|
||||
"message": "Транскрибация...",
|
||||
"queue_position": None,
|
||||
})
|
||||
await _send_progress(task_id, 5, "Извлечение аудио...", "processing")
|
||||
|
||||
try:
|
||||
# Загружаем конфиг
|
||||
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(
|
||||
run_pipeline,
|
||||
input_path=str(file_path),
|
||||
@ -87,41 +108,56 @@ async def process_file(file_path: Path, task_id: str):
|
||||
config_path=None,
|
||||
)
|
||||
|
||||
await _send_progress(task_id, 75, "Генерация документов...", "processing")
|
||||
await _send_progress(task_id, 65, "Сохранение протокола...", "processing")
|
||||
|
||||
# Определяем имена выходных файлов (уникальная папка с timestamp)
|
||||
stem = file_path.stem
|
||||
stem = Path(display_name).stem
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
folder_name = f"{stem}_{timestamp}"
|
||||
output_dir = PROCESSED_DIR / folder_name
|
||||
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
|
||||
|
||||
# Сохраняем docx и md (в отдельном потоке)
|
||||
docx_path = str(output_dir / f"{stem}.docx")
|
||||
md_path = str(output_dir / f"{stem}.md")
|
||||
docx_path = output_dir / f"{stem}.docx"
|
||||
md_path = output_dir / f"{stem}.md"
|
||||
segments_path = output_dir / f"{stem}_segments.json"
|
||||
|
||||
await asyncio.to_thread(build_document, result["segments"], docx_path, config)
|
||||
await asyncio.to_thread(build_document, result["segments"], md_path, config)
|
||||
await asyncio.to_thread(build_document, result["segments"], str(docx_path), config)
|
||||
await asyncio.to_thread(build_document, result["segments"], str(md_path), config)
|
||||
await asyncio.to_thread(
|
||||
segments_path.write_text,
|
||||
json.dumps(result["segments"], ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Удаляем исходник из uploads после обработки
|
||||
if file_path.exists():
|
||||
await asyncio.to_thread(file_path.unlink)
|
||||
await asyncio.to_thread(_cleanup_upload, file_path)
|
||||
|
||||
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),
|
||||
"md": str(md_path),
|
||||
"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:
|
||||
error_msg = str(e)
|
||||
@ -131,88 +167,251 @@ async def process_file(file_path: Path, task_id: str):
|
||||
"progress": 0,
|
||||
"message": f"Ошибка: {error_msg}",
|
||||
"error": error_msg,
|
||||
"queue_position": None,
|
||||
})
|
||||
|
||||
|
||||
# Очередь задач
|
||||
_queue: asyncio.Queue = asyncio.Queue()
|
||||
_workers: List[asyncio.Task] = []
|
||||
async def process_postprocessing(job: Dict[str, Any]):
|
||||
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
|
||||
task_id = job["task_id"]
|
||||
display_name = job["display_name"]
|
||||
stem = job["stem"]
|
||||
output_dir = Path(job["output_dir"])
|
||||
|
||||
summary_path = output_dir / f"{stem}_summary.md"
|
||||
txt_path = output_dir / f"{stem}.txt"
|
||||
|
||||
tasks[task_id].update({
|
||||
"status": "postprocessing",
|
||||
"message": "Summary и индексация...",
|
||||
})
|
||||
await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing")
|
||||
|
||||
result_data = dict(tasks[task_id].get("result") or {})
|
||||
|
||||
try:
|
||||
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
|
||||
config = load_config()
|
||||
rag_cfg = config.get("rag", {})
|
||||
api_key, base_url = resolve_opencode_credentials(config)
|
||||
project = parse_project_from_filename(display_name)
|
||||
meeting_text = build_meeting_text_only(segments)
|
||||
metadata = _default_metadata(project, segments)
|
||||
|
||||
if api_key:
|
||||
try:
|
||||
sections = rag_cfg.get("sections", ["Общие вопросы"])
|
||||
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
|
||||
summary_model = rag_cfg.get("summary_model", "deepseek-v4-flash-free")
|
||||
summary_auto = rag_cfg.get("summary_auto", True)
|
||||
classify_chunk_size = int(rag_cfg.get("classify_chunk_size", 7000))
|
||||
summary_chunk_size = int(rag_cfg.get("summary_chunk_size", 10000))
|
||||
|
||||
await _send_progress(task_id, 80, "Анализ совещания...", "postprocessing")
|
||||
metadata = await classify_meeting(
|
||||
text=meeting_text,
|
||||
project=project,
|
||||
sections=sections,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=index_model,
|
||||
chunk_size=classify_chunk_size,
|
||||
)
|
||||
|
||||
if summary_auto:
|
||||
await _send_progress(task_id, 85, "Формирование краткого содержания...", "postprocessing")
|
||||
brief = await generate_meeting_brief(
|
||||
text=meeting_text,
|
||||
metadata=metadata,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=summary_model,
|
||||
chunk_size=summary_chunk_size,
|
||||
)
|
||||
summary_md = format_summary_markdown(metadata, brief, display_name)
|
||||
await asyncio.to_thread(
|
||||
summary_path.write_text,
|
||||
summary_md,
|
||||
encoding="utf-8",
|
||||
)
|
||||
result_data["summary"] = str(summary_path)
|
||||
|
||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
||||
result_data["txt"] = str(txt_path)
|
||||
result_data["metadata"] = metadata
|
||||
result_data["project"] = project
|
||||
|
||||
if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True):
|
||||
await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing")
|
||||
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
|
||||
global_doc_text = format_global_document(doc_text, metadata)
|
||||
await index_meeting(
|
||||
doc_text=doc_text,
|
||||
global_doc_text=global_doc_text,
|
||||
project_name=project,
|
||||
working_dir_base=index_dir,
|
||||
model=index_model,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Postprocess Warning] {task_id}: {e}")
|
||||
if not result_data.get("txt"):
|
||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
||||
result_data["txt"] = str(txt_path)
|
||||
else:
|
||||
doc_text = format_meeting_document(segments, metadata, display_name)
|
||||
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
|
||||
result_data["txt"] = str(txt_path)
|
||||
|
||||
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
|
||||
tasks[task_id].update({
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"message": "Обработка завершена",
|
||||
"result": result_data,
|
||||
"finished": datetime.now().isoformat(),
|
||||
"queue_position": None,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
await _send_progress(task_id, 70, f"Ошибка summary/RAG: {error_msg}", "error", error=error_msg)
|
||||
tasks[task_id].update({
|
||||
"status": "error",
|
||||
"message": f"Ошибка summary/RAG: {error_msg}",
|
||||
"error": error_msg,
|
||||
})
|
||||
|
||||
|
||||
async def _worker_loop():
|
||||
"""Рабочий цикл обработки."""
|
||||
print("[Worker] Рабочий процесс запущен и ждёт задачи...")
|
||||
async def _transcribe_worker_loop(worker_id: int):
|
||||
print(f"[Transcribe Worker {worker_id}] запущен")
|
||||
while True:
|
||||
try:
|
||||
task_id, file_path = await _queue.get()
|
||||
print(f"[Worker] Получена задача: {task_id}")
|
||||
await process_file(file_path, task_id)
|
||||
_queue.task_done()
|
||||
print(f"[Worker] Задача завершена: {task_id}")
|
||||
task_id, file_path = await _transcribe_queue.get()
|
||||
print(f"[Transcribe Worker {worker_id}] задача {task_id}")
|
||||
await process_transcription(file_path, task_id)
|
||||
_transcribe_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
print("[Worker] Остановка рабочего процесса")
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[Worker Error] {e}")
|
||||
print(f"[Transcribe Worker {worker_id} Error] {e}")
|
||||
|
||||
|
||||
def start_workers(num_workers: int = 1):
|
||||
"""Запускает рабочих в текущем event loop."""
|
||||
async def _postprocess_worker_loop(worker_id: int):
|
||||
print(f"[Postprocess Worker {worker_id}] запущен")
|
||||
while True:
|
||||
try:
|
||||
job = await _postprocess_queue.get()
|
||||
print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}")
|
||||
await process_postprocessing(job)
|
||||
_postprocess_queue.task_done()
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"[Postprocess Worker {worker_id} Error] {e}")
|
||||
|
||||
|
||||
def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1):
|
||||
"""Запускает пулы воркеров транскрибации и post-processing."""
|
||||
global _workers
|
||||
for i in range(num_workers):
|
||||
task = asyncio.create_task(_worker_loop())
|
||||
_workers.append(task)
|
||||
_workers.clear()
|
||||
for i in range(transcribe_workers):
|
||||
_workers.append(asyncio.create_task(_transcribe_worker_loop(i + 1)))
|
||||
for i in range(postprocess_workers):
|
||||
_workers.append(asyncio.create_task(_postprocess_worker_loop(i + 1)))
|
||||
print(f"[Queue] transcribe_workers={transcribe_workers}, postprocess_workers={postprocess_workers}")
|
||||
|
||||
|
||||
def stop_workers():
|
||||
"""Останавливает рабочих."""
|
||||
for w in _workers:
|
||||
w.cancel()
|
||||
|
||||
|
||||
async def enqueue(file_path: Path) -> str:
|
||||
"""Добавляет файл в очередь."""
|
||||
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file_path.stem}"
|
||||
async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
|
||||
safe_name = Path(filename).name or "upload.bin"
|
||||
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
|
||||
task_dir = UPLOAD_DIR / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = task_dir / safe_name
|
||||
|
||||
await asyncio.to_thread(file_path.write_bytes, content)
|
||||
|
||||
queue_position = _transcribe_queue.qsize() + 1
|
||||
tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"message": "В очереди...",
|
||||
"file": str(file_path.name),
|
||||
"message": f"В очереди транскрибации (№{queue_position})",
|
||||
"file": safe_name,
|
||||
"queue_position": queue_position,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started": datetime.now().isoformat(),
|
||||
}
|
||||
await _queue.put((task_id, file_path))
|
||||
return task_id
|
||||
|
||||
await _transcribe_queue.put((task_id, file_path))
|
||||
await _send_progress(task_id, 0, f"В очереди транскрибации (№{queue_position})", "queued")
|
||||
return task_id, file_path
|
||||
|
||||
|
||||
def get_queue_info() -> Dict[str, Any]:
|
||||
queued = sum(1 for t in tasks.values() if t.get("status") == "queued")
|
||||
processing = sum(1 for t in tasks.values() if t.get("status") == "processing")
|
||||
postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing")
|
||||
return {
|
||||
"queued": queued,
|
||||
"processing": processing,
|
||||
"postprocessing": postprocessing,
|
||||
"pending_transcribe": _transcribe_queue.qsize(),
|
||||
"pending_postprocess": _postprocess_queue.qsize(),
|
||||
"pending_in_queue": _transcribe_queue.qsize(),
|
||||
}
|
||||
|
||||
|
||||
def get_task_status(task_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Возвращает статус задачи."""
|
||||
return tasks.get(task_id)
|
||||
|
||||
|
||||
def get_all_tasks() -> List[Dict[str, Any]]:
|
||||
"""Возвращает все задачи."""
|
||||
return list(tasks.values())
|
||||
|
||||
|
||||
def _file_sort_key(file_info: Dict[str, Any]) -> tuple:
|
||||
name = file_info.get("name", "").lower()
|
||||
if "_summary." in name:
|
||||
return (0, name)
|
||||
if name.endswith(".md") and "_summary" not in name:
|
||||
return (1, name)
|
||||
if name.endswith(".txt"):
|
||||
return (2, name)
|
||||
if name.endswith(".docx"):
|
||||
return (3, name)
|
||||
return (4, name)
|
||||
|
||||
|
||||
def get_processed_tree() -> List[Dict[str, Any]]:
|
||||
"""Возвращает дерево обработанных файлов."""
|
||||
tree = []
|
||||
if not PROCESSED_DIR.exists():
|
||||
return tree
|
||||
|
||||
skip_names = {"lightrag_caches", "lightrag_caches_test"}
|
||||
skip_suffixes = ("_segments.json",)
|
||||
|
||||
for item in sorted(PROCESSED_DIR.iterdir()):
|
||||
if item.is_dir():
|
||||
if not item.is_dir() or item.name in skip_names:
|
||||
continue
|
||||
files = []
|
||||
for f in sorted(item.iterdir()):
|
||||
if f.is_file():
|
||||
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
|
||||
if f.is_file() and not f.name.endswith(skip_suffixes):
|
||||
files.append({
|
||||
"name": f.name,
|
||||
"path": str(f.relative_to(PROCESSED_DIR)),
|
||||
"size": f.stat().st_size,
|
||||
"ext": f.suffix.lower(),
|
||||
"kind": "summary" if "_summary" in f.name else "protocol",
|
||||
})
|
||||
tree.append({
|
||||
"name": item.name,
|
||||
@ -224,10 +423,8 @@ def get_processed_tree() -> List[Dict[str, Any]]:
|
||||
|
||||
|
||||
def read_file_content(rel_path: str) -> str:
|
||||
"""Читает содержимое файла."""
|
||||
full_path = PROCESSED_DIR / rel_path
|
||||
if not full_path.exists() or not full_path.is_file():
|
||||
raise FileNotFoundError(f"Файл не найден: {rel_path}")
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
@ -8,12 +8,17 @@ class TranscriptionApp {
|
||||
this.tasks = new Map();
|
||||
this.currentFile = null;
|
||||
this.renderTimeout = null;
|
||||
this.chatHistory = [];
|
||||
this.chatProjects = [];
|
||||
this.isChatThinking = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.connectWebSocket();
|
||||
this.setupUpload();
|
||||
this.setupChat();
|
||||
this.loadChatHistory();
|
||||
}
|
||||
|
||||
// ===== WebSocket =====
|
||||
@ -60,8 +65,13 @@ class TranscriptionApp {
|
||||
handleWebSocketMessage(data) {
|
||||
if (data.type === 'tasks_list') {
|
||||
this.updateTasks(data.tasks);
|
||||
this.updateQueueSummary(data.queue);
|
||||
} else if (data.type === 'file_tree') {
|
||||
this.renderFileTree(data.tree);
|
||||
} else if (data.type === 'rag_response') {
|
||||
this.handleChatResponse(data);
|
||||
} else if (data.type === 'rag_error') {
|
||||
this.handleChatError(data.error);
|
||||
} else if (data.task_id) {
|
||||
// Прогресс обработки
|
||||
this.updateTaskProgress(data);
|
||||
@ -134,11 +144,21 @@ class TranscriptionApp {
|
||||
if (result.error) {
|
||||
this.showToast(`Ошибка: ${result.error}`, 'error');
|
||||
} else {
|
||||
this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success');
|
||||
const queueInfo = result.queue || {};
|
||||
const queued = queueInfo.pending_in_queue ?? result.uploaded;
|
||||
this.showToast(
|
||||
`${result.uploaded} файл(ов) в очереди. В ожидании: ${queued}`,
|
||||
'success'
|
||||
);
|
||||
result.tasks.forEach(task => {
|
||||
this.tasks.set(task.task_id, task);
|
||||
this.tasks.set(task.task_id, {
|
||||
...task,
|
||||
message: task.message || 'В очереди...',
|
||||
progress: 0,
|
||||
});
|
||||
});
|
||||
this.renderTasks();
|
||||
this.updateQueueSummary(result.queue);
|
||||
}
|
||||
} catch (error) {
|
||||
this.showToast(`Ошибка загрузки: ${error.message}`, 'error');
|
||||
@ -153,6 +173,17 @@ class TranscriptionApp {
|
||||
this.renderTasks();
|
||||
}
|
||||
|
||||
updateQueueSummary(queue) {
|
||||
const el = document.getElementById('queueSummary');
|
||||
if (!el || !queue) return;
|
||||
const parts = [];
|
||||
if (queue.processing) parts.push(`транскрибация: ${queue.processing}`);
|
||||
if (queue.pending_transcribe) parts.push(`в очереди ASR: ${queue.pending_transcribe}`);
|
||||
if (queue.postprocessing) parts.push(`summary/RAG: ${queue.postprocessing}`);
|
||||
if (queue.pending_postprocess) parts.push(`в очереди post: ${queue.pending_postprocess}`);
|
||||
el.textContent = parts.length ? parts.join(' · ') : 'очередь пуста';
|
||||
}
|
||||
|
||||
updateTaskProgress(data) {
|
||||
const existing = Array.from(this.tasks.values()).find(t => t.task_id === data.task_id);
|
||||
if (existing) {
|
||||
@ -163,7 +194,9 @@ class TranscriptionApp {
|
||||
this.renderTasks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
this.showToast(`Готово: ${data.message}`, 'success');
|
||||
this.showToast(`Готово: ${data.file || data.message}`, 'success');
|
||||
this.requestTree();
|
||||
} else if (data.status === 'postprocessing' && data.progress === 70) {
|
||||
this.requestTree();
|
||||
} else if (data.status === 'error') {
|
||||
this.showToast(`Ошибка: ${data.message}`, 'error');
|
||||
@ -174,7 +207,10 @@ class TranscriptionApp {
|
||||
if (this.renderTimeout) clearTimeout(this.renderTimeout);
|
||||
this.renderTimeout = setTimeout(() => {
|
||||
const container = document.getElementById('tasksList');
|
||||
const tasks = Array.from(this.tasks.values());
|
||||
const tasks = Array.from(this.tasks.values()).sort((a, b) => {
|
||||
const order = { processing: 0, postprocessing: 1, queued: 2, completed: 3, error: 4 };
|
||||
return (order[a.status] ?? 9) - (order[b.status] ?? 9);
|
||||
});
|
||||
|
||||
if (tasks.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state">Нет активных задач</p>';
|
||||
@ -189,7 +225,8 @@ class TranscriptionApp {
|
||||
const progress = task.progress || 0;
|
||||
const statusClass = task.status === 'completed' ? 'success' :
|
||||
task.status === 'error' ? 'error' :
|
||||
task.status === 'processing' ? 'processing' : 'queued';
|
||||
task.status === 'processing' ? 'processing' :
|
||||
task.status === 'postprocessing' ? 'postprocessing' : 'queued';
|
||||
|
||||
return `
|
||||
<div class="task-item ${statusClass}">
|
||||
@ -208,7 +245,8 @@ class TranscriptionApp {
|
||||
getStatusLabel(status) {
|
||||
const labels = {
|
||||
'queued': 'В очереди',
|
||||
'processing': 'Обработка',
|
||||
'processing': 'Транскрибация',
|
||||
'postprocessing': 'Summary/RAG',
|
||||
'completed': 'Готово',
|
||||
'error': 'Ошибка',
|
||||
};
|
||||
@ -230,10 +268,13 @@ class TranscriptionApp {
|
||||
|
||||
renderFolder(folder) {
|
||||
const files = folder.files.map(file => {
|
||||
const isSummary = file.kind === 'summary' || file.name.includes('_summary');
|
||||
const isMd = file.ext === '.md';
|
||||
const isDocx = file.ext === '.docx';
|
||||
const icon = isMd ? '📝' : isDocx ? '📄' : '📎';
|
||||
const isTxt = file.ext === '.txt';
|
||||
const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎';
|
||||
const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`;
|
||||
const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
|
||||
|
||||
if (isDocx) {
|
||||
return `
|
||||
@ -246,7 +287,7 @@ class TranscriptionApp {
|
||||
}
|
||||
|
||||
return `
|
||||
<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-name">${this.escapeHtml(file.name)}</span>
|
||||
<span class="file-size">${this.formatBytes(file.size)}</span>
|
||||
@ -347,7 +388,7 @@ class TranscriptionApp {
|
||||
async loadFileContent(path, ext) {
|
||||
const viewer = document.getElementById('viewer');
|
||||
|
||||
if (ext === '.md') {
|
||||
if (ext === '.md' || ext === '.txt') {
|
||||
try {
|
||||
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||
const result = await response.json();
|
||||
@ -357,8 +398,10 @@ class TranscriptionApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Render markdown
|
||||
const html = marked.parse(result.content);
|
||||
const bodyHtml = ext === '.md'
|
||||
? marked.parse(result.content)
|
||||
: `<pre class="txt-content">${this.escapeHtml(result.content)}</pre>`;
|
||||
|
||||
viewer.innerHTML = `
|
||||
<div class="md-content">
|
||||
<div class="md-header">
|
||||
@ -366,7 +409,7 @@ class TranscriptionApp {
|
||||
<a href="/api/files/download?path=${encodeURIComponent(path)}"
|
||||
class="btn-download" download>⬇️ Скачать</a>
|
||||
</div>
|
||||
<div class="md-body">${html}</div>
|
||||
<div class="md-body">${bodyHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
this.currentFile = path;
|
||||
@ -420,6 +463,161 @@ class TranscriptionApp {
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Chat =====
|
||||
setupChat() {
|
||||
const input = document.getElementById('chatInput');
|
||||
const sendBtn = document.getElementById('chatSendBtn');
|
||||
const clearBtn = document.getElementById('chatClearBtn');
|
||||
|
||||
sendBtn.addEventListener('click', () => this.sendChatMessage());
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') this.sendChatMessage();
|
||||
});
|
||||
clearBtn.addEventListener('click', () => this.clearChatHistory());
|
||||
|
||||
this.loadChatProjects();
|
||||
}
|
||||
|
||||
async loadChatProjects() {
|
||||
try {
|
||||
const response = await fetch('/api/rag/projects');
|
||||
const result = await response.json();
|
||||
const select = document.getElementById('chatProjectSelect');
|
||||
select.innerHTML = '<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
|
||||
|
||||
@ -34,7 +34,10 @@
|
||||
|
||||
<!-- Queue Status -->
|
||||
<div class="queue-status" id="queueStatus">
|
||||
<div class="queue-status-header">
|
||||
<h3>Очередь обработки</h3>
|
||||
<span class="queue-summary" id="queueSummary">очередь пуста</span>
|
||||
</div>
|
||||
<div class="tasks-list" id="tasksList">
|
||||
<p class="empty-state">Нет активных задач</p>
|
||||
</div>
|
||||
@ -59,6 +62,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -109,11 +109,24 @@ header h1 {
|
||||
}
|
||||
|
||||
.queue-status h3 {
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.queue-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tasks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -138,6 +151,13 @@ header h1 {
|
||||
.task-item.success { border-left-color: var(--success); }
|
||||
.task-item.error { border-left-color: var(--error); }
|
||||
.task-item.processing { border-left-color: var(--warning); }
|
||||
.task-item.postprocessing { border-left-color: #8b5cf6; }
|
||||
.task-item.queued { border-left-color: var(--text-secondary); opacity: 0.95; }
|
||||
|
||||
.task-status-badge.postprocessing {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
@ -299,6 +319,22 @@ header h1 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-item.file-summary {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.file-item.file-summary:hover {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
.txt-content {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
@ -472,6 +508,170 @@ header h1 {
|
||||
to { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* ===== Chat Section ===== */
|
||||
.chat-section {
|
||||
margin-top: 30px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-header h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#chatProjectSelect {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#chatClearBtn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#chatClearBtn:hover {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.chat-welcome {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.chat-welcome p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.chat-hint {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
max-width: 80%;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-bubble.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chat-bubble.bot {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-bubble .bubble-sources {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chat-bubble.bot .bubble-sources {
|
||||
border-top-color: var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chat-thinking {
|
||||
align-self: flex-start;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#chatInput {
|
||||
flex: 1;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#chatInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
#chatSendBtn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 20px;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#chatSendBtn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
#chatSendBtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.results-section {
|
||||
@ -485,4 +685,10 @@ header h1 {
|
||||
.drop-zone {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
29
config.yaml
29
config.yaml
@ -42,6 +42,35 @@ output:
|
||||
speaker_label_style: name # name | id | none
|
||||
paragraph_pause_sec: 2.0 # новый абзац, если пауза > N секунд
|
||||
|
||||
# Настройки RAG (LightRAG для протоколов совещаний)
|
||||
rag:
|
||||
enabled: true
|
||||
auto_index: true
|
||||
sections:
|
||||
- Планировка
|
||||
- Конструкции
|
||||
- MEP
|
||||
- Отделка
|
||||
- Общие вопросы
|
||||
- Согласование
|
||||
- Контроль качества
|
||||
index_backend: opencode
|
||||
index_model: mimo-v2.5-free
|
||||
chat_backend: opencode
|
||||
chat_model: deepseek-v4-flash-free
|
||||
summary_auto: true
|
||||
summary_model: deepseek-v4-flash-free
|
||||
summary_chunk_size: 10000
|
||||
classify_chunk_size: 7000
|
||||
opencode_api_key: "sk-4jJBUMS7WJyBOtZZAexsSy6aT4NKOYp2gA19WLlaux8jHMw0HvyCl1V45Jf8SONz" # Или через env: OPENCODE_API_KEY
|
||||
opencode_url: "https://opencode.ai/zen/v1"
|
||||
project_index_dir: ./processed/lightrag_caches
|
||||
|
||||
# Очередь обработки
|
||||
queue:
|
||||
transcribe_workers: 2
|
||||
postprocess_workers: 1
|
||||
|
||||
# Пути
|
||||
paths:
|
||||
output_dir: ./output
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
services:
|
||||
transcription:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.rag
|
||||
image: transcription-transcription:latest
|
||||
container_name: transcription_service
|
||||
ports:
|
||||
@ -9,14 +12,21 @@ services:
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- HF_TOKEN=${HF_TOKEN}
|
||||
- OPENCODE_API_KEY=${OPENCODE_API_KEY}
|
||||
- OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1}
|
||||
- HF_HOME=/root/.cache/huggingface
|
||||
- NLTK_DATA=/root/nltk_data
|
||||
- TRANSFORMERS_OFFLINE=0
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- processed:/app/processed
|
||||
- tmp:/app/tmp
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./backend:/app/backend:ro
|
||||
- ./src:/app/src:ro
|
||||
- ./scripts:/app/scripts:ro
|
||||
- nltk_data:/root/nltk_data
|
||||
- ./models/huggingface:/root/.cache/huggingface
|
||||
- ./models/nltk_data:/root/nltk_data:ro
|
||||
restart: unless-stopped
|
||||
entrypoint: ["uvicorn"]
|
||||
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@ -31,4 +41,3 @@ volumes:
|
||||
uploads:
|
||||
processed:
|
||||
tmp:
|
||||
nltk_data:
|
||||
|
||||
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
|
||||
pyyaml
|
||||
whisperx
|
||||
|
||||
# RAG / LightRAG
|
||||
lightrag-hku>=1.4.0
|
||||
openai>=1.0.0
|
||||
python-dotenv>=1.0.0
|
||||
sentence-transformers>=3.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
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:
|
||||
token = os.environ.get("HF_TOKEN")
|
||||
return token
|
||||
|
||||
|
||||
def resolve_opencode_credentials(config: Dict[str, Any] | None = None) -> tuple[str, str]:
|
||||
"""Возвращает (api_key, base_url) для OpenCode/DeepSeek API."""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
rag = config.get("rag", {})
|
||||
api_key = rag.get("opencode_api_key") or os.environ.get("OPENCODE_API_KEY", "")
|
||||
base_url = rag.get("opencode_url") or os.environ.get(
|
||||
"OPENCODE_URL", "https://opencode.ai/zen/v1"
|
||||
)
|
||||
return api_key, base_url
|
||||
|
||||
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