Overview
A professional-services firm generated ~200 client-facing contract amendments a month through a copy-paste-from-last-time process. They wanted LLM drafting, but with an absolute constraint: no document reaches a client without named-human approval, and legal wanted the approval step auditable. This is a workflow-orchestration problem wearing an AI costume — the drafting is the easy 20%.
LangGraph's interrupt/resume model fit exactly. The graph drafts an amendment, runs an automated compliance-check node, then hits an interrupt() and parks. The full state checkpoints to Postgres; a reviewer gets a task in their queue and might not act for three days. When they approve, edit, or reject, the graph resumes from the exact node it paused at — no state reconstruction, no duplicate drafts. Rejections loop back to redrafting with the reviewer's comments injected into context.
Architecture
- Five-node graph: extract_terms -> draft_amendment -> compliance_check -> human_approval (interrupt) -> finalize_and_send.
- interrupt() at the approval node surfaces a payload (draft, diff vs. template, compliance flags) to a React review UI; the graph thread parks indefinitely.
- PostgresSaver checkpoints every node transition; a resumed run days later carries full message history and intermediate state.
- Reviewer actions map to Command(resume=...) values: approve proceeds, edit proceeds with the human's text replacing the draft, reject loops to draft_amendment with comments appended.
- compliance_check is deterministic code plus one LLM pass — regex-verified clause presence first, then a model check for tone and prohibited commitments.
- Every transition is written to an audit table (who, what, when, which checkpoint), which is what actually got legal to sign off on the system.
Code Snippets
def human_approval(state: AmendmentState) -> Command:
decision = interrupt({
"draft": state["draft"],
"diff_vs_template": state["template_diff"],
"compliance_flags": state["compliance_flags"],
"client": state["client_name"],
})
# Execution parks here; resumes when a reviewer acts, possibly days later.
if decision["action"] == "approve":
return Command(goto="finalize_and_send",
update={"approved_by": decision["reviewer_id"]})
if decision["action"] == "edit":
return Command(goto="finalize_and_send",
update={"draft": decision["edited_text"],
"approved_by": decision["reviewer_id"]})
return Command(goto="draft_amendment",
update={"revision_notes": decision["comments"],
"revision_count": state["revision_count"] + 1})
graph.add_node("human_approval", human_approval)
app = graph.compile(checkpointer=PostgresSaver(pool))@router.post("/amendments/{thread_id}/decision")
async def submit_decision(thread_id: str, decision: ReviewerDecision):
config = {"configurable": {"thread_id": thread_id}}
snapshot = await app.aget_state(config)
if not snapshot.next or "human_approval" not in snapshot.next:
raise HTTPException(409, "Thread is not awaiting approval.")
result = await app.ainvoke(
Command(resume=decision.model_dump()),
config=config,
)
await audit_log.record(
thread_id=thread_id,
reviewer=decision.reviewer_id,
action=decision.action,
checkpoint_id=snapshot.config["configurable"]["checkpoint_id"],
)
return {"status": result["status"]}Lessons Learned
- Human-in-the-loop is a state-management problem, not a UI problem. Durable checkpoints that survive a three-day reviewer delay were the actual hard requirement.
- Design the rejection loop as carefully as the happy path. Injecting reviewer comments into the redraft context made revision two dramatically better than revision one.
- Cap revision loops in the graph. We hit a draft-reject-draft oscillation between the model and one picky reviewer; after three cycles it now escalates to a senior human.
- Run deterministic compliance checks before the LLM pass. Regex-verified clause presence is free and catches the failures that would embarrass you most.
- The audit trail sold the system. Legal approved it because every document had a named approver and a replayable checkpoint chain — the AI quality was secondary to them.