"""Human workspace adapter: forms and rendering, with no storage or policy rules."""
from dataclasses import replace
import re
from urllib.parse import urlencode
import uuid
from .lifecycle_commands import decode_memo
from .lifecycle_service import Caller, LifecycleError
from .memo import Memo, BindingLevel, BindingSlice, Principal, Scope, StepKind
from .records import dumps
from .store import EvidenceUnavailable
from .ui import document, hidden, text
import json
def _form(action, session, fields, button):
return (f'
')
def _textarea(name, label, value=""):
return f'{text(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 = ('Decision workspace Prepare a decision, examine evidence, and invite discussion.
'
''
'Prepare a decision
')
for row in page["decisions"]:
content += (f'{text(row["question"])} '
f' · revision {row["version"]} ')
content += ' '
if not page["decisions"]:
content += 'No decisions to display on this page.
'
if page["next_cursor"]:
content += f'Next page
'
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")
+ 'Subject or scope identifier '
'Subject or scope label ')
content = 'Prepare a decision ' + _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"})
memo = Memo(id=str(uuid.uuid4()), version=1, question=form["question"], brief=form["brief"],
requested_act="accept", binding_level=BindingLevel.ORGANIZATIONAL,
step_kind=StepKind.MITZEICHNUNG,
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|packet/([0-9]{1,3})))?", path)
if not match:
return 404, "Not found.", []
memo_id, action, index = match.groups()
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"}:
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"}}[action]
self._fields(form, fields)
version = int(form["version"])
if 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):
if not isinstance(form, dict) or set(form) != required | {"csrf", "key"}:
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'Decision workspace
{text(memo.question)} '
f'Revision {memo.version} · {text(memo.binding.target.label)}
'
f'Context and recommendation {text(memo.brief)}
'
'Publishing invites discussion; it does not record acceptance.
')
if not outcome or not outcome['review'] or not outcome['review'].get('path'):
content += 'Human acceptance is not available for this draft.
'
if outcome and outcome["review"]:
review = outcome["review"]
content += 'Human review '
if review.get("path"):
content += f'Open protected human review
'
elif review["stale"]:
content += 'This review belongs to an earlier revision and cannot receive a new answer.
'
else:
content += 'Review handoff: ' + text(review["intake_state"].replace("_", " ")) + '
'
for item in outcome["dispositions"]:
answer = item["disposition"]
content += f'{text(answer["actor"]["sub"])}: {text(answer["verb"])}'
if item["submission_state"]:
content += ' · approval entry ' + text(item["submission_state"])
content += '
'
content += ' '
if previous is not None:
content += f'Compare with revision {previous.version} '
for field, label in (("question", "Question"), ("brief", "Context and recommendation")):
before, after = getattr(previous, field), getattr(memo, field)
if before != after:
content += (f'{label} Before
{text(before)}
'
f'After
{text(after)}
')
content += ' '
if memo.packet:
content += 'Supporting documents '
for index, item in enumerate(memo.packet):
content += f'{text(item.label)} '
content += ' '
content += 'Discussion and history '
for event in events:
body = {key: value for key, value in event["body"].items() if key not in {"policy_observation_id", "envelope"}}
content += (f'{text(event["kind"])} · revision {event["version"]} '
f'{text(event["actor"]["sub"])} ({text(event["actor"]["kind"])}) · {text(event["at"])}
')
if event["kind"] == "source":
content += f'Source reference: {text(event["id"])}
'
for key, value in body.items():
content += f'{text(key.replace("_", " "))}: {text(dumps(value) if isinstance(value, (dict, list)) else value)}
'
content += ' '
if len(events) == 100:
content += f'More history
'
content += 'Add a comment '
content += _form(base + "/comment", session, revision + _textarea("text", "Comment"), "Add comment") + ' '
content += 'Refine this decision '
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") + ' '
content += 'Publish for discussion Enter explicit member or group identifiers, one per line.
'
content += _form(base + "/publish", session, revision + 'Members '
'Groups ', "Publish this revision") + ' '
content += 'Add a research source '
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") + ' '
content += 'Evaluate the evidence '
content += _form(base + "/evaluate", session, revision + _textarea("assessment", "Assessment")
+ 'Source references (one per line) '
+ _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + ' '
return document("Decision workspace", content, session.subject)