entari-plugin-hyw 4.0.0rc6__py3-none-any.whl → 4.0.0rc8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of entari-plugin-hyw might be problematic. Click here for more details.

Files changed (114) hide show
  1. entari_plugin_hyw/Untitled-1 +1865 -0
  2. entari_plugin_hyw/__init__.py +733 -379
  3. entari_plugin_hyw/history.py +60 -57
  4. entari_plugin_hyw/misc.py +3 -0
  5. entari_plugin_hyw/search_cache.py +154 -0
  6. {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/METADATA +3 -1
  7. entari_plugin_hyw-4.0.0rc8.dist-info/RECORD +68 -0
  8. {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/WHEEL +1 -1
  9. {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/top_level.txt +1 -0
  10. hyw_core/__init__.py +94 -0
  11. hyw_core/browser_control/__init__.py +65 -0
  12. hyw_core/browser_control/assets/card-dist/index.html +409 -0
  13. hyw_core/browser_control/assets/index.html +5691 -0
  14. hyw_core/browser_control/engines/__init__.py +17 -0
  15. hyw_core/browser_control/engines/default.py +166 -0
  16. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/duckduckgo.py +42 -8
  17. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/google.py +1 -1
  18. {entari_plugin_hyw/browser → hyw_core/browser_control}/manager.py +15 -8
  19. entari_plugin_hyw/render_vue.py → hyw_core/browser_control/renderer.py +29 -14
  20. hyw_core/browser_control/service.py +720 -0
  21. hyw_core/config.py +154 -0
  22. hyw_core/core.py +322 -0
  23. hyw_core/definitions.py +83 -0
  24. entari_plugin_hyw/modular_pipeline.py → hyw_core/pipeline.py +204 -86
  25. {entari_plugin_hyw → hyw_core}/search.py +60 -19
  26. hyw_core/stages/__init__.py +21 -0
  27. entari_plugin_hyw/stage_base.py → hyw_core/stages/base.py +3 -0
  28. entari_plugin_hyw/stage_summary.py → hyw_core/stages/summary.py +36 -7
  29. entari_plugin_hyw/assets/card-dist/index.html +0 -387
  30. entari_plugin_hyw/browser/__init__.py +0 -10
  31. entari_plugin_hyw/browser/engines/bing.py +0 -95
  32. entari_plugin_hyw/browser/service.py +0 -304
  33. entari_plugin_hyw/card-ui/.gitignore +0 -24
  34. entari_plugin_hyw/card-ui/README.md +0 -5
  35. entari_plugin_hyw/card-ui/index.html +0 -16
  36. entari_plugin_hyw/card-ui/package-lock.json +0 -2342
  37. entari_plugin_hyw/card-ui/package.json +0 -31
  38. entari_plugin_hyw/card-ui/public/logos/anthropic.svg +0 -1
  39. entari_plugin_hyw/card-ui/public/logos/cerebras.svg +0 -9
  40. entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
  41. entari_plugin_hyw/card-ui/public/logos/gemini.svg +0 -1
  42. entari_plugin_hyw/card-ui/public/logos/google.svg +0 -1
  43. entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
  44. entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
  45. entari_plugin_hyw/card-ui/public/logos/microsoft.svg +0 -15
  46. entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
  47. entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
  48. entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
  49. entari_plugin_hyw/card-ui/public/logos/openai.svg +0 -1
  50. entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
  51. entari_plugin_hyw/card-ui/public/logos/perplexity.svg +0 -24
  52. entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
  53. entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
  54. entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
  55. entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
  56. entari_plugin_hyw/card-ui/public/vite.svg +0 -1
  57. entari_plugin_hyw/card-ui/src/App.vue +0 -756
  58. entari_plugin_hyw/card-ui/src/assets/vue.svg +0 -1
  59. entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +0 -41
  60. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +0 -382
  61. entari_plugin_hyw/card-ui/src/components/SectionCard.vue +0 -41
  62. entari_plugin_hyw/card-ui/src/components/StageCard.vue +0 -240
  63. entari_plugin_hyw/card-ui/src/main.ts +0 -5
  64. entari_plugin_hyw/card-ui/src/style.css +0 -29
  65. entari_plugin_hyw/card-ui/src/test_regex.js +0 -103
  66. entari_plugin_hyw/card-ui/src/types.ts +0 -61
  67. entari_plugin_hyw/card-ui/tsconfig.app.json +0 -16
  68. entari_plugin_hyw/card-ui/tsconfig.json +0 -7
  69. entari_plugin_hyw/card-ui/tsconfig.node.json +0 -26
  70. entari_plugin_hyw/card-ui/vite.config.ts +0 -16
  71. entari_plugin_hyw/definitions.py +0 -155
  72. entari_plugin_hyw/stage_instruct.py +0 -345
  73. entari_plugin_hyw/stage_instruct_deepsearch.py +0 -104
  74. entari_plugin_hyw-4.0.0rc6.dist-info/RECORD +0 -100
  75. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/anthropic.svg +0 -0
  76. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/cerebras.svg +0 -0
  77. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/deepseek.png +0 -0
  78. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/gemini.svg +0 -0
  79. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/google.svg +0 -0
  80. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/grok.png +0 -0
  81. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/huggingface.png +0 -0
  82. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/microsoft.svg +0 -0
  83. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/minimax.png +0 -0
  84. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/mistral.png +0 -0
  85. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/nvida.png +0 -0
  86. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openai.svg +0 -0
  87. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openrouter.png +0 -0
  88. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/perplexity.svg +0 -0
  89. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/qwen.png +0 -0
  90. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xai.png +0 -0
  91. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xiaomi.png +0 -0
  92. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/zai.png +0 -0
  93. {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/vite.svg +0 -0
  94. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/anthropic.svg +0 -0
  95. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/cerebras.svg +0 -0
  96. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/deepseek.png +0 -0
  97. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/gemini.svg +0 -0
  98. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/google.svg +0 -0
  99. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/grok.png +0 -0
  100. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/huggingface.png +0 -0
  101. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/microsoft.svg +0 -0
  102. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/minimax.png +0 -0
  103. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/mistral.png +0 -0
  104. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/nvida.png +0 -0
  105. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openai.svg +0 -0
  106. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openrouter.png +0 -0
  107. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/perplexity.svg +0 -0
  108. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/qwen.png +0 -0
  109. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xai.png +0 -0
  110. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xiaomi.png +0 -0
  111. {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/zai.png +0 -0
  112. {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/base.py +0 -0
  113. {entari_plugin_hyw/browser → hyw_core/browser_control}/landing.html +0 -0
  114. {entari_plugin_hyw → hyw_core}/image_cache.py +0 -0
@@ -7,15 +7,15 @@ Simpler flow with self-correction/feedback loop.
7
7
 
8
8
  import asyncio
9
9
  import time
10
+ import re
10
11
  from typing import Any, Dict, List, Optional, Callable, Awaitable
11
12
 
12
13
  from loguru import logger
13
14
  from openai import AsyncOpenAI
14
15
 
15
- from .stage_base import StageContext
16
- from .stage_instruct import InstructStage
17
- from .stage_instruct_deepsearch import InstructDeepsearchStage
18
- from .stage_summary import SummaryStage
16
+ from .stages.base import StageContext, StageResult
17
+ from .stages.base import StageContext, StageResult, BaseStage
18
+ from .stages.summary import SummaryStage
19
19
  from .search import SearchService
20
20
 
21
21
 
@@ -24,36 +24,47 @@ class ModularPipeline:
24
24
  Modular Pipeline.
25
25
 
26
26
  Flow:
27
- 1. Instruct: Initial Discovery + Mode Decision (fast/deepsearch).
28
- 2. [Deepsearch only] Instruct Deepsearch Loop: Supplement info (max 3 iterations).
29
- 3. Summary: Generate final response.
27
+ 1. Input Analysis:
28
+ - If Images -> Skip Search -> Summary
29
+ - If Text -> Execute Search (or URL fetch) -> Summary
30
+ 2. Summary: Generate final response.
30
31
  """
31
32
 
32
- def __init__(self, config: Any, send_func: Optional[Callable[[str], Awaitable[None]]] = None):
33
+ def __init__(self, config: Any, search_service: SearchService, send_func: Optional[Callable[[str], Awaitable[None]]] = None):
33
34
  self.config = config
34
35
  self.send_func = send_func
35
- self.search_service = SearchService(config)
36
+ self.search_service = search_service
36
37
  self.client = AsyncOpenAI(base_url=config.base_url, api_key=config.api_key)
37
38
 
38
39
  # Initialize stages
39
- self.instruct_stage = InstructStage(config, self.search_service, self.client)
40
- self.instruct_deepsearch_stage = InstructDeepsearchStage(config, self.search_service, self.client)
41
40
  self.summary_stage = SummaryStage(config, self.search_service, self.client)
42
41
 
42
+ @property
43
+ def _send_func(self) -> Optional[Callable[[str], Awaitable[None]]]:
44
+ """Getter for _send_func (alias for send_func)."""
45
+ return self.send_func
46
+
47
+ @_send_func.setter
48
+ def _send_func(self, value: Optional[Callable[[str], Awaitable[None]]]):
49
+ """Setter for _send_func - updates send_func and propagates to stages."""
50
+ self.send_func = value
51
+
52
+
43
53
  async def execute(
44
54
  self,
45
55
  user_input: str,
46
56
  conversation_history: List[Dict],
47
57
  model_name: str = None,
48
58
  images: List[str] = None,
49
- vision_model_name: str = None,
50
- selected_vision_model: str = None,
51
59
  ) -> Dict[str, Any]:
52
60
  """Execute the modular pipeline."""
53
61
  start_time = time.time()
54
62
  stats = {"start_time": start_time}
55
63
  usage_totals = {"input_tokens": 0, "output_tokens": 0}
56
64
  active_model = model_name or self.config.model_name
65
+ if not active_model:
66
+ # Fallback to instruct model for logging/context
67
+ active_model = self.config.get_model_config("instruct").model_name
57
68
 
58
69
  context = StageContext(
59
70
  user_input=user_input,
@@ -79,56 +90,167 @@ class ModularPipeline:
79
90
  try:
80
91
  logger.info(f"Pipeline: Processing '{user_input[:30]}...'")
81
92
 
82
- # === Stage 1: Instruct (Initial Discovery) ===
83
- logger.info("Pipeline: Stage 1 - Instruct")
84
- instruct_result = await self.instruct_stage.execute(context)
93
+ # === Image-First Logic ===
94
+ # When user provides images, skip search and go directly to Instruct
95
+ # Images will be passed through to both Instruct and Summary stages
96
+ has_user_images = bool(images)
97
+ if has_user_images:
98
+ logger.info(f"Pipeline: {len(images)} user image(s) detected. Skipping search -> Instruct.")
99
+
100
+ # === Search-First Logic (only when no images) ===
101
+ # 1. URL Detection
102
+ # Updated to capture full URLs including queries and paths
103
+ url_pattern = re.compile(r'https?://(?:[-\w./?=&%#]+)')
104
+ found_urls = url_pattern.findall(user_input)
105
+
106
+ hit_content = False
107
+
108
+ # Skip URL fetch and search if user provided images or long query
109
+ is_long_query = len(user_input) > 20
110
+ if has_user_images:
111
+ hit_content = False # Force into Instruct path
112
+ elif is_long_query:
113
+ logger.info(f"Pipeline: Long query ({len(user_input)} chars). Skipping direct search/fetch -> Instruct.")
114
+ hit_content = False
115
+ elif found_urls:
116
+ logger.info(f"Pipeline: Detected {len(found_urls)} URLs. Executing direct fetch...")
117
+ # Fetch pages (borrowing logic from InstructStage's batch fetch would be ideal,
118
+ # but we'll use search_service directly and simulate what Instruct did for context)
119
+
120
+ # Fetch
121
+ fetch_results = await self.search_service.fetch_pages_batch(found_urls)
122
+
123
+ # Pre-render screenshots if needed (similar to InstructStage logic)
124
+ # For brevity/cleanliness, assuming fetch_pages_batch returns what we need or we process it.
125
+ # Ideally we want screenshots for the UI. The serivce.fetch_page usually returns raw data.
126
+ # We need to render them if we want screenshots.
127
+ # To keep it simple for this file, we'll skip complex screenshot rendering here OR
128
+ # we rely on the summary stage to just use the text.
129
+ # But the user logic implies "Search/Fetch Hit -> Summary".
130
+
131
+ # Let's populate context.web_results
132
+ for i, page_data in enumerate(fetch_results):
133
+ if page_data.get("content"):
134
+ hit_content = True
135
+ context.web_results.append({
136
+ "_id": context.next_id(),
137
+ "_type": "page",
138
+ "title": page_data.get("title", "Page"),
139
+ "url": page_data.get("url", found_urls[i]),
140
+ "content": page_data.get("content", ""),
141
+ "images": page_data.get("images", []),
142
+ # For now, no screenshot unless we call renderer.
143
+ # If critical, we can add it later.
144
+ })
145
+
146
+ # 2. Search (if no URLs or just always try search if simple query?)
147
+ # The prompt says: "judging result quantity > 0".
148
+ if not hit_content and not has_user_images and not is_long_query and user_input.strip():
149
+ logger.info("Pipeline: No URLs found or fetched. Executing direct search...")
150
+ search_start = time.time()
151
+ search_results = await self.search_service.search(user_input)
152
+ context.search_time = time.time() - search_start
153
+
154
+ # Filter out the raw debug page
155
+ valid_results = [r for r in search_results if not r.get("_hidden")]
156
+
157
+ if valid_results:
158
+ logger.info(f"Pipeline: Search found {len(valid_results)} results in {context.search_time:.2f}s. Proceeding to Summary.")
159
+ hit_content = True
160
+ for item in search_results: # Add all, including hidden debug ones if needed by history
161
+ item["_id"] = context.next_id()
162
+ if "_type" not in item: item["_type"] = "search"
163
+ item["query"] = user_input
164
+ context.web_results.append(item)
165
+ else:
166
+ logger.info("Pipeline: Search yielded 0 results.")
167
+
168
+ # === Branching ===
169
+ if hit_content and not has_user_images:
170
+ # -> Summary Stage (search/URL results available)
171
+ logger.info("Pipeline: Content found (URL/Search). Proceeding to Summary.")
172
+
173
+ # If no content was found and no images, we still proceed to Summary but with empty context (Direct Chat)
174
+ # If images, we proceed to Summary with images.
175
+
176
+ # Refusal check from search results? (Unlikely, but good to keep in mind)
177
+ pass
178
+
179
+
180
+ # === Parallel Execution: Summary Generation + Image Prefetching ===
181
+ # We run image prefetching concurrently with Summary generation to save time.
182
+
183
+ # 1. Prepare candidates for prefetch (all images in search results)
184
+ all_candidate_urls = set()
185
+ for r in context.web_results:
186
+ # Add images from search results/pages
187
+ if r.get("images"):
188
+ for img in r["images"]:
189
+ if img and isinstance(img, str) and img.startswith("http"):
190
+ all_candidate_urls.add(img)
85
191
 
86
- # Trace & Usage
87
- instruct_result.trace["stage_name"] = "Instruct (Round 1)"
88
- trace["instruct_rounds"].append(instruct_result.trace)
89
- usage_totals["input_tokens"] += instruct_result.usage.get("input_tokens", 0)
90
- usage_totals["output_tokens"] += instruct_result.usage.get("output_tokens", 0)
192
+ prefetch_list = list(all_candidate_urls)
193
+ logger.info(f"Pipeline: Starting parallel execution (Summary + Prefetch {len(prefetch_list)} images)")
194
+
195
+ # 2. Define parallel tasks with timing
196
+ async def timed_summary():
197
+ t0 = time.time()
198
+ # Collect page screenshots if image mode
199
+ summary_input_images = list(images) if images else []
200
+ if context.image_input_supported:
201
+ # Collect pre-rendered screenshots from web_results
202
+ for r in context.web_results:
203
+ if r.get("_type") == "page" and r.get("screenshot_b64"):
204
+ summary_input_images.append(r["screenshot_b64"])
205
+
206
+ if context.should_refuse:
207
+ return StageResult(success=True, data={"content": "Refused"}, usage={}, trace={}), 0.0
208
+
209
+ res = await self.summary_stage.execute(
210
+ context,
211
+ images=summary_input_images if summary_input_images else None
212
+ )
213
+ duration = time.time() - t0
214
+ return res, duration
215
+
216
+ async def timed_prefetch():
217
+ t0 = time.time()
218
+ if not prefetch_list:
219
+ return {}, 0.0
220
+ try:
221
+ from .image_cache import get_image_cache
222
+ cache = get_image_cache()
223
+ # Start prefetch (non-blocking kickoff)
224
+ cache.start_prefetch(prefetch_list)
225
+ # Wait for results (blocking until done)
226
+ res = await cache.get_all_cached(prefetch_list)
227
+ duration = time.time() - t0
228
+ return res, duration
229
+ except Exception as e:
230
+ logger.warning(f"Pipeline: Prefetch failed: {e}")
231
+ return {}, time.time() - t0
232
+
233
+ # 3. Execute concurrently
234
+ summary_task = asyncio.create_task(timed_summary())
235
+ prefetch_task = asyncio.create_task(timed_prefetch())
236
+
237
+ # Wait for both to complete
238
+ await asyncio.wait([summary_task, prefetch_task])
239
+
240
+ # 4. Process results and log timing
241
+ summary_result, summary_time = await summary_task
242
+ cached_map, prefetch_time = await prefetch_task
91
243
 
92
- # Check refuse
93
244
  if context.should_refuse:
245
+ # Double check if summary triggered refusal
94
246
  return self._build_refusal_response(context, conversation_history, active_model, stats)
95
247
 
96
- # === Stage 2: Deepsearch Loop (if mode is deepsearch) ===
97
- if context.selected_mode == "deepsearch":
98
- MAX_DEEPSEARCH_ITERATIONS = 3
99
- logger.info(f"Pipeline: Mode is 'deepsearch', starting loop (max {MAX_DEEPSEARCH_ITERATIONS} iterations)")
100
-
101
- for i in range(MAX_DEEPSEARCH_ITERATIONS):
102
- logger.info(f"Pipeline: Stage 2 - Deepsearch Iteration {i + 1}")
103
- deepsearch_result = await self.instruct_deepsearch_stage.execute(context)
104
-
105
- # Trace & Usage
106
- deepsearch_result.trace["stage_name"] = f"Deepsearch (Iteration {i + 1})"
107
- trace["instruct_rounds"].append(deepsearch_result.trace)
108
- usage_totals["input_tokens"] += deepsearch_result.usage.get("input_tokens", 0)
109
- usage_totals["output_tokens"] += deepsearch_result.usage.get("output_tokens", 0)
110
-
111
- # Check if should stop
112
- if deepsearch_result.data.get("should_stop"):
113
- logger.info(f"Pipeline: Deepsearch loop ended at iteration {i + 1}")
114
- break
248
+ time_diff = abs(summary_time - prefetch_time)
249
+ if summary_time > prefetch_time:
250
+ logger.info(f"Pipeline: Image Prefetch finished first ({prefetch_time:.2f}s). Summary took {summary_time:.2f}s. (Waited {time_diff:.2f}s for Summary)")
115
251
  else:
116
- logger.info("Pipeline: Mode is 'fast', skipping deepsearch stage")
117
-
118
- # === Stage 3: Summary ===
119
- # Collect page screenshots if image mode (already rendered in InstructStage)
120
- all_images = list(images) if images else []
121
-
122
- if context.image_input_supported:
123
- # Collect pre-rendered screenshots from web_results
124
- for r in context.web_results:
125
- if r.get("_type") == "page" and r.get("screenshot_b64"):
126
- all_images.append(r["screenshot_b64"])
127
-
128
- summary_result = await self.summary_stage.execute(
129
- context,
130
- images=all_images if all_images else None
131
- )
252
+ logger.info(f"Pipeline: Summary finished first ({summary_time:.2f}s). Image Prefetch took {prefetch_time:.2f}s. (Waited {time_diff:.2f}s for Prefetch)")
253
+
132
254
  trace["summary"] = summary_result.trace
133
255
  usage_totals["input_tokens"] += summary_result.usage.get("input_tokens", 0)
134
256
  usage_totals["output_tokens"] += summary_result.usage.get("output_tokens", 0)
@@ -139,40 +261,30 @@ class ModularPipeline:
139
261
  stats["total_time"] = time.time() - start_time
140
262
  structured = self._parse_response(summary_content, context)
141
263
 
142
- # === Image Caching (Prefetch images for UI) ===
143
- try:
144
- from .image_cache import get_image_cache
145
- cache = get_image_cache()
146
-
147
- # 1. Collect all image URLs from structured response
148
- all_image_urls = []
149
- for ref in structured.get("references", []):
150
- if ref.get("images"):
151
- all_image_urls.extend([img for img in ref["images"] if img and img.startswith("http")])
152
-
153
- if all_image_urls:
154
- # 2. Prefetch (wait for them as we are about to render)
155
- cached_map = await cache.get_all_cached(all_image_urls)
156
-
157
- # 3. Update structured response with cached (base64) URLs
264
+ # === Apply Cached Images ===
265
+ # Update structured response using the map from parallel prefetch
266
+ if cached_map:
267
+ try:
268
+ total_replaced = 0
158
269
  for ref in structured.get("references", []):
159
270
  if ref.get("images"):
160
- # Keep cached images, but preserve original URLs as fallback
161
271
  new_images = []
162
272
  for img in ref["images"]:
163
- # 1. Already Base64 (from Search Injection) -> Keep it
273
+ # 1. Already Base64 -> Keep it
164
274
  if img.startswith("data:"):
165
275
  new_images.append(img)
166
276
  continue
167
-
168
- # 2. Cached successfully -> Keep it
277
+
278
+ # 2. Check cache
169
279
  cached_val = cached_map.get(img)
170
280
  if cached_val and cached_val.startswith("data:"):
171
281
  new_images.append(cached_val)
172
- # 3. Else -> DROP IT (User request: "Delete Fallback, must download in advance")
282
+ total_replaced += 1
283
+ # 3. Else -> DROP IT (as per policy)
173
284
  ref["images"] = new_images
174
- except Exception as e:
175
- logger.warning(f"Pipeline: Image caching failed: {e}")
285
+ logger.debug(f"Pipeline: Replaced {total_replaced} images with cached versions")
286
+ except Exception as e:
287
+ logger.warning(f"Pipeline: Applying cached images failed: {e}")
176
288
 
177
289
  # Debug: Log image counts
178
290
  total_ref_images = sum(len(ref.get("images", []) or []) for ref in structured.get("references", []))
@@ -197,6 +309,9 @@ class ModularPipeline:
197
309
  },
198
310
  "stages_used": stages_used,
199
311
  "web_results": context.web_results,
312
+ "trace": trace,
313
+
314
+ "instruct_traces": trace.get("instruct_rounds", []),
200
315
  }
201
316
 
202
317
  except Exception as e:
@@ -305,6 +420,8 @@ class ModularPipeline:
305
420
  # Sort: Fetched first
306
421
  search_refs.sort(key=lambda x: x["is_fetched"], reverse=True)
307
422
 
423
+ logger.debug(f"_build_stages_ui: Found {len(search_refs)} search refs from {len(context.web_results)} web_results")
424
+
308
425
  if search_refs:
309
426
  stages.append({
310
427
  "name": "Search",
@@ -312,9 +429,10 @@ class ModularPipeline:
312
429
  "icon_config": "openai",
313
430
  "provider": "Web",
314
431
  "references": search_refs,
315
- "description": f"Found {len(search_refs)} results."
432
+ "description": f"Found {len(search_refs)} results.",
433
+ "time": getattr(context, 'search_time', 0)
316
434
  })
317
-
435
+
318
436
  # 2. Instruct Rounds
319
437
  for i, t in enumerate(trace.get("instruct_rounds", [])):
320
438
  stage_name = t.get("stage_name", f"Analysis {i+1}")
@@ -330,8 +448,8 @@ class ModularPipeline:
330
448
  # Calculate cost from config prices
331
449
  usage = t.get("usage", {})
332
450
  instruct_cfg = self.config.get_model_config("instruct")
333
- input_price = instruct_cfg.get("input_price") or 0
334
- output_price = instruct_cfg.get("output_price") or 0
451
+ input_price = instruct_cfg.input_price or 0
452
+ output_price = instruct_cfg.output_price or 0
335
453
  cost = (usage.get("input_tokens", 0) * input_price + usage.get("output_tokens", 0) * output_price) / 1_000_000
336
454
 
337
455
  stages.append({
@@ -350,8 +468,8 @@ class ModularPipeline:
350
468
  s = trace["summary"]
351
469
  usage = s.get("usage", {})
352
470
  main_cfg = self.config.get_model_config("main")
353
- input_price = main_cfg.get("input_price") or 0
354
- output_price = main_cfg.get("output_price") or 0
471
+ input_price = main_cfg.input_price or 0
472
+ output_price = main_cfg.output_price or 0
355
473
  cost = (usage.get("input_tokens", 0) * input_price + usage.get("output_tokens", 0) * output_price) / 1_000_000
356
474
 
357
475
  stages.append({
@@ -5,11 +5,11 @@ import time
5
5
  from typing import List, Dict, Any, Optional
6
6
  from loguru import logger
7
7
 
8
- from .browser.service import get_screenshot_service
9
- # New engines
10
- from .browser.engines.bing import BingEngine
11
- from .browser.engines.duckduckgo import DuckDuckGoEngine
12
- from .browser.engines.google import GoogleEngine
8
+ from .browser_control.service import get_screenshot_service
9
+ # Search engines from browser_control subpackage
10
+ from .browser_control.engines.duckduckgo import DuckDuckGoEngine
11
+ from .browser_control.engines.google import GoogleEngine
12
+ from .browser_control.engines.default import DefaultEngine
13
13
 
14
14
  class SearchService:
15
15
  def __init__(self, config: Any):
@@ -21,17 +21,19 @@ class SearchService:
21
21
  # Domain blocking
22
22
  self._blocked_domains = getattr(config, "blocked_domains", []) or []
23
23
 
24
- # Select Engine
25
- self._engine_name = getattr(config, "search_engine", "bing").lower()
26
- if self._engine_name == "bing":
27
- self._engine = BingEngine()
28
- elif self._engine_name == "google":
24
+ # Select Engine - DefaultEngine when not specified
25
+ self._engine_name = getattr(config, "search_engine", None)
26
+ if self._engine_name:
27
+ self._engine_name = self._engine_name.lower()
28
+
29
+ if self._engine_name == "google":
29
30
  self._engine = GoogleEngine()
30
- elif self._engine_name == "duckduckgo":
31
- self._engine = DuckDuckGoEngine()
31
+ elif self._engine_name == "default_address_bar": # Explicitly requested address bar capability if needed
32
+ self._engine = DefaultEngine()
32
33
  else:
33
- # Default fallback
34
- self._engine = BingEngine()
34
+ # Default: use DuckDuckGo
35
+ self._engine = DuckDuckGoEngine()
36
+ self._engine_name = "duckduckgo"
35
37
 
36
38
  logger.info(f"SearchService initialized with engine: {self._engine_name}")
37
39
 
@@ -39,7 +41,8 @@ class SearchService:
39
41
  return self._engine.build_url(query, self._default_limit)
40
42
 
41
43
  async def search_batch(self, queries: List[str]) -> List[List[Dict[str, Any]]]:
42
- """Execute multiple searches concurrently."""
44
+ """Execute multiple searches concurrently using standard URL navigation."""
45
+ logger.info(f"SearchService: Batch searching {len(queries)} queries in parallel...")
43
46
  tasks = [self.search(q) for q in queries]
44
47
  return await asyncio.gather(*tasks)
45
48
 
@@ -58,17 +61,36 @@ class SearchService:
58
61
  final_query = f"{query} {exclusions}"
59
62
 
60
63
  url = self._build_search_url(final_query)
61
- logger.info(f"Search: '{query}' -> {url}")
62
-
64
+
63
65
  results = []
64
66
  try:
65
- # Fetch - Search parsing doesn't need screenshot, only HTML
66
- page_data = await self.fetch_page_raw(url, include_screenshot=False)
67
+ # Check if this is an address bar search (DefaultEngine)
68
+ if url.startswith("__ADDRESS_BAR_SEARCH__:"):
69
+ # Extract query from marker
70
+ search_query = url.replace("__ADDRESS_BAR_SEARCH__:", "")
71
+ logger.info(f"Search: '{query}' -> [Address Bar Search]")
72
+
73
+ # Use address bar input method
74
+ service = get_screenshot_service(headless=self._headless)
75
+ page_data = await service.search_via_address_bar(search_query)
76
+ else:
77
+ logger.info(f"Search: '{query}' -> {url}")
78
+ # Standard URL navigation
79
+ page_data = await self.fetch_page_raw(url, include_screenshot=False)
80
+
67
81
  content = page_data.get("html", "") or page_data.get("content", "")
82
+
83
+ # Debug: Log content length
84
+ logger.debug(f"Search: Raw content length = {len(content)} chars")
85
+ if len(content) < 500:
86
+ logger.warning(f"Search: Content too short, may be empty/blocked. First 500 chars: {content[:500]}")
68
87
 
69
88
  # Parse Results (skip raw page - only return parsed results)
70
89
  if content and not content.startswith("Error"):
71
90
  parsed = self._engine.parse(content)
91
+
92
+ # Debug: Log parse result
93
+ logger.info(f"Search: Engine {self._engine_name} parsed {len(parsed)} results from {len(content)} chars")
72
94
 
73
95
  # JAVASCRIPT IMAGE INJECTION
74
96
  # Inject base64 images from JS extraction if available
@@ -84,6 +106,17 @@ class SearchService:
84
106
  parsed[i]["images"].insert(0, b64_src)
85
107
 
86
108
  logger.info(f"Search parsed {len(parsed)} results for '{query}' using {self._engine_name}")
109
+
110
+ # ALWAYS add raw search page as hidden item for debug saving
111
+ # (even when 0 results, so we can debug the parser)
112
+ results.append({
113
+ "title": f"[DEBUG] Raw Search: {query}",
114
+ "url": url,
115
+ "content": content[:50000], # Limit to 50KB
116
+ "_type": "search_raw_page",
117
+ "_hidden": True, # Don't show to LLM
118
+ })
119
+
87
120
  results.extend(parsed)
88
121
  else:
89
122
  logger.warning(f"Search failed/empty for '{query}': {content[:100]}")
@@ -120,3 +153,11 @@ class SearchService:
120
153
  timeout = self._fetch_timeout
121
154
  service = get_screenshot_service(headless=self._headless)
122
155
  return await service.fetch_page(url, timeout=timeout, include_screenshot=include_screenshot)
156
+
157
+ async def screenshot_url(self, url: str, full_page: bool = True) -> Optional[str]:
158
+ """
159
+ Capture a screenshot of a URL.
160
+ Delegates to screenshot service.
161
+ """
162
+ service = get_screenshot_service(headless=self._headless)
163
+ return await service.screenshot_url(url, full_page=full_page)
@@ -0,0 +1,21 @@
1
+ """
2
+ hyw_core.stages - Pipeline Stages
3
+
4
+ This subpackage provides the pipeline stage implementations:
5
+ - BaseStage: Abstract base class for all stages
6
+ - StageContext: Shared context between stages
7
+ - StageResult: Stage execution result
8
+ - InstructStage: Initial task planning and search execution
9
+ - SummaryStage: Final response generation
10
+ """
11
+
12
+ from .base import BaseStage, StageContext, StageResult
13
+
14
+ from .summary import SummaryStage
15
+
16
+ __all__ = [
17
+ "BaseStage",
18
+ "StageContext",
19
+ "StageResult",
20
+ "SummaryStage",
21
+ ]
@@ -39,6 +39,9 @@ class StageContext:
39
39
  # Model capabilities
40
40
  image_input_supported: bool = True
41
41
 
42
+ # Search timing
43
+ search_time: float = 0.0
44
+
42
45
  def next_id(self) -> int:
43
46
  """Get next global ID."""
44
47
  self.global_id_counter += 1
@@ -12,8 +12,8 @@ from typing import Any, Dict, List, Optional
12
12
  from loguru import logger
13
13
  from openai import AsyncOpenAI
14
14
 
15
- from .stage_base import BaseStage, StageContext, StageResult
16
- from .definitions import SUMMARY_REPORT_SP
15
+ from .base import BaseStage, StageContext, StageResult
16
+ from ..definitions import SUMMARY_REPORT_SP, get_refuse_answer_tool
17
17
 
18
18
 
19
19
  class SummaryStage(BaseStage):
@@ -35,6 +35,9 @@ class SummaryStage(BaseStage):
35
35
 
36
36
  # Format context from web results
37
37
  web_content = self._format_web_content(context)
38
+
39
+ # Tools
40
+ refuse_tool = get_refuse_answer_tool()
38
41
  full_context = f"{context.agent_context}\n\n{web_content}"
39
42
 
40
43
  # Select prompt
@@ -47,10 +50,13 @@ class SummaryStage(BaseStage):
47
50
  # Build Context Message
48
51
  context_message = f"## Web Search & Page Content\n\n```context\n{full_context}\n```"
49
52
 
53
+
50
54
  # Build user content
51
55
  user_text = context.user_input or "..."
52
56
  if images:
53
- user_content: List[Dict[str, Any]] = [{"type": "text", "text": user_text}]
57
+ # Add image context message for multimodal input
58
+ image_context = f"[System: The user has provided {len(images)} image(s). Please analyze these images together with the text query to provide a comprehensive response.]"
59
+ user_content: List[Dict[str, Any]] = [{"type": "text", "text": f"{image_context}\n\n{user_text}"}]
54
60
  for img_b64 in images:
55
61
  url = f"data:image/jpeg;base64,{img_b64}" if not img_b64.startswith("data:") else img_b64
56
62
  user_content.append({"type": "image_url", "image_url": {"url": url}})
@@ -67,18 +73,21 @@ class SummaryStage(BaseStage):
67
73
  model_cfg = self.config.get_model_config("main")
68
74
 
69
75
  client = self._client_for(
70
- api_key=model_cfg.get("api_key"),
71
- base_url=model_cfg.get("base_url")
76
+ api_key=model_cfg.api_key,
77
+ base_url=model_cfg.base_url
72
78
  )
73
79
 
74
- model = model_cfg.get("model_name") or self.config.model_name
80
+ model = model_cfg.model_name or self.config.model_name
75
81
 
76
82
  try:
77
83
  response = await client.chat.completions.create(
78
84
  model=model,
79
85
  messages=messages,
80
86
  temperature=self.config.temperature,
87
+
81
88
  extra_body=getattr(self.config, "summary_extra_body", None),
89
+ tools=[refuse_tool],
90
+ tool_choice="auto",
82
91
  )
83
92
  except Exception as e:
84
93
  logger.error(f"SummaryStage LLM error: {e}")
@@ -93,6 +102,25 @@ class SummaryStage(BaseStage):
93
102
  usage["input_tokens"] = getattr(response.usage, "prompt_tokens", 0) or 0
94
103
  usage["output_tokens"] = getattr(response.usage, "completion_tokens", 0) or 0
95
104
 
105
+ # Handle Tool Calls (Refusal)
106
+ tool_calls = response.choices[0].message.tool_calls
107
+ if tool_calls:
108
+ for tc in tool_calls:
109
+ if tc.function.name == "refuse_answer":
110
+ import json
111
+ try:
112
+ args = json.loads(tc.function.arguments)
113
+ reason = args.get("reason", "Refused")
114
+ context.should_refuse = True
115
+ context.refuse_reason = reason
116
+ return StageResult(
117
+ success=True,
118
+ data={"content": f"Refused: {reason}"},
119
+ usage=usage,
120
+ trace={"skipped": True, "reason": reason}
121
+ )
122
+ except: pass
123
+
96
124
  content = (response.choices[0].message.content or "").strip()
97
125
 
98
126
  return StageResult(
@@ -101,9 +129,10 @@ class SummaryStage(BaseStage):
101
129
  usage=usage,
102
130
  trace={
103
131
  "model": model,
104
- "provider": model_cfg.get("model_provider") or "Unknown",
132
+ "provider": model_cfg.model_provider or "Unknown",
105
133
  "usage": usage,
106
134
  "system_prompt": system_prompt,
135
+ "context_message": context_message, # Includes vision description + search results
107
136
  "output": content,
108
137
  "time": time.time() - start_time,
109
138
  "images_count": len(images) if images else 0,