Madras1 commited on
Commit
4bcc05b
·
verified ·
1 Parent(s): 691a389

Upload 89 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app/__init__.py +3 -0
  2. app/__pycache__/__init__.cpython-311.pyc +0 -0
  3. app/__pycache__/config.cpython-311.pyc +0 -0
  4. app/__pycache__/main.cpython-311.pyc +0 -0
  5. app/agents/__init__.py +1 -0
  6. app/agents/__pycache__/__init__.cpython-311.pyc +0 -0
  7. app/agents/__pycache__/browser_decision.cpython-311.pyc +0 -0
  8. app/agents/__pycache__/browser_dom.cpython-311.pyc +0 -0
  9. app/agents/__pycache__/browser_fastpath.cpython-311.pyc +0 -0
  10. app/agents/__pycache__/browser_search.cpython-311.pyc +0 -0
  11. app/agents/__pycache__/browser_stealth.cpython-311.pyc +0 -0
  12. app/agents/__pycache__/browser_tools.cpython-311.pyc +0 -0
  13. app/agents/__pycache__/browser_visual.cpython-311.pyc +0 -0
  14. app/agents/__pycache__/deep_research.cpython-311.pyc +0 -0
  15. app/agents/__pycache__/flaresolverr.cpython-311.pyc +0 -0
  16. app/agents/__pycache__/heavy_search.cpython-311.pyc +0 -0
  17. app/agents/__pycache__/llm_client.cpython-311.pyc +0 -0
  18. app/agents/__pycache__/planner.cpython-311.pyc +0 -0
  19. app/agents/__pycache__/synthesizer.cpython-311.pyc +0 -0
  20. app/agents/__pycache__/tooling.cpython-311.pyc +0 -0
  21. app/agents/browser_decision.py +216 -0
  22. app/agents/browser_dom.py +142 -0
  23. app/agents/browser_search.py +56 -0
  24. app/agents/browser_stealth.py +312 -0
  25. app/agents/browser_tools.py +237 -0
  26. app/agents/browser_visual.py +293 -0
  27. app/agents/deep_research.py +236 -0
  28. app/agents/flaresolverr.py +128 -0
  29. app/agents/graph/__init__.py +1 -0
  30. app/agents/graph/__pycache__/__init__.cpython-311.pyc +0 -0
  31. app/agents/graph/__pycache__/nodes.cpython-311.pyc +0 -0
  32. app/agents/graph/__pycache__/runner.cpython-311.pyc +0 -0
  33. app/agents/graph/__pycache__/simple_agent.cpython-311.pyc +0 -0
  34. app/agents/graph/__pycache__/state.cpython-311.pyc +0 -0
  35. app/agents/graph/nodes.py +338 -0
  36. app/agents/graph/runner.py +133 -0
  37. app/agents/graph/simple_agent.py +321 -0
  38. app/agents/graph/state.py +187 -0
  39. app/agents/heavy_search.py +192 -0
  40. app/agents/llm_client.py +522 -0
  41. app/agents/planner.py +133 -0
  42. app/agents/synthesizer.py +173 -0
  43. app/agents/tooling.py +221 -0
  44. app/api/__init__.py +1 -0
  45. app/api/__pycache__/__init__.cpython-311.pyc +0 -0
  46. app/api/__pycache__/schemas.cpython-311.pyc +0 -0
  47. app/api/routes/__init__.py +1 -0
  48. app/api/routes/__pycache__/__init__.cpython-311.pyc +0 -0
  49. app/api/routes/__pycache__/search.cpython-311.pyc +0 -0
  50. 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
+