add nodes.py

This commit is contained in:
2026-06-04 16:37:59 +00:00
parent 3abd4bd4a4
commit e7ea727947
+100 -167
View File
@@ -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
)
# 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 = """Ты — исследовательский ассистент. Составь план из 4-5 пунктов для исследования темы.
def validate_quality(self) -> tuple[bool, str]:
"""Проверяет качество ответа по критериям"""
issues = []
Тема: {topic}
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"
RESEARCH_PROMPT = """Ты — исследователь. На основе результатов поиска составь краткую заметку (5-8 предложений) по следующему пункту плана.
# Промпт для генерации с инструкцией по качеству
ANSWER_PROMPT = """Ты — эксперт, который дает качественные ответы.
Требования к ответу:
1. Полнота: ответ покрывает все аспекты вопроса
2. Конкретика: есть примеры, детали, точные формулировки
3. Краткость: 5-10 предложений, без воды
Пункт плана: {outline_item}
Вопрос: {question}
Результаты поиска:
{search_results}
После ответа добавь метаданные в формате JSON:
{{
"is_complete": true/false,
"has_examples": true/false,
"is_concise": true/false
}}
Заметка должна:
- Быть информативной и конкретной
- Опираться на факты из поиска
- Содержать 5-8 предложений
- Заканчиваться выводом или ключевым инсайтом"""
Сам ответ должен быть в markdown формате.
"""
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] Составляю план исследования...")
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 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}")
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})
# Извлекаем текст результатов
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]
# Генерируем заметку на основе поиска
prompt = RESEARCH_PROMPT.format(
outline_item=current_item,
search_results=results_text
)
# 1. Генерация ответа
prompt = ANSWER_PROMPT.format(question=state["question"])
response = llm.invoke([HumanMessage(content=prompt)])
note = response.content.strip()
print(f" ✅ Заметка создана ({len(note)} символов)")
# 2. Парсинг JSON-метаданных из ответа
import json
import re
# Ищем 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)
)
# 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
# Другие ошибки (сеть, API и т.д.)
print(f" ❌ Критическая ошибка: {str(e)}")
return {
**state,
"draft": "",
"error": f"Критическая ошибка: {str(e)}"
}