From 7f52f1c4d03855172587d12d5d283dadd5bcd1b0 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Thu, 22 Oct 2026 21:07:01 +0800 Subject: [PATCH] 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 --- docs/assignment6-compat.md | 24 +++++++++++++++--- src/services/app_service.py | 35 ++++++++++++++++++-------- src/services/fields_registry.py | 36 +++++++++++++++++++++++++++ test/services/test_app_service.py | 34 +++++++++++++++++++++++-- test/services/test_fields_registry.py | 32 ++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 17 deletions(-) diff --git a/docs/assignment6-compat.md b/docs/assignment6-compat.md index 057ad98..f1d46a6 100644 --- a/docs/assignment6-compat.md +++ b/docs/assignment6-compat.md @@ -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 落地。 diff --git a/src/services/app_service.py b/src/services/app_service.py index cde348c..2f0f93b 100644 --- a/src/services/app_service.py +++ b/src/services/app_service.py @@ -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、零辅助对话 ---- diff --git a/src/services/fields_registry.py b/src/services/fields_registry.py index e51b956..bd0aa88 100644 --- a/src/services/fields_registry.py +++ b/src/services/fields_registry.py @@ -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", diff --git a/test/services/test_app_service.py b/test/services/test_app_service.py index 77dcf0b..81720ca 100644 --- a/test/services/test_app_service.py +++ b/test/services/test_app_service.py @@ -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:清理 + 幂等 ---- diff --git a/test/services/test_fields_registry.py b/test/services/test_fields_registry.py index 01e0d10..871739d 100644 --- a/test/services/test_fields_registry.py +++ b/test/services/test_fields_registry.py @@ -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