|
@@ -13,6 +13,7 @@ import signal
|
|
|
import shutil
|
|
import shutil
|
|
|
import subprocess
|
|
import subprocess
|
|
|
import tempfile
|
|
import tempfile
|
|
|
|
|
+import threading
|
|
|
import time
|
|
import time
|
|
|
from pathlib import Path
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
from typing import Any
|
|
@@ -40,11 +41,14 @@ CLIP_SECONDS = 30.0
|
|
|
MIN_USABLE_CLIP_SECONDS = 15.0
|
|
MIN_USABLE_CLIP_SECONDS = 15.0
|
|
|
REMOTE_CLIP_TIMEOUT_SECONDS = 120.0
|
|
REMOTE_CLIP_TIMEOUT_SECONDS = 120.0
|
|
|
REMOTE_CLIP_ATTEMPTS = 2
|
|
REMOTE_CLIP_ATTEMPTS = 2
|
|
|
-UPLOAD_TIMEOUT_SECONDS = 60.0
|
|
|
|
|
|
|
+UPLOAD_TIMEOUT_SECONDS = 120.0
|
|
|
|
|
+OSS_UPLOAD_ATTEMPTS = 2
|
|
|
|
|
+OSS_UPLOAD_CONCURRENCY = 2
|
|
|
MODEL_TIMEOUT_SECONDS = 300.0
|
|
MODEL_TIMEOUT_SECONDS = 300.0
|
|
|
TOOL_TIMEOUT_SECONDS = 480.0
|
|
TOOL_TIMEOUT_SECONDS = 480.0
|
|
|
PROBE_TIMEOUT_SECONDS = 15.0
|
|
PROBE_TIMEOUT_SECONDS = 15.0
|
|
|
_DURATION_RE = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
|
_DURATION_RE = re.compile(r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)")
|
|
|
|
|
+_OSS_UPLOAD_SLOTS = threading.BoundedSemaphore(OSS_UPLOAD_CONCURRENCY)
|
|
|
|
|
|
|
|
|
|
|
|
|
class RemoteClipError(RuntimeError):
|
|
class RemoteClipError(RuntimeError):
|
|
@@ -52,6 +56,7 @@ class RemoteClipError(RuntimeError):
|
|
|
|
|
|
|
|
|
|
|
|
|
def _result(**payload: Any) -> str:
|
|
def _result(**payload: Any) -> str:
|
|
|
|
|
+ payload.setdefault("success", not bool(payload.get("error_code")))
|
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
return json.dumps(payload, ensure_ascii=False)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -166,6 +171,57 @@ def _upload_to_oss(clipped: Path, video_url: str) -> str:
|
|
|
return client.upload_file(clipped, object_key)
|
|
return client.upload_file(clipped, object_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _consume_background_upload(future: asyncio.Future) -> None:
|
|
|
|
|
+ """Consume a timed-out thread result so a later exception is not left unobserved."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ future.exception()
|
|
|
|
|
+ except (asyncio.CancelledError, Exception):
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _upload_attempt(clipped: Path, video_url: str) -> str:
|
|
|
|
|
+ """Run one upload with a timeout that starts only after a concurrency slot is acquired."""
|
|
|
|
|
+ loop = asyncio.get_running_loop()
|
|
|
|
|
+ started = asyncio.Event()
|
|
|
|
|
+
|
|
|
|
|
+ def upload_with_slot() -> str:
|
|
|
|
|
+ with _OSS_UPLOAD_SLOTS:
|
|
|
|
|
+ try:
|
|
|
|
|
+ loop.call_soon_threadsafe(started.set)
|
|
|
|
|
+ except RuntimeError:
|
|
|
|
|
+ pass
|
|
|
|
|
+ return _upload_to_oss(clipped, video_url)
|
|
|
|
|
+
|
|
|
|
|
+ future = loop.run_in_executor(None, upload_with_slot)
|
|
|
|
|
+ await started.wait()
|
|
|
|
|
+ try:
|
|
|
|
|
+ return await asyncio.wait_for(
|
|
|
|
|
+ asyncio.shield(future),
|
|
|
|
|
+ timeout=UPLOAD_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ except TimeoutError:
|
|
|
|
|
+ # asyncio cannot stop a running upload thread. Keep the thread-owned
|
|
|
|
|
+ # semaphore until the SDK call really exits, then consume its result.
|
|
|
|
|
+ future.add_done_callback(_consume_background_upload)
|
|
|
|
|
+ raise TimeoutError(f"OSS 上传视频超时({int(UPLOAD_TIMEOUT_SECONDS)} 秒)") from None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def _upload_to_oss_with_one_retry(clipped: Path, video_url: str) -> str:
|
|
|
|
|
+ errors: list[str] = []
|
|
|
|
|
+ for attempt in range(1, OSS_UPLOAD_ATTEMPTS + 1):
|
|
|
|
|
+ try:
|
|
|
|
|
+ return await _upload_attempt(clipped, video_url)
|
|
|
|
|
+ except Exception as exc:
|
|
|
|
|
+ errors.append(f"attempt {attempt}/{OSS_UPLOAD_ATTEMPTS}: {type(exc).__name__}: {exc}")
|
|
|
|
|
+ logger.warning("find_agent_v2 OSS upload %s", errors[-1])
|
|
|
|
|
+ last = errors[-1] if errors else "unknown error"
|
|
|
|
|
+ if "TimeoutError" in last:
|
|
|
|
|
+ raise TimeoutError(
|
|
|
|
|
+ f"OSS 上传视频连续 {OSS_UPLOAD_ATTEMPTS} 次超时(单次 {int(UPLOAD_TIMEOUT_SECONDS)} 秒)"
|
|
|
|
|
+ ) from None
|
|
|
|
|
+ raise RuntimeError("OSS 上传视频失败:" + " | ".join(errors))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _qwen_client() -> OpenAI:
|
|
def _qwen_client() -> OpenAI:
|
|
|
load_dotenv(find_project_root() / ".env")
|
|
load_dotenv(find_project_root() / ".env")
|
|
|
api_key = os.getenv("DASHSCOPE_API_KEY")
|
|
api_key = os.getenv("DASHSCOPE_API_KEY")
|
|
@@ -193,13 +249,7 @@ async def _prepare_oss_video(video_url: str) -> tuple[str, float, bool]:
|
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="find_agent_v2_video_"))
|
|
temp_dir = Path(tempfile.mkdtemp(prefix="find_agent_v2_video_"))
|
|
|
try:
|
|
try:
|
|
|
clipped, duration, complete = await _clip_remote_with_one_retry(video_url, temp_dir)
|
|
clipped, duration, complete = await _clip_remote_with_one_retry(video_url, temp_dir)
|
|
|
- try:
|
|
|
|
|
- oss_url = await asyncio.wait_for(
|
|
|
|
|
- asyncio.to_thread(_upload_to_oss, clipped, video_url),
|
|
|
|
|
- timeout=UPLOAD_TIMEOUT_SECONDS,
|
|
|
|
|
- )
|
|
|
|
|
- except TimeoutError:
|
|
|
|
|
- raise TimeoutError("OSS 上传视频超时(60 秒)") from None
|
|
|
|
|
|
|
+ oss_url = await _upload_to_oss_with_one_retry(clipped, video_url)
|
|
|
return oss_url, duration, complete
|
|
return oss_url, duration, complete
|
|
|
finally:
|
|
finally:
|
|
|
for path in temp_dir.iterdir():
|
|
for path in temp_dir.iterdir():
|
|
@@ -215,7 +265,7 @@ async def _prepare_oss_video(video_url: str) -> tuple[str, float, bool]:
|
|
|
name="understand_candidate_video_30s_v2",
|
|
name="understand_candidate_video_30s_v2",
|
|
|
description=(
|
|
description=(
|
|
|
"理解当前评估分片中的候选视频。按 candidate_id 读取播放地址,由 ffmpeg 远程截取前 30 秒"
|
|
"理解当前评估分片中的候选视频。按 candidate_id 读取播放地址,由 ffmpeg 远程截取前 30 秒"
|
|
|
- "(单次 60 秒超时,失败重试 1 次);两次均不足 30 秒时只接受超过 15 秒的最长片段。上传 OSS"
|
|
|
|
|
|
|
+ "(单次 120 秒超时,失败重试 1 次);两次均不足 30 秒时只接受超过 15 秒的最长片段。上传 OSS"
|
|
|
"并清理全部本地文件后调用千问返回内容理解。"
|
|
"并清理全部本地文件后调用千问返回内容理解。"
|
|
|
"仅用于核验实际主题、需求相关性、表达与场景、传播理由及视频中的时间线索;不能用画面人物"
|
|
"仅用于核验实际主题、需求相关性、表达与场景、传播理由及视频中的时间线索;不能用画面人物"
|
|
|
"年龄推断受众年龄。prompt 应写明当前需求和需要核验的疑点。"
|
|
"年龄推断受众年龄。prompt 应写明当前需求和需要核验的疑点。"
|
|
@@ -265,17 +315,20 @@ async def understand_candidate_video_30s_v2(
|
|
|
output=content,
|
|
output=content,
|
|
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
duration_ms=int((time.monotonic() - started) * 1000),
|
|
|
)
|
|
)
|
|
|
- except (RemoteClipError, TimeoutError) as exc:
|
|
|
|
|
|
|
+ except RemoteClipError as exc:
|
|
|
return _result(
|
|
return _result(
|
|
|
error=str(exc),
|
|
error=str(exc),
|
|
|
- error_code=(
|
|
|
|
|
- "video_understanding_timeout"
|
|
|
|
|
- if isinstance(exc, TimeoutError)
|
|
|
|
|
- else "remote_clip_failed"
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ error_code="remote_clip_failed",
|
|
|
candidate_id=candidate_id,
|
|
candidate_id=candidate_id,
|
|
|
retryable=False,
|
|
retryable=False,
|
|
|
)
|
|
)
|
|
|
|
|
+ except TimeoutError as exc:
|
|
|
|
|
+ return _result(
|
|
|
|
|
+ error=str(exc),
|
|
|
|
|
+ error_code="video_understanding_timeout",
|
|
|
|
|
+ candidate_id=candidate_id,
|
|
|
|
|
+ retryable=True,
|
|
|
|
|
+ )
|
|
|
except APIStatusError as exc:
|
|
except APIStatusError as exc:
|
|
|
return _result(
|
|
return _result(
|
|
|
error=str(exc),
|
|
error=str(exc),
|