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
+68 -10
View File
@@ -11,7 +11,12 @@ from ..backends.chat import (
FormUpdate,
TextDelta,
)
from ..core.fastgpt_client import get_chat_backend, get_fastgpt_client
from ..core.fastgpt_client import (
delete_chat_session,
get_app_service,
get_chat_backend,
get_fastgpt_client,
)
from ..core.config import Config
from ..utils.text_chunker import SentenceTextChunker, SentenceTextChunkerConfig
from loguru import logger
@@ -131,7 +136,11 @@ async def chat(
stream: bool = False,
backend: ChatBackend = Depends(get_chat_backend)
):
"""Handle chat completion request."""
"""Handle chat completion request.
作业 6:langgraph 模式由应用服务承载业务状态同步
(repository 字段注入 + 差量写回),endpoint 仍负责协议解析。
"""
json_data = request.model_dump()
client_mode = json_data['clientMode']
need_form_update = json_data.get('needFormUpdate', False)
@@ -142,6 +151,14 @@ async def chat(
need_form_update=need_form_update,
client_mode=client_mode,
)
# 应用服务:langgraph 模式下包装字段注入/写回;fastgpt 模式透传。
# 单测直调函数(未走 lifespan)时回退 backend 直调。
app_service = None
if Config.AGENT_BACKEND == "langgraph":
try:
app_service = get_app_service()
except RuntimeError:
app_service = None
request_started_at = time.perf_counter()
logger.info(
"Chat request received "
@@ -250,7 +267,11 @@ async def chat(
state_filter_buffer = ""
return cleaned
async for event in backend.stream(chat_input):
async for event in (
app_service.stream(chat_input)
if app_service is not None
else backend.stream(chat_input)
):
try:
if not first_event_logged:
first_event_logged = True
@@ -370,7 +391,10 @@ async def chat(
return StreamingResponse(event_generator(), media_type="text/event-stream")
try:
result = await backend.complete(chat_input)
if app_service is not None:
result = await app_service.chat(chat_input)
else:
result = await backend.complete(chat_input)
logger.info(
"Chat backend non-stream response received "
f"sessionId={json_data['sessionId']} "
@@ -485,10 +509,28 @@ async def chat(
@router.post("/set_info", response_model=ProcessResponse_set)
async def set_info(
request: ProcessRequest_set,
client: AsyncChatClient = Depends(get_fastgpt_client),
client=None, # fastgpt 模式使用;注解省略(FastAPI 会解析参数注解)
):
"""Set information in chat state."""
"""Set information in chat state.
作业 6:langgraph 模式走应用服务 + repository——零 LLM 调用、
零辅助对话;includeInputInfo 继续接受但不再触发辅助对话。
fastgpt 模式保留旧实现。
"""
json_data = request.model_dump()
if Config.AGENT_BACKEND == "langgraph":
code, msg = get_app_service().set_info(
json_data['sessionId'], json_data['key'], json_data['value']
)
return ProcessResponse_set(
sessionId=json_data['sessionId'],
timeStamp=json_data['timeStamp'],
code=code,
msg=msg or None,
)
client = client or get_fastgpt_client()
set_info_payload = {'key': json_data['key'], 'value': json_data['value']}
set_info_input = format_set_info_input(
set_info_payload,
@@ -577,10 +619,25 @@ async def set_info(
@router.post("/get_info", response_model=ProcessResponse_get)
async def get_info(
request: ProcessRequest_get,
client: AsyncChatClient = Depends(get_fastgpt_client),
client=None, # fastgpt 模式使用;注解省略(FastAPI 会解析参数注解)
):
"""Get information from chat state."""
"""Get information from chat state.
作业 6:langgraph 模式走应用服务 + repository——零 LLM 调用;
分组与单键读法兼容现有契约,未知 key 返回 JSON 空串。
"""
json_data = request.model_dump()
if Config.AGENT_BACKEND == "langgraph":
return ProcessResponse_get(
sessionId=json_data['sessionId'],
timeStamp=json_data['timeStamp'],
value=get_app_service().get_info(json_data['sessionId'], json_data['key']),
code="200",
msg="",
)
client = client or get_fastgpt_client()
get_info_payload = {'key': json_data['key']}
get_info_input = format_set_info_input(
get_info_payload,
@@ -700,9 +757,10 @@ async def delete_session(
)
if Config.AGENT_BACKEND == "langgraph":
from ..core.fastgpt_client import delete_chat_session
try:
await delete_chat_session(chat_id)
await get_app_service().delete_session(
chat_id, drop_checkpoint=delete_chat_session
)
return ProcessResponse_delete_session(
sessionId=json_data['sessionId'],
timeStamp=json_data['timeStamp'],
+4
View File
@@ -13,6 +13,10 @@ class ChatInput:
text: str
need_form_update: bool = False
client_mode: str = ""
# 作业 6:应用服务注入的最新业务字段(acd 进 accident_fields,
# 人员进 person_fields)。LangGraph 后端合并进图输入;
# FastGPT 后端忽略该字段。
injected_fields: dict | None = None
@dataclass(frozen=True)
+10 -5
View File
@@ -49,12 +49,17 @@ class LangGraphBackend(ChatBackend):
- 本阶段不处理 ``need_form_update`` 的真实表单差量
(作业 6 起实现)。
"""
graph_input: dict = {
"session_id": chat_input.session_id,
"input_text": chat_input.text,
"client_mode": chat_input.client_mode,
}
# 作业 6:应用服务注入的最新业务字段覆盖本轮输入,
# 使 set_info 写入后本轮 chat 立即可见(输入覆盖 checkpoint 同键)。
if chat_input.injected_fields:
graph_input.update(chat_input.injected_fields)
result = await self._graph.ainvoke(
{
"session_id": chat_input.session_id,
"input_text": chat_input.text,
"client_mode": chat_input.client_mode,
},
graph_input,
config={"configurable": {"thread_id": chat_input.session_id}},
context=GraphDeps(llm=self._llm),
)
+16 -1
View File
@@ -6,12 +6,15 @@ from ..agent.graph import build_graph
from ..backends.chat import ChatBackend
from ..backends.fastgpt import FastGPTBackend
from ..backends.langgraph import LangGraphBackend
from ..services.app_service import AccidentAppService
from ..services.repository import InMemorySessionRepository
from .config import Config
_fastgpt_client: AsyncChatClient | None = None
_chat_backend: ChatBackend | None = None
_langgraph_graph = None
_app_service: AccidentAppService | None = None
@asynccontextmanager
@@ -25,9 +28,13 @@ async def lifespan(app: FastAPI):
# TODO(作业 2): 若语音仍启用,需在此单独初始化并校验 FastGPT 配置。
from ..agent.llm import build_llm_service
global _langgraph_graph
global _langgraph_graph, _app_service
_langgraph_graph = build_graph()
_chat_backend = LangGraphBackend(_langgraph_graph, llm=build_llm_service())
# 作业 6:应用服务 + 内存 repository(作业 7 换 PostgreSQL)。
_app_service = AccidentAppService(
_chat_backend, InMemorySessionRepository()
)
else:
_fastgpt_client = AsyncChatClient(
api_key=Config.FASTGPT_API_KEY,
@@ -45,6 +52,7 @@ async def lifespan(app: FastAPI):
finally:
_chat_backend = None
_langgraph_graph = None
_app_service = None
if _fastgpt_client:
await _fastgpt_client.__aexit__(None, None, None)
_fastgpt_client = None
@@ -68,6 +76,13 @@ def get_fastgpt_client() -> AsyncChatClient:
return _fastgpt_client
def get_app_service() -> AccidentAppService:
"""Get the initialized application service(langgraph 模式)。"""
if _app_service is None:
raise RuntimeError("Application service not initialized")
return _app_service
def get_chat_backend() -> ChatBackend:
"""Get the backend-neutral chat service."""
if _chat_backend is None:
+5
View File
@@ -0,0 +1,5 @@
"""应用服务层(作业 6):应用服务、字段注册表、会话 repository。
endpoint 只处理校验、协议(SSE/响应模型)和依赖注入;
业务状态读写、字段白名单与分组语义集中在本层。
"""
+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"]
+144
View File
@@ -0,0 +1,144 @@
"""字段注册表(作业 6-3/6-4):外部业务键 -> 类型/分组/可写性/敏感级别。
完整覆盖当前 get_info 代码中的三个分组:
- 事故字段(acdinfo):ywrysw/ywfjdc/ywmtc/bjrjs/sgfssj/sfsgxc/jdcsl/sgyy
- 本方人员(acdhuman1):xm1/hpzl1/hphm1/sfzmhm1/sfzmwh1/sjhm1/sjwh1/csbw1
- 对方人员(acdhuman2):同上一组,序号 2
设计约束:
- 外部传输一律字符串;内部按 value_type 存储强类型值,
保留区分 空值 / False / 0 / 未知 的能力(不做 bool("0") 转换);
- 敏感级别用于作业 9 的访问边界与日志脱敏,注册表先行记录;
- get 的分组名(acdinfo/acdhuman1/acdhuman2)与外部接口契约保持一致。
"""
from dataclasses import dataclass
from enum import StrEnum
from typing import Any
class FieldGroup(StrEnum):
"""get_info 对外分组名(兼容现有 /get_info 契约)。"""
ACD = "acdinfo"
HUMAN1 = "acdhuman1"
HUMAN2 = "acdhuman2"
class SensitiveLevel(StrEnum):
"""敏感级别(作业 9 用于访问边界/脱敏)。"""
PUBLIC = "public" # 无个人信息
PERSONAL = "personal" # 姓名等一般个人信息
SENSITIVE = "sensitive" # 证件号/手机号,日志与回显需保护
@dataclass(frozen=True)
class FieldSpec:
"""一个外部业务字段的注册信息。"""
key: str
group: FieldGroup
value_type: type # str / bool / int
writable: bool
sensitive: SensitiveLevel
description: str = ""
def _acd_fields() -> list[FieldSpec]:
return [
FieldSpec("ywrysw", FieldGroup.ACD, bool, True, SensitiveLevel.PUBLIC, "是否有人受伤"),
FieldSpec("ywfjdc", FieldGroup.ACD, bool, True, SensitiveLevel.PUBLIC, "是否撞非机动车"),
FieldSpec("ywmtc", FieldGroup.ACD, bool, True, SensitiveLevel.PUBLIC, "是否撞摩托车"),
FieldSpec("bjrjs", FieldGroup.ACD, bool, True, SensitiveLevel.PUBLIC, "报警人是否驾驶员"),
FieldSpec("sgfssj", FieldGroup.ACD, str, True, SensitiveLevel.PUBLIC, "事故发生时间"),
FieldSpec("sfsgxc", FieldGroup.ACD, bool, True, SensitiveLevel.PUBLIC, "是否在现场"),
FieldSpec("jdcsl", FieldGroup.ACD, int, True, SensitiveLevel.PUBLIC, "机动车数量"),
FieldSpec("sgyy", FieldGroup.ACD, str, True, SensitiveLevel.PUBLIC, "事故原因"),
]
def _human_fields(index: int) -> list[FieldSpec]:
group = FieldGroup.HUMAN1 if index == 1 else FieldGroup.HUMAN2
side = "本方" if index == 1 else "对方"
return [
FieldSpec(f"xm{index}", group, str, True, SensitiveLevel.PERSONAL, f"{side}姓名"),
FieldSpec(f"hpzl{index}", group, str, True, SensitiveLevel.PUBLIC, f"{side}号牌种类"),
FieldSpec(f"hphm{index}", group, str, True, SensitiveLevel.PUBLIC, f"{side}号牌号码"),
FieldSpec(f"sfzmhm{index}", group, str, True, SensitiveLevel.SENSITIVE, f"{side}身份证明号码"),
FieldSpec(f"sfzmwh{index}", group, str, True, SensitiveLevel.SENSITIVE, f"{side}身份证明文号"),
FieldSpec(f"sjhm{index}", group, str, True, SensitiveLevel.SENSITIVE, f"{side}手机号码"),
FieldSpec(f"sjwh{index}", group, str, True, SensitiveLevel.PUBLIC, f"{side}手机文号"),
FieldSpec(f"csbw{index}", group, str, True, SensitiveLevel.PUBLIC, f"{side}常熟部位/身份标识"),
]
# 注册表:key -> FieldSpec(构建一次,只读共享)
FIELD_REGISTRY: dict[str, FieldSpec] = {
spec.key: spec for spec in [*_acd_fields(), *_human_fields(1), *_human_fields(2)]
}
# 分组 -> 组内有序字段键(保持当前 get_info 的键顺序,输出稳定)
GROUP_KEYS: dict[FieldGroup, tuple[str, ...]] = {
group: tuple(spec.key for spec in FIELD_REGISTRY.values() if spec.group == group)
for group in FieldGroup
}
# 内部受保护字段:注册表之外、图内部使用的键,set_info 不得写入
# (兼容策略见 docs/assignment6-compat.md)
INTERNAL_FIELD_KEYS = frozenset(
{
"stage_code", "raw_status_code", "turn_count", "client_event",
"clarify_count", "no_reply_count", "state_version",
}
)
def parse_field_value(spec: FieldSpec, raw: str) -> Any:
"""外部字符串 -> 内部强类型值;无法解析时抛 ValueError。
bool 只接受明确的 1/0/true/false/是/否;拒绝空串与模糊值,
避免 bool("0") == True 这类陷阱。int 必须是纯数字字面量。
"""
if raw is None:
raise ValueError(f"{spec.key}: 值为空")
text = raw.strip()
if spec.value_type is bool:
lowered = text.lower()
if lowered in ("1", "true", "是"):
return True
if lowered in ("0", "false", "否"):
return False
raise ValueError(f"{spec.key}: 无法解析为布尔值: {raw!r}")
if spec.value_type is int:
if text.isdigit():
return int(text)
raise ValueError(f"{spec.key}: 无法解析为整数: {raw!r}")
return raw # str 原样存储(空串视为有效的外部空值)
def to_external_string(value: Any) -> str:
"""内部值 -> 外部字符串表示(兼容现有 get_info 的 bool_to_str)。
True -> "1",False -> "0",None/未知 -> "",其余 str() 原样。
内部仍保留 False 与 0 的区分,仅在对外序列化时收敛。
"""
if value is True:
return "1"
if value is False:
return "0"
if value is None:
return ""
return str(value)
__all__ = [
"FIELD_REGISTRY",
"GROUP_KEYS",
"INTERNAL_FIELD_KEYS",
"FieldGroup",
"FieldSpec",
"SensitiveLevel",
"parse_field_value",
"to_external_string",
]
+52
View File
@@ -0,0 +1,52 @@
"""会话业务状态 repository(作业 6-1/6-2)。
抽象出 repository 接口,使应用服务不依赖具体存储;
作业 6 用内存实现跑通四接口,作业 7 再替换为 PostgreSQL 实现
(对话 checkpoint 已由 LangGraph checkpointer 承担,本层只管
业务字段——单一事实来源,图每轮读取最新值)。
语义约定:
- 业务状态按外部业务键(hphm1、ywrysw...)扁平存储,值为内部强类型;
- 不存在的会话视为空状态(读取返回 {}),与首轮 checkpoint 无字段一致;
- delete 幂等:重复删除固定成功,不抛异常(行为进测试)。
"""
from typing import Protocol
class SessionRepository(Protocol):
"""业务字段存储接口(作业 7 由 PostgreSQL 实现替换)。"""
def load(self, session_id: str) -> dict:
"""读取会话业务字段;无记录时返回空 dict。"""
...
def apply_update(self, session_id: str, patch: dict) -> dict:
"""合并差量并返回合并后的完整业务字段。"""
...
def delete(self, session_id: str) -> None:
"""删除会话业务状态;幂等,重复删除不抛异常。"""
...
class InMemorySessionRepository:
"""内存实现:字典存储,仅用于单进程开发与测试。"""
def __init__(self) -> None:
self._store: dict[str, dict] = {}
def load(self, session_id: str) -> dict:
return dict(self._store.get(session_id) or {})
def apply_update(self, session_id: str, patch: dict) -> dict:
state = self._store.setdefault(session_id, {})
state.update(patch)
return dict(state)
def delete(self, session_id: str) -> None:
# 幂等:pop 的缺省值吞掉不存在的情况,重复删除行为固定为成功
self._store.pop(session_id, None)
__all__ = ["SessionRepository", "InMemorySessionRepository"]