Files
darkorion 0f239d5015 feat: документация из Nextcloud, график у сотрудника, автоочистка фото, мониторинг, итог мастера, фильтр по местам; доп. сервисы в docker-compose
- docker-compose.yml: профили docs (Nextcloud), office (ONLYOFFICE Document Server), ai (Ollama + скачивание модели)
- автонастройка: адреса Nextcloud, ONLYOFFICE, портала и модели подставляются из .env или по именам сервисов
  во внутренней сети Docker; значения из интерфейса важнее
- локальная модель в своей сети (имя сервиса, частный IP) подключается без ключа API
- .env.example, docs/DEPLOY.md (раздел 7а), README
2026-10-13 13:27:20 +03:00

87 lines
4.3 KiB
Python

"""AI-помощник: статус, чат (любой вошедший пользователь, если включено), настройки и проверка (администратор)."""
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from .. import assistant as A, settings_store as ss
from ..db import get_db
from ..models import User
from ..security import current_user, require_portal_admin as require_admin
from .catalog import commit
router = APIRouter(prefix="/assistant", tags=["assistant"])
class Msg(BaseModel):
role: Literal["user", "assistant"]
content: str = Field(max_length=4000)
class ChatIn(BaseModel):
messages: list[Msg] = Field(min_length=1, max_length=40)
class ConfigIn(BaseModel):
enabled: Optional[bool] = None
provider: Optional[Literal["openai", "anthropic", "gigachat"]] = None
base_url: Optional[str] = Field(default=None, max_length=300)
model: Optional[str] = Field(default=None, max_length=200)
api_key: Optional[str] = Field(default=None, max_length=2000) # пустая строка — удалить ключ; null — не менять
scope: Optional[str] = Field(default=None, max_length=2000) # доп. указания помощнику от администратора
@router.get("/status")
def status(db: Session = Depends(get_db), _: User = Depends(current_user)):
return {"enabled": A.ready(db)}
@router.post("/chat")
async def chat(data: ChatIn, request: Request, db: Session = Depends(get_db), u: User = Depends(current_user)):
token = request.headers.get("authorization", "")[7:]
try:
return await A.chat(request.app, db, u, token, [m.model_dump() for m in data.messages])
except A.AssistantError as e:
raise HTTPException(429 if "Слишком много" in str(e) else 503 if "выключен" in str(e) or "провайдер" in str(e).lower() else 422, str(e))
@router.get("/config")
def get_config(db: Session = Depends(get_db), _: User = Depends(require_admin)):
return A.public_cfg(db)
@router.put("/config")
def put_config(data: ConfigIn, db: Session = Depends(get_db), _: User = Depends(require_admin)):
d = data.model_dump(exclude_unset=True)
if d.get("base_url") and not d["base_url"].strip().lower().startswith(("https://", "http://")):
raise HTTPException(422, "Адрес провайдера должен начинаться с https:// (или http:// для локальной модели)")
for k, v in d.items():
if v is None:
continue
ss.set_(db, "assistant_" + k, ("1" if v else "0") if isinstance(v, bool) else str(v).strip())
commit(db)
c = A.cfg(db)
if c["enabled"] and (not c["model"] or not (c["api_key"] or A.autoconf.is_local(c["base_url"]))):
ss.set_(db, "assistant_enabled", "0")
commit(db)
raise HTTPException(422, "Для включения укажите модель и ключ API (без ключа — только локальная модель)")
return A.public_cfg(db)
@router.post("/test")
async def test(request: Request, db: Session = Depends(get_db), u: User = Depends(require_admin)):
"""Проверка подключения: короткий вопрос по сервису и вопрос не по теме (должен быть отклонён)."""
c = A.cfg(db)
if not c["model"]:
raise HTTPException(422, "Укажите модель")
try:
token = request.headers.get("authorization", "")[7:]
if not A.ready(db): # проверяем и до включения: временно считаем помощника включённым
c["enabled"] = True
ok = await A.chat(request.app, db, u, token, [{"role": "user", "content": "Какие у меня задания на сегодня и где их посмотреть?"}], cfg_override=c)
off = await A.complete(c, A.system_prompt(u, c["scope"]), [{"role": "user", "content": "Напиши стихотворение про весну"}])
except A.AssistantError as e:
raise HTTPException(502, str(e))
return {"ok": True, "answer": ok["reply"][:600], "tools": ok["tools"], "offtopic_filtered": off.strip().upper().startswith("OFFTOPIC")}