Add multi-tenant auth with org projects, roles, and personal workspaces.

JWT login, org-scoped storage and RAG, admin/director/user roles, user-owned projects, login UI, and legacy data migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keboss-m 2026-06-01 18:54:25 +03:00
parent 2727d3cd32
commit 8df14e3102
21 changed files with 2322 additions and 324 deletions

3
.gitignore vendored
View File

@ -73,3 +73,6 @@ video/
# Server logs # Server logs
server.log server.log
*.log *.log
# Auth database
data/

View File

@ -6,4 +6,6 @@ RUN pip install --no-cache-dir --timeout 300 \
lightrag-hku>=1.4.0 \ lightrag-hku>=1.4.0 \
openai>=1.0.0 \ openai>=1.0.0 \
python-dotenv>=1.0.0 \ python-dotenv>=1.0.0 \
sentence-transformers>=3.0.0 sentence-transformers>=3.0.0 \
bcrypt>=4.0.0 \
"python-jose[cryptography]"

6
backend/auth/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Authentication and multi-tenant access control."""
from backend.auth.deps import get_current_user, require_admin
from backend.auth.models import UserContext
__all__ = ["UserContext", "get_current_user", "require_admin"]

407
backend/auth/database.py Normal file
View File

@ -0,0 +1,407 @@
"""SQLite persistence for organizations, users, projects."""
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from backend.auth.security import hash_password
DB_PATH = Path("data/transcriba.db")
_SCHEMA = """
CREATE TABLE IF NOT EXISTS organizations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(org_id, username),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id INTEGER NOT NULL,
slug TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE(org_id, slug),
FOREIGN KEY (org_id) REFERENCES organizations(id)
);
CREATE TABLE IF NOT EXISTS user_projects (
user_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
PRIMARY KEY (user_id, project_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
"""
def _utcnow() -> str:
return datetime.now(timezone.utc).isoformat()
def get_db_path(config: Optional[dict] = None) -> Path:
env_path = os.getenv("AUTH_DATABASE_PATH")
if env_path:
return Path(env_path)
if config:
custom = config.get("auth", {}).get("database_path")
if custom:
return Path(custom)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
return DB_PATH
@contextmanager
def get_connection(config: Optional[dict] = None):
db_path = get_db_path(config)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db(config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.executescript(_SCHEMA)
migrate_schema(config)
def migrate_schema(config: Optional[dict] = None) -> None:
"""Добавляет owner_user_id для личных проектов пользователей."""
with get_connection(config) as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "owner_user_id" not in cols:
conn.execute(
"ALTER TABLE projects ADD COLUMN owner_user_id INTEGER REFERENCES users(id)"
)
print("[Auth] Migration: projects.owner_user_id added")
def count_users(config: Optional[dict] = None) -> int:
with get_connection(config) as conn:
row = conn.execute("SELECT COUNT(*) AS c FROM users").fetchone()
return int(row["c"])
def get_org_by_slug(slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"SELECT id, slug, name, created_at FROM organizations WHERE slug = ?",
(slug,),
).fetchone()
return dict(row) if row else None
def create_organization(slug: str, name: str, config: Optional[dict] = None) -> Dict[str, Any]:
with get_connection(config) as conn:
cur = conn.execute(
"INSERT INTO organizations (slug, name, created_at) VALUES (?, ?, ?)",
(slug, name, _utcnow()),
)
org_id = cur.lastrowid
row = conn.execute(
"SELECT id, slug, name, created_at FROM organizations WHERE id = ?",
(org_id,),
).fetchone()
return dict(row)
def create_user(
org_id: int,
username: str,
password: str,
role: str = "user",
config: Optional[dict] = None,
) -> Dict[str, Any]:
with get_connection(config) as conn:
cur = conn.execute(
"""
INSERT INTO users (org_id, username, password_hash, role, is_active, created_at)
VALUES (?, ?, ?, ?, 1, ?)
""",
(org_id, username, hash_password(password), role, _utcnow()),
)
user_id = cur.lastrowid
row = conn.execute(
"SELECT id, org_id, username, role, is_active, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
return dict(row)
def get_user_by_username(org_id: int, username: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
o.slug AS org_slug, o.name AS org_name
FROM users u
JOIN organizations o ON o.id = u.org_id
WHERE u.org_id = ? AND u.username = ?
""",
(org_id, username),
).fetchone()
return dict(row) if row else None
def get_user_by_id(user_id: int, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT u.id, u.org_id, u.username, u.password_hash, u.role, u.is_active,
o.slug AS org_slug, o.name AS org_name
FROM users u
JOIN organizations o ON o.id = u.org_id
WHERE u.id = ?
""",
(user_id,),
).fetchone()
return dict(row) if row else None
def list_users(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, username, role, is_active, created_at
FROM users WHERE org_id = ? ORDER BY username
""",
(org_id,),
).fetchall()
users = [dict(r) for r in rows]
for user in users:
user["projects"] = get_user_project_slugs(user["id"], config=config, conn=conn)
return users
def get_user_project_slugs(
user_id: int,
config: Optional[dict] = None,
conn: Optional[sqlite3.Connection] = None,
) -> List[str]:
"""Org-wide projects assigned to user by admin (not personal)."""
query = """
SELECT p.slug FROM projects p
JOIN user_projects up ON up.project_id = p.id
WHERE up.user_id = ? AND p.owner_user_id IS NULL
ORDER BY p.slug
"""
def _fetch(connection: sqlite3.Connection) -> List[str]:
rows = connection.execute(query, (user_id,)).fetchall()
return [row["slug"] for row in rows]
if conn is not None:
return _fetch(conn)
with get_connection(config) as connection:
return _fetch(connection)
def get_owned_project_slugs(
user_id: int,
config: Optional[dict] = None,
) -> List[str]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT slug FROM projects
WHERE owner_user_id = ?
ORDER BY slug
""",
(user_id,),
).fetchall()
return [row["slug"] for row in rows]
def _row_to_project(row: sqlite3.Row) -> Dict[str, Any]:
data = dict(row)
data["scope"] = "personal" if data.get("owner_user_id") else "org"
return data
def create_project(
org_id: int,
slug: str,
name: str,
owner_user_id: Optional[int] = None,
config: Optional[dict] = None,
) -> Dict[str, Any]:
slug = slug.strip().lower()
with get_connection(config) as conn:
cur = conn.execute(
"""
INSERT INTO projects (org_id, slug, name, owner_user_id, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(org_id, slug, name, owner_user_id, _utcnow()),
)
project_id = cur.lastrowid
row = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE id = ?
""",
(project_id,),
).fetchone()
return _row_to_project(row)
def list_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? ORDER BY slug
""",
(org_id,),
).fetchall()
return [_row_to_project(r) for r in rows]
def list_org_projects(org_id: int, config: Optional[dict] = None) -> List[Dict[str, Any]]:
"""Только общие проекты организации (без личных)."""
with get_connection(config) as conn:
rows = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? AND owner_user_id IS NULL
ORDER BY slug
""",
(org_id,),
).fetchall()
return [_row_to_project(r) for r in rows]
def set_user_projects(user_id: int, project_ids: List[int], config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.execute("DELETE FROM user_projects WHERE user_id = ?", (user_id,))
for project_id in project_ids:
conn.execute(
"INSERT INTO user_projects (user_id, project_id) VALUES (?, ?)",
(user_id, project_id),
)
def get_project_by_slug(org_id: int, slug: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
with get_connection(config) as conn:
row = conn.execute(
"""
SELECT id, org_id, slug, name, owner_user_id, created_at
FROM projects WHERE org_id = ? AND slug = ?
""",
(org_id, slug.strip().lower()),
).fetchone()
return _row_to_project(row) if row else None
def delete_project(project_id: int, config: Optional[dict] = None) -> None:
with get_connection(config) as conn:
conn.execute("DELETE FROM user_projects WHERE project_id = ?", (project_id,))
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
def bootstrap_from_config(config: dict) -> None:
init_db(config)
if count_users(config) > 0:
return
auth_cfg = config.get("auth", {})
bootstrap = auth_cfg.get("bootstrap", {})
org_slug = bootstrap.get("org_slug", "merakom")
org_name = bootstrap.get("org_name", "МЕРАКОМ")
admin_username = bootstrap.get("admin_username", "admin")
admin_password = (
os.getenv("AUTH_ADMIN_PASSWORD")
or bootstrap.get("admin_password")
or auth_cfg.get("admin_password", "admin123")
)
org = get_org_by_slug(org_slug, config)
if not org:
org = create_organization(org_slug, org_name, config)
create_user(org["id"], admin_username, admin_password, role="admin", config=config)
default_projects = bootstrap.get("default_projects") or [
{"slug": "2026", "name": "2026"},
{"slug": "gp-merakom", "name": "ГП МЕРАКОМ"},
]
for proj in default_projects:
slug = str(proj.get("slug", "")).strip().lower()
name = str(proj.get("name", slug))
if slug and not get_project_by_slug(org["id"], slug, config):
create_project(org["id"], slug, name, owner_user_id=None, config=config)
print(f"[Auth] Bootstrap: org={org_slug}, admin={admin_username}")
def migrate_legacy_data(org_slug: str) -> None:
"""Move flat processed/* into org-scoped layout (one-time)."""
from backend.paths import MEETINGS_DIRNAME, RAG_CACHE_DIRNAME, PROCESSED_ROOT, write_folder_project_meta
from src.rag.parser import parse_project_from_filename
import shutil
legacy_rag = PROCESSED_ROOT / "lightrag_caches"
legacy_rag_test = PROCESSED_ROOT / "lightrag_caches_test"
org_root = PROCESSED_ROOT / org_slug
target_meetings = org_root / MEETINGS_DIRNAME
target_rag = org_root / RAG_CACHE_DIRNAME
target_meetings.mkdir(parents=True, exist_ok=True)
target_rag.mkdir(parents=True, exist_ok=True)
skip = {org_slug, "lightrag_caches", "lightrag_caches_test", MEETINGS_DIRNAME, RAG_CACHE_DIRNAME}
if PROCESSED_ROOT.exists():
for item in PROCESSED_ROOT.iterdir():
if not item.is_dir() or item.name in skip:
continue
dest = target_meetings / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
print(f"[Auth] Migrated meeting folder: {item.name} -> {dest}")
for folder in target_meetings.iterdir():
if not folder.is_dir():
continue
meta_path = folder / ".project.json"
if not meta_path.exists():
project_slug = parse_project_from_filename(folder.name)
write_folder_project_meta(folder, project_slug)
print(f"[Auth] Assigned project {project_slug} -> {folder.name}")
if legacy_rag.exists() and legacy_rag != target_rag:
for item in legacy_rag.iterdir():
dest = target_rag / item.name
if item.is_dir() and not dest.exists():
shutil.move(str(item), str(dest))
if not any(legacy_rag.iterdir()):
legacy_rag.rmdir()
print(f"[Auth] Migrated RAG cache -> {target_rag}")
if legacy_rag_test.exists():
shutil.rmtree(legacy_rag_test, ignore_errors=True)

70
backend/auth/deps.py Normal file
View File

@ -0,0 +1,70 @@
"""FastAPI auth dependencies."""
from typing import Optional
from fastapi import Depends, HTTPException, Query, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend.auth.models import UserContext
from backend.auth.security import safe_decode_token
from backend.auth.service import get_user_context
from src.config import load_config
bearer_scheme = HTTPBearer(auto_error=False)
def _resolve_token(
credentials: Optional[HTTPAuthorizationCredentials],
token: Optional[str],
) -> Optional[str]:
if credentials and credentials.scheme.lower() == "bearer":
return credentials.credentials
if token:
return token
return None
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme),
token: Optional[str] = Query(None, alias="token"),
) -> UserContext:
raw_token = _resolve_token(credentials, token)
if not raw_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется авторизация",
headers={"WWW-Authenticate": "Bearer"},
)
config = load_config()
payload = safe_decode_token(raw_token, config)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный или просроченный токен",
)
user_id = int(payload.get("sub", 0))
ctx = get_user_context(user_id)
if not ctx:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Пользователь не найден или деактивирован",
)
return ctx
async def require_admin(user: UserContext = Depends(get_current_user)) -> UserContext:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Требуются права администратора")
return user
def get_user_from_token(token: Optional[str]) -> Optional[UserContext]:
if not token:
return None
config = load_config()
payload = safe_decode_token(token, config)
if not payload:
return None
return get_user_context(int(payload.get("sub", 0)))

53
backend/auth/models.py Normal file
View File

@ -0,0 +1,53 @@
"""Auth data models."""
from dataclasses import dataclass, field
from typing import List, Optional
ROLES = ("admin", "director", "user")
@dataclass
class UserContext:
user_id: int
username: str
role: str
org_id: int
org_slug: str
org_name: str
project_slugs: List[str] = field(default_factory=list) # назначенные org-проекты
owned_project_slugs: List[str] = field(default_factory=list) # личные проекты
@property
def accessible_project_slugs(self) -> set[str]:
if self.has_all_projects_access:
return set()
return set(self.project_slugs) | set(self.owned_project_slugs)
@property
def is_admin(self) -> bool:
return self.role == "admin"
@property
def is_director(self) -> bool:
return self.role == "director"
@property
def has_all_projects_access(self) -> bool:
"""Admin and director see all org projects and global RAG search."""
return self.role in ("admin", "director")
def can_access_project(self, project_slug: Optional[str]) -> bool:
if not project_slug:
return True
if self.has_all_projects_access:
return True
return project_slug in self.accessible_project_slugs
def filter_projects(self, projects: List[str]) -> List[str]:
if self.has_all_projects_access:
return projects
allowed = self.accessible_project_slugs
return [p for p in projects if p in allowed]
def can_global_search(self) -> bool:
return self.has_all_projects_access

146
backend/auth/routes.py Normal file
View File

@ -0,0 +1,146 @@
"""Auth HTTP routes."""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from backend.auth.deps import get_current_user, require_admin
from backend.auth.models import ROLES, UserContext
from backend.auth import database as db
from backend.auth.service import (
authenticate,
create_org_user,
create_personal_project,
delete_personal_project,
get_user_context,
list_accessible_projects,
update_user_projects,
user_to_dict,
)
from src.config import load_config
router = APIRouter(prefix="/api/auth", tags=["auth"])
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
class LoginRequest(BaseModel):
org_slug: str = Field(default="merakom")
username: str
password: str
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "user"
projects: List[str] = Field(default_factory=list)
class CreateProjectRequest(BaseModel):
slug: str
name: str
class UpdateUserProjectsRequest(BaseModel):
projects: List[str] = Field(default_factory=list)
@router.post("/login")
async def login(payload: LoginRequest):
result = authenticate(payload.org_slug, payload.username, payload.password)
if not result:
raise HTTPException(status_code=401, detail="Неверная организация, логин или пароль")
return result
@router.get("/me")
async def me(user: UserContext = Depends(get_current_user)):
return user_to_dict(user)
@router.get("/projects")
async def my_projects(user: UserContext = Depends(get_current_user)):
return {"projects": list_accessible_projects(user)}
@router.post("/projects")
async def create_my_project(payload: CreateProjectRequest, user: UserContext = Depends(get_current_user)):
try:
project = create_personal_project(user, payload.slug, payload.name)
return {"project": project}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
if "UNIQUE" in str(e):
raise HTTPException(status_code=409, detail="Проект уже существует") from e
raise HTTPException(status_code=400, detail=str(e)) from e
@router.delete("/projects/{slug}")
async def delete_my_project(slug: str, user: UserContext = Depends(get_current_user)):
try:
delete_personal_project(user, slug)
return {"deleted": slug}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@admin_router.get("/users")
async def admin_list_users(admin: UserContext = Depends(require_admin)):
config = load_config()
rows = db.list_users(admin.org_id, config)
users = []
for row in rows:
ctx = get_user_context(row["id"])
if ctx:
users.append(user_to_dict(ctx))
return {"users": users}
@admin_router.post("/users")
async def admin_create_user(payload: CreateUserRequest, admin: UserContext = Depends(require_admin)):
if payload.role not in ROLES:
raise HTTPException(status_code=400, detail=f"role must be one of: {', '.join(ROLES)}")
try:
user = create_org_user(admin, payload.username, payload.password, payload.role, payload.projects)
return {"user": user}
except Exception as e:
if "UNIQUE" in str(e):
raise HTTPException(status_code=409, detail="Пользователь уже существует") from e
raise HTTPException(status_code=400, detail=str(e)) from e
@admin_router.put("/users/{user_id}/projects")
async def admin_set_user_projects(
user_id: int,
payload: UpdateUserProjectsRequest,
admin: UserContext = Depends(require_admin),
):
try:
user = update_user_projects(admin, user_id, payload.projects)
return {"user": user}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@admin_router.get("/projects")
async def admin_list_projects(admin: UserContext = Depends(require_admin)):
config = load_config()
return {"projects": db.list_projects(admin.org_id, config)}
@admin_router.post("/projects")
async def admin_create_project(payload: CreateProjectRequest, admin: UserContext = Depends(require_admin)):
slug = payload.slug.strip().lower()
if not slug:
raise HTTPException(status_code=400, detail="slug обязателен")
try:
project = db.create_project(admin.org_id, slug, payload.name, owner_user_id=None, config=load_config())
return {"project": project}
except Exception as e:
if "UNIQUE" in str(e):
raise HTTPException(status_code=409, detail="Проект уже существует") from e
raise HTTPException(status_code=400, detail=str(e)) from e

71
backend/auth/security.py Normal file
View File

@ -0,0 +1,71 @@
"""Password hashing and JWT tokens."""
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional
import bcrypt
from jose import JWTError, jwt
ALGORITHM = "HS256"
def get_jwt_secret(config: Optional[dict] = None) -> str:
env_secret = os.getenv("JWT_SECRET")
if env_secret:
return env_secret
if config:
auth_cfg = config.get("auth", {})
secret = auth_cfg.get("jwt_secret")
if secret:
return secret
return "dev-insecure-change-me"
def get_jwt_expire_hours(config: Optional[dict] = None) -> int:
if config:
return int(config.get("auth", {}).get("jwt_expire_hours", 168))
return 168
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except ValueError:
return False
def create_access_token(
user_id: int,
org_id: int,
role: str,
username: str,
org_slug: str,
config: Optional[dict] = None,
) -> str:
expire_hours = get_jwt_expire_hours(config)
expire = datetime.now(timezone.utc) + timedelta(hours=expire_hours)
payload = {
"sub": str(user_id),
"org_id": org_id,
"org_slug": org_slug,
"role": role,
"username": username,
"exp": expire,
}
return jwt.encode(payload, get_jwt_secret(config), algorithm=ALGORITHM)
def decode_access_token(token: str, config: Optional[dict] = None) -> Dict[str, Any]:
return jwt.decode(token, get_jwt_secret(config), algorithms=[ALGORITHM])
def safe_decode_token(token: str, config: Optional[dict] = None) -> Optional[Dict[str, Any]]:
try:
return decode_access_token(token, config)
except JWTError:
return None

180
backend/auth/service.py Normal file
View File

@ -0,0 +1,180 @@
"""Auth business logic."""
import re
from typing import Any, Dict, List, Optional
from backend.auth import database as db
from backend.auth.models import UserContext
from backend.auth.security import create_access_token, verify_password
from src.config import load_config
def normalize_project_slug(slug: str) -> str:
value = slug.strip().lower()
value = re.sub(r"[^\w\-]", "-", value)
value = re.sub(r"-+", "-", value).strip("-_")
if not value:
raise ValueError("Некорректный slug проекта")
return value
def _build_user_context(row: Dict[str, Any], config: Optional[dict] = None) -> UserContext:
user_id = row["id"]
return UserContext(
user_id=user_id,
username=row["username"],
role=row["role"],
org_id=row["org_id"],
org_slug=row["org_slug"],
org_name=row["org_name"],
project_slugs=db.get_user_project_slugs(user_id, config=config),
owned_project_slugs=db.get_owned_project_slugs(user_id, config=config),
)
def _enrich_project(project: Dict[str, Any], ctx: UserContext) -> Dict[str, Any]:
item = dict(project)
item["is_owner"] = project.get("owner_user_id") == ctx.user_id
item["scope"] = project.get("scope") or ("personal" if project.get("owner_user_id") else "org")
return item
def authenticate(org_slug: str, username: str, password: str) -> Optional[Dict[str, Any]]:
config = load_config()
org = db.get_org_by_slug(org_slug.strip().lower(), config)
if not org:
return None
user = db.get_user_by_username(org["id"], username.strip(), config)
if not user or not user.get("is_active"):
return None
if not verify_password(password, user["password_hash"]):
return None
ctx = _build_user_context(user, config)
token = create_access_token(
user_id=ctx.user_id,
org_id=ctx.org_id,
role=ctx.role,
username=ctx.username,
org_slug=ctx.org_slug,
config=config,
)
return {
"access_token": token,
"token_type": "bearer",
"user": user_to_dict(ctx),
}
def get_user_context(user_id: int) -> Optional[UserContext]:
config = load_config()
row = db.get_user_by_id(user_id, config)
if not row or not row.get("is_active"):
return None
return _build_user_context(row, config)
def user_to_dict(ctx: UserContext) -> Dict[str, Any]:
return {
"id": ctx.user_id,
"username": ctx.username,
"role": ctx.role,
"org_id": ctx.org_id,
"org_slug": ctx.org_slug,
"org_name": ctx.org_name,
"shared_projects": ctx.project_slugs,
"owned_projects": ctx.owned_project_slugs,
"projects": list(ctx.accessible_project_slugs) if not ctx.has_all_projects_access else [],
"is_admin": ctx.is_admin,
"is_director": ctx.is_director,
"all_projects_access": ctx.has_all_projects_access,
}
def list_accessible_projects(ctx: UserContext) -> List[Dict[str, Any]]:
config = load_config()
all_projects = db.list_projects(ctx.org_id, config)
if ctx.has_all_projects_access:
return [_enrich_project(p, ctx) for p in all_projects]
result = []
assigned = set(ctx.project_slugs)
owned = set(ctx.owned_project_slugs)
for project in all_projects:
slug = project["slug"]
if project.get("owner_user_id") == ctx.user_id or slug in owned:
result.append(_enrich_project(project, ctx))
elif not project.get("owner_user_id") and slug in assigned:
result.append(_enrich_project(project, ctx))
return sorted(result, key=lambda p: (p["scope"] != "personal", p["slug"]))
def ensure_project_access(ctx: UserContext, project_slug: str) -> None:
slug = project_slug.strip().lower()
if not ctx.can_access_project(slug):
raise PermissionError(f"Нет доступа к проекту: {slug}")
def create_personal_project(ctx: UserContext, slug: str, name: str) -> Dict[str, Any]:
config = load_config()
normalized = normalize_project_slug(slug)
display_name = name.strip() or normalized
if db.get_project_by_slug(ctx.org_id, normalized, config):
raise ValueError("Проект с таким slug уже существует")
project = db.create_project(
ctx.org_id,
normalized,
display_name,
owner_user_id=ctx.user_id,
config=config,
)
return _enrich_project(project, ctx)
def delete_personal_project(ctx: UserContext, slug: str) -> None:
config = load_config()
normalized = normalize_project_slug(slug)
project = db.get_project_by_slug(ctx.org_id, normalized, config)
if not project:
raise ValueError("Проект не найден")
if ctx.is_admin:
db.delete_project(project["id"], config)
return
if project.get("owner_user_id") != ctx.user_id:
raise PermissionError("Можно удалять только свои личные проекты")
db.delete_project(project["id"], config)
def create_org_user(
ctx: UserContext,
username: str,
password: str,
role: str,
project_slugs: List[str],
) -> Dict[str, Any]:
config = load_config()
user = db.create_user(ctx.org_id, username, password, role=role, config=config)
if role == "user" and project_slugs:
projects = db.list_org_projects(ctx.org_id, config)
slug_to_id = {p["slug"]: p["id"] for p in projects}
project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id]
db.set_user_projects(user["id"], project_ids, config)
user_row = db.get_user_by_id(user["id"], config)
return user_to_dict(_build_user_context(user_row, config))
def update_user_projects(ctx: UserContext, user_id: int, project_slugs: List[str]) -> Dict[str, Any]:
config = load_config()
target = db.get_user_by_id(user_id, config)
if not target or target["org_id"] != ctx.org_id:
raise ValueError("Пользователь не найден")
if target["role"] in ("admin", "director"):
return user_to_dict(_build_user_context(target, config))
projects = db.list_org_projects(ctx.org_id, config)
slug_to_id = {p["slug"]: p["id"] for p in projects}
project_ids = [slug_to_id[s] for s in project_slugs if s in slug_to_id]
db.set_user_projects(user_id, project_ids, config)
updated = db.get_user_by_id(user_id, config)
return user_to_dict(_build_user_context(updated, config))

View File

@ -1,88 +1,91 @@
"""FastAPI backend для сервиса транскрибации.""" """FastAPI backend для сервиса транскрибации."""
import json import json
import os
import sys import sys
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import List, Optional
from fastapi import FastAPI, File, UploadFile, WebSocket, WebSocketDisconnect from fastapi import Depends, FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, PlainTextResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from backend.auth.deps import get_current_user, get_user_from_token, require_admin
from backend.auth.models import UserContext
from backend.auth.routes import admin_router, router as auth_router
from backend.auth import database as auth_db
from backend.auth.service import ensure_project_access, list_accessible_projects
from backend.paths import org_meetings_dir, org_rag_index_dir, resolve_meeting_path
from backend.queue import ( from backend.queue import (
UPLOAD_DIR, delete_folder,
PROCESSED_DIR,
save_upload,
get_all_tasks, get_all_tasks,
get_download_path,
get_processed_tree,
get_queue_info, get_queue_info,
get_task_status, get_task_status,
get_processed_tree,
read_file_content, read_file_content,
save_upload,
set_progress_callback, set_progress_callback,
start_workers, start_workers,
stop_workers, stop_workers,
) )
# Добавляем корень проекта в путь, чтобы импортировать src.rag
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from src.config import load_config, resolve_opencode_credentials from src.config import load_config, resolve_opencode_credentials
from src.rag.indexer import get_project_names from src.rag.formatter import format_global_document
from src.rag.parser import parse_project_from_filename from src.rag.indexer import get_project_names, index_meeting
from src.rag.query import rag_chat, retrieve_context from src.rag.query import rag_chat
STATIC_DIR = Path(__file__).parent / "static"
# WebSocket менеджер
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
self.active_connections: List[WebSocket] = [] self.active_connections: list[tuple[WebSocket, Optional[UserContext]]] = []
async def connect(self, websocket: WebSocket): async def connect(self, websocket: WebSocket, user: Optional[UserContext] = None):
await websocket.accept() await websocket.accept()
self.active_connections.append(websocket) self.active_connections.append((websocket, user))
def disconnect(self, websocket: WebSocket): def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections: self.active_connections = [(ws, u) for ws, u in self.active_connections if ws is not websocket]
self.active_connections.remove(websocket)
def set_user(self, websocket: WebSocket, user: UserContext):
self.active_connections = [(ws, user if ws is websocket else u) for ws, u in self.active_connections]
async def broadcast(self, message: dict): async def broadcast(self, message: dict):
for conn in self.active_connections: for conn, _user in self.active_connections:
try: try:
await conn.send_json(message) await conn.send_json(message)
except Exception: except Exception:
pass pass
manager = ConnectionManager()
# Устанавливаем callback для отправки прогресса через WebSocket manager = ConnectionManager()
set_progress_callback(manager.broadcast) set_progress_callback(manager.broadcast)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Управление жизненным циклом приложения."""
config = load_config() config = load_config()
auth_db.init_db(config)
auth_db.bootstrap_from_config(config)
org_slug = config.get("auth", {}).get("bootstrap", {}).get("org_slug", "merakom")
auth_db.migrate_legacy_data(org_slug)
queue_cfg = config.get("queue", {}) queue_cfg = config.get("queue", {})
transcribe_workers = int(queue_cfg.get("transcribe_workers", 2)) transcribe_workers = int(queue_cfg.get("transcribe_workers", 2))
postprocess_workers = int(queue_cfg.get("postprocess_workers", 1)) postprocess_workers = int(queue_cfg.get("postprocess_workers", 1))
print("🚀 Запуск рабочих процессов...") print("🚀 Запуск рабочих процессов...")
start_workers( start_workers(transcribe_workers=transcribe_workers, postprocess_workers=postprocess_workers)
transcribe_workers=transcribe_workers,
postprocess_workers=postprocess_workers,
)
yield yield
print("🛑 Остановка рабочих процессов...") print("🛑 Остановка рабочих процессов...")
stop_workers() stop_workers()
app = FastAPI( app = FastAPI(title="Transcription Service", version="2.0.0", lifespan=lifespan)
title="Transcription Service",
version="1.0.0",
lifespan=lifespan,
)
# CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@ -91,84 +94,132 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(auth_router)
app.include_router(admin_router)
def _org_index_dir(user: UserContext) -> Path:
return org_rag_index_dir(user.org_slug)
async def _list_rag_project_slugs(user: UserContext) -> List[str]:
projects = await get_project_names(_org_index_dir(user))
return user.filter_projects(projects)
async def _rag_chat_for_user(user: UserContext, question: str, history: list, project_name: Optional[str], mode: str):
if project_name:
ensure_project_access(user, project_name)
elif not user.can_global_search():
raise HTTPException(status_code=403, detail="Глобальный поиск доступен только администратору")
config = load_config()
rag_cfg = config.get("rag", {})
api_key, base_url = resolve_opencode_credentials(config)
return await rag_chat(
question=question,
working_dir_base=_org_index_dir(user),
history=history,
api_key=api_key,
project_name=project_name,
base_url=base_url,
chat_model=rag_cfg.get("chat_model", "deepseek-v4-flash-free"),
mode=mode,
index_model=rag_cfg.get("index_model", "mimo-v2.5-free"),
)
# === API Endpoints ===
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
async def root(): async def root():
"""Главная страница.""" index_path = STATIC_DIR / "index.html"
index_path = Path(__file__).parent / "static" / "index.html"
if index_path.exists(): if index_path.exists():
return index_path.read_text(encoding="utf-8") return index_path.read_text(encoding="utf-8")
return "<h1>Transcription Service</h1><p>Frontend not built</p>" return "<h1>Transcription Service</h1>"
@app.get("/login", response_class=HTMLResponse)
async def login_page():
login_path = STATIC_DIR / "login.html"
if login_path.exists():
return login_path.read_text(encoding="utf-8")
return "<h1>Login page missing</h1>"
@app.post("/upload") @app.post("/upload")
async def upload_file(file: UploadFile = File(...)): async def upload_file(
"""Загружает файл и добавляет в очередь обработки.""" file: UploadFile = File(...),
project: str = Form(...),
user: UserContext = Depends(get_current_user),
):
content = await file.read() content = await file.read()
task_id, _ = await save_upload(content, file.filename or "upload.bin") try:
task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
return { return {
"task_id": task_id, "task_id": task_id,
"file": file.filename, "file": file.filename,
"project": project,
"status": "queued", "status": "queued",
"message": "Файл добавлен в очередь обработки", "message": "Файл добавлен в очередь обработки",
"queue": get_queue_info(), "queue": get_queue_info(user),
} }
@app.post("/upload-batch") @app.post("/upload-batch")
async def upload_batch(files: List[UploadFile] = File(...)): async def upload_batch(
"""Загружает несколько файлов пакетно — все ставятся в очередь.""" files: List[UploadFile] = File(...),
project: str = Form(...),
user: UserContext = Depends(get_current_user),
):
if not files: if not files:
return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []} return {"error": "Не переданы файлы", "uploaded": 0, "tasks": []}
results = [] results = []
for file in files: for file in files:
content = await file.read() content = await file.read()
task_id, _ = await save_upload(content, file.filename or "upload.bin") try:
results.append({ task_id, _ = await save_upload(content, file.filename or "upload.bin", user, project)
"task_id": task_id, except PermissionError as e:
"file": file.filename, raise HTTPException(status_code=403, detail=str(e)) from e
"status": "queued", results.append({"task_id": task_id, "file": file.filename, "project": project, "status": "queued"})
})
return { return {
"uploaded": len(results), "uploaded": len(results),
"tasks": results, "tasks": results,
"queue": get_queue_info(), "queue": get_queue_info(user),
"message": f"{len(results)} файл(ов) добавлено в очередь", "message": f"{len(results)} файл(ов) добавлено в очередь",
} }
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""WebSocket для получения прогресса обработки.""" token = websocket.query_params.get("token")
await manager.connect(websocket) user = get_user_from_token(token)
if not user:
await websocket.close(code=4401)
return
await manager.connect(websocket, user)
try: try:
while True: while True:
# Ждём сообщения от клиента (ping/keepalive)
data = await websocket.receive_text() data = await websocket.receive_text()
msg = json.loads(data) msg = json.loads(data)
if msg.get("action") == "get_tasks": if msg.get("action") == "get_tasks":
tasks = get_all_tasks()
await websocket.send_json({ await websocket.send_json({
"type": "tasks_list", "type": "tasks_list",
"tasks": tasks, "tasks": get_all_tasks(user),
"queue": get_queue_info(), "queue": get_queue_info(user),
}) })
elif msg.get("action") == "get_tree": elif msg.get("action") == "get_tree":
tree = get_processed_tree()
await websocket.send_json({ await websocket.send_json({
"type": "file_tree", "type": "file_tree",
"tree": tree, "tree": get_processed_tree(user),
}) })
elif msg.get("action") == "rag_query": elif msg.get("action") in ("rag_query", "rag_query_global"):
await _handle_rag_query(websocket, msg) await _handle_rag_query_ws(websocket, msg, user)
elif msg.get("action") == "rag_query_global":
await _handle_rag_query(websocket, msg)
except WebSocketDisconnect: except WebSocketDisconnect:
manager.disconnect(websocket) manager.disconnect(websocket)
except Exception: except Exception:
@ -176,202 +227,126 @@ async def websocket_endpoint(websocket: WebSocket):
@app.get("/api/tasks") @app.get("/api/tasks")
async def api_tasks(): async def api_tasks(user: UserContext = Depends(get_current_user)):
"""Возвращает список всех задач.""" return {"tasks": get_all_tasks(user), "queue": get_queue_info(user)}
return {"tasks": get_all_tasks(), "queue": get_queue_info()}
@app.get("/api/tasks/{task_id}") @app.get("/api/tasks/{task_id}")
async def api_task(task_id: str): async def api_task(task_id: str, user: UserContext = Depends(get_current_user)):
"""Возвращает статус конкретной задачи.""" status = get_task_status(task_id, user)
status = get_task_status(task_id)
if not status: if not status:
return {"error": "Task not found"} return {"error": "Task not found"}
return status return status
@app.get("/api/files") @app.get("/api/files")
async def api_files(): async def api_files(user: UserContext = Depends(get_current_user)):
"""Возвращает дерево обработанных файлов.""" return {"tree": get_processed_tree(user)}
return {"tree": get_processed_tree()}
@app.get("/api/files/content") @app.get("/api/files/content")
async def api_file_content(path: str): async def api_file_content(path: str, user: UserContext = Depends(get_current_user)):
"""Возвращает содержимое файла."""
try: try:
content = read_file_content(path) content = read_file_content(user, path)
return {"content": content, "path": path} return {"content": content, "path": path}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.get("/api/files/download") @app.get("/api/files/download")
async def api_download(path: str): async def api_download(path: str, user: UserContext = Depends(get_current_user)):
"""Скачивает файл."""
file_path = PROCESSED_DIR / path
if not file_path.exists():
return {"error": "File not found"}
return FileResponse(file_path, filename=file_path.name)
import shutil
@app.delete("/api/folders/{folder_name}")
async def api_delete_folder(folder_name: str):
"""Удаляет папку с обработанными файлами."""
folder_path = PROCESSED_DIR / folder_name
if not folder_path.exists():
return {"error": "Folder not found"}
try: try:
shutil.rmtree(folder_path) file_path = get_download_path(user, path)
return {"deleted": folder_name} return FileResponse(file_path, filename=file_path.name)
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
# === RAG / Chat API === @app.delete("/api/folders/{folder_name:path}")
async def api_delete_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
try:
delete_folder(user, folder_name)
return {"deleted": folder_name}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e:
return {"error": str(e)}
@app.get("/api/rag/projects") @app.get("/api/rag/projects")
async def api_rag_projects(): async def api_rag_projects(user: UserContext = Depends(get_current_user)):
"""Возвращает список проектов с RAG-индексами."""
try: try:
config = load_config() db_projects = {p["slug"] for p in list_accessible_projects(user)}
rag_cfg = config.get("rag", {}) indexed = await _list_rag_project_slugs(user)
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) merged = sorted(db_projects | set(indexed))
projects = await get_project_names(index_dir) if not user.is_admin:
return {"projects": projects} merged = user.filter_projects(merged)
return {"projects": merged}
except Exception as e: except Exception as e:
return {"error": str(e), "projects": []} return {"error": str(e), "projects": []}
@app.post("/api/rag/query") @app.post("/api/rag/query")
async def api_rag_query(payload: dict): async def api_rag_query(payload: dict, user: UserContext = Depends(get_current_user)):
"""Запрос к чат-боту по конкретному проекту."""
try: try:
config = load_config() result = await _rag_chat_for_user(
rag_cfg = config.get("rag", {}) user,
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) payload.get("question", ""),
api_key, base_url = resolve_opencode_credentials(config) payload.get("history", []),
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") payload.get("project"),
index_model = rag_cfg.get("index_model", "mimo-v2.5-free") payload.get("mode", "hybrid"),
mode = payload.get("mode", "hybrid")
result = await rag_chat(
question=payload.get("question", ""),
working_dir_base=index_dir,
history=payload.get("history", []),
api_key=api_key,
project_name=payload.get("project"),
base_url=base_url,
chat_model=chat_model,
mode=mode,
index_model=index_model,
) )
return { return {"answer": result["answer"], "context": result["context"], "project": result["project"]}
"answer": result["answer"], except HTTPException:
"context": result["context"], raise
"project": result["project"], except PermissionError as e:
} raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.post("/api/rag/query-global") @app.post("/api/rag/query-global")
async def api_rag_query_global(payload: dict): async def api_rag_query_global(payload: dict, user: UserContext = Depends(get_current_user)):
"""Глобальный запрос ко всем проектам."""
try: try:
config = load_config() result = await _rag_chat_for_user(
rag_cfg = config.get("rag", {}) user,
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) payload.get("question", ""),
api_key, base_url = resolve_opencode_credentials(config) payload.get("history", []),
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") None,
index_model = rag_cfg.get("index_model", "mimo-v2.5-free") payload.get("mode", "hybrid"),
mode = payload.get("mode", "hybrid")
result = await rag_chat(
question=payload.get("question", ""),
working_dir_base=index_dir,
history=payload.get("history", []),
api_key=api_key,
project_name=None,
base_url=base_url,
chat_model=chat_model,
mode=mode,
index_model=index_model,
) )
return { return {"answer": result["answer"], "context": result["context"], "project": None}
"answer": result["answer"], except HTTPException:
"context": result["context"], raise
"project": None,
}
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
@app.get("/api/rag/tasks") @app.post("/api/rag/index/{folder_name:path}")
async def api_rag_tasks(project: Optional[str] = None): async def api_rag_index_folder(folder_name: str, user: UserContext = Depends(get_current_user)):
"""Возвращает action items из RAG."""
try: try:
config = load_config() folder_path = resolve_meeting_path(user.org_slug, folder_name)
rag_cfg = config.get("rag", {})
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
api_key, base_url = resolve_opencode_credentials(config)
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free")
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
question = "Перечисли все action items, задачи и ответственных из протоколов."
if project:
question = f"Перечисли все action items, задачи и ответственных по проекту {project}."
result = await rag_chat(
question=question,
working_dir_base=index_dir,
history=[],
api_key=api_key,
project_name=project,
base_url=base_url,
chat_model=chat_model,
mode="hybrid",
index_model=index_model,
)
return {
"tasks": result["answer"],
"project": project,
}
except Exception as e:
return {"error": str(e)}
@app.post("/api/rag/index/{folder_name}")
async def api_rag_index_folder(folder_name: str):
"""Принудительная переиндексация папки с обработанным совещанием."""
try:
folder_path = PROCESSED_DIR / folder_name
if not folder_path.exists(): if not folder_path.exists():
return {"error": "Folder not found"} return {"error": "Folder not found"}
# Ищем .txt файл с протоколом from backend.queue import _folder_project_slug
project = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
if not project:
return {"error": "Project metadata not found"}
ensure_project_access(user, project)
txt_files = list(folder_path.glob("*.txt")) txt_files = list(folder_path.glob("*.txt"))
if not txt_files: if not txt_files:
return {"error": "No .txt protocol found in folder"} return {"error": "No .txt protocol found in folder"}
txt_path = txt_files[0] doc_text = txt_files[0].read_text(encoding="utf-8")
doc_text = txt_path.read_text(encoding="utf-8")
# Определяем проект из имени папки
project = parse_project_from_filename(folder_name)
config = load_config() config = load_config()
rag_cfg = config.get("rag", {}) rag_cfg = config.get("rag", {})
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches"))
index_model = rag_cfg.get("index_model", "mimo-v2.5-free")
api_key, base_url = resolve_opencode_credentials(config) api_key, base_url = resolve_opencode_credentials(config)
from src.rag.indexer import index_meeting
from src.rag.formatter import format_global_document
# Для переиндексации используем простую заглушку метаданных
metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"} metadata = {"project": project, "section": "Общие вопросы", "topic": "Переиндексация"}
global_doc_text = format_global_document(doc_text, metadata) global_doc_text = format_global_document(doc_text, metadata)
@ -379,54 +354,47 @@ async def api_rag_index_folder(folder_name: str):
doc_text=doc_text, doc_text=doc_text,
global_doc_text=global_doc_text, global_doc_text=global_doc_text,
project_name=project, project_name=project,
working_dir_base=index_dir, working_dir_base=_org_index_dir(user),
model=index_model, model=rag_cfg.get("index_model", "mimo-v2.5-free"),
api_key=api_key, api_key=api_key,
base_url=base_url, base_url=base_url,
) )
return {"indexed": folder_name, "project": project} return {"indexed": folder_name, "project": project}
except PermissionError as e:
raise HTTPException(status_code=403, detail=str(e)) from e
except Exception as e: except Exception as e:
return {"error": str(e)} return {"error": str(e)}
# === WebSocket Chat Actions === async def _handle_rag_query_ws(websocket: WebSocket, msg: dict, user: UserContext):
async def _handle_rag_query(websocket: WebSocket, msg: dict):
"""Обрабатывает rag_query через WebSocket."""
try: try:
config = load_config() project = msg.get("project")
rag_cfg = config.get("rag", {}) if msg.get("action") == "rag_query_global":
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) project = None
api_key, base_url = resolve_opencode_credentials(config) result = await _rag_chat_for_user(
chat_model = rag_cfg.get("chat_model", "deepseek-v4-flash-free") user,
index_model = rag_cfg.get("index_model", "mimo-v2.5-free") msg.get("question", ""),
mode = msg.get("mode", "hybrid") msg.get("history", []),
project,
result = await rag_chat( msg.get("mode", "hybrid"),
question=msg.get("question", ""),
working_dir_base=index_dir,
history=msg.get("history", []),
api_key=api_key,
project_name=msg.get("project"),
base_url=base_url,
chat_model=chat_model,
mode=mode,
index_model=index_model,
) )
await websocket.send_json({ await websocket.send_json({
"type": "rag_response", "type": "rag_response",
"answer": result["answer"], "answer": result["answer"],
"context": result["context"], "context": result["context"],
"project": result["project"], "project": result["project"],
}) })
except HTTPException as e:
await websocket.send_json({"type": "rag_error", "error": e.detail})
except PermissionError as e:
await websocket.send_json({"type": "rag_error", "error": str(e)})
except Exception as e: except Exception as e:
await websocket.send_json({ await websocket.send_json({"type": "rag_error", "error": str(e)})
"type": "rag_error",
"error": str(e),
})
# Статические файлы @app.get("/api/health")
app.mount("/static", StaticFiles(directory="backend/static"), name="static") async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

49
backend/paths.py Normal file
View File

@ -0,0 +1,49 @@
"""Org-scoped filesystem paths."""
import json
from datetime import datetime
from pathlib import Path
DATA_ROOT = Path("data")
UPLOAD_ROOT = Path("uploads")
PROCESSED_ROOT = Path("processed")
RAG_CACHE_DIRNAME = "lightrag_caches"
MEETINGS_DIRNAME = "meetings"
def org_upload_dir(org_slug: str, user_id: int) -> Path:
path = UPLOAD_ROOT / org_slug / str(user_id)
path.mkdir(parents=True, exist_ok=True)
return path
def org_meetings_dir(org_slug: str) -> Path:
path = PROCESSED_ROOT / org_slug / MEETINGS_DIRNAME
path.mkdir(parents=True, exist_ok=True)
return path
def org_rag_index_dir(org_slug: str) -> Path:
path = PROCESSED_ROOT / org_slug / RAG_CACHE_DIRNAME
path.mkdir(parents=True, exist_ok=True)
return path
def resolve_meeting_path(org_slug: str, rel_path: str) -> Path:
"""Resolve relative path under org meetings dir; reject traversal."""
base = org_meetings_dir(org_slug).resolve()
full = (base / rel_path).resolve()
if not str(full).startswith(str(base)):
raise ValueError("Invalid path")
return full
def write_folder_project_meta(folder_path: Path, project_slug: str) -> None:
meta = {
"project_slug": project_slug.strip().lower(),
"created_at": datetime.now().isoformat(),
}
(folder_path / ".project.json").write_text(
json.dumps(meta, ensure_ascii=False),
encoding="utf-8",
)

View File

@ -1,4 +1,4 @@
"""Фоновая очередь: транскрибация и post-processing (summary/RAG) разделены.""" """Фоновая очередь: транскрибация и post-processing с org/project изоляцией."""
import asyncio import asyncio
import json import json
@ -11,6 +11,9 @@ from typing import Any, Callable, Dict, List, Optional
sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent.parent))
from backend.auth.models import UserContext
from backend.auth.service import ensure_project_access
from backend.paths import org_meetings_dir, org_rag_index_dir, org_upload_dir, resolve_meeting_path, write_folder_project_meta
from src.audio_utils import prepare_audio_input from src.audio_utils import prepare_audio_input
from src.config import load_config, resolve_opencode_credentials from src.config import load_config, resolve_opencode_credentials
from src.document import build_document from src.document import build_document
@ -22,13 +25,7 @@ from src.rag.formatter import (
format_summary_markdown, format_summary_markdown,
) )
from src.rag.indexer import index_meeting from src.rag.indexer import index_meeting
from src.rag.parser import classify_meeting, generate_meeting_brief, parse_project_from_filename from src.rag.parser import classify_meeting, generate_meeting_brief
UPLOAD_DIR = Path("uploads")
PROCESSED_DIR = Path("processed")
UPLOAD_DIR.mkdir(exist_ok=True)
PROCESSED_DIR.mkdir(exist_ok=True)
tasks: Dict[str, Dict[str, Any]] = {} tasks: Dict[str, Dict[str, Any]] = {}
_progress_callback: Optional[Callable] = None _progress_callback: Optional[Callable] = None
@ -42,6 +39,33 @@ def set_progress_callback(callback: Callable):
_progress_callback = callback _progress_callback = callback
def _task_visible(task: Dict[str, Any], user: UserContext) -> bool:
if task.get("org_slug") != user.org_slug:
return False
if user.has_all_projects_access:
return True
return task.get("user_id") == user.user_id
def _filter_tasks_for_user(user: UserContext) -> List[Dict[str, Any]]:
return [t for t in tasks.values() if _task_visible(t, user)]
def _filter_queue_info(user: UserContext) -> Dict[str, Any]:
visible = _filter_tasks_for_user(user)
queued = sum(1 for t in visible if t.get("status") == "queued")
processing = sum(1 for t in visible if t.get("status") == "processing")
postprocessing = sum(1 for t in visible if t.get("status") == "postprocessing")
return {
"queued": queued,
"processing": processing,
"postprocessing": postprocessing,
"pending_transcribe": _transcribe_queue.qsize(),
"pending_postprocess": _postprocess_queue.qsize(),
"pending_in_queue": _transcribe_queue.qsize(),
}
async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None): async def _send_progress(task_id: str, progress: int, message: str, status: str, result=None, error=None):
if _progress_callback: if _progress_callback:
try: try:
@ -52,6 +76,7 @@ async def _send_progress(task_id: str, progress: int, message: str, status: str,
"message": message, "message": message,
"status": status, "status": status,
"file": task_info.get("file", ""), "file": task_info.get("file", ""),
"project": task_info.get("project_slug", ""),
"queue_position": task_info.get("queue_position"), "queue_position": task_info.get("queue_position"),
"result": result, "result": result,
"error": error, "error": error,
@ -64,7 +89,7 @@ def _cleanup_upload(file_path: Path):
if not file_path.exists(): if not file_path.exists():
return return
parent = file_path.parent parent = file_path.parent
if parent != UPLOAD_DIR and parent.name.startswith("task_"): if parent.name.startswith("task_"):
shutil.rmtree(parent, ignore_errors=True) shutil.rmtree(parent, ignore_errors=True)
else: else:
file_path.unlink(missing_ok=True) file_path.unlink(missing_ok=True)
@ -85,8 +110,12 @@ def _default_metadata(project: str, segments: List[Dict[str, Any]]) -> Dict[str,
async def process_transcription(file_path: Path, task_id: str): async def process_transcription(file_path: Path, task_id: str):
"""Этап 1: WhisperX + docx/md. После — в очередь summary/RAG.""" task = tasks.get(task_id, {})
display_name = tasks.get(task_id, {}).get("file", file_path.name) display_name = task.get("file", file_path.name)
org_slug = task["org_slug"]
project_slug = task["project_slug"]
meetings_dir = org_meetings_dir(org_slug)
tasks[task_id].update({ tasks[task_id].update({
"status": "processing", "status": "processing",
"progress": 0, "progress": 0,
@ -113,8 +142,9 @@ async def process_transcription(file_path: Path, task_id: str):
stem = Path(display_name).stem stem = Path(display_name).stem
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
folder_name = f"{stem}_{timestamp}" folder_name = f"{stem}_{timestamp}"
output_dir = PROCESSED_DIR / folder_name output_dir = meetings_dir / folder_name
await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True) await asyncio.to_thread(output_dir.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(write_folder_project_meta, output_dir, project_slug)
docx_path = output_dir / f"{stem}.docx" docx_path = output_dir / f"{stem}.docx"
md_path = output_dir / f"{stem}.md" md_path = output_dir / f"{stem}.md"
@ -130,22 +160,26 @@ async def process_transcription(file_path: Path, task_id: str):
await asyncio.to_thread(_cleanup_upload, file_path) await asyncio.to_thread(_cleanup_upload, file_path)
rel_dir = str(output_dir.relative_to(meetings_dir))
post_position = _postprocess_queue.qsize() + 1 post_position = _postprocess_queue.qsize() + 1
result_data = {
"docx": str(docx_path),
"md": str(md_path),
"dir": str(output_dir),
"rel_dir": rel_dir,
"project": project_slug,
}
tasks[task_id].update({ tasks[task_id].update({
"status": "postprocessing", "status": "postprocessing",
"progress": 70, "progress": 70,
"message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", "message": f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
"result": { "result": result_data,
"docx": str(docx_path),
"md": str(md_path),
"dir": str(output_dir),
},
}) })
await _send_progress( await _send_progress(
task_id, 70, task_id, 70,
f"Транскрибация готова. Summary/RAG в очереди (№{post_position})", f"Транскрибация готова. Summary/RAG в очереди (№{post_position})",
"postprocessing", "postprocessing",
result=tasks[task_id]["result"], result=result_data,
) )
await _postprocess_queue.put({ await _postprocess_queue.put({
@ -154,10 +188,10 @@ async def process_transcription(file_path: Path, task_id: str):
"output_dir": str(output_dir), "output_dir": str(output_dir),
"stem": stem, "stem": stem,
"segments_path": str(segments_path), "segments_path": str(segments_path),
"docx_path": str(docx_path), "org_slug": org_slug,
"md_path": str(md_path), "project_slug": project_slug,
}) })
print(f"[Transcribe] Задача {task_id} передана в post-processing") print(f"[Transcribe] {task_id} -> post-processing (project={project_slug})")
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
@ -172,11 +206,12 @@ async def process_transcription(file_path: Path, task_id: str):
async def process_postprocessing(job: Dict[str, Any]): async def process_postprocessing(job: Dict[str, Any]):
"""Этап 2: summary, txt, RAG — не блокирует следующую транскрибацию."""
task_id = job["task_id"] task_id = job["task_id"]
display_name = job["display_name"] display_name = job["display_name"]
stem = job["stem"] stem = job["stem"]
output_dir = Path(job["output_dir"]) output_dir = Path(job["output_dir"])
org_slug = job["org_slug"]
project = job["project_slug"]
summary_path = output_dir / f"{stem}_summary.md" summary_path = output_dir / f"{stem}_summary.md"
txt_path = output_dir / f"{stem}.txt" txt_path = output_dir / f"{stem}.txt"
@ -188,13 +223,13 @@ async def process_postprocessing(job: Dict[str, Any]):
await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing") await _send_progress(task_id, 75, "Summary и индексация...", "postprocessing")
result_data = dict(tasks[task_id].get("result") or {}) result_data = dict(tasks[task_id].get("result") or {})
meetings_dir = org_meetings_dir(org_slug)
try: try:
segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8")) segments = json.loads(Path(job["segments_path"]).read_text(encoding="utf-8"))
config = load_config() config = load_config()
rag_cfg = config.get("rag", {}) rag_cfg = config.get("rag", {})
api_key, base_url = resolve_opencode_credentials(config) api_key, base_url = resolve_opencode_credentials(config)
project = parse_project_from_filename(display_name)
meeting_text = build_meeting_text_only(segments) meeting_text = build_meeting_text_only(segments)
metadata = _default_metadata(project, segments) metadata = _default_metadata(project, segments)
@ -229,11 +264,7 @@ async def process_postprocessing(job: Dict[str, Any]):
chunk_size=summary_chunk_size, chunk_size=summary_chunk_size,
) )
summary_md = format_summary_markdown(metadata, brief, display_name) summary_md = format_summary_markdown(metadata, brief, display_name)
await asyncio.to_thread( await asyncio.to_thread(summary_path.write_text, summary_md, encoding="utf-8")
summary_path.write_text,
summary_md,
encoding="utf-8",
)
result_data["summary"] = str(summary_path) result_data["summary"] = str(summary_path)
doc_text = format_meeting_document(segments, metadata, display_name) doc_text = format_meeting_document(segments, metadata, display_name)
@ -241,10 +272,11 @@ async def process_postprocessing(job: Dict[str, Any]):
result_data["txt"] = str(txt_path) result_data["txt"] = str(txt_path)
result_data["metadata"] = metadata result_data["metadata"] = metadata
result_data["project"] = project result_data["project"] = project
result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True): if rag_cfg.get("enabled", False) and rag_cfg.get("auto_index", True):
await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing") await _send_progress(task_id, 92, "Индексация в базу знаний...", "postprocessing")
index_dir = Path(rag_cfg.get("project_index_dir", "./processed/lightrag_caches")) index_dir = org_rag_index_dir(org_slug)
global_doc_text = format_global_document(doc_text, metadata) global_doc_text = format_global_document(doc_text, metadata)
await index_meeting( await index_meeting(
doc_text=doc_text, doc_text=doc_text,
@ -265,6 +297,7 @@ async def process_postprocessing(job: Dict[str, Any]):
doc_text = format_meeting_document(segments, metadata, display_name) doc_text = format_meeting_document(segments, metadata, display_name)
await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8") await asyncio.to_thread(txt_path.write_text, doc_text, encoding="utf-8")
result_data["txt"] = str(txt_path) result_data["txt"] = str(txt_path)
result_data["rel_dir"] = str(output_dir.relative_to(meetings_dir))
await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data) await _send_progress(task_id, 100, "Обработка завершена", "completed", result=result_data)
tasks[task_id].update({ tasks[task_id].update({
@ -291,7 +324,6 @@ async def _transcribe_worker_loop(worker_id: int):
while True: while True:
try: try:
task_id, file_path = await _transcribe_queue.get() task_id, file_path = await _transcribe_queue.get()
print(f"[Transcribe Worker {worker_id}] задача {task_id}")
await process_transcription(file_path, task_id) await process_transcription(file_path, task_id)
_transcribe_queue.task_done() _transcribe_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
@ -305,7 +337,6 @@ async def _postprocess_worker_loop(worker_id: int):
while True: while True:
try: try:
job = await _postprocess_queue.get() job = await _postprocess_queue.get()
print(f"[Postprocess Worker {worker_id}] задача {job.get('task_id')}")
await process_postprocessing(job) await process_postprocessing(job)
_postprocess_queue.task_done() _postprocess_queue.task_done()
except asyncio.CancelledError: except asyncio.CancelledError:
@ -315,7 +346,6 @@ async def _postprocess_worker_loop(worker_id: int):
def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1): def start_workers(transcribe_workers: int = 2, postprocess_workers: int = 1):
"""Запускает пулы воркеров транскрибации и post-processing."""
global _workers global _workers
_workers.clear() _workers.clear()
for i in range(transcribe_workers): for i in range(transcribe_workers):
@ -330,10 +360,17 @@ def stop_workers():
w.cancel() w.cancel()
async def save_upload(content: bytes, filename: str) -> tuple[str, Path]: async def save_upload(
content: bytes,
filename: str,
user: UserContext,
project_slug: str,
) -> tuple[str, Path]:
ensure_project_access(user, project_slug)
slug = project_slug.strip().lower()
safe_name = Path(filename).name or "upload.bin" safe_name = Path(filename).name or "upload.bin"
task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}" task_id = f"task_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
task_dir = UPLOAD_DIR / task_id task_dir = org_upload_dir(user.org_slug, user.user_id) / task_id
task_dir.mkdir(parents=True, exist_ok=True) task_dir.mkdir(parents=True, exist_ok=True)
file_path = task_dir / safe_name file_path = task_dir / safe_name
@ -346,6 +383,10 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
"progress": 0, "progress": 0,
"message": f"В очереди транскрибации (№{queue_position})", "message": f"В очереди транскрибации (№{queue_position})",
"file": safe_name, "file": safe_name,
"project_slug": slug,
"org_slug": user.org_slug,
"user_id": user.user_id,
"username": user.username,
"queue_position": queue_position, "queue_position": queue_position,
"result": None, "result": None,
"error": None, "error": None,
@ -357,7 +398,9 @@ async def save_upload(content: bytes, filename: str) -> tuple[str, Path]:
return task_id, file_path return task_id, file_path
def get_queue_info() -> Dict[str, Any]: def get_queue_info(user: Optional[UserContext] = None) -> Dict[str, Any]:
if user:
return _filter_queue_info(user)
queued = sum(1 for t in tasks.values() if t.get("status") == "queued") queued = sum(1 for t in tasks.values() if t.get("status") == "queued")
processing = sum(1 for t in tasks.values() if t.get("status") == "processing") processing = sum(1 for t in tasks.values() if t.get("status") == "processing")
postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing") postprocessing = sum(1 for t in tasks.values() if t.get("status") == "postprocessing")
@ -371,11 +414,18 @@ def get_queue_info() -> Dict[str, Any]:
} }
def get_task_status(task_id: str) -> Optional[Dict[str, Any]]: def get_task_status(task_id: str, user: Optional[UserContext] = None) -> Optional[Dict[str, Any]]:
return tasks.get(task_id) task = tasks.get(task_id)
if not task:
return None
if user and not _task_visible(task, user):
return None
return task
def get_all_tasks() -> List[Dict[str, Any]]: def get_all_tasks(user: Optional[UserContext] = None) -> List[Dict[str, Any]]:
if user:
return _filter_tasks_for_user(user)
return list(tasks.values()) return list(tasks.values())
@ -392,39 +442,84 @@ def _file_sort_key(file_info: Dict[str, Any]) -> tuple:
return (4, name) return (4, name)
def get_processed_tree() -> List[Dict[str, Any]]: def _folder_project_slug(folder_name: str, meetings_dir: Path) -> Optional[str]:
meta_path = meetings_dir / folder_name / ".project.json"
if meta_path.exists():
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
return data.get("project_slug")
except Exception:
pass
return None
def get_processed_tree(user: UserContext) -> List[Dict[str, Any]]:
tree = [] tree = []
if not PROCESSED_DIR.exists(): meetings_dir = org_meetings_dir(user.org_slug)
if not meetings_dir.exists():
return tree return tree
skip_names = {"lightrag_caches", "lightrag_caches_test"}
skip_suffixes = ("_segments.json",) skip_suffixes = ("_segments.json",)
for item in sorted(PROCESSED_DIR.iterdir()): for item in sorted(meetings_dir.iterdir()):
if not item.is_dir() or item.name in skip_names: if not item.is_dir():
continue continue
project_slug = _folder_project_slug(item.name, meetings_dir)
if project_slug and not user.can_access_project(project_slug):
continue
files = [] files = []
for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})): for f in sorted(item.iterdir(), key=lambda p: _file_sort_key({"name": p.name})):
if f.is_file() and not f.name.endswith(skip_suffixes): if f.is_file() and not f.name.endswith(skip_suffixes) and f.name != ".project.json":
files.append({ files.append({
"name": f.name, "name": f.name,
"path": str(f.relative_to(PROCESSED_DIR)), "path": str(f.relative_to(meetings_dir)),
"size": f.stat().st_size, "size": f.stat().st_size,
"ext": f.suffix.lower(), "ext": f.suffix.lower(),
"kind": "summary" if "_summary" in f.name else "protocol", "kind": "summary" if "_summary" in f.name else "protocol",
}) })
tree.append({ tree.append({
"name": item.name, "name": item.name,
"path": str(item.relative_to(PROCESSED_DIR)), "path": str(item.relative_to(meetings_dir)),
"project": project_slug,
"files": files, "files": files,
"created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(), "created": datetime.fromtimestamp(item.stat().st_ctime).isoformat(),
}) })
return tree return tree
def read_file_content(rel_path: str) -> str: def read_file_content(user: UserContext, rel_path: str) -> str:
full_path = PROCESSED_DIR / rel_path full_path = resolve_meeting_path(user.org_slug, rel_path)
if not full_path.exists() or not full_path.is_file(): if not full_path.exists() or not full_path.is_file():
raise FileNotFoundError(f"Файл не найден: {rel_path}") raise FileNotFoundError(f"Файл не найден: {rel_path}")
folder = full_path.parent.name
meetings_dir = org_meetings_dir(user.org_slug)
project_slug = _folder_project_slug(folder, meetings_dir)
if project_slug and not user.can_access_project(project_slug):
raise PermissionError("Нет доступа к этому файлу")
with open(full_path, "r", encoding="utf-8") as f: with open(full_path, "r", encoding="utf-8") as f:
return f.read() return f.read()
def get_download_path(user: UserContext, rel_path: str) -> Path:
full_path = resolve_meeting_path(user.org_slug, rel_path)
if not full_path.exists() or not full_path.is_file():
raise FileNotFoundError(f"Файл не найден: {rel_path}")
folder = full_path.parent.name
meetings_dir = org_meetings_dir(user.org_slug)
project_slug = _folder_project_slug(folder, meetings_dir)
if project_slug and not user.can_access_project(project_slug):
raise PermissionError("Нет доступа к этому файлу")
return full_path
def delete_folder(user: UserContext, folder_rel: str) -> None:
folder_path = resolve_meeting_path(user.org_slug, folder_rel)
if not folder_path.is_dir():
raise FileNotFoundError("Folder not found")
project_slug = _folder_project_slug(folder_path.name, org_meetings_dir(user.org_slug))
if project_slug and not user.can_access_project(project_slug):
raise PermissionError("Нет доступа к этой папке")
shutil.rmtree(folder_path)

View File

@ -10,21 +10,158 @@ class TranscriptionApp {
this.renderTimeout = null; this.renderTimeout = null;
this.chatHistory = []; this.chatHistory = [];
this.chatProjects = []; this.chatProjects = [];
this.uploadProjects = [];
this.isChatThinking = false; this.isChatThinking = false;
this.user = null;
this.init(); this.init();
} }
init() { init() {
this.connectWebSocket(); if (!Auth.requireAuth()) return;
this.setupUpload(); this.user = Auth.getUser();
this.setupChat(); this.setupUserBar();
this.loadChatHistory(); this.loadProjects().then(() => {
this.connectWebSocket();
this.setupProjectsWorkspace();
this.setupUpload();
this.setupChat();
this.setupAdmin();
this.loadChatHistory();
});
}
setupUserBar() {
const info = document.getElementById('userInfo');
const logoutBtn = document.getElementById('logoutBtn');
if (info && this.user) {
const roleLabel = {
admin: 'администратор',
director: 'директор',
user: 'пользователь',
}[this.user.role] || this.user.role;
info.textContent = `${this.user.username} · ${this.user.org_name} · ${roleLabel}`;
}
logoutBtn?.addEventListener('click', () => Auth.logout());
}
downloadUrl(path) {
const token = encodeURIComponent(Auth.getToken() || '');
return `/api/files/download?path=${encodeURIComponent(path)}&token=${token}`;
}
async loadProjects() {
try {
const response = await Auth.apiFetch('/api/auth/projects');
const result = await response.json();
this.uploadProjects = result.projects || [];
this.renderMyProjectsList();
this.populateProjectSelect('uploadProjectSelect', this.uploadProjects, false);
await this.loadChatProjects();
} catch (e) {
console.error('Failed to load projects:', e);
this.showToast('Не удалось загрузить проекты', 'error');
}
}
populateProjectSelect(selectId, projects, includeGlobal) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = '';
if (includeGlobal && this.user?.all_projects_access) {
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = 'Все проекты';
select.appendChild(allOpt);
}
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.slug || p;
const scope = p.scope === 'personal' ? ' · мой' : (p.scope === 'org' ? ' · общий' : '');
opt.textContent = `${p.name || p.slug || p}${scope}`;
select.appendChild(opt);
});
if (!select.options.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'Нет доступных проектов';
select.appendChild(opt);
}
}
renderMyProjectsList() {
const container = document.getElementById('myProjectsList');
if (!container) return;
const projects = this.uploadProjects || [];
if (!projects.length) {
container.innerHTML = '<p class="empty-state">Нет проектов. Создайте первый проект ниже.</p>';
return;
}
container.innerHTML = projects.map(p => {
const badge = p.scope === 'personal'
? '<span class="project-badge personal">мой</span>'
: '<span class="project-badge org">общий</span>';
const deleteBtn = p.is_owner
? `<button type="button" class="project-delete" data-slug="${this.escapeHtml(p.slug)}" title="Удалить">✕</button>`
: '';
return `
<div class="project-card">
<div class="project-card-main">
<strong>${this.escapeHtml(p.name || p.slug)}</strong>
${badge}
</div>
<span class="project-slug">${this.escapeHtml(p.slug)}</span>
${deleteBtn}
</div>
`;
}).join('');
container.querySelectorAll('.project-delete').forEach(btn => {
btn.addEventListener('click', () => this.deleteMyProject(btn.dataset.slug));
});
}
setupProjectsWorkspace() {
document.getElementById('createMyProjectForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const slug = document.getElementById('myProjectSlug').value.trim();
const name = document.getElementById('myProjectName').value.trim();
try {
const response = await Auth.apiFetch('/api/auth/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, name }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ошибка создания проекта');
this.showToast(`Проект «${data.project.name}» создан`, 'success');
e.target.reset();
await this.loadProjects();
} catch (err) {
this.showToast(err.message, 'error');
}
});
}
async deleteMyProject(slug) {
if (!confirm(`Удалить проект «${slug}»?\n\nИндекс RAG и файлы не удаляются автоматически.`)) return;
try {
const response = await Auth.apiFetch(`/api/auth/projects/${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ошибка удаления');
this.showToast(`Проект «${slug}» удалён`, 'success');
await this.loadProjects();
} catch (err) {
this.showToast(err.message, 'error');
}
} }
// ===== WebSocket ===== // ===== WebSocket =====
connectWebSocket() { connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; this.ws = new WebSocket(Auth.wsUrl('/ws'));
this.ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
this.ws.onopen = () => { this.ws.onopen = () => {
console.log('WebSocket connected'); console.log('WebSocket connected');
@ -38,7 +175,12 @@ class TranscriptionApp {
this.handleWebSocketMessage(data); this.handleWebSocketMessage(data);
}; };
this.ws.onclose = () => { this.ws.onclose = (event) => {
if (event.code === 4401) {
Auth.clearSession();
window.location.href = '/login';
return;
}
console.log('WebSocket disconnected, reconnecting in 3s...'); console.log('WebSocket disconnected, reconnecting in 3s...');
setTimeout(() => this.connectWebSocket(), 3000); setTimeout(() => this.connectWebSocket(), 3000);
}; };
@ -126,7 +268,14 @@ class TranscriptionApp {
async handleFiles(files) { async handleFiles(files) {
if (!files.length) return; if (!files.length) return;
const project = document.getElementById('uploadProjectSelect')?.value;
if (!project) {
this.showToast('Выберите проект перед загрузкой', 'error');
return;
}
const formData = new FormData(); const formData = new FormData();
formData.append('project', project);
for (const file of files) { for (const file of files) {
formData.append('files', file); formData.append('files', file);
} }
@ -134,7 +283,7 @@ class TranscriptionApp {
try { try {
this.showToast(`Загрузка ${files.length} файл(а)...`, 'info'); this.showToast(`Загрузка ${files.length} файл(а)...`, 'info');
const response = await fetch('/upload-batch', { const response = await Auth.apiFetch('/upload-batch', {
method: 'POST', method: 'POST',
body: formData, body: formData,
}); });
@ -273,7 +422,7 @@ class TranscriptionApp {
const isDocx = file.ext === '.docx'; const isDocx = file.ext === '.docx';
const isTxt = file.ext === '.txt'; const isTxt = file.ext === '.txt';
const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎'; const icon = isSummary ? '📋' : isMd ? '📝' : isDocx ? '📄' : isTxt ? '📃' : '📎';
const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`; const downloadUrl = this.downloadUrl(file.path);
const cssClass = isSummary ? 'file-item file-summary' : 'file-item'; const cssClass = isSummary ? 'file-item file-summary' : 'file-item';
if (isDocx) { if (isDocx) {
@ -363,7 +512,7 @@ class TranscriptionApp {
async deleteFolder(folderName, folderElement) { async deleteFolder(folderName, folderElement) {
try { try {
const response = await fetch(`/api/folders/${encodeURIComponent(folderName)}`, { const response = await Auth.apiFetch(`/api/folders/${encodeURIComponent(folderName)}`, {
method: 'DELETE', method: 'DELETE',
}); });
const result = await response.json(); const result = await response.json();
@ -390,7 +539,7 @@ class TranscriptionApp {
if (ext === '.md' || ext === '.txt') { if (ext === '.md' || ext === '.txt') {
try { try {
const response = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`); const response = await Auth.apiFetch(`/api/files/content?path=${encodeURIComponent(path)}`);
const result = await response.json(); const result = await response.json();
if (result.error) { if (result.error) {
@ -406,7 +555,7 @@ class TranscriptionApp {
<div class="md-content"> <div class="md-content">
<div class="md-header"> <div class="md-header">
<span class="md-title">${this.escapeHtml(path)}</span> <span class="md-title">${this.escapeHtml(path)}</span>
<a href="/api/files/download?path=${encodeURIComponent(path)}" <a href="${this.downloadUrl(path)}"
class="btn-download" download> Скачать</a> class="btn-download" download> Скачать</a>
</div> </div>
<div class="md-body">${bodyHtml}</div> <div class="md-body">${bodyHtml}</div>
@ -493,20 +642,20 @@ class TranscriptionApp {
async loadChatProjects() { async loadChatProjects() {
try { try {
const response = await fetch('/api/rag/projects'); const response = await Auth.apiFetch('/api/rag/projects');
const result = await response.json(); const result = await response.json();
const select = document.getElementById('chatProjectSelect'); const projects = (result.projects || []).map(p => (
select.innerHTML = '<option value="">Все проекты</option>'; typeof p === 'string' ? { slug: p, name: p } : p
if (result.projects) { ));
result.projects.forEach(p => { this.populateProjectSelect('chatProjectSelect', projects, true);
const opt = document.createElement('option'); const hint = document.getElementById('chatHint');
opt.value = p; if (hint) {
opt.textContent = p; hint.textContent = this.user?.all_projects_access
select.appendChild(opt); ? 'Выберите проект или «Все проекты» для поиска по организации.'
}); : 'Выберите проект, к которому у вас есть доступ.';
} }
} catch (e) { } catch (e) {
console.error('Failed to load projects:', e); console.error('Failed to load chat projects:', e);
} }
} }
@ -520,6 +669,11 @@ class TranscriptionApp {
const select = document.getElementById('chatProjectSelect'); const select = document.getElementById('chatProjectSelect');
const project = select.value; const project = select.value;
if (!project && !this.user?.all_projects_access) {
this.showToast('Выберите проект для поиска', 'error');
return;
}
input.value = ''; input.value = '';
this.addChatBubble('user', question); this.addChatBubble('user', question);
this.setChatThinking(true); this.setChatThinking(true);
@ -629,6 +783,131 @@ class TranscriptionApp {
this.chatHistory = []; this.chatHistory = [];
} }
} }
setupAdmin() {
if (!this.user?.is_admin) return;
const section = document.getElementById('adminSection');
if (section) section.hidden = false;
this.refreshAdminData();
document.getElementById('createUserForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
const role = document.getElementById('newUserRole').value;
const projects = role === 'user'
? document.getElementById('newUserProjects').value.split(',').map(s => s.trim()).filter(Boolean)
: [];
try {
const response = await Auth.apiFetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('newUsername').value.trim(),
password: document.getElementById('newPassword').value,
role: document.getElementById('newUserRole').value,
projects,
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ошибка создания');
this.showToast(`Пользователь ${data.user.username} создан`, 'success');
e.target.reset();
this.toggleProjectsField();
this.refreshAdminData();
} catch (err) {
this.showToast(err.message, 'error');
}
});
document.getElementById('newUserRole')?.addEventListener('change', () => this.toggleProjectsField());
this.toggleProjectsField();
document.getElementById('createProjectForm')?.addEventListener('submit', async (e) => {
e.preventDefault();
try {
const response = await Auth.apiFetch('/api/admin/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slug: document.getElementById('newProjectSlug').value.trim(),
name: document.getElementById('newProjectName').value.trim(),
}),
});
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Ошибка');
this.showToast(`Проект ${data.project.slug} добавлен`, 'success');
e.target.reset();
await this.loadProjects();
this.refreshAdminData();
} catch (err) {
this.showToast(err.message, 'error');
}
});
}
toggleProjectsField() {
const role = document.getElementById('newUserRole')?.value;
const projectsInput = document.getElementById('newUserProjects');
const hint = document.getElementById('newUserProjectsHint');
const isUser = role === 'user';
if (projectsInput) {
projectsInput.disabled = !isUser;
projectsInput.placeholder = isUser
? 'Проекты через запятую (2026, gp-merakom)'
: 'Не требуется — доступ ко всем проектам организации';
}
if (hint) {
hint.textContent = role === 'director'
? 'Директор: все проекты, глобальный поиск, без панели администрирования.'
: role === 'admin'
? 'Администратор: полный доступ + управление пользователями.'
: 'Укажите slug общих проектов org, назначенных администратором.';
}
}
async refreshAdminData() {
if (!this.user?.is_admin) return;
try {
const [usersRes, projectsRes] = await Promise.all([
Auth.apiFetch('/api/admin/users'),
Auth.apiFetch('/api/admin/projects'),
]);
const usersData = await usersRes.json();
const projectsData = await projectsRes.json();
const usersEl = document.getElementById('adminUsersList');
if (usersEl) {
usersEl.innerHTML = (usersData.users || []).map(u => {
const shared = (u.shared_projects || u.projects || []).join(', ');
const owned = (u.owned_projects || []).join(', ');
const parts = [];
if (owned) parts.push(`личные: ${owned}`);
if (shared) parts.push(`общие: ${shared}`);
if (u.all_projects_access) parts.push('все проекты');
const projectsLabel = parts.join(' · ') || '—';
const roleLabel = { admin: 'admin', director: 'director', user: 'user' }[u.role] || u.role;
return `
<div class="admin-list-item">
<strong>${this.escapeHtml(u.username)}</strong>
<span>${roleLabel}</span>
<span>${this.escapeHtml(projectsLabel)}</span>
</div>
`}).join('');
}
const projectsEl = document.getElementById('adminProjectsList');
if (projectsEl) {
projectsEl.innerHTML = (projectsData.projects || []).map(p => `
<div class="admin-list-item">
<strong>${this.escapeHtml(p.slug)}</strong>
<span>${this.escapeHtml(p.name)}</span>
<span>${p.scope === 'personal' ? 'личный' : 'org'}</span>
</div>
`).join('');
}
} catch (e) {
console.error('Admin refresh failed:', e);
}
}
} }
// Initialize // Initialize

106
backend/static/auth.js Normal file
View File

@ -0,0 +1,106 @@
/**
* Shared auth helpers for Transcriba frontend.
*/
const Auth = {
TOKEN_KEY: 'transcriba_token',
USER_KEY: 'transcriba_user',
getToken() {
return localStorage.getItem(this.TOKEN_KEY);
},
setSession(token, user) {
localStorage.setItem(this.TOKEN_KEY, token);
localStorage.setItem(this.USER_KEY, JSON.stringify(user));
},
getUser() {
try {
const raw = localStorage.getItem(this.USER_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
},
clearSession() {
localStorage.removeItem(this.TOKEN_KEY);
localStorage.removeItem(this.USER_KEY);
localStorage.removeItem('transcriba_chat_history');
},
requireAuth() {
if (!this.getToken()) {
window.location.href = '/login';
return false;
}
return true;
},
authHeaders(extra = {}) {
const headers = { ...extra };
const token = this.getToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
},
async apiFetch(url, options = {}) {
const headers = this.authHeaders(options.headers || {});
const response = await fetch(url, { ...options, headers });
if (response.status === 401) {
this.clearSession();
window.location.href = '/login';
throw new Error('Требуется авторизация');
}
return response;
},
async login(orgSlug, username, password) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ org_slug: orgSlug, username, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || 'Ошибка входа');
}
this.setSession(data.access_token, data.user);
return data.user;
},
logout() {
this.clearSession();
window.location.href = '/login';
},
wsUrl(path = '/ws') {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = encodeURIComponent(this.getToken() || '');
return `${protocol}//${window.location.host}${path}?token=${token}`;
},
};
if (document.getElementById('loginForm')) {
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginBtn');
const errEl = document.getElementById('loginError');
errEl.hidden = true;
btn.disabled = true;
try {
await Auth.login(
document.getElementById('orgSlug').value.trim(),
document.getElementById('username').value.trim(),
document.getElementById('password').value
);
window.location.href = '/';
} catch (error) {
errEl.textContent = error.message;
errEl.hidden = false;
} finally {
btn.disabled = false;
}
});
}

