feat: 多检测方式推荐引擎(#20,设备/紧急/操作者/成本偏好)
This commit is contained in:
@@ -117,6 +117,7 @@ func main() {
|
|||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
|
|
||||||
// 启动高发病天气预警定时任务(未配置时跳过)
|
// 启动高发病天气预警定时任务(未配置时跳过)
|
||||||
go startWeatherAlertLoop(db, weatherSvc, time.Duration(cfg.QWeatherIntervalMin)*time.Minute)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,3 +71,15 @@ export const uploadSpectrumEntryFile = (file: File) => {
|
|||||||
return post<{ url: string }>('/spectrum-entries/upload', fd);
|
return post<{ url: string }>('/spectrum-entries/upload', fd);
|
||||||
};
|
};
|
||||||
export const deleteSpectrumEntry = (id: string) => del(`/spectrum-entries/${id}`);
|
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 });
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import { PlusOutlined } from '@ant-design/icons';
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd';
|
import type { UploadFile } from 'antd';
|
||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
createSpectrumEntry,
|
createSpectrumEntry,
|
||||||
uploadSpectrumEntryFile,
|
uploadSpectrumEntryFile,
|
||||||
deleteSpectrumEntry,
|
deleteSpectrumEntry,
|
||||||
|
recommendDetectionMethod,
|
||||||
|
type DetectionRecommendation,
|
||||||
type LampTest,
|
type LampTest,
|
||||||
type LampTestStep,
|
type LampTestStep,
|
||||||
type SpectrumEntry,
|
type SpectrumEntry,
|
||||||
@@ -66,6 +68,11 @@ function LampTab() {
|
|||||||
const [steps, setSteps] = useState<LampTestStep[]>([]);
|
const [steps, setSteps] = useState<LampTestStep[]>([]);
|
||||||
const [ctValues, setCtValues] = useState('');
|
const [ctValues, setCtValues] = useState('');
|
||||||
const [threshold, setThreshold] = useState<number>(35);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -200,6 +207,84 @@ function LampTab() {
|
|||||||
<Form.Item label="蚕房" name="roomId">
|
<Form.Item label="蚕房" name="roomId">
|
||||||
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
||||||
</Form.Item>
|
</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">
|
<Form.Item label="检测方式" name="method" initialValue="lamp">
|
||||||
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
Reference in New Issue
Block a user