feat: 修复 AI 风险语义并隔离 Mock 数据

This commit is contained in:
weijuesen
2026-08-14 01:16:50 +08:00
parent 839ba91354
commit 74d1948d68
29 changed files with 650 additions and 113 deletions
+4 -4
View File
@@ -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 开发服务器部署摘要
+4 -2
View File
@@ -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 后端计算(#90.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。 - 只做检测并返回 `abnormalProbability`风险评分在 Go 后端计算(#90.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。 - ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
+1
View File
@@ -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"))
+46
View File
@@ -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
View File
@@ -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),
+30
View File
@@ -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
+20 -1
View File
@@ -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"
+2
View File
@@ -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>
+11
View File
@@ -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;
+27
View File
@@ -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;
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
}
} }
+1 -1
View File
@@ -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{}
+61 -28
View File
@@ -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
} }
+35 -1
View File
@@ -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)
}
}
+3
View File
@@ -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"`
+28
View File
@@ -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)
+18 -3
View File
@@ -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)
}
} }
+108 -12
View File
@@ -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
+96 -19
View File
@@ -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) {
// 全 10.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 置信度 10.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.70.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);
+11
View File
@@ -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;
} }
+13 -1
View File
@@ -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>
+2 -2
View File
@@ -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-42026-08-13 启动) ## 整改实施计划 Wave 0-42026-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 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
+30
View File
@@ -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 0Go 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` 仍保留,历史数据不回写。