View File

@ -6,20 +6,48 @@
<title>Транскрибация совещаний</title> <title>Транскрибация совещаний</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>"> <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎙️</text></svg>">
<link rel="stylesheet" href="/static/styles.css"> <link rel="stylesheet" href="/static/styles.css">
<!-- Markdown renderer -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
<script src="/static/auth.js"></script>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>🎙️ Транскрибация совещаний</h1> <div class="header-top">
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p> <div>
<h1>🎙️ Транскрибация совещаний</h1>
<p class="subtitle">Загрузите аудио или видео файл для получения протокола</p>
</div>
<div class="user-bar" id="userBar">
<span id="userInfo"></span>
<button type="button" id="logoutBtn" class="btn-secondary">Выйти</button>
</div>
</div>
</header> </header>
<main> <main>
<!-- Upload Section --> <section class="workspace-section" id="workspaceSection">
<div class="workspace-header">
<h2>📂 Мои проекты</h2>
<p class="workspace-hint">Создайте личный проект и работайте в нём: загрузка, протоколы, RAG-поиск.</p>
</div>
<div class="projects-panel">
<div id="myProjectsList" class="projects-list"></div>
<form id="createMyProjectForm" class="project-create-form">
<input type="text" id="myProjectSlug" placeholder="slug (например: gp-2026)" required>
<input type="text" id="myProjectName" placeholder="Название проекта" required>
<button type="submit">Создать проект</button>
</form>
</div>
</section>
<section class="upload-section" id="uploadSection"> <section class="upload-section" id="uploadSection">
<div class="upload-controls">
<label>
Проект
<select id="uploadProjectSelect" required></select>
</label>
</div>
<div class="drop-zone" id="dropZone"> <div class="drop-zone" id="dropZone">
<div class="drop-zone-content"> <div class="drop-zone-content">
<svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -33,7 +61,6 @@
<input type="file" id="fileInput" multiple accept=".mp4,.webm,.avi,.mkv,.mov,.wav,.mp3,.m4a,.ogg,.flac" hidden> <input type="file" id="fileInput" multiple accept=".mp4,.webm,.avi,.mkv,.mov,.wav,.mp3,.m4a,.ogg,.flac" hidden>
</div> </div>
<!-- Queue Status -->
<div class="queue-status" id="queueStatus"> <div class="queue-status" id="queueStatus">
<div class="queue-status-header"> <div class="queue-status-header">
<h3>Очередь обработки</h3> <h3>Очередь обработки</h3>
@ -45,7 +72,6 @@
</div> </div>
</section> </section>
<!-- Results Section -->
<section class="results-section"> <section class="results-section">
<div class="panel-left"> <div class="panel-left">
<h2>📁 Файлы</h2> <h2>📁 Файлы</h2>
@ -64,21 +90,18 @@
</div> </div>
</section> </section>
<!-- Chat Section -->
<section class="chat-section" id="chatSection"> <section class="chat-section" id="chatSection">
<div class="chat-header"> <div class="chat-header">
<h2>🤖 Чат с базой знаний</h2> <h2>🤖 Чат с базой знаний</h2>
<div class="chat-controls"> <div class="chat-controls">
<select id="chatProjectSelect"> <select id="chatProjectSelect"></select>
<option value="">Все проекты</option>
</select>
<button id="chatClearBtn" title="Очистить историю">🗑️</button> <button id="chatClearBtn" title="Очистить историю">🗑️</button>
</div> </div>
</div> </div>
<div class="chat-messages" id="chatMessages"> <div class="chat-messages" id="chatMessages">
<div class="chat-welcome"> <div class="chat-welcome">
<p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p> <p>Здравствуйте! Я помогу найти информацию в протоколах совещаний.</p>
<p class="chat-hint">Выберите проект или оставьте «Все проекты» для глобального поиска.</p> <p class="chat-hint" id="chatHint">Выберите проект для поиска.</p>
</div> </div>
</div> </div>
<div class="chat-input-row"> <div class="chat-input-row">
@ -86,12 +109,41 @@
<button id="chatSendBtn">Отправить</button> <button id="chatSendBtn">Отправить</button>
</div> </div>
</section> </section>
<section class="admin-section" id="adminSection" hidden>
<h2>⚙️ Администрирование</h2>
<div class="admin-grid">
<div class="admin-card">
<h3>Пользователи</h3>
<div id="adminUsersList" class="admin-list"></div>
<form id="createUserForm" class="admin-form">
<input type="text" id="newUsername" placeholder="Логин" required>
<input type="password" id="newPassword" placeholder="Пароль" required>
<select id="newUserRole">
<option value="user">user — выбранные проекты</option>
<option value="director">director — все проекты и поиск</option>
<option value="admin">admin — полный доступ</option>
</select>
<input type="text" id="newUserProjects" placeholder="Общие org-проекты через запятую">
<p class="admin-hint" id="newUserProjectsHint"></p>
<button type="submit">Создать</button>
</form>
</div>
<div class="admin-card">
<h3>Проекты организации</h3>
<div id="adminProjectsList" class="admin-list"></div>
<form id="createProjectForm" class="admin-form">
<input type="text" id="newProjectSlug" placeholder="slug (2026)" required>
<input type="text" id="newProjectName" placeholder="Название" required>
<button type="submit">Добавить проект</button>
</form>
</div>
</div>
</section>
</main> </main>
</div> </div>
<!-- Toast notifications -->
<div class="toast-container" id="toastContainer"></div> <div class="toast-container" id="toastContainer"></div>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>

