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

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()