diff --git a/nodes.py b/nodes.py index 7538a4f..e6dbca0 100644 --- a/nodes.py +++ b/nodes.py @@ -1,187 +1,120 @@ -import os from langchain_openai import ChatOpenAI -from langchain_core.messages import HumanMessage, SystemMessage -from langchain_tavily import TavilySearch -from state import BriefState +from langchain_core.messages import HumanMessage +from pydantic import BaseModel, Field, ValidationError +from state import ReflectState # Инициализация LLM -llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.3) +llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.7) -# Инициализация Tavily search tool -tavily_search = TavilySearch( - max_results=3, - topic="general", - include_answer=False, - include_raw_content=False -) - -# Промпты для узлов -OUTLINE_PROMPT = """Ты — исследовательский ассистент. Составь план из 4-5 пунктов для исследования темы. - -Тема: {topic} - -Требования к плану: -- Каждый пункт должен быть конкретным и исследовательским -- Пункты должны покрывать разные аспекты темы -- Используй четкие формулировки (например: "История развития", "Ключевые технологии", "Практические примеры") - -Ответ дай в виде списка, каждый пункт на новой строке, начиная с "- ". - -Пример: -- Текущее состояние и тренды -- Основные вызовы и решения -- Рекомендации для начинающих""" - -RESEARCH_PROMPT = """Ты — исследователь. На основе результатов поиска составь краткую заметку (5-8 предложений) по следующему пункту плана. - -Пункт плана: {outline_item} - -Результаты поиска: -{search_results} - -Заметка должна: -- Быть информативной и конкретной -- Опираться на факты из поиска -- Содержать 5-8 предложений -- Заканчиваться выводом или ключевым инсайтом""" - -SYNTHESIS_PROMPT = """Ты — аналитик. Объедини собранные заметки в связный исследовательский бриф (полстраницы - страница). - -Тема: {topic} - -Заметки по пунктам плана: -{notes} - -Требования к брифу: -1. Начни с краткого введения (2-3 предложения) -2. Каждый пункт плана оформи как отдельный раздел с заголовком -3. Сохрани ключевые факты и инсайты из заметок -4. Добавь заключение с общими выводами (3-4 предложения) -5. Используй связующие фразы между разделами - -Формат: -# Исследовательский бриф: {topic} - -## Введение -[текст] - -## [Заголовок пункта 1] -[текст] - -## [Заголовок пункта 2] -[текст] - -... - -## Заключение -[текст]""" - -def outline_node(state: BriefState) -> BriefState: - """Узел 1: Составление плана исследования""" - print("\n📋 [1/4] Составляю план исследования...") +# 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)") - prompt = OUTLINE_PROMPT.format(topic=state["topic"]) - response = llm.invoke([HumanMessage(content=prompt)]) - - # Парсим список из ответа - lines = response.content.strip().split('\n') - outline = [] - for line in lines: - line = line.strip() - if line.startswith('- '): - outline.append(line[2:]) - elif line.startswith('• '): - outline.append(line[2:]) - elif line and not line.startswith('```'): - # Если нет маркера, убираем номер - clean = line.lstrip('0123456789. ').strip() - if clean: - outline.append(clean) - - # Ограничиваем 5 пунктами - outline = outline[:5] - - print(f" ✅ План составлен: {len(outline)} пунктов") - for i, item in enumerate(outline, 1): - print(f" {i}. {item}") - - new_state = state.copy() - new_state["outline"] = outline - new_state["step_index"] = 0 - new_state["notes"] = [] - return new_state + 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" -def research_step_node(state: BriefState) -> BriefState: - """Узел 2: Исследование одного пункта плана через web search""" - current_idx = state["step_index"] - current_item = state["outline"][current_idx] - - print(f"\n🔍 [2/4] Исследую пункт {current_idx + 1}/{len(state['outline'])}: {current_item}") - - # Формируем поисковый запрос - search_query = f"{state['topic']} {current_item}" - print(f" Запрос в Tavily: {search_query}") +# Промпт для генерации с инструкцией по качеству +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']}] Генерация ответа...") try: - # Выполняем поиск - search_result = tavily_search.invoke({"query": search_query}) + # 1. Генерация ответа + prompt = ANSWER_PROMPT.format(question=state["question"]) + response = llm.invoke([HumanMessage(content=prompt)]) - # Извлекаем текст результатов - if isinstance(search_result, list): - results_text = "\n\n".join([ - f"Источник {i+1}: {item.get('content', '')[:500]}" - for i, item in enumerate(search_result[:3]) - if item.get('content') - ]) - else: - results_text = str(search_result)[:1500] + # 2. Парсинг JSON-метаданных из ответа + import json + import re - # Генерируем заметку на основе поиска - prompt = RESEARCH_PROMPT.format( - outline_item=current_item, - search_results=results_text + # Ищем 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) ) - response = llm.invoke([HumanMessage(content=prompt)]) - note = response.content.strip() - print(f" ✅ Заметка создана ({len(note)} символов)") + # 4. Валидация качества + is_valid, error_message = answer_obj.validate_quality() + if not is_valid: + raise ValueError(error_message) + + # 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: - print(f" ⚠️ Ошибка поиска: {e}") - # Fallback: заметка без поиска - prompt = f"Напиши краткую заметку (5-8 предложений) о: {current_item} в контексте {state['topic']}" - response = llm.invoke([HumanMessage(content=prompt)]) - note = response.content.strip() - print(f" ✅ Заметка создана (без поиска)") - - # Обновляем состояние - new_state = state.copy() - new_state["notes"] = state["notes"] + [note] - new_state["step_index"] = state["step_index"] + 1 - - return new_state - -def synthesize_node(state: BriefState) -> BriefState: - """Узел 3: Синтез финального брифа из заметок""" - print("\n📝 [3/4] Синтезирую финальный исследовательский бриф...") - - # Форматируем заметки с заголовками пунктов - notes_with_headers = [] - for i, (outline_item, note) in enumerate(zip(state["outline"], state["notes"])): - notes_with_headers.append(f"## {outline_item}\n{note}") - - notes_text = "\n\n".join(notes_with_headers) - - prompt = SYNTHESIS_PROMPT.format( - topic=state["topic"], - notes=notes_text - ) - - response = llm.invoke([HumanMessage(content=prompt)]) - final_brief = response.content.strip() - - print(f" ✅ Бриф создан ({len(final_brief)} символов)") - - new_state = state.copy() - new_state["final_brief"] = final_brief - return new_state \ No newline at end of file + # Другие ошибки (сеть, API и т.д.) + print(f" ❌ Критическая ошибка: {str(e)}") + return { + **state, + "draft": "", + "error": f"Критическая ошибка: {str(e)}" + } \ No newline at end of file