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
+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",