|
1 | | -from __future__ import annotations |
2 | | -import time |
3 | | -import logging |
4 | | -from typing import Optional, List |
5 | | - |
6 | | -logger = logging.getLogger(__name__) |
7 | | - |
8 | | -class TimeEstimator: |
9 | | - """ |
10 | | - Provides static ETA estimations based on heuristics. |
11 | | - In a production system, this could query a database of historical execution times. |
12 | | - """ |
13 | | - |
14 | | - WORKFLOW_AVERAGES = { |
15 | | - "amazon_bsr": 30.0, # Baseline 30 seconds |
16 | | - # Add others as needed |
17 | | - } |
18 | | - |
19 | | - AGENT_BASELINE_PER_ITERATION = 6.0 # Approx 12s per ReAct loop (LLM + Tool) |
20 | | - |
21 | | - @classmethod |
22 | | - def estimate_workflow(cls, workflow_name: str, params: dict = None) -> str: |
23 | | - base_time = cls.WORKFLOW_AVERAGES.get(workflow_name, 30.0) |
24 | | - # E.g., if we know pagination params, we could multiply base_time |
25 | | - return f"~{int(base_time)}秒" |
26 | | - |
27 | | - @classmethod |
28 | | - def estimate_agent(cls, max_iterations: int = 5) -> str: |
29 | | - # Agent is highly variable, give a conservative range |
30 | | - min_time = int(cls.AGENT_BASELINE_PER_ITERATION * 1) |
31 | | - max_time = int(cls.AGENT_BASELINE_PER_ITERATION * (max_iterations * 0.6)) # Assume it rarely hits max |
32 | | - return f"{min_time}~{max_time}秒" |
33 | | - |
34 | | - |
35 | | -class TelemetryTracker: |
36 | | - """ |
37 | | - Tracks dynamic progress and calculates moving averages for remaining ETA. |
38 | | - """ |
39 | | - def __init__(self, total_steps: int): |
40 | | - self.total_steps = total_steps |
41 | | - self.current_step = 0 |
42 | | - self.start_time = time.monotonic() |
43 | | - self.step_times: List[float] = [] |
44 | | - self.last_step_time = self.start_time |
45 | | - |
46 | | - def record_step(self): |
47 | | - """Record the completion of a step and track its duration.""" |
48 | | - now = time.monotonic() |
49 | | - duration = now - self.last_step_time |
50 | | - self.step_times.append(duration) |
51 | | - self.last_step_time = now |
52 | | - self.current_step += 1 |
53 | | - |
54 | | - def get_dynamic_eta(self) -> Optional[int]: |
55 | | - """Returns the estimated remaining time in seconds.""" |
56 | | - if self.current_step == 0 or self.current_step >= self.total_steps: |
57 | | - return None |
58 | | - |
59 | | - # Simple Average for remaining steps |
60 | | - avg_time_per_step = sum(self.step_times) / len(self.step_times) |
61 | | - remaining_steps = self.total_steps - self.current_step |
62 | | - return int(avg_time_per_step * remaining_steps) |
| 1 | +from __future__ import annotations |
| 2 | +import json |
| 3 | +import logging |
| 4 | +import os |
| 5 | +import time |
| 6 | +from typing import Dict, List, Optional |
| 7 | + |
| 8 | +logger = logging.getLogger(__name__) |
| 9 | + |
| 10 | +# Per-step history stored here; keyed by step_name, value = list of durations (s) |
| 11 | +_HISTORY_PATH = os.path.join(os.path.dirname(__file__), "step_history.json") |
| 12 | +_HISTORY_MAX_SAMPLES = 20 # rolling window per step |
| 13 | + |
| 14 | + |
| 15 | +def _load_history() -> Dict[str, List[float]]: |
| 16 | + try: |
| 17 | + with open(_HISTORY_PATH, encoding="utf-8") as f: |
| 18 | + return json.load(f) |
| 19 | + except Exception: |
| 20 | + return {} |
| 21 | + |
| 22 | + |
| 23 | +def _save_history(history: Dict[str, List[float]]) -> None: |
| 24 | + try: |
| 25 | + with open(_HISTORY_PATH, "w", encoding="utf-8") as f: |
| 26 | + json.dump(history, f) |
| 27 | + except Exception as e: |
| 28 | + logger.debug(f"Could not save step history: {e}") |
| 29 | + |
| 30 | + |
| 31 | +class TimeEstimator: |
| 32 | + """ |
| 33 | + Static ETA estimations based on heuristics (kept for backward compat). |
| 34 | + """ |
| 35 | + |
| 36 | + WORKFLOW_AVERAGES = { |
| 37 | + "amazon_bsr": 30.0, |
| 38 | + } |
| 39 | + AGENT_BASELINE_PER_ITERATION = 6.0 |
| 40 | + |
| 41 | + @classmethod |
| 42 | + def estimate_workflow(cls, workflow_name: str, params: dict = None) -> str: |
| 43 | + base_time = cls.WORKFLOW_AVERAGES.get(workflow_name, 30.0) |
| 44 | + return f"~{int(base_time)}秒" |
| 45 | + |
| 46 | + @classmethod |
| 47 | + def estimate_agent(cls, max_iterations: int = 5) -> str: |
| 48 | + min_time = int(cls.AGENT_BASELINE_PER_ITERATION * 1) |
| 49 | + max_time = int(cls.AGENT_BASELINE_PER_ITERATION * (max_iterations * 0.6)) |
| 50 | + return f"{min_time}~{max_time}秒" |
| 51 | + |
| 52 | + |
| 53 | +class TelemetryTracker: |
| 54 | + """ |
| 55 | + Tracks dynamic progress and calculates ETA using two complementary methods: |
| 56 | +
|
| 57 | + 1. Elapsed-ratio (always available, no history needed): |
| 58 | + eta = elapsed_total × remaining_steps / completed_steps |
| 59 | + Same algorithm used by curl, wget, and GitHub Actions progress bars. |
| 60 | + Robust because it doesn't assume equal step durations — it uses actual |
| 61 | + wall time. |
| 62 | +
|
| 63 | + 2. Per-step historical baseline (learned from past runs): |
| 64 | + eta = Σ historical_avg(step_i) for remaining steps |
| 65 | + Stored in step_history.json. When N ≥ 3 samples exist for a step, |
| 66 | + the estimate is blended: 40 % elapsed-ratio + 60 % historical. |
| 67 | +
|
| 68 | + Confidence tiers shown in the UI: |
| 69 | + 🔴 < 2 completed steps — wild guess, elapsed-ratio only |
| 70 | + 🟡 2–4 completed steps — improving estimate |
| 71 | + 🟢 history N ≥ 3 per step — data-backed estimate |
| 72 | + """ |
| 73 | + |
| 74 | + def __init__(self, total_steps: int, workflow_name: str = ""): |
| 75 | + self.total_steps = total_steps |
| 76 | + self.workflow_name = workflow_name |
| 77 | + self.current_step = 0 |
| 78 | + self.start_time = time.monotonic() |
| 79 | + self.step_times: List[float] = [] |
| 80 | + self.step_names: List[str] = [] |
| 81 | + self.last_step_time = self.start_time |
| 82 | + self._history: Dict[str, List[float]] = _load_history() |
| 83 | + |
| 84 | + def record_step(self, step_name: str = "") -> None: |
| 85 | + now = time.monotonic() |
| 86 | + duration = now - self.last_step_time |
| 87 | + self.step_times.append(duration) |
| 88 | + self.step_names.append(step_name) |
| 89 | + self.last_step_time = now |
| 90 | + self.current_step += 1 |
| 91 | + |
| 92 | + # Persist to rolling history |
| 93 | + if step_name: |
| 94 | + key = f"{self.workflow_name}:{step_name}" if self.workflow_name else step_name |
| 95 | + samples = self._history.setdefault(key, []) |
| 96 | + samples.append(duration) |
| 97 | + if len(samples) > _HISTORY_MAX_SAMPLES: |
| 98 | + samples.pop(0) |
| 99 | + _save_history(self._history) |
| 100 | + |
| 101 | + # ── ETA methods ────────────────────────────────────────────────────── |
| 102 | + |
| 103 | + def _elapsed_ratio_eta(self) -> Optional[float]: |
| 104 | + """eta = elapsed × remaining / completed (download-bar method).""" |
| 105 | + if self.current_step == 0: |
| 106 | + return None |
| 107 | + elapsed = time.monotonic() - self.start_time |
| 108 | + remaining = self.total_steps - self.current_step |
| 109 | + return elapsed * remaining / self.current_step |
| 110 | + |
| 111 | + def _historical_eta(self, remaining_step_names: List[str]) -> Optional[float]: |
| 112 | + """Sum of historical averages for the remaining steps.""" |
| 113 | + if not remaining_step_names: |
| 114 | + return None |
| 115 | + total = 0.0 |
| 116 | + covered = 0 |
| 117 | + for name in remaining_step_names: |
| 118 | + key = f"{self.workflow_name}:{name}" if self.workflow_name else name |
| 119 | + samples = self._history.get(key, []) |
| 120 | + if len(samples) >= 3: |
| 121 | + total += sum(samples) / len(samples) |
| 122 | + covered += 1 |
| 123 | + if covered == 0: |
| 124 | + return None |
| 125 | + # Scale up to cover steps with no history (proportional) |
| 126 | + total *= len(remaining_step_names) / covered |
| 127 | + return total |
| 128 | + |
| 129 | + def _min_history_samples(self, remaining_step_names: List[str]) -> int: |
| 130 | + """Minimum number of samples across remaining steps (confidence proxy).""" |
| 131 | + mins = [] |
| 132 | + for name in remaining_step_names: |
| 133 | + key = f"{self.workflow_name}:{name}" if self.workflow_name else name |
| 134 | + mins.append(len(self._history.get(key, []))) |
| 135 | + return min(mins) if mins else 0 |
| 136 | + |
| 137 | + def get_dynamic_eta( |
| 138 | + self, |
| 139 | + remaining_step_names: Optional[List[str]] = None, |
| 140 | + ) -> Optional[str]: |
| 141 | + """ |
| 142 | + Return a human-readable ETA string, or None when estimate is unavailable. |
| 143 | +
|
| 144 | + Parameters |
| 145 | + ---------- |
| 146 | + remaining_step_names: |
| 147 | + Optional list of future step names (enables historical blending). |
| 148 | + """ |
| 149 | + if self.current_step == 0 or self.current_step >= self.total_steps: |
| 150 | + return None |
| 151 | + |
| 152 | + er_eta = self._elapsed_ratio_eta() |
| 153 | + hist_eta = self._historical_eta(remaining_step_names or []) |
| 154 | + min_samples = self._min_history_samples(remaining_step_names or []) |
| 155 | + |
| 156 | + # Blend: weight historical more heavily when data is solid |
| 157 | + if hist_eta is not None and min_samples >= 3: |
| 158 | + eta = 0.4 * (er_eta or hist_eta) + 0.6 * hist_eta |
| 159 | + confidence = "🟢" |
| 160 | + elif er_eta is not None and self.current_step >= 2: |
| 161 | + eta = er_eta |
| 162 | + confidence = "🟡" |
| 163 | + elif er_eta is not None: |
| 164 | + eta = er_eta |
| 165 | + confidence = "🔴" |
| 166 | + else: |
| 167 | + return None |
| 168 | + |
| 169 | + secs = max(1, int(eta)) |
| 170 | + if secs >= 60: |
| 171 | + mins, s = divmod(secs, 60) |
| 172 | + eta_str = f"{mins}分{s:02d}秒" if s else f"{mins}分钟" |
| 173 | + else: |
| 174 | + eta_str = f"{secs}秒" |
| 175 | + |
| 176 | + return f"{confidence} 预计剩余 {eta_str}" |
0 commit comments