From 1798b2909efc4cce1e54bd6d739d2dbc7996ab0a Mon Sep 17 00:00:00 2001 From: weijuesen Date: Wed, 12 Aug 2026 18:00:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=A4=E5=8F=89=E9=AA=8C=E8=AF=81=20?= =?UTF-8?q?AI=20vs=20LAMP=EF=BC=88#15=EF=BC=8C=E4=B8=80=E8=87=B4=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4/=E4=B8=8D=E4=B8=80=E8=87=B4=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E4=BC=9A=E8=AF=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniapp/src/api/lamp.ts | 2 + miniapp/src/pages/lamp/index.module.scss | 14 ++++ miniapp/src/pages/lamp/index.tsx | 7 ++ server-go/internal/handler/lamp.go | 75 +++++++++++++++++++ server-go/internal/model/lamp.go | 2 + server-go/internal/service/cross_validate.go | 34 +++++++++ .../internal/service/cross_validate_test.go | 40 ++++++++++ web/src/dal/lamp.ts | 2 + web/src/pages/LampTests.tsx | 23 ++++++ 9 files changed, 199 insertions(+) create mode 100644 server-go/internal/service/cross_validate.go create mode 100644 server-go/internal/service/cross_validate_test.go diff --git a/miniapp/src/api/lamp.ts b/miniapp/src/api/lamp.ts index 0ae045d..4a68621 100644 --- a/miniapp/src/api/lamp.ts +++ b/miniapp/src/api/lamp.ts @@ -10,6 +10,8 @@ export interface LampTest { sampleInfo?: string; result?: string; resultImageUrl?: string; + crossStatus?: string; + crossReason?: string; note?: string; createdAt?: string; } diff --git a/miniapp/src/pages/lamp/index.module.scss b/miniapp/src/pages/lamp/index.module.scss index 624c1e3..55c2b6a 100644 --- a/miniapp/src/pages/lamp/index.module.scss +++ b/miniapp/src/pages/lamp/index.module.scss @@ -108,6 +108,20 @@ margin-top: $spacing-xs; } +.crossOk { + display: block; + font-size: $font-size-xs; + color: $color-success; + margin-top: $spacing-xs; +} + +.crossWarn { + display: block; + font-size: $font-size-xs; + color: $color-warning; + margin-top: $spacing-xs; +} + .emptyText { display: block; text-align: center; diff --git a/miniapp/src/pages/lamp/index.tsx b/miniapp/src/pages/lamp/index.tsx index 163b4e6..5162fdc 100644 --- a/miniapp/src/pages/lamp/index.tsx +++ b/miniapp/src/pages/lamp/index.tsx @@ -156,6 +156,13 @@ const LampPage: React.FC = () => { {t.result ? 当前结果:{RESULT_LABELS[t.result]} : null} + {t.crossStatus && t.crossStatus !== 'pending' ? ( + + {t.crossStatus === 'consistent' + ? '交叉验证:一致(确认诊断)✓' + : `交叉验证:不一致 ⚠ ${t.crossReason || '建议专家会诊'}`} + + ) : null} diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go index 2a4cb9d..1a02641 100644 --- a/server-go/internal/handler/lamp.go +++ b/server-go/internal/handler/lamp.go @@ -2,6 +2,7 @@ package handler import ( "bytes" + "encoding/json" "io" "net/http" "strconv" @@ -26,6 +27,7 @@ func RegisterLampRoutes(rg *gin.RouterGroup, db *gorm.DB, s3 *service.S3Service, rg.GET("/lamp-tests/:id/steps", read, listLampTestSteps(db)) rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db)) rg.POST("/lamp-tests/:id/result-image", write, uploadLampResultImage(db, s3, imageBucket)) + rg.GET("/lamp-tests/:id/cross-validation", read, getLampCrossValidation(db)) } // listLampTests 检测任务单列表(roomId/batchId/status 过滤) @@ -97,6 +99,8 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + resultSet := false + resultValue := "" if r, ok := updates["result"]; ok { result, _ := r.(string) if result != "" && !model.ValidLampResult(result) { @@ -109,15 +113,86 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc { } else { updates["status"] = "testing" } + resultSet = true + resultValue = result } if len(updates) > 0 { db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates) } + // 交叉验证:结果落库后与同房间最近巡检比对 + if resultSet && resultValue != "" { + _ = runCrossValidation(db, id, resultValue) + } db.Where("id = ?", id).First(&t) c.JSON(http.StatusOK, t) } } +// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason +func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error { + var t model.LampTest + if err := db.Where("id = ?", lampTestID).First(&t).Error; err != nil { + return err + } + if t.RoomID == nil { + return nil + } + var inspection model.InspectionRecord + if err := db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error; err != nil { + return nil // 无关联巡检,保持 pending + } + var detections []service.AIDetection + if len(inspection.Detections) > 0 { + _ = json.Unmarshal(inspection.Detections, &detections) + } + aiClass := service.AIClassFromDetections(detections) + var diseases []string + if len(t.Diseases) > 0 { + _ = json.Unmarshal(t.Diseases, &diseases) + } + ok, reason := service.CrossValidate(aiClass, lampResult, diseases) + status := "inconsistent" + if ok { + status = "consistent" + } + return db.Model(&model.LampTest{}).Where("id = ?", lampTestID). + Updates(map[string]interface{}{"cross_status": status, "cross_reason": reason}).Error +} + +// getLampCrossValidation 交叉验证详情(含关联巡检摘要) +func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + id := c.Param("id") + var t model.LampTest + if db.Where("id = ?", id).First(&t).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "lamp test not found"}) + return + } + var related map[string]interface{} + if t.RoomID != nil { + var inspection model.InspectionRecord + if db.Where("room_id = ?", *t.RoomID).Order("created_at DESC").First(&inspection).Error == nil { + var detections []service.AIDetection + if len(inspection.Detections) > 0 { + _ = json.Unmarshal(inspection.Detections, &detections) + } + related = map[string]interface{}{ + "id": inspection.ID, + "imageUrl": inspection.ImageURL, + "aiClass": service.AIClassFromDetections(detections), + "riskLevel": inspection.RiskLevel, + "createdAt": inspection.CreatedAt, + } + } + } + c.JSON(http.StatusOK, gin.H{ + "crossStatus": t.CrossStatus, + "crossReason": t.CrossReason, + "relatedInspection": related, + }) + } +} + // deleteLampTest 删除任务单(级联删除步骤) func deleteLampTest(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { diff --git a/server-go/internal/model/lamp.go b/server-go/internal/model/lamp.go index f8fb4a2..42163bc 100644 --- a/server-go/internal/model/lamp.go +++ b/server-go/internal/model/lamp.go @@ -17,6 +17,8 @@ type LampTest struct { ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"` OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"` ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` + CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent + CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"` Note *string `gorm:"type:text" json:"note,omitempty"` CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` diff --git a/server-go/internal/service/cross_validate.go b/server-go/internal/service/cross_validate.go new file mode 100644 index 0000000..9180c00 --- /dev/null +++ b/server-go/internal/service/cross_validate.go @@ -0,0 +1,34 @@ +package service + +// CrossValidate 交叉验证:AI 检测结果 vs LAMP 检测结果(规格书:一致→确认诊断;不一致→升级专家会诊) +func CrossValidate(aiClass, lampResult string, lampDiseases []string) (bool, string) { + switch { + case lampResult == "invalid": + return false, "LAMP 判读无效,建议复检或专家会诊" + case aiClass == "": + return false, "未找到关联巡检记录,暂无法交叉验证" + case aiClass == "sick" && lampResult == "positive": + return true, "AI 检出异常与 LAMP 阳性一致,确认诊断" + case aiClass == "healthy" && lampResult == "negative": + return true, "AI 未见异常与 LAMP 阴性一致" + case aiClass == "sick" && lampResult == "negative": + return false, "AI 检出异常但 LAMP 阴性,建议专家会诊" + case aiClass == "healthy" && lampResult == "positive": + return false, "AI 未见异常但 LAMP 阳性,建议专家会诊" + default: + return false, "交叉验证结果待确认" + } +} + +// AIClassFromDetections 从巡检检测结果归纳 AI 结论(任一非 healthy 视为 sick) +func AIClassFromDetections(detections []AIDetection) string { + if len(detections) == 0 { + return "" + } + for _, d := range detections { + if d.ClassName != "healthy" { + return "sick" + } + } + return "healthy" +} diff --git a/server-go/internal/service/cross_validate_test.go b/server-go/internal/service/cross_validate_test.go new file mode 100644 index 0000000..adfaba1 --- /dev/null +++ b/server-go/internal/service/cross_validate_test.go @@ -0,0 +1,40 @@ +package service + +import "testing" + +func TestCrossValidate(t *testing.T) { + cases := []struct { + name string + aiClass string + lampResult string + wantOK bool + }{ + {"AI异常+LAMP阳性 一致", "sick", "positive", true}, + {"AI健康+LAMP阴性 一致", "healthy", "negative", true}, + {"AI异常+LAMP阴性 不一致", "sick", "negative", false}, + {"AI健康+LAMP阳性 不一致", "healthy", "positive", false}, + {"LAMP无效 不一致", "sick", "invalid", false}, + {"无AI记录 不一致", "", "positive", false}, + } + for _, c := range cases { + ok, reason := CrossValidate(c.aiClass, c.lampResult, []string{"核型多角体病"}) + if ok != c.wantOK { + t.Errorf("%s: ok=%v, want %v(原因 %s)", c.name, ok, c.wantOK, reason) + } + if reason == "" { + t.Errorf("%s: 缺少原因说明", c.name) + } + } +} + +func TestAIClassFromDetections(t *testing.T) { + if got := AIClassFromDetections(nil); got != "" { + t.Errorf("空检测应为空,实际 %s", got) + } + if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy", Confidence: 0.9}}); got != "healthy" { + t.Errorf("全健康应为 healthy,实际 %s", got) + } + if got := AIClassFromDetections([]AIDetection{{ClassName: "healthy"}, {ClassName: "sick", Confidence: 0.6}}); got != "sick" { + t.Errorf("含 sick 应为 sick,实际 %s", got) + } +} diff --git a/web/src/dal/lamp.ts b/web/src/dal/lamp.ts index c9271f8..d2f84ab 100644 --- a/web/src/dal/lamp.ts +++ b/web/src/dal/lamp.ts @@ -11,6 +11,8 @@ export interface LampTest { resultImageUrl?: string; operatorId?: string; resultedAt?: string; + crossStatus?: string; + crossReason?: string; note?: string; createdAt?: string; } diff --git a/web/src/pages/LampTests.tsx b/web/src/pages/LampTests.tsx index afe9ca7..0cc02bd 100644 --- a/web/src/pages/LampTests.tsx +++ b/web/src/pages/LampTests.tsx @@ -94,6 +94,19 @@ function LampTab() { render: (_, r) => {STATUS_LABELS[r.status] || r.status}, }, { title: '结果', dataIndex: 'result', search: false, render: (_, r) => (r.result ? RESULT_LABELS[r.result] || r.result : '-') }, + { + title: '交叉验证', + dataIndex: 'crossStatus', + search: false, + render: (_, r) => + r.crossStatus === 'consistent' ? ( + 一致 + ) : r.crossStatus === 'inconsistent' ? ( + 不一致 + ) : ( + '-' + ), + }, { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, { title: '操作', @@ -211,6 +224,16 @@ function LampTab() { ]} /> + {resultTarget?.crossStatus && resultTarget.crossStatus !== 'pending' ? ( +
+ + {resultTarget.crossStatus === 'consistent' ? '交叉验证:一致' : '交叉验证:不一致'} + + {resultTarget.crossReason ? ( + {resultTarget.crossReason} + ) : null} +
+ ) : null}