Compare commits

..

No commits in common. "e9f5b80e237f48e4878ec0ce59937d96b37f7db6" and "6206e24af049889ab034dd8b4d9a3a06f3fe9a76" have entirely different histories.

20 changed files with 149 additions and 1919 deletions

View File

@ -31,7 +31,6 @@ uploads/
processed/ processed/
tmp/ tmp/
output/ output/
models/
video/ video/
*.mp4 *.mp4
*.wav *.wav

2
.env
View File

@ -1,3 +1 @@
HF_TOKEN=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 HF_TOKEN=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
OPENCODE_API_KEY=sk-4jJBUMS7WJyBOtZZAexsSy6aT4NKOYp2gA19WLlaux8jHMw0HvyCl1V45Jf8SONz
OPENCODE_URL=https://opencode.ai/zen/v1

View File

@ -12,8 +12,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
# Копируем зависимости # Копируем зависимости
COPY requirements.txt pip.conf ./ COPY requirements.txt .
RUN pip install --no-cache-dir --timeout 300 -r requirements.txt RUN pip install --no-cache-dir --timeout 300 -i https://mirrors.aliyun.com/pypi/simple/ -r requirements.txt
# Копируем код проекта # Копируем код проекта
COPY . . COPY . .

View File

@ -1,9 +0,0 @@
# Быстрое обновление: добавляет 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

View File

