add
This commit is contained in:
@@ -1,120 +1,185 @@
|
|||||||
|
import os
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
from pydantic import BaseModel, Field, ValidationError
|
from langchain_tavily import TavilySearch
|
||||||
from state import ReflectState
|
from state import BriefState
|
||||||
|
|
||||||
# Инициализация LLM
|
# Инициализация LLM и поискового инструмента
|
||||||
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.7)
|
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):
|
OUTLINE_PROMPT = """Ты — исследовательский ассистент. Составь план исследования по теме: {topic}
|
||||||
"""Модель для валидации качества ответа"""
|
|
||||||
content: str = Field(description="Ответ на вопрос")
|
|
||||||
is_complete: bool = Field(description="Полнота ответа (должно быть True)")
|
|
||||||
has_examples: bool = Field(description="Наличие примеров (должно быть True)")
|
|
||||||
is_concise: bool = Field(description="Отсутствие воды (должно быть True)")
|
|
||||||
|
|
||||||
def validate_quality(self) -> tuple[bool, str]:
|
Требования:
|
||||||
"""Проверяет качество ответа по критериям"""
|
- 4-5 пунктов
|
||||||
issues = []
|
- Каждый пункт должен быть конкретным аспектом для исследования
|
||||||
|
- Пункты должны логически раскрывать тему
|
||||||
|
- Формат вывода: просто список пунктов (каждый с новой строки, начинается с цифры)
|
||||||
|
|
||||||
if not self.is_complete:
|
Пример:
|
||||||
issues.append("неполный ответ")
|
1. Основные понятия и определения
|
||||||
if not self.has_examples:
|
2. Ключевые технологии и инструменты
|
||||||
issues.append("нет конкретных примеров")
|
3. Практические примеры использования
|
||||||
if not self.is_concise:
|
4. Потенциальные риски и их mitigation
|
||||||
issues.append("содержит воду/общие фразы")
|
5. Лучшие практики и рекомендации
|
||||||
|
|
||||||
if not self.content or len(self.content.split()) < 50:
|
Тема: {topic}
|
||||||
issues.append("слишком короткий ответ (<50 слов)")
|
План:"""
|
||||||
|
|
||||||
if len(self.content.split()) > 300:
|
RESEARCH_PROMPT = """Ты — исследователь, собирающий информацию по конкретному пункту плана.
|
||||||
issues.append("слишком длинный ответ (>300 слов)")
|
|
||||||
|
|
||||||
if issues:
|
Тема исследования: {topic}
|
||||||
return False, f"Проблемы: {', '.join(issues)}"
|
Текущий пункт плана: {outline_item}
|
||||||
return True, "OK"
|
|
||||||
|
|
||||||
# Промпт для генерации с инструкцией по качеству
|
На основе результатов поиска (ниже) составь краткую заметку (5-8 предложений), которая:
|
||||||
ANSWER_PROMPT = """Ты — эксперт, который дает качественные ответы.
|
1. Отвечает на пункт плана
|
||||||
Требования к ответу:
|
2. Содержит конкретные факты, данные, примеры
|
||||||
1. Полнота: ответ покрывает все аспекты вопроса
|
3. Использует информацию из результатов поиска
|
||||||
2. Конкретика: есть примеры, детали, точные формулировки
|
4. Пишется связным текстом (не маркированным списком)
|
||||||
3. Краткость: 5-10 предложений, без воды
|
|
||||||
|
|
||||||
Вопрос: {question}
|
Результаты поиска:
|
||||||
|
{search_results}
|
||||||
|
|
||||||
После ответа добавь метаданные в формате JSON:
|
Заметка по пункту "{outline_item}":"""
|
||||||
{{
|
|
||||||
"is_complete": true/false,
|
|
||||||
"has_examples": true/false,
|
|
||||||
"is_concise": true/false
|
|
||||||
}}
|
|
||||||
|
|
||||||
Сам ответ должен быть в markdown формате.
|
SYNTHESIS_PROMPT = """Ты — аналитик, который создает итоговый исследовательский бриф.
|
||||||
"""
|
|
||||||
|
|
||||||
def generate_answer(state: ReflectState) -> ReflectState:
|
Тема: {topic}
|
||||||
"""
|
План исследования:
|
||||||
Узел генерации ответа с try/except и валидацией
|
{outline}
|
||||||
При ошибке заполняет поле error
|
|
||||||
"""
|
Собранные заметки по пунктам:
|
||||||
print(f"\n📝 [Попытка {state['round']}/{state['max_rounds']}] Генерация ответа...")
|
{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']}")
|
||||||
|
|
||||||
|
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": []
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
# 1. Генерация ответа
|
# Выполняем поиск через Tavily
|
||||||
prompt = ANSWER_PROMPT.format(question=state["question"])
|
search_result = tavily.invoke({"query": search_query})
|
||||||
response = llm.invoke([HumanMessage(content=prompt)])
|
|
||||||
|
|
||||||
# 2. Парсинг JSON-метаданных из ответа
|
# Форматируем результаты поиска
|
||||||
import json
|
if isinstance(search_result, list):
|
||||||
import re
|
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)
|
research_prompt = RESEARCH_PROMPT.format(
|
||||||
if not json_match:
|
topic=state["topic"],
|
||||||
raise ValueError("Не найдены метаданные JSON в ответе")
|
outline_item=outline_item,
|
||||||
|
search_results=search_text[:1500] # Ограничиваем длину
|
||||||
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. Валидация качества
|
response = llm.invoke([HumanMessage(content=research_prompt)])
|
||||||
is_valid, error_message = answer_obj.validate_quality()
|
note = response.content.strip()
|
||||||
|
|
||||||
if not is_valid:
|
print(f" ✅ Заметка создана ({len(note)} символов)")
|
||||||
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:
|
except Exception as e:
|
||||||
# Другие ошибки (сеть, API и т.д.)
|
print(f" ⚠️ Ошибка поиска: {e}")
|
||||||
print(f" ❌ Критическая ошибка: {str(e)}")
|
# Fallback: генерируем заметку без поиска
|
||||||
return {
|
fallback_prompt = f"Напиши краткую заметку (5-8 предложений) по пункту '{outline_item}' для темы '{state['topic']}' (без внешнего поиска)."
|
||||||
**state,
|
response = llm.invoke([HumanMessage(content=fallback_prompt)])
|
||||||
"draft": "",
|
note = response.content.strip()
|
||||||
"error": f"Критическая ошибка: {str(e)}"
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user