feat: 修复 AI 风险语义并隔离 Mock 数据
This commit is contained in:
@@ -67,8 +67,8 @@
|
|||||||
|
|
||||||
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9)
|
||||||
|
|
||||||
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度,`POST /stream-detect` 拉流抽帧骨架,`GET /metrics` 监控指标)
|
- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度、`modelVersion`、`isMock`、`abnormalProbability`,`POST /internal/stream-tasks` 受限拉流任务,`GET /metrics` 监控指标)
|
||||||
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级,只消费 AI 异常概率,缺失项不按 0 参与)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等)
|
||||||
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
- 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查)
|
||||||
|
|
||||||
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
### 1.10 知识库与阶段风险提示(计划 #10/#13)
|
||||||
@@ -595,8 +595,8 @@ python -m venv .venv
|
|||||||
MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
MODEL_MODE=mock .venv\Scripts\python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
- 接口:`GET /health`、`POST /detect`(multipart 图片)、`POST /stream-detect`(摄像头流拉帧骨架)、`GET /metrics`(含 GPU 信息)
|
- 接口:`GET /health`、`POST /detect`(multipart 图片,返回 `abnormalProbability`)、`POST /internal/stream-tasks`(受限摄像头流拉帧任务)、`GET /metrics`(含 GPU 信息)
|
||||||
- 默认 mock 模式;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别)
|
- 默认 mock 模式且响应带 `isMock=true`;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别、`MODEL_VERSION` 必须显式配置)
|
||||||
- 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像
|
- 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像
|
||||||
|
|
||||||
### 12.7 开发服务器部署摘要
|
### 12.7 开发服务器部署摘要
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FastAPI + ONNX Runtime 的蚕病检测推理服务。YOLO 训练挂起期间以
|
|||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
- `GET /health` → `{"status":"ok","model":"mock|onnx"}`
|
- `GET /health` → `{"status":"ok","model":"mock|onnx"}`
|
||||||
- `POST /detect`(multipart 字段 `file`)→ `{"model":"mock","detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
|
- `POST /detect`(multipart 字段 `file`)→ `{"model":"mock","modelVersion":"...","isMock":true,"status":"healthy|abnormal|unknown","abnormalProbability":0,"detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
|
||||||
- `POST /internal/stream-tasks`(需 `X-Internal-Key`)→ 创建受限拉流任务,不再接受客户端任意 URL
|
- `POST /internal/stream-tasks`(需 `X-Internal-Key`)→ 创建受限拉流任务,不再接受客户端任意 URL
|
||||||
|
|
||||||
## 本地运行
|
## 本地运行
|
||||||
@@ -33,6 +33,7 @@ powershell -ExecutionPolicy Bypass -File scripts/verify.ps1
|
|||||||
| `MODEL_MODE` | `mock` | `mock` / `onnx` |
|
| `MODEL_MODE` | `mock` | `mock` / `onnx` |
|
||||||
| `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 |
|
| `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 |
|
||||||
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
|
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
|
||||||
|
| `MODEL_VERSION` | `mock-2026.08.14` / `best.onnx` | 模型版本;真实模型上线时必须显式配置 |
|
||||||
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
|
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
|
||||||
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
|
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
|
||||||
| `INTERNAL_API_KEY` | `silk-internal-2026` | 内部接口认证密钥 |
|
| `INTERNAL_API_KEY` | `silk-internal-2026` | 内部接口认证密钥 |
|
||||||
@@ -42,5 +43,6 @@ powershell -ExecutionPolicy Bypass -File scripts/verify.ps1
|
|||||||
|
|
||||||
## 说明
|
## 说明
|
||||||
|
|
||||||
- 只做检测,风险评分在 Go 后端计算(#9:0.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
|
- 只做检测并返回 `abnormalProbability`;风险评分在 Go 后端计算(#9:0.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
|
||||||
|
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
|
||||||
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ MODEL_MODE = os.getenv("MODEL_MODE", "mock")
|
|||||||
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
|
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
|
||||||
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
|
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
|
||||||
MODEL_LABELS = os.getenv("MODEL_LABELS", "healthy,sick").split(",")
|
MODEL_LABELS = os.getenv("MODEL_LABELS", "healthy,sick").split(",")
|
||||||
|
MODEL_VERSION = os.getenv("MODEL_VERSION", "mock-2026.08.14" if MODEL_MODE == "mock" else "best.onnx")
|
||||||
# mock 模式返回的固定结果
|
# mock 模式返回的固定结果
|
||||||
MOCK_CLASS = os.getenv("MOCK_CLASS", "healthy")
|
MOCK_CLASS = os.getenv("MOCK_CLASS", "healthy")
|
||||||
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
|
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
|
||||||
|
|||||||
@@ -5,6 +5,52 @@ from abc import ABC, abstractmethod
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def _label_set(labels) -> set[str]:
|
||||||
|
return {label.strip().lower() for label in labels if label.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
def abnormal_probability(detections: list[dict], labels: tuple[str, ...] | list[str]) -> float:
|
||||||
|
"""单帧异常概率:取异常类检测的最高置信度,避免多框求和造成虚高。"""
|
||||||
|
abnormal = _label_set(labels) - {"healthy"}
|
||||||
|
best = 0.0
|
||||||
|
for detection in detections:
|
||||||
|
class_name = str(detection.get("class_name", "")).strip().lower()
|
||||||
|
if class_name in ("", "healthy", "unknown"):
|
||||||
|
continue
|
||||||
|
if abnormal and class_name not in abnormal:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
confidence = float(detection.get("confidence", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
confidence = 0.0
|
||||||
|
best = max(best, min(1.0, max(0.0, confidence)))
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def detection_status(detections: list[dict], labels: tuple[str, ...] | list[str]) -> str:
|
||||||
|
"""空检测或 unknown 类不再被当作 healthy。"""
|
||||||
|
if not detections:
|
||||||
|
return "unknown"
|
||||||
|
abnormal = _label_set(labels) - {"healthy"}
|
||||||
|
healthy_seen = False
|
||||||
|
unknown_seen = False
|
||||||
|
for detection in detections:
|
||||||
|
class_name = str(detection.get("class_name", "")).strip().lower()
|
||||||
|
if abnormal and class_name in abnormal:
|
||||||
|
return "abnormal"
|
||||||
|
if not abnormal and class_name not in ("", "healthy", "unknown"):
|
||||||
|
return "abnormal"
|
||||||
|
if class_name == "healthy":
|
||||||
|
healthy_seen = True
|
||||||
|
elif class_name in ("", "unknown"):
|
||||||
|
unknown_seen = True
|
||||||
|
else:
|
||||||
|
unknown_seen = True
|
||||||
|
if healthy_seen and not unknown_seen:
|
||||||
|
return "healthy"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
class Detector(ABC):
|
class Detector(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def detect(self, image_bytes: bytes) -> list[dict]:
|
def detect(self, image_bytes: bytes) -> list[dict]:
|
||||||
|
|||||||
+13
-2
@@ -6,7 +6,7 @@ import time
|
|||||||
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
from .detector import MockDetector, ONNXDetector
|
from .detector import MockDetector, ONNXDetector, abnormal_probability, detection_status
|
||||||
from .stream_tasks import StreamTaskWorker, is_allowed_stream_ref
|
from .stream_tasks import StreamTaskWorker, is_allowed_stream_ref
|
||||||
|
|
||||||
app = FastAPI(title="Silk AI Service", version="0.1.0")
|
app = FastAPI(title="Silk AI Service", version="0.1.0")
|
||||||
@@ -36,7 +36,12 @@ else:
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok", "model": config.MODEL_MODE}
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/detect")
|
@app.post("/detect")
|
||||||
@@ -50,6 +55,10 @@ async def detect(file: UploadFile = File(...)):
|
|||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
return {
|
return {
|
||||||
"model": config.MODEL_MODE,
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
|
"status": detection_status(detections, config.MODEL_LABELS),
|
||||||
|
"abnormalProbability": abnormal_probability(detections, config.MODEL_LABELS),
|
||||||
"detections": [
|
"detections": [
|
||||||
{
|
{
|
||||||
"bbox": d["bbox"],
|
"bbox": d["bbox"],
|
||||||
@@ -112,6 +121,8 @@ def metrics():
|
|||||||
gpu = None
|
gpu = None
|
||||||
return {
|
return {
|
||||||
"model": config.MODEL_MODE,
|
"model": config.MODEL_MODE,
|
||||||
|
"modelVersion": config.MODEL_VERSION,
|
||||||
|
"isMock": isinstance(detector, MockDetector),
|
||||||
"uptimeSeconds": int(time.time() - START_TIME),
|
"uptimeSeconds": int(time.time() - START_TIME),
|
||||||
"requests": reqs,
|
"requests": reqs,
|
||||||
"avgLatencyMs": round(avg, 2),
|
"avgLatencyMs": round(avg, 2),
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ os.environ.setdefault("ALLOWED_STREAM_HOSTS", "localhost,127.0.0.1,100.83.103.1"
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main as main_module
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
from app.detector import MockDetector
|
||||||
|
|
||||||
# 1x1 透明 PNG
|
# 1x1 透明 PNG
|
||||||
TINY_PNG = base64.b64decode(
|
TINY_PNG = base64.b64decode(
|
||||||
@@ -22,6 +24,8 @@ def test_health():
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["status"] == "ok"
|
assert body["status"] == "ok"
|
||||||
assert body["model"] in ("mock", "onnx")
|
assert body["model"] in ("mock", "onnx")
|
||||||
|
assert body["modelVersion"]
|
||||||
|
assert body["isMock"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_detect_ok():
|
def test_detect_ok():
|
||||||
@@ -29,6 +33,10 @@ def test_detect_ok():
|
|||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["model"] == "mock"
|
assert body["model"] == "mock"
|
||||||
|
assert body["modelVersion"]
|
||||||
|
assert body["isMock"] is True
|
||||||
|
assert body["status"] == "healthy"
|
||||||
|
assert body["abnormalProbability"] == 0
|
||||||
assert len(body["detections"]) >= 1
|
assert len(body["detections"]) >= 1
|
||||||
d = body["detections"][0]
|
d = body["detections"][0]
|
||||||
assert d["class"] in ("healthy", "sick")
|
assert d["class"] in ("healthy", "sick")
|
||||||
@@ -36,6 +44,28 @@ def test_detect_ok():
|
|||||||
assert d["bbox"]["w"] > 0
|
assert d["bbox"]["w"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_uses_abnormal_class_confidence(monkeypatch):
|
||||||
|
monkeypatch.setattr(main_module, "detector", MockDetector(class_name="sick", confidence=0.92))
|
||||||
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "abnormal"
|
||||||
|
assert body["abnormalProbability"] == 0.92
|
||||||
|
|
||||||
|
|
||||||
|
def test_detect_empty_result_is_unknown(monkeypatch):
|
||||||
|
class EmptyDetector:
|
||||||
|
def detect(self, image_bytes):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(main_module, "detector", EmptyDetector())
|
||||||
|
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["status"] == "unknown"
|
||||||
|
assert body["abnormalProbability"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_detect_empty_file_rejected():
|
def test_detect_empty_file_rejected():
|
||||||
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
|
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import base64
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.detector import MockDetector
|
from app.detector import MockDetector, abnormal_probability, detection_status
|
||||||
|
|
||||||
# 1x1 透明 PNG
|
# 1x1 透明 PNG
|
||||||
TINY_PNG = base64.b64decode(
|
TINY_PNG = base64.b64decode(
|
||||||
@@ -23,3 +23,22 @@ def test_mock_detector_rejects_invalid_image():
|
|||||||
det = MockDetector()
|
det = MockDetector()
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
det.detect(b"not an image")
|
det.detect(b"not an image")
|
||||||
|
|
||||||
|
|
||||||
|
def test_abnormal_probability_ignores_healthy_and_unknown():
|
||||||
|
detections = [
|
||||||
|
{"class_name": "healthy", "confidence": 0.95},
|
||||||
|
{"class_name": "unknown", "confidence": 0.8},
|
||||||
|
{"class_name": "sick", "confidence": 0.72},
|
||||||
|
]
|
||||||
|
assert abnormal_probability(detections, ("healthy", "sick")) == 0.72
|
||||||
|
|
||||||
|
|
||||||
|
def test_detection_status_returns_unknown_for_empty_or_unknown():
|
||||||
|
assert detection_status([], ("healthy", "sick")) == "unknown"
|
||||||
|
assert detection_status([{"class_name": "unknown", "confidence": 0.8}], ("healthy", "sick")) == "unknown"
|
||||||
|
assert detection_status([{"class_name": "healthy", "confidence": 0.95}], ("healthy", "sick")) == "healthy"
|
||||||
|
assert detection_status(
|
||||||
|
[{"class_name": "white_muscardine", "confidence": 0.7}],
|
||||||
|
("healthy", "white_muscardine", "nuclear_polyhedrosis"),
|
||||||
|
) == "abnormal"
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ const InspectionPage: React.FC = () => {
|
|||||||
? '检测到疑似异常'
|
? '检测到疑似异常'
|
||||||
: '未见明显异常'
|
: '未见明显异常'
|
||||||
: 'AI 检测失败'}
|
: 'AI 检测失败'}
|
||||||
|
{result.isMock ? '(联调 Mock)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
{result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? (
|
||||||
<View className={styles.detectionList}>
|
<View className={styles.detectionList}>
|
||||||
@@ -145,6 +146,7 @@ const InspectionPage: React.FC = () => {
|
|||||||
? '疑似异常'
|
? '疑似异常'
|
||||||
: '正常'
|
: '正常'
|
||||||
: '检测失败'}
|
: '检测失败'}
|
||||||
|
{rec.isMock ? '(Mock)' : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
<Text className={styles.historyTime}>{formatRelativeTime(rec.createdAt)}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -223,6 +223,17 @@ export interface InspectionRecord {
|
|||||||
detections?: AIDetection[];
|
detections?: AIDetection[];
|
||||||
riskScore?: number;
|
riskScore?: number;
|
||||||
riskLevel?: string;
|
riskLevel?: string;
|
||||||
|
riskAssessment?: {
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
confidence: string;
|
||||||
|
modelVersion: string;
|
||||||
|
ruleVersion: string;
|
||||||
|
components: Record<string, number | null>;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
modelVersion?: string;
|
||||||
|
isMock?: boolean;
|
||||||
aiStatus: string;
|
aiStatus: string;
|
||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Task 6 Step 5: 只读历史数据评估报告
|
||||||
|
-- 仅统计,不更新数据;未经人工确认不得批量重算或覆盖历史 risk_score/risk_level。
|
||||||
|
|
||||||
|
WITH real_inspections AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
risk_level,
|
||||||
|
risk_score,
|
||||||
|
detections
|
||||||
|
FROM inspection_records
|
||||||
|
WHERE ai_status = 'done'
|
||||||
|
AND (is_mock IS NULL OR is_mock = false)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
risk_level,
|
||||||
|
count(*) AS records,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_array_elements(detections) AS d
|
||||||
|
WHERE d->>'class' = 'healthy'
|
||||||
|
AND COALESCE((d->>'confidence')::numeric, 0) >= 0.80
|
||||||
|
)
|
||||||
|
) AS healthy_high_conf_records
|
||||||
|
FROM real_inspections
|
||||||
|
GROUP BY risk_level
|
||||||
|
ORDER BY risk_level;
|
||||||
@@ -111,7 +111,7 @@ func main() {
|
|||||||
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg)
|
||||||
handler.RegisterStorageRoutes(api, db)
|
handler.RegisterStorageRoutes(api, db)
|
||||||
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterKnowledgeRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection)
|
handler.RegisterInspectionRoutes(api, db, s3Svc, aiSvc, cfg.S3BucketImages, wechatSvc, cfg.WechatTemplateInspection, cfg.AppEnv)
|
||||||
handler.RegisterTrayBatchRoutes(api, db)
|
handler.RegisterTrayBatchRoutes(api, db)
|
||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database/postgres"
|
"github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"silk-server-go/migrations"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"silk-server-go/migrations"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "1"
|
const CurrentSchemaVersion = "2"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -100,4 +100,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if version != 1 {
|
if version != 1 {
|
||||||
t.Fatalf("expected baseline migration version 1, got %d", version)
|
t.Fatalf("expected baseline migration version 1, got %d", version)
|
||||||
}
|
}
|
||||||
|
next, err := driver.Next(version)
|
||||||
|
if err != nil || next != 2 {
|
||||||
|
t.Fatalf("expected risk assessment migration version 2, got %d (err %v)", next, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
db.Table("inspection_records").
|
db.Table("inspection_records").
|
||||||
Select("risk_level, count(*) AS cnt").
|
Select("risk_level, count(*) AS cnt").
|
||||||
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ?", id, since).
|
Where("room_id = ? AND ai_status = 'done' AND risk_level IS NOT NULL AND created_at >= ? AND COALESCE(is_mock, false) = false", id, since).
|
||||||
Group("risk_level").
|
Group("risk_level").
|
||||||
Scan(&riskRows)
|
Scan(&riskRows)
|
||||||
riskCounts := map[string]int64{}
|
riskCounts := map[string]int64{}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -26,8 +27,8 @@ func isUUID(s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RegisterInspectionRoutes 注册 AI 巡检路由
|
// RegisterInspectionRoutes 注册 AI 巡检路由
|
||||||
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string) {
|
func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) {
|
||||||
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID))
|
rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID, appEnv))
|
||||||
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +44,29 @@ func currentUserID(c *gin.Context) *string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildRiskInput(detRes *service.AIDetectResponse) service.RiskInput {
|
||||||
|
status := detRes.Status
|
||||||
|
if status == "" {
|
||||||
|
status = service.AIDetectionStatus(detRes.Detections)
|
||||||
|
}
|
||||||
|
modelVersion := detRes.ModelVersion
|
||||||
|
if modelVersion == "" {
|
||||||
|
modelVersion = "unknown"
|
||||||
|
}
|
||||||
|
in := service.RiskInput{ModelVersion: modelVersion}
|
||||||
|
if status != "unknown" {
|
||||||
|
aiProb := detRes.AbnormalProbability
|
||||||
|
if len(detRes.Detections) > 0 && aiProb == 0 {
|
||||||
|
aiProb = service.AbnormalProbability(detRes.Detections)
|
||||||
|
}
|
||||||
|
in.AI = &aiProb
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|
||||||
// createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。
|
// createInspection 拍照巡检:图片存 S3 → 调 AI /detect → 写记录。
|
||||||
// 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。
|
// 幂等:客户端传 Idempotency-Key 头时,重复请求返回已有记录。
|
||||||
func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID string) gin.HandlerFunc {
|
func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, bucket string, wechat *service.WechatService, inspectionTemplateID string, appEnv string) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key"))
|
||||||
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
roomID := strings.TrimSpace(c.PostForm("roomId"))
|
||||||
@@ -113,26 +134,30 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
} else {
|
} else {
|
||||||
raw, _ := json.Marshal(detRes.Detections)
|
raw, _ := json.Marshal(detRes.Detections)
|
||||||
rec.Detections = raw
|
rec.Detections = raw
|
||||||
|
isMock := detRes.IsMock
|
||||||
// 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算
|
rec.IsMock = &isMock
|
||||||
aiConf := 0.0
|
modelVersion := detRes.ModelVersion
|
||||||
for _, d := range detRes.Detections {
|
if modelVersion == "" {
|
||||||
if d.Confidence > aiConf {
|
modelVersion = "unknown"
|
||||||
aiConf = d.Confidence
|
|
||||||
}
|
}
|
||||||
}
|
rec.ModelVersion = &modelVersion
|
||||||
stageCoef, envCoef := loadRoomRisk(db, roomID)
|
|
||||||
score := service.ComputeRiskScore(service.RiskInput{
|
|
||||||
AI: aiConf,
|
|
||||||
Env: envCoef,
|
|
||||||
Stage: stageCoef,
|
|
||||||
})
|
|
||||||
rec.RiskScore = &score
|
|
||||||
level := service.RiskLevel(score)
|
|
||||||
rec.RiskLevel = &level
|
|
||||||
|
|
||||||
// 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送
|
if appEnv == "production" && isMock {
|
||||||
if key := service.WechatTemplateKey(level); key != "" {
|
slog.Error("生产环境收到 Mock AI 检测结果,按失败记录", "modelVersion", modelVersion, "roomId", roomID)
|
||||||
|
rec.AIStatus = "failed"
|
||||||
|
} else {
|
||||||
|
// 风险评分(#9 V2):只消费 AI 异常概率;缺失环境/阶段不填 0
|
||||||
|
riskInput := buildRiskInput(detRes)
|
||||||
|
riskInput.Env, riskInput.Stage = loadRoomRisk(db, roomID)
|
||||||
|
assessment := service.ComputeRiskScore(riskInput)
|
||||||
|
rec.RiskScore = &assessment.Score
|
||||||
|
rec.RiskLevel = &assessment.Level
|
||||||
|
rawRisk, _ := json.Marshal(assessment)
|
||||||
|
rec.RiskAssessment = rawRisk
|
||||||
|
|
||||||
|
// 微信订阅消息(#11 骨架):Mock 结果不进入告警,风险非绿且用户已授权时异步推送
|
||||||
|
if !isMock {
|
||||||
|
if key := service.WechatTemplateKey(assessment.Level); key != "" {
|
||||||
go func(uid *string, lv string, sc float64) {
|
go func(uid *string, lv string, sc float64) {
|
||||||
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
|
if uid == nil || !wechat.Configured() || inspectionTemplateID == "" {
|
||||||
return
|
return
|
||||||
@@ -155,7 +180,9 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
service.BuildSubscribeData(lv, sc),
|
service.BuildSubscribeData(lv, sc),
|
||||||
"pages/inspection/index",
|
"pages/inspection/index",
|
||||||
)
|
)
|
||||||
}(rec.UserID, level, score)
|
}(rec.UserID, assessment.Level, assessment.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,17 +202,19 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0)
|
// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil)
|
||||||
func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) {
|
||||||
if roomID == "" {
|
if roomID == "" {
|
||||||
return 0, 0
|
return nil, nil
|
||||||
}
|
}
|
||||||
var room model.Room
|
var room model.Room
|
||||||
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
if db.Where("id = ?", roomID).First(&room).Error != nil {
|
||||||
return 0, 0
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
var stageCoef *float64
|
||||||
if room.Stage != nil {
|
if room.Stage != nil {
|
||||||
stageCoef = service.StageCoefficient(*room.Stage)
|
value := service.StageCoefficient(*room.Stage)
|
||||||
|
stageCoef = &value
|
||||||
}
|
}
|
||||||
|
|
||||||
var humidity, temperature *float64
|
var humidity, temperature *float64
|
||||||
@@ -209,7 +238,11 @@ func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) {
|
|||||||
First(&t).Error; err == nil {
|
First(&t).Error; err == nil {
|
||||||
temperature = &t.Value
|
temperature = &t.Value
|
||||||
}
|
}
|
||||||
envCoef = service.EnvCoefficient(temperature, humidity)
|
var envCoef *float64
|
||||||
|
if humidity != nil || temperature != nil {
|
||||||
|
value := service.EnvCoefficient(temperature, humidity)
|
||||||
|
envCoef = &value
|
||||||
|
}
|
||||||
return stageCoef, envCoef
|
return stageCoef, envCoef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"silk-server-go/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
func TestIsUUID(t *testing.T) {
|
func TestIsUUID(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
@@ -19,3 +23,33 @@ func TestIsUUID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputUsesAbnormalProbability(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{
|
||||||
|
ModelVersion: "silk-yolo-2026.08.1",
|
||||||
|
Status: "abnormal",
|
||||||
|
AbnormalProbability: 0.93,
|
||||||
|
})
|
||||||
|
if in.AI == nil || *in.AI != 0.93 {
|
||||||
|
t.Fatalf("AI 异常概率应进入风险输入,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
if in.ModelVersion != "silk-yolo-2026.08.1" {
|
||||||
|
t.Errorf("modelVersion = %s", in.ModelVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputUnknownKeepsAIMissing(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{Status: "unknown"})
|
||||||
|
if in.AI != nil {
|
||||||
|
t.Fatalf("unknown 状态不应把 0 当作 AI 组件,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRiskInputFallsBackToDetections(t *testing.T) {
|
||||||
|
in := buildRiskInput(&service.AIDetectResponse{
|
||||||
|
Detections: []service.AIDetection{{ClassName: "sick", Confidence: 0.9}},
|
||||||
|
})
|
||||||
|
if in.AI == nil || *in.AI != 0.9 {
|
||||||
|
t.Fatalf("旧 AI 响应应从检测类别计算异常概率,实际 %v", in.AI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ type InspectionRecord struct {
|
|||||||
Detections json.RawMessage `gorm:"column:detections;type:jsonb" json:"detections,omitempty"`
|
Detections json.RawMessage `gorm:"column:detections;type:jsonb" json:"detections,omitempty"`
|
||||||
RiskScore *float64 `gorm:"column:risk_score;type:float" json:"riskScore,omitempty"`
|
RiskScore *float64 `gorm:"column:risk_score;type:float" json:"riskScore,omitempty"`
|
||||||
RiskLevel *string `gorm:"column:risk_level;size:16" json:"riskLevel,omitempty"`
|
RiskLevel *string `gorm:"column:risk_level;size:16" json:"riskLevel,omitempty"`
|
||||||
|
RiskAssessment json.RawMessage `gorm:"column:risk_assessment;type:jsonb" json:"riskAssessment,omitempty"`
|
||||||
|
ModelVersion *string `gorm:"column:model_version;size:64" json:"modelVersion,omitempty"`
|
||||||
|
IsMock *bool `gorm:"column:is_mock;default:false" json:"isMock,omitempty"`
|
||||||
AIStatus string `gorm:"column:ai_status;size:16;default:done" json:"aiStatus"`
|
AIStatus string `gorm:"column:ai_status;size:16;default:done" json:"aiStatus"`
|
||||||
IdempotencyKey *string `gorm:"column:idempotency_key;size:128;uniqueIndex" json:"idempotencyKey,omitempty"`
|
IdempotencyKey *string `gorm:"column:idempotency_key;size:128;uniqueIndex" json:"idempotencyKey,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
|
|||||||
@@ -30,9 +30,37 @@ type AIDetection struct {
|
|||||||
// AIDetectResponse /detect 响应
|
// AIDetectResponse /detect 响应
|
||||||
type AIDetectResponse struct {
|
type AIDetectResponse struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
ModelVersion string `json:"modelVersion"`
|
||||||
|
IsMock bool `json:"isMock"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AbnormalProbability float64 `json:"abnormalProbability"`
|
||||||
Detections []AIDetection `json:"detections"`
|
Detections []AIDetection `json:"detections"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AIDetectionStatus 从检测结果归纳 AI 状态;空检测或 unknown 不当作 healthy。
|
||||||
|
func AIDetectionStatus(detections []AIDetection) string {
|
||||||
|
if len(detections) == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
healthySeen := false
|
||||||
|
unknownSeen := false
|
||||||
|
for _, d := range detections {
|
||||||
|
class := strings.ToLower(strings.TrimSpace(d.ClassName))
|
||||||
|
switch class {
|
||||||
|
case "healthy":
|
||||||
|
healthySeen = true
|
||||||
|
case "", "unknown":
|
||||||
|
unknownSeen = true
|
||||||
|
default:
|
||||||
|
return "abnormal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if healthySeen && !unknownSeen {
|
||||||
|
return "healthy"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
// AIClient ai-service HTTP 客户端
|
// AIClient ai-service HTTP 客户端
|
||||||
type AIClient struct {
|
type AIClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ func TestAIClientDetectParsesResult(t *testing.T) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"model": "mock",
|
"model": "mock",
|
||||||
|
"modelVersion": "silk-yolo-2026.08.1",
|
||||||
|
"isMock": true,
|
||||||
|
"status": "abnormal",
|
||||||
|
"abnormalProbability": 0.93,
|
||||||
"detections": []map[string]any{
|
"detections": []map[string]any{
|
||||||
{"bbox": map[string]float64{"x": 1, "y": 2, "w": 3, "h": 4}, "class": "sick", "confidence": 0.93},
|
{"bbox": map[string]float64{"x": 1, "y": 2, "w": 3, "h": 4}, "class": "sick", "confidence": 0.93},
|
||||||
},
|
},
|
||||||
@@ -40,6 +44,15 @@ func TestAIClientDetectParsesResult(t *testing.T) {
|
|||||||
if res.Model != "mock" {
|
if res.Model != "mock" {
|
||||||
t.Errorf("model = %s, want mock", res.Model)
|
t.Errorf("model = %s, want mock", res.Model)
|
||||||
}
|
}
|
||||||
|
if res.ModelVersion != "silk-yolo-2026.08.1" {
|
||||||
|
t.Errorf("modelVersion = %s, want silk-yolo-2026.08.1", res.ModelVersion)
|
||||||
|
}
|
||||||
|
if !res.IsMock {
|
||||||
|
t.Error("isMock 应解析为 true")
|
||||||
|
}
|
||||||
|
if res.Status != "abnormal" || res.AbnormalProbability != 0.93 {
|
||||||
|
t.Errorf("AI 语义字段解析不正确: %+v", res)
|
||||||
|
}
|
||||||
if len(res.Detections) != 1 {
|
if len(res.Detections) != 1 {
|
||||||
t.Fatalf("detections 数量 = %d, want 1", len(res.Detections))
|
t.Fatalf("detections 数量 = %d, want 1", len(res.Detections))
|
||||||
}
|
}
|
||||||
@@ -49,6 +62,21 @@ func TestAIClientDetectParsesResult(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAIDetectionStatus(t *testing.T) {
|
||||||
|
if got := AIDetectionStatus(nil); got != "unknown" {
|
||||||
|
t.Errorf("空检测应为 unknown,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := AIDetectionStatus([]AIDetection{{ClassName: "healthy"}}); got != "healthy" {
|
||||||
|
t.Errorf("全健康应为 healthy,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := AIDetectionStatus([]AIDetection{{ClassName: "unknown"}}); got != "unknown" {
|
||||||
|
t.Errorf("unknown 应为 unknown,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := AIDetectionStatus([]AIDetection{{ClassName: "sick"}}); got != "abnormal" {
|
||||||
|
t.Errorf("异常类别应为 abnormal,实际 %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAIClientDetectServerError(t *testing.T) {
|
func TestAIClientDetectServerError(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, "boom", http.StatusInternalServerError)
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// CrossValidate 交叉验证:AI 检测结果 vs LAMP 检测结果(规格书:一致→确认诊断;不一致→升级专家会诊)
|
// CrossValidate 交叉验证:AI 检测结果 vs LAMP 检测结果(规格书:一致→确认诊断;不一致→升级专家会诊)
|
||||||
func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, string) {
|
func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, string) {
|
||||||
switch {
|
switch {
|
||||||
@@ -7,6 +9,8 @@ func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, str
|
|||||||
return false, "LAMP 判读无效,建议复检或专家会诊"
|
return false, "LAMP 判读无效,建议复检或专家会诊"
|
||||||
case aiClass == "":
|
case aiClass == "":
|
||||||
return false, "未找到关联巡检记录,暂无法交叉验证"
|
return false, "未找到关联巡检记录,暂无法交叉验证"
|
||||||
|
case aiClass == "unknown":
|
||||||
|
return false, "AI 结果未知,建议复检或专家会诊"
|
||||||
case aiClass == "sick" && lampResult == "positive":
|
case aiClass == "sick" && lampResult == "positive":
|
||||||
return true, "AI 检出异常与 LAMP 阳性一致,确认诊断"
|
return true, "AI 检出异常与 LAMP 阳性一致,确认诊断"
|
||||||
case aiClass == "healthy" && lampResult == "negative":
|
case aiClass == "healthy" && lampResult == "negative":
|
||||||
@@ -20,15 +24,26 @@ func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AIClassFromDetections 从巡检检测结果归纳 AI 结论(任一非 healthy 视为 sick)
|
// AIClassFromDetections 从巡检检测结果归纳 AI 结论;空检测或 unknown 不当作 healthy。
|
||||||
func AIClassFromDetections(detections []AIDetection) string {
|
func AIClassFromDetections(detections []AIDetection) string {
|
||||||
if len(detections) == 0 {
|
if len(detections) == 0 {
|
||||||
return ""
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
healthySeen := false
|
||||||
|
unknownSeen := false
|
||||||
for _, d := range detections {
|
for _, d := range detections {
|
||||||
if d.ClassName != "healthy" {
|
class := strings.ToLower(strings.TrimSpace(d.ClassName))
|
||||||
|
switch class {
|
||||||
|
case "healthy":
|
||||||
|
healthySeen = true
|
||||||
|
case "", "unknown":
|
||||||
|
unknownSeen = true
|
||||||
|
default:
|
||||||
return "sick"
|
return "sick"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if healthySeen && !unknownSeen {
|
||||||
return "healthy"
|
return "healthy"
|
||||||
}
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func TestCrossValidate(t *testing.T) {
|
|||||||
{"AI健康+LAMP阴性 一致", "healthy", "negative", true},
|
{"AI健康+LAMP阴性 一致", "healthy", "negative", true},
|
||||||
{"AI异常+LAMP阴性 不一致", "sick", "negative", false},
|
{"AI异常+LAMP阴性 不一致", "sick", "negative", false},
|
||||||
{"AI健康+LAMP阳性 不一致", "healthy", "positive", false},
|
{"AI健康+LAMP阳性 不一致", "healthy", "positive", false},
|
||||||
|
{"AI未知+LAMP阳性 不一致", "unknown", "positive", false},
|
||||||
{"LAMP无效 不一致", "sick", "invalid", false},
|
{"LAMP无效 不一致", "sick", "invalid", false},
|
||||||
{"无AI记录 不一致", "", "positive", false},
|
{"无AI记录 不一致", "", "positive", false},
|
||||||
}
|
}
|
||||||
@@ -28,8 +29,8 @@ func TestCrossValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAIClassFromDetections(t *testing.T) {
|
func TestAIClassFromDetections(t *testing.T) {
|
||||||
if got := AIClassFromDetections(nil); got != "" {
|
if got := AIClassFromDetections(nil); got != "unknown" {
|
||||||
t.Errorf("空检测应为空,实际 %s", got)
|
t.Errorf("空检测应为 unknown,实际 %s", got)
|
||||||
}
|
}
|
||||||
if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy", Confidence: 0.9}}); got != "healthy" {
|
if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy", Confidence: 0.9}}); got != "healthy" {
|
||||||
t.Errorf("全健康应为 healthy,实际 %s", got)
|
t.Errorf("全健康应为 healthy,实际 %s", got)
|
||||||
@@ -37,4 +38,7 @@ func TestAIClassFromDetections(t *testing.T) {
|
|||||||
if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy"}, {ClassName: "sick", Confidence: 0.6}}); got != "sick" {
|
if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy"}, {ClassName: "sick", Confidence: 0.6}}); got != "sick" {
|
||||||
t.Errorf("含 sick 应为 sick,实际 %s", got)
|
t.Errorf("含 sick 应为 sick,实际 %s", got)
|
||||||
}
|
}
|
||||||
|
if got := AIClassFromDetections([]AIDetection{{ClassName: "unknown", Confidence: 0.6}}); got != "unknown" {
|
||||||
|
t.Errorf("unknown 应为 unknown,实际 %s", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,117 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "math"
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
// RiskInput 风险评分输入(各系数取值 0~1)
|
// RiskRuleVersion 当前风险规则版本;权重仍沿用试点公式,待数据校准后升版。
|
||||||
|
const RiskRuleVersion = "risk-v2-2026.08.14"
|
||||||
|
|
||||||
|
// RiskInput 风险评分输入。nil 表示未采集,不能按 0 参与归一化。
|
||||||
type RiskInput struct {
|
type RiskInput struct {
|
||||||
AI float64 // AI 识别置信度(权重 0.5)
|
AI *float64 // AI 异常概率(权重 0.5)
|
||||||
Env float64 // 环境风险系数(权重 0.2)
|
Env *float64 // 环境风险系数(权重 0.2)
|
||||||
Stage float64 // 饲养阶段风险系数(权重 0.15)
|
Stage *float64 // 饲养阶段风险系数(权重 0.15)
|
||||||
Uniformity float64 // 群体整齐度偏离度(权重 0.15)
|
Uniformity *float64 // 群体整齐度偏离度(权重 0.15)
|
||||||
|
ModelVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComputeRiskScore 按规格书 3.1.4 公式计算 0~100 风险分:
|
// RiskAssessment 可解释风险输出。
|
||||||
// 风险分 = 0.5×AI置信度 + 0.2×环境系数 + 0.15×阶段系数 + 0.15×整齐度偏离度
|
type RiskAssessment struct {
|
||||||
func ComputeRiskScore(in RiskInput) float64 {
|
Score float64 `json:"score"`
|
||||||
score := 0.5*in.AI + 0.2*in.Env + 0.15*in.Stage + 0.15*in.Uniformity
|
Level string `json:"level"`
|
||||||
score = math.Max(0, math.Min(1, score))
|
Confidence string `json:"confidence"`
|
||||||
return score * 100
|
ModelVersion string `json:"modelVersion"`
|
||||||
|
RuleVersion string `json:"ruleVersion"`
|
||||||
|
Components map[string]*float64 `json:"components"`
|
||||||
|
Missing []string `json:"missing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComputeRiskScore 按可用权重归一化计算 0~100 风险分,缺失组件返回 null。
|
||||||
|
// 当前 0.5/0.2/0.15/0.15 是待试点校准规则,不是科学结论。
|
||||||
|
func ComputeRiskScore(in RiskInput) RiskAssessment {
|
||||||
|
defs := []struct {
|
||||||
|
key string
|
||||||
|
weight float64
|
||||||
|
value *float64
|
||||||
|
}{
|
||||||
|
{"aiAbnormalProbability", 0.5, in.AI},
|
||||||
|
{"environment", 0.2, in.Env},
|
||||||
|
{"stage", 0.15, in.Stage},
|
||||||
|
{"uniformity", 0.15, in.Uniformity},
|
||||||
|
}
|
||||||
|
components := make(map[string]*float64, len(defs))
|
||||||
|
missing := make([]string, 0, len(defs))
|
||||||
|
weighted := 0.0
|
||||||
|
totalWeight := 0.0
|
||||||
|
for _, def := range defs {
|
||||||
|
if def.value == nil {
|
||||||
|
components[def.key] = nil
|
||||||
|
missing = append(missing, def.key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value := clamp01(*def.value)
|
||||||
|
components[def.key] = &value
|
||||||
|
weighted += def.weight * value
|
||||||
|
totalWeight += def.weight
|
||||||
|
}
|
||||||
|
|
||||||
|
score := 0.0
|
||||||
|
if totalWeight > 0 {
|
||||||
|
score = math.Round(clamp01(weighted/totalWeight)*10000) / 100
|
||||||
|
}
|
||||||
|
return RiskAssessment{
|
||||||
|
Score: score,
|
||||||
|
Level: RiskLevel(score),
|
||||||
|
Confidence: RiskConfidence(in),
|
||||||
|
ModelVersion: in.ModelVersion,
|
||||||
|
RuleVersion: RiskRuleVersion,
|
||||||
|
Components: components,
|
||||||
|
Missing: missing,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RiskConfidence 当前仅给出 low/medium/unknown,避免把未校准规则描述为 high。
|
||||||
|
func RiskConfidence(in RiskInput) string {
|
||||||
|
if in.AI == nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if *in.AI >= 0.25 {
|
||||||
|
return "medium"
|
||||||
|
}
|
||||||
|
return "low"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AbnormalProbability 从 AI 检测结果计算异常概率:healthy/unknown 不贡献风险。
|
||||||
|
// abnormalClasses 可选;缺省时任一非 healthy/unknown 类别都视为异常类别。
|
||||||
|
func AbnormalProbability(detections []AIDetection, abnormalClasses ...string) float64 {
|
||||||
|
configured := make(map[string]struct{}, len(abnormalClasses))
|
||||||
|
for _, class := range abnormalClasses {
|
||||||
|
if class = strings.ToLower(strings.TrimSpace(class)); class != "" {
|
||||||
|
configured[class] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best := 0.0
|
||||||
|
for _, d := range detections {
|
||||||
|
class := strings.ToLower(strings.TrimSpace(d.ClassName))
|
||||||
|
if class == "" || class == "healthy" || class == "unknown" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(configured) > 0 {
|
||||||
|
if _, ok := configured[class]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Confidence > best {
|
||||||
|
best = d.Confidence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(best)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(v float64) float64 {
|
||||||
|
return math.Max(0, math.Min(1, v))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RiskLevel 按规格书 3.1.4 分级:绿 0-30 / 黄 31-60 / 橙 61-80 / 红 81-100
|
// RiskLevel 按规格书 3.1.4 分级:绿 0-30 / 黄 31-60 / 橙 61-80 / 红 81-100
|
||||||
|
|||||||
@@ -1,30 +1,107 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func f(v float64) *float64 { return &v }
|
func ptr(v float64) *float64 { return &v }
|
||||||
|
|
||||||
func TestComputeRiskScoreWeights(t *testing.T) {
|
func TestComputeRiskScoreWeights(t *testing.T) {
|
||||||
// 全 1:0.5*1 + 0.2*1 + 0.15*1 + 0.15*1 = 1 → 100
|
got := ComputeRiskScore(RiskInput{AI: ptr(1), Env: ptr(1), Stage: ptr(1), Uniformity: ptr(1)})
|
||||||
if s := ComputeRiskScore(RiskInput{AI: 1, Env: 1, Stage: 1, Uniformity: 1}); s != 100 {
|
if got.Score != 100 {
|
||||||
t.Errorf("全 1 应得 100,实际 %.2f", s)
|
t.Errorf("全 1 应得 100,实际 %.2f", got.Score)
|
||||||
}
|
}
|
||||||
// 仅 AI 置信度 1:0.5*1 = 0.5 → 50
|
if len(got.Missing) != 0 {
|
||||||
if s := ComputeRiskScore(RiskInput{AI: 1}); s != 50 {
|
t.Errorf("全组件可用时不应有 missing,实际 %v", got.Missing)
|
||||||
t.Errorf("仅 AI=1 应得 50,实际 %.2f", s)
|
|
||||||
}
|
}
|
||||||
// 0.5*0.8 + 0.2*0.5 = 0.5
|
if got.Components["aiAbnormalProbability"] == nil || got.Components["uniformity"] == nil {
|
||||||
if s := ComputeRiskScore(RiskInput{AI: 0.8, Env: 0.5}); s != 50 {
|
t.Errorf("全组件可用时 components 不应为 null: %v", got.Components)
|
||||||
t.Errorf("0.8/0.5 应得 50,实际 %.2f", s)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeRiskScoreNormalizesMissingComponents(t *testing.T) {
|
||||||
|
got := ComputeRiskScore(RiskInput{AI: ptr(1)})
|
||||||
|
if got.Score != 100 {
|
||||||
|
t.Errorf("仅 AI=1 归一化后应得 100,实际 %.2f", got.Score)
|
||||||
|
}
|
||||||
|
if len(got.Missing) != 3 {
|
||||||
|
t.Errorf("missing 应为 environment/stage/uniformity,实际 %v", got.Missing)
|
||||||
|
}
|
||||||
|
if got.Components["environment"] != nil {
|
||||||
|
t.Error("缺失 environment 应序列化为 null")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthyHighConfidenceDoesNotIncreaseRisk(t *testing.T) {
|
||||||
|
got := AbnormalProbability([]AIDetection{{ClassName: "healthy", Confidence: .95}})
|
||||||
|
if got != 0 {
|
||||||
|
t.Fatalf("healthy 高置信度不应产生异常概率,实际 %v", got)
|
||||||
|
}
|
||||||
|
assessment := ComputeRiskScore(RiskInput{AI: ptr(got)})
|
||||||
|
if assessment.Score != 0 {
|
||||||
|
t.Fatalf("healthy 高置信度风险分应为 0,实际 %.2f", assessment.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSickHighConfidenceIncreasesRisk(t *testing.T) {
|
||||||
|
got := AbnormalProbability([]AIDetection{{ClassName: "sick", Confidence: .9}})
|
||||||
|
if got != .9 {
|
||||||
|
t.Fatalf("sick 高置信度异常概率应为 0.9,实际 %v", got)
|
||||||
|
}
|
||||||
|
assessment := ComputeRiskScore(RiskInput{AI: ptr(got)})
|
||||||
|
if assessment.Score != 90 {
|
||||||
|
t.Fatalf("仅 AI=0.9 归一化后风险分应为 90,实际 %.2f", assessment.Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAbnormalProbabilityIgnoresUnknown(t *testing.T) {
|
||||||
|
if got := AbnormalProbability([]AIDetection{{ClassName: "unknown", Confidence: .9}}); got != 0 {
|
||||||
|
t.Errorf("unknown 不应贡献异常概率,实际 %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeRiskScoreMissingAll(t *testing.T) {
|
||||||
|
got := ComputeRiskScore(RiskInput{})
|
||||||
|
if got.Score != 0 {
|
||||||
|
t.Errorf("无任何组件时分数应为 0,实际 %.2f", got.Score)
|
||||||
|
}
|
||||||
|
if got.Confidence != "unknown" {
|
||||||
|
t.Errorf("无 AI 组件时 confidence 应为 unknown,实际 %s", got.Confidence)
|
||||||
|
}
|
||||||
|
if len(got.Missing) != 4 {
|
||||||
|
t.Errorf("missing 应为 4 项,实际 %v", got.Missing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeRiskScoreClamps(t *testing.T) {
|
func TestComputeRiskScoreClamps(t *testing.T) {
|
||||||
if s := ComputeRiskScore(RiskInput{AI: 2, Env: 2, Stage: 2, Uniformity: 2}); s > 100 {
|
got := ComputeRiskScore(RiskInput{AI: ptr(2), Env: ptr(2), Stage: ptr(2), Uniformity: ptr(2)})
|
||||||
t.Errorf("应钳制到 100,实际 %.2f", s)
|
if got.Score > 100 {
|
||||||
|
t.Errorf("应钳制到 100,实际 %.2f", got.Score)
|
||||||
}
|
}
|
||||||
if s := ComputeRiskScore(RiskInput{AI: -1}); s < 0 {
|
if got := ComputeRiskScore(RiskInput{AI: ptr(-1)}).Score; got < 0 {
|
||||||
t.Errorf("应钳制到 0,实际 %.2f", s)
|
t.Errorf("应钳制到 0,实际 %.2f", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRiskConfidenceLowMediumUnknown(t *testing.T) {
|
||||||
|
if got := RiskConfidence(RiskInput{}); got != "unknown" {
|
||||||
|
t.Errorf("无 AI 应为 unknown,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := RiskConfidence(RiskInput{AI: ptr(0.2)}); got != "low" {
|
||||||
|
t.Errorf("低异常概率应为 low,实际 %s", got)
|
||||||
|
}
|
||||||
|
if got := RiskConfidence(RiskInput{AI: ptr(0.9)}); got != "medium" {
|
||||||
|
t.Errorf("高异常概率当前最多应为 medium,实际 %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeRiskScoreBoundaryUsesWeightNormalization(t *testing.T) {
|
||||||
|
// 0.5*0.8 + 0.2*0.5 的可用权重为 0.7:0.5/0.7 = 71.43
|
||||||
|
got := ComputeRiskScore(RiskInput{AI: ptr(0.8), Env: ptr(0.5)})
|
||||||
|
want := 50.0 / 0.7
|
||||||
|
if math.Abs(got.Score-want) > 0.01 {
|
||||||
|
t.Errorf("归一化应得 %.2f,实际 %.2f", want, got.Score)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,19 +140,19 @@ func TestStageCoefficientMapping(t *testing.T) {
|
|||||||
|
|
||||||
func TestEnvCoefficientRules(t *testing.T) {
|
func TestEnvCoefficientRules(t *testing.T) {
|
||||||
// 湿度 >=80 → 高(真菌病)
|
// 湿度 >=80 → 高(真菌病)
|
||||||
if c := EnvCoefficient(f(25), f(85)); c < 0.7 {
|
if c := EnvCoefficient(ptr(25), ptr(85)); c < 0.7 {
|
||||||
t.Errorf("湿度 85 应 ≥0.7,实际 %.2f", c)
|
t.Errorf("湿度 85 应 ≥0.7,实际 %.2f", c)
|
||||||
}
|
}
|
||||||
// 湿度 75-80 → 中
|
// 湿度 75-80 → 中
|
||||||
if c := EnvCoefficient(f(25), f(78)); c < 0.3 {
|
if c := EnvCoefficient(ptr(25), ptr(78)); c < 0.3 {
|
||||||
t.Errorf("湿度 78 应 ≥0.3,实际 %.2f", c)
|
t.Errorf("湿度 78 应 ≥0.3,实际 %.2f", c)
|
||||||
}
|
}
|
||||||
// 温度突变 >30 → 中(核型多角体病诱发)
|
// 温度突变 >30 → 中(核型多角体病诱发)
|
||||||
if c := EnvCoefficient(f(32), f(60)); c < 0.3 {
|
if c := EnvCoefficient(ptr(32), ptr(60)); c < 0.3 {
|
||||||
t.Errorf("温度 32 应 ≥0.3,实际 %.2f", c)
|
t.Errorf("温度 32 应 ≥0.3,实际 %.2f", c)
|
||||||
}
|
}
|
||||||
// 舒适环境 → 0
|
// 舒适环境 → 0
|
||||||
if c := EnvCoefficient(f(25), f(60)); c != 0 {
|
if c := EnvCoefficient(ptr(25), ptr(60)); c != 0 {
|
||||||
t.Errorf("舒适环境应为 0,实际 %.2f", c)
|
t.Errorf("舒适环境应为 0,实际 %.2f", c)
|
||||||
}
|
}
|
||||||
// 缺数据 → 0
|
// 缺数据 → 0
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_inspection_records_mock_created_at;
|
||||||
|
|
||||||
|
ALTER TABLE inspection_records
|
||||||
|
DROP COLUMN IF EXISTS is_mock,
|
||||||
|
DROP COLUMN IF EXISTS model_version,
|
||||||
|
DROP COLUMN IF EXISTS risk_assessment;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
ALTER TABLE inspection_records
|
||||||
|
ADD COLUMN IF NOT EXISTS risk_assessment jsonb,
|
||||||
|
ADD COLUMN IF NOT EXISTS model_version varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS is_mock boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_inspection_records_mock_created_at
|
||||||
|
ON inspection_records (is_mock, created_at);
|
||||||
@@ -15,6 +15,17 @@ export interface InspectionRecord {
|
|||||||
detections?: AIDetection[];
|
detections?: AIDetection[];
|
||||||
riskScore?: number;
|
riskScore?: number;
|
||||||
riskLevel?: string;
|
riskLevel?: string;
|
||||||
|
riskAssessment?: {
|
||||||
|
score: number;
|
||||||
|
level: string;
|
||||||
|
confidence: string;
|
||||||
|
modelVersion: string;
|
||||||
|
ruleVersion: string;
|
||||||
|
components: Record<string, number | null>;
|
||||||
|
missing: string[];
|
||||||
|
};
|
||||||
|
modelVersion?: string;
|
||||||
|
isMock?: boolean;
|
||||||
aiStatus: string;
|
aiStatus: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,17 @@ export default function InspectionsPage() {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (_, r) => (r.imageUrl ? <Image src={r.imageUrl} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '-'),
|
render: (_, r) => (r.imageUrl ? <Image src={r.imageUrl} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '-'),
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'aiStatus', search: false, render: (_, r) => (r.aiStatus === 'done' ? <Tag color="green">成功</Tag> : <Tag color="red">失败</Tag>) },
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'aiStatus',
|
||||||
|
search: false,
|
||||||
|
render: (_, r) => (
|
||||||
|
<>
|
||||||
|
{r.aiStatus === 'done' ? <Tag color="green">成功</Tag> : <Tag color="red">失败</Tag>}
|
||||||
|
{r.isMock ? <Tag color="orange">Mock</Tag> : null}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
valueType: 'option',
|
valueType: 'option',
|
||||||
@@ -101,6 +111,8 @@ export default function InspectionsPage() {
|
|||||||
<br />
|
<br />
|
||||||
状态:{detail.aiStatus === 'done' ? '检测成功' : '检测失败'}
|
状态:{detail.aiStatus === 'done' ? '检测成功' : '检测失败'}
|
||||||
<br />
|
<br />
|
||||||
|
数据:{detail.isMock ? '联调 Mock,不计入生产统计' : detail.modelVersion || '未知模型'}
|
||||||
|
<br />
|
||||||
风险分:{detail.riskScore !== undefined ? Math.round(detail.riskScore) : '-'}(
|
风险分:{detail.riskScore !== undefined ? Math.round(detail.riskScore) : '-'}(
|
||||||
{detail.riskLevel ? riskLevelLabel(detail.riskLevel) : '-'})
|
{detail.riskLevel ? riskLevelLabel(detail.riskLevel) : '-'})
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后续工作计划
|
# 后续工作计划
|
||||||
|
|
||||||
> **完成状态(2026-08-13 更新)**:#5-#24、#27 已完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;微信/天气真实数据待凭证。
|
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;微信/天气真实数据待凭证。
|
||||||
|
|
||||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
| Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 延后到最后(跳过) | 用户 2026-08-13 明确要求跳过并留到最后 |
|
| Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 延后到最后(跳过) | 用户 2026-08-13 明确要求跳过并留到最后 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 部分可用 | 待开发服务器部署联调 |
|
| Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 部分可用 | 待开发服务器部署联调 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 部分可用 | 待开发服务器部署与真实 WS/AI 联调 |
|
| Wave 1 | P0 安全与正确性 | Task 5 修复 WebSocket 越权与 AI 流 SSRF | 部分可用 | 待开发服务器部署与真实 WS/AI 联调 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 未开始 | 无 |
|
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 未开始 | 需领域专家确认 |
|
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 未开始 | 需领域专家确认 |
|
||||||
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 未开始 | 无 |
|
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 未开始 | 无 |
|
||||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
|
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
|
||||||
|
|||||||
@@ -921,3 +921,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
|||||||
|
|
||||||
- 本任务前分支提交为 `1a29def`;回滚可还原 Task 5 提交;
|
- 本任务前分支提交为 `1a29def`;回滚可还原 Task 5 提交;
|
||||||
- WS/AI 改动无数据库 schema 变更;若已部署,恢复旧二进制并重启即可,但需同步回滚客户端 WS 连接方式。
|
- WS/AI 改动无数据库 schema 变更;若已部署,恢复旧二进制并重启即可,但需同步回滚客户端 WS 连接方式。
|
||||||
|
|
||||||
|
## 2026-08-14 整改 Task 6:修复 AI 风险语义并隔离 Mock 数据
|
||||||
|
|
||||||
|
### 做了什么
|
||||||
|
|
||||||
|
- AI 服务 `/detect` 响应新增 `modelVersion`、`isMock`、`status`、`abnormalProbability`;`healthy` 高置信度不再贡献异常概率,空检测或 `unknown` 返回 `unknown`,不自动当健康;
|
||||||
|
- Go 风险评分改为只消费 AI 异常概率:`RiskInput` 四个组件全部可空,缺失项不填 0;可用权重归一化后输出 `RiskAssessment`(score/level/confidence/modelVersion/ruleVersion/components/missing);
|
||||||
|
- 巡检记录新增 `risk_assessment`、`model_version`、`is_mock` 字段和 `000002_risk_assessment` 迁移;健康画像统计默认排除 mock,Mock 结果不触发微信告警;
|
||||||
|
- `APP_ENV=production` 时收到 `isMock=true` 按失败记录并输出 error 日志,避免把联调数据当作生产检测结论;
|
||||||
|
- Web/小程序巡检页显示 Mock 标识;交叉验证中空检测/`unknown` 不再被当作 healthy;
|
||||||
|
- 新增只读历史评估 SQL `scripts/risk_historical_review.sql`,统计历史 healthy 高置信度记录与风险分布,未执行批量重算。
|
||||||
|
|
||||||
|
### 设计思路与决策依据
|
||||||
|
|
||||||
|
- 规格书 AI-INS-002 要求只用 `abnormalProbability` 计算 AI 风险,因此修复了原先取最大 confidence 导致 healthy=0.95 也能得 47.5 分的问题;
|
||||||
|
- RISK-002 要求缺失数据不能以 0 冒充正常,所以环境/阶段/整齐度改为指针输入,缺失项进入 `missing` 并在 JSON 中返回 null;
|
||||||
|
- 权重沿用 0.5/0.2/0.15/0.15 并标记为 `risk-v2-2026.08.14` 待试点校准规则;`confidence` 只输出 low/medium/unknown,不声称未校准结论为 high;
|
||||||
|
- `000007_inspection_idempotency` 未另建迁移,因为 `000001_baseline` 已包含 `idempotency_key` 唯一索引;本次只补风险语义相关字段。
|
||||||
|
- 历史旧记录没有 `is_mock` 标识,不能自动判别是否来自 mock;只读 SQL 报告用于人工审阅,未批量重算或改写旧数据。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
|
||||||
|
- 新增测试覆盖 healthy/sick/unknown 异常概率、缺失组件归一化、风险分级边界、AI 响应解析、空检测 unknown、handler 风险输入和迁移版本;
|
||||||
|
- 未部署开发服务器,未对现有库执行 `000002` 迁移;历史只读 SQL 报告未执行。
|
||||||
|
|
||||||
|
### 回滚点
|
||||||
|
|
||||||
|
- 本任务前分支提交为 `839ba91`;回滚可还原 Task 6 提交;
|
||||||
|
- 数据库回滚执行 `migrate -path ... -database ... down 1` 或手工执行 `000002_risk_assessment.down.sql`,可移除新增三列和索引;`risk_score/risk_level` 仍保留,历史数据不回写。
|
||||||
|
|||||||
Reference in New Issue
Block a user