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
|
||||
through core execution, persisted run inspection, and resume validation.
|
||||
2. Add a deterministic `examples/lda_report_workflow/` case study with local
|
||||
document, report, and issue-board sources.
|
||||
2. Completed: deterministic `examples/lda_report_workflow/` case study with
|
||||
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
|
||||
method registry.
|
||||
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