{"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"}}}}