Joseph Pollack commited on
Commit
35d9120
·
unverified ·
1 Parent(s): d5a01e1

adds file returns , configuration enhancements , oauth fixes , and interface fixes

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. FILE_OUTPUT_IMPLEMENTATION_PLAN.md +237 -0
  2. REPORT_WRITING_AGENTS_ANALYSIS.md +2 -0
  3. SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md +2 -0
  4. docs/api/agents.md +2 -0
  5. docs/api/models.md +2 -0
  6. docs/api/orchestrators.md +2 -0
  7. docs/api/services.md +2 -0
  8. docs/api/tools.md +2 -0
  9. docs/architecture/agents.md +2 -0
  10. docs/architecture/middleware.md +2 -0
  11. docs/architecture/services.md +2 -0
  12. docs/architecture/tools.md +2 -0
  13. docs/contributing/code-quality.md +2 -0
  14. docs/contributing/code-style.md +2 -0
  15. docs/contributing/error-handling.md +2 -0
  16. docs/contributing/implementation-patterns.md +2 -0
  17. docs/contributing/index.md +2 -0
  18. docs/contributing/prompt-engineering.md +2 -0
  19. docs/contributing/testing.md +2 -0
  20. docs/getting-started/examples.md +2 -0
  21. docs/getting-started/installation.md +2 -0
  22. docs/getting-started/mcp-integration.md +2 -0
  23. docs/getting-started/quick-start.md +2 -0
  24. docs/implementation/IMPLEMENTATION_SUMMARY.md +2 -0
  25. docs/implementation/TTS_MODAL_IMPLEMENTATION.md +2 -0
  26. docs/license.md +2 -0
  27. docs/overview/architecture.md +2 -0
  28. docs/overview/features.md +2 -0
  29. docs/team.md +2 -0
  30. new_env.txt +2 -0
  31. src/agent_factory/judges.py +45 -18
  32. src/app.py +8 -1
  33. src/middleware/state_machine.py +2 -0
  34. src/orchestrator/graph_orchestrator.py +40 -0
  35. src/orchestrator/research_flow.py +63 -0
  36. src/services/image_ocr.py +2 -0
  37. src/services/report_file_service.py +269 -0
  38. src/tools/crawl_adapter.py +2 -0
  39. src/tools/searchxng_web_search.py +2 -0
  40. src/tools/serper_web_search.py +2 -0
  41. src/tools/vendored/__init__.py +2 -0
  42. src/tools/vendored/searchxng_client.py +2 -0
  43. src/tools/vendored/serper_client.py +2 -0
  44. src/tools/vendored/web_search_core.py +2 -0
  45. src/tools/web_search_factory.py +2 -0
  46. src/utils/config.py +18 -0
  47. tests/unit/middleware/__init__.py +2 -0
  48. tests/unit/middleware/test_budget_tracker_phase7.py +2 -0
  49. tests/unit/middleware/test_state_machine.py +2 -0
  50. tests/unit/middleware/test_workflow_manager.py +2 -0
