feat(investigations): refine report authoring and replay flushing
This commit is contained in:
@@ -5,13 +5,16 @@ import re
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import Field, JsonValue, field_validator, model_serializer, model_validator
|
||||
from pydantic import Field, JsonValue, StringConstraints, field_validator, model_serializer, model_validator
|
||||
from app.domain.schemas.answer_ui import (
|
||||
CHART_SAMPLE_TIME_PATTERN, ReportUiBlock, validate_chart_sample_time, validate_report_ui_blocks,
|
||||
)
|
||||
|
||||
from app.domain.schemas.task_question import TaskQuestionRequest
|
||||
from app.domain.schemas.shared import NonEmptyStr, PositiveInt, StrictBool, StrictModel
|
||||
from app.domain.schemas.report_authoring import (
|
||||
validate_presentation_command_text, validate_report_text, validate_report_ui_text,
|
||||
)
|
||||
from app.domain.schemas.operational_data import TaskDataSnapshot, TaskDataSource
|
||||
from app.domain.schemas.workbench_runtime import (
|
||||
WorkbenchDateTime,
|
||||
@@ -349,12 +352,44 @@ class InvestigationReport(StrictModel):
|
||||
return self
|
||||
|
||||
|
||||
class InvestigationDataGapInput(InvestigationDataGap):
|
||||
"""New gap descriptions use business prose; references remain opaque."""
|
||||
|
||||
missing_data: NonEmptyStr = Field(max_length=4000, description="缺失的业务资料,不写执行字段")
|
||||
scope: NonEmptyStr = Field(max_length=4000, description="缺口涉及的对象、地点与当地时间范围")
|
||||
impact: NonEmptyStr = Field(max_length=4000, description="资料缺项对当前判断的实际影响")
|
||||
required_data: NonEmptyStr = Field(max_length=4000, description="需要补充的具体业务资料")
|
||||
|
||||
@field_validator("missing_data", "scope", "impact", "required_data")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str) -> str:
|
||||
return validate_report_text(value)
|
||||
|
||||
|
||||
class InvestigationReportInput(InvestigationReport):
|
||||
"""Write model: every new report explicitly declares its business result."""
|
||||
|
||||
title: NonEmptyStr = Field(max_length=80, description="简短中文业务标题,写明对象和当前结果,不含执行轮次、流程版本或原始时间戳")
|
||||
current_conclusion: NonEmptyStr = Field(max_length=300, description="先写当前业务判断,再写关键依据与下一步,建议 150 字以内;完整数值和对象比较使用 ui_blocks.Table")
|
||||
conclusion_boundary: NonEmptyStr = Field(max_length=240, description="只写影响判断的证据范围、不确定性和适用限制,不复述结论、执行步骤或参数")
|
||||
recommendations: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||
pending_items: list[Annotated[NonEmptyStr, Field(max_length=160)]] = Field(default_factory=list)
|
||||
stop_reason: Annotated[NonEmptyStr, Field(max_length=160)] | None = None
|
||||
stage: Literal["interim", "final"]
|
||||
outcome: Literal["concluded", "insufficient_data"]
|
||||
data_gaps: list[InvestigationDataGap]
|
||||
data_gaps: list[InvestigationDataGapInput]
|
||||
|
||||
@field_validator("title", "current_conclusion", "conclusion_boundary", "stop_reason")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_authoring(self):
|
||||
for value in [*self.recommendations, *self.pending_items]:
|
||||
validate_report_text(value)
|
||||
validate_report_ui_text(self.ui_blocks)
|
||||
return self
|
||||
|
||||
|
||||
class InvestigationProgress(StrictModel):
|
||||
@@ -365,8 +400,14 @@ class InvestigationProgress(StrictModel):
|
||||
|
||||
|
||||
class InvestigationProgressInput(InvestigationProgress):
|
||||
summary: NonEmptyStr = Field(max_length=300, description="用简短中文说明本轮新增发现、当前依赖和下一步;不复制报告、执行字段、版本号或证据编号")
|
||||
graph_update: InvestigationGraphUpdate | None = None
|
||||
|
||||
@field_validator("summary")
|
||||
@classmethod
|
||||
def validate_reader_summary(cls, value: str) -> str:
|
||||
return validate_report_text(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_graph_input(self):
|
||||
if self.graph_update is not None and self.diagram is not None:
|
||||
@@ -908,11 +949,72 @@ TaskPresentationCommand = Annotated[
|
||||
]
|
||||
|
||||
|
||||
class TaskPresentationNarration(StrictModel):
|
||||
stage: Literal[
|
||||
"anomaly_detection", "evidence_verification", "asset_localization",
|
||||
"event_impact", "source_control", "isolation_plan", "flushing_plan",
|
||||
"field_feedback", "clearance_verification", "recovery_verification",
|
||||
]
|
||||
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160)]
|
||||
evidence_refs: Annotated[list[TaskFeatureId], Field(min_length=1, max_length=100)]
|
||||
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120)] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_narration(self):
|
||||
if not self.summary.strip() or (self.next_step is not None and not self.next_step.strip()):
|
||||
raise ValueError("presentation narration must contain readable text")
|
||||
if len(set(self.evidence_refs)) != len(self.evidence_refs):
|
||||
raise ValueError("presentation narration evidence IDs must be unique")
|
||||
return self
|
||||
|
||||
|
||||
TaskPresentationPurpose = Annotated[
|
||||
str, StringConstraints(strict=True, strip_whitespace=True, min_length=1, max_length=160),
|
||||
]
|
||||
|
||||
|
||||
class TaskPresentationNarrationInput(TaskPresentationNarration):
|
||||
"""New live findings use business prose; saved narration remains readable."""
|
||||
|
||||
summary: Annotated[str, Field(strict=True, min_length=1, max_length=160,
|
||||
description="本步已取得的关键发现,建议 80 字以内;区分观测、模型候选和现场反馈,不复述动作或核查目的")]
|
||||
next_step: Annotated[str, Field(strict=True, min_length=1, max_length=120,
|
||||
description="实际下一步或等待条件,建议 60 字以内;无后续时省略,不把展示当作现场执行")] | None = None
|
||||
|
||||
@field_validator("summary", "next_step")
|
||||
@classmethod
|
||||
def validate_reader_text(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
|
||||
class TaskPresentationEventCommand(StrictModel):
|
||||
request_id: UUID
|
||||
run_id: UUID
|
||||
source_session_id: NonEmptyStr
|
||||
command: TaskPresentationCommand
|
||||
narration: TaskPresentationNarrationInput | None = None
|
||||
purpose: TaskPresentationPurpose | None = Field(default=None,
|
||||
description="一句中文说明本步为何查看这些对象,建议 60 字以内;不堆砌编号、执行参数或复述当前发现")
|
||||
|
||||
@field_validator("purpose")
|
||||
@classmethod
|
||||
def validate_reader_purpose(cls, value: str | None) -> str | None:
|
||||
return validate_report_text(value) if value is not None else value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_visible_labels(self):
|
||||
validate_presentation_command_text(self.command.model_dump())
|
||||
return self
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize_legacy_command(self, handler):
|
||||
value = handler(self)
|
||||
# Keep old idempotency digests stable when no narration was supplied.
|
||||
if self.narration is None:
|
||||
value.pop("narration", None)
|
||||
if self.purpose is None:
|
||||
value.pop("purpose", None)
|
||||
return value
|
||||
|
||||
|
||||
class TaskPresentationEventResult(StrictModel):
|
||||
@@ -943,6 +1045,8 @@ class TaskPresentationHistoryItem(StrictModel):
|
||||
# Stored records are validated against the current command contract by the
|
||||
# reader; an obsolete record must not prevent reading the rest of the page.
|
||||
command: dict[str, JsonValue]
|
||||
narration: TaskPresentationNarration | None = None
|
||||
purpose: TaskPresentationPurpose | None = None
|
||||
|
||||
|
||||
class TaskPresentationHistoryPage(StrictModel):
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Reader-facing text checks for new publications, never historical reads."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.domain.schemas.answer_ui import ReportUiBlock
|
||||
|
||||
|
||||
_execution_text = re.compile(
|
||||
r"\b(?:execution_generation|handling_context|verification_ready|waiting_work_order|"
|
||||
r"waiting_scada|returned_at|verified_through|required_samples|interval_seconds|"
|
||||
r"knowledge_cutoff|batch_sequence|query_complete|evidence_id|action_id|run_id|"
|
||||
r"task_id|source_session_id|approval_status|execution_status|verification_evidence_id|"
|
||||
r"normal_day_count|baseline_method|selection_method|get_task_context|query_scada_readings|"
|
||||
r"run_burst_location|publish_task_report|publish_task_progress)\b|"
|
||||
r"\b(?:status|phase|interval)\s*[:=]|\b(?:mcp|recovery|sha256):|"
|
||||
r"\bskill(?:\.(?:name|version))?\s*[:=]|\bskill\s+[\w-]+\s+v?\d|"
|
||||
r"\b(?:final|interim)/concluded\b|"
|
||||
r"[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}",
|
||||
re.IGNORECASE | re.ASCII,
|
||||
)
|
||||
_source_time = re.compile(
|
||||
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}|UTC\s*[+-]\s*\d|Asia/Shanghai|上海时间|"
|
||||
r"\d{2}:\d{2}(?::\d{2})?\s*\+\d{2}:\d{2}",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_unrendered_markup = re.compile(r"`|\*\*|</?[A-Za-z][^>]*>|^\s{0,3}#{1,6}\s", re.MULTILINE)
|
||||
|
||||
|
||||
def validate_report_text(value: str) -> str:
|
||||
if _execution_text.search(value):
|
||||
raise ValueError("请将执行字段和状态码改写为中文业务说明,完整编号保留在结构化引用中")
|
||||
if _source_time.search(value):
|
||||
raise ValueError("请将来源时间转换为当地日期和时刻,正文省略时区名称、偏移和 ISO 时间戳")
|
||||
if _unrendered_markup.search(value):
|
||||
raise ValueError("可见文字使用纯文本;重点与明细使用对应结果组件,不写 Markdown 或 HTML")
|
||||
return value
|
||||
|
||||
|
||||
def validate_report_ui_text(blocks: list[ReportUiBlock]) -> None:
|
||||
for item in blocks:
|
||||
validate_report_text(item.block.fallback_text)
|
||||
for element in item.block.spec.elements.values():
|
||||
props = element.props
|
||||
for key in ("title", "summary", "text", "description", "x_axis_name", "y_axis_name"):
|
||||
if isinstance(props.get(key), str):
|
||||
validate_report_text(props[key])
|
||||
for value in props.get("columns", []):
|
||||
validate_report_text(value)
|
||||
for row in props.get("rows", []):
|
||||
for value in row:
|
||||
validate_report_text(value)
|
||||
for value in props.get("x_data", []):
|
||||
validate_report_text(value)
|
||||
for series in props.get("series", []):
|
||||
validate_report_text(series["name"])
|
||||
|
||||
|
||||
def validate_presentation_command_text(command: dict[str, Any]) -> None:
|
||||
"""Check visible labels without touching geometry, references or timestamps."""
|
||||
if command["action"] == "set_overlay":
|
||||
validate_report_text(command["title"])
|
||||
elif command["action"] == "open_chart":
|
||||
chart = command["chart"]
|
||||
for key in ("title", "x_axis_name", "y_axis_name"):
|
||||
if isinstance(chart.get(key), str):
|
||||
validate_report_text(chart[key])
|
||||
for value in chart["x_data"]:
|
||||
validate_report_text(value)
|
||||
for series in chart["series"]:
|
||||
validate_report_text(series["name"])
|
||||
Reference in New Issue
Block a user