This commit is contained in:
commit
46af290db3
100 files changed
+16663
No files matched your search
@@ -0,0 +1,40 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
.venv
|
||||
|
||||
# Данные (монтируются как volume, не копируются в образ)
|
||||
data/
|
||||
|
||||
# Переменные окружения (секреты не должны попасть в образ)
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Документация
|
||||
*.md
|
||||
!requirements.txt
|
||||
|
||||
# Тесты
|
||||
tests/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
@@ -0,0 +1,18 @@
|
||||
# OpenRouter API
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# Ollama — имя контейнера в docker-compose сети
|
||||
OLLAMA_BASE_URL=http://ollama:11434
|
||||
|
||||
# Путь к данным — монтируется volume
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Окружение
|
||||
ENVIRONMENT=production
|
||||
|
||||
# CORS — домен вашего сервера
|
||||
CORS_ORIGINS=http://localhost:3000,http://your-server-ip:3000
|
||||
|
||||
# JWT секрет — сгенерируйте случайную строку!
|
||||
JWT_SECRET_KEY=generate-a-long-random-string-here
|
||||
@@ -0,0 +1,18 @@
|
||||
# OpenRouter API
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# Ollama — в Docker это будет http://ollama:11434 или IP хоста
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Путь к данным — в Docker будет монтироваться volume
|
||||
DATA_DIR=/app/data
|
||||
|
||||
# Окружение
|
||||
ENVIRONMENT=development
|
||||
|
||||
# CORS — через запятую, если несколько источников
|
||||
CORS_ORIGINS=http://localhost:3000,http://your-domain.com
|
||||
|
||||
# JWT секрет — ОБЯЗАТЕЛЬНО измените в production!
|
||||
JWT_SECRET_KEY=change-this-to-a-random-secret-string
|
||||
@@ -0,0 +1,8 @@
|
||||
# OpenRouter API Key (получить на https://openrouter.ai)
|
||||
OPENROUTER_API_KEY="sk-yapWeqHgWRbBQZcWOKSlq4bmd_uYC2oB"
|
||||
|
||||
# Ollama URL (по умолчанию localhost)
|
||||
OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Режим разработки
|
||||
ENVIRONMENT=development
|
||||
@@ -0,0 +1,11 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
data/
|
||||
.env
|
||||
@@ -0,0 +1,49 @@
|
||||
# Используем slim-образ Python для меньшего размера
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Устанавливаем системные зависимости для WeasyPrint и Pandoc
|
||||
# WeasyPrint требует: cairo, pango, gdk-pixbuf2, libffi
|
||||
# Pandoc — просто устанавливается через apt
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Для WeasyPrint
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libffi-dev \
|
||||
shared-mime-info \
|
||||
# Для Pandoc
|
||||
pandoc \
|
||||
# Утилиты
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Устанавливаем рабочую директорию
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем requirements и устанавливаем Python-зависимости
|
||||
# (отдельным слоем для кэширования)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Создаём папку для данных (будет переопределена volume при запуске)
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Обновляем кэш MIME-типов (нужно для WeasyPrint)
|
||||
RUN update-mime-database /usr/share/mime
|
||||
|
||||
# Переменные окружения по умолчанию
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
DATA_DIR=/app/data \
|
||||
OLLAMA_BASE_URL=http://ollama:11434 \
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Открываем порт
|
||||
EXPOSE 8000
|
||||
|
||||
# Команда запуска
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,91 @@
|
||||
# Writer IDE Backend
|
||||
|
||||
## Установка
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate # На Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Все настройки читаются из переменных окружения. Создайте файл `.env` на основе `.env.example`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env, указав ваши значения
|
||||
```
|
||||
|
||||
### Ключевые переменные
|
||||
|
||||
| Переменная | Описание | По умолчанию |
|
||||
|------------|----------|--------------|
|
||||
| `OPENROUTER_API_KEY` | API ключ OpenRouter | (пусто) |
|
||||
| `OPENROUTER_BASE_URL` | URL OpenRouter API | https://openrouter.ai/api/v1 |
|
||||
| `OLLAMA_BASE_URL` | URL Ollama сервера | http://localhost:11434 |
|
||||
| `DATA_DIR` | Путь к папке с данными | data |
|
||||
| `CORS_ORIGINS` | Разрешённые источники (через запятую) | http://localhost:3000 |
|
||||
| `JWT_SECRET_KEY` | Секрет для JWT токенов | (захардкожен, изменить в production!) |
|
||||
| `ENVIRONMENT` | Окружение | development |
|
||||
|
||||
### Docker-окружение
|
||||
|
||||
При запуске в Docker используйте файл `.env.docker` как шаблон:
|
||||
|
||||
```bash
|
||||
cp .env.docker .env
|
||||
```
|
||||
|
||||
Обратите внимание:
|
||||
- `OLLAMA_BASE_URL` должен указывать на контейнер Ollama (например, http://ollama:11434)
|
||||
- `DATA_DIR` должен быть `/app/data` (монтируется volume)
|
||||
- `CORS_ORIGINS` должен включать домен/IP вашего сервера
|
||||
- `JWT_SECRET_KEY` **обязательно** измените на случайную строку!
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## Проверка
|
||||
- API Health: http://localhost:8000/api/health
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
|
||||
## Если возникли проблемы с Python 3.14
|
||||
|
||||
Если пакеты не устанавливаются, используйте pyenv для создания окружения с Python 3.12:
|
||||
|
||||
```bash
|
||||
pyenv install 3.12.0
|
||||
pyenv local 3.12.0
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Установка Pandoc (для экспорта в EPUB, FB2, PDF, DOCX)
|
||||
|
||||
Для работы функции экспорта необходимо установить [Pandoc](https://pandoc.org/) — универсальный конвертер документов.
|
||||
|
||||
### Ubuntu / Debian
|
||||
```bash
|
||||
sudo apt-get install pandoc
|
||||
```
|
||||
|
||||
### macOS
|
||||
```bash
|
||||
brew install pandoc
|
||||
```
|
||||
|
||||
### Windows
|
||||
Скачайте установщик с официального сайта: [pandoc.org/installing.html](https://pandoc.org/installing.html)
|
||||
|
||||
### Проверка установки
|
||||
```bash
|
||||
pandoc --version
|
||||
```
|
||||
|
||||
> **Примечание:** Для экспорта в PDF также требуется LaTeX-движок (например, `texlive-xetex` на Ubuntu или `MacTeX` на macOS). По умолчанию используется `xelatex`.
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"ollama": {
|
||||
"default_model": "qwen2.5:1.5b",
|
||||
"available_models": [
|
||||
{
|
||||
"id": "qwen2.5:1.5b",
|
||||
"name": "Qwen 2.5 1.5B (Local)",
|
||||
"description": "Компактная модель для быстрых задач",
|
||||
"context_length": 32768
|
||||
},
|
||||
{
|
||||
"id": "qwen2.5:7b",
|
||||
"name": "Qwen 2.5 7B (Local)",
|
||||
"description": "Баланс скорости и качества",
|
||||
"context_length": 32768
|
||||
},
|
||||
{
|
||||
"id": "qwen2.5:14b",
|
||||
"name": "Qwen 2.5 14B (Local)",
|
||||
"description": "Высокое качество, требует больше ресурсов",
|
||||
"context_length": 32768
|
||||
}
|
||||
]
|
||||
},
|
||||
"openrouter": {
|
||||
"default_model": "qwen/qwen-2.5-coder-7b-instruct",
|
||||
"available_models": [
|
||||
{
|
||||
"id": "qwen/qwen-2.5-coder-7b-instruct",
|
||||
"name": "Qwen 2.5 Coder 7B",
|
||||
"description": "Специализированная модель для кода, быстрая и дешевая",
|
||||
"context_length": 32768,
|
||||
"pricing": "$0.0001/1K tokens"
|
||||
},
|
||||
{
|
||||
"id": "qwen/qwen-2.5-coder-32b-instruct",
|
||||
"name": "Qwen 2.5 Coder 32B",
|
||||
"description": "Мощная модель для сложных задач",
|
||||
"context_length": 32768,
|
||||
"pricing": "$0.0008/1K tokens"
|
||||
},
|
||||
{
|
||||
"id": "qwen/qwen-2.5-7b-instruct",
|
||||
"name": "Qwen 2.5 7B Instruct",
|
||||
"description": "Универсальная модель для текста",
|
||||
"context_length": 32768,
|
||||
"pricing": "$0.0001/1K tokens"
|
||||
},
|
||||
{
|
||||
"id": "qwen/qwen-2.5-72b-instruct",
|
||||
"name": "Qwen 2.5 72B Instruct",
|
||||
"description": "Максимальное качество, дороже",
|
||||
"context_length": 32768,
|
||||
"pricing": "$0.0008/1K tokens"
|
||||
},
|
||||
{
|
||||
"id": "meta-llama/llama-3.1-8b-instruct",
|
||||
"name": "Llama 3.1 8B",
|
||||
"description": "Универсальная модель Meta",
|
||||
"context_length": 131072,
|
||||
"pricing": "$0.0001/1K tokens"
|
||||
},
|
||||
{
|
||||
"id": "google/gemma-2-9b-it",
|
||||
"name": "Gemma 2 9B",
|
||||
"description": "Хороша для креативных задач",
|
||||
"context_length": 8192,
|
||||
"pricing": "$0.0001/1K tokens"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загружаем переменные окружения из .env файла
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Settings:
|
||||
# API ключи
|
||||
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||
OPENROUTER_BASE_URL: str = os.getenv(
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
|
||||
# Ollama — в Docker это будет не localhost, а имя контейнера или IP хоста
|
||||
OLLAMA_BASE_URL: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
|
||||
# Путь к данным — по умолчанию локальная папка, в Docker будет монтироваться volume
|
||||
DATA_DIR: str = os.getenv("DATA_DIR", "data")
|
||||
|
||||
# Окружение
|
||||
ENVIRONMENT: str = os.getenv("ENVIRONMENT", "development")
|
||||
|
||||
# CORS — разрешённые источники (в production это будет домен)
|
||||
CORS_ORIGINS: list = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
||||
|
||||
# JWT секрет — в production обязательно переопределить через .env
|
||||
JWT_SECRET_KEY: str = os.getenv(
|
||||
"JWT_SECRET_KEY", "your-super-secret-key-change-in-production"
|
||||
)
|
||||
|
||||
def validate(self) -> dict:
|
||||
"""Проверить, какие провайдеры настроены"""
|
||||
return {
|
||||
"ollama": True, # Ollama всегда доступна локально
|
||||
"openrouter": bool(
|
||||
self.OPENROUTER_API_KEY
|
||||
and self.OPENROUTER_API_KEY != "your_api_key_here"
|
||||
),
|
||||
"data_dir": self.DATA_DIR,
|
||||
"cors_origins": self.CORS_ORIGINS,
|
||||
}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,44 @@
|
||||
from config.settings import settings
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from routers import ai, auth, bible, documents, export, health, images, projects
|
||||
from services.document_service import document_service
|
||||
from services.project_service import project_service
|
||||
|
||||
app = FastAPI(title="Writer IDE API", version="0.1.0")
|
||||
|
||||
# CORS configuration - uses settings from .env
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(projects.router, prefix="/api")
|
||||
app.include_router(documents.router, prefix="/api")
|
||||
app.include_router(ai.router, prefix="/api")
|
||||
app.include_router(bible.router, prefix="/api")
|
||||
app.include_router(export.router, prefix="/api")
|
||||
app.include_router(images.router, prefix="/api")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Инициализация тестового документа в активном проекте при запуске сервера"""
|
||||
# Если есть проекты, инициализируем тестовый документ в первом
|
||||
all_projects = project_service.get_all_projects()
|
||||
if all_projects:
|
||||
default_project = all_projects[0]
|
||||
document_service.initialize_test_document(
|
||||
default_project.id, default_project.user_id
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Writer IDE API", "docs": "/docs"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,95 @@
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from models.bible import StoryBible
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
provider: Literal["ollama", "openrouter"] = "ollama"
|
||||
model: Optional[str] = None
|
||||
api_key: Optional[str] = None # Опционально, обычно берется из .env
|
||||
preset: Literal["creative_draft", "editor", "brainstorm"] = "creative_draft"
|
||||
options: Optional[Dict[str, Any]] = None
|
||||
story_bible: Optional[StoryBible] = None # Story Bible для контекста
|
||||
selected_text: Optional[str] = None # Текст, который будет обрабатываться
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
generated_text: str
|
||||
provider: str
|
||||
model: str
|
||||
preset: str
|
||||
|
||||
|
||||
class ExtractedEntities(BaseModel):
|
||||
new_characters: List[Dict[str, Any]] = []
|
||||
new_locations: List[Dict[str, Any]] = []
|
||||
new_events: List[Dict[str, Any]] = []
|
||||
updated_traits: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
class ExtractEntitiesRequest(BaseModel):
|
||||
text: str
|
||||
existing_bible: Optional[StoryBible] = None
|
||||
task_type: str = "background"
|
||||
|
||||
|
||||
class ExtractEntitiesResponse(BaseModel):
|
||||
entities: ExtractedEntities
|
||||
model_used: str
|
||||
provider: str
|
||||
|
||||
|
||||
class AutoUpdateBibleRequest(BaseModel):
|
||||
project_id: str
|
||||
scene_text: str
|
||||
auto_update_enabled: bool = True
|
||||
|
||||
|
||||
class AutoUpdateBibleResponse(BaseModel):
|
||||
updated: bool
|
||||
new_characters: int = 0
|
||||
new_locations: int = 0
|
||||
new_events: int = 0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class CleanupDuplicatesResponse(BaseModel):
|
||||
removed_characters: int = 0
|
||||
removed_locations: int = 0
|
||||
removed_events: int = 0
|
||||
message: str = ""
|
||||
|
||||
|
||||
class PreviewContextRequest(BaseModel):
|
||||
text: str
|
||||
story_bible: StoryBible
|
||||
|
||||
|
||||
class PreviewContextResponse(BaseModel):
|
||||
context_string: str
|
||||
mentioned_characters: List[str]
|
||||
mentioned_locations: List[str]
|
||||
mentioned_events: List[str]
|
||||
|
||||
|
||||
class GenerateImageRequest(BaseModel):
|
||||
prompt: str
|
||||
project_id: str
|
||||
document_id: str # ID документа (книги или главы)
|
||||
document_type: str # 'book' или 'chapter'
|
||||
model: Optional[str] = None
|
||||
size: str = "1024x1024"
|
||||
|
||||
|
||||
class GenerateImageResponse(BaseModel):
|
||||
image_id: str
|
||||
filename: str
|
||||
path: str
|
||||
prompt: str
|
||||
model: str
|
||||
data_url: str # base64 изображение для немедленного отображения
|
||||
document_id: str # ID документа, к которому привязано изображение
|
||||
document_type: str # 'book' или 'chapter'
|
||||
@@ -0,0 +1,46 @@
|
||||
from typing import List, Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Character(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
name: str
|
||||
description: str = ""
|
||||
traits: List[str] = [] # Характер, привычки
|
||||
appearance: str = "" # Внешность
|
||||
role: Literal["protagonist", "antagonist", "supporting", "minor"] = "supporting"
|
||||
|
||||
|
||||
class Location(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
name: str
|
||||
description: str = ""
|
||||
atmosphere: str = "" # Атмосфера, ощущения
|
||||
type: Literal["city", "building", "nature", "interior", "other"] = "other"
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
title: str
|
||||
description: str = ""
|
||||
chapter: Optional[str] = None # В какой главе произошло
|
||||
importance: Literal["major", "minor", "background"] = "minor"
|
||||
|
||||
|
||||
class StoryThread(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
title: str
|
||||
description: str = ""
|
||||
status: Literal["active", "resolved", "abandoned"] = "active"
|
||||
related_characters: List[str] = [] # ID персонажей
|
||||
related_events: List[str] = [] # ID событий
|
||||
|
||||
|
||||
class StoryBible(BaseModel):
|
||||
characters: List[Character] = []
|
||||
locations: List[Location] = []
|
||||
events: List[Event] = []
|
||||
threads: List[StoryThread] = []
|
||||
last_updated: Optional[str] = None # ISO timestamp
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DocumentNode(BaseModel):
|
||||
"""Иерархическая модель документа: Книга → Глава → Сцена"""
|
||||
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
project_id: str
|
||||
parent_id: Optional[str] = None
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = ""
|
||||
type: str = Field(default="scene", pattern="^(book|chapter|scene)$")
|
||||
order: int = 0
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
# --- Модели для обратной совместимости (старый плоский формат) ---
|
||||
|
||||
|
||||
class DocumentBase(BaseModel):
|
||||
"""Базовая модель документа (legacy)"""
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: str = ""
|
||||
type: str = Field(default="scene", pattern="^(scene|chapter|bible)$")
|
||||
|
||||
|
||||
class DocumentCreate(DocumentBase):
|
||||
"""Модель для создания нового документа (legacy)"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
"""Модель для обновления контента документа (legacy)"""
|
||||
|
||||
content: str
|
||||
|
||||
|
||||
class DocumentResponse(DocumentBase):
|
||||
"""Модель ответа с документом (legacy)"""
|
||||
|
||||
id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
id: str = Field(default_factory=lambda: str(uuid4()))
|
||||
user_id: str = (
|
||||
"" # Привязка к пользователю (пустой для старых проектов до миграции)
|
||||
)
|
||||
title: str
|
||||
description: str = ""
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
@@ -0,0 +1,28 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
email: str
|
||||
hashed_password: str
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user_id: str
|
||||
username: str
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
httpx
|
||||
python-dotenv
|
||||
bcrypt
|
||||
python-jose[cryptography]
|
||||
weasyprint
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
from config.settings import settings
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.ai import (
|
||||
AutoUpdateBibleRequest,
|
||||
AutoUpdateBibleResponse,
|
||||
CleanupDuplicatesResponse,
|
||||
ExtractedEntities,
|
||||
ExtractEntitiesRequest,
|
||||
ExtractEntitiesResponse,
|
||||
GenerateImageRequest,
|
||||
GenerateImageResponse,
|
||||
GenerateRequest,
|
||||
GenerateResponse,
|
||||
PreviewContextRequest,
|
||||
PreviewContextResponse,
|
||||
)
|
||||
from models.bible import Character, Event, Location, StoryBible
|
||||
from models.user import User
|
||||
from services.bible_service import bible_service
|
||||
from services.context_builder import context_builder
|
||||
from services.deduplication import deduplication_service
|
||||
from services.entity_extractor import entity_extractor
|
||||
from services.image_generator import image_generator
|
||||
from services.llm_router import llm_router
|
||||
from services.model_config import model_config
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_user_project(project_id: str, current_user: User) -> None:
|
||||
"""Проверить что проект принадлежит пользователю"""
|
||||
from services.project_service import project_service
|
||||
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/ai/providers/status")
|
||||
async def get_providers_status():
|
||||
"""Проверить, какие провайдеры настроены и готовы к использованию"""
|
||||
return {
|
||||
"providers": settings.validate(),
|
||||
"openrouter_configured": bool(
|
||||
settings.OPENROUTER_API_KEY
|
||||
and settings.OPENROUTER_API_KEY != "your_api_key_here"
|
||||
),
|
||||
"openrouter_url": settings.OPENROUTER_BASE_URL,
|
||||
"ollama_url": settings.OLLAMA_BASE_URL,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ai/models")
|
||||
async def get_available_models(provider: str = "ollama"):
|
||||
"""Получить список доступных моделей для указанного провайдера"""
|
||||
try:
|
||||
models = model_config.get_available_models(provider)
|
||||
default_model = model_config.get_default_model(provider)
|
||||
return {
|
||||
"provider": provider,
|
||||
"default_model": default_model,
|
||||
"models": models,
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/ai/generate", response_model=GenerateResponse)
|
||||
async def generate_text(request: GenerateRequest):
|
||||
try:
|
||||
# Обогащаем промпт контекстом из Story Bible
|
||||
enriched_prompt = context_builder.enrich_prompt(
|
||||
original_prompt=request.prompt,
|
||||
selected_text=request.selected_text or "",
|
||||
bible=request.story_bible,
|
||||
)
|
||||
|
||||
# Генерируем текст с обогащенным промптом
|
||||
generated_text = await llm_router.generate(
|
||||
prompt=enriched_prompt,
|
||||
provider=request.provider,
|
||||
model=request.model,
|
||||
api_key=request.api_key,
|
||||
preset=request.preset,
|
||||
options=request.options,
|
||||
)
|
||||
return GenerateResponse(
|
||||
generated_text=generated_text,
|
||||
provider=request.provider,
|
||||
model=request.model or "default",
|
||||
preset=request.preset,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/ai/preview-context", response_model=PreviewContextResponse)
|
||||
async def preview_context(request: PreviewContextRequest):
|
||||
"""Предпросмотр контекста, который будет добавлен к промпту"""
|
||||
mentioned = context_builder.extract_mentioned_entities(
|
||||
text=request.text,
|
||||
bible=request.story_bible,
|
||||
)
|
||||
|
||||
context_str = context_builder.build_context_string(mentioned)
|
||||
|
||||
return PreviewContextResponse(
|
||||
context_string=context_str,
|
||||
mentioned_characters=[c.name for c in mentioned["characters"]],
|
||||
mentioned_locations=[l.name for l in mentioned["locations"]],
|
||||
mentioned_events=[e.title for e in mentioned["events"]],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ai/extract-entities", response_model=ExtractEntitiesResponse)
|
||||
async def extract_entities(request: ExtractEntitiesRequest):
|
||||
"""Извлечение сущностей из текста с помощью AI"""
|
||||
try:
|
||||
# Используем OpenRouter с моделью, оптимальной для извлечения
|
||||
provider = "openrouter"
|
||||
model = "qwen/qwen-2.5-7b-instruct"
|
||||
|
||||
if (
|
||||
not settings.OPENROUTER_API_KEY
|
||||
or settings.OPENROUTER_API_KEY == "your_api_key_here"
|
||||
):
|
||||
raise ValueError(
|
||||
"OpenRouter API key not configured. "
|
||||
"Entity extraction requires a cloud model. "
|
||||
"Please set OPENROUTER_API_KEY in backend/.env"
|
||||
)
|
||||
|
||||
entities = await entity_extractor.extract_entities(
|
||||
text=request.text,
|
||||
existing_bible=request.existing_bible,
|
||||
provider=provider,
|
||||
model=model,
|
||||
)
|
||||
|
||||
return ExtractEntitiesResponse(
|
||||
entities=ExtractedEntities(**entities),
|
||||
model_used=model,
|
||||
provider=provider,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/ai/auto-update-bible", response_model=AutoUpdateBibleResponse)
|
||||
async def auto_update_bible(
|
||||
request: AutoUpdateBibleRequest, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Автоматическое обновление Story Bible при сохранении сцены"""
|
||||
if not request.auto_update_enabled:
|
||||
return AutoUpdateBibleResponse(updated=False, message="Auto-update disabled")
|
||||
|
||||
_get_user_project(request.project_id, current_user)
|
||||
|
||||
try:
|
||||
# Получаем текущую Story Bible проекта
|
||||
current_bible = bible_service.get_bible(request.project_id, current_user.id)
|
||||
|
||||
# Извлекаем сущности из текста сцены
|
||||
entities = await entity_extractor.extract_entities(
|
||||
text=request.scene_text,
|
||||
existing_bible=current_bible,
|
||||
provider="openrouter",
|
||||
model="qwen/qwen-2.5-7b-instruct",
|
||||
)
|
||||
|
||||
# Добавляем новые сущности в Bible
|
||||
new_chars_count = 0
|
||||
new_locs_count = 0
|
||||
new_events_count = 0
|
||||
|
||||
# Добавляем новых персонажей С ДЕДУПЛИКАЦИЕЙ
|
||||
for char_data in entities.get("new_characters", []):
|
||||
try:
|
||||
char_name = char_data.get("name", "")
|
||||
if not char_name:
|
||||
continue
|
||||
|
||||
# Ищем существующего персонажа с похожим именем
|
||||
existing_char = deduplication_service.find_existing_character(
|
||||
char_name, current_bible
|
||||
)
|
||||
|
||||
if existing_char:
|
||||
# Объединяем данные с существующим персонажем
|
||||
deduplication_service.merge_character_data(existing_char, char_data)
|
||||
print(f"Merged character: {char_name} -> {existing_char.name}")
|
||||
else:
|
||||
# Создаем нового персонажа
|
||||
character = Character(**char_data)
|
||||
current_bible.characters.append(character)
|
||||
new_chars_count += 1
|
||||
print(f"Added new character: {char_name}")
|
||||
except Exception as e:
|
||||
print(f"Failed to process character: {e}")
|
||||
|
||||
# Добавляем новые локации С ДЕДУПЛИКАЦИЕЙ
|
||||
for loc_data in entities.get("new_locations", []):
|
||||
try:
|
||||
loc_name = loc_data.get("name", "")
|
||||
if not loc_name:
|
||||
continue
|
||||
|
||||
existing_loc = deduplication_service.find_existing_location(
|
||||
loc_name, current_bible
|
||||
)
|
||||
|
||||
if existing_loc:
|
||||
# Обновляем описание, если новое длиннее
|
||||
new_desc = loc_data.get("description", "")
|
||||
if new_desc and len(new_desc) > len(existing_loc.description):
|
||||
existing_loc.description = new_desc
|
||||
|
||||
new_atm = loc_data.get("atmosphere", "")
|
||||
if new_atm and not existing_loc.atmosphere:
|
||||
existing_loc.atmosphere = new_atm
|
||||
|
||||
print(f"Merged location: {loc_name} -> {existing_loc.name}")
|
||||
else:
|
||||
location = Location(**loc_data)
|
||||
current_bible.locations.append(location)
|
||||
new_locs_count += 1
|
||||
print(f"Added new location: {loc_name}")
|
||||
except Exception as e:
|
||||
print(f"Failed to process location: {e}")
|
||||
|
||||
# Добавляем новые события С ДЕДУПЛИКАЦИЕЙ
|
||||
for event_data in entities.get("new_events", []):
|
||||
try:
|
||||
event_title = event_data.get("title", "")
|
||||
if not event_title:
|
||||
continue
|
||||
|
||||
existing_event = deduplication_service.find_existing_event(
|
||||
event_title, current_bible
|
||||
)
|
||||
|
||||
if existing_event:
|
||||
# Обновляем описание, если новое длиннее
|
||||
new_desc = event_data.get("description", "")
|
||||
if new_desc and len(new_desc) > len(existing_event.description):
|
||||
existing_event.description = new_desc
|
||||
print(f"Merged event: {event_title} -> {existing_event.title}")
|
||||
else:
|
||||
event = Event(**event_data)
|
||||
current_bible.events.append(event)
|
||||
new_events_count += 1
|
||||
print(f"Added new event: {event_title}")
|
||||
except Exception as e:
|
||||
print(f"Failed to process event: {e}")
|
||||
|
||||
# Обновляем черты существующих персонажей
|
||||
for trait_update in entities.get("updated_traits", []):
|
||||
char_name = trait_update.get("character_name")
|
||||
new_traits = trait_update.get("new_traits", [])
|
||||
|
||||
for char in current_bible.characters:
|
||||
if char.name.lower() == (char_name or "").lower():
|
||||
# Добавляем новые черты, которых еще нет
|
||||
for trait in new_traits:
|
||||
if trait not in char.traits:
|
||||
char.traits.append(trait)
|
||||
|
||||
# Сохраняем обновленную Bible
|
||||
if new_chars_count > 0 or new_locs_count > 0 or new_events_count > 0:
|
||||
bible_service.update_bible(
|
||||
request.project_id, current_bible, current_user.id
|
||||
)
|
||||
message = (
|
||||
f"Story Bible обновлена: +{new_chars_count} персонажей, "
|
||||
f"+{new_locs_count} локаций, +{new_events_count} событий"
|
||||
)
|
||||
else:
|
||||
message = "Новых сущностей не обнаружено"
|
||||
|
||||
return AutoUpdateBibleResponse(
|
||||
updated=True,
|
||||
new_characters=new_chars_count,
|
||||
new_locations=new_locs_count,
|
||||
new_events=new_events_count,
|
||||
message=message,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Auto-update bible error: {e}")
|
||||
return AutoUpdateBibleResponse(
|
||||
updated=False,
|
||||
message=f"Ошибка при обновлении: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ai/extract-entities/test")
|
||||
async def test_extraction():
|
||||
"""Тестовый эндпоинт с примером текста"""
|
||||
test_text = """
|
||||
Алекс вошел в таверну "Серебряный дракон". Внутри пахло жареным мясом и элем.
|
||||
За стойкой стоял хозяин — крепкий мужчина по имени Гаррет с рыжей бородой.
|
||||
В углу сидела таинственная женщина в красном плаще — Алекс узнал Лиру.
|
||||
"""
|
||||
|
||||
sample_bible = StoryBible(
|
||||
characters=[
|
||||
Character(name="Алекс", description="Главный герой", role="protagonist"),
|
||||
Character(
|
||||
name="Лира", description="Таинственная проводница", role="supporting"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
try:
|
||||
entities = await entity_extractor.extract_entities(
|
||||
text=test_text,
|
||||
existing_bible=sample_bible,
|
||||
provider="openrouter",
|
||||
model="qwen/qwen-2.5-7b-instruct",
|
||||
)
|
||||
|
||||
return {
|
||||
"test_text": test_text,
|
||||
"extracted": entities,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/bible/cleanup-duplicates",
|
||||
response_model=CleanupDuplicatesResponse,
|
||||
)
|
||||
async def cleanup_duplicates(
|
||||
project_id: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Очистка дубликатов в Story Bible"""
|
||||
_get_user_project(project_id, current_user)
|
||||
try:
|
||||
bible = bible_service.get_bible(project_id, current_user.id)
|
||||
|
||||
# Удаляем дубликаты персонажей
|
||||
unique_chars = []
|
||||
removed_chars = 0
|
||||
for char in bible.characters:
|
||||
is_duplicate = False
|
||||
for existing in unique_chars:
|
||||
if deduplication_service.is_similar_name(char.name, existing.name):
|
||||
# Объединяем данные
|
||||
deduplication_service.merge_character_data(
|
||||
existing, char.model_dump()
|
||||
)
|
||||
is_duplicate = True
|
||||
removed_chars += 1
|
||||
break
|
||||
if not is_duplicate:
|
||||
unique_chars.append(char)
|
||||
bible.characters = unique_chars
|
||||
|
||||
# Удаляем дубликаты локаций
|
||||
unique_locs = []
|
||||
removed_locs = 0
|
||||
for loc in bible.locations:
|
||||
is_duplicate = False
|
||||
for existing in unique_locs:
|
||||
if deduplication_service.is_similar_name(loc.name, existing.name):
|
||||
is_duplicate = True
|
||||
removed_locs += 1
|
||||
break
|
||||
if not is_duplicate:
|
||||
unique_locs.append(loc)
|
||||
bible.locations = unique_locs
|
||||
|
||||
# Удаляем дубликаты событий
|
||||
unique_events = []
|
||||
removed_events = 0
|
||||
for event in bible.events:
|
||||
is_duplicate = False
|
||||
for existing in unique_events:
|
||||
if deduplication_service.is_similar_name(event.title, existing.title):
|
||||
is_duplicate = True
|
||||
removed_events += 1
|
||||
break
|
||||
if not is_duplicate:
|
||||
unique_events.append(event)
|
||||
bible.events = unique_events
|
||||
|
||||
# Сохраняем очищенную Bible
|
||||
bible_service.update_bible(project_id, bible, current_user.id)
|
||||
|
||||
return CleanupDuplicatesResponse(
|
||||
removed_characters=removed_chars,
|
||||
removed_locations=removed_locs,
|
||||
removed_events=removed_events,
|
||||
message=f"Удалено дубликатов: {removed_chars} персонажей, {removed_locs} локаций, {removed_events} событий",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/ai/generate-image", response_model=GenerateImageResponse)
|
||||
async def generate_image(
|
||||
request: GenerateImageRequest, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Генерация изображения по промпту"""
|
||||
try:
|
||||
result = await image_generator.generate_image(
|
||||
prompt=request.prompt,
|
||||
project_id=request.project_id,
|
||||
user_id=current_user.id,
|
||||
document_id=request.document_id,
|
||||
document_type=request.document_type,
|
||||
model=request.model,
|
||||
size=request.size,
|
||||
)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,50 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from models.user import Token, User, UserCreate, UserLogin
|
||||
from services.auth_service import ALGORITHM, SECRET_KEY, auth_service
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
@router.post("/auth/register", response_model=Token)
|
||||
async def register(user_create: UserCreate):
|
||||
try:
|
||||
user = auth_service.create_user(user_create)
|
||||
return auth_service.get_token(user.username, user_create.password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/auth/login", response_model=Token)
|
||||
async def login(user_login: UserLogin):
|
||||
try:
|
||||
return auth_service.get_token(user_login.username, user_login.password)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> User:
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
user_id: str = payload.get("user_id")
|
||||
if username is None or user_id is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
user = auth_service.get_user_by_username(username)
|
||||
if not user or user.id != user_id:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
@router.get("/auth/me", response_model=User)
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.bible import Character, Event, Location, StoryBible, StoryThread
|
||||
from models.user import User
|
||||
from services.bible_service import bible_service
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_user_project(project_id: str, current_user: User) -> None:
|
||||
"""Проверить что проект принадлежит пользователю"""
|
||||
from services.project_service import project_service
|
||||
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/bible", response_model=StoryBible)
|
||||
async def get_story_bible(
|
||||
project_id: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Получить Story Bible проекта"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.get_bible(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/bible", response_model=StoryBible)
|
||||
async def update_story_bible(
|
||||
project_id: str,
|
||||
bible: StoryBible,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Обновить всю Story Bible проекта"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.update_bible(project_id, bible, current_user.id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/bible/characters", response_model=Character)
|
||||
async def add_character(
|
||||
project_id: str,
|
||||
character: Character,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Добавить нового персонажа в проект"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.add_character(project_id, character, current_user.id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/bible/locations", response_model=Location)
|
||||
async def add_location(
|
||||
project_id: str,
|
||||
location: Location,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Добавить новую локацию в проект"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.add_location(project_id, location, current_user.id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/bible/events", response_model=Event)
|
||||
async def add_event(
|
||||
project_id: str,
|
||||
event: Event,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Добавить новое событие в проект"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.add_event(project_id, event, current_user.id)
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/bible/threads", response_model=StoryThread)
|
||||
async def add_thread(
|
||||
project_id: str,
|
||||
thread: StoryThread,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Добавить новую сюжетную нить в проект"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return bible_service.add_thread(project_id, thread, current_user.id)
|
||||
@@ -0,0 +1,151 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.document import DocumentNode
|
||||
from models.user import User
|
||||
from pydantic import BaseModel
|
||||
from services.document_service import document_service
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateDocumentRequest(BaseModel):
|
||||
title: str
|
||||
type: str # "book", "chapter", "scene"
|
||||
parent_id: Optional[str] = None
|
||||
content: str = ""
|
||||
|
||||
|
||||
class UpdateDocumentRequest(BaseModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
parent_id: Optional[str] = None # Для переноса
|
||||
order: Optional[int] = None
|
||||
|
||||
|
||||
def _get_user_project(project_id: str, current_user: User) -> None:
|
||||
"""Проверить что проект принадлежит пользователю"""
|
||||
from services.project_service import project_service
|
||||
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/documents/tree", response_model=List[Dict[str, Any]]
|
||||
)
|
||||
async def get_documents_tree(
|
||||
project_id: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Получить документы в виде дерева"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return document_service.get_documents_tree(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/documents", response_model=List[DocumentNode])
|
||||
async def get_all_documents(
|
||||
project_id: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Получить все документы (плоский список)"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return document_service.get_all_documents(project_id, current_user.id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/documents/{document_id}", response_model=DocumentNode
|
||||
)
|
||||
async def get_document(
|
||||
project_id: str,
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Получить документ по ID"""
|
||||
_get_user_project(project_id, current_user)
|
||||
document = document_service.get_document(project_id, document_id, current_user.id)
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return document
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/documents", response_model=DocumentNode, status_code=201
|
||||
)
|
||||
async def create_document(
|
||||
project_id: str,
|
||||
request: CreateDocumentRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Создать новый документ"""
|
||||
_get_user_project(project_id, current_user)
|
||||
try:
|
||||
return document_service.create_document(
|
||||
project_id=project_id,
|
||||
title=request.title,
|
||||
doc_type=request.type,
|
||||
user_id=current_user.id,
|
||||
parent_id=request.parent_id,
|
||||
content=request.content,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/projects/{project_id}/documents/{document_id}", response_model=DocumentNode
|
||||
)
|
||||
async def update_document(
|
||||
project_id: str,
|
||||
document_id: str,
|
||||
request: UpdateDocumentRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Обновить документ (включая перенос)"""
|
||||
_get_user_project(project_id, current_user)
|
||||
try:
|
||||
document = document_service.update_document(
|
||||
project_id=project_id,
|
||||
document_id=document_id,
|
||||
user_id=current_user.id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
parent_id=request.parent_id,
|
||||
order=request.order,
|
||||
)
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return document
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/documents/{document_id}")
|
||||
async def delete_document(
|
||||
project_id: str,
|
||||
document_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Удалить документ с каскадом"""
|
||||
_get_user_project(project_id, current_user)
|
||||
success = document_service.delete_document(project_id, document_id, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return {"status": "deleted", "document_id": document_id}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/documents/{parent_id}/children",
|
||||
response_model=List[DocumentNode],
|
||||
)
|
||||
async def get_children(
|
||||
project_id: str,
|
||||
parent_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Получить прямых детей узла"""
|
||||
_get_user_project(project_id, current_user)
|
||||
return document_service.get_children(project_id, parent_id, current_user.id)
|
||||
@@ -0,0 +1,64 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from models.user import User
|
||||
from pydantic import BaseModel
|
||||
from services.export_service import export_service
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
project_id: str
|
||||
document_id: Optional[str] = (
|
||||
None # ID книги или главы. Если None — экспортируем всю книгу
|
||||
)
|
||||
format: str # 'epub', 'fb2', 'pdf', 'docx'
|
||||
|
||||
|
||||
def _get_user_project(project_id: str, current_user: User) -> None:
|
||||
"""Проверить что проект принадлежит пользователю"""
|
||||
from services.project_service import project_service
|
||||
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
async def export_book(
|
||||
request: ExportRequest, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
_get_user_project(request.project_id, current_user)
|
||||
try:
|
||||
file_content = export_service.export_document(
|
||||
project_id=request.project_id,
|
||||
user_id=current_user.id,
|
||||
document_id=request.document_id,
|
||||
format=request.format,
|
||||
)
|
||||
|
||||
# Определяем MIME-тип
|
||||
mime_types = {
|
||||
"epub": "application/epub+zip",
|
||||
"fb2": "application/fb2",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
filename = f"export.{request.format}"
|
||||
|
||||
return Response(
|
||||
content=file_content,
|
||||
media_type=mime_types.get(request.format, "application/octet-stream"),
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -0,0 +1,8 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "message": "Writer IDE API is running"}
|
||||
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from models.user import User
|
||||
from services.project_service import project_service
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/images/{project_id}/{filename}")
|
||||
async def get_image(
|
||||
project_id: str, filename: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Отдаёт изображение из папки проекта"""
|
||||
try:
|
||||
project_dir = project_service.get_project_dir(project_id, current_user.id)
|
||||
image_path = project_dir / "images" / filename
|
||||
|
||||
if not image_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Image not found")
|
||||
|
||||
# Определяем MIME тип по расширению
|
||||
suffix = Path(filename).suffix.lower()
|
||||
mime_types = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
path=image_path,
|
||||
media_type=mime_types.get(suffix, "application/octet-stream"),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -0,0 +1,85 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from models.project import Project
|
||||
from models.user import User
|
||||
from pydantic import BaseModel
|
||||
from services.project_service import project_service
|
||||
|
||||
from routers.auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateProjectRequest(BaseModel):
|
||||
title: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class UpdateProjectRequest(BaseModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@router.get("/projects", response_model=list[Project])
|
||||
async def get_all_projects(current_user: User = Depends(get_current_user)):
|
||||
"""Получить все проекты текущего пользователя"""
|
||||
return project_service.get_all_projects(user_id=current_user.id)
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=Project)
|
||||
async def get_project(project_id: str, current_user: User = Depends(get_current_user)):
|
||||
"""Получить проект по ID"""
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects", response_model=Project, status_code=201)
|
||||
async def create_project(
|
||||
request: CreateProjectRequest, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Создать новый проект"""
|
||||
return project_service.create_project(
|
||||
title=request.title,
|
||||
user_id=current_user.id,
|
||||
description=request.description,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}", response_model=Project)
|
||||
async def update_project(
|
||||
project_id: str,
|
||||
request: UpdateProjectRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Обновить проект"""
|
||||
project = project_service.get_project(project_id)
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
if project.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
updated = project_service.update_project(
|
||||
project_id=project_id,
|
||||
title=request.title,
|
||||
description=request.description,
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return updated
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: str, current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Удалить проект"""
|
||||
try:
|
||||
success = project_service.delete_project(project_id, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return {"status": "deleted", "project_id": project_id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
Whitespace-only changes.
@@ -0,0 +1,109 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import bcrypt
|
||||
|
||||
# Настройки JWT (читаются из config/settings)
|
||||
from config.settings import settings
|
||||
from jose import JWTError, jwt
|
||||
from models.user import Token, User, UserCreate
|
||||
|
||||
SECRET_KEY = settings.JWT_SECRET_KEY
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 дней
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(self):
|
||||
from config.settings import settings
|
||||
|
||||
self.data_dir = Path(settings.DATA_DIR)
|
||||
self.data_dir.mkdir(exist_ok=True)
|
||||
self.users_file = self.data_dir / "users.json"
|
||||
self.users = self._load_users()
|
||||
|
||||
def _load_users(self) -> list[User]:
|
||||
if self.users_file.exists():
|
||||
with open(self.users_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return [User(**u) for u in data]
|
||||
return []
|
||||
|
||||
def _save_users(self):
|
||||
with open(self.users_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
[u.model_dump() for u in self.users], f, ensure_ascii=False, indent=2
|
||||
)
|
||||
|
||||
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
def get_password_hash(self, password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
def get_user_by_username(self, username: str) -> Optional[User]:
|
||||
for user in self.users:
|
||||
if user.username == username:
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_user_by_id(self, user_id: str) -> Optional[User]:
|
||||
for user in self.users:
|
||||
if user.id == user_id:
|
||||
return user
|
||||
return None
|
||||
|
||||
def create_user(self, user_create: UserCreate) -> User:
|
||||
if self.get_user_by_username(user_create.username):
|
||||
raise ValueError("Username already exists")
|
||||
|
||||
new_user = User(
|
||||
id=str(uuid4()),
|
||||
username=user_create.username,
|
||||
email=user_create.email,
|
||||
hashed_password=self.get_password_hash(user_create.password),
|
||||
)
|
||||
self.users.append(new_user)
|
||||
self._save_users()
|
||||
|
||||
# Создаем папку для проектов пользователя
|
||||
user_projects_dir = self.data_dir / "users" / new_user.id / "projects"
|
||||
user_projects_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return new_user
|
||||
|
||||
def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
||||
user = self.get_user_by_username(username)
|
||||
if not user or not self.verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
def create_access_token(self, data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
def get_token(self, username: str, password: str) -> Token:
|
||||
user = self.authenticate_user(username, password)
|
||||
if not user:
|
||||
raise ValueError("Invalid username or password")
|
||||
|
||||
access_token = self.create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
token_type="bearer",
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
)
|
||||
|
||||
|
||||
# Singleton
|
||||
auth_service = AuthService()
|
||||
@@ -0,0 +1,135 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
from models.bible import Character, Event, Location, StoryBible, StoryThread
|
||||
|
||||
from services.project_service import project_service
|
||||
|
||||
|
||||
class BibleService:
|
||||
def __init__(self):
|
||||
from config.settings import settings
|
||||
|
||||
self.data_dir = Path(settings.DATA_DIR)
|
||||
self.project_service = project_service
|
||||
|
||||
def _get_bible_file(self, project_id: str, user_id: str) -> Path:
|
||||
"""Получить путь к файлу Story Bible проекта"""
|
||||
project_dir = self.project_service.get_project_dir(project_id, user_id)
|
||||
return project_dir / "story_bible.json"
|
||||
|
||||
def _load_bible(self, project_id: str, user_id: str) -> StoryBible:
|
||||
"""Загрузить Story Bible проекта"""
|
||||
bible_file = self._get_bible_file(project_id, user_id)
|
||||
if bible_file.exists():
|
||||
with open(bible_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return StoryBible(**data)
|
||||
else:
|
||||
# Инициализация с примером данных
|
||||
return self._create_sample_bible()
|
||||
|
||||
def _create_sample_bible(self) -> StoryBible:
|
||||
"""Создать пример Story Bible для тестирования"""
|
||||
return StoryBible(
|
||||
characters=[
|
||||
Character(
|
||||
name="Алекс",
|
||||
description="Главный герой, бывший архитектор",
|
||||
traits=["Циничный", "Справедливый", "Наблюдательный"],
|
||||
appearance="Высокий, темные волосы, шрам на левой руке",
|
||||
role="protagonist",
|
||||
),
|
||||
Character(
|
||||
name="Лира",
|
||||
description="Таинственная проводница",
|
||||
traits=["Загадочная", "Решительная"],
|
||||
appearance="Носит красный плащ, зеленые глаза",
|
||||
role="supporting",
|
||||
),
|
||||
],
|
||||
locations=[
|
||||
Location(
|
||||
name="Нижний Город",
|
||||
description="Старая часть города с узкими улочками",
|
||||
atmosphere="Вечный туман, запах озона и мокрого камня",
|
||||
type="city",
|
||||
)
|
||||
],
|
||||
events=[
|
||||
Event(
|
||||
title="Встреча с Лирой",
|
||||
description="Алекс встречает Лиру в тумане Нижнего Города",
|
||||
chapter="Глава 1",
|
||||
importance="major",
|
||||
)
|
||||
],
|
||||
threads=[
|
||||
StoryThread(
|
||||
title="Тайна хранилища",
|
||||
description="Поиск древнего хранилища знаний",
|
||||
status="active",
|
||||
related_characters=[],
|
||||
related_events=[],
|
||||
)
|
||||
],
|
||||
last_updated=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
def _save_bible(self, project_id: str, bible: StoryBible, user_id: str):
|
||||
"""Сохранить Story Bible проекта"""
|
||||
bible_file = self._get_bible_file(project_id, user_id)
|
||||
bible.last_updated = datetime.now().isoformat()
|
||||
with open(bible_file, "w", encoding="utf-8") as f:
|
||||
json.dump(bible.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
def get_bible(self, project_id: str, user_id: str) -> StoryBible:
|
||||
"""Получить Story Bible проекта"""
|
||||
return self._load_bible(project_id, user_id)
|
||||
|
||||
def update_bible(
|
||||
self, project_id: str, new_bible: StoryBible, user_id: str
|
||||
) -> StoryBible:
|
||||
"""Обновить Story Bible проекта"""
|
||||
self._save_bible(project_id, new_bible, user_id)
|
||||
return new_bible
|
||||
|
||||
def add_character(
|
||||
self, project_id: str, character: Character, user_id: str
|
||||
) -> Character:
|
||||
"""Добавить персонажа в проект"""
|
||||
bible = self._load_bible(project_id, user_id)
|
||||
bible.characters.append(character)
|
||||
self._save_bible(project_id, bible, user_id)
|
||||
return character
|
||||
|
||||
def add_location(
|
||||
self, project_id: str, location: Location, user_id: str
|
||||
) -> Location:
|
||||
"""Добавить локацию в проект"""
|
||||
bible = self._load_bible(project_id, user_id)
|
||||
bible.locations.append(location)
|
||||
self._save_bible(project_id, bible, user_id)
|
||||
return location
|
||||
|
||||
def add_event(self, project_id: str, event: Event, user_id: str) -> Event:
|
||||
"""Добавить событие в проект"""
|
||||
bible = self._load_bible(project_id, user_id)
|
||||
bible.events.append(event)
|
||||
self._save_bible(project_id, bible, user_id)
|
||||
return event
|
||||
|
||||
def add_thread(
|
||||
self, project_id: str, thread: StoryThread, user_id: str
|
||||
) -> StoryThread:
|
||||
"""Добавить сюжетную нить в проект"""
|
||||
bible = self._load_bible(project_id, user_id)
|
||||
bible.threads.append(thread)
|
||||
self._save_bible(project_id, bible, user_id)
|
||||
return thread
|
||||
|
||||
|
||||
# Singleton instance
|
||||
bible_service = BibleService()
|
||||
@@ -0,0 +1,123 @@
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models.bible import Character, Event, Location, StoryBible
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
def extract_mentioned_entities(
|
||||
self, text: str, bible: StoryBible
|
||||
) -> Dict[str, List[Any]]:
|
||||
"""Извлекает упоминания сущностей из текста"""
|
||||
text_lower = text.lower()
|
||||
|
||||
mentioned_chars: List[Character] = []
|
||||
mentioned_locs: List[Location] = []
|
||||
mentioned_events: List[Event] = []
|
||||
|
||||
# Ищем упоминания персонажей
|
||||
for char in bible.characters:
|
||||
name_patterns = [
|
||||
char.name.lower(),
|
||||
char.name.lower().split()[0] if " " in char.name else char.name.lower(),
|
||||
]
|
||||
|
||||
for pattern in name_patterns:
|
||||
if pattern and len(pattern) > 2:
|
||||
if re.search(r"\b" + re.escape(pattern) + r"\b", text_lower):
|
||||
mentioned_chars.append(char)
|
||||
break
|
||||
|
||||
# Ищем упоминания локаций
|
||||
for loc in bible.locations:
|
||||
name_patterns = [
|
||||
loc.name.lower(),
|
||||
loc.name.lower().split()[0] if " " in loc.name else loc.name.lower(),
|
||||
]
|
||||
|
||||
for pattern in name_patterns:
|
||||
if pattern and len(pattern) > 2:
|
||||
if re.search(r"\b" + re.escape(pattern) + r"\b", text_lower):
|
||||
mentioned_locs.append(loc)
|
||||
break
|
||||
|
||||
# Ищем упоминания событий (по ключевым словам в названии)
|
||||
for event in bible.events:
|
||||
keywords = re.findall(r"\w{4,}", event.title.lower())
|
||||
for keyword in keywords:
|
||||
if keyword in text_lower:
|
||||
mentioned_events.append(event)
|
||||
break
|
||||
|
||||
return {
|
||||
"characters": mentioned_chars,
|
||||
"locations": mentioned_locs,
|
||||
"events": mentioned_events,
|
||||
}
|
||||
|
||||
def build_context_string(self, mentioned_entities: Dict[str, List[Any]]) -> str:
|
||||
"""Строит строку контекста для промпта"""
|
||||
context_parts: List[str] = []
|
||||
|
||||
# Добавляем информацию о персонажах
|
||||
if mentioned_entities["characters"]:
|
||||
chars_info: List[str] = []
|
||||
for char in mentioned_entities["characters"][:5]:
|
||||
traits_str = (
|
||||
", ".join(char.traits[:3]) if char.traits else "нет описания"
|
||||
)
|
||||
chars_info.append(
|
||||
f"- {char.name}: {char.description} (черты: {traits_str})"
|
||||
)
|
||||
|
||||
context_parts.append("ПЕРСОНАЖИ В СЦЕНЕ:\n" + "\n".join(chars_info))
|
||||
|
||||
# Добавляем информацию о локациях
|
||||
if mentioned_entities["locations"]:
|
||||
locs_info: List[str] = []
|
||||
for loc in mentioned_entities["locations"][:3]:
|
||||
atm_str = f"Атмосфера: {loc.atmosphere}" if loc.atmosphere else ""
|
||||
locs_info.append(f"- {loc.name}: {loc.description} {atm_str}".strip())
|
||||
|
||||
context_parts.append("ЛОКАЦИИ:\n" + "\n".join(locs_info))
|
||||
|
||||
# Добавляем информацию о событиях
|
||||
if mentioned_entities["events"]:
|
||||
events_info: List[str] = []
|
||||
for event in mentioned_entities["events"][:3]:
|
||||
events_info.append(f"- {event.title}: {event.description}")
|
||||
|
||||
context_parts.append("СОБЫТИЯ:\n" + "\n".join(events_info))
|
||||
|
||||
return "\n\n".join(context_parts)
|
||||
|
||||
def enrich_prompt(
|
||||
self,
|
||||
original_prompt: str,
|
||||
selected_text: str,
|
||||
bible: Optional[StoryBible],
|
||||
) -> str:
|
||||
"""Обогащает промпт контекстом из Story Bible"""
|
||||
if not bible or not selected_text:
|
||||
return original_prompt
|
||||
|
||||
mentioned = self.extract_mentioned_entities(selected_text, bible)
|
||||
|
||||
if not any(mentioned.values()):
|
||||
return original_prompt
|
||||
|
||||
context_str = self.build_context_string(mentioned)
|
||||
|
||||
enriched_prompt = (
|
||||
f"КОНТЕКСТ ПРОИЗВЕДЕНИЯ:\n{context_str}\n\n"
|
||||
f"ВАЖНО: При выполнении задачи учитывай приведенный контекст. "
|
||||
f"Сохраняй консистентность с описаниями персонажей, локаций и событий. "
|
||||
f"Не противоречь установленным фактам.\n\n"
|
||||
f"ЗАДАЧА:\n{original_prompt}"
|
||||
)
|
||||
|
||||
return enriched_prompt
|
||||
|
||||
|
||||
# Singleton instance
|
||||
context_builder = ContextBuilder()
|
||||
@@ -0,0 +1,133 @@
|
||||
import re
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from models.bible import Character, Event, Location, StoryBible
|
||||
|
||||
|
||||
class DeduplicationService:
|
||||
# Общие слова, которые нужно удалять при нормализации
|
||||
CHARACTER_PREFIXES = [
|
||||
"котенок",
|
||||
"кот",
|
||||
"господин",
|
||||
"мистер",
|
||||
"доктор",
|
||||
"профессор",
|
||||
"мальчик",
|
||||
"девочка",
|
||||
"пёс",
|
||||
"собака",
|
||||
]
|
||||
LOCATION_PREFIXES = ["город", "деревня", "село", "улица", "площадь"]
|
||||
|
||||
def normalize_name(self, name: str, entity_type: str = "character") -> str:
|
||||
"""Нормализует имя сущности для сравнения"""
|
||||
# Приводим к нижнему регистру
|
||||
normalized = name.lower().strip()
|
||||
|
||||
# Убираем лишние пробелы
|
||||
normalized = re.sub(r"\s+", " ", normalized)
|
||||
|
||||
# Убираем префиксы в зависимости от типа
|
||||
prefixes = (
|
||||
self.CHARACTER_PREFIXES
|
||||
if entity_type == "character"
|
||||
else self.LOCATION_PREFIXES
|
||||
)
|
||||
for prefix in prefixes:
|
||||
if normalized.startswith(prefix + " "):
|
||||
normalized = normalized[len(prefix) :].strip()
|
||||
|
||||
return normalized
|
||||
|
||||
def is_similar_name(self, name1: str, name2: str) -> bool:
|
||||
"""Проверяет, являются ли два имени вариантами одного и того же"""
|
||||
norm1 = self.normalize_name(name1)
|
||||
norm2 = self.normalize_name(name2)
|
||||
|
||||
# Точное совпадение после нормализации
|
||||
if norm1 == norm2:
|
||||
return True
|
||||
|
||||
# Одно имя содержит другое (например, "Кекс" и "котенок Кекс")
|
||||
if norm1 in norm2 or norm2 in norm1:
|
||||
return True
|
||||
|
||||
# Расстояние Левенштейна (простая реализация)
|
||||
distance = self._levenshtein_distance(norm1, norm2)
|
||||
max_len = max(len(norm1), len(norm2))
|
||||
if max_len > 0:
|
||||
similarity = 1 - (distance / max_len)
|
||||
if similarity > 0.7: # 70% сходства
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _levenshtein_distance(self, s1: str, s2: str) -> int:
|
||||
"""Вычисляет расстояние Левенштейна между двумя строками"""
|
||||
if len(s1) < len(s2):
|
||||
return self._levenshtein_distance(s2, s1)
|
||||
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
previous_row = range(len(s2) + 1)
|
||||
for i, c1 in enumerate(s1):
|
||||
current_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
insertions = previous_row[j + 1] + 1
|
||||
deletions = current_row[j] + 1
|
||||
substitutions = previous_row[j] + (c1 != c2)
|
||||
current_row.append(min(insertions, deletions, substitutions))
|
||||
previous_row = current_row
|
||||
|
||||
return previous_row[-1]
|
||||
|
||||
def find_existing_character(
|
||||
self, name: str, bible: StoryBible
|
||||
) -> Optional[Character]:
|
||||
"""Ищет существующего персонажа с похожим именем"""
|
||||
for char in bible.characters:
|
||||
if self.is_similar_name(name, char.name):
|
||||
return char
|
||||
return None
|
||||
|
||||
def find_existing_location(
|
||||
self, name: str, bible: StoryBible
|
||||
) -> Optional[Location]:
|
||||
"""Ищет существующую локацию с похожим именем"""
|
||||
for loc in bible.locations:
|
||||
if self.is_similar_name(name, loc.name):
|
||||
return loc
|
||||
return None
|
||||
|
||||
def find_existing_event(self, title: str, bible: StoryBible) -> Optional[Event]:
|
||||
"""Ищет существующее событие с похожим названием"""
|
||||
for event in bible.events:
|
||||
if self.is_similar_name(title, event.title):
|
||||
return event
|
||||
return None
|
||||
|
||||
def merge_character_data(self, existing: Character, new_data: dict) -> Character:
|
||||
"""Объединяет данные нового персонажа с существующим"""
|
||||
# Добавляем новые черты, которых еще нет
|
||||
new_traits = new_data.get("traits", [])
|
||||
for trait in new_traits:
|
||||
if trait and trait not in existing.traits:
|
||||
existing.traits.append(trait)
|
||||
|
||||
# Обновляем описание, если новое длиннее и содержательнее
|
||||
new_desc = new_data.get("description", "")
|
||||
if new_desc and len(new_desc) > len(existing.description):
|
||||
existing.description = new_desc
|
||||
|
||||
# Обновляем внешность, если она была пустой
|
||||
new_appearance = new_data.get("appearance", "")
|
||||
if new_appearance and not existing.appearance:
|
||||
existing.appearance = new_appearance
|
||||
|
||||
return existing
|
||||
|
||||
|
||||
# Singleton instance
|
||||
deduplication_service = DeduplicationService()
|
||||
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
Сервис для работы с документами (иерархическая структура: Книга → Глава → Сцена)
|
||||
Документы хранятся в папке проекта: data/projects/{project_id}/documents.json
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from models.document import DocumentNode
|
||||
|
||||
from services.project_service import project_service
|
||||
|
||||
|
||||
class DocumentService:
|
||||
def __init__(self):
|
||||
self.project_service = project_service
|
||||
|
||||
def _get_documents_file(self, project_id: str, user_id: str) -> Path:
|
||||
project_dir = self.project_service.get_project_dir(project_id, user_id)
|
||||
return project_dir / "documents.json"
|
||||
|
||||
def _load_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
|
||||
documents_file = self._get_documents_file(project_id, user_id)
|
||||
if documents_file.exists():
|
||||
with open(documents_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Старый формат: dict, где ключи — ID документов
|
||||
if isinstance(data, dict):
|
||||
flat_list = list(data.values())
|
||||
if flat_list and len(flat_list) > 0:
|
||||
return self._migrate_flat_to_hierarchical(
|
||||
flat_list, project_id, user_id
|
||||
)
|
||||
return []
|
||||
|
||||
# Новый формат: list
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
# Миграция: если старые данные без parent_id
|
||||
first = data[0]
|
||||
if isinstance(first, dict) and "parent_id" not in first:
|
||||
return self._migrate_flat_to_hierarchical(
|
||||
data, project_id, user_id
|
||||
)
|
||||
return [DocumentNode(**doc) for doc in data]
|
||||
return []
|
||||
|
||||
def _migrate_flat_to_hierarchical(
|
||||
self, old_data: List[Dict], project_id: str, user_id: str
|
||||
) -> List[DocumentNode]:
|
||||
"""Миграция плоских документов в иерархию"""
|
||||
book = DocumentNode(
|
||||
project_id=project_id,
|
||||
title="Книга 1",
|
||||
type="book",
|
||||
order=0,
|
||||
)
|
||||
|
||||
chapter = DocumentNode(
|
||||
project_id=project_id,
|
||||
parent_id=book.id,
|
||||
title="Глава 1",
|
||||
type="chapter",
|
||||
order=0,
|
||||
)
|
||||
|
||||
scenes = []
|
||||
for i, old_doc in enumerate(old_data):
|
||||
scene = DocumentNode(
|
||||
id=old_doc.get("id", str(uuid4())),
|
||||
project_id=project_id,
|
||||
parent_id=chapter.id,
|
||||
title=old_doc.get("title", f"Сцена {i + 1}"),
|
||||
content=old_doc.get("content", ""),
|
||||
type="scene",
|
||||
order=i,
|
||||
)
|
||||
scenes.append(scene)
|
||||
|
||||
result = [book, chapter] + scenes
|
||||
self._save_documents(project_id, result, user_id)
|
||||
print(f"Migrated {len(old_data)} flat documents to hierarchical structure")
|
||||
return result
|
||||
|
||||
def _save_documents(
|
||||
self, project_id: str, documents: List[DocumentNode], user_id: str
|
||||
):
|
||||
documents_file = self._get_documents_file(project_id, user_id)
|
||||
with open(documents_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
[doc.model_dump() for doc in documents],
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def get_documents_tree(self, project_id: str, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Получить документы в виде дерева"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
return self._build_tree(documents)
|
||||
|
||||
def _build_tree(self, documents: List[DocumentNode]) -> List[Dict[str, Any]]:
|
||||
"""Строит дерево из плоского списка"""
|
||||
doc_map = {doc.id: {**doc.model_dump(), "children": []} for doc in documents}
|
||||
roots = []
|
||||
|
||||
for doc in documents:
|
||||
node = doc_map[doc.id]
|
||||
if doc.parent_id is None:
|
||||
roots.append(node)
|
||||
elif doc.parent_id in doc_map:
|
||||
doc_map[doc.parent_id]["children"].append(node)
|
||||
|
||||
def sort_tree(nodes):
|
||||
nodes.sort(key=lambda x: x["order"])
|
||||
for node in nodes:
|
||||
sort_tree(node["children"])
|
||||
|
||||
sort_tree(roots)
|
||||
return roots
|
||||
|
||||
def get_all_documents(self, project_id: str, user_id: str) -> List[DocumentNode]:
|
||||
"""Получить все документы (плоский список)"""
|
||||
return self._load_documents(project_id, user_id)
|
||||
|
||||
def get_document(
|
||||
self, project_id: str, document_id: str, user_id: str
|
||||
) -> Optional[DocumentNode]:
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
for doc in documents:
|
||||
if doc.id == document_id:
|
||||
return doc
|
||||
return None
|
||||
|
||||
def create_document(
|
||||
self,
|
||||
project_id: str,
|
||||
title: str,
|
||||
doc_type: str,
|
||||
user_id: str,
|
||||
parent_id: Optional[str] = None,
|
||||
content: str = "",
|
||||
) -> DocumentNode:
|
||||
"""Создать новый документ с валидацией иерархии"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
|
||||
# Валидация иерархии
|
||||
if doc_type == "chapter" and parent_id:
|
||||
parent = next((d for d in documents if d.id == parent_id), None)
|
||||
if not parent or parent.type != "book":
|
||||
raise ValueError("Глава может быть создана только внутри книги")
|
||||
|
||||
if doc_type == "scene" and parent_id:
|
||||
parent = next((d for d in documents if d.id == parent_id), None)
|
||||
if not parent or parent.type != "chapter":
|
||||
raise ValueError("Сцена может быть создана только внутри главы")
|
||||
|
||||
if doc_type == "book" and parent_id:
|
||||
raise ValueError("Книга не может иметь родителя")
|
||||
|
||||
# Вычисляем order (последний среди братьев + 1)
|
||||
siblings = [d for d in documents if d.parent_id == parent_id]
|
||||
max_order = max([s.order for s in siblings], default=-1)
|
||||
|
||||
document = DocumentNode(
|
||||
project_id=project_id,
|
||||
parent_id=parent_id,
|
||||
title=title,
|
||||
content=content,
|
||||
type=doc_type,
|
||||
order=max_order + 1,
|
||||
)
|
||||
|
||||
documents.append(document)
|
||||
self._save_documents(project_id, documents, user_id)
|
||||
return document
|
||||
|
||||
def update_document(
|
||||
self,
|
||||
project_id: str,
|
||||
document_id: str,
|
||||
user_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
parent_id: Optional[str] = None,
|
||||
order: Optional[int] = None,
|
||||
) -> Optional[DocumentNode]:
|
||||
"""Обновить документ (включая перенос)"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
if doc.id == document_id:
|
||||
# Валидация при переносе
|
||||
if parent_id is not None and parent_id != doc.parent_id:
|
||||
new_parent = next((d for d in documents if d.id == parent_id), None)
|
||||
|
||||
if doc.type == "book":
|
||||
raise ValueError("Книга не может быть перемещена")
|
||||
|
||||
if doc.type == "chapter" and (
|
||||
not new_parent or new_parent.type != "book"
|
||||
):
|
||||
raise ValueError("Глава может находиться только внутри книги")
|
||||
|
||||
if doc.type == "scene" and (
|
||||
not new_parent or new_parent.type != "chapter"
|
||||
):
|
||||
raise ValueError("Сцена может находиться только внутри главы")
|
||||
|
||||
documents[i].parent_id = parent_id
|
||||
|
||||
if title is not None:
|
||||
documents[i].title = title
|
||||
if content is not None:
|
||||
documents[i].content = content
|
||||
if order is not None:
|
||||
documents[i].order = order
|
||||
|
||||
documents[i].updated_at = datetime.now().isoformat()
|
||||
self._save_documents(project_id, documents, user_id)
|
||||
return documents[i]
|
||||
|
||||
return None
|
||||
|
||||
def delete_document(self, project_id: str, document_id: str, user_id: str) -> bool:
|
||||
"""Удалить документ с каскадом (дети тоже удаляются)"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
|
||||
target = next((d for d in documents if d.id == document_id), None)
|
||||
if not target:
|
||||
return False
|
||||
|
||||
ids_to_delete: set[str] = set()
|
||||
|
||||
def collect_descendants(parent_id: str):
|
||||
ids_to_delete.add(parent_id)
|
||||
children = [d for d in documents if d.parent_id == parent_id]
|
||||
for child in children:
|
||||
collect_descendants(child.id)
|
||||
|
||||
collect_descendants(document_id)
|
||||
|
||||
documents = [d for d in documents if d.id not in ids_to_delete]
|
||||
self._save_documents(project_id, documents, user_id)
|
||||
return True
|
||||
|
||||
def get_children(
|
||||
self, project_id: str, parent_id: str, user_id: str
|
||||
) -> List[DocumentNode]:
|
||||
"""Получить прямых детей узла"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
children = [d for d in documents if d.parent_id == parent_id]
|
||||
children.sort(key=lambda x: x.order)
|
||||
return children
|
||||
|
||||
def initialize_test_document(
|
||||
self, project_id: str, user_id: str
|
||||
) -> List[DocumentNode]:
|
||||
"""
|
||||
Создаёт тестовую иерархию документов при первом запуске,
|
||||
если файл документов пуст.
|
||||
"""
|
||||
documents = self._load_documents(project_id, user_id)
|
||||
|
||||
if documents:
|
||||
return documents
|
||||
|
||||
book = DocumentNode(
|
||||
project_id=project_id,
|
||||
title="Книга 1",
|
||||
type="book",
|
||||
order=0,
|
||||
)
|
||||
|
||||
chapter = DocumentNode(
|
||||
project_id=project_id,
|
||||
parent_id=book.id,
|
||||
title="Глава 1",
|
||||
type="chapter",
|
||||
order=0,
|
||||
)
|
||||
|
||||
scene = DocumentNode(
|
||||
project_id=project_id,
|
||||
parent_id=chapter.id,
|
||||
title="Сцена 1",
|
||||
content="Это тестовый документ для разработки Writer IDE. Здесь будет располагаться контент ваших сценариев.",
|
||||
type="scene",
|
||||
order=0,
|
||||
)
|
||||
|
||||
result = [book, chapter, scene]
|
||||
self._save_documents(project_id, result, user_id)
|
||||
print(f"Created test hierarchical documents for project: {project_id}")
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
document_service = DocumentService()
|
||||
@@ -0,0 +1,134 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models.bible import StoryBible
|
||||
|
||||
from services.llm_router import llm_router
|
||||
|
||||
|
||||
class EntityExtractor:
|
||||
def __init__(self):
|
||||
self.llm_router = llm_router
|
||||
|
||||
def _build_extraction_prompt(
|
||||
self, text: str, existing_bible: Optional[StoryBible]
|
||||
) -> str:
|
||||
"""Формирует промпт для извлечения сущностей"""
|
||||
|
||||
existing_chars = ""
|
||||
existing_locs = ""
|
||||
existing_events = ""
|
||||
|
||||
if existing_bible:
|
||||
if existing_bible.characters:
|
||||
existing_chars = "Известные персонажи: " + ", ".join(
|
||||
[c.name for c in existing_bible.characters]
|
||||
)
|
||||
if existing_bible.locations:
|
||||
existing_locs = "Известные локации: " + ", ".join(
|
||||
[l.name for l in existing_bible.locations]
|
||||
)
|
||||
if existing_bible.events:
|
||||
existing_events = "Известные события: " + ", ".join(
|
||||
[e.title for e in existing_bible.events]
|
||||
)
|
||||
|
||||
prompt = f"""Ты — эксперт по анализу художественных текстов. Твоя задача — извлечь из текста новых персонажей, локации и события.
|
||||
|
||||
ВАЖНО: Отвечай СТРОГО НА РУССКОМ ЯЗЫКЕ. Все имена, описания и названия должны быть на русском языке.
|
||||
|
||||
{existing_chars}
|
||||
{existing_locs}
|
||||
{existing_events}
|
||||
|
||||
ТЕКСТ ДЛЯ АНАЛИЗА:
|
||||
---
|
||||
{text}
|
||||
---
|
||||
|
||||
ИНСТРУКЦИИ:
|
||||
1. Извлеки ТОЛЬКО НОВЫЕ сущности, которых еще нет в списке известных
|
||||
2. Для персонажей укажи: name, description (краткое), traits (список черт), appearance (если описана), role (protagonist/antagonist/supporting/minor)
|
||||
3. Для локаций укажи: name, description, atmosphere (если есть), type (city/building/nature/interior/other)
|
||||
4. Для событий укажи: title, description, importance (major/minor/background)
|
||||
5. Если обнаружены новые черты для УЖЕ ИЗВЕСТНЫХ персонажей, укажи их в updated_traits
|
||||
6. НЕ выдумывай информацию, которой нет в тексте
|
||||
7. Если новых сущностей нет, верни пустые списки
|
||||
8. Все ответы СТРОГО НА РУССКОМ ЯЗЫКЕ
|
||||
|
||||
ОТВЕТЬ СТРОГО В ФОРМАТЕ JSON (без markdown, без пояснений, только чистый JSON):
|
||||
{{
|
||||
"new_characters": [
|
||||
{{"name": "...", "description": "...", "traits": ["...", "..."], "appearance": "...", "role": "..."}}
|
||||
],
|
||||
"new_locations": [
|
||||
{{"name": "...", "description": "...", "atmosphere": "...", "type": "..."}}
|
||||
],
|
||||
"new_events": [
|
||||
{{"title": "...", "description": "...", "importance": "..."}}
|
||||
],
|
||||
"updated_traits": [
|
||||
{{"character_name": "...", "new_traits": ["...", "..."]}}
|
||||
]
|
||||
}}"""
|
||||
|
||||
return prompt
|
||||
|
||||
def _parse_json_response(self, response_text: str) -> Dict[str, Any]:
|
||||
"""Парсит JSON из ответа модели, обрабатывая возможные markdown обертки"""
|
||||
# Убираем возможные markdown обертки
|
||||
cleaned = response_text.strip()
|
||||
|
||||
# Убираем ```json и ```
|
||||
if cleaned.startswith("```"):
|
||||
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
|
||||
cleaned = re.sub(r"\s*```$", "", cleaned)
|
||||
|
||||
# Пытаемся найти JSON в тексте
|
||||
json_match = re.search(r"\{[\s\S]*\}", cleaned)
|
||||
if json_match:
|
||||
cleaned = json_match.group(0)
|
||||
|
||||
try:
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(
|
||||
f"Failed to parse JSON from LLM response: {e}\n"
|
||||
f"Raw response: {response_text[:500]}"
|
||||
)
|
||||
|
||||
async def extract_entities(
|
||||
self,
|
||||
text: str,
|
||||
existing_bible: Optional[StoryBible] = None,
|
||||
provider: str = "openrouter",
|
||||
model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Извлекает сущности из текста"""
|
||||
|
||||
prompt = self._build_extraction_prompt(text, existing_bible)
|
||||
|
||||
# Используем пресет editor для точности (низкая температура)
|
||||
generated_text = await self.llm_router.generate(
|
||||
prompt=prompt,
|
||||
provider=provider,
|
||||
model=model,
|
||||
preset="editor",
|
||||
)
|
||||
|
||||
parsed = self._parse_json_response(generated_text)
|
||||
|
||||
# Валидация и нормализация ответа
|
||||
result = {
|
||||
"new_characters": parsed.get("new_characters", []),
|
||||
"new_locations": parsed.get("new_locations", []),
|
||||
"new_events": parsed.get("new_events", []),
|
||||
"updated_traits": parsed.get("updated_traits", []),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Singleton instance
|
||||
entity_extractor = EntityExtractor()
|
||||
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Сервис экспорта документов в различные форматы (EPUB, FB2, PDF, DOCX) через Pandoc.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import List
|
||||
|
||||
from fastapi import HTTPException
|
||||
from models.document import DocumentNode
|
||||
|
||||
from services.document_service import document_service
|
||||
from services.project_service import project_service
|
||||
|
||||
|
||||
class ExportService:
|
||||
def __init__(self):
|
||||
# Проверка наличия pandoc при инициализации
|
||||
try:
|
||||
subprocess.run(["pandoc", "--version"], capture_output=True, check=True)
|
||||
self.available = True
|
||||
except FileNotFoundError:
|
||||
print("WARNING: Pandoc not found. Export functionality will be limited.")
|
||||
self.available = False
|
||||
|
||||
def _get_document_and_children(
|
||||
self, documents: List[DocumentNode], target_id: str
|
||||
) -> List[DocumentNode]:
|
||||
"""Рекурсивно получает документ и всех его потомков, отсортированных по order."""
|
||||
result: List[DocumentNode] = []
|
||||
doc_map = {d.id: d for d in documents}
|
||||
|
||||
def collect_children(parent_id: str):
|
||||
children = [d for d in documents if d.parent_id == parent_id]
|
||||
children.sort(key=lambda x: x.order)
|
||||
for child in children:
|
||||
result.append(child)
|
||||
collect_children(child.id)
|
||||
|
||||
if target_id in doc_map:
|
||||
result.append(doc_map[target_id])
|
||||
collect_children(target_id)
|
||||
|
||||
return result
|
||||
|
||||
def _build_book_html(
|
||||
self,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
document_id: str | None = None,
|
||||
base_path: str = "", # Абсолютный путь для относительных ресурсов
|
||||
) -> str:
|
||||
"""Собирает HTML-контент книги или конкретного документа с изображениями."""
|
||||
documents = document_service.get_all_documents(project_id, user_id)
|
||||
project_dir = project_service.get_project_dir(project_id, user_id)
|
||||
images_dir = project_dir / "images"
|
||||
|
||||
# Проверяем наличие обложки
|
||||
cover_path = images_dir / "cover.png"
|
||||
has_cover = cover_path.exists()
|
||||
|
||||
if document_id:
|
||||
target_docs = self._get_document_and_children(documents, document_id)
|
||||
else:
|
||||
# Экспортируем всё: берем корневые документы (книги) и всех потомков
|
||||
tree = document_service.get_documents_tree(project_id, user_id)
|
||||
target_docs = self._flatten_tree(tree)
|
||||
|
||||
# Собираем все PNG-файлы изображений
|
||||
all_images = []
|
||||
if images_dir.exists():
|
||||
all_images = [
|
||||
f.name
|
||||
for f in images_dir.iterdir()
|
||||
if f.is_file() and f.suffix.lower() == ".png"
|
||||
]
|
||||
|
||||
# Определяем ID всех глав для поиска привязанных изображений
|
||||
chapter_ids = {d.id for d in documents if d.type == "chapter"}
|
||||
|
||||
# "Бесхозные" изображения — не cover.png и не {chapter_id}.png
|
||||
# (старые файлы с UUID, сгенерированные до введения конвенции)
|
||||
orphan_images = [
|
||||
img
|
||||
for img in all_images
|
||||
if img != "cover.png" and img.removesuffix(".png") not in chapter_ids
|
||||
]
|
||||
orphan_index = (
|
||||
0 # Индекс для последовательного использования orphan-изображений
|
||||
)
|
||||
|
||||
# Формируем префикс пути: абсолютный путь если задан, иначе относительный
|
||||
img_prefix = f"{base_path}/images" if base_path else "images"
|
||||
|
||||
html_parts: List[str] = []
|
||||
|
||||
# Добавляем обложку, если есть и это полная книга (не один документ)
|
||||
if has_cover and not document_id:
|
||||
html_parts.append(
|
||||
'<div style="text-align: center; page-break-after: always;">'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<img src="{img_prefix}/cover.png" alt="Cover" style="max-width: 100%; height: auto;" />'
|
||||
)
|
||||
html_parts.append("</div>")
|
||||
html_parts.append("<br/><br/>")
|
||||
|
||||
for doc in target_docs:
|
||||
if doc.type == "book":
|
||||
html_parts.append(f"<h1>{doc.title}</h1>")
|
||||
elif doc.type == "chapter":
|
||||
# Ищем изображение главы: сначала по конвенции {chapter_id}.png
|
||||
chapter_image_path = images_dir / f"{doc.id}.png"
|
||||
has_chapter_image = chapter_image_path.exists()
|
||||
chapter_image_filename = f"{doc.id}.png" if has_chapter_image else None
|
||||
|
||||
# Fallback: если нет файла по конвенции, берём "бесхозное" изображение
|
||||
if not has_chapter_image and orphan_index < len(orphan_images):
|
||||
chapter_image_filename = orphan_images[orphan_index]
|
||||
orphan_index += 1
|
||||
|
||||
html_parts.append(f"<h2>{doc.title}</h2>")
|
||||
|
||||
# Вставляем изображение главы, если есть
|
||||
if chapter_image_filename:
|
||||
html_parts.append(
|
||||
'<div style="text-align: center; margin: 20px 0;">'
|
||||
)
|
||||
html_parts.append(
|
||||
f'<img src="{img_prefix}/{chapter_image_filename}" alt="{doc.title}" style="max-width: 100%; height: auto;" />'
|
||||
)
|
||||
html_parts.append("</div>")
|
||||
elif doc.type == "scene" and doc.content:
|
||||
html_parts.append(f"<h3>{doc.title}</h3>")
|
||||
html_parts.append(doc.content)
|
||||
html_parts.append("<br/><br/>")
|
||||
|
||||
return "\n".join(html_parts)
|
||||
|
||||
def _flatten_tree(self, tree: List[dict]) -> List[DocumentNode]:
|
||||
"""Разворачивает дерево документов в плоский список в порядке обхода."""
|
||||
result: List[DocumentNode] = []
|
||||
for node in tree:
|
||||
result.append(DocumentNode(**node))
|
||||
for child in node.get("children", []):
|
||||
result.extend(self._flatten_tree([child]))
|
||||
return result
|
||||
|
||||
def export_document(
|
||||
self, project_id: str, user_id: str, document_id: str | None, format: str
|
||||
) -> bytes:
|
||||
"""Экспортирует документ (или всю книгу) в указанный формат."""
|
||||
if not self.available:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Pandoc is not installed. Export functionality is unavailable.",
|
||||
)
|
||||
|
||||
valid_formats = ["epub", "fb2", "pdf", "docx"]
|
||||
if format not in valid_formats:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported format. Choose from {valid_formats}",
|
||||
)
|
||||
|
||||
# Получаем проект для метаданных
|
||||
project = project_service.get_project(project_id)
|
||||
title = project.title if project else "Без названия"
|
||||
|
||||
# Собираем HTML
|
||||
html_content = self._build_book_html(project_id, user_id, document_id)
|
||||
|
||||
# Получаем путь к папке проекта для копирования изображений
|
||||
project_dir = project_service.get_project_dir(project_id, user_id)
|
||||
images_dir = project_dir / "images"
|
||||
|
||||
# Создаём временную директорию
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_html = os.path.join(temp_dir, "content.html")
|
||||
output_path = os.path.join(temp_dir, f"output.{format}")
|
||||
|
||||
# Копируем папку images во временную директорию для Pandoc
|
||||
if images_dir.exists():
|
||||
shutil.copytree(images_dir, os.path.join(temp_dir, "images"))
|
||||
|
||||
try:
|
||||
# Записываем HTML
|
||||
html_content = self._build_book_html(
|
||||
project_id, user_id, document_id, base_path=temp_dir
|
||||
)
|
||||
with open(temp_html, "w", encoding="utf-8") as f:
|
||||
f.write(html_content)
|
||||
|
||||
# Формируем команду pandoc
|
||||
cmd = [
|
||||
"pandoc",
|
||||
temp_html,
|
||||
"-o",
|
||||
output_path,
|
||||
"--from",
|
||||
"html",
|
||||
"--to",
|
||||
format,
|
||||
]
|
||||
|
||||
# Метаданные для EPUB/FB2
|
||||
if format in ["epub", "fb2"]:
|
||||
cmd.extend(["--metadata", f"title={title}"])
|
||||
cmd.extend(["--metadata", "author=Author"])
|
||||
# Указываем путь к ресурсам (изображениям)
|
||||
cmd.extend(["--resource-path", temp_dir])
|
||||
|
||||
# PDF через WeasyPrint (не требует LaTeX)
|
||||
if format == "pdf":
|
||||
cmd.extend(["--pdf-engine=weasyprint"])
|
||||
|
||||
# Выполняем
|
||||
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
|
||||
# Читаем результат
|
||||
with open(output_path, "rb") as f:
|
||||
file_content = f.read()
|
||||
|
||||
return file_content
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Pandoc error: {e.stderr}")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
# Очистка временных файлов
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
|
||||
# Singleton instance
|
||||
export_service = ExportService()
|
||||
@@ -0,0 +1,203 @@
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from config.settings import settings
|
||||
from fastapi import HTTPException
|
||||
|
||||
from services.project_service import project_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageGenerator:
|
||||
def __init__(self):
|
||||
self.api_key = settings.OPENROUTER_API_KEY
|
||||
self.base_url = getattr(
|
||||
settings, "OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
self.default_model = "qwen/qwen-image-2.1-turbo"
|
||||
|
||||
def _get_images_dir(self, project_id: str, user_id: str) -> Path:
|
||||
"""Получить или создать директорию для изображений проекта"""
|
||||
project_dir = project_service.get_project_dir(project_id, user_id)
|
||||
images_dir = project_dir / "images"
|
||||
images_dir.mkdir(exist_ok=True)
|
||||
return images_dir
|
||||
|
||||
async def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
project_id: str,
|
||||
user_id: str,
|
||||
document_id: str,
|
||||
document_type: str, # 'book' или 'chapter'
|
||||
model: Optional[str] = None,
|
||||
size: str = "1024x1024",
|
||||
) -> dict:
|
||||
"""
|
||||
Генерирует изображение по промпту и сохраняет его в папку проекта.
|
||||
Имя файла определяется по конвенции:
|
||||
- document_type='book' -> cover.png (обложка книги)
|
||||
- document_type='chapter' -> {document_id}.png (иллюстрация главы)
|
||||
Возвращает информацию о сохранённом файле.
|
||||
"""
|
||||
if not self.api_key or self.api_key == "your_api_key_here":
|
||||
raise ValueError("OpenRouter API key not configured")
|
||||
|
||||
model = model or self.default_model
|
||||
|
||||
# Генерация изображений использует OpenAI Images API endpoint,
|
||||
# а не Chat Completions. См. официальную документацию модели.
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
|
||||
# Мапим size в aspect_ratio для моделей, которые его поддерживают
|
||||
aspect_ratio_map = {
|
||||
"1024x1024": "1:1",
|
||||
"1024x1792": "9:16",
|
||||
"1792x1024": "16:9",
|
||||
"512x512": "1:1",
|
||||
}
|
||||
if size in aspect_ratio_map:
|
||||
payload["size"] = size
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "http://localhost:3000",
|
||||
"X-Title": "Writer IDE",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/images/generations",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Проверяем наличие ошибки в ответе (некоторые провайдеры
|
||||
# возвращают 200 с полем error вместо HTTP error status)
|
||||
if "error" in data:
|
||||
error_info = data["error"]
|
||||
error_msg = (
|
||||
error_info.get("message", str(error_info))
|
||||
if isinstance(error_info, dict)
|
||||
else str(error_info)
|
||||
)
|
||||
raise ValueError(f"API returned error: {error_msg}")
|
||||
|
||||
# OpenAI Images API формат: {"data": [{"b64_json": "..."}]}
|
||||
images_data = data.get("data", [])
|
||||
if not images_data:
|
||||
raise ValueError(
|
||||
f"No image data in response. Keys: {list(data.keys())}"
|
||||
)
|
||||
|
||||
img_data = images_data[0]
|
||||
image_url = None
|
||||
image_base64 = None
|
||||
|
||||
if isinstance(img_data, dict):
|
||||
image_base64 = img_data.get("b64_json")
|
||||
image_url = img_data.get("url")
|
||||
elif isinstance(img_data, str):
|
||||
# Некоторые провайдеры могут вернуть URL напрямую в data
|
||||
image_url = img_data if img_data.startswith("http") else None
|
||||
|
||||
# Фоллбэк: пробуем извлечь из text content (если провайдер
|
||||
# использует chat/completions формат поверх images/generations)
|
||||
if not image_url and not image_base64:
|
||||
content = (
|
||||
data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
)
|
||||
if content:
|
||||
base64_match = re.search(
|
||||
r"data:image/[^;]+;base64,([A-Za-z0-9+/=]+)", content
|
||||
)
|
||||
if base64_match:
|
||||
image_base64 = base64_match.group(1)
|
||||
|
||||
if not image_url and not image_base64:
|
||||
logger.warning(
|
||||
"Could not extract image. Response keys: %s",
|
||||
list(data.keys()),
|
||||
)
|
||||
raise ValueError(
|
||||
f"Could not extract image from response. Available keys: {list(data.keys())}"
|
||||
)
|
||||
|
||||
# Сохраняем изображение
|
||||
images_dir = self._get_images_dir(project_id, user_id)
|
||||
|
||||
# Определяем имя файла по конвенции
|
||||
if document_type == "book":
|
||||
filename = "cover.png" # Обложка книги
|
||||
else: # chapter
|
||||
filename = f"{document_id}.png" # Изображение главы
|
||||
|
||||
filepath = images_dir / filename
|
||||
|
||||
if image_url:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
img_response = await client.get(image_url)
|
||||
img_response.raise_for_status()
|
||||
image_bytes = img_response.content
|
||||
filepath.write_bytes(image_bytes)
|
||||
data_url = f"data:image/png;base64,{base64.b64encode(image_bytes).decode('ascii')}"
|
||||
elif image_base64:
|
||||
image_bytes = base64.b64decode(image_base64)
|
||||
filepath.write_bytes(image_bytes)
|
||||
# Определяем MIME тип по первым байтам изображения
|
||||
mime = "image/png" # default
|
||||
if image_bytes[:2] == b"\xff\xd8":
|
||||
mime = "image/jpeg"
|
||||
elif image_bytes[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
mime = "image/png"
|
||||
elif image_bytes[:6] in (b"GIF87a", b"GIF89a"):
|
||||
mime = "image/gif"
|
||||
elif image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
|
||||
mime = "image/webp"
|
||||
data_url = f"data:{mime};base64,{image_base64}"
|
||||
else:
|
||||
# Должно быть обработано выше, но на всякий случай
|
||||
raise ValueError("No image data available to encode")
|
||||
|
||||
return {
|
||||
"image_id": document_id,
|
||||
"filename": filename,
|
||||
"path": f"/api/images/{project_id}/{filename}",
|
||||
"prompt": prompt,
|
||||
"model": model,
|
||||
"data_url": data_url,
|
||||
"document_id": document_id,
|
||||
"document_type": document_type,
|
||||
}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Image generation API error: {e.response.status_code} - {e.response.text[:200]}",
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=504, detail="Image generation timed out")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Image generation failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Singleton
|
||||
image_generator = ImageGenerator()
|
||||
@@ -0,0 +1,126 @@
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from config.settings import settings
|
||||
|
||||
from services.model_config import model_config
|
||||
|
||||
|
||||
class LLMRouter:
|
||||
def __init__(self):
|
||||
self.ollama_base_url = settings.OLLAMA_BASE_URL
|
||||
self.openrouter_base_url = settings.OPENROUTER_BASE_URL
|
||||
|
||||
async def generate_ollama(
|
||||
self,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
preset: str = "creative_draft",
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Генерация через локальную Ollama с пресетами"""
|
||||
from services.presets import get_preset
|
||||
|
||||
# Используем модель из конфига, если не указана
|
||||
model = model or model_config.get_default_model("ollama")
|
||||
|
||||
# Валидация модели
|
||||
if not model_config.validate_model("ollama", model):
|
||||
raise ValueError(f"Model {model} not available for Ollama")
|
||||
|
||||
# Получаем базовые параметры из пресета
|
||||
preset_options = get_preset(preset, "ollama")
|
||||
# Объединяем с пользовательскими опциями (пользовательские переопределяют пресет)
|
||||
merged_options = {**preset_options, **(options or {})}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": merged_options,
|
||||
}
|
||||
response = await client.post(
|
||||
f"{self.ollama_base_url}/api/generate", json=payload, timeout=60.0
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["response"]
|
||||
|
||||
async def generate_openrouter(
|
||||
self,
|
||||
prompt: str,
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
preset: str = "creative_draft",
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Генерация через OpenRouter API с пресетами"""
|
||||
from services.presets import get_preset
|
||||
|
||||
# Используем ключ из settings, если не передан явно
|
||||
actual_api_key = api_key or settings.OPENROUTER_API_KEY
|
||||
|
||||
# Валидация API ключа
|
||||
if not actual_api_key or actual_api_key == "your_api_key_here":
|
||||
raise ValueError(
|
||||
"OpenRouter API key not configured. "
|
||||
"Please set OPENROUTER_API_KEY in backend/.env file"
|
||||
)
|
||||
|
||||
# Используем модель из конфига, если не указана
|
||||
model = model or model_config.get_default_model("openrouter")
|
||||
|
||||
# Валидация модели
|
||||
if not model_config.validate_model("openrouter", model):
|
||||
raise ValueError(f"Model {model} not available for OpenRouter")
|
||||
|
||||
# Получаем базовые параметры из пресета
|
||||
preset_options = get_preset(preset, "openrouter")
|
||||
# Объединяем с пользовательскими опциями
|
||||
merged_options = {**preset_options, **(options or {})}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
}
|
||||
# Добавляем объединённые опции (включая frequency_penalty, presence_penalty и temperature)
|
||||
if merged_options:
|
||||
payload.update(merged_options)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {actual_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "http://localhost:3000", # Для OpenRouter
|
||||
"X-Title": "Writer IDE", # Для OpenRouter
|
||||
}
|
||||
response = await client.post(
|
||||
f"{self.openrouter_base_url}/chat/completions",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=60.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["choices"][0]["message"]["content"]
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
provider: str = "ollama",
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
preset: str = "creative_draft",
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Универсальный метод генерации"""
|
||||
if provider == "ollama":
|
||||
return await self.generate_ollama(prompt, model, preset, options)
|
||||
elif provider == "openrouter":
|
||||
return await self.generate_openrouter(
|
||||
prompt, model, api_key, preset, options
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
llm_router = LLMRouter()
|
||||
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class ModelConfig:
|
||||
"""Класс для работы с конфигурацией моделей AI провайдеров"""
|
||||
|
||||
def __init__(self, config_path: str = "config/models.json"):
|
||||
self.config_path = Path(config_path)
|
||||
self.config = self._load_config()
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Загрузить конфигурацию из JSON файла"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {self.config_path}")
|
||||
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
def get_available_models(self, provider: str) -> List[Dict[str, Any]]:
|
||||
"""Получить список доступных моделей для провайдера"""
|
||||
if provider not in self.config:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return self.config[provider]["available_models"]
|
||||
|
||||
def get_default_model(self, provider: str) -> str:
|
||||
"""Получить модель по умолчанию для провайдера"""
|
||||
if provider not in self.config:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
return self.config[provider]["default_model"]
|
||||
|
||||
def get_model_info(self, provider: str, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Получить информацию о конкретной модели"""
|
||||
models = self.get_available_models(provider)
|
||||
for model in models:
|
||||
if model["id"] == model_id:
|
||||
return model
|
||||
return None
|
||||
|
||||
def validate_model(self, provider: str, model_id: str) -> bool:
|
||||
"""Проверить, существует ли модель для провайдера"""
|
||||
return self.get_model_info(provider, model_id) is not None
|
||||
|
||||
|
||||
# Singleton instance
|
||||
model_config = ModelConfig()
|
||||
@@ -0,0 +1,85 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
PresetName = Literal["creative_draft", "editor", "brainstorm"]
|
||||
|
||||
# Пресеты для Ollama (параметры, которые понимает Ollama API)
|
||||
OLLAMA_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"creative_draft": {
|
||||
"temperature": 0.85,
|
||||
"top_p": 0.92,
|
||||
"top_k": 50,
|
||||
"repeat_penalty": 1.15,
|
||||
},
|
||||
"editor": {
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.85,
|
||||
"top_k": 40,
|
||||
"repeat_penalty": 1.0,
|
||||
},
|
||||
"brainstorm": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"top_k": 80,
|
||||
"repeat_penalty": 1.2,
|
||||
},
|
||||
}
|
||||
|
||||
# Пресеты для OpenRouter (параметры OpenAI-совместимого API)
|
||||
OPENROUTER_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"creative_draft": {
|
||||
"temperature": 0.85,
|
||||
"top_p": 0.92,
|
||||
"frequency_penalty": 0.3,
|
||||
"presence_penalty": 0.4,
|
||||
},
|
||||
"editor": {
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.85,
|
||||
"frequency_penalty": 0.0,
|
||||
"presence_penalty": 0.0,
|
||||
},
|
||||
"brainstorm": {
|
||||
"temperature": 1.0,
|
||||
"top_p": 0.95,
|
||||
"frequency_penalty": 0.5,
|
||||
"presence_penalty": 0.6,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_preset(preset_name: str, provider: str) -> dict[str, Any]:
|
||||
"""Получить параметры пресета для указанного провайдера.
|
||||
|
||||
Args:
|
||||
preset_name: Название пресета (creative_draft, editor, brainstorm)
|
||||
provider: Провайдер ("ollama" или "openrouter")
|
||||
|
||||
Returns:
|
||||
Параметры пресета как словарь
|
||||
|
||||
Raises:
|
||||
ValueError: Если провайдер неизвестен или пресет не найден
|
||||
"""
|
||||
if provider == "ollama":
|
||||
return OLLAMA_PRESETS.get(preset_name, OLLAMA_PRESETS["creative_draft"])
|
||||
elif provider == "openrouter":
|
||||
return OPENROUTER_PRESETS.get(preset_name, OPENROUTER_PRESETS["creative_draft"])
|
||||
else:
|
||||
raise ValueError(f"Unknown provider: {provider}")
|
||||
|
||||
|
||||
def list_presets(provider: str | None = None) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Вернуть все доступные пресеты.
|
||||
|
||||
Args:
|
||||
provider: Опционально фильтровать по провайдеру
|
||||
|
||||
Returns:
|
||||
Словарь всех доступных пресетов
|
||||
"""
|
||||
if provider == "ollama":
|
||||
return OLLAMA_PRESETS
|
||||
elif provider == "openrouter":
|
||||
return OPENROUTER_PRESETS
|
||||
else:
|
||||
return {"ollama": OLLAMA_PRESETS, "openrouter": OPENROUTER_PRESETS}
|
||||
@@ -0,0 +1,196 @@
|
||||
import json
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config.settings import settings
|
||||
from models.bible import StoryBible
|
||||
from models.project import Project
|
||||
from models.user import User
|
||||
|
||||
from services.auth_service import auth_service
|
||||
|
||||
|
||||
class ProjectService:
|
||||
def __init__(self):
|
||||
self.data_dir = Path(settings.DATA_DIR)
|
||||
self.projects_dir = self.data_dir / "projects"
|
||||
self.projects_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.projects_file = self.data_dir / "projects.json"
|
||||
self.projects = self._load_projects()
|
||||
|
||||
# Миграция: перенос старых проектов в структуру с пользователями
|
||||
self._migrate_old_data()
|
||||
|
||||
def _load_projects(self) -> list[Project]:
|
||||
"""Загрузить список проектов"""
|
||||
if self.projects_file.exists():
|
||||
with open(self.projects_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return [Project(**p) for p in data]
|
||||
return []
|
||||
|
||||
def _save_projects(self):
|
||||
"""Сохранить список проектов"""
|
||||
with open(self.projects_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
[p.model_dump() for p in self.projects],
|
||||
f,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
def _migrate_old_data(self):
|
||||
"""Миграция старых проектов в структуру data/users/{user_id}/projects/"""
|
||||
old_projects_dir = self.data_dir / "projects"
|
||||
users_dir = self.data_dir / "users"
|
||||
users_file = self.data_dir / "users.json"
|
||||
default_user_id = "default-user-0000-0000-0000-000000000000"
|
||||
|
||||
# Если есть старые проекты, но нет пользователей — создаём дефолтного и перемещаем
|
||||
if old_projects_dir.exists() and not users_dir.exists():
|
||||
print("Migrating old projects to a default user...")
|
||||
default_user = User(
|
||||
id=default_user_id,
|
||||
username="admin",
|
||||
email="admin@local",
|
||||
hashed_password=auth_service.get_password_hash("admin"),
|
||||
)
|
||||
auth_service.users.append(default_user)
|
||||
auth_service._save_users()
|
||||
|
||||
# Перемещаем папку проектов
|
||||
new_user_dir = users_dir / default_user.id
|
||||
new_user_dir.mkdir(parents=True, exist_ok=True)
|
||||
old_projects_dir.rename(new_user_dir / "projects")
|
||||
|
||||
# Обновляем user_id у существующих проектов
|
||||
for i, project in enumerate(self.projects):
|
||||
self.projects[i].user_id = default_user.id
|
||||
self._save_projects()
|
||||
|
||||
print("Migration complete. Default user: admin / admin")
|
||||
|
||||
# Если проекты уже перемещены, но users.json отсутствует — восстанавливаем
|
||||
if users_dir.exists() and not users_file.exists():
|
||||
has_projects_without_user = any(
|
||||
p.user_id == "" or p.user_id == default_user_id for p in self.projects
|
||||
)
|
||||
if has_projects_without_user or any(users_dir.iterdir()):
|
||||
print("Restoring default user (users.json was missing)...")
|
||||
default_user = User(
|
||||
id=default_user_id,
|
||||
username="admin",
|
||||
email="admin@local",
|
||||
hashed_password=auth_service.get_password_hash("admin"),
|
||||
)
|
||||
auth_service.users.append(default_user)
|
||||
auth_service._save_users()
|
||||
|
||||
# Обновляем user_id у проектов без привязки
|
||||
for i, project in enumerate(self.projects):
|
||||
if not project.user_id:
|
||||
self.projects[i].user_id = default_user.id
|
||||
self._save_projects()
|
||||
print("Default user restored: admin / admin")
|
||||
|
||||
# Если папка projects уже переехала, обновляем ссылку
|
||||
if users_dir.exists():
|
||||
new_projects_dir = self.data_dir / "users"
|
||||
# Проверяем что old_projects_dir больше не существует (уже перемещён)
|
||||
if not old_projects_dir.exists():
|
||||
self.projects_dir = new_projects_dir
|
||||
|
||||
def get_project_dir(self, project_id: str, user_id: str) -> Path:
|
||||
"""Получить директорию проекта с учётом пользователя"""
|
||||
# Определяем базу: если есть users/{user_id}, используем новую структуру
|
||||
user_projects_base = self.data_dir / "users" / user_id / "projects"
|
||||
if user_projects_base.exists():
|
||||
project_dir = user_projects_base / project_id
|
||||
else:
|
||||
# Fallback для обратной совместимости
|
||||
project_dir = self.data_dir / "projects" / project_id
|
||||
|
||||
if not project_dir.exists():
|
||||
raise ValueError(f"Project directory not found: {project_id}")
|
||||
return project_dir
|
||||
|
||||
def get_all_projects(self, user_id: str | None = None) -> list[Project]:
|
||||
"""Получить все проекты (опционально фильтруя по пользователю)"""
|
||||
if user_id:
|
||||
return [p for p in self.projects if p.user_id == user_id]
|
||||
return self.projects
|
||||
|
||||
def get_project(self, project_id: str) -> Project | None:
|
||||
"""Получить проект по ID"""
|
||||
for project in self.projects:
|
||||
if project.id == project_id:
|
||||
return project
|
||||
return None
|
||||
|
||||
def create_project(
|
||||
self, title: str, user_id: str, description: str = ""
|
||||
) -> Project:
|
||||
"""Создать новый проект"""
|
||||
project = Project(title=title, user_id=user_id, description=description)
|
||||
self.projects.append(project)
|
||||
self._save_projects()
|
||||
|
||||
# Создаем директорию проекта в папке пользователя
|
||||
project_dir = self.data_dir / "users" / user_id / "projects" / project.id
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Инициализируем пустые файлы
|
||||
(project_dir / "documents.json").write_text("[]", encoding="utf-8")
|
||||
|
||||
# Создаем пустую Story Bible
|
||||
empty_bible = StoryBible()
|
||||
(project_dir / "story_bible.json").write_text(
|
||||
json.dumps(empty_bible.model_dump(), ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return project
|
||||
|
||||
def update_project(
|
||||
self,
|
||||
project_id: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> Project | None:
|
||||
"""Обновить проект"""
|
||||
for i, project in enumerate(self.projects):
|
||||
if project.id == project_id:
|
||||
if title is not None:
|
||||
self.projects[i].title = title
|
||||
if description is not None:
|
||||
self.projects[i].description = description
|
||||
self.projects[i].updated_at = datetime.now().isoformat()
|
||||
self._save_projects()
|
||||
return self.projects[i]
|
||||
return None
|
||||
|
||||
def delete_project(self, project_id: str, user_id: str) -> bool:
|
||||
"""Удалить проект и все его данные"""
|
||||
for i, project in enumerate(self.projects):
|
||||
if project.id == project_id:
|
||||
# Проверяем принадлежность
|
||||
if project.user_id != user_id:
|
||||
raise ValueError("Project does not belong to this user")
|
||||
|
||||
self.projects.pop(i)
|
||||
self._save_projects()
|
||||
|
||||
# Удаляем директорию проекта
|
||||
project_dir = (
|
||||
self.data_dir / "users" / user_id / "projects" / project_id
|
||||
)
|
||||
if project_dir.exists():
|
||||
shutil.rmtree(project_dir)
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
project_service = ProjectService()
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для сборки и тестирования Docker-образа backend локально
|
||||
|
||||
echo "🔨 Building backend Docker image..."
|
||||
docker build -t writer-ide-backend:local .
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build successful!"
|
||||
echo ""
|
||||
echo "🔍 Checking installed tools..."
|
||||
docker run --rm writer-ide-backend:local pandoc --version
|
||||
docker run --rm writer-ide-backend:local python -c "import weasyprint; print('WeasyPrint version:', weasyprint.__version__)"
|
||||
|
||||
echo ""
|
||||
echo "🚀 To run the container:"
|
||||
echo "docker run -p 8000:8000 -v /path/to/data:/app/data --env-file .env.docker writer-ide-backend:local"
|
||||
Reference in new issue
Block a user