| """ |
| Progress Tracker for Speech Script Generation |
| """ |
|
|
| import asyncio |
| import json |
| import logging |
| import time |
| import threading |
| from typing import Dict, Any, Optional |
| from dataclasses import dataclass, asdict |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| @dataclass |
| class ProgressInfo: |
| """Progress information for speech script generation""" |
| task_id: str |
| project_id: str |
| total_slides: int |
| completed_slides: int |
| failed_slides: int |
| skipped_slides: int |
| current_slide: Optional[int] = None |
| current_slide_title: Optional[str] = None |
| status: str = "running" |
| message: str = "" |
| start_time: float = 0 |
| last_update: float = 0 |
| error_details: list = None |
| |
| def __post_init__(self): |
| if self.start_time == 0: |
| self.start_time = time.time() |
| if self.last_update == 0: |
| self.last_update = time.time() |
| if self.error_details is None: |
| self.error_details = [] |
|
|
| def to_persist_dict(self) -> Dict[str, Any]: |
| """Convert to dictionary suitable for persistence (no computed fields).""" |
| return asdict(self) |
| |
| def to_dict(self) -> Dict[str, Any]: |
| """Convert to dictionary for JSON serialization""" |
| data = asdict(self) |
| |
| data['processed_slides'] = self.processed_slides |
| data['progress_percentage'] = self.progress_percentage |
| data['elapsed_time'] = self.elapsed_time |
| return data |
|
|
| @classmethod |
| def from_dict(cls, data: Dict[str, Any]) -> "ProgressInfo": |
| """Create ProgressInfo from dict (ignores unknown/computed fields).""" |
| required_fields = [ |
| "task_id", |
| "project_id", |
| "total_slides", |
| "completed_slides", |
| "failed_slides", |
| "skipped_slides", |
| ] |
| missing = [k for k in required_fields if k not in data] |
| if missing: |
| raise ValueError(f"Missing progress fields: {missing}") |
|
|
| field_names = set(cls.__dataclass_fields__.keys()) |
| init_kwargs: Dict[str, Any] = {k: v for k, v in data.items() if k in field_names} |
| return cls(**init_kwargs) |
| |
| @property |
| def processed_slides(self) -> int: |
| """Get total processed slides including success/failure/skip.""" |
| processed = self.completed_slides + self.failed_slides + self.skipped_slides |
| if self.total_slides <= 0: |
| return 0 |
| return min(processed, self.total_slides) |
|
|
| @property |
| def progress_percentage(self) -> float: |
| """Calculate progress percentage""" |
| if self.total_slides == 0: |
| return 0 |
| return (self.processed_slides / self.total_slides) * 100 |
| |
| @property |
| def elapsed_time(self) -> float: |
| """Get elapsed time in seconds""" |
| return time.time() - self.start_time |
|
|
|
|
| class ProgressTracker: |
| """Progress tracker for speech script generation (supports Valkey for multi-worker deployments).""" |
|
|
| TASK_TTL = 86400 |
| KEY_PREFIX = "speech_script_progress:" |
| |
| def __init__(self): |
| self._progress_data: Dict[str, ProgressInfo] = {} |
| self._lock = threading.Lock() |
| self._cache_service = None |
|
|
| def _cache_key(self, task_id: str) -> str: |
| return f"{self.KEY_PREFIX}{task_id}" |
|
|
| async def _get_cache(self): |
| """Get cache service lazily.""" |
| if self._cache_service is None: |
| try: |
| from .cache_service import get_cache_service |
|
|
| self._cache_service = await get_cache_service() |
| except Exception as e: |
| logger.warning(f"Failed to get cache service: {e}") |
| self._cache_service = None |
| return self._cache_service |
|
|
| async def _save_progress_to_cache(self, progress: ProgressInfo) -> None: |
| cache = await self._get_cache() |
| if cache and cache.is_connected: |
| try: |
| await cache.set( |
| self._cache_key(progress.task_id), |
| json.dumps(progress.to_persist_dict(), ensure_ascii=False), |
| self.TASK_TTL, |
| ) |
| except Exception as e: |
| logger.warning(f"Failed to save progress to cache: {e}") |
|
|
| async def _get_progress_from_cache(self, task_id: str) -> Optional[ProgressInfo]: |
| cache = await self._get_cache() |
| if cache and cache.is_connected: |
| try: |
| value = await cache.get(self._cache_key(task_id)) |
| if value: |
| data = json.loads(value) |
| return ProgressInfo.from_dict(data) |
| except Exception as e: |
| logger.warning(f"Failed to get progress from cache: {e}") |
| return None |
|
|
| async def _ensure_local_progress(self, task_id: str) -> Optional[ProgressInfo]: |
| """Ensure this worker has a local copy before applying an update.""" |
| with self._lock: |
| local = self._progress_data.get(task_id) |
| if local is not None: |
| return local |
|
|
| cached = await self._get_progress_from_cache(task_id) |
| if cached is None: |
| return None |
|
|
| with self._lock: |
| existing = self._progress_data.get(task_id) |
| if existing is not None: |
| return existing |
| self._progress_data[task_id] = cached |
| return cached |
|
|
| async def _delete_progress_from_cache(self, task_id: str) -> None: |
| cache = await self._get_cache() |
| if cache and cache.is_connected: |
| try: |
| await cache.delete(self._cache_key(task_id)) |
| except Exception as e: |
| logger.warning(f"Failed to delete progress from cache: {e}") |
| |
| def create_task(self, task_id: str, project_id: str, total_slides: int) -> ProgressInfo: |
| """Create a new progress tracking task""" |
| with self._lock: |
| progress = ProgressInfo( |
| task_id=task_id, |
| project_id=project_id, |
| total_slides=total_slides, |
| completed_slides=0, |
| failed_slides=0, |
| skipped_slides=0, |
| status="running", |
| message="开始生成演讲稿..." |
| ) |
| self._progress_data[task_id] = progress |
|
|
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(progress)) |
| except RuntimeError: |
| pass |
|
|
| return progress |
|
|
| def update_progress(self, task_id: str, **kwargs) -> Optional[ProgressInfo]: |
| """Update progress for a task""" |
| with self._lock: |
| if task_id not in self._progress_data: |
| return None |
| |
| progress = self._progress_data[task_id] |
| |
| |
| for key, value in kwargs.items(): |
| if hasattr(progress, key): |
| setattr(progress, key, value) |
| |
| progress.last_update = time.time() |
|
|
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(progress)) |
| except RuntimeError: |
| pass |
|
|
| return progress |
| |
| def get_progress(self, task_id: str) -> Optional[ProgressInfo]: |
| """Get progress for a task""" |
| with self._lock: |
| return self._progress_data.get(task_id) |
|
|
| async def get_progress_async(self, task_id: str) -> Optional[ProgressInfo]: |
| """Get progress for a task (async, cross-worker via Valkey).""" |
| |
| |
| |
|
|
| cached = await self._get_progress_from_cache(task_id) |
| with self._lock: |
| local = self._progress_data.get(task_id) |
|
|
| if cached is None: |
| return local |
|
|
| if local is None: |
| with self._lock: |
| self._progress_data[task_id] = cached |
| return cached |
|
|
| |
| try: |
| local_ts = float(local.last_update or 0) |
| except Exception: |
| local_ts = 0.0 |
| try: |
| cached_ts = float(cached.last_update or 0) |
| except Exception: |
| cached_ts = 0.0 |
|
|
| if cached_ts >= local_ts: |
| with self._lock: |
| self._progress_data[task_id] = cached |
| return cached |
|
|
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(local)) |
| except RuntimeError: |
| pass |
| return local |
|
|
| async def create_task_async( |
| self, |
| task_id: str, |
| project_id: str, |
| total_slides: int, |
| overwrite: bool = False, |
| ) -> ProgressInfo: |
| """Create a new progress tracking task and persist it (async).""" |
| if not overwrite: |
| existing = await self.get_progress_async(task_id) |
| if existing: |
| return existing |
|
|
| with self._lock: |
| progress = ProgressInfo( |
| task_id=task_id, |
| project_id=project_id, |
| total_slides=total_slides, |
| completed_slides=0, |
| failed_slides=0, |
| skipped_slides=0, |
| status="running", |
| message="开始生成演讲稿...", |
| ) |
| self._progress_data[task_id] = progress |
|
|
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def update_progress_async(self, task_id: str, **kwargs) -> Optional[ProgressInfo]: |
| """Update progress for a task and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.update_progress(task_id, **kwargs) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def complete_task_async(self, task_id: str, message: str = "生成完成") -> Optional[ProgressInfo]: |
| """Mark task as completed and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.complete_task(task_id, message=message) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def fail_task_async(self, task_id: str, error_message: str) -> Optional[ProgressInfo]: |
| """Mark task as failed and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.fail_task(task_id, error_message=error_message) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def add_slide_completed_async(self, task_id: str, slide_index: int, slide_title: str) -> Optional[ProgressInfo]: |
| """Mark a slide as completed and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.add_slide_completed(task_id, slide_index, slide_title) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def add_slide_failed_async( |
| self, task_id: str, slide_index: int, slide_title: str, error: str |
| ) -> Optional[ProgressInfo]: |
| """Mark a slide as failed and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.add_slide_failed(task_id, slide_index, slide_title, error) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
|
|
| async def add_slide_skipped_async( |
| self, task_id: str, slide_index: int, slide_title: str, reason: str |
| ) -> Optional[ProgressInfo]: |
| """Mark a slide as skipped and persist it (async).""" |
| await self._ensure_local_progress(task_id) |
| progress = self.add_slide_skipped(task_id, slide_index, slide_title, reason) |
| if progress: |
| await self._save_progress_to_cache(progress) |
| return progress |
| |
| def complete_task(self, task_id: str, message: str = "生成完成") -> Optional[ProgressInfo]: |
| """Mark task as completed""" |
| return self.update_progress( |
| task_id, |
| status="completed", |
| message=message |
| ) |
| |
| def fail_task(self, task_id: str, error_message: str) -> Optional[ProgressInfo]: |
| """Mark task as failed""" |
| return self.update_progress( |
| task_id, |
| status="failed", |
| message=f"生成失败: {error_message}" |
| ) |
| |
| def add_slide_completed(self, task_id: str, slide_index: int, slide_title: str) -> Optional[ProgressInfo]: |
| """Mark a slide as completed""" |
| with self._lock: |
| if task_id not in self._progress_data: |
| return None |
| |
| progress = self._progress_data[task_id] |
| progress.completed_slides += 1 |
| progress.current_slide = slide_index |
| progress.current_slide_title = slide_title |
| progress.message = f"已完成第{slide_index + 1}页: {slide_title}" |
| progress.last_update = time.time() |
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(progress)) |
| except RuntimeError: |
| pass |
|
|
| return progress |
| |
| def add_slide_failed(self, task_id: str, slide_index: int, slide_title: str, error: str) -> Optional[ProgressInfo]: |
| """Mark a slide as failed""" |
| with self._lock: |
| if task_id not in self._progress_data: |
| return None |
| |
| progress = self._progress_data[task_id] |
| progress.failed_slides += 1 |
| progress.current_slide = slide_index |
| progress.current_slide_title = slide_title |
| progress.message = f"第{slide_index + 1}页生成失败: {slide_title}" |
| progress.error_details.append({ |
| 'slide_index': slide_index, |
| 'slide_title': slide_title, |
| 'error': error |
| }) |
| progress.last_update = time.time() |
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(progress)) |
| except RuntimeError: |
| pass |
|
|
| return progress |
| |
| def add_slide_skipped(self, task_id: str, slide_index: int, slide_title: str, reason: str) -> Optional[ProgressInfo]: |
| """Mark a slide as skipped""" |
| with self._lock: |
| if task_id not in self._progress_data: |
| return None |
| |
| progress = self._progress_data[task_id] |
| progress.skipped_slides += 1 |
| progress.current_slide = slide_index |
| progress.current_slide_title = slide_title |
| progress.message = f"第{slide_index + 1}页已跳过: {slide_title}" |
| progress.last_update = time.time() |
| |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._save_progress_to_cache(progress)) |
| except RuntimeError: |
| pass |
|
|
| return progress |
| |
| def cleanup_old_tasks(self, max_age_seconds: int = 3600): |
| """Clean up old completed/failed tasks""" |
| current_time = time.time() |
| with self._lock: |
| to_remove = [] |
| for task_id, progress in self._progress_data.items(): |
| if (progress.status in ["completed", "failed"] and |
| current_time - progress.last_update > max_age_seconds): |
| to_remove.append(task_id) |
| |
| for task_id in to_remove: |
| del self._progress_data[task_id] |
| |
| def remove_task(self, task_id: str) -> bool: |
| """Remove a specific task""" |
| with self._lock: |
| if task_id in self._progress_data: |
| del self._progress_data[task_id] |
| removed = True |
| else: |
| removed = False |
|
|
| |
| if removed: |
| try: |
| loop = asyncio.get_running_loop() |
| loop.create_task(self._delete_progress_from_cache(task_id)) |
| except RuntimeError: |
| pass |
| return removed |
|
|
|
|
| |
| progress_tracker = ProgressTracker() |
|
|