feat: 完善环境规则、会诊治理、知识审核与效果评估

This commit is contained in:
weijuesen
2026-08-14 10:33:17 +08:00
parent 69ef5a0704
commit 126c215a6b
27 changed files with 963 additions and 88 deletions
@@ -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)
}
}
+150
View File
@@ -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")
}
}