postmo 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- postmo/__init__.py +49 -0
- postmo/disciplines.py +138 -0
- postmo/evidence.py +146 -0
- postmo/ledger.py +132 -0
- postmo/overlay.py +74 -0
- postmo/record.py +352 -0
- postmo/scans.py +433 -0
- postmo-1.0.0.dist-info/METADATA +186 -0
- postmo-1.0.0.dist-info/RECORD +12 -0
- postmo-1.0.0.dist-info/WHEEL +5 -0
- postmo-1.0.0.dist-info/licenses/LICENSE +21 -0
- postmo-1.0.0.dist-info/top_level.txt +1 -0
postmo/record.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""The audit record — the canonical artifact of a session.
|
|
2
|
+
|
|
3
|
+
Ryan's design call: the FULL structured record. Every discipline that
|
|
4
|
+
fired, every held tension, every person-state sequence, every typed
|
|
5
|
+
correction, every softening and motive flag — as structured data the
|
|
6
|
+
next session can load and continue from, with timestamps. Machine-
|
|
7
|
+
readable first, renderable conversationally (`render_conversation()`
|
|
8
|
+
produces the human surface the skill's output shape calls for).
|
|
9
|
+
|
|
10
|
+
Stand-down rule, enforced in code: the audit never resolves. The
|
|
11
|
+
record must not contain synthesis, resolution, or verdict sections —
|
|
12
|
+
a record that resolves has stopped auditing and started narrating.
|
|
13
|
+
`check_stand_down()` rejects both on render and on parse.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import date, datetime, timezone
|
|
21
|
+
|
|
22
|
+
from .disciplines import DISCIPLINES, discipline_name
|
|
23
|
+
from .evidence import EvidenceItem
|
|
24
|
+
from .ledger import Ledger
|
|
25
|
+
from .scans import (
|
|
26
|
+
Correction, HeldTension, MotiveFlag, PersonState, SofteningFlag,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
PROTOCOL = "postmo/1.0"
|
|
30
|
+
|
|
31
|
+
# Headings an audit record must never contain. An audit that synthesizes
|
|
32
|
+
# or resolves has left its lane — the schema rejects it.
|
|
33
|
+
# Stems chosen to actually match: "resolut" catches resolution/resolve/
|
|
34
|
+
# resolved ("resolv" would miss "resolution" — no v in r-e-s-o-l-u).
|
|
35
|
+
FORBIDDEN_SECTIONS = ("synthes", "resolut", "verdict", "conclus")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RecordError(ValueError):
|
|
39
|
+
"""Raised when a record breaks the schema or the stand-down rule."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_stand_down(rendered: str) -> None:
|
|
43
|
+
"""Reject rendered text with resolution/narration sections."""
|
|
44
|
+
headings = re.findall(r"^#{1,3}\s*(.+)$", rendered, re.MULTILINE)
|
|
45
|
+
for heading in headings:
|
|
46
|
+
lowered = heading.lower()
|
|
47
|
+
for forbidden in FORBIDDEN_SECTIONS:
|
|
48
|
+
if forbidden in lowered:
|
|
49
|
+
raise RecordError(
|
|
50
|
+
f"Stand-down violation: the audit record must not contain "
|
|
51
|
+
f"a '{heading.strip()}' section. The audit holds "
|
|
52
|
+
"contradictions — it never synthesizes, resolves, or "
|
|
53
|
+
"issues verdicts."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def utc_now() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class DisciplineEntry:
|
|
63
|
+
"""One discipline enforcement, timestamped.
|
|
64
|
+
|
|
65
|
+
action: "enforced" (the discipline shaped the session),
|
|
66
|
+
"flagged" (a mechanical check fired — REVIEW item),
|
|
67
|
+
"reviewed" (the operator judged a flagged item).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
discipline: int
|
|
71
|
+
action: str
|
|
72
|
+
detail: str
|
|
73
|
+
timestamp: str = field(default_factory=utc_now)
|
|
74
|
+
|
|
75
|
+
def __post_init__(self) -> None:
|
|
76
|
+
if self.discipline not in DISCIPLINES:
|
|
77
|
+
raise RecordError(f"Unknown discipline: {self.discipline!r}.")
|
|
78
|
+
if self.action not in ("enforced", "flagged", "reviewed"):
|
|
79
|
+
raise RecordError(f"Bad discipline action: {self.action!r}.")
|
|
80
|
+
if not self.detail.strip():
|
|
81
|
+
raise RecordError("Discipline entry needs a detail.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AuditRecord:
|
|
86
|
+
"""The full structured record of an audit session."""
|
|
87
|
+
|
|
88
|
+
case_name: str
|
|
89
|
+
tensions: list[HeldTension] = field(default_factory=list)
|
|
90
|
+
states: list[PersonState] = field(default_factory=list)
|
|
91
|
+
corrections: list[Correction] = field(default_factory=list)
|
|
92
|
+
motive_flags: list[MotiveFlag] = field(default_factory=list)
|
|
93
|
+
softening_flags: list[SofteningFlag] = field(default_factory=list)
|
|
94
|
+
discipline_log: list[DisciplineEntry] = field(default_factory=list)
|
|
95
|
+
evidence: list[EvidenceItem] = field(default_factory=list)
|
|
96
|
+
lifecycle: str = "FINAL"
|
|
97
|
+
record_date: str = field(default_factory=lambda: date.today().isoformat())
|
|
98
|
+
|
|
99
|
+
def __post_init__(self) -> None:
|
|
100
|
+
if not self.case_name.strip():
|
|
101
|
+
raise RecordError("Audit record needs a case name.")
|
|
102
|
+
if self.lifecycle not in ("INITIAL", "ITERATIVE", "FINAL"):
|
|
103
|
+
raise RecordError(f"Bad lifecycle: {self.lifecycle!r}.")
|
|
104
|
+
|
|
105
|
+
# -- building helpers ----------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def log(self, discipline: int, action: str, detail: str) -> DisciplineEntry:
|
|
108
|
+
"""Append a timestamped discipline-log entry; returns it."""
|
|
109
|
+
entry = DisciplineEntry(discipline=discipline, action=action, detail=detail)
|
|
110
|
+
self.discipline_log.append(entry)
|
|
111
|
+
return entry
|
|
112
|
+
|
|
113
|
+
def core_claim_changes(self) -> list[Correction]:
|
|
114
|
+
"""The signal (D3): corrections that changed the account itself."""
|
|
115
|
+
return [c for c in self.corrections if c.kind == "core-claim"]
|
|
116
|
+
|
|
117
|
+
def states_for(self, person: str) -> list[PersonState]:
|
|
118
|
+
"""One person's time-indexed sequence (D2)."""
|
|
119
|
+
return [s for s in self.states if s.person.lower() == person.lower()]
|
|
120
|
+
|
|
121
|
+
# -- rendering -------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def render(self) -> str:
|
|
124
|
+
"""The machine-readable record: frontmatter + structured sections."""
|
|
125
|
+
lines = [
|
|
126
|
+
"---",
|
|
127
|
+
"artifact: audit-record",
|
|
128
|
+
f"date: {self.record_date}",
|
|
129
|
+
f"protocol: {PROTOCOL}",
|
|
130
|
+
f"lifecycle: {self.lifecycle}",
|
|
131
|
+
"---",
|
|
132
|
+
"",
|
|
133
|
+
f"# Audit record: {self.case_name}",
|
|
134
|
+
"",
|
|
135
|
+
"## Held tensions (discipline 1)",
|
|
136
|
+
]
|
|
137
|
+
for t in self.tensions:
|
|
138
|
+
lines.append(f"- {t.text}")
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append("## Person-state sequences (discipline 2)")
|
|
141
|
+
for s in self.states:
|
|
142
|
+
lines.append(f"- [{s.when}] {s.person}: {s.state}")
|
|
143
|
+
lines.append("")
|
|
144
|
+
lines.append("## Corrections (discipline 3)")
|
|
145
|
+
for c in self.corrections:
|
|
146
|
+
lines.append(f"- ({c.kind}) {c.text}")
|
|
147
|
+
lines.append("")
|
|
148
|
+
lines.append("## Motive flags (discipline 4)")
|
|
149
|
+
for m in self.motive_flags:
|
|
150
|
+
lines.append(f"- [{m.severity}] {m.sentence} — {m.pattern}")
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append("## Softening flags (discipline 5)")
|
|
153
|
+
for f in self.softening_flags:
|
|
154
|
+
lines.append(f"- [{f.kind}] {f.detail}")
|
|
155
|
+
lines.append("")
|
|
156
|
+
lines.append("## Discipline log")
|
|
157
|
+
for e in self.discipline_log:
|
|
158
|
+
lines.append(
|
|
159
|
+
f"- [{e.timestamp}] D{e.discipline} "
|
|
160
|
+
f"({discipline_name(e.discipline)}) {e.action}: {e.detail}"
|
|
161
|
+
)
|
|
162
|
+
lines.append("")
|
|
163
|
+
lines.append("## Evidence")
|
|
164
|
+
for item in self.evidence:
|
|
165
|
+
lines.append(f"- [{item.tag}] {item.text}")
|
|
166
|
+
rendered = "\n".join(lines)
|
|
167
|
+
check_stand_down(rendered)
|
|
168
|
+
return rendered
|
|
169
|
+
|
|
170
|
+
def render_conversation(self) -> str:
|
|
171
|
+
"""The human surface: the record's contents in conversational form.
|
|
172
|
+
|
|
173
|
+
This is a digest, not a conversation — the operator's actual
|
|
174
|
+
replies stay with the operator. It names held tensions explicitly
|
|
175
|
+
("Both of these are true, and I'm not picking one"), gives
|
|
176
|
+
per-person accounts, names correction types before responding to
|
|
177
|
+
them, and surfaces flags inline, per the skill's output shape.
|
|
178
|
+
"""
|
|
179
|
+
parts = [f"Auditing: {self.case_name}", ""]
|
|
180
|
+
if self.tensions:
|
|
181
|
+
parts.append(
|
|
182
|
+
"Both of these are true, and I'm not picking one:"
|
|
183
|
+
)
|
|
184
|
+
for t in self.tensions:
|
|
185
|
+
parts.append(f" {t.text}")
|
|
186
|
+
parts.append("")
|
|
187
|
+
people = sorted({s.person for s in self.states})
|
|
188
|
+
for person in people:
|
|
189
|
+
seq = self.states_for(person)
|
|
190
|
+
states = "; ".join(f"{s.when}: {s.state}" for s in seq)
|
|
191
|
+
parts.append(f"{person} — {states}.")
|
|
192
|
+
if people:
|
|
193
|
+
parts.append("")
|
|
194
|
+
for c in self.corrections:
|
|
195
|
+
parts.append(
|
|
196
|
+
f"Correction typed as {c.kind}: {c.text}"
|
|
197
|
+
)
|
|
198
|
+
if self.corrections:
|
|
199
|
+
parts.append("")
|
|
200
|
+
for m in self.motive_flags:
|
|
201
|
+
if m.severity == "flag":
|
|
202
|
+
parts.append(
|
|
203
|
+
f"Flagging a supplied motive — the teller didn't state "
|
|
204
|
+
f"this why: {m.sentence}"
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
parts.append(
|
|
208
|
+
f"Marked as a guess (stays marked): {m.sentence}"
|
|
209
|
+
)
|
|
210
|
+
for f in self.softening_flags:
|
|
211
|
+
parts.append(f"Softening check: {f.detail}")
|
|
212
|
+
if self.motive_flags or self.softening_flags:
|
|
213
|
+
parts.append("")
|
|
214
|
+
parts.append(
|
|
215
|
+
"Nothing here is resolved. The contradictions stay live; "
|
|
216
|
+
"the sequences stay sequences."
|
|
217
|
+
)
|
|
218
|
+
rendered = "\n".join(parts)
|
|
219
|
+
check_stand_down(rendered)
|
|
220
|
+
return rendered
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- parsing ------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
_SECTION_RES = {
|
|
226
|
+
"tensions": re.compile(r"^## Held tensions", re.MULTILINE),
|
|
227
|
+
"states": re.compile(r"^## Person-state sequences", re.MULTILINE),
|
|
228
|
+
"corrections": re.compile(r"^## Corrections", re.MULTILINE),
|
|
229
|
+
"motive_flags": re.compile(r"^## Motive flags", re.MULTILINE),
|
|
230
|
+
"softening_flags": re.compile(r"^## Softening flags", re.MULTILINE),
|
|
231
|
+
"discipline_log": re.compile(r"^## Discipline log", re.MULTILINE),
|
|
232
|
+
"evidence": re.compile(r"^## Evidence", re.MULTILINE),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _section_text(rendered: str, key: str) -> str:
|
|
237
|
+
match = _SECTION_RES[key].search(rendered)
|
|
238
|
+
if not match:
|
|
239
|
+
return ""
|
|
240
|
+
tail = rendered[match.end():]
|
|
241
|
+
next_heading = re.search(r"^## ", tail, re.MULTILINE)
|
|
242
|
+
return tail[: next_heading.start()] if next_heading else tail
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def parse(rendered: str) -> AuditRecord:
|
|
246
|
+
"""Parse a rendered record back into an AuditRecord.
|
|
247
|
+
|
|
248
|
+
Round-trip fidelity: case name, lifecycle, date, and every
|
|
249
|
+
structured section survive. Items re-validate on the way in —
|
|
250
|
+
a hand-edited record with a subordinating tension or a bad tag
|
|
251
|
+
fails here, not silently.
|
|
252
|
+
"""
|
|
253
|
+
check_stand_down(rendered)
|
|
254
|
+
front = dict(
|
|
255
|
+
line.split(":", 1)
|
|
256
|
+
for line in rendered.splitlines()
|
|
257
|
+
if re.match(r"^(artifact|date|protocol|lifecycle):", line)
|
|
258
|
+
)
|
|
259
|
+
if front.get("artifact", "").strip() != "audit-record":
|
|
260
|
+
raise RecordError("Not an audit-record artifact.")
|
|
261
|
+
match = re.search(r"^# Audit record: (.+)$", rendered, re.MULTILINE)
|
|
262
|
+
if not match:
|
|
263
|
+
raise RecordError("Record is missing its case-name heading.")
|
|
264
|
+
|
|
265
|
+
tensions = [
|
|
266
|
+
HeldTension(text=line[2:].strip())
|
|
267
|
+
for line in _section_text(rendered, "tensions").splitlines()
|
|
268
|
+
if line.startswith("- ")
|
|
269
|
+
]
|
|
270
|
+
states: list[PersonState] = []
|
|
271
|
+
for line in _section_text(rendered, "states").splitlines():
|
|
272
|
+
if line.startswith("- "):
|
|
273
|
+
m = re.match(r"^- \[(.+?)\] (.+?): (.+)$", line)
|
|
274
|
+
if not m:
|
|
275
|
+
raise RecordError(f"Unparseable person-state line: {line!r}")
|
|
276
|
+
states.append(PersonState(when=m.group(1), person=m.group(2),
|
|
277
|
+
state=m.group(3)))
|
|
278
|
+
corrections: list[Correction] = []
|
|
279
|
+
for line in _section_text(rendered, "corrections").splitlines():
|
|
280
|
+
if line.startswith("- "):
|
|
281
|
+
m = re.match(r"^- \((detail|core-claim)\) (.+)$", line)
|
|
282
|
+
if not m:
|
|
283
|
+
raise RecordError(f"Unparseable correction line: {line!r}")
|
|
284
|
+
corrections.append(Correction(kind=m.group(1), text=m.group(2)))
|
|
285
|
+
motive_flags: list[MotiveFlag] = []
|
|
286
|
+
for line in _section_text(rendered, "motive_flags").splitlines():
|
|
287
|
+
if line.startswith("- "):
|
|
288
|
+
m = re.match(r"^- \[(flag|guess)\] (.+?) — (.+)$", line)
|
|
289
|
+
if not m:
|
|
290
|
+
raise RecordError(f"Unparseable motive-flag line: {line!r}")
|
|
291
|
+
motive_flags.append(MotiveFlag(sentence=m.group(2),
|
|
292
|
+
pattern=m.group(3),
|
|
293
|
+
severity=m.group(1)))
|
|
294
|
+
softening_flags: list[SofteningFlag] = []
|
|
295
|
+
for line in _section_text(rendered, "softening_flags").splitlines():
|
|
296
|
+
if line.startswith("- "):
|
|
297
|
+
m = re.match(
|
|
298
|
+
r"^- \[(downgrade|dropped|tidy-restatement|ambivalence-resolved)\] (.+)$",
|
|
299
|
+
line,
|
|
300
|
+
)
|
|
301
|
+
if not m:
|
|
302
|
+
raise RecordError(f"Unparseable softening-flag line: {line!r}")
|
|
303
|
+
softening_flags.append(SofteningFlag(kind=m.group(1), detail=m.group(2)))
|
|
304
|
+
discipline_log: list[DisciplineEntry] = []
|
|
305
|
+
for line in _section_text(rendered, "discipline_log").splitlines():
|
|
306
|
+
if line.startswith("- "):
|
|
307
|
+
m = re.match(
|
|
308
|
+
r"^- \[(.+?)\] D(\d) \((.+?)\) (enforced|flagged|reviewed): (.+)$",
|
|
309
|
+
line,
|
|
310
|
+
)
|
|
311
|
+
if not m:
|
|
312
|
+
raise RecordError(f"Unparseable discipline-log line: {line!r}")
|
|
313
|
+
discipline_log.append(DisciplineEntry(
|
|
314
|
+
discipline=int(m.group(2)), action=m.group(4),
|
|
315
|
+
detail=m.group(5), timestamp=m.group(1)))
|
|
316
|
+
evidence: list[EvidenceItem] = []
|
|
317
|
+
for line in _section_text(rendered, "evidence").splitlines():
|
|
318
|
+
if line.startswith("- "):
|
|
319
|
+
m = re.match(r"^- \[(.+?)\] (.+)$", line)
|
|
320
|
+
if not m:
|
|
321
|
+
raise RecordError(f"Unparseable evidence line: {line!r}")
|
|
322
|
+
evidence.append(EvidenceItem(text=m.group(2), tag=m.group(1)))
|
|
323
|
+
|
|
324
|
+
rec = AuditRecord(
|
|
325
|
+
case_name=match.group(1).strip(),
|
|
326
|
+
tensions=tensions,
|
|
327
|
+
states=states,
|
|
328
|
+
corrections=corrections,
|
|
329
|
+
motive_flags=motive_flags,
|
|
330
|
+
softening_flags=softening_flags,
|
|
331
|
+
discipline_log=discipline_log,
|
|
332
|
+
evidence=evidence,
|
|
333
|
+
lifecycle=front.get("lifecycle", "FINAL").strip() or "FINAL",
|
|
334
|
+
record_date=front.get("date", "").strip() or date.today().isoformat(),
|
|
335
|
+
)
|
|
336
|
+
Ledger("postmo").record(
|
|
337
|
+
"audit",
|
|
338
|
+
{
|
|
339
|
+
"lifecycle": rec.lifecycle,
|
|
340
|
+
"tensions": len(tensions),
|
|
341
|
+
"states": len(states),
|
|
342
|
+
"corrections": len(corrections),
|
|
343
|
+
"core_claim_changes": len(rec.core_claim_changes()),
|
|
344
|
+
"motive_flags": len(motive_flags),
|
|
345
|
+
"softening_flags": len(softening_flags),
|
|
346
|
+
"discipline_entries": len(discipline_log),
|
|
347
|
+
"disciplines_used": sorted({e.discipline for e in discipline_log}),
|
|
348
|
+
"evidence_items": len(evidence),
|
|
349
|
+
},
|
|
350
|
+
input_text=rendered,
|
|
351
|
+
)
|
|
352
|
+
return rec
|