Compare commits

...

10 Commits

Author SHA1 Message Date
Admin 24acac4f2d fix: add persistent huggingface cache volume for worker
Prevents re-downloading Whisper model on container recreate.
Also switched to whisper medium (large-v3 OOM on 8GB VRAM with Ollama).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:05:08 +05:00
Admin a2ba3d8d2b fix: pin bcrypt==4.0.1 for passlib compat, explicit celery task imports
- bcrypt 5.x breaks passlib 1.7.4, pin to 4.0.1
- autodiscover_tasks not finding task modules, add explicit imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:27:15 +05:00
Admin 3e15f643d6 fix: deployment — resolve dependency conflicts, add initial migration, CI/CD webhook
- Fix httpx/pydantic/redis version conflicts for litellm/aiogram/celery compatibility
- Add PYTHONPATH=/app to Dockerfile for alembic module resolution
- Skip Telegram webhook setup when URL is not HTTPS
- Fix FileUpload prop type mismatch in RecordingsPage
- Add alembic script.py.mako template and initial migration
- Configure docker-compose: port 8081, built frontend, no exposed internal ports
- Add deploy.sh and webhook-server.js for Gitea CI/CD auto-deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:49:12 +05:00
admin d2ef7785a4 feat: final integration — all routers, CORS, seed data, deploy ready
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:25:48 +05:00
admin ffab9cbc8e feat: frontend pages — recordings, protocols, templates, settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:24:17 +05:00
admin 106c82bdf4 feat: frontend setup — React, TypeScript, Tailwind, auth, layout, routing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:19:55 +05:00
admin bfe52bb9f8 feat: Lensa bot stub — MessengerService interface ready for SDK integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:46:26 +05:00
admin 922b1b26a6 feat: Telegram bot — full functionality with inline keyboards, file upload, auth
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:46:20 +05:00
admin 397b999558 feat: storage management — disk usage, cleanup API, scheduled cleanup task
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:32:36 +05:00
admin b1eafa4e0b feat: export service — DOCX, PDF, MD generation and download API
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:32:14 +05:00
52 changed files with 1971 additions and 13 deletions
+1
View File
@@ -2,6 +2,7 @@ FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
# System dependencies # System dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
+26
View File
@@ -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 ###
+79
View File
@@ -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}",
)
+24
View File
@@ -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"}
+21
View File
@@ -1,13 +1,18 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select from sqlalchemy import select
from app.api.auth import router as auth_router 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.protocols import router as protocols_router
from app.api.recordings import router as recordings_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.api.templates import router as templates_router
from app.config import settings
from app.database import async_session 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.models.prompt_template import PromptTemplate
from app.prompts.defaults import DEFAULT_TEMPLATES from app.prompts.defaults import DEFAULT_TEMPLATES
@@ -25,14 +30,30 @@ async def seed_default_templates():
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await seed_default_templates() await seed_default_templates()
if settings.telegram_bot_token and settings.telegram_webhook_url.startswith("https"):
await telegram_service.setup()
yield 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 = 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(auth_router)
app.include_router(recordings_router) app.include_router(recordings_router)
app.include_router(protocols_router) app.include_router(protocols_router)
app.include_router(templates_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") @app.get("/api/health")
View File
+24
View File
@@ -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
+44
View File
@@ -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()
+59
View File
@@ -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)
+276
View File
@@ -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)
+20
View File
@@ -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}
+105
View File
@@ -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
+57
View File
@@ -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}
+4
View File
@@ -25,3 +25,7 @@ celery_app.conf.update(
) )
celery_app.autodiscover_tasks(["app.tasks"]) 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
+10
View File
@@ -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
+5 -4
View File
@@ -10,16 +10,17 @@ psycopg2-binary==2.9.10
alembic==1.14.1 alembic==1.14.1
# Validation # Validation
pydantic==2.10.4 pydantic>=2.7.0,<2.10
pydantic-settings==2.7.1 pydantic-settings==2.7.1
# Auth # Auth
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.0.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
# Task queue # Task queue
celery[redis]==5.4.0 celery[redis]==5.4.0
redis==5.2.1 redis==4.6.0
# AI/ML # AI/ML
faster-whisper==1.1.0 faster-whisper==1.1.0
@@ -36,10 +37,10 @@ markdown==3.7
# Utils # Utils
ffmpeg-python==0.2.0 ffmpeg-python==0.2.0
httpx==0.28.1 httpx>=0.23.0,<0.28.0
aiofiles==24.1.0 aiofiles==24.1.0
# Testing # Testing
pytest==8.3.4 pytest==8.3.4
pytest-asyncio==0.25.0 pytest-asyncio==0.25.0
httpx==0.28.1 httpx>=0.23.0,<0.28.0
+33
View File
@@ -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
+26
View File
@@ -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
+34
View File
@@ -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
+18
View File
@@ -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
View File
@@ -2,8 +2,8 @@ services:
app: app:
build: ./backend build: ./backend
env_file: .env env_file: .env
ports: expose:
- "8000:8000" - "8000"
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
- exports:/app/exports - exports:/app/exports
@@ -12,6 +12,7 @@ services:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
working_dir: /app
command: > command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"
@@ -21,6 +22,7 @@ services:
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
- exports:/app/exports - exports:/app/exports
- huggingface_cache:/root/.cache/huggingface
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -45,8 +47,6 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
ports:
- "6379:6379"
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
@@ -57,7 +57,7 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
env_file: .env env_file: .env
ports: ports:
- "5432:5432" - "5433:5432"
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -66,13 +66,12 @@ services:
timeout: 3s timeout: 3s
retries: 5 retries: 5
nginx: frontend:
image: nginx:alpine build: ./frontend
ports: ports:
- "80:80" - "8081:80"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf
- ./frontend/dist:/usr/share/nginx/html
depends_on: depends_on:
- app - app
@@ -80,3 +79,4 @@ volumes:
pgdata: pgdata:
uploads: uploads:
exports: exports:
huggingface_cache:
+9
View File
@@ -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
+12
View File
@@ -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>
+28
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+32
View File
@@ -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>
)
}
+24
View File
@@ -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
+39
View File
@@ -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>
)
}
+37
View File
@@ -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}</>
}
+23
View File
@@ -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>
)
}
+31
View File
@@ -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 }
}
+34
View File
@@ -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,
})
}
+37
View File
@@ -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,
}
}
+36
View File
@@ -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,
}
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+10
View File
@@ -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>,
)
+50
View File
@@ -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>
)
}
+96
View File
@@ -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>
)
}
+45
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+71
View File
@@ -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>
)
}
+89
View File
@@ -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>
)
}
+70
View File
@@ -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
}
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}
+21
View File
@@ -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"]
}
+11
View File
@@ -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',
},
},
})
+46
View File
@@ -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}`);
});