docketry 0.1.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.
- docketry/__init__.py +8 -0
- docketry/cite.py +253 -0
- docketry/cite_client.py +68 -0
- docketry/classify.py +60 -0
- docketry/cli.py +634 -0
- docketry/config.py +77 -0
- docketry/envelope.py +166 -0
- docketry/extract.py +195 -0
- docketry/gates/__init__.py +28 -0
- docketry/gates/builtin.py +164 -0
- docketry/gates/classifier.py +28 -0
- docketry/gates/notice.py +59 -0
- docketry/lint.py +172 -0
- docketry/mailbox.py +77 -0
- docketry/manifest.py +105 -0
- docketry/notices.py +268 -0
- docketry/pipeline.py +157 -0
- docketry/store.py +340 -0
- docketry/webui.py +202 -0
- docketry-0.1.0.dist-info/METADATA +343 -0
- docketry-0.1.0.dist-info/RECORD +25 -0
- docketry-0.1.0.dist-info/WHEEL +4 -0
- docketry-0.1.0.dist-info/entry_points.txt +2 -0
- docketry-0.1.0.dist-info/licenses/LICENSE +202 -0
- docketry-0.1.0.dist-info/licenses/NOTICE +4 -0
docketry/pipeline.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The gate runner: enforcement lives here, not in prompts.
|
|
2
|
+
|
|
3
|
+
A message advances through the manifest's stages. Entering a stage runs every
|
|
4
|
+
gate bound to it. A failed gate resolves per its configured on_fail:
|
|
5
|
+
|
|
6
|
+
block -> the message stops; only a recorded override by the gate's declared
|
|
7
|
+
authority lets it continue.
|
|
8
|
+
bounce -> the message parks in the human review queue; a recorded approval
|
|
9
|
+
by the declared authority releases it.
|
|
10
|
+
warn -> the finding is recorded and the message proceeds.
|
|
11
|
+
|
|
12
|
+
advance() is the only code path that moves a message forward, and it re-checks
|
|
13
|
+
approvals every time — there is no advisory mode and no bypass flag.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
from . import store as st
|
|
21
|
+
from .envelope import Envelope
|
|
22
|
+
|
|
23
|
+
SEVERITY_FAIL = "fail"
|
|
24
|
+
SEVERITY_WARN = "warn"
|
|
25
|
+
SEVERITY_INFO = "info"
|
|
26
|
+
|
|
27
|
+
ON_FAIL = ("block", "bounce", "warn")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Finding:
|
|
32
|
+
gate_id: str
|
|
33
|
+
severity: str
|
|
34
|
+
summary: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Gate(Protocol):
|
|
38
|
+
"""A gate checks one thing about a message and reports findings.
|
|
39
|
+
|
|
40
|
+
`allowed_stages` declares where in the pipeline this gate is meant to run
|
|
41
|
+
(None = anywhere). Binding it elsewhere is a manifest error, so the
|
|
42
|
+
meant-for / not-meant-for scoping is enforced at load time, not documented
|
|
43
|
+
in prose.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
id: str
|
|
47
|
+
allowed_stages: set[str] | None
|
|
48
|
+
|
|
49
|
+
def check(self, envelope: Envelope, options: dict) -> list[Finding]: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class GateBinding:
|
|
54
|
+
gate: Gate
|
|
55
|
+
binds_to: list[str]
|
|
56
|
+
on_fail: str = "bounce"
|
|
57
|
+
authority: str = "attorney"
|
|
58
|
+
options: dict = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GateRefusal(Exception):
|
|
62
|
+
"""Raised when advance() is asked to move a message a gate is holding."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Pipeline:
|
|
67
|
+
stages: list[str]
|
|
68
|
+
bindings: list[GateBinding]
|
|
69
|
+
|
|
70
|
+
def bindings_for(self, stage: str) -> list[GateBinding]:
|
|
71
|
+
return [b for b in self.bindings if stage in b.binds_to]
|
|
72
|
+
|
|
73
|
+
def next_stage(self, stage: str) -> str | None:
|
|
74
|
+
idx = self.stages.index(stage)
|
|
75
|
+
return self.stages[idx + 1] if idx + 1 < len(self.stages) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Runner:
|
|
79
|
+
def __init__(self, pipeline: Pipeline, store: st.Store):
|
|
80
|
+
self.pipeline = pipeline
|
|
81
|
+
self.store = store
|
|
82
|
+
|
|
83
|
+
# -- internals -------------------------------------------------------
|
|
84
|
+
def _run_stage_gates(self, msg_id: int, stage: str, env: Envelope) -> str:
|
|
85
|
+
"""Run every gate bound to `stage`; return the resulting status."""
|
|
86
|
+
status = st.OK
|
|
87
|
+
for binding in self.pipeline.bindings_for(stage):
|
|
88
|
+
cleared = binding.authority in self.store.approval_roles(
|
|
89
|
+
msg_id, stage, binding.gate.id
|
|
90
|
+
)
|
|
91
|
+
findings = binding.gate.check(env, binding.options)
|
|
92
|
+
failed = False
|
|
93
|
+
for f in findings:
|
|
94
|
+
self.store.add_finding(msg_id, stage, f.gate_id, f.severity, f.summary)
|
|
95
|
+
if f.severity == SEVERITY_FAIL:
|
|
96
|
+
failed = True
|
|
97
|
+
if not failed or cleared:
|
|
98
|
+
continue
|
|
99
|
+
if binding.on_fail == "block":
|
|
100
|
+
status = st.BLOCKED
|
|
101
|
+
elif binding.on_fail == "bounce" and status != st.BLOCKED:
|
|
102
|
+
status = st.PENDING_REVIEW
|
|
103
|
+
return status
|
|
104
|
+
|
|
105
|
+
def _envelope(self, msg_id: int) -> Envelope:
|
|
106
|
+
import json
|
|
107
|
+
|
|
108
|
+
row = self.store.get_message(msg_id)
|
|
109
|
+
if row is None:
|
|
110
|
+
raise KeyError(f"no message {msg_id}")
|
|
111
|
+
d = json.loads(row["envelope_json"])
|
|
112
|
+
d["attachments"] = [
|
|
113
|
+
{**a, "content": b""} for a in d.get("attachments", [])
|
|
114
|
+
]
|
|
115
|
+
from .envelope import Attachment
|
|
116
|
+
|
|
117
|
+
d["attachments"] = [Attachment(**a) for a in d["attachments"]]
|
|
118
|
+
return Envelope(**d)
|
|
119
|
+
|
|
120
|
+
# -- public API ------------------------------------------------------
|
|
121
|
+
def enter(self, msg_id: int) -> str:
|
|
122
|
+
"""Run the gates of the message's current stage (used at ingest)."""
|
|
123
|
+
row = self.store.get_message(msg_id)
|
|
124
|
+
status = self._run_stage_gates(msg_id, row["stage"], self._envelope(msg_id))
|
|
125
|
+
if status == st.OK and self.pipeline.next_stage(row["stage"]) is None:
|
|
126
|
+
status = st.DONE
|
|
127
|
+
self.store.set_state(msg_id, status=status)
|
|
128
|
+
return status
|
|
129
|
+
|
|
130
|
+
def advance(self, msg_id: int) -> str:
|
|
131
|
+
"""Move one stage forward. The ONLY forward path; re-checks holds."""
|
|
132
|
+
row = self.store.get_message(msg_id)
|
|
133
|
+
if row is None:
|
|
134
|
+
raise KeyError(f"no message {msg_id}")
|
|
135
|
+
env = self._envelope(msg_id)
|
|
136
|
+
|
|
137
|
+
if row["status"] in (st.BLOCKED, st.PENDING_REVIEW):
|
|
138
|
+
# Holds only clear through recorded approvals; re-run the stage.
|
|
139
|
+
status = self._run_stage_gates(msg_id, row["stage"], env)
|
|
140
|
+
if status != st.OK:
|
|
141
|
+
self.store.set_state(msg_id, status=status)
|
|
142
|
+
raise GateRefusal(
|
|
143
|
+
f"message {msg_id} is {status} at stage '{row['stage']}';"
|
|
144
|
+
" approval by the declared authority is required"
|
|
145
|
+
)
|
|
146
|
+
self.store.set_state(msg_id, status=st.OK)
|
|
147
|
+
|
|
148
|
+
nxt = self.pipeline.next_stage(row["stage"])
|
|
149
|
+
if nxt is None:
|
|
150
|
+
self.store.set_state(msg_id, status=st.DONE)
|
|
151
|
+
return st.DONE
|
|
152
|
+
|
|
153
|
+
status = self._run_stage_gates(msg_id, nxt, env)
|
|
154
|
+
if status == st.OK and self.pipeline.next_stage(nxt) is None:
|
|
155
|
+
status = st.DONE
|
|
156
|
+
self.store.set_state(msg_id, stage=nxt, status=status)
|
|
157
|
+
return status
|
docketry/store.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Local SQLite store: the pipeline's single source of truth.
|
|
2
|
+
|
|
3
|
+
Everything lives on the firm's own disk. Ingest is idempotent (keyed on the
|
|
4
|
+
raw message hash), attachment bytes go to a content-addressed directory, and
|
|
5
|
+
every gate finding and human approval lands in an audit table.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .envelope import Envelope
|
|
15
|
+
|
|
16
|
+
SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS messages(
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
message_id TEXT NOT NULL,
|
|
20
|
+
raw_sha256 TEXT NOT NULL UNIQUE,
|
|
21
|
+
source TEXT NOT NULL,
|
|
22
|
+
fetched_at TEXT NOT NULL,
|
|
23
|
+
envelope_json TEXT NOT NULL,
|
|
24
|
+
stage TEXT NOT NULL,
|
|
25
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
26
|
+
updated_at TEXT NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS attachments(
|
|
29
|
+
id INTEGER PRIMARY KEY,
|
|
30
|
+
message_id INTEGER NOT NULL REFERENCES messages(id),
|
|
31
|
+
filename TEXT NOT NULL,
|
|
32
|
+
content_type TEXT,
|
|
33
|
+
sha256 TEXT NOT NULL,
|
|
34
|
+
size INTEGER NOT NULL,
|
|
35
|
+
path TEXT NOT NULL
|
|
36
|
+
);
|
|
37
|
+
CREATE TABLE IF NOT EXISTS findings(
|
|
38
|
+
id INTEGER PRIMARY KEY,
|
|
39
|
+
message_id INTEGER NOT NULL REFERENCES messages(id),
|
|
40
|
+
stage TEXT NOT NULL,
|
|
41
|
+
gate_id TEXT NOT NULL,
|
|
42
|
+
severity TEXT NOT NULL,
|
|
43
|
+
summary TEXT NOT NULL,
|
|
44
|
+
created_at TEXT NOT NULL
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS approvals(
|
|
47
|
+
id INTEGER PRIMARY KEY,
|
|
48
|
+
message_id INTEGER NOT NULL REFERENCES messages(id),
|
|
49
|
+
stage TEXT NOT NULL,
|
|
50
|
+
gate_id TEXT NOT NULL,
|
|
51
|
+
approved_by TEXT NOT NULL,
|
|
52
|
+
role TEXT NOT NULL,
|
|
53
|
+
note TEXT,
|
|
54
|
+
created_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE TABLE IF NOT EXISTS notices(
|
|
57
|
+
id INTEGER PRIMARY KEY,
|
|
58
|
+
message_id INTEGER NOT NULL REFERENCES messages(id),
|
|
59
|
+
adapter TEXT NOT NULL,
|
|
60
|
+
notice_type TEXT NOT NULL,
|
|
61
|
+
fields_json TEXT NOT NULL,
|
|
62
|
+
missing_json TEXT NOT NULL,
|
|
63
|
+
created_at TEXT NOT NULL
|
|
64
|
+
);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS classifications(
|
|
66
|
+
id INTEGER PRIMARY KEY,
|
|
67
|
+
attachment_id INTEGER NOT NULL REFERENCES attachments(id),
|
|
68
|
+
label TEXT NOT NULL,
|
|
69
|
+
tier TEXT NOT NULL,
|
|
70
|
+
applied INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
applied_by TEXT,
|
|
72
|
+
applied_role TEXT,
|
|
73
|
+
applied_at TEXT,
|
|
74
|
+
created_at TEXT NOT NULL
|
|
75
|
+
);
|
|
76
|
+
CREATE TABLE IF NOT EXISTS imap_state(
|
|
77
|
+
mailbox TEXT PRIMARY KEY,
|
|
78
|
+
uidvalidity INTEGER,
|
|
79
|
+
last_uid INTEGER NOT NULL DEFAULT 0
|
|
80
|
+
);
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
# Statuses a message row can hold.
|
|
84
|
+
OK = "ok"
|
|
85
|
+
PENDING_REVIEW = "pending_review"
|
|
86
|
+
BLOCKED = "blocked"
|
|
87
|
+
DONE = "done"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def utcnow() -> str:
|
|
91
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Store:
|
|
95
|
+
def __init__(self, root: str | Path):
|
|
96
|
+
self.root = Path(root)
|
|
97
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
self.attachments_dir = self.root / "attachments"
|
|
99
|
+
self.attachments_dir.mkdir(exist_ok=True)
|
|
100
|
+
self.db = sqlite3.connect(self.root / "docketry.db")
|
|
101
|
+
self.db.row_factory = sqlite3.Row
|
|
102
|
+
self.db.executescript(SCHEMA)
|
|
103
|
+
cols = {r["name"] for r in self.db.execute("PRAGMA table_info(attachments)")}
|
|
104
|
+
if "doc_type" not in cols:
|
|
105
|
+
with self.db:
|
|
106
|
+
self.db.execute("ALTER TABLE attachments ADD COLUMN doc_type TEXT")
|
|
107
|
+
|
|
108
|
+
def close(self) -> None:
|
|
109
|
+
self.db.close()
|
|
110
|
+
|
|
111
|
+
# -- ingest ----------------------------------------------------------
|
|
112
|
+
def ingest(self, env: Envelope, *, first_stage: str) -> int | None:
|
|
113
|
+
"""Persist an envelope; return row id, or None if already seen."""
|
|
114
|
+
seen = self.db.execute(
|
|
115
|
+
"SELECT id FROM messages WHERE raw_sha256=?", (env.raw_sha256,)
|
|
116
|
+
).fetchone()
|
|
117
|
+
if seen:
|
|
118
|
+
return None
|
|
119
|
+
now = utcnow()
|
|
120
|
+
with self.db:
|
|
121
|
+
cur = self.db.execute(
|
|
122
|
+
"INSERT INTO messages(message_id, raw_sha256, source, fetched_at,"
|
|
123
|
+
" envelope_json, stage, status, updated_at) VALUES(?,?,?,?,?,?,?,?)",
|
|
124
|
+
(
|
|
125
|
+
env.message_id,
|
|
126
|
+
env.raw_sha256,
|
|
127
|
+
env.source,
|
|
128
|
+
env.fetched_at,
|
|
129
|
+
json.dumps(env.to_record()),
|
|
130
|
+
first_stage,
|
|
131
|
+
OK,
|
|
132
|
+
now,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
msg_id = cur.lastrowid
|
|
136
|
+
for a in env.attachments:
|
|
137
|
+
sub = self.attachments_dir / a.sha256[:2]
|
|
138
|
+
sub.mkdir(exist_ok=True)
|
|
139
|
+
path = sub / f"{a.sha256[:16]}_{a.filename}"
|
|
140
|
+
if not path.exists():
|
|
141
|
+
path.write_bytes(a.content)
|
|
142
|
+
self.db.execute(
|
|
143
|
+
"INSERT INTO attachments(message_id, filename, content_type,"
|
|
144
|
+
" sha256, size, path) VALUES(?,?,?,?,?,?)",
|
|
145
|
+
(msg_id, a.filename, a.content_type, a.sha256, a.size, str(path)),
|
|
146
|
+
)
|
|
147
|
+
return msg_id
|
|
148
|
+
|
|
149
|
+
# -- pipeline state --------------------------------------------------
|
|
150
|
+
def get_message(self, msg_id: int) -> sqlite3.Row | None:
|
|
151
|
+
return self.db.execute("SELECT * FROM messages WHERE id=?", (msg_id,)).fetchone()
|
|
152
|
+
|
|
153
|
+
def set_state(self, msg_id: int, *, stage: str | None = None, status: str | None = None) -> None:
|
|
154
|
+
row = self.get_message(msg_id)
|
|
155
|
+
if row is None:
|
|
156
|
+
raise KeyError(f"no message {msg_id}")
|
|
157
|
+
with self.db:
|
|
158
|
+
self.db.execute(
|
|
159
|
+
"UPDATE messages SET stage=?, status=?, updated_at=? WHERE id=?",
|
|
160
|
+
(stage or row["stage"], status or row["status"], utcnow(), msg_id),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def add_finding(self, msg_id: int, stage: str, gate_id: str, severity: str, summary: str) -> None:
|
|
164
|
+
with self.db:
|
|
165
|
+
self.db.execute(
|
|
166
|
+
"INSERT INTO findings(message_id, stage, gate_id, severity, summary,"
|
|
167
|
+
" created_at) VALUES(?,?,?,?,?,?)",
|
|
168
|
+
(msg_id, stage, gate_id, severity, summary, utcnow()),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def add_approval(
|
|
172
|
+
self, msg_id: int, stage: str, gate_id: str, *, approved_by: str, role: str, note: str = ""
|
|
173
|
+
) -> None:
|
|
174
|
+
with self.db:
|
|
175
|
+
self.db.execute(
|
|
176
|
+
"INSERT INTO approvals(message_id, stage, gate_id, approved_by, role,"
|
|
177
|
+
" note, created_at) VALUES(?,?,?,?,?,?,?)",
|
|
178
|
+
(msg_id, stage, gate_id, approved_by, role, note, utcnow()),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def approval_roles(self, msg_id: int, stage: str, gate_id: str) -> set[str]:
|
|
182
|
+
rows = self.db.execute(
|
|
183
|
+
"SELECT role FROM approvals WHERE message_id=? AND stage=? AND gate_id=?",
|
|
184
|
+
(msg_id, stage, gate_id),
|
|
185
|
+
).fetchall()
|
|
186
|
+
return {r["role"] for r in rows}
|
|
187
|
+
|
|
188
|
+
def findings_for(self, msg_id: int) -> list[sqlite3.Row]:
|
|
189
|
+
return self.db.execute(
|
|
190
|
+
"SELECT * FROM findings WHERE message_id=? ORDER BY id", (msg_id,)
|
|
191
|
+
).fetchall()
|
|
192
|
+
|
|
193
|
+
def list_by_status(self, status: str) -> list[sqlite3.Row]:
|
|
194
|
+
return self.db.execute(
|
|
195
|
+
"SELECT * FROM messages WHERE status=? ORDER BY id", (status,)
|
|
196
|
+
).fetchall()
|
|
197
|
+
|
|
198
|
+
def counts(self) -> dict[str, int]:
|
|
199
|
+
rows = self.db.execute(
|
|
200
|
+
"SELECT status, COUNT(*) n FROM messages GROUP BY status"
|
|
201
|
+
).fetchall()
|
|
202
|
+
return {r["status"]: r["n"] for r in rows}
|
|
203
|
+
|
|
204
|
+
# -- notices ---------------------------------------------------------
|
|
205
|
+
def add_notice(self, msg_id: int, adapter: str, notice_type: str,
|
|
206
|
+
fields: dict, missing: list[str]) -> None:
|
|
207
|
+
with self.db:
|
|
208
|
+
self.db.execute(
|
|
209
|
+
"INSERT INTO notices(message_id, adapter, notice_type,"
|
|
210
|
+
" fields_json, missing_json, created_at) VALUES(?,?,?,?,?,?)",
|
|
211
|
+
(msg_id, adapter, notice_type, json.dumps(fields),
|
|
212
|
+
json.dumps(missing), utcnow()),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def list_notices(self, notice_type: str | None = None) -> list[sqlite3.Row]:
|
|
216
|
+
if notice_type:
|
|
217
|
+
return self.db.execute(
|
|
218
|
+
"SELECT * FROM notices WHERE notice_type=? ORDER BY id",
|
|
219
|
+
(notice_type,),
|
|
220
|
+
).fetchall()
|
|
221
|
+
return self.db.execute("SELECT * FROM notices ORDER BY id").fetchall()
|
|
222
|
+
|
|
223
|
+
# -- classifications (stage-for-approval, fill-only) -----------------
|
|
224
|
+
def stage_classification(self, attachment_id: int, label: str, tier: str) -> int | None:
|
|
225
|
+
"""Stage a proposed doc type; skip if an open proposal already exists."""
|
|
226
|
+
row = self.db.execute(
|
|
227
|
+
"SELECT id FROM classifications WHERE attachment_id=? AND applied=0",
|
|
228
|
+
(attachment_id,),
|
|
229
|
+
).fetchone()
|
|
230
|
+
if row:
|
|
231
|
+
return None
|
|
232
|
+
with self.db:
|
|
233
|
+
cur = self.db.execute(
|
|
234
|
+
"INSERT INTO classifications(attachment_id, label, tier, created_at)"
|
|
235
|
+
" VALUES(?,?,?,?)",
|
|
236
|
+
(attachment_id, label, tier, utcnow()),
|
|
237
|
+
)
|
|
238
|
+
return cur.lastrowid
|
|
239
|
+
|
|
240
|
+
def open_classifications(self) -> list[sqlite3.Row]:
|
|
241
|
+
return self.db.execute(
|
|
242
|
+
"SELECT c.*, a.filename, a.doc_type FROM classifications c"
|
|
243
|
+
" JOIN attachments a ON a.id = c.attachment_id"
|
|
244
|
+
" WHERE c.applied=0 ORDER BY c.id"
|
|
245
|
+
).fetchall()
|
|
246
|
+
|
|
247
|
+
def apply_classification(self, class_id: int, *, by: str, role: str) -> str:
|
|
248
|
+
"""Fill-only: sets attachments.doc_type only when it is NULL."""
|
|
249
|
+
row = self.db.execute(
|
|
250
|
+
"SELECT c.*, a.doc_type FROM classifications c"
|
|
251
|
+
" JOIN attachments a ON a.id = c.attachment_id WHERE c.id=?",
|
|
252
|
+
(class_id,),
|
|
253
|
+
).fetchone()
|
|
254
|
+
if row is None:
|
|
255
|
+
raise KeyError(f"no classification {class_id}")
|
|
256
|
+
if row["applied"]:
|
|
257
|
+
return "already-applied"
|
|
258
|
+
with self.db:
|
|
259
|
+
if row["doc_type"] is None:
|
|
260
|
+
self.db.execute(
|
|
261
|
+
"UPDATE attachments SET doc_type=? WHERE id=?",
|
|
262
|
+
(row["label"], row["attachment_id"]),
|
|
263
|
+
)
|
|
264
|
+
outcome = "applied"
|
|
265
|
+
else:
|
|
266
|
+
outcome = f"kept-existing:{row['doc_type']}"
|
|
267
|
+
self.db.execute(
|
|
268
|
+
"UPDATE classifications SET applied=1, applied_by=?, applied_role=?,"
|
|
269
|
+
" applied_at=? WHERE id=?",
|
|
270
|
+
(by, role, utcnow(), class_id),
|
|
271
|
+
)
|
|
272
|
+
return outcome
|
|
273
|
+
|
|
274
|
+
def attachments_for(self, msg_id: int) -> list[sqlite3.Row]:
|
|
275
|
+
return self.db.execute(
|
|
276
|
+
"SELECT * FROM attachments WHERE message_id=? ORDER BY id", (msg_id,)
|
|
277
|
+
).fetchall()
|
|
278
|
+
|
|
279
|
+
# -- stats: queue health, not people scores --------------------------
|
|
280
|
+
def stats(self, days: int = 7) -> dict:
|
|
281
|
+
cutoff = f"-{days} days"
|
|
282
|
+
q = self.db.execute
|
|
283
|
+
ingested = q(
|
|
284
|
+
"SELECT COUNT(*) n FROM messages WHERE fetched_at >= datetime('now', ?)",
|
|
285
|
+
(cutoff,),
|
|
286
|
+
).fetchone()["n"]
|
|
287
|
+
by_status = {r["status"]: r["n"] for r in q(
|
|
288
|
+
"SELECT status, COUNT(*) n FROM messages"
|
|
289
|
+
" WHERE fetched_at >= datetime('now', ?) GROUP BY status", (cutoff,))}
|
|
290
|
+
holds_by_gate = {r["gate_id"]: r["n"] for r in q(
|
|
291
|
+
"SELECT gate_id, COUNT(DISTINCT message_id) n FROM findings"
|
|
292
|
+
" WHERE severity='fail' AND created_at >= datetime('now', ?)"
|
|
293
|
+
" GROUP BY gate_id ORDER BY n DESC", (cutoff,))}
|
|
294
|
+
notices_by_type = {r["notice_type"]: r["n"] for r in q(
|
|
295
|
+
"SELECT notice_type, COUNT(*) n FROM notices"
|
|
296
|
+
" WHERE created_at >= datetime('now', ?) GROUP BY notice_type", (cutoff,))}
|
|
297
|
+
drift = q(
|
|
298
|
+
"SELECT COUNT(DISTINCT message_id) n FROM findings"
|
|
299
|
+
" WHERE gate_id='notice-parser' AND severity='fail'"
|
|
300
|
+
" AND created_at >= datetime('now', ?)", (cutoff,),
|
|
301
|
+
).fetchone()["n"]
|
|
302
|
+
release = q(
|
|
303
|
+
"SELECT AVG((julianday(a.created_at) - julianday(m.fetched_at)) * 24) h"
|
|
304
|
+
" FROM approvals a JOIN messages m ON m.id = a.message_id"
|
|
305
|
+
" WHERE a.created_at >= datetime('now', ?)", (cutoff,),
|
|
306
|
+
).fetchone()["h"]
|
|
307
|
+
approvals_by_role = {r["role"]: r["n"] for r in q(
|
|
308
|
+
"SELECT role, COUNT(*) n FROM approvals"
|
|
309
|
+
" WHERE created_at >= datetime('now', ?) GROUP BY role", (cutoff,))}
|
|
310
|
+
classifications = {r["k"]: r["n"] for r in q(
|
|
311
|
+
"SELECT CASE WHEN applied=0 THEN 'open' ELSE 'applied' END k, COUNT(*) n"
|
|
312
|
+
" FROM classifications WHERE created_at >= datetime('now', ?)"
|
|
313
|
+
" GROUP BY k", (cutoff,))}
|
|
314
|
+
return {
|
|
315
|
+
"days": days,
|
|
316
|
+
"ingested": ingested,
|
|
317
|
+
"by_status": by_status,
|
|
318
|
+
"holds_by_gate": holds_by_gate,
|
|
319
|
+
"notices_by_type": notices_by_type,
|
|
320
|
+
"template_drift_messages": drift,
|
|
321
|
+
"avg_hours_to_release": round(release, 1) if release is not None else None,
|
|
322
|
+
"approvals_by_role": approvals_by_role,
|
|
323
|
+
"classifications": classifications,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# -- imap cursor -----------------------------------------------------
|
|
327
|
+
def imap_cursor(self, mailbox: str) -> tuple[int | None, int]:
|
|
328
|
+
row = self.db.execute(
|
|
329
|
+
"SELECT uidvalidity, last_uid FROM imap_state WHERE mailbox=?", (mailbox,)
|
|
330
|
+
).fetchone()
|
|
331
|
+
return (row["uidvalidity"], row["last_uid"]) if row else (None, 0)
|
|
332
|
+
|
|
333
|
+
def set_imap_cursor(self, mailbox: str, uidvalidity: int | None, last_uid: int) -> None:
|
|
334
|
+
with self.db:
|
|
335
|
+
self.db.execute(
|
|
336
|
+
"INSERT INTO imap_state(mailbox, uidvalidity, last_uid) VALUES(?,?,?)"
|
|
337
|
+
" ON CONFLICT(mailbox) DO UPDATE SET uidvalidity=excluded.uidvalidity,"
|
|
338
|
+
" last_uid=excluded.last_uid",
|
|
339
|
+
(mailbox, uidvalidity, last_uid),
|
|
340
|
+
)
|