fix(charts): validate same-time comparison publications
Generic Container CI/CD / test-build-publish (push) Failing after 2m10s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 2m10s

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:
2026-11-08 03:04:14 +08:00
parent c0dea2aacf
commit b8359ba83c
10 changed files with 261 additions and 8 deletions
+78
View File
@@ -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 必须来自当前窗口之前的对应日期,不能使用当天或未来采样")