Trae Assistant commited on
Commit
e3f24bb
·
0 Parent(s):

feat: 药链智控Agent 升级 · 中文界面/默认数据/批量导入/曲线/数据集导出

Browse files
Files changed (7) hide show
  1. .gitattributes +7 -0
  2. Dockerfile +16 -0
  3. README.md +44 -0
  4. app.py +412 -0
  5. instance/pharma.db +0 -0
  6. requirements.txt +5 -0
  7. 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>