Compare commits
No commits in common. "e9f5b80e237f48e4878ec0ce59937d96b37f7db6" and "6206e24af049889ab034dd8b4d9a3a06f3fe9a76" have entirely different histories.
e9f5b80e23
...
6206e24af0
@ -31,7 +31,6 @@ uploads/
|
|||||||
processed/
|
processed/
|
||||||
tmp/
|
tmp/
|
||||||
output/
|
output/
|
||||||
models/
|
|
||||||
video/
|
video/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.wav
|
*.wav
|
||||||
|
|||||||
2
.env
2
.env
@ -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
|
|
||||||
|
|||||||
@ -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 . .
|
||||||
|
|||||||
@ -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
|
|
||||||
254
backend/main.py
254
backend/main.py
@ -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,28 +89,34 @@ 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,
|
||||||
@ -137,8 +126,6 @@ async def upload_batch(files: List[UploadFile] = File(...)):
|
|||||||
return {
|
return {
|
||||||
"uploaded": len(results),
|
"uploaded": len(results),
|
||||||
"tasks": results,
|
"tasks": results,
|
||||||
"queue": get_queue_info(),
|
|
||||||
"message": f"{len(results)} файл(ов) добавлено в очередь",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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")
|
||||||
|
|||||||
397
backend/queue.py
397
backend/queue.py
@ -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,47 +52,34 @@ 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),
|
||||||
@ -108,56 +87,41 @@ async def process_transcription(file_path: Path, task_id: str):
|
|||||||
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,
|
|
||||||
json.dumps(result["segments"], ensure_ascii=False),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.to_thread(_cleanup_upload, file_path)
|
# Удаляем исходник из uploads после обработки
|
||||||
|
if file_path.exists():
|
||||||
|
await asyncio.to_thread(file_path.unlink)
|
||||||
|
|
||||||
|
result_data = {
|
||||||
|
"docx": str(docx_path),
|
||||||
|
"md": str(md_path),
|
||||||
|
"dir": str(output_dir),
|
||||||
|
}
|
||||||
|
|
||||||
|
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
|
||||||
|
|
||||||
post_position = _postprocess_queue.qsize() + 1
|
|
||||||
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)
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
29
config.yaml
29
config.yaml
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
11
pip.conf
11
pip.conf
@ -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
|
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
|
|||||||
@ -1 +0,0 @@
|
|||||||
"""RAG core package for meeting transcription."""
|
|
||||||
@ -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)
|
|
||||||
@ -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)
|
|
||||||
@ -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 = """Ты — секретарь совещания строительной компании.
|
|
||||||
|
|
||||||
Напиши краткое содержание совещания ОДНИМ связным абзацем на русском (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
154
src/rag/query.py
@ -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,
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user