feat: 完成 issue #75 [Ti-1] 交接班摘要生成功能

This commit is contained in:
2026-08-05 04:19:36 +08:00
parent 95dace5ef5
commit e2d24f1839
3 changed files with 296 additions and 0 deletions
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# 模板「交接班摘要」配置资产:ti-cl4(Template-Ti 一期)。
#
# 说明(issue #75 / 父 Issue #11):
# - sections:交接班摘要章节(固定五节,可增删改标题);
# - alarm_requires_disposal:异常事件必须含处置记录(缺失 → 待跟进标记,
# 对齐交接班规范「异常事项须标注发生时间与处理人」);
# - default_shift:默认班次;
# - 换行业/调章节只改本文件,生成器内核零改动。
template: ti-cl4
version: 1.0.0
handover:
default_shift: 甲班
alarm_requires_disposal: true
sections:
- {key: production, title: 生产概况}
- {key: equipment, title: 设备运行状态}
- {key: alarm, title: 异常与处置}
- {key: safety, title: 安全注意事项}
- {key: todo, title: 待办事项}
+166
View File
@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""Template-Ti 一期 · 交接班摘要生成功能 —— issue #75。
父 Issue #11「④ LLM 报警解释 / 交接班 / NL 查询」子任务:
把班次事件记录生成为**结构化交接班摘要**(本地规则,可离线测试):
- 事件归类:production(生产概况)/ equipment(设备运行)/ alarm(异常与处置)/
safety(安全注意事项)/ todo(待办事项);
- 异常处置校验:alarm 事件必须含处置记录,缺失 → `needs_followup=True`
(对齐交接班规范:异常事项须标注发生时间与处理人,PRD 5.4);
- 章节输出:固定五节 Markdown 摘要(to_text),供交接班对话/报表使用;
- 与 `TiScenarioRunner.generate_handover`(LLM + RAG 润色)互补:
本模块产出结构化底稿,LLM 层可再润色/补充。
纯本地规则实现(无 LLM 依赖);事件来源:DCS 告警、人工录入、巡检记录。
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from datetime import date as _date
from typing import Dict, List, Optional
#: 默认配置资产路径(相对本模块)
DEFAULT_CONFIG_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "config",
"handover.template.yaml")
#: 默认章节定义(key → 标题)
DEFAULT_SECTIONS: List[Dict[str, str]] = [
{"key": "production", "title": "生产概况"},
{"key": "equipment", "title": "设备运行状态"},
{"key": "alarm", "title": "异常与处置"},
{"key": "safety", "title": "安全注意事项"},
{"key": "todo", "title": "待办事项"},
]
@dataclass
class HandoverReport:
"""结构化交接班摘要。"""
shift: str
date: str
sections: Dict[str, List[str]] = field(default_factory=dict)
needs_followup: bool = False
followup_items: List[str] = field(default_factory=list)
def to_text(self, section_titles: Optional[Dict[str, str]] = None) -> str:
"""输出 Markdown 交接班摘要文本。"""
titles = section_titles or {s["key"]: s["title"]
for s in DEFAULT_SECTIONS}
lines = [f"# 交接班摘要({self.date} {self.shift})"]
for key, title in titles.items():
items = self.sections.get(key, [])
lines.append(f"\n## {title}")
lines.append("\n".join(f"- {it}" for it in items)
if items else "- (无记录)")
if self.needs_followup:
lines.append("\n> ⚠ 待跟进:异常事项缺少处置记录,请人工确认。")
return "\n".join(lines)
def to_dict(self) -> dict:
return {
"shift": self.shift, "date": self.date,
"sections": self.sections,
"needs_followup": self.needs_followup,
"followup_items": self.followup_items,
}
class HandoverGenerator:
"""交接班摘要生成器(事件 → 结构化报告,本地规则)。"""
#: 事件类型 → 章节 key(未知类型归入待办)
_TYPE_TO_SECTION = {
"production": "production", "设备": "production",
"equipment": "equipment", "设备运行": "equipment",
"alarm": "alarm", "异常": "alarm", "报警": "alarm",
"safety": "safety", "安全": "safety",
"todo": "todo", "待办": "todo",
}
def __init__(
self,
sections: Optional[List[Dict[str, str]]] = None,
alarm_requires_disposal: bool = True,
default_shift: str = "甲班",
) -> None:
self.sections = sections or DEFAULT_SECTIONS
self.alarm_requires_disposal = alarm_requires_disposal
self.default_shift = default_shift
# ------------------------------------------------------------------
@classmethod
def from_template_config(cls, path: str = DEFAULT_CONFIG_PATH) -> "HandoverGenerator":
"""从模板配置资产加载(config/handover.template.yaml)。"""
import yaml
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
cfg = raw.get("handover", {}) or {}
return cls(
sections=cfg.get("sections") or DEFAULT_SECTIONS,
alarm_requires_disposal=bool(
cfg.get("alarm_requires_disposal", True)),
default_shift=cfg.get("default_shift", "甲班"),
)
# ------------------------------------------------------------------
def generate(
self,
events: List[dict],
shift: Optional[str] = None,
date: Optional[str] = None,
) -> HandoverReport:
"""按事件记录生成交接班摘要。
Args:
events: `[{type, text, time?, operator?}]`;type 见 `_TYPE_TO_SECTION`;
shift: 班次(缺省 = 配置默认);
date: 日期(缺省 = 今天)。
"""
sections: Dict[str, List[str]] = {s["key"]: []
for s in self.sections}
report = HandoverReport(
shift=shift or self.default_shift,
date=date or _date.today().isoformat(),
sections=sections,
)
for ev in events:
section = self._section_for(ev.get("type", ""))
text = self._format_event(ev)
sections.setdefault(section, []).append(text)
if section == "alarm":
self._check_disposal(ev, report)
if not sections.get("alarm"):
sections["alarm"] = ["本班无异常事项"]
return report
# ------------------------------------------------------------------
def _section_for(self, event_type: str) -> str:
return self._TYPE_TO_SECTION.get(event_type.strip(), "todo")
@staticmethod
def _format_event(ev: dict) -> str:
time_ = ev.get("time")
operator = ev.get("operator")
text = str(ev.get("text", "")).strip()
parts = []
if time_:
parts.append(f"[{time_}]")
parts.append(text or "(无描述)")
if operator:
parts.append(f"(处理人: {operator})")
return " ".join(parts)
def _check_disposal(self, ev: dict, report: HandoverReport) -> None:
"""异常事件须含处置记录(disposal 或 operator),否则标记待跟进。"""
if not self.alarm_requires_disposal:
return
disposal = ev.get("disposal") or ev.get("operator")
if not disposal:
report.needs_followup = True
report.followup_items.append(
f"异常事件缺处置记录: {ev.get('text', '')[:40]}")
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""交接班摘要生成器测试(issue #75)。
覆盖:
1. 配置资产加载(章节/异常处置要求/默认班次);
2. 事件归类(production/equipment/alarm/safety/todo + 未知 → 待办);
3. 异常处置校验:缺失处置 → needs_followup + followup_items;
4. 无异常事件 → 「本班无异常事项」占位;
5. to_text Markdown 输出(标题/章节/待跟进标注)。
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import _bootstrap # noqa: F401
from ti_scenarios.handover import ( # noqa: E402
DEFAULT_SECTIONS,
HandoverGenerator,
)
CONFIG = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config", "handover.template.yaml",
)
class TestConfigLoad(unittest.TestCase):
"""配置资产加载。"""
def setUp(self):
self.g = HandoverGenerator.from_template_config(CONFIG)
def test_sections_and_defaults(self):
self.assertEqual([s["key"] for s in self.g.sections],
[s["key"] for s in DEFAULT_SECTIONS])
self.assertTrue(self.g.alarm_requires_disposal)
self.assertEqual(self.g.default_shift, "甲班")
class TestGenerate(unittest.TestCase):
"""事件归类与摘要生成。"""
def setUp(self):
self.g = HandoverGenerator.from_template_config(CONFIG)
def test_event_classification(self):
events = [
{"type": "production", "text": "生产平稳,产出 12 批", "time": "08:00"},
{"type": "equipment", "text": "洗涤泵 A 运行正常"},
{"type": "safety", "text": "巡检未发现隐患"},
{"type": "todo", "text": "明日更换滤芯"},
{"type": "未知类型", "text": "临时事项"},
]
report = self.g.generate(events, shift="甲班", date="2026-09-01")
self.assertEqual(len(report.sections["production"]), 1)
self.assertEqual(len(report.sections["equipment"]), 1)
self.assertEqual(len(report.sections["safety"]), 1)
self.assertEqual(len(report.sections["todo"]), 2) # 已知 + 未知类型
self.assertFalse(report.needs_followup)
def test_alarm_with_disposal_ok(self):
events = [{"type": "alarm", "text": "炉温超上限",
"time": "09:12", "operator": "李工",
"disposal": "已降低氯气流量,10 分钟内回落"}]
report = self.g.generate(events, shift="乙班")
self.assertEqual(len(report.sections["alarm"]), 1)
self.assertFalse(report.needs_followup)
def test_alarm_missing_disposal_marks_followup(self):
events = [{"type": "alarm", "text": "炉温超上限", "time": "09:12"}]
report = self.g.generate(events)
self.assertTrue(report.needs_followup)
self.assertTrue(any("炉温超上限" in it for it in report.followup_items))
def test_no_alarm_placeholder(self):
report = self.g.generate([{"type": "production", "text": "正常"}])
self.assertEqual(report.sections["alarm"], ["本班无异常事项"])
self.assertFalse(report.needs_followup)
def test_default_shift(self):
report = self.g.generate([])
self.assertEqual(report.shift, "甲班")
class TestOutput(unittest.TestCase):
"""Markdown 输出。"""
def test_to_text_sections(self):
g = HandoverGenerator.from_template_config(CONFIG)
report = g.generate([{"type": "alarm", "text": "炉温报警"}],
shift="甲班", date="2026-09-01")
text = report.to_text()
self.assertIn("# 交接班摘要(2026-09-01 甲班)", text)
self.assertIn("## 生产概况", text)
self.assertIn("## 异常与处置", text)
self.assertIn("待跟进", text) # 缺处置记录 → 待跟进标注
self.assertIn("- 炉温报警", text)
def test_to_dict(self):
report = HandoverGenerator().generate([])
d = report.to_dict()
self.assertEqual(set(d), {"shift", "date", "sections",
"needs_followup", "followup_items"})
if __name__ == "__main__":
unittest.main()