feat(services): assignment 6 full - form/business key mapping and stream write-back

- fields_registry: FORM_TO_BUSINESS_KEY bidirectional mapping between
  the graph's six form keys (has_injury...) and the acd business keys
  (ywrysw...); bjrjs/ywmtc have no form source (set_info only);
  business_patch_from_form maps graph diffs to whitelisted business keys
  and filters internal/unknown keys
- app_service: chat injection now maps repository business keys back to
  form keys; both complete() and stream() write back field diffs
  (FormUpdate events captured in stream, closing the skeleton gap);
  form keys never leak into the business repository
- docs/assignment6-compat.md: mapping table documented, stream write-back
  backlog item resolved
- tests: mapping coverage, write-back mapping end-to-end, reverse
  mapping injection (bjrjs/ywmtc excluded), stream FormUpdate write-back

Verified: 205 unit tests
This commit is contained in:
Eric Wang
2026-10-22 21:07:01 +08:00
parent 8717d9066c
commit 7f52f1c4d0
5 changed files with 144 additions and 17 deletions
+20 -4
View File
@@ -21,16 +21,32 @@
| `set_info` 未知 key | 任意 key 写入 state 变量 | **拒绝**(500 + "入参不规范:未知或受保护的字段 key") | 白名单保护;任意写入会污染图输入。上线前需审计旧调用方是否依赖任意 key 写入 |
| `set_info` 图内部键(`stage_code` 等) | 可写(state 是裸 dict) | **拒绝**(`INTERNAL_FIELD_KEYS` 保护) | 内部状态只允许图自己推进 |
| `set_info` 字段值 | 任意字符串原样存 | 按注册表类型解析(bool/int 严格),无法解析拒绝 | 保留 空/False/0/未知 区分,杜绝 `bool("0")` 陷阱 |
| 图表单键与业务键 | FastGPT state 为裸 dict,无映射概念 | 建立双向映射(作业 6):表单六键(`has_injury` 等)<-> acd 业务键(`ywrysw` 等);`bjrjs`/`ywmtc` 无表单来源,仅 `set_info` 可写 | 图的表单语义与 get_info 业务分组需要同一份事实 |
| LLM 调用 | set/get 走辅助对话 + 事后删记录 | **零 LLM 调用**(测试 spy 保证) | 成本与延迟;删除 race 消失 |
| 重复 `delete_session` | FastGPT SDK 行为未固定 | **幂等成功**(业务状态 + checkpoint 均静默容忍缺失) | 行为固定为测试 |
| 重复 `delete_session` | FastGPT SDK 行为未固定 | **幂等成功**(业务状态删除天然幂等;checkpoint 清理重复触发但缺失静默容忍) | 行为固定为测试 |
## 图表单键 <-> 业务键映射(作业 6 建立)
| 图表单键(accident_fields) | 业务键(acdinfo 分组) |
|---|---|
| `has_injury` | `ywrysw` |
| `has_non_motor_vehicle_hit` | `ywfjdc` |
| `is_on_scene` | `sfsgxc` |
| `vehicle_count` | `jdcsl` |
| `accident_time` | `sgfssj` |
| `accident_cause` | `sgyy` |
- chat 方向:repository 业务键经映射注入图为表单键(图只认表单语义)。
- 写回方向:图产出的表单键差量经映射落 repository 业务键,get_info 分组即可读到。
- `bjrjs`(报警人是否驾驶员)、`ywmtc`(是否撞摩托车)无表单来源:
不进入注入映射,仅支持 set_info 直写、get_info 直读。
## 待审计项(作业 6-7 要求的后续工作)
- [ ] 审计现有调用方是否存在对 `set_info` 任意 key 写入的依赖
(如动态自定义键)。存在则在注册表补登记或提供逃生口。
- [ ] 流式 `chat` 的 `formUpdate` 差量写回 repository:
骨架阶段流式原样透传,差量写回仅在非流式 `chat()` 生效;
完整版随 FormUpdate 事件捕获补齐(见 `app_service.stream` 注释)。
- [x] ~~流式 `chat` 的 `formUpdate` 差量写回 repository~~
(完整版已实现:FormUpdate 事件捕获后经映射写回,见 `app_service.stream`)。
- [ ] `get_info` 返回值中的敏感字段(证件号/手机号)回显边界:
注册表已记录 `SensitiveLevel`,访问控制随作业 9 落地。
+24 -11
View File
@@ -13,11 +13,13 @@
endpoint 只负责校验、协议(SSE / 响应模型)与依赖注入。
"""
from ..backends.chat import ChatBackend, ChatInput, ChatResult
from ..backends.chat import ChatBackend, ChatInput, ChatResult, FormUpdate
from .fields_registry import (
FIELD_REGISTRY,
GROUP_KEYS,
BUSINESS_TO_FORM_KEY,
FieldGroup,
business_patch_from_form,
parse_field_value,
to_external_string,
)
@@ -37,8 +39,12 @@ class AccidentAppService:
# ---- chat:图每轮读取最新业务状态,产出差量写回 ----
def _injected_fields(self, session_id: str) -> dict:
"""repository 业务字段 -> 图输入的分组注入(acd 进 accident_fields,
两组人员进 person_fields,与 AgentState 的键结构对齐)。"""
"""repository 业务字段 -> 图输入的分组注入。
acd 业务键经 BUSINESS_TO_FORM_KEY 映射回图表单键
(无表单来源的 bjrjs/ywmtc 不注入);人员业务键与图的
person_fields 同构,原样注入。
"""
business = self._repository.load(session_id)
if not business:
return {}
@@ -48,11 +54,19 @@ class AccidentAppService:
if spec is None:
continue
if spec.group is FieldGroup.ACD:
injected["accident_fields"][key] = value
form_key = BUSINESS_TO_FORM_KEY.get(key)
if form_key is not None:
injected["accident_fields"][form_key] = value
else:
injected["person_fields"][key] = value
return injected
def _writeback_patch(self, session_id: str, patch: dict) -> None:
"""图产出的差量(表单键/人员键)经映射落 repository 业务键。"""
business_patch = business_patch_from_form(patch)
if business_patch:
self._repository.apply_update(session_id, business_patch)
async def chat(self, chat_input: ChatInput) -> ChatResult:
"""非流式一轮对话:注入最新业务字段 -> 执行图 -> 写回字段差量。"""
injected = self._injected_fields(chat_input.session_id)
@@ -65,15 +79,14 @@ class AccidentAppService:
injected_fields=injected or None,
)
)
# 图产出的字段差量(turn_patch / 完整字段)写回 repository,
# 图产出的字段差量写回 repository,
# 使 set/get_info 与后续轮次看到同一事实来源
patch = result.form_update if isinstance(result.form_update, dict) else {}
if patch:
self._repository.apply_update(chat_input.session_id, patch)
if isinstance(result.form_update, dict):
self._writeback_patch(chat_input.session_id, result.form_update)
return result
async def stream(self, chat_input: ChatInput):
"""流式代理:字段注入与写回语义同 chat(),事件流原样透传。"""
"""流式代理:字段注入同 chat();FormUpdate 事件捕获写回后透传。"""
injected = self._injected_fields(chat_input.session_id)
result_input = ChatInput(
session_id=chat_input.session_id,
@@ -82,9 +95,9 @@ class AccidentAppService:
client_mode=chat_input.client_mode,
injected_fields=injected or None,
)
# 骨架阶段原样透传事件流;字段差量写回在作业 6 完整版随
# FormUpdate 事件捕获补齐(对照计划作业 8 的真实增量)。
async for event in self._backend.stream(result_input):
if isinstance(event, FormUpdate) and isinstance(event.data, dict):
self._writeback_patch(chat_input.session_id, event.data)
yield event
# ---- set_info / get_info:零 LLM、零辅助对话 ----
+36
View File
@@ -93,6 +93,39 @@ INTERNAL_FIELD_KEYS = frozenset(
}
)
# 图表单键(accident_fields,作业 4 起使用)与 get_info 业务键的映射
# (作业 6 建立;对照工作流「更新表单」白名单与 get_info 分组):
# 表单六键覆盖 acd 分组中的六个对应项;bjrjs(报警人是否驾驶员)与
# ywmtc(是否撞摩托车)无表单来源,只能由 set_info 直接写入。
FORM_TO_BUSINESS_KEY: dict[str, str] = {
"has_injury": "ywrysw",
"has_non_motor_vehicle_hit": "ywfjdc",
"is_on_scene": "sfsgxc",
"vehicle_count": "jdcsl",
"accident_time": "sgfssj",
"accident_cause": "sgyy",
}
BUSINESS_TO_FORM_KEY: dict[str, str] = {
business: form for form, business in FORM_TO_BUSINESS_KEY.items()
}
def business_patch_from_form(form_patch: dict) -> dict:
"""图产出的表单差量 -> repository 业务键差量(白名单内)。
只收录能落到注册表的键:表单六键经映射转换,其余键若本身
是注册表业务键(如 chat 核实产出的人员键)原样收录。
"""
patch: dict = {}
for key, value in (form_patch or {}).items():
business_key = FORM_TO_BUSINESS_KEY.get(key, key)
spec = FIELD_REGISTRY.get(business_key)
if spec is None or not spec.writable:
continue
# 类型以注册表为准:图表单值已是内部类型,直接采用
patch[business_key] = value
return patch
def parse_field_value(spec: FieldSpec, raw: str) -> Any:
"""外部字符串 -> 内部强类型值;无法解析时抛 ValueError。
@@ -136,6 +169,9 @@ __all__ = [
"FIELD_REGISTRY",
"GROUP_KEYS",
"INTERNAL_FIELD_KEYS",
"FORM_TO_BUSINESS_KEY",
"BUSINESS_TO_FORM_KEY",
"business_patch_from_form",
"FieldGroup",
"FieldSpec",
"SensitiveLevel",
+32 -2
View File
@@ -128,6 +128,33 @@ async def test_chat_sees_set_info_values_and_writes_back_patch():
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
@pytest.mark.asyncio
async def test_chat_form_patch_mapped_to_business_keys():
"""图表单键差量经映射写回业务键(作业 6 映射的端到端)。"""
service, backend = make_service(form_update={
"has_injury": False, "vehicle_count": 2, "accident_cause": "追尾",
})
await service.chat(_input("s1", "两辆汽车追尾,没有人受伤"))
acd = json.loads(service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0"
assert acd["jdcsl"] == "2"
assert acd["sgyy"] == "追尾"
# 表单键本身不落 repository(未知业务键 -> JSON 空串)
assert json.loads(service.get_info("s1", "has_injury")) == ""
@pytest.mark.asyncio
async def test_set_info_acd_value_mapped_into_form_injection():
"""set_info 写业务键 -> chat 注入为图表单键(反向映射)。"""
service, backend = make_service()
service.set_info("s1", "ywrysw", "0")
service.set_info("s1", "jdcsl", "2")
service.set_info("s1", "bjrjs", "1") # 无表单来源,不注入
await service.chat(_input("s1", "继续办理"))
injected = backend.calls[0].injected_fields
assert injected["accident_fields"] == {"has_injury": False, "vehicle_count": 2}
@pytest.mark.asyncio
async def test_chat_without_business_state_passes_no_injection():
service, backend = make_service()
@@ -136,8 +163,9 @@ async def test_chat_without_business_state_passes_no_injection():
@pytest.mark.asyncio
async def test_stream_injects_fields_and_passthrough_events():
service, backend = make_service(form_update={"ywrysw": False})
async def test_stream_injects_fields_writes_back_form_update():
"""流式:注入同 chat;FormUpdate 事件差量捕获写回后透传。"""
service, backend = make_service(form_update={"has_injury": False})
service.set_info("s1", "xm1", "王五")
events = [
event async for event in service.stream(_input("s1", "继续办理"))
@@ -145,6 +173,8 @@ async def test_stream_injects_fields_and_passthrough_events():
kinds = [type(event).__name__ for event in events]
assert kinds == ["TextDelta", "FormUpdate", "TextDelta"]
assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五"
# FormUpdate 差量已写回 repository(表单键 -> 业务键)
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
# ---- delete:清理 + 幂等 ----
+32
View File
@@ -6,8 +6,11 @@ from src.services.fields_registry import (
FIELD_REGISTRY,
GROUP_KEYS,
INTERNAL_FIELD_KEYS,
FORM_TO_BUSINESS_KEY,
BUSINESS_TO_FORM_KEY,
FieldGroup,
SensitiveLevel,
business_patch_from_form,
parse_field_value,
to_external_string,
)
@@ -87,3 +90,32 @@ def test_internal_field_protection_listed():
"""图内部键登记在保护清单中,set_info 白名单不会收录它们。"""
for key in INTERNAL_FIELD_KEYS:
assert key not in FIELD_REGISTRY
def test_form_business_key_mapping_covers_form_fields():
"""图表单六键全部映射到 acd 业务键(作业 6 建立的映射)。"""
assert set(FORM_TO_BUSINESS_KEY) == {
"has_injury", "has_non_motor_vehicle_hit", "is_on_scene",
"vehicle_count", "accident_time", "accident_cause",
}
for business_key in FORM_TO_BUSINESS_KEY.values():
assert business_key in FIELD_REGISTRY
assert FIELD_REGISTRY[business_key].group is FieldGroup.ACD
def test_business_patch_from_form_maps_and_filters():
"""表单键映射、人员键原样、未知键过滤。"""
patch = business_patch_from_form({
"has_injury": False, # -> ywrysw
"vehicle_count": 2, # -> jdcsl
"xm2": "李四", # 人员键原样
"stage_code": "1002", # 内部键过滤
"no_such_form_key": "x", # 未知键过滤
})
assert patch == {"ywrysw": False, "jdcsl": 2, "xm2": "李四"}
def test_business_keys_without_form_source_not_injected():
"""bjrjs/ywmtc 无表单来源:不进入注入映射(只能 set_info 直写)。"""
assert "bjrjs" not in BUSINESS_TO_FORM_KEY
assert "ywmtc" not in BUSINESS_TO_FORM_KEY