Spaces:
Sleeping
Sleeping
Trae Assistant commited on
Commit ·
e3f24bb
0
Parent(s):
feat: 药链智控Agent 升级 · 中文界面/默认数据/批量导入/曲线/数据集导出
Browse files- .gitattributes +7 -0
- Dockerfile +16 -0
- README.md +44 -0
- app.py +412 -0
- instance/pharma.db +0 -0
- requirements.txt +5 -0
- templates/index.html +264 -0
.gitattributes
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.csv filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
COPY requirements.txt /app/requirements.txt
|
| 8 |
+
RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /app/requirements.txt
|
| 9 |
+
|
| 10 |
+
COPY . /app
|
| 11 |
+
|
| 12 |
+
ENV PORT=7860
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
CMD ["python", "app.py"]
|
| 16 |
+
|
README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: 药链智控 Agent
|
| 3 |
+
emoji: 🚚
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
short_description: 药品冷链合规与优化智能体
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 药链智控 Agent
|
| 12 |
+
|
| 13 |
+
面向医药冷链场景的“推理/工具行动/记忆/校验/回放”闭环智能体,支持多步推理、温控数据持久化、合规校验与验收报告生成。界面中文,移动端友好,支持通过 SiliconFlow 接入 LLM(无钥时自动降级为离线策略)。
|
| 14 |
+
|
| 15 |
+
## 快速开始
|
| 16 |
+
|
| 17 |
+
本地运行:
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
pip install -r requirements.txt
|
| 21 |
+
python app.py
|
| 22 |
+
# 访问 http://localhost:7870
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Docker 运行:
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
docker build -t pharma-chain-agent .
|
| 29 |
+
docker run -p 7860:7860 -e SILICONFLOW_API_KEY=$SILICONFLOW_API_KEY pharma-chain-agent
|
| 30 |
+
# 访问 http://localhost:7860
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## 环境变量
|
| 34 |
+
|
| 35 |
+
- SILICONFLOW_API_KEY 可选,提供后将通过硅基流模型进行推理。
|
| 36 |
+
- SILICONFLOW_BASE_URL 默认为 https://api.siliconflow.cn/v1
|
| 37 |
+
- SILICONFLOW_MODEL 默认为 deepseek-ai/DeepSeek-V3
|
| 38 |
+
|
| 39 |
+
## 商业价值
|
| 40 |
+
|
| 41 |
+
- 适配医药、疫苗、试剂等冷链运输环节的合规与运营优化;
|
| 42 |
+
- 支持异常越界识别、路线建议与审计复核;
|
| 43 |
+
- 可延展对接 IoT 平台、WMS/TMS,形成可落地的企业级解决方案。
|
| 44 |
+
|
app.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import uuid
|
| 4 |
+
import datetime
|
| 5 |
+
import sqlite3
|
| 6 |
+
import csv
|
| 7 |
+
import io
|
| 8 |
+
from typing import Dict, Any, List
|
| 9 |
+
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, Form, UploadFile, File
|
| 10 |
+
from fastapi.responses import HTMLResponse, JSONResponse
|
| 11 |
+
from fastapi.staticfiles import StaticFiles
|
| 12 |
+
from fastapi.templating import Jinja2Templates
|
| 13 |
+
import httpx
|
| 14 |
+
|
| 15 |
+
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 16 |
+
DB_PATH = os.path.join(BASE_DIR, "instance", "pharma.db")
|
| 17 |
+
os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True)
|
| 18 |
+
os.makedirs(os.path.join(BASE_DIR, "templates"), exist_ok=True)
|
| 19 |
+
os.makedirs(os.path.join(BASE_DIR, "static"), exist_ok=True)
|
| 20 |
+
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
| 21 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 22 |
+
HF_DATASET_DIR = os.path.join(BASE_DIR, "hf_dataset")
|
| 23 |
+
os.makedirs(HF_DATASET_DIR, exist_ok=True)
|
| 24 |
+
|
| 25 |
+
API_KEY = os.getenv("SILICONFLOW_API_KEY", "").strip()
|
| 26 |
+
BASE_URL = os.getenv("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1").strip()
|
| 27 |
+
MODEL_NAME = os.getenv("SILICONFLOW_MODEL", "deepseek-ai/DeepSeek-V3").strip()
|
| 28 |
+
|
| 29 |
+
def init_db():
|
| 30 |
+
conn = sqlite3.connect(DB_PATH)
|
| 31 |
+
cur = conn.cursor()
|
| 32 |
+
cur.execute(
|
| 33 |
+
"""
|
| 34 |
+
CREATE TABLE IF NOT EXISTS shipments (
|
| 35 |
+
id TEXT PRIMARY KEY,
|
| 36 |
+
name TEXT,
|
| 37 |
+
status TEXT DEFAULT '新建',
|
| 38 |
+
created_at TEXT,
|
| 39 |
+
updated_at TEXT
|
| 40 |
+
)
|
| 41 |
+
"""
|
| 42 |
+
)
|
| 43 |
+
cur.execute(
|
| 44 |
+
"""
|
| 45 |
+
CREATE TABLE IF NOT EXISTS sensors (
|
| 46 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 47 |
+
shipment_id TEXT,
|
| 48 |
+
temperature REAL,
|
| 49 |
+
humidity REAL,
|
| 50 |
+
location TEXT,
|
| 51 |
+
ts TEXT
|
| 52 |
+
)
|
| 53 |
+
"""
|
| 54 |
+
)
|
| 55 |
+
cur.execute(
|
| 56 |
+
"""
|
| 57 |
+
CREATE TABLE IF NOT EXISTS tasks (
|
| 58 |
+
id TEXT PRIMARY KEY,
|
| 59 |
+
shipment_id TEXT,
|
| 60 |
+
title TEXT,
|
| 61 |
+
status TEXT DEFAULT 'pending',
|
| 62 |
+
created_at TEXT,
|
| 63 |
+
updated_at TEXT,
|
| 64 |
+
result_md TEXT DEFAULT '',
|
| 65 |
+
logs TEXT DEFAULT '[]'
|
| 66 |
+
)
|
| 67 |
+
"""
|
| 68 |
+
)
|
| 69 |
+
conn.commit()
|
| 70 |
+
# 初始化默认数据(无数据时)
|
| 71 |
+
cur.execute("SELECT COUNT(1) FROM shipments")
|
| 72 |
+
try:
|
| 73 |
+
cnt = cur.fetchone()[0]
|
| 74 |
+
except Exception:
|
| 75 |
+
cnt = 0
|
| 76 |
+
if cnt == 0:
|
| 77 |
+
sid = "demo" + str(uuid.uuid4())[:4]
|
| 78 |
+
now = datetime.datetime.now().isoformat()
|
| 79 |
+
cur.execute(
|
| 80 |
+
"INSERT INTO shipments (id, name, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
| 81 |
+
(sid, "示例批次-疫苗-冷链 D01", "新建", now, now),
|
| 82 |
+
)
|
| 83 |
+
base_time = datetime.datetime.now()
|
| 84 |
+
samples = [
|
| 85 |
+
(5.2, 60.0, "冷库A", base_time + datetime.timedelta(minutes=0)),
|
| 86 |
+
(5.0, 61.0, "出库→装车", base_time + datetime.timedelta(minutes=15)),
|
| 87 |
+
(8.5, 59.0, "途中节点1", base_time + datetime.timedelta(minutes=35)),
|
| 88 |
+
(6.1, 58.0, "途中节点2", base_time + datetime.timedelta(minutes=50)),
|
| 89 |
+
(4.8, 62.0, "到达冷库B", base_time + datetime.timedelta(minutes=70)),
|
| 90 |
+
]
|
| 91 |
+
for t, h, loc, ts in samples:
|
| 92 |
+
cur.execute(
|
| 93 |
+
"INSERT INTO sensors (shipment_id, temperature, humidity, location, ts) VALUES (?, ?, ?, ?, ?)",
|
| 94 |
+
(sid, t, h, loc, ts.isoformat()),
|
| 95 |
+
)
|
| 96 |
+
conn.commit()
|
| 97 |
+
conn.close()
|
| 98 |
+
|
| 99 |
+
init_db()
|
| 100 |
+
|
| 101 |
+
app = FastAPI(title="药链智控 Agent")
|
| 102 |
+
templates = Jinja2Templates(directory=os.path.join(BASE_DIR, "templates"))
|
| 103 |
+
app.mount("/static", StaticFiles(directory=os.path.join(BASE_DIR, "static")), name="static")
|
| 104 |
+
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")
|
| 105 |
+
|
| 106 |
+
def get_conn():
|
| 107 |
+
return sqlite3.connect(DB_PATH)
|
| 108 |
+
|
| 109 |
+
def append_log(conn, task_id: str, role: str, content: str):
|
| 110 |
+
cur = conn.cursor()
|
| 111 |
+
cur.execute("SELECT logs FROM tasks WHERE id = ?", (task_id,))
|
| 112 |
+
row = cur.fetchone()
|
| 113 |
+
logs = json.loads(row[0] or "[]") if row else []
|
| 114 |
+
logs.append(
|
| 115 |
+
{"timestamp": datetime.datetime.now().isoformat(), "role": role, "content": content}
|
| 116 |
+
)
|
| 117 |
+
cur.execute(
|
| 118 |
+
"UPDATE tasks SET logs = ?, updated_at = ? WHERE id = ?",
|
| 119 |
+
(json.dumps(logs, ensure_ascii=False), datetime.datetime.now().isoformat(), task_id),
|
| 120 |
+
)
|
| 121 |
+
conn.commit()
|
| 122 |
+
|
| 123 |
+
async def call_llm(messages: List[Dict[str, str]]) -> str:
|
| 124 |
+
if not API_KEY:
|
| 125 |
+
return "【离线推理】将执行冷链合规检查、温控异常识别、路线建议与验收回放。"
|
| 126 |
+
url = f"{BASE_URL}/chat/completions"
|
| 127 |
+
headers = {"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}
|
| 128 |
+
payload = {
|
| 129 |
+
"model": MODEL_NAME,
|
| 130 |
+
"messages": messages,
|
| 131 |
+
"temperature": 0.3,
|
| 132 |
+
}
|
| 133 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 134 |
+
try:
|
| 135 |
+
r = await client.post(url, headers=headers, json=payload)
|
| 136 |
+
r.raise_for_status()
|
| 137 |
+
data = r.json()
|
| 138 |
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() or "(空响应)"
|
| 139 |
+
except Exception:
|
| 140 |
+
return "【备用】系统使用离线策略:以 2℃-8℃ 为冷藏区间,标记越界段并生成纠偏方案。"
|
| 141 |
+
|
| 142 |
+
def temp_stats(conn, shipment_id: str) -> Dict[str, Any]:
|
| 143 |
+
cur = conn.cursor()
|
| 144 |
+
cur.execute(
|
| 145 |
+
"SELECT temperature, ts, location FROM sensors WHERE shipment_id = ? ORDER BY ts ASC",
|
| 146 |
+
(shipment_id,),
|
| 147 |
+
)
|
| 148 |
+
rows = cur.fetchall()
|
| 149 |
+
temps = [r[0] for r in rows] if rows else []
|
| 150 |
+
if not temps:
|
| 151 |
+
return {
|
| 152 |
+
"count": 0,
|
| 153 |
+
"min": None,
|
| 154 |
+
"max": None,
|
| 155 |
+
"avg": None,
|
| 156 |
+
"breaches": [],
|
| 157 |
+
"timeline": [],
|
| 158 |
+
}
|
| 159 |
+
breaches = []
|
| 160 |
+
for t, ts, loc in rows:
|
| 161 |
+
if t is not None and (t < 2.0 or t > 8.0):
|
| 162 |
+
breaches.append({"t": t, "ts": ts, "loc": loc})
|
| 163 |
+
return {
|
| 164 |
+
"count": len(temps),
|
| 165 |
+
"min": min(temps),
|
| 166 |
+
"max": max(temps),
|
| 167 |
+
"avg": round(sum(temps) / len(temps), 3),
|
| 168 |
+
"breaches": breaches,
|
| 169 |
+
"timeline": [{"t": r[0], "ts": r[1], "loc": r[2]} for r in rows],
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def simple_compliance_check(stats: Dict[str, Any]) -> Dict[str, Any]:
|
| 173 |
+
ok = stats["count"] > 0 and stats["min"] is not None and stats["max"] is not None and not stats["breaches"]
|
| 174 |
+
level = "合规" if ok else "警告"
|
| 175 |
+
reason = "全程处于 2-8℃" if ok else f"发现 {len(stats['breaches'])} 次越界"
|
| 176 |
+
return {"status": level, "reason": reason}
|
| 177 |
+
|
| 178 |
+
def optimize_route_hint(stats: Dict[str, Any]) -> str:
|
| 179 |
+
if not stats["breaches"]:
|
| 180 |
+
return "建议维持现有路线与包装方案。"
|
| 181 |
+
first = stats["breaches"][0]
|
| 182 |
+
return f"建议在 {first['ts']} 附近增设冷媒或改道冷库中转(位置:{first['loc']})。"
|
| 183 |
+
|
| 184 |
+
async def agent_run(task_id: str, shipment_id: str, title: str):
|
| 185 |
+
conn = get_conn()
|
| 186 |
+
try:
|
| 187 |
+
cur = conn.cursor()
|
| 188 |
+
cur.execute("UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?", ("in_progress", datetime.datetime.now().isoformat(), task_id))
|
| 189 |
+
conn.commit()
|
| 190 |
+
|
| 191 |
+
append_log(conn, task_id, "system", "开始推理/决策")
|
| 192 |
+
plan = await call_llm([
|
| 193 |
+
{"role": "system", "content": "你是医药冷链合规与运营专家,请用简洁中文输出执行计划。"},
|
| 194 |
+
{"role": "user", "content": f"任务:{title};请分步说明:检查项、必要工具动作、验收标准、回放要点。"}
|
| 195 |
+
])
|
| 196 |
+
append_log(conn, task_id, "agent_plan", plan)
|
| 197 |
+
|
| 198 |
+
append_log(conn, task_id, "system", "执行工具动作:温控统计")
|
| 199 |
+
stats = temp_stats(conn, shipment_id)
|
| 200 |
+
append_log(conn, task_id, "tool", f"温控统计: {json.dumps(stats, ensure_ascii=False)}")
|
| 201 |
+
|
| 202 |
+
append_log(conn, task_id, "system", "执行工具动作:合规检查")
|
| 203 |
+
comp = simple_compliance_check(stats)
|
| 204 |
+
append_log(conn, task_id, "tool", f"合规检查: {json.dumps(comp, ensure_ascii=False)}")
|
| 205 |
+
|
| 206 |
+
append_log(conn, task_id, "system", "执行工具动作:路线建议")
|
| 207 |
+
route = optimize_route_hint(stats)
|
| 208 |
+
append_log(conn, task_id, "tool", f"路线建议: {route}")
|
| 209 |
+
|
| 210 |
+
verify = await call_llm([
|
| 211 |
+
{"role": "system", "content": "作为审计验收官,请对执行结果进行核验并给出结论,用中文。"},
|
| 212 |
+
{"role": "user", "content": f"温控统计: {json.dumps(stats, ensure_ascii=False)};合规检查: {json.dumps(comp, ensure_ascii=False)};路线建议: {route}。请判定是否通过并给出改进要点。"}
|
| 213 |
+
])
|
| 214 |
+
append_log(conn, task_id, "agent_verify", verify)
|
| 215 |
+
|
| 216 |
+
result_md = f"# 验收结论\n\n- 合规状态:{comp['status']}\n- 理由:{comp['reason']}\n- 路线建议:{route}\n\n## 规划摘要\n\n{plan}\n\n## 审计复核\n\n{verify}"
|
| 217 |
+
cur.execute(
|
| 218 |
+
"UPDATE tasks SET status = ?, result_md = ?, updated_at = ? WHERE id = ?",
|
| 219 |
+
("completed", result_md, datetime.datetime.now().isoformat(), task_id),
|
| 220 |
+
)
|
| 221 |
+
conn.commit()
|
| 222 |
+
except Exception as e:
|
| 223 |
+
append_log(conn, task_id, "error", str(e))
|
| 224 |
+
cur = conn.cursor()
|
| 225 |
+
cur.execute("UPDATE tasks SET status = ?, updated_at = ? WHERE id = ?", ("failed", datetime.datetime.now().isoformat(), task_id))
|
| 226 |
+
conn.commit()
|
| 227 |
+
finally:
|
| 228 |
+
conn.close()
|
| 229 |
+
|
| 230 |
+
@app.get("/", response_class=HTMLResponse)
|
| 231 |
+
async def index(request: Request):
|
| 232 |
+
try:
|
| 233 |
+
return templates.TemplateResponse("index.html", {"request": request, "title": "药链智控 Agent"})
|
| 234 |
+
except Exception as e:
|
| 235 |
+
html = f"<html><body><h2>服务暂时不可用</h2><p>原因:{str(e)}</p></body></html>"
|
| 236 |
+
return HTMLResponse(html, status_code=200)
|
| 237 |
+
|
| 238 |
+
@app.get("/api/health")
|
| 239 |
+
async def health():
|
| 240 |
+
return {"status": "ok", "message": "pharma-chain-agent running"}
|
| 241 |
+
|
| 242 |
+
@app.get("/api/shipments")
|
| 243 |
+
async def list_shipments():
|
| 244 |
+
conn = get_conn()
|
| 245 |
+
cur = conn.cursor()
|
| 246 |
+
cur.execute("SELECT id, name, status, created_at FROM shipments ORDER BY created_at DESC")
|
| 247 |
+
rows = cur.fetchall()
|
| 248 |
+
conn.close()
|
| 249 |
+
return [{"id": r[0], "name": r[1], "status": r[2], "created_at": r[3]} for r in rows]
|
| 250 |
+
|
| 251 |
+
@app.post("/api/shipments")
|
| 252 |
+
async def create_shipments(name: str = Form(...)):
|
| 253 |
+
sid = str(uuid.uuid4())[:8]
|
| 254 |
+
now = datetime.datetime.now().isoformat()
|
| 255 |
+
conn = get_conn()
|
| 256 |
+
cur = conn.cursor()
|
| 257 |
+
cur.execute("INSERT INTO shipments (id, name, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", (sid, name, "新建", now, now))
|
| 258 |
+
conn.commit()
|
| 259 |
+
conn.close()
|
| 260 |
+
return {"id": sid}
|
| 261 |
+
|
| 262 |
+
@app.post("/api/sensors")
|
| 263 |
+
async def ingest_sensor(shipment_id: str = Form(...), temperature: float = Form(...), humidity: float = Form(...), location: str = Form("未知"), ts: str = Form(None)):
|
| 264 |
+
ts = ts or datetime.datetime.now().isoformat()
|
| 265 |
+
conn = get_conn()
|
| 266 |
+
cur = conn.cursor()
|
| 267 |
+
cur.execute(
|
| 268 |
+
"INSERT INTO sensors (shipment_id, temperature, humidity, location, ts) VALUES (?, ?, ?, ?, ?)",
|
| 269 |
+
(shipment_id, temperature, humidity, location, ts),
|
| 270 |
+
)
|
| 271 |
+
conn.commit()
|
| 272 |
+
conn.close()
|
| 273 |
+
return {"ok": True}
|
| 274 |
+
|
| 275 |
+
@app.post("/api/sensors/batch")
|
| 276 |
+
async def ingest_sensor_batch(shipment_id: str = Form(...), file: UploadFile = File(...)):
|
| 277 |
+
try:
|
| 278 |
+
raw = await file.read()
|
| 279 |
+
size_mb = len(raw) / (1024 * 1024)
|
| 280 |
+
if size_mb > 100:
|
| 281 |
+
save_name = f"{uuid.uuid4().hex}_{file.filename}"
|
| 282 |
+
save_path = os.path.join(UPLOAD_DIR, save_name)
|
| 283 |
+
with open(save_path, "wb") as f:
|
| 284 |
+
f.write(raw)
|
| 285 |
+
return JSONResponse(
|
| 286 |
+
status_code=413,
|
| 287 |
+
content={
|
| 288 |
+
"ok": False,
|
| 289 |
+
"message": "文件过大,已本地保存。建议改为 Hugging Face 数据集。",
|
| 290 |
+
"saved": f"/uploads/{save_name}",
|
| 291 |
+
},
|
| 292 |
+
)
|
| 293 |
+
text = raw.decode("utf-8", errors="ignore")
|
| 294 |
+
reader = csv.DictReader(io.StringIO(text))
|
| 295 |
+
rows = list(reader)
|
| 296 |
+
if not rows:
|
| 297 |
+
raise HTTPException(status_code=400, detail="CSV 内容为空或格式不正确")
|
| 298 |
+
conn = get_conn()
|
| 299 |
+
cur = conn.cursor()
|
| 300 |
+
n = 0
|
| 301 |
+
for r in rows:
|
| 302 |
+
try:
|
| 303 |
+
t = float(r.get("temperature") or r.get("temp") or r.get("Temperature"))
|
| 304 |
+
h = float(r.get("humidity") or r.get("hum") or r.get("Humidity") or 0.0)
|
| 305 |
+
loc = r.get("location") or r.get("loc") or "未知"
|
| 306 |
+
ts = r.get("ts") or datetime.datetime.now().isoformat()
|
| 307 |
+
cur.execute(
|
| 308 |
+
"INSERT INTO sensors (shipment_id, temperature, humidity, location, ts) VALUES (?, ?, ?, ?, ?)",
|
| 309 |
+
(shipment_id, t, h, loc, ts),
|
| 310 |
+
)
|
| 311 |
+
n += 1
|
| 312 |
+
except Exception:
|
| 313 |
+
continue
|
| 314 |
+
conn.commit()
|
| 315 |
+
conn.close()
|
| 316 |
+
return {"ok": True, "count": n}
|
| 317 |
+
except HTTPException:
|
| 318 |
+
raise
|
| 319 |
+
except Exception as e:
|
| 320 |
+
raise HTTPException(status_code=400, detail=f"导入失败:{e}")
|
| 321 |
+
|
| 322 |
+
@app.get("/api/sensors/summary")
|
| 323 |
+
async def sensor_summary(shipment_id: str):
|
| 324 |
+
conn = get_conn()
|
| 325 |
+
stats = temp_stats(conn, shipment_id)
|
| 326 |
+
conn.close()
|
| 327 |
+
return stats
|
| 328 |
+
|
| 329 |
+
@app.post("/api/upload")
|
| 330 |
+
async def upload_file(file: UploadFile = File(...)):
|
| 331 |
+
try:
|
| 332 |
+
raw = await file.read()
|
| 333 |
+
save_name = f"{uuid.uuid4().hex}_{file.filename}"
|
| 334 |
+
save_path = os.path.join(UPLOAD_DIR, save_name)
|
| 335 |
+
with open(save_path, "wb") as f:
|
| 336 |
+
f.write(raw)
|
| 337 |
+
return {"ok": True, "filename": file.filename, "saved_path": f"/uploads/{save_name}"}
|
| 338 |
+
except Exception as e:
|
| 339 |
+
raise HTTPException(status_code=400, detail=f"上传失败:{e}")
|
| 340 |
+
|
| 341 |
+
@app.get("/api/export/dataset")
|
| 342 |
+
async def export_dataset(shipment_id: str):
|
| 343 |
+
conn = get_conn()
|
| 344 |
+
cur = conn.cursor()
|
| 345 |
+
cur.execute(
|
| 346 |
+
"SELECT temperature, humidity, location, ts FROM sensors WHERE shipment_id = ? ORDER BY ts ASC",
|
| 347 |
+
(shipment_id,),
|
| 348 |
+
)
|
| 349 |
+
rows = cur.fetchall()
|
| 350 |
+
conn.close()
|
| 351 |
+
if not rows:
|
| 352 |
+
raise HTTPException(status_code=404, detail="无数据可导出")
|
| 353 |
+
csv_path = os.path.join(HF_DATASET_DIR, f"{shipment_id}_sensors.csv")
|
| 354 |
+
with open(csv_path, "w", encoding="utf-8", newline="") as f:
|
| 355 |
+
w = csv.writer(f)
|
| 356 |
+
w.writerow(["temperature", "humidity", "location", "ts"])
|
| 357 |
+
for r in rows:
|
| 358 |
+
w.writerow(r)
|
| 359 |
+
readme_path = os.path.join(HF_DATASET_DIR, "README.md")
|
| 360 |
+
if not os.path.exists(readme_path):
|
| 361 |
+
with open(readme_path, "w", encoding="utf-8") as f:
|
| 362 |
+
f.write("# 药链智控 数据集示例\n\n- 包含传感器温湿度时序数据(2-8℃为冷藏区间)\n- 可用于冷链合规分析与路线优化研究\n")
|
| 363 |
+
return {"ok": True, "csv": csv_path, "readme": readme_path}
|
| 364 |
+
|
| 365 |
+
@app.get("/api/tasks")
|
| 366 |
+
async def list_tasks():
|
| 367 |
+
conn = get_conn()
|
| 368 |
+
cur = conn.cursor()
|
| 369 |
+
cur.execute("SELECT id, shipment_id, title, status, created_at FROM tasks ORDER BY created_at DESC")
|
| 370 |
+
rows = cur.fetchall()
|
| 371 |
+
conn.close()
|
| 372 |
+
return [{"id": r[0], "shipment_id": r[1], "title": r[2], "status": r[3], "created_at": r[4]} for r in rows]
|
| 373 |
+
|
| 374 |
+
@app.get("/api/tasks/{task_id}")
|
| 375 |
+
async def get_task(task_id: str):
|
| 376 |
+
conn = get_conn()
|
| 377 |
+
cur = conn.cursor()
|
| 378 |
+
cur.execute("SELECT id, shipment_id, title, status, result_md, logs, created_at, updated_at FROM tasks WHERE id = ?", (task_id,))
|
| 379 |
+
row = cur.fetchone()
|
| 380 |
+
conn.close()
|
| 381 |
+
if not row:
|
| 382 |
+
raise HTTPException(status_code=404, detail="任务不存在")
|
| 383 |
+
return {
|
| 384 |
+
"id": row[0],
|
| 385 |
+
"shipment_id": row[1],
|
| 386 |
+
"title": row[2],
|
| 387 |
+
"status": row[3],
|
| 388 |
+
"result_md": row[4],
|
| 389 |
+
"logs": json.loads(row[5] or "[]"),
|
| 390 |
+
"created_at": row[6],
|
| 391 |
+
"updated_at": row[7],
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
@app.post("/api/agent/run")
|
| 395 |
+
async def run_agent(background_tasks: BackgroundTasks, shipment_id: str = Form(...), title: str = Form("冷链合规巡检")):
|
| 396 |
+
tid = str(uuid.uuid4())
|
| 397 |
+
now = datetime.datetime.now().isoformat()
|
| 398 |
+
conn = get_conn()
|
| 399 |
+
cur = conn.cursor()
|
| 400 |
+
cur.execute(
|
| 401 |
+
"INSERT INTO tasks (id, shipment_id, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
| 402 |
+
(tid, shipment_id, title, "pending", now, now),
|
| 403 |
+
)
|
| 404 |
+
conn.commit()
|
| 405 |
+
conn.close()
|
| 406 |
+
background_tasks.add_task(agent_run, tid, shipment_id, title)
|
| 407 |
+
return {"task_id": tid}
|
| 408 |
+
|
| 409 |
+
if __name__ == "__main__":
|
| 410 |
+
import uvicorn
|
| 411 |
+
port = int(os.getenv("PORT", "7870"))
|
| 412 |
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
instance/pharma.db
ADDED
|
Binary file (28.7 kB). View file
|
|
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.110.0
|
| 2 |
+
uvicorn[standard]==0.27.1
|
| 3 |
+
jinja2==3.1.3
|
| 4 |
+
httpx==0.27.0
|
| 5 |
+
python-multipart==0.0.9
|
templates/index.html
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
+
<title>药链智控 Agent</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
|
| 10 |
+
<style>
|
| 11 |
+
:root { --bg:#0b1220; --card:#121a2b; --muted:#8aa0b6; --text:#e8eef5; --accent:#4fd1c5; --danger:#ef4444; }
|
| 12 |
+
*{box-sizing:border-box}body{margin:0;font-family:Inter,system-ui,Segoe UI,Arial; background:var(--bg); color:var(--text)}
|
| 13 |
+
.wrap{max-width:980px;margin:0 auto;padding:16px}
|
| 14 |
+
header{display:flex;align-items:center;justify-content:space-between;padding:14px 8px}
|
| 15 |
+
.brand{font-weight:800;letter-spacing:.3px}
|
| 16 |
+
.badge{padding:4px 10px;border-radius:999px;background:rgba(79,209,197,.1);color:var(--accent);font-size:12px}
|
| 17 |
+
.grid{display:grid;gap:12px}
|
| 18 |
+
@media(min-width:800px){.grid{grid-template-columns:1.2fr .8fr}}
|
| 19 |
+
.card{background:linear-gradient(180deg,rgba(255,255,255,.02),transparent 60%) ,var(--card);border:1px solid rgba(255,255,255,.06);border-radius:14px;padding:16px}
|
| 20 |
+
.card h3{margin:0 0 10px 0;font-size:18px}
|
| 21 |
+
input,button,select,textarea{width:100%;padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,.1);background:#0d1626;color:var(--text)}
|
| 22 |
+
button{background:linear-gradient(90deg,#1f4b99,#3b82f6);border:none;cursor:pointer;font-weight:600}
|
| 23 |
+
button:disabled{opacity:.6;cursor:not-allowed}
|
| 24 |
+
.row{display:flex;gap:8px}
|
| 25 |
+
.row>*{flex:1}
|
| 26 |
+
.list{display:grid;gap:8px}
|
| 27 |
+
.item{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;background:#0e1730;border:1px solid rgba(255,255,255,.06);border-radius:10px}
|
| 28 |
+
.muted{color:var(--muted);font-size:12px}
|
| 29 |
+
.status{font-size:12px;padding:2px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.12)}
|
| 30 |
+
.status.ok{color:#10b981;border-color:#10b98133}
|
| 31 |
+
.status.wip{color:#f59e0b;border-color:#f59e0b33}
|
| 32 |
+
.status.fail{color:var(--danger);border-color:#ef444433}
|
| 33 |
+
.log{white-space:pre-wrap;background:#0a1122;border:1px solid rgba(255,255,255,.08);padding:10px;border-radius:10px;max-height:240px;overflow:auto}
|
| 34 |
+
.md{line-height:1.6}
|
| 35 |
+
.md h1,.md h2{margin:12px 0 8px}
|
| 36 |
+
.md ul{padding-left:18px}
|
| 37 |
+
</style>
|
| 38 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 39 |
+
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
| 40 |
+
</head>
|
| 41 |
+
<body>
|
| 42 |
+
<div class="wrap">
|
| 43 |
+
<header>
|
| 44 |
+
<div>
|
| 45 |
+
<div class="brand">药链智控 Agent</div>
|
| 46 |
+
<div class="muted">医药冷链合规与运营闭环 · 推理/行动/记忆/验收/回放</div>
|
| 47 |
+
</div>
|
| 48 |
+
<div class="badge">Beta</div>
|
| 49 |
+
</header>
|
| 50 |
+
|
| 51 |
+
<div class="grid">
|
| 52 |
+
<section class="card">
|
| 53 |
+
<h3>创建运输批次</h3>
|
| 54 |
+
<div class="row">
|
| 55 |
+
<input id="shipmentName" placeholder="输入批次名称,如:疫苗-成都→贵阳 D01" />
|
| 56 |
+
<button id="btnCreate">创建批次</button>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="muted" style="margin-top:6px">创建后可录入温湿度数据,并触发合规智能体。</div>
|
| 59 |
+
</section>
|
| 60 |
+
|
| 61 |
+
<section class="card">
|
| 62 |
+
<h3>批次列表</h3>
|
| 63 |
+
<div id="shipList" class="list"></div>
|
| 64 |
+
</section>
|
| 65 |
+
</div>
|
| 66 |
+
|
| 67 |
+
<section class="card" style="margin-top:12px">
|
| 68 |
+
<h3>录入传感数据</h3>
|
| 69 |
+
<div class="row">
|
| 70 |
+
<select id="selShip"></select>
|
| 71 |
+
<input id="temp" type="number" step="0.1" placeholder="温度 ℃,如 5.2" />
|
| 72 |
+
</div>
|
| 73 |
+
<div class="row" style="margin-top:8px">
|
| 74 |
+
<input id="humi" type="number" step="0.1" placeholder="湿度 %,如 60" />
|
| 75 |
+
<input id="loc" placeholder="位置/节点,如:冷库A→转运点B" />
|
| 76 |
+
</div>
|
| 77 |
+
<button id="btnIngest" style="margin-top:8px">提交传感数据</button>
|
| 78 |
+
<div class="muted" style="margin-top:6px">支持多次提交以形成温控曲线。可批量导入 CSV。</div>
|
| 79 |
+
<div class="row" style="margin-top:8px">
|
| 80 |
+
<input id="csvFile" type="file" accept=".csv,text/csv" />
|
| 81 |
+
<button id="btnBatch">批量导入 CSV</button>
|
| 82 |
+
</div>
|
| 83 |
+
</section>
|
| 84 |
+
|
| 85 |
+
<section class="card" style="margin-top:12px">
|
| 86 |
+
<h3>温度曲线 · 越界一览</h3>
|
| 87 |
+
<div class="muted" id="statsLine" style="margin-bottom:6px"></div>
|
| 88 |
+
<canvas id="chart" height="120"></canvas>
|
| 89 |
+
<div class="muted" style="margin-top:6px">绿色为 2-8℃ 合规区间,红点表示越界。</div>
|
| 90 |
+
<div class="row" style="margin-top:8px">
|
| 91 |
+
<select id="selSummary"></select>
|
| 92 |
+
<button id="btnExport">导出为数据集</button>
|
| 93 |
+
</div>
|
| 94 |
+
<div id="exportMsg" class="muted" style="margin-top:6px"></div>
|
| 95 |
+
</section>
|
| 96 |
+
|
| 97 |
+
<section class="card" style="margin-top:12px">
|
| 98 |
+
<h3>合规智能体 · 执行</h3>
|
| 99 |
+
<div class="row">
|
| 100 |
+
<select id="selRun"></select>
|
| 101 |
+
<input id="taskTitle" placeholder="任务标题,如:例行合规巡检" />
|
| 102 |
+
</div>
|
| 103 |
+
<button id="btnRun" style="margin-top:8px">启动智能体</button>
|
| 104 |
+
<div id="taskStatus" class="muted" style="margin-top:6px"></div>
|
| 105 |
+
</section>
|
| 106 |
+
|
| 107 |
+
<section class="card" style="margin-top:12px">
|
| 108 |
+
<h3>任务详情 · 验收与回放</h3>
|
| 109 |
+
<div id="result" class="md"></div>
|
| 110 |
+
<h4 style="margin:12px 0 6px">执行过程日志</h4>
|
| 111 |
+
<div id="logs" class="log"></div>
|
| 112 |
+
</section>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<script>
|
| 116 |
+
const api = async (p, opt={})=>{
|
| 117 |
+
const r = await fetch(p, opt);
|
| 118 |
+
let data = null;
|
| 119 |
+
try { data = await r.json(); } catch { data = {}; }
|
| 120 |
+
if(!r.ok){
|
| 121 |
+
const msg = (data && (data.detail || data.message)) || '请求失败';
|
| 122 |
+
throw new Error(msg);
|
| 123 |
+
}
|
| 124 |
+
return data;
|
| 125 |
+
};
|
| 126 |
+
const $ = (id)=>document.getElementById(id);
|
| 127 |
+
let chart = null;
|
| 128 |
+
|
| 129 |
+
async function refreshShipments() {
|
| 130 |
+
let data = [];
|
| 131 |
+
try { data = await api('/api/shipments'); } catch(e){ alert('获取批次失败:'+e.message); return; }
|
| 132 |
+
const list = $('shipList');
|
| 133 |
+
const sel1 = $('selShip');
|
| 134 |
+
const sel2 = $('selRun');
|
| 135 |
+
const sel3 = $('selSummary');
|
| 136 |
+
list.innerHTML = '';
|
| 137 |
+
sel1.innerHTML = '';
|
| 138 |
+
sel2.innerHTML = '';
|
| 139 |
+
sel3.innerHTML = '';
|
| 140 |
+
data.forEach(s=>{
|
| 141 |
+
const div = document.createElement('div');
|
| 142 |
+
div.className='item';
|
| 143 |
+
div.innerHTML = `<div><div>${s.name}</div><div class="muted">${s.id} · ${s.created_at}</div></div><div class="status ${s.status==='新建'?'wip':'ok'}">${s.status}</div>`;
|
| 144 |
+
list.appendChild(div);
|
| 145 |
+
const opt1 = new Option(s.name, s.id);
|
| 146 |
+
const opt2 = new Option(s.name, s.id);
|
| 147 |
+
const opt3 = new Option(s.name, s.id);
|
| 148 |
+
sel1.add(opt1); sel2.add(opt2); sel3.add(opt3);
|
| 149 |
+
})
|
| 150 |
+
if(sel1.options.length>0){ sel1.selectedIndex=0; }
|
| 151 |
+
if(sel2.options.length>0){ sel2.selectedIndex=0; }
|
| 152 |
+
if(sel3.options.length>0){ sel3.selectedIndex=0; loadSummary(sel3.value); }
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
$('btnCreate').onclick = async ()=>{
|
| 156 |
+
const name = $('shipmentName').value.trim();
|
| 157 |
+
if(!name) return alert('请输入批次名称');
|
| 158 |
+
const fd = new FormData(); fd.append('name', name);
|
| 159 |
+
try {
|
| 160 |
+
await api('/api/shipments', {method:'POST', body: fd});
|
| 161 |
+
} catch(e){ return alert('创建失败:'+e.message); }
|
| 162 |
+
$('shipmentName').value = '';
|
| 163 |
+
refreshShipments();
|
| 164 |
+
};
|
| 165 |
+
|
| 166 |
+
$('btnIngest').onclick = async ()=>{
|
| 167 |
+
const sid = $('selShip').value;
|
| 168 |
+
if(!sid) return alert('请选择批次');
|
| 169 |
+
const t = $('temp').value; const h = $('humi').value; const l = $('loc').value || '未知';
|
| 170 |
+
if(t==='') return alert('请输入温度');
|
| 171 |
+
const fd = new FormData(); fd.append('shipment_id', sid); fd.append('temperature', t); fd.append('humidity', h||'0'); fd.append('location', l);
|
| 172 |
+
try {
|
| 173 |
+
const res = await api('/api/sensors', {method:'POST', body: fd});
|
| 174 |
+
if(res.ok){ $('temp').value=''; $('humi').value=''; $('loc').value=''; alert('已记录'); loadSummary($('selSummary').value || sid); }
|
| 175 |
+
} catch(e){ alert('提交失败:'+e.message); }
|
| 176 |
+
};
|
| 177 |
+
|
| 178 |
+
$('btnBatch').onclick = async ()=>{
|
| 179 |
+
const f = $('csvFile').files[0];
|
| 180 |
+
const sid = $('selShip').value;
|
| 181 |
+
if(!sid) return alert('请选择批次');
|
| 182 |
+
if(!f) return alert('请选择CSV文件');
|
| 183 |
+
const fd = new FormData(); fd.append('shipment_id', sid); fd.append('file', f);
|
| 184 |
+
try {
|
| 185 |
+
const res = await api('/api/sensors/batch', {method:'POST', body: fd});
|
| 186 |
+
if(res.ok){ alert(`导入成功:${res.count} 条`); loadSummary($('selSummary').value || sid); }
|
| 187 |
+
} catch(e){ alert('导入失败:'+e.message); }
|
| 188 |
+
};
|
| 189 |
+
|
| 190 |
+
async function loadSummary(sid){
|
| 191 |
+
if(!sid) return;
|
| 192 |
+
try {
|
| 193 |
+
const s = await api('/api/sensors/summary?shipment_id='+encodeURIComponent(sid));
|
| 194 |
+
$('statsLine').textContent = `记录数:${s.count},最低:${s.min??'-'}℃,最高:${s.max??'-'}℃,平均:${s.avg??'-'}℃;越界次数:${s.breaches.length}`;
|
| 195 |
+
const labels = s.timeline.map(x=>x.ts.slice(11,19));
|
| 196 |
+
const temps = s.timeline.map(x=>x.t);
|
| 197 |
+
const ctx = $('chart').getContext('2d');
|
| 198 |
+
if(chart){ chart.destroy(); }
|
| 199 |
+
chart = new Chart(ctx, {
|
| 200 |
+
type: 'line',
|
| 201 |
+
data: { labels, datasets:[{ label:'温度℃', data:temps, borderColor:'#3b82f6', tension:0.2, pointRadius:2 }] },
|
| 202 |
+
options: {
|
| 203 |
+
plugins:{ legend:{display:false}},
|
| 204 |
+
scales:{ y:{ beginAtZero:false, suggestedMin:0, suggestedMax:10 } }
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
} catch(e){
|
| 208 |
+
$('statsLine').textContent = '加载统计失败:'+e.message;
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
$('selSummary').onchange = (e)=> loadSummary(e.target.value);
|
| 213 |
+
|
| 214 |
+
let polling = null;
|
| 215 |
+
function startPoll(taskId){
|
| 216 |
+
if(polling) clearInterval(polling);
|
| 217 |
+
polling = setInterval(async ()=>{
|
| 218 |
+
try{
|
| 219 |
+
const d = await api('/api/tasks/'+taskId);
|
| 220 |
+
$('taskStatus').textContent = `状态:${d.status} · 任务ID:${d.id}`;
|
| 221 |
+
$('logs').textContent = d.logs.map(x=>`[${x.timestamp}] ${x.role}: ${x.content}`).join('\n');
|
| 222 |
+
$('result').innerHTML = marked.parse(d.result_md || '');
|
| 223 |
+
if(d.status!=='in_progress' && d.status!=='pending') clearInterval(polling);
|
| 224 |
+
}catch(e){
|
| 225 |
+
$('taskStatus').textContent = '查询任务失败:'+e.message;
|
| 226 |
+
clearInterval(polling);
|
| 227 |
+
}
|
| 228 |
+
}, 1000);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
$('btnRun').onclick = async ()=>{
|
| 232 |
+
const sid = $('selRun').value;
|
| 233 |
+
if(!sid) return alert('请选择批次');
|
| 234 |
+
const title = $('taskTitle').value || '冷链合规巡检';
|
| 235 |
+
const fd = new FormData(); fd.append('shipment_id', sid); fd.append('title', title);
|
| 236 |
+
try{
|
| 237 |
+
const r = await api('/api/agent/run', {method:'POST', body: fd});
|
| 238 |
+
$('taskStatus').textContent = '任务已创建,正在执行…';
|
| 239 |
+
startPoll(r.task_id);
|
| 240 |
+
}catch(e){
|
| 241 |
+
$('taskStatus').textContent = '启动失败:'+e.message;
|
| 242 |
+
}
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
$('btnExport').onclick = async ()=>{
|
| 246 |
+
const sid = $('selSummary').value;
|
| 247 |
+
if(!sid) return alert('请选择批次');
|
| 248 |
+
try{
|
| 249 |
+
const r = await api('/api/export/dataset', {method:'POST', body: new URLSearchParams({shipment_id: sid})});
|
| 250 |
+
}catch{
|
| 251 |
+
// 兼容 GET 导出
|
| 252 |
+
}
|
| 253 |
+
try{
|
| 254 |
+
const r2 = await api('/api/export/dataset?shipment_id='+encodeURIComponent(sid));
|
| 255 |
+
$('exportMsg').textContent = '已导出到本地 hf_dataset 目录:'+ (r2.csv||'');
|
| 256 |
+
}catch(e){
|
| 257 |
+
$('exportMsg').textContent = '导出失败:'+e.message;
|
| 258 |
+
}
|
| 259 |
+
};
|
| 260 |
+
|
| 261 |
+
refreshShipments();
|
| 262 |
+
</script>
|
| 263 |
+
</body>
|
| 264 |
+
</html>
|