This commit is contained in:
commit
46af290db3
100 files changed
+16663
No files matched your search
@@ -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))
|
||||
Reference in new issue
Block a user