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.
- entari_plugin_hyw/Untitled-1 +1865 -0
- entari_plugin_hyw/__init__.py +733 -379
- entari_plugin_hyw/history.py +60 -57
- entari_plugin_hyw/misc.py +3 -0
- entari_plugin_hyw/search_cache.py +154 -0
- {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/METADATA +3 -1
- entari_plugin_hyw-4.0.0rc8.dist-info/RECORD +68 -0
- {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/WHEEL +1 -1
- {entari_plugin_hyw-4.0.0rc6.dist-info → entari_plugin_hyw-4.0.0rc8.dist-info}/top_level.txt +1 -0
- hyw_core/__init__.py +94 -0
- hyw_core/browser_control/__init__.py +65 -0
- hyw_core/browser_control/assets/card-dist/index.html +409 -0
- hyw_core/browser_control/assets/index.html +5691 -0
- hyw_core/browser_control/engines/__init__.py +17 -0
- hyw_core/browser_control/engines/default.py +166 -0
- {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/duckduckgo.py +42 -8
- {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/google.py +1 -1
- {entari_plugin_hyw/browser → hyw_core/browser_control}/manager.py +15 -8
- entari_plugin_hyw/render_vue.py → hyw_core/browser_control/renderer.py +29 -14
- hyw_core/browser_control/service.py +720 -0
- hyw_core/config.py +154 -0
- hyw_core/core.py +322 -0
- hyw_core/definitions.py +83 -0
- entari_plugin_hyw/modular_pipeline.py → hyw_core/pipeline.py +204 -86
- {entari_plugin_hyw → hyw_core}/search.py +60 -19
- hyw_core/stages/__init__.py +21 -0
- entari_plugin_hyw/stage_base.py → hyw_core/stages/base.py +3 -0
- entari_plugin_hyw/stage_summary.py → hyw_core/stages/summary.py +36 -7
- entari_plugin_hyw/assets/card-dist/index.html +0 -387
- entari_plugin_hyw/browser/__init__.py +0 -10
- entari_plugin_hyw/browser/engines/bing.py +0 -95
- entari_plugin_hyw/browser/service.py +0 -304
- entari_plugin_hyw/card-ui/.gitignore +0 -24
- entari_plugin_hyw/card-ui/README.md +0 -5
- entari_plugin_hyw/card-ui/index.html +0 -16
- entari_plugin_hyw/card-ui/package-lock.json +0 -2342
- entari_plugin_hyw/card-ui/package.json +0 -31
- entari_plugin_hyw/card-ui/public/logos/anthropic.svg +0 -1
- entari_plugin_hyw/card-ui/public/logos/cerebras.svg +0 -9
- entari_plugin_hyw/card-ui/public/logos/deepseek.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/gemini.svg +0 -1
- entari_plugin_hyw/card-ui/public/logos/google.svg +0 -1
- entari_plugin_hyw/card-ui/public/logos/grok.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/huggingface.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/microsoft.svg +0 -15
- entari_plugin_hyw/card-ui/public/logos/minimax.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/mistral.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/nvida.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/openai.svg +0 -1
- entari_plugin_hyw/card-ui/public/logos/openrouter.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/perplexity.svg +0 -24
- entari_plugin_hyw/card-ui/public/logos/qwen.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/xai.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/xiaomi.png +0 -0
- entari_plugin_hyw/card-ui/public/logos/zai.png +0 -0
- entari_plugin_hyw/card-ui/public/vite.svg +0 -1
- entari_plugin_hyw/card-ui/src/App.vue +0 -756
- entari_plugin_hyw/card-ui/src/assets/vue.svg +0 -1
- entari_plugin_hyw/card-ui/src/components/HelloWorld.vue +0 -41
- entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +0 -382
- entari_plugin_hyw/card-ui/src/components/SectionCard.vue +0 -41
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +0 -240
- entari_plugin_hyw/card-ui/src/main.ts +0 -5
- entari_plugin_hyw/card-ui/src/style.css +0 -29
- entari_plugin_hyw/card-ui/src/test_regex.js +0 -103
- entari_plugin_hyw/card-ui/src/types.ts +0 -61
- entari_plugin_hyw/card-ui/tsconfig.app.json +0 -16
- entari_plugin_hyw/card-ui/tsconfig.json +0 -7
- entari_plugin_hyw/card-ui/tsconfig.node.json +0 -26
- entari_plugin_hyw/card-ui/vite.config.ts +0 -16
- entari_plugin_hyw/definitions.py +0 -155
- entari_plugin_hyw/stage_instruct.py +0 -345
- entari_plugin_hyw/stage_instruct_deepsearch.py +0 -104
- entari_plugin_hyw-4.0.0rc6.dist-info/RECORD +0 -100
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/anthropic.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/cerebras.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/deepseek.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/gemini.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/google.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/grok.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/huggingface.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/microsoft.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/minimax.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/mistral.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/nvida.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openai.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/openrouter.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/perplexity.svg +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/qwen.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xai.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/xiaomi.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/logos/zai.png +0 -0
- {entari_plugin_hyw → hyw_core/browser_control}/assets/card-dist/vite.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/anthropic.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/cerebras.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/deepseek.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/gemini.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/google.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/grok.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/huggingface.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/microsoft.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/minimax.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/mistral.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/nvida.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openai.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/openrouter.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/perplexity.svg +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/qwen.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xai.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/xiaomi.png +0 -0
- {entari_plugin_hyw/assets/icon → hyw_core/browser_control/assets/logos}/zai.png +0 -0
- {entari_plugin_hyw/browser → hyw_core/browser_control}/engines/base.py +0 -0
- {entari_plugin_hyw/browser → hyw_core/browser_control}/landing.html +0 -0
- {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 .
|
|
16
|
-
from .
|
|
17
|
-
from .
|
|
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.
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
-
# ===
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
97
|
-
if
|
|
98
|
-
|
|
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:
|
|
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
|
-
# ===
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
273
|
+
# 1. Already Base64 -> Keep it
|
|
164
274
|
if img.startswith("data:"):
|
|
165
275
|
new_images.append(img)
|
|
166
276
|
continue
|
|
167
|
-
|
|
168
|
-
# 2.
|
|
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
|
-
|
|
282
|
+
total_replaced += 1
|
|
283
|
+
# 3. Else -> DROP IT (as per policy)
|
|
173
284
|
ref["images"] = new_images
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
334
|
-
output_price = instruct_cfg.
|
|
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.
|
|
354
|
-
output_price = main_cfg.
|
|
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 .
|
|
9
|
-
#
|
|
10
|
-
from .
|
|
11
|
-
from .
|
|
12
|
-
from .
|
|
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",
|
|
26
|
-
if self._engine_name
|
|
27
|
-
self.
|
|
28
|
-
|
|
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 == "
|
|
31
|
-
|
|
31
|
+
elif self._engine_name == "default_address_bar": # Explicitly requested address bar capability if needed
|
|
32
|
+
self._engine = DefaultEngine()
|
|
32
33
|
else:
|
|
33
|
-
# Default
|
|
34
|
-
self._engine =
|
|
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
|
-
|
|
62
|
-
|
|
64
|
+
|
|
63
65
|
results = []
|
|
64
66
|
try:
|
|
65
|
-
#
|
|
66
|
-
|
|
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
|
+
]
|
|
@@ -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 .
|
|
16
|
-
from
|
|
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
|
-
|
|
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.
|
|
71
|
-
base_url=model_cfg.
|
|
76
|
+
api_key=model_cfg.api_key,
|
|
77
|
+
base_url=model_cfg.base_url
|
|
72
78
|
)
|
|
73
79
|
|
|
74
|
-
model = model_cfg.
|
|
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.
|
|
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,
|