diff --git a/FILE_OUTPUT_IMPLEMENTATION_PLAN.md b/FILE_OUTPUT_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000000000000000000000000000000000..c01596b3496399375d051f82a137a71e7c07c115 --- /dev/null +++ b/FILE_OUTPUT_IMPLEMENTATION_PLAN.md @@ -0,0 +1,237 @@ +# File Output Implementation Plan + +## Overview + +This plan implements file writing and return functionality for report-writing agents, enabling reports to be saved as files and returned through the Gradio ChatInterface. + +## Current State Analysis + +✅ **Report Generation**: All agents generate markdown strings +✅ **File Output Integration**: `event_to_chat_message()` supports file paths +✅ **Graph Orchestrator**: Can handle file paths in results +❌ **File Writing**: No agents write files to disk +❌ **File Service**: No utility service for saving reports + +--- + +## Implementation Plan + +### PROJECT 1: File Writing Service +**Goal**: Create a reusable service for saving reports to files + +#### Activity 1.1: Create Report File Service +**File**: `src/services/report_file_service.py` (NEW) + +**Tasks**: +1. Create `ReportFileService` class +2. Implement `save_report()` method + - Accepts: report content (str), filename (optional), output_dir (optional) + - Returns: file path (str) + - Uses temp directory by default + - Supports custom output directory + - Handles file naming with timestamps +3. Implement `save_report_multiple_formats()` method + - Save as .md (always) + - Optionally save as .html, .pdf (future) +4. Add configuration support + - Read from settings + - Enable/disable file saving + - Configurable output directory +5. Add error handling and logging +6. Add file cleanup utilities (optional) + +**Line-level subtasks**: +- Line 1-20: Imports and class definition +- Line 21-40: `__init__()` method with settings +- Line 41-80: `save_report()` method + - Line 41-50: Input validation + - Line 51-60: Directory creation + - Line 61-70: File writing + - Line 71-80: Error handling +- Line 81-100: `save_report_multiple_formats()` method +- Line 101-120: Helper methods (filename generation, cleanup) + +--- + +### PROJECT 2: Configuration Updates +**Goal**: Add settings for file output functionality + +#### Activity 2.1: Update Settings Model +**File**: `src/utils/config.py` + +**Tasks**: +1. Add `save_reports_to_file: bool` field (default: True) +2. Add `report_output_directory: str | None` field (default: None, uses temp) +3. Add `report_file_format: Literal["md", "md_html", "md_pdf"]` field (default: "md") +4. Add `report_filename_template: str` field (default: "report_{timestamp}_{query_hash}.md") + +**Line-level subtasks**: +- Line 166-170: Add `save_reports_to_file` field after TTS config +- Line 171-175: Add `report_output_directory` field +- Line 176-180: Add `report_file_format` field +- Line 181-185: Add `report_filename_template` field + +--- + +### PROJECT 3: Graph Orchestrator Integration +**Goal**: Integrate file writing into graph execution + +#### Activity 3.1: Update Graph Orchestrator +**File**: `src/orchestrator/graph_orchestrator.py` + +**Tasks**: +1. Import `ReportFileService` at top +2. Initialize service in `__init__()` (optional, can be lazy) +3. Modify `_execute_agent_node()` for synthesizer node + - After `long_writer_agent.write_report()`, save to file + - Return dict with `{"message": report, "file": file_path}` +4. Update final event generation to handle file paths + - Already implemented, verify it works correctly + +**Line-level subtasks**: +- Line 1-35: Add import for `ReportFileService` +- Line 119-148: Update `__init__()` to accept optional file service +- Line 589-650: Modify `_execute_agent_node()` synthesizer handling + - Line 642-645: After `write_report()`, add file saving + - Line 646-650: Return dict with file path +- Line 534-564: Verify final event generation handles file paths (already done) + +--- + +### PROJECT 4: Research Flow Integration +**Goal**: Integrate file writing into research flows + +#### Activity 4.1: Update IterativeResearchFlow +**File**: `src/orchestrator/research_flow.py` + +**Tasks**: +1. Import `ReportFileService` at top +2. Add optional file service to `__init__()` +3. Modify `_create_final_report()` method + - After `writer_agent.write_report()`, save to file if enabled + - Return string (backward compatible) OR dict with file path + +**Line-level subtasks**: +- Line 1-50: Add import for `ReportFileService` +- Line 48-120: Update `__init__()` to accept optional file service +- Line 622-667: Modify `_create_final_report()` method + - Line 647-652: After `write_report()`, add file saving + - Line 653-667: Return report string (keep backward compatible for now) + +#### Activity 4.2: Update DeepResearchFlow +**File**: `src/orchestrator/research_flow.py` + +**Tasks**: +1. Add optional file service to `__init__()` (if not already) +2. Modify `_create_final_report()` method + - After `long_writer_agent.write_report()` or `proofreader_agent.proofread()`, save to file + - Return string (backward compatible) OR dict with file path + +**Line-level subtasks**: +- Line 670-750: Update `DeepResearchFlow.__init__()` to accept optional file service +- Line 954-1005: Modify `_create_final_report()` method + - Line 979-983: After `write_report()`, add file saving + - Line 984-989: After `proofread()`, add file saving + - Line 990-1005: Return report string (keep backward compatible) + +--- + +### PROJECT 5: Agent Factory Integration +**Goal**: Make file service available to agents if needed + +#### Activity 5.1: Update Agent Factory (Optional) +**File**: `src/agent_factory/agents.py` + +**Tasks**: +1. Add optional file service parameter to agent creation functions (if needed) +2. Pass file service to agents that need it (currently not needed, agents return strings) + +**Line-level subtasks**: +- Not required - agents return strings, file writing happens at orchestrator level + +--- + +### PROJECT 6: Testing & Validation +**Goal**: Ensure file output works end-to-end + +#### Activity 6.1: Unit Tests +**File**: `tests/unit/services/test_report_file_service.py` (NEW) + +**Tasks**: +1. Test `save_report()` with default settings +2. Test `save_report()` with custom directory +3. Test `save_report()` with custom filename +4. Test error handling (permission errors, disk full, etc.) +5. Test file cleanup + +**Line-level subtasks**: +- Line 1-30: Test fixtures and setup +- Line 31-60: Test basic save functionality +- Line 61-90: Test custom directory +- Line 91-120: Test error handling + +#### Activity 6.2: Integration Tests +**File**: `tests/integration/test_file_output_integration.py` (NEW) + +**Tasks**: +1. Test graph orchestrator with file output +2. Test research flows with file output +3. Test Gradio ChatInterface receives file paths +4. Test file download in Gradio UI + +**Line-level subtasks**: +- Line 1-40: Test setup with mock orchestrator +- Line 41-80: Test file generation in graph execution +- Line 81-120: Test file paths in AgentEvent +- Line 121-160: Test Gradio message conversion + +--- + +## Implementation Order + +1. **PROJECT 2** (Configuration) - Foundation +2. **PROJECT 1** (File Service) - Core functionality +3. **PROJECT 3** (Graph Orchestrator) - Primary integration point +4. **PROJECT 4** (Research Flows) - Secondary integration points +5. **PROJECT 6** (Testing) - Validation +6. **PROJECT 5** (Agent Factory) - Not needed, skip + +--- + +## File Changes Summary + +### New Files +- `src/services/report_file_service.py` - File writing service +- `tests/unit/services/test_report_file_service.py` - Unit tests +- `tests/integration/test_file_output_integration.py` - Integration tests + +### Modified Files +- `src/utils/config.py` - Add file output settings +- `src/orchestrator/graph_orchestrator.py` - Add file saving after report generation +- `src/orchestrator/research_flow.py` - Add file saving in both flows + +--- + +## Gradio Integration Notes + +According to Gradio ChatInterface documentation: +- File paths in chat message content are automatically converted to download links +- Markdown links like `[Download: filename](file_path)` work +- Files must be accessible from the Gradio server +- Temp files are fine as long as they exist during the session + +Current implementation in `event_to_chat_message()` already handles this correctly. + +--- + +## Success Criteria + +✅ Reports are saved to files when generated +✅ File paths are included in AgentEvent data +✅ File paths appear as download links in Gradio ChatInterface +✅ File saving is configurable (can be disabled) +✅ Backward compatible (existing code still works) +✅ Error handling prevents crashes if file writing fails + + + diff --git a/REPORT_WRITING_AGENTS_ANALYSIS.md b/REPORT_WRITING_AGENTS_ANALYSIS.md index d59f2d033c2a4ddacd5e23930f3c3734643852be..2199b96b2054b9a0820b3bb83152c0482873c8af 100644 --- a/REPORT_WRITING_AGENTS_ANALYSIS.md +++ b/REPORT_WRITING_AGENTS_ANALYSIS.md @@ -181,3 +181,5 @@ return { The infrastructure to handle file outputs in Gradio is in place, but the agents themselves do not yet write files. They would need to be enhanced or wrapped to add file writing capability. + + diff --git a/SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md b/SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md index 33c99ccc51d0de19f9cd2d3021326a47f2e18a43..d894d8b2b549b318035874eb30ad705adc93509d 100644 --- a/SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md +++ b/SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md @@ -395,3 +395,5 @@ This plan details the implementation of SERPER-based web search by vendoring cod - Consider adding relevance scoring in the future + + diff --git a/docs/api/agents.md b/docs/api/agents.md index 31ee642b77524bff7619e2e176b18c6f7f0cca7b..80270a81e20e897b9cc016138a989cacb5e144ea 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -272,3 +272,5 @@ def create_input_parser_agent(model: Any | None = None) -> InputParserAgent + + diff --git a/docs/api/models.md b/docs/api/models.md index 337bc4a6560b24282c072b5cd4ebbe8d2eaac507..9a1c1cfa96f362472b3f8a1a06dba8d6ca338dd8 100644 --- a/docs/api/models.md +++ b/docs/api/models.md @@ -250,3 +250,5 @@ class BudgetStatus(BaseModel): + + diff --git a/docs/api/orchestrators.md b/docs/api/orchestrators.md index 0f60e71629ee5c1e2df39419ba94c750845136e1..3c9efb9ce7eb4de198c4bd63c9e2b3664d8b494e 100644 --- a/docs/api/orchestrators.md +++ b/docs/api/orchestrators.md @@ -197,3 +197,5 @@ Runs Magentic orchestration. + + diff --git a/docs/api/services.md b/docs/api/services.md index 409471c9b9aad2213adf87fc4ccc01c818926e1f..5a2569d6dbe7d482070c097c6a142e23b708add9 100644 --- a/docs/api/services.md +++ b/docs/api/services.md @@ -203,3 +203,5 @@ Analyzes a hypothesis using statistical methods. + + diff --git a/docs/api/tools.md b/docs/api/tools.md index de933513be13d14c03933ed2b2eb55a3dc7fbee2..dd2fedb6950d5495216ed0ac8ebeff5797b83f88 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -237,3 +237,5 @@ Searches multiple tools in parallel. + + diff --git a/docs/architecture/agents.md b/docs/architecture/agents.md index 29f143d1765bbe2677c613e5aadfedf0c4ed7e7c..21d391d107853b749c4dd5909a18623b0a3fefd1 100644 --- a/docs/architecture/agents.md +++ b/docs/architecture/agents.md @@ -194,3 +194,5 @@ Factory functions: + + diff --git a/docs/architecture/middleware.md b/docs/architecture/middleware.md index f4fc5af6a2e8c24564d3313fcb06df5a145c6c1a..949a54acdb699e5f13043ac1502494ecc61cf2f7 100644 --- a/docs/architecture/middleware.md +++ b/docs/architecture/middleware.md @@ -144,3 +144,5 @@ All middleware components use `ContextVar` for thread-safe isolation: + + diff --git a/docs/architecture/services.md b/docs/architecture/services.md index 1a69c60e21c88f795d6990cae486ab198a18d409..86d91d34dedbfdf9f07f0094ad24d3c84c2db9b7 100644 --- a/docs/architecture/services.md +++ b/docs/architecture/services.md @@ -144,3 +144,5 @@ if settings.has_openai_key: + + diff --git a/docs/architecture/tools.md b/docs/architecture/tools.md index a9a2705502b37c820a491ee365f8e38769d6efa7..bf0cdb8da1c6ab953e8a9753dea756d58bfdd7f7 100644 --- a/docs/architecture/tools.md +++ b/docs/architecture/tools.md @@ -177,3 +177,5 @@ search_handler = SearchHandler( + + diff --git a/docs/contributing/code-quality.md b/docs/contributing/code-quality.md index a56b04b67c66973453fef70aff83654b1c9b82a1..97d00093d3047d01c7c1a511fe6479963e2248b4 100644 --- a/docs/contributing/code-quality.md +++ b/docs/contributing/code-quality.md @@ -83,3 +83,5 @@ async def search(self, query: str, max_results: int = 10) -> list[Evidence]: + + diff --git a/docs/contributing/code-style.md b/docs/contributing/code-style.md index 202748686c76d0a6d3928744521089f603c9bed9..55de718a9ab72d91429bb2597a939654ee6dd5ce 100644 --- a/docs/contributing/code-style.md +++ b/docs/contributing/code-style.md @@ -63,3 +63,5 @@ result = await loop.run_in_executor(None, cpu_bound_function, args) + + diff --git a/docs/contributing/error-handling.md b/docs/contributing/error-handling.md index 385193e1ac60e102aa02992ae0c12eb05c14d660..54d6627a3f56216a0215dfe2a272620d9b094cc8 100644 --- a/docs/contributing/error-handling.md +++ b/docs/contributing/error-handling.md @@ -71,3 +71,5 @@ except httpx.HTTPError as e: + + diff --git a/docs/contributing/implementation-patterns.md b/docs/contributing/implementation-patterns.md index e50edb72a8df417557bd68cd360b9ca094760ce4..255fc1fc5b0c38422f81fb1525a07e54cf7ddc56 100644 --- a/docs/contributing/implementation-patterns.md +++ b/docs/contributing/implementation-patterns.md @@ -86,3 +86,5 @@ def get_embedding_service() -> EmbeddingService: + + diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 9f38565ab2a6c24f2fcb418e7903ce4ff5680c2c..6006161b728b855c085926139916022d9ceee0ee 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -165,3 +165,5 @@ Thank you for contributing to DeepCritical! + + diff --git a/docs/contributing/prompt-engineering.md b/docs/contributing/prompt-engineering.md index c4c407af555c2258f8b89c3ec2e1e8e54662c7ca..17e507792697259db49109ec0e95a716baf898ec 100644 --- a/docs/contributing/prompt-engineering.md +++ b/docs/contributing/prompt-engineering.md @@ -71,3 +71,5 @@ This document outlines prompt engineering guidelines and citation validation rul + + diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 4b4b33dc877b79922c9d61ee2e4cffcb2c0372b0..a49859cdb2e47d1587f6023c6cecfad989a094c5 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -67,3 +67,5 @@ async def test_real_pubmed_search(): + + diff --git a/docs/getting-started/examples.md b/docs/getting-started/examples.md index 7e67c45f8ba7ef881e6712af4091b7b13755f440..74cc4b0a532ce31ccf181dd96fcf9de89f645a0c 100644 --- a/docs/getting-started/examples.md +++ b/docs/getting-started/examples.md @@ -211,3 +211,5 @@ USE_GRAPH_EXECUTION=true + + diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 3b76ee3a88f0810ca00e1e9b3394d1b2752516e6..d85c7f9af37e7b57204c1b459b462dd55526200e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -150,3 +150,5 @@ uv run pre-commit install + + diff --git a/docs/getting-started/mcp-integration.md b/docs/getting-started/mcp-integration.md index e99a1550af42f11a59afcdcc7beb744073960edb..3dbf91e6ec7dccaca79a2e1ee3844083a1eccce4 100644 --- a/docs/getting-started/mcp-integration.md +++ b/docs/getting-started/mcp-integration.md @@ -217,3 +217,5 @@ You can configure multiple DeepCritical instances: + + diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index f0496983ed65fa0296c6ee5783d484635eb616a2..d3ec9f8ec24c84d72304f7f1c919d2042dda0a01 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -121,3 +121,5 @@ What are the active clinical trials investigating Alzheimer's disease treatments + + diff --git a/docs/implementation/IMPLEMENTATION_SUMMARY.md b/docs/implementation/IMPLEMENTATION_SUMMARY.md index 8f1af50c288010546c861848fcbd59f0bb85b0be..423921de493c62babb5a19c427a6fa292992f6c5 100644 --- a/docs/implementation/IMPLEMENTATION_SUMMARY.md +++ b/docs/implementation/IMPLEMENTATION_SUMMARY.md @@ -180,3 +180,5 @@ Located in `src/app.py` lines 667-712: + + diff --git a/docs/implementation/TTS_MODAL_IMPLEMENTATION.md b/docs/implementation/TTS_MODAL_IMPLEMENTATION.md index 307b18bc07100797df10164174775746ccdf1a8e..1aa0a7e462c0148fda868e8d4cfb42ae6db67cf3 100644 --- a/docs/implementation/TTS_MODAL_IMPLEMENTATION.md +++ b/docs/implementation/TTS_MODAL_IMPLEMENTATION.md @@ -134,3 +134,5 @@ To test TTS: + + diff --git a/docs/license.md b/docs/license.md index 30cea0a58ab52286aaba1cf686bbc637ed435ea7..b06a2efdc199bbb25a6aab4cd825ebb6480fcaf4 100644 --- a/docs/license.md +++ b/docs/license.md @@ -41,3 +41,5 @@ SOFTWARE. + + diff --git a/docs/overview/architecture.md b/docs/overview/architecture.md index 57348f855bdf7f1614f055ae85230e33543708e3..cba6f5c49b1204408de24bdd1eb66ce0b318b1aa 100644 --- a/docs/overview/architecture.md +++ b/docs/overview/architecture.md @@ -198,3 +198,5 @@ The system supports complex research workflows through: + + diff --git a/docs/overview/features.md b/docs/overview/features.md index bf1b9497ef543df24f30d6f6ee02e536b46e5dac..f3df42781443c9983d4ea11754fa72b41eec2d3c 100644 --- a/docs/overview/features.md +++ b/docs/overview/features.md @@ -150,3 +150,5 @@ DeepCritical provides a comprehensive set of features for AI-assisted research: + + diff --git a/docs/team.md b/docs/team.md index df1194aec8722b74556daef0914cadc2cecf2c5c..ff003663a6ccee46095442f83f0e71cbe2bc293b 100644 --- a/docs/team.md +++ b/docs/team.md @@ -46,3 +46,5 @@ We welcome contributions! See the [Contributing Guide](contributing/index.md) fo + + diff --git a/new_env.txt b/new_env.txt index 5017dc17e9ab6870bd60ce44c9beb51b213da5d0..390935c76a41d2be741b263ba62e487051386efd 100644 --- a/new_env.txt +++ b/new_env.txt @@ -96,3 +96,5 @@ MODAL_TOKEN_SECRET=your_modal_token_secret_here + + diff --git a/src/agent_factory/judges.py b/src/agent_factory/judges.py index db7b9f18d48eefcfb3a33077d0d837988bb889c4..92fe3b1e324e2e6253b2f8dea5c09a56dfa1ba29 100644 --- a/src/agent_factory/judges.py +++ b/src/agent_factory/judges.py @@ -33,34 +33,61 @@ def get_model(oauth_token: str | None = None) -> Any: Explicitly passes API keys from settings to avoid requiring users to export environment variables manually. - Priority: If OAuth token is available, prefer HuggingFace (even if provider is set to OpenAI). + Priority order: + 1. HuggingFace (if OAuth token or API key available - preferred for free tier) + 2. OpenAI (if API key available) + 3. Anthropic (if API key available) + + If OAuth token is available, prefer HuggingFace (even if provider is set to OpenAI). This ensures users logged in via HuggingFace Spaces get the free tier. Args: oauth_token: Optional OAuth token from HuggingFace login (takes priority over env vars) + + Returns: + Configured Pydantic AI model + + Raises: + ConfigurationError: If no LLM provider is available """ # Priority: oauth_token > settings.hf_token > settings.huggingface_api_key effective_hf_token = oauth_token or settings.hf_token or settings.huggingface_api_key - # HuggingFaceProvider requires a token - cannot use None - if not effective_hf_token: - raise ConfigurationError( - "HuggingFace token required. Please either:\n" - "1. Log in via HuggingFace OAuth (recommended for Spaces)\n" - "2. Set HF_TOKEN environment variable\n" - "3. Set huggingface_api_key in settings" + # Try HuggingFace first (preferred for free tier) + if effective_hf_token: + model_name = settings.huggingface_model or "meta-llama/Llama-3.1-8B-Instruct" + hf_provider = HuggingFaceProvider(api_key=effective_hf_token) + logger.info( + "using_huggingface_with_token", + has_oauth=bool(oauth_token), + has_settings_token=bool(settings.hf_token or settings.huggingface_api_key), + model=model_name, ) - - # Always use HuggingFace with available token - model_name = settings.huggingface_model or "meta-llama/Llama-3.1-8B-Instruct" - hf_provider = HuggingFaceProvider(api_key=effective_hf_token) - logger.info( - "using_huggingface_with_token", - has_oauth=bool(oauth_token), - has_settings_token=bool(settings.hf_token or settings.huggingface_api_key), - model=model_name, + return HuggingFaceModel(model_name, provider=hf_provider) + + # Fallback to OpenAI if available + if settings.has_openai_key: + assert settings.openai_api_key is not None # Type narrowing + model_name = settings.openai_model + openai_provider = OpenAIProvider(api_key=settings.openai_api_key) + logger.info("using_openai", model=model_name) + return OpenAIModel(model_name, provider=openai_provider) + + # Fallback to Anthropic if available + if settings.has_anthropic_key: + assert settings.anthropic_api_key is not None # Type narrowing + model_name = settings.anthropic_model + anthropic_provider = AnthropicProvider(api_key=settings.anthropic_api_key) + logger.info("using_anthropic", model=model_name) + return AnthropicModel(model_name, provider=anthropic_provider) + + # No provider available + raise ConfigurationError( + "No LLM provider available. Please configure one of:\n" + "1. HuggingFace: Log in via OAuth (recommended for Spaces) or set HF_TOKEN\n" + "2. OpenAI: Set OPENAI_API_KEY environment variable\n" + "3. Anthropic: Set ANTHROPIC_API_KEY environment variable" ) - return HuggingFaceModel(model_name, provider=hf_provider) class JudgeHandler: diff --git a/src/app.py b/src/app.py index ee81bf94aa7e50527d28c8f2763a121f4f46ec2b..1cf94817b628912da0fb16e237a3fd87c10d3803 100644 --- a/src/app.py +++ b/src/app.py @@ -158,6 +158,7 @@ def configure_orchestrator( judge_handler=judge_handler, config=config, mode=effective_mode, # type: ignore + oauth_token=oauth_token, ) return orchestrator, backend_info @@ -570,7 +571,13 @@ async def research_agent( if oauth_token is not None: # OAuthToken has a .token attribute containing the access token - token_value = oauth_token.token if hasattr(oauth_token, "token") else None + if hasattr(oauth_token, "token"): + token_value = oauth_token.token + elif isinstance(oauth_token, str): + # Handle case where oauth_token is already a string (shouldn't happen but defensive) + token_value = oauth_token + else: + token_value = None if oauth_profile is not None: # OAuthProfile has .username, .name, .profile_image attributes diff --git a/src/middleware/state_machine.py b/src/middleware/state_machine.py index 66e4a477a24cc195053b3b122d1d57f19e4c027e..d43e131e9a6df0d98a8a2d8dd61d6464c1f5661d 100644 --- a/src/middleware/state_machine.py +++ b/src/middleware/state_machine.py @@ -135,3 +135,5 @@ def get_workflow_state() -> WorkflowState: + + diff --git a/src/orchestrator/graph_orchestrator.py b/src/orchestrator/graph_orchestrator.py index 9dbec183be4a3845ac6e4a95a0373c84162cf0d5..066920eb1451f6e6f4349d99bfcef646f3ebd448 100644 --- a/src/orchestrator/graph_orchestrator.py +++ b/src/orchestrator/graph_orchestrator.py @@ -32,6 +32,7 @@ from src.legacy_orchestrator import JudgeHandlerProtocol, SearchHandlerProtocol from src.middleware.budget_tracker import BudgetTracker from src.middleware.state_machine import WorkflowState, init_workflow_state from src.orchestrator.research_flow import DeepResearchFlow, IterativeResearchFlow +from src.services.report_file_service import ReportFileService, get_report_file_service from src.utils.models import AgentEvent if TYPE_CHECKING: @@ -147,6 +148,9 @@ class GraphOrchestrator: self.oauth_token = oauth_token self.logger = logger + # Initialize file service (lazy if not provided) + self._file_service: ReportFileService | None = None + # Initialize flows (for backward compatibility) self._iterative_flow: IterativeResearchFlow | None = None self._deep_flow: DeepResearchFlow | None = None @@ -155,6 +159,21 @@ class GraphOrchestrator: self._graph: ResearchGraph | None = None self._budget_tracker: BudgetTracker | None = None + def _get_file_service(self) -> ReportFileService | None: + """ + Get file service instance (lazy initialization). + + Returns: + ReportFileService instance or None if disabled + """ + if self._file_service is None: + try: + self._file_service = get_report_file_service() + except Exception as e: + self.logger.warning("Failed to initialize file service", error=str(e)) + return None + return self._file_service + async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]: """ Run the research workflow. @@ -649,6 +668,27 @@ class GraphOrchestrator: estimated_tokens = len(final_report) // 4 # Rough token estimate context.budget_tracker.add_tokens("graph_execution", estimated_tokens) + # Save report to file if enabled + file_path: str | None = None + try: + file_service = self._get_file_service() + if file_service: + file_path = file_service.save_report( + report_content=final_report, + query=query, + ) + self.logger.info("Report saved to file", file_path=file_path) + except Exception as e: + # Don't fail the entire operation if file saving fails + self.logger.warning("Failed to save report to file", error=str(e)) + file_path = None + + # Return dict with file path if available, otherwise return string (backward compatible) + if file_path: + return { + "message": final_report, + "file": file_path, + } return final_report # Standard agent execution diff --git a/src/orchestrator/research_flow.py b/src/orchestrator/research_flow.py index 074f995773218b1d051e25df0061f1d8650e68e1..52756654814e02e63fe29ac1cf4bf1b5270c18e8 100644 --- a/src/orchestrator/research_flow.py +++ b/src/orchestrator/research_flow.py @@ -25,6 +25,7 @@ from src.middleware.budget_tracker import BudgetTracker from src.middleware.state_machine import get_workflow_state, init_workflow_state from src.middleware.workflow_manager import WorkflowManager from src.services.llamaindex_rag import LlamaIndexRAGService, get_rag_service +from src.services.report_file_service import ReportFileService, get_report_file_service from src.tools.tool_executor import execute_tool_tasks from src.utils.exceptions import ConfigurationError from src.utils.models import ( @@ -112,6 +113,24 @@ class IterativeResearchFlow: # Graph orchestrator (lazy initialization) self._graph_orchestrator: Any = None + # File service (lazy initialization) + self._file_service: ReportFileService | None = None + + def _get_file_service(self) -> ReportFileService | None: + """ + Get file service instance (lazy initialization). + + Returns: + ReportFileService instance or None if disabled + """ + if self._file_service is None: + try: + self._file_service = get_report_file_service() + except Exception as e: + self.logger.warning("Failed to initialize file service", error=str(e)) + return None + return self._file_service + async def run( self, query: str, @@ -659,6 +678,19 @@ FINDINGS: tokens=estimated_tokens, ) + # Save report to file if enabled + try: + file_service = self._get_file_service() + if file_service: + file_path = file_service.save_report( + report_content=report, + query=query, + ) + self.logger.info("Report saved to file", file_path=file_path) + except Exception as e: + # Don't fail the entire operation if file saving fails + self.logger.warning("Failed to save report to file", error=str(e)) + # Note: Citation validation for markdown reports would require Evidence objects # Currently, findings are strings, not Evidence objects. For full validation, # consider using ResearchReport format or passing Evidence objects separately. @@ -725,6 +757,24 @@ class DeepResearchFlow: # Graph orchestrator (lazy initialization) self._graph_orchestrator: Any = None + # File service (lazy initialization) + self._file_service: ReportFileService | None = None + + def _get_file_service(self) -> ReportFileService | None: + """ + Get file service instance (lazy initialization). + + Returns: + ReportFileService instance or None if disabled + """ + if self._file_service is None: + try: + self._file_service = get_report_file_service() + except Exception as e: + self.logger.warning("Failed to initialize file service", error=str(e)) + return None + return self._file_service + async def run(self, query: str) -> str: """ Run the deep research flow. @@ -1000,6 +1050,19 @@ class DeepResearchFlow: agent="long_writer" if self.use_long_writer else "proofreader", ) + # Save report to file if enabled + try: + file_service = self._get_file_service() + if file_service: + file_path = file_service.save_report( + report_content=final_report, + query=query, + ) + self.logger.info("Report saved to file", file_path=file_path) + except Exception as e: + # Don't fail the entire operation if file saving fails + self.logger.warning("Failed to save report to file", error=str(e)) + self.logger.info("Final report created", length=len(final_report)) return final_report diff --git a/src/services/image_ocr.py b/src/services/image_ocr.py index 0cbbe571a394de907b84bda94c54ad147af94dcf..670fc36951b6b78581ca1dd4513826a4d280d121 100644 --- a/src/services/image_ocr.py +++ b/src/services/image_ocr.py @@ -243,3 +243,5 @@ def get_image_ocr_service() -> ImageOCRService: + + diff --git a/src/services/report_file_service.py b/src/services/report_file_service.py new file mode 100644 index 0000000000000000000000000000000000000000..6d0fba60d07589861a5e549a23ac1a1beb084ca1 --- /dev/null +++ b/src/services/report_file_service.py @@ -0,0 +1,269 @@ +"""Service for saving research reports to files.""" + +import hashlib +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Literal + +import structlog + +from src.utils.config import settings +from src.utils.exceptions import ConfigurationError + +logger = structlog.get_logger() + + +class ReportFileService: + """ + Service for saving research reports to files. + + Handles file creation, naming, and directory management for report outputs. + Supports saving reports in multiple formats (markdown, HTML, PDF). + """ + + def __init__( + self, + output_directory: str | None = None, + enabled: bool | None = None, + file_format: Literal["md", "md_html", "md_pdf"] | None = None, + ) -> None: + """ + Initialize the report file service. + + Args: + output_directory: Directory to save reports. If None, uses settings or temp directory. + enabled: Whether file saving is enabled. If None, uses settings. + file_format: File format to save. If None, uses settings. + """ + self.enabled = enabled if enabled is not None else settings.save_reports_to_file + self.file_format = file_format or settings.report_file_format + self.filename_template = settings.report_filename_template + + # Determine output directory + if output_directory: + self.output_directory = Path(output_directory) + elif settings.report_output_directory: + self.output_directory = Path(settings.report_output_directory) + else: + # Use system temp directory + self.output_directory = Path(tempfile.gettempdir()) / "deepcritical_reports" + + # Create output directory if it doesn't exist + if self.enabled: + try: + self.output_directory.mkdir(parents=True, exist_ok=True) + logger.debug( + "Report output directory initialized", + path=str(self.output_directory), + enabled=self.enabled, + ) + except Exception as e: + logger.error("Failed to create report output directory", error=str(e), path=str(self.output_directory)) + raise ConfigurationError(f"Failed to create report output directory: {e}") from e + + def _generate_filename(self, query: str | None = None, extension: str = ".md") -> str: + """ + Generate filename for report using template. + + Args: + query: Optional query string for hash generation + extension: File extension (e.g., ".md", ".html") + + Returns: + Generated filename + """ + # Generate timestamp + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + # Generate query hash if query provided + query_hash = "" + if query: + query_hash = hashlib.md5(query.encode()).hexdigest()[:8] + + # Generate date + date = datetime.now().strftime("%Y-%m-%d") + + # Replace template placeholders + filename = self.filename_template + filename = filename.replace("{timestamp}", timestamp) + filename = filename.replace("{query_hash}", query_hash) + filename = filename.replace("{date}", date) + + # Ensure correct extension + if not filename.endswith(extension): + # Remove existing extension if present + if "." in filename: + filename = filename.rsplit(".", 1)[0] + filename += extension + + return filename + + def save_report( + self, + report_content: str, + query: str | None = None, + filename: str | None = None, + ) -> str: + """ + Save a report to a file. + + Args: + report_content: The report content (markdown string) + query: Optional query string for filename generation + filename: Optional custom filename. If None, generates from template. + + Returns: + Path to saved file + + Raises: + ConfigurationError: If file saving is disabled or fails + """ + if not self.enabled: + logger.debug("File saving disabled, skipping") + raise ConfigurationError("Report file saving is disabled") + + if not report_content or not report_content.strip(): + raise ValueError("Report content cannot be empty") + + # Generate filename if not provided + if not filename: + filename = self._generate_filename(query=query, extension=".md") + + # Ensure filename is safe + filename = self._sanitize_filename(filename) + + # Build full file path + file_path = self.output_directory / filename + + try: + # Write file + with open(file_path, "w", encoding="utf-8") as f: + f.write(report_content) + + logger.info( + "Report saved to file", + path=str(file_path), + size=len(report_content), + query=query[:50] if query else None, + ) + + return str(file_path) + + except Exception as e: + logger.error("Failed to save report to file", error=str(e), path=str(file_path)) + raise ConfigurationError(f"Failed to save report to file: {e}") from e + + def save_report_multiple_formats( + self, + report_content: str, + query: str | None = None, + ) -> dict[str, str]: + """ + Save a report in multiple formats. + + Args: + report_content: The report content (markdown string) + query: Optional query string for filename generation + + Returns: + Dictionary mapping format to file path (e.g., {"md": "/path/to/report.md"}) + + Raises: + ConfigurationError: If file saving is disabled or fails + """ + if not self.enabled: + logger.debug("File saving disabled, skipping") + raise ConfigurationError("Report file saving is disabled") + + saved_files: dict[str, str] = {} + + # Always save markdown + md_path = self.save_report(report_content, query=query, filename=None) + saved_files["md"] = md_path + + # Save additional formats based on file_format setting + if self.file_format == "md_html": + # TODO: Implement HTML conversion + logger.warning("HTML format not yet implemented, saving markdown only") + elif self.file_format == "md_pdf": + # TODO: Implement PDF conversion + logger.warning("PDF format not yet implemented, saving markdown only") + + return saved_files + + def _sanitize_filename(self, filename: str) -> str: + """ + Sanitize filename to remove unsafe characters. + + Args: + filename: Original filename + + Returns: + Sanitized filename + """ + # Remove or replace unsafe characters + unsafe_chars = '<>:"/\\|?*' + sanitized = filename + for char in unsafe_chars: + sanitized = sanitized.replace(char, "_") + + # Limit length + if len(sanitized) > 200: + name, ext = sanitized.rsplit(".", 1) if "." in sanitized else (sanitized, "") + sanitized = name[:190] + ext + + return sanitized + + def cleanup_old_files(self, max_age_days: int = 7) -> int: + """ + Clean up old report files. + + Args: + max_age_days: Maximum age in days for files to keep + + Returns: + Number of files deleted + """ + if not self.output_directory.exists(): + return 0 + + deleted_count = 0 + cutoff_time = datetime.now().timestamp() - (max_age_days * 24 * 60 * 60) + + try: + for file_path in self.output_directory.iterdir(): + if file_path.is_file() and file_path.stat().st_mtime < cutoff_time: + try: + file_path.unlink() + deleted_count += 1 + except Exception as e: + logger.warning("Failed to delete old file", path=str(file_path), error=str(e)) + + if deleted_count > 0: + logger.info("Cleaned up old report files", deleted=deleted_count, max_age_days=max_age_days) + + except Exception as e: + logger.error("Failed to cleanup old files", error=str(e)) + + return deleted_count + + +def get_report_file_service() -> ReportFileService: + """ + Get or create a ReportFileService instance (singleton pattern). + + Returns: + ReportFileService instance + """ + # Use lru_cache for singleton pattern + from functools import lru_cache + + @lru_cache(maxsize=1) + def _get_service() -> ReportFileService: + return ReportFileService() + + return _get_service() + + + diff --git a/src/tools/crawl_adapter.py b/src/tools/crawl_adapter.py index 58fd2e526acfcbcfb9804701633184d44c657a76..71a57fa66dcc4464e219692456c0916b6cedebe0 100644 --- a/src/tools/crawl_adapter.py +++ b/src/tools/crawl_adapter.py @@ -64,3 +64,5 @@ async def crawl_website(starting_url: str) -> str: + + diff --git a/src/tools/searchxng_web_search.py b/src/tools/searchxng_web_search.py index 13f9c6c7c934cfe4eddb59f038097ff8f8feaf92..49e26e84f004ed95ccace20cd29997877a1e0919 100644 --- a/src/tools/searchxng_web_search.py +++ b/src/tools/searchxng_web_search.py @@ -118,3 +118,5 @@ class SearchXNGWebSearchTool: raise SearchError(f"SearchXNG search failed: {e}") from e + + diff --git a/src/tools/serper_web_search.py b/src/tools/serper_web_search.py index 0a989016eb11b1dd054f13d153de6b5e3bdc280c..246427d31ea17c916426541362c769da91076765 100644 --- a/src/tools/serper_web_search.py +++ b/src/tools/serper_web_search.py @@ -118,3 +118,5 @@ class SerperWebSearchTool: raise SearchError(f"Serper search failed: {e}") from e + + diff --git a/src/tools/vendored/__init__.py b/src/tools/vendored/__init__.py index 2d0da1a467fffad6d43fb2754d6107954e5afb1a..6275950a4dd39fa650321367af74dbd789f38097 100644 --- a/src/tools/vendored/__init__.py +++ b/src/tools/vendored/__init__.py @@ -25,3 +25,5 @@ __all__ = [ ] + + diff --git a/src/tools/vendored/searchxng_client.py b/src/tools/vendored/searchxng_client.py index 12d903c7b001b8973ea9327ff97c35f7ed76d0c4..82f1a72d5b1eaf7a7169ebcfccdcef0f74a5c6d7 100644 --- a/src/tools/vendored/searchxng_client.py +++ b/src/tools/vendored/searchxng_client.py @@ -97,3 +97,5 @@ class SearchXNGClient: raise SearchError(f"SearchXNG search failed: {e}") from e + + diff --git a/src/tools/vendored/serper_client.py b/src/tools/vendored/serper_client.py index 9c5005b91a69d06b0044716a59c4b8a3f47513de..475bec708b12a2042ac3a58c8711616c8fcbd3e8 100644 --- a/src/tools/vendored/serper_client.py +++ b/src/tools/vendored/serper_client.py @@ -93,3 +93,5 @@ class SerperClient: raise SearchError(f"Serper search failed: {e}") from e + + diff --git a/src/tools/vendored/web_search_core.py b/src/tools/vendored/web_search_core.py index ef6cf031c2dc6031ae65e3b8762346fb156aae16..c374b55a6598e17128ad765e1541df9283f86462 100644 --- a/src/tools/vendored/web_search_core.py +++ b/src/tools/vendored/web_search_core.py @@ -204,3 +204,5 @@ def is_valid_url(url: str) -> bool: return True + + diff --git a/src/tools/web_search_factory.py b/src/tools/web_search_factory.py index fdc1f16bbb6be0df48b193da297e5b5ab241e9c9..ca6b082448d82cb42693787c84bc33340a97cf2a 100644 --- a/src/tools/web_search_factory.py +++ b/src/tools/web_search_factory.py @@ -72,3 +72,5 @@ def create_web_search_tool() -> SearchTool | None: return None + + diff --git a/src/utils/config.py b/src/utils/config.py index 5c49c77ea4d7aa81c584c915d1e45c93d34d6b65..643a69456a79246471c23bf3810300b82b5c162e 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -164,6 +164,24 @@ class Settings(BaseSettings): description="Modal GPU type for TTS (T4, A10, A100, L4, L40S). None uses default T4.", ) + # Report File Output Configuration + save_reports_to_file: bool = Field( + default=True, + description="Save generated reports to files (enables file downloads in Gradio)", + ) + report_output_directory: str | None = Field( + default=None, + description="Directory to save report files. If None, uses system temp directory.", + ) + report_file_format: Literal["md", "md_html", "md_pdf"] = Field( + default="md", + description="File format(s) to save reports in. 'md' saves only markdown, others save multiple formats.", + ) + report_filename_template: str = Field( + default="report_{timestamp}_{query_hash}.md", + description="Template for report filenames. Supports {timestamp}, {query_hash}, {date} placeholders.", + ) + @property def modal_available(self) -> bool: """Check if Modal credentials are configured.""" diff --git a/tests/unit/middleware/__init__.py b/tests/unit/middleware/__init__.py index 87cc084c5e5109ae3019919d55204b1e2ca214ef..12b5e65c8c5e127284f9f49fb3aad90fabf849de 100644 --- a/tests/unit/middleware/__init__.py +++ b/tests/unit/middleware/__init__.py @@ -18,6 +18,8 @@ + + diff --git a/tests/unit/middleware/test_budget_tracker_phase7.py b/tests/unit/middleware/test_budget_tracker_phase7.py index fa96e8b4ec69370ce8b3af80c69923149dd22603..486aeb9eaad02fff62079352b265f25db1009421 100644 --- a/tests/unit/middleware/test_budget_tracker_phase7.py +++ b/tests/unit/middleware/test_budget_tracker_phase7.py @@ -176,6 +176,8 @@ class TestIterationTokenTracking: + + diff --git a/tests/unit/middleware/test_state_machine.py b/tests/unit/middleware/test_state_machine.py index ecef2d0379190bc40c76f502cf66623e9585ecdf..b5e27d03d0725f16796ef73fc3d72348318bc7c5 100644 --- a/tests/unit/middleware/test_state_machine.py +++ b/tests/unit/middleware/test_state_machine.py @@ -373,6 +373,8 @@ class TestContextVarIsolation: + + diff --git a/tests/unit/middleware/test_workflow_manager.py b/tests/unit/middleware/test_workflow_manager.py index ba607f9458db19093f08f2d5327298ffc630159c..46985ff14458eda617b2c0c300a48cf19932bbc8 100644 --- a/tests/unit/middleware/test_workflow_manager.py +++ b/tests/unit/middleware/test_workflow_manager.py @@ -303,6 +303,8 @@ class TestWorkflowManager: + + diff --git a/tests/unit/orchestrator/__init__.py b/tests/unit/orchestrator/__init__.py index e32d84080511edec9fa87ef87aa23edea923cb48..6f5ede43dfd4d87e28bc6f33933bece3a8b831e0 100644 --- a/tests/unit/orchestrator/__init__.py +++ b/tests/unit/orchestrator/__init__.py @@ -18,6 +18,8 @@ + +