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:
@@ -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
@@ -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、零辅助对话 ----
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:清理 + 幂等 ----
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user