Files
ZNJJ-api-server/src/services/app_service.py
T
Eric Wang 7f52f1c4d0 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
2026-10-22 21:07:01 +08:00

166 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""事故处理应用服务(作业 6-1):四业务接口共享同一份业务状态。
职责边界(对应计划作业 6):
- set_info / get_info / delete_session **不调用 LLM、不创建辅助对话**
(对照旧 FastGPT 实现的“辅助对话 + 删记录”hack);
- chat 每轮从 repository 读最新业务字段注入图(set_info 写入后
下一轮 chat 必须可见),图产出字段差量后写回 repository;
- 未知 key 兼容策略:get 对未知 key 返回 JSON 空串 '""'(现状兼容);
set 对未知 key 拒绝写入(白名单保护,差异见兼容清单);
- includeInputInfo 继续被 endpoint 接受,但本层不再使用
——新后端无需触发辅助对话。
endpoint 只负责校验、协议(SSE / 响应模型)与依赖注入。
"""
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,
)
from .repository import SessionRepository
# set_info 对未知/受保护 key 的拒绝码(与现有“入参不规范”文案对齐)
SET_INFO_INVALID_KEY_MSG = "入参不规范:未知或受保护的字段 key"
class AccidentAppService:
"""四个业务接口的应用服务(langgraph 后端)。"""
def __init__(self, backend: ChatBackend, repository: SessionRepository):
self._backend = backend
self._repository = repository
# ---- chat:图每轮读取最新业务状态,产出差量写回 ----
def _injected_fields(self, session_id: str) -> dict:
"""repository 业务字段 -> 图输入的分组注入。
acd 业务键经 BUSINESS_TO_FORM_KEY 映射回图表单键
(无表单来源的 bjrjs/ywmtc 不注入);人员业务键与图的
person_fields 同构,原样注入。
"""
business = self._repository.load(session_id)
if not business:
return {}
injected: dict = {"accident_fields": {}, "person_fields": {}}
for key, value in business.items():
spec = FIELD_REGISTRY.get(key)
if spec is None:
continue
if spec.group is FieldGroup.ACD:
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)
result = await self._backend.complete(
ChatInput(
session_id=chat_input.session_id,
text=chat_input.text,
need_form_update=chat_input.need_form_update,
client_mode=chat_input.client_mode,
injected_fields=injected or None,
)
)
# 图产出的字段差量写回 repository,
# 使 set/get_info 与后续轮次看到同一事实来源
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();FormUpdate 事件捕获写回后透传。"""
injected = self._injected_fields(chat_input.session_id)
result_input = ChatInput(
session_id=chat_input.session_id,
text=chat_input.text,
need_form_update=chat_input.need_form_update,
client_mode=chat_input.client_mode,
injected_fields=injected or None,
)
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、零辅助对话 ----
def set_info(self, session_id: str, key: str, value: str) -> tuple[str, str]:
"""白名单校验后写入 repository。返回 (code, msg)。"""
spec = FIELD_REGISTRY.get(key)
if spec is None or not spec.writable:
return "500", SET_INFO_INVALID_KEY_MSG
try:
parsed = parse_field_value(spec, value)
except ValueError:
return "500", "入参不规范:字段值无法解析"
self._repository.apply_update(session_id, {key: parsed})
return "200", ""
def get_info(self, session_id: str, key: str) -> str:
"""按分组或单键读出,返回 JSON 字符串(兼容现有 /get_info 契约)。"""
import json
business = self._repository.load(session_id)
if key == "all":
return json.dumps(
{
group.value: {
k: to_external_string(business.get(k))
for k in GROUP_KEYS[group]
}
for group in FieldGroup
},
ensure_ascii=False,
)
try:
group = FieldGroup(key)
except ValueError:
group = None
if group is not None:
return json.dumps(
{k: to_external_string(business.get(k)) for k in GROUP_KEYS[group]},
ensure_ascii=False,
)
# 单键:未知 key 返回 JSON 空串(兼容现状);已知 key 返回其外部表示
if key not in FIELD_REGISTRY:
return json.dumps("", ensure_ascii=False)
return json.dumps(to_external_string(business.get(key)), ensure_ascii=False)
# ---- delete_session:业务状态 + 图 checkpoint 一起清理 ----
async def delete_session(self, session_id: str, drop_checkpoint) -> None:
"""删除会话全部数据。
drop_checkpoint:由 core 层注入的 checkpoint 清理函数
(图生命周期归 lifespan 管理,本层不持有图引用)。
repository 删除幂等;checkpoint 缺失时静默(幂等语义一致)。
"""
self._repository.delete(session_id)
if drop_checkpoint is not None:
try:
await drop_checkpoint(session_id)
except Exception:
# checkpoint 已不存在视为删除成功的一部分(幂等)
pass
__all__ = ["AccidentAppService", "SET_INFO_INVALID_KEY_MSG"]