Files
iAOP/core/shift-handover/handover.py
bot_dev1 5bc7ff957e feat(#53): 移动端交接班摘要生成(NL,PRD 场景C ⑤ 移动端交接班摘要)
对应 PRD 场景C(辅助):交接班 → LLM 汇总本班关键事件/能耗/待办 → 生成交接班
报告 → 推送下一班(line 84/350-351)。把本班原始数据 → 移动端交接班摘要这条
链路模板化、可配置、可测试。

新增 core/shift-handover 内核模块:
- handover.py:班次数据归一化(ShiftRecord) + 摘要配置(HandoverBriefConfig) +
  validate/load 校验对 + build_llm_input(填 shift_handover v1.0.1 提示词占位符) +
  generate_handover_brief(LLM 注入生成, 离线/故障自动降级为确定性摘要) +
  render_handover_brief(编译移动端只读卡片 props)
- 零运行时依赖(仅标准库); LLM 以依赖注入传入, 内核不绑定云端 SDK
- 离线/LLM故障降级保证可用性≥99.8%(PRD 模型服务故障自动降级)
- 含 P0/安全事件时强制 requireConfirm=true(PRD 高利害人工确认)

新增资产/测试/验收:
- templates/ti-cl4/dashboard/handover_brief.ti.yaml(Ti 模板配置资产)
- tests/test_handover.py(32 用例全通过) + tests/_bootstrap.py(连字符目录挂载)
- scripts/verify_handover_brief.py(4 能力点全通过: 配置合法/LLM+降级/
  配置点驱动展示/≤2分钟验收线)
- README.md

验证: python -m unittest discover -s tests (32 OK) +
      python scripts/verify_handover_brief.py (全部通过)
2026-08-05 03:38:07 +08:00

686 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""移动端交接班摘要生成(issue #53 / PRD 5.4「④ LLM 网关」+ 场景C「⑤ 配置化驾驶舱」)。
对应 PRD **场景C(辅助)**:
交接班 → LLM 汇总本班关键事件/能耗/待办 → 生成交接班报告 → 推送下一班。
以及 PRD 验收口径:
- 「LLM 报警解释/交接班报告/ NL查询可用 …… **交接班报告生成 ≤ 2 分钟**」;
- 「驾驶舱按 Ti 布局渲染,**移动端交接班摘要可用**」(PRD line 284/351)。
本模块把"本班原始数据 → 移动端交接班摘要"这条链路**模板化、可配置、可测试**:
1. **班次数据归一化**(``ShiftRecord`` / ``normalize_shift_record``):把本班
关键事件 / 能耗 / 待办 / 异常告警等异构输入归一为一份内存模型;
2. **移动端摘要配置**(``HandoverBriefConfig``):摘要包含哪些章节、移动端
展示策略(折叠/字号/最多条数/是否高利害人工确认)、绑定的 LLM 提示词模板名
(``shift_handover`` v1.0.1,见 ``core/llm-gateway/config/prompts.template.yaml``);
3. **LLM 出参渲染**(``build_llm_input``):把班次数据填入 ``shift_handover``
提示词的占位符(``{query}``),产出给 LLM 网关的输入;
4. **摘要生成**(``generate_handover_brief``):调用传入的 LLM 生成函数;当
未配置 LLM(dry-run / 离线 / 单测)时回落到**确定性模板渲染**,保证:
- 离线/降级时仍有可用摘要(PRD「可用性 ≥ 99.8%」「模型服务故障自动降级」);
- 单测无需真实 LLM 即可断言结构与耗时(验收线 ≤ 2 分钟);
5. **移动端负载编译**(``render_handover_brief``):把摘要 + 班次要点编译成
移动端只读卡片可直接消费的 props dict(camelCase、扁平、可 ``json.dumps``)。
设计要点
--------
- **零运行时依赖**:与 cockpit / data-bus / llm-gateway 一致,只用标准库;
LLM 调用以**依赖注入**形式传入(``llm_generate: Callable[[str], str]``),
不在内核里硬编码云端 SDK,便于本地闭环与离线降级。
- **声明式 + 强校验**:``validate_handover_config`` 收集全部字段级错误
(沿用 #50 / #52 的多错误聚合风格),``load_handover_config`` 校验失败抛
``HandoverConfigError`` 并携带错误清单。
- **与未合并分支解耦**:不 import ``llm-gateway`` 的 ``PromptRegistry``(其
feature 分支尚未合入 main);仅按约定的 ``prompt_template`` 名字产出 LLM
输入文本,待 llm-gateway 合入后由调用方注入真正的 LLM 生成函数。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional, Tuple
# ---------------------------------------------------------------------------
# 版本标识(被交接班摘要配置资产的 $schema 引用)
# ---------------------------------------------------------------------------
HANDOVER_CONFIG_SCHEMA_ID: str = "iAOP-cockpit-handover-brief-v1"
HANDOVER_CONFIG_SCHEMA_VERSION: int = 1
# ---------------------------------------------------------------------------
# 提示词模板绑定(与 core/llm-gateway/config/prompts.template.yaml 对齐)
# ---------------------------------------------------------------------------
# PRD 5.4「Prompt 版本管理」:交接班摘要绑定的提示词模板名 + 版本。
# prompts.template.yaml 中 shift_handover v1.0.1(current)正文含
# 「生产概况/异常事项/安全注意事项」三个章节(v1.0.1 相对 v1.0.0 补充安全章节)。
DEFAULT_PROMPT_TEMPLATE: str = "shift_handover"
DEFAULT_PROMPT_VERSION: str = "1.0.1"
# ---------------------------------------------------------------------------
# 移动端展示策略(默认值,可被模板配置资产覆盖)
# ---------------------------------------------------------------------------
# PRD NFR:移动端只读并发 ≥ 200;交接班摘要是只读卡片,默认折叠次要章节。
DEFAULT_MAX_EVENTS: int = 8
MAX_ALLOWED_EVENTS: int = 50
DEFAULT_MAX_TODOS: int = 5
MAX_ALLOWED_TODOS: int = 30
# 移动端章节折叠阈值:超过该条数则折叠为「展开更多」。
DEFAULT_COLLAPSE_THRESHOLD: int = 4
# 移动端字号档位(与驾驶舱主题档位对齐)。
VALID_FONT_SIZES: Tuple[str, ...] = ("sm", "md", "lg")
# 合法的章节顺序键(与 shift_handover 提示词三章节 + 摘要额外章节对齐)。
VALID_SECTIONS: Tuple[str, ...] = (
"overview", # 生产概况
"abnormal", # 异常事项
"safety", # 安全注意事项
"energy", # 能耗
"todos", # 待办(交下一班)
)
# 高利害标记:交接班摘要通常非高利害(scenarios.template.yaml: high_stakes=false),
# 但若本班出现 P0 告警 / 安全事件,可强制 require_confirm=true(PRD 高利害人工确认)。
DEFAULT_REQUIRE_CONFIRM: bool = False
# 验收线:交接班报告生成 ≤ 2 分钟(PRD line 350),单位毫秒。
HANDOVER_GENERATION_BUDGET_MS: int = 120_000
# ---------------------------------------------------------------------------
# 异常 / 结果
# ---------------------------------------------------------------------------
class HandoverConfigError(ValueError):
"""交接班摘要配置校验失败。``load_handover_config`` 在校验不通过时抛出。
``errors`` 收集全部字段级错误,沿用 #50 / #52 的多错误聚合风格。
"""
def __init__(self, errors: List[str]):
super().__init__(
"; ".join(errors) if errors else "handover brief config validation failed"
)
self.errors: List[str] = list(errors)
@dataclass
class HandoverConfigValidationResult:
"""``validate_handover_config`` 的返回值。"""
ok: bool
errors: List[str] = field(default_factory=list)
normalized: Optional[Dict[str, Any]] = None
# ---------------------------------------------------------------------------
# 班次数据内存模型
# ---------------------------------------------------------------------------
@dataclass
class ShiftEvent:
"""本班一条关键事件(告警 / 操作 / 质量波动等)。
Attributes:
time: 事件时间(ISO 字符串或可读文本,如 ``"03:12"``)。
title: 事件标题(移动端卡片主文案)。
severity: 严重度(``P0`` / ``P1`` / ``P2`` / ``info``;与告警面板对齐)。
detail: 可选详情(移动端「展开更多」)。
"""
time: str
title: str
severity: str = "info"
detail: Optional[str] = None
@dataclass
class ShiftTodo:
"""交接给下一班的待办。
Attributes:
title: 待办内容。
priority: 优先级(``high`` / ``medium`` / ``low``)。
due: 可选截止提示(如 ``"接班后 30min 内"``)。
"""
title: str
priority: str = "medium"
due: Optional[str] = None
@dataclass
class ShiftRecord:
"""一个班次的归一化数据(移动端交接班摘要的输入)。
所有字段均有合理默认值:哪怕只给 ``shift`` / ``operator`` 也能产出一份
可用摘要(降级友好)。
Attributes:
shift: 班次标识(如 ``"夜班 2026-08-05 00:00~08:00"``)。
operator: 交班人/值班长。
overview: 生产概况自由文本(产量/批次/质量达成等)。
events: 关键事件列表(按时间顺序)。
todos: 交接待办列表。
energy: 能耗小结(自由文本或结构化键值,如 ``"总电耗 12.4 万 kWh"``)。
safety: 安全注意事项自由文本。
next_shift: 下一班接班人(可选,用于推送)。
"""
shift: str
operator: str
overview: str = ""
events: List[ShiftEvent] = field(default_factory=list)
todos: List[ShiftTodo] = field(default_factory=list)
energy: str = ""
safety: str = ""
next_shift: Optional[str] = None
# ---------------------------------------------------------------------------
# 摘要配置内存模型
# ---------------------------------------------------------------------------
@dataclass
class HandoverBriefConfig:
"""一份移动端交接班摘要配置(对应一份模板级配置资产)。
切换行业模板 = 加载另一份 ``HandoverBriefConfig``,移动端卡片代码零改动
(与 #52 告警面板配置化同一验收口径)。
"""
prompt_template: str = DEFAULT_PROMPT_TEMPLATE
prompt_version: str = DEFAULT_PROMPT_VERSION
sections: List[str] = field(
default_factory=lambda: list(VALID_SECTIONS)
)
max_events: int = DEFAULT_MAX_EVENTS
max_todos: int = DEFAULT_MAX_TODOS
collapse_threshold: int = DEFAULT_COLLAPSE_THRESHOLD
font_size: str = "md"
require_confirm: bool = DEFAULT_REQUIRE_CONFIRM
schema: str = HANDOVER_CONFIG_SCHEMA_ID
# ---------------------------------------------------------------------------
# 校验器
# ---------------------------------------------------------------------------
def _is_str_nonempty(value: Any) -> bool:
return isinstance(value, str) and value.strip() != ""
def validate_handover_config(config: Any) -> HandoverConfigValidationResult:
"""对一份交接班摘要配置资产做结构 + 语义校验,返回校验结果。
非法资产不提前返回:尽量收集全部字段级错误,便于配置台一次性展示
「错误列表(字段 + 原因)」,与 #50 / #52 行为一致。
"""
errors: List[str] = []
if not isinstance(config, dict):
return HandoverConfigValidationResult(
ok=False,
errors=[f"配置必须是对象(dict),实际为 {type(config).__name__}"],
)
# $schema(选填,但若给出必须对齐版本标识)
schema = config.get("$schema")
if schema is not None and schema != HANDOVER_CONFIG_SCHEMA_ID:
errors.append(
f"$schema: 应为 {HANDOVER_CONFIG_SCHEMA_ID!r},实际为 {schema!r}"
)
# promptTemplate:非空字符串
prompt_template = config.get(
"promptTemplate", config.get("prompt_template", DEFAULT_PROMPT_TEMPLATE)
)
if not _is_str_nonempty(prompt_template):
errors.append("promptTemplate: 缺失或非非空字符串")
# promptVersion:非空字符串(semver 由 llm-gateway 校验,这里只要求非空)
prompt_version = config.get(
"promptVersion", config.get("prompt_version", DEFAULT_PROMPT_VERSION)
)
if not _is_str_nonempty(prompt_version):
errors.append("promptVersion: 缺失或非非空字符串")
# sections:列表、每项在 VALID_SECTIONS 内、至少 1 项、无重复
raw_sections = config.get("sections", list(VALID_SECTIONS))
norm_sections: List[str] = []
if not isinstance(raw_sections, list) or not raw_sections:
errors.append(
f"sections: 缺失或非列表(至少 1 项,合法值 {list(VALID_SECTIONS)})"
)
else:
seen: set = set()
for i, s in enumerate(raw_sections):
if not _is_str_nonempty(s):
errors.append(f"sections[{i}]: 必须是非空字符串")
continue
if s not in VALID_SECTIONS:
errors.append(
f"sections[{i}]: 非法 {s!r},合法值 {list(VALID_SECTIONS)}"
)
continue
if s in seen:
errors.append(f"sections[{i}]: {s!r} 重复声明")
continue
seen.add(s)
norm_sections.append(s)
# maxEvents:1..MAX_ALLOWED_EVENTS
max_events = config.get("maxEvents", config.get("max_events", DEFAULT_MAX_EVENTS))
if (
isinstance(max_events, bool)
or not isinstance(max_events, int)
or not (1 <= max_events <= MAX_ALLOWED_EVENTS)
):
errors.append(
f"maxEvents: 必须是 1..{MAX_ALLOWED_EVENTS} 的整数,实际为 {max_events!r}"
)
# maxTodos:1..MAX_ALLOWED_TODOS
max_todos = config.get("maxTodos", config.get("max_todos", DEFAULT_MAX_TODOS))
if (
isinstance(max_todos, bool)
or not isinstance(max_todos, int)
or not (1 <= max_todos <= MAX_ALLOWED_TODOS)
):
errors.append(
f"maxTodos: 必须是 1..{MAX_ALLOWED_TODOS} 的整数,实际为 {max_todos!r}"
)
# collapseThreshold:0..max(maxEvents, maxTodos)
collapse_threshold = config.get(
"collapseThreshold", config.get("collapse_threshold", DEFAULT_COLLAPSE_THRESHOLD)
)
if (
isinstance(collapse_threshold, bool)
or not isinstance(collapse_threshold, int)
or collapse_threshold < 0
):
errors.append(
f"collapseThreshold: 必须是非负整数,实际为 {collapse_threshold!r}"
)
# fontSize:枚举
font_size = config.get("fontSize", config.get("font_size", "md"))
if font_size not in VALID_FONT_SIZES:
errors.append(
f"fontSize: 非法 {font_size!r},合法值 {list(VALID_FONT_SIZES)}"
)
# requireConfirm:布尔
require_confirm = config.get(
"requireConfirm", config.get("require_confirm", DEFAULT_REQUIRE_CONFIRM)
)
if not isinstance(require_confirm, bool):
errors.append(
f"requireConfirm: 必须是布尔,实际为 {type(require_confirm).__name__}"
)
if errors:
return HandoverConfigValidationResult(ok=False, errors=errors)
normalized: Dict[str, Any] = {
"$schema": HANDOVER_CONFIG_SCHEMA_ID,
"promptTemplate": prompt_template,
"promptVersion": prompt_version,
"sections": norm_sections or list(VALID_SECTIONS),
"maxEvents": max_events,
"maxTodos": max_todos,
"collapseThreshold": collapse_threshold,
"fontSize": font_size,
"requireConfirm": require_confirm,
}
return HandoverConfigValidationResult(ok=True, errors=[], normalized=normalized)
def load_handover_config(config: Any) -> HandoverBriefConfig:
"""从已解析的 dict 构造 ``HandoverBriefConfig``(校验失败抛 ``HandoverConfigError``)。
与 #52 ``load_alarm_config`` 对称:先校验再按规范化结果构造内存模型。
"""
result = validate_handover_config(config)
if not result.ok:
raise HandoverConfigError(result.errors)
assert result.normalized is not None
n = result.normalized
return HandoverBriefConfig(
prompt_template=n["promptTemplate"],
prompt_version=n["promptVersion"],
sections=n["sections"],
max_events=n["maxEvents"],
max_todos=n["maxTodos"],
collapse_threshold=n["collapseThreshold"],
font_size=n["fontSize"],
require_confirm=n["requireConfirm"],
schema=HANDOVER_CONFIG_SCHEMA_ID,
)
# ---------------------------------------------------------------------------
# 班次数据归一化(从 dict → ShiftRecord)
# ---------------------------------------------------------------------------
def normalize_shift_record(raw: Any) -> ShiftRecord:
"""把一份班次数据 dict 归一为 ``ShiftRecord``。
用于从驾驶舱 / 总线 / 告警面板聚合出的原始数据(JSON)转入内存模型。
宽松解析:缺字段给默认值;非法字段抛 ``HandoverConfigError`` 携带错误清单。
"""
errors: List[str] = []
if not isinstance(raw, dict):
raise HandoverConfigError(
[f"班次数据必须是对象(dict),实际为 {type(raw).__name__}"]
)
shift = raw.get("shift", "")
operator = raw.get("operator", "")
if not _is_str_nonempty(shift):
errors.append("shift: 缺失或非非空字符串(班次标识必填)")
if not _is_str_nonempty(operator):
errors.append("operator: 缺失或非非空字符串(交班人必填)")
events: List[ShiftEvent] = []
raw_events = raw.get("events", [])
if raw_events is None:
raw_events = []
if not isinstance(raw_events, list):
errors.append("events: 必须是列表")
else:
for i, e in enumerate(raw_events):
if not isinstance(e, dict):
errors.append(f"events[{i}]: 必须是对象(dict)")
continue
t = e.get("time")
title = e.get("title")
if not _is_str_nonempty(t):
errors.append(f"events[{i}].time: 缺失或非非空字符串")
continue
if not _is_str_nonempty(title):
errors.append(f"events[{i}].title: 缺失或非非空字符串")
continue
events.append(
ShiftEvent(
time=t,
title=title,
severity=e.get("severity", "info"),
detail=e.get("detail"),
)
)
todos: List[ShiftTodo] = []
raw_todos = raw.get("todos", [])
if raw_todos is None:
raw_todos = []
if not isinstance(raw_todos, list):
errors.append("todos: 必须是列表")
else:
for i, td in enumerate(raw_todos):
if not isinstance(td, dict):
errors.append(f"todos[{i}]: 必须是对象(dict)")
continue
title = td.get("title")
if not _is_str_nonempty(title):
errors.append(f"todos[{i}].title: 缺失或非非空字符串")
continue
todos.append(
ShiftTodo(
title=title,
priority=td.get("priority", "medium"),
due=td.get("due"),
)
)
if errors:
raise HandoverConfigError(errors)
return ShiftRecord(
shift=str(shift),
operator=str(operator),
overview=str(raw.get("overview", "")) or "",
events=events,
todos=todos,
energy=str(raw.get("energy", "")) or "",
safety=str(raw.get("safety", "")) or "",
next_shift=raw.get("next_shift"),
)
# ---------------------------------------------------------------------------
# LLM 输入渲染(填 shift_handover 提示词占位符)
# ---------------------------------------------------------------------------
def build_llm_input(
record: ShiftRecord,
config: Optional[HandoverBriefConfig] = None,
) -> Tuple[str, str]:
"""把班次数据填入 ``shift_handover`` 提示词,产出给 LLM 网关的输入。
返回 ``(prompt_template_name, rendered_text)``:
- ``prompt_template_name`` 来自配置(默认 ``shift_handover``),调用方据此
绑定 llm-gateway 的 ``PromptRegistry`` 版本(PRD「运行时绑定模板版本」);
- ``rendered_text`` 是提示词正文(占位符 ``{query}`` 已替换为本班结构化摘要)。
与 prompts.template.yaml 中 ``shift_handover v1.0.1`` 正文
(「生成交接班摘要,包含:生产概况、异常事项、安全注意事项。班次:{query}」)
对齐:把结构化班次数据序列化为 ``{query}`` 占位的班次文本。
"""
if config is None:
config = HandoverBriefConfig()
# 班次结构化摘要文本(作为 {query} 的填充)
parts: List[str] = [f"班次:{record.shift}", f"交班人:{record.operator}"]
if record.overview:
parts.append(f"生产概况:{record.overview}")
if record.events:
lines = ["关键事件:"]
for e in record.events:
tag = f"[{e.severity}]" if e.severity and e.severity != "info" else ""
line = f"- {e.time} {tag} {e.title}".rstrip()
if e.detail:
line += f"({e.detail})"
lines.append(line)
parts.append("\n".join(lines))
if record.energy:
parts.append(f"能耗:{record.energy}")
if record.safety:
parts.append(f"安全注意事项:{record.safety}")
if record.todos:
lines = ["待办(交下一班):"]
for td in record.todos:
line = f"- [{td.priority}] {td.title}"
if td.due:
line += f"({td.due})"
lines.append(line)
parts.append("\n".join(lines))
query = "\n".join(parts)
# shift_handover 提示词正文:生成交接班摘要,包含:生产概况、异常事项、安全注意事项。班次:{query}
rendered = (
"生成交接班摘要,包含:生产概况、异常事项、安全注意事项。班次:\n" + query
)
return config.prompt_template, rendered
# ---------------------------------------------------------------------------
# 确定性降级渲染(无 LLM 时的离线摘要)
# ---------------------------------------------------------------------------
def render_deterministic_brief(
record: ShiftRecord,
config: Optional[HandoverBriefConfig] = None,
) -> str:
"""无 LLM(dry-run / 离线 / LLM 故障降级)时的确定性交接班摘要。
PRD NFR「可用性 ≥ 99.8%」「模型服务故障自动降级」:当 LLM 网关不可用时,
交接班摘要链路不能中断,回落到基于班次数据的模板拼接摘要。输出与
``shift_handover`` 提示词三章节(生产概况/异常事项/安全注意事项)结构对齐,
额外补充能耗与待办章节。
"""
if config is None:
config = HandoverBriefConfig()
sections = config.sections
blocks: List[str] = [f"# {record.shift} 交接班摘要", f"交班人:{record.operator}"]
def _add(title: str, body: str) -> None:
if body and body.strip():
blocks.append(f"\n## {title}\n{body.strip()}")
if "overview" in sections:
_add("生产概况", record.overview or "(本班生产平稳,无显著波动)")
if "abnormal" in sections:
if record.events:
lines = []
for e in record.events:
tag = f"[{e.severity}]" if e.severity and e.severity != "info" else ""
line = f"- {e.time} {tag} {e.title}".rstrip()
if e.detail:
line += f"({e.detail})"
lines.append(line)
_add("异常事项", "\n".join(lines))
else:
_add("异常事项", "本班无关键异常事件。")
if "safety" in sections:
_add("安全注意事项", record.safety or "无特殊安全注意事项。")
if "energy" in sections:
_add("能耗", record.energy or "(未填报能耗数据)")
if "todos" in sections:
if record.todos:
lines = [
f"- [{td.priority}] {td.title}" + (f"({td.due})" if td.due else "")
for td in record.todos
]
_add("待办(交下一班)", "\n".join(lines))
else:
_add("待办(交下一班)", "无待办事项。")
return "\n".join(blocks)
# ---------------------------------------------------------------------------
# 摘要生成主入口(注入 LLM,离线自动降级)
# ---------------------------------------------------------------------------
def generate_handover_brief(
record: ShiftRecord,
config: Optional[HandoverBriefConfig] = None,
llm_generate: Optional[Callable[[str], str]] = None,
) -> str:
"""生成交接班摘要文本(PRD 场景C,验收线 ≤ 2 分钟)。
Args:
record: 归一化班次数据。
config: 摘要配置(章节/移动端策略/提示词模板);None 用默认。
llm_generate: LLM 生成函数(输入提示词文本,返回摘要文本)。None 或
调用失败时**自动降级**为 ``render_deterministic_brief``(离线可用)。
Returns:
交接班摘要文本(含生产概况/异常事项/安全注意事项等章节)。
设计:LLM 以依赖注入传入,内核不绑定具体云端 SDK,保证本地闭环与离线降级。
"""
if config is None:
config = HandoverBriefConfig()
if llm_generate is None:
return render_deterministic_brief(record, config)
_, rendered = build_llm_input(record, config)
try:
text = llm_generate(rendered)
except Exception:
# LLM 故障 → 降级为确定性摘要(PRD「模型服务故障自动降级」)
return render_deterministic_brief(record, config)
if not _is_str_nonempty(text):
return render_deterministic_brief(record, config)
return text
# ---------------------------------------------------------------------------
# 移动端负载编译(摘要 + 班次要点 → 移动端只读卡片 props)
# ---------------------------------------------------------------------------
def render_handover_brief(
record: ShiftRecord,
config: Optional[HandoverBriefConfig] = None,
summary: Optional[str] = None,
llm_generate: Optional[Callable[[str], str]] = None,
generated_at: Optional[datetime] = None,
) -> Dict[str, Any]:
"""把班次数据 + 摘要编译成移动端只读卡片可直接消费的 props dict。
Args:
record: 班次数据。
config: 摘要配置;None 用默认。
summary: 已生成的摘要文本;None 则调用 ``generate_handover_brief`` 生成。
llm_generate: 透传给 ``generate_handover_brief``(None 时离线降级)。
generated_at: 摘要生成时间(用于卡片时间戳);None 取当前 UTC。
Returns:
扁平、camelCase、可 ``json.dumps`` 的 props dict,移动端 ``HandoverBrief``
组件据此渲染标题/章节/事件/待办/折叠策略/字号(**切换行业模板只换配置资产,
移动端代码零改动**,与 #52 同一验收口径)。
"""
if config is None:
config = HandoverBriefConfig()
if summary is None:
summary = generate_handover_brief(record, config, llm_generate)
if generated_at is None:
generated_at = datetime.now(timezone.utc)
# 按 maxEvents / maxTodos 截断,移动端展示策略由配置驱动
truncated_events = record.events[: config.max_events]
truncated_todos = record.todos[: config.max_todos]
# 是否含 P0 / safety 事件 → 强制 requireConfirm(PRD 高利害人工确认)
has_critical = any(e.severity == "P0" for e in record.events) or bool(
record.safety and record.safety.strip()
)
require_confirm = config.require_confirm or has_critical
props: Dict[str, Any] = {
"schema": config.schema,
"shift": record.shift,
"operator": record.operator,
"nextShift": record.next_shift,
"summary": summary,
"generatedAt": generated_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
"promptTemplate": config.prompt_template,
"promptVersion": config.prompt_version,
"sections": list(config.sections),
"events": [
{
"time": e.time,
"title": e.title,
"severity": e.severity,
"detail": e.detail,
}
for e in truncated_events
],
"todos": [
{"title": t.title, "priority": t.priority, "due": t.due}
for t in truncated_todos
],
"eventOverflow": max(0, len(record.events) - config.max_events),
"todoOverflow": max(0, len(record.todos) - config.max_todos),
"collapseThreshold": config.collapse_threshold,
"fontSize": config.font_size,
"requireConfirm": require_confirm,
# 演示「配置点真实驱动展示」:章节开关决定卡片渲染哪些区块
"showOverview": "overview" in config.sections,
"showAbnormal": "abnormal" in config.sections,
"showSafety": "safety" in config.sections,
"showEnergy": "energy" in config.sections,
"showTodos": "todos" in config.sections,
}
return props
def within_generation_budget(elapsed_ms: float) -> bool:
"""判断本次摘要生成是否满足 PRD 验收线「交接班报告生成 ≤ 2 分钟」。
供验收脚本断言耗时用。
"""
return elapsed_ms <= HANDOVER_GENERATION_BUDGET_MS