feat: 完善环境规则、会诊治理、知识审核与效果评估
This commit is contained in:
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthInput 健康画像输入
|
||||
@@ -85,3 +86,99 @@ func AggregateMonthlyStats(entries []MonthDiseaseEntry) []MonthStat {
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Month < result[j].Month })
|
||||
return result
|
||||
}
|
||||
|
||||
// EffectWindow 处置前后评估窗口。
|
||||
type EffectWindow struct {
|
||||
BeforeStart time.Time `json:"beforeStart"`
|
||||
BeforeEnd time.Time `json:"beforeEnd"`
|
||||
AfterStart time.Time `json:"afterStart"`
|
||||
AfterEnd time.Time `json:"afterEnd"`
|
||||
}
|
||||
|
||||
// EffectMetrics 处置前后指标输入。
|
||||
type EffectMetrics struct {
|
||||
RiskSamples []float64 `json:"riskSamples"`
|
||||
LampPositive int `json:"lampPositive"`
|
||||
LampTotal int `json:"lampTotal"`
|
||||
Recurrences int `json:"recurrences"`
|
||||
Loss *float64 `json:"loss,omitempty"`
|
||||
Cost *float64 `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
// EffectReport 防控效果报告;缺失维度不按 0 当改善。
|
||||
type EffectReport struct {
|
||||
EventID string `json:"eventId"`
|
||||
Window EffectWindow `json:"window"`
|
||||
BeforeRiskAvg *float64 `json:"beforeRiskAvg,omitempty"`
|
||||
AfterRiskAvg *float64 `json:"afterRiskAvg,omitempty"`
|
||||
BeforePositiveRate *float64 `json:"beforePositiveRate,omitempty"`
|
||||
AfterPositiveRate *float64 `json:"afterPositiveRate,omitempty"`
|
||||
Recurrences int `json:"recurrences"`
|
||||
Missing []string `json:"missing"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
}
|
||||
|
||||
// EvaluateControlEffect 对比指定窗口风险、阳性率和复发;缺失维度单独列出。
|
||||
func EvaluateControlEffect(eventID string, window EffectWindow, before, after EffectMetrics) EffectReport {
|
||||
report := EffectReport{
|
||||
EventID: eventID,
|
||||
Window: window,
|
||||
Recurrences: after.Recurrences,
|
||||
Missing: []string{},
|
||||
}
|
||||
if len(before.RiskSamples) > 0 {
|
||||
value := avg(before.RiskSamples)
|
||||
report.BeforeRiskAvg = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "before_risk")
|
||||
}
|
||||
if len(after.RiskSamples) > 0 {
|
||||
value := avg(after.RiskSamples)
|
||||
report.AfterRiskAvg = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "after_risk")
|
||||
}
|
||||
if before.LampTotal > 0 {
|
||||
value := float64(before.LampPositive) / float64(before.LampTotal)
|
||||
report.BeforePositiveRate = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "before_lamp")
|
||||
}
|
||||
if after.LampTotal > 0 {
|
||||
value := float64(after.LampPositive) / float64(after.LampTotal)
|
||||
report.AfterPositiveRate = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "after_lamp")
|
||||
}
|
||||
if before.Loss == nil {
|
||||
report.Missing = append(report.Missing, "before_loss")
|
||||
}
|
||||
if after.Loss == nil {
|
||||
report.Missing = append(report.Missing, "after_loss")
|
||||
}
|
||||
if before.Cost == nil {
|
||||
report.Missing = append(report.Missing, "before_cost")
|
||||
}
|
||||
if after.Cost == nil {
|
||||
report.Missing = append(report.Missing, "after_cost")
|
||||
}
|
||||
switch {
|
||||
case report.AfterRiskAvg == nil || report.BeforeRiskAvg == nil:
|
||||
report.Conclusion = "证据不足,无法判断效果"
|
||||
case *report.AfterRiskAvg < *report.BeforeRiskAvg && after.Recurrences == 0:
|
||||
report.Conclusion = "改善"
|
||||
case *report.AfterRiskAvg >= *report.BeforeRiskAvg:
|
||||
report.Conclusion = "未改善"
|
||||
default:
|
||||
report.Conclusion = "需人工复核"
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func avg(values []float64) float64 {
|
||||
sum := 0.0
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeHealthScore(t *testing.T) {
|
||||
if s, g := ComputeHealthScore(HealthInput{}); s != 100 || g != "优" {
|
||||
t.Errorf("空输入应 100/优,实际 %.1f/%s", s, g)
|
||||
}
|
||||
bad := HealthInput{
|
||||
RecentRiskLevels: []string{"red", "red"},
|
||||
LampPositive: 1,
|
||||
LampTotal: 2,
|
||||
RecentRiskLevels: []string{"red", "red"},
|
||||
LampPositive: 1,
|
||||
LampTotal: 2,
|
||||
ConsultationCount: 1,
|
||||
TraceCount: 1,
|
||||
TraceCount: 1,
|
||||
}
|
||||
s, g := ComputeHealthScore(bad)
|
||||
if s >= 80 {
|
||||
@@ -50,3 +53,33 @@ func TestAggregateMonthlyStats(t *testing.T) {
|
||||
t.Error("空输入应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateControlEffectComparesWindows(t *testing.T) {
|
||||
window := EffectWindow{
|
||||
BeforeStart: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
BeforeEnd: time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC),
|
||||
AfterStart: time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC),
|
||||
AfterEnd: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
report := EvaluateControlEffect("event-1", window, EffectMetrics{
|
||||
RiskSamples: []float64{80}, LampPositive: 2, LampTotal: 3,
|
||||
}, EffectMetrics{
|
||||
RiskSamples: []float64{40}, LampPositive: 1, LampTotal: 4,
|
||||
})
|
||||
if report.Conclusion != "改善" {
|
||||
t.Fatalf("结论 = %s, want 改善", report.Conclusion)
|
||||
}
|
||||
if report.BeforeRiskAvg == nil || *report.BeforeRiskAvg != 80 {
|
||||
t.Fatalf("before risk = %+v", report.BeforeRiskAvg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateControlEffectReportsMissing(t *testing.T) {
|
||||
report := EvaluateControlEffect("event-1", EffectWindow{}, EffectMetrics{}, EffectMetrics{})
|
||||
if report.Conclusion != "证据不足,无法判断效果" {
|
||||
t.Fatalf("无数据结论应为证据不足,实际 %s", report.Conclusion)
|
||||
}
|
||||
if len(report.Missing) < 4 {
|
||||
t.Fatalf("缺失维度应单独列出: %v", report.Missing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelemetrySample 规则引擎遥测样本。
|
||||
type TelemetrySample struct {
|
||||
Metric string `json:"metric"`
|
||||
Value float64 `json:"value"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// RuleContext 规则输入;缺失项用 nil 表示,不填 0。
|
||||
type RuleContext struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Now time.Time `json:"now"`
|
||||
Samples []TelemetrySample `json:"samples"`
|
||||
Density *float64 `json:"density,omitempty"`
|
||||
Ventilation *bool `json:"ventilation,omitempty"`
|
||||
SeedSourceCount *int `json:"seedSourceCount,omitempty"`
|
||||
DisinfectionCount *int `json:"disinfectionCount,omitempty"`
|
||||
}
|
||||
|
||||
// RuleVersion 可版本化规则。
|
||||
type RuleVersion struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Disease string `json:"disease"`
|
||||
WindowHours int `json:"windowHours"`
|
||||
MaxStaleness time.Duration `json:"-"`
|
||||
RequiresDensity bool `json:"-"`
|
||||
RequiresBiosecurity bool `json:"-"`
|
||||
}
|
||||
|
||||
// RuleResult 规则输出,保留版本、输入快照、缺失项和计算时间。
|
||||
type RuleResult struct {
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Disease string `json:"disease"`
|
||||
Matched bool `json:"matched"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Missing []string `json:"missing"`
|
||||
InputSnapshot json.RawMessage `json:"inputSnapshot"`
|
||||
ComputedAt time.Time `json:"computedAt"`
|
||||
}
|
||||
|
||||
// EvaluateRules 按版本执行规则,所有缺失输入进入 Missing。
|
||||
func EvaluateRules(ctx RuleContext, rules []RuleVersion) []RuleResult {
|
||||
if ctx.Now.IsZero() {
|
||||
ctx.Now = time.Now()
|
||||
}
|
||||
results := make([]RuleResult, 0, len(rules))
|
||||
input, _ := json.Marshal(ctx)
|
||||
for _, rule := range rules {
|
||||
missing := ruleMissing(ctx, rule)
|
||||
result := RuleResult{
|
||||
RuleVersion: rule.Version,
|
||||
RuleName: rule.Name,
|
||||
Disease: rule.Disease,
|
||||
Missing: missing,
|
||||
InputSnapshot: input,
|
||||
ComputedAt: ctx.Now,
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
switch rule.Name {
|
||||
case "continuous_humidity":
|
||||
result.Matched, result.Level, result.Reason = evaluateContinuousHumidity(ctx, rule)
|
||||
default:
|
||||
result.Matched = false
|
||||
result.Reason = "未配置规则实现"
|
||||
}
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ruleMissing(ctx RuleContext, rule RuleVersion) []string {
|
||||
missing := []string{}
|
||||
hasHumidity := false
|
||||
hasStaleHumidity := false
|
||||
for _, sample := range ctx.Samples {
|
||||
if sample.Metric != "humidity" {
|
||||
continue
|
||||
}
|
||||
if ctx.Now.Sub(sample.Timestamp) > rule.MaxStaleness {
|
||||
hasStaleHumidity = true
|
||||
continue
|
||||
}
|
||||
hasHumidity = true
|
||||
}
|
||||
if rule.Name == "continuous_humidity" {
|
||||
if hasStaleHumidity && !hasHumidity {
|
||||
missing = append(missing, "humidity_stale")
|
||||
}
|
||||
if !hasHumidity {
|
||||
missing = append(missing, "humidity")
|
||||
}
|
||||
}
|
||||
if rule.RequiresDensity && ctx.Density == nil {
|
||||
missing = append(missing, "density")
|
||||
}
|
||||
if rule.RequiresBiosecurity {
|
||||
if ctx.SeedSourceCount == nil {
|
||||
missing = append(missing, "seed_source")
|
||||
}
|
||||
if ctx.DisinfectionCount == nil {
|
||||
missing = append(missing, "disinfection")
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func evaluateContinuousHumidity(ctx RuleContext, rule RuleVersion) (bool, string, string) {
|
||||
byDay := map[string]float64{}
|
||||
for _, sample := range ctx.Samples {
|
||||
if sample.Metric != "humidity" {
|
||||
continue
|
||||
}
|
||||
day := sample.Timestamp.Format("2006-01-02")
|
||||
if sample.Value > byDay[day] {
|
||||
byDay[day] = sample.Value
|
||||
}
|
||||
}
|
||||
days := make([]string, 0, len(byDay))
|
||||
for day := range byDay {
|
||||
days = append(days, day)
|
||||
}
|
||||
sort.Strings(days)
|
||||
for i := 2; i < len(days); i++ {
|
||||
if consecutiveDays(days[i-2], days[i-1], days[i]) && byDay[days[i-2]] >= 80 && byDay[days[i-1]] >= 80 && byDay[days[i]] >= 80 {
|
||||
return true, "orange", fmt.Sprintf("连续 %d 天湿度≥80%%,满足规则 %s", rule.WindowHours, rule.Version)
|
||||
}
|
||||
}
|
||||
return false, "", "连续高湿天数不足或窗口内存在缺失"
|
||||
}
|
||||
|
||||
func consecutiveDays(a, b, c string) bool {
|
||||
parse := func(s string) time.Time {
|
||||
t, _ := time.Parse("2006-01-02", s)
|
||||
return t
|
||||
}
|
||||
da, db, dc := parse(a), parse(b), parse(c)
|
||||
return db.Sub(da) == 24*time.Hour && dc.Sub(db) == 24*time.Hour
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRuleRequiresThreeContinuousDaysOfHumidity(t *testing.T) {
|
||||
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||
samples := []TelemetrySample{
|
||||
{Metric: "humidity", Value: 85, Timestamp: now.Add(-72 * time.Hour)},
|
||||
{Metric: "humidity", Value: 83, Timestamp: now.Add(-48 * time.Hour)},
|
||||
{Metric: "humidity", Value: 82, Timestamp: now.Add(-24 * time.Hour)},
|
||||
}
|
||||
results := EvaluateRules(RuleContext{Now: now, Samples: samples}, []RuleVersion{{
|
||||
Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", WindowHours: 3, MaxStaleness: 100 * time.Hour,
|
||||
}})
|
||||
if len(results) != 1 || !results[0].Matched {
|
||||
t.Fatalf("连续 3 天高湿应命中: %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleTelemetryIsReportedMissing(t *testing.T) {
|
||||
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||
results := EvaluateRules(RuleContext{Now: now, Samples: []TelemetrySample{{
|
||||
Metric: "humidity", Value: 90, Timestamp: now.Add(-24 * time.Hour),
|
||||
}}}, []RuleVersion{{
|
||||
Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", MaxStaleness: 2 * time.Hour,
|
||||
}})
|
||||
if len(results) != 1 || results[0].Matched {
|
||||
t.Fatalf("过期数据不应命中: %+v", results)
|
||||
}
|
||||
found := false
|
||||
for _, key := range results[0].Missing {
|
||||
if key == "humidity_stale" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("应标记 humidity_stale: %+v", results[0].Missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleResultKeepsInputAndVersion(t *testing.T) {
|
||||
now := time.Now()
|
||||
results := EvaluateRules(RuleContext{Now: now}, []RuleVersion{{
|
||||
Version: "env-v1", Name: "missing_rule", Disease: "测试", RequiresBiosecurity: true,
|
||||
}})
|
||||
if results[0].RuleVersion != "env-v1" || results[0].ComputedAt.IsZero() {
|
||||
t.Fatalf("规则结果缺少版本/计算时间: %+v", results[0])
|
||||
}
|
||||
var snapshot map[string]any
|
||||
if err := json.Unmarshal(results[0].InputSnapshot, &snapshot); err != nil {
|
||||
t.Fatalf("inputSnapshot 非法: %v", err)
|
||||
}
|
||||
if len(results[0].Missing) == 0 {
|
||||
t.Fatal("缺失输入应进入 missing")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user