"""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'
' + hidden("csrf", session.csrf) + hidden("key", str(uuid.uuid4())) + fields + f'
') def _textarea(name, label, value=""): return f'' 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

' 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") + '' '') 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

' 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 + '' '', "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") + '' + _textarea("uncertainty", "Uncertainty and open questions"), "Add evaluation") + '
' return document("Decision workspace", content, session.subject)