feat: 修复 AI 风险语义并隔离 Mock 数据
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 客户端
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user