@ -1,11 +1,9 @@
"""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 Any, Dict, List, Optional from typing import 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
@ -15,9 +13,8 @@ from fastapi.staticfiles import StaticFiles
from backend.queue import ( from backend.queue import (
UPLOAD_DIR, UPLOAD_DIR,
PROCESSED_DIR, PROCESSED_DIR,
save_upload, enqueue,
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,
@ -26,13 +23,6 @@ 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):
@ -62,15 +52,8 @@ 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( start_workers(num_workers=1)
transcribe_workers=transcribe_workers,
postprocess_workers=postprocess_workers,
)
yield yield
print("🛑 Остановка рабочих процессов...") print("🛑 Остановка рабочих процессов...")
stop_workers() stop_workers()
@ -106,39 +89,43 @@ async def root():
@app.post("/upload") @app.post("/upload")
async def upload_file(file: UploadFile = File(...)): async def upload_file(file: UploadFile = File(...)):
"""Загружает файл и добавляет в очередь обработки.""" """Загружает файл и добавляет в очередь обработки."""
content = await file.read() # Сохраняем файл
task_id, _ = await save_upload(content, file.filename or "upload.bin") 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)
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:
content = await file.read() file_path = UPLOAD_DIR / file.filename
task_id, _ = await save_upload(content, file.filename or "upload.bin") with open(file_path, "wb") as f:
content = await file.read()
f.write(content)
task_id = await enqueue(file_path)
results.append({ results.append({
"task_id": task_id, "task_id": task_id,
"file": file.filename, "file": file.filename,
"status": "queued", "status": "queued",
}) })
return { return {
"uploaded": len(results), "uploaded": len(results),
"tasks": results, "tasks": results,
"queue": get_queue_info(),
"message": f"{len(results)} файл(ов) добавлено в очередь",
} }
@ -157,7 +144,6 @@ 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()
@ -165,10 +151,6 @@ 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:
@ -178,7 +160,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(), "queue": get_queue_info()} return {"tasks": get_all_tasks()}
@app.get("/api/tasks/{task_id}") @app.get("/api/tasks/{task_id}")
@ -230,203 +212,5 @@ 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")

View File

@ -1,10 +1,9 @@
"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены.""" """Фоновая очередь обработки аудио/видео."""
import asyncio import asyncio
import json import json
import shutil import os
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
@ -12,17 +11,9 @@ 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 load_config, resolve_opencode_credentials from src.config import get_profile, load_config, resolve_hf_token
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")
@ -30,19 +21,21 @@ 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, {})
@ -52,7 +45,6 @@ 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,
}) })
@ -60,105 +52,77 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
pass pass
def _cleanup_upload(file_path: Path): async def process_file(file_path: Path, task_id: str):
if not file_path.exists(): """Обрабатывает один файл и отправляет прогресс."""
return tasks[task_id] = {
parent = file_path.parent "task_id": task_id,
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", "status": "processing",
"progress": 0, "progress": 0,
"message": "Транскрибация...", "message": "Начало обработки...",
"queue_position": None, "file": str(file_path.name),
}) "result": None,
"error": None,
"started": datetime.now().isoformat(),
}
await _send_progress(task_id, 5, "Извлечение аудио...", "processing") await _send_progress(task_id, 5, "Извлечение аудио...", "processing")
try: try:
# Загружаем конфиг
config = load_config() config = load_config()
await _send_progress(task_id, 15, "Загрузка моделей Whisper...", "processing") profile = get_profile(config)
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),
profile_name=None, profile_name=None,
config_path=None, config_path=None,
) )
await _send_progress(task_id, 65, "Сохранение протокола...", "processing") await _send_progress(task_id, 75, "Генерация документов...", "processing")
stem = Path(display_name).stem # Определяем имена выходных файлов (уникальная папка с timestamp)
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_path = output_dir / f"{stem}.docx" # Сохраняем docx и md (в отдельном потоке)
md_path = output_dir / f"{stem}.md" docx_path = str(output_dir / f"{stem}.docx")
segments_path = output_dir / f"{stem}_segments.json" md_path = str(output_dir / f"{stem}.md")
await asyncio.to_thread(build_document, result["segments"], str(docx_path), config) await asyncio.to_thread(build_document, result["segments"], docx_path, config)
await asyncio.to_thread(build_document, result["segments"], str(md_path), config) await asyncio.to_thread(build_document, result["segments"], md_path, config)
await asyncio.to_thread(
segments_path.write_text, # Удаляем исходник из uploads после обработки
json.dumps(result["segments"], ensure_ascii=False), if file_path.exists():
encoding="utf-8", await asyncio.to_thread(file_path.unlink)
)
result_data = {
await asyncio.to_thread(_cleanup_upload, file_path) "docx": str(docx_path),
"md": str(md_path),
post_position = _postprocess_queue.qsize() + 1 "dir": str(output_dir),
}
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
tasks[task_id].update({ tasks[task_id].update({
"status": "postprocessing", "status": "completed",
"progress": 70, "progress": 100,
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", "message": "Обработка завершена",
"result": { "result": result_data,
"docx": str(docx_path), "finished": datetime.now().isoformat(),
"md": str(md_path),
"dir": str(output_dir),
},
}) })
await _send_progress(
task_id, 70,
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
"postprocessing",
result=tasks[task_id]["result"],
)
await _postprocess_queue.put({
"task_id": task_id,
"display_name": display_name,
"output_dir": str(output_dir),
"stem": stem,
"segments_path": str(segments_path),
"docx_path": str(docx_path),
"md_path": str(md_path),
})
print(f"[Transcribe] Задача {task_id} передана в post-processing")
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
await _send_progress(task_id, 0, f"Ошибка: {error_msg}", "error", error=error_msg) await _send_progress(task_id, 0, f"Ошибка: {error_msg}", "error", error=error_msg)
@ -167,264 +131,103 @@ async def process_transcription(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]): # Очередь задач
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию.""" _queue: asyncio.Queue = asyncio.Queue()
task_id = job["task_id"] _workers: List[asyncio.Task] = []
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 _transcribe_worker_loop(worker_id: int): async def _worker_loop():
print(f"[Transcribe Worker {worker_id}] запущен") """Рабочий цикл обработки."""
print("[Worker] Рабочий процесс запущен и ждёт задачи...")
while True: while True:
try: try:
task_id, file_path = await _transcribe_queue.get() task_id, file_path = await _queue.get()
print(f"[Transcribe Worker {worker_id}] задача {task_id}") print(f"[Worker] Получена задача: {task_id}")
await process_transcription(file_path, task_id) await process_file(file_path, task_id)
_transcribe_queue.task_done() _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"[Transcribe Worker {worker_id} Error] {e}") print(f"[Worker Error] {e}")
async def _postprocess_worker_loop(worker_id: int): def start_workers(num_workers: int = 1):
print(f"[Postprocess Worker {worker_id}] запущен") """Запускает рабочих в текущем event loop."""
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
_workers.clear() for i in range(num_workers):
for i in range(transcribe_workers): task = asyncio.create_task(_worker_loop())
_workers.append(asyncio.create_task(_transcribe_worker_loop(i + 1))) _workers.append(task)
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 save_upload(content: bytes, filename: str) -> tuple[str, Path]: async def enqueue(file_path: Path) -> str:
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_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{file_path.stem}"
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": f"В очереди транскрибации (№{queue_position})", "message": "В очереди...",
"file": safe_name, "file": str(file_path.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))
await _transcribe_queue.put((task_id, file_path)) return task_id
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 not item.is_dir() or item.name in skip_names: if item.is_dir():
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({
}) "name": item.name,
tree.append({ "path": str(item.relative_to(PROCESSED_DIR)),
"name": item.name, "files": files,
"path": str(item.relative_to(PROCESSED_DIR)), "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
"files": files, })
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
})
return tree return tree
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()

View File

@ -8,17 +8,12 @@ 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 =====
@ -65,13 +60,8 @@ 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);
@ -144,21 +134,11 @@ class TranscriptionApp {
if (result.error) { if (result.error) {
this.showToast(`Ошибка: ${result.error}`, 'error'); this.showToast(`Ошибка: ${result.error}`, 'error');
} else { } else {
const queueInfo = result.queue || {}; this.showToast(`Загружено ${result.uploaded} файл(а). Начинается обработка...`, 'success');
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, { this.tasks.set(task.task_id, task);
...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');
@ -173,17 +153,6 @@ 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) {
@ -194,9 +163,7 @@ class TranscriptionApp {
this.renderTasks(); this.renderTasks();
if (data.status === 'completed') { if (data.status === 'completed') {
this.showToast(`Готово: ${data.file || data.message}`, 'success'); this.showToast(`Готово: ${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');
@ -207,10 +174,7 @@ 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()).sort((a, b) => { const tasks = Array.from(this.tasks.values());
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>';
@ -225,8 +189,7 @@ 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' : task.status === 'processing' ? 'processing' : 'queued';
task.status === 'postprocessing' ? 'postprocessing' : 'queued';
return ` return `
<div class="task-item ${statusClass}"> <div class="task-item ${statusClass}">
@ -245,8 +208,7 @@ class TranscriptionApp {
getStatusLabel(status) { getStatusLabel(status) {
const labels = { const labels = {
'queued': 'В очереди', 'queued': 'В очереди',
'processing': 'Транскрибация', 'processing': 'Обработка',
'postprocessing': 'Summary/RAG',
'completed': 'Готово', 'completed': 'Готово',
'error': 'Ошибка', 'error': 'Ошибка',
}; };
@ -268,13 +230,10 @@ 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 isTxt = file.ext === '.txt'; const icon = isMd ? '📝' : isDocx ? '📄' : '📎';
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 `
@ -287,7 +246,7 @@ class TranscriptionApp {
} }
return ` return `
<div class="${cssClass}" data-path="${this.escapeHtml(file.path)}" data-ext="${file.ext}"> <div class="file-item" 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>
@ -388,7 +347,7 @@ class TranscriptionApp {
async loadFileContent(path, ext) { async loadFileContent(path, ext) {
const viewer = document.getElementById('viewer'); const viewer = document.getElementById('viewer');
if (ext === '.md' || ext === '.txt') { if (ext === '.md') {
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();
@ -398,10 +357,8 @@ class TranscriptionApp {
return; return;
} }
const bodyHtml = ext === '.md' // Render markdown
? marked.parse(result.content) const html = 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">
@ -409,7 +366,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">${bodyHtml}</div> <div class="md-body">${html}</div>
</div> </div>
`; `;
this.currentFile = path; this.currentFile = path;
@ -463,161 +420,6 @@ 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

View File

@ -34,10 +34,7 @@
<!-- 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>
@ -62,29 +59,6 @@
</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>

View File

@ -109,24 +109,11 @@ header h1 {
} }
.queue-status h3 { .queue-status h3 {
margin-bottom: 0; margin-bottom: 15px;
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;
@ -151,13 +138,6 @@ 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;
@ -319,22 +299,6 @@ 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;
} }
@ -508,170 +472,6 @@ 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 {
@ -685,10 +485,4 @@ header h1 {
.drop-zone { .drop-zone {
padding: 20px; padding: 20px;
} }
.chat-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
} }

View File

@ -42,35 +42,6 @@ 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: "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: paths:
output_dir: ./output output_dir: ./output

View File

@ -1,8 +1,5 @@
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:
@ -12,21 +9,14 @@ 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
- ./models/huggingface:/root/.cache/huggingface - nltk_data:/root/nltk_data
- ./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"]
@ -41,3 +31,4 @@ volumes:
uploads: uploads:
processed: processed:
tmp: tmp:
nltk_data:

View File

@ -1,11 +0,0 @@
[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

View File

@ -5,10 +5,3 @@ 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

View File

@ -1,57 +0,0 @@
# Подготовка локальной папки 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"

View File

@ -31,15 +31,3 @@ 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

View File

@ -1 +0,0 @@
"""RAG core package for meeting transcription."""

View File

@ -1,145 +0,0 @@
"""Форматирование документа совещания для индексации в 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)

