diff --git a/nodes.py b/nodes.py index e6dbca0..f659c95 100644 --- a/nodes.py +++ b/nodes.py @@ -1,120 +1,185 @@ +import os from langchain_openai import ChatOpenAI -from langchain_core.messages import HumanMessage -from pydantic import BaseModel, Field, ValidationError -from state import ReflectState +from langchain_core.messages import HumanMessage, SystemMessage +from langchain_tavily import TavilySearch +from state import BriefState -# Инициализация LLM -llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.7) +# Инициализация LLM и поискового инструмента +llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.5) +tavily = TavilySearch( + api_key=os.getenv("TAVILY_API_KEY"), + max_results=3, # Количество результатов поиска + include_answer=True, + include_raw_content=False +) -# Pydantic модель для валидации ответа -class ValidAnswer(BaseModel): - """Модель для валидации качества ответа""" - content: str = Field(description="Ответ на вопрос") - is_complete: bool = Field(description="Полнота ответа (должно быть True)") - has_examples: bool = Field(description="Наличие примеров (должно быть True)") - is_concise: bool = Field(description="Отсутствие воды (должно быть True)") +# Промпты +OUTLINE_PROMPT = """Ты — исследовательский ассистент. Составь план исследования по теме: {topic} + +Требования: +- 4-5 пунктов +- Каждый пункт должен быть конкретным аспектом для исследования +- Пункты должны логически раскрывать тему +- Формат вывода: просто список пунктов (каждый с новой строки, начинается с цифры) + +Пример: +1. Основные понятия и определения +2. Ключевые технологии и инструменты +3. Практические примеры использования +4. Потенциальные риски и их mitigation +5. Лучшие практики и рекомендации + +Тема: {topic} +План:""" + +RESEARCH_PROMPT = """Ты — исследователь, собирающий информацию по конкретному пункту плана. + +Тема исследования: {topic} +Текущий пункт плана: {outline_item} + +На основе результатов поиска (ниже) составь краткую заметку (5-8 предложений), которая: +1. Отвечает на пункт плана +2. Содержит конкретные факты, данные, примеры +3. Использует информацию из результатов поиска +4. Пишется связным текстом (не маркированным списком) + +Результаты поиска: +{search_results} + +Заметка по пункту "{outline_item}":""" + +SYNTHESIS_PROMPT = """Ты — аналитик, который создает итоговый исследовательский бриф. + +Тема: {topic} +План исследования: +{outline} + +Собранные заметки по пунктам: +{notes} + +Задание: создай связный исследовательский бриф (объемом ½ - 1 страницу), который: +1. Имеет заголовок "Исследовательский бриф: {topic}" +2. Структурирован по пунктам плана (каждый пункт как подзаголовок) +3. Содержит введение (1 абзац) и заключение (1 абзац) +4. Объединяет заметки в единый поток, убирает повторы +5. Добавляет связующие предложения между пунктами +6. Завершается списком ключевых выводов (3-4 пункта) + +Форматирование: используй markdown для заголовков и списков. + +Бриф:""" + +def outline_node(state: BriefState) -> BriefState: + """Узел: генерация плана исследования""" + print(f"\n📋 Генерация плана исследования по теме: {state['topic']}") - def validate_quality(self) -> tuple[bool, str]: - """Проверяет качество ответа по критериям""" - issues = [] - - if not self.is_complete: - issues.append("неполный ответ") - if not self.has_examples: - issues.append("нет конкретных примеров") - if not self.is_concise: - issues.append("содержит воду/общие фразы") - - if not self.content or len(self.content.split()) < 50: - issues.append("слишком короткий ответ (<50 слов)") - - if len(self.content.split()) > 300: - issues.append("слишком длинный ответ (>300 слов)") - - if issues: - return False, f"Проблемы: {', '.join(issues)}" - return True, "OK" + prompt = OUTLINE_PROMPT.format(topic=state["topic"]) + response = llm.invoke([HumanMessage(content=prompt)]) + + # Парсим план в список + outline_lines = response.content.strip().split('\n') + outline = [] + for line in outline_lines: + # Убираем номера пунктов и лишние символы + cleaned = line.strip() + if cleaned and cleaned[0].isdigit(): + # Убираем "1. " или "1)" и т.д. + import re + cleaned = re.sub(r'^\d+[\.\)]\s*', '', cleaned) + outline.append(cleaned) + + print(f"✅ Создан план из {len(outline)} пунктов:") + for i, item in enumerate(outline, 1): + print(f" {i}. {item}") + + return { + **state, + "outline": outline, + "step_index": 0, + "notes": [] + } -# Промпт для генерации с инструкцией по качеству -ANSWER_PROMPT = """Ты — эксперт, который дает качественные ответы. -Требования к ответу: -1. Полнота: ответ покрывает все аспекты вопроса -2. Конкретика: есть примеры, детали, точные формулировки -3. Краткость: 5-10 предложений, без воды - -Вопрос: {question} - -После ответа добавь метаданные в формате JSON: -{{ - "is_complete": true/false, - "has_examples": true/false, - "is_concise": true/false -}} - -Сам ответ должен быть в markdown формате. -""" - -def generate_answer(state: ReflectState) -> ReflectState: - """ - Узел генерации ответа с try/except и валидацией - При ошибке заполняет поле error - """ - print(f"\n📝 [Попытка {state['round']}/{state['max_rounds']}] Генерация ответа...") +def research_step_node(state: BriefState) -> BriefState: + """Узел: исследование одного пункта плана с поиском в интернете""" + current_step = state["step_index"] + outline_item = state["outline"][current_step] + + print(f"\n🔍 [Шаг {current_step + 1}/{len(state['outline'])}] Исследование: {outline_item}") + + # Формируем поисковый запрос + search_query = f"{state['topic']} {outline_item}" + print(f" Поисковый запрос: {search_query}") try: - # 1. Генерация ответа - prompt = ANSWER_PROMPT.format(question=state["question"]) - response = llm.invoke([HumanMessage(content=prompt)]) + # Выполняем поиск через Tavily + search_result = tavily.invoke({"query": search_query}) - # 2. Парсинг JSON-метаданных из ответа - import json - import re + # Форматируем результаты поиска + if isinstance(search_result, list): + formatted_results = [] + for idx, result in enumerate(search_result, 1): + if hasattr(result, 'content') and result.content: + formatted_results.append(f"{idx}. {result.content[:500]}") + elif isinstance(result, dict) and 'content' in result: + formatted_results.append(f"{idx}. {result['content'][:500]}") + search_text = "\n\n".join(formatted_results) if formatted_results else "Результаты не найдены." + elif isinstance(search_result, str): + search_text = search_result + else: + search_text = str(search_result) - # Ищем JSON в конце ответа - json_match = re.search(r'\{[^{}]*"is_complete"[^{}]*\}', response.content) - if not json_match: - raise ValueError("Не найдены метаданные JSON в ответе") - - metadata = json.loads(json_match.group()) - - # 3. Создаем объект ValidAnswer - answer_obj = ValidAnswer( - content=response.content, - is_complete=metadata.get("is_complete", False), - has_examples=metadata.get("has_examples", False), - is_concise=metadata.get("is_concise", False) + # Генерируем заметку на основе поиска + research_prompt = RESEARCH_PROMPT.format( + topic=state["topic"], + outline_item=outline_item, + search_results=search_text[:1500] # Ограничиваем длину ) - # 4. Валидация качества - is_valid, error_message = answer_obj.validate_quality() + response = llm.invoke([HumanMessage(content=research_prompt)]) + note = response.content.strip() - if not is_valid: - raise ValueError(error_message) + print(f" ✅ Заметка создана ({len(note)} символов)") - # 5. Успех - очищаем ошибку - print(f" ✅ Ответ прошел валидацию!") - return { - **state, - "draft": answer_obj.content, - "error": None - } - - except (ValidationError, ValueError, json.JSONDecodeError) as e: - # Перехватываем все ошибки валидации - error_msg = str(e) - print(f" ❌ Ошибка валидации: {error_msg}") - - return { - **state, - "draft": "", # Очищаем невалидный ответ - "error": error_msg - } - except Exception as e: - # Другие ошибки (сеть, API и т.д.) - print(f" ❌ Критическая ошибка: {str(e)}") - return { - **state, - "draft": "", - "error": f"Критическая ошибка: {str(e)}" - } \ No newline at end of file + print(f" ⚠️ Ошибка поиска: {e}") + # Fallback: генерируем заметку без поиска + fallback_prompt = f"Напиши краткую заметку (5-8 предложений) по пункту '{outline_item}' для темы '{state['topic']}' (без внешнего поиска)." + response = llm.invoke([HumanMessage(content=fallback_prompt)]) + note = response.content.strip() + print(f" ⚠️ Создана fallback-заметка без поиска") + + # Добавляем заметку в список + updated_notes = state["notes"] + [note] + + return { + **state, + "notes": updated_notes, + "step_index": state["step_index"] + 1 + } + +def synthesize_node(state: BriefState) -> BriefState: + """Узел: синтез итогового брифа из собранных заметок""" + print(f"\n📝 Синтез итогового исследовательского брифа...") + + # Форматируем план и заметки для промпта + outline_text = "\n".join([f"{i+1}. {item}" for i, item in enumerate(state["outline"])]) + notes_text = "\n\n---\n\n".join([ + f"**Пункт {i+1}: {state['outline'][i]}**\n{note}" + for i, note in enumerate(state["notes"]) + ]) + + prompt = SYNTHESIS_PROMPT.format( + topic=state["topic"], + outline=outline_text, + notes=notes_text + ) + + response = llm.invoke([HumanMessage(content=prompt)]) + final_brief = response.content.strip() + + print(f"✅ Бриф создан ({len(final_brief)} символов)") + + return { + **state, + "final_brief": final_brief + } \ No newline at end of file