Compare commits
10 Commits
29e3047792
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 24acac4f2d | |||
| a2ba3d8d2b | |||
| 3e15f643d6 | |||
| d2ef7785a4 | |||
| ffab9cbc8e | |||
| 106c82bdf4 | |||
| bfe52bb9f8 | |||
| 922b1b26a6 | |||
| 397b999558 | |||
| b1eafa4e0b |
@@ -2,6 +2,7 @@ FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# System dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,111 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: a7d968fceaaf
|
||||
Revises:
|
||||
Create Date: 2026-04-06 17:59:05.406839
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a7d968fceaaf'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('prompt_templates',
|
||||
sa.Column('name', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.String(length=1000), nullable=False),
|
||||
sa.Column('type', sa.Enum('client_survey', 'client_intro', 'internal', 'custom', name='templatetype'), nullable=False),
|
||||
sa.Column('system_prompt', sa.Text(), nullable=False),
|
||||
sa.Column('user_prompt', sa.Text(), nullable=False),
|
||||
sa.Column('output_schema', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('is_default', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('users',
|
||||
sa.Column('username', sa.String(length=150), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.Enum('admin', 'user', name='userrole'), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('recordings',
|
||||
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('title', sa.String(length=500), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=1000), nullable=False),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('duration', sa.Float(), nullable=True),
|
||||
sa.Column('format', sa.String(length=20), nullable=False),
|
||||
sa.Column('source', sa.Enum('web', 'telegram', 'lensa', name='recordingsource'), nullable=False),
|
||||
sa.Column('status', sa.Enum('uploaded', 'processing', 'done', 'error', name='recordingstatus'), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('transcriptions',
|
||||
sa.Column('recording_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('text', sa.Text(), nullable=False),
|
||||
sa.Column('segments', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('language', sa.String(length=10), nullable=True),
|
||||
sa.Column('whisper_model', sa.String(length=50), nullable=False),
|
||||
sa.Column('processing_time', sa.Float(), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['recording_id'], ['recordings.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('recording_id')
|
||||
)
|
||||
op.create_table('protocols',
|
||||
sa.Column('transcription_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('template_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('content', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column('raw_text', sa.Text(), nullable=False),
|
||||
sa.Column('llm_model', sa.String(length=100), nullable=False),
|
||||
sa.Column('status', sa.Enum('generating', 'done', 'error', name='protocolstatus'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['prompt_templates.id'], ),
|
||||
sa.ForeignKeyConstraint(['transcription_id'], ['transcriptions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('export_files',
|
||||
sa.Column('protocol_id', sa.Uuid(), nullable=False),
|
||||
sa.Column('format', sa.Enum('docx', 'pdf', 'md', name='exportformat'), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=1000), nullable=False),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['protocol_id'], ['protocols.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('export_files')
|
||||
op.drop_table('protocols')
|
||||
op.drop_table('transcriptions')
|
||||
op.drop_table('recordings')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_table('prompt_templates')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.export_file import ExportFile, ExportFormat
|
||||
from app.models.protocol import Protocol
|
||||
from app.models.user import User
|
||||
from app.schemas.export_file import ExportFileResponse, ExportRequest
|
||||
from app.services.export import export_docx, export_markdown, export_pdf
|
||||
|
||||
router = APIRouter(prefix="/api/exports", tags=["exports"])
|
||||
|
||||
EXPORTERS = {
|
||||
ExportFormat.md: export_markdown,
|
||||
ExportFormat.docx: export_docx,
|
||||
ExportFormat.pdf: export_pdf,
|
||||
}
|
||||
|
||||
MEDIA_TYPES = {
|
||||
ExportFormat.md: "text/markdown",
|
||||
ExportFormat.docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
ExportFormat.pdf: "application/pdf",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/protocol/{protocol_id}", response_model=ExportFileResponse, status_code=201)
|
||||
async def create_export(
|
||||
protocol_id: uuid.UUID,
|
||||
data: ExportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Protocol).where(Protocol.id == protocol_id)
|
||||
)
|
||||
protocol = result.scalar_one_or_none()
|
||||
if not protocol:
|
||||
raise HTTPException(404, "Protocol not found")
|
||||
|
||||
exporter = EXPORTERS[data.format]
|
||||
file_path = exporter(protocol.content, protocol.id)
|
||||
file_size = os.path.getsize(file_path)
|
||||
|
||||
export_file = ExportFile(
|
||||
protocol_id=protocol_id,
|
||||
format=data.format,
|
||||
file_path=file_path,
|
||||
file_size=file_size,
|
||||
)
|
||||
db.add(export_file)
|
||||
await db.commit()
|
||||
await db.refresh(export_file)
|
||||
return export_file
|
||||
|
||||
|
||||
@router.get("/{export_id}/download")
|
||||
async def download_export(
|
||||
export_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ExportFile).where(ExportFile.id == export_id)
|
||||
)
|
||||
export_file = result.scalar_one_or_none()
|
||||
if not export_file or not os.path.exists(export_file.file_path):
|
||||
raise HTTPException(404, "Export file not found")
|
||||
|
||||
return FileResponse(
|
||||
path=export_file.file_path,
|
||||
media_type=MEDIA_TYPES[export_file.format],
|
||||
filename=f"protocol.{export_file.format.value}",
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.storage import get_disk_usage
|
||||
from app.tasks.cleanup import cleanup_old_data_task
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/storage")
|
||||
async def storage_info(user: User = Depends(get_current_user)):
|
||||
return get_disk_usage()
|
||||
|
||||
|
||||
@router.post("/cleanup")
|
||||
async def trigger_cleanup(
|
||||
retention_days: int | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
):
|
||||
if user.role != UserRole.admin:
|
||||
raise HTTPException(403, "Admin access required")
|
||||
cleanup_old_data_task.delay(retention_days)
|
||||
return {"message": "Cleanup started"}
|
||||
@@ -1,13 +1,18 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.exports import router as exports_router
|
||||
from app.api.protocols import router as protocols_router
|
||||
from app.api.recordings import router as recordings_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.templates import router as templates_router
|
||||
from app.config import settings
|
||||
from app.database import async_session
|
||||
from app.messengers.telegram.bot import api_router as telegram_router, telegram_service
|
||||
from app.models.prompt_template import PromptTemplate
|
||||
from app.prompts.defaults import DEFAULT_TEMPLATES
|
||||
|
||||
@@ -25,14 +30,30 @@ async def seed_default_templates():
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await seed_default_templates()
|
||||
if settings.telegram_bot_token and settings.telegram_webhook_url.startswith("https"):
|
||||
await telegram_service.setup()
|
||||
yield
|
||||
if settings.telegram_bot_token and settings.telegram_webhook_url.startswith("https"):
|
||||
await telegram_service.shutdown()
|
||||
|
||||
|
||||
app = FastAPI(title="Meeting Protocol Service", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(recordings_router)
|
||||
app.include_router(protocols_router)
|
||||
app.include_router(templates_router)
|
||||
app.include_router(exports_router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(telegram_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class MessengerService(ABC):
|
||||
@abstractmethod
|
||||
async def setup(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_message(self, chat_id: str, text: str, **kwargs):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def send_file(self, chat_id: str, file_path: str, caption: str = ""):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def notify_progress(self, chat_id: str, status: str, progress: int | None = None):
|
||||
pass
|
||||
@@ -0,0 +1,44 @@
|
||||
import logging
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from app.messengers.base import MessengerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LensaService(MessengerService):
|
||||
"""
|
||||
Stub implementation for Lensa (IVA Technologies) messenger bot.
|
||||
|
||||
Lensa's Bot API/SDK is not publicly available.
|
||||
This stub provides the MessengerService interface so the integration
|
||||
can be implemented when SDK access is obtained from IVA Technologies.
|
||||
|
||||
To integrate:
|
||||
1. Obtain SDK/API documentation from IVA Technologies (iva-tech.ru)
|
||||
2. Implement the abstract methods below
|
||||
3. Register webhook endpoint in main.py (similar to Telegram)
|
||||
"""
|
||||
|
||||
async def setup(self):
|
||||
logger.info("Lensa bot: stub — SDK not available, skipping setup")
|
||||
|
||||
async def shutdown(self):
|
||||
logger.info("Lensa bot: stub — shutting down")
|
||||
|
||||
async def send_message(self, chat_id: str, text: str, **kwargs):
|
||||
logger.warning(f"Lensa bot: send_message not implemented (chat_id={chat_id})")
|
||||
|
||||
async def send_file(self, chat_id: str, file_path: str, caption: str = ""):
|
||||
logger.warning(f"Lensa bot: send_file not implemented (chat_id={chat_id})")
|
||||
|
||||
async def notify_progress(self, chat_id: str, status: str, progress: int | None = None):
|
||||
logger.warning(f"Lensa bot: notify_progress not implemented (chat_id={chat_id})")
|
||||
|
||||
async def feed_webhook(self, request: Request) -> Response:
|
||||
logger.warning("Lensa bot: webhook received but SDK not implemented")
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
lensa_service = LensaService()
|
||||
@@ -0,0 +1,59 @@
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler
|
||||
from fastapi import APIRouter, Request, Response
|
||||
|
||||
from app.config import settings
|
||||
from app.messengers.base import MessengerService
|
||||
from app.messengers.telegram.handlers import router as handlers_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
||||
class TelegramService(MessengerService):
|
||||
def __init__(self):
|
||||
self.bot = Bot(token=settings.telegram_bot_token)
|
||||
self.dp = Dispatcher()
|
||||
self.dp.include_router(handlers_router)
|
||||
|
||||
async def setup(self):
|
||||
if settings.telegram_webhook_url:
|
||||
await self.bot.set_webhook(
|
||||
settings.telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
|
||||
async def shutdown(self):
|
||||
await self.bot.delete_webhook()
|
||||
await self.bot.session.close()
|
||||
|
||||
async def send_message(self, chat_id: str, text: str, **kwargs):
|
||||
await self.bot.send_message(int(chat_id), text, **kwargs)
|
||||
|
||||
async def send_file(self, chat_id: str, file_path: str, caption: str = ""):
|
||||
from aiogram.types import FSInputFile
|
||||
await self.bot.send_document(
|
||||
int(chat_id),
|
||||
FSInputFile(file_path),
|
||||
caption=caption,
|
||||
)
|
||||
|
||||
async def notify_progress(self, chat_id: str, status: str, progress: int | None = None):
|
||||
text = f"⚙️ {status}"
|
||||
if progress is not None:
|
||||
bar = "▓" * (progress // 10) + "░" * (10 - progress // 10)
|
||||
text += f"\n{bar} {progress}%"
|
||||
await self.send_message(chat_id, text)
|
||||
|
||||
async def feed_webhook(self, request: Request) -> Response:
|
||||
update = await request.json()
|
||||
from aiogram.types import Update
|
||||
await self.dp.feed_update(self.bot, Update(**update))
|
||||
return Response(status_code=200)
|
||||
|
||||
|
||||
telegram_service = TelegramService()
|
||||
|
||||
|
||||
@api_router.post("/api/telegram/webhook")
|
||||
async def telegram_webhook(request: Request):
|
||||
return await telegram_service.feed_webhook(request)
|
||||
@@ -0,0 +1,276 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from aiogram import Bot, F, Router
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.messengers.telegram.keyboards import (
|
||||
protocol_actions_keyboard,
|
||||
recording_list_keyboard,
|
||||
template_choice_keyboard,
|
||||
)
|
||||
from app.models.prompt_template import PromptTemplate
|
||||
from app.models.protocol import Protocol, ProtocolStatus
|
||||
from app.models.recording import Recording, RecordingSource, RecordingStatus
|
||||
from app.models.user import User
|
||||
from app.services.auth import get_user_by_username, verify_password
|
||||
from app.services.recording import get_upload_path
|
||||
from app.tasks.transcription import transcribe_recording
|
||||
|
||||
from app.tasks.transcription import SyncSession
|
||||
|
||||
router = Router()
|
||||
|
||||
_user_sessions: dict[int, uuid.UUID] = {}
|
||||
_pending_files: dict[int, str] = {}
|
||||
|
||||
|
||||
@router.message(Command("start"))
|
||||
async def cmd_start(message: Message):
|
||||
await message.answer(
|
||||
"Добро пожаловать в Meeting Protocol Bot!\n\n"
|
||||
"Для авторизации отправьте логин и пароль в формате:\n"
|
||||
"/login username password"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("login"))
|
||||
async def cmd_login(message: Message):
|
||||
parts = (message.text or "").split(maxsplit=2)
|
||||
if len(parts) < 3:
|
||||
await message.answer("Формат: /login username password")
|
||||
return
|
||||
|
||||
username, password = parts[1], parts[2]
|
||||
with SyncSession() as db:
|
||||
from sqlalchemy import select as sync_select
|
||||
result = db.execute(sync_select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
await message.answer("❌ Неверный логин или пароль")
|
||||
return
|
||||
|
||||
_user_sessions[message.from_user.id] = user.id
|
||||
await message.answer(f"✅ Авторизация успешна! Привет, {user.username}.\n\nОтправьте аудио/видео файл для обработки.")
|
||||
|
||||
|
||||
@router.message(Command("list"))
|
||||
async def cmd_list(message: Message):
|
||||
user_id = _user_sessions.get(message.from_user.id)
|
||||
if not user_id:
|
||||
await message.answer("Сначала авторизуйтесь: /login username password")
|
||||
return
|
||||
|
||||
with SyncSession() as db:
|
||||
result = db.execute(
|
||||
select(Recording)
|
||||
.where(Recording.user_id == user_id)
|
||||
.order_by(Recording.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
recordings = result.scalars().all()
|
||||
|
||||
if not recordings:
|
||||
await message.answer("У вас пока нет записей.")
|
||||
return
|
||||
|
||||
recs = [{"id": str(r.id), "title": r.title, "status": r.status.value} for r in recordings]
|
||||
await message.answer("📋 Ваши записи:", reply_markup=recording_list_keyboard(recs))
|
||||
|
||||
|
||||
@router.message(Command("protocols"))
|
||||
async def cmd_protocols(message: Message):
|
||||
user_id = _user_sessions.get(message.from_user.id)
|
||||
if not user_id:
|
||||
await message.answer("Сначала авторизуйтесь: /login username password")
|
||||
return
|
||||
|
||||
with SyncSession() as db:
|
||||
result = db.execute(
|
||||
select(Protocol)
|
||||
.join(Protocol.transcription)
|
||||
.join(Recording)
|
||||
.where(Recording.user_id == user_id)
|
||||
.order_by(Protocol.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
protocols = result.scalars().all()
|
||||
|
||||
if not protocols:
|
||||
await message.answer("Протоколов пока нет.")
|
||||
return
|
||||
|
||||
text_parts = []
|
||||
for p in protocols:
|
||||
status_emoji = {"generating": "⚙️", "done": "✅", "error": "❌"}
|
||||
emoji = status_emoji.get(p.status.value, "")
|
||||
text_parts.append(f"{emoji} `{str(p.id)[:8]}` — {p.llm_model or 'N/A'}")
|
||||
|
||||
await message.answer(
|
||||
"📋 Ваши протоколы:\n\n" + "\n".join(text_parts) +
|
||||
"\n\nДля просмотра: /view <id>",
|
||||
parse_mode="Markdown",
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("templates"))
|
||||
async def cmd_templates(message: Message):
|
||||
with SyncSession() as db:
|
||||
result = db.execute(select(PromptTemplate))
|
||||
templates = result.scalars().all()
|
||||
|
||||
text_parts = [f"• {t.name} — {t.description}" for t in templates]
|
||||
await message.answer("📐 Доступные шаблоны:\n\n" + "\n".join(text_parts))
|
||||
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def cmd_help(message: Message):
|
||||
await message.answer(
|
||||
"📖 Команды:\n\n"
|
||||
"/start — приветствие\n"
|
||||
"/login username password — авторизация\n"
|
||||
"/list — список записей\n"
|
||||
"/protocols — список протоколов\n"
|
||||
"/templates — список шаблонов\n"
|
||||
"/view <id> — просмотр протокола\n"
|
||||
"/help — эта справка\n"
|
||||
"/logout — выход\n\n"
|
||||
"Или просто отправьте аудио/видео файл!"
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("logout"))
|
||||
async def cmd_logout(message: Message):
|
||||
_user_sessions.pop(message.from_user.id, None)
|
||||
await message.answer("Вы вышли из системы.")
|
||||
|
||||
|
||||
@router.message(F.document | F.audio | F.video | F.voice | F.video_note)
|
||||
async def handle_file(message: Message, bot: Bot):
|
||||
user_id = _user_sessions.get(message.from_user.id)
|
||||
if not user_id:
|
||||
await message.answer("Сначала авторизуйтесь: /login username password")
|
||||
return
|
||||
|
||||
if message.document:
|
||||
file_obj = message.document
|
||||
filename = file_obj.file_name or "document"
|
||||
elif message.audio:
|
||||
file_obj = message.audio
|
||||
filename = file_obj.file_name or "audio.mp3"
|
||||
elif message.video:
|
||||
file_obj = message.video
|
||||
filename = f"video_{message.video.file_unique_id}.mp4"
|
||||
elif message.voice:
|
||||
file_obj = message.voice
|
||||
filename = f"voice_{message.voice.file_unique_id}.ogg"
|
||||
elif message.video_note:
|
||||
file_obj = message.video_note
|
||||
filename = f"videonote_{message.video_note.file_unique_id}.mp4"
|
||||
else:
|
||||
return
|
||||
|
||||
if file_obj.file_size and file_obj.file_size > 50 * 1024 * 1024:
|
||||
await message.answer(
|
||||
f"⚠️ Файл слишком большой для Telegram (лимит 50 МБ).\n"
|
||||
f"Загрузите через веб-интерфейс: {settings.app_url}"
|
||||
)
|
||||
return
|
||||
|
||||
await message.answer("⏬ Загружаю файл...")
|
||||
|
||||
file = await bot.get_file(file_obj.file_id)
|
||||
file_path = get_upload_path(filename)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
await bot.download_file(file.file_path, file_path)
|
||||
|
||||
file_size = os.path.getsize(file_path)
|
||||
_pending_files[message.from_user.id] = file_path
|
||||
|
||||
with SyncSession() as db:
|
||||
result = db.execute(select(PromptTemplate))
|
||||
templates = result.scalars().all()
|
||||
|
||||
tpls = [{"id": str(t.id), "name": t.name} for t in templates]
|
||||
await message.answer(
|
||||
"📁 Файл получен! Выберите тип протокола:",
|
||||
reply_markup=template_choice_keyboard(tpls),
|
||||
)
|
||||
|
||||
_pending_files[message.from_user.id] = {
|
||||
"file_path": file_path,
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
}
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("template:"))
|
||||
async def on_template_selected(callback: CallbackQuery):
|
||||
user_id = _user_sessions.get(callback.from_user.id)
|
||||
if not user_id:
|
||||
await callback.answer("Сначала авторизуйтесь")
|
||||
return
|
||||
|
||||
file_info = _pending_files.pop(callback.from_user.id, None)
|
||||
if not file_info or not isinstance(file_info, dict):
|
||||
await callback.answer("Файл не найден, отправьте заново")
|
||||
return
|
||||
|
||||
template_id = callback.data.split(":")[1]
|
||||
|
||||
from app.services.recording import get_format_from_filename
|
||||
fmt = get_format_from_filename(file_info["filename"])
|
||||
|
||||
with SyncSession() as db:
|
||||
recording = Recording(
|
||||
user_id=user_id,
|
||||
title=file_info["filename"],
|
||||
file_path=file_info["file_path"],
|
||||
file_size=file_info["file_size"],
|
||||
format=fmt,
|
||||
source=RecordingSource.telegram,
|
||||
)
|
||||
db.add(recording)
|
||||
db.commit()
|
||||
db.refresh(recording)
|
||||
recording_id = recording.id
|
||||
|
||||
await callback.message.edit_text("⏳ Принято! Обработка началась...")
|
||||
transcribe_recording.delay(str(recording_id))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("export:"))
|
||||
async def on_export_requested(callback: CallbackQuery, bot: Bot):
|
||||
parts = callback.data.split(":")
|
||||
protocol_id, fmt = parts[1], parts[2]
|
||||
|
||||
from app.services.export import export_docx, export_markdown, export_pdf
|
||||
from app.models.export_file import ExportFormat
|
||||
|
||||
with SyncSession() as db:
|
||||
result = db.execute(select(Protocol).where(Protocol.id == uuid.UUID(protocol_id)))
|
||||
protocol = result.scalar_one_or_none()
|
||||
|
||||
if not protocol:
|
||||
await callback.answer("Протокол не найден")
|
||||
return
|
||||
|
||||
exporters = {"docx": export_docx, "pdf": export_pdf, "md": export_markdown}
|
||||
exporter = exporters.get(fmt)
|
||||
if not exporter:
|
||||
await callback.answer("Неизвестный формат")
|
||||
return
|
||||
|
||||
file_path = exporter(protocol.content, protocol.id)
|
||||
from aiogram.types import FSInputFile
|
||||
await bot.send_document(
|
||||
callback.message.chat.id,
|
||||
FSInputFile(file_path, filename=f"protocol.{fmt}"),
|
||||
)
|
||||
await callback.answer()
|
||||
@@ -0,0 +1,39 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
|
||||
|
||||
def template_choice_keyboard(templates: list[dict]) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
for tpl in templates:
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=tpl["name"],
|
||||
callback_data=f"template:{tpl['id']}",
|
||||
)])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
|
||||
|
||||
def protocol_actions_keyboard(protocol_id: str, app_url: str) -> InlineKeyboardMarkup:
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(text="📄 DOCX", callback_data=f"export:{protocol_id}:docx"),
|
||||
InlineKeyboardButton(text="📕 PDF", callback_data=f"export:{protocol_id}:pdf"),
|
||||
InlineKeyboardButton(text="📝 MD", callback_data=f"export:{protocol_id}:md"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🔄 Перегенерировать", callback_data=f"regen:{protocol_id}"),
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🌐 Открыть в вебе", url=f"{app_url}/protocols/{protocol_id}"),
|
||||
],
|
||||
])
|
||||
|
||||
|
||||
def recording_list_keyboard(recordings: list[dict]) -> InlineKeyboardMarkup:
|
||||
buttons = []
|
||||
for rec in recordings[:10]:
|
||||
status_emoji = {"uploaded": "⏳", "processing": "⚙️", "done": "✅", "error": "❌"}
|
||||
emoji = status_emoji.get(rec["status"], "")
|
||||
buttons.append([InlineKeyboardButton(
|
||||
text=f"{emoji} {rec['title'][:40]}",
|
||||
callback_data=f"rec:{rec['id']}",
|
||||
)])
|
||||
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.export_file import ExportFormat
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
format: ExportFormat
|
||||
|
||||
|
||||
class ExportFileResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
protocol_id: uuid.UUID
|
||||
format: ExportFormat
|
||||
file_size: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,105 @@
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import markdown
|
||||
from docx import Document
|
||||
from docx.shared import Pt
|
||||
from jinja2 import Template
|
||||
from weasyprint import HTML
|
||||
|
||||
from app.config import settings
|
||||
|
||||
SECTION_LABELS = {
|
||||
"date_participants_topic": "Дата, участники, тема встречи",
|
||||
"process_description": "Описание обследуемого процесса",
|
||||
"current_state": "Текущее состояние (as-is)",
|
||||
"problems": "Выявленные проблемы / узкие места",
|
||||
"agreements": "Договорённости и решения",
|
||||
"open_questions": "Открытые вопросы",
|
||||
"tasks": "Задачи с ответственными и сроками",
|
||||
"next_steps": "Следующие шаги",
|
||||
"client_goals": "Верхнеуровневые цели клиента",
|
||||
"project_parameters": "Параметры будущего проекта",
|
||||
"budget_constraints": "Бюджетные ограничения",
|
||||
"time_constraints": "Ограничения по срокам",
|
||||
"decision_makers": "Лица, принимающие решения (ЛПР)",
|
||||
"beneficiaries": "Бенефициары",
|
||||
"influencers": "Лица, влияющие на принятие решения",
|
||||
"discussed_topics": "Обсуждённые вопросы",
|
||||
"decisions": "Принятые решения",
|
||||
}
|
||||
|
||||
|
||||
def _format_section_value(value) -> str:
|
||||
if isinstance(value, list):
|
||||
if value and isinstance(value[0], dict):
|
||||
lines = []
|
||||
for item in value:
|
||||
parts = [f"{k}: {v}" for k, v in item.items()]
|
||||
lines.append("- " + ", ".join(parts))
|
||||
return "\n".join(lines)
|
||||
return "\n".join(f"- {item}" for item in value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _protocol_to_markdown(content: dict) -> str:
|
||||
lines = ["# Протокол встречи\n"]
|
||||
for key, value in content.items():
|
||||
if key == "raw":
|
||||
lines.append(str(value))
|
||||
continue
|
||||
label = SECTION_LABELS.get(key, key)
|
||||
lines.append(f"## {label}\n")
|
||||
lines.append(_format_section_value(value))
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def export_markdown(content: dict, protocol_id: uuid.UUID) -> str:
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
file_path = os.path.join(settings.export_dir, f"{protocol_id}.md")
|
||||
md_text = _protocol_to_markdown(content)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(md_text)
|
||||
return file_path
|
||||
|
||||
|
||||
def export_docx(content: dict, protocol_id: uuid.UUID) -> str:
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
file_path = os.path.join(settings.export_dir, f"{protocol_id}.docx")
|
||||
|
||||
doc = Document()
|
||||
doc.add_heading("Протокол встречи", level=0)
|
||||
|
||||
for key, value in content.items():
|
||||
if key == "raw":
|
||||
doc.add_paragraph(str(value))
|
||||
continue
|
||||
label = SECTION_LABELS.get(key, key)
|
||||
doc.add_heading(label, level=1)
|
||||
text = _format_section_value(value)
|
||||
for line in text.split("\n"):
|
||||
doc.add_paragraph(line, style="List Bullet" if line.startswith("- ") else None)
|
||||
|
||||
doc.save(file_path)
|
||||
return file_path
|
||||
|
||||
|
||||
def export_pdf(content: dict, protocol_id: uuid.UUID) -> str:
|
||||
os.makedirs(settings.export_dir, exist_ok=True)
|
||||
file_path = os.path.join(settings.export_dir, f"{protocol_id}.pdf")
|
||||
|
||||
md_text = _protocol_to_markdown(content)
|
||||
html_body = markdown.markdown(md_text)
|
||||
html_full = f"""
|
||||
<html><head><meta charset="utf-8">
|
||||
<style>
|
||||
body {{ font-family: sans-serif; margin: 40px; font-size: 14px; line-height: 1.6; }}
|
||||
h1 {{ font-size: 22px; }} h2 {{ font-size: 16px; color: #333; border-bottom: 1px solid #ccc; padding-bottom: 4px; }}
|
||||
ul {{ padding-left: 20px; }}
|
||||
</style></head>
|
||||
<body>{html_body}</body></html>
|
||||
"""
|
||||
HTML(string=html_full).write_pdf(file_path)
|
||||
return file_path
|
||||
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import settings
|
||||
from app.models.export_file import ExportFile
|
||||
from app.models.recording import Recording
|
||||
from app.models.transcription import Transcription
|
||||
|
||||
|
||||
def get_disk_usage() -> dict:
|
||||
upload_size = _dir_size(settings.upload_dir)
|
||||
export_size = _dir_size(settings.export_dir)
|
||||
return {
|
||||
"uploads_bytes": upload_size,
|
||||
"exports_bytes": export_size,
|
||||
"total_bytes": upload_size + export_size,
|
||||
"total_gb": round((upload_size + export_size) / (1024 ** 3), 2),
|
||||
}
|
||||
|
||||
|
||||
def _dir_size(path: str) -> int:
|
||||
total = 0
|
||||
if not os.path.exists(path):
|
||||
return 0
|
||||
for dirpath, _, filenames in os.walk(path):
|
||||
for f in filenames:
|
||||
fp = os.path.join(dirpath, f)
|
||||
total += os.path.getsize(fp)
|
||||
return total
|
||||
|
||||
|
||||
def cleanup_old_data(db: Session, retention_days: int | None = None):
|
||||
days = retention_days or settings.retention_days
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
|
||||
old_recordings = db.execute(
|
||||
select(Recording).where(Recording.created_at < cutoff)
|
||||
).scalars().all()
|
||||
|
||||
deleted_count = 0
|
||||
for recording in old_recordings:
|
||||
if os.path.exists(recording.file_path):
|
||||
os.remove(recording.file_path)
|
||||
if recording.transcription:
|
||||
for protocol in recording.transcription.protocols:
|
||||
for export_file in protocol.export_files:
|
||||
if os.path.exists(export_file.file_path):
|
||||
os.remove(export_file.file_path)
|
||||
db.delete(recording)
|
||||
deleted_count += 1
|
||||
|
||||
db.commit()
|
||||
return {"deleted_recordings": deleted_count}
|
||||
@@ -25,3 +25,7 @@ celery_app.conf.update(
|
||||
)
|
||||
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
|
||||
import app.tasks.transcription # noqa: F401, E402
|
||||
import app.tasks.protocol # noqa: F401, E402
|
||||
import app.tasks.cleanup # noqa: F401, E402
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from app.tasks.celery_app import celery_app
|
||||
from app.tasks.transcription import SyncSession
|
||||
from app.services.storage import cleanup_old_data
|
||||
|
||||
|
||||
@celery_app.task(name="app.tasks.cleanup.cleanup_old_data")
|
||||
def cleanup_old_data_task(retention_days: int | None = None):
|
||||
with SyncSession() as db:
|
||||
result = cleanup_old_data(db, retention_days)
|
||||
return result
|
||||
@@ -10,16 +10,17 @@ psycopg2-binary==2.9.10
|
||||
alembic==1.14.1
|
||||
|
||||
# Validation
|
||||
pydantic==2.10.4
|
||||
pydantic>=2.7.0,<2.10
|
||||
pydantic-settings==2.7.1
|
||||
|
||||
# Auth
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
|
||||
# Task queue
|
||||
celery[redis]==5.4.0
|
||||
redis==5.2.1
|
||||
redis==4.6.0
|
||||
|
||||
# AI/ML
|
||||
faster-whisper==1.1.0
|
||||
@@ -36,10 +37,10 @@ markdown==3.7
|
||||
|
||||
# Utils
|
||||
ffmpeg-python==0.2.0
|
||||
httpx==0.28.1
|
||||
httpx>=0.23.0,<0.28.0
|
||||
aiofiles==24.1.0
|
||||
|
||||
# Testing
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.0
|
||||
httpx==0.28.1
|
||||
httpx>=0.23.0,<0.28.0
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import pytest
|
||||
from app.services.export import _protocol_to_markdown, _format_section_value
|
||||
|
||||
|
||||
def test_format_section_value_string():
|
||||
assert _format_section_value("Hello world") == "Hello world"
|
||||
|
||||
|
||||
def test_format_section_value_list():
|
||||
result = _format_section_value(["item1", "item2"])
|
||||
assert "- item1" in result
|
||||
assert "- item2" in result
|
||||
|
||||
|
||||
def test_format_section_value_list_of_dicts():
|
||||
tasks = [{"task": "Do X", "responsible": "Alice", "deadline": "2026-04-10"}]
|
||||
result = _format_section_value(tasks)
|
||||
assert "task: Do X" in result
|
||||
assert "responsible: Alice" in result
|
||||
|
||||
|
||||
def test_protocol_to_markdown():
|
||||
content = {
|
||||
"date_participants_topic": "01.04.2026, Иванов, Петров",
|
||||
"problems": ["Проблема 1", "Проблема 2"],
|
||||
"next_steps": "Назначить следующую встречу",
|
||||
}
|
||||
md = _protocol_to_markdown(content)
|
||||
assert "# Протокол встречи" in md
|
||||
assert "## Дата, участники, тема встречи" in md
|
||||
assert "## Выявленные проблемы" in md
|
||||
assert "- Проблема 1" in md
|
||||
assert "## Следующие шаги" in md
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.services.storage import get_disk_usage, _dir_size
|
||||
|
||||
|
||||
def test_dir_size_empty(tmp_path):
|
||||
assert _dir_size(str(tmp_path)) == 0
|
||||
|
||||
|
||||
def test_dir_size_with_files(tmp_path):
|
||||
f = tmp_path / "test.bin"
|
||||
f.write_bytes(b"\x00" * 1024)
|
||||
assert _dir_size(str(tmp_path)) == 1024
|
||||
|
||||
|
||||
def test_dir_size_nonexistent():
|
||||
assert _dir_size("/nonexistent/path/12345") == 0
|
||||
|
||||
|
||||
def test_get_disk_usage():
|
||||
with patch("app.services.storage._dir_size", return_value=1024 * 1024 * 512):
|
||||
result = get_disk_usage()
|
||||
assert result["total_bytes"] == 1024 * 1024 * 512 * 2
|
||||
assert "total_gb" in result
|
||||
@@ -0,0 +1,34 @@
|
||||
import pytest
|
||||
from app.messengers.telegram.keyboards import (
|
||||
template_choice_keyboard,
|
||||
protocol_actions_keyboard,
|
||||
recording_list_keyboard,
|
||||
)
|
||||
|
||||
|
||||
def test_template_choice_keyboard():
|
||||
templates = [
|
||||
{"id": "abc-123", "name": "Обследование"},
|
||||
{"id": "def-456", "name": "Внутренняя"},
|
||||
]
|
||||
kb = template_choice_keyboard(templates)
|
||||
assert len(kb.inline_keyboard) == 2
|
||||
assert kb.inline_keyboard[0][0].text == "Обследование"
|
||||
assert kb.inline_keyboard[0][0].callback_data == "template:abc-123"
|
||||
|
||||
|
||||
def test_protocol_actions_keyboard():
|
||||
kb = protocol_actions_keyboard("proto-123", "http://localhost")
|
||||
assert len(kb.inline_keyboard) == 3
|
||||
assert kb.inline_keyboard[0][0].callback_data == "export:proto-123:docx"
|
||||
|
||||
|
||||
def test_recording_list_keyboard():
|
||||
recordings = [
|
||||
{"id": "rec-1", "title": "Meeting 1", "status": "done"},
|
||||
{"id": "rec-2", "title": "Meeting 2", "status": "processing"},
|
||||
]
|
||||
kb = recording_list_keyboard(recordings)
|
||||
assert len(kb.inline_keyboard) == 2
|
||||
assert "✅" in kb.inline_keyboard[0][0].text
|
||||
assert "⚙️" in kb.inline_keyboard[1][0].text
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# Auto-deploy script triggered by Gitea webhook
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="D:/meeting-protocol-service"
|
||||
LOG_FILE="$DEPLOY_DIR/deploy.log"
|
||||
|
||||
echo "$(date) - Deploy started" >> "$LOG_FILE"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
# Pull latest changes
|
||||
git pull origin main >> "$LOG_FILE" 2>&1
|
||||
|
||||
# Rebuild and restart services
|
||||
docker compose up -d --build >> "$LOG_FILE" 2>&1
|
||||
|
||||
echo "$(date) - Deploy completed" >> "$LOG_FILE"
|
||||
+9
-9
@@ -2,8 +2,8 @@ services:
|
||||
app:
|
||||
build: ./backend
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
expose:
|
||||
- "8000"
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- exports:/app/exports
|
||||
@@ -12,6 +12,7 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
working_dir: /app
|
||||
command: >
|
||||
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
|
||||
@@ -21,6 +22,7 @@ services:
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- exports:/app/exports
|
||||
- huggingface_cache:/root/.cache/huggingface
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -45,8 +47,6 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
env_file: .env
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -66,13 +66,12 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
- "8081:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./frontend/dist:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
@@ -80,3 +79,4 @@ volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
exports:
|
||||
huggingface_cache:
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meeting Protocol Service</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "meeting-protocol-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import Layout from './components/Layout'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import RecordingsPage from './pages/RecordingsPage'
|
||||
import ProtocolsPage from './pages/ProtocolsPage'
|
||||
import ProtocolDetailPage from './pages/ProtocolDetailPage'
|
||||
import TemplatesPage from './pages/TemplatesPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/" element={<RecordingsPage />} />
|
||||
<Route path="/recordings" element={<RecordingsPage />} />
|
||||
<Route path="/protocols" element={<ProtocolsPage />} />
|
||||
<Route path="/protocols/:id" element={<ProtocolDetailPage />} />
|
||||
<Route path="/templates" element={<TemplatesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
interface Props {
|
||||
onUpload: (file: File, title: string) => Promise<unknown>
|
||||
isUploading: boolean
|
||||
}
|
||||
|
||||
export default function FileUpload({ onUpload, isUploading }: Props) {
|
||||
const [title, setTitle] = useState('')
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const file = fileRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
await onUpload(file, title || file.name)
|
||||
setTitle('')
|
||||
if (fileRef.current) fileRef.current.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-end">
|
||||
<input ref={fileRef} type="file" accept="audio/*,video/*" className="text-sm" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Название (необязательно)"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="border rounded px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading}
|
||||
className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Загрузка...' : 'Загрузить'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import api from '../api/client'
|
||||
import type { StorageInfo } from '../types'
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth()
|
||||
const { data: storage } = useQuery<StorageInfo>({
|
||||
queryKey: ['storage'],
|
||||
queryFn: () => api.get('/settings/storage').then((r) => r.data),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<nav className="bg-gray-900 text-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link to="/" className="text-blue-400 font-bold text-lg">MeetingProto</Link>
|
||||
<Link to="/recordings" className="hover:text-blue-300">Записи</Link>
|
||||
<Link to="/protocols" className="hover:text-blue-300">Протоколы</Link>
|
||||
<Link to="/templates" className="hover:text-blue-300">Шаблоны</Link>
|
||||
<Link to="/settings" className="text-gray-400 hover:text-blue-300">Настройки</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{storage && (
|
||||
<span className="text-gray-400">Занято: {storage.total_gb} ГБ</span>
|
||||
)}
|
||||
<span>{user?.username}</span>
|
||||
<button onClick={logout} className="text-gray-400 hover:text-white">Выйти</button>
|
||||
</div>
|
||||
</nav>
|
||||
<main className="max-w-7xl mx-auto px-6 py-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, isLoading } = useAuth()
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">Загрузка...</div>
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
uploaded: 'bg-gray-100 text-gray-700',
|
||||
processing: 'bg-yellow-100 text-yellow-800',
|
||||
done: 'bg-green-100 text-green-800',
|
||||
error: 'bg-red-100 text-red-800',
|
||||
generating: 'bg-blue-100 text-blue-800',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
uploaded: 'Загружено',
|
||||
processing: 'Обработка...',
|
||||
done: 'Готово',
|
||||
error: 'Ошибка',
|
||||
generating: 'Генерация...',
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_STYLES[status] ?? ''}`}>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../api/client'
|
||||
import type { User } from '../types'
|
||||
|
||||
export function useAuth() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: user, isLoading } = useQuery<User>({
|
||||
queryKey: ['me'],
|
||||
queryFn: () => api.get('/auth/me').then((r) => r.data),
|
||||
retry: false,
|
||||
enabled: !!localStorage.getItem('token'),
|
||||
})
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) =>
|
||||
api.post('/auth/login', data).then((r) => r.data),
|
||||
onSuccess: (data) => {
|
||||
localStorage.setItem('token', data.access_token)
|
||||
queryClient.invalidateQueries({ queryKey: ['me'] })
|
||||
},
|
||||
})
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token')
|
||||
queryClient.clear()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
return { user, isLoading, login: loginMutation.mutateAsync, loginError: loginMutation.error, logout }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../api/client'
|
||||
import type { Protocol } from '../types'
|
||||
|
||||
export function useProtocols() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery<{ items: Protocol[]; total: number }>({
|
||||
queryKey: ['protocols'],
|
||||
queryFn: () => api.get('/protocols').then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: ({ id, templateId }: { id: string; templateId?: string }) =>
|
||||
api.post(`/protocols/${id}/regenerate`, { template_id: templateId }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['protocols'] }),
|
||||
})
|
||||
|
||||
return {
|
||||
protocols: data?.items ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
regenerate: regenerateMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
|
||||
export function useProtocol(id: string) {
|
||||
return useQuery<Protocol>({
|
||||
queryKey: ['protocol', id],
|
||||
queryFn: () => api.get(`/protocols/${id}`).then((r) => r.data),
|
||||
refetchInterval: 3000,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../api/client'
|
||||
import type { Recording } from '../types'
|
||||
|
||||
export function useRecordings() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery<{ items: Recording[]; total: number }>({
|
||||
queryKey: ['recordings'],
|
||||
queryFn: () => api.get('/recordings').then((r) => r.data),
|
||||
refetchInterval: 5000,
|
||||
})
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, title }: { file: File; title: string }) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('title', title)
|
||||
return api.post('/recordings/upload', form)
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/recordings/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['recordings'] }),
|
||||
})
|
||||
|
||||
return {
|
||||
recordings: data?.items ?? [],
|
||||
total: data?.total ?? 0,
|
||||
isLoading,
|
||||
upload: uploadMutation.mutateAsync,
|
||||
isUploading: uploadMutation.isPending,
|
||||
deleteRecording: deleteMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../api/client'
|
||||
import type { PromptTemplate } from '../types'
|
||||
|
||||
export function useTemplates() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery<PromptTemplate[]>({
|
||||
queryKey: ['templates'],
|
||||
queryFn: () => api.get('/templates').then((r) => r.data),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: Partial<PromptTemplate>) => api.post('/templates', data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: Partial<PromptTemplate> & { id: string }) =>
|
||||
api.put(`/templates/${id}`, data),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/templates/${id}`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['templates'] }),
|
||||
})
|
||||
|
||||
return {
|
||||
templates: data ?? [],
|
||||
isLoading,
|
||||
createTemplate: createMutation.mutateAsync,
|
||||
updateTemplate: updateMutation.mutateAsync,
|
||||
deleteTemplate: deleteMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
try {
|
||||
await login({ username, password })
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Неверный логин или пароль')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<form onSubmit={handleSubmit} className="bg-white p-8 rounded-lg shadow-md w-96">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">Meeting Protocol Service</h1>
|
||||
{error && <p className="text-red-500 mb-4 text-sm">{error}</p>}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Логин"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2 mb-4"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full border rounded px-3 py-2 mb-4"
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useProtocol } from '../hooks/useProtocols'
|
||||
import { useTemplates } from '../hooks/useTemplates'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import api from '../api/client'
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
date_participants_topic: 'Дата, участники, тема встречи',
|
||||
process_description: 'Описание обследуемого процесса',
|
||||
current_state: 'Текущее состояние (as-is)',
|
||||
problems: 'Выявленные проблемы / узкие места',
|
||||
agreements: 'Договорённости и решения',
|
||||
open_questions: 'Открытые вопросы',
|
||||
tasks: 'Задачи с ответственными и сроками',
|
||||
next_steps: 'Следующие шаги',
|
||||
client_goals: 'Верхнеуровневые цели клиента',
|
||||
project_parameters: 'Параметры будущего проекта',
|
||||
budget_constraints: 'Бюджетные ограничения',
|
||||
time_constraints: 'Ограничения по срокам',
|
||||
decision_makers: 'ЛПР',
|
||||
beneficiaries: 'Бенефициары',
|
||||
influencers: 'Лица, влияющие на принятие решения',
|
||||
discussed_topics: 'Обсуждённые вопросы',
|
||||
decisions: 'Принятые решения',
|
||||
}
|
||||
|
||||
function renderValue(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) =>
|
||||
typeof item === 'object' ? Object.entries(item as Record<string, unknown>).map(([k, v]) => `${k}: ${v}`).join(', ') : String(item),
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
return String(value ?? '')
|
||||
}
|
||||
|
||||
async function exportProtocol(protocolId: string, format: string) {
|
||||
const resp = await api.post(`/exports/protocol/${protocolId}`, { format }, { responseType: 'json' })
|
||||
const exportId = resp.data.id
|
||||
window.open(`/api/exports/${exportId}/download`, '_blank')
|
||||
}
|
||||
|
||||
export default function ProtocolDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: protocol, isLoading } = useProtocol(id!)
|
||||
const { templates: _templates } = useTemplates()
|
||||
|
||||
if (isLoading || !protocol) return <p>Загрузка...</p>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<h1 className="text-2xl font-bold">Протокол</h1>
|
||||
<StatusBadge status={protocol.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
{(['docx', 'pdf', 'md'] as const).map((fmt) => (
|
||||
<button
|
||||
key={fmt}
|
||||
onClick={() => exportProtocol(protocol.id, fmt)}
|
||||
className="bg-green-600 text-white px-3 py-1.5 rounded text-sm hover:bg-green-700"
|
||||
>
|
||||
{fmt.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{protocol.status === 'done' && protocol.content && (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(protocol.content).map(([key, value]) => (
|
||||
<div key={key} className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 mb-2">
|
||||
{SECTION_LABELS[key] ?? key}
|
||||
</h3>
|
||||
<p className="text-gray-600 whitespace-pre-line">{renderValue(value)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protocol.status === 'generating' && (
|
||||
<div className="bg-yellow-50 p-6 rounded-lg text-center">
|
||||
<p className="text-yellow-800">Протокол генерируется, подождите...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protocol.status === 'error' && (
|
||||
<div className="bg-red-50 p-6 rounded-lg text-center">
|
||||
<p className="text-red-800">Ошибка при генерации протокола</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { useProtocols } from '../hooks/useProtocols'
|
||||
|
||||
export default function ProtocolsPage() {
|
||||
const { protocols, isLoading } = useProtocols()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Протоколы</h1>
|
||||
{isLoading ? (
|
||||
<p>Загрузка...</p>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">ID</th>
|
||||
<th className="text-left px-4 py-3">Модель</th>
|
||||
<th className="text-left px-4 py-3">Статус</th>
|
||||
<th className="text-left px-4 py-3">Дата</th>
|
||||
<th className="text-left px-4 py-3">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{protocols.map((p) => (
|
||||
<tr key={p.id} className="border-t">
|
||||
<td className="px-4 py-3 font-mono text-xs">{p.id.slice(0, 8)}</td>
|
||||
<td className="px-4 py-3">{p.llm_model || '—'}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={p.status} /></td>
|
||||
<td className="px-4 py-3">{new Date(p.created_at).toLocaleDateString('ru')}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Link to={`/protocols/${p.id}`} className="text-blue-600 hover:underline">
|
||||
Открыть
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import FileUpload from '../components/FileUpload'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import { useRecordings } from '../hooks/useRecordings'
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (!seconds) return '—'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = { web: '🌐 Web', telegram: '💬 Telegram', lensa: '💬 Lensa' }
|
||||
|
||||
export default function RecordingsPage() {
|
||||
const { recordings, isLoading, upload, isUploading, deleteRecording } = useRecordings()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Записи</h1>
|
||||
</div>
|
||||
<div className="mb-6 bg-white p-4 rounded-lg shadow-sm">
|
||||
<FileUpload onUpload={(file, title) => upload({ file, title })} isUploading={isUploading} />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<p>Загрузка...</p>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-500">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">Название</th>
|
||||
<th className="text-left px-4 py-3">Источник</th>
|
||||
<th className="text-left px-4 py-3">Размер</th>
|
||||
<th className="text-left px-4 py-3">Длительность</th>
|
||||
<th className="text-left px-4 py-3">Статус</th>
|
||||
<th className="text-left px-4 py-3">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recordings.map((rec) => (
|
||||
<tr key={rec.id} className="border-t">
|
||||
<td className="px-4 py-3">{rec.title}</td>
|
||||
<td className="px-4 py-3">{SOURCE_LABELS[rec.source] ?? rec.source}</td>
|
||||
<td className="px-4 py-3">{formatSize(rec.file_size)}</td>
|
||||
<td className="px-4 py-3">{formatDuration(rec.duration)}</td>
|
||||
<td className="px-4 py-3"><StatusBadge status={rec.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => deleteRecording(rec.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../hooks/useAuth'
|
||||
import api from '../api/client'
|
||||
import type { StorageInfo } from '../types'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth()
|
||||
const [retentionDays, setRetentionDays] = useState(90)
|
||||
const [cleanupStarted, setCleanupStarted] = useState(false)
|
||||
|
||||
const { data: storage } = useQuery<StorageInfo>({
|
||||
queryKey: ['storage'],
|
||||
queryFn: () => api.get('/settings/storage').then((r) => r.data),
|
||||
})
|
||||
|
||||
const handleCleanup = async () => {
|
||||
await api.post('/settings/cleanup', null, { params: { retention_days: retentionDays } })
|
||||
setCleanupStarted(true)
|
||||
setTimeout(() => setCleanupStarted(false), 5000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-6">Настройки</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="font-semibold mb-3">Профиль</h3>
|
||||
<p className="text-sm text-gray-600">Пользователь: <strong>{user?.username}</strong></p>
|
||||
<p className="text-sm text-gray-600">Email: <strong>{user?.email}</strong></p>
|
||||
<p className="text-sm text-gray-600">Роль: <strong>{user?.role}</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="font-semibold mb-3">Хранилище</h3>
|
||||
{storage && (
|
||||
<div className="text-sm text-gray-600 space-y-1">
|
||||
<p>Записи: <strong>{(storage.uploads_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ</strong></p>
|
||||
<p>Экспорт: <strong>{(storage.exports_bytes / 1024 / 1024 / 1024).toFixed(2)} ГБ</strong></p>
|
||||
<p>Итого: <strong>{storage.total_gb} ГБ</strong></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user?.role === 'admin' && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<h3 className="font-semibold mb-3">Очистка данных</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">Удалить записи старше</label>
|
||||
<input
|
||||
type="number"
|
||||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(Number(e.target.value))}
|
||||
className="border rounded px-2 py-1 w-20 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">дней</span>
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
className="bg-red-600 text-white px-3 py-1.5 rounded text-sm hover:bg-red-700"
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
{cleanupStarted && <p className="text-green-600 text-sm mt-2">Очистка запущена!</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { useTemplates } from '../hooks/useTemplates'
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { templates, isLoading, createTemplate, deleteTemplate } = useTemplates()
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' as const })
|
||||
|
||||
const handleCreate = async () => {
|
||||
await createTemplate(form)
|
||||
setShowForm(false)
|
||||
setForm({ name: '', description: '', system_prompt: '', user_prompt: '', type: 'custom' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Шаблоны протоколов</h1>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
+ Новый шаблон
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm mb-6 space-y-3">
|
||||
<input
|
||||
placeholder="Название"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<input
|
||||
placeholder="Описание"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="System prompt"
|
||||
value={form.system_prompt}
|
||||
onChange={(e) => setForm({ ...form, system_prompt: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm h-24"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="User prompt (используйте {transcription} для вставки текста)"
|
||||
value={form.user_prompt}
|
||||
onChange={(e) => setForm({ ...form, user_prompt: e.target.value })}
|
||||
className="w-full border rounded px-3 py-2 text-sm h-24"
|
||||
/>
|
||||
<button onClick={handleCreate} className="bg-green-600 text-white px-4 py-2 rounded text-sm">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p>Загрузка...</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{templates.map((tpl) => (
|
||||
<div key={tpl.id} className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{tpl.name}</h3>
|
||||
<p className="text-sm text-gray-500">{tpl.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{tpl.type}</span>
|
||||
{tpl.is_default && <span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">По умолчанию</span>}
|
||||
{tpl.type === 'custom' && (
|
||||
<button
|
||||
onClick={() => deleteTemplate(tpl.id)}
|
||||
className="text-red-500 text-xs hover:text-red-700"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Recording {
|
||||
id: string
|
||||
user_id: string
|
||||
title: string
|
||||
file_size: number
|
||||
duration: number | null
|
||||
format: string
|
||||
source: 'web' | 'telegram' | 'lensa'
|
||||
status: 'uploaded' | 'processing' | 'done' | 'error'
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Transcription {
|
||||
id: string
|
||||
recording_id: string
|
||||
text: string
|
||||
segments: { start: number; end: number; text: string }[]
|
||||
language: string | null
|
||||
whisper_model: string
|
||||
processing_time: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Protocol {
|
||||
id: string
|
||||
transcription_id: string
|
||||
template_id: string
|
||||
content: Record<string, unknown>
|
||||
raw_text: string
|
||||
llm_model: string
|
||||
status: 'generating' | 'done' | 'error'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
type: 'client_survey' | 'client_intro' | 'internal' | 'custom'
|
||||
system_prompt: string
|
||||
user_prompt: string
|
||||
output_schema: Record<string, unknown>
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ExportFile {
|
||||
id: string
|
||||
protocol_id: string
|
||||
format: 'docx' | 'pdf' | 'md'
|
||||
file_size: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface StorageInfo {
|
||||
uploads_bytes: number
|
||||
exports_bytes: number
|
||||
total_bytes: number
|
||||
total_gb: number
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: { extend: {} },
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
const http = require('http');
|
||||
const { execFile } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const PORT = 9000;
|
||||
const SECRET = 'meeting-proto-deploy-secret';
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.method !== 'POST' || req.url !== '/deploy') {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
// Verify Gitea signature
|
||||
const sig = req.headers['x-gitea-signature'];
|
||||
if (sig) {
|
||||
const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex');
|
||||
if (sig !== hmac) {
|
||||
console.log(`${new Date().toISOString()} - Invalid signature, rejecting`);
|
||||
res.writeHead(403);
|
||||
res.end('Invalid signature');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${new Date().toISOString()} - Deploy triggered`);
|
||||
res.writeHead(200);
|
||||
res.end('Deploy started');
|
||||
|
||||
execFile('bash', ['D:/meeting-protocol-service/deploy.sh'], (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error(`Deploy error: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
console.log(`Deploy output: ${stdout}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Webhook server listening on port ${PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user