feat(investigation): preserve cumulative reference graphs
Accumulate investigation paths and preserve failed attempts across rounds. Keep graph annotations separate from execution state, synchronize the API contract, and consolidate documentation.
This commit is contained in:
+992
@@ -0,0 +1,992 @@
|
||||
[
|
||||
{
|
||||
"name": "zero and sparse historical data",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "old mixed charts are read only",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "line",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing history timestamps",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing observed timestamp",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
null,
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "timestamp on a missing value",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-09T08:00:00+08:00",
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "wrong projected slot",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:30:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mixed historical dates",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-10T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "same day history",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "future history",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-17T08:15:00+08:00",
|
||||
"2026-07-17T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duplicate slots",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:00",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date appended axis",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"07-16 08:00",
|
||||
"07-16 08:15",
|
||||
"07-16 08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "inconsistent slot precision",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bars mixed with comparison",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "bar",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no historical series",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "no current observations",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "missing samples with missing timestamps",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
null,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "UTC spelling preserves projected instant",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T00:15:00Z",
|
||||
"2026-07-09T00:30:00Z"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ordinary line",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "line",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "合成读数",
|
||||
"data": [
|
||||
0,
|
||||
null,
|
||||
30
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ordinary bar",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "bar",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "合成读数",
|
||||
"data": [
|
||||
0,
|
||||
null,
|
||||
30
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "midnight rollover keeps one window",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "跨午夜同期对比",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"23:45",
|
||||
"00:00"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
25
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T23:45:00+08:00",
|
||||
"2026-07-17T00:00:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
26,
|
||||
27
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-15T23:45:00+08:00",
|
||||
"2026-07-16T00:00:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "microseconds are not aligned",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00.000001+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fraction beyond Python microsecond precision",
|
||||
"accepted": true,
|
||||
"chart": {
|
||||
"title": "P1 合成压力七日对照(m)",
|
||||
"chart_type": "time_comparison",
|
||||
"x_axis_name": "时间",
|
||||
"y_axis_name": "压力(m)",
|
||||
"x_data": [
|
||||
"08:00",
|
||||
"08:15",
|
||||
"08:30"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "P1 当前 2026-07-16",
|
||||
"type": "line",
|
||||
"data": [
|
||||
0,
|
||||
28,
|
||||
30
|
||||
],
|
||||
"sample_times": [
|
||||
"2026-07-16T08:00:00.0000001+08:00",
|
||||
"2026-07-16T08:15:00+08:00",
|
||||
"2026-07-16T08:30:00+08:00"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "P1 2026-07-09",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null,
|
||||
32,
|
||||
33
|
||||
],
|
||||
"sample_times": [
|
||||
null,
|
||||
"2026-07-09T08:15:00+08:00",
|
||||
"2026-07-09T08:30:00+08:00"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar overflow",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "calendar overflow",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"07:45"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
1
|
||||
],
|
||||
"sample_times": [
|
||||
"9999-12-31T23:45:00Z"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史缺测",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "calendar underflow",
|
||||
"accepted": false,
|
||||
"chart": {
|
||||
"title": "calendar underflow",
|
||||
"chart_type": "time_comparison",
|
||||
"x_data": [
|
||||
"08:00"
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "当前",
|
||||
"type": "line",
|
||||
"data": [
|
||||
1
|
||||
],
|
||||
"sample_times": [
|
||||
"0001-01-01T00:00:00+23:59"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "历史缺测",
|
||||
"type": "scatter",
|
||||
"data": [
|
||||
null
|
||||
],
|
||||
"sample_times": [
|
||||
null
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}}
|
||||
{"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}}
|
||||
{"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}}
|
||||
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P1","evidence_refs":["dev:synthetic"],"chart":{"title":"P1 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P1 当前 2026-07-16","type":"line","data":[32,28,30],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P1 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P1 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P1 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P1 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P1 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P1 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P1 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P2","evidence_refs":["dev:synthetic"],"chart":{"title":"P2 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P2 当前 2026-07-16","type":"line","data":[31,27,29],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P2 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P2 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P2 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P2 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P2 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P2 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P2 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
|
||||
{"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}}
|
||||
{"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}}
|
||||
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}}
|
||||
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"}
|
||||
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P1"}}
|
||||
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P2"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_highlights"}}
|
||||
{"record_type":"presentation","command":{"action":"clear_scada_analysis"}}
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Opt-in read-only JSONB probes; no tables or business records are written."""
|
||||
import asyncio
|
||||
import os
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.infra.db.postgresql import read_queries
|
||||
|
||||
|
||||
@pytest.mark.parametrize("section", ["progress", "report"])
|
||||
def test_reference_annotations_are_normalized_before_nested_paging(section):
|
||||
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||
|
||||
async def check():
|
||||
statuses = ["running", "completed", "waiting", "waiting_human", "skipped", "cancelled", "failed", None]
|
||||
original = {"section": section, "data": {
|
||||
"revision": 3, "execution_generation": 2,
|
||||
section: {"diagram": {"mermaid": "flowchart TD\nA[原图]", "node_refs": [
|
||||
{"node_id": f"n{index}", "status": status, "detail": "保留原说明", "evidence_refs": ["e1"]}
|
||||
for index, status in enumerate(statuses)
|
||||
] + [{"node_id": "unset"}]}},
|
||||
"status": "running", "action": {"status": "completed"},
|
||||
}}
|
||||
untouched = deepcopy(original)
|
||||
async with await AsyncConnection.connect(
|
||||
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||
) as conn:
|
||||
async def page(path=(), *, limit=50, offset=0, full=False):
|
||||
statement, params = read_queries._reference_diagram_projection(
|
||||
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), section,
|
||||
)
|
||||
return await read_queries._snapshot_page(
|
||||
conn, stored_sql=statement, stored_params=params, data_path=list(path),
|
||||
limit=limit, offset=offset, mcp_sources=[], full_snapshot=full, max_bytes=8000,
|
||||
)
|
||||
|
||||
scalar = await page([section, "diagram", "node_refs", "0", "status"])
|
||||
assert scalar["value"] == "reference"
|
||||
second = await page([section, "diagram", "node_refs"], limit=1, offset=1)
|
||||
assert second["value"][0]["status"] == "recorded"
|
||||
assert second["total"] == 9 and second["nested"]
|
||||
assert second["value"][0]["evidence_refs"]["data_path"] == [section, "diagram", "node_refs", "1", "evidence_refs"]
|
||||
snapshot = await page(full=True)
|
||||
nodes = snapshot["data"][section]["diagram"]["node_refs"]
|
||||
assert [node.get("status") for node in nodes] == [
|
||||
"reference", "recorded", "needs_input", "needs_input", "not_taken", "not_taken", "failed", None, None,
|
||||
]
|
||||
assert "status" not in nodes[-1]
|
||||
assert nodes[6]["detail"] == "保留原说明" and nodes[6]["evidence_refs"] == ["e1"]
|
||||
assert snapshot["data"]["status"] == "running"
|
||||
assert snapshot["data"]["action"]["status"] == "completed"
|
||||
assert snapshot["data"][section]["diagram"]["mermaid"] == original["data"][section]["diagram"]["mermaid"]
|
||||
assert original == untouched
|
||||
|
||||
asyncio.run(check())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("diagram", [None, {}, {"node_refs": []}])
|
||||
def test_reference_projection_preserves_empty_and_missing_legacy_diagrams(diagram):
|
||||
dsn = os.environ.get("MCP_QUERY_TEST_DSN")
|
||||
if not dsn:
|
||||
pytest.skip("MCP_QUERY_TEST_DSN is required for a read-only PostgreSQL JSONB probe")
|
||||
|
||||
async def check():
|
||||
original = {"section": "progress", "data": {"progress": {"diagram": diagram}, "status": "running"}}
|
||||
async with await AsyncConnection.connect(
|
||||
dsn, row_factory=dict_row, options="-c default_transaction_read_only=on",
|
||||
) as conn:
|
||||
statement, params = read_queries._reference_diagram_projection(
|
||||
"SELECT %s::jsonb AS snapshot", (Jsonb(original),), "progress",
|
||||
)
|
||||
result = await read_queries._snapshot_page(
|
||||
conn, stored_sql=statement, stored_params=params, data_path=[],
|
||||
limit=20, offset=0, mcp_sources=[], full_snapshot=True,
|
||||
)
|
||||
assert result == original
|
||||
|
||||
asyncio.run(check())
|
||||
@@ -104,6 +104,19 @@ class TaskRepository:
|
||||
async def get_latest_progress(self, task_id, generation):
|
||||
return self.progress.get((task_id, generation))
|
||||
|
||||
async def get_latest_graph_progress(self, task_id):
|
||||
snapshots = [value for (stored_id, _), value in self.progress.items()
|
||||
if stored_id == task_id
|
||||
and (value["progress"].get("diagram") or {}).get("edges") is not None]
|
||||
return max(snapshots, key=lambda row: (row["execution_generation"], row["revision"])) if snapshots else None
|
||||
|
||||
async def get_latest_diagram(self, task_id):
|
||||
diagrams = [(row["published_at"], row["progress"].get("diagram"))
|
||||
for (stored_id, _), row in self.progress.items() if stored_id == task_id]
|
||||
diagrams.extend((row["published_at"], row["report"].get("diagram")) for row in self.reports)
|
||||
return next((diagram for _, diagram in sorted(diagrams, key=lambda item: item[0], reverse=True)
|
||||
if diagram is not None), None)
|
||||
|
||||
async def save_progress(self, task_id, snapshot):
|
||||
self.progress[(task_id, snapshot["execution_generation"])] = snapshot
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Generated fixtures keep executable examples and publication semantics aligned across repositories."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import TaskPresentationEventCommand, TaskSafeChart
|
||||
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parents[1] / "fixtures"
|
||||
CASES = json.loads((FIXTURES / "chart-publication-cases.json").read_text())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", CASES, ids=lambda case: case["name"])
|
||||
def test_chart_publication_matches_frontend_and_agent(case):
|
||||
def validate():
|
||||
chart = TaskSafeChart.model_validate(case["chart"]).model_dump(mode="json")
|
||||
validate_time_comparison_chart(chart)
|
||||
|
||||
if case["accepted"]:
|
||||
validate()
|
||||
else:
|
||||
with pytest.raises((DomainError, ValidationError)):
|
||||
validate()
|
||||
|
||||
|
||||
def test_historical_mixed_charts_remain_structurally_readable():
|
||||
chart = next(case["chart"] for case in CASES if case["name"] == "old mixed charts are read only")
|
||||
TaskSafeChart.model_validate(chart)
|
||||
|
||||
|
||||
def test_exported_command_examples_use_the_current_publication_contract():
|
||||
for line in (FIXTURES / "unified-presentation.jsonl").read_text().splitlines():
|
||||
record = json.loads(line)
|
||||
if record["record_type"] != "presentation":
|
||||
continue
|
||||
event = TaskPresentationEventCommand.model_validate({
|
||||
"request_id": "95000000-0000-4000-8000-000000000001",
|
||||
"run_id": "95000000-0000-4000-8000-000000000002",
|
||||
"source_session_id": "synthetic-chart-contract",
|
||||
"command": record["command"],
|
||||
})
|
||||
command = event.command
|
||||
if command.action == "open_chart":
|
||||
validate_time_comparison_chart(command.chart.model_dump(mode="json"))
|
||||
@@ -28,7 +28,7 @@ def test_waiting_and_unknown_node_states_survive_progress_and_final_report():
|
||||
stage="final", outcome="concluded", data_gaps=[], diagram=diagram,
|
||||
)
|
||||
assert report.diagram == progress.diagram
|
||||
assert [node.status for node in report.diagram.node_refs] == ["completed", "waiting", None, None]
|
||||
assert [node.status for node in report.diagram.node_refs] == ["recorded", "needs_input", None, None]
|
||||
assert "status" not in report.diagram.node_refs[-1].model_fields_set
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_class_attached_nodes_are_valid_declarations_without_accepting_partial_i
|
||||
"node_refs": [{"node_id": "QUERY", "label": "核查", "status": "running"},
|
||||
{"node_id": "REVIEW", "label": "复核", "status": "pending"}]},
|
||||
}}
|
||||
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "running"
|
||||
assert AgentArtifactsCommand.model_validate(payload).progress.diagram.node_refs[0].status == "reference"
|
||||
payload["progress"]["diagram"]["node_refs"][0]["node_id"] = "QUER"
|
||||
with pytest.raises(ValidationError, match="QUER"):
|
||||
AgentArtifactsCommand.model_validate(payload)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||
from app.domain.schemas.operational_task import InvestigationDiagram, InvestigationGraphUpdate
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot
|
||||
from app.services.investigation_graph import update_investigation_graph
|
||||
from app.services.operational_tasks import OperationalTaskService
|
||||
from tests.support.operational_task_repository import (
|
||||
TaskRepository, _active_run, _context, _task_document,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def setup_service():
|
||||
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
|
||||
context = _context()
|
||||
task = _task_document(context, now, lease=now + timedelta(minutes=1))
|
||||
repo = TaskRepository(task, _active_run(task, now))
|
||||
return task, repo, OperationalTaskService(repo, context, set(), clock=lambda: now)
|
||||
|
||||
|
||||
async def publish(service, repo, task_id, *, nodes=(), edges=(), **progress):
|
||||
command = AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||
"run_id": repo.run["run_id"],
|
||||
"progress": {"summary": "更新调查路线", "graph_update": {
|
||||
"nodes": list(nodes), "edges": list(edges),
|
||||
}, **progress},
|
||||
})
|
||||
return await service.publish_artifacts(task_id, command)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_path_is_retained_when_a_new_route_is_added():
|
||||
task, repo, service = setup_service()
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "read", "label": "读取全量 SCADA", "status": "recorded"},
|
||||
{"node_id": "locate_1", "label": "第一次候选定位", "status": "reference"},
|
||||
], edges=[{"source": "read", "target": "locate_1"}])
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "locate_1", "label": "第一次候选定位", "status": "failed", "detail": "计算超时"},
|
||||
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "reference"},
|
||||
], edges=[{"source": "locate_1", "target": "locate_2", "label": "调整后重试"}])
|
||||
result = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "locate_2", "label": "调整窗口后重试", "status": "recorded"},
|
||||
])
|
||||
diagram = result.task.latest_progress.progress.diagram
|
||||
assert [(n.node_id, n.status) for n in diagram.node_refs] == [
|
||||
("read", "recorded"), ("locate_1", "failed"), ("locate_2", "recorded"),
|
||||
]
|
||||
assert diagram.node_refs[1].detail == "计算超时"
|
||||
assert len(diagram.edges) == 2
|
||||
assert "第一次候选定位(失败)" in diagram.mermaid
|
||||
assert 'locate_1 -->|"调整后重试"| locate_2' in diagram.mermaid
|
||||
# A text-only update cannot clear the accumulated graph.
|
||||
summary = AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||
"run_id": repo.run["run_id"], "progress": {"summary": "等待人工", "diagram": None},
|
||||
})
|
||||
assert (await service.publish_artifacts(task["task_id"], summary)).task.latest_progress.progress.diagram == diagram
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_failed_attempt_cannot_be_relabelled_as_a_success():
|
||||
task, repo, service = setup_service()
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "attempt_1", "label": "候选定位", "status": "failed", "detail": "超时"},
|
||||
])
|
||||
with pytest.raises(DomainError, match="new node"):
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "attempt_1", "label": "候选定位", "status": "recorded"},
|
||||
])
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_new_execution_round_continues_the_same_task_graph():
|
||||
task, repo, service = setup_service()
|
||||
first = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "first", "label": "首次查询", "status": "failed"},
|
||||
])
|
||||
archived = first.task.latest_progress.progress.diagram.model_dump(mode="json")
|
||||
repo.task["execution_generation"] = 2
|
||||
repo.task["execution_request_id"] = str(uuid4())
|
||||
repo.run["status"] = "succeeded"
|
||||
repo.run = _active_run(repo.task, datetime(2026, 11, 10, tzinfo=timezone.utc))
|
||||
repo.runs[repo.run["run_id"]] = repo.run
|
||||
assert (await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs[0].status == "failed"
|
||||
result = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "second", "label": "补充信息后查询", "status": "recorded"},
|
||||
], edges=[{"source": "first", "target": "second"}])
|
||||
assert result.progress_revision == 1
|
||||
assert result.task.latest_progress.execution_generation == 2
|
||||
assert len(result.task.latest_progress.progress.diagram.node_refs) == 2
|
||||
assert repo.progress[(task["task_id"], 1)]["progress"]["diagram"] == archived
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_reports_archive_the_host_graph_and_keep_older_versions_unchanged():
|
||||
task, repo, service = setup_service()
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "failed", "label": "数据读取", "status": "failed", "detail": "接口超时"},
|
||||
])
|
||||
command = AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||
"run_id": repo.run["run_id"], "report": {
|
||||
"title": "调查", "current_conclusion": "已有阶段判断", "conclusion_boundary": "限于现有资料",
|
||||
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||
"diagram": {"title": "重新生成的图", "mermaid": "flowchart TD\nNEW[新图]"},
|
||||
},
|
||||
})
|
||||
first = await service.publish_artifacts(task["task_id"], command)
|
||||
assert first.task.latest_report.report.diagram.node_refs[0].status == "failed"
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "retry", "label": "重新读取", "status": "recorded"},
|
||||
], edges=[{"source": "failed", "target": "retry"}])
|
||||
assert len((await service.report(task["task_id"], 1)).report.diagram.node_refs) == 1
|
||||
assert len((await service.detail(task["task_id"])).latest_progress.progress.diagram.node_refs) == 2
|
||||
# Idempotent replays do not append nodes or change the archived report.
|
||||
assert (await service.publish_artifacts(task["task_id"], command)).report_revision == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_graph_updates_validate_references_and_cannot_replace_a_managed_graph():
|
||||
task, repo, service = setup_service()
|
||||
with pytest.raises(DomainError, match="unknown evidence"):
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "unknown", "label": "未知依据", "evidence_refs": ["forged"]},
|
||||
])
|
||||
await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "kept", "label": "实际步骤", "status": "recorded"},
|
||||
])
|
||||
with pytest.raises(DomainError, match="graph_update"):
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid4()), "source_session_id": repo.run["agent_session_id"],
|
||||
"run_id": repo.run["run_id"], "progress": {
|
||||
"summary": "重画", "diagram": {"title": "替换", "mermaid": "flowchart TD\nNEW[新图]"},
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
def test_graph_renderer_escapes_labels_and_keeps_explicit_parallel_and_failed_paths():
|
||||
graph = update_investigation_graph(None, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [
|
||||
{"node_id": "start", "label": '核查 "地点" <script> ``` %%{init: {}}'},
|
||||
{"node_id": "left", "label": "监测", "status": "failed"},
|
||||
{"node_id": "right", "label": "工单", "status": "reference"},
|
||||
], "edges": [{"source": "start", "target": target} for target in ["left", "right"]],
|
||||
}))
|
||||
assert "<script>" not in graph.mermaid
|
||||
assert "#lt;script#gt;" in graph.mermaid
|
||||
assert "#quot;地点#quot;" in graph.mermaid
|
||||
assert "```" not in graph.mermaid and "%%{" not in graph.mermaid
|
||||
assert "监测(失败)" in graph.mermaid
|
||||
updated = update_investigation_graph(graph, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "right", "label": "工单", "status": "recorded"}],
|
||||
"edges": [{"source": "start", "target": "right"}],
|
||||
}))
|
||||
assert len(updated.edges) == 2
|
||||
assert graph.node_refs[2].status == "reference"
|
||||
|
||||
|
||||
def test_first_structured_update_preserves_an_existing_mermaid_without_reinterpreting_it():
|
||||
original = InvestigationDiagram.model_validate({
|
||||
"title": "原调查路线", "mermaid": 'flowchart LR\nA{核查条件} -->|具备| B[原路径]\nA --> C[另一分支]',
|
||||
"node_refs": [{"node_id": "B", "label": "原路径", "status": "failed", "detail": "查询超时"}],
|
||||
})
|
||||
updated = update_investigation_graph(original, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "reference"}],
|
||||
"edges": [{"source": "B", "target": "retry"}],
|
||||
}))
|
||||
assert updated.mermaid.startswith(original.mermaid + "\n")
|
||||
assert updated.source_mermaid == original.mermaid
|
||||
assert updated.node_refs[0].status == "failed"
|
||||
final = update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "retry", "label": "改用其他数据", "status": "recorded"}],
|
||||
}))
|
||||
assert final.mermaid.count("A{核查条件}") == 1
|
||||
assert "B --> retry" in final.mermaid
|
||||
with pytest.raises(DomainError, match="original Mermaid"):
|
||||
update_investigation_graph(updated, InvestigationGraphUpdate.model_validate({
|
||||
"nodes": [{"node_id": "C", "label": "与旧图冲突的新步骤"}],
|
||||
}))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_first_update_uses_the_last_saved_legacy_report_graph():
|
||||
task, repo, service = setup_service()
|
||||
repo.reports.append({
|
||||
"revision": 1, "execution_generation": 1, "source_session_id": "session-1",
|
||||
"published_at": "2026-11-09T00:00:00Z", "published_by": "agent", "report": {
|
||||
"title": "旧报告", "current_conclusion": "已有判断", "conclusion_boundary": "已有证据",
|
||||
"diagram": {"title": "旧路线", "mermaid": "flowchart TD\nold[此前查询]",
|
||||
"node_refs": [{"node_id": "old", "label": "此前查询", "status": "failed"}]},
|
||||
},
|
||||
})
|
||||
result = await publish(service, repo, task["task_id"], nodes=[
|
||||
{"node_id": "retry", "label": "接续查询", "status": "reference"},
|
||||
], edges=[{"source": "old", "target": "retry"}])
|
||||
diagram = result.task.latest_progress.progress.diagram
|
||||
assert diagram.source_mermaid == "flowchart TD\nold[此前查询]"
|
||||
assert [n.node_id for n in diagram.node_refs] == ["old", "retry"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("update,code", [
|
||||
({"nodes": []}, "GRAPH_NODE_REQUIRED"),
|
||||
({"nodes": [{"node_id": "end", "label": "保留字"}]}, "GRAPH_NODE_ID_INVALID"),
|
||||
({"nodes": [{"node_id": "n", "label": "一"}, {"node_id": "n", "label": "二"}]}, "GRAPH_NODE_DUPLICATE"),
|
||||
({"nodes": [{"node_id": "n", "label": "一"}], "edges": [{"source": "n", "target": "missing"}]}, "GRAPH_EDGE_NODE_NOT_FOUND"),
|
||||
])
|
||||
def test_graph_updates_reject_invalid_structure_without_erasing_history(update, code):
|
||||
with pytest.raises(DomainError) as error:
|
||||
update_investigation_graph(None, InvestigationGraphUpdate.model_validate(update))
|
||||
assert error.value.code == code
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("node_refs", [[], [{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "查询超时"}]])
|
||||
async def test_summary_only_adopts_legacy_graph_before_the_first_incremental_update(node_refs):
|
||||
task, repo, service = setup_service()
|
||||
original = {"title": "旧路线", "mermaid": "flowchart LR\nold{旧尝试} --> other[另一分支]", "node_refs": node_refs}
|
||||
for progress in [{"summary": "此前调查", "diagram": original}, {"summary": "继续调查"}]:
|
||||
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", run_id=repo.run["run_id"], progress=progress,
|
||||
))
|
||||
diagram = result.task.latest_progress.progress.diagram
|
||||
assert diagram is not None
|
||||
assert diagram.mermaid == diagram.source_mermaid == original["mermaid"]
|
||||
assert diagram.edges == []
|
||||
assert diagram.node_refs == InvestigationDiagram.model_validate(original).node_refs
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_identical_effective_reports_do_not_repeat_notifications_for_inherited_graph_evidence():
|
||||
task, repo, service = setup_service()
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="query_scada_readings", source_ref="probe", queried_at=service._now(),
|
||||
origin="system_data", truncated=False, data={"data": []},
|
||||
))
|
||||
await publish(service, repo, task["task_id"], nodes=[{
|
||||
"node_id": "finding", "label": "已有结果", "status": "recorded", "evidence_refs": [evidence.evidence_id],
|
||||
}])
|
||||
saved = []
|
||||
for _ in range(2):
|
||||
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={
|
||||
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
|
||||
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||
},
|
||||
))
|
||||
saved.append(result.task.latest_report.report)
|
||||
assert saved[0] == saved[1]
|
||||
assert saved[0].evidence_refs == [evidence.evidence_id]
|
||||
assert len([notice for notice in repo.notifications if notice["kind"] == "report_updated"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_final_scada_report_validates_the_effective_inherited_evidence():
|
||||
task, repo, service = setup_service()
|
||||
task["workflow"] = "scada_anomaly"
|
||||
now = service._now()
|
||||
evidence, _ = await service.record_data_snapshot(task["task_id"], TaskDataSnapshot(
|
||||
source="query_scada_readings", source_ref="complete-scada", queried_at=now,
|
||||
origin="system_data", truncated=False, data={
|
||||
"data": {"coverage": {"mode": "all_scada", "query_complete": True}},
|
||||
"window": {"start": (now - timedelta(hours=1)).isoformat(), "end": now.isoformat()},
|
||||
},
|
||||
))
|
||||
await publish(service, repo, task["task_id"], nodes=[{
|
||||
"node_id": "all_scada", "label": "全量读取", "evidence_refs": [evidence.evidence_id],
|
||||
}])
|
||||
result = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", report={
|
||||
"title": "调查", "current_conclusion": "已有判断", "conclusion_boundary": "当前资料",
|
||||
"stage": "final", "outcome": "concluded", "data_gaps": [],
|
||||
},
|
||||
))
|
||||
assert result.task.latest_report.report.evidence_refs == [evidence.evidence_id]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_evidence_only_publication_does_not_load_the_graph_baseline(monkeypatch):
|
||||
task, repo, service = setup_service()
|
||||
calls = []
|
||||
original = repo.get_latest_graph_progress
|
||||
|
||||
async def load_graph(task_id):
|
||||
calls.append(task_id)
|
||||
return await original(task_id)
|
||||
|
||||
# The returned task detail can read progress; count separately from publication.
|
||||
original_detail = service.detail
|
||||
|
||||
async def detail(task_id):
|
||||
before_detail = len(calls)
|
||||
result = await original_detail(task_id)
|
||||
del calls[before_detail:]
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(repo, "get_latest_graph_progress", load_graph)
|
||||
monkeypatch.setattr(service, "detail", detail)
|
||||
await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", evidence=[{
|
||||
"evidence_id": "demo", "kind": "synthetic", "summary": "演练依据",
|
||||
"source_label": "fixture", "observed_at": service._now(),
|
||||
}],
|
||||
))
|
||||
assert calls == []
|
||||
@@ -0,0 +1,45 @@
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.migrate_investigation_graphs import prepare_progress_document
|
||||
from tests.support.operational_task_repository import _active_run, _context, _task_document
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_refs", [[], [
|
||||
{"node_id": "old", "label": "旧尝试", "status": "failed", "detail": "原查询超时", "evidence_refs": ["e1"]},
|
||||
{"node_id": "next", "label": "后续核查", "status": "running"},
|
||||
]])
|
||||
def test_migration_keeps_source_paths_failures_and_original_publication_provenance(node_refs):
|
||||
now = datetime(2026, 11, 10, tzinfo=timezone.utc)
|
||||
task = _task_document(_context(), now)
|
||||
run = _active_run(task, now)
|
||||
document = {
|
||||
"revision": 7, "run_id": run["run_id"], "execution_generation": 1,
|
||||
"source_session_id": "session-1", "published_at": now.isoformat(),
|
||||
"progress": {"summary": "原调查说明", "diagram": {"title": "旧图", "node_refs": node_refs,
|
||||
"mermaid": "flowchart LR\nold{旧尝试} -->|失败后重试| next[后续核查]"}},
|
||||
}
|
||||
original = deepcopy(document)
|
||||
migrated = prepare_progress_document(document)
|
||||
assert document == original
|
||||
assert migrated["revision"] == 8
|
||||
assert {key: migrated[key] for key in original if key not in {"revision", "progress"}} == {
|
||||
key: original[key] for key in original if key not in {"revision", "progress"}
|
||||
}
|
||||
assert migrated["progress"]["summary"] == original["progress"]["summary"]
|
||||
graph = migrated["progress"]["diagram"]
|
||||
assert graph["mermaid"] == graph["source_mermaid"] == original["progress"]["diagram"]["mermaid"]
|
||||
assert graph["edges"] == []
|
||||
if node_refs:
|
||||
assert graph["node_refs"][0]["detail"] == "原查询超时"
|
||||
assert graph["node_refs"][0]["evidence_refs"] == ["e1"]
|
||||
assert [node["status"] for node in graph["node_refs"]] == ["failed", "reference"]
|
||||
assert prepare_progress_document(migrated) is None
|
||||
|
||||
|
||||
def test_migration_does_not_invent_a_graph_when_none_was_saved():
|
||||
document = {"progress": {"summary": "仅说明", "diagram": None}}
|
||||
assert prepare_progress_document(document) is None
|
||||
assert document == {"progress": {"summary": "仅说明", "diagram": None}}
|
||||
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas.operational_task import InvestigationDiagramNodeRef, InvestigationGraphUpdate
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["reference", "recorded", "needs_input", "failed", "not_taken"])
|
||||
def test_graph_updates_only_describe_reference_steps_and_recorded_outcomes(status):
|
||||
update = InvestigationGraphUpdate.model_validate({"nodes": [{"node_id": "check", "label": "核查", "status": status}]})
|
||||
assert update.nodes[0].status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize("legacy,expected", [
|
||||
("pending", "reference"), ("running", "reference"), ("completed", "recorded"),
|
||||
("waiting", "needs_input"), ("waiting_human", "needs_input"),
|
||||
("skipped", "not_taken"), ("cancelled", "not_taken"),
|
||||
])
|
||||
def test_old_diagrams_are_read_as_reference_annotations_without_changing_stored_documents(legacy, expected):
|
||||
document = {"node_id": "check", "label": "核查", "status": legacy}
|
||||
assert InvestigationDiagramNodeRef.model_validate(document).status == expected
|
||||
assert document["status"] == legacy
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["running", "pending", "completed", "waiting", "waiting_human", "skipped", "cancelled"])
|
||||
def test_new_incremental_writes_reject_execution_lifecycle_states(status):
|
||||
with pytest.raises(ValidationError):
|
||||
InvestigationGraphUpdate.model_validate({"nodes": [{"node_id": "check", "label": "核查", "status": status}]})
|
||||
@@ -314,12 +314,13 @@ async def test_progress_can_publish_without_conclusion_and_replaces_the_complete
|
||||
updated = await service.publish_artifacts(task["task_id"], replacement)
|
||||
assert updated.progress_revision == 2
|
||||
assert [node.node_id for node in updated.task.latest_progress.progress.diagram.node_refs] == ["other"]
|
||||
cleared = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
retained = await service.publish_artifacts(task["task_id"], AgentArtifactsCommand(
|
||||
request_id=uuid4(), source_session_id="session-1", run_id=repository.run["run_id"],
|
||||
progress={"summary": "当前不需要图示。", "diagram": None},
|
||||
progress={"summary": "补充本轮说明。", "diagram": None},
|
||||
))
|
||||
assert cleared.progress_revision == 3
|
||||
assert cleared.task.latest_progress.progress.diagram is None
|
||||
assert retained.progress_revision == 3
|
||||
assert retained.task.latest_progress.progress.diagram.mermaid == updated.task.latest_progress.progress.diagram.mermaid
|
||||
assert retained.task.latest_progress.progress.diagram.edges == []
|
||||
assert not repository.notifications
|
||||
|
||||
|
||||
@@ -1318,7 +1319,7 @@ async def test_final_report_archives_its_progress_diagram_across_new_rounds():
|
||||
)
|
||||
await service.publish_artifacts(task["task_id"], command)
|
||||
assert repository.reports[-1]["report"]["diagram"]["title"] == "调查过程"
|
||||
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "running"
|
||||
assert repository.reports[-1]["report"]["diagram"]["node_refs"][0]["status"] == "reference"
|
||||
assert command.report.diagram is None # Do not change the idempotency request.
|
||||
repository.task["execution_generation"] = 2
|
||||
detail = await service.detail(task["task_id"])
|
||||
|
||||
Reference in New Issue
Block a user