Files
lda-wf/tests/authoring/test_demo_workflow.py
T

447 lines
12 KiB
Python

from __future__ import annotations
import pytest
from pydantic import BaseModel
from examples.demo_workflow import build_demo_registry, build_demo_workflow
from wf_authoring import (
NodeReturn,
WorkflowBuilder,
build_registry,
context_path,
input_from,
input_path,
node,
output_to,
state,
state_path,
)
from wf_core import (
END,
FrameStatus,
RunStatus,
RuntimeContext,
WorkflowExecutionError,
execute_workflow,
resume_workflow,
step_workflow,
)
from wf_core.runtime.ops.runs import create_run_state
class DriveListFilesInput(BaseModel):
folder_id: str
class DriveListFilesOutput(BaseModel):
documents: list[str]
class SummarizeDocumentInput(BaseModel):
document: str
class SummarizeDocumentOutput(BaseModel):
item_summary: str
class CombineSummariesInput(BaseModel):
item_summaries: list[str]
class CombineSummariesOutput(BaseModel):
summary: str
class SendEmailInput(BaseModel):
summary: str
class SendEmailOutput(BaseModel):
email_status: str
class MarkEmailSkippedInput(BaseModel):
pass
class MarkEmailSkippedOutput(BaseModel):
email_status: str
@node(
name="drive_list_files",
input_model=DriveListFilesInput,
output_model=DriveListFilesOutput,
)
def drive_list_files_spec(
payload: DriveListFilesInput,
ctx: RuntimeContext,
) -> DriveListFilesOutput:
return DriveListFilesOutput(
documents=[
f"{payload.folder_id}/meeting-notes.md",
f"{payload.folder_id}/weekly-report.md",
]
)
@node(
name="summarize_document",
input_model=SummarizeDocumentInput,
output_model=SummarizeDocumentOutput,
)
def summarize_document_spec(
payload: SummarizeDocumentInput,
ctx: RuntimeContext,
) -> SummarizeDocumentOutput:
return SummarizeDocumentOutput(item_summary=f"Summary of {payload.document}")
@node(
name="combine_summaries",
input_model=CombineSummariesInput,
output_model=CombineSummariesOutput,
)
def combine_summaries_spec(
payload: CombineSummariesInput,
ctx: RuntimeContext,
) -> CombineSummariesOutput:
return CombineSummariesOutput(summary=" | ".join(payload.item_summaries))
@node(
name="send_email",
input_model=SendEmailInput,
output_model=SendEmailOutput,
outcomes=("sent",),
)
def send_email_spec(
payload: SendEmailInput,
ctx: RuntimeContext,
) -> NodeReturn[SendEmailOutput]:
return NodeReturn(
outcome="sent",
output=SendEmailOutput(email_status=f"sent: {payload.summary}"),
)
@node(
name="mark_email_skipped",
input_model=MarkEmailSkippedInput,
output_model=MarkEmailSkippedOutput,
)
def mark_email_skipped_spec(
payload: MarkEmailSkippedInput,
ctx: RuntimeContext,
) -> MarkEmailSkippedOutput:
return MarkEmailSkippedOutput(email_status="skipped")
def build_authoring_demo_workflow():
declared = build_demo_workflow()
builder = WorkflowBuilder(
name=declared.name,
input_schema=declared.input_schema,
state_schema=declared.state_schema,
output_schema=declared.output_schema,
start="list_files",
)
list_files = builder.use(
drive_list_files_spec,
id="list_files",
input=[input_from(input_path("folder_id"), "folder_id")],
output=[output_to("documents", state_path("documents"))],
desc="List files from a Google Drive folder",
)
summarize_each = builder.foreach(
id="summarize_each",
over=state_path("documents"),
as_="document",
mode="serial",
on_item_error="fail",
)
summarize_one = builder.use(
summarize_document_spec,
id="summarize_one",
input=[input_from(context_path("document"), "document")],
output=[output_to("item_summary", state_path("item_summaries"))],
desc="Summarize one document",
)
combine_summaries = builder.use(
combine_summaries_spec,
id="combine_summaries",
input=[input_from(state_path("item_summaries"), "item_summaries")],
output=[output_to("summary", state_path("summary"))],
desc="Combine item summaries into one final summary",
)
should_email = builder.condition(
id="should_email",
check=state("should_email").eq(True),
)
send_email = builder.use(
send_email_spec,
id="send_email",
input=[input_from(state_path("summary"), "summary")],
output=[output_to("email_status", state_path("email_status"))],
desc="Send the summary by email",
)
approve_email = builder.interrupt(
id="approve_email",
kind="approval",
request=[
input_from(state_path("summary"), "summary"),
input_from(input_path("folder_id"), "folder_id"),
],
resume=[
output_to("approved", state_path("approved")),
output_to("comment", state_path("approval_comment")),
],
outcomes=["submitted", "cancelled"],
)
skip_email = builder.use(
mark_email_skipped_spec,
id="skip_email",
output=[output_to("email_status", state_path("email_status"))],
desc="Record that email delivery was skipped",
)
builder.connect(list_files, "ok", summarize_each)
builder.connect(summarize_each, "loop", summarize_one)
builder.connect(summarize_each, "done", combine_summaries)
builder.connect(summarize_one, "ok", summarize_each)
builder.connect(combine_summaries, "ok", should_email)
builder.connect(should_email, "true", approve_email)
builder.connect(should_email, "false", skip_email)
builder.connect(approve_email, "submitted", send_email)
builder.connect(approve_email, "cancelled", skip_email)
builder.connect(send_email, "sent", END)
builder.connect(skip_email, "ok", END)
registry = build_registry(
drive_list_files_spec,
summarize_document_spec,
combine_summaries_spec,
send_email_spec,
mark_email_skipped_spec,
)
return builder.compile(), registry
def _strip_schema_titles(value: object) -> object:
if isinstance(value, dict):
normalized = {
key: _strip_schema_titles(inner)
for key, inner in value.items()
if key not in {"title", "items"}
}
return normalized
if isinstance(value, list):
return [_strip_schema_titles(item) for item in value]
return value
def test_interrupt_then_resume_to_send_email() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
interrupted_run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": True},
registry,
)
assert interrupted_run.status == RunStatus.INTERRUPTED
assert interrupted_run.current_node_id == "approve_email"
assert interrupted_run.interrupt is not None
assert interrupted_run.interrupt.kind == "approval"
assert interrupted_run.state["summary"].startswith("Summary of demo-folder/")
resumed_run = resume_workflow(
workflow,
interrupted_run,
registry,
resume_payload={"approved": True, "comment": "Looks good to send."},
resume_outcome="submitted",
)
assert resumed_run.status == RunStatus.COMPLETED
assert resumed_run.current_node_id == END
assert resumed_run.output["email_status"].startswith("sent:")
assert resumed_run.state["approved"] is True
assert resumed_run.state["approval_comment"] == "Looks good to send."
def test_non_interrupt_path_skips_email() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
run = execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": False},
registry,
)
assert run.status == RunStatus.COMPLETED
assert run.output["email_status"] == "skipped"
assert run.interrupt is None
def test_workflow_input_schema_rejects_wrong_type() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
with pytest.raises(
WorkflowExecutionError,
match=r"workflow input\['should_email'\].*not of type 'boolean'",
):
execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": "false"},
registry,
)
def test_node_output_schema_rejects_wrong_type() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
def bad_list_files(
payload: dict[str, object], ctx: RuntimeContext
) -> dict[str, object]:
return {"outcome": "ok", "output": {"documents": "not-a-list"}}
registry["drive_list_files"] = bad_list_files
with pytest.raises(
WorkflowExecutionError,
match=r"node output for list_files\['documents'\].*not of type 'array'",
):
execute_workflow(
workflow,
{"folder_id": "demo-folder", "should_email": False},
registry,
)
def test_stepwise_execution_reaches_interrupt() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
run = create_run_state(
workflow,
{"folder_id": "demo-folder", "should_email": True},
)
workflow.validate_structure().raise_for_errors()
while run.status not in {RunStatus.INTERRUPTED, RunStatus.COMPLETED}:
step_workflow(workflow, run, registry)
assert run.status == RunStatus.INTERRUPTED
assert run.current_node_id == "approve_email"
assert any(entry.step_type == "foreach" for entry in run.trace)
assert len(run.trace) == 9
def test_foreach_stress_with_many_documents() -> None:
workflow = build_demo_workflow()
registry = build_demo_registry()
document_count = 25
def many_files(payload: dict[str, object], ctx: object) -> dict[str, object]:
folder_id = payload["folder_id"]
return {
"outcome": "ok",
"output": {
"documents": [
f"{folder_id}/doc-{index:02d}.md" for index in range(document_count)
]
},
}
registry["drive_list_files"] = many_files
run = execute_workflow(
workflow,
{"folder_id": "bulk-folder", "should_email": False},
registry,
)
assert run.status == RunStatus.COMPLETED
assert len(run.state["documents"]) == document_count
assert len(run.state["item_summaries"]) == document_count
assert (
len(
[
frame
for frame in run.frames.values()
if frame.kind == "foreach_iteration"
and frame.status == FrameStatus.COMPLETED
]
)
== document_count
)
assert len([entry for entry in run.trace if entry.step_type == "foreach"]) == (
document_count + 1
)
def test_builder_compiles_same_workflow_as_declared_demo() -> None:
declared = build_demo_workflow()
built, _registry = build_authoring_demo_workflow()
assert _strip_schema_titles(
built.model_dump(by_alias=True)
) == _strip_schema_titles(declared.model_dump(by_alias=True))
def test_builder_compiled_workflow_executes_like_declared_demo() -> None:
declared = build_demo_workflow()
declared_registry = build_demo_registry()
built, built_registry = build_authoring_demo_workflow()
declared_run = execute_workflow(
declared,
{"folder_id": "demo-folder", "should_email": False},
declared_registry,
)
built_run = execute_workflow(
built,
{"folder_id": "demo-folder", "should_email": False},
built_registry,
)
assert built_run.status == declared_run.status
assert built_run.output == declared_run.output
assert built_run.state == declared_run.state
assert built_run.current_node_id == declared_run.current_node_id
def test_async_node_spec_cannot_export_sync_registry_handler() -> None:
class AsyncInput(BaseModel):
value: str
class AsyncOutput(BaseModel):
echoed: str
@node(
name="async_echo",
input_model=AsyncInput,
output_model=AsyncOutput,
is_async=True,
)
async def async_echo(
payload: AsyncInput,
ctx: RuntimeContext,
) -> AsyncOutput:
return AsyncOutput(echoed=payload.value)
try:
async_echo.to_registry_handler()
except TypeError as exc:
assert "async" in str(exc)
else:
raise AssertionError("expected async node export to fail for sync registry")