# -*- 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