initial commit
Build and Push Docker Images / build-and-push (push) Failing after 3m33s

This commit is contained in:
admin committed 2026-11-07 16:39:43 +03:00
commit 46af290db3
100 files changed
+16663

No files matched your search

+40
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+8
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.coverage
.pytest_cache/
data/
.env
+49
View File
@@ -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"]
+91
View File
@@ -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`.
+72
View File
@@ -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"
}
]
}
}
+48
View File
@@ -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()
+44
View File
@@ -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"}
View File
Whitespace-only changes.
+95
View File
@@ -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'
+46
View File
@@ -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
+51
View File
@@ -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
+15
View File
@@ -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())
+28
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
fastapi
uvicorn[standard]
pydantic
httpx
python-dotenv
bcrypt
python-jose[cryptography]
weasyprint
+1
View File
@@ -0,0 +1 @@
+428
View File
@@ -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))
+50
View File
@@ -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
+83
View File
@@ -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)
+151
View File
@@ -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)
+64
View File
@@ -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))
+8
View File
@@ -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"}
+40
View File
@@ -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))
+85
View File
@@ -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))
View File
Whitespace-only changes.
+109
View File
@@ -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()
+135
View File
@@ -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()
+123
View File
@@ -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()
+133
View File
@@ -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()
+302
View File
@@ -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()
+134
View File
@@ -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()
+239
View File
@@ -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()
+203
View File
@@ -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()
+126
View File
@@ -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()
+47
View File
@@ -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()
+85
View File
@@ -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}
+196
View File
@@ -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()
+20
View File
@@ -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"