diff --git a/services/sofiia-console/app/main.py b/services/sofiia-console/app/main.py index ef978486..908b802b 100644 --- a/services/sofiia-console/app/main.py +++ b/services/sofiia-console/app/main.py @@ -253,6 +253,23 @@ _aurora_live_samples: Dict[str, collections.deque] = {} _aurora_live_last: Dict[str, Dict[str, Any]] = {} _aurora_live_last_loaded = False _aurora_live_last_path = (AURORA_DATA_DIR.parent / "sofiia-console-cache" / "aurora_live_last.json") +_aurora_smart_runs: Dict[str, Dict[str, Any]] = {} +_aurora_smart_runs_loaded = False +_aurora_smart_runs_path = (AURORA_DATA_DIR.parent / "sofiia-console-cache" / "aurora_smart_runs.json") +_aurora_smart_policy: Dict[str, Any] = { + "updated_at": None, + "strategies": { + "local_only": {"count": 0, "avg_score": 0.0, "wins": 0, "losses": 0}, + "local_then_kling": {"count": 0, "avg_score": 0.0, "wins": 0, "losses": 0}, + }, +} +_aurora_smart_policy_loaded = False +_aurora_smart_policy_path = (AURORA_DATA_DIR.parent / "sofiia-console-cache" / "aurora_smart_policy.json") +_AURORA_SMART_MAX_RUNS = max(20, int(os.getenv("AURORA_SMART_MAX_RUNS", "200"))) +_AURORA_SMART_LOCAL_POLL_SEC = max(2.0, float(os.getenv("AURORA_SMART_LOCAL_POLL_SEC", "3.0"))) +_AURORA_SMART_KLING_POLL_SEC = max(3.0, float(os.getenv("AURORA_SMART_KLING_POLL_SEC", "6.0"))) +_AURORA_SMART_LOCAL_MAX_SEC = max(60.0, float(os.getenv("AURORA_SMART_LOCAL_MAX_SEC", "10800"))) +_AURORA_SMART_KLING_MAX_SEC = max(60.0, float(os.getenv("AURORA_SMART_KLING_MAX_SEC", "3600"))) MEDIA_COMFY_AGENT_URL = os.getenv( "MEDIA_COMFY_AGENT_URL", "http://comfy-agent:8880" if _is_container_runtime() else "http://127.0.0.1:8880", @@ -352,6 +369,10 @@ async def lifespan(app_: Any): task = asyncio.create_task(_nodes_poll_loop()) logger.info("Nodes poll loop started (interval=%ds)", _NODES_POLL_INTERVAL) + try: + _smart_resume_active_monitors() + except Exception as e: + logger.warning("aurora smart monitor resume failed: %s", e) yield task.cancel() try: @@ -972,6 +993,505 @@ def _aurora_persist_live_last_to_disk() -> None: logger.debug("aurora live-last persist failed: %s", e) +def _smart_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _smart_is_terminal(status: Any) -> bool: + return str(status or "").lower() in {"completed", "failed", "cancelled"} + + +def _smart_media_type(file_name: str, content_type: str) -> str: + name = str(file_name or "").lower() + ctype = str(content_type or "").lower() + video_ext = (".mp4", ".avi", ".mov", ".mkv", ".webm") + audio_ext = (".mp3", ".wav", ".flac", ".m4a", ".aac", ".ogg") + image_ext = (".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff", ".bmp") + if ctype.startswith("video/") or name.endswith(video_ext): + return "video" + if ctype.startswith("audio/") or name.endswith(audio_ext): + return "audio" + if ctype.startswith("image/") or name.endswith(image_ext): + return "photo" + return "unknown" + + +def _smart_trim_runs() -> None: + if len(_aurora_smart_runs) <= _AURORA_SMART_MAX_RUNS: + return + ordered = sorted( + _aurora_smart_runs.items(), + key=lambda kv: str((kv[1] or {}).get("created_at") or ""), + reverse=True, + ) + keep = dict(ordered[:_AURORA_SMART_MAX_RUNS]) + _aurora_smart_runs.clear() + _aurora_smart_runs.update(keep) + + +def _smart_load_runs_from_disk() -> None: + global _aurora_smart_runs_loaded + if _aurora_smart_runs_loaded: + return + _aurora_smart_runs_loaded = True + try: + if not _aurora_smart_runs_path.exists(): + return + payload = json.loads(_aurora_smart_runs_path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + runs = payload.get("runs") + else: + runs = payload + if isinstance(runs, dict): + for run_id, run in runs.items(): + if isinstance(run_id, str) and isinstance(run, dict): + _aurora_smart_runs[run_id] = run + _smart_trim_runs() + except Exception as exc: + logger.debug("aurora smart-runs load failed: %s", exc) + + +def _smart_persist_runs() -> None: + try: + _smart_trim_runs() + _aurora_smart_runs_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "updated_at": _smart_now_iso(), + "runs": _aurora_smart_runs, + } + _aurora_smart_runs_path.write_text( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + except Exception as exc: + logger.debug("aurora smart-runs persist failed: %s", exc) + + +def _smart_load_policy_from_disk() -> None: + global _aurora_smart_policy_loaded + if _aurora_smart_policy_loaded: + return + _aurora_smart_policy_loaded = True + try: + if not _aurora_smart_policy_path.exists(): + return + payload = json.loads(_aurora_smart_policy_path.read_text(encoding="utf-8")) + if isinstance(payload, dict): + strategies = payload.get("strategies") + if isinstance(strategies, dict): + _aurora_smart_policy["strategies"] = strategies + _aurora_smart_policy["updated_at"] = payload.get("updated_at") + except Exception as exc: + logger.debug("aurora smart-policy load failed: %s", exc) + + +def _smart_persist_policy() -> None: + try: + _aurora_smart_policy["updated_at"] = _smart_now_iso() + _aurora_smart_policy_path.parent.mkdir(parents=True, exist_ok=True) + _aurora_smart_policy_path.write_text( + json.dumps(_aurora_smart_policy, ensure_ascii=False, separators=(",", ":")), + encoding="utf-8", + ) + except Exception as exc: + logger.debug("aurora smart-policy persist failed: %s", exc) + + +def _smart_strategy_stats(strategy: str) -> Dict[str, Any]: + _smart_load_policy_from_disk() + strategies = _aurora_smart_policy.setdefault("strategies", {}) + stats = strategies.get(strategy) + if not isinstance(stats, dict): + stats = {"count": 0, "avg_score": 0.0, "wins": 0, "losses": 0} + strategies[strategy] = stats + return stats + + +def _smart_update_strategy_score(strategy: str, score: float) -> None: + stats = _smart_strategy_stats(strategy) + try: + count = int(stats.get("count") or 0) + 1 + avg = float(stats.get("avg_score") or 0.0) + stats["avg_score"] = round(((avg * (count - 1)) + float(score)) / max(1, count), 4) + stats["count"] = count + _smart_persist_policy() + except Exception: + return + + +def _smart_update_strategy_outcome(strategy: str, success: bool) -> None: + stats = _smart_strategy_stats(strategy) + key = "wins" if success else "losses" + stats[key] = int(stats.get(key) or 0) + 1 + _smart_persist_policy() + + +def _smart_new_run_id() -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + return f"smart_{stamp}_{uuid.uuid4().hex[:6]}" + + +def _smart_append_audit(run: Dict[str, Any], event: str, detail: Optional[Dict[str, Any]] = None) -> None: + audit = run.setdefault("audit", []) + if not isinstance(audit, list): + audit = [] + run["audit"] = audit + item: Dict[str, Any] = {"ts": _smart_now_iso(), "event": str(event)} + if isinstance(detail, dict) and detail: + item["detail"] = detail + audit.append(item) + if len(audit) > 200: + del audit[:-200] + run["updated_at"] = item["ts"] + + +def _smart_analysis_features(analysis: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not isinstance(analysis, dict): + return { + "faces": 0, + "plates": 0, + "noise": "unknown", + "blur": "unknown", + "quality_score": 0.0, + } + faces = len(analysis.get("faces") or []) if isinstance(analysis.get("faces"), list) else 0 + plates = len(analysis.get("license_plates") or []) if isinstance(analysis.get("license_plates"), list) else 0 + qa = analysis.get("quality_analysis") if isinstance(analysis.get("quality_analysis"), dict) else {} + noise = str(qa.get("noise_level") or "unknown").lower() + blur = str(qa.get("blur_level") or "unknown").lower() + score = 0.0 + score += min(2.0, faces * 0.2) + score += min(2.0, plates * 0.4) + if noise in {"high", "very_high"}: + score += 1.0 + if blur in {"high", "very_high"}: + score += 1.0 + return { + "faces": faces, + "plates": plates, + "noise": noise, + "blur": blur, + "quality_score": round(score, 3), + } + + +def _smart_decide_strategy( + *, + media_type: str, + mode: str, + requested_strategy: str, + prefer_quality: bool, + budget_tier: str, + analysis: Optional[Dict[str, Any]], + learning_enabled: bool, +) -> Dict[str, Any]: + strategy = str(requested_strategy or "auto").strip().lower() + valid = {"auto", "local_only", "local_then_kling"} + if strategy not in valid: + strategy = "auto" + + features = _smart_analysis_features(analysis) + reasons: List[str] = [] + score = 0.0 + + if media_type != "video": + chosen = "local_only" + reasons.append("non-video media -> local stack only") + return {"strategy": chosen, "reasons": reasons, "score": 0.0, "features": features} + + if strategy in {"local_only", "local_then_kling"}: + reasons.append(f"explicit strategy={strategy}") + return {"strategy": strategy, "reasons": reasons, "score": features["quality_score"], "features": features} + + score += float(features["quality_score"]) + if prefer_quality: + score += 1.3 + reasons.append("prefer_quality=true") + if str(mode).lower() == "forensic": + score += 0.8 + reasons.append("forensic mode") + + budget_norm = str(budget_tier or "normal").strip().lower() + if budget_norm == "low": + score -= 1.4 + reasons.append("budget_tier=low") + elif budget_norm == "high": + score += 0.6 + reasons.append("budget_tier=high") + + if learning_enabled: + stats = _smart_strategy_stats("local_then_kling") + wins = int(stats.get("wins") or 0) + losses = int(stats.get("losses") or 0) + total = wins + losses + if total >= 6: + success_ratio = wins / max(1, total) + if success_ratio >= 0.65: + score += 0.5 + reasons.append(f"learned success ratio {success_ratio:.2f}") + elif success_ratio <= 0.35: + score -= 0.7 + reasons.append(f"learned low success ratio {success_ratio:.2f}") + + chosen = "local_then_kling" if score >= 2.1 else "local_only" + if not reasons: + reasons.append("default heuristic") + return {"strategy": chosen, "reasons": reasons, "score": round(score, 3), "features": features} + + +def _smart_compact_result(result_payload: Dict[str, Any]) -> Dict[str, Any]: + payload = {} + if not isinstance(result_payload, dict): + return payload + payload["mode"] = result_payload.get("mode") + payload["media_type"] = result_payload.get("media_type") + payload["digital_signature"] = result_payload.get("digital_signature") + output_files = result_payload.get("output_files") + if isinstance(output_files, list): + payload["output_files"] = output_files[:8] + q = result_payload.get("quality_report") + if isinstance(q, dict): + payload["quality_report"] = q + return payload + + +async def _smart_fetch_run_status(run_id: str) -> Optional[Dict[str, Any]]: + _smart_load_runs_from_disk() + run = _aurora_smart_runs.get(run_id) + if not isinstance(run, dict): + return None + return run + + +async def _smart_monitor_run(run_id: str) -> None: + run = await _smart_fetch_run_status(run_id) + if not run: + return + + local = run.get("local") if isinstance(run.get("local"), dict) else {} + local_job_id = str(local.get("job_id") or "") + if not local_job_id: + _smart_append_audit(run, "monitor.error", {"reason": "missing local job id"}) + run["status"] = "failed" + run["phase"] = "failed" + _smart_persist_runs() + return + + start = time.monotonic() + while time.monotonic() - start <= _AURORA_SMART_LOCAL_MAX_SEC: + try: + st = await _aurora_request_json( + "GET", + f"/api/aurora/status/{quote(local_job_id, safe='')}", + timeout=20.0, + retries=2, + retry_backoff_sec=0.25, + ) + except Exception as exc: + _smart_append_audit(run, "local.status.error", {"error": str(exc)[:220]}) + await asyncio.sleep(_AURORA_SMART_LOCAL_POLL_SEC) + continue + + status = str(st.get("status") or "").lower() + if status in {"queued", "processing"}: + run["phase"] = "local_processing" + run["status"] = "processing" + elif status == "completed": + run["phase"] = "local_completed" + run["status"] = "processing" + else: + run["phase"] = f"local_{status or 'unknown'}" + run["status"] = status + run["local"] = { + **local, + "job_id": local_job_id, + "status": status, + "progress": st.get("progress"), + "current_stage": st.get("current_stage"), + "eta_seconds": st.get("eta_seconds"), + "live_fps": st.get("live_fps"), + "error_message": st.get("error_message"), + "updated_at": _smart_now_iso(), + } + _smart_persist_runs() + + if status in {"queued", "processing"}: + await asyncio.sleep(_AURORA_SMART_LOCAL_POLL_SEC) + continue + + if status != "completed": + run["status"] = "failed" + run["phase"] = "local_failed" + _smart_append_audit( + run, + "local.failed", + {"status": status, "error": str(st.get("error_message") or "")[:220]}, + ) + _smart_update_strategy_outcome(str(run.get("policy", {}).get("strategy") or "local_only"), False) + _smart_persist_runs() + return + + _smart_append_audit(run, "local.completed", {"job_id": local_job_id}) + break + else: + run["status"] = "failed" + run["phase"] = "local_timeout" + _smart_append_audit(run, "local.timeout", {"max_sec": _AURORA_SMART_LOCAL_MAX_SEC}) + _smart_update_strategy_outcome(str(run.get("policy", {}).get("strategy") or "local_only"), False) + _smart_persist_runs() + return + + try: + local_result = await _aurora_request_json( + "GET", + f"/api/aurora/result/{quote(local_job_id, safe='')}", + timeout=30.0, + retries=2, + retry_backoff_sec=0.25, + ) + except Exception as exc: + run["status"] = "failed" + run["phase"] = "local_result_error" + _smart_append_audit(run, "local.result.error", {"error": str(exc)[:240]}) + _smart_update_strategy_outcome(str(run.get("policy", {}).get("strategy") or "local_only"), False) + _smart_persist_runs() + return + + run.setdefault("local", {}) + if isinstance(run["local"], dict): + run["local"]["result"] = _smart_compact_result(local_result) + run["local"]["result_ready"] = True + run["selected_stack"] = "local" + + policy = run.get("policy") if isinstance(run.get("policy"), dict) else {} + strategy = str(policy.get("strategy") or "local_only") + media_type = str(run.get("media_type") or "") + kling = run.get("kling") if isinstance(run.get("kling"), dict) else {} + if strategy != "local_then_kling" or media_type != "video": + run["status"] = "completed" + run["phase"] = "completed" + _smart_append_audit(run, "smart.completed", {"selected_stack": "local", "reason": "strategy local_only or non-video"}) + _smart_update_strategy_outcome(strategy, True) + _smart_persist_runs() + return + + run["phase"] = "kling_submitting" + run["status"] = "processing" + _smart_append_audit(run, "kling.submit.start") + _smart_persist_runs() + + try: + submit = await _aurora_request_json( + "POST", + "/api/aurora/kling/enhance", + data={ + "job_id": local_job_id, + "prompt": str(kling.get("prompt") or "enhance video quality, improve sharpness and clarity"), + "negative_prompt": str(kling.get("negative_prompt") or "noise, blur, artifacts, distortion"), + "mode": str(kling.get("mode") or "pro"), + "duration": str(kling.get("duration") or "5"), + "cfg_scale": str(kling.get("cfg_scale") if kling.get("cfg_scale") is not None else "0.5"), + }, + timeout=120.0, + retries=1, + retry_backoff_sec=0.25, + ) + except Exception as exc: + run["kling"] = { + **kling, + "status": "failed", + "error": str(exc)[:320], + } + run["status"] = "completed" + run["phase"] = "completed_with_kling_failure" + run["selected_stack"] = "local" + _smart_append_audit(run, "kling.submit.error", {"error": str(exc)[:220]}) + _smart_update_strategy_outcome(strategy, False) + _smart_persist_runs() + return + + task_id = str(submit.get("kling_task_id") or "") + run["kling"] = { + **kling, + "task_id": task_id, + "status": str(submit.get("status") or "submitted").lower(), + "submitted_at": _smart_now_iso(), + } + _smart_append_audit(run, "kling.submitted", {"task_id": task_id}) + _smart_persist_runs() + + k_start = time.monotonic() + while time.monotonic() - k_start <= _AURORA_SMART_KLING_MAX_SEC: + try: + kst = await _aurora_request_json( + "GET", + f"/api/aurora/kling/status/{quote(local_job_id, safe='')}", + timeout=30.0, + retries=1, + retry_backoff_sec=0.2, + ) + except Exception as exc: + _smart_append_audit(run, "kling.status.error", {"error": str(exc)[:220]}) + await asyncio.sleep(_AURORA_SMART_KLING_POLL_SEC) + continue + + k_status = str(kst.get("status") or "").lower() + k_url = kst.get("kling_result_url") + run["phase"] = "kling_processing" + run["kling"] = { + **(run.get("kling") if isinstance(run.get("kling"), dict) else {}), + "status": k_status, + "result_url": k_url, + "last_polled_at": _smart_now_iso(), + } + _smart_persist_runs() + + if k_status in {"submitted", "queued", "running", "processing", "pending"}: + await asyncio.sleep(_AURORA_SMART_KLING_POLL_SEC) + continue + + if k_status in {"succeed", "completed", "success"} and k_url: + run["status"] = "completed" + run["phase"] = "completed" + run["selected_stack"] = "kling" + _smart_append_audit(run, "smart.completed", {"selected_stack": "kling", "task_id": task_id}) + _smart_update_strategy_outcome(strategy, True) + _smart_persist_runs() + return + + run["status"] = "completed" + run["phase"] = "completed_with_kling_failure" + run["selected_stack"] = "local" + _smart_append_audit( + run, + "kling.terminal.non_success", + {"status": k_status, "task_id": task_id}, + ) + _smart_update_strategy_outcome(strategy, False) + _smart_persist_runs() + return + + run["status"] = "completed" + run["phase"] = "completed_with_kling_timeout" + run["selected_stack"] = "local" + _smart_append_audit(run, "kling.timeout", {"max_sec": _AURORA_SMART_KLING_MAX_SEC}) + _smart_update_strategy_outcome(strategy, False) + _smart_persist_runs() + + +def _smart_resume_active_monitors() -> None: + _smart_load_runs_from_disk() + for run_id, run in list(_aurora_smart_runs.items()): + if not isinstance(run, dict): + continue + if _smart_is_terminal(run.get("status")): + continue + try: + asyncio.create_task(_smart_monitor_run(run_id)) + except Exception: + continue + + @app.get("/api/aurora/health") async def api_aurora_health() -> Dict[str, Any]: return await _aurora_request_json("GET", "/health", timeout=10.0) @@ -1012,6 +1532,241 @@ async def api_aurora_upload( return payload +@app.post("/api/aurora/process-smart") +async def api_aurora_process_smart( + file: UploadFile = File(...), + mode: str = Form("tactical"), + priority: str = Form("balanced"), + export_options: str = Form(""), + strategy: str = Form("auto"), + prefer_quality: bool = Form(True), + budget_tier: str = Form("normal"), + learning_enabled: bool = Form(True), + kling_prompt: str = Form("enhance video quality, improve sharpness and clarity"), + kling_negative_prompt: str = Form("noise, blur, artifacts, distortion"), + kling_mode: str = Form("pro"), + kling_duration: str = Form("5"), + kling_cfg_scale: float = Form(0.5), +) -> Dict[str, Any]: + _smart_load_runs_from_disk() + _smart_load_policy_from_disk() + + file_name = file.filename or "upload.bin" + content_type = file.content_type or "application/octet-stream" + media_type = _smart_media_type(file_name, content_type) + + analysis: Optional[Dict[str, Any]] = None + if media_type in {"video", "photo"}: + try: + await file.seek(0) + files = {"file": (file_name, file.file, content_type)} + analysis = await _aurora_request_json( + "POST", + "/api/aurora/analyze", + files=files, + timeout=120.0, + retries=1, + retry_backoff_sec=0.25, + ) + except Exception as exc: + analysis = None + logger.warning("smart-process analyze skipped: %s", str(exc)[:220]) + + policy = _smart_decide_strategy( + media_type=media_type, + mode=mode, + requested_strategy=strategy, + prefer_quality=bool(prefer_quality), + budget_tier=budget_tier, + analysis=analysis, + learning_enabled=bool(learning_enabled), + ) + chosen_strategy = str(policy.get("strategy") or "local_only") + policy.setdefault("requested_strategy", str(strategy or "auto")) + policy["learning_enabled"] = bool(learning_enabled) + policy["budget_tier"] = str(budget_tier or "normal") + + await file.seek(0) + files = {"file": (file_name, file.file, content_type)} + local_payload = await _aurora_request_json( + "POST", + "/api/aurora/upload", + files=files, + data={ + "mode": mode, + "priority": priority, + "export_options": export_options, + }, + timeout=120.0, + ) + local_job_id = str(local_payload.get("job_id") or "") + if not local_job_id: + raise HTTPException(status_code=502, detail="Smart process failed: local job_id missing") + + run_id = _smart_new_run_id() + now = _smart_now_iso() + run: Dict[str, Any] = { + "run_id": run_id, + "created_at": now, + "updated_at": now, + "status": "processing", + "phase": "local_processing", + "media_type": media_type, + "selected_stack": None, + "requested": { + "mode": mode, + "priority": priority, + "export_options": export_options, + "strategy": strategy, + "prefer_quality": bool(prefer_quality), + "budget_tier": budget_tier, + "learning_enabled": bool(learning_enabled), + }, + "policy": policy, + "analysis_summary": _smart_analysis_features(analysis), + "analysis": analysis if isinstance(analysis, dict) else None, + "local": { + "job_id": local_job_id, + "status": "queued", + "submit_payload": { + "status_url": f"/api/aurora/status/{quote(local_job_id, safe='')}", + "result_url": f"/api/aurora/result/{quote(local_job_id, safe='')}", + }, + }, + "kling": { + "enabled": chosen_strategy == "local_then_kling" and media_type == "video", + "status": "pending", + "prompt": kling_prompt, + "negative_prompt": kling_negative_prompt, + "mode": kling_mode, + "duration": kling_duration, + "cfg_scale": kling_cfg_scale, + }, + "audit": [], + } + _smart_append_audit( + run, + "smart.submitted", + { + "local_job_id": local_job_id, + "media_type": media_type, + "strategy": chosen_strategy, + "score": policy.get("score"), + }, + ) + _aurora_smart_runs[run_id] = run + _smart_persist_runs() + + try: + asyncio.create_task(_smart_monitor_run(run_id)) + except Exception as exc: + _smart_append_audit(run, "monitor.spawn.error", {"error": str(exc)[:220]}) + _smart_persist_runs() + + return { + "smart_run_id": run_id, + "status": run.get("status"), + "phase": run.get("phase"), + "media_type": media_type, + "local_job_id": local_job_id, + "policy": policy, + "smart_status_url": f"/api/aurora/process-smart/{quote(run_id, safe='')}", + "local_status_url": f"/api/aurora/status/{quote(local_job_id, safe='')}", + "local_result_url": f"/api/aurora/result/{quote(local_job_id, safe='')}", + } + + +@app.get("/api/aurora/process-smart") +async def api_aurora_process_smart_list( + limit: int = Query(default=20, ge=1, le=200), + status: Optional[str] = Query(default=None), +) -> Dict[str, Any]: + _smart_load_runs_from_disk() + requested = str(status or "").strip().lower() + rows = [] + for run in _aurora_smart_runs.values(): + if not isinstance(run, dict): + continue + run_status = str(run.get("status") or "") + if requested and run_status.lower() != requested: + continue + local = run.get("local") if isinstance(run.get("local"), dict) else {} + kling = run.get("kling") if isinstance(run.get("kling"), dict) else {} + rows.append( + { + "run_id": run.get("run_id"), + "status": run_status, + "phase": run.get("phase"), + "media_type": run.get("media_type"), + "strategy": (run.get("policy") or {}).get("strategy") if isinstance(run.get("policy"), dict) else None, + "selected_stack": run.get("selected_stack"), + "created_at": run.get("created_at"), + "updated_at": run.get("updated_at"), + "local_job_id": local.get("job_id"), + "local_status": local.get("status"), + "kling_status": kling.get("status"), + } + ) + rows.sort(key=lambda x: str(x.get("created_at") or ""), reverse=True) + return {"runs": rows[:limit], "count": min(limit, len(rows)), "total": len(rows)} + + +@app.get("/api/aurora/process-smart/{run_id}") +async def api_aurora_process_smart_status(run_id: str) -> Dict[str, Any]: + run = await _smart_fetch_run_status(run_id) + if not run: + raise HTTPException(status_code=404, detail="smart run not found") + return run + + +@app.post("/api/aurora/process-smart/{run_id}/feedback") +async def api_aurora_process_smart_feedback( + run_id: str, + payload: Optional[Dict[str, Any]] = Body(default=None), +) -> Dict[str, Any]: + run = await _smart_fetch_run_status(run_id) + if not run: + raise HTTPException(status_code=404, detail="smart run not found") + body = payload if isinstance(payload, dict) else {} + score_raw = body.get("score") + score: Optional[float] = None + try: + if score_raw is not None: + score = float(score_raw) + except Exception: + score = None + selected_stack = str(body.get("selected_stack") or "").strip().lower() or None + notes = str(body.get("notes") or "").strip() + + feedback = { + "ts": _smart_now_iso(), + "score": score, + "selected_stack": selected_stack, + "notes": notes[:1000] if notes else None, + } + run["feedback"] = feedback + strategy = str((run.get("policy") or {}).get("strategy") or "local_only") + if score is not None: + score = max(1.0, min(5.0, score)) + _smart_update_strategy_score(strategy, score) + if selected_stack in {"local", "kling"}: + run["selected_stack"] = selected_stack + _smart_append_audit(run, "feedback.received", {"score": score, "selected_stack": selected_stack}) + _smart_persist_runs() + return { + "ok": True, + "run_id": run_id, + "feedback": feedback, + "policy": _aurora_smart_policy, + } + + +@app.get("/api/aurora/process-smart/policy/stats") +async def api_aurora_process_smart_policy_stats() -> Dict[str, Any]: + _smart_load_policy_from_disk() + return _aurora_smart_policy + + @app.post("/api/aurora/analyze") async def api_aurora_analyze(file: UploadFile = File(...)) -> Dict[str, Any]: await file.seek(0) diff --git a/services/sofiia-console/static/index.html b/services/sofiia-console/static/index.html index c0d58a66..38a67b75 100644 --- a/services/sofiia-console/static/index.html +++ b/services/sofiia-console/static/index.html @@ -836,6 +836,35 @@ +
+
Smart Orchestrator (Dual Stack)
+ +
+ + +
+ +
policy: standby
+
+
@@ -847,6 +876,8 @@
Job Status
Job ID
+
Smart Run
+
Smart Policy
Статусidle
Етап
Черга
@@ -2018,6 +2049,8 @@ let currentAudio = null; let auroraMode = 'tactical'; let auroraSelectedFile = null; let auroraJobId = null; +let auroraSmartRunId = null; +let auroraSmartStatusCache = null; let auroraPollTimer = null; let auroraResultCache = null; let auroraAnalysisCache = null; @@ -2035,8 +2068,10 @@ let auroraChatBusy = false; let auroraFolderPath = null; const AURORA_MAX_TRANSIENT_ERRORS = 12; const AURORA_ACTIVE_JOB_KEY = 'aurora_active_job_id'; +const AURORA_SMART_RUN_KEY = 'aurora_smart_run_id'; const AURORA_TIMING_CACHE_PREFIX = 'aurora_timing_cache_v1:'; try { auroraJobId = localStorage.getItem(AURORA_ACTIVE_JOB_KEY) || null; } catch (_) {} +try { auroraSmartRunId = localStorage.getItem(AURORA_SMART_RUN_KEY) || null; } catch (_) {} let mediaTabBootstrapped = false; let aistalkTabBootstrapped = false; let aistalkRunId = null; @@ -2214,6 +2249,38 @@ function auroraPersistActiveJob() { } catch (_) {} } +function auroraPersistSmartRun() { + try { + if (auroraSmartRunId) localStorage.setItem(AURORA_SMART_RUN_KEY, auroraSmartRunId); + else localStorage.removeItem(AURORA_SMART_RUN_KEY); + } catch (_) {} +} + +function auroraSetSmartRunId(runId) { + const normalized = String(runId || '').trim(); + auroraSmartRunId = normalized || null; + const el = document.getElementById('auroraSmartRunId'); + if (el) el.textContent = auroraSmartRunId || '—'; + auroraPersistSmartRun(); +} + +function auroraSetSmartPolicyText(text) { + const el = document.getElementById('auroraSmartPolicy'); + const hint = document.getElementById('auroraSmartHint'); + const msg = String(text || '').trim() || '—'; + if (el) el.textContent = msg; + if (hint) hint.textContent = `policy: ${msg}`; +} + +function auroraSmartConfig() { + return { + enabled: Boolean(document.getElementById('auroraSmartEnabled')?.checked), + strategy: document.getElementById('auroraSmartStrategy')?.value || 'auto', + budget_tier: document.getElementById('auroraSmartBudget')?.value || 'normal', + prefer_quality: Boolean(document.getElementById('auroraSmartPreferQuality')?.checked), + }; +} + function auroraTimingCacheKey(jobId) { const id = String(jobId || '').trim(); return id ? `${AURORA_TIMING_CACHE_PREFIX}${id}` : ''; @@ -2301,6 +2368,9 @@ function auroraSetSelectedFile(file) { auroraSuggestedPriority = 'balanced'; auroraSuggestedExport = {}; auroraPresetMode = 'balanced'; + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('standby'); auroraResetAnalysisControls(); auroraUpdateQueuePosition(null); auroraUpdateStorage(null); @@ -2474,6 +2544,9 @@ function auroraSelectJob(jobId) { const id = String(jobId || '').trim(); if (!id) return; auroraSetActiveJobId(id); + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('manual open'); auroraStatusCache = null; auroraResultCache = null; auroraPollErrorCount = 0; @@ -2491,6 +2564,9 @@ function auroraSelectJob(jobId) { function auroraClearActiveJob() { auroraStopPolling(); auroraSetActiveJobId(null); + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('—'); auroraStatusCache = null; auroraResultCache = null; auroraLastProgress = 0; @@ -3165,6 +3241,9 @@ async function auroraReprocess(options) { } const data = await r.json(); auroraSetActiveJobId(data.job_id); + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('audio local'); auroraStatusCache = null; auroraResultCache = null; auroraPollErrorCount = 0; @@ -3301,6 +3380,9 @@ async function auroraStartAudio() { } const data = await r.json(); auroraSetActiveJobId(data.job_id); + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('reprocess local'); auroraStatusCache = null; auroraResultCache = null; auroraPollErrorCount = 0; @@ -3331,6 +3413,29 @@ function auroraStopPolling() { auroraPollInFlight = false; } +async function auroraPollSmartStatus({ quiet = true } = {}) { + if (!auroraSmartRunId) return null; + try { + const r = await fetch(`${API}/api/aurora/process-smart/${encodeURIComponent(auroraSmartRunId)}`); + if (r.status === 404) { + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('—'); + return null; + } + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const smart = await r.json(); + auroraSmartStatusCache = smart; + const strategy = smart?.policy?.strategy || 'auto'; + const phase = smart?.phase || smart?.status || '—'; + auroraSetSmartPolicyText(`${strategy} · ${phase}`); + return smart; + } catch (e) { + if (!quiet) console.warn('aurora smart status error:', e); + return auroraSmartStatusCache; + } +} + async function auroraPollStatus() { if (!auroraJobId || auroraPollInFlight) return; auroraPollInFlight = true; @@ -3346,6 +3451,7 @@ async function auroraPollStatus() { } if (!r.ok) throw new Error(`HTTP ${r.status}`); const st = await r.json(); + const smart = await auroraPollSmartStatus({ quiet: true }); auroraStatusCache = st; auroraPollErrorCount = 0; const cachedTiming = auroraGetPersistedTiming(auroraJobId) || {}; @@ -3369,10 +3475,24 @@ async function auroraPollStatus() { const reBtn = document.getElementById('auroraReprocessBtn'); if (reBtn) reBtn.disabled = !(st.status === 'completed' || st.status === 'failed' || st.status === 'cancelled'); if (st.status === 'completed') { + const smartActive = smart && !['completed', 'failed', 'cancelled'].includes(String(smart.status || '').toLowerCase()); + if (smartActive) { + if (!auroraResultCache) { + await auroraLoadResult(auroraJobId); + } + const kStat = smart?.kling?.status ? ` · Kling ${smart.kling.status}` : ''; + auroraSetProgress(99, 'processing', `smart orchestration (${smart.phase || 'running'}${kStat})`); + const cancelBtn = document.getElementById('auroraCancelBtn'); + if (cancelBtn) cancelBtn.style.display = 'none'; + return; + } auroraStopPolling(); await auroraLoadResult(auroraJobId); const cancelBtn = document.getElementById('auroraCancelBtn'); if (cancelBtn) cancelBtn.style.display = 'none'; + if (smart && smart.selected_stack) { + auroraChatAdd('assistant', `Smart run завершено. Selected stack: ${smart.selected_stack}.`); + } await auroraRefreshJobs(); } else if (st.status === 'failed' || st.status === 'cancelled') { auroraStopPolling(); @@ -3406,20 +3526,29 @@ async function auroraStart() { return; } const analysisControls = auroraCollectAnalysisControls(); + const smartCfg = auroraSmartConfig(); const fd = new FormData(); fd.append('file', auroraSelectedFile); fd.append('mode', auroraMode); fd.append('priority', analysisControls.priority || auroraSuggestedPriority || 'balanced'); const uiExport = auroraCollectExportOptions(); const analysisExport = auroraBuildAnalysisExportHints(analysisControls); - fd.append('export_options', JSON.stringify({ ...auroraSuggestedExport, ...uiExport, ...analysisExport })); + const mergedExport = { ...auroraSuggestedExport, ...uiExport, ...analysisExport }; + fd.append('export_options', JSON.stringify(mergedExport)); + if (smartCfg.enabled) { + fd.append('strategy', smartCfg.strategy || 'auto'); + fd.append('prefer_quality', smartCfg.prefer_quality ? 'true' : 'false'); + fd.append('budget_tier', smartCfg.budget_tier || 'normal'); + fd.append('learning_enabled', 'true'); + } _auroraLogLines = []; const startBtn = document.getElementById('auroraStartBtn'); const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn'); if (startBtn) startBtn.disabled = true; if (quickStartBtn) quickStartBtn.disabled = true; try { - const r = await fetch(`${API}/api/aurora/upload`, { + const endpoint = smartCfg.enabled ? '/api/aurora/process-smart' : '/api/aurora/upload'; + const r = await fetch(`${API}${endpoint}`, { method: 'POST', body: fd, }); @@ -3428,7 +3557,23 @@ async function auroraStart() { throw new Error(body || `HTTP ${r.status}`); } const data = await r.json(); - auroraSetActiveJobId(data.job_id); + const localJobId = data.local_job_id || data.job_id; + if (!localJobId) { + throw new Error('job_id missing in response'); + } + auroraSetActiveJobId(localJobId); + if (smartCfg.enabled) { + auroraSetSmartRunId(data.smart_run_id || null); + const policyStrategy = data?.policy?.strategy || smartCfg.strategy || 'auto'; + const policyScore = Number(data?.policy?.score); + const scoreTxt = Number.isFinite(policyScore) ? ` (${policyScore.toFixed(2)})` : ''; + auroraSetSmartPolicyText(`${policyStrategy}${scoreTxt}`); + auroraChatAdd('assistant', `Smart run ${data.smart_run_id || '—'}: strategy=${policyStrategy}`); + } else { + auroraSetSmartRunId(null); + auroraSmartStatusCache = null; + auroraSetSmartPolicyText('manual local'); + } auroraStatusCache = null; auroraResultCache = null; auroraPollErrorCount = 0; @@ -3441,7 +3586,7 @@ async function auroraStart() { document.getElementById('auroraResultCard').style.display = 'none'; const reBtn = document.getElementById('auroraReprocessBtn'); if (reBtn) reBtn.disabled = true; - auroraSetProgress(1, 'processing', 'dispatching'); + auroraSetProgress(1, 'processing', smartCfg.enabled ? 'dispatching smart orchestration' : 'dispatching'); const cancelBtn = document.getElementById('auroraCancelBtn'); if (cancelBtn) cancelBtn.style.display = 'inline-block'; auroraStopPolling(); @@ -3449,7 +3594,7 @@ async function auroraStart() { await auroraPollStatus(); await auroraRefreshJobs(); } catch (e) { - alert(`Aurora upload error: ${e.message || e}`); + alert(`Aurora start error: ${e.message || e}`); auroraSetProgress(0, 'failed', 'upload_error'); } finally { if (startBtn) startBtn.disabled = !auroraSelectedFile; @@ -3805,6 +3950,10 @@ function auroraInitTab() { auroraBindDropzone(); auroraRefreshHealth(); auroraUpdatePriorityLabel(); + auroraSetSmartRunId(auroraSmartRunId); + if (!auroraSmartRunId) { + auroraSetSmartPolicyText('standby'); + } const quickStartBtn = document.getElementById('auroraStartFromAnalysisBtn'); if (quickStartBtn) quickStartBtn.disabled = !auroraSelectedFile; if (!auroraTabBootstrapped) { @@ -3827,6 +3976,15 @@ function auroraInitTab() { } auroraUpdateQueuePosition((auroraStatusCache || {}).queue_position || null); auroraUpdateStorage((auroraStatusCache || {}).storage || null); + if (auroraSmartRunId) { + auroraPollSmartStatus({ quiet: true }).then((smart) => { + if (!smart || typeof smart !== 'object') return; + const localJob = smart?.local?.job_id || null; + if (!auroraJobId && localJob) { + auroraSetActiveJobId(localJob); + } + }).catch(() => {}); + } auroraRefreshJobs(); if (auroraJobId && !auroraPollTimer) { auroraSetProgress(Math.max(1, auroraLastProgress || 0), 'processing', 'restoring previous job...');