feat: 交叉验证 AI vs LAMP(#15,一致确认/不一致升级会诊)
This commit is contained in:
@@ -10,6 +10,8 @@ export interface LampTest {
|
|||||||
sampleInfo?: string;
|
sampleInfo?: string;
|
||||||
result?: string;
|
result?: string;
|
||||||
resultImageUrl?: string;
|
resultImageUrl?: string;
|
||||||
|
crossStatus?: string;
|
||||||
|
crossReason?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,20 @@
|
|||||||
margin-top: $spacing-xs;
|
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 {
|
.emptyText {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -156,6 +156,13 @@ const LampPage: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
{t.result ? <Text className={styles.resultHint}>当前结果:{RESULT_LABELS[t.result]}</Text> : null}
|
||||||
|
{t.crossStatus && t.crossStatus !== 'pending' ? (
|
||||||
|
<Text className={t.crossStatus === 'consistent' ? styles.crossOk : styles.crossWarn}>
|
||||||
|
{t.crossStatus === 'consistent'
|
||||||
|
? '交叉验证:一致(确认诊断)✓'
|
||||||
|
: `交叉验证:不一致 ⚠ ${t.crossReason || '建议专家会诊'}`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<Button className={`${styles.btn} ${styles.btnPrimary}`} onClick={() => handleSaveResult(t)}>
|
<Button className={`${styles.btn} ${styles.btnPrimary}`} onClick={() => handleSaveResult(t)}>
|
||||||
保存结果
|
保存结果
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"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.GET("/lamp-tests/:id/steps", read, listLampTestSteps(db))
|
||||||
rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db))
|
rg.PATCH("/lamp-tests/:id/steps/:stepNo", write, updateLampTestStep(db))
|
||||||
rg.POST("/lamp-tests/:id/result-image", write, uploadLampResultImage(db, s3, imageBucket))
|
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 过滤)
|
// listLampTests 检测任务单列表(roomId/batchId/status 过滤)
|
||||||
@@ -97,6 +99,8 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resultSet := false
|
||||||
|
resultValue := ""
|
||||||
if r, ok := updates["result"]; ok {
|
if r, ok := updates["result"]; ok {
|
||||||
result, _ := r.(string)
|
result, _ := r.(string)
|
||||||
if result != "" && !model.ValidLampResult(result) {
|
if result != "" && !model.ValidLampResult(result) {
|
||||||
@@ -109,15 +113,86 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
} else {
|
} else {
|
||||||
updates["status"] = "testing"
|
updates["status"] = "testing"
|
||||||
}
|
}
|
||||||
|
resultSet = true
|
||||||
|
resultValue = result
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
|
// 交叉验证:结果落库后与同房间最近巡检比对
|
||||||
|
if resultSet && resultValue != "" {
|
||||||
|
_ = runCrossValidation(db, id, resultValue)
|
||||||
|
}
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, 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 删除任务单(级联删除步骤)
|
// deleteLampTest 删除任务单(级联删除步骤)
|
||||||
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
|
func deleteLampTest(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type LampTest struct {
|
|||||||
ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"`
|
ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"`
|
||||||
OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"`
|
OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"`
|
||||||
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,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"`
|
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ export interface LampTest {
|
|||||||
resultImageUrl?: string;
|
resultImageUrl?: string;
|
||||||
operatorId?: string;
|
operatorId?: string;
|
||||||
resultedAt?: string;
|
resultedAt?: string;
|
||||||
|
crossStatus?: string;
|
||||||
|
crossReason?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ function LampTab() {
|
|||||||
render: (_, r) => <Tag color={r.status === 'resulted' ? 'green' : 'default'}>{STATUS_LABELS[r.status] || r.status}</Tag>,
|
render: (_, r) => <Tag color={r.status === 'resulted' ? 'green' : 'default'}>{STATUS_LABELS[r.status] || r.status}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '结果', dataIndex: 'result', search: false, render: (_, r) => (r.result ? RESULT_LABELS[r.result] || r.result : '-') },
|
{ 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' ? (
|
||||||
|
<Tag color="green">一致</Tag>
|
||||||
|
) : r.crossStatus === 'inconsistent' ? (
|
||||||
|
<Tag color="red">不一致</Tag>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
{ title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -211,6 +224,16 @@ function LampTab() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{resultTarget?.crossStatus && resultTarget.crossStatus !== 'pending' ? (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Tag color={resultTarget.crossStatus === 'consistent' ? 'green' : 'red'}>
|
||||||
|
{resultTarget.crossStatus === 'consistent' ? '交叉验证:一致' : '交叉验证:不一致'}
|
||||||
|
</Tag>
|
||||||
|
{resultTarget.crossReason ? (
|
||||||
|
<span style={{ marginLeft: 8, color: '#666' }}>{resultTarget.crossReason}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Form.Item label="结果照片">
|
<Form.Item label="结果照片">
|
||||||
<Upload
|
<Upload
|
||||||
listType="picture-card"
|
listType="picture-card"
|
||||||
|
|||||||
Reference in New Issue
Block a user