feat(services): assignment 6 skeleton - app service, field registry, in-memory repository

- fields_registry: 24 business keys in acd/human1/human2 groups with
  value_type, writable and SensitiveLevel metadata; strict bool/int
  parsing (no bool('0') trap); INTERNAL_FIELD_KEYS protection list
- repository: SessionRepository protocol + InMemorySessionRepository
  (idempotent delete; PostgreSQL swap-in is assignment 7)
- AccidentAppService: chat/set_info/get_info/delete_session share one
  business state - chat injects latest repository fields into the graph
  and writes back validated field diffs; set/get/delete make zero LLM
  calls and zero helper conversations (includeInputInfo accepted but
  unused); unknown get keys keep the '' JSON-empty-string contract,
  unknown set keys are rejected by whitelist
- ChatInput.injected_fields neutral contract extension; LangGraphBackend
  merges injected fields into graph input (set_info visible next chat)
- endpoints: thin layer - langgraph mode routes business state through
  the app service (fastgpt mode keeps the legacy path); delete cleans
  repository + graph checkpoint, idempotent
- tests: registry completeness (sfzmwh/sjwh/csbw included), app service
  combos with LLM-call spy, HTTP acceptance flow (set -> get -> chat ->
  groups -> delete -> no leak, idempotent re-delete)
- docs/assignment6-compat.md: behavior diff list vs FastGPT status quo
  and the audit backlog (arbitrary set_info key usage, stream formUpdate
  write-back, sensitive-field display boundary)

Verified: 200 unit tests (172 existing + 28 new)
This commit is contained in:
Eric Wang
2026-10-22 20:50:10 +08:00
parent 6c1fbd0295
commit 8717d9066c
12 changed files with 882 additions and 16 deletions
+152
View File
@@ -0,0 +1,152 @@
"""事故处理应用服务(作业 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
from .fields_registry import (
FIELD_REGISTRY,
GROUP_KEYS,
FieldGroup,
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 进 accident_fields,
两组人员进 person_fields,与 AgentState 的键结构对齐)。"""
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:
injected["accident_fields"][key] = value
else:
injected["person_fields"][key] = value
return injected
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,
)
)
# 图产出的字段差量(turn_patch / 完整字段)写回 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)
return result
async def stream(self, chat_input: ChatInput):
"""流式代理:字段注入与写回语义同 chat(),事件流原样透传。"""
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,
)
# 骨架阶段原样透传事件流;字段差量写回在作业 6 完整版随
# FormUpdate 事件捕获补齐(对照计划作业 8 的真实增量)。
async for event in self._backend.stream(result_input):
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"]