feat: 多检测方式推荐引擎(#20,设备/紧急/操作者/成本偏好)

This commit is contained in:
weijuesen
2026-08-12 21:57:42 +08:00
parent 164ae63392
commit 47a2da3d0d
6 changed files with 338 additions and 1 deletions
+1
View File
@@ -117,6 +117,7 @@ func main() {
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
handler.RegisterConsumableRoutes(api, db)
handler.RegisterConsultationRoutes(api, db)
handler.RegisterDetectionMethodRoutes(api, db)
// 启动高发病天气预警定时任务(未配置时跳过)
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
@@ -0,0 +1,34 @@
package handler
import (
"net/http"
"silk-server-go/internal/middleware"
"silk-server-go/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// RegisterDetectionMethodRoutes 注册检测方式推荐路由
func RegisterDetectionMethodRoutes(rg *gin.RouterGroup, db *gorm.DB) {
rg.GET("/detection-methods/recommend",
middleware.RequirePermission(db, "lamp:read"),
recommendDetectionMethod())
}
// recommendDetectionMethod 按设备/紧急/操作者/成本偏好推荐检测方式(纯计算)
func recommendDetectionMethod() gin.HandlerFunc {
return func(c *gin.Context) {
in := service.DetectionRecommendInput{
HasLamp: c.Query("hasLamp") == "true",
HasSers: c.Query("hasSers") == "true",
HasQPCR: c.Query("hasQpcr") == "true",
HasHyperspectral: c.Query("hasHyperspectral") == "true",
Urgency: c.DefaultQuery("urgency", "routine"),
OperatorLevel: c.DefaultQuery("operatorLevel", "expert"),
CostPreference: c.DefaultQuery("costPreference", "balanced"),
}
c.JSON(http.StatusOK, service.RecommendMethod(in))
}
}
@@ -0,0 +1,141 @@
package service
import "sort"
// DetectionRecommendInput 多检测方式推荐输入
type DetectionRecommendInput struct {
HasLamp bool
HasSers bool
HasQPCR bool
HasHyperspectral bool
Urgency string // routine/urgent
OperatorLevel string // novice/expert
CostPreference string // balanced/low_cost/fastest
}
// DetectionRecommendation 推荐结果
type DetectionRecommendation struct {
Method string `json:"method"`
Name string `json:"name"`
Reason string `json:"reason"`
Time string `json:"time"`
Cost string `json:"cost"`
Difficulty string `json:"difficulty"`
}
type methodScore struct {
info DetectionRecommendation
time int
cost int
diff int
}
// RecommendMethod 按设备条件/紧急程度/操作者水平/成本偏好推荐检测方式
// 规则取自规格书 3.3.2 与 11.2 参数对比;高光谱理论可行待验证,仅在其他方式都不可用时推荐
func RecommendMethod(in DetectionRecommendInput) []DetectionRecommendation {
pool := make([]methodScore, 0, 4)
if in.HasLamp {
pool = append(pool, methodScore{
info: DetectionRecommendation{
Method: "lamp", Name: "LAMP", Time: "50-100 分钟", Cost: "设备 <3000 元,单次 5-20 元", Difficulty: "中",
},
time: 3, cost: 5, diff: 3,
})
}
if in.HasSers {
pool = append(pool, methodScore{
info: DetectionRecommendation{
Method: "sers", Name: "SERS", Time: "约 3 分钟", Cost: "设备 8-15 万,单次 10-30 元", Difficulty: "低",
},
time: 5, cost: 1, diff: 5,
})
}
if in.HasQPCR {
pool = append(pool, methodScore{
info: DetectionRecommendation{
Method: "qpcr", Name: "qPCR", Time: "2-3 小时", Cost: "设备 5-30 万,单次 50-100 元", Difficulty: "高",
},
time: 1, cost: 2, diff: 1,
})
}
if in.HasHyperspectral {
pool = append(pool, methodScore{
info: DetectionRecommendation{
Method: "hyperspectral", Name: "高光谱", Time: "秒级~分钟级", Cost: "设备数万-十几万,单次 0 元", Difficulty: "低(待验证)",
},
time: 5, cost: 2, diff: 5,
})
}
if len(pool) == 0 {
return nil
}
// 高光谱理论可行待验证:仅当它是唯一可用方式时推荐,否则剔除
if len(pool) > 1 {
filtered := pool[:0]
for _, m := range pool {
if m.info.Method != "hyperspectral" {
filtered = append(filtered, m)
}
}
pool = filtered
}
// 权重
wt, wc, wd := 0.3, 0.4, 0.3
if in.Urgency == "urgent" {
wt, wc, wd = 0.6, 0.2, 0.2
}
if in.OperatorLevel == "novice" {
wt, wc, wd = 0.2, 0.2, 0.6
}
if in.CostPreference == "low_cost" {
wt, wc, wd = 0.2, 0.6, 0.2
} else if in.CostPreference == "fastest" {
wt, wc, wd = 0.6, 0.2, 0.2
}
scores := make([]float64, len(pool))
for i, m := range pool {
scores[i] = wt*float64(m.time) + wc*float64(m.cost) + wd*float64(m.diff)
}
order := make([]int, len(pool))
for i := range order {
order[i] = i
}
sort.SliceStable(order, func(a, b int) bool {
if scores[order[a]] != scores[order[b]] {
return scores[order[a]] > scores[order[b]]
}
return order[a] < order[b]
})
result := make([]DetectionRecommendation, 0, len(pool))
for _, idx := range order {
m := pool[idx]
m.info.Reason = reasonFor(m.info.Method, in)
result = append(result, m.info)
}
return result
}
func reasonFor(method string, in DetectionRecommendInput) string {
switch method {
case "sers":
return "出结果最快(约 3 分钟)、操作难度低,适合紧急或新手场景;设备投入较高"
case "lamp":
if in.CostPreference == "low_cost" {
return "设备投入最低(<3000 元)、基层最务实,性价比高"
}
if in.Urgency == "urgent" {
return "在无 SERS 时最快的可选方案(50-100 分钟),设备投入低"
}
return "设备投入低、基层最务实,是目前蚕业领域最常用的分子检测方案"
case "qpcr":
return "金标准准确率最高,适合争议仲裁;设备投入大、操作门槛高、耗时 2-3 小时"
case "hyperspectral":
return "非接触秒级检测,但蚕病领域理论可行待验证,建议先做可行性实验"
default:
return ""
}
}
@@ -0,0 +1,64 @@
package service
import "testing"
func TestRecommendMethodNoDevices(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{})
if len(got) != 0 {
t.Errorf("无可用设备应返回空,实际 %+v", got)
}
}
func TestRecommendMethodLowCost(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{
HasLamp: true, HasSers: true, HasQPCR: true,
CostPreference: "low_cost",
})
if len(got) == 0 || got[0].Method != "lamp" {
t.Errorf("低成本偏好应首选 LAMP,实际 %+v", got)
}
}
func TestRecommendMethodFastest(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{
HasLamp: true, HasSers: true, HasQPCR: true,
CostPreference: "fastest",
})
if len(got) == 0 || got[0].Method != "sers" {
t.Errorf("最快偏好应首选 SERS,实际 %+v", got)
}
}
func TestRecommendMethodUrgentNoSERS(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{
HasLamp: true, HasQPCR: true,
Urgency: "urgent",
})
if len(got) == 0 || got[0].Method != "lamp" {
t.Errorf("紧急且无 SERS 应首选 LAMP,实际 %+v", got)
}
}
func TestRecommendMethodNoviceAvoidsQPCR(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{
HasLamp: true, HasSers: true, HasQPCR: true,
OperatorLevel: "novice",
})
if len(got) == 0 {
t.Fatal("应有推荐")
}
if got[len(got)-1].Method == "qpcr" {
// qPCR 难度高,新手应排在最后(除非仅此可选)
}
// 至少 qPCR 不应排第一
if got[0].Method == "qpcr" {
t.Errorf("新手不应首选 qPCR,实际 %+v", got)
}
}
func TestRecommendMethodOnlyHyperspectral(t *testing.T) {
got := RecommendMethod(DetectionRecommendInput{HasHyperspectral: true})
if len(got) != 1 || got[0].Method != "hyperspectral" {
t.Errorf("仅有高光谱时应推荐且标注待验证,实际 %+v", got)
}
}
+12
View File
@@ -71,3 +71,15 @@ export const uploadSpectrumEntryFile = (file: File) => {
return post<{ url: string }>('/spectrum-entries/upload', fd);
};
export const deleteSpectrumEntry = (id: string) => del(`/spectrum-entries/${id}`);
export interface DetectionRecommendation {
method: string;
name: string;
reason: string;
time: string;
cost: string;
difficulty: string;
}
export const recommendDetectionMethod = (params: any) =>
get<DetectionRecommendation[]>('/detection-methods/recommend', { params });
+86 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import {
Button, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Switch, Tabs, Tag, Upload, message,
Button, Card, Checkbox, Form, Input, Modal, Popconfirm, Radio, Select, Space, Switch, Tabs, Tag, Upload, message,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd';
@@ -20,6 +20,8 @@ import {
createSpectrumEntry,
uploadSpectrumEntryFile,
deleteSpectrumEntry,
recommendDetectionMethod,
type DetectionRecommendation,
type LampTest,
type LampTestStep,
type SpectrumEntry,
@@ -66,6 +68,11 @@ function LampTab() {
const [steps, setSteps] = useState<LampTestStep[]>([]);
const [ctValues, setCtValues] = useState('');
const [threshold, setThreshold] = useState<number>(35);
const [recDevices, setRecDevices] = useState({ hasLamp: true, hasSers: false, hasQpcr: false, hasHyperspectral: false });
const [recUrgency, setRecUrgency] = useState('routine');
const [recOperator, setRecOperator] = useState('expert');
const [recCost, setRecCost] = useState('balanced');
const [recommendations, setRecommendations] = useState<DetectionRecommendation[]>([]);
useEffect(() => {
Promise.all([
@@ -200,6 +207,84 @@ function LampTab() {
<Form.Item label="蚕房" name="roomId">
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
</Form.Item>
<Card size="small" title="推荐检测方式" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }}>
<Space wrap>
<Checkbox
checked={recDevices.hasLamp}
onChange={(e) => setRecDevices((p) => ({ ...p, hasLamp: e.target.checked }))}
>
LAMP
</Checkbox>
<Checkbox
checked={recDevices.hasSers}
onChange={(e) => setRecDevices((p) => ({ ...p, hasSers: e.target.checked }))}
>
SERS
</Checkbox>
<Checkbox
checked={recDevices.hasQpcr}
onChange={(e) => setRecDevices((p) => ({ ...p, hasQpcr: e.target.checked }))}
>
qPCR
</Checkbox>
<Checkbox
checked={recDevices.hasHyperspectral}
onChange={(e) => setRecDevices((p) => ({ ...p, hasHyperspectral: e.target.checked }))}
>
</Checkbox>
</Space>
<Space wrap>
<Select
value={recUrgency}
style={{ width: 110 }}
onChange={setRecUrgency}
options={[
{ value: 'routine', label: '常规' },
{ value: 'urgent', label: '紧急' },
]}
/>
<Select
value={recOperator}
style={{ width: 110 }}
onChange={setRecOperator}
options={[
{ value: 'expert', label: '熟练' },
{ value: 'novice', label: '新手' },
]}
/>
<Select
value={recCost}
style={{ width: 130 }}
onChange={setRecCost}
options={[
{ value: 'balanced', label: '平衡' },
{ value: 'low_cost', label: '最低成本' },
{ value: 'fastest', label: '最快' },
]}
/>
<Button
onClick={async () => {
const res = await recommendDetectionMethod({ ...recDevices, urgency: recUrgency, operatorLevel: recOperator, costPreference: recCost });
setRecommendations(res);
}}
>
</Button>
</Space>
{recommendations.map((r) => (
<div key={r.method}>
<Tag color="blue">{r.name}</Tag>
<span style={{ marginRight: 8 }}>{r.time} · {r.difficulty}</span>
<div style={{ color: '#666', fontSize: 12 }}>{r.reason}</div>
</div>
))}
{recommendations.length === 0 ? (
<span style={{ color: '#999' }}></span>
) : null}
</Space>
</Card>
<Form.Item label="检测方式" name="method" initialValue="lamp">
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>