Исправление ошибок, добавление ИИ-ассистента

This commit is contained in:
2026-02-16 14:43:14 +03:00
parent 5b7ea9276b
commit 213d2bcb5a
18 changed files with 1672 additions and 426 deletions
+2
View File
@@ -11,6 +11,7 @@ from .relationships import router as relationships_router
from .cap import router as cap_router
from .users import router as users_router
from .misc import router as misc_router
from .llm import router as llm_router
api_router = APIRouter()
@@ -26,3 +27,4 @@ api_router.include_router(loans_router, prefix="/api")
api_router.include_router(cap_router, prefix="/api")
api_router.include_router(users_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api")
api_router.include_router(llm_router, prefix="/api")
+253
View File
@@ -0,0 +1,253 @@
"""Модуль обработки запросов к llm"""
import asyncio, json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, status
from ollama import AsyncClient
from library_service.settings import get_logger, OLLAMA_URL, ASSISTANT_LLM
from library_service.auth import RequireStaffWS
logger = get_logger()
router = APIRouter(prefix="/llm")
client = AsyncClient(host=OLLAMA_URL)
SYSTEM_PROMPT = """
Ты — ассистент библиотекаря. Помогаешь заполнять карточку книги.
Доступные поля:
- title (строка) — название книги
- description (строка) — описание книги
- page_count (целое число ≥ 1 или null) — количество страниц
- status — ТОЛЬКО ДЛЯ ЧТЕНИЯ, изменять ЗАПРЕЩЕНО
Правила:
1. Используй инструменты для изменения полей книги.
2. Можешь вызывать несколько инструментов за раз.
3. Если пользователь просит изменить status — вежливо откажи.
4. Если page_count задаётся — только целое число ≥ 1.
5. Для очистки поля передавай null.
6. Отвечай кратко и по делу."""
TOOLS = [
{
"type": "function",
"function": {
"name": "set_title",
"description": "Установить или очистить название книги",
"parameters": {
"type": "object",
"properties": {
"value": {
"type": ["string", "null"],
"description": "Новое название или null для очистки",
}
},
"required": ["value"],
},
},
},
{
"type": "function",
"function": {
"name": "set_description",
"description": "Установить или очистить описание книги",
"parameters": {
"type": "object",
"properties": {
"value": {
"type": ["string", "null"],
"description": "Новое описание или null для очистки",
}
},
"required": ["value"],
},
},
},
{
"type": "function",
"function": {
"name": "set_page_count",
"description": "Установить или очистить количество страниц",
"parameters": {
"type": "object",
"properties": {
"value": {
"type": ["integer", "null"],
"minimum": 1,
"description": "Количество страниц (≥1) или null для очистки",
}
},
"required": ["value"],
},
},
},
]
TOOL_TO_TYPE = {
"set_title": "title",
"set_description": "description",
"set_page_count": "page_count",
}
@router.websocket("/book")
async def assist_book(websocket: WebSocket, current_user: RequireStaffWS):
"""WebSocket-ассистент для заполнения карточки книги.
IN (от клиента):
{
"prompt": "текст запроса",
"fields": {
"title": "...",
"description": "...",
"page_count": 1,
"status": "..."
}
}
OUT (от сервера, потоково, много сообщений):
{
"type": "info" | "thinking" | "title" | "description" | "page_count" | "end",
"value": "..." | int | null
}
"""
await websocket.accept()
messages: list[dict] = [
{"role": "system", "content": SYSTEM_PROMPT}
]
try:
if not ASSISTANT_LLM:
await websocket.close(status.WS_1011_INTERNAL_ERROR, "LLM not available")
return
while True:
request = await websocket.receive_json()
prompt: str = request["prompt"]
fields: dict = request.get("fields", {})
user_content = (
f"Текущие поля книги:\n"
f"- title: {fields.get('title', '')!r}\n"
f"- description: {fields.get('description', '')!r}\n"
f"- page_count: {fields.get('page_count')!r}\n"
f"- status: {fields.get('status', '-')!r} (read-only)\n\n"
f"Запрос: {prompt}"
)
messages.append({"role": "user", "content": user_content})
await _process_llm_loop(websocket, messages)
except WebSocketDisconnect:
pass
async def _process_llm_loop(websocket: WebSocket, messages: list[dict]):
while True:
assistant_text, tool_calls = await _stream_response(websocket, messages)
assistant_msg: dict = {"role": "assistant", "content": assistant_text or ""}
if tool_calls:
assistant_msg["tool_calls"] = tool_calls
messages.append(assistant_msg)
if not tool_calls:
await websocket.send_json({
"type": "end",
"value": "",
})
break
for call in tool_calls:
func_name = call["function"]["name"]
raw_args = call["function"].get("arguments", {})
if isinstance(raw_args, str):
try:
args = json.loads(raw_args)
except (json.JSONDecodeError, KeyError):
args = {}
elif isinstance(raw_args, dict):
args = raw_args
else:
args = {}
value = args.get("value")
msg_type = TOOL_TO_TYPE.get(func_name)
if msg_type:
if msg_type == "page_count" and value is not None:
if not isinstance(value, int) or value < 1:
value = None
await websocket.send_json({
"type": msg_type,
"value": value,
})
messages.append({
"role": "tool",
"content": json.dumps({
"status": "ok",
"field": msg_type,
"value": value,
}),
})
else:
messages.append({
"role": "tool",
"content": json.dumps({
"status": "error",
"message": f"Unknown tool: {func_name}",
}),
})
async def _stream_response(
websocket: WebSocket,
messages: list[dict],
) -> tuple[str, list[dict]]:
"""Стримит ответ модели в WebSocket."""
full_text = ""
full_thinking = ""
tool_calls: list[dict] = []
in_thinking = False
stream = await client.chat(
model=ASSISTANT_LLM,
messages=messages,
tools=TOOLS,
stream=True,
)
async for chunk in stream:
message = chunk.get("message", {})
thinking_content = message.get("thinking", "")
if thinking_content:
in_thinking = True
full_thinking += thinking_content
await websocket.send_json({
"type": "thinking",
"value": thinking_content,
})
content = message.get("content", "")
if content:
if in_thinking:
in_thinking = False
full_text += content
await websocket.send_json({
"type": "info",
"value": content,
})
if message.get("tool_calls"):
tool_calls.extend(message["tool_calls"])
return full_text, tool_calls
+8 -8
View File
@@ -84,7 +84,7 @@ async def edit_author(request: Request, author_id: str, app=Depends(lambda: get_
"""Рендерит страницу редактирования автора"""
try:
author = session.get(Author, int(author_id))
author = session.get(Author, int(author_id)) # ty: ignore
assert author is not None
except:
return await unknown(request, app)
@@ -100,7 +100,7 @@ async def author(request: Request, author_id: str, app=Depends(lambda: get_app()
return RedirectResponse("/authors")
try:
author = session.get(Author, int(author_id))
author = session.get(Author, int(author_id)) # ty: ignore
assert author is not None
except:
return await unknown(request, app)
@@ -125,7 +125,7 @@ async def edit_book(request: Request, book_id: str, app=Depends(lambda: get_app(
"""Рендерит страницу редактирования книги"""
try:
book = session.get(Book, int(book_id))
book = session.get(Book, int(book_id)) # ty: ignore
assert book is not None
except:
return await unknown(request, app)
@@ -141,7 +141,7 @@ async def book(request: Request, book_id: str, app=Depends(lambda: get_app()), s
return RedirectResponse("/books")
try:
book = session.get(Book, int(book_id))
book = session.get(Book, int(book_id)) # ty: ignore
assert book is not None
except:
return await unknown(request, app)
@@ -238,9 +238,9 @@ async def api_stats(session=Depends(get_session)):
users = select(func.count()).select_from(User)
return JSONResponse(
content={
"authors": session.exec(authors).one(),
"books": session.exec(books).one(),
"genres": session.exec(genres).one(),
"users": session.exec(users).one(),
"authors": session.exec(authors).one(), # ty: ignore
"books": session.exec(books).one(), # ty: ignore
"genres": session.exec(genres).one(), # ty: ignore
"users": session.exec(users).one(), # ty: ignore
}
)