FILE_OUTPUT_IMPLEMENTATION_PLAN.md ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # File Output Implementation Plan
2
+
3
+ ## Overview
4
+
5
+ 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.
6
+
7
+ ## Current State Analysis
8
+
9
+ ✅ **Report Generation**: All agents generate markdown strings
10
+ ✅ **File Output Integration**: `event_to_chat_message()` supports file paths
11
+ ✅ **Graph Orchestrator**: Can handle file paths in results
12
+ ❌ **File Writing**: No agents write files to disk
13
+ ❌ **File Service**: No utility service for saving reports
14
+
15
+ ---
16
+
17
+ ## Implementation Plan
18
+
19
+ ### PROJECT 1: File Writing Service
20
+ **Goal**: Create a reusable service for saving reports to files
21
+
22
+ #### Activity 1.1: Create Report File Service
23
+ **File**: `src/services/report_file_service.py` (NEW)
24
+
25
+ **Tasks**:
26
+ 1. Create `ReportFileService` class
27
+ 2. Implement `save_report()` method
28
+ - Accepts: report content (str), filename (optional), output_dir (optional)
29
+ - Returns: file path (str)
30
+ - Uses temp directory by default
31
+ - Supports custom output directory
32
+ - Handles file naming with timestamps
33
+ 3. Implement `save_report_multiple_formats()` method
34
+ - Save as .md (always)
35
+ - Optionally save as .html, .pdf (future)
36
+ 4. Add configuration support
37
+ - Read from settings
38
+ - Enable/disable file saving
39
+ - Configurable output directory
40
+ 5. Add error handling and logging
41
+ 6. Add file cleanup utilities (optional)
42
+
43
+ **Line-level subtasks**:
44
+ - Line 1-20: Imports and class definition
45
+ - Line 21-40: `__init__()` method with settings
46
+ - Line 41-80: `save_report()` method
47
+ - Line 41-50: Input validation
48
+ - Line 51-60: Directory creation
49
+ - Line 61-70: File writing
50
+ - Line 71-80: Error handling
51
+ - Line 81-100: `save_report_multiple_formats()` method
52
+ - Line 101-120: Helper methods (filename generation, cleanup)
53
+
54
+ ---
55
+
56
+ ### PROJECT 2: Configuration Updates
57
+ **Goal**: Add settings for file output functionality
58
+
59
+ #### Activity 2.1: Update Settings Model
60
+ **File**: `src/utils/config.py`
61
+
62
+ **Tasks**:
63
+ 1. Add `save_reports_to_file: bool` field (default: True)
64
+ 2. Add `report_output_directory: str | None` field (default: None, uses temp)
65
+ 3. Add `report_file_format: Literal["md", "md_html", "md_pdf"]` field (default: "md")
66
+ 4. Add `report_filename_template: str` field (default: "report_{timestamp}_{query_hash}.md")
67
+
68
+ **Line-level subtasks**:
69
+ - Line 166-170: Add `save_reports_to_file` field after TTS config
70
+ - Line 171-175: Add `report_output_directory` field
71
+ - Line 176-180: Add `report_file_format` field
72
+ - Line 181-185: Add `report_filename_template` field
73
+
74
+ ---
75
+
76
+ ### PROJECT 3: Graph Orchestrator Integration
77
+ **Goal**: Integrate file writing into graph execution
78
+
79
+ #### Activity 3.1: Update Graph Orchestrator
80
+ **File**: `src/orchestrator/graph_orchestrator.py`
81
+
82
+ **Tasks**:
83
+ 1. Import `ReportFileService` at top
84
+ 2. Initialize service in `__init__()` (optional, can be lazy)
85
+ 3. Modify `_execute_agent_node()` for synthesizer node
86
+ - After `long_writer_agent.write_report()`, save to file
87
+ - Return dict with `{"message": report, "file": file_path}`
88
+ 4. Update final event generation to handle file paths
89
+ - Already implemented, verify it works correctly
90
+
91
+ **Line-level subtasks**:
92
+ - Line 1-35: Add import for `ReportFileService`
93
+ - Line 119-148: Update `__init__()` to accept optional file service
94
+ - Line 589-650: Modify `_execute_agent_node()` synthesizer handling
95
+ - Line 642-645: After `write_report()`, add file saving
96
+ - Line 646-650: Return dict with file path
97
+ - Line 534-564: Verify final event generation handles file paths (already done)
98
+
99
+ ---
100
+
101
+ ### PROJECT 4: Research Flow Integration
102
+ **Goal**: Integrate file writing into research flows
103
+
104
+ #### Activity 4.1: Update IterativeResearchFlow
105
+ **File**: `src/orchestrator/research_flow.py`
106
+
107
+ **Tasks**:
108
+ 1. Import `ReportFileService` at top
109
+ 2. Add optional file service to `__init__()`
110
+ 3. Modify `_create_final_report()` method
111
+ - After `writer_agent.write_report()`, save to file if enabled
112
+ - Return string (backward compatible) OR dict with file path
113
+
114
+ **Line-level subtasks**:
115
+ - Line 1-50: Add import for `ReportFileService`
116
+ - Line 48-120: Update `__init__()` to accept optional file service
117
+ - Line 622-667: Modify `_create_final_report()` method
118
+ - Line 647-652: After `write_report()`, add file saving
119
+ - Line 653-667: Return report string (keep backward compatible for now)
120
+
121
+ #### Activity 4.2: Update DeepResearchFlow
122
+ **File**: `src/orchestrator/research_flow.py`
123
+
124
+ **Tasks**:
125
+ 1. Add optional file service to `__init__()` (if not already)
126
+ 2. Modify `_create_final_report()` method
127
+ - After `long_writer_agent.write_report()` or `proofreader_agent.proofread()`, save to file
128
+ - Return string (backward compatible) OR dict with file path
129
+
130
+ **Line-level subtasks**:
131
+ - Line 670-750: Update `DeepResearchFlow.__init__()` to accept optional file service
132
+ - Line 954-1005: Modify `_create_final_report()` method
133
+ - Line 979-983: After `write_report()`, add file saving
134
+ - Line 984-989: After `proofread()`, add file saving
135
+ - Line 990-1005: Return report string (keep backward compatible)
136
+
137
+ ---
138
+
139
+ ### PROJECT 5: Agent Factory Integration
140
+ **Goal**: Make file service available to agents if needed
141
+
142
+ #### Activity 5.1: Update Agent Factory (Optional)
143
+ **File**: `src/agent_factory/agents.py`
144
+
145
+ **Tasks**:
146
+ 1. Add optional file service parameter to agent creation functions (if needed)
147
+ 2. Pass file service to agents that need it (currently not needed, agents return strings)
148
+
149
+ **Line-level subtasks**:
150
+ - Not required - agents return strings, file writing happens at orchestrator level
151
+
152
+ ---
153
+
154
+ ### PROJECT 6: Testing & Validation
155
+ **Goal**: Ensure file output works end-to-end
156
+
157
+ #### Activity 6.1: Unit Tests
158
+ **File**: `tests/unit/services/test_report_file_service.py` (NEW)
159
+
160
+ **Tasks**:
161
+ 1. Test `save_report()` with default settings
162
+ 2. Test `save_report()` with custom directory
163
+ 3. Test `save_report()` with custom filename
164
+ 4. Test error handling (permission errors, disk full, etc.)
165
+ 5. Test file cleanup
166
+
167
+ **Line-level subtasks**:
168
+ - Line 1-30: Test fixtures and setup
169
+ - Line 31-60: Test basic save functionality
170
+ - Line 61-90: Test custom directory
171
+ - Line 91-120: Test error handling
172
+
173
+ #### Activity 6.2: Integration Tests
174
+ **File**: `tests/integration/test_file_output_integration.py` (NEW)
175
+
176
+ **Tasks**:
177
+ 1. Test graph orchestrator with file output
178
+ 2. Test research flows with file output
179
+ 3. Test Gradio ChatInterface receives file paths
180
+ 4. Test file download in Gradio UI
181
+
182
+ **Line-level subtasks**:
183
+ - Line 1-40: Test setup with mock orchestrator
184
+ - Line 41-80: Test file generation in graph execution
185
+ - Line 81-120: Test file paths in AgentEvent
186
+ - Line 121-160: Test Gradio message conversion
187
+
188
+ ---
189
+
190
+ ## Implementation Order
191
+
192
+ 1. **PROJECT 2** (Configuration) - Foundation
193
+ 2. **PROJECT 1** (File Service) - Core functionality
194
+ 3. **PROJECT 3** (Graph Orchestrator) - Primary integration point
195
+ 4. **PROJECT 4** (Research Flows) - Secondary integration points
196
+ 5. **PROJECT 6** (Testing) - Validation
197
+ 6. **PROJECT 5** (Agent Factory) - Not needed, skip
198
+
199
+ ---
200
+
201
+ ## File Changes Summary
202
+
203
+ ### New Files
204
+ - `src/services/report_file_service.py` - File writing service
205
+ - `tests/unit/services/test_report_file_service.py` - Unit tests
206
+ - `tests/integration/test_file_output_integration.py` - Integration tests
207
+
208
+ ### Modified Files
209
+ - `src/utils/config.py` - Add file output settings
210
+ - `src/orchestrator/graph_orchestrator.py` - Add file saving after report generation
211
+ - `src/orchestrator/research_flow.py` - Add file saving in both flows
212
+
213
+ ---
214
+
215
+ ## Gradio Integration Notes
216
+
217
+ According to Gradio ChatInterface documentation:
218
+ - File paths in chat message content are automatically converted to download links
219
+ - Markdown links like `[Download: filename](file_path)` work
220
+ - Files must be accessible from the Gradio server
221
+ - Temp files are fine as long as they exist during the session
222
+
223
+ Current implementation in `event_to_chat_message()` already handles this correctly.
224
+
225
+ ---
226
+
227
+ ## Success Criteria
228
+
229
+ ✅ Reports are saved to files when generated
230
+ ✅ File paths are included in AgentEvent data
231
+ ✅ File paths appear as download links in Gradio ChatInterface
232
+ ✅ File saving is configurable (can be disabled)
233
+ ✅ Backward compatible (existing code still works)
234
+ ✅ Error handling prevents crashes if file writing fails
235
+
236
+
237
+
REPORT_WRITING_AGENTS_ANALYSIS.md CHANGED
@@ -181,3 +181,5 @@ return {
181
  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.
182
 
183
 
 
 
 
181
  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.
182
 
183
 
184
+
185
+
SERPER_WEBSEARCH_IMPLEMENTATION_PLAN.md CHANGED
@@ -395,3 +395,5 @@ This plan details the implementation of SERPER-based web search by vendoring cod
395
  - Consider adding relevance scoring in the future
396
 
397
 
 
 
 
395
  - Consider adding relevance scoring in the future
396
 
397
 
398
+
399
+
docs/api/agents.md CHANGED
@@ -272,3 +272,5 @@ def create_input_parser_agent(model: Any | None = None) -> InputParserAgent
272
 
273
 
274
 
 
 
 
272
 
273
 
274
 
275
+
276
+
docs/api/models.md CHANGED
@@ -250,3 +250,5 @@ class BudgetStatus(BaseModel):
250
 
251
 
252
 
 
 
 
250
 
251
 
252
 
253
+
254
+
docs/api/orchestrators.md CHANGED
@@ -197,3 +197,5 @@ Runs Magentic orchestration.
197
 
198
 
199
 
 
 
 
197
 
198
 
199
 
200
+
201
+
docs/api/services.md CHANGED
@@ -203,3 +203,5 @@ Analyzes a hypothesis using statistical methods.
203
 
204
 
205
 
 
 
 
203
 
204
 
205
 
206
+
207
+
docs/api/tools.md CHANGED
@@ -237,3 +237,5 @@ Searches multiple tools in parallel.
237
 
238
 
239
 
 
 
 
237
 
238
 
239
 
240
+
241
+
docs/architecture/agents.md CHANGED
@@ -194,3 +194,5 @@ Factory functions:
194
 
195
 
196
 
 
 
 
194
 
195
 
196
 
197
+
198
+
docs/architecture/middleware.md CHANGED
@@ -144,3 +144,5 @@ All middleware components use `ContextVar` for thread-safe isolation:
144
 
145
 
146
 
 
 
 
144
 
145
 
146
 
147
+
148
+
docs/architecture/services.md CHANGED
@@ -144,3 +144,5 @@ if settings.has_openai_key:
144
 
145
 
146
 
 
 
 
144
 
145
 
146
 
147
+
148
+
docs/architecture/tools.md CHANGED
@@ -177,3 +177,5 @@ search_handler = SearchHandler(
177
 
178
 
179
 
 
 
 
177
 
178
 
179
 
180
+
181
+
docs/contributing/code-quality.md CHANGED
@@ -83,3 +83,5 @@ async def search(self, query: str, max_results: int = 10) -> list[Evidence]:
83
 
84
 
85
 
 
 
 
83
 
84
 
85
 
86
+
87
+
docs/contributing/code-style.md CHANGED
@@ -63,3 +63,5 @@ result = await loop.run_in_executor(None, cpu_bound_function, args)
63
 
64
 
65
 
 
 
 
63
 
64
 
65
 
66
+
67
+
docs/contributing/error-handling.md CHANGED
@@ -71,3 +71,5 @@ except httpx.HTTPError as e:
71
 
72
 
73
 
 
 
 
71
 
72
 
73
 
74
+
75
+
docs/contributing/implementation-patterns.md CHANGED
@@ -86,3 +86,5 @@ def get_embedding_service() -> EmbeddingService:
86
 
87
 
88
 
 
 
 
86
 
87
 
88
 
89
+
90
+
docs/contributing/index.md CHANGED
@@ -165,3 +165,5 @@ Thank you for contributing to DeepCritical!
165
 
166
 
167
 
 
 
 
165
 
166
 
167
 
168
+
169
+
docs/contributing/prompt-engineering.md CHANGED
@@ -71,3 +71,5 @@ This document outlines prompt engineering guidelines and citation validation rul
71
 
72
 
73
 
 
 
 
71
 
72
 
73
 
74
+
75
+
docs/contributing/testing.md CHANGED
@@ -67,3 +67,5 @@ async def test_real_pubmed_search():
67
 
68
 
69
 
 
 
 
67
 
68
 
69
 
70
+
71
+
docs/getting-started/examples.md CHANGED
@@ -211,3 +211,5 @@ USE_GRAPH_EXECUTION=true
211
 
212
 
213
 
 
 
 
211
 
212
 
213
 
214
+
215
+
docs/getting-started/installation.md CHANGED
@@ -150,3 +150,5 @@ uv run pre-commit install
150
 
151
 
152
 
 
 
 
150
 
151
 
152
 
153
+
154
+
docs/getting-started/mcp-integration.md CHANGED
@@ -217,3 +217,5 @@ You can configure multiple DeepCritical instances:
217
 
218
 
219
 
 
 
 
217
 
218
 
219
 
220
+
221
+
docs/getting-started/quick-start.md CHANGED
@@ -121,3 +121,5 @@ What are the active clinical trials investigating Alzheimer's disease treatments
121
 
122
 
123
 
 
 
 
121
 
122
 
123
 
124
+
125
+
docs/implementation/IMPLEMENTATION_SUMMARY.md CHANGED
@@ -180,3 +180,5 @@ Located in `src/app.py` lines 667-712:
180
 
181
 
182
 
 
 
 
180
 
181
 
182
 
183
+
184
+
docs/implementation/TTS_MODAL_IMPLEMENTATION.md CHANGED
@@ -134,3 +134,5 @@ To test TTS:
134
 
135
 
136
 
 
 
 
134
 
135
 
136
 
137
+
138
+
docs/license.md CHANGED
@@ -41,3 +41,5 @@ SOFTWARE.
41
 
42
 
43
 
 
 
 
41
 
42
 
43
 
44
+
45
+
docs/overview/architecture.md CHANGED
@@ -198,3 +198,5 @@ The system supports complex research workflows through:
198
 
199
 
200
 
 
 
 
198
 
199
 
200
 
201
+
202
+
docs/overview/features.md CHANGED
@@ -150,3 +150,5 @@ DeepCritical provides a comprehensive set of features for AI-assisted research:
150
 
151
 
152
 
 
 
 
150
 
151
 
152
 
153
+
154
+
docs/team.md CHANGED
@@ -46,3 +46,5 @@ We welcome contributions! See the [Contributing Guide](contributing/index.md) fo
46
 
47
 
48
 
 
 
 
46
 
47
 
48
 
49
+
50
+
new_env.txt CHANGED
@@ -96,3 +96,5 @@ MODAL_TOKEN_SECRET=your_modal_token_secret_here
96
 
97
 
98
 
 
 
 
96
 
97
 
98
 
99
+
100
+
src/agent_factory/judges.py CHANGED
@@ -33,34 +33,61 @@ def get_model(oauth_token: str | None = None) -> Any:
33
  Explicitly passes API keys from settings to avoid requiring
34
  users to export environment variables manually.
35
 
36
- Priority: If OAuth token is available, prefer HuggingFace (even if provider is set to OpenAI).
 
 
 
 
 
37
  This ensures users logged in via HuggingFace Spaces get the free tier.
38
 
39
  Args:
40
  oauth_token: Optional OAuth token from HuggingFace login (takes priority over env vars)
 
 
 
 
 
 
41
  """
42
  # Priority: oauth_token > settings.hf_token > settings.huggingface_api_key
43
  effective_hf_token = oauth_token or settings.hf_token or settings.huggingface_api_key
44
 
45
- # HuggingFaceProvider requires a token - cannot use None
46
- if not effective_hf_token:
47
- raise ConfigurationError(
48
- "HuggingFace token required. Please either:\n"
49
- "1. Log in via HuggingFace OAuth (recommended for Spaces)\n"
50
- "2. Set HF_TOKEN environment variable\n"
51
- "3. Set huggingface_api_key in settings"
 
 
52
  )
53
-
54
- # Always use HuggingFace with available token
55
- model_name = settings.huggingface_model or "meta-llama/Llama-3.1-8B-Instruct"
56
- hf_provider = HuggingFaceProvider(api_key=effective_hf_token)
57
- logger.info(
58
- "using_huggingface_with_token",
59
- has_oauth=bool(oauth_token),
60
- has_settings_token=bool(settings.hf_token or settings.huggingface_api_key),
61
- model=model_name,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  )
63
- return HuggingFaceModel(model_name, provider=hf_provider)
64
 
65
 
66
  class JudgeHandler:
 
33
  Explicitly passes API keys from settings to avoid requiring
34
  users to export environment variables manually.
35
 
36
+ Priority order:
37
+ 1. HuggingFace (if OAuth token or API key available - preferred for free tier)
38
+ 2. OpenAI (if API key available)
39
+ 3. Anthropic (if API key available)
40
+
41
+ If OAuth token is available, prefer HuggingFace (even if provider is set to OpenAI).
42
  This ensures users logged in via HuggingFace Spaces get the free tier.
43
 
44
  Args:
45
  oauth_token: Optional OAuth token from HuggingFace login (takes priority over env vars)
46
+
47
+ Returns:
48
+ Configured Pydantic AI model
49
+
50
+ Raises:
51
+ ConfigurationError: If no LLM provider is available
52
  """
53
  # Priority: oauth_token > settings.hf_token > settings.huggingface_api_key
54
  effective_hf_token = oauth_token or settings.hf_token or settings.huggingface_api_key
55
 
56
+ # Try HuggingFace first (preferred for free tier)
57
+ if effective_hf_token:
58
+ model_name = settings.huggingface_model or "meta-llama/Llama-3.1-8B-Instruct"
59
+ hf_provider = HuggingFaceProvider(api_key=effective_hf_token)
60
+ logger.info(
61
+ "using_huggingface_with_token",
62
+ has_oauth=bool(oauth_token),
63
+ has_settings_token=bool(settings.hf_token or settings.huggingface_api_key),
64
+ model=model_name,
65
  )
66
+ return HuggingFaceModel(model_name, provider=hf_provider)
67
+
68
+ # Fallback to OpenAI if available
69
+ if settings.has_openai_key:
70
+ assert settings.openai_api_key is not None # Type narrowing
71
+ model_name = settings.openai_model
72
+ openai_provider = OpenAIProvider(api_key=settings.openai_api_key)
73
+ logger.info("using_openai", model=model_name)
74
+ return OpenAIModel(model_name, provider=openai_provider)
75
+
76
+ # Fallback to Anthropic if available
77
+ if settings.has_anthropic_key:
78
+ assert settings.anthropic_api_key is not None # Type narrowing
79
+ model_name = settings.anthropic_model
80
+ anthropic_provider = AnthropicProvider(api_key=settings.anthropic_api_key)
81
+ logger.info("using_anthropic", model=model_name)
82
+ return AnthropicModel(model_name, provider=anthropic_provider)
83
+
84
+ # No provider available
85
+ raise ConfigurationError(
86
+ "No LLM provider available. Please configure one of:\n"
87
+ "1. HuggingFace: Log in via OAuth (recommended for Spaces) or set HF_TOKEN\n"
88
+ "2. OpenAI: Set OPENAI_API_KEY environment variable\n"
89
+ "3. Anthropic: Set ANTHROPIC_API_KEY environment variable"
90
  )
 
91
 
92
 
93
  class JudgeHandler:
src/app.py CHANGED
@@ -158,6 +158,7 @@ def configure_orchestrator(
158
  judge_handler=judge_handler,
159
  config=config,
160
  mode=effective_mode, # type: ignore
 
161
  )
162
 
163
  return orchestrator, backend_info
@@ -570,7 +571,13 @@ async def research_agent(
570
 
571
  if oauth_token is not None:
572
  # OAuthToken has a .token attribute containing the access token
573
- token_value = oauth_token.token if hasattr(oauth_token, "token") else None
 
 
 
 
 
 
574
 
575
  if oauth_profile is not None:
576
  # OAuthProfile has .username, .name, .profile_image attributes
 
158
  judge_handler=judge_handler,
159
  config=config,
160
  mode=effective_mode, # type: ignore
161
+ oauth_token=oauth_token,
162
  )
163
 
164
  return orchestrator, backend_info
 
571
 
572
  if oauth_token is not None:
573
  # OAuthToken has a .token attribute containing the access token
574
+ if hasattr(oauth_token, "token"):
575
+ token_value = oauth_token.token
576
+ elif isinstance(oauth_token, str):
577
+ # Handle case where oauth_token is already a string (shouldn't happen but defensive)
578
+ token_value = oauth_token
579
+ else:
580
+ token_value = None
581
 
582
  if oauth_profile is not None:
583
  # OAuthProfile has .username, .name, .profile_image attributes
src/middleware/state_machine.py CHANGED
@@ -135,3 +135,5 @@ def get_workflow_state() -> WorkflowState:
135
 
136
 
137
 
 
 
 
135
 
136
 
137
 
138
+
139
+
src/orchestrator/graph_orchestrator.py CHANGED
@@ -32,6 +32,7 @@ from src.legacy_orchestrator import JudgeHandlerProtocol, SearchHandlerProtocol
32
  from src.middleware.budget_tracker import BudgetTracker
33
  from src.middleware.state_machine import WorkflowState, init_workflow_state
34
  from src.orchestrator.research_flow import DeepResearchFlow, IterativeResearchFlow
 
35
  from src.utils.models import AgentEvent
36
 
37
  if TYPE_CHECKING:
@@ -147,6 +148,9 @@ class GraphOrchestrator:
147
  self.oauth_token = oauth_token
148
  self.logger = logger
149
 
 
 
 
150
  # Initialize flows (for backward compatibility)
151
  self._iterative_flow: IterativeResearchFlow | None = None
152
  self._deep_flow: DeepResearchFlow | None = None
@@ -155,6 +159,21 @@ class GraphOrchestrator:
155
  self._graph: ResearchGraph | None = None
156
  self._budget_tracker: BudgetTracker | None = None
157
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
  async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]:
159
  """
160
  Run the research workflow.
@@ -649,6 +668,27 @@ class GraphOrchestrator:
649
  estimated_tokens = len(final_report) // 4 # Rough token estimate
650
  context.budget_tracker.add_tokens("graph_execution", estimated_tokens)
651
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
652
  return final_report
653
 
654
  # Standard agent execution
 
32
  from src.middleware.budget_tracker import BudgetTracker
33
  from src.middleware.state_machine import WorkflowState, init_workflow_state
34
  from src.orchestrator.research_flow import DeepResearchFlow, IterativeResearchFlow
35
+ from src.services.report_file_service import ReportFileService, get_report_file_service
36
  from src.utils.models import AgentEvent
37
 
38
  if TYPE_CHECKING:
 
148
  self.oauth_token = oauth_token
149
  self.logger = logger
150
 
151
+ # Initialize file service (lazy if not provided)
152
+ self._file_service: ReportFileService | None = None
153
+
154
  # Initialize flows (for backward compatibility)
155
  self._iterative_flow: IterativeResearchFlow | None = None
156
  self._deep_flow: DeepResearchFlow | None = None
 
159
  self._graph: ResearchGraph | None = None
160
  self._budget_tracker: BudgetTracker | None = None
161
 
162
+ def _get_file_service(self) -> ReportFileService | None:
163
+ """
164
+ Get file service instance (lazy initialization).
165
+
166
+ Returns:
167
+ ReportFileService instance or None if disabled
168
+ """
169
+ if self._file_service is None:
170
+ try:
171
+ self._file_service = get_report_file_service()
172
+ except Exception as e:
173
+ self.logger.warning("Failed to initialize file service", error=str(e))
174
+ return None
175
+ return self._file_service
176
+
177
  async def run(self, query: str) -> AsyncGenerator[AgentEvent, None]:
178
  """
179
  Run the research workflow.
 
668
  estimated_tokens = len(final_report) // 4 # Rough token estimate
669
  context.budget_tracker.add_tokens("graph_execution", estimated_tokens)
670
 
671
+ # Save report to file if enabled
672
+ file_path: str | None = None
673
+ try:
674
+ file_service = self._get_file_service()
675
+ if file_service:
676
+ file_path = file_service.save_report(
677
+ report_content=final_report,
678
+ query=query,
679
+ )
680
+ self.logger.info("Report saved to file", file_path=file_path)
681
+ except Exception as e:
682
+ # Don't fail the entire operation if file saving fails
683
+ self.logger.warning("Failed to save report to file", error=str(e))
684
+ file_path = None
685
+
686
+ # Return dict with file path if available, otherwise return string (backward compatible)
687
+ if file_path:
688
+ return {
689
+ "message": final_report,
690
+ "file": file_path,
691
+ }
692
  return final_report
693
 
694
  # Standard agent execution
src/orchestrator/research_flow.py CHANGED
@@ -25,6 +25,7 @@ from src.middleware.budget_tracker import BudgetTracker
25
  from src.middleware.state_machine import get_workflow_state, init_workflow_state
26
  from src.middleware.workflow_manager import WorkflowManager
27
  from src.services.llamaindex_rag import LlamaIndexRAGService, get_rag_service
 
28
  from src.tools.tool_executor import execute_tool_tasks
29
  from src.utils.exceptions import ConfigurationError
30
  from src.utils.models import (
@@ -112,6 +113,24 @@ class IterativeResearchFlow:
112
  # Graph orchestrator (lazy initialization)
113
  self._graph_orchestrator: Any = None
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  async def run(
116
  self,
117
  query: str,
@@ -659,6 +678,19 @@ FINDINGS:
659
  tokens=estimated_tokens,
660
  )
661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
  # Note: Citation validation for markdown reports would require Evidence objects
663
  # Currently, findings are strings, not Evidence objects. For full validation,
664
  # consider using ResearchReport format or passing Evidence objects separately.
@@ -725,6 +757,24 @@ class DeepResearchFlow:
725
  # Graph orchestrator (lazy initialization)
726
  self._graph_orchestrator: Any = None
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  async def run(self, query: str) -> str:
729
  """
730
  Run the deep research flow.
@@ -1000,6 +1050,19 @@ class DeepResearchFlow:
1000
  agent="long_writer" if self.use_long_writer else "proofreader",
1001
  )
1002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  self.logger.info("Final report created", length=len(final_report))
1004
 
1005
  return final_report
 
25
  from src.middleware.state_machine import get_workflow_state, init_workflow_state
26
  from src.middleware.workflow_manager import WorkflowManager
27
  from src.services.llamaindex_rag import LlamaIndexRAGService, get_rag_service
28
+ from src.services.report_file_service import ReportFileService, get_report_file_service
29
  from src.tools.tool_executor import execute_tool_tasks
30
  from src.utils.exceptions import ConfigurationError
31
  from src.utils.models import (
 
113
  # Graph orchestrator (lazy initialization)
114
  self._graph_orchestrator: Any = None
115
 
116
+ # File service (lazy initialization)
117
+ self._file_service: ReportFileService | None = None
118
+
119
+ def _get_file_service(self) -> ReportFileService | None:
120
+ """
121
+ Get file service instance (lazy initialization).
122
+
123
+ Returns:
124
+ ReportFileService instance or None if disabled
125
+ """
126
+ if self._file_service is None:
127
+ try:
128
+ self._file_service = get_report_file_service()
129
+ except Exception as e:
130
+ self.logger.warning("Failed to initialize file service", error=str(e))
131
+ return None
132
+ return self._file_service
133
+
134
  async def run(
135
  self,
136
  query: str,
 
678
  tokens=estimated_tokens,
679
  )
680
 
681
+ # Save report to file if enabled
682
+ try:
683
+ file_service = self._get_file_service()
684
+ if file_service:
685
+ file_path = file_service.save_report(
686
+ report_content=report,
687
+ query=query,
688
+ )
689
+ self.logger.info("Report saved to file", file_path=file_path)
690
+ except Exception as e:
691
+ # Don't fail the entire operation if file saving fails
692
+ self.logger.warning("Failed to save report to file", error=str(e))
693
+
694
  # Note: Citation validation for markdown reports would require Evidence objects
695
  # Currently, findings are strings, not Evidence objects. For full validation,
696
  # consider using ResearchReport format or passing Evidence objects separately.
 
757
  # Graph orchestrator (lazy initialization)
758
  self._graph_orchestrator: Any = None
759
 
760
+ # File service (lazy initialization)
761
+ self._file_service: ReportFileService | None = None
762
+
763
+ def _get_file_service(self) -> ReportFileService | None:
764
+ """
765
+ Get file service instance (lazy initialization).
766
+
767
+ Returns:
768
+ ReportFileService instance or None if disabled
769
+ """
770
+ if self._file_service is None:
771
+ try:
772
+ self._file_service = get_report_file_service()
773
+ except Exception as e:
774
+ self.logger.warning("Failed to initialize file service", error=str(e))
775
+ return None
776
+ return self._file_service
777
+
778
  async def run(self, query: str) -> str:
779
  """
780
  Run the deep research flow.
 
1050
  agent="long_writer" if self.use_long_writer else "proofreader",
1051
  )
1052
 
1053
+ # Save report to file if enabled
1054
+ try:
1055
+ file_service = self._get_file_service()
1056
+ if file_service:
1057
+ file_path = file_service.save_report(
1058
+ report_content=final_report,
1059
+ query=query,
1060
+ )
1061
+ self.logger.info("Report saved to file", file_path=file_path)
1062
+ except Exception as e:
1063
+ # Don't fail the entire operation if file saving fails
1064
+ self.logger.warning("Failed to save report to file", error=str(e))
1065
+
1066
  self.logger.info("Final report created", length=len(final_report))
1067
 
1068
  return final_report
src/services/image_ocr.py CHANGED
@@ -243,3 +243,5 @@ def get_image_ocr_service() -> ImageOCRService:
243
 
244
 
245
 
 
 
 
243
 
244
 
245
 
246
+
247
+
src/services/report_file_service.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Service for saving research reports to files."""
2
+
3
+ import hashlib
4
+ import tempfile
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import structlog
10
+
11
+ from src.utils.config import settings
12
+ from src.utils.exceptions import ConfigurationError
13
+
14
+ logger = structlog.get_logger()
15
+
16
+
17
+ class ReportFileService:
18
+ """
19
+ Service for saving research reports to files.
20
+
21
+ Handles file creation, naming, and directory management for report outputs.
22
+ Supports saving reports in multiple formats (markdown, HTML, PDF).
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ output_directory: str | None = None,
28
+ enabled: bool | None = None,
29
+ file_format: Literal["md", "md_html", "md_pdf"] | None = None,
30
+ ) -> None:
31
+ """
32
+ Initialize the report file service.
33
+
34
+ Args:
35
+ output_directory: Directory to save reports. If None, uses settings or temp directory.
36
+ enabled: Whether file saving is enabled. If None, uses settings.
37
+ file_format: File format to save. If None, uses settings.
38
+ """
39
+ self.enabled = enabled if enabled is not None else settings.save_reports_to_file
40
+ self.file_format = file_format or settings.report_file_format
41
+ self.filename_template = settings.report_filename_template
42
+
43
+ # Determine output directory
44
+ if output_directory:
45
+ self.output_directory = Path(output_directory)
46
+ elif settings.report_output_directory:
47
+ self.output_directory = Path(settings.report_output_directory)
48
+ else:
49
+ # Use system temp directory
50
+ self.output_directory = Path(tempfile.gettempdir()) / "deepcritical_reports"
51
+
52
+ # Create output directory if it doesn't exist
53
+ if self.enabled:
54
+ try:
55
+ self.output_directory.mkdir(parents=True, exist_ok=True)
56
+ logger.debug(
57
+ "Report output directory initialized",
58
+ path=str(self.output_directory),
59
+ enabled=self.enabled,
60
+ )
61
+ except Exception as e:
62
+ logger.error("Failed to create report output directory", error=str(e), path=str(self.output_directory))
63
+ raise ConfigurationError(f"Failed to create report output directory: {e}") from e
64
+
65
+ def _generate_filename(self, query: str | None = None, extension: str = ".md") -> str:
66
+ """
67
+ Generate filename for report using template.
68
+
69
+ Args:
70
+ query: Optional query string for hash generation
71
+ extension: File extension (e.g., ".md", ".html")
72
+
73
+ Returns:
74
+ Generated filename
75
+ """
76
+ # Generate timestamp
77
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
78
+
79
+ # Generate query hash if query provided
80
+ query_hash = ""
81
+ if query:
82
+ query_hash = hashlib.md5(query.encode()).hexdigest()[:8]
83
+
84
+ # Generate date
85
+ date = datetime.now().strftime("%Y-%m-%d")
86
+
87
+ # Replace template placeholders
88
+ filename = self.filename_template
89
+ filename = filename.replace("{timestamp}", timestamp)
90
+ filename = filename.replace("{query_hash}", query_hash)
91
+ filename = filename.replace("{date}", date)
92
+
93
+ # Ensure correct extension
94
+ if not filename.endswith(extension):
95
+ # Remove existing extension if present
96
+ if "." in filename:
97
+ filename = filename.rsplit(".", 1)[0]
98
+ filename += extension
99
+
100
+ return filename
101
+
102
+ def save_report(
103
+ self,
104
+ report_content: str,
105
+ query: str | None = None,
106
+ filename: str | None = None,
107
+ ) -> str:
108
+ """
109
+ Save a report to a file.
110
+
111
+ Args:
112
+ report_content: The report content (markdown string)
113
+ query: Optional query string for filename generation
114
+ filename: Optional custom filename. If None, generates from template.
115
+
116
+ Returns:
117
+ Path to saved file
118
+
119
+ Raises:
120
+ ConfigurationError: If file saving is disabled or fails
121
+ """
122
+ if not self.enabled:
123
+ logger.debug("File saving disabled, skipping")
124
+ raise ConfigurationError("Report file saving is disabled")
125
+
126
+ if not report_content or not report_content.strip():
127
+ raise ValueError("Report content cannot be empty")
128
+
129
+ # Generate filename if not provided
130
+ if not filename:
131
+ filename = self._generate_filename(query=query, extension=".md")
132
+
133
+ # Ensure filename is safe
134
+ filename = self._sanitize_filename(filename)
135
+
136
+ # Build full file path
137
+ file_path = self.output_directory / filename
138
+
139
+ try:
140
+ # Write file
141
+ with open(file_path, "w", encoding="utf-8") as f:
142
+ f.write(report_content)
143
+
144
+ logger.info(
145
+ "Report saved to file",
146
+ path=str(file_path),
147
+ size=len(report_content),
148
+ query=query[:50] if query else None,
149
+ )
150
+
151
+ return str(file_path)
152
+
153
+ except Exception as e:
154
+ logger.error("Failed to save report to file", error=str(e), path=str(file_path))
155
+ raise ConfigurationError(f"Failed to save report to file: {e}") from e
156
+
157
+ def save_report_multiple_formats(
158
+ self,
159
+ report_content: str,
160
+ query: str | None = None,
161
+ ) -> dict[str, str]:
162
+ """
163
+ Save a report in multiple formats.
164
+
165
+ Args:
166
+ report_content: The report content (markdown string)
167
+ query: Optional query string for filename generation
168
+
169
+ Returns:
170
+ Dictionary mapping format to file path (e.g., {"md": "/path/to/report.md"})
171
+
172
+ Raises:
173
+ ConfigurationError: If file saving is disabled or fails
174
+ """
175
+ if not self.enabled:
176
+ logger.debug("File saving disabled, skipping")
177
+ raise ConfigurationError("Report file saving is disabled")
178
+
179
+ saved_files: dict[str, str] = {}
180
+
181
+ # Always save markdown
182
+ md_path = self.save_report(report_content, query=query, filename=None)
183
+ saved_files["md"] = md_path
184
+
185
+ # Save additional formats based on file_format setting
186
+ if self.file_format == "md_html":
187
+ # TODO: Implement HTML conversion
188
+ logger.warning("HTML format not yet implemented, saving markdown only")
189
+ elif self.file_format == "md_pdf":
190
+ # TODO: Implement PDF conversion
191
+ logger.warning("PDF format not yet implemented, saving markdown only")
192
+
193
+ return saved_files
194
+
195
+ def _sanitize_filename(self, filename: str) -> str:
196
+ """
197
+ Sanitize filename to remove unsafe characters.
198
+
199
+ Args:
200
+ filename: Original filename
201
+
202
+ Returns:
203
+ Sanitized filename
204
+ """
205
+ # Remove or replace unsafe characters
206
+ unsafe_chars = '<>:"/\\|?*'
207
+ sanitized = filename
208
+ for char in unsafe_chars:
209
+ sanitized = sanitized.replace(char, "_")
210
+
211
+ # Limit length
212
+ if len(sanitized) > 200:
213
+ name, ext = sanitized.rsplit(".", 1) if "." in sanitized else (sanitized, "")
214
+ sanitized = name[:190] + ext
215
+
216
+ return sanitized
217
+
218
+ def cleanup_old_files(self, max_age_days: int = 7) -> int:
219
+ """
220
+ Clean up old report files.
221
+
222
+ Args:
223
+ max_age_days: Maximum age in days for files to keep
224
+
225
+ Returns:
226
+ Number of files deleted
227
+ """
228
+ if not self.output_directory.exists():
229
+ return 0
230
+
231
+ deleted_count = 0
232
+ cutoff_time = datetime.now().timestamp() - (max_age_days * 24 * 60 * 60)
233
+
234
+ try:
235
+ for file_path in self.output_directory.iterdir():
236
+ if file_path.is_file() and file_path.stat().st_mtime < cutoff_time:
237
+ try:
238
+ file_path.unlink()
239
+ deleted_count += 1
240
+ except Exception as e:
241
+ logger.warning("Failed to delete old file", path=str(file_path), error=str(e))
242
+
243
+ if deleted_count > 0:
244
+ logger.info("Cleaned up old report files", deleted=deleted_count, max_age_days=max_age_days)
245
+
246
+ except Exception as e:
247
+ logger.error("Failed to cleanup old files", error=str(e))
248
+
249
+ return deleted_count
250
+
251
+
252
+ def get_report_file_service() -> ReportFileService:
253
+ """
254
+ Get or create a ReportFileService instance (singleton pattern).
255
+
256
+ Returns:
257
+ ReportFileService instance
258
+ """
259
+ # Use lru_cache for singleton pattern
260
+ from functools import lru_cache
261
+
262
+ @lru_cache(maxsize=1)
263
+ def _get_service() -> ReportFileService:
264
+ return ReportFileService()
265
+
266
+ return _get_service()
267
+
268
+
269
+
src/tools/crawl_adapter.py CHANGED
@@ -64,3 +64,5 @@ async def crawl_website(starting_url: str) -> str:
64
 
