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 (全部通过)
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""移动端交接班摘要验收脚本(issue #53,PRD 场景C 验收口径)。
|
||||
|
||||
验证四个能力点(覆盖 PRD「交接班报告生成 ≤ 2 分钟」「移动端交接班摘要可用」):
|
||||
1. **Ti 行业模板配置资产合法**:交接班摘要配置通过 ``iAOP-cockpit-handover-brief-v1``
|
||||
校验,并能构造 ``HandoverBriefConfig``;
|
||||
2. **LLM 注入生成 + 离线降级**:注入 LLM 时用其输出;LLM 故障/未注入时自动降级为
|
||||
确定性摘要(PRD「模型服务故障自动降级」「可用性 ≥ 99.8%」),且降级摘要含
|
||||
「生产概况/异常事项/安全注意事项」三章节(对齐 shift_handover 提示词);
|
||||
3. **配置点真实驱动展示**:``sections`` / ``maxEvents`` / ``requireConfirm`` 配置项
|
||||
改变 → 移动端 props 随之变化(切换模板零改码,与 #52 同一验收口径);含 P0 事件
|
||||
→ ``requireConfirm`` 被强制为 true(PRD 高利害人工确认);
|
||||
4. **生成耗时达标**:确定性降级路径在 2 分钟验收线内(PRD line 350)。
|
||||
|
||||
用法(在 core/shift-handover 目录下):
|
||||
python scripts/verify_handover_brief.py
|
||||
退出码:0 = 全部通过;1 = 存在未达标项。
|
||||
|
||||
说明:本脚本不依赖 PyYAML(保持零运行时依赖),用与 Ti 模板资产等价的 dict 校验。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 本脚本位于 core/shift-handover/scripts/,需把 core/ 加入 sys.path
|
||||
sys.path.insert(
|
||||
0,
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
)
|
||||
|
||||
# 含连字符的目录无法直接以包名 import:挂载为 shift_handover 后导入子模块
|
||||
import types # noqa: E402
|
||||
|
||||
_SHIFT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if "shift_handover" not in sys.modules:
|
||||
_pkg = types.ModuleType("shift_handover")
|
||||
_pkg.__path__ = [_SHIFT_DIR]
|
||||
sys.modules["shift_handover"] = _pkg
|
||||
|
||||
from shift_handover.handover import ( # noqa: E402
|
||||
HANDOVER_CONFIG_SCHEMA_ID,
|
||||
HANDOVER_GENERATION_BUDGET_MS,
|
||||
HandoverBriefConfig,
|
||||
generate_handover_brief,
|
||||
load_handover_config,
|
||||
normalize_shift_record,
|
||||
render_handover_brief,
|
||||
within_generation_budget,
|
||||
)
|
||||
|
||||
|
||||
def _ti_config() -> dict:
|
||||
"""与 templates/ti-cl4/dashboard/handover_brief.ti.yaml 等价的 dict。"""
|
||||
return {
|
||||
"$schema": HANDOVER_CONFIG_SCHEMA_ID,
|
||||
"promptTemplate": "shift_handover",
|
||||
"promptVersion": "1.0.1",
|
||||
"sections": ["overview", "abnormal", "safety", "energy", "todos"],
|
||||
"maxEvents": 8,
|
||||
"maxTodos": 5,
|
||||
"collapseThreshold": 4,
|
||||
"fontSize": "md",
|
||||
"requireConfirm": False,
|
||||
}
|
||||
|
||||
|
||||
def _ti_shift_record() -> dict:
|
||||
"""Ti 氯化车间夜班典型班次数据。"""
|
||||
return {
|
||||
"shift": "夜班 2026-08-05 00:00~08:00",
|
||||
"operator": "张工",
|
||||
"overview": "TiCl4 产量 36.2t,质量达成率 98.6%。",
|
||||
"events": [
|
||||
{"time": "01:20", "title": "炉层温度越上限",
|
||||
"severity": "P1", "detail": "CLF-01 第3层 942℃,已调风量回落"},
|
||||
{"time": "03:05", "title": "夜巡正常", "severity": "info"},
|
||||
],
|
||||
"todos": [
|
||||
{"title": "白班复测 3 层温度趋势", "priority": "high", "due": "接班后 1h"},
|
||||
{"title": "补录 LIMS 03:00 批次", "priority": "medium"},
|
||||
],
|
||||
"energy": "总电耗 12.4 万 kWh",
|
||||
"safety": "夜班无安全事件;注意 3 层高温区巡检。",
|
||||
"next_shift": "李工",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
all_ok = True
|
||||
|
||||
def _check(title: str, fn):
|
||||
nonlocal all_ok
|
||||
try:
|
||||
ok = fn()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ok = False
|
||||
print(f" ✗ {title}:异常 {exc!r}")
|
||||
status = "✅" if ok else "❌"
|
||||
print(f" {status} {title}")
|
||||
all_ok = all_ok and ok
|
||||
|
||||
print("=" * 64)
|
||||
print("移动端交接班摘要验收(issue #53 / PRD 场景C)")
|
||||
print("=" * 64)
|
||||
|
||||
# 1) Ti 模板配置资产合法
|
||||
def check_config_valid() -> bool:
|
||||
cfg = load_handover_config(_ti_config())
|
||||
return (
|
||||
isinstance(cfg, HandoverBriefConfig)
|
||||
and cfg.prompt_template == "shift_handover"
|
||||
and cfg.prompt_version == "1.0.1"
|
||||
and cfg.font_size == "md"
|
||||
)
|
||||
|
||||
_check("Ti 模板配置资产合法(iAOP-cockpit-handover-brief-v1)", check_config_valid)
|
||||
|
||||
# 2) LLM 注入生成 + 离线降级
|
||||
def check_llm_and_fallback() -> bool:
|
||||
rec = normalize_shift_record(_ti_shift_record())
|
||||
# 注入 LLM:用其输出
|
||||
def llm(prompt: str) -> str:
|
||||
return "【LLM 摘要】本班平稳,3 层温度曾越限已处置,交白班复测。"
|
||||
with_llm = generate_handover_brief(rec, llm_generate=llm)
|
||||
# 离线降级(不注入 LLM)
|
||||
fallback = generate_handover_brief(rec)
|
||||
# LLM 故障降级
|
||||
def broken(prompt: str) -> str:
|
||||
raise RuntimeError("LLM 网关不可达")
|
||||
degraded = generate_handover_brief(rec, llm_generate=broken)
|
||||
return (
|
||||
with_llm.startswith("【LLM 摘要】")
|
||||
and "交接班摘要" in fallback
|
||||
and "生产概况" in fallback
|
||||
and "异常事项" in fallback
|
||||
and "安全注意事项" in fallback
|
||||
and "交接班摘要" in degraded # 故障也降级为可用摘要
|
||||
)
|
||||
|
||||
_check("LLM 注入生成 + 离线/故障降级为可用摘要", check_llm_and_fallback)
|
||||
|
||||
# 3) 配置点真实驱动移动端展示
|
||||
def check_config_drives_props() -> bool:
|
||||
rec = normalize_shift_record(_ti_shift_record())
|
||||
props = render_handover_brief(rec)
|
||||
# 默认配置下事件/待办齐全、章节开关正确
|
||||
base_ok = (
|
||||
len(props["events"]) == 2
|
||||
and len(props["todos"]) == 2
|
||||
and props["showOverview"] is True
|
||||
and props["showAbnormal"] is True
|
||||
and props["eventOverflow"] == 0
|
||||
)
|
||||
# sections 收窄 → 对应章节开关关闭
|
||||
cfg2 = HandoverBriefConfig(sections=["overview"])
|
||||
props2 = render_handover_brief(rec, cfg2)
|
||||
sections_ok = (
|
||||
props2["showOverview"] is True
|
||||
and props2["showAbnormal"] is False
|
||||
and props2["showTodos"] is False
|
||||
)
|
||||
# maxEvents 截断 + 溢出计数
|
||||
many = {
|
||||
"shift": "夜班", "operator": "张工",
|
||||
"events": [{"time": f"0{i}:00", "title": f"事件{i}"} for i in range(12)],
|
||||
}
|
||||
rec_many = normalize_shift_record(many)
|
||||
cfg3 = HandoverBriefConfig(max_events=5)
|
||||
props3 = render_handover_brief(rec_many, cfg3)
|
||||
trunc_ok = len(props3["events"]) == 5 and props3["eventOverflow"] == 7
|
||||
# 含 P0 事件 → requireConfirm 被强制为 true
|
||||
critical = {
|
||||
"shift": "夜班", "operator": "张工",
|
||||
"events": [{"time": "01:00", "title": "严重告警", "severity": "P0"}],
|
||||
}
|
||||
rec_crit = normalize_shift_record(critical)
|
||||
props4 = render_handover_brief(rec_crit, HandoverBriefConfig(require_confirm=False))
|
||||
crit_ok = props4["requireConfirm"] is True
|
||||
return base_ok and sections_ok and trunc_ok and crit_ok
|
||||
|
||||
_check("配置点(sections/maxEvents/requireConfirm)真实驱动移动端展示", check_config_drives_props)
|
||||
|
||||
# 4) 生成耗时达标(≤ 2 分钟验收线)
|
||||
def check_generation_budget() -> bool:
|
||||
rec = normalize_shift_record(_ti_shift_record())
|
||||
start = time.perf_counter()
|
||||
for _ in range(50):
|
||||
generate_handover_brief(rec) # 离线降级路径
|
||||
elapsed_ms = (time.perf_counter() - start) * 1000.0
|
||||
# 单次远小于预算(取平均更稳),且预算判定函数正确
|
||||
per_call = elapsed_ms / 50.0
|
||||
return (
|
||||
per_call < HANDOVER_GENERATION_BUDGET_MS
|
||||
and within_generation_budget(per_call)
|
||||
and not within_generation_budget(HANDOVER_GENERATION_BUDGET_MS + 1)
|
||||
)
|
||||
|
||||
_check("生成耗时达标(PRD:交接班报告生成 ≤ 2 分钟)", check_generation_budget)
|
||||
|
||||
print("=" * 64)
|
||||
print("结果: " + ("全部通过 ✅" if all_ok else "存在未达标项 ❌"))
|
||||
return 0 if all_ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user