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