Upload 89 files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- app/__init__.py +3 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/config.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/agents/__init__.py +1 -0
- app/agents/__pycache__/__init__.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_decision.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_dom.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_fastpath.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_search.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_stealth.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_tools.cpython-311.pyc +0 -0
- app/agents/__pycache__/browser_visual.cpython-311.pyc +0 -0
- app/agents/__pycache__/deep_research.cpython-311.pyc +0 -0
- app/agents/__pycache__/flaresolverr.cpython-311.pyc +0 -0
- app/agents/__pycache__/heavy_search.cpython-311.pyc +0 -0
- app/agents/__pycache__/llm_client.cpython-311.pyc +0 -0
- app/agents/__pycache__/planner.cpython-311.pyc +0 -0
- app/agents/__pycache__/synthesizer.cpython-311.pyc +0 -0
- app/agents/__pycache__/tooling.cpython-311.pyc +0 -0
- app/agents/browser_decision.py +216 -0
- app/agents/browser_dom.py +142 -0
- app/agents/browser_search.py +56 -0
- app/agents/browser_stealth.py +312 -0
- app/agents/browser_tools.py +237 -0
- app/agents/browser_visual.py +293 -0
- app/agents/deep_research.py +236 -0
- app/agents/flaresolverr.py +128 -0
- app/agents/graph/__init__.py +1 -0
- app/agents/graph/__pycache__/__init__.cpython-311.pyc +0 -0
- app/agents/graph/__pycache__/nodes.cpython-311.pyc +0 -0
- app/agents/graph/__pycache__/runner.cpython-311.pyc +0 -0
- app/agents/graph/__pycache__/simple_agent.cpython-311.pyc +0 -0
- app/agents/graph/__pycache__/state.cpython-311.pyc +0 -0
- app/agents/graph/nodes.py +338 -0
- app/agents/graph/runner.py +133 -0
- app/agents/graph/simple_agent.py +321 -0
- app/agents/graph/state.py +187 -0
- app/agents/heavy_search.py +192 -0
- app/agents/llm_client.py +522 -0
- app/agents/planner.py +133 -0
- app/agents/synthesizer.py +173 -0
- app/agents/tooling.py +221 -0
- app/api/__init__.py +1 -0
- app/api/__pycache__/__init__.cpython-311.pyc +0 -0
- app/api/__pycache__/schemas.cpython-311.pyc +0 -0
- app/api/routes/__init__.py +1 -0
- app/api/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- app/api/routes/__pycache__/search.cpython-311.pyc +0 -0
- app/api/routes/search.py +579 -0
app/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lancer - Advanced AI Search API"""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.1.0"
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (196 Bytes). View file
|
|
|
app/__pycache__/config.cpython-311.pyc
ADDED
|
Binary file (2.68 kB). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (2.91 kB). View file
|
|
|
app/agents/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Agents module."""
|
app/agents/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
app/agents/__pycache__/browser_decision.cpython-311.pyc
ADDED
|
Binary file (8.86 kB). View file
|
|
|
app/agents/__pycache__/browser_dom.cpython-311.pyc
ADDED
|
Binary file (7.18 kB). View file
|
|
|
app/agents/__pycache__/browser_fastpath.cpython-311.pyc
ADDED
|
Binary file (7.33 kB). View file
|
|
|
app/agents/__pycache__/browser_search.cpython-311.pyc
ADDED
|
Binary file (3.05 kB). View file
|
|
|
app/agents/__pycache__/browser_stealth.cpython-311.pyc
ADDED
|
Binary file (15.1 kB). View file
|
|
|
app/agents/__pycache__/browser_tools.cpython-311.pyc
ADDED
|
Binary file (11.3 kB). View file
|
|
|
app/agents/__pycache__/browser_visual.cpython-311.pyc
ADDED
|
Binary file (15.8 kB). View file
|
|
|
app/agents/__pycache__/deep_research.cpython-311.pyc
ADDED
|
Binary file (10.3 kB). View file
|
|
|
app/agents/__pycache__/flaresolverr.cpython-311.pyc
ADDED
|
Binary file (5.72 kB). View file
|
|
|
app/agents/__pycache__/heavy_search.cpython-311.pyc
ADDED
|
Binary file (9.03 kB). View file
|
|
|
app/agents/__pycache__/llm_client.cpython-311.pyc
ADDED
|
Binary file (22.7 kB). View file
|
|
|
app/agents/__pycache__/planner.cpython-311.pyc
ADDED
|
Binary file (5.73 kB). View file
|
|
|
app/agents/__pycache__/synthesizer.cpython-311.pyc
ADDED
|
Binary file (6.75 kB). View file
|
|
|
app/agents/__pycache__/tooling.cpython-311.pyc
ADDED
|
Binary file (11.7 kB). View file
|
|
|
app/agents/browser_decision.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared decision helper for browser agents.
|
| 2 |
+
|
| 3 |
+
This keeps browser action selection consistent across visual and stealth modes,
|
| 4 |
+
while allowing a gradual move from plain JSON prompting to tool calling.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from app.agents.browser_tools import (
|
| 11 |
+
execute_browser_tool_call,
|
| 12 |
+
get_browser_tools,
|
| 13 |
+
parse_browser_json_response,
|
| 14 |
+
)
|
| 15 |
+
from app.agents.llm_client import generate_completion, generate_completion_response
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def decide_browser_action(
|
| 19 |
+
*,
|
| 20 |
+
task: str,
|
| 21 |
+
current_url: str,
|
| 22 |
+
state,
|
| 23 |
+
content_preview: str,
|
| 24 |
+
blocked: bool,
|
| 25 |
+
allow_scroll: bool,
|
| 26 |
+
mode_label: str,
|
| 27 |
+
step_label: str,
|
| 28 |
+
links: list[str] | None = None,
|
| 29 |
+
max_tokens: int = 700,
|
| 30 |
+
) -> dict[str, Any]:
|
| 31 |
+
"""Ask the LLM for the next browser action using tools with JSON fallback."""
|
| 32 |
+
memory_context = state.get_context_for_llm()
|
| 33 |
+
history_str = "\n".join([f"- {url}" for url in state.visited_urls[-8:]]) or "(none)"
|
| 34 |
+
known_str = "\n".join([f"- {fact}" for fact in state.known_facts[-6:]]) or "(none yet)"
|
| 35 |
+
missing_str = "\n".join([f"- {point}" for point in state.missing_points[-6:]]) or "(none)"
|
| 36 |
+
recent_queries_str = "\n".join([f"- {query}" for query in state.last_queries[-6:]]) or "(none)"
|
| 37 |
+
links_str = "\n".join([f"- {url}" for url in (links or [])[:10]]) or "(none)"
|
| 38 |
+
|
| 39 |
+
scroll_rule = (
|
| 40 |
+
"- `scroll_page`: only if the current page likely contains the missing answer but needs more content\n"
|
| 41 |
+
if allow_scroll
|
| 42 |
+
else ""
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
prompt = f"""You are a {mode_label} browser agent.
|
| 46 |
+
|
| 47 |
+
Choose the single best next action for this task. Use tool calls when available.
|
| 48 |
+
If tool calling is unavailable, return valid JSON matching the same intent.
|
| 49 |
+
Never reveal chain-of-thought, hidden reasoning, deliberation, or internal analysis.
|
| 50 |
+
Never answer with prose paragraphs unless you are placing the final user-facing answer inside `finish_task` or JSON `answer`.
|
| 51 |
+
|
| 52 |
+
TASK: {task}
|
| 53 |
+
CURRENT URL: {current_url}
|
| 54 |
+
STEP: {step_label}
|
| 55 |
+
BLOCKED: {blocked}
|
| 56 |
+
|
| 57 |
+
MEMORY:
|
| 58 |
+
{memory_context}
|
| 59 |
+
|
| 60 |
+
KNOWN FACTS:
|
| 61 |
+
{known_str}
|
| 62 |
+
|
| 63 |
+
MISSING INFO:
|
| 64 |
+
{missing_str}
|
| 65 |
+
|
| 66 |
+
RECENT QUERIES:
|
| 67 |
+
{recent_queries_str}
|
| 68 |
+
|
| 69 |
+
VISITED URLS:
|
| 70 |
+
{history_str}
|
| 71 |
+
|
| 72 |
+
CURRENT PAGE CONTENT:
|
| 73 |
+
{content_preview or "(empty page)"}
|
| 74 |
+
|
| 75 |
+
DISCOVERED LINKS:
|
| 76 |
+
{links_str}
|
| 77 |
+
|
| 78 |
+
Prefer these tools:
|
| 79 |
+
- `search_web`: when current evidence is insufficient and you need a new query
|
| 80 |
+
- `navigate_to_url`: when a discovered URL is the best next source and has not been visited
|
| 81 |
+
{scroll_rule}- `finish_task`: when you have enough evidence to answer now
|
| 82 |
+
|
| 83 |
+
Rules:
|
| 84 |
+
1. Do not revisit URLs already listed under VISITED URLS
|
| 85 |
+
2. If the page is blocked, prefer search or a different navigation target immediately
|
| 86 |
+
3. Keep `known_facts` and `missing_points` concise and session-specific
|
| 87 |
+
4. If you finish, provide the answer in the tool arguments or JSON output
|
| 88 |
+
"""
|
| 89 |
+
|
| 90 |
+
response = await generate_completion_response(
|
| 91 |
+
messages=[{"role": "user", "content": prompt}],
|
| 92 |
+
max_tokens=max_tokens,
|
| 93 |
+
tools=get_browser_tools(allow_scroll=allow_scroll),
|
| 94 |
+
tool_choice="auto",
|
| 95 |
+
reasoning_effort="medium",
|
| 96 |
+
prefer_responses_api=True,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
if response.tool_calls:
|
| 100 |
+
last_error: Exception | None = None
|
| 101 |
+
for tool_call in response.tool_calls:
|
| 102 |
+
try:
|
| 103 |
+
return execute_browser_tool_call(tool_call, allow_scroll=allow_scroll)
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
last_error = exc
|
| 106 |
+
if last_error is not None:
|
| 107 |
+
raise last_error
|
| 108 |
+
|
| 109 |
+
if response.content:
|
| 110 |
+
try:
|
| 111 |
+
return parse_browser_json_response(response.content, allow_scroll=allow_scroll)
|
| 112 |
+
except Exception:
|
| 113 |
+
repaired = await _repair_browser_decision_text(
|
| 114 |
+
raw_text=response.content,
|
| 115 |
+
allow_scroll=allow_scroll,
|
| 116 |
+
)
|
| 117 |
+
if repaired is not None:
|
| 118 |
+
return repaired
|
| 119 |
+
|
| 120 |
+
return _fallback_browser_decision(
|
| 121 |
+
task=task,
|
| 122 |
+
current_url=current_url,
|
| 123 |
+
blocked=blocked,
|
| 124 |
+
allow_scroll=allow_scroll,
|
| 125 |
+
links=links or [],
|
| 126 |
+
raw_text=response.content,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return _fallback_browser_decision(
|
| 130 |
+
task=task,
|
| 131 |
+
current_url=current_url,
|
| 132 |
+
blocked=blocked,
|
| 133 |
+
allow_scroll=allow_scroll,
|
| 134 |
+
links=links or [],
|
| 135 |
+
raw_text="",
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
async def _repair_browser_decision_text(
|
| 140 |
+
*,
|
| 141 |
+
raw_text: str,
|
| 142 |
+
allow_scroll: bool,
|
| 143 |
+
) -> dict[str, Any] | None:
|
| 144 |
+
"""Ask the model to convert its previous free-form text into strict JSON."""
|
| 145 |
+
prompt = f"""Convert the text below into exactly one valid JSON object for a browser action.
|
| 146 |
+
|
| 147 |
+
Allowed actions:
|
| 148 |
+
- SEARCH with field `query`
|
| 149 |
+
- NAVIGATE with field `url`
|
| 150 |
+
{"- SCROLL with no extra field" if allow_scroll else ""}
|
| 151 |
+
- DONE with field `answer`
|
| 152 |
+
|
| 153 |
+
Rules:
|
| 154 |
+
1. Output JSON only
|
| 155 |
+
2. Do not include reasoning
|
| 156 |
+
3. Keep `reason`, `known_facts`, and `missing_points` concise
|
| 157 |
+
4. If the text is only internal reasoning and not a final answer, prefer SEARCH, NAVIGATE, or {"SCROLL" if allow_scroll else "SEARCH"} over DONE
|
| 158 |
+
|
| 159 |
+
TEXT:
|
| 160 |
+
{raw_text}
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
repaired_text = await generate_completion(
|
| 164 |
+
messages=[{"role": "user", "content": prompt}],
|
| 165 |
+
max_tokens=300,
|
| 166 |
+
)
|
| 167 |
+
return parse_browser_json_response(repaired_text, allow_scroll=allow_scroll)
|
| 168 |
+
except Exception:
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _fallback_browser_decision(
|
| 173 |
+
*,
|
| 174 |
+
task: str,
|
| 175 |
+
current_url: str,
|
| 176 |
+
blocked: bool,
|
| 177 |
+
allow_scroll: bool,
|
| 178 |
+
links: list[str],
|
| 179 |
+
raw_text: str,
|
| 180 |
+
) -> dict[str, Any]:
|
| 181 |
+
"""Choose a safe next action when the model fails to return structured output."""
|
| 182 |
+
unseen_links = [url for url in links if url.startswith("http")]
|
| 183 |
+
if unseen_links:
|
| 184 |
+
return {
|
| 185 |
+
"action": "NAVIGATE",
|
| 186 |
+
"value": unseen_links[0],
|
| 187 |
+
"answer": "",
|
| 188 |
+
"reason": "Fallback navigation because the model returned unstructured output",
|
| 189 |
+
"known_facts": [],
|
| 190 |
+
"missing_points": [],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
if allow_scroll and not blocked and current_url.startswith("http"):
|
| 194 |
+
return {
|
| 195 |
+
"action": "SCROLL",
|
| 196 |
+
"value": "",
|
| 197 |
+
"answer": "",
|
| 198 |
+
"reason": "Fallback scroll because the model returned unstructured output",
|
| 199 |
+
"known_facts": [],
|
| 200 |
+
"missing_points": [],
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
query = task.strip()
|
| 204 |
+
if current_url.startswith("https://html.duckduckgo.com/"):
|
| 205 |
+
query = f"{task.strip()} answer"
|
| 206 |
+
elif blocked:
|
| 207 |
+
query = f"{task.strip()} alternate source"
|
| 208 |
+
|
| 209 |
+
return {
|
| 210 |
+
"action": "SEARCH",
|
| 211 |
+
"value": query,
|
| 212 |
+
"answer": "",
|
| 213 |
+
"reason": "Fallback search because the model returned unstructured output",
|
| 214 |
+
"known_facts": [],
|
| 215 |
+
"missing_points": [],
|
| 216 |
+
}
|
app/agents/browser_dom.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers for DOM-based extraction in browser agents."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import html
|
| 6 |
+
from html.parser import HTMLParser
|
| 7 |
+
import json
|
| 8 |
+
import re
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class _DomSnapshotParser(HTMLParser):
|
| 12 |
+
"""Extract visible text and absolute links from HTML."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, max_links: int = 12):
|
| 15 |
+
super().__init__()
|
| 16 |
+
self.max_links = max_links
|
| 17 |
+
self._skip_depth = 0
|
| 18 |
+
self._text_parts: list[str] = []
|
| 19 |
+
self._links: list[str] = []
|
| 20 |
+
|
| 21 |
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
| 22 |
+
lowered = tag.lower()
|
| 23 |
+
if lowered in {"script", "style", "noscript"}:
|
| 24 |
+
self._skip_depth += 1
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
if self._skip_depth:
|
| 28 |
+
return
|
| 29 |
+
|
| 30 |
+
if lowered == "a":
|
| 31 |
+
href = dict(attrs).get("href") or ""
|
| 32 |
+
href = href.strip()
|
| 33 |
+
if href.startswith("http") and href not in self._links and len(self._links) < self.max_links:
|
| 34 |
+
self._links.append(href)
|
| 35 |
+
|
| 36 |
+
def handle_endtag(self, tag: str) -> None:
|
| 37 |
+
if tag.lower() in {"script", "style", "noscript"} and self._skip_depth:
|
| 38 |
+
self._skip_depth -= 1
|
| 39 |
+
|
| 40 |
+
def handle_data(self, data: str) -> None:
|
| 41 |
+
if self._skip_depth:
|
| 42 |
+
return
|
| 43 |
+
|
| 44 |
+
text = data.strip()
|
| 45 |
+
if text:
|
| 46 |
+
self._text_parts.append(text)
|
| 47 |
+
|
| 48 |
+
def snapshot(self, max_chars: int = 6000) -> tuple[str, list[str]]:
|
| 49 |
+
text = html.unescape(" ".join(self._text_parts))
|
| 50 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 51 |
+
return text[:max_chars], self._links[: self.max_links]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def extract_dom_snapshot(html_text: str, max_chars: int = 6000, max_links: int = 12) -> tuple[str, list[str]]:
|
| 55 |
+
"""Extract visible text and absolute links from a rendered DOM snapshot."""
|
| 56 |
+
parser = _DomSnapshotParser(max_links=max_links)
|
| 57 |
+
parser.feed(html_text or "")
|
| 58 |
+
parser.close()
|
| 59 |
+
return parser.snapshot(max_chars=max_chars)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def build_visual_dom_extract_script(url: str, max_chars: int = 6000, max_links: int = 12) -> str:
|
| 63 |
+
"""Build a sandbox-side Python script that extracts rendered DOM via headless Chrome."""
|
| 64 |
+
quoted_url = json.dumps(url)
|
| 65 |
+
return f'''
|
| 66 |
+
import json
|
| 67 |
+
import subprocess
|
| 68 |
+
import sys
|
| 69 |
+
from html.parser import HTMLParser
|
| 70 |
+
import html as html_lib
|
| 71 |
+
import re
|
| 72 |
+
|
| 73 |
+
URL = {quoted_url}
|
| 74 |
+
MAX_CHARS = {max_chars}
|
| 75 |
+
MAX_LINKS = {max_links}
|
| 76 |
+
|
| 77 |
+
class DomSnapshotParser(HTMLParser):
|
| 78 |
+
def __init__(self):
|
| 79 |
+
super().__init__()
|
| 80 |
+
self.skip_depth = 0
|
| 81 |
+
self.text_parts = []
|
| 82 |
+
self.links = []
|
| 83 |
+
|
| 84 |
+
def handle_starttag(self, tag, attrs):
|
| 85 |
+
lowered = tag.lower()
|
| 86 |
+
if lowered in ("script", "style", "noscript"):
|
| 87 |
+
self.skip_depth += 1
|
| 88 |
+
return
|
| 89 |
+
if self.skip_depth:
|
| 90 |
+
return
|
| 91 |
+
if lowered == "a":
|
| 92 |
+
href = dict(attrs).get("href") or ""
|
| 93 |
+
href = href.strip()
|
| 94 |
+
if href.startswith("http") and href not in self.links and len(self.links) < MAX_LINKS:
|
| 95 |
+
self.links.append(href)
|
| 96 |
+
|
| 97 |
+
def handle_endtag(self, tag):
|
| 98 |
+
if tag.lower() in ("script", "style", "noscript") and self.skip_depth:
|
| 99 |
+
self.skip_depth -= 1
|
| 100 |
+
|
| 101 |
+
def handle_data(self, data):
|
| 102 |
+
if self.skip_depth:
|
| 103 |
+
return
|
| 104 |
+
text = data.strip()
|
| 105 |
+
if text:
|
| 106 |
+
self.text_parts.append(text)
|
| 107 |
+
|
| 108 |
+
def snapshot(self):
|
| 109 |
+
text = html_lib.unescape(" ".join(self.text_parts))
|
| 110 |
+
text = re.sub(r"\\s+", " ", text).strip()
|
| 111 |
+
return text[:MAX_CHARS], self.links[:MAX_LINKS]
|
| 112 |
+
|
| 113 |
+
def is_blocked(text):
|
| 114 |
+
lowered = text.lower()
|
| 115 |
+
markers = ["checking your browser", "cloudflare", "access denied", "just a moment", "enable javascript"]
|
| 116 |
+
return len(text) < 800 and any(marker in lowered for marker in markers)
|
| 117 |
+
|
| 118 |
+
def dump_dom():
|
| 119 |
+
commands = [
|
| 120 |
+
["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox", "--dump-dom", URL],
|
| 121 |
+
["google-chrome", "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", URL],
|
| 122 |
+
]
|
| 123 |
+
last_error = None
|
| 124 |
+
for command in commands:
|
| 125 |
+
try:
|
| 126 |
+
result = subprocess.run(command, capture_output=True, text=True, timeout=25, check=True)
|
| 127 |
+
return result.stdout
|
| 128 |
+
except Exception as exc:
|
| 129 |
+
last_error = exc
|
| 130 |
+
raise last_error or RuntimeError("Could not dump DOM")
|
| 131 |
+
|
| 132 |
+
try:
|
| 133 |
+
dom_html = dump_dom()
|
| 134 |
+
parser = DomSnapshotParser()
|
| 135 |
+
parser.feed(dom_html)
|
| 136 |
+
parser.close()
|
| 137 |
+
content, links = parser.snapshot()
|
| 138 |
+
print(json.dumps({{"content": content, "links": links, "blocked": is_blocked(content)}}))
|
| 139 |
+
except Exception as exc:
|
| 140 |
+
print(json.dumps({{"error": str(exc), "content": "", "links": [], "blocked": False}}))
|
| 141 |
+
sys.exit(0)
|
| 142 |
+
'''
|
app/agents/browser_search.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search URL helpers for browser agents."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from urllib.parse import quote_plus
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
SEARCH_ENGINES: tuple[tuple[str, str], ...] = (
|
| 9 |
+
("bing", "https://www.bing.com/search?q={query}"),
|
| 10 |
+
("wikipedia", "https://en.wikipedia.org/w/index.php?search={query}"),
|
| 11 |
+
("duckduckgo", "https://html.duckduckgo.com/html/?q={query}"),
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_search_url(query: str, engine: str = "bing") -> str:
|
| 16 |
+
"""Build a search URL for the requested engine."""
|
| 17 |
+
normalized_query = quote_plus((query or "").strip())
|
| 18 |
+
for engine_name, template in SEARCH_ENGINES:
|
| 19 |
+
if engine_name == engine:
|
| 20 |
+
return template.format(query=normalized_query)
|
| 21 |
+
return SEARCH_ENGINES[0][1].format(query=normalized_query)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def detect_search_engine(url: str) -> str | None:
|
| 25 |
+
"""Infer which configured engine a URL belongs to."""
|
| 26 |
+
lowered = (url or "").lower()
|
| 27 |
+
if "bing.com/search" in lowered:
|
| 28 |
+
return "bing"
|
| 29 |
+
if "wikipedia.org/w/index.php?search=" in lowered:
|
| 30 |
+
return "wikipedia"
|
| 31 |
+
if "duckduckgo.com/html/" in lowered:
|
| 32 |
+
return "duckduckgo"
|
| 33 |
+
return None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def choose_search_url(
|
| 37 |
+
query: str,
|
| 38 |
+
visited_urls: list[str],
|
| 39 |
+
current_url: str = "",
|
| 40 |
+
blocked: bool = False,
|
| 41 |
+
) -> str:
|
| 42 |
+
"""Choose the next search URL, rotating engines to avoid dead loops."""
|
| 43 |
+
current_engine = detect_search_engine(current_url)
|
| 44 |
+
engine_order = [engine for engine, _ in SEARCH_ENGINES]
|
| 45 |
+
|
| 46 |
+
if blocked and current_engine in engine_order:
|
| 47 |
+
engine_order = [engine for engine in engine_order if engine != current_engine] + [current_engine]
|
| 48 |
+
|
| 49 |
+
for engine in engine_order:
|
| 50 |
+
candidate = build_search_url(query, engine=engine)
|
| 51 |
+
if candidate not in visited_urls:
|
| 52 |
+
return candidate
|
| 53 |
+
|
| 54 |
+
if current_engine:
|
| 55 |
+
return build_search_url(query, engine=current_engine)
|
| 56 |
+
return build_search_url(query, engine="bing")
|
app/agents/browser_stealth.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stealth browser agent - Camoufox with full agentic navigation.
|
| 2 |
+
|
| 3 |
+
Camoufox = Firefox stealth que passa anti-bot.
|
| 4 |
+
Roda DENTRO do E2B sandbox.
|
| 5 |
+
Full agentic loop with LLM-driven navigation.
|
| 6 |
+
Time limit: 5 minutes (300 seconds)
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
from typing import AsyncGenerator, Optional
|
| 14 |
+
|
| 15 |
+
from app.agents.browser_decision import decide_browser_action
|
| 16 |
+
from app.agents.browser_search import choose_search_url
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
from app.agents.llm_client import generate_completion
|
| 19 |
+
from app.agents.graph.state import AgentState
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
MAX_TIME_SECONDS = 300 # 5 minutes
|
| 24 |
+
MAX_PAGES = 5
|
| 25 |
+
STEALTH_SANDBOX_TIMEOUT_SECONDS = 600
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def run_browser_stealth_agent(
|
| 29 |
+
task: str,
|
| 30 |
+
url: Optional[str] = None,
|
| 31 |
+
) -> AsyncGenerator[dict, None]:
|
| 32 |
+
"""Run the stealth browser agent with Camoufox."""
|
| 33 |
+
settings = get_settings()
|
| 34 |
+
|
| 35 |
+
if not settings.e2b_api_key:
|
| 36 |
+
yield {"type": "error", "message": "E2B_API_KEY not configured"}
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
# Initialize agent state
|
| 40 |
+
state = AgentState(
|
| 41 |
+
task=task,
|
| 42 |
+
url=url,
|
| 43 |
+
timeout_seconds=MAX_TIME_SECONDS,
|
| 44 |
+
start_time=time.time()
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
yield {"type": "status", "message": "🚀 Initializing stealth agent..."}
|
| 48 |
+
|
| 49 |
+
desktop = None
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
from e2b_desktop import Sandbox
|
| 53 |
+
|
| 54 |
+
os.environ["E2B_API_KEY"] = settings.e2b_api_key
|
| 55 |
+
|
| 56 |
+
yield {"type": "status", "message": "🖥️ Creating sandbox..."}
|
| 57 |
+
desktop = Sandbox.create(timeout=STEALTH_SANDBOX_TIMEOUT_SECONDS)
|
| 58 |
+
state.desktop = desktop
|
| 59 |
+
|
| 60 |
+
# Install Camoufox
|
| 61 |
+
yield {"type": "status", "message": "📦 Installing stealth browser..."}
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
desktop.commands.run("pip install --user camoufox playwright -q", timeout=120)
|
| 65 |
+
yield {"type": "status", "message": "🔽 Downloading Firefox stealth (~30s)..."}
|
| 66 |
+
desktop.commands.run("camoufox fetch", timeout=180)
|
| 67 |
+
desktop.commands.run("sudo apt-get update -qq && sudo apt-get install -y -qq libgtk-3-0 libasound2 libdbus-glib-1-2 2>/dev/null || true", timeout=60)
|
| 68 |
+
yield {"type": "status", "message": "✅ Browser ready!"}
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Camoufox install failed: {e}")
|
| 71 |
+
yield {"type": "error", "message": f"Install failed: {e}"}
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
# Build initial URL
|
| 75 |
+
if url:
|
| 76 |
+
start_url = url
|
| 77 |
+
else:
|
| 78 |
+
start_url = choose_search_url(task, visited_urls=state.visited_urls)
|
| 79 |
+
state.add_query(task)
|
| 80 |
+
|
| 81 |
+
state.visited_urls.append(start_url)
|
| 82 |
+
state.add_action({"type": "start", "url": start_url})
|
| 83 |
+
|
| 84 |
+
# Agentic loop
|
| 85 |
+
while state.should_continue() and state.step_count < MAX_PAGES:
|
| 86 |
+
state.step_count += 1
|
| 87 |
+
elapsed = int(state.get_elapsed_time())
|
| 88 |
+
remaining = int(state.get_remaining_time())
|
| 89 |
+
|
| 90 |
+
current_url = state.visited_urls[-1]
|
| 91 |
+
|
| 92 |
+
yield {"type": "status", "message": f"🔍 Step {state.step_count}: Fetching {current_url[:40]}... ({elapsed}s)"}
|
| 93 |
+
|
| 94 |
+
# Fetch page with Camoufox
|
| 95 |
+
script = _build_fetch_script(current_url)
|
| 96 |
+
desktop.commands.run(f"cat > /tmp/fetch.py << 'EOF'\n{script}\nEOF", timeout=10)
|
| 97 |
+
|
| 98 |
+
result = desktop.commands.run("python3 /tmp/fetch.py", timeout=60)
|
| 99 |
+
output = result.stdout.strip() if hasattr(result, 'stdout') else ""
|
| 100 |
+
|
| 101 |
+
# Parse result
|
| 102 |
+
page_content = ""
|
| 103 |
+
page_links = []
|
| 104 |
+
is_blocked = False
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
data = json.loads(output)
|
| 108 |
+
page_content = data.get("content", "")
|
| 109 |
+
page_links = data.get("links", [])
|
| 110 |
+
is_blocked = data.get("blocked", False)
|
| 111 |
+
|
| 112 |
+
if data.get("error"):
|
| 113 |
+
state.add_error(data["error"])
|
| 114 |
+
except json.JSONDecodeError:
|
| 115 |
+
page_content = output[:3000]
|
| 116 |
+
|
| 117 |
+
if is_blocked:
|
| 118 |
+
yield {"type": "status", "message": f"🚫 Blocked at {current_url[:30]}..., trying next..."}
|
| 119 |
+
state.add_error(f"Blocked: {current_url}")
|
| 120 |
+
else:
|
| 121 |
+
state.extracted_data.append({
|
| 122 |
+
"url": current_url,
|
| 123 |
+
"content_length": len(page_content),
|
| 124 |
+
"links_found": len(page_links),
|
| 125 |
+
"preview": page_content[:300]
|
| 126 |
+
})
|
| 127 |
+
|
| 128 |
+
decision = await decide_browser_action(
|
| 129 |
+
task=task,
|
| 130 |
+
current_url=current_url,
|
| 131 |
+
state=state,
|
| 132 |
+
content_preview=page_content[:2000] if page_content else "(empty)",
|
| 133 |
+
blocked=is_blocked,
|
| 134 |
+
allow_scroll=False,
|
| 135 |
+
mode_label="stealth Camoufox",
|
| 136 |
+
step_label=f"{state.step_count}/{MAX_PAGES}, {remaining}s remaining",
|
| 137 |
+
links=page_links,
|
| 138 |
+
max_tokens=650,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
action = decision.get("action", "DONE")
|
| 142 |
+
value = decision.get("value", "")
|
| 143 |
+
final_answer = decision.get("answer", "")
|
| 144 |
+
reason = decision.get("reason", "")
|
| 145 |
+
known_facts = decision.get("known_facts", [])
|
| 146 |
+
missing_points = decision.get("missing_points", [])
|
| 147 |
+
|
| 148 |
+
if action == "SEARCH":
|
| 149 |
+
state.add_query(value)
|
| 150 |
+
|
| 151 |
+
if isinstance(known_facts, list) or isinstance(missing_points, list):
|
| 152 |
+
state.update_research_progress(
|
| 153 |
+
known_facts=known_facts if isinstance(known_facts, list) else None,
|
| 154 |
+
missing_points=missing_points if isinstance(missing_points, list) else None,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
state.add_action({"type": action.lower(), "value": value, "reason": reason})
|
| 158 |
+
|
| 159 |
+
yield {"type": "status", "message": f"🤔 {action}: {reason[:40]}"}
|
| 160 |
+
|
| 161 |
+
yield {
|
| 162 |
+
"type": "progress",
|
| 163 |
+
"known_facts": state.known_facts[-8:],
|
| 164 |
+
"missing_points": state.missing_points[-8:],
|
| 165 |
+
"last_queries": state.last_queries[-8:],
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
if action == "DONE":
|
| 169 |
+
state.success = True
|
| 170 |
+
|
| 171 |
+
if not final_answer:
|
| 172 |
+
# Generate from memory
|
| 173 |
+
all_content = "\n\n".join([
|
| 174 |
+
f"Source: {d['url']}\n{d.get('preview', '')}"
|
| 175 |
+
for d in state.extracted_data[-5:]
|
| 176 |
+
])
|
| 177 |
+
known_summary = "\n".join([f"- {f}" for f in state.known_facts[-8:]]) or "(none)"
|
| 178 |
+
missing_summary = "\n".join([f"- {m}" for m in state.missing_points[-8:]]) or "(none)"
|
| 179 |
+
final_prompt = (
|
| 180 |
+
f"Answer this: {task}\n\n"
|
| 181 |
+
f"Known facts:\n{known_summary}\n\n"
|
| 182 |
+
f"Missing points:\n{missing_summary}\n\n"
|
| 183 |
+
f"Content:\n{all_content}"
|
| 184 |
+
)
|
| 185 |
+
final_answer = await generate_completion(
|
| 186 |
+
messages=[{"role": "user", "content": final_prompt}],
|
| 187 |
+
max_tokens=1200
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
state.final_result = final_answer
|
| 191 |
+
|
| 192 |
+
yield {"type": "stream_end", "message": "Done"}
|
| 193 |
+
yield {
|
| 194 |
+
"type": "result",
|
| 195 |
+
"content": final_answer,
|
| 196 |
+
"links": state.visited_urls,
|
| 197 |
+
"steps": state.step_count,
|
| 198 |
+
"success": True
|
| 199 |
+
}
|
| 200 |
+
yield {"type": "complete", "message": f"Done in {int(state.get_elapsed_time())}s (stealth)"}
|
| 201 |
+
return
|
| 202 |
+
|
| 203 |
+
elif action == "NAVIGATE":
|
| 204 |
+
if value and value.startswith("http"):
|
| 205 |
+
if value not in state.visited_urls:
|
| 206 |
+
state.visited_urls.append(value)
|
| 207 |
+
else:
|
| 208 |
+
state.add_error(f"Tried revisit: {value}")
|
| 209 |
+
|
| 210 |
+
elif action == "SEARCH":
|
| 211 |
+
new_url = choose_search_url(
|
| 212 |
+
value,
|
| 213 |
+
visited_urls=state.visited_urls,
|
| 214 |
+
current_url=current_url,
|
| 215 |
+
blocked=is_blocked,
|
| 216 |
+
)
|
| 217 |
+
if new_url not in state.visited_urls:
|
| 218 |
+
state.visited_urls.append(new_url)
|
| 219 |
+
|
| 220 |
+
# Timeout or max pages - generate from memory
|
| 221 |
+
yield {"type": "status", "message": "⏰ Generating final answer from memory..."}
|
| 222 |
+
|
| 223 |
+
all_content = "\n\n".join([
|
| 224 |
+
f"Source: {d['url']}\n{d.get('preview', '')}"
|
| 225 |
+
for d in state.extracted_data[-5:]
|
| 226 |
+
])
|
| 227 |
+
known_summary = "\n".join([f"- {f}" for f in state.known_facts[-8:]]) or "(none)"
|
| 228 |
+
missing_summary = "\n".join([f"- {m}" for m in state.missing_points[-8:]]) or "(none)"
|
| 229 |
+
final_prompt = (
|
| 230 |
+
f"Answer this: {task}\n\n"
|
| 231 |
+
f"Known facts:\n{known_summary}\n\n"
|
| 232 |
+
f"Missing points:\n{missing_summary}\n\n"
|
| 233 |
+
f"Content:\n{all_content}"
|
| 234 |
+
)
|
| 235 |
+
final_answer = await generate_completion(
|
| 236 |
+
messages=[{"role": "user", "content": final_prompt}],
|
| 237 |
+
max_tokens=1200
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
state.final_result = final_answer
|
| 241 |
+
|
| 242 |
+
yield {"type": "stream_end", "message": "Done"}
|
| 243 |
+
yield {
|
| 244 |
+
"type": "result",
|
| 245 |
+
"content": final_answer,
|
| 246 |
+
"links": state.visited_urls,
|
| 247 |
+
"steps": state.step_count,
|
| 248 |
+
"success": True
|
| 249 |
+
}
|
| 250 |
+
yield {"type": "complete", "message": f"Done in {int(state.get_elapsed_time())}s (stealth, {state.step_count} pages)"}
|
| 251 |
+
|
| 252 |
+
except ImportError:
|
| 253 |
+
yield {"type": "error", "message": "e2b-desktop not installed"}
|
| 254 |
+
except Exception as e:
|
| 255 |
+
logger.exception("Stealth agent error")
|
| 256 |
+
yield {"type": "error", "message": str(e)}
|
| 257 |
+
finally:
|
| 258 |
+
if desktop:
|
| 259 |
+
try:
|
| 260 |
+
desktop.kill()
|
| 261 |
+
except:
|
| 262 |
+
pass
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _build_fetch_script(url: str) -> str:
|
| 266 |
+
"""Build Python script to fetch a URL with Camoufox."""
|
| 267 |
+
return f'''
|
| 268 |
+
import json
|
| 269 |
+
import sys
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
+
from camoufox.sync_api import Camoufox
|
| 273 |
+
except:
|
| 274 |
+
print(json.dumps({{"error": "Camoufox not found"}}))
|
| 275 |
+
sys.exit(1)
|
| 276 |
+
|
| 277 |
+
def is_blocked(text):
|
| 278 |
+
t = text.lower()
|
| 279 |
+
blocks = ["checking your browser", "cloudflare", "access denied", "just a moment", "enable javascript"]
|
| 280 |
+
return len(text) < 800 and any(b in t for b in blocks)
|
| 281 |
+
|
| 282 |
+
try:
|
| 283 |
+
with Camoufox(headless=True) as browser:
|
| 284 |
+
page = browser.new_page()
|
| 285 |
+
page.goto("{url}", timeout=30000)
|
| 286 |
+
page.wait_for_timeout(2000)
|
| 287 |
+
|
| 288 |
+
# Extract text
|
| 289 |
+
content = page.evaluate("""() => {{
|
| 290 |
+
document.querySelectorAll('script,style,noscript').forEach(e => e.remove());
|
| 291 |
+
return document.body.innerText || '';
|
| 292 |
+
}}""")[:5000]
|
| 293 |
+
|
| 294 |
+
# Extract links
|
| 295 |
+
links = page.evaluate("""() => {{
|
| 296 |
+
return Array.from(document.querySelectorAll('a[href^="http"]'))
|
| 297 |
+
.map(a => a.href)
|
| 298 |
+
.filter(h => !h.includes('duckduckgo') && !h.includes('google'))
|
| 299 |
+
.slice(0, 10);
|
| 300 |
+
}}""")
|
| 301 |
+
|
| 302 |
+
blocked = is_blocked(content)
|
| 303 |
+
|
| 304 |
+
print(json.dumps({{
|
| 305 |
+
"content": content,
|
| 306 |
+
"links": links,
|
| 307 |
+
"blocked": blocked
|
| 308 |
+
}}))
|
| 309 |
+
|
| 310 |
+
except Exception as e:
|
| 311 |
+
print(json.dumps({{"error": str(e)}}))
|
| 312 |
+
'''
|
app/agents/browser_tools.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable tooling for browser-agent action selection.
|
| 2 |
+
|
| 3 |
+
This module gives the browser agents a shared, validated action surface.
|
| 4 |
+
It supports both:
|
| 5 |
+
- native tool calling when the LLM/provider can emit tool calls
|
| 6 |
+
- JSON fallback when the model only returns plain text
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from app.agents.tooling import ToolCall, ToolRegistry, tool
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _normalize_points(values: list[str] | str | None) -> list[str]:
|
| 18 |
+
"""Trim and deduplicate short research memory lists.
|
| 19 |
+
|
| 20 |
+
Models occasionally return a plain string instead of a list. Treat that as a
|
| 21 |
+
single item instead of iterating character-by-character.
|
| 22 |
+
"""
|
| 23 |
+
cleaned: list[str] = []
|
| 24 |
+
if values is None:
|
| 25 |
+
iterable: list[Any] = []
|
| 26 |
+
elif isinstance(values, str):
|
| 27 |
+
iterable = [values]
|
| 28 |
+
else:
|
| 29 |
+
iterable = list(values)
|
| 30 |
+
|
| 31 |
+
for value in iterable:
|
| 32 |
+
text = str(value).strip()
|
| 33 |
+
if len(text) <= 1 and text.isalpha():
|
| 34 |
+
continue
|
| 35 |
+
if text and text not in cleaned:
|
| 36 |
+
cleaned.append(text)
|
| 37 |
+
return cleaned
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@tool(description="Search the web with a fresh query when the current page is insufficient.")
|
| 41 |
+
def search_web(
|
| 42 |
+
query: str,
|
| 43 |
+
reason: str = "",
|
| 44 |
+
known_facts: list[str] | None = None,
|
| 45 |
+
missing_points: list[str] | None = None,
|
| 46 |
+
) -> dict[str, Any]:
|
| 47 |
+
"""Search the web.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
query: Query terms to search for next.
|
| 51 |
+
reason: Why a new search is needed.
|
| 52 |
+
known_facts: Short facts already established.
|
| 53 |
+
missing_points: What information is still missing.
|
| 54 |
+
"""
|
| 55 |
+
return {
|
| 56 |
+
"action": "SEARCH",
|
| 57 |
+
"value": query.strip(),
|
| 58 |
+
"reason": reason.strip(),
|
| 59 |
+
"known_facts": _normalize_points(known_facts),
|
| 60 |
+
"missing_points": _normalize_points(missing_points),
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@tool(description="Open a new URL that was discovered in the current page or results.")
|
| 65 |
+
def navigate_to_url(
|
| 66 |
+
url: str,
|
| 67 |
+
reason: str = "",
|
| 68 |
+
known_facts: list[str] | None = None,
|
| 69 |
+
missing_points: list[str] | None = None,
|
| 70 |
+
) -> dict[str, Any]:
|
| 71 |
+
"""Navigate to a specific URL.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
url: Absolute URL to visit next.
|
| 75 |
+
reason: Why that URL is the best next step.
|
| 76 |
+
known_facts: Short facts already established.
|
| 77 |
+
missing_points: What information is still missing.
|
| 78 |
+
"""
|
| 79 |
+
return {
|
| 80 |
+
"action": "NAVIGATE",
|
| 81 |
+
"value": url.strip(),
|
| 82 |
+
"reason": reason.strip(),
|
| 83 |
+
"known_facts": _normalize_points(known_facts),
|
| 84 |
+
"missing_points": _normalize_points(missing_points),
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@tool(description="Scroll the current page to reveal more content.")
|
| 89 |
+
def scroll_page(
|
| 90 |
+
reason: str = "",
|
| 91 |
+
known_facts: list[str] | None = None,
|
| 92 |
+
missing_points: list[str] | None = None,
|
| 93 |
+
) -> dict[str, Any]:
|
| 94 |
+
"""Scroll the current page.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
reason: Why scrolling is useful right now.
|
| 98 |
+
known_facts: Short facts already established.
|
| 99 |
+
missing_points: What information is still missing.
|
| 100 |
+
"""
|
| 101 |
+
return {
|
| 102 |
+
"action": "SCROLL",
|
| 103 |
+
"value": "",
|
| 104 |
+
"reason": reason.strip(),
|
| 105 |
+
"known_facts": _normalize_points(known_facts),
|
| 106 |
+
"missing_points": _normalize_points(missing_points),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@tool(description="Finish the task and provide the final answer based on the collected evidence.")
|
| 111 |
+
def finish_task(
|
| 112 |
+
answer: str,
|
| 113 |
+
reason: str = "",
|
| 114 |
+
known_facts: list[str] | None = None,
|
| 115 |
+
missing_points: list[str] | None = None,
|
| 116 |
+
) -> dict[str, Any]:
|
| 117 |
+
"""Finish the task.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
answer: Final user-facing answer.
|
| 121 |
+
reason: Why the task is complete.
|
| 122 |
+
known_facts: Short facts already established.
|
| 123 |
+
missing_points: Remaining uncertainty, if any.
|
| 124 |
+
"""
|
| 125 |
+
return {
|
| 126 |
+
"action": "DONE",
|
| 127 |
+
"value": "",
|
| 128 |
+
"answer": answer.strip(),
|
| 129 |
+
"reason": reason.strip(),
|
| 130 |
+
"known_facts": _normalize_points(known_facts),
|
| 131 |
+
"missing_points": _normalize_points(missing_points),
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
BROWSER_TOOL_REGISTRY = ToolRegistry([
|
| 136 |
+
search_web,
|
| 137 |
+
navigate_to_url,
|
| 138 |
+
scroll_page,
|
| 139 |
+
finish_task,
|
| 140 |
+
])
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_browser_tools(allow_scroll: bool = True) -> list[dict[str, Any]]:
|
| 144 |
+
"""Return OpenAI-compatible tool schemas for the browser agent."""
|
| 145 |
+
tools = []
|
| 146 |
+
for schema in BROWSER_TOOL_REGISTRY.schemas:
|
| 147 |
+
if not allow_scroll and schema.name == "scroll_page":
|
| 148 |
+
continue
|
| 149 |
+
tools.append(schema.to_openai_tool())
|
| 150 |
+
return tools
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def execute_browser_tool_call(tool_call: ToolCall, allow_scroll: bool = True) -> dict[str, Any]:
|
| 154 |
+
"""Execute a browser decision tool call and validate mode-specific constraints."""
|
| 155 |
+
if not allow_scroll and tool_call.name == "scroll_page":
|
| 156 |
+
raise ValueError("scroll_page is not allowed for this browser mode")
|
| 157 |
+
|
| 158 |
+
result = BROWSER_TOOL_REGISTRY.execute(tool_call)
|
| 159 |
+
return validate_browser_decision(result, allow_scroll=allow_scroll)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def parse_browser_json_response(text: str, allow_scroll: bool = True) -> dict[str, Any]:
|
| 163 |
+
"""Parse legacy JSON action output into the normalized browser-decision shape."""
|
| 164 |
+
snippet = _extract_json_object(text)
|
| 165 |
+
data = json.loads(snippet)
|
| 166 |
+
|
| 167 |
+
action = str(data.get("action", "DONE")).strip().upper()
|
| 168 |
+
normalized = {
|
| 169 |
+
"action": action,
|
| 170 |
+
"value": str(data.get("value", "")).strip(),
|
| 171 |
+
"answer": str(data.get("answer", "")).strip(),
|
| 172 |
+
"reason": str(data.get("reason", "")).strip(),
|
| 173 |
+
"known_facts": _normalize_points(data.get("known_facts")),
|
| 174 |
+
"missing_points": _normalize_points(data.get("missing_points")),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if action == "SEARCH":
|
| 178 |
+
normalized["value"] = str(data.get("query", normalized["value"])).strip()
|
| 179 |
+
elif action == "NAVIGATE":
|
| 180 |
+
normalized["value"] = str(data.get("url", normalized["value"])).strip()
|
| 181 |
+
elif action == "DONE":
|
| 182 |
+
normalized["answer"] = str(data.get("answer", data.get("result", normalized["answer"]))).strip()
|
| 183 |
+
elif action == "SCROLL":
|
| 184 |
+
normalized["value"] = ""
|
| 185 |
+
|
| 186 |
+
return validate_browser_decision(normalized, allow_scroll=allow_scroll)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def validate_browser_decision(decision: dict[str, Any], allow_scroll: bool = True) -> dict[str, Any]:
|
| 190 |
+
"""Validate and normalize a browser agent decision."""
|
| 191 |
+
action = str(decision.get("action", "")).strip().upper()
|
| 192 |
+
value = str(decision.get("value", "")).strip()
|
| 193 |
+
answer = str(decision.get("answer", "")).strip()
|
| 194 |
+
|
| 195 |
+
normalized = {
|
| 196 |
+
"action": action or "DONE",
|
| 197 |
+
"value": value,
|
| 198 |
+
"answer": answer,
|
| 199 |
+
"reason": str(decision.get("reason", "")).strip(),
|
| 200 |
+
"known_facts": _normalize_points(decision.get("known_facts")),
|
| 201 |
+
"missing_points": _normalize_points(decision.get("missing_points")),
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
if normalized["action"] == "SEARCH":
|
| 205 |
+
if not normalized["value"]:
|
| 206 |
+
raise ValueError("SEARCH decision requires a non-empty query")
|
| 207 |
+
elif normalized["action"] == "NAVIGATE":
|
| 208 |
+
if not normalized["value"].startswith("http"):
|
| 209 |
+
raise ValueError("NAVIGATE decision requires an absolute URL")
|
| 210 |
+
elif normalized["action"] == "SCROLL":
|
| 211 |
+
if not allow_scroll:
|
| 212 |
+
raise ValueError("SCROLL is not supported in this browser mode")
|
| 213 |
+
normalized["value"] = ""
|
| 214 |
+
elif normalized["action"] == "DONE":
|
| 215 |
+
pass
|
| 216 |
+
else:
|
| 217 |
+
raise ValueError(f"Unsupported browser action '{normalized['action']}'")
|
| 218 |
+
|
| 219 |
+
return normalized
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _extract_json_object(text: str) -> str:
|
| 223 |
+
"""Extract the first JSON object-looking slice from a model response."""
|
| 224 |
+
raw = (text or "").strip()
|
| 225 |
+
if raw.startswith("```"):
|
| 226 |
+
parts = raw.split("```")
|
| 227 |
+
if len(parts) >= 2:
|
| 228 |
+
raw = parts[1]
|
| 229 |
+
if raw.startswith("json"):
|
| 230 |
+
raw = raw[4:]
|
| 231 |
+
raw = raw.strip()
|
| 232 |
+
|
| 233 |
+
start = raw.find("{")
|
| 234 |
+
end = raw.rfind("}")
|
| 235 |
+
if start == -1 or end == -1 or end <= start:
|
| 236 |
+
raise ValueError("Model response did not contain a JSON object")
|
| 237 |
+
return raw[start:end + 1]
|
app/agents/browser_visual.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Visual browser agent - Chrome with live stream and agent memory.
|
| 2 |
+
|
| 3 |
+
Uses E2B Desktop sandbox with Chrome browser.
|
| 4 |
+
Time limit: 5 minutes (300 seconds)
|
| 5 |
+
Shows live video stream.
|
| 6 |
+
Includes full memory/history tracking via AgentState.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import shlex
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
import json
|
| 14 |
+
from typing import AsyncGenerator, Optional
|
| 15 |
+
|
| 16 |
+
from app.agents.browser_dom import build_visual_dom_extract_script
|
| 17 |
+
from app.agents.browser_search import choose_search_url
|
| 18 |
+
from app.agents.browser_decision import decide_browser_action
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
from app.agents.llm_client import generate_completion
|
| 21 |
+
from app.agents.graph.state import AgentState
|
| 22 |
+
from app.agents.flaresolverr import is_cloudflare_blocked
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
MAX_TIME_SECONDS = 300 # 5 minutes
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def run_browser_visual_agent(
|
| 30 |
+
task: str,
|
| 31 |
+
url: Optional[str] = None,
|
| 32 |
+
) -> AsyncGenerator[dict, None]:
|
| 33 |
+
"""Run the visual browser agent with Chrome and live stream."""
|
| 34 |
+
settings = get_settings()
|
| 35 |
+
|
| 36 |
+
if not settings.e2b_api_key:
|
| 37 |
+
yield {"type": "error", "message": "E2B_API_KEY not configured"}
|
| 38 |
+
return
|
| 39 |
+
|
| 40 |
+
# Initialize agent state with memory
|
| 41 |
+
state = AgentState(
|
| 42 |
+
task=task,
|
| 43 |
+
url=url,
|
| 44 |
+
timeout_seconds=MAX_TIME_SECONDS,
|
| 45 |
+
start_time=time.time()
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
yield {"type": "status", "message": "🚀 Initializing agent..."}
|
| 49 |
+
|
| 50 |
+
desktop = None
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
from e2b_desktop import Sandbox
|
| 54 |
+
|
| 55 |
+
os.environ["E2B_API_KEY"] = settings.e2b_api_key
|
| 56 |
+
|
| 57 |
+
yield {"type": "status", "message": "🖥️ Creating virtual desktop..."}
|
| 58 |
+
desktop = Sandbox.create(timeout=600)
|
| 59 |
+
state.desktop = desktop
|
| 60 |
+
|
| 61 |
+
# Start streaming
|
| 62 |
+
stream_url = None
|
| 63 |
+
try:
|
| 64 |
+
desktop.stream.start(require_auth=True)
|
| 65 |
+
auth_key = desktop.stream.get_auth_key()
|
| 66 |
+
stream_url = desktop.stream.get_url(auth_key=auth_key)
|
| 67 |
+
yield {"type": "stream", "url": stream_url}
|
| 68 |
+
logger.info(f"Stream started: {stream_url}")
|
| 69 |
+
desktop.wait(2000)
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.warning(f"Could not start stream: {e}")
|
| 72 |
+
|
| 73 |
+
# Launch Chrome
|
| 74 |
+
yield {"type": "status", "message": "🌐 Launching browser..."}
|
| 75 |
+
|
| 76 |
+
if url:
|
| 77 |
+
start_url = url
|
| 78 |
+
else:
|
| 79 |
+
start_url = choose_search_url(task, visited_urls=state.visited_urls)
|
| 80 |
+
state.add_query(task)
|
| 81 |
+
|
| 82 |
+
chrome_flags = "--no-sandbox --disable-gpu --start-maximized --no-first-run --disable-default-apps --disable-popup-blocking --disable-translate --no-default-browser-check"
|
| 83 |
+
desktop.commands.run(f"google-chrome {chrome_flags} {shlex.quote(start_url)} &", background=True)
|
| 84 |
+
desktop.wait(3000)
|
| 85 |
+
|
| 86 |
+
# Close dialogs
|
| 87 |
+
desktop.press("enter")
|
| 88 |
+
desktop.wait(1000)
|
| 89 |
+
|
| 90 |
+
# Add to memory
|
| 91 |
+
state.visited_urls.append(start_url)
|
| 92 |
+
state.add_action({"type": "navigate", "url": start_url})
|
| 93 |
+
|
| 94 |
+
# Main loop - time based with memory
|
| 95 |
+
while state.should_continue():
|
| 96 |
+
state.step_count += 1
|
| 97 |
+
elapsed = int(state.get_elapsed_time())
|
| 98 |
+
remaining = int(state.get_remaining_time())
|
| 99 |
+
|
| 100 |
+
yield {"type": "status", "message": f"🔍 Step {state.step_count}: Analyzing... ({elapsed}s / {MAX_TIME_SECONDS}s)"}
|
| 101 |
+
|
| 102 |
+
# Get page content
|
| 103 |
+
current_url = state.visited_urls[-1]
|
| 104 |
+
page_content = ""
|
| 105 |
+
page_links: list[str] = []
|
| 106 |
+
extracted_blocked = False
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
script = build_visual_dom_extract_script(current_url)
|
| 110 |
+
desktop.commands.run(f"cat > /tmp/visual_dom_extract.py << 'EOF'\n{script}\nEOF", timeout=10)
|
| 111 |
+
result = desktop.commands.run("python3 /tmp/visual_dom_extract.py", timeout=45)
|
| 112 |
+
output = result.stdout.strip() if hasattr(result, "stdout") else ""
|
| 113 |
+
data = json.loads(output) if output else {}
|
| 114 |
+
page_content = str(data.get("content", "") or "")
|
| 115 |
+
page_links = [
|
| 116 |
+
link for link in (data.get("links", []) or [])
|
| 117 |
+
if isinstance(link, str) and link.startswith("http")
|
| 118 |
+
]
|
| 119 |
+
extracted_blocked = bool(data.get("blocked", False))
|
| 120 |
+
state.page_content = page_content
|
| 121 |
+
if data.get("error"):
|
| 122 |
+
state.add_error(f"DOM extraction warning: {data['error']}")
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.warning(f"DOM extraction failed: {e}")
|
| 125 |
+
state.add_error(f"DOM extraction failed: {e}")
|
| 126 |
+
|
| 127 |
+
preview_text = page_content[:2000] if page_content else "(empty page)"
|
| 128 |
+
|
| 129 |
+
# Check for Cloudflare block
|
| 130 |
+
is_blocked = extracted_blocked or (is_cloudflare_blocked(page_content) if page_content else False)
|
| 131 |
+
|
| 132 |
+
if is_blocked:
|
| 133 |
+
yield {"type": "status", "message": f"🚫 Cloudflare at {current_url[:40]}..., trying next link..."}
|
| 134 |
+
state.add_error(f"Cloudflare blocked: {current_url}")
|
| 135 |
+
else:
|
| 136 |
+
# Add to memory
|
| 137 |
+
state.extracted_data.append({
|
| 138 |
+
"url": current_url,
|
| 139 |
+
"content_length": len(page_content),
|
| 140 |
+
"links_found": len(page_links),
|
| 141 |
+
"preview": page_content[:200]
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
decision = await decide_browser_action(
|
| 145 |
+
task=task,
|
| 146 |
+
current_url=current_url,
|
| 147 |
+
state=state,
|
| 148 |
+
content_preview=preview_text,
|
| 149 |
+
blocked=is_blocked,
|
| 150 |
+
allow_scroll=False,
|
| 151 |
+
mode_label="visual Chrome",
|
| 152 |
+
step_label=f"{state.step_count}, {remaining}s remaining",
|
| 153 |
+
links=page_links,
|
| 154 |
+
max_tokens=600,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
action = decision.get("action", "DONE")
|
| 158 |
+
value = decision.get("value", "")
|
| 159 |
+
final_answer = decision.get("answer", "")
|
| 160 |
+
reason = decision.get("reason", "")
|
| 161 |
+
known_facts = decision.get("known_facts", [])
|
| 162 |
+
missing_points = decision.get("missing_points", [])
|
| 163 |
+
|
| 164 |
+
if action == "SEARCH":
|
| 165 |
+
state.add_query(value)
|
| 166 |
+
|
| 167 |
+
if isinstance(known_facts, list) or isinstance(missing_points, list):
|
| 168 |
+
state.update_research_progress(
|
| 169 |
+
known_facts=known_facts if isinstance(known_facts, list) else None,
|
| 170 |
+
missing_points=missing_points if isinstance(missing_points, list) else None,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Record action in memory
|
| 174 |
+
state.add_action({"type": action.lower(), "value": value, "reason": reason})
|
| 175 |
+
|
| 176 |
+
yield {"type": "status", "message": f"🤔 Action: {action} - {reason[:50]}"}
|
| 177 |
+
|
| 178 |
+
yield {
|
| 179 |
+
"type": "progress",
|
| 180 |
+
"known_facts": state.known_facts[-8:],
|
| 181 |
+
"missing_points": state.missing_points[-8:],
|
| 182 |
+
"last_queries": state.last_queries[-8:],
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if action == "DONE":
|
| 186 |
+
state.success = True
|
| 187 |
+
|
| 188 |
+
if not final_answer:
|
| 189 |
+
# Generate from memory
|
| 190 |
+
all_content = "\n\n".join([
|
| 191 |
+
f"Source: {d['url']}\n{d.get('preview', '')}"
|
| 192 |
+
for d in state.extracted_data[-5:]
|
| 193 |
+
])
|
| 194 |
+
known_summary = "\n".join([f"- {f}" for f in state.known_facts[-8:]]) or "(none)"
|
| 195 |
+
missing_summary = "\n".join([f"- {m}" for m in state.missing_points[-8:]]) or "(none)"
|
| 196 |
+
final_prompt = (
|
| 197 |
+
f"Based on this content, answer: {task}\n\n"
|
| 198 |
+
f"Known facts:\n{known_summary}\n\n"
|
| 199 |
+
f"Missing points:\n{missing_summary}\n\n"
|
| 200 |
+
f"Content:\n{all_content}"
|
| 201 |
+
)
|
| 202 |
+
final_answer = await generate_completion(
|
| 203 |
+
messages=[{"role": "user", "content": final_prompt}],
|
| 204 |
+
max_tokens=1000
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
state.final_result = final_answer
|
| 208 |
+
|
| 209 |
+
yield {"type": "stream_end", "message": "Done"}
|
| 210 |
+
yield {
|
| 211 |
+
"type": "result",
|
| 212 |
+
"content": final_answer,
|
| 213 |
+
"links": state.visited_urls,
|
| 214 |
+
"steps": state.step_count,
|
| 215 |
+
"success": True
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
yield {"type": "complete", "message": f"Completed in {int(state.get_elapsed_time())}s with {state.step_count} steps"}
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
elif action == "SEARCH":
|
| 222 |
+
new_url = choose_search_url(
|
| 223 |
+
value,
|
| 224 |
+
visited_urls=state.visited_urls,
|
| 225 |
+
current_url=current_url,
|
| 226 |
+
blocked=is_blocked,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
if new_url not in state.visited_urls:
|
| 230 |
+
desktop.commands.run(f"google-chrome {shlex.quote(new_url)} &", background=True)
|
| 231 |
+
desktop.wait(3000)
|
| 232 |
+
state.visited_urls.append(new_url)
|
| 233 |
+
|
| 234 |
+
elif action == "NAVIGATE":
|
| 235 |
+
if value and value.startswith("http"):
|
| 236 |
+
if value in state.visited_urls:
|
| 237 |
+
yield {"type": "status", "message": f"⏭️ Already visited, skipping..."}
|
| 238 |
+
state.add_error(f"Tried to revisit: {value}")
|
| 239 |
+
else:
|
| 240 |
+
desktop.commands.run(f"google-chrome {shlex.quote(value)} &", background=True)
|
| 241 |
+
desktop.wait(3000)
|
| 242 |
+
state.visited_urls.append(value)
|
| 243 |
+
|
| 244 |
+
# Small delay
|
| 245 |
+
desktop.wait(1000)
|
| 246 |
+
|
| 247 |
+
# Timeout - generate from memory
|
| 248 |
+
yield {"type": "status", "message": "⏰ Time limit reached, generating final answer from memory..."}
|
| 249 |
+
|
| 250 |
+
all_content = "\n\n".join([
|
| 251 |
+
f"Source: {d['url']}\n{d.get('preview', '')}"
|
| 252 |
+
for d in state.extracted_data[-5:]
|
| 253 |
+
])
|
| 254 |
+
known_summary = "\n".join([f"- {f}" for f in state.known_facts[-8:]]) or "(none)"
|
| 255 |
+
missing_summary = "\n".join([f"- {m}" for m in state.missing_points[-8:]]) or "(none)"
|
| 256 |
+
final_prompt = (
|
| 257 |
+
f"Based on this content, answer: {task}\n\n"
|
| 258 |
+
f"Known facts:\n{known_summary}\n\n"
|
| 259 |
+
f"Missing points:\n{missing_summary}\n\n"
|
| 260 |
+
f"Content:\n{all_content}"
|
| 261 |
+
)
|
| 262 |
+
final_answer = await generate_completion(
|
| 263 |
+
messages=[{"role": "user", "content": final_prompt}],
|
| 264 |
+
max_tokens=1000
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
state.final_result = final_answer
|
| 268 |
+
|
| 269 |
+
yield {"type": "stream_end", "message": "Done"}
|
| 270 |
+
yield {
|
| 271 |
+
"type": "result",
|
| 272 |
+
"content": final_answer,
|
| 273 |
+
"links": state.visited_urls,
|
| 274 |
+
"steps": state.step_count,
|
| 275 |
+
"success": True
|
| 276 |
+
}
|
| 277 |
+
yield {"type": "complete", "message": f"Completed in {MAX_TIME_SECONDS}s (timeout) with {state.step_count} steps"}
|
| 278 |
+
|
| 279 |
+
except ImportError as e:
|
| 280 |
+
yield {"type": "error", "message": "e2b-desktop not installed"}
|
| 281 |
+
except Exception as e:
|
| 282 |
+
logger.exception("Browser agent error")
|
| 283 |
+
yield {"type": "error", "message": f"Error: {str(e)}"}
|
| 284 |
+
finally:
|
| 285 |
+
if desktop:
|
| 286 |
+
try:
|
| 287 |
+
desktop.stream.stop()
|
| 288 |
+
except:
|
| 289 |
+
pass
|
| 290 |
+
try:
|
| 291 |
+
desktop.kill()
|
| 292 |
+
except:
|
| 293 |
+
pass
|
app/agents/deep_research.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deep Research Orchestrator.
|
| 2 |
+
|
| 3 |
+
Coordinates the full deep research pipeline:
|
| 4 |
+
1. Planning (query decomposition)
|
| 5 |
+
2. Parallel searching (multiple dimensions)
|
| 6 |
+
3. Report synthesis
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
from typing import AsyncIterator, Optional
|
| 13 |
+
|
| 14 |
+
from app.agents.planner import create_research_plan, ResearchPlan, ResearchDimension
|
| 15 |
+
from app.agents.llm_client import generate_completion_stream
|
| 16 |
+
from app.reranking.pipeline import rerank_results
|
| 17 |
+
from app.config import get_settings
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DimensionResult:
|
| 21 |
+
"""Results from researching a single dimension."""
|
| 22 |
+
|
| 23 |
+
def __init__(self, dimension: ResearchDimension):
|
| 24 |
+
self.dimension = dimension
|
| 25 |
+
self.results: list[dict] = []
|
| 26 |
+
self.error: Optional[str] = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def run_deep_research(
|
| 30 |
+
query: str,
|
| 31 |
+
max_dimensions: int = 6,
|
| 32 |
+
max_sources_per_dim: int = 5,
|
| 33 |
+
max_total_searches: int = 20,
|
| 34 |
+
) -> AsyncIterator[str]:
|
| 35 |
+
"""
|
| 36 |
+
Run a deep research pipeline with streaming progress.
|
| 37 |
+
|
| 38 |
+
Yields SSE-formatted events as the research progresses.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
query: The research query
|
| 42 |
+
max_dimensions: Maximum dimensions to research
|
| 43 |
+
max_sources_per_dim: Max results per dimension
|
| 44 |
+
max_total_searches: Total Tavily API calls allowed
|
| 45 |
+
|
| 46 |
+
Yields:
|
| 47 |
+
SSE event strings in format: data: {json}\n\n
|
| 48 |
+
"""
|
| 49 |
+
start_time = time.perf_counter()
|
| 50 |
+
settings = get_settings()
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
# === PHASE 1: PLANNING ===
|
| 54 |
+
yield _sse_event("status", {"phase": "planning", "message": "Analyzing query..."})
|
| 55 |
+
|
| 56 |
+
plan = await create_research_plan(query, max_dimensions)
|
| 57 |
+
|
| 58 |
+
yield _sse_event("plan_ready", {
|
| 59 |
+
"refined_query": plan.refined_query,
|
| 60 |
+
"dimensions": [
|
| 61 |
+
{"name": d.name, "description": d.description, "priority": d.priority}
|
| 62 |
+
for d in plan.dimensions
|
| 63 |
+
],
|
| 64 |
+
"estimated_sources": plan.estimated_sources,
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
# === PHASE 2: PARALLEL SEARCHING ===
|
| 68 |
+
yield _sse_event("status", {"phase": "searching", "message": "Researching dimensions..."})
|
| 69 |
+
|
| 70 |
+
# Distribute search budget across dimensions
|
| 71 |
+
num_dimensions = len(plan.dimensions)
|
| 72 |
+
searches_per_dim = max(1, max_total_searches // num_dimensions)
|
| 73 |
+
|
| 74 |
+
dimension_results: list[DimensionResult] = []
|
| 75 |
+
|
| 76 |
+
# Search dimensions in parallel batches
|
| 77 |
+
for i, dimension in enumerate(plan.dimensions):
|
| 78 |
+
yield _sse_event("dimension_start", {
|
| 79 |
+
"index": i + 1,
|
| 80 |
+
"total": num_dimensions,
|
| 81 |
+
"name": dimension.name,
|
| 82 |
+
"query": dimension.search_query,
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
# Search this dimension
|
| 86 |
+
result = await _search_dimension(
|
| 87 |
+
dimension=dimension,
|
| 88 |
+
max_results=max_sources_per_dim,
|
| 89 |
+
max_searches=searches_per_dim,
|
| 90 |
+
)
|
| 91 |
+
dimension_results.append(result)
|
| 92 |
+
|
| 93 |
+
yield _sse_event("dimension_complete", {
|
| 94 |
+
"index": i + 1,
|
| 95 |
+
"name": dimension.name,
|
| 96 |
+
"results_count": len(result.results),
|
| 97 |
+
"error": result.error,
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
# Small delay to avoid rate limits
|
| 101 |
+
await asyncio.sleep(0.1)
|
| 102 |
+
|
| 103 |
+
# === PHASE 3: SYNTHESIS ===
|
| 104 |
+
yield _sse_event("status", {"phase": "synthesizing", "message": "Generating report..."})
|
| 105 |
+
yield _sse_event("synthesis_start", {})
|
| 106 |
+
|
| 107 |
+
# Stream the report generation
|
| 108 |
+
async for chunk in _synthesize_report_stream(query, plan, dimension_results):
|
| 109 |
+
yield _sse_event("report_chunk", {"content": chunk})
|
| 110 |
+
|
| 111 |
+
# === COMPLETE ===
|
| 112 |
+
total_time = time.perf_counter() - start_time
|
| 113 |
+
total_sources = sum(len(r.results) for r in dimension_results)
|
| 114 |
+
|
| 115 |
+
yield _sse_event("done", {
|
| 116 |
+
"total_sources": total_sources,
|
| 117 |
+
"total_dimensions": num_dimensions,
|
| 118 |
+
"total_time_seconds": round(total_time, 2),
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
except Exception as e:
|
| 122 |
+
yield _sse_event("error", {"message": str(e)})
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
async def _search_dimension(
|
| 126 |
+
dimension: ResearchDimension,
|
| 127 |
+
max_results: int = 5,
|
| 128 |
+
max_searches: int = 2,
|
| 129 |
+
) -> DimensionResult:
|
| 130 |
+
"""Search a single dimension using the aggregator."""
|
| 131 |
+
from app.sources.aggregator import aggregate_search
|
| 132 |
+
|
| 133 |
+
result = DimensionResult(dimension)
|
| 134 |
+
|
| 135 |
+
try:
|
| 136 |
+
# Use aggregator to search all sources
|
| 137 |
+
all_results = await aggregate_search(
|
| 138 |
+
query=dimension.search_query,
|
| 139 |
+
max_results=max_results + 3, # Get extra for reranking
|
| 140 |
+
include_wikipedia=True,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Light reranking (use embeddings when we have many results from SearXNG)
|
| 144 |
+
if all_results:
|
| 145 |
+
use_embeddings = len(all_results) > 15
|
| 146 |
+
ranked = await rerank_results(
|
| 147 |
+
query=dimension.search_query,
|
| 148 |
+
results=all_results,
|
| 149 |
+
temporal_urgency=0.5,
|
| 150 |
+
max_results=max_results,
|
| 151 |
+
use_embeddings=use_embeddings,
|
| 152 |
+
)
|
| 153 |
+
result.results = ranked
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
result.error = str(e)
|
| 157 |
+
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
async def _synthesize_report_stream(
|
| 162 |
+
original_query: str,
|
| 163 |
+
plan: ResearchPlan,
|
| 164 |
+
dimension_results: list[DimensionResult],
|
| 165 |
+
) -> AsyncIterator[str]:
|
| 166 |
+
"""Stream the synthesis of the final report."""
|
| 167 |
+
|
| 168 |
+
# Build context from all dimension results
|
| 169 |
+
context_parts = []
|
| 170 |
+
all_sources = []
|
| 171 |
+
source_index = 1
|
| 172 |
+
|
| 173 |
+
for dr in dimension_results:
|
| 174 |
+
if dr.results:
|
| 175 |
+
context_parts.append(f"\n## {dr.dimension.name}\n")
|
| 176 |
+
for r in dr.results:
|
| 177 |
+
context_parts.append(
|
| 178 |
+
f"[{source_index}] {r.get('title', 'Untitled')}\n"
|
| 179 |
+
f" URL: {r.get('url', '')}\n"
|
| 180 |
+
f" Content: {r.get('content', '')[:400]}...\n"
|
| 181 |
+
)
|
| 182 |
+
all_sources.append({
|
| 183 |
+
"index": source_index,
|
| 184 |
+
"title": r.get("title", ""),
|
| 185 |
+
"url": r.get("url", ""),
|
| 186 |
+
})
|
| 187 |
+
source_index += 1
|
| 188 |
+
|
| 189 |
+
context = "\n".join(context_parts)
|
| 190 |
+
|
| 191 |
+
# Build synthesis prompt
|
| 192 |
+
prompt = f"""You are a research analyst. Create a comprehensive research report based on the gathered information.
|
| 193 |
+
|
| 194 |
+
ORIGINAL QUERY: {original_query}
|
| 195 |
+
REFINED QUERY: {plan.refined_query}
|
| 196 |
+
|
| 197 |
+
RESEARCH DIMENSIONS:
|
| 198 |
+
{', '.join(d.name for d in plan.dimensions)}
|
| 199 |
+
|
| 200 |
+
GATHERED INFORMATION:
|
| 201 |
+
{context}
|
| 202 |
+
|
| 203 |
+
INSTRUCTIONS:
|
| 204 |
+
1. Write a comprehensive research report in Markdown format
|
| 205 |
+
2. Start with an Executive Summary (2-3 paragraphs)
|
| 206 |
+
3. Create a section for each research dimension
|
| 207 |
+
4. Use citations [1], [2], etc. to reference sources
|
| 208 |
+
5. Include a Conclusion section
|
| 209 |
+
6. Be thorough but concise
|
| 210 |
+
7. Write in the same language as the query
|
| 211 |
+
8. Use headers (##) to organize sections
|
| 212 |
+
|
| 213 |
+
Generate the report:"""
|
| 214 |
+
|
| 215 |
+
messages = [
|
| 216 |
+
{"role": "system", "content": "You are a research analyst creating detailed reports."},
|
| 217 |
+
{"role": "user", "content": prompt},
|
| 218 |
+
]
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
async for chunk in generate_completion_stream(messages, temperature=0.4):
|
| 222 |
+
yield chunk
|
| 223 |
+
|
| 224 |
+
# Append sources at the end
|
| 225 |
+
yield "\n\n---\n\n## Sources\n\n"
|
| 226 |
+
for src in all_sources:
|
| 227 |
+
yield f"[{src['index']}] [{src['title']}]({src['url']})\n"
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
yield f"\n\n**Error generating report:** {e}"
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _sse_event(event_type: str, data: dict) -> str:
|
| 234 |
+
"""Format an SSE event."""
|
| 235 |
+
payload = {"type": event_type, **data}
|
| 236 |
+
return f"data: {json.dumps(payload)}\n\n"
|
app/agents/flaresolverr.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FlareSolverr client for Cloudflare bypass.
|
| 2 |
+
|
| 3 |
+
FlareSolverr uses undetected-chromedriver to solve Cloudflare challenges.
|
| 4 |
+
Must be running at http://localhost:8191 in the E2B sandbox.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import logging
|
| 8 |
+
import json
|
| 9 |
+
import shlex
|
| 10 |
+
from typing import Optional, Tuple
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
FLARESOLVERR_URL = "http://localhost:8191/v1"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def solve_cloudflare(desktop, url: str, timeout: int = 60) -> Tuple[bool, str]:
|
| 18 |
+
"""
|
| 19 |
+
Use FlareSolverr to bypass Cloudflare protection.
|
| 20 |
+
|
| 21 |
+
Args:
|
| 22 |
+
desktop: E2B desktop instance
|
| 23 |
+
url: URL to fetch through FlareSolverr
|
| 24 |
+
timeout: Max seconds to wait for solution
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
(success: bool, content: str)
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
# Make request to FlareSolverr - properly escape the JSON payload
|
| 31 |
+
payload = json.dumps({
|
| 32 |
+
"cmd": "request.get",
|
| 33 |
+
"url": url,
|
| 34 |
+
"maxTimeout": timeout * 1000
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
result = desktop.commands.run(
|
| 38 |
+
f"curl -s -X POST {shlex.quote(FLARESOLVERR_URL)} "
|
| 39 |
+
f"-H 'Content-Type: application/json' "
|
| 40 |
+
f"-d {shlex.quote(payload)} 2>/dev/null",
|
| 41 |
+
timeout=timeout + 10
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
if not hasattr(result, 'stdout') or not result.stdout:
|
| 45 |
+
return False, ""
|
| 46 |
+
|
| 47 |
+
response = json.loads(result.stdout)
|
| 48 |
+
|
| 49 |
+
if response.get("status") == "ok":
|
| 50 |
+
solution = response.get("solution", {})
|
| 51 |
+
html = solution.get("response", "")
|
| 52 |
+
|
| 53 |
+
# Strip HTML tags - use base64 to safely pass content
|
| 54 |
+
if html:
|
| 55 |
+
import base64
|
| 56 |
+
html_b64 = base64.b64encode(html[:10000].encode()).decode()
|
| 57 |
+
clean_result = desktop.commands.run(
|
| 58 |
+
f"echo {shlex.quote(html_b64)} | base64 -d | sed 's/<[^>]*>//g' | tr -s ' \\n' ' ' | head -c 6000",
|
| 59 |
+
timeout=5
|
| 60 |
+
)
|
| 61 |
+
content = clean_result.stdout.strip() if hasattr(clean_result, 'stdout') else html[:6000]
|
| 62 |
+
logger.info(f"FlareSolverr solved: {url[:50]}")
|
| 63 |
+
return True, content
|
| 64 |
+
|
| 65 |
+
logger.warning(f"FlareSolverr failed: {response.get('message', 'unknown')}")
|
| 66 |
+
return False, ""
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.warning(f"FlareSolverr error: {e}")
|
| 70 |
+
return False, ""
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def is_cloudflare_blocked(content: str) -> bool:
|
| 74 |
+
"""Check if page content indicates Cloudflare block.
|
| 75 |
+
|
| 76 |
+
Only returns True for actual Cloudflare challenge pages,
|
| 77 |
+
not just pages that mention Cloudflare.
|
| 78 |
+
"""
|
| 79 |
+
content_lower = content.lower()
|
| 80 |
+
|
| 81 |
+
# Must have multiple strong indicators to be considered blocked
|
| 82 |
+
strong_indicators = [
|
| 83 |
+
"checking your browser before accessing",
|
| 84 |
+
"please wait while we verify",
|
| 85 |
+
"ray id:",
|
| 86 |
+
"cloudflare ray id",
|
| 87 |
+
"enable javascript and cookies",
|
| 88 |
+
"attention required! | cloudflare",
|
| 89 |
+
"just a moment...",
|
| 90 |
+
"ddos protection by cloudflare",
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
# Check for strong indicators (need at least 1)
|
| 94 |
+
has_strong = any(ind in content_lower for ind in strong_indicators)
|
| 95 |
+
|
| 96 |
+
# Also check if content is suspiciously short (challenge pages are small)
|
| 97 |
+
is_short = len(content) < 500
|
| 98 |
+
|
| 99 |
+
# Only block if we have strong indicator AND page is short
|
| 100 |
+
# (real content pages that mention cloudflare will be longer)
|
| 101 |
+
if has_strong and is_short:
|
| 102 |
+
return True
|
| 103 |
+
|
| 104 |
+
# Very specific patterns that are definitely challenge pages
|
| 105 |
+
definite_blocks = [
|
| 106 |
+
"checking if the site connection is secure",
|
| 107 |
+
"please turn javascript on and reload the page",
|
| 108 |
+
"please enable cookies",
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
return any(block in content_lower for block in definite_blocks)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def is_login_wall(content: str) -> bool:
|
| 115 |
+
"""Check if page requires login."""
|
| 116 |
+
login_indicators = [
|
| 117 |
+
"sign in",
|
| 118 |
+
"log in",
|
| 119 |
+
"login",
|
| 120 |
+
"create account",
|
| 121 |
+
"register",
|
| 122 |
+
"enter your password",
|
| 123 |
+
"authentication required",
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
content_lower = content.lower()
|
| 127 |
+
# Check for login indicators but make sure it's not just a login link
|
| 128 |
+
return sum(1 for ind in login_indicators if ind in content_lower) >= 2
|
app/agents/graph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Agent Graph Package
|
app/agents/graph/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (137 Bytes). View file
|
|
|
app/agents/graph/__pycache__/nodes.cpython-311.pyc
ADDED
|
Binary file (17.7 kB). View file
|
|
|
app/agents/graph/__pycache__/runner.cpython-311.pyc
ADDED
|
Binary file (6.07 kB). View file
|
|
|
app/agents/graph/__pycache__/simple_agent.cpython-311.pyc
ADDED
|
Binary file (16.7 kB). View file
|
|
|
app/agents/graph/__pycache__/state.cpython-311.pyc
ADDED
|
Binary file (9.74 kB). View file
|
|
|
app/agents/graph/nodes.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph nodes for the agent execution.
|
| 2 |
+
|
| 3 |
+
Each node represents a step in the agent's decision process:
|
| 4 |
+
- PlanNode: Decomposes the task into subtasks
|
| 5 |
+
- SearchNode: Performs web searches
|
| 6 |
+
- NavigateNode: Navigates to URLs
|
| 7 |
+
- ExtractNode: Extracts content from pages
|
| 8 |
+
- VerifyNode: Verifies if goal is achieved
|
| 9 |
+
- RespondNode: Generates final response
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import shlex
|
| 15 |
+
import base64
|
| 16 |
+
from abc import ABC, abstractmethod
|
| 17 |
+
from typing import Tuple
|
| 18 |
+
|
| 19 |
+
from app.agents.graph.state import AgentState, NodeType
|
| 20 |
+
from app.agents.llm_client import generate_completion
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BaseNode(ABC):
|
| 26 |
+
"""Base class for all graph nodes."""
|
| 27 |
+
|
| 28 |
+
node_type: NodeType = NodeType.START
|
| 29 |
+
|
| 30 |
+
@abstractmethod
|
| 31 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 32 |
+
"""Execute the node logic and return updated state + next node."""
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class PlanNode(BaseNode):
|
| 37 |
+
"""Decomposes task into subtasks."""
|
| 38 |
+
|
| 39 |
+
node_type = NodeType.PLAN
|
| 40 |
+
|
| 41 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 42 |
+
prompt = f"""Você é um planejador de tarefas. Decomponha a tarefa em passos simples.
|
| 43 |
+
|
| 44 |
+
TAREFA: {state.task}
|
| 45 |
+
URL inicial: {state.url or 'Nenhuma - começar com busca'}
|
| 46 |
+
|
| 47 |
+
Responda com JSON:
|
| 48 |
+
{{
|
| 49 |
+
"goal": "objetivo principal",
|
| 50 |
+
"steps": [
|
| 51 |
+
{{"action": "search", "query": "termos de busca"}},
|
| 52 |
+
{{"action": "navigate", "description": "onde navegar"}},
|
| 53 |
+
{{"action": "extract", "what": "o que extrair"}}
|
| 54 |
+
],
|
| 55 |
+
"success_criteria": "critério de sucesso"
|
| 56 |
+
}}
|
| 57 |
+
|
| 58 |
+
Responda APENAS o JSON, sem explicação."""
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
response = await generate_completion(
|
| 62 |
+
messages=[{"role": "user", "content": prompt}],
|
| 63 |
+
max_tokens=500
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Parse JSON
|
| 67 |
+
response = response.strip()
|
| 68 |
+
if response.startswith("```"):
|
| 69 |
+
response = response.split("```")[1]
|
| 70 |
+
if response.startswith("json"):
|
| 71 |
+
response = response[4:]
|
| 72 |
+
|
| 73 |
+
plan = json.loads(response)
|
| 74 |
+
state.plan = plan
|
| 75 |
+
logger.info(f"Plan created: {plan.get('goal', 'No goal')}")
|
| 76 |
+
|
| 77 |
+
# Decide next node based on plan
|
| 78 |
+
if plan.get("steps") and plan["steps"][0].get("action") == "navigate" and state.url:
|
| 79 |
+
return state, NodeType.NAVIGATE
|
| 80 |
+
return state, NodeType.SEARCH
|
| 81 |
+
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error(f"Planning failed: {e}")
|
| 84 |
+
state.add_error(f"Planning failed: {e}")
|
| 85 |
+
# Fallback to search
|
| 86 |
+
state.plan = {"goal": state.task, "steps": [{"action": "search", "query": state.task}]}
|
| 87 |
+
return state, NodeType.SEARCH
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class SearchNode(BaseNode):
|
| 91 |
+
"""Performs web search."""
|
| 92 |
+
|
| 93 |
+
node_type = NodeType.SEARCH
|
| 94 |
+
|
| 95 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 96 |
+
desktop = state.desktop
|
| 97 |
+
|
| 98 |
+
# Determine search query
|
| 99 |
+
query = state.task
|
| 100 |
+
if state.plan.get("steps"):
|
| 101 |
+
for step in state.plan["steps"]:
|
| 102 |
+
if step.get("action") == "search" and step.get("query"):
|
| 103 |
+
query = step["query"]
|
| 104 |
+
break
|
| 105 |
+
|
| 106 |
+
# Execute search
|
| 107 |
+
search_url = f"https://html.duckduckgo.com/html/?q={query.replace(' ', '+')}"
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
desktop.commands.run(f"google-chrome {shlex.quote(search_url)} &", background=True)
|
| 111 |
+
state.visited_urls.append(search_url)
|
| 112 |
+
desktop.wait(3000)
|
| 113 |
+
|
| 114 |
+
state.add_action({"type": "search", "query": query})
|
| 115 |
+
logger.info(f"Searched: {query}")
|
| 116 |
+
|
| 117 |
+
return state, NodeType.EXTRACT
|
| 118 |
+
|
| 119 |
+
except Exception as e:
|
| 120 |
+
state.add_error(f"Search failed: {e}")
|
| 121 |
+
return state, NodeType.VERIFY
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class NavigateNode(BaseNode):
|
| 125 |
+
"""Navigates to a URL."""
|
| 126 |
+
|
| 127 |
+
node_type = NodeType.NAVIGATE
|
| 128 |
+
|
| 129 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 130 |
+
desktop = state.desktop
|
| 131 |
+
|
| 132 |
+
# Get URL to navigate
|
| 133 |
+
url = state.url
|
| 134 |
+
if not url and state.extracted_data:
|
| 135 |
+
# Try to get URL from extracted links
|
| 136 |
+
last_data = state.extracted_data[-1]
|
| 137 |
+
if "links" in last_data.get("data", {}):
|
| 138 |
+
links = last_data["data"]["links"]
|
| 139 |
+
if links:
|
| 140 |
+
url = links[0]
|
| 141 |
+
|
| 142 |
+
if not url:
|
| 143 |
+
return state, NodeType.SEARCH
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
desktop.commands.run(f"google-chrome {shlex.quote(url)} &", background=True)
|
| 147 |
+
if url not in state.visited_urls:
|
| 148 |
+
state.visited_urls.append(url)
|
| 149 |
+
desktop.wait(3000)
|
| 150 |
+
|
| 151 |
+
state.add_action({"type": "navigate", "url": url})
|
| 152 |
+
logger.info(f"Navigated to: {url[:50]}")
|
| 153 |
+
|
| 154 |
+
return state, NodeType.EXTRACT
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
state.add_error(f"Navigation failed: {e}")
|
| 158 |
+
return state, NodeType.SEARCH
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class ExtractNode(BaseNode):
|
| 162 |
+
"""Extracts content from current page."""
|
| 163 |
+
|
| 164 |
+
node_type = NodeType.EXTRACT
|
| 165 |
+
|
| 166 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 167 |
+
desktop = state.desktop
|
| 168 |
+
current_url = state.visited_urls[-1] if state.visited_urls else ""
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
# Get window title
|
| 172 |
+
result = desktop.commands.run("xdotool getactivewindow getwindowname 2>/dev/null", timeout=5)
|
| 173 |
+
state.window_title = result.stdout.strip() if hasattr(result, 'stdout') else ""
|
| 174 |
+
|
| 175 |
+
# Extract page content via curl
|
| 176 |
+
if current_url.startswith("http"):
|
| 177 |
+
result = desktop.commands.run(
|
| 178 |
+
f"curl -sL --max-time 10 --connect-timeout 5 "
|
| 179 |
+
f"-A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' "
|
| 180 |
+
f"'{current_url}' 2>/dev/null | "
|
| 181 |
+
"sed -e 's/<script[^>]*>.*<\\/script>//g' -e 's/<style[^>]*>.*<\\/style>//g' | "
|
| 182 |
+
"sed 's/<[^>]*>//g' | "
|
| 183 |
+
"tr -s ' \\n' ' ' | "
|
| 184 |
+
"head -c 6000",
|
| 185 |
+
timeout=15
|
| 186 |
+
)
|
| 187 |
+
state.page_content = result.stdout.strip() if hasattr(result, 'stdout') else ""
|
| 188 |
+
|
| 189 |
+
state.add_action({"type": "extract", "content_length": len(state.page_content)})
|
| 190 |
+
logger.info(f"Extracted {len(state.page_content)} chars from {current_url[:50]}")
|
| 191 |
+
|
| 192 |
+
return state, NodeType.VERIFY
|
| 193 |
+
|
| 194 |
+
except Exception as e:
|
| 195 |
+
state.add_error(f"Extraction failed: {e}")
|
| 196 |
+
return state, NodeType.VERIFY
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class VerifyNode(BaseNode):
|
| 200 |
+
"""Verifies if goal is achieved and decides next action."""
|
| 201 |
+
|
| 202 |
+
node_type = NodeType.VERIFY
|
| 203 |
+
|
| 204 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 205 |
+
context = state.get_context_for_llm()
|
| 206 |
+
page_preview = state.page_content[:4000] if state.page_content else "(No content)"
|
| 207 |
+
|
| 208 |
+
prompt = f"""Você é um agente de navegação web. Analise o conteúdo e decida o próximo passo.
|
| 209 |
+
|
| 210 |
+
TAREFA: {state.task}
|
| 211 |
+
PLANO: {state.plan.get('goal', 'Nenhum')}
|
| 212 |
+
CRITÉRIO DE SUCESSO: {state.plan.get('success_criteria', 'Encontrar a informação pedida')}
|
| 213 |
+
|
| 214 |
+
HISTÓRICO:
|
| 215 |
+
{context}
|
| 216 |
+
|
| 217 |
+
CONTEÚDO DA PÁGINA ATUAL:
|
| 218 |
+
{page_preview}
|
| 219 |
+
|
| 220 |
+
TEMPO RESTANTE: {int(state.get_remaining_time())}s
|
| 221 |
+
|
| 222 |
+
Decida:
|
| 223 |
+
1. Se encontrou a resposta, retorne: {{"status": "complete", "result": "Sua resposta formatada com **negrito** para valores importantes"}}
|
| 224 |
+
2. Se precisa buscar mais, retorne: {{"action": "search", "query": "nova busca"}}
|
| 225 |
+
3. Se precisa navegar para um link, retorne: {{"action": "navigate", "url": "https://..."}}
|
| 226 |
+
4. Se precisa rolar a página, retorne: {{"action": "scroll"}}
|
| 227 |
+
|
| 228 |
+
REGRAS:
|
| 229 |
+
- Use **negrito** para preços e valores importantes
|
| 230 |
+
- Cite as fontes
|
| 231 |
+
- Se página pede login, tente outra fonte
|
| 232 |
+
- Seja eficiente
|
| 233 |
+
|
| 234 |
+
Responda APENAS com JSON válido."""
|
| 235 |
+
|
| 236 |
+
try:
|
| 237 |
+
response = await generate_completion(
|
| 238 |
+
messages=[{"role": "user", "content": prompt}],
|
| 239 |
+
max_tokens=800
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
# Parse response
|
| 243 |
+
response = response.strip()
|
| 244 |
+
if response.startswith("```"):
|
| 245 |
+
response = response.split("```")[1]
|
| 246 |
+
if response.startswith("json"):
|
| 247 |
+
response = response[4:]
|
| 248 |
+
|
| 249 |
+
decision = json.loads(response)
|
| 250 |
+
state.add_action({"type": "verify", "decision": decision})
|
| 251 |
+
|
| 252 |
+
# Route based on decision
|
| 253 |
+
if decision.get("status") == "complete":
|
| 254 |
+
state.final_result = decision.get("result", "")
|
| 255 |
+
state.success = True
|
| 256 |
+
logger.info("Goal achieved!")
|
| 257 |
+
return state, NodeType.RESPOND
|
| 258 |
+
|
| 259 |
+
action = decision.get("action", "")
|
| 260 |
+
if action == "search":
|
| 261 |
+
# Update plan with new search
|
| 262 |
+
state.plan["steps"] = [{"action": "search", "query": decision.get("query", state.task)}]
|
| 263 |
+
return state, NodeType.SEARCH
|
| 264 |
+
elif action == "navigate":
|
| 265 |
+
state.url = decision.get("url", "")
|
| 266 |
+
return state, NodeType.NAVIGATE
|
| 267 |
+
elif action == "scroll":
|
| 268 |
+
state.desktop.scroll(-3)
|
| 269 |
+
state.desktop.wait(1000)
|
| 270 |
+
return state, NodeType.EXTRACT
|
| 271 |
+
|
| 272 |
+
# Default: try another search
|
| 273 |
+
return state, NodeType.SEARCH
|
| 274 |
+
|
| 275 |
+
except Exception as e:
|
| 276 |
+
logger.error(f"Verify failed: {e}")
|
| 277 |
+
state.add_error(f"Verify failed: {e}")
|
| 278 |
+
|
| 279 |
+
# If we have some content, try to respond anyway
|
| 280 |
+
if state.get_remaining_time() < 30:
|
| 281 |
+
return state, NodeType.RESPOND
|
| 282 |
+
return state, NodeType.SEARCH
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class RespondNode(BaseNode):
|
| 286 |
+
"""Generates final response."""
|
| 287 |
+
|
| 288 |
+
node_type = NodeType.RESPOND
|
| 289 |
+
|
| 290 |
+
async def execute(self, state: AgentState) -> Tuple[AgentState, NodeType]:
|
| 291 |
+
# If we already have a result, we're done
|
| 292 |
+
if state.final_result:
|
| 293 |
+
state.success = True
|
| 294 |
+
return state, NodeType.RESPOND
|
| 295 |
+
|
| 296 |
+
# Generate response from collected data
|
| 297 |
+
context = state.get_context_for_llm()
|
| 298 |
+
page_content = state.page_content[:3000] if state.page_content else "(Nenhum conteúdo extraído)"
|
| 299 |
+
|
| 300 |
+
prompt = f"""Você realizou uma tarefa de navegação web. Sintetize os resultados.
|
| 301 |
+
|
| 302 |
+
TAREFA: {state.task}
|
| 303 |
+
|
| 304 |
+
DADOS COLETADOS:
|
| 305 |
+
{context}
|
| 306 |
+
|
| 307 |
+
ÚLTIMO CONTEÚDO DA PÁGINA:
|
| 308 |
+
{page_content}
|
| 309 |
+
|
| 310 |
+
URLs VISITADAS:
|
| 311 |
+
{chr(10).join(state.visited_urls[:5]) if state.visited_urls else '(Nenhuma)'}
|
| 312 |
+
|
| 313 |
+
INSTRUÇÕES:
|
| 314 |
+
- Gere uma resposta útil baseada no que foi encontrado
|
| 315 |
+
- Use **negrito** para valores importantes (preços, números, nomes)
|
| 316 |
+
- Cite as fontes quando possível
|
| 317 |
+
- Se não encontrou o que foi pedido, explique o que encontrou ou diga honestamente que não encontrou
|
| 318 |
+
|
| 319 |
+
Responda em português de forma clara e organizada."""
|
| 320 |
+
|
| 321 |
+
try:
|
| 322 |
+
response = await generate_completion(
|
| 323 |
+
messages=[{"role": "user", "content": prompt}],
|
| 324 |
+
max_tokens=1000
|
| 325 |
+
)
|
| 326 |
+
state.final_result = response.strip()
|
| 327 |
+
state.success = bool(state.final_result)
|
| 328 |
+
logger.info(f"Generated response: {len(state.final_result)} chars")
|
| 329 |
+
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(f"Response generation failed: {e}")
|
| 332 |
+
# Fallback: create response from available data
|
| 333 |
+
if state.page_content:
|
| 334 |
+
state.final_result = f"**Informação encontrada:**\n\n{state.page_content[:500]}...\n\n*Fonte: {state.visited_urls[-1] if state.visited_urls else 'desconhecida'}*"
|
| 335 |
+
else:
|
| 336 |
+
state.final_result = f"Não foi possível completar a tarefa. Erro: {e}"
|
| 337 |
+
|
| 338 |
+
return state, NodeType.RESPOND
|
app/agents/graph/runner.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Graph runner - executes the agent graph.
|
| 2 |
+
|
| 3 |
+
The runner orchestrates node execution, manages state transitions,
|
| 4 |
+
and yields status updates for streaming.
|
| 5 |
+
|
| 6 |
+
Uses timeout-based execution instead of fixed iteration count.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import time
|
| 11 |
+
from typing import AsyncGenerator, Dict, Type
|
| 12 |
+
|
| 13 |
+
from app.agents.graph.state import AgentState, NodeType
|
| 14 |
+
from app.agents.graph.nodes import (
|
| 15 |
+
BaseNode,
|
| 16 |
+
PlanNode,
|
| 17 |
+
SearchNode,
|
| 18 |
+
NavigateNode,
|
| 19 |
+
ExtractNode,
|
| 20 |
+
VerifyNode,
|
| 21 |
+
RespondNode,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Node registry
|
| 27 |
+
NODE_REGISTRY: Dict[NodeType, Type[BaseNode]] = {
|
| 28 |
+
NodeType.PLAN: PlanNode,
|
| 29 |
+
NodeType.SEARCH: SearchNode,
|
| 30 |
+
NodeType.NAVIGATE: NavigateNode,
|
| 31 |
+
NodeType.EXTRACT: ExtractNode,
|
| 32 |
+
NodeType.VERIFY: VerifyNode,
|
| 33 |
+
NodeType.RESPOND: RespondNode,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
# Status messages with emojis
|
| 37 |
+
STATUS_MESSAGES = {
|
| 38 |
+
NodeType.PLAN: "🎯 Planning task...",
|
| 39 |
+
NodeType.SEARCH: "🔍 Searching...",
|
| 40 |
+
NodeType.NAVIGATE: "🌐 Navigating...",
|
| 41 |
+
NodeType.EXTRACT: "📊 Extracting content...",
|
| 42 |
+
NodeType.VERIFY: "🤔 Analyzing...",
|
| 43 |
+
NodeType.RESPOND: "✅ Generating response...",
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def run_graph(state: AgentState) -> AsyncGenerator[dict, None]:
|
| 48 |
+
"""Run the agent graph and yield status updates.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
state: Initial agent state with task, url, and desktop
|
| 52 |
+
|
| 53 |
+
Yields:
|
| 54 |
+
Status updates and final result
|
| 55 |
+
"""
|
| 56 |
+
# Initialize timing
|
| 57 |
+
state.start_time = time.time()
|
| 58 |
+
current_node_type = NodeType.PLAN
|
| 59 |
+
state.current_node = current_node_type
|
| 60 |
+
|
| 61 |
+
logger.info(f"Starting graph execution for task: {state.task[:50]}, timeout: {state.timeout_seconds}s")
|
| 62 |
+
|
| 63 |
+
while state.should_continue():
|
| 64 |
+
state.step_count += 1
|
| 65 |
+
state.current_node = current_node_type
|
| 66 |
+
|
| 67 |
+
# Get node instance
|
| 68 |
+
node_class = NODE_REGISTRY.get(current_node_type)
|
| 69 |
+
if not node_class:
|
| 70 |
+
logger.error(f"Unknown node type: {current_node_type}")
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
node = node_class()
|
| 74 |
+
|
| 75 |
+
# Calculate remaining time
|
| 76 |
+
remaining = int(state.get_remaining_time())
|
| 77 |
+
elapsed = int(state.get_elapsed_time())
|
| 78 |
+
|
| 79 |
+
# Yield status update
|
| 80 |
+
status_msg = STATUS_MESSAGES.get(current_node_type, "Processing...")
|
| 81 |
+
if current_node_type == NodeType.SEARCH and state.plan.get("steps"):
|
| 82 |
+
for step in state.plan["steps"]:
|
| 83 |
+
if step.get("action") == "search":
|
| 84 |
+
status_msg = f"🔍 Searching: {step.get('query', state.task)[:40]}..."
|
| 85 |
+
break
|
| 86 |
+
elif current_node_type == NodeType.NAVIGATE and state.url:
|
| 87 |
+
status_msg = f"🌐 Navigating to {state.url[:40]}..."
|
| 88 |
+
|
| 89 |
+
yield {
|
| 90 |
+
"type": "status",
|
| 91 |
+
"message": f"{status_msg} (step {state.step_count}, {remaining}s remaining)"
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
# Execute node
|
| 95 |
+
try:
|
| 96 |
+
state, next_node_type = await node.execute(state)
|
| 97 |
+
logger.info(f"Step {state.step_count}: {current_node_type.value} -> {next_node_type.value} ({elapsed}s elapsed)")
|
| 98 |
+
|
| 99 |
+
# Check if we're done
|
| 100 |
+
if current_node_type == NodeType.RESPOND:
|
| 101 |
+
break
|
| 102 |
+
|
| 103 |
+
# Transition to next node
|
| 104 |
+
current_node_type = next_node_type
|
| 105 |
+
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.exception(f"Node execution failed: {e}")
|
| 108 |
+
state.add_error(str(e))
|
| 109 |
+
|
| 110 |
+
# If running low on time, try to respond
|
| 111 |
+
if state.get_remaining_time() < 30:
|
| 112 |
+
current_node_type = NodeType.RESPOND
|
| 113 |
+
else:
|
| 114 |
+
current_node_type = NodeType.SEARCH
|
| 115 |
+
|
| 116 |
+
# If we timed out without a result, generate one from what we have
|
| 117 |
+
if not state.final_result and not state.success:
|
| 118 |
+
logger.warning("Timeout reached, forcing response generation")
|
| 119 |
+
respond_node = RespondNode()
|
| 120 |
+
state, _ = await respond_node.execute(state)
|
| 121 |
+
|
| 122 |
+
# Yield final result
|
| 123 |
+
yield {
|
| 124 |
+
"type": "result",
|
| 125 |
+
"content": state.final_result,
|
| 126 |
+
"links": state.visited_urls[:10],
|
| 127 |
+
"success": state.success
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
yield {"type": "complete", "message": f"Task completed in {int(state.get_elapsed_time())}s"}
|
| 131 |
+
|
| 132 |
+
logger.info(f"Graph execution complete. Success: {state.success}, Steps: {state.step_count}, Time: {state.get_elapsed_time():.1f}s")
|
| 133 |
+
|
app/agents/graph/simple_agent.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Simplified agent nodes - ONE LLM call per cycle.
|
| 2 |
+
|
| 3 |
+
DAG:
|
| 4 |
+
START → THINK_ACT ←→ EXECUTE → RESPOND
|
| 5 |
+
↑______________|
|
| 6 |
+
|
| 7 |
+
ThinkAndAct: Analyzes content + decides action in ONE call
|
| 8 |
+
Execute: Runs the action (search, navigate, scroll) - NO LLM
|
| 9 |
+
Respond: Final synthesis
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import shlex
|
| 15 |
+
import time
|
| 16 |
+
from abc import ABC, abstractmethod
|
| 17 |
+
from typing import Tuple, Optional, List
|
| 18 |
+
|
| 19 |
+
from app.agents.llm_client import generate_completion
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class SimpleState:
|
| 25 |
+
"""Minimal state for the agent."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, task: str, url: Optional[str], desktop, timeout: float = 300):
|
| 28 |
+
self.task = task
|
| 29 |
+
self.url = url
|
| 30 |
+
self.desktop = desktop
|
| 31 |
+
self.timeout = timeout
|
| 32 |
+
self.start_time = time.time()
|
| 33 |
+
|
| 34 |
+
# Memory - content cache (URL -> content)
|
| 35 |
+
self.content_cache: dict = {} # {url: content}
|
| 36 |
+
self.visited_urls: List[str] = []
|
| 37 |
+
self.action_history: List[str] = []
|
| 38 |
+
|
| 39 |
+
# Accumulated knowledge
|
| 40 |
+
self.findings: List[str] = [] # Key findings extracted
|
| 41 |
+
|
| 42 |
+
# Result
|
| 43 |
+
self.final_result = ""
|
| 44 |
+
self.done = False
|
| 45 |
+
|
| 46 |
+
def elapsed(self) -> float:
|
| 47 |
+
return time.time() - self.start_time
|
| 48 |
+
|
| 49 |
+
def remaining(self) -> float:
|
| 50 |
+
return max(0, self.timeout - self.elapsed())
|
| 51 |
+
|
| 52 |
+
def should_continue(self) -> bool:
|
| 53 |
+
return not self.done and self.remaining() > 20
|
| 54 |
+
|
| 55 |
+
def add_page(self, url: str, content: str):
|
| 56 |
+
"""Add page to cache - no duplicate fetching."""
|
| 57 |
+
if url not in self.content_cache:
|
| 58 |
+
self.content_cache[url] = content[:4000]
|
| 59 |
+
if url not in self.visited_urls:
|
| 60 |
+
self.visited_urls.append(url)
|
| 61 |
+
|
| 62 |
+
def get_cached_content(self, url: str) -> Optional[str]:
|
| 63 |
+
"""Get content from cache if available."""
|
| 64 |
+
return self.content_cache.get(url)
|
| 65 |
+
|
| 66 |
+
def add_finding(self, finding: str):
|
| 67 |
+
"""Add a key finding to memory."""
|
| 68 |
+
if finding and finding not in self.findings:
|
| 69 |
+
self.findings.append(finding)
|
| 70 |
+
|
| 71 |
+
def get_all_content(self) -> str:
|
| 72 |
+
"""Get all cached content for final synthesis."""
|
| 73 |
+
parts = []
|
| 74 |
+
for url in self.visited_urls[-5:]:
|
| 75 |
+
content = self.content_cache.get(url, "")
|
| 76 |
+
if content:
|
| 77 |
+
parts.append(f"[{url[:60]}]\n{content[:1500]}")
|
| 78 |
+
return "\n\n---\n\n".join(parts)
|
| 79 |
+
|
| 80 |
+
def get_recent_content(self) -> str:
|
| 81 |
+
"""Get last 2 pages content for context."""
|
| 82 |
+
recent_urls = self.visited_urls[-2:] if self.visited_urls else []
|
| 83 |
+
parts = []
|
| 84 |
+
for url in recent_urls:
|
| 85 |
+
content = self.content_cache.get(url, "")
|
| 86 |
+
if content:
|
| 87 |
+
parts.append(f"[{url[:60]}]\n{content[:2000]}")
|
| 88 |
+
return "\n\n---\n\n".join(parts)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
async def think_and_act(state: SimpleState) -> Tuple[str, dict]:
|
| 92 |
+
"""
|
| 93 |
+
ONE LLM call that analyzes current state and decides next action.
|
| 94 |
+
Returns: (action_type, action_params)
|
| 95 |
+
|
| 96 |
+
Actions:
|
| 97 |
+
- search: {"query": "..."}
|
| 98 |
+
- navigate: {"url": "..."}
|
| 99 |
+
- scroll: {}
|
| 100 |
+
- complete: {"result": "..."}
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
content = state.get_recent_content() or "(No content yet)"
|
| 104 |
+
history = ", ".join(state.action_history[-5:]) if state.action_history else "(starting)"
|
| 105 |
+
|
| 106 |
+
# Memory: show visited URLs so LLM doesn't repeat
|
| 107 |
+
visited = "\n".join([f" - {u[:70]}" for u in state.visited_urls[-10:]]) if state.visited_urls else "(none)"
|
| 108 |
+
|
| 109 |
+
prompt = f"""You are a web research agent. Analyze the current state and decide your next action.
|
| 110 |
+
|
| 111 |
+
TASK: {state.task}
|
| 112 |
+
|
| 113 |
+
ALREADY VISITED (DO NOT visit again):
|
| 114 |
+
{visited}
|
| 115 |
+
|
| 116 |
+
CURRENT PAGE CONTENT:
|
| 117 |
+
{content}
|
| 118 |
+
|
| 119 |
+
HISTORY: {history}
|
| 120 |
+
TIME REMAINING: {int(state.remaining())}s
|
| 121 |
+
|
| 122 |
+
Decide ONE action. Return JSON:
|
| 123 |
+
|
| 124 |
+
If you need to search: {{"action": "search", "query": "search terms"}}
|
| 125 |
+
If you found a NEW relevant link to visit: {{"action": "navigate", "url": "https://..."}}
|
| 126 |
+
If you need to scroll for more content: {{"action": "scroll"}}
|
| 127 |
+
If you have enough info to answer: {{"action": "complete", "result": "Your answer with **bold** for important values. Cite sources."}}
|
| 128 |
+
|
| 129 |
+
RULES:
|
| 130 |
+
- DO NOT navigate to URLs already in "ALREADY VISITED" list
|
| 131 |
+
- Only use URLs you see in the content above
|
| 132 |
+
- If you see the answer, return complete immediately
|
| 133 |
+
- Use **bold** for prices, numbers, names
|
| 134 |
+
- Be efficient - don't repeat searches
|
| 135 |
+
|
| 136 |
+
Return ONLY valid JSON:"""
|
| 137 |
+
|
| 138 |
+
try:
|
| 139 |
+
response = await generate_completion(
|
| 140 |
+
messages=[{"role": "user", "content": prompt}],
|
| 141 |
+
max_tokens=800
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
# Parse JSON
|
| 145 |
+
response = response.strip()
|
| 146 |
+
if response.startswith("```"):
|
| 147 |
+
response = response.split("```")[1]
|
| 148 |
+
if response.startswith("json"):
|
| 149 |
+
response = response[4:]
|
| 150 |
+
|
| 151 |
+
decision = json.loads(response)
|
| 152 |
+
action = decision.get("action", "search")
|
| 153 |
+
|
| 154 |
+
# Safety check: prevent navigating to already visited URL
|
| 155 |
+
if action == "navigate":
|
| 156 |
+
url = decision.get("url", "").rstrip("/")
|
| 157 |
+
|
| 158 |
+
# Check if URL already visited (normalize by removing trailing slash)
|
| 159 |
+
visited_normalized = [u.rstrip("/") for u in state.visited_urls]
|
| 160 |
+
if url in visited_normalized or url in state.visited_urls:
|
| 161 |
+
logger.warning(f"LLM tried to revisit {url}, trying different approach")
|
| 162 |
+
|
| 163 |
+
# If we have good content, finish
|
| 164 |
+
good_content = [c for c in state.content_cache.values()
|
| 165 |
+
if c and c not in ["[BLOCKED]", "[LOGIN_REQUIRED]"]]
|
| 166 |
+
if good_content:
|
| 167 |
+
return "complete", {"result": f"Informação coletada: {state.get_recent_content()[:800]}"}
|
| 168 |
+
|
| 169 |
+
# Otherwise, search with different terms
|
| 170 |
+
return "search", {"query": f"{state.task} site:wikipedia.org OR site:gov.br"}
|
| 171 |
+
|
| 172 |
+
logger.info(f"ThinkAndAct decision: {action}")
|
| 173 |
+
return action, decision
|
| 174 |
+
|
| 175 |
+
except Exception as e:
|
| 176 |
+
logger.error(f"ThinkAndAct failed: {e}")
|
| 177 |
+
# Fallback: if we have content, try to respond
|
| 178 |
+
if state.content_cache:
|
| 179 |
+
return "complete", {"result": f"Based on collected data: {state.get_recent_content()[:500]}"}
|
| 180 |
+
return "search", {"query": state.task}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def execute_action(state: SimpleState, action: str, params: dict) -> bool:
|
| 184 |
+
"""
|
| 185 |
+
Execute action WITHOUT LLM call.
|
| 186 |
+
Uses cache to avoid repeated requests.
|
| 187 |
+
Returns True if should continue, False if done.
|
| 188 |
+
"""
|
| 189 |
+
desktop = state.desktop
|
| 190 |
+
|
| 191 |
+
if action == "complete":
|
| 192 |
+
state.final_result = params.get("result", "")
|
| 193 |
+
state.done = True
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
elif action == "search":
|
| 197 |
+
query = params.get("query", state.task)
|
| 198 |
+
search_url = f"https://html.duckduckgo.com/html/?q={query.replace(' ', '+')}"
|
| 199 |
+
|
| 200 |
+
# Check cache first
|
| 201 |
+
cached = state.get_cached_content(search_url)
|
| 202 |
+
if cached:
|
| 203 |
+
logger.info(f"Using cached content for search: {query[:30]}")
|
| 204 |
+
state.action_history.append(f"search(cached):{query[:30]}")
|
| 205 |
+
return True
|
| 206 |
+
|
| 207 |
+
desktop.commands.run(f"google-chrome {shlex.quote(search_url)} &", background=True)
|
| 208 |
+
desktop.wait(3000)
|
| 209 |
+
|
| 210 |
+
content = await _extract_content(desktop, search_url)
|
| 211 |
+
state.add_page(search_url, content)
|
| 212 |
+
state.action_history.append(f"search:{query[:30]}")
|
| 213 |
+
|
| 214 |
+
return True
|
| 215 |
+
|
| 216 |
+
elif action == "navigate":
|
| 217 |
+
url = params.get("url", "")
|
| 218 |
+
if not url.startswith("http"):
|
| 219 |
+
return True # Invalid URL, continue
|
| 220 |
+
|
| 221 |
+
# Check cache first - don't re-fetch
|
| 222 |
+
cached = state.get_cached_content(url)
|
| 223 |
+
if cached:
|
| 224 |
+
logger.info(f"Using cached content for: {url[:50]}")
|
| 225 |
+
state.action_history.append(f"nav(cached):{url[:30]}")
|
| 226 |
+
return True
|
| 227 |
+
|
| 228 |
+
desktop.commands.run(f"google-chrome {shlex.quote(url)} &", background=True)
|
| 229 |
+
desktop.wait(3000)
|
| 230 |
+
|
| 231 |
+
content = await _extract_content(desktop, url)
|
| 232 |
+
|
| 233 |
+
# Check for Cloudflare/bot detection - just skip if blocked
|
| 234 |
+
from app.agents.flaresolverr import is_cloudflare_blocked, is_login_wall
|
| 235 |
+
|
| 236 |
+
if is_cloudflare_blocked(content):
|
| 237 |
+
logger.warning(f"Cloudflare block detected at {url[:50]}, skipping...")
|
| 238 |
+
# Mark as visited so LLM doesn't try again
|
| 239 |
+
if url not in state.visited_urls:
|
| 240 |
+
state.visited_urls.append(url)
|
| 241 |
+
state.content_cache[url] = "[BLOCKED]" # Mark as blocked in cache
|
| 242 |
+
state.action_history.append(f"nav(blocked):{url[:30]}")
|
| 243 |
+
return True
|
| 244 |
+
|
| 245 |
+
if is_login_wall(content):
|
| 246 |
+
logger.warning(f"Login wall detected at {url[:50]}, skipping...")
|
| 247 |
+
# Mark as visited so LLM doesn't try again
|
| 248 |
+
if url not in state.visited_urls:
|
| 249 |
+
state.visited_urls.append(url)
|
| 250 |
+
state.content_cache[url] = "[LOGIN_REQUIRED]" # Mark in cache
|
| 251 |
+
state.action_history.append(f"nav(login_wall):{url[:30]}")
|
| 252 |
+
return True
|
| 253 |
+
|
| 254 |
+
state.add_page(url, content)
|
| 255 |
+
state.action_history.append(f"nav:{url[:30]}")
|
| 256 |
+
|
| 257 |
+
return True
|
| 258 |
+
|
| 259 |
+
elif action == "scroll":
|
| 260 |
+
desktop.scroll(-3)
|
| 261 |
+
desktop.wait(1500)
|
| 262 |
+
|
| 263 |
+
# Update cache for current page with new content
|
| 264 |
+
if state.visited_urls:
|
| 265 |
+
current_url = state.visited_urls[-1]
|
| 266 |
+
content = await _extract_content(desktop, current_url)
|
| 267 |
+
state.content_cache[current_url] = content[:4000] # Update cache
|
| 268 |
+
|
| 269 |
+
state.action_history.append("scroll")
|
| 270 |
+
return True
|
| 271 |
+
|
| 272 |
+
return True
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
async def _extract_content(desktop, url: str) -> str:
|
| 276 |
+
"""Extract page content via curl."""
|
| 277 |
+
try:
|
| 278 |
+
result = desktop.commands.run(
|
| 279 |
+
f"curl -sL --max-time 8 --connect-timeout 5 "
|
| 280 |
+
f"-A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' "
|
| 281 |
+
f"'{url}' 2>/dev/null | "
|
| 282 |
+
"sed -e 's/<script[^>]*>.*<\\/script>//g' -e 's/<style[^>]*>.*<\\/style>//g' | "
|
| 283 |
+
"sed 's/<[^>]*>//g' | "
|
| 284 |
+
"tr -s ' \\n' ' ' | "
|
| 285 |
+
"head -c 6000",
|
| 286 |
+
timeout=12
|
| 287 |
+
)
|
| 288 |
+
return result.stdout.strip() if hasattr(result, 'stdout') else ""
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.warning(f"Extract failed: {e}")
|
| 291 |
+
return ""
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
async def generate_final_response(state: SimpleState) -> str:
|
| 295 |
+
"""Generate response if agent timed out without completing."""
|
| 296 |
+
if state.final_result:
|
| 297 |
+
return state.final_result
|
| 298 |
+
|
| 299 |
+
content = state.get_recent_content()
|
| 300 |
+
|
| 301 |
+
prompt = f"""Based on the research done, answer the question.
|
| 302 |
+
|
| 303 |
+
TASK: {state.task}
|
| 304 |
+
|
| 305 |
+
COLLECTED DATA:
|
| 306 |
+
{content if content else "(No data collected)"}
|
| 307 |
+
|
| 308 |
+
SOURCES VISITED: {', '.join(state.visited_urls[:5]) if state.visited_urls else 'None'}
|
| 309 |
+
|
| 310 |
+
Provide a helpful answer based on what was found. Use **bold** for important values. If you couldn't find the answer, say so honestly.
|
| 311 |
+
|
| 312 |
+
Answer in Portuguese:"""
|
| 313 |
+
|
| 314 |
+
try:
|
| 315 |
+
response = await generate_completion(
|
| 316 |
+
messages=[{"role": "user", "content": prompt}],
|
| 317 |
+
max_tokens=1000
|
| 318 |
+
)
|
| 319 |
+
return response.strip()
|
| 320 |
+
except Exception as e:
|
| 321 |
+
return f"Não foi possível completar a pesquisa. Erro: {e}"
|
app/agents/graph/state.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent state management for graph-based execution.
|
| 2 |
+
|
| 3 |
+
The state is passed between nodes and accumulates information
|
| 4 |
+
throughout the agent's execution.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from typing import Optional, Any
|
| 9 |
+
from enum import Enum
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class NodeType(Enum):
|
| 13 |
+
"""Types of nodes in the agent graph."""
|
| 14 |
+
START = "start"
|
| 15 |
+
PLAN = "plan"
|
| 16 |
+
SEARCH = "search"
|
| 17 |
+
NAVIGATE = "navigate"
|
| 18 |
+
EXTRACT = "extract"
|
| 19 |
+
VERIFY = "verify"
|
| 20 |
+
RESPOND = "respond"
|
| 21 |
+
ERROR = "error"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class AgentState:
|
| 26 |
+
"""Shared state passed between graph nodes."""
|
| 27 |
+
|
| 28 |
+
# Task info
|
| 29 |
+
task: str = ""
|
| 30 |
+
url: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
# Planning
|
| 33 |
+
plan: dict = field(default_factory=dict)
|
| 34 |
+
current_subtask: int = 0
|
| 35 |
+
|
| 36 |
+
# Execution
|
| 37 |
+
current_node: NodeType = NodeType.START
|
| 38 |
+
step_count: int = 0
|
| 39 |
+
start_time: float = field(default_factory=lambda: 0.0)
|
| 40 |
+
timeout_seconds: float = 300.0 # 5 minutes default
|
| 41 |
+
|
| 42 |
+
# Memory
|
| 43 |
+
visited_urls: list = field(default_factory=list)
|
| 44 |
+
extracted_data: list = field(default_factory=list)
|
| 45 |
+
page_content: str = ""
|
| 46 |
+
window_title: str = ""
|
| 47 |
+
known_facts: list = field(default_factory=list)
|
| 48 |
+
missing_points: list = field(default_factory=list)
|
| 49 |
+
last_queries: list = field(default_factory=list)
|
| 50 |
+
|
| 51 |
+
# History
|
| 52 |
+
action_history: list = field(default_factory=list)
|
| 53 |
+
error_history: list = field(default_factory=list)
|
| 54 |
+
|
| 55 |
+
# Results
|
| 56 |
+
final_result: str = ""
|
| 57 |
+
success: bool = False
|
| 58 |
+
|
| 59 |
+
# Desktop reference (set at runtime)
|
| 60 |
+
desktop: Any = None
|
| 61 |
+
|
| 62 |
+
def add_action(self, action: dict):
|
| 63 |
+
"""Add action to history."""
|
| 64 |
+
self.action_history.append({
|
| 65 |
+
"step": self.step_count,
|
| 66 |
+
"node": self.current_node.value,
|
| 67 |
+
"action": action
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
def add_error(self, error: str):
|
| 71 |
+
"""Add error to history."""
|
| 72 |
+
self.error_history.append({
|
| 73 |
+
"step": self.step_count,
|
| 74 |
+
"error": error
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
def add_extracted_data(self, source: str, data: dict):
|
| 78 |
+
"""Add extracted data from a source."""
|
| 79 |
+
self.extracted_data.append({
|
| 80 |
+
"source": source,
|
| 81 |
+
"url": self.visited_urls[-1] if self.visited_urls else "",
|
| 82 |
+
"data": data
|
| 83 |
+
})
|
| 84 |
+
|
| 85 |
+
def add_query(self, query: str):
|
| 86 |
+
"""Track recent search queries used by the agent."""
|
| 87 |
+
query = (query or "").strip()
|
| 88 |
+
if not query:
|
| 89 |
+
return
|
| 90 |
+
if query not in self.last_queries:
|
| 91 |
+
self.last_queries.append(query)
|
| 92 |
+
self.last_queries = self.last_queries[-8:]
|
| 93 |
+
|
| 94 |
+
def update_research_progress(self, known_facts: list | None = None, missing_points: list | None = None):
|
| 95 |
+
"""Update short-term research memory from LLM output."""
|
| 96 |
+
if isinstance(known_facts, str):
|
| 97 |
+
known_facts = [known_facts]
|
| 98 |
+
if isinstance(missing_points, str):
|
| 99 |
+
missing_points = [missing_points]
|
| 100 |
+
|
| 101 |
+
if known_facts:
|
| 102 |
+
for fact in known_facts:
|
| 103 |
+
text = str(fact).strip()
|
| 104 |
+
if len(text) <= 1 and text.isalpha():
|
| 105 |
+
continue
|
| 106 |
+
if text and text not in self.known_facts:
|
| 107 |
+
self.known_facts.append(text)
|
| 108 |
+
self.known_facts = self.known_facts[-10:]
|
| 109 |
+
|
| 110 |
+
if missing_points:
|
| 111 |
+
cleaned = []
|
| 112 |
+
for point in missing_points:
|
| 113 |
+
text = str(point).strip()
|
| 114 |
+
if len(text) <= 1 and text.isalpha():
|
| 115 |
+
continue
|
| 116 |
+
if text:
|
| 117 |
+
cleaned.append(text)
|
| 118 |
+
# Keep latest gaps as "active" missing info.
|
| 119 |
+
self.missing_points = cleaned[-8:]
|
| 120 |
+
|
| 121 |
+
def get_context_for_llm(self) -> str:
|
| 122 |
+
"""Get formatted context for LLM prompts."""
|
| 123 |
+
context_parts = []
|
| 124 |
+
|
| 125 |
+
if self.action_history:
|
| 126 |
+
recent = self.action_history[-5:]
|
| 127 |
+
context_parts.append("Recent actions:")
|
| 128 |
+
for h in recent:
|
| 129 |
+
action_str = h.get('action', h)
|
| 130 |
+
node_str = h.get('node', 'action')
|
| 131 |
+
context_parts.append(f" - {node_str}: {action_str}")
|
| 132 |
+
|
| 133 |
+
if self.extracted_data:
|
| 134 |
+
context_parts.append("\nExtracted data:")
|
| 135 |
+
for d in self.extracted_data[-5:]:
|
| 136 |
+
# Support both old format (source/data) and new format (url/preview)
|
| 137 |
+
source = d.get('source') or d.get('url', 'unknown')
|
| 138 |
+
data = d.get('data') or d.get('preview', '')[:100]
|
| 139 |
+
context_parts.append(f" - {source[:50]}: {data[:100]}...")
|
| 140 |
+
|
| 141 |
+
if self.known_facts:
|
| 142 |
+
context_parts.append("\nKnown facts:")
|
| 143 |
+
for fact in self.known_facts[-5:]:
|
| 144 |
+
context_parts.append(f" - {fact}")
|
| 145 |
+
|
| 146 |
+
if self.missing_points:
|
| 147 |
+
context_parts.append("\nMissing points:")
|
| 148 |
+
for point in self.missing_points[-5:]:
|
| 149 |
+
context_parts.append(f" - {point}")
|
| 150 |
+
|
| 151 |
+
if self.last_queries:
|
| 152 |
+
context_parts.append("\nRecent queries:")
|
| 153 |
+
for query in self.last_queries[-5:]:
|
| 154 |
+
context_parts.append(f" - {query}")
|
| 155 |
+
|
| 156 |
+
if self.error_history:
|
| 157 |
+
context_parts.append("\nErrors encountered:")
|
| 158 |
+
for e in self.error_history[-3:]:
|
| 159 |
+
context_parts.append(f" - {e.get('error', str(e))}")
|
| 160 |
+
|
| 161 |
+
return "\n".join(context_parts)
|
| 162 |
+
|
| 163 |
+
def should_continue(self) -> bool:
|
| 164 |
+
"""Check if agent should continue execution based on timeout."""
|
| 165 |
+
import time
|
| 166 |
+
if self.start_time == 0:
|
| 167 |
+
self.start_time = time.time()
|
| 168 |
+
|
| 169 |
+
elapsed = time.time() - self.start_time
|
| 170 |
+
time_ok = elapsed < self.timeout_seconds
|
| 171 |
+
|
| 172 |
+
return (
|
| 173 |
+
not self.success and
|
| 174 |
+
time_ok and
|
| 175 |
+
self.current_node != NodeType.ERROR
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
def get_elapsed_time(self) -> float:
|
| 179 |
+
"""Get elapsed time in seconds."""
|
| 180 |
+
import time
|
| 181 |
+
if self.start_time == 0:
|
| 182 |
+
return 0.0
|
| 183 |
+
return time.time() - self.start_time
|
| 184 |
+
|
| 185 |
+
def get_remaining_time(self) -> float:
|
| 186 |
+
"""Get remaining time in seconds."""
|
| 187 |
+
return max(0, self.timeout_seconds - self.get_elapsed_time())
|
app/agents/heavy_search.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Heavy Search Agent.
|
| 2 |
+
|
| 3 |
+
Middle-ground between Quick Search and Deep Research.
|
| 4 |
+
Scrapes full content from top results for richer answers.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
from typing import AsyncIterator
|
| 10 |
+
|
| 11 |
+
from app.agents.llm_client import generate_completion_stream
|
| 12 |
+
from app.sources.aggregator import aggregate_search
|
| 13 |
+
from app.sources.scraper import scrape_multiple_urls
|
| 14 |
+
from app.reranking.pipeline import rerank_results
|
| 15 |
+
from app.temporal.intent_detector import detect_temporal_intent
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def run_heavy_search(
|
| 19 |
+
query: str,
|
| 20 |
+
max_results: int = 15,
|
| 21 |
+
max_scrape: int = 8,
|
| 22 |
+
freshness: str = "any",
|
| 23 |
+
) -> AsyncIterator[str]:
|
| 24 |
+
"""
|
| 25 |
+
Run heavy search with content scraping.
|
| 26 |
+
|
| 27 |
+
Steps:
|
| 28 |
+
1. Aggregate search from multiple sources
|
| 29 |
+
2. Rerank results
|
| 30 |
+
3. Scrape full content from top N results
|
| 31 |
+
4. Stream synthesized answer
|
| 32 |
+
|
| 33 |
+
Yields:
|
| 34 |
+
SSE event strings
|
| 35 |
+
"""
|
| 36 |
+
start_time = time.perf_counter()
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
# Step 1: Status
|
| 40 |
+
yield _sse_event("status", {"phase": "searching", "message": "Searching multiple sources..."})
|
| 41 |
+
|
| 42 |
+
# Step 2: Aggregate search
|
| 43 |
+
temporal_intent, temporal_urgency = detect_temporal_intent(query)
|
| 44 |
+
|
| 45 |
+
raw_results = await aggregate_search(
|
| 46 |
+
query=query,
|
| 47 |
+
max_results=max_results + 5,
|
| 48 |
+
freshness=freshness,
|
| 49 |
+
include_wikipedia=True,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
if not raw_results:
|
| 53 |
+
yield _sse_event("error", {"message": "No results found"})
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
yield _sse_event("search_complete", {
|
| 57 |
+
"results_count": len(raw_results),
|
| 58 |
+
"sources": list(set(r.get("source", "unknown") for r in raw_results)),
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
# Step 3: Rerank (use embeddings when we have many results from SearXNG)
|
| 62 |
+
yield _sse_event("status", {"phase": "ranking", "message": "Ranking results..."})
|
| 63 |
+
|
| 64 |
+
# Enable embeddings when we have many results (SearXNG provides volume)
|
| 65 |
+
use_embeddings = len(raw_results) > 20
|
| 66 |
+
|
| 67 |
+
ranked_results = await rerank_results(
|
| 68 |
+
query=query,
|
| 69 |
+
results=raw_results,
|
| 70 |
+
temporal_urgency=temporal_urgency,
|
| 71 |
+
max_results=max_results,
|
| 72 |
+
use_embeddings=use_embeddings,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
# Step 4: Scrape top results
|
| 76 |
+
yield _sse_event("status", {"phase": "scraping", "message": f"Reading top {max_scrape} sources..."})
|
| 77 |
+
|
| 78 |
+
urls_to_scrape = [r.get("url") for r in ranked_results[:max_scrape] if r.get("url")]
|
| 79 |
+
scraped_content = await scrape_multiple_urls(
|
| 80 |
+
urls=urls_to_scrape,
|
| 81 |
+
max_chars_per_url=4000,
|
| 82 |
+
max_concurrent=3,
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# Merge scraped content into results
|
| 86 |
+
for result in ranked_results:
|
| 87 |
+
url = result.get("url", "")
|
| 88 |
+
if url in scraped_content and scraped_content[url]:
|
| 89 |
+
result["full_content"] = scraped_content[url]
|
| 90 |
+
result["scraped"] = True
|
| 91 |
+
else:
|
| 92 |
+
result["full_content"] = result.get("content", "")
|
| 93 |
+
result["scraped"] = False
|
| 94 |
+
|
| 95 |
+
scraped_count = sum(1 for r in ranked_results if r.get("scraped"))
|
| 96 |
+
yield _sse_event("scrape_complete", {
|
| 97 |
+
"scraped_count": scraped_count,
|
| 98 |
+
"total": len(urls_to_scrape),
|
| 99 |
+
})
|
| 100 |
+
|
| 101 |
+
# Step 5: Send results
|
| 102 |
+
yield _sse_event("results", {
|
| 103 |
+
"results": [
|
| 104 |
+
{
|
| 105 |
+
"title": r.get("title", ""),
|
| 106 |
+
"url": r.get("url", ""),
|
| 107 |
+
"score": r.get("score", 0),
|
| 108 |
+
"source": r.get("source", ""),
|
| 109 |
+
"scraped": r.get("scraped", False),
|
| 110 |
+
}
|
| 111 |
+
for r in ranked_results
|
| 112 |
+
],
|
| 113 |
+
"temporal_intent": temporal_intent,
|
| 114 |
+
"temporal_urgency": temporal_urgency,
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
# Step 6: Synthesize answer
|
| 118 |
+
yield _sse_event("status", {"phase": "synthesizing", "message": "Generating answer..."})
|
| 119 |
+
yield _sse_event("answer_start", {})
|
| 120 |
+
|
| 121 |
+
async for chunk in _synthesize_heavy_answer(query, ranked_results, temporal_intent):
|
| 122 |
+
yield _sse_event("answer_chunk", {"content": chunk})
|
| 123 |
+
|
| 124 |
+
# Done
|
| 125 |
+
total_time = time.perf_counter() - start_time
|
| 126 |
+
yield _sse_event("done", {
|
| 127 |
+
"total_sources": len(ranked_results),
|
| 128 |
+
"scraped_sources": scraped_count,
|
| 129 |
+
"total_time_seconds": round(total_time, 2),
|
| 130 |
+
})
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
yield _sse_event("error", {"message": str(e)})
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def _synthesize_heavy_answer(
|
| 137 |
+
query: str,
|
| 138 |
+
results: list[dict],
|
| 139 |
+
temporal_intent: str,
|
| 140 |
+
) -> AsyncIterator[str]:
|
| 141 |
+
"""Synthesize answer from scraped content."""
|
| 142 |
+
|
| 143 |
+
# Build context with full content
|
| 144 |
+
context_parts = []
|
| 145 |
+
for i, r in enumerate(results[:8], 1):
|
| 146 |
+
content = r.get("full_content", r.get("content", ""))[:3000]
|
| 147 |
+
scraped_tag = "[FULL]" if r.get("scraped") else "[SNIPPET]"
|
| 148 |
+
|
| 149 |
+
context_parts.append(
|
| 150 |
+
f"[{i}] {r.get('title', 'Untitled')} {scraped_tag}\n"
|
| 151 |
+
f"URL: {r.get('url', '')}\n"
|
| 152 |
+
f"Content:\n{content}\n"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
context = "\n---\n".join(context_parts)
|
| 156 |
+
|
| 157 |
+
prompt = f"""You are a research assistant providing comprehensive answers.
|
| 158 |
+
|
| 159 |
+
QUERY: {query}
|
| 160 |
+
TEMPORAL INTENT: {temporal_intent}
|
| 161 |
+
|
| 162 |
+
SOURCES (some with full content [FULL], some with snippets [SNIPPET]):
|
| 163 |
+
{context}
|
| 164 |
+
|
| 165 |
+
INSTRUCTIONS:
|
| 166 |
+
1. Provide a comprehensive, well-structured answer
|
| 167 |
+
2. Use information from [FULL] sources more extensively
|
| 168 |
+
3. Cite sources using [1], [2], etc.
|
| 169 |
+
4. Write in the same language as the query
|
| 170 |
+
5. Be thorough but clear
|
| 171 |
+
|
| 172 |
+
Answer:"""
|
| 173 |
+
|
| 174 |
+
messages = [
|
| 175 |
+
{"role": "system", "content": "You are a helpful research assistant."},
|
| 176 |
+
{"role": "user", "content": prompt},
|
| 177 |
+
]
|
| 178 |
+
|
| 179 |
+
async for chunk in generate_completion_stream(messages, temperature=0.3):
|
| 180 |
+
yield chunk
|
| 181 |
+
|
| 182 |
+
# Add citations
|
| 183 |
+
yield "\n\n---\n**Sources:**\n"
|
| 184 |
+
for i, r in enumerate(results[:8], 1):
|
| 185 |
+
scraped = "📄" if r.get("scraped") else "📋"
|
| 186 |
+
yield f"{scraped} [{i}] [{r.get('title', 'Untitled')}]({r.get('url', '')})\n"
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _sse_event(event_type: str, data: dict) -> str:
|
| 190 |
+
"""Format an SSE event."""
|
| 191 |
+
payload = {"type": event_type, **data}
|
| 192 |
+
return f"data: {json.dumps(payload)}\n\n"
|
app/agents/llm_client.py
ADDED
|
@@ -0,0 +1,522 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM client abstraction for multiple providers.
|
| 2 |
+
|
| 3 |
+
Supports Groq and OpenRouter for LLM inference.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
+
import httpx
|
| 8 |
+
import json
|
| 9 |
+
from typing import Optional, AsyncIterator, Any
|
| 10 |
+
|
| 11 |
+
from tenacity import (
|
| 12 |
+
retry,
|
| 13 |
+
stop_after_attempt,
|
| 14 |
+
wait_exponential,
|
| 15 |
+
retry_if_exception_type,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
from app.config import get_settings
|
| 19 |
+
from app.agents.tooling import ToolCall
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class RetryableError(Exception):
|
| 23 |
+
"""Error that should trigger a retry."""
|
| 24 |
+
pass
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class CompletionResponse:
|
| 29 |
+
"""Normalized LLM response across providers."""
|
| 30 |
+
|
| 31 |
+
content: str = ""
|
| 32 |
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 33 |
+
reasoning: list[str] = field(default_factory=list)
|
| 34 |
+
model: str | None = None
|
| 35 |
+
finish_reason: str | None = None
|
| 36 |
+
raw: dict[str, Any] | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def generate_completion(
|
| 40 |
+
messages: list[dict],
|
| 41 |
+
model: Optional[str] = None,
|
| 42 |
+
temperature: float = 0.3,
|
| 43 |
+
max_tokens: int = 2048,
|
| 44 |
+
tools: list[dict[str, Any]] | None = None,
|
| 45 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 46 |
+
reasoning_effort: str | None = None,
|
| 47 |
+
prefer_responses_api: bool = False,
|
| 48 |
+
) -> str:
|
| 49 |
+
"""Generate a completion using the configured LLM provider."""
|
| 50 |
+
response = await generate_completion_response(
|
| 51 |
+
messages=messages,
|
| 52 |
+
model=model,
|
| 53 |
+
temperature=temperature,
|
| 54 |
+
max_tokens=max_tokens,
|
| 55 |
+
tools=tools,
|
| 56 |
+
tool_choice=tool_choice,
|
| 57 |
+
reasoning_effort=reasoning_effort,
|
| 58 |
+
prefer_responses_api=prefer_responses_api,
|
| 59 |
+
)
|
| 60 |
+
return response.content
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def generate_completion_response(
|
| 64 |
+
messages: list[dict],
|
| 65 |
+
model: Optional[str] = None,
|
| 66 |
+
temperature: float = 0.3,
|
| 67 |
+
max_tokens: int = 2048,
|
| 68 |
+
tools: list[dict[str, Any]] | None = None,
|
| 69 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 70 |
+
reasoning_effort: str | None = None,
|
| 71 |
+
prefer_responses_api: bool = False,
|
| 72 |
+
) -> CompletionResponse:
|
| 73 |
+
"""Generate a normalized completion response with optional tool calls."""
|
| 74 |
+
settings = get_settings()
|
| 75 |
+
provider = settings.llm_provider
|
| 76 |
+
model = model or settings.llm_model
|
| 77 |
+
|
| 78 |
+
if provider == "groq":
|
| 79 |
+
return await _call_groq(messages, model, temperature, max_tokens, tools, tool_choice)
|
| 80 |
+
elif provider == "openrouter":
|
| 81 |
+
if prefer_responses_api:
|
| 82 |
+
return await _call_openrouter_responses(
|
| 83 |
+
messages=messages,
|
| 84 |
+
model=model,
|
| 85 |
+
temperature=temperature,
|
| 86 |
+
max_tokens=max_tokens,
|
| 87 |
+
tools=tools,
|
| 88 |
+
tool_choice=tool_choice,
|
| 89 |
+
reasoning_effort=reasoning_effort,
|
| 90 |
+
)
|
| 91 |
+
return await _call_openrouter(messages, model, temperature, max_tokens, tools, tool_choice)
|
| 92 |
+
else:
|
| 93 |
+
raise ValueError(f"Unknown LLM provider: {provider}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _build_payload(
|
| 97 |
+
model: str,
|
| 98 |
+
messages: list[dict],
|
| 99 |
+
temperature: float,
|
| 100 |
+
max_tokens: int,
|
| 101 |
+
tools: list[dict[str, Any]] | None = None,
|
| 102 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 103 |
+
stream: bool = False,
|
| 104 |
+
) -> dict[str, Any]:
|
| 105 |
+
"""Build an OpenAI-compatible chat payload."""
|
| 106 |
+
payload: dict[str, Any] = {
|
| 107 |
+
"model": model,
|
| 108 |
+
"messages": messages,
|
| 109 |
+
"temperature": temperature,
|
| 110 |
+
"max_tokens": max_tokens,
|
| 111 |
+
}
|
| 112 |
+
if stream:
|
| 113 |
+
payload["stream"] = True
|
| 114 |
+
if tools:
|
| 115 |
+
payload["tools"] = tools
|
| 116 |
+
if tool_choice is not None:
|
| 117 |
+
payload["tool_choice"] = tool_choice
|
| 118 |
+
return payload
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _parse_tool_calls(message: dict[str, Any]) -> list[ToolCall]:
|
| 122 |
+
"""Parse tool calls from an OpenAI-compatible response message."""
|
| 123 |
+
parsed_calls: list[ToolCall] = []
|
| 124 |
+
|
| 125 |
+
for index, raw_tool_call in enumerate(message.get("tool_calls", []) or []):
|
| 126 |
+
function_payload = raw_tool_call.get("function", {}) or {}
|
| 127 |
+
raw_arguments = function_payload.get("arguments", "{}")
|
| 128 |
+
try:
|
| 129 |
+
arguments = json.loads(raw_arguments) if raw_arguments else {}
|
| 130 |
+
except json.JSONDecodeError:
|
| 131 |
+
arguments = {"raw": raw_arguments}
|
| 132 |
+
|
| 133 |
+
parsed_calls.append(
|
| 134 |
+
ToolCall(
|
| 135 |
+
id=str(raw_tool_call.get("id", f"tool_call_{index}")),
|
| 136 |
+
name=str(function_payload.get("name", "")),
|
| 137 |
+
arguments=arguments,
|
| 138 |
+
)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
return parsed_calls
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _parse_completion_response(data: dict[str, Any]) -> CompletionResponse:
|
| 145 |
+
"""Parse an OpenAI-compatible completion payload into a normalized response."""
|
| 146 |
+
choice = (data.get("choices") or [{}])[0]
|
| 147 |
+
message = choice.get("message") or {}
|
| 148 |
+
|
| 149 |
+
return CompletionResponse(
|
| 150 |
+
content=message.get("content") or "",
|
| 151 |
+
tool_calls=_parse_tool_calls(message),
|
| 152 |
+
reasoning=[],
|
| 153 |
+
model=data.get("model"),
|
| 154 |
+
finish_reason=choice.get("finish_reason"),
|
| 155 |
+
raw=data,
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _chat_messages_to_responses_input(messages: list[dict]) -> list[dict[str, Any]]:
|
| 160 |
+
"""Convert chat-style messages into OpenRouter Responses API input items."""
|
| 161 |
+
converted: list[dict[str, Any]] = []
|
| 162 |
+
|
| 163 |
+
for message in messages:
|
| 164 |
+
role = str(message.get("role", "user"))
|
| 165 |
+
content = message.get("content", "")
|
| 166 |
+
if content is None:
|
| 167 |
+
content = ""
|
| 168 |
+
|
| 169 |
+
content_type = "output_text" if role == "assistant" else "input_text"
|
| 170 |
+
converted.append(
|
| 171 |
+
{
|
| 172 |
+
"type": "message",
|
| 173 |
+
"role": role,
|
| 174 |
+
"content": [
|
| 175 |
+
{
|
| 176 |
+
"type": content_type,
|
| 177 |
+
"text": str(content),
|
| 178 |
+
}
|
| 179 |
+
],
|
| 180 |
+
}
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
return converted
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _responses_tools_from_chat_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | None:
|
| 187 |
+
"""Convert chat-completions tool format into Responses API tool format."""
|
| 188 |
+
if not tools:
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
converted: list[dict[str, Any]] = []
|
| 192 |
+
for tool in tools:
|
| 193 |
+
if tool.get("type") != "function":
|
| 194 |
+
continue
|
| 195 |
+
function_def = tool.get("function", {}) or {}
|
| 196 |
+
converted.append(
|
| 197 |
+
{
|
| 198 |
+
"type": "function",
|
| 199 |
+
"name": function_def.get("name", ""),
|
| 200 |
+
"description": function_def.get("description", ""),
|
| 201 |
+
"parameters": function_def.get("parameters", {"type": "object", "properties": {}}),
|
| 202 |
+
"strict": None,
|
| 203 |
+
}
|
| 204 |
+
)
|
| 205 |
+
return converted
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def _parse_openrouter_responses_api(data: dict[str, Any]) -> CompletionResponse:
|
| 209 |
+
"""Parse OpenRouter Responses API output into a normalized response."""
|
| 210 |
+
content_parts: list[str] = []
|
| 211 |
+
tool_calls: list[ToolCall] = []
|
| 212 |
+
reasoning_parts: list[str] = []
|
| 213 |
+
|
| 214 |
+
for index, item in enumerate(data.get("output", []) or []):
|
| 215 |
+
item_type = item.get("type")
|
| 216 |
+
|
| 217 |
+
if item_type == "reasoning":
|
| 218 |
+
summaries = item.get("summary", []) or []
|
| 219 |
+
for summary in summaries:
|
| 220 |
+
if isinstance(summary, str):
|
| 221 |
+
reasoning_parts.append(summary.strip())
|
| 222 |
+
elif isinstance(summary, dict):
|
| 223 |
+
text = summary.get("text") or summary.get("summary") or ""
|
| 224 |
+
if text:
|
| 225 |
+
reasoning_parts.append(str(text).strip())
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
if item_type == "function_call":
|
| 229 |
+
raw_arguments = item.get("arguments", "{}")
|
| 230 |
+
try:
|
| 231 |
+
arguments = json.loads(raw_arguments) if raw_arguments else {}
|
| 232 |
+
except json.JSONDecodeError:
|
| 233 |
+
arguments = {"raw": raw_arguments}
|
| 234 |
+
|
| 235 |
+
tool_calls.append(
|
| 236 |
+
ToolCall(
|
| 237 |
+
id=str(item.get("call_id") or item.get("id") or f"tool_call_{index}"),
|
| 238 |
+
name=str(item.get("name", "")),
|
| 239 |
+
arguments=arguments,
|
| 240 |
+
)
|
| 241 |
+
)
|
| 242 |
+
continue
|
| 243 |
+
|
| 244 |
+
if item_type == "message":
|
| 245 |
+
for content_item in item.get("content", []) or []:
|
| 246 |
+
if content_item.get("type") == "output_text":
|
| 247 |
+
text = content_item.get("text") or ""
|
| 248 |
+
if text:
|
| 249 |
+
content_parts.append(str(text))
|
| 250 |
+
|
| 251 |
+
return CompletionResponse(
|
| 252 |
+
content="\n".join(part for part in content_parts if part).strip(),
|
| 253 |
+
tool_calls=tool_calls,
|
| 254 |
+
reasoning=[part for part in reasoning_parts if part],
|
| 255 |
+
model=data.get("model"),
|
| 256 |
+
finish_reason=data.get("status"),
|
| 257 |
+
raw=data,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@retry(
|
| 262 |
+
stop=stop_after_attempt(3),
|
| 263 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 264 |
+
retry=retry_if_exception_type(RetryableError),
|
| 265 |
+
reraise=True,
|
| 266 |
+
)
|
| 267 |
+
async def _call_groq(
|
| 268 |
+
messages: list[dict],
|
| 269 |
+
model: str,
|
| 270 |
+
temperature: float,
|
| 271 |
+
max_tokens: int,
|
| 272 |
+
tools: list[dict[str, Any]] | None = None,
|
| 273 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 274 |
+
) -> CompletionResponse:
|
| 275 |
+
"""Call Groq API with retry logic."""
|
| 276 |
+
settings = get_settings()
|
| 277 |
+
|
| 278 |
+
if not settings.groq_api_key:
|
| 279 |
+
raise ValueError("GROQ_API_KEY not configured")
|
| 280 |
+
|
| 281 |
+
try:
|
| 282 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 283 |
+
payload = _build_payload(
|
| 284 |
+
model=model,
|
| 285 |
+
messages=messages,
|
| 286 |
+
temperature=temperature,
|
| 287 |
+
max_tokens=max_tokens,
|
| 288 |
+
tools=tools,
|
| 289 |
+
tool_choice=tool_choice,
|
| 290 |
+
)
|
| 291 |
+
response = await client.post(
|
| 292 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 293 |
+
headers={
|
| 294 |
+
"Authorization": f"Bearer {settings.groq_api_key}",
|
| 295 |
+
"Content-Type": "application/json",
|
| 296 |
+
},
|
| 297 |
+
json=payload,
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
# Retry on rate limit or server errors
|
| 301 |
+
if response.status_code in (429, 502, 503, 504):
|
| 302 |
+
raise RetryableError(f"Groq error {response.status_code}")
|
| 303 |
+
|
| 304 |
+
if response.status_code == 400 and tools:
|
| 305 |
+
# Some model/provider combinations reject tools. Fall back to plain text.
|
| 306 |
+
fallback_payload = _build_payload(
|
| 307 |
+
model=model,
|
| 308 |
+
messages=messages,
|
| 309 |
+
temperature=temperature,
|
| 310 |
+
max_tokens=max_tokens,
|
| 311 |
+
)
|
| 312 |
+
response = await client.post(
|
| 313 |
+
"https://api.groq.com/openai/v1/chat/completions",
|
| 314 |
+
headers={
|
| 315 |
+
"Authorization": f"Bearer {settings.groq_api_key}",
|
| 316 |
+
"Content-Type": "application/json",
|
| 317 |
+
},
|
| 318 |
+
json=fallback_payload,
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
response.raise_for_status()
|
| 322 |
+
data = response.json()
|
| 323 |
+
|
| 324 |
+
return _parse_completion_response(data)
|
| 325 |
+
except httpx.TimeoutException as e:
|
| 326 |
+
raise RetryableError(f"Groq timeout: {e}")
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
@retry(
|
| 330 |
+
stop=stop_after_attempt(3),
|
| 331 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 332 |
+
retry=retry_if_exception_type(RetryableError),
|
| 333 |
+
reraise=True,
|
| 334 |
+
)
|
| 335 |
+
async def _call_openrouter(
|
| 336 |
+
messages: list[dict],
|
| 337 |
+
model: str,
|
| 338 |
+
temperature: float,
|
| 339 |
+
max_tokens: int,
|
| 340 |
+
tools: list[dict[str, Any]] | None = None,
|
| 341 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 342 |
+
) -> CompletionResponse:
|
| 343 |
+
"""Call OpenRouter API with retry logic."""
|
| 344 |
+
settings = get_settings()
|
| 345 |
+
|
| 346 |
+
if not settings.openrouter_api_key:
|
| 347 |
+
raise ValueError("OPENROUTER_API_KEY not configured")
|
| 348 |
+
|
| 349 |
+
headers = {
|
| 350 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 351 |
+
"Content-Type": "application/json",
|
| 352 |
+
"HTTP-Referer": "https://madras1-lancer.hf.space",
|
| 353 |
+
"X-Title": "Lancer Search API",
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
try:
|
| 357 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 358 |
+
payload = _build_payload(
|
| 359 |
+
model=model,
|
| 360 |
+
messages=messages,
|
| 361 |
+
temperature=temperature,
|
| 362 |
+
max_tokens=max_tokens,
|
| 363 |
+
tools=tools,
|
| 364 |
+
tool_choice=tool_choice,
|
| 365 |
+
)
|
| 366 |
+
response = await client.post(
|
| 367 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 368 |
+
headers=headers,
|
| 369 |
+
json=payload,
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
# Retry on rate limit or server errors
|
| 373 |
+
if response.status_code in (429, 502, 503, 504):
|
| 374 |
+
raise RetryableError(f"OpenRouter error {response.status_code}")
|
| 375 |
+
|
| 376 |
+
if response.status_code == 400 and tools:
|
| 377 |
+
fallback_payload = _build_payload(
|
| 378 |
+
model=model,
|
| 379 |
+
messages=messages,
|
| 380 |
+
temperature=temperature,
|
| 381 |
+
max_tokens=max_tokens,
|
| 382 |
+
)
|
| 383 |
+
response = await client.post(
|
| 384 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 385 |
+
headers=headers,
|
| 386 |
+
json=fallback_payload,
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
if response.status_code != 200:
|
| 390 |
+
error_text = response.text
|
| 391 |
+
raise ValueError(f"OpenRouter error {response.status_code}: {error_text}")
|
| 392 |
+
|
| 393 |
+
data = response.json()
|
| 394 |
+
return _parse_completion_response(data)
|
| 395 |
+
except httpx.TimeoutException as e:
|
| 396 |
+
raise RetryableError(f"OpenRouter timeout: {e}")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
@retry(
|
| 400 |
+
stop=stop_after_attempt(3),
|
| 401 |
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
| 402 |
+
retry=retry_if_exception_type(RetryableError),
|
| 403 |
+
reraise=True,
|
| 404 |
+
)
|
| 405 |
+
async def _call_openrouter_responses(
|
| 406 |
+
messages: list[dict],
|
| 407 |
+
model: str,
|
| 408 |
+
temperature: float,
|
| 409 |
+
max_tokens: int,
|
| 410 |
+
tools: list[dict[str, Any]] | None = None,
|
| 411 |
+
tool_choice: str | dict[str, Any] | None = None,
|
| 412 |
+
reasoning_effort: str | None = None,
|
| 413 |
+
) -> CompletionResponse:
|
| 414 |
+
"""Call OpenRouter Responses API and keep reasoning separated from content."""
|
| 415 |
+
settings = get_settings()
|
| 416 |
+
|
| 417 |
+
if not settings.openrouter_api_key:
|
| 418 |
+
raise ValueError("OPENROUTER_API_KEY not configured")
|
| 419 |
+
|
| 420 |
+
headers = {
|
| 421 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 422 |
+
"Content-Type": "application/json",
|
| 423 |
+
"HTTP-Referer": "https://madras1-lancer.hf.space",
|
| 424 |
+
"X-Title": "Lancer Search API",
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
payload: dict[str, Any] = {
|
| 428 |
+
"model": model,
|
| 429 |
+
"input": _chat_messages_to_responses_input(messages),
|
| 430 |
+
"max_output_tokens": max_tokens,
|
| 431 |
+
"temperature": temperature,
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
responses_tools = _responses_tools_from_chat_tools(tools)
|
| 435 |
+
if responses_tools:
|
| 436 |
+
payload["tools"] = responses_tools
|
| 437 |
+
if tool_choice is not None:
|
| 438 |
+
if isinstance(tool_choice, str):
|
| 439 |
+
payload["tool_choice"] = tool_choice
|
| 440 |
+
elif isinstance(tool_choice, dict):
|
| 441 |
+
function_name = (
|
| 442 |
+
tool_choice.get("function", {}) or {}
|
| 443 |
+
).get("name") or tool_choice.get("name")
|
| 444 |
+
if function_name:
|
| 445 |
+
payload["tool_choice"] = {"type": "function", "name": function_name}
|
| 446 |
+
|
| 447 |
+
if reasoning_effort:
|
| 448 |
+
payload["reasoning"] = {"effort": reasoning_effort}
|
| 449 |
+
|
| 450 |
+
try:
|
| 451 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 452 |
+
response = await client.post(
|
| 453 |
+
"https://openrouter.ai/api/v1/responses",
|
| 454 |
+
headers=headers,
|
| 455 |
+
json=payload,
|
| 456 |
+
)
|
| 457 |
+
|
| 458 |
+
if response.status_code in (429, 502, 503, 504):
|
| 459 |
+
raise RetryableError(f"OpenRouter responses error {response.status_code}")
|
| 460 |
+
|
| 461 |
+
if response.status_code != 200:
|
| 462 |
+
error_text = response.text
|
| 463 |
+
raise ValueError(f"OpenRouter responses error {response.status_code}: {error_text}")
|
| 464 |
+
|
| 465 |
+
data = response.json()
|
| 466 |
+
return _parse_openrouter_responses_api(data)
|
| 467 |
+
except httpx.TimeoutException as e:
|
| 468 |
+
raise RetryableError(f"OpenRouter responses timeout: {e}")
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
async def generate_completion_stream(
|
| 472 |
+
messages: list[dict],
|
| 473 |
+
model: Optional[str] = None,
|
| 474 |
+
temperature: float = 0.3,
|
| 475 |
+
max_tokens: int = 2048,
|
| 476 |
+
) -> AsyncIterator[str]:
|
| 477 |
+
"""Generate a streaming completion using OpenRouter."""
|
| 478 |
+
settings = get_settings()
|
| 479 |
+
model = model or settings.llm_model
|
| 480 |
+
|
| 481 |
+
if not settings.openrouter_api_key:
|
| 482 |
+
raise ValueError("OPENROUTER_API_KEY not configured")
|
| 483 |
+
|
| 484 |
+
headers = {
|
| 485 |
+
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
| 486 |
+
"Content-Type": "application/json",
|
| 487 |
+
"HTTP-Referer": "https://madras1-lancer.hf.space",
|
| 488 |
+
"X-Title": "Lancer Search API",
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
payload = _build_payload(
|
| 492 |
+
model=model,
|
| 493 |
+
messages=messages,
|
| 494 |
+
temperature=temperature,
|
| 495 |
+
max_tokens=max_tokens,
|
| 496 |
+
stream=True,
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 500 |
+
async with client.stream(
|
| 501 |
+
"POST",
|
| 502 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 503 |
+
headers=headers,
|
| 504 |
+
json=payload,
|
| 505 |
+
) as response:
|
| 506 |
+
if response.status_code != 200:
|
| 507 |
+
error_text = await response.aread()
|
| 508 |
+
raise ValueError(f"OpenRouter streaming error {response.status_code}: {error_text}")
|
| 509 |
+
|
| 510 |
+
async for line in response.aiter_lines():
|
| 511 |
+
if line.startswith("data: "):
|
| 512 |
+
data_str = line[6:]
|
| 513 |
+
if data_str.strip() == "[DONE]":
|
| 514 |
+
break
|
| 515 |
+
try:
|
| 516 |
+
data = json.loads(data_str)
|
| 517 |
+
delta = data.get("choices", [{}])[0].get("delta", {})
|
| 518 |
+
content = delta.get("content", "")
|
| 519 |
+
if content:
|
| 520 |
+
yield content
|
| 521 |
+
except json.JSONDecodeError:
|
| 522 |
+
continue
|
app/agents/planner.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Research Planner Agent.
|
| 2 |
+
|
| 3 |
+
Decomposes complex queries into multiple research dimensions.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, Field
|
| 10 |
+
|
| 11 |
+
from app.agents.llm_client import generate_completion
|
| 12 |
+
from app.config import get_settings
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ResearchDimension(BaseModel):
|
| 16 |
+
"""A single dimension/aspect to research."""
|
| 17 |
+
|
| 18 |
+
name: str = Field(..., description="Short name for this dimension")
|
| 19 |
+
description: str = Field(..., description="What this dimension covers")
|
| 20 |
+
search_query: str = Field(..., description="Optimized search query for this dimension")
|
| 21 |
+
priority: int = Field(default=1, ge=1, le=3, description="1=high, 2=medium, 3=low")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ResearchPlan(BaseModel):
|
| 25 |
+
"""Complete research plan with all dimensions."""
|
| 26 |
+
|
| 27 |
+
original_query: str
|
| 28 |
+
refined_query: str = Field(..., description="Clarified version of the query")
|
| 29 |
+
dimensions: list[ResearchDimension]
|
| 30 |
+
estimated_sources: int = Field(default=20)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
PLANNER_PROMPT = """You are a research planning assistant. Your job is to decompose a complex query into multiple research dimensions.
|
| 34 |
+
|
| 35 |
+
USER QUERY: {query}
|
| 36 |
+
|
| 37 |
+
INSTRUCTIONS:
|
| 38 |
+
1. Analyze the query and identify 2-6 key dimensions/aspects that need to be researched
|
| 39 |
+
2. Each dimension should be distinct and cover a different angle
|
| 40 |
+
3. Create an optimized search query for each dimension
|
| 41 |
+
4. Assign priority (1=high, 2=medium, 3=low) based on relevance to the main query
|
| 42 |
+
5. Respond ONLY with valid JSON, no other text
|
| 43 |
+
|
| 44 |
+
OUTPUT FORMAT:
|
| 45 |
+
{{
|
| 46 |
+
"refined_query": "A clearer version of the user's query",
|
| 47 |
+
"dimensions": [
|
| 48 |
+
{{
|
| 49 |
+
"name": "Short name",
|
| 50 |
+
"description": "What this covers",
|
| 51 |
+
"search_query": "Optimized search query",
|
| 52 |
+
"priority": 1
|
| 53 |
+
}}
|
| 54 |
+
]
|
| 55 |
+
}}
|
| 56 |
+
|
| 57 |
+
Generate the research plan:"""
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
async def create_research_plan(
|
| 61 |
+
query: str,
|
| 62 |
+
max_dimensions: int = 6,
|
| 63 |
+
) -> ResearchPlan:
|
| 64 |
+
"""
|
| 65 |
+
Create a research plan by decomposing a query into dimensions.
|
| 66 |
+
|
| 67 |
+
Args:
|
| 68 |
+
query: The user's research query
|
| 69 |
+
max_dimensions: Maximum number of dimensions to generate
|
| 70 |
+
|
| 71 |
+
Returns:
|
| 72 |
+
ResearchPlan with dimensions to investigate
|
| 73 |
+
"""
|
| 74 |
+
settings = get_settings()
|
| 75 |
+
|
| 76 |
+
messages = [
|
| 77 |
+
{"role": "system", "content": "You are a research planning assistant. Always respond with valid JSON only."},
|
| 78 |
+
{"role": "user", "content": PLANNER_PROMPT.format(query=query)},
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
response = await generate_completion(messages, temperature=0.3)
|
| 83 |
+
|
| 84 |
+
# Parse JSON response
|
| 85 |
+
# Try to extract JSON if there's extra text
|
| 86 |
+
json_start = response.find("{")
|
| 87 |
+
json_end = response.rfind("}") + 1
|
| 88 |
+
if json_start >= 0 and json_end > json_start:
|
| 89 |
+
response = response[json_start:json_end]
|
| 90 |
+
|
| 91 |
+
data = json.loads(response)
|
| 92 |
+
|
| 93 |
+
# Build dimensions
|
| 94 |
+
dimensions = []
|
| 95 |
+
for dim_data in data.get("dimensions", [])[:max_dimensions]:
|
| 96 |
+
dimensions.append(ResearchDimension(
|
| 97 |
+
name=dim_data.get("name", "Unknown"),
|
| 98 |
+
description=dim_data.get("description", ""),
|
| 99 |
+
search_query=dim_data.get("search_query", query),
|
| 100 |
+
priority=dim_data.get("priority", 2),
|
| 101 |
+
))
|
| 102 |
+
|
| 103 |
+
# Sort by priority
|
| 104 |
+
dimensions.sort(key=lambda d: d.priority)
|
| 105 |
+
|
| 106 |
+
return ResearchPlan(
|
| 107 |
+
original_query=query,
|
| 108 |
+
refined_query=data.get("refined_query", query),
|
| 109 |
+
dimensions=dimensions,
|
| 110 |
+
estimated_sources=len(dimensions) * 5,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
except (json.JSONDecodeError, KeyError) as e:
|
| 114 |
+
# Fallback: create a simple 2-dimension plan
|
| 115 |
+
return ResearchPlan(
|
| 116 |
+
original_query=query,
|
| 117 |
+
refined_query=query,
|
| 118 |
+
dimensions=[
|
| 119 |
+
ResearchDimension(
|
| 120 |
+
name="Main Research",
|
| 121 |
+
description=f"Primary research on: {query}",
|
| 122 |
+
search_query=query,
|
| 123 |
+
priority=1,
|
| 124 |
+
),
|
| 125 |
+
ResearchDimension(
|
| 126 |
+
name="Background",
|
| 127 |
+
description=f"Background and context for: {query}",
|
| 128 |
+
search_query=f"{query} background overview",
|
| 129 |
+
priority=2,
|
| 130 |
+
),
|
| 131 |
+
],
|
| 132 |
+
estimated_sources=10,
|
| 133 |
+
)
|
app/agents/synthesizer.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Answer synthesizer agent.
|
| 2 |
+
|
| 3 |
+
Generates a coherent answer from search results with citations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional, AsyncIterator
|
| 8 |
+
|
| 9 |
+
from app.api.schemas import SearchResult, TemporalContext, Citation
|
| 10 |
+
from app.agents.llm_client import generate_completion, generate_completion_stream
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SYNTHESIS_PROMPT = """You are a research assistant that synthesizes information from search results.
|
| 14 |
+
|
| 15 |
+
CURRENT DATE: {current_date}
|
| 16 |
+
|
| 17 |
+
USER QUERY: {query}
|
| 18 |
+
|
| 19 |
+
TEMPORAL CONTEXT:
|
| 20 |
+
- Query intent: {temporal_intent} (the user {intent_explanation})
|
| 21 |
+
- Temporal urgency: {temporal_urgency:.0%} (how important freshness is)
|
| 22 |
+
|
| 23 |
+
SEARCH RESULTS:
|
| 24 |
+
{formatted_results}
|
| 25 |
+
|
| 26 |
+
INSTRUCTIONS:
|
| 27 |
+
1. Synthesize a comprehensive answer based on the search results
|
| 28 |
+
2. ALWAYS cite your sources using [1], [2], etc. format
|
| 29 |
+
3. If the query requires current information, prioritize the most recent results
|
| 30 |
+
4. If there are conflicting dates or versions mentioned, use the most recent accurate information
|
| 31 |
+
5. Be concise but thorough
|
| 32 |
+
6. If information seems outdated compared to current date ({current_date}), note this
|
| 33 |
+
7. Write in the same language as the query
|
| 34 |
+
|
| 35 |
+
Generate your answer:"""
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def synthesize_answer(
|
| 39 |
+
query: str,
|
| 40 |
+
results: list[SearchResult],
|
| 41 |
+
temporal_context: Optional[TemporalContext] = None,
|
| 42 |
+
) -> tuple[str, list[Citation]]:
|
| 43 |
+
"""
|
| 44 |
+
Synthesize an answer from search results.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
query: Original search query
|
| 48 |
+
results: List of search results to synthesize from
|
| 49 |
+
temporal_context: Temporal analysis context
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
Tuple of (answer_text, citations_list)
|
| 53 |
+
"""
|
| 54 |
+
if not results:
|
| 55 |
+
return "No results found to synthesize an answer.", []
|
| 56 |
+
|
| 57 |
+
messages = _build_messages(query, results, temporal_context)
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
answer = await generate_completion(messages, temperature=0.3)
|
| 61 |
+
except Exception as e:
|
| 62 |
+
# Fallback: return a simple summary without LLM
|
| 63 |
+
answer = f"Error generating synthesis: {e}. Please review the search results directly."
|
| 64 |
+
|
| 65 |
+
# Build citations list
|
| 66 |
+
citations = _build_citations(results)
|
| 67 |
+
|
| 68 |
+
return answer, citations
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def synthesize_answer_stream(
|
| 72 |
+
query: str,
|
| 73 |
+
results: list[SearchResult],
|
| 74 |
+
temporal_context: Optional[TemporalContext] = None,
|
| 75 |
+
) -> AsyncIterator[str]:
|
| 76 |
+
"""
|
| 77 |
+
Synthesize an answer with streaming output.
|
| 78 |
+
|
| 79 |
+
Yields chunks of the answer as they are generated.
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
query: Original search query
|
| 83 |
+
results: List of search results to synthesize from
|
| 84 |
+
temporal_context: Temporal analysis context
|
| 85 |
+
|
| 86 |
+
Yields:
|
| 87 |
+
Chunks of the answer text
|
| 88 |
+
"""
|
| 89 |
+
if not results:
|
| 90 |
+
yield "No results found to synthesize an answer."
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
messages = _build_messages(query, results, temporal_context)
|
| 94 |
+
|
| 95 |
+
try:
|
| 96 |
+
async for chunk in generate_completion_stream(messages, temperature=0.3):
|
| 97 |
+
yield chunk
|
| 98 |
+
except Exception as e:
|
| 99 |
+
yield f"Error generating synthesis: {e}. Please review the search results directly."
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _build_messages(
|
| 103 |
+
query: str,
|
| 104 |
+
results: list[SearchResult],
|
| 105 |
+
temporal_context: Optional[TemporalContext] = None,
|
| 106 |
+
) -> list[dict]:
|
| 107 |
+
"""Build messages for LLM prompt."""
|
| 108 |
+
# Format results for the prompt
|
| 109 |
+
formatted_results = format_results_for_prompt(results[:10]) # Top 10 only
|
| 110 |
+
|
| 111 |
+
# Prepare temporal context
|
| 112 |
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
| 113 |
+
temporal_intent = "neutral"
|
| 114 |
+
temporal_urgency = 0.5
|
| 115 |
+
|
| 116 |
+
if temporal_context:
|
| 117 |
+
temporal_intent = temporal_context.query_temporal_intent
|
| 118 |
+
temporal_urgency = temporal_context.temporal_urgency
|
| 119 |
+
current_date = temporal_context.current_date
|
| 120 |
+
|
| 121 |
+
# Map intent to explanation
|
| 122 |
+
intent_explanations = {
|
| 123 |
+
"current": "is looking for the most recent/current information",
|
| 124 |
+
"historical": "is interested in historical or background information",
|
| 125 |
+
"neutral": "has no specific temporal preference",
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
prompt = SYNTHESIS_PROMPT.format(
|
| 129 |
+
current_date=current_date,
|
| 130 |
+
query=query,
|
| 131 |
+
temporal_intent=temporal_intent,
|
| 132 |
+
intent_explanation=intent_explanations.get(temporal_intent, ""),
|
| 133 |
+
temporal_urgency=temporal_urgency,
|
| 134 |
+
formatted_results=formatted_results,
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
return [
|
| 138 |
+
{"role": "system", "content": "You are a helpful research assistant."},
|
| 139 |
+
{"role": "user", "content": prompt},
|
| 140 |
+
]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _build_citations(results: list[SearchResult]) -> list[Citation]:
|
| 144 |
+
"""Build citations list from results."""
|
| 145 |
+
citations = []
|
| 146 |
+
for i, result in enumerate(results[:10], 1):
|
| 147 |
+
citations.append(
|
| 148 |
+
Citation(
|
| 149 |
+
index=i,
|
| 150 |
+
url=result.url,
|
| 151 |
+
title=result.title,
|
| 152 |
+
)
|
| 153 |
+
)
|
| 154 |
+
return citations
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def format_results_for_prompt(results: list[SearchResult]) -> str:
|
| 158 |
+
"""Format search results for inclusion in the LLM prompt."""
|
| 159 |
+
formatted = []
|
| 160 |
+
|
| 161 |
+
for i, result in enumerate(results, 1):
|
| 162 |
+
date_str = ""
|
| 163 |
+
if result.published_date:
|
| 164 |
+
date_str = f" (Published: {result.published_date.strftime('%Y-%m-%d')})"
|
| 165 |
+
|
| 166 |
+
formatted.append(
|
| 167 |
+
f"[{i}] {result.title}{date_str}\n"
|
| 168 |
+
f" URL: {result.url}\n"
|
| 169 |
+
f" Freshness: {result.freshness_score:.0%} | Authority: {result.authority_score:.0%}\n"
|
| 170 |
+
f" Content: {result.content[:500]}..."
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
return "\n\n".join(formatted)
|
app/agents/tooling.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Lightweight tooling primitives for Lancer agents.
|
| 2 |
+
|
| 3 |
+
This module provides a small subset of the ideas from JadeAgent:
|
| 4 |
+
- declarative tool schemas from Python signatures
|
| 5 |
+
- a registry for reusable tools
|
| 6 |
+
- structured tool-call objects that can be returned by LLM backends
|
| 7 |
+
|
| 8 |
+
It intentionally stays small and synchronous so the existing agent code can
|
| 9 |
+
adopt it incrementally without turning Lancer into a generic framework.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import inspect
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any, Callable, get_args, get_origin, get_type_hints
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
JSON_TYPE_MAP = {
|
| 20 |
+
str: "string",
|
| 21 |
+
int: "integer",
|
| 22 |
+
float: "number",
|
| 23 |
+
bool: "boolean",
|
| 24 |
+
list: "array",
|
| 25 |
+
dict: "object",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
|
| 30 |
+
"""Map simple Python annotations to JSON schema fragments."""
|
| 31 |
+
origin = get_origin(py_type)
|
| 32 |
+
if origin is not None:
|
| 33 |
+
args = get_args(py_type)
|
| 34 |
+
if origin is list:
|
| 35 |
+
item_type = args[0] if args else str
|
| 36 |
+
return {"type": "array", "items": _python_type_to_json_schema(item_type)}
|
| 37 |
+
if origin is dict:
|
| 38 |
+
return {"type": "object"}
|
| 39 |
+
non_none = [arg for arg in args if arg is not type(None)]
|
| 40 |
+
if non_none:
|
| 41 |
+
return _python_type_to_json_schema(non_none[0])
|
| 42 |
+
|
| 43 |
+
return {"type": JSON_TYPE_MAP.get(py_type, "string")}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _extract_param_description(docstring: str | None, param_name: str) -> str | None:
|
| 47 |
+
"""Extract a simple parameter description from an Args: section."""
|
| 48 |
+
if not docstring:
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
lines = docstring.splitlines()
|
| 52 |
+
in_args = False
|
| 53 |
+
|
| 54 |
+
for raw_line in lines:
|
| 55 |
+
line = raw_line.strip()
|
| 56 |
+
if line.lower().startswith("args:"):
|
| 57 |
+
in_args = True
|
| 58 |
+
continue
|
| 59 |
+
if not in_args:
|
| 60 |
+
continue
|
| 61 |
+
if not line:
|
| 62 |
+
continue
|
| 63 |
+
if line.startswith(f"{param_name}:"):
|
| 64 |
+
return line.split(":", 1)[1].strip() or None
|
| 65 |
+
if line.startswith(f"{param_name} "):
|
| 66 |
+
parts = line.split(":", 1)
|
| 67 |
+
if len(parts) > 1:
|
| 68 |
+
return parts[1].strip() or None
|
| 69 |
+
# Stop when we leave the Args section.
|
| 70 |
+
if not raw_line.startswith((" ", "\t")):
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass(frozen=True)
|
| 77 |
+
class ToolCall:
|
| 78 |
+
"""Structured tool call emitted by the LLM layer."""
|
| 79 |
+
|
| 80 |
+
id: str
|
| 81 |
+
name: str
|
| 82 |
+
arguments: dict[str, Any]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass(frozen=True)
|
| 86 |
+
class ToolSchema:
|
| 87 |
+
"""OpenAI-compatible tool schema."""
|
| 88 |
+
|
| 89 |
+
name: str
|
| 90 |
+
description: str
|
| 91 |
+
parameters: dict[str, Any]
|
| 92 |
+
|
| 93 |
+
def to_openai_tool(self) -> dict[str, Any]:
|
| 94 |
+
"""Convert schema into OpenAI-compatible tool format."""
|
| 95 |
+
return {
|
| 96 |
+
"type": "function",
|
| 97 |
+
"function": {
|
| 98 |
+
"name": self.name,
|
| 99 |
+
"description": self.description,
|
| 100 |
+
"parameters": self.parameters,
|
| 101 |
+
},
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass
|
| 106 |
+
class Tool:
|
| 107 |
+
"""Registered callable tool with generated schema."""
|
| 108 |
+
|
| 109 |
+
func: Callable[..., Any]
|
| 110 |
+
name: str | None = None
|
| 111 |
+
description: str | None = None
|
| 112 |
+
schema: ToolSchema = field(init=False)
|
| 113 |
+
|
| 114 |
+
def __post_init__(self):
|
| 115 |
+
if self.name is None:
|
| 116 |
+
self.name = self.func.__name__
|
| 117 |
+
if self.description is None:
|
| 118 |
+
self.description = self.func.__doc__ or f"Tool: {self.name}"
|
| 119 |
+
self.schema = self._build_schema()
|
| 120 |
+
|
| 121 |
+
def _build_schema(self) -> ToolSchema:
|
| 122 |
+
sig = inspect.signature(self.func)
|
| 123 |
+
hints = get_type_hints(self.func)
|
| 124 |
+
properties: dict[str, Any] = {}
|
| 125 |
+
required: list[str] = []
|
| 126 |
+
|
| 127 |
+
for param_name, param in sig.parameters.items():
|
| 128 |
+
if param_name in {"self", "cls"}:
|
| 129 |
+
continue
|
| 130 |
+
|
| 131 |
+
py_type = hints.get(param_name, str)
|
| 132 |
+
prop = _python_type_to_json_schema(py_type)
|
| 133 |
+
description = _extract_param_description(self.func.__doc__, param_name)
|
| 134 |
+
if description:
|
| 135 |
+
prop["description"] = description
|
| 136 |
+
properties[param_name] = prop
|
| 137 |
+
|
| 138 |
+
if param.default is inspect.Parameter.empty:
|
| 139 |
+
required.append(param_name)
|
| 140 |
+
|
| 141 |
+
parameters: dict[str, Any] = {
|
| 142 |
+
"type": "object",
|
| 143 |
+
"properties": properties,
|
| 144 |
+
}
|
| 145 |
+
if required:
|
| 146 |
+
parameters["required"] = required
|
| 147 |
+
|
| 148 |
+
return ToolSchema(
|
| 149 |
+
name=str(self.name),
|
| 150 |
+
description=str(self.description),
|
| 151 |
+
parameters=parameters,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
def execute(self, arguments: dict[str, Any]) -> Any:
|
| 155 |
+
"""Execute the tool with validated arguments."""
|
| 156 |
+
return self.func(**arguments)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def tool(
|
| 160 |
+
func: Callable[..., Any] | None = None,
|
| 161 |
+
*,
|
| 162 |
+
name: str | None = None,
|
| 163 |
+
description: str | None = None,
|
| 164 |
+
) -> Tool | Callable[[Callable[..., Any]], Tool]:
|
| 165 |
+
"""Decorator for creating Tool objects from plain Python callables."""
|
| 166 |
+
|
| 167 |
+
def decorator(inner: Callable[..., Any]) -> Tool:
|
| 168 |
+
return Tool(func=inner, name=name, description=description)
|
| 169 |
+
|
| 170 |
+
if func is not None:
|
| 171 |
+
return decorator(func)
|
| 172 |
+
|
| 173 |
+
return decorator
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class ToolRegistry:
|
| 177 |
+
"""Small registry of reusable tools."""
|
| 178 |
+
|
| 179 |
+
def __init__(self, tools: list[Tool | Callable[..., Any]] | None = None):
|
| 180 |
+
self._tools: dict[str, Tool] = {}
|
| 181 |
+
for item in tools or []:
|
| 182 |
+
self.register(item)
|
| 183 |
+
|
| 184 |
+
def register(self, item: Tool | Callable[..., Any]):
|
| 185 |
+
"""Register a Tool or plain callable."""
|
| 186 |
+
if isinstance(item, Tool):
|
| 187 |
+
tool_obj = item
|
| 188 |
+
elif callable(item):
|
| 189 |
+
tool_obj = Tool(func=item)
|
| 190 |
+
else:
|
| 191 |
+
raise TypeError(f"Expected Tool or callable, got {type(item)!r}")
|
| 192 |
+
|
| 193 |
+
self._tools[str(tool_obj.name)] = tool_obj
|
| 194 |
+
|
| 195 |
+
def get(self, name: str) -> Tool | None:
|
| 196 |
+
"""Get a tool by name."""
|
| 197 |
+
return self._tools.get(name)
|
| 198 |
+
|
| 199 |
+
@property
|
| 200 |
+
def names(self) -> list[str]:
|
| 201 |
+
"""Registered tool names."""
|
| 202 |
+
return list(self._tools.keys())
|
| 203 |
+
|
| 204 |
+
@property
|
| 205 |
+
def schemas(self) -> list[ToolSchema]:
|
| 206 |
+
"""Structured schemas for all tools."""
|
| 207 |
+
return [tool_obj.schema for tool_obj in self._tools.values()]
|
| 208 |
+
|
| 209 |
+
def as_openai_tools(self) -> list[dict[str, Any]]:
|
| 210 |
+
"""Serialize all tools into OpenAI-compatible schema format."""
|
| 211 |
+
return [schema.to_openai_tool() for schema in self.schemas]
|
| 212 |
+
|
| 213 |
+
def execute(self, tool_call: ToolCall) -> Any:
|
| 214 |
+
"""Execute a structured tool call."""
|
| 215 |
+
tool_obj = self.get(tool_call.name)
|
| 216 |
+
if tool_obj is None:
|
| 217 |
+
raise KeyError(f"Unknown tool '{tool_call.name}'. Available: {self.names}")
|
| 218 |
+
return tool_obj.execute(tool_call.arguments)
|
| 219 |
+
|
| 220 |
+
def __len__(self) -> int:
|
| 221 |
+
return len(self._tools)
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API routes package."""
|
app/api/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (161 Bytes). View file
|
|
|
app/api/__pycache__/schemas.cpython-311.pyc
ADDED
|
Binary file (7.57 kB). View file
|
|
|
app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""API routes package."""
|
app/api/routes/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (168 Bytes). View file
|
|
|
app/api/routes/__pycache__/search.cpython-311.pyc
ADDED
|
Binary file (27.8 kB). View file
|
|
|
app/api/routes/search.py
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Search API routes."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, HTTPException, Request
|
| 8 |
+
from fastapi.responses import StreamingResponse
|
| 9 |
+
|
| 10 |
+
from app.api.schemas import (
|
| 11 |
+
SearchRequest,
|
| 12 |
+
SearchResponse,
|
| 13 |
+
SearchResult,
|
| 14 |
+
TemporalContext,
|
| 15 |
+
Citation,
|
| 16 |
+
ErrorResponse,
|
| 17 |
+
DeepResearchRequest,
|
| 18 |
+
BrowseRequest,
|
| 19 |
+
)
|
| 20 |
+
from app.config import get_settings
|
| 21 |
+
from app.temporal.intent_detector import detect_temporal_intent
|
| 22 |
+
from app.temporal.freshness_scorer import calculate_freshness_score
|
| 23 |
+
from app.sources.tavily import search_tavily
|
| 24 |
+
from app.sources.duckduckgo import search_duckduckgo
|
| 25 |
+
from app.reranking.pipeline import rerank_results
|
| 26 |
+
from app.agents.synthesizer import synthesize_answer, synthesize_answer_stream
|
| 27 |
+
from app.middleware.rate_limiter import limiter
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post(
|
| 33 |
+
"/search",
|
| 34 |
+
response_model=SearchResponse,
|
| 35 |
+
responses={500: {"model": ErrorResponse}},
|
| 36 |
+
summary="Search with AI synthesis",
|
| 37 |
+
description="Perform a search with temporal intelligence and return an AI-synthesized answer.",
|
| 38 |
+
)
|
| 39 |
+
@limiter.limit("30/minute")
|
| 40 |
+
async def search(request: Request, body: SearchRequest) -> SearchResponse:
|
| 41 |
+
"""
|
| 42 |
+
Perform an intelligent search with:
|
| 43 |
+
- Temporal intent detection
|
| 44 |
+
- Multi-source search
|
| 45 |
+
- Multi-stage reranking
|
| 46 |
+
- AI-powered answer synthesis
|
| 47 |
+
"""
|
| 48 |
+
start_time = time.perf_counter()
|
| 49 |
+
settings = get_settings()
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
# Step 1: Analyze temporal intent
|
| 53 |
+
temporal_intent, temporal_urgency = detect_temporal_intent(body.query)
|
| 54 |
+
|
| 55 |
+
temporal_context = TemporalContext(
|
| 56 |
+
query_temporal_intent=temporal_intent,
|
| 57 |
+
temporal_urgency=temporal_urgency,
|
| 58 |
+
current_date=datetime.now().strftime("%Y-%m-%d"),
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Step 2: Search multiple sources
|
| 62 |
+
raw_results = []
|
| 63 |
+
|
| 64 |
+
# Try Tavily first (best quality)
|
| 65 |
+
if settings.tavily_api_key:
|
| 66 |
+
tavily_results = await search_tavily(
|
| 67 |
+
query=body.query,
|
| 68 |
+
max_results=settings.max_search_results,
|
| 69 |
+
freshness=body.freshness,
|
| 70 |
+
include_domains=body.include_domains,
|
| 71 |
+
exclude_domains=body.exclude_domains,
|
| 72 |
+
)
|
| 73 |
+
raw_results.extend(tavily_results)
|
| 74 |
+
|
| 75 |
+
# Fallback to DuckDuckGo if needed
|
| 76 |
+
if not raw_results:
|
| 77 |
+
ddg_results = await search_duckduckgo(
|
| 78 |
+
query=body.query,
|
| 79 |
+
max_results=settings.max_search_results,
|
| 80 |
+
)
|
| 81 |
+
raw_results.extend(ddg_results)
|
| 82 |
+
|
| 83 |
+
if not raw_results:
|
| 84 |
+
return SearchResponse(
|
| 85 |
+
query=body.query,
|
| 86 |
+
answer="No results found for your query.",
|
| 87 |
+
results=[],
|
| 88 |
+
citations=[],
|
| 89 |
+
temporal_context=temporal_context,
|
| 90 |
+
processing_time_ms=(time.perf_counter() - start_time) * 1000,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Step 3: Apply multi-stage reranking
|
| 94 |
+
ranked_results = await rerank_results(
|
| 95 |
+
query=body.query,
|
| 96 |
+
results=raw_results,
|
| 97 |
+
temporal_urgency=temporal_urgency,
|
| 98 |
+
max_results=body.max_results,
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Step 4: Convert to SearchResult models
|
| 102 |
+
search_results = []
|
| 103 |
+
for i, result in enumerate(ranked_results):
|
| 104 |
+
freshness = calculate_freshness_score(result.get("published_date"))
|
| 105 |
+
search_results.append(
|
| 106 |
+
SearchResult(
|
| 107 |
+
title=result.get("title", ""),
|
| 108 |
+
url=result.get("url", ""),
|
| 109 |
+
content=result.get("content", ""),
|
| 110 |
+
score=result.get("score", 0.5),
|
| 111 |
+
published_date=result.get("published_date"),
|
| 112 |
+
freshness_score=freshness,
|
| 113 |
+
authority_score=result.get("authority_score", 0.5),
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Step 5: Synthesize answer (if requested)
|
| 118 |
+
answer = None
|
| 119 |
+
citations = []
|
| 120 |
+
|
| 121 |
+
if body.include_answer and search_results:
|
| 122 |
+
answer, citations = await synthesize_answer(
|
| 123 |
+
query=body.query,
|
| 124 |
+
results=search_results,
|
| 125 |
+
temporal_context=temporal_context,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
processing_time = (time.perf_counter() - start_time) * 1000
|
| 129 |
+
|
| 130 |
+
return SearchResponse(
|
| 131 |
+
query=body.query,
|
| 132 |
+
answer=answer,
|
| 133 |
+
results=search_results,
|
| 134 |
+
citations=citations,
|
| 135 |
+
temporal_context=temporal_context,
|
| 136 |
+
processing_time_ms=processing_time,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.post(
|
| 145 |
+
"/search/raw",
|
| 146 |
+
response_model=SearchResponse,
|
| 147 |
+
summary="Search without synthesis",
|
| 148 |
+
description="Perform a search and return raw results without AI synthesis (faster).",
|
| 149 |
+
)
|
| 150 |
+
@limiter.limit("30/minute")
|
| 151 |
+
async def search_raw(request: Request, body: SearchRequest) -> SearchResponse:
|
| 152 |
+
"""Fast search without answer synthesis."""
|
| 153 |
+
body.include_answer = False
|
| 154 |
+
return await search(request, body)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@router.post(
|
| 158 |
+
"/search/stream",
|
| 159 |
+
summary="Search with streaming synthesis",
|
| 160 |
+
description="Perform a search and stream the AI-synthesized answer in real-time using SSE.",
|
| 161 |
+
)
|
| 162 |
+
@limiter.limit("30/minute")
|
| 163 |
+
async def search_stream(request: Request, body: SearchRequest):
|
| 164 |
+
"""
|
| 165 |
+
Streaming search with Server-Sent Events.
|
| 166 |
+
|
| 167 |
+
Returns results first, then streams the answer as it's generated.
|
| 168 |
+
"""
|
| 169 |
+
settings = get_settings()
|
| 170 |
+
|
| 171 |
+
async def event_generator():
|
| 172 |
+
try:
|
| 173 |
+
# Step 1: Analyze temporal intent
|
| 174 |
+
temporal_intent, temporal_urgency = detect_temporal_intent(body.query)
|
| 175 |
+
|
| 176 |
+
temporal_context = TemporalContext(
|
| 177 |
+
query_temporal_intent=temporal_intent,
|
| 178 |
+
temporal_urgency=temporal_urgency,
|
| 179 |
+
current_date=datetime.now().strftime("%Y-%m-%d"),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# Step 2: Search sources
|
| 183 |
+
raw_results = []
|
| 184 |
+
|
| 185 |
+
if settings.tavily_api_key:
|
| 186 |
+
tavily_results = await search_tavily(
|
| 187 |
+
query=body.query,
|
| 188 |
+
max_results=settings.max_search_results,
|
| 189 |
+
freshness=body.freshness,
|
| 190 |
+
include_domains=body.include_domains,
|
| 191 |
+
exclude_domains=body.exclude_domains,
|
| 192 |
+
)
|
| 193 |
+
raw_results.extend(tavily_results)
|
| 194 |
+
|
| 195 |
+
if not raw_results:
|
| 196 |
+
ddg_results = await search_duckduckgo(
|
| 197 |
+
query=body.query,
|
| 198 |
+
max_results=settings.max_search_results,
|
| 199 |
+
)
|
| 200 |
+
raw_results.extend(ddg_results)
|
| 201 |
+
|
| 202 |
+
if not raw_results:
|
| 203 |
+
yield f"data: {json.dumps({'type': 'error', 'content': 'No results found'})}\n\n"
|
| 204 |
+
return
|
| 205 |
+
|
| 206 |
+
# Step 3: Rerank
|
| 207 |
+
ranked_results = await rerank_results(
|
| 208 |
+
query=body.query,
|
| 209 |
+
results=raw_results,
|
| 210 |
+
temporal_urgency=temporal_urgency,
|
| 211 |
+
max_results=body.max_results,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
# Step 4: Convert to SearchResult models
|
| 215 |
+
search_results = []
|
| 216 |
+
for result in ranked_results:
|
| 217 |
+
freshness = calculate_freshness_score(result.get("published_date"))
|
| 218 |
+
search_results.append(
|
| 219 |
+
SearchResult(
|
| 220 |
+
title=result.get("title", ""),
|
| 221 |
+
url=result.get("url", ""),
|
| 222 |
+
content=result.get("content", ""),
|
| 223 |
+
score=result.get("score", 0.5),
|
| 224 |
+
published_date=result.get("published_date"),
|
| 225 |
+
freshness_score=freshness,
|
| 226 |
+
authority_score=result.get("authority_score", 0.5),
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Send results first
|
| 231 |
+
results_data = {
|
| 232 |
+
"type": "results",
|
| 233 |
+
"results": [r.model_dump(mode="json") for r in search_results],
|
| 234 |
+
"temporal_context": temporal_context.model_dump(),
|
| 235 |
+
}
|
| 236 |
+
yield f"data: {json.dumps(results_data)}\n\n"
|
| 237 |
+
|
| 238 |
+
# Step 5: Stream answer
|
| 239 |
+
yield f"data: {json.dumps({'type': 'answer_start'})}\n\n"
|
| 240 |
+
|
| 241 |
+
async for chunk in synthesize_answer_stream(
|
| 242 |
+
query=body.query,
|
| 243 |
+
results=search_results,
|
| 244 |
+
temporal_context=temporal_context,
|
| 245 |
+
):
|
| 246 |
+
yield f"data: {json.dumps({'type': 'answer_chunk', 'content': chunk})}\n\n"
|
| 247 |
+
|
| 248 |
+
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
| 249 |
+
|
| 250 |
+
except Exception as e:
|
| 251 |
+
yield f"data: {json.dumps({'type': 'error', 'content': str(e)})}\n\n"
|
| 252 |
+
|
| 253 |
+
return StreamingResponse(
|
| 254 |
+
event_generator(),
|
| 255 |
+
media_type="text/event-stream",
|
| 256 |
+
headers={
|
| 257 |
+
"Cache-Control": "no-cache",
|
| 258 |
+
"Connection": "keep-alive",
|
| 259 |
+
"X-Accel-Buffering": "no",
|
| 260 |
+
},
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# === Deep Research Endpoints ===
|
| 265 |
+
|
| 266 |
+
@router.post(
|
| 267 |
+
"/research/deep",
|
| 268 |
+
summary="Deep research with multi-dimensional analysis",
|
| 269 |
+
description="Decompose a query into dimensions, search each in parallel, and generate a comprehensive report.",
|
| 270 |
+
)
|
| 271 |
+
@limiter.limit("5/minute")
|
| 272 |
+
async def deep_research(request: Request, body: DeepResearchRequest):
|
| 273 |
+
"""
|
| 274 |
+
Run deep research with streaming progress updates.
|
| 275 |
+
|
| 276 |
+
Returns SSE events:
|
| 277 |
+
- plan_ready: Research plan with dimensions
|
| 278 |
+
- dimension_start/complete: Progress per dimension
|
| 279 |
+
- report_chunk: Streaming report content
|
| 280 |
+
- done: Final summary
|
| 281 |
+
"""
|
| 282 |
+
from app.agents.deep_research import run_deep_research
|
| 283 |
+
|
| 284 |
+
return StreamingResponse(
|
| 285 |
+
run_deep_research(
|
| 286 |
+
query=body.query,
|
| 287 |
+
max_dimensions=body.max_dimensions,
|
| 288 |
+
max_sources_per_dim=body.max_sources_per_dim,
|
| 289 |
+
max_total_searches=body.max_total_searches,
|
| 290 |
+
),
|
| 291 |
+
media_type="text/event-stream",
|
| 292 |
+
headers={
|
| 293 |
+
"Cache-Control": "no-cache",
|
| 294 |
+
"Connection": "keep-alive",
|
| 295 |
+
"X-Accel-Buffering": "no",
|
| 296 |
+
},
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
@router.post(
|
| 301 |
+
"/search/heavy",
|
| 302 |
+
summary="Heavy search with content scraping",
|
| 303 |
+
description="Search with full content extraction from top sources for richer answers.",
|
| 304 |
+
)
|
| 305 |
+
@limiter.limit("10/minute")
|
| 306 |
+
async def heavy_search(request: Request, body: SearchRequest):
|
| 307 |
+
"""
|
| 308 |
+
Heavy search with content scraping.
|
| 309 |
+
|
| 310 |
+
Scrapes full content from top results instead of just snippets,
|
| 311 |
+
providing richer context for answer generation.
|
| 312 |
+
"""
|
| 313 |
+
from app.agents.heavy_search import run_heavy_search
|
| 314 |
+
|
| 315 |
+
return StreamingResponse(
|
| 316 |
+
run_heavy_search(
|
| 317 |
+
query=body.query,
|
| 318 |
+
max_results=body.max_results,
|
| 319 |
+
max_scrape=5,
|
| 320 |
+
freshness=body.freshness,
|
| 321 |
+
),
|
| 322 |
+
media_type="text/event-stream",
|
| 323 |
+
headers={
|
| 324 |
+
"Cache-Control": "no-cache",
|
| 325 |
+
"Connection": "keep-alive",
|
| 326 |
+
"X-Accel-Buffering": "no",
|
| 327 |
+
},
|
| 328 |
+
)
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@router.get(
|
| 332 |
+
"/images",
|
| 333 |
+
summary="Search for images",
|
| 334 |
+
description="Search for images related to a query using Brave Image Search.",
|
| 335 |
+
)
|
| 336 |
+
@limiter.limit("60/minute")
|
| 337 |
+
async def image_search(request: Request, query: str, max_results: int = 6):
|
| 338 |
+
"""
|
| 339 |
+
Search for images related to a query.
|
| 340 |
+
|
| 341 |
+
Returns a list of image results with thumbnails and source URLs.
|
| 342 |
+
"""
|
| 343 |
+
from app.sources.images import search_images
|
| 344 |
+
|
| 345 |
+
if not query:
|
| 346 |
+
raise HTTPException(status_code=400, detail="Query is required")
|
| 347 |
+
|
| 348 |
+
images = await search_images(query=query, max_results=max_results)
|
| 349 |
+
|
| 350 |
+
return {"query": query, "images": images}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
# === SearXNG Search (pure - no LLM) ===
|
| 354 |
+
|
| 355 |
+
@router.post(
|
| 356 |
+
"/search/searxng",
|
| 357 |
+
summary="Search using SearXNG + embedding reranking",
|
| 358 |
+
description="Uses SearXNG meta-search with embedding reranking. No LLM synthesis.",
|
| 359 |
+
)
|
| 360 |
+
@limiter.limit("20/minute")
|
| 361 |
+
async def searxng_search(request: Request, body: SearchRequest):
|
| 362 |
+
"""
|
| 363 |
+
Search using SearXNG with embedding reranking only.
|
| 364 |
+
|
| 365 |
+
This endpoint uses your SearXNG instance for 50+ results
|
| 366 |
+
and reranks with embeddings. No LLM synthesis.
|
| 367 |
+
"""
|
| 368 |
+
import json
|
| 369 |
+
from app.sources.searxng import search_searxng
|
| 370 |
+
from app.reranking.embeddings import compute_bi_encoder_scores
|
| 371 |
+
|
| 372 |
+
async def event_generator():
|
| 373 |
+
try:
|
| 374 |
+
# Step 1: Search SearXNG
|
| 375 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Searching SearXNG...'})}\n\n"
|
| 376 |
+
|
| 377 |
+
time_range = {"day": "day", "week": "week", "month": "month"}.get(body.freshness)
|
| 378 |
+
raw_results = await search_searxng(
|
| 379 |
+
query=body.query,
|
| 380 |
+
max_results=50,
|
| 381 |
+
time_range=time_range,
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
if not raw_results:
|
| 385 |
+
yield f"data: {json.dumps({'type': 'error', 'message': 'No results from SearXNG'})}\n\n"
|
| 386 |
+
return
|
| 387 |
+
|
| 388 |
+
yield f"data: {json.dumps({'type': 'searxng_complete', 'count': len(raw_results)})}\n\n"
|
| 389 |
+
|
| 390 |
+
# Step 2: Rerank with embeddings
|
| 391 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Reranking with embeddings...'})}\n\n"
|
| 392 |
+
|
| 393 |
+
docs = [f"{r.get('title', '')}. {r.get('content', '')[:500]}" for r in raw_results]
|
| 394 |
+
scores = compute_bi_encoder_scores(body.query, docs)
|
| 395 |
+
|
| 396 |
+
for i, result in enumerate(raw_results):
|
| 397 |
+
result["embedding_score"] = scores[i]
|
| 398 |
+
orig_score = result.get("score", 0.5)
|
| 399 |
+
result["score"] = (scores[i] * 0.7) + (orig_score * 0.3)
|
| 400 |
+
|
| 401 |
+
raw_results.sort(key=lambda x: x["score"], reverse=True)
|
| 402 |
+
final_results = raw_results[:body.max_results]
|
| 403 |
+
|
| 404 |
+
# Step 3: Return results (no LLM)
|
| 405 |
+
yield f"data: {json.dumps({'type': 'results', 'results': [{'title': r.get('title'), 'url': r.get('url'), 'content': r.get('content', '')[:300], 'score': round(r.get('score', 0), 3), 'source': r.get('source')} for r in final_results]})}\n\n"
|
| 406 |
+
|
| 407 |
+
yield f"data: {json.dumps({'type': 'done', 'total_raw': len(raw_results), 'returned': len(final_results)})}\n\n"
|
| 408 |
+
|
| 409 |
+
except Exception as e:
|
| 410 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 411 |
+
|
| 412 |
+
return StreamingResponse(
|
| 413 |
+
event_generator(),
|
| 414 |
+
media_type="text/event-stream",
|
| 415 |
+
headers={
|
| 416 |
+
"Cache-Control": "no-cache",
|
| 417 |
+
"Connection": "keep-alive",
|
| 418 |
+
},
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
# === Code Search (GitHub, StackOverflow) ===
|
| 423 |
+
|
| 424 |
+
@router.post(
|
| 425 |
+
"/search/code",
|
| 426 |
+
summary="Search code repositories and programming Q&A",
|
| 427 |
+
description="Uses SearXNG with GitHub, StackOverflow, and code-focused engines.",
|
| 428 |
+
)
|
| 429 |
+
@limiter.limit("20/minute")
|
| 430 |
+
async def code_search(request: Request, body: SearchRequest):
|
| 431 |
+
"""
|
| 432 |
+
Search for code, programming solutions, and documentation.
|
| 433 |
+
Uses GitHub, StackOverflow, GitLab, and other code-focused engines.
|
| 434 |
+
"""
|
| 435 |
+
import json
|
| 436 |
+
from app.sources.searxng import search_searxng
|
| 437 |
+
from app.reranking.embeddings import compute_bi_encoder_scores
|
| 438 |
+
|
| 439 |
+
async def event_generator():
|
| 440 |
+
try:
|
| 441 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Searching code repositories...'})}\n\n"
|
| 442 |
+
|
| 443 |
+
# Use code-specific engines
|
| 444 |
+
raw_results = await search_searxng(
|
| 445 |
+
query=body.query,
|
| 446 |
+
max_results=50,
|
| 447 |
+
categories=["it"], # IT category includes code engines
|
| 448 |
+
engines=["github", "stackoverflow", "gitlab", "npm", "pypi", "crates.io", "packagist"],
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
if not raw_results:
|
| 452 |
+
yield f"data: {json.dumps({'type': 'error', 'message': 'No code results found'})}\n\n"
|
| 453 |
+
return
|
| 454 |
+
|
| 455 |
+
yield f"data: {json.dumps({'type': 'search_complete', 'count': len(raw_results)})}\n\n"
|
| 456 |
+
|
| 457 |
+
# Rerank with embeddings
|
| 458 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Ranking by relevance...'})}\n\n"
|
| 459 |
+
|
| 460 |
+
docs = [f"{r.get('title', '')}. {r.get('content', '')[:500]}" for r in raw_results]
|
| 461 |
+
scores = compute_bi_encoder_scores(body.query, docs)
|
| 462 |
+
|
| 463 |
+
for i, result in enumerate(raw_results):
|
| 464 |
+
result["embedding_score"] = scores[i]
|
| 465 |
+
orig_score = result.get("score", 0.5)
|
| 466 |
+
result["score"] = (scores[i] * 0.7) + (orig_score * 0.3)
|
| 467 |
+
|
| 468 |
+
raw_results.sort(key=lambda x: x["score"], reverse=True)
|
| 469 |
+
final_results = raw_results[:body.max_results]
|
| 470 |
+
|
| 471 |
+
yield f"data: {json.dumps({'type': 'results', 'results': [{'title': r.get('title'), 'url': r.get('url'), 'content': r.get('content', '')[:300], 'score': round(r.get('score', 0), 3), 'source': r.get('source')} for r in final_results]})}\n\n"
|
| 472 |
+
yield f"data: {json.dumps({'type': 'done', 'total_raw': len(raw_results), 'returned': len(final_results)})}\n\n"
|
| 473 |
+
|
| 474 |
+
except Exception as e:
|
| 475 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 476 |
+
|
| 477 |
+
return StreamingResponse(
|
| 478 |
+
event_generator(),
|
| 479 |
+
media_type="text/event-stream",
|
| 480 |
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
# === Academic Search (arXiv, Google Scholar) ===
|
| 485 |
+
|
| 486 |
+
@router.post(
|
| 487 |
+
"/search/academic",
|
| 488 |
+
summary="Search academic papers and research",
|
| 489 |
+
description="Uses SearXNG with arXiv, Google Scholar, Semantic Scholar, and academic engines.",
|
| 490 |
+
)
|
| 491 |
+
@limiter.limit("20/minute")
|
| 492 |
+
async def academic_search(request: Request, body: SearchRequest):
|
| 493 |
+
"""
|
| 494 |
+
Search for academic papers, research, and scientific content.
|
| 495 |
+
Uses arXiv, Google Scholar, Semantic Scholar, PubMed, and other academic engines.
|
| 496 |
+
"""
|
| 497 |
+
import json
|
| 498 |
+
from app.sources.searxng import search_searxng
|
| 499 |
+
from app.reranking.embeddings import compute_bi_encoder_scores
|
| 500 |
+
|
| 501 |
+
async def event_generator():
|
| 502 |
+
try:
|
| 503 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Searching academic sources...'})}\n\n"
|
| 504 |
+
|
| 505 |
+
# Use academic engines
|
| 506 |
+
raw_results = await search_searxng(
|
| 507 |
+
query=body.query,
|
| 508 |
+
max_results=50,
|
| 509 |
+
categories=["science"],
|
| 510 |
+
engines=["arxiv", "google scholar", "semantic scholar", "pubmed", "base", "crossref"],
|
| 511 |
+
)
|
| 512 |
+
|
| 513 |
+
if not raw_results:
|
| 514 |
+
yield f"data: {json.dumps({'type': 'error', 'message': 'No academic results found'})}\n\n"
|
| 515 |
+
return
|
| 516 |
+
|
| 517 |
+
yield f"data: {json.dumps({'type': 'search_complete', 'count': len(raw_results)})}\n\n"
|
| 518 |
+
|
| 519 |
+
# Rerank with embeddings
|
| 520 |
+
yield f"data: {json.dumps({'type': 'status', 'message': 'Ranking by relevance...'})}\n\n"
|
| 521 |
+
|
| 522 |
+
docs = [f"{r.get('title', '')}. {r.get('content', '')[:500]}" for r in raw_results]
|
| 523 |
+
scores = compute_bi_encoder_scores(body.query, docs)
|
| 524 |
+
|
| 525 |
+
for i, result in enumerate(raw_results):
|
| 526 |
+
result["embedding_score"] = scores[i]
|
| 527 |
+
orig_score = result.get("score", 0.5)
|
| 528 |
+
result["score"] = (scores[i] * 0.7) + (orig_score * 0.3)
|
| 529 |
+
|
| 530 |
+
raw_results.sort(key=lambda x: x["score"], reverse=True)
|
| 531 |
+
final_results = raw_results[:body.max_results]
|
| 532 |
+
|
| 533 |
+
yield f"data: {json.dumps({'type': 'results', 'results': [{'title': r.get('title'), 'url': r.get('url'), 'content': r.get('content', '')[:300], 'score': round(r.get('score', 0), 3), 'source': r.get('source')} for r in final_results]})}\n\n"
|
| 534 |
+
yield f"data: {json.dumps({'type': 'done', 'total_raw': len(raw_results), 'returned': len(final_results)})}\n\n"
|
| 535 |
+
|
| 536 |
+
except Exception as e:
|
| 537 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 538 |
+
|
| 539 |
+
return StreamingResponse(
|
| 540 |
+
event_generator(),
|
| 541 |
+
media_type="text/event-stream",
|
| 542 |
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
# === Browser Agent ===
|
| 547 |
+
|
| 548 |
+
@router.post(
|
| 549 |
+
"/agent/browse",
|
| 550 |
+
summary="Browser agent - navigate and extract from websites",
|
| 551 |
+
description="Uses E2B sandbox. stream_visual=true runs the visual Chrome agent, false runs the stealth Camoufox agent.",
|
| 552 |
+
)
|
| 553 |
+
@limiter.limit("10/minute")
|
| 554 |
+
async def browser_agent(request: Request, body: BrowseRequest):
|
| 555 |
+
"""
|
| 556 |
+
Browser agent with two modes:
|
| 557 |
+
- stream_visual=true: visual Chrome agent with live video stream
|
| 558 |
+
- stream_visual=false: stealth Camoufox headless agent
|
| 559 |
+
"""
|
| 560 |
+
|
| 561 |
+
async def event_generator():
|
| 562 |
+
try:
|
| 563 |
+
if body.stream_visual:
|
| 564 |
+
from app.agents.browser_visual import run_browser_visual_agent
|
| 565 |
+
async for event in run_browser_visual_agent(body.task, body.url):
|
| 566 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 567 |
+
else:
|
| 568 |
+
from app.agents.browser_stealth import run_browser_stealth_agent
|
| 569 |
+
async for event in run_browser_stealth_agent(body.task, body.url):
|
| 570 |
+
yield f"data: {json.dumps(event)}\n\n"
|
| 571 |
+
except Exception as e:
|
| 572 |
+
yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n"
|
| 573 |
+
|
| 574 |
+
return StreamingResponse(
|
| 575 |
+
event_generator(),
|
| 576 |
+
media_type="text/event-stream",
|
| 577 |
+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
|
| 578 |
+
)
|
| 579 |
+
|