entari-plugin-hyw 4.0.0rc4__py3-none-any.whl → 4.0.0rc6__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/__init__.py +216 -75
- entari_plugin_hyw/assets/card-dist/index.html +70 -79
- entari_plugin_hyw/browser/__init__.py +10 -0
- entari_plugin_hyw/browser/engines/base.py +13 -0
- entari_plugin_hyw/browser/engines/bing.py +95 -0
- entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
- entari_plugin_hyw/browser/engines/google.py +155 -0
- entari_plugin_hyw/browser/landing.html +172 -0
- entari_plugin_hyw/browser/manager.py +153 -0
- entari_plugin_hyw/browser/service.py +304 -0
- entari_plugin_hyw/card-ui/src/App.vue +526 -182
- entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
- entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
- entari_plugin_hyw/card-ui/src/types.ts +9 -0
- entari_plugin_hyw/definitions.py +155 -0
- entari_plugin_hyw/history.py +111 -33
- entari_plugin_hyw/misc.py +34 -0
- entari_plugin_hyw/modular_pipeline.py +384 -0
- entari_plugin_hyw/render_vue.py +326 -239
- entari_plugin_hyw/search.py +95 -708
- entari_plugin_hyw/stage_base.py +92 -0
- entari_plugin_hyw/stage_instruct.py +345 -0
- entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
- entari_plugin_hyw/stage_summary.py +164 -0
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
- entari_plugin_hyw/pipeline.py +0 -1219
- entari_plugin_hyw/prompts.py +0 -47
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
- {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modular Pipeline Dispatcher
|
|
3
|
+
|
|
4
|
+
New pipeline architecture: Instruct Loop (x2) -> Summary.
|
|
5
|
+
Simpler flow with self-correction/feedback loop.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional, Callable, Awaitable
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from openai import AsyncOpenAI
|
|
14
|
+
|
|
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
|
|
19
|
+
from .search import SearchService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModularPipeline:
|
|
23
|
+
"""
|
|
24
|
+
Modular Pipeline.
|
|
25
|
+
|
|
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.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: Any, send_func: Optional[Callable[[str], Awaitable[None]]] = None):
|
|
33
|
+
self.config = config
|
|
34
|
+
self.send_func = send_func
|
|
35
|
+
self.search_service = SearchService(config)
|
|
36
|
+
self.client = AsyncOpenAI(base_url=config.base_url, api_key=config.api_key)
|
|
37
|
+
|
|
38
|
+
# 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
|
+
self.summary_stage = SummaryStage(config, self.search_service, self.client)
|
|
42
|
+
|
|
43
|
+
async def execute(
|
|
44
|
+
self,
|
|
45
|
+
user_input: str,
|
|
46
|
+
conversation_history: List[Dict],
|
|
47
|
+
model_name: str = None,
|
|
48
|
+
images: List[str] = None,
|
|
49
|
+
vision_model_name: str = None,
|
|
50
|
+
selected_vision_model: str = None,
|
|
51
|
+
) -> Dict[str, Any]:
|
|
52
|
+
"""Execute the modular pipeline."""
|
|
53
|
+
start_time = time.time()
|
|
54
|
+
stats = {"start_time": start_time}
|
|
55
|
+
usage_totals = {"input_tokens": 0, "output_tokens": 0}
|
|
56
|
+
active_model = model_name or self.config.model_name
|
|
57
|
+
|
|
58
|
+
context = StageContext(
|
|
59
|
+
user_input=user_input,
|
|
60
|
+
images=images or [],
|
|
61
|
+
conversation_history=conversation_history,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Determine if model supports image input
|
|
65
|
+
model_cfg_dict = next((m for m in self.config.models if m.get("name") == active_model), None)
|
|
66
|
+
if model_cfg_dict:
|
|
67
|
+
context.image_input_supported = model_cfg_dict.get("image_input", True)
|
|
68
|
+
else:
|
|
69
|
+
context.image_input_supported = True # Default to True if unknown
|
|
70
|
+
|
|
71
|
+
logger.info(f"Pipeline Execution: Model '{active_model}' Image Input Supported: {context.image_input_supported}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
trace: Dict[str, Any] = {
|
|
75
|
+
"instruct_rounds": [],
|
|
76
|
+
"summary": None,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
logger.info(f"Pipeline: Processing '{user_input[:30]}...'")
|
|
81
|
+
|
|
82
|
+
# === Stage 1: Instruct (Initial Discovery) ===
|
|
83
|
+
logger.info("Pipeline: Stage 1 - Instruct")
|
|
84
|
+
instruct_result = await self.instruct_stage.execute(context)
|
|
85
|
+
|
|
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)
|
|
91
|
+
|
|
92
|
+
# Check refuse
|
|
93
|
+
if context.should_refuse:
|
|
94
|
+
return self._build_refusal_response(context, conversation_history, active_model, stats)
|
|
95
|
+
|
|
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
|
|
115
|
+
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
|
+
)
|
|
132
|
+
trace["summary"] = summary_result.trace
|
|
133
|
+
usage_totals["input_tokens"] += summary_result.usage.get("input_tokens", 0)
|
|
134
|
+
usage_totals["output_tokens"] += summary_result.usage.get("output_tokens", 0)
|
|
135
|
+
|
|
136
|
+
summary_content = summary_result.data.get("content", "")
|
|
137
|
+
|
|
138
|
+
# === Result Assembly ===
|
|
139
|
+
stats["total_time"] = time.time() - start_time
|
|
140
|
+
structured = self._parse_response(summary_content, context)
|
|
141
|
+
|
|
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
|
|
158
|
+
for ref in structured.get("references", []):
|
|
159
|
+
if ref.get("images"):
|
|
160
|
+
# Keep cached images, but preserve original URLs as fallback
|
|
161
|
+
new_images = []
|
|
162
|
+
for img in ref["images"]:
|
|
163
|
+
# 1. Already Base64 (from Search Injection) -> Keep it
|
|
164
|
+
if img.startswith("data:"):
|
|
165
|
+
new_images.append(img)
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# 2. Cached successfully -> Keep it
|
|
169
|
+
cached_val = cached_map.get(img)
|
|
170
|
+
if cached_val and cached_val.startswith("data:"):
|
|
171
|
+
new_images.append(cached_val)
|
|
172
|
+
# 3. Else -> DROP IT (User request: "Delete Fallback, must download in advance")
|
|
173
|
+
ref["images"] = new_images
|
|
174
|
+
except Exception as e:
|
|
175
|
+
logger.warning(f"Pipeline: Image caching failed: {e}")
|
|
176
|
+
|
|
177
|
+
# Debug: Log image counts
|
|
178
|
+
total_ref_images = sum(len(ref.get("images", []) or []) for ref in structured.get("references", []))
|
|
179
|
+
logger.info(f"Pipeline: Final structured response has {len(structured.get('references', []))} refs with {total_ref_images} images total")
|
|
180
|
+
|
|
181
|
+
stages_used = self._build_stages_ui(trace, context, images)
|
|
182
|
+
|
|
183
|
+
conversation_history.append({"role": "user", "content": user_input})
|
|
184
|
+
conversation_history.append({"role": "assistant", "content": summary_content})
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"llm_response": summary_content,
|
|
188
|
+
"structured_response": structured,
|
|
189
|
+
"stats": stats,
|
|
190
|
+
"model_used": active_model,
|
|
191
|
+
"conversation_history": conversation_history,
|
|
192
|
+
"trace_markdown": self._render_trace_markdown(trace),
|
|
193
|
+
"billing_info": {
|
|
194
|
+
"input_tokens": usage_totals["input_tokens"],
|
|
195
|
+
"output_tokens": usage_totals["output_tokens"],
|
|
196
|
+
"total_cost": 0.0
|
|
197
|
+
},
|
|
198
|
+
"stages_used": stages_used,
|
|
199
|
+
"web_results": context.web_results,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
logger.error(f"Pipeline: Critical Error - {e}")
|
|
204
|
+
import traceback
|
|
205
|
+
logger.error(traceback.format_exc())
|
|
206
|
+
return {
|
|
207
|
+
"llm_response": f"Error: {e}",
|
|
208
|
+
"stats": stats,
|
|
209
|
+
"error": str(e)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
def _build_refusal_response(self, context, history, model, stats):
|
|
213
|
+
return {
|
|
214
|
+
"llm_response": "Refused",
|
|
215
|
+
"structured_response": {},
|
|
216
|
+
"stats": stats,
|
|
217
|
+
"model_used": model,
|
|
218
|
+
"conversation_history": history,
|
|
219
|
+
"refuse_answer": True,
|
|
220
|
+
"refuse_reason": context.refuse_reason
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def _parse_response(self, text: str, context: StageContext) -> Dict[str, Any]:
|
|
224
|
+
"""Parse response and extract citations, prioritizing fetched items."""
|
|
225
|
+
import re
|
|
226
|
+
parsed = {"response": "", "references": [], "page_references": [], "image_references": []}
|
|
227
|
+
if not text: return parsed
|
|
228
|
+
|
|
229
|
+
# Simple cleanup
|
|
230
|
+
ref_pattern = re.compile(r'(?:\n\s*|^)\s*(?:#{1,3}|\*\*)\s*(?:References|Citations|Sources|参考资料)[\s\S]*$', re.IGNORECASE | re.MULTILINE)
|
|
231
|
+
body_text = ref_pattern.sub('', text)
|
|
232
|
+
|
|
233
|
+
# 1. Identify all cited numeric IDs from [N]
|
|
234
|
+
cited_ids = []
|
|
235
|
+
for m in re.finditer(r'\[(\d+)\]', body_text):
|
|
236
|
+
try:
|
|
237
|
+
cid = int(m.group(1))
|
|
238
|
+
if cid not in cited_ids: cited_ids.append(cid)
|
|
239
|
+
except: pass
|
|
240
|
+
|
|
241
|
+
# 2. Collect cited items and determine "is_fetched" status
|
|
242
|
+
cited_items = []
|
|
243
|
+
for cid in cited_ids:
|
|
244
|
+
item = next((r for r in context.web_results if r.get("_id") == cid), None)
|
|
245
|
+
if not item: continue
|
|
246
|
+
|
|
247
|
+
# Check if this URL was fetched (appears as a "page" result)
|
|
248
|
+
is_fetched = any(r.get("_type") == "page" and r.get("url") == item.get("url") for r in context.web_results)
|
|
249
|
+
cited_items.append({
|
|
250
|
+
"original_id": cid,
|
|
251
|
+
"item": item,
|
|
252
|
+
"is_fetched": is_fetched
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
# 3. Sort: Fetched pages first, then regular search results
|
|
256
|
+
cited_items.sort(key=lambda x: x["is_fetched"], reverse=True)
|
|
257
|
+
|
|
258
|
+
# 4. Create Re-indexing Map
|
|
259
|
+
reindex_map = {}
|
|
260
|
+
for i, entry in enumerate(cited_items):
|
|
261
|
+
reindex_map[entry["original_id"]] = i + 1
|
|
262
|
+
|
|
263
|
+
# Populate result references in sorted order
|
|
264
|
+
item = entry["item"]
|
|
265
|
+
ref_entry = {
|
|
266
|
+
"title": item.get("title", ""),
|
|
267
|
+
"url": item.get("url", ""),
|
|
268
|
+
"domain": item.get("domain", ""),
|
|
269
|
+
"snippet": (item.get("content", "") or "")[:200] + "...", # More snippet
|
|
270
|
+
"is_fetched": entry["is_fetched"],
|
|
271
|
+
"type": item.get("_type", "search"),
|
|
272
|
+
"raw_screenshot_b64": item.get("raw_screenshot_b64"), # Real page screenshot for Sources
|
|
273
|
+
"images": item.get("images"),
|
|
274
|
+
}
|
|
275
|
+
# Add to unified list (frontend can handle splitting if needed, but we provide sorted order)
|
|
276
|
+
parsed["references"].append(ref_entry)
|
|
277
|
+
|
|
278
|
+
# 5. Replace [N] in text with new indices
|
|
279
|
+
def repl(m):
|
|
280
|
+
try:
|
|
281
|
+
oid = int(m.group(1))
|
|
282
|
+
return f"[{reindex_map[oid]}]" if oid in reindex_map else m.group(0)
|
|
283
|
+
except: return m.group(0)
|
|
284
|
+
|
|
285
|
+
parsed["response"] = re.sub(r'\[(\d+)\]', repl, body_text).strip()
|
|
286
|
+
return parsed
|
|
287
|
+
|
|
288
|
+
def _build_stages_ui(self, trace: Dict[str, Any], context: StageContext, images: List[str]) -> List[Dict[str, Any]]:
|
|
289
|
+
stages = []
|
|
290
|
+
|
|
291
|
+
# 1. Search Results
|
|
292
|
+
search_refs = []
|
|
293
|
+
seen = set()
|
|
294
|
+
for r in context.web_results:
|
|
295
|
+
if r.get("_type") == "search" and r.get("url") not in seen:
|
|
296
|
+
seen.add(r["url"])
|
|
297
|
+
is_fetched = any(p.get("url") == r["url"] for p in context.web_results if p.get("_type") == "page")
|
|
298
|
+
search_refs.append({
|
|
299
|
+
"title": r.get("title", ""),
|
|
300
|
+
"url": r["url"],
|
|
301
|
+
"snippet": (r.get("content", "") or "")[:100] + "...",
|
|
302
|
+
"is_fetched": is_fetched
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
# Sort: Fetched first
|
|
306
|
+
search_refs.sort(key=lambda x: x["is_fetched"], reverse=True)
|
|
307
|
+
|
|
308
|
+
if search_refs:
|
|
309
|
+
stages.append({
|
|
310
|
+
"name": "Search",
|
|
311
|
+
"model": "Web Search",
|
|
312
|
+
"icon_config": "openai",
|
|
313
|
+
"provider": "Web",
|
|
314
|
+
"references": search_refs,
|
|
315
|
+
"description": f"Found {len(search_refs)} results."
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
# 2. Instruct Rounds
|
|
319
|
+
for i, t in enumerate(trace.get("instruct_rounds", [])):
|
|
320
|
+
stage_name = t.get("stage_name", f"Analysis {i+1}")
|
|
321
|
+
tool_count = t.get("tool_calls", 0)
|
|
322
|
+
desc = t.get("output", "")
|
|
323
|
+
|
|
324
|
+
if tool_count > 0:
|
|
325
|
+
# If tools were used, prefer showing tool info even if there's reasoning
|
|
326
|
+
desc = f"Executed {tool_count} tool calls."
|
|
327
|
+
elif not desc:
|
|
328
|
+
desc = "Processing..."
|
|
329
|
+
|
|
330
|
+
# Calculate cost from config prices
|
|
331
|
+
usage = t.get("usage", {})
|
|
332
|
+
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
|
|
335
|
+
cost = (usage.get("input_tokens", 0) * input_price + usage.get("output_tokens", 0) * output_price) / 1_000_000
|
|
336
|
+
|
|
337
|
+
stages.append({
|
|
338
|
+
"name": stage_name,
|
|
339
|
+
"model": t.get("model"),
|
|
340
|
+
"icon_config": "google",
|
|
341
|
+
"provider": "Instruct",
|
|
342
|
+
"time": t.get("time", 0),
|
|
343
|
+
"description": desc,
|
|
344
|
+
"usage": usage,
|
|
345
|
+
"cost": cost
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
# 3. Summary
|
|
349
|
+
if trace.get("summary"):
|
|
350
|
+
s = trace["summary"]
|
|
351
|
+
usage = s.get("usage", {})
|
|
352
|
+
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
|
|
355
|
+
cost = (usage.get("input_tokens", 0) * input_price + usage.get("output_tokens", 0) * output_price) / 1_000_000
|
|
356
|
+
|
|
357
|
+
stages.append({
|
|
358
|
+
"name": "Summary",
|
|
359
|
+
"model": s.get("model"),
|
|
360
|
+
"icon_config": "google",
|
|
361
|
+
"provider": "Summary",
|
|
362
|
+
"time": s.get("time", 0),
|
|
363
|
+
"description": "Generated final answer.",
|
|
364
|
+
"usage": usage,
|
|
365
|
+
"cost": cost
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
return stages
|
|
369
|
+
|
|
370
|
+
def _render_trace_markdown(self, trace: Dict[str, Any]) -> str:
|
|
371
|
+
parts = ["# Pipeline Trace\n"]
|
|
372
|
+
if trace.get("instruct_rounds"):
|
|
373
|
+
parts.append(f"## Instruct ({len(trace['instruct_rounds'])} rounds)\n")
|
|
374
|
+
for i, r in enumerate(trace["instruct_rounds"]):
|
|
375
|
+
name = r.get("stage_name", f"Round {i+1}")
|
|
376
|
+
parts.append(f"### {name}\n" + str(r))
|
|
377
|
+
if trace.get("summary"):
|
|
378
|
+
parts.append("## Summary\n" + str(trace["summary"]))
|
|
379
|
+
return "\n".join(parts)
|
|
380
|
+
|
|
381
|
+
async def close(self):
|
|
382
|
+
try:
|
|
383
|
+
await self.search_service.close()
|
|
384
|
+
except: pass
|