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.

Files changed (30) hide show
  1. entari_plugin_hyw/__init__.py +216 -75
  2. entari_plugin_hyw/assets/card-dist/index.html +70 -79
  3. entari_plugin_hyw/browser/__init__.py +10 -0
  4. entari_plugin_hyw/browser/engines/base.py +13 -0
  5. entari_plugin_hyw/browser/engines/bing.py +95 -0
  6. entari_plugin_hyw/browser/engines/duckduckgo.py +137 -0
  7. entari_plugin_hyw/browser/engines/google.py +155 -0
  8. entari_plugin_hyw/browser/landing.html +172 -0
  9. entari_plugin_hyw/browser/manager.py +153 -0
  10. entari_plugin_hyw/browser/service.py +304 -0
  11. entari_plugin_hyw/card-ui/src/App.vue +526 -182
  12. entari_plugin_hyw/card-ui/src/components/MarkdownContent.vue +7 -11
  13. entari_plugin_hyw/card-ui/src/components/StageCard.vue +33 -30
  14. entari_plugin_hyw/card-ui/src/types.ts +9 -0
  15. entari_plugin_hyw/definitions.py +155 -0
  16. entari_plugin_hyw/history.py +111 -33
  17. entari_plugin_hyw/misc.py +34 -0
  18. entari_plugin_hyw/modular_pipeline.py +384 -0
  19. entari_plugin_hyw/render_vue.py +326 -239
  20. entari_plugin_hyw/search.py +95 -708
  21. entari_plugin_hyw/stage_base.py +92 -0
  22. entari_plugin_hyw/stage_instruct.py +345 -0
  23. entari_plugin_hyw/stage_instruct_deepsearch.py +104 -0
  24. entari_plugin_hyw/stage_summary.py +164 -0
  25. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/METADATA +4 -4
  26. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/RECORD +28 -16
  27. entari_plugin_hyw/pipeline.py +0 -1219
  28. entari_plugin_hyw/prompts.py +0 -47
  29. {entari_plugin_hyw-4.0.0rc4.dist-info → entari_plugin_hyw-4.0.0rc6.dist-info}/WHEEL +0 -0
  30. {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