feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
This commit is contained in:
122
packages/core/tests/fakes_orchestrator.py
Normal file
122
packages/core/tests/fakes_orchestrator.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""编排器测试替身——mock 网关(`stream` / `run`)+ 内存审稿留痕 repo。
|
||||
|
||||
绝对导入(无包目录下不能相对导入,见 gotchas 2026-06-18):`from fakes_orchestrator import ...`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_llm_gateway.types import (
|
||||
Delta,
|
||||
LlmRequest,
|
||||
LlmResponse,
|
||||
ServedBy,
|
||||
Usage,
|
||||
)
|
||||
|
||||
|
||||
class FakeStreamGateway:
|
||||
"""按预设 token 列表吐 `Delta`,并记录收到的 `LlmRequest`(断言请求构造)。"""
|
||||
|
||||
def __init__(self, tokens: list[str]) -> None:
|
||||
self._tokens = tokens
|
||||
self.last_request: LlmRequest | None = None
|
||||
self.call_count = 0
|
||||
|
||||
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
|
||||
self.last_request = req
|
||||
self.call_count += 1
|
||||
for tok in self._tokens:
|
||||
yield Delta(text=tok)
|
||||
|
||||
|
||||
class RaisingGateway:
|
||||
"""先吐几个 token,再抛指定异常——模拟流式中途失败(验 SSE error 归一)。"""
|
||||
|
||||
def __init__(self, tokens: list[str], exc: Exception) -> None:
|
||||
self._tokens = tokens
|
||||
self._exc = exc
|
||||
|
||||
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
|
||||
for tok in self._tokens:
|
||||
yield Delta(text=tok)
|
||||
raise self._exc
|
||||
|
||||
|
||||
def _stub_usage() -> Usage:
|
||||
return Usage(
|
||||
provider="fake",
|
||||
model="fake-analyst",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
|
||||
class FakeRunGateway:
|
||||
"""按预设 `parsed` 实例回 `LlmResponse`,记录收到的请求(断言审稿请求构造)。"""
|
||||
|
||||
def __init__(self, parsed: BaseModel) -> None:
|
||||
self._parsed = parsed
|
||||
self.last_request: LlmRequest | None = None
|
||||
self.call_count = 0
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.last_request = req
|
||||
self.call_count += 1
|
||||
return LlmResponse(
|
||||
text=self._parsed.model_dump_json(),
|
||||
parsed=self._parsed,
|
||||
usage=_stub_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake-analyst"),
|
||||
)
|
||||
|
||||
|
||||
class FailingRunGateway:
|
||||
"""`run` 抛指定异常——验「某审失败不阻塞其余」+ 未完成占位归一。"""
|
||||
|
||||
def __init__(self, exc: Exception) -> None:
|
||||
self._exc = exc
|
||||
self.call_count = 0
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
self.call_count += 1
|
||||
raise self._exc
|
||||
|
||||
|
||||
class FakeReviewRepo:
|
||||
"""内存审稿留痕 repo(实现 `ReviewRepo` Protocol 的 `record`)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[dict[str, Any]] = []
|
||||
|
||||
async def record(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
*,
|
||||
chapter_version: int | None = None,
|
||||
conflicts: list[dict[str, Any]],
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
entry = {
|
||||
"id": uuid.uuid4(),
|
||||
"project_id": project_id,
|
||||
"chapter_no": chapter_no,
|
||||
"chapter_version": chapter_version,
|
||||
"conflicts": list(conflicts),
|
||||
"foreshadow_sug": list(foreshadow_sug or []),
|
||||
"style": style,
|
||||
"pace": pace,
|
||||
"health_score": health_score,
|
||||
}
|
||||
self.records.append(entry)
|
||||
return entry
|
||||
270
packages/core/tests/test_memory.py
Normal file
270
packages/core/tests/test_memory.py
Normal file
@@ -0,0 +1,270 @@
|
||||
"""T1.2 记忆服务单测——确定性选择 + 渲染 + 组装(ARCH §3.4/§5.3)。
|
||||
|
||||
全部用内存 fake repo,无 DB;断言确定性、稳定块排序、无时间戳/UUID。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ww_core.domain.repositories import (
|
||||
CharacterView,
|
||||
DigestView,
|
||||
ForeshadowView,
|
||||
MemoryRepos,
|
||||
OutlineView,
|
||||
RuleView,
|
||||
StyleView,
|
||||
WorldEntityView,
|
||||
)
|
||||
from ww_core.memory import (
|
||||
AssembledContext,
|
||||
EntityKind,
|
||||
SelectedEntity,
|
||||
SelectionTrace,
|
||||
assemble,
|
||||
render_cards,
|
||||
select_relevant_entities,
|
||||
)
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
|
||||
|
||||
# ---- 内存 fake repositories ----
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeOutlineRepo:
|
||||
rows: dict[int, OutlineView] = field(default_factory=dict)
|
||||
|
||||
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
|
||||
return self.rows.get(chapter_no)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeCharacterRepo:
|
||||
rows: list[CharacterView] = field(default_factory=list)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeWorldEntityRepo:
|
||||
rows: list[WorldEntityView] = field(default_factory=list)
|
||||
|
||||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDigestRepo:
|
||||
rows: list[DigestView] = field(default_factory=list)
|
||||
|
||||
async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]:
|
||||
return sorted(self.rows, key=lambda d: d.chapter_no, reverse=True)[:k]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeForeshadowRepo:
|
||||
rows: list[ForeshadowView] = field(default_factory=list)
|
||||
|
||||
async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]:
|
||||
wanted = set(codes)
|
||||
return [r for r in self.rows if r.code in wanted]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeStyleRepo:
|
||||
row: StyleView | None = None
|
||||
|
||||
async def latest(self, project_id: uuid.UUID) -> StyleView | None:
|
||||
return self.row
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeRulesRepo:
|
||||
rows: list[RuleView] = field(default_factory=list)
|
||||
|
||||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
def build_repos(
|
||||
*,
|
||||
outline: dict[int, OutlineView] | None = None,
|
||||
characters: list[CharacterView] | None = None,
|
||||
world: list[WorldEntityView] | None = None,
|
||||
digests: list[DigestView] | None = None,
|
||||
foreshadows: list[ForeshadowView] | None = None,
|
||||
style: StyleView | None = None,
|
||||
rules: list[RuleView] | None = None,
|
||||
) -> MemoryRepos:
|
||||
return MemoryRepos(
|
||||
outline=FakeOutlineRepo(outline or {}),
|
||||
character=FakeCharacterRepo(characters or []),
|
||||
world_entity=FakeWorldEntityRepo(world or []),
|
||||
digest=FakeDigestRepo(digests or []),
|
||||
foreshadow=FakeForeshadowRepo(foreshadows or []),
|
||||
style=FakeStyleRepo(style),
|
||||
rules=FakeRulesRepo(rules or []),
|
||||
)
|
||||
|
||||
|
||||
# ---- select_relevant_entities ----
|
||||
|
||||
|
||||
def char(name: str, role: str | None = None, latest_state: str | None = None) -> CharacterView:
|
||||
return CharacterView(name=name, role=role, latest_state=latest_state)
|
||||
|
||||
|
||||
def world(name: str, type_: str = "设定", latest_state: str | None = None) -> WorldEntityView:
|
||||
return WorldEntityView(type=type_, name=name, latest_state=latest_state)
|
||||
|
||||
|
||||
def test_selects_main_character_even_when_not_in_beats() -> None:
|
||||
outline = OutlineView(volume=1, chapter_no=1, beats={"summary": "无人提及"})
|
||||
trace = select_relevant_entities(
|
||||
outline=outline,
|
||||
characters=[char("林动", role="主角"), char("路人甲", role="配角")],
|
||||
world_entities=[],
|
||||
recent_digests=[],
|
||||
)
|
||||
names = {e.name for e in trace.selected}
|
||||
assert "林动" in names
|
||||
assert "路人甲" not in names
|
||||
main = next(e for e in trace.selected if e.name == "林动")
|
||||
assert "main_character" in main.reasons
|
||||
|
||||
|
||||
def test_selects_entity_named_in_beats() -> None:
|
||||
outline = OutlineView(volume=1, chapter_no=2, beats={"entities": ["青檀"], "text": "青檀出场"})
|
||||
trace = select_relevant_entities(
|
||||
outline=outline,
|
||||
characters=[char("青檀", role="配角")],
|
||||
world_entities=[],
|
||||
recent_digests=[],
|
||||
)
|
||||
assert any(e.name == "青檀" and "explicit_beat" in e.reasons for e in trace.selected)
|
||||
|
||||
|
||||
def test_selects_recent_digest_entity() -> None:
|
||||
outline = OutlineView(volume=1, chapter_no=10, beats={})
|
||||
digests = [DigestView(chapter_no=9, facts={"entities": ["玄机阁"]})]
|
||||
trace = select_relevant_entities(
|
||||
outline=outline,
|
||||
characters=[],
|
||||
world_entities=[world("玄机阁")],
|
||||
recent_digests=digests,
|
||||
)
|
||||
assert any(e.name == "玄机阁" and "recent_digest" in e.reasons for e in trace.selected)
|
||||
|
||||
|
||||
def test_union_dedups_reasons_sorted() -> None:
|
||||
outline = OutlineView(
|
||||
volume=1,
|
||||
chapter_no=3,
|
||||
beats={"entities": ["林动"]},
|
||||
foreshadow_windows=[{"code": "F1", "entities": ["林动"]}],
|
||||
)
|
||||
digests = [DigestView(chapter_no=2, facts={"entities": ["林动"]})]
|
||||
trace = select_relevant_entities(
|
||||
outline=outline,
|
||||
characters=[char("林动", role="主角")],
|
||||
world_entities=[],
|
||||
recent_digests=digests,
|
||||
)
|
||||
selected = [e for e in trace.selected if e.name == "林动"]
|
||||
assert len(selected) == 1 # 去重为一条
|
||||
# 四个理由全命中且按规范顺序排列
|
||||
assert selected[0].reasons == [
|
||||
"explicit_beat",
|
||||
"main_character",
|
||||
"recent_digest",
|
||||
"foreshadow_window",
|
||||
]
|
||||
|
||||
|
||||
# ---- render_cards ----
|
||||
|
||||
|
||||
def test_render_cards_deterministic_and_sorted() -> None:
|
||||
selection = SelectionTrace(
|
||||
selected=[
|
||||
_selected("character", "乙"),
|
||||
_selected("character", "甲"),
|
||||
]
|
||||
)
|
||||
chars = [char("乙", role="配角", latest_state="受伤"), char("甲", role="主角")]
|
||||
out1 = render_cards(selection, chars, [])
|
||||
out2 = render_cards(selection, chars, [])
|
||||
assert out1 == out2 # 确定性
|
||||
# 按名排序(Python 默认 codepoint 序:乙 U+4E59 < 甲 U+7532)
|
||||
assert out1.index("乙") < out1.index("甲")
|
||||
|
||||
|
||||
# ---- assemble ----
|
||||
|
||||
|
||||
async def test_assemble_deterministic_no_timestamps() -> None:
|
||||
repos = _full_repos()
|
||||
ctx1 = await assemble(repos, PROJECT, 5)
|
||||
ctx2 = await assemble(repos, PROJECT, 5)
|
||||
assert isinstance(ctx1, AssembledContext)
|
||||
assert ctx1.stable_core == ctx2.stable_core
|
||||
assert ctx1.volatile == ctx2.volatile
|
||||
# 无 UUID / 时间戳泄漏进缓存前缀
|
||||
assert str(PROJECT) not in ctx1.stable_core
|
||||
assert "T00:00" not in ctx1.stable_core
|
||||
|
||||
|
||||
async def test_latest_state_in_volatile_not_stable() -> None:
|
||||
repos = _full_repos()
|
||||
ctx = await assemble(repos, PROJECT, 5)
|
||||
# latest_state 是易变态——必须在断点之后,不污染缓存前缀
|
||||
assert "断左臂" in ctx.volatile
|
||||
assert "断左臂" not in ctx.stable_core
|
||||
|
||||
|
||||
async def test_rule_merge_precedence() -> None:
|
||||
rules = [
|
||||
RuleView(level="global", content="全局规则"),
|
||||
RuleView(level="project", content="作品规则"),
|
||||
RuleView(level="genre", content="题材规则"),
|
||||
RuleView(level="style", content="文风规则"),
|
||||
]
|
||||
repos = build_repos(rules=rules)
|
||||
ctx = await assemble(repos, PROJECT, 1)
|
||||
# 四级都出现,且 global→genre→style→project 顺序(越具体越靠后/越优先)
|
||||
g = ctx.stable_core.index("全局规则")
|
||||
ge = ctx.stable_core.index("题材规则")
|
||||
s = ctx.stable_core.index("文风规则")
|
||||
p = ctx.stable_core.index("作品规则")
|
||||
assert g < ge < s < p
|
||||
|
||||
|
||||
# ---- helpers ----
|
||||
|
||||
|
||||
def _selected(kind: EntityKind, name: str) -> SelectedEntity:
|
||||
return SelectedEntity(kind=kind, name=name, reasons=["main_character"])
|
||||
|
||||
|
||||
def _full_repos() -> MemoryRepos:
|
||||
return build_repos(
|
||||
outline={
|
||||
5: OutlineView(
|
||||
volume=1,
|
||||
chapter_no=5,
|
||||
beats={"entities": ["林动"], "text": "林动突破"},
|
||||
foreshadow_windows=[{"code": "F1", "entities": []}],
|
||||
)
|
||||
},
|
||||
characters=[char("林动", role="主角", latest_state="断左臂")],
|
||||
world=[world("元力", type_="力量体系")],
|
||||
digests=[DigestView(chapter_no=4, facts={"entities": ["元力"]})],
|
||||
foreshadows=[ForeshadowView(code="F1", title="神秘石符", status="OPEN", planted_at=1)],
|
||||
style=StyleView(dimensions={"句长": "短句为主"}),
|
||||
rules=[RuleView(level="global", content="全局规则")],
|
||||
)
|
||||
165
packages/core/tests/test_orchestrator.py
Normal file
165
packages/core/tests/test_orchestrator.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""T1.3 编排器单测——write 节点 + SSE 归一 + 图编译(ARCH §5.2/§7.3)。
|
||||
|
||||
全部注入 mock 网关,无真 LLM、无真 Postgres(图用 MemorySaver)。asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_core.orchestrator import (
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
EVENT_TOKEN,
|
||||
WRITE_NODE,
|
||||
ChapterState,
|
||||
build_write_graph,
|
||||
build_write_request,
|
||||
normalize_deltas,
|
||||
stream_chapter_draft,
|
||||
write_node,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
STABLE = "## 世界观硬规则\n灵气可凝丹"
|
||||
VOLATILE = "## 本章节拍\n主角破境"
|
||||
|
||||
|
||||
# ---- build_write_request:纯函数、缓存断点、tier 不变量 ----
|
||||
|
||||
|
||||
def test_build_write_request_puts_stable_core_in_cached_system_block() -> None:
|
||||
req = build_write_request(
|
||||
stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
assert req.tier == "writer" # 不变量②:只传档位,不传 model
|
||||
assert req.stream is True
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == STABLE
|
||||
assert req.system[0].cache is True # 不变量#9:稳定内核进缓存断点前
|
||||
assert req.input == VOLATILE
|
||||
assert req.scope.user_id == USER
|
||||
assert req.scope.project_id == PROJECT
|
||||
|
||||
|
||||
# ---- stream_chapter_draft:转发网关 Delta、构造正确请求 ----
|
||||
|
||||
|
||||
async def test_stream_chapter_draft_yields_deltas_and_builds_request() -> None:
|
||||
gateway = FakeStreamGateway(["灵", "气", "破", "境"])
|
||||
|
||||
texts = [
|
||||
d.text
|
||||
async for d in stream_chapter_draft(
|
||||
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
]
|
||||
|
||||
assert texts == ["灵", "气", "破", "境"]
|
||||
assert gateway.call_count == 1
|
||||
assert gateway.last_request is not None
|
||||
assert gateway.last_request.tier == "writer"
|
||||
assert gateway.last_request.system[0].cache is True
|
||||
|
||||
|
||||
# ---- write_node:累积草稿、不可变增量返回 ----
|
||||
|
||||
|
||||
async def test_write_node_accumulates_draft() -> None:
|
||||
gateway = FakeStreamGateway(["第一段。", "第二段。"])
|
||||
state: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 7,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
result = await write_node(state, gateway=gateway)
|
||||
|
||||
assert result == {"draft": "第一段。第二段。"}
|
||||
assert state["draft"] == "" # 不可变:未原地改入参
|
||||
|
||||
|
||||
# ---- SSE 归一:token / done ----
|
||||
|
||||
|
||||
async def test_normalize_deltas_emits_tokens_then_done() -> None:
|
||||
gateway = FakeStreamGateway(["a", "bc", ""]) # 空 Delta 不应产 token
|
||||
deltas = stream_chapter_draft(
|
||||
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
events = [e async for e in normalize_deltas(deltas)]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_TOKEN, EVENT_TOKEN, EVENT_DONE]
|
||||
assert events[0].data == {"text": "a"}
|
||||
assert events[1].data == {"text": "bc"}
|
||||
assert events[-1].data == {"length": 3} # "a"+"bc"
|
||||
|
||||
|
||||
# ---- SSE 归一:中途失败 → error 事件(不向上抛) ----
|
||||
|
||||
|
||||
async def test_normalize_deltas_emits_error_on_app_error() -> None:
|
||||
gateway = RaisingGateway(["半句"], AppError(ErrorCode.LLM_UNAVAILABLE, "fallbacks exhausted"))
|
||||
deltas = stream_chapter_draft(
|
||||
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
events = [e async for e in normalize_deltas(deltas, request_id="req-1")]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_TOKEN, EVENT_ERROR] # 无 done
|
||||
err = events[-1].data
|
||||
assert err["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
assert err["request_id"] == "req-1"
|
||||
|
||||
|
||||
async def test_normalize_deltas_maps_unexpected_error_to_internal() -> None:
|
||||
gateway = RaisingGateway([], RuntimeError("boom"))
|
||||
deltas = stream_chapter_draft(
|
||||
gateway, stable_core=STABLE, volatile=VOLATILE, user_id=USER, project_id=PROJECT
|
||||
)
|
||||
|
||||
events = [e async for e in normalize_deltas(deltas)]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_ERROR]
|
||||
assert events[0].data["code"] == ErrorCode.INTERNAL
|
||||
|
||||
|
||||
# ---- 图编译:装节点 + checkpointer(MemorySaver,不连真 Postgres) ----
|
||||
|
||||
|
||||
def test_build_write_graph_compiles_with_checkpointer() -> None:
|
||||
gateway = FakeStreamGateway(["x"])
|
||||
|
||||
graph = build_write_graph(gateway, checkpointer=MemorySaver())
|
||||
|
||||
assert WRITE_NODE in graph.get_graph().nodes
|
||||
assert graph.checkpointer is not None
|
||||
|
||||
|
||||
async def test_compiled_graph_runs_write_node_end_to_end() -> None:
|
||||
gateway = FakeStreamGateway(["云", "深"])
|
||||
graph = build_write_graph(gateway, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "t-1"}}
|
||||
initial: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 1,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
final = await graph.ainvoke(initial, config=config)
|
||||
|
||||
assert final["draft"] == "云深"
|
||||
Reference in New Issue
Block a user