32
backend/static/login.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вход — Транскрибация</title>
<link rel="stylesheet" href="/static/styles.css">
</head>
<body class="login-page">
<div class="login-card">
<h1>🎙️ Transcriba</h1>
<p class="login-subtitle">Вход в рабочее пространство организации</p>
<form id="loginForm">
<label>
Организация
<input type="text" id="orgSlug" value="merakom" autocomplete="organization" required>
</label>
<label>
Логин
<input type="text" id="username" autocomplete="username" required>
</label>
<label>
Пароль
<input type="password" id="password" autocomplete="current-password" required>
</label>
<button type="submit" id="loginBtn">Войти</button>
<p class="login-error" id="loginError" hidden></p>
</form>
</div>
<script src="/static/auth.js"></script>
</body>
</html>

View File

@ -750,3 +750,289 @@ header h1 {
gap: 10px; gap: 10px;
} }
} }
/* ===== Auth / Login ===== */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 420px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
box-shadow: var(--shadow);
}
.login-card h1 {
margin-bottom: 8px;
}
.login-subtitle {
color: var(--text-secondary);
margin-bottom: 24px;
}
.login-card label {
display: block;
margin-bottom: 14px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.login-card input {
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--text-primary);
}
.login-card button {
width: 100%;
margin-top: 8px;
padding: 12px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
cursor: pointer;
}
.login-error {
color: var(--error);
margin-top: 12px;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.user-bar {
display: flex;
align-items: center;
gap: 12px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.btn-secondary {
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 14px;
cursor: pointer;
}
.upload-controls {
margin-bottom: 12px;
}
.upload-controls label {
display: flex;
flex-direction: column;
gap: 6px;
color: var(--text-secondary);
max-width: 320px;
}
.upload-controls select {
padding: 8px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--text-primary);
}
.workspace-section {
margin-bottom: 24px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.workspace-header h2 {
margin-bottom: 6px;
}
.workspace-hint {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 16px;
}
.projects-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.projects-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
}
.project-card {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
position: relative;
}
.project-card-main {
display: flex;
align-items: center;
gap: 8px;
}
.project-slug {
font-size: 0.8rem;
color: var(--text-secondary);
}
.project-badge {
font-size: 0.7rem;
padding: 2px 6px;
border-radius: 999px;
}
.project-badge.personal {
background: rgba(74, 158, 255, 0.15);
color: var(--accent);
}
.project-badge.org {
background: rgba(74, 222, 128, 0.12);
color: var(--success);
}
.project-delete {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
}
.project-create-form {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.project-create-form input {
padding: 8px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--text-primary);
min-width: 180px;
}
.project-create-form button {
padding: 8px 16px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
cursor: pointer;
}
.admin-section {
margin-top: 24px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.admin-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.admin-card {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.admin-list {
margin: 12px 0;
max-height: 220px;
overflow-y: auto;
}
.admin-list-item {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
.admin-form {
display: flex;
flex-direction: column;
gap: 8px;
}
.admin-form input,
.admin-form select {
padding: 8px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--text-primary);
}
.admin-form button {
align-self: flex-start;
padding: 8px 14px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
cursor: pointer;
}
.admin-hint {
font-size: 0.85rem;
color: var(--text-secondary);
margin: 0;
}
@media (max-width: 768px) {
.admin-grid {
grid-template-columns: 1fr;
}
.header-top {
flex-direction: column;
}
}

View File

@ -71,6 +71,22 @@ queue:
transcribe_workers: 2 transcribe_workers: 2
postprocess_workers: 1 postprocess_workers: 1
# Авторизация и multi-tenant (org + projects)
auth:
jwt_secret: "" # Обязательно задайте JWT_SECRET в .env для production
jwt_expire_hours: 168
database_path: ./data/transcriba.db
bootstrap:
org_slug: merakom
org_name: "МЕРАКОМ"
admin_username: admin
admin_password: "admin123" # Смените после первого входа; или AUTH_ADMIN_PASSWORD в .env
default_projects:
- slug: "2026"
name: "2026"
- slug: "gp-merakom"
name: "ГП МЕРАКОМ"
# Пути # Пути
paths: paths:
output_dir: ./output output_dir: ./output

View File

@ -16,7 +16,8 @@ services:
- OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1} - OPENCODE_URL=${OPENCODE_URL:-https://opencode.ai/zen/v1}
- HF_HOME=/root/.cache/huggingface - HF_HOME=/root/.cache/huggingface
- NLTK_DATA=/root/nltk_data - NLTK_DATA=/root/nltk_data
- TRANSFORMERS_OFFLINE=0 - JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- AUTH_ADMIN_PASSWORD=${AUTH_ADMIN_PASSWORD:-admin123}
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
- processed:/app/processed - processed:/app/processed
@ -27,11 +28,12 @@ services:
- ./scripts:/app/scripts:ro - ./scripts:/app/scripts:ro
- ./models/huggingface:/root/.cache/huggingface - ./models/huggingface:/root/.cache/huggingface
- ./models/nltk_data:/root/nltk_data:ro - ./models/nltk_data:/root/nltk_data:ro
- data:/app/data
restart: unless-stopped restart: unless-stopped
entrypoint: ["uvicorn"] entrypoint: ["uvicorn"]
command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"] command: ["backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/files"] test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@ -41,3 +43,4 @@ volumes:
uploads: uploads:
processed: processed:
tmp: tmp:
data:

View File

@ -6,6 +6,10 @@ python-docx
pyyaml pyyaml
whisperx whisperx
# Auth
bcrypt>=4.0.0
python-jose[cryptography]
# RAG / LightRAG # RAG / LightRAG
lightrag-hku>=1.4.0 lightrag-hku>=1.4.0
openai>=1.0.0 openai>=1.0.0

170
tests/test_auth.py Normal file
View File

@ -0,0 +1,170 @@
"""Tests for multi-tenant auth (without WhisperX dependency)."""
import os
import tempfile
import unittest
from pathlib import Path
from fastapi import FastAPI
from fastapi.testclient import TestClient
from backend.auth.database import bootstrap_from_config, init_db
from backend.auth.routes import admin_router, router as auth_router
def _build_test_app() -> FastAPI:
app = FastAPI()
app.include_router(auth_router)
app.include_router(admin_router)
return app
class AuthTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._tmpdir = tempfile.TemporaryDirectory()
cls.db_path = Path(cls._tmpdir.name) / "test.db"
os.environ["JWT_SECRET"] = "test-secret-key"
os.environ["AUTH_ADMIN_PASSWORD"] = "admin123"
os.environ["AUTH_DATABASE_PATH"] = str(cls.db_path)
config = {
"auth": {
"database_path": str(cls.db_path),
"bootstrap": {
"org_slug": "merakom",
"org_name": "Test Org",
"admin_username": "admin",
"default_projects": [
{"slug": "2026", "name": "2026"},
{"slug": "gp-merakom", "name": "GP"},
],
},
}
}
init_db(config)
bootstrap_from_config(config)
cls.client = TestClient(_build_test_app())
@classmethod
def tearDownClass(cls):
cls._tmpdir.cleanup()
os.environ.pop("AUTH_DATABASE_PATH", None)
def test_login_admin(self):
response = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "admin", "password": "admin123"},
)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("access_token", data)
self.assertTrue(data["user"]["is_admin"])
def test_create_user_and_project_acl(self):
admin_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "admin", "password": "admin123"},
).json()
headers = {"Authorization": f"Bearer {admin_login['access_token']}"}
create_user = self.client.post(
"/api/admin/users",
headers=headers,
json={
"username": "worker",
"password": "worker123",
"role": "user",
"projects": ["2026"],
},
)
self.assertEqual(create_user.status_code, 200)
user_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "worker", "password": "worker123"},
).json()
user_headers = {"Authorization": f"Bearer {user_login['access_token']}"}
projects = self.client.get("/api/auth/projects", headers=user_headers).json()
slugs = {p["slug"] for p in projects["projects"]}
self.assertEqual(slugs, {"2026"})
def test_director_has_all_projects_and_no_admin(self):
admin_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "admin", "password": "admin123"},
).json()
headers = {"Authorization": f"Bearer {admin_login['access_token']}"}
create_director = self.client.post(
"/api/admin/users",
headers=headers,
json={
"username": "director1",
"password": "dir123",
"role": "director",
"projects": [],
},
)
self.assertEqual(create_director.status_code, 200)
self.assertTrue(create_director.json()["user"]["all_projects_access"])
self.assertFalse(create_director.json()["user"]["is_admin"])
director_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "director1", "password": "dir123"},
).json()
d_headers = {"Authorization": f"Bearer {director_login['access_token']}"}
projects = self.client.get("/api/auth/projects", headers=d_headers).json()
slugs = {p["slug"] for p in projects["projects"]}
self.assertEqual(slugs, {"2026", "gp-merakom"})
global_query = self.client.post(
"/api/rag/query-global",
headers=d_headers,
json={"question": "test"},
)
self.assertNotEqual(global_query.status_code, 403)
def test_user_creates_personal_project(self):
admin_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "admin", "password": "admin123"},
).json()
headers = {"Authorization": f"Bearer {admin_login['access_token']}"}
self.client.post(
"/api/admin/users",
headers=headers,
json={
"username": "builder",
"password": "build123",
"role": "user",
"projects": [],
},
)
user_login = self.client.post(
"/api/auth/login",
json={"org_slug": "merakom", "username": "builder", "password": "build123"},
).json()
user_headers = {"Authorization": f"Bearer {user_login['access_token']}"}
created = self.client.post(
"/api/auth/projects",
headers=user_headers,
json={"slug": "my-gp", "name": "Мой ГП"},
)
self.assertEqual(created.status_code, 200)
self.assertEqual(created.json()["project"]["scope"], "personal")
self.assertTrue(created.json()["project"]["is_owner"])
projects = self.client.get("/api/auth/projects", headers=user_headers).json()
slugs = {p["slug"] for p in projects["projects"]}
self.assertEqual(slugs, {"my-gp"})
if __name__ == "__main__":
unittest.main()