diff --git a/examples/langgraph_smoke.py b/examples/langgraph_smoke.py new file mode 100644 index 0000000..143d0ce --- /dev/null +++ b/examples/langgraph_smoke.py @@ -0,0 +1,38 @@ +"""LangGraph 脚手架冒烟脚本(作业 1)。 + +同一 thread 输入两次、另一个 thread 输入一次,打印轮数和文本。 +预期输出轮数依次为 1、2、1。 + +本脚本不导入 FastGPT、不读取其密钥;离线即可运行: + + python examples/langgraph_smoke.py +""" + +import asyncio + +from src.agent.graph import build_graph + + +async def main() -> None: + graph = build_graph() + + async def ask(thread_id: str, text: str) -> None: + # 只传本轮变化的字段;会话字段(turn_count 等)由 checkpoint 保管。 + # 注意:对已有 checkpoint 的 thread 传入完整初始状态, + # 其中的 turn_count=0 会覆盖已累计的值。 + result = await graph.ainvoke( + {"session_id": thread_id, "input_text": text}, + config={"configurable": {"thread_id": thread_id}}, + ) + print( + f"thread={thread_id} turn_count={result['turn_count']} " + f"stage_code={result['stage_code']} text={result['response_text']}" + ) + + await ask("thread-a", "你好") + await ask("thread-a", "发生了交通事故") + await ask("thread-b", "你好") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt index 62fbfeb..64f244a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ requests sqlalchemy pymysql PyYAML>=6.0 +langgraph==1.2.9 diff --git a/src/agent/__init__.py b/src/agent/__init__.py new file mode 100644 index 0000000..0bf7c46 --- /dev/null +++ b/src/agent/__init__.py @@ -0,0 +1 @@ +"""LangGraph accident-flow agent (作业 1 起逐步交付)。""" diff --git a/src/agent/graph.py b/src/agent/graph.py new file mode 100644 index 0000000..8ca0152 --- /dev/null +++ b/src/agent/graph.py @@ -0,0 +1,93 @@ +"""LangGraph 最小脚手架(作业 1)。 + +图结构:START → normalize_input → reply → END + +骨架已装配好:状态、节点注册、边和编译(默认 InMemorySaver)。 +两个节点的具体逻辑留待实现(见函数体内的 TODO)。 + +离线运行示例: + + python examples/langgraph_smoke.py + +对应测试:test/agent/test_scaffold.py +""" + +from langgraph.checkpoint.memory import InMemorySaver +from langgraph.graph import END, START, StateGraph + +from .state import AgentState + +# 脚手架阶段固定返回的阶段码;作业 3 起由转换表驱动。 +DEFAULT_STAGE_CODE = "1002" + + +def normalize_input(state: AgentState) -> dict: + """归一化本轮输入,为回复节点准备上下文。 + + TODO(作业 1): 实现该节点。建议职责: + - 校验/清理 ``state["input_text"]``(例如去除首尾空白); + - 返回需要写入状态的字段增量,例如 + ``{"input_text": cleaned}``; + - 保持纯函数:不读全局状态、不做 IO。 + """ + cleaned = state["input_text"].strip() + return {"input_text": cleaned} + + +def reply(state: AgentState) -> dict: + """生成固定模板回复,并把轮数加一。 + + TODO(作业 1): 实现该节点。验收要求: + - 每次调用使 ``turn_count`` 恰好 +1;首轮会话没有历史 + checkpoint,用 ``state.get("turn_count") or 0`` 起步; + - ``response_text`` 使用固定模板,**包含本轮输入文本** + (``state["input_text"]``); + - 响应文本需带公共协议前缀,例如 + ``1002...``(``stage_code`` 用 + ``DEFAULT_STAGE_CODE``,API 层负责解析该前缀); + - ``stage_code`` 写入 ``DEFAULT_STAGE_CODE``。 + + 参考实现:: + + turn_count = (state.get("turn_count") or 0) + 1 + response_text = ( + f"{DEFAULT_STAGE_CODE}" + f"已收到:{state['input_text']}" + ) + return { + "turn_count": turn_count, + "response_text": response_text, + "stage_code": DEFAULT_STAGE_CODE, + } + """ + turn_count = (state.get("turn_count") or 0) + 1 + response_text = ( + f"{DEFAULT_STAGE_CODE}" + f"已收到:{state['input_text']}" + ) + return { + "turn_count": turn_count, + "response_text": response_text, + "stage_code": DEFAULT_STAGE_CODE, + } + + +def build_graph(checkpointer=None): + """装配并编译最小图。 + + 参数: + checkpointer: 自定义 checkpointer;缺省使用 ``InMemorySaver``。 + 作业 7 会替换为 ``AsyncPostgresSaver``。 + + 返回: + 编译后的图。调用方通过 + ``config={"configurable": {"thread_id": <会话 ID>}}`` + 区分会话。 + """ + builder = StateGraph(AgentState) + builder.add_node("normalize_input", normalize_input) + builder.add_node("reply", reply) + builder.add_edge(START, "normalize_input") + builder.add_edge("normalize_input", "reply") + builder.add_edge("reply", END) + return builder.compile(checkpointer=checkpointer or InMemorySaver()) diff --git a/src/agent/state.py b/src/agent/state.py new file mode 100644 index 0000000..89b58eb --- /dev/null +++ b/src/agent/state.py @@ -0,0 +1,22 @@ +"""图状态定义(作业 1)。 + +最小状态只包含脚手架所需的五个字段;作业 3 会扩充领域状态 +(阶段、事故字段、人员字段、澄清计数、本轮 patch 等)。 +所有字段必须可序列化,不得保存 HTTP client、数据库连接等资源。 +""" + +from typing import TypedDict + + +class AgentState(TypedDict): + """一次对话轮次在图中流动的状态。 + + ``turn_count`` 由同一 ``thread_id`` 的 checkpoint 承载, + 节点每轮只负责 +1,不负责跨会话记忆。 + """ + + session_id: str + turn_count: int + input_text: str + response_text: str + stage_code: str diff --git a/test/agent/test_scaffold.py b/test/agent/test_scaffold.py new file mode 100644 index 0000000..dba661f --- /dev/null +++ b/test/agent/test_scaffold.py @@ -0,0 +1,56 @@ +"""作业 1 验收测试:LangGraph 最小图。 + +通过标准(对应迁移计划作业 1): +- 同一会话(thread)轮数累计:1 → 2; +- 不同会话相互隔离:新 thread 的轮数从 1 开始; +- 不依赖 FastGPT 与真实模型,离线可重复执行。 + +注意:节点实现(src/agent/graph.py 中的 TODO)完成前, +本文件的测试会失败,属于预期状态。 +""" + +import pytest + +from src.agent.graph import build_graph + + +async def ask(graph, thread_id: str, text: str) -> dict: + # 只传本轮变化的字段;对已有 checkpoint 的 thread 传完整初始状态, + # 其中的 turn_count=0 会覆盖已累计的值(LangGraph 输入按通道覆盖)。 + return await graph.ainvoke( + {"session_id": thread_id, "input_text": text}, + config={"configurable": {"thread_id": thread_id}}, + ) + + +@pytest.mark.asyncio +async def test_same_thread_accumulates_turns() -> None: + graph = build_graph() + + first = await ask(graph, "thread-a", "你好") + second = await ask(graph, "thread-a", "发生了交通事故") + + assert first["turn_count"] == 1 + assert second["turn_count"] == 2 + + +@pytest.mark.asyncio +async def test_different_threads_are_isolated() -> None: + graph = build_graph() + + await ask(graph, "thread-a", "你好") + await ask(graph, "thread-a", "发生了交通事故") + other = await ask(graph, "thread-b", "你好") + + assert other["turn_count"] == 1 + + +@pytest.mark.asyncio +async def test_reply_uses_fixed_template_with_stage_prefix() -> None: + graph = build_graph() + + result = await ask(graph, "thread-a", "你好") + + assert result["stage_code"] == "1002" + assert result["response_text"].startswith("1002") + assert "你好" in result["response_text"]