feat: add lda report workflow example
This commit is contained in:
@@ -39,8 +39,9 @@ Implementation order:
|
|||||||
|
|
||||||
1. Completed: self-describing interrupt request/resume schemas are carried
|
1. Completed: self-describing interrupt request/resume schemas are carried
|
||||||
through core execution, persisted run inspection, and resume validation.
|
through core execution, persisted run inspection, and resume validation.
|
||||||
2. Add a deterministic `examples/lda_report_workflow/` case study with local
|
2. Completed: deterministic `examples/lda_report_workflow/` case study with
|
||||||
document, report, and issue-board sources.
|
local document, report, issue-board sources, and typed issue-review
|
||||||
|
interrupt.
|
||||||
3. Add a top-level `web/` Astro/Effect app with loopback JSON-RPC connection and
|
3. Add a top-level `web/` Astro/Effect app with loopback JSON-RPC connection and
|
||||||
method registry.
|
method registry.
|
||||||
4. Add console read/inspect views for sources, drafts, artifacts, deployments,
|
4. Add console read/inspect views for sources, drafts, artifacts, deployments,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.wf_lda_report_store/
|
||||||
|
issue-board.json
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# lda.chat Report Workflow
|
||||||
|
|
||||||
|
This example is a deterministic case study for the Workflow Console and defense
|
||||||
|
demo. It uses local fixture documents, trusted Python sources, a typed
|
||||||
|
`issue_review` interrupt, and a local JSON-backed issue board.
|
||||||
|
|
||||||
|
It does not call Google Drive, email, GitHub, or an LLM.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- `local.lda_docs`: lists and reads deterministic project documents.
|
||||||
|
- `local.lda_report`: analyses documents, builds the readiness report, creates
|
||||||
|
proposed issue drafts, and finalises the report.
|
||||||
|
- `local.issue_board`: writes selected issues to a local JSON file.
|
||||||
|
|
||||||
|
## Workflow Definition
|
||||||
|
|
||||||
|
`workflow.plan.json` is generated from `build_workflow.py`. After changing the
|
||||||
|
graph, regenerate and review the raw plan:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run python examples/lda_report_workflow/build_workflow.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Product Path
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json config validate
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local cap list --source local.lda_report
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local artifact create-from-plan examples/lda_report_workflow/workflow.plan.json --artifact lda_report_case_study --version 1 --title "lda.chat Report Case Study" --outcome completed --outcome cancelled --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local deploy save lda_report_case_study.default --artifact lda_report_case_study --version 1 --binding local.lda_docs=local.lda_docs --binding local.lda_report=local.lda_report --binding local.issue_board=local.issue_board
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local run start lda_report_case_study.default --input-file examples/lda_report_workflow/run-input.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The run stops at an `issue_review` interrupt. Inspect it:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local run inspect <run_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Resume with selected issues:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run wf --config examples/lda_report_workflow/wf.config.json --local run resume <run_id> --payload '{"approved":true,"selected_issue_ids":["risk-1"],"comment":"Create selected issues."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cleanup
|
||||||
|
|
||||||
|
The example writes `.wf_lda_report_store/` and `issue-board.json`, both ignored
|
||||||
|
by git.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from wf_api.models import RawWorkflowPlan
|
||||||
|
from wf_authoring import WorkflowBuilder
|
||||||
|
from wf_core import Workflow
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
WORKFLOW_OUTPUT = [
|
||||||
|
{"path": "state.approved", "target": "approved"},
|
||||||
|
{"path": "state.final_markdown", "target": "markdown"},
|
||||||
|
{"path": "state.created_issues", "target": "created_issues"},
|
||||||
|
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_workflow() -> Workflow:
|
||||||
|
"""Build the demo workflow with the public authoring API.
|
||||||
|
|
||||||
|
`WorkflowBuilder` does not yet expose a workflow-output setter, so this
|
||||||
|
module adds the final output projection in `_with_workflow_output()` after
|
||||||
|
compiling the graph. Keep that seam small and validated.
|
||||||
|
"""
|
||||||
|
builder = WorkflowBuilder(
|
||||||
|
name="lda_report_case_study",
|
||||||
|
input_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"selected_documents": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"board_path": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["selected_documents", "board_path"],
|
||||||
|
},
|
||||||
|
state_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"documents": {"type": "array"},
|
||||||
|
"analysis": {"type": "array"},
|
||||||
|
"report": {"type": "object"},
|
||||||
|
"report_markdown": {"type": "string"},
|
||||||
|
"proposed_issues": {"type": "array"},
|
||||||
|
"selected_issue_ids": {"type": "array"},
|
||||||
|
"approval_comment": {"type": "string"},
|
||||||
|
"approved": {"type": "boolean"},
|
||||||
|
"created_issues": {"type": "array"},
|
||||||
|
"final_markdown": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"approved": {"type": "boolean"},
|
||||||
|
"markdown": {"type": "string"},
|
||||||
|
"created_issues": {"type": "array"},
|
||||||
|
"selected_issue_ids": {"type": "array"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outcomes=["completed", "cancelled"],
|
||||||
|
)
|
||||||
|
|
||||||
|
read_docs = builder.use_ref(
|
||||||
|
"local.lda_docs.read_documents",
|
||||||
|
id="read_docs",
|
||||||
|
input=[{"path": "input.selected_documents", "target": "names"}],
|
||||||
|
output=[{"source": "documents", "target": "state.documents"}],
|
||||||
|
)
|
||||||
|
analyze = builder.use_ref(
|
||||||
|
"local.lda_report.analyze_documents",
|
||||||
|
id="analyze",
|
||||||
|
input=[{"path": "state.documents", "target": "documents"}],
|
||||||
|
output=[{"source": "analysis", "target": "state.analysis"}],
|
||||||
|
)
|
||||||
|
build_report = builder.use_ref(
|
||||||
|
"local.lda_report.build_report",
|
||||||
|
id="build_report",
|
||||||
|
input=[{"path": "state.analysis", "target": "analysis"}],
|
||||||
|
output=[
|
||||||
|
{"source": "report", "target": "state.report"},
|
||||||
|
{"source": "markdown", "target": "state.report_markdown"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
draft_issues = builder.use_ref(
|
||||||
|
"local.lda_report.create_issue_drafts",
|
||||||
|
id="draft_issues",
|
||||||
|
input=[{"path": "state.report", "target": "report"}],
|
||||||
|
output=[{"source": "issues", "target": "state.proposed_issues"}],
|
||||||
|
)
|
||||||
|
review_issues = builder.interrupt(
|
||||||
|
id="review_issues",
|
||||||
|
kind="issue_review",
|
||||||
|
request=[
|
||||||
|
{"path": "state.report_markdown", "target": "report_markdown"},
|
||||||
|
{"path": "state.proposed_issues", "target": "proposed_issues"},
|
||||||
|
],
|
||||||
|
resume=[
|
||||||
|
{"source": "approved", "target": "state.approved"},
|
||||||
|
{"source": "selected_issue_ids", "target": "state.selected_issue_ids"},
|
||||||
|
{"source": "comment", "target": "state.approval_comment"},
|
||||||
|
],
|
||||||
|
outcomes=["submitted", "cancelled"],
|
||||||
|
request_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"report_markdown": {"type": "string"},
|
||||||
|
"proposed_issues": {"type": "array"},
|
||||||
|
},
|
||||||
|
"required": ["report_markdown", "proposed_issues"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
resume_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"approved": {"type": "boolean"},
|
||||||
|
"selected_issue_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
},
|
||||||
|
"comment": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["approved", "selected_issue_ids"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
create_issues = builder.use_ref(
|
||||||
|
"local.issue_board.create_issues",
|
||||||
|
id="create_issues",
|
||||||
|
input=[
|
||||||
|
{"path": "state.proposed_issues", "target": "issues"},
|
||||||
|
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
|
||||||
|
{"path": "input.board_path", "target": "board_path"},
|
||||||
|
],
|
||||||
|
output=[{"source": "created_issues", "target": "state.created_issues"}],
|
||||||
|
)
|
||||||
|
finalise = builder.use_ref(
|
||||||
|
"local.lda_report.finalise_report",
|
||||||
|
id="finalise",
|
||||||
|
input=[
|
||||||
|
{"path": "state.report", "target": "report"},
|
||||||
|
{"path": "state.created_issues", "target": "created_issues"},
|
||||||
|
{"path": "state.approved", "target": "approved"},
|
||||||
|
{"path": "state.selected_issue_ids", "target": "selected_issue_ids"},
|
||||||
|
{"path": "state.approval_comment", "target": "comment"},
|
||||||
|
],
|
||||||
|
output=[{"source": "markdown", "target": "state.final_markdown"}],
|
||||||
|
)
|
||||||
|
revision_requested = builder.use_ref(
|
||||||
|
"local.lda_report.record_revision_request",
|
||||||
|
id="revision_requested",
|
||||||
|
input=[{"path": "state.approval_comment", "target": "comment"}],
|
||||||
|
output=[
|
||||||
|
{"source": "approved", "target": "state.approved"},
|
||||||
|
{"source": "markdown", "target": "state.final_markdown"},
|
||||||
|
{"source": "created_issues", "target": "state.created_issues"},
|
||||||
|
{"source": "selected_issue_ids", "target": "state.selected_issue_ids"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
end_completed = builder.end("completed", id="end_completed")
|
||||||
|
end_cancelled = builder.end("cancelled", id="end_cancelled")
|
||||||
|
|
||||||
|
builder.set_entry_point(read_docs)
|
||||||
|
builder.connect(read_docs, "ok", analyze)
|
||||||
|
builder.connect(analyze, "ok", build_report)
|
||||||
|
builder.connect(build_report, "ok", draft_issues)
|
||||||
|
builder.connect(draft_issues, "ok", review_issues)
|
||||||
|
builder.branch(
|
||||||
|
review_issues,
|
||||||
|
{
|
||||||
|
"submitted": create_issues,
|
||||||
|
"cancelled": revision_requested,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
builder.connect(create_issues, "ok", finalise)
|
||||||
|
builder.connect(finalise, "ok", end_completed)
|
||||||
|
builder.connect(revision_requested, "ok", end_cancelled)
|
||||||
|
return _with_workflow_output(builder.compile())
|
||||||
|
|
||||||
|
|
||||||
|
def _with_workflow_output(workflow: Workflow) -> Workflow:
|
||||||
|
payload = workflow.model_dump(mode="json", by_alias=True)
|
||||||
|
payload["output"] = WORKFLOW_OUTPUT
|
||||||
|
return Workflow.model_validate(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def workflow_plan_payload() -> dict[str, Any]:
|
||||||
|
payload = build_workflow().model_dump(mode="json", by_alias=True)
|
||||||
|
payload.pop("node_defs", None)
|
||||||
|
RawWorkflowPlan.model_validate(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def write_plan(path: Path = HERE / "workflow.plan.json") -> None:
|
||||||
|
payload = workflow_plan_payload()
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
write_plan()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wf_authoring import node
|
||||||
|
|
||||||
|
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||||
|
_DOCUMENT_DIR = _EXAMPLE_DIR / "documents"
|
||||||
|
|
||||||
|
|
||||||
|
class ListDocumentsInput(BaseModel):
|
||||||
|
include_archived: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Reserved for future filtering; current fixture has no archived docs.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentRef(BaseModel):
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
|
||||||
|
|
||||||
|
class ListDocumentsOutput(BaseModel):
|
||||||
|
documents: list[DocumentRef]
|
||||||
|
|
||||||
|
|
||||||
|
class ReadDocumentsInput(BaseModel):
|
||||||
|
names: list[str] = Field(description="Document names returned by list_documents.")
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentText(BaseModel):
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ReadDocumentsOutput(BaseModel):
|
||||||
|
documents: list[DocumentText]
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="list_documents",
|
||||||
|
description="List deterministic lda.chat project documents available to the demo.",
|
||||||
|
)
|
||||||
|
def list_documents(payload: ListDocumentsInput) -> ListDocumentsOutput:
|
||||||
|
return _list_documents(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="read_documents",
|
||||||
|
description="Read selected lda.chat project documents by fixture name.",
|
||||||
|
)
|
||||||
|
def read_documents(payload: ReadDocumentsInput) -> ReadDocumentsOutput:
|
||||||
|
return _read_documents(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_documents(_payload: ListDocumentsInput) -> ListDocumentsOutput:
|
||||||
|
documents = [
|
||||||
|
DocumentRef(name=path.name, title=_title_for(path))
|
||||||
|
for path in sorted(_DOCUMENT_DIR.glob("*.md"))
|
||||||
|
]
|
||||||
|
return ListDocumentsOutput(documents=documents)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_documents(payload: ReadDocumentsInput) -> ReadDocumentsOutput:
|
||||||
|
allowed = {
|
||||||
|
document.name for document in _list_documents(ListDocumentsInput()).documents
|
||||||
|
}
|
||||||
|
selected: list[DocumentText] = []
|
||||||
|
for name in payload.names:
|
||||||
|
if name not in allowed:
|
||||||
|
raise ValueError(
|
||||||
|
f"unknown or unsafe document name: {name!r}; use a known document"
|
||||||
|
)
|
||||||
|
path = _DOCUMENT_DIR / name
|
||||||
|
selected.append(
|
||||||
|
DocumentText(
|
||||||
|
name=name, title=_title_for(path), text=path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return ReadDocumentsOutput(documents=selected)
|
||||||
|
|
||||||
|
|
||||||
|
def _title_for(path: Path) -> str:
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if line.startswith("# "):
|
||||||
|
return line.removeprefix("# ").strip()
|
||||||
|
return path.stem.replace("-", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
registry = [list_documents, read_documents]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Architecture Notes
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
|
||||||
|
- Drafts are mutable authoring workspaces with revisions.
|
||||||
|
- Artifacts are immutable versioned workflow definitions.
|
||||||
|
- Deployments bind logical source ids to configured concrete sources.
|
||||||
|
- Runs persist stopped execution records and bounded traces.
|
||||||
|
|
||||||
|
Runtime:
|
||||||
|
|
||||||
|
- The core executes typed graph nodes and routes by declared outcomes.
|
||||||
|
- State writes go through reducer-aware merge semantics.
|
||||||
|
- Interrupt nodes pause at explicit human-in-the-loop boundaries.
|
||||||
|
- Resume payloads are validated before state mutation.
|
||||||
|
|
||||||
|
Source providers:
|
||||||
|
|
||||||
|
- Python sources support trusted local demo capabilities.
|
||||||
|
- MCP sources preserve upstream session state through a runtime pool.
|
||||||
|
- OpenAPI source support exists as an experimental provider.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Evaluation Findings
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Automated tests cover core runtime, artifacts, deployments, CLI, JSON-RPC,
|
||||||
|
source providers, and examples.
|
||||||
|
- A 36-trial audited agent challenge campaign evaluated the product-facing CLI
|
||||||
|
under bounded conditions.
|
||||||
|
- Manual audit flags separate product-surface success from source-code or prior
|
||||||
|
answer reads.
|
||||||
|
|
||||||
|
Limitations:
|
||||||
|
|
||||||
|
- Agent challenge runs are operational evidence, not a controlled model study.
|
||||||
|
- The campaign used small sample sizes and changing prototype snapshots.
|
||||||
|
- The prototype does not claim production security, scheduling, RBAC, or a
|
||||||
|
general autonomous planning algorithm.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Project Brief
|
||||||
|
|
||||||
|
lda.chat is a workflow substrate for AI-agent-facing workspace automation. The
|
||||||
|
prototype separates external planning from deterministic workflow execution.
|
||||||
|
|
||||||
|
Key achievements:
|
||||||
|
|
||||||
|
- Typed Draft, Artifact, Deployment, Run, and Trace lifecycle records.
|
||||||
|
- Source-provider boundary for platform, MCP, Python, and experimental OpenAPI
|
||||||
|
sources.
|
||||||
|
- JSON-RPC and CLI surfaces usable by external agents and human operators.
|
||||||
|
- Deterministic report and browser-click examples with audited agent challenge
|
||||||
|
runs.
|
||||||
|
|
||||||
|
Current positioning:
|
||||||
|
|
||||||
|
- The system is not a bundled autonomous planner.
|
||||||
|
- External agents or humans operate the workflow lifecycle.
|
||||||
|
- The next product-facing step is a local Workflow Console and defense demo.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Risk Register
|
||||||
|
|
||||||
|
Material risks:
|
||||||
|
|
||||||
|
- Title and product framing can overstate the implemented autonomous-agent
|
||||||
|
layer if not explained carefully.
|
||||||
|
- Evaluation evidence is stronger as systems evidence than as a controlled
|
||||||
|
empirical model comparison.
|
||||||
|
- File-backed stores are useful for auditability but not a production
|
||||||
|
transaction boundary.
|
||||||
|
- The Workflow Console needs a strict loopback-only first slice to avoid
|
||||||
|
becoming an arbitrary RPC proxy.
|
||||||
|
|
||||||
|
Mitigations:
|
||||||
|
|
||||||
|
- Keep the agent/substrate boundary explicit in the thesis and defense.
|
||||||
|
- Present challenge data as bounded operational evidence.
|
||||||
|
- Defer production storage, auth, and remote proxying to future work.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
Near-term:
|
||||||
|
|
||||||
|
- Add self-describing interrupt request and resume contracts.
|
||||||
|
- Build a deterministic lda.chat report workflow with typed issue approval.
|
||||||
|
- Build a local Workflow Console over JSON-RPC.
|
||||||
|
- Add live-demo replay support for the defense.
|
||||||
|
|
||||||
|
Later:
|
||||||
|
|
||||||
|
- Stabilize the experimental OpenAPI provider.
|
||||||
|
- Add production secret stores and transactional persistence.
|
||||||
|
- Add a surrounding agent interface and planner loop.
|
||||||
|
- Explore scheduling, richer debugging, and visual workflow editing.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wf_authoring import node
|
||||||
|
|
||||||
|
from .report_source import CreatedIssue, ProposedIssue
|
||||||
|
|
||||||
|
_EXAMPLE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
class ResetIssueBoardInput(BaseModel):
|
||||||
|
board_path: str = Field(default="issue-board.json")
|
||||||
|
|
||||||
|
|
||||||
|
class ResetIssueBoardOutput(BaseModel):
|
||||||
|
reset: bool
|
||||||
|
board_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIssuesInput(BaseModel):
|
||||||
|
issues: list[ProposedIssue]
|
||||||
|
selected_issue_ids: list[str]
|
||||||
|
board_path: str = Field(default="issue-board.json")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIssuesOutput(BaseModel):
|
||||||
|
created_issues: list[CreatedIssue]
|
||||||
|
board_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@node(name="reset_issue_board", description="Reset the local demo issue board file.")
|
||||||
|
def reset_issue_board(payload: ResetIssueBoardInput) -> ResetIssueBoardOutput:
|
||||||
|
return _reset_issue_board(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="create_issues",
|
||||||
|
description="Create selected issues in the local demo issue board.",
|
||||||
|
)
|
||||||
|
def create_issues(payload: CreateIssuesInput) -> CreateIssuesOutput:
|
||||||
|
return _create_issues(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_issue_board(payload: ResetIssueBoardInput) -> ResetIssueBoardOutput:
|
||||||
|
path = _resolve_board_path(payload.board_path)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return ResetIssueBoardOutput(reset=True, board_path=str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_issues(payload: CreateIssuesInput) -> CreateIssuesOutput:
|
||||||
|
path = _resolve_board_path(payload.board_path)
|
||||||
|
selected = set(payload.selected_issue_ids)
|
||||||
|
existing = _read_board(path)
|
||||||
|
created: list[CreatedIssue] = []
|
||||||
|
next_number = len(existing) + 1
|
||||||
|
for issue in payload.issues:
|
||||||
|
if issue.id not in selected:
|
||||||
|
continue
|
||||||
|
issue_id = f"ISSUE-{next_number:03d}"
|
||||||
|
created_issue = CreatedIssue(
|
||||||
|
id=issue_id,
|
||||||
|
title=issue.title,
|
||||||
|
url=f"local://issue-board/{issue_id}",
|
||||||
|
)
|
||||||
|
existing.append(
|
||||||
|
{
|
||||||
|
"id": created_issue.id,
|
||||||
|
"title": created_issue.title,
|
||||||
|
"url": created_issue.url,
|
||||||
|
"body": issue.body,
|
||||||
|
"severity": issue.severity,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
created.append(created_issue)
|
||||||
|
next_number += 1
|
||||||
|
_write_board(path, existing)
|
||||||
|
return CreateIssuesOutput(created_issues=created, board_path=str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_board_path(path: str) -> Path:
|
||||||
|
candidate = Path(path)
|
||||||
|
if candidate.is_absolute():
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
else:
|
||||||
|
resolved = (_EXAMPLE_DIR / candidate).resolve()
|
||||||
|
# Keep the demo capability from deleting or writing arbitrary files while still
|
||||||
|
# allowing pytest tmp_path fixtures to exercise isolated board files.
|
||||||
|
allowed_roots = [_EXAMPLE_DIR.resolve(), Path(tempfile.gettempdir()).resolve()]
|
||||||
|
if not any(_is_relative_to(resolved, root) for root in allowed_roots):
|
||||||
|
raise ValueError("board_path must stay inside the example dir or temp dir")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _is_relative_to(path: Path, root: Path) -> bool:
|
||||||
|
return path == root or path.is_relative_to(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_board(path: Path) -> list[dict[str, str]]:
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
value = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ValueError("issue board file must contain a JSON list")
|
||||||
|
return [item for item in value if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_board(path: Path, value: list[dict[str, str]]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(value, indent=2, sort_keys=True), encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
registry = [reset_issue_board, create_issues]
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from wf_authoring import node
|
||||||
|
|
||||||
|
from .document_source import DocumentText
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeDocumentsInput(BaseModel):
|
||||||
|
documents: list[DocumentText]
|
||||||
|
|
||||||
|
|
||||||
|
class Finding(BaseModel):
|
||||||
|
source: str
|
||||||
|
summary: str
|
||||||
|
risks: list[str] = Field(default_factory=list)
|
||||||
|
actions: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyzeDocumentsOutput(BaseModel):
|
||||||
|
analysis: list[Finding]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportInput(BaseModel):
|
||||||
|
analysis: list[Finding]
|
||||||
|
|
||||||
|
|
||||||
|
class ReadinessReport(BaseModel):
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
achievements: list[str]
|
||||||
|
risks: list[str]
|
||||||
|
next_actions: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportOutput(BaseModel):
|
||||||
|
report: ReadinessReport
|
||||||
|
markdown: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIssueDraftsInput(BaseModel):
|
||||||
|
report: ReadinessReport
|
||||||
|
|
||||||
|
|
||||||
|
class ProposedIssue(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
severity: str = "medium"
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIssueDraftsOutput(BaseModel):
|
||||||
|
issues: list[ProposedIssue]
|
||||||
|
|
||||||
|
|
||||||
|
class CreatedIssue(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class FinaliseReportInput(BaseModel):
|
||||||
|
report: ReadinessReport
|
||||||
|
created_issues: list[CreatedIssue] = Field(default_factory=list)
|
||||||
|
approved: bool
|
||||||
|
selected_issue_ids: list[str] = Field(default_factory=list)
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FinalReportOutput(BaseModel):
|
||||||
|
approved: bool
|
||||||
|
markdown: str
|
||||||
|
created_issues: list[CreatedIssue]
|
||||||
|
selected_issue_ids: list[str]
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecordRevisionRequestInput(BaseModel):
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="analyze_documents",
|
||||||
|
description="Extract deterministic findings from lda.chat project documents.",
|
||||||
|
)
|
||||||
|
def analyze_documents(payload: AnalyzeDocumentsInput) -> AnalyzeDocumentsOutput:
|
||||||
|
return _analyze_documents(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="build_report",
|
||||||
|
description="Build a typed lda.chat readiness report from document findings.",
|
||||||
|
)
|
||||||
|
def build_report(payload: BuildReportInput) -> BuildReportOutput:
|
||||||
|
return _build_report(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="create_issue_drafts",
|
||||||
|
description="Create proposed local issue drafts from report risks and next actions.",
|
||||||
|
)
|
||||||
|
def create_issue_drafts(payload: CreateIssueDraftsInput) -> CreateIssueDraftsOutput:
|
||||||
|
return _create_issue_drafts(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="finalise_report",
|
||||||
|
description="Render the approved report and include created issue references.",
|
||||||
|
)
|
||||||
|
def finalise_report(payload: FinaliseReportInput) -> FinalReportOutput:
|
||||||
|
return _finalise_report(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@node(
|
||||||
|
name="record_revision_request",
|
||||||
|
description="Return a cancelled report result when the human asks for revision.",
|
||||||
|
)
|
||||||
|
def record_revision_request(payload: RecordRevisionRequestInput) -> FinalReportOutput:
|
||||||
|
return _record_revision_request(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_documents(payload: AnalyzeDocumentsInput) -> AnalyzeDocumentsOutput:
|
||||||
|
findings: list[Finding] = []
|
||||||
|
for document in payload.documents:
|
||||||
|
text = document.text.lower()
|
||||||
|
risks = _lines_after(document.text, "Material risks:")
|
||||||
|
actions = _lines_after(document.text, "Near-term:") or _lines_after(
|
||||||
|
document.text, "Mitigations:"
|
||||||
|
)
|
||||||
|
if "workflow substrate" in text:
|
||||||
|
summary = (
|
||||||
|
"lda.chat is positioned as a workflow substrate for external agents."
|
||||||
|
)
|
||||||
|
elif "evaluation" in text:
|
||||||
|
summary = "Evaluation evidence is bounded and should be presented as operational evidence."
|
||||||
|
elif "risk" in text:
|
||||||
|
summary = "The current risk register emphasizes framing, evaluation, and storage limits."
|
||||||
|
elif "roadmap" in text:
|
||||||
|
summary = "Near-term roadmap focuses on typed interrupts, deterministic demos, and a console."
|
||||||
|
else:
|
||||||
|
summary = document.text.splitlines()[0].lstrip("# ").strip()
|
||||||
|
findings.append(
|
||||||
|
Finding(
|
||||||
|
source=document.name,
|
||||||
|
summary=summary,
|
||||||
|
risks=risks[:3],
|
||||||
|
actions=actions[:3],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return AnalyzeDocumentsOutput(analysis=findings)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report(payload: BuildReportInput) -> BuildReportOutput:
|
||||||
|
achievements = [
|
||||||
|
finding.summary
|
||||||
|
for finding in payload.analysis
|
||||||
|
if "risk register" not in finding.summary
|
||||||
|
]
|
||||||
|
risks = _unique(item for finding in payload.analysis for item in finding.risks)
|
||||||
|
next_actions = _unique(
|
||||||
|
item for finding in payload.analysis for item in finding.actions
|
||||||
|
)
|
||||||
|
report = ReadinessReport(
|
||||||
|
title="lda.chat Thesis And Project Readiness Report",
|
||||||
|
summary=(
|
||||||
|
"lda.chat is a typed workflow substrate with lifecycle records, "
|
||||||
|
"source-provider boundaries, and agent-operable CLI/RPC surfaces."
|
||||||
|
),
|
||||||
|
achievements=achievements[:6],
|
||||||
|
risks=risks[:6],
|
||||||
|
next_actions=next_actions[:6],
|
||||||
|
)
|
||||||
|
return BuildReportOutput(report=report, markdown=_render_report(report))
|
||||||
|
|
||||||
|
|
||||||
|
def _create_issue_drafts(payload: CreateIssueDraftsInput) -> CreateIssueDraftsOutput:
|
||||||
|
issues: list[ProposedIssue] = []
|
||||||
|
for index, risk in enumerate(payload.report.risks[:4], start=1):
|
||||||
|
issue_id = f"risk-{index}"
|
||||||
|
issues.append(
|
||||||
|
ProposedIssue(
|
||||||
|
id=issue_id,
|
||||||
|
title=risk.rstrip("."),
|
||||||
|
body=f"Track mitigation for: {risk}",
|
||||||
|
severity="high" if "title" in risk.lower() else "medium",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not issues:
|
||||||
|
issues.append(
|
||||||
|
ProposedIssue(
|
||||||
|
id="follow-up-1",
|
||||||
|
title="Review thesis demo readiness",
|
||||||
|
body="Confirm the prepared workflow and replay are ready for defense.",
|
||||||
|
severity="medium",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CreateIssueDraftsOutput(issues=issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _finalise_report(payload: FinaliseReportInput) -> FinalReportOutput:
|
||||||
|
markdown = _render_report(payload.report)
|
||||||
|
if payload.created_issues:
|
||||||
|
markdown += "\n\nCreated issues:\n"
|
||||||
|
markdown += "\n".join(
|
||||||
|
f"- {issue.id}: {issue.title} ({issue.url})"
|
||||||
|
for issue in payload.created_issues
|
||||||
|
)
|
||||||
|
if payload.comment:
|
||||||
|
markdown += f"\n\nApproval comment: {payload.comment}"
|
||||||
|
return FinalReportOutput(
|
||||||
|
approved=payload.approved,
|
||||||
|
markdown=markdown,
|
||||||
|
created_issues=payload.created_issues,
|
||||||
|
selected_issue_ids=payload.selected_issue_ids,
|
||||||
|
comment=payload.comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_revision_request(payload: RecordRevisionRequestInput) -> FinalReportOutput:
|
||||||
|
comment = payload.comment or "Revision requested."
|
||||||
|
return FinalReportOutput(
|
||||||
|
approved=False,
|
||||||
|
markdown=f"# Revision Requested\n\n{comment}",
|
||||||
|
created_issues=[],
|
||||||
|
selected_issue_ids=[],
|
||||||
|
comment=comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_report(report: ReadinessReport) -> str:
|
||||||
|
lines = [f"# {report.title}", "", "Summary:", report.summary, ""]
|
||||||
|
lines.append("Achievements:")
|
||||||
|
lines.extend(f"- {item}" for item in report.achievements)
|
||||||
|
lines.extend(["", "Risks:"])
|
||||||
|
lines.extend(f"- {item}" for item in report.risks)
|
||||||
|
lines.extend(["", "Next actions:"])
|
||||||
|
lines.extend(f"- {item}" for item in report.next_actions)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_after(text: str, heading: str) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
active = False
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if line == heading:
|
||||||
|
active = True
|
||||||
|
continue
|
||||||
|
if active and line.endswith(":"):
|
||||||
|
break
|
||||||
|
if active and line.startswith("- "):
|
||||||
|
lines.append(line.removeprefix("- ").strip())
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(items: object) -> list[str]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
values: list[str] = []
|
||||||
|
for item in items: # type: ignore[union-attr]
|
||||||
|
if isinstance(item, str) and item and item not in seen:
|
||||||
|
seen.add(item)
|
||||||
|
values.append(item)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
registry = [
|
||||||
|
analyze_documents,
|
||||||
|
build_report,
|
||||||
|
create_issue_drafts,
|
||||||
|
finalise_report,
|
||||||
|
record_revision_request,
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"selected_documents": [
|
||||||
|
"project-brief.md",
|
||||||
|
"architecture-notes.md",
|
||||||
|
"evaluation-findings.md",
|
||||||
|
"risk-register.md",
|
||||||
|
"roadmap.md"
|
||||||
|
],
|
||||||
|
"board_path": "issue-board.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"store": {
|
||||||
|
"kind": "filesystem",
|
||||||
|
"root": ".wf_lda_report_store"
|
||||||
|
},
|
||||||
|
"transports": [
|
||||||
|
{
|
||||||
|
"kind": "rpc_http",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 8765,
|
||||||
|
"path": "/rpc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"id": "local.lda_docs",
|
||||||
|
"kind": "python",
|
||||||
|
"path": ".",
|
||||||
|
"module": "document_source",
|
||||||
|
"registry": "registry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "local.lda_report",
|
||||||
|
"kind": "python",
|
||||||
|
"path": ".",
|
||||||
|
"module": "report_source",
|
||||||
|
"registry": "registry"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "local.issue_board",
|
||||||
|
"kind": "python",
|
||||||
|
"path": ".",
|
||||||
|
"module": "issue_board_source",
|
||||||
|
"registry": "registry"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
{
|
||||||
|
"edges": [
|
||||||
|
{
|
||||||
|
"from": "read_docs",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "analyze"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "analyze",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "build_report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "build_report",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "draft_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "draft_issues",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "review_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "review_issues",
|
||||||
|
"outcome": "submitted",
|
||||||
|
"to": "create_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "review_issues",
|
||||||
|
"outcome": "cancelled",
|
||||||
|
"to": "revision_requested"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "create_issues",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "finalise"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "finalise",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "end_completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "revision_requested",
|
||||||
|
"outcome": "ok",
|
||||||
|
"to": "end_cancelled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"input_schema": {
|
||||||
|
"properties": {
|
||||||
|
"board_path": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selected_documents": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"selected_documents",
|
||||||
|
"board_path"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"name": "lda_report_case_study",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "read_docs",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "input.selected_documents",
|
||||||
|
"target": "names"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_docs.read_documents",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "documents",
|
||||||
|
"target": "state.documents"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "analyze",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.documents",
|
||||||
|
"target": "documents"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_report.analyze_documents",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "analysis",
|
||||||
|
"target": "state.analysis"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "build_report",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.analysis",
|
||||||
|
"target": "analysis"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_report.build_report",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "report",
|
||||||
|
"target": "state.report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "markdown",
|
||||||
|
"target": "state.report_markdown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "draft_issues",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.report",
|
||||||
|
"target": "report"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_report.create_issue_drafts",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "issues",
|
||||||
|
"target": "state.proposed_issues"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review_issues",
|
||||||
|
"kind": "issue_review",
|
||||||
|
"outcomes": [
|
||||||
|
"submitted",
|
||||||
|
"cancelled"
|
||||||
|
],
|
||||||
|
"request": [
|
||||||
|
{
|
||||||
|
"path": "state.report_markdown",
|
||||||
|
"target": "report_markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.proposed_issues",
|
||||||
|
"target": "proposed_issues"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request_schema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"proposed_issues": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"report_markdown": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"report_markdown",
|
||||||
|
"proposed_issues"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"resume": [
|
||||||
|
{
|
||||||
|
"source": "approved",
|
||||||
|
"target": "state.approved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "selected_issue_ids",
|
||||||
|
"target": "state.selected_issue_ids"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "comment",
|
||||||
|
"target": "state.approval_comment"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resume_schema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"approved": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selected_issue_ids": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"approved",
|
||||||
|
"selected_issue_ids"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"type": "interrupt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "create_issues",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.proposed_issues",
|
||||||
|
"target": "issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.selected_issue_ids",
|
||||||
|
"target": "selected_issue_ids"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "input.board_path",
|
||||||
|
"target": "board_path"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.issue_board.create_issues",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "created_issues",
|
||||||
|
"target": "state.created_issues"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "finalise",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.report",
|
||||||
|
"target": "report"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.created_issues",
|
||||||
|
"target": "created_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.approved",
|
||||||
|
"target": "approved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.selected_issue_ids",
|
||||||
|
"target": "selected_issue_ids"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.approval_comment",
|
||||||
|
"target": "comment"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_report.finalise_report",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "markdown",
|
||||||
|
"target": "state.final_markdown"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"desc": null,
|
||||||
|
"id": "revision_requested",
|
||||||
|
"input": [
|
||||||
|
{
|
||||||
|
"path": "state.approval_comment",
|
||||||
|
"target": "comment"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"node": "local.lda_report.record_revision_request",
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"source": "approved",
|
||||||
|
"target": "state.approved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "markdown",
|
||||||
|
"target": "state.final_markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "created_issues",
|
||||||
|
"target": "state.created_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "selected_issue_ids",
|
||||||
|
"target": "state.selected_issue_ids"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retry": null,
|
||||||
|
"timeout_seconds": null,
|
||||||
|
"type": "node"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_completed",
|
||||||
|
"outcome": "completed",
|
||||||
|
"type": "end"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "end_cancelled",
|
||||||
|
"outcome": "cancelled",
|
||||||
|
"type": "end"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outcomes": [
|
||||||
|
"completed",
|
||||||
|
"cancelled"
|
||||||
|
],
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"path": "state.approved",
|
||||||
|
"target": "approved"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.final_markdown",
|
||||||
|
"target": "markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.created_issues",
|
||||||
|
"target": "created_issues"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "state.selected_issue_ids",
|
||||||
|
"target": "selected_issue_ids"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"output_schema": {
|
||||||
|
"properties": {
|
||||||
|
"approved": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"created_issues": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"markdown": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selected_issue_ids": {
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"start": "read_docs",
|
||||||
|
"state_schema": {
|
||||||
|
"properties": {
|
||||||
|
"analysis": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"approval_comment": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"approved": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"created_issues": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"documents": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"final_markdown": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"proposed_issues": {
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"report_markdown": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selected_issue_ids": {
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from examples.lda_report_workflow.build_workflow import (
|
||||||
|
build_workflow,
|
||||||
|
workflow_plan_payload,
|
||||||
|
)
|
||||||
|
from examples.lda_report_workflow.document_source import (
|
||||||
|
ListDocumentsInput,
|
||||||
|
ReadDocumentsInput,
|
||||||
|
_list_documents,
|
||||||
|
_read_documents,
|
||||||
|
)
|
||||||
|
from examples.lda_report_workflow.issue_board_source import (
|
||||||
|
CreateIssuesInput,
|
||||||
|
ResetIssueBoardInput,
|
||||||
|
_create_issues,
|
||||||
|
_reset_issue_board,
|
||||||
|
)
|
||||||
|
from examples.lda_report_workflow.report_source import (
|
||||||
|
AnalyzeDocumentsInput,
|
||||||
|
BuildReportInput,
|
||||||
|
CreateIssueDraftsInput,
|
||||||
|
FinaliseReportInput,
|
||||||
|
ReadinessReport,
|
||||||
|
RecordRevisionRequestInput,
|
||||||
|
_analyze_documents,
|
||||||
|
_build_report,
|
||||||
|
_create_issue_drafts,
|
||||||
|
_finalise_report,
|
||||||
|
_record_revision_request,
|
||||||
|
)
|
||||||
|
from wf_api.models import RawWorkflowPlan
|
||||||
|
from wf_config import load_workflow_config
|
||||||
|
from wf_server.config import build_workflow_server_from_workflow_config
|
||||||
|
|
||||||
|
EXAMPLE_DIR = Path(__file__).resolve().parents[2] / "examples" / "lda_report_workflow"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_docs_lists_known_documents() -> None:
|
||||||
|
result = _list_documents(ListDocumentsInput())
|
||||||
|
|
||||||
|
names = {document.name for document in result.documents}
|
||||||
|
|
||||||
|
assert "project-brief.md" in names
|
||||||
|
assert "architecture-notes.md" in names
|
||||||
|
assert len(result.documents) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_docs_reads_selected_documents() -> None:
|
||||||
|
result = _read_documents(
|
||||||
|
ReadDocumentsInput(names=["project-brief.md", "roadmap.md"])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [document.name for document in result.documents] == [
|
||||||
|
"project-brief.md",
|
||||||
|
"roadmap.md",
|
||||||
|
]
|
||||||
|
assert "workflow substrate" in result.documents[0].text
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_docs_rejects_path_traversal() -> None:
|
||||||
|
with pytest.raises(ValueError, match="known document"):
|
||||||
|
_read_documents(ReadDocumentsInput(names=["../README.md"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_report_source_builds_report_and_issue_drafts() -> None:
|
||||||
|
docs = _read_documents(
|
||||||
|
ReadDocumentsInput(names=["project-brief.md", "risk-register.md", "roadmap.md"])
|
||||||
|
)
|
||||||
|
|
||||||
|
analysis = _analyze_documents(AnalyzeDocumentsInput(documents=docs.documents))
|
||||||
|
report = _build_report(BuildReportInput(analysis=analysis.analysis))
|
||||||
|
issue_drafts = _create_issue_drafts(CreateIssueDraftsInput(report=report.report))
|
||||||
|
|
||||||
|
assert report.report.title == "lda.chat Thesis And Project Readiness Report"
|
||||||
|
assert "workflow substrate" in report.report.summary
|
||||||
|
assert issue_drafts.issues
|
||||||
|
assert issue_drafts.issues[0].id
|
||||||
|
assert issue_drafts.issues[0].title
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_report_source_finalises_approved_report() -> None:
|
||||||
|
docs = _read_documents(ReadDocumentsInput(names=["project-brief.md", "roadmap.md"]))
|
||||||
|
analysis = _analyze_documents(AnalyzeDocumentsInput(documents=docs.documents))
|
||||||
|
report = _build_report(BuildReportInput(analysis=analysis.analysis))
|
||||||
|
issue_drafts = _create_issue_drafts(CreateIssueDraftsInput(report=report.report))
|
||||||
|
|
||||||
|
final = _finalise_report(
|
||||||
|
FinaliseReportInput(
|
||||||
|
report=report.report,
|
||||||
|
created_issues=[],
|
||||||
|
approved=True,
|
||||||
|
selected_issue_ids=[issue_drafts.issues[0].id],
|
||||||
|
comment="Looks good.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert final.approved is True
|
||||||
|
assert final.markdown.startswith("# lda.chat Thesis And Project Readiness Report")
|
||||||
|
assert "Looks good." in final.markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_report_source_records_revision_request() -> None:
|
||||||
|
result = _record_revision_request(
|
||||||
|
RecordRevisionRequestInput(comment="Needs revision")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.approved is False
|
||||||
|
assert "Needs revision" in result.markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_board_creates_selected_issues(tmp_path: Path) -> None:
|
||||||
|
board_path = tmp_path / "issue-board.json"
|
||||||
|
drafts = _create_issue_drafts(
|
||||||
|
CreateIssueDraftsInput(
|
||||||
|
report=ReadinessReport(
|
||||||
|
title="Test",
|
||||||
|
summary="Summary",
|
||||||
|
achievements=[],
|
||||||
|
risks=["Risk one", "Risk two"],
|
||||||
|
next_actions=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = _create_issues(
|
||||||
|
CreateIssuesInput(
|
||||||
|
board_path=str(board_path),
|
||||||
|
issues=drafts.issues,
|
||||||
|
selected_issue_ids=[drafts.issues[0].id],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result.created_issues) == 1
|
||||||
|
assert result.created_issues[0].title == drafts.issues[0].title
|
||||||
|
assert board_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_board_reset_removes_existing_file(tmp_path: Path) -> None:
|
||||||
|
board_path = tmp_path / "issue-board.json"
|
||||||
|
board_path.write_text("[]", encoding="utf-8")
|
||||||
|
|
||||||
|
result = _reset_issue_board(ResetIssueBoardInput(board_path=str(board_path)))
|
||||||
|
|
||||||
|
assert result.reset is True
|
||||||
|
assert not board_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_board_rejects_paths_outside_example_or_temp() -> None:
|
||||||
|
unsafe_path = Path.home() / "lda-chat-unsafe-issue-board.json"
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="board_path must stay"):
|
||||||
|
_reset_issue_board(ResetIssueBoardInput(board_path=str(unsafe_path)))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lda_report_workflow_config_loads_sources(tmp_path: Path) -> None:
|
||||||
|
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
|
||||||
|
config.server.store.root = tmp_path / "store"
|
||||||
|
server = build_workflow_server_from_workflow_config(config)
|
||||||
|
|
||||||
|
listed = await server.api.list_capabilities(source_id="local.lda_report")
|
||||||
|
names = {capability["name"] for capability in listed["capabilities"]}
|
||||||
|
|
||||||
|
assert "local.lda_report.build_report" in names
|
||||||
|
assert "local.lda_report.finalise_report" in names
|
||||||
|
|
||||||
|
|
||||||
|
def test_lda_report_workflow_builder_generates_committed_raw_plan() -> None:
|
||||||
|
workflow = build_workflow()
|
||||||
|
payload = workflow_plan_payload()
|
||||||
|
committed = json.loads(
|
||||||
|
(EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
validated = RawWorkflowPlan.model_validate(payload)
|
||||||
|
|
||||||
|
assert workflow.name == "lda_report_case_study"
|
||||||
|
assert validated.name == "lda_report_case_study"
|
||||||
|
assert any(node.id == "review_issues" for node in validated.nodes)
|
||||||
|
assert payload == committed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lda_report_workflow_artifact_interrupt_resume_path(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
|
||||||
|
config.server.store.root = tmp_path / "store"
|
||||||
|
server = build_workflow_server_from_workflow_config(config)
|
||||||
|
plan = json.loads((EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="lda_report_case_study",
|
||||||
|
version=1,
|
||||||
|
title="lda.chat Report Case Study",
|
||||||
|
plan=plan,
|
||||||
|
outcomes=["completed", "cancelled"],
|
||||||
|
source_bindings={
|
||||||
|
"local.lda_docs": "local.lda_docs",
|
||||||
|
"local.lda_report": "local.lda_report",
|
||||||
|
"local.issue_board": "local.issue_board",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await server.api.save_deployment(
|
||||||
|
{
|
||||||
|
"id": "lda_report_case_study.default",
|
||||||
|
"artifact_id": "lda_report_case_study",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"bindings": {
|
||||||
|
"local.lda_docs": "local.lda_docs",
|
||||||
|
"local.lda_report": "local.lda_report",
|
||||||
|
"local.issue_board": "local.issue_board",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
run_input = json.loads((EXAMPLE_DIR / "run-input.json").read_text(encoding="utf-8"))
|
||||||
|
run_input["board_path"] = str(tmp_path / "issue-board.json")
|
||||||
|
started = await server.api.run_deployment(
|
||||||
|
deployment_id="lda_report_case_study.default",
|
||||||
|
workflow_input=run_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert started["status"] == "interrupted"
|
||||||
|
assert started["interrupt"]["kind"] == "issue_review"
|
||||||
|
assert started["interrupt"]["typed"] is True
|
||||||
|
assert started["interrupt"]["request_schema"]["required"] == [
|
||||||
|
"report_markdown",
|
||||||
|
"proposed_issues",
|
||||||
|
]
|
||||||
|
assert started["interrupt"]["resume_schema"]["required"] == [
|
||||||
|
"approved",
|
||||||
|
"selected_issue_ids",
|
||||||
|
]
|
||||||
|
proposed_ids = [
|
||||||
|
issue["id"] for issue in started["interrupt"]["payload"]["proposed_issues"]
|
||||||
|
]
|
||||||
|
assert proposed_ids
|
||||||
|
|
||||||
|
resumed = await server.api.resume_run(
|
||||||
|
run_id=started["run_id"],
|
||||||
|
resume_payload={
|
||||||
|
"approved": True,
|
||||||
|
"selected_issue_ids": proposed_ids[:2],
|
||||||
|
"comment": "Create selected issues before the defense.",
|
||||||
|
},
|
||||||
|
resume_outcome="submitted",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resumed["status"] == "completed"
|
||||||
|
assert resumed["outcome"] == "completed"
|
||||||
|
assert resumed["output"]["approved"] is True
|
||||||
|
assert resumed["output"]["created_issues"]
|
||||||
|
assert resumed["output"]["markdown"].startswith(
|
||||||
|
"# lda.chat Thesis And Project Readiness Report"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lda_report_workflow_cancelled_resume_path(tmp_path: Path) -> None:
|
||||||
|
config = load_workflow_config(EXAMPLE_DIR / "wf.config.json")
|
||||||
|
config.server.store.root = tmp_path / "store"
|
||||||
|
server = build_workflow_server_from_workflow_config(config)
|
||||||
|
plan = json.loads((EXAMPLE_DIR / "workflow.plan.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
await server.api.create_artifact_from_plan(
|
||||||
|
artifact_id="lda_report_cancel_case",
|
||||||
|
version=1,
|
||||||
|
title="lda.chat Report Cancel Case",
|
||||||
|
plan=plan,
|
||||||
|
outcomes=["completed", "cancelled"],
|
||||||
|
source_bindings={
|
||||||
|
"local.lda_docs": "local.lda_docs",
|
||||||
|
"local.lda_report": "local.lda_report",
|
||||||
|
"local.issue_board": "local.issue_board",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await server.api.save_deployment(
|
||||||
|
{
|
||||||
|
"id": "lda_report_cancel_case.default",
|
||||||
|
"artifact_id": "lda_report_cancel_case",
|
||||||
|
"artifact_version": 1,
|
||||||
|
"bindings": {
|
||||||
|
"local.lda_docs": "local.lda_docs",
|
||||||
|
"local.lda_report": "local.lda_report",
|
||||||
|
"local.issue_board": "local.issue_board",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
run_input = json.loads((EXAMPLE_DIR / "run-input.json").read_text(encoding="utf-8"))
|
||||||
|
run_input["board_path"] = str(tmp_path / "issue-board.json")
|
||||||
|
started = await server.api.run_deployment(
|
||||||
|
deployment_id="lda_report_cancel_case.default",
|
||||||
|
workflow_input=run_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
resumed = await server.api.resume_run(
|
||||||
|
run_id=started["run_id"],
|
||||||
|
resume_payload={
|
||||||
|
"approved": False,
|
||||||
|
"selected_issue_ids": [],
|
||||||
|
"comment": "Revise risk wording.",
|
||||||
|
},
|
||||||
|
resume_outcome="cancelled",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resumed["status"] == "completed"
|
||||||
|
assert resumed["outcome"] == "cancelled"
|
||||||
|
assert resumed["output"]["approved"] is False
|
||||||
|
assert "Revision Requested" in resumed["output"]["markdown"]
|
||||||
Reference in New Issue
Block a user