View File

@ -1,184 +0,0 @@
"""Индексация протоколов совещаний в 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)

View File

@ -1,306 +0,0 @@
"""Парсинг имени файла и классификация совещания через 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 = """Ты — секретарь совещания строительной компании.
Напиши краткое содержание совещания ОДНИМ связным абзацем на русском (814 предложений).
Обязательно включи: тему встречи; ключевые проблемы и вопросы; принятые решения; поручения (кто, что, срок если озвучены).
Пиши плотно и по делу, без заголовков и списков. Не выдумывай только факты из расшифровки.
Проект: {project}
Тема: {topic}
Расшифровка:
---
{text}
---
"""
CHUNK_BRIEF_PROMPT = """Ты — секретарь совещания.
Кратко опиши содержание ФРАГМЕНТА совещания (35 предложений): темы, проблемы, решения, поручения.
Только факты из текста, без заголовков.
Фрагмент {part} из {total}:
---
{text}
---
"""
REDUCE_BRIEF_PROMPT = """Ты — секретарь совещания строительной компании.
Ниже описания частей одного совещания. Объедини их в ОДИН связный абзац на русском (814 предложений).
Включи все важные проблемы, решения и поручения. Не повторяйся. Не выдумывай.
Проект: {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", "")

View File

@ -1,154 +0,0 @@
"""Запросы к 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,
}