65
 
66
 
 
 
 
64
 
65
 
66
 
67
+
68
+
src/tools/searchxng_web_search.py CHANGED
@@ -118,3 +118,5 @@ class SearchXNGWebSearchTool:
118
  raise SearchError(f"SearchXNG search failed: {e}") from e
119
 
120
 
 
 
 
118
  raise SearchError(f"SearchXNG search failed: {e}") from e
119
 
120
 
121
+
122
+
src/tools/serper_web_search.py CHANGED
@@ -118,3 +118,5 @@ class SerperWebSearchTool:
118
  raise SearchError(f"Serper search failed: {e}") from e
119
 
120
 
 
 
 
118
  raise SearchError(f"Serper search failed: {e}") from e
119
 
120
 
121
+
122
+
src/tools/vendored/__init__.py CHANGED
@@ -25,3 +25,5 @@ __all__ = [
25
  ]
26
 
27
 
 
 
 
25
  ]
26
 
27
 
28
+
29
+
src/tools/vendored/searchxng_client.py CHANGED
@@ -97,3 +97,5 @@ class SearchXNGClient:
97
  raise SearchError(f"SearchXNG search failed: {e}") from e
98
 
99
 
 
 
 
97
  raise SearchError(f"SearchXNG search failed: {e}") from e
