dbadeev commited on
Commit
da524e0
·
verified ·
1 Parent(s): 1b6013c

Upload 24 files

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