From 74d1948d68c415fbc0ca42371d57645ece6ac2f5 Mon Sep 17 00:00:00 2001 From: weijuesen Date: Fri, 14 Aug 2026 01:16:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20AI=20=E9=A3=8E?= =?UTF-8?q?=E9=99=A9=E8=AF=AD=E4=B9=89=E5=B9=B6=E9=9A=94=E7=A6=BB=20Mock?= =?UTF-8?q?=20=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- ai-service/README.md | 6 +- ai-service/app/config.py | 1 + ai-service/app/detector.py | 46 ++++++ ai-service/app/main.py | 15 +- ai-service/tests/test_api.py | 30 ++++ ai-service/tests/test_detector.py | 21 ++- miniapp/src/pages/inspection/index.tsx | 2 + miniapp/src/types/index.ts | 11 ++ scripts/risk_historical_review.sql | 27 ++++ server-go/cmd/server/main.go | 2 +- server-go/internal/database/migrate.go | 4 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/health.go | 2 +- server-go/internal/handler/inspection.go | 131 +++++++++++------- server-go/internal/handler/inspection_test.go | 36 ++++- server-go/internal/model/inspection.go | 3 + server-go/internal/service/ai_client.go | 32 ++++- server-go/internal/service/ai_client_test.go | 30 +++- server-go/internal/service/cross_validate.go | 23 ++- .../internal/service/cross_validate_test.go | 8 +- server-go/internal/service/risk.go | 120 ++++++++++++++-- server-go/internal/service/risk_test.go | 129 +++++++++++++---- .../000002_risk_assessment.down.sql | 6 + .../migrations/000002_risk_assessment.up.sql | 7 + web/src/dal/inspections.ts | 11 ++ web/src/pages/Inspections.tsx | 14 +- 后续工作计划.md | 4 +- 开发交接记录.md | 30 ++++ 29 files changed, 650 insertions(+), 113 deletions(-) create mode 100644 scripts/risk_historical_review.sql create mode 100644 server-go/migrations/000002_risk_assessment.down.sql create mode 100644 server-go/migrations/000002_risk_assessment.up.sql diff --git a/README.md b/README.md index 1d3ad3b..a7c34e2 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ ### 1.9 AI 拍照巡检闭环(计划 #5/#6/#8/#9) -- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度,`POST /stream-detect` 拉流抽帧骨架,`GET /metrics` 监控指标) -- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等) +- `ai-service/`:FastAPI + ONNX Runtime(默认 mock 模式;`POST /detect` 返回框/类别/置信度、`modelVersion`、`isMock`、`abnormalProbability`,`POST /internal/stream-tasks` 受限拉流任务,`GET /metrics` 监控指标) +- 拍照上传 → AI 检测 → 风险评分(0-100 分,绿/黄/橙/红四级,只消费 AI 异常概率,缺失项不按 0 参与)→ 巡检记录(`inspection_records`,`Idempotency-Key` 幂等) - 小程序「拍照巡检」页;Web「巡检记录」页(技术员复查) ### 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 ``` -- 接口:`GET /health`、`POST /detect`(multipart 图片)、`POST /stream-detect`(摄像头流拉帧骨架)、`GET /metrics`(含 GPU 信息) -- 默认 mock 模式;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别) +- 接口:`GET /health`、`POST /detect`(multipart 图片,返回 `abnormalProbability`)、`POST /internal/stream-tasks`(受限摄像头流拉帧任务)、`GET /metrics`(含 GPU 信息) +- 默认 mock 模式且响应带 `isMock=true`;训练恢复后放 `models/best.onnx` 并设 `MODEL_MODE=onnx`(`MODEL_LABELS` 可配类别、`MODEL_VERSION` 必须显式配置) - 开发服务器部署:`/home/pan/ai-service`(venv + `start.sh`,:8000),服务器 pip 源已配置清华镜像 ### 12.7 开发服务器部署摘要 diff --git a/ai-service/README.md b/ai-service/README.md index 89489e1..a3f02ed 100644 --- a/ai-service/README.md +++ b/ai-service/README.md @@ -5,7 +5,7 @@ FastAPI + ONNX Runtime 的蚕病检测推理服务。YOLO 训练挂起期间以 ## 接口 - `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 ## 本地运行 @@ -33,6 +33,7 @@ powershell -ExecutionPolicy Bypass -File scripts/verify.ps1 | `MODEL_MODE` | `mock` | `mock` / `onnx` | | `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 | | `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) | +| `MODEL_VERSION` | `mock-2026.08.14` / `best.onnx` | 模型版本;真实模型上线时必须显式配置 | | `MOCK_CLASS` | `healthy` | mock 返回类别 | | `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 | | `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),训练产物出来后需用真实模型校准验证。 diff --git a/ai-service/app/config.py b/ai-service/app/config.py index 825b9e3..17c019a 100644 --- a/ai-service/app/config.py +++ b/ai-service/app/config.py @@ -5,6 +5,7 @@ MODEL_MODE = os.getenv("MODEL_MODE", "mock") MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx") # YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整) 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_CLASS = os.getenv("MOCK_CLASS", "healthy") MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95")) diff --git a/ai-service/app/detector.py b/ai-service/app/detector.py index 8095515..9512718 100644 --- a/ai-service/app/detector.py +++ b/ai-service/app/detector.py @@ -5,6 +5,52 @@ from abc import ABC, abstractmethod 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): @abstractmethod def detect(self, image_bytes: bytes) -> list[dict]: diff --git a/ai-service/app/main.py b/ai-service/app/main.py index dc006a9..7625a9f 100644 --- a/ai-service/app/main.py +++ b/ai-service/app/main.py @@ -6,7 +6,7 @@ import time from fastapi import FastAPI, File, HTTPException, Request, UploadFile 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 app = FastAPI(title="Silk AI Service", version="0.1.0") @@ -36,7 +36,12 @@ else: @app.get("/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") @@ -50,6 +55,10 @@ async def detect(file: UploadFile = File(...)): raise HTTPException(status_code=400, detail=str(exc)) from exc return { "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": [ { "bbox": d["bbox"], @@ -112,6 +121,8 @@ def metrics(): gpu = None return { "model": config.MODEL_MODE, + "modelVersion": config.MODEL_VERSION, + "isMock": isinstance(detector, MockDetector), "uptimeSeconds": int(time.time() - START_TIME), "requests": reqs, "avgLatencyMs": round(avg, 2), diff --git a/ai-service/tests/test_api.py b/ai-service/tests/test_api.py index 2173990..6be53d2 100644 --- a/ai-service/tests/test_api.py +++ b/ai-service/tests/test_api.py @@ -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 app import main as main_module from app.main import app +from app.detector import MockDetector # 1x1 透明 PNG TINY_PNG = base64.b64decode( @@ -22,6 +24,8 @@ def test_health(): body = r.json() assert body["status"] == "ok" assert body["model"] in ("mock", "onnx") + assert body["modelVersion"] + assert body["isMock"] is True def test_detect_ok(): @@ -29,6 +33,10 @@ def test_detect_ok(): assert r.status_code == 200 body = r.json() 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 d = body["detections"][0] assert d["class"] in ("healthy", "sick") @@ -36,6 +44,28 @@ def test_detect_ok(): 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(): r = client.post("/detect", files={"file": ("a.png", b"", "image/png")}) assert r.status_code == 400 diff --git a/ai-service/tests/test_detector.py b/ai-service/tests/test_detector.py index 756203a..b6c4ed3 100644 --- a/ai-service/tests/test_detector.py +++ b/ai-service/tests/test_detector.py @@ -2,7 +2,7 @@ import base64 import pytest -from app.detector import MockDetector +from app.detector import MockDetector, abnormal_probability, detection_status # 1x1 透明 PNG TINY_PNG = base64.b64decode( @@ -23,3 +23,22 @@ def test_mock_detector_rejects_invalid_image(): det = MockDetector() with pytest.raises(ValueError): 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" diff --git a/miniapp/src/pages/inspection/index.tsx b/miniapp/src/pages/inspection/index.tsx index 2ee0137..f35fc48 100644 --- a/miniapp/src/pages/inspection/index.tsx +++ b/miniapp/src/pages/inspection/index.tsx @@ -113,6 +113,7 @@ const InspectionPage: React.FC = () => { ? '检测到疑似异常' : '未见明显异常' : 'AI 检测失败'} + {result.isMock ? '(联调 Mock)' : ''} {result.aiStatus === 'done' && result.detections && result.detections.length > 0 ? ( @@ -145,6 +146,7 @@ const InspectionPage: React.FC = () => { ? '疑似异常' : '正常' : '检测失败'} + {rec.isMock ? '(Mock)' : ''} {formatRelativeTime(rec.createdAt)} diff --git a/miniapp/src/types/index.ts b/miniapp/src/types/index.ts index 82db964..d319e74 100644 --- a/miniapp/src/types/index.ts +++ b/miniapp/src/types/index.ts @@ -223,6 +223,17 @@ export interface InspectionRecord { detections?: AIDetection[]; riskScore?: number; riskLevel?: string; + riskAssessment?: { + score: number; + level: string; + confidence: string; + modelVersion: string; + ruleVersion: string; + components: Record; + missing: string[]; + }; + modelVersion?: string; + isMock?: boolean; aiStatus: string; idempotencyKey?: string; createdAt?: string; diff --git a/scripts/risk_historical_review.sql b/scripts/risk_historical_review.sql new file mode 100644 index 0000000..aa0a8c1 --- /dev/null +++ b/scripts/risk_historical_review.sql @@ -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; diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 8467ba7..04a13d6 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -111,7 +111,7 @@ func main() { handler.RegisterVideoRecordRoutes(api, db, mediaSvc, cfg) handler.RegisterStorageRoutes(api, db) 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.RegisterWechatRoutes(api, db, wechatSvc) handler.RegisterWeatherRoutes(api, db, weatherSvc) diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index e01df10..529eff2 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -8,12 +8,12 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database/postgres" "github.com/golang-migrate/migrate/v4/source/iofs" - "silk-server-go/migrations" "gorm.io/gorm" + "silk-server-go/migrations" ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "1" +const CurrentSchemaVersion = "2" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index 00fc19b..b3b6720 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -100,4 +100,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if version != 1 { 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) + } } diff --git a/server-go/internal/handler/health.go b/server-go/internal/handler/health.go index d5f3396..19c0a80 100644 --- a/server-go/internal/handler/health.go +++ b/server-go/internal/handler/health.go @@ -44,7 +44,7 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc { } db.Table("inspection_records"). 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"). Scan(&riskRows) riskCounts := map[string]int64{} diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index f89e0c8..5c5c04f 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "io" + "log/slog" "net/http" "regexp" "strconv" @@ -26,8 +27,8 @@ func isUUID(s string) bool { } // RegisterInspectionRoutes 注册 AI 巡检路由 -func RegisterInspectionRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, imageBucket string, wechat *service.WechatService, inspectionTemplateID string) { - rg.POST("/inspections", middleware.RequirePermission(db, "inspection:create"), createInspection(db, s3, ai, imageBucket, wechat, inspectionTemplateID)) +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, appEnv)) rg.GET("/inspections", middleware.RequirePermission(db, "inspection:read"), listInspections(db)) } @@ -43,9 +44,29 @@ func currentUserID(c *gin.Context) *string { 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 → 写记录。 // 幂等:客户端传 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) { idemKey := strings.TrimSpace(c.GetHeader("Idempotency-Key")) roomID := strings.TrimSpace(c.PostForm("roomId")) @@ -113,49 +134,55 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, } else { raw, _ := json.Marshal(detRes.Detections) rec.Detections = raw - - // 风险评分(#9):AI 置信度取检测结果最大值;环境/阶段系数在有 roomId 时按房间数据计算 - aiConf := 0.0 - for _, d := range detRes.Detections { - if d.Confidence > aiConf { - aiConf = d.Confidence - } + isMock := detRes.IsMock + rec.IsMock = &isMock + modelVersion := detRes.ModelVersion + if modelVersion == "" { + modelVersion = "unknown" } - 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 + rec.ModelVersion = &modelVersion - // 微信订阅消息(#11 骨架):风险非绿且用户已授权时异步推送 - if key := service.WechatTemplateKey(level); key != "" { - go func(uid *string, lv string, sc float64) { - if uid == nil || !wechat.Configured() || inspectionTemplateID == "" { - return + if appEnv == "production" && isMock { + 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) { + if uid == nil || !wechat.Configured() || inspectionTemplateID == "" { + return + } + var binding model.WechatBinding + if db.Where("user_id = ?", *uid).First(&binding).Error != nil { + return + } + var authorized []string + if len(binding.AuthorizedTemplates) > 0 { + _ = json.Unmarshal(binding.AuthorizedTemplates, &authorized) + } + if !service.IsAuthorized(authorized, key) { + return + } + _ = wechat.SendSubscribe( + context.Background(), + binding.OpenID, + inspectionTemplateID, + service.BuildSubscribeData(lv, sc), + "pages/inspection/index", + ) + }(rec.UserID, assessment.Level, assessment.Score) } - var binding model.WechatBinding - if db.Where("user_id = ?", *uid).First(&binding).Error != nil { - return - } - var authorized []string - if len(binding.AuthorizedTemplates) > 0 { - _ = json.Unmarshal(binding.AuthorizedTemplates, &authorized) - } - if !service.IsAuthorized(authorized, key) { - return - } - _ = wechat.SendSubscribe( - context.Background(), - binding.OpenID, - inspectionTemplateID, - service.BuildSubscribeData(lv, sc), - "pages/inspection/index", - ) - }(rec.UserID, level, score) + } } } @@ -175,17 +202,19 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, } } -// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 0) -func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) { +// loadRoomRisk 加载房间阶段系数与环境系数(无房间/无数据时返回 nil) +func loadRoomRisk(db *gorm.DB, roomID string) (*float64, *float64) { if roomID == "" { - return 0, 0 + return nil, nil } var room model.Room if db.Where("id = ?", roomID).First(&room).Error != nil { - return 0, 0 + return nil, nil } + var stageCoef *float64 if room.Stage != nil { - stageCoef = service.StageCoefficient(*room.Stage) + value := service.StageCoefficient(*room.Stage) + stageCoef = &value } var humidity, temperature *float64 @@ -209,7 +238,11 @@ func loadRoomRisk(db *gorm.DB, roomID string) (stageCoef, envCoef float64) { First(&t).Error; err == nil { 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 } diff --git a/server-go/internal/handler/inspection_test.go b/server-go/internal/handler/inspection_test.go index fd6a3e7..92cff1e 100644 --- a/server-go/internal/handler/inspection_test.go +++ b/server-go/internal/handler/inspection_test.go @@ -1,6 +1,10 @@ package handler -import "testing" +import ( + "testing" + + "silk-server-go/internal/service" +) func TestIsUUID(t *testing.T) { 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) + } +} diff --git a/server-go/internal/model/inspection.go b/server-go/internal/model/inspection.go index 936708d..0c943be 100644 --- a/server-go/internal/model/inspection.go +++ b/server-go/internal/model/inspection.go @@ -14,6 +14,9 @@ type InspectionRecord struct { Detections json.RawMessage `gorm:"column:detections;type:jsonb" json:"detections,omitempty"` RiskScore *float64 `gorm:"column:risk_score;type:float" json:"riskScore,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"` IdempotencyKey *string `gorm:"column:idempotency_key;size:128;uniqueIndex" json:"idempotencyKey,omitempty"` CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` diff --git a/server-go/internal/service/ai_client.go b/server-go/internal/service/ai_client.go index 4c4a8b8..53628f2 100644 --- a/server-go/internal/service/ai_client.go +++ b/server-go/internal/service/ai_client.go @@ -29,8 +29,36 @@ type AIDetection struct { // AIDetectResponse /detect 响应 type AIDetectResponse struct { - Model string `json:"model"` - Detections []AIDetection `json:"detections"` + Model string `json:"model"` + ModelVersion string `json:"modelVersion"` + IsMock bool `json:"isMock"` + Status string `json:"status"` + AbnormalProbability float64 `json:"abnormalProbability"` + 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 客户端 diff --git a/server-go/internal/service/ai_client_test.go b/server-go/internal/service/ai_client_test.go index b80c949..745fcff 100644 --- a/server-go/internal/service/ai_client_test.go +++ b/server-go/internal/service/ai_client_test.go @@ -24,7 +24,11 @@ func TestAIClientDetectParsesResult(t *testing.T) { } w.Header().Set("Content-Type", "application/json") _ = 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{ {"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" { 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 { 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) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) diff --git a/server-go/internal/service/cross_validate.go b/server-go/internal/service/cross_validate.go index 9180c00..c595a59 100644 --- a/server-go/internal/service/cross_validate.go +++ b/server-go/internal/service/cross_validate.go @@ -1,5 +1,7 @@ package service +import "strings" + // CrossValidate 交叉验证:AI 检测结果 vs LAMP 检测结果(规格书:一致→确认诊断;不一致→升级专家会诊) func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, string) { switch { @@ -7,6 +9,8 @@ func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, str return false, "LAMP 判读无效,建议复检或专家会诊" case aiClass == "": return false, "未找到关联巡检记录,暂无法交叉验证" + case aiClass == "unknown": + return false, "AI 结果未知,建议复检或专家会诊" case aiClass == "sick" && lampResult == "positive": return true, "AI 检出异常与 LAMP 阳性一致,确认诊断" 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 { if len(detections) == 0 { - return "" + return "unknown" } + healthySeen := false + unknownSeen := false 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 "healthy" + if healthySeen && !unknownSeen { + return "healthy" + } + return "unknown" } diff --git a/server-go/internal/service/cross_validate_test.go b/server-go/internal/service/cross_validate_test.go index adfaba1..0d62e53 100644 --- a/server-go/internal/service/cross_validate_test.go +++ b/server-go/internal/service/cross_validate_test.go @@ -13,6 +13,7 @@ func TestCrossValidate(t *testing.T) { {"AI健康+LAMP阴性 一致", "healthy", "negative", true}, {"AI异常+LAMP阴性 不一致", "sick", "negative", false}, {"AI健康+LAMP阳性 不一致", "healthy", "positive", false}, + {"AI未知+LAMP阳性 不一致", "unknown", "positive", false}, {"LAMP无效 不一致", "sick", "invalid", false}, {"无AI记录 不一致", "", "positive", false}, } @@ -28,8 +29,8 @@ func TestCrossValidate(t *testing.T) { } func TestAIClassFromDetections(t *testing.T) { - if got := AIClassFromDetections(nil); got != "" { - t.Errorf("空检测应为空,实际 %s", got) + if got := AIClassFromDetections(nil); got != "unknown" { + t.Errorf("空检测应为 unknown,实际 %s", got) } if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy", Confidence: 0.9}}); got != "healthy" { 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" { t.Errorf("含 sick 应为 sick,实际 %s", got) } + if got := AIClassFromDetections([]AIDetection{{ClassName: "unknown", Confidence: 0.6}}); got != "unknown" { + t.Errorf("unknown 应为 unknown,实际 %s", got) + } } diff --git a/server-go/internal/service/risk.go b/server-go/internal/service/risk.go index 5b054f9..1d322f6 100644 --- a/server-go/internal/service/risk.go +++ b/server-go/internal/service/risk.go @@ -1,21 +1,117 @@ package service -import "math" +import ( + "math" + "strings" +) -// RiskInput 风险评分输入(各系数取值 0~1) +// RiskRuleVersion 当前风险规则版本;权重仍沿用试点公式,待数据校准后升版。 +const RiskRuleVersion = "risk-v2-2026.08.14" + +// RiskInput 风险评分输入。nil 表示未采集,不能按 0 参与归一化。 type RiskInput struct { - AI float64 // AI 识别置信度(权重 0.5) - Env float64 // 环境风险系数(权重 0.2) - Stage float64 // 饲养阶段风险系数(权重 0.15) - Uniformity float64 // 群体整齐度偏离度(权重 0.15) + AI *float64 // AI 异常概率(权重 0.5) + Env *float64 // 环境风险系数(权重 0.2) + Stage *float64 // 饲养阶段风险系数(权重 0.15) + Uniformity *float64 // 群体整齐度偏离度(权重 0.15) + ModelVersion string } -// ComputeRiskScore 按规格书 3.1.4 公式计算 0~100 风险分: -// 风险分 = 0.5×AI置信度 + 0.2×环境系数 + 0.15×阶段系数 + 0.15×整齐度偏离度 -func ComputeRiskScore(in RiskInput) float64 { - score := 0.5*in.AI + 0.2*in.Env + 0.15*in.Stage + 0.15*in.Uniformity - score = math.Max(0, math.Min(1, score)) - return score * 100 +// RiskAssessment 可解释风险输出。 +type RiskAssessment struct { + Score float64 `json:"score"` + Level string `json:"level"` + Confidence string `json:"confidence"` + 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 diff --git a/server-go/internal/service/risk_test.go b/server-go/internal/service/risk_test.go index 0fa67bd..4124a6e 100644 --- a/server-go/internal/service/risk_test.go +++ b/server-go/internal/service/risk_test.go @@ -1,43 +1,120 @@ 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) { - // 全 1:0.5*1 + 0.2*1 + 0.15*1 + 0.15*1 = 1 → 100 - if s := ComputeRiskScore(RiskInput{AI: 1, Env: 1, Stage: 1, Uniformity: 1}); s != 100 { - t.Errorf("全 1 应得 100,实际 %.2f", s) + got := ComputeRiskScore(RiskInput{AI: ptr(1), Env: ptr(1), Stage: ptr(1), Uniformity: ptr(1)}) + if got.Score != 100 { + t.Errorf("全 1 应得 100,实际 %.2f", got.Score) } - // 仅 AI 置信度 1:0.5*1 = 0.5 → 50 - if s := ComputeRiskScore(RiskInput{AI: 1}); s != 50 { - t.Errorf("仅 AI=1 应得 50,实际 %.2f", s) + if len(got.Missing) != 0 { + t.Errorf("全组件可用时不应有 missing,实际 %v", got.Missing) } - // 0.5*0.8 + 0.2*0.5 = 0.5 - if s := ComputeRiskScore(RiskInput{AI: 0.8, Env: 0.5}); s != 50 { - t.Errorf("0.8/0.5 应得 50,实际 %.2f", s) + if got.Components["aiAbnormalProbability"] == nil || got.Components["uniformity"] == nil { + t.Errorf("全组件可用时 components 不应为 null: %v", got.Components) + } +} + +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) { - if s := ComputeRiskScore(RiskInput{AI: 2, Env: 2, Stage: 2, Uniformity: 2}); s > 100 { - t.Errorf("应钳制到 100,实际 %.2f", s) + got := ComputeRiskScore(RiskInput{AI: ptr(2), Env: ptr(2), Stage: ptr(2), Uniformity: ptr(2)}) + if got.Score > 100 { + t.Errorf("应钳制到 100,实际 %.2f", got.Score) } - if s := ComputeRiskScore(RiskInput{AI: -1}); s < 0 { - t.Errorf("应钳制到 0,实际 %.2f", s) + if got := ComputeRiskScore(RiskInput{AI: ptr(-1)}).Score; got < 0 { + 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) } } func TestRiskLevelBoundaries(t *testing.T) { cases := map[float64]string{ - 0: "green", - 30: "green", + 0: "green", + 30: "green", 30.5: "yellow", - 60: "yellow", - 61: "orange", - 80: "orange", - 81: "red", - 100: "red", + 60: "yellow", + 61: "orange", + 80: "orange", + 81: "red", + 100: "red", } for score, want := range cases { if got := RiskLevel(score); got != want { @@ -63,19 +140,19 @@ func TestStageCoefficientMapping(t *testing.T) { func TestEnvCoefficientRules(t *testing.T) { // 湿度 >=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) } // 湿度 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) } // 温度突变 >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) } // 舒适环境 → 0 - if c := EnvCoefficient(f(25), f(60)); c != 0 { + if c := EnvCoefficient(ptr(25), ptr(60)); c != 0 { t.Errorf("舒适环境应为 0,实际 %.2f", c) } // 缺数据 → 0 diff --git a/server-go/migrations/000002_risk_assessment.down.sql b/server-go/migrations/000002_risk_assessment.down.sql new file mode 100644 index 0000000..508977e --- /dev/null +++ b/server-go/migrations/000002_risk_assessment.down.sql @@ -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; diff --git a/server-go/migrations/000002_risk_assessment.up.sql b/server-go/migrations/000002_risk_assessment.up.sql new file mode 100644 index 0000000..bd17fdf --- /dev/null +++ b/server-go/migrations/000002_risk_assessment.up.sql @@ -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); diff --git a/web/src/dal/inspections.ts b/web/src/dal/inspections.ts index c2064fd..9cbb6af 100644 --- a/web/src/dal/inspections.ts +++ b/web/src/dal/inspections.ts @@ -15,6 +15,17 @@ export interface InspectionRecord { detections?: AIDetection[]; riskScore?: number; riskLevel?: string; + riskAssessment?: { + score: number; + level: string; + confidence: string; + modelVersion: string; + ruleVersion: string; + components: Record; + missing: string[]; + }; + modelVersion?: string; + isMock?: boolean; aiStatus: string; createdAt?: string; } diff --git a/web/src/pages/Inspections.tsx b/web/src/pages/Inspections.tsx index 1cb5aaf..88aba8d 100644 --- a/web/src/pages/Inspections.tsx +++ b/web/src/pages/Inspections.tsx @@ -60,7 +60,17 @@ export default function InspectionsPage() { width: 80, render: (_, r) => (r.imageUrl ? : '-'), }, - { title: '状态', dataIndex: 'aiStatus', search: false, render: (_, r) => (r.aiStatus === 'done' ? 成功 : 失败) }, + { + title: '状态', + dataIndex: 'aiStatus', + search: false, + render: (_, r) => ( + <> + {r.aiStatus === 'done' ? 成功 : 失败} + {r.isMock ? Mock : null} + + ), + }, { title: '操作', valueType: 'option', @@ -101,6 +111,8 @@ export default function InspectionsPage() {
状态:{detail.aiStatus === 'done' ? '检测成功' : '检测失败'}
+ 数据:{detail.isMock ? '联调 Mock,不计入生产统计' : detail.modelVersion || '未知模型'} +
风险分:{detail.riskScore !== undefined ? Math.round(detail.riskScore) : '-'}( {detail.riskLevel ? riskLevelLabel(detail.riskLevel) : '-'}) diff --git a/后续工作计划.md b/后续工作计划.md index 74d3dc1..e1299ef 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -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 启动) @@ -14,7 +14,7 @@ | Wave 1 | P0 安全与正确性 | Task 3 移除默认密钥与默认管理员密码 | 延后到最后(跳过) | 用户 2026-08-13 明确要求跳过并留到最后 | | Wave 1 | P0 安全与正确性 | Task 4 收口视频访问与摄像头密钥输出 | 部分可用 | 待开发服务器部署联调 | | 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 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 未开始 | 无 | | Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index 6a4d61b..49b3570 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -921,3 +921,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 - 本任务前分支提交为 `1a29def`;回滚可还原 Task 5 提交; - 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` 仍保留,历史数据不回写。