fix(charts): validate same-time comparison publications
Prompt-only projection rules allowed historical samples to be appended to the current axis. Enforce typed comparison semantics at presentation and report publication, preserving historical read contracts and returning actionable validation errors.
This commit is contained in:
@@ -722,7 +722,7 @@ class TaskSafeChartSeries(StrictModel):
|
||||
|
||||
class TaskSafeChart(StrictModel):
|
||||
title: Annotated[str, Field(max_length=200)]
|
||||
chart_type: Literal["line", "bar"]
|
||||
chart_type: Literal["line", "bar", "time_comparison"]
|
||||
x_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
||||
y_axis_name: Annotated[str, Field(max_length=128)] | None = None
|
||||
x_data: Annotated[list[Annotated[str, Field(max_length=128)]], Field(min_length=1, max_length=100)]
|
||||
|
||||
@@ -74,6 +74,7 @@ from app.services.content_digest import digest
|
||||
from app.services.operational_presentation import resolve_overlay
|
||||
from app.services.operational_recovery import accept_completion, track_work_order
|
||||
from app.services.operational_scada_coverage import require_complete_scada_evidence
|
||||
from app.services.time_comparison_chart import validate_time_comparison_chart
|
||||
|
||||
|
||||
LEASE_DURATION = timedelta(seconds=60)
|
||||
@@ -837,6 +838,11 @@ class OperationalTaskService:
|
||||
"Only a running run accepts Agent artifacts", status=409)
|
||||
now = self._now()
|
||||
self._require_active_run_lease(active_run, now)
|
||||
if command.report:
|
||||
for item in command.report.ui_blocks:
|
||||
for element in item.block.spec.elements.values():
|
||||
if element.type == "Chart":
|
||||
validate_time_comparison_chart(element.props)
|
||||
if command.report and (
|
||||
command.report.stage == "final" or command.report.outcome == "insufficient_data"
|
||||
):
|
||||
@@ -1095,6 +1101,7 @@ class OperationalTaskService:
|
||||
self._require_active_run_lease(run, now)
|
||||
payload = _json(command.command)
|
||||
if command.command.action == "open_chart":
|
||||
validate_time_comparison_chart(payload["chart"])
|
||||
known = {item["evidence_id"] for item in await self.repo.list_evidence(str(task_id))}
|
||||
self._require_evidence_refs(command.command.evidence_refs, known)
|
||||
elif command.command.action == "set_overlay":
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Publication-only validation for same-time-of-day line/scatter comparisons."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.domain.domain_errors import require
|
||||
|
||||
|
||||
DISPLAY_TIMEZONE = timezone(timedelta(hours=8))
|
||||
DAY_SECONDS = 86400
|
||||
|
||||
|
||||
def validate_time_comparison_chart(chart: Mapping[str, Any]) -> None:
|
||||
"""Validate new publications; persisted historical charts keep their read contract."""
|
||||
chart_type = chart["chart_type"]
|
||||
series = chart["series"]
|
||||
kinds = {item.get("type") or chart_type for item in series}
|
||||
if chart_type != "time_comparison":
|
||||
require(not {"line", "scatter"} <= kinds, "TIME_COMPARISON_INVALID",
|
||||
"曲线与历史散点复合图须使用 chart_type=time_comparison", status=422)
|
||||
return
|
||||
|
||||
def check(condition: bool, message: str) -> None:
|
||||
require(condition, "TIME_COMPARISON_INVALID", message, status=422)
|
||||
|
||||
kinds = {item.get("type") or "line" for item in series}
|
||||
check(kinds == {"line", "scatter"},
|
||||
"time_comparison 的 series 必须包含当前 line 和历史 scatter,不能混入 bar")
|
||||
labels = chart["x_data"]
|
||||
check(all(re.fullmatch(r"(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?", label)
|
||||
for label in labels),
|
||||
"time_comparison.x_data 只接受 HH:mm 或 HH:mm:ss 时刻;历史日期保留在 sample_times,不能追加到横轴")
|
||||
check(len({len(label) for label in labels}) == 1, "x_data 必须使用一致的时刻精度")
|
||||
seconds = [sum(int(part) * factor for part, factor in zip(label.split(":"), (3600, 60, 1)))
|
||||
for label in labels]
|
||||
offsets = [0]
|
||||
for index in range(1, len(seconds)):
|
||||
step = (seconds[index] - seconds[index - 1]) % DAY_SECONDS
|
||||
check(step > 0, f"x_data[{index}] 重复了上一时刻;历史点应复用当前槽位")
|
||||
offsets.append(offsets[-1] + step)
|
||||
check(offsets[-1] < DAY_SECONDS, "x_data 必须按时间递增且小于一天,不能追加多天历史槽位")
|
||||
|
||||
current_bases: set[datetime] = set()
|
||||
historical_bases: list[datetime] = []
|
||||
for series_index, item in enumerate(series):
|
||||
times = item.get("sample_times")
|
||||
path = f"series[{series_index}]"
|
||||
check(times is not None and len(times) == len(labels), f"{path}.sample_times 必须提供并与 x_data 等长")
|
||||
bases: set[datetime] = set()
|
||||
for index, (value, at) in enumerate(zip(item["data"], times, strict=True)):
|
||||
check((value is None) == (at is None),
|
||||
f"{path}.sample_times[{index}]:每个有效值必须保留真实带时区时间,缺测值和时间均填 null")
|
||||
if at is None:
|
||||
continue
|
||||
try:
|
||||
local = datetime.fromisoformat(at).astimezone(DISPLAY_TIMEZONE)
|
||||
base = local - timedelta(seconds=offsets[index])
|
||||
except OverflowError:
|
||||
check(False, f"{path}.sample_times[{index}] 超出同期对比可表达的日期范围")
|
||||
label = labels[index]
|
||||
expected = local.strftime("%H:%M:%S" if len(label) == 8 else "%H:%M")
|
||||
check(expected == label and local.microsecond == 0 and (len(label) == 8 or local.second == 0),
|
||||
f"{path}.sample_times[{index}] 换算为 UTC+8 后须与 x_data[{index}]={label} 对齐")
|
||||
bases.add(base)
|
||||
check(len(bases) <= 1, f"{path}.sample_times 必须按同一窗口对齐,不能将不同日期拼入同一组")
|
||||
if (item.get("type") or "line") == "line":
|
||||
current_bases.update(bases)
|
||||
else:
|
||||
historical_bases.extend(bases)
|
||||
check(len(current_bases) == 1, "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口")
|
||||
current = next(iter(current_bases))
|
||||
for historical in historical_bases:
|
||||
delta = (current - historical).total_seconds()
|
||||
check(delta > 0 and delta % DAY_SECONDS == 0,
|
||||
"历史 scatter 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")
|
||||
Reference in New Issue
Block a user