Files

1 line
37 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "419bw/edge-cloud-ai", "files": {"cloud/start_cloud.py": {"f35585ed4e240510d3d3266004d3f684e0879690": {"t": 1785768244.183736, "n": 3787, "h": "c8f40d4dad62149a", "c": "\"\"\"云端服务启动脚本(同时拉起 API + MQTT 消费者)。\n\n用法:\n python -m cloud.start_cloud\n\"\"\"\n\nimport logging\nimport os\nimport sys\nimport threading\nimport time\nfrom pathlib import Path\n\n# 把项目根加到 sys.path\nPROJECT_ROOT = Path(__file__).resolve().parent.parent\nif str(PROJECT_ROOT) not in sys.path:\n sys.path.insert(0, str(PROJECT_ROOT))\n\nlogging.basicConfig(\n level=logging.INFO,\n format=\"%(asctime)s [%(name)s] %(levelname)s: %(message)s\",\n)\nlogger = logging.getLogger(\"start_cloud\")\n\n\ndef main():\n # 加载 .env\n env_path = PROJECT_ROOT / \"deploy\" / \".env\"\n if env_path.exists():\n from dotenv import load_dotenv\n load_dotenv(env_path)\n logger.info(f\"已加载环境变量: {env_path}\")\n\n # 检查 API Key\n if not os.environ.get(\"SENSENOVA_API_KEY\"):\n logger.warning(\"⚠️ SENSENOVA_API_KEY 未设置,云端 LLM 不可用\")\n logger.warning(\" 请在 deploy/.env 中填入,或 export SENSENOVA_API_KEY=sk-xxx\")\n\n # 1. 初始化数据库\n from cloud.db.events import EventDB\n db = EventDB()\n logger.info(f\"✓ 事件数据库就绪({db.count_events()} 条历史事件)\")\n\n # 2. 初始化 Agent\n from cloud.agent.chain import EdgeCloudAgent\n try:\n agent = EdgeCloudAgent()\n logger.info(\"✓ 云端 Agent 就绪\")\n except Exception as e:\n logger.error(f\"✗ Agent 初始化失败: {e}\")\n agent = None\n\n # 3. 启动 MQTT 消费者\n from cloud.bridge.mqtt_consumer import MqttAlertConsumer\n mqtt_host = os.environ.get(\"MQTT_HOST\", \"127.0.0.1\")\n mqtt_port = int(os.environ.get(\"MQTT_PORT\", 1883))\n\n consumer = None\n if agent is not None:\n def on_alert(alert_dict):\n \"\"\"处理告警:调 Agent + 存库。\"\"\"\n logger.info(f\"[bridge] 收到告警: {alert_dict.get('device_id')} {alert_dict.get('reason')}\")\n try:\n reply = agent.analyze_alert(alert_dict)\n db.insert_event(alert_dict, agent_reply=reply, agent_status=\"ok\")\n logger.info(f\"[bridge] Agent 回复: {reply[:100]}...\")\n return reply\n except Exception as e:\n logger.error(f\"[bridge] Agent 失败: {e}\")\n db.insert_event(alert_dict, agent_reply=None, agent_status=f\"error: {e}\")\n return f\"[错误] {e}\"\n\n consumer = MqttAlertConsumer(host=mqtt_host, port=mqtt_port, on_alert=on_alert)\n if consumer.start():\n logger.info(f\"✓ MQTT 消费者已连接 {mqtt_host}:{mqtt_port}\")\n else:\n logger.warning(f\"⚠️ MQTT 消费者未连上 {mqtt_host}:{mqtt_port},将重试\")\n # 后台重试\n def _retry():\n while True:\n time.sleep(5)\n try:\n if consumer._client is not None:\n consumer._client.reconnect()\n except Exception:\n pass\n threading.Thread(target=_retry, daemon=True).start()\n\n # 4. 启动 FastAPI\n from cloud.api.server import set_agent, set_db, set_consumer, start_server_background\n set_agent(agent)\n set_db(db)\n set_consumer(consumer)\n\n api_host = os.environ.get(\"API_HOST\", \"0.0.0.0\")\n api_port = int(os.environ.get(\"API_PORT\", 9000))\n start_server_background(host=api_host, port=api_port)\n logger.info(f\"✓ Cloud API 已启动: http://{api_host}:{api_port}\")\n logger.info(\" - GET /health\")\n logger.info(\" - GET /events\")\n logger.info(\" - POST /chat\")\n logger.info(\" - POST /alerts\")\n\n logger.info(\"\")\n logger.info(\"=\" * 60)\n logger.info(\" 云端服务已就绪,启动 Dashboard:\")\n logger.info(\" streamlit run dashboard/app.py\")\n logger.info(\"=\" * 60)\n logger.info(\"\")\n\n # 阻塞主线程\n try:\n while True:\n time.sleep(1)\n except KeyboardInterrupt:\n logger.info(\"收到 Ctrl+C,正在退出...\")\n if consumer:\n consumer.stop()\n sys.exit(0)\n\n\nif __name__ == \"__main__\":\n main()\n"}}, "cloud/agent/llm_factory.py": {"f35585ed4e240510d3d3266004d3f684e0879690": {"t": 1785768245.8583362, "n": 6204, "h": "0c5e9c917d52c481", "c": "\"\"\"商汤 SenseNova LLM 工厂(云端 Agent 核心)。\n\n设计要点:\n- **一个模型统一处理文本+视觉**:sensenova-6.7-flash-lite 支持多模态\n- **OpenAI 兼容协议**:用 openai SDK 直接调,无需 sensenova SDK\n- **支持 function calling**:tools 字段声明,tool_choice=\"auto\"\n- **支持流式响应**:stream=True,SSE\n- **失败重试**:tenacity 3 次指数退避\n\n使用:\n from cloud.agent.llm_factory import get_llm, get_vision_messages\n\n # 1. 普通文本对话\n llm = get_llm()\n resp = llm.invoke(\"你好,介绍下你自己\")\n\n # 2. 视觉问答(看图)\n msgs = get_vision_messages(\n user_text=\"图中的人在做什么?\",\n image_b64=\"iVBORw0KGgo...\", # base64 JPEG/PNG\n )\n resp = llm.invoke(msgs)\n\"\"\"\n\nfrom __future__ import annotations\n\nimport base64\nimport os\nfrom typing import Any, List, Optional, Union\n\nfrom openai import OpenAI\nfrom tenacity import retry, stop_after_attempt, wait_exponential\n\n# --------------------- 配置 ---------------------\n\nDEFAULT_BASE_URL = \"https://token.sensenova.cn/v1\"\nDEFAULT_MODEL = \"sensenova-6.7-flash-lite\"\n\n\ndef get_api_key() -> str:\n \"\"\"读取 SENSENOVA_API_KEY 环境变量。\"\"\"\n key = os.environ.get(\"SENSENOVA_API_KEY\", \"\")\n if not key:\n raise RuntimeError(\n \"SENSENOVA_API_KEY 未设置!请在 .env 文件中填入,\"\n \"或在启动前 export SENSENOVA_API_KEY=sk-xxx\"\n )\n return key\n\n\ndef get_base_url() -> str:\n return os.environ.get(\"SENSENOVA_BASE_URL\", DEFAULT_BASE_URL)\n\n\ndef get_model() -> str:\n return os.environ.get(\"SENSENOVA_MODEL\", DEFAULT_MODEL)\n\n\n# --------------------- LLM 客户端 ---------------------\n\nclass SensenovaClient:\n \"\"\"商汤 SenseNova 客户端(OpenAI 兼容协议封装)。\"\"\"\n\n def __init__(self, api_key: Optional[str] = None,\n base_url: Optional[str] = None,\n model: Optional[str] = None,\n timeout: float = 60.0):\n self.api_key = api_key or get_api_key()\n self.base_url = base_url or get_base_url()\n self.model = model or get_model()\n self.timeout = timeout\n\n # OpenAI 客户端\n self._client = OpenAI(\n api_key=self.api_key,\n base_url=self.base_url,\n timeout=self.timeout,\n )\n\n @retry(\n stop=stop_after_attempt(3),\n wait=wait_exponential(multiplier=1, min=2, max=10),\n reraise=True,\n )\n def chat(self, messages: List[dict], **kwargs) -> dict:\n \"\"\"普通对话(带重试)。\n\n 参数:\n messages: OpenAI 风格消息列表\n **kwargs: 透传给 chat.completions.create\n\n 返回:\n OpenAI 风格的 response(已转 dict)\n\n 注意:\n SenseNova 6.7 Flash-Lite 默认开启\"思考\"(reasoning_effort=medium),\n 思考过程会消耗很多 token。本方法默认关闭(reasoning_effort=\"none\"),\n 让模型直接回答,更适合 RAG/工具调用等场景。\n\n 遇到 429 RPM 限流时 tenacity 会自动指数退避重试 3 次。\n \"\"\"\n # 合并默认参数\n params = {\n \"model\": self.model,\n \"messages\": messages,\n \"temperature\": kwargs.pop(\"temperature\", 0.6),\n \"max_tokens\": kwargs.pop(\"max_tokens\", 2048),\n # 关闭思考,节省 token\n \"reasoning_effort\": kwargs.pop(\"reasoning_effort\", \"none\"),\n **kwargs,\n }\n try:\n resp = self._client.chat.completions.create(**params)\n return resp.model_dump()\n except Exception as e:\n err_str = str(e)\n # 429 RPM 限流:友好提示(不暴露完整 stack)\n if \"429\" in err_str or \"rpm\" in err_str.lower() or \"quota\" in err_str.lower():\n print(f\"[LLM] ⚠️ RPM 限流 (SenseNova 配额),将自动重试...\")\n raise # 让 tenacity 接管重试\n\n @retry(\n stop=stop_after_attempt(3),\n wait=wait_exponential(multiplier=1, min=2, max=10),\n reraise=True,\n )\n def chat_stream(self, messages: List[dict], **kwargs):\n \"\"\"流式对话(SSE)。\"\"\"\n params = {\n \"model\": self.model,\n \"messages\": messages,\n \"temperature\": kwargs.pop(\"temperature\", 0.6),\n \"max_tokens\": kwargs.pop(\"max_tokens\", 4096),\n \"stream\": True,\n \"stream_options\": {\"include_usage\": True},\n **kwargs,\n }\n stream = self._client.chat.completions.create(**params)\n for chunk in stream:\n yield chunk\n\n def vision_chat(self, user_text: str, image_b64: str,\n system_prompt: Optional[str] = None,\n **kwargs) -> str:\n \"\"\"视觉问答(看图 + 回答)。\n\n 参数:\n user_text: 用户的文本问题\n image_b64: base64 编码的图像(JPEG/PNG)\n system_prompt: 系统提示词(可选)\n\n 返回:\n 模型输出的文本\n \"\"\"\n # 构造 image_url\n # 优先用 data URI 方式(避免上传)\n # SenseNova 接受 data:image/jpeg;base64,{b64}\n data_uri = f\"data:image/jpeg;base64,{image_b64}\"\n # 注意:base64 可能已经包含 data URI 前缀\n if not image_b64.startswith(\"data:\"):\n data_uri = f\"data:image/jpeg;base64,{image_b64}\"\n else:\n data_uri = image_b64\n\n messages = []\n if system_prompt:\n messages.append({\"role\": \"system\", \"content\": system_prompt})\n\n messages.append({\n \"role\": \"user\",\n \"content\": [\n {\"type\": \"text\", \"text\": user_text},\n {\"type\": \"image_url\", \"image_url\": {\"url\": data_uri}},\n ],\n })\n\n resp_dict = self.chat(messages, **kwargs)\n return resp_dict[\"choices\"][0][\"message\"][\"content\"]\n\n\n# --------------------- 便捷函数 ---------------------\n\n_singleton: Optional[SensenovaClient] = None\n\n\ndef get_llm() -> SensenovaClient:\n \"\"\"获取全局 LLM 单例。\"\"\"\n global _singleton\n if _singleton is None:\n _singleton = SensenovaClient()\n return _singleton\n\n\ndef reset_singleton():\n \"\"\"重置单例(测试用)。\"\"\"\n global _singleton\n _singleton = None\n\n\ndef get_vision_messages(user_text: str, image_b64: str,\n system_prompt: Optional[str] = None) -> List[dict]:\n \"\"\"构造视觉问答消息列表(OpenAI 格式)。\"\"\"\n data_uri = image_b64 if image_b64.startswith(\"data:\") else f\"data:image/jpeg;base64,{image_b64}\"\n msgs = []\n if system_prompt:\n msgs.append({\"role\": \"system\", \"content\": system_prompt})\n msgs.append({\n \"role\": \"user\",\n \"content\": [\n {\"type\": \"text\", \"text\": user_text},\n {\"type\": \"image_url\", \"image_url\": {\"url\": data_uri}},\n ],\n })\n return msgs\n"}}, "README.md": {"f35585ed4e240510d3d3266004d3f684e0879690": {"t": 1785768260.5984597, "n": 7522, "h": "66be493a6cf31e00", "c": "# 边云协同智能系统 (Edge-Cloud Collaborative AI)\n\n> 《计算机系统综合实习》课程设计 \n> 简单任务边缘处理,复杂任务云端决策\n\n![架构](docs/architecture.png)\n\n## 系统组成\n\n| 组件 | 技术栈 | 部署位置 |\n|------|--------|----------|\n| **边端 Edge** | 华为 Atlas 200I DK A2 (昇腾 310B NPU) + YOLOv8n OM / PC 模拟 (ultralytics) | 现场 |\n| **MQTT Broker** | EMQX 5.6 (Docker) | 云端/PC |\n| **云端 Agent** | LangChain 风格 + **商汤 SenseNova 6.7 Flash-Lite** (多模态) + Tavily + Chroma RAG | 云端 (Docker/本机) |\n| **管理平台** | Streamlit 1.35+ (4 页面) | 浏览器 |\n\n## ✨ 核心特性\n\n- ✅ **边云协同**:YOLO 简单任务(计数/存在性)在边端完成,复杂任务(行为分析/场景理解)上云\n- ✅ **多模态云端大脑**:商汤 SenseNova 6.7 Flash-Lite 一个模型统一处理文本+图像\n- ✅ **完整工具链**:联网搜索 (Tavily) + 知识库 (Chroma RAG) + 边端指令下发 (MQTT)\n- ✅ **双后端切换**:PC 模式(ultralytics,~17 FPS) ↔ Atlas 模式(pyacl NPU,~22 FPS)\n- ✅ **零外部依赖测试**:纯 Python 断言,116+ 单元测试\n- ✅ **生产级 MQTT**:自动重连 + 离线消息缓存 + QoS 分级\n- ✅ **可视化管理**:Streamlit 4 页面(实时监控/任务日志/智能助手/系统状态)\n\n## 项目结构\n\n```\nedge-cloud-ai/\n├── shared/ # 跨边-云-管理平台的常量与数据模型\n├── edge/ # 边端代码\n│ ├── camera/ # USB摄像头 / 视频文件 / RTSP\n│ ├── detector/ # YOLO 双后端(pc/atlas/mock)\n│ ├── scheduler/ # 任务调度器(简单/复杂任务判定)\n│ ├── mqtt_client/ # paho-mqtt 自动重连\n│ ├── video_server/ # FastAPI MJPEG 流\n│ └── main.py # 主进程\n├── pc_simulator/ # PC 一键演示脚本\n├── cloud/ # 云端服务\n│ ├── agent/ # LLM 工厂 + Agent 主链\n│ │ ├── llm_factory.py # 商汤 SenseNova 客户端\n│ │ ├── chain.py # 多模态 Agent + function calling\n│ │ └── tools/ # Tavily / RAG / MQTT\n│ ├── api/ # FastAPI (:9000)\n│ ├── bridge/ # MQTT 消费者\n│ ├── rag/ # RAG 摄入脚本\n│ ├── db/ # SQLite 事件库\n│ └── start_cloud.py # 一键启动\n├── dashboard/ # Streamlit 4 页面 (:8501)\n│ ├── app.py # 首页\n│ ├── pages/ # 4 个分页\n│ └── utils/ # API/MQTT 工具\n├── deploy/ # Docker Compose + .env\n├── tests/ # 116+ 回归测试\n├── models/ # YOLO 权重\n└── requirements/ # 分模块依赖\n```\n\n## 🚀 一键复现指南\n\n### 0. 环境要求\n\n- Python 3.11+\n- Windows / Linux / macOS\n- 可选:Docker(用于 EMQX)\n\n### 1. 创建虚拟环境\n\n```bash\ncd edge-cloud-ai\npython -m venv .venv\n# Windows\n.venv\\Scripts\\activate\n# Linux/Mac\nsource .venv/bin/activate\n```\n\n### 2. 安装依赖\n\n```bash\npip install -r requirements/edge.txt # 边端\npip install -r requirements/cloud.txt # 云端\npip install -r requirements/dashboard.txt # Dashboard\n```\n\n### 3. 配置环境变量\n\n```bash\n# 复制样例\ncp deploy/.env.example deploy/.env\n\n# 编辑 deploy/.env 填入真实 API Key\n# SENSENOVA_API_KEY=sk-your-real-key\n# TAVILY_API_KEY=tvly-your-real-key(可选)\n```\n\n### 4. 启动 EMQX(可选,没 EMQX 也能 demo)\n\n```bash\ndocker run -d --name emqx \\\n -p 1883:1883 -p 18083:18083 \\\n -e EMQX_ALLOW_ANONYMOUS=true \\\n emqx/emqx:5.6\n```\n\n### 5. 启动云端服务\n\n```bash\npython -m cloud.start_cloud\n# ✓ 事件数据库就绪\n# ✓ 云端 Agent 就绪\n# ✓ Cloud API 已启动: http://0.0.0.0:9000\n```\n\n### 6. 启动 Dashboard(新终端)\n\n```bash\nstreamlit run dashboard/app.py\n# 浏览器打开 http://localhost:8501\n```\n\n### 7. 启动边缘模拟器(新终端)\n\n```bash\n# 自动模式:有 USB 摄像头就用,没有就 fallback 视频\npython pc_simulator/start_edge_simulator.py\n\n# 强制视频模式(无需摄像头)\npython pc_simulator/start_edge_simulator.py --video\n```\n\n### 8. 端到端测试\n\n```bash\n# 发一条 HTTP 告警测试 Agent\npython -c \"\nimport requests, time, base64, io\nfrom PIL import Image\nimg = Image.new('RGB', (224, 224), (180, 50, 50))\nbuf = io.BytesIO(); img.save(buf, 'JPEG')\ncrop = base64.b64encode(buf.getvalue()).decode()\nr = requests.post('http://127.0.0.1:9000/alerts', json={\n 'device_id': 'demo', 'ts': int(time.time()*1000), 'frame_id': 1,\n 'reason': 'blacklist', 'detections': [{'cls':'knife','cls_id':43,'conf':0.78,'bbox':[80,80,180,180]}],\n 'crop_b64': crop, 'thumb_b64': crop,\n})\nprint(r.json()['reply'][:300])\n\"\n\n# 浏览器打开 http://localhost:8501 查看 Dashboard\n```\n\n## 🧪 回归测试\n\n```bash\n# 跑所有测试(116+ 用例,0 依赖)\npython tests/run_all.py\n\n# 只跑某个模块\npython tests/run_all.py --module tests.test_scheduler\n```\n\n## 📊 任务分工(核心设计)\n\n| 任务类型 | 在哪跑 | 判定规则 | 示例 |\n|---------|--------|----------|------|\n| 存在性检测 | 边端 YOLO | \"画面里有没有 X\" | 有人/无人 |\n| 目标计数 | 边端 YOLO | \"画面里有几个 X\" | 人数计数 |\n| 类别识别 | 边端 YOLO | \"这个目标是什么\" | 人/车/狗 |\n| **行为分析** | **云端 Nova** | \"目标在做什么\" | 摔倒/聚集/攀爬 |\n| **场景理解** | **云端 Nova** | \"画面里发生什么\" | 老人摔倒 |\n| **意图推断** | **云端 Nova** | \"是否需要干预\" | 安全评估 |\n| 低置信度复核 | 云端 Nova | YOLO conf < 0.4 | 边端不确定 |\n| 黑名单组合 | 云端 Nova | knife/gun/fire | 立即上云 |\n\n**判定优先级**(`edge/scheduler/upstream.py`):\n1. 黑名单(knife/scissors/fire)→ 立即上云\n2. 低置信度 < 0.4 → 云端复核\n3. 高置信度 + 可信类别 → 边端自己处理\n4. 行为类别 (person/cat/dog) → 云端分析\n5. 默认 → 边端处理\n\n## 🛠️ 关键技术决策\n\n| 场景 | 选型 | 理由 |\n|------|------|------|\n| 边端后端 | PC: ultralytics / Atlas: pyacl | 一套代码两套推理 |\n| 通信协议 | MQTT (EMQX 5.6) | 轻量、发布订阅、Dashboard 自带 |\n| 云端 LLM | 商汤 SenseNova 6.7 Flash-Lite | 国产、便宜、多模态、OpenAI 协议 |\n| 联网搜索 | Tavily | AI 优化、免费 1000 次/月 |\n| 向量库 | Chroma 0.5 | 嵌入式、零部署 |\n| 前端 | Streamlit 1.35 | 纯 Python,4 页面 0 JS |\n| 测试 | 纯 Python 断言 | 0 依赖、跨平台 |\n\n## 📈 性能参考\n\n| 模式 | 推理 | FPS | 备注 |\n|------|------|-----|------|\n| PC 模式 (CPU) | YOLOv8n 640 | 5-17 FPS | 取决于 CPU |\n| PC 模式 (GPU) | YOLOv8n 640 | 50-100+ FPS | 推荐用于开发 |\n| Atlas 模式 | YOLOv8n OM | 15-22 FPS | NPU 加速 |\n| 云端 LLM | SenseNova 6.7 | 2-5s/响应 | 含图片+function calling |\n\n## 📚 实施进度\n\n- [x] **Day 0** 项目骨架 + PC 模拟模式(无需 Atlas 板)\n- [x] **阶段 1** 边端 YOLO 双后端 + MJPEG 视频流服务\n- [x] **阶段 2** MQTT 边云通信 + 任务调度器\n- [x] **阶段 3** 云端 LLM Agent (商汤 Nova) + RAG + 工具\n- [x] **阶段 4** Streamlit 4 页面管理平台\n- [x] **阶段 5** 整合测试 + 文档 + 报告\n\n## 🔐 安全注意\n\n- ⚠️ **SENSENOVA_API_KEY** 必须放在 `deploy/.env`(已 gitignore),**绝不提交到 git**\n- 课程演示用可开 EMQX 匿名访问(`EMQX_ALLOW_ANONYMOUS=true`),生产环境务必加 ACL\n\n## 📞 故障排查\n\n| 现象 | 排查 |\n|------|------|\n| `ModuleNotFoundError: ultralytics` | `pip install -r requirements/edge.txt` |\n| 云端 503 | 检查 SENSENOVA_API_KEY 是否设置 |\n| 边端连不上 | 检查 EDGE_BACKEND 是否正确(pc/atlas) |\n| Dashboard 空白 | 检查 CLOUD_API_URL 环境变量 |\n| 中文乱码 | Windows 终端 `chcp 65001` |\n\n## 📄 课程设计文档\n\n- 完整实现计划:`C:\\Users\\z1950\\.claude\\plans\\linux-ancient-scroll.md`\n- 系统架构图:`docs/architecture.png`\n- API 文档:`docs/API.md`\n- 复现指南:`docs/复现指南.md`\n- 课程报告:`reports/报告.pdf`\n- 海报:`reports/海报.pptx`\n\n## 🐳 Docker 一键部署\n\n**前提**:Docker Desktop + 镜像加速器(见 [docs/踩坑记录.md](docs/踩坑记录.md) #1)\n\n```bash\n# 1. 准备 .env\ncp deploy/.env.example deploy/.env\n# 编辑 deploy/.env 填入 SENSENOVA_API_KEY\n\n# 2. 准备模型(避免容器内下载)\npython -c \"from ultralytics import YOLO; YOLO('yolov8n.pt')\"\nmv yolov8n.pt models/\n\n# 3. 一键启动\ncd deploy\ndocker compose up -d --build\n# 首次构建约 10-15 分钟(要装 PyTorch)\n\n# 4. 等待就绪\ncurl -s http://127.0.0.1:9000/health\n\n# 5. 浏览器打开\n# Dashboard: http://localhost:8501\n# 视频流: http://localhost:8000/video_feed\n# Cloud API: http://localhost:9000\n# EMQX 控制台: http://localhost:18083 (admin/public)\n\n# 6. 真实图片测试\ncd ..\nsource .venv/Scripts/activate\npython scripts/test_with_real_image.py --image your_photo.jpg\n\n# 7. 停止\ncd deploy && docker compose down\n```\n\n**架构图**:\n\n```\n┌────────────────────────────────────────────────────────┐\n│ edge-cloud-net (Docker bridge) │\n├────────────────────────────────────────────────────────┤\n│ emqx ─┐ chroma ─┐ agent ─┐ dashboard ─┐ │\n│ :1883 │ :8000 │ :9000 │ :8501 │ │\n│ ▼ ▼ ▼ │ │\n│ MQTT 消费者 ← Agent 看图分析 → Dashboard 展示 │\n│ ┌────┴┐\n│ edge ── 挂载 ──→ scripts/demo_data + models │edge │\n│ :8000 YOLOv8+MQTT+MJPEG │ │\n└──────────────────────────────────────────────────┴─────┘\n```\n\n详细 Docker 部署文档:[docs/复现指南.md](docs/复现指南.md) \n开发踩坑记录:[docs/踩坑记录.md](docs/踩坑记录.md)\n\n"}}, "tests/test_agent.py": {"f35585ed4e240510d3d3266004d3f684e0879690": {"t": 1785846970.9749372, "n": 6435, "h": "bf012c312346a9d9", "c": "\"\"\"云端 Agent / 工具 / 事件库的回归测试。\"\"\"\n\nimport os\nimport sys\nimport tempfile\nimport time\nfrom pathlib import Path\n\nimport numpy as np\n\nPROJECT_ROOT = Path(__file__).resolve().parent.parent\nsys.path.insert(0, str(PROJECT_ROOT))\n\n\n# --------------------- 事件库 ---------------------\n\ndef test_event_db_create_and_insert(tmp_path):\n \"\"\"创建数据库、插入事件、读取。\"\"\"\n from cloud.db.events import EventDB\n db_path = str(tmp_path / \"events.db\")\n db = EventDB(db_path=db_path)\n\n alert = {\n \"ts\": int(time.time() * 1000),\n \"device_id\": \"atlas-01\",\n \"frame_id\": 42,\n \"reason\": \"behavior_analysis\",\n \"detections\": [\n {\"cls\": \"person\", \"cls_id\": 0, \"conf\": 0.9, \"bbox\": [1, 2, 3, 4]}\n ],\n \"crop_b64\": \"fake\",\n \"thumb_b64\": None,\n }\n eid = db.insert_event(alert, agent_reply=\"这是测试\", agent_status=\"ok\")\n assert eid > 0\n assert db.count_events() == 1\n\n events = db.get_recent_events(limit=10)\n assert len(events) == 1\n e = events[0]\n assert e[\"device_id\"] == \"atlas-01\"\n assert e[\"reason\"] == \"behavior_analysis\"\n assert e[\"agent_reply\"] == \"这是测试\"\n assert e[\"agent_status\"] == \"ok\"\n\n\ndef test_event_db_filter_by_device(tmp_path):\n \"\"\"按 device_id 过滤。\"\"\"\n from cloud.db.events import EventDB\n db = EventDB(db_path=str(tmp_path / \"events.db\"))\n\n for dev in [\"a\", \"b\", \"a\", \"a\"]:\n db.insert_event({\"ts\": int(time.time()*1000), \"device_id\": dev,\n \"frame_id\": 0, \"reason\": \"test\", \"detections\": []})\n\n assert db.count_events() == 4\n a_events = db.get_recent_events(device_id=\"a\")\n assert len(a_events) == 3\n b_events = db.get_recent_events(device_id=\"b\")\n assert len(b_events) == 1\n\n\ndef test_event_db_clear(tmp_path):\n \"\"\"清空。\"\"\"\n from cloud.db.events import EventDB\n db = EventDB(db_path=str(tmp_path / \"events.db\"))\n db.insert_event({\"ts\": 0, \"device_id\": \"x\", \"frame_id\": 0, \"reason\": \"x\", \"detections\": []})\n assert db.count_events() == 1\n db.clear()\n assert db.count_events() == 0\n\n\n# --------------------- 工具 schema ---------------------\n\ndef test_tavily_schema_format():\n \"\"\"tavily 工具 schema 应是 OpenAI function calling 格式。\"\"\"\n from cloud.agent.tools import TAVILY_SCHEMA\n assert TAVILY_SCHEMA[\"type\"] == \"function\"\n assert TAVILY_SCHEMA[\"function\"][\"name\"] == \"tavily_search\"\n assert \"query\" in TAVILY_SCHEMA[\"function\"][\"parameters\"][\"properties\"]\n\n\ndef test_rag_schema_format():\n from cloud.agent.tools import RAG_SCHEMA\n assert RAG_SCHEMA[\"function\"][\"name\"] == \"query_knowledge_base\"\n\n\ndef test_mqtt_schema_format():\n from cloud.agent.tools import MQTT_SCHEMA\n assert MQTT_SCHEMA[\"function\"][\"name\"] == \"publish_command\"\n assert \"device_id\" in MQTT_SCHEMA[\"function\"][\"parameters\"][\"required\"]\n\n\ndef test_all_schemas_have_unique_names():\n \"\"\"所有工具 schema 的 name 应唯一。\"\"\"\n from cloud.agent.tools import ALL_SCHEMAS\n names = [s[\"function\"][\"name\"] for s in ALL_SCHEMAS]\n assert len(names) == len(set(names)), f\"重名: {names}\"\n\n\ndef test_execute_tool_unknown():\n \"\"\"未知工具名应返回错误。\"\"\"\n from cloud.agent.tools import execute_tool\n r = execute_tool(\"nonexistent_tool\", {})\n assert \"错误\" in r or \"未知\" in r\n\n\n# --------------------- Agent 单元测试(不调真实 LLM) ---------------------\n\ndef test_agent_system_prompt_not_empty():\n \"\"\"Agent 应有 system prompt。\"\"\"\n from cloud.agent.chain import SYSTEM_PROMPT\n assert len(SYSTEM_PROMPT) > 50\n\n\ndef test_agent_alert_message_construction():\n \"\"\"AlertEvent 转 LLM 消息的格式。\"\"\"\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-test\"\n from cloud.agent.chain import EdgeCloudAgent\n\n agent = EdgeCloudAgent()\n alert = {\n \"device_id\": \"atlas-01\",\n \"ts\": int(time.time() * 1000),\n \"frame_id\": 100,\n \"reason\": \"behavior_analysis\",\n \"detections\": [\n {\"cls\": \"person\", \"cls_id\": 0, \"conf\": 0.9, \"bbox\": [10, 20, 100, 200]}\n ],\n \"crop_b64\": \"abc\",\n \"thumb_b64\": \"xyz\",\n }\n messages = agent._build_alert_messages(alert)\n assert len(messages) == 2\n assert messages[0][\"role\"] == \"system\"\n assert messages[1][\"role\"] == \"user\"\n content = messages[1][\"content\"]\n # 多模态:text + 2 张图\n text_items = [c for c in content if c[\"type\"] == \"text\"]\n image_items = [c for c in content if c[\"type\"] == \"image_url\"]\n assert len(text_items) >= 1\n assert len(image_items) == 2 # crop + thumb\n\n\ndef test_agent_alert_message_without_images():\n \"\"\"无图像时也应能构造消息。\"\"\"\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-test\"\n from cloud.agent.chain import EdgeCloudAgent\n\n agent = EdgeCloudAgent()\n alert = {\n \"device_id\": \"d1\", \"ts\": 0, \"frame_id\": 1, \"reason\": \"low_confidence\",\n \"detections\": [], \"crop_b64\": None, \"thumb_b64\": None,\n }\n messages = agent._build_alert_messages(alert)\n content = messages[1][\"content\"]\n image_items = [c for c in content if c[\"type\"] == \"image_url\"]\n assert len(image_items) == 0 # 无图\n\n\n# --------------------- Agent 真实 API 测试(可选) ---------------------\n\ndef _has_real_api_key():\n key = os.environ.get(\"SENSENOVA_API_KEY\", \"\")\n return bool(key) and not key.startswith(\"sk-your-\")\n\n\ndef test_agent_analyze_alert_real():\n \"\"\"真实分析告警测试(需要 API Key)。\"\"\"\n if not _has_real_api_key():\n print(\" [SKIP] 需真实 SENSENOVA_API_KEY\")\n return\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-xw3OZ5LQuMyhalIY9dmIKUSeIz8u8tgc\" if not os.environ.get(\"SENSENOVA_API_KEY\") else os.environ[\"SENSENOVA_API_KEY\"]\n from cloud.agent.chain import EdgeCloudAgent\n\n agent = EdgeCloudAgent()\n\n # 用 PIL 造一个 224x224 假图(蓝色块代表\"目标\")\n try:\n from PIL import Image\n import io\n import base64\n img = Image.new(\"RGB\", (224, 224), color=(100, 100, 200))\n buf = io.BytesIO()\n img.save(buf, format=\"JPEG\")\n crop_b64 = base64.b64encode(buf.getvalue()).decode(\"ascii\")\n except ImportError:\n print(\" [SKIP] 需 Pillow\")\n return\n\n alert = {\n \"device_id\": \"atlas-test\",\n \"ts\": int(time.time() * 1000),\n \"frame_id\": 1,\n \"reason\": \"behavior_analysis\",\n \"detections\": [\n {\"cls\": \"person\", \"cls_id\": 0, \"conf\": 0.9, \"bbox\": [50, 50, 200, 200]}\n ],\n \"crop_b64\": crop_b64,\n \"thumb_b64\": crop_b64,\n }\n reply = agent.analyze_alert(alert)\n assert reply\n assert len(reply) > 20, f\"回复太短: {reply}\"\n print(f\"\\n[Agent 真实分析]\\n{reply[:500]}\")\n"}}, "tests/test_llm_factory.py": {"f35585ed4e240510d3d3266004d3f684e0879690": {"t": 1785846971.0946562, "n": 5731, "h": "9c363a4371b7416d", "c": "\"\"\"云端 LLM 工厂的回归测试。\n\n包含:\n- 单元测试:配置解析、消息构造(不调真实 API)\n- 集成测试:真实调商汤 API(设 RUN_SENSENOVA_TEST=1 启用)\n\"\"\"\n\nimport os\nimport sys\nfrom pathlib import Path\n\nPROJECT_ROOT = Path(__file__).resolve().parent.parent\nsys.path.insert(0, str(PROJECT_ROOT))\n\n\ndef _skip_if_no_real_api_key():\n \"\"\"检查是否设置了真实的 API Key。\"\"\"\n key = os.environ.get(\"SENSENOVA_API_KEY\", \"\")\n if not key or key.startswith(\"sk-your-\"):\n print(f\" [SKIP] 需设置真实 SENSENOVA_API_KEY 才能跑\")\n return True\n return False\n\n\n# --------------------- 配置解析 ---------------------\n\ndef test_default_base_url():\n \"\"\"未设环境变量时使用默认 base_url。\"\"\"\n saved = os.environ.get(\"SENSENOVA_BASE_URL\")\n os.environ.pop(\"SENSENOVA_BASE_URL\", None)\n try:\n from cloud.agent.llm_factory import get_base_url\n assert get_base_url() == \"https://token.sensenova.cn/v1\"\n finally:\n if saved is not None:\n os.environ[\"SENSENOVA_BASE_URL\"] = saved\n\n\ndef test_default_model():\n \"\"\"未设环境变量时使用默认模型。\"\"\"\n saved = os.environ.get(\"SENSENOVA_MODEL\")\n os.environ.pop(\"SENSENOVA_MODEL\", None)\n try:\n from cloud.agent.llm_factory import get_model\n assert get_model() == \"sensenova-6.7-flash-lite\"\n finally:\n if saved is not None:\n os.environ[\"SENSENOVA_MODEL\"] = saved\n\n\ndef test_get_api_key_missing_raises():\n \"\"\"未设 API Key 时应抛 RuntimeError。\"\"\"\n saved = os.environ.get(\"SENSENOVA_API_KEY\")\n os.environ.pop(\"SENSENOVA_API_KEY\", None)\n try:\n from cloud.agent.llm_factory import get_api_key\n try:\n get_api_key()\n raise AssertionError(\"应该抛 RuntimeError\")\n except RuntimeError as e:\n assert \"SENSENOVA_API_KEY\" in str(e)\n finally:\n if saved is not None:\n os.environ[\"SENSENOVA_API_KEY\"] = saved\n\n\ndef test_get_api_key_present():\n \"\"\"设了 API Key 时能正常读取。\"\"\"\n saved = os.environ.get(\"SENSENOVA_API_KEY\")\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-test-12345\"\n try:\n from cloud.agent.llm_factory import get_api_key\n assert get_api_key() == \"sk-test-12345\"\n finally:\n if saved is None:\n os.environ.pop(\"SENSENOVA_API_KEY\", None)\n else:\n os.environ[\"SENSENOVA_API_KEY\"] = saved\n\n\n# --------------------- 消息构造 ---------------------\n\ndef test_get_vision_messages_basic():\n \"\"\"视觉消息应包含 text + image_url。\"\"\"\n from cloud.agent.llm_factory import get_vision_messages\n\n msgs = get_vision_messages(\"图中有什么?\", \"abc123\")\n assert len(msgs) == 1\n assert msgs[0][\"role\"] == \"user\"\n content = msgs[0][\"content\"]\n assert isinstance(content, list)\n assert len(content) == 2\n assert content[0][\"type\"] == \"text\"\n assert content[0][\"text\"] == \"图中有什么?\"\n assert content[1][\"type\"] == \"image_url\"\n assert content[1][\"image_url\"][\"url\"].startswith(\"data:image/jpeg;base64,abc123\")\n\n\ndef test_get_vision_messages_with_data_uri():\n \"\"\"如果 b64 已含 data: 前缀,不重复添加。\"\"\"\n from cloud.agent.llm_factory import get_vision_messages\n\n b64 = \"data:image/png;base64,abc123\"\n msgs = get_vision_messages(\"?\", b64)\n assert msgs[0][\"content\"][1][\"image_url\"][\"url\"] == b64\n\n\ndef test_get_vision_messages_with_system():\n \"\"\"带 system prompt 时应有 2 条消息。\"\"\"\n from cloud.agent.llm_factory import get_vision_messages\n\n msgs = get_vision_messages(\"?\", \"abc\", system_prompt=\"你是助手\")\n assert len(msgs) == 2\n assert msgs[0][\"role\"] == \"system\"\n assert msgs[0][\"content\"] == \"你是助手\"\n assert msgs[1][\"role\"] == \"user\"\n\n\n# --------------------- 客户端构造(不实例化) ---------------------\n\ndef test_sensenova_client_init():\n \"\"\"构造客户端不报错。\"\"\"\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-test-123\"\n from cloud.agent.llm_factory import SensenovaClient\n client = SensenovaClient()\n assert client.api_key == \"sk-test-123\"\n assert client.base_url == \"https://token.sensenova.cn/v1\"\n assert client.model == \"sensenova-6.7-flash-lite\"\n\n\ndef test_singleton_pattern():\n \"\"\"get_llm 应返回同一实例。\"\"\"\n os.environ[\"SENSENOVA_API_KEY\"] = \"sk-test-singleton\"\n from cloud.agent import llm_factory\n llm_factory.reset_singleton()\n c1 = llm_factory.get_llm()\n c2 = llm_factory.get_llm()\n assert c1 is c2\n\n\n# --------------------- 真实 API 集成测试 ---------------------\n\ndef test_real_text_chat():\n \"\"\"真实文本对话测试。\"\"\"\n if _skip_if_no_real_api_key():\n return\n from cloud.agent.llm_factory import get_llm\n\n llm = get_llm()\n resp = llm.chat([\n {\"role\": \"user\", \"content\": \"用一句话介绍你自己(不超过 30 字)\"},\n ], max_tokens=2048, temperature=0.1)\n assert \"choices\" in resp\n assert len(resp[\"choices\"]) > 0\n text = resp[\"choices\"][0][\"message\"][\"content\"]\n assert text, f\"模型未输出文本,可能 finish_reason={resp['choices'][0].get('finish_reason')}\"\n assert \"SenseNova\" in text or \"商汤\" in text or \"AI\" in text or \"助手\" in text, \\\n f\"应提到 SenseNova/商汤/AI/助手,实际: {text}\"\n print(f\"\\n[真实 API 响应] {text}\")\n\n\ndef test_real_vision_chat():\n \"\"\"真实视觉问答测试。\"\"\"\n if _skip_if_no_real_api_key():\n return\n from cloud.agent.llm_factory import get_llm\n\n # 用 PIL 生成一张合法的 32x32 红色 PNG\n try:\n from PIL import Image\n import io\n import base64\n\n img = Image.new(\"RGB\", (32, 32), color=(255, 0, 0)) # 纯红\n buf = io.BytesIO()\n img.save(buf, format=\"PNG\")\n red_png_b64 = base64.b64encode(buf.getvalue()).decode(\"ascii\")\n except ImportError:\n print(\" [SKIP] 需要 Pillow 库\")\n return\n\n llm = get_llm()\n text = llm.vision_chat(\n user_text=\"这是什么颜色?只回答颜色名。\",\n image_b64=red_png_b64,\n max_tokens=2048,\n temperature=0.1,\n )\n assert text, \"模型应输出文本\"\n assert \"红\" in text or \"red\" in text.lower(), f\"应识别红色,实际: {text}\"\n print(f\"\\n[真实视觉 API 响应] {text}\")\n"}}}}