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
+192
View File
@@ -0,0 +1,192 @@
"""作业 6:应用服务四接口组合测试。
验收演示对应(计划作业 6):
创建会话 -> set_info 写车牌 -> get_info 读回 -> chat 使用车牌继续核实
-> 分组读值 -> 删除 -> 再读与再次聊天均不泄漏旧数据。
spy 断言:set_info / get_info / delete_session 全程零模型调用。
"""
import json
import pytest
from src.backends.chat import ChatResult, TextDelta, FormUpdate
from src.services.app_service import AccidentAppService, SET_INFO_INVALID_KEY_MSG
from src.services.repository import InMemorySessionRepository
class SpyBackend:
"""记录调用的 ChatBackend 替身;form_update 模拟图产出的字段差量。"""
def __init__(self, form_update=None):
self.calls = [] # complete 收到的 ChatInput
self.stream_calls = []
self._form_update = form_update or {}
# 用调用次数当“模型调用”spy:真实 backend 每次 complete 至少一次 LLM
self.llm_calls = 0
async def complete(self, chat_input):
self.calls.append(chat_input)
self.llm_calls += 1
return ChatResult(
content="<state>1002</state>收到",
status_code=None,
form_update=dict(self._form_update),
)
async def stream(self, chat_input):
self.stream_calls.append(chat_input)
self.llm_calls += 1
yield TextDelta("<state>1002</state>")
if self._form_update:
yield FormUpdate(dict(self._form_update))
yield TextDelta("收到")
def make_service(form_update=None):
backend = SpyBackend(form_update=form_update)
service = AccidentAppService(backend, InMemorySessionRepository())
return service, backend
# ---- set/get:白名单、分组、外部表示 ----
def test_set_then_get_single_key_roundtrip():
service, backend = make_service()
code, msg = service.set_info("s1", "hphm1", "苏B12345")
assert (code, msg) == ("200", "")
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
# set/get 零模型调用
assert backend.llm_calls == 0
def test_get_groups_match_existing_contract():
service, _ = make_service()
service.set_info("s1", "ywrysw", "0") # False
service.set_info("s1", "jdcsl", "0") # int 0
service.set_info("s1", "xm2", "张三")
acd = json.loads(service.get_info("s1", "acdinfo"))
assert acd["ywrysw"] == "0" # False -> "0"
assert acd["jdcsl"] == "0" # int 0 -> "0"(内部类型不同)
assert acd["sgyy"] == "" # 未知 -> 空串
human2 = json.loads(service.get_info("s1", "acdhuman2"))
assert human2["xm2"] == "张三"
assert human2["sfzmwh2"] == "" # 全字段齐全,未填为空
all_groups = json.loads(service.get_info("s1", "all"))
assert set(all_groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
def test_get_unknown_key_returns_json_empty_string():
"""兼容现状:未知 key 返回 JSON 空串 '""'。"""
service, _ = make_service()
service.set_info("s1", "hphm1", "苏B12345")
assert service.get_info("s1", "no_such_key") == '""'
assert service.get_info("ghost-session", "hphm1") == '""'
def test_set_unknown_or_internal_key_rejected():
service, backend = make_service()
code, msg = service.set_info("s1", "no_such_key", "x")
assert code == "500" and msg == SET_INFO_INVALID_KEY_MSG
code, _ = service.set_info("s1", "stage_code", "1002")
assert code == "500" # 内部字段保护
assert service.get_info("s1", "hphm1") == '""' # 未被污染
assert backend.llm_calls == 0
def test_set_rejects_unparsable_value():
service, _ = make_service()
code, msg = service.set_info("s1", "ywrysw", "maybe")
assert code == "500" and "无法解析" in msg
# ---- chat:注入 + 写回 ----
@pytest.mark.asyncio
async def test_chat_sees_set_info_values_and_writes_back_patch():
"""set_info 写入 -> chat 输入注入 -> 图差量写回 -> get_info 读到。"""
service, backend = make_service(form_update={"ywrysw": False})
service.set_info("s1", "hphm1", "苏B12345")
service.set_info("s1", "xm2", "李四")
result = await service.chat(
_input("s1", "我是刚才报警的驾驶员")
)
assert result.content == "<state>1002</state>收到"
# 图输入必须携带 repository 最新业务字段(车牌供核实使用)
injected = backend.calls[0].injected_fields
assert injected["person_fields"]["hphm1"] == "苏B12345"
assert injected["person_fields"]["xm2"] == "李四"
# 图产出的差量写回 repository,get_info 立即可读
assert json.loads(service.get_info("s1", "acdinfo"))["ywrysw"] == "0"
assert json.loads(service.get_info("s1", "hphm1")) == "苏B12345"
@pytest.mark.asyncio
async def test_chat_without_business_state_passes_no_injection():
service, backend = make_service()
await service.chat(_input("fresh-session", "你好"))
assert backend.calls[0].injected_fields is None
@pytest.mark.asyncio
async def test_stream_injects_fields_and_passthrough_events():
service, backend = make_service(form_update={"ywrysw": False})
service.set_info("s1", "xm1", "王五")
events = [
event async for event in service.stream(_input("s1", "继续办理"))
]
kinds = [type(event).__name__ for event in events]
assert kinds == ["TextDelta", "FormUpdate", "TextDelta"]
assert backend.stream_calls[0].injected_fields["person_fields"]["xm1"] == "王五"
# ---- delete:清理 + 幂等 ----
@pytest.mark.asyncio
async def test_delete_clears_business_state_and_is_idempotent():
drops = []
async def drop_checkpoint(session_id):
drops.append(session_id)
service, backend = make_service()
service.set_info("s1", "hphm1", "苏B12345")
await service.chat(_input("s1", "你好"))
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1"]
# 再读为空、再聊天不注入旧数据
assert service.get_info("s1", "hphm1") == '""'
await service.chat(_input("s1", "还在吗"))
assert backend.calls[-1].injected_fields is None
# 重复删除固定行为:成功返回;checkpoint 清理会再次触发,
# 但缺失时静默容忍(幂等语义由 drop_checkpoint 内部保证)
await service.delete_session("s1", drop_checkpoint=drop_checkpoint)
assert drops == ["s1", "s1"]
assert backend.llm_calls == 2 # 两次 chat 各一次;set/get/delete 零调用
@pytest.mark.asyncio
async def test_delete_tolerates_missing_checkpoint():
async def missing(_session_id):
raise RuntimeError("no such thread")
service, _ = make_service()
service.set_info("s1", "xm1", "王五")
# checkpoint 不存在视为删除成功的一部分,不向上抛
await service.delete_session("s1", drop_checkpoint=missing)
assert service.get_info("s1", "xm1") == '""'
def _input(session_id, text):
from src.backends.chat import ChatInput
return ChatInput(session_id=session_id, text=text, client_mode="direct")
+89
View File
@@ -0,0 +1,89 @@
"""作业 6:字段注册表测试(分组完整性、类型/敏感级别、外部表示)。"""
import pytest
from src.services.fields_registry import (
FIELD_REGISTRY,
GROUP_KEYS,
INTERNAL_FIELD_KEYS,
FieldGroup,
SensitiveLevel,
parse_field_value,
to_external_string,
)
def test_registry_covers_all_three_groups():
"""注册表完整覆盖 acd/human1/human2 三个分组(作业 6-3)。"""
assert set(GROUP_KEYS) == set(FieldGroup)
assert len(FIELD_REGISTRY) == 24
assert len(GROUP_KEYS[FieldGroup.ACD]) == 8
assert len(GROUP_KEYS[FieldGroup.HUMAN1]) == 8
assert len(GROUP_KEYS[FieldGroup.HUMAN2]) == 8
def test_person_fields_include_full_set():
"""人员字段必须包含 sfzmwh/sjwh/csbw,不能只迁姓名和号码。"""
for index in (1, 2):
group_keys = set(GROUP_KEYS[FieldGroup.HUMAN1 if index == 1 else FieldGroup.HUMAN2])
for suffix in ("xm", "hpzl", "hphm", "sfzmhm", "sfzmwh", "sjhm", "sjwh", "csbw"):
assert f"{suffix}{index}" in group_keys
def test_registry_metadata_recorded():
"""每个字段都记录类型、可写性、敏感级别(作业 6-4)。"""
for spec in FIELD_REGISTRY.values():
assert spec.value_type in (str, bool, int)
assert spec.writable is True
assert isinstance(spec.sensitive, SensitiveLevel)
assert spec.description
def test_sensitive_levels_assigned():
"""证件号/手机号 sensitive,姓名 personal,其余 public。"""
assert FIELD_REGISTRY["sfzmhm1"].sensitive is SensitiveLevel.SENSITIVE
assert FIELD_REGISTRY["sjhm2"].sensitive is SensitiveLevel.SENSITIVE
assert FIELD_REGISTRY["xm1"].sensitive is SensitiveLevel.PERSONAL
assert FIELD_REGISTRY["hphm1"].sensitive is SensitiveLevel.PUBLIC
@pytest.mark.parametrize("raw,expected", [
("1", True), ("true", True), ("是", True),
("0", False), ("false", False), ("否", False),
])
def test_parse_bool_explicit(raw, expected):
assert parse_field_value(FIELD_REGISTRY["ywrysw"], raw) is expected
@pytest.mark.parametrize("bad", ["", "maybe", "2", "有吧"])
def test_parse_bool_rejects_ambiguous(bad):
"""模糊值必须拒绝——保留 空/False/0/未知 的区分能力。"""
with pytest.raises(ValueError):
parse_field_value(FIELD_REGISTRY["ywrysw"], bad)
def test_parse_int_and_str():
assert parse_field_value(FIELD_REGISTRY["jdcsl"], "2") == 2
assert parse_field_value(FIELD_REGISTRY["jdcsl"], "0") == 0
with pytest.raises(ValueError):
parse_field_value(FIELD_REGISTRY["jdcsl"], "两辆")
assert parse_field_value(FIELD_REGISTRY["hphm1"], "苏B12345") == "苏B12345"
def test_external_string_representation():
"""对外序列化:True->1,False->0,None->空串;内部仍区分 False 与 0。"""
assert to_external_string(True) == "1"
assert to_external_string(False) == "0"
assert to_external_string(0) == "0"
assert to_external_string(None) == ""
assert to_external_string("苏B12345") == "苏B12345"
# 内部存储不混淆:False 是 bool、0 是 int
assert parse_field_value(FIELD_REGISTRY["ywrysw"], "0") is False
assert parse_field_value(FIELD_REGISTRY["jdcsl"], "0") == 0
assert isinstance(parse_field_value(FIELD_REGISTRY["jdcsl"], "0"), int)
def test_internal_field_protection_listed():
"""图内部键登记在保护清单中,set_info 白名单不会收录它们。"""
for key in INTERNAL_FIELD_KEYS:
assert key not in FIELD_REGISTRY