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 @@ +