98
 
99
 
100
+
101
+
src/tools/vendored/serper_client.py CHANGED
@@ -93,3 +93,5 @@ class SerperClient:
93
  raise SearchError(f"Serper search failed: {e}") from e
94
 
95
 
 
 
 
93
  raise SearchError(f"Serper search failed: {e}") from e
94
 
95
 
96
+
97
+
src/tools/vendored/web_search_core.py CHANGED
@@ -204,3 +204,5 @@ def is_valid_url(url: str) -> bool:
204
  return True
205
 
206
 
 
 
 
204
  return True
205
 
206
 
207
+
208
+
src/tools/web_search_factory.py CHANGED
@@ -72,3 +72,5 @@ def create_web_search_tool() -> SearchTool | None:
72
  return None
73
 
74
 
 
 
 
72
  return None
73
 
74
 
75
+
76
+
src/utils/config.py CHANGED
@@ -164,6 +164,24 @@ class Settings(BaseSettings):
164
  description="Modal GPU type for TTS (T4, A10, A100, L4, L40S). None uses default T4.",
165
  )
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  @property
168
  def modal_available(self) -> bool:
169
  """Check if Modal credentials are configured."""
 
164
  description="Modal GPU type for TTS (T4, A10, A100, L4, L40S). None uses default T4.",
