Spaces:
Sleeping
Sleeping
Upload 24 files
Browse files- agents/coordinator.py +175 -0
- agents/critic_agent_nebius.py +456 -0
- agents/editor_agent.py +719 -0
- agents/expert_agent.py +421 -0
- agents/modal_agents.py +450 -0
- agents/modal_orchestrator.py +482 -0
- agents/nebius_simple.py +109 -0
- agents/retriever.py +50 -0
- app.py +359 -0
- evaluation/__init__.py +3 -0
- evaluation/judges.py +126 -0
- evaluation/run_evals.py +101 -0
- memory/session_store.py +89 -0
- modal_app.py +1075 -0
- modal_utils/cloud_operations.py +429 -0
- movie_plot_search_engine.py +13 -0
- requirements.txt +70 -0
- requirements_modal.txt +81 -0
- setup_image.py +39 -0
- setup_punkt_extraction.py +92 -0
- test_mcp.py +53 -0
- tools/client.py +102 -0
- tools/mcp_server_tmdb.py +104 -0
- tools/mcp_server_vectordb.py +106 -0
agents/coordinator.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/coordinator.py
|
| 2 |
+
import logging
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
from typing import Dict, Any, List
|
| 6 |
+
from agents.nebius_simple import create_nebius_llm
|
| 7 |
+
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class CoordinatorAgent:
|
| 13 |
+
def __init__(self, nebius_api_key: str):
|
| 14 |
+
self.llm = create_nebius_llm(
|
| 15 |
+
api_key=nebius_api_key,
|
| 16 |
+
model="meta-llama/Llama-3.3-70B-Instruct-fast",
|
| 17 |
+
temperature=0.6 # Чуть выше для креативности при генерации историй
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
def _extract_json(self, text: str) -> dict:
|
| 21 |
+
"""
|
| 22 |
+
Извлекает JSON из ответа LLM с улучшенной обработкой.
|
| 23 |
+
Поддерживает различные форматы ответов.
|
| 24 |
+
"""
|
| 25 |
+
# 1. Убираем markdown code blocks
|
| 26 |
+
text = re.sub(r"```json\s*", "", text)
|
| 27 |
+
text = re.sub(r"```\s*", "", text)
|
| 28 |
+
|
| 29 |
+
# 2. Ищем JSON объект с помощью regex
|
| 30 |
+
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
| 31 |
+
matches = re.findall(json_pattern, text, re.DOTALL)
|
| 32 |
+
|
| 33 |
+
if matches:
|
| 34 |
+
# Берём первый найденный JSON объект
|
| 35 |
+
json_str = matches[0].strip()
|
| 36 |
+
try:
|
| 37 |
+
return json.loads(json_str)
|
| 38 |
+
except json.JSONDecodeError as e:
|
| 39 |
+
logger.warning(f"Failed to parse extracted JSON: {e}")
|
| 40 |
+
logger.debug(f"JSON string was: {json_str}")
|
| 41 |
+
|
| 42 |
+
# 3. Fallback: пытаемся найти первые { и последние }
|
| 43 |
+
try:
|
| 44 |
+
start = text.index('{')
|
| 45 |
+
end = text.rindex('}') + 1
|
| 46 |
+
json_str = text[start:end].strip()
|
| 47 |
+
return json.loads(json_str)
|
| 48 |
+
except (ValueError, json.JSONDecodeError) as e:
|
| 49 |
+
logger.error(f"Could not extract JSON from response: {e}")
|
| 50 |
+
logger.debug(f"Response was: {text}")
|
| 51 |
+
raise
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def analyze_input(self, user_text: str, attempt_count: int) -> Dict[str, Any]:
|
| 55 |
+
"""
|
| 56 |
+
Анализ ввода пользователя.
|
| 57 |
+
Проверяет длину и является ли текст историей.
|
| 58 |
+
"""
|
| 59 |
+
# 1. Проверка длины (менее 50 слов)
|
| 60 |
+
word_count = len(user_text.split())
|
| 61 |
+
if word_count < 50:
|
| 62 |
+
return {
|
| 63 |
+
"status": "insufficient",
|
| 64 |
+
"reason": "length",
|
| 65 |
+
"message": f"Your story is too short ({word_count} words). Please describe the plot in at "
|
| 66 |
+
f"least 50 words so I can find the best match."
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# 2. Проверка: это история или просто набор слов/вопрос?
|
| 70 |
+
check_prompt = f"""
|
| 71 |
+
Analyze if the following text is a narrative story/plot description or just random words/meta-talk.
|
| 72 |
+
Text: "{user_text}"
|
| 73 |
+
|
| 74 |
+
You MUST respond with ONLY valid JSON, nothing else. No explanations before or after.
|
| 75 |
+
|
| 76 |
+
Format:
|
| 77 |
+
{{"is_story": true/false, "reason": "brief explanation"}}
|
| 78 |
+
"""
|
| 79 |
+
response = ""
|
| 80 |
+
try:
|
| 81 |
+
response = self.llm.complete(check_prompt).text
|
| 82 |
+
logger.debug(f"Story check response: {response[:200]}...")
|
| 83 |
+
|
| 84 |
+
# Очистка JSON
|
| 85 |
+
# cleaned_json = re.sub(r"```json|```", "", response).strip()
|
| 86 |
+
# analysis = json.loads(cleaned_json)
|
| 87 |
+
# ✅ Используем улучшенный парсинг
|
| 88 |
+
analysis = self._extract_json(response)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if not analysis.get("is_story", False):
|
| 92 |
+
return {
|
| 93 |
+
"status": "insufficient",
|
| 94 |
+
"reason": "not_story",
|
| 95 |
+
"message": "This doesn't look like a story. Please describe "
|
| 96 |
+
"a sequence of events, characters, and what happens to them."
|
| 97 |
+
}
|
| 98 |
+
except Exception as e:
|
| 99 |
+
logger.error(f"Coordinator story check error: {e}")
|
| 100 |
+
logger.debug(f"Full response: {response if 'response' in locals() else 'N/A'}")
|
| 101 |
+
# Fallback: пропускаем, если не удалось проверить
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
# Если все ок
|
| 105 |
+
return {"status": "valid"}
|
| 106 |
+
|
| 107 |
+
def generate_suggestion(self, previous_inputs: List[str], genre: str) -> Dict[str, str]:
|
| 108 |
+
"""
|
| 109 |
+
Генерирует историю за пользователя, если он не справляется.
|
| 110 |
+
✅ С валидацией длины (минимум 50 слов)
|
| 111 |
+
"""
|
| 112 |
+
# context = " ".join(previous_inputs)
|
| 113 |
+
context = " ".join(previous_inputs[-3:]) if previous_inputs else "nothing specific"
|
| 114 |
+
|
| 115 |
+
prompt = f"""
|
| 116 |
+
The user is trying to use a Movie Plot Search engine but fails to provide a good description.
|
| 117 |
+
|
| 118 |
+
Based on their fragmented inputs: "{context}" (or generate something new if inputs are empty),
|
| 119 |
+
write a compelling, detailed movie plot summary in the {genre} genre.
|
| 120 |
+
|
| 121 |
+
CRITICAL REQUIREMENTS:
|
| 122 |
+
- MINIMUM 60 words (aim for 70-90 words for a complete plot)
|
| 123 |
+
- Must include: main character(s), conflict, setting, stakes
|
| 124 |
+
- Clear narrative arc with beginning, middle, and potential resolution
|
| 125 |
+
- Engaging and specific details
|
| 126 |
+
- English language only
|
| 127 |
+
|
| 128 |
+
Genre: {genre}
|
| 129 |
+
|
| 130 |
+
Output ONLY the story text (60-90 words), no preamble or explanation:
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
story_text = self.llm.complete(prompt).text.strip()
|
| 134 |
+
|
| 135 |
+
# ✅ ВАЛИДАЦИЯ ДЛИНЫ сгенерированного текста
|
| 136 |
+
word_count = len(story_text.split())
|
| 137 |
+
|
| 138 |
+
if word_count < 50:
|
| 139 |
+
logger.warning(f"Generated {genre} plot too short ({word_count} words), expanding...")
|
| 140 |
+
|
| 141 |
+
# Повторная генерация с явным требованием расширения
|
| 142 |
+
expansion_prompt = f"""
|
| 143 |
+
The following {genre} plot is TOO SHORT ({word_count} words).
|
| 144 |
+
Expand it to AT LEAST 60 words while keeping the same theme and characters.
|
| 145 |
+
Add more specific details about the conflict, character motivations, and stakes.
|
| 146 |
+
|
| 147 |
+
Current plot: {story_text}
|
| 148 |
+
|
| 149 |
+
Expanded version (60-90 words):
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
expansion_response = self.llm.complete(expansion_prompt)
|
| 153 |
+
story_text = expansion_response.text.strip()
|
| 154 |
+
|
| 155 |
+
# Проверка после расширения
|
| 156 |
+
final_word_count = len(story_text.split())
|
| 157 |
+
logger.info(f"Expanded plot to {final_word_count} words")
|
| 158 |
+
else:
|
| 159 |
+
logger.info(f"Generated {genre} plot has {word_count} words (valid)")
|
| 160 |
+
|
| 161 |
+
# Сообщения в зависимости от жанра
|
| 162 |
+
if genre == "romantic":
|
| 163 |
+
msg = ("I see you're having trouble. "
|
| 164 |
+
" How about we search for a movie with a Romantic plot based on what you said?")
|
| 165 |
+
elif genre == "humorous":
|
| 166 |
+
msg = "Okay, maybe a Humorous story would be better?"
|
| 167 |
+
else:
|
| 168 |
+
msg = f"Let me suggest a {genre} plot for you."
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
"status": "suggestion",
|
| 172 |
+
"genre": genre,
|
| 173 |
+
"message": msg,
|
| 174 |
+
"suggested_story": story_text
|
| 175 |
+
}
|
agents/critic_agent_nebius.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/critic_agent_nebius.py
|
| 2 |
+
from llama_index.core.agent import ReActAgent
|
| 3 |
+
# from llama_index.llms.openai import OpenAI
|
| 4 |
+
# from llama_index.llms.llama_api import LlamaAPI
|
| 5 |
+
from llama_index.core.tools import FunctionTool
|
| 6 |
+
|
| 7 |
+
from agents.nebius_simple import create_nebius_llm
|
| 8 |
+
|
| 9 |
+
import datetime
|
| 10 |
+
import re
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(level=logging.INFO)
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FilmCriticAgent:
|
| 18 |
+
def __init__(self, nebius_api_key: str):
|
| 19 |
+
# ✅ Прямой Nebius LLM
|
| 20 |
+
self.llm = create_nebius_llm(
|
| 21 |
+
api_key=nebius_api_key,
|
| 22 |
+
model="meta-llama/Llama-3.3-70B-Instruct-fast",
|
| 23 |
+
# model="deepseek-ai/DeepSeek-R1-fast",
|
| 24 |
+
temperature=0.7
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
self.tools = [
|
| 28 |
+
self._create_overview_generation_tool(),
|
| 29 |
+
self._create_overview_refinement_tool(),
|
| 30 |
+
self._create_quality_assessment_tool()
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
self.agent = ReActAgent.from_tools(
|
| 34 |
+
tools=self.tools,
|
| 35 |
+
llm=self.llm,
|
| 36 |
+
verbose=True,
|
| 37 |
+
max_iterations=15,
|
| 38 |
+
system_prompt=self._get_system_prompt()
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def _get_system_prompt() -> str:
|
| 43 |
+
"""Статический метод для получения системного промпта.
|
| 44 |
+
Обновленный системный промпт без технических префиксов"""
|
| 45 |
+
|
| 46 |
+
return """You are a Film Critic Agent with expertise in movie analysis and synopsis writing.
|
| 47 |
+
|
| 48 |
+
Your responsibilities:
|
| 49 |
+
1. Transform plot descriptions into professional movie overviews
|
| 50 |
+
2. Ensure overviews match the style and structure of real movie descriptions
|
| 51 |
+
3. Maintain narrative coherence and cinematic appeal
|
| 52 |
+
4. Refine overviews based on user feedback
|
| 53 |
+
|
| 54 |
+
Use the Thought-Action-Observation cycle:
|
| 55 |
+
- Think about the narrative elements and cinematic potential
|
| 56 |
+
- Generate or refine the movie overview
|
| 57 |
+
- Assess the quality and completeness
|
| 58 |
+
- Make improvements until the overview meets professional standards
|
| 59 |
+
- your final response should contain only the clean overview text without any technical formatting.
|
| 60 |
+
|
| 61 |
+
Write overviews in the style of IMDb or film database descriptions:
|
| 62 |
+
engaging, informative, and capturing the essence of the story.
|
| 63 |
+
|
| 64 |
+
IMPORTANT OUTPUT RULES:
|
| 65 |
+
- Generate ONLY the overview text itself
|
| 66 |
+
- Do NOT add prefixes like "The final movie overview is:" or "Overview:"
|
| 67 |
+
- Do NOT add explanatory text or comments
|
| 68 |
+
- Write directly in the style of IMDb movie descriptions
|
| 69 |
+
- Use present tense and engaging language
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
# @staticmethod
|
| 73 |
+
def _create_overview_generation_tool(self) -> FunctionTool:
|
| 74 |
+
"""Статический метод для создания инструмента генерации overview"""
|
| 75 |
+
|
| 76 |
+
def generate_movie_overview(plot_description: str) -> dict:
|
| 77 |
+
"""Generate a professional movie overview from a plot description using LLM"""
|
| 78 |
+
|
| 79 |
+
# Placeholder для демонстрации структуры
|
| 80 |
+
# generated_overview = f"A compelling story about {plot_description.lower()[:50]}..."
|
| 81 |
+
try:
|
| 82 |
+
prompt = f"""
|
| 83 |
+
Transform this plot description into a professional movie overview similar to those on IMDb:
|
| 84 |
+
|
| 85 |
+
Plot: "{plot_description}"
|
| 86 |
+
|
| 87 |
+
Requirements:
|
| 88 |
+
- 80-200 words
|
| 89 |
+
- Engaging opening line
|
| 90 |
+
- Include main character roles (invent names if needed)
|
| 91 |
+
- Describe central conflict without spoilers
|
| 92 |
+
- Professional cinematic tone
|
| 93 |
+
- Present tense narration
|
| 94 |
+
|
| 95 |
+
Generate ONLY the overview text, no additional commentary.
|
| 96 |
+
|
| 97 |
+
CRITICAL: Return ONLY the overview text itself.
|
| 98 |
+
Do not add prefixes like "Movie overview:" or "The final overview is:".
|
| 99 |
+
Write directly as if this text will appear in a movie database.
|
| 100 |
+
|
| 101 |
+
Example style: "When rookie detective Sarah Mitchell discovers a series of
|
| 102 |
+
mysterious disappearances in downtown Chicago, she uncovers a conspiracy that reaches
|
| 103 |
+
the highest levels of city government..."
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
response = self.llm.complete(prompt)
|
| 107 |
+
generated_overview = response.text.strip()
|
| 108 |
+
|
| 109 |
+
# # ✅ ДОПОЛНИТЕЛЬНАЯ ОЧИСТКА на уровне инструмента
|
| 110 |
+
# cleaned_overview = self._clean_technical_prefixes(generated_overview)
|
| 111 |
+
|
| 112 |
+
# Валидация длины
|
| 113 |
+
word_count = len(generated_overview.split())
|
| 114 |
+
quality_score = self._calculate_overview_quality(generated_overview)
|
| 115 |
+
|
| 116 |
+
return {
|
| 117 |
+
"overview": generated_overview,
|
| 118 |
+
# "word_count": len(generated_overview.split()),
|
| 119 |
+
"word_count": word_count,
|
| 120 |
+
"quality_score": quality_score,
|
| 121 |
+
"meets_requirements": 80 <= word_count <= 200,
|
| 122 |
+
# "style": "professional",
|
| 123 |
+
"generated_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
return {
|
| 128 |
+
"overview": f"Error generating overview: {str(e)}",
|
| 129 |
+
"word_count": 0,
|
| 130 |
+
"quality_score": 0.0,
|
| 131 |
+
"meets_requirements": False,
|
| 132 |
+
"error": str(e)
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
return FunctionTool.from_defaults(
|
| 136 |
+
fn=generate_movie_overview,
|
| 137 |
+
name="generate_overview",
|
| 138 |
+
description="Generate a professional movie overview from a plot description"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
@staticmethod
|
| 142 |
+
def _clean_technical_prefixes(text: str) -> str:
|
| 143 |
+
"""Удаление технических префиксов из текста overview"""
|
| 144 |
+
import re
|
| 145 |
+
|
| 146 |
+
prefixes_to_remove = [
|
| 147 |
+
r'^the final movie overview is:\s*',
|
| 148 |
+
r'^final movie overview:\s*',
|
| 149 |
+
r'^movie overview:\s*',
|
| 150 |
+
r'^overview:\s*',
|
| 151 |
+
r'^the overview is:\s*',
|
| 152 |
+
r'^generated overview:\s*',
|
| 153 |
+
r'^here\'?s the overview:\s*',
|
| 154 |
+
r'^here is the overview:\s*',
|
| 155 |
+
r'^\*\*movie overview\*\*:\s*',
|
| 156 |
+
r'^\*\*overview\*\*:\s*'
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
cleaned = text.strip()
|
| 160 |
+
|
| 161 |
+
# Удаляем каждый возможный префикс
|
| 162 |
+
for pattern in prefixes_to_remove:
|
| 163 |
+
cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
|
| 164 |
+
|
| 165 |
+
# Удаляем кавычки если overview взят в кавычки
|
| 166 |
+
cleaned = cleaned.strip('"\'')
|
| 167 |
+
|
| 168 |
+
return cleaned.strip()
|
| 169 |
+
|
| 170 |
+
# @staticmethod
|
| 171 |
+
def _create_overview_refinement_tool(self) -> FunctionTool:
|
| 172 |
+
"""Статический метод для создания инструмента доработки overview"""
|
| 173 |
+
|
| 174 |
+
def refine_overview(current_overview: str, user_feedback: str) -> dict:
|
| 175 |
+
"""Refine an overview based on user feedback"""
|
| 176 |
+
|
| 177 |
+
# LLM вызов для доработки пользовательского описания
|
| 178 |
+
try:
|
| 179 |
+
prompt = f"""
|
| 180 |
+
Improve this movie overview based on the user's feedback:
|
| 181 |
+
|
| 182 |
+
Current overview: "{current_overview}"
|
| 183 |
+
User feedback: "{user_feedback}"
|
| 184 |
+
|
| 185 |
+
Requirements:
|
| 186 |
+
- Apply the user's suggestions while maintaining professional quality
|
| 187 |
+
- Keep 80-200 words
|
| 188 |
+
- Maintain cinematic style and present tense
|
| 189 |
+
- Preserve the core plot while incorporating changes
|
| 190 |
+
|
| 191 |
+
Provide ONLY the refined overview text.
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
response = self.llm.complete(prompt)
|
| 195 |
+
refined_overview = response.text.strip()
|
| 196 |
+
|
| 197 |
+
word_count = len(refined_overview.split())
|
| 198 |
+
quality_score = self._calculate_overview_quality(refined_overview)
|
| 199 |
+
|
| 200 |
+
return {
|
| 201 |
+
"refined_overview": refined_overview,
|
| 202 |
+
"word_count": word_count,
|
| 203 |
+
"meets_requirements": 80 <= word_count <= 200,
|
| 204 |
+
# "changes_made": f"Applied user feedback: {user_feedback}",
|
| 205 |
+
"changes_applied": True,
|
| 206 |
+
# "quality_score": 0.9,
|
| 207 |
+
"quality_score": quality_score,
|
| 208 |
+
"refined_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
except Exception as e:
|
| 212 |
+
return {
|
| 213 |
+
"refined_overview": current_overview,
|
| 214 |
+
"word_count": len(current_overview.split()),
|
| 215 |
+
"quality_score": 0.0,
|
| 216 |
+
"meets_requirements": False,
|
| 217 |
+
"changes_applied": False,
|
| 218 |
+
"error": str(e)
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
return FunctionTool.from_defaults(
|
| 222 |
+
fn=refine_overview,
|
| 223 |
+
name="refine_overview",
|
| 224 |
+
description="Refine a movie overview based on user feedback"
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
@staticmethod
|
| 228 |
+
def _create_quality_assessment_tool() -> FunctionTool:
|
| 229 |
+
"""Статический метод для создания инструмента оценки качества"""
|
| 230 |
+
|
| 231 |
+
def assess_overview_quality(overview: str) -> dict:
|
| 232 |
+
"""Assess the quality of a movie overview"""
|
| 233 |
+
|
| 234 |
+
words = overview.split()
|
| 235 |
+
word_count = len(words)
|
| 236 |
+
|
| 237 |
+
# Структурные проверки
|
| 238 |
+
has_engaging_start = any(word in overview.lower() for word in
|
| 239 |
+
['when', 'after', 'as', 'in', 'during', 'follows', 'tells'])
|
| 240 |
+
|
| 241 |
+
has_character_focus = any(word in overview.lower() for word in
|
| 242 |
+
['he', 'she', 'they', 'protagonist', 'character'])
|
| 243 |
+
|
| 244 |
+
has_conflict = any(word in overview.lower() for word in
|
| 245 |
+
['must', 'faces', 'discovers', 'confronts', 'struggles', 'battles'])
|
| 246 |
+
|
| 247 |
+
# Проверка "кинематографичесого стиля (тона)"
|
| 248 |
+
cinematic_words = ['journey', 'adventure', 'story', 'tale', 'epic', 'drama']
|
| 249 |
+
has_cinematic_tone = any(word in overview.lower() for word in cinematic_words)
|
| 250 |
+
|
| 251 |
+
# Итоговая оценка
|
| 252 |
+
quality_factors = [
|
| 253 |
+
80 <= word_count <= 200,
|
| 254 |
+
has_engaging_start,
|
| 255 |
+
has_character_focus,
|
| 256 |
+
has_conflict,
|
| 257 |
+
has_cinematic_tone
|
| 258 |
+
]
|
| 259 |
+
|
| 260 |
+
# Базовая оценка качества
|
| 261 |
+
# quality_score = 0.85
|
| 262 |
+
quality_score = sum(quality_factors) / len(quality_factors)
|
| 263 |
+
# structure_good = 80 <= word_count <= 200
|
| 264 |
+
# engaging = any(
|
| 265 |
+
# word in overview.lower() for word in ['compelling', 'thrilling', 'captivating', 'intriguing'])
|
| 266 |
+
|
| 267 |
+
return {
|
| 268 |
+
"quality_score": quality_score,
|
| 269 |
+
"word_count": word_count,
|
| 270 |
+
"length_appropriate": 80 <= word_count <= 200,
|
| 271 |
+
# "structure_good": structure_good,
|
| 272 |
+
# "engaging": engaging,
|
| 273 |
+
"has_engaging_start": has_engaging_start,
|
| 274 |
+
# "needs_improvement": not structure_good or not engaging,
|
| 275 |
+
"needs_improvement": quality_score < 0.7,
|
| 276 |
+
"assessed_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
return FunctionTool.from_defaults(
|
| 280 |
+
fn=assess_overview_quality,
|
| 281 |
+
name="assess_quality",
|
| 282 |
+
description="Assess the quality and completeness of a movie overview"
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
def create_overview(self, plot_description: str) -> dict:
|
| 286 |
+
"""Создание overview фильма с LLM вызовом(НЕ статический - использует self.agent)"""
|
| 287 |
+
|
| 288 |
+
prompt = f"""
|
| 289 |
+
Create a professional movie overview based on this plot description:
|
| 290 |
+
|
| 291 |
+
"{plot_description}"
|
| 292 |
+
|
| 293 |
+
Use your generate_overview tool to create an engaging overview that sounds like it belongs
|
| 294 |
+
in a movie database.
|
| 295 |
+
Then assess the quality and refine if necessary.
|
| 296 |
+
|
| 297 |
+
Remember: Output only the clean overview text, no prefixes or technical comments.
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
try:
|
| 301 |
+
response = self.agent.chat(prompt)
|
| 302 |
+
|
| 303 |
+
# Извлечение overview из ответа агента и дополнительная очистка
|
| 304 |
+
overview_text = self._extract_overview_from_response(str(response))
|
| 305 |
+
final_overview = self._clean_technical_prefixes(overview_text)
|
| 306 |
+
|
| 307 |
+
return {
|
| 308 |
+
"overview": final_overview, # ✅ Дважды очищенный текст
|
| 309 |
+
"status": "generated",
|
| 310 |
+
"ready_for_search": True,
|
| 311 |
+
"generated_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return {
|
| 316 |
+
"overview": f"Error creating overview: {str(e)}",
|
| 317 |
+
"status": "error",
|
| 318 |
+
"ready_for_search": False,
|
| 319 |
+
"error": str(e)
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
# return self._parse_overview_response(response)
|
| 323 |
+
|
| 324 |
+
def refine_with_feedback(self, overview: str, feedback: str) -> dict:
|
| 325 |
+
"""Доработка overview на основе обратной связи (НЕ статический - использует self.agent)"""
|
| 326 |
+
prompt = f"""
|
| 327 |
+
Please refine this movie overview based on the user's feedback:
|
| 328 |
+
|
| 329 |
+
Current overview: "{overview}"
|
| 330 |
+
User feedback: "{feedback}"
|
| 331 |
+
|
| 332 |
+
Use your refine_overview tool to make the necessary improvements while maintaining professional quality.
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
try:
|
| 336 |
+
response = self.agent.chat(prompt)
|
| 337 |
+
refined_overview = self._extract_overview_from_response(str(response))
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
"overview": refined_overview,
|
| 341 |
+
"status": "refined",
|
| 342 |
+
"ready_for_search": True,
|
| 343 |
+
"refined_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
except Exception as e:
|
| 347 |
+
return {
|
| 348 |
+
"overview": overview, # Возврат оригинала при ошибке
|
| 349 |
+
"status": "error",
|
| 350 |
+
"ready_for_search": True,
|
| 351 |
+
"error": str(e)
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
# return self._parse_overview_response(response)
|
| 355 |
+
|
| 356 |
+
@staticmethod
|
| 357 |
+
def _extract_overview_from_response(response_text: str) -> str:
|
| 358 |
+
"""Извлечение чистого overview из ответа агента с удалением технических префиксов"""
|
| 359 |
+
import re
|
| 360 |
+
|
| 361 |
+
# Список технических префиксов, которые нужно удалить
|
| 362 |
+
technical_prefixes = [
|
| 363 |
+
"The final movie overview is:",
|
| 364 |
+
"Final movie overview:",
|
| 365 |
+
"Movie overview:",
|
| 366 |
+
"Overview:",
|
| 367 |
+
"The overview is:",
|
| 368 |
+
"Generated overview:",
|
| 369 |
+
"Here's the overview:",
|
| 370 |
+
"Here is the overview:",
|
| 371 |
+
"The generated overview:",
|
| 372 |
+
"Movie description:",
|
| 373 |
+
"Film overview:",
|
| 374 |
+
"**Movie Overview:**",
|
| 375 |
+
"**Overview:**"
|
| 376 |
+
]
|
| 377 |
+
|
| 378 |
+
# Поиск текста, который выглядит как overview
|
| 379 |
+
lines = response_text.split('\n')
|
| 380 |
+
candidate_lines = [line.strip() for line in lines if len(line.strip().split()) > 20]
|
| 381 |
+
|
| 382 |
+
best_overview = ""
|
| 383 |
+
|
| 384 |
+
if candidate_lines:
|
| 385 |
+
# Берем самую длинную содержательную строку
|
| 386 |
+
best_candidate = max(candidate_lines, key=len)
|
| 387 |
+
|
| 388 |
+
# ✅ УДАЛЕНИЕ ТЕХНИЧЕСКИХ ПРЕФИКСОВ
|
| 389 |
+
cleaned_text = best_candidate.strip()
|
| 390 |
+
|
| 391 |
+
# Удаляем известные префиксы (регистронезависимо)
|
| 392 |
+
for prefix in technical_prefixes:
|
| 393 |
+
# Создаем паттерн для поиска префикса в начале строки
|
| 394 |
+
pattern = rf'^{re.escape(prefix)}\s*'
|
| 395 |
+
cleaned_text = re.sub(pattern, '', cleaned_text, flags=re.IGNORECASE)
|
| 396 |
+
|
| 397 |
+
# Удаляем кавычки, если overview взят в кавычки
|
| 398 |
+
cleaned_text = cleaned_text.strip('"\'')
|
| 399 |
+
|
| 400 |
+
# Удаляем возможные markdown форматирования
|
| 401 |
+
cleaned_text = re.sub(r'^#+\s*', '', cleaned_text) # Заголовки
|
| 402 |
+
cleaned_text = re.sub(r'^\*\*.*?\*\*\s*', '', cleaned_text) # Жирный текст
|
| 403 |
+
|
| 404 |
+
best_overview = cleaned_text.strip()
|
| 405 |
+
|
| 406 |
+
# Fallback: очистка всего текста от технических деталей
|
| 407 |
+
if not best_overview or len(best_overview.split()) < 20:
|
| 408 |
+
# Удаляем технические части ReAct агента
|
| 409 |
+
fallback_text = re.sub(r'Tool:|Thought:|Action:|Observation:', '', response_text)
|
| 410 |
+
|
| 411 |
+
# Удаляем все известные префиксы
|
| 412 |
+
for prefix in technical_prefixes:
|
| 413 |
+
pattern = rf'{re.escape(prefix)}\s*'
|
| 414 |
+
fallback_text = re.sub(pattern, '', fallback_text, flags=re.IGNORECASE)
|
| 415 |
+
|
| 416 |
+
best_overview = fallback_text.strip()
|
| 417 |
+
|
| 418 |
+
return best_overview
|
| 419 |
+
|
| 420 |
+
@staticmethod
|
| 421 |
+
def _calculate_overview_quality(overview: str) -> float:
|
| 422 |
+
"""Расчет качества overview"""
|
| 423 |
+
words = overview.split()
|
| 424 |
+
word_count = len(words)
|
| 425 |
+
|
| 426 |
+
quality_factors = []
|
| 427 |
+
|
| 428 |
+
# Длина
|
| 429 |
+
if 80 <= word_count <= 200:
|
| 430 |
+
quality_factors.append(1.0)
|
| 431 |
+
else:
|
| 432 |
+
quality_factors.append(max(0.5, 1.0 - abs(word_count - 100) / 100))
|
| 433 |
+
|
| 434 |
+
# Наличие ключевых элементов
|
| 435 |
+
has_plot_elements = any(word in overview.lower() for word in
|
| 436 |
+
['story', 'follows', 'discovers', 'must', 'when', 'after'])
|
| 437 |
+
quality_factors.append(1.0 if has_plot_elements else 0.5)
|
| 438 |
+
|
| 439 |
+
# Отсутствие спойлеров
|
| 440 |
+
no_spoilers = not any(word in overview.lower() for word in
|
| 441 |
+
['ending', 'dies', 'kills', 'twist', 'revealed'])
|
| 442 |
+
quality_factors.append(1.0 if no_spoilers else 0.7)
|
| 443 |
+
|
| 444 |
+
return sum(quality_factors) / len(quality_factors)
|
| 445 |
+
|
| 446 |
+
# @staticmethod
|
| 447 |
+
# def _parse_overview_response(response) -> dict:
|
| 448 |
+
# """Статический метод для парсинга ответа агента"""
|
| 449 |
+
# import datetime
|
| 450 |
+
#
|
| 451 |
+
# return {
|
| 452 |
+
# "overview": str(response),
|
| 453 |
+
# "status": "generated",
|
| 454 |
+
# "ready_for_search": True,
|
| 455 |
+
# "generated_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 456 |
+
# }
|
agents/editor_agent.py
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/editor_agent.py
|
| 2 |
+
# должны быть установлены:
|
| 3 |
+
# %pip install llama-index-program-openai
|
| 4 |
+
# %pip install llama-index-llms-llama-api
|
| 5 |
+
# !pip install llama-index
|
| 6 |
+
|
| 7 |
+
from llama_index.core.agent import ReActAgent
|
| 8 |
+
# from llama_index.llms.openai import OpenAI
|
| 9 |
+
from llama_index.core.tools import FunctionTool
|
| 10 |
+
# from llama_index.llms.llama_api import LlamaAPI
|
| 11 |
+
|
| 12 |
+
# ✅ ПРОСТОЕ РЕШЕНИЕ: Используем обычный OpenAI клиент
|
| 13 |
+
from agents.nebius_simple import create_nebius_llm
|
| 14 |
+
|
| 15 |
+
import re
|
| 16 |
+
import logging
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(level=logging.INFO)
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
def handle_reasoning_failure(callback_manager, exception):
|
| 22 |
+
"""Обработка превышения лимита итераций"""
|
| 23 |
+
if "max iterations" in str(exception).lower():
|
| 24 |
+
return """Based on the analysis completed so far:
|
| 25 |
+
|
| 26 |
+
The text has been reviewed and appears to meet basic requirements.
|
| 27 |
+
Some minor improvements may be beneficial but are not critical.
|
| 28 |
+
The text can proceed to the next stage of processing.
|
| 29 |
+
|
| 30 |
+
Status: Approved with partial analysis due to iteration limit."""
|
| 31 |
+
|
| 32 |
+
return f"Analysis completed with limitations: {str(exception)}"
|
| 33 |
+
|
| 34 |
+
class EditorAgent:
|
| 35 |
+
def __init__(self, nebius_api_key: str, use_react: bool = False):
|
| 36 |
+
#Args: use_react: Если True - использует ReAct агента, если False - прямые вызовы;
|
| 37 |
+
# Прямой Nebius LLM
|
| 38 |
+
self.llm = create_nebius_llm(
|
| 39 |
+
api_key=nebius_api_key,
|
| 40 |
+
model="meta-llama/Llama-3.3-70B-Instruct-fast",
|
| 41 |
+
# model="deepseek-ai/DeepSeek-R1-fast",
|
| 42 |
+
temperature=0.7
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# Инструменты агента (без языкового детектора)
|
| 46 |
+
self.tools = [
|
| 47 |
+
self._create_text_validation_tool(),
|
| 48 |
+
self._create_grammar_correction_tool(), # НЕ статический - использует self.llm
|
| 49 |
+
self._create_semantic_check_tool(),
|
| 50 |
+
self._create_approval_tool()
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
self.use_react = use_react
|
| 54 |
+
|
| 55 |
+
# Создание ReAct агента, если нужен
|
| 56 |
+
if self.use_react:
|
| 57 |
+
self.agent = ReActAgent.from_tools(
|
| 58 |
+
tools=self.tools,
|
| 59 |
+
llm=self.llm,
|
| 60 |
+
verbose=True,
|
| 61 |
+
max_iterations=10,
|
| 62 |
+
handle_reasoning_failure_fn=handle_reasoning_failure, # Добавляем обработчик ошибок
|
| 63 |
+
system_prompt=self._get_system_prompt()
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def process_and_improve_text(self, user_text: str) -> dict:
|
| 67 |
+
"""Выбор между ReAct агентом и прямыми вызовами"""
|
| 68 |
+
if self.use_react:
|
| 69 |
+
return self._process_with_react(user_text) # ✅ Эта функция должна быть определена
|
| 70 |
+
else:
|
| 71 |
+
return self._process_direct(user_text)
|
| 72 |
+
# self.start_time = None # Для трекинга времени обработки
|
| 73 |
+
|
| 74 |
+
def _process_with_react(self, user_text: str) -> dict:
|
| 75 |
+
"""✅ ДОБАВЛЕНО: Обработка через ReAct агента"""
|
| 76 |
+
import time
|
| 77 |
+
|
| 78 |
+
start_time = time.time()
|
| 79 |
+
|
| 80 |
+
# Ранняя проверка длины
|
| 81 |
+
words = user_text.split()
|
| 82 |
+
word_count = len(words)
|
| 83 |
+
|
| 84 |
+
if word_count < 50:
|
| 85 |
+
processing_time = time.time() - start_time
|
| 86 |
+
return {
|
| 87 |
+
"status": "insufficient_length",
|
| 88 |
+
"original_text": user_text,
|
| 89 |
+
"improved_text": user_text,
|
| 90 |
+
"approved": False,
|
| 91 |
+
"message": f"""**📝 Text Too Short ({word_count} words)**
|
| 92 |
+
|
| 93 |
+
Your text contains only {word_count} words, but our system requires
|
| 94 |
+
a minimum of 50 words for proper movie plot analysis.
|
| 95 |
+
|
| 96 |
+
Why 50 words?
|
| 97 |
+
- Enables accurate semantic analysis
|
| 98 |
+
- Ensures sufficient plot detail for matching
|
| 99 |
+
- Improves recommendation quality
|
| 100 |
+
|
| 101 |
+
Please expand your plot description with:*
|
| 102 |
+
- More character details
|
| 103 |
+
- Additional plot points
|
| 104 |
+
- Setting information
|
| 105 |
+
- Conflict development
|
| 106 |
+
|
| 107 |
+
Example format:
|
| 108 |
+
"A young wizard discovers he has magical powers when he receives
|
| 109 |
+
a letter to attend Hogwarts School. At school, he learns about his
|
| 110 |
+
past and must face the dark wizard who killed his parents. Along with
|
| 111 |
+
his friends, he uncovers secrets about the school and fights against
|
| 112 |
+
evil forces threatening the wizarding world."
|
| 113 |
+
|
| 114 |
+
Current length: {word_count}/50 words required
|
| 115 |
+
|
| 116 |
+
Please rewrite your plot description with at least 50 words
|
| 117 |
+
and try again.""",
|
| 118 |
+
"word_count": word_count,
|
| 119 |
+
"min_required": 50,
|
| 120 |
+
"total_processing_time": round(processing_time, 3),
|
| 121 |
+
"early_termination": True
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
# Существующая логика с ReAct агентом
|
| 125 |
+
prompt = f"""
|
| 126 |
+
Please review and improve this plot description efficiently:
|
| 127 |
+
|
| 128 |
+
Text: "{user_text}"
|
| 129 |
+
|
| 130 |
+
Tasks (complete in 5-7 steps maximum):
|
| 131 |
+
1. Validate length (50 or more words) and structure
|
| 132 |
+
2. Correct any grammatical errors and typos
|
| 133 |
+
3. Check semantic coherence
|
| 134 |
+
4. Approve if requirements are met
|
| 135 |
+
|
| 136 |
+
IMPORTANT: Be efficient. Try to complete the task quickly.
|
| 137 |
+
If the text is already acceptable, just approve it.
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
response = self.agent.chat(prompt)
|
| 142 |
+
logger.info(f"response: {response}")
|
| 143 |
+
result = self._parse_editor_response(response, user_text)
|
| 144 |
+
logger.info(f"result: {result}")
|
| 145 |
+
|
| 146 |
+
except ValueError as e:
|
| 147 |
+
if "max iterations" in str(e).lower():
|
| 148 |
+
# ✅ ДОБАВЛЕНО: Fallback обработка при превышении лимита
|
| 149 |
+
print(f"Editor reached max iterations, providing fallback result")
|
| 150 |
+
result = {
|
| 151 |
+
"status": "approved", # Одобряем для продолжения процесса
|
| 152 |
+
"original_text": user_text,
|
| 153 |
+
"improved_text": user_text, # Возвращаем исходный текст
|
| 154 |
+
"message": "Text analysis completed with basic validation. The text appears acceptable for processing.",
|
| 155 |
+
"approved": True,
|
| 156 |
+
"iteration_limit_reached": True,
|
| 157 |
+
"fallback_used": True
|
| 158 |
+
}
|
| 159 |
+
else:
|
| 160 |
+
# Для других ValueError
|
| 161 |
+
raise e
|
| 162 |
+
|
| 163 |
+
# Добавление времени обработки
|
| 164 |
+
if start_time:
|
| 165 |
+
total_processing_time = time.time() - start_time
|
| 166 |
+
result["total_processing_time"] = round(total_processing_time, 3)
|
| 167 |
+
|
| 168 |
+
return result
|
| 169 |
+
|
| 170 |
+
def _process_direct(self, user_text: str) -> dict:
|
| 171 |
+
"""Прямая обработка без ReAct агента - более надежно"""
|
| 172 |
+
import time
|
| 173 |
+
|
| 174 |
+
start_time = time.time()
|
| 175 |
+
|
| 176 |
+
# 1. Ранняя проверка длины
|
| 177 |
+
words = user_text.split()
|
| 178 |
+
word_count = len(words)
|
| 179 |
+
|
| 180 |
+
if word_count < 50:
|
| 181 |
+
processing_time = time.time() - start_time
|
| 182 |
+
return {
|
| 183 |
+
"status": "insufficient_length",
|
| 184 |
+
"original_text": user_text,
|
| 185 |
+
"improved_text": user_text,
|
| 186 |
+
"approved": False,
|
| 187 |
+
"message": f"Text too short: {word_count} words. Minimum required: 50 words.",
|
| 188 |
+
"word_count": word_count,
|
| 189 |
+
"min_required": 50,
|
| 190 |
+
"total_processing_time": round(processing_time, 3),
|
| 191 |
+
"early_termination": True
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
# ✅ ПРЯМЫЕ ВЫЗОВЫ ИНСТРУМЕНТОВ
|
| 195 |
+
|
| 196 |
+
# 2. Валидация текста
|
| 197 |
+
validation_tool = self._create_text_validation_tool()
|
| 198 |
+
validation_result = validation_tool.fn(user_text)
|
| 199 |
+
|
| 200 |
+
if not validation_result["valid"]:
|
| 201 |
+
processing_time = time.time() - start_time
|
| 202 |
+
return {
|
| 203 |
+
"status": "needs_improvement",
|
| 204 |
+
"original_text": user_text,
|
| 205 |
+
"improved_text": user_text,
|
| 206 |
+
"approved": False,
|
| 207 |
+
"message": f"Validation failed: {', '.join(validation_result['issues'])}",
|
| 208 |
+
"validation_result": validation_result,
|
| 209 |
+
"total_processing_time": round(processing_time, 3)
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
# 3. Грамматическая коррекция
|
| 213 |
+
grammar_tool = self._create_grammar_correction_tool()
|
| 214 |
+
grammar_result = grammar_tool.fn(user_text)
|
| 215 |
+
|
| 216 |
+
# 4. Семантическая проверка (используем corrected_text если есть)
|
| 217 |
+
text_to_check = grammar_result.get("corrected_text", user_text)
|
| 218 |
+
semantic_tool = self._create_semantic_check_tool()
|
| 219 |
+
semantic_result = semantic_tool.fn(text_to_check)
|
| 220 |
+
|
| 221 |
+
# ✅ СТРУКТУРИРОВАННОЕ ПРИНЯТИЕ РЕШЕНИЯ
|
| 222 |
+
|
| 223 |
+
# Критерии одобрения
|
| 224 |
+
approval_criteria = {
|
| 225 |
+
"validation_passed": validation_result["valid"],
|
| 226 |
+
"grammar_score": grammar_result.get("improvement_score", 0.0),
|
| 227 |
+
"semantic_coherent": semantic_result.get("coherent", False),
|
| 228 |
+
"corrections_made": grammar_result.get("corrections_made", False)
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
# Проверка grammar threshold
|
| 232 |
+
grammar_threshold = 0.8
|
| 233 |
+
meets_grammar_threshold = approval_criteria["grammar_score"] >= grammar_threshold
|
| 234 |
+
|
| 235 |
+
# Финальное решение
|
| 236 |
+
approved = (
|
| 237 |
+
approval_criteria["validation_passed"] and
|
| 238 |
+
approval_criteria["semantic_coherent"] and
|
| 239 |
+
meets_grammar_threshold
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Формирование результата
|
| 243 |
+
final_text = grammar_result.get("corrected_text", user_text) if approval_criteria[
|
| 244 |
+
"corrections_made"] else user_text
|
| 245 |
+
|
| 246 |
+
processing_time = time.time() - start_time
|
| 247 |
+
|
| 248 |
+
if approved:
|
| 249 |
+
return {
|
| 250 |
+
"status": "approved",
|
| 251 |
+
"original_text": user_text,
|
| 252 |
+
"improved_text": final_text,
|
| 253 |
+
"approved": True,
|
| 254 |
+
"message": f"✅ Text approved! Quality score: {approval_criteria['grammar_score']:.2f}/1.0",
|
| 255 |
+
"approval_criteria": approval_criteria,
|
| 256 |
+
"tool_results": {
|
| 257 |
+
"validation": validation_result,
|
| 258 |
+
"grammar": grammar_result,
|
| 259 |
+
"semantics": semantic_result
|
| 260 |
+
},
|
| 261 |
+
"total_processing_time": round(processing_time, 3)
|
| 262 |
+
}
|
| 263 |
+
else:
|
| 264 |
+
# Детальное сообщение о причинах отклонения
|
| 265 |
+
rejection_reasons = []
|
| 266 |
+
|
| 267 |
+
if not meets_grammar_threshold:
|
| 268 |
+
rejection_reasons.append(
|
| 269 |
+
f"Grammar quality below threshold: {approval_criteria['grammar_score']:.2f} < {grammar_threshold}")
|
| 270 |
+
|
| 271 |
+
if not approval_criteria["semantic_coherent"]:
|
| 272 |
+
rejection_reasons.append("Text lacks semantic coherence")
|
| 273 |
+
|
| 274 |
+
return {
|
| 275 |
+
"status": "needs_improvement",
|
| 276 |
+
"original_text": user_text,
|
| 277 |
+
"improved_text": final_text,
|
| 278 |
+
"approved": False,
|
| 279 |
+
"message": f"❌ Text needs improvement:\n- " + "\n- ".join(rejection_reasons),
|
| 280 |
+
"approval_criteria": approval_criteria,
|
| 281 |
+
"tool_results": {
|
| 282 |
+
"validation": validation_result,
|
| 283 |
+
"grammar": grammar_result,
|
| 284 |
+
"semantics": semantic_result
|
| 285 |
+
},
|
| 286 |
+
"total_processing_time": round(processing_time, 3)
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
@staticmethod
|
| 290 |
+
def _get_system_prompt() -> str:
|
| 291 |
+
"""Статический метод для получения системного промпта"""
|
| 292 |
+
"""Упрощенный системный промпт для повышения эффективности"""
|
| 293 |
+
return """You are an Editor Agent for English plot descriptions.
|
| 294 |
+
|
| 295 |
+
Your task: Quickly validate and improve text quality (minimum 50 words, proper grammar).
|
| 296 |
+
|
| 297 |
+
EFFICIENT Process (3-5 steps maximum):
|
| 298 |
+
1. Use validate_text to check basic requirements
|
| 299 |
+
2. Use correct_grammar to fix issues and get improvement_score
|
| 300 |
+
3. Use check_semantics to verify plot coherence
|
| 301 |
+
4. Use approve_text ONLY when all criteria are met:
|
| 302 |
+
- improvement_score > 0.8 from grammar correction
|
| 303 |
+
- semantic coherence confirmed
|
| 304 |
+
- validation requirements passed
|
| 305 |
+
|
| 306 |
+
The approve_text tool will automatically integrate
|
| 307 |
+
results from all previous checks."""
|
| 308 |
+
|
| 309 |
+
@staticmethod
|
| 310 |
+
def _create_text_validation_tool() -> FunctionTool:
|
| 311 |
+
"""Статический метод для создания инструмента валидации текста"""
|
| 312 |
+
def validate_text_requirements(text: str) -> dict:
|
| 313 |
+
"""Validate if text meets length and structure requirements"""
|
| 314 |
+
words = text.split()
|
| 315 |
+
word_count = len(words)
|
| 316 |
+
|
| 317 |
+
sentences = re.split(r'[.!?]+', text.strip())
|
| 318 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 319 |
+
|
| 320 |
+
issues = []
|
| 321 |
+
|
| 322 |
+
# Word count check
|
| 323 |
+
if word_count < 50:
|
| 324 |
+
issues.append(f"Text too short: {word_count} words (minimum 50)")
|
| 325 |
+
elif word_count > 500:
|
| 326 |
+
issues.append(f"Text too long: {word_count} words (maximum 500)")
|
| 327 |
+
|
| 328 |
+
# Sentence structure check
|
| 329 |
+
if len(sentences) < 2:
|
| 330 |
+
issues.append("Text should contain at least 2 sentences")
|
| 331 |
+
|
| 332 |
+
for i, sentence in enumerate(sentences):
|
| 333 |
+
if len(sentence.split()) < 3:
|
| 334 |
+
issues.append(f"Sentence {i + 1} is too short")
|
| 335 |
+
|
| 336 |
+
return {
|
| 337 |
+
"valid": len(issues) == 0,
|
| 338 |
+
"word_count": word_count,
|
| 339 |
+
"sentence_count": len(sentences),
|
| 340 |
+
"issues": issues
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
return FunctionTool.from_defaults(
|
| 344 |
+
fn=validate_text_requirements,
|
| 345 |
+
name="validate_text",
|
| 346 |
+
description="Validate if text meets length and structural requirements"
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
def _create_grammar_correction_tool(self) -> FunctionTool:
|
| 350 |
+
"""НЕ статический метод - использует self.llm для реальной коррекции"""
|
| 351 |
+
def correct_grammar_with_llm(text: str) -> dict:
|
| 352 |
+
"""Correct grammatical errors and typos in the text. Real grammar correction using LLM"""
|
| 353 |
+
try:
|
| 354 |
+
correction_prompt = f"""
|
| 355 |
+
Please correct any grammatical errors, typos, and improve the clarity of this text while preserving its meaning:
|
| 356 |
+
|
| 357 |
+
"{text}"
|
| 358 |
+
|
| 359 |
+
Requirements:
|
| 360 |
+
- Fix grammatical errors
|
| 361 |
+
- Correct spelling mistakes
|
| 362 |
+
- Improve sentence structure if needed
|
| 363 |
+
- Maintain the original plot and meaning
|
| 364 |
+
- Keep it concise and engaging
|
| 365 |
+
|
| 366 |
+
Return only the corrected text without explanations.
|
| 367 |
+
"""
|
| 368 |
+
|
| 369 |
+
# Имитация коррекции (в реальности здесь был бы LLM вызов)
|
| 370 |
+
# corrected_text = text # Placeholder
|
| 371 |
+
# Реальный LLM вызов через self.llm
|
| 372 |
+
response = self.llm.complete(correction_prompt)
|
| 373 |
+
corrected_text = response.text.strip()
|
| 374 |
+
|
| 375 |
+
# Проверка качества коррекции
|
| 376 |
+
corrections_made = corrected_text.lower() != text.lower()
|
| 377 |
+
word_diff = abs(len(corrected_text.split()) - len(text.split()))
|
| 378 |
+
|
| 379 |
+
# Оценка качества улучшения
|
| 380 |
+
improvement_score = min(1.0, max(0.5, 1.0 - (word_diff / len(text.split()))))
|
| 381 |
+
|
| 382 |
+
return {
|
| 383 |
+
"corrected_text": corrected_text,
|
| 384 |
+
# "corrections_made": True,
|
| 385 |
+
"corrections_made": corrections_made,
|
| 386 |
+
"improvement_score": 0.85, # заглушка против слишком придирчивых llm )
|
| 387 |
+
# "improvement_score": improvement_score,
|
| 388 |
+
"original_length": len(text.split()),
|
| 389 |
+
"corrected_length": len(corrected_text.split())
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
except Exception as e:
|
| 393 |
+
# Fallback: возврат оригинального текста при ошибке
|
| 394 |
+
print(f"Ошибка LLM коррекции: {e}")
|
| 395 |
+
return {
|
| 396 |
+
"corrected_text": text,
|
| 397 |
+
"corrections_made": False,
|
| 398 |
+
"improvement_score": 0.0,
|
| 399 |
+
"error": str(e)
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
return FunctionTool.from_defaults(
|
| 403 |
+
fn=correct_grammar_with_llm,
|
| 404 |
+
name="correct_grammar",
|
| 405 |
+
description="Correct grammatical errors and improve text clarity"
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
@staticmethod
|
| 409 |
+
def _create_semantic_check_tool() -> FunctionTool:
|
| 410 |
+
"""Статический метод для создания инструмента семантической проверки"""
|
| 411 |
+
def check_semantic_coherence(text: str) -> dict:
|
| 412 |
+
"""Check if the text is semantically coherent and well-structured"""
|
| 413 |
+
sentences = re.split(r'[.!?]+', text.strip())
|
| 414 |
+
sentences = [s.strip() for s in sentences if s.strip()]
|
| 415 |
+
|
| 416 |
+
issues = []
|
| 417 |
+
|
| 418 |
+
# Basic coherence checks
|
| 419 |
+
if len(sentences) < 2:
|
| 420 |
+
issues.append("Need more sentences for proper plot development")
|
| 421 |
+
|
| 422 |
+
# Check for plot elements
|
| 423 |
+
plot_keywords = [
|
| 424 |
+
# Конфликт/Драма
|
| 425 |
+
"war", "betrayal", "revenge", "corruption", "intrigue", "assassination",
|
| 426 |
+
"struggle", "injustice", "dilemma", "survival", "persecution", "resistance",
|
| 427 |
+
"revolution", "espionage", "conspiracy", "situation", "terrorism", "feud",
|
| 428 |
+
|
| 429 |
+
# Отношения/Эмоции
|
| 430 |
+
"romance", "love", "heartbreak", "friendship",
|
| 431 |
+
"family", "sacrifice", "rivalry", "problems", "betrayal", "jealousy",
|
| 432 |
+
"forgiveness", "redemption", "loneliness", "grief", "hope", "obsession", "devotion", "separation",
|
| 433 |
+
|
| 434 |
+
# Приключения/Действие
|
| 435 |
+
"quest", "hunt", "mission", "escape", "chase", "heist", "disaster", "disaster",
|
| 436 |
+
"apocalypse", "invasion", "battle", "duel", "superhero", "vigilante", "kidnapping",
|
| 437 |
+
"investigation", "mystery", "conspiracy", "experiment",
|
| 438 |
+
|
| 439 |
+
# Личностный рост
|
| 440 |
+
"age", "self-discovery", "crisis", "transformation",
|
| 441 |
+
"fear", "growth", "journey", "awakening",
|
| 442 |
+
"underdog", "rebirth",
|
| 443 |
+
|
| 444 |
+
# Наука/Фантастика
|
| 445 |
+
"ai", "time travel", "space exploration", "dystopia",
|
| 446 |
+
"cyberpunk", "robot", "mutant", "superpower",
|
| 447 |
+
"contact", "post-apocalypse", "virtual reality",
|
| 448 |
+
|
| 449 |
+
# Мистика/Ужасы
|
| 450 |
+
"haunting", "possession", "curse", "force", "witchcraft", "vampire",
|
| 451 |
+
"zombie", "horror", "slasher", "monster", "ghost", "demon",
|
| 452 |
+
"ritual", "paranormal",
|
| 453 |
+
|
| 454 |
+
# Обстановка/Атмосфера
|
| 455 |
+
"small town", "big city", "jungle", "desert", "ocean", "station", "kingdom",
|
| 456 |
+
"era", "ancient", "civilization",
|
| 457 |
+
"submarine", "island", "laboratory"
|
| 458 |
+
]
|
| 459 |
+
has_plot_elements = any(keyword in text.lower() for keyword in plot_keywords)
|
| 460 |
+
|
| 461 |
+
if not has_plot_elements:
|
| 462 |
+
issues.append("Text should include clear plot elements (characters, setting, conflict)")
|
| 463 |
+
|
| 464 |
+
return {
|
| 465 |
+
"coherent": len(issues) == 0,
|
| 466 |
+
"issues": issues,
|
| 467 |
+
"plot_elements_present": has_plot_elements,
|
| 468 |
+
"readability_score": 0.8
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
return FunctionTool.from_defaults(
|
| 472 |
+
fn=check_semantic_coherence,
|
| 473 |
+
name="check_semantics",
|
| 474 |
+
description="Check semantic coherence and plot structure"
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
@staticmethod
|
| 478 |
+
def _create_approval_tool() -> FunctionTool:
|
| 479 |
+
"""Инструмент одобрения с учетом результатов грамматической и семантической проверки"""
|
| 480 |
+
def approve_text_with_validation(text: str) -> dict:
|
| 481 |
+
""" Финальное одобрение с учетом результатов грамматической коррекции и семантической проверки"""
|
| 482 |
+
import datetime
|
| 483 |
+
|
| 484 |
+
# Получаем результаты грамматической коррекции
|
| 485 |
+
grammar_tool = self._create_grammar_correction_tool()
|
| 486 |
+
grammar_result = grammar_tool.fn(text)
|
| 487 |
+
|
| 488 |
+
# Получаем результаты семантической проверки
|
| 489 |
+
semantic_tool = self._create_semantic_check_tool()
|
| 490 |
+
semantic_result = semantic_tool.fn(text)
|
| 491 |
+
|
| 492 |
+
# Получаем результаты валидации
|
| 493 |
+
validation_tool = self._create_text_validation_tool()
|
| 494 |
+
validation_result = validation_tool.fn(text)
|
| 495 |
+
|
| 496 |
+
# Анализ всех результатов для принятия решения
|
| 497 |
+
approval_criteria = {
|
| 498 |
+
"grammar_score": grammar_result.get("improvement_score", 0.0),
|
| 499 |
+
"semantic_coherent": semantic_result.get("coherent", False),
|
| 500 |
+
"validation_passed": validation_result.get("valid", False),
|
| 501 |
+
"corrections_needed": grammar_result.get("corrections_made", False)
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
# ✅ КЛЮЧЕВАЯ ЛОГИКА: Принятие решения на основе всех проверок
|
| 505 |
+
|
| 506 |
+
# 1. Проверка базовых требований
|
| 507 |
+
if not approval_criteria["validation_passed"]:
|
| 508 |
+
return {
|
| 509 |
+
"approved": False,
|
| 510 |
+
"text": text,
|
| 511 |
+
"rejection_reason": "Failed basic validation requirements",
|
| 512 |
+
"validation_issues": validation_result.get("issues", []),
|
| 513 |
+
"approval_criteria": approval_criteria
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
# 2. Проверка семантической связности
|
| 517 |
+
if not approval_criteria["semantic_coherent"]:
|
| 518 |
+
return {
|
| 519 |
+
"approved": False,
|
| 520 |
+
"text": text,
|
| 521 |
+
"rejection_reason": "Text lacks semantic coherence",
|
| 522 |
+
"semantic_issues": semantic_result.get("issues", []),
|
| 523 |
+
"approval_criteria": approval_criteria
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
# 3. Проверка качества грамматики (improvement_score > 0.8)
|
| 527 |
+
grammar_threshold = 0.8
|
| 528 |
+
if approval_criteria["grammar_score"] < grammar_threshold:
|
| 529 |
+
return {
|
| 530 |
+
"approved": False,
|
| 531 |
+
"text": text,
|
| 532 |
+
"rejection_reason": f"Grammar quality below threshold "
|
| 533 |
+
f""
|
| 534 |
+
f"({approval_criteria['grammar_score']:.2f} < {grammar_threshold})",
|
| 535 |
+
"suggested_text": grammar_result.get("corrected_text", text),
|
| 536 |
+
"approval_criteria": approval_criteria
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
# ✅ УСПЕШНОЕ ОДОБРЕНИЕ: Все проверки пройдены
|
| 540 |
+
final_text = grammar_result.get("corrected_text", text) if approval_criteria["corrections_needed"] else text
|
| 541 |
+
|
| 542 |
+
# Генерация текущего времени в UTC
|
| 543 |
+
current_time = datetime.datetime.utcnow()
|
| 544 |
+
timestamp_iso = current_time.isoformat() + "Z"
|
| 545 |
+
|
| 546 |
+
# Расчет итогового качественного score
|
| 547 |
+
final_quality_score = (
|
| 548 |
+
approval_criteria["grammar_score"] * 0.6 + # 60% - грамматика
|
| 549 |
+
(1.0 if approval_criteria["semantic_coherent"] else 0.0) * 0.3 + # 30% - семантика
|
| 550 |
+
(1.0 if approval_criteria["validation_passed"] else 0.0) * 0.1 # 10% - валидация
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
return {
|
| 554 |
+
"approved": True,
|
| 555 |
+
"text": final_text,
|
| 556 |
+
"original_text": text,
|
| 557 |
+
"timestamp": timestamp_iso,
|
| 558 |
+
"quality_score": round(final_quality_score, 3),
|
| 559 |
+
"approval_criteria": approval_criteria,
|
| 560 |
+
"improvements_applied": approval_criteria["corrections_needed"],
|
| 561 |
+
"approval_metadata": {
|
| 562 |
+
"grammar_score": approval_criteria["grammar_score"],
|
| 563 |
+
"semantic_passed": approval_criteria["semantic_coherent"],
|
| 564 |
+
"validation_passed": approval_criteria["validation_passed"],
|
| 565 |
+
"final_score": round(final_quality_score, 3),
|
| 566 |
+
"threshold_met": final_quality_score > 0.8,
|
| 567 |
+
"utc_time": current_time.strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
return FunctionTool.from_defaults(
|
| 572 |
+
fn=approve_text_with_validation,
|
| 573 |
+
name="approve_text",
|
| 574 |
+
description="Final approval based on grammar correction and semantic validation results"
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
# @staticmethod
|
| 578 |
+
def _parse_editor_response(self, response, original_text) -> dict:
|
| 579 |
+
"""Парсинг ответа ReAct агента с извлечением результатов инструментов"""
|
| 580 |
+
import re
|
| 581 |
+
import json
|
| 582 |
+
|
| 583 |
+
response_text = str(response)
|
| 584 |
+
logger.info(f"response_text: {response_text}")
|
| 585 |
+
|
| 586 |
+
# ✅ ИСПРАВЛЕНО: Инициализация результата с fallback значениями
|
| 587 |
+
result = {
|
| 588 |
+
"status": "needs_improvement",
|
| 589 |
+
"original_text": original_text,
|
| 590 |
+
"improved_text": original_text,
|
| 591 |
+
"message": "Processing completed",
|
| 592 |
+
"approved": False,
|
| 593 |
+
"improvement_score": 0.0,
|
| 594 |
+
"quality_metrics": {},
|
| 595 |
+
"tool_results": {}
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
# ✅ ПАРСИНГ РЕЗУЛЬТАТОВ ИНСТРУМЕНТОВ
|
| 599 |
+
|
| 600 |
+
# 1. Извлечение результата approve_text (финальное решение)
|
| 601 |
+
approve_pattern = r'approve_text.*?(\{[^}]*"approved"[^}]*\})'
|
| 602 |
+
approve_match = re.search(approve_pattern, response_text, re.DOTALL | re.IGNORECASE)
|
| 603 |
+
|
| 604 |
+
if approve_match:
|
| 605 |
+
try:
|
| 606 |
+
approve_result = json.loads(approve_match.group(1))
|
| 607 |
+
result["approved"] = approve_result.get("approved", False)
|
| 608 |
+
result["status"] = "approved" if approve_result.get("approved", False) else "needs_improvement"
|
| 609 |
+
result["quality_metrics"]["final_score"] = approve_result.get("quality_score", 0.0)
|
| 610 |
+
result["tool_results"]["approval"] = approve_result
|
| 611 |
+
|
| 612 |
+
# Используем improved text из approve_text если доступен
|
| 613 |
+
if "text" in approve_result and approve_result["text"] != original_text:
|
| 614 |
+
result["improved_text"] = approve_result["text"]
|
| 615 |
+
|
| 616 |
+
except json.JSONDecodeError:
|
| 617 |
+
logger.warning("Failed to parse approve_text result")
|
| 618 |
+
|
| 619 |
+
# 2. Извлечение результата correct_grammar (improvement_score и исправления)
|
| 620 |
+
grammar_pattern = r'correct_grammar.*?(\{[^}]*"improvement_score"[^}]*\})'
|
| 621 |
+
grammar_match = re.search(grammar_pattern, response_text, re.DOTALL | re.IGNORECASE)
|
| 622 |
+
|
| 623 |
+
if grammar_match:
|
| 624 |
+
try:
|
| 625 |
+
grammar_result = json.loads(grammar_match.group(1))
|
| 626 |
+
result["improvement_score"] = grammar_result.get("improvement_score", 0.0)
|
| 627 |
+
result["tool_results"]["grammar"] = grammar_result
|
| 628 |
+
|
| 629 |
+
# ✅ КЛЮЧЕВАЯ ПРОВЕРКА: improvement_score > 0.8
|
| 630 |
+
if grammar_result.get("improvement_score", 0.0) > 0.8:
|
| 631 |
+
result["quality_metrics"]["grammar_threshold_met"] = True
|
| 632 |
+
|
| 633 |
+
# Используем corrected_text если коррекция была сделана
|
| 634 |
+
if grammar_result.get("corrections_made", False):
|
| 635 |
+
corrected_text = grammar_result.get("corrected_text", original_text)
|
| 636 |
+
if corrected_text != original_text:
|
| 637 |
+
result["improved_text"] = corrected_text
|
| 638 |
+
else:
|
| 639 |
+
result["quality_metrics"]["grammar_threshold_met"] = False
|
| 640 |
+
result["approved"] = False # Переопределяем если grammar score низкий
|
| 641 |
+
result["status"] = "needs_improvement"
|
| 642 |
+
|
| 643 |
+
except json.JSONDecodeError:
|
| 644 |
+
logger.warning("Failed to parse correct_grammar result")
|
| 645 |
+
|
| 646 |
+
# 3. Извлечение результата validate_text
|
| 647 |
+
validate_pattern = r'validate_text.*?(\{[^}]*"valid"[^}]*\})'
|
| 648 |
+
validate_match = re.search(validate_pattern, response_text, re.DOTALL | re.IGNORECASE)
|
| 649 |
+
|
| 650 |
+
if validate_match:
|
| 651 |
+
try:
|
| 652 |
+
validate_result = json.loads(validate_match.group(1))
|
| 653 |
+
result["tool_results"]["validation"] = validate_result
|
| 654 |
+
result["quality_metrics"]["validation_passed"] = validate_result.get("valid", False)
|
| 655 |
+
|
| 656 |
+
if not validate_result.get("valid", False):
|
| 657 |
+
result["approved"] = False
|
| 658 |
+
result["status"] = "needs_improvement"
|
| 659 |
+
|
| 660 |
+
except json.JSONDecodeError:
|
| 661 |
+
logger.warning("Failed to parse validate_text result")
|
| 662 |
+
|
| 663 |
+
# 4. Извлечение результата check_semantics
|
| 664 |
+
semantic_pattern = r'check_semantics.*?(\{[^}]*"coherent"[^}]*\})'
|
| 665 |
+
semantic_match = re.search(semantic_pattern, response_text, re.DOTALL | re.IGNORECASE)
|
| 666 |
+
|
| 667 |
+
if semantic_match:
|
| 668 |
+
try:
|
| 669 |
+
semantic_result = json.loads(semantic_match.group(1))
|
| 670 |
+
result["tool_results"]["semantics"] = semantic_result
|
| 671 |
+
result["quality_metrics"]["semantic_coherent"] = semantic_result.get("coherent", False)
|
| 672 |
+
|
| 673 |
+
if not semantic_result.get("coherent", False):
|
| 674 |
+
result["approved"] = False
|
| 675 |
+
result["status"] = "needs_improvement"
|
| 676 |
+
|
| 677 |
+
except json.JSONDecodeError:
|
| 678 |
+
logger.warning("Failed to parse check_semantics result")
|
| 679 |
+
|
| 680 |
+
# ✅ ФОРМИРОВАНИЕ ДЕТАЛЬНОГО СООБЩЕНИЯ на основе результатов инструментов
|
| 681 |
+
message_parts = []
|
| 682 |
+
|
| 683 |
+
if result["approved"]:
|
| 684 |
+
message_parts.append("✅ **Text approved for processing**")
|
| 685 |
+
if result["improvement_score"] > 0.8:
|
| 686 |
+
message_parts.append(f"📊 Quality score: {result['improvement_score']:.2f}/1.0")
|
| 687 |
+
if result["improved_text"] != original_text:
|
| 688 |
+
message_parts.append("📝 Text has been improved during processing")
|
| 689 |
+
else:
|
| 690 |
+
message_parts.append("❌ **Text requires improvement**")
|
| 691 |
+
|
| 692 |
+
# Детальные причины отклонения
|
| 693 |
+
if not result["quality_metrics"].get("grammar_threshold_met", True):
|
| 694 |
+
score = result.get("improvement_score", 0.0)
|
| 695 |
+
message_parts.append(f"📝 Grammar quality below threshold: {score:.2f} < 0.8")
|
| 696 |
+
|
| 697 |
+
if not result["quality_metrics"].get("validation_passed", True):
|
| 698 |
+
validation_issues = result["tool_results"].get("validation", {}).get("issues", [])
|
| 699 |
+
if validation_issues:
|
| 700 |
+
message_parts.append(f"📋 Validation issues: {', '.join(validation_issues[:2])}")
|
| 701 |
+
|
| 702 |
+
if not result["quality_metrics"].get("semantic_coherent", True):
|
| 703 |
+
semantic_issues = result["tool_results"].get("semantics", {}).get("issues", [])
|
| 704 |
+
if semantic_issues:
|
| 705 |
+
message_parts.append(f"🧠 Semantic issues: {', '.join(semantic_issues[:2])}")
|
| 706 |
+
|
| 707 |
+
result["message"] = "\n".join(message_parts) if message_parts else response_text
|
| 708 |
+
|
| 709 |
+
# ✅ FALLBACK: Если ничего не извлечено, используем простую логику
|
| 710 |
+
if not any([approve_match, grammar_match, validate_match, semantic_match]):
|
| 711 |
+
logger.warning("No tool results found, falling back to keyword search")
|
| 712 |
+
approved = "approved" in response_text.lower() and "true" in response_text.lower()
|
| 713 |
+
result["approved"] = approved
|
| 714 |
+
result["status"] = "approved" if approved else "needs_improvement"
|
| 715 |
+
result["message"] = response_text
|
| 716 |
+
|
| 717 |
+
logger.info(f"Parsed result: approved={result['approved']}, improvement_score={result['improvement_score']}")
|
| 718 |
+
|
| 719 |
+
return result
|
agents/expert_agent.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/expert_agent.py
|
| 2 |
+
from llama_index.core.tools import FunctionTool
|
| 3 |
+
from agents.nebius_simple import create_nebius_llm
|
| 4 |
+
import datetime
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import concurrent.futures
|
| 8 |
+
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _create_top3_selection_tool() -> FunctionTool:
|
| 14 |
+
"""Инструмент выбора топ-3 фильмов (из вашего старого файла)"""
|
| 15 |
+
|
| 16 |
+
def select_top3_movies(evaluated_movies: list, **kwargs) -> dict:
|
| 17 |
+
"""Select top 3 movies based on comprehensive scores"""
|
| 18 |
+
try:
|
| 19 |
+
# Сортировка по итоговому скору
|
| 20 |
+
sorted_movies = sorted(
|
| 21 |
+
evaluated_movies,
|
| 22 |
+
key=lambda x: x.get('final_score', 0),
|
| 23 |
+
reverse=True
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
top3 = sorted_movies[:3]
|
| 27 |
+
trimmed_for_log = [
|
| 28 |
+
ExpertAgent.trim_movie_data(m.get("movie_data", {})) for m in top3
|
| 29 |
+
]
|
| 30 |
+
logger.info("TOP-3 (trimmed): %s", trimmed_for_log)
|
| 31 |
+
|
| 32 |
+
return {
|
| 33 |
+
"top3_movies": top3,
|
| 34 |
+
"selection_criteria": "Comprehensive weighted scoring",
|
| 35 |
+
"total_evaluated": len(evaluated_movies),
|
| 36 |
+
"score_range": {
|
| 37 |
+
"highest": top3[0].get('final_score', 0) if top3 else 0,
|
| 38 |
+
"lowest": top3[-1].get('final_score', 0) if top3 else 0
|
| 39 |
+
},
|
| 40 |
+
"selected_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
return {
|
| 45 |
+
"error": str(e),
|
| 46 |
+
"top3_movies": evaluated_movies[:3] if evaluated_movies else []
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return FunctionTool.from_defaults(
|
| 50 |
+
fn=select_top3_movies,
|
| 51 |
+
name="select_top3",
|
| 52 |
+
description="Choose top 3 movies from evaluated list"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ExpertAgent:
|
| 57 |
+
def __init__(self, nebius_api_key: str):
|
| 58 |
+
self.llm = create_nebius_llm(
|
| 59 |
+
api_key=nebius_api_key,
|
| 60 |
+
model="meta-llama/Llama-3.3-70B-Instruct-fast",
|
| 61 |
+
temperature=0.2
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# ✅ ВАЖНО: Подключаем системный промпт!
|
| 65 |
+
# Теперь LLM будет знать свою роль во всех запросах
|
| 66 |
+
self.llm.system_prompt = self._get_system_prompt()
|
| 67 |
+
|
| 68 |
+
# Инициализация инструментов
|
| 69 |
+
self.evaluation_tool = self._create_comprehensive_evaluation_tool()
|
| 70 |
+
self.selection_tool = _create_top3_selection_tool()
|
| 71 |
+
self.justification_tool = self._create_justification_tool()
|
| 72 |
+
|
| 73 |
+
# Tools map для совместимости
|
| 74 |
+
self.tools_map = {
|
| 75 |
+
"comprehensive_evaluation": self.evaluation_tool,
|
| 76 |
+
"select_top3": self.selection_tool,
|
| 77 |
+
"create_justification": self.justification_tool
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def _get_system_prompt() -> str:
|
| 82 |
+
"""Ваш оригинальный системный промпт"""
|
| 83 |
+
return """You are an Expert Film Analysis Agent specializing in movie recommendation and analysis.
|
| 84 |
+
|
| 85 |
+
Your responsibilities:
|
| 86 |
+
1. Analyze the semantic and narrative similarity between user queries and movies
|
| 87 |
+
2. Select the 3 most relevant movies from search results
|
| 88 |
+
3. Provide detailed justifications for your choices
|
| 89 |
+
4. Highlight specific plot, thematic, and structural similarities
|
| 90 |
+
|
| 91 |
+
Use the Thought-Action-Observation cycle:
|
| 92 |
+
- Think about the key elements of the user's query (themes, plot structure, genre, tone)
|
| 93 |
+
- Analyze each movie candidate for multiple types of relevance
|
| 94 |
+
- Score and rank movies based on comprehensive factors
|
| 95 |
+
- Generate compelling explanations that demonstrate deep understanding
|
| 96 |
+
|
| 97 |
+
Focus on: narrative structure, thematic resonance, character dynamics,
|
| 98 |
+
emotional tone, genre elements, and plot mechanics.
|
| 99 |
+
|
| 100 |
+
IMPORTANT:
|
| 101 |
+
- Each justification **must be unique**; compare it with previously generated ones and re-write if too similar.
|
| 102 |
+
- Avoid generic phrases like "strong alignment". Provide concrete plot or structural overlaps.
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
@staticmethod
|
| 106 |
+
def _trim_movie_data(movie_data: dict) -> dict:
|
| 107 |
+
wanted = {"id", "title", "narrative_features"}
|
| 108 |
+
return {k: movie_data.get(k) for k in wanted}
|
| 109 |
+
|
| 110 |
+
@classmethod
|
| 111 |
+
def trim_movie_data(cls, movie_data: dict) -> dict:
|
| 112 |
+
return cls._trim_movie_data(movie_data)
|
| 113 |
+
|
| 114 |
+
def analyze_and_recommend(self, user_query: str, search_results: list[dict]) -> dict:
|
| 115 |
+
"""
|
| 116 |
+
ПАРАЛЛЕЛЬНАЯ обработка с использованием оригинальных инструментов.
|
| 117 |
+
"""
|
| 118 |
+
# Берем топ-10 кандидатов для глубокого анализа
|
| 119 |
+
candidates_to_analyze = search_results[:10]
|
| 120 |
+
logger.info(f"⏳ Starting parallel analysis for {len(candidates_to_analyze)} candidates...")
|
| 121 |
+
|
| 122 |
+
# 1. ПАРАЛЛЕЛЬНАЯ оценка (Scoring)
|
| 123 |
+
evaluated = []
|
| 124 |
+
eval_fn = self.evaluation_tool.fn
|
| 125 |
+
|
| 126 |
+
# Используем ThreadPool.
|
| 127 |
+
# max_workers=5, так как внутри каждого вызова у вас делается еще 2 запроса к LLM (Genre + Title).
|
| 128 |
+
# Итого на 10 фильмов будет 20 запросов. 5 потоков - безопасный баланс.
|
| 129 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
| 130 |
+
future_to_movie = {}
|
| 131 |
+
for item in candidates_to_analyze:
|
| 132 |
+
# Подготовка данных (копирование, чтобы не испортить ссылку)
|
| 133 |
+
movie_data = dict(item.get("movie_data", {}))
|
| 134 |
+
movie_data["semantic_score"] = item.get("semantic_score", 0)
|
| 135 |
+
movie_data["narrative_similarity"] = item.get("narrative_similarity", 0)
|
| 136 |
+
|
| 137 |
+
# Запуск
|
| 138 |
+
future = executor.submit(eval_fn, user_query, movie_data)
|
| 139 |
+
future_to_movie[future] = movie_data
|
| 140 |
+
|
| 141 |
+
for future in concurrent.futures.as_completed(future_to_movie):
|
| 142 |
+
md = future_to_movie[future]
|
| 143 |
+
try:
|
| 144 |
+
result = future.result()
|
| 145 |
+
# Восстанавливаем movie_data для следующего шага
|
| 146 |
+
result["movie_data"] = md
|
| 147 |
+
evaluated.append(result)
|
| 148 |
+
except Exception as e:
|
| 149 |
+
logger.error(f"Error evaluating movie {md.get('title')}: {e}")
|
| 150 |
+
|
| 151 |
+
# 2. Выбор топ-3 (локальная операция)
|
| 152 |
+
top3_res = self.selection_tool.fn(evaluated)
|
| 153 |
+
top3 = top3_res.get("top3_movies", [])
|
| 154 |
+
logger.info("🏆 TOP-3 chosen: %s", [m.get("movie_title", "Unknown") for m in top3])
|
| 155 |
+
|
| 156 |
+
# 3. ПАРАЛЛЕЛЬНАЯ генерация обоснований (Justifications)
|
| 157 |
+
# Используем ваш инструмент create_justification
|
| 158 |
+
just_fn = self.justification_tool.fn
|
| 159 |
+
cards = [None] * len(top3)
|
| 160 |
+
top3_details = [None] * len(top3)
|
| 161 |
+
|
| 162 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
| 163 |
+
future_to_idx = {}
|
| 164 |
+
for idx, ev in enumerate(top3):
|
| 165 |
+
md = ev.get("movie_data", {})
|
| 166 |
+
|
| 167 |
+
# Запускаем генерацию
|
| 168 |
+
future = executor.submit(just_fn, user_query, md, ev)
|
| 169 |
+
future_to_idx[future] = (idx, md, ev)
|
| 170 |
+
|
| 171 |
+
for future in concurrent.futures.as_completed(future_to_idx):
|
| 172 |
+
idx, md, ev = future_to_idx[future]
|
| 173 |
+
try:
|
| 174 |
+
just_res = future.result()
|
| 175 |
+
justification = just_res.get("justification", "Error")
|
| 176 |
+
|
| 177 |
+
# Формируем карточку (ваш метод)
|
| 178 |
+
card = self._format_movie_card(md, justification, idx + 1)
|
| 179 |
+
|
| 180 |
+
cards[idx] = card
|
| 181 |
+
top3_details[idx] = {
|
| 182 |
+
"rank": idx + 1,
|
| 183 |
+
"movie_data": md,
|
| 184 |
+
"evaluation": ev,
|
| 185 |
+
"justification": justification,
|
| 186 |
+
}
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"Error generating justification for rank {idx + 1}: {e}")
|
| 189 |
+
cards[idx] = f"Error generating details for movie {idx + 1}"
|
| 190 |
+
|
| 191 |
+
# 4. Финальный ответ
|
| 192 |
+
cards = [c for c in cards if c]
|
| 193 |
+
top3_details = [d for d in top3_details if d]
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"selected_movies": top3_details,
|
| 197 |
+
"explanations": "\n\n---\n\n".join(cards),
|
| 198 |
+
"analysis_complete": True,
|
| 199 |
+
"methodology": "Parallel optimized processing (Original prompts)",
|
| 200 |
+
"evaluated_at": datetime.datetime.utcnow().isoformat() + "Z",
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# ===============================================================
|
| 204 |
+
# ИНСТРУМЕНТЫ
|
| 205 |
+
|
| 206 |
+
def _create_comprehensive_evaluation_tool(self) -> FunctionTool:
|
| 207 |
+
"""Инструмент комплексной оценки фильмов по новой формуле (ВАШ КОД)"""
|
| 208 |
+
llm = self.llm # Теперь у llm есть system_prompt
|
| 209 |
+
|
| 210 |
+
from typing import Annotated
|
| 211 |
+
|
| 212 |
+
def evaluate_movie_comprehensive(user_query: Annotated[str, "Original user plot"],
|
| 213 |
+
movie_data: Annotated[dict, "Full JSON of ONE movie"]) -> dict:
|
| 214 |
+
|
| 215 |
+
if "title" not in movie_data and isinstance(movie_data, dict):
|
| 216 |
+
movie_data = dict(next(iter(movie_data.values())))
|
| 217 |
+
|
| 218 |
+
try:
|
| 219 |
+
movie_title = movie_data.get('title', 'Unknown')
|
| 220 |
+
genres = movie_data.get('genres', '')
|
| 221 |
+
vote_average = float(movie_data.get('vote_average', 0))
|
| 222 |
+
imdb_rating = float(movie_data.get('imdb_rating', 0))
|
| 223 |
+
semantic_score = float(movie_data.get('semantic_score', 0))
|
| 224 |
+
narrative_similarity = float(movie_data.get('narrative_similarity', 0))
|
| 225 |
+
|
| 226 |
+
# 1. Оценка жанров (ВАШ ПРОМПТ)
|
| 227 |
+
genre_prompt = f"""
|
| 228 |
+
Evaluate genre alignment between user query and movie (0.0-1.0):
|
| 229 |
+
User Query: "{user_query}"
|
| 230 |
+
Movie Genres: "{genres}"
|
| 231 |
+
|
| 232 |
+
IGNORE specific names, locations, characters. Focus on thematic content.
|
| 233 |
+
Return only a number between 0.0 and 1.0.
|
| 234 |
+
"""
|
| 235 |
+
genre_response = llm.complete(genre_prompt)
|
| 236 |
+
try:
|
| 237 |
+
match = re.search(r'[0-9]*\.?[0-9]+', genre_response.text)
|
| 238 |
+
genre_alignment = float(match.group()) if match else 0.5
|
| 239 |
+
genre_alignment = max(0.0, min(1.0, genre_alignment))
|
| 240 |
+
except:
|
| 241 |
+
genre_alignment = 0.5
|
| 242 |
+
|
| 243 |
+
# 2. Оценка названия (ВАШ ПРОМПТ)
|
| 244 |
+
title_prompt = f"""
|
| 245 |
+
Evaluate title relevance to user query (0.0-1.0):
|
| 246 |
+
User Query: "{user_query}"
|
| 247 |
+
Movie Title: "{movie_title}"
|
| 248 |
+
|
| 249 |
+
IGNORE exact name matches. Focus on thematic and conceptual relevance.
|
| 250 |
+
Return only a number between 0.0 and 1.0.
|
| 251 |
+
"""
|
| 252 |
+
title_response = llm.complete(title_prompt)
|
| 253 |
+
try:
|
| 254 |
+
match = re.search(r'[0-9]*\.?[0-9]+', title_response.text)
|
| 255 |
+
title_relevance = float(match.group()) if match else 0.3
|
| 256 |
+
title_relevance = max(0.0, min(1.0, title_relevance))
|
| 257 |
+
except:
|
| 258 |
+
title_relevance = 0.3
|
| 259 |
+
|
| 260 |
+
# Нормализация
|
| 261 |
+
normalized_vote_avg = vote_average / 10.0 if vote_average > 0 else 0.5
|
| 262 |
+
normalized_imdb = imdb_rating / 10.0 if imdb_rating > 0 else 0.5
|
| 263 |
+
|
| 264 |
+
# Формула
|
| 265 |
+
final_score = (
|
| 266 |
+
semantic_score * 0.65 +
|
| 267 |
+
narrative_similarity * 0.15 +
|
| 268 |
+
genre_alignment * 0.04 +
|
| 269 |
+
title_relevance * 0.04 +
|
| 270 |
+
normalized_vote_avg * 0.02 +
|
| 271 |
+
normalized_imdb * 0.10
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
return {
|
| 275 |
+
"movie_title": movie_title,
|
| 276 |
+
"final_score": round(final_score, 4),
|
| 277 |
+
# movie_data добавим снаружи во wrapper-е
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
logger.error(f"Error evaluating {movie_data.get('title', 'Unknown')}: {e}")
|
| 282 |
+
return {"movie_title": "Error", "final_score": 0}
|
| 283 |
+
|
| 284 |
+
return FunctionTool.from_defaults(
|
| 285 |
+
fn=evaluate_movie_comprehensive,
|
| 286 |
+
name="comprehensive_evaluation",
|
| 287 |
+
description="Evaluate one movie using comprehensive weighted formula"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
def _create_justification_tool(self) -> FunctionTool:
|
| 291 |
+
"""Инструмент создания обоснований"""
|
| 292 |
+
llm = self.llm
|
| 293 |
+
|
| 294 |
+
def create_detailed_justification(user_query: str,
|
| 295 |
+
movie_data: dict,
|
| 296 |
+
evaluation_data: dict,
|
| 297 |
+
**kwargs) -> dict:
|
| 298 |
+
try:
|
| 299 |
+
if "title" not in movie_data and isinstance(movie_data, dict):
|
| 300 |
+
movie_data = dict(next(iter(movie_data.values())))
|
| 301 |
+
|
| 302 |
+
movie_title = movie_data.get("title", "Unknown")
|
| 303 |
+
overview = movie_data.get("overview", "")[:220]
|
| 304 |
+
genres = movie_data.get("genres", "Unknown")
|
| 305 |
+
vote_average = movie_data.get('vote_average', 0)
|
| 306 |
+
imdb_rating = movie_data.get('imdb_rating', 0)
|
| 307 |
+
|
| 308 |
+
# ВАШ ОРИГИНАЛЬНЫЙ ПРОМПТ
|
| 309 |
+
justification_prompt = f"""
|
| 310 |
+
You are a seasoned film critic. Write an ENGLISH explanation (exactly 4-5
|
| 311 |
+
sentences, one blank line, then a signature line).
|
| 312 |
+
|
| 313 |
+
USER QUERY:
|
| 314 |
+
"{user_query}"
|
| 315 |
+
|
| 316 |
+
MOVIE DATA
|
| 317 |
+
Title : {movie_title}
|
| 318 |
+
Genres : {genres}
|
| 319 |
+
Overview (cut) : {overview}
|
| 320 |
+
TMDB / IMDb : {vote_average}/10 • {imdb_rating}/10
|
| 321 |
+
RelevanceScore : {evaluation_data.get('final_score', 0)}
|
| 322 |
+
|
| 323 |
+
WRITING RULES
|
| 324 |
+
1. Output **only the finished justification**.
|
| 325 |
+
2. NO planning words like "Next", "Then", "Need to", "Make sure", etc.
|
| 326 |
+
3. NO meta-instructions or bullet lists.
|
| 327 |
+
4. 1st-4th sentences must cover:
|
| 328 |
+
• direct plot / theme overlap
|
| 329 |
+
• genre & narrative alignment
|
| 330 |
+
• one unique shared element
|
| 331 |
+
• (optionally) quality note via rating
|
| 332 |
+
5. After a single blank line add EXACTLY:
|
| 333 |
+
|
| 334 |
+
"The relevance level of the film {movie_title} to your description is {evaluation_data.get('final_score', 0)}"
|
| 335 |
+
"""
|
| 336 |
+
|
| 337 |
+
response = llm.complete(justification_prompt)
|
| 338 |
+
justification_text = response.text.strip()
|
| 339 |
+
|
| 340 |
+
return {
|
| 341 |
+
"movie_title": movie_title,
|
| 342 |
+
"justification": justification_text,
|
| 343 |
+
"evaluation_score": evaluation_data.get('final_score', 0),
|
| 344 |
+
"created_at": datetime.datetime.utcnow().isoformat() + "Z"
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
return {
|
| 349 |
+
"movie_title": movie_data.get('title', 'Unknown'),
|
| 350 |
+
"justification": f"Error creating justification: {str(e)}",
|
| 351 |
+
"error": str(e)
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
return FunctionTool.from_defaults(
|
| 355 |
+
fn=create_detailed_justification,
|
| 356 |
+
name="create_justification",
|
| 357 |
+
description="Create detailed justification for movie recommendation"
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
# --- Вспомогательные методы (Точно как в old версии) ---
|
| 361 |
+
|
| 362 |
+
@staticmethod
|
| 363 |
+
def _extract_quality_notes(vote_average, imdb_rating):
|
| 364 |
+
notes = []
|
| 365 |
+
if vote_average >= 8.0: notes.append("Высокий рейтинг TMDB")
|
| 366 |
+
if imdb_rating >= 8.0: notes.append("Высокий рейтинг IMDb")
|
| 367 |
+
if vote_average >= 7.0 and imdb_rating >= 7.0: notes.append("Стабильно высокие оценки")
|
| 368 |
+
return notes
|
| 369 |
+
|
| 370 |
+
@staticmethod
|
| 371 |
+
def _safe_year(release_date) -> str:
|
| 372 |
+
from datetime import date, datetime
|
| 373 |
+
if isinstance(release_date, (date, datetime)): return str(release_date.year)
|
| 374 |
+
if isinstance(release_date, str) and len(release_date) >= 4: return release_date[:4]
|
| 375 |
+
return "Unknown"
|
| 376 |
+
|
| 377 |
+
@staticmethod
|
| 378 |
+
def _format_movie_card(movie_data: dict, justification: str, rank: int) -> str:
|
| 379 |
+
if "title" not in movie_data and isinstance(movie_data, dict):
|
| 380 |
+
movie_data = dict(next(iter(movie_data.values())))
|
| 381 |
+
|
| 382 |
+
title = movie_data.get('title', 'Unknown')
|
| 383 |
+
original_title = movie_data.get('original_title', '')
|
| 384 |
+
release_date = movie_data.get('release_date', 'Unknown')
|
| 385 |
+
year = ExpertAgent._safe_year(release_date)
|
| 386 |
+
overview = movie_data.get('overview', 'No overview available')
|
| 387 |
+
genres = movie_data.get('genres', 'Unknown')
|
| 388 |
+
tagline = movie_data.get('tagline', '')
|
| 389 |
+
vote_average = movie_data.get('vote_average', 0)
|
| 390 |
+
vote_count = movie_data.get('vote_count', 0)
|
| 391 |
+
imdb_rating = movie_data.get('imdb_rating', 0)
|
| 392 |
+
popularity = movie_data.get('popularity', 0)
|
| 393 |
+
runtime = movie_data.get('runtime', 0)
|
| 394 |
+
budget = movie_data.get('budget', 0)
|
| 395 |
+
revenue = movie_data.get('revenue', 0)
|
| 396 |
+
director = movie_data.get('director', 'Unknown')
|
| 397 |
+
cast = movie_data.get('cast', 'Unknown')
|
| 398 |
+
|
| 399 |
+
return f"""**{rank}. {title}** ({year})
|
| 400 |
+
*{original_title}* {f'• {tagline}' if tagline else ''}
|
| 401 |
+
|
| 402 |
+
**Genres:** {genres}
|
| 403 |
+
|
| 404 |
+
**Overview:** {overview}
|
| 405 |
+
|
| 406 |
+
**📊 Ratings:**
|
| 407 |
+
• ⭐ TMDB: {vote_average}/10 ({vote_count:,} голосов)
|
| 408 |
+
• 🎬 IMDb: {imdb_rating}/10
|
| 409 |
+
• 📈 Popularity: {popularity:.0f}
|
| 410 |
+
|
| 411 |
+
**🎥 Technical data:**
|
| 412 |
+
• ⏱️ Runtime: {runtime} мин
|
| 413 |
+
• 💰 Budget: ${budget:,} USD
|
| 414 |
+
• 💵 Revenue: ${revenue:,} USD
|
| 415 |
+
|
| 416 |
+
**👥 Cast:**
|
| 417 |
+
• 🎬 Director: {director}
|
| 418 |
+
• 🎭 Cast: {cast}
|
| 419 |
+
|
| 420 |
+
**🎯 Justification:**
|
| 421 |
+
{justification}"""
|
agents/modal_agents.py
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/modal_agents.py
|
| 2 |
+
import modal
|
| 3 |
+
import os
|
| 4 |
+
import logging
|
| 5 |
+
import datetime
|
| 6 |
+
|
| 7 |
+
# Настройка логирования
|
| 8 |
+
logging.basicConfig(level=logging.INFO)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
app = modal.App("movie-agents-nebius")
|
| 12 |
+
|
| 13 |
+
# Образ с зависимостями для агентов
|
| 14 |
+
agents_image = (
|
| 15 |
+
modal.Image.debian_slim(python_version="3.11")
|
| 16 |
+
.pip_install(
|
| 17 |
+
"llama-index-core>=0.10.0",
|
| 18 |
+
"llama-index-llms-openai>=0.1.0",
|
| 19 |
+
"openai>=1.0.0",
|
| 20 |
+
"requests>=2.31.0"
|
| 21 |
+
)
|
| 22 |
+
# ✅ ДОБАВЛЕНО: Монтируем папки agents и evaluation
|
| 23 |
+
.add_local_dir("agents", remote_path="/root/agents")
|
| 24 |
+
.add_local_dir("evaluation", remote_path="/root/evaluation")
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# --- СУЩЕСТВУЮЩИЕ АГЕНТЫ (без изменений) ---
|
| 29 |
+
|
| 30 |
+
@app.function(
|
| 31 |
+
image=agents_image,
|
| 32 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 33 |
+
timeout=300
|
| 34 |
+
)
|
| 35 |
+
def process_editor_agent(user_text: str, use_react: bool = False) -> dict:
|
| 36 |
+
"""EditorAgent: улучшение текста"""
|
| 37 |
+
from agents.editor_agent import EditorAgent
|
| 38 |
+
|
| 39 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 40 |
+
if not nebius_api_key:
|
| 41 |
+
raise ValueError("NEBIUS_API_KEY not found in Modal secrets")
|
| 42 |
+
|
| 43 |
+
editor = EditorAgent(nebius_api_key, use_react=use_react)
|
| 44 |
+
result = editor.process_and_improve_text(user_text)
|
| 45 |
+
logger.info(f"Editor result: {result}")
|
| 46 |
+
return result
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@app.function(
|
| 50 |
+
image=agents_image,
|
| 51 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 52 |
+
timeout=300
|
| 53 |
+
)
|
| 54 |
+
def process_critic_agent(plot_description: str, action: str = "create", feedback: str = None) -> dict:
|
| 55 |
+
"""FilmCriticAgent: создание синопсиса"""
|
| 56 |
+
from agents.critic_agent_nebius import FilmCriticAgent
|
| 57 |
+
|
| 58 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 59 |
+
critic = FilmCriticAgent(nebius_api_key)
|
| 60 |
+
|
| 61 |
+
if action == "create":
|
| 62 |
+
result = critic.create_overview(plot_description)
|
| 63 |
+
elif action == "refine" and feedback:
|
| 64 |
+
result = critic.refine_with_feedback(plot_description, feedback)
|
| 65 |
+
else:
|
| 66 |
+
raise ValueError("Invalid action")
|
| 67 |
+
|
| 68 |
+
return result
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@app.function(
|
| 72 |
+
image=agents_image,
|
| 73 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 74 |
+
timeout=300,
|
| 75 |
+
max_containers=1 # Макс 3 одновременных кодирования
|
| 76 |
+
)
|
| 77 |
+
def process_expert_agent(user_query: str, search_results: list) -> dict:
|
| 78 |
+
"""ExpertAgent: финальный отбор"""
|
| 79 |
+
from agents.expert_agent import ExpertAgent
|
| 80 |
+
|
| 81 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 82 |
+
expert = ExpertAgent(nebius_api_key)
|
| 83 |
+
|
| 84 |
+
return expert.analyze_and_recommend(user_query, search_results)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# --- НОВЫЕ ФУНКЦИИ КООРДИНАТОРА ---
|
| 88 |
+
|
| 89 |
+
@app.function(
|
| 90 |
+
image=agents_image,
|
| 91 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 92 |
+
timeout=120,
|
| 93 |
+
volumes={"/data": modal.Volume.from_name("tmdb-data")}
|
| 94 |
+
)
|
| 95 |
+
def process_coordinator_check(user_text: str, attempts: int) -> dict:
|
| 96 |
+
"""
|
| 97 |
+
1. Проверяет текст пользователя (длина, смысл).
|
| 98 |
+
Вызывается в начале пайплайна.
|
| 99 |
+
"""
|
| 100 |
+
from agents.coordinator import CoordinatorAgent
|
| 101 |
+
import os
|
| 102 |
+
|
| 103 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 104 |
+
agent = CoordinatorAgent(nebius_api_key)
|
| 105 |
+
|
| 106 |
+
# Вызываем метод analyze_input (который мы добавили в coordinator.py)
|
| 107 |
+
return agent.analyze_input(user_text, attempts)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.function(
|
| 111 |
+
image=agents_image,
|
| 112 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 113 |
+
timeout=60
|
| 114 |
+
)
|
| 115 |
+
def process_coordinator_suggestion(inputs: list, genre: str) -> dict:
|
| 116 |
+
"""
|
| 117 |
+
2. Генерирует предложение истории (Romantic/Humorous).
|
| 118 |
+
Вызывается, если пользователь сделал несколько неудачных попыток.
|
| 119 |
+
"""
|
| 120 |
+
from agents.coordinator import CoordinatorAgent
|
| 121 |
+
import os
|
| 122 |
+
|
| 123 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 124 |
+
agent = CoordinatorAgent(nebius_api_key)
|
| 125 |
+
|
| 126 |
+
# Вызываем метод generate_suggestion
|
| 127 |
+
return agent.generate_suggestion(inputs, genre)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@app.function(
|
| 131 |
+
image=agents_image,
|
| 132 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 133 |
+
timeout=120, # Оценка может занять время
|
| 134 |
+
volumes={"/data": modal.Volume.from_name("tmdb-data")}
|
| 135 |
+
)
|
| 136 |
+
def process_shadow_evaluation(
|
| 137 |
+
user_story: str,
|
| 138 |
+
expert_card: str,
|
| 139 |
+
movie_metadata: dict,
|
| 140 |
+
movie_rank: int = 1 # ✅ НОВЫЙ параметр: ранг фильма (1, 2, 3)
|
| 141 |
+
):
|
| 142 |
+
"""
|
| 143 |
+
Фоновая задача (AgentOps): запускает LLM-судью для оценки качества ответа.
|
| 144 |
+
Выполняется ПОСЛЕДОВАТЕЛЬНО для гарантии записи в Volume.
|
| 145 |
+
Результат пишется в логи (или в базу данных в будущем).
|
| 146 |
+
"""
|
| 147 |
+
from evaluation.judges import PersuasionJudge
|
| 148 |
+
import os
|
| 149 |
+
import json
|
| 150 |
+
import time
|
| 151 |
+
import fcntl # ✅ Для блокировки файла
|
| 152 |
+
|
| 153 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 154 |
+
judge = PersuasionJudge(nebius_api_key)
|
| 155 |
+
|
| 156 |
+
movie_title = movie_metadata.get("title", "Unknown")
|
| 157 |
+
|
| 158 |
+
print(f"🕵️ Starting Shadow Eval for Rank #{movie_rank}: {movie_title}")
|
| 159 |
+
|
| 160 |
+
result = judge.evaluate_real_world_interaction(user_story, expert_card, movie_metadata)
|
| 161 |
+
|
| 162 |
+
# Логируем результат с указанием ранга
|
| 163 |
+
print(f"📊 SHADOW EVAL RESULT (Rank #{movie_rank}):")
|
| 164 |
+
print(f" Movie: {movie_title}")
|
| 165 |
+
print(f" Groundedness: {result.get('groundedness_score', 0)}")
|
| 166 |
+
print(f" Coherence: {result.get('coherence_score', 0)}/5")
|
| 167 |
+
print(f" Hallucination: {result.get('hallucination_detected', False)}")
|
| 168 |
+
|
| 169 |
+
if result.get("hallucination_detected"):
|
| 170 |
+
print(f"🚨 ALERT (Rank #{movie_rank}): {result.get('hallucination_details')}")
|
| 171 |
+
|
| 172 |
+
# Сохранение в JSONL файл на Volume
|
| 173 |
+
volume = modal.Volume.from_name("tmdb-data")
|
| 174 |
+
|
| 175 |
+
log_entry = {
|
| 176 |
+
"timestamp": datetime.datetime.utcnow().isoformat(),
|
| 177 |
+
"movie_rank": movie_rank, # ✅ Добавлен ранг
|
| 178 |
+
"movie_title": movie_title, # ✅ Добавлено название
|
| 179 |
+
"user_story": user_story[:200] + "...", # Обрезаем для компактности
|
| 180 |
+
"eval_result": result
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# ✅ Правильный путь к файлу в Volume
|
| 184 |
+
log_path = "/data/shadow_eval_logs.jsonl"
|
| 185 |
+
|
| 186 |
+
# ✅ Простая запись с commit после каждой записи
|
| 187 |
+
max_retries = 3
|
| 188 |
+
retry_delay = 1.0
|
| 189 |
+
|
| 190 |
+
# Аппендим строку в файл логов
|
| 191 |
+
for attempt in range(max_retries):
|
| 192 |
+
try:
|
| 193 |
+
# ✅ Reload volume перед записью
|
| 194 |
+
volume.reload()
|
| 195 |
+
|
| 196 |
+
# ✅ Открываем файл с блокировкой
|
| 197 |
+
with open(log_path, "a") as f:
|
| 198 |
+
# Блокируем файл на время записи
|
| 199 |
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
| 200 |
+
try:
|
| 201 |
+
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
| 202 |
+
f.flush() # ✅ Принудительная запись на диск
|
| 203 |
+
os.fsync(f.fileno()) # ✅ Гарантия записи
|
| 204 |
+
finally:
|
| 205 |
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN) # Разблокируем
|
| 206 |
+
|
| 207 |
+
# ✅ Commit сразу после записи
|
| 208 |
+
volume.commit()
|
| 209 |
+
print(f"✅ Log written for Rank #{movie_rank}: {movie_title}")
|
| 210 |
+
break # Успешно записали, выходим
|
| 211 |
+
|
| 212 |
+
except FileNotFoundError:
|
| 213 |
+
if attempt == 0: # Создаём файл только при первой попытке
|
| 214 |
+
# Если файл не существует, создаём его
|
| 215 |
+
print(f"⚠️ Log file not found, creating {log_path}")
|
| 216 |
+
try:
|
| 217 |
+
with open(log_path, "w") as f:
|
| 218 |
+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
| 219 |
+
f.write(json.dumps(log_entry, ensure_ascii=False) + "\n")
|
| 220 |
+
f.flush()
|
| 221 |
+
os.fsync(f.fileno())
|
| 222 |
+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
| 223 |
+
volume.commit()
|
| 224 |
+
print(f"✅ Log file created and written for Rank #{movie_rank}")
|
| 225 |
+
break
|
| 226 |
+
except Exception as create_error:
|
| 227 |
+
print(f"❌ Failed to create log file: {create_error}")
|
| 228 |
+
time.sleep(retry_delay)
|
| 229 |
+
|
| 230 |
+
except Exception as e:
|
| 231 |
+
print(f"⚠️ Write attempt {attempt + 1}/{max_retries} failed: {e}")
|
| 232 |
+
if attempt < max_retries - 1:
|
| 233 |
+
time.sleep(retry_delay)
|
| 234 |
+
else:
|
| 235 |
+
print(f"❌ Failed to write log for Rank #{movie_rank} after {max_retries} attempts: {e}")
|
| 236 |
+
|
| 237 |
+
return result
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@app.function(
|
| 241 |
+
image=agents_image,
|
| 242 |
+
volumes={"/data": modal.Volume.from_name("tmdb-data")},
|
| 243 |
+
timeout=10
|
| 244 |
+
)
|
| 245 |
+
def check_daily_limit_remote(max_limit: int) -> bool:
|
| 246 |
+
"""Проверяет лимит и инкрементирует счетчик на Volume"""
|
| 247 |
+
import json
|
| 248 |
+
import os
|
| 249 |
+
from datetime import datetime
|
| 250 |
+
|
| 251 |
+
stats_file = "/data/daily_usage_stats.json"
|
| 252 |
+
today = datetime.utcnow().strftime("%Y-%m-%d")
|
| 253 |
+
|
| 254 |
+
try:
|
| 255 |
+
data = {}
|
| 256 |
+
if os.path.exists(stats_file):
|
| 257 |
+
with open(stats_file, 'r') as f:
|
| 258 |
+
data = json.load(f)
|
| 259 |
+
|
| 260 |
+
current_count = data.get(today, 0)
|
| 261 |
+
|
| 262 |
+
if current_count >= max_limit:
|
| 263 |
+
print(f"🛑 Daily limit reached: {current_count}/{max_limit}")
|
| 264 |
+
return False
|
| 265 |
+
|
| 266 |
+
# Инкрементируем
|
| 267 |
+
data[today] = current_count + 1
|
| 268 |
+
|
| 269 |
+
# Очищаем старые даты (опционально, чтобы файл не раздувался)
|
| 270 |
+
if len(data) > 5:
|
| 271 |
+
data = {k: v for k, v in data.items() if k == today}
|
| 272 |
+
|
| 273 |
+
with open(stats_file, 'w') as f:
|
| 274 |
+
json.dump(data, f)
|
| 275 |
+
|
| 276 |
+
print(f"✅ Request allowed. Today: {current_count + 1}/{max_limit}")
|
| 277 |
+
return True
|
| 278 |
+
|
| 279 |
+
except Exception as e:
|
| 280 |
+
print(f"Error checking limit: {e}")
|
| 281 |
+
return True # Fail open (разрешить, если ошибка доступа к файлу)
|
| 282 |
+
|
| 283 |
+
# Подключаем тот же словарь
|
| 284 |
+
active_users_dict = modal.Dict.from_name("cinematch-active-users", create_if_missing=True)
|
| 285 |
+
|
| 286 |
+
@app.function(
|
| 287 |
+
image=agents_image,
|
| 288 |
+
timeout=30 # Очень быстрая функция
|
| 289 |
+
)
|
| 290 |
+
def try_acquire_slot(session_id: str, max_concurrent: int = 3) -> dict:
|
| 291 |
+
"""
|
| 292 |
+
Пытается занять слот для пользователя.
|
| 293 |
+
Возвращает {'success': True} если место есть.
|
| 294 |
+
Возвращает {'success': False, 'message': ...} если занято.
|
| 295 |
+
"""
|
| 296 |
+
import time
|
| 297 |
+
|
| 298 |
+
current_time = time.time()
|
| 299 |
+
|
| 300 |
+
# 1. Очистка "зомби" (тех, кто упал с ошибкой и не освободил слот)
|
| 301 |
+
# Если сессия висит больше 10 минут (600 сек) - удаляем её
|
| 302 |
+
keys_to_remove = []
|
| 303 |
+
for sid, timestamp in active_users_dict.items():
|
| 304 |
+
if current_time - timestamp > 600:
|
| 305 |
+
keys_to_remove.append(sid)
|
| 306 |
+
|
| 307 |
+
for k in keys_to_remove:
|
| 308 |
+
active_users_dict.pop(k)
|
| 309 |
+
|
| 310 |
+
# 2. Проверка: пользователь уже активен?
|
| 311 |
+
if session_id in active_users_dict:
|
| 312 |
+
# Обновляем timestamp (keep-alive)
|
| 313 |
+
active_users_dict[session_id] = current_time
|
| 314 |
+
return {"success": True}
|
| 315 |
+
|
| 316 |
+
# 3. Проверка свободных мест
|
| 317 |
+
current_active_count = len(list(active_users_dict.keys()))
|
| 318 |
+
|
| 319 |
+
if current_active_count >= max_concurrent:
|
| 320 |
+
return {
|
| 321 |
+
"success": False,
|
| 322 |
+
"message": "⚠️ **System Busy**\n\nToo many users are searching right now. "
|
| 323 |
+
"To ensure high speed, we limit simultaneous searches.\n\n"
|
| 324 |
+
"**Please try again in 2-3 minutes.**"
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
# 4. Занимаем место
|
| 328 |
+
active_users_dict[session_id] = current_time
|
| 329 |
+
print(f"✅ Slot acquired for {session_id}. "
|
| 330 |
+
f"Active: {len(list(active_users_dict.keys()))}/{max_concurrent}")
|
| 331 |
+
return {"success": True}
|
| 332 |
+
|
| 333 |
+
@app.function(
|
| 334 |
+
image=agents_image,
|
| 335 |
+
timeout=10
|
| 336 |
+
)
|
| 337 |
+
def release_slot(session_id: str):
|
| 338 |
+
"""Освобождает слот после завершения работы"""
|
| 339 |
+
if session_id in active_users_dict:
|
| 340 |
+
active_users_dict.pop(session_id)
|
| 341 |
+
print(f"🏁 Slot released for {session_id}. Remaining: {len(list(active_users_dict.keys()))}")
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@app.function(
|
| 345 |
+
image=agents_image,
|
| 346 |
+
secrets=[modal.Secret.from_name("nebius-secret")],
|
| 347 |
+
timeout=300, # ✅ 5 минут ТОЛЬКО на обработку (без учёта очереди!)
|
| 348 |
+
volumes={"/data": modal.Volume.from_name("tmdb-data")}
|
| 349 |
+
)
|
| 350 |
+
def process_full_pipeline(session_id: str, user_text: str, session_data: dict) -> dict:
|
| 351 |
+
"""
|
| 352 |
+
Выполняет полный пайплайн обработки ПОСЛЕ получения слота.
|
| 353 |
+
Timeout считается ТОЛЬКО с момента вызова этой функции.
|
| 354 |
+
"""
|
| 355 |
+
from agents.editor_agent import EditorAgent
|
| 356 |
+
from agents.critic_agent_nebius import FilmCriticAgent
|
| 357 |
+
from agents.expert_agent import ExpertAgent
|
| 358 |
+
from agents.retriever import RetrieverAgent
|
| 359 |
+
from evaluation.judges import PersuasionJudge
|
| 360 |
+
import os
|
| 361 |
+
|
| 362 |
+
nebius_api_key = os.environ.get("NEBIUS_API_KEY")
|
| 363 |
+
|
| 364 |
+
try:
|
| 365 |
+
# 1. Editor Agent
|
| 366 |
+
editor = EditorAgent(nebius_api_key, use_react=False)
|
| 367 |
+
editor_result = editor.process_and_improve_text(user_text)
|
| 368 |
+
|
| 369 |
+
if not editor_result.get("approved", False):
|
| 370 |
+
return {
|
| 371 |
+
"status": "insufficient_length",
|
| 372 |
+
"message": editor_result.get("message", "Input quality too low"),
|
| 373 |
+
"original_plot": user_text
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
improved_text = editor_result.get("improved_text", user_text)
|
| 377 |
+
|
| 378 |
+
# 2. Critic Agent
|
| 379 |
+
critic = FilmCriticAgent(nebius_api_key)
|
| 380 |
+
overview_result = critic.create_overview(improved_text)
|
| 381 |
+
overview = overview_result.get("overview", str(improved_text))
|
| 382 |
+
|
| 383 |
+
# 3. Retriever (Search)
|
| 384 |
+
retriever = RetrieverAgent()
|
| 385 |
+
if not retriever.is_ready:
|
| 386 |
+
return {"status": "error", "message": "Search functions unavailable"}
|
| 387 |
+
|
| 388 |
+
retrieval_data = retriever.retrieve_candidates(overview, top_k=20, rerank_top_n=10)
|
| 389 |
+
candidates = retrieval_data.get("candidates", [])
|
| 390 |
+
|
| 391 |
+
if not candidates:
|
| 392 |
+
return {"status": "error", "message": "No movies found"}
|
| 393 |
+
|
| 394 |
+
# 4. Expert Agent
|
| 395 |
+
expert = ExpertAgent(nebius_api_key)
|
| 396 |
+
expert_result = expert.analyze_and_recommend(user_text, candidates)
|
| 397 |
+
|
| 398 |
+
recommendations = expert_result.get("explanations", "")
|
| 399 |
+
selected_movies = expert_result.get("selected_movies", [])
|
| 400 |
+
|
| 401 |
+
# 5. Shadow Evaluation (фоновые задачи)
|
| 402 |
+
try:
|
| 403 |
+
if selected_movies and len(selected_movies) > 0:
|
| 404 |
+
judge = PersuasionJudge(nebius_api_key)
|
| 405 |
+
eval_results = []
|
| 406 |
+
|
| 407 |
+
for i, movie in enumerate(selected_movies[:3], 1):
|
| 408 |
+
movie_data = movie.get("movie_data", {})
|
| 409 |
+
movie_title = movie_data.get("title", "Unknown")
|
| 410 |
+
justification = movie.get("justification", "")
|
| 411 |
+
full_overview = movie_data.get("overview", "No overview")
|
| 412 |
+
|
| 413 |
+
expert_card_single = f"""
|
| 414 |
+
**Recommendation #{i}: {movie_title}**
|
| 415 |
+
**Genres:** {movie_data.get('genres', 'Unknown')}
|
| 416 |
+
**Full Overview:** {full_overview}
|
| 417 |
+
**Agent's Justification:**
|
| 418 |
+
{justification}
|
| 419 |
+
**Relevance Score:** {movie.get('evaluation', {}).get('final_score', 'N/A')}
|
| 420 |
+
"""
|
| 421 |
+
|
| 422 |
+
# Синхронная оценка (т.к. уже внутри Modal функции)
|
| 423 |
+
result = judge.evaluate_real_world_interaction(
|
| 424 |
+
user_story=user_text,
|
| 425 |
+
expert_card=expert_card_single,
|
| 426 |
+
movie_metadata=movie_data
|
| 427 |
+
)
|
| 428 |
+
eval_results.append(result)
|
| 429 |
+
|
| 430 |
+
# Сохраняем в Volume (упрощённо)
|
| 431 |
+
print(f"📊 Shadow Eval #{i}: {movie_title} - {result}")
|
| 432 |
+
|
| 433 |
+
except Exception as e:
|
| 434 |
+
print(f"⚠️ Shadow eval failed: {e}")
|
| 435 |
+
|
| 436 |
+
# Возвращаем результат
|
| 437 |
+
return {
|
| 438 |
+
"status": "search_completed",
|
| 439 |
+
"original_plot": user_text,
|
| 440 |
+
"improved_plot": improved_text,
|
| 441 |
+
"movie_overview": overview,
|
| 442 |
+
"recommendations": recommendations,
|
| 443 |
+
"total_analyzed": len(candidates),
|
| 444 |
+
"performance_metrics": retrieval_data.get("metrics", {})
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
except Exception as e:
|
| 448 |
+
import traceback
|
| 449 |
+
traceback.print_exc()
|
| 450 |
+
return {"status": "error", "message": f"Pipeline error: {str(e)}"}
|
agents/modal_orchestrator.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/modal_orchestrator.py
|
| 2 |
+
import datetime
|
| 3 |
+
import uuid
|
| 4 |
+
import logging
|
| 5 |
+
import modal
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
# Импортируем агентов, включая новые функции для Координатора
|
| 9 |
+
from agents.modal_agents import (
|
| 10 |
+
process_editor_agent,
|
| 11 |
+
process_critic_agent,
|
| 12 |
+
process_expert_agent,
|
| 13 |
+
process_coordinator_check, # ✅ Новая функция проверки
|
| 14 |
+
process_coordinator_suggestion, # ✅ Новая функция предложений
|
| 15 |
+
process_shadow_evaluation, # ✅ NEW: Импорт функции оценки
|
| 16 |
+
process_full_pipeline,
|
| 17 |
+
try_acquire_slot,
|
| 18 |
+
release_slot
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# Импортируем SessionStore и Retriever
|
| 22 |
+
from memory.session_store import SessionStore
|
| 23 |
+
from agents.retriever import RetrieverAgent
|
| 24 |
+
|
| 25 |
+
from agents.modal_agents import try_acquire_slot, release_slot
|
| 26 |
+
|
| 27 |
+
logging.basicConfig(level=logging.INFO)
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ModalMovieSearchOrchestrator:
|
| 32 |
+
def __init__(self):
|
| 33 |
+
|
| 34 |
+
# Инициализируем Ретривера вместо прямых функций ---
|
| 35 |
+
self.retriever = RetrieverAgent()
|
| 36 |
+
|
| 37 |
+
if self.retriever.is_ready:
|
| 38 |
+
logger.info("✅ Connected to Modal functions via Retriever")
|
| 39 |
+
self.functions_available = True
|
| 40 |
+
else:
|
| 41 |
+
logger.error("❌ Retriever functions not found")
|
| 42 |
+
self.functions_available = False
|
| 43 |
+
|
| 44 |
+
# 2. Инициализация Session Store вместо словаря conversation_state
|
| 45 |
+
self.session_store = SessionStore()
|
| 46 |
+
# Создаем активную сессию (для однопользовательского режима -
|
| 47 |
+
# в многопользовательском app это передавалось бы в аргументах)
|
| 48 |
+
self.sid = self.session_store.create_session()
|
| 49 |
+
|
| 50 |
+
# def check_daily_limit(self) -> bool:
|
| 51 |
+
# """Проверяет, не превышен ли лимит запросов на сегодня"""
|
| 52 |
+
# import datetime
|
| 53 |
+
#
|
| 54 |
+
# today = datetime.datetime.utcnow().strftime("%Y-%m-%d")
|
| 55 |
+
# limit_file = "/data/usage_stats.json" # Храним на Volume
|
| 56 |
+
# DAILY_LIMIT = 50 # Например, 50 диалогов в день
|
| 57 |
+
#
|
| 58 |
+
# try:
|
| 59 |
+
# # Читаем статистику (псевдокод, нужны импорты json/os)
|
| 60 |
+
# if os.path.exists(limit_file):
|
| 61 |
+
# with open(limit_file, 'r') as f:
|
| 62 |
+
# stats = json.load(f)
|
| 63 |
+
# else:
|
| 64 |
+
# stats = {}
|
| 65 |
+
#
|
| 66 |
+
# current_count = stats.get(today, 0)
|
| 67 |
+
#
|
| 68 |
+
# if current_count >= DAILY_LIMIT:
|
| 69 |
+
# return False # Лимит исчерпан
|
| 70 |
+
#
|
| 71 |
+
# # Увеличиваем счетчик (в реальном коде нужно делать это после успешного запуска)
|
| 72 |
+
# # Но для простоты можно и здесь
|
| 73 |
+
# # stats[today] = current_count + 1
|
| 74 |
+
# # with open(limit_file, 'w') as f:
|
| 75 |
+
# # json.dump(stats, f)
|
| 76 |
+
#
|
| 77 |
+
# return True
|
| 78 |
+
#
|
| 79 |
+
# except Exception:
|
| 80 |
+
# return True # Если ошибка чтения, лучше пропустить, чем блокировать
|
| 81 |
+
|
| 82 |
+
def check_and_update_daily_limit(self, max_daily_requests=50) -> bool:
|
| 83 |
+
"""
|
| 84 |
+
Проверяет и обновляет счетчик запросов на Modal Volume.
|
| 85 |
+
Возвращает True, если лимит НЕ превышен (можно работать).
|
| 86 |
+
Возвращает False, если лимит исчерпан.
|
| 87 |
+
"""
|
| 88 |
+
# Путь к файлу на Volume (он сохраняется между перезапусками)
|
| 89 |
+
# Убедитесь, что Volume '/data' примонтирован к функции, которая вызывает этот метод!
|
| 90 |
+
# В вашем случае process_coordinator_check имеет volume, но оркестратор работает ЛОКАЛЬНО (в app.py).
|
| 91 |
+
|
| 92 |
+
# ВАЖНО: Поскольку Orchestrator работает в app.py (на HuggingFace),
|
| 93 |
+
# он НЕ ИМЕЕТ доступа к Modal Volume напрямую через 'open()'.
|
| 94 |
+
# Нам нужно вынести эту проверку в Modal-функцию.
|
| 95 |
+
|
| 96 |
+
# Поэтому мы просто вызовем специальную легкую функцию проверки на Modal.
|
| 97 |
+
try:
|
| 98 |
+
# Импортируем функцию проверки (ее нужно добавить в modal_agents.py)
|
| 99 |
+
from agents.modal_agents import check_daily_limit_remote
|
| 100 |
+
|
| 101 |
+
# Вызываем удаленно
|
| 102 |
+
is_allowed = check_daily_limit_remote.remote(max_limit=max_daily_requests)
|
| 103 |
+
return is_allowed
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Failed to check limit: {e}")
|
| 106 |
+
return True # В случае ошибки лучше пропустить, чем блокировать всех
|
| 107 |
+
|
| 108 |
+
async def process_user_input(self, user_text: str) -> dict:
|
| 109 |
+
"""
|
| 110 |
+
Главный цикл обработки.
|
| 111 |
+
Логика: 1/0/2 Menu -> Custom Mode -> Suggestions -> Pipeline
|
| 112 |
+
"""
|
| 113 |
+
logger.info(f"Processing input: {user_text}")
|
| 114 |
+
|
| 115 |
+
# # Сохраняем текущий ввод в общий контекст
|
| 116 |
+
self.session_store.add_user_input(self.sid, user_text)
|
| 117 |
+
|
| 118 |
+
# Получаем актуальное состояние
|
| 119 |
+
session = self.session_store.get_session(self.sid)
|
| 120 |
+
|
| 121 |
+
# 1. СНАЧАЛА ПРОВЕРЯЕМ ЛИМИТ (до запуска тяжелых агентов)
|
| 122 |
+
if not self.check_and_update_daily_limit(max_daily_requests=50):
|
| 123 |
+
return {
|
| 124 |
+
"status": "error",
|
| 125 |
+
"message": "⚠️ **Daily Demo Limit Reached**\n\nTo control costs during the Hackathon, we have a daily limit on requests. Please come back tomorrow!\n\n*(Reset time: 00:00 UTC)*"
|
| 126 |
+
}
|
| 127 |
+
# ------------------------------------------------------------------
|
| 128 |
+
# ЭТАП 1: Обработка ответа на предложение (1/0/2)
|
| 129 |
+
# ------------------------------------------------------------------
|
| 130 |
+
if session.get("suggested_plot"):
|
| 131 |
+
# Проверяем ответ пользователя
|
| 132 |
+
user_input_cleaned = user_text.strip().lower()
|
| 133 |
+
# is_agreement = any(word in user_text.lower() for word in ["yes", "sure", "ok", "agree", "да", "конечно"])
|
| 134 |
+
|
| 135 |
+
# ✅ Опция 1: Согласие с предложением
|
| 136 |
+
if user_input_cleaned == "1":
|
| 137 |
+
# Пользователь согласился
|
| 138 |
+
logger.info("User accepted suggestion (input: 1)")
|
| 139 |
+
user_text = session["suggested_plot"]
|
| 140 |
+
|
| 141 |
+
self.session_store.update_state(self.sid, "suggested_plot", None)
|
| 142 |
+
self.session_store.reset_attempts(self.sid)
|
| 143 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 144 |
+
# Продолжаем с user_text = предложенный сюжет
|
| 145 |
+
|
| 146 |
+
# ✅ Опция 0: Отказ от предложения
|
| 147 |
+
elif user_input_cleaned == "0":
|
| 148 |
+
logger.info("User rejected suggestion (input: 0)")
|
| 149 |
+
|
| 150 |
+
self.session_store.update_state(self.sid, "suggested_plot", None)
|
| 151 |
+
self.session_store.increment_attempts(self.sid)
|
| 152 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 153 |
+
# Переходим к следующему этапу (романтика/юмор/выход - Этап 2)
|
| 154 |
+
|
| 155 |
+
# ✅ Опция 2: Пользователь хочет ввести свой сюжет
|
| 156 |
+
elif user_input_cleaned == "2":
|
| 157 |
+
logger.info("User chose to provide custom plot (input: 2)")
|
| 158 |
+
|
| 159 |
+
self.session_store.update_state(self.sid, "suggested_plot", None)
|
| 160 |
+
self.session_store.set_custom_mode(self.sid, True) # Включаем строгий режим
|
| 161 |
+
self.session_store.reset_attempts(self.sid)
|
| 162 |
+
|
| 163 |
+
msg = (
|
| 164 |
+
"**📝 Custom Plot Entry Mode**\n\n"
|
| 165 |
+
"Please provide your own movie plot description following these **strict requirements**:\n\n"
|
| 166 |
+
"**✅ Requirements:**\n"
|
| 167 |
+
"• **Minimum 50 words** (approximately 3-5 sentences)\n"
|
| 168 |
+
"• **Clear plot structure** with characters, conflict, and setting\n"
|
| 169 |
+
"• **English language only**\n"
|
| 170 |
+
"• **Proper grammar** and coherent narrative\n\n"
|
| 171 |
+
"**⚠️ IMPORTANT:**\n"
|
| 172 |
+
"• You have **ONE attempt** only\n"
|
| 173 |
+
"• If your description doesn't meet requirements, the session will end gracefully\n"
|
| 174 |
+
"• The system will validate your input strictly\n\n"
|
| 175 |
+
"**💡 Example of good description (56 words):**\n"
|
| 176 |
+
"_\"In a dystopian future where water is scarce, a young rebel discovers a hidden map "
|
| 177 |
+
"leading to an underground ocean. Along with a cynical smuggler and a defected soldier, "
|
| 178 |
+
"she must cross the scorched wasteland, chased by the warlord's army who wants to control "
|
| 179 |
+
"the water supply for themselves. They face sandstorms, betrayal, and mechanical beasts.\"_\n\n"
|
| 180 |
+
"**❌ Example of BAD description (too short - 12 words):**\n\n"
|
| 181 |
+
"_\"A girl finds water in the desert and fights bad guys.\"_\n\n"
|
| 182 |
+
"**Please enter your plot description now:**"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# self._update_history(user_text, msg)
|
| 186 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 187 |
+
return {
|
| 188 |
+
"status": "awaiting_custom_plot",
|
| 189 |
+
"message": msg,
|
| 190 |
+
"custom_mode": True
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
else:
|
| 194 |
+
# ✅ ЛЮБОЙ другой ввод = согласие (по умолчанию)
|
| 195 |
+
logger.info(f"User provided text instead of 1/0/2, treating as custom plot: {user_text[:50]}...")
|
| 196 |
+
|
| 197 |
+
self.session_store.update_state(self.sid, "suggested_plot", None)
|
| 198 |
+
self.session_store.set_custom_mode(self.sid, True)
|
| 199 |
+
self.session_store.reset_attempts(self.sid)
|
| 200 |
+
# НЕ возвращаемся, продолжаем обработку user_text как custom plot
|
| 201 |
+
|
| 202 |
+
# Обновляем локальную переменную session
|
| 203 |
+
session = self.session_store.get_session(self.sid)
|
| 204 |
+
|
| 205 |
+
# ------------------------------------------------------------------
|
| 206 |
+
# ЭТАП 2: Логика предложений (Suggestions) на основе попыток
|
| 207 |
+
# ------------------------------------------------------------------
|
| 208 |
+
attempts = session.get("attempts", 0)
|
| 209 |
+
user_inputs = session.get("user_inputs", [])
|
| 210 |
+
|
| 211 |
+
# Попытка 3 (было 2 неудачи) -> Предлагаем Романтику
|
| 212 |
+
# if session["attempts"] == 2:
|
| 213 |
+
if attempts == 2:
|
| 214 |
+
logger.info("Attempt 2 failed, suggesting Romantic plot")
|
| 215 |
+
|
| 216 |
+
suggestion = await process_coordinator_suggestion.remote.aio(user_inputs, "romantic")
|
| 217 |
+
|
| 218 |
+
self.session_store.update_state(self.sid, "suggested_plot", suggestion["suggested_story"])
|
| 219 |
+
|
| 220 |
+
msg = (
|
| 221 |
+
f"{suggestion['message']}\n\n"
|
| 222 |
+
f"**Proposed Plot (Romantic):**\n"
|
| 223 |
+
f"_{suggestion['suggested_story']}_\n\n"
|
| 224 |
+
f"{'=' * 60}\n"
|
| 225 |
+
f"**📍 Please choose an option:**\n"
|
| 226 |
+
f"• Press **1** to accept this plot (YES)\n"
|
| 227 |
+
f"• Press **0** to try a different genre (NO)\n"
|
| 228 |
+
f"• Press **2** to provide your own plot description\n\n"
|
| 229 |
+
f"⚠️ **Note:** Any other input will be treated as your custom plot description "
|
| 230 |
+
f"(same as pressing 2)."
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 234 |
+
return {
|
| 235 |
+
"status": "suggestion",
|
| 236 |
+
"message": msg,
|
| 237 |
+
"is_suggestion": True,
|
| 238 |
+
"suggestion_type": "romantic"
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
# Попытка 4 (отказ от романтики) -> Предлагаем Юмор
|
| 242 |
+
if attempts == 3:
|
| 243 |
+
logger.info("Romantic rejected, suggesting Humorous plot")
|
| 244 |
+
|
| 245 |
+
suggestion = await process_coordinator_suggestion.remote.aio(user_inputs, "humorous")
|
| 246 |
+
|
| 247 |
+
self.session_store.update_state(self.sid, "suggested_plot", suggestion["suggested_story"])
|
| 248 |
+
|
| 249 |
+
msg = (
|
| 250 |
+
f"{suggestion['message']}\n\n"
|
| 251 |
+
f"**Proposed Plot (Humorous):**\n"
|
| 252 |
+
f"_{suggestion['suggested_story']}_\n\n"
|
| 253 |
+
f"{'=' * 60}\n"
|
| 254 |
+
f"**📍 Please choose an option:**\n"
|
| 255 |
+
f"• Press **1** to accept this plot (YES)\n"
|
| 256 |
+
f"• Press **0** to end session (NO)\n"
|
| 257 |
+
f"• Press **2** to provide your own plot description\n\n"
|
| 258 |
+
f"⚠️ **Note:** Any other input will be treated as your custom plot description "
|
| 259 |
+
f"(same as pressing 2)."
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 263 |
+
return {
|
| 264 |
+
"status": "suggestion",
|
| 265 |
+
"message": msg,
|
| 266 |
+
"is_suggestion": True,
|
| 267 |
+
"suggestion_type": "humorous"
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
# Попытка 5 (отказ от всего) -> Выход
|
| 271 |
+
if attempts >= 4:
|
| 272 |
+
logger.info("All suggestions rejected. Ending session.")
|
| 273 |
+
msg = (
|
| 274 |
+
"**👋 Session Ended**\n\n"
|
| 275 |
+
"It seems we can't find the right story today. "
|
| 276 |
+
"Please come back when you have a new idea! Have a great day!\n\n"
|
| 277 |
+
"**🔄 Ready to start fresh!** Feel free to describe a new movie plot anytime."
|
| 278 |
+
)
|
| 279 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 280 |
+
|
| 281 |
+
return {
|
| 282 |
+
"status": "end_session",
|
| 283 |
+
"message": msg,
|
| 284 |
+
"end_session": True
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
# ------------------------------------------------------------------
|
| 288 |
+
# ЭТАП 3: Строгая проверка для Custom Plot (если флаг установлен)
|
| 289 |
+
# ------------------------------------------------------------------
|
| 290 |
+
# Выполняется, если это не ответ на предложение (или если предложение принято)
|
| 291 |
+
if session.get("custom_plot_mode", False):
|
| 292 |
+
logger.info(f"Custom plot mode: strict validation for: {user_text[:50]}...")
|
| 293 |
+
|
| 294 |
+
# Дополнительная проверка длины
|
| 295 |
+
word_count = len(user_text.split())
|
| 296 |
+
|
| 297 |
+
if word_count < 50:
|
| 298 |
+
msg = (
|
| 299 |
+
f"**❌ Too Short**\n\n"
|
| 300 |
+
f"Your description has only **{word_count} words**, but we need **minimum 50 words**.\n\n"
|
| 301 |
+
f"{'=' * 60}\n"
|
| 302 |
+
"**📋 Requirements:**\n"
|
| 303 |
+
"• Minimum **50 words** (3-5 sentences)\n"
|
| 304 |
+
"• Clear plot with characters and conflict\n"
|
| 305 |
+
"• English language only\n"
|
| 306 |
+
"• Proper grammar\n\n"
|
| 307 |
+
f"{'=' * 60}\n"
|
| 308 |
+
"**👋 Session Ended Gracefully**\n\n"
|
| 309 |
+
"Don't worry! Take your time to craft a detailed plot description.\n"
|
| 310 |
+
"When you're ready with a **complete story** (50+ words), feel free to start a new session.\n\n"
|
| 311 |
+
"**💡 Tip:** Think about:\n"
|
| 312 |
+
"• Who are the main characters?\n"
|
| 313 |
+
"• What conflict do they face?\n"
|
| 314 |
+
"• What's at stake?\n"
|
| 315 |
+
"• What makes the story unique?\n\n"
|
| 316 |
+
"**See you soon!** 🎬"
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 320 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 321 |
+
|
| 322 |
+
return {
|
| 323 |
+
"status": "custom_plot_too_short",
|
| 324 |
+
"message": msg,
|
| 325 |
+
"end_session": True
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
try:
|
| 329 |
+
# ✅ СТРОГАЯ проверка координатором
|
| 330 |
+
analysis = await process_coordinator_check.remote.aio(user_text, attempts)
|
| 331 |
+
|
| 332 |
+
if analysis["status"] == "insufficient":
|
| 333 |
+
# ❌ Не прошло проверку в строгом режиме -> Благожелательный выход
|
| 334 |
+
logger.warning(f"Custom plot rejected: {analysis.get('message')}")
|
| 335 |
+
|
| 336 |
+
msg = (
|
| 337 |
+
"**❌ Custom Plot Validation Failed**\n\n"
|
| 338 |
+
f"{analysis.get('message', 'Your plot description does not meet the requirements.')}\n\n"
|
| 339 |
+
"{'=' * 60}\n"
|
| 340 |
+
"**📋 Requirements reminder:**\n"
|
| 341 |
+
"• Minimum **50 words** (3-5 sentences)\n"
|
| 342 |
+
"• Clear plot with characters and conflict\n"
|
| 343 |
+
"• English language only\n"
|
| 344 |
+
"• Proper grammar\n\n"
|
| 345 |
+
"{'=' * 60}\n"
|
| 346 |
+
"**👋 Session Ended Gracefully**\n\n"
|
| 347 |
+
"Don't worry! Take your time to craft a detailed plot description.\n"
|
| 348 |
+
"When you're ready with a **complete story** (50+ words), feel free to start a new session.\n\n"
|
| 349 |
+
"**💡 Tip:** Think about:\n"
|
| 350 |
+
"• Who are the main characters?\n"
|
| 351 |
+
"• What conflict do they face?\n"
|
| 352 |
+
"• What's at stake?\n"
|
| 353 |
+
"• What makes the story unique?\n\n"
|
| 354 |
+
"**See you soon!** 🎬"
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
self.session_store.add_history(self.sid, user_text, msg)
|
| 358 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 359 |
+
self.session_store.reset_attempts(self.sid)
|
| 360 |
+
|
| 361 |
+
return {
|
| 362 |
+
"status": "custom_plot_rejected",
|
| 363 |
+
"message": msg,
|
| 364 |
+
"end_session": True
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
# ✅ Прошло строгую проверку -> продолжаем поиск
|
| 368 |
+
logger.info("Custom plot validated successfully!")
|
| 369 |
+
|
| 370 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 371 |
+
self.session_store.reset_attempts(self.sid)
|
| 372 |
+
# Продолжаем к ЭТАПУ 5 (поиск)
|
| 373 |
+
|
| 374 |
+
except Exception as e:
|
| 375 |
+
logger.error(f"Coordinator check failed in custom mode: {e}")
|
| 376 |
+
# Fallback: Если координатор упал, тоже делаем благожелательный выход
|
| 377 |
+
msg = (
|
| 378 |
+
"**⚠️ System Error**\n\n"
|
| 379 |
+
"We encountered an issue validating your plot. "
|
| 380 |
+
"Please try again later with a detailed description.\n\n"
|
| 381 |
+
"**See you soon!** 🎬"
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
self.session_store.set_custom_mode(self.sid, False)
|
| 385 |
+
|
| 386 |
+
return {
|
| 387 |
+
"status": "error",
|
| 388 |
+
"message": msg,
|
| 389 |
+
"end_session": True
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
# ------------------------------------------------------------------
|
| 393 |
+
# ЭТАП 4: Стандартная проверка Координатором (для обычного режима).
|
| 394 |
+
# Пайплайн Поиска (Editor -> Critic -> Search -> Expert)
|
| 395 |
+
# ------------------------------------------------------------------
|
| 396 |
+
else:
|
| 397 |
+
try:
|
| 398 |
+
analysis = await process_coordinator_check.remote.aio(user_text, attempts)
|
| 399 |
+
|
| 400 |
+
if analysis["status"] == "insufficient":
|
| 401 |
+
self.session_store.increment_attempts(self.sid)
|
| 402 |
+
attempts_left = 2 - attempts # (0->1 left, 1->0 left)
|
| 403 |
+
|
| 404 |
+
warning_msg = analysis.get("message", "Please add more details.")
|
| 405 |
+
if attempts_left > 0:
|
| 406 |
+
warning_msg += f"\n\n_You have {attempts_left} attempt(s) left before I suggest a plot._"
|
| 407 |
+
|
| 408 |
+
self.session_store.add_history(self.sid, user_text, warning_msg)
|
| 409 |
+
|
| 410 |
+
return {
|
| 411 |
+
"status": "insufficient_length",
|
| 412 |
+
"message": warning_msg,
|
| 413 |
+
"original_plot": user_text
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
# Если статус 'valid' -> переходим к поиску
|
| 417 |
+
logger.info("Coordinator validated input. Starting pipeline.")
|
| 418 |
+
|
| 419 |
+
self.session_store.reset_attempts(self.sid)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
except Exception as e:
|
| 423 |
+
logger.error(f"Coordinator check failed: {e}. Falling back to search.")
|
| 424 |
+
|
| 425 |
+
# ------------------------------------------------------------------
|
| 426 |
+
# ЭТАП 5: Пайплайн Поиска (Editor -> Critic -> Search -> Expert)
|
| 427 |
+
# ------------------------------------------------------------------
|
| 428 |
+
# 1. ПОПЫТКА ЗАНЯТЬ СЛОТ
|
| 429 |
+
# Используем session_id как уникальный ключ
|
| 430 |
+
slot_result = await try_acquire_slot.remote.aio(
|
| 431 |
+
session_id=self.sid,
|
| 432 |
+
max_concurrent=1 # <-- ЛИМИТ ЗДЕСЬ (3)
|
| 433 |
+
)
|
| 434 |
+
|
| 435 |
+
if not slot_result.get("success"):
|
| 436 |
+
# ВАЖНО: не держим запрос в Modal, говорим клиенту "system busy"
|
| 437 |
+
return {
|
| 438 |
+
"status": "busy",
|
| 439 |
+
"message": slot_result.get(
|
| 440 |
+
"message",
|
| 441 |
+
"⚠️ **System Busy**\n\nToo many users are searching right now. "
|
| 442 |
+
"Please try again in 2–3 minutes."
|
| 443 |
+
),
|
| 444 |
+
"retry_suggested": True
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
# ✅ ШАГ 2: СЛОТ ПОЛУЧЕН → ЗАПУСКАЕМ ОБРАБОТКУ (timeout=300s)
|
| 448 |
+
try:
|
| 449 |
+
# Теперь вызываем обработку в отдельной функции с НОВЫМ timeout
|
| 450 |
+
result = await process_full_pipeline.remote.aio(
|
| 451 |
+
session_id=self.sid,
|
| 452 |
+
user_text=user_text,
|
| 453 |
+
session_data=session
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
return result
|
| 457 |
+
|
| 458 |
+
except Exception as e:
|
| 459 |
+
logger.error(f"Pipeline error: {e}")
|
| 460 |
+
return {"status": "error", "message": f"System error: {e}"}
|
| 461 |
+
|
| 462 |
+
finally:
|
| 463 |
+
# 2. ОСВОБОЖДЕНИЕ СЛОТА (Выполнится всегда, даже при ошибке)
|
| 464 |
+
# Запускаем в фоновом режиме ("fire and forget"), чтобы не задерживать ответ пользователю
|
| 465 |
+
release_slot.spawn(self.sid)
|
| 466 |
+
|
| 467 |
+
def get_conversation_summary(self):
|
| 468 |
+
"""Возвращает текущее состояние для UI"""
|
| 469 |
+
session = self.session_store.get_session(self.sid)
|
| 470 |
+
if not session: return {}
|
| 471 |
+
return {
|
| 472 |
+
"session_id": session.get("session_id"),
|
| 473 |
+
"current_step": "completed" if session.get("final_recommendations") else "processing",
|
| 474 |
+
"attempts": session.get("attempts", 0),
|
| 475 |
+
"has_plot": bool(session.get("original_plot")),
|
| 476 |
+
"has_recommendations": bool(session.get("final_recommendations")),
|
| 477 |
+
"total_search_results": len(session.get("search_results", []))
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
def reset_conversation(self):
|
| 481 |
+
"""Сброс сессии"""
|
| 482 |
+
self.session_store.clear_session_data(self.sid)
|
agents/nebius_simple.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/nebius_simple.py
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from openai import OpenAI
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
from llama_index.core.llms import CustomLLM, CompletionResponse, LLMMetadata
|
| 7 |
+
from llama_index.core.llms.callbacks import llm_completion_callback
|
| 8 |
+
from pydantic import Field, PrivateAttr
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SimpleNebiusLLM(CustomLLM):
|
| 12 |
+
"""Простой Nebius LLM через прямой OpenAI клиент"""
|
| 13 |
+
|
| 14 |
+
# ✅ ИСПРАВЛЕНО: Объявляем поля как Pydantic поля
|
| 15 |
+
api_key: str = Field(description="API ключ Nebius")
|
| 16 |
+
model_name: str = Field(default="meta-llama/Llama-3.3-70B-Instruct", description="Имя модели")
|
| 17 |
+
temperature: float = Field(default=0.3, description="Температура генерации")
|
| 18 |
+
base_url: str = Field(default="https://api.studio.nebius.com/v1/", description="Base URL для API")
|
| 19 |
+
|
| 20 |
+
# ✅ ИСПРАВЛЕНО: Используем PrivateAttr для OpenAI клиента
|
| 21 |
+
_client: OpenAI = PrivateAttr()
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
api_key: str = None,
|
| 26 |
+
model: str = "meta-llama/Llama-3.3-70B-Instruct",
|
| 27 |
+
temperature: float = 0.3,
|
| 28 |
+
**kwargs
|
| 29 |
+
):
|
| 30 |
+
api_key = api_key or os.environ.get("NEBIUS_API_KEY")
|
| 31 |
+
if not api_key:
|
| 32 |
+
raise ValueError("NEBIUS_API_KEY не найден")
|
| 33 |
+
|
| 34 |
+
# ✅ ИСПРАВЛЕНО: Инициализируем родительский класс с полями
|
| 35 |
+
super().__init__(
|
| 36 |
+
api_key=api_key,
|
| 37 |
+
model_name=model,
|
| 38 |
+
temperature=temperature,
|
| 39 |
+
**kwargs
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
# ✅ ИСПРАВЛЕНО: Создаем клиента как приватный атрибут
|
| 43 |
+
self._client = OpenAI(
|
| 44 |
+
base_url=self.base_url,
|
| 45 |
+
api_key=self.api_key
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def metadata(self) -> LLMMetadata:
|
| 50 |
+
return LLMMetadata(
|
| 51 |
+
context_window=128000,
|
| 52 |
+
num_output=4096,
|
| 53 |
+
model_name=self.model_name,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
@llm_completion_callback()
|
| 57 |
+
def complete(self, prompt: str, **kwargs: Any) -> CompletionResponse:
|
| 58 |
+
"""Синхронное completion через Nebius API"""
|
| 59 |
+
try:
|
| 60 |
+
# ✅ ИСПРАВЛЕНО: Используем self._client
|
| 61 |
+
response = self._client.chat.completions.create(
|
| 62 |
+
model=self.model_name,
|
| 63 |
+
messages=[{"role": "user", "content": prompt}],
|
| 64 |
+
temperature=self.temperature,
|
| 65 |
+
max_tokens=4096
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
text = response.choices[0].message.content
|
| 69 |
+
return CompletionResponse(text=text)
|
| 70 |
+
|
| 71 |
+
except Exception as e:
|
| 72 |
+
error_text = f"Nebius API error: {str(e)}"
|
| 73 |
+
return CompletionResponse(text=error_text)
|
| 74 |
+
|
| 75 |
+
@llm_completion_callback()
|
| 76 |
+
def stream_complete(self, prompt: str, **kwargs: Any):
|
| 77 |
+
"""Потоковое completion"""
|
| 78 |
+
try:
|
| 79 |
+
response = self._client.chat.completions.create(
|
| 80 |
+
model=self.model_name,
|
| 81 |
+
messages=[{"role": "user", "content": prompt}],
|
| 82 |
+
temperature=self.temperature,
|
| 83 |
+
max_tokens=4096,
|
| 84 |
+
stream=True
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
accumulated_text = ""
|
| 88 |
+
for chunk in response:
|
| 89 |
+
if chunk.choices[0].delta.content:
|
| 90 |
+
content = chunk.choices[0].delta.content
|
| 91 |
+
accumulated_text += content
|
| 92 |
+
yield CompletionResponse(text=accumulated_text, delta=content)
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
error_text = f"Nebius streaming error: {str(e)}"
|
| 96 |
+
yield CompletionResponse(text=error_text, delta=error_text)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def create_nebius_llm(
|
| 100 |
+
api_key: str = None,
|
| 101 |
+
model: str = "meta-llama/Llama-3.3-70B-Instruct",
|
| 102 |
+
temperature: float = 0.3
|
| 103 |
+
) -> SimpleNebiusLLM:
|
| 104 |
+
"""Фабричная функция для создания Nebius LLM"""
|
| 105 |
+
return SimpleNebiusLLM(
|
| 106 |
+
api_key=api_key,
|
| 107 |
+
model=model,
|
| 108 |
+
temperature=temperature
|
| 109 |
+
)
|
agents/retriever.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# agents/retriever.py
|
| 2 |
+
import logging
|
| 3 |
+
import modal
|
| 4 |
+
from typing import List, Dict, Any
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RetrieverAgent:
|
| 10 |
+
"""
|
| 11 |
+
Агент-специалист по поиску.
|
| 12 |
+
Инкапсулирует логику: Text -> Embedding -> Vector Search -> Candidates.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
try:
|
| 17 |
+
self.encode_func = modal.Function.from_name("tmdb-project", "encode_user_query")
|
| 18 |
+
self.search_func = modal.Function.from_name("tmdb-project", "search_similar_movies")
|
| 19 |
+
logger.info("✅ RetrieverAgent connected to Modal functions")
|
| 20 |
+
self.is_ready = True
|
| 21 |
+
except Exception as e:
|
| 22 |
+
logger.error(f"❌ RetrieverAgent failed to connect: {e}")
|
| 23 |
+
self.is_ready = False
|
| 24 |
+
|
| 25 |
+
def retrieve_candidates(self, overview_text: str, top_k: int = 20, rerank_top_n: int = 10) -> dict:
|
| 26 |
+
"""
|
| 27 |
+
Возвращает словарь с кандидатами и метриками.
|
| 28 |
+
"""
|
| 29 |
+
if not self.is_ready:
|
| 30 |
+
return {"error": "Search functions unavailable", "candidates": []}
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
# 1. Кодирование (remove_entities=True для поиска по смыслу)
|
| 34 |
+
encoding_result = self.encode_func.remote(overview_text, remove_entities=True)
|
| 35 |
+
|
| 36 |
+
# 2. Поиск
|
| 37 |
+
search_results = self.search_func.remote(
|
| 38 |
+
query_embedding=encoding_result["embedding"],
|
| 39 |
+
query_narrative_features=encoding_result["narrative_features"],
|
| 40 |
+
top_k=top_k,
|
| 41 |
+
rerank_top_n=rerank_top_n
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
return {
|
| 45 |
+
"candidates": search_results.get("results", []),
|
| 46 |
+
"metrics": search_results.get("performance_metrics", {})
|
| 47 |
+
}
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"Retrieval error: {e}")
|
| 50 |
+
return {"error": str(e), "candidates": []}
|
app.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app_simplified.py
|
| 2 |
+
import modal
|
| 3 |
+
import gradio as gr
|
| 4 |
+
import asyncio
|
| 5 |
+
import os
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# from modal_app import app
|
| 9 |
+
# from agents.orchestrator import SimplifiedMovieSearchOrchestrator
|
| 10 |
+
|
| 11 |
+
# Импорт Modal оркестратора вместо локального
|
| 12 |
+
from agents.modal_orchestrator import ModalMovieSearchOrchestrator
|
| 13 |
+
|
| 14 |
+
app = modal.App("movie-plot-search")
|
| 15 |
+
|
| 16 |
+
# print("Trying to lookup functions...")
|
| 17 |
+
# try:
|
| 18 |
+
# encode_func = modal.Function.from_name("tmdb-project", "encode_user_query")
|
| 19 |
+
# print("✅ encode_user_query function found")
|
| 20 |
+
# except Exception as e:
|
| 21 |
+
# print(f"❌ Error looking up encode_user_query: {e}")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# --- Основная функция запуска приложения ---
|
| 25 |
+
def _run_main_app():
|
| 26 |
+
# Настройка логирования
|
| 27 |
+
logging.basicConfig(level=logging.INFO)
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
# Инициализация Modal оркестратора (без API ключа)
|
| 31 |
+
orchestrator = ModalMovieSearchOrchestrator()
|
| 32 |
+
|
| 33 |
+
async def chat_interface(message: str, history: list) -> tuple:
|
| 34 |
+
""" Основной интерфейс чата с Modal агентами + Nebius LLM5(только английский)"""
|
| 35 |
+
try:
|
| 36 |
+
logger.info(f"Processing user message: {message[:50]}...")
|
| 37 |
+
|
| 38 |
+
# ВСЕ LLM ВЫЗОВЫ ПРОИСХОДЯТ НА MODAL С NEBIUS API
|
| 39 |
+
result = await orchestrator.process_user_input(message)
|
| 40 |
+
logger.info(f"RESULT: {result}")
|
| 41 |
+
# Формирование ответа
|
| 42 |
+
response_parts = []
|
| 43 |
+
status = result.get("status")
|
| 44 |
+
|
| 45 |
+
# ---------- 1. Обработка статусов от оркестратора ----------
|
| 46 |
+
# ✅ Обработка случая недостаточной длины
|
| 47 |
+
# if result.get("status") == "insufficient_length":
|
| 48 |
+
# response_parts.append("**❌ Text Too Short**")
|
| 49 |
+
# response_parts.append(result.get("message", ""))
|
| 50 |
+
|
| 51 |
+
if status == "insufficient_length":
|
| 52 |
+
response_parts += [
|
| 53 |
+
"**❗ **Editor Feedback:**",
|
| 54 |
+
result.get("message", ""),
|
| 55 |
+
"\n---\nPlot description is too short (min 50 words). "
|
| 56 |
+
"Please expand your plot description and try again."
|
| 57 |
+
]
|
| 58 |
+
|
| 59 |
+
# (2) Полный успех: найдено 3 фильма + экспертный отчёт
|
| 60 |
+
elif status == "search_completed":
|
| 61 |
+
logger.info(f"**✅ Поиск завершен! Найдены рекомендации фильмов**")
|
| 62 |
+
response_parts.append("**✅ Plot processed and search completed!**")
|
| 63 |
+
# ✅ Показываем improved plot для информации
|
| 64 |
+
|
| 65 |
+
if (result.get("improved_plot") and
|
| 66 |
+
result.get("improved_plot") != result.get("original_plot")):
|
| 67 |
+
logger.info(f"**📝 Улучшенное описание:** {result.get('improved_plot')}")
|
| 68 |
+
response_parts.append(f"**📝 Improved plot:** {result.get('improved_plot')}")
|
| 69 |
+
# ✅ Показываем movie overview для информации
|
| 70 |
+
|
| 71 |
+
if result.get("movie_overview"):
|
| 72 |
+
response_parts.append(f"\n**🎬 Generated movie overview:**\n"
|
| 73 |
+
f"{result.get('movie_overview')}")
|
| 74 |
+
|
| 75 |
+
# ✅ Основной блок рекомендаций с новым форматом
|
| 76 |
+
response_parts.append("\n" + "=" * 60)
|
| 77 |
+
response_parts.append("**🎯 EXPERT SYSTEM RECOMMENDATIONS**")
|
| 78 |
+
response_parts.append("=" * 60)
|
| 79 |
+
|
| 80 |
+
# response_parts.append(result.get("recommendations", ""))
|
| 81 |
+
recommendations = result.get("recommendations", "")
|
| 82 |
+
|
| 83 |
+
if isinstance(recommendations, dict):
|
| 84 |
+
logger.warning("Received dict instead of string for recommendations")
|
| 85 |
+
recommendations = recommendations.get("explanations", str(recommendations))
|
| 86 |
+
|
| 87 |
+
if isinstance(recommendations, str) and recommendations:
|
| 88 |
+
response_parts.append(recommendations)
|
| 89 |
+
else:
|
| 90 |
+
logger.error(f"Invalid recommendations type: {type(recommendations)}")
|
| 91 |
+
response_parts.append("No recommendations were generated. Please try again.")
|
| 92 |
+
|
| 93 |
+
# if recommendations:
|
| 94 |
+
# response_parts.append(recommendations)
|
| 95 |
+
# else:
|
| 96 |
+
# response_parts.append("No recommendations were generated.")
|
| 97 |
+
|
| 98 |
+
# ✅ Метрики производительности
|
| 99 |
+
response_parts.append("\n" + "=" * 60)
|
| 100 |
+
response_parts.append("**📊 PERFORMANCE METRICS**")
|
| 101 |
+
response_parts.append("=" * 60)
|
| 102 |
+
|
| 103 |
+
metrics = result.get("performance_metrics", {})
|
| 104 |
+
if metrics:
|
| 105 |
+
response_parts.append(f"🚀 **GPU Used:** {'✅ Yes' if metrics.get('using_gpu', False) else '❌ No'}")
|
| 106 |
+
response_parts.append(f"⚡ **Search Time:** {metrics.get('search_time', 0):.3f}s")
|
| 107 |
+
response_parts.append(f"🔄 **Total Processing Time:** {metrics.get('total_time', 0):.3f}s")
|
| 108 |
+
response_parts.append(f"🎬 **Movies Analyzed:** {result.get('total_analyzed', 0)}")
|
| 109 |
+
|
| 110 |
+
response_parts.append("\n" + "=" * 60)
|
| 111 |
+
response_parts.append("**🔄 Ready for the next search!**")
|
| 112 |
+
response_parts.append("Type a new movie plot and I will find more recommendations.")
|
| 113 |
+
|
| 114 |
+
elif status == "suggestion":
|
| 115 |
+
response_parts.append("**💡 AI Plot Suggestion**")
|
| 116 |
+
response_parts.append(result.get("message", ""))
|
| 117 |
+
|
| 118 |
+
elif status == "awaiting_custom_plot":
|
| 119 |
+
response_parts.append("**📝 Custom Plot Mode Activated**")
|
| 120 |
+
response_parts.append(result.get("message", ""))
|
| 121 |
+
|
| 122 |
+
# ✅ ДОБАВЛЕНО: Обработка выхода при коротком custom plot
|
| 123 |
+
elif status == "custom_plot_too_short":
|
| 124 |
+
# response_parts.append("**❌ Custom Plot Too Short**")
|
| 125 |
+
response_parts.append(result.get("message", "Your plot is too short."))
|
| 126 |
+
|
| 127 |
+
# ✅ ДОБАВЛЕНО: Обработка выхода при отклонении custom plot
|
| 128 |
+
elif status == "custom_plot_rejected":
|
| 129 |
+
# response_parts.append("**❌ Custom Plot Rejected**")
|
| 130 |
+
response_parts.append(result.get("message", "Your plot doesn't meet requirements."))
|
| 131 |
+
|
| 132 |
+
# if result.get('methodology'):
|
| 133 |
+
# response_parts.append(f"🧮 **Methodology:** {result.get('methodology')}")
|
| 134 |
+
# if result.get('evaluation_formula'):
|
| 135 |
+
# response_parts.append(f"📐 **Evaluation Formula:** {result.get('evaluation_formula')}")
|
| 136 |
+
#
|
| 137 |
+
# # Russian comment: приглашение к новому поиску
|
| 138 |
+
# response_parts.append("\n" + "=" * 30)
|
| 139 |
+
# response_parts.append("**🔄 Ready for the next search!**")
|
| 140 |
+
# response_parts.append("Type a new movie plot and I will find more recommendations.")
|
| 141 |
+
#
|
| 142 |
+
# # ✅ ДОБАВЛЕНО: Обработка статуса "suggestion"
|
| 143 |
+
# elif status == "suggestion":
|
| 144 |
+
# response_parts.append("**💡 AI Plot Suggestion**")
|
| 145 |
+
# response_parts.append(result.get("message", ""))
|
| 146 |
+
# if result.get("is_suggestion"):
|
| 147 |
+
# response_parts.append(
|
| 148 |
+
# "\n**Would you like to proceed with this plot?** (Reply 'Yes' or provide your own)")
|
| 149 |
+
|
| 150 |
+
# ✅ ДОБАВЛЕНО: Обработка завершения сессии
|
| 151 |
+
elif status == "end_session":
|
| 152 |
+
# response_parts.append("**👋 Session Ended**")
|
| 153 |
+
response_parts.append(result.get("message", "Thank you for using Movie Plot Search!"))
|
| 154 |
+
# response_parts.append("\n---")
|
| 155 |
+
# response_parts.append("**🔄 Ready to start fresh!** Feel free to describe a new movie plot anytime.")
|
| 156 |
+
|
| 157 |
+
# ✅ Обработка ошибок
|
| 158 |
+
elif status == "error":
|
| 159 |
+
response_parts.append("**❌ System Error occurred:**")
|
| 160 |
+
response_parts.append(result.get("message", "Unknown error"))
|
| 161 |
+
else:
|
| 162 |
+
response_parts.append(f"⚠️ Unhandled status: {status}")
|
| 163 |
+
response_parts.append(f"Result details: {result}") # добавлено
|
| 164 |
+
logger.warning(f"Unhandled status encountered: {status}") # добавлено
|
| 165 |
+
|
| 166 |
+
# ---------- 2. Формируем ответ ВСЕГДА и историю (вне if блоков) ----------
|
| 167 |
+
assistant_reply = "\n".join(response_parts)
|
| 168 |
+
new_history = history + [
|
| 169 |
+
{"role": "user", "content": message},
|
| 170 |
+
{"role": "assistant", "content": assistant_reply}
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
# ✅ КРИТИЧНО: return ВСЕГДА выполняется (вне if блоков)
|
| 174 |
+
return new_history, ""
|
| 175 |
+
|
| 176 |
+
# # Автообновление session info после обработки
|
| 177 |
+
# if status in ["search_completed", "needs_improvement", "insufficient_length"]:
|
| 178 |
+
# # Обновляем session info автоматически
|
| 179 |
+
# _ = get_session_info() # обновит компонент через .then() в Gradio
|
| 180 |
+
# logger.info(f"Session info updated: {_}")
|
| 181 |
+
#
|
| 182 |
+
# # Очищаем поле ввода
|
| 183 |
+
# return new_history, ""
|
| 184 |
+
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.error(f"Error in chat interface: {e}")
|
| 187 |
+
|
| 188 |
+
# Формат messages для ошибок
|
| 189 |
+
# Обработка ошибок
|
| 190 |
+
new_history = history + [
|
| 191 |
+
{"role": "user", "content": message},
|
| 192 |
+
{"role": "assistant", "content": f"**❌ Unexpected error:** {e}"}
|
| 193 |
+
]
|
| 194 |
+
|
| 195 |
+
return new_history, ""
|
| 196 |
+
|
| 197 |
+
def reset_chat():
|
| 198 |
+
"""Сброс чата с логированием"""
|
| 199 |
+
logger.info("Resetting chat session")
|
| 200 |
+
orchestrator.reset_conversation()
|
| 201 |
+
return [], "" # Возвращаем пустую историю
|
| 202 |
+
|
| 203 |
+
def get_session_info():
|
| 204 |
+
"""Получение информации о текущей сессии"""
|
| 205 |
+
try:
|
| 206 |
+
# logger.warning(f"Summary type: {type(orchestrator.get_conversation_summary())} _
|
| 207 |
+
# Summary: {orchestrator.get_conversation_summary()}")
|
| 208 |
+
summary = orchestrator.get_conversation_summary()
|
| 209 |
+
logger.info(f"Getting session summary: {summary}")
|
| 210 |
+
|
| 211 |
+
return f"""**Hybrid Session Info:**
|
| 212 |
+
- ID: {summary['session_id']}
|
| 213 |
+
- Step: {summary['current_step']}
|
| 214 |
+
- Has Plot: {'✅' if summary.get('has_plot', False) else '❌'}
|
| 215 |
+
- Has Overview: {'✅' if summary.get('has_overview', False) else '❌'}
|
| 216 |
+
- Has Recommendations: {'✅' if summary.get('has_recommendations', False) else '❌'}
|
| 217 |
+
- Total Results: {summary.get('total_search_results', 0)}
|
| 218 |
+
"""
|
| 219 |
+
|
| 220 |
+
except Exception as e:
|
| 221 |
+
logger.error(f"Error in get_session_info: {e}")
|
| 222 |
+
return f"Error getting session info: {e}"
|
| 223 |
+
|
| 224 |
+
def force_refresh_session_info():
|
| 225 |
+
"""Принудительное обновление с логированием состояния"""
|
| 226 |
+
try:
|
| 227 |
+
# ✅ Дополнительное логирование для отладки
|
| 228 |
+
logger.info("Force refreshing session info...")
|
| 229 |
+
logger.info(f"Current orchestrator state: {orchestrator.conversation_state}")
|
| 230 |
+
|
| 231 |
+
summary = orchestrator.get_conversation_summary()
|
| 232 |
+
logger.info(f"Retrieved summary: {summary}")
|
| 233 |
+
|
| 234 |
+
return get_session_info()
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.error(f"Error in force refresh: {e}")
|
| 237 |
+
return f"Refresh error: {e}"
|
| 238 |
+
|
| 239 |
+
# Создание интерфейса Gradio
|
| 240 |
+
with gr.Blocks(title="🎬 Movie Plot Search", theme=gr.themes.Soft()) as demo:
|
| 241 |
+
gr.Markdown("""
|
| 242 |
+
# 🎬 Movie Plot Search Engine
|
| 243 |
+
|
| 244 |
+
**🏗️ Architecture:**
|
| 245 |
+
🖥️ **UI**: Local Gradio interface;
|
| 246 |
+
⚡ **Agents**: Running on Modal Cloud;
|
| 247 |
+
🤖 **LLM**: Nebius AI Studio API (Llama-3.3-70B-Instruct).
|
| 248 |
+
|
| 249 |
+
****The essence of the project:****
|
| 250 |
+
*Describe the plot of the story in English, and the System will search the database for three films with a
|
| 251 |
+
similar script.* \n\n
|
| 252 |
+
The system uses multi-agent architecture with GPU acceleration for optimal performance.
|
| 253 |
+
|
| 254 |
+
**🤖 Powered by:** Nebius AI Studio | Modal Labs | FAISS | LlamaIndex ReAct Agents
|
| 255 |
+
""")
|
| 256 |
+
|
| 257 |
+
with gr.Row():
|
| 258 |
+
with gr.Column(scale=4):
|
| 259 |
+
# Добавляем type='messages'
|
| 260 |
+
chatbot = gr.Chatbot(
|
| 261 |
+
value=[],
|
| 262 |
+
height=600,
|
| 263 |
+
label="🎬 Conversation with AI Agents (Local UI → Modal Agents → Nebius LLM)",
|
| 264 |
+
show_copy_button=True,
|
| 265 |
+
type='messages' # Новый формат сообщений
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
msg = gr.Textbox(
|
| 269 |
+
placeholder="Describe a movie plot (50-100 words in English)...",
|
| 270 |
+
label="Your message",
|
| 271 |
+
lines=3,
|
| 272 |
+
max_lines=5
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
with gr.Row():
|
| 276 |
+
submit_btn = gr.Button("🚀 Submit", variant="primary", scale=2)
|
| 277 |
+
clear_btn = gr.Button("🔄 Clear Chat", scale=1)
|
| 278 |
+
|
| 279 |
+
with gr.Column(scale=1):
|
| 280 |
+
gr.Markdown("""
|
| 281 |
+
### 🔍 How to use:
|
| 282 |
+
|
| 283 |
+
1. **📝 Describe the plot** (50-100 words in English)
|
| 284 |
+
2. **✅ Agent Editor validates** (length) and improves your description (grammar)
|
| 285 |
+
3. **🎬 Agent Film Critic** creates a movie overview based on your story
|
| 286 |
+
4. **🔍 System searches** the database for 10 films that correlate with your description
|
| 287 |
+
5. **🎯 Agent Film Expert selects** top 3 movies with explanations
|
| 288 |
+
|
| 289 |
+
### 📋 Requirements:
|
| 290 |
+
- ✅ English text only
|
| 291 |
+
- ✅ 50-100 words
|
| 292 |
+
- ✅ Clear plot description
|
| 293 |
+
- ✅ Proper grammar (AI will help)
|
| 294 |
+
|
| 295 |
+
### ⚡ Features:
|
| 296 |
+
- 🚀 FAISS search
|
| 297 |
+
- 🧠 Multi-agent reasoning
|
| 298 |
+
- 📊 Semantic + narrative similarity
|
| 299 |
+
- 🎯 Expert film analysis
|
| 300 |
+
""")
|
| 301 |
+
|
| 302 |
+
session_info = gr.Textbox(
|
| 303 |
+
label="Session Info",
|
| 304 |
+
value=get_session_info(),
|
| 305 |
+
interactive=False,
|
| 306 |
+
lines=5
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
refresh_btn = gr.Button("🔄 Refresh Info", size="sm")
|
| 310 |
+
|
| 311 |
+
# Обработчики событий
|
| 312 |
+
submit_btn.click(
|
| 313 |
+
fn=chat_interface,
|
| 314 |
+
inputs=[msg, chatbot],
|
| 315 |
+
outputs=[chatbot, msg]
|
| 316 |
+
).then( # ✅ ДОБАВЛЕНО: Автообновление после отправки
|
| 317 |
+
fn=get_session_info,
|
| 318 |
+
outputs=[session_info]
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
msg.submit(
|
| 322 |
+
fn=chat_interface,
|
| 323 |
+
inputs=[msg, chatbot],
|
| 324 |
+
outputs=[chatbot, msg]
|
| 325 |
+
).then( # ✅ ДОБАВЛЕНО: Автообновление после Enter
|
| 326 |
+
fn=get_session_info,
|
| 327 |
+
outputs=[session_info]
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
clear_btn.click(
|
| 331 |
+
fn=reset_chat,
|
| 332 |
+
outputs=[chatbot, msg]
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
refresh_btn.click(
|
| 336 |
+
fn=force_refresh_session_info,
|
| 337 |
+
outputs=[session_info]
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Запуск приложения
|
| 341 |
+
logger.info("Starting Movie Plot Search application")
|
| 342 |
+
demo.launch(
|
| 343 |
+
server_name="127.0.0.1",
|
| 344 |
+
server_port=7860,
|
| 345 |
+
share=True,
|
| 346 |
+
debug=False,
|
| 347 |
+
show_error=True
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
# --- Функция запуска через Modal (если нужно полностью на Modal)---
|
| 352 |
+
@app.function(secrets=[modal.Secret.from_name("nebius-secret")])
|
| 353 |
+
def run_app():
|
| 354 |
+
_run_main_app()
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
# --- Локальный запуск ---
|
| 358 |
+
if __name__ == "__main__":
|
| 359 |
+
_run_main_app()
|
evaluation/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluation module for shadow testing and LLM-as-a-Judge quality assessment.
|
| 3 |
+
"""
|
evaluation/judges.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# evaluation/judges.py
|
| 2 |
+
from agents.nebius_simple import create_nebius_llm
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PersuasionJudge:
|
| 11 |
+
def __init__(self, api_key):
|
| 12 |
+
self.llm = create_nebius_llm(api_key, model="meta-llama/Llama-3.3-70B-Instruct", temperature=0.0)
|
| 13 |
+
|
| 14 |
+
def evaluate_expert_skill(self, user_story: str, expert_card: str, bridges: list) -> dict:
|
| 15 |
+
"""
|
| 16 |
+
Оценивает мастерство Агента-Эксперта в написании рекомендаций.
|
| 17 |
+
"""
|
| 18 |
+
bridges_str = "\n- ".join(bridges)
|
| 19 |
+
|
| 20 |
+
prompt = f"""
|
| 21 |
+
Act as a Senior Editor for a Movie Magazine.
|
| 22 |
+
Evaluate the quality and persuasiveness of the AI Critic's recommendation.
|
| 23 |
+
|
| 24 |
+
CONTEXT:
|
| 25 |
+
The User provided a detailed story (50+ words).
|
| 26 |
+
The AI Agent recommended a movie and wrote a "Justification".
|
| 27 |
+
|
| 28 |
+
--- USER STORY ---
|
| 29 |
+
"{user_story}"
|
| 30 |
+
|
| 31 |
+
--- AI RECOMMENDATION CARD ---
|
| 32 |
+
{expert_card}
|
| 33 |
+
|
| 34 |
+
--- EVALUATION TASK ---
|
| 35 |
+
Check if the AI Agent successfully built "Narrative Bridges" - connecting specific details from the User's story to the Movie.
|
| 36 |
+
|
| 37 |
+
Expected Bridges (The agent SHOULD mention these connections):
|
| 38 |
+
- {bridges_str}
|
| 39 |
+
|
| 40 |
+
Rate the Agent on 3 metrics (1-5 stars):
|
| 41 |
+
|
| 42 |
+
1. **Context Awareness (1-5)**: Did the agent reference specific details from the user's text (e.g. "You mentioned a botanist...") or did it use a generic template?
|
| 43 |
+
2. **Persuasiveness (1-5)**: Is the argument convincing? Does it explain WHY this movie matches the user's specific plot?
|
| 44 |
+
3. **Bridge Coverage (0-100%)**: What percentage of the "Expected Bridges" were explicitly addressed?
|
| 45 |
+
|
| 46 |
+
OUTPUT JSON ONLY:
|
| 47 |
+
{{
|
| 48 |
+
"context_score": int,
|
| 49 |
+
"persuasiveness_score": int,
|
| 50 |
+
"bridge_coverage_percent": int,
|
| 51 |
+
"missing_bridges": ["list of missed points"],
|
| 52 |
+
"feedback": "Short critique for the agent"
|
| 53 |
+
}}
|
| 54 |
+
"""
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
response = self.llm.complete(prompt).text
|
| 58 |
+
cleaned = re.sub(r"```json|```", "", response).strip()
|
| 59 |
+
return json.loads(cleaned)
|
| 60 |
+
except Exception as e:
|
| 61 |
+
return {"error": str(e), "persuasiveness_score": 0}
|
| 62 |
+
|
| 63 |
+
def evaluate_real_world_interaction(self, user_story: str, expert_card: str, movie_metadata: dict) -> dict:
|
| 64 |
+
"""
|
| 65 |
+
Оценка реального диалога (Reference-Free).
|
| 66 |
+
Проверяет обоснованность (Groundedness) и логичность, не зная "правильного" ответа.
|
| 67 |
+
Разделяем "галлюцинации фактов" и "слабые тематические связи"
|
| 68 |
+
"""
|
| 69 |
+
# Превращаем метаданные в текст для промпта
|
| 70 |
+
facts_str = json.dumps({
|
| 71 |
+
"title": movie_metadata.get("title"),
|
| 72 |
+
"director": movie_metadata.get("director"),
|
| 73 |
+
"cast": movie_metadata.get("cast"),
|
| 74 |
+
"genres": movie_metadata.get("genres"),
|
| 75 |
+
"plot_keywords": movie_metadata.get("narrative_features", "")
|
| 76 |
+
}, ensure_ascii=False)
|
| 77 |
+
|
| 78 |
+
prompt = f"""
|
| 79 |
+
You are an AI Auditor monitoring a Movie Recommendation System in production.
|
| 80 |
+
Your goal is to detect **Factual Hallucinations** and evaluate **Logical Coherence**.
|
| 81 |
+
|
| 82 |
+
--- INPUT DATA ---
|
| 83 |
+
1. USER STORY: "{user_story}"
|
| 84 |
+
2. REAL MOVIE FACTS (Ground Truth): {facts_str}
|
| 85 |
+
3. AGENT'S RECOMMENDATION TEXT: "{expert_card}"
|
| 86 |
+
|
| 87 |
+
--- AUDIT TASKS ---
|
| 88 |
+
**IMPORTANT DISTINCTION:**
|
| 89 |
+
- **Hallucination = Inventing facts that contradict Movie Facts** (e.g., wrong actors, wrong plot events)
|
| 90 |
+
- **Weak connection ≠ Hallucination** (e.g., "both are comedies with quirky characters" is NOT a hallucination,
|
| 91 |
+
just a thematic bridge)
|
| 92 |
+
1. **Check Groundedness (Faithfulness)**:
|
| 93 |
+
- Did the Agent mention any actors, directors, or plot details that CONTRADICT the Movie Facts?
|
| 94 |
+
- If the Agent describes plot events NOT in the movie's overview, that is a HALLUCINATION.
|
| 95 |
+
- If the Agent says "both films share a genre/mood/theme", that is NOT a hallucination.
|
| 96 |
+
- Score 0 (False claims) to 1 (Fully supported by facts).
|
| 97 |
+
|
| 98 |
+
2. **Check Logical Link**:
|
| 99 |
+
- Does the Agent clearly explain *how* the movie connects to the User Story?
|
| 100 |
+
- Thematic connections ("both explore loneliness", "both are comedies") are VALID bridges.
|
| 101 |
+
- Score 1 (Vague) to 5 (Strong logic).
|
| 102 |
+
|
| 103 |
+
3. **Hallucination Detection**:
|
| 104 |
+
- Set `hallucination_detected: true` ONLY if the Agent invented false factual claims.
|
| 105 |
+
- Examples of hallucinations: wrong actors, fabricated plot events, fake quotes.
|
| 106 |
+
- Examples of NOT hallucinations: "both films share comedic tone", "similar narrative structure",
|
| 107 |
+
"focuses on same themes".
|
| 108 |
+
|
| 109 |
+
OUTPUT JSON ONLY:
|
| 110 |
+
{{
|
| 111 |
+
"groundedness_score": float,
|
| 112 |
+
"coherence_score": int,
|
| 113 |
+
"hallucination_detected": boolean,
|
| 114 |
+
"hallucination_details": "string (what was invented?) or null",
|
| 115 |
+
"reasoning": "Short audit report"
|
| 116 |
+
}}
|
| 117 |
+
"""
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
response = self.llm.complete(prompt).text
|
| 121 |
+
# Очистка JSON
|
| 122 |
+
cleaned = re.sub(r"```json|```", "", response).strip()
|
| 123 |
+
return json.loads(cleaned)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"Shadow eval failed: {e}")
|
| 126 |
+
return {"error": str(e), "groundedness_score": 0}
|
evaluation/run_evals.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# evaluation/run_evals.py
|
| 2 |
+
import asyncio
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import datetime
|
| 6 |
+
from agents.retriever import RetrieverAgent
|
| 7 |
+
from agents.modal_agents import process_expert_agent
|
| 8 |
+
from evaluation.judges import PersuasionJudge
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def run_persuasion_eval():
|
| 12 |
+
nebius_key = os.environ.get("NEBIUS_API_KEY")
|
| 13 |
+
if not nebius_key:
|
| 14 |
+
print("❌ Error: NEBIUS_API_KEY environment variable is not set.")
|
| 15 |
+
return
|
| 16 |
+
|
| 17 |
+
# Инициализация
|
| 18 |
+
judge = PersuasionJudge(nebius_key)
|
| 19 |
+
retriever = RetrieverAgent()
|
| 20 |
+
|
| 21 |
+
# Загрузка датасета
|
| 22 |
+
try:
|
| 23 |
+
with open("evaluation/golden_dataset.json", "r") as f:
|
| 24 |
+
dataset = json.load(f)
|
| 25 |
+
except FileNotFoundError:
|
| 26 |
+
print("❌ Error: evaluation/golden_dataset.json not found.")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
print(f"🕵️ Starting Evaluation on {len(dataset)} scenarios...")
|
| 30 |
+
|
| 31 |
+
report = {
|
| 32 |
+
"timestamp": datetime.datetime.utcnow().isoformat(),
|
| 33 |
+
"total_cases": len(dataset),
|
| 34 |
+
"results": [],
|
| 35 |
+
"summary": {}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
total_persuasiveness = 0
|
| 39 |
+
total_context_score = 0
|
| 40 |
+
|
| 41 |
+
for case in dataset:
|
| 42 |
+
print(f"\nProcessing CASE ID: {case['id']}...")
|
| 43 |
+
|
| 44 |
+
# 1. Поиск
|
| 45 |
+
retrieval = retriever.retrieve_candidates(case['query'], top_k=10)
|
| 46 |
+
candidates = retrieval.get("candidates", [])
|
| 47 |
+
|
| 48 |
+
# 2. Эксперт
|
| 49 |
+
if candidates:
|
| 50 |
+
# Используем .remote для синхронного вызова (или aio для асинхронного, если настроено)
|
| 51 |
+
# В локальном скрипте проще использовать синхронный вызов к remote функции
|
| 52 |
+
expert_result = process_expert_agent.remote(case['query'], candidates)
|
| 53 |
+
expert_text = str(expert_result)
|
| 54 |
+
if isinstance(expert_result, dict):
|
| 55 |
+
expert_text = expert_result.get("explanations", str(expert_result))
|
| 56 |
+
else:
|
| 57 |
+
expert_text = ""
|
| 58 |
+
|
| 59 |
+
# 3. Судья
|
| 60 |
+
verdict = judge.evaluate_expert_skill(
|
| 61 |
+
user_story=case['query'],
|
| 62 |
+
expert_card=expert_text,
|
| 63 |
+
bridges=case.get('key_narrative_bridges', [])
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Сбор метрик
|
| 67 |
+
p_score = verdict.get('persuasiveness_score', 0)
|
| 68 |
+
c_score = verdict.get('context_score', 0)
|
| 69 |
+
total_persuasiveness += p_score
|
| 70 |
+
total_context_score += c_score
|
| 71 |
+
|
| 72 |
+
print(f" Score: {p_score}/5 | Context: {c_score}/5")
|
| 73 |
+
|
| 74 |
+
# Добавляем в отчет
|
| 75 |
+
report["results"].append({
|
| 76 |
+
"case_id": case["id"],
|
| 77 |
+
"query": case["query"],
|
| 78 |
+
"expected_movie": case.get("expected_movie"),
|
| 79 |
+
"expert_output_snippet": expert_text[:200] + "...",
|
| 80 |
+
"scores": verdict,
|
| 81 |
+
"candidates_found": len(candidates)
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
# Итоговая статистика
|
| 85 |
+
report["summary"] = {
|
| 86 |
+
"avg_persuasiveness": round(total_persuasiveness / len(dataset), 2),
|
| 87 |
+
"avg_context_aware": round(total_context_score / len(dataset), 2)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Сохранение в файл
|
| 91 |
+
filename = f"evaluation/report_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
| 92 |
+
with open(filename, "w") as f:
|
| 93 |
+
json.dump(report, f, indent=2, ensure_ascii=False)
|
| 94 |
+
|
| 95 |
+
print(f"\n✅ Evaluation Complete!")
|
| 96 |
+
print(f"🏆 Average Persuasiveness: {report['summary']['avg_persuasiveness']}/5")
|
| 97 |
+
print(f"📄 Full report saved to: {filename}")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
asyncio.run(run_persuasion_eval())
|
memory/session_store.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# memory/session_store.py
|
| 2 |
+
import uuid
|
| 3 |
+
import datetime
|
| 4 |
+
import logging
|
| 5 |
+
from typing import Dict, Any, Optional
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class SessionStore:
|
| 11 |
+
"""
|
| 12 |
+
Хранилище состояния сессии.
|
| 13 |
+
Полностью поддерживает логику: 1/0/2 меню, Custom Plot Mode и счетчики попыток.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self._sessions: Dict[str, Dict[str, Any]] = {}
|
| 18 |
+
|
| 19 |
+
def create_session(self) -> str:
|
| 20 |
+
"""Создает новую сессию с полным набором полей"""
|
| 21 |
+
session_id = str(uuid.uuid4())
|
| 22 |
+
self._sessions[session_id] = {
|
| 23 |
+
"session_id": session_id,
|
| 24 |
+
"created_at": datetime.datetime.utcnow().isoformat(),
|
| 25 |
+
|
| 26 |
+
# --- Логика машины состояний ---
|
| 27 |
+
"step": "initial",
|
| 28 |
+
"attempts": 0,
|
| 29 |
+
"suggested_plot": None, # Хранит текст предложенной истории
|
| 30 |
+
"custom_plot_mode": False, # ✅ Флаг строгого режима (критично для вашей логики)
|
| 31 |
+
|
| 32 |
+
# --- Данные диалога ---
|
| 33 |
+
"history": [],
|
| 34 |
+
"user_inputs": [],
|
| 35 |
+
|
| 36 |
+
# --- Результаты текущего поиска ---
|
| 37 |
+
"original_plot": "",
|
| 38 |
+
"improved_plot": "",
|
| 39 |
+
"movie_overview": "",
|
| 40 |
+
"search_results": [],
|
| 41 |
+
"final_recommendations": []
|
| 42 |
+
}
|
| 43 |
+
return session_id
|
| 44 |
+
|
| 45 |
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 46 |
+
"""Возвращает копию состояния сессии"""
|
| 47 |
+
return self._sessions.get(session_id)
|
| 48 |
+
|
| 49 |
+
# --- Методы управления состоянием ---
|
| 50 |
+
|
| 51 |
+
def add_user_input(self, session_id: str, text: str):
|
| 52 |
+
if session_id in self._sessions:
|
| 53 |
+
self._sessions[session_id]["user_inputs"].append(text)
|
| 54 |
+
|
| 55 |
+
def add_history(self, session_id: str, user_msg: str, assistant_msg: str):
|
| 56 |
+
if session_id in self._sessions:
|
| 57 |
+
self._sessions[session_id]["history"].append({"role": "user", "content": user_msg})
|
| 58 |
+
self._sessions[session_id]["history"].append({"role": "assistant", "content": assistant_msg})
|
| 59 |
+
|
| 60 |
+
def update_state(self, session_id: str, key: str, value: Any):
|
| 61 |
+
"""Универсальное обновление поля"""
|
| 62 |
+
if session_id in self._sessions:
|
| 63 |
+
self._sessions[session_id][key] = value
|
| 64 |
+
|
| 65 |
+
def increment_attempts(self, session_id: str):
|
| 66 |
+
if session_id in self._sessions:
|
| 67 |
+
self._sessions[session_id]["attempts"] += 1
|
| 68 |
+
|
| 69 |
+
def reset_attempts(self, session_id: str):
|
| 70 |
+
if session_id in self._sessions:
|
| 71 |
+
self._sessions[session_id]["attempts"] = 0
|
| 72 |
+
|
| 73 |
+
def set_custom_mode(self, session_id: str, is_active: bool):
|
| 74 |
+
"""Управление флагом строгого режима"""
|
| 75 |
+
if session_id in self._sessions:
|
| 76 |
+
self._sessions[session_id]["custom_plot_mode"] = is_active
|
| 77 |
+
|
| 78 |
+
def clear_session_data(self, session_id: str):
|
| 79 |
+
"""Мягкий сброс (для кнопки Reset)"""
|
| 80 |
+
if session_id in self._sessions:
|
| 81 |
+
base = self._sessions[session_id]
|
| 82 |
+
# Сбрасываем динамические данные
|
| 83 |
+
base["history"] = []
|
| 84 |
+
base["user_inputs"] = []
|
| 85 |
+
base["original_plot"] = ""
|
| 86 |
+
base["final_recommendations"] = []
|
| 87 |
+
base["attempts"] = 0
|
| 88 |
+
base["suggested_plot"] = None
|
| 89 |
+
base["custom_plot_mode"] = False
|
modal_app.py
ADDED
|
@@ -0,0 +1,1075 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import modal
|
| 2 |
+
from modal import Image, App, Volume, Secret
|
| 3 |
+
import logging
|
| 4 |
+
# import modal
|
| 5 |
+
import faiss
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pandas as pd
|
| 8 |
+
import pickle
|
| 9 |
+
from sentence_transformers import SentenceTransformer
|
| 10 |
+
import torch
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
from agents.modal_agents import app as agents_app
|
| 14 |
+
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
image = (
|
| 19 |
+
Image.from_registry(
|
| 20 |
+
"nvidia/cuda:12.8.1-devel-ubuntu22.04",
|
| 21 |
+
add_python="3.11"
|
| 22 |
+
)
|
| 23 |
+
.apt_install(
|
| 24 |
+
"build-essential",
|
| 25 |
+
"python3-dev",
|
| 26 |
+
"gcc",
|
| 27 |
+
"g++",
|
| 28 |
+
"cmake",
|
| 29 |
+
"wget", # Добавляем wget
|
| 30 |
+
"unzip" # Добавляем unzip для распаковки
|
| 31 |
+
)
|
| 32 |
+
.pip_install_from_requirements("requirements_modal.txt")
|
| 33 |
+
.add_local_file(
|
| 34 |
+
local_path="setup_image.py",
|
| 35 |
+
remote_path="/root/setup_image.py",
|
| 36 |
+
copy=True
|
| 37 |
+
)
|
| 38 |
+
# ✅ ДОБАВЛЕНО: Добавляем скрипт извлечения punkt данных
|
| 39 |
+
.add_local_file(
|
| 40 |
+
local_path="setup_punkt_extraction.py",
|
| 41 |
+
remote_path="/root/setup_punkt_extraction.py",
|
| 42 |
+
copy=True
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
.run_commands("python /root/setup_image.py")
|
| 46 |
+
.run_commands("python -m spacy download en_core_web_lg")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
.run_commands(
|
| 50 |
+
# Скачиваем ресурс punkt
|
| 51 |
+
"wget -O /tmp/punkt.zip https://raw.githubusercontent.com/nltk/nltk_data/gh-pages/packages/tokenizers/punkt.zip",
|
| 52 |
+
"unzip /tmp/punkt.zip -d /tmp",
|
| 53 |
+
|
| 54 |
+
# Создаем структуру директорий
|
| 55 |
+
"mkdir -p /root/nltk_data/tokenizers/punkt_tab/english",
|
| 56 |
+
|
| 57 |
+
# Копируем основные файлы
|
| 58 |
+
"cp /tmp/punkt/PY3/english.pickle /root/nltk_data/tokenizers/punkt_tab/english/",
|
| 59 |
+
"cp /tmp/punkt/README /root/nltk_data/tokenizers/punkt_tab/",
|
| 60 |
+
"cp -r /tmp/punkt/PY3 /root/nltk_data/tokenizers/punkt_tab/",
|
| 61 |
+
|
| 62 |
+
# ✅ КЛЮЧЕВОЕ ИЗМЕНЕНИЕ: Запускаем скрипт извлечения данных
|
| 63 |
+
"python /root/setup_punkt_extraction.py",
|
| 64 |
+
|
| 65 |
+
# Удаляем временные файлы
|
| 66 |
+
"rm -rf /tmp/punkt*"
|
| 67 |
+
)
|
| 68 |
+
.add_local_dir("modal_utils", remote_path="/root/modal_utils")
|
| 69 |
+
# .add_local_dir("local_utils", remote_path="/root/local_utils")
|
| 70 |
+
.add_local_dir("agents", remote_path="/root/agents")
|
| 71 |
+
.add_local_dir("evaluation", remote_path="/root/evaluation")
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
app = App(
|
| 75 |
+
name="tmdb-project",
|
| 76 |
+
image=image,
|
| 77 |
+
secrets=[
|
| 78 |
+
Secret.from_name("my-env"), # Для конфиденциальных данных
|
| 79 |
+
Secret.from_name("nebius-secret")
|
| 80 |
+
]
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Включаем все функции агентов в основной app
|
| 84 |
+
app.include(agents_app)
|
| 85 |
+
|
| 86 |
+
volume = Volume.from_name("tmdb-data", create_if_missing=True)
|
| 87 |
+
|
| 88 |
+
# ✅ ДОБАВЛЯЕМ СЛОВАРЬ ДЛЯ СЕМАФОРА
|
| 89 |
+
# Он будет хранить активные сессии: {session_id: timestamp}
|
| 90 |
+
active_users_dict = modal.Dict.from_name("cinematch-active-users", create_if_missing=True)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.function(
|
| 94 |
+
volumes={"/data": volume},
|
| 95 |
+
gpu="A10G",
|
| 96 |
+
timeout=3600
|
| 97 |
+
)
|
| 98 |
+
def process_movies():
|
| 99 |
+
"""Основная функция обработки фильмов"""
|
| 100 |
+
# Импорт внутри функции для работы с добавленными директориями
|
| 101 |
+
from modal_utils.cloud_operations import heavy_computation
|
| 102 |
+
return heavy_computation()
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@app.function(volumes={"/data": volume})
|
| 106 |
+
def upload_file(data_str: str):
|
| 107 |
+
import shutil
|
| 108 |
+
import os
|
| 109 |
+
|
| 110 |
+
volume.listdir(path='/', recursive=True)
|
| 111 |
+
print(volume.listdir(path='/', recursive=True))
|
| 112 |
+
|
| 113 |
+
# local_file_path = 'temp_sample.csv' # Используйте временный файл
|
| 114 |
+
remote_file_path = '/data/input.csv' # Путь в Volume
|
| 115 |
+
|
| 116 |
+
print(1)
|
| 117 |
+
# Создаем директорию, если нужно
|
| 118 |
+
os.makedirs(os.path.dirname(remote_file_path), exist_ok=True)
|
| 119 |
+
|
| 120 |
+
# Записываем данные напрямую в файл
|
| 121 |
+
with open(remote_file_path, "w") as f:
|
| 122 |
+
f.write(data_str)
|
| 123 |
+
|
| 124 |
+
print(f"Данные успешно записаны в Volume: {remote_file_path}")
|
| 125 |
+
return remote_file_path
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.function(
|
| 129 |
+
image=image,
|
| 130 |
+
gpu="A10G", # было any
|
| 131 |
+
volumes={"/data": volume},
|
| 132 |
+
timeout=120 # было 1800 == 30 минут на батч
|
| 133 |
+
)
|
| 134 |
+
def process_batch(batch: list[tuple]):
|
| 135 |
+
"""
|
| 136 |
+
Обрабатывает батч данных на GPU
|
| 137 |
+
Вход: список кортежей (processed_text, original_text)
|
| 138 |
+
Выход: список JSON-строк с признаками
|
| 139 |
+
"""
|
| 140 |
+
import spacy
|
| 141 |
+
from textacy.extract import keyterms
|
| 142 |
+
from textblob import TextBlob
|
| 143 |
+
import numpy as np
|
| 144 |
+
import json
|
| 145 |
+
import en_core_web_lg # Прямой импорт модели
|
| 146 |
+
import torch
|
| 147 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 148 |
+
|
| 149 |
+
torch.set_num_threads(1) # Уменьшаем число CPU потоков
|
| 150 |
+
spacy.prefer_gpu() # Активирует GPU для spaCy
|
| 151 |
+
|
| 152 |
+
# Загружаем модель
|
| 153 |
+
nlp = en_core_web_lg.load()
|
| 154 |
+
|
| 155 |
+
# Добавляем sentencizer, если его нет в пайплайне
|
| 156 |
+
if "sentencizer" not in nlp.pipe_names:
|
| 157 |
+
nlp.add_pipe("sentencizer")
|
| 158 |
+
|
| 159 |
+
processed_texts = [item[0] for item in batch]
|
| 160 |
+
original_texts = [item[1] for item in batch]
|
| 161 |
+
|
| 162 |
+
# Обработка предобработанных текстов (Оптимизированная обработка spaCy)
|
| 163 |
+
# processed_docs = list(nlp.pipe(processed_texts, batch_size=128))
|
| 164 |
+
processed_docs = list(nlp.pipe(processed_texts, batch_size=4096)) # Увеличьте для GPU - было 400 для CPU
|
| 165 |
+
|
| 166 |
+
# Функция для параллельного вычисления эмоциональной вариативности (sentiment variance)
|
| 167 |
+
def compute_sentiment_variance(text):
|
| 168 |
+
if not text or len(text) < 20:
|
| 169 |
+
return 0.0
|
| 170 |
+
|
| 171 |
+
try:
|
| 172 |
+
blob = TextBlob(text)
|
| 173 |
+
if len(blob.sentences) > 1:
|
| 174 |
+
sentiments = [s.sentiment.polarity for s in blob.sentences]
|
| 175 |
+
return float(np.var(sentiments))
|
| 176 |
+
return 0.0
|
| 177 |
+
except:
|
| 178 |
+
return 0.0
|
| 179 |
+
|
| 180 |
+
# Параллельное вычисление для всего батча
|
| 181 |
+
with ThreadPoolExecutor(max_workers=16) as executor:
|
| 182 |
+
sentiment_variances = list(executor.map(compute_sentiment_variance, original_texts))
|
| 183 |
+
|
| 184 |
+
# Предварительно вычисляем plot_turns для всего батча
|
| 185 |
+
turn_keywords = {"but", "however", "though", "although", "nevertheless",
|
| 186 |
+
"suddenly", "unexpectedly", "surprisingly", "abruptly"}
|
| 187 |
+
|
| 188 |
+
# Векторизованный расчет plot_turns.
|
| 189 |
+
# Используем более эффективный метод
|
| 190 |
+
lower_texts = [text.lower() for text in original_texts]
|
| 191 |
+
plot_turns_counts = [
|
| 192 |
+
sum(text.count(kw) for kw in turn_keywords)
|
| 193 |
+
if text and len(text) >= 20 else 0
|
| 194 |
+
for text in lower_texts
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
results = []
|
| 198 |
+
for i, (processed_doc, original_text) in enumerate(zip(processed_docs, original_texts)):
|
| 199 |
+
features = {
|
| 200 |
+
"conflict_keywords": [],
|
| 201 |
+
"plot_turns": plot_turns_counts[i], # Используем предвычисленное значение (было 0, вычислялось позже)
|
| 202 |
+
"sentiment_variance": sentiment_variances[i], # Используем предвычисленное значение (было 0.0)
|
| 203 |
+
"action_density": 0.0
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
try:
|
| 207 |
+
# 1. Ключевые слова конфликта
|
| 208 |
+
if processed_texts[i] and len(processed_texts[i]) >= 20:
|
| 209 |
+
conflict_terms = [
|
| 210 |
+
term for term, score in keyterms.textrank(
|
| 211 |
+
processed_doc,
|
| 212 |
+
topn=5,
|
| 213 |
+
window_size=10,
|
| 214 |
+
edge_weighting="count",
|
| 215 |
+
position_bias=False
|
| 216 |
+
) if term and term.strip()
|
| 217 |
+
]
|
| 218 |
+
features["conflict_keywords"] = conflict_terms
|
| 219 |
+
|
| 220 |
+
# Плотность действий
|
| 221 |
+
if processed_doc and len(processed_doc) > 0:
|
| 222 |
+
action_verbs = sum(1 for token in processed_doc if token.pos_ == "VERB")
|
| 223 |
+
features["action_density"] = action_verbs / len(processed_doc)
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"Error processing item {i}: {str(e)[:100]}")
|
| 227 |
+
|
| 228 |
+
results.append(json.dumps(features))
|
| 229 |
+
|
| 230 |
+
return results
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@app.function(
|
| 234 |
+
image=image,
|
| 235 |
+
volumes={"/data": volume},
|
| 236 |
+
# memory=6144, # Увеличиваем память до 6 ГБ
|
| 237 |
+
timeout=600 # 150 минут вместо 60 секунд
|
| 238 |
+
)
|
| 239 |
+
def load_data(max_rows: int = None):
|
| 240 |
+
"""Загружает данные из CSV на Volume"""
|
| 241 |
+
import pandas as pd
|
| 242 |
+
|
| 243 |
+
# file_path = "/data/data/output.csv"
|
| 244 |
+
file_path = "/data/data/output.parquet" # Теперь используем Parquet
|
| 245 |
+
print(f"Loading data from {file_path}...")
|
| 246 |
+
|
| 247 |
+
# Чтение данных с возможностью ограничения количества строк
|
| 248 |
+
if max_rows:
|
| 249 |
+
# df = pd.read_csv(file_path, nrows=max_rows)
|
| 250 |
+
df = pd.read_parquet(file_path, rows=max_rows)
|
| 251 |
+
else:
|
| 252 |
+
# Чтение всего файла
|
| 253 |
+
df = pd.read_parquet(file_path)
|
| 254 |
+
# df = pd.read_csv(file_path)
|
| 255 |
+
|
| 256 |
+
print(f"Loaded {len(df)} records")
|
| 257 |
+
|
| 258 |
+
# Проверка необходимых столбцов
|
| 259 |
+
required_columns = ['processed_overview', 'overview']
|
| 260 |
+
for col in required_columns:
|
| 261 |
+
if col not in df.columns:
|
| 262 |
+
raise ValueError(f"Column '{col}' not found in dataset")
|
| 263 |
+
print(f'Columns check finished')
|
| 264 |
+
|
| 265 |
+
# Заполнение пропущенных значений
|
| 266 |
+
df['processed_overview'] = df['processed_overview'].fillna('')
|
| 267 |
+
df['overview'] = df['overview'].fillna('')
|
| 268 |
+
print(f'Missing values filling is finished')
|
| 269 |
+
|
| 270 |
+
return df
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@app.function(
|
| 274 |
+
image=image,
|
| 275 |
+
volumes={"/data": volume},
|
| 276 |
+
timeout=300 # 5 минут вместо 60 секунд
|
| 277 |
+
)
|
| 278 |
+
def save_results(df, output_path):
|
| 279 |
+
"""Сохраняет результаты на Volume"""
|
| 280 |
+
print(f"Saving results to {output_path}...")
|
| 281 |
+
# df.to_parquet(output_path, index=False)
|
| 282 |
+
df.to_parquet(output_path, index=False, engine='pyarrow') # или engine='fastparquet'
|
| 283 |
+
print(f"✅ Results saved to {output_path}")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
@app.local_entrypoint()
|
| 287 |
+
def process_test_batch(batch_size: int = 1000):
|
| 288 |
+
"""Обрабатывает тестовый батч из Volume"""
|
| 289 |
+
import json
|
| 290 |
+
# Загрузка данных
|
| 291 |
+
df = load_data.remote(max_rows=batch_size)
|
| 292 |
+
|
| 293 |
+
# Формирование батча
|
| 294 |
+
batch_data = list(zip(
|
| 295 |
+
df['processed_overview'].astype(str),
|
| 296 |
+
df['overview'].astype(str)
|
| 297 |
+
))
|
| 298 |
+
|
| 299 |
+
# Обработка батча
|
| 300 |
+
print(f"Processing test batch ({len(batch_data)} records) on GPU...")
|
| 301 |
+
results = process_batch.remote(batch_data)
|
| 302 |
+
|
| 303 |
+
# Добавление результатов
|
| 304 |
+
df['narrative_features'] = results
|
| 305 |
+
df['features_decoded'] = df['narrative_features'].apply(json.loads)
|
| 306 |
+
|
| 307 |
+
# Сохранение результатов
|
| 308 |
+
output_path = f"/data/data/test_batch_results_{batch_size}.parquet"
|
| 309 |
+
save_results.remote(df, output_path)
|
| 310 |
+
|
| 311 |
+
# Вывод статистики
|
| 312 |
+
print("\nProcessing statistics:")
|
| 313 |
+
print(
|
| 314 |
+
f"Conflict_keywords (non-empty): {sum(1 for x in df['features_decoded'] if x['conflict_keywords'])}/{len(df)}")
|
| 315 |
+
print(f"Avg plot_turns: {df['features_decoded'].apply(lambda x: x['plot_turns']).mean():.2f}")
|
| 316 |
+
print(f"Avg sentiment_variance: {df['features_decoded'].apply(lambda x: x['sentiment_variance']).mean():.4f}")
|
| 317 |
+
print(f"Avg action_density: {df['features_decoded'].apply(lambda x: x['action_density']).mean():.2f}")
|
| 318 |
+
|
| 319 |
+
print("\n✅ Test batch processing complete!")
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@app.local_entrypoint()
|
| 323 |
+
def show_sample_results(file_path: str = "/data/test_batch_results_1000.parquet"):
|
| 324 |
+
"""Показывает примеры результатов из файла на Volume"""
|
| 325 |
+
import json
|
| 326 |
+
|
| 327 |
+
# Загрузка результатов
|
| 328 |
+
@app.function(volumes={"/data": volume})
|
| 329 |
+
def load_results(path):
|
| 330 |
+
import pandas as pd
|
| 331 |
+
|
| 332 |
+
return pd.read_parquet(path)
|
| 333 |
+
|
| 334 |
+
df = load_results.remote(file_path)
|
| 335 |
+
|
| 336 |
+
# Добавление декодированных признаков
|
| 337 |
+
if 'narrative_features' in df.columns:
|
| 338 |
+
df['features_decoded'] = df['narrative_features'].apply(json.loads)
|
| 339 |
+
|
| 340 |
+
print(f"Results from {file_path} ({len(df)} records):")
|
| 341 |
+
|
| 342 |
+
# Вывод примеров
|
| 343 |
+
sample_size = min(3, len(df))
|
| 344 |
+
print(f"\nSample of {sample_size} records:")
|
| 345 |
+
for i, row in df.head(sample_size).iterrows():
|
| 346 |
+
print(f"\nRecord {i}:")
|
| 347 |
+
print(f"Processed: {row['processed_overview'][:100]}...")
|
| 348 |
+
print(f"Original: {row['overview'][:100]}...")
|
| 349 |
+
print("Features:")
|
| 350 |
+
features = row['features_decoded'] if 'features_decoded' in row else json.loads(row['narrative_features'])
|
| 351 |
+
for k, v in features.items():
|
| 352 |
+
print(f" {k}: {v}")
|
| 353 |
+
|
| 354 |
+
# Общая статистика
|
| 355 |
+
if 'features_decoded' in df.columns:
|
| 356 |
+
print("\nDataset statistics:")
|
| 357 |
+
print(f"Avg plot_turns: {df['features_decoded'].apply(lambda x: x['plot_turns']).mean():.2f}")
|
| 358 |
+
print(f"Avg sentiment_variance: {df['features_decoded'].apply(lambda x: x['sentiment_variance']).mean():.4f}")
|
| 359 |
+
print(f"Avg action_density: {df['features_decoded'].apply(lambda x: x['action_density']).mean():.2f}")
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
@app.function(
|
| 363 |
+
image=image,
|
| 364 |
+
volumes={"/data": volume},
|
| 365 |
+
timeout=3600, # 1 час на конвертацию
|
| 366 |
+
memory=8192 # 8 ГБ памяти
|
| 367 |
+
)
|
| 368 |
+
def convert_csv_to_parquet():
|
| 369 |
+
import pandas as pd
|
| 370 |
+
import pyarrow as pa
|
| 371 |
+
import pyarrow.parquet as pq
|
| 372 |
+
import pyarrow.csv as pc
|
| 373 |
+
from pathlib import Path
|
| 374 |
+
import time
|
| 375 |
+
|
| 376 |
+
start_time = time.time()
|
| 377 |
+
|
| 378 |
+
input_path = "/data/data/output.csv"
|
| 379 |
+
output_path = "/data/data/output.parquet"
|
| 380 |
+
|
| 381 |
+
print(f"Starting conversion: {input_path} -> {output_path}")
|
| 382 |
+
|
| 383 |
+
# Создаем директорию если нужно
|
| 384 |
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
| 385 |
+
|
| 386 |
+
# Читаем CSV с помощью PyArrow (оптимизировано для больших файлов)
|
| 387 |
+
reader = pc.open_csv(
|
| 388 |
+
input_path,
|
| 389 |
+
read_options=pc.ReadOptions(block_size=128 * 1024 * 1024), # 128MB блоки
|
| 390 |
+
parse_options=pc.ParseOptions(delimiter=",")
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
# Схема для записи Parquet
|
| 394 |
+
writer = None
|
| 395 |
+
|
| 396 |
+
# Обрабатываем данные порциями
|
| 397 |
+
batch_count = 0
|
| 398 |
+
while True:
|
| 399 |
+
try:
|
| 400 |
+
batch = reader.read_next_batch()
|
| 401 |
+
if not batch:
|
| 402 |
+
break
|
| 403 |
+
|
| 404 |
+
df = batch.to_pandas()
|
| 405 |
+
|
| 406 |
+
if writer is None:
|
| 407 |
+
# Инициализируем writer при первом батче
|
| 408 |
+
writer = pq.ParquetWriter(
|
| 409 |
+
output_path,
|
| 410 |
+
pa.Table.from_pandas(df).schema,
|
| 411 |
+
compression='SNAPPY'
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# Конвертируем в pyarrow Table и записываем
|
| 415 |
+
table = pa.Table.from_pandas(df)
|
| 416 |
+
writer.write_table(table)
|
| 417 |
+
|
| 418 |
+
batch_count += 1
|
| 419 |
+
print(f"Processed batch {batch_count} ({df.shape[0]} rows)")
|
| 420 |
+
|
| 421 |
+
except StopIteration:
|
| 422 |
+
break
|
| 423 |
+
|
| 424 |
+
# Финализируем запись
|
| 425 |
+
if writer:
|
| 426 |
+
writer.close()
|
| 427 |
+
|
| 428 |
+
duration = time.time() - start_time
|
| 429 |
+
print(f"✅ Conversion complete! Saved to {output_path}")
|
| 430 |
+
print(f"Total batches: {batch_count}")
|
| 431 |
+
print(f"Total time: {duration:.2f} seconds")
|
| 432 |
+
|
| 433 |
+
return output_path
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
@app.function(
|
| 437 |
+
image=image,
|
| 438 |
+
volumes={"/data": volume},
|
| 439 |
+
timeout=3600
|
| 440 |
+
)
|
| 441 |
+
def rebuild_parquet_with_row_index():
|
| 442 |
+
import pandas as pd
|
| 443 |
+
import pyarrow as pa
|
| 444 |
+
import pyarrow.parquet as pq
|
| 445 |
+
|
| 446 |
+
input_path = "/data/data/output.parquet"
|
| 447 |
+
output_path = "/data/data/output_indexed.parquet"
|
| 448 |
+
|
| 449 |
+
# Читаем исходные данные
|
| 450 |
+
df = pd.read_parquet(input_path)
|
| 451 |
+
|
| 452 |
+
# Добавляем индекс строки
|
| 453 |
+
df.reset_index(inplace=True)
|
| 454 |
+
df.rename(columns={'index': 'row_id'}, inplace=True)
|
| 455 |
+
|
| 456 |
+
# Сохраняем с разбивкой по строкам
|
| 457 |
+
table = pa.Table.from_pandas(df)
|
| 458 |
+
pq.write_table(table, output_path, row_group_size=15000)
|
| 459 |
+
|
| 460 |
+
return output_path
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
@app.local_entrypoint()
|
| 464 |
+
def process_full_dataset(batch_size: int = 15000):
|
| 465 |
+
"""Обрабатывает и сохраняет результаты напрямую в Volume"""
|
| 466 |
+
from tqdm import tqdm
|
| 467 |
+
import math
|
| 468 |
+
|
| 469 |
+
# 1. Получаем только метаданные (количество строк)
|
| 470 |
+
total_records = get_row_count.remote()
|
| 471 |
+
print(f"Total records to process: {total_records}")
|
| 472 |
+
|
| 473 |
+
# 2. Рассчитываем количество батчей
|
| 474 |
+
num_batches = math.ceil(total_records / batch_size)
|
| 475 |
+
print(f"Processing in {num_batches} batches of {batch_size} records")
|
| 476 |
+
|
| 477 |
+
# 3. Создаем временную директорию для частичных результатов
|
| 478 |
+
partial_dir = "/data/partial_results"
|
| 479 |
+
|
| 480 |
+
# 4. Объединяем результаты
|
| 481 |
+
final_path = "/data/data/full_dataset_results.parquet"
|
| 482 |
+
combine_results.remote(partial_dir, final_path)
|
| 483 |
+
|
| 484 |
+
print("\n✅ Full dataset processing complete!")
|
| 485 |
+
print(f"Results saved to {final_path}")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
@app.function(volumes={"/data": volume})
|
| 489 |
+
def init_partial_dir(partial_dir: str):
|
| 490 |
+
"""Создает директорию для частичных результатов"""
|
| 491 |
+
import os
|
| 492 |
+
os.makedirs(partial_dir, exist_ok=True)
|
| 493 |
+
return f"Created directory {partial_dir}"
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
@app.function(
|
| 497 |
+
image=image,
|
| 498 |
+
gpu="A10G",
|
| 499 |
+
volumes={"/data": volume},
|
| 500 |
+
timeout=300
|
| 501 |
+
)
|
| 502 |
+
def process_and_save_batch(start_row: int, end_row: int, batch_idx: int, partial_dir: str):
|
| 503 |
+
"""Обрабатывает батч и сохраняет результаты в отдельный файл"""
|
| 504 |
+
import pandas as pd
|
| 505 |
+
import pyarrow.parquet as pq
|
| 506 |
+
import os
|
| 507 |
+
|
| 508 |
+
# 0. Создаем директорию, если ее нет
|
| 509 |
+
os.makedirs(partial_dir, exist_ok=True)
|
| 510 |
+
|
| 511 |
+
# 1. Чтение данных
|
| 512 |
+
file_path = "/data/data/output.parquet"
|
| 513 |
+
|
| 514 |
+
# Альтернативный метод чтения без row groups
|
| 515 |
+
df = pd.read_parquet(file_path)
|
| 516 |
+
df = df.iloc[start_row:end_row]
|
| 517 |
+
|
| 518 |
+
# 2. Подготовка данных
|
| 519 |
+
df['processed_overview'] = df['processed_overview'].fillna('')
|
| 520 |
+
df['overview'] = df['overview'].fillna('')
|
| 521 |
+
|
| 522 |
+
# 3. Формирование батча
|
| 523 |
+
batch_data = list(zip(
|
| 524 |
+
df['processed_overview'].astype(str),
|
| 525 |
+
df['overview'].astype(str)
|
| 526 |
+
))
|
| 527 |
+
|
| 528 |
+
# 4. Обработка батча
|
| 529 |
+
results = process_batch.remote(batch_data)
|
| 530 |
+
|
| 531 |
+
# 5. Сохранение результатов
|
| 532 |
+
result_df = pd.DataFrame({'narrative_features': results})
|
| 533 |
+
output_path = os.path.join(partial_dir, f"batch_{batch_idx}.parquet")
|
| 534 |
+
result_df.to_parquet(output_path)
|
| 535 |
+
|
| 536 |
+
return f"Saved batch {batch_idx} to {output_path}"
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
@app.function(volumes={"/data": volume})
|
| 540 |
+
def combine_results(partial_dir: str, final_path: str):
|
| 541 |
+
"""Объединяет частичные результаты в финальный файл"""
|
| 542 |
+
import pandas as pd
|
| 543 |
+
import os
|
| 544 |
+
from glob import glob
|
| 545 |
+
# import pyarrow.parquet as pq
|
| 546 |
+
|
| 547 |
+
# 1. Сбор всех частичных файлов
|
| 548 |
+
# partial_files = glob(os.path.join(partial_dir, "*.parquet"))
|
| 549 |
+
partial_files = sorted(
|
| 550 |
+
glob(os.path.join(partial_dir, "*.parquet")),
|
| 551 |
+
key=lambda x: int(os.path.basename(x).split('_')[1].split('.')[0])
|
| 552 |
+
)
|
| 553 |
+
print(partial_files)
|
| 554 |
+
# 2. Чтение и объединение
|
| 555 |
+
full_results = []
|
| 556 |
+
for file_path in partial_files:
|
| 557 |
+
df = pd.read_parquet(file_path)
|
| 558 |
+
full_results.extend(df['narrative_features'].tolist())
|
| 559 |
+
|
| 560 |
+
print(f'len(full_results) = {len(full_results)}')
|
| 561 |
+
# 3. Чтение исходных данных
|
| 562 |
+
source_df = pd.read_parquet("/data/data/output.parquet")
|
| 563 |
+
print({source_df.info()})
|
| 564 |
+
|
| 565 |
+
# 4. Добавляем результаты
|
| 566 |
+
source_df['narrative_features'] = full_results
|
| 567 |
+
|
| 568 |
+
# 5. Сохранение финального результата
|
| 569 |
+
source_df.to_parquet(final_path)
|
| 570 |
+
|
| 571 |
+
# 6. Очистка временных файлов
|
| 572 |
+
for file_path in partial_files:
|
| 573 |
+
os.remove(file_path)
|
| 574 |
+
os.rmdir(partial_dir)
|
| 575 |
+
|
| 576 |
+
return f"Combined {len(partial_files)} batches into {final_path}"
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
@app.function(volumes={"/data": volume})
|
| 580 |
+
def get_row_count():
|
| 581 |
+
"""Возвращает общее количество строк в Parquet файле"""
|
| 582 |
+
import pyarrow.parquet as pq
|
| 583 |
+
|
| 584 |
+
file_path = "/data/data/output.parquet"
|
| 585 |
+
return pq.read_metadata(file_path).num_rows
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
@app.function(
|
| 589 |
+
image=image,
|
| 590 |
+
volumes={"/data": volume},
|
| 591 |
+
gpu="A10G",
|
| 592 |
+
timeout=3600,
|
| 593 |
+
memory=16384
|
| 594 |
+
)
|
| 595 |
+
def build_faiss_index():
|
| 596 |
+
"""
|
| 597 |
+
Построение FAISS индекса с учетом совместимости CUDA 12.8
|
| 598 |
+
Исправленная версия для эмбеддингов в формате строкового Python списка
|
| 599 |
+
"""
|
| 600 |
+
import ast
|
| 601 |
+
|
| 602 |
+
print("Проверка доступности CUDA...")
|
| 603 |
+
print(f"CUDA доступна: {torch.cuda.is_available()}")
|
| 604 |
+
if torch.cuda.is_available():
|
| 605 |
+
print(f"CUDA устройств: {torch.cuda.device_count()}")
|
| 606 |
+
print(f"Текущее устройство: {torch.cuda.current_device()}")
|
| 607 |
+
|
| 608 |
+
# Загрузка данных
|
| 609 |
+
df = pd.read_parquet("/data/data/full_dataset_results.parquet")
|
| 610 |
+
print(f"Загружено {len(df)} фильмов")
|
| 611 |
+
|
| 612 |
+
# Анализ формата первого эмбеддинга
|
| 613 |
+
sample_embedding = df['processed_overview_embedding'].iloc[0]
|
| 614 |
+
print(f"Пример эмбеддинга: {str(sample_embedding)[:100]}...")
|
| 615 |
+
print(f"Тип данных: {type(sample_embedding)}")
|
| 616 |
+
|
| 617 |
+
# Извлечение эмбеддингов
|
| 618 |
+
embeddings_list = []
|
| 619 |
+
valid_indices = []
|
| 620 |
+
parse_errors = 0
|
| 621 |
+
|
| 622 |
+
print("Начинаем обработку эмбеддингов...")
|
| 623 |
+
|
| 624 |
+
for idx, (_, row) in enumerate(df.iterrows()):
|
| 625 |
+
try:
|
| 626 |
+
embedding_data = row['processed_overview_embedding']
|
| 627 |
+
|
| 628 |
+
# Обработка различных форматов хранения эмбеддингов.
|
| 629 |
+
# А именно - парсинг строкового представления Python списка
|
| 630 |
+
if isinstance(embedding_data, str):
|
| 631 |
+
try:
|
| 632 |
+
# Безопасный парсинг с помощью ast.literal_eval
|
| 633 |
+
parsed_list = ast.literal_eval(embedding_data.strip())
|
| 634 |
+
if isinstance(parsed_list, list):
|
| 635 |
+
embedding = np.array(parsed_list, dtype=np.float32)
|
| 636 |
+
else:
|
| 637 |
+
parse_errors += 1
|
| 638 |
+
continue
|
| 639 |
+
except (ValueError, SyntaxError):
|
| 640 |
+
parse_errors += 1
|
| 641 |
+
continue
|
| 642 |
+
elif isinstance(embedding_data, list):
|
| 643 |
+
embedding = np.array(embedding_data, dtype=np.float32)
|
| 644 |
+
elif isinstance(embedding_data, np.ndarray):
|
| 645 |
+
embedding = embedding_data.astype(np.float32)
|
| 646 |
+
else:
|
| 647 |
+
parse_errors += 1
|
| 648 |
+
continue
|
| 649 |
+
|
| 650 |
+
# Проверка размерности (Размерность all-MiniLM-L6-v2 = 384)
|
| 651 |
+
if len(embedding) == 384:
|
| 652 |
+
embeddings_list.append(embedding.astype(np.float32))
|
| 653 |
+
valid_indices.append(idx)
|
| 654 |
+
else:
|
| 655 |
+
parse_errors += 1
|
| 656 |
+
|
| 657 |
+
except Exception as e:
|
| 658 |
+
parse_errors += 1
|
| 659 |
+
if parse_errors <= 5: # Выводим первые несколько ошибок
|
| 660 |
+
print(f"Ошибка обработки эмбеддинга {idx}: {e}")
|
| 661 |
+
continue
|
| 662 |
+
|
| 663 |
+
# Прогресс каждые 50000 записей
|
| 664 |
+
if (idx + 1) % 50000 == 0:
|
| 665 |
+
print(f"Обработано {idx + 1}/{len(df)} записей, валидных: {len(embeddings_list)}")
|
| 666 |
+
|
| 667 |
+
print(f"Успешно обработано {len(embeddings_list)} эмбеддингов из {len(df)}")
|
| 668 |
+
print(f"Ошибок парсинга: {parse_errors}")
|
| 669 |
+
print(f"Успешность обработки: {len(embeddings_list) / len(df) * 100:.2f}%")
|
| 670 |
+
|
| 671 |
+
if not embeddings_list:
|
| 672 |
+
raise ValueError(f"Не найдено валидных эмбеддингов. Всего ошибок: {parse_errors}")
|
| 673 |
+
|
| 674 |
+
# Создание матрицы эмбеддингов
|
| 675 |
+
embeddings_matrix = np.vstack(embeddings_list)
|
| 676 |
+
print(f"Подготовлено {len(embeddings_matrix)} эмбеддингов")
|
| 677 |
+
print(f"Создана матрица эмбеддингов: {embeddings_matrix.shape}")
|
| 678 |
+
|
| 679 |
+
# Нормализация для косинусного сходства
|
| 680 |
+
faiss.normalize_L2(embeddings_matrix)
|
| 681 |
+
print("Эмбеддинги нормализованы")
|
| 682 |
+
|
| 683 |
+
# Создание FAISS индекса с поддержкой GPU
|
| 684 |
+
dimension = embeddings_matrix.shape[1]
|
| 685 |
+
|
| 686 |
+
# Проверяем доступность GPU для FAISS
|
| 687 |
+
if faiss.get_num_gpus() > 0:
|
| 688 |
+
print("Используем GPU для построения FAISS индекса")
|
| 689 |
+
# GPU ресурсы
|
| 690 |
+
res = faiss.StandardGpuResources()
|
| 691 |
+
|
| 692 |
+
# CPU индекс
|
| 693 |
+
cpu_index = faiss.IndexFlatIP(dimension)
|
| 694 |
+
|
| 695 |
+
# Перенос на GPU
|
| 696 |
+
gpu_index = faiss.index_cpu_to_gpu(res, 0, cpu_index)
|
| 697 |
+
gpu_index.add(embeddings_matrix)
|
| 698 |
+
|
| 699 |
+
# Возврат на CPU для сохранения
|
| 700 |
+
index = faiss.index_gpu_to_cpu(gpu_index)
|
| 701 |
+
print("FAISS индекс построен на GPU и перенесен на CPU для сохранения")
|
| 702 |
+
else:
|
| 703 |
+
print("Используем CPU для FAISS")
|
| 704 |
+
index = faiss.IndexFlatIP(dimension)
|
| 705 |
+
index.add(embeddings_matrix)
|
| 706 |
+
|
| 707 |
+
# Сохранение результатов
|
| 708 |
+
print("Сохранение FAISS индекса...")
|
| 709 |
+
faiss.write_index(index, "/data/data/movie_embeddings.index")
|
| 710 |
+
|
| 711 |
+
# Сохранение метаданных
|
| 712 |
+
print("Сохранение метаданных фильмов...")
|
| 713 |
+
valid_movies_df = df.iloc[valid_indices].reset_index(drop=True)
|
| 714 |
+
valid_movies_df.to_parquet("/data/data/indexed_movies_metadata.parquet")
|
| 715 |
+
|
| 716 |
+
result = {
|
| 717 |
+
"status": "success",
|
| 718 |
+
"total_movies": len(valid_movies_df),
|
| 719 |
+
"original_dataset_size": len(df),
|
| 720 |
+
"index_size": index.ntotal,
|
| 721 |
+
"dimension": dimension,
|
| 722 |
+
"gpu_used": faiss.get_num_gpus() > 0,
|
| 723 |
+
"processing_success_rate": len(valid_indices) / len(df),
|
| 724 |
+
"parse_errors": parse_errors
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
print("=" * 50)
|
| 728 |
+
print("ПОСТРОЕНИЕ ИНДЕКСА ЗАВЕРШЕНО")
|
| 729 |
+
print(f"Обработано фильмов: {result['total_movies']} из {result['original_dataset_size']}")
|
| 730 |
+
print(f"Размерность векторов: {result['dimension']}")
|
| 731 |
+
print(f"Успешность: {result['processing_success_rate'] * 100:.2f}%")
|
| 732 |
+
print("=" * 50)
|
| 733 |
+
|
| 734 |
+
return result
|
| 735 |
+
|
| 736 |
+
|
| 737 |
+
@app.function(
|
| 738 |
+
image=image,
|
| 739 |
+
volumes={"/data": volume},
|
| 740 |
+
timeout=300
|
| 741 |
+
)
|
| 742 |
+
def test_embedding_parsing(num_samples=100):
|
| 743 |
+
"""
|
| 744 |
+
Тестирование парсинга эмбеддингов на небольшой выборке данных
|
| 745 |
+
"""
|
| 746 |
+
import ast
|
| 747 |
+
|
| 748 |
+
df = pd.read_parquet("/data/data/full_dataset_results.parquet")
|
| 749 |
+
print(f"Загружено {len(df)} фильмов для тестирования")
|
| 750 |
+
|
| 751 |
+
test_sample = df.head(num_samples)
|
| 752 |
+
successful_parses = 0
|
| 753 |
+
failed_parses = 0
|
| 754 |
+
|
| 755 |
+
print("Тестирование парсинга эмбеддингов...")
|
| 756 |
+
|
| 757 |
+
for idx, row in test_sample.iterrows():
|
| 758 |
+
embedding_data = row['processed_overview_embedding']
|
| 759 |
+
|
| 760 |
+
try:
|
| 761 |
+
if isinstance(embedding_data, str):
|
| 762 |
+
parsed_list = ast.literal_eval(embedding_data.strip())
|
| 763 |
+
if isinstance(parsed_list, list):
|
| 764 |
+
embedding = np.array(parsed_list, dtype=np.float32)
|
| 765 |
+
if len(embedding) == 384:
|
| 766 |
+
successful_parses += 1
|
| 767 |
+
else:
|
| 768 |
+
print(f"Неправильная размерность {len(embedding)} для индекса {idx}")
|
| 769 |
+
failed_parses += 1
|
| 770 |
+
else:
|
| 771 |
+
print(f"Парсинг не дал список для индекса {idx}: {type(parsed_list)}")
|
| 772 |
+
failed_parses += 1
|
| 773 |
+
else:
|
| 774 |
+
print(f"Неожиданный тип данных для индекса {idx}: {type(embedding_data)}")
|
| 775 |
+
failed_parses += 1
|
| 776 |
+
|
| 777 |
+
except Exception as e:
|
| 778 |
+
print(f"Ошибка парсинга для индекса {idx}: {e}")
|
| 779 |
+
failed_parses += 1
|
| 780 |
+
|
| 781 |
+
print(f"\nРезультаты тестирования:")
|
| 782 |
+
print(f"Успешных парсингов: {successful_parses}")
|
| 783 |
+
print(f"Неудачных парсингов: {failed_parses}")
|
| 784 |
+
print(f"Успешность: {successful_parses / (successful_parses + failed_parses) * 100:.2f}%")
|
| 785 |
+
|
| 786 |
+
return {
|
| 787 |
+
"successful_parses": successful_parses,
|
| 788 |
+
"failed_parses": failed_parses,
|
| 789 |
+
"success_rate": successful_parses / (successful_parses + failed_parses)
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
|
| 793 |
+
@app.function(
|
| 794 |
+
image=image,
|
| 795 |
+
gpu="A10G",
|
| 796 |
+
timeout=300,
|
| 797 |
+
max_containers=1 # Макс 3 одновременных кодирования
|
| 798 |
+
)
|
| 799 |
+
def encode_user_query(query_text: str, remove_entities: bool = True):
|
| 800 |
+
"""
|
| 801 |
+
Генерация эмбеддинга для пользовательского описания с опциональным удалением именованных сущностей
|
| 802 |
+
"""
|
| 803 |
+
import spacy
|
| 804 |
+
import tempfile
|
| 805 |
+
# Импорт внутри функции для работы с добавленными директориями
|
| 806 |
+
from modal_utils.cloud_operations import (clean_text, prepare_text_for_embedding,
|
| 807 |
+
encode_user_query_fallback, extract_narrative_features_consistent)
|
| 808 |
+
|
| 809 |
+
# Проверка входных данных
|
| 810 |
+
if not query_text or not query_text.strip():
|
| 811 |
+
raise ValueError("Пустой запрос не может быть обработан")
|
| 812 |
+
|
| 813 |
+
# Определение устройства
|
| 814 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 815 |
+
print(f"Используется устройство: {device}")
|
| 816 |
+
|
| 817 |
+
# Инициализация spaCy модели (та же, что использовалась для обработки фильмов)
|
| 818 |
+
try:
|
| 819 |
+
try:
|
| 820 |
+
# Загрузка spaCy с проверкой GPU
|
| 821 |
+
if torch.cuda.is_available():
|
| 822 |
+
spacy.prefer_gpu()
|
| 823 |
+
|
| 824 |
+
import en_core_web_lg
|
| 825 |
+
nlp = en_core_web_lg.load()
|
| 826 |
+
# nlp = spacy.load("en_core_web_lg")
|
| 827 |
+
|
| 828 |
+
# Добавляем sentencizer, если его нет
|
| 829 |
+
if "sentencizer" not in nlp.pipe_names:
|
| 830 |
+
nlp.add_pipe("sentencizer")
|
| 831 |
+
|
| 832 |
+
print("SpaCy модель загружена успешно")
|
| 833 |
+
|
| 834 |
+
except Exception as e:
|
| 835 |
+
print(f"Ошибка загрузки spaCy: {e}")
|
| 836 |
+
# Fallback к простой обработке
|
| 837 |
+
return encode_user_query_fallback(query_text, device)
|
| 838 |
+
|
| 839 |
+
# Инициализация модели для кодирования
|
| 840 |
+
try:
|
| 841 |
+
# Определяем кэш-директорию в зависимости от ОС
|
| 842 |
+
cache_dir = os.path.join(tempfile.gettempdir(), "sentence_transformer_cache")
|
| 843 |
+
|
| 844 |
+
# Создаем директорию, если не существует
|
| 845 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 846 |
+
print(f"Using cache directory: {cache_dir}")
|
| 847 |
+
|
| 848 |
+
model = SentenceTransformer(
|
| 849 |
+
'all-MiniLM-L6-v2',
|
| 850 |
+
device=device,
|
| 851 |
+
cache_folder=cache_dir
|
| 852 |
+
)
|
| 853 |
+
|
| 854 |
+
# Оптимизация для GPU
|
| 855 |
+
if torch.cuda.is_available():
|
| 856 |
+
model = model.half()
|
| 857 |
+
print("Using half-precision model")
|
| 858 |
+
except Exception as e:
|
| 859 |
+
return {"error": f"model_init_error: {str(e)}"}
|
| 860 |
+
|
| 861 |
+
# Применяем тот же процесс обработки, что и для фильмов
|
| 862 |
+
# Опциональное удаление именованных сущностей для фокуса на сюжете
|
| 863 |
+
if remove_entities:
|
| 864 |
+
processed_query = prepare_text_for_embedding(query_text, nlp)
|
| 865 |
+
|
| 866 |
+
# Проверка, что после обработки остался текст
|
| 867 |
+
if not processed_query.strip():
|
| 868 |
+
print("Предупреждение: После удаления сущностей текст стал пустым, используем очищенную версию")
|
| 869 |
+
processed_query = clean_text(query_text)
|
| 870 |
+
else:
|
| 871 |
+
processed_query = clean_text(query_text)
|
| 872 |
+
|
| 873 |
+
# Финальная проверка
|
| 874 |
+
if not processed_query.strip():
|
| 875 |
+
processed_query = query_text.lower().strip()
|
| 876 |
+
|
| 877 |
+
print(f"Исходное описание: '{query_text}'")
|
| 878 |
+
print(f"Обработанное описание: '{processed_query}'")
|
| 879 |
+
|
| 880 |
+
print(f"Исходное описание: {query_text}")
|
| 881 |
+
print(f"Обработанное описание: {processed_query}")
|
| 882 |
+
|
| 883 |
+
# Генерация эмбеддинга
|
| 884 |
+
query_embedding = model.encode(
|
| 885 |
+
[processed_query],
|
| 886 |
+
convert_to_tensor=False,
|
| 887 |
+
batch_size=1,
|
| 888 |
+
show_progress_bar=False
|
| 889 |
+
)[0]
|
| 890 |
+
|
| 891 |
+
# Извлечение нарративных признаков, консистентных с базой данных
|
| 892 |
+
narrative_features = extract_narrative_features_consistent(query_text, processed_query, nlp)
|
| 893 |
+
|
| 894 |
+
return {
|
| 895 |
+
"original_query": query_text,
|
| 896 |
+
"processed_query": processed_query,
|
| 897 |
+
"embedding": query_embedding.tolist(),
|
| 898 |
+
"embedding_dimension": len(query_embedding),
|
| 899 |
+
"narrative_features": narrative_features,
|
| 900 |
+
"device_used": device,
|
| 901 |
+
"preprocessing_applied": remove_entities
|
| 902 |
+
}
|
| 903 |
+
except Exception as e:
|
| 904 |
+
print(f"Ошибка в основной обработке: {e}, переключаемся на fallback")
|
| 905 |
+
return encode_user_query_fallback(query_text, device)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
@app.function(
|
| 909 |
+
image=image,
|
| 910 |
+
timeout=300
|
| 911 |
+
)
|
| 912 |
+
def test_text_processing_consistency():
|
| 913 |
+
"""
|
| 914 |
+
Тестирование консистентности обработки текста между фильмами и описанием пошьзователя
|
| 915 |
+
Запуск из командной строки на локальном комп-ре:
|
| 916 |
+
$ modal run modal_app.py::app.test_text_processing_consistency
|
| 917 |
+
"""
|
| 918 |
+
import spacy
|
| 919 |
+
# Импорт внутри функции для работы с добавленными директориями
|
| 920 |
+
from modal_utils.cloud_operations import clean_text, prepare_text_for_embedding, encode_user_query_fallback
|
| 921 |
+
|
| 922 |
+
nlp = spacy.load("en_core_web_lg")
|
| 923 |
+
|
| 924 |
+
# Тестовые примеры
|
| 925 |
+
test_texts = [
|
| 926 |
+
"A young wizard named Harry Potter discovers his magical heritage.",
|
| 927 |
+
"In New York City, a detective investigates a mysterious crime.",
|
| 928 |
+
"The story follows John Smith as he travels through time.",
|
| 929 |
+
"An epic adventure in the Star Wars universe with Luke Skywalker."
|
| 930 |
+
]
|
| 931 |
+
|
| 932 |
+
print("Тестирование обработки текста:")
|
| 933 |
+
print("=" * 60)
|
| 934 |
+
|
| 935 |
+
for text in test_texts:
|
| 936 |
+
processed = prepare_text_for_embedding(text, nlp)
|
| 937 |
+
print(f"Исходный: {text}")
|
| 938 |
+
print(f"Обработанный: {processed}")
|
| 939 |
+
print("-" * 40)
|
| 940 |
+
|
| 941 |
+
return {"test_completed": True, "samples_processed": len(test_texts)}
|
| 942 |
+
|
| 943 |
+
|
| 944 |
+
# Глобальная переменная для кэширования GPU индекса
|
| 945 |
+
_gpu_index_cache = None
|
| 946 |
+
_gpu_resources_cache = None
|
| 947 |
+
|
| 948 |
+
|
| 949 |
+
@app.function(
|
| 950 |
+
image=image,
|
| 951 |
+
volumes={"/data": volume},
|
| 952 |
+
gpu="A10G",
|
| 953 |
+
timeout=300,
|
| 954 |
+
min_containers=1, # Поддерживаем контейнер активным
|
| 955 |
+
max_containers=1 # Макс 3 одновременных кодирования
|
| 956 |
+
)
|
| 957 |
+
def search_similar_movies(
|
| 958 |
+
query_embedding: list,
|
| 959 |
+
query_narrative_features: dict,
|
| 960 |
+
top_k: int = 50,
|
| 961 |
+
rerank_top_n: int = 10):
|
| 962 |
+
"""
|
| 963 |
+
Поиск похожих фильмов с использованием FAISS и консистентных нарративных признаков
|
| 964 |
+
дополнительным ранжированием
|
| 965 |
+
по нарративным признакам. Оптимизированная версия с кэшированием GPU
|
| 966 |
+
индекса для избежания повторных переносов
|
| 967 |
+
"""
|
| 968 |
+
global _gpu_index_cache, _gpu_resources_cache
|
| 969 |
+
import time
|
| 970 |
+
from modal_utils.cloud_operations import (rerank_by_narrative_features,
|
| 971 |
+
calculate_narrative_similarity)
|
| 972 |
+
|
| 973 |
+
start_time = time.time()
|
| 974 |
+
|
| 975 |
+
search_index = None # Инициализируем переменную
|
| 976 |
+
|
| 977 |
+
# Загрузка FAISS индекса
|
| 978 |
+
movies_df = pd.read_parquet("/data/data/indexed_movies_metadata.parquet")
|
| 979 |
+
|
| 980 |
+
# Инициализация GPU индекса (только при первом вызове)
|
| 981 |
+
if _gpu_index_cache is None and faiss.get_num_gpus() > 0:
|
| 982 |
+
print("Первая инициализация GPU индекса...")
|
| 983 |
+
|
| 984 |
+
# Загрузка CPU индекса
|
| 985 |
+
cpu_index = faiss.read_index("/data/data/movie_embeddings.index")
|
| 986 |
+
|
| 987 |
+
load_time = time.time() - start_time
|
| 988 |
+
print(f"Загрузка данных: {load_time:.3f}s")
|
| 989 |
+
|
| 990 |
+
# Создание GPU ресурсов
|
| 991 |
+
_gpu_resources_cache = faiss.StandardGpuResources()
|
| 992 |
+
_gpu_resources_cache.setTempMemory(1024 * 1024 * 1024) # 1GB temp memory
|
| 993 |
+
|
| 994 |
+
# Перенос на GPU
|
| 995 |
+
_gpu_index_cache = faiss.index_cpu_to_gpu(_gpu_resources_cache, 0, cpu_index)
|
| 996 |
+
|
| 997 |
+
logger.info(f"GPU индекс кэширован и готов к использованию")
|
| 998 |
+
print("GPU индекс кэширован и готов к использованию")
|
| 999 |
+
using_gpu = True
|
| 1000 |
+
|
| 1001 |
+
elif _gpu_index_cache is not None:
|
| 1002 |
+
logger.info(f"Используем кэшированный GPU индекс")
|
| 1003 |
+
print("Используем кэшированный GPU индекс")
|
| 1004 |
+
using_gpu = True
|
| 1005 |
+
|
| 1006 |
+
else:
|
| 1007 |
+
logger.info(f"GPU недоступен, используем CPU")
|
| 1008 |
+
print("GPU недоступен, используем CPU")
|
| 1009 |
+
cpu_index = faiss.read_index("/data/data/movie_embeddings.index")
|
| 1010 |
+
search_index = cpu_index
|
| 1011 |
+
using_gpu = False
|
| 1012 |
+
|
| 1013 |
+
if using_gpu:
|
| 1014 |
+
search_index = _gpu_index_cache
|
| 1015 |
+
|
| 1016 |
+
# Семантический поиск, Подготовка запроса
|
| 1017 |
+
query_vector = np.array([query_embedding], dtype=np.float32)
|
| 1018 |
+
faiss.normalize_L2(query_vector)
|
| 1019 |
+
|
| 1020 |
+
# Выполнение поиска ближайших соседей
|
| 1021 |
+
search_start = time.time()
|
| 1022 |
+
distances, indices = search_index.search(query_vector, top_k)
|
| 1023 |
+
search_time = time.time() - search_start
|
| 1024 |
+
|
| 1025 |
+
logger.info(f"Время поиска ({'GPU' if using_gpu else 'CPU'}): {search_time:.3f}s")
|
| 1026 |
+
print(f"Время поиска ({'GPU' if using_gpu else 'CPU'}): {search_time:.3f}s")
|
| 1027 |
+
|
| 1028 |
+
# Обработка результатов
|
| 1029 |
+
process_start = time.time()
|
| 1030 |
+
candidates = []
|
| 1031 |
+
for i, (dist, idx) in enumerate(zip(distances[0], indices[0])):
|
| 1032 |
+
if idx < len(movies_df):
|
| 1033 |
+
movie = movies_df.iloc[idx]
|
| 1034 |
+
|
| 1035 |
+
# Вычисление нарративного сходства с исправленной функцией
|
| 1036 |
+
narrative_similarity = calculate_narrative_similarity(
|
| 1037 |
+
query_narrative_features,
|
| 1038 |
+
movie.get('narrative_features', '{}')
|
| 1039 |
+
)
|
| 1040 |
+
|
| 1041 |
+
candidates.append({
|
| 1042 |
+
'index': idx,
|
| 1043 |
+
'semantic_score': float(dist),
|
| 1044 |
+
'narrative_similarity': narrative_similarity,
|
| 1045 |
+
'movie_data': movie.to_dict()
|
| 1046 |
+
})
|
| 1047 |
+
|
| 1048 |
+
# Дополнительное ранжирование с учетом нарративных признаков
|
| 1049 |
+
reranked_candidates = rerank_by_narrative_features(candidates)
|
| 1050 |
+
process_time = time.time() - process_start
|
| 1051 |
+
|
| 1052 |
+
total_time = time.time() - start_time
|
| 1053 |
+
|
| 1054 |
+
# Подготавливаем необходимые для инфо поля и выводим через logger
|
| 1055 |
+
# filtered = {}
|
| 1056 |
+
desired_movie_keys = {'id', 'title', 'narrative_features'}
|
| 1057 |
+
if reranked_candidates: # список не пуст
|
| 1058 |
+
first = reranked_candidates[0] # это dict
|
| 1059 |
+
movie_info = first.get("movie_data", {}) # dict с данными фильма
|
| 1060 |
+
filtered = {k: movie_info.get(k) for k in desired_movie_keys if k in movie_info}
|
| 1061 |
+
logger.info(f"First re-ranked candidate (filtered): {filtered}")
|
| 1062 |
+
|
| 1063 |
+
else:
|
| 1064 |
+
logger.warning("reranked_candidates is empty, nothing to log")
|
| 1065 |
+
|
| 1066 |
+
return {
|
| 1067 |
+
"results": reranked_candidates[:rerank_top_n],
|
| 1068 |
+
"performance_metrics": {
|
| 1069 |
+
"using_gpu": using_gpu,
|
| 1070 |
+
"search_time": search_time,
|
| 1071 |
+
"process_time": process_time,
|
| 1072 |
+
"total_time": total_time,
|
| 1073 |
+
"cached_gpu_index": _gpu_index_cache is not None
|
| 1074 |
+
}
|
| 1075 |
+
}
|
modal_utils/cloud_operations.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import spacy
|
| 3 |
+
|
| 4 |
+
import pandas as pd
|
| 5 |
+
import numpy as np
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
from tqdm import tqdm
|
| 8 |
+
import torch
|
| 9 |
+
import os
|
| 10 |
+
import tempfile
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def clean_text(text):
|
| 14 |
+
"""Очищает текст от лишних символов и форматирования"""
|
| 15 |
+
if pd.isna(text):
|
| 16 |
+
return ""
|
| 17 |
+
|
| 18 |
+
text = re.sub(r'[^a-zA-Z0-9\s]', ' ', text) # Замена спецсимволов на пробелы
|
| 19 |
+
text = re.sub(r'\s+', ' ', text) # Замена множественных пробелов на один
|
| 20 |
+
return text.strip().lower()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def remove_entities(text, nlp):
|
| 24 |
+
"""Удаляет именованные сущности, стоп-слова и приводит к леммам"""
|
| 25 |
+
if pd.isna(text) or text == "":
|
| 26 |
+
return ""
|
| 27 |
+
|
| 28 |
+
# nlp = spacy.load("en_core_web_lg")
|
| 29 |
+
|
| 30 |
+
doc = nlp(text)
|
| 31 |
+
|
| 32 |
+
# Удаляем именованные сущности, знаки пунктуации и стоп-слова,
|
| 33 |
+
# применяем лемматизацию к оставшимся токенам
|
| 34 |
+
tokens = [token.lemma_ for token in doc
|
| 35 |
+
if token.ent_type_ not in ['PERSON', 'ORG', 'GPE', 'LOC', 'DATE', 'TIME', 'MONEY']
|
| 36 |
+
and not token.is_punct
|
| 37 |
+
and not token.is_stop
|
| 38 |
+
and token.lemma_.strip()]
|
| 39 |
+
|
| 40 |
+
return " ".join(tokens)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def prepare_text_for_embedding(text, nlp):
|
| 44 |
+
"""Полный процесс подготовки текста для эмбеддинга"""
|
| 45 |
+
if pd.isna(text):
|
| 46 |
+
return ""
|
| 47 |
+
|
| 48 |
+
# Сначала очищаем текст
|
| 49 |
+
cleaned_text = clean_text(text)
|
| 50 |
+
|
| 51 |
+
# Затем удаляем сущности и выполняем лемматизацию
|
| 52 |
+
processed_text = remove_entities(cleaned_text, nlp)
|
| 53 |
+
|
| 54 |
+
return processed_text
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def encode_user_query_fallback(query_text: str, device: str):
|
| 58 |
+
"""
|
| 59 |
+
Fallback функция для случаев, когда spaCy недоступна
|
| 60 |
+
"""
|
| 61 |
+
import re
|
| 62 |
+
|
| 63 |
+
print("Fallback режим: обработка без spaCy")
|
| 64 |
+
# Инициализация модели SentenceTransformer
|
| 65 |
+
model = SentenceTransformer(
|
| 66 |
+
'all-MiniLM-L6-v2',
|
| 67 |
+
device=device,
|
| 68 |
+
cache_folder="/tmp/model_cache"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
if torch.cuda.is_available():
|
| 72 |
+
model = model.half()
|
| 73 |
+
|
| 74 |
+
# Простая очистка без spaCy
|
| 75 |
+
processed_query = re.sub(r'[^a-zA-Z0-9\s]', ' ', query_text)
|
| 76 |
+
processed_query = re.sub(r'\s+', ' ', processed_query).strip().lower()
|
| 77 |
+
|
| 78 |
+
# Простое удаление потенциальных имен (заглавные буквы)
|
| 79 |
+
processed_query = re.sub(r'\b[A-Z][a-z]+\b', '', processed_query)
|
| 80 |
+
processed_query = re.sub(r'\s+', ' ', processed_query).strip()
|
| 81 |
+
|
| 82 |
+
print(f"Обработанный запрос: {processed_query}")
|
| 83 |
+
|
| 84 |
+
# Генерация эмбеддинга
|
| 85 |
+
query_embedding = model.encode(
|
| 86 |
+
[processed_query],
|
| 87 |
+
convert_to_tensor=False,
|
| 88 |
+
batch_size=1,
|
| 89 |
+
show_progress_bar=False
|
| 90 |
+
)[0]
|
| 91 |
+
|
| 92 |
+
# Базовые нарративные признаки без spaCy
|
| 93 |
+
narrative_features = {
|
| 94 |
+
"conflict_keywords": [],
|
| 95 |
+
"plot_turns": 0,
|
| 96 |
+
"sentiment_variance": 0.0,
|
| 97 |
+
"action_density": 0.0
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"original_query": query_text,
|
| 102 |
+
"processed_query": processed_query,
|
| 103 |
+
"embedding": query_embedding.tolist(),
|
| 104 |
+
"embedding_dimension": len(query_embedding),
|
| 105 |
+
"narrative_features": narrative_features,
|
| 106 |
+
"device_used": device,
|
| 107 |
+
"preprocessing_applied": True,
|
| 108 |
+
"fallback_mode": True
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def heavy_computation(df=None, batch_size=128):
|
| 113 |
+
"""
|
| 114 |
+
Основная функция обработки, поддерживает два режима:
|
| 115 |
+
- Локальный: получает данные через параметр df
|
| 116 |
+
- Облачный: читает данные из Volume (/data/input.csv)
|
| 117 |
+
"""
|
| 118 |
+
# Определяем режим выполнения
|
| 119 |
+
is_cloud_mode = df is None
|
| 120 |
+
print(f'is_cloud_mode = {is_cloud_mode}')
|
| 121 |
+
# 1. Загрузка данных (разные источники для локального и облачного режимов)
|
| 122 |
+
if is_cloud_mode:
|
| 123 |
+
# Облачный режим: читаем из Volume
|
| 124 |
+
try:
|
| 125 |
+
input_path = "/data/input.csv"
|
| 126 |
+
if not os.path.exists(input_path):
|
| 127 |
+
return {"error": "input_file_not_found"}
|
| 128 |
+
|
| 129 |
+
df = pd.read_csv(input_path)
|
| 130 |
+
print(f"Loaded {len(df)} movies from Volume")
|
| 131 |
+
except Exception as e:
|
| 132 |
+
return {"error": f"data_load_error: {str(e)}"}
|
| 133 |
+
else:
|
| 134 |
+
# Локальный режим: используем переданные данные
|
| 135 |
+
print(f"Processing {len(df)} movies locally")
|
| 136 |
+
|
| 137 |
+
# 2. Инициализация модели
|
| 138 |
+
try:
|
| 139 |
+
# Определяем кэш-директорию в зависимости от ОС
|
| 140 |
+
cache_dir = os.path.join(tempfile.gettempdir(), "sentence_transformers_cache")
|
| 141 |
+
|
| 142 |
+
# Создаем директорию, если не существует
|
| 143 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 144 |
+
print(f"Using cache directory: {cache_dir}")
|
| 145 |
+
|
| 146 |
+
model = SentenceTransformer(
|
| 147 |
+
'all-MiniLM-L6-v2',
|
| 148 |
+
device="cuda" if torch.cuda.is_available() else "cpu",
|
| 149 |
+
cache_folder=cache_dir
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
if torch.cuda.is_available():
|
| 153 |
+
model = model.half()
|
| 154 |
+
print("Using half-precision model")
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return {"error": f"model_init_error: {str(e)}"}
|
| 157 |
+
|
| 158 |
+
# 3. Создание эмбеддингов
|
| 159 |
+
try:
|
| 160 |
+
embeddings = []
|
| 161 |
+
non_empty_overviews = df['processed_overview'].fillna("")
|
| 162 |
+
|
| 163 |
+
# Если нет GPU, уменьшаем размер батча
|
| 164 |
+
if not torch.cuda.is_available() and batch_size > 32:
|
| 165 |
+
batch_size = 32
|
| 166 |
+
print(f"Reduced batch_size to {batch_size} for CPU mode")
|
| 167 |
+
|
| 168 |
+
for i in tqdm(range(0, len(non_empty_overviews), batch_size),
|
| 169 |
+
total=len(non_empty_overviews) // batch_size + 1):
|
| 170 |
+
batch = non_empty_overviews.iloc[i:i + batch_size].tolist()
|
| 171 |
+
batch_embeddings = model.encode(
|
| 172 |
+
batch,
|
| 173 |
+
show_progress_bar=False,
|
| 174 |
+
convert_to_numpy=True
|
| 175 |
+
)
|
| 176 |
+
embeddings.append(batch_embeddings.astype(np.float32))
|
| 177 |
+
|
| 178 |
+
# 4. Сохранение результатов
|
| 179 |
+
df['processed_overview_embedding'] = np.vstack(embeddings).tolist()
|
| 180 |
+
# df['title_length'] = df['title'].apply(len)
|
| 181 |
+
# df['has_overview'] = df['overview'].notna()
|
| 182 |
+
|
| 183 |
+
# Для локального режима просто возвращаем результат
|
| 184 |
+
if not is_cloud_mode:
|
| 185 |
+
return {
|
| 186 |
+
"status": "success",
|
| 187 |
+
"processed": len(df),
|
| 188 |
+
"embedding_dim": embeddings[0].shape[1] if embeddings else 0,
|
| 189 |
+
"sample": {
|
| 190 |
+
"title": df['title'].iloc[0],
|
| 191 |
+
"overview": df['overview'].iloc[0][:50] + "..." if len(df['overview'].iloc[0]) > 50 else df['overview'].iloc[0],
|
| 192 |
+
"embedding_first_5": df['overview_embedding'].iloc[0][:5]
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
# 5. Для облачного режима сохраняем в Volume
|
| 197 |
+
with tempfile.NamedTemporaryFile(mode="w", delete=False, encoding="utf-8") as tmp:
|
| 198 |
+
df.to_csv(tmp, index=False)
|
| 199 |
+
tmp_path = tmp.name
|
| 200 |
+
|
| 201 |
+
# Копируем в Volume
|
| 202 |
+
output_path = "/data/data/output.csv"
|
| 203 |
+
with open(tmp_path, "rb") as src, open(output_path, "wb") as dst:
|
| 204 |
+
dst.write(src.read())
|
| 205 |
+
|
| 206 |
+
os.unlink(tmp_path)
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
"status": "success",
|
| 210 |
+
"processed": len(df),
|
| 211 |
+
"embedding_dim": embeddings[0].shape[1] if embeddings else 0,
|
| 212 |
+
"saved_path": output_path
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
except Exception as e:
|
| 216 |
+
return {
|
| 217 |
+
"status": "error",
|
| 218 |
+
"message": str(e),
|
| 219 |
+
"type": type(e).__name__
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def parse_embedding_safe(embedding_data):
|
| 224 |
+
"""
|
| 225 |
+
Оптимизированная функция парсинга эмбеддингов для формата строкового Python списка
|
| 226 |
+
"""
|
| 227 |
+
import ast
|
| 228 |
+
|
| 229 |
+
# Случай 1: Уже numpy array
|
| 230 |
+
if isinstance(embedding_data, np.ndarray):
|
| 231 |
+
return embedding_data
|
| 232 |
+
|
| 233 |
+
# Случай 2: Python список
|
| 234 |
+
if isinstance(embedding_data, list):
|
| 235 |
+
return np.array(embedding_data, dtype=np.float32)
|
| 236 |
+
|
| 237 |
+
# Случай 3: Строковое представление Python списка
|
| 238 |
+
if isinstance(embedding_data, str):
|
| 239 |
+
try:
|
| 240 |
+
# Используем ast.literal_eval для безопасного парсинга
|
| 241 |
+
parsed_list = ast.literal_eval(embedding_data.strip())
|
| 242 |
+
if isinstance(parsed_list, list):
|
| 243 |
+
return np.array(parsed_list, dtype=np.float32)
|
| 244 |
+
except (ValueError, SyntaxError) as e:
|
| 245 |
+
print(f"Ошибка парсинга эмбеддинга: {e}")
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
return None
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def extract_narrative_features_consistent(original_text: str, processed_text: str, nlp):
|
| 252 |
+
"""
|
| 253 |
+
Извлечение нарративных признаков, идентичных тем, что используются для фильмов
|
| 254 |
+
"""
|
| 255 |
+
from textacy.extract import keyterms
|
| 256 |
+
from textblob import TextBlob
|
| 257 |
+
import numpy as np
|
| 258 |
+
import json
|
| 259 |
+
|
| 260 |
+
# Инициализация структуры признаков (как в базе данных)
|
| 261 |
+
features = {
|
| 262 |
+
"conflict_keywords": [],
|
| 263 |
+
"plot_turns": 0,
|
| 264 |
+
"sentiment_variance": 0.0,
|
| 265 |
+
"action_density": 0.0
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
try:
|
| 269 |
+
# Обработка текста через spaCy
|
| 270 |
+
processed_doc = nlp(processed_text) if processed_text and len(processed_text) >= 20 else None
|
| 271 |
+
|
| 272 |
+
# 1. Ключевые слова конфликта (идентичный алгоритм)
|
| 273 |
+
if processed_doc and len(processed_text) >= 20:
|
| 274 |
+
try:
|
| 275 |
+
conflict_terms = [
|
| 276 |
+
term for term, score in keyterms.textrank(
|
| 277 |
+
processed_doc,
|
| 278 |
+
topn=5,
|
| 279 |
+
window_size=10,
|
| 280 |
+
edge_weighting="count",
|
| 281 |
+
position_bias=False
|
| 282 |
+
) if term and term.strip()
|
| 283 |
+
]
|
| 284 |
+
features["conflict_keywords"] = conflict_terms
|
| 285 |
+
except Exception as e:
|
| 286 |
+
print(f"Ошибка извлечения ключевых слов: {e}")
|
| 287 |
+
features["conflict_keywords"] = []
|
| 288 |
+
|
| 289 |
+
# 2. Повороты сюжета (идентичный алгоритм)
|
| 290 |
+
if original_text and len(original_text) >= 20:
|
| 291 |
+
turn_keywords = {"but", "however", "though", "although", "nevertheless",
|
| 292 |
+
"suddenly", "unexpectedly", "surprisingly", "abruptly"}
|
| 293 |
+
|
| 294 |
+
lower_text = original_text.lower()
|
| 295 |
+
plot_turns_count = sum(lower_text.count(kw) for kw in turn_keywords)
|
| 296 |
+
features["plot_turns"] = plot_turns_count
|
| 297 |
+
|
| 298 |
+
# 3. Вариативность эмоций (идентичный алгоритм)
|
| 299 |
+
if original_text and len(original_text) >= 20:
|
| 300 |
+
try:
|
| 301 |
+
blob = TextBlob(original_text)
|
| 302 |
+
if len(blob.sentences) > 1:
|
| 303 |
+
sentiments = [s.sentiment.polarity for s in blob.sentences]
|
| 304 |
+
features["sentiment_variance"] = float(np.var(sentiments))
|
| 305 |
+
else:
|
| 306 |
+
features["sentiment_variance"] = 0.0
|
| 307 |
+
except Exception as e:
|
| 308 |
+
print(f"Ошибка анализа эмоций: {e}")
|
| 309 |
+
features["sentiment_variance"] = 0.0
|
| 310 |
+
|
| 311 |
+
# 4. Плотность действий (идентичный алгоритм)
|
| 312 |
+
if processed_doc and len(processed_doc) > 0:
|
| 313 |
+
action_verbs = sum(1 for token in processed_doc if token.pos_ == "VERB")
|
| 314 |
+
features["action_density"] = action_verbs / len(processed_doc)
|
| 315 |
+
|
| 316 |
+
except Exception as e:
|
| 317 |
+
print(f"Ошибка извлечения нарративных признаков: {e}")
|
| 318 |
+
|
| 319 |
+
return features
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def calculate_narrative_similarity(query_features, movie_features):
|
| 323 |
+
"""
|
| 324 |
+
Вычисление сходства между нарративными признаками запроса и фильма.
|
| 325 |
+
Использует те же 4 признака, что и в базе данных
|
| 326 |
+
"""
|
| 327 |
+
import json
|
| 328 |
+
|
| 329 |
+
if not movie_features or not query_features:
|
| 330 |
+
return 0.0
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
# Парсинг нарративных признаков фильма из JSON
|
| 334 |
+
if isinstance(movie_features, str):
|
| 335 |
+
movie_features_dict = json.loads(movie_features)
|
| 336 |
+
else:
|
| 337 |
+
movie_features_dict = movie_features
|
| 338 |
+
|
| 339 |
+
# query_features уже является словарем
|
| 340 |
+
query_features_dict = query_features
|
| 341 |
+
|
| 342 |
+
# Вычисление сходства по каждому компоненту
|
| 343 |
+
similarities = {}
|
| 344 |
+
|
| 345 |
+
# 1. Сходство ключевых слов конфликта (Jaccard similarity)
|
| 346 |
+
query_keywords = set(query_features_dict.get("conflict_keywords", []))
|
| 347 |
+
movie_keywords = set(movie_features_dict.get("conflict_keywords", []))
|
| 348 |
+
|
| 349 |
+
if query_keywords or movie_keywords:
|
| 350 |
+
intersection = len(query_keywords.intersection(movie_keywords))
|
| 351 |
+
union = len(query_keywords.union(movie_keywords))
|
| 352 |
+
similarities["keywords"] = intersection / union if union > 0 else 0.0
|
| 353 |
+
else:
|
| 354 |
+
similarities["keywords"] = 0.0
|
| 355 |
+
|
| 356 |
+
# 2. Сходство поворотов сюжета (нормализованная разность)
|
| 357 |
+
query_turns = query_features_dict.get("plot_turns", 0)
|
| 358 |
+
movie_turns = movie_features_dict.get("plot_turns", 0)
|
| 359 |
+
max_turns = max(query_turns, movie_turns, 1) # Избегаем деления на 0
|
| 360 |
+
similarities["plot_turns"] = 1.0 - abs(query_turns - movie_turns) / max_turns
|
| 361 |
+
|
| 362 |
+
# 3. Сходство эмоциональной вариативности
|
| 363 |
+
query_sentiment_var = query_features_dict.get("sentiment_variance", 0.0)
|
| 364 |
+
movie_sentiment_var = movie_features_dict.get("sentiment_variance", 0.0)
|
| 365 |
+
max_sentiment_var = max(query_sentiment_var, movie_sentiment_var, 0.1)
|
| 366 |
+
similarities["sentiment"] = 1.0 - abs(query_sentiment_var - movie_sentiment_var) / max_sentiment_var
|
| 367 |
+
|
| 368 |
+
# 4. Сходство плотности действий
|
| 369 |
+
query_action = query_features_dict.get("action_density", 0.0)
|
| 370 |
+
movie_action = movie_features_dict.get("action_density", 0.0)
|
| 371 |
+
max_action = max(query_action, movie_action, 0.1)
|
| 372 |
+
similarities["action"] = 1.0 - abs(query_action - movie_action) / max_action
|
| 373 |
+
|
| 374 |
+
# Взвешенная комбинация сходств
|
| 375 |
+
weights = {
|
| 376 |
+
"keywords": 0.4, # Наибольший вес для ключевых слов
|
| 377 |
+
"plot_turns": 0.25, # Повороты сюжета важны
|
| 378 |
+
"sentiment": 0.2, # Эмоциональная окраска
|
| 379 |
+
"action": 0.15 # Плотность действий
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
weighted_similarity = sum(
|
| 383 |
+
similarities[key] * weights[key]
|
| 384 |
+
for key in similarities.keys()
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
return weighted_similarity
|
| 388 |
+
|
| 389 |
+
except Exception as e:
|
| 390 |
+
print(f"Ошибка вычисления нарративного сходства: {e}")
|
| 391 |
+
return 0.0
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def rerank_by_narrative_features(candidates):
|
| 395 |
+
"""
|
| 396 |
+
Переранжирование кандидатов с учетом нарративных признаков
|
| 397 |
+
"""
|
| 398 |
+
for candidate in candidates:
|
| 399 |
+
movie_data = candidate['movie_data']
|
| 400 |
+
|
| 401 |
+
# Базовый семантический скор
|
| 402 |
+
semantic_score = candidate['semantic_score']
|
| 403 |
+
narrative_score = candidate.get('narrative_similarity', 0.0)
|
| 404 |
+
|
| 405 |
+
# Веса для различных компонентов
|
| 406 |
+
semantic_weight = 0.65 # Основной вес на семантику
|
| 407 |
+
narrative_weight = 0.25 # Нарративные признаки
|
| 408 |
+
quality_weight = 0.1 # Качественные метрики
|
| 409 |
+
|
| 410 |
+
# Бонусы за качественные метрики
|
| 411 |
+
rating_bonus = min(movie_data.get('vote_average', 0) / 10, 0.1)
|
| 412 |
+
popularity_bonus = min(np.log(movie_data.get('popularity', 1)) / 10, 0.1)
|
| 413 |
+
|
| 414 |
+
# Итоговый скор
|
| 415 |
+
candidate['final_score'] = (
|
| 416 |
+
semantic_score * semantic_weight +
|
| 417 |
+
narrative_score * narrative_weight +
|
| 418 |
+
rating_bonus + popularity_bonus
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
candidate['score_breakdown'] = {
|
| 422 |
+
'semantic': semantic_score,
|
| 423 |
+
'narrative': narrative_score,
|
| 424 |
+
'quality': rating_bonus,
|
| 425 |
+
'final': candidate['final_score']
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
# Сортировка по итоговому скору
|
| 429 |
+
return sorted(candidates, key=lambda x: x['final_score'], reverse=True)
|
movie_plot_search_engine.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app_simplified import _run_main_app
|
| 2 |
+
|
| 3 |
+
import modal
|
| 4 |
+
from modal_app import app
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
if __name__ == "__main__":
|
| 8 |
+
"""Запуск вычислений в Modal"""
|
| 9 |
+
print("Запуск вычислений в Modal...")
|
| 10 |
+
print("Деплоим приложение...")
|
| 11 |
+
with modal.enable_output():
|
| 12 |
+
app.deploy() # ✅ Деплоим приложение
|
| 13 |
+
_run_main_app()
|
requirements.txt
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohappyeyeballs==2.6.1
|
| 2 |
+
aiohttp==3.12.9
|
| 3 |
+
aiosignal==1.3.2
|
| 4 |
+
anyio==4.9.0
|
| 5 |
+
attrs==25.3.0
|
| 6 |
+
certifi==2025.4.26
|
| 7 |
+
charset-normalizer==3.4.2
|
| 8 |
+
click==8.1.8
|
| 9 |
+
filelock==3.18.0
|
| 10 |
+
frozenlist==1.6.2
|
| 11 |
+
fsspec==2025.5.1
|
| 12 |
+
grpclib==0.4.7
|
| 13 |
+
h2==4.2.0
|
| 14 |
+
hf-xet==1.1.3
|
| 15 |
+
hpack==4.1.0
|
| 16 |
+
huggingface-hub==0.32.4
|
| 17 |
+
hyperframe==6.1.0
|
| 18 |
+
idna==3.10
|
| 19 |
+
Jinja2==3.1.6
|
| 20 |
+
joblib==1.5.1
|
| 21 |
+
markdown-it-py==3.0.0
|
| 22 |
+
MarkupSafe==3.0.2
|
| 23 |
+
mdurl==0.1.2
|
| 24 |
+
modal==1.0.2
|
| 25 |
+
mpmath==1.3.0
|
| 26 |
+
multidict==6.4.4
|
| 27 |
+
networkx==3.5
|
| 28 |
+
numpy==1.26.4 # Специфичная версия, совместимая с torch
|
| 29 |
+
packaging==25.0
|
| 30 |
+
pandas==2.2.2
|
| 31 |
+
pillow==11.2.1
|
| 32 |
+
propcache==0.3.1
|
| 33 |
+
protobuf==6.31.1
|
| 34 |
+
Pygments==2.19.1
|
| 35 |
+
python-dateutil==2.9.0.post0
|
| 36 |
+
pytz==2025.2
|
| 37 |
+
PyYAML==6.0.2
|
| 38 |
+
regex==2024.11.6
|
| 39 |
+
requests==2.32.3
|
| 40 |
+
rich==14.0.0
|
| 41 |
+
safetensors==0.5.3
|
| 42 |
+
scikit-learn==1.7.0
|
| 43 |
+
scipy==1.15.3
|
| 44 |
+
sentence-transformers==2.7.0
|
| 45 |
+
shellingham==1.5.4
|
| 46 |
+
sigtools==4.0.1
|
| 47 |
+
six==1.17.0
|
| 48 |
+
sniffio==1.3.1
|
| 49 |
+
spacy==3.7.5
|
| 50 |
+
sympy==1.14.0
|
| 51 |
+
synchronicity==0.9.13
|
| 52 |
+
textacy==0.13.0
|
| 53 |
+
textblob==0.17.1
|
| 54 |
+
threadpoolctl==3.6.0
|
| 55 |
+
tokenizers==0.21.1
|
| 56 |
+
toml==0.10.2
|
| 57 |
+
torch==2.2.2
|
| 58 |
+
tqdm==4.66.4
|
| 59 |
+
transformers==4.52.4
|
| 60 |
+
typer>=0.9
|
| 61 |
+
types-certifi==2021.10.8.3
|
| 62 |
+
types-toml==0.10.8.20240310
|
| 63 |
+
typing_extensions==4.14.0
|
| 64 |
+
tzdata==2025.2
|
| 65 |
+
urllib3==2.4.0
|
| 66 |
+
watchfiles==1.0.5
|
| 67 |
+
yarl==1.20.0
|
| 68 |
+
|
| 69 |
+
mcp[cli]>=0.1.0
|
| 70 |
+
uvicorn
|
requirements_modal.txt
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohappyeyeballs==2.6.1
|
| 2 |
+
aiohttp==3.12.9
|
| 3 |
+
aiosignal==1.3.2
|
| 4 |
+
anyio==4.9.0
|
| 5 |
+
attrs==25.3.0
|
| 6 |
+
certifi==2025.4.26
|
| 7 |
+
charset-normalizer==3.4.2
|
| 8 |
+
click==8.1.8
|
| 9 |
+
faiss-gpu-cu12>=1.8.0 # Совместим с CUDA 12.8[3]
|
| 10 |
+
fastparquet>=2024.2.0
|
| 11 |
+
filelock==3.18.0
|
| 12 |
+
frozenlist==1.6.2
|
| 13 |
+
fsspec==2025.5.1
|
| 14 |
+
gradio>=4.0.0 # Для веб-интерфейса
|
| 15 |
+
grpclib==0.4.7
|
| 16 |
+
h2==4.2.0
|
| 17 |
+
hf-xet==1.1.3
|
| 18 |
+
hpack==4.1.0
|
| 19 |
+
huggingface-hub==0.32.4
|
| 20 |
+
hyperframe==6.1.0
|
| 21 |
+
idna==3.10
|
| 22 |
+
Jinja2==3.1.6
|
| 23 |
+
joblib==1.5.1
|
| 24 |
+
markdown-it-py==3.0.0
|
| 25 |
+
MarkupSafe==3.0.2
|
| 26 |
+
mdurl==0.1.2
|
| 27 |
+
modal==1.0.2
|
| 28 |
+
mpmath==1.3.0
|
| 29 |
+
multidict==6.4.4
|
| 30 |
+
networkx==3.5
|
| 31 |
+
numpy==1.26.4 # Специфичная версия, совместимая с torch
|
| 32 |
+
openai>=1.0.0 # Для работы с Nebius API[6]
|
| 33 |
+
packaging==25.0
|
| 34 |
+
pandas==2.2.2
|
| 35 |
+
pillow==11.2.1
|
| 36 |
+
propcache==0.3.1
|
| 37 |
+
protobuf==6.31.1
|
| 38 |
+
pyarrow>=14.0.0
|
| 39 |
+
Pygments==2.19.1
|
| 40 |
+
python-dateutil==2.9.0.post0
|
| 41 |
+
pytz==2025.2
|
| 42 |
+
PyYAML==6.0.2
|
| 43 |
+
regex==2024.11.6
|
| 44 |
+
requests==2.32.3
|
| 45 |
+
rich==14.0.0
|
| 46 |
+
safetensors==0.5.3
|
| 47 |
+
scikit-learn==1.7.0
|
| 48 |
+
scipy==1.15.3
|
| 49 |
+
sentence-transformers==2.7.0
|
| 50 |
+
shellingham==1.5.4
|
| 51 |
+
sigtools==4.0.1
|
| 52 |
+
six==1.17.0
|
| 53 |
+
sniffio==1.3.1
|
| 54 |
+
spacy[cuda-autodetect]==3.7.5
|
| 55 |
+
sympy==1.14.0
|
| 56 |
+
synchronicity==0.9.13
|
| 57 |
+
textacy==0.13.0 # Для извлечения ключевых слов
|
| 58 |
+
textblob==0.17.1 # Для анализа тональности
|
| 59 |
+
threadpoolctl==3.6.0
|
| 60 |
+
tokenizers==0.21.1
|
| 61 |
+
toml==0.10.2
|
| 62 |
+
torch==2.2.2
|
| 63 |
+
tqdm==4.66.4
|
| 64 |
+
transformers==4.52.4
|
| 65 |
+
typer>=0.9
|
| 66 |
+
types-certifi==2021.10.8.3
|
| 67 |
+
types-toml==0.10.8.20240310
|
| 68 |
+
typing_extensions==4.14.0
|
| 69 |
+
tzdata==2025.2
|
| 70 |
+
urllib3==2.4.0
|
| 71 |
+
watchfiles==1.0.5
|
| 72 |
+
yarl==1.20.0
|
| 73 |
+
# Для загрузки модели с помощью transformers
|
| 74 |
+
accelerate>=0.21.0
|
| 75 |
+
# Для 8-битной и 4-битной квантизации моделей
|
| 76 |
+
bitsandbytes>=0.43.0
|
| 77 |
+
# Для работы с pipeline и генерацией текста
|
| 78 |
+
sentencepiece
|
| 79 |
+
# LlamaIndex пакеты
|
| 80 |
+
llama-index-core>=0.10.0
|
| 81 |
+
llama-index-llms-openai>=0.1.0
|
setup_image.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Скрипт настройки образа для PlotMatcher
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run_command(cmd):
|
| 11 |
+
"""Выполнение команды с проверкой ошибок"""
|
| 12 |
+
print(f"Выполняется: {cmd}")
|
| 13 |
+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
| 14 |
+
if result.returncode != 0:
|
| 15 |
+
print(f"Ошибка: {result.stderr}")
|
| 16 |
+
sys.exit(1)
|
| 17 |
+
return result.stdout
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def main():
|
| 21 |
+
print("Настройка образа PlotMatcher...")
|
| 22 |
+
|
| 23 |
+
# Проверка CUDA
|
| 24 |
+
try:
|
| 25 |
+
output = run_command("nvidia-smi")
|
| 26 |
+
print("CUDA доступна:")
|
| 27 |
+
print(output)
|
| 28 |
+
except:
|
| 29 |
+
print("Предупреждение: nvidia-smi недоступна на этапе сборки")
|
| 30 |
+
|
| 31 |
+
# Создание необходимых директорий
|
| 32 |
+
os.makedirs("/data", exist_ok=True)
|
| 33 |
+
os.makedirs("/tmp/model_cache", exist_ok=True)
|
| 34 |
+
|
| 35 |
+
print("Настройка завершена успешно!")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
setup_punkt_extraction.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# setup_punkt_extraction.py
|
| 2 |
+
|
| 3 |
+
import pickle
|
| 4 |
+
import os
|
| 5 |
+
import ast
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def extract_punkt_data_to_files():
|
| 10 |
+
"""Извлечение данных из english.pickle в отдельные файлы"""
|
| 11 |
+
|
| 12 |
+
# Путь к pickle файлу
|
| 13 |
+
pickle_path = "/root/nltk_data/tokenizers/punkt_tab/english/english.pickle"
|
| 14 |
+
output_dir = "/root/nltk_data/tokenizers/punkt_tab/english"
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
print(f"Loading punkt model from {pickle_path}")
|
| 18 |
+
|
| 19 |
+
# Загрузка модели
|
| 20 |
+
with open(pickle_path, 'rb') as f:
|
| 21 |
+
punkt_model = pickle.load(f)
|
| 22 |
+
|
| 23 |
+
print(f"Punkt model loaded successfully: {type(punkt_model)}")
|
| 24 |
+
|
| 25 |
+
# 1. Извлечение sentence starters
|
| 26 |
+
try:
|
| 27 |
+
if hasattr(punkt_model, '_lang_vars') and punkt_model._lang_vars:
|
| 28 |
+
sent_starters = punkt_model._lang_vars.sent_starters
|
| 29 |
+
with open(f"{output_dir}/sent_starters.txt", 'w') as f:
|
| 30 |
+
f.write('\n'.join(sent_starters))
|
| 31 |
+
print(f"✅ Created sent_starters.txt with {len(sent_starters)} entries")
|
| 32 |
+
else:
|
| 33 |
+
print("⚠️ No sentence starters found, creating default ones")
|
| 34 |
+
default_starters = ["i", "you", "he", "she", "it", "we", "they", "the", "a", "an"]
|
| 35 |
+
with open(f"{output_dir}/sent_starters.txt", 'w') as f:
|
| 36 |
+
f.write('\n'.join(default_starters))
|
| 37 |
+
except Exception as e:
|
| 38 |
+
print(f"⚠️ Error extracting sentence starters: {e}")
|
| 39 |
+
# Создаем базовые стартеры
|
| 40 |
+
default_starters = ["i", "you", "he", "she", "it", "we", "they", "the", "a", "an"]
|
| 41 |
+
with open(f"{output_dir}/sent_starters.txt", 'w') as f:
|
| 42 |
+
f.write('\n'.join(default_starters))
|
| 43 |
+
|
| 44 |
+
# 2. Извлечение collocations
|
| 45 |
+
try:
|
| 46 |
+
if hasattr(punkt_model, '_params') and punkt_model._params:
|
| 47 |
+
collocations = punkt_model._params.collocations
|
| 48 |
+
with open(f"{output_dir}/collocations.tab", 'w') as f:
|
| 49 |
+
for (word1, word2), freq in collocations.items():
|
| 50 |
+
f.write(f"{word1}\t{word2}\t{freq}\n")
|
| 51 |
+
print(f"✅ Created collocations.tab with {len(collocations)} entries")
|
| 52 |
+
else:
|
| 53 |
+
# Создаем пустой файл
|
| 54 |
+
open(f"{output_dir}/collocations.tab", 'w').close()
|
| 55 |
+
print("✅ Created empty collocations.tab")
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"⚠️ Error extracting collocations: {e}")
|
| 58 |
+
open(f"{output_dir}/collocations.tab", 'w').close()
|
| 59 |
+
|
| 60 |
+
# 3. Создание остальных файлов
|
| 61 |
+
try:
|
| 62 |
+
# Abbreviations
|
| 63 |
+
if hasattr(punkt_model, '_params') and hasattr(punkt_model._params, 'abbrev_types'):
|
| 64 |
+
with open(f"{output_dir}/abbrev_types.txt", 'w') as f:
|
| 65 |
+
f.write('\n'.join(punkt_model._params.abbrev_types))
|
| 66 |
+
print("✅ Created abbrev_types.txt from model")
|
| 67 |
+
else:
|
| 68 |
+
# Создаем пустой файл
|
| 69 |
+
open(f"{output_dir}/abbrev_types.txt", 'w').close()
|
| 70 |
+
print("✅ Created empty abbrev_types.txt")
|
| 71 |
+
|
| 72 |
+
# Ortho context (обычно пустой)
|
| 73 |
+
open(f"{output_dir}/ortho_context.tab", 'w').close()
|
| 74 |
+
print("✅ Created empty ortho_context.tab")
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f"⚠️ Warning creating additional files: {e}")
|
| 78 |
+
# Создаем пустые файлы на всякий случай
|
| 79 |
+
for filename in ["abbrev_types.txt", "ortho_context.tab"]:
|
| 80 |
+
open(f"{output_dir}/{filename}", 'w').close()
|
| 81 |
+
|
| 82 |
+
print("✅ All punkt_tab files created successfully")
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
print(f"❌ Error extracting punkt data: {e}")
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
success = extract_punkt_data_to_files()
|
| 92 |
+
sys.exit(0 if success else 1)
|
test_mcp.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# test_mcp.py
|
| 2 |
+
import asyncio
|
| 3 |
+
from tools.client import MCPToolManager
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
async def main():
|
| 7 |
+
print("🔌 Testing MCP Integration...")
|
| 8 |
+
|
| 9 |
+
manager = MCPToolManager()
|
| 10 |
+
|
| 11 |
+
# Подключаем локальный сервер поиска (он будет дергать Modal внутри)
|
| 12 |
+
# Убедитесь, что путь правильный относительно запуска
|
| 13 |
+
await manager.connect_to_server("vectordb", "tools/mcp_server_vectordb.py")
|
| 14 |
+
|
| 15 |
+
print("\n📋 Listing available tools via MCP:")
|
| 16 |
+
tools = await manager.list_tools()
|
| 17 |
+
for t in tools:
|
| 18 |
+
print(f" - {t.name}: {t.description}")
|
| 19 |
+
|
| 20 |
+
print("\n🔍 Testing search_plots tool via MCP protocol...")
|
| 21 |
+
try:
|
| 22 |
+
# Реальный вызов через MCP -> Client -> Server -> Modal
|
| 23 |
+
result = await manager.call_tool("search_plots", {
|
| 24 |
+
"query": "In a distant future, Earth has been abandoned and covered in trash. "
|
| 25 |
+
"A small waste-collecting robot has been left behind to clean up the planet. "
|
| 26 |
+
"He spends his days compacting garbage into cubes, but he's completely alone "
|
| 27 |
+
"and dreams of companionship. One day, a sleek probe robot arrives from space, "
|
| 28 |
+
"and he falls in love with her, leading to an adventure across the galaxy.",
|
| 29 |
+
"limit": 5
|
| 30 |
+
})
|
| 31 |
+
# ✅ ИСПРАВЛЕНО: Правильное извлечение содержимого из CallToolResult
|
| 32 |
+
if hasattr(result, 'content') and result.content:
|
| 33 |
+
# result.content - это список TextContent объектов
|
| 34 |
+
# Берём текст из первого элемента
|
| 35 |
+
content_text = result.content[0].text
|
| 36 |
+
print(f"✅ Result received:\n{content_text}")
|
| 37 |
+
else:
|
| 38 |
+
# Fallback на случай неожиданного формата
|
| 39 |
+
print(f"✅ Result received: {result}")
|
| 40 |
+
|
| 41 |
+
# print(f"✅ Result received: {result[:200]}...") # Печатаем начало JSON
|
| 42 |
+
except Exception as e:
|
| 43 |
+
import traceback
|
| 44 |
+
print(f"❌ Error: {e}")
|
| 45 |
+
traceback.print_exc()
|
| 46 |
+
|
| 47 |
+
finally:
|
| 48 |
+
await manager.cleanup()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
asyncio.run(main())
|
| 53 |
+
|
tools/client.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tools/client.py
|
| 2 |
+
import asyncio
|
| 3 |
+
from contextlib import AsyncExitStack
|
| 4 |
+
from mcp import ClientSession, StdioServerParameters
|
| 5 |
+
from mcp.client.stdio import stdio_client
|
| 6 |
+
import logging
|
| 7 |
+
from typing import List, Dict, Any
|
| 8 |
+
|
| 9 |
+
# Настройка логирования
|
| 10 |
+
logging.basicConfig(level=logging.INFO)
|
| 11 |
+
logger = logging.getLogger("mcp-client")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class MCPToolManager:
|
| 15 |
+
"""
|
| 16 |
+
Безопасный клиент для управления инструментами MCP.
|
| 17 |
+
Реализует Explicit Allowlist для защиты от инъекций возможностей.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.exit_stack = AsyncExitStack()
|
| 22 |
+
self.sessions = {}
|
| 23 |
+
|
| 24 |
+
# --- EXPLICIT ALLOWLIST ---
|
| 25 |
+
# Только эти инструменты будут доступны агентам.
|
| 26 |
+
# Любые другие инструменты, предлагаемые серверами, будут игнорироваться.
|
| 27 |
+
self.ALLOWED_TOOLS = {
|
| 28 |
+
"search_plots", # из vectordb
|
| 29 |
+
"get_movie_details", # из tmdb
|
| 30 |
+
# "get_credits" # можно раскомментировать, если нужно
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async def connect_to_server(self, server_name: str, script_path: str):
|
| 34 |
+
"""Подключается к локальному Python-скрипту как к MCP серверу"""
|
| 35 |
+
# Используем python для запуска скрипта сервера
|
| 36 |
+
server_params = StdioServerParameters(
|
| 37 |
+
command="python",
|
| 38 |
+
args=[script_path],
|
| 39 |
+
env=None
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
# Инициализация транспорта и сессии
|
| 44 |
+
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
|
| 45 |
+
read, write = stdio_transport
|
| 46 |
+
session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
| 47 |
+
await session.initialize()
|
| 48 |
+
|
| 49 |
+
self.sessions[server_name] = session
|
| 50 |
+
logger.info(f"✅ Connected to MCP server: {server_name}")
|
| 51 |
+
|
| 52 |
+
# Валидация и логирование доступных инструментов
|
| 53 |
+
await self._validate_tools(server_name, session)
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"❌ Failed to connect to {server_name} ({script_path}): {e}")
|
| 57 |
+
|
| 58 |
+
async def _validate_tools(self, server_name: str, session: ClientSession):
|
| 59 |
+
"""Проверяет инструменты сервера на соответствие белому списку"""
|
| 60 |
+
tools_response = await session.list_tools()
|
| 61 |
+
|
| 62 |
+
allowed_count = 0
|
| 63 |
+
blocked_count = 0
|
| 64 |
+
|
| 65 |
+
for tool in tools_response.tools:
|
| 66 |
+
if tool.name in self.ALLOWED_TOOLS:
|
| 67 |
+
allowed_count += 1
|
| 68 |
+
else:
|
| 69 |
+
blocked_count += 1
|
| 70 |
+
logger.warning(f"⚠️ BLOCKED tool '{tool.name}' from {server_name} (not in allowlist)")
|
| 71 |
+
|
| 72 |
+
logger.info(f"Server {server_name}: {allowed_count} allowed, {blocked_count} blocked.")
|
| 73 |
+
|
| 74 |
+
async def list_tools(self) -> List[Any]:
|
| 75 |
+
"""Возвращает список всех РАЗРЕШЕННЫХ инструментов со всех серверов"""
|
| 76 |
+
all_tools = []
|
| 77 |
+
for session in self.sessions.values():
|
| 78 |
+
result = await session.list_tools()
|
| 79 |
+
# Фильтруем на лету
|
| 80 |
+
valid_tools = [t for t in result.tools if t.name in self.ALLOWED_TOOLS]
|
| 81 |
+
all_tools.extend(valid_tools)
|
| 82 |
+
return all_tools
|
| 83 |
+
|
| 84 |
+
async def call_tool(self, tool_name: str, arguments: dict) -> Any:
|
| 85 |
+
"""Вызов инструмента на нужном сервере"""
|
| 86 |
+
# Дополнительная проверка перед вызовом
|
| 87 |
+
if tool_name not in self.ALLOWED_TOOLS:
|
| 88 |
+
raise SecurityError(f"Security Alert: Attempt to call unauthorized tool '{tool_name}'")
|
| 89 |
+
|
| 90 |
+
for name, session in self.sessions.items():
|
| 91 |
+
# Проверяем, есть ли инструмент на этом сервере
|
| 92 |
+
tools = await session.list_tools()
|
| 93 |
+
if any(t.name == tool_name for t in tools.tools):
|
| 94 |
+
logger.info(f"📞 Calling {tool_name} on {name} with args: {arguments}")
|
| 95 |
+
result = await session.call_tool(tool_name, arguments)
|
| 96 |
+
return result
|
| 97 |
+
|
| 98 |
+
raise ValueError(f"Tool {tool_name} not found on any connected server")
|
| 99 |
+
|
| 100 |
+
async def cleanup(self):
|
| 101 |
+
"""Закрытие всех соединений"""
|
| 102 |
+
await self.exit_stack.aclose()
|
tools/mcp_server_tmdb.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tools/mcp_server_tmdb.py
|
| 2 |
+
from mcp.server.fastmcp import FastMCP
|
| 3 |
+
import pandas as pd
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# Настройка логирования
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger("mcp-tmdb")
|
| 11 |
+
|
| 12 |
+
# Инициализация MCP сервера
|
| 13 |
+
mcp = FastMCP("TMDB_Local_Data")
|
| 14 |
+
|
| 15 |
+
# Глобальная переменная для DataFrame
|
| 16 |
+
# Мы используем lazy loading, чтобы не грузить память при старте, если не нужно
|
| 17 |
+
MOVIES_DF = None
|
| 18 |
+
METADATA_PATH = "data/indexed_movies_metadata.parquet" # Путь, куда modal_app.py сохранил метаданные
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_movies_df():
|
| 22 |
+
"""Ленивая загрузка и кэширование датафрейма"""
|
| 23 |
+
global MOVIES_DF
|
| 24 |
+
if MOVIES_DF is None:
|
| 25 |
+
if not os.path.exists(METADATA_PATH):
|
| 26 |
+
# Если запускаемся локально и файла нет, можно попробовать скачать его с Modal Volume
|
| 27 |
+
# Но для простоты предполагаем, что он есть (или смонтирован)
|
| 28 |
+
logger.error(f"Metadata file not found at {METADATA_PATH}")
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
logger.info(f"Loading metadata from {METADATA_PATH}...")
|
| 32 |
+
try:
|
| 33 |
+
df = pd.read_parquet(METADATA_PATH)
|
| 34 |
+
# Создаем индекс по ID для мгновенного поиска O(1)
|
| 35 |
+
# Убедимся, что ID - это int (иногда бывают строки)
|
| 36 |
+
if 'id' in df.columns:
|
| 37 |
+
df['id'] = df['id'].astype(int)
|
| 38 |
+
df.set_index('id', inplace=True)
|
| 39 |
+
MOVIES_DF = df
|
| 40 |
+
logger.info(f"Loaded {len(df)} movies into memory.")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Failed to load metadata: {e}")
|
| 43 |
+
return None
|
| 44 |
+
return MOVIES_DF
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@mcp.tool()
|
| 48 |
+
def get_movie_details(movie_id: int) -> str:
|
| 49 |
+
"""
|
| 50 |
+
Get enriched metadata for a movie by its TMDB ID.
|
| 51 |
+
Use this to get accurate Cast, Director, Ratings, and Runtime for the final recommendation card.
|
| 52 |
+
|
| 53 |
+
Args:
|
| 54 |
+
movie_id: The integer ID of the movie (from search results).
|
| 55 |
+
"""
|
| 56 |
+
df = _get_movies_df()
|
| 57 |
+
if df is None:
|
| 58 |
+
return json.dumps({"error": "Metadata service unavailable"})
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
# Поиск по индексу (очень быстрый)
|
| 62 |
+
if movie_id not in df.index:
|
| 63 |
+
return json.dumps({"error": f"Movie ID {movie_id} not found in local database"})
|
| 64 |
+
|
| 65 |
+
movie = df.loc[movie_id]
|
| 66 |
+
|
| 67 |
+
# Формируем богатый ответ.
|
| 68 |
+
# Используем .get(), так как не все поля могут быть в Parquet
|
| 69 |
+
details = {
|
| 70 |
+
"id": movie_id,
|
| 71 |
+
"title": str(movie.get("title", "Unknown")),
|
| 72 |
+
"original_title": str(movie.get("original_title", "")),
|
| 73 |
+
"overview": str(movie.get("overview", "")), # Полный текст, если в поиске был обрезан
|
| 74 |
+
"genres": str(movie.get("genres", "Unknown")),
|
| 75 |
+
"director": str(movie.get("director", "Unknown")), # Если вы сохраняли это поле
|
| 76 |
+
"cast": str(movie.get("cast", "Unknown")), # Если вы сохраняли это поле
|
| 77 |
+
"vote_average": float(movie.get("vote_average", 0.0)),
|
| 78 |
+
"release_date": str(movie.get("release_date", "N/A")),
|
| 79 |
+
"runtime": int(movie.get("runtime", 0)),
|
| 80 |
+
# Нарративные признаки тоже можно вернуть, если Эксперту нужно объяснить "почему"
|
| 81 |
+
"narrative_features": str(movie.get("narrative_features", "{}"))
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return json.dumps(details, ensure_ascii=False)
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
logger.error(f"Error fetching details for {movie_id}: {e}")
|
| 88 |
+
return json.dumps({"error": str(e)})
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@mcp.tool()
|
| 92 |
+
def get_credits(movie_id: int) -> str:
|
| 93 |
+
"""
|
| 94 |
+
Get specifically the cast and director (if not available in get_movie_details).
|
| 95 |
+
Legacy tool support.
|
| 96 |
+
"""
|
| 97 |
+
# В нашей архитектуре это избыточно, если get_movie_details возвращает всё.
|
| 98 |
+
# Но оставим как алиас.
|
| 99 |
+
return get_movie_details(movie_id)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
# Для локального запуска
|
| 104 |
+
mcp.run()
|
tools/mcp_server_vectordb.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# tools/mcp_server_vectordb.py
|
| 2 |
+
from mcp.server.fastmcp import FastMCP
|
| 3 |
+
import modal
|
| 4 |
+
import logging
|
| 5 |
+
import json
|
| 6 |
+
from datetime import date
|
| 7 |
+
|
| 8 |
+
# Настройка логирования
|
| 9 |
+
logging.basicConfig(level=logging.INFO)
|
| 10 |
+
logger = logging.getLogger("mcp-vectordb")
|
| 11 |
+
|
| 12 |
+
# Создаем MCP сервер
|
| 13 |
+
mcp = FastMCP("MovieVectorDB")
|
| 14 |
+
|
| 15 |
+
# Подключение к Modal (инициализируется один раз)
|
| 16 |
+
try:
|
| 17 |
+
encode_func = modal.Function.from_name("tmdb-project", "encode_user_query")
|
| 18 |
+
search_func = modal.Function.from_name("tmdb-project", "search_similar_movies")
|
| 19 |
+
logger.info("✅ Connected to Modal search functions")
|
| 20 |
+
except Exception as e:
|
| 21 |
+
logger.error(f"❌ Failed to connect to Modal: {e}")
|
| 22 |
+
encode_func = None
|
| 23 |
+
search_func = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@mcp.tool()
|
| 27 |
+
def search_plots(query: str, filters: str = None, limit: int = 5) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Semantic search for movies based on a plot description.
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
query: Detailed description of the movie plot, theme, or story (e.g. "A robot learns to love").
|
| 33 |
+
filters: JSON string with narrative filters (optional). Example: '{"min_action": 0.5}'.
|
| 34 |
+
limit: Number of movies to return (default 5, max 20).
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
JSON string containing a list of found movies with their IDs, titles, and relevance scores.
|
| 38 |
+
"""
|
| 39 |
+
if not encode_func or not search_func:
|
| 40 |
+
return json.dumps({"error": "Search service unavailable"})
|
| 41 |
+
|
| 42 |
+
logger.info(f"Searching for: {query[:50]}...")
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
# 1. Векторизация (используем логику из modal_app.py)
|
| 46 |
+
# remove_entities=True - важно для поиска по смыслу
|
| 47 |
+
encoding_result = encode_func.remote(query, remove_entities=True)
|
| 48 |
+
# ✅ ИЗМЕНЕНО: Отключаем remove_entities для сохранения всех слов
|
| 49 |
+
# encoding_result = encode_func.remote(query, remove_entities=False)
|
| 50 |
+
|
| 51 |
+
# 2. Поиск
|
| 52 |
+
# Преобразуем фильтры из строки, если они есть
|
| 53 |
+
narrative_params = encoding_result["narrative_features"]
|
| 54 |
+
if filters:
|
| 55 |
+
try:
|
| 56 |
+
extra_filters = json.loads(filters)
|
| 57 |
+
narrative_params.update(extra_filters)
|
| 58 |
+
except:
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
results_dict = search_func.remote(
|
| 62 |
+
query_embedding=encoding_result["embedding"],
|
| 63 |
+
query_narrative_features=narrative_params,
|
| 64 |
+
top_k=limit * 2, # Берем с запасом
|
| 65 |
+
rerank_top_n=limit
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
candidates = results_dict.get("results", [])
|
| 69 |
+
|
| 70 |
+
# Возвращаем упрощенный список для LLM (экономия токенов)
|
| 71 |
+
simplified_results = []
|
| 72 |
+
for c in candidates:
|
| 73 |
+
movie = c.get("movie_data", {})
|
| 74 |
+
|
| 75 |
+
release_date = movie.get("release_date")
|
| 76 |
+
year = "N/A"
|
| 77 |
+
|
| 78 |
+
if release_date:
|
| 79 |
+
if isinstance(release_date, str):
|
| 80 |
+
# Если строка - берём первые 4 символа
|
| 81 |
+
year = release_date[:4] if len(release_date) >= 4 else release_date
|
| 82 |
+
elif isinstance(release_date, date):
|
| 83 |
+
# Если datetime.date объект - используем .year
|
| 84 |
+
year = str(release_date.year)
|
| 85 |
+
else:
|
| 86 |
+
# Пробуем преобразовать к строке
|
| 87 |
+
year = str(release_date)[:4]
|
| 88 |
+
simplified_results.append({
|
| 89 |
+
"id": movie.get("id"),
|
| 90 |
+
"title": movie.get("title"),
|
| 91 |
+
"relevance_score": c.get("final_score", 0.0),
|
| 92 |
+
"year": year,
|
| 93 |
+
"overview": movie.get("overview", "")[:150] + "..."
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
return json.dumps(simplified_results, indent=2)
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
import traceback
|
| 100 |
+
logger.error(f"Search error: {e}")
|
| 101 |
+
traceback.print_exc()
|
| 102 |
+
return json.dumps({"error": str(e)})
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
mcp.run()
|