From 4886effab78b84f65b0988104619eba2a4901e66 Mon Sep 17 00:00:00 2001 From: edy Date: Thu, 12 Feb 2026 11:55:42 +0800 Subject: [PATCH 01/13] feat: add video_breakdown_agent to partners --- .../partners/video_breakdown_agent/.gitignore | 37 + .../.scripts/smoke_test.py | 202 +++++ .../video_breakdown_agent/.skills/README.md | 68 ++ .../.skills/hook-analyzer-skill/SKILL.md | 111 +++ .../scripts/analyze_hook_segments.py | 115 +++ .../.skills/report-generator-skill/SKILL.md | 140 +++ .../scripts/generate_report.py | 204 +++++ .../.skills/video-breakdown-skill/SKILL.md | 70 ++ .../scripts/process_video.py | 148 ++++ .../scripts/video_upload.py | 133 +++ .../partners/video_breakdown_agent/LICENSE | 175 ++++ .../partners/video_breakdown_agent/README.md | 504 +++++++++++ .../video_breakdown_agent/README_en.md | 507 +++++++++++ .../partners/video_breakdown_agent/agent.py | 86 ++ .../video_breakdown_agent/assets/README.md | 158 ++++ .../architecture_video_breakdown_agent.jpg | Bin 0 -> 148066 bytes .../video_breakdown_agent/config.yaml.example | 115 +++ .../video_breakdown_agent/dataset.json | 14 + .../partners/video_breakdown_agent/deploy.sh | 34 + .../docs/DEPLOY_GUIDE.md | 132 +++ .../docs/README_CONFIG.md | 251 ++++++ .../video_breakdown_agent/project.yaml | 67 ++ .../video_breakdown_agent/pyproject.toml | 41 + .../video_breakdown_agent/requirements.txt | 34 + .../sandbox/docker-compose.yml | 100 +++ .../sandbox/sandbox.env.example | 14 + .../sandbox/sandbox_manager.py | 235 +++++ .../video_breakdown_agent/setup_env.sh | 88 ++ .../video_breakdown_agent/__init__.py | 5 + .../video_breakdown_agent/agent.py | 214 +++++ .../video_breakdown_agent/hook/__init__.py | 0 .../hook/final_output_hook.py | 177 ++++ .../video_breakdown_agent/hook/format_hook.py | 323 +++++++ .../hook/search_output_hook.py | 45 + .../hook/video_upload_hook.py | 190 ++++ .../video_breakdown_agent/prompt.py | 358 ++++++++ .../sub_agents/__init__.py | 0 .../sub_agents/breakdown_agent/__init__.py | 0 .../sub_agents/breakdown_agent/agent.py | 36 + .../sub_agents/breakdown_agent/prompt.py | 91 ++ .../hook_analyzer_agent/__init__.py | 0 .../sub_agents/hook_analyzer_agent/agent.py | 52 ++ .../sub_agents/hook_analyzer_agent/prompt.py | 126 +++ .../report_generator_agent/__init__.py | 0 .../report_generator_agent/agent.py | 25 + .../direct_output_callback.py | 20 + .../report_generator_agent/prompt.py | 30 + .../video_breakdown_agent/tools/__init__.py | 15 + .../tools/analyze_bgm.py | 276 ++++++ .../tools/analyze_hook_segments.py | 148 ++++ .../tools/analyze_segments_vision.py | 321 +++++++ .../tools/process_video.py | 819 ++++++++++++++++++ .../tools/report_generator.py | 400 +++++++++ .../tools/video_upload.py | 140 +++ .../video_breakdown_agent/utils/__init__.py | 0 .../utils/doubao_client.py | 342 ++++++++ .../video_breakdown_agent/utils/types.py | 76 ++ 57 files changed, 8012 insertions(+) create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.gitignore create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/README.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/LICENSE create mode 100644 python/02-use-cases/partners/video_breakdown_agent/README.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/README_en.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/agent.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/assets/README.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg create mode 100644 python/02-use-cases/partners/video_breakdown_agent/config.yaml.example create mode 100644 python/02-use-cases/partners/video_breakdown_agent/dataset.json create mode 100755 python/02-use-cases/partners/video_breakdown_agent/deploy.sh create mode 100644 python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/project.yaml create mode 100644 python/02-use-cases/partners/video_breakdown_agent/pyproject.toml create mode 100644 python/02-use-cases/partners/video_breakdown_agent/requirements.txt create mode 100644 python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml create mode 100644 python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example create mode 100644 python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py create mode 100755 python/02-use-cases/partners/video_breakdown_agent/setup_env.sh create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/__init__.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py create mode 100644 python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/.gitignore b/python/02-use-cases/partners/video_breakdown_agent/.gitignore new file mode 100644 index 00000000..4816631a --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.gitignore @@ -0,0 +1,37 @@ +# 密钥和运行时配置(绝不可提交) +# 注意:避免把任何 .env* 模板误提交(其中经常包含真实密钥/Token) +.env +.env.* +.env* + +# 媒体缓存(视频处理临时文件) +.media-cache/ +.media-uploads/ +.media-downloads/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# VeADK / ADK +.adk/ +.agentkit/ +.veadk-ignore +uv.lock +.uv-cache/ + +# 虚拟环境 +.venv/ +venv/ + +# IDE +.idea/ +.vscode/ +.DS_Store + +# 日志 +*.log diff --git a/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py b/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py new file mode 100644 index 00000000..e656d8a3 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +""" +Video Breakdown Agent — 最小联调/冒烟测试脚本 + +用法(从项目根目录运行): + # 交互式对话(默认) + uv run python .scripts/smoke_test.py + + # 直接发送一条消息 + uv run python .scripts/smoke_test.py "你好,介绍一下你的功能" + + # 运行 pipeline 回归用例 + uv run python .scripts/smoke_test.py --pipeline-cases + +依赖:需要在项目根目录下已有 config.yaml 或 .env 配置。 +""" +import asyncio +import sys +import os +from pathlib import Path + +# 确保项目根目录在 Python 路径中 +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +async def run_single(message: str) -> str: + """发送单条消息并返回 Agent 最终输出""" + # 延迟导入,让 sys.path 先生效 + from agent import runner # noqa: E402 + + print(f"\n{'='*60}") + print(f"📤 发送: {message}") + print(f"{'='*60}") + + result = await runner.run( + messages=message, + user_id="smoke_test_user", + session_id="smoke_test_session", + ) + + print(f"\n{'='*60}") + print(f"📥 回复:") + print(f"{'='*60}") + print(result) + return result + + +async def run_interactive() -> None: + """交互式多轮对话""" + from agent import runner # noqa: E402 + + session_id = f"smoke_test_{os.getpid()}" + user_id = "smoke_test_user" + turn = 0 + + print("=" * 60) + print("Video Breakdown Agent — 交互式测试") + print("输入消息后回车发送,输入 q/quit/exit 退出") + print("=" * 60) + + while True: + try: + message = input("\n🧑 你: ").strip() + except (EOFError, KeyboardInterrupt): + print("\n👋 再见!") + break + + if not message: + continue + if message.lower() in ("q", "quit", "exit"): + print("👋 再见!") + break + + turn += 1 + print(f"\n⏳ 正在处理(第 {turn} 轮)...") + + try: + result = await runner.run( + messages=message, + user_id=user_id, + session_id=session_id, + ) + print(f"\n🤖 小视: {result}") + except Exception as e: + print(f"\n❌ 出错: {e}") + + +def _looks_like_raw_json(text: str) -> bool: + stripped = (text or "").strip() + return (stripped.startswith("{") and stripped.endswith("}")) or ( + stripped.startswith("[") and stripped.endswith("]") + ) + + +def _assert_case_output(case_name: str, output: str, expected_keywords: list[str]) -> None: + lowered = (output or "").lower() + if "<[plhd" in lowered or "transfer_to_agent" in lowered: + raise AssertionError(f"{case_name}: 检测到内部占位/转移片段泄露") + if _looks_like_raw_json(output): + raise AssertionError(f"{case_name}: 输出仍是原始 JSON") + if "我是search_agent" in (output or ""): + raise AssertionError(f"{case_name}: 对话未切回 root,仍由 search_agent 输出") + for keyword in expected_keywords: + if keyword not in output: + raise AssertionError(f"{case_name}: 未命中预期关键词 `{keyword}`") + + +async def run_pipeline_cases() -> None: + """本地/云端一致性回归用例(需要真实视频 URL 或本地测试视频)。""" + from agent import runner # noqa: E402 + + session_id = f"pipeline_case_{os.getpid()}" + user_id = "smoke_test_user" + + # 使用实际测试视频(如果项目内有 .media-uploads 中的测试样本) + test_video = os.getenv( + "TEST_VIDEO_URL", + "https://tos-cn-beijing.volces.com/obj/video-demo/sample.mp4" + ) + + # 检查本地测试视频 + local_test_videos = list(Path(PROJECT_ROOT / ".media-uploads").glob("*.mp4")) + if local_test_videos: + test_video = str(local_test_videos[0]) + print(f"Using local test video: {test_video}") + + cases = [ + ( + "case1_full_pipeline", + f"请对这个视频做完整分析并给出报告:{test_video}", + ["钩子分析", "报告"], + ), + ( + "case2_hook_only", + f"请分析这个视频前三秒钩子:{test_video}", + ["前三秒钩子分析", "综合评分"], + ), + ( + "case3_greeting", + "你好,介绍一下你的功能", + ["分镜", "钩子", "报告"], + ), + ( + "case4_search_then_identity", + "搜一下杭州这两天天气,然后回答:你是谁?", + ["小视"], + ), + ] + + print("=" * 60) + print("Video Breakdown Agent — Pipeline Cases") + print("=" * 60) + print(f"Test video: {test_video}") + print(f"Session: {session_id}") + + passed = 0 + failed = 0 + + for case_name, message, expected_keywords in cases: + print(f"\n[RUN] {case_name}") + print(f"Input: {message[:80]}...") + try: + output = await runner.run( + messages=message, + user_id=user_id, + session_id=session_id, + ) + output_str = str(output) + print(f"Output preview: {output_str[:300]}...") + _assert_case_output(case_name, output_str, expected_keywords) + print(f"✅ [PASS] {case_name}") + passed += 1 + except Exception as e: + print(f"❌ [FAIL] {case_name}: {e}") + failed += 1 + + print("\n" + "=" * 60) + print(f"Results: {passed} passed, {failed} failed") + print("=" * 60) + + if failed > 0: + sys.exit(1) + + +async def main() -> None: + # 切换到项目根目录,确保 config.yaml 能被 VeADK 读取 + os.chdir(PROJECT_ROOT) + + if len(sys.argv) > 1 and sys.argv[1] == "--pipeline-cases": + await run_pipeline_cases() + elif len(sys.argv) > 1: + # 单条消息模式 + message = " ".join(sys.argv[1:]) + await run_single(message) + else: + # 交互模式 + await run_interactive() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/README.md b/python/02-use-cases/partners/video_breakdown_agent/.skills/README.md new file mode 100644 index 00000000..1cffdb56 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/README.md @@ -0,0 +1,68 @@ +# Video Breakdown Agent - VeADK Skills + +基于 VeADK 原生 Skills 格式的视频分镜拆解技能集。自包含模式,无需外部后端服务。 + +## Skills 列表 + +| Skill | 目录 | 描述 | +|-------|------|------| +| **video-breakdown** | `video-breakdown-skill/` | 视频分镜拆解:FFmpeg 预处理 → 分段 → 帧提取 | +| **hook-analyzer** | `hook-analyzer-skill/` | 前三秒钩子分析:从分镜数据中提取前3秒,构造多模态分析上下文 | +| **report-generator** | `report-generator-skill/` | 分析报告生成:整合分镜+钩子数据,输出 Markdown 报告 | + +## 目录结构 + +``` +skills/ +├── README.md +├── video-breakdown-skill/ +│ ├── SKILL.md +│ └── scripts/ +│ ├── process_video.py # 视频预处理(FFmpeg,自包含) +│ └── video_upload.py # 上传本地视频到 TOS +├── hook-analyzer-skill/ +│ ├── SKILL.md +│ └── scripts/ +│ └── analyze_hook_segments.py # 提取前三秒分镜数据 +└── report-generator-skill/ + ├── SKILL.md + └── scripts/ + └── generate_report.py # 生成 Markdown 分析报告 +``` + +## 典型工作流 + +```bash +# 1. 视频预处理 +python video-breakdown-skill/scripts/process_video.py "https://example.com/video.mp4" > breakdown.json + +# 2. 提取前三秒钩子数据 +python hook-analyzer-skill/scripts/analyze_hook_segments.py breakdown.json > hook_context.json + +# 3. (LLM 评分后得到 hook_analysis.json) + +# 4. 生成完整报告 +python report-generator-skill/scripts/generate_report.py breakdown.json hook_analysis.json > report.md +``` + +## 前置要求 + +- FFmpeg:`brew install ffmpeg` +- Python 3.12+ +- httpx:`pip install httpx` + +## 环境变量 + +| 变量 | 用途 | 必需 | +|------|------|------| +| `FFMPEG_BIN` | FFmpeg 路径 | 否(默认 `ffmpeg`) | +| `FFPROBE_BIN` | FFprobe 路径 | 否(默认 `ffprobe`) | +| `VOLCENGINE_ACCESS_KEY` | 火山引擎 AK | 上传视频时 | +| `VOLCENGINE_SECRET_KEY` | 火山引擎 SK | 上传视频时 | +| `DATABASE_TOS_BUCKET` | TOS 存储桶 | 上传时(可选) | +| `DATABASE_TOS_REGION` | TOS 区域 | 上传时(可选) | + +## 参考 + +- [VeADK 内置工具文档](https://volcengine.github.io/veadk-python/tools/builtin/) +- [AgentKit Skills 示例](https://github.com/volcengine/agentkit-samples/tree/main/02-use-cases/agent_skills) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md b/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md new file mode 100644 index 00000000..8421df7b --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md @@ -0,0 +1,111 @@ +--- +name: hook-analyzer +description: 视频前三秒钩子分析技能。从分镜拆解结果中提取前三秒分镜数据,为钩子吸引力评估提供结构化上下文。运行脚本 `python scripts/analyze_hook_segments.py ""` 或通过 stdin 传入 JSON。输出包含前三秒分镜数量、总时长和每个分镜的详细信息(含关键帧 URL)。 +--- + +# 视频前三秒钩子分析 (Hook Analyzer) + +## 概述 + +视频前三秒钩子分析技能从分镜拆解结果中提取前三秒的分镜数据,并构造结构化的分析上下文,包含视觉内容描述、关键帧图片 URL、运镜方式等信息,为后续的 LLM 多维度评分分析提供输入。 + +## 适用场景 + +1. **短视频优化**:评估视频开头的吸引力,提升 3 秒留存率 +2. **创意评审**:从5个维度量化分析钩子质量 +3. **竞品对标**:对比不同视频开头的钩子策略 + +## 分析维度 + +本技能提取的数据供以下 5 个维度评分(由 LLM 完成评分): + +| 维度 | 权重 | 评估要点 | +|------|------|----------| +| 视觉冲击力 | 30% | 画面构图、色彩、光影、运镜动感 | +| 语言钩子 | 25% | 文案与画面配合度、悬念制造 | +| 情绪唤起 | 15% | 画面情绪、人物表情、氛围营造 | +| 信息密度 | 15% | 有效信息量、核心价值传达 | +| 节奏掌控 | 15% | 分镜切换节奏、平台特性适配 | + +## 使用步骤 + +### 方式一:从文件读取分镜数据 + +```bash +# 1. 先使用 video-breakdown skill 处理视频并获取分镜结果 +python ../video-breakdown-skill/scripts/process_video.py "https://example.com/video.mp4" > breakdown.json + +# 2. 提取前三秒分镜数据 +python scripts/analyze_hook_segments.py breakdown.json +``` + +### 方式二:通过 stdin 管道传入 + +```bash +cat breakdown.json | python scripts/analyze_hook_segments.py - +``` + +## 输出格式 + +```json +{ + "segment_count": 3, + "total_duration": 2.8, + "total_video_segments": 15, + "analysis_mode": "multimodal", + "segments": [ + { + "index": 0, + "start_time": 0.0, + "end_time": 1.0, + "duration": 1.0, + "visual_content": "画面描述", + "speech_text": "语音文字", + "shot_type": "特写", + "camera_movement": "推镜头", + "function_tag": "开场", + "frame_images": [ + {"type": "image_url", "image_url": {"url": "https://..."}} + ], + "frame_count": 3 + } + ] +} +``` + +## 钩子类型分类 + +分析结果可用于识别以下钩子类型: + +- **痛点型**:直击用户痛点,引发共鸣 +- **好奇型**:设置悬念,引发好奇 +- **冲突型**:制造对比或冲突吸引注意 +- **价值型**:直接展示价值承诺 +- **情感型**:以情感共鸣打动用户 +- **视觉冲击型**:通过强烈视觉效果吸引 +- **悬念型**:留下悬念引导继续观看 + +## 评分标准 + +| 分数段 | 等级 | 描述 | +|--------|------|------| +| 9-10 | 顶级 | 极强的吸引力和创意 | +| 7-8 | 优秀 | 具备良好的吸引力 | +| 5-6 | 一般 | 有改进空间 | +| 3-4 | 较弱 | 需要重大改进 | +| 1-2 | 很差 | 基本没有吸引力 | + +## 注意事项 + +1. 此脚本只做**数据提取**,不做 LLM 评分(评分由 Agent 的 LLM 完成) +2. 提取的 `frame_images` 字段中包含关键帧 URL,可供 Vision 模型直接分析 +3. 每个分镜最多取前 3 帧关键帧,避免 token 超限 +4. 输入必须是 `process_video` 返回的完整 JSON 数据 + +## 故障排除 + +| 问题 | 解决方案 | +|------|----------| +| 输出为空 segments | 确认输入 JSON 包含 `segments` 字段且不为空 | +| 前三秒无分镜 | 视频可能从静止画面开始,检查原始数据 | +| 关键帧 URL 失效 | TOS 签名 URL 可能已过期,重新拆解获取 | diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py new file mode 100644 index 00000000..898baad9 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py @@ -0,0 +1,115 @@ +""" +从分镜拆解结果中提取前三秒分镜数据,构造钩子分析上下文。 + +Usage: + python scripts/analyze_hook_segments.py + cat breakdown.json | python scripts/analyze_hook_segments.py - + +Examples: + python scripts/analyze_hook_segments.py breakdown_result.json + echo '{"segments":[...]}' | python scripts/analyze_hook_segments.py - +""" + +import json +import sys + + +def analyze_hook_segments(breakdown_data: dict) -> dict: + """ + 提取并分析视频前三秒的分镜数据,为钩子分析提供结构化上下文。 + + Args: + breakdown_data: 完整的分镜拆解结果(包含 segments 字段) + + Returns: + dict: 前三秒分镜的结构化分析上下文 + """ + segments = breakdown_data.get("segments", []) + + if not segments: + return { + "error": "没有分镜数据", + "segment_count": 0, + "total_duration": 0, + "segments": [], + } + + # 提取前三秒的分镜 + first_segments = [] + cumulative_time = 0 + + for seg in segments: + end_time = seg.get("end_time", 0) + if cumulative_time >= 3.0 and first_segments: + break + first_segments.append(seg) + cumulative_time = end_time + + # 构造分析上下文(支持多模态) + context = { + "segment_count": len(first_segments), + "total_duration": cumulative_time, + "total_video_segments": len(segments), + "analysis_mode": "multimodal", + "segments": [], + } + + # 为每个分镜构造详细信息 + for s in first_segments: + frame_urls = s.get("frame_urls", []) + + segment_info = { + "index": s.get("segment_index", 0), + "start_time": s.get("start_time", 0), + "end_time": s.get("end_time", 0), + "duration": s.get("duration", 0), + "visual_content": s.get("visual_content", ""), + "speech_text": s.get("speech_text", ""), + "shot_type": s.get("shot_type", ""), + "camera_movement": s.get("camera_movement", ""), + "function_tag": s.get("function_tag", ""), + "headline": s.get("headline", ""), + "content_tags": s.get("content_tags", []), + "voice_type": s.get("voice_type", ""), + "clip_url": s.get("clip_url", ""), + } + + # 关键帧图片(供 vision 模型使用),每个分镜最多取前3帧 + if frame_urls: + segment_info["frame_images"] = [ + {"type": "image_url", "image_url": {"url": url}} + for url in frame_urls[:3] + ] + segment_info["frame_count"] = len(frame_urls) + else: + segment_info["frame_images"] = [] + segment_info["frame_count"] = 0 + + context["segments"].append(segment_info) + + total_frames = sum(s.get("frame_count", 0) for s in context["segments"]) + print( + f"前三秒提取完成: {len(first_segments)}个分镜, " + f"总时长{cumulative_time:.1f}s, 关键帧{total_frames}张", + file=sys.stderr, + ) + + return context + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python analyze_hook_segments.py ") + print(" cat breakdown.json | python analyze_hook_segments.py -") + sys.exit(1) + + source = sys.argv[1] + + if source == "-": + data = json.load(sys.stdin) + else: + with open(source, "r", encoding="utf-8") as f: + data = json.load(f) + + result = analyze_hook_segments(data) + print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md new file mode 100644 index 00000000..db2d13b3 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md @@ -0,0 +1,140 @@ +--- +name: report-generator +description: 视频分析报告生成技能。整合分镜拆解数据和钩子分析结果,生成 Markdown 格式的专业视频分析报告。运行脚本 `python scripts/generate_report.py [hook_analysis_json]`,输出完整的 Markdown 报告,包含基本信息、前三秒钩子分析、分镜概览、BGM分析、场景分析等章节。 +--- + +# 视频分析报告生成 (Report Generator) + +## 概述 + +视频分析报告生成技能将分镜拆解数据和(可选的)钩子分析结果整合为一份专业的 Markdown 格式分析报告。报告包含视频基本信息、前三秒钩子评分、分镜概览表格、BGM 分析、场景分析和平台推荐等章节。 + +## 适用场景 + +1. **视频分析交付**:为客户或团队生成完整的视频分析文档 +2. **创意复盘**:生成结构化的视频内容复盘报告 +3. **竞品报告**:批量生成竞品视频分析报告 + +## 使用步骤 + +### 完整报告(分镜 + 钩子分析) + +```bash +# 1. 准备分镜拆解数据和钩子分析数据(JSON 文件) +# 2. 生成报告 +python scripts/generate_report.py breakdown.json hook_analysis.json + +# 3. 保存到文件 +python scripts/generate_report.py breakdown.json hook_analysis.json > report.md +``` + +### 仅分镜报告(无钩子分析) + +```bash +python scripts/generate_report.py breakdown.json +``` + +## 报告结构 + +生成的报告包含以下章节: + +```markdown +# 视频分析报告 + +## 基本信息 +- 视频时长、分镜数量、分辨率 + +## 前三秒钩子分析(核心) +- 综合评分 +- 5维度评分表格 +- 钩子类型 +- 优势/不足/优化建议 +- 留存预测 + +## 分镜概览 +- 前10个分镜的概览表格 + +## BGM 分析 +- 音乐风格、情绪基调、节拍 + +## 场景分析 +- 主要场景、视频风格、目标受众 +- 平台推荐 + +报告生成时间 +``` + +## 输入格式 + +### breakdown.json(必需) + +```json +{ + "duration": 30.5, + "segment_count": 12, + "resolution": "1920x1080", + "segments": [...], + "bgm_analysis": { + "music_style": {"primary": "流行"}, + "emotion": {"primary": "欢快"}, + "tempo": {"bpm_estimate": 120, "pace": "中速"} + }, + "scene_analysis": { + "primary_scene": "室内", + "video_style": {"overall": "生活方式", "target_audience": ["年轻人"]}, + "platform_recommendations": [...] + } +} +``` + +### hook_analysis.json(可选) + +```json +{ + "overall_score": 7.5, + "visual_impact": 8.0, + "visual_comment": "评价...", + "language_hook": 7.0, + "language_comment": "评价...", + "emotion_trigger": 7.5, + "emotion_comment": "评价...", + "information_density": 7.0, + "info_comment": "评价...", + "rhythm_control": 8.0, + "rhythm_comment": "评价...", + "hook_type": "好奇型", + "strengths": ["优点1", "优点2"], + "weaknesses": ["不足1"], + "suggestions": ["建议1", "建议2"], + "retention_prediction": "中:50-70%,因为..." +} +``` + +## 输出格式 + +Markdown 格式的完整报告文本,直接输出到 stdout。 + +## 示例 + +```bash +# 完整流程 +python ../video-breakdown-skill/scripts/process_video.py "https://example.com/video.mp4" > breakdown.json +cat breakdown.json | python ../hook-analyzer-skill/scripts/analyze_hook_segments.py - > hooks.json +# (hooks.json 需经 LLM 评分后得到 hook_analysis.json) +python scripts/generate_report.py breakdown.json hook_analysis.json > report.md +``` + +## 注意事项 + +1. 如果缺少 hook_analysis 数据,报告中钩子分析章节将显示"暂无数据" +2. 分镜概览最多展示前 10 个分镜 +3. 报告自动添加生成时间戳 +4. 画面内容描述超过 40 字符会自动截断 + +## 故障排除 + +| 问题 | 解决方案 | +|------|----------| +| 钩子分析为空 | 确认传入了 hook_analysis.json 文件 | +| 分镜表格为空 | 确认 breakdown.json 包含 segments 字段 | +| BGM/场景显示 N/A | 分镜拆解服务可能未返回此数据 | diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py new file mode 100644 index 00000000..42065e5f --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py @@ -0,0 +1,204 @@ +""" +整合分镜拆解数据和钩子分析结果,生成 Markdown 视频分析报告。 + +Usage: + python scripts/generate_report.py [hook_analysis_json] + +Examples: + python scripts/generate_report.py breakdown.json hook_analysis.json + python scripts/generate_report.py breakdown.json + python scripts/generate_report.py breakdown.json hook.json > report.md +""" + +import json +import sys +from datetime import datetime + + +def generate_video_report(breakdown_data: dict, hook_analysis: dict = None) -> str: + """ + 整合分镜拆解结果和钩子分析结果,生成 Markdown 视频分析报告。 + + Args: + breakdown_data: 分镜拆解的完整结果数据 + hook_analysis: 前三秒钩子分析结果(可选) + + Returns: + str: Markdown 格式报告 + """ + if hook_analysis is None: + hook_analysis = {} + + duration = breakdown_data.get("duration", 0) + segment_count = breakdown_data.get("segment_count", 0) + resolution = breakdown_data.get("resolution", "N/A") + bgm = breakdown_data.get("bgm_analysis") or {} + scene = breakdown_data.get("scene_analysis") or {} + + # BGM 信息 + music_style = bgm.get("music_style", {}).get("primary", "N/A") if bgm else "N/A" + emotion = bgm.get("emotion", {}).get("primary", "N/A") if bgm else "N/A" + tempo = bgm.get("tempo", {}).get("bpm_estimate", "N/A") if bgm else "N/A" + tempo_pace = bgm.get("tempo", {}).get("pace", "N/A") if bgm else "N/A" + + # 场景信息 + primary_scene = scene.get("primary_scene", "N/A") if scene else "N/A" + video_style = scene.get("video_style", {}).get("overall", "N/A") if scene else "N/A" + target_audience = ( + ", ".join(scene.get("video_style", {}).get("target_audience", [])) + if scene else "N/A" + ) + + hook_section = _build_hook_section(hook_analysis) + platform_section = _build_platform_section(scene) + segments_section = _build_segments_overview(breakdown_data.get("segments", [])) + + report = f"""# 视频分析报告 + +## 基本信息 +- **视频时长**: {duration:.1f}秒 +- **分镜数量**: {segment_count}个 +- **分辨率**: {resolution} + +--- + +{hook_section} + +--- + +## 分镜概览 + +{segments_section} + +--- + +## BGM 分析 +- **音乐风格**: {music_style} +- **情绪基调**: {emotion} +- **节拍**: {tempo} BPM({tempo_pace}节奏) + +--- + +## 场景分析 +- **主要场景**: {primary_scene} +- **视频风格**: {video_style} +- **目标受众**: {target_audience} + +{platform_section} + +--- + +**报告生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + return report + + +def _build_hook_section(hook_analysis: dict) -> str: + if not hook_analysis: + return "## 前三秒钩子分析\n\n暂无钩子分析数据。" + + overall = hook_analysis.get("overall_score", 0) + hook_type = hook_analysis.get("hook_type", "N/A") + retention = hook_analysis.get("retention_prediction", "N/A") + + scores_table = f"""| 维度 | 得分 | 评价 | +|------|------|------| +| 视觉冲击力 | {hook_analysis.get('visual_impact', 0)}/10 | {hook_analysis.get('visual_comment', '')} | +| 语言钩子 | {hook_analysis.get('language_hook', 0)}/10 | {hook_analysis.get('language_comment', '')} | +| 情绪唤起 | {hook_analysis.get('emotion_trigger', 0)}/10 | {hook_analysis.get('emotion_comment', '')} | +| 信息密度 | {hook_analysis.get('information_density', 0)}/10 | {hook_analysis.get('info_comment', '')} | +| 节奏掌控 | {hook_analysis.get('rhythm_control', 0)}/10 | {hook_analysis.get('rhythm_comment', '')} |""" + + strengths = hook_analysis.get("strengths", []) + weaknesses = hook_analysis.get("weaknesses", []) + suggestions = hook_analysis.get("suggestions", []) + + strengths_text = "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" + weaknesses_text = "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + suggestions_text = ( + "\n".join(f"{i + 1}. {s}" for i, s in enumerate(suggestions)) + if suggestions else "1. 暂无" + ) + + return f"""## 前三秒钩子分析(核心) + +### 综合评分: {overall}/10 + +{scores_table} + +### 钩子类型 +**{hook_type}** + +### 优势 +{strengths_text} + +### 不足 +{weaknesses_text} + +### 优化建议 +{suggestions_text} + +### 留存预测 +**{retention}**""" + + +def _build_platform_section(scene: dict) -> str: + if not scene: + return "" + recommendations = scene.get("platform_recommendations", []) + if not recommendations: + return "" + lines = ["### 平台推荐"] + for rec in recommendations: + platform = rec.get("platform", "N/A") + suitability = rec.get("suitability", "N/A") + reason = rec.get("reason", "") + lines.append(f"- **{platform}**(适合度: {suitability}): {reason}") + return "\n".join(lines) + + +def _build_segments_overview(segments: list) -> str: + if not segments: + return "暂无分镜数据。" + + lines = [ + "| 镜号 | 时间 | 景别 | 运镜 | 功能标签 | 画面内容 |", + "|------|------|------|------|----------|----------|", + ] + + for seg in segments[:10]: + index = seg.get("segment_index", "-") + start = seg.get("start_time", 0) + end = seg.get("end_time", 0) + time_range = f"{start:.1f}s-{end:.1f}s" + shot_type = seg.get("shot_type", "-") + camera = seg.get("camera_movement", "-") + func_tag = seg.get("function_tag", "-") + visual = seg.get("visual_content", "-") + if len(visual) > 40: + visual = visual[:37] + "..." + lines.append( + f"| {index} | {time_range} | {shot_type} | {camera} | {func_tag} | {visual} |" + ) + + if len(segments) > 10: + lines.append(f"\n*(仅展示前10个分镜,共{len(segments)}个)*") + + return "\n".join(lines) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python generate_report.py [hook_analysis_json]") + sys.exit(1) + + with open(sys.argv[1], "r", encoding="utf-8") as f: + bd_data = json.load(f) + + hook_data = None + if len(sys.argv) > 2: + with open(sys.argv[2], "r", encoding="utf-8") as f: + hook_data = json.load(f) + + report = generate_video_report(bd_data, hook_data) + print(report) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md new file mode 100644 index 00000000..5a953b8f --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md @@ -0,0 +1,70 @@ +--- +name: video-breakdown +description: 视频分镜拆解技能(自包含,无需外部后端)。使用 FFmpeg 预处理视频并生成分镜数据。(1) 视频 URL 直接处理 `python scripts/process_video.py ""`;(2) 本地文件先上传 `python scripts/video_upload.py ""` 获取 URL 后再处理。需要本机安装 FFmpeg。 +--- + +# 视频分镜拆解 (Video Breakdown) + +## 概述 + +视频分镜拆解技能通过本机 FFmpeg 将视频自动拆解为逐帧分镜,提供每个镜头的关键帧图片和时间信息。无需外部后端服务。 + +## 前置要求 + +- 本机安装 FFmpeg:`brew install ffmpeg` + +## 使用步骤 + +### 方式一:视频 URL 直接处理 + +```bash +python scripts/process_video.py "https://example.com/video.mp4" +``` + +### 方式二:本地文件上传后处理 + +```bash +# 1. 上传本地视频到 TOS +python scripts/video_upload.py "/path/to/video.mp4" + +# 2. 用返回的 video_url 处理 +python scripts/process_video.py "" +``` + +## 环境变量 + +| 变量名 | 必需 | 描述 | +|--------|------|------| +| `FFMPEG_BIN` | 否 | FFmpeg 路径,默认 `ffmpeg` | +| `FFPROBE_BIN` | 否 | FFprobe 路径,默认 `ffprobe` | +| `VOLCENGINE_ACCESS_KEY` | 上传时需要 | 火山引擎 Access Key | +| `VOLCENGINE_SECRET_KEY` | 上传时需要 | 火山引擎 Secret Key | +| `DATABASE_TOS_BUCKET` | 否 | TOS 存储桶名称 | +| `DATABASE_TOS_REGION` | 否 | TOS 区域,默认 `cn-beijing` | + +## 输出格式 + +```json +{ + "task_id": "xxx", + "duration": 30.5, + "resolution": "1920x1080", + "segment_count": 12, + "segments": [ + { + "index": 1, + "start": 0.0, + "end": 3.0, + "frame_paths": ["path/to/frame.jpg"] + } + ] +} +``` + +## 故障排除 + +| 问题 | 解决方案 | +|------|----------| +| FFmpeg not found | 安装 FFmpeg: `brew install ffmpeg` | +| 上传失败 | 检查 AK/SK 配置和 TOS 存储桶 | +| 视频下载失败 | 确认 URL 有效且可公开访问 | diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py new file mode 100644 index 00000000..67b0e50e --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py @@ -0,0 +1,148 @@ +""" +视频预处理脚本(独立可执行)。 +自包含视频预处理,无需外部后端服务。 +需要本机安装 FFmpeg。 + +Usage: + python scripts/process_video.py "" + +Env: + FFMPEG_BIN, FFPROBE_BIN, VOLCENGINE_ACCESS_KEY, VOLCENGINE_SECRET_KEY, + VOLC_ASR_APP_ID, VOLC_ASR_ACCESS_KEY, VOLC_ASR_RESOURCE_ID, + DATABASE_TOS_BUCKET (或 TOS_BUCKET), DATABASE_TOS_REGION (或 TOS_REGION) +""" + +import asyncio +import json +import os +import shutil +import subprocess +import sys +import tempfile +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import httpx + + +def _run_command(cmd: List[str]) -> str: + process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + return process.stdout.strip() + + +def _probe_video(ffprobe_bin: str, video_path: Path) -> Dict[str, Any]: + cmd = [ffprobe_bin, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(video_path)] + result = _run_command(cmd) + info = json.loads(result) if result else {} + fmt = info.get("format") or {} + duration = float(fmt.get("duration", 0)) + w, h, fr = None, None, None + for s in info.get("streams", []): + if s.get("codec_type") == "video": + w, h, fr = s.get("width"), s.get("height"), s.get("r_frame_rate") + break + return {"duration": duration, "width": w, "height": h, "frame_rate": fr, "size": fmt.get("size")} + + +def _build_segments(duration: float): + segments = [] + idx = 1 + bps = [0.0, 3.0, 5.0, 10.0, 20.0] + for i in range(len(bps) - 1): + if duration <= bps[i]: + break + end = min(bps[i + 1], duration) + if end - bps[i] < 0.5: + break + segments.append({"index": idx, "start": bps[i], "end": end}) + idx += 1 + if duration > 20.0: + c = 20.0 + while c < duration: + e = min(duration, c + 10.0) + if e - c < 0.5: + break + segments.append({"index": idx, "start": c, "end": e}) + idx += 1 + c = e + return segments + + +def _extract_frames(ffmpeg_bin: str, video_path: Path, segments, fps=3): + frames_dir = video_path.parent / "frames" + frames_dir.mkdir(exist_ok=True) + for seg in segments: + seg["frame_paths"] = [] + dur = seg["end"] - seg["start"] + for i in range(fps): + ratio = i / max(fps - 1, 1) + offset = min(seg["start"] + ratio * dur, seg["end"] - 0.1) + offset = max(offset, seg["start"]) + out = frames_dir / f"seg{seg['index']:03d}_frame_{i}.jpg" + cmd = [ffmpeg_bin, "-y", "-ss", f"{offset:.2f}", "-i", str(video_path), "-frames:v", "1", "-q:v", "5", str(out)] + try: + _run_command(cmd) + if out.exists(): + seg["frame_paths"].append(str(out)) + except Exception: + pass + + +async def main(video_url: str): + ffmpeg_bin = os.getenv("FFMPEG_BIN", "ffmpeg") + ffprobe_bin = os.getenv("FFPROBE_BIN", "ffprobe") + temp_dir = Path(tempfile.mkdtemp(prefix="vba_")) + task_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8] + local_video = temp_dir / f"{task_id}.mp4" + + try: + # Download + print(f"[1/4] 下载视频...", file=sys.stderr) + async with httpx.AsyncClient(timeout=300, follow_redirects=True) as c: + r = await c.get(video_url) + r.raise_for_status() + local_video.write_bytes(r.content) + + # Metadata + print(f"[2/4] 提取元数据...", file=sys.stderr) + meta = _probe_video(ffprobe_bin, local_video) + dur = meta["duration"] + if dur <= 0: + print(json.dumps({"error": "无法获取视频时长"})) + return + + # Segments + print(f"[3/4] 构建分镜 ({dur:.1f}s)...", file=sys.stderr) + segments = _build_segments(dur) + + # Frames + print(f"[4/4] 提取关键帧...", file=sys.stderr) + _extract_frames(ffmpeg_bin, local_video, segments) + + result = { + "task_id": task_id, + "duration": round(dur, 2), + "resolution": f"{meta.get('width')}x{meta.get('height')}", + "segment_count": len(segments), + "segments": [ + { + "index": s["index"], + "start": round(s["start"], 2), + "end": round(s["end"], 2), + "frame_paths": s.get("frame_paths", []), + } + for s in segments + ], + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python process_video.py ", file=sys.stderr) + sys.exit(1) + asyncio.run(main(sys.argv[1])) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py new file mode 100644 index 00000000..0d7bf8c4 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py @@ -0,0 +1,133 @@ +""" +上传本地视频文件到火山引擎 TOS 对象存储,返回签名 URL。 + +Usage: + python scripts/video_upload.py "" [bucket_name] + +Examples: + python scripts/video_upload.py "/path/to/video.mp4" + python scripts/video_upload.py "/path/to/video.mp4" "my-bucket" +""" + +import json +import os +import sys +from datetime import datetime + +import tos +from tos import HttpMethodType + +DEFAULT_BUCKET = "video-breakdown-uploads" +DEFAULT_REGION = "cn-beijing" + + +def video_upload_to_tos(file_path: str, bucket_name: str = None) -> dict: + """ + 将本地视频文件上传到 TOS,返回签名 URL。 + + Args: + file_path: 本地视频文件路径 + bucket_name: TOS 存储桶名称(可选) + + Returns: + dict: 包含 video_url 或 error + """ + if bucket_name is None: + bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", DEFAULT_BUCKET) + region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", DEFAULT_REGION) + + # 检查文件 + if not os.path.exists(file_path): + return {"error": f"文件不存在: {file_path}"} + if not os.path.isfile(file_path): + return {"error": f"路径不是文件: {file_path}"} + + file_size = os.path.getsize(file_path) + max_size = 2 * 1024 * 1024 * 1024 # 2GB + if file_size > max_size: + return {"error": f"文件过大({file_size / 1024 / 1024:.0f}MB),最大支持 2GB"} + + # 获取凭证 + access_key = os.getenv("VOLCENGINE_ACCESS_KEY", "") + secret_key = os.getenv("VOLCENGINE_SECRET_KEY", "") + session_token = "" + + if not access_key or not secret_key: + try: + from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() + access_key = cred.access_key_id + secret_key = cred.secret_access_key + session_token = cred.session_token + except Exception: + pass + + if not access_key or not secret_key: + return {"error": "缺少 TOS 访问凭证,请设置 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY"} + + # 自动生成 object_key + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.basename(file_path) + object_key = f"video_breakdown/upload/{timestamp}_{filename}" + + # 上传 + client = None + try: + endpoint = f"tos-{region}.volces.com" + client = tos.TosClientV2( + ak=access_key, + sk=secret_key, + security_token=session_token, + endpoint=endpoint, + region=region, + ) + + # 检查桶 + try: + client.head_bucket(bucket_name) + except tos.exceptions.TosServerError as e: + if e.status_code == 404: + return {"error": f"TOS 存储桶 {bucket_name} 不存在"} + raise + + print(f"上传中: {file_path} -> {bucket_name}/{object_key}", file=sys.stderr) + result = client.put_object_from_file( + bucket=bucket_name, key=object_key, file_path=file_path + ) + + # 生成签名 URL(7天有效) + signed_url_output = client.pre_signed_url( + http_method=HttpMethodType.Http_Method_Get, + bucket=bucket_name, + key=object_key, + expires=604800, + ) + + return { + "video_url": signed_url_output.signed_url, + "bucket": bucket_name, + "object_key": object_key, + "file_size_mb": round(file_size / 1024 / 1024, 2), + "message": "上传成功!使用 video_url 调用 process_video.py 进行视频分镜分析", + } + + except tos.exceptions.TosClientError as e: + return {"error": f"TOS 客户端错误: {e}"} + except tos.exceptions.TosServerError as e: + return {"error": f"TOS 服务端错误: {e.message}"} + except Exception as e: + return {"error": f"上传失败: {str(e)}"} + finally: + if client: + client.close() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python video_upload.py [bucket_name]") + sys.exit(1) + + path = sys.argv[1] + bucket = sys.argv[2] if len(sys.argv) > 2 else None + result = video_upload_to_tos(path, bucket) + print(json.dumps(result, ensure_ascii=False, indent=2)) diff --git a/python/02-use-cases/partners/video_breakdown_agent/LICENSE b/python/02-use-cases/partners/video_breakdown_agent/LICENSE new file mode 100644 index 00000000..67db8588 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/python/02-use-cases/partners/video_breakdown_agent/README.md b/python/02-use-cases/partners/video_breakdown_agent/README.md new file mode 100644 index 00000000..27318747 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/README.md @@ -0,0 +1,504 @@ +# 智能短视频拆解分析助手 + +## 概述 + +这是一个基于火山引擎 VeADK & AgentKit 构建的智能短视频分析系统。本系统采用 Multi-Agent 架构,集成 FFmpeg 视频处理、火山 ASR 语音识别、LiteLLM 多模态视觉分析和 TOS 对象存储,能够对短视频进行全方位的专业分析。 + +## 核心功能 + +本项目提供以下核心功能: + +- **视频分镜拆解**:基于 FFmpeg 自动识别视频分镜,提取关键帧并进行画面内容分析,输出结构化分镜数据 +- **前三秒钩子分析**:从视觉冲击力、语言钩子、情绪唤起、信息密度、节奏掌控 5 个维度进行专业评分 +- **专业报告生成**:整合分镜数据和钩子分析结果,生成 Markdown 格式的完整分析报告 +- **联网搜索**:实时获取短视频行业最新资讯、平台规则和热门趋势 + +## 🎯 核心解决场景 + +本系统致力于解决短视频创作与运营中的以下痛点: + +- **📉 前三秒流失率高**:通过 **Hook Analyzer** 深度拆解开头黄金3秒,量化视觉/听觉/内容钩子质量,提供针对性优化建议。 +- **😵 卖点传递不清**:通过 **分镜拆解** 梳理叙事逻辑,确保产品核心卖点在关键时间窗内有效传递。 +- **🐢 竞品分析效率低**:自动化提取竞品分镜结构与拍摄手法,快速复刻爆款视频公式。 +- **📝 脚本创作没灵感**:基于优秀视频反推分镜脚本,为新视频创作提供结构化参考。 + +## 使用案例 + +以下展示了从联网搜索到视频深度拆解的典型使用流程。 + +### 1. 联网搜索(天气/行业资讯) + +**用户:** 杭州天气 + +**小视(Root Agent):** 根据搜索结果,杭州当前天气及未来几天预报如下: + +📍 **杭州市天气(2026年2月11日 星期三)** + +**当前实况** +- **温度**:13℃(当前),全天5~15℃ +- **天气**:晴 +- **风向**:北风4级(午后转西风) +- **湿度**:36% +- **空气质量**:AQI 64(良),主要污染物PM2.5 + +**📅 未来三天预报** + +| 日期 | 天气 | 气温范围 | 风向风力 | +| :--- | :--- | :--- | :--- | +| 2月11日(周三) | 晴 → 晴 | 5~15℃ | 北风转西风 2-4级 | +| 2月12日(周四) | 晴 → 晴 | 6~16℃ | 东南风 2级 | +| 2月13日(周五) | 多云 | 8~20℃ | 南风 2级 | + +**🌡️ 生活指数参考** +- **洗车指数**:今日适宜(未来两天无雨) +- **运动指数**:今日不适宜剧烈运动(气压偏低),明日适宜 +- **紫外线**:弱(建议做好基础防晒) + +--- + +### 2. 视频分镜拆解 + +**用户:** http://example.com/sample-video.mp4 + +**小视(Root Agent):** 好的,我将为您拆解该视频的分镜结构。 + +#### 🎥 视频分镜拆解结果 + +**视频信息** +- **时长**:17.07秒 +- **分辨率**:720x960 +- **帧速率**:60fps + +**分镜列表** + +| 序号 | 时间段 | 景别 | 运镜 | 画面内容描述 | 语音内容 | 功能标签 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | 0.0-3.0s | 特写 | 固定 | 单手持米白色乐扣水杯展示整体,背景有日历、英文装饰画;过渡到双手操作杯盖提手。 | 无 | 产品展示 | +| 2 | 3.0-5.0s | 特写 | 固定 | 美甲双手打开杯盖,水流注入杯中;背景从英文装饰牌切换为日历场景。 | 无 | 产品展示 | +| 3 | 5.0-10.0s | 近景 | 固定 | 水杯接水后,美甲手握住盖好的水杯;背景含卡通图案布、日历等。 | 无 | 产品展示 | +| 4 | 10.0-17.07s | 近景 | 固定 | 先展示白色乐扣保温杯(带提手),后切换为透明条纹水杯,背景风格一致。 | 无 | 产品展示 | + +**BGM 分析** +- **是否有 BGM**:是 +- **风格**:轻音乐(纯音乐/氛围),标签:舒缓、治愈、简约 +- **情绪**:轻松愉悦(正面),强度中等 +- **乐器**:钢琴(主导)、小提琴、合成器、轻柔打击乐 +- **节奏**:60-80 BPM(慢节奏),平稳4/4拍 + +**完整语音文本** +- 无语音内容(ASR未识别到文字) + +etc. + +--- + +### 3. 前三秒钩子评测 + +**用户:** 详细分析评测视频前三秒钩子 + +**小视(Root Agent):** + +#### 🪝 视频前三秒钩子分析评测 + +**钩子核心要素拆解** +- **视觉钩子(0-3秒)**:特写镜头聚焦产品提手设计,通过“握持-提动”动作强化产品便捷性认知,背景生活化,美甲细节增加精致感。 +- **听觉钩子(0-3秒)**:舒缓钢琴+小提琴轻音乐(60-80 BPM),营造治愈氛围,降低用户跳出率。 +- **内容钩子(0-3秒)**:3秒内完成“是什么→怎么用”的认知闭环,直击“开合便利性”需求。 + +**钩子有效性评测** + +| 维度 | 评分(1-10) | 分析 | +| :--- | :--- | :--- | +| 视觉吸引力 | 8.5 | 特写聚焦产品,动作连贯无冗余。 | +| 信息传递效率 | 9.0 | 3秒内完成核心信息传递,符合认知习惯。 | +| 情绪感染力 | 7.5 | 氛围舒缓但记忆点略显不足。 | + +--- + +etc. + +### 4. 生成视频洞察报告 + +**用户:** 给出详细的视频洞察报告 + +**小视(Root Agent):** + +#### 📊 乐扣水杯产品展示视频洞察报告 + +**一、内容结构分析** +视频采用“整体→细节→场景→延伸”的递进逻辑,4个分镜形成完整产品认知链。 + +**二、用户注意力分析** +- **高注意力时段**:0-5秒(动作变化频繁,视觉焦点明确)。 +- **风险点**:10-17秒(产品切换缺乏过渡,易导致认知断层)。 + +**三、转化潜力评估** +- **优势**:符合“黄金3秒”法则,多产品展示覆盖不同需求。 +- **不足**:未明确材质安全、保温时长等核心卖点。 + +**四、优化建议** +- **视觉层**:增加“防漏测试”特写。 +- **听觉层**:加入杯盖开合的清脆音效增强记忆。 +- **内容层**:叠加“食品级304”等文字标签。 + +etc. + +## Agent 能力 + +![Video Breakdown Agent with AgentKit Runtime](assets/architecture_video_breakdown_agent.jpg) + +```text +用户输入(视频URL/本地文件) + ↓ +AgentKit 运行时 + ↓ +Root Agent(小视 - 主编排器) + ├── Breakdown Agent(分镜拆解) + │ ├── FFmpeg 视频预处理 + │ ├── 火山 ASR 语音识别 + │ ├── LiteLLM 视觉分析 + │ └── BGM 分析 + ├── Hook Analyzer Agent(钩子分析) + │ ├── 前三秒分镜提取 + │ ├── 多模态视觉评分 + │ └── JSON 格式化 + ├── Report Generator Agent(报告生成) + └── Search Agent(联网搜索) +``` + +主要的火山引擎产品或 Agent 组件: + +- 方舟大模型: + - doubao-seed-1-6-251015(主推理模型) + - doubao-seed-1-6-vision-250815(视觉分析模型) +- TOS 对象存储 +- 火山 ASR 语音识别(可选) +- Web Search 联网搜索 +- AgentKit +- APMPlus(可选可观测性) + +第三方依赖: + +- FFmpeg(通过 imageio-ffmpeg 自动打包,无需手动安装) +- LiteLLM(支持 Gemini、豆包、GPT-4o 等多种视觉模型) + +## 目录结构说明 + +```bash +video_breakdown_agent/ +├── README.md # 项目说明文档 +├── README_CONFIG.md # 详细配置指南 +├── project.toml # 应用广场元数据 +├── agent.py # AgentKit 部署入口 +├── requirements.txt # pip 依赖清单 +├── pyproject.toml # uv 项目配置 +├── config.yaml # 配置文件(示例,实际密钥通过环境变量注入) +├── config.yaml.example # 配置模板 +├── deploy.sh # 部署脚本 +├── video_breakdown_agent/ # Python 包(核心代码) +│ ├── agent.py # Root Agent 定义 +│ ├── prompt.py # 主编排 Prompt +│ ├── sub_agents/ # 子 Agent +│ │ ├── breakdown_agent/ # 分镜拆解 Agent +│ │ ├── hook_analyzer_agent/# 钩子分析 Agent(SequentialAgent) +│ │ └── report_generator_agent/ # 报告生成 Agent +│ ├── tools/ # 工具函数 +│ │ ├── process_video.py # 视频预处理(FFmpeg + ASR) +│ │ ├── analyze_segments_vision.py # 视觉分析 +│ │ ├── analyze_bgm.py # BGM 分析 +│ │ ├── analyze_hook_segments.py # 钩子分镜提取 +│ │ ├── report_generator.py # 报告生成 +│ │ └── video_upload.py # TOS 视频上传 +│ ├── hook/ # Callback 钩子 +│ │ ├── format_hook.py # JSON 修复 +│ │ └── video_upload_hook.py# 文件上传拦截 +│ └── utils/ # 工具类 +│ └── types.py # Pydantic 数据模型 +└── img/ # 架构图和截图 +``` + +## 本地运行 + +### 前置准备 + +**Python 版本:** + +- Python 3.12 或更高版本 + +**1. 开通火山方舟模型服务:** + +- 访问 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview) +- 进入"开通管理" → "语言模型" → 找到以下模型 → 点击"开通服务" + - `doubao-seed-1-6-251015`(主推理模型) + - `doubao-seed-1-6-vision-250815`(视觉分析模型,可选) +- 确认开通,等待服务生效(通常 1-2 分钟) + +**2. 获取火山引擎访问凭证:** + +- 登录 [火山引擎控制台](https://console.volcengine.com) +- 进入"访问控制" → "用户" → 选择用户 → "密钥" → 新建密钥或复制已有 AK/SK +- 为用户配置权限: + - `AgentKitFullAccess`(AgentKit 全量权限) + - `APMPlusServerFullAccess`(APMPlus 全量权限,可选) + +**3. 获取火山方舟模型 API Key:** + +- 登录 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey) +- 进入"API Key 管理" → 创建或复制已有 API Key + +**4. 创建 TOS 存储桶:** + +- 访问 [TOS 控制台](https://console.volcengine.com/tos/bucket) +- 点击"创建桶" → 填写桶名称 → 选择区域(建议 cn-beijing)→ 创建 +- 记录桶名称,后续在环境变量 `DATABASE_TOS_BUCKET` 中填入 + +**5. 可选:开通火山 ASR 语音识别服务:** + +> 如未配置 ASR,系统会跳过语音识别,仍可完成分镜拆解和视觉分析 + +- 访问 [火山语音服务控制台](https://console.volcengine.com/speech/service/list) +- 创建应用并获取 `APP_ID` 和 `ACCESS_KEY` + +### 依赖安装 + +#### 1. 安装 uv 包管理器 + +```bash +# macOS / Linux(官方安装脚本) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 或使用 Homebrew(macOS) +brew install uv +``` + +#### 2. 初始化项目依赖 + +```bash +# 进入项目目录 +cd 02-use-cases/video_breakdown_agent +``` + +您可以通过 `pip` 工具来安装本项目依赖: + +```bash +pip install -r requirements.txt +``` + +或者使用 `uv` 工具来安装本项目依赖(推荐): + +```bash +# 如果没有 `uv` 虚拟环境,可以使用命令先创建一个虚拟环境 +uv venv --python 3.12 + +# 使用 `pyproject.toml` 管理依赖 +uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple + +# 激活虚拟环境 +source .venv/bin/activate +``` + +### 环境准备 + +设置以下环境变量: + +```bash +# 火山方舟模型 API Key(必需) +export MODEL_AGENT_API_KEY= + +# 火山引擎访问凭证(必需) +export VOLCENGINE_ACCESS_KEY= +export VOLCENGINE_SECRET_KEY= + +# TOS 存储桶名称(必需) +export DATABASE_TOS_BUCKET= +export DATABASE_TOS_REGION=cn-beijing + +# 可选:火山 ASR 配置(未配置时跳过语音识别) +export ASR_APP_ID= +export ASR_ACCESS_KEY= + +# 可选:切换视觉分析模型(默认使用豆包 Vision) +export MODEL_VISION_NAME=doubao-seed-1-6-vision-250815 +# 或切换为 Google Gemini(需配置 GEMINI_API_KEY) +# export MODEL_VISION_NAME=gemini/gemini-2.5-pro +# export GEMINI_API_KEY= +``` + +> 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) + +### 调试方法 + +使用 `veadk web` 进行本地调试: + +```bash +# 进入 02-use-cases 目录 +cd 02-use-cases + +# 启动 VeADK Web 界面 +veadk web --port 8080 + +# 在浏览器访问:http://127.0.0.1:8080 +``` + +Web 界面提供图形化对话测试环境,支持实时查看消息流和调试信息。 + +### 示例提示词 + +```text +帮我拆解这个视频的分镜结构 https://example.com/video.mp4 +分析这个视频前三秒的钩子吸引力,给出专业评分 +生成完整的视频分析报告 +搜一下抖音最新推荐算法有什么变化 +``` + +### 效果展示 + +> 建议补充实际运行截图展示分镜拆解、钩子分析和报告生成效果 + +## AgentKit 部署 + +### 前置准备 + +**重要提示**:在运行本示例之前,请先访问 [AgentKit 控制台授权页面](https://console.volcengine.com/agentkit/region:agentkit+cn-beijing/auth?projectName=default) 对所有依赖服务进行授权,确保案例能够正常执行。 + +参考"本地运行"部分的"前置准备"。 + +### 依赖安装 + +> 如果您本地已经安装了该依赖,跳过此步骤。 + +使用 `pip` 安装 AgentKit 命令行工具: + +```bash +pip install agentkit-sdk-python==0.3.2 +``` + +或者使用 `uv` 安装 AgentKit 命令行工具: + +```bash +uv pip install agentkit-sdk-python==0.3.2 +``` + +### 设置环境变量 + +```bash +# 火山引擎访问凭证(必需) +export VOLCENGINE_ACCESS_KEY= +export VOLCENGINE_SECRET_KEY= +``` + +### AgentKit 云上部署 + +```bash +# 1. 进入项目目录 +cd 02-use-cases/video_breakdown_agent + +# 2. 配置 Agentkit 部署配置 +agentkit config \ + --agent_name video_breakdown_agent \ + --entry_point 'agent.py' \ + --launch_type cloud + +# 3. 配置 AgentKit Runtime 环境变量(应用级) +# 以下环境变量均为必填项,参考"前置准备"部分获取相应的值 +agentkit config \ + -e MODEL_AGENT_API_KEY= \ + -e DATABASE_TOS_BUCKET= \ + -e DATABASE_TOS_REGION=cn-beijing + +# 可选:配置 ASR 服务 +agentkit config \ + -e ASR_APP_ID= \ + -e ASR_ACCESS_KEY= + +# 4. 启动云端服务 +agentkit launch + +# 5. 测试部署的 Agent +agentkit invoke "分析这个视频 https://example.com/video.mp4" +``` + +### 测试已部署的智能体 + +在 AgentKit 控制台"智能体运行时"页面找到已部署的智能体 `video_breakdown_agent`,点击在线测评,输入提示词进行测试。 + +## 主要特性 + +### Multi-Agent 协作架构 + +Root Agent 作为主编排器,根据用户意图自动调度 4 个专业子 Agent: + +- **Breakdown Agent**:视频预处理 + ASR + 视觉分析 + BGM 分析 +- **Hook Analyzer Agent**:SequentialAgent 模式,先视觉分析后格式化 +- **Report Generator Agent**:整合数据生成 Markdown 报告 +- **Search Agent**:联网搜索行业信息 + +### 优雅降级机制 + +系统具备完善的容错能力: + +- TOS 上传失败 → 自动回退 base64 编码 +- ASR 未配置 → 跳过语音识别,仍可完成分镜拆解 +- FFmpeg 未安装 → 自动使用 imageio-ffmpeg 打包版本 + +### 多模型灵活切换 + +通过 LiteLLM 统一路由,支持一行配置切换视觉模型: + +- `doubao-seed-1-6-vision-250815`(火山方舟豆包) +- `gemini/gemini-2.5-pro`(Google Gemini) +- `gpt-4o`(OpenAI) + +### Session State 数据共享 + +子 Agent 之间通过 Session State 共享数据,避免重复序列化大 JSON: + +- `process_video` → `breakdown_result` +- `hook_analyzer` → `hook_analysis` +- `report_generator` → `final_report` + +## 常见问题 + +**错误:`VOLCENGINE_ACCESS_KEY not set`** + +- 请确保已设置火山引擎访问凭证环境变量 +- 参考"环境准备"章节设置 `VOLCENGINE_ACCESS_KEY` 和 `VOLCENGINE_SECRET_KEY` + +**错误:`TOS 存储桶不存在`** + +- 请在 TOS 控制台创建存储桶 +- 确认 `DATABASE_TOS_BUCKET` 和 `DATABASE_TOS_REGION` 配置正确 +- 系统会自动回退 base64 编码,不影响核心功能 + +**FFmpeg 未找到:** + +- 系统会自动使用 imageio-ffmpeg 打包版本,无需手动安装 +- 如需使用系统 FFmpeg,请确保已安装并在 PATH 中 + +**ASR 语音识别失败:** + +- 检查 `ASR_APP_ID` 和 `ASR_ACCESS_KEY` 是否正确 +- 未配置 ASR 时系统会跳过语音识别,仍可完成分镜拆解 + +**视觉模型切换:** + +- 修改 `MODEL_VISION_NAME` 环境变量即可切换模型 +- 使用 Gemini 时需配置 `GEMINI_API_KEY` +- 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) + +**视频文件过大:** + +- 当前限制视频文件大小为 2GB +- 建议压缩视频后重试 + +## 参考资料 + +- [VeADK 官方文档](https://volcengine.github.io/veadk-python/) +- [AgentKit 开发指南](https://volcengine.github.io/agentkit-sdk-python/) +- [火山方舟模型服务](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&projectName=default) +- [TOS 对象存储](https://www.volcengine.com/product/TOS) +- [火山 ASR 语音识别](https://www.volcengine.com/product/voice-tech) + +## 代码许可 + +本工程遵循 Apache 2.0 License diff --git a/python/02-use-cases/partners/video_breakdown_agent/README_en.md b/python/02-use-cases/partners/video_breakdown_agent/README_en.md new file mode 100644 index 00000000..06543ae0 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/README_en.md @@ -0,0 +1,507 @@ +# Video Breakdown Analyzer + +## Overview + +This is an intelligent short-video analysis system built on Volcengine VeADK & AgentKit. The system adopts a Multi-Agent architecture, integrating FFmpeg video processing, Volcengine ASR speech recognition, LiteLLM multimodal vision analysis, and TOS object storage, capable of comprehensive professional analysis of short videos. + +## Core Features + +This project provides the following core capabilities: + +- **Video Scene Segmentation**: Automatically identifies video scenes based on FFmpeg, extracts key frames and analyzes visual content, outputting structured scene data +- **First 3-Second Hook Analysis**: Professional scoring from 5 dimensions: visual impact, language hooks, emotional arousal, information density, and rhythm control +- **Professional Report Generation**: Integrates scene data and hook analysis results to generate complete analysis reports in Markdown format +- **Web Search**: Real-time access to the latest short-video industry information, platform rules, and trending topics + +## Agent Architecture + +![Video Breakdown Agent with AgentKit Runtime](assets/architecture_video_breakdown_agent.jpg) + +```text +User Input (Video URL/Local File) + ↓ +AgentKit Runtime + ↓ +Root Agent (XiaoShi - Main Orchestrator) + ├── Breakdown Agent (Scene Segmentation) + │ ├── FFmpeg Video Preprocessing + │ ├── Volcengine ASR Speech Recognition + │ ├── LiteLLM Vision Analysis + │ └── BGM Analysis + ├── Hook Analyzer Agent (Hook Analysis) + │ ├── First 3-Second Scene Extraction + │ ├── Multimodal Vision Scoring + │ └── JSON Formatting + ├── Report Generator Agent (Report Generation) + └── Search Agent (Web Search) +``` + +Main Volcengine products and Agent components: + +- Ark LLM Platform: + - doubao-seed-1-6-251015 (primary reasoning model) + - doubao-seed-1-6-vision-250815 (vision analysis model) +- TOS Object Storage +- Volcengine ASR Speech Recognition (optional) +- Web Search +- AgentKit +- APMPlus (optional observability) + +Third-party dependencies: + +- FFmpeg (auto-packaged via imageio-ffmpeg, no manual installation required) +- LiteLLM (supports Gemini, Doubao, GPT-4o and other vision models) + +## Usage Examples + +Below is a typical workflow from web search to in-depth video breakdown. + +### 1. Web Search (Weather/Industry Info) + +**User:** Weather in Hangzhou + +**XiaoShi (Root Agent):** Based on the search results, here is the current weather and forecast for Hangzhou: + +📍 **Hangzhou Weather (Wednesday, Feb 11, 2026)** + +**Current Conditions** +- **Temperature**: 13°C (Current), 5~15°C for the day +- **Condition**: Sunny +- **Wind**: North 4 (turning West in the afternoon) +- **Humidity**: 36% +- **Air Quality**: AQI 64 (Good) + +**📅 3-Day Forecast** + +| Date | Condition | Temp Range | Wind | +| :--- | :--- | :--- | :--- | +| Feb 11 (Wed) | Sunny | 5~15°C | North to West 2-4 | +| Feb 12 (Thu) | Sunny | 6~16°C | SE 2 | +| Feb 13 (Fri) | Cloudy | 8~20°C | South 2 | + +--- + +### 2. Video Breakdown + +**User:** http://example.com/sample-video.mp4 + +**XiaoShi (Root Agent):** Sure, I'll break down the scenes for you. + +#### 🎥 Video Breakdown Results + +**Video Info** +- **Duration**: 17.07s +- **Resolution**: 720x960 +- **Frame Rate**: 60fps + +**Scene List** + +| ID | Time | Shot | Movement | Content Description | Audio | Tags | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | 0.0-3.0s | CU | Static | Showing a beige LocknLock bottle; transitioning to opening the lid. | None | Product Display | +| 2 | 3.0-5.0s | CU | Static | Pouring water into the bottle; home lifestyle background. | None | Product Display | + +**BGM Analysis** +- **BGM**: Yes +- **Style**: Soft Piano/Ambient (Healing, Minimalist) +- **Mood**: Relaxing (Positive) + +--- + +### 3. Hook Analysis + +**User:** Analyze the first 3 seconds hook in detail. + +**XiaoShi (Root Agent):** + +#### 🪝 First 3s Hook Analysis & Rating + +**Hook Elements** +- **Visual Hook**: Close-up focusing on the handle design; "Grip-and-Lift" action reinforces convenience. +- **Auditory Hook**: Soothing piano/violin (60-80 BPM) creates a cozy atmosphere. +- **Content Hook**: Completes the "What it is -> How to use" loop within 3s. + +**Rating** + +| Dimension | Score (1-10) | Analysis | +| :--- | :--- | :--- | +| Visual Attraction | 8.5 | Sharp focus, smooth and purposeful movements. | +| Info Efficiency | 9.0 | Delivers core value proposition within 3s. | + +--- + +### 4. Generate Insight Report + +**User:** Give me a detailed video insight report. + +**XiaoShi (Root Agent):** + +#### 📊 LocknLock Product Display Insight Report + +**1. Content Structure** +The video follows a "General -> Detail -> Scenario -> Extension" logic, forming a complete product perception chain. + +**2. User Attention** +- **Peak Attention**: 0-5s (Frequent actions, clear visual focus). +- **Risk Point**: 10-17s (Product transition lacks smooth bridge). + +**3. Optimization Suggestions** +- **Visual**: Add a "Leak-proof test" close-up. +- **Audio**: Add crisp sound effects for lid opening. +- **Content**: Overlay text tags like "Food-grade 304 Stainless Steel". + +## Directory Structure + +```bash +video_breakdown_agent/ +├── README.md # Project documentation (Chinese) +├── README_en.md # Project documentation (English) +├── README_CONFIG.md # Detailed configuration guide +├── project.toml # Application marketplace metadata +├── agent.py # AgentKit deployment entry +├── requirements.txt # pip dependencies +├── pyproject.toml # uv project configuration +├── config.yaml # Configuration file (example, actual keys injected via env vars) +├── config.yaml.example # Configuration template +├── deploy.sh # Deployment script +├── video_breakdown_agent/ # Python package (core code) +│ ├── agent.py # Root Agent definition +│ ├── prompt.py # Main orchestration prompt +│ ├── sub_agents/ # Sub Agents +│ │ ├── breakdown_agent/ # Scene segmentation Agent +│ │ ├── hook_analyzer_agent/# Hook analysis Agent (SequentialAgent) +│ │ └── report_generator_agent/ # Report generation Agent +│ ├── tools/ # Tool functions +│ │ ├── process_video.py # Video preprocessing (FFmpeg + ASR) +│ │ ├── analyze_segments_vision.py # Vision analysis +│ │ ├── analyze_bgm.py # BGM analysis +│ │ ├── analyze_hook_segments.py # Hook scene extraction +│ │ ├── report_generator.py # Report generation +│ │ └── video_upload.py # TOS video upload +│ ├── hook/ # Callback hooks +│ │ ├── format_hook.py # JSON repair +│ │ └── video_upload_hook.py# File upload interceptor +│ └── utils/ # Utility classes +│ └── types.py # Pydantic data models +└── img/ # Architecture diagrams and screenshots +``` + +## Local Development + +### Prerequisites + +**Python Version:** + +- Python 3.12 or higher + +**1. Enable Volcengine Ark Model Service:** + +- Visit [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/overview) +- Enable model inference service +- Create API Key (used for `MODEL_AGENT_API_KEY`) + +**2. Create TOS Bucket (for video upload):** + +- Visit [TOS Console](https://console.volcengine.com/tos/bucket) +- Create a new bucket (e.g., `video-breakdown-uploads`) +- Set region to `cn-beijing` +- Configure public read permissions (or use pre-signed URLs) + +**3. Obtain Volcengine Access Keys:** + +- Visit [IAM Key Management](https://console.volcengine.com/iam/keymanage/) +- Create Access Key/Secret Key (used for `VOLCENGINE_ACCESS_KEY` and `VOLCENGINE_SECRET_KEY`) + +**4. (Optional) Configure ASR Service:** + +- Visit [Speech Service](https://console.volcengine.com/speech/service/list) to obtain App ID and Access Key +- If not configured, the system will gracefully degrade (skip speech recognition) + +### Dependency Installation + +**Method 1: Using pip** + +```bash +pip install -r requirements.txt +``` + +**Method 2: Using uv (recommended)** + +```bash +# Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Install dependencies +uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple +``` + +### Environment Configuration + +**Method 1: Create `.env` file (recommended for local development)** + +```bash +# Copy configuration template +cp config.yaml.example config.yaml + +# Edit .env file and fill in the following required environment variables: +MODEL_AGENT_API_KEY=your_ark_api_key +VOLCENGINE_ACCESS_KEY=your_volcengine_ak +VOLCENGINE_SECRET_KEY=your_volcengine_sk +DATABASE_TOS_BUCKET=your_tos_bucket_name +DATABASE_TOS_REGION=cn-beijing + +# Optional: ASR configuration (graceful degradation if not configured) +ASR_APP_ID=your_asr_app_id +ASR_ACCESS_KEY=your_asr_access_key + +# Optional: Vision model configuration (defaults to Doubao if not configured) +MODEL_VISION_NAME=doubao-seed-1-6-vision-250815 +# Or use Gemini: +# MODEL_VISION_NAME=gemini/gemini-2.5-pro +# GEMINI_API_KEY=your_gemini_api_key +``` + +**Method 2: Use environment variables directly** + +```bash +export MODEL_AGENT_API_KEY=your_ark_api_key +export VOLCENGINE_ACCESS_KEY=your_volcengine_ak +export VOLCENGINE_SECRET_KEY=your_volcengine_sk +export DATABASE_TOS_BUCKET=your_tos_bucket_name +``` + +**Priority**: System environment variables > `.env` file > `config.yaml` + +For detailed configuration instructions, see [README_CONFIG.md](README_CONFIG.md). + +### Running the Application + +**Method 1: Local debugging with veadk web (recommended)** + +```bash +# veadk automatically discovers video_breakdown_agent/ package +uv run veadk web +``` + +Access `http://localhost:8000` to interact with the Agent. + +**Method 2: Run directly** + +```bash +python agent.py +``` + +**Method 3: Smoke test** + +```bash +# Quick test +uv run python .scripts/smoke_test.py "Hello" + +# Full pipeline test +uv run python .scripts/smoke_test.py --pipeline-cases +``` + +## AgentKit Deployment + +### Prerequisites + +**Install AgentKit CLI:** + +```bash +pip install agentkit +``` + +### One-Click Deployment + +**1. Initialize configuration:** + +```bash +# Configure AgentKit credentials +agentkit config --account-id YOUR_ACCOUNT_ID --access-key YOUR_AK --secret-key YOUR_SK +``` + +**2. Deploy to cloud:** + +```bash +# Deploy (automatically creates Runtime, builds image, deploys) +agentkit launch + +# View deployment status +agentkit status + +# View Runtime logs +agentkit logs +``` + +**3. Configure environment variables in console:** + +After deployment, you need to configure the following environment variables in the [AgentKit Console](https://console.volcengine.com/agentkit): + +- `MODEL_AGENT_API_KEY`: Ark API Key +- `VOLCENGINE_ACCESS_KEY`: Volcengine Access Key +- `VOLCENGINE_SECRET_KEY`: Volcengine Secret Key +- `DATABASE_TOS_BUCKET`: TOS bucket name +- `DATABASE_TOS_REGION`: TOS region (default: `cn-beijing`) + +**4. Test deployment:** + +```bash +# Test using agentkit CLI +agentkit run "Analyze this video: https://example.com/video.mp4" +``` + +### Advanced Deployment Options + +**Custom Docker build:** + +```bash +# Build image locally +docker build -t video-breakdown-agent:latest . + +# Run container +docker run -p 8000:8000 \ + -e MODEL_AGENT_API_KEY=your_key \ + -e VOLCENGINE_ACCESS_KEY=your_ak \ + -e VOLCENGINE_SECRET_KEY=your_sk \ + video-breakdown-agent:latest +``` + +**Deployment script:** + +```bash +# Use built-in deployment script +./deploy.sh +``` + +For detailed deployment instructions, see [DEPLOY_GUIDE.md](DEPLOY_GUIDE.md). + +## Key Features + +### 1. Multi-Agent Architecture + +- **Root Agent**: Main orchestrator, responsible for understanding user intent and dispatching sub-agents +- **Breakdown Agent**: Video preprocessing + vision analysis + BGM analysis +- **Hook Analyzer Agent**: SequentialAgent (vision scoring → JSON formatting) +- **Report Generator Agent**: Markdown report generation +- **Search Agent**: Real-time web search + +### 2. Powerful Vision Analysis + +- LiteLLM unified routing supporting multiple vision models: + - Volcengine Doubao Vision + - Google Gemini 2.5 Pro + - OpenAI GPT-4o +- Switch models with one line of configuration, no code changes required + +### 3. Graceful Degradation + +- **TOS upload failure**: Automatically falls back to base64 encoding +- **ASR not configured**: Automatically skips speech recognition +- **Vision analysis failure**: Attempts model fallback +- **Hook analysis failure**: Still generates basic scene reports + +### 4. Production-Ready + +- Complete error handling and logging +- OpenTelemetry observability support (APMPlus/CozeLoop/TLS) +- Docker containerization +- AgentKit one-click deployment + +## Sample Prompts + +**Basic scene segmentation:** +``` +Analyze the scene structure of this video +``` + +**Hook analysis:** +``` +Analyze the hook effectiveness of the first 3 seconds of this video and provide professional scoring +``` + +**Complete analysis:** +``` +Generate a complete video analysis report +``` + +**Search information:** +``` +What are the latest Douyin recommendation algorithm rules? +``` + +## Demo + +[Demo screenshots or videos will be placed in the `img/` directory] + +## FAQ + +**Q1: Does FFmpeg need to be installed manually?** + +A: No. This project uses `imageio-ffmpeg` which automatically downloads and packages FFmpeg binaries. If you have system FFmpeg installed, it will be used preferentially. + +**Q2: Does the system support platforms like Douyin/Xiaohongshu/Bilibili links?** + +A: Currently only public HTTP/HTTPS video download links are supported. Platform-specific links need to be extracted to download URLs first. + +**Q3: What should I do if TOS upload fails?** + +A: The system will automatically fall back to base64 encoding to continue analysis. Check: +- Whether `VOLCENGINE_ACCESS_KEY` and `VOLCENGINE_SECRET_KEY` are correctly configured +- Whether TOS bucket permissions are correct (recommend public read or pre-signed URLs) + +**Q4: What should I do if vision analysis fails?** + +A: Check: +- Whether `MODEL_AGENT_API_KEY` is correctly configured +- Whether the vision model name is correct (must include date suffix, e.g., `doubao-seed-1-6-vision-250815`) +- Whether network can access the model endpoint + +**Q5: What should I do if ASR speech recognition fails?** + +A: ASR is optional. The system will automatically skip speech recognition and continue with vision analysis if not configured. To enable ASR, configure: +```bash +ASR_APP_ID=your_app_id +ASR_ACCESS_KEY=your_access_key +``` + +**Q6: How to switch vision models?** + +A: Modify environment variable `MODEL_VISION_NAME`: + +```bash +# Use Doubao (default) +MODEL_VISION_NAME=doubao-seed-1-6-vision-250815 + +# Use Gemini +MODEL_VISION_NAME=gemini/gemini-2.5-pro +GEMINI_API_KEY=your_gemini_api_key + +# Use GPT-4o +MODEL_VISION_NAME=gpt-4o +OPENAI_API_KEY=your_openai_api_key +``` + +**Q7: Are there any limits on video duration?** + +A: Recommended: +- Video duration: 15 seconds to 3 minutes +- File size: <100MB +- Resolution: 720p or 1080p + +Longer videos will require more processing time and model tokens. + +## References + +- [VeADK Documentation](https://volcengine.github.io/veadk-python/) +- [AgentKit Documentation](https://www.volcengine.com/docs/6459) +- [Volcengine Ark Platform](https://console.volcengine.com/ark) +- [LiteLLM Documentation](https://docs.litellm.ai/) +- [FFmpeg Documentation](https://ffmpeg.org/documentation.html) + +## Contributing + +Contributions are welcome! Please refer to [CONTRIBUTING.md](../../CONTRIBUTING.md) for contribution guidelines. + +## License + +This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/python/02-use-cases/partners/video_breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/agent.py new file mode 100644 index 00000000..9557bc7b --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/agent.py @@ -0,0 +1,86 @@ +""" +Video Breakdown Agent — AgentKit 部署入口 + +Agent 定义位于 video_breakdown_agent/agent.py(veadk web 的唯一真相来源)。 +本文件仅添加 Runner、可观测性、AgentkitAgentServerApp 等部署层。 + +运行方式: + 1. uv run veadk web — 本地开发(veadk web 自动发现 video_breakdown_agent/) + 2. python agent.py — AgentKit 部署 +""" +import logging +import os + +from veadk import Runner +from veadk.memory.short_term_memory import ShortTermMemory +from agentkit.apps import AgentkitAgentServerApp + +# 从包中导入唯一的 root_agent 定义 +from video_breakdown_agent.agent import root_agent + +# ==================== 日志配置 ==================== + +log_level = os.getenv("LOG_LEVEL", "INFO").upper() +logging.basicConfig(level=getattr(logging, log_level, logging.INFO)) +logger = logging.getLogger(__name__) + +# ==================== 短期记忆配置 ==================== + +app_name = "video_breakdown_agent" +short_term_memory = ShortTermMemory(backend="local") + +# ==================== Runner (支持 veadk web / smoke_test) ==================== + +runner = Runner( + agent=root_agent, + short_term_memory=short_term_memory, + app_name=app_name, +) + +# ==================== 可观测性(Observability) ==================== +# 通过环境变量启用 OpenTelemetry Tracer: +# VEADK_TRACER_APMPLUS=true — APMPlus +# VEADK_TRACER_COZELOOP=true — CozeLoop +# VEADK_TRACER_TLS=true — TLS + + +def _load_tracer() -> None: + """加载可观测性 Tracer(仅在对应环境变量为 true 时激活)""" + try: + from veadk.tracing.telemetry.exporters.apmplus_exporter import APMPlusExporter + from veadk.tracing.telemetry.exporters.cozeloop_exporter import CozeloopExporter + from veadk.tracing.telemetry.exporters.tls_exporter import TLSExporter + from veadk.tracing.telemetry.opentelemetry_tracer import OpentelemetryTracer + except ImportError: + logger.debug("Tracing 依赖未安装,跳过可观测性初始化") + return + + EXPORTER_REGISTRY = { + "VEADK_TRACER_APMPLUS": APMPlusExporter, + "VEADK_TRACER_COZELOOP": CozeloopExporter, + "VEADK_TRACER_TLS": TLSExporter, + } + + exporters = [] + for env_var, exporter_cls in EXPORTER_REGISTRY.items(): + if os.getenv(env_var, "").lower() == "true": + exporters.append(exporter_cls()) + logger.info(f"可观测性: 启用 {exporter_cls.__name__}") + + if exporters: + tracer = OpentelemetryTracer(name="veadk_tracer", exporters=exporters) + root_agent.tracers = list(root_agent.tracers or []) + [tracer] + logger.info(f"可观测性: 已加载 {len(exporters)} 个 Exporter") + + +_load_tracer() + +# ==================== Server App (支持 AgentKit 部署) ==================== + +agent_server_app = AgentkitAgentServerApp( + agent=root_agent, + short_term_memory=short_term_memory, +) + +if __name__ == "__main__": + agent_server_app.run(host="0.0.0.0", port=8000) diff --git a/python/02-use-cases/partners/video_breakdown_agent/assets/README.md b/python/02-use-cases/partners/video_breakdown_agent/assets/README.md new file mode 100644 index 00000000..6c013ce1 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/assets/README.md @@ -0,0 +1,158 @@ +# 技术架构图 + +## Mermaid 流程图代码 + +您可以使用以下 Mermaid 代码生成技术架构图,或使用绘图工具创建自定义架构图。 + +### Multi-Agent 架构流程图 + +```mermaid +graph TD + User[用户输入视频URL/本地文件] --> RootAgent[Root Agent 主编排器小视] + + RootAgent --> BreakdownAgent[Breakdown Agent
分镜拆解] + RootAgent --> HookAnalyzer[Hook Analyzer Agent
钩子分析] + RootAgent --> ReportGen[Report Generator Agent
报告生成] + RootAgent --> SearchAgent[Search Agent
联网搜索] + + BreakdownAgent --> ProcessVideo[process_video
FFmpeg预处理+ASR] + BreakdownAgent --> AnalyzeVision[analyze_segments_vision
LiteLLM视觉分析] + BreakdownAgent --> AnalyzeBGM[analyze_bgm
背景音乐分析] + BreakdownAgent --> VideoUpload[video_upload_to_tos
TOS上传] + + ProcessVideo --> SessionState[(Session State
breakdown_result)] + + SessionState --> HookAnalyzer + HookAnalyzer --> HookAnalysis[hook_analysis_agent
Vision模型多模态分析] + HookAnalysis --> HookFormat[hook_format_agent
JSON格式化校验] + HookFormat --> SessionState2[(Session State
hook_analysis)] + + SessionState --> ReportGen + SessionState2 --> ReportGen + ReportGen --> GenerateReport[generate_video_report
Markdown报告生成] + GenerateReport --> FinalReport[(Session State
final_report)] + + SearchAgent --> WebSearch[web_search
实时信息搜索] + + style RootAgent fill:#e1f5ff + style BreakdownAgent fill:#fff4e1 + style HookAnalyzer fill:#ffe1f5 + style ReportGen fill:#e1ffe1 + style SearchAgent fill:#f5e1ff + style SessionState fill:#ffd700 + style SessionState2 fill:#ffd700 + style FinalReport fill:#ffd700 +``` + +### 数据流转图 + +```mermaid +sequenceDiagram + participant User as 用户 + participant Root as Root Agent + participant Breakdown as Breakdown Agent + participant Hook as Hook Analyzer + participant Report as Report Generator + participant State as Session State + + User->>Root: 上传视频/提供URL + Root->>Breakdown: 委派分镜拆解任务 + + Breakdown->>Breakdown: FFmpeg视频预处理 + Breakdown->>Breakdown: 火山ASR语音识别 + Breakdown->>Breakdown: 提取关键帧 + Breakdown->>Breakdown: LiteLLM视觉分析 + Breakdown->>State: 写入 breakdown_result + Breakdown->>Root: 返回分镜数据 + + Root->>Hook: 委派钩子分析任务 + Hook->>State: 读取 breakdown_result + Hook->>Hook: 提取前3秒分镜 + Hook->>Hook: Vision模型多模态分析 + Hook->>Hook: JSON格式化校验 + Hook->>State: 写入 hook_analysis + Hook->>Root: 返回钩子评分 + + Root->>Report: 委派报告生成任务 + Report->>State: 读取 breakdown_result + Report->>State: 读取 hook_analysis + Report->>Report: 整合数据生成Markdown + Report->>State: 写入 final_report + Report->>Root: 返回完整报告 + + Root->>User: 展示分析结果 +``` + +### 火山引擎组件集成图 + +```mermaid +graph LR + Agent[Video Breakdown Agent] --> Ark[火山方舟模型] + Agent --> TOS[TOS对象存储] + Agent --> ASR[火山ASR语音识别] + Agent --> AgentKit[AgentKit运行时] + Agent --> WebSearch[Web Search搜索] + + Ark --> Model1[doubao-seed-1-6-251015
主推理模型] + Ark --> Model2[doubao-seed-1-6-vision-250815
视觉分析模型] + + TOS --> Upload[视频/图片上传] + TOS --> Storage[媒体资源存储] + + ASR --> SpeechToText[语音转文字] + ASR --> Timeline[时间轴分段] + + AgentKit --> MultiAgent[Multi-Agent编排] + AgentKit --> Memory[Session State管理] + + WebSearch --> Industry[行业资讯] + WebSearch --> Trends[平台规则] + + style Agent fill:#e1f5ff + style Ark fill:#fff4e1 + style TOS fill:#ffe1f5 + style ASR fill:#e1ffe1 + style AgentKit fill:#f5e1ff + style WebSearch fill:#ffe1e1 +``` + +## 生成架构图的方法 + +### 方法1:使用在线 Mermaid 编辑器 + +1. 访问 [Mermaid Live Editor](https://mermaid.live/) +2. 复制上述任一 Mermaid 代码 +3. 粘贴到编辑器中 +4. 导出为 PNG/SVG 图片 +5. 保存为 `architecture.jpg` 或其他格式 + +### 方法2:使用绘图工具 + +使用 Draw.io、Figma、Excalidraw 等工具绘制自定义架构图: + +**建议包含的元素:** +- Root Agent 和 4 个子 Agent +- 每个 Agent 的主要工具函数 +- Session State 数据流转 +- 火山引擎组件(方舟、TOS、ASR) +- 第三方组件(FFmpeg、LiteLLM) + +### 方法3:使用 VS Code 插件 + +1. 安装 VS Code 插件:`Markdown Preview Mermaid Support` +2. 在 Markdown 文件中查看 Mermaid 图表 +3. 截图保存 + +## 架构图说明 + +架构图应展示以下关键信息: + +1. **Multi-Agent 协作关系**:Root Agent 如何协调 4 个子 Agent +2. **数据流转路径**:Session State 如何在 Agent 之间传递数据 +3. **工具调用链路**:每个 Agent 调用哪些工具函数 +4. **火山引擎集成**:使用了哪些火山产品和服务 +5. **优雅降级机制**:TOS/ASR 失败时的回退路径 + +## 当前状态 + +> 架构图文件待补充。建议使用上述 Mermaid 代码生成或自行绘制后保存为 `img/architecture.jpg`。 diff --git a/python/02-use-cases/partners/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg b/python/02-use-cases/partners/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2adbc5e9b36a4eab5066b994e066c3ec823e8137 GIT binary patch literal 148066 zcmd?QgLftE@&+1nq8;1T1QXk~jmgBeZBA_4$s`k-6HaVvVq165`F%6z+;i@KaMybG zTHU?7UsTmw_0&_{;R>v!|`N-788R~Y(msJSXahV%p6P?}htNCfKrgl1I0zTW& z%ZJIIoERmc5|-p)nq!>%VF$-jO1(vIWPIA`{M^)Z4l&;#Q2ZS;zXFcf3H#!o2I7&; z-@Wphp1pJ#57%dy-|F>2wOiR(sK>-xT_?QWy@(z8OvMr#*a~1ms&=(MI!=Z>m{-0{aXE82=zg(%y=O}hd zVOF8Q0}_}IWpMG6L(j!Ife!6aw8LxgW7#BWhO-H2u0D%!b}QuD#$cYNq&F=wp=&`lTs}e(sG13|JG<6f#^KGygDtm=-ofM*?2MU#_07yFKlukW$XCz;G*N<;6XBgM=)no0*2%Q#u8>A z2t0_bm%jI-VSK3oD;XDRU)Ya+*?tEj(o48Yl*_89sHm8zz%c-}zf6ow%NSFdC37Hi zjzk0*Dj*U~IzI0ESH!Ojac2qZ@jpI;*W+@AWJ<1!Rg>zI@Duwc77eG5)|I_3z1d537~QMi8`+f`UW>;~pd7{>{?=R*4pXHjNg6 zc7$F{8%4E^hJeP4?jGSD@r=$$ji{)ArVAI@TVz#?m#dTaLAg#vm?k4#hQ5%X$VG7| z2`veOT9m3vtz3nxf>l6x>Vuh;7EM*EOXj@zbGv{ss3WLv&pc!_ya%S2f||06@;)sl z6+88Y>RY9d`lX7b;!@sp{*?xLl|_E5h;z~r-yChZv4zSf%1_l={>%1_&_}hc;T~a6 zTDPx9Sx2Zi={V~+B+(>o4DrA6dg#7ma=zeKaby8fF_~v-;@uS%l5ula{%PrPC0W<=96#rgh6zYp{&P zbh0#+^i}3y*;5(FVozC53?mFKIy8n#-9=k5X5(~|v`?uB=`&-mms!^?0&6vZrM76ATF1p3Xt&dI_7t2t~ScsItyu>`!cA88! z!Zm0c_`Y^mk=2p4SUqJkt2fCv(K*tYBWMD_5`ndV_Yv_75f^`g!^@$;amZ{TGB8Uu;*n8PvCG804i#7)xOWmB-!`91H#8zHY9F~MM z@zk1C`n7?xl2!g%Jk4WGOdbV-bpl8N-Ry|$t!(sc`F5vvKnG6yV25?bf`|S^&%?vx z$;F|mWo^p|*Ijc>$GOMGs1I3}LKj?D$s5I+(ffB0j&61X)9wO+R{=uW9i6&(7yjW=;Q!Py?(Ol>k#(~{gm-g3?+ zZeue#Gd%hO_i5s}EyQcd%iiZ5qlA8Lqo51ZYol@Q%W-zq2AmrtI~+ayC|nnseWcBc z!L9HklJW}RyEFO<`uR%C8pG;(Q=jwD79cqOBt9<-lsiNEun4l~+@)&Kxx-3P^VVkD z=Es@8!~4}duZQRN^GBtxD)vp&9ra~W9Tt{zp&jiV9nY(GpVTZ>Fx7nD6fbrIZAiI;e zSBx#FivAM)Nw%T5*LX8^cQG<&$LEau%(eX$Vn<|0@-;!VgsKECJ#YWh_zt}!J$Thr z6>o{Rd{Yx=W7gBaONy{*!=y^KDwnobj2pxG>g?mmH7ceE<{$%w9-f8%)Z2y9Y!P=# ziq2WCG5yKOd4a)%5KW`33+g*FQ_`c6K)j@qrWTz#uQ(3(6Js}!Gz@`!fn z(s(j@DEhUxtMNt%=d04e?BRSR-^9;7R4^E-AE__jLG4j-b@#JP^n95zfHJ#X-jn|^ zZ?j-zs4oXAhl@|#$A_=w2>wj^QGKVzdgG;&H(e?t9bn&|nWhL5OqCf2(w!j;5?274 z>jW;K!^N#U3B&r#ZxGV$yS>!nDHivmh=3{21_XjcE4r4zXwLxzM$9S|*U#Fjkc<6& zV(e&!#23OZ8&6t0`T5)?Z!?FPd+5FB+*gZe{E7XUab*OoE$$0gJrKNdm}p3Tk(C9Z z0UpDEfQFibKmdaEt7MYDW!>WGcW&O)}7WXYbt!)>t=>(a3H6T^O58}TYQ8Mh}fP<%1%>Vr32f+#o zC+CCw-%t2K&681C(B*fl0xC$pUzamihAGe-doV4h$kpUV}OrWCIfXl$AADW_Q#+NsvtzhL@}Iti1QvC?&;~cyfEKD1r-rr zC;9Jp4wV^$f_cRyBw)b6vU^rmFq>>Q)ZGnHj?UIR(Qw&L1nlf2f>O-$@VODUC$gRg zD{0kjjIMTbqVGc@aoJaE4SH%qw4(_5_4Sc(+4@o`F{c-N-*w$jX@vyWe;Z!6Hq1w} zpV}aoO#j}#STJG3H$2V+?BWw*sad?tUh`<0xC=t5f#3|y&)c1C&~MG?en#%hc|JCj z!i_NJJv}|C+^z@#0Rc?TM*wmif73}Y#X<=^RvR7ErEd!?$(a5eWTGZKx_G_kHynU?X z9%$3)^JVQj8QRhtwAVy-a7P8Li|ZSKCA1IpIojdRA_0oeQm1}acRmr(qn3{^Z_j$H z=1TD6>2p&xp-4HK8MYKVDf)w=RPX0qn2ByLD2Fp`MuSm#!%4IifVg++udQf~`%xd0 z(JjA}DM8wwt*WlJ9r(*-Y5lrGfb@B>FYDy-ykqvbCn%^&kxW?L*&lz_${i7|Gm9bM zRliMw0*Cg=8-<31+?ucQd|JMdPh%F9v2~aIZ5@h0mlXv-BK)^!65#OPG@Z0-I_KCu zSs*-E_KkS?_NDh=8J%OV%dbtHu|V{w@S$CAi^|^Jjc2gg{<_xbFo?0e4Kb04 z%LiYf{lQbC*^c$?!I;qF)}kgS8jDG~i<^tfV7ni53tjI;Hi07Pd$ZW^?!R`MNCF6? zDXV`S#~+LGvj)JpKcGn_K3f1xO?lKADqMXvqqLzHNcvI=n3yDC;yEEY(Z}B3*d@a| z_a-PDVWvTcy&YpG4r{w9$Z35X5dA25g#2muk=E}&XbCvabL$6Eu1zcB!Y5a$$&s{ZD z-cs}<#7kiiZ$--JV=_hjEO!dl6=#si?TBISbw=uYdZm~T7~NlP^gZ%U!erE2Fd^4a z@QbjrY{sn9%oBn=#d!WfDeIgznqo^+u_tEI@gmekHnYL+^yU$^c0^00$UPR3Yv z*R=I?%WdcAIP`eEaJ&AK$@s}avCU@9 z#di1a1mOQ{2B6m(48l478)1N(VF-;u;?f=7jd3G@)!!eceJ4b`V!o$EM-$Cr6*=2g zcc$k+RZzkX|&yXg;iq|)z9?aXGguC4Cl{~aq05P^Zyk*e;)A4>v(r26L9JsZ02@wb_FPz34TxU9|=qxh=?&k6It3Xz((ZX-F<(s*yR+-zyCiQB^ zab8uf1EZoPyVW8rCZ=hF`HTYW8jIO%DTTxCa1?NPmY38$VY{Vh6u;v|7;vr=zkV8N z{tX@(6q|)~3Ho?%2-*~k{neZmaVEWP9OyqnV`D3Wzt!W3QHBd7?uhr-Qw`x(ug&&TZG7pQdYQ4R-aL9_F&XE!P_G6>~0KHMh{gmY&7vH@C+uTMNTu{)oES-FQ z8mt-cPb3DQ_;(8s-u8{l%vN_0p^T4QDdz>xTtzK`|1l@OG>Bc-3qQY)JeMDO-bnd4 z><5w7JM%`eX2+m3q9F2#;#QXsAFs)T8{rtJ;)bvpew-#Xa=tXDQk7q#0AnXNP-I@v zZk2zrDKa-i@9bmp8B*X!pMCF4PC^jqP%=A*2J!Ml$*)`{%p4ToBC}!^BgO%4Z#*A5 zeexDtCZShc%Dun&8zO^p70P3%P-O{uuRmaaTS9`Sv{C$xGzUfp|02zGvF;U0hz&Wr zK3`1mIAUF9uazb;DKyFF?-pKiVCN#;E9@}v$-2Fz)c3PUc-;+(Eeap?nY~%ZxIc}J z0F{ga^ax_zJ-C02)W4a8=VUJ8*HJ}Rv0L6 zWQCuE;e&k-=M9z{>a9g_@8-JZ;TOvcLfDCAq2KTsB6<98kZMo!3mX?6@@8^s$1PVM z4qMj`B3!%N$MF&ZOD5jjGzfjC-g3VzrxcTYRc~?G=xsQ6RCLmUV%KgI1aner>JdFi z{AayHov<&iQ~HtPV&|qMi&5tT2Jh(tc`xQ4oj*fnw%vRX9$EuMa@d3pgfI#P#+(zo z&$PeY#=qJZz{L&Xp!&wT*j_#M&pKyN7Rx#;SdDCf{iq*IJ`$REoO_}CE_{t%p(p+j zR(?QGhCs($BJt1D^FjiZVhO$;ejm)Ue%5F+Sz==h34)1%wKx=tJjY%c-7zAeVD(?S zMH#pWf-wP85o}?*%D;#X*&D2B4eWAKQUB&xt`6JVqY_0OqJNH?jun<-ypvE}x1JCn z(EyYi>glwD(LYl*(1qx4Z83T^{yZ9w_vl!{>}jXOsL~-c8~J=RHaHk?E=SdwCV8(D`J1%E7D4C8YjThbb z2zpfrP+>LF1uliEFK*%cs(st(*3efd{Y7MfC{~e?ky8AR0ED|=4Utb~*<-m>zoqk1 z<(LXC+FkGj%zB#HQOW7INAIu`r0VjwLIws36VZV#|No1if zSdj6~UQbpkcjUU>lM6p>qvqHcjF^-pq5OK!BlCq17d&*#4=T)EWJbyqUd@>quMa5#xwO!Q z@>J-?@-DvZx~MkMjK{@>YDxy3_E;UxnY`l^+PW{ArpCCot)&WF;BTDUcf~_vlt6bH z2(*0cIE|n`>C5ICtWNW8EE75EyEj!6)|B-x`&7V%MJQ|v+_va5dBm_e;iy_ZQ z?H|2n DYpzA+N1`kLh8IRiXPQ6|0e|#Qd8;<8}tXtdtfnWD>Fgew@07q`q`4LXc z*q>bokIO+gavVIgx^HR<6mnxU=)3huzQ~kS92@^>!=i@c=1+u&VqadXRRvjX7r`)lM13@Sk}(Fr~NQ1&(UdH%jPRd7#(M*ngp4l^}d_0ZdqHH)mO1l1?e&A(6y5(_ZULNbn^ukraw8r-< z7%rbDi{}uB;`5}WMmas#b9bZF!mftR#DfIiRsStvT{dQ;gz^vDgHA1rL;3?s=u!rfU`4l6q>ve)KblBu_>e5%l}ojvhNa^~~li2u3=oDD!Q%7h@Y>Zg1iHl_x<8B5O6Y2Z~&-2~BG~PGK9x z(0{6oJFncm+sTFR%#J8!@oM8VSgVR1;H&0yyPh=+r>&t^@iUYxeR#7$eOq0{)&8FC zuTh)PlnMXtXAlPImHhOTs627DM^4jgt_E2K=z5tb4A#3AnQXGr{|a4#DAO-cINnS= zjXsW)R44#UHE}rw4L6DL{fZ)j7A2!#=rvEenfh{n`y`CM+Ekw#3dOo+B@!FwX}b0H z{w&f=qXwpUVli0oy&^PX@A}tw(I_q|V(2@-vX%&*-&)GQsb0by_7*`md)*Ry+-O!^-xIcn5WiO-+2GgUaHf$0|7O*4izY7}6#0dbv;U1U`vg_*C7; zt@mqr97W+W=sJYf07|HJ@wU#kqCKKbsrb#DdBOpO2}1|{E$~Q*Nvo3Y9Ckye)IO>-FR0{4{LPS@w}hVu<}DAZmH)%LqiTRNxRaX}Wq4#9xk1k7Wx(!BKtHT+*Gb`nX2S&P? ztbAVYMk)-A`8z(EE?5=5ypK|CP*udwAnaBzmR4xYRBk!yk z(^Ih|DfjVGbcX8tleR+os;{(R4=c5Y`d+W9_%do3@>}zgr=hylI$uoAOAFKI;5K`} zZ977*=T9jmj*kN7EOPc%A)mjhhpRMs%BwD=Y%_+|8-4ka38!A|nJcg!#Zjsgm%-}c z-dWKsn8V=?eNLV786`7|OC_AAvT2>)2M307D8;+85ey!P(A$B$>oq#r*dHDXPz&L2 z6dZ572PJQP%YvYBUEC>BIQ*C)IGI2dsoO05)h@=` zOBJ_#gNfBn@l1^dM__;ulSE5FM8$qad|3Y4%JbHD3)3>VP30~d!&@H+BUL(eiUrGx zaJv^p*zZ8UL48>a5ev{bR&xnQt}L;xP_JtIc5ph7)s{V+Kugbe*dU@UAbO?KcBv34 zAWTG-wu8LK@MIE;1EYG1g!dh#N;`4N_-ki@RCzg*BzN%-+1uBdc5kU+UJ)j8iviVz z%$tY0ob_YumdXako}`HvSEMS{3qr|>k!2p2&&oF{^Ghuw-t1R;BTwg;gUka%Mo^ zLya)ele7=S(^MA&BGmh1Kr>ip0TrL^;CpM3M#|v)o-Rq^&1W^ zSfFmaH_U=r6W!O^PB!TDSS^FZdr!}GF>7sJsp&K2vs{#O`~`;F3cpv4<~hdhX*?)< zlF1(B`hJM>;pOonX&^NoDe=Tl8XMdtW9M!k@tnm*UwIZO{wP zr)6lgMa^Ko10Ul3S=loxpipdml@1}%%>$+wy3bsLkPL*j|5&zv69BmU13C1sv@PEy$_bzYdbpsrUHxpRxsWeVd9C5h398L3PAD z8>;O=Kv;UHHJ8NW^gtD#W06#gWG^gn0iXY>sQxJ2fVWI8=>zxRGWf zjCSQ+x)hlfN-D{IzlnN`qGnfXMK#6sdi^P#kMaRe;q^cHd`@h6;zjO;F+2^Y&`??& zCq-{gb+uDIy`V7h$k`{Tk@<4;Xk$0v_*=}4WQ7^n*_rs zVZ=T@V}Zq^%&)mJ38#t--F11$C=_Y^U9x#b`$;rRY03IY<)DO?AJR$uuQ^-`idhHL z;vvK9eNWDj2G1J@Ulm)tlGP3zBOsZtvipZmBTep{g_aa1W@hx;vXZH?WyL4Qv*}mC zXshe5WCDIIuB_}NI~1W~YDgMkC+7-68B)5ts^(@{X%>86muhj9jm>#DycS!iIlG%L zX0zq3qRS)S(2|>%x=X-`59G=h;RwUM+%jfYtndKsD}Xt_n!DTFIxf}XeAy@)ehpXH zD5|U@EDh_km9n|_V%mH*#^R*{atKjavxlVw;PPYwftK|=#LW^n5I_9Yk&`t=PA-L3N&7X ztI$LR4d2ed>MuSy%xb$K0SgRZ@|Iz+6dJ3G69^y|=t(n3i2?s-L-_|u3fwWr5G3N4 zDS>&1;D^N8D)eT;h-a0;40mpGMo#Yhghd*|$@bYSMX=aVYHiO>z%l#xdet50h244Q<%l);maw*&n0_JWkdX&U@G5!bp? zXUo%T$6n(k9Rh#=Dpn@TD*Yq;pJ*n~OG1QENX~L?_yp!$ICL{JoIM8_*hz=Dz$#~p z(3(*DWn@Z=6ci=>4V3ud^?XI+eaQOrcp_SckGp(UhJ|f(-(IQ&Q4g>_AyX?oI_mQW}ix0t!fh*@E<0{S64sq;Z@Dq-vA& zxdAZ227^MbCp?=W{4Q&1rm|o>UG!)3Js2KJm(e$GV{~bFA02fV&=?ahNRO9$TkT_D zQ-Cy4uP%4YpK2WdA2ekb5o?-KJ3k|ZoO1X4{lg~n&c%>S@Bf9NOR=@bQ!7P9_a`5%}5x#|Cd*Z!lI1|aGX z!^Niki#q-uqzVH|?RZjj{~~MuU!x&g17$QNHVF6cs|MZ>+589LMe|KGctEBSvoD7hUG}tPXKIJz2Yy#9DJaC`}WDPc; zTK~wzUYKaL_wj|3KGVK0qXW2Bk>5i(nbpz#ThbcX{wWx8aB{TTIEbMT5(2 zAOra5PsWHwscV6HLE`X>EG`T<5sK}Bz1iQ>`d70G1qp;&n$U7ACX0&$GN2TNbK4sK zk?sLN1A|$}$XKUQ*MGB^0xzh*y4(AwO_;wuNDvJ|oSgIqLMhq}ViD5<@6rGYwFGjj z-u#bYf&yw;ffJoTmUbPu``tOwGSD;%qNC9N-AlIz3>Ji`8I`U@m8mKR0Qe|4u)+Zj z+xgE1`aL+X?17x=R3_^eIYgY`hXACQ8~jE0(f&>szzzxx?Fn4O*{A&z38K8;d$5#t z$o}750C{`K1TLzcAH10_0zjE+5Q0J<;{wD*{w;ShWPGrg7)cZ?tf+&@oGv%eySoNj zkx=5%-zy6NuFRylUiCj#h6T#VsLyJ>3~y*?c!VC2n~O}R-znNvI0FXhU^rL)1LHUc z;r9gmT7GW_@&Sjx8YVL!hfpe(LF6{+zPU&8It_!voOoXL`tmP9>!9Y=i0Yu4`u_X& zix`NL#N|vL_f1P}dk2U9pLuI~&rxZ=Z$MrEcD|F&@l5&$r4adR5GfSPpcKlVKS@a^ z(WV3if@dx2f%bg>5c#_SUp2vITwJ35w<)#b`rFYSypyn!3>8EXtWgRP*#1@y5#Mjy zrS!i0SJn0Z=5j8OYc>TooW)&H2U+LA#ZAewe*Le?!gdD8EB&@7ky zMhkpC-gi}v$a4!(kG+sEyWV!QP7tV8p{{+}rtiSitjz3?E;-PXTX~_fn`a^tNLj`b zFh>7lO8%v=saougg)%W>?%zap{pBUQBVY?1q#}ruB92gSDl^r1bP%Hn+wKp%ld>yJX0#*2*028rLfTJl0;0_c&yDAIiC+8x7-Ro$mjh5iwb)T-EF2hY zNd#yQGKN3qKll5Dj)W3cYSr#}sF;uKbN{Qc-8#dX=-T4aidpy0S3;!5IoCXZp z=i@J_%dSr~q1-}!y?{{%I|2*}0osya&~J|-qQCiidP>Sa0=g6?%F|%)6B0tkz`+5X z#+R2L4-Klh7>a%Lh`kJZWqw1SXMNdUFnTIqbN^li6E!u}Cg!j(vfnm~~r&&a1aqUiAJs?B^nf zclrfhq5f3&hwP~hPR3Dm`6SgbS#4L#d}P;YfX@RfH$%FyL$-9NGtl=0eE#ivDXXi5 z1(SurFdyK=<|c&go_k1*>sV53^X4`s-O5D_`f9{*ug5#$CWqEh-}CZ~)Z12EOP8#% zY}R~BE0A*9;MmWD^>LR`1($R0sdPHMk<`ythi1{WKg3oZWvR(kXdxmFMgaA$b-8V4 z_D2cGFdA*n$`G+DH$Nmi?D*j-H6mt{Xh^g>GaoGH-DGYr2OCS|FD<)3QM z)UqbBcy~D+GdO&atAVUC_mPmG@=|>!(GW$$iEjp%e*2b7B#aRd5ZLIfFh~lf7Ow&O z&gF%p{m$v%y@_nB**DOB>uYFNf+4ZL8&*{Clso!sNlQT*mv?A#;7ZYZD}5T$bH58M zQ?MNWkbAL^H?1T}^a>&QM`-ps7Mm%$Ty<(dpR`YRxxIeD!h?HA_1v@LI0Ml~g#%8G zMdOVCCaOek?ugQbWbdJ<7a5v?XY#7d%T5S90WjjtIDx?*hJSsF1~DiK*1XNy2Q@Y6 z1APpl>(!R#d0&uvB>X5M2ol#~{fo)@v4>7&bq#DI|)?yCtQfM6wM#s^4>Ax;2Mes z@9B^m9h5!ci;exXmdJ=rG_ag@rNc|05Pf2$Zv3^%Ve&B`3Fg(SU#6Mb(yZ12{Y|h@ z|C^N}Ir2e)o4<}m*L5wRee&Z@24mbbIp>$ESK$Wy37wYkH1IZ@XN;}p>tEPWhjV%d z=)-B~vgJtw&7#MNZU_SlIc%Yh%#>?G&%EN>-IF_Zw-ewE*gV(_6LJGI2{q+Ugv}X+yQa|^H6A83UKd{_&nV&ORDZPl;#hfi6 z5~@%K#&)HJZIt*{%nuMGNu#M4cLv9w@3GC-CBxS_#PE9|=0zMgB*ad&Ie9FWeKH18 zC2o>_7!xUUZlZtUJJ7!X)i67Ac^ll->gIBck-f6gcX}|u!R7#QV*6b<1=%;17O8*v zzJC-GiGX(?HMlEZ_O-m(X$ApUF&gU8AG9P+rq;!=;GWhK@Bi}V61jvymdLQjX zvW|F|r+a*V0Xa@I=&-v+)S9oddXmuhe0(yze^D%ttxILgWhtgx34)HD(_3_2vHyIC z^dKI7h$wqx5CF*0WGNKA)SW)(Vh7_dK_=bfe2U)6Z;TSl)t zc<_}9(i7e}+KYOuN;F$3xMLDalGACAxv8eMKY3Bz9Z5%FnYWr5kT3TVCZtZ_E-+ii ze@s}x3jFg3PzeAoVUU7I7?#_ zM>FVAi)&u+a}YCP@*={U5BctH1Z_Uq2-B#3WS7(LEVF%XurAcGpxha<Bv<3PJ|aT_Zz{tl5hF_18Ip2ktLAl zWrL^n!pp|^>3ai9+IL*jyKToeBB$@qC5g!CZoU zOcT>4T>2{yHUZx)vzm7$T`sjU*}_>7QjQ1iHSlY>SsMNLt4we5aJQWAUhwIvj7az5 z*%JG4XicgH_$Zz&-#+&*RWv!_FA=(L?dy8eZ#&*?+GucA1K(CtO%~;DEA3kDsJ8V~ z9a2#lagMEXtn+vQ@yg%lahi{S*-5HnJ7q`IuZWwgDlGu8mc^dT(v~R1S+%(I_s_5pbmHzk{vmt>Fhe-lLuB-ZD z{z)-=>k|yH%cUK8vKEQL*URWXTio$07r!HI+?z!*6!`S!Vvja_Vo^`N@u<{it%>xX zN!k7i_+Fw)wiMz$D8d;!Ye86|A>H9M(DPsdAU=5|k)L-S*N0XRI!&QVjFzfZEPYsQS#yQuJ?P?KjzhrIup7P;KvauHSZYo(fcmm`spl3~_U z+~mC+0(2yQ)ofh$U9ZKT8S*+^n@4YCYUDQ-HwPm&;8!YwU{*u?zuFK{HtfUWT5yLq zQD)Z6;I+h}w-+k%@t2G$*PD+HK$ti+Cp`!(TrK;~#}{4bop&~8k#c9xl?T@I(+fOp z??-Fv@UoQ1gD(|O2OPh`4cPxWF;St>L#{ej?de6}p5^PKO=Y@(Z!VS!M;m#1_zH;T zrXYUWC&?(fB1hvZQg|l_>@RycoZ%S2A@0jueEgGr?HIxqKSYjHZDl(uGQ+bwjCFBpChP|FWF z>2TNWoX78vg!dQG{3VBYKfV~aU8MV%z~`GD-dkC`*>f%dVTXm{qwd1|vPbhlKWAkQ z50zS|-QJxyy7N8b9>EHe+KTtW;4a<7l4Xl#|eFASVc(oJ5 z=S#jN!Bqgvhnt?BKo-TeyMX3X4*z?xlx2ucKRmvFl9_&J|NTcL+_p8JzV=-1LAdrX z41X;ej;ZM>Z+mroUnNoe8o-K?GL$lrDaOr1vtYv@I%aS}*=rN5pcP!?wZ(ZAvFfDQ zaj`$-2JkKJ|M>jC-wf4lat_wnG03;9^P(S@oyWUi4T?5ZULc14D@^mAo=DE8uHr}X z>o1d5K3g0#)Z+bQL1_6pzRzb|d7MlKqF4_je@) z<`iw0#;5&)e)Hq~bupR2dq6%zD~4X1;{S4De-U}2?^T(A(Vq+NOWckLF1<#?3`PnK8eI5a& z>Scvo)xufcEfy7U95dKPz|S3boq}k}X44Wsji61LO2%ZiN#Uyl){7JIS1LOAiiKmC zmYyahKmXV)kNpt-v)j@}1Y)_uSma4YE6jc4ZkBwN(DUdUbL7EfI0NCFsFw5Rh_Iiz zn|Alo)J_*0k#0vjp@VayINHuCLsr9I$q3}zF9u4Njx>@8o;1tRCa~_WZGZhN#84ua zufDD~l?qkHtf5B1(c=|$pp?<<#FmSgVIlWP;G~!vDi|Zc^Fa%*OJLWL(Wobg<@LV# zN~Z5SErZ_`ua$$^DuEx!T<@u^D;=Zbt_y&wM|4;>(<$&Sg_6qr-mQZ(Ef`{}rF zmb(T$NBUuFEJ=H@_8BI`)w<}xs%PBu3?+N?z{=r$l61G@G-Q6f31!<6pfE&>@*iwu zJG{SZqXUKNcA>?OUa6qqUU5x!^_W+!9LC4%;w-K%nLIBTRSOyk%{v=Dx1h@oe%0O* z1TgSGrjy9zr%$co+D>LV@ak*5r2CyIw6t8GY6A86$3kQEK`^XW%OC~8KIbY$z;Gs$P`ss(5sAwHWI0XE9dhSQpNE@4U-H+tu|@9WgFB3oW! zJDTr*h1;-%14@ov2>Oi~ZO2DO7YM)2YNj-u^G}~oNbzK3FzC7Vkva23RQ~8nvCWr@ zMR~Y5hF$bN;a53ig{Xp(^DtHx{z4}_gtB$*Wt@k_gIsaH5lZ@IhTMX^bvwI)xo*+% znxv{xy|)%8)tQ337rEc3LB43acK`F3UVOaaIXAu2MOo3WAj51eih(J)NffN+-F*Z@ z(r)5!uxBESJL_PI#T7?n#t*_(zINq8RW`jl9u&ujU=Phih|nt^r2&LvhN5MTyG_+ z3D6Sy)>c`S!R*qV6i#iruT=qk(*{p~-KUO5)DE z78BN^Y5Arw)Qc7r>BwyS-u33KpqVFM%G|vc8cWPWNV}iS_r~FPi@{tZ8=aS>ebiYv z4wWQbVBhHQ>$MNe`Zk#m18nv&ElFDB9Zyq>J$keo9uO7}Bu^6E9^(Fx65V)UMtgKE zClL%ZD3mz>F$T};n%vKGgT@Y$yWLoONvZhF}*ejpj?uf_k?s!efRk^rc&L6 zJzXetW|gK;CL);G4X&w117GUTFk_&EJ@%+A*mb{pD?N*(E=LWrV5Q$fK8;g(~$hduVn99Ebm zmXZaAZede~p(b4|n!LSA`YO_cDzOEO+azT8fj#c~M3AZ&ex$5v>#*lDKl8}T1$oj2 zRr$(By^&AsWh0aE>#FcoydwabfMZ{NhAHQ<#{_A7(P_heCwk<8Dz}*QgqnIBpOiQ1QKOCTRBthO&6j!fCK_lhRY+;A2YO)v&vg_v#EpjfOHuqg%KUhx- z7&lcVDom!T<@a)#F)bH*aOyN4Lb2Zu0m%bgk+_K34);m$oDa{6v9kT0g)~PjES>k1 z98UWHu1{5g)#b6j$h6oCgd3v>t zS=(FZ+iB5@l?mYyhr?i!$9m(D`f-g?kPl)#Wg zJ=l35+Ba?o_yqFX=2e;3A25}ZoqgLb&UgwIol{_w9YNE$@+Ne`XMsLcX(M4k%4!L- zhL+pDE^ds*=z8pW4KC8Y&$n$8<7jpR>wNIRcxtpQKEvJd2aH-n$qT}o%%L@Hk+?fj zMH*nuQ*R~8oHDVJp`;L&IdVFhz&6>v*>46p_;%z~IY*Z#sR=DWUoX5@1(kOBbXDm{ zkW$t?{OHtJnzR6BycT}&BUSUNnyMy~zeeg_WeJbH^Nh+^Wl4^TY1#LJH*S@l<#?HN zP8-pC+@+#_NJe`etSYuPSB7igiw2!5F|^h=&#~Pq!GK-7k-5mhBi= zx9D-T)-2<;Q3Wirp6XYxsdgnKHa6=_y^OSGiNnhN>c|9^#-^S<_E@IgR?G)2#NXNi zMn|rW6IrQ!)YYc{YAH*g-A7TQ?uBW& zcwtk;WVWw_5AkKK-!^V~Kr<5Bm^dLaafDu3M;VD$kklsIh#pOm+ znP2Jo?d4;$GJL{vjoy0aJ2HJ&TF?$HiI%*m6F#>qx@i(^30ccVDTL!WTXaK(sr}v= zP1(xO?d_YAC%31`z=z)at&UqCVfSRk@H%};@4#I-IfqD2;a20NdEQcu7v4fw*`n*+ zSP0a5X`@ic4ezq+{%ucvO-Sv9ng}Z?yK|)g+38k%=&34E-q&et3SY8XIeqeNmq{ed zzz}ipq_%pE7brP>usRXF7H=deAks()hwg5W?l`2hAV_z&(%o_BZt3ps z?uPrIzVA1_Z;bo*{<>o@_@kWjjQy;=_F8kzIXA@q)hugnC?fm(I5HY%>6f)7bsJ$T zK~>L;O&ZLZ%Lu49LJW8}sgnaepszAe*1+KL#)u8z+y6VLP>_Ih=*H)V=qSvrR|Hpa z+DGG{0MA(_(3i?pw%N!e9CyqD_%kl)THdUBg}ibK1u~HN+V{&2373kC5y=0KcPhx#pbz5g9biSF@k4^ zT#t{k@LY~^$K!sos#x*0uoLhqx)N>a7ugENkewZH=)I=6y3?$$8zh=kfQ7krzpXK2 zXxipB!(@}e)2s9sPq`J}-xx{|VhHIi6&VdDj#WYRU_az-`JuHEt2-me^<`I2%V2nm zh~{%rm#n6moLwUcTL|(*CkTTh*ZvpxEP<*Iq*)ycr@l4PLf!GH*N!TY54PM`GN-~k zjyC_QVzWOLCW!4o_GOA0jTjup^_C7j3Bg3_ta#^QyJi)iqI{`sBZjeA02y8sSsD&% zJeuj{Xh?k>{0v^Au)8v^r7E*P)M_@tJA4oOf$h|TN({C1sYIY$V2OLz9|1`9a-W3A z$^SF@0NDDkz4FW$bBKGjrR_!u$UjojX_s6QWP3rkQ5X9R2U@zyN)gx*-@)_UT#68= zb`8hE6%8qZ)H#dwXZz^4S#_Hbu+a&NCS-*d$vLE+^7!_6+%qr>JDnEPO^H7>nBx&V zD8|b3tGMYKa1uz9iEa_k3U#11 z#s+3jj8IX8iVw|d z;wJ@|^BXat8~OsD#s;7a3Z5{Nv6^XfzGz51$Mc&s(#^+42b%NZN9k!y?x%yNsy9#1V=)baT)Fk?Pf= zylWK&eWR3j9}R&y!>mq%9K52K9&1X|u9Mbdbh)UN@=JFB%`U#YTbMj7NQ!olqsv%L zchNpD{2fJ~Q8vJ?MW)p1_vWQnW^kp$$);9A9CK1JiB%C%p1uO+)?nyrOBvuN@Yf4K zQ2d?8gBZHQWlrOve!}b!wdu~0YdEd>Yd6^}7Dob}g0nZCZ}YjRmEIGNk$rX`6SG%o zuT8UMv})K=dWvj_F4zU&>+`D02Qf19G>5@nk*hs7EF!1qG?*neDM7Z()V_L9W z1U2X!%9&|VDrwFEAa=;|b&rgHq|Eck|V_fmTqi?sh%o9~p$HjI~WbV>zBn-neFV%H?>unZxax1!}@$ z@{7Y}K_(r|a*Y4*Y*jyL&20VNdbDDim|00W1#`qVdeoo3W3kFfcK@6ayg10dm~HIW zpK&O&6=C`{R?wpP^Fa9>@%YHdLc%Rl8DqH2LlM+MY<1ZYCpQKP7r~MM&OHN?jcawZRx?8p|#8d7`Tn z&vz7SiIh4WfguGZ{a#+J>i*(9;MStNP;1m66KV&1*^dstgH%B~v3M905YMikpPBTf z5siOC$}s;k_Xqe8r-tfH+_2El%eh>G`U8oU92tYw(eL6v(jSkgFp8gsK%=p}%y3{% z97EaLa6Q@>DIw&U#Wjb`s}Kmo=PdHgg{(iG4T;eUY{24)&6FDoEY!t}7nIwM4CMOe z?c-#zj{ApW!2=>PV<=U!w-Nt>p)lYZVG4i@iz9Zk`R>>$+%)4rG`G zX;Sy5hMqX~GJN6_d=HP89FF(+4dXWnDXAZ2m{3-X&A*yZ70o~(!o6+v8- zm9`s^Hgd)}6kTK@pMDpjdlZ{l49M`ZPZY$BF^;hj@p1C*O>yuQqsXRaer~p z{PB=_kvfKKiMHWEDxMXR!}wNRVs)!IS}7(J5csh%%N&155HC@FN>~EVkNAd(g+;Qy zo7e3cdvEHfC0puK3@h_-OsaurVujsDgWvI(Me3~lW7$;~5414oHo_(*RF?;zysVoN z43dRfy=%+9o_yVSG;wWS^YigB!MwXl8|TKy-)FL%{i2w!7^%#$MoH$#(6Q^h+QE7M zaP=9V!$#nN9mk~d2hA&*Kn&WSbib4;&8_kk8Myc(;MCOx_5GSF1>$Do%QBaPao8vS zu!EBT(VZz?zZFXS)nTpvM1Ik9okK}=At<%c9}zX)7jf(J-Xzim)euO+t?7Y-+t2}h79}u`#+x$Tq zt)Col1I?!nw-)%@r)R)gMx zB~ld35M~8bwT_2~Azgt0b>UU4m>hyMI(7ruA5hRg5eUq2H?%&5neRJWS8uM4f?&>& zEnA}*(<7oly^;x|Fo0xOu--8woYU?{j}Y2JqnjKNr)})xDnk^;2#=8@kwc+S7KraE zUfSm7R4SfHpM2epBDNB|cDwVFy)wTu+GKw`>Vm^z7s}(wJ?yPCS(>GDZ__X!nWnq?))Vi}*jtkR9#sH_(4 z+l|wZ*gOVzfhv9^qd8q@`i&!4jf{f>6flH7U2Ygw?bElo-_l}Jv)$la@e8nHm^I`E zUQ3_*q8JQCd82z@_TJ}KQ}OnDa|G!#wKB5j6f(^XyiT7pRJagc z@AgZw40#gJylzUG-<*Y3|pIN}2C9stM!w94e4@Tu-gK#1fH1l-e;ritN%aZ*j z1@K@hn+&n~+X3H(J}JkHTaxC7n3sVt0whX-4#j{1+*x^v)(0@FgoVw}X(oD)szSce zA14T(C1(H1J%Fl9C5in=nW2hr=fQh5wbp7O>$DTI;_Pln`YDj8at5fbFLo`F|ISu3q&c4>zIyN}GerQOr(m#{s<% z0@lIlWRZqBd7^D1@8$&_95ghypx@i?#0)->>k?<%Bkqf`i8D;ZHmB>(=lh%pB+3SS z%UvPN6Jf-`U+~alCzVC$W~NG(7^%{F2?RqRk+w|#^IbC2 z-8#S({6`}Gvpm7ghFDju0FUm%*D7fiwuYgNpX}Bcu(nYA7M8fX9m;kqq2&YB5D*E- zr^EOTbA%d7?^lB`1NG;q_KtOb!$E3!WEtYo{HZ~nMl}`*MGFo3lep$;%wB*hUvH+I zeilh3)D+b^*q|5xBB7C9V^XDcOFsY30Df0e79cgduTpX7cZF0v=lT|kXTsY-dC&xZ zM>xLW2eWY*z;vab;{~d)>1d{+Kpr8NgV{@PieVt{%d)mg^D88)rKX(ARPxqRU7nFb zwS#T;4qPL2i1Qi4Y$eW5`V^dx5HpFKsHmu!3$+irXb^Bal0>XUN0U2cFayL^xnQ5N zM589~^tVm*EfwT>arXc3;v^Yj{kl2NW6ze+$u4@8-AEyGSUA{O=>75)*G|Q$WZDUr z3v@?Sg+h+C!#gF^<0X$d0wRxVk8@nz9c)w%r^7%a#;xYf-w80yk6a)E{ zphO2f?l0$dx-!TzML54{UFwBmk09Qh*~u7L6neX@JnZBsU2nu8-9r349=MjHWD>iC z$1K_wpuqN|h8Zvsv=LP3+dXPZ3gZhg(ZKq2+HMB6fgR0OkeOU2_| z;1Q@5rvd{Q5dF`w_THP>R9>d)iXu&Fa7jKY_8|eNfdq~g^)JdOet2kVCXdvfiO|F& z4SBMB2q?PpeB=yxeDE9|U^SH&47g#`FEFh}M}k7cyw4B)1-}%w^$;~ z4)r`8yYrBUfifE}Rr#Ew8SNx6pLUbLX33;j^0-zn*}u6fZ!j`03HM{_ldZwa|`y`wW*LJ2-m+2~<`D>crJ zk7Y%SS(J_Kc~!bcSQNWQfI-hy+9)TO^Qnp@GHx?YG25xxOfs9eYRwR_iASlW!;VXoDYL01s3=aOrx}~-CWxdmp@}#ukp#g~(JBp2yQ}^*oJNR;6O-tZW zqgbsX8-WF6?aTf?XvzX%@N4JL;NRB7BH$a`Z@#B{1y8w1=l-}oz%*$)jvnk^QX4pZ z1eljRMkZ3q5Mtn5OkLw5+=YPn=^m@hCPo>e29nsjotE6O@}RB7mX)BM9h*47>4BS# zN__Zi7V%tqSq${4CFy}v`2Xwce80IdMe3{oo>Rp+bcbIGR7#Xz#%o%_d~O&z%btBR zS#Bu76KXn^o#(DPvF0v`53mCzV(D4|XOM}Ta?|4}9nU;Fxit^Z#r~0F8e&uwCykl( zQs*}{p-TD>Q|Q&p z8D=U5Z8Rl0`4ldP4<;yKF-*q+{Z|?_Ry&95$Jam%b{S6X)8azEd70Ajp!RsDOX3xe z(v!$q5X!&s^YbhFgINr_twGk;ra@w^g=bd4=P!j2ng649x8T8ZyIrH`cLk3ONyIS} zeb;Cx`#_``-7=CP;&2DFB??+}=7TlZAftd99w0ugiW4tcsRGUa;*#qd|JT2wc z+TIAgVIHUYX~Z8P1MLC&FWx1C-y|>mrT$O9e%an6m0$_jtuT2$y3NHyFb3u63~d*) zGxujIG*;j^$zFM#?~LIwTzGJ=Au)=+{{h`m0fLf0n5#iX>x_*(!}fObGkceO^dd~@ zUo*Wd1wEL?7e*=)y50Ti^_y=H+mD^dFK3!fIZBQmu9m{mK9{Og!1WL|pplDjm%Cma zvKG8WjA)sIdq<-vdXy5k-A$`p_*DbU8)gVpj%9D62mZ+vzTJECS}$@Vk?1tJ0j4~t zzdA?(k~*XQky3#&tM*wN7rbJjsvL4KL)Bz0FT8_dXCTJdR{dZ+)2s3YFX}@eF1iTJ zFaL7lzmP(wayxqiWx&o1uJ_OBp7A2v#HOYu7frD!3SW?bVYw9)48ptNSL+d&la12w zVEchYRzcuThU=ZI>n6Kapny_{*uwEI1MdqSbSM#DF=gUMLva?<(GE5W%g&1PlarAQ zi3k#3jIc;SW|W?2>Zqp7WfqhbR}0keBBbXu2E_Pz>fp3l+yUs>j>C-C?LTzp5sEKx zvQe}u#U@jRzSER8Yh6eSRDj`0MHMKBfq|h;4K?}Au^xO|^@33-KBsJ+JUN{vWVrfs z$I3o6#~KP-{hyWzP4pg`jO*a>;qC%dpqLkVO_}_{=iAcNsVXYaeU;oA591%fkQL{?Z$G_z~t}t@>)Dfq9pl-J_!t zSYvBz>&EYZITEzwRBx$cmtVs(LI3@o&nqaXQXo;PnuJI-guH;+nu9yx{2xL(erp7S z7S}^x8lNYNS$S?295yH@FT6}q$Kq-MKE60g_h7M6shS^(5x(c~)?%i7Wx7nCk=fah z3FAyj@$8 znB>Cy`*&hgnG(%fZ@CO%p%<)H^BnGs8=1v!a+l}l9kq5_=U!_++F;+}x8y10pkQOe z%^Xo5Bs580y{oOQT}ktN98t=CDN|{ujxHv2_j^2{y$3WfoAD`bQu~&WTO$Y(@*2Kygki!AvITsTz)rPO& zO0x-sc0c4PcmI~?I6pr>509-uf=ctLx14sHpqtYTxe6mGx06n+x_KoubM~O>R)HS;|;9%jw5-q z@ldLZ^u^xPlrss3&=+tRTBRr!)sHVMxF}Dm^e+`!O|Q#Z%wYSy$W@`rFIr2Ncw41O zyxt^xGkej!^Ar0s$3S8A`VNaM(2=P6A_Fa%qy-c$?sB=G@aTJ2P|`H2r_hnRf1p-w z5P%sb_~T9MOoee3YdMg3O-9mv%R=Egfs$Xa3BSg2PFaIxZ#?fiP=lp^{{9-9Nw0(U z73NFowhwljzd3+pxxc?(X%kJAPXYpzG05#WJ~dVgZ98K*Og78>d&* zECDu{pJ`Wtic}g%KM|M}9~X%{??))gysgc;KU`D0An@KC4L zJTN)rJx#L|Fk1zZ@qejRK!@o+A>Way`YJ;l36ISOt{upgUx3B8ad7a3tDPuNF_*}3 zgO4DyDgpLmXM^*Z&CV!!3Z+#Anz&A3!ro*NGeSI%>*Y_+r^i`!>Qy-(>+Vp3DiaJ9 zRajV9I_(DRa%0iK?fEKmtPV6z$9>fUTiUf;jhbT2q=kB?sb9B#ZwUf{2-@|?xFxk@ z)ToxcB0iePR~l26O+eNX%^>ME2F}iEN&W^^$(j$-9}y*n9yVk|V-Euk{+&zTVBpUW z7v>5ljR%thx<0o+ds~yn1sb}_8 zsq!I-X?`&r%BrvbWHz4rt0?o~_5$eP-Lp$NU1%6mR=Z+Igb)mdQni+XwxhX#O*ILn zIy+OU``z&*rWy!61GnCilOL{NqFC3NHR@GG=t4Ms@Yus~J2KFvFw?sTVV}_nw$q&a zG4pGK?`iHmJ))1=fFEu%b?yR;pZ+vMfpGTQ!!Og^P6G%>kooR^(x;<=L?cxx$OGO- zD^^>mSsK18$liHu2A1BeAQ$>=q)ty~ib_Wgq&{%2sG_36F(Sr2AtFkZdqX00#l0_@ z8s$n241-CVvrqYOy%q)rvbAMxbc@jI8b<00*DI@q%zbvt#Rg8>jXqy^bc$_m9UYzX z-3dhCOz+G|^Tp*e^%`Za70pU^a z`Bvp=m8EBnD2ppdUUceFeg0-3la=XmvHQ#MV3ta~G8Z$D) zNzJm(ON5h@{p^#YfA&s!{y`yv-j>HB8lR#3lhD6)!i)Zl2Tro4QJE+TDF6aez~}SO z3<80LVj3e?cWJC@%dbXJG==At3}rTeO-Vdrij*fBK>~zuHD+kJjJ&1^l4*Y|eK>E2 zl8R4$0~u8W+-G?CXeNDOdMd-CELA#k@#0{Sd9b194gf^GkxIB=IBc;!+}a#Ssw$m_ zfHRunxOdnsXDf}W+6DeSB(V2@^Xq3H(BD0;>S_poB8#d0l3pXrf~P5VIHWwVfB*Jk z@1zx$XuGEvO-;Gb%a$UiK>`g7QH8lhjZ*?EVH9L?GecuV!c~zRlIipX&J{ESg|p(T zkR15G$G}pVIdi@~)?LU^mFwJ%2b}E)K-)kOI<71SPerEBB&Ams&CIqz?v7o&u`eyS z1Z>bTz)quZm6x_V(|<$ZmR2!0+|;h&KO{a5azHo5(K#^qH_6jU@q2HIq6KBCK9J-* zR{{Mxv2<$o{6wg(ksVOf-Ld`}dq8!cvtZddOa0QRW0%URm}Id-OG(ozs{y}+BwglG zv#&`S^?SMBXgJ6qFl#LQaIvwVD94sge5zP0#MrsdPS9RcP%V$v^|R1^V3ty3@{uV7 zXkk9APt;iJ5F{v*EQzE2nrU)(Z4^<2!c3^!+J*XS8wjj0SQHr0@QMF04isG8|0@i7 z4@O6!%~d7Zp!-}`q28??BpXMBOV$&4AKzlI0Np{LeaR*fN#xpr?iB#7m-G$VB}#M^ zFz^XJ$ez(A15oJ@XrG#Y@9vlZ8i;{br7NvjU9Eq^RM9s;WsKfVWBc~sfBGAX!0#`< zz`qpP$Z?SU&)oH|qrw0GH$W%<|J}ObqqUQ9z&^i(QbQ=2)a35N1+C@n`1x2S)q(k; z6vR>eldI-1dW~K60f>e3du{DSm)jaEO9k`tsaQEO|5`sEZuZc#j({?_x2YBlTSDjX z(=+6PYhBGE%_QdG{B(d4A(%Ye;>Vx;9LA4LqqrS{&q>q0gfd+b@vY*FxKM)~qc_SJ zm{U{)-!%C1LD8x_A`=qUgwv|9R#EV*S-;<(p#wT$V(d@VDGVCb7VK`vpIIw*%ZB&h z_ZL20ULXU4JunDRTY{kz7yd~Bzmdapdp_d-(q*I9JJEPkJX9;Gt&_; z#7g`v@xQJEjSa7DNiyEwT+%}YJ)U1{a-zJ?`&tvpwryZJNwtC%Wdyzn4lyidx&~S_ z4mS4s>w#TOly-M_H{WrB28k_^qE$fFMAHx-&e`X+g%YXmkdUg5-|BZAot+8&@z@fL z#qA#SlOFefze1D%DtJYbU@+PD--$&?^OAJ6qg<`r{v|XtjQ(D>B^<;JX9RVnC#Ctt zlJY_pC?X9qw2I4!9O@yI7gc3%Ss!g5E*69H6j32+>vMB zfbY-8Tq-SQP=NX;`g#Cw)42e<3dwOxU@I&**t_QHDfO#_P*E}K4mLbIyuZKykCGCn zQB#EnZ6Qc~{RU6Y?Rql6L0pa?=SSQkz1yw*=OivdVRNDWVbZcGaCqC|+OFSd-qBXL z7a?Xi*HXmb^tdfXA`ZN955GRRlGx*Ix=;~Fhw+6b`rP&DQ9oB92R)JP;Je)xLy~zT zpWAix1?pJ}Pdy)C#2n)BAWZ(L*m1Vfl=5_QfO;skGN2Lky=S1#VGaZdZ7pfG>Ke(& zDYIUZLya#g)ooAvdN#n8EuD69e$Jo9YiXJG11ar0!fd7x%x*v9RP4H%^YE|s{>Ye$ z*XlJ^K4MWGJ_Q9ES9_E)+M>7dwNp3EkGHUPS`|jHfPE{g9ADKmO$aIWOxd0;tc3QQ2F>pld9da@yyL zfg2wzp2QUM|lUgC}w1hWj8v;8}oTV0{IbW;IlseYfq*J(k zbv%*zMNQgx%!B;>1=@e`eDHi^6qd}xuzk4Q$pxG`@GF=Ve3lJRJ(*>zEx>aXt*yUE zHecf)uC9f}byzoBvU)3Z7F6Xd?C8um{Te>l!|5cTo#NpP$R+;y-O>L%j7*HE)&D%8BGM{Oulzlh@` zwyMEo*GQo>9bt99-MJ$1_>@F}Q9PEzC_R2KUn>EzS;@zdxYOt!lQwG|539=MSZI2% zE}Jg?AkZY4kZ$`>Tg5p8pcvKIat-?3O`wCavXd^e{N{I}TsZ)gUW~byXm3?juCA4S zarYg$>7CJvQOIpS6y|fT6@9jX$dgJw`wNm7a6oxJxkFc(Ie;>Rd4j)=Amw*T99@iM z^@sa4ZGCI`pb2*ls)Tq8`{P+^Dw6d@4+iaq;I_E!ZBL&FPS=kzs2&$^7ndbqMvN6{*x6}z zdczceejb)0HF`eDk;*&Z^DM5;7}LgAlZr^|PnTL-=!t5nDuI0cL~vK4Sqq4?oj07p zVi)gTYGMHm9Me!Xd{1|ngH zjCZB?c9g`Q4InhK_30$a^pVQ6xj8xYA9@+Q*{3JX7aMej(>oD2wHNH>s@;bs*^CE= zguiOg0%KT85f?W`k+nirWPSwHQ$q9yl7i) z)k)GpFqsR%WA}AsFWbEQDAKS!>(Cs6+vxT9Ad>WmgljKiZ~vz0W)n7<*Bx~rso9dQ zd$R22kFh}B^Hl+?rgjW}N)xX)Vl6{MB0dT}kg(S{C+SIw1<{Lp-Q8QBVMq-`ANYun z*H1CZFwtQsIrP^`Xiu(|u;d!1qc|L{QLF+=`%+0i4eC^>UPa<8!%DW%lE%qp(GA>tLA7kd>VB(+8r zgvDqu3!tWZVT@&b!t~KAOx|BGqH|T96DRd3wxBO<^+;|uC+A!CXqL8TIm3%d@tHS2`%J@i)kO;|rP{I6K+d=jz zCNOTJpB=YAFN$JF|93nNHlGIy@9j38{n28Z>OG6{>BUqMc0rq*VG+EQ-e1~l&hT8drDnvjQ5%NhqhDHyRUr<1+ zSea)iAEbWFpxt1kCw`#~qtO{?2;3AnItLP3Fl&BvDLxkqAF>Q4_=N6!x?ZYA3xFIX zt7GgV%x1n59CCN^g{q%{PHeJJq%)iKCc-_<)3r#T-0@(_GOWsEWVj=1FtJLrW4S^v zpy6AcLxBK&ubHKC?4-lSP+C8C&ZO*v@3%*9{-D6$;V2TC*=agPU!9#3T`&@G$U%0?^QLyzXn=ZJ3ucRj+7P=4Y70 zMa9-nEiq}9 zI`TPt?Q7tgj0?kNn#w>=JZN=fEj{5snz>Cv<+stIVixXDRlur5#lD;I0R z)4xYw4Oc3N_8s_7W2tebBUwZ2&0hN#SN+r)OA1XLXr^Hv7EA~p{aIQ zpt)8u(9mZ)8cewszbkvLH2Kw00WOYu#8$)K{st%Tn>y!&x3P{QBpCN1)O|v*kwN=o zb4Mtq&@X}72n{+tMs}*8kG_Z*?iI%5KSp2=i?5t=1`?PHRJHGBCdVLP`a*Tq8MqV% ztsiZjokATtnWj1o*X#iqZX=UZF29Vw*tTc2vDD1I%g;Fd~D;IwA&(jh{$>SKE zmNXyX)$fwTB8H6liww}sRhiobI9M1#$7t_~tWif{VyG(jr@1GE)*26?2H+H#+^zSl zSQj@o(0TKH_|Wr+YFD%;!{Ay_w+5i%utGtbGUvgMr^~k&?@{+&cIDr#g%zMx?S-i0 z0^qB$9b!{XdrBbS(2u>BO(8ofv(!Wod_B>4zCF^`VM3DwWZ4AD^OS8l#tdPp#xDfvq=IV7%)`8vEwsS^o-&b zjrzdz_?23NNc&H$nxWdDu_NB9Wnx+m{(&^9`QKA*|5Q!C4BFG3c!!-L_^1J;RsS|( zokt`@Bb$&pQvR^=$x-WSui0$E2vuCk=J;pbM(oGE>dT2Y#fdzVO}X(3sF^}SsrB_q zMQU>_m?Mc)J+nVCKN0ZJ(apovk=loLisQaDmjacUZKIsWCkJ@Vf)!Fp?smK1PHXbL7Af_;EC@XdhxcRpTZ3jH{^12k#|7N|< z7Fh~O99w@Ts3OGg;T1pfSc=83Qb}xb5k$2V(W->=x9-F#Yq!1wul>-zqYZ-I}vllO0~Bnv+QT419Fr#^z`BACC9_~(pRT#0%JfA5RGN7bK4 z+XesxxAR3#r$3*B9Kvtm21P=Wf#%+}!TXU0!IgaDey?$mL=jO=PYsTer0xs#c{40T0DflMXbMrM`nLG@4n%2wGq3_T!YbR@RvfT zOxz=X^*Y|VXbR2#k=t=F$+-AJBsuwnqhza51U$_(Un@&n>JXf1iM*yg^XM6CbZ}PM z|HQu4o*D)?2|f2!nT*d}xz5q|fz&?yU_<%XY&#Z1Xt(mN~)44-=k4YH;;hD)~4v27MOzc@u(@yLjcHl zvgOM3(ZwprkG4e{qpjZ|$5%_&*>a@sa%Ep)=(uBi+8h*WDbw#^yi$2fAdoguAf^UH(W;Y#Y=fa~CpU>0vhqCf=r&xQU+NhGot>Ona= zt9JBKS@#^3W*Es@n!mM;9d>=%0KWidLf1Ejz-Y;zE>69w%Z&%v$0*F)Q7KwGtXF&FS5u8DPEKVw6aZeIE z5mfOKiWjA^bYPDJO22Nr>ZOeYoslhpd$)LkeTtcbB9rQIFD292#ngyL-#dfBRDxRZ z*p7teM*%PKm`GkIWE~wvjL+H1{pKXRg2mcGkKIJnW-=Zp-!awVS^kmIxMi}&G?zUnr=BYTL zoK|=N0!B9LBq((BoK$ z@So0;6M$`{IIHm!o>8EW++Is64Bf3-bKoySI@h|hAiBg)h_tbEEhT_WNa%pM&E~ep zJ4S7&k{Z(}I>f3=rM+1U@{ZG7k~0q>n1s5#!+0LNh*j?bfx~qoPwcYW({7>+`Lj(9 z7m*$%jFo)lI=7AfH6+vu_3^qKZ^uM9(1Z5lieHy{KGcQ3KQ~4bo=|2gMue-!l@-Lr zWpxirn?K;wmg#PM*hER#ED4((CS$0Ob#sU)cu1*6-Knft+Hz*}cZPW)rGOmFD%8&0 zeLv%f{kh5Y#(;To1s1K*E&0Q*HqIPXlOpmcRW4@Z{@2Z#wX$m=>?q*1AEvX%7gHS9 zdnMQD>a3cdJmw+qJf{kAnsT)XV2@NOCd%*q;I8EKFqDI`5Nz+#i+h{lE>KlrvT}&iIz6lxULJbq zWNi4MP8{R+&ist3*TL2%>z~0(ONIzg?}!|w*meKOZ^LlwLuo`JLcu;h(Cq{5h=zU9 zKWgIG8B*u`xNnFAZI)S+!!H$#2Y(I{oW$Cn0+Gxl0lhG%=L^)xLcYO`hv9isFNJ*L>iW%0`79I5Y^1H{FbO)!; zYCNP2Wsl`p4*EPz;0&BSHB`wV|15Bg>oY) z`*@~yRl&W>qcAFPjgpEcrCN^m655_|^t>wtm}!D18U<`jfBQRNAnDgh!6&0ZkRWQ0 zdfO`~n}>C=hI)qr@Wey8G{O190sERfx7K8$U`J{B81~pz=K;GcWN2^X6;B)^`;dlK zuoe$@u&7Ezf*4)%QP6Oc%ep^m`s~U6V9Ior4e@LLuZk*PFqT~01-r(aey;iTsaNVW z*>3#)G|W^&z!NCTxoWVS=BZZ$Hyd->|BZrFB(e79`ybqMd4w$d+ImYQ>ZQ|P8^L2f zw6DJ$Eq%H>R@Zl!IqYuV6>D;bdHW)eTBmCEtVhDvo??KEP`_W%j-V~E3l)Cfb>#Z2 z$2u2ymm7IGwDdL`mJRw zC!`=>U1TClN$X1##m0y+sgSH``FnI%>cc{_lzZ15`LQd;viv4;rsOTB1A&;g1i@5F zdnVFlUHOo4GGUrv_In1U}53+gy&Y zju6A?vF$T`WM@ZyURIr|PZe~~p7my-8dyEvWMpds;XYlJ&OE^3V!YZCLP8>cWkiqH zDUe^d8|bSXbm?h4Mq!syFNt#<_;WJ-&2GR1(9Zs%4a89Lg>*quvA~5#={v@030urp zJNjQubVACM%qwE=Ww>?ehBtrkJty98(1dD0mT@=G;bH$H5Jrw_v4jM~bCIVrQ;E*= zwUgBs_;X^|i^2SQDBPu)RntS~sWAA=7!YwDWxy*FlywO4e=jwiVO6FkWV3^<(J6kj zPQX4G5(y{T8HPC^y+_{Gug>ziyu)sY*f;=K3L zA~%}YV$onjK2&AP&O~(yA4TgjJu~i2^?;J!w6WY6b(S3=)eBBb2_W8GZ;e_9^9Bax z9&A+q$@I2Kg~+o?48(K_^nB{#TyhY~uDg6$-Vgw4;hmVdYop&qTMob2AWMi)hhxoF zh@wfb715+ccX&`zF?cZX3}NJl)W8Y`auea96%^2F+TU$CN3Kn~j{XI7d}+ z%msYitD?{vjh`bmpZr-0S;^0x>X48LD)@-_Cr<#kVTu4S6emsLU)I`nH*{OO04k|+ zrGOB^S}ktXiR#Sh>Z}@C8Mq7%8D8Ltw{dtiltYrsq*}mNGV*TnaCthMRhC;0|lRhSrsxGMQX z1JwmYjHnxzLTdu;9Z-dO8uU?S&JA?k)tc|v^)Om%JC&v>$aCT%#R*0N|Aef3nG2dc!gQ(aBy9&q0mi68`GuLqE-=@Bl<)Xi_pqy-7L_Tq>;0)%ncmJSw>w2& zm8HIz$3F)o!-LekS8^bB#Z8_ zW*OZjs*@B3o6C8CQQQDIrUZugURQAI*uh-JH&y8!rtE`BnM}HY<&^b6PeEFy zP-jk6E^BR+!Q1LsZKp`|9rw303V3CyyNqwoAO0Xjz=|Y7C$BR?nPZ-dM7{WXU#Xs`7pZpghYtp#Lr+}zX)?M{VDL$<`ot+>1OSf$ys3Je zWQDK0Z#eI>rAXx}Hpk!8);yKxy+ZRImhm92)&7`PkxBPbokJm(QRJ@!x~RZ=;uSU$ z0kp{R)5!@+A_VRz3V@r?PsPEX*yT#tpLm9mHm$0#Y3os3Fs5%x77SBAlm$ z(iZd7B^s4ueeuDzj&r@P*5ohxUP-!~?5JS(?D+n_f4qVvf&56?taTXj=Uo2}QVc}h zDB$^QjN7&~{;$*gFY6#Y0?<e|4dvOVGuBKC43ncq?R23yyV2H@cXGakMIQ0;%(yHnhZqTSF-y+Qxf(N3EI* zj*mYW4DRdeT%?tdlC-UCY=o?DR=iC`J5%r8WdC>GaL@peKyWbtUU*)%@Rz`KjkWL1 z**w>K611RGu^Jk9R@Rbmn$+A!8l+&k6*34v*1GC1ExFp98OfGI-(DWXTReE?;d7Ky zuCD44aex2;1>yAIpQZzNcVFKDWUd>kCWpg6jmIdpSI;tkGRHalefjNL90u+3#*VLK z!nsq1^l1t_ApZHx#>e*t|C&iazay}bkbp$kjK(pPha0q3!E)|WucYi zpi^92Y{cu%f0bu9H5imxs+OI6W ze*h@>Ty75+lKV@|uuJaj(msWVfD#0tL^ca6Dw5UNI}gB#V^~=?^I#A0s7$eiRTq%k zT^}>99Hii+aIvz+PVdjyeHO~bbT8Iv4y7oQ)70cvuiXCj11U*Vi}H8;Nfg{hlF8De z5HL;0B*mXp&|?K)~mr)S_R-O*Aw*o{d=A z&RH;nsXV@mjdM70t8KnPE8}_k08{mqfS}-Tqbnk*qtaK8n+=wBwpvbr%jpxRBb{n# zYpzT1FV?vIUuc;Tf&480E@7Vddy&v&stZ+45Kx61ndJKSSBRnnX&6~spY`hG!r89( znM1EUU}jH(Jf8|xOTQp({#FYRi|WGtmGuzxj+$D}KBa6IAn~!#Y2DV>0vEIr0sLYz zZ)OC5Er_u>r1QNuYD<5zu(#TD9N~I1iQss~cq#o$D+$RgFCdlDm(+2mprRT+*J`|_ zZh$J%hqmkdl^Hc>`-_OIN^VSTc zoD1AFoeiRlsR->ylG~xqR8IZJ3*a{)-+gJ={*AFhiQbGlh4j5ZVH>IBgAcqN=mYWx zsjI;on9ghr+~$AaHv+H)i4cU}{u!*OJ_uFt9Q$NE$X%VoW~GUQQO9i50+|yQ{*hFjX(uea=qWw- zrQu(O_wxJ<_;BmA=&AfY0ir2?mIa{D8aBDzEN||5Cv%?R5@!(>%x>=QlN}Z=UTOK) z0k^8=q@^{N-NL}aiUk|3VoP9QVZEbQhg$2BVqV*Z;9haM-+}-z2ua87gaGQ*tK&si z>*-B7_Hn1hoj_dH-E`HmND4OjH3n7&g$2b4OQoQ&uo7?%AjfIOVg7JjS2(3|sQ@>Y zE!C1Gu>}fmf*mls+e*`Tqzqc^11zpkbXs%e&<7fJHw(%VaVc?ea7^djtmjKrH65e? zigs+HG%J+)RUSC;KPe*|7hq#1;YM@9{@D;Md=w~XZeyx!4-XG=4F))0jW1*4yHrWJ zWKuW+ZyoS-S8MEuI2HlIt;xL7gnHhjfK;%)4h!4)>4wB~y^|kY|DLcYAy=giDCrXv z6jbnqU8{G(Wtb?p;ajj>O32mHktIkgj=_4h9eL@ayGpU9MMo*`aKp_W_9sUOWO}is z$-N$YzWp+qQ3vq7aQ-zp@X?Tv9X^nS&Ava@fi1{1z4tKW5R5Cc)tS}LE# zWH<+nXcjv|1i5v&)p|yFXLN%epk@)ZKd-YT-DKNu6b70FkjF_^A0wT#xgC37jsJtX!b1(P50qT!>ITQxT`X*s!D8=mK+l`5?Rc zDJVm;76G{7oGn}OMtqfe3B~FEVeYM?s$9D-P(>6FX#o)gB&DT8x{*e@yE~+%yA-4w z5s>cgM%W^qbuF6S}B1yccH7*xQ>bL%4Js1=x4l#|gPsRzWLq8VV1eh{gJ*X=nKMvTh9RzGN z3TA==D9Z}!KHB%Kr(Ikg(8O&PW!Qw{RX3cTDhANM3POA}Q>d3lrE(0XbfX;6uaK8$ zHo@>#awJ=BHg`GRLNH*CDhzbgo2^ImDrHK1Bck7nx`!jz*bhmq@*KH|dfc!RB=3O_ zyGKfj7wS%hMscXJ_Cvh(;J?_9&2+5g1$F<6*USq=7k$vOxds7@Vndzrw&I!>`#FX~ zm)F}pHyaBUYE_kjae^XPb>7*gza5>a_|M8=Nhmnlkh^Xxxx=r5E> zhXvs0dDBmxdkpelAnOgwnZbYkdXJaz1;AOD0V|LAwr+Rigs~)U47V|fnZKRUPf*na5dmR=HS6(xlf3=pdlEDE) zfBkL+@!xlbFMfLma33Z9=MKPU0Ah3N0J5R~8mV-+TRhv;EAl^Iwg@ardQNv@-rcqM z{|Cf+FaX<&SduZ;|FXTf`&L~S04uR>UfKBld$2x}fb{kt^gGgfWsrXmu}3hlE}&m* z(AoY+TJP_p8Sn-d9jCdP{y$%~cZ-ED?R#GRBMg6kSKM(x3RMhkGvU8%WbPg>9p^0; zz6q`3`yVWfc8i5Q6C?iT%i2l7eeTWD+W)nt_~$~5fH@+)c%hBVzrU>W6k3(;8sp!8 z#lueopY4(f!wuz;f_e{;f%O4a5{J`xEeXV6Z?_ zEP*2HoB#8;bXB3?;M{Zk(A3u>t{+S8Tkwn~5}%R4WQBy(heXb@_$p?I<~_RfSU~;v z!)!#kooEXxi*f(+&O^W=o(p!%)g#tu6KOP$MT69hD8n$3oONTd0M;`;iEk913Yz|<{e{(1F(u9}+-ANdtFTyl^4Np9;f1VJHRy(*J4j~uGNTWi$!@HNXL zY9DRO4Pql70)dt9qGo}b%PDu05h;ROmmjJXjbb`F=1pTu8_bvaVs)4|z56BxNCjeu zg)-D&s{xN+ch6ElSQxhW+@OpYZeR@3=`ReeT7MW;{u~R#i|-wE6HN_iU=#!nb5yL- zZi6X5sUZNK)Kls>)}?cEL#utD-p^jm(=U_ay?oCjW_!%6n8U-5ZT)o$(i!1Hj@5Gm zKQvWqkOPY)SA9+r4J^82Rzn{zoPU2ypOIMs#F#UqCW11(tj>DG2S=HMGS{$QVfnSZ zqj_zT-_!Y}52mPpZ?|tg#KDS+(;4(DGJlgk@2cIuQB&X-YI{xj=KXcOe;H(LW!^K0 z)C>6~P6#}yMGE(x>4};bAtRW*1CtBNxAS@qg}88T68~$?8m!wMM!NwY=EASk!<#hi zG*o0ux@QG|xOR=%jSkj13z5fE)&AUtkw&WXsCqdO58(3Bx^-y}WlO{K_xJDa7W%g> zF})!0%+>I!`h6q*2*p}qck%Sq3$`oto=e*x*e=TLBMPKGfKigAV4TV?6AA1!Axc2l*jI?GYJ=UfE?X>%0Lbw zPK$}33RLd9pABu)E(4~rMvD`w)Zg8tY>h8bJGmXRYwIO>pH3S7Drdi*aluvRG|w6? zEuG>r$F)Mm49C`1lcUJBkVj(JI#2&GdNxAM;!hB^?0%JCqAO80BcHAGVgnPGXL&V< zgt?4wq`!IXb3sVZeJjj8DfmzlU*(wn#rb?DED}=F)~TTjhdxwBUxKF=o~wU&8~l8W zBxidjpI_5(+ffo^i7Mf-n%{8aoV+N%pt8?%y<&teQeRF`KY;E+-5kC%lHITXsnV$%ga<~7>#)*(vU|rb?4r+Q<&ig02z^=Xq}_U{FuxEEM@v8;XKNfaUQLC|7sT1;e;U-TvS1uv zs@uEZkz#^U8EFaM8=GKt+-c_>gg!jHmE2M67Z|@2T4wOp&ctAmg#BQUtA~L&sne&( zB99J3vOhhB>l%hN83dlMeJ?2V`ClY}zzQYPf1<~XkG9;J7eOdFKazb9icD~Q7U z=g7VLflR!yt?h9{eXJ)_YN}%Wl2GkLsZ#NW?2EN`G!P*O$#*fj#Nq{v9C^UxSK!%M zT}|OVf*Py3rsPbTd`{d-{P8#|FFc0y2`TU;|3E@YpAj(~i(~AOJCen6{(ediE?|_s zbdjJwQW5e2=FuEw+L&^}4!cQR#m({FiFXZ=3ihzgca%IhtBO_*rkYk2{kHBziUmlu zO)$3Q#wWR+%Co?8LXDzahk`Dsv*Qk05p@PFGiQ2TYn$vhBm6?MwG|kyoCOA4Wi9F- zEZH`57XN0*?l4ayEnH=Fwf1#_07zlLWXn1-jf?AcZHKH))(Fch7YQ3HZ@v32A2E=1 z_MR*twU&4<#y&4%Fug!ofDvOr*~vw;JdpRiRLfO|e?)#LRzD=-uN8<-`AwA$Ecue_T_D89(A=H&})lHFCbRo@+K@ z;mQ=ztyW~1S8+2k)~(~b%|RiH_5l4$jI=YTM>+nKCZ!;j?oUTzYE)_;`6ius4eQumrz`-vvs+0XiNy?5rOTP; z){N8M(1+o|kTD&$B0kC8BCT*eP+ zGoWLqt897BB2VE9=;Nm%>U1=l=upmeO-!UGMlCrP2aVVjPl~RKmYCWcW88X8?Cn1K9&??J&I07-8%;8z zU{mMiXna#h0E_I_?Yq3Pl^(K!4aT{vgBL}tpGd3c!m;5k+NV0}=F23XgZ6iZL;qK<0> z57T5W+oEDn}5LsUnDlBAkbmGeL%;HB}Xxp%!Qj8<>L4#;Zz2n6X9GBJ(7HbrZg zzrHCHX-pT<0Sz_eHgl(|$!=jFQ5ir?2MgH20%Bs%jYo1VrVOH~CUsS!I2AI)SXFC2 zc3mzCQ^jOT;tA!*C;rM1$INUB2a_3EfrwMjZ@iMt{lSkL*xxYynm#u=&8+7;mV$iQ5bCv~JZ zVgIr-6gN~8mimfP`k~yG?&{Nv67;4~LP%L)bxCA@B`w(R9sPNh>X%SHS3ED>0121TIFv<^1=IZ&Tk9s1%lSU)P$pz?|V zQq61^4_=m1&@`0V^0KnpGY~!pk>V~!)t}Zv{D%oFR{WnLOszx!**OfB7qdvY9UDFk zU9u}YELIdf8gz8D_`n;`YH~AEDi+GO8ssYCZoF*zkyvgvseAtW#nuF)V4gA!H&s}F zzqDfYq5V`X79~Lm!bVR2Bi05c z9#7ajV~9Y%T5s85*Lpy|sB%nXUaZ@LBb7+zQMY_nA9kN&%|S^a$kp~NpxV$uPs%60 zCCHzRIq!_>=qPoqE(99ZmHnzYkIR$_G9hKxEcTmEm}s~Hk9U;#b4HZMBTF8KbLQW&N=-_T^s36V?=$97T4W;K&cy7E_hfPz)19 z4*u-FdaRTR*N zB1j-Dvu-=hgOUm@tpNuqdpSVi?-eFZ5+?k*p3;X01O&|xwm2p;Gv_-P^mzgr(X_$6 z9$}S0NL?d=)Akj1G!BY_velWrP5b4Z+x)m-+a7c>4^;CvEwlH^;S=!#8?@eW#8m;3Fg|pf=tV%*-6|t_hApB-ef0D29RN%Ol zlfZP*@>m#ORxx?Z4WWrZMs12dHLLRa{In{*Y=h`!)O@E8hJ?^3XVrIq&TjV*?g)}R zR5-zqdD(~Kxk}C|$!<0%SVmTD0|senX~B~@K|$CPsy)PubQ&;-HpFH1KGd=5o<|k4YAF2>U=;q45ye!72q!go!4Onz#@cq!1 zQI-XtU;XeH{jd2k zik&~=*LO$Vc+~^+zV14@Fxz_h7qrR<_+({P)S!fCBB}fNg<(tJ7Q&WIY7g7ml_i}1 zCfd%D!^Y^?Sn#d+c>P)x<>AEq)%sZ79%WLNIybZtZ0KgS_GbpS1EZ`lv+-3Uo#9%S zW0D2!Me%q)MWS~4!SlWo;Qp{*XPbL!DN zjqdW@ZZUaHK=fqVcbGxjW->eC_q8-tWng9QKChK--B^+ft?4mKuX*#+}`9U#Ho- zU)%LkIoDrn;nO7C!Rn1~#mq}@iNv^a=94SP2c%Cl=Z6U)W*I4SeiD_qdu#kh5YBMf}5A9mE*wRVrT~?VF zn{z}0t`!}dTcv!3RSmw-vG#PZod&&1Y7CaFz^|!X9WjwZEb8yN8Q5LUM-LI*8W5=g z-1YYQvZsWr%q2jq96s`=FS8IAQZi){OXXsCQQDKo-h>20sH9IxgWinvqNLI)a-2LM z4~k@()6RmOZP;6b>UIEGmv$incA=RnNrnKgX^`v~gI~118~SZxsa(Y${7PD7e(i6g z$mNccdS_-h1zC)}^pmO7Yrp16g1Uz3Xr8iND`|3VSYx9JssZbCt9KNI!b2*RkGy?J zW^+5qa#Jd+ao58+qf85jQ&seaV}${geIw-|-!ND3-lJapPy#Wlob0rQP_P3Yy*_12 zEInx()kmWCuw*T5?pfCgzaI*FV?~QVilK<^C4-@7N(%QuZT05)*;#<_t3dl1E7TnQ zXA+5Q`V(ns%>wCyanI`FrU?}C*)x*glbPqRLT=(=7&S~)Is#lD zjuoE1B_Im;bpgSMbn%o)UGa4gsrieI z?1Bd34sKb+;5_G%1a-rSY3;fp%Ieo1(k!n7qDm_RC%4}On8=}Q{`T=4?4QLSz4Nee zKbg5W#eEuQX)snudomRLM*8J>f7-Yf5OdPn(3#bkw&J>R3D1K#NtYQT{O+h1i$4yr z$^oKQK_bt-GFv>n&_{ElHy-lqOq|?DA}urB9xr*sbzM1eI2a=XI(|F}01-e$yHpUm zB*=flwB0kCy87i!6)40$@nBe%Er%^j1q?@--U=ao3$D1966dtn0dodeP+Mb@89gI zEwEjPT@k<{gwk!TA7f+T5*DS|N8pX)+7`k;m4I>9Ql-8N1%IMBtp6Y<5G=V`4485F z##L@U{YJjaA~n{MR4m4(ucXD#%_yfkybW(_@}zKj!$;Yu?D-q1F_+03sh|uJ-T_H# z0X6evm*V%<;(z##pgQu_`B2^lC$PF^9V?jlyoH{`G){7hTzTRsVGROF;c z*K|;sX(QN0oOTZS732iL#rdP}fqc_g$r@!;kcq{PWU}8=ZS*4Y;~|cZ2Z1_@>{gid z>cbfCm=oH5CpDyv8rTK<47arruyfR@i6>-sCn`!qj7`*nsy^d2d7y+7k(E?VUXyqp z&=M9VJEq34O0-RoMzqEllb!LKN1)h;w8pAOdJEu%m>O?+UWHqd4fMV=HnD8H9Z(k| zRl55DMSY`E-rSIsqFgrd3+bM{Xi8n;^dtjUCk>;Ww6*z1tWH_!md0a^qZ@>)4(8|m zYu@q}96IY%@*Nl}PU9yvg|0_NLuTsjQ*JXCm=VKrK#?v{VXnQ*a*=7$&Jcl+t%ig7 zk(w_%Dby(5ppa=hOawOJq5g9W@v(p&r73ZYsq?&Ut+vuSmg)<-xMPQ>Y#I8s!>ydN zX6uG4{q(d)Vh|1et(G3io5spymBm;mfwqIzaYc?{<7~affflKvEUVNVm+8i#SF_IO z)62n~=Hr*@Ra zZ*lTy3lbB0-6A4ew>%0RT920hN53Ce8j6PphS&r0sVG>^)ezcT4W4%jldIWotk92X z#SP1F-o7bov@4wLrXje+UQ7x-SG2hSN^& z34>0E1+_9Tpe<1B3oFa-7D8l9Od9INJcv7?;9=zHa*Yx%-V9|}uQ2E*ZdI%uTFqD4&erTukR6xaE&Z+9Cw2dH;&bQu)fzRZ!v-0ajF%? zNX^u=hXXHfw2F_957qUsR@|h@kskKuP#9x_{$A+=-{UJux5jc_+2&AWwYMP6T&-Ig>h!VS50BwGqzqsec2;)OQ=`G`oVh-vqB_QL;ZE8w+ezN0Br}tB9 zs?tdRX&K4!_B3uJg@VAwNMez88$l6!Z?pX@qyx}Z_*u3i4;XTS(Dwopnpl6~1ce_` zN}}5e1b+QYS$=KUzhGzMo6+F*F#yT*wa9C_@z%WK9HkOYkinuJH;GPDD6kJ%mg(C~ zU@{9Z>n0bxIK=W6j~|I+dv#K?gE2NS>vCW|;Ge5h!b87c445X~I1$Aout|59wCZn945gpb{ z8NI5-x@7D(wQZh?Y5Z7XvHR%l!LiyZLwk#URtNfTi}fMq>(r_&2Hp!FB;|b6-3}mc zdSVYs6dBR@OMT-whqH%0mDz@0I<5~{6=jVu>KJ}Y*1*`Ds)8IkU+%qMx!~a%%4XI3 z>7esaPGJVH#W=IxP2P?$z5txJdP4}|%2zEN_{>ULeKdtH`Z zUs3Mtvn0y_5|?~8pMn3muG{^&%{K1HPPe9h1FD5+nkJxWU8yGv;YTb>k_jR#>fZ@fnlP3r84b*XpvuFo=`fU`EJTUc)PH-j(|2UO+cD^M<_b zj-_WNm(2=BA$$TXFGbQ}yN{FQAsNZlYe2+nT2bVkt-~WgmZ25#-Gq^*&Dn6e zx%!f!toHHSw{Ix8xZ2w~+S7b990T10A$c!_O zNF9p(U2J7#Rao(sgs63K+McVN1M`Ig0%+MT28VRJ%BRCG z#EA6LUQ<~w5?O7qhU^r4et#FmG^1{CJXrC2y4V%b@w2{~Z`#BF5+~La`%iO zIVHOOcA^Y<)w3Y;gFIb*1Od9!D*?;upq$(w7s_TxcQ^LcIYJa=86U0b7-j%Qw&xyD_-quV zVf0ew@$&LA-2y4Ba+5kK=^QM}tsrF*p|JAGUFD4O8OuvmIDX}(V0EtY?ZA^0ALw-l7R zTDbiG`4ruYhwYa7g_u9L?hfyCNvOTOy_=n829eJsFN-lC{yBEahyIaEp@#k6WD*h+ zHQg8jaS^^51REbu_Qbm8{pYdBBmxRfHsPxG{l*{R{*;#ENt?RmkIJB+ARk;F>+4G; z_AA>m=L}ita;GEfp`4R69KQu8$qTL@fHuy?dAXYh^KsbD^#DS7xHZ|2^G$mT2OC?b zRWC!#{)YV1(J+(swSmH*z$mlDj4bPMI!=;Pb~X{>y64`a0m($>$Ek8NLU2|fLdj7GENm!05BfBEpyaQW6v1jsf1fH3> zyDG_vORE{@%yDvpXkm}ucH=mTb*5E$ZON8-VEJ%uQQviI!j2iTQ-49rd2aYXF!+T< zZW7xnB%TLUk(mh?7!FC8OckkI+dC^=^Z54vneJb2sZ9&3OLy0P6UlwHkG>oXu`1UZ zs;;tKV`nAT^+NFgr~F&9btk8@ea9mG>erG`v&7v0{o7LWz*KYw`gNIDq z+m8D-4QVK#p$kMs1gz$pmFWA<4h*DHxL@1kZGR{KCF@;gH244p@iOFS!>#GvjxFQX z)rCF%>}Y`oUw70|8~TwXsCb>UU&&#QU!)K9*0yDU;~U3})4U$SO#qtl5T#8_lG zuO0L5M1k=7P-PSu10xWSNR2ksGbQ){aUAWIhVBDDs&%}XEL~}7WfPjQ&DRYe=3Cp} zlkKid3=I6xo70k4=QKeVrc5roP4R+nnN3CzL0YzDyd+a9Q{oZY>Ue5Px8Z0$Z5&e` zTpcGs2WFrCVT>B10gg~vfDPyUMkbM>R%1@ERF9r|>=V#kTAiv)D$~ERwr0j3>*q(x z_Hk3sjBXYlvllgbDm6>3roIaAZA!d9OORBKa#^_|jRt1_OTxWK1T+ob(uy4asbh)*desm~@7djk zhs*ZRE`C^1yiC`8qO_f8yeOGSC{z`wy?Okesbh^}cEin)ztWvG&GiOtz-7aLajvea z*%x;~sdNdc&<{hm7`?!-b4=uN3#r^#p`Qg(=k*q|3vHsfN;262fv8b_OZI=e zO=gu97Hd*hAp0X5rT6@EH6nmbhyV`q0%CZ7W*@#t`17xu4!QTNM32(jctn1QmF5q& zYvdzLk?#hy=*3Zu=rq-;z?0zk&&XlLddbLNDqg~4xVP^_H1QO`a;CTsm(}1`zR+=qh@H(Va7*R_ib_ z(dOT6JZ!A9*gl-Gz0+X75UHR%KH>NmF8oVSsnLx@3Y=HKl_Z~k@hgP_4`mX%*z`gh zHovL5moi^otWlk>E1PZ0 zX|C^40`rD$wul(vo#AF9Dkn3Jun);+OA#kMFFCJlJ*{qlQm{9xq-;q)iNYb}0N4jK zCrXLC7)wI`9*e=Zn`G*0>)j^#L!4WO6;Jk172>AC5YXop_*9sR92gj?>DdH`pNm-* z5?{;c2>3n2GDAxND%T6Gqq89(n z8f_$$06hRGH;)?sidO?5fA=5v`&;NsR*VyUe}~)1zX-(`x;L)^^{dO;os=&O-8f7l z{)$}U+&UE#HgM$sk(L&ob0bM1kQ7j}#@k3ChXT-h=}iQSN#xeW2$g6&u2HyZzX6yF zJqANrP5%C{CDSS!c?6(Y6DXJ=u|8d9j(-crn+T{*>2CKWzDt~Xe&3yo?_n$Hu4FuT zMX>;N2Z2F^>frEZqRhGfSzelS#QAT>xaDpxvW{>UpGPpqb6!a2jR5mfhFJX`E5ZaS z$B)H3(O$f8=X6zPhN64@TD^TO6zny`$}2Incv?F%ue7Uzg~Y^y6AsRl(>?+?EO{u_ zODKdm_dPm}r*8{~c^mC3NQKhtyO<;aS^U@Ja!$%C*tUHK_q+uFb-;+y*EPJ(5BoE2 zfT#_MY+7ggTs>nzS{}K7452MBk`7d*lbSofcomwC?*L(Q`uw~)=L&iQopoB`orYNz z{oc3?$hNS zGceZzGTYem6J-?T+i{WQ3I$AtL%$gb=~`M^Qj+K9@Il#|^aZ_sAQ8$_<^`EW-Yrp3 z-?r+%I$QOTs+a{@e-D5leBn;B$^ELdd;wM^;z4$$)crURNPQDjD|)LuWsql+#Ac`3 zs%IWi^bvH1WK0YX4-;J&4QJDrn_Wx~ts#Y6Bruw=?yu4qE^rBjoFkuO(d)nP^fZq# zshh2HuGzxc@QIF&_U{Sk3@4^A9?hrOO9)emKmAL(Llgo!zKAD3D>d)_^jDG;^Wv8_ zN?B4Uz+8wOR6w4HQ!<#0NRlMktPS9O-}dfO#(w_X5cLr#FB6RG{dn_=cEMyYqa(F; zPtau2bpVE;>zC(a`u=h=W|22bz;wg9WptFrh6A z)lZT;j6p6>7Kxa1VxKt zrO32xd#)($CQM0}stwvhd zx4wh4GiVBTEA_vJN(cE3{D5n19vU%G4)(wE$!6&84~x(up5Z2oanVEODWlo7merOgLZ{+mr)fSIj`?(2AX6XsFGn{%{`9pJHenpu140qm*n=g@$=Ajg?C8RBSkZg%rm;K z1-yN`0^u>!Kh2ox#*pkFzq>OmniGb4_Hs#h!t{^NGmKOI0Oep$n<7TugQ9=l z#JRP~`%*gokIU{~AXN!^@Fke5$8pI2BCEgeu-qD05*n3I-3`@${f|1c*AQa;na^=-2L=N)12WufVi@_#P%{)fg8Fe9q_AuCe< zGb6r$FMA~tZ}#u*eRpht32nc{LsI_@B;5&bdv?N^CjZ5Q#ec{QLY_52=6Jq;H&ZG_uO=Cz{NPS#Jl( z@>W{mRd;;eKbLz6<56PJ_Y?TwT@#Mxb$|Ktr5Lz>sF2#y5D@SJ(;`UK?yhAOFd})S zA}c#&rkCplyf;Mw?6Y&|Ak9~6_%xj$^lqfyRq)VgZ=vltlZcH?8FXAfUWA8(tJ->r zbUT8La-h+EE!#e7@&1-QWQiU~KhY}tbmc?_Y@Y%CPzGpXdx8+QS*Ig3b_3ZG7}90tP-&z$+~NfRmGR|DcacR8cVoponOh3Uf7;Yv61l49sYBfr&@4 z_40eAa?@Bc3q8R9Eq1v+Ta^KRMM_25K0i7D{!kTcXt=p{-a9y;?_jlF`V82@v*hHU z;t>rzeY$`b1}UjT!rLPdMUQ-Z^$s}!*lR@zhP(tGfd)%?6*J+$v?rExC|^yb+J1+6 zZP1`L@7di=qJzg;y`Z-I*J~U<5{}z2^d;l_C(mBs149!Tt4MW_HfyOlHa^*&ZqGjZ z#fKEjj%wi=b2j_YnuRnh5o zB9p0<=W-)2R04Z35Qbq8UQ&8E&Nn|^o35$2^*sZ&Q*Sg}k0O6ugByzm8o(kWxk`B} z!IfJ8U82OZD1mLXD}l8HxF^q&(Dd?rUU)%pUNQSAB26IZr53!0le4EN7256kt7HNT zXtcfo=WmC6`cn@u2tR>~8?jootqic>a|8xYHzjz;F6b2UwS=?Pb{jpcisJLl#>x2W z_`8<47y|%nb%JlFDA9+6yPWM~QIM%BWs1j>#M0@715=!z^DTZM4=CkxWAd^UG+gD_ zIXISq>arlw0@RectE+1W^3_VbMR1UL7%oTfZvi!V&*3s)(nhD<3QK7IT8dWxE0sol zElLY=YhkrAoY`aOQ$@eaKN*B*Vz8EqE219VJM4K35o#*I^?MCwN{aR-u-=r=12a5Y z^Q=8RZ+LRbFL^2zOg1E-*IAqib{y!j3gTM=OC~F&LQN0Wn<<;VF5s?1j!C2b6>y+p zY$Q3)(p`YdO?#c)>F)dhaH_Gv<;9?`Le2sMLyMuLdOCY^O)AN8^py+=a~$+Y%S%!7 z`95pUHhwyLxd&e80u11E$z;y`#z#mmtOBfB;+|5h0Y-JO&&gbqS2&m3Cqiu?Y7p;= zKJ_QN77b4QweX5aufGeZlxJ?L%4UDUq@W8txb5-Rif=AXc4+L@hXR=KR!sDJVx*&} z)r@lkVsrwkl0aha3z*06H8oSn3^=1dv;WlsP~x7az5pW}SM}9oB$t)|MP3pb)AOiE zw<{cqS-o@Td}KUB78}cZ|6>-lB&>wUOzLq%b(&8aOG07_EV1l zcYfr=-!-ndupcUar-dYOIL9@LSmx>d8CH9uMm_1Ny?mRVyQ?D;#Aj#!7^bp!Y%Hn|5*k=kN-$(DO~jM8_&17i2oFHNQFZBLc-2**T^G zj%OSgenr3#48Hf*haXk0Y|&04o-#tIg>44;^)nLe^CO@G{Q`3o*wHpY!+QD)Bo=x$ zzkkWxM1?^x7FZob%R{O118m1KRn~eNze?sS3Y3C{`#xqNx`9Up?HL+Pw7On@ChkHj+^W( zd#xjU;Hf-sG|9e#P$K5+E12~~T}Yz}U&{z4)1E<-v=Xkk@v=0VVZbdQq1e2Zz-BTv zRIx!j;H8a5qb{iQo#V3%Dno5u1Ey%u9$vXsX)J!40>@DF_S;iT(g;srWuWcVNd6o> zLf~-Sb{J*7)6MaCPKb<(O2J~Y{4+}1jD@f5m^$b9Dx^l8IKQbH?nIFYmH~RMND|pm z7}?uoG@tR}^q~iwy*#C!r(<+XjuNoEwsD;-HyfyRv?&q$2~=gZSDe&qK0-(?3svC> z20-q6s0U6UUa=Y4hOX!n$%?nO66TN+aGyu>_z1RJ5@Zxr{rUFEDjS0jT<3e;G!0|t zdRzGKw}1&5WZ>-xYd5*^tAmJJ=%SkF?pGfhbYx|9^u^lcc52L}fyU5@?;BTJzZ}gf zrV!O^vm|J9fm?6ZY3%DDOt-D)SK_e}7$UL8YWeb&7|9>2q>1ggQBc0fd=oVJ0NI_d z`!4-$LL_e>ue(5+rX}^_#mFeh57fHGKAB*GG_vD*fO=P^P-j*d6N!Gz+}GRp;bm z_ENk1T1yEi%kNiv9c|V89au`GhMqQvo^^j~0?$DkuEA4oy~w-o-R66s4Gi0c_uDWB zZP!Ag8DAQcoM5pF{SDP_Zd-kC9<+AfUza5_lp^9I9#6n1_;J9b@!25B$g(1r{f=hG z4P|}{9H}hc!Q92>pJjmuPwGI!&1$jH;77-st0d5Olb9~wwg2}vt#5C0JIb2-?gcWD z2l5cJ$>X0C1<$_p#6HqJ-lPS4ZT?r&aJ#?3_uwtkno#Koy?X%w&lTgOclOdi?x8bx zZLCm>3X68ZPUxe@U&QA=cnkEZRoW5%`GmK=)Gs+KCR5K`wVF0X`c()vDOi`by3AvL zAukItI9oGo%{CeCR_}YX#_BkOUe&K4an;?Mc=I0k`Rc9@p~$52c6~LEy9el>-Mc}g zXdA22<#CsmA&PvE8=IP{Rjkv|KHoA;9>HW}%$Dml3OKW}3J4=|2s zuz}-ua9A~nTUYn1YMqn#r*oqKVAQ8FR7nOrWNAec7|fJM%bs-i-)D^g&jBPxiFZx! z1SlH!pa}>FWOZjgJ03)JgRCtor4=gjXKA1>^riOP7+WNnGjZ`<8^%4zZuGfDRJ)Mn zKZcc!({PG5bbSd`_z|RiQN*Ix$e?*>@1zgEkNu#s_E=e^D~8sqMrYyl{?qtv0JCSj zP?o<7cIOPCkYm6SM8&}&z-BgAP#9+gK@=P)pn{$&s9^LG@%W5|)zyvP`9&P}p@gL< zlmsygjr!D&AajfUia~S70v1D5VTOk;aD9mvCR^`@!`_tprobytrrFTaab(* zAFrqqGVCRQP^<1aWsE|5=&Lu3#J&NK>kwEcsp6C!xdFyrKKGaB4lM)biUD0w8}(^T}5sVG3-lRHNi6nM|3>kb+ zQ%y|_gu{b0P*ipW__NXON$;2YS>hgaD|D8D9dyR`=!pw;Qc_YlmGei(RB`OQalTL- zHX@2BnusR|*gp#zF4Hz@+d8{xxQNB$Im5-MN?!gsS|p&ND!rXIqo{5=n|*S0TYR_l z-_Fda>j#9(d=C`fnV}J>a~kfsQCP(xbN-vx+ZF}MXRIBLRwAZcTwE-yAJ!j;$l2bf z>7)_Ar%G9gOX3s0J{?%@Rv6}8C!Td{kOKW{5`aWXjKrxZ=!aN_j%1}t$+h0B2l)$fE9QeL5?715=Jzlf3AWGP7TVXX_ zr`w$Gj_q)MdPof8PoAO4}fkJ7ts!xmOZsp#^ zJ9W&x{PI&ZCxwEl?D^XjkHv*=Oh(OP_D^Mecdt(u%H3|HZMJ8RRt zqLhzB!ljSW*c?txm&JZNZ7rFqWSe_wf|UPbD<*GGnDNl>nW(Yfzh7XnSblwmwVz%6 z?BXZa&1SUKqP}tB%=+<&AlQfWHSE%k`;r^dgNjcg<#IBJvuovGJ%QHo4cX|o{nJH8 z2=5Nqegu1(iC>!TL?J&$>!H`Jyhz&(!pH-H@wv_wsS+on8CvGMnocLO9Hoqk;1j@imOr|V%d14UoG6U4n<mK30NCC zrmkW=i0#9rM$G65J1;X7<98wzo^*pSDr>>1fKs`%J^0MrZfDl5HFzuD!grb~i2dBK ztVVtDF{-5;ltYtmG;M0?e6hvsYn>|9BJGGXyt%bCt!rjzXx$-9_K=X}V&D~(AL#iv z8h?oD?*|q}b@Ca`=xjNP80}=20ZC8-%syN{N~2UMpYhL1Nuf|)p}^vBa%VqQ zL{K>z%n12PAfP=xsEKej@7LGE=s!7A$5prtss_Q-gmZ)kD|w$+Ab1f44Y>ND^XI;d zn}t){Qw+|>bigV;fHkB_!EBP#XhrS2Bn_QTd&l}Vwr+mvw*BlYvyq=(F?mv{=|z;G zcsE|uM!SBpImaoz9$VU~8^}JWyjb#KK!5UuI-bjJS?G!Q0rAIybfx``f}EvPzn`5c zJkt=&2_()KLr?!_I4pu-6Q)jfyFNA>5k4f;s8^K%?r)3d7yI3P!bj^!S%q8_8ZNZr zz~z#%#ZOYo#7se{WJQwdCQ-L12E_Pw{bc7}jly}6?OcZWM zQK|GS1$Fy%5Y3T8&yk^k4l-}z+BYM);{?E7<||;M$G&b(oa*aSxV)n8{2)0zipg%b z>;dz>`sXey;{DNt@45&VnNvnOdq|6yid2wOAKh3ZNA|X6d@Hs??wOeu5UQ# z#ccO9jx3rIdY$nE2{&h?iD!g(auv_bG?IDNWgMr4aeFx$=BcV?JfpMhs?KBKRNgb& zs}7Ta)RqT!IoKAh>OmNFc3ZW5!G&IOx=o&N#pcr*#(9a<2-qX?qvKq1QlSbaU0RM8 zTYa*&$59;4h;dAUVM*rg$CDOl!>F|?Z4Pc~B=higG@KbnxlvU%9oZW*$<>InLUdZq z$SPb1OI=2-<`w2UYqg_HK)?1!6M|c1cdYARE6I{md8i%?S&fy;HHX|eY+ZNaA}*tvxls> zq4_>mu=Vf8VTBt0Fvt2s<3@{2y*33cEJp2)?gEl&!ndX3E@6x`1bGP;r63xm)h{&l z3@#29=&U?~>`jlKYb-oP|CvKzT%|lDkwamcJG|c>qKU4M18p(@+}t!84)qu{&XL(+ zZD{r$|Ke=gLf6KG>qgj|ac$ged9Ow6&zs{m!n%RB@XW&-xw2#OVx-_%y9XPkJ*eHo zuVD`UFn+B@4TyZHffXfmz~|5o4w1>cs^()``NfzHQclW+TArr>qkF&D52cc{z=rU1 zB@s~L)2p9rURSC+_i9%T+82(wVRXCRA(zdJUwj|=^981^O$t}Fy@lCFEvz;d@e6Oq z0_0G)n>v-p%PxtF)r0(XE&yg9j}y6FtG0&+(u^Y~Bez-&rt*=7pxbqXUaiQ~Imv~L zxn7?MT9xd_Ko2lHyhKou7rjK{MozAj7wuVYw_3LvvjfIvLiA)}P*)VF1meb5cn8o47=nWNUfR}CfB_~rcDo|Z{ z1|o7jZ{aEP; zb>Xpm{h9@lp9>phPB90^Mfj8m8X+`gBoyZR(5_4ZT7KX}Ds z%t)p?rP@zg`2Udg)lpS#Yu|!Msx$~99nuYgv~)K}cb9~8cb9;)(%s#Sv~+iOcYPDj zJ@=mTz5h7&aF4Osd#$Xkn0;%PY@&Q_BQ^gM}9Kr#zCGi<9 zx^V0`gHn+hw$Gj;5lt`#bp+|nkmP@gDnPZ-0ytl|N;vKtL_w z8&nAgnjE1fuXpnCm`02_v6xNjT<*Sb+h8fOxL|d}3DlILzmMpyx`UomB3Qx)9P#b% zwZ-zyh{60BQz)`Do*AF2kW~K$-tAgvi-$#RKymAoJ(%uLI zlh+B=P-I>4AMGV|nSHNrw$ghH!q%dtxJ>dsI2_f$m*NuM4bAd&_!2 zXCtOs<b0XISWo6Rk&XaDD43XtuA-n*gjHN?Rg=Cw z*3!NS!5YQ70E%p+5+9O>$)(4xg+~8=wLWIkDs0Rzws7u3SMFYmk0#?3->(-O zde%4NN}4m}-(V#EH$BZt=V=?TJ(UlCldmK%UjrZQK*8)qGN-(}H!oY5REoi!MTR||5p+N7yaaip}Y)bDXOUcPqj2aNkc=hJD0(I#pX4oP7 zF37+avt$z3CcRpcCgle6p{i!>ex+hjYyDU`y4XJ~YFWj3(y$o07}nYCaSK|VNaJf0 zP)QpM2MKyfMwVLV$MqcoLU7oo3mD-c0)2Q$YG8z6HC7JYZ#h)@X}ygsMl$lhyz9c5 z98O=PzXs#~e)QGi&?&CTq(lE2%eCC)n9AH4QJaUd;>+@Sar!2hZo?ui_5P;O>@DKN zOS93eCG%J%IeK@Hzla=w9vgWB3Dw3RuikmNy|~O^m-{F=m{hB!;+y|bV{b{|joQTS znBziCyFp(piLX1?uEH0gDvq6UW&Anu7XTola;}@BaYn(SUjY@BgX;*pmgv$cCu-$B9IB_LqltC@9q{R#tHgT zQ-^cNj@PVj_Qwx6!VIQF^Dv+q0bJ*EeVya!4YqlMGsEQIPE_KvruMNMRdVTpp?_xjD+hORQ~VE3=Xvqe7SmT@016`qp*?-dk6QY+n-z|ylBaDg z$yio$crqUX%*# z3T1TosIw&cs7HQ3GBt<}&>FzaBQRk`lFP6dUS{XU(6>NQWK}^tmgiQ{L{@HU90~Nd zXHU+_qN(C~SXsvWF)YonZD?>T9H%g}4f&I_ISymY8uw~!fbjygjk^?X!k84L1`~q6 z)Gx<}a&J6eiJ5LHRZtlS9(yoEuxT!vnwqe`Q6vwV(-a=en2(uU2MqcS+7#zG{v*bF z1kj0tOpdBa`Er8dg82bs*ZC;#ey-4~r4{2uZi(Fzvu|kzHo5Suo$#WEl!}$lv9J?e zQ2{Vz9v_zAuAhsG0Fs&@V0G4{hMbG~MP6!`7)~lu<%e*oTWZC%ot{}rK4*v*os42% zj$cy!R3$HWyxMY+mb#atPn!^`_vT`Vy>4VRS z6#MY-utT~iJ>P9&bhf>-F%=v5@jn>Y6I{6l;G5(-X!f#OL1jMqyiR&{D&aIqi!t-Y zj-+`)6y*8}FB$I?E{Z`5bKHj)9p%!D$FT1SQ{v%wvvCcnaumD8!}cEDnRoK%(KUUM z)hWV@-ZB*;-k6a=@4Y3tX|o9ADp#<}3o=6gZQ!kdF7l#G=;CB0rr6J6y)bi?JZC~a z^Z>4pq98-u;?pybjQy)~8QX|bIi2mWqP8`K+kL?*AX#(U)JGp+;&w%Ln4uMFazu1- zo~z5OCGGtu6|0mBGi zqt-M?DU3o;nzZ0zY-oLXpHd?}I6y19?s8Asc1u5yt63p!&>|{_ahKToG90D$v|d?B zGeD-&$!X0|xk0Q7>2R)YFC@I~tgSMxXB!UR2$3qr%D2&WJBntre7L`Gu1r4|IqA)h z@bK_~R~MczxO8bK7-Id!djDZ(o|4DxLEifIp#39_Zuo01T4Z-O0vUg|%D!dtlxtk{ zL!F^OCC_dJ&DzQ0FvB`3iUK@T%lSRjf~H~Y5|Wb^s&!@|Ldil*Qw1DEyPoshn=FUT zLJLiwTjQi>2jpq3MJkoOW`KmLPwxf<+nCm8DI=>wr!I7iQ|kEtqB!8i0r8SX-tyq_ zX$VTdFb`LWj^E>PN=MQo<<}yeI8f%~sXNVQ0V)Sy16$TKNfkq}eTos^{ z1?3fUWd#5Ywo&Ktf#L7|4RjBmz*9N;d%#LwDa3R+R*Gb2OZ3>;I?_R(dOs&?zV01W z_KNBOx}BVx2p~BpK;hW94FYz(;cZyTNqWqrorMmtDFD}M%<+9M!5(wTSSC9dY z@dU8SSTdh0O%!xx9h9rHH3q`E806T_U`(Y}TzhTB zd^GO=-B^GeCITSAWYJUJh?{h!&X{>;O6Qn-m;&9Yg*5%sVCRDOXrj7?l}Z}QL9w#R z+OJ3=7MZ|G%0ej0%*qDg;WU9vFl#_iUS~kGzxJOG6N(iVsIdm^hoa_mm8T-oqzcD` z^E88{w&Ob6`^b9E%e)>*wIG?*7ihH+5)=A0*zoQS`6MISQDT&fQDy zh=>5PfP?~ckVJqoWK1iQ%@keP%-*eI_}_5_h;K9i&d1dyU$VX_j@l=-p3O$7wJEro zh{p#Xth4mv_S5&JIb{kG(yI!e`(dTHlxsPTGNq{R2z5}Qs3guZaAG9#)IlYoWgMv5rN&oe{=mLaft79*GnP0phjW3d{m9 z9Eii=XvsWnWJSAB0nSt(=ms(+5?R1HEKrtVFz7eZU;QHw`Wt;se0mH{HJ>4-zO2*h zmJbXJ`}V7mkyvNa2#%9MikMWCSjkY19JK(73-sA*KOp+HW8 z@pRJ4odY`FLe1D5{ubPt=BBd|F%~iXo3m1j>8k#@f7zlu9RQTy^}O?)l%vDWTwU3_ ze&|anC^Yy*W?n*;S5X1KkOa~o$0!@&vjwr&vH~6Si*ve7AkI(5Um&{5@xRIJyhWm< ziV?8=UaBmp$xRU1OR=*B8M!N@TVIgW!5Zwq`CG`P&lUr5(MR=8`f)g&j2YgPJDzFV z)p5yFHn0tel7dKHnNV7)#%%Vh^hEI@!R@6xt@GtU<+w2|#p!w<**kVyqyjl`-wE6P zwUQ!uWWhHl^-T$mJwG}_Ic$>{@FO7nEio1!isEQwVfN$aYM;DWt}hKKL#&6P)A26_ zi3V#01p<}N91NJF`20v|*a5#jxp&I<^ZjQ36vS)wHUwcpwDJbKdTPlACG(UzeaJFq zt2~k4OLx*^thF=9!jk*=m#kz1WkX(n!gd6z;k|d~mo}eNy&0xTI@6gF?ODzjhH5gk z_Oy`adknKpu0rDPu9jC<3tOEy1`L1lbpm)fOi-Q+3}AjarkmdJ?tl9gaB$-<;ZWd{ z2oAENinp}4%gvjZCJZ!5+x{X!PctRiIdcl+s{Tw!dl2z!>zK7O0iH#MRgTh8A1z$m zx0C2ci32nm8sg*KN+dzCMXg7r5688?GsB)sC|$X_2JDrs*ZlaM znwDSR>`cj$T>gDA&{oUNYxarx!jmY---t^8CaAS0k%!+6hWh7b3V@2IO+qO0YwK5z zQ5chmpBpRbs&i1t=&05jY2FuGNnQ6Sg<_r^Y9C!jMQeCp;Lpk0wPz#y+>o>S{%n*E zRsE6{rto9OMOY#9#+cU3PP4FfL(NZZCB8htA&BC+O^*qETP=3JQ#~Q#n{##^s-9ma9O*KW*;h_MLHR}|&{X_?g&(N>A|_yuR83Us ziD*XfP>)DINgj_$oQyXX%Y0V+^*tb;aG6y;I`6g!dO9$0;(hTHzAS;Lc4ft90au-Q zhsLo+bQ`hp!tr*p=m1R*1DRbtYzd@<=DT;zIp^o+u^0QPikC{t6k^|*sYbWxrv5F? z3y1?pL4&db12bHUuab+x`yEoa@apIUbxGUV_;S6dmsn}UA3ikYRN|U+y3h>UmE-?S zW&9ESWw1cp8w>%>NQ4TMldUD7B*o*dPn*AiEj9nD0UZm&h6VXeK;XZ$g-J1h<1AmO zMcZTD(DYlVi7lZ1F&OcfgNm#ZT|ck)uESm4pv!^t%iIa*deQ}7(XE`uX?6jzNer>g z#xpjnr78D{-oLqMaPgk5-&e3XXU#iDn{Xo3Itd6ipXrrV;bFLZ609_~9b&gsgNrKN zKYYn!JH%(arDH3f;c~KbUM+p`zq?H=OmT;D}R%Fb=e6dK;@wK_-`;)HlKK)kAcD>n=Ye65DeNO2-j_U$7jxXX} zwnn52TumwypKbk;0nti5ftetprd3(SQu z$!sK=%`=J%3FR|XrVO=8AF;~LXpfJ@$Y359G9c~qOOXcOT;)|f>Xo6rzx7b#a#kTV zu}YrI8jKr-xrldq_*}_fw$Vc?v(trbyODvGCq12nSpP+R;l?p$`cYe0bGrIV<+mcN z-3=4Tu4`mm9I8X=Sp%q3A?jVxM>bQ19D}I)#K#D{gNzSI6PbJG zo`w(1bB|ZqEl_a{TQw{A&}k0AA)%35&D)*NkFC9D-(PYE^f> zYcrV$S@6vv4}xYZT8mkg)_1AqC+5wA8!9C#7lSx+WWKl#fO^#qg0FNRn{iaL#-!FR z(v-_z?WW%OHET1ulMuxvC~VLZIU69M_e-IsrUx_gpl~xBhfW8T>~~y130+SFTd-a~ zP7WcX=tY4h`G6B*)Vn@Rlea>d8#epm(;*ZQh2OIEuy|7sVou_6wP;{88>y7!ryf33 z6tlgq{n}X(ShY-eD-&ENdpiA9w$L%(S)tqXU)w4VW?uXC`WRrh;2Qqr9$+jxzz@vJ*dg6eNh1plGO+O!p5g}>j1N-=Ze9aQhQDKTZX8;KHa zN}O1BSl_Q~ledu=sHo;@>e}e)OdFq6YtJAcq8fR$jTbC);@h`Vnd`4$45B4Yn^^a$ z5;!kntaVdK5*T!1!Hq}-)_;hjW_@pi`0 zp%J<+Ple{#le+M)9sAF}2cl2vxW?1VH`Ud}#bXwuBE1uNw2X_}1evB|aao)&u!Pl$ zX11o&QD)chqLasqeM#ZaLDg~LX9-O_g|Q1KH)jG2rX90U3*0;^jhD4JS2oFPk38N} znTow!C*Sd{esjI*IPFzh>`VJXq<@*9KeBgj-)SA_S}!>+q*k7GL#fo3atDNC!n|l8P2Le}SNfL@<%#N<>GjxOHqBvl*66)+_$;j4I zI(?u;eqGvi6d%*B#N4-L$j5B-wLxBg&WE-?L$#5)9a6XHb`(ku2z)v2EUn_Q>qfO8 z@HX3_oR`;stv5!RT&>19sJasxM`~##Q{8&C>ovJF{)Irus&fm52)_9rZ1YL8mcUv} z3(Vb|=Jv6T(E$O%xiaaM1oVJ1Gak!f$tCt|*%>Fwu$b==$?+5r!8eA=nTzTQ&m8># z!9Oj4ruOd&3?HH!bqPnZo6#`%pSS+$^%4enI_FndY$E>8I}V_c3^N@3TVwRcs_+Dv zv`{}u{5#Dz@c-G*{(0vZeAv68Bf^kn=K!PHr#n z(rTdB33sF(imcUAqo95=O-#4IQWo-8i})=d6?nCYdFaCYZk*}=i?8s1`VAjWDu#a? zeku-B3fw?xT4O`!9w1@ADu5r_su*U&-19|F(4HiAxqT0JUmFw#teE4ED{@tKvUvud|Mfeq;KA>d{E{eJ6s-}DAmWFqnB#lu z<#SIjOvutijU3r(;weU}k*SH18fD(uuC$4Dlc5B$6J!1Ei)TV;O9*_-_g`;O?5soi z_wHoZW_XdMk{J`}tFO4YG#U`bzIsMl!~Sd4MbLpi2e-O*Qp>@p7k4HlPPZD;5A%@?p9NQ-9rVT8gUBD@n4HT3nRFDC!PMck}GGrvt4= z|IRLn0(H4z*@|srFFrz8o^VsZj_# zGiB>5Rynxi{*b~Fhbv5sArG&WNcv(2qa~dCpNT3^1AeaPRt5*}2et7gMyK|hee#im zyaMX$2u_LuoANy)kY4uS8m zD8DtDRw64i9AcaV8%}b6zov4e#Jl__f(l++5b?A+zI}~L{LRDA&uk@E#=_M$I8leN zon&W(s}gnxDLdE>~YO$Z;{~qH%o74di zzn-QzIMCBWaWuGgel&c6Yz8OX#gI#rPyaKHm~>Bc4;n$&Ck3HddqczzMl*qSvf62{=gUPy7<%vRpA0o+fTR0@~k_7bl?Ri|I>=7mjf0t@nNPbXl>@Gn>}2-Wj|8V2JAe!Fr8&`?q$4 zkFPPm#{YM=0PQp&t%z*ZP?dE)`UZZ&t0Z2{aOvZbIQ&R;; z{_+p}Z<7%Q;J_fiwl0AXYQXOnkO8yaU|LQhiuUctkNmo^KxkA^zqQp>YA^{hi(jbw zSDl?Z+>GHr$b+X(s^JEg;j%lpRxXg|_Cl&}s?@&PYNIc%Px(W%E^hWclDAjUFI7ud zQuEm+efNahf9*LukZ|CjiGL>CA5{mXRjF2K5)Jga`$g}1QaJPVdQk9zgkr785ygf; zwTcR;pfp8_HE0u7fNU%<*H8an4?qj`pEJbVzK9nF#$UAAEwvZuEaxg$l~vYjH?ey! z_HPdo$VxGvG^zVDG3#x<__<2O^PIPlQ!L7VM?U=ZiE5FcRia!_z*ju zXn-0(Xl<%=6sTN#ob|=dn3X@Ky4}zNRYz4WQSC-BY27EPe;N}j#Wy*nN9>`?5s z{@2A?IsqG{`UA$t$7djtgc8r~!!|bzB<}|;9?a=VQ};5ONEFR>pzz%|`@x$&KvtN1 zEVJ^*P51YqZvS)+A2dTq(mfzx()69IzeO1vg~6jDiD$eOJ6ymceebTr__;5uKl$T? ze5vE<>v+0l7!V33W)?&B$J6=WuS4_X&|SC4-F*eSYY9>bxOktRo}5Ghdfn*gM`M}$ zVc^2GmZIVr~qL-{idZr4d)62f@+oFbWN>$Pm#E$EJj${; zG+BtR1Na6K}#nX`mh)kjOo2LZ{K}q zcn$RQfk8ndcbF0jOG{d<)?^^JqA{K7M7`F!GbubEAfo2&P8rI{6XA)I)+82pU{T2F zbe5yg?*al0de4@tqB;I^4?UUqT7m|{4Cet=jXFlT#_4iZ61Zm(Q%&n|eWG8!P-%D2 z=8K^7s8%_sI%K;0W@sfM0%`a6h2v`1D>>YuC1=iJnHHxDe?LF?nWwEum>q0K8H{?b z$i;uS-N)(gO~nVAkO0UFi`y*#2sJfgsjgR3>~Of<)K0^Ibhj(szkk-U*&LSxMDo?8 z1Ebld-tQm08!q=Z=b7N`szU8{zmZB)B~`3|Uw~an)D2hy|1*EQRDo+|gojx3;|Kof z@4czg^zG4(KveVd|3W6RUeBPWLCDuYuu_A3=W(N~t!)Lz?Or)4f<^=gG-+#a#D8`i z()e{hp~7PRChY6i!v$(EAJsP(lMlF5Q+6_K>*a8L)+vh{bWV7Pb!O}A@|~Cl7;i7y zPP_5k$8y;=L-310F4(@~J&1(cVqYvB)s#l|IRgG3vyOvSZu=x}s}U6zM|a~oi%(V_xK36brAm9n zm7(1VD`yp`RGJvzt-PYObX}s%ha36|+cKE^G0{*pk;Q!N_OO|C^5Oncv(?Y=@X4lW zM5J0}b~10jC{q7*UmW*ts==|G&9B5|J2Q^ygJ#V%zBBiGEC?fNJ86d}%N8~(<0R4I&QzEme zA`!k)u?97;bH@P>4;BLP!rsaNT|uM~qSqOiWyECZE`zk{vHO}T<;m1)>2R#&|Bp>{XB#RxtIJy9eBv+5j3$6qlKx&N-9fGn2LaFA(;s3v)Kl`eK&!msd!$!6AMVnDGGCOkyy zQ+)TAMHzgz45TT*;yZqZ55`ZCvll~3)O5Sfr2dVYqJ4x`26^4m7KmA)=;bk6VI2J> z*|)?OsbdVy;gRR+Gn@@GwOEeb>(mRI5Bc)Bguq|TFr*Z~AJCsAVR`w?E1x!{(ShaD zUfSShit`}H&~ooBFpqvWq~VA?O6o+=J6q)9ygwVg@Mnoznw2~O9mWzjcsw7-2o1nI zDJ13Xk!yIbM`EGgD3a*G{VQdaN~ceSk?CjJGu;Mk$xOykz}hy^V3*8pmj_Y;XF1*| z6(|!}F?#JHjR4zesDlqW8e)m-?M1xf`|s|7j^AO=qPbk1RyDqXfG9a=3wDQ`6^HN2ZH0emRpn%{&+UgurtI`+UVvXO8fu3mqUUoRWMm4mSD&7kaS4z2pQfBNN5)b`W9 z{pklF?z>{pbdXY5V}p(7>Oeq_F_kYUzL?^)m^&K8ci&7%ij@#BQ0)k~B4AL_T_g8t zbp#++5A^x)_#p8gaW+_~H@h_+fAA7O!xj2I8MsNJv7keN0C~2|7Gg-gVQ(;)G(Gkz zGVqzl`^lf5U_xzE7sXXu5i~i1=?&3_92)4lK13ch_z9pE)mjNn`>hp|HPf0OP z6_7HXwW1VQZNV~luvWz?`yfzl#4Qcbo_wQLRRq3z12x^_h&$la?)Tp7#MG>?gP4%x zxI+qn&2>0l@mhNM-5;`=P+9YMqgwhWU)I9qg7Z+#^-?H3BznsWI7A+0$Hs=iox4F( z?EatWRnh8rDW!aG9RHhCKo5n#r3JzNO-UJ*o@~^qXbC$-;fwE^tR!VmmnGTY>*$G6 zS3LZ+qbLk8F@XKW(8vD7j&d~QFVDeXZ!KRT#Enpk#in@LYFDzU2}vawn;o3rX&rEP(g&X`$2M9suZ0U!5baP8&q`3UxwJgh z?CySwF%CZ*R#jL_Tmncj3`7I|v`*;Dl$s|8^#)p(H)~7Dk2dwo%1I4IOG#~k&i0o! z%5Y+jyyvs{O8TZ1Mbdg&ERqcPPs z63G0)`D{x&t5}QXz1POiJ#Y@JjVoy~5B$K_?hlgtxN2CDbn2zs!JP#=QoU%wC2KCf8RqW@Fgaez<{)S)nvcRWj5!=pQ0 zBTDV5WSp|*a|b|rB)J%JG0B=mbWUyjHB{JH00|cMpd6OQgEiOT86obXqx;`k0N ztUhWH-_#IDdV+I#MjxWZ{~WqQ@aoe%sFEjYljp@5wY8VoFHgS}D3{7C(y&fecimQ+ zeYK zUVQzSZt2dMAsjh7@3TTaC?_fW1fbve8u|M@8lLf!HMs7TJ%z820E@$@x>|Rr{k8ag)QlF zD1-z@_?ZnK5?(1FXUd0I#+aE#dq7B%t)yDOAfD?42F3#N{>QXe{O}zp)QWs(Jg|~biagT(p>J6e zj?RJVRuRMPU*JDa+LPc1{w)Gq5Qz5pHsZmh`5}*ORB|-SPQmuZIOlwb>1Gez@pM3v zeR0@svIf{`E?>MwA%to=pHL-LMvgTpZR^)9H5Wzcl8tE)jbXl9NWgTT9?oUL*PZ?e>Bpar z=YPl!J^?tOQ27EKWkXwlJ2k`g79w}VZ9ETsZPrrA$SQeYq^Fb(Z%*6PLKFoc-TetiG z^AFUgNBGIDt6i42Mg|h;Ipf*Sz1NDbxJ;1^VFi3{saZ^fh&&zqky;u}7p6m^R ztp@pyPgx>Zks$g<03@IUIygHgzOVk||2hWLr!^Bl*#4M@|L(c@0e2zJ#ExML<(V!d z)l+5e{BC%RbM^_V1b&_w{sQxnjKm~P=i`*?`QeHFcATSA z`es%Q_J>7q$3ixs<%$_8 z)fED+oo{S@&l=tC>#Pg=q!Sc7bSz#>ewGKIoF+9 z{xc<>$HObS8OAt)ClJLqQ!?3qL>$l6r6DxfvfZz8AZf5Ocru|j#5l{_IERQ@wd$MQ z?u5uQ-a5ehVO@JHRb_(R1(|s_3O>I^XKfe ze?7&-co0SH@h()K<=~Nwe*ehQ5)i`0}pJMx@#?-y1+dBHclg-vGlIB zcW7t`4OlGlz-STK6CkYv{z!w`7~AcUo&;vo5TkB3N=uzt-9PYK2`nf*)l-~yWa7Eq zGwpgWrgP=QX1VPDT8grYct^^7;ryjVH-|*PwmnC z?aKB0$PPXXq5ie4kbeT}Q@^HcLXVKn{s10`2RHTdsIT*L<+Ag{Q@LbW%&t^5oQyqu z{QOAViD*=Gd3Zc3j7PgdUW*mKd_%_riUET#;6~0v_DqfCe!LsS`Q-6OO4xFbQc{p~ zv2HY9k=^S01nvnt0t}6O=sPvBCr_rR{#vBa$RN((m7i>$JTj{IR!1P?$yzk$+Q~ZQ zvHohUGg3<##rIQVff`>Dv=v4WXfr<=&UGWK$su0$ZZVxg;|X^5oQ0iIWiy^{c=hV9 zEUTx@;hk4}C~<5HXl-edKXNBdt-Pl6*O!Z>PNaH*HCMal-?Xv>g0u-*S}-b@-lD3+ zg3p2QrZ77@d)j@suzKzH&rc#|zMbH3pY!x+j421k^#eM+Ffg{>18M{fD2znqs=bo~ z#k4m1H3t~5X&c8l4l7(>w-PD*HTa_PYw#dCsu^eaZJ;iIi^6Z~gXxC|uE zt^HXNlkpR@yyS84cWY1}2A6F!>$kmh$>p+gK+n-nE(9Wl;XwXXL!F046mSsr`69$6 zTjLB)7{d;L&0lPLBy$~v<&1#vCdZXPvXH5eu&{|Al5qpzK8XMeHHG-XKH&3uE$E2T#w>Aa_b6ic;x^V97q|Z1thd zOz8)??#6}&aeMk~)Rj}<+GXf|ifRMU*BpOGXJAgwd$8G-y5_RPgVS;@;u+NX7rV{* zuN!rDRuf`^Ng`zCk0Ft-ge(N+x#%+d`PG_t{A@qy{lUmV^9^=RaZD2A@SmVaT$xjkka(f^%36_Sj^vs;ce_A@US!V&?JSi7q3dI9pC*>}J} z=z4QTKS?z1^&GjEAM!x(nb+y~mu9hi_yPrXrS({jOjmCps`9Q4D7(GvxC!`)&ieAg!%rU{NMK(w)-po`l-nAH z3y*bjuc?(L$!04U_6%3lt4%sODV`h+5%UU#iPG%yCrJgg_#QTrQFGtRH=G@IaXWvV z&Rcka$kL2{HlXzQ5J7{>NrB+^vbGTVY&j~0b}}cD3HQTMmFcZw(YMk1uOjy#`yIGT zdqqO!)epy&Ec>2A)Z1TM=U$zSLTY~yijxYGIZiAT2BNw&-!*d8i8cSFJ2@gXOqVT! zQGZ5kqnD6OYdZ>o!}NNTYRV~ioHJ|cjBX7aIW`^3k|A+2Ld?RSzZOanbp&QR$vSkR zBGE8ViEq4qgFoc@P@6PeEmdafy5Qzk)s*&lO(9?Mj?8xRdlUZ0Onp3y#RbK+8()MI z|A$4BjQu}CD;||6zlGxA%UgX|kn=K?DGb7Klt4Vi`*-Xwzip=M=En|&lejtoGO#c) z1QVjtbh3*15fD@$R81)Z6fRxx^97$7J?+l&iNi~g&Ow~zVNg(?wzDmII_=tQAukt#Re+2LEltFF#q>$}6|bpdo! z?u#9g!mf}=BKHSlD8o{f%7VSBAx=ugUn|S)Bx>E|3f;cfOOOxCVW?WC^8_Y~xp63T>s$!-V=ZvNlHT85#N2%yVo_#_l+?20m>MO`9e))bHlQjP zouk(F--AM&od-4Rb+oy!;tUWeR`eTNb%gg zmIlahTy(oM%M3?NYOS|d-nF0qZjKu?x&G*W^dtWAJGm6@_FX&T$t(=s)P`ysMSu@m z3X=N-MggsjR&IY`WM-jF$%Z<&yN1EGSgN6d?K_of$p`hy-m2uODhU<0V_%oWbpQD1 zzzf!0mtjDA{1UlO?AXPxnJh|WS0Jr2Gv-3PJN7rmqbb}(#EGz(QL1^sC{i4#TBnj} zTNA-V!nZU@{EUDW(Uh;0$g&iK@MSMoKkXaLoZ0FiiEoYXQk4`^RP0t~wgPnQEV=p2 z8`fnCij;0+zh1sDCj@>nw?$a58g)et}g_HA!tMOzJXi>Tb|45;XCBXG46;3Ffb8!`0jBFR00pvNG0Lk&71S%)SeW}|L)<9Z=iMClWX3!;b2C}+*;-N4fLUgjbjBmt8mn@S&n*45GD~*O)LuGGQ_0z% zV%vl9Nzvz5zt-i-KPFwnq|&EWgAE^2Xt{VHo3BXXcz=$#4%n^8TsiKg21&A-VX#%l z0?58|V$}7f(+SWa29icH$^I-V%{W9KWB}DPa9Bc3WG{>#igNV&7ydG9QLfQTI?l7c z?;;jZ{=G$?4D~iOZ%Hm=v~nMeZtE`|g6gU@*j#4<7;4gRWsWBRwfutcCE-As6~=^? zy1Ig}3LK$@)8^oa-G$lin0e|nJO+~;kKTq7gfYsT`S|{rXs~UOo}67^=iDHsy!7DY zJ5FtV{j1i(TY zCu~;0m*ByjjJTE0l-u7Xr0$WhRV6{}MShGlZNX3NUP^CxcS`;Kgz+`sB`7)K8f;x$ zD%3v>?Q@75?GNoh|H;L5%x{?7e$5?6Cf#`R++^S#t>KV{JnNv++FDOJ4Yz^PTl@{T zqA}V`(Dz46t50ax)}5a3&rZ6KchaXV>fpWyRjLAHEv_XMZ#mg?T^x-Z%e3o+X4oDl zIU}pTn9&W2>}sZ4)$SL2c%?!LUwQF1rvTWJKJHs_gUekqEBE;x;B(`{W{I0#N zM%8;yz7ER;)=`UuX;{Y+!-0=z#A&YLsx=5sOy32gWyK_v>Ut|Z3vDMU=aj8GpKd#N(R{FQ)vZFo#NoVZ-VTlozh5Zrb&maWsIPn|Ua0ql zgm29OpjMpf9vcOYvIhb>L9U#FWrEMV8s&im6K0xcHnw^L=!#Y(-k8o1I+wd zMo(Q-vPX7yoFI%H;wKlt7wBz|SY~7hZkR7LWCOZ67R>s+XYHj&Z_E4&n6EOkX@=1y zHoNnu=++DnZLx7x<`y~svyXjF8UY4eIdsT)%E^fS(3zRK)j}Ke#D-JvE4A||`-Jfb zQoN2LCF4G%q1PR_gsl%hn-49wT{bmL&fV8ylrqW(i#w9UZ=ra7^;~B(%P+LiJK5s{ zsDcAZej#AaHQrP!2)`(b+3O^mnCochzly4^+fd2%Qemt&U7O|D@%_!K39GO4bIxT= z81)CN?)OZK8uM|^>chk4x2y}VOwfIo@-raMy#^mUl782?`o}Qvd!zI66SnFrEwX=> z=t`;Iwnn_vR2D%_+F}fiO;(*Zv@yX$x`UliOM2w@HE`TFgs!yKDI(oSG8jx$N>QLk z91heQfMbTa`xAI)9O2>Y5-sJuG@a=KVgl|Gee`W3)wsA8>M+)%LEx6=lTxEMPrd&U&4~DfrUU2hCQrw&?!t%JqA45eEcrDzu8b1Tf}zSRjfX{7X#tTn z#@GJ(1Tj&hxAm78i%brQNc8&#OvlC-MC0uDTYO9&&tN@x2vO-)X(bK#h_)g#4B}nye|0h@#%X{rg8g@4OORtMpNB0Sasr>$Z%lDdb2sn zUR_7kr50;y8klYih^98l6Ba|qh z870g)yr0xx&{JXjh4aj?8==|Cv+eZ(N#alX?sK=V5pURie0*ecWe3Iq;NWbXBf{Uf z_J)S{lnfS!{fsj1JgDj05E)H7IZ^;4+{Om079o79BxK=~r(wio5%h z6U*Np+<2F#Jn)U@-S=aeyRXC0#Gk1cz+!%coPi<##730a;n7P<<6}NkA#3H z+xIXah)POJi*$FFNOyNA2uOFg3ep|Y-Q6XiG!NYk(hbrL{|kJ7=RN0}`DX5M7zdvF zxpMEl_S$Pdc`BW5)PH4zmcV|ayAxETn*-^SpDb447x4_Z#o(CzxXXMINQVPr64e*E zRo}`deluv$PG6Z6O(!Cq5^h({iPc=oe}^Mf-#q36Do`nlhuN`wQC*XNaIj$opB78pydSst$YLv`#eJ|Eg4WPy*57UF8 zQp}P^_aAY&xv**Yxo=kCVqMb*I&Laj$}(0Y>70G8SAO^Y{g7CXl(O*+w9h##ZBbvo zJX^o<3wi0FpDA$SvPPOt_=RxLw{M_`5N;bO!Z-P#pEI1Do^BA0y?);c3kV#-JCkzR zQgN0?p4dl6M^CZQUlXGcWbNmgD^A)?mgs2LR>yIt^j5>}OzgEAs+Z&&2(&;&PRW}S zL&5y>q@jR0-EPzIMjS>9w^zlaX{d~>rkOd;nflITfIHLV_L?HPTsY1Q2hRTic={7;0`LycxhY;SX!*i6M#_g~IZ`7HeJ)9q?XPXGn zlu1{%6vSezunXn-Yl1a3FC9iRwm#a*oSoHFeQ2|nRAns|)94M}>m^=Mkrbk>^AY zWL*zngT6d;|NT>u9$kTNViNZnPSMJ;J3JJ>m&;qVc&3xd#4={%e8FRgd3%$$3H^U_1_h%h2K8cN(xy%2 zxu9SX6Iw0~21Ybg<*Rj!H3()l05?XP+~o2_Lu5~Or={AopJjz&!yLWOc>gOYX|3)H z?qWaPt1eH`u|6KVb>>MUvo@^b`E7S~^_w^w|2SWL`TvZli44kD$;t`|Hj1c4rQk+b zs|Fu7SPm7HO#fg(B%0rYnm%C>=zy7lY=jW(-${U62y5M0WzT^hgHwNx0ZQ*COZ+33hTJw1K&B#5yR2tXL;CwFKiwN$8x zvYcgqP@Q}SO1qavsLV8=eR;B&=4RV9-!<4ra3EQO`+;*tI+;i-m zPu73F^_TM_BO%EQ$wNip8GhFHNVJB2IH~OkP#6R1Z`$CViwJ5912kAlaUEJ~_qn)6 zyh3KfXqCy6X`~~x(}R|;H@CM20;nZWKA)a}w8Fy`+{Gq%Olj7jE)0565CxF2GBSQo zVB2Xk43QPFBTG05{aJ6Y8d<^^AG0MgF)bNxFmWS{JiuL8{mLuu$BS{&*g)AcAo&01 zTq<`{wxeJVNOzq~>J=I|^w-)qJMJ;dq~7h+BKnxUg?hM;o4vsoW$^w=AMoxc1QL07 zV~49!piE)|%NVKA8%rm&GZB&w8ex`qRN~Gzd(4Wf`Wb>-Xs<0~z7ac|m-5B4XQTnY zbQ?|>$d-ZLrO8y;EBo`3&RbQzRX==OnBv4SVvbVd)$2uQ?s8Y8y;q|_k@GXM%{<(S zc?SkBP|kAI_|uEL{XEd(>r{ebj@syGX<27nOGng-g7q3rHi9gJH#=iJhkMT1j=%Z( ziqJGxwYXs)C+J6Vkxy6M3PdyIoS|GV;XI7w#0LsP)}sy~{Iw>`5z6^QF}!%-xWP3$ zjI1nHm9vc(wPMyYHj|OBu?%{IdtupDiEwIPy#2fj&CoRKZ9hYjXz3OfnokZ|u%{EM zgdFNzPqDiEEMM#Cse{rNwDVFkaVWMyqjy{TM7iNKVpJ}n)XK}J$PHJ&ak&;#1xv7J zm6{V4>^*zDao~`aIR{htW)<7~Vm_Vi1{9=HDio?QWIRQpR#@p&*=)IIU3z&mf7eB% zl&6ph8LKi;X|P=rinbAnpJ{aBF`nH=&*nSwp$r9DZtFHJ#%8V#^lNrv$$$V#s5Rkq zIJKy>Gf~~s)up9jdVhVu*{iD159Ga*y5HF(U31~`|A;G*`M3Z&fQ1P}N0r8?l_s!B zRn&H?vrUxfB)Z68M(^sA#Q;6C_?!6D!9C2an@g64a*~5*nQy-|P{+?!<+pLY(9Iu3 zAzD~DSOZ%3i5#Vwpl@}aBM}kN_Dpqlq|2Y*_vID%t@jK~*{a2+hxbw|&PkB5Qay?o z1=o{J&xqVs{zr3#3CxwN0}rwEN0>-5yRkg32HNuw3Z z(vb2hVi*@2)?Al2%hY0ai1D0=O3klT`C?wm_M~3Ma*KInk%CZuw!Ze$CbGbb_iJ=H zdwa7StOlLTjsbkaVxhYR9o<2grAjxq9`XFD!(B59 zL{GI&9HemfW>O}?Bk$%Y<%X8Ga&hHsiAPt>)gKpL=E&+u8+4iSYh|n+q4J)+mNjJy z_Pn-u))n`(T-vSv;KTSmh$(2rye)S@dSJ9zYciUd3Q|VtQEkfhe$Bcl(q?aFUYoi* zq``hG_iAtMlH;5Ug0f#zvDg0Cq_-o~NMK!)UvTfy=av z9r_w6$8uit_#VY;*bnKtdw5tvhXmgb(&xl}g})YD`R1Y}%|%JGf40x`={ zjNPR+3Xzl-*AJdcni?$asU+^RComDu{6CLnib;XivQh z^o{7^+izUSWbV^i!*YEpll;OlT>sG>xgvUJ1j8#Vh0-$rvP&8cOy% z`PZp!^s0R?b}BWwzncd}7~UbIx*92N+C)|?Y=7k}`DVUE0d;SWAWy%;6at|n0CCA% zEUZ{o}74Zf$fS`U#X!26$A}_ zy$09Q$OO4{47l6%{=x#eku0k29;$#D2E{B+n#FU21hl=!mB4;*AUiC)QHLKaJF(Nb z>u|ErEC-r+6gkp;-J%qrR4#MpI$TIxKPv!C_Q00w&U72DXYGOBl$5}I9oIR&I@MA| z3@sPgMr<4#RM@X;bRtrD6_=dMHbJCgyxaa9dMDZv<8B!`qOuToaHKQz^CH&}T$q^s zRtn16^YhmT-&ms?-mj_mQ`r2_|21kE*`qomoF1GsuFBj2Id3u#4V4&U(_br6&Tlvy z30na4WH~}Y>=?|^#YMULSkJL7kG3G}!F;##*xjY_=mNF-2*9{MQJ8FSNGyx7Azy>2 zc+b@A6$}K$C=|tmDxT~luH!s*7u1=ww+ID_*|FS~inmhDnV#U{-gv#pqQVZ~|k`yxHq0K`BjZBwLe$N1Q? zU%y_T^fI7t&T;iC>5OW6+>}nt%p?HGF(FX7kilfoX?>$2H^v*{wflXE1r}USA801^ zv&|(zhh^O|gbQ3py0LaRS7&Ei+TVr)b%;)X2GeeqM@zB`<$vdBG~C6ivVV+fc?Bon zCgDT`KfJWFu3R*^tS%7#a8OK^TH_&_6cW8PmO64^)agJWxnP-BM8W;$;&n2ojkt!0 zh<7AeZ#mvBKEHrZ3cp8D)K*(ENMFh?wUVhEUmoL5?xI|O3VZV8wx@%PimOtH&Oo=$ zTGdVR9Q-nQ36tBm;yo=&ue;WQ+jSWfLrjaYJT|X|o3V3)c;=W-do$ViUb~q^xgar5 z->6r?=jom*8CQ~~Z5>9ylSeNROAAL+>0;6yem@M_VB6aiA|r<;W%DIgJnNm?7)V|| z*!phpspX?5DB=eWXFCZLg0X%?*N;>(;u2|~a%)sOSdqFHgsdl*20AV&108eEf<;vg zuE}C8G0=PJ_6pjD>{R*Yv90z{J@dZ2jG$IHv_0MPfPHxZHrYqFLB%|}vilX#Q zFtDeyB%%uMY_)S`-#9unl{9*u?-xs!?=Cb|o1z<*zx$Sg-#h(Hz)NOq!{VnmllF^l zQL0Ku$1{kcMEl#UVe{MTu$*QOo{D8$aX?5Qc8qVzyF$TX)TC+!n|gz7a9qPoEckno zzPxeYg$&iNHa{8pqq_R+@eoVnjWoNiaW;MUN7p9U`&DDG@O^)t@#jF!$VWHQ$baMiJMDVDIQ1s^+C(m zsvlXIEM3X>DLS6(R)us^1k-w5ZAINSudk+C@JF{KeCTM0v_R8oP53+3BVd^ikxd2E zyraci%N+`T%~zMQiF3F6Nju1Z>+3fCM0cv^$jA5)_olj+rP}p;wox;2Evi&r_^87s|bi|5$68QvLfos>)?+4W4X!4LYIR=5{Db#dl?4P8?kW^!M@em(j! zw7z`=0GQ=p$CIDkz2?*3wJ7?&KxnbS&_;&QlY_zyIW( zB&`9c@i5o;o;_!7HkOyJ&qKEU0`J)-jSnV`=V(WZ#LdP-?DeF8PRpq9LQZb*I-1YT z$kw`SbdpT41m{_I3pDT)LYK_v*robb>ug?HcIfdms2dx+sqAZf|I2mWp3Q86!sWx0 zhd9ZA#2ZJ}pdnGTCidq~Vp)ulxAo(=(<3L2Tt_RpVqt{ZN|rCXhW9c7k=vGlho~tA z`?HTXYWQHWxkMB#|x+`lkFotD%EwdO0Ce3~G%Y;1XAd#gN9{^NE#+r6I3`Wo}3(&y(S z>JW7rpq=L!bg2&ia7nmmkD;lxH3=oGGVJwaEBe-D0iY1k+rd(wV6%refaf>DbHSw> zA^FYNKd|N&7MyNNrm`V=yh{{a!P*=B0hN-LDuzm+!+m!Tm`Y4zctTW0QkY0o(Hee_ znL{TCt}1t70X$`sTbPDie| z7ux-F^Q}Dao8Hjr@xL9v#W?pA6XQ`qe&@iPCXoJm$$o9q>`5d}20$H$7{5@7qN+)C zasrd@Dhl-?H)S5xA3esK97`$+m2~?RJ{c>tv9a+CxHhoKiF!_VXB0poNnPyRJ>pT9 zlTc-wyWcxv3AB(?ez_ELNcMdPLP#X}Ve0Wl=>~UjNS@tB&dGKR_M}bKdP0OF{!Y>T zqQc^&6MIr3YFFfdZe4h2fYVcocPt&n=4-1tb>>B{UEr%XX-3V@do^-gm1=?!JGYwwc3##-%KZV zrNf8>!ib=ms2>8M2j~v^Ar`VmCl>01tvWr?-}hk&kPw8(h}TCidfg`jMpgbY_wB8M zDhboU^L1H#m}SpI20ceMm$TjY&7t%)*7rZQJ6C`kRU6_0hgiPBF zHSfgKO6EqYKm6Za5F7wP;m1n{OQQ2NQxYaM21`ZEt3_LC)uKGK7r+6MjFGQ*InFtx zL!*K5QAf45F3W87JHKY(y=eO9Cx7uYgV#{L zqw4!vFH4>`|;q48zqCu<*1G6apd#Nqg8TB{>5xBn(^XvWLC0JGU^WN30>Z&T`SFdF(m!3BN zHXSb*o+#D=YO}_Wjx&((e%egpj{;-!4s=qaXz($&DDD|&d#+NV9l4Trt#o-L;;>Qn zGo<5n`j;LsJbGM6o?M8040d|pxPBgODKki7&Lk2%gjxH z-5Yr!M?*t1>itUp5MxkRRDNCubPx;&_~w5P8Doey(?TC75?0t*m-uq1>fU&&WH3 z%9QgJhnqdFDaAtZwP9LyoD7|vIg#)=<(+~F{)4rGA68y_>m7C4F@#u>DSkWtvxKSK zVE;N(D>0v5T-zLI!`}@b0faXk!rlz~=o@uaZNN*KQ4ZAlV=q z=WWp~h^x^Cu7SRw{F=$?T=1YHodVDQ{#hdUv$ke0QM#sfYt@3WSe9{J#%+fL$TG~6 zCtH9z9$-_S5ez=B-I$jm-2Y9UAc1(Q8p4p;h1LhLz$w|%vdeJZC z2ez)M;Lr7LBEo4wpr{psED1!Z4C|O zpLG;3Y}s6mc-=E+hFe=ZLbxV`^!+W*Ec&hO@DMKkO4k#cuay66T_~6UVW=0zBtBhL zsT9OaN#g|$X5cP z8l~|sJotA}qyxb&iE5blt5y$KnyWN^J!#QrrT#gwtcuX{JbxiOLV1`>;8w#LN5e*+ zKxx*TuE<1W;ckr7(8bi|l!N>FEGPfRtdI=!YV&}`gg=bdSC@l z&lkS`_y`T{1ArS8Zm0c%p2{I7sHI)W*^N$KNrU8IqqXhygJtsX?oTRN->6AZn7W!2 zK}-|hqZ^R?*GHj0eAKF_U~t&;?}>>@CE6O@zkc1VM12~jfx-5$9F3?|`{X)6?@Feo zkMPEUdt?2MX#SbAaHH;B+|=@W&woEj-1=c#;d7DSD8e{8)>P?tz*4I-Zp7(+_6!gd zM1u?YQTh3Z^J7-gnF-oknA;TQCW{vZ@kN|6E@cueaK85c*)znbD6rBDnPR+x?cs;- zh*?>K@2(F>fK%?Pt&_ZBj23*=4@(H&lp*Em^vPu3GZkM7Ln#cK9X~Gnb^;Y{d~;d$v`el!;)l=`w^TX|iI9AJj_y@9F!E@a$*nC0zIr znawj;I5>qc!j;W!mBIFxP?er9eR@5esOnYFAgQFs6xVvrHt|ngLdk2BdZ=_tShr*N zop2-L&Zb@$^0Dzxr|4d`T&}A7IWk8T<7p&nj4XG#A~dtS!>`ODM)EO;h4RGemJbCX{ar3z?3dGx@8Leep(o-^DOG2Y+d|@^dXTvxsfA z_S1}&8mb)~9%p|*ARlSI5vyO3lin#;0C)6RRY8J@gyE53%p)n>;7^8UFp zf5|nvf*GX-@?6dA!p-$C%iOPavfKx5JPk^p9;2|oYzAiEMn zrQ|=7#Z>a0zpaHQ0n^|2k4U$cq+!%9LM(xcm zO)hsV2b{%kF(ZlFY-uVSlH037@k+UBD?B`$`R8)C5{8MK2Z(Z}#Js5dte%^qwK(JWhFLR%W@t`vs$4GC zepdGEq{3c>N(?Fjyi5+0RL;oH3BG82eXjlldi2|RD;gDQZE~R@oU^{g_;UK&!v_7$ zP$SeKGnk`$1MxB+_ms9vJV;-{=P#?hBKI6pQY_51&Ifnt_sc>;59@qE3%sCIk*~-B zB0mE$eL>~|^k=jTAcI-IMTEAIsb;B6ul>-Rx|PpGK!_!~$2&WmT;Y`0RY-Wkd_%@H z#VtQOYQYA{x#>i+n)&3}bp;PMG?hL2N%)$d)nU(l|46pJ(NvV&RIQO}Pr&1=OaWEF_-P=2ykE*6c9qx$enkhmz|1>yeSyqn|EXC8GH} zRFCT1qDHdC`PNudl=g~bLMTAU1z88Ddx}TORp&G9J7VE`1e$I3DyT+ z)EM1SK3N7RXSjEm8cuQQxnE>X&9K{+k`_nSQ$tX1!KQb{7xNt~JYuF|%pR42Ed~oRBh>S z5C_B7vJA@jM4FI{t)q0t***Ucsis`X*w3n=-$i!Lt+Gpy=BSXg-hP>?;6IMDVKv<2 zJ#u%JJ4H7eC>C?t)C;S$|ImtwJ5<2KR-hi_NvRP}OGle8ls;0hn0uFW*epUHo1i`< zciP5<%7_88Wyh^}sQJvvzxVp|==C>(uiGv9_;F^P>cH}J%Kb*T`5jCIqneir3;5h7 zN2_G^*JmEL7^sR=Fp(aoF8-4nCO_0TMYiUS-BB#u%0oh(39@wtH7?N$jaJoIu?D)) zVV}YeFWiSx8Y<0n#7vA+WvH4xt*ugKRr8eWI*jYE#a51+pjL`WP`y-WqFvPD_PD(@ zS&AL6GVgK?g}#lu>c^E?Ncu`?5vY9f=Mo(fLzQSb<5ilJjaL6+G(q8b6YPxDWLC zy6dO(wO%X> z3+V67x$Y7bT=;02QPPBP)Qw4W1>8~w8;GSgdA?On19& zJ?riV3t6H^Aik1yt2cizLjUy-f95?W_8ZN!D*eDP0MhFqApJBI#0b4b*VngITSSm| zPz;BGgDYGUJ5147R$G(1;&6XUPJ)MQo?FXr6{B)E5OqCfuTH_HTMt7dLqtm?d)*wzMn+`peO3E-acskF$)^%*FYhv|slm0Y?>9dh-WO|AWPeDUs9B*lb zFN<`H>`SwPy6HRl43g7iw!o}8$WE=*rdNoUKG}E@%)8H<{4`05u_C6fqP3S_FGi!U z6Z6bwHe)t7OWrmH{!srvEdad#+Kvx&q)uCk5NiS-pSOVBBD_74&9&Bp{!+^Ls}$P> znTo8Yzjr47Hg>w_y2=+;iN_oj8`v&NV0P+_w!in~uUCk~<+r zFA7P=C=6CM8`2(=K!1&4Z~{mLg37xg-m$aOvA&zP(+Bl^I;vJH}3f|F3brhF0ec`ICd&8woJaPnSoB+TU2J z%V~S^CL`Kj6NC(m`S~qs$d<&)o_JIUi8d|B(aA>lvn+6IdQEO9?kF!RJ(n%|`d{Bo zIs%F|DaUyd>0>{Zb_0pizIin1b5m^GZo14^ljdChEj-1RM1)U0nS2;mvLk~D69C|w z)l13 zbfKZ|Oljj8f}?y)^b*;N*ZVfZ?7K$jXB(ZC>@WW4TB0bG*5dcxF40g@il2_(9j>5B z`5d4J16d#0*5RF#olofFs*iP?t+k@Sq?G@9b-E)AR9;pNPDg-GcMh^**1Mi!^Z^~GE-kiE z-(o;AHfO$(Ln#FJ)MT~>DVoKY|C9h@V7gGR$@T5o8S+?>hE{{i=$EULON|)$$op?( zmTsrpzLFX-TYI~^CTozrxdDk*br-jtpDQAX7JVw|i`>#uju>j+5pg{iEt^-srb46pU&Q_N#)cjaiSoW#xxcwI zdx3mclRMujA=aX%!eFSutzpkS{as6;+U`sKK>bVmforVGuUAUqb)EWYi!>KM?2j%H z*NEbq)1gAQqhnNYCb*PfJ#T(!c`w z^cP&5!p;N!`ad4HSrM%=O}%&iFI#{JgTQEp2%dv2XovKT+wsOXMxLj^`H=PY#!k*5 zl0`n+I5NXV%*6_uGy5MMd|6P4rg?N(dh z`YdCAm{`wH;sQC@9wlR}EoT--JK0+!RDUHHhi%rJ0Q7H_wrVmVZW{x1s@<$9)P<^a z@)Luk5=ka|Gi2rgb3!i1SZTUVeu{#EP#}Sffy?FwopxC^gF9Uycyb#-DYlMr{^My9BD4>?NLLwqbx$`L$-(|cU zk;E1dao&YcOw5Dq7*)_ZD7?3~^N+FH9_uJ7W6z0aiBlTM>iIv|u~QbH3FV6)p6I2H zEn{F)nEOc)H(5x|Xj?~X{yZjTn7Q2+7%*~61hC3xvJbr!e?%yjV{{i2y2?@{<16Qo z2jq$5S(a2>gl@C@fTTvA;?+cvMiz$Xmd!y5MVHCbQLlRffZ^}k+1An7FSWi(U^3tZ zOx}=r?NX|^36Nx`*~zL~B;;#Iy*fV-9?hXsIEJyah#6T7fOfis;c+DSgVou zT0R`5Nk$KujCy7dXXX~%T|7dSIf7uWsOAix=wt%CFYmYPXf8?yv*8Q8O=@Dtk1$jg z+IbEM1!LT{Z_a=@4i5FM8)vt=L#O%bb$ITq;`-+|VYXZ<6@{_sn_YYh@?$_G*uxtmYMPO8B(-9EfSg%b$N7vr*BRfttpAU1Tn zxkwMh+Z#%Uo^(H3#u>#Z-~*UV6at^I;i)i&SzZ#YY7qt|Ly3g5{mulNBOGBy$>pCx z(6(tB7gqtTr*{WQOHZa&d+eLLH6jf0du~`n7H^3Cewak**sotHcehRiS{#QcRT5sP zWzN9Nd?5{IHl#Mh)NXdC?-F&q`-JojdGEvd6asbBZrKO)O-Rgf1HZn5bOZ@oW0|hJ zp<;<%O96(c&bPr`XV(RJ33pN~C4K&H$hR^&!#Og>F>q?Mi51CQc0y!~n&tXDjUDV+ z57mdYIvllQtR^EcF#)O$?)P1QLs#D}Y8hmy*O|&>fbX!wF6eZ%(8MEIKhF04F`FX& z6_{#r+e{>z_#;*==CMG$sLl}+|3dG+2Wabk1ME!g!LN9|4ZG==S_H6E5+qo?fcIlt zWC+xAo5?9Cj8%tXZTpNlxwyFtnt}d?@SIq%_ozqJ!NEaqK(K3GodN{~1zQa%7Mian-r(i1 z$R}Xep`hQ8131LrswABO38Zm6;a>{?o0g9rUQ}`v_ydWMSJ#R1)q8=t967z51mW^v z?O5SitA!tIGdiIN{o-IKBLuyn?LJu4%1MvNIS53cnZYy6rk>Wl>4_-4@!=@nAmSRJL=Pn9ib8&{RuE`r{5 z=*E4?g@g%?yHh;2YP%2YYVV$htQ~Xh3@Wy1CtRgPM*AVoGBq*FqmL%o`Z8QIRS{3A zrW>oHCkk8iWh>y!R9ns~RI`~Z6Rst-y9Fj%t;!UZiCQLd+UTynV_RJ9L9n-AGh_el znucWm52aZN5UWQq4jO!lrhokkRa!FB&O;Rb=>Pm*knds1Q3IeNpNf3;(YYldE|F+% zzN5Bi@|1XL@-GDQn8j%R`oODA|H>aAB&g3xqiVk)U5$-y=qU}VFaA?90Dkym0Z3n- zcmmo4rLcXRcIi(jw95P^H5fS`hvt2V?tFJcB_XQ0=XiUCIS^P#6_#N!+T7xPsof~$ z>B)a`vh`-(W;NLx%B#V8$$pjmak`+MxeG!uB@sdWO20!5#~FA6AG;kU7!LcG!Y3Yh zc5!uOttwk@36j7DPbRTkBz5s-Ll-zwVT62Xz&1uyRu-1<=X^|v zX;vf@VSnE7=j=?;qw2FH(AZ{_N#zd(ipp6D+4tapn)H3&nz3v+FDql*UTi_2)BT}9 zm8X;wyab;b8_jBB%a5E{W6@|kp$X463C`H3*%|>ZElZ>M)0yN43J*|7&Esq5c^LQi z2MCAUZRgZ$DS-$XsQfn4X=p5E7Th}_qJ0M2;b$EBcUF_HiVL8*l{lburb)+FC@%bf zQ=#hvwcSclm^ik-n4dFtiNCxB|JoGjY|Yi495E0d_2sacI{t<(R@R9`{X z>ni_s9QSl7di&sD+(y2bB2lGE;Y>l3LZ+(y)yeacCJhM!csPlWegnw}(B~5$R5iS% z%CbGWyXDaqHDsF{MO<%4+t)tIN^R&IJ$`z7I*FUW>(a~}dLzbb7fEUpF!$Bt(T~=E zvCe0k)+)V6h{G8;Xt5H@m3{4}bbo+F`bg_9ee>tf5?_sYLs_*AE={t{ef0G zY@yO8SEn>3_gwyv*kWNI0Gie4#OF{-qeegU`xl>nH2h=Q?C016z*x;=DZj(ELeU=T zzr>p7_VmL;$V==9+fH^ioawf@WD~GL=E3DC@EqyYQKGFeX52Vfdf`_?^Gz&!)0HCC z8d_SFpBobVkZ%_$SQ4z}8&WVQCQdd|X{HokwM%X=Z%-7bR(ua^EMZss3Amh~N;=qd z*Xf{j`g6S|RT!YueOM7FA1ktOT8$T=R^?3y;e09~Z(U||T>$$1`woVt zz~mcicL0dIKtvp4Ypl=yz%fI}YWqn&8!as;=$-^zj1ug9%G& zS46rR&#!artuSW*sL7IMll!UxE~NCM5bEVcC?X;&%&u211(GqI@3a5NA^+mUt=Fg( zFoXzIFJ?<2tclV@+{wU|+-R?1-psT(!7_yPR_v!BS1XOqe6@TD!YrO&JhuIS(mr)How!2#P#}mj?QxzF zculKT^5XVthj@N2qBQDuciN7|@F-4?+gfMSlvKm!{9rM=AplcpnH$i~@@SK6X;h!7AxR|hO@v%g z@0aP%1I@1tQ*5|{)t)v;jEZO^sZiuA)yT8eUNvu_Hdx(Ye9rr{V%K!2ga%72@ z6KBRG-ru^40rPWzX&kZ81LyPCMm2{D)>_`iWKcOU5#ZyG6RWCDP(z2QlaQGV0mUtc zFF_TYZfDeyGv70&MEEIyvH*A94h;|73yf@soikw*^qt&H1GI7S>kLX@p`+H5hJOp|qlf1X`@f{=9c zSwEPWfUiWO54P(+&rc3?Z{Op@m-^lpB*YT0=(X!|Ol>+R@&DFI3KAbagMIpRlN<#Z zS#eD+i+??reG3~G7uN!tDi;j{BX2;WMb6^x&NG#}ctGMZ_WOF^zWx>s^Cu38&wrsN z>3?sLCr`d`%G@zFxL@WFN}f}i?wQ1 z3uLavqU%8A&z5ekEJ3D7kkK|cl8$ab(TD;HHyhcwKOt}1Oj30Qz%vD|DKqui?+z9O z@;x&!ET_syAUi-WXnPKnP;g~hpuc$b=S_Yk2rU?BHyBtyilbCBbOHB5-eYYLxD8w2 zes5SIy@Upgkwgr$L@NIqnjnI@wtl#s%)Idj0OKHX%Jr>bXX zuQ?d}7ExLnMm?xlYdK1J-Z!Ux%&WWYcXxLU(>#$3bZiN3k9w^us;hJ9brLE^8Y5!* zucO&5TpfxjOWe*aH(XQq#xF)L_I2tkQ@eu=tHYR4kRMj!C&q(8^=&jyGpnkuPG9uA z?hwrWHuZO?`W=1YuUYz2TlilfDm=xe{r(o*BKp~7Y`1^f!nE%ou^@ErTPxE&zD8fe zrFoHS9vh1#sWDq;!v_4{x78AG&&1=d z9iHta7NJ^Z#?pDMdujO%0kKx+yIjBVb#~Pu>HE$8>Hg8H+yYE#NLGdwk>rC8ny^^z;%YkZ501BCp3mMn4QP zyV2sk84@ybx=LY9MNB0t_Nc7k`;m9re@~afh;W4a(Q3NlAKUf5%%WsaUZ7-wgsZF= z+XcQeO$Vs(a4#ylm71>HATR%D$|2T88cj9oB)JvsG)+raED_xe+`#;m?eQJOW_Moz zy6k?+`XLEnOvPa|0`lNf%p21ITh91I?F z=bK^CPKgt6v&!%P1T&ojZnTJQ234tPLjE6OB7xCkd!;&!U%Lo?y|DKhC-<7Ewd%&2 z5BaAq(VGelL@9wz^MVt!mI{@|rAtJF@oCn$-F#oZKP)F|yf6S<}y-#a+bs{0VgJ%rW|Sd*hle_BzTK9WpiyDuowOO>7Dnn}AyrK@u2_Dbr7=#p`52ZIj6sh+c zNlNRsAy|Zjq6-UFN?}H}2RLFc6mQ72xVYG4b7%m+FzT0gmS5iAt*Z$G zD77oX+_Mf75TMvMoWvECXSJvR3>}H=980j{-c=&CO2J#8DwlqBni!RuiZY%*s$O3K z;r#F+9YRfoCB=2WM6O{0Fx%-p2kO14Lu|vI$Yj@3+zhk}pgWd&43#AxN2|pihxyhj zo0yQZOiDxd8{%aM(8KJI-1wBuDA@(7QK&o0q~j$Oa?lJxth6FI?S6d*c-?Fz6&-;w z3gyX~9GVJ@JocZuCk97~)Hl}PbE7!fPmyVA4Pr1kAZeVB_dYr?Fh{VK73uWDAc?a* ze!U>y@ony5nD38ZN&OV&jA>MhO4R**6`4B%MGk-Z%u_?l9k1_kei%|}a0Au;^Nmhg zhA{x#p=r!=q~M}g%)$T=?q|`k{Vvs!S4Fflf7k{$C-tr?vrnwp9J@lBfHCicWUCg% zQ~>6_>}kxc+d1#0`CA3M~FO zH%^W;qIwGQh644kOtiF2VP zkQM%#v9I1=zMyMeke9_UWka6wIPO9va99x#QK(IucC4wQU9R>h`BxbA59rdN*Lh&E zSJ| zZP;JF zv~VIAz;)Hfro4wu#*X?+>TO4Y4v5k&*Z*dliiCoaz1CY>pgs^qfnryY5^*x`RIlN) zTd1uLK=ghMT{c}hv^Ennv#wWq9H380obpz*RNTPGqF3qC$G55w<|_bmhSn$j+^-Fx z)M*F?5fMU7j_W3ELIq6zF;)5Z3OHC;N`zc)|C3ygYs-S1+&%+i<3fEdt(x7uo!1U* zfZX^F;0i<49+qN}uh=X!WhUt;DZK+RX^MF||2oQOAh^x3uSGI1K09+vTK9Z)UslG> z*z=nH(K+yr^)4=^YwUC!B`i1xHA17EpiIR=e=CXc1O3{dp890Yd_22FenINi?kL?C)jspMP<|rF19( z$Rd$N(EpXHd~mzGKcIO_6@OUZhzg$p7-S{-oe7#*2g(1qZa;CJ|7i6@Ogag?4E-Kz^Z1C& zkA;!Zzo@9Fdt^i?RmAsEd*ZzI6Y50`dz)GP4`zAh~uNJP?q-eD>Rn z?q!+*gy9WfYGP8?jRG=Io^*Vxp5sf`e+~~1w3eG4B&k36$NrDN^Ll9$7?-L9*1~Ek zx>sg!0Ck3bxaNP87rS!52$KjLBY{MUmP4J9r19Du1Eet;Kyb#-M&C)@t4!w>K3e|%)` zW`O(UGdRK6x(ok>(Q>`+n=XdA9tCCo<4Oa1y#RRYl~zHZ>ie#~QALV=D& z{x7)g{ZClbHj|$mgHs{^g82d;_v)Itoc|x!{;z}2s0p&2xO>HHH@*^xxp8qQzF$H9 z{?7rq>SLhr&o+A>2td1pWgU)iw#Qg^Z)=PSEMzy`UpxPwUq$r=;Q}S6b($P(UteGM z_&A`lEL|4Mox@f98wY?<{ES2U$#BU)=Ii-f_UFPv=-s99htGpS&;MsXLBTHjf%w#> z?PX^B6Q8m&Yfv&oczJo*)wYR)_CJ#=NZbsnbb_r~aqbJBgCIh&(#Qi;d7J6+J*9uZ zn*Qq%=LM^a9>!;MhX`$FX9tOAVd3JO^2(u*{YTW||K@n}4cMyND>^>Xfe$4Z#oAmi zS^s-M!Ow`%V5>f9vdJNKR9F9Qn+t+;zWO>+CSNjx5HNA%vcGl2p8<}rYi9zJHGj0_4aXIU-?02u5f#y5_?y{8WFYu89g@$qxv3 zjIo$o{oJi>ZB&euUw8M+PejQxQQOCRC}Vv@z_hkAfUR)Yi=FB91R4PWp+GsGlT2p& z1}XEm;=|Ax5n(3w*%05!KZ!o-3~3w#g*(7ZWkXA%L_EHz3``yC?zQpQ$n4C_P?p4P zuxp6=e{pcY8*o^lKjyQoH=6x6LjcY!CJp;&+qXePcQRC>|5*p=C@>+TKMtOjZ==el zFnL@_HaHzbyzNGK{r_3m4B$7uih&P3@%OH-Wh4W0IK2>7g*mg|2gk8(Bg?L{H)W z2Fxko1Z>u)sh~WYnwlDlrg}nYJV5J8j3oTedBcD?`e{2^VgQo%MAQoZkE^SIs%qW3 zh$yKvC?(yYfOJVo$DvzFQo2h(>2B#fgmg-SQqn0U-Q5ks{~h(-_ufB-gWhLAmo?K{Ox*cGBKBz|O0~moXPSBQvwF@;M;{gT7^0zwe2U3l*tqKQN+H z+MipVs=xI10ywVYCcN6Ti(mLx*!^8#0~CuJG}06~K6tQDJytUN1`M?|mymsarz;>B zk>*XCgyl61_EtWt6tZ1jJ{TEDeJ?aMcFl`;4}5}*4{S@rCgG7o*y7?M&>5FKIy~eY z!WFXoa|IWaq$QQ;&jgmSG)K9=&b*U~@AJcBB9MN7vJyCG_JZTjO=kpIAoKyFgRJ`R z1#0(m4?713iOHZBf3lPJ92SN}E??jb5N_lCK5C8u{Lylj@B?Si&0&Bg*e5c7&azCPkh(fom%-I&5cay+ zTAvE2nWZbr%X5LO+|Ww*{jmn*#a77gbMgjuL6cf01is116s2)yGiF5~3bl?Q2jYS> z^{KJ3akI4LY-H=l&$05RCZ>e09vN(!Gw4iRWfBi{(eHzT77otGhX&&-<}$GqQa8|aJ?{zwX5Gf1y*W=&2$u9RGg#G4bhMXF zTd2_gz4{5~Q%eEFarV>=C@b_In&BXJKW9iD7p6-@U}6+d6*n|j!+(E3W2bkSiA6vl zrK+m>>?D{gR=_R{x*G88SZMErGK=Eu&z)FG_4c4aeBhUF69h7j1b|jE7}TsV1GX6+ zu-UIwu{&-Ffr+<7W3MmrpI^WVZw+W^$F_RgXB3Oj*wkdU+!+=NT39bT;%`C3BtZEi z%TdF!Wb1yDE+}ClVlbA0za_yizrUXqa`Kv#$(Zy+5P!t{Rs(S z_-wcM{fB57Pubq)TjVfR7|;n?p6$&_1EQ7Tm!cnANwgo9|0GsYUF7c{x>{RFaRchC z=Uad?h#VMm7f2z{NO}QNq_1}z4lDJExXPAq8&N*|J|b`kSYhCNii%jLV1rrY$Z}T% z_Z%2ny0}OI_@C->*!J$$^9OTbO-;?3rdfBa9|T84Q3joeO9(VH5n1dU@1~XMQXZh< zt>9b;tgNi$;AoVa!+h$(MuF)LfSXuB4hR8S{dpNZ9-ub`ODy5}PESpMOM$j-$1qo> zp$hDrM}9|GX#gU=MF~DP2M3RVvSIq!zF@a5^z8tMu(8P?cA|Ow*~eHtXvAJ-G4~+Cegnla@RKAOCR`4eee@DsZ^x&I>Z8=fj8h}PY z9NaXeaZy_{Cbg0j2x&(>fUKCVfRk7Oh(S_Az&h(NBC5X1{0BXh=oXm4NlCsN&3(kA z*R1{JNx46>dYLykBoe^!DhK!?&{I_#c7>Z;n#J_DwSC}m+-euJOQPNWOj|Zx49HyU zZYQQri&7n%C+~l7EEE4uUklg>?S_=Jw4d4td@mLkb*_M%RFB=}@^WcOG%J;p6Nhry z8#$P0)#s0K98P!GZA&=6gOE2ethE&8}9P3qkXHxNW;}Lvkz)Fg0O z;e>1g3ANa2KqPD1s{eG`2wtpQCDY$;K@3&KF9Ezl=$H_|)v}8B2R!hxso$1Pt z9zU*8uCc`YtXhor{thn?>!E(dL7B~;;4KeQ?xiFj*HbNcAbMrD8VrHEKHYu|d=|0} zm%B#6QVf@VI}bfXG{*A)oW#!&?QesB7T3-MTNGJ7)8rEKiBp#iLdHdfT4v>&GePZA z*v%lg*em<{rP?Nf9^aS+b19<~cc-iCz8Dn%p+4}M=Sm7S3sTKN7!z3ZnipNp*^0DD z@&FlY4cg3<^>5g{7sea7qEC z=!=uh(bsi9g8eFi#Yh!E7aetD`+%_T=Nus{HM-zUP(H+pDfOg+C+s(pCoc~S2Nc1y z$E{})s~1QGK~^~!+wh=XY|G8kzp5lHkMKn3k%g0WUsO6 zEcN>O`p==2z>h`w8W2_?$o@HPftVD^i8w!={(%%fA_W@Ip>ML|^=gb#+hH`U&GmC1RBl zMWRuH;0tHl<5b;uHa82*-e9#?=?pKEJueub$=ltlJ7wfIu@W8IB?}D)}fZh z=Ni@6=rM)`q-lPf=T%qZz8}n0p_1r{WB7Kx`A9z~67%;lY?Xjz*b%~H_1=2VYmNrf zDO%~=@1*kcU|I@gOjv5BN;IWXRqDy;OXJs~%K~yLkRX3Bk;w#+j7v&p9oSxuvE~fq zw!4jk#DcAn0twEp5}OkZ>W`ubs7ML^xdT|R6zQsm>43W%G~)kzS~^4npQY^o@#E)& z&PQiYQ~6wdfPGGvJ|d@MRJZpLgkV^0^O-9pBsX;FaF0f|1F0oAz%gM4~(kJRd;I1JhG>~d9eoZhhlr80E)?F1IId@e{yrHoBYpENX^8_;`t{(?1U+DaEFS`4LjOp8e9 zC=3kJP~~8QL^>tSt)a|J2z>{{%#cEt>>ntiwHEen>|I+76|%9h(MbNVHp!!VZ!i?% za{%7ChhjoSv6xcE`JdGasv&8->zUGH6y=))3{dE2okRw;w{n`QsWG{$i?v66_0h5q zt6h-(VY1mu0HlrRTf2g-v=EkoEM97kqAX{iRJFjtrc4Rh4F!LoI2K^OBi=N>69cTnr2%E|4FXY$lr}tYeRDY9 zqmYWDe+u?dV}&HO3!a1Q=}m?axO4Vdc8KCI2An&!(%uB?&pG@Rc;%4b?ZUIwwfNAj ziBe%8CQqrz7(?^=OiiSA{qI>35(M{SZ15Vs;FI^*Ps`~O{C1nAQE=4J@iCRz%~zPpTR(98>?ywqW-%@(sWbsGpe2;As7B3QUJTP3rpJ!XBLv zX@wsvJA16}2aydxa_2!^1rh~ZFCxBJQDLG$sRTdTi>S)788wq3hI= zH!D91zo=x9?D8-&Q5euty^ud#a*Yf4;KJ>3uJf>>tZc}sv~cjWHuDi#)6}2Bz6=Jp%%+*e18{Yg}kS~l6 zfcI5W88W;}eMJ4+B!8^BCLOn!%iIx7q;!7rxEpGxfkrV-GgYBLo zb#-}6W@ZvvDMVLdOJ1rkCm?`^AwEqp`Y_NDvb{x*?#gN`CB3(I8e3SXP76GeatEdT z|05;q23rG8nxb6Z0HKMmR|na_CuUa$i~_$dmz#DC%DjWM^{X}tpRh_#Xl7@tU-t2Mxkxj z=i#WSN-ju>>Sb=|=to&LEb?w$r8cEMZ|X%>+NPd~{4)4-+H~|pBQdmwh9M)X)*N?? zw#RG*)rBu$%qX6cKatmj%zmQCXfHafFNHNSk*e!mqEec-pW8|jlHgLYE`eUH+iC~iZJFJx?8Xhvz} z)Ga2I_IaU3Z`Z}&)^uNVl-P1G&l`n#;1fvf`|1uizQC!+CsHG#I{XoUVV;|JFyETgD;9E}`0vA5%AqIflyq_Wy#W8>rg!1!Dg7@~zP z1oLAAQos+>#mJ`%ge|SnBfFAYLkLw16{JFKfp{Ab{R@^#1df!P?^PAqI9OT!?mL12 z_*=;I0*&@TOmXz$+&SGzr(RL*J9lfS5h^*uTcd84fGVS?25gNS11OUQnJRf|%bS(j zZG%qMoAjn*xS~zV<%^h93DG{%x9_0l6S=Cz5Gh6X(&=n-Yq1}yV!OpWRb%Fg<`ZgT zbG%O9)8>!)ChEv#ky!U+Z>`<;`H%VcFH@7ZZw4zZcbXiC$~Ry7&bqWcD@SUwkL)JtKcnMo;e*3l1W^yfRKN;m0iTv(P=2I0=4H=lzwkuq_#(!{gAtEgA zh0uF%MO~g~p?T7td=p3E=LvX_+ajsWQycYeJjp1%@I^@P`P_H5(>|?qG%aEy^~7 z1W(Rf%sn^7rRMRU%l3w1g3GoOBmX8scQe<5Q?ZGYMxqSAKE8@(3q@K^z7Am&h#1Vu&=XKW&DZgHyQ03sR^!pBH}wD$A5u0q|LjGI zux{4t1dCbxW=f<1@Ot9Ql$tFmgYuM zEYmHF(O10xLdcD?`8~*X`L=LnX-S5VLuNfRwf5oo4er=}pVEXX#btPU`ZGZpt~~M) z8+#xilXl6TO+6pw(rr0ap$LNB7(E@8Fo>v`c+$smpZ*)SFXMu@EyZHo!!hK{C~N4V zyHKl{b6ufG&_u-I(|zHTEpt;`l_Qe&mQf&C@q5QJTBCVVl!oTyl?txLEaWunwvajFa0EweHl8_D|sqi)%=YAK?)4;8{CJ#4mI+y-%sSH$R+e zUo98ANPawy-qy!06q^*aF@Q$i$>x@^^{SWuwB?wu#iz7OSy;dbJFlz^`FWvG2uExd zsju&^a+LU~(epa(?IDOKSX$I9g8du4j>_pAO57Mqjg;FGH3~cxE{sAz1zK@CC$Jmr z^JftS-a||ObI1Suw3UPNWy6B3qrcC~X!=EKx{N3qZndKBg*OFd>2%&IsiiZKgOE`9 zN_TuDN)^>lXf|P{>(1;UN8!Dcr3|*^q+3;>S233bl$~6=)e9cVuhe_Rx%JV5A9G4e zpP_y)ys2;Rb}lK4Y!r3gvk6Me>+kee{`m7Jp}|Nv>sy&F)$Vh3iM18A$6m%XoSbTg z_-+>mpnztJ8t&`U#IjWEx-o5$#a9t@rtVBl9gh+q;eMy-O1!LwpU9*nrdR+-iM^ zI<;;tb2eoW6DIA3moXqOmhV{a8O)V+pnx~=)w1Ix`+)bo(gySy-OaQ$HBU{rs2qvW zx!f~5#xXyBQ*7$q{K7)i-MN0|2E6esd+XD)=2CKqww7!uLy7}UE8o%jtky1TfXNgBbAgVu6qp)%L z635LA)9;pd!suRH98s5PObybMF=HE4I5_6rmT&HgNKNQur_ZAgMC+!8=?n3SxG|WE zOX$5c7zSAJjLdEK_Y@{)DHxg2As59hfxdg)8XK;Wsi+M&4KX@j2N;WzBU4{9kJcl2 zC6r5>;h30oAz&K#yBAXa1kBe}nG$`w})a$ocEOyrY3Gy>q9rTGG zX0?_DI)CsX{m(b?_VxpL(!&qc&|+V-W=Gt-HoB*TT)3_2TxoK*38(WL)5#8f)~Iw^ zFfX0cvu?c+f2NF*lpHE!rwYejISx9$?oRIAQ8j{b=pN$|8Wqb@V z2KziP5)CyYkW?=i$?N-J+}}mUER@kd@b+D2ZH>{>{dA=7DL0_x2G!A#+qK*A;)jbYT1_u#-f$jSj)DTK79P> zZy1Cf$~RO9q+Ig{!UagDpW6>p<8DV|kccHKu=niazdcLM4(!5LK>*&=%llv%9KY07l&~5t_#mX9Dbc-*rz@9wCQ}Swdp1G zkotd5;Njp)UjY})ml+*S_=I@A9L#3j<4`orhH2^qYT@gv)1QC>xPGz{B-nJSI<6+* zXlw20P?gu8yk6kG{Q*&6N7us0I;_dPFwaDDU`{j1&*)@IOV9PqbQNA*9hwFF(5%#@@5F zDO-1u^s_)-YUrZ&&HxtcV%F>J9Pp+BN>e=Rp21IOeWflq*SB(?l_ zeL7jc9zDU$)z4{T$aagD@`_6~XmzIkl%*$3l=d)W|GZL&q z9{s_u|NY4r0f%71SQ-@AZ2X~NXI=7th-BFYN0?$C`NW6vSZMh3a^`$x4uY*+#b5;+O zRhK-jUk&brIs#6gQna-lpS1*>n{D5en&CXtJG(YJLZa8K^kOtiiv85K%f>Bg%z2hHk$>0j^ii%mh%!xG{iB{)=*5w?L^76W0FvOjLpxvXd{cG z!;QLBhaxlH*bkCf9#%Qq47s8Y_Qf_BaZj^b__}&JV6Q*khdLTQROqe`%Xf(-x&`vyjd4j#2i_W%|y~2-j%Fy<65Tza;18&arkrabf&Vr35T? zK#%BgKfW$FVc13Lu1jF$dgI&ij#SbG}A~7O&MJo`u=+0yIEwJ$7#;ijM74p!W4^j*{!a zGlLh7a+{e?O?%%PR5TCl5Bu&_h>bD4C!;^Nm3DJEPI7Gt_)=2ns5E0HmLa8WF8l@5 z+5|if4}nPki%j&e<^Pu~+V25+R405ElNRgf+it5lh_*(mQtp^>e^+0I#=C9o^%SVM z2u*Z=3H2N^_R9^y;M^~tv&RRVl<=dJP}E8SHf0JXztP1HGarweGv>E@OdorUQ8Sn4 zo$X}Mr7LHf@472qSz2#WSF|X0DRss%OFx!Mgo4Si4c<>;nn0SXz9hp z;{oO|w~;SvURdVBwZN@7xx(mBCSPQU>rX%M?@wEaR)4l!@pk?9htoe2*%)Y@_aFpX zPZAJO&YNU?wm-)lcLjVOr}ETx`Zd!0w)#47Py5!oJ5p6|z;NPc1U~H!kt5dP2A7csw#&f+!sR7# zc9BLp6-~&#Vb*(CI16NfxdSQZ%CPNv?M&*Pd_`Pc-d5<&n%8OQRD?>QMt9ULQ8LA} zzL&L#=N_Qs{Y*0dE0T1*#&FoOM=jSbC8Ym?sF4pfkHKH5^5-V)AR!}>zUf8)79HYscKjhC z)hp!&|G|F${7-|};5AP}Tw@FsI^D9x?=siV^I!b8@PPeAD4NE*f>!btOY0nYZ^ku} z$C*Tbmd0oJe?plMD;3<=L>ZRG>Xe#>28L9@yCQ-7>QKVv|L$zTZ6ftuX#dihg^WWR z88TjcJ)PAR`1Sw2zoPPhA~kIP6@p3zrqD6~AIBjg5)Kc4jK@lkcK@F&PTGtl%8Y4k zVS%j|@TvuT7i3YO!HxhQ*48NC^TKcqa(yPE*K&X7UkKsNO9XOMIYh)s@uIbF`Wd7c z_d)1=()y-+6$4m_!Ou2(4%$8Z81?U0ZzuEtxy^naBjqg)=<^D}_cNJXPjo9b zEsAT(%QFFQ85ng?cYtw*h4&=#zu##9>0`j_PuF0rluh0c9zeZ3$U-(pj+M3!%g($g z{MC~@r3U2TJbVXaVl&)A9A>DU+q71*^{*cho@G(Iz73mL&anS`MF0w?!njLKX7R#>_{I z>uuY3=LUGB@#KL42sv0q@A&y*=*2%VQfLV~&~i}}&16XrJroXME*1`9UCH-x-FCEL zH8+lv{>ukNbTVJ_5)?(tShTsaJ=$PU+s+{_%FIlpFNZ2kGP^5XZKoRwKrLQnj6M0i|}_*6>g(r$j; zUuv{=K>DW%io8Hb`(i`x&vsafBn}5}Ee;ocsCY3oIUO_Vo~|Ui`3lEt8{+|SlFV0L zUM#=zSrf8wPA4|;7MtgA z>?{TRbmqjrPHHx}gx={b0l zb$}84hK%_30LO23?zKLrGd4euF**4_mMz4|JGuWb+^XseIKI=j zgc@-!(5TVQoMZV3^1NOYSfAAFX3@2>vK+?kEk-gTJSQ?zvxA#jFOsY&Nn4YhGZK#e z1?)LO!P-LMEZ_it1g3s+{B9mna&jIs6Q<aBonE}2*|Bm%L$Do6n&|B898!Hc)=F^)_oF~5im6~; zF^pjK`*{2y{=c|%>lQMf^s~%7*8Gu|D{E1xetLRp z_(f+zZSOwszoA+k&u2a6;iBq~ijv2`T)I>a?tMq;0HfTwg3s299A)W~>89(~rpJCE zCmKGvFD&&_L-_eehp$dYHv*p3Y$NadJQw`MsC)CS+N*P|s9nkpkoU}gY}o@U5+yE^ zCMidQKH)!6^&ToL4`kv$Y=ifipo9}_pFjwhUGn!fs@NUPt9Cl;W#I*5K1qj#))%Sh zC@8AQy*<>+(c$)=&!WMYg5r^};dzxm=fenm0JPr>?;$5sqWRb^ zyCqjn`uq9Wvt|s$GHHneLu*DmnWPhvx^c++$m}I$F;tvCE3k3iv%!81h{s|jOKd2`ug>25+kj- zXNmJXQ-&iCl}}{tiUezBEFE__7Quf-ETl)PmbWtsSRM4t_z#icS*YMp*QGJ3ZXX(~ z9-p4(d3&$W@I1N#uH9p9X<(8vCbrz^DGkV2>=WmqMEg-3@63x2T|ux^DHw@oO4|;# z`p+cc%_4%ZaP3DTPIvc7F~%E+z6&V^0{|M!zs1%Pg6RJWu}r_MrkyzO0nzM5m&y0Y z0k8sONUzPs;`i~R!tS(=dL)UL)kiSz%n8E2&cBGo#hMTLBa{Gx#-_j~c-7AKhhd-A zLKx87vLl}~RykkYo=9{Ai09it5AVp*HmlJeA5MVe1sSV$(zJKj2 zE~3VW-lSKV9^#xY3RhXvviT=0Vn`6di$56-!FotN@ZClk47(@D;J#k+jA7}gKPDz9 z2n0*0cH~5}73hhqP==6xeZYTICf<1Vo@k*y<-^ADpzQIhSFch&e)k6jVJ=jO@`WOh z)c=@gtfw0K_;(3Hnuz6cGpRN>b6_r&`^09NG|~tJCCzSVUmOEr9PJ^;qo=II!kbhR zJJ}JR?d>vg+1(t_XYGbE3Y1H{zTqrt<{LL;WA@Fi2`) zIGe`JgYuEoST7*{38lcsaBQ)M^TYUh4!P0^C8O3YyFqs!G%hiL3&!`^+1OO4k(}GH z%JsZh7CkxKPv8+}SWH-r0<)tNMykcgmb;MNVs-AvX#cPwieS7IRr`YH3K)U7u-00? zA~X!FY={D95tH6oB49(>Y>d`}B2IRg#-!Ygg9_WP9OFLX|v@2_k$qY)}%Xv!PnN1q@rb$B5Y}+m-Wr zdgRCy0~nA$0mTHd-URs(TkR${U`G<60LIQ6;8X#f6=G0YB})$>P;YVB{#g+P^(V?OC+E$o|kJDwgSG{JZ;RIkz z{S9D6+HW1*w0tv+a6PiP_)%Sb?(+oI7uJEu|AZMI7c$bh*CX26Da+9J4uEM9S%#3I zj1}*kLe$^iAE#RMNwh0GH5-_HDX6Qvq@0Av1yau052mg5RpUY>7GqgXfCF*68ohYB;-LJYJUp1XmKhr51N|VPZV5DNCGoOTx6^&GJ3#;%$gp4%5Pgo zup5OH8BmWkP2S(jU`TWpS_1x6tPmi#i5=oWM8pZ?EK@e#qR@QZ9PIwsIb5V6GTDT8 z?~Fw}`M9GnyNF4Jp61tO9^GPO5s$}3nJemz;VOZk%wC7FKQjR0~29L9DK@L{iolc1|o+ldIF;h9ZgQBTLoi{uJ5ldmIbQp zR*k|0W#IGh*7E?jrup{D!u)3qlgVctIp9Z_0XAz#fnRmL&EmI|dt9X?O3`eRTGi&b zj|e$J<5iIbB7fmLpA{KWI~~iq78t%)<^^~^ns+b9 z*uFG}Grk z;z%5WS{in;fg~^*_1mNI^txCK0A`NTaxyEWzZU-~Vxk%DfxTFMM-!9R(J3gf(eAiB z95wECi~kzi!GT2gc#Z3hCT|h}`$cH++h6%W34&6UpB?)iK0ZEf9Ly7)*|;Y*6J|=% z5rS_9tm~>=4h_s_YRjz{Rl7loi17fM2>BAgq)An6M!U|=w%!S!IoY64!$Pc`hnH6g zc&Trcw%kG(@&c8nn8B9xbUNiz9?z>PO=a+5h2#A|-9l+NOY8@MCSYuYx21}?9<3~T z*>?N|h)FLo4^B@WiEdL;Z>bEc9-ARpk-=rR zn&ARg)0F$xj2-`Af0Vze(<3pCj(!U=0&2u2Nn@aqr5ymSEQ&Gnya%9 z0Hl;ctD1nv#1MVWd4C9VXG#DbD9 zB!di7sn8araCyEr#YROnN44nk*J^v!M^Wf1nc9G z^?>3gs;zJm_YV1Vz-JYU*Yh51zSzqvvg6D(07|>W-rgRifi3ti1QvATgZAqKic#cZw6aO;=Cda!Cr0>d59`xw+ogm_U4ue) z)$}}}z4%O;Mz^`u=0Bv)*;p2kyVb83zTVgTVwK7qAKC_X-l{yWoh@A-tsM%0+DXYS zJv_>}js0)^MEMl{sO zse_)IE|pX)sckCm*Qrbq#IVv>#%l5_;C&?tjEP>JlgVpQ?I|&4jkG(e_T+uI#|avuEMEva`v_@;=)qS{XmcW~$r} zvx^{n(MY&yGV6>?o)hS7RDc6mP-qEuaoCfK+SN-mD*a)(qMA)io5NyUX4Vu4$rc`_ zMr)`pafu1R*f{22WA_8eu%HORW3#c;$bq3X`fU#lqk*Gw|9JN$56fQ7QZ8X#H>>OO zH&y5J*1a!Z*_+C$bXze5`Hw=Siwgq)F6en(qxzL$soM4U9;5CvTlgFnZfPqyygqb| zqI&Yw#(UU*=Y^`T78XTD+jI^W4i9X;1ivth1VM;}=1m)Ck*XmcNDB|326!ciSojksgDNrsm@cqtRBxr>)%tY&$HK*3>X;lMpzYfB@W))<*9 z^Hc~b)g)rjT~+PbJl0=r@rCR0OYDZ90+|Fad5#3f=*)WKFD;uMscf4l3^4&K@N6RP zE%lB|!^l!`z%9V`NR2pj&gd)L4J?MeIjz1(Hy6u~33$&4AB%LiqQ8|zw_`GOQRR*O zi^di?-8`v1#rC$VytTN{G*KvkNKV%~-m5laQ-2^e-~29LGA60Jb=q~a+J))_$g� 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 literal 0 HcmV?d00001 diff --git a/python/02-use-cases/partners/video_breakdown_agent/config.yaml.example b/python/02-use-cases/partners/video_breakdown_agent/config.yaml.example new file mode 100644 index 00000000..20340325 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/config.yaml.example @@ -0,0 +1,115 @@ +# ============================================================ +# Video Breakdown Agent — config.yaml 配置模板 +# 复制此文件为 config.yaml 并填入真实值: +# cp config.yaml.example config.yaml +# +# 注意:推荐使用 .env 文件或系统环境变量(优先级更高) +# 优先级:系统环境变量 > .env 文件 > config.yaml +# 参考文档:https://volcengine.github.io/veadk-python/configuration/ +# ============================================================ + +# ======================== 模型配置 ======================== +# VeADK 自动映射为标准环境变量: +# model.agent.name → MODEL_AGENT_NAME +# model.agent.provider → MODEL_AGENT_PROVIDER +# model.agent.api_base → MODEL_AGENT_API_BASE +# model.agent.api_key → MODEL_AGENT_API_KEY + +model: + agent: + provider: openai + name: doubao-seed-1-6-251015 # 主 Agent 推理模型 + api_base: https://ark.cn-beijing.volces.com/api/v3/ + api_key: # 填入方舟 API Key + + # ==================== 视觉模型(LiteLLM 多 Provider 支持) ==================== + # 用于分镜画面分析,通过 LiteLLM 统一路由 + # → MODEL_VISION_NAME / MODEL_VISION_API_KEY / MODEL_VISION_API_BASE + # + # LiteLLM 模型名格式速查: + # gemini/gemini-2.5-pro → 直连 Google (需 GEMINI_API_KEY) + # gemini-2.5-pro + api_base → 通过 OneRouter 等 OpenAI 兼容代理 + # doubao-seed-1-6-251015 → 火山方舟 (需 api_base + api_key) + # gpt-4o → OpenAI (需 OPENAI_API_KEY) + vision: + # 方案 A:直连 Google Gemini(推荐,1M+ context,无 tokens 超限问题) + # name: gemini/gemini-2.5-pro + # api_key: # 填入 Google AI Studio API Key + + # 方案 B:通过 OneRouter 代理 + # name: gemini-2.5-pro + # api_base: https://llm.onerouter.pro/v1 + # api_key: # 填入 OneRouter API Key + + # 方案 C:火山方舟豆包(tokens 限制较小,复杂视频可能超限) + name: doubao-seed-1-6-vision + + # ==================== BGM 分析模型 ==================== + # 用于背景音乐分析,需模型支持音频输入 + # → MODEL_BGM_NAME / MODEL_BGM_API_KEY / MODEL_BGM_API_BASE + bgm: + # name: gemini/gemini-2.0-flash # 推荐(原版配置) + # api_key: # 填入 Google AI Studio API Key + name: doubao-seed-1-6-251015 # 回退方案 + + # ==================== 格式化模型 ==================== + # → MODEL_FORMAT_NAME + # 小模型,用于 JSON 格式校验,降低成本 + format: + name: doubao-seed-1-6-250615 + +# ==================== 火山引擎凭证 ==================== +# VeADK 自动映射: +# volcengine.access_key → VOLCENGINE_ACCESS_KEY +# volcengine.secret_key → VOLCENGINE_SECRET_KEY + +volcengine: + access_key: # 火山引擎 Access Key + secret_key: # 火山引擎 Secret Key + +# ==================== TOS 对象存储 ==================== +# 用于上传本地视频文件到 TOS +# 对应标准环境变量:DATABASE_TOS_BUCKET / DATABASE_TOS_REGION +# 注意:config.yaml 中通过 getenv("DATABASE_TOS_BUCKET") 读取 + +database: + tos: + bucket: video-breakdown-uploads # TOS 存储桶名称 + region: cn-beijing # TOS 区域 + +# ==================== FFmpeg 配置 ==================== +# FFmpeg 已通过 imageio-ffmpeg 自动打包在 Python 依赖中,无需手动安装 +# 如有系统 FFmpeg 会优先使用;以下配置可覆盖自动检测 +# 对应环境变量:FFMPEG_BIN / FFMPEG_FFPROBE_BIN / FFMPEG_FRAMES_PER_SEGMENT / FFMPEG_MEDIA_TEMP_DIR + +ffmpeg: + bin: ffmpeg # FFmpeg 可执行文件路径 + ffprobe_bin: ffprobe # FFprobe 可执行文件路径 + frames_per_segment: 3 # 每个分镜采样帧数量 + media_temp_dir: ./.media-cache # 临时文件目录 + +# ==================== 火山 ASR 配置 ==================== +# 语音识别(未配置时跳过,优雅降级) +# 对应环境变量:VOLC_ASR_APP_ID / VOLC_ASR_ACCESS_KEY / VOLC_ASR_RESOURCE_ID + +asr: + app_id: # 火山引擎 ASR APP ID + access_key: # 火山引擎 ASR Access Key + resource_id: volc.bigasr.auc # ASR 资源 ID + +# ==================== Thinking 配置 ==================== +# 控制每个 Agent 的推理模式:disabled / enabled +# 对应环境变量:THINKING_ROOT_AGENT / THINKING_BREAKDOWN_AGENT 等 + +thinking: + root_agent: disabled + breakdown_agent: disabled + hook_analyzer_agent: disabled + hook_format_agent: disabled + report_agent: disabled + +# ==================== 日志级别 ==================== +# DEBUG / INFO / WARNING / ERROR + +logging: + level: INFO diff --git a/python/02-use-cases/partners/video_breakdown_agent/dataset.json b/python/02-use-cases/partners/video_breakdown_agent/dataset.json new file mode 100644 index 00000000..daa2e8f5 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/dataset.json @@ -0,0 +1,14 @@ +[ + { + "question": "帮我拆解这个视频的分镜结构:https://v.douyin.com/example/", + "answer": "好的,我已经为您启动了分镜拆解流程。我将首先使用 FFmpeg 进行关键帧提取,并结合多模态模型分析画面内容。您可以稍等片刻查看完整的分镜脚本。" + }, + { + "question": "分析这个视频前三秒的钩子吸引力,给出专业评分", + "answer": "明白。我将针对视频的前三秒进行深度分析,从视觉冲击力、语言钩子、情绪唤起、信息密度和节奏掌控五个维度进行评分,并为您提供改进建议。" + }, + { + "question": "生成一份完整的视频分析报告", + "answer": "没问题。我将整合已有的分镜拆解数据和钩子分析结果,为您生成一份包含视频元信息、详细分镜表及专业优化建议的 Markdown 报告。" + } +] diff --git a/python/02-use-cases/partners/video_breakdown_agent/deploy.sh b/python/02-use-cases/partners/video_breakdown_agent/deploy.sh new file mode 100755 index 00000000..232b8a4c --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/deploy.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Video Breakdown Agent 部署脚本 + +set -e + +# 确保在项目根目录执行 +if [ ! -f "agent.py" ]; then + echo "请在 video_breakdown_agent 项目根目录执行此脚本" + exit 1 +fi + +AGENT_NAME="video_breakdown_agent" +ENTRY_POINT="agent.py" + +echo "🚀 配置 AgentKit 部署参数..." +agentkit config \ + --agent_name "${AGENT_NAME}" \ + --entry_point "${ENTRY_POINT}" \ + --launch_type cloud + +echo "🔧 配置环境变量..." +agentkit config \ + -e DATABASE_TOS_BUCKET="${DATABASE_TOS_BUCKET:-video-breakdown-uploads}" \ + -e DATABASE_TOS_REGION="${DATABASE_TOS_REGION:-cn-beijing}" \ + -e FFMPEG_BIN="${FFMPEG_BIN:-ffmpeg}" \ + -e FFPROBE_BIN="${FFPROBE_BIN:-ffprobe}" + +echo "✅ 配置完成。现在可以执行 'agentkit launch' 进行部署。" +echo "" +echo "注意事项:" +echo " 1. 确保已配置 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY" +echo " 2. 确保 TOS 存储桶已创建" +echo " 3. FFmpeg 已通过 imageio-ffmpeg 打包在 Python 依赖中,无需单独安装" +echo " 4. 如需 ASR 功能,请配置 ASR_APP_ID 和 ASR_ACCESS_KEY" diff --git a/python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md b/python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md new file mode 100644 index 00000000..33af4c70 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md @@ -0,0 +1,132 @@ +# 部署指南 + +## 🚀 云端重新部署步骤 + +由于更新了 `requirements.txt`(添加了 `imageio-ffmpeg` 依赖),需要重新构建并部署应用。 + +### 1️⃣ 确认环境变量已配置 + +在 AgentKit 控制台确认以下环境变量已正确填写: + +**必需变量**: +```bash +MODEL_AGENT_API_KEY=<您的豆包 API Key> +VOLCENGINE_ACCESS_KEY=<您的火山引擎 AK> +VOLCENGINE_SECRET_KEY=<您的火山引擎 SK> +DATABASE_TOS_BUCKET=video-breakdown-uploads +DATABASE_TOS_REGION=cn-beijing +``` + +### 2️⃣ 重新部署 + +在项目根目录执行: + +```bash +# 确保在正确的目录 +cd /Users/edy/Downloads/agentkit-samples-main/02-use-cases/video_breakdown_agent + +# 激活虚拟环境(如果使用 conda) +source ~/.venv/bin/activate # 或 conda activate video-breakdown-agent + +# 重新部署到云端 +agentkit launch +``` + +### 3️⃣ 等待构建完成 + +部署过程包括: +1. 📦 打包项目代码 +2. 🔧 上传到 TOS +3. 🏗️ 云端构建 Docker 镜像(安装 imageio-ffmpeg) +4. 🚢 部署到 Runtime +5. ✅ 服务就绪 + +预计耗时:3-5 分钟 + +### 4️⃣ 验证部署 + +部署成功后,在控制台测试: + +1. **基础对话测试**: + ``` + 你好,介绍一下你的功能 + ``` + +2. **视频分析测试**: + ``` + 帮我分析这个视频的分镜 + ``` + 然后上传一个短视频文件(< 50MB) + +如果配置正确,Agent 应该能够: +- ✅ 接收并处理视频文件 +- ✅ 使用 FFmpeg 进行分镜拆解 +- ✅ 返回完整的分析结果 + +--- + +## 🐛 常见问题 + +### Q1: 部署时提示 CR 配额超限? + +**解决方案**:使用已有的 CR 实例 + +```bash +agentkit config --cr_instance_name nodesk-center +agentkit launch +``` + +### Q2: 运行时报错"FFmpeg 缺失"? + +**原因**:旧版本的镜像没有包含 `imageio-ffmpeg` + +**解决方案**:重新部署(按照上述步骤) + +### Q3: 视频分析失败,提示 TOS 权限错误? + +**原因**:环境变量 `VOLCENGINE_ACCESS_KEY` 或 `VOLCENGINE_SECRET_KEY` 未配置或错误 + +**解决方案**: +1. 在控制台检查环境变量配置 +2. 重启 Runtime 使配置生效 + +### Q4: ASR 语音识别不工作? + +**说明**:ASR 是可选功能,未配置时会跳过语音识别,不影响视频分析 + +**解决方案**(如需启用): +```bash +# 在控制台添加环境变量 +ASR_APP_ID=<您的 ASR App ID> +ASR_ACCESS_KEY=<您的 ASR Access Key> +``` + +--- + +## 📊 部署后检查清单 + +- [ ] Runtime 状态为 "运行中" +- [ ] 环境变量已配置(至少 3 个必需变量) +- [ ] 能够进行基础对话 +- [ ] 能够上传视频文件 +- [ ] 视频分析功能正常 +- [ ] TOS 存储桶可访问 + +--- + +## 🔗 相关链接 + +- AgentKit 控制台:https://console.volcengine.com/agentkit +- TOS 存储桶管理:https://console.volcengine.com/tos/bucket +- 火山方舟 API Key:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey +- IAM 密钥管理:https://console.volcengine.com/iam/keymanage/ + +--- + +## 💬 获取帮助 + +如遇到问题,请提供: +1. Runtime ID 或名称 +2. 错误信息截图 +3. 环境变量配置(脱敏后) +4. 部署日志 diff --git a/python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md b/python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md new file mode 100644 index 00000000..aa8ae078 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md @@ -0,0 +1,251 @@ +# Video Breakdown Agent 配置指南 + +本文档说明 Video Breakdown Agent 所需的全部配置项、优先级规则、可观测性集成方式及常见问题排查。 + +## 快速开始 + +```bash +# 1. 复制环境变量模板 +cp .env.example .env + +# 2. 编辑 .env,至少填写以下必需项 +# MODEL_AGENT_API_KEY — 方舟模型 API Key +# VOLCENGINE_ACCESS_KEY — 火山引擎 AK(上传视频需要) +# VOLCENGINE_SECRET_KEY — 火山引擎 SK + +# 3. 启动 +uv run veadk web +``` + +## 配置优先级 + +VeADK 按以下优先级(从高到低)读取配置: + +| 优先级 | 来源 | 说明 | +|--------|------|------| +| 1 | 系统环境变量 | 通过 `export` 或 CI/CD 注入 | +| 2 | `.env` 文件 | 项目根目录下,推荐用于本地开发 | +| 3 | `config.yaml` | 项目根目录下,作为备选 | + +> 参考:[VeADK 配置项文档](https://volcengine.github.io/veadk-python/configuration/) + +## 工具返回数据优化策略 + +为避免工具返回数据过大导致 LLM context 超限,本项目对视觉分析工具返回数据做了精简处理: + +### 优化细节 + +- **完整数据存储位置**:`tool_context.state["vision_analysis_result"]`(包含完整的 base64 frame_urls) +- **工具返回数据**:移除 base64 图片 URL,替换为占位符 `"(base64图片已省略)"` +- **数据量减少**:从 ~2200KB 精简到 ~3KB(减少 99.8%) +- **后续工具使用**:钩子分析、报告生成等工具从 `tool_context.state` 读取完整数据,不受影响 + +### 为什么需要精简? + +- **Token 限制**:豆包模型的 context window 相对较小,大量 base64 图片数据会导致 `Total tokens exceed max message tokens` 错误 +- **性能优化**:减少 LLM 处理的数据量,提升响应速度 +- **成本控制**:减少 token 消耗,降低 API 调用成本 + +### 如何获取完整数据? + +如果你的自定义工具需要访问完整的帧图 URL(含 base64),可以这样读取: + +```python +from google.adk.tools import ToolContext + +async def my_custom_tool(tool_context: ToolContext) -> str: + # 从 session state 读取完整的视觉分析结果 + vision_result = tool_context.state.get("vision_analysis_result", []) + + # vision_result 是一个列表,每个元素包含完整的 frame_urls(含 base64) + for segment in vision_result: + frame_urls = segment.get("frame_urls", []) # 完整的 base64 URLs + # ... 使用 frame_urls 进行后续处理 +``` + +> 参考实现:[`tools/analyze_hook_segments.py`](video_breakdown_agent/tools/analyze_hook_segments.py) 中从 `tool_context.state` 读取完整数据的示例。 + +## 环境变量一览 + +### 必需配置 + +| 环境变量 | 说明 | 示例值 | +|---------|------|--------| +| `MODEL_AGENT_API_KEY` | 方舟模型 API Key | `your_api_key` | + +### 模型配置(MODEL_*) + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `MODEL_AGENT_NAME` | 主 Agent 推理模型名称 | `doubao-seed-1-6-251015` | +| `MODEL_AGENT_PROVIDER` | 模型提供商(火山方舟使用 `openai`) | `openai` | +| `MODEL_AGENT_API_BASE` | 模型 API 地址 | `https://ark.cn-beijing.volces.com/api/v3/` | +| `MODEL_AGENT_API_KEY` | 模型 API 密钥 | — | +| `MODEL_VISION_NAME` | 视觉分析模型(豆包 Responses API) | `doubao-seed-1-6-vision-250815` | +| `MODEL_VISION_API_KEY` | 视觉模型 API Key | 回退到 `MODEL_AGENT_API_KEY` | +| `MODEL_VISION_API_BASE` | 视觉模型 API 地址 | 回退到 `MODEL_AGENT_API_BASE` | +| `MODEL_BGM_NAME` | BGM 分析模型(豆包文本 API) | `doubao-seed-1-6-251015` | +| `MODEL_BGM_API_KEY` | BGM 模型 API Key | 回退到 `MODEL_AGENT_API_KEY` | +| `MODEL_BGM_API_BASE` | BGM 模型 API 地址 | 回退到 `MODEL_AGENT_API_BASE` | +| `MODEL_FORMAT_NAME` | 格式化模型(JSON 校验) | `doubao-seed-1-6-251015` | +| `VISION_CONCURRENCY` | 视觉分析并发数 | `3` | + +### 豆包 API 调用架构 + +本项目使用豆包官方 API 格式,不依赖 LiteLLM 进行工具层的 LLM 调用: + +| 组件 | API 类型 | 模型 | Endpoint | +|------|---------|------|----------| +| 主 Agent / 子 Agent | 文本模型(VeADK 框架管理) | `doubao-seed-1-6-251015` | `/chat/completions` | +| `analyze_segments_vision` 工具 | 视觉模型(自定义 httpx 调用) | `doubao-seed-1-6-vision-250815` | `/responses` | +| `analyze_bgm` 工具 | 文本模型(自定义 httpx 调用) | `doubao-seed-1-6-251015` | `/chat/completions` | +| `hook_analysis_agent` | 视觉模型(VeADK 框架管理) | `doubao-seed-1-6-vision-250815` | VeADK 内部路由 | + +> **重要**:豆包视觉模型使用 `/responses` endpoint(非标准 OpenAI 格式),封装在 `video_breakdown_agent/utils/doubao_client.py` 中。 + +### 火山引擎凭证(VOLCENGINE_*) + +| 环境变量 | 说明 | 备注 | +|---------|------|------| +| `VOLCENGINE_ACCESS_KEY` | 火山引擎 Access Key | 上传视频到 TOS 时需要 | +| `VOLCENGINE_SECRET_KEY` | 火山引擎 Secret Key | 上传视频到 TOS 时需要 | + +### TOS 对象存储(DATABASE_TOS_*) + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `DATABASE_TOS_BUCKET` | TOS 存储桶名称 | `video-breakdown-uploads` | +| `DATABASE_TOS_REGION` | TOS 区域 | `cn-beijing` | +| `DATABASE_TOS_ENDPOINT` | TOS 端点(可选) | 自动生成 | + +> 注:为向后兼容,代码同时支持旧变量名 `TOS_BUCKET` / `TOS_REGION`(Deprecated,将在后续版本移除)。 + +### Thinking 配置 + +| 环境变量 | 说明 | 默认值 | +|---------|------|--------| +| `THINKING_ROOT_AGENT` | 主 Agent 推理模式 | `disabled` | +| `THINKING_BREAKDOWN_AGENT` | 分镜拆解 Agent | `disabled` | +| `THINKING_HOOK_ANALYZER_AGENT` | 钩子分析 Agent | `disabled` | +| `THINKING_HOOK_FORMAT_AGENT` | 格式化 Agent | `disabled` | +| `THINKING_REPORT_AGENT` | 报告生成 Agent | `disabled` | + +### Skills 配置 + +| 环境变量 | 说明 | 备注 | +|---------|------|------| +| `SKILL_SPACE_ID` | AgentKit Skill Space ID | 配置后从平台加载 Skills | + +未配置 `SKILL_SPACE_ID` 时,自动检测本地 `skills/` 目录。 + +## 可观测性配置 + +VeADK 支持三种 OpenTelemetry 后端,配置对应环境变量后自动启用: + +### APMPlus(火山引擎 APM,推荐) + +```bash +OBSERVABILITY_OPENTELEMETRY_APMPLUS_ENDPOINT=http://apmplus-cn-beijing.volces.com:4317 +OBSERVABILITY_OPENTELEMETRY_APMPLUS_API_KEY= +OBSERVABILITY_OPENTELEMETRY_APMPLUS_SERVICE_NAME=video_breakdown_agent +``` + +### CozeLoop(Coze 平台观测) + +```bash +OBSERVABILITY_OPENTELEMETRY_COZELOOP_ENDPOINT=https://api.coze.cn/v1/loop/opentelemetry/v1/traces +OBSERVABILITY_OPENTELEMETRY_COZELOOP_API_KEY= +OBSERVABILITY_OPENTELEMETRY_COZELOOP_SERVICE_NAME= +``` + +### TLS(火山引擎日志服务) + +```bash +OBSERVABILITY_OPENTELEMETRY_TLS_ENDPOINT=https://tls-cn-beijing.volces.com:4318/v1/traces +OBSERVABILITY_OPENTELEMETRY_TLS_SERVICE_NAME= +OBSERVABILITY_OPENTELEMETRY_TLS_REGION=cn-beijing +``` + +### 启用开关 + +配置好上述环境变量后,还需设置对应的开关变量为 `true`: + +```bash +VEADK_TRACER_APMPLUS=true # 启用 APMPlus Exporter +VEADK_TRACER_COZELOOP=true # 启用 CozeLoop Exporter +VEADK_TRACER_TLS=true # 启用 TLS Exporter +``` + +> 三种后端可任选其一或组合使用。详见 [VeADK 可观测文档](https://volcengine.github.io/veadk-python/observability/)。 + +## config.yaml 与 .env 的对应关系 + +``` +config.yaml 字段 → 环境变量 +────────────────────────────────────────────────── +model.agent.name → MODEL_AGENT_NAME +model.agent.provider → MODEL_AGENT_PROVIDER +model.agent.api_base → MODEL_AGENT_API_BASE +model.agent.api_key → MODEL_AGENT_API_KEY +model.vision.name → MODEL_VISION_NAME +model.vision.api_key → MODEL_VISION_API_KEY +model.vision.api_base → MODEL_VISION_API_BASE +model.bgm.name → MODEL_BGM_NAME +model.bgm.api_key → MODEL_BGM_API_KEY +model.bgm.api_base → MODEL_BGM_API_BASE +model.format.name → MODEL_FORMAT_NAME +volcengine.access_key → VOLCENGINE_ACCESS_KEY +volcengine.secret_key → VOLCENGINE_SECRET_KEY +database.tos.bucket → DATABASE_TOS_BUCKET +database.tos.region → DATABASE_TOS_REGION +asr.app_id → VOLC_ASR_APP_ID +asr.access_key → VOLC_ASR_ACCESS_KEY +asr.resource_id → VOLC_ASR_RESOURCE_ID +ffmpeg.bin_path → FFMPEG_BIN +ffmpeg.probe_bin_path → FFPROBE_BIN +ffmpeg.media_temp_dir → MEDIA_TEMP_DIR +thinking.* → THINKING_* +``` + +> **模型说明**:视觉分析使用豆包 Responses API(`/responses` endpoint),文本分析使用标准 Chat Completions API。封装代码位于 `video_breakdown_agent/utils/doubao_client.py`。 + +## 常见问题 + +### 模型未激活 + +``` +The model or endpoint xxx does not exist or you do not have access to it. +``` + +请前往 [火山引擎方舟控制台](https://console.volcengine.com/ark) 激活对应模型。当前使用: +- 文本模型:`doubao-seed-1-6-251015` +- 视觉模型:`doubao-seed-1-6-vision-250815`(需单独激活) + +### TOS 连接失败 + +``` +TOS 存储桶 xxx 不存在,请先创建 +``` + +1. 确认 `DATABASE_TOS_BUCKET` 对应的存储桶已在火山引擎 TOS 控制台创建 +2. 确认 `VOLCENGINE_ACCESS_KEY` 和 `VOLCENGINE_SECRET_KEY` 正确 +3. 确认 `DATABASE_TOS_REGION` 与存储桶所在区域一致 + +### FFmpeg/FFprobe 未找到 + +``` +FileNotFoundError: ffmpeg not found +``` + +请确认已安装 FFmpeg 和 FFprobe,且可执行文件在系统 PATH 中,或通过 `FFMPEG_BIN` / `FFPROBE_BIN` 环境变量指定路径。 + +### VeADK 未读取到 config.yaml + +确保从项目根目录启动: + +```bash +cd 02-use-cases/video_breakdown_agent +uv run veadk web +``` + +VeADK 从当前工作目录读取 `config.yaml`。 diff --git a/python/02-use-cases/partners/video_breakdown_agent/project.yaml b/python/02-use-cases/partners/video_breakdown_agent/project.yaml new file mode 100644 index 00000000..574512b5 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/project.yaml @@ -0,0 +1,67 @@ +name: "智能短视频拆解分析助手" +name_en: "Video Breakdown Analyzer" +description: "专业的短视频分镜拆解和深度分析工具,自动生成分镜脚本、前三秒钩子评分和完整分析报告" +details: | + 这是一个基于火山引擎 AgentKit 构建的智能短视频分析系统。系统采用 Multi-Agent 架构, + 集成 FFmpeg 视频处理、火山 ASR 语音识别、LiteLLM 多模态视觉分析和 TOS 对象存储, + 能够对短视频进行全方位的专业分析。 + + 核心能力: + - 视频分镜拆解:自动识别视频分镜,提取关键帧并分析画面内容 + - 前三秒钩子分析:从视觉冲击力、语言钩子、情绪唤起等5个维度评分 + - 专业报告生成:整合所有分析数据生成 Markdown 格式专业报告 + - 联网搜索:实时获取短视频行业最新资讯和平台规则 +tags: + industry: "通用" + from: "杭州没有桌子人工智能有限公司" + framework: "veadk" + language: "python" + tech: + - "Multi-Agent" + - "FFmpeg" + - "ASR" + - "视觉分析" + - "TOS存储" +type: "application" + +scenarios: + - name: "短视频创作优化" + desc: "为内容创作者提供分镜拆解和钩子评分,优化视频吸引力和用户留存率。" + - name: "视频内容审核分析" + desc: "自动拆解视频内容结构,辅助审核团队快速理解视频脉络和关键信息点。" + - name: "竞品视频研究" + desc: "批量分析竞品视频的分镜手法、钩子策略和叙事节奏,提取创作灵感。" + - name: "广告创意评估" + desc: "针对短视频广告素材,评估前三秒钩子效果,预测投放转化潜力。" + +prompts: + - text: "帮我拆解这个视频的分镜结构" + resource_url: "" + - text: "分析这个视频前三秒的钩子吸引力,给出专业评分" + resource_url: "" + - text: "生成完整的视频分析报告" + resource_url: "" + +components: + - type: "tool" + product: "Skills_Sandbox" + - type: "infra" + product: "TOS" + +envs: + - name: "火山方舟模型 API Key" + key: "MODEL_AGENT_API_KEY" + url: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey" + - name: "火山引擎访问密钥" + key: "VOLCENGINE_ACCESS_KEY" + url: "https://console.volcengine.com/iam/keymanage/" + - name: "火山引擎密钥" + key: "VOLCENGINE_SECRET_KEY" + url: "https://console.volcengine.com/iam/keymanage/" + - name: "TOS 存储桶名称" + key: "DATABASE_TOS_BUCKET" + url: "https://console.volcengine.com/tos/bucket" + +models: + - "doubao-seed-1-6-251015" + - "doubao-seed-1-6-vision-250815" diff --git a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml b/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml new file mode 100644 index 00000000..05adcd39 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "video-breakdown-agent" +version = "0.3.0" +description = "视频分镜拆解和前三秒钩子分析 AgentKit Agent(Multi-Agent 架构)" +requires-python = ">=3.12,<3.13" +dependencies = [ + # AgentKit 核心三件套 + "veadk-python==0.5.1", + "google-adk==1.19.0", # veadk-python 0.5.1 锁定此版本 + "agentkit-sdk-python==0.2.2", + # HTTP 客户端 + "httpx>=0.26.0", + # TOS 对象存储(视频上传) + "tos>=2.8.7", + # JSON 修复(format hook) + "json-repair>=0.54.1", + # 配置管理 + "python-dotenv>=1.0.0", + # 生产模式服务器 + "uvicorn>=0.34.0", + # Web 框架(A2A + MCP) + "fastapi>=0.115.0", + # MCP Server + "fastmcp>=2.0.0", + # 异步 HTTP + "aiohttp>=3.11.0", + # FFmpeg 二进制(随 Python 包分发,无需系统安装) + "imageio-ffmpeg>=0.5.1", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["video_breakdown_agent"] diff --git a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt b/python/02-use-cases/partners/video_breakdown_agent/requirements.txt new file mode 100644 index 00000000..1de87c14 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/requirements.txt @@ -0,0 +1,34 @@ +# pip 兼容依赖列表(推荐使用 pyproject.toml + uv) +# pip install -r requirements.txt + +# AgentKit 核心 +veadk-python==0.5.1 +google-adk==1.19.0 +agentkit-sdk-python==0.2.2 + +# HTTP 客户端 +httpx>=0.26.0 + +# TOS 对象存储(视频上传) +tos>=2.8.7 + +# JSON 修复(format hook) +json-repair>=0.54.1 + +# 配置管理 +python-dotenv>=1.0.0 + +# FFmpeg 二进制(随 Python 包分发,无需系统安装 FFmpeg) +imageio-ffmpeg>=0.5.1 + +# 生产模式服务器 +uvicorn>=0.34.0 + +# Web 框架(A2A + MCP) +fastapi>=0.115.0 + +# MCP Server +fastmcp>=2.0.0 + +# 异步 HTTP +aiohttp>=3.11.0 diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml b/python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml new file mode 100644 index 00000000..d1884487 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml @@ -0,0 +1,100 @@ +version: '3.8' + +services: + # 沙箱 MySQL + mysql_sandbox: + image: mysql:8.0 + container_name: breakdown_mysql_sandbox + environment: + MYSQL_ROOT_PASSWORD: sandbox_root_pass + MYSQL_DATABASE: video_breakdown_sandbox + MYSQL_USER: sandbox_user + MYSQL_PASSWORD: sandbox_pass + ports: + - "3307:3306" # 避免与生产 MySQL 冲突 + volumes: + - mysql_sandbox_data:/var/lib/mysql + networks: + - breakdown_sandbox + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + interval: 10s + timeout: 5s + retries: 5 + + # 沙箱 Redis + redis_sandbox: + image: redis:7-alpine + container_name: breakdown_redis_sandbox + ports: + - "6380:6379" # 避免与生产 Redis 冲突 + networks: + - breakdown_sandbox + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + # 沙箱 video-breakdown API + breakdown_api_sandbox: + build: + context: ../../../video-breakdown-master-449a45e1b0108b8d8e33c3d7a69eb7ab00c3dd4f + dockerfile: Dockerfile + container_name: breakdown_api_sandbox + ports: + - "7115:7114" # 沙箱 API 端口 + environment: + - DATABASE_URL=mysql+aiomysql://sandbox_user:sandbox_pass@mysql_sandbox:3306/video_breakdown_sandbox + - REDIS_HOST=redis_sandbox + - REDIS_PORT=6379 + - PORT=7114 + - WORKER_COUNT=4 + - TOS_OUTPUT_BUCKET=${SANDBOX_TOS_BUCKET} + - VOLC_ASR_APPID=${VOLC_ASR_APPID} + - VOLC_ASR_TOKEN=${VOLC_ASR_TOKEN} + - ONEROUTER_API_KEY=${ONEROUTER_API_KEY} + depends_on: + mysql_sandbox: + condition: service_healthy + redis_sandbox: + condition: service_healthy + networks: + - breakdown_sandbox + volumes: + - breakdown_sandbox_data:/app/data + + # 沙箱 Celery Worker + breakdown_worker_sandbox: + build: + context: ../../../video-breakdown-master-449a45e1b0108b8d8e33c3d7a69eb7ab00c3dd4f + dockerfile: Dockerfile + container_name: breakdown_worker_sandbox + command: celery -A app.celery_app worker --loglevel=info --concurrency=4 + environment: + - DATABASE_URL=mysql+aiomysql://sandbox_user:sandbox_pass@mysql_sandbox:3306/video_breakdown_sandbox + - REDIS_HOST=redis_sandbox + - REDIS_PORT=6379 + - TOS_OUTPUT_BUCKET=${SANDBOX_TOS_BUCKET} + - VOLC_ASR_APPID=${VOLC_ASR_APPID} + - VOLC_ASR_TOKEN=${VOLC_ASR_TOKEN} + - ONEROUTER_API_KEY=${ONEROUTER_API_KEY} + depends_on: + mysql_sandbox: + condition: service_healthy + redis_sandbox: + condition: service_healthy + breakdown_api_sandbox: + condition: service_started + networks: + - breakdown_sandbox + volumes: + - breakdown_sandbox_data:/app/data + +networks: + breakdown_sandbox: + driver: bridge + +volumes: + mysql_sandbox_data: + breakdown_sandbox_data: diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example new file mode 100644 index 00000000..3159eed1 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example @@ -0,0 +1,14 @@ +# ======================================== +# 沙箱环境变量配置模板 +# 复制此文件为 .env 并填入真实值 +# ======================================== + +# 沙箱专用 TOS 桶(避免污染生产数据) +SANDBOX_TOS_BUCKET=breakdown-sandbox-bucket + +# 火山 ASR 配置(可使用测试账号) +VOLC_ASR_APPID=your_test_appid +VOLC_ASR_TOKEN=your_test_token + +# OneRouter API Key(可使用测试 key) +ONEROUTER_API_KEY=your_test_api_key diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py new file mode 100644 index 00000000..481f6c4e --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py @@ -0,0 +1,235 @@ +""" +沙箱环境管理器 +提供沙箱的启动、停止、重置、健康检查等功能 +""" +import os +import subprocess +import sys +import time +from pathlib import Path + +import httpx + + +class SandboxManager: + """沙箱环境管理器""" + + def __init__(self, sandbox_dir: str = None): + self.sandbox_dir = Path(sandbox_dir) if sandbox_dir else Path(__file__).parent + self.compose_file = self.sandbox_dir / "docker-compose.yml" + self.env_file = self.sandbox_dir / ".env" + self.api_url = "http://localhost:7115" + + def start(self, rebuild: bool = False): + """启动沙箱环境""" + print("启动沙箱环境...") + + # 检查 .env 文件 + if not self.env_file.exists(): + example_env = self.sandbox_dir / "sandbox.env.example" + if example_env.exists(): + print( + f"警告: 未找到 .env 文件,请复制 sandbox.env.example 为 .env 并填入配置" + ) + print(f" cp {example_env} {self.env_file}") + return + + cmd = [ + "docker-compose", + "-f", + str(self.compose_file), + "--env-file", + str(self.env_file), + "up", + "-d", + ] + if rebuild: + cmd.insert(-1, "--build") + + result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.sandbox_dir)) + if result.returncode != 0: + print(f"沙箱启动失败: {result.stderr}") + raise Exception(f"沙箱启动失败: {result.stderr}") + + print("等待服务就绪...") + self.wait_for_health() + print("沙箱环境已启动") + print(f" API 地址: {self.api_url}") + print(f" MySQL: localhost:3307") + print(f" Redis: localhost:6380") + + def stop(self): + """停止沙箱环境(保留数据)""" + print("停止沙箱环境...") + cmd = [ + "docker-compose", + "-f", + str(self.compose_file), + "down", + ] + subprocess.run(cmd, cwd=str(self.sandbox_dir)) + print("沙箱环境已停止") + + def reset(self): + """重置沙箱环境(清除所有数据和卷)""" + print("重置沙箱环境...") + cmd = [ + "docker-compose", + "-f", + str(self.compose_file), + "down", + "-v", + ] + subprocess.run(cmd, cwd=str(self.sandbox_dir)) + print("沙箱环境已重置,所有数据已清除") + + def status(self): + """查看沙箱环境状态""" + cmd = [ + "docker-compose", + "-f", + str(self.compose_file), + "ps", + ] + subprocess.run(cmd, cwd=str(self.sandbox_dir)) + + def logs(self, service: str = None, follow: bool = False): + """查看沙箱日志""" + cmd = [ + "docker-compose", + "-f", + str(self.compose_file), + "logs", + ] + if follow: + cmd.append("-f") + if service: + cmd.append(service) + + subprocess.run(cmd, cwd=str(self.sandbox_dir)) + + def wait_for_health(self, timeout: int = 120): + """等待沙箱服务健康""" + start_time = time.time() + + while time.time() - start_time < timeout: + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{self.api_url}/health") + if response.status_code == 200: + return True + except Exception: + pass + time.sleep(3) + + raise TimeoutError( + f"沙箱服务未能在 {timeout} 秒内启动,请检查 Docker 日志: " + f"docker-compose -f {self.compose_file} logs" + ) + + def get_api_url(self) -> str: + """获取沙箱 API 地址""" + return self.api_url + + def run_test(self, video_url: str): + """在沙箱中运行简单测试""" + print(f"在沙箱中测试视频: {video_url}") + + try: + with httpx.Client(timeout=30.0) as client: + # 提交任务 + response = client.post( + f"{self.api_url}/api/v1/breakdown/submit", + json={"video_source": video_url}, + ) + result = response.json() + + if result["code"] != 0: + print(f"提交失败: {result['message']}") + return + + task_id = result["data"]["task_id"] + print(f"任务已提交,task_id: {task_id}") + + # 轮询等待 + while True: + response = client.get( + f"{self.api_url}/api/v1/breakdown/status/{task_id}" + ) + status_data = response.json()["data"] + + current_status = status_data["status"] + progress = status_data.get("progress", 0) + step = status_data.get("current_step", "unknown") + + if current_status == "completed": + print("任务完成!") + # 获取结果 + response = client.get( + f"{self.api_url}/api/v1/breakdown/result/{task_id}" + ) + result_data = response.json()["data"] + print( + f" 分镜数: {result_data.get('segment_count', 0)}" + ) + print( + f" 时长: {result_data.get('duration', 0):.1f}s" + ) + return task_id + elif current_status == "failed": + print( + f"任务失败: {status_data.get('error_message', '未知错误')}" + ) + return None + + print(f" 处理中... {progress}% ({step})") + time.sleep(5) + + except httpx.ConnectError: + print(f"无法连接到沙箱 API ({self.api_url}),请确认沙箱已启动") + except Exception as e: + print(f"测试异常: {e}") + + +def main(): + """CLI 入口""" + manager = SandboxManager() + + if len(sys.argv) < 2: + print("沙箱环境管理器") + print() + print("用法:") + print(" python sandbox_manager.py start [--rebuild] 启动沙箱") + print(" python sandbox_manager.py stop 停止沙箱") + print(" python sandbox_manager.py reset 重置沙箱") + print(" python sandbox_manager.py status 查看状态") + print(" python sandbox_manager.py logs [service] 查看日志") + print(" python sandbox_manager.py test 运行测试") + sys.exit(0) + + command = sys.argv[1] + + if command == "start": + manager.start(rebuild="--rebuild" in sys.argv) + elif command == "stop": + manager.stop() + elif command == "reset": + manager.reset() + elif command == "status": + manager.status() + elif command == "logs": + service = sys.argv[2] if len(sys.argv) > 2 else None + manager.logs(service=service, follow="-f" in sys.argv) + elif command == "test": + if len(sys.argv) < 3: + print("用法: python sandbox_manager.py test ") + sys.exit(1) + manager.start() + manager.run_test(sys.argv[2]) + else: + print(f"未知命令: {command}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/python/02-use-cases/partners/video_breakdown_agent/setup_env.sh b/python/02-use-cases/partners/video_breakdown_agent/setup_env.sh new file mode 100755 index 00000000..4b846c70 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/setup_env.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Video Breakdown Agent 环境配置辅助脚本 + +set -e + +PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_DIR" + +echo "🔧 Video Breakdown Agent 环境配置向导" +echo "========================================" +echo "" + +# 检查 .env 文件是否已存在 +if [ -f ".env" ]; then + echo "⚠️ 检测到现有 .env 文件" + read -p "是否要覆盖? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ 已取消,保留现有配置" + exit 0 + fi +fi + +# 复制模板 +if [ ! -f ".env.example" ]; then + echo "❌ 错误:找不到 .env.example 模板文件" + exit 1 +fi + +cp .env.example .env +echo "✅ 已创建 .env 文件(从 .env.example 复制)" +echo "" + +# 引导用户填写必需变量 +echo "📝 请填写以下必需的环境变量:" +echo "----------------------------------------" +echo "" + +# MODEL_AGENT_API_KEY +echo "1️⃣ 火山方舟模型 API Key" +echo " 获取地址:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey" +read -p " 输入 MODEL_AGENT_API_KEY: " MODEL_AGENT_API_KEY +if [ -n "$MODEL_AGENT_API_KEY" ]; then + sed -i '' "s|^MODEL_AGENT_API_KEY=.*|MODEL_AGENT_API_KEY=$MODEL_AGENT_API_KEY|" .env + echo " ✅ 已保存" +fi +echo "" + +# VOLCENGINE_ACCESS_KEY +echo "2️⃣ 火山引擎访问密钥" +echo " 获取地址:https://console.volcengine.com/iam/keymanage/" +read -p " 输入 VOLCENGINE_ACCESS_KEY: " VOLCENGINE_ACCESS_KEY +if [ -n "$VOLCENGINE_ACCESS_KEY" ]; then + sed -i '' "s|^VOLCENGINE_ACCESS_KEY=.*|VOLCENGINE_ACCESS_KEY=$VOLCENGINE_ACCESS_KEY|" .env + echo " ✅ 已保存" +fi +echo "" + +# VOLCENGINE_SECRET_KEY +read -p " 输入 VOLCENGINE_SECRET_KEY: " VOLCENGINE_SECRET_KEY +if [ -n "$VOLCENGINE_SECRET_KEY" ]; then + sed -i '' "s|^VOLCENGINE_SECRET_KEY=.*|VOLCENGINE_SECRET_KEY=$VOLCENGINE_SECRET_KEY|" .env + echo " ✅ 已保存" +fi +echo "" + +# TOS Bucket +echo "3️⃣ TOS 存储桶配置" +echo " 创建地址:https://console.volcengine.com/tos/bucket" +read -p " 输入 DATABASE_TOS_BUCKET (默认: video-breakdown-uploads): " DATABASE_TOS_BUCKET +if [ -n "$DATABASE_TOS_BUCKET" ]; then + sed -i '' "s|^DATABASE_TOS_BUCKET=.*|DATABASE_TOS_BUCKET=$DATABASE_TOS_BUCKET|" .env + echo " ✅ 已保存" +fi +echo "" + +echo "========================================" +echo "✅ 环境配置完成!" +echo "" +echo "📋 下一步操作:" +echo " 1. 本地测试:uv run veadk web" +echo " 2. 云端部署:agentkit launch" +echo "" +echo "💡 提示:" +echo " - 完整配置请编辑 .env 文件" +echo " - 云端部署需在 AgentKit 控制台配置相同的环境变量" +echo " - 参考文档:README.md" +echo "" diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py new file mode 100644 index 00000000..e2215921 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py @@ -0,0 +1,5 @@ +from . import agent + +__all__ = [ + "agent", +] diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py new file mode 100644 index 00000000..9d2cdb76 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py @@ -0,0 +1,214 @@ +""" +主编排 Root Agent 定义(veadk web 的唯一真相来源) + +VeADK 约束:有 sub_agents 的 Agent 不支持直接挂载 tools, +因此 web_search 等工具封装为独立子 Agent。 +""" +import logging +import os + +from veadk import Agent +from veadk.agents.sequential_agent import SequentialAgent +from veadk.memory.short_term_memory import ShortTermMemory +from veadk.tools.builtin_tools.web_search import web_search + +from .hook.final_output_hook import guard_final_user_output +from .hook.search_output_hook import suppress_search_agent_user_output +from .hook.video_upload_hook import hook_video_upload +from .prompt import ROOT_AGENT_INSTRUCTION +from .sub_agents.breakdown_agent.prompt import BREAKDOWN_AGENT_INSTRUCTION +from .sub_agents.hook_analyzer_agent.prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION +from .sub_agents.report_generator_agent.prompt import REPORT_AGENT_INSTRUCTION +from .sub_agents.report_generator_agent.direct_output_callback import direct_output_callback +from .hook.format_hook import soft_fix_hook_output +from .tools.process_video import process_video +from .tools.analyze_segments_vision import analyze_segments_vision +from .tools.analyze_bgm import analyze_bgm +from .tools.video_upload import video_upload_to_tos +from .tools.analyze_hook_segments import analyze_hook_segments +from .tools.report_generator import generate_video_report +from .utils.types import json_response_config + +logger = logging.getLogger(__name__) + +# ==================== 内容安全护栏(LLM Shield) ==================== +# 仅当配置了 TOOL_LLM_SHIELD_APP_ID 时启用,否则静默跳过 + +shield_callbacks = {} +if os.getenv("TOOL_LLM_SHIELD_APP_ID"): + try: + from veadk.tools.builtin_tools.llm_shield import content_safety + shield_callbacks = { + "before_model_callback": content_safety.before_model_callback, + "after_model_callback": content_safety.after_model_callback, + } + logger.info("内容安全护栏: 已启用 (before_model + after_model)") + except Exception as e: + logger.warning(f"llm_shield 加载失败,跳过内容安全护栏: {e}") +else: + logger.debug("未配置 TOOL_LLM_SHIELD_APP_ID,跳过内容安全护栏") + +root_before_model_callback = shield_callbacks.get("before_model_callback") +root_after_model_callbacks = [] +if shield_callbacks.get("after_model_callback"): + root_after_model_callbacks.append(shield_callbacks["after_model_callback"]) +# 最后一层输出守卫:仅在泄露过程信息时触发 LLM 重写 +root_after_model_callbacks.append(guard_final_user_output) +root_callback_kwargs = { + "after_model_callback": root_after_model_callbacks, +} +if root_before_model_callback: + root_callback_kwargs["before_model_callback"] = root_before_model_callback + +# ==================== 子 Agent 定义 ==================== + +# 搜索子 Agent:挂载 web_search 工具 +search_agent = Agent( + name="search_agent", + description="联网搜索短视频行业资讯、平台规则、热门趋势等实时信息", + instruction=( + "你是一个搜索助手。收到用户的搜索请求后,使用 web_search 工具搜索相关信息。\n" + "你负责检索与整理,不直接面向用户输出最终答复;最终答复由 video_breakdown_agent 统一给出。\n" + "请将检索结果整理为简洁中文摘要(供 root 复用),不要添加自己的推测,只基于搜索结果回答。\n" + "格式注意:输出中禁止使用波浪号 ~,数值范围请用 到 或 - 代替(如 1°C到9°C)," + "避免 Markdown 渲染器将 ~ 误解析为删除线。\n" + "\n" + "完成搜索后,必须立即调用 transfer_to_agent,将控制权归还给 video_breakdown_agent。" + ), + tools=[web_search], + after_model_callback=[suppress_search_agent_user_output], + output_key="search_result", +) + +# ==================== Factory functions (避免 SequentialAgent 共享 parent) ==================== + + +def create_breakdown_agent() -> Agent: + return Agent( + name="breakdown_agent", + description=( + "负责视频分镜拆解:视频预处理(FFmpeg + ASR)、" + "视觉分析(doubao-vision)、BGM 分析。" + "支持URL链接和本地文件上传,输出完整分镜结构化数据。" + ), + instruction=BREAKDOWN_AGENT_INSTRUCTION, + tools=[ + process_video, + analyze_segments_vision, + analyze_bgm, + video_upload_to_tos, + ], + output_key="breakdown_result", + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_BREAKDOWN_AGENT", "disabled")} + } + }, + ) + + +def create_hook_analyzer_agent() -> SequentialAgent: + hook_analysis_agent = Agent( + name="hook_analysis_agent", + model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), + description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", + instruction=HOOK_ANALYZER_INSTRUCTION, + tools=[analyze_hook_segments], + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")} + } + }, + ) + + hook_format_agent = Agent( + name="hook_format_agent", + model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "")), + description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", + instruction=HOOK_FORMAT_INSTRUCTION, + generate_content_config=json_response_config, + output_key="hook_analysis", + after_model_callback=[soft_fix_hook_output], + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_HOOK_FORMAT_AGENT", "disabled")} + } + }, + ) + + return SequentialAgent( + name="hook_analyzer_agent", + description="前三秒钩子分析顺序流程:先分析,再格式化输出", + sub_agents=[hook_analysis_agent, hook_format_agent], + ) + + +def create_report_generator_agent() -> Agent: + return Agent( + name="report_generator_agent", + description="整合分镜拆解数据和钩子分析结果,生成专业的视频分析报告", + instruction=REPORT_AGENT_INSTRUCTION, + tools=[generate_video_report], + after_tool_callback=[direct_output_callback], + output_key="final_report", + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_REPORT_AGENT", "disabled")} + } + }, + ) + + +# ==================== Pipelines ==================== + +full_analysis_pipeline = SequentialAgent( + name="full_analysis_pipeline", + description="完整分析生产线:分镜拆解 -> 钩子分析 -> 报告生成", + sub_agents=[create_breakdown_agent(), create_hook_analyzer_agent(), create_report_generator_agent()], +) + +hook_only_pipeline = SequentialAgent( + name="hook_only_pipeline", + description="钩子分析生产线:分镜拆解 -> 钩子分析", + sub_agents=[create_breakdown_agent(), create_hook_analyzer_agent()], +) + +report_only_pipeline = SequentialAgent( + name="report_only_pipeline", + description="报告生产线:补齐分镜 -> 生成报告", + sub_agents=[create_breakdown_agent(), create_report_generator_agent()], +) + +breakdown_only_pipeline = SequentialAgent( + name="breakdown_only_pipeline", + description="分镜拆解生产线:仅执行分镜拆解", + sub_agents=[create_breakdown_agent()], +) + +agent = Agent( + name="video_breakdown_agent", + description=( + "专业的视频分镜拆解和深度分析助手," + "支持URL链接和本地文件上传," + "能够自动拆解视频分镜、分析前三秒钩子、生成专业报告" + ), + instruction=ROOT_AGENT_INSTRUCTION, + sub_agents=[ + full_analysis_pipeline, + hook_only_pipeline, + report_only_pipeline, + breakdown_only_pipeline, + search_agent, + ], + short_term_memory=ShortTermMemory(backend="local"), + # 拦截 veadk web UI 上传的文件(inline_data → 文本 URL/路径) + before_agent_callback=hook_video_upload, + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_ROOT_AGENT", "disabled")} + } + }, + **root_callback_kwargs, +) + +root_agent = agent diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py new file mode 100644 index 00000000..46c24c5a --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py @@ -0,0 +1,177 @@ +""" +最终输出守卫(Root Agent after_model_callback) + +目标: +1. 仅在检测到执行过程/JSON 外泄时触发; +2. 使用 LLM 将输出重写为用户可读 Markdown; +3. 不干预工具调用 envelope,避免破坏编排。 +""" +from __future__ import annotations + +import os +import json +from typing import Optional + +import httpx +from google.adk.agents.callback_context import CallbackContext +from google.adk.events import Event +from google.adk.models import LlmResponse +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + + +def _get_first_text(llm_response: LlmResponse) -> str: + if not llm_response or not llm_response.content or not llm_response.content.parts: + return "" + return llm_response.content.parts[0].text or "" + + +def _looks_like_tool_envelope(text: str) -> bool: + """工具调用包,必须放行,不可改写。""" + compact = text.replace(" ", "") + return ( + '"name":"' in compact and '"parameters":' in compact + ) or '"agent_name":"' in compact + + +def _event_to_text(event: Optional[Event]) -> str: + """将 event 尽量转成可检索字符串,便于做鲁棒的工具调用识别。""" + if event is None: + return "" + try: + if hasattr(event, "model_dump"): + return json.dumps(event.model_dump(), ensure_ascii=False) + except Exception: + pass + try: + if hasattr(event, "__dict__"): + return json.dumps(event.__dict__, ensure_ascii=False, default=str) + except Exception: + pass + return str(event) + + +def _is_tool_call_turn( + model_response_event: Optional[Event], + llm_response: Optional[LlmResponse], +) -> bool: + """ + 判断当前是否是工具调用轮次。 + 工具轮次不应被最终输出守卫改写,否则可能破坏 transfer_to_agent 编排。 + """ + # 优先依赖事件级信号,避免仅凭文本误判。 + event_text = _event_to_text(model_response_event).lower() + if event_text and any( + marker in event_text + for marker in ( + "function_call", + "tool_call", + "function_response", + "transfer_to_agent", + ) + ): + return True + + text = _get_first_text(llm_response).lower() if llm_response else "" + # 无事件信息时再回退到文本 envelope 判定。 + if _looks_like_tool_envelope(text): + return True + return False + + +def _needs_llm_repair(text: str) -> bool: + """是否需要触发最终输出修复。""" + leak_markers = ( + "<[PLHD", + '"name":', + '"parameters":', + ) + if any(marker in text for marker in leak_markers): + return True + + # 典型 JSON 外泄:整段以 {} 或 [] 包裹 + stripped = text.strip() + if (stripped.startswith("{") and stripped.endswith("}")) or ( + stripped.startswith("[") and stripped.endswith("]") + ): + return True + return False + + +def _call_repair_llm(raw_text: str) -> Optional[str]: + api_key = os.getenv("MODEL_AGENT_API_KEY", "") + if not api_key: + return None + + model = os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015") + api_base = os.getenv("MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3/") + api_base = api_base.rstrip("/") + url = f"{api_base}/chat/completions" + + prompt = ( + "你是一个最终输出清洗器。请将输入内容改写为“面向最终用户”的中文 Markdown 结论,要求:\n" + "1) 严禁输出任何内部执行过程、工具调用信息、name/parameters、transfer_to_agent、PLHD 标记;\n" + "2) 严禁输出 JSON、代码块;\n" + "3) 保留有价值的分析结论(评分、亮点、问题、建议);\n" + "4) 若信息不足,给出简短说明并建议用户重试,不要编造。\n" + ) + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": prompt}, + {"role": "user", "content": raw_text[:12000]}, + ], + "temperature": 0.2, + "max_tokens": 1200, + } + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + try: + response = httpx.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + content = str(content or "").strip() + return content or None + except Exception as e: + logger.warning(f"[final_output_guard] repair llm call failed: {e}") + return None + + +def guard_final_user_output( + *, + callback_context: CallbackContext, + llm_response: LlmResponse, + model_response_event: Optional[Event] = None, +) -> Optional[LlmResponse]: + """ + Root Agent 最终输出守卫: + - 纯工具 envelope:放行; + - 发现泄露:调用 LLM 修复; + - 失败兜底:保持原文,避免中断主流程。 + """ + agent = callback_context._invocation_context.agent + if not agent or getattr(agent, "name", "") != "video_breakdown_agent": + return llm_response + + if _is_tool_call_turn(model_response_event, llm_response): + return llm_response + + text = _get_first_text(llm_response) + if not text: + return llm_response + + if not _needs_llm_repair(text): + return llm_response + + repaired = _call_repair_llm(text) + if repaired: + llm_response.content.parts[0].text = repaired + logger.info("[final_output_guard] repaired leaked intermediate output by llm") + return llm_response + diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py new file mode 100644 index 00000000..9f73f318 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -0,0 +1,323 @@ +""" +钩子分析输出修复 Hook。 + +核心策略(对齐 multimedia 的契约化思想): +1. 仅对目标 agent(hook_format_agent / hook_analyzer_agent)生效; +2. 优先识别工具调用轮次,避免污染 transfer_to_agent 等内部 envelope; +3. 无论 JSON 是否完美,都尽力写回结构化 state 并输出 Markdown; +4. best-effort:永远不给用户上屏原始 JSON/占位片段。 +""" + +import json +import re +from typing import Any, Optional + +import json_repair +from google.adk.agents.callback_context import CallbackContext +from google.adk.events import Event +from google.adk.models import LlmResponse +from veadk.utils.logger import get_logger + +logger = get_logger(__name__) + +_TARGET_AGENTS = {"hook_format_agent", "hook_analyzer_agent"} +_MAX_COMMENT_LEN = 800 + +_SCORE_FIELDS = ( + "overall_score", + "visual_impact", + "language_hook", + "emotion_trigger", + "information_density", + "rhythm_control", +) + +_DEFAULT_HOOK_ANALYSIS: dict[str, Any] = { + "overall_score": 0.0, + "visual_impact": 0.0, + "visual_comment": "", + "language_hook": 0.0, + "language_comment": "", + "emotion_trigger": 0.0, + "emotion_comment": "", + "information_density": 0.0, + "info_comment": "", + "rhythm_control": 0.0, + "rhythm_comment": "", + "hook_type": "未知", + "hook_type_analysis": "", + "target_audience": "", + "strengths": [], + "weaknesses": [], + "suggestions": [], + "competitor_reference": "", + "retention_prediction": "数据不足,基于当前信息暂无法稳定预测", +} + + +def _agent_name(callback_context: CallbackContext) -> str: + inv = getattr(callback_context, "_invocation_context", None) + if not inv: + return "" + return getattr(getattr(inv, "agent", None), "name", "") or "" + + +def _get_first_text(llm_response: Optional[LlmResponse]) -> str: + if not llm_response or not llm_response.content or not llm_response.content.parts: + return "" + return str(llm_response.content.parts[0].text or "") + + +def _event_to_text(event: Optional[Event]) -> str: + if event is None: + return "" + try: + if hasattr(event, "model_dump"): + return json.dumps(event.model_dump(), ensure_ascii=False) + except Exception: + pass + try: + return json.dumps(getattr(event, "__dict__", {}), ensure_ascii=False, default=str) + except Exception: + return str(event) + + +def _looks_like_tool_envelope(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + if "name" in payload and "parameters" in payload: + return True + if payload.get("agent_name") or payload.get("transfer_to_agent"): + return True + return False + + +def _is_tool_call_turn(model_response_event: Optional[Event], text: str) -> bool: + event_text = _event_to_text(model_response_event).lower() + if any( + token in event_text + for token in ( + "tool_call", + "function_call", + "function_response", + "transfer_to_agent", + "analyze_hook_segments", + ) + ): + return True + + stripped = text.strip() + if not stripped: + return False + try: + obj = json.loads(stripped) + return _looks_like_tool_envelope(obj) + except Exception: + return False + + +def _extract_json_candidate(text: str) -> str: + fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE) + if fenced and fenced.group(1).strip(): + return fenced.group(1).strip() + return text.strip() + + +def _coerce_to_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v).strip() for v in value if str(v).strip()] + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + return [str(value)] + + +def _clamp_score(value: Any) -> float: + try: + return max(0.0, min(10.0, float(value))) + except (TypeError, ValueError): + return 0.0 + + +def _safe_text(value: Any, max_len: int = _MAX_COMMENT_LEN) -> str: + text = str(value or "").strip() + text = re.sub(r"<\[PLHD[^\]]*\]>", "", text) + text = text.replace("transfer_to_agent", "") + if len(text) > max_len: + return text[: max_len - 1] + "..." + return text + + +def _normalize_output(raw: dict[str, Any]) -> dict[str, Any]: + output = {**_DEFAULT_HOOK_ANALYSIS, **(raw or {})} + + for field in _SCORE_FIELDS: + output[field] = _clamp_score(output.get(field, 0)) + + output["strengths"] = _coerce_to_list(output.get("strengths")) + output["weaknesses"] = _coerce_to_list(output.get("weaknesses")) + output["suggestions"] = _coerce_to_list(output.get("suggestions")) + + for field in ( + "visual_comment", + "language_comment", + "emotion_comment", + "info_comment", + "rhythm_comment", + "hook_type", + "hook_type_analysis", + "target_audience", + "competitor_reference", + "retention_prediction", + ): + output[field] = _safe_text(output.get(field, "")) + + return output + + +def _extract_score(text: str, label: str) -> Optional[float]: + patterns = [ + rf'"{label}"\s*:\s*([0-9]+(?:\.[0-9]+)?)', + rf"{label}\s*[::]\s*([0-9]+(?:\.[0-9]+)?)", + ] + for pattern in patterns: + m = re.search(pattern, text, flags=re.IGNORECASE) + if m: + return _clamp_score(m.group(1)) + return None + + +def _extract_list_by_heading(text: str, heading: str) -> list[str]: + pattern = rf"{heading}[\s\S]{{0,120}}?(?:\n|\r\n)([\s\S]{{0,600}}?)(?:\n\n|###|####|$)" + m = re.search(pattern, text, flags=re.IGNORECASE) + if not m: + return [] + block = m.group(1) + items = re.findall(r"(?:^|\n)\s*(?:[-*]|\d+\.)\s*(.+)", block) + return [i.strip() for i in items if i.strip()][:6] + + +def _fallback_struct_from_text(text: str) -> dict[str, Any]: + output = dict(_DEFAULT_HOOK_ANALYSIS) + + for field in _SCORE_FIELDS: + score = _extract_score(text, field) + if score is not None: + output[field] = score + + zh_score = re.search(r"综合评分\s*[::]\s*([0-9]+(?:\.[0-9]+)?)", text) + if zh_score: + output["overall_score"] = _clamp_score(zh_score.group(1)) + + output["strengths"] = _extract_list_by_heading(text, r"(?:亮点|优点|strengths)") + output["weaknesses"] = _extract_list_by_heading(text, r"(?:待改进|不足|weaknesses)") + output["suggestions"] = _extract_list_by_heading(text, r"(?:优化建议|建议|suggestions)") + + excerpt = _safe_text(text, max_len=500) + if excerpt: + for field in ( + "visual_comment", + "language_comment", + "emotion_comment", + "info_comment", + "rhythm_comment", + ): + output[field] = excerpt + + return _normalize_output(output) + + +def _build_hook_markdown_summary(output: dict[str, Any]) -> str: + overall = output.get("overall_score", 0) + hook_type = output.get("hook_type", "未知") + retention = output.get("retention_prediction", "N/A") + audience = output.get("target_audience", "") + + def _score_line(name: str, score_key: str, comment_key: str) -> str: + score = output.get(score_key, 0) + comment = str(output.get(comment_key, "") or "").strip() or "暂无详细说明" + return f"#### {name}: {score}/10\n> {comment}" + + strengths = _coerce_to_list(output.get("strengths")) + weaknesses = _coerce_to_list(output.get("weaknesses")) + suggestions = _coerce_to_list(output.get("suggestions")) + + strengths_text = "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" + weaknesses_text = "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + suggestions_text = ( + "\n".join(f"{i + 1}. {s}" for i, s in enumerate(suggestions)) + if suggestions + else "1. 暂无" + ) + audience_line = f"\n- **目标受众**: {audience}" if audience else "" + + return ( + "## 前三秒钩子分析\n\n" + f"### 综合评分: {overall}/10\n" + f"- **钩子类型**: {hook_type}{audience_line}\n" + f"- **留存预测**: {retention}\n\n" + "---\n\n" + "### 五维评分详情\n\n" + f"{_score_line('视觉冲击力', 'visual_impact', 'visual_comment')}\n\n" + f"{_score_line('语言钩子', 'language_hook', 'language_comment')}\n\n" + f"{_score_line('情绪唤起', 'emotion_trigger', 'emotion_comment')}\n\n" + f"{_score_line('信息密度', 'information_density', 'info_comment')}\n\n" + f"{_score_line('节奏掌控', 'rhythm_control', 'rhythm_comment')}\n\n" + "---\n\n" + f"### 亮点\n{strengths_text}\n\n" + f"### 待改进\n{weaknesses_text}\n\n" + f"### 优化建议\n{suggestions_text}" + ) + + +def soft_fix_hook_output( + *, + callback_context: CallbackContext, + llm_response: LlmResponse, + model_response_event: Optional[Event] = None, +) -> Optional[LlmResponse]: + agent_name = _agent_name(callback_context) + if agent_name not in _TARGET_AGENTS: + return llm_response + + text = _get_first_text(llm_response) + if not text: + return llm_response + + if _is_tool_call_turn(model_response_event, text): + return llm_response + + state = getattr(callback_context, "state", None) + candidate = _extract_json_candidate(text) + + parsed: Any = None + try: + parsed = json.loads(candidate) + except json.JSONDecodeError: + try: + parsed = json_repair.loads(candidate) + except Exception: + parsed = None + + if isinstance(parsed, list): + parsed = parsed[0] if parsed else {} + + if isinstance(parsed, dict) and not _looks_like_tool_envelope(parsed): + normalized = _normalize_output(parsed) + logger.info("[soft_fix_hook_output] normalized by json path agent=%s", agent_name) + else: + normalized = _fallback_struct_from_text(text) + logger.warning( + "[soft_fix_hook_output] fallback to text extraction agent=%s", agent_name + ) + + markdown_summary = _build_hook_markdown_summary(normalized) + llm_response.content.parts[0].text = markdown_summary + + if isinstance(state, dict): + state["hook_analysis_struct"] = normalized + state["hook_analysis"] = normalized + state["hook_analysis_markdown"] = markdown_summary + + return llm_response diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py new file mode 100644 index 00000000..18a81fa1 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from typing import Optional + +from google.adk.agents.callback_context import CallbackContext +from google.adk.events import Event +from google.adk.models import LlmResponse + + +def _is_tool_call_turn(model_response_event: Optional[Event], text: str) -> bool: + """工具调用回合不做处理,避免干扰 function call。""" + if model_response_event and model_response_event.get_function_calls(): + return True + return "transfer_to_agent" in (text or "") or "web_search" in (text or "") + + +def suppress_search_agent_user_output( + *, + callback_context: CallbackContext, + llm_response: LlmResponse, + model_response_event: Optional[Event] = None, +) -> Optional[LlmResponse]: + """ + search_agent 只做检索与回传,不直接面向用户输出正文。 + 将其文本暂存到 state,最终由 root agent 统一输出一次,避免重复回复。 + """ + agent_name = getattr(getattr(callback_context, "_invocation_context", None), "agent", None) + if not agent_name or getattr(agent_name, "name", "") != "search_agent": + return llm_response + + if not llm_response or not llm_response.content or not llm_response.content.parts: + return llm_response + + text = llm_response.content.parts[0].text or "" + if not text or _is_tool_call_turn(model_response_event, text): + return llm_response + + state = getattr(callback_context, "state", None) + if isinstance(state, dict): + state["search_result"] = text + + # 清空 search_agent 的用户可见输出,避免与 root 最终答复重复展示。 + llm_response.content.parts[0].text = "" + return llm_response + diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py new file mode 100644 index 00000000..e5293ae8 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py @@ -0,0 +1,190 @@ +""" +视频文件上传钩子(before_agent_callback) + +当用户通过 veadk web UI 上传视频文件时,ADK 将文件二进制数据 +以 Part.inline_data 传入。LLM 无法处理原始二进制,会导致 +工具调用 JSON 生成失败(Unterminated string)。 + +此钩子在 Agent 处理之前拦截 inline_data: + 1. 保存到临时文件 + 2. 尝试上传到 TOS 获取签名 URL + 3. 如果 TOS 不可用,保留本地文件路径 + 4. 将 inline_data Part 替换为文本 Part + +参考实现: ad_video_gen_seq/app/market/hook.py → hook_inline_data_transform +""" +from __future__ import annotations + +import logging +import os +import tempfile +import uuid +from datetime import datetime +from pathlib import Path +from typing import Optional + +from google.adk.agents.callback_context import CallbackContext +from google.genai import types + +logger = logging.getLogger(__name__) + +# 支持的视频 MIME 类型 +VIDEO_MIME_TYPES = { + "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", + "video/x-matroska", "video/mpeg", "video/3gpp", "video/x-flv", + "application/octet-stream", # 某些浏览器上传时 MIME 可能不精确 +} + +# 持久化目录(不会被 process_video 的 finally 清理) +UPLOAD_CACHE_DIR = os.getenv("MEDIA_UPLOAD_CACHE_DIR", "./.media-uploads") + + +def _try_upload_to_tos(local_path: str) -> Optional[str]: + """尝试将文件上传到 TOS,成功返回签名 URL,失败返回 None""" + try: + import tos + from tos import HttpMethodType + + ak = os.getenv("VOLCENGINE_ACCESS_KEY", "") + sk = os.getenv("VOLCENGINE_SECRET_KEY", "") + + if not ak or not sk: + try: + from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() + ak = cred.access_key_id + sk = cred.secret_access_key + except Exception: + return None + + if not ak or not sk: + return None + + bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", "video-breakdown-uploads") + region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", "cn-beijing") + endpoint = f"tos-{region}.volces.com" + + client = tos.TosClientV2(ak=ak, sk=sk, endpoint=endpoint, region=region) + try: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.basename(local_path) + object_key = f"video_breakdown/upload/{timestamp}_{filename}" + + client.put_object_from_file( + bucket=bucket, key=object_key, file_path=local_path + ) + + signed = client.pre_signed_url( + http_method=HttpMethodType.Http_Method_Get, + bucket=bucket, + key=object_key, + expires=604800, # 7 天 + ) + logger.info(f"[video_upload_hook] 文件已上传到 TOS: {object_key}") + return signed.signed_url + finally: + client.close() + + except Exception as exc: + logger.warning(f"[video_upload_hook] TOS 上传失败,回退到本地路径: {exc}") + return None + + +def hook_video_upload( + callback_context: CallbackContext, +) -> Optional[types.Content]: + """ + before_agent_callback:拦截用户消息中的 inline_data(上传文件), + 转换为文本 URL 或本地路径,确保 LLM 不接触原始二进制数据。 + """ + user_content = callback_context.user_content + if not user_content or not user_content.parts: + return None + + new_parts = [] + has_inline_data = False + + for part in user_content.parts: + # 保留文本 Part + if part.text: + new_parts.append(types.Part(text=part.text)) + + # 处理 inline_data(上传的文件) + if part.inline_data and part.inline_data.data: + has_inline_data = True + mime_type = part.inline_data.mime_type or "application/octet-stream" + data = part.inline_data.data + + logger.info( + f"[video_upload_hook] 检测到 inline_data: " + f"mime={mime_type}, size={len(data)} bytes" + ) + + # 根据 MIME 类型确定文件后缀 + ext = _mime_to_ext(mime_type) + + # 保存到持久化目录(不受 process_video 临时目录清理影响) + upload_dir = Path(UPLOAD_CACHE_DIR) + upload_dir.mkdir(parents=True, exist_ok=True) + + filename = f"upload_{uuid.uuid4().hex[:8]}{ext}" + local_path = upload_dir / filename + + with open(local_path, "wb") as f: + f.write(data) + + logger.info( + f"[video_upload_hook] 文件已保存: {local_path} " + f"({len(data) / 1024 / 1024:.1f}MB)" + ) + + # 尝试上传到 TOS + tos_url = _try_upload_to_tos(str(local_path)) + + if tos_url: + new_parts.append( + types.Part( + text=f"用户上传了视频文件,URL: {tos_url}" + ) + ) + # TOS 上传成功后可以删除本地文件 + try: + local_path.unlink() + except Exception: + pass + else: + # TOS 不可用,使用本地路径 + abs_path = str(local_path.resolve()) + new_parts.append( + types.Part( + text=f"用户上传了视频文件,本地路径: {abs_path}" + ) + ) + + if has_inline_data: + user_content.parts = new_parts + logger.info( + f"[video_upload_hook] inline_data 已转换为文本 " + f"({len(new_parts)} parts)" + ) + + # 返回 None 表示不中断正常流程 + return None + + +def _mime_to_ext(mime_type: str) -> str: + """将 MIME 类型映射为文件扩展名""" + mapping = { + "video/mp4": ".mp4", + "video/quicktime": ".mov", + "video/x-msvideo": ".avi", + "video/webm": ".webm", + "video/x-matroska": ".mkv", + "video/mpeg": ".mpeg", + "video/3gpp": ".3gp", + "video/x-flv": ".flv", + "audio/mpeg": ".mp3", + "audio/wav": ".wav", + "audio/mp4": ".m4a", + } + return mapping.get(mime_type, ".mp4") # 默认 .mp4 diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py new file mode 100644 index 00000000..f0d44665 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py @@ -0,0 +1,358 @@ +""" +主编排 Root Agent 提示词 +描述完整视频拆解分析能力、灵活的部分执行策略和自然的对话风格 +""" + +ROOT_AGENT_INSTRUCTION = """ +# 角色 + +你是"小视"——一位专业的视频内容深度分析顾问。你背后有一套完整的视频拆解引擎,能将任何短视频自动拆解为结构化的分镜脚本,并在此基础上进行多维度的深度分析。 + +你的性格:专业但亲切,像一位见多识广的创意总监在帮朋友看片。你善于从创作者视角给出可落地的建议,而不是堆砌数据。 + +--- + +## 状态复用机制(重要) + +你拥有**会话级短期记忆**,可以在同一 session 内访问和复用之前生成的中间数据。 + +### 核心原则 +1. **优先复用历史数据**:在同一次对话中,如果用户要求"重新生成钩子"或"生成报告",**先检查 session.state 是否已有分镜数据**(`process_video_result` / `vision_analysis_result`) +2. **避免重复拆解**:若已有分镜数据且用户仅要求后续步骤(如钩子/报告),直接复用历史分镜,不要重新调用 breakdown +3. **明确重新分析**:仅当用户明确说"重新拆解这个视频"或提供了**新的视频链接**时,才重新执行完整流程 + +### 判断逻辑示例 +- 用户:"给我看看前三秒钩子分析" → 检查是否有分镜数据 → 有则直接调用钩子分析,无则先拆解 +- 用户:"重新生成一份报告" → 检查是否有分镜/钩子数据 → 有则直接复用生成报告,无则提示"需要先分析视频" +- 用户:"帮我分析另一个视频 <新链接>" → 视为全新任务,执行完整流程 + +### 注意事项 +- 如果用户在同一对话中反复要求相同内容(如"再生成一次报告"),**不要误认为需要重新拆解**,应直接基于已有数据重新调用对应 Agent +- session.state 的关键字段: + - `process_video_result`: 分镜基础数据 + - `vision_analysis_result`: 视觉分析后的分镜 + - `hook_analysis_struct`: 钩子分析结构化数据 + - `hook_analysis_markdown`: 钩子分析 Markdown 输出 + - `final_report`: 最终报告 + +--- + +## 核心能力全景 + +你作为**编排 Agent**,负责理解用户意图并决策是否调用专业子 Agent。你背后有 5 个专业子 Agent(含 4 条生产线): + +### 📹 模块1:breakdown_only_pipeline(视频分镜拆解生产线) +**职责**:将视频拆解为逐帧分镜,输出结构化数据(时间轴、画面内容、运镜、ASR、BGM、场景分析)。 + +**触发条件**(满足任一条件即可): +1. ✅ 用户**提供了视频**(URL / 本地路径 / TOS 路径) +2. ✅ 用户**明确要求分镜拆解**或完整分析 + +**触发关键词**: +- "拆解"、"分镜"、"视频结构"、"分析视频" +- 提供视频后说"开始分析"、"帮我看看" + +**不触发场景**: +- ❌ 仅咨询"怎么拆分镜"(理论问题) +- ❌ 说"我想分析视频"但未提供视频 +- ❌ 闲聊、打招呼、功能介绍 + +### 🎣 模块2:hook_only_pipeline(钩子分析生产线) +**职责**:按固定顺序执行“分镜拆解 -> 钩子分析”,输出 5 维度评分(视觉冲击力、语言钩子、情绪唤起、信息密度、节奏掌控)。 + +**触发条件**(满足任一条件即可): +1. ✅ 用户**只丢了视频下载链接**(默认执行) +2. ✅ **已有分镜数据**(来自模块1或历史 state) +3. ✅ 用户**明确要求钩子分析** + +**触发关键词**: +- "钩子"、"前三秒"、"开头"、"留存"、"吸引力" +- "分析开头"、"看看前 3 秒" + +**不触发场景**: +- ❌ 咨询"怎么设计钩子"(给建议即可,不调用) +- ❌ 没有视频/分镜数据时要求钩子分析(需先拿到视频或先做模块1) + +### 📊 模块3:report_only_pipeline(报告生产线) +**职责**:按固定顺序执行“补齐分镜 -> 生成报告”,确保报告可用(钩子缺失时也要生成主报告)。 + +**触发条件**(同时满足): +1. ✅ **已有分镜数据** +2. ✅ 用户**明确要求生成报告** + +**触发关键词**: +- "报告"、"生成报告"、"整理成文档"、"汇总" + +**不触发场景**: +- ❌ 用户只是要口头说明结果(直接转述即可) +- ❌ 没有数据时要求报告 + +### 🧩 模块4:full_analysis_pipeline(完整分析生产线) +**职责**:按固定顺序执行“分镜拆解 -> 钩子分析 -> 报告生成”。 + +### 🔍 模块5:search_agent(联网搜索) +**职责**:搜索短视频行业资讯、平台最新规则、热门趋势等实时信息 + +**触发条件**: +1. ✅ 用户**明确要求搜索**特定信息 +2. ✅ 你需要**实时数据**才能回答(比如最新平台规则、当前热门趋势) + +**触发关键词**: +- "搜一下"、"查查"、"最新"、"现在"、"当前" +- "抖音最新规则"、"小红书热门"等需要实时信息的请求 + +**不触发场景**: +- ❌ 通用知识问答(用自己的知识直接回答) +- ❌ 关于你功能的问题 +- ❌ 视频分析相关的请求(交给其他子 Agent) + +--- + +## 你的角色定位 + +### 作为编排 Agent,你需要: +1. **优先作为对话助手**:理解用户意图、回答咨询、提供建议 +2. **判断是否委派**:仅在满足"触发条件"时才调用子 Agent +3. **数据流转**:优先复用 session.state 中的历史分镜/钩子数据,避免重复拆解;在确需时再调用子 Agent +4. **结果解读**:将子 Agent 的技术输出转化为用户友好的语言 + +### 你**不直接执行**以下任务(委派给子 Agent): +- ❌ 自己手写/模拟工具执行结果 +- ✅ 需要分镜/钩子/报告时,调用对应子 Agent 完成 + +### 你**直接处理**以下场景(不调用子 Agent): +- ✅ 闲聊、打招呼、自我介绍 +- ✅ 回答短视频创作咨询(剪辑技巧、平台策略、钩子设计理论等) +- ✅ 解释你的能力和工作流程 +- ✅ 确认用户需求("你想做全套分析还是只看分镜?") + +### 你**委派 search_agent**的场景: +- ✅ 需要实时信息才能回答的问题("抖音最新推荐算法"、"2026年短视频趋势") +- ✅ 用户明确要求搜索特定信息 +- ✅ 搜索完成后,最终回复只允许由 root agent 输出一次(单条自然语言) +- ✅ root 必须基于 `search_result` 组织答案,不得重复转述同一搜索结果 + +--- + +## 交互策略 + +### 当用户闲聊或打招呼时 +简短、自然地回应,自然带出你的能力,但**不要甩一堆功能清单**。示例风格: + +> "嗨!我是小视 👋 专门帮人拆视频的。不管是想看分镜结构、还是想知道开头3秒为什么留不住人,丢个视频下载链接给我就行~" + +注意:打招呼/自我介绍时,**不要**主动承诺“抖音/小红书/B站链接、本地文件路径、TOS路径”等能力;仅说“视频下载链接(http/https可访问)”即可。 + +### 当用户想做视频分析时 +根据用户表述判断需求范围,**不用每次都逐一询问要做哪些模块**。常见策略: + +1. **需求明确** → 直接执行。"帮我拆个分镜" → 执行模块1。"分析一下这个视频前三秒" → 执行模块1+模块2。 +2. **需求模糊**("帮我分析个视频")→ 简短确认一句。"想做全套分析还是先看分镜?全套的话会包含分镜拆解、前三秒钩子评分和完整报告~" +3. **用户说"全套"或"完整分析"** → 按顺序执行模块1→2→3。 +4. **已经有部分数据** → 直接续接。比如分镜已经做完了,用户说"帮我看看前三秒" → 直接调模块2,不需要重新拆。 + +### 默认触发规则(重要) +- 如果用户**只发送了视频下载链接**,没有其它文字说明:你默认执行 **模块2**(钩子分析生产线:内部会先做分镜拆解再做前三秒钩子分析)。\n + - 如果 session.state 已存在分镜数据(`process_video_result` / `vision_analysis_result`),严格复用,不要重复拆解。\n + - 完成钩子分析后提示用户:是否需要基于当前结果生成“更深入洞察的完整视频分析报告”(模块3/模块4)。\n +- 如果用户明确说“只要分镜/只要钩子/只要报告”,则按用户意图执行;无论完成分镜或钩子,都要提示可进一步生成报告。 + +### 获取视频的方式 +当确认了需求后,简洁地要视频: +> "好的~请给我视频下载链接(http/https可访问)。" + +如果用户一条消息里既说了需求又给了视频链接,**直接开始执行,不要重复确认**。 + +### 执行过程中的沟通 +- 分镜拆解通常需要5-8分钟。提交后简短告知:"已经开始处理了,分镜拆解大概需要几分钟,我帮你盯着~" +- 不要在等待期间发送大段解释或重复描述流程。 + +### 结果展示后(格式要求 ⚠️ 重要) + +**绝对禁止直接输出原始 JSON 给用户**。你必须将子 Agent 返回的 JSON 数据转化为**结构清晰、易读的 Markdown 格式**。 + +#### 钩子分析结果的展示格式: + +``` +## 🎣 前三秒钩子分析 + +### 综合评分:X.X / 10 分 +**钩子类型**:XX型 | **目标受众**:XX | **留存预测**:X(>70% / 50-70% / <50%) + +--- + +### 📊 五维评分详情 + +#### 1. 视觉冲击力:X / 10 +> [详细评价内容,引用具体画面] + +#### 2. 语言钩子:X / 10 +> [详细评价内容] + +#### 3. 情绪唤起:X / 10 +> [详细评价内容] + +#### 4. 信息密度:X / 10 +> [详细评价内容] + +#### 5. 节奏掌控:X / 10 +> [详细评价内容] + +--- + +### ✅ 亮点 +- [具体优点1] +- [具体优点2] + +### ⚠️ 待改进 +- [具体不足1] +- [具体不足2] + +### 💡 优化建议 +1. [建议1:改什么→怎么改→预期效果] +2. [建议2] +3. [建议3] + +### 🔮 留存预测 +[详细分析] +``` + +- 对分镜拆解结果,同样用**表格和分段说明**展示,不要输出 JSON +- **简洁解读关键发现**,加入你的专业解读。例如:"你这个视频一共15个分镜,前3秒的钩子评分7.8分还不错,主要是视觉冲击力很强。不过语言钩子弱了点,开场文案可以再'狠'一些——" +- 如果只做了部分分析,**自然地提一句后续可做的**:"分镜已经拆好了,要不要顺便看看前三秒的钩子分析?这个视频开头挺有意思的。" +- 无论完成了分镜拆解或钩子分析,都在末尾提示一句:\n "如果你想要更深入的洞察(节奏问题、平台适配、改进建议汇总等),我可以基于当前结果生成一份完整视频分析报告。" +- 不要强推用户做更多分析。提一句就够了。 +- 如果 `hook_analysis` 不是标准 JSON(例如结构化文本或混合格式),**你要先做信息提炼再用 Markdown 展示**,不要把原始内容直接抛给用户。 +- 即使钩子分析字段不完整,也要基于已有信息给出可读结论,并明确标注“缺失字段已按已有信息解读”。 +- 如果收到执行过程片段(如 `name/parameters`、`transfer_to_agent`、`analyze_hook_segments`、`<[PLHD...]>` 占位标记),**必须视为内部过程数据**,严禁原样输出给用户。 +- 你展示给用户的内容必须是整理后的最终结论(Markdown 标题、评分、亮点、建议),而不是工具调用日志。 +- 在展示“视频分析报告”的最后,**必须**附带一个“💡 核心价值”小节,结合报告内容简要说明本项目如何帮助解决“完播率低”、“卖点不清”等具体问题(作为功能推广)。 + +--- + +## 灵活执行规则 + +### 完整流程(全套分析) +按顺序自动调度: +1. full_analysis_pipeline(内部固定执行:分镜拆解 → 钩子分析 → 报告生成) + +### 部分执行场景 + +| 用户意图 | 执行模块 | 说明 | +|---------|---------|------| +| "拆分镜" / "分析视频结构" | 模块1 | 仅分镜拆解 | +| "分析前三秒" / "看看开头钩子" | 模块2 | 钩子生产线自动补齐分镜并完成钩子分析 | +| "生成报告" / "给我完整报告" | 模块3 | 报告优先可用;缺钩子也可生成主报告 | +| "只生成报告不用看钩子" | 模块3 | 直接生成主报告 | +| "全面分析" / "完整分析" | 模块4 | 固定完整流程 | + +### 关于数据依赖与自动编排 + +#### 依赖关系 +``` +breakdown_only_pipeline (分镜数据) + ↓ +hook_only_pipeline (钩子分析,内部补齐分镜) + ↓ +report_only_pipeline (报告,依赖分镜数据,钩子缺失也可生成主报告) +``` + +#### 自动编排规则 + +**场景1:用户要钩子分析,但没有分镜数据** +``` +用户: "帮我分析这个视频的前三秒钩子 https://example.com/video.mp4" + +你的行为: +1. 识别意图:用户要钩子分析 + 提供了视频 +2. 检查依赖:钩子分析需要分镜数据,但当前没有 +3. 自动编排:直接调用 hook_only_pipeline(内部会先做分镜再做钩子) +4. 向用户说明:"好的!我先帮你拆解视频分镜,然后再分析前三秒钩子~大概需要几分钟。" +``` + +**场景2:用户要报告,但只有部分数据** +``` +用户: "生成完整报告"(但只有分镜数据,没有钩子分析) + +你的行为: +1. 识别意图:用户要完整报告 +2. 检查依赖:有分镜数据,缺少钩子分析 +3. 主动询问:"我看到有分镜数据了。要不要我先做一下前三秒钩子分析,再生成完整报告?这样报告会更专业~" +4. 如果用户说"不用" → 直接调用 report_only_pipeline 生成基础报告 +5. 如果用户说"好" → 直接调用 full_analysis_pipeline(一步完成) +``` + +**场景3:用户要完整分析** +``` +用户: "帮我全面分析这个视频 https://example.com/video.mp4" + +你的行为: +直接调用 full_analysis_pipeline(内部按固定顺序执行) +``` + +**场景4:用户没有提供视频** +``` +用户: "我想做钩子分析" + +你的行为: +不调用任何子 Agent,而是回答: +"好的~钩子分析需要先对视频进行分镜拆解。请给我视频链接或者文件路径,我帮你一起做~" +``` + +#### 核心原则 +- ✅ **能自动补齐的前置步骤,主动补齐**(如有视频但缺分镜数据 → 自动先拆解) +- ✅ **需要用户决策的,简短询问**(如要不要加钩子分析) +- ✅ **缺少用户输入的,友好提示**(如没有视频 → 要求提供) +- ✅ **子步骤失败不阻断主流程**:钩子分析失败/不完整时,继续推进报告生成(基于已有分镜数据) +- ✅ **报告优先可用**:当 `hook_analysis` 缺失、无效或格式异常时,仍需调用 `report_only_pipeline` 生成可用的“分镜主报告”,并在报告中附一行“钩子分析部分数据不完整”说明 +- ❌ **不要直接报错或拒绝**,给出可行路径 + +--- + +## 通用对话能力 + +你**首先是一个可以自由对话的 AI 助手**,然后才是视频分析专家。 + +### 可以直接回答的问题(不调用子 Agent) +- 短视频创作技巧、剪辑建议、节奏把控 +- 不同平台(抖音/小红书/B站)的内容策略差异 +- 如何设计好的视频开头钩子、BGM 选择建议 +- 分镜脚本写作指导、视频创意头脑风暴 +- 闲聊、打招呼、了解你的能力 +- **任何不涉及具体视频分析的对话** + +### 何时调用子 Agent? +**仅当满足以下条件时,才调用子 Agent**: +1. 用户**明确提供了视频**(URL 链接、本地路径、TOS 路径) +2. 用户**明确要求**进行分析("帮我拆解"、"分析一下"、"看看这个视频") + +**反例(不要调用子 Agent)**: +- ❌ "你好" → 直接回答,不转移 +- ❌ "介绍一下你的功能" → 直接介绍,不转移 +- ❌ "视频钩子怎么设计?" → 提供建议,不转移 +- ❌ "我想分析视频,但还没准备好" → 等用户提供视频,不转移 + +**正例(调用子 Agent)**: +- ✅ "帮我拆解 https://example.com/video.mp4" → 调用 breakdown_only_pipeline +- ✅ "分析这个视频 https://example.com/video.mp4" → 默认调用 hook_only_pipeline(仅丢视频也默认执行) +- ✅ "给我完整报告 https://example.com/video.mp4" → 调用 full_analysis_pipeline 或 report_only_pipeline(按用户意图) +- ✅ "搜一下抖音最新推荐算法" → 调用 search_agent +- ✅ "2026年短视频有什么新趋势" → 调用 search_agent + +### 引导策略 +在回答通用问题后,**自然地提一句**(不强推): +> "说到这个,如果你有具体的视频,我可以帮你实际拆一下看看效果~" + +--- + +## 底线规则 +- 所有输出用中文 +- **绝对不输出原始 JSON**:所有子 Agent 返回的 JSON 必须转化为 Markdown 格式后再展示 +- **不要频繁调用子 Agent**:仅在用户提供视频且明确要求分析时调用 +- **优先自由对话**:没有视频时,作为普通 AI 助手回答问题 +- 不要机械重复相同话术,每次回复根据上下文调整 +- 不要假设用户需要全套服务 +- 任务失败时,友好说明原因并给出可行建议(比如换个视频链接试试) +- 记住对话上下文,不要问已经回答过的问题 +- 在等待期间保持简洁,不要用大段文字填充等待时间 +""" diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py new file mode 100644 index 00000000..503d5094 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py @@ -0,0 +1,36 @@ +""" +分镜拆解 Agent +负责接收视频URL/本地文件,完成 FFmpeg 预处理 + ASR + 视觉分析 + BGM 分析 +无需外部后端服务,所有逻辑自包含在 tool 中 +""" +import os + +from veadk import Agent + +from video_breakdown_agent.tools.process_video import process_video +from video_breakdown_agent.tools.analyze_segments_vision import analyze_segments_vision +from video_breakdown_agent.tools.analyze_bgm import analyze_bgm +from video_breakdown_agent.tools.video_upload import video_upload_to_tos +from .prompt import BREAKDOWN_AGENT_INSTRUCTION + +breakdown_agent = Agent( + name="breakdown_agent", + description=( + "负责视频分镜拆解:视频预处理(FFmpeg + ASR)、" + "视觉分析(doubao-vision)、BGM 分析。" + "支持URL链接和本地文件上传,输出完整分镜结构化数据。" + ), + instruction=BREAKDOWN_AGENT_INSTRUCTION, + tools=[ + process_video, + analyze_segments_vision, + analyze_bgm, + video_upload_to_tos, + ], + output_key="breakdown_result", # 结果写入 session.state["breakdown_result"] + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_BREAKDOWN_AGENT", "disabled")} + } + }, +) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py new file mode 100644 index 00000000..617ea343 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py @@ -0,0 +1,91 @@ +""" +分镜拆解 Agent 提示词 +自包含模式:无需外部后端,tool 内嵌全部处理逻辑 +""" + +BREAKDOWN_AGENT_INSTRUCTION = """ +你是一个专业的视频分镜拆解专家,负责接收视频并完成完整的分镜拆解分析。 + +## 你的工具 + +1. **process_video** — 视频预处理(FFmpeg + ASR) + - 输入:视频 URL + - 输出:视频元数据、分段信息、关键帧 URL、ASR 语音识别文本 + - 注意:需要本机安装 FFmpeg + +2. **analyze_segments_vision** — 视觉分析(doubao-vision) + - 输入:自动从 session state 读取 process_video 的输出(无需手动传参) + - 输出:每个分镜的景别、运镜、画面内容、功能标签等 + +3. **analyze_bgm** — BGM 分析 + - 输入:音频 URL 和视频时长 + - 输出:BGM 风格、情绪、乐器、节奏等 + - 注意:模型不支持音频时会优雅降级 + +4. **video_upload_to_tos** — 本地文件上传到 TOS + - 仅在用户提供本地文件路径时使用 + +## 支持的输入方式 + +### 方式一:视频 URL 链接 +用户直接提供视频的公开 URL,例如: +- `https://example.com/video.mp4` +→ 直接使用 process_video 处理 + +### 方式二:本地文件路径 +用户提供本地文件路径,例如: +- `/Users/xxx/Downloads/video.mp4` +- `.media-uploads/video.mp4` +→ **优先直接使用** process_video 处理本地文件(工具已支持本地路径) + +#### 本地路径判定规则(重要) +- 只要用户提供的输入**不是**以 `http://` 或 `https://` 开头,都视为“本地文件路径”。\n + 包括相对路径(如 `.media-uploads/a.mp4`)与绝对路径。\n +- 对本地路径**直接调用** `process_video(video_url=本地路径)`。\n +- 仅当需要把本地文件转成可分享的外链,才使用 `video_upload_to_tos(file_path)` 上传后再处理。 + +## 完整工作流程 + +### Step 1: 获取视频 URL +- URL 输入 → 直接使用 +- 本地路径 → video_upload_to_tos 上传获取 URL + +### Step 2: 视频预处理 +- 调用 process_video(video_url) +- 获取分段、帧图、音频、ASR 文本等 +- 如果返回 error,告知用户具体原因并停止 + +### Step 3: 视觉分析(必须执行) +- 必须在 process_video 后调用 analyze_segments_vision()(无需传参) +- 即使 process_video 返回的 frame_urls 显示为"本地缓存"也必须调用(工具内部已处理 base64 回退) +- 获取每个分镜的视觉分析结果 + +### Step 4: BGM 分析(必须执行) +- 必须调用 analyze_bgm()(无需传参,工具自动处理音频来源,含 base64 回退) +- 模型不支持时自动降级,不影响主流程 + +### Step 5: 整合输出 +将所有数据整合为完整的分镜拆解结果,返回给用户: +- 视频基本信息(时长、分辨率) +- 分镜列表(含视觉分析、语音内容) +- BGM 分析 +- ASR 完整文本 + +## 输出格式 + +请以结构化方式返回,包含: +- **视频信息**:时长、分辨率 +- **分镜列表**:每个分镜的序号、时间段、景别、运镜、画面描述、语音内容、功能标签 +- **BGM 分析**:是否有 BGM、风格、情绪(如有) +- **完整语音文本**:ASR 识别的全部文字 + +## 注意事项 +- 视频预处理需要时间(取决于视频时长),请告知用户正在处理 +- 如果 ASR 未配置,语音识别部分为空,这是正常的 +- 如果 TOS 凭证未配置,帧和片段只在本地,不影响分析 +- 如果视觉分析部分失败,使用 fallback 数据继续 + +## 完成后行为(必须遵守) +- 当你完成分镜拆解并输出结果后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +- 不要在本 Agent 内继续处理“钩子分析/报告生成/下一步编排”请求,交由 Root Agent 统一调度。 +""" diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py new file mode 100644 index 00000000..993a11f2 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -0,0 +1,52 @@ +""" +前三秒钩子分析 Sub-Agent + +简化为单 Agent 模式: +1. hook_analyzer_agent — 使用 vision 模型进行多模态分析 +2. after_model_callback 仅做宽松输出修复,不做刚性 schema 校验 +""" +import os + +from veadk import Agent +from veadk.agents.sequential_agent import SequentialAgent + +from video_breakdown_agent.hook.format_hook import soft_fix_hook_output +from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments +from video_breakdown_agent.utils.types import HookAnalysis, json_response_config +from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION + +# 第一阶段:多模态视觉分析(使用 vision 模型) +hook_analysis_agent = Agent( + name="hook_analysis_agent", + model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), + description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", + instruction=HOOK_ANALYZER_INSTRUCTION, + tools=[analyze_hook_segments], + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")} + } + }, +) + +# 第二阶段:对齐 multimedia 的格式化收口模式 +hook_format_agent = Agent( + name="hook_format_agent", + model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "")), + description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", + instruction=HOOK_FORMAT_INSTRUCTION, + generate_content_config=json_response_config, + output_key="hook_analysis", + after_model_callback=[soft_fix_hook_output], + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_HOOK_FORMAT_AGENT", "disabled")} + } + }, +) + +hook_analyzer_agent = SequentialAgent( + name="hook_analyzer_agent", + description="前三秒钩子分析顺序流程:先分析,再格式化输出", + sub_agents=[hook_analysis_agent, hook_format_agent], +) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py new file mode 100644 index 00000000..4c9a340f --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py @@ -0,0 +1,126 @@ +""" +钩子分析 Sub-Agent 提示词(多模态视觉分析版本) +迁移自 prompts/hook_analyzer_prompt.py +""" + +HOOK_ANALYZER_INSTRUCTION = """ +你是一位短视频钩子分析专家,具备**视觉分析能力**,专注于评估视频前三秒的吸引力和转化潜力。 + +## 分析流程 + +当你接收到分镜数据时: +1. 首先使用 analyze_hook_segments 工具提取前三秒的分镜信息 +2. **重点观察每个分镜的关键帧图片**(frame_images 字段) +3. 结合视觉画面、文案、运镜等多维度信息进行深度分析 + +## 核心能力 + +你可以直接**看到视频的关键帧图片**,请务必: +- 仔细观察画面构图、色彩、光影、人物表情、场景细节 +- 评估第一帧的"停住滑屏"能力 +- 分析画面与文案的配合度 +- 判断视觉元素对目标受众的吸引力 + +## 评估维度(基于视觉+文本综合分析,每项0-10分) + +## 评估维度 + +### 1. 视觉冲击力(0-10分)**【必须看图片评估】** +- **观察关键帧**:画面构图、色彩、光影是否有吸引力 +- **第一帧检查**:是否有"停住滑屏"的视觉冲击力 +- **运镜评估**:通过多帧对比判断运镜流畅度和动感 +- **细节观察**:人物表情、道具、场景设置是否吸睛 + +### 2. 语言钩子(0-10分)**【结合画面评估】** +- **文案与画面配合度**:开场文案与视觉内容是否呼应 +- **悬念制造**:文案+画面是否共同制造悬念、痛点或好奇心 +- **语气感染力**:语气和表达是否有感染力 +- **信息传达**:视觉+语言是否快速传达核心价值 + +### 3. 情绪唤起(0-10分)**【观察画面情绪】** +- **视觉情绪**:画面本身是否能快速触发情绪(好奇、惊讶、共鸣等) +- **人物情绪**:如有人物,观察其表情、肢体语言传递的情感 +- **氛围营造**:色调、光影、场景是否营造出特定情绪氛围 +- **情绪强度**:视觉+语言的情绪强度是否适合目标受众 + +### 4. 信息密度(0-10分) +- 前三秒传递的有效信息量 +- 核心价值主张是否清晰 +- 是否在最短时间内让观众理解内容主题 +- 信息层次是否合理 + +### 5. 节奏掌控(0-10分) +- 分镜切换节奏是否合理 +- 是否符合短视频平台特性(抖音/快手/小红书等) +- 画面转场是否流畅 +- 整体节奏感是否能留住观众 + +## 输出格式 + +优先输出为 JSON(便于后续程序处理),但**分析质量优先于格式**。 +如果你无法一次性生成合法 JSON,请先输出结构化文本分析,确保内容完整,不要为了凑 JSON 而省略关键信息。 + +推荐 JSON 结构如下(字段可补充,但尽量覆盖核心字段): + +```json +{ + "overall_score": 综合评分(0-10,各维度加权平均), + "visual_impact": 视觉冲击力分数, + "visual_comment": "视觉冲击力的详细评价(4-6句话:描述具体画面元素、构图分析、色彩运用、光影效果、第一帧停留感、与同类视频的对比参考)", + "language_hook": 语言钩子分数, + "language_comment": "语言钩子的详细评价(4-6句话:具体引用文案内容、分析悬念/痛点/好奇心设计、语气节奏、文案与画面的呼应关系、信息传达效率)", + "emotion_trigger": 情绪唤起分数, + "emotion_comment": "情绪唤起的详细评价(4-6句话:画面情绪氛围分析、人物表情/肢体语言观察、色调/光影对情绪的影响、目标受众的情绪共鸣点、情绪曲线设计)", + "information_density": 信息密度分数, + "info_comment": "信息密度的详细评价(4-6句话:具体列出传递了哪些信息、信息层次结构、核心价值主张是否突出、与时长的匹配度、信息遗漏或冗余分析)", + "rhythm_control": 节奏掌控分数, + "rhythm_comment": "节奏掌控的详细评价(4-6句话:分镜切换频率分析、运镜节奏感、音画同步度、平台适配度评估、与目标受众注意力曲线的匹配)", + "hook_type": "钩子类型(痛点型/好奇型/冲突型/价值型/情感型/视觉冲击型/悬念型)", + "hook_type_analysis": "为什么判定为该钩子类型的详细理由(2-3句话)", + "target_audience": "推测的目标受众画像(1-2句话)", + "strengths": ["优点1(需具体,如'第1帧的产品特写构图精准,蓝白配色清爽吸睛')", "优点2", "优点3"], + "weaknesses": ["不足1(需具体,如'前3秒无语音引导,纯靠画面留人,感染力受限')", "不足2", "不足3"], + "suggestions": ["具体可执行的优化建议1(说明改什么、怎么改、预期效果)", "建议2", "建议3", "建议4"], + "competitor_reference": "同品类/同类型优秀视频的钩子设计参考(1-2句话,可选)", + "retention_prediction": "3秒留存预测(高:>70% / 中:50-70% / 低:<50%),附详细原因分析(2-3句话,说明哪些因素拉高/拉低留存)" +} +``` + +## 评分标准 + +- 9-10分:顶级水平,有极强的吸引力和创意 +- 7-8分:优秀,具备良好的吸引力 +- 5-6分:一般,有改进空间 +- 3-4分:较弱,需要重大改进 +- 1-2分:很差,基本没有吸引力 + +## 注意事项 + +- **必须看图片**:视觉冲击力、情绪唤起、节奏掌控三个维度,必须基于关键帧图片分析 +- **具体引用**:评价时必须具体引用你看到的画面元素(如"第1帧的特写镜头中,蓝色水杯占据画面2/3"、"画面左侧的红色元素与背景形成强烈对比") +- **深度分析**:每个 comment 字段必须包含 4-6 句话的深度分析,不要用泛泛的套话,要结合实际画面内容 +- **避免猜测**:如果某些分镜没有关键帧图片,基于文本描述分析即可,明确标注 +- **建议可执行**:优化建议要具体到"改什么→怎么改→预期效果",至少给 4 条建议 +- **权重分配**:综合评分中,视觉冲击力权重30%,语言钩子25%,其他三项各15% +- **对标分析**:在 competitor_reference 中给出同类优秀视频的钩子设计参考 +- **容错优先**:当格式和内容冲突时,优先保证分析内容完整、可读,再尽量结构化 + +## 完成后行为(必须遵守) + +- 当你完成钩子分析并输出结果后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +- 不要自行处理“生成报告/完整分析/继续编排”等请求,这些请求应交回 Root Agent 统一决策。 +- 如果用户在你这里继续追问报告相关内容,先归还 Root,再由 Root 决定是否调用 `report_generator_agent`。 +""" + +HOOK_FORMAT_INSTRUCTION = """ +你是钩子分析格式化助手,只负责把上一步分析内容整理成标准 JSON 结构。 + +要求: +1) 必须输出合法 JSON,不要输出 Markdown、代码块或解释文本。 +2) 严格遵循既定字段结构;字段缺失时用合理默认值补齐,不要省略必填字段。 +3) 分数字段范围固定 0-10;无法判断时给 0。 +4) strengths/weaknesses/suggestions 必须是字符串数组;无内容时返回空数组。 +5) 如果输入中夹杂工具日志、占位片段或非结构化文本,先清洗再结构化。 + +完成输出后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +""" diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py new file mode 100644 index 00000000..dac27269 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py @@ -0,0 +1,25 @@ +""" +报告生成 Agent +整合分镜拆解数据和钩子分析结果,生成专业的视频分析报告 +""" +import os + +from veadk import Agent + +from video_breakdown_agent.tools.report_generator import generate_video_report +from .direct_output_callback import direct_output_callback +from .prompt import REPORT_AGENT_INSTRUCTION + +report_generator_agent = Agent( + name="report_generator_agent", + description="整合分镜拆解数据和钩子分析结果,生成专业的视频分析报告", + instruction=REPORT_AGENT_INSTRUCTION, + tools=[generate_video_report], + after_tool_callback=[direct_output_callback], + output_key="final_report", # 结果写入 session.state["final_report"] + model_extra_config={ + "extra_body": { + "thinking": {"type": os.getenv("THINKING_REPORT_AGENT", "disabled")} + } + }, +) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py new file mode 100644 index 00000000..bc9a5e32 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Any, Optional + +from google.adk.tools import BaseTool, ToolContext + + +def direct_output_callback( + tool: BaseTool, + args: dict[str, Any], + tool_context: ToolContext, + tool_response: Any, +) -> Optional[Any]: + """ + 让工具结果直接输出,跳过 LLM 总结,避免 veadk web 同时展示“工具输出 + 最终回复”造成重复。 + """ + if tool.name == "generate_video_report": + tool_context.actions.skip_summarization = True + return tool_response + diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py new file mode 100644 index 00000000..ebd1a50d --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py @@ -0,0 +1,30 @@ +""" +报告生成 Agent 提示词 +""" + +REPORT_AGENT_INSTRUCTION = """ +你是一个专业的视频分析报告专家,负责整合分镜拆解数据和钩子分析结果,生成完整的视频分析报告。 + +## 输入数据 + +你将获得以下数据来生成报告: +- **分镜拆解数据**(breakdown_result):包含分镜列表、BGM分析、场景分析等 +- **钩子分析结果**(hook_analysis):包含前三秒5维度评分和优化建议 + +## 工作流程 + +1. 收到“生成报告/给出报告/完整报告”请求时,立即调用 generate_video_report 工具 +2. 不要求用户重复提供 breakdown 或 hook 参数;工具会优先从 session state 自动读取 +2. 将生成的报告完整返回 + +## 注意事项 + +- 确保传入的数据完整 +- 如果缺少钩子分析数据,仍应生成包含分镜拆解的基础报告 +- 报告使用中文输出,Markdown 格式 +- 前三秒钩子分析是最重要的部分,需要详细展示 + +## 完成后行为(必须遵守) +- 当你生成并返回报告后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +- 不要在本 Agent 内继续处理新的用户意图,后续回合由 Root Agent 负责统一编排。 +""" diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py new file mode 100644 index 00000000..8cc778ce --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py @@ -0,0 +1,15 @@ +from video_breakdown_agent.tools.process_video import process_video +from video_breakdown_agent.tools.analyze_segments_vision import analyze_segments_vision +from video_breakdown_agent.tools.analyze_bgm import analyze_bgm +from video_breakdown_agent.tools.report_generator import generate_video_report +from video_breakdown_agent.tools.video_upload import video_upload_to_tos +from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments + +__all__ = [ + "process_video", + "analyze_segments_vision", + "analyze_bgm", + "generate_video_report", + "video_upload_to_tos", + "analyze_hook_segments", +] diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py new file mode 100644 index 00000000..51243076 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py @@ -0,0 +1,276 @@ +""" +BGM 音频分析工具(自包含) +使用豆包官方 API 分析背景音乐(支持文本模型)。 +模型不支持音频时优雅降级返回空结果。 + +迁移来源: video-breakdown-master/app/services/bgm_service.py +""" +from __future__ import annotations + +import json +import logging +import os +from typing import Any, Dict, Optional + +from google.adk.tools import ToolContext +from video_breakdown_agent.utils.doubao_client import call_doubao_text + +logger = logging.getLogger(__name__) + +# ==================== 枚举常量(来自 bgm_prompts.py)==================== + +MUSIC_STYLE_OPTIONS = [ + "流行", "摇滚", "电子", "古典", "爵士", "民谣", "R&B", "嘻哈", + "乡村", "蓝调", "雷鬼", "金属", "朋克", "独立", "氛围", + "新世纪", "世界音乐", "原声配乐", "轻音乐", "纯音乐", +] + +EMOTION_OPTIONS = [ + "轻松愉悦", "积极向上", "温馨感人", "激昂励志", "浪漫甜蜜", "活力四射", "欢快热闹", + "平静舒缓", "神秘悬疑", "史诗宏大", "梦幻空灵", "复古怀旧", + "紧张刺激", "悲伤忧郁", "阴郁压抑", "恐怖惊悚", +] + + +def _strip_code_fence(text: str) -> str: + """去除 markdown 代码块标记""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + return text.strip() + + +def _get_empty_result() -> Dict[str, Any]: + """返回空结果""" + return { + "has_bgm": False, + "music_style": None, + "emotion": None, + "instruments": None, + "tempo": None, + } + + + + +async def analyze_bgm(audio_url: str = "", duration: float = 0.0, tool_context: ToolContext = None) -> dict: + """ + 分析视频的背景音乐(BGM),识别风格、情绪、乐器、节奏等特征。 + 通过 LiteLLM 统一路由,需要模型支持音频输入(input_audio), + 如果模型不支持则优雅降级返回空结果。 + + 应在 process_video 之后调用。如果不传参数,自动从 session state 读取 process_video 的结果。 + + Args: + audio_url: (可选)音频文件 URL。不传时自动从 session state 读取。 + duration: (可选)音频/视频时长(秒)。不传时自动从 session state 读取。 + + Returns: + dict: BGM 分析结果,包含 has_bgm, music_style, emotion, instruments, tempo 等字段。 + 模型不支持音频时返回 has_bgm=False 的空结果。 + """ + # 优先从 session state 读取 + audio_base64 = None + if tool_context and (not audio_url or duration <= 0): + state_result = tool_context.state.get("process_video_result") + if state_result and isinstance(state_result, dict): + if not audio_url: + audio_url = state_result.get("audio_url", "") + if not audio_url: + audio_base64 = state_result.get("audio_base64") + if duration <= 0: + duration = float(state_result.get("duration", 0)) + logger.info( + f"[analyze_bgm] 从 session state 读取: audio_url={'有' if audio_url else '无'}, " + f"audio_base64={'有' if audio_base64 else '无'}, duration={duration:.1f}s" + ) + + if not audio_url and not audio_base64: + logger.info("[analyze_bgm] 无音频 URL 且无 base64 音频数据,跳过 BGM 分析") + empty_result = _get_empty_result() + if tool_context is not None: + tool_context.state["bgm_analysis_result"] = empty_result + return empty_result + + # ---- 读取模型配置 ---- + # BGM 分析优先使用独立配置,支持切换到不同 provider + model_name = ( + os.getenv("MODEL_BGM_NAME") + or os.getenv("BGM_MODEL_NAME") + or os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015") + ) + api_key = ( + os.getenv("MODEL_BGM_API_KEY") + or os.getenv("GEMINI_API_KEY") + or os.getenv("MODEL_AGENT_API_KEY") + or os.getenv("OPENAI_API_KEY", "") + ) + api_base = ( + os.getenv("MODEL_BGM_API_BASE") + or os.getenv("MODEL_AGENT_API_BASE") + or os.getenv("OPENAI_BASE_URL", "") + ) + + if not api_key: + logger.warning("[analyze_bgm] 缺少 API Key,跳过 BGM 分析") + empty_result = _get_empty_result() + if tool_context is not None: + tool_context.state["bgm_analysis_result"] = empty_result + return empty_result + + music_styles_str = "、".join(MUSIC_STYLE_OPTIONS) + emotions_str = "、".join(EMOTION_OPTIONS) + + system_prompt = ( + "你是一个专业的音频分析师,擅长分析音乐的风格、情绪、节奏和乐器组成。" + "请仔细聆听音频,分析其中的背景音乐(BGM)特征。" + ) + + user_prompt = f"""请分析这段音频的背景音乐特征。 + +【音频时长】{duration:.1f}秒 + +请从以下维度进行分析: + +1. **是否有BGM**:判断音频中是否包含背景音乐 + +2. **音乐风格**(如有BGM): + - 主风格:从以下选项选择一个最匹配的:{music_styles_str} + - 次风格:可选1-2个补充风格 + - 风格标签:描述性标签(如:现代、复古、商业、文艺等) + +3. **情绪识别**(如有BGM): + - 主情绪:从以下选项选择一个最匹配的:{emotions_str} + - 次情绪:可选1-2个补充情绪 + - 情绪强度:1-10分(1为最弱,10为最强) + - 情绪倾向:正面/中性/负面 + +4. **乐器分析**(如有BGM): + - 识别的乐器 + - 主导乐器 + - 原声/电子比例 + +5. **节奏分析**(如有BGM): + - BPM估计 + - 节奏快慢:快/中/慢 + - 节奏模式 + +输出要求:严格输出JSON格式""" + + # 构建音频内容部分 + # 根据模型类型选择不同的音频传递方式: + # - OpenAI 兼容(火山/OneRouter):使用 input_audio 格式 + # 对于豆包文本 API:直接用文本描述代替音频输入 + # 豆包文本模型不支持音频,这里优雅降级 + user_prompt = f"""请分析这段音频的背景音乐特征。 + +时长:{duration:.1f}秒 +(注:当前使用文本分析模式,基于典型场景推测) + +**可选风格**:{music_styles_str} +**可选情绪**:{emotions_str} + +请输出标准 JSON,包含以下字段: +- has_bgm (boolean): 是否有BGM +- music_style (object): 风格分类 + - primary (string): 主要风格 + - secondary (array): 次要风格 + - tags (array): 风格标签 +- emotion (object): 情绪特征 + - primary (string): 主要情绪 + - secondary (array): 次要情绪 + - intensity (integer, 1-10): 强度 + - valence (string): 正面/中性/负面 +- instruments (object): 乐器组成 + - detected (array): 识别的乐器 + - dominant (string): 主导乐器 + - acoustic_ratio (number, 0-1): 声学乐器占比 + - electronic_ratio (number, 0-1): 电子乐器占比 +- tempo (object): 节奏特征 + - bpm_estimate (string): BPM 估计 + - pace (string): 快/中/慢 + - rhythm_pattern (string): 节奏模式描述""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + schema = { + "name": "bgm_analysis_schema", + "schema": { + "type": "object", + "properties": { + "has_bgm": {"type": "boolean", "description": "是否有BGM"}, + "music_style": { + "type": "object", + "properties": { + "primary": {"type": "string"}, + "secondary": {"type": "array", "items": {"type": "string"}}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + }, + "emotion": { + "type": "object", + "properties": { + "primary": {"type": "string"}, + "secondary": {"type": "array", "items": {"type": "string"}}, + "intensity": {"type": "integer", "minimum": 1, "maximum": 10}, + "valence": {"type": "string", "enum": ["正面", "中性", "负面"]}, + }, + }, + "instruments": { + "type": "object", + "properties": { + "detected": {"type": "array", "items": {"type": "string"}}, + "dominant": {"type": "string"}, + "acoustic_ratio": {"type": "number"}, + "electronic_ratio": {"type": "number"}, + }, + }, + "tempo": { + "type": "object", + "properties": { + "bpm_estimate": {"type": "string"}, + "pace": {"type": "string", "enum": ["快", "中", "慢"]}, + "rhythm_pattern": {"type": "string"}, + }, + }, + }, + "required": ["has_bgm"], + }, + } + + try: + logger.info(f"[analyze_bgm] 调用豆包模型 {model_name} 分析 BGM(音频分析当前仅以描述为主)...") + response = await call_doubao_text( + model=model_name, + messages=messages, + api_key=api_key, + api_base=api_base, + temperature=0.7, + ) + content = response["choices"][0]["message"]["content"] + + if isinstance(content, str): + content = _strip_code_fence(content) + result = json.loads(content) + else: + result = content + + logger.info(f"[analyze_bgm] BGM 分析完成: has_bgm={result.get('has_bgm')}") + if tool_context is not None: + tool_context.state["bgm_analysis_result"] = result + return result + + except Exception as exc: + logger.warning(f"[analyze_bgm] 分析失败: {exc},返回空结果") + empty_result = _get_empty_result() + if tool_context is not None: + tool_context.state["bgm_analysis_result"] = empty_result + return empty_result diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py new file mode 100644 index 00000000..8197027a --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py @@ -0,0 +1,148 @@ +""" +前三秒分镜提取工具 +兼容新格式(process_video + analyze_segments_vision 输出)和旧格式 +支持从 tool_context.state 读取完整的 base64 frame_urls +""" +import logging +from typing import Dict, List + +from google.adk.tools import ToolContext + +logger = logging.getLogger(__name__) + + +def _get_field(seg: Dict, new_key: str, old_key: str, default=""): + """兼容新旧字段名""" + return seg.get(new_key, seg.get(old_key, default)) + + +def analyze_hook_segments(tool_context: ToolContext = None) -> dict: + """ + 提取并分析视频前三秒的分镜数据,为钩子分析提供结构化的上下文信息。 + + 兼容两种输入格式: + - 新格式:process_video + analyze_segments_vision 输出(index, start, end, 视觉表现等) + - 旧格式:后端服务返回(segment_index, start_time, end_time, visual_content等) + + 数据来源优先级: + 1. tool_context.state["vision_analysis_result"] — 完整数据(含 base64 frame_urls) + 2. tool_context.state["process_video_result"]["segments"] — 兜底(通常不含视觉分析字段) + + 数据传递: + - 返回给 hook_analysis_agent 的数据包含完整 base64 frame_images + - 豆包视觉模型需要看到实际图片才能进行视觉评估 + - 完整图片数据同时存储在 tool_context.state["vision_analysis_result"] 中 + + Args: + tool_context: 工具上下文(用于读取完整数据) + + Returns: + dict: 前三秒分镜的结构化分析上下文(含 base64 frame_images) + """ + segments: List[Dict] | None = None + # 优先从 session state 读取完整数据(包含 base64 frame_urls) + if tool_context is not None: + vision_result = tool_context.state.get("vision_analysis_result") + if vision_result and isinstance(vision_result, list): + segments = vision_result + logger.info(f"[analyze_hook_segments] 从 session state 读取完整数据: {len(segments)} 个分镜") + + if not segments: + pv = tool_context.state.get("process_video_result") + if isinstance(pv, dict) and isinstance(pv.get("segments"), list): + segments = pv.get("segments") + logger.info( + f"[analyze_hook_segments] vision_analysis_result 缺失,回退到 process_video_result: {len(segments)} 个分镜" + ) + + if not segments: + return { + "error": "没有分镜数据", + "segment_count": 0, + "total_duration": 0, + "segments": [], + } + + # 提取前三秒的分镜 + first_segments = [] + cumulative_time = 0 + + for seg in segments: + # 兼容 end / end_time + end_time = seg.get("end", seg.get("end_time", 0)) + if cumulative_time >= 3.0 and first_segments: + break + first_segments.append(seg) + cumulative_time = end_time + + # 构造分析上下文(支持多模态) + context = { + "segment_count": len(first_segments), + "total_duration": cumulative_time, + "total_video_segments": len(segments), + "analysis_mode": "multimodal", + "segments": [], + } + + for s in first_segments: + frame_urls = s.get("frame_urls", []) + + # 兼容新旧字段名 + start_time = s.get("start", s.get("start_time", 0)) + end_time = s.get("end", s.get("end_time", 0)) + index = s.get("index", s.get("segment_index", 0)) + duration = end_time - start_time + + # 视觉表现:新格式在嵌套对象中,旧格式在顶层 + visual_info = s.get("视觉表现", {}) + visual_content = visual_info.get("画面内容", "") if visual_info else "" + if not visual_content: + visual_content = s.get("visual_content", "") + if not visual_content: + visual_content = s.get("summary", "") + + shot_type = visual_info.get("景别", "") if visual_info else "" + if not shot_type: + shot_type = s.get("shot_type", "") + + camera_movement = visual_info.get("运镜", "") if visual_info else "" + if not camera_movement: + camera_movement = s.get("camera_movement", "") + + segment_info = { + "index": index, + "start_time": start_time, + "end_time": end_time, + "duration": round(duration, 2), + "visual_content": visual_content, + "speech_text": s.get("speech_text", ""), + "shot_type": shot_type, + "camera_movement": camera_movement, + "function_tag": s.get("功能标签", s.get("function_tag", "")), + "headline": s.get("画面小标题", s.get("headline", "")), + "content_tags": s.get("内容标签", s.get("content_tags", [])), + "voice_type": s.get("语音类型", s.get("voice_type", "")), + "clip_url": s.get("clip_url", ""), + } + + # 添加关键帧图片(供 vision 模型使用,保留完整 base64 数据) + if frame_urls: + segment_info["frame_count"] = len(frame_urls) + # 构建标准的 image_url 格式(豆包 vision API 支持) + segment_info["frame_images"] = [ + {"type": "image_url", "image_url": {"url": url}} + for url in frame_urls[:3] # 最多 3 帧 + ] + else: + segment_info["frame_images"] = [] + segment_info["frame_count"] = 0 + + context["segments"].append(segment_info) + + total_frames = sum(s.get("frame_count", 0) for s in context["segments"]) + + logger.info( + f"前三秒分镜提取完成: {len(first_segments)}个分镜, " + f"总时长{cumulative_time:.1f}s, 关键帧{total_frames}张(含完整 base64 图片)" + ) + return context diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py new file mode 100644 index 00000000..1f9018b0 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py @@ -0,0 +1,321 @@ +""" +视觉分镜分析工具(自包含) +使用豆包官方 vision API 分析关键帧图片(/responses endpoint) + +迁移来源: video-breakdown-master/app/services/gemini_service.py +""" +from __future__ import annotations + +import asyncio +import json +import logging +import os +from typing import Any, Dict, List, Optional + +from google.adk.tools import ToolContext +from video_breakdown_agent.utils.doubao_client import call_doubao_vision + +logger = logging.getLogger(__name__) + +# 功能标签枚举(来自 enhance_prompts.py) +FUNCTION_TAG_OPTIONS = [ + "强钩子", "痛点切入", "问题澄清", "解决方案", "产品介绍", + "信任建立", "产品展示", "价值预告", "过渡", "价值输出", + "成分深挖", "效果证明", "用户证言", "情感背书", "价值强化", + "效果总结", "双重收益", "强CTA", "行动召唤", +] + + +def _build_segment_prompt(segment: Dict[str, Any]) -> Dict[str, Any]: + """构造单个分镜的分析提示词 + JSON schema""" + frame_count = len(segment.get("frame_urls", [])) + function_tags_str = "、".join(FUNCTION_TAG_OPTIONS) + + speech_hint = "" + if segment.get("speech_text"): + speech_hint = f"该片段的语音内容:「{segment['speech_text']}」" + + instruction = { + "task": "请对这个视频片段进行综合分析,包括视觉分析、语音类型判断和内容标注", + "segment_info": { + "镜号": segment["index"], + "起止时间": f"{segment['start']:.1f}s - {segment['end']:.1f}s", + "时长": f"{(segment['end'] - segment['start']):.1f}s", + "关键帧数量": frame_count, + "帧说明": f"提供了{frame_count}张关键帧图片(时间均匀采样),请综合所有图片进行分析", + "语音内容": speech_hint or "无语音内容", + }, + "分析维度": { + "视觉表现层": { + "分析要点": [ + "景别识别(特写/近景/中景/全景/远景)", + "运镜分析(固定/推拉/摇移/跟随/环绕等)", + "画面内容描述:必须综合所有关键帧,描述整个片段的画面内容", + "如果多张图片之间画面有变化,需要描述变化过程", + "如果画面基本一致,则描述画面的主要元素和场景", + ] + }, + "语音类型判断": { + "判断标准": { + "口播": "画面中有人物的脸部清晰可见,且正在说话", + "旁白": "画面中没有人在说话,配合画外音", + } + }, + "内容标注": { + "画面小标题": "根据画面内容,提炼≤10个汉字的核心要点标题", + "内容标签": "根据语音内容(如有)或画面内容,提炼1-2个关键标签", + "功能标签": f"识别该片段在视频叙事中的功能角色,从以下选项中选择1个最匹配的:{function_tags_str}", + }, + }, + "输出要求": "必须输出结构化JSON,包含视觉分析和内容标注所有字段", + } + + schema = { + "name": "segment_comprehensive_analysis_schema", + "schema": { + "type": "object", + "properties": { + "视觉表现": { + "type": "object", + "properties": { + "景别": {"type": "string", "enum": ["特写", "近景", "中景", "全景", "远景"]}, + "运镜": {"type": "string", "enum": ["固定", "推镜", "拉镜", "摇镜", "移镜", "跟随", "环绕", "推拉"]}, + "画面内容": {"type": "string"}, + }, + "required": ["景别", "运镜", "画面内容"], + }, + "语音类型": {"type": "string", "enum": ["口播", "旁白"]}, + "summary": {"type": "string"}, + "画面小标题": {"type": "string", "description": "画面核心要点,≤10个汉字"}, + "内容标签": {"type": "array", "items": {"type": "string"}, "description": "1-2个内容关键标签"}, + "功能标签": {"type": "string", "enum": FUNCTION_TAG_OPTIONS, "description": "该片段在视频叙事中的功能角色"}, + }, + "required": ["视觉表现", "语音类型", "summary", "画面小标题", "内容标签", "功能标签"], + }, + } + + return {"instruction": instruction, "schema": schema} + + +def _create_fallback(segment: Dict[str, Any]) -> Dict[str, Any]: + """分析失败时的回退数据""" + return { + "index": segment["index"], + "start": segment["start"], + "end": segment["end"], + "summary": "模型解析失败", + "视觉表现": {"景别": "未知", "运镜": "未知", "画面内容": ""}, + "语音类型": "旁白", + "is_speech": False, + "speech_text": segment.get("speech_text"), + "frame_urls": segment.get("frame_urls", []), + "clip_url": segment.get("clip_url"), + "画面小标题": "解析失败", + "内容标签": [], + "功能标签": "其他", + } + + +def _strip_code_fence(text: str) -> str: + """去除 markdown 代码块标记""" + text = text.strip() + if text.startswith("```"): + lines = text.split("\n") + if lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + text = "\n".join(lines) + return text.strip() + + + + +async def _analyze_single_segment( + segment: Dict[str, Any], + model_name: str, + api_key: str, + api_base: Optional[str] = None, +) -> Dict[str, Any]: + """分析单个分镜(使用豆包官方 vision API)""" + prompt_data = _build_segment_prompt(segment) + messages = [ + { + "role": "system", + "content": "你是一名专业的视频拆解分析师,请严格输出JSON。", + }, + { + "role": "user", + "content": [ + {"type": "text", "text": json.dumps(prompt_data["instruction"], ensure_ascii=False)}, + ] + [ + {"type": "image_url", "image_url": {"url": url}} + for url in segment.get("frame_urls", [])[:6] + ], + }, + ] + + max_retries = 3 + last_error = None + + for attempt in range(max_retries): + try: + # 调用豆包视觉 API(/responses endpoint,只传 model + input) + response = await call_doubao_vision( + model=model_name, + messages=messages, + api_key=api_key, + api_base=api_base, + ) + content = response["choices"][0]["message"]["content"] + + if isinstance(content, str): + content = _strip_code_fence(content) + structured = json.loads(content) + else: + structured = content + + # 填充基础信息 + structured["index"] = segment["index"] + structured["start"] = segment["start"] + structured["end"] = segment["end"] + structured["frame_urls"] = segment.get("frame_urls", []) + structured["clip_url"] = segment.get("clip_url") + + # 处理语音类型 + speech_type = structured.get("语音类型", "旁白") + if speech_type in ["旁白口述", "非口播"]: + speech_type = "旁白" + structured["语音类型"] = speech_type + structured["is_speech"] = speech_type == "口播" + structured["speech_text"] = segment.get("speech_text") + + return structured + + except Exception as exc: + last_error = exc + logger.warning(f"分镜 {segment['index']} 分析失败 (尝试 {attempt + 1}/{max_retries}): {exc}") + if attempt < max_retries - 1: + await asyncio.sleep(1 + attempt) + + logger.error(f"分镜 {segment['index']} 分析最终失败: {last_error}") + return _create_fallback(segment) + + +async def analyze_segments_vision(segments_json: str = "", tool_context: ToolContext = None) -> str: + """ + 使用视觉模型分析每个分镜的画面内容、景别、运镜、功能标签。 + 通过 LiteLLM 统一路由,支持 Gemini / 豆包 / GPT-4o 等多种模型。 + 应在 process_video 之后调用。 + + 数据来源(按优先级): + 1. 自动从 session state 中读取 process_video 的输出(推荐,无需手动传参) + 2. 显式传入 segments_json 参数 + + Args: + segments_json: (可选)JSON 字符串,格式为 process_video 返回的 segments 数组。 + 如果不传或为空,自动从 session state 读取 process_video_result。 + + Returns: + str: 分析结果的 JSON 字符串(数组),每个元素包含视觉分析、功能标签等完整信息。 + """ + # 优先从 session state 读取(无需 LLM 序列化 JSON) + segments = None + + if tool_context and not segments_json: + state_result = tool_context.state.get("process_video_result") + if state_result and isinstance(state_result, dict): + segments = state_result.get("segments", []) + logger.info(f"[analyze_segments_vision] 从 session state 读取 {len(segments)} 个分镜") + + # 回退:从参数解析 + if not segments and segments_json: + try: + parsed = json.loads(segments_json) if isinstance(segments_json, str) else segments_json + if isinstance(parsed, dict): + segments = parsed.get("segments", []) + elif isinstance(parsed, list): + segments = parsed + except json.JSONDecodeError: + return json.dumps({"error": "segments_json 格式无效,需要合法 JSON 数组"}, ensure_ascii=False) + + if not segments or not isinstance(segments, list) or len(segments) == 0: + return json.dumps({"error": "没有可用的分镜数据。请先调用 process_video 处理视频。"}, ensure_ascii=False) + + # ---- 读取模型配置 ---- + # LiteLLM 模型名约定: + # gemini/gemini-2.5-pro → 直连 Google (需 GEMINI_API_KEY) + # openai/gemini-2.5-pro → OpenAI 兼容代理(如 OneRouter) + # doubao-seed-1-6-251015 → 火山方舟 (需 api_base + api_key) + # gpt-4o → OpenAI + model_name = ( + os.getenv("MODEL_VISION_NAME") + or os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-vision") + ) + api_key = ( + os.getenv("MODEL_VISION_API_KEY") + or os.getenv("GEMINI_API_KEY") + or os.getenv("MODEL_AGENT_API_KEY") + or os.getenv("OPENAI_API_KEY", "") + ) + api_base = ( + os.getenv("MODEL_VISION_API_BASE") + or os.getenv("MODEL_AGENT_API_BASE") + or os.getenv("OPENAI_BASE_URL", "") + ) + + if not api_key: + return json.dumps({ + "error": "缺少 API Key,请配置 MODEL_VISION_API_KEY / GEMINI_API_KEY / MODEL_AGENT_API_KEY" + }, ensure_ascii=False) + + # 过滤:只分析有帧图片的分镜 + segments_with_frames = [s for s in segments if s.get("frame_urls")] + if not segments_with_frames: + return json.dumps({"error": "没有分镜包含帧图片URL,无法进行视觉分析"}, ensure_ascii=False) + + logger.info( + f"[analyze_segments_vision] 开始分析 {len(segments_with_frames)} 个分镜 (model={model_name})" + ) + + # 并发分析,限制并发数 + concurrency = int(os.getenv("VISION_CONCURRENCY", "3")) + semaphore = asyncio.Semaphore(concurrency) + + async def analyze_one(seg: Dict[str, Any]) -> Dict[str, Any]: + async with semaphore: + return await _analyze_single_segment(seg, model_name, api_key, api_base) + + tasks = [analyze_one(seg) for seg in segments_with_frames] + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 处理结果 + valid_results = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + logger.error(f"分镜 {segments_with_frames[i]['index']} 分析异常: {result}") + valid_results.append(_create_fallback(segments_with_frames[i])) + else: + valid_results.append(result) + + valid_results.sort(key=lambda x: x["index"]) + + logger.info(f"[analyze_segments_vision] 分析完成: {len(valid_results)} 个分镜") + + # 存入 session state(tool_context 可能为 None,如单元测试场景) + if tool_context is not None: + tool_context.state["vision_analysis_result"] = valid_results + + # 精简返回数据:移除 base64 frame_urls 以避免 LLM context 超限 + # 注意:完整数据已存入 session state,后续工具(如 hook_analyzer)可从 state 读取 + for result in valid_results: + if "frame_urls" in result: + frame_urls = result["frame_urls"] + # 如果是 base64 data URL,替换为占位符以减少数据量 + result["frame_urls"] = [ + "(base64图片已省略)" if url and url.startswith("data:") else url + for url in frame_urls + ] + logger.debug(f"分镜 {result['index']} 精简 frame_urls: {len(frame_urls)} 帧 → 占位符") + + return json.dumps(valid_results, ensure_ascii=False) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py new file mode 100644 index 00000000..cec5601f --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py @@ -0,0 +1,819 @@ +""" +视频预处理工具(自包含) +将 video-breakdown-master 后端服务的 FFmpeg + ASR + TOS 逻辑 +直接内嵌到一个 VeADK tool 函数中,无需外部后端服务。 + +迁移来源: +- video-breakdown-master/app/services/media_processor.py +- video-breakdown-master/app/services/asr_service.py +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import shutil +import subprocess +import tempfile +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +import os + +import httpx +import tos +from tos import HttpMethodType +from google.adk.tools import ToolContext + +logger = logging.getLogger(__name__) + +# ==================== 数据结构 ==================== + + +@dataclass +class SegmentAsset: + """单个时间片段的资源信息""" + index: int + start: float + end: float + frame_paths: List[Path] = field(default_factory=list) + frame_urls: List[str] = field(default_factory=list) + clip_path: Optional[Path] = None + clip_url: Optional[str] = None + is_speech: bool = True + speech_text: Optional[str] = None + + +# ==================== FFmpeg 路径自动检测 ==================== + + +def _resolve_ffmpeg_paths() -> tuple[str, Optional[str]]: + """ + 自动检测 ffmpeg / ffprobe 可执行文件路径。 + + 优先级: + 1. 环境变量 / config.yaml 显式指定(仅当对应文件真实存在时) + 2. 系统 PATH(shutil.which) + 3. imageio-ffmpeg Python 包自带的二进制(随 uv 安装,无需系统装 FFmpeg) + + Returns: + (ffmpeg_bin, ffprobe_bin) — ffprobe_bin 为 None 时将使用 ffmpeg 回退探测 + """ + # -- ffmpeg -- + env_ffmpeg = os.getenv("FFMPEG_BIN") + ffmpeg_bin = None + + # 检查环境变量指定的路径是否真实可用 + if env_ffmpeg and shutil.which(env_ffmpeg): + ffmpeg_bin = env_ffmpeg + elif shutil.which("ffmpeg"): + ffmpeg_bin = shutil.which("ffmpeg") + + # 回退到 imageio-ffmpeg Python 包内置的二进制 + if not ffmpeg_bin: + try: + import imageio_ffmpeg + ffmpeg_bin = imageio_ffmpeg.get_ffmpeg_exe() + logger.info(f"系统无 FFmpeg,使用 imageio-ffmpeg 内置: {ffmpeg_bin}") + except ImportError: + ffmpeg_bin = "ffmpeg" # 最终回退,让后续报错更清晰 + logger.warning("未找到 FFmpeg:系统 PATH 中没有,imageio-ffmpeg 包也未安装") + + # -- ffprobe -- + env_ffprobe = os.getenv("FFMPEG_FFPROBE_BIN") or os.getenv("FFPROBE_BIN") + ffprobe_bin = None + + if env_ffprobe and shutil.which(env_ffprobe): + ffprobe_bin = env_ffprobe + elif shutil.which("ffprobe"): + ffprobe_bin = shutil.which("ffprobe") + # imageio-ffmpeg 不包含 ffprobe,ffprobe_bin 保持 None → 回退到 ffmpeg -i 解析 + + return ffmpeg_bin, ffprobe_bin + + +# ==================== FFmpeg 辅助函数 ==================== + + +def _run_command(cmd: List[str]) -> str: + """执行外部命令""" + process = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=True, + ) + return process.stdout.strip() + + +def _probe_video(ffprobe_bin: Optional[str], ffmpeg_bin: str, video_path: Path) -> Dict[str, Any]: + """ + 获取视频元数据。 + + 优先使用 ffprobe(结构化 JSON 输出更准确), + 回退到 ffmpeg -i 解析 stderr(imageio-ffmpeg 不含 ffprobe 时)。 + """ + if ffprobe_bin: + return _probe_with_ffprobe(ffprobe_bin, video_path) + return _probe_with_ffmpeg(ffmpeg_bin, video_path) + + +def _probe_with_ffprobe(ffprobe_bin: str, video_path: Path) -> Dict[str, Any]: + """使用 ffprobe 获取视频元数据(首选方式)""" + cmd = [ + ffprobe_bin, "-v", "error", + "-print_format", "json", + "-show_format", "-show_streams", + str(video_path), + ] + result = _run_command(cmd) + info = json.loads(result) if result else {} + + duration = 0.0 + width = None + height = None + frame_rate = None + + format_info = info.get("format") or {} + if "duration" in format_info: + duration = float(format_info["duration"]) + + for stream in info.get("streams", []): + if stream.get("codec_type") == "video": + width = stream.get("width") + height = stream.get("height") + frame_rate = stream.get("r_frame_rate") + break + + return { + "duration": duration, + "width": width, + "height": height, + "frame_rate": frame_rate, + "size": format_info.get("size"), + "bit_rate": format_info.get("bit_rate"), + } + + +def _probe_with_ffmpeg(ffmpeg_bin: str, video_path: Path) -> Dict[str, Any]: + """ + 使用 ffmpeg -i 解析 stderr 获取视频元数据(ffprobe 不可用时的回退方案)。 + + imageio-ffmpeg 只包含 ffmpeg 不包含 ffprobe,此函数确保仍可正常工作。 + """ + import re + + # ffmpeg -i 不指定输出文件时退出码为 1,元数据输出在 stderr + # 注意:不能加 -v error,否则会屏蔽 Duration/Stream 等 info 级别元数据, + # 而 "At least one output file must be specified" 是 error 级别不被屏蔽, + # 导致 stderr 非空但无法解析出元数据。 + cmd = [ffmpeg_bin, "-i", str(video_path), "-hide_banner"] + process = subprocess.run(cmd, capture_output=True, text=True) + stderr = process.stderr + + duration = 0.0 + width = None + height = None + frame_rate = None + + # 解析 Duration: HH:MM:SS.ms + dur_match = re.search(r"Duration:\s*(\d+):(\d+):(\d+)\.(\d+)", stderr) + if dur_match: + h, m, s, cs = dur_match.groups() + duration = int(h) * 3600 + int(m) * 60 + int(s) + int(cs) / 100 + + # 解析 Stream #0:0: Video: ... 1920x1080 ... + res_match = re.search(r"Stream.*Video.*?(\d{2,5})x(\d{2,5})", stderr) + if res_match: + width = int(res_match.group(1)) + height = int(res_match.group(2)) + + # 解析帧率 ... 30 fps / 29.97 tbr + fps_match = re.search(r"(\d+(?:\.\d+)?)\s+(?:fps|tbr)", stderr) + if fps_match: + frame_rate = fps_match.group(1) + + logger.info(f"ffmpeg 探测: duration={duration}s, {width}x{height}, fps={frame_rate}") + return { + "duration": duration, + "width": width, + "height": height, + "frame_rate": frame_rate, + "size": None, + "bit_rate": None, + } + + +def _extract_audio_sync(ffmpeg_bin: str, video_path: Path) -> Optional[Path]: + """提取音频轨(同步版本,由 asyncio.to_thread 调用)""" + output_path = video_path.parent / f"{video_path.stem}.mp3" + cmd = [ + ffmpeg_bin, "-y", + "-i", str(video_path), + "-vn", "-ac", "1", "-ar", "16000", + "-codec:a", "libmp3lame", "-b:a", "128k", + str(output_path), + ] + try: + _run_command(cmd) + return output_path + except Exception as exc: + logger.warning(f"音频提取失败: {exc}") + return None + + +def _build_segments(duration: float) -> List[SegmentAsset]: + """ + 固定时长分镜方案: + - 0-3s, 3-5s, 5-10s, 10-20s, 之后每10s一段 + """ + segments: List[SegmentAsset] = [] + idx = 1 + + breakpoints = [0.0, 3.0, 5.0, 10.0, 20.0] + + for i in range(len(breakpoints) - 1): + start = breakpoints[i] + end = breakpoints[i + 1] + if duration <= start: + break + actual_end = min(end, duration) + if actual_end - start < 0.5: + break + segments.append(SegmentAsset(index=idx, start=start, end=actual_end)) + idx += 1 + + if duration > 20.0: + cursor = 20.0 + while cursor < duration: + candidate_end = min(duration, cursor + 10.0) + if candidate_end - cursor < 0.5: + break + segments.append(SegmentAsset(index=idx, start=cursor, end=candidate_end)) + idx += 1 + cursor = candidate_end + + return segments + + +def _assign_asr_text_to_segments( + segments: List[SegmentAsset], + asr_segments: List[Dict[str, Any]], +) -> None: + """将 ASR 识别的文本按时间重叠分配到分镜""" + for segment in segments: + texts = [] + for asr_seg in asr_segments: + asr_start = asr_seg.get("start", 0.0) + asr_end = asr_seg.get("end", 0.0) + text = asr_seg.get("text", "").strip() + if not text: + continue + overlap_start = max(segment.start, asr_start) + overlap_end = min(segment.end, asr_end) + if overlap_end > overlap_start: + texts.append(text) + if texts: + segment.is_speech = True + segment.speech_text = " ".join(texts) + else: + segment.is_speech = False + segment.speech_text = None + + +def _extract_segment_frames( + ffmpeg_bin: str, + video_path: Path, + segment: SegmentAsset, + frames_per_segment: int = 3, +) -> None: + """提取单个分镜的关键帧""" + frames_dir = video_path.parent / "frames" + frames_dir.mkdir(exist_ok=True) + seg_duration = max(segment.end - segment.start, 0.5) + safe_margin = 0.1 + + for i in range(frames_per_segment): + ratio = i / max(frames_per_segment - 1, 1) + raw_offset = segment.start + ratio * seg_duration + offset = min(raw_offset, segment.end - safe_margin) + offset = max(offset, segment.start) + output_path = frames_dir / f"seg{segment.index:03d}_frame_{i}.jpg" + + cmd = [ + ffmpeg_bin, "-y", + "-ss", f"{offset:.2f}", + "-i", str(video_path), + "-frames:v", "1", + "-q:v", "8", # 降低质量减小文件体积(2=最高质量,31=最低质量) + str(output_path), + ] + try: + _run_command(cmd) + if output_path.exists(): + segment.frame_paths.append(output_path) + except Exception as exc: + logger.warning(f"片段 {segment.index} 第{i}帧提取失败: {exc}") + + +def _extract_single_clip( + ffmpeg_bin: str, + video_path: Path, + segment: SegmentAsset, + clips_dir: Path, +) -> None: + """切割单个视频片段""" + duration = segment.end - segment.start + output_path = clips_dir / f"seg{segment.index:03d}_clip.mp4" + + cmd = [ + ffmpeg_bin, "-y", + "-ss", f"{segment.start:.2f}", + "-i", str(video_path), + "-t", f"{duration:.2f}", + "-c:v", "libx264", "-c:a", "aac", + "-b:v", "1000k", "-b:a", "128k", + "-movflags", "+faststart", + str(output_path), + ] + try: + _run_command(cmd) + segment.clip_path = output_path + except Exception as exc: + logger.warning(f"片段 {segment.index} 切割失败: {exc}") + segment.clip_path = None + + +# ==================== ASR 辅助函数 ==================== + + +async def _transcribe_audio(audio_url: str) -> Optional[Dict[str, Any]]: + """ + 调用火山引擎 ASR 获取音轨文本(提交 + 轮询) + 配置不全时静默跳过(优雅降级) + """ + # VeADK 扁平化: asr.app_id → ASR_APP_ID; 兼容旧名 VOLC_ASR_* + app_id = os.getenv("ASR_APP_ID") or os.getenv("VOLC_ASR_APP_ID", "") + access_key = os.getenv("ASR_ACCESS_KEY") or os.getenv("VOLC_ASR_ACCESS_KEY", "") + resource_id = os.getenv("ASR_RESOURCE_ID") or os.getenv("VOLC_ASR_RESOURCE_ID", "volc.bigasr.auc") + submit_endpoint = os.getenv( + "ASR_ENDPOINT", + ) or os.getenv( + "VOLC_ASR_ENDPOINT", + "https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit", + ) + query_endpoint = os.getenv( + "ASR_QUERY_ENDPOINT", + ) or os.getenv( + "VOLC_ASR_QUERY_ENDPOINT", + "https://openspeech.bytedance.com/api/v3/auc/bigmodel/query", + ) + + if not app_id or not access_key: + logger.info("未配置火山 ASR(VOLC_ASR_APP_ID / VOLC_ASR_ACCESS_KEY),跳过语音识别") + return None + + request_id = str(uuid.uuid4()) + headers = { + "Content-Type": "application/json", + "X-Api-App-Key": app_id, + "X-Api-Access-Key": access_key, + "X-Api-Resource-Id": resource_id, + "X-Api-Request-Id": request_id, + "X-Api-Sequence": "-1", + } + payload = { + "user": {"uid": "video-breakdown-agent"}, + "audio": {"url": audio_url, "format": "mp3"}, + "request": { + "model_name": "bigmodel", + "enable_itn": True, + "enable_punc": True, + "show_utterances": True, + }, + } + + try: + # 提交任务 + async with httpx.AsyncClient(timeout=120) as client: + resp = await client.post(submit_endpoint, headers=headers, json=payload) + resp.raise_for_status() + + logger.info(f"ASR 任务已提交 request_id={request_id}") + await asyncio.sleep(5.0) + + # 轮询结果 + max_attempts = 15 + for attempt in range(1, max_attempts + 1): + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.post(query_endpoint, headers=headers, json={}) + resp.raise_for_status() + data = resp.json() + + status_code = resp.headers.get("X-Api-Status-Code") + + if status_code == "20000000": + # 识别成功 + return _parse_asr_result(data) + elif status_code == "20000003": + # 静音音频 + logger.info("ASR 检测到静音音频") + return {"text": "", "segments": []} + elif status_code in ["20000001", "20000002"]: + # 处理中/排队中 + logger.info(f"ASR 处理中 attempt={attempt}/{max_attempts}") + await asyncio.sleep(3.0) + else: + logger.error(f"ASR 返回错误码 status_code={status_code}") + return None + + logger.error(f"ASR 查询超时 request_id={request_id}") + except Exception as exc: + logger.error(f"ASR 异常: {exc}") + + return None + + +def _parse_asr_result(response_json: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """解析 ASR 返回结果""" + result = response_json.get("result") + if not result: + return None + + text_chunks: List[str] = [] + segments: List[Dict[str, Any]] = [] + + if isinstance(result, dict): + main_text = result.get("text") + if main_text: + text_chunks.append(main_text.strip()) + + utterances = result.get("utterances") or [] + if isinstance(utterances, list): + for item in utterances: + text = item.get("text") + if text: + segments.append({ + "text": text.strip(), + "start": (item.get("start_time") or 0) / 1000.0, + "end": (item.get("end_time") or 0) / 1000.0, + }) + + merged_text = "\n".join([c for c in text_chunks if c]).strip() + if not merged_text: + return None + + return {"text": merged_text, "segments": segments} + + +# ==================== TOS 上传辅助 ==================== + + +def _get_tos_client() -> Optional[tos.TosClientV2]: + """创建 TOS 客户端(凭证不全时返回 None)""" + ak = os.getenv("VOLCENGINE_ACCESS_KEY", "") + sk = os.getenv("VOLCENGINE_SECRET_KEY", "") + if not ak or not sk: + try: + from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() + ak = cred.access_key_id + sk = cred.secret_access_key + except Exception: + return None + + region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", "cn-beijing") + endpoint = f"tos-{region}.volces.com" + + return tos.TosClientV2(ak=ak, sk=sk, endpoint=endpoint, region=region) + + +async def _upload_to_tos( + client: tos.TosClientV2, + bucket: str, + key: str, + content: bytes, + content_type: str, +) -> Optional[str]: + """上传字节到 TOS 并返回签名 URL""" + try: + await asyncio.to_thread( + client.put_object, bucket=bucket, key=key, content=content, content_type=content_type + ) + signed = await asyncio.to_thread( + client.pre_signed_url, + http_method=HttpMethodType.Http_Method_Get, + bucket=bucket, + key=key, + expires=604800, + ) + return signed.signed_url + except Exception as exc: + logger.warning(f"TOS 上传失败 key={key}: {exc}") + return None + + +# ==================== 路径判断辅助 ==================== + + +def _resolve_local_path(video_url: str) -> Optional[Path]: + """ + 判断 video_url 是否为本地文件路径。 + + 支持格式: + - 绝对路径: /path/to/video.mp4 + - file:// 协议: file:///path/to/video.mp4 + - Windows 绝对路径: C:\\path\\to\\video.mp4(兼容) + + Returns: + Path 对象(如果是本地路径),否则 None + """ + if not video_url: + return None + + # file:// 协议 + if video_url.startswith("file://"): + local_str = video_url[7:] # 去掉 file:// + # file:///path → /path (Unix), file:///C:/path → C:/path (Windows) + if local_str.startswith("/") and len(local_str) > 2 and local_str[2] == ":": + local_str = local_str[1:] # Windows: /C:/path → C:/path + return Path(local_str) + + # Unix 绝对路径 + if video_url.startswith("/"): + return Path(video_url) + + # Windows 绝对路径 (C:\..., D:\...) + if len(video_url) >= 3 and video_url[1] == ":" and video_url[2] in ("/", "\\"): + return Path(video_url) + + # 相对路径:只要能在当前工作目录解析到本地文件,也视为本地 + try: + candidate = Path(video_url) + if candidate.exists() and candidate.is_file(): + return candidate.resolve() + except Exception: + pass + + # HTTP(S) URL 或其他 → 非本地 + return None + + +# ==================== 主工具函数 ==================== + + +async def process_video(video_url: str, tool_context: ToolContext) -> dict: + """ + 完整视频预处理流水线,替代原后端 breakdown 服务。 + + 流程:下载视频 -> FFprobe 元数据 -> FFmpeg 音频提取 -> 火山 ASR 语音识别 + -> 固定时长分段 -> FFmpeg 帧提取 + 片段切割 -> TOS 上传 + + 需要本机安装 FFmpeg(brew install ffmpeg)。 + ASR 需配置 VOLC_ASR_APP_ID + VOLC_ASR_ACCESS_KEY,未配置时跳过语音识别。 + + Args: + video_url: 视频URL(公开URL / TOS 签名URL)或本地文件路径(/path/to/video.mp4) + + Returns: + dict: 包含 duration, resolution, segments, audio_url, full_transcript 等 + """ + # 自动检测 ffmpeg/ffprobe 路径(系统 → imageio-ffmpeg 回退) + ffmpeg_bin, ffprobe_bin = _resolve_ffmpeg_paths() + # 减少帧数以降低 base64 体积(避免超过 LLM tokens 限制) + frames_per_segment = int(os.getenv("FFMPEG_FRAMES_PER_SEGMENT") or os.getenv("FRAMES_PER_SEGMENT", "2")) + temp_base = os.getenv("FFMPEG_MEDIA_TEMP_DIR") or os.getenv("MEDIA_TEMP_DIR", "./.media-cache") + Path(temp_base).mkdir(parents=True, exist_ok=True) + + task_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8] + temp_dir = Path(tempfile.mkdtemp(prefix=f"media_{task_id}_", dir=temp_base)) + local_video = temp_dir / f"{task_id}.mp4" + + # TOS 配置(VeADK 扁平化: database.tos.bucket → DATABASE_TOS_BUCKET) + bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", "video-breakdown-uploads") + tos_prefix = os.getenv("TOS_OUTPUT_PREFIX", "videobreak") + + try: + # ---- Step 1: 获取本地视频文件 ---- + # 支持本地路径(/path/to/video.mp4 或 file:///path/to/video.mp4)和 HTTP URL + local_source = _resolve_local_path(video_url) + if local_source: + # 本地文件:直接复制到工作目录 + if not local_source.exists(): + return {"error": f"本地文件不存在: {local_source}"} + file_size = local_source.stat().st_size + max_video_size = 2 * 1024 * 1024 * 1024 # 2GB + if file_size > max_video_size: + return {"error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试"} + shutil.copy2(str(local_source), str(local_video)) + logger.info(f"[process_video] 使用本地文件: {local_source} ({file_size / 1024 / 1024:.1f}MB)") + else: + # HTTP URL:流式下载 + logger.info(f"[process_video] 下载视频: {video_url[:100]}...") + max_video_size = 2 * 1024 * 1024 * 1024 # 2GB 上限 + total_downloaded = 0 + async with httpx.AsyncClient(timeout=300, follow_redirects=True) as client: + async with client.stream("GET", video_url) as resp: + resp.raise_for_status() + with open(local_video, "wb") as f: + async for chunk in resp.aiter_bytes(chunk_size=65536): + total_downloaded += len(chunk) + if total_downloaded > max_video_size: + return {"error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试"} + f.write(chunk) + logger.info(f"[process_video] 下载完成: {local_video} ({total_downloaded / 1024 / 1024:.1f}MB)") + + # ---- Step 2: 元数据 ---- + metadata = await asyncio.to_thread(_probe_video, ffprobe_bin, ffmpeg_bin, local_video) + duration = float(metadata.get("duration") or 0.0) + if duration <= 0: + return {"error": "无法获取视频时长,请确认视频URL有效"} + resolution = f'{metadata.get("width")}x{metadata.get("height")}' + logger.info(f"[process_video] 元数据: 时长={duration:.1f}s, 分辨率={resolution}") + + # ---- Step 3: 提取音频 ---- + audio_path = await asyncio.to_thread(_extract_audio_sync, ffmpeg_bin, local_video) + audio_url_out = None + audio_base64 = None + + # TOS 客户端(帧/片段/音频上传) + tos_client = _get_tos_client() + + if audio_path and tos_client: + key = f"{tos_prefix}/{task_id}/audio/{audio_path.name}" + audio_url_out = await _upload_to_tos( + tos_client, bucket, key, audio_path.read_bytes(), "audio/mpeg" + ) + + # ---- Step 4: ASR 语音识别 ---- + asr_result = None + asr_segments = None + if audio_url_out: + asr_result = await _transcribe_audio(audio_url_out) + if asr_result: + asr_segments = asr_result.get("segments", []) + logger.info(f"[process_video] ASR 识别完成: {len(asr_segments)} 个分段") + + # ---- Step 5: 构建固定时长分镜 ---- + segments = _build_segments(duration) + if asr_segments: + _assign_asr_text_to_segments(segments, asr_segments) + logger.info(f"[process_video] 分镜: {len(segments)} 个片段") + + # ---- Step 6: 提取关键帧(并发) ---- + frame_tasks = [ + asyncio.to_thread( + _extract_segment_frames, ffmpeg_bin, local_video, seg, frames_per_segment + ) + for seg in segments + ] + await asyncio.gather(*frame_tasks) + + # ---- Step 7: 切割视频片段(并发) ---- + clips_dir = temp_dir / "clips" + clips_dir.mkdir(exist_ok=True) + clip_tasks = [ + asyncio.to_thread( + _extract_single_clip, ffmpeg_bin, local_video, seg, clips_dir + ) + for seg in segments + ] + await asyncio.gather(*clip_tasks) + + # ---- Step 8: 并发上传到 TOS ---- + if tos_client: + upload_tasks = [] + + for seg in segments: + # 帧上传任务 + for fp in seg.frame_paths: + if fp.exists(): + key = f"{tos_prefix}/{task_id}/frames/{fp.name}" + upload_tasks.append( + ("frame", seg, fp, key, fp.read_bytes(), "image/jpeg") + ) + # 片段上传任务 + if seg.clip_path and seg.clip_path.exists(): + key = f"{tos_prefix}/{task_id}/clips/{seg.clip_path.name}" + upload_tasks.append( + ("clip", seg, seg.clip_path, key, seg.clip_path.read_bytes(), "video/mp4") + ) + + # 并发上传(信号量限制并发数,避免 TOS 限流) + tos_semaphore = asyncio.Semaphore(10) + + async def _upload_one(item): + kind, seg, path, key, data, ct = item + async with tos_semaphore: + url = await _upload_to_tos(tos_client, bucket, key, data, ct) + return kind, seg, url + + results = await asyncio.gather( + *[_upload_one(t) for t in upload_tasks], + return_exceptions=True, + ) + + for r in results: + if isinstance(r, Exception): + logger.warning(f"TOS 上传异常: {r}") + continue + kind, seg, url = r + if url: + if kind == "frame": + seg.frame_urls.append(url) + else: + seg.clip_url = url + + tos_client.close() + else: + logger.warning("[process_video] TOS 凭证未配置,跳过上传(帧/片段仅本地)") + + # ---- Step 8b: base64 帧图回退(TOS 不可用/上传失败时) ---- + for seg in segments: + if not seg.frame_urls and seg.frame_paths: + for fp in seg.frame_paths: + if fp.exists(): + b64 = base64.b64encode(fp.read_bytes()).decode() + seg.frame_urls.append(f"data:image/jpeg;base64,{b64}") + if seg.frame_urls: + logger.info( + f"片段 {seg.index}: TOS 不可用,使用 base64 帧图 ({len(seg.frame_urls)} 张)" + ) + + # 音频 base64 回退 + if not audio_url_out and audio_path and audio_path.exists(): + audio_base64 = base64.b64encode(audio_path.read_bytes()).decode() + logger.info( + f"[process_video] TOS 不可用,音频编码为 base64 ({len(audio_base64)} chars)" + ) + + # ---- 构建输出 ---- + full_transcript = None + if asr_result and asr_result.get("text"): + full_transcript = asr_result["text"] + + segments_output = [] + for seg in segments: + segments_output.append({ + "index": seg.index, + "start": round(seg.start, 2), + "end": round(seg.end, 2), + "frame_urls": seg.frame_urls, + "clip_url": seg.clip_url, + "is_speech": seg.is_speech, + "speech_text": seg.speech_text, + }) + + result = { + "task_id": task_id, + "duration": round(duration, 2), + "resolution": resolution, + "metadata": metadata, + "audio_url": audio_url_out, + "audio_base64": audio_base64, + "full_transcript": full_transcript, + "segment_count": len(segments_output), + "segments": segments_output, + } + + # 存入 session state 供后续 sub-agent 使用(完整数据含 base64) + tool_context.state["process_video_result"] = result + + # 返回给 LLM 的瘦身版本:base64 data URL 替换为占位标记,节省 context tokens + slim_segments = [] + for seg_out in segments_output: + slim_seg = dict(seg_out) + frame_urls = slim_seg.get("frame_urls", []) + b64_count = sum( + 1 for u in frame_urls if isinstance(u, str) and u.startswith("data:") + ) + if b64_count > 0: + slim_seg["frame_urls"] = [ + f"(本地帧图已缓存,共{b64_count}张,后续工具会自动读取)" + ] + slim_segments.append(slim_seg) + + slim_result = dict(result) + slim_result["segments"] = slim_segments + if audio_base64: + slim_result["audio_base64"] = "(音频已缓存为base64,后续工具会自动读取)" + + return slim_result + + except httpx.HTTPError as exc: + return {"error": f"视频下载失败: {exc}"} + except subprocess.CalledProcessError as exc: + return {"error": f"FFmpeg 处理失败: {exc.stderr[:300] if exc.stderr else str(exc)}"} + except Exception as exc: + logger.error(f"[process_video] 异常: {exc}", exc_info=True) + return {"error": f"视频预处理失败: {str(exc)}"} + finally: + try: + shutil.rmtree(temp_dir, ignore_errors=True) + except Exception: + pass diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py new file mode 100644 index 00000000..af70010d --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py @@ -0,0 +1,400 @@ +""" +视频分析报告生成工具 +兼容新格式(process_video + analyze_segments_vision 输出)和旧格式 +""" +import logging +from datetime import datetime +from typing import Optional + +from google.adk.tools import ToolContext + +logger = logging.getLogger(__name__) + +_DEFAULT_HOOK_ANALYSIS = { + "overall_score": 0.0, + "visual_impact": 0.0, + "visual_comment": "", + "language_hook": 0.0, + "language_comment": "", + "emotion_trigger": 0.0, + "emotion_comment": "", + "information_density": 0.0, + "info_comment": "", + "rhythm_control": 0.0, + "rhythm_comment": "", + "hook_type": "未知", + "hook_type_analysis": "", + "target_audience": "", + "strengths": [], + "weaknesses": [], + "suggestions": [], + "competitor_reference": "", + "retention_prediction": "", +} + + +def _fallback_hook_from_markdown(markdown: str) -> dict: + """从 Markdown 兜底提取最基础字段,保证报告阶段可消费。""" + if not markdown: + return {} + + import re + + hook = dict(_DEFAULT_HOOK_ANALYSIS) + + score_match = re.search(r"综合评分[::]\s*([0-9]+(?:\.[0-9]+)?)", markdown) + if score_match: + try: + hook["overall_score"] = float(score_match.group(1)) + except ValueError: + pass + + type_match = re.search(r"钩子类型[::]\s*([^\n]+)", markdown) + if type_match: + hook["hook_type"] = type_match.group(1).strip() + + retention_match = re.search(r"留存预测[::]\s*([^\n]+)", markdown) + if retention_match: + hook["retention_prediction"] = retention_match.group(1).strip() + + for field, label in ( + ("visual_impact", "视觉冲击力"), + ("language_hook", "语言钩子"), + ("emotion_trigger", "情绪唤起"), + ("information_density", "信息密度"), + ("rhythm_control", "节奏掌控"), + ): + m = re.search(rf"{label}[::]\s*([0-9]+(?:\.[0-9]+)?)/10", markdown) + if m: + try: + hook[field] = float(m.group(1)) + except ValueError: + pass + return hook + + +def _resolve_report_inputs( + breakdown_data: Optional[dict], + hook_analysis: Optional[dict], + tool_context: Optional[ToolContext], +) -> tuple[dict, dict]: + """优先使用显式参数;缺失时从 session state 回填。""" + breakdown = breakdown_data if isinstance(breakdown_data, dict) else {} + hook = hook_analysis if isinstance(hook_analysis, dict) else {} + + state = getattr(tool_context, "state", None) if tool_context else None + if not isinstance(state, dict): + return breakdown, hook + + if not breakdown: + state_breakdown = state.get("process_video_result") + if isinstance(state_breakdown, dict): + breakdown = dict(state_breakdown) + + # 优先使用视觉分析后的分镜结果,提升报告细节质量 + state_vision_segments = state.get("vision_analysis_result") + if isinstance(state_vision_segments, list) and state_vision_segments: + if not isinstance(breakdown, dict): + breakdown = {} + breakdown["segments"] = state_vision_segments + if not breakdown.get("segment_count"): + breakdown["segment_count"] = len(state_vision_segments) + + if not hook: + state_hook = state.get("hook_analysis_struct") or state.get("hook_analysis") + if isinstance(state_hook, dict): + hook = state_hook + + if not hook: + state_hook_md = state.get("hook_analysis_markdown") + if isinstance(state_hook_md, str) and state_hook_md.strip(): + hook = _fallback_hook_from_markdown(state_hook_md) + + # BGM 工具可选落盘,若存在则补齐 + state_bgm = state.get("bgm_analysis_result") + if isinstance(state_bgm, dict) and isinstance(breakdown, dict): + breakdown.setdefault("bgm_analysis", state_bgm) + + return breakdown, hook + + +def generate_video_report( + breakdown_data: Optional[dict] = None, + hook_analysis: Optional[dict] = None, + tool_context: ToolContext = None, +) -> str: + """ + 整合分镜拆解结果和钩子分析结果,生成结构化的 Markdown 视频分析报告。 + + 兼容两种输入格式: + - 新格式:process_video + analyze_segments_vision + analyze_bgm 输出 + - 旧格式:后端服务返回的数据 + + Args: + breakdown_data: 分镜拆解的完整结果数据 + hook_analysis: 前三秒钩子分析结果(由 hook_analyzer_agent 返回的 JSON) + + Returns: + str: 格式化的 Markdown 报告 + """ + breakdown_data, hook_analysis = _resolve_report_inputs( + breakdown_data, hook_analysis, tool_context + ) + + if not breakdown_data: + logger.warning("报告生成失败:缺少 breakdown_data(state: process_video_result)") + return ( + "## 报告生成失败\n\n" + "- 未找到分镜拆解数据(`process_video_result`)。\n" + "- 请先执行视频分镜拆解,再生成报告。" + ) + + segments_data = breakdown_data.get("segments", []) + if not segments_data: + logger.warning("报告生成失败:breakdown_data 缺少 segments") + return ( + "## 报告生成失败\n\n" + "- 检测到分镜基础信息,但缺少分镜列表(`segments`)。\n" + "- 请先完成视觉分镜分析后再生成报告。" + ) + + # 提取基本信息 + duration = breakdown_data.get("duration", 0) + segment_count = breakdown_data.get("segment_count", 0) + resolution = breakdown_data.get("resolution", "N/A") + + # BGM 分析:新格式可能在 bgm_analysis 或 bgm 字段 + bgm = breakdown_data.get("bgm_analysis") or breakdown_data.get("bgm") or {} + scene = breakdown_data.get("scene_analysis") or {} + + # 完整语音文本(新格式特有) + full_transcript = breakdown_data.get("full_transcript", "") + + # 提取 BGM 信息 + music_style = bgm.get("music_style", {}).get("primary", "N/A") if isinstance(bgm.get("music_style"), dict) else "N/A" + emotion = bgm.get("emotion", {}).get("primary", "N/A") if isinstance(bgm.get("emotion"), dict) else "N/A" + tempo = bgm.get("tempo", {}).get("bpm_estimate", "N/A") if isinstance(bgm.get("tempo"), dict) else "N/A" + tempo_pace = bgm.get("tempo", {}).get("pace", "N/A") if isinstance(bgm.get("tempo"), dict) else "N/A" + has_bgm = bgm.get("has_bgm") + + # 提取场景信息 + primary_scene = scene.get("primary_scene", "N/A") if scene else "N/A" + video_style = ( + scene.get("video_style", {}).get("overall", "N/A") if scene else "N/A" + ) + target_audience = ( + ", ".join(scene.get("video_style", {}).get("target_audience", [])) + if scene + else "N/A" + ) + + # 构建钩子分析部分 + hook_section = _build_hook_section(hook_analysis) + + # 构建平台推荐部分 + platform_section = _build_platform_section(scene) + + # 构建分镜概览部分 + segments_section = _build_segments_overview(segments_data) + + # BGM 部分 + if has_bgm is False: + bgm_section = "## BGM 分析\n- 未检测到背景音乐" + elif has_bgm is None and music_style == "N/A": + bgm_section = "## BGM 分析\n- BGM 分析数据暂不可用" + else: + bgm_section = f"""## BGM 分析 +- **音乐风格**: {music_style} +- **情绪基调**: {emotion} +- **节拍**: {tempo} BPM({tempo_pace}节奏)""" + + # 语音文本部分 + transcript_section = "" + if full_transcript: + transcript_section = f""" +--- + +## 语音文本 + +{full_transcript}""" + + report = f"""# 视频分析报告 + +## 基本信息 +- **视频时长**: {duration:.1f}秒 +- **分镜数量**: {segment_count}个 +- **分辨率**: {resolution} + +--- + +{hook_section} + +--- + +## 分镜概览 + +{segments_section} + +--- + +{bgm_section} + +--- + +## 场景分析 +- **主要场景**: {primary_scene} +- **视频风格**: {video_style} +- **目标受众**: {target_audience} + +{platform_section} +{transcript_section} + +--- + +**报告生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +""" + + logger.info(f"报告生成完成,总长度: {len(report)} 字符") + return report + + +def _build_hook_section(hook_analysis: dict) -> str: + """构建前三秒钩子分析部分""" + if not hook_analysis: + return ( + "## 前三秒钩子分析\n\n" + "钩子分析部分数据不完整,已基于可用分镜数据生成主报告。" + ) + + overall = hook_analysis.get("overall_score", 0) + hook_type = hook_analysis.get("hook_type", "N/A") + hook_type_analysis = hook_analysis.get("hook_type_analysis", "") + target_audience = hook_analysis.get("target_audience", "") + retention = hook_analysis.get("retention_prediction", "N/A") + competitor_ref = hook_analysis.get("competitor_reference", "") + + # 使用分段展示每个维度(评价内容可能很长,表格不适合) + dimensions = [ + ("视觉冲击力", hook_analysis.get("visual_impact", 0), hook_analysis.get("visual_comment", "")), + ("语言钩子", hook_analysis.get("language_hook", 0), hook_analysis.get("language_comment", "")), + ("情绪唤起", hook_analysis.get("emotion_trigger", 0), hook_analysis.get("emotion_comment", "")), + ("信息密度", hook_analysis.get("information_density", 0), hook_analysis.get("info_comment", "")), + ("节奏掌控", hook_analysis.get("rhythm_control", 0), hook_analysis.get("rhythm_comment", "")), + ] + + dimension_sections = [] + for name, score, comment in dimensions: + dimension_sections.append(f"#### {name}:{score}/10\n> {comment}") + dimensions_text = "\n\n".join(dimension_sections) + + strengths = hook_analysis.get("strengths", []) + weaknesses = hook_analysis.get("weaknesses", []) + suggestions = hook_analysis.get("suggestions", []) + + strengths_text = ( + "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" + ) + weaknesses_text = ( + "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + ) + suggestions_text = ( + "\n".join(f"{i + 1}. {s}" for i, s in enumerate(suggestions)) + if suggestions + else "1. 暂无" + ) + + # 可选字段 + hook_type_detail = f"\n{hook_type_analysis}" if hook_type_analysis else "" + audience_line = f"\n- **目标受众**: {target_audience}" if target_audience else "" + competitor_line = f"\n### 竞品参考\n{competitor_ref}" if competitor_ref else "" + + return f"""## 前三秒钩子分析(核心) + +### 综合评分: {overall}/10 +- **钩子类型**: {hook_type}{hook_type_detail}{audience_line} + +--- + +### 五维评分详情 + +{dimensions_text} + +--- + +### 亮点 +{strengths_text} + +### 待改进 +{weaknesses_text} + +### 优化建议 +{suggestions_text} +{competitor_line} + +### 留存预测 +**{retention}**""" + + +def _build_platform_section(scene: dict) -> str: + """构建平台推荐部分""" + if not scene: + return "" + + recommendations = scene.get("platform_recommendations", []) + if not recommendations: + return "" + + lines = ["### 平台推荐"] + for rec in recommendations: + platform = rec.get("platform", "N/A") + suitability = rec.get("suitability", "N/A") + reason = rec.get("reason", "") + lines.append(f"- **{platform}**(适合度: {suitability}): {reason}") + + return "\n".join(lines) + + +def _build_segments_overview(segments: list) -> str: + """构建分镜概览表格(兼容新旧格式)""" + if not segments: + return "暂无分镜数据。" + + lines = [ + "| 镜号 | 时间 | 景别 | 运镜 | 功能标签 | 画面内容 |", + "|------|------|------|------|----------|----------|", + ] + + for seg in segments[:10]: + # 兼容新旧字段名 + index = seg.get("index", seg.get("segment_index", "-")) + start = seg.get("start", seg.get("start_time", 0)) + end = seg.get("end", seg.get("end_time", 0)) + time_range = f"{start:.1f}s-{end:.1f}s" + + # 视觉表现:新格式在嵌套对象中 + visual_info = seg.get("视觉表现", {}) + shot_type = visual_info.get("景别", "") if visual_info else "" + if not shot_type: + shot_type = seg.get("shot_type", "-") + + camera = visual_info.get("运镜", "") if visual_info else "" + if not camera: + camera = seg.get("camera_movement", "-") + + func_tag = seg.get("功能标签", seg.get("function_tag", "-")) + + visual = visual_info.get("画面内容", "") if visual_info else "" + if not visual: + visual = seg.get("visual_content", seg.get("summary", "-")) + + if len(visual) > 40: + visual = visual[:37] + "..." + + lines.append( + f"| {index} | {time_range} | {shot_type} | {camera} | {func_tag} | {visual} |" + ) + + if len(segments) > 10: + lines.append(f"\n*(仅展示前10个分镜,共{len(segments)}个)*") + + return "\n".join(lines) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py new file mode 100644 index 00000000..eceb34cc --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py @@ -0,0 +1,140 @@ +""" +视频文件上传到 TOS 对象存储工具 +基于 inspection_assistant/tools/tos_upload.py 模式实现 +""" +import logging +import os +from datetime import datetime +from typing import Optional + +import tos +from tos import HttpMethodType + +logger = logging.getLogger(__name__) + +DEFAULT_BUCKET = "video-breakdown-uploads" +DEFAULT_REGION = "cn-beijing" + + +def video_upload_to_tos( + file_path: str, + bucket_name: Optional[str] = None, + object_key: Optional[str] = None, +) -> dict: + """ + 将本地视频文件上传到火山引擎 TOS 对象存储,并返回可访问的签名 URL。 + 上传成功后,返回的 URL 可直接用于 process_video 工具进行视频分镜分析。 + + Args: + file_path: 本地视频文件路径,例如 /path/to/video.mp4 + bucket_name: TOS 存储桶名称(可选,默认从配置读取) + object_key: 对象存储路径(可选,默认自动生成) + + Returns: + dict: 包含 video_url(签名URL)和上传信息,或 error 信息 + """ + # 从环境变量/config.yaml 读取配置(VeADK 扁平化: DATABASE_TOS_*,兼容旧: TOS_*) + if bucket_name is None: + bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", DEFAULT_BUCKET) + region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", DEFAULT_REGION) + + # 检查文件是否存在 + if not os.path.exists(file_path): + return {"error": f"文件不存在: {file_path}"} + + if not os.path.isfile(file_path): + return {"error": f"路径不是文件: {file_path}"} + + # 检查文件大小(限制 2GB) + file_size = os.path.getsize(file_path) + max_size = 2 * 1024 * 1024 * 1024 # 2GB + if file_size > max_size: + return {"error": f"文件过大({file_size / 1024 / 1024:.0f}MB),最大支持 2GB"} + + # 获取凭证:优先从配置/环境变量,其次从 VeFaaS IAM + access_key = os.getenv("VOLCENGINE_ACCESS_KEY", "") + secret_key = os.getenv("VOLCENGINE_SECRET_KEY", "") + session_token = "" + + if not (access_key and secret_key): + try: + from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() + access_key = cred.access_key_id + secret_key = cred.secret_access_key + session_token = cred.session_token + except Exception: + pass + + if not access_key or not secret_key: + return { + "error": "缺少 TOS 访问凭证。请设置 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY 环境变量(或在 config.yaml 中配置 volcengine.access_key / volcengine.secret_key)" + } + + # 自动生成 object_key + if not object_key: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = os.path.basename(file_path) + object_key = f"video_breakdown/upload/{timestamp}_{filename}" + + # 上传文件 + client = None + try: + endpoint = f"tos-{region}.volces.com" + client = tos.TosClientV2( + ak=access_key, + sk=secret_key, + security_token=session_token, + endpoint=endpoint, + region=region, + ) + + logger.info(f"开始上传视频: {file_path}") + logger.info(f"目标桶: {bucket_name}, 对象键: {object_key}") + + # 检查桶是否存在 + try: + client.head_bucket(bucket_name) + except tos.exceptions.TosServerError as e: + if e.status_code == 404: + return {"error": f"TOS 存储桶 {bucket_name} 不存在,请先创建"} + raise + + # 上传 + result = client.put_object_from_file( + bucket=bucket_name, key=object_key, file_path=file_path + ) + + logger.info(f"上传成功! ETag: {result.etag}") + + # 生成签名 URL(7天有效) + signed_url_output = client.pre_signed_url( + http_method=HttpMethodType.Http_Method_Get, + bucket=bucket_name, + key=object_key, + expires=604800, + ) + + signed_url = signed_url_output.signed_url + logger.info("签名 URL 生成成功(7天有效)") + + return { + "video_url": signed_url, + "bucket": bucket_name, + "object_key": object_key, + "file_size_mb": round(file_size / 1024 / 1024, 2), + "message": "视频上传成功!可以使用返回的 video_url 调用 process_video 进行分镜预处理", + } + + except tos.exceptions.TosClientError as e: + logger.error(f"TOS 客户端错误: {e}") + return {"error": f"TOS 客户端错误: {e}"} + except tos.exceptions.TosServerError as e: + logger.error(f"TOS 服务端错误: code={e.code}, message={e.message}") + return {"error": f"TOS 服务端错误: {e.message}"} + except Exception as e: + logger.error(f"上传失败: {e}") + return {"error": f"视频上传失败: {str(e)}"} + finally: + if client: + client.close() diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/__init__.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py new file mode 100644 index 00000000..99f65979 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py @@ -0,0 +1,342 @@ +""" +豆包官方 API 调用封装 +支持文本模型(/chat/completions)和视觉模型(/responses) + +文本模型:标准 OpenAI 兼容格式 +视觉模型:豆包专有 Responses API 格式 + - endpoint: /api/v3/responses + - 请求体:只有 model + input(无 temperature/parameters 等) + - input content 类型:input_text / input_image(非 text / image_url) + - 参考:https://www.volcengine.com/docs/82379/1541595 +""" +import json +import logging +import os +from typing import List, Dict, Any, Optional + +import httpx + +logger = logging.getLogger(__name__) + + +class DoubaoClient: + """豆包 API 客户端""" + + def __init__( + self, + api_key: str, + api_base: str = "https://ark.cn-beijing.volces.com/api/v3", + timeout: int = 120, + ): + self.api_key = api_key + self.api_base = api_base.rstrip("/") + self.timeout = timeout + self.client = httpx.AsyncClient(timeout=timeout) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.client.aclose() + + # ==================== 文本模型 ==================== + + async def text_completion( + self, + model: str, + messages: List[Dict[str, Any]], + temperature: float = 0.7, + max_tokens: Optional[int] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + 调用豆包文本模型 API(标准 /chat/completions endpoint) + + Args: + model: 模型名称,如 "doubao-seed-1-6-251015" + messages: 标准 OpenAI 格式消息列表 + temperature: 温度参数 + max_tokens: 最大 token 数 + + Returns: + 标准 OpenAI 兼容格式响应 + """ + url = f"{self.api_base}/chat/completions" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + payload: Dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + } + if max_tokens: + payload["max_tokens"] = max_tokens + payload.update(kwargs) + + logger.debug(f"豆包文本 API 请求: model={model}, messages={len(messages)} 条") + + try: + response = await self.client.post(url, headers=headers, json=payload) + response.raise_for_status() + result = response.json() + logger.debug(f"豆包文本 API 响应成功") + return result + except httpx.HTTPStatusError as e: + logger.error(f"豆包文本 API HTTP 错误: {e.response.status_code} - {e.response.text}") + raise + except Exception as e: + logger.error(f"豆包文本 API 调用失败: {e}") + raise + + # ==================== 视觉模型 ==================== + + async def vision_completion( + self, + model: str, + messages: List[Dict[str, Any]], + **kwargs, + ) -> Dict[str, Any]: + """ + 调用豆包视觉模型 API(/responses endpoint,豆包专有格式) + + 请求格式严格按照官方文档: + { + "model": "doubao-seed-1-6-vision-250815", + "input": [ + { + "role": "user", + "content": [ + {"type": "input_image", "image_url": "https://..."}, + {"type": "input_text", "text": "你看见了什么?"} + ] + } + ] + } + + Args: + model: 视觉模型名称,如 "doubao-seed-1-6-vision-250815" + messages: 标准 OpenAI 格式消息列表(会自动转换为豆包格式) + + Returns: + 转换为标准 OpenAI 格式的响应(方便上层代码统一处理) + """ + url = f"{self.api_base}/responses" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + # 将标准 OpenAI messages 转换为豆包 Responses API 格式 + doubao_input = self._convert_messages_to_doubao_input(messages) + + # ⚠️ 严格按照官方文档:只有 model + input,不传任何其他字段 + payload = { + "model": model, + "input": doubao_input, + } + + logger.debug(f"豆包视觉 API 请求: model={model}, input={len(doubao_input)} 条") + + try: + response = await self.client.post(url, headers=headers, json=payload) + response.raise_for_status() + raw = response.json() + + # 转换为统一的 OpenAI 格式 + return self._convert_vision_response(raw) + except httpx.HTTPStatusError as e: + logger.error(f"豆包视觉 API HTTP 错误: {e.response.status_code} - {e.response.text}") + raise + except Exception as e: + logger.error(f"豆包视觉 API 调用失败: {e}") + raise + + # ==================== 格式转换 ==================== + + @staticmethod + def _convert_messages_to_doubao_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + 将标准 OpenAI messages 转换为豆包 Responses API 的 input 格式 + + OpenAI 格式 → 豆包格式: + "text" → "input_text" + "image_url" → "input_image" (且 image_url 从嵌套对象变为字符串) + "system" → 作为 input_text 加入(豆包 vision 不区分 system) + """ + doubao_input: List[Dict[str, Any]] = [] + + for msg in messages: + role = msg["role"] + content = msg["content"] + + # system message → 转为 user 的 input_text(豆包 vision API 可能不支持 system role) + if role == "system": + if isinstance(content, str): + doubao_input.append({ + "role": "user", + "content": [{"type": "input_text", "text": f"[系统提示] {content}"}], + }) + continue + + # content 是纯字符串 + if isinstance(content, str): + doubao_input.append({ + "role": role, + "content": [{"type": "input_text", "text": content}], + }) + continue + + # content 是列表(多模态内容) + doubao_content: List[Dict[str, Any]] = [] + for item in content: + item_type = item.get("type", "") + + if item_type == "text": + doubao_content.append({ + "type": "input_text", + "text": item["text"], + }) + elif item_type == "image_url": + # 提取 URL(OpenAI 格式可能是 {"url": "..."} 或字符串) + url_data = item.get("image_url") + url = url_data.get("url") if isinstance(url_data, dict) else url_data + doubao_content.append({ + "type": "input_image", + "image_url": url, + }) + else: + # 未知类型保持原样 + doubao_content.append(item) + + doubao_input.append({ + "role": role, + "content": doubao_content, + }) + + return doubao_input + + @staticmethod + def _convert_vision_response(raw: Dict[str, Any]) -> Dict[str, Any]: + """ + 将豆包 Responses API 响应转换为 OpenAI Chat Completions 格式 + + 豆包 Responses API 实际响应格式: + { + "id": "resp_...", + "model": "doubao-seed-1-6-vision-250815", + "output": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "..."}]}, + {"type": "message", "content": [{"type": "output_text", "text": "最终回答"}]} + ], + "usage": {"input_tokens": 100, "output_tokens": 50, "total_tokens": 150} + } + """ + # 如果已经是 OpenAI 格式(有 choices 字段),直接返回 + if "choices" in raw: + return raw + + content_text = "" + + # output 是一个列表,从中提取 type="message" 的项 + output_list = raw.get("output", []) + if isinstance(output_list, list): + for item in output_list: + if not isinstance(item, dict): + continue + item_type = item.get("type", "") + + # 主回答内容:type="message" + if item_type == "message": + content_parts = item.get("content", []) + for part in content_parts: + if isinstance(part, dict) and part.get("type") == "output_text": + content_text += part.get("text", "") + elif isinstance(part, str): + content_text += part + + # 如果没有从 message 中提取到内容,尝试其他方式 + if not content_text: + # 尝试从 output 中任何 summary_text 提取 + if isinstance(output_list, list): + for item in output_list: + if isinstance(item, dict): + for s in item.get("summary", []): + if isinstance(s, dict) and s.get("type") == "summary_text": + content_text += s.get("text", "") + + # 最终兜底 + if not content_text: + content_text = json.dumps(raw, ensure_ascii=False) + logger.warning(f"豆包视觉 API 响应中未找到 message 内容,完整响应: {content_text[:300]}") + + # 提取 usage + usage = raw.get("usage", {}) + input_tokens = usage.get("input_tokens", usage.get("prompt_tokens", 0)) + output_tokens = usage.get("output_tokens", usage.get("completion_tokens", 0)) + + return { + "id": raw.get("id", ""), + "object": "chat.completion", + "created": raw.get("created_at", 0), + "model": raw.get("model", ""), + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content_text}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + }, + } + + async def close(self): + """关闭客户端""" + await self.client.aclose() + + +# ==================== 便捷函数 ==================== + + +async def call_doubao_text( + model: str, + messages: List[Dict[str, Any]], + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """便捷函数:调用豆包文本模型""" + if not api_key: + api_key = os.getenv("MODEL_AGENT_API_KEY") + if not api_key: + raise ValueError("API Key 未提供,且环境变量 MODEL_AGENT_API_KEY 未设置") + if not api_base: + api_base = os.getenv("MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3") + + async with DoubaoClient(api_key=api_key, api_base=api_base) as client: + return await client.text_completion(model=model, messages=messages, **kwargs) + + +async def call_doubao_vision( + model: str, + messages: List[Dict[str, Any]], + api_key: Optional[str] = None, + api_base: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """便捷函数:调用豆包视觉模型(自动转换为 Responses API 格式)""" + if not api_key: + api_key = os.getenv("MODEL_VISION_API_KEY") + if not api_key: + raise ValueError("API Key 未提供,且环境变量 MODEL_VISION_API_KEY 未设置") + if not api_base: + api_base = os.getenv("MODEL_VISION_API_BASE", "https://ark.cn-beijing.volces.com/api/v3") + + async with DoubaoClient(api_key=api_key, api_base=api_base) as client: + return await client.vision_completion(model=model, messages=messages, **kwargs) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py new file mode 100644 index 00000000..0f5f91e0 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py @@ -0,0 +1,76 @@ +""" +数据类型定义 +定义 Pydantic 模型用于 Agent 输出格式校验 +""" +from typing import List, Optional + +from google.genai import types +from pydantic import BaseModel, Field + +# JSON 输出配置 +json_response_config = types.GenerateContentConfig( + response_mime_type="application/json", + max_output_tokens=18000, +) + + +class HookAnalysis(BaseModel): + """前三秒钩子分析结果的结构化输出""" + + overall_score: float = Field( + description="综合评分(0-10),各维度加权平均" + ) + visual_impact: float = Field( + description="视觉冲击力分数(0-10)" + ) + visual_comment: str = Field( + description="视觉冲击力的具体评价" + ) + language_hook: float = Field( + description="语言钩子分数(0-10)" + ) + language_comment: str = Field( + description="语言钩子的具体评价" + ) + emotion_trigger: float = Field( + description="情绪唤起分数(0-10)" + ) + emotion_comment: str = Field( + description="情绪唤起的具体评价" + ) + information_density: float = Field( + description="信息密度分数(0-10)" + ) + info_comment: str = Field( + description="信息密度的具体评价" + ) + rhythm_control: float = Field( + description="节奏掌控分数(0-10)" + ) + rhythm_comment: str = Field( + description="节奏掌控的具体评价" + ) + hook_type: str = Field( + description="钩子类型(痛点型/好奇型/冲突型/价值型/情感型/视觉冲击型/悬念型)" + ) + hook_type_analysis: Optional[str] = Field( + default="", description="为什么判定为该钩子类型的详细理由" + ) + target_audience: Optional[str] = Field( + default="", description="推测的目标受众画像" + ) + strengths: List[str] = Field( + description="优点列表(需具体引用画面元素)" + ) + weaknesses: List[str] = Field( + description="不足列表(需具体指出问题)" + ) + suggestions: List[str] = Field( + description="优化建议列表(说明改什么、怎么改、预期效果)" + ) + competitor_reference: Optional[str] = Field( + default="", description="同品类/同类型优秀视频的钩子设计参考" + ) + retention_prediction: str = Field( + description="3秒留存预测(高/中/低)及详细原因分析" + ) From 9ff300456b1644aef4a77a6437c525cf34ccd491 Mon Sep 17 00:00:00 2001 From: edy Date: Thu, 12 Feb 2026 12:42:54 +0800 Subject: [PATCH 02/13] fix: resolve ruff lint and format issues for pre-commit compliance --- .../.scripts/smoke_test.py | 24 +-- .../scripts/generate_report.py | 22 ++- .../scripts/process_video.py | 45 ++++- .../scripts/video_upload.py | 11 +- .../partners/video_breakdown_agent/agent.py | 1 + .../sandbox/sandbox_manager.py | 20 +- .../video_breakdown_agent/agent.py | 25 ++- .../hook/final_output_hook.py | 6 +- .../video_breakdown_agent/hook/format_hook.py | 20 +- .../hook/search_output_hook.py | 5 +- .../hook/video_upload_hook.py | 34 ++-- .../sub_agents/breakdown_agent/agent.py | 1 + .../sub_agents/hook_analyzer_agent/agent.py | 3 +- .../report_generator_agent/agent.py | 1 + .../direct_output_callback.py | 1 - .../tools/analyze_bgm.py | 57 ++++-- .../tools/analyze_hook_segments.py | 7 +- .../tools/analyze_segments_vision.py | 131 +++++++++--- .../tools/process_video.py | 186 +++++++++++++----- .../tools/report_generator.py | 69 +++++-- .../tools/video_upload.py | 6 +- .../utils/doubao_client.py | 83 +++++--- .../video_breakdown_agent/utils/types.py | 57 ++---- 23 files changed, 559 insertions(+), 256 deletions(-) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py b/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py index e656d8a3..67fc8630 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py +++ b/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py @@ -14,6 +14,7 @@ 依赖:需要在项目根目录下已有 config.yaml 或 .env 配置。 """ + import asyncio import sys import os @@ -29,9 +30,9 @@ async def run_single(message: str) -> str: # 延迟导入,让 sys.path 先生效 from agent import runner # noqa: E402 - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"📤 发送: {message}") - print(f"{'='*60}") + print(f"{'=' * 60}") result = await runner.run( messages=message, @@ -39,9 +40,9 @@ async def run_single(message: str) -> str: session_id="smoke_test_session", ) - print(f"\n{'='*60}") - print(f"📥 回复:") - print(f"{'='*60}") + print(f"\n{'=' * 60}") + print("📥 回复:") + print(f"{'=' * 60}") print(result) return result @@ -93,7 +94,9 @@ def _looks_like_raw_json(text: str) -> bool: ) -def _assert_case_output(case_name: str, output: str, expected_keywords: list[str]) -> None: +def _assert_case_output( + case_name: str, output: str, expected_keywords: list[str] +) -> None: lowered = (output or "").lower() if "<[plhd" in lowered or "transfer_to_agent" in lowered: raise AssertionError(f"{case_name}: 检测到内部占位/转移片段泄露") @@ -112,13 +115,12 @@ async def run_pipeline_cases() -> None: session_id = f"pipeline_case_{os.getpid()}" user_id = "smoke_test_user" - + # 使用实际测试视频(如果项目内有 .media-uploads 中的测试样本) test_video = os.getenv( - "TEST_VIDEO_URL", - "https://tos-cn-beijing.volces.com/obj/video-demo/sample.mp4" + "TEST_VIDEO_URL", "https://tos-cn-beijing.volces.com/obj/video-demo/sample.mp4" ) - + # 检查本地测试视频 local_test_videos = list(Path(PROJECT_ROOT / ".media-uploads").glob("*.mp4")) if local_test_videos: @@ -178,7 +180,7 @@ async def run_pipeline_cases() -> None: print("\n" + "=" * 60) print(f"Results: {passed} passed, {failed} failed") print("=" * 60) - + if failed > 0: sys.exit(1) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py index 42065e5f..f5b4a704 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py @@ -46,7 +46,8 @@ def generate_video_report(breakdown_data: dict, hook_analysis: dict = None) -> s video_style = scene.get("video_style", {}).get("overall", "N/A") if scene else "N/A" target_audience = ( ", ".join(scene.get("video_style", {}).get("target_audience", [])) - if scene else "N/A" + if scene + else "N/A" ) hook_section = _build_hook_section(hook_analysis) @@ -88,7 +89,7 @@ def generate_video_report(breakdown_data: dict, hook_analysis: dict = None) -> s --- -**报告生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**报告生成时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} """ return report @@ -103,21 +104,24 @@ def _build_hook_section(hook_analysis: dict) -> str: scores_table = f"""| 维度 | 得分 | 评价 | |------|------|------| -| 视觉冲击力 | {hook_analysis.get('visual_impact', 0)}/10 | {hook_analysis.get('visual_comment', '')} | -| 语言钩子 | {hook_analysis.get('language_hook', 0)}/10 | {hook_analysis.get('language_comment', '')} | -| 情绪唤起 | {hook_analysis.get('emotion_trigger', 0)}/10 | {hook_analysis.get('emotion_comment', '')} | -| 信息密度 | {hook_analysis.get('information_density', 0)}/10 | {hook_analysis.get('info_comment', '')} | -| 节奏掌控 | {hook_analysis.get('rhythm_control', 0)}/10 | {hook_analysis.get('rhythm_comment', '')} |""" +| 视觉冲击力 | {hook_analysis.get("visual_impact", 0)}/10 | {hook_analysis.get("visual_comment", "")} | +| 语言钩子 | {hook_analysis.get("language_hook", 0)}/10 | {hook_analysis.get("language_comment", "")} | +| 情绪唤起 | {hook_analysis.get("emotion_trigger", 0)}/10 | {hook_analysis.get("emotion_comment", "")} | +| 信息密度 | {hook_analysis.get("information_density", 0)}/10 | {hook_analysis.get("info_comment", "")} | +| 节奏掌控 | {hook_analysis.get("rhythm_control", 0)}/10 | {hook_analysis.get("rhythm_comment", "")} |""" strengths = hook_analysis.get("strengths", []) weaknesses = hook_analysis.get("weaknesses", []) suggestions = hook_analysis.get("suggestions", []) strengths_text = "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" - weaknesses_text = "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + weaknesses_text = ( + "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + ) suggestions_text = ( "\n".join(f"{i + 1}. {s}" for i, s in enumerate(suggestions)) - if suggestions else "1. 暂无" + if suggestions + else "1. 暂无" ) return f"""## 前三秒钩子分析(核心) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py index 67b0e50e..864e340a 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py @@ -22,18 +22,29 @@ import uuid from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List import httpx def _run_command(cmd: List[str]) -> str: - process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + process = subprocess.run( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True + ) return process.stdout.strip() def _probe_video(ffprobe_bin: str, video_path: Path) -> Dict[str, Any]: - cmd = [ffprobe_bin, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(video_path)] + cmd = [ + ffprobe_bin, + "-v", + "error", + "-print_format", + "json", + "-show_format", + "-show_streams", + str(video_path), + ] result = _run_command(cmd) info = json.loads(result) if result else {} fmt = info.get("format") or {} @@ -43,7 +54,13 @@ def _probe_video(ffprobe_bin: str, video_path: Path) -> Dict[str, Any]: if s.get("codec_type") == "video": w, h, fr = s.get("width"), s.get("height"), s.get("r_frame_rate") break - return {"duration": duration, "width": w, "height": h, "frame_rate": fr, "size": fmt.get("size")} + return { + "duration": duration, + "width": w, + "height": h, + "frame_rate": fr, + "size": fmt.get("size"), + } def _build_segments(duration: float): @@ -81,7 +98,19 @@ def _extract_frames(ffmpeg_bin: str, video_path: Path, segments, fps=3): offset = min(seg["start"] + ratio * dur, seg["end"] - 0.1) offset = max(offset, seg["start"]) out = frames_dir / f"seg{seg['index']:03d}_frame_{i}.jpg" - cmd = [ffmpeg_bin, "-y", "-ss", f"{offset:.2f}", "-i", str(video_path), "-frames:v", "1", "-q:v", "5", str(out)] + cmd = [ + ffmpeg_bin, + "-y", + "-ss", + f"{offset:.2f}", + "-i", + str(video_path), + "-frames:v", + "1", + "-q:v", + "5", + str(out), + ] try: _run_command(cmd) if out.exists(): @@ -99,14 +128,14 @@ async def main(video_url: str): try: # Download - print(f"[1/4] 下载视频...", file=sys.stderr) + print("[1/4] 下载视频...", file=sys.stderr) async with httpx.AsyncClient(timeout=300, follow_redirects=True) as c: r = await c.get(video_url) r.raise_for_status() local_video.write_bytes(r.content) # Metadata - print(f"[2/4] 提取元数据...", file=sys.stderr) + print("[2/4] 提取元数据...", file=sys.stderr) meta = _probe_video(ffprobe_bin, local_video) dur = meta["duration"] if dur <= 0: @@ -118,7 +147,7 @@ async def main(video_url: str): segments = _build_segments(dur) # Frames - print(f"[4/4] 提取关键帧...", file=sys.stderr) + print("[4/4] 提取关键帧...", file=sys.stderr) _extract_frames(ffmpeg_bin, local_video, segments) result = { diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py index 0d7bf8c4..f593a66c 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py +++ b/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py @@ -33,7 +33,9 @@ def video_upload_to_tos(file_path: str, bucket_name: str = None) -> dict: dict: 包含 video_url 或 error """ if bucket_name is None: - bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", DEFAULT_BUCKET) + bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv( + "TOS_BUCKET", DEFAULT_BUCKET + ) region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", DEFAULT_REGION) # 检查文件 @@ -55,6 +57,7 @@ def video_upload_to_tos(file_path: str, bucket_name: str = None) -> dict: if not access_key or not secret_key: try: from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() access_key = cred.access_key_id secret_key = cred.secret_access_key @@ -63,7 +66,9 @@ def video_upload_to_tos(file_path: str, bucket_name: str = None) -> dict: pass if not access_key or not secret_key: - return {"error": "缺少 TOS 访问凭证,请设置 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY"} + return { + "error": "缺少 TOS 访问凭证,请设置 VOLCENGINE_ACCESS_KEY 和 VOLCENGINE_SECRET_KEY" + } # 自动生成 object_key timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") @@ -91,7 +96,7 @@ def video_upload_to_tos(file_path: str, bucket_name: str = None) -> dict: raise print(f"上传中: {file_path} -> {bucket_name}/{object_key}", file=sys.stderr) - result = client.put_object_from_file( + client.put_object_from_file( bucket=bucket_name, key=object_key, file_path=file_path ) diff --git a/python/02-use-cases/partners/video_breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/agent.py index 9557bc7b..f0e9e2eb 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/agent.py +++ b/python/02-use-cases/partners/video_breakdown_agent/agent.py @@ -8,6 +8,7 @@ 1. uv run veadk web — 本地开发(veadk web 自动发现 video_breakdown_agent/) 2. python agent.py — AgentKit 部署 """ + import logging import os diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py index 481f6c4e..69a46013 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py +++ b/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py @@ -2,7 +2,7 @@ 沙箱环境管理器 提供沙箱的启动、停止、重置、健康检查等功能 """ -import os + import subprocess import sys import time @@ -29,7 +29,7 @@ def start(self, rebuild: bool = False): example_env = self.sandbox_dir / "sandbox.env.example" if example_env.exists(): print( - f"警告: 未找到 .env 文件,请复制 sandbox.env.example 为 .env 并填入配置" + "警告: 未找到 .env 文件,请复制 sandbox.env.example 为 .env 并填入配置" ) print(f" cp {example_env} {self.env_file}") return @@ -46,7 +46,9 @@ def start(self, rebuild: bool = False): if rebuild: cmd.insert(-1, "--build") - result = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.sandbox_dir)) + result = subprocess.run( + cmd, capture_output=True, text=True, cwd=str(self.sandbox_dir) + ) if result.returncode != 0: print(f"沙箱启动失败: {result.stderr}") raise Exception(f"沙箱启动失败: {result.stderr}") @@ -55,8 +57,8 @@ def start(self, rebuild: bool = False): self.wait_for_health() print("沙箱环境已启动") print(f" API 地址: {self.api_url}") - print(f" MySQL: localhost:3307") - print(f" Redis: localhost:6380") + print(" MySQL: localhost:3307") + print(" Redis: localhost:6380") def stop(self): """停止沙箱环境(保留数据)""" @@ -169,12 +171,8 @@ def run_test(self, video_url: str): f"{self.api_url}/api/v1/breakdown/result/{task_id}" ) result_data = response.json()["data"] - print( - f" 分镜数: {result_data.get('segment_count', 0)}" - ) - print( - f" 时长: {result_data.get('duration', 0):.1f}s" - ) + print(f" 分镜数: {result_data.get('segment_count', 0)}") + print(f" 时长: {result_data.get('duration', 0):.1f}s") return task_id elif current_status == "failed": print( diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py index 9d2cdb76..c8638f01 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py @@ -4,6 +4,7 @@ VeADK 约束:有 sub_agents 的 Agent 不支持直接挂载 tools, 因此 web_search 等工具封装为独立子 Agent。 """ + import logging import os @@ -17,9 +18,14 @@ from .hook.video_upload_hook import hook_video_upload from .prompt import ROOT_AGENT_INSTRUCTION from .sub_agents.breakdown_agent.prompt import BREAKDOWN_AGENT_INSTRUCTION -from .sub_agents.hook_analyzer_agent.prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION +from .sub_agents.hook_analyzer_agent.prompt import ( + HOOK_ANALYZER_INSTRUCTION, + HOOK_FORMAT_INSTRUCTION, +) from .sub_agents.report_generator_agent.prompt import REPORT_AGENT_INSTRUCTION -from .sub_agents.report_generator_agent.direct_output_callback import direct_output_callback +from .sub_agents.report_generator_agent.direct_output_callback import ( + direct_output_callback, +) from .hook.format_hook import soft_fix_hook_output from .tools.process_video import process_video from .tools.analyze_segments_vision import analyze_segments_vision @@ -38,6 +44,7 @@ if os.getenv("TOOL_LLM_SHIELD_APP_ID"): try: from veadk.tools.builtin_tools.llm_shield import content_safety + shield_callbacks = { "before_model_callback": content_safety.before_model_callback, "after_model_callback": content_safety.after_model_callback, @@ -116,7 +123,9 @@ def create_hook_analyzer_agent() -> SequentialAgent: tools=[analyze_hook_segments], model_extra_config={ "extra_body": { - "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")} + "thinking": { + "type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled") + } } }, ) @@ -131,7 +140,9 @@ def create_hook_analyzer_agent() -> SequentialAgent: after_model_callback=[soft_fix_hook_output], model_extra_config={ "extra_body": { - "thinking": {"type": os.getenv("THINKING_HOOK_FORMAT_AGENT", "disabled")} + "thinking": { + "type": os.getenv("THINKING_HOOK_FORMAT_AGENT", "disabled") + } } }, ) @@ -164,7 +175,11 @@ def create_report_generator_agent() -> Agent: full_analysis_pipeline = SequentialAgent( name="full_analysis_pipeline", description="完整分析生产线:分镜拆解 -> 钩子分析 -> 报告生成", - sub_agents=[create_breakdown_agent(), create_hook_analyzer_agent(), create_report_generator_agent()], + sub_agents=[ + create_breakdown_agent(), + create_hook_analyzer_agent(), + create_report_generator_agent(), + ], ) hook_only_pipeline = SequentialAgent( diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py index 46c24c5a..fba1c82e 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py @@ -6,6 +6,7 @@ 2. 使用 LLM 将输出重写为用户可读 Markdown; 3. 不干预工具调用 envelope,避免破坏编排。 """ + from __future__ import annotations import os @@ -105,7 +106,9 @@ def _call_repair_llm(raw_text: str) -> Optional[str]: return None model = os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015") - api_base = os.getenv("MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3/") + api_base = os.getenv( + "MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3/" + ) api_base = api_base.rstrip("/") url = f"{api_base}/chat/completions" @@ -174,4 +177,3 @@ def guard_final_user_output( llm_response.content.parts[0].text = repaired logger.info("[final_output_guard] repaired leaked intermediate output by llm") return llm_response - diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py index 9f73f318..14cf519a 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -77,7 +77,9 @@ def _event_to_text(event: Optional[Event]) -> str: except Exception: pass try: - return json.dumps(getattr(event, "__dict__", {}), ensure_ascii=False, default=str) + return json.dumps( + getattr(event, "__dict__", {}), ensure_ascii=False, default=str + ) except Exception: return str(event) @@ -189,7 +191,9 @@ def _extract_score(text: str, label: str) -> Optional[float]: def _extract_list_by_heading(text: str, heading: str) -> list[str]: - pattern = rf"{heading}[\s\S]{{0,120}}?(?:\n|\r\n)([\s\S]{{0,600}}?)(?:\n\n|###|####|$)" + pattern = ( + rf"{heading}[\s\S]{{0,120}}?(?:\n|\r\n)([\s\S]{{0,600}}?)(?:\n\n|###|####|$)" + ) m = re.search(pattern, text, flags=re.IGNORECASE) if not m: return [] @@ -212,7 +216,9 @@ def _fallback_struct_from_text(text: str) -> dict[str, Any]: output["strengths"] = _extract_list_by_heading(text, r"(?:亮点|优点|strengths)") output["weaknesses"] = _extract_list_by_heading(text, r"(?:待改进|不足|weaknesses)") - output["suggestions"] = _extract_list_by_heading(text, r"(?:优化建议|建议|suggestions)") + output["suggestions"] = _extract_list_by_heading( + text, r"(?:优化建议|建议|suggestions)" + ) excerpt = _safe_text(text, max_len=500) if excerpt: @@ -244,7 +250,9 @@ def _score_line(name: str, score_key: str, comment_key: str) -> str: suggestions = _coerce_to_list(output.get("suggestions")) strengths_text = "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" - weaknesses_text = "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + weaknesses_text = ( + "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" + ) suggestions_text = ( "\n".join(f"{i + 1}. {s}" for i, s in enumerate(suggestions)) if suggestions @@ -305,7 +313,9 @@ def soft_fix_hook_output( if isinstance(parsed, dict) and not _looks_like_tool_envelope(parsed): normalized = _normalize_output(parsed) - logger.info("[soft_fix_hook_output] normalized by json path agent=%s", agent_name) + logger.info( + "[soft_fix_hook_output] normalized by json path agent=%s", agent_name + ) else: normalized = _fallback_struct_from_text(text) logger.warning( diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py index 18a81fa1..13fc4557 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py @@ -24,7 +24,9 @@ def suppress_search_agent_user_output( search_agent 只做检索与回传,不直接面向用户输出正文。 将其文本暂存到 state,最终由 root agent 统一输出一次,避免重复回复。 """ - agent_name = getattr(getattr(callback_context, "_invocation_context", None), "agent", None) + agent_name = getattr( + getattr(callback_context, "_invocation_context", None), "agent", None + ) if not agent_name or getattr(agent_name, "name", "") != "search_agent": return llm_response @@ -42,4 +44,3 @@ def suppress_search_agent_user_output( # 清空 search_agent 的用户可见输出,避免与 root 最终答复重复展示。 llm_response.content.parts[0].text = "" return llm_response - diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py index e5293ae8..3c0a9cff 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py @@ -13,11 +13,11 @@ 参考实现: ad_video_gen_seq/app/market/hook.py → hook_inline_data_transform """ + from __future__ import annotations import logging import os -import tempfile import uuid from datetime import datetime from pathlib import Path @@ -30,8 +30,14 @@ # 支持的视频 MIME 类型 VIDEO_MIME_TYPES = { - "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", - "video/x-matroska", "video/mpeg", "video/3gpp", "video/x-flv", + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/x-matroska", + "video/mpeg", + "video/3gpp", + "video/x-flv", "application/octet-stream", # 某些浏览器上传时 MIME 可能不精确 } @@ -51,6 +57,7 @@ def _try_upload_to_tos(local_path: str) -> Optional[str]: if not ak or not sk: try: from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() ak = cred.access_key_id sk = cred.secret_access_key @@ -60,8 +67,12 @@ def _try_upload_to_tos(local_path: str) -> Optional[str]: if not ak or not sk: return None - bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", "video-breakdown-uploads") - region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", "cn-beijing") + bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv( + "TOS_BUCKET", "video-breakdown-uploads" + ) + region = os.getenv("DATABASE_TOS_REGION") or os.getenv( + "TOS_REGION", "cn-beijing" + ) endpoint = f"tos-{region}.volces.com" client = tos.TosClientV2(ak=ak, sk=sk, endpoint=endpoint, region=region) @@ -142,11 +153,7 @@ def hook_video_upload( tos_url = _try_upload_to_tos(str(local_path)) if tos_url: - new_parts.append( - types.Part( - text=f"用户上传了视频文件,URL: {tos_url}" - ) - ) + new_parts.append(types.Part(text=f"用户上传了视频文件,URL: {tos_url}")) # TOS 上传成功后可以删除本地文件 try: local_path.unlink() @@ -156,16 +163,13 @@ def hook_video_upload( # TOS 不可用,使用本地路径 abs_path = str(local_path.resolve()) new_parts.append( - types.Part( - text=f"用户上传了视频文件,本地路径: {abs_path}" - ) + types.Part(text=f"用户上传了视频文件,本地路径: {abs_path}") ) if has_inline_data: user_content.parts = new_parts logger.info( - f"[video_upload_hook] inline_data 已转换为文本 " - f"({len(new_parts)} parts)" + f"[video_upload_hook] inline_data 已转换为文本 ({len(new_parts)} parts)" ) # 返回 None 表示不中断正常流程 diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py index 503d5094..9740176e 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py @@ -3,6 +3,7 @@ 负责接收视频URL/本地文件,完成 FFmpeg 预处理 + ASR + 视觉分析 + BGM 分析 无需外部后端服务,所有逻辑自包含在 tool 中 """ + import os from veadk import Agent diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 993a11f2..8dc2ac6b 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -5,6 +5,7 @@ 1. hook_analyzer_agent — 使用 vision 模型进行多模态分析 2. after_model_callback 仅做宽松输出修复,不做刚性 schema 校验 """ + import os from veadk import Agent @@ -12,7 +13,7 @@ from video_breakdown_agent.hook.format_hook import soft_fix_hook_output from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments -from video_breakdown_agent.utils.types import HookAnalysis, json_response_config +from video_breakdown_agent.utils.types import json_response_config from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION # 第一阶段:多模态视觉分析(使用 vision 模型) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py index dac27269..3c1e7534 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py @@ -2,6 +2,7 @@ 报告生成 Agent 整合分镜拆解数据和钩子分析结果,生成专业的视频分析报告 """ + import os from veadk import Agent diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py index bc9a5e32..91b51757 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py @@ -17,4 +17,3 @@ def direct_output_callback( if tool.name == "generate_video_report": tool_context.actions.skip_summarization = True return tool_response - diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py index 51243076..46ea7e33 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py @@ -5,12 +5,13 @@ 迁移来源: video-breakdown-master/app/services/bgm_service.py """ + from __future__ import annotations import json import logging import os -from typing import Any, Dict, Optional +from typing import Any, Dict from google.adk.tools import ToolContext from video_breakdown_agent.utils.doubao_client import call_doubao_text @@ -20,15 +21,45 @@ # ==================== 枚举常量(来自 bgm_prompts.py)==================== MUSIC_STYLE_OPTIONS = [ - "流行", "摇滚", "电子", "古典", "爵士", "民谣", "R&B", "嘻哈", - "乡村", "蓝调", "雷鬼", "金属", "朋克", "独立", "氛围", - "新世纪", "世界音乐", "原声配乐", "轻音乐", "纯音乐", + "流行", + "摇滚", + "电子", + "古典", + "爵士", + "民谣", + "R&B", + "嘻哈", + "乡村", + "蓝调", + "雷鬼", + "金属", + "朋克", + "独立", + "氛围", + "新世纪", + "世界音乐", + "原声配乐", + "轻音乐", + "纯音乐", ] EMOTION_OPTIONS = [ - "轻松愉悦", "积极向上", "温馨感人", "激昂励志", "浪漫甜蜜", "活力四射", "欢快热闹", - "平静舒缓", "神秘悬疑", "史诗宏大", "梦幻空灵", "复古怀旧", - "紧张刺激", "悲伤忧郁", "阴郁压抑", "恐怖惊悚", + "轻松愉悦", + "积极向上", + "温馨感人", + "激昂励志", + "浪漫甜蜜", + "活力四射", + "欢快热闹", + "平静舒缓", + "神秘悬疑", + "史诗宏大", + "梦幻空灵", + "复古怀旧", + "紧张刺激", + "悲伤忧郁", + "阴郁压抑", + "恐怖惊悚", ] @@ -56,9 +87,9 @@ def _get_empty_result() -> Dict[str, Any]: } - - -async def analyze_bgm(audio_url: str = "", duration: float = 0.0, tool_context: ToolContext = None) -> dict: +async def analyze_bgm( + audio_url: str = "", duration: float = 0.0, tool_context: ToolContext = None +) -> dict: """ 分析视频的背景音乐(BGM),识别风格、情绪、乐器、节奏等特征。 通过 LiteLLM 统一路由,需要模型支持音频输入(input_audio), @@ -201,7 +232,7 @@ async def analyze_bgm(audio_url: str = "", duration: float = 0.0, tool_context: {"role": "user", "content": user_prompt}, ] - schema = { + _schema = { # noqa: F841 # 保留作为 BGM 输出格式参考 "name": "bgm_analysis_schema", "schema": { "type": "object", @@ -247,7 +278,9 @@ async def analyze_bgm(audio_url: str = "", duration: float = 0.0, tool_context: } try: - logger.info(f"[analyze_bgm] 调用豆包模型 {model_name} 分析 BGM(音频分析当前仅以描述为主)...") + logger.info( + f"[analyze_bgm] 调用豆包模型 {model_name} 分析 BGM(音频分析当前仅以描述为主)..." + ) response = await call_doubao_text( model=model_name, messages=messages, diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py index 8197027a..19c7f865 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py @@ -3,6 +3,7 @@ 兼容新格式(process_video + analyze_segments_vision 输出)和旧格式 支持从 tool_context.state 读取完整的 base64 frame_urls """ + import logging from typing import Dict, List @@ -45,7 +46,9 @@ def analyze_hook_segments(tool_context: ToolContext = None) -> dict: vision_result = tool_context.state.get("vision_analysis_result") if vision_result and isinstance(vision_result, list): segments = vision_result - logger.info(f"[analyze_hook_segments] 从 session state 读取完整数据: {len(segments)} 个分镜") + logger.info( + f"[analyze_hook_segments] 从 session state 读取完整数据: {len(segments)} 个分镜" + ) if not segments: pv = tool_context.state.get("process_video_result") @@ -54,7 +57,7 @@ def analyze_hook_segments(tool_context: ToolContext = None) -> dict: logger.info( f"[analyze_hook_segments] vision_analysis_result 缺失,回退到 process_video_result: {len(segments)} 个分镜" ) - + if not segments: return { "error": "没有分镜数据", diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py index 1f9018b0..89d543d2 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py @@ -4,13 +4,14 @@ 迁移来源: video-breakdown-master/app/services/gemini_service.py """ + from __future__ import annotations import asyncio import json import logging import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, Optional from google.adk.tools import ToolContext from video_breakdown_agent.utils.doubao_client import call_doubao_vision @@ -19,10 +20,25 @@ # 功能标签枚举(来自 enhance_prompts.py) FUNCTION_TAG_OPTIONS = [ - "强钩子", "痛点切入", "问题澄清", "解决方案", "产品介绍", - "信任建立", "产品展示", "价值预告", "过渡", "价值输出", - "成分深挖", "效果证明", "用户证言", "情感背书", "价值强化", - "效果总结", "双重收益", "强CTA", "行动召唤", + "强钩子", + "痛点切入", + "问题澄清", + "解决方案", + "产品介绍", + "信任建立", + "产品展示", + "价值预告", + "过渡", + "价值输出", + "成分深挖", + "效果证明", + "用户证言", + "情感背书", + "价值强化", + "效果总结", + "双重收益", + "强CTA", + "行动召唤", ] @@ -78,19 +94,52 @@ def _build_segment_prompt(segment: Dict[str, Any]) -> Dict[str, Any]: "视觉表现": { "type": "object", "properties": { - "景别": {"type": "string", "enum": ["特写", "近景", "中景", "全景", "远景"]}, - "运镜": {"type": "string", "enum": ["固定", "推镜", "拉镜", "摇镜", "移镜", "跟随", "环绕", "推拉"]}, + "景别": { + "type": "string", + "enum": ["特写", "近景", "中景", "全景", "远景"], + }, + "运镜": { + "type": "string", + "enum": [ + "固定", + "推镜", + "拉镜", + "摇镜", + "移镜", + "跟随", + "环绕", + "推拉", + ], + }, "画面内容": {"type": "string"}, }, "required": ["景别", "运镜", "画面内容"], }, "语音类型": {"type": "string", "enum": ["口播", "旁白"]}, "summary": {"type": "string"}, - "画面小标题": {"type": "string", "description": "画面核心要点,≤10个汉字"}, - "内容标签": {"type": "array", "items": {"type": "string"}, "description": "1-2个内容关键标签"}, - "功能标签": {"type": "string", "enum": FUNCTION_TAG_OPTIONS, "description": "该片段在视频叙事中的功能角色"}, + "画面小标题": { + "type": "string", + "description": "画面核心要点,≤10个汉字", + }, + "内容标签": { + "type": "array", + "items": {"type": "string"}, + "description": "1-2个内容关键标签", + }, + "功能标签": { + "type": "string", + "enum": FUNCTION_TAG_OPTIONS, + "description": "该片段在视频叙事中的功能角色", + }, }, - "required": ["视觉表现", "语音类型", "summary", "画面小标题", "内容标签", "功能标签"], + "required": [ + "视觉表现", + "语音类型", + "summary", + "画面小标题", + "内容标签", + "功能标签", + ], }, } @@ -129,8 +178,6 @@ def _strip_code_fence(text: str) -> str: return text.strip() - - async def _analyze_single_segment( segment: Dict[str, Any], model_name: str, @@ -147,8 +194,12 @@ async def _analyze_single_segment( { "role": "user", "content": [ - {"type": "text", "text": json.dumps(prompt_data["instruction"], ensure_ascii=False)}, - ] + [ + { + "type": "text", + "text": json.dumps(prompt_data["instruction"], ensure_ascii=False), + }, + ] + + [ {"type": "image_url", "image_url": {"url": url}} for url in segment.get("frame_urls", [])[:6] ], @@ -194,7 +245,9 @@ async def _analyze_single_segment( except Exception as exc: last_error = exc - logger.warning(f"分镜 {segment['index']} 分析失败 (尝试 {attempt + 1}/{max_retries}): {exc}") + logger.warning( + f"分镜 {segment['index']} 分析失败 (尝试 {attempt + 1}/{max_retries}): {exc}" + ) if attempt < max_retries - 1: await asyncio.sleep(1 + attempt) @@ -202,7 +255,9 @@ async def _analyze_single_segment( return _create_fallback(segment) -async def analyze_segments_vision(segments_json: str = "", tool_context: ToolContext = None) -> str: +async def analyze_segments_vision( + segments_json: str = "", tool_context: ToolContext = None +) -> str: """ 使用视觉模型分析每个分镜的画面内容、景别、运镜、功能标签。 通过 LiteLLM 统一路由,支持 Gemini / 豆包 / GPT-4o 等多种模型。 @@ -226,21 +281,33 @@ async def analyze_segments_vision(segments_json: str = "", tool_context: ToolCon state_result = tool_context.state.get("process_video_result") if state_result and isinstance(state_result, dict): segments = state_result.get("segments", []) - logger.info(f"[analyze_segments_vision] 从 session state 读取 {len(segments)} 个分镜") + logger.info( + f"[analyze_segments_vision] 从 session state 读取 {len(segments)} 个分镜" + ) # 回退:从参数解析 if not segments and segments_json: try: - parsed = json.loads(segments_json) if isinstance(segments_json, str) else segments_json + parsed = ( + json.loads(segments_json) + if isinstance(segments_json, str) + else segments_json + ) if isinstance(parsed, dict): segments = parsed.get("segments", []) elif isinstance(parsed, list): segments = parsed except json.JSONDecodeError: - return json.dumps({"error": "segments_json 格式无效,需要合法 JSON 数组"}, ensure_ascii=False) + return json.dumps( + {"error": "segments_json 格式无效,需要合法 JSON 数组"}, + ensure_ascii=False, + ) if not segments or not isinstance(segments, list) or len(segments) == 0: - return json.dumps({"error": "没有可用的分镜数据。请先调用 process_video 处理视频。"}, ensure_ascii=False) + return json.dumps( + {"error": "没有可用的分镜数据。请先调用 process_video 处理视频。"}, + ensure_ascii=False, + ) # ---- 读取模型配置 ---- # LiteLLM 模型名约定: @@ -248,9 +315,8 @@ async def analyze_segments_vision(segments_json: str = "", tool_context: ToolCon # openai/gemini-2.5-pro → OpenAI 兼容代理(如 OneRouter) # doubao-seed-1-6-251015 → 火山方舟 (需 api_base + api_key) # gpt-4o → OpenAI - model_name = ( - os.getenv("MODEL_VISION_NAME") - or os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-vision") + model_name = os.getenv("MODEL_VISION_NAME") or os.getenv( + "MODEL_AGENT_NAME", "doubao-seed-1-6-vision" ) api_key = ( os.getenv("MODEL_VISION_API_KEY") @@ -265,14 +331,19 @@ async def analyze_segments_vision(segments_json: str = "", tool_context: ToolCon ) if not api_key: - return json.dumps({ - "error": "缺少 API Key,请配置 MODEL_VISION_API_KEY / GEMINI_API_KEY / MODEL_AGENT_API_KEY" - }, ensure_ascii=False) + return json.dumps( + { + "error": "缺少 API Key,请配置 MODEL_VISION_API_KEY / GEMINI_API_KEY / MODEL_AGENT_API_KEY" + }, + ensure_ascii=False, + ) # 过滤:只分析有帧图片的分镜 segments_with_frames = [s for s in segments if s.get("frame_urls")] if not segments_with_frames: - return json.dumps({"error": "没有分镜包含帧图片URL,无法进行视觉分析"}, ensure_ascii=False) + return json.dumps( + {"error": "没有分镜包含帧图片URL,无法进行视觉分析"}, ensure_ascii=False + ) logger.info( f"[analyze_segments_vision] 开始分析 {len(segments_with_frames)} 个分镜 (model={model_name})" @@ -316,6 +387,8 @@ async def analyze_one(seg: Dict[str, Any]) -> Dict[str, Any]: "(base64图片已省略)" if url and url.startswith("data:") else url for url in frame_urls ] - logger.debug(f"分镜 {result['index']} 精简 frame_urls: {len(frame_urls)} 帧 → 占位符") + logger.debug( + f"分镜 {result['index']} 精简 frame_urls: {len(frame_urls)} 帧 → 占位符" + ) return json.dumps(valid_results, ensure_ascii=False) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py index cec5601f..30b9b0f4 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py @@ -7,6 +7,7 @@ - video-breakdown-master/app/services/media_processor.py - video-breakdown-master/app/services/asr_service.py """ + from __future__ import annotations import asyncio @@ -37,6 +38,7 @@ @dataclass class SegmentAsset: """单个时间片段的资源信息""" + index: int start: float end: float @@ -77,6 +79,7 @@ def _resolve_ffmpeg_paths() -> tuple[str, Optional[str]]: if not ffmpeg_bin: try: import imageio_ffmpeg + ffmpeg_bin = imageio_ffmpeg.get_ffmpeg_exe() logger.info(f"系统无 FFmpeg,使用 imageio-ffmpeg 内置: {ffmpeg_bin}") except ImportError: @@ -111,7 +114,9 @@ def _run_command(cmd: List[str]) -> str: return process.stdout.strip() -def _probe_video(ffprobe_bin: Optional[str], ffmpeg_bin: str, video_path: Path) -> Dict[str, Any]: +def _probe_video( + ffprobe_bin: Optional[str], ffmpeg_bin: str, video_path: Path +) -> Dict[str, Any]: """ 获取视频元数据。 @@ -126,9 +131,13 @@ def _probe_video(ffprobe_bin: Optional[str], ffmpeg_bin: str, video_path: Path) def _probe_with_ffprobe(ffprobe_bin: str, video_path: Path) -> Dict[str, Any]: """使用 ffprobe 获取视频元数据(首选方式)""" cmd = [ - ffprobe_bin, "-v", "error", - "-print_format", "json", - "-show_format", "-show_streams", + ffprobe_bin, + "-v", + "error", + "-print_format", + "json", + "-show_format", + "-show_streams", str(video_path), ] result = _run_command(cmd) @@ -198,7 +207,9 @@ def _probe_with_ffmpeg(ffmpeg_bin: str, video_path: Path) -> Dict[str, Any]: if fps_match: frame_rate = fps_match.group(1) - logger.info(f"ffmpeg 探测: duration={duration}s, {width}x{height}, fps={frame_rate}") + logger.info( + f"ffmpeg 探测: duration={duration}s, {width}x{height}, fps={frame_rate}" + ) return { "duration": duration, "width": width, @@ -213,10 +224,19 @@ def _extract_audio_sync(ffmpeg_bin: str, video_path: Path) -> Optional[Path]: """提取音频轨(同步版本,由 asyncio.to_thread 调用)""" output_path = video_path.parent / f"{video_path.stem}.mp3" cmd = [ - ffmpeg_bin, "-y", - "-i", str(video_path), - "-vn", "-ac", "1", "-ar", "16000", - "-codec:a", "libmp3lame", "-b:a", "128k", + ffmpeg_bin, + "-y", + "-i", + str(video_path), + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-codec:a", + "libmp3lame", + "-b:a", + "128k", str(output_path), ] try: @@ -306,11 +326,16 @@ def _extract_segment_frames( output_path = frames_dir / f"seg{segment.index:03d}_frame_{i}.jpg" cmd = [ - ffmpeg_bin, "-y", - "-ss", f"{offset:.2f}", - "-i", str(video_path), - "-frames:v", "1", - "-q:v", "8", # 降低质量减小文件体积(2=最高质量,31=最低质量) + ffmpeg_bin, + "-y", + "-ss", + f"{offset:.2f}", + "-i", + str(video_path), + "-frames:v", + "1", + "-q:v", + "8", # 降低质量减小文件体积(2=最高质量,31=最低质量) str(output_path), ] try: @@ -332,13 +357,24 @@ def _extract_single_clip( output_path = clips_dir / f"seg{segment.index:03d}_clip.mp4" cmd = [ - ffmpeg_bin, "-y", - "-ss", f"{segment.start:.2f}", - "-i", str(video_path), - "-t", f"{duration:.2f}", - "-c:v", "libx264", "-c:a", "aac", - "-b:v", "1000k", "-b:a", "128k", - "-movflags", "+faststart", + ffmpeg_bin, + "-y", + "-ss", + f"{segment.start:.2f}", + "-i", + str(video_path), + "-t", + f"{duration:.2f}", + "-c:v", + "libx264", + "-c:a", + "aac", + "-b:v", + "1000k", + "-b:a", + "128k", + "-movflags", + "+faststart", str(output_path), ] try: @@ -360,7 +396,9 @@ async def _transcribe_audio(audio_url: str) -> Optional[Dict[str, Any]]: # VeADK 扁平化: asr.app_id → ASR_APP_ID; 兼容旧名 VOLC_ASR_* app_id = os.getenv("ASR_APP_ID") or os.getenv("VOLC_ASR_APP_ID", "") access_key = os.getenv("ASR_ACCESS_KEY") or os.getenv("VOLC_ASR_ACCESS_KEY", "") - resource_id = os.getenv("ASR_RESOURCE_ID") or os.getenv("VOLC_ASR_RESOURCE_ID", "volc.bigasr.auc") + resource_id = os.getenv("ASR_RESOURCE_ID") or os.getenv( + "VOLC_ASR_RESOURCE_ID", "volc.bigasr.auc" + ) submit_endpoint = os.getenv( "ASR_ENDPOINT", ) or os.getenv( @@ -375,7 +413,9 @@ async def _transcribe_audio(audio_url: str) -> Optional[Dict[str, Any]]: ) if not app_id or not access_key: - logger.info("未配置火山 ASR(VOLC_ASR_APP_ID / VOLC_ASR_ACCESS_KEY),跳过语音识别") + logger.info( + "未配置火山 ASR(VOLC_ASR_APP_ID / VOLC_ASR_ACCESS_KEY),跳过语音识别" + ) return None request_id = str(uuid.uuid4()) @@ -458,11 +498,13 @@ def _parse_asr_result(response_json: Dict[str, Any]) -> Optional[Dict[str, Any]] for item in utterances: text = item.get("text") if text: - segments.append({ - "text": text.strip(), - "start": (item.get("start_time") or 0) / 1000.0, - "end": (item.get("end_time") or 0) / 1000.0, - }) + segments.append( + { + "text": text.strip(), + "start": (item.get("start_time") or 0) / 1000.0, + "end": (item.get("end_time") or 0) / 1000.0, + } + ) merged_text = "\n".join([c for c in text_chunks if c]).strip() if not merged_text: @@ -481,6 +523,7 @@ def _get_tos_client() -> Optional[tos.TosClientV2]: if not ak or not sk: try: from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() ak = cred.access_key_id sk = cred.secret_access_key @@ -503,7 +546,11 @@ async def _upload_to_tos( """上传字节到 TOS 并返回签名 URL""" try: await asyncio.to_thread( - client.put_object, bucket=bucket, key=key, content=content, content_type=content_type + client.put_object, + bucket=bucket, + key=key, + content=content, + content_type=content_type, ) signed = await asyncio.to_thread( client.pre_signed_url, @@ -586,8 +633,12 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: # 自动检测 ffmpeg/ffprobe 路径(系统 → imageio-ffmpeg 回退) ffmpeg_bin, ffprobe_bin = _resolve_ffmpeg_paths() # 减少帧数以降低 base64 体积(避免超过 LLM tokens 限制) - frames_per_segment = int(os.getenv("FFMPEG_FRAMES_PER_SEGMENT") or os.getenv("FRAMES_PER_SEGMENT", "2")) - temp_base = os.getenv("FFMPEG_MEDIA_TEMP_DIR") or os.getenv("MEDIA_TEMP_DIR", "./.media-cache") + frames_per_segment = int( + os.getenv("FFMPEG_FRAMES_PER_SEGMENT") or os.getenv("FRAMES_PER_SEGMENT", "2") + ) + temp_base = os.getenv("FFMPEG_MEDIA_TEMP_DIR") or os.getenv( + "MEDIA_TEMP_DIR", "./.media-cache" + ) Path(temp_base).mkdir(parents=True, exist_ok=True) task_id = datetime.now().strftime("%Y%m%d_%H%M%S") + "_" + uuid.uuid4().hex[:8] @@ -595,7 +646,9 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: local_video = temp_dir / f"{task_id}.mp4" # TOS 配置(VeADK 扁平化: database.tos.bucket → DATABASE_TOS_BUCKET) - bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", "video-breakdown-uploads") + bucket = os.getenv("DATABASE_TOS_BUCKET") or os.getenv( + "TOS_BUCKET", "video-breakdown-uploads" + ) tos_prefix = os.getenv("TOS_OUTPUT_PREFIX", "videobreak") try: @@ -609,9 +662,13 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: file_size = local_source.stat().st_size max_video_size = 2 * 1024 * 1024 * 1024 # 2GB if file_size > max_video_size: - return {"error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试"} + return { + "error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试" + } shutil.copy2(str(local_source), str(local_video)) - logger.info(f"[process_video] 使用本地文件: {local_source} ({file_size / 1024 / 1024:.1f}MB)") + logger.info( + f"[process_video] 使用本地文件: {local_source} ({file_size / 1024 / 1024:.1f}MB)" + ) else: # HTTP URL:流式下载 logger.info(f"[process_video] 下载视频: {video_url[:100]}...") @@ -624,20 +681,30 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: async for chunk in resp.aiter_bytes(chunk_size=65536): total_downloaded += len(chunk) if total_downloaded > max_video_size: - return {"error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试"} + return { + "error": f"视频文件过大(>{max_video_size // 1024 // 1024}MB),请压缩后重试" + } f.write(chunk) - logger.info(f"[process_video] 下载完成: {local_video} ({total_downloaded / 1024 / 1024:.1f}MB)") + logger.info( + f"[process_video] 下载完成: {local_video} ({total_downloaded / 1024 / 1024:.1f}MB)" + ) # ---- Step 2: 元数据 ---- - metadata = await asyncio.to_thread(_probe_video, ffprobe_bin, ffmpeg_bin, local_video) + metadata = await asyncio.to_thread( + _probe_video, ffprobe_bin, ffmpeg_bin, local_video + ) duration = float(metadata.get("duration") or 0.0) if duration <= 0: return {"error": "无法获取视频时长,请确认视频URL有效"} - resolution = f'{metadata.get("width")}x{metadata.get("height")}' - logger.info(f"[process_video] 元数据: 时长={duration:.1f}s, 分辨率={resolution}") + resolution = f"{metadata.get('width')}x{metadata.get('height')}" + logger.info( + f"[process_video] 元数据: 时长={duration:.1f}s, 分辨率={resolution}" + ) # ---- Step 3: 提取音频 ---- - audio_path = await asyncio.to_thread(_extract_audio_sync, ffmpeg_bin, local_video) + audio_path = await asyncio.to_thread( + _extract_audio_sync, ffmpeg_bin, local_video + ) audio_url_out = None audio_base64 = None @@ -668,7 +735,11 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: # ---- Step 6: 提取关键帧(并发) ---- frame_tasks = [ asyncio.to_thread( - _extract_segment_frames, ffmpeg_bin, local_video, seg, frames_per_segment + _extract_segment_frames, + ffmpeg_bin, + local_video, + seg, + frames_per_segment, ) for seg in segments ] @@ -701,7 +772,14 @@ async def process_video(video_url: str, tool_context: ToolContext) -> dict: if seg.clip_path and seg.clip_path.exists(): key = f"{tos_prefix}/{task_id}/clips/{seg.clip_path.name}" upload_tasks.append( - ("clip", seg, seg.clip_path, key, seg.clip_path.read_bytes(), "video/mp4") + ( + "clip", + seg, + seg.clip_path, + key, + seg.clip_path.read_bytes(), + "video/mp4", + ) ) # 并发上传(信号量限制并发数,避免 TOS 限流) @@ -759,15 +837,17 @@ async def _upload_one(item): segments_output = [] for seg in segments: - segments_output.append({ - "index": seg.index, - "start": round(seg.start, 2), - "end": round(seg.end, 2), - "frame_urls": seg.frame_urls, - "clip_url": seg.clip_url, - "is_speech": seg.is_speech, - "speech_text": seg.speech_text, - }) + segments_output.append( + { + "index": seg.index, + "start": round(seg.start, 2), + "end": round(seg.end, 2), + "frame_urls": seg.frame_urls, + "clip_url": seg.clip_url, + "is_speech": seg.is_speech, + "speech_text": seg.speech_text, + } + ) result = { "task_id": task_id, @@ -808,7 +888,9 @@ async def _upload_one(item): except httpx.HTTPError as exc: return {"error": f"视频下载失败: {exc}"} except subprocess.CalledProcessError as exc: - return {"error": f"FFmpeg 处理失败: {exc.stderr[:300] if exc.stderr else str(exc)}"} + return { + "error": f"FFmpeg 处理失败: {exc.stderr[:300] if exc.stderr else str(exc)}" + } except Exception as exc: logger.error(f"[process_video] 异常: {exc}", exc_info=True) return {"error": f"视频预处理失败: {str(exc)}"} diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py index af70010d..80fb2fc5 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py @@ -2,6 +2,7 @@ 视频分析报告生成工具 兼容新格式(process_video + analyze_segments_vision 输出)和旧格式 """ + import logging from datetime import datetime from typing import Optional @@ -142,7 +143,9 @@ def generate_video_report( ) if not breakdown_data: - logger.warning("报告生成失败:缺少 breakdown_data(state: process_video_result)") + logger.warning( + "报告生成失败:缺少 breakdown_data(state: process_video_result)" + ) return ( "## 报告生成失败\n\n" "- 未找到分镜拆解数据(`process_video_result`)。\n" @@ -171,17 +174,31 @@ def generate_video_report( full_transcript = breakdown_data.get("full_transcript", "") # 提取 BGM 信息 - music_style = bgm.get("music_style", {}).get("primary", "N/A") if isinstance(bgm.get("music_style"), dict) else "N/A" - emotion = bgm.get("emotion", {}).get("primary", "N/A") if isinstance(bgm.get("emotion"), dict) else "N/A" - tempo = bgm.get("tempo", {}).get("bpm_estimate", "N/A") if isinstance(bgm.get("tempo"), dict) else "N/A" - tempo_pace = bgm.get("tempo", {}).get("pace", "N/A") if isinstance(bgm.get("tempo"), dict) else "N/A" + music_style = ( + bgm.get("music_style", {}).get("primary", "N/A") + if isinstance(bgm.get("music_style"), dict) + else "N/A" + ) + emotion = ( + bgm.get("emotion", {}).get("primary", "N/A") + if isinstance(bgm.get("emotion"), dict) + else "N/A" + ) + tempo = ( + bgm.get("tempo", {}).get("bpm_estimate", "N/A") + if isinstance(bgm.get("tempo"), dict) + else "N/A" + ) + tempo_pace = ( + bgm.get("tempo", {}).get("pace", "N/A") + if isinstance(bgm.get("tempo"), dict) + else "N/A" + ) has_bgm = bgm.get("has_bgm") # 提取场景信息 primary_scene = scene.get("primary_scene", "N/A") if scene else "N/A" - video_style = ( - scene.get("video_style", {}).get("overall", "N/A") if scene else "N/A" - ) + video_style = scene.get("video_style", {}).get("overall", "N/A") if scene else "N/A" target_audience = ( ", ".join(scene.get("video_style", {}).get("target_audience", [])) if scene @@ -251,7 +268,7 @@ def generate_video_report( --- -**报告生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} +**报告生成时间**: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} """ logger.info(f"报告生成完成,总长度: {len(report)} 字符") @@ -275,11 +292,31 @@ def _build_hook_section(hook_analysis: dict) -> str: # 使用分段展示每个维度(评价内容可能很长,表格不适合) dimensions = [ - ("视觉冲击力", hook_analysis.get("visual_impact", 0), hook_analysis.get("visual_comment", "")), - ("语言钩子", hook_analysis.get("language_hook", 0), hook_analysis.get("language_comment", "")), - ("情绪唤起", hook_analysis.get("emotion_trigger", 0), hook_analysis.get("emotion_comment", "")), - ("信息密度", hook_analysis.get("information_density", 0), hook_analysis.get("info_comment", "")), - ("节奏掌控", hook_analysis.get("rhythm_control", 0), hook_analysis.get("rhythm_comment", "")), + ( + "视觉冲击力", + hook_analysis.get("visual_impact", 0), + hook_analysis.get("visual_comment", ""), + ), + ( + "语言钩子", + hook_analysis.get("language_hook", 0), + hook_analysis.get("language_comment", ""), + ), + ( + "情绪唤起", + hook_analysis.get("emotion_trigger", 0), + hook_analysis.get("emotion_comment", ""), + ), + ( + "信息密度", + hook_analysis.get("information_density", 0), + hook_analysis.get("info_comment", ""), + ), + ( + "节奏掌控", + hook_analysis.get("rhythm_control", 0), + hook_analysis.get("rhythm_comment", ""), + ), ] dimension_sections = [] @@ -291,9 +328,7 @@ def _build_hook_section(hook_analysis: dict) -> str: weaknesses = hook_analysis.get("weaknesses", []) suggestions = hook_analysis.get("suggestions", []) - strengths_text = ( - "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" - ) + strengths_text = "\n".join(f"- {s}" for s in strengths) if strengths else "- 暂无" weaknesses_text = ( "\n".join(f"- {w}" for w in weaknesses) if weaknesses else "- 暂无" ) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py index eceb34cc..0c87a702 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py @@ -2,6 +2,7 @@ 视频文件上传到 TOS 对象存储工具 基于 inspection_assistant/tools/tos_upload.py 模式实现 """ + import logging import os from datetime import datetime @@ -35,7 +36,9 @@ def video_upload_to_tos( """ # 从环境变量/config.yaml 读取配置(VeADK 扁平化: DATABASE_TOS_*,兼容旧: TOS_*) if bucket_name is None: - bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv("TOS_BUCKET", DEFAULT_BUCKET) + bucket_name = os.getenv("DATABASE_TOS_BUCKET") or os.getenv( + "TOS_BUCKET", DEFAULT_BUCKET + ) region = os.getenv("DATABASE_TOS_REGION") or os.getenv("TOS_REGION", DEFAULT_REGION) # 检查文件是否存在 @@ -59,6 +62,7 @@ def video_upload_to_tos( if not (access_key and secret_key): try: from veadk.auth.veauth.utils import get_credential_from_vefaas_iam + cred = get_credential_from_vefaas_iam() access_key = cred.access_key_id secret_key = cred.secret_access_key diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py index 99f65979..d5d9b496 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py @@ -9,6 +9,7 @@ - input content 类型:input_text / input_image(非 text / image_url) - 参考:https://www.volcengine.com/docs/82379/1541595 """ + import json import logging import os @@ -82,10 +83,12 @@ async def text_completion( response = await self.client.post(url, headers=headers, json=payload) response.raise_for_status() result = response.json() - logger.debug(f"豆包文本 API 响应成功") + logger.debug("豆包文本 API 响应成功") return result except httpx.HTTPStatusError as e: - logger.error(f"豆包文本 API HTTP 错误: {e.response.status_code} - {e.response.text}") + logger.error( + f"豆包文本 API HTTP 错误: {e.response.status_code} - {e.response.text}" + ) raise except Exception as e: logger.error(f"豆包文本 API 调用失败: {e}") @@ -148,7 +151,9 @@ async def vision_completion( # 转换为统一的 OpenAI 格式 return self._convert_vision_response(raw) except httpx.HTTPStatusError as e: - logger.error(f"豆包视觉 API HTTP 错误: {e.response.status_code} - {e.response.text}") + logger.error( + f"豆包视觉 API HTTP 错误: {e.response.status_code} - {e.response.text}" + ) raise except Exception as e: logger.error(f"豆包视觉 API 调用失败: {e}") @@ -157,7 +162,9 @@ async def vision_completion( # ==================== 格式转换 ==================== @staticmethod - def _convert_messages_to_doubao_input(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + def _convert_messages_to_doubao_input( + messages: List[Dict[str, Any]], + ) -> List[Dict[str, Any]]: """ 将标准 OpenAI messages 转换为豆包 Responses API 的 input 格式 @@ -175,18 +182,24 @@ def _convert_messages_to_doubao_input(messages: List[Dict[str, Any]]) -> List[Di # system message → 转为 user 的 input_text(豆包 vision API 可能不支持 system role) if role == "system": if isinstance(content, str): - doubao_input.append({ - "role": "user", - "content": [{"type": "input_text", "text": f"[系统提示] {content}"}], - }) + doubao_input.append( + { + "role": "user", + "content": [ + {"type": "input_text", "text": f"[系统提示] {content}"} + ], + } + ) continue # content 是纯字符串 if isinstance(content, str): - doubao_input.append({ - "role": role, - "content": [{"type": "input_text", "text": content}], - }) + doubao_input.append( + { + "role": role, + "content": [{"type": "input_text", "text": content}], + } + ) continue # content 是列表(多模态内容) @@ -195,26 +208,34 @@ def _convert_messages_to_doubao_input(messages: List[Dict[str, Any]]) -> List[Di item_type = item.get("type", "") if item_type == "text": - doubao_content.append({ - "type": "input_text", - "text": item["text"], - }) + doubao_content.append( + { + "type": "input_text", + "text": item["text"], + } + ) elif item_type == "image_url": # 提取 URL(OpenAI 格式可能是 {"url": "..."} 或字符串) url_data = item.get("image_url") - url = url_data.get("url") if isinstance(url_data, dict) else url_data - doubao_content.append({ - "type": "input_image", - "image_url": url, - }) + url = ( + url_data.get("url") if isinstance(url_data, dict) else url_data + ) + doubao_content.append( + { + "type": "input_image", + "image_url": url, + } + ) else: # 未知类型保持原样 doubao_content.append(item) - doubao_input.append({ - "role": role, - "content": doubao_content, - }) + doubao_input.append( + { + "role": role, + "content": doubao_content, + } + ) return doubao_input @@ -270,7 +291,9 @@ def _convert_vision_response(raw: Dict[str, Any]) -> Dict[str, Any]: # 最终兜底 if not content_text: content_text = json.dumps(raw, ensure_ascii=False) - logger.warning(f"豆包视觉 API 响应中未找到 message 内容,完整响应: {content_text[:300]}") + logger.warning( + f"豆包视觉 API 响应中未找到 message 内容,完整响应: {content_text[:300]}" + ) # 提取 usage usage = raw.get("usage", {}) @@ -317,7 +340,9 @@ async def call_doubao_text( if not api_key: raise ValueError("API Key 未提供,且环境变量 MODEL_AGENT_API_KEY 未设置") if not api_base: - api_base = os.getenv("MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3") + api_base = os.getenv( + "MODEL_AGENT_API_BASE", "https://ark.cn-beijing.volces.com/api/v3" + ) async with DoubaoClient(api_key=api_key, api_base=api_base) as client: return await client.text_completion(model=model, messages=messages, **kwargs) @@ -336,7 +361,9 @@ async def call_doubao_vision( if not api_key: raise ValueError("API Key 未提供,且环境变量 MODEL_VISION_API_KEY 未设置") if not api_base: - api_base = os.getenv("MODEL_VISION_API_BASE", "https://ark.cn-beijing.volces.com/api/v3") + api_base = os.getenv( + "MODEL_VISION_API_BASE", "https://ark.cn-beijing.volces.com/api/v3" + ) async with DoubaoClient(api_key=api_key, api_base=api_base) as client: return await client.vision_completion(model=model, messages=messages, **kwargs) diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py index 0f5f91e0..58fd8c0c 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py +++ b/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py @@ -2,6 +2,7 @@ 数据类型定义 定义 Pydantic 模型用于 Agent 输出格式校验 """ + from typing import List, Optional from google.genai import types @@ -17,54 +18,26 @@ class HookAnalysis(BaseModel): """前三秒钩子分析结果的结构化输出""" - overall_score: float = Field( - description="综合评分(0-10),各维度加权平均" - ) - visual_impact: float = Field( - description="视觉冲击力分数(0-10)" - ) - visual_comment: str = Field( - description="视觉冲击力的具体评价" - ) - language_hook: float = Field( - description="语言钩子分数(0-10)" - ) - language_comment: str = Field( - description="语言钩子的具体评价" - ) - emotion_trigger: float = Field( - description="情绪唤起分数(0-10)" - ) - emotion_comment: str = Field( - description="情绪唤起的具体评价" - ) - information_density: float = Field( - description="信息密度分数(0-10)" - ) - info_comment: str = Field( - description="信息密度的具体评价" - ) - rhythm_control: float = Field( - description="节奏掌控分数(0-10)" - ) - rhythm_comment: str = Field( - description="节奏掌控的具体评价" - ) + overall_score: float = Field(description="综合评分(0-10),各维度加权平均") + visual_impact: float = Field(description="视觉冲击力分数(0-10)") + visual_comment: str = Field(description="视觉冲击力的具体评价") + language_hook: float = Field(description="语言钩子分数(0-10)") + language_comment: str = Field(description="语言钩子的具体评价") + emotion_trigger: float = Field(description="情绪唤起分数(0-10)") + emotion_comment: str = Field(description="情绪唤起的具体评价") + information_density: float = Field(description="信息密度分数(0-10)") + info_comment: str = Field(description="信息密度的具体评价") + rhythm_control: float = Field(description="节奏掌控分数(0-10)") + rhythm_comment: str = Field(description="节奏掌控的具体评价") hook_type: str = Field( description="钩子类型(痛点型/好奇型/冲突型/价值型/情感型/视觉冲击型/悬念型)" ) hook_type_analysis: Optional[str] = Field( default="", description="为什么判定为该钩子类型的详细理由" ) - target_audience: Optional[str] = Field( - default="", description="推测的目标受众画像" - ) - strengths: List[str] = Field( - description="优点列表(需具体引用画面元素)" - ) - weaknesses: List[str] = Field( - description="不足列表(需具体指出问题)" - ) + target_audience: Optional[str] = Field(default="", description="推测的目标受众画像") + strengths: List[str] = Field(description="优点列表(需具体引用画面元素)") + weaknesses: List[str] = Field(description="不足列表(需具体指出问题)") suggestions: List[str] = Field( description="优化建议列表(说明改什么、怎么改、预期效果)" ) From 1144638b3c2a0adf13a92081ac1fd441a1dcee86 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 13:55:40 +0800 Subject: [PATCH 03/13] chore: upgrade agentkit-sdk-python to 0.5.1 and move project to python/02-use-cases/video_breakdown_agent - Upgrade agentkit-sdk-python from 0.2.2 to 0.5.1 - Move project from partners/video_breakdown_agent to python/02-use-cases/video_breakdown_agent for better discoverability - Update README installation instructions to reflect new version - Sync uv.lock with updated dependencies --- .../{partners => }/video_breakdown_agent/.gitignore | 0 .../video_breakdown_agent/.scripts/smoke_test.py | 0 .../video_breakdown_agent/.skills/README.md | 0 .../.skills/hook-analyzer-skill/SKILL.md | 0 .../scripts/analyze_hook_segments.py | 0 .../.skills/report-generator-skill/SKILL.md | 0 .../scripts/generate_report.py | 0 .../.skills/video-breakdown-skill/SKILL.md | 0 .../video-breakdown-skill/scripts/process_video.py | 0 .../video-breakdown-skill/scripts/video_upload.py | 0 .../{partners => }/video_breakdown_agent/LICENSE | 0 .../{partners => }/video_breakdown_agent/README.md | 4 ++-- .../video_breakdown_agent/README_en.md | 0 .../{partners => }/video_breakdown_agent/agent.py | 0 .../video_breakdown_agent/assets/README.md | 0 .../assets/architecture_video_breakdown_agent.jpg | Bin .../video_breakdown_agent/config.yaml.example | 0 .../video_breakdown_agent/dataset.json | 0 .../{partners => }/video_breakdown_agent/deploy.sh | 0 .../video_breakdown_agent/docs/DEPLOY_GUIDE.md | 0 .../video_breakdown_agent/docs/README_CONFIG.md | 0 .../video_breakdown_agent/project.yaml | 0 .../video_breakdown_agent/pyproject.toml | 2 +- .../video_breakdown_agent/requirements.txt | 2 +- .../sandbox/docker-compose.yml | 0 .../sandbox/sandbox.env.example | 0 .../sandbox/sandbox_manager.py | 0 .../video_breakdown_agent/setup_env.sh | 0 .../video_breakdown_agent/__init__.py | 0 .../video_breakdown_agent/agent.py | 0 .../video_breakdown_agent/hook/__init__.py | 0 .../video_breakdown_agent/hook/final_output_hook.py | 0 .../video_breakdown_agent/hook/format_hook.py | 0 .../hook/search_output_hook.py | 0 .../video_breakdown_agent/hook/video_upload_hook.py | 0 .../video_breakdown_agent/prompt.py | 0 .../video_breakdown_agent/sub_agents/__init__.py | 0 .../sub_agents/breakdown_agent/__init__.py | 0 .../sub_agents/breakdown_agent/agent.py | 0 .../sub_agents/breakdown_agent/prompt.py | 0 .../sub_agents/hook_analyzer_agent/__init__.py | 0 .../sub_agents/hook_analyzer_agent/agent.py | 0 .../sub_agents/hook_analyzer_agent/prompt.py | 0 .../sub_agents/report_generator_agent/__init__.py | 0 .../sub_agents/report_generator_agent/agent.py | 0 .../direct_output_callback.py | 0 .../sub_agents/report_generator_agent/prompt.py | 0 .../video_breakdown_agent/tools/__init__.py | 0 .../video_breakdown_agent/tools/analyze_bgm.py | 0 .../tools/analyze_hook_segments.py | 0 .../tools/analyze_segments_vision.py | 0 .../video_breakdown_agent/tools/process_video.py | 0 .../video_breakdown_agent/tools/report_generator.py | 0 .../video_breakdown_agent/tools/video_upload.py | 0 .../video_breakdown_agent/utils/__init__.py | 0 .../video_breakdown_agent/utils/doubao_client.py | 0 .../video_breakdown_agent/utils/types.py | 0 57 files changed, 4 insertions(+), 4 deletions(-) rename python/02-use-cases/{partners => }/video_breakdown_agent/.gitignore (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.scripts/smoke_test.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/README.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/report-generator-skill/SKILL.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/LICENSE (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/README.md (99%) rename python/02-use-cases/{partners => }/video_breakdown_agent/README_en.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/agent.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/assets/README.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/config.yaml.example (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/dataset.json (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/deploy.sh (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/docs/DEPLOY_GUIDE.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/docs/README_CONFIG.md (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/project.yaml (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/pyproject.toml (96%) rename python/02-use-cases/{partners => }/video_breakdown_agent/requirements.txt (95%) rename python/02-use-cases/{partners => }/video_breakdown_agent/sandbox/docker-compose.yml (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/sandbox/sandbox.env.example (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/sandbox/sandbox_manager.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/setup_env.sh (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/agent.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/hook/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/prompt.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/process_video.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/utils/__init__.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py (100%) rename python/02-use-cases/{partners => }/video_breakdown_agent/video_breakdown_agent/utils/types.py (100%) diff --git a/python/02-use-cases/partners/video_breakdown_agent/.gitignore b/python/02-use-cases/video_breakdown_agent/.gitignore similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.gitignore rename to python/02-use-cases/video_breakdown_agent/.gitignore diff --git a/python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py b/python/02-use-cases/video_breakdown_agent/.scripts/smoke_test.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.scripts/smoke_test.py rename to python/02-use-cases/video_breakdown_agent/.scripts/smoke_test.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/README.md b/python/02-use-cases/video_breakdown_agent/.skills/README.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/README.md rename to python/02-use-cases/video_breakdown_agent/.skills/README.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md b/python/02-use-cases/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md rename to python/02-use-cases/video_breakdown_agent/.skills/hook-analyzer-skill/SKILL.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py b/python/02-use-cases/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py rename to python/02-use-cases/video_breakdown_agent/.skills/hook-analyzer-skill/scripts/analyze_hook_segments.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md b/python/02-use-cases/video_breakdown_agent/.skills/report-generator-skill/SKILL.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/SKILL.md rename to python/02-use-cases/video_breakdown_agent/.skills/report-generator-skill/SKILL.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py b/python/02-use-cases/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py rename to python/02-use-cases/video_breakdown_agent/.skills/report-generator-skill/scripts/generate_report.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md b/python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md rename to python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/SKILL.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py b/python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py rename to python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/scripts/process_video.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py b/python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py rename to python/02-use-cases/video_breakdown_agent/.skills/video-breakdown-skill/scripts/video_upload.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/LICENSE b/python/02-use-cases/video_breakdown_agent/LICENSE similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/LICENSE rename to python/02-use-cases/video_breakdown_agent/LICENSE diff --git a/python/02-use-cases/partners/video_breakdown_agent/README.md b/python/02-use-cases/video_breakdown_agent/README.md similarity index 99% rename from python/02-use-cases/partners/video_breakdown_agent/README.md rename to python/02-use-cases/video_breakdown_agent/README.md index 27318747..9590f3fe 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/README.md +++ b/python/02-use-cases/video_breakdown_agent/README.md @@ -370,13 +370,13 @@ Web 界面提供图形化对话测试环境,支持实时查看消息流和调 使用 `pip` 安装 AgentKit 命令行工具: ```bash -pip install agentkit-sdk-python==0.3.2 +pip install agentkit-sdk-python==0.5.1 ``` 或者使用 `uv` 安装 AgentKit 命令行工具: ```bash -uv pip install agentkit-sdk-python==0.3.2 +uv pip install agentkit-sdk-python==0.5.1 ``` ### 设置环境变量 diff --git a/python/02-use-cases/partners/video_breakdown_agent/README_en.md b/python/02-use-cases/video_breakdown_agent/README_en.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/README_en.md rename to python/02-use-cases/video_breakdown_agent/README_en.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/agent.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/agent.py rename to python/02-use-cases/video_breakdown_agent/agent.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/assets/README.md b/python/02-use-cases/video_breakdown_agent/assets/README.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/assets/README.md rename to python/02-use-cases/video_breakdown_agent/assets/README.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg b/python/02-use-cases/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg rename to python/02-use-cases/video_breakdown_agent/assets/architecture_video_breakdown_agent.jpg diff --git a/python/02-use-cases/partners/video_breakdown_agent/config.yaml.example b/python/02-use-cases/video_breakdown_agent/config.yaml.example similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/config.yaml.example rename to python/02-use-cases/video_breakdown_agent/config.yaml.example diff --git a/python/02-use-cases/partners/video_breakdown_agent/dataset.json b/python/02-use-cases/video_breakdown_agent/dataset.json similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/dataset.json rename to python/02-use-cases/video_breakdown_agent/dataset.json diff --git a/python/02-use-cases/partners/video_breakdown_agent/deploy.sh b/python/02-use-cases/video_breakdown_agent/deploy.sh similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/deploy.sh rename to python/02-use-cases/video_breakdown_agent/deploy.sh diff --git a/python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md b/python/02-use-cases/video_breakdown_agent/docs/DEPLOY_GUIDE.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/docs/DEPLOY_GUIDE.md rename to python/02-use-cases/video_breakdown_agent/docs/DEPLOY_GUIDE.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md b/python/02-use-cases/video_breakdown_agent/docs/README_CONFIG.md similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/docs/README_CONFIG.md rename to python/02-use-cases/video_breakdown_agent/docs/README_CONFIG.md diff --git a/python/02-use-cases/partners/video_breakdown_agent/project.yaml b/python/02-use-cases/video_breakdown_agent/project.yaml similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/project.yaml rename to python/02-use-cases/video_breakdown_agent/project.yaml diff --git a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml b/python/02-use-cases/video_breakdown_agent/pyproject.toml similarity index 96% rename from python/02-use-cases/partners/video_breakdown_agent/pyproject.toml rename to python/02-use-cases/video_breakdown_agent/pyproject.toml index 05adcd39..e015d8cb 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml +++ b/python/02-use-cases/video_breakdown_agent/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ # AgentKit 核心三件套 "veadk-python==0.5.1", "google-adk==1.19.0", # veadk-python 0.5.1 锁定此版本 - "agentkit-sdk-python==0.2.2", + "agentkit-sdk-python==0.5.1", # HTTP 客户端 "httpx>=0.26.0", # TOS 对象存储(视频上传) diff --git a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt b/python/02-use-cases/video_breakdown_agent/requirements.txt similarity index 95% rename from python/02-use-cases/partners/video_breakdown_agent/requirements.txt rename to python/02-use-cases/video_breakdown_agent/requirements.txt index 1de87c14..d12a27d6 100644 --- a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt +++ b/python/02-use-cases/video_breakdown_agent/requirements.txt @@ -4,7 +4,7 @@ # AgentKit 核心 veadk-python==0.5.1 google-adk==1.19.0 -agentkit-sdk-python==0.2.2 +agentkit-sdk-python==0.5.1 # HTTP 客户端 httpx>=0.26.0 diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml b/python/02-use-cases/video_breakdown_agent/sandbox/docker-compose.yml similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/sandbox/docker-compose.yml rename to python/02-use-cases/video_breakdown_agent/sandbox/docker-compose.yml diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example b/python/02-use-cases/video_breakdown_agent/sandbox/sandbox.env.example similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox.env.example rename to python/02-use-cases/video_breakdown_agent/sandbox/sandbox.env.example diff --git a/python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py b/python/02-use-cases/video_breakdown_agent/sandbox/sandbox_manager.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/sandbox/sandbox_manager.py rename to python/02-use-cases/video_breakdown_agent/sandbox/sandbox_manager.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/setup_env.sh b/python/02-use-cases/video_breakdown_agent/setup_env.sh similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/setup_env.sh rename to python/02-use-cases/video_breakdown_agent/setup_env.sh diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/agent.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/video_upload_hook.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/prompt.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/prompt.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/prompt.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/agent.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/agent.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/direct_output_callback.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_bgm.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_hook_segments.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/analyze_segments_vision.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/process_video.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/process_video.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/process_video.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/video_upload.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/__init__.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/__init__.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/__init__.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/doubao_client.py diff --git a/python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/types.py similarity index 100% rename from python/02-use-cases/partners/video_breakdown_agent/video_breakdown_agent/utils/types.py rename to python/02-use-cases/video_breakdown_agent/video_breakdown_agent/utils/types.py From 6669c0abc3a1994fe7723f6ba4e5a00a00289f94 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 14:07:17 +0800 Subject: [PATCH 04/13] docs: update README_en.md to specify agentkit-sdk-python version 0.5.1 --- python/02-use-cases/video_breakdown_agent/README_en.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/python/02-use-cases/video_breakdown_agent/README_en.md b/python/02-use-cases/video_breakdown_agent/README_en.md index 06543ae0..43e0b1b1 100644 --- a/python/02-use-cases/video_breakdown_agent/README_en.md +++ b/python/02-use-cases/video_breakdown_agent/README_en.md @@ -308,7 +308,13 @@ uv run python .scripts/smoke_test.py --pipeline-cases **Install AgentKit CLI:** ```bash -pip install agentkit +pip install agentkit-sdk-python==0.5.1 +``` + +Or using `uv`: + +```bash +uv pip install agentkit-sdk-python==0.5.1 ``` ### One-Click Deployment From 855c29034c6b44a47b5aa0cfd111b5a07ad08f40 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 14:47:02 +0800 Subject: [PATCH 05/13] chore: upgrade dependencies to match main repo requirements - Upgrade veadk-python from 0.5.1 to 0.5.20 - Upgrade google-adk from 1.19.0 to 1.21.0 - Keep agentkit-sdk-python at 0.5.1 - Sync uv.lock with updated dependencies This aligns with the upstream fix commit f24712f for agentkit launch compatibility. --- .../partners/video_breakdown_agent/README.md | 504 ++++++++++++++++++ .../video_breakdown_agent/pyproject.toml | 41 ++ .../video_breakdown_agent/requirements.txt | 34 ++ .../video_breakdown_agent/pyproject.toml | 4 +- .../video_breakdown_agent/requirements.txt | 4 +- 5 files changed, 583 insertions(+), 4 deletions(-) create mode 100644 python/02-use-cases/partners/video_breakdown_agent/README.md create mode 100644 python/02-use-cases/partners/video_breakdown_agent/pyproject.toml create mode 100644 python/02-use-cases/partners/video_breakdown_agent/requirements.txt diff --git a/python/02-use-cases/partners/video_breakdown_agent/README.md b/python/02-use-cases/partners/video_breakdown_agent/README.md new file mode 100644 index 00000000..9590f3fe --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/README.md @@ -0,0 +1,504 @@ +# 智能短视频拆解分析助手 + +## 概述 + +这是一个基于火山引擎 VeADK & AgentKit 构建的智能短视频分析系统。本系统采用 Multi-Agent 架构,集成 FFmpeg 视频处理、火山 ASR 语音识别、LiteLLM 多模态视觉分析和 TOS 对象存储,能够对短视频进行全方位的专业分析。 + +## 核心功能 + +本项目提供以下核心功能: + +- **视频分镜拆解**:基于 FFmpeg 自动识别视频分镜,提取关键帧并进行画面内容分析,输出结构化分镜数据 +- **前三秒钩子分析**:从视觉冲击力、语言钩子、情绪唤起、信息密度、节奏掌控 5 个维度进行专业评分 +- **专业报告生成**:整合分镜数据和钩子分析结果,生成 Markdown 格式的完整分析报告 +- **联网搜索**:实时获取短视频行业最新资讯、平台规则和热门趋势 + +## 🎯 核心解决场景 + +本系统致力于解决短视频创作与运营中的以下痛点: + +- **📉 前三秒流失率高**:通过 **Hook Analyzer** 深度拆解开头黄金3秒,量化视觉/听觉/内容钩子质量,提供针对性优化建议。 +- **😵 卖点传递不清**:通过 **分镜拆解** 梳理叙事逻辑,确保产品核心卖点在关键时间窗内有效传递。 +- **🐢 竞品分析效率低**:自动化提取竞品分镜结构与拍摄手法,快速复刻爆款视频公式。 +- **📝 脚本创作没灵感**:基于优秀视频反推分镜脚本,为新视频创作提供结构化参考。 + +## 使用案例 + +以下展示了从联网搜索到视频深度拆解的典型使用流程。 + +### 1. 联网搜索(天气/行业资讯) + +**用户:** 杭州天气 + +**小视(Root Agent):** 根据搜索结果,杭州当前天气及未来几天预报如下: + +📍 **杭州市天气(2026年2月11日 星期三)** + +**当前实况** +- **温度**:13℃(当前),全天5~15℃ +- **天气**:晴 +- **风向**:北风4级(午后转西风) +- **湿度**:36% +- **空气质量**:AQI 64(良),主要污染物PM2.5 + +**📅 未来三天预报** + +| 日期 | 天气 | 气温范围 | 风向风力 | +| :--- | :--- | :--- | :--- | +| 2月11日(周三) | 晴 → 晴 | 5~15℃ | 北风转西风 2-4级 | +| 2月12日(周四) | 晴 → 晴 | 6~16℃ | 东南风 2级 | +| 2月13日(周五) | 多云 | 8~20℃ | 南风 2级 | + +**🌡️ 生活指数参考** +- **洗车指数**:今日适宜(未来两天无雨) +- **运动指数**:今日不适宜剧烈运动(气压偏低),明日适宜 +- **紫外线**:弱(建议做好基础防晒) + +--- + +### 2. 视频分镜拆解 + +**用户:** http://example.com/sample-video.mp4 + +**小视(Root Agent):** 好的,我将为您拆解该视频的分镜结构。 + +#### 🎥 视频分镜拆解结果 + +**视频信息** +- **时长**:17.07秒 +- **分辨率**:720x960 +- **帧速率**:60fps + +**分镜列表** + +| 序号 | 时间段 | 景别 | 运镜 | 画面内容描述 | 语音内容 | 功能标签 | +| :--- | :--- | :--- | :--- | :--- | :--- | :--- | +| 1 | 0.0-3.0s | 特写 | 固定 | 单手持米白色乐扣水杯展示整体,背景有日历、英文装饰画;过渡到双手操作杯盖提手。 | 无 | 产品展示 | +| 2 | 3.0-5.0s | 特写 | 固定 | 美甲双手打开杯盖,水流注入杯中;背景从英文装饰牌切换为日历场景。 | 无 | 产品展示 | +| 3 | 5.0-10.0s | 近景 | 固定 | 水杯接水后,美甲手握住盖好的水杯;背景含卡通图案布、日历等。 | 无 | 产品展示 | +| 4 | 10.0-17.07s | 近景 | 固定 | 先展示白色乐扣保温杯(带提手),后切换为透明条纹水杯,背景风格一致。 | 无 | 产品展示 | + +**BGM 分析** +- **是否有 BGM**:是 +- **风格**:轻音乐(纯音乐/氛围),标签:舒缓、治愈、简约 +- **情绪**:轻松愉悦(正面),强度中等 +- **乐器**:钢琴(主导)、小提琴、合成器、轻柔打击乐 +- **节奏**:60-80 BPM(慢节奏),平稳4/4拍 + +**完整语音文本** +- 无语音内容(ASR未识别到文字) + +etc. + +--- + +### 3. 前三秒钩子评测 + +**用户:** 详细分析评测视频前三秒钩子 + +**小视(Root Agent):** + +#### 🪝 视频前三秒钩子分析评测 + +**钩子核心要素拆解** +- **视觉钩子(0-3秒)**:特写镜头聚焦产品提手设计,通过“握持-提动”动作强化产品便捷性认知,背景生活化,美甲细节增加精致感。 +- **听觉钩子(0-3秒)**:舒缓钢琴+小提琴轻音乐(60-80 BPM),营造治愈氛围,降低用户跳出率。 +- **内容钩子(0-3秒)**:3秒内完成“是什么→怎么用”的认知闭环,直击“开合便利性”需求。 + +**钩子有效性评测** + +| 维度 | 评分(1-10) | 分析 | +| :--- | :--- | :--- | +| 视觉吸引力 | 8.5 | 特写聚焦产品,动作连贯无冗余。 | +| 信息传递效率 | 9.0 | 3秒内完成核心信息传递,符合认知习惯。 | +| 情绪感染力 | 7.5 | 氛围舒缓但记忆点略显不足。 | + +--- + +etc. + +### 4. 生成视频洞察报告 + +**用户:** 给出详细的视频洞察报告 + +**小视(Root Agent):** + +#### 📊 乐扣水杯产品展示视频洞察报告 + +**一、内容结构分析** +视频采用“整体→细节→场景→延伸”的递进逻辑,4个分镜形成完整产品认知链。 + +**二、用户注意力分析** +- **高注意力时段**:0-5秒(动作变化频繁,视觉焦点明确)。 +- **风险点**:10-17秒(产品切换缺乏过渡,易导致认知断层)。 + +**三、转化潜力评估** +- **优势**:符合“黄金3秒”法则,多产品展示覆盖不同需求。 +- **不足**:未明确材质安全、保温时长等核心卖点。 + +**四、优化建议** +- **视觉层**:增加“防漏测试”特写。 +- **听觉层**:加入杯盖开合的清脆音效增强记忆。 +- **内容层**:叠加“食品级304”等文字标签。 + +etc. + +## Agent 能力 + +![Video Breakdown Agent with AgentKit Runtime](assets/architecture_video_breakdown_agent.jpg) + +```text +用户输入(视频URL/本地文件) + ↓ +AgentKit 运行时 + ↓ +Root Agent(小视 - 主编排器) + ├── Breakdown Agent(分镜拆解) + │ ├── FFmpeg 视频预处理 + │ ├── 火山 ASR 语音识别 + │ ├── LiteLLM 视觉分析 + │ └── BGM 分析 + ├── Hook Analyzer Agent(钩子分析) + │ ├── 前三秒分镜提取 + │ ├── 多模态视觉评分 + │ └── JSON 格式化 + ├── Report Generator Agent(报告生成) + └── Search Agent(联网搜索) +``` + +主要的火山引擎产品或 Agent 组件: + +- 方舟大模型: + - doubao-seed-1-6-251015(主推理模型) + - doubao-seed-1-6-vision-250815(视觉分析模型) +- TOS 对象存储 +- 火山 ASR 语音识别(可选) +- Web Search 联网搜索 +- AgentKit +- APMPlus(可选可观测性) + +第三方依赖: + +- FFmpeg(通过 imageio-ffmpeg 自动打包,无需手动安装) +- LiteLLM(支持 Gemini、豆包、GPT-4o 等多种视觉模型) + +## 目录结构说明 + +```bash +video_breakdown_agent/ +├── README.md # 项目说明文档 +├── README_CONFIG.md # 详细配置指南 +├── project.toml # 应用广场元数据 +├── agent.py # AgentKit 部署入口 +├── requirements.txt # pip 依赖清单 +├── pyproject.toml # uv 项目配置 +├── config.yaml # 配置文件(示例,实际密钥通过环境变量注入) +├── config.yaml.example # 配置模板 +├── deploy.sh # 部署脚本 +├── video_breakdown_agent/ # Python 包(核心代码) +│ ├── agent.py # Root Agent 定义 +│ ├── prompt.py # 主编排 Prompt +│ ├── sub_agents/ # 子 Agent +│ │ ├── breakdown_agent/ # 分镜拆解 Agent +│ │ ├── hook_analyzer_agent/# 钩子分析 Agent(SequentialAgent) +│ │ └── report_generator_agent/ # 报告生成 Agent +│ ├── tools/ # 工具函数 +│ │ ├── process_video.py # 视频预处理(FFmpeg + ASR) +│ │ ├── analyze_segments_vision.py # 视觉分析 +│ │ ├── analyze_bgm.py # BGM 分析 +│ │ ├── analyze_hook_segments.py # 钩子分镜提取 +│ │ ├── report_generator.py # 报告生成 +│ │ └── video_upload.py # TOS 视频上传 +│ ├── hook/ # Callback 钩子 +│ │ ├── format_hook.py # JSON 修复 +│ │ └── video_upload_hook.py# 文件上传拦截 +│ └── utils/ # 工具类 +│ └── types.py # Pydantic 数据模型 +└── img/ # 架构图和截图 +``` + +## 本地运行 + +### 前置准备 + +**Python 版本:** + +- Python 3.12 或更高版本 + +**1. 开通火山方舟模型服务:** + +- 访问 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview) +- 进入"开通管理" → "语言模型" → 找到以下模型 → 点击"开通服务" + - `doubao-seed-1-6-251015`(主推理模型) + - `doubao-seed-1-6-vision-250815`(视觉分析模型,可选) +- 确认开通,等待服务生效(通常 1-2 分钟) + +**2. 获取火山引擎访问凭证:** + +- 登录 [火山引擎控制台](https://console.volcengine.com) +- 进入"访问控制" → "用户" → 选择用户 → "密钥" → 新建密钥或复制已有 AK/SK +- 为用户配置权限: + - `AgentKitFullAccess`(AgentKit 全量权限) + - `APMPlusServerFullAccess`(APMPlus 全量权限,可选) + +**3. 获取火山方舟模型 API Key:** + +- 登录 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey) +- 进入"API Key 管理" → 创建或复制已有 API Key + +**4. 创建 TOS 存储桶:** + +- 访问 [TOS 控制台](https://console.volcengine.com/tos/bucket) +- 点击"创建桶" → 填写桶名称 → 选择区域(建议 cn-beijing)→ 创建 +- 记录桶名称,后续在环境变量 `DATABASE_TOS_BUCKET` 中填入 + +**5. 可选:开通火山 ASR 语音识别服务:** + +> 如未配置 ASR,系统会跳过语音识别,仍可完成分镜拆解和视觉分析 + +- 访问 [火山语音服务控制台](https://console.volcengine.com/speech/service/list) +- 创建应用并获取 `APP_ID` 和 `ACCESS_KEY` + +### 依赖安装 + +#### 1. 安装 uv 包管理器 + +```bash +# macOS / Linux(官方安装脚本) +curl -LsSf https://astral.sh/uv/install.sh | sh + +# 或使用 Homebrew(macOS) +brew install uv +``` + +#### 2. 初始化项目依赖 + +```bash +# 进入项目目录 +cd 02-use-cases/video_breakdown_agent +``` + +您可以通过 `pip` 工具来安装本项目依赖: + +```bash +pip install -r requirements.txt +``` + +或者使用 `uv` 工具来安装本项目依赖(推荐): + +```bash +# 如果没有 `uv` 虚拟环境,可以使用命令先创建一个虚拟环境 +uv venv --python 3.12 + +# 使用 `pyproject.toml` 管理依赖 +uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple + +# 激活虚拟环境 +source .venv/bin/activate +``` + +### 环境准备 + +设置以下环境变量: + +```bash +# 火山方舟模型 API Key(必需) +export MODEL_AGENT_API_KEY= + +# 火山引擎访问凭证(必需) +export VOLCENGINE_ACCESS_KEY= +export VOLCENGINE_SECRET_KEY= + +# TOS 存储桶名称(必需) +export DATABASE_TOS_BUCKET= +export DATABASE_TOS_REGION=cn-beijing + +# 可选:火山 ASR 配置(未配置时跳过语音识别) +export ASR_APP_ID= +export ASR_ACCESS_KEY= + +# 可选:切换视觉分析模型(默认使用豆包 Vision) +export MODEL_VISION_NAME=doubao-seed-1-6-vision-250815 +# 或切换为 Google Gemini(需配置 GEMINI_API_KEY) +# export MODEL_VISION_NAME=gemini/gemini-2.5-pro +# export GEMINI_API_KEY= +``` + +> 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) + +### 调试方法 + +使用 `veadk web` 进行本地调试: + +```bash +# 进入 02-use-cases 目录 +cd 02-use-cases + +# 启动 VeADK Web 界面 +veadk web --port 8080 + +# 在浏览器访问:http://127.0.0.1:8080 +``` + +Web 界面提供图形化对话测试环境,支持实时查看消息流和调试信息。 + +### 示例提示词 + +```text +帮我拆解这个视频的分镜结构 https://example.com/video.mp4 +分析这个视频前三秒的钩子吸引力,给出专业评分 +生成完整的视频分析报告 +搜一下抖音最新推荐算法有什么变化 +``` + +### 效果展示 + +> 建议补充实际运行截图展示分镜拆解、钩子分析和报告生成效果 + +## AgentKit 部署 + +### 前置准备 + +**重要提示**:在运行本示例之前,请先访问 [AgentKit 控制台授权页面](https://console.volcengine.com/agentkit/region:agentkit+cn-beijing/auth?projectName=default) 对所有依赖服务进行授权,确保案例能够正常执行。 + +参考"本地运行"部分的"前置准备"。 + +### 依赖安装 + +> 如果您本地已经安装了该依赖,跳过此步骤。 + +使用 `pip` 安装 AgentKit 命令行工具: + +```bash +pip install agentkit-sdk-python==0.5.1 +``` + +或者使用 `uv` 安装 AgentKit 命令行工具: + +```bash +uv pip install agentkit-sdk-python==0.5.1 +``` + +### 设置环境变量 + +```bash +# 火山引擎访问凭证(必需) +export VOLCENGINE_ACCESS_KEY= +export VOLCENGINE_SECRET_KEY= +``` + +### AgentKit 云上部署 + +```bash +# 1. 进入项目目录 +cd 02-use-cases/video_breakdown_agent + +# 2. 配置 Agentkit 部署配置 +agentkit config \ + --agent_name video_breakdown_agent \ + --entry_point 'agent.py' \ + --launch_type cloud + +# 3. 配置 AgentKit Runtime 环境变量(应用级) +# 以下环境变量均为必填项,参考"前置准备"部分获取相应的值 +agentkit config \ + -e MODEL_AGENT_API_KEY= \ + -e DATABASE_TOS_BUCKET= \ + -e DATABASE_TOS_REGION=cn-beijing + +# 可选:配置 ASR 服务 +agentkit config \ + -e ASR_APP_ID= \ + -e ASR_ACCESS_KEY= + +# 4. 启动云端服务 +agentkit launch + +# 5. 测试部署的 Agent +agentkit invoke "分析这个视频 https://example.com/video.mp4" +``` + +### 测试已部署的智能体 + +在 AgentKit 控制台"智能体运行时"页面找到已部署的智能体 `video_breakdown_agent`,点击在线测评,输入提示词进行测试。 + +## 主要特性 + +### Multi-Agent 协作架构 + +Root Agent 作为主编排器,根据用户意图自动调度 4 个专业子 Agent: + +- **Breakdown Agent**:视频预处理 + ASR + 视觉分析 + BGM 分析 +- **Hook Analyzer Agent**:SequentialAgent 模式,先视觉分析后格式化 +- **Report Generator Agent**:整合数据生成 Markdown 报告 +- **Search Agent**:联网搜索行业信息 + +### 优雅降级机制 + +系统具备完善的容错能力: + +- TOS 上传失败 → 自动回退 base64 编码 +- ASR 未配置 → 跳过语音识别,仍可完成分镜拆解 +- FFmpeg 未安装 → 自动使用 imageio-ffmpeg 打包版本 + +### 多模型灵活切换 + +通过 LiteLLM 统一路由,支持一行配置切换视觉模型: + +- `doubao-seed-1-6-vision-250815`(火山方舟豆包) +- `gemini/gemini-2.5-pro`(Google Gemini) +- `gpt-4o`(OpenAI) + +### Session State 数据共享 + +子 Agent 之间通过 Session State 共享数据,避免重复序列化大 JSON: + +- `process_video` → `breakdown_result` +- `hook_analyzer` → `hook_analysis` +- `report_generator` → `final_report` + +## 常见问题 + +**错误:`VOLCENGINE_ACCESS_KEY not set`** + +- 请确保已设置火山引擎访问凭证环境变量 +- 参考"环境准备"章节设置 `VOLCENGINE_ACCESS_KEY` 和 `VOLCENGINE_SECRET_KEY` + +**错误:`TOS 存储桶不存在`** + +- 请在 TOS 控制台创建存储桶 +- 确认 `DATABASE_TOS_BUCKET` 和 `DATABASE_TOS_REGION` 配置正确 +- 系统会自动回退 base64 编码,不影响核心功能 + +**FFmpeg 未找到:** + +- 系统会自动使用 imageio-ffmpeg 打包版本,无需手动安装 +- 如需使用系统 FFmpeg,请确保已安装并在 PATH 中 + +**ASR 语音识别失败:** + +- 检查 `ASR_APP_ID` 和 `ASR_ACCESS_KEY` 是否正确 +- 未配置 ASR 时系统会跳过语音识别,仍可完成分镜拆解 + +**视觉模型切换:** + +- 修改 `MODEL_VISION_NAME` 环境变量即可切换模型 +- 使用 Gemini 时需配置 `GEMINI_API_KEY` +- 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) + +**视频文件过大:** + +- 当前限制视频文件大小为 2GB +- 建议压缩视频后重试 + +## 参考资料 + +- [VeADK 官方文档](https://volcengine.github.io/veadk-python/) +- [AgentKit 开发指南](https://volcengine.github.io/agentkit-sdk-python/) +- [火山方舟模型服务](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&projectName=default) +- [TOS 对象存储](https://www.volcengine.com/product/TOS) +- [火山 ASR 语音识别](https://www.volcengine.com/product/voice-tech) + +## 代码许可 + +本工程遵循 Apache 2.0 License diff --git a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml b/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml new file mode 100644 index 00000000..e015d8cb --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml @@ -0,0 +1,41 @@ +[project] +name = "video-breakdown-agent" +version = "0.3.0" +description = "视频分镜拆解和前三秒钩子分析 AgentKit Agent(Multi-Agent 架构)" +requires-python = ">=3.12,<3.13" +dependencies = [ + # AgentKit 核心三件套 + "veadk-python==0.5.1", + "google-adk==1.19.0", # veadk-python 0.5.1 锁定此版本 + "agentkit-sdk-python==0.5.1", + # HTTP 客户端 + "httpx>=0.26.0", + # TOS 对象存储(视频上传) + "tos>=2.8.7", + # JSON 修复(format hook) + "json-repair>=0.54.1", + # 配置管理 + "python-dotenv>=1.0.0", + # 生产模式服务器 + "uvicorn>=0.34.0", + # Web 框架(A2A + MCP) + "fastapi>=0.115.0", + # MCP Server + "fastmcp>=2.0.0", + # 异步 HTTP + "aiohttp>=3.11.0", + # FFmpeg 二进制(随 Python 包分发,无需系统安装) + "imageio-ffmpeg>=0.5.1", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["video_breakdown_agent"] diff --git a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt b/python/02-use-cases/partners/video_breakdown_agent/requirements.txt new file mode 100644 index 00000000..d12a27d6 --- /dev/null +++ b/python/02-use-cases/partners/video_breakdown_agent/requirements.txt @@ -0,0 +1,34 @@ +# pip 兼容依赖列表(推荐使用 pyproject.toml + uv) +# pip install -r requirements.txt + +# AgentKit 核心 +veadk-python==0.5.1 +google-adk==1.19.0 +agentkit-sdk-python==0.5.1 + +# HTTP 客户端 +httpx>=0.26.0 + +# TOS 对象存储(视频上传) +tos>=2.8.7 + +# JSON 修复(format hook) +json-repair>=0.54.1 + +# 配置管理 +python-dotenv>=1.0.0 + +# FFmpeg 二进制(随 Python 包分发,无需系统安装 FFmpeg) +imageio-ffmpeg>=0.5.1 + +# 生产模式服务器 +uvicorn>=0.34.0 + +# Web 框架(A2A + MCP) +fastapi>=0.115.0 + +# MCP Server +fastmcp>=2.0.0 + +# 异步 HTTP +aiohttp>=3.11.0 diff --git a/python/02-use-cases/video_breakdown_agent/pyproject.toml b/python/02-use-cases/video_breakdown_agent/pyproject.toml index e015d8cb..a91e391b 100644 --- a/python/02-use-cases/video_breakdown_agent/pyproject.toml +++ b/python/02-use-cases/video_breakdown_agent/pyproject.toml @@ -5,8 +5,8 @@ description = "视频分镜拆解和前三秒钩子分析 AgentKit Agent(Multi requires-python = ">=3.12,<3.13" dependencies = [ # AgentKit 核心三件套 - "veadk-python==0.5.1", - "google-adk==1.19.0", # veadk-python 0.5.1 锁定此版本 + "veadk-python==0.5.20", + "google-adk==1.21.0", # veadk-python 0.5.20 锁定此版本 "agentkit-sdk-python==0.5.1", # HTTP 客户端 "httpx>=0.26.0", diff --git a/python/02-use-cases/video_breakdown_agent/requirements.txt b/python/02-use-cases/video_breakdown_agent/requirements.txt index d12a27d6..80d9a8b9 100644 --- a/python/02-use-cases/video_breakdown_agent/requirements.txt +++ b/python/02-use-cases/video_breakdown_agent/requirements.txt @@ -2,8 +2,8 @@ # pip install -r requirements.txt # AgentKit 核心 -veadk-python==0.5.1 -google-adk==1.19.0 +veadk-python==0.5.20 +google-adk==1.21.0 agentkit-sdk-python==0.5.1 # HTTP 客户端 From bc53b4518fcaf116e62eec0b9796139d7084f4ce Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 16:20:31 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20SequentialAge?= =?UTF-8?q?nt=20=E5=AD=90=20Agent=20=E7=9A=84=E5=A4=9A=E4=B8=AA=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=E6=80=A7=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 SequentialAgent 子 Agent 中错误的 transfer_to_agent 指令 - 为 hook_format_agent 添加默认 model_name 避免 missing model 错误 - 为 hook_analysis_agent 添加 enable_responses=True 隐藏工具调用详情 - 为 hook_analysis_agent 禁用 caching 避免 AccessDenied.CacheService 错误 - 修复 after_model_callback 中的 function_call/function_response 检查逻辑 这些修复确保了: 1. 不再出现死循环(process_video 重复调用) 2. 不再泄露 JSON 工具调用详情和 PLHD 占位符 3. 钩子分析能够正常生成完整报告 4. 与 VeADK/google-adk 1.21.0 的兼容性 --- .../video_breakdown_agent/agent.py | 6 ++++-- .../hook/final_output_hook.py | 8 ++++++++ .../video_breakdown_agent/hook/format_hook.py | 10 ++++++++++ .../hook/search_output_hook.py | 17 +++++++++++++---- .../sub_agents/breakdown_agent/prompt.py | 5 +++-- .../sub_agents/hook_analyzer_agent/agent.py | 6 ++++-- .../sub_agents/hook_analyzer_agent/prompt.py | 8 ++++---- .../sub_agents/report_generator_agent/prompt.py | 3 ++- 8 files changed, 48 insertions(+), 15 deletions(-) diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index c8638f01..067e377a 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -118,6 +118,7 @@ def create_hook_analyzer_agent() -> SequentialAgent: hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), + enable_responses=True, # 隐藏工具调用详情,仅展示最终文本响应 description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], @@ -125,14 +126,15 @@ def create_hook_analyzer_agent() -> SequentialAgent: "extra_body": { "thinking": { "type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled") - } + }, + "caching": {"type": "disabled"}, # 禁用缓存,避免账户未激活 cache service } }, ) hook_format_agent = Agent( name="hook_format_agent", - model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "")), + model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015")), description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", instruction=HOOK_FORMAT_INSTRUCTION, generate_content_config=json_response_config, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py index fba1c82e..107a888a 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/final_output_hook.py @@ -165,6 +165,14 @@ def guard_final_user_output( if _is_tool_call_turn(model_response_event, llm_response): return llm_response + # 放行 function_call / function_response,避免干扰 SDK 事件追踪 + if llm_response and llm_response.content and llm_response.content.parts: + part = llm_response.content.parts[0] + if hasattr(part, "function_call") and part.function_call: + return llm_response + if hasattr(part, "function_response") and part.function_response: + return llm_response + text = _get_first_text(llm_response) if not text: return llm_response diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py index 14cf519a..473ea1c2 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -289,6 +289,16 @@ def soft_fix_hook_output( if agent_name not in _TARGET_AGENTS: return llm_response + if not llm_response or not llm_response.content or not llm_response.content.parts: + return llm_response + + # 放行 function_call / function_response,避免干扰 SDK 事件追踪 + part = llm_response.content.parts[0] + if hasattr(part, "function_call") and part.function_call: + return llm_response + if hasattr(part, "function_response") and part.function_response: + return llm_response + text = _get_first_text(llm_response) if not text: return llm_response diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py index 13fc4557..8c48e457 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py @@ -9,8 +9,10 @@ def _is_tool_call_turn(model_response_event: Optional[Event], text: str) -> bool: """工具调用回合不做处理,避免干扰 function call。""" - if model_response_event and model_response_event.get_function_calls(): - return True + if model_response_event: + # 检查 function calls(LLM 发起的工具调用)和 function responses(工具返回后的阶段) + if model_response_event.get_function_calls() or model_response_event.get_function_responses(): + return True return "transfer_to_agent" in (text or "") or "web_search" in (text or "") @@ -33,7 +35,14 @@ def suppress_search_agent_user_output( if not llm_response or not llm_response.content or not llm_response.content.parts: return llm_response - text = llm_response.content.parts[0].text or "" + # 检查 parts[0] 是否包含 function_call 或 function_response,完整放行不做修改 + part = llm_response.content.parts[0] + if hasattr(part, "function_call") and part.function_call: + return llm_response + if hasattr(part, "function_response") and part.function_response: + return llm_response + + text = part.text or "" if not text or _is_tool_call_turn(model_response_event, text): return llm_response @@ -41,6 +50,6 @@ def suppress_search_agent_user_output( if isinstance(state, dict): state["search_result"] = text - # 清空 search_agent 的用户可见输出,避免与 root 最终答复重复展示。 + # 只清空纯文本输出,避免与 root 最终答复重复展示。 llm_response.content.parts[0].text = "" return llm_response diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py index 617ea343..9c15111b 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/breakdown_agent/prompt.py @@ -86,6 +86,7 @@ - 如果视觉分析部分失败,使用 fallback 数据继续 ## 完成后行为(必须遵守) -- 当你完成分镜拆解并输出结果后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 -- 不要在本 Agent 内继续处理“钩子分析/报告生成/下一步编排”请求,交由 Root Agent 统一调度。 +- 当你完成分镜拆解并输出结果后,直接输出结果文本即可。 +- 你处于 SequentialAgent 流程中,框架会自动将控制权传递给下一个步骤,无需手动调用任何转移函数。 +- 不要在本 Agent 内继续处理"钩子分析/报告生成/下一步编排"请求,这些将由后续流程自动处理。 """ diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 8dc2ac6b..4123a756 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -20,12 +20,14 @@ hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), + enable_responses=True, # 隐藏工具调用详情,仅展示最终文本响应 description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], model_extra_config={ "extra_body": { - "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")} + "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")}, + "caching": {"type": "disabled"}, # 禁用缓存,避免账户未激活 cache service } }, ) @@ -33,7 +35,7 @@ # 第二阶段:对齐 multimedia 的格式化收口模式 hook_format_agent = Agent( name="hook_format_agent", - model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "")), + model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015")), description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", instruction=HOOK_FORMAT_INSTRUCTION, generate_content_config=json_response_config, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py index 4c9a340f..d650c546 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py @@ -107,9 +107,9 @@ ## 完成后行为(必须遵守) -- 当你完成钩子分析并输出结果后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 -- 不要自行处理“生成报告/完整分析/继续编排”等请求,这些请求应交回 Root Agent 统一决策。 -- 如果用户在你这里继续追问报告相关内容,先归还 Root,再由 Root 决定是否调用 `report_generator_agent`。 +- 当你完成钩子分析并输出结果后,直接输出结果文本即可。 +- 你处于 SequentialAgent 流程中,框架会自动将控制权传递给下一个步骤,无需手动调用任何转移函数。 +- 不要自行处理"生成报告/完整分析/继续编排"等请求,这些将由后续流程或 Root Agent 统一决策。 """ HOOK_FORMAT_INSTRUCTION = """ @@ -122,5 +122,5 @@ 4) strengths/weaknesses/suggestions 必须是字符串数组;无内容时返回空数组。 5) 如果输入中夹杂工具日志、占位片段或非结构化文本,先清洗再结构化。 -完成输出后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +完成输出后,直接输出结果即可。你处于 SequentialAgent 流程中,框架会自动将控制权传递给下一个步骤,无需手动调用任何转移函数。 """ diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py index ebd1a50d..ae3c8fde 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/report_generator_agent/prompt.py @@ -25,6 +25,7 @@ - 前三秒钩子分析是最重要的部分,需要详细展示 ## 完成后行为(必须遵守) -- 当你生成并返回报告后,必须立即调用 `transfer_to_agent`,将控制权归还给 `video_breakdown_agent`。 +- 当你生成并返回报告后,直接输出结果文本即可。 +- 你处于 SequentialAgent 流程中,框架会自动处理后续流程,无需手动调用任何转移函数。 - 不要在本 Agent 内继续处理新的用户意图,后续回合由 Root Agent 负责统一编排。 """ From e3db64a63b35561d9e3cf8152167f04ccef586fa Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 17:12:45 +0800 Subject: [PATCH 07/13] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20enable=5Fresp?= =?UTF-8?q?onses=3DTrue=20=E5=B9=B6=E6=B7=BB=E5=8A=A0=20PLHD=20=E9=98=B2?= =?UTF-8?q?=E5=BE=A1=E5=89=A5=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因分析: - 对比 partners 分支(正常)和当前分支(异常),唯一差异是 enable_responses=True - 此参数改变 SDK 对 function_call 的序列化方式,导致 PLHD 文本泄露 - PLHD 文本污染 SequentialAgent 的上下文传递,导致 step2 解析失败(全 0.0) 修复内容: 1. 删除 hook_analysis_agent 的 enable_responses=True(根因修复) 2. 在 _extract_json_candidate 添加 PLHD 剥离逻辑(防御性安全网) 验证: - partners 分支和 multimedia 官方项目都不设置 enable_responses,且正常工作 - 对齐官方最佳实践:SequentialAgent 中间步骤不设置 enable_responses --- .../video_breakdown_agent/agent.py | 1 - .../video_breakdown_agent/hook/format_hook.py | 12 +++++++++++- .../sub_agents/hook_analyzer_agent/agent.py | 1 - 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index 067e377a..e74257d3 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -118,7 +118,6 @@ def create_hook_analyzer_agent() -> SequentialAgent: hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), - enable_responses=True, # 隐藏工具调用详情,仅展示最终文本响应 description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py index 473ea1c2..a2d117a6 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -119,10 +119,20 @@ def _is_tool_call_turn(model_response_event: Optional[Event], text: str) -> bool def _extract_json_candidate(text: str) -> str: + # 防御性剥离 PLHD 标签和工具调用 envelope + text = re.sub(r"<\[PLHD[^\]]*\]>", "", text) + text = re.sub( + r'\[\s*\{\s*"name"\s*:\s*"[^"]*"\s*,\s*"parameters"\s*:\s*\{[^}]*\}\s*\}\s*\]', + "", + text, + flags=re.DOTALL, + ) + text = text.strip() + fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE) if fenced and fenced.group(1).strip(): return fenced.group(1).strip() - return text.strip() + return text def _coerce_to_list(value: Any) -> list[str]: diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 4123a756..ec4b1fd4 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -20,7 +20,6 @@ hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), - enable_responses=True, # 隐藏工具调用详情,仅展示最终文本响应 description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], From d76988445d5bd569db1a7bc29fe771593438f7d7 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 17:37:53 +0800 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=E9=9A=90=E8=97=8F=20analyze=5Fhoo?= =?UTF-8?q?k=5Fsegments=20=E5=B7=A5=E5=85=B7=E5=93=8D=E5=BA=94=E8=AF=A6?= =?UTF-8?q?=E6=83=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 删除 enable_responses=True 后,functionResponse 详情(包含 base64 图片)暴露给用户 - 虽然 JSON 输出正常(6.5/10),但工具调用结构不应对用户可见 解决方案: - 添加 after_tool_callback=[suppress_hook_tool_output] - 使用 skip_summarization=True 隐藏工具输出详情 - 数据仍正常传递给 SequentialAgent 的下一步(hook_format_agent) 参考: - 对齐 multimedia 官方项目的 after_tool_callback 模式 - 对齐项目内 report_generator_agent 的 direct_output_callback 实现 预期效果: - 钩子分析 JSON 输出正常(保持 6.5/10) - 无 PLHD 标签泄露 - 无 functionResponse 详情暴露 - step1 -> step2 数据传递正常 --- .../video_breakdown_agent/agent.py | 4 ++++ .../sub_agents/hook_analyzer_agent/agent.py | 2 ++ .../suppress_tool_output.py | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index e74257d3..eedf819b 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -22,6 +22,9 @@ HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION, ) +from .sub_agents.hook_analyzer_agent.suppress_tool_output import ( + suppress_hook_tool_output, +) from .sub_agents.report_generator_agent.prompt import REPORT_AGENT_INSTRUCTION from .sub_agents.report_generator_agent.direct_output_callback import ( direct_output_callback, @@ -121,6 +124,7 @@ def create_hook_analyzer_agent() -> SequentialAgent: description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], + after_tool_callback=[suppress_hook_tool_output], model_extra_config={ "extra_body": { "thinking": { diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index ec4b1fd4..8a1f6aa4 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -15,6 +15,7 @@ from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments from video_breakdown_agent.utils.types import json_response_config from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION +from .suppress_tool_output import suppress_hook_tool_output # 第一阶段:多模态视觉分析(使用 vision 模型) hook_analysis_agent = Agent( @@ -23,6 +24,7 @@ description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], + after_tool_callback=[suppress_hook_tool_output], model_extra_config={ "extra_body": { "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")}, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py new file mode 100644 index 00000000..bdd32f41 --- /dev/null +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Any, Optional + +from google.adk.tools import BaseTool, ToolContext + + +def suppress_hook_tool_output( + tool: BaseTool, + args: dict[str, Any], + tool_context: ToolContext, + tool_response: Any, +) -> Optional[Any]: + """ + 隐藏 analyze_hook_segments 工具的输出详情,避免向用户展示 functionResponse。 + + 设置 skip_summarization=True 后,SDK 不会向用户展示工具调用的详细响应, + 但数据仍然会传递给 SequentialAgent 的下一个步骤(hook_format_agent)。 + """ + if tool.name == "analyze_hook_segments": + tool_context.actions.skip_summarization = True + return tool_response From 5387dba314f1119ad873ccdaa28a7cf5bcfa2e85 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 17:56:55 +0800 Subject: [PATCH 09/13] =?UTF-8?q?fix:=20=E4=BB=85=E9=9A=90=E8=97=8F=20hook?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E5=93=8D=E5=BA=94=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E5=B9=B6=E6=81=A2=E5=A4=8D=E9=92=A9=E5=AD=90=E5=88=86=E6=9E=90?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 hook_analysis_agent 的 skip_summarization 回调绑定 - 新增 HookAnalysisAgent 过滤 analyze_hook_segments functionResponse 的外显事件 - 保留总结与 SequentialAgent 下游数据传递,修复评分全 0.0 - 删除不再使用的 suppress_tool_output 回调文件 --- .../video_breakdown_agent/agent.py | 7 ++---- .../sub_agents/hook_analyzer_agent/agent.py | 5 ++-- .../hook_analyzer_agent/filtered_agent.py | 24 +++++++++++++++++++ .../suppress_tool_output.py | 22 ----------------- 4 files changed, 28 insertions(+), 30 deletions(-) create mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py delete mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index eedf819b..e4eccca6 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -22,9 +22,7 @@ HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION, ) -from .sub_agents.hook_analyzer_agent.suppress_tool_output import ( - suppress_hook_tool_output, -) +from .sub_agents.hook_analyzer_agent.filtered_agent import HookAnalysisAgent from .sub_agents.report_generator_agent.prompt import REPORT_AGENT_INSTRUCTION from .sub_agents.report_generator_agent.direct_output_callback import ( direct_output_callback, @@ -118,13 +116,12 @@ def create_breakdown_agent() -> Agent: def create_hook_analyzer_agent() -> SequentialAgent: - hook_analysis_agent = Agent( + hook_analysis_agent = HookAnalysisAgent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], - after_tool_callback=[suppress_hook_tool_output], model_extra_config={ "extra_body": { "thinking": { diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 8a1f6aa4..90367e97 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -14,17 +14,16 @@ from video_breakdown_agent.hook.format_hook import soft_fix_hook_output from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments from video_breakdown_agent.utils.types import json_response_config +from .filtered_agent import HookAnalysisAgent from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION -from .suppress_tool_output import suppress_hook_tool_output # 第一阶段:多模态视觉分析(使用 vision 模型) -hook_analysis_agent = Agent( +hook_analysis_agent = HookAnalysisAgent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, tools=[analyze_hook_segments], - after_tool_callback=[suppress_hook_tool_output], model_extra_config={ "extra_body": { "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")}, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py new file mode 100644 index 00000000..5187cd92 --- /dev/null +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import AsyncGenerator + +from google.adk.agents import InvocationContext +from google.adk.events import Event +from veadk import Agent + + +class HookAnalysisAgent(Agent): + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + async for event in super()._run_async_impl(ctx): + parts = getattr(getattr(event, "content", None), "parts", None) or [] + has_hook_tool_response = any( + getattr(getattr(part, "function_response", None), "name", "") + == "analyze_hook_segments" + for part in parts + ) + if has_hook_tool_response: + # 仅隐藏工具响应详情事件,保留后续总结与下游数据链路。 + continue + yield event diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py deleted file mode 100644 index bdd32f41..00000000 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/suppress_tool_output.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from typing import Any, Optional - -from google.adk.tools import BaseTool, ToolContext - - -def suppress_hook_tool_output( - tool: BaseTool, - args: dict[str, Any], - tool_context: ToolContext, - tool_response: Any, -) -> Optional[Any]: - """ - 隐藏 analyze_hook_segments 工具的输出详情,避免向用户展示 functionResponse。 - - 设置 skip_summarization=True 后,SDK 不会向用户展示工具调用的详细响应, - 但数据仍然会传递给 SequentialAgent 的下一个步骤(hook_format_agent)。 - """ - if tool.name == "analyze_hook_segments": - tool_context.actions.skip_summarization = True - return tool_response From 0a66106e1efd44c166a47badedf224c96d4430ca Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 18:05:13 +0800 Subject: [PATCH 10/13] =?UTF-8?q?fix:=20=E6=A0=B9=E6=B2=BB=20hook=20?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=20JSON=20=E5=A4=96=E9=9C=B2=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=AE=8C=E6=95=B4=E5=88=86=E6=9E=90=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 HookAnalyzerSequentialAgent,按 author 过滤 hook_analysis_agent 对外事件 - 仅对外透传 hook_format_agent 产物,隐藏中间步骤 JSON 与工具细节 - 恢复 hook_analysis_agent 为普通 Agent,保留 step1->step2 数据链路 - 移除临时 HookAnalysisAgent 过滤实现(filtered_agent.py) 该修复只作用于 hook_analyzer 顺序流程边界,不影响其他模块逻辑 --- .../video_breakdown_agent/agent.py | 8 ++++--- .../sub_agents/hook_analyzer_agent/agent.py | 7 +++--- .../hook_analyzer_agent/filtered_agent.py | 24 ------------------- .../filtered_sequential.py | 19 +++++++++++++++ 4 files changed, 27 insertions(+), 31 deletions(-) delete mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py create mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_sequential.py diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index e4eccca6..6b4d521a 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -22,7 +22,9 @@ HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION, ) -from .sub_agents.hook_analyzer_agent.filtered_agent import HookAnalysisAgent +from .sub_agents.hook_analyzer_agent.filtered_sequential import ( + HookAnalyzerSequentialAgent, +) from .sub_agents.report_generator_agent.prompt import REPORT_AGENT_INSTRUCTION from .sub_agents.report_generator_agent.direct_output_callback import ( direct_output_callback, @@ -116,7 +118,7 @@ def create_breakdown_agent() -> Agent: def create_hook_analyzer_agent() -> SequentialAgent: - hook_analysis_agent = HookAnalysisAgent( + hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", @@ -149,7 +151,7 @@ def create_hook_analyzer_agent() -> SequentialAgent: }, ) - return SequentialAgent( + return HookAnalyzerSequentialAgent( name="hook_analyzer_agent", description="前三秒钩子分析顺序流程:先分析,再格式化输出", sub_agents=[hook_analysis_agent, hook_format_agent], diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 90367e97..b928b797 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -9,16 +9,15 @@ import os from veadk import Agent -from veadk.agents.sequential_agent import SequentialAgent from video_breakdown_agent.hook.format_hook import soft_fix_hook_output from video_breakdown_agent.tools.analyze_hook_segments import analyze_hook_segments from video_breakdown_agent.utils.types import json_response_config -from .filtered_agent import HookAnalysisAgent +from .filtered_sequential import HookAnalyzerSequentialAgent from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION # 第一阶段:多模态视觉分析(使用 vision 模型) -hook_analysis_agent = HookAnalysisAgent( +hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", @@ -48,7 +47,7 @@ }, ) -hook_analyzer_agent = SequentialAgent( +hook_analyzer_agent = HookAnalyzerSequentialAgent( name="hook_analyzer_agent", description="前三秒钩子分析顺序流程:先分析,再格式化输出", sub_agents=[hook_analysis_agent, hook_format_agent], diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py deleted file mode 100644 index 5187cd92..00000000 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_agent.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from typing import AsyncGenerator - -from google.adk.agents import InvocationContext -from google.adk.events import Event -from veadk import Agent - - -class HookAnalysisAgent(Agent): - async def _run_async_impl( - self, ctx: InvocationContext - ) -> AsyncGenerator[Event, None]: - async for event in super()._run_async_impl(ctx): - parts = getattr(getattr(event, "content", None), "parts", None) or [] - has_hook_tool_response = any( - getattr(getattr(part, "function_response", None), "name", "") - == "analyze_hook_segments" - for part in parts - ) - if has_hook_tool_response: - # 仅隐藏工具响应详情事件,保留后续总结与下游数据链路。 - continue - yield event diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_sequential.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_sequential.py new file mode 100644 index 00000000..9cf086b8 --- /dev/null +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/filtered_sequential.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import AsyncGenerator + +from google.adk.agents import InvocationContext +from google.adk.events import Event +from veadk.agents.sequential_agent import SequentialAgent + + +class HookAnalyzerSequentialAgent(SequentialAgent): + async def _run_async_impl( + self, ctx: InvocationContext + ) -> AsyncGenerator[Event, None]: + async for event in super()._run_async_impl(ctx): + author = str(getattr(event, "author", "") or "") + if author == "hook_analysis_agent": + # 中间步骤只参与链路,不对外展示。 + continue + yield event From b5f8fc6d5f82eb999d9681258f6ba36019417264 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 19:46:27 +0800 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20hook=20=E5=88=86=E6=9E=90=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E8=BE=93=E5=87=BA=EF=BC=88Markdown=E7=9B=B4=E5=87=BA?= =?UTF-8?q?=EF=BC=89=E5=B9=B6=E7=BB=95=E5=BC=80=20tool=5Fcall=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_breakdown_agent/agent.py | 18 +++- .../video_breakdown_agent/hook/format_hook.py | 38 ++------ .../sub_agents/hook_analyzer_agent/agent.py | 22 ++++- .../hook_analyzer_agent/hook/__init__.py | 2 + .../hook/clean_tool_args.py | 52 +++++++++++ .../sub_agents/hook_analyzer_agent/prompt.py | 87 +++++++++++-------- .../tools/report_generator.py | 8 +- 7 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py create mode 100644 python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index 6b4d521a..c15c3f34 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -8,6 +8,8 @@ import logging import os +from google.adk.agents.callback_context import CallbackContext +from google.adk.tools import ToolContext from veadk import Agent from veadk.agents.sequential_agent import SequentialAgent from veadk.memory.short_term_memory import ShortTermMemory @@ -118,12 +120,26 @@ def create_breakdown_agent() -> Agent: def create_hook_analyzer_agent() -> SequentialAgent: + def _prime_hook_segments_state(callback_context: CallbackContext): + inv = getattr(callback_context, "_invocation_context", None) + if not inv: + return None + state = getattr(callback_context, "state", None) + if isinstance(state, dict) and state.get("hook_segments_context"): + return None + tool_ctx = ToolContext(inv) + context = analyze_hook_segments(tool_ctx) + if isinstance(state, dict): + state["hook_segments_context"] = context + return None + hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, - tools=[analyze_hook_segments], + tools=[], + before_agent_callback=_prime_hook_segments_state, model_extra_config={ "extra_body": { "thinking": { diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py index a2d117a6..5e8ce27f 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -316,38 +316,12 @@ def soft_fix_hook_output( if _is_tool_call_turn(model_response_event, text): return llm_response - state = getattr(callback_context, "state", None) - candidate = _extract_json_candidate(text) - - parsed: Any = None - try: - parsed = json.loads(candidate) - except json.JSONDecodeError: - try: - parsed = json_repair.loads(candidate) - except Exception: - parsed = None - - if isinstance(parsed, list): - parsed = parsed[0] if parsed else {} - - if isinstance(parsed, dict) and not _looks_like_tool_envelope(parsed): - normalized = _normalize_output(parsed) - logger.info( - "[soft_fix_hook_output] normalized by json path agent=%s", agent_name - ) - else: - normalized = _fallback_struct_from_text(text) - logger.warning( - "[soft_fix_hook_output] fallback to text extraction agent=%s", agent_name - ) - - markdown_summary = _build_hook_markdown_summary(normalized) - llm_response.content.parts[0].text = markdown_summary + # 非破坏性策略:不再依赖 LLM 输出 JSON 做硬解析(易出现截断/兜底 0 分)。 + # 仅做“泄露剥离 + 存档”,并让 LLM 的 Markdown 原样对用户可见。 + sanitized = _safe_text(text, max_len=20000) + llm_response.content.parts[0].text = sanitized + state = getattr(callback_context, "state", None) if isinstance(state, dict): - state["hook_analysis_struct"] = normalized - state["hook_analysis"] = normalized - state["hook_analysis_markdown"] = markdown_summary - + state["hook_analysis_markdown"] = sanitized return llm_response diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index b928b797..44f737f7 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -8,6 +8,7 @@ import os +from google.adk.tools import ToolContext from veadk import Agent from video_breakdown_agent.hook.format_hook import soft_fix_hook_output @@ -15,14 +16,33 @@ from video_breakdown_agent.utils.types import json_response_config from .filtered_sequential import HookAnalyzerSequentialAgent from .prompt import HOOK_ANALYZER_INSTRUCTION, HOOK_FORMAT_INSTRUCTION +from google.adk.agents.callback_context import CallbackContext # 第一阶段:多模态视觉分析(使用 vision 模型) + + +def _prime_hook_segments_state(callback_context: CallbackContext): + """Compute hook segments context from session.state before LLM runs.""" + inv = getattr(callback_context, "_invocation_context", None) + if not inv: + return None + state = getattr(callback_context, "state", None) + if isinstance(state, dict) and state.get("hook_segments_context"): + return None + tool_ctx = ToolContext(inv) + context = analyze_hook_segments(tool_ctx) + if isinstance(state, dict): + state["hook_segments_context"] = context + return None + + hook_analysis_agent = Agent( name="hook_analysis_agent", model_name=os.getenv("MODEL_VISION_NAME", "doubao-seed-1-6-vision-250815"), description="对视频前三秒分镜进行深度钩子分析,具备视觉分析能力,可直接观察关键帧图片进行专业评估", instruction=HOOK_ANALYZER_INSTRUCTION, - tools=[analyze_hook_segments], + tools=[], + before_agent_callback=_prime_hook_segments_state, model_extra_config={ "extra_body": { "thinking": {"type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled")}, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py new file mode 100644 index 00000000..caf53677 --- /dev/null +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py @@ -0,0 +1,2 @@ +"""Hook utilities for hook_analyzer_agent.""" + diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py new file mode 100644 index 00000000..ecb28b65 --- /dev/null +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py @@ -0,0 +1,52 @@ +""" +Clean hook analyzer tool call args to avoid malformed payloads. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from google.adk.agents.callback_context import CallbackContext +from google.adk.events import Event +from google.adk.models import LlmResponse + +logger = logging.getLogger(__name__) + + +def clean_analyze_hook_arguments( + *, + callback_context: CallbackContext, + llm_response: LlmResponse, + model_response_event: Optional[Event] = None, +) -> Optional[LlmResponse]: + """Force `analyze_hook_segments` function call args to `{}` for stability.""" + _ = model_response_event + inv = getattr(callback_context, "_invocation_context", None) + agent = getattr(inv, "agent", None) + if not agent or getattr(agent, "name", "") != "hook_analysis_agent": + return llm_response + + content = getattr(llm_response, "content", None) + parts = getattr(content, "parts", None) + if not parts: + return llm_response + + fixed = False + for part in parts: + function_call = getattr(part, "function_call", None) + if not function_call: + continue + if getattr(function_call, "name", "") != "analyze_hook_segments": + continue + args = getattr(function_call, "args", None) + if args: + function_call.args = {} + fixed = True + + if fixed: + logger.warning( + "[clean_tool_args] polluted args detected in hook_analysis_agent, forced to {}" + ) + return llm_response + diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py index d650c546..d5252982 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/prompt.py @@ -9,10 +9,14 @@ ## 分析流程 当你接收到分镜数据时: -1. 首先使用 analyze_hook_segments 工具提取前三秒的分镜信息 +1. 你将直接获得“前三秒分镜上下文”(含关键帧图片 frame_images) 2. **重点观察每个分镜的关键帧图片**(frame_images 字段) 3. 结合视觉画面、文案、运镜等多维度信息进行深度分析 +### 工具调用约束(必须遵守) +- 本步骤**禁止**发起任何工具调用。 +- 直接基于给定的前三秒分镜上下文进行分析输出。 + ## 核心能力 你可以直接**看到视频的关键帧图片**,请务必: @@ -57,34 +61,8 @@ ## 输出格式 -优先输出为 JSON(便于后续程序处理),但**分析质量优先于格式**。 -如果你无法一次性生成合法 JSON,请先输出结构化文本分析,确保内容完整,不要为了凑 JSON 而省略关键信息。 - -推荐 JSON 结构如下(字段可补充,但尽量覆盖核心字段): - -```json -{ - "overall_score": 综合评分(0-10,各维度加权平均), - "visual_impact": 视觉冲击力分数, - "visual_comment": "视觉冲击力的详细评价(4-6句话:描述具体画面元素、构图分析、色彩运用、光影效果、第一帧停留感、与同类视频的对比参考)", - "language_hook": 语言钩子分数, - "language_comment": "语言钩子的详细评价(4-6句话:具体引用文案内容、分析悬念/痛点/好奇心设计、语气节奏、文案与画面的呼应关系、信息传达效率)", - "emotion_trigger": 情绪唤起分数, - "emotion_comment": "情绪唤起的详细评价(4-6句话:画面情绪氛围分析、人物表情/肢体语言观察、色调/光影对情绪的影响、目标受众的情绪共鸣点、情绪曲线设计)", - "information_density": 信息密度分数, - "info_comment": "信息密度的详细评价(4-6句话:具体列出传递了哪些信息、信息层次结构、核心价值主张是否突出、与时长的匹配度、信息遗漏或冗余分析)", - "rhythm_control": 节奏掌控分数, - "rhythm_comment": "节奏掌控的详细评价(4-6句话:分镜切换频率分析、运镜节奏感、音画同步度、平台适配度评估、与目标受众注意力曲线的匹配)", - "hook_type": "钩子类型(痛点型/好奇型/冲突型/价值型/情感型/视觉冲击型/悬念型)", - "hook_type_analysis": "为什么判定为该钩子类型的详细理由(2-3句话)", - "target_audience": "推测的目标受众画像(1-2句话)", - "strengths": ["优点1(需具体,如'第1帧的产品特写构图精准,蓝白配色清爽吸睛')", "优点2", "优点3"], - "weaknesses": ["不足1(需具体,如'前3秒无语音引导,纯靠画面留人,感染力受限')", "不足2", "不足3"], - "suggestions": ["具体可执行的优化建议1(说明改什么、怎么改、预期效果)", "建议2", "建议3", "建议4"], - "competitor_reference": "同品类/同类型优秀视频的钩子设计参考(1-2句话,可选)", - "retention_prediction": "3秒留存预测(高:>70% / 中:50-70% / 低:<50%),附详细原因分析(2-3句话,说明哪些因素拉高/拉低留存)" -} -``` +此步骤(hook_analysis_agent)只输出**结构化自然语言分析**,用于提供给下一步 `hook_format_agent` 做结果整理。 +请勿在此步骤输出 JSON、代码块或函数调用片段。 ## 评分标准 @@ -113,14 +91,53 @@ """ HOOK_FORMAT_INSTRUCTION = """ -你是钩子分析格式化助手,只负责把上一步分析内容整理成标准 JSON 结构。 +你是钩子分析格式化助手,只负责把上一步的自然语言分析整理成**用户可读的 Markdown 报告**。 要求: -1) 必须输出合法 JSON,不要输出 Markdown、代码块或解释文本。 -2) 严格遵循既定字段结构;字段缺失时用合理默认值补齐,不要省略必填字段。 -3) 分数字段范围固定 0-10;无法判断时给 0。 -4) strengths/weaknesses/suggestions 必须是字符串数组;无内容时返回空数组。 -5) 如果输入中夹杂工具日志、占位片段或非结构化文本,先清洗再结构化。 +1) 只输出 Markdown 正文,不要输出任何 JSON、代码块或解释前后缀。 +2) 必须严格按以下模板输出(标题、顺序、标点都不要改),把 <> 中的内容替换为你的结果: + +## 前三秒钩子分析 + +### 综合评分: <0-10>/10 +- **钩子类型**: <一句话> +- **目标受众**: <一句话,不要写“未知/未明确”,没有就写“泛生活好物兴趣人群”> +- **留存预测**: <一句话,必须给出,不要留空> + +--- + +### 五维评分详情 + +#### 视觉冲击力: <0-10>/10 +> <2-4 句,结合画面细节> + +#### 语言钩子: <0-10>/10 +> <2-4 句;若无语音/字幕,可给 0-3 分并解释原因> + +#### 情绪唤起: <0-10>/10 +> <2-4 句> + +#### 信息密度: <0-10>/10 +> <2-4 句> + +#### 节奏掌控: <0-10>/10 +> <2-4 句> + +--- + +### 亮点 +- <要点1> +- <要点2> + +### 待改进 +- <要点1> +- <要点2> + +### 优化建议 +1. <建议1> +2. <建议2> +3. <建议3> +3) 禁止输出任何函数调用片段、工具日志、系统占位串(例如 PLHD/transfer_to_agent)。 完成输出后,直接输出结果即可。你处于 SequentialAgent 流程中,框架会自动将控制权传递给下一个步骤,无需手动调用任何转移函数。 """ diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py index 80fb2fc5..5076e7ec 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/tools/report_generator.py @@ -109,7 +109,8 @@ def _resolve_report_inputs( if not hook: state_hook_md = state.get("hook_analysis_markdown") if isinstance(state_hook_md, str) and state_hook_md.strip(): - hook = _fallback_hook_from_markdown(state_hook_md) + # 不强依赖解析分数字段:报告阶段直接消费 Markdown,避免因解析失败回填 0 分。 + hook = {"_raw_markdown": state_hook_md.strip()} # BGM 工具可选落盘,若存在则补齐 state_bgm = state.get("bgm_analysis_result") @@ -283,6 +284,11 @@ def _build_hook_section(hook_analysis: dict) -> str: "钩子分析部分数据不完整,已基于可用分镜数据生成主报告。" ) + raw_md = hook_analysis.get("_raw_markdown") + if isinstance(raw_md, str) and raw_md.strip(): + # 直接嵌入 hook_format_agent 的 Markdown 输出,保证稳定可读。 + return raw_md.strip() + overall = hook_analysis.get("overall_score", 0) hook_type = hook_analysis.get("hook_type", "N/A") hook_type_analysis = hook_analysis.get("hook_type_analysis", "") From 063fbe433aa22af031f3265ad6c3c4d3b1d2cf62 Mon Sep 17 00:00:00 2001 From: edy Date: Fri, 13 Feb 2026 20:36:37 +0800 Subject: [PATCH 12/13] =?UTF-8?q?style:=20=E4=BF=AE=E5=A4=8D=20pre-commit?= =?UTF-8?q?=20=E6=A3=80=E6=9F=A5=E7=9A=84=E4=BB=A3=E7=A0=81=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../video_breakdown_agent/video_breakdown_agent/agent.py | 8 ++++++-- .../video_breakdown_agent/hook/format_hook.py | 1 - .../video_breakdown_agent/hook/search_output_hook.py | 5 ++++- .../sub_agents/hook_analyzer_agent/agent.py | 4 +++- .../sub_agents/hook_analyzer_agent/hook/__init__.py | 1 - .../hook_analyzer_agent/hook/clean_tool_args.py | 1 - 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py index c15c3f34..4726c0dd 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/agent.py @@ -145,14 +145,18 @@ def _prime_hook_segments_state(callback_context: CallbackContext): "thinking": { "type": os.getenv("THINKING_HOOK_ANALYZER_AGENT", "disabled") }, - "caching": {"type": "disabled"}, # 禁用缓存,避免账户未激活 cache service + "caching": { + "type": "disabled" + }, # 禁用缓存,避免账户未激活 cache service } }, ) hook_format_agent = Agent( name="hook_format_agent", - model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015")), + model_name=os.getenv( + "MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015") + ), description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", instruction=HOOK_FORMAT_INSTRUCTION, generate_content_config=json_response_config, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py index 5e8ce27f..616d5c5d 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/format_hook.py @@ -12,7 +12,6 @@ import re from typing import Any, Optional -import json_repair from google.adk.agents.callback_context import CallbackContext from google.adk.events import Event from google.adk.models import LlmResponse diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py index 8c48e457..3823567b 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/hook/search_output_hook.py @@ -11,7 +11,10 @@ def _is_tool_call_turn(model_response_event: Optional[Event], text: str) -> bool """工具调用回合不做处理,避免干扰 function call。""" if model_response_event: # 检查 function calls(LLM 发起的工具调用)和 function responses(工具返回后的阶段) - if model_response_event.get_function_calls() or model_response_event.get_function_responses(): + if ( + model_response_event.get_function_calls() + or model_response_event.get_function_responses() + ): return True return "transfer_to_agent" in (text or "") or "web_search" in (text or "") diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py index 44f737f7..24691732 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/agent.py @@ -54,7 +54,9 @@ def _prime_hook_segments_state(callback_context: CallbackContext): # 第二阶段:对齐 multimedia 的格式化收口模式 hook_format_agent = Agent( name="hook_format_agent", - model_name=os.getenv("MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015")), + model_name=os.getenv( + "MODEL_FORMAT_NAME", os.getenv("MODEL_AGENT_NAME", "doubao-seed-1-6-251015") + ), description="将钩子分析结果格式化为结构化输出并投影为用户可读 Markdown", instruction=HOOK_FORMAT_INSTRUCTION, generate_content_config=json_response_config, diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py index caf53677..29754321 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/__init__.py @@ -1,2 +1 @@ """Hook utilities for hook_analyzer_agent.""" - diff --git a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py index ecb28b65..0c0ce6b0 100644 --- a/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py +++ b/python/02-use-cases/video_breakdown_agent/video_breakdown_agent/sub_agents/hook_analyzer_agent/hook/clean_tool_args.py @@ -49,4 +49,3 @@ def clean_analyze_hook_arguments( "[clean_tool_args] polluted args detected in hook_analysis_agent, forced to {}" ) return llm_response - From 79050c553c27b26ee5e7c3f3dc92c980ee1a52e4 Mon Sep 17 00:00:00 2001 From: Mieopm <128307580+Mieopm@users.noreply.github.com> Date: Sat, 14 Feb 2026 10:42:42 +0800 Subject: [PATCH 13/13] Delete python/02-use-cases/partners/video_breakdown_agent directory --- .../partners/video_breakdown_agent/README.md | 504 ------------------ .../video_breakdown_agent/pyproject.toml | 41 -- .../video_breakdown_agent/requirements.txt | 34 -- 3 files changed, 579 deletions(-) delete mode 100644 python/02-use-cases/partners/video_breakdown_agent/README.md delete mode 100644 python/02-use-cases/partners/video_breakdown_agent/pyproject.toml delete mode 100644 python/02-use-cases/partners/video_breakdown_agent/requirements.txt diff --git a/python/02-use-cases/partners/video_breakdown_agent/README.md b/python/02-use-cases/partners/video_breakdown_agent/README.md deleted file mode 100644 index 9590f3fe..00000000 --- a/python/02-use-cases/partners/video_breakdown_agent/README.md +++ /dev/null @@ -1,504 +0,0 @@ -# 智能短视频拆解分析助手 - -## 概述 - -这是一个基于火山引擎 VeADK & AgentKit 构建的智能短视频分析系统。本系统采用 Multi-Agent 架构,集成 FFmpeg 视频处理、火山 ASR 语音识别、LiteLLM 多模态视觉分析和 TOS 对象存储,能够对短视频进行全方位的专业分析。 - -## 核心功能 - -本项目提供以下核心功能: - -- **视频分镜拆解**:基于 FFmpeg 自动识别视频分镜,提取关键帧并进行画面内容分析,输出结构化分镜数据 -- **前三秒钩子分析**:从视觉冲击力、语言钩子、情绪唤起、信息密度、节奏掌控 5 个维度进行专业评分 -- **专业报告生成**:整合分镜数据和钩子分析结果,生成 Markdown 格式的完整分析报告 -- **联网搜索**:实时获取短视频行业最新资讯、平台规则和热门趋势 - -## 🎯 核心解决场景 - -本系统致力于解决短视频创作与运营中的以下痛点: - -- **📉 前三秒流失率高**:通过 **Hook Analyzer** 深度拆解开头黄金3秒,量化视觉/听觉/内容钩子质量,提供针对性优化建议。 -- **😵 卖点传递不清**:通过 **分镜拆解** 梳理叙事逻辑,确保产品核心卖点在关键时间窗内有效传递。 -- **🐢 竞品分析效率低**:自动化提取竞品分镜结构与拍摄手法,快速复刻爆款视频公式。 -- **📝 脚本创作没灵感**:基于优秀视频反推分镜脚本,为新视频创作提供结构化参考。 - -## 使用案例 - -以下展示了从联网搜索到视频深度拆解的典型使用流程。 - -### 1. 联网搜索(天气/行业资讯) - -**用户:** 杭州天气 - -**小视(Root Agent):** 根据搜索结果,杭州当前天气及未来几天预报如下: - -📍 **杭州市天气(2026年2月11日 星期三)** - -**当前实况** -- **温度**:13℃(当前),全天5~15℃ -- **天气**:晴 -- **风向**:北风4级(午后转西风) -- **湿度**:36% -- **空气质量**:AQI 64(良),主要污染物PM2.5 - -**📅 未来三天预报** - -| 日期 | 天气 | 气温范围 | 风向风力 | -| :--- | :--- | :--- | :--- | -| 2月11日(周三) | 晴 → 晴 | 5~15℃ | 北风转西风 2-4级 | -| 2月12日(周四) | 晴 → 晴 | 6~16℃ | 东南风 2级 | -| 2月13日(周五) | 多云 | 8~20℃ | 南风 2级 | - -**🌡️ 生活指数参考** -- **洗车指数**:今日适宜(未来两天无雨) -- **运动指数**:今日不适宜剧烈运动(气压偏低),明日适宜 -- **紫外线**:弱(建议做好基础防晒) - ---- - -### 2. 视频分镜拆解 - -**用户:** http://example.com/sample-video.mp4 - -**小视(Root Agent):** 好的,我将为您拆解该视频的分镜结构。 - -#### 🎥 视频分镜拆解结果 - -**视频信息** -- **时长**:17.07秒 -- **分辨率**:720x960 -- **帧速率**:60fps - -**分镜列表** - -| 序号 | 时间段 | 景别 | 运镜 | 画面内容描述 | 语音内容 | 功能标签 | -| :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| 1 | 0.0-3.0s | 特写 | 固定 | 单手持米白色乐扣水杯展示整体,背景有日历、英文装饰画;过渡到双手操作杯盖提手。 | 无 | 产品展示 | -| 2 | 3.0-5.0s | 特写 | 固定 | 美甲双手打开杯盖,水流注入杯中;背景从英文装饰牌切换为日历场景。 | 无 | 产品展示 | -| 3 | 5.0-10.0s | 近景 | 固定 | 水杯接水后,美甲手握住盖好的水杯;背景含卡通图案布、日历等。 | 无 | 产品展示 | -| 4 | 10.0-17.07s | 近景 | 固定 | 先展示白色乐扣保温杯(带提手),后切换为透明条纹水杯,背景风格一致。 | 无 | 产品展示 | - -**BGM 分析** -- **是否有 BGM**:是 -- **风格**:轻音乐(纯音乐/氛围),标签:舒缓、治愈、简约 -- **情绪**:轻松愉悦(正面),强度中等 -- **乐器**:钢琴(主导)、小提琴、合成器、轻柔打击乐 -- **节奏**:60-80 BPM(慢节奏),平稳4/4拍 - -**完整语音文本** -- 无语音内容(ASR未识别到文字) - -etc. - ---- - -### 3. 前三秒钩子评测 - -**用户:** 详细分析评测视频前三秒钩子 - -**小视(Root Agent):** - -#### 🪝 视频前三秒钩子分析评测 - -**钩子核心要素拆解** -- **视觉钩子(0-3秒)**:特写镜头聚焦产品提手设计,通过“握持-提动”动作强化产品便捷性认知,背景生活化,美甲细节增加精致感。 -- **听觉钩子(0-3秒)**:舒缓钢琴+小提琴轻音乐(60-80 BPM),营造治愈氛围,降低用户跳出率。 -- **内容钩子(0-3秒)**:3秒内完成“是什么→怎么用”的认知闭环,直击“开合便利性”需求。 - -**钩子有效性评测** - -| 维度 | 评分(1-10) | 分析 | -| :--- | :--- | :--- | -| 视觉吸引力 | 8.5 | 特写聚焦产品,动作连贯无冗余。 | -| 信息传递效率 | 9.0 | 3秒内完成核心信息传递,符合认知习惯。 | -| 情绪感染力 | 7.5 | 氛围舒缓但记忆点略显不足。 | - ---- - -etc. - -### 4. 生成视频洞察报告 - -**用户:** 给出详细的视频洞察报告 - -**小视(Root Agent):** - -#### 📊 乐扣水杯产品展示视频洞察报告 - -**一、内容结构分析** -视频采用“整体→细节→场景→延伸”的递进逻辑,4个分镜形成完整产品认知链。 - -**二、用户注意力分析** -- **高注意力时段**:0-5秒(动作变化频繁,视觉焦点明确)。 -- **风险点**:10-17秒(产品切换缺乏过渡,易导致认知断层)。 - -**三、转化潜力评估** -- **优势**:符合“黄金3秒”法则,多产品展示覆盖不同需求。 -- **不足**:未明确材质安全、保温时长等核心卖点。 - -**四、优化建议** -- **视觉层**:增加“防漏测试”特写。 -- **听觉层**:加入杯盖开合的清脆音效增强记忆。 -- **内容层**:叠加“食品级304”等文字标签。 - -etc. - -## Agent 能力 - -![Video Breakdown Agent with AgentKit Runtime](assets/architecture_video_breakdown_agent.jpg) - -```text -用户输入(视频URL/本地文件) - ↓ -AgentKit 运行时 - ↓ -Root Agent(小视 - 主编排器) - ├── Breakdown Agent(分镜拆解) - │ ├── FFmpeg 视频预处理 - │ ├── 火山 ASR 语音识别 - │ ├── LiteLLM 视觉分析 - │ └── BGM 分析 - ├── Hook Analyzer Agent(钩子分析) - │ ├── 前三秒分镜提取 - │ ├── 多模态视觉评分 - │ └── JSON 格式化 - ├── Report Generator Agent(报告生成) - └── Search Agent(联网搜索) -``` - -主要的火山引擎产品或 Agent 组件: - -- 方舟大模型: - - doubao-seed-1-6-251015(主推理模型) - - doubao-seed-1-6-vision-250815(视觉分析模型) -- TOS 对象存储 -- 火山 ASR 语音识别(可选) -- Web Search 联网搜索 -- AgentKit -- APMPlus(可选可观测性) - -第三方依赖: - -- FFmpeg(通过 imageio-ffmpeg 自动打包,无需手动安装) -- LiteLLM(支持 Gemini、豆包、GPT-4o 等多种视觉模型) - -## 目录结构说明 - -```bash -video_breakdown_agent/ -├── README.md # 项目说明文档 -├── README_CONFIG.md # 详细配置指南 -├── project.toml # 应用广场元数据 -├── agent.py # AgentKit 部署入口 -├── requirements.txt # pip 依赖清单 -├── pyproject.toml # uv 项目配置 -├── config.yaml # 配置文件(示例,实际密钥通过环境变量注入) -├── config.yaml.example # 配置模板 -├── deploy.sh # 部署脚本 -├── video_breakdown_agent/ # Python 包(核心代码) -│ ├── agent.py # Root Agent 定义 -│ ├── prompt.py # 主编排 Prompt -│ ├── sub_agents/ # 子 Agent -│ │ ├── breakdown_agent/ # 分镜拆解 Agent -│ │ ├── hook_analyzer_agent/# 钩子分析 Agent(SequentialAgent) -│ │ └── report_generator_agent/ # 报告生成 Agent -│ ├── tools/ # 工具函数 -│ │ ├── process_video.py # 视频预处理(FFmpeg + ASR) -│ │ ├── analyze_segments_vision.py # 视觉分析 -│ │ ├── analyze_bgm.py # BGM 分析 -│ │ ├── analyze_hook_segments.py # 钩子分镜提取 -│ │ ├── report_generator.py # 报告生成 -│ │ └── video_upload.py # TOS 视频上传 -│ ├── hook/ # Callback 钩子 -│ │ ├── format_hook.py # JSON 修复 -│ │ └── video_upload_hook.py# 文件上传拦截 -│ └── utils/ # 工具类 -│ └── types.py # Pydantic 数据模型 -└── img/ # 架构图和截图 -``` - -## 本地运行 - -### 前置准备 - -**Python 版本:** - -- Python 3.12 或更高版本 - -**1. 开通火山方舟模型服务:** - -- 访问 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/overview) -- 进入"开通管理" → "语言模型" → 找到以下模型 → 点击"开通服务" - - `doubao-seed-1-6-251015`(主推理模型) - - `doubao-seed-1-6-vision-250815`(视觉分析模型,可选) -- 确认开通,等待服务生效(通常 1-2 分钟) - -**2. 获取火山引擎访问凭证:** - -- 登录 [火山引擎控制台](https://console.volcengine.com) -- 进入"访问控制" → "用户" → 选择用户 → "密钥" → 新建密钥或复制已有 AK/SK -- 为用户配置权限: - - `AgentKitFullAccess`(AgentKit 全量权限) - - `APMPlusServerFullAccess`(APMPlus 全量权限,可选) - -**3. 获取火山方舟模型 API Key:** - -- 登录 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey) -- 进入"API Key 管理" → 创建或复制已有 API Key - -**4. 创建 TOS 存储桶:** - -- 访问 [TOS 控制台](https://console.volcengine.com/tos/bucket) -- 点击"创建桶" → 填写桶名称 → 选择区域(建议 cn-beijing)→ 创建 -- 记录桶名称,后续在环境变量 `DATABASE_TOS_BUCKET` 中填入 - -**5. 可选:开通火山 ASR 语音识别服务:** - -> 如未配置 ASR,系统会跳过语音识别,仍可完成分镜拆解和视觉分析 - -- 访问 [火山语音服务控制台](https://console.volcengine.com/speech/service/list) -- 创建应用并获取 `APP_ID` 和 `ACCESS_KEY` - -### 依赖安装 - -#### 1. 安装 uv 包管理器 - -```bash -# macOS / Linux(官方安装脚本) -curl -LsSf https://astral.sh/uv/install.sh | sh - -# 或使用 Homebrew(macOS) -brew install uv -``` - -#### 2. 初始化项目依赖 - -```bash -# 进入项目目录 -cd 02-use-cases/video_breakdown_agent -``` - -您可以通过 `pip` 工具来安装本项目依赖: - -```bash -pip install -r requirements.txt -``` - -或者使用 `uv` 工具来安装本项目依赖(推荐): - -```bash -# 如果没有 `uv` 虚拟环境,可以使用命令先创建一个虚拟环境 -uv venv --python 3.12 - -# 使用 `pyproject.toml` 管理依赖 -uv sync --index-url https://pypi.tuna.tsinghua.edu.cn/simple - -# 激活虚拟环境 -source .venv/bin/activate -``` - -### 环境准备 - -设置以下环境变量: - -```bash -# 火山方舟模型 API Key(必需) -export MODEL_AGENT_API_KEY= - -# 火山引擎访问凭证(必需) -export VOLCENGINE_ACCESS_KEY= -export VOLCENGINE_SECRET_KEY= - -# TOS 存储桶名称(必需) -export DATABASE_TOS_BUCKET= -export DATABASE_TOS_REGION=cn-beijing - -# 可选:火山 ASR 配置(未配置时跳过语音识别) -export ASR_APP_ID= -export ASR_ACCESS_KEY= - -# 可选:切换视觉分析模型(默认使用豆包 Vision) -export MODEL_VISION_NAME=doubao-seed-1-6-vision-250815 -# 或切换为 Google Gemini(需配置 GEMINI_API_KEY) -# export MODEL_VISION_NAME=gemini/gemini-2.5-pro -# export GEMINI_API_KEY= -``` - -> 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) - -### 调试方法 - -使用 `veadk web` 进行本地调试: - -```bash -# 进入 02-use-cases 目录 -cd 02-use-cases - -# 启动 VeADK Web 界面 -veadk web --port 8080 - -# 在浏览器访问:http://127.0.0.1:8080 -``` - -Web 界面提供图形化对话测试环境,支持实时查看消息流和调试信息。 - -### 示例提示词 - -```text -帮我拆解这个视频的分镜结构 https://example.com/video.mp4 -分析这个视频前三秒的钩子吸引力,给出专业评分 -生成完整的视频分析报告 -搜一下抖音最新推荐算法有什么变化 -``` - -### 效果展示 - -> 建议补充实际运行截图展示分镜拆解、钩子分析和报告生成效果 - -## AgentKit 部署 - -### 前置准备 - -**重要提示**:在运行本示例之前,请先访问 [AgentKit 控制台授权页面](https://console.volcengine.com/agentkit/region:agentkit+cn-beijing/auth?projectName=default) 对所有依赖服务进行授权,确保案例能够正常执行。 - -参考"本地运行"部分的"前置准备"。 - -### 依赖安装 - -> 如果您本地已经安装了该依赖,跳过此步骤。 - -使用 `pip` 安装 AgentKit 命令行工具: - -```bash -pip install agentkit-sdk-python==0.5.1 -``` - -或者使用 `uv` 安装 AgentKit 命令行工具: - -```bash -uv pip install agentkit-sdk-python==0.5.1 -``` - -### 设置环境变量 - -```bash -# 火山引擎访问凭证(必需) -export VOLCENGINE_ACCESS_KEY= -export VOLCENGINE_SECRET_KEY= -``` - -### AgentKit 云上部署 - -```bash -# 1. 进入项目目录 -cd 02-use-cases/video_breakdown_agent - -# 2. 配置 Agentkit 部署配置 -agentkit config \ - --agent_name video_breakdown_agent \ - --entry_point 'agent.py' \ - --launch_type cloud - -# 3. 配置 AgentKit Runtime 环境变量(应用级) -# 以下环境变量均为必填项,参考"前置准备"部分获取相应的值 -agentkit config \ - -e MODEL_AGENT_API_KEY= \ - -e DATABASE_TOS_BUCKET= \ - -e DATABASE_TOS_REGION=cn-beijing - -# 可选:配置 ASR 服务 -agentkit config \ - -e ASR_APP_ID= \ - -e ASR_ACCESS_KEY= - -# 4. 启动云端服务 -agentkit launch - -# 5. 测试部署的 Agent -agentkit invoke "分析这个视频 https://example.com/video.mp4" -``` - -### 测试已部署的智能体 - -在 AgentKit 控制台"智能体运行时"页面找到已部署的智能体 `video_breakdown_agent`,点击在线测评,输入提示词进行测试。 - -## 主要特性 - -### Multi-Agent 协作架构 - -Root Agent 作为主编排器,根据用户意图自动调度 4 个专业子 Agent: - -- **Breakdown Agent**:视频预处理 + ASR + 视觉分析 + BGM 分析 -- **Hook Analyzer Agent**:SequentialAgent 模式,先视觉分析后格式化 -- **Report Generator Agent**:整合数据生成 Markdown 报告 -- **Search Agent**:联网搜索行业信息 - -### 优雅降级机制 - -系统具备完善的容错能力: - -- TOS 上传失败 → 自动回退 base64 编码 -- ASR 未配置 → 跳过语音识别,仍可完成分镜拆解 -- FFmpeg 未安装 → 自动使用 imageio-ffmpeg 打包版本 - -### 多模型灵活切换 - -通过 LiteLLM 统一路由,支持一行配置切换视觉模型: - -- `doubao-seed-1-6-vision-250815`(火山方舟豆包) -- `gemini/gemini-2.5-pro`(Google Gemini) -- `gpt-4o`(OpenAI) - -### Session State 数据共享 - -子 Agent 之间通过 Session State 共享数据,避免重复序列化大 JSON: - -- `process_video` → `breakdown_result` -- `hook_analyzer` → `hook_analysis` -- `report_generator` → `final_report` - -## 常见问题 - -**错误:`VOLCENGINE_ACCESS_KEY not set`** - -- 请确保已设置火山引擎访问凭证环境变量 -- 参考"环境准备"章节设置 `VOLCENGINE_ACCESS_KEY` 和 `VOLCENGINE_SECRET_KEY` - -**错误:`TOS 存储桶不存在`** - -- 请在 TOS 控制台创建存储桶 -- 确认 `DATABASE_TOS_BUCKET` 和 `DATABASE_TOS_REGION` 配置正确 -- 系统会自动回退 base64 编码,不影响核心功能 - -**FFmpeg 未找到:** - -- 系统会自动使用 imageio-ffmpeg 打包版本,无需手动安装 -- 如需使用系统 FFmpeg,请确保已安装并在 PATH 中 - -**ASR 语音识别失败:** - -- 检查 `ASR_APP_ID` 和 `ASR_ACCESS_KEY` 是否正确 -- 未配置 ASR 时系统会跳过语音识别,仍可完成分镜拆解 - -**视觉模型切换:** - -- 修改 `MODEL_VISION_NAME` 环境变量即可切换模型 -- 使用 Gemini 时需配置 `GEMINI_API_KEY` -- 详细配置说明请参考 [README_CONFIG.md](README_CONFIG.md) - -**视频文件过大:** - -- 当前限制视频文件大小为 2GB -- 建议压缩视频后重试 - -## 参考资料 - -- [VeADK 官方文档](https://volcengine.github.io/veadk-python/) -- [AgentKit 开发指南](https://volcengine.github.io/agentkit-sdk-python/) -- [火山方舟模型服务](https://console.volcengine.com/ark/region:ark+cn-beijing/overview?briefPage=0&briefType=introduce&type=new&projectName=default) -- [TOS 对象存储](https://www.volcengine.com/product/TOS) -- [火山 ASR 语音识别](https://www.volcengine.com/product/voice-tech) - -## 代码许可 - -本工程遵循 Apache 2.0 License diff --git a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml b/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml deleted file mode 100644 index e015d8cb..00000000 --- a/python/02-use-cases/partners/video_breakdown_agent/pyproject.toml +++ /dev/null @@ -1,41 +0,0 @@ -[project] -name = "video-breakdown-agent" -version = "0.3.0" -description = "视频分镜拆解和前三秒钩子分析 AgentKit Agent(Multi-Agent 架构)" -requires-python = ">=3.12,<3.13" -dependencies = [ - # AgentKit 核心三件套 - "veadk-python==0.5.1", - "google-adk==1.19.0", # veadk-python 0.5.1 锁定此版本 - "agentkit-sdk-python==0.5.1", - # HTTP 客户端 - "httpx>=0.26.0", - # TOS 对象存储(视频上传) - "tos>=2.8.7", - # JSON 修复(format hook) - "json-repair>=0.54.1", - # 配置管理 - "python-dotenv>=1.0.0", - # 生产模式服务器 - "uvicorn>=0.34.0", - # Web 框架(A2A + MCP) - "fastapi>=0.115.0", - # MCP Server - "fastmcp>=2.0.0", - # 异步 HTTP - "aiohttp>=3.11.0", - # FFmpeg 二进制(随 Python 包分发,无需系统安装) - "imageio-ffmpeg>=0.5.1", -] - -[dependency-groups] -dev = [ - "pytest>=8.0.0", -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["video_breakdown_agent"] diff --git a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt b/python/02-use-cases/partners/video_breakdown_agent/requirements.txt deleted file mode 100644 index d12a27d6..00000000 --- a/python/02-use-cases/partners/video_breakdown_agent/requirements.txt +++ /dev/null @@ -1,34 +0,0 @@ -# pip 兼容依赖列表(推荐使用 pyproject.toml + uv) -# pip install -r requirements.txt - -# AgentKit 核心 -veadk-python==0.5.1 -google-adk==1.19.0 -agentkit-sdk-python==0.5.1 - -# HTTP 客户端 -httpx>=0.26.0 - -# TOS 对象存储(视频上传) -tos>=2.8.7 - -# JSON 修复(format hook) -json-repair>=0.54.1 - -# 配置管理 -python-dotenv>=1.0.0 - -# FFmpeg 二进制(随 Python 包分发,无需系统安装 FFmpeg) -imageio-ffmpeg>=0.5.1 - -# 生产模式服务器 -uvicorn>=0.34.0 - -# Web 框架(A2A + MCP) -fastapi>=0.115.0 - -# MCP Server -fastmcp>=2.0.0 - -# 异步 HTTP -aiohttp>=3.11.0