@tasksai/install 0.1.38 → 0.1.40
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.
- package/README.md +34 -0
- package/bootstrap/Install RealtorTasksAI.command +37 -0
- package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
- package/package.json +14 -3
- package/runtime/document_renderer.py +882 -0
- package/runtime/server.py +55 -581
- package/runtime/software_use.py +39 -0
- package/runtime/workflow-requirements.txt +5 -0
- package/runtime/workflows/__init__.py +0 -0
- package/runtime/workflows/account_snapshot.py +33 -0
- package/runtime/workflows/attachments.py +45 -0
- package/runtime/workflows/authority_guides.py +81 -0
- package/runtime/workflows/catalog.py +51 -0
- package/runtime/workflows/catalog_app.py +174 -0
- package/runtime/workflows/catalog_generation.py +186 -0
- package/runtime/workflows/catalog_output.py +312 -0
- package/runtime/workflows/catalog_suggestions.py +51 -0
- package/runtime/workflows/catalog_workspace.py +152 -0
- package/runtime/workflows/cli.py +66 -0
- package/runtime/workflows/document_answers.py +96 -0
- package/runtime/workflows/document_selection.py +50 -0
- package/runtime/workflows/documents.py +243 -0
- package/runtime/workflows/embedded/catalog.html +83 -0
- package/runtime/workflows/embedded/offer-review.html +516 -0
- package/runtime/workflows/embedded/preview.html +36 -0
- package/runtime/workflows/embedded_actions.py +96 -0
- package/runtime/workflows/embedded_demo.py +424 -0
- package/runtime/workflows/folders.py +120 -0
- package/runtime/workflows/gateway.py +157 -0
- package/runtime/workflows/generation_lock.py +26 -0
- package/runtime/workflows/launcher.py +61 -0
- package/runtime/workflows/licensed_delivery.py +46 -0
- package/runtime/workflows/mcp_dev.py +52 -0
- package/runtime/workflows/meeting_plan.py +92 -0
- package/runtime/workflows/model_client.py +26 -0
- package/runtime/workflows/numeric_consistency.py +72 -0
- package/runtime/workflows/public_source_fetch.py +66 -0
- package/runtime/workflows/realtor/__init__.py +0 -0
- package/runtime/workflows/realtor/adapter.py +179 -0
- package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
- package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
- package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
- package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
- package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
- package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
- package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
- package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
- package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
- package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
- package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
- package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
- package/runtime/workflows/realtor-release-registry.json +705 -0
- package/runtime/workflows/released_catalog.py +91 -0
- package/runtime/workflows/source_capture.py +82 -0
- package/runtime/workflows/store.py +492 -0
- package/runtime/workflows/table_calculations.py +132 -0
- package/runtime/workflows/template.py +65 -0
- package/runtime/workflows/workspace_launch.py +76 -0
- package/src/index.js +217 -118
- package/src/managed-python.js +63 -0
- package/src/operation-lock.js +22 -0
- package/src/prepared-update.js +86 -0
- package/src/private-workflow.js +116 -0
- package/src/python-runtime.js +50 -0
- package/src/recover-installation.js +30 -0
- package/src/recovery-lock.js +30 -0
- package/src/software-hash.js +24 -0
- package/src/software-use.js +32 -0
- package/src/update-journal.js +24 -0
- package/src/update-recovery.js +72 -0
- package/src/workspace-runtime.js +45 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Local single-owner development job store. No network or licensed MCP hooks.
|
|
2
|
+
|
|
3
|
+
The OS account is the security boundary; owner labels are not authentication.
|
|
4
|
+
SQLite serializes mutations. Only a fully exported revision becomes current.
|
|
5
|
+
"""
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime, timezone, timedelta
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class JobError(ValueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def canonical(value):
|
|
22
|
+
return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def digest(value):
|
|
26
|
+
return hashlib.sha256(canonical(value).encode()).hexdigest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def file_hash(path):
|
|
30
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def now():
|
|
34
|
+
return datetime.now(timezone.utc).isoformat()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def label(value, field):
|
|
38
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 240:
|
|
39
|
+
raise JobError(f"{field} must be nonempty text of at most 240 characters")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def identifier(value):
|
|
44
|
+
if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{32}", value):
|
|
45
|
+
raise JobError("Invalid job or revision ID")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class JobStore:
|
|
50
|
+
def __init__(self, root, owner):
|
|
51
|
+
self.owner = label(owner, "owner")
|
|
52
|
+
raw = Path(root).expanduser().absolute()
|
|
53
|
+
if raw.is_symlink():
|
|
54
|
+
raise JobError("Workspace cannot be a symbolic link")
|
|
55
|
+
raw.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
56
|
+
self.root = raw.resolve()
|
|
57
|
+
self.root.chmod(0o700)
|
|
58
|
+
self.db_path = self.root / "jobs.sqlite3"
|
|
59
|
+
self._safe(self.db_path)
|
|
60
|
+
with self._db() as db:
|
|
61
|
+
db.executescript("""
|
|
62
|
+
CREATE TABLE IF NOT EXISTS workspace (owner TEXT NOT NULL);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS jobs (
|
|
64
|
+
id TEXT PRIMARY KEY, owner TEXT NOT NULL, workflow TEXT NOT NULL,
|
|
65
|
+
matter TEXT NOT NULL, current_revision TEXT, created_at TEXT NOT NULL);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
67
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL, parent TEXT,
|
|
68
|
+
created_at TEXT NOT NULL, reason TEXT NOT NULL, manifest_hash TEXT NOT NULL);
|
|
69
|
+
CREATE TABLE IF NOT EXISTS requests (
|
|
70
|
+
request_id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, response TEXT NOT NULL);
|
|
71
|
+
CREATE TABLE IF NOT EXISTS review_requests (
|
|
72
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
|
73
|
+
manifest_hash TEXT NOT NULL, scope TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
74
|
+
expires_at TEXT NOT NULL);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS review_events (
|
|
76
|
+
id TEXT PRIMARY KEY, ticket_id TEXT UNIQUE NOT NULL, job_id TEXT NOT NULL,
|
|
77
|
+
revision_id TEXT NOT NULL, manifest_hash TEXT NOT NULL, reviewer TEXT NOT NULL,
|
|
78
|
+
scope TEXT NOT NULL, outcome TEXT NOT NULL, evidence_kind TEXT NOT NULL,
|
|
79
|
+
recorded_at TEXT NOT NULL);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS concerns (
|
|
81
|
+
id TEXT PRIMARY KEY, job_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
|
82
|
+
title TEXT NOT NULL, detail TEXT NOT NULL, responsible_role TEXT NOT NULL,
|
|
83
|
+
created_at TEXT NOT NULL);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
85
|
+
id TEXT NOT NULL, job_id TEXT NOT NULL, filename TEXT NOT NULL,
|
|
86
|
+
sha256 TEXT NOT NULL, extraction_hash TEXT NOT NULL, extension TEXT NOT NULL,
|
|
87
|
+
created_at TEXT NOT NULL, PRIMARY KEY(job_id,id));
|
|
88
|
+
CREATE TABLE IF NOT EXISTS concern_events (
|
|
89
|
+
id TEXT PRIMARY KEY, concern_id TEXT NOT NULL, revision_id TEXT NOT NULL,
|
|
90
|
+
status TEXT NOT NULL, explanation TEXT NOT NULL, refs TEXT NOT NULL,
|
|
91
|
+
created_at TEXT NOT NULL);
|
|
92
|
+
CREATE TABLE IF NOT EXISTS attention_drafts (
|
|
93
|
+
job_id TEXT NOT NULL, concern_id TEXT NOT NULL, base_revision TEXT NOT NULL,
|
|
94
|
+
payload TEXT NOT NULL, updated_at TEXT NOT NULL,
|
|
95
|
+
PRIMARY KEY(job_id, concern_id));
|
|
96
|
+
""")
|
|
97
|
+
db.execute("BEGIN IMMEDIATE")
|
|
98
|
+
for table in ("review_requests", "review_events"):
|
|
99
|
+
if "concerns_hash" not in {r[1] for r in db.execute(f"PRAGMA table_info({table})")}:
|
|
100
|
+
db.execute(f"ALTER TABLE {table} ADD COLUMN concerns_hash TEXT")
|
|
101
|
+
row = db.execute("SELECT owner FROM workspace").fetchone()
|
|
102
|
+
if row is None:
|
|
103
|
+
db.execute("INSERT INTO workspace VALUES (?)", (self.owner,))
|
|
104
|
+
elif row[0] != self.owner:
|
|
105
|
+
raise JobError("This workspace belongs to another owner")
|
|
106
|
+
self.db_path.chmod(0o600)
|
|
107
|
+
|
|
108
|
+
def _safe(self, path):
|
|
109
|
+
if self.root.is_symlink():
|
|
110
|
+
raise JobError("Workspace was replaced by a symbolic link")
|
|
111
|
+
path = Path(path)
|
|
112
|
+
try:
|
|
113
|
+
relative = path.relative_to(self.root)
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise JobError("Path outside workspace") from exc
|
|
116
|
+
current = self.root
|
|
117
|
+
for part in relative.parts:
|
|
118
|
+
if part in ("..", "."):
|
|
119
|
+
raise JobError("Invalid workspace path")
|
|
120
|
+
current = current / part
|
|
121
|
+
if current.is_symlink():
|
|
122
|
+
raise JobError("Symbolic links inside the workspace are not supported")
|
|
123
|
+
return path
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def _db(self):
|
|
127
|
+
self._safe(self.db_path)
|
|
128
|
+
for suffix in ("-journal", "-wal", "-shm"):
|
|
129
|
+
self._safe(Path(str(self.db_path) + suffix))
|
|
130
|
+
db = sqlite3.connect(self.db_path, timeout=120)
|
|
131
|
+
db.row_factory = sqlite3.Row
|
|
132
|
+
try:
|
|
133
|
+
yield db
|
|
134
|
+
db.commit()
|
|
135
|
+
except BaseException:
|
|
136
|
+
db.rollback()
|
|
137
|
+
raise
|
|
138
|
+
finally:
|
|
139
|
+
db.close()
|
|
140
|
+
|
|
141
|
+
def _job(self, db, job_id):
|
|
142
|
+
identifier(job_id)
|
|
143
|
+
row = db.execute("SELECT * FROM jobs WHERE id=? AND owner=?", (job_id, self.owner)).fetchone()
|
|
144
|
+
if row is None:
|
|
145
|
+
raise JobError("Job not found in this workspace")
|
|
146
|
+
return dict(row)
|
|
147
|
+
|
|
148
|
+
def _retry(self, db, request_id, fingerprint):
|
|
149
|
+
label(request_id, "request_id")
|
|
150
|
+
row = db.execute("SELECT * FROM requests WHERE request_id=?", (request_id,)).fetchone()
|
|
151
|
+
if row:
|
|
152
|
+
if row["fingerprint"] != fingerprint:
|
|
153
|
+
raise JobError("Request ID was already used for different work")
|
|
154
|
+
return json.loads(row["response"])
|
|
155
|
+
|
|
156
|
+
def create(self, matter, request_id, workflow="realtor.seller_offer"):
|
|
157
|
+
label(matter, "matter")
|
|
158
|
+
if workflow not in ("realtor.seller_offer", "realtor.meeting_plan"):
|
|
159
|
+
from .released_catalog import registry
|
|
160
|
+
if workflow not in registry():
|
|
161
|
+
raise JobError("Unknown workflow")
|
|
162
|
+
fp = digest(["create", self.owner, matter, workflow])
|
|
163
|
+
with self._db() as db:
|
|
164
|
+
db.execute("BEGIN IMMEDIATE")
|
|
165
|
+
response = self._retry(db, request_id, fp)
|
|
166
|
+
if response:
|
|
167
|
+
return response
|
|
168
|
+
job_id = uuid.uuid4().hex
|
|
169
|
+
db.execute("INSERT INTO jobs VALUES (?,?,?,?,?,?)", (job_id, self.owner, workflow, matter, None, now()))
|
|
170
|
+
response = {"job_id": job_id, "current_revision": None}
|
|
171
|
+
db.execute("INSERT INTO requests VALUES (?,?,?)", (request_id, fp, canonical(response)))
|
|
172
|
+
return response
|
|
173
|
+
|
|
174
|
+
def _revision(self, db, job_id, revision_id):
|
|
175
|
+
identifier(revision_id)
|
|
176
|
+
row = db.execute("SELECT * FROM revisions WHERE id=? AND job_id=?", (revision_id, job_id)).fetchone()
|
|
177
|
+
if row is None:
|
|
178
|
+
raise JobError("Revision not found for this job")
|
|
179
|
+
directory = self._safe(self.root / job_id / "revisions" / revision_id)
|
|
180
|
+
manifest_path = self._safe(directory / "job-manifest.json")
|
|
181
|
+
if not manifest_path.is_file() or file_hash(manifest_path) != row["manifest_hash"]:
|
|
182
|
+
raise JobError("Revision manifest changed or is missing")
|
|
183
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
184
|
+
for expected in manifest.get('documents', []):
|
|
185
|
+
from .documents import read_document
|
|
186
|
+
document = read_document(self, db, job_id, expected['id'])
|
|
187
|
+
if any(document[key] != expected[key] for key in ('sha256', 'extraction_hash')):
|
|
188
|
+
raise JobError('Revision source document changed')
|
|
189
|
+
for name, expected in manifest["files"].items():
|
|
190
|
+
p = self._safe(directory / name)
|
|
191
|
+
if not p.is_file() or file_hash(p) != expected:
|
|
192
|
+
raise JobError(f"Revision file changed or is missing: {name}")
|
|
193
|
+
return {**dict(row), "directory": str(directory), "manifest": manifest,
|
|
194
|
+
"input": json.loads((directory / "input.json").read_text(encoding="utf-8")),
|
|
195
|
+
"result": json.loads((directory / "package" / "result.json").read_text(encoding="utf-8"))}
|
|
196
|
+
|
|
197
|
+
def read(self, job_id):
|
|
198
|
+
with self._db() as db:
|
|
199
|
+
db.execute("BEGIN")
|
|
200
|
+
job = self._job(db, job_id)
|
|
201
|
+
history = [dict(r) for r in db.execute("SELECT id,parent,created_at,reason FROM revisions WHERE job_id=? ORDER BY rowid", (job_id,))]
|
|
202
|
+
current = self._revision(db, job_id, job["current_revision"]) if job["current_revision"] else None
|
|
203
|
+
known = {r["id"] for r in history}
|
|
204
|
+
orphaned = []
|
|
205
|
+
for group in ("attempts", "revisions"):
|
|
206
|
+
folder = self._safe(self.root / job_id / group)
|
|
207
|
+
if folder.exists():
|
|
208
|
+
for p in folder.iterdir():
|
|
209
|
+
self._safe(p)
|
|
210
|
+
if group == "attempts" or p.name not in known:
|
|
211
|
+
orphaned.append(str(p))
|
|
212
|
+
reviews = [dict(r) for r in db.execute("SELECT * FROM review_events WHERE job_id=? ORDER BY rowid", (job_id,))]
|
|
213
|
+
concerns = self._concerns(db, job_id, job["current_revision"])
|
|
214
|
+
concerns_hash = digest(concerns)
|
|
215
|
+
current_reviews = [r for r in reviews if r["revision_id"] == job["current_revision"]]
|
|
216
|
+
pending = [dict(r) for r in db.execute("SELECT * FROM review_requests WHERE job_id=? AND revision_id=? AND id NOT IN (SELECT ticket_id FROM review_events) AND expires_at>?", (job_id, job["current_revision"], now()))]
|
|
217
|
+
pending = [r for r in pending if r["concerns_hash"] == concerns_hash or (r["concerns_hash"] is None and not concerns)]
|
|
218
|
+
review_status = "review_required" if reviews else "not_reviewed"
|
|
219
|
+
if current_reviews:
|
|
220
|
+
review_status = "simulated_review" if current_reviews[-1]["evidence_kind"] == "simulated_test" else current_reviews[-1]["outcome"]
|
|
221
|
+
if current_reviews[-1]["concerns_hash"] != concerns_hash and not (current_reviews[-1]["concerns_hash"] is None and not concerns):
|
|
222
|
+
review_status = "review_required"
|
|
223
|
+
elif pending:
|
|
224
|
+
review_status = "review_pending"
|
|
225
|
+
if pending and review_status == "review_required":
|
|
226
|
+
review_status = "review_pending"
|
|
227
|
+
return {**job, "history": history, "current": current,
|
|
228
|
+
"incomplete_attempts": orphaned, "review_status": review_status,
|
|
229
|
+
"review_events": reviews, "pending_reviews": pending,
|
|
230
|
+
"concerns": concerns, "concerns_hash": concerns_hash,
|
|
231
|
+
"external_actions": [], "next_step": "Resolve open questions and review the prepared files" if current else "Supply offer records"}
|
|
232
|
+
|
|
233
|
+
def list_jobs(self, limit=100, offset=0):
|
|
234
|
+
if type(limit) is not int or not 1 <= limit <= 200 or type(offset) is not int or offset < 0:
|
|
235
|
+
raise JobError("Invalid project page")
|
|
236
|
+
with self._db() as db:
|
|
237
|
+
return [dict(row) for row in db.execute("SELECT id,matter,workflow,current_revision,created_at FROM jobs WHERE owner=? ORDER BY rowid DESC LIMIT ? OFFSET ?", (self.owner,limit,offset))]
|
|
238
|
+
|
|
239
|
+
def read_revision(self, job_id, revision_id):
|
|
240
|
+
with self._db() as db:
|
|
241
|
+
self._job(db, job_id)
|
|
242
|
+
return self._revision(db, job_id, revision_id)
|
|
243
|
+
|
|
244
|
+
def _concerns(self, db, job_id, current_revision):
|
|
245
|
+
concerns = []
|
|
246
|
+
for row in db.execute("SELECT * FROM concerns WHERE job_id=? ORDER BY rowid", (job_id,)):
|
|
247
|
+
concern = dict(row)
|
|
248
|
+
events = []
|
|
249
|
+
for event in db.execute("SELECT * FROM concern_events WHERE concern_id=? ORDER BY rowid", (row["id"],)):
|
|
250
|
+
event = dict(event)
|
|
251
|
+
event["references"] = json.loads(event.pop("refs"))
|
|
252
|
+
events.append(event)
|
|
253
|
+
last = events[-1]
|
|
254
|
+
status = last["status"]
|
|
255
|
+
if status == "resolution_recorded" and last["revision_id"] != current_revision:
|
|
256
|
+
status = "needs_recheck"
|
|
257
|
+
concern.update(status=status, last_event_id=last["id"], events=events,
|
|
258
|
+
resolution_is_professional_review=False)
|
|
259
|
+
concerns.append(concern)
|
|
260
|
+
return concerns
|
|
261
|
+
|
|
262
|
+
def record_concern(self, job_id, revision_id, request_id, title, detail, responsible_role, references):
|
|
263
|
+
for key, value in (("title", title), ("detail", detail), ("responsible_role", responsible_role)):
|
|
264
|
+
label(value, key)
|
|
265
|
+
return self._change_concern(job_id, revision_id, request_id, "open", detail, references,
|
|
266
|
+
new=(title, detail, responsible_role))
|
|
267
|
+
|
|
268
|
+
def update_concern(self, job_id, revision_id, request_id, concern_id, expected_event_id, status, explanation, references):
|
|
269
|
+
identifier(concern_id)
|
|
270
|
+
identifier(expected_event_id)
|
|
271
|
+
if status not in ("open", "resolution_recorded"):
|
|
272
|
+
raise JobError("Concern status must be open or resolution_recorded")
|
|
273
|
+
label(explanation, "explanation")
|
|
274
|
+
return self._change_concern(job_id, revision_id, request_id, status, explanation, references,
|
|
275
|
+
concern_id=concern_id, expected_event_id=expected_event_id)
|
|
276
|
+
|
|
277
|
+
def _change_concern(self, job_id, revision_id, request_id, status, explanation, references, *, new=None, concern_id=None, expected_event_id=None):
|
|
278
|
+
if not isinstance(references, list) or not 1 <= len(references) <= 20:
|
|
279
|
+
raise JobError("Supply 1 to 20 source references")
|
|
280
|
+
for ref in references:
|
|
281
|
+
if not isinstance(ref, dict) or set(ref) != {"source_id", "locator"}:
|
|
282
|
+
raise JobError("Each reference requires source_id and locator")
|
|
283
|
+
label(ref["source_id"], "source_id")
|
|
284
|
+
label(ref["locator"], "locator")
|
|
285
|
+
if len({canonical(r) for r in references}) != len(references):
|
|
286
|
+
raise JobError("Duplicate concern reference")
|
|
287
|
+
fp = digest(["concern", job_id, revision_id, new, concern_id, expected_event_id, status, explanation, references])
|
|
288
|
+
with self._db() as db:
|
|
289
|
+
db.execute("BEGIN IMMEDIATE")
|
|
290
|
+
job = self._job(db, job_id)
|
|
291
|
+
retried = self._retry(db, request_id, fp)
|
|
292
|
+
if retried:
|
|
293
|
+
self._revision(db, job_id, revision_id)
|
|
294
|
+
return retried
|
|
295
|
+
if revision_id != job["current_revision"]:
|
|
296
|
+
raise JobError("Job changed; reopen before recording a concern or resolution")
|
|
297
|
+
revision = self._revision(db, job_id, revision_id)
|
|
298
|
+
sources = {s["id"]: s for s in revision["input"].get("sources", [])}
|
|
299
|
+
refs = []
|
|
300
|
+
for ref in references:
|
|
301
|
+
if ref["source_id"] not in sources:
|
|
302
|
+
raise JobError("Concern references an unknown source in this revision")
|
|
303
|
+
refs.append({**ref, "source_hash": digest(sources[ref["source_id"]])})
|
|
304
|
+
if new:
|
|
305
|
+
# Exact duplicate observations reuse the saved concern even with a new request ID.
|
|
306
|
+
for existing in self._concerns(db, job_id, revision_id):
|
|
307
|
+
first = existing["events"][0]
|
|
308
|
+
if (existing["title"], existing["detail"], existing["responsible_role"], first["references"]) == (*new, refs):
|
|
309
|
+
if existing["status"] == "resolution_recorded":
|
|
310
|
+
raise JobError("Concern already has a resolution; use update_concern to reopen it")
|
|
311
|
+
response = {"concern_id": existing["id"], "event_id": existing["last_event_id"], "duplicate": True}
|
|
312
|
+
db.execute("INSERT INTO requests VALUES (?,?,?)", (request_id, fp, canonical(response)))
|
|
313
|
+
return response
|
|
314
|
+
concern_id = uuid.uuid4().hex
|
|
315
|
+
db.execute("INSERT INTO concerns VALUES (?,?,?,?,?,?,?)", (concern_id, job_id, revision_id, *new, now()))
|
|
316
|
+
else:
|
|
317
|
+
existing = next((c for c in self._concerns(db, job_id, revision_id) if c["id"] == concern_id), None)
|
|
318
|
+
if existing is None:
|
|
319
|
+
raise JobError("Concern not found for this job")
|
|
320
|
+
if existing["last_event_id"] != expected_event_id:
|
|
321
|
+
raise JobError("Concern changed; reopen before updating it")
|
|
322
|
+
event_id = uuid.uuid4().hex
|
|
323
|
+
db.execute("INSERT INTO concern_events VALUES (?,?,?,?,?,?,?)", (event_id, concern_id, revision_id, status, explanation, canonical(refs), now()))
|
|
324
|
+
response = {"concern_id": concern_id, "event_id": event_id, "duplicate": False}
|
|
325
|
+
db.execute("INSERT INTO requests VALUES (?,?,?)", (request_id, fp, canonical(response)))
|
|
326
|
+
return response
|
|
327
|
+
|
|
328
|
+
def request_review(self, job_id, revision_id, request_id, scope):
|
|
329
|
+
label(scope, "scope")
|
|
330
|
+
fp = digest(["request_review", job_id, revision_id, scope])
|
|
331
|
+
with self._db() as db:
|
|
332
|
+
db.execute("BEGIN IMMEDIATE")
|
|
333
|
+
job = self._job(db, job_id)
|
|
334
|
+
if revision_id != job["current_revision"]:
|
|
335
|
+
raise JobError("Only the current revision can be submitted for review")
|
|
336
|
+
revision = self._revision(db, job_id, revision_id)
|
|
337
|
+
retried = self._retry(db, request_id, fp)
|
|
338
|
+
if retried:
|
|
339
|
+
return retried
|
|
340
|
+
ticket = {"id": uuid.uuid4().hex, "job_id": job_id, "revision_id": revision_id,
|
|
341
|
+
"manifest_hash": revision["manifest_hash"], "scope": scope, "created_at": now(),
|
|
342
|
+
"expires_at": (datetime.now(timezone.utc) + timedelta(hours=24)).isoformat(),
|
|
343
|
+
"concerns_hash": digest(self._concerns(db, job_id, revision_id))}
|
|
344
|
+
db.execute("INSERT INTO review_requests VALUES (?,?,?,?,?,?,?,?)", tuple(ticket.values()))
|
|
345
|
+
db.execute("INSERT INTO requests VALUES (?,?,?)", (request_id, fp, canonical(ticket)))
|
|
346
|
+
return ticket
|
|
347
|
+
|
|
348
|
+
def review_ticket(self, ticket_id):
|
|
349
|
+
identifier(ticket_id)
|
|
350
|
+
with self._db() as db:
|
|
351
|
+
row = db.execute("SELECT * FROM review_requests WHERE id=?", (ticket_id,)).fetchone()
|
|
352
|
+
if row is None:
|
|
353
|
+
raise JobError("Review request not found")
|
|
354
|
+
self._job(db, row["job_id"])
|
|
355
|
+
return dict(row)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def review_attestation(ticket, outcome):
|
|
359
|
+
return f"I personally reviewed revision {ticket['revision_id']} and concerns {ticket.get('concerns_hash') or 'none (legacy request)'} for {ticket['scope']}; outcome {outcome}"
|
|
360
|
+
|
|
361
|
+
def record_local_review(self, ticket_id, reviewer, outcome, attestation, *, evidence_kind="local_attestation"):
|
|
362
|
+
"""Review attestation entry point; not exposed as a model-callable tool.
|
|
363
|
+
|
|
364
|
+
The CLI requires an interactive terminal; the private card requires explicit confirmation. This is a recorded self-attestation,
|
|
365
|
+
not identity verification or independent proof that review was performed.
|
|
366
|
+
"""
|
|
367
|
+
identifier(ticket_id)
|
|
368
|
+
label(reviewer, "reviewer")
|
|
369
|
+
if outcome not in ("review_recorded", "changes_requested") or evidence_kind not in ("local_attestation", "card_attestation", "simulated_test"):
|
|
370
|
+
raise JobError("Invalid review outcome or evidence kind")
|
|
371
|
+
with self._db() as db:
|
|
372
|
+
db.execute("BEGIN IMMEDIATE")
|
|
373
|
+
row = db.execute("SELECT * FROM review_requests WHERE id=?", (ticket_id,)).fetchone()
|
|
374
|
+
if row is None:
|
|
375
|
+
raise JobError("Review request not found")
|
|
376
|
+
ticket = dict(row)
|
|
377
|
+
job = self._job(db, ticket["job_id"])
|
|
378
|
+
if job["current_revision"] != ticket["revision_id"]:
|
|
379
|
+
raise JobError("Package changed; review the current revision")
|
|
380
|
+
revision = self._revision(db, ticket["job_id"], ticket["revision_id"])
|
|
381
|
+
if revision["manifest_hash"] != ticket["manifest_hash"]:
|
|
382
|
+
raise JobError("Package hash changed")
|
|
383
|
+
concerns = self._concerns(db, ticket["job_id"], ticket["revision_id"])
|
|
384
|
+
if ticket["concerns_hash"] != digest(concerns) and not (ticket["concerns_hash"] is None and not concerns):
|
|
385
|
+
raise JobError("Review concerns changed; request a new review")
|
|
386
|
+
if attestation != self.review_attestation(ticket, outcome):
|
|
387
|
+
raise JobError("The exact review attestation is required")
|
|
388
|
+
previous = db.execute("SELECT * FROM review_events WHERE ticket_id=?", (ticket_id,)).fetchone()
|
|
389
|
+
if previous:
|
|
390
|
+
event = dict(previous)
|
|
391
|
+
if (event["reviewer"],event["outcome"],event["evidence_kind"]) != (reviewer,outcome,evidence_kind):
|
|
392
|
+
raise JobError("Review request already has a different outcome")
|
|
393
|
+
return event
|
|
394
|
+
if datetime.fromisoformat(ticket["expires_at"]) <= datetime.now(timezone.utc):
|
|
395
|
+
raise JobError("Review request expired; request a new review")
|
|
396
|
+
event = {"id": uuid.uuid4().hex, "ticket_id": ticket_id, "job_id": ticket["job_id"],
|
|
397
|
+
"revision_id": ticket["revision_id"], "manifest_hash": ticket["manifest_hash"],
|
|
398
|
+
"reviewer": reviewer, "scope": ticket["scope"], "outcome": outcome,
|
|
399
|
+
"evidence_kind": evidence_kind, "recorded_at": now(), "concerns_hash": digest(concerns)}
|
|
400
|
+
db.execute("INSERT INTO review_events VALUES (?,?,?,?,?,?,?,?,?,?,?)", tuple(event.values()))
|
|
401
|
+
return event
|
|
402
|
+
|
|
403
|
+
def prepare(self, job_id, packet, expected_parent, request_id, reason, adapter, *, node=None, concern_note=None):
|
|
404
|
+
label(reason, "reason")
|
|
405
|
+
raw = canonical(packet)
|
|
406
|
+
if len(raw.encode()) > 2_000_000:
|
|
407
|
+
raise JobError("Structured input exceeds 2 MB")
|
|
408
|
+
fp = digest(["prepare", job_id, expected_parent, packet, reason, adapter.version] + ([concern_note] if concern_note is not None else []))
|
|
409
|
+
with self._db() as db:
|
|
410
|
+
db.execute("BEGIN IMMEDIATE")
|
|
411
|
+
job = self._job(db, job_id)
|
|
412
|
+
if job["workflow"] != adapter.workflow_id:
|
|
413
|
+
raise JobError("Wrong workflow adapter")
|
|
414
|
+
response = self._retry(db, request_id, fp)
|
|
415
|
+
if response:
|
|
416
|
+
revision = self._revision(db, job_id, response["revision_id"])
|
|
417
|
+
# A restored workspace can have a different root. Resolve the
|
|
418
|
+
# verified revision here instead of replaying its old address.
|
|
419
|
+
return {**response, "directory": revision["directory"]}
|
|
420
|
+
if expected_parent != job["current_revision"]:
|
|
421
|
+
raise JobError("Job changed; reopen it before preparing another revision")
|
|
422
|
+
parent = self._revision(db, job_id, expected_parent) if expected_parent else None
|
|
423
|
+
supplied = json.loads(raw)
|
|
424
|
+
from .documents import bind_documents
|
|
425
|
+
documents = bind_documents(self, db, job_id, supplied)
|
|
426
|
+
normalized, result, changes = adapter.prepare(supplied, parent)
|
|
427
|
+
revision_id = uuid.uuid4().hex
|
|
428
|
+
if concern_note is not None:
|
|
429
|
+
# Save the explanation and the exported snapshot in one transaction.
|
|
430
|
+
# A user clarification stays open for review; it cannot certify itself.
|
|
431
|
+
if not isinstance(concern_note, dict) or set(concern_note) != {"concern_id", "expected_event_id", "explanation", "source_id", "locator"}:
|
|
432
|
+
raise JobError("Invalid concern note")
|
|
433
|
+
for field, value in concern_note.items():
|
|
434
|
+
label(value, field)
|
|
435
|
+
existing = next((c for c in self._concerns(db, job_id, expected_parent) if c["id"] == concern_note["concern_id"]), None)
|
|
436
|
+
if existing is None or existing["last_event_id"] != concern_note["expected_event_id"]:
|
|
437
|
+
raise JobError("Concern changed; reopen the project before saving")
|
|
438
|
+
source = next((s for s in normalized.get("sources", []) if s["id"] == concern_note["source_id"]), None)
|
|
439
|
+
if source is None:
|
|
440
|
+
raise JobError("Clarification source is missing")
|
|
441
|
+
refs = [{"source_id": source["id"], "locator": concern_note["locator"], "source_hash": digest(source)}]
|
|
442
|
+
db.execute("INSERT INTO concern_events VALUES (?,?,?,?,?,?,?)", (uuid.uuid4().hex, existing["id"], revision_id, "open", concern_note["explanation"], canonical(refs), now()))
|
|
443
|
+
db.execute("DELETE FROM attention_drafts WHERE job_id=? AND concern_id=?", (job_id, existing["id"]))
|
|
444
|
+
staging = self._safe(self.root / job_id / "attempts" / revision_id)
|
|
445
|
+
staging.mkdir(parents=True, mode=0o700)
|
|
446
|
+
context = {"job_id": job_id, "revision_id": revision_id, "parent_revision": expected_parent,
|
|
447
|
+
"reason": reason, "changes": changes, "review_status": "not_reviewed",
|
|
448
|
+
"concerns": self._concerns(db, job_id, revision_id)}
|
|
449
|
+
try:
|
|
450
|
+
(staging / "input.json").write_text(canonical(normalized) + "\n", encoding="utf-8")
|
|
451
|
+
(staging / "changes.json").write_text(canonical(context) + "\n", encoding="utf-8")
|
|
452
|
+
adapter.export(staging / "input.json", staging / "package", context, node=node)
|
|
453
|
+
adapter.validate(staging / "package", result)
|
|
454
|
+
(staging / "changes.md").write_text("# Package changes\n\n" + "\n".join("- " + c for c in changes) + "\n", encoding="utf-8")
|
|
455
|
+
manifest = {**context, "workflow_id": adapter.workflow_id, "workflow_version": adapter.version,
|
|
456
|
+
"documents": documents,
|
|
457
|
+
"implementation_hashes": adapter.hashes(), "created_at": now(),
|
|
458
|
+
"source_versions": [{"id": s["id"], "sha256": digest(s)} for s in normalized.get("sources", [])],
|
|
459
|
+
"files": {}}
|
|
460
|
+
for p in sorted(staging.rglob("*")):
|
|
461
|
+
self._safe(p)
|
|
462
|
+
if p.is_file():
|
|
463
|
+
p.chmod(0o600)
|
|
464
|
+
manifest["files"][str(p.relative_to(staging))] = file_hash(p)
|
|
465
|
+
elif p.is_dir():
|
|
466
|
+
p.chmod(0o700)
|
|
467
|
+
manifest_path = staging / "job-manifest.json"
|
|
468
|
+
manifest_path.write_text(canonical(manifest) + "\n", encoding="utf-8")
|
|
469
|
+
manifest_path.chmod(0o600)
|
|
470
|
+
# Flush artifacts before publishing the pointer in SQLite.
|
|
471
|
+
for p in staging.rglob("*"):
|
|
472
|
+
if p.is_file():
|
|
473
|
+
# Windows _commit requires a writable descriptor. These
|
|
474
|
+
# are newly generated staging files, not source files.
|
|
475
|
+
with p.open("r+b") as stream:
|
|
476
|
+
os.fsync(stream.fileno())
|
|
477
|
+
manifest_hash = file_hash(manifest_path)
|
|
478
|
+
final = self._safe(self.root / job_id / "revisions" / revision_id)
|
|
479
|
+
final.parent.mkdir(exist_ok=True, mode=0o700)
|
|
480
|
+
staging.rename(final)
|
|
481
|
+
db.execute("INSERT INTO revisions VALUES (?,?,?,?,?,?)", (revision_id, job_id, expected_parent, manifest["created_at"], reason, manifest_hash))
|
|
482
|
+
db.execute("UPDATE jobs SET current_revision=? WHERE id=?", (revision_id, job_id))
|
|
483
|
+
response = {"job_id": job_id, "revision_id": revision_id, "directory": str(final)}
|
|
484
|
+
db.execute("INSERT INTO requests VALUES (?,?,?)", (request_id, fp, canonical(response)))
|
|
485
|
+
return response
|
|
486
|
+
except BaseException:
|
|
487
|
+
if staging.exists():
|
|
488
|
+
(staging / "INCOMPLETE.txt").write_text("Preparation interrupted. Not a current package. Retry with the same request.\n", encoding="utf-8")
|
|
489
|
+
for p in staging.rglob("*"):
|
|
490
|
+
if p.is_file() and not p.is_symlink():
|
|
491
|
+
p.chmod(0o600)
|
|
492
|
+
raise
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Bounded local arithmetic shared by Word snapshots and Excel formulas."""
|
|
2
|
+
import ast
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from datetime import date
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from statistics import median
|
|
8
|
+
from .store import JobError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def calculated_table(table):
|
|
12
|
+
rows=deepcopy(table['rows']); specs=table.get('calculations',[])
|
|
13
|
+
if not isinstance(specs,list) or len(specs)>200:
|
|
14
|
+
raise JobError('A table supports up to 200 calculated cells')
|
|
15
|
+
targets={}; parsed={}; compiled={}; visiting=set(); done=set()
|
|
16
|
+
def coordinate(row,column):
|
|
17
|
+
if type(row) is not int or type(column) is not int or not 1<=row<=len(rows) or not 1<=column<=len(table['columns']):
|
|
18
|
+
raise JobError('Calculation cell is outside its table')
|
|
19
|
+
return (row-1,column-1)
|
|
20
|
+
def ref(name):
|
|
21
|
+
match=re.fullmatch(r'R([1-9]\d*)C([1-9]\d*)',name)
|
|
22
|
+
if not match:raise JobError('Use table references such as R1C2')
|
|
23
|
+
return coordinate(int(match[1]),int(match[2]))
|
|
24
|
+
def excel(key):
|
|
25
|
+
r,c=key;name='';c+=1
|
|
26
|
+
while c:c,n=divmod(c-1,26);name=chr(65+n)+name
|
|
27
|
+
return name+str(r+3)
|
|
28
|
+
for spec in specs:
|
|
29
|
+
if not isinstance(spec,dict) or set(spec)!={'row','column','expression'}:
|
|
30
|
+
raise JobError('Calculated cells need row, column and expression')
|
|
31
|
+
key=coordinate(spec['row'],spec['column'])
|
|
32
|
+
if key in targets:raise JobError('Calculation target is repeated')
|
|
33
|
+
expression=spec['expression']
|
|
34
|
+
if not isinstance(expression,str) or not 1<=len(expression)<=500:raise JobError('Calculation expression exceeds its limit')
|
|
35
|
+
try:tree=ast.parse(expression,mode='eval')
|
|
36
|
+
except (SyntaxError,ValueError,RecursionError):raise JobError('Invalid calculation expression') from None
|
|
37
|
+
if sum(1 for _ in ast.walk(tree))>100:raise JobError('Calculation is too complex')
|
|
38
|
+
targets[key]=spec;parsed[key]=tree.body
|
|
39
|
+
def evaluate(key,depth=0):
|
|
40
|
+
if key in done:return rows[key[0]][key[1]]
|
|
41
|
+
if depth>100 or key in visiting:raise JobError('Circular or excessively deep table calculation')
|
|
42
|
+
if key not in targets:return rows[key[0]][key[1]]
|
|
43
|
+
visiting.add(key);references=set();denominators=[];empty_aggregates=[];date_guards=[]
|
|
44
|
+
def walk(node,level=0):
|
|
45
|
+
if level>20:raise JobError('Calculation nesting exceeds its limit')
|
|
46
|
+
if isinstance(node,ast.Name):
|
|
47
|
+
cell=ref(node.id);references.add(cell);value=evaluate(cell,depth+1)
|
|
48
|
+
return value if type(value) in (int,float) else None,excel(cell)
|
|
49
|
+
if isinstance(node,ast.Constant) and type(node.value) in (int,float):
|
|
50
|
+
if abs(node.value)>1e15 or not math.isfinite(node.value):raise JobError('Calculation constant is outside the supported range')
|
|
51
|
+
return node.value,str(node.value)
|
|
52
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id=='COUNT_BETWEEN':
|
|
53
|
+
if node.keywords or len(node.args)<3 or any(not isinstance(arg,ast.Name) for arg in node.args[2:]):
|
|
54
|
+
raise JobError('COUNT_BETWEEN needs two numeric bounds and explicit table cells')
|
|
55
|
+
bounds=node.args[:2]
|
|
56
|
+
parts=[walk(arg,level+1) for arg in bounds]
|
|
57
|
+
(lower,low_text),(upper,high_text)=parts
|
|
58
|
+
constant_bounds=all(isinstance(arg,ast.Constant) for arg in bounds)
|
|
59
|
+
if constant_bounds and lower>upper:
|
|
60
|
+
raise JobError('COUNT_BETWEEN bounds are invalid')
|
|
61
|
+
valid=lower is not None and upper is not None and lower<=upper
|
|
62
|
+
cells=[ref(arg.id) for arg in node.args[2:]]
|
|
63
|
+
if len(set(cells))!=len(cells):raise JobError('Aggregate cell references must not repeat')
|
|
64
|
+
values=[evaluate(cell,depth+1) for cell in cells]
|
|
65
|
+
count=sum(type(v) in (int,float) and lower<=v<=upper for v in values) if valid else None
|
|
66
|
+
# ISNUMBER excludes empty/text cells, even when zero is in range.
|
|
67
|
+
terms=[f'IF(ISNUMBER({excel(cell)}),IF(AND({excel(cell)}>={low_text},{excel(cell)}<={high_text}),1,0),0)' for cell in cells]
|
|
68
|
+
return count,f'IF({low_text}<={high_text},SUM('+','.join(terms)+'),"")'
|
|
69
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id=='DATE':
|
|
70
|
+
if node.keywords or len(node.args)!=3 or any(not isinstance(arg,ast.Name) for arg in node.args):
|
|
71
|
+
raise JobError('DATE needs three table cells containing year, month and day')
|
|
72
|
+
parts=[walk(arg,level+1) for arg in node.args]
|
|
73
|
+
values=[value for value,_ in parts];texts=[text for _,text in parts]
|
|
74
|
+
formula='DATE('+','.join(texts)+')';value=None
|
|
75
|
+
if all(type(v) in (int,float) and math.isfinite(v) and v==int(v) for v in values):
|
|
76
|
+
try:
|
|
77
|
+
calendar=date(*(int(v) for v in values))
|
|
78
|
+
if calendar>=date(1900,3,1):value=(calendar-date(1899,12,30)).days
|
|
79
|
+
except (ValueError,OverflowError):pass
|
|
80
|
+
# Excel DATE normalizes invalid month/day values; require exact
|
|
81
|
+
# round-trip components so an invalid date cannot shift a deadline.
|
|
82
|
+
checks=[f'{text}=INT({text})' for text in texts]
|
|
83
|
+
checks += [f'{part}({formula})={text}' for part,text in zip(('YEAR','MONTH','DAY'),texts)]
|
|
84
|
+
checks.append(f'{formula}>=61')
|
|
85
|
+
date_guards.append('AND('+','.join(checks)+')')
|
|
86
|
+
return value,formula
|
|
87
|
+
if isinstance(node,ast.Call) and isinstance(node.func,ast.Name) and node.func.id in ('SUM','COUNT','AVERAGE','MEDIAN'):
|
|
88
|
+
# Explicit cell lists only: no ranges, external references, code,
|
|
89
|
+
# keyword arguments or arbitrary nested expressions.
|
|
90
|
+
if node.keywords or not node.args or any(not isinstance(arg,ast.Name) for arg in node.args):
|
|
91
|
+
raise JobError('Aggregate calculations need an explicit list of table cells')
|
|
92
|
+
cells=[ref(arg.id) for arg in node.args]
|
|
93
|
+
if len(set(cells))!=len(cells):raise JobError('Aggregate cell references must not repeat')
|
|
94
|
+
values=[evaluate(cell,depth+1) for cell in cells]
|
|
95
|
+
values=[value for value in values if type(value) in (int,float)]
|
|
96
|
+
args=','.join(excel(cell) for cell in cells)
|
|
97
|
+
count=len(values);name=node.func.id
|
|
98
|
+
if name=='COUNT':return count,f'COUNT({args})'
|
|
99
|
+
empty_aggregates.append(args)
|
|
100
|
+
value=(median(values) if name=='MEDIAN' else sum(values)/(count if name=='AVERAGE' else 1)) if count else None
|
|
101
|
+
if value is not None and (not math.isfinite(value) or abs(value)>1e15):
|
|
102
|
+
raise JobError('Calculated result is outside the supported range')
|
|
103
|
+
# Empty totals stay unknown; averages exclude unknown entries.
|
|
104
|
+
# Aggregate cells deliberately bypass the scalar all-known guard.
|
|
105
|
+
return value,f'IF(COUNT({args})=0,"",{name}({args}))'
|
|
106
|
+
if isinstance(node,ast.UnaryOp) and isinstance(node.op,(ast.UAdd,ast.USub)):
|
|
107
|
+
value,text=walk(node.operand,level+1);sign='-' if isinstance(node.op,ast.USub) else '+'
|
|
108
|
+
return (None if value is None else (-value if sign=='-' else value)),f'({sign}{text})'
|
|
109
|
+
if isinstance(node,ast.BinOp) and isinstance(node.op,(ast.Add,ast.Sub,ast.Mult,ast.Div)):
|
|
110
|
+
left,a=walk(node.left,level+1);right,b=walk(node.right,level+1)
|
|
111
|
+
op={ast.Add:'+',ast.Sub:'-',ast.Mult:'*',ast.Div:'/'}[type(node.op)]
|
|
112
|
+
if op=='/':denominators.append(b)
|
|
113
|
+
value=None
|
|
114
|
+
if left is not None and right is not None and not (op=='/' and right==0):
|
|
115
|
+
value={'+' : lambda:left+right,'-':lambda:left-right,'*':lambda:left*right,'/':lambda:left/right}[op]()
|
|
116
|
+
if not math.isfinite(value) or abs(value)>1e15:raise JobError('Calculated result is outside the supported range')
|
|
117
|
+
return value,f'({a}{op}{b})'
|
|
118
|
+
raise JobError('Calculations allow numbers, table cells, +, -, *, /, DATE, COUNT_BETWEEN and SUM, COUNT, AVERAGE or MEDIAN of explicit table cells')
|
|
119
|
+
value,formula=walk(parsed[key])
|
|
120
|
+
# Inner divisions are checked before expressions that depend on them.
|
|
121
|
+
for denominator in reversed(denominators):formula=f'IF({denominator}=0,"",{formula})'
|
|
122
|
+
for args in empty_aggregates:formula=f'IF(COUNT({args})=0,"",{formula})'
|
|
123
|
+
for guard in date_guards:formula=f'IF({guard},{formula},"")'
|
|
124
|
+
if date_guards:formula=f'IFERROR({formula},"")'
|
|
125
|
+
if references:
|
|
126
|
+
cells=','.join(excel(k) for k in sorted(references))
|
|
127
|
+
formula=f'IF(COUNT({cells})={len(references)},{formula},"")'
|
|
128
|
+
if len(formula)>8000:raise JobError('Compiled calculation exceeds the Excel limit')
|
|
129
|
+
rows[key[0]][key[1]]=value;compiled[key]='='+formula
|
|
130
|
+
visiting.remove(key);done.add(key);return value
|
|
131
|
+
for key in targets:evaluate(key)
|
|
132
|
+
return {**table,'rows':rows},compiled
|