informed-decision/informed_decision/lifecycle_ui.py
tegwick 9bc581a547 Add lifecycle browser authoring and durable review handoff retrieval
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a10780-6d41-7952-9718-dfbc1f41236f
2026-10-05 00:48:07 +02:00

292 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Human workspace adapter: forms and rendering, with no storage or policy rules."""
from dataclasses import replace
import base64
import hashlib
import re
from urllib.parse import urlencode
import uuid
from .lifecycle_commands import decode_memo
from .lifecycle_service import Caller, LifecycleError
from .lifecycle_review import supports_review
from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind, PacketItem, Highlight
from .records import dumps
from .store import EvidenceUnavailable
from .ui import document, hidden, text
import json
def _form(action, session, fields, button):
return (f'<form method="post" action="{text(action)}">'
+ hidden("csrf", session.csrf) + hidden("key", str(uuid.uuid4()))
+ fields + f'<button type="submit">{text(button)}</button></form>')
def _textarea(name, label, value=""):
return f'<label>{text(label)}<textarea name="{text(name)}" required>{text(value)}</textarea></label>'
def _lines(value):
return [line.strip() for line in value.splitlines() if line.strip()]
class LifecycleWorkspace:
def __init__(self, service):
self.service = service
def dispatch(self, method, path, params, session, form=None):
caller = Caller.from_human_session(session)
self.service._caller(caller)
if method == "GET" and path == "/decisions":
if set(params) - {"q", "cursor"}:
raise ValueError()
query = params.get("q", "")
page = self.service.search(caller, query=query, cursor=params.get("cursor"))
content = ('<h1>Decision workspace</h1><p>Prepare a decision, examine evidence, and invite discussion.</p>'
'<form method="get" action="/decisions"><label>Search decisions '
f'<input name="q" value="{text(query)}" maxlength="200"></label><button>Search</button></form>'
'<p><a href="/decisions/new/prepare">Prepare a decision</a></p><ul>')
for row in page["decisions"]:
content += (f'<li><a href="/decisions/{text(row["memo_id"])}">{text(row["question"])}</a>'
f' · revision {row["version"]}</li>')
content += '</ul>'
if not page["decisions"]:
content += '<p>No decisions to display on this page.</p>'
if page["next_cursor"]:
content += f'<p><a href="/decisions?{text(urlencode({"q": query, "cursor": page["next_cursor"]}))}">Next page</a></p>'
return 200, document("Decision workspace", content, session.subject), []
if path == "/decisions/new/prepare":
if params:
raise ValueError()
if method == "GET":
fields = (_textarea("question", "Question") + _textarea("brief", "Context, alternatives and recommendation")
+ '<label>Subject or scope identifier <input name="scope_id" required maxlength="200"></label>'
'<label>Subject or scope label <input name="scope_label" required maxlength="200"></label>'
'<label for="review-profile">Intended review</label><select id="review-profile" name="review_profile" required>'
'<option value="approval">Human approval (organizational)</option>'
'<option value="discussion">Discussion only</option></select>'
'<p>Saving a draft does not request or record approval.</p>')
content = '<h1>Prepare a decision</h1>' + _form(path, session, fields, "Save draft")
return 200, document("Prepare a decision", content, session.subject), []
if method == "POST":
self._fields(form, {"question", "brief", "scope_id", "scope_label"}, {"review_profile"})
profile = form.get("review_profile", "legacy")
if profile not in {"approval", "discussion", "legacy"}:
raise ValueError()
memo = Memo(id=str(uuid.uuid4()), version=1, question=form["question"], brief=form["brief"],
requested_act="approve" if profile == "approval" else "comment" if profile == "discussion" else "accept",
binding_level=BindingLevel.ORGANIZATIONAL,
step_kind={"approval": StepKind.APPROVE, "discussion": StepKind.COMMENT,
"legacy": StepKind.MITZEICHNUNG}[profile],
binding=BindingSlice(Principal("pending-human-session", "person", "To be assigned"),
Scope("resource", form["scope_id"], form["scope_label"])))
# Stable create identity across an HTTP retry of the same rendered form.
memo = replace(memo, id=str(uuid.uuid5(uuid.NAMESPACE_URL,
dumps([caller.attribution.tenant, session.subject, form["key"]]))))
self.service.register(caller, decode_memo(json.loads(dumps(memo))), key=form["key"])
return 303, "", [("Location", "/decisions/" + memo.id)]
match = re.fullmatch(r"/decisions/([A-Za-z0-9._:-]{1,256})(?:/(revise|comment|publish|source|evaluate|document|request-review|review-request|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", []
memo_id, action, index = match.groups()
if method == "GET" and action == "review-request":
if set(params) != {"version"}:
raise ValueError()
receipt = self.service.review_handoff(caller, memo_id, int(params["version"]))
return 200, dumps(receipt["envelope"]).encode(), [
("Content-Disposition", 'attachment; filename="decision-review-request.json"')]
if method == "GET" and (action is None or index is not None):
if set(params) - {"version", "after"}:
raise ValueError()
version = int(params["version"]) if "version" in params else None
if index is not None:
if version is None or "after" in params:
raise ValueError()
return 200, self.service.packet(caller, memo_id, version, int(index)), [
("Content-Disposition", f'attachment; filename="decision-document-{int(index)+1}.bin"')]
memo = self.service.retrieve(caller, memo_id, version)
events = self.service.events(caller, memo_id, after=int(params.get("after", "0")))
previous = None
if memo.version > 1:
try:
previous = self.service.retrieve(caller, memo_id, memo.version - 1)
except EvidenceUnavailable:
pass
except LifecycleError as error:
if error.status != 403:
raise
outcome = None
try:
outcome = self.service.outcomes(caller, memo.id, memo.version)
except LifecycleError as error:
if error.status != 403:
raise
return 200, self._page(memo, events, session, previous, outcome), []
if method == "POST" and action in {"revise", "comment", "publish", "source", "evaluate", "document", "request-review"}:
if params:
raise ValueError()
fields = {"revise": {"version", "question", "brief", "summary"},
"comment": {"version", "text"}, "publish": {"version", "subjects", "groups"},
"source": {"version", "reference", "retrieved_at", "digest", "summary"},
"evaluate": {"version", "assessment", "source_ids", "uncertainty"},
"document": {"version", "label", "content", "highlight", "summary"},
"request-review": {"version", "principal", "purpose"}}[action]
self._fields(form, fields)
version = int(form["version"])
if action == "document":
previous = self.service.retrieve(caller, memo_id, version)
if not form["content"].strip() or not form["label"].strip():
raise ValueError()
content = form["content"].encode()
digest = "sha256:" + hashlib.sha256(content).hexdigest()
item_id = "doc-" + form["key"]
highlights = previous.highlights
if form["highlight"].strip():
highlights += (Highlight("highlight-" + form["key"], item_id, form["highlight"], required_ack=True),)
memo = previous.next_version(packet=previous.packet + (PacketItem(item_id, form["label"], digest),),
highlights=highlights)
self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version,
summary=form["summary"], key=form["key"], documents=[{"digest": digest,
"media_type": "text/plain", "content_base64": base64.b64encode(content).decode()}])
elif action == "request-review":
memo = self.service.retrieve(caller, memo_id, version)
self.service.request_review(caller, memo_id, version, key=form["key"], options={
"submitter": "state-hub", "memo_class": "decision-record", "review_group": "net-kingdom-admins",
"approval_intent": {"action": "accept", "principal": form["principal"], "purpose": form["purpose"],
"target": {"id": memo.binding.target.id, "type": memo.binding.target.kind,
"system": "informed-decision"}}})
elif action == "revise":
previous = self.service.retrieve(caller, memo_id, version)
memo = previous.next_version(question=form["question"], brief=form["brief"])
self.service.revise(caller, decode_memo(json.loads(dumps(memo))), expected_version=version,
summary=form["summary"], key=form["key"])
elif action == "publish":
self.service.publish(caller, memo_id, version, key=form["key"],
audience={"subjects": _lines(form["subjects"]), "groups": _lines(form["groups"])})
else:
body = {key: form[key] for key in fields - {"version"}}
if action == "evaluate":
body["source_ids"] = _lines(body["source_ids"])
self.service.annotate(caller, memo_id, version, key=form["key"],
kind="evaluation" if action == "evaluate" else action, body=body)
return 303, "", [("Location", "/decisions/" + memo_id)]
return 405, "Method not allowed.", []
@staticmethod
def _fields(form, required, optional=frozenset()):
required = required | {"csrf", "key"}
if not isinstance(form, dict) or not required <= form.keys() or form.keys() - required - optional:
raise ValueError()
uuid.UUID(form["key"])
def _page(self, memo, events, session, previous=None, outcome=None):
base = "/decisions/" + memo.id
revision = hidden("version", memo.version)
content = (f'<p><a href="/decisions">Decision workspace</a></p><h1>{text(memo.question)}</h1>'
f'<p>Revision {memo.version} · {text(memo.binding.target.label)}</p>'
f'<section class="card"><h2>Context and recommendation</h2><p class="brief">{text(memo.brief)}</p></section>'
'<p>Publishing invites discussion; it does not record acceptance.</p>')
if not outcome or not outcome['review'] or not outcome['review'].get('path'):
content += '<p>Human acceptance is not available for this draft.</p>'
if outcome and outcome["review"]:
review = outcome["review"]
content += '<section class="card"><h2>Human review</h2>'
if review.get("path"):
content += f'<p><a href="{text(review["path"])}">Open protected human review</a></p>'
elif review["stale"]:
content += '<p>This review belongs to an earlier revision and cannot receive a new answer.</p>'
else:
content += '<p>Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '</p>'
if review["intake_state"] == "not_submitted":
content += (f'<p><a href="{text(base)}/review-request?version={memo.version}">Download review request</a>'
' for the admitted State Hub agent to submit. Preparing this request has not created an approval.</p>')
for item in outcome["dispositions"]:
answer = item["disposition"]
content += f'<p>{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
if item["submission_state"]:
content += ' · approval entry ' + text(item["submission_state"])
content += '</p>'
content += '</section>'
elif supports_review(memo):
content += '<section class="card"><h2>Prepare human review</h2>'
if memo.packet:
content += ('<p>Publish this revision before preparing its review. The initial review group is '
'net-kingdom-admins, with the configured platform reviewer as recipient.</p>'
'<p>Preparation saves a request for the admitted State Hub agent to submit. '
'You can download it afterwards; no human answer is recorded here.</p>')
content += _form(base + "/request-review", session, revision
+ _textarea("principal", "Responsible party or system")
+ _textarea("purpose", "Purpose of this approval"), "Prepare review request")
else:
content += '<p>Add a supporting document, then publish the new revision to prepare human review.</p>'
content += '</section>'
else:
content += '<p>This draft’s review type is not supported by the current human approval flow.</p>'
if previous is not None:
content += f'<details class="card"><summary>Compare with revision {previous.version}</summary>'
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
before, after = getattr(previous, field), getattr(memo, field)
if before != after:
content += (f'<h3>{label}</h3><p>Before</p><p class="brief">{text(before)}</p>'
f'<p>After</p><p class="brief">{text(after)}</p>')
if previous.packet != memo.packet:
content += '<h3>Supporting documents changed</h3>'
for label, version in (("Before", previous), ("After", memo)):
content += f'<p>{label}</p><ul>'
for index, item in enumerate(version.packet):
content += (f'<li><a href="{text(base)}/packet/{index}?version={version.version}">'
f'{text(item.label)}</a> · <code>{text(item.hash)}</code></li>')
content += '</ul>'
if previous.highlights != memo.highlights:
content += '<h3>Review acknowledgments changed</h3>'
for label, version in (("Before", previous), ("After", memo)):
content += f'<p>{label}</p><ul>'
for highlight in version.highlights:
content += (f'<li>{text(highlight.note)}'
+ (' · acknowledgment required' if highlight.required_ack else '') + '</li>')
content += '</ul>'
content += '</details>'
if memo.packet:
content += '<section class="card"><h2>Supporting documents</h2><ul>'
for index, item in enumerate(memo.packet):
content += f'<li><a href="{text(base)}/packet/{index}?version={memo.version}">{text(item.label)}</a></li>'
content += '</ul></section>'
content += '<section class="card"><h2>Discussion and history</h2>'
for event in events:
body = {key: value for key, value in event["body"].items() if key not in {"policy_observation_id", "envelope"}}
content += (f'<article class="record"><h3>{text(event["kind"])} · revision {event["version"]}</h3>'
f'<p>{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}</p>')
if event["kind"] == "source":
content += f'<p>Source reference: <code>{text(event["id"])}</code></p>'
for key, value in body.items():
content += f'<p><strong>{text(key.replace("_", " "))}:</strong> {text(dumps(value) if isinstance(value, (dict, list)) else value)}</p>'
content += '</article>'
if len(events) == 100:
content += f'<p><a href="{text(base)}?version={memo.version}&amp;after={events[-1]["sequence"]}">More history</a></p>'
content += '</section><section class="card"><h2>Add a comment</h2>'
content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + '</section>'
content += '<details class="card"><summary>Add a supporting document</summary>'
content += ('<p>The document becomes part of a new revision. Earlier documents and answers remain unchanged. '
'Publish the new revision before requesting review. This form accepts short text documents; '
'larger or binary documents can be supplied by an agent.</p>')
content += _form(base + "/document", session, revision + _textarea("label", "Document title")
+ _textarea("content", "Document text")
+ '<label>Required acknowledgment (optional)<textarea name="highlight"></textarea></label>'
+ _textarea("summary", "What changed"), "Save document in new revision") + '</details>'
content += '<details class="card"><summary>Refine this decision</summary>'
content += _form(base + "/revise", session, revision + _textarea("question", "Question", memo.question)
+ _textarea("brief", "Context and recommendation", memo.brief)
+ _textarea("summary", "What changed"), "Save new revision") + '</details>'
content += '<details class="card"><summary>Publish for discussion</summary><p>Enter explicit member or group identifiers, one per line.</p>'
content += _form(base + "/publish", session, revision + '<label>Members<textarea name="subjects"></textarea></label>'
'<label>Groups<textarea name="groups"></textarea></label>', "Publish this revision") + '</details>'
content += '<details class="card"><summary>Add a research source</summary>'
content += _form(base + "/source", session, revision + ''.join(_textarea(name, label) for name, label in (
("reference", "Source location or reference"), ("retrieved_at", "Retrieved at (date, time and timezone)"),
("digest", "Source content digest (sha256:...)"), ("summary", "What the source supports"))), "Add source") + '</details>'
content += '<details class="card"><summary>Evaluate the evidence</summary>'
content += _form(base + "/evaluate", session, revision + _textarea("assessment", "Assessment")
+ '<label>Source references (one per line)<textarea name="source_ids"></textarea></label>'
+ _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + '</details>'
return document("Decision workspace", content, session.subject)