Spaces:
Running
Running
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import logging | |
| import os | |
| from functools import lru_cache | |
| from typing import Any | |
| from dotenv import load_dotenv | |
| from duckduckgo_search import DDGS | |
| from openai import AsyncOpenAI | |
| from pydantic import BaseModel, field_validator | |
| try: | |
| from tavily import AsyncTavilyClient | |
| TAVILY_AVAILABLE = True | |
| except ImportError: | |
| AsyncTavilyClient = None | |
| TAVILY_AVAILABLE = False | |
| load_dotenv() | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| DOMAIN_DEPTH_MAP = { | |
| "finance": 5, | |
| "law": 5, | |
| "healthcare": 5, | |
| "governance": 4, | |
| "economics": 4, | |
| "ai": 4, | |
| "academia": 4, | |
| "science": 4, | |
| } | |
| DEFAULT_MAX_SUBTOPICS = 3 | |
| DEFAULT_MAX_SEARCH_RESULTS = 4 | |
| class Subtopics(BaseModel): | |
| subtopics: list[str] | |
| class ResearchResults(BaseModel): | |
| findings: str | list[Any] | dict[str, Any] | |
| def normalize_findings(cls, value: str | list[Any] | dict[str, Any]) -> str: | |
| return normalize_model_text(value) | |
| class OptimizationDecision(BaseModel): | |
| justification: str | |
| needs_more_research: bool | |
| class SummaryReport(BaseModel): | |
| report: str | list[Any] | dict[str, Any] | |
| def as_text(self) -> str: | |
| return normalize_report_text(self.report) | |
| def normalize_model_text(value: Any) -> str: | |
| if isinstance(value, str): | |
| return value | |
| if isinstance(value, list): | |
| return "\n".join(format_model_item(item, index) for index, item in enumerate(value, 1)) | |
| if isinstance(value, dict): | |
| return format_model_item(value) | |
| return str(value) | |
| def normalize_report_text(value: Any) -> str: | |
| if isinstance(value, str): | |
| return value | |
| if isinstance(value, list): | |
| return "\n\n".join(format_model_item(item) for item in value) | |
| if isinstance(value, dict): | |
| sections = [] | |
| for key, section_value in value.items(): | |
| heading = str(key).replace("_", " ").title() | |
| body = normalize_model_text(section_value) | |
| sections.append(f"## {heading}\n{body}") | |
| return "\n\n".join(sections) | |
| return str(value) | |
| def format_model_item(item: Any, index: int | None = None) -> str: | |
| prefix = f"{index}. " if index is not None else "" | |
| if isinstance(item, str): | |
| return f"{prefix}{item}" | |
| if not isinstance(item, dict): | |
| return f"{prefix}{item}" | |
| fact = item.get("fact") or item.get("finding") or item.get("insight") or item.get("summary") | |
| source = item.get("source") or item.get("url") or item.get("citation") | |
| title = item.get("title") | |
| parts = [] | |
| if fact: | |
| parts.append(str(fact)) | |
| else: | |
| parts.append(json.dumps(item, ensure_ascii=False)) | |
| if title: | |
| parts.append(f"Title: {title}") | |
| if source: | |
| parts.append(f"Source: {source}") | |
| return prefix + " | ".join(parts) | |
| def get_deepseek_client() -> AsyncOpenAI: | |
| api_key = os.getenv("DEEPSEEK_API_KEY") | |
| if not api_key: | |
| raise RuntimeError("DEEPSEEK_API_KEY is not configured.") | |
| return AsyncOpenAI(api_key=api_key, base_url="https://api.deepseek.com/v1") | |
| def get_tavily_client(): | |
| api_key = os.getenv("TAVILY_API_KEY") | |
| if not TAVILY_AVAILABLE or not api_key: | |
| return None | |
| return AsyncTavilyClient(api_key=api_key) | |
| def get_research_depth(topic: str) -> tuple[int, int]: | |
| topic_lower = topic.lower() | |
| for domain, depth in DOMAIN_DEPTH_MAP.items(): | |
| if domain in topic_lower: | |
| logger.info("Detected domain '%s' -> depth %s", domain, depth) | |
| return depth, depth | |
| return DEFAULT_MAX_SUBTOPICS, DEFAULT_MAX_SEARCH_RESULTS | |
| async def call_deepseek_json( | |
| system_prompt: str, | |
| user_prompt: str, | |
| output_model: type[BaseModel], | |
| temperature: float = 0.3, | |
| ) -> BaseModel: | |
| response = await get_deepseek_client().chat.completions.create( | |
| model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), | |
| messages=[ | |
| {"role": "system", "content": system_prompt}, | |
| {"role": "user", "content": user_prompt}, | |
| ], | |
| response_format={"type": "json_object"}, | |
| temperature=temperature, | |
| ) | |
| content = response.choices[0].message.content or "{}" | |
| try: | |
| data = json.loads(content) | |
| except json.JSONDecodeError as exc: | |
| logger.error("Invalid JSON from DeepSeek: %s", content[:300]) | |
| raise ValueError("Model returned invalid JSON.") from exc | |
| return output_model.model_validate(data) | |
| async def call_deepseek_text( | |
| messages: list[dict[str, str]], | |
| temperature: float = 0.4, | |
| max_tokens: int = 700, | |
| ) -> str: | |
| response = await get_deepseek_client().chat.completions.create( | |
| model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"), | |
| messages=messages, | |
| temperature=temperature, | |
| max_tokens=max_tokens, | |
| ) | |
| return response.choices[0].message.content or "" | |
| async def run_chat_completion(user_query: str, memory: list[dict[str, str]]) -> str: | |
| system = ( | |
| "You are the normal chat mode of a deep research assistant. Answer directly, " | |
| "briefly, and helpfully. Do not claim to have searched the web. If the user " | |
| "needs current sources or a detailed report, suggest asking for deep research." | |
| ) | |
| recent_memory = memory[-8:] | |
| messages = [{"role": "system", "content": system}, *recent_memory, {"role": "user", "content": user_query}] | |
| try: | |
| return await call_deepseek_text(messages) | |
| except Exception as exc: | |
| logger.error("Chat completion failed: %s", exc) | |
| return ( | |
| "I can help with quick chat or deep research. The chat model is not available " | |
| "right now, so please check the DeepSeek API key configuration." | |
| ) | |
| async def web_search_multi(query: str, max_results: int = 4) -> str: | |
| tasks = [web_search_duckduckgo(query, max_results)] | |
| if get_tavily_client(): | |
| tasks.append(web_search_tavily(query, max_results)) | |
| results = await asyncio.gather(*tasks, return_exceptions=True) | |
| combined = [] | |
| for result in results: | |
| if isinstance(result, str) and result.strip(): | |
| combined.append(result) | |
| elif isinstance(result, Exception): | |
| logger.warning("Search engine error: %s", result) | |
| return "\n\n---\n\n".join(combined) if combined else "No web results found." | |
| async def web_search_duckduckgo(query: str, max_results: int) -> str: | |
| try: | |
| return await asyncio.to_thread(_duckduckgo_search_sync, query, max_results) | |
| except Exception as exc: | |
| logger.error("DuckDuckGo error: %s", exc) | |
| return f"[DuckDuckGo] Search failed: {exc}" | |
| def _duckduckgo_search_sync(query: str, max_results: int) -> str: | |
| with DDGS() as ddgs: | |
| results = list(ddgs.text(query, max_results=max_results)) | |
| if not results: | |
| return "[DuckDuckGo] No results found." | |
| snippets = [] | |
| for index, result in enumerate(results, 1): | |
| title = result.get("title", "No title") | |
| body = result.get("body", "No content") | |
| href = result.get("href", "") | |
| snippets.append(f"{index}. {title}\n {body}\n Source: {href}") | |
| return "[DuckDuckGo Results]\n" + "\n\n".join(snippets) | |
| async def web_search_tavily(query: str, max_results: int) -> str: | |
| tavily_client = get_tavily_client() | |
| if not tavily_client: | |
| return "[Tavily] Not configured." | |
| try: | |
| response = await tavily_client.search( | |
| query=query, | |
| max_results=max_results, | |
| search_depth="basic", | |
| include_answer=True, | |
| include_raw_content=False, | |
| ) | |
| except Exception as exc: | |
| logger.error("Tavily error: %s", exc) | |
| return f"[Tavily] Search failed: {exc}" | |
| if not response.get("results"): | |
| return "[Tavily] No results found." | |
| snippets = [] | |
| for index, result in enumerate(response["results"][:max_results], 1): | |
| title = result.get("title", "No title") | |
| content = result.get("content", "No content") | |
| url = result.get("url", "") | |
| snippets.append(f"{index}. {title}\n {content}\n Source: {url}") | |
| answer = response.get("answer", "") | |
| result_text = "[Tavily Results]\n" + "\n\n".join(snippets) | |
| return f"Tavily AI Summary: {answer}\n\n{result_text}" if answer else result_text | |
| async def split_topic(user_query: str, num_subtopics: int) -> Subtopics: | |
| system = ( | |
| f"Break the user's research request into exactly {num_subtopics} concrete, " | |
| "searchable subtopics. Return JSON with a 'subtopics' list." | |
| ) | |
| return await call_deepseek_json(system, user_query, Subtopics) | |
| async def research_subtopic(subtopic: str, max_search_results: int) -> ResearchResults: | |
| search_results = await web_search_multi(subtopic, max_results=max_search_results) | |
| system = ( | |
| "Given web search results for a subtopic, extract relevant facts, data, " | |
| "source-backed claims, and insights. Return JSON with a 'findings' field." | |
| ) | |
| user_prompt = f"Subtopic: {subtopic}\n\nWeb search results:\n{search_results}" | |
| return await call_deepseek_json(system, user_prompt, ResearchResults) | |
| async def optimize_research(findings: str) -> OptimizationDecision: | |
| system = ( | |
| "Decide whether the findings are sufficient to answer the original research " | |
| "request. Return JSON with 'justification' and 'needs_more_research'." | |
| ) | |
| return await call_deepseek_json(system, f"Findings so far:\n{findings}", OptimizationDecision) | |
| async def synthesize_report(findings: str) -> SummaryReport: | |
| system = ( | |
| "Combine the provided research findings into a structured final report. " | |
| "Include an introduction, key findings, caveats, and conclusion. Return JSON " | |
| "with a 'report' field." | |
| ) | |
| return await call_deepseek_json(system, findings, SummaryReport) | |
| async def research_workflow(user_query: str) -> str: | |
| num_subtopics, max_search_results = get_research_depth(user_query) | |
| logger.info("Research depth: %s subtopics, %s results each", num_subtopics, max_search_results) | |
| subtopics_obj = await split_topic(user_query, num_subtopics) | |
| subtopics_list = subtopics_obj.subtopics[:num_subtopics] | |
| logger.info("Subtopics: %s", subtopics_list) | |
| research_tasks = [ | |
| research_subtopic(subtopic, max_search_results) | |
| for subtopic in subtopics_list | |
| ] | |
| research_results = await asyncio.gather(*research_tasks) | |
| return "\n\n---\n\n".join(result.findings for result in research_results) | |
| async def run_research_pipeline(user_query: str, memory: list[dict[str, str]]) -> str: | |
| try: | |
| context_text = "\n".join( | |
| f"{item['role']}: {item['content']}" for item in memory[-8:] | |
| ) | |
| enhanced_query = f"Previous conversation:\n{context_text}\n\nCurrent query:\n{user_query}" | |
| research = await research_workflow(enhanced_query) | |
| optimizer_decision = await optimize_research(research) | |
| if optimizer_decision.needs_more_research: | |
| logger.info("Optimizer requested one additional research pass") | |
| research = await research_workflow(enhanced_query) | |
| final_report = await synthesize_report(research) | |
| report_text = final_report.as_text() | |
| memory.append({"role": "user", "content": user_query}) | |
| memory.append({"role": "assistant", "content": report_text}) | |
| return report_text | |
| except Exception as exc: | |
| logger.error("Pipeline error: %s", exc) | |
| return f"Error: {exc}" | |