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:
@@ -0,0 +1,106 @@
|
||||
"""作业 6 验收演示的 HTTP 层组合测试。
|
||||
|
||||
链路(对应计划作业 6):创建会话 -> /set_info 写车牌 -> /get_info 读回
|
||||
-> /chat 继续核实 -> 分组读值 -> /delete_session -> 再读与再聊不泄漏。
|
||||
|
||||
fake LLM(LLM_PROVIDER=fake)保证本测试离线可跑、零真实模型调用。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from src.agent.llm import workflow_fake_llm
|
||||
from src.core.config import Config
|
||||
from src.main import app
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(monkeypatch):
|
||||
"""langgraph 后端 + fake LLM 的测试客户端(lifespan 完整初始化)。"""
|
||||
monkeypatch.setattr(Config, "AGENT_BACKEND", "langgraph")
|
||||
monkeypatch.setattr(Config, "LLM_PROVIDER", "fake")
|
||||
|
||||
import src.agent.llm as llm_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
llm_module, "build_llm_service", lambda *a, **k: workflow_fake_llm()
|
||||
)
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
TS = "20261022120000"
|
||||
|
||||
|
||||
def test_assignment6_acceptance_flow(client):
|
||||
session = "a6-acceptance-001"
|
||||
|
||||
# set_info 写入车牌(零 LLM:fake 未注入任何 responder,调用即报错)
|
||||
response = client.post("/set_info", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
"key": "hphm1", "value": "苏B12345",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["code"] == "200"
|
||||
|
||||
# get_info 单键读回
|
||||
response = client.post("/get_info", json={
|
||||
"sessionId": session, "timeStamp": TS, "key": "hphm1",
|
||||
})
|
||||
assert response.json()["value"] == '"苏B12345"'
|
||||
|
||||
# chat 使用业务状态继续(非流式,协议层解析 state 前缀)
|
||||
response = client.post("/chat", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
"text": "我是刚才报警的驾驶员", "clientMode": "direct",
|
||||
})
|
||||
body = response.json()
|
||||
assert body["code"] == "200"
|
||||
assert body["nextStageCode"] in ("1001", "1002")
|
||||
|
||||
# 分组读值:三个分组齐全,未填字段为空串
|
||||
response = client.post("/get_info", json={
|
||||
"sessionId": session, "timeStamp": TS, "key": "all",
|
||||
})
|
||||
groups = json.loads(response.json()["value"])
|
||||
assert set(groups) == {"acdinfo", "acdhuman1", "acdhuman2"}
|
||||
assert groups["acdhuman1"]["hphm1"] == "苏B12345"
|
||||
assert groups["acdhuman1"]["xm1"] == ""
|
||||
|
||||
# 未知 key 兼容:JSON 空串
|
||||
response = client.post("/get_info", json={
|
||||
"sessionId": session, "timeStamp": TS, "key": "no_such_key",
|
||||
})
|
||||
assert response.json()["value"] == '""'
|
||||
|
||||
# set_info 未知 key:白名单拒绝
|
||||
response = client.post("/set_info", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
"key": "no_such_key", "value": "x",
|
||||
})
|
||||
assert response.json()["code"] == "500"
|
||||
|
||||
# 删除 -> 再读为空
|
||||
response = client.request("DELETE", "/delete_session", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
})
|
||||
assert response.json()["code"] == "200"
|
||||
response = client.post("/get_info", json={
|
||||
"sessionId": session, "timeStamp": TS, "key": "hphm1",
|
||||
})
|
||||
assert response.json()["value"] == '""'
|
||||
|
||||
# 重复删除幂等成功
|
||||
response = client.request("DELETE", "/delete_session", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
})
|
||||
assert response.json()["code"] == "200"
|
||||
|
||||
# 删除后再聊天:不注入旧业务数据,服务正常应答
|
||||
response = client.post("/chat", json={
|
||||
"sessionId": session, "timeStamp": TS,
|
||||
"text": "新对话", "clientMode": "direct",
|
||||
})
|
||||
assert response.json()["code"] == "200"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user