Spaces:
Running
Running
Commit
·
b4f9ff5
1
Parent(s):
8e9e85e
Add TTS on-demand with UI credentials, improve UI layout, and fix References removal
Browse files- .github/scripts/deploy_to_hf_space.py +56 -55
- deployments/modal_tts.py +1 -5
- src/agent_factory/agents.py +21 -7
- src/agent_factory/graph_builder.py +5 -4
- src/agent_factory/judges.py +5 -8
- src/agents/audio_refiner.py +66 -61
- src/agents/knowledge_gap.py +3 -1
- src/agents/long_writer.py +11 -7
- src/agents/proofreader.py +3 -1
- src/agents/thinking.py +3 -1
- src/agents/tool_selector.py +3 -1
- src/agents/writer.py +3 -3
- src/app.py +332 -256
- src/mcp_tools.py +1 -3
- src/middleware/state_machine.py +0 -11
- src/orchestrator/research_flow.py +17 -5
- src/services/audio_processing.py +7 -6
- src/services/multimodal_processing.py +3 -5
- src/services/report_file_service.py +0 -1
- src/services/tts_modal.py +166 -20
- src/tools/crawl_adapter.py +0 -5
- src/tools/search_handler.py +21 -11
- src/tools/searchxng_web_search.py +1 -16
- src/tools/serper_web_search.py +1 -16
- src/tools/tool_executor.py +3 -3
- src/tools/vendored/__init__.py +4 -4
- src/tools/web_search.py +2 -1
- src/tools/web_search_adapter.py +0 -8
- src/tools/web_search_factory.py +2 -17
- src/utils/config.py +7 -5
- src/utils/llm_factory.py +1 -1
- src/utils/models.py +3 -1
.github/scripts/deploy_to_hf_space.py
CHANGED
|
@@ -5,12 +5,11 @@ import shutil
|
|
| 5 |
import subprocess
|
| 6 |
import tempfile
|
| 7 |
from pathlib import Path
|
| 8 |
-
from typing import Set
|
| 9 |
|
| 10 |
from huggingface_hub import HfApi
|
| 11 |
|
| 12 |
|
| 13 |
-
def get_excluded_dirs() ->
|
| 14 |
"""Get set of directory names to exclude from deployment."""
|
| 15 |
return {
|
| 16 |
"docs",
|
|
@@ -43,7 +42,7 @@ def get_excluded_dirs() -> Set[str]:
|
|
| 43 |
}
|
| 44 |
|
| 45 |
|
| 46 |
-
def get_excluded_files() ->
|
| 47 |
"""Get set of file names to exclude from deployment."""
|
| 48 |
return {
|
| 49 |
".pre-commit-config.yaml",
|
|
@@ -61,17 +60,17 @@ def get_excluded_files() -> Set[str]:
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
-
def should_exclude(path: Path, excluded_dirs:
|
| 65 |
"""Check if a path should be excluded from deployment."""
|
| 66 |
# Check if any parent directory is excluded
|
| 67 |
for parent in path.parents:
|
| 68 |
if parent.name in excluded_dirs:
|
| 69 |
return True
|
| 70 |
-
|
| 71 |
# Check if the path itself is a directory that should be excluded
|
| 72 |
if path.is_dir() and path.name in excluded_dirs:
|
| 73 |
return True
|
| 74 |
-
|
| 75 |
# Check if the file name matches excluded patterns
|
| 76 |
if path.is_file():
|
| 77 |
# Check exact match
|
|
@@ -84,24 +83,24 @@ def should_exclude(path: Path, excluded_dirs: Set[str], excluded_files: Set[str]
|
|
| 84 |
suffix = pattern.replace("*", "")
|
| 85 |
if path.name.endswith(suffix):
|
| 86 |
return True
|
| 87 |
-
|
| 88 |
return False
|
| 89 |
|
| 90 |
|
| 91 |
def deploy_to_hf_space() -> None:
|
| 92 |
"""Deploy repository to Hugging Face Space.
|
| 93 |
-
|
| 94 |
Supports both user and organization Spaces:
|
| 95 |
- User Space: username/space-name
|
| 96 |
- Organization Space: organization-name/space-name
|
| 97 |
-
|
| 98 |
Works with both classic tokens and fine-grained tokens.
|
| 99 |
"""
|
| 100 |
# Get configuration from environment variables
|
| 101 |
hf_token = os.getenv("HF_TOKEN")
|
| 102 |
hf_username = os.getenv("HF_USERNAME") # Can be username or organization name
|
| 103 |
space_name = os.getenv("HF_SPACE_NAME")
|
| 104 |
-
|
| 105 |
# Check which variables are missing and provide helpful error message
|
| 106 |
missing = []
|
| 107 |
if not hf_token:
|
|
@@ -110,7 +109,7 @@ def deploy_to_hf_space() -> None:
|
|
| 110 |
missing.append("HF_USERNAME (should be in repository variables)")
|
| 111 |
if not space_name:
|
| 112 |
missing.append("HF_SPACE_NAME (should be in repository variables)")
|
| 113 |
-
|
| 114 |
if missing:
|
| 115 |
raise ValueError(
|
| 116 |
f"Missing required environment variables: {', '.join(missing)}\n"
|
|
@@ -119,17 +118,17 @@ def deploy_to_hf_space() -> None:
|
|
| 119 |
f" - HF_USERNAME in Settings > Secrets and variables > Actions > Variables\n"
|
| 120 |
f" - HF_SPACE_NAME in Settings > Secrets and variables > Actions > Variables"
|
| 121 |
)
|
| 122 |
-
|
| 123 |
# HF_USERNAME can be either a username or organization name
|
| 124 |
# Format: {username|organization}/{space_name}
|
| 125 |
repo_id = f"{hf_username}/{space_name}"
|
| 126 |
local_dir = "hf_space"
|
| 127 |
-
|
| 128 |
print(f"🚀 Deploying to Hugging Face Space: {repo_id}")
|
| 129 |
-
|
| 130 |
# Initialize HF API
|
| 131 |
api = HfApi(token=hf_token)
|
| 132 |
-
|
| 133 |
# Create Space if it doesn't exist
|
| 134 |
try:
|
| 135 |
api.repo_info(repo_id=repo_id, repo_type="space", token=hf_token)
|
|
@@ -147,43 +146,45 @@ def deploy_to_hf_space() -> None:
|
|
| 147 |
exist_ok=True,
|
| 148 |
)
|
| 149 |
print(f"✅ Created new Space: {repo_id}")
|
| 150 |
-
|
| 151 |
# Configure Git credential helper for authentication
|
| 152 |
# This is needed for Git LFS to work properly with fine-grained tokens
|
| 153 |
print("🔐 Configuring Git credentials...")
|
| 154 |
-
|
| 155 |
# Use Git credential store to store the token
|
| 156 |
# This allows Git LFS to authenticate properly
|
| 157 |
temp_dir = Path(tempfile.gettempdir())
|
| 158 |
credential_store = temp_dir / ".git-credentials-hf"
|
| 159 |
-
|
| 160 |
# Write credentials in the format: https://username:[email protected]
|
| 161 |
-
credential_store.write_text(
|
|
|
|
|
|
|
| 162 |
try:
|
| 163 |
credential_store.chmod(0o600) # Secure permissions (Unix only)
|
| 164 |
except OSError:
|
| 165 |
# Windows doesn't support chmod, skip
|
| 166 |
pass
|
| 167 |
-
|
| 168 |
# Configure Git to use the credential store
|
| 169 |
subprocess.run(
|
| 170 |
["git", "config", "--global", "credential.helper", f"store --file={credential_store}"],
|
| 171 |
check=True,
|
| 172 |
capture_output=True,
|
| 173 |
)
|
| 174 |
-
|
| 175 |
# Also set environment variable for Git LFS
|
| 176 |
os.environ["GIT_CREDENTIAL_HELPER"] = f"store --file={credential_store}"
|
| 177 |
-
|
| 178 |
# Clone repository using git
|
| 179 |
# Use the token in the URL for initial clone, but LFS will use credential store
|
| 180 |
space_url = f"https://{hf_username}:{hf_token}@huggingface.co/spaces/{repo_id}"
|
| 181 |
-
|
| 182 |
if Path(local_dir).exists():
|
| 183 |
print(f"🧹 Removing existing {local_dir} directory...")
|
| 184 |
shutil.rmtree(local_dir)
|
| 185 |
-
|
| 186 |
-
print(
|
| 187 |
try:
|
| 188 |
result = subprocess.run(
|
| 189 |
["git", "clone", space_url, local_dir],
|
|
@@ -191,8 +192,8 @@ def deploy_to_hf_space() -> None:
|
|
| 191 |
capture_output=True,
|
| 192 |
text=True,
|
| 193 |
)
|
| 194 |
-
print(
|
| 195 |
-
|
| 196 |
# After clone, configure the remote to use credential helper
|
| 197 |
# This ensures future operations (like push) use the credential store
|
| 198 |
os.chdir(local_dir)
|
|
@@ -202,17 +203,17 @@ def deploy_to_hf_space() -> None:
|
|
| 202 |
capture_output=True,
|
| 203 |
)
|
| 204 |
os.chdir("..")
|
| 205 |
-
|
| 206 |
except subprocess.CalledProcessError as e:
|
| 207 |
error_msg = e.stderr if e.stderr else e.stdout if e.stdout else "Unknown error"
|
| 208 |
print(f"❌ Failed to clone Space repository: {error_msg}")
|
| 209 |
-
|
| 210 |
# Try alternative: clone with LFS skip, then fetch LFS files separately
|
| 211 |
print("🔄 Trying alternative clone method (skip LFS during clone)...")
|
| 212 |
try:
|
| 213 |
env = os.environ.copy()
|
| 214 |
env["GIT_LFS_SKIP_SMUDGE"] = "1" # Skip LFS during clone
|
| 215 |
-
|
| 216 |
subprocess.run(
|
| 217 |
["git", "clone", space_url, local_dir],
|
| 218 |
check=True,
|
|
@@ -220,8 +221,8 @@ def deploy_to_hf_space() -> None:
|
|
| 220 |
text=True,
|
| 221 |
env=env,
|
| 222 |
)
|
| 223 |
-
print(
|
| 224 |
-
|
| 225 |
# Configure remote
|
| 226 |
os.chdir(local_dir)
|
| 227 |
subprocess.run(
|
|
@@ -229,7 +230,7 @@ def deploy_to_hf_space() -> None:
|
|
| 229 |
check=True,
|
| 230 |
capture_output=True,
|
| 231 |
)
|
| 232 |
-
|
| 233 |
# Try to fetch LFS files with proper authentication
|
| 234 |
print("📥 Fetching LFS files...")
|
| 235 |
subprocess.run(
|
|
@@ -239,16 +240,16 @@ def deploy_to_hf_space() -> None:
|
|
| 239 |
text=True,
|
| 240 |
)
|
| 241 |
os.chdir("..")
|
| 242 |
-
print(
|
| 243 |
except subprocess.CalledProcessError as e2:
|
| 244 |
error_msg2 = e2.stderr if e2.stderr else e2.stdout if e2.stdout else "Unknown error"
|
| 245 |
print(f"❌ Alternative clone method also failed: {error_msg2}")
|
| 246 |
raise RuntimeError(f"Git clone failed: {error_msg}") from e
|
| 247 |
-
|
| 248 |
# Get exclusion sets
|
| 249 |
excluded_dirs = get_excluded_dirs()
|
| 250 |
excluded_files = get_excluded_files()
|
| 251 |
-
|
| 252 |
# Remove all existing files in HF Space (except .git)
|
| 253 |
print("🧹 Cleaning existing files...")
|
| 254 |
for item in Path(local_dir).iterdir():
|
|
@@ -258,43 +259,43 @@ def deploy_to_hf_space() -> None:
|
|
| 258 |
shutil.rmtree(item)
|
| 259 |
else:
|
| 260 |
item.unlink()
|
| 261 |
-
|
| 262 |
# Copy files from repository root
|
| 263 |
print("📦 Copying files...")
|
| 264 |
repo_root = Path(".")
|
| 265 |
files_copied = 0
|
| 266 |
dirs_copied = 0
|
| 267 |
-
|
| 268 |
for item in repo_root.rglob("*"):
|
| 269 |
# Skip if in .git directory
|
| 270 |
if ".git" in item.parts:
|
| 271 |
continue
|
| 272 |
-
|
| 273 |
# Skip if in hf_space directory (the cloned Space directory)
|
| 274 |
if "hf_space" in item.parts:
|
| 275 |
continue
|
| 276 |
-
|
| 277 |
# Skip if should be excluded
|
| 278 |
if should_exclude(item, excluded_dirs, excluded_files):
|
| 279 |
continue
|
| 280 |
-
|
| 281 |
# Calculate relative path
|
| 282 |
try:
|
| 283 |
rel_path = item.relative_to(repo_root)
|
| 284 |
except ValueError:
|
| 285 |
# Item is outside repo root, skip
|
| 286 |
continue
|
| 287 |
-
|
| 288 |
# Skip if in excluded directory
|
| 289 |
if any(part in excluded_dirs for part in rel_path.parts):
|
| 290 |
continue
|
| 291 |
-
|
| 292 |
# Destination path
|
| 293 |
dest_path = Path(local_dir) / rel_path
|
| 294 |
-
|
| 295 |
# Create parent directories
|
| 296 |
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 297 |
-
|
| 298 |
# Copy file or directory
|
| 299 |
if item.is_file():
|
| 300 |
shutil.copy2(item, dest_path)
|
|
@@ -302,16 +303,16 @@ def deploy_to_hf_space() -> None:
|
|
| 302 |
elif item.is_dir():
|
| 303 |
# Directory will be created by parent mkdir, but we track it
|
| 304 |
dirs_copied += 1
|
| 305 |
-
|
| 306 |
print(f"✅ Copied {files_copied} files and {dirs_copied} directories")
|
| 307 |
-
|
| 308 |
# Commit and push changes using git
|
| 309 |
print("💾 Committing changes...")
|
| 310 |
-
|
| 311 |
# Change to the Space directory
|
| 312 |
original_cwd = os.getcwd()
|
| 313 |
os.chdir(local_dir)
|
| 314 |
-
|
| 315 |
try:
|
| 316 |
# Configure git user (required for commit)
|
| 317 |
subprocess.run(
|
|
@@ -324,21 +325,22 @@ def deploy_to_hf_space() -> None:
|
|
| 324 |
check=True,
|
| 325 |
capture_output=True,
|
| 326 |
)
|
| 327 |
-
|
| 328 |
# Add all files
|
| 329 |
subprocess.run(
|
| 330 |
["git", "add", "."],
|
| 331 |
check=True,
|
| 332 |
capture_output=True,
|
| 333 |
)
|
| 334 |
-
|
| 335 |
# Check if there are changes to commit
|
| 336 |
result = subprocess.run(
|
| 337 |
["git", "status", "--porcelain"],
|
|
|
|
| 338 |
capture_output=True,
|
| 339 |
text=True,
|
| 340 |
)
|
| 341 |
-
|
| 342 |
if result.stdout.strip():
|
| 343 |
# There are changes, commit and push
|
| 344 |
subprocess.run(
|
|
@@ -373,7 +375,7 @@ def deploy_to_hf_space() -> None:
|
|
| 373 |
finally:
|
| 374 |
# Return to original directory
|
| 375 |
os.chdir(original_cwd)
|
| 376 |
-
|
| 377 |
# Clean up credential store for security
|
| 378 |
try:
|
| 379 |
if credential_store.exists():
|
|
@@ -381,10 +383,9 @@ def deploy_to_hf_space() -> None:
|
|
| 381 |
except Exception:
|
| 382 |
# Ignore cleanup errors
|
| 383 |
pass
|
| 384 |
-
|
| 385 |
print(f"🎉 Successfully deployed to: https://huggingface.co/spaces/{repo_id}")
|
| 386 |
|
| 387 |
|
| 388 |
if __name__ == "__main__":
|
| 389 |
deploy_to_hf_space()
|
| 390 |
-
|
|
|
|
| 5 |
import subprocess
|
| 6 |
import tempfile
|
| 7 |
from pathlib import Path
|
|
|
|
| 8 |
|
| 9 |
from huggingface_hub import HfApi
|
| 10 |
|
| 11 |
|
| 12 |
+
def get_excluded_dirs() -> set[str]:
|
| 13 |
"""Get set of directory names to exclude from deployment."""
|
| 14 |
return {
|
| 15 |
"docs",
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
|
| 45 |
+
def get_excluded_files() -> set[str]:
|
| 46 |
"""Get set of file names to exclude from deployment."""
|
| 47 |
return {
|
| 48 |
".pre-commit-config.yaml",
|
|
|
|
| 60 |
}
|
| 61 |
|
| 62 |
|
| 63 |
+
def should_exclude(path: Path, excluded_dirs: set[str], excluded_files: set[str]) -> bool:
|
| 64 |
"""Check if a path should be excluded from deployment."""
|
| 65 |
# Check if any parent directory is excluded
|
| 66 |
for parent in path.parents:
|
| 67 |
if parent.name in excluded_dirs:
|
| 68 |
return True
|
| 69 |
+
|
| 70 |
# Check if the path itself is a directory that should be excluded
|
| 71 |
if path.is_dir() and path.name in excluded_dirs:
|
| 72 |
return True
|
| 73 |
+
|
| 74 |
# Check if the file name matches excluded patterns
|
| 75 |
if path.is_file():
|
| 76 |
# Check exact match
|
|
|
|
| 83 |
suffix = pattern.replace("*", "")
|
| 84 |
if path.name.endswith(suffix):
|
| 85 |
return True
|
| 86 |
+
|
| 87 |
return False
|
| 88 |
|
| 89 |
|
| 90 |
def deploy_to_hf_space() -> None:
|
| 91 |
"""Deploy repository to Hugging Face Space.
|
| 92 |
+
|
| 93 |
Supports both user and organization Spaces:
|
| 94 |
- User Space: username/space-name
|
| 95 |
- Organization Space: organization-name/space-name
|
| 96 |
+
|
| 97 |
Works with both classic tokens and fine-grained tokens.
|
| 98 |
"""
|
| 99 |
# Get configuration from environment variables
|
| 100 |
hf_token = os.getenv("HF_TOKEN")
|
| 101 |
hf_username = os.getenv("HF_USERNAME") # Can be username or organization name
|
| 102 |
space_name = os.getenv("HF_SPACE_NAME")
|
| 103 |
+
|
| 104 |
# Check which variables are missing and provide helpful error message
|
| 105 |
missing = []
|
| 106 |
if not hf_token:
|
|
|
|
| 109 |
missing.append("HF_USERNAME (should be in repository variables)")
|
| 110 |
if not space_name:
|
| 111 |
missing.append("HF_SPACE_NAME (should be in repository variables)")
|
| 112 |
+
|
| 113 |
if missing:
|
| 114 |
raise ValueError(
|
| 115 |
f"Missing required environment variables: {', '.join(missing)}\n"
|
|
|
|
| 118 |
f" - HF_USERNAME in Settings > Secrets and variables > Actions > Variables\n"
|
| 119 |
f" - HF_SPACE_NAME in Settings > Secrets and variables > Actions > Variables"
|
| 120 |
)
|
| 121 |
+
|
| 122 |
# HF_USERNAME can be either a username or organization name
|
| 123 |
# Format: {username|organization}/{space_name}
|
| 124 |
repo_id = f"{hf_username}/{space_name}"
|
| 125 |
local_dir = "hf_space"
|
| 126 |
+
|
| 127 |
print(f"🚀 Deploying to Hugging Face Space: {repo_id}")
|
| 128 |
+
|
| 129 |
# Initialize HF API
|
| 130 |
api = HfApi(token=hf_token)
|
| 131 |
+
|
| 132 |
# Create Space if it doesn't exist
|
| 133 |
try:
|
| 134 |
api.repo_info(repo_id=repo_id, repo_type="space", token=hf_token)
|
|
|
|
| 146 |
exist_ok=True,
|
| 147 |
)
|
| 148 |
print(f"✅ Created new Space: {repo_id}")
|
| 149 |
+
|
| 150 |
# Configure Git credential helper for authentication
|
| 151 |
# This is needed for Git LFS to work properly with fine-grained tokens
|
| 152 |
print("🔐 Configuring Git credentials...")
|
| 153 |
+
|
| 154 |
# Use Git credential store to store the token
|
| 155 |
# This allows Git LFS to authenticate properly
|
| 156 |
temp_dir = Path(tempfile.gettempdir())
|
| 157 |
credential_store = temp_dir / ".git-credentials-hf"
|
| 158 |
+
|
| 159 |
# Write credentials in the format: https://username:[email protected]
|
| 160 |
+
credential_store.write_text(
|
| 161 |
+
f"https://{hf_username}:{hf_token}@huggingface.co\n", encoding="utf-8"
|
| 162 |
+
)
|
| 163 |
try:
|
| 164 |
credential_store.chmod(0o600) # Secure permissions (Unix only)
|
| 165 |
except OSError:
|
| 166 |
# Windows doesn't support chmod, skip
|
| 167 |
pass
|
| 168 |
+
|
| 169 |
# Configure Git to use the credential store
|
| 170 |
subprocess.run(
|
| 171 |
["git", "config", "--global", "credential.helper", f"store --file={credential_store}"],
|
| 172 |
check=True,
|
| 173 |
capture_output=True,
|
| 174 |
)
|
| 175 |
+
|
| 176 |
# Also set environment variable for Git LFS
|
| 177 |
os.environ["GIT_CREDENTIAL_HELPER"] = f"store --file={credential_store}"
|
| 178 |
+
|
| 179 |
# Clone repository using git
|
| 180 |
# Use the token in the URL for initial clone, but LFS will use credential store
|
| 181 |
space_url = f"https://{hf_username}:{hf_token}@huggingface.co/spaces/{repo_id}"
|
| 182 |
+
|
| 183 |
if Path(local_dir).exists():
|
| 184 |
print(f"🧹 Removing existing {local_dir} directory...")
|
| 185 |
shutil.rmtree(local_dir)
|
| 186 |
+
|
| 187 |
+
print("📥 Cloning Space repository...")
|
| 188 |
try:
|
| 189 |
result = subprocess.run(
|
| 190 |
["git", "clone", space_url, local_dir],
|
|
|
|
| 192 |
capture_output=True,
|
| 193 |
text=True,
|
| 194 |
)
|
| 195 |
+
print("✅ Cloned Space repository")
|
| 196 |
+
|
| 197 |
# After clone, configure the remote to use credential helper
|
| 198 |
# This ensures future operations (like push) use the credential store
|
| 199 |
os.chdir(local_dir)
|
|
|
|
| 203 |
capture_output=True,
|
| 204 |
)
|
| 205 |
os.chdir("..")
|
| 206 |
+
|
| 207 |
except subprocess.CalledProcessError as e:
|
| 208 |
error_msg = e.stderr if e.stderr else e.stdout if e.stdout else "Unknown error"
|
| 209 |
print(f"❌ Failed to clone Space repository: {error_msg}")
|
| 210 |
+
|
| 211 |
# Try alternative: clone with LFS skip, then fetch LFS files separately
|
| 212 |
print("🔄 Trying alternative clone method (skip LFS during clone)...")
|
| 213 |
try:
|
| 214 |
env = os.environ.copy()
|
| 215 |
env["GIT_LFS_SKIP_SMUDGE"] = "1" # Skip LFS during clone
|
| 216 |
+
|
| 217 |
subprocess.run(
|
| 218 |
["git", "clone", space_url, local_dir],
|
| 219 |
check=True,
|
|
|
|
| 221 |
text=True,
|
| 222 |
env=env,
|
| 223 |
)
|
| 224 |
+
print("✅ Cloned Space repository (LFS skipped)")
|
| 225 |
+
|
| 226 |
# Configure remote
|
| 227 |
os.chdir(local_dir)
|
| 228 |
subprocess.run(
|
|
|
|
| 230 |
check=True,
|
| 231 |
capture_output=True,
|
| 232 |
)
|
| 233 |
+
|
| 234 |
# Try to fetch LFS files with proper authentication
|
| 235 |
print("📥 Fetching LFS files...")
|
| 236 |
subprocess.run(
|
|
|
|
| 240 |
text=True,
|
| 241 |
)
|
| 242 |
os.chdir("..")
|
| 243 |
+
print("✅ Repository cloned (LFS files may be incomplete, but deployment can continue)")
|
| 244 |
except subprocess.CalledProcessError as e2:
|
| 245 |
error_msg2 = e2.stderr if e2.stderr else e2.stdout if e2.stdout else "Unknown error"
|
| 246 |
print(f"❌ Alternative clone method also failed: {error_msg2}")
|
| 247 |
raise RuntimeError(f"Git clone failed: {error_msg}") from e
|
| 248 |
+
|
| 249 |
# Get exclusion sets
|
| 250 |
excluded_dirs = get_excluded_dirs()
|
| 251 |
excluded_files = get_excluded_files()
|
| 252 |
+
|
| 253 |
# Remove all existing files in HF Space (except .git)
|
| 254 |
print("🧹 Cleaning existing files...")
|
| 255 |
for item in Path(local_dir).iterdir():
|
|
|
|
| 259 |
shutil.rmtree(item)
|
| 260 |
else:
|
| 261 |
item.unlink()
|
| 262 |
+
|
| 263 |
# Copy files from repository root
|
| 264 |
print("📦 Copying files...")
|
| 265 |
repo_root = Path(".")
|
| 266 |
files_copied = 0
|
| 267 |
dirs_copied = 0
|
| 268 |
+
|
| 269 |
for item in repo_root.rglob("*"):
|
| 270 |
# Skip if in .git directory
|
| 271 |
if ".git" in item.parts:
|
| 272 |
continue
|
| 273 |
+
|
| 274 |
# Skip if in hf_space directory (the cloned Space directory)
|
| 275 |
if "hf_space" in item.parts:
|
| 276 |
continue
|
| 277 |
+
|
| 278 |
# Skip if should be excluded
|
| 279 |
if should_exclude(item, excluded_dirs, excluded_files):
|
| 280 |
continue
|
| 281 |
+
|
| 282 |
# Calculate relative path
|
| 283 |
try:
|
| 284 |
rel_path = item.relative_to(repo_root)
|
| 285 |
except ValueError:
|
| 286 |
# Item is outside repo root, skip
|
| 287 |
continue
|
| 288 |
+
|
| 289 |
# Skip if in excluded directory
|
| 290 |
if any(part in excluded_dirs for part in rel_path.parts):
|
| 291 |
continue
|
| 292 |
+
|
| 293 |
# Destination path
|
| 294 |
dest_path = Path(local_dir) / rel_path
|
| 295 |
+
|
| 296 |
# Create parent directories
|
| 297 |
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
| 298 |
+
|
| 299 |
# Copy file or directory
|
| 300 |
if item.is_file():
|
| 301 |
shutil.copy2(item, dest_path)
|
|
|
|
| 303 |
elif item.is_dir():
|
| 304 |
# Directory will be created by parent mkdir, but we track it
|
| 305 |
dirs_copied += 1
|
| 306 |
+
|
| 307 |
print(f"✅ Copied {files_copied} files and {dirs_copied} directories")
|
| 308 |
+
|
| 309 |
# Commit and push changes using git
|
| 310 |
print("💾 Committing changes...")
|
| 311 |
+
|
| 312 |
# Change to the Space directory
|
| 313 |
original_cwd = os.getcwd()
|
| 314 |
os.chdir(local_dir)
|
| 315 |
+
|
| 316 |
try:
|
| 317 |
# Configure git user (required for commit)
|
| 318 |
subprocess.run(
|
|
|
|
| 325 |
check=True,
|
| 326 |
capture_output=True,
|
| 327 |
)
|
| 328 |
+
|
| 329 |
# Add all files
|
| 330 |
subprocess.run(
|
| 331 |
["git", "add", "."],
|
| 332 |
check=True,
|
| 333 |
capture_output=True,
|
| 334 |
)
|
| 335 |
+
|
| 336 |
# Check if there are changes to commit
|
| 337 |
result = subprocess.run(
|
| 338 |
["git", "status", "--porcelain"],
|
| 339 |
+
check=False,
|
| 340 |
capture_output=True,
|
| 341 |
text=True,
|
| 342 |
)
|
| 343 |
+
|
| 344 |
if result.stdout.strip():
|
| 345 |
# There are changes, commit and push
|
| 346 |
subprocess.run(
|
|
|
|
| 375 |
finally:
|
| 376 |
# Return to original directory
|
| 377 |
os.chdir(original_cwd)
|
| 378 |
+
|
| 379 |
# Clean up credential store for security
|
| 380 |
try:
|
| 381 |
if credential_store.exists():
|
|
|
|
| 383 |
except Exception:
|
| 384 |
# Ignore cleanup errors
|
| 385 |
pass
|
| 386 |
+
|
| 387 |
print(f"🎉 Successfully deployed to: https://huggingface.co/spaces/{repo_id}")
|
| 388 |
|
| 389 |
|
| 390 |
if __name__ == "__main__":
|
| 391 |
deploy_to_hf_space()
|
|
|
deployments/modal_tts.py
CHANGED
|
@@ -92,10 +92,6 @@ def kokoro_tts_function(text: str, voice: str, speed: float) -> tuple[int, np.nd
|
|
| 92 |
def test():
|
| 93 |
"""Test the TTS function."""
|
| 94 |
print("Testing Modal TTS function...")
|
| 95 |
-
sample_rate, audio = kokoro_tts_function.remote(
|
| 96 |
-
"Hello, this is a test.",
|
| 97 |
-
"af_heart",
|
| 98 |
-
1.0
|
| 99 |
-
)
|
| 100 |
print(f"Generated audio: {sample_rate}Hz, shape={audio.shape}")
|
| 101 |
print("✓ TTS function works!")
|
|
|
|
| 92 |
def test():
|
| 93 |
"""Test the TTS function."""
|
| 94 |
print("Testing Modal TTS function...")
|
| 95 |
+
sample_rate, audio = kokoro_tts_function.remote("Hello, this is a test.", "af_heart", 1.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
print(f"Generated audio: {sample_rate}Hz, shape={audio.shape}")
|
| 97 |
print("✓ TTS function works!")
|
src/agent_factory/agents.py
CHANGED
|
@@ -27,7 +27,9 @@ if TYPE_CHECKING:
|
|
| 27 |
logger = structlog.get_logger()
|
| 28 |
|
| 29 |
|
| 30 |
-
def create_input_parser_agent(
|
|
|
|
|
|
|
| 31 |
"""
|
| 32 |
Create input parser agent for query analysis and research mode detection.
|
| 33 |
|
|
@@ -51,7 +53,9 @@ def create_input_parser_agent(model: Any | None = None, oauth_token: str | None
|
|
| 51 |
raise ConfigurationError(f"Failed to create input parser agent: {e}") from e
|
| 52 |
|
| 53 |
|
| 54 |
-
def create_planner_agent(
|
|
|
|
|
|
|
| 55 |
"""
|
| 56 |
Create planner agent with web search and crawl tools.
|
| 57 |
|
|
@@ -76,7 +80,9 @@ def create_planner_agent(model: Any | None = None, oauth_token: str | None = Non
|
|
| 76 |
raise ConfigurationError(f"Failed to create planner agent: {e}") from e
|
| 77 |
|
| 78 |
|
| 79 |
-
def create_knowledge_gap_agent(
|
|
|
|
|
|
|
| 80 |
"""
|
| 81 |
Create knowledge gap agent for evaluating research completeness.
|
| 82 |
|
|
@@ -100,7 +106,9 @@ def create_knowledge_gap_agent(model: Any | None = None, oauth_token: str | None
|
|
| 100 |
raise ConfigurationError(f"Failed to create knowledge gap agent: {e}") from e
|
| 101 |
|
| 102 |
|
| 103 |
-
def create_tool_selector_agent(
|
|
|
|
|
|
|
| 104 |
"""
|
| 105 |
Create tool selector agent for choosing tools to address gaps.
|
| 106 |
|
|
@@ -124,7 +132,9 @@ def create_tool_selector_agent(model: Any | None = None, oauth_token: str | None
|
|
| 124 |
raise ConfigurationError(f"Failed to create tool selector agent: {e}") from e
|
| 125 |
|
| 126 |
|
| 127 |
-
def create_thinking_agent(
|
|
|
|
|
|
|
| 128 |
"""
|
| 129 |
Create thinking agent for generating observations.
|
| 130 |
|
|
@@ -172,7 +182,9 @@ def create_writer_agent(model: Any | None = None, oauth_token: str | None = None
|
|
| 172 |
raise ConfigurationError(f"Failed to create writer agent: {e}") from e
|
| 173 |
|
| 174 |
|
| 175 |
-
def create_long_writer_agent(
|
|
|
|
|
|
|
| 176 |
"""
|
| 177 |
Create long writer agent for iteratively writing report sections.
|
| 178 |
|
|
@@ -196,7 +208,9 @@ def create_long_writer_agent(model: Any | None = None, oauth_token: str | None =
|
|
| 196 |
raise ConfigurationError(f"Failed to create long writer agent: {e}") from e
|
| 197 |
|
| 198 |
|
| 199 |
-
def create_proofreader_agent(
|
|
|
|
|
|
|
| 200 |
"""
|
| 201 |
Create proofreader agent for finalizing report drafts.
|
| 202 |
|
|
|
|
| 27 |
logger = structlog.get_logger()
|
| 28 |
|
| 29 |
|
| 30 |
+
def create_input_parser_agent(
|
| 31 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 32 |
+
) -> "InputParserAgent":
|
| 33 |
"""
|
| 34 |
Create input parser agent for query analysis and research mode detection.
|
| 35 |
|
|
|
|
| 53 |
raise ConfigurationError(f"Failed to create input parser agent: {e}") from e
|
| 54 |
|
| 55 |
|
| 56 |
+
def create_planner_agent(
|
| 57 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 58 |
+
) -> "PlannerAgent":
|
| 59 |
"""
|
| 60 |
Create planner agent with web search and crawl tools.
|
| 61 |
|
|
|
|
| 80 |
raise ConfigurationError(f"Failed to create planner agent: {e}") from e
|
| 81 |
|
| 82 |
|
| 83 |
+
def create_knowledge_gap_agent(
|
| 84 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 85 |
+
) -> "KnowledgeGapAgent":
|
| 86 |
"""
|
| 87 |
Create knowledge gap agent for evaluating research completeness.
|
| 88 |
|
|
|
|
| 106 |
raise ConfigurationError(f"Failed to create knowledge gap agent: {e}") from e
|
| 107 |
|
| 108 |
|
| 109 |
+
def create_tool_selector_agent(
|
| 110 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 111 |
+
) -> "ToolSelectorAgent":
|
| 112 |
"""
|
| 113 |
Create tool selector agent for choosing tools to address gaps.
|
| 114 |
|
|
|
|
| 132 |
raise ConfigurationError(f"Failed to create tool selector agent: {e}") from e
|
| 133 |
|
| 134 |
|
| 135 |
+
def create_thinking_agent(
|
| 136 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 137 |
+
) -> "ThinkingAgent":
|
| 138 |
"""
|
| 139 |
Create thinking agent for generating observations.
|
| 140 |
|
|
|
|
| 182 |
raise ConfigurationError(f"Failed to create writer agent: {e}") from e
|
| 183 |
|
| 184 |
|
| 185 |
+
def create_long_writer_agent(
|
| 186 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 187 |
+
) -> "LongWriterAgent":
|
| 188 |
"""
|
| 189 |
Create long writer agent for iteratively writing report sections.
|
| 190 |
|
|
|
|
| 208 |
raise ConfigurationError(f"Failed to create long writer agent: {e}") from e
|
| 209 |
|
| 210 |
|
| 211 |
+
def create_proofreader_agent(
|
| 212 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 213 |
+
) -> "ProofreaderAgent":
|
| 214 |
"""
|
| 215 |
Create proofreader agent for finalizing report drafts.
|
| 216 |
|
src/agent_factory/graph_builder.py
CHANGED
|
@@ -487,12 +487,13 @@ def create_iterative_graph(
|
|
| 487 |
# Add nodes
|
| 488 |
builder.add_agent_node("thinking", thinking_agent, "Generate observations")
|
| 489 |
builder.add_agent_node("knowledge_gap", knowledge_gap_agent, "Evaluate knowledge gaps")
|
|
|
|
| 490 |
def _decision_function(result: Any) -> str:
|
| 491 |
"""Decision function for continue_decision node.
|
| 492 |
-
|
| 493 |
Args:
|
| 494 |
result: Result from knowledge_gap node (KnowledgeGapOutput or tuple)
|
| 495 |
-
|
| 496 |
Returns:
|
| 497 |
Next node ID: "writer" if research complete, "tool_selector" otherwise
|
| 498 |
"""
|
|
@@ -510,11 +511,11 @@ def create_iterative_graph(
|
|
| 510 |
return "writer" if item["research_complete"] else "tool_selector"
|
| 511 |
# Default to continuing research if we can't determine
|
| 512 |
return "tool_selector"
|
| 513 |
-
|
| 514 |
# Normal case: result is KnowledgeGapOutput object
|
| 515 |
research_complete = getattr(result, "research_complete", False)
|
| 516 |
return "writer" if research_complete else "tool_selector"
|
| 517 |
-
|
| 518 |
builder.add_decision_node(
|
| 519 |
"continue_decision",
|
| 520 |
decision_function=_decision_function,
|
|
|
|
| 487 |
# Add nodes
|
| 488 |
builder.add_agent_node("thinking", thinking_agent, "Generate observations")
|
| 489 |
builder.add_agent_node("knowledge_gap", knowledge_gap_agent, "Evaluate knowledge gaps")
|
| 490 |
+
|
| 491 |
def _decision_function(result: Any) -> str:
|
| 492 |
"""Decision function for continue_decision node.
|
| 493 |
+
|
| 494 |
Args:
|
| 495 |
result: Result from knowledge_gap node (KnowledgeGapOutput or tuple)
|
| 496 |
+
|
| 497 |
Returns:
|
| 498 |
Next node ID: "writer" if research complete, "tool_selector" otherwise
|
| 499 |
"""
|
|
|
|
| 511 |
return "writer" if item["research_complete"] else "tool_selector"
|
| 512 |
# Default to continuing research if we can't determine
|
| 513 |
return "tool_selector"
|
| 514 |
+
|
| 515 |
# Normal case: result is KnowledgeGapOutput object
|
| 516 |
research_complete = getattr(result, "research_complete", False)
|
| 517 |
return "writer" if research_complete else "tool_selector"
|
| 518 |
+
|
| 519 |
builder.add_decision_node(
|
| 520 |
"continue_decision",
|
| 521 |
decision_function=_decision_function,
|
src/agent_factory/judges.py
CHANGED
|
@@ -37,7 +37,7 @@ def get_model(oauth_token: str | None = None) -> Any:
|
|
| 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 |
|
|
@@ -175,9 +175,8 @@ class JudgeHandler:
|
|
| 175 |
from src.utils.hf_error_handler import (
|
| 176 |
extract_error_details,
|
| 177 |
get_user_friendly_error_message,
|
| 178 |
-
should_retry_with_fallback,
|
| 179 |
)
|
| 180 |
-
|
| 181 |
error_details = extract_error_details(e)
|
| 182 |
logger.error(
|
| 183 |
"Assessment failed",
|
|
@@ -187,12 +186,12 @@ class JudgeHandler:
|
|
| 187 |
is_auth_error=error_details.get("is_auth_error"),
|
| 188 |
is_model_error=error_details.get("is_model_error"),
|
| 189 |
)
|
| 190 |
-
|
| 191 |
# Log user-friendly message for debugging
|
| 192 |
if error_details.get("is_auth_error") or error_details.get("is_model_error"):
|
| 193 |
user_msg = get_user_friendly_error_message(e, error_details.get("model_name"))
|
| 194 |
logger.warning("API error details", user_message=user_msg[:200])
|
| 195 |
-
|
| 196 |
# Return a safe default assessment on failure
|
| 197 |
return self._create_fallback_assessment(question, str(e))
|
| 198 |
|
|
@@ -244,9 +243,7 @@ class HFInferenceJudgeHandler:
|
|
| 244 |
"HuggingFaceH4/zephyr-7b-beta", # Fallback (Ungated)
|
| 245 |
]
|
| 246 |
|
| 247 |
-
def __init__(
|
| 248 |
-
self, model_id: str | None = None, api_key: str | None = None
|
| 249 |
-
) -> None:
|
| 250 |
"""
|
| 251 |
Initialize with HF Inference client.
|
| 252 |
|
|
|
|
| 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 |
|
|
|
|
| 175 |
from src.utils.hf_error_handler import (
|
| 176 |
extract_error_details,
|
| 177 |
get_user_friendly_error_message,
|
|
|
|
| 178 |
)
|
| 179 |
+
|
| 180 |
error_details = extract_error_details(e)
|
| 181 |
logger.error(
|
| 182 |
"Assessment failed",
|
|
|
|
| 186 |
is_auth_error=error_details.get("is_auth_error"),
|
| 187 |
is_model_error=error_details.get("is_model_error"),
|
| 188 |
)
|
| 189 |
+
|
| 190 |
# Log user-friendly message for debugging
|
| 191 |
if error_details.get("is_auth_error") or error_details.get("is_model_error"):
|
| 192 |
user_msg = get_user_friendly_error_message(e, error_details.get("model_name"))
|
| 193 |
logger.warning("API error details", user_message=user_msg[:200])
|
| 194 |
+
|
| 195 |
# Return a safe default assessment on failure
|
| 196 |
return self._create_fallback_assessment(question, str(e))
|
| 197 |
|
|
|
|
| 243 |
"HuggingFaceH4/zephyr-7b-beta", # Fallback (Ungated)
|
| 244 |
]
|
| 245 |
|
| 246 |
+
def __init__(self, model_id: str | None = None, api_key: str | None = None) -> None:
|
|
|
|
|
|
|
| 247 |
"""
|
| 248 |
Initialize with HF Inference client.
|
| 249 |
|
src/agents/audio_refiner.py
CHANGED
|
@@ -5,7 +5,6 @@ audio-friendly plain text suitable for text-to-speech synthesis.
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import re
|
| 8 |
-
from typing import Optional
|
| 9 |
|
| 10 |
import structlog
|
| 11 |
from pydantic_ai import Agent
|
|
@@ -27,18 +26,30 @@ class AudioRefiner:
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
# Roman numeral to integer mapping
|
| 30 |
-
ROMAN_VALUES = {
|
| 31 |
-
'I': 1, 'V': 5, 'X': 10, 'L': 50,
|
| 32 |
-
'C': 100, 'D': 500, 'M': 1000
|
| 33 |
-
}
|
| 34 |
|
| 35 |
# Number to word mapping (1-20, common in medical literature)
|
| 36 |
NUMBER_TO_WORD = {
|
| 37 |
-
1:
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
async def refine_for_audio(self, markdown_text: str, use_llm_polish: bool = False) -> str:
|
|
@@ -55,7 +66,7 @@ class AudioRefiner:
|
|
| 55 |
|
| 56 |
text = markdown_text
|
| 57 |
|
| 58 |
-
# Step 1:
|
| 59 |
text = self._remove_references_sections(text)
|
| 60 |
|
| 61 |
# Step 2: Remove markdown formatting
|
|
@@ -81,7 +92,7 @@ class AudioRefiner:
|
|
| 81 |
"Audio refinement complete",
|
| 82 |
original_length=len(markdown_text),
|
| 83 |
refined_length=len(text),
|
| 84 |
-
llm_polish_applied=use_llm_polish
|
| 85 |
)
|
| 86 |
|
| 87 |
return text.strip()
|
|
@@ -97,10 +108,11 @@ class AudioRefiner:
|
|
| 97 |
- ## References
|
| 98 |
- **References:**
|
| 99 |
- **Additional References:**
|
|
|
|
| 100 |
"""
|
| 101 |
# Pattern to match References section heading (case-insensitive)
|
| 102 |
-
#
|
| 103 |
-
references_pattern = r
|
| 104 |
|
| 105 |
# Find all References sections
|
| 106 |
while True:
|
|
@@ -114,11 +126,11 @@ class AudioRefiner:
|
|
| 114 |
# Find the next section (markdown header or bold heading) or end of document
|
| 115 |
# Match: "# Header", "## Header", or "**Header**"
|
| 116 |
next_section_patterns = [
|
| 117 |
-
r
|
| 118 |
-
r
|
| 119 |
]
|
| 120 |
|
| 121 |
-
remaining_text = text[match.end():]
|
| 122 |
next_section_match = None
|
| 123 |
|
| 124 |
# Try all patterns and find the earliest match
|
|
@@ -139,10 +151,7 @@ class AudioRefiner:
|
|
| 139 |
|
| 140 |
# Remove the References section
|
| 141 |
text = text[:section_start] + text[section_end:]
|
| 142 |
-
logger.debug(
|
| 143 |
-
"Removed References section",
|
| 144 |
-
removed_chars=section_end - section_start
|
| 145 |
-
)
|
| 146 |
|
| 147 |
return text
|
| 148 |
|
|
@@ -150,38 +159,38 @@ class AudioRefiner:
|
|
| 150 |
"""Remove markdown formatting syntax."""
|
| 151 |
|
| 152 |
# Headers (# ## ###)
|
| 153 |
-
text = re.sub(r
|
| 154 |
|
| 155 |
# Bold (**text** or __text__)
|
| 156 |
-
text = re.sub(r
|
| 157 |
-
text = re.sub(r
|
| 158 |
|
| 159 |
# Italic (*text* or _text_)
|
| 160 |
-
text = re.sub(r
|
| 161 |
-
text = re.sub(r
|
| 162 |
|
| 163 |
# Links [text](url) → text
|
| 164 |
-
text = re.sub(r
|
| 165 |
|
| 166 |
# Inline code `code` → code
|
| 167 |
-
text = re.sub(r
|
| 168 |
|
| 169 |
# Strikethrough ~~text~~
|
| 170 |
-
text = re.sub(r
|
| 171 |
|
| 172 |
# Blockquotes (> text)
|
| 173 |
-
text = re.sub(r
|
| 174 |
|
| 175 |
# Horizontal rules (---, ***, ___)
|
| 176 |
-
text = re.sub(r
|
| 177 |
|
| 178 |
# List markers (-, *, 1., 2.)
|
| 179 |
-
text = re.sub(r
|
| 180 |
-
text = re.sub(r
|
| 181 |
|
| 182 |
return text
|
| 183 |
|
| 184 |
-
def _roman_to_int(self, roman: str) ->
|
| 185 |
"""Convert roman numeral string to integer.
|
| 186 |
|
| 187 |
Args:
|
|
@@ -236,10 +245,10 @@ class AudioRefiner:
|
|
| 236 |
- Standalone I, II, III (with word boundaries)
|
| 237 |
"""
|
| 238 |
|
| 239 |
-
def replace_roman(match):
|
| 240 |
"""Callback to replace matched roman numeral."""
|
| 241 |
prefix = match.group(1) # Word before roman numeral (if any)
|
| 242 |
-
roman = match.group(2)
|
| 243 |
|
| 244 |
# Convert to integer
|
| 245 |
num = self._roman_to_int(roman)
|
|
@@ -258,7 +267,7 @@ class AudioRefiner:
|
|
| 258 |
# Pattern: Optional word + space + roman numeral
|
| 259 |
# Matches: "Phase I", "Trial II", standalone "I", "II"
|
| 260 |
# Uses word boundaries to avoid matching "I" in "INVALID"
|
| 261 |
-
pattern = r
|
| 262 |
|
| 263 |
text = re.sub(pattern, replace_roman, text)
|
| 264 |
|
|
@@ -268,19 +277,19 @@ class AudioRefiner:
|
|
| 268 |
"""Remove citation markers and references."""
|
| 269 |
|
| 270 |
# Numbered citations [1], [2], [1,2], [1-3]
|
| 271 |
-
text = re.sub(r
|
| 272 |
|
| 273 |
# Author citations (Smith et al., 2023) or (Smith et al. 2023)
|
| 274 |
-
text = re.sub(r
|
| 275 |
|
| 276 |
# Simple year citations (2023)
|
| 277 |
-
text = re.sub(r
|
| 278 |
|
| 279 |
# Author-year (Smith, 2023)
|
| 280 |
-
text = re.sub(r
|
| 281 |
|
| 282 |
# Footnote markers (¹, ², ³)
|
| 283 |
-
text = re.sub(r
|
| 284 |
|
| 285 |
return text
|
| 286 |
|
|
@@ -288,26 +297,26 @@ class AudioRefiner:
|
|
| 288 |
"""Clean up special characters and formatting artifacts."""
|
| 289 |
|
| 290 |
# Replace em dashes with regular dashes
|
| 291 |
-
text = text.replace(
|
| 292 |
-
text = text.replace(
|
| 293 |
|
| 294 |
# Replace smart quotes with regular quotes
|
| 295 |
-
text = text.replace(
|
| 296 |
-
text = text.replace(
|
| 297 |
-
text = text.replace(
|
| 298 |
-
text = text.replace(
|
| 299 |
|
| 300 |
# Remove excessive punctuation (!!!, ???)
|
| 301 |
-
text = re.sub(r
|
| 302 |
|
| 303 |
# Remove asterisks used for footnotes
|
| 304 |
-
text = re.sub(r
|
| 305 |
|
| 306 |
# Remove hash symbols (from headers)
|
| 307 |
-
text = text.replace(
|
| 308 |
|
| 309 |
# Remove excessive dots (...)
|
| 310 |
-
text = re.sub(r
|
| 311 |
|
| 312 |
return text
|
| 313 |
|
|
@@ -315,13 +324,13 @@ class AudioRefiner:
|
|
| 315 |
"""Normalize whitespace for clean audio output."""
|
| 316 |
|
| 317 |
# Replace multiple spaces with single space
|
| 318 |
-
text = re.sub(r
|
| 319 |
|
| 320 |
# Replace multiple newlines with double newline (paragraph break)
|
| 321 |
-
text = re.sub(r
|
| 322 |
|
| 323 |
# Remove trailing/leading whitespace from lines
|
| 324 |
-
text =
|
| 325 |
|
| 326 |
# Remove empty lines at start/end
|
| 327 |
text = text.strip()
|
|
@@ -363,18 +372,14 @@ class AudioRefiner:
|
|
| 363 |
polished_text = result.output.strip()
|
| 364 |
|
| 365 |
logger.info(
|
| 366 |
-
"llm_polish_applied",
|
| 367 |
-
original_length=len(text),
|
| 368 |
-
polished_length=len(polished_text)
|
| 369 |
)
|
| 370 |
|
| 371 |
return polished_text
|
| 372 |
|
| 373 |
except Exception as e:
|
| 374 |
logger.warning(
|
| 375 |
-
"llm_polish_failed",
|
| 376 |
-
error=str(e),
|
| 377 |
-
message="Falling back to rule-based output"
|
| 378 |
)
|
| 379 |
# Graceful fallback: return original text if LLM fails
|
| 380 |
return text
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import re
|
|
|
|
| 8 |
|
| 9 |
import structlog
|
| 10 |
from pydantic_ai import Agent
|
|
|
|
| 26 |
"""
|
| 27 |
|
| 28 |
# Roman numeral to integer mapping
|
| 29 |
+
ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
# Number to word mapping (1-20, common in medical literature)
|
| 32 |
NUMBER_TO_WORD = {
|
| 33 |
+
1: "One",
|
| 34 |
+
2: "Two",
|
| 35 |
+
3: "Three",
|
| 36 |
+
4: "Four",
|
| 37 |
+
5: "Five",
|
| 38 |
+
6: "Six",
|
| 39 |
+
7: "Seven",
|
| 40 |
+
8: "Eight",
|
| 41 |
+
9: "Nine",
|
| 42 |
+
10: "Ten",
|
| 43 |
+
11: "Eleven",
|
| 44 |
+
12: "Twelve",
|
| 45 |
+
13: "Thirteen",
|
| 46 |
+
14: "Fourteen",
|
| 47 |
+
15: "Fifteen",
|
| 48 |
+
16: "Sixteen",
|
| 49 |
+
17: "Seventeen",
|
| 50 |
+
18: "Eighteen",
|
| 51 |
+
19: "Nineteen",
|
| 52 |
+
20: "Twenty",
|
| 53 |
}
|
| 54 |
|
| 55 |
async def refine_for_audio(self, markdown_text: str, use_llm_polish: bool = False) -> str:
|
|
|
|
| 66 |
|
| 67 |
text = markdown_text
|
| 68 |
|
| 69 |
+
# Step 1: Remove References sections first (before other processing)
|
| 70 |
text = self._remove_references_sections(text)
|
| 71 |
|
| 72 |
# Step 2: Remove markdown formatting
|
|
|
|
| 92 |
"Audio refinement complete",
|
| 93 |
original_length=len(markdown_text),
|
| 94 |
refined_length=len(text),
|
| 95 |
+
llm_polish_applied=use_llm_polish,
|
| 96 |
)
|
| 97 |
|
| 98 |
return text.strip()
|
|
|
|
| 108 |
- ## References
|
| 109 |
- **References:**
|
| 110 |
- **Additional References:**
|
| 111 |
+
- References: (plain text)
|
| 112 |
"""
|
| 113 |
# Pattern to match References section heading (case-insensitive)
|
| 114 |
+
# Matches: markdown headers (# References), bold (**References:**), or plain text (References:)
|
| 115 |
+
references_pattern = r"\n(?:#+\s*References?:?\s*\n|\*\*\s*(?:Additional\s+)?References?:?\s*\*\*\s*\n|References?:?\s*\n)"
|
| 116 |
|
| 117 |
# Find all References sections
|
| 118 |
while True:
|
|
|
|
| 126 |
# Find the next section (markdown header or bold heading) or end of document
|
| 127 |
# Match: "# Header", "## Header", or "**Header**"
|
| 128 |
next_section_patterns = [
|
| 129 |
+
r"\n#+\s+\w+", # Markdown headers (# Section, ## Section)
|
| 130 |
+
r"\n\*\*[A-Z][^*]+\*\*", # Bold headings (**Section Name**)
|
| 131 |
]
|
| 132 |
|
| 133 |
+
remaining_text = text[match.end() :]
|
| 134 |
next_section_match = None
|
| 135 |
|
| 136 |
# Try all patterns and find the earliest match
|
|
|
|
| 151 |
|
| 152 |
# Remove the References section
|
| 153 |
text = text[:section_start] + text[section_end:]
|
| 154 |
+
logger.debug("Removed References section", removed_chars=section_end - section_start)
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
return text
|
| 157 |
|
|
|
|
| 159 |
"""Remove markdown formatting syntax."""
|
| 160 |
|
| 161 |
# Headers (# ## ###)
|
| 162 |
+
text = re.sub(r"^\s*#+\s+", "", text, flags=re.MULTILINE)
|
| 163 |
|
| 164 |
# Bold (**text** or __text__)
|
| 165 |
+
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
| 166 |
+
text = re.sub(r"__([^_]+)__", r"\1", text)
|
| 167 |
|
| 168 |
# Italic (*text* or _text_)
|
| 169 |
+
text = re.sub(r"\*([^*]+)\*", r"\1", text)
|
| 170 |
+
text = re.sub(r"_([^_]+)_", r"\1", text)
|
| 171 |
|
| 172 |
# Links [text](url) → text
|
| 173 |
+
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
| 174 |
|
| 175 |
# Inline code `code` → code
|
| 176 |
+
text = re.sub(r"`([^`]+)`", r"\1", text)
|
| 177 |
|
| 178 |
# Strikethrough ~~text~~
|
| 179 |
+
text = re.sub(r"~~([^~]+)~~", r"\1", text)
|
| 180 |
|
| 181 |
# Blockquotes (> text)
|
| 182 |
+
text = re.sub(r"^\s*>\s+", "", text, flags=re.MULTILINE)
|
| 183 |
|
| 184 |
# Horizontal rules (---, ***, ___)
|
| 185 |
+
text = re.sub(r"^\s*[-*_]{3,}\s*$", "", text, flags=re.MULTILINE)
|
| 186 |
|
| 187 |
# List markers (-, *, 1., 2.)
|
| 188 |
+
text = re.sub(r"^\s*[-*]\s+", "", text, flags=re.MULTILINE)
|
| 189 |
+
text = re.sub(r"^\s*\d+\.\s+", "", text, flags=re.MULTILINE)
|
| 190 |
|
| 191 |
return text
|
| 192 |
|
| 193 |
+
def _roman_to_int(self, roman: str) -> int | None:
|
| 194 |
"""Convert roman numeral string to integer.
|
| 195 |
|
| 196 |
Args:
|
|
|
|
| 245 |
- Standalone I, II, III (with word boundaries)
|
| 246 |
"""
|
| 247 |
|
| 248 |
+
def replace_roman(match: re.Match[str]) -> str:
|
| 249 |
"""Callback to replace matched roman numeral."""
|
| 250 |
prefix = match.group(1) # Word before roman numeral (if any)
|
| 251 |
+
roman = match.group(2) # The roman numeral
|
| 252 |
|
| 253 |
# Convert to integer
|
| 254 |
num = self._roman_to_int(roman)
|
|
|
|
| 267 |
# Pattern: Optional word + space + roman numeral
|
| 268 |
# Matches: "Phase I", "Trial II", standalone "I", "II"
|
| 269 |
# Uses word boundaries to avoid matching "I" in "INVALID"
|
| 270 |
+
pattern = r"\b(Phase|Trial|Type|Stage|Class|Group|Arm|Cohort)?\s*([IVXLCDM]+)\b"
|
| 271 |
|
| 272 |
text = re.sub(pattern, replace_roman, text)
|
| 273 |
|
|
|
|
| 277 |
"""Remove citation markers and references."""
|
| 278 |
|
| 279 |
# Numbered citations [1], [2], [1,2], [1-3]
|
| 280 |
+
text = re.sub(r"\[\d+(?:[-,]\d+)*\]", "", text)
|
| 281 |
|
| 282 |
# Author citations (Smith et al., 2023) or (Smith et al. 2023)
|
| 283 |
+
text = re.sub(r"\([A-Z][a-z]+\s+et\s+al\.?,?\s+\d{4}\)", "", text)
|
| 284 |
|
| 285 |
# Simple year citations (2023)
|
| 286 |
+
text = re.sub(r"\(\d{4}\)", "", text)
|
| 287 |
|
| 288 |
# Author-year (Smith, 2023)
|
| 289 |
+
text = re.sub(r"\([A-Z][a-z]+,?\s+\d{4}\)", "", text)
|
| 290 |
|
| 291 |
# Footnote markers (¹, ², ³)
|
| 292 |
+
text = re.sub(r"[¹²³⁴⁵⁶⁷⁸⁹⁰]+", "", text)
|
| 293 |
|
| 294 |
return text
|
| 295 |
|
|
|
|
| 297 |
"""Clean up special characters and formatting artifacts."""
|
| 298 |
|
| 299 |
# Replace em dashes with regular dashes
|
| 300 |
+
text = text.replace("\u2014", "-") # em dash
|
| 301 |
+
text = text.replace("\u2013", "-") # en dash
|
| 302 |
|
| 303 |
# Replace smart quotes with regular quotes
|
| 304 |
+
text = text.replace("\u201c", '"') # left double quote
|
| 305 |
+
text = text.replace("\u201d", '"') # right double quote
|
| 306 |
+
text = text.replace("\u2018", "'") # left single quote
|
| 307 |
+
text = text.replace("\u2019", "'") # right single quote
|
| 308 |
|
| 309 |
# Remove excessive punctuation (!!!, ???)
|
| 310 |
+
text = re.sub(r"([!?]){2,}", r"\1", text)
|
| 311 |
|
| 312 |
# Remove asterisks used for footnotes
|
| 313 |
+
text = re.sub(r"\*+", "", text)
|
| 314 |
|
| 315 |
# Remove hash symbols (from headers)
|
| 316 |
+
text = text.replace("#", "")
|
| 317 |
|
| 318 |
# Remove excessive dots (...)
|
| 319 |
+
text = re.sub(r"\.{4,}", "...", text)
|
| 320 |
|
| 321 |
return text
|
| 322 |
|
|
|
|
| 324 |
"""Normalize whitespace for clean audio output."""
|
| 325 |
|
| 326 |
# Replace multiple spaces with single space
|
| 327 |
+
text = re.sub(r" {2,}", " ", text)
|
| 328 |
|
| 329 |
# Replace multiple newlines with double newline (paragraph break)
|
| 330 |
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
| 331 |
|
| 332 |
# Remove trailing/leading whitespace from lines
|
| 333 |
+
text = "\n".join(line.strip() for line in text.split("\n"))
|
| 334 |
|
| 335 |
# Remove empty lines at start/end
|
| 336 |
text = text.strip()
|
|
|
|
| 372 |
polished_text = result.output.strip()
|
| 373 |
|
| 374 |
logger.info(
|
| 375 |
+
"llm_polish_applied", original_length=len(text), polished_length=len(polished_text)
|
|
|
|
|
|
|
| 376 |
)
|
| 377 |
|
| 378 |
return polished_text
|
| 379 |
|
| 380 |
except Exception as e:
|
| 381 |
logger.warning(
|
| 382 |
+
"llm_polish_failed", error=str(e), message="Falling back to rule-based output"
|
|
|
|
|
|
|
| 383 |
)
|
| 384 |
# Graceful fallback: return original text if LLM fails
|
| 385 |
return text
|
src/agents/knowledge_gap.py
CHANGED
|
@@ -142,7 +142,9 @@ HISTORY OF ACTIONS, FINDINGS AND THOUGHTS:
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
-
def create_knowledge_gap_agent(
|
|
|
|
|
|
|
| 146 |
"""
|
| 147 |
Factory function to create a knowledge gap agent.
|
| 148 |
|
|
|
|
| 142 |
)
|
| 143 |
|
| 144 |
|
| 145 |
+
def create_knowledge_gap_agent(
|
| 146 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 147 |
+
) -> KnowledgeGapAgent:
|
| 148 |
"""
|
| 149 |
Factory function to create a knowledge gap agent.
|
| 150 |
|
src/agents/long_writer.py
CHANGED
|
@@ -225,25 +225,27 @@ class LongWriterAgent:
|
|
| 225 |
"Section writing failed after all attempts",
|
| 226 |
error=str(last_exception) if last_exception else "Unknown error",
|
| 227 |
)
|
| 228 |
-
|
| 229 |
# Try to enhance fallback with evidence if available
|
| 230 |
try:
|
| 231 |
from src.middleware.state_machine import get_workflow_state
|
| 232 |
-
|
| 233 |
state = get_workflow_state()
|
| 234 |
if state and state.evidence:
|
| 235 |
# Include evidence citations in fallback
|
| 236 |
evidence_refs: list[str] = []
|
| 237 |
for i, ev in enumerate(state.evidence[:10], 1): # Limit to 10
|
| 238 |
-
authors =
|
|
|
|
|
|
|
| 239 |
evidence_refs.append(
|
| 240 |
f"[{i}] {authors}. *{ev.citation.title}*. {ev.citation.url}"
|
| 241 |
)
|
| 242 |
-
|
| 243 |
enhanced_draft = f"## {next_section_title}\n\n{next_section_draft}"
|
| 244 |
if evidence_refs:
|
| 245 |
enhanced_draft += "\n\n### Sources\n\n" + "\n".join(evidence_refs)
|
| 246 |
-
|
| 247 |
return LongWriterOutput(
|
| 248 |
next_section_markdown=enhanced_draft,
|
| 249 |
references=evidence_refs,
|
|
@@ -253,7 +255,7 @@ class LongWriterAgent:
|
|
| 253 |
"Failed to enhance fallback with evidence",
|
| 254 |
error=str(e),
|
| 255 |
)
|
| 256 |
-
|
| 257 |
# Basic fallback
|
| 258 |
return LongWriterOutput(
|
| 259 |
next_section_markdown=f"## {next_section_title}\n\n{next_section_draft}",
|
|
@@ -437,7 +439,9 @@ class LongWriterAgent:
|
|
| 437 |
return re.sub(r"^(#+)\s(.+)$", adjust_heading_level, section_markdown, flags=re.MULTILINE)
|
| 438 |
|
| 439 |
|
| 440 |
-
def create_long_writer_agent(
|
|
|
|
|
|
|
| 441 |
"""
|
| 442 |
Factory function to create a long writer agent.
|
| 443 |
|
|
|
|
| 225 |
"Section writing failed after all attempts",
|
| 226 |
error=str(last_exception) if last_exception else "Unknown error",
|
| 227 |
)
|
| 228 |
+
|
| 229 |
# Try to enhance fallback with evidence if available
|
| 230 |
try:
|
| 231 |
from src.middleware.state_machine import get_workflow_state
|
| 232 |
+
|
| 233 |
state = get_workflow_state()
|
| 234 |
if state and state.evidence:
|
| 235 |
# Include evidence citations in fallback
|
| 236 |
evidence_refs: list[str] = []
|
| 237 |
for i, ev in enumerate(state.evidence[:10], 1): # Limit to 10
|
| 238 |
+
authors = (
|
| 239 |
+
", ".join(ev.citation.authors[:2]) if ev.citation.authors else "Unknown"
|
| 240 |
+
)
|
| 241 |
evidence_refs.append(
|
| 242 |
f"[{i}] {authors}. *{ev.citation.title}*. {ev.citation.url}"
|
| 243 |
)
|
| 244 |
+
|
| 245 |
enhanced_draft = f"## {next_section_title}\n\n{next_section_draft}"
|
| 246 |
if evidence_refs:
|
| 247 |
enhanced_draft += "\n\n### Sources\n\n" + "\n".join(evidence_refs)
|
| 248 |
+
|
| 249 |
return LongWriterOutput(
|
| 250 |
next_section_markdown=enhanced_draft,
|
| 251 |
references=evidence_refs,
|
|
|
|
| 255 |
"Failed to enhance fallback with evidence",
|
| 256 |
error=str(e),
|
| 257 |
)
|
| 258 |
+
|
| 259 |
# Basic fallback
|
| 260 |
return LongWriterOutput(
|
| 261 |
next_section_markdown=f"## {next_section_title}\n\n{next_section_draft}",
|
|
|
|
| 439 |
return re.sub(r"^(#+)\s(.+)$", adjust_heading_level, section_markdown, flags=re.MULTILINE)
|
| 440 |
|
| 441 |
|
| 442 |
+
def create_long_writer_agent(
|
| 443 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 444 |
+
) -> LongWriterAgent:
|
| 445 |
"""
|
| 446 |
Factory function to create a long writer agent.
|
| 447 |
|
src/agents/proofreader.py
CHANGED
|
@@ -181,7 +181,9 @@ REPORT DRAFT:
|
|
| 181 |
return f"# Research Report\n\n## Query\n{query}\n\n" + "\n\n".join(sections)
|
| 182 |
|
| 183 |
|
| 184 |
-
def create_proofreader_agent(
|
|
|
|
|
|
|
| 185 |
"""
|
| 186 |
Factory function to create a proofreader agent.
|
| 187 |
|
|
|
|
| 181 |
return f"# Research Report\n\n## Query\n{query}\n\n" + "\n\n".join(sections)
|
| 182 |
|
| 183 |
|
| 184 |
+
def create_proofreader_agent(
|
| 185 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 186 |
+
) -> ProofreaderAgent:
|
| 187 |
"""
|
| 188 |
Factory function to create a proofreader agent.
|
| 189 |
|
src/agents/thinking.py
CHANGED
|
@@ -134,7 +134,9 @@ HISTORY OF ACTIONS, FINDINGS AND THOUGHTS:
|
|
| 134 |
return f"Starting iteration {iteration}. Need to gather information about: {query}"
|
| 135 |
|
| 136 |
|
| 137 |
-
def create_thinking_agent(
|
|
|
|
|
|
|
| 138 |
"""
|
| 139 |
Factory function to create a thinking agent.
|
| 140 |
|
|
|
|
| 134 |
return f"Starting iteration {iteration}. Need to gather information about: {query}"
|
| 135 |
|
| 136 |
|
| 137 |
+
def create_thinking_agent(
|
| 138 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 139 |
+
) -> ThinkingAgent:
|
| 140 |
"""
|
| 141 |
Factory function to create a thinking agent.
|
| 142 |
|
src/agents/tool_selector.py
CHANGED
|
@@ -154,7 +154,9 @@ HISTORY OF ACTIONS, FINDINGS AND THOUGHTS:
|
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
-
def create_tool_selector_agent(
|
|
|
|
|
|
|
| 158 |
"""
|
| 159 |
Factory function to create a tool selector agent.
|
| 160 |
|
|
|
|
| 154 |
)
|
| 155 |
|
| 156 |
|
| 157 |
+
def create_tool_selector_agent(
|
| 158 |
+
model: Any | None = None, oauth_token: str | None = None
|
| 159 |
+
) -> ToolSelectorAgent:
|
| 160 |
"""
|
| 161 |
Factory function to create a tool selector agent.
|
| 162 |
|
src/agents/writer.py
CHANGED
|
@@ -175,12 +175,12 @@ FINDINGS:
|
|
| 175 |
"Report writing failed after all attempts",
|
| 176 |
error=str(last_exception) if last_exception else "Unknown error",
|
| 177 |
)
|
| 178 |
-
|
| 179 |
# Try to use evidence-based report generator for better fallback
|
| 180 |
try:
|
| 181 |
from src.middleware.state_machine import get_workflow_state
|
| 182 |
from src.utils.report_generator import generate_report_from_evidence
|
| 183 |
-
|
| 184 |
state = get_workflow_state()
|
| 185 |
if state and state.evidence:
|
| 186 |
self.logger.info(
|
|
@@ -197,7 +197,7 @@ FINDINGS:
|
|
| 197 |
"Failed to use evidence-based report generator",
|
| 198 |
error=str(e),
|
| 199 |
)
|
| 200 |
-
|
| 201 |
# Fallback to simple report if evidence generator fails
|
| 202 |
# Truncate findings in fallback if too long
|
| 203 |
fallback_findings = findings[:500] + "..." if len(findings) > 500 else findings
|
|
|
|
| 175 |
"Report writing failed after all attempts",
|
| 176 |
error=str(last_exception) if last_exception else "Unknown error",
|
| 177 |
)
|
| 178 |
+
|
| 179 |
# Try to use evidence-based report generator for better fallback
|
| 180 |
try:
|
| 181 |
from src.middleware.state_machine import get_workflow_state
|
| 182 |
from src.utils.report_generator import generate_report_from_evidence
|
| 183 |
+
|
| 184 |
state = get_workflow_state()
|
| 185 |
if state and state.evidence:
|
| 186 |
self.logger.info(
|
|
|
|
| 197 |
"Failed to use evidence-based report generator",
|
| 198 |
error=str(e),
|
| 199 |
)
|
| 200 |
+
|
| 201 |
# Fallback to simple report if evidence generator fails
|
| 202 |
# Truncate findings in fallback if too long
|
| 203 |
fallback_findings = findings[:500] + "..." if len(findings) > 500 else findings
|
src/app.py
CHANGED
|
@@ -18,7 +18,6 @@ import structlog
|
|
| 18 |
|
| 19 |
from src.agent_factory.judges import HFInferenceJudgeHandler, JudgeHandler, MockJudgeHandler
|
| 20 |
from src.orchestrator_factory import create_orchestrator
|
| 21 |
-
from src.services.audio_processing import get_audio_service
|
| 22 |
from src.services.multimodal_processing import get_multimodal_service
|
| 23 |
from src.utils.config import settings
|
| 24 |
from src.utils.models import AgentEvent, OrchestratorConfig
|
|
@@ -445,9 +444,6 @@ async def research_agent(
|
|
| 445 |
use_graph: bool = True,
|
| 446 |
enable_image_input: bool = True,
|
| 447 |
enable_audio_input: bool = True,
|
| 448 |
-
tts_voice: str = "af_heart",
|
| 449 |
-
tts_speed: float = 1.0,
|
| 450 |
-
tts_use_llm_polish: bool = False,
|
| 451 |
web_search_provider: str = "auto",
|
| 452 |
oauth_token: gr.OAuthToken | None = None,
|
| 453 |
oauth_profile: gr.OAuthProfile | None = None,
|
|
@@ -465,15 +461,12 @@ async def research_agent(
|
|
| 465 |
use_graph: Whether to use graph execution
|
| 466 |
enable_image_input: Whether to process image inputs
|
| 467 |
enable_audio_input: Whether to process audio inputs
|
| 468 |
-
tts_voice: TTS voice selection
|
| 469 |
-
tts_speed: TTS speech speed
|
| 470 |
-
tts_use_llm_polish: Apply LLM-based final polish to audio text (costs API calls)
|
| 471 |
web_search_provider: Web search provider selection
|
| 472 |
oauth_token: Gradio OAuth token (None if user not logged in)
|
| 473 |
oauth_profile: Gradio OAuth profile (None if user not logged in)
|
| 474 |
|
| 475 |
Yields:
|
| 476 |
-
Chat message dictionaries
|
| 477 |
"""
|
| 478 |
# Extract OAuth token and username
|
| 479 |
token_value = _extract_oauth_token(oauth_token)
|
|
@@ -585,33 +578,8 @@ async def research_agent(
|
|
| 585 |
chat_msg = event_to_chat_message(event)
|
| 586 |
yield chat_msg
|
| 587 |
|
| 588 |
-
#
|
| 589 |
-
|
| 590 |
-
try:
|
| 591 |
-
audio_service = get_audio_service()
|
| 592 |
-
# Get the last message from history for TTS
|
| 593 |
-
last_message = history[-1].get("content", "") if history else processed_text
|
| 594 |
-
if last_message:
|
| 595 |
-
# Temporarily override tts_use_llm_polish setting from UI
|
| 596 |
-
original_llm_polish = settings.tts_use_llm_polish
|
| 597 |
-
try:
|
| 598 |
-
settings.tts_use_llm_polish = tts_use_llm_polish
|
| 599 |
-
# Use UI-configured voice and speed, fallback to settings defaults
|
| 600 |
-
await audio_service.generate_audio_output(
|
| 601 |
-
text=last_message,
|
| 602 |
-
voice=tts_voice or settings.tts_voice,
|
| 603 |
-
speed=tts_speed if tts_speed else settings.tts_speed,
|
| 604 |
-
)
|
| 605 |
-
finally:
|
| 606 |
-
# Restore original setting
|
| 607 |
-
settings.tts_use_llm_polish = original_llm_polish
|
| 608 |
-
except Exception as e:
|
| 609 |
-
logger.warning("audio_synthesis_failed", error=str(e))
|
| 610 |
-
# Continue without audio output
|
| 611 |
-
|
| 612 |
-
# Note: Audio output is handled separately via TTS service
|
| 613 |
-
# Gradio ChatInterface doesn't support tuple yields, so we skip audio output here
|
| 614 |
-
# Audio can be handled via a separate component if needed
|
| 615 |
|
| 616 |
except Exception as e:
|
| 617 |
# Return error message without metadata to avoid issues during example caching
|
|
@@ -746,19 +714,26 @@ def create_demo() -> gr.Blocks:
|
|
| 746 |
)
|
| 747 |
gr.LoginButton("Sign in with Hugging Face")
|
| 748 |
gr.Markdown("---")
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 762 |
gr.Markdown("---")
|
| 763 |
|
| 764 |
# Settings Section - Organized in Accordions
|
|
@@ -924,231 +899,321 @@ def create_demo() -> gr.Blocks:
|
|
| 924 |
info="Process uploaded/recorded audio with speech-to-text",
|
| 925 |
)
|
| 926 |
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
label="Enable Audio Output",
|
| 933 |
-
info="Generate audio responses using text-to-speech",
|
| 934 |
)
|
| 935 |
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
"
|
| 939 |
-
"
|
| 940 |
-
"
|
| 941 |
-
"
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
"
|
| 946 |
-
"
|
| 947 |
-
"
|
| 948 |
-
"
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
| 1043 |
-
|
| 1044 |
-
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
-
|
| 1054 |
-
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1073 |
)
|
| 1074 |
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
value=settings.tts_speed,
|
| 1079 |
-
step=0.1,
|
| 1080 |
-
label="TTS Speech Speed",
|
| 1081 |
-
info="Adjust TTS speech speed (0.5x to 2.0x)",
|
| 1082 |
)
|
| 1083 |
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
info="Modal GPU type for TTS (T4 is cheapest, A100 is fastest). Note: GPU changes require app restart.",
|
| 1089 |
-
visible=settings.modal_available,
|
| 1090 |
-
interactive=False, # GPU type set at function definition time, requires restart
|
| 1091 |
)
|
| 1092 |
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1104 |
)
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
gr.update(visible=enabled),
|
| 1116 |
-
gr.update(visible=enabled),
|
| 1117 |
)
|
| 1118 |
|
| 1119 |
-
|
| 1120 |
-
fn=update_tts_visibility,
|
| 1121 |
-
inputs=[enable_audio_output_checkbox],
|
| 1122 |
-
outputs=[tts_voice_dropdown, tts_speed_slider, tts_use_llm_polish_checkbox, audio_output],
|
| 1123 |
-
)
|
| 1124 |
|
| 1125 |
# Chat interface with multimodal support
|
| 1126 |
# Examples are provided but will NOT run at startup (cache_examples=False)
|
| 1127 |
# Users must log in first before using examples or submitting queries
|
| 1128 |
-
gr.ChatInterface(
|
| 1129 |
fn=research_agent,
|
| 1130 |
multimodal=True, # Enable multimodal input (text + images + audio)
|
| 1131 |
title="🔬 The DETERMINATOR",
|
| 1132 |
description=(
|
| 1133 |
-
"*Generalist Deep Research Agent — stops at nothing until finding precise answers
|
| 1134 |
-
"
|
| 1135 |
-
"
|
| 1136 |
-
"It automatically determines if medical knowledge sources (PubMed, ClinicalTrials.gov) are needed and adapts its search strategy accordingly.\n\n"
|
| 1137 |
-
"**Key Features**:\n"
|
| 1138 |
-
"- 🔍 Multi-source search (Web, PubMed, ClinicalTrials.gov, Europe PMC, RAG)\n"
|
| 1139 |
-
"- 🧠 Automatic medical knowledge detection\n"
|
| 1140 |
-
"- 🔄 Iterative refinement until precise answers are found\n"
|
| 1141 |
-
"- ⏹️ Stops only at configured limits (budget, time, iterations)\n"
|
| 1142 |
-
"- 📊 Evidence synthesis with citations\n\n"
|
| 1143 |
-
"**MCP Server Active**: Connect Claude Desktop to `/gradio_api/mcp/`\n\n"
|
| 1144 |
-
"**📷🎤 Multimodal Input Support**:\n"
|
| 1145 |
-
"- **Images**: Click the 📷 image icon in the textbox to upload images (OCR)\n"
|
| 1146 |
-
"- **Audio**: Click the 🎤 microphone icon in the textbox to record audio (STT)\n"
|
| 1147 |
-
"- **Files**: Drag & drop or click to upload image/audio files\n"
|
| 1148 |
-
"- **Text**: Type your research questions directly\n\n"
|
| 1149 |
-
"💡 **Tip**: Look for the 📷 and 🎤 icons in the text input box below!\n\n"
|
| 1150 |
-
"Configure multimodal inputs in the sidebar settings.\n\n"
|
| 1151 |
-
"**⚠️ Authentication Required**: Please **sign in with HuggingFace** above before using this application."
|
| 1152 |
),
|
| 1153 |
examples=[
|
| 1154 |
# When additional_inputs are provided, examples must be lists of lists
|
|
@@ -1211,15 +1276,26 @@ def create_demo() -> gr.Blocks:
|
|
| 1211 |
use_graph_checkbox,
|
| 1212 |
enable_image_input_checkbox,
|
| 1213 |
enable_audio_input_checkbox,
|
| 1214 |
-
tts_voice_dropdown,
|
| 1215 |
-
tts_speed_slider,
|
| 1216 |
-
tts_use_llm_polish_checkbox,
|
| 1217 |
web_search_provider_dropdown,
|
| 1218 |
# Note: gr.OAuthToken and gr.OAuthProfile are automatically passed as function parameters
|
| 1219 |
],
|
| 1220 |
cache_examples=False, # Don't cache examples - requires authentication
|
| 1221 |
)
|
| 1222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1223 |
return demo # type: ignore[no-any-return]
|
| 1224 |
|
| 1225 |
|
|
|
|
| 18 |
|
| 19 |
from src.agent_factory.judges import HFInferenceJudgeHandler, JudgeHandler, MockJudgeHandler
|
| 20 |
from src.orchestrator_factory import create_orchestrator
|
|
|
|
| 21 |
from src.services.multimodal_processing import get_multimodal_service
|
| 22 |
from src.utils.config import settings
|
| 23 |
from src.utils.models import AgentEvent, OrchestratorConfig
|
|
|
|
| 444 |
use_graph: bool = True,
|
| 445 |
enable_image_input: bool = True,
|
| 446 |
enable_audio_input: bool = True,
|
|
|
|
|
|
|
|
|
|
| 447 |
web_search_provider: str = "auto",
|
| 448 |
oauth_token: gr.OAuthToken | None = None,
|
| 449 |
oauth_profile: gr.OAuthProfile | None = None,
|
|
|
|
| 461 |
use_graph: Whether to use graph execution
|
| 462 |
enable_image_input: Whether to process image inputs
|
| 463 |
enable_audio_input: Whether to process audio inputs
|
|
|
|
|
|
|
|
|
|
| 464 |
web_search_provider: Web search provider selection
|
| 465 |
oauth_token: Gradio OAuth token (None if user not logged in)
|
| 466 |
oauth_profile: Gradio OAuth profile (None if user not logged in)
|
| 467 |
|
| 468 |
Yields:
|
| 469 |
+
Chat message dictionaries
|
| 470 |
"""
|
| 471 |
# Extract OAuth token and username
|
| 472 |
token_value = _extract_oauth_token(oauth_token)
|
|
|
|
| 578 |
chat_msg = event_to_chat_message(event)
|
| 579 |
yield chat_msg
|
| 580 |
|
| 581 |
+
# Note: Audio output is now handled via on-demand TTS button
|
| 582 |
+
# Users click "Generate Audio" button to create TTS for the last response
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 583 |
|
| 584 |
except Exception as e:
|
| 585 |
# Return error message without metadata to avoid issues during example caching
|
|
|
|
| 714 |
)
|
| 715 |
gr.LoginButton("Sign in with Hugging Face")
|
| 716 |
gr.Markdown("---")
|
| 717 |
+
|
| 718 |
+
# About Section - Collapsible with details
|
| 719 |
+
with gr.Accordion("ℹ️ About", open=False):
|
| 720 |
+
gr.Markdown(
|
| 721 |
+
"**The DETERMINATOR** - Generalist Deep Research Agent\n\n"
|
| 722 |
+
"Stops at nothing until finding precise answers to complex questions.\n\n"
|
| 723 |
+
"**How It Works**:\n"
|
| 724 |
+
"- 🔍 Multi-source search (Web, PubMed, ClinicalTrials.gov, Europe PMC, RAG)\n"
|
| 725 |
+
"- 🧠 Automatic medical knowledge detection\n"
|
| 726 |
+
"- 🔄 Iterative refinement with search-judge loops\n"
|
| 727 |
+
"- ⏹️ Continues until budget/time/iteration limits\n"
|
| 728 |
+
"- 📊 Evidence synthesis with citations\n\n"
|
| 729 |
+
"**Multimodal Input**:\n"
|
| 730 |
+
"- 📷 **Images**: Click image icon in textbox (OCR)\n"
|
| 731 |
+
"- 🎤 **Audio**: Click microphone icon (speech-to-text)\n"
|
| 732 |
+
"- 📄 **Files**: Drag & drop or click to upload\n\n"
|
| 733 |
+
"**MCP Server**: Connect Claude Desktop to `/gradio_api/mcp/`\n\n"
|
| 734 |
+
"⚠️ **Research tool only** - Synthesizes evidence but cannot provide medical advice."
|
| 735 |
+
)
|
| 736 |
+
|
| 737 |
gr.Markdown("---")
|
| 738 |
|
| 739 |
# Settings Section - Organized in Accordions
|
|
|
|
| 899 |
info="Process uploaded/recorded audio with speech-to-text",
|
| 900 |
)
|
| 901 |
|
| 902 |
+
# Audio Output Configuration - Collapsible
|
| 903 |
+
with gr.Accordion("🔊 Audio Output (TTS)", open=False):
|
| 904 |
+
gr.Markdown(
|
| 905 |
+
"**Generate audio for research responses on-demand.**\n\n"
|
| 906 |
+
"Enter Modal keys below or set `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` in `.env` for local development."
|
|
|
|
|
|
|
| 907 |
)
|
| 908 |
|
| 909 |
+
with gr.Accordion("🔑 Modal Credentials (Optional)", open=False):
|
| 910 |
+
modal_token_id_input = gr.Textbox(
|
| 911 |
+
label="Modal Token ID",
|
| 912 |
+
placeholder="ak-... (leave empty to use .env)",
|
| 913 |
+
type="password",
|
| 914 |
+
value="",
|
| 915 |
+
)
|
| 916 |
+
|
| 917 |
+
modal_token_secret_input = gr.Textbox(
|
| 918 |
+
label="Modal Token Secret",
|
| 919 |
+
placeholder="as-... (leave empty to use .env)",
|
| 920 |
+
type="password",
|
| 921 |
+
value="",
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
with gr.Accordion("🎚️ Voice & Quality Settings", open=False):
|
| 925 |
+
tts_voice_dropdown = gr.Dropdown(
|
| 926 |
+
choices=[
|
| 927 |
+
"af_heart",
|
| 928 |
+
"af_bella",
|
| 929 |
+
"af_sarah",
|
| 930 |
+
"af_sky",
|
| 931 |
+
"af_nova",
|
| 932 |
+
"af_shimmer",
|
| 933 |
+
"af_echo",
|
| 934 |
+
"af_fable",
|
| 935 |
+
"af_onyx",
|
| 936 |
+
"af_angel",
|
| 937 |
+
"af_asteria",
|
| 938 |
+
"af_jessica",
|
| 939 |
+
"af_elli",
|
| 940 |
+
"af_domi",
|
| 941 |
+
"af_gigi",
|
| 942 |
+
"af_freya",
|
| 943 |
+
"af_glinda",
|
| 944 |
+
"af_cora",
|
| 945 |
+
"af_serena",
|
| 946 |
+
"af_liv",
|
| 947 |
+
"af_naomi",
|
| 948 |
+
"af_rachel",
|
| 949 |
+
"af_antoni",
|
| 950 |
+
"af_thomas",
|
| 951 |
+
"af_charlie",
|
| 952 |
+
"af_emily",
|
| 953 |
+
"af_george",
|
| 954 |
+
"af_arnold",
|
| 955 |
+
"af_adam",
|
| 956 |
+
"af_sam",
|
| 957 |
+
"af_paul",
|
| 958 |
+
"af_josh",
|
| 959 |
+
"af_daniel",
|
| 960 |
+
"af_liam",
|
| 961 |
+
"af_dave",
|
| 962 |
+
"af_fin",
|
| 963 |
+
"af_sarah",
|
| 964 |
+
"af_glinda",
|
| 965 |
+
"af_grace",
|
| 966 |
+
"af_dorothy",
|
| 967 |
+
"af_michael",
|
| 968 |
+
"af_james",
|
| 969 |
+
"af_joseph",
|
| 970 |
+
"af_jeremy",
|
| 971 |
+
"af_ryan",
|
| 972 |
+
"af_oliver",
|
| 973 |
+
"af_harry",
|
| 974 |
+
"af_kyle",
|
| 975 |
+
"af_leo",
|
| 976 |
+
"af_otto",
|
| 977 |
+
"af_owen",
|
| 978 |
+
"af_pepper",
|
| 979 |
+
"af_phil",
|
| 980 |
+
"af_raven",
|
| 981 |
+
"af_rocky",
|
| 982 |
+
"af_rusty",
|
| 983 |
+
"af_serena",
|
| 984 |
+
"af_sky",
|
| 985 |
+
"af_spark",
|
| 986 |
+
"af_stella",
|
| 987 |
+
"af_storm",
|
| 988 |
+
"af_taylor",
|
| 989 |
+
"af_vera",
|
| 990 |
+
"af_will",
|
| 991 |
+
"af_aria",
|
| 992 |
+
"af_ash",
|
| 993 |
+
"af_ballad",
|
| 994 |
+
"af_bella",
|
| 995 |
+
"af_breeze",
|
| 996 |
+
"af_cove",
|
| 997 |
+
"af_dusk",
|
| 998 |
+
"af_ember",
|
| 999 |
+
"af_flash",
|
| 1000 |
+
"af_flow",
|
| 1001 |
+
"af_glow",
|
| 1002 |
+
"af_harmony",
|
| 1003 |
+
"af_journey",
|
| 1004 |
+
"af_lullaby",
|
| 1005 |
+
"af_lyra",
|
| 1006 |
+
"af_melody",
|
| 1007 |
+
"af_midnight",
|
| 1008 |
+
"af_moon",
|
| 1009 |
+
"af_muse",
|
| 1010 |
+
"af_music",
|
| 1011 |
+
"af_narrator",
|
| 1012 |
+
"af_nightingale",
|
| 1013 |
+
"af_poet",
|
| 1014 |
+
"af_rain",
|
| 1015 |
+
"af_redwood",
|
| 1016 |
+
"af_rewind",
|
| 1017 |
+
"af_river",
|
| 1018 |
+
"af_sage",
|
| 1019 |
+
"af_seashore",
|
| 1020 |
+
"af_shadow",
|
| 1021 |
+
"af_silver",
|
| 1022 |
+
"af_song",
|
| 1023 |
+
"af_starshine",
|
| 1024 |
+
"af_story",
|
| 1025 |
+
"af_summer",
|
| 1026 |
+
"af_sun",
|
| 1027 |
+
"af_thunder",
|
| 1028 |
+
"af_tide",
|
| 1029 |
+
"af_time",
|
| 1030 |
+
"af_valentino",
|
| 1031 |
+
"af_verdant",
|
| 1032 |
+
"af_verse",
|
| 1033 |
+
"af_vibrant",
|
| 1034 |
+
"af_vivid",
|
| 1035 |
+
"af_warmth",
|
| 1036 |
+
"af_whisper",
|
| 1037 |
+
"af_wilderness",
|
| 1038 |
+
"af_willow",
|
| 1039 |
+
"af_winter",
|
| 1040 |
+
"af_wit",
|
| 1041 |
+
"af_witness",
|
| 1042 |
+
"af_wren",
|
| 1043 |
+
"af_writer",
|
| 1044 |
+
"af_zara",
|
| 1045 |
+
"af_zeus",
|
| 1046 |
+
"af_ziggy",
|
| 1047 |
+
"af_zoom",
|
| 1048 |
+
"af_river",
|
| 1049 |
+
"am_michael",
|
| 1050 |
+
"am_fenrir",
|
| 1051 |
+
"am_puck",
|
| 1052 |
+
"am_echo",
|
| 1053 |
+
"am_eric",
|
| 1054 |
+
"am_liam",
|
| 1055 |
+
"am_onyx",
|
| 1056 |
+
"am_santa",
|
| 1057 |
+
"am_adam",
|
| 1058 |
+
],
|
| 1059 |
+
value=settings.tts_voice,
|
| 1060 |
+
label="TTS Voice",
|
| 1061 |
+
info="Select TTS voice (American English voices: af_*, am_*)",
|
| 1062 |
+
)
|
| 1063 |
+
|
| 1064 |
+
tts_speed_slider = gr.Slider(
|
| 1065 |
+
minimum=0.5,
|
| 1066 |
+
maximum=2.0,
|
| 1067 |
+
value=settings.tts_speed,
|
| 1068 |
+
step=0.1,
|
| 1069 |
+
label="TTS Speech Speed",
|
| 1070 |
+
info="Adjust TTS speech speed (0.5x to 2.0x)",
|
| 1071 |
+
)
|
| 1072 |
+
|
| 1073 |
+
gr.Dropdown(
|
| 1074 |
+
choices=["T4", "A10", "A100", "L4", "L40S"],
|
| 1075 |
+
value=settings.tts_gpu or "T4",
|
| 1076 |
+
label="TTS GPU Type",
|
| 1077 |
+
info="Modal GPU type for TTS (T4 is cheapest, A100 is fastest). Note: GPU changes require app restart.",
|
| 1078 |
+
visible=settings.modal_available,
|
| 1079 |
+
interactive=False, # GPU type set at function definition time, requires restart
|
| 1080 |
+
)
|
| 1081 |
+
|
| 1082 |
+
tts_use_llm_polish_checkbox = gr.Checkbox(
|
| 1083 |
+
value=settings.tts_use_llm_polish,
|
| 1084 |
+
label="Use LLM Polish for Audio",
|
| 1085 |
+
info="Apply LLM-based final polish to remove remaining formatting artifacts (costs API calls)",
|
| 1086 |
+
)
|
| 1087 |
+
|
| 1088 |
+
tts_generate_button = gr.Button(
|
| 1089 |
+
"🎵 Generate Audio for Last Response",
|
| 1090 |
+
variant="primary",
|
| 1091 |
+
size="lg",
|
| 1092 |
)
|
| 1093 |
|
| 1094 |
+
tts_status_text = gr.Markdown(
|
| 1095 |
+
"Click the button above to generate audio for the last research response.",
|
| 1096 |
+
elem_classes="tts-status",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1097 |
)
|
| 1098 |
|
| 1099 |
+
# Audio output component (for TTS response)
|
| 1100 |
+
audio_output = gr.Audio(
|
| 1101 |
+
label="🔊 Audio Output",
|
| 1102 |
+
visible=True,
|
|
|
|
|
|
|
|
|
|
| 1103 |
)
|
| 1104 |
|
| 1105 |
+
# TTS on-demand generation handler
|
| 1106 |
+
async def handle_tts_generation(
|
| 1107 |
+
history: list[dict[str, Any]],
|
| 1108 |
+
modal_token_id: str,
|
| 1109 |
+
modal_token_secret: str,
|
| 1110 |
+
voice: str,
|
| 1111 |
+
speed: float,
|
| 1112 |
+
use_llm_polish: bool,
|
| 1113 |
+
) -> tuple[Any | None, str]:
|
| 1114 |
+
"""Generate audio on-demand for the last response.
|
| 1115 |
+
|
| 1116 |
+
Args:
|
| 1117 |
+
history: Chat history
|
| 1118 |
+
modal_token_id: Modal token ID from UI
|
| 1119 |
+
modal_token_secret: Modal token secret from UI
|
| 1120 |
+
voice: TTS voice selection
|
| 1121 |
+
speed: TTS speed
|
| 1122 |
+
use_llm_polish: Enable LLM polish
|
| 1123 |
+
|
| 1124 |
+
Returns:
|
| 1125 |
+
Tuple of (audio_output, status_message)
|
| 1126 |
+
"""
|
| 1127 |
+
from src.services.tts_modal import generate_audio_on_demand
|
| 1128 |
+
|
| 1129 |
+
# Get last assistant message from history
|
| 1130 |
+
# History is a list of tuples: [(user_msg, assistant_msg), ...]
|
| 1131 |
+
if not history:
|
| 1132 |
+
logger.warning("tts_no_history", history=history)
|
| 1133 |
+
return None, "❌ No messages in history to generate audio for"
|
| 1134 |
+
|
| 1135 |
+
# Debug: Log history format
|
| 1136 |
+
logger.info(
|
| 1137 |
+
"tts_history_debug",
|
| 1138 |
+
history_type=type(history).__name__,
|
| 1139 |
+
history_length=len(history) if isinstance(history, list) else 0,
|
| 1140 |
+
first_entry_type=type(history[0]).__name__
|
| 1141 |
+
if isinstance(history, list) and len(history) > 0
|
| 1142 |
+
else None,
|
| 1143 |
+
first_entry_sample=str(history[0])[:200]
|
| 1144 |
+
if isinstance(history, list) and len(history) > 0
|
| 1145 |
+
else None,
|
| 1146 |
+
)
|
| 1147 |
|
| 1148 |
+
# Get the last assistant message (second element of last tuple)
|
| 1149 |
+
last_message = None
|
| 1150 |
+
if isinstance(history, list) and len(history) > 0:
|
| 1151 |
+
last_entry = history[-1]
|
| 1152 |
+
# ChatInterface format: (user_message, assistant_message)
|
| 1153 |
+
if isinstance(last_entry, (tuple, list)) and len(last_entry) >= 2:
|
| 1154 |
+
last_message = last_entry[1]
|
| 1155 |
+
logger.info(
|
| 1156 |
+
"tts_extracted_from_tuple", message_type=type(last_message).__name__
|
| 1157 |
+
)
|
| 1158 |
+
# Dict format: {"role": "assistant", "content": "..."}
|
| 1159 |
+
elif isinstance(last_entry, dict):
|
| 1160 |
+
if last_entry.get("role") == "assistant":
|
| 1161 |
+
content = last_entry.get("content", "")
|
| 1162 |
+
# Content might be a list (multimodal) or string
|
| 1163 |
+
if isinstance(content, list):
|
| 1164 |
+
# Extract text from multimodal content list
|
| 1165 |
+
last_message = " ".join(str(item) for item in content if item)
|
| 1166 |
+
else:
|
| 1167 |
+
last_message = content
|
| 1168 |
+
logger.info(
|
| 1169 |
+
"tts_extracted_from_dict",
|
| 1170 |
+
message_type=type(content).__name__,
|
| 1171 |
+
message_length=len(last_message)
|
| 1172 |
+
if isinstance(last_message, str)
|
| 1173 |
+
else 0,
|
| 1174 |
+
)
|
| 1175 |
+
else:
|
| 1176 |
+
logger.warning(
|
| 1177 |
+
"tts_unknown_format",
|
| 1178 |
+
entry_type=type(last_entry).__name__,
|
| 1179 |
+
entry=str(last_entry)[:200],
|
| 1180 |
+
)
|
| 1181 |
+
|
| 1182 |
+
# Also handle if last_message itself is a list
|
| 1183 |
+
if isinstance(last_message, list):
|
| 1184 |
+
last_message = " ".join(str(item) for item in last_message if item)
|
| 1185 |
+
|
| 1186 |
+
if not last_message or not isinstance(last_message, str) or not last_message.strip():
|
| 1187 |
+
logger.error(
|
| 1188 |
+
"tts_no_message_found",
|
| 1189 |
+
last_message_type=type(last_message).__name__ if last_message else None,
|
| 1190 |
+
last_message_value=str(last_message)[:100] if last_message else None,
|
| 1191 |
)
|
| 1192 |
+
return None, "❌ No assistant response found in history"
|
| 1193 |
+
|
| 1194 |
+
# Generate audio
|
| 1195 |
+
audio_output, status_message = await generate_audio_on_demand(
|
| 1196 |
+
text=last_message,
|
| 1197 |
+
modal_token_id=modal_token_id,
|
| 1198 |
+
modal_token_secret=modal_token_secret,
|
| 1199 |
+
voice=voice,
|
| 1200 |
+
speed=speed,
|
| 1201 |
+
use_llm_polish=use_llm_polish,
|
|
|
|
|
|
|
| 1202 |
)
|
| 1203 |
|
| 1204 |
+
return audio_output, status_message
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
|
| 1206 |
# Chat interface with multimodal support
|
| 1207 |
# Examples are provided but will NOT run at startup (cache_examples=False)
|
| 1208 |
# Users must log in first before using examples or submitting queries
|
| 1209 |
+
chat_interface = gr.ChatInterface(
|
| 1210 |
fn=research_agent,
|
| 1211 |
multimodal=True, # Enable multimodal input (text + images + audio)
|
| 1212 |
title="🔬 The DETERMINATOR",
|
| 1213 |
description=(
|
| 1214 |
+
"*Generalist Deep Research Agent — stops at nothing until finding precise answers*\n\n"
|
| 1215 |
+
"💡 **Quick Start**: Type your research question below. Use 📷 for images, 🎤 for audio.\n\n"
|
| 1216 |
+
"⚠️ **Sign in with HuggingFace** (sidebar) before starting."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1217 |
),
|
| 1218 |
examples=[
|
| 1219 |
# When additional_inputs are provided, examples must be lists of lists
|
|
|
|
| 1276 |
use_graph_checkbox,
|
| 1277 |
enable_image_input_checkbox,
|
| 1278 |
enable_audio_input_checkbox,
|
|
|
|
|
|
|
|
|
|
| 1279 |
web_search_provider_dropdown,
|
| 1280 |
# Note: gr.OAuthToken and gr.OAuthProfile are automatically passed as function parameters
|
| 1281 |
],
|
| 1282 |
cache_examples=False, # Don't cache examples - requires authentication
|
| 1283 |
)
|
| 1284 |
|
| 1285 |
+
# Wire up TTS generation button
|
| 1286 |
+
tts_generate_button.click(
|
| 1287 |
+
fn=handle_tts_generation,
|
| 1288 |
+
inputs=[
|
| 1289 |
+
chat_interface.chatbot, # Get chat history from ChatInterface
|
| 1290 |
+
modal_token_id_input,
|
| 1291 |
+
modal_token_secret_input,
|
| 1292 |
+
tts_voice_dropdown,
|
| 1293 |
+
tts_speed_slider,
|
| 1294 |
+
tts_use_llm_polish_checkbox,
|
| 1295 |
+
],
|
| 1296 |
+
outputs=[audio_output, tts_status_text],
|
| 1297 |
+
)
|
| 1298 |
+
|
| 1299 |
return demo # type: ignore[no-any-return]
|
| 1300 |
|
| 1301 |
|
src/mcp_tools.py
CHANGED
|
@@ -242,7 +242,6 @@ async def extract_text_from_image(
|
|
| 242 |
Extracted text from the image
|
| 243 |
"""
|
| 244 |
from src.services.image_ocr import get_image_ocr_service
|
| 245 |
-
|
| 246 |
from src.utils.config import settings
|
| 247 |
|
| 248 |
try:
|
|
@@ -280,7 +279,6 @@ async def transcribe_audio_file(
|
|
| 280 |
Transcribed text from the audio file
|
| 281 |
"""
|
| 282 |
from src.services.stt_gradio import get_stt_service
|
| 283 |
-
|
| 284 |
from src.utils.config import settings
|
| 285 |
|
| 286 |
try:
|
|
@@ -300,4 +298,4 @@ async def transcribe_audio_file(
|
|
| 300 |
return f"## Audio Transcription\n\n{transcribed_text}"
|
| 301 |
|
| 302 |
except Exception as e:
|
| 303 |
-
return f"Error transcribing audio: {e}"
|
|
|
|
| 242 |
Extracted text from the image
|
| 243 |
"""
|
| 244 |
from src.services.image_ocr import get_image_ocr_service
|
|
|
|
| 245 |
from src.utils.config import settings
|
| 246 |
|
| 247 |
try:
|
|
|
|
| 279 |
Transcribed text from the audio file
|
| 280 |
"""
|
| 281 |
from src.services.stt_gradio import get_stt_service
|
|
|
|
| 282 |
from src.utils.config import settings
|
| 283 |
|
| 284 |
try:
|
|
|
|
| 298 |
return f"## Audio Transcription\n\n{transcribed_text}"
|
| 299 |
|
| 300 |
except Exception as e:
|
| 301 |
+
return f"Error transcribing audio: {e}"
|
src/middleware/state_machine.py
CHANGED
|
@@ -169,14 +169,3 @@ def get_workflow_state() -> WorkflowState:
|
|
| 169 |
logger.debug("Workflow state not found, auto-initializing")
|
| 170 |
return init_workflow_state()
|
| 171 |
return state
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
| 169 |
logger.debug("Workflow state not found, auto-initializing")
|
| 170 |
return init_workflow_state()
|
| 171 |
return state
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/orchestrator/research_flow.py
CHANGED
|
@@ -219,7 +219,9 @@ class IterativeResearchFlow:
|
|
| 219 |
|
| 220 |
# 4. Select tools for next gap
|
| 221 |
next_gap = evaluation.outstanding_gaps[0] if evaluation.outstanding_gaps else query
|
| 222 |
-
selection_plan = await self._select_agents(
|
|
|
|
|
|
|
| 223 |
|
| 224 |
# 5. Execute tools
|
| 225 |
await self._execute_tools(selection_plan.tasks)
|
|
@@ -324,7 +326,10 @@ class IterativeResearchFlow:
|
|
| 324 |
return True
|
| 325 |
|
| 326 |
async def _generate_observations(
|
| 327 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 328 |
) -> str:
|
| 329 |
"""Generate observations from current research state."""
|
| 330 |
# Build input prompt for token estimation
|
|
@@ -364,7 +369,10 @@ ORIGINAL QUERY:
|
|
| 364 |
return observations
|
| 365 |
|
| 366 |
async def _evaluate_gaps(
|
| 367 |
-
self,
|
|
|
|
|
|
|
|
|
|
| 368 |
) -> KnowledgeGapOutput:
|
| 369 |
"""Evaluate knowledge gaps in current research."""
|
| 370 |
if self.start_time:
|
|
@@ -812,7 +820,9 @@ class DeepResearchFlow:
|
|
| 812 |
else:
|
| 813 |
return await self._run_with_chains(query, message_history)
|
| 814 |
|
| 815 |
-
async def _run_with_chains(
|
|
|
|
|
|
|
| 816 |
"""
|
| 817 |
Run the deep research flow using agent chains.
|
| 818 |
|
|
@@ -868,7 +878,9 @@ class DeepResearchFlow:
|
|
| 868 |
|
| 869 |
return final_report
|
| 870 |
|
| 871 |
-
async def _run_with_graph(
|
|
|
|
|
|
|
| 872 |
"""
|
| 873 |
Run the deep research flow using graph execution.
|
| 874 |
|
|
|
|
| 219 |
|
| 220 |
# 4. Select tools for next gap
|
| 221 |
next_gap = evaluation.outstanding_gaps[0] if evaluation.outstanding_gaps else query
|
| 222 |
+
selection_plan = await self._select_agents(
|
| 223 |
+
next_gap, query, background_context, message_history
|
| 224 |
+
)
|
| 225 |
|
| 226 |
# 5. Execute tools
|
| 227 |
await self._execute_tools(selection_plan.tasks)
|
|
|
|
| 326 |
return True
|
| 327 |
|
| 328 |
async def _generate_observations(
|
| 329 |
+
self,
|
| 330 |
+
query: str,
|
| 331 |
+
background_context: str = "",
|
| 332 |
+
message_history: list[ModelMessage] | None = None,
|
| 333 |
) -> str:
|
| 334 |
"""Generate observations from current research state."""
|
| 335 |
# Build input prompt for token estimation
|
|
|
|
| 369 |
return observations
|
| 370 |
|
| 371 |
async def _evaluate_gaps(
|
| 372 |
+
self,
|
| 373 |
+
query: str,
|
| 374 |
+
background_context: str = "",
|
| 375 |
+
message_history: list[ModelMessage] | None = None,
|
| 376 |
) -> KnowledgeGapOutput:
|
| 377 |
"""Evaluate knowledge gaps in current research."""
|
| 378 |
if self.start_time:
|
|
|
|
| 820 |
else:
|
| 821 |
return await self._run_with_chains(query, message_history)
|
| 822 |
|
| 823 |
+
async def _run_with_chains(
|
| 824 |
+
self, query: str, message_history: list[ModelMessage] | None = None
|
| 825 |
+
) -> str:
|
| 826 |
"""
|
| 827 |
Run the deep research flow using agent chains.
|
| 828 |
|
|
|
|
| 878 |
|
| 879 |
return final_report
|
| 880 |
|
| 881 |
+
async def _run_with_graph(
|
| 882 |
+
self, query: str, message_history: list[ModelMessage] | None = None
|
| 883 |
+
) -> str:
|
| 884 |
"""
|
| 885 |
Run the deep research flow using graph execution.
|
| 886 |
|
src/services/audio_processing.py
CHANGED
|
@@ -105,13 +105,14 @@ class AudioService:
|
|
| 105 |
# Refine text for audio (remove markdown, citations, etc.)
|
| 106 |
# Use LLM polish if enabled in settings
|
| 107 |
refined_text = await audio_refiner.refine_for_audio(
|
| 108 |
-
text,
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
)
|
| 111 |
-
logger.info("text_refined_for_audio",
|
| 112 |
-
original_length=len(text),
|
| 113 |
-
refined_length=len(refined_text),
|
| 114 |
-
llm_polish_enabled=settings.tts_use_llm_polish)
|
| 115 |
|
| 116 |
# Use provided voice/speed or fallback to settings defaults
|
| 117 |
voice = voice if voice else settings.tts_voice
|
|
|
|
| 105 |
# Refine text for audio (remove markdown, citations, etc.)
|
| 106 |
# Use LLM polish if enabled in settings
|
| 107 |
refined_text = await audio_refiner.refine_for_audio(
|
| 108 |
+
text, use_llm_polish=settings.tts_use_llm_polish
|
| 109 |
+
)
|
| 110 |
+
logger.info(
|
| 111 |
+
"text_refined_for_audio",
|
| 112 |
+
original_length=len(text),
|
| 113 |
+
refined_length=len(refined_text),
|
| 114 |
+
llm_polish_enabled=settings.tts_use_llm_polish,
|
| 115 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
# Use provided voice/speed or fallback to settings defaults
|
| 118 |
voice = voice if voice else settings.tts_voice
|
src/services/multimodal_processing.py
CHANGED
|
@@ -83,7 +83,9 @@ class MultimodalService:
|
|
| 83 |
# For now, log a warning
|
| 84 |
logger.warning("audio_file_upload_not_supported", file_path=file_path)
|
| 85 |
except Exception as e:
|
| 86 |
-
logger.warning(
|
|
|
|
|
|
|
| 87 |
|
| 88 |
# Add original text if present
|
| 89 |
if text and text.strip():
|
|
@@ -142,7 +144,3 @@ def get_multimodal_service() -> MultimodalService:
|
|
| 142 |
MultimodalService instance
|
| 143 |
"""
|
| 144 |
return MultimodalService()
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
| 83 |
# For now, log a warning
|
| 84 |
logger.warning("audio_file_upload_not_supported", file_path=file_path)
|
| 85 |
except Exception as e:
|
| 86 |
+
logger.warning(
|
| 87 |
+
"audio_file_processing_failed", file_path=file_path, error=str(e)
|
| 88 |
+
)
|
| 89 |
|
| 90 |
# Add original text if present
|
| 91 |
if text and text.strip():
|
|
|
|
| 144 |
MultimodalService instance
|
| 145 |
"""
|
| 146 |
return MultimodalService()
|
|
|
|
|
|
|
|
|
|
|
|
src/services/report_file_service.py
CHANGED
|
@@ -329,4 +329,3 @@ def get_report_file_service() -> ReportFileService:
|
|
| 329 |
return ReportFileService()
|
| 330 |
|
| 331 |
return _get_service()
|
| 332 |
-
|
|
|
|
| 329 |
return ReportFileService()
|
| 330 |
|
| 331 |
return _get_service()
|
|
|
src/services/tts_modal.py
CHANGED
|
@@ -2,15 +2,19 @@
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import os
|
|
|
|
|
|
|
| 5 |
from functools import lru_cache
|
| 6 |
-
from typing import Any
|
| 7 |
|
| 8 |
import numpy as np
|
|
|
|
| 9 |
import structlog
|
| 10 |
|
| 11 |
# Load .env file BEFORE importing Modal SDK
|
| 12 |
# Modal SDK reads MODAL_TOKEN_ID and MODAL_TOKEN_SECRET from environment on import
|
| 13 |
from dotenv import load_dotenv
|
|
|
|
| 14 |
load_dotenv()
|
| 15 |
|
| 16 |
from src.utils.config import settings
|
|
@@ -33,6 +37,60 @@ _tts_function: Any | None = None
|
|
| 33 |
_tts_image: Any | None = None
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _get_modal_app() -> Any:
|
| 37 |
"""Get or create Modal app instance.
|
| 38 |
|
|
@@ -69,7 +127,8 @@ def _get_modal_app() -> Any:
|
|
| 69 |
)
|
| 70 |
|
| 71 |
try:
|
| 72 |
-
|
|
|
|
| 73 |
except Exception as e:
|
| 74 |
error_msg = str(e).lower()
|
| 75 |
if "token" in error_msg or "malformed" in error_msg or "invalid" in error_msg:
|
|
@@ -121,7 +180,7 @@ def _create_tts_function() -> Any:
|
|
| 121 |
|
| 122 |
# Get GPU and timeout from settings (with defaults)
|
| 123 |
gpu_type = getattr(settings, "tts_gpu", None) or "T4"
|
| 124 |
-
timeout_seconds = getattr(settings, "tts_timeout", None) or
|
| 125 |
|
| 126 |
@app.function(
|
| 127 |
image=tts_image,
|
|
@@ -129,7 +188,7 @@ def _create_tts_function() -> Any:
|
|
| 129 |
timeout=timeout_seconds,
|
| 130 |
serialized=True, # Allow function to be defined outside global scope
|
| 131 |
)
|
| 132 |
-
def kokoro_tts_function(text: str, voice: str, speed: float) -> tuple[int, np.
|
| 133 |
"""Modal GPU function for Kokoro TTS.
|
| 134 |
|
| 135 |
This function runs on Modal's GPU infrastructure.
|
|
@@ -170,10 +229,13 @@ def _create_tts_function() -> Any:
|
|
| 170 |
def _setup_modal_function() -> None:
|
| 171 |
"""Setup Modal GPU function for TTS (called once, lazy initialization).
|
| 172 |
|
| 173 |
-
|
| 174 |
-
|
|
|
|
| 175 |
|
| 176 |
-
|
|
|
|
|
|
|
| 177 |
"""
|
| 178 |
global _tts_function
|
| 179 |
|
|
@@ -183,24 +245,38 @@ def _setup_modal_function() -> None:
|
|
| 183 |
try:
|
| 184 |
import modal
|
| 185 |
|
| 186 |
-
#
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
|
|
|
|
|
|
|
|
|
| 193 |
logger.info(
|
| 194 |
-
"
|
| 195 |
app_name="deepcritical-tts",
|
| 196 |
function_name="kokoro_tts_function",
|
|
|
|
| 197 |
)
|
| 198 |
|
| 199 |
except Exception as e:
|
| 200 |
logger.error("modal_tts_function_setup_failed", error=str(e))
|
| 201 |
raise ConfigurationError(
|
| 202 |
-
f"Failed to
|
| 203 |
-
"
|
| 204 |
) from e
|
| 205 |
|
| 206 |
|
|
@@ -233,8 +309,8 @@ class ModalTTSExecutor:
|
|
| 233 |
text: str,
|
| 234 |
voice: str = "af_heart",
|
| 235 |
speed: float = 1.0,
|
| 236 |
-
timeout: int =
|
| 237 |
-
) -> tuple[int, np.
|
| 238 |
"""Synthesize text to speech using Kokoro on Modal GPU.
|
| 239 |
|
| 240 |
Args:
|
|
@@ -259,7 +335,7 @@ class ModalTTSExecutor:
|
|
| 259 |
|
| 260 |
try:
|
| 261 |
# Call the GPU function remotely
|
| 262 |
-
result = _tts_function.remote(text, voice, speed)
|
| 263 |
|
| 264 |
logger.info(
|
| 265 |
"tts_synthesis_complete", sample_rate=result[0], audio_shape=result[1].shape
|
|
@@ -296,7 +372,7 @@ class TTSService:
|
|
| 296 |
text: str,
|
| 297 |
voice: str = "af_heart",
|
| 298 |
speed: float = 1.0,
|
| 299 |
-
) -> tuple[int, np.
|
| 300 |
"""Async wrapper for TTS synthesis.
|
| 301 |
|
| 302 |
Args:
|
|
@@ -334,3 +410,73 @@ def get_tts_service() -> TTSService:
|
|
| 334 |
ConfigurationError: If Modal credentials not configured
|
| 335 |
"""
|
| 336 |
return TTSService()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
import os
|
| 5 |
+
from collections.abc import Iterator
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
from functools import lru_cache
|
| 8 |
+
from typing import Any, cast
|
| 9 |
|
| 10 |
import numpy as np
|
| 11 |
+
from numpy.typing import NDArray
|
| 12 |
import structlog
|
| 13 |
|
| 14 |
# Load .env file BEFORE importing Modal SDK
|
| 15 |
# Modal SDK reads MODAL_TOKEN_ID and MODAL_TOKEN_SECRET from environment on import
|
| 16 |
from dotenv import load_dotenv
|
| 17 |
+
|
| 18 |
load_dotenv()
|
| 19 |
|
| 20 |
from src.utils.config import settings
|
|
|
|
| 37 |
_tts_image: Any | None = None
|
| 38 |
|
| 39 |
|
| 40 |
+
@contextmanager
|
| 41 |
+
def modal_credentials_override(token_id: str | None, token_secret: str | None) -> Iterator[None]:
|
| 42 |
+
"""Context manager to temporarily override Modal credentials.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
token_id: Modal token ID (overrides env if provided)
|
| 46 |
+
token_secret: Modal token secret (overrides env if provided)
|
| 47 |
+
|
| 48 |
+
Yields:
|
| 49 |
+
None
|
| 50 |
+
|
| 51 |
+
Note:
|
| 52 |
+
Resets global Modal state to force re-initialization with new credentials.
|
| 53 |
+
"""
|
| 54 |
+
global _modal_app, _tts_function
|
| 55 |
+
|
| 56 |
+
# Save original credentials
|
| 57 |
+
original_token_id = os.environ.get("MODAL_TOKEN_ID")
|
| 58 |
+
original_token_secret = os.environ.get("MODAL_TOKEN_SECRET")
|
| 59 |
+
|
| 60 |
+
# Save original Modal state
|
| 61 |
+
original_app = _modal_app
|
| 62 |
+
original_function = _tts_function
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
# Override environment variables if provided
|
| 66 |
+
if token_id:
|
| 67 |
+
os.environ["MODAL_TOKEN_ID"] = token_id
|
| 68 |
+
if token_secret:
|
| 69 |
+
os.environ["MODAL_TOKEN_SECRET"] = token_secret
|
| 70 |
+
|
| 71 |
+
# Reset Modal state to force re-initialization
|
| 72 |
+
_modal_app = None
|
| 73 |
+
_tts_function = None
|
| 74 |
+
|
| 75 |
+
yield
|
| 76 |
+
|
| 77 |
+
finally:
|
| 78 |
+
# Restore original credentials
|
| 79 |
+
if original_token_id is not None:
|
| 80 |
+
os.environ["MODAL_TOKEN_ID"] = original_token_id
|
| 81 |
+
elif "MODAL_TOKEN_ID" in os.environ:
|
| 82 |
+
del os.environ["MODAL_TOKEN_ID"]
|
| 83 |
+
|
| 84 |
+
if original_token_secret is not None:
|
| 85 |
+
os.environ["MODAL_TOKEN_SECRET"] = original_token_secret
|
| 86 |
+
elif "MODAL_TOKEN_SECRET" in os.environ:
|
| 87 |
+
del os.environ["MODAL_TOKEN_SECRET"]
|
| 88 |
+
|
| 89 |
+
# Restore original Modal state
|
| 90 |
+
_modal_app = original_app
|
| 91 |
+
_tts_function = original_function
|
| 92 |
+
|
| 93 |
+
|
| 94 |
def _get_modal_app() -> Any:
|
| 95 |
"""Get or create Modal app instance.
|
| 96 |
|
|
|
|
| 127 |
)
|
| 128 |
|
| 129 |
try:
|
| 130 |
+
# Use lookup with create_if_missing for inline function fallback
|
| 131 |
+
_modal_app = modal.App.lookup("deepcritical-tts", create_if_missing=True)
|
| 132 |
except Exception as e:
|
| 133 |
error_msg = str(e).lower()
|
| 134 |
if "token" in error_msg or "malformed" in error_msg or "invalid" in error_msg:
|
|
|
|
| 180 |
|
| 181 |
# Get GPU and timeout from settings (with defaults)
|
| 182 |
gpu_type = getattr(settings, "tts_gpu", None) or "T4"
|
| 183 |
+
timeout_seconds = getattr(settings, "tts_timeout", None) or 120 # 2 minutes for cold starts
|
| 184 |
|
| 185 |
@app.function(
|
| 186 |
image=tts_image,
|
|
|
|
| 188 |
timeout=timeout_seconds,
|
| 189 |
serialized=True, # Allow function to be defined outside global scope
|
| 190 |
)
|
| 191 |
+
def kokoro_tts_function(text: str, voice: str, speed: float) -> tuple[int, NDArray[np.float32]]:
|
| 192 |
"""Modal GPU function for Kokoro TTS.
|
| 193 |
|
| 194 |
This function runs on Modal's GPU infrastructure.
|
|
|
|
| 229 |
def _setup_modal_function() -> None:
|
| 230 |
"""Setup Modal GPU function for TTS (called once, lazy initialization).
|
| 231 |
|
| 232 |
+
Hybrid approach:
|
| 233 |
+
1. Try to lookup pre-deployed function (fast path for advanced users)
|
| 234 |
+
2. If lookup fails, create function inline (fallback for casual users)
|
| 235 |
|
| 236 |
+
This allows both workflows:
|
| 237 |
+
- Advanced: Deploy with `modal deploy deployments/modal_tts.py` for best performance
|
| 238 |
+
- Casual: Just add Modal keys and it auto-creates function on first use
|
| 239 |
"""
|
| 240 |
global _tts_function
|
| 241 |
|
|
|
|
| 245 |
try:
|
| 246 |
import modal
|
| 247 |
|
| 248 |
+
# Try path 1: Lookup pre-deployed function (fast path)
|
| 249 |
+
try:
|
| 250 |
+
_tts_function = modal.Function.from_name("deepcritical-tts", "kokoro_tts_function")
|
| 251 |
+
logger.info(
|
| 252 |
+
"modal_tts_function_lookup_success",
|
| 253 |
+
app_name="deepcritical-tts",
|
| 254 |
+
function_name="kokoro_tts_function",
|
| 255 |
+
method="lookup",
|
| 256 |
+
)
|
| 257 |
+
return
|
| 258 |
+
except Exception as lookup_error:
|
| 259 |
+
logger.info(
|
| 260 |
+
"modal_tts_function_lookup_failed",
|
| 261 |
+
error=str(lookup_error),
|
| 262 |
+
fallback="Creating function inline",
|
| 263 |
+
)
|
| 264 |
|
| 265 |
+
# Try path 2: Create function inline (fallback for casual users)
|
| 266 |
+
logger.info("modal_tts_creating_inline_function")
|
| 267 |
+
_tts_function = _create_tts_function()
|
| 268 |
logger.info(
|
| 269 |
+
"modal_tts_function_setup_complete",
|
| 270 |
app_name="deepcritical-tts",
|
| 271 |
function_name="kokoro_tts_function",
|
| 272 |
+
method="inline",
|
| 273 |
)
|
| 274 |
|
| 275 |
except Exception as e:
|
| 276 |
logger.error("modal_tts_function_setup_failed", error=str(e))
|
| 277 |
raise ConfigurationError(
|
| 278 |
+
f"Failed to setup Modal TTS function: {e}. "
|
| 279 |
+
"Ensure Modal credentials (MODAL_TOKEN_ID, MODAL_TOKEN_SECRET) are valid."
|
| 280 |
) from e
|
| 281 |
|
| 282 |
|
|
|
|
| 309 |
text: str,
|
| 310 |
voice: str = "af_heart",
|
| 311 |
speed: float = 1.0,
|
| 312 |
+
timeout: int = 120,
|
| 313 |
+
) -> tuple[int, NDArray[np.float32]]:
|
| 314 |
"""Synthesize text to speech using Kokoro on Modal GPU.
|
| 315 |
|
| 316 |
Args:
|
|
|
|
| 335 |
|
| 336 |
try:
|
| 337 |
# Call the GPU function remotely
|
| 338 |
+
result = cast(tuple[int, NDArray[np.float32]], _tts_function.remote(text, voice, speed))
|
| 339 |
|
| 340 |
logger.info(
|
| 341 |
"tts_synthesis_complete", sample_rate=result[0], audio_shape=result[1].shape
|
|
|
|
| 372 |
text: str,
|
| 373 |
voice: str = "af_heart",
|
| 374 |
speed: float = 1.0,
|
| 375 |
+
) -> tuple[int, NDArray[np.float32]] | None:
|
| 376 |
"""Async wrapper for TTS synthesis.
|
| 377 |
|
| 378 |
Args:
|
|
|
|
| 410 |
ConfigurationError: If Modal credentials not configured
|
| 411 |
"""
|
| 412 |
return TTSService()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
async def generate_audio_on_demand(
|
| 416 |
+
text: str,
|
| 417 |
+
modal_token_id: str | None = None,
|
| 418 |
+
modal_token_secret: str | None = None,
|
| 419 |
+
voice: str = "af_heart",
|
| 420 |
+
speed: float = 1.0,
|
| 421 |
+
use_llm_polish: bool = False,
|
| 422 |
+
) -> tuple[tuple[int, NDArray[np.float32]] | None, str]:
|
| 423 |
+
"""Generate audio on-demand with optional runtime credentials.
|
| 424 |
+
|
| 425 |
+
Args:
|
| 426 |
+
text: Text to synthesize
|
| 427 |
+
modal_token_id: Modal token ID (UI input, overrides .env)
|
| 428 |
+
modal_token_secret: Modal token secret (UI input, overrides .env)
|
| 429 |
+
voice: Voice ID (default: af_heart)
|
| 430 |
+
speed: Speech speed (default: 1.0)
|
| 431 |
+
use_llm_polish: Apply LLM polish to text (default: False)
|
| 432 |
+
|
| 433 |
+
Returns:
|
| 434 |
+
Tuple of (audio_output, status_message)
|
| 435 |
+
- audio_output: (sample_rate, audio_array) or None if failed
|
| 436 |
+
- status_message: Status/error message for user
|
| 437 |
+
|
| 438 |
+
Priority: UI credentials > .env credentials
|
| 439 |
+
"""
|
| 440 |
+
# Priority: UI keys > .env keys
|
| 441 |
+
token_id = (modal_token_id or "").strip() or os.getenv("MODAL_TOKEN_ID")
|
| 442 |
+
token_secret = (modal_token_secret or "").strip() or os.getenv("MODAL_TOKEN_SECRET")
|
| 443 |
+
|
| 444 |
+
if not token_id or not token_secret:
|
| 445 |
+
return (
|
| 446 |
+
None,
|
| 447 |
+
"❌ Modal credentials required. Enter keys above or set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET in .env",
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
# Use credentials override context
|
| 452 |
+
with modal_credentials_override(token_id, token_secret):
|
| 453 |
+
# Import audio_processing here to avoid circular import
|
| 454 |
+
from src.services.audio_processing import AudioService
|
| 455 |
+
|
| 456 |
+
# Temporarily override LLM polish setting
|
| 457 |
+
original_llm_polish = settings.tts_use_llm_polish
|
| 458 |
+
try:
|
| 459 |
+
settings.tts_use_llm_polish = use_llm_polish
|
| 460 |
+
|
| 461 |
+
# Create fresh AudioService instance (bypass cache to pick up new credentials)
|
| 462 |
+
audio_service = AudioService()
|
| 463 |
+
audio_output = await audio_service.generate_audio_output(
|
| 464 |
+
text=text,
|
| 465 |
+
voice=voice,
|
| 466 |
+
speed=speed,
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
if audio_output:
|
| 470 |
+
return audio_output, "✅ Audio generated successfully"
|
| 471 |
+
else:
|
| 472 |
+
return None, "⚠️ Audio generation returned no output"
|
| 473 |
+
|
| 474 |
+
finally:
|
| 475 |
+
settings.tts_use_llm_polish = original_llm_polish
|
| 476 |
+
|
| 477 |
+
except ConfigurationError as e:
|
| 478 |
+
logger.error("audio_generation_config_error", error=str(e))
|
| 479 |
+
return None, f"❌ Configuration error: {e}"
|
| 480 |
+
except Exception as e:
|
| 481 |
+
logger.error("audio_generation_failed", error=str(e), exc_info=True)
|
| 482 |
+
return None, f"❌ Audio generation failed: {e}"
|
src/tools/crawl_adapter.py
CHANGED
|
@@ -56,8 +56,3 @@ async def crawl_website(starting_url: str) -> str:
|
|
| 56 |
except Exception as e:
|
| 57 |
logger.error("Crawl failed", error=str(e), url=starting_url)
|
| 58 |
return f"Error crawling website: {e!s}"
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 56 |
except Exception as e:
|
| 57 |
logger.error("Crawl failed", error=str(e), url=starting_url)
|
| 58 |
return f"Error crawling website: {e!s}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/search_handler.py
CHANGED
|
@@ -5,11 +5,11 @@ from typing import TYPE_CHECKING, cast
|
|
| 5 |
|
| 6 |
import structlog
|
| 7 |
|
|
|
|
| 8 |
from src.tools.base import SearchTool
|
| 9 |
from src.tools.rag_tool import create_rag_tool
|
| 10 |
from src.utils.exceptions import ConfigurationError, SearchError
|
| 11 |
from src.utils.models import Evidence, SearchResult, SourceName
|
| 12 |
-
from src.services.neo4j_service import get_neo4j_service
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
| 15 |
from src.services.llamaindex_rag import LlamaIndexRAGService
|
|
@@ -133,7 +133,15 @@ class SearchHandler:
|
|
| 133 |
|
| 134 |
# Map tool.name to SourceName (handle tool names that don't match SourceName literals)
|
| 135 |
tool_name = tool_name_to_source.get(tool.name, cast(SourceName, tool.name))
|
| 136 |
-
if tool_name not in [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
logger.warning(
|
| 138 |
"Tool name not in SourceName literals, defaulting to 'web'",
|
| 139 |
tool_name=tool.name,
|
|
@@ -175,18 +183,20 @@ class SearchHandler:
|
|
| 175 |
disease = query
|
| 176 |
if "for" in query.lower():
|
| 177 |
disease = query.split("for")[-1].strip().rstrip("?")
|
| 178 |
-
|
| 179 |
# Convert Evidence objects to dicts for Neo4j
|
| 180 |
papers = []
|
| 181 |
for ev in all_evidence:
|
| 182 |
-
papers.append(
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
| 190 |
stats = neo4j_service.ingest_search_results(disease, papers)
|
| 191 |
logger.info("💾 Saved to Neo4j", stats=stats)
|
| 192 |
except Exception as e:
|
|
|
|
| 5 |
|
| 6 |
import structlog
|
| 7 |
|
| 8 |
+
from src.services.neo4j_service import get_neo4j_service
|
| 9 |
from src.tools.base import SearchTool
|
| 10 |
from src.tools.rag_tool import create_rag_tool
|
| 11 |
from src.utils.exceptions import ConfigurationError, SearchError
|
| 12 |
from src.utils.models import Evidence, SearchResult, SourceName
|
|
|
|
| 13 |
|
| 14 |
if TYPE_CHECKING:
|
| 15 |
from src.services.llamaindex_rag import LlamaIndexRAGService
|
|
|
|
| 133 |
|
| 134 |
# Map tool.name to SourceName (handle tool names that don't match SourceName literals)
|
| 135 |
tool_name = tool_name_to_source.get(tool.name, cast(SourceName, tool.name))
|
| 136 |
+
if tool_name not in [
|
| 137 |
+
"pubmed",
|
| 138 |
+
"clinicaltrials",
|
| 139 |
+
"biorxiv",
|
| 140 |
+
"europepmc",
|
| 141 |
+
"preprint",
|
| 142 |
+
"rag",
|
| 143 |
+
"web",
|
| 144 |
+
]:
|
| 145 |
logger.warning(
|
| 146 |
"Tool name not in SourceName literals, defaulting to 'web'",
|
| 147 |
tool_name=tool.name,
|
|
|
|
| 183 |
disease = query
|
| 184 |
if "for" in query.lower():
|
| 185 |
disease = query.split("for")[-1].strip().rstrip("?")
|
| 186 |
+
|
| 187 |
# Convert Evidence objects to dicts for Neo4j
|
| 188 |
papers = []
|
| 189 |
for ev in all_evidence:
|
| 190 |
+
papers.append(
|
| 191 |
+
{
|
| 192 |
+
"id": ev.citation.url or "",
|
| 193 |
+
"title": ev.citation.title or "",
|
| 194 |
+
"abstract": ev.content,
|
| 195 |
+
"url": ev.citation.url or "",
|
| 196 |
+
"source": ev.citation.source,
|
| 197 |
+
}
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
stats = neo4j_service.ingest_search_results(disease, papers)
|
| 201 |
logger.info("💾 Saved to Neo4j", stats=stats)
|
| 202 |
except Exception as e:
|
src/tools/searchxng_web_search.py
CHANGED
|
@@ -89,7 +89,7 @@ class SearchXNGWebSearchTool:
|
|
| 89 |
title = result.title
|
| 90 |
if len(title) > 500:
|
| 91 |
title = title[:497] + "..."
|
| 92 |
-
|
| 93 |
ev = Evidence(
|
| 94 |
content=result.text,
|
| 95 |
citation=Citation(
|
|
@@ -118,18 +118,3 @@ class SearchXNGWebSearchTool:
|
|
| 118 |
except Exception as e:
|
| 119 |
logger.error("Unexpected error in SearchXNG search", error=str(e), query=final_query)
|
| 120 |
raise SearchError(f"SearchXNG search failed: {e}") from e
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 89 |
title = result.title
|
| 90 |
if len(title) > 500:
|
| 91 |
title = title[:497] + "..."
|
| 92 |
+
|
| 93 |
ev = Evidence(
|
| 94 |
content=result.text,
|
| 95 |
citation=Citation(
|
|
|
|
| 118 |
except Exception as e:
|
| 119 |
logger.error("Unexpected error in SearchXNG search", error=str(e), query=final_query)
|
| 120 |
raise SearchError(f"SearchXNG search failed: {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/serper_web_search.py
CHANGED
|
@@ -89,7 +89,7 @@ class SerperWebSearchTool:
|
|
| 89 |
title = result.title
|
| 90 |
if len(title) > 500:
|
| 91 |
title = title[:497] + "..."
|
| 92 |
-
|
| 93 |
ev = Evidence(
|
| 94 |
content=result.text,
|
| 95 |
citation=Citation(
|
|
@@ -118,18 +118,3 @@ class SerperWebSearchTool:
|
|
| 118 |
except Exception as e:
|
| 119 |
logger.error("Unexpected error in Serper search", error=str(e), query=final_query)
|
| 120 |
raise SearchError(f"Serper search failed: {e}") from e
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 89 |
title = result.title
|
| 90 |
if len(title) > 500:
|
| 91 |
title = title[:497] + "..."
|
| 92 |
+
|
| 93 |
ev = Evidence(
|
| 94 |
content=result.text,
|
| 95 |
citation=Citation(
|
|
|
|
| 118 |
except Exception as e:
|
| 119 |
logger.error("Unexpected error in Serper search", error=str(e), query=final_query)
|
| 120 |
raise SearchError(f"Serper search failed: {e}") from e
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/tool_executor.py
CHANGED
|
@@ -182,9 +182,9 @@ async def execute_tool_tasks(
|
|
| 182 |
results[f"{task.agent}_{i}"] = ToolAgentOutput(output=f"Error: {result!s}", sources=[])
|
| 183 |
else:
|
| 184 |
# Type narrowing: result is ToolAgentOutput after Exception check
|
| 185 |
-
assert isinstance(
|
| 186 |
-
|
| 187 |
-
)
|
| 188 |
key = f"{task.agent}_{task.gap or i}" if task.gap else f"{task.agent}_{i}"
|
| 189 |
results[key] = result
|
| 190 |
|
|
|
|
| 182 |
results[f"{task.agent}_{i}"] = ToolAgentOutput(output=f"Error: {result!s}", sources=[])
|
| 183 |
else:
|
| 184 |
# Type narrowing: result is ToolAgentOutput after Exception check
|
| 185 |
+
assert isinstance(result, ToolAgentOutput), (
|
| 186 |
+
"Expected ToolAgentOutput after Exception check"
|
| 187 |
+
)
|
| 188 |
key = f"{task.agent}_{task.gap or i}" if task.gap else f"{task.agent}_{i}"
|
| 189 |
results[key] = result
|
| 190 |
|
src/tools/vendored/__init__.py
CHANGED
|
@@ -16,12 +16,12 @@ from src.tools.vendored.web_search_core import (
|
|
| 16 |
__all__ = [
|
| 17 |
"CONTENT_LENGTH_LIMIT",
|
| 18 |
"ScrapeResult",
|
| 19 |
-
"WebpageSnippet",
|
| 20 |
-
"SerperClient",
|
| 21 |
"SearchXNGClient",
|
| 22 |
-
"
|
|
|
|
|
|
|
| 23 |
"fetch_and_process_url",
|
| 24 |
"html_to_text",
|
| 25 |
"is_valid_url",
|
| 26 |
-
"
|
| 27 |
]
|
|
|
|
| 16 |
__all__ = [
|
| 17 |
"CONTENT_LENGTH_LIMIT",
|
| 18 |
"ScrapeResult",
|
|
|
|
|
|
|
| 19 |
"SearchXNGClient",
|
| 20 |
+
"SerperClient",
|
| 21 |
+
"WebpageSnippet",
|
| 22 |
+
"crawl_website",
|
| 23 |
"fetch_and_process_url",
|
| 24 |
"html_to_text",
|
| 25 |
"is_valid_url",
|
| 26 |
+
"scrape_urls",
|
| 27 |
]
|
src/tools/web_search.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
import structlog
|
|
|
|
| 6 |
try:
|
| 7 |
from ddgs import DDGS # New package name
|
| 8 |
except ImportError:
|
|
@@ -59,7 +60,7 @@ class WebSearchTool:
|
|
| 59 |
title = r.get("title", "No Title")
|
| 60 |
if len(title) > 500:
|
| 61 |
title = title[:497] + "..."
|
| 62 |
-
|
| 63 |
ev = Evidence(
|
| 64 |
content=r.get("body", ""),
|
| 65 |
citation=Citation(
|
|
|
|
| 3 |
import asyncio
|
| 4 |
|
| 5 |
import structlog
|
| 6 |
+
|
| 7 |
try:
|
| 8 |
from ddgs import DDGS # New package name
|
| 9 |
except ImportError:
|
|
|
|
| 60 |
title = r.get("title", "No Title")
|
| 61 |
if len(title) > 500:
|
| 62 |
title = title[:497] + "..."
|
| 63 |
+
|
| 64 |
ev = Evidence(
|
| 65 |
content=r.get("body", ""),
|
| 66 |
citation=Citation(
|
src/tools/web_search_adapter.py
CHANGED
|
@@ -53,11 +53,3 @@ async def web_search(query: str) -> str:
|
|
| 53 |
except Exception as e:
|
| 54 |
logger.error("Web search failed", error=str(e), query=query)
|
| 55 |
return f"Error performing web search: {e!s}"
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 53 |
except Exception as e:
|
| 54 |
logger.error("Web search failed", error=str(e), query=query)
|
| 55 |
return f"Error performing web search: {e!s}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/tools/web_search_factory.py
CHANGED
|
@@ -50,7 +50,7 @@ def create_web_search_tool(provider: str | None = None) -> SearchTool | None:
|
|
| 50 |
"Failed to initialize Serper, falling back",
|
| 51 |
error=str(e),
|
| 52 |
)
|
| 53 |
-
|
| 54 |
# Try SearchXNG as second choice
|
| 55 |
if settings.searchxng_host:
|
| 56 |
try:
|
|
@@ -64,7 +64,7 @@ def create_web_search_tool(provider: str | None = None) -> SearchTool | None:
|
|
| 64 |
"Failed to initialize SearchXNG, falling back",
|
| 65 |
error=str(e),
|
| 66 |
)
|
| 67 |
-
|
| 68 |
# Fall back to DuckDuckGo
|
| 69 |
if provider == "auto":
|
| 70 |
logger.info(
|
|
@@ -113,18 +113,3 @@ def create_web_search_tool(provider: str | None = None) -> SearchTool | None:
|
|
| 113 |
except Exception as e:
|
| 114 |
logger.error("Unexpected error creating web search tool", error=str(e), provider=provider)
|
| 115 |
return None
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
| 50 |
"Failed to initialize Serper, falling back",
|
| 51 |
error=str(e),
|
| 52 |
)
|
| 53 |
+
|
| 54 |
# Try SearchXNG as second choice
|
| 55 |
if settings.searchxng_host:
|
| 56 |
try:
|
|
|
|
| 64 |
"Failed to initialize SearchXNG, falling back",
|
| 65 |
error=str(e),
|
| 66 |
)
|
| 67 |
+
|
| 68 |
# Fall back to DuckDuckGo
|
| 69 |
if provider == "auto":
|
| 70 |
logger.info(
|
|
|
|
| 113 |
except Exception as e:
|
| 114 |
logger.error("Unexpected error creating web search tool", error=str(e), provider=provider)
|
| 115 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/utils/config.py
CHANGED
|
@@ -77,9 +77,11 @@ class Settings(BaseSettings):
|
|
| 77 |
)
|
| 78 |
|
| 79 |
# Web Search Configuration
|
| 80 |
-
web_search_provider: Literal["serper", "searchxng", "brave", "tavily", "duckduckgo", "auto"] =
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
| 83 |
)
|
| 84 |
serper_api_key: str | None = Field(default=None, description="Serper API key for Google search")
|
| 85 |
searchxng_host: str | None = Field(default=None, description="SearchXNG host URL")
|
|
@@ -284,10 +286,10 @@ class Settings(BaseSettings):
|
|
| 284 |
|
| 285 |
def get_hf_fallback_models_list(self) -> list[str]:
|
| 286 |
"""Get the list of fallback models as a list.
|
| 287 |
-
|
| 288 |
Parses the comma-separated HF_FALLBACK_MODELS string into a list,
|
| 289 |
stripping whitespace from each model ID.
|
| 290 |
-
|
| 291 |
Returns:
|
| 292 |
List of model IDs
|
| 293 |
"""
|
|
|
|
| 77 |
)
|
| 78 |
|
| 79 |
# Web Search Configuration
|
| 80 |
+
web_search_provider: Literal["serper", "searchxng", "brave", "tavily", "duckduckgo", "auto"] = (
|
| 81 |
+
Field(
|
| 82 |
+
default="auto",
|
| 83 |
+
description="Web search provider to use. 'auto' will auto-detect best available (prefers Serper > SearchXNG > DuckDuckGo)",
|
| 84 |
+
)
|
| 85 |
)
|
| 86 |
serper_api_key: str | None = Field(default=None, description="Serper API key for Google search")
|
| 87 |
searchxng_host: str | None = Field(default=None, description="SearchXNG host URL")
|
|
|
|
| 286 |
|
| 287 |
def get_hf_fallback_models_list(self) -> list[str]:
|
| 288 |
"""Get the list of fallback models as a list.
|
| 289 |
+
|
| 290 |
Parses the comma-separated HF_FALLBACK_MODELS string into a list,
|
| 291 |
stripping whitespace from each model ID.
|
| 292 |
+
|
| 293 |
Returns:
|
| 294 |
List of model IDs
|
| 295 |
"""
|
src/utils/llm_factory.py
CHANGED
|
@@ -102,7 +102,7 @@ def get_chat_client_for_agent(oauth_token: str | None = None) -> Any:
|
|
| 102 |
"""
|
| 103 |
# Check if we have OAuth token or env vars
|
| 104 |
has_hf_key = bool(oauth_token or settings.has_huggingface_key)
|
| 105 |
-
|
| 106 |
# Prefer HuggingFace if available (free tier)
|
| 107 |
if has_hf_key:
|
| 108 |
return get_huggingface_chat_client(oauth_token=oauth_token)
|
|
|
|
| 102 |
"""
|
| 103 |
# Check if we have OAuth token or env vars
|
| 104 |
has_hf_key = bool(oauth_token or settings.has_huggingface_key)
|
| 105 |
+
|
| 106 |
# Prefer HuggingFace if available (free tier)
|
| 107 |
if has_hf_key:
|
| 108 |
return get_huggingface_chat_client(oauth_token=oauth_token)
|
src/utils/models.py
CHANGED
|
@@ -6,7 +6,9 @@ from typing import Any, ClassVar, Literal
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
|
| 8 |
# Centralized source type - add new sources here (e.g., "biorxiv" in Phase 11)
|
| 9 |
-
SourceName = Literal[
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
class Citation(BaseModel):
|
|
|
|
| 6 |
from pydantic import BaseModel, Field
|
| 7 |
|
| 8 |
# Centralized source type - add new sources here (e.g., "biorxiv" in Phase 11)
|
| 9 |
+
SourceName = Literal[
|
| 10 |
+
"pubmed", "clinicaltrials", "biorxiv", "europepmc", "preprint", "rag", "web", "neo4j"
|
| 11 |
+
]
|
| 12 |
|
| 13 |
|
| 14 |
class Citation(BaseModel):
|