165
  )
166
 
167
+ # Report File Output Configuration
168
+ save_reports_to_file: bool = Field(
169
+ default=True,
170
+ description="Save generated reports to files (enables file downloads in Gradio)",
171
+ )
172
+ report_output_directory: str | None = Field(
173
+ default=None,
174
+ description="Directory to save report files. If None, uses system temp directory.",
175
+ )
176
+ report_file_format: Literal["md", "md_html", "md_pdf"] = Field(
177
+ default="md",
178
+ description="File format(s) to save reports in. 'md' saves only markdown, others save multiple formats.",
179
+ )
180
+ report_filename_template: str = Field(
181
+ default="report_{timestamp}_{query_hash}.md",
182
+ description="Template for report filenames. Supports {timestamp}, {query_hash}, {date} placeholders.",
183
+ )
184
+
185
  @property
186
  def modal_available(self) -> bool:
187
  """Check if Modal credentials are configured."""
tests/unit/middleware/__init__.py CHANGED
@@ -18,6 +18,8 @@
18
 
19
 
20
 
 
 
21
 
22
 
23
 
 
18
 
19
 
20
 
21
+
22
+
23
 
24
 
25
 
tests/unit/middleware/test_budget_tracker_phase7.py CHANGED
@@ -176,6 +176,8 @@ class TestIterationTokenTracking:
176
 
177
 
178
 
 
 
179
 
180
 
181
 
 
176
 
177
 
178
 
179
+
180
+
181
 
182
 
183
 
tests/unit/middleware/test_state_machine.py CHANGED
@@ -373,6 +373,8 @@ class TestContextVarIsolation:
373
 
374
 
375
 
 
 
376
 
377
 
378
 
 
373
 
374
 
375
 
376
+
377
+
378
 
379
 
380
 
tests/unit/middleware/test_workflow_manager.py CHANGED
@@ -303,6 +303,8 @@ class TestWorkflowManager:
303
 
304
 
305
 
 
 
306
 
307
 
308
 
 
303
 
304
 
305
 
306
+
307
+
308
 
309
 
310