deepcell-cli 0.6.1__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.
- deepcell_cli/__init__.py +12 -0
- deepcell_cli/__main__.py +5 -0
- deepcell_cli/_findings.py +84 -0
- deepcell_cli/capabilities.py +560 -0
- deepcell_cli/capability-contract.json +15622 -0
- deepcell_cli/client.py +503 -0
- deepcell_cli/commands/__init__.py +1 -0
- deepcell_cli/commands/_batch_input.py +29 -0
- deepcell_cli/commands/_datatypes.py +56 -0
- deepcell_cli/commands/_negative_args.py +133 -0
- deepcell_cli/commands/_swapped_args.py +153 -0
- deepcell_cli/commands/_version_display.py +40 -0
- deepcell_cli/commands/_write_opts.py +139 -0
- deepcell_cli/commands/account.py +123 -0
- deepcell_cli/commands/auth.py +610 -0
- deepcell_cli/commands/changes.py +307 -0
- deepcell_cli/commands/deck.py +594 -0
- deepcell_cli/commands/defs.py +3890 -0
- deepcell_cli/commands/describe.py +902 -0
- deepcell_cli/commands/doc.py +529 -0
- deepcell_cli/commands/doctor.py +257 -0
- deepcell_cli/commands/download.py +36 -0
- deepcell_cli/commands/edit.py +384 -0
- deepcell_cli/commands/example.py +161 -0
- deepcell_cli/commands/export.py +81 -0
- deepcell_cli/commands/export_docx.py +57 -0
- deepcell_cli/commands/export_pdf.py +66 -0
- deepcell_cli/commands/export_pptx.py +45 -0
- deepcell_cli/commands/files.py +386 -0
- deepcell_cli/commands/grep.py +90 -0
- deepcell_cli/commands/guide.py +431 -0
- deepcell_cli/commands/help_cmd.py +348 -0
- deepcell_cli/commands/impact.py +382 -0
- deepcell_cli/commands/import_cmd.py +208 -0
- deepcell_cli/commands/ingest.py +110 -0
- deepcell_cli/commands/merge.py +399 -0
- deepcell_cli/commands/query.py +718 -0
- deepcell_cli/commands/reasoning.py +2981 -0
- deepcell_cli/commands/ref.py +279 -0
- deepcell_cli/commands/replace.py +326 -0
- deepcell_cli/commands/rules.py +206 -0
- deepcell_cli/commands/share.py +186 -0
- deepcell_cli/commands/sync.py +804 -0
- deepcell_cli/commands/upgrade.py +185 -0
- deepcell_cli/commands/variant.py +353 -0
- deepcell_cli/commands/version.py +445 -0
- deepcell_cli/commands/viewer.py +54 -0
- deepcell_cli/commands/workspace.py +101 -0
- deepcell_cli/config.py +352 -0
- deepcell_cli/context.py +187 -0
- deepcell_cli/errors.py +141 -0
- deepcell_cli/logging_setup.py +161 -0
- deepcell_cli/main.py +518 -0
- deepcell_cli/mcp_server.py +906 -0
- deepcell_cli/oauth_provider.py +580 -0
- deepcell_cli/output.py +503 -0
- deepcell_cli/revision.py +164 -0
- deepcell_cli/stages.py +223 -0
- deepcell_cli/surface.py +628 -0
- deepcell_cli/sync_state.py +120 -0
- deepcell_cli/upgrade_check.py +399 -0
- deepcell_cli/xml_replace.py +89 -0
- deepcell_cli-0.6.1.dist-info/METADATA +264 -0
- deepcell_cli-0.6.1.dist-info/RECORD +67 -0
- deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
- deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
- deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2981 @@
|
|
|
1
|
+
"""CLI subcommands for Reasoning section (spec §6.3).
|
|
2
|
+
|
|
3
|
+
Every command here addresses a file by its WORKSPACE filename, the same way
|
|
4
|
+
`deepcell query` / `cat` / `ls` do. The `claim` / `assumption` inspection
|
|
5
|
+
groups used to take a local `--file` path instead, which made them unusable
|
|
6
|
+
from MCP (no checkout) and collided with the root `-f/--format`; they now
|
|
7
|
+
fetch the XML through the API and parse it in-process. `reasoning-diff` is
|
|
8
|
+
the one exception and is local by nature — it compares a working-tree file
|
|
9
|
+
against its own git HEAD.
|
|
10
|
+
|
|
11
|
+
Implementation note: parsing is done directly with stdlib xml.etree.ElementTree
|
|
12
|
+
rather than importing the full backend DeepCellDocument stack, which carries
|
|
13
|
+
heavy optional dependencies (defusedxml, etc.) not installed in the CLI venv.
|
|
14
|
+
The logic mirrors backend/src/core/document/queries.py (get_claims,
|
|
15
|
+
get_arguments, get_supersedes_chain, get_assumption_impact) and
|
|
16
|
+
backend/src/core/validation/reasoning_diff.py (compute_reasoning_diff).
|
|
17
|
+
|
|
18
|
+
KNOWN DUPLICATION (Phase 4 / I5)
|
|
19
|
+
================================
|
|
20
|
+
This module re-implements ~300 lines of XML traversal that lives canonically in
|
|
21
|
+
backend/src/core/document/queries.py and backend/src/core/validation/reasoning_diff.py.
|
|
22
|
+
Phase 4 deliberately defers the full collapse — the JS-side SDK
|
|
23
|
+
(@deepcell/spreadsheet-sdk) does not yet publish a Python-native helper, and
|
|
24
|
+
adding the backend stack as a CLI dependency carries heavy optional deps
|
|
25
|
+
(defusedxml, lxml, calc-engine) that double the install footprint.
|
|
26
|
+
|
|
27
|
+
Migration plan once a thin shared helper exists:
|
|
28
|
+
1. Publish a small `deepcell_reasoning` python package (or factor a
|
|
29
|
+
dependency-light subset of `src.core.document.queries` that only needs
|
|
30
|
+
stdlib XML) that exposes get_claims / get_arguments / get_supersedes_chain
|
|
31
|
+
/ get_assumption_impact and compute_reasoning_diff.
|
|
32
|
+
2. Replace the helpers in this file with calls into that package; keep the
|
|
33
|
+
click command wiring unchanged.
|
|
34
|
+
3. The pinning golden-file contract test in tests/test_reasoning_commands.py
|
|
35
|
+
will detect any text-output drift introduced by the swap.
|
|
36
|
+
|
|
37
|
+
Until then, every change here MUST be mirrored in the canonical backend code
|
|
38
|
+
(or vice versa). The Phase 4 codegen pipeline locks the *typed schema* shared
|
|
39
|
+
between Python and TypeScript; the *traversal logic* still lives in two places.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import subprocess
|
|
46
|
+
import sys
|
|
47
|
+
import xml.etree.ElementTree as ET
|
|
48
|
+
from dataclasses import dataclass
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import click
|
|
53
|
+
|
|
54
|
+
from ._swapped_args import FileFirstCommand, FileFirstGroup
|
|
55
|
+
from ._write_opts import FileFirstWriteCommand, WriteCommand, write_message
|
|
56
|
+
|
|
57
|
+
from deepcell_cli.commands.query import _get_xml
|
|
58
|
+
from deepcell_cli._findings import cited_rules_footer, provenance, rule_citation
|
|
59
|
+
from deepcell_cli.context import Ctx, pass_ctx
|
|
60
|
+
from deepcell_cli.output import (
|
|
61
|
+
echo_info,
|
|
62
|
+
echo_success,
|
|
63
|
+
echo_validation,
|
|
64
|
+
echo_warning,
|
|
65
|
+
output,
|
|
66
|
+
print_plain,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# File / stdin transport for free-text options
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
# Path type for every `--<option>-file` companion: `-` must pass validation
|
|
75
|
+
# (allow_dash) so stdin transport works.
|
|
76
|
+
_TEXT_FILE = click.Path(exists=True, dir_okay=False, allow_dash=True)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_text_option(
|
|
80
|
+
inline: str | None, file_path: str | None, option: str,
|
|
81
|
+
*, required: bool = False,
|
|
82
|
+
) -> str | None:
|
|
83
|
+
"""Resolve a free-text option that may arrive inline or from a file.
|
|
84
|
+
|
|
85
|
+
File / stdin ('-') transport exists because inline shell arguments mangle
|
|
86
|
+
dollar amounts: inside double quotes bash expands "$0" to the shell path
|
|
87
|
+
and "$35" to "5", which run 20260812-062931 showed corrupting stored
|
|
88
|
+
reasoning text while every deterministic check stayed green.
|
|
89
|
+
"""
|
|
90
|
+
if inline is not None and file_path is not None:
|
|
91
|
+
raise click.UsageError(
|
|
92
|
+
f"--{option} and --{option}-file are mutually exclusive"
|
|
93
|
+
)
|
|
94
|
+
if file_path is not None:
|
|
95
|
+
if file_path == "-":
|
|
96
|
+
return sys.stdin.read().rstrip("\n")
|
|
97
|
+
with open(file_path, "r", encoding="utf-8") as handle:
|
|
98
|
+
return handle.read().rstrip("\n")
|
|
99
|
+
if required and inline is None:
|
|
100
|
+
raise click.UsageError(
|
|
101
|
+
f"one of --{option} or --{option}-file is required"
|
|
102
|
+
)
|
|
103
|
+
return inline
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_single_stdin(*file_paths: str | None) -> None:
|
|
107
|
+
"""Refuse two '-' file options on one invocation — stdin can only be
|
|
108
|
+
consumed once, so the second would silently receive empty text."""
|
|
109
|
+
if sum(1 for p in file_paths if p == "-") > 1:
|
|
110
|
+
raise click.UsageError(
|
|
111
|
+
"only one option may read stdin ('-') per invocation"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _text_file_help(noun: str, inline_option: str) -> str:
|
|
116
|
+
"""Help text for a `--<option>-file` companion option."""
|
|
117
|
+
return (
|
|
118
|
+
f"File holding the {noun} text; '-' reads stdin. Prefer this over "
|
|
119
|
+
f"--{inline_option} for text containing '$' — inline shell arguments "
|
|
120
|
+
"mangle dollar amounts."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
# Lightweight local XML reader — mirrors DeepCellDocument query methods
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _attrib(el: ET.Element, name: str) -> str:
|
|
130
|
+
"""Return attribute value, checking both 'name' and '@name' forms."""
|
|
131
|
+
return el.get(name) or el.get(f"@{name}") or ""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _body_text(el: ET.Element) -> str:
|
|
135
|
+
"""Extract body text from a <Body> child element."""
|
|
136
|
+
body = el.find("Body")
|
|
137
|
+
if body is None:
|
|
138
|
+
return ""
|
|
139
|
+
return (body.text or "").strip()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_reasoning(xml: str, label: str) -> ET.Element | None:
|
|
143
|
+
"""Parse XML text and return the <Reasoning> element, or None if absent."""
|
|
144
|
+
try:
|
|
145
|
+
root = ET.fromstring(xml)
|
|
146
|
+
except ET.ParseError as exc:
|
|
147
|
+
click.echo(f"error: cannot parse '{label}': {exc}", err=True)
|
|
148
|
+
sys.exit(2)
|
|
149
|
+
return root.find("Reasoning")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _get_claims(reasoning: ET.Element) -> list[ET.Element]:
|
|
153
|
+
return reasoning.findall("Claim")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _get_arguments(reasoning: ET.Element) -> list[ET.Element]:
|
|
157
|
+
return reasoning.findall("Argument")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _get_claim_by_id(reasoning: ET.Element, claim_id: str) -> ET.Element | None:
|
|
161
|
+
for c in _get_claims(reasoning):
|
|
162
|
+
if _attrib(c, "id") == claim_id:
|
|
163
|
+
return c
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_supersedes_chain(reasoning: ET.Element, claim_id: str) -> list[ET.Element]:
|
|
168
|
+
"""Walk supersedes edges from claim_id back through history. Newest first."""
|
|
169
|
+
# Build supersedes map: from_id -> to_id
|
|
170
|
+
sup: dict[str, str] = {}
|
|
171
|
+
for a in _get_arguments(reasoning):
|
|
172
|
+
if _attrib(a, "rel") == "supersedes":
|
|
173
|
+
src = _attrib(a, "from")
|
|
174
|
+
dst = _attrib(a, "to")
|
|
175
|
+
if src:
|
|
176
|
+
sup[src] = dst
|
|
177
|
+
|
|
178
|
+
chain: list[ET.Element] = []
|
|
179
|
+
seen: set[str] = set()
|
|
180
|
+
cur = claim_id
|
|
181
|
+
while cur and cur not in seen:
|
|
182
|
+
seen.add(cur)
|
|
183
|
+
c = _get_claim_by_id(reasoning, cur)
|
|
184
|
+
if c is not None:
|
|
185
|
+
chain.append(c)
|
|
186
|
+
cur = sup.get(cur, "")
|
|
187
|
+
return chain
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _get_assumption_impact(reasoning: ET.Element, assumption_id: str) -> list[ET.Element]:
|
|
191
|
+
"""Return Claims that depend_on the given assumption_id."""
|
|
192
|
+
results: list[ET.Element] = []
|
|
193
|
+
for a in _get_arguments(reasoning):
|
|
194
|
+
if _attrib(a, "rel") == "depends_on" and _attrib(a, "to") == assumption_id:
|
|
195
|
+
src = _attrib(a, "from")
|
|
196
|
+
c = _get_claim_by_id(reasoning, src)
|
|
197
|
+
if c is not None:
|
|
198
|
+
results.append(c)
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _summary(el: ET.Element, max_len: int = 80) -> str:
|
|
203
|
+
text = _body_text(el)
|
|
204
|
+
return text[:max_len]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _require_reasoning(ctx: Ctx, filename: str) -> ET.Element:
|
|
208
|
+
"""Fetch FILENAME from the workspace, return its <Reasoning>; exit if absent."""
|
|
209
|
+
reasoning = _load_reasoning(_get_xml(ctx, filename), filename)
|
|
210
|
+
if reasoning is None:
|
|
211
|
+
click.echo("(no Reasoning section in file)")
|
|
212
|
+
sys.exit(0)
|
|
213
|
+
return reasoning
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------- claim group ----------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@click.group(cls=FileFirstGroup)
|
|
220
|
+
def claim() -> None:
|
|
221
|
+
"""Inspect Claims in a workspace .deepcell file.
|
|
222
|
+
|
|
223
|
+
Read-only views of one Claim or one status: its supersedes history, its
|
|
224
|
+
variant/consensus pair, and the falsified list. Whole-graph views and
|
|
225
|
+
every write live under `deepcell reasoning`.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@claim.command("history", cls=FileFirstCommand)
|
|
230
|
+
@click.argument("filename")
|
|
231
|
+
@click.argument("claim_id")
|
|
232
|
+
@pass_ctx
|
|
233
|
+
def claim_history(ctx: Ctx, filename: str, claim_id: str) -> None:
|
|
234
|
+
"""Walk the supersedes chain backwards from CLAIM_ID (newest first).
|
|
235
|
+
|
|
236
|
+
FILENAME is a file in the active workspace, as `deepcell ls` lists it.
|
|
237
|
+
"""
|
|
238
|
+
reasoning = _require_reasoning(ctx, filename)
|
|
239
|
+
chain = _get_supersedes_chain(reasoning, claim_id)
|
|
240
|
+
if not chain:
|
|
241
|
+
click.echo(f"claim '{claim_id}' not found", err=True)
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
for c in chain:
|
|
244
|
+
cid = _attrib(c, "id")
|
|
245
|
+
status = _attrib(c, "status") or "active"
|
|
246
|
+
click.echo(f"{cid} [{status}] {_summary(c)}")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@claim.command("variant", cls=FileFirstCommand)
|
|
250
|
+
@click.argument("filename")
|
|
251
|
+
@click.argument("claim_id")
|
|
252
|
+
@pass_ctx
|
|
253
|
+
def claim_variant(ctx: Ctx, filename: str, claim_id: str) -> None:
|
|
254
|
+
"""Show your view of CLAIM_ID beside the consensus it is a variant of.
|
|
255
|
+
|
|
256
|
+
Follows the outgoing `variant_of` Argument from CLAIM_ID in FILENAME and
|
|
257
|
+
prints both sides. Prints only your view when there is no such edge.
|
|
258
|
+
"""
|
|
259
|
+
reasoning = _require_reasoning(ctx, filename)
|
|
260
|
+
c = _get_claim_by_id(reasoning, claim_id)
|
|
261
|
+
if c is None:
|
|
262
|
+
click.echo(f"claim '{claim_id}' not found", err=True)
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
|
|
265
|
+
consensus_id: str = ""
|
|
266
|
+
for a in _get_arguments(reasoning):
|
|
267
|
+
if _attrib(a, "from") == claim_id and _attrib(a, "rel") == "variant_of":
|
|
268
|
+
consensus_id = _attrib(a, "to")
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
click.echo(f"My view ({claim_id}): {_summary(c, 120)}")
|
|
272
|
+
if consensus_id:
|
|
273
|
+
cs = _get_claim_by_id(reasoning, consensus_id)
|
|
274
|
+
if cs is not None:
|
|
275
|
+
click.echo(f"Consensus ({consensus_id}): {_summary(cs, 120)}")
|
|
276
|
+
else:
|
|
277
|
+
click.echo(f"Consensus ({consensus_id}): <not found in document>")
|
|
278
|
+
else:
|
|
279
|
+
click.echo("(no variant_of edge from this claim)")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@claim.command("falsified")
|
|
283
|
+
@click.argument("filename")
|
|
284
|
+
@pass_ctx
|
|
285
|
+
def claim_falsified(ctx: Ctx, filename: str) -> None:
|
|
286
|
+
"""List every Claim in FILENAME whose status is `falsified`.
|
|
287
|
+
|
|
288
|
+
There is no date filter: a <Claim> carries no timestamp, so when it was
|
|
289
|
+
falsified is not in the document. Use `deepcell log` / `deepcell diff` to
|
|
290
|
+
see when the status changed.
|
|
291
|
+
"""
|
|
292
|
+
reasoning = _require_reasoning(ctx, filename)
|
|
293
|
+
found = False
|
|
294
|
+
for c in _get_claims(reasoning):
|
|
295
|
+
if _attrib(c, "status") == "falsified":
|
|
296
|
+
cid = _attrib(c, "id")
|
|
297
|
+
click.echo(f"{cid} {_summary(c, 100)}")
|
|
298
|
+
found = True
|
|
299
|
+
if not found:
|
|
300
|
+
click.echo("(no falsified claims)")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------- assumption group ----------
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@click.group(cls=FileFirstGroup)
|
|
307
|
+
def assumption() -> None:
|
|
308
|
+
"""Inspect Assumptions in a workspace .deepcell file.
|
|
309
|
+
|
|
310
|
+
Assumptions are written with `deepcell reasoning add-assumption` /
|
|
311
|
+
`update-assumption`; this group only reads them back.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@assumption.command("impact", cls=FileFirstCommand)
|
|
316
|
+
@click.argument("filename")
|
|
317
|
+
@click.argument("assumption_id")
|
|
318
|
+
@pass_ctx
|
|
319
|
+
def assumption_impact(ctx: Ctx, filename: str, assumption_id: str) -> None:
|
|
320
|
+
"""List Claims that depend_on ASSUMPTION_ID.
|
|
321
|
+
|
|
322
|
+
Answers "what breaks if this assumption is wrong" for one node. Errors
|
|
323
|
+
when ASSUMPTION_ID is not in FILENAME; prints nothing and exits 0 when it
|
|
324
|
+
exists but nothing depends on it. Continue through calculations,
|
|
325
|
+
backlinks, prose, and slides with `deepcell guide revise/premise-change`.
|
|
326
|
+
"""
|
|
327
|
+
reasoning = _require_reasoning(ctx, filename)
|
|
328
|
+
# A typo'd id must error — silent-empty was indistinguishable from a real
|
|
329
|
+
# assumption with no dependents.
|
|
330
|
+
known_ids = {
|
|
331
|
+
node_id
|
|
332
|
+
for node in reasoning.iter()
|
|
333
|
+
if (node_id := _attrib(node, "id"))
|
|
334
|
+
}
|
|
335
|
+
if assumption_id not in known_ids:
|
|
336
|
+
raise click.ClickException(
|
|
337
|
+
f"Reasoning node '{assumption_id}' not found in {filename} "
|
|
338
|
+
f"({len(known_ids)} nodes)."
|
|
339
|
+
)
|
|
340
|
+
impact = _get_assumption_impact(reasoning, assumption_id)
|
|
341
|
+
if not impact:
|
|
342
|
+
# Empty output (exit 0) is intentional — the id exists, it just has
|
|
343
|
+
# no dependents.
|
|
344
|
+
return
|
|
345
|
+
for c in impact:
|
|
346
|
+
cid = _attrib(c, "id")
|
|
347
|
+
status = _attrib(c, "status") or "active"
|
|
348
|
+
click.echo(f"{cid} [{status}] {_summary(c)}")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# reasoning-diff — stdlib XML implementation (mirrors backend reasoning_diff.py)
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True)
|
|
357
|
+
class _ChangedRef:
|
|
358
|
+
drift_kind: str # "structural" | "snapshot" | "semantic" | "logical"
|
|
359
|
+
ref: str # e.g. "value:GM_PCT@Q1/projected", "item:GM", "assumption:a1"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@dataclass
|
|
363
|
+
class _DiffEntry:
|
|
364
|
+
node_id: str
|
|
365
|
+
drift_kind: str
|
|
366
|
+
change_summary: str
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _parse_xml_string(xml_text: str) -> ET.Element:
|
|
370
|
+
"""Parse an XML string and return the root element."""
|
|
371
|
+
try:
|
|
372
|
+
return ET.fromstring(xml_text)
|
|
373
|
+
except ET.ParseError as exc:
|
|
374
|
+
click.echo(f"error: cannot parse XML: {exc}", err=True)
|
|
375
|
+
sys.exit(2)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _parse_custom_dims_attr_et(attr_value: str | None) -> dict[str, str]:
|
|
379
|
+
"""Parse a customDimensions attribute string ("k:v;k:v") into a dict.
|
|
380
|
+
|
|
381
|
+
Mirrors backend/src/core/calculation/dimensions.py:parse_custom_dimensions_attr.
|
|
382
|
+
"""
|
|
383
|
+
if not attr_value:
|
|
384
|
+
return {}
|
|
385
|
+
out: dict[str, str] = {}
|
|
386
|
+
for pair in attr_value.split(";"):
|
|
387
|
+
pair = pair.strip()
|
|
388
|
+
if ":" in pair:
|
|
389
|
+
k, v = pair.split(":", 1)
|
|
390
|
+
k = k.strip()
|
|
391
|
+
v = v.strip()
|
|
392
|
+
if k:
|
|
393
|
+
out[k] = v
|
|
394
|
+
return out
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _canonical_dims_et(attr_value: str | None) -> str:
|
|
398
|
+
if not attr_value:
|
|
399
|
+
return ""
|
|
400
|
+
dims = _parse_custom_dims_attr_et(attr_value)
|
|
401
|
+
if not dims:
|
|
402
|
+
return ""
|
|
403
|
+
return ";".join(f"{k}={v}" for k, v in sorted(dims.items()))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _index_values_et(root: ET.Element) -> dict:
|
|
407
|
+
"""Return {(item, ctx, status, scenario, dims): value_str} for all Values."""
|
|
408
|
+
out: dict[tuple, str] = {}
|
|
409
|
+
values_el = root.find("Values")
|
|
410
|
+
if values_el is None:
|
|
411
|
+
return out
|
|
412
|
+
# ItemGroup-wrapped form
|
|
413
|
+
for ig in values_el.findall("ItemGroup"):
|
|
414
|
+
item = ig.get("itemRef") or ""
|
|
415
|
+
status = ig.get("statusRef") or ""
|
|
416
|
+
group_scenario = ig.get("scenarioRef") or ""
|
|
417
|
+
group_dims_attr = ig.get("customDimensions")
|
|
418
|
+
for v in ig.findall("Value"):
|
|
419
|
+
ctx = v.get("contextRef") or ""
|
|
420
|
+
v_scen = v.get("scenarioRef")
|
|
421
|
+
scenario = v_scen if v_scen else group_scenario
|
|
422
|
+
v_dims_attr = v.get("customDimensions")
|
|
423
|
+
dims_attr = v_dims_attr if v_dims_attr else group_dims_attr
|
|
424
|
+
dims_canon = _canonical_dims_et(dims_attr)
|
|
425
|
+
txt = (v.text or "").strip()
|
|
426
|
+
if item and ctx:
|
|
427
|
+
out[(item, ctx, status, scenario, dims_canon)] = txt
|
|
428
|
+
# Flat form: <Value itemRef=... contextRef=... statusRef=...>
|
|
429
|
+
for v in values_el.findall("Value"):
|
|
430
|
+
item = v.get("itemRef") or ""
|
|
431
|
+
ctx = v.get("contextRef") or ""
|
|
432
|
+
status = v.get("statusRef") or ""
|
|
433
|
+
scenario = v.get("scenarioRef") or ""
|
|
434
|
+
dims_canon = _canonical_dims_et(v.get("customDimensions"))
|
|
435
|
+
txt = (v.text or "").strip()
|
|
436
|
+
if item and ctx:
|
|
437
|
+
out[(item, ctx, status, scenario, dims_canon)] = txt
|
|
438
|
+
return out
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
_CALC_IGNORE_ATTRS_ET = frozenset({"description", "displayName"})
|
|
442
|
+
_CALC_IGNORE_CHILDREN_ET = frozenset({"Label", "Notes", "Description"})
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _index_calcs_et(root: ET.Element) -> dict:
|
|
446
|
+
"""Return {calc_id: canonical_repr} for all Calculation nodes.
|
|
447
|
+
|
|
448
|
+
Drops reorder-only attrs / children; normalizes Formula whitespace.
|
|
449
|
+
"""
|
|
450
|
+
out: dict[str, str] = {}
|
|
451
|
+
calc_defs = root.find("CalculationDefinitions")
|
|
452
|
+
if calc_defs is None:
|
|
453
|
+
return out
|
|
454
|
+
for c in calc_defs.findall("Calculation"):
|
|
455
|
+
cid = c.get("id") or ""
|
|
456
|
+
if not cid:
|
|
457
|
+
continue
|
|
458
|
+
attribs = sorted(
|
|
459
|
+
(k, v) for k, v in c.attrib.items() if k not in _CALC_IGNORE_ATTRS_ET
|
|
460
|
+
)
|
|
461
|
+
# child elements
|
|
462
|
+
child_parts: list[str] = []
|
|
463
|
+
formulas = c.findall("Formula")
|
|
464
|
+
for f in formulas:
|
|
465
|
+
txt = (f.text or "")
|
|
466
|
+
child_parts.append("Formula=" + " ".join(txt.split()))
|
|
467
|
+
other_children: list[str] = []
|
|
468
|
+
for child in c:
|
|
469
|
+
if child.tag in _CALC_IGNORE_CHILDREN_ET or child.tag == "Formula":
|
|
470
|
+
continue
|
|
471
|
+
other_children.append(f"{child.tag}={ET.tostring(child, encoding='unicode').strip()}")
|
|
472
|
+
other_children.sort()
|
|
473
|
+
out[cid] = repr(attribs) + "|" + "|".join(child_parts + other_children)
|
|
474
|
+
return out
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _index_items_et(root: ET.Element) -> set:
|
|
478
|
+
"""Return the set of all itemId values defined in ItemDefinitions."""
|
|
479
|
+
item_defs = root.find("ItemDefinitions")
|
|
480
|
+
if item_defs is None:
|
|
481
|
+
return set()
|
|
482
|
+
return {el.get("itemId") for el in item_defs.findall("Item") if el.get("itemId")}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _index_item_signatures_et(root: ET.Element) -> dict[str, tuple[str, str]]:
|
|
486
|
+
"""Return {itemId: (displayName, parentItemRef)} for rename heuristic."""
|
|
487
|
+
out: dict[str, tuple[str, str]] = {}
|
|
488
|
+
item_defs = root.find("ItemDefinitions")
|
|
489
|
+
if item_defs is None:
|
|
490
|
+
return out
|
|
491
|
+
for it in item_defs.findall("Item"):
|
|
492
|
+
iid = it.get("itemId") or ""
|
|
493
|
+
if not iid:
|
|
494
|
+
continue
|
|
495
|
+
display = it.get("displayName") or ""
|
|
496
|
+
if not display:
|
|
497
|
+
label = it.find("Label")
|
|
498
|
+
if label is not None:
|
|
499
|
+
# Match backend `_index_item_signatures` (reasoning_diff.py:230-234):
|
|
500
|
+
# an empty <Label/> yields "" rather than skipping the field.
|
|
501
|
+
display = label.text or ""
|
|
502
|
+
parent = it.get("parentItemRef") or ""
|
|
503
|
+
out[iid] = ((display or "").strip(), parent.strip())
|
|
504
|
+
return out
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _index_contexts_et(root: ET.Element) -> set:
|
|
508
|
+
ctx_defs = root.find("ContextDefinitions")
|
|
509
|
+
if ctx_defs is None:
|
|
510
|
+
return set()
|
|
511
|
+
return {el.get("contextId") for el in ctx_defs.findall("Context") if el.get("contextId")}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _index_statuses_et(root: ET.Element) -> set:
|
|
515
|
+
st_defs = root.find("StatusDefinitions")
|
|
516
|
+
if st_defs is None:
|
|
517
|
+
return set()
|
|
518
|
+
return {el.get("statusId") for el in st_defs.findall("Status") if el.get("statusId")}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _index_claim_kinds_et(root: ET.Element) -> dict:
|
|
522
|
+
out: dict[str, str] = {}
|
|
523
|
+
reasoning = root.find("Reasoning")
|
|
524
|
+
if reasoning is None:
|
|
525
|
+
return out
|
|
526
|
+
for c in reasoning.findall("Claim"):
|
|
527
|
+
cid = c.get("id") or ""
|
|
528
|
+
if cid:
|
|
529
|
+
out[cid] = c.get("kind") or ""
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _index_assumption_status_et(root: ET.Element) -> dict:
|
|
534
|
+
"""Return {assumption_id: status_str} for all Assumption nodes."""
|
|
535
|
+
out: dict[str, str] = {}
|
|
536
|
+
reasoning = root.find("Reasoning")
|
|
537
|
+
if reasoning is None:
|
|
538
|
+
return out
|
|
539
|
+
for a in reasoning.findall("Assumption"):
|
|
540
|
+
aid = a.get("id") or ""
|
|
541
|
+
if aid:
|
|
542
|
+
out[aid] = a.get("status") or "holding"
|
|
543
|
+
return out
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _index_reasoning_nodes_et(root: ET.Element, element: str) -> dict:
|
|
547
|
+
"""Return {node_id: ET.Element} for Reasoning nodes of the given element type."""
|
|
548
|
+
out: dict[str, ET.Element] = {}
|
|
549
|
+
reasoning = root.find("Reasoning")
|
|
550
|
+
if reasoning is None:
|
|
551
|
+
return out
|
|
552
|
+
for n in reasoning.findall(element):
|
|
553
|
+
nid = n.get("id") or ""
|
|
554
|
+
if nid:
|
|
555
|
+
out[nid] = n
|
|
556
|
+
return out
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
_ANCHOR_ATTRS_ET = frozenset({
|
|
560
|
+
"itemRefs", "itemRef", "contextRefs", "contextRef", "statusRef",
|
|
561
|
+
"calcRef", "scenarioRef", "customDimensions", "kind", "parentClaimRef",
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _node_anchor_attrs_changed_et(p: ET.Element, w: ET.Element) -> set[str]:
|
|
566
|
+
"""Return anchor attrs that differ between parent/working ET nodes."""
|
|
567
|
+
changed: set[str] = set()
|
|
568
|
+
keys = set(p.attrib.keys()) | set(w.attrib.keys())
|
|
569
|
+
for k in keys:
|
|
570
|
+
if k not in _ANCHOR_ATTRS_ET:
|
|
571
|
+
continue
|
|
572
|
+
if p.attrib.get(k) != w.attrib.get(k):
|
|
573
|
+
changed.add(k)
|
|
574
|
+
return changed
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _split_csv_et(s: str | None) -> list:
|
|
578
|
+
if not s:
|
|
579
|
+
return []
|
|
580
|
+
return [x.strip() for x in s.split(",") if x.strip()]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _build_changed_set(parent_root: ET.Element, working_root: ET.Element) -> set:
|
|
584
|
+
"""Compute the set of _ChangedRef entries representing all changes between parent and working."""
|
|
585
|
+
changed: set[_ChangedRef] = set()
|
|
586
|
+
|
|
587
|
+
# Snapshot drift: Value changes (keyed by item, ctx, status, scenario, dims)
|
|
588
|
+
pv = _index_values_et(parent_root)
|
|
589
|
+
wv = _index_values_et(working_root)
|
|
590
|
+
for k, v in wv.items():
|
|
591
|
+
if pv.get(k) != v:
|
|
592
|
+
item, ctx, status, scenario, dims = k
|
|
593
|
+
ref = f"value:{item}@{ctx}/{status}"
|
|
594
|
+
if scenario:
|
|
595
|
+
ref += f"#scen={scenario}"
|
|
596
|
+
if dims:
|
|
597
|
+
ref += f"|dims={dims}"
|
|
598
|
+
changed.add(_ChangedRef("snapshot", ref))
|
|
599
|
+
|
|
600
|
+
# Semantic drift: CalcDef formula or attribute changes
|
|
601
|
+
pc = _index_calcs_et(parent_root)
|
|
602
|
+
wc = _index_calcs_et(working_root)
|
|
603
|
+
for cid, w_repr in wc.items():
|
|
604
|
+
if pc.get(cid) != w_repr:
|
|
605
|
+
changed.add(_ChangedRef("semantic", f"calc:{cid}"))
|
|
606
|
+
|
|
607
|
+
# Structural drift: Item removals (with rename heuristic)
|
|
608
|
+
p_items = _index_items_et(parent_root)
|
|
609
|
+
w_items = _index_items_et(working_root)
|
|
610
|
+
p_sigs = _index_item_signatures_et(parent_root)
|
|
611
|
+
w_sigs = _index_item_signatures_et(working_root)
|
|
612
|
+
removed = p_items - w_items
|
|
613
|
+
added = w_items - p_items
|
|
614
|
+
|
|
615
|
+
suppress_removed: set[str] = set()
|
|
616
|
+
matched_added: set[str] = set()
|
|
617
|
+
for old_id in sorted(removed):
|
|
618
|
+
old_sig = p_sigs.get(old_id)
|
|
619
|
+
if not old_sig or not old_sig[0]:
|
|
620
|
+
continue
|
|
621
|
+
for new_id in sorted(added):
|
|
622
|
+
if new_id in matched_added:
|
|
623
|
+
continue
|
|
624
|
+
if w_sigs.get(new_id) == old_sig:
|
|
625
|
+
changed.add(_ChangedRef("rename", f"item:{old_id}->{new_id}"))
|
|
626
|
+
suppress_removed.add(old_id)
|
|
627
|
+
matched_added.add(new_id)
|
|
628
|
+
break
|
|
629
|
+
|
|
630
|
+
for r in removed - suppress_removed:
|
|
631
|
+
changed.add(_ChangedRef("structural", f"item:{r}"))
|
|
632
|
+
|
|
633
|
+
# Structural drift: ContextDef / StatusDef removals
|
|
634
|
+
for cid in _index_contexts_et(parent_root) - _index_contexts_et(working_root):
|
|
635
|
+
changed.add(_ChangedRef("structural", f"context:{cid}"))
|
|
636
|
+
for sid in _index_statuses_et(parent_root) - _index_statuses_et(working_root):
|
|
637
|
+
changed.add(_ChangedRef("structural", f"status:{sid}"))
|
|
638
|
+
|
|
639
|
+
# Logical drift: Assumption status flipping to "broken"
|
|
640
|
+
pa = _index_assumption_status_et(parent_root)
|
|
641
|
+
wa = _index_assumption_status_et(working_root)
|
|
642
|
+
for aid, w_status in wa.items():
|
|
643
|
+
if w_status == "broken" and pa.get(aid) != "broken":
|
|
644
|
+
changed.add(_ChangedRef("logical", f"assumption:{aid}"))
|
|
645
|
+
|
|
646
|
+
return changed
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _detect_kind_flips_et(parent_root: ET.Element, working_root: ET.Element) -> list:
|
|
650
|
+
pk = _index_claim_kinds_et(parent_root)
|
|
651
|
+
wk = _index_claim_kinds_et(working_root)
|
|
652
|
+
out: list[_DiffEntry] = []
|
|
653
|
+
for cid, new_kind in wk.items():
|
|
654
|
+
old_kind = pk.get(cid)
|
|
655
|
+
if old_kind is None:
|
|
656
|
+
continue
|
|
657
|
+
if old_kind != new_kind:
|
|
658
|
+
out.append(_DiffEntry(cid, "semantic", f"kind changed: {old_kind}->{new_kind}"))
|
|
659
|
+
return out
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _compute_reasoning_diff_pair_et(
|
|
663
|
+
parent_root: ET.Element, working_root: ET.Element
|
|
664
|
+
) -> list:
|
|
665
|
+
changed = _build_changed_set(parent_root, working_root)
|
|
666
|
+
|
|
667
|
+
# Per-anchor same-commit suppression
|
|
668
|
+
edited_anchor_attrs: dict[str, set[str]] = {}
|
|
669
|
+
for element in ("Claim", "Assumption", "Evidence"):
|
|
670
|
+
p_idx = _index_reasoning_nodes_et(parent_root, element)
|
|
671
|
+
w_idx = _index_reasoning_nodes_et(working_root, element)
|
|
672
|
+
for nid, w_node in w_idx.items():
|
|
673
|
+
p_node = p_idx.get(nid)
|
|
674
|
+
if p_node is None:
|
|
675
|
+
edited_anchor_attrs[nid] = set(_ANCHOR_ATTRS_ET)
|
|
676
|
+
continue
|
|
677
|
+
edited_anchor_attrs[nid] = _node_anchor_attrs_changed_et(p_node, w_node)
|
|
678
|
+
|
|
679
|
+
entries: list[_DiffEntry] = []
|
|
680
|
+
seen: set = set()
|
|
681
|
+
|
|
682
|
+
def _emit(node_id: str, kind: str, summary: str) -> None:
|
|
683
|
+
key = (node_id, kind, summary)
|
|
684
|
+
if key in seen:
|
|
685
|
+
return
|
|
686
|
+
seen.add(key)
|
|
687
|
+
entries.append(_DiffEntry(node_id, kind, summary))
|
|
688
|
+
|
|
689
|
+
def _suppressed(nid: str, anchor_attrs: set[str]) -> bool:
|
|
690
|
+
edited = edited_anchor_attrs.get(nid, set())
|
|
691
|
+
return bool(edited & anchor_attrs)
|
|
692
|
+
|
|
693
|
+
if not changed:
|
|
694
|
+
return entries
|
|
695
|
+
|
|
696
|
+
w_reasoning = working_root.find("Reasoning")
|
|
697
|
+
if w_reasoning is None:
|
|
698
|
+
return entries
|
|
699
|
+
|
|
700
|
+
for element in ("Claim", "Assumption"):
|
|
701
|
+
for n in w_reasoning.findall(element):
|
|
702
|
+
nid = n.get("id")
|
|
703
|
+
if nid is None:
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
item_refs = _split_csv_et(n.get("itemRefs") or n.get("itemRef"))
|
|
707
|
+
ctx_refs = _split_csv_et(n.get("contextRefs") or n.get("contextRef"))
|
|
708
|
+
status_ref = n.get("statusRef")
|
|
709
|
+
calc_ref = n.get("calcRef")
|
|
710
|
+
anchor_scenario = n.get("scenarioRef")
|
|
711
|
+
anchor_dims = _canonical_dims_et(n.get("customDimensions"))
|
|
712
|
+
|
|
713
|
+
for cr in changed:
|
|
714
|
+
if cr.drift_kind == "snapshot":
|
|
715
|
+
body = cr.ref[len("value:"):]
|
|
716
|
+
cr_dims = ""
|
|
717
|
+
if "|dims=" in body:
|
|
718
|
+
body, cr_dims = body.split("|dims=", 1)
|
|
719
|
+
cr_scen = ""
|
|
720
|
+
if "#scen=" in body:
|
|
721
|
+
body, cr_scen = body.split("#scen=", 1)
|
|
722
|
+
item, rest = body.split("@", 1)
|
|
723
|
+
ctx, status = rest.split("/", 1)
|
|
724
|
+
item_match = item in item_refs
|
|
725
|
+
ctx_match = (not ctx_refs) or (ctx in ctx_refs)
|
|
726
|
+
status_match = (not status_ref) or (status_ref == status)
|
|
727
|
+
scen_match = (not anchor_scenario) or (anchor_scenario == cr_scen)
|
|
728
|
+
dims_match = (not anchor_dims) or (anchor_dims == cr_dims)
|
|
729
|
+
if item_match and ctx_match and status_match and scen_match and dims_match:
|
|
730
|
+
if _suppressed(nid, {"itemRefs", "itemRef", "contextRefs",
|
|
731
|
+
"contextRef", "statusRef",
|
|
732
|
+
"scenarioRef", "customDimensions"}):
|
|
733
|
+
continue
|
|
734
|
+
_emit(nid, "snapshot", cr.ref)
|
|
735
|
+
elif cr.drift_kind == "semantic":
|
|
736
|
+
if calc_ref and cr.ref == f"calc:{calc_ref}":
|
|
737
|
+
if _suppressed(nid, {"calcRef"}):
|
|
738
|
+
continue
|
|
739
|
+
_emit(nid, "semantic", cr.ref)
|
|
740
|
+
elif cr.drift_kind == "structural":
|
|
741
|
+
body = cr.ref
|
|
742
|
+
if body.startswith("item:"):
|
|
743
|
+
target = body[len("item:"):]
|
|
744
|
+
if target in item_refs:
|
|
745
|
+
if _suppressed(nid, {"itemRefs", "itemRef"}):
|
|
746
|
+
continue
|
|
747
|
+
_emit(nid, "structural", cr.ref)
|
|
748
|
+
elif body.startswith("context:"):
|
|
749
|
+
target = body[len("context:"):]
|
|
750
|
+
if target in ctx_refs:
|
|
751
|
+
if _suppressed(nid, {"contextRefs", "contextRef"}):
|
|
752
|
+
continue
|
|
753
|
+
_emit(nid, "structural", cr.ref)
|
|
754
|
+
elif body.startswith("status:"):
|
|
755
|
+
target = body[len("status:"):]
|
|
756
|
+
if status_ref == target:
|
|
757
|
+
if _suppressed(nid, {"statusRef"}):
|
|
758
|
+
continue
|
|
759
|
+
_emit(nid, "structural", cr.ref)
|
|
760
|
+
elif cr.drift_kind == "rename":
|
|
761
|
+
body = cr.ref[len("item:"):]
|
|
762
|
+
old_id, _, _new_id = body.partition("->")
|
|
763
|
+
if old_id in item_refs:
|
|
764
|
+
if _suppressed(nid, {"itemRefs", "itemRef"}):
|
|
765
|
+
continue
|
|
766
|
+
_emit(nid, "rename", cr.ref)
|
|
767
|
+
|
|
768
|
+
broken_assumptions = {
|
|
769
|
+
cr.ref[len("assumption:"):]
|
|
770
|
+
for cr in changed
|
|
771
|
+
if cr.drift_kind == "logical"
|
|
772
|
+
}
|
|
773
|
+
for a in w_reasoning.findall("Argument"):
|
|
774
|
+
if a.get("rel") != "depends_on":
|
|
775
|
+
continue
|
|
776
|
+
target = a.get("to")
|
|
777
|
+
if target in broken_assumptions:
|
|
778
|
+
src = a.get("from")
|
|
779
|
+
if not src:
|
|
780
|
+
continue
|
|
781
|
+
if edited_anchor_attrs.get(src):
|
|
782
|
+
continue
|
|
783
|
+
_emit(src, "logical", f"depended-on assumption '{target}' flipped to broken")
|
|
784
|
+
|
|
785
|
+
return entries
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _compute_reasoning_diff(
|
|
789
|
+
parent_root: ET.Element,
|
|
790
|
+
working_root: ET.Element,
|
|
791
|
+
branch_base_root: ET.Element | None = None,
|
|
792
|
+
) -> list:
|
|
793
|
+
"""Return one _DiffEntry per Reasoning node whose anchor was touched.
|
|
794
|
+
|
|
795
|
+
Per-anchor same-commit suppression and Phase 2 drift coverage (renames,
|
|
796
|
+
Context/Status removals, kind flips). When `branch_base_root` is supplied,
|
|
797
|
+
drift accumulates across both branch_base→parent and parent→working.
|
|
798
|
+
"""
|
|
799
|
+
entries = _compute_reasoning_diff_pair_et(parent_root, working_root)
|
|
800
|
+
entries.extend(_detect_kind_flips_et(parent_root, working_root))
|
|
801
|
+
|
|
802
|
+
if branch_base_root is not None:
|
|
803
|
+
base_entries = _compute_reasoning_diff_pair_et(branch_base_root, parent_root)
|
|
804
|
+
base_entries.extend(_detect_kind_flips_et(branch_base_root, parent_root))
|
|
805
|
+
existing = {(e.node_id, e.drift_kind, e.change_summary) for e in entries}
|
|
806
|
+
for e in base_entries:
|
|
807
|
+
key = (e.node_id, e.drift_kind, e.change_summary)
|
|
808
|
+
if key in existing:
|
|
809
|
+
continue
|
|
810
|
+
existing.add(key)
|
|
811
|
+
entries.append(e)
|
|
812
|
+
|
|
813
|
+
return entries
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
@click.command("reasoning-diff")
|
|
817
|
+
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
|
818
|
+
@click.option(
|
|
819
|
+
"--accept",
|
|
820
|
+
is_flag=True,
|
|
821
|
+
default=False,
|
|
822
|
+
help="Print warnings but exit 0 (acknowledge and continue).",
|
|
823
|
+
)
|
|
824
|
+
@click.option(
|
|
825
|
+
"--since-branch-base",
|
|
826
|
+
"since_branch_base",
|
|
827
|
+
type=click.Path(exists=True, path_type=Path),
|
|
828
|
+
default=None,
|
|
829
|
+
help=(
|
|
830
|
+
"Path to a third .deepcell file representing the branch base. When "
|
|
831
|
+
"supplied, drift is computed across both branch_base->HEAD and "
|
|
832
|
+
"HEAD->working intervals so accumulated commits surface."
|
|
833
|
+
),
|
|
834
|
+
)
|
|
835
|
+
def reasoning_diff(
|
|
836
|
+
file: Path, accept: bool, since_branch_base: Path | None
|
|
837
|
+
) -> None:
|
|
838
|
+
"""Warn when an edit moved something a Claim was anchored to.
|
|
839
|
+
|
|
840
|
+
Compares FILE at git HEAD against the working tree. Run it before
|
|
841
|
+
committing: it exits non-zero if any Claim is potentially affected, so a
|
|
842
|
+
git pre-commit hook can block. Pass --accept to acknowledge and exit zero
|
|
843
|
+
anyway, or --since-branch-base PATH to also include drift accumulated
|
|
844
|
+
since the supplied branch-base file.
|
|
845
|
+
|
|
846
|
+
Takes a local path, not a workspace filename — it reads git.
|
|
847
|
+
For a workspace premise change and its cross-surface reassessment, read
|
|
848
|
+
`deepcell guide revise/premise-change`.
|
|
849
|
+
"""
|
|
850
|
+
# Locate the git repo for FILE
|
|
851
|
+
try:
|
|
852
|
+
repo_root = subprocess.check_output(
|
|
853
|
+
["git", "-C", str(file.parent), "rev-parse", "--show-toplevel"],
|
|
854
|
+
text=True,
|
|
855
|
+
stderr=subprocess.DEVNULL,
|
|
856
|
+
).strip()
|
|
857
|
+
except subprocess.CalledProcessError:
|
|
858
|
+
click.echo(
|
|
859
|
+
f"warning: {file} is not in a git repo; skipping reasoning-diff", err=True
|
|
860
|
+
)
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
# Path of FILE relative to repo root
|
|
864
|
+
try:
|
|
865
|
+
rel_path = file.resolve().relative_to(Path(repo_root).resolve())
|
|
866
|
+
except ValueError:
|
|
867
|
+
click.echo(f"warning: {file} is not inside {repo_root}; skipping", err=True)
|
|
868
|
+
return
|
|
869
|
+
|
|
870
|
+
# Read parent (git HEAD) version. If the file has no history, exit cleanly.
|
|
871
|
+
try:
|
|
872
|
+
parent_xml = subprocess.check_output(
|
|
873
|
+
["git", "-C", repo_root, "show", f"HEAD:{rel_path.as_posix()}"],
|
|
874
|
+
text=True,
|
|
875
|
+
stderr=subprocess.DEVNULL,
|
|
876
|
+
)
|
|
877
|
+
except subprocess.CalledProcessError:
|
|
878
|
+
# File is untracked / new — nothing to diff against
|
|
879
|
+
return
|
|
880
|
+
|
|
881
|
+
working_xml = file.read_text()
|
|
882
|
+
|
|
883
|
+
parent_root = _parse_xml_string(parent_xml)
|
|
884
|
+
working_root = _parse_xml_string(working_xml)
|
|
885
|
+
branch_base_root = None
|
|
886
|
+
if since_branch_base is not None:
|
|
887
|
+
branch_base_xml = since_branch_base.read_text()
|
|
888
|
+
branch_base_root = _parse_xml_string(branch_base_xml)
|
|
889
|
+
entries = _compute_reasoning_diff(parent_root, working_root, branch_base_root)
|
|
890
|
+
|
|
891
|
+
if not entries:
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
click.echo(
|
|
895
|
+
click.style(
|
|
896
|
+
"Reasoning diff — Claims potentially affected by this change:",
|
|
897
|
+
fg="yellow",
|
|
898
|
+
),
|
|
899
|
+
err=True,
|
|
900
|
+
)
|
|
901
|
+
for e in entries:
|
|
902
|
+
click.echo(f" * {e.node_id} [{e.drift_kind}] {e.change_summary}", err=True)
|
|
903
|
+
|
|
904
|
+
if accept:
|
|
905
|
+
return # exit 0
|
|
906
|
+
|
|
907
|
+
click.echo(
|
|
908
|
+
click.style(
|
|
909
|
+
"Pass --accept to acknowledge and continue, or edit the affected Claims first.",
|
|
910
|
+
fg="red",
|
|
911
|
+
),
|
|
912
|
+
err=True,
|
|
913
|
+
)
|
|
914
|
+
sys.exit(1)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
# ---------------------------------------------------------------------------
|
|
918
|
+
# `deepcell reasoning ...` group — API-backed (workspace FILENAME)
|
|
919
|
+
# ---------------------------------------------------------------------------
|
|
920
|
+
#
|
|
921
|
+
# Every command in this file takes a workspace-relative FILENAME (see the
|
|
922
|
+
# module docstring); the difference here is *where the work happens*. The
|
|
923
|
+
# `claim`/`assumption` groups above fetch the XML and parse it in-process, while
|
|
924
|
+
# this group posts it to the Jingwei API and renders the response.
|
|
925
|
+
# `reasoning-diff` is the only local-path command in the file.
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@click.group()
|
|
929
|
+
def reasoning() -> None:
|
|
930
|
+
"""Read and edit the Claim / Assumption / Evidence graph in a document.
|
|
931
|
+
|
|
932
|
+
A .deepcell document records why it says what it says: Claims, the
|
|
933
|
+
Assumptions and Evidence they rest on, and the Argument edges between
|
|
934
|
+
them. These subcommands author that graph, inspect it, and check it.
|
|
935
|
+
|
|
936
|
+
\b
|
|
937
|
+
AUTHORING A WHOLE ARGUMENT? Use `reasoning apply`. A finished argument is
|
|
938
|
+
a dozen nodes and two dozen edges, and each add-* below is one round-trip;
|
|
939
|
+
`apply` sends them all in a single atomic call, in the order you list them,
|
|
940
|
+
so an Argument may name a node declared earlier in the same batch:
|
|
941
|
+
deepcell reasoning apply model.deepcell --ops '[{"op": "add_claim", ...}]'
|
|
942
|
+
The single add-* commands stay the right tool for one node, and for
|
|
943
|
+
learning an op's fields from `--help` rather than from JSON.
|
|
944
|
+
|
|
945
|
+
\b
|
|
946
|
+
Declare: set-conclusion (which Claim is the document's answer — point
|
|
947
|
+
<Reasoning> at the apex thesis as soon as add-claim has
|
|
948
|
+
authored it, then build the argument for it)
|
|
949
|
+
Add: add-claim, add-assumption, add-evidence, add-argument
|
|
950
|
+
(or all of them at once: apply)
|
|
951
|
+
Revise: supersede-claim / supersede-assumption (keeps the old node as
|
|
952
|
+
history — the default way to change what a Claim asserts),
|
|
953
|
+
update-* (corrects a node in place)
|
|
954
|
+
Remove: delete-* (erases the record; prefer supersede)
|
|
955
|
+
Inspect: graph (Mermaid/DOT), impact (what breaks if a claim falls)
|
|
956
|
+
Check: lint (structural rules; --strict for hygiene warnings too)
|
|
957
|
+
|
|
958
|
+
`set-conclusion` does not create a Claim — author the apex `thesis` with
|
|
959
|
+
`add-claim`, then point `<Reasoning>` at it.
|
|
960
|
+
|
|
961
|
+
Read `deepcell guide revise/reasoning` before writing your first node, and
|
|
962
|
+
`deepcell guide verify/review` when reviewing a finished document.
|
|
963
|
+
`deepcell claim` and `deepcell assumption` hold the per-node read views.
|
|
964
|
+
"""
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
# --- Mermaid renderer -------------------------------------------------------
|
|
968
|
+
|
|
969
|
+
# Mermaid classDef for each node type. Driven by the `type` field on the
|
|
970
|
+
# /reasoning/graph response.
|
|
971
|
+
_MERMAID_NODE_CLASS = {
|
|
972
|
+
"claim": "claim",
|
|
973
|
+
"assumption": "assumption",
|
|
974
|
+
"evidence": "evidence",
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
_MERMAID_CLASSDEFS = (
|
|
978
|
+
" classDef claim fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a;\n"
|
|
979
|
+
" classDef assumption fill:#fef3c7,stroke:#b45309,color:#78350f;\n"
|
|
980
|
+
" classDef evidence fill:#dcfce7,stroke:#15803d,color:#14532d;\n"
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
# Edge styling per Argument @rel — color/dasharray. Cosmetic; safe to extend.
|
|
984
|
+
_MERMAID_LINK_STYLE = {
|
|
985
|
+
"supports": "stroke:#16a34a,stroke-width:2px",
|
|
986
|
+
"refutes": "stroke:#dc2626,stroke-width:2px",
|
|
987
|
+
"contradicts": "stroke:#dc2626,stroke-width:2px,stroke-dasharray:4 2",
|
|
988
|
+
"depends_on": "stroke:#1d4ed8,stroke-width:2px,stroke-dasharray:4 2",
|
|
989
|
+
"derives_from": "stroke:#737373,stroke-width:1.5px",
|
|
990
|
+
"supersedes": "stroke:#525252,stroke-width:1.5px",
|
|
991
|
+
"variant_of": "stroke:#7c3aed,stroke-width:1.5px",
|
|
992
|
+
"references": "stroke:#a3a3a3,stroke-width:1px",
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _mermaid_id(raw: str) -> str:
|
|
997
|
+
"""Sanitize a node id so it parses as a Mermaid identifier."""
|
|
998
|
+
out = []
|
|
999
|
+
for ch in raw:
|
|
1000
|
+
if ch.isalnum() or ch == "_":
|
|
1001
|
+
out.append(ch)
|
|
1002
|
+
else:
|
|
1003
|
+
out.append("_")
|
|
1004
|
+
sanitized = "".join(out) or "node"
|
|
1005
|
+
if sanitized[0].isdigit():
|
|
1006
|
+
sanitized = "n_" + sanitized
|
|
1007
|
+
return sanitized
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def _mermaid_label(label: str, node_id: str) -> str:
|
|
1011
|
+
"""Quote and escape a node label so Mermaid accepts it inside [\"...\"]."""
|
|
1012
|
+
text = label or node_id
|
|
1013
|
+
# Single-line, length-bounded so the graph stays readable.
|
|
1014
|
+
text = " ".join(text.split())
|
|
1015
|
+
if len(text) > 60:
|
|
1016
|
+
text = text[:57] + "..."
|
|
1017
|
+
return text.replace("\\", "\\\\").replace('"', '\\"')
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _arrow_with_label(rel: str) -> str:
|
|
1021
|
+
# Mermaid edge with hover/inline label: -- "rel" -->
|
|
1022
|
+
safe_rel = rel.replace('"', '\\"')
|
|
1023
|
+
return f'-- "{safe_rel}" -->'
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _render_mermaid(graph: dict) -> str:
|
|
1027
|
+
nodes = graph.get("nodes", []) or []
|
|
1028
|
+
edges = graph.get("edges", []) or []
|
|
1029
|
+
|
|
1030
|
+
lines: list[str] = ["flowchart TD"]
|
|
1031
|
+
lines.append(_MERMAID_CLASSDEFS.rstrip("\n"))
|
|
1032
|
+
|
|
1033
|
+
# id_map: original id → sanitized id; collisions resolved by suffixing.
|
|
1034
|
+
id_map: dict[str, str] = {}
|
|
1035
|
+
used: set[str] = set()
|
|
1036
|
+
for n in nodes:
|
|
1037
|
+
raw_id = n.get("id", "")
|
|
1038
|
+
base = _mermaid_id(raw_id)
|
|
1039
|
+
candidate = base
|
|
1040
|
+
i = 2
|
|
1041
|
+
while candidate in used:
|
|
1042
|
+
candidate = f"{base}_{i}"
|
|
1043
|
+
i += 1
|
|
1044
|
+
used.add(candidate)
|
|
1045
|
+
id_map[raw_id] = candidate
|
|
1046
|
+
|
|
1047
|
+
for n in nodes:
|
|
1048
|
+
nid = id_map.get(n.get("id", ""), "")
|
|
1049
|
+
if not nid:
|
|
1050
|
+
continue
|
|
1051
|
+
label = _mermaid_label(n.get("label") or n.get("id") or "", n.get("id", ""))
|
|
1052
|
+
lines.append(f' {nid}["{label}"]')
|
|
1053
|
+
|
|
1054
|
+
# Per-node class assignments — one line is fine but per-node is clearest.
|
|
1055
|
+
for n in nodes:
|
|
1056
|
+
nid = id_map.get(n.get("id", ""), "")
|
|
1057
|
+
cls = _MERMAID_NODE_CLASS.get(n.get("type", ""))
|
|
1058
|
+
if nid and cls:
|
|
1059
|
+
lines.append(f" class {nid} {cls};")
|
|
1060
|
+
|
|
1061
|
+
# Edges + linkStyle. linkStyle is positional, so collect (index, style).
|
|
1062
|
+
link_style_lines: list[str] = []
|
|
1063
|
+
for idx, e in enumerate(edges):
|
|
1064
|
+
src = id_map.get(e.get("source", ""))
|
|
1065
|
+
tgt = id_map.get(e.get("target", ""))
|
|
1066
|
+
rel = e.get("type", "")
|
|
1067
|
+
if not (src and tgt and rel):
|
|
1068
|
+
continue
|
|
1069
|
+
lines.append(f" {src} {_arrow_with_label(rel)} {tgt}")
|
|
1070
|
+
style = _MERMAID_LINK_STYLE.get(rel)
|
|
1071
|
+
if style:
|
|
1072
|
+
link_style_lines.append(f" linkStyle {idx} {style};")
|
|
1073
|
+
|
|
1074
|
+
lines.extend(link_style_lines)
|
|
1075
|
+
return "\n".join(lines) + "\n"
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# --- DOT (Graphviz) renderer ------------------------------------------------
|
|
1079
|
+
|
|
1080
|
+
_DOT_NODE_STYLE = {
|
|
1081
|
+
"claim": 'shape=box, style="rounded,filled", fillcolor="#dbeafe", color="#1d4ed8"',
|
|
1082
|
+
"assumption": 'shape=octagon, style=filled, fillcolor="#fef3c7", color="#b45309"',
|
|
1083
|
+
"evidence": 'shape=note, style=filled, fillcolor="#dcfce7", color="#15803d"',
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
_DOT_EDGE_STYLE = {
|
|
1087
|
+
"supports": 'color="#16a34a", penwidth=2',
|
|
1088
|
+
"refutes": 'color="#dc2626", penwidth=2',
|
|
1089
|
+
"contradicts": 'color="#dc2626", penwidth=2, style=dashed',
|
|
1090
|
+
"depends_on": 'color="#1d4ed8", penwidth=2, style=dashed',
|
|
1091
|
+
"derives_from": 'color="#737373"',
|
|
1092
|
+
"supersedes": 'color="#525252"',
|
|
1093
|
+
"variant_of": 'color="#7c3aed"',
|
|
1094
|
+
"references": 'color="#a3a3a3"',
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def _dot_escape(s: str) -> str:
|
|
1099
|
+
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
def _render_dot(graph: dict) -> str:
|
|
1103
|
+
nodes = graph.get("nodes", []) or []
|
|
1104
|
+
edges = graph.get("edges", []) or []
|
|
1105
|
+
|
|
1106
|
+
lines: list[str] = ["digraph reasoning {", ' rankdir="TB";', ' node [fontname="Helvetica", fontsize=10];', ' edge [fontname="Helvetica", fontsize=9];']
|
|
1107
|
+
|
|
1108
|
+
for n in nodes:
|
|
1109
|
+
nid = _dot_escape(n.get("id", ""))
|
|
1110
|
+
if not nid:
|
|
1111
|
+
continue
|
|
1112
|
+
label = _dot_escape(n.get("label") or n.get("id") or "")
|
|
1113
|
+
style = _DOT_NODE_STYLE.get(n.get("type", ""), "")
|
|
1114
|
+
attrs = f'label="{label}"'
|
|
1115
|
+
if style:
|
|
1116
|
+
attrs += f", {style}"
|
|
1117
|
+
lines.append(f' "{nid}" [{attrs}];')
|
|
1118
|
+
|
|
1119
|
+
for e in edges:
|
|
1120
|
+
src = _dot_escape(e.get("source", ""))
|
|
1121
|
+
tgt = _dot_escape(e.get("target", ""))
|
|
1122
|
+
rel = e.get("type", "")
|
|
1123
|
+
if not (src and tgt and rel):
|
|
1124
|
+
continue
|
|
1125
|
+
attrs = [f'label="{_dot_escape(rel)}"']
|
|
1126
|
+
if rel in _DOT_EDGE_STYLE:
|
|
1127
|
+
attrs.append(_DOT_EDGE_STYLE[rel])
|
|
1128
|
+
lines.append(f' "{src}" -> "{tgt}" [{", ".join(attrs)}];')
|
|
1129
|
+
|
|
1130
|
+
lines.append("}")
|
|
1131
|
+
return "\n".join(lines) + "\n"
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@reasoning.command("graph")
|
|
1135
|
+
@click.argument("filename")
|
|
1136
|
+
@click.option(
|
|
1137
|
+
"--syntax",
|
|
1138
|
+
"graph_syntax",
|
|
1139
|
+
type=click.Choice(["mermaid", "dot"]),
|
|
1140
|
+
default="mermaid",
|
|
1141
|
+
help="Graph syntax to emit. Use the global -f json for the raw graph payload.",
|
|
1142
|
+
)
|
|
1143
|
+
@pass_ctx
|
|
1144
|
+
def reasoning_graph_cmd(ctx: Ctx, filename: str, graph_syntax: str) -> None:
|
|
1145
|
+
"""Render the Argument graph from FILENAME as Mermaid (default) or DOT.
|
|
1146
|
+
|
|
1147
|
+
Pipe the output into a markdown PR comment, a Mermaid renderer, or
|
|
1148
|
+
`dot -Tsvg` to visualize Claims/Assumptions/Evidence and the Arguments
|
|
1149
|
+
that connect them. `-f json` returns the raw graph payload (nodes,
|
|
1150
|
+
edges, metadata).
|
|
1151
|
+
"""
|
|
1152
|
+
xml = _get_xml(ctx, filename)
|
|
1153
|
+
data = ctx.client.post("/reasoning/graph", json={"xml": xml})
|
|
1154
|
+
if ctx.fmt == "json":
|
|
1155
|
+
output(data, "json")
|
|
1156
|
+
return
|
|
1157
|
+
if graph_syntax == "dot":
|
|
1158
|
+
print_plain(_render_dot(data))
|
|
1159
|
+
else:
|
|
1160
|
+
print_plain(_render_mermaid(data))
|
|
1161
|
+
# Malformed nodes are dropped server-side with a warning in
|
|
1162
|
+
# metadata.warnings — without echoing it the rendered graph reads as
|
|
1163
|
+
# authoritative while silently incomplete.
|
|
1164
|
+
meta = data.get("metadata") if isinstance(data, dict) else None
|
|
1165
|
+
for w in (meta or {}).get("warnings") or []:
|
|
1166
|
+
echo_warning(str(w))
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
# --- reasoning lint ---------------------------------------------------------
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
_SEVERITY_COLOR = {"error": "red", "warn": "yellow"}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _format_lint_findings_plain(data: dict) -> str:
|
|
1176
|
+
"""Format /reasoning/lint findings for human reading.
|
|
1177
|
+
|
|
1178
|
+
One finding per line, plus a final summary. Empty result → empty string
|
|
1179
|
+
(matches `reasoning-diff`'s quiet-success convention). Severity color
|
|
1180
|
+
is suppressed when stdout is not a TTY so piped output stays clean.
|
|
1181
|
+
"""
|
|
1182
|
+
findings = data.get("findings", []) or []
|
|
1183
|
+
counts = data.get("counts", {}) or {}
|
|
1184
|
+
if not findings:
|
|
1185
|
+
return ""
|
|
1186
|
+
use_color = sys.stdout.isatty()
|
|
1187
|
+
lines = []
|
|
1188
|
+
for f in findings:
|
|
1189
|
+
sev = f.get("severity", "warn")
|
|
1190
|
+
marker = (
|
|
1191
|
+
click.style(sev, fg=_SEVERITY_COLOR.get(sev, "yellow"))
|
|
1192
|
+
if use_color else sev
|
|
1193
|
+
)
|
|
1194
|
+
rule = f.get("rule", "")
|
|
1195
|
+
tid = f.get("target_id", "")
|
|
1196
|
+
msg = f.get("message", "")
|
|
1197
|
+
lines.append(f"{marker} [{rule}{rule_citation(f)}] {tid}: {msg}")
|
|
1198
|
+
err_n = counts.get("error", 0)
|
|
1199
|
+
warn_n = counts.get("warn", 0)
|
|
1200
|
+
lines.append("")
|
|
1201
|
+
lines.append(f"{err_n} error(s), {warn_n} warning(s)")
|
|
1202
|
+
footer = cited_rules_footer(findings)
|
|
1203
|
+
if footer:
|
|
1204
|
+
lines.append(footer)
|
|
1205
|
+
return "\n".join(lines)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@reasoning.command("lint")
|
|
1209
|
+
@click.argument("filename")
|
|
1210
|
+
@click.option(
|
|
1211
|
+
"--strict",
|
|
1212
|
+
is_flag=True,
|
|
1213
|
+
help="Exit non-zero on warn-level findings too "
|
|
1214
|
+
"(default: only error-level findings fail).",
|
|
1215
|
+
)
|
|
1216
|
+
@pass_ctx
|
|
1217
|
+
def reasoning_lint_cmd(ctx: Ctx, filename: str, strict: bool) -> None:
|
|
1218
|
+
"""Lint the <Reasoning> section of FILENAME for structural issues.
|
|
1219
|
+
|
|
1220
|
+
Runs the full read-only rule set (orphan claims, single-evidence funnels,
|
|
1221
|
+
missing required risk attrs, Body↔itemRefs drift, missing @calcRef anchors,
|
|
1222
|
+
unanchored claims/assumptions, attribute
|
|
1223
|
+
typos, exclusivity assertions, dangling sensitivity refs, missing apex
|
|
1224
|
+
recommendation). Exits non-zero when any error-level finding is produced;
|
|
1225
|
+
warn-level findings are hygiene nudges and exit 0 unless --strict. Wire
|
|
1226
|
+
`--strict` into pre-commit hooks the same way as `reasoning-diff`.
|
|
1227
|
+
`-f json` returns the raw payload (findings + counts).
|
|
1228
|
+
"""
|
|
1229
|
+
xml = _get_xml(ctx, filename)
|
|
1230
|
+
data = ctx.client.post("/reasoning/lint", json={"xml": xml})
|
|
1231
|
+
if ctx.fmt == "json":
|
|
1232
|
+
output(data, "json")
|
|
1233
|
+
else:
|
|
1234
|
+
text = _format_lint_findings_plain(data)
|
|
1235
|
+
if text:
|
|
1236
|
+
print_plain(text)
|
|
1237
|
+
findings = data.get("findings") or []
|
|
1238
|
+
errors = [f for f in findings if f.get("severity") == "error"]
|
|
1239
|
+
if errors or (strict and findings):
|
|
1240
|
+
sys.exit(1)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
# --- reasoning impact -------------------------------------------------------
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
@reasoning.command("impact", cls=FileFirstCommand)
|
|
1247
|
+
@click.argument("filename")
|
|
1248
|
+
@click.argument("claim_id")
|
|
1249
|
+
@pass_ctx
|
|
1250
|
+
def reasoning_impact_cmd(ctx: Ctx, filename: str, claim_id: str) -> None:
|
|
1251
|
+
"""Walk Argument edges from CLAIM_ID and list downstream nodes affected if it's falsified.
|
|
1252
|
+
|
|
1253
|
+
Companion to `assumption impact` (single-hop) — `reasoning impact` is a
|
|
1254
|
+
graph walk that surfaces transitive Claims/Assumptions that depend_on,
|
|
1255
|
+
derive_from, support, or are variant_of the falsified claim. Each
|
|
1256
|
+
returned node carries its `dependency_path` — the chain of @rel labels
|
|
1257
|
+
from the node back to CLAIM_ID.
|
|
1258
|
+
|
|
1259
|
+
Both impact commands are workspace-backed and take a workspace-relative
|
|
1260
|
+
FILENAME (matching the `reasoning graph`/`lint` pattern). The local Git
|
|
1261
|
+
command is `reasoning-diff`. `-f json` returns the raw `{nodes: [...]}`
|
|
1262
|
+
payload. Continue with `deepcell guide revise/premise-change`.
|
|
1263
|
+
"""
|
|
1264
|
+
xml = _get_xml(ctx, filename)
|
|
1265
|
+
data = ctx.client.post(
|
|
1266
|
+
"/reasoning/query",
|
|
1267
|
+
json={"xml": xml, "kind": "claim_impact", "id": claim_id},
|
|
1268
|
+
)
|
|
1269
|
+
if ctx.fmt == "json":
|
|
1270
|
+
output(data, "json")
|
|
1271
|
+
return
|
|
1272
|
+
nodes = data.get("nodes", []) if isinstance(data, dict) else []
|
|
1273
|
+
if not nodes:
|
|
1274
|
+
return
|
|
1275
|
+
for n in nodes:
|
|
1276
|
+
nid = n.get("id", "")
|
|
1277
|
+
ntype = n.get("type", "")
|
|
1278
|
+
kind = n.get("kind")
|
|
1279
|
+
label = n.get("label", "")
|
|
1280
|
+
path = "->".join(n.get("dependency_path", []) or [])
|
|
1281
|
+
marker = f"{ntype}/{kind}" if kind else ntype
|
|
1282
|
+
if path:
|
|
1283
|
+
click.echo(f"{nid} [{marker}] {label} ({path})")
|
|
1284
|
+
else:
|
|
1285
|
+
click.echo(f"{nid} [{marker}] {label}")
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
# --- reasoning add-* --------------------------------------------------------
|
|
1289
|
+
#
|
|
1290
|
+
# Each add-* subcommand reads the .deepcell XML, sends a single
|
|
1291
|
+
# `POST /reasoning/edit` with `op="add_<element>"` and a payload of
|
|
1292
|
+
# camelCase Pydantic field names (see backend/jingwei_api/utils/reasoning_pydantic.py
|
|
1293
|
+
# lines 88-172). The backend's `_pyd_to_canonical` (reasoning.py:242-266)
|
|
1294
|
+
# rewrites the payload into the canonical XML-dict shape, so the CLI just
|
|
1295
|
+
# needs to forward the flat dict verbatim and strip None values.
|
|
1296
|
+
#
|
|
1297
|
+
# Choice values mirror backend/src/core/constants/reasoning.py.
|
|
1298
|
+
# Sync with backend/src/core/constants/reasoning.py — if this list drifts,
|
|
1299
|
+
# `deepcell reasoning add-*` will reject values the backend would accept.
|
|
1300
|
+
_CLAIM_KINDS = (
|
|
1301
|
+
"thesis", "risk", "catalyst", "counter", "question",
|
|
1302
|
+
"market_consensus",
|
|
1303
|
+
)
|
|
1304
|
+
_ARGUMENT_RELS = (
|
|
1305
|
+
"supports", "refutes", "depends_on", "derives_from",
|
|
1306
|
+
"variant_of", "supersedes", "contradicts", "references", "answers",
|
|
1307
|
+
)
|
|
1308
|
+
_SEVERITY_VALUES = ("low", "med", "high")
|
|
1309
|
+
|
|
1310
|
+
#: Mirrors ``NOTATIONS`` in ``backend/src/core/prose/body.py``. Declared here
|
|
1311
|
+
#: as a ``Choice`` so a typo is refused locally rather than coming back as a
|
|
1312
|
+
#: 422, the same standard ``BINDING_KINDS`` is held to in ``deck.py``.
|
|
1313
|
+
#:
|
|
1314
|
+
#: A reasoning body defaults to ``text`` — that is what every existing one
|
|
1315
|
+
#: already was, and re-reading the corpus as markdown would change what 11
|
|
1316
|
+
#: documents say without anyone editing them. Declaring ``markdown`` is how an
|
|
1317
|
+
#: author opts a body in, and it is the only way to stop `**0.397**` rendering
|
|
1318
|
+
#: as four visible asterisks.
|
|
1319
|
+
_BODY_NOTATIONS = ("markdown", "text")
|
|
1320
|
+
|
|
1321
|
+
#: What a Label is *for*, said where the author is about to write one. The
|
|
1322
|
+
#: standard itself is `deepcell ref claim` (per kind) and the agent prompt;
|
|
1323
|
+
#: this is the one-line reminder at the point of use, and it is shared so the
|
|
1324
|
+
#: seven options that take a Label cannot drift into seven standards.
|
|
1325
|
+
_LABEL_HELP = (
|
|
1326
|
+
"The finding itself, not the subject it concerns — \"Working capital is a "
|
|
1327
|
+
"source of cash\", not \"Working capital\". Renders as the reasoning "
|
|
1328
|
+
"outline row, and a Deck may bind it as a slide headline."
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
_BODY_NOTATION_HELP = (
|
|
1332
|
+
"What the <Body> IS: markdown or literal text. Declared, never sniffed — "
|
|
1333
|
+
"no reader guesses from the bytes. Omitted leaves it as it was."
|
|
1334
|
+
)
|
|
1335
|
+
#: `@strength` — how strongly a claim is held, qualitatively. Mirrors
|
|
1336
|
+
#: `src.core.constants.reasoning.CLAIM_STRENGTHS`. Independent of
|
|
1337
|
+
#: `--confidence`, which is the same judgement as a number: the format defines
|
|
1338
|
+
#: both and says they may coexist, so neither is offered as an alias for the
|
|
1339
|
+
#: other.
|
|
1340
|
+
_CLAIM_STRENGTHS = ("low", "med", "high")
|
|
1341
|
+
_CLAIM_STATUSES = ("draft", "active", "superseded", "falsified", "revoked")
|
|
1342
|
+
_ASSUMPTION_STATUSES = ("holding", "uncertain", "broken", "superseded")
|
|
1343
|
+
|
|
1344
|
+
# Assumption lifecycle dates. Both were documented in the Assumption attribute
|
|
1345
|
+
# table with no way to set them, so `--broken-at` was reached for in good faith
|
|
1346
|
+
# and answered with "No such option" (guide eval, `reasoning` task-2). They are
|
|
1347
|
+
# plain ISO date attributes: the backend already allow-lists and ISO-validates
|
|
1348
|
+
# `@verifiedAt` / `@brokenAt`, so this is a CLI-side gap only.
|
|
1349
|
+
_VERIFIED_AT_HELP = (
|
|
1350
|
+
"ISO date (YYYY-MM-DD) this assumption was last confirmed to still hold "
|
|
1351
|
+
"(@verifiedAt). Records WHEN it was checked; --status holding records that "
|
|
1352
|
+
"it stands."
|
|
1353
|
+
)
|
|
1354
|
+
_BROKEN_AT_HELP = (
|
|
1355
|
+
"ISO date (YYYY-MM-DD) this assumption was found no longer to hold "
|
|
1356
|
+
"(@brokenAt). Records WHEN it failed; --status broken records THAT it "
|
|
1357
|
+
"failed — set both when actuals contradict an assumption."
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
def _get_xml_with_revision(ctx: Ctx, filename: str) -> tuple[str, str | None]:
|
|
1361
|
+
"""Read the document and the revision it was read at.
|
|
1362
|
+
|
|
1363
|
+
Every `reasoning add-*`/`update-*` is fetch → `/reasoning/edit` →
|
|
1364
|
+
POST-full-content, so the revision has to travel with the XML or the write
|
|
1365
|
+
back is unguarded and overwrites whatever landed in between (#1217).
|
|
1366
|
+
"""
|
|
1367
|
+
from deepcell_cli.revision import fetch_xml_with_revision
|
|
1368
|
+
|
|
1369
|
+
return fetch_xml_with_revision(ctx, ctx.require_workspace(), filename)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _persist_edit_result(
|
|
1373
|
+
ctx: Ctx, filename: str, data: Any, revision: str | None = None
|
|
1374
|
+
) -> None:
|
|
1375
|
+
"""Write the post-edit XML back to the workspace and print the result.
|
|
1376
|
+
|
|
1377
|
+
`/reasoning/edit` and the workspace write are two separate calls; if the
|
|
1378
|
+
second fails after the first succeeds, the user has no persisted state
|
|
1379
|
+
but a successful-looking edit. Surface that explicitly so the user knows
|
|
1380
|
+
to retry the write rather than re-run the whole add-* command (which
|
|
1381
|
+
would re-apply the edit to a stale tree).
|
|
1382
|
+
|
|
1383
|
+
``revision`` makes the write a compare-and-swap against the fetch, so a
|
|
1384
|
+
concurrent change to this file is refused (409) instead of being silently
|
|
1385
|
+
replaced by our copy of the pre-edit document.
|
|
1386
|
+
|
|
1387
|
+
The ``-m`` / ``--title`` the caller typed travel with it. Without them
|
|
1388
|
+
this path sent no message at all and the server fell through to ``Update
|
|
1389
|
+
<filename>`` — so the family editing the Claims and Assumptions, the one
|
|
1390
|
+
place where "why did this change" IS the subject matter, recorded neither
|
|
1391
|
+
the reason nor even which operation ran (#1717).
|
|
1392
|
+
"""
|
|
1393
|
+
if not isinstance(data, dict):
|
|
1394
|
+
raise click.ClickException("Unexpected response shape from /reasoning/edit.")
|
|
1395
|
+
new_xml = data.get("xml", "")
|
|
1396
|
+
if not new_xml:
|
|
1397
|
+
raise click.ClickException("Server returned empty XML after edit.")
|
|
1398
|
+
slug = ctx.require_workspace()
|
|
1399
|
+
from deepcell_cli.errors import APIError
|
|
1400
|
+
from deepcell_cli.revision import raise_if_stale, write_body
|
|
1401
|
+
|
|
1402
|
+
title, rationale = write_message()
|
|
1403
|
+
try:
|
|
1404
|
+
write_result = ctx.client.post(
|
|
1405
|
+
f"/workspaces/{slug}/files/{filename}",
|
|
1406
|
+
json=write_body(new_xml, revision, title=title, rationale=rationale),
|
|
1407
|
+
)
|
|
1408
|
+
except Exception as exc:
|
|
1409
|
+
# A stale-revision 409 is not "the workspace is broken, retry the
|
|
1410
|
+
# write" — replaying this XML would be the lost update. It gets its
|
|
1411
|
+
# own message; everything else keeps the pre-existing one.
|
|
1412
|
+
if isinstance(exc, APIError):
|
|
1413
|
+
raise_if_stale(exc, filename=filename)
|
|
1414
|
+
raise click.ClickException(
|
|
1415
|
+
f"Edit succeeded server-side but persisting to "
|
|
1416
|
+
f"{slug}/{filename} failed: {exc}. The edit was NOT saved — "
|
|
1417
|
+
"re-run the command after resolving the workspace error."
|
|
1418
|
+
) from exc
|
|
1419
|
+
output(data.get("result", {}), ctx.fmt)
|
|
1420
|
+
# #1171 — a retried add whose first attempt landed server-side succeeds
|
|
1421
|
+
# idempotently; tell the caller no second node was created.
|
|
1422
|
+
if isinstance(data.get("result"), dict) and data["result"].get("already_existed"):
|
|
1423
|
+
echo_info(
|
|
1424
|
+
"node already existed with identical content — nothing new was "
|
|
1425
|
+
"added (idempotent retry)"
|
|
1426
|
+
)
|
|
1427
|
+
# Surface lint findings (warn-level — error-level findings would have
|
|
1428
|
+
# raised an HTTPException with a structured detail before reaching here).
|
|
1429
|
+
# On stderr: stdout carries the structured result (pure JSON in -f json).
|
|
1430
|
+
findings = data.get("findings") or data.get("result", {}).get("findings") or []
|
|
1431
|
+
if findings:
|
|
1432
|
+
click.echo("Findings:", err=True)
|
|
1433
|
+
for f in findings:
|
|
1434
|
+
sev = f.get("severity", "warn")
|
|
1435
|
+
click.echo(
|
|
1436
|
+
f" [{sev}] {f.get('rule')}{rule_citation(f)}: "
|
|
1437
|
+
f"{f.get('message')}{provenance(f)}",
|
|
1438
|
+
err=True,
|
|
1439
|
+
)
|
|
1440
|
+
footer = cited_rules_footer(findings)
|
|
1441
|
+
if footer:
|
|
1442
|
+
click.echo(footer, err=True)
|
|
1443
|
+
|
|
1444
|
+
# What to do next, as opposed to what is wrong now. The server defers a
|
|
1445
|
+
# warning it would be unfair to raise yet (an Argument cannot exist before
|
|
1446
|
+
# the node it points at) and states it forward instead.
|
|
1447
|
+
for step in (data.get("result") or {}).get("next_steps") or []:
|
|
1448
|
+
echo_info(step)
|
|
1449
|
+
errors = echo_validation(write_result)
|
|
1450
|
+
sha = write_result.get("commit_sha") if isinstance(write_result, dict) else None
|
|
1451
|
+
sha_part = f" (commit {str(sha)[:8]})" if sha else ""
|
|
1452
|
+
if errors:
|
|
1453
|
+
echo_warning(
|
|
1454
|
+
f"'{filename}' saved{sha_part} with {len(errors)} validation "
|
|
1455
|
+
"error(s) — fix the errors above"
|
|
1456
|
+
)
|
|
1457
|
+
raise click.exceptions.Exit(1)
|
|
1458
|
+
echo_success(f"'{filename}' saved{sha_part}")
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
# The coverage anchor, as flags, declared once.
|
|
1462
|
+
#
|
|
1463
|
+
# Coverage is what a node is ABOUT: Item x Context x Status x Scenario x
|
|
1464
|
+
# CustomDimensions -- the same five dimensions a cell has. The format has
|
|
1465
|
+
# always carried all five and the CLI reached three of them: `--context-refs`
|
|
1466
|
+
# was on 2 of the 8 write commands, and `--scenario-ref` / `--custom-dimensions`
|
|
1467
|
+
# on none at all, so a five-dimension anchor could only be written by editing
|
|
1468
|
+
# XML by hand.
|
|
1469
|
+
#
|
|
1470
|
+
# Declared here so a ninth write command cannot quietly ship with three of the
|
|
1471
|
+
# five. `--item-refs` and `--status-ref` stay on their commands: their help
|
|
1472
|
+
# text is command-specific (one explains `unanchored_claim`, the other warns
|
|
1473
|
+
# it is not the lifecycle `--status`).
|
|
1474
|
+
#
|
|
1475
|
+
# These say what a claim RANGES OVER. To state a number the model holds, cite
|
|
1476
|
+
# the cell inside the body -- `[[deepcell:cell/Item[Context]#status]]` through
|
|
1477
|
+
# `--body` -- which needs no flag of its own. See
|
|
1478
|
+
# `deepcell guide orient/surface-ownership`.
|
|
1479
|
+
_CONTEXT_REFS_OPTION = click.option(
|
|
1480
|
+
"--context-refs", "context_refs", default=None,
|
|
1481
|
+
help="Comma-separated context (period) ids this node reasons across.",
|
|
1482
|
+
)
|
|
1483
|
+
_SCENARIO_REF_OPTION = click.option(
|
|
1484
|
+
"--scenario-ref", "scenario_ref", default=None,
|
|
1485
|
+
help="Scenario id this node is about, when it holds only in one world.",
|
|
1486
|
+
)
|
|
1487
|
+
_CUSTOM_DIMENSIONS_OPTION = click.option(
|
|
1488
|
+
"--custom-dimensions", "custom_dimensions", default=None,
|
|
1489
|
+
help="Custom dimension members as `dim:member;dim2:member2` — the fifth "
|
|
1490
|
+
"axis of the anchor (e.g. `region:emea`).",
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _coverage_anchor_options(f):
|
|
1495
|
+
"""`--context-refs`, `--scenario-ref`, `--custom-dimensions`, in that order."""
|
|
1496
|
+
for option in (
|
|
1497
|
+
_CUSTOM_DIMENSIONS_OPTION, _SCENARIO_REF_OPTION, _CONTEXT_REFS_OPTION,
|
|
1498
|
+
):
|
|
1499
|
+
f = option(f)
|
|
1500
|
+
return f
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _narrow_anchor_options(f):
|
|
1504
|
+
"""The two a command that already declares `--context-refs` still lacks."""
|
|
1505
|
+
for option in (_CUSTOM_DIMENSIONS_OPTION, _SCENARIO_REF_OPTION):
|
|
1506
|
+
f = option(f)
|
|
1507
|
+
return f
|
|
1508
|
+
|
|
1509
|
+
|
|
1510
|
+
#: Batch entries name their verb ``op`` (the `/reasoning/edit` spelling) while
|
|
1511
|
+
#: `defs apply` names it ``kind``. The two batches are separate doors — a
|
|
1512
|
+
#: reasoning kind is refused by `defs apply`'s request schema, which is a
|
|
1513
|
+
#: tagged union of the structural ops only — but an agent moving between them
|
|
1514
|
+
#: carries the vocabulary across, and being answered "unknown op: ''" over a
|
|
1515
|
+
#: spelling costs exactly the round the batch just saved. So both spellings are
|
|
1516
|
+
#: accepted here, and the same for the field bag, which `defs apply` calls
|
|
1517
|
+
#: ``defaults`` and this route calls ``payload``.
|
|
1518
|
+
def _normalize_reasoning_ops(payload: Any) -> list[dict[str, Any]]:
|
|
1519
|
+
"""Coerce a `reasoning apply` payload into `/reasoning/edit` batch entries."""
|
|
1520
|
+
if isinstance(payload, dict):
|
|
1521
|
+
ops = payload.get("ops")
|
|
1522
|
+
if ops is None:
|
|
1523
|
+
raise click.ClickException(
|
|
1524
|
+
"ops JSON object has no 'ops' key. Pass either a bare array "
|
|
1525
|
+
'of ops or {"ops": [...]}.'
|
|
1526
|
+
)
|
|
1527
|
+
else:
|
|
1528
|
+
ops = payload
|
|
1529
|
+
if not isinstance(ops, list):
|
|
1530
|
+
raise click.ClickException(
|
|
1531
|
+
'ops must be a JSON array, or an object with an "ops" array.'
|
|
1532
|
+
)
|
|
1533
|
+
if not ops:
|
|
1534
|
+
raise click.ClickException("ops list is empty — nothing to apply.")
|
|
1535
|
+
|
|
1536
|
+
out: list[dict[str, Any]] = []
|
|
1537
|
+
for i, op in enumerate(ops):
|
|
1538
|
+
if not isinstance(op, dict):
|
|
1539
|
+
raise click.ClickException(f"op {i} is not a JSON object.")
|
|
1540
|
+
entry = dict(op)
|
|
1541
|
+
if "op" not in entry and "kind" in entry:
|
|
1542
|
+
entry["op"] = entry.pop("kind")
|
|
1543
|
+
if "payload" not in entry and "defaults" in entry:
|
|
1544
|
+
entry["payload"] = entry.pop("defaults")
|
|
1545
|
+
if not entry.get("op"):
|
|
1546
|
+
# Deliberately does NOT name a kind: `test_op_reachability` reads
|
|
1547
|
+
# string constants in real code positions out of this package, and
|
|
1548
|
+
# a kind spelled here would be counted as one the CLI emits.
|
|
1549
|
+
raise click.ClickException(
|
|
1550
|
+
f"op {i} has no 'op' — each entry needs one, beside its "
|
|
1551
|
+
"'payload'. Run `deepcell ref op` for the kinds and "
|
|
1552
|
+
"their fields."
|
|
1553
|
+
)
|
|
1554
|
+
out.append(entry)
|
|
1555
|
+
return out
|
|
1556
|
+
|
|
1557
|
+
|
|
1558
|
+
@reasoning.command("apply", cls=WriteCommand)
|
|
1559
|
+
@click.argument("filename")
|
|
1560
|
+
@click.option(
|
|
1561
|
+
"--ops-file",
|
|
1562
|
+
"ops_file",
|
|
1563
|
+
default=None,
|
|
1564
|
+
type=click.File("r"),
|
|
1565
|
+
help="JSON file with an `ops` array (use '-' for stdin).",
|
|
1566
|
+
)
|
|
1567
|
+
@click.option(
|
|
1568
|
+
"--ops",
|
|
1569
|
+
"ops_inline",
|
|
1570
|
+
default=None,
|
|
1571
|
+
help="Inline ops JSON (same shapes as --ops-file).",
|
|
1572
|
+
)
|
|
1573
|
+
@pass_ctx
|
|
1574
|
+
def reasoning_apply_cmd(
|
|
1575
|
+
ctx: Ctx,
|
|
1576
|
+
filename: str,
|
|
1577
|
+
ops_file: Any,
|
|
1578
|
+
ops_inline: str | None,
|
|
1579
|
+
) -> None:
|
|
1580
|
+
"""Apply a batch of reasoning ops atomically.
|
|
1581
|
+
|
|
1582
|
+
\b
|
|
1583
|
+
The whole argument in one call. Each `reasoning add-*` is a separate
|
|
1584
|
+
round-trip, and a real argument is a dozen nodes and two dozen edges —
|
|
1585
|
+
one eval run spent 69 of its 282 commands here, one node at a time.
|
|
1586
|
+
|
|
1587
|
+
\b
|
|
1588
|
+
The payload is a JSON document of either form:
|
|
1589
|
+
[{"op": "add_claim", "payload": {...}}, ...] # bare array
|
|
1590
|
+
{"ops": [{"op": "add_evidence", "payload": {...}}, ...]}
|
|
1591
|
+
|
|
1592
|
+
\b
|
|
1593
|
+
Verbs are the `add_*` / `update_*` / `delete_*` / `supersede_*` /
|
|
1594
|
+
`set_*` op kinds (`deepcell ref op`). Edge ops carry `from_id`,
|
|
1595
|
+
`to_id` and `edge_rel` beside the payload rather than inside it:
|
|
1596
|
+
{"op": "add_argument", "payload": {"from": "c_thesis",
|
|
1597
|
+
"rel": "supports", "to": "c_growth"}}
|
|
1598
|
+
|
|
1599
|
+
\b
|
|
1600
|
+
Order matters and is honoured: declare a node before the Argument that
|
|
1601
|
+
points at it, in the same batch. The batch is atomic — one bad op applies
|
|
1602
|
+
none of them and persists nothing, so a rejection is safe to fix and
|
|
1603
|
+
re-send whole.
|
|
1604
|
+
|
|
1605
|
+
\b
|
|
1606
|
+
`defs apply` is the other door and carries the structural kinds; a
|
|
1607
|
+
reasoning kind sent there is refused by its request schema. Structure and
|
|
1608
|
+
argument are two batched calls, not one mixed one — still two instead of
|
|
1609
|
+
the two hundred a command per node costs.
|
|
1610
|
+
"""
|
|
1611
|
+
if (ops_file is None) == (ops_inline is None):
|
|
1612
|
+
raise click.UsageError("Provide exactly one of --ops-file or --ops.")
|
|
1613
|
+
|
|
1614
|
+
raw = ops_inline if ops_inline is not None else ops_file.read()
|
|
1615
|
+
try:
|
|
1616
|
+
parsed = json.loads(raw)
|
|
1617
|
+
except json.JSONDecodeError as exc:
|
|
1618
|
+
raise click.ClickException(f"ops payload is not valid JSON: {exc}") from exc
|
|
1619
|
+
|
|
1620
|
+
ops = _normalize_reasoning_ops(parsed)
|
|
1621
|
+
|
|
1622
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
1623
|
+
data = ctx.client.post(
|
|
1624
|
+
"/reasoning/edit",
|
|
1625
|
+
json={"op": "batch", "xml": xml, "ops": ops},
|
|
1626
|
+
)
|
|
1627
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
1628
|
+
|
|
1629
|
+
|
|
1630
|
+
@reasoning.command("add-claim", cls=WriteCommand)
|
|
1631
|
+
@click.argument("filename")
|
|
1632
|
+
@click.option("--id", "claim_id", required=True, help="New Claim id.")
|
|
1633
|
+
@click.option(
|
|
1634
|
+
"--kind",
|
|
1635
|
+
required=True,
|
|
1636
|
+
type=click.Choice(_CLAIM_KINDS),
|
|
1637
|
+
help="What the claim is: thesis (what you conclude), risk / catalyst "
|
|
1638
|
+
"(what moves it, needs --probability), counter (the case against), "
|
|
1639
|
+
"question (open), market_consensus (someone else's view, needs "
|
|
1640
|
+
"--attribution). See `deepcell guide revise/reasoning`.",
|
|
1641
|
+
)
|
|
1642
|
+
@click.option("--label", default=None, help=f"<Label> text. {_LABEL_HELP}")
|
|
1643
|
+
@click.option(
|
|
1644
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
1645
|
+
help=_text_file_help("<Label>", "label"),
|
|
1646
|
+
)
|
|
1647
|
+
@click.option("--body", default=None, help="<Body> text.")
|
|
1648
|
+
@click.option(
|
|
1649
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
1650
|
+
help=_text_file_help("<Body>", "body"),
|
|
1651
|
+
)
|
|
1652
|
+
@click.option(
|
|
1653
|
+
"--body-notation", "body_notation", default=None,
|
|
1654
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
1655
|
+
help=_BODY_NOTATION_HELP,
|
|
1656
|
+
)
|
|
1657
|
+
@click.option(
|
|
1658
|
+
"--status-ref", default=None,
|
|
1659
|
+
help="Model Status dimension id (e.g. actual / projected) — NOT the "
|
|
1660
|
+
"claim lifecycle state (that is --status).",
|
|
1661
|
+
)
|
|
1662
|
+
@click.option(
|
|
1663
|
+
"--calc-ref", default=None,
|
|
1664
|
+
help="CalcDef id anchoring this claim to a formula in the model.",
|
|
1665
|
+
)
|
|
1666
|
+
@click.option(
|
|
1667
|
+
"--item-refs", default=None,
|
|
1668
|
+
help="Comma-separated item ids anchoring this claim to a Spreadsheet. "
|
|
1669
|
+
"When Items exist, thesis/risk/catalyst claims need --item-refs or "
|
|
1670
|
+
"--calc-ref, else unanchored_claim warns (they become invisible to "
|
|
1671
|
+
"reasoning-diff and item-scoped queries). A qualitative no-grid file "
|
|
1672
|
+
"does not invent Items; question/market_consensus are always exempt.",
|
|
1673
|
+
)
|
|
1674
|
+
@click.option(
|
|
1675
|
+
"--probability", default=None, type=float,
|
|
1676
|
+
help="Likelihood, 0-1 (required for kind=risk / catalyst).",
|
|
1677
|
+
)
|
|
1678
|
+
@click.option(
|
|
1679
|
+
"--severity", default=None, type=click.Choice(_SEVERITY_VALUES),
|
|
1680
|
+
help="Qualitative impact (required for kind=risk).",
|
|
1681
|
+
)
|
|
1682
|
+
@click.option(
|
|
1683
|
+
"--confidence", default=None, type=float,
|
|
1684
|
+
help="How strongly you hold this claim, 0-1.",
|
|
1685
|
+
)
|
|
1686
|
+
@click.option(
|
|
1687
|
+
"--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
|
|
1688
|
+
help="How strongly the claim is held, qualitatively. Independent of "
|
|
1689
|
+
"--confidence (the numeric form); a claim may carry both.",
|
|
1690
|
+
)
|
|
1691
|
+
@click.option(
|
|
1692
|
+
"--parent-claim-ref", default=None,
|
|
1693
|
+
help="Claim id this one hangs under (hierarchy, not an Argument edge).",
|
|
1694
|
+
)
|
|
1695
|
+
@click.option(
|
|
1696
|
+
"--attribution", default=None,
|
|
1697
|
+
help="Who holds this view (required for kind=market_consensus), "
|
|
1698
|
+
"e.g. 'Bloomberg consensus, 2026-07'.",
|
|
1699
|
+
)
|
|
1700
|
+
@click.option(
|
|
1701
|
+
"--status", default=None, type=click.Choice(_CLAIM_STATUSES),
|
|
1702
|
+
help="Claim lifecycle state — NOT the model's Status dimension "
|
|
1703
|
+
"(that is --status-ref).",
|
|
1704
|
+
)
|
|
1705
|
+
@_coverage_anchor_options
|
|
1706
|
+
@pass_ctx
|
|
1707
|
+
def reasoning_add_claim_cmd(
|
|
1708
|
+
ctx: Ctx,
|
|
1709
|
+
filename: str,
|
|
1710
|
+
claim_id: str,
|
|
1711
|
+
kind: str,
|
|
1712
|
+
label: str | None,
|
|
1713
|
+
label_file: str | None,
|
|
1714
|
+
body: str | None,
|
|
1715
|
+
body_file: str | None,
|
|
1716
|
+
body_notation: str | None,
|
|
1717
|
+
status_ref: str | None,
|
|
1718
|
+
calc_ref: str | None,
|
|
1719
|
+
item_refs: str | None,
|
|
1720
|
+
context_refs: str | None,
|
|
1721
|
+
scenario_ref: str | None,
|
|
1722
|
+
custom_dimensions: str | None,
|
|
1723
|
+
probability: float | None,
|
|
1724
|
+
severity: str | None,
|
|
1725
|
+
confidence: float | None,
|
|
1726
|
+
strength: str | None,
|
|
1727
|
+
parent_claim_ref: str | None,
|
|
1728
|
+
attribution: str | None,
|
|
1729
|
+
status: str | None,
|
|
1730
|
+
) -> None:
|
|
1731
|
+
"""Add a new <Claim> to FILENAME's <Reasoning> section.
|
|
1732
|
+
|
|
1733
|
+
Anchor the claim to the model with --item-refs or --calc-ref so it can
|
|
1734
|
+
be impact-traced later. See `deepcell ref claim` for what each kind
|
|
1735
|
+
is for.
|
|
1736
|
+
"""
|
|
1737
|
+
_check_single_stdin(label_file, body_file)
|
|
1738
|
+
label = _resolve_text_option(label, label_file, "label", required=True)
|
|
1739
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
1740
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
1741
|
+
payload = {
|
|
1742
|
+
k: v for k, v in {
|
|
1743
|
+
"id": claim_id,
|
|
1744
|
+
"kind": kind,
|
|
1745
|
+
"label_text": label,
|
|
1746
|
+
"body_text": body,
|
|
1747
|
+
"body_notation": body_notation,
|
|
1748
|
+
"statusRef": status_ref,
|
|
1749
|
+
"calcRef": calc_ref,
|
|
1750
|
+
"itemRefs": item_refs,
|
|
1751
|
+
"contextRefs": context_refs,
|
|
1752
|
+
"scenarioRef": scenario_ref,
|
|
1753
|
+
"customDimensions": custom_dimensions,
|
|
1754
|
+
"probability": probability,
|
|
1755
|
+
"severity": severity,
|
|
1756
|
+
"confidence": confidence,
|
|
1757
|
+
"strength": strength,
|
|
1758
|
+
"parentClaimRef": parent_claim_ref,
|
|
1759
|
+
"attribution": attribution,
|
|
1760
|
+
"status": status,
|
|
1761
|
+
}.items() if v is not None
|
|
1762
|
+
}
|
|
1763
|
+
data = ctx.client.post(
|
|
1764
|
+
"/reasoning/edit",
|
|
1765
|
+
json={"xml": xml, "op": "add_claim", "payload": payload},
|
|
1766
|
+
)
|
|
1767
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
@reasoning.command("add-assumption", cls=WriteCommand)
|
|
1771
|
+
@click.argument("filename")
|
|
1772
|
+
@click.option("--id", "assumption_id", required=True, help="New Assumption id.")
|
|
1773
|
+
@click.option("--label", default=None, help=f"<Label> text. {_LABEL_HELP}")
|
|
1774
|
+
@click.option(
|
|
1775
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
1776
|
+
help=_text_file_help("<Label>", "label"),
|
|
1777
|
+
)
|
|
1778
|
+
@click.option("--body", default=None, help="<Body> text.")
|
|
1779
|
+
@click.option(
|
|
1780
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
1781
|
+
help=_text_file_help("<Body>", "body"),
|
|
1782
|
+
)
|
|
1783
|
+
@click.option(
|
|
1784
|
+
"--body-notation", "body_notation", default=None,
|
|
1785
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
1786
|
+
help=_BODY_NOTATION_HELP,
|
|
1787
|
+
)
|
|
1788
|
+
@click.option(
|
|
1789
|
+
"--status-ref",
|
|
1790
|
+
default=None,
|
|
1791
|
+
help="Model Status dimension id (e.g. actual / projected) — NOT the "
|
|
1792
|
+
"assumption's lifecycle state (that is --status).",
|
|
1793
|
+
)
|
|
1794
|
+
@click.option(
|
|
1795
|
+
"--calc-ref",
|
|
1796
|
+
default=None,
|
|
1797
|
+
help="CalcDef id anchoring this assumption to a formula in the model.",
|
|
1798
|
+
)
|
|
1799
|
+
@click.option(
|
|
1800
|
+
"--item-refs", default=None,
|
|
1801
|
+
help="Comma-separated item ids the assumption constrains. When a "
|
|
1802
|
+
"Spreadsheet exists, omitting it triggers unanchored_assumption because "
|
|
1803
|
+
"the premise cannot be item-impact-traced as actuals drift in. A "
|
|
1804
|
+
"qualitative no-grid file and macro assumptions may legitimately omit it.",
|
|
1805
|
+
)
|
|
1806
|
+
@click.option(
|
|
1807
|
+
"--confidence",
|
|
1808
|
+
default=None,
|
|
1809
|
+
type=float,
|
|
1810
|
+
help="How strongly you hold this assumption, 0-1.",
|
|
1811
|
+
)
|
|
1812
|
+
@click.option(
|
|
1813
|
+
"--status",
|
|
1814
|
+
default=None,
|
|
1815
|
+
type=click.Choice(_ASSUMPTION_STATUSES),
|
|
1816
|
+
help="Assumption lifecycle state: holding until something contradicts it, "
|
|
1817
|
+
"broken once actuals do, superseded when a newer assumption replaces it.",
|
|
1818
|
+
)
|
|
1819
|
+
@click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
|
|
1820
|
+
@click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
|
|
1821
|
+
@click.option(
|
|
1822
|
+
"--parent-claim-ref", default=None,
|
|
1823
|
+
help="Claim id this assumption hangs under (hierarchy, not an Argument edge).",
|
|
1824
|
+
)
|
|
1825
|
+
@_coverage_anchor_options
|
|
1826
|
+
@pass_ctx
|
|
1827
|
+
def reasoning_add_assumption_cmd(
|
|
1828
|
+
ctx: Ctx,
|
|
1829
|
+
filename: str,
|
|
1830
|
+
assumption_id: str,
|
|
1831
|
+
label: str | None,
|
|
1832
|
+
label_file: str | None,
|
|
1833
|
+
body: str | None,
|
|
1834
|
+
body_file: str | None,
|
|
1835
|
+
body_notation: str | None,
|
|
1836
|
+
status_ref: str | None,
|
|
1837
|
+
calc_ref: str | None,
|
|
1838
|
+
item_refs: str | None,
|
|
1839
|
+
context_refs: str | None,
|
|
1840
|
+
scenario_ref: str | None,
|
|
1841
|
+
custom_dimensions: str | None,
|
|
1842
|
+
confidence: float | None,
|
|
1843
|
+
status: str | None,
|
|
1844
|
+
verified_at: str | None,
|
|
1845
|
+
broken_at: str | None,
|
|
1846
|
+
parent_claim_ref: str | None,
|
|
1847
|
+
) -> None:
|
|
1848
|
+
"""Add a new <Assumption> to FILENAME's <Reasoning> section."""
|
|
1849
|
+
_check_single_stdin(label_file, body_file)
|
|
1850
|
+
label = _resolve_text_option(label, label_file, "label", required=True)
|
|
1851
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
1852
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
1853
|
+
payload = {
|
|
1854
|
+
k: v for k, v in {
|
|
1855
|
+
"id": assumption_id,
|
|
1856
|
+
"label_text": label,
|
|
1857
|
+
"body_text": body,
|
|
1858
|
+
"body_notation": body_notation,
|
|
1859
|
+
"statusRef": status_ref,
|
|
1860
|
+
"calcRef": calc_ref,
|
|
1861
|
+
"itemRefs": item_refs,
|
|
1862
|
+
"contextRefs": context_refs,
|
|
1863
|
+
"scenarioRef": scenario_ref,
|
|
1864
|
+
"customDimensions": custom_dimensions,
|
|
1865
|
+
"confidence": confidence,
|
|
1866
|
+
"status": status,
|
|
1867
|
+
"verifiedAt": verified_at,
|
|
1868
|
+
"brokenAt": broken_at,
|
|
1869
|
+
"parentClaimRef": parent_claim_ref,
|
|
1870
|
+
}.items() if v is not None
|
|
1871
|
+
}
|
|
1872
|
+
data = ctx.client.post(
|
|
1873
|
+
"/reasoning/edit",
|
|
1874
|
+
json={"xml": xml, "op": "add_assumption", "payload": payload},
|
|
1875
|
+
)
|
|
1876
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def _update_reasoning_node(
|
|
1880
|
+
ctx: Ctx, filename: str, op: str, target_id: str | None, payload: dict,
|
|
1881
|
+
address: dict | None = None,
|
|
1882
|
+
) -> None:
|
|
1883
|
+
"""Shared body for update-claim / update-assumption: require at least
|
|
1884
|
+
one attribute, post the partial payload through /reasoning/edit, and
|
|
1885
|
+
persist. The backend merges the payload into the existing node.
|
|
1886
|
+
|
|
1887
|
+
`address` is the edge triple `update-argument` may give instead of an id;
|
|
1888
|
+
it rides on the request envelope beside `target_id`, never inside the
|
|
1889
|
+
payload, because the payload is the patch and the triple is the address.
|
|
1890
|
+
"""
|
|
1891
|
+
if not payload:
|
|
1892
|
+
raise click.ClickException(
|
|
1893
|
+
"Nothing to update — pass at least one attribute option "
|
|
1894
|
+
"(e.g. --item-refs, --calc-ref, --label)."
|
|
1895
|
+
)
|
|
1896
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
1897
|
+
data = ctx.client.post(
|
|
1898
|
+
"/reasoning/edit",
|
|
1899
|
+
json={
|
|
1900
|
+
"xml": xml, "op": op, "target_id": target_id, "payload": payload,
|
|
1901
|
+
**(address or {}),
|
|
1902
|
+
},
|
|
1903
|
+
)
|
|
1904
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
@reasoning.command("update-claim", cls=FileFirstWriteCommand)
|
|
1908
|
+
@click.argument("filename")
|
|
1909
|
+
@click.argument("claim_id")
|
|
1910
|
+
@click.option(
|
|
1911
|
+
"--kind",
|
|
1912
|
+
default=None,
|
|
1913
|
+
type=click.Choice(_CLAIM_KINDS),
|
|
1914
|
+
help="Reclassify the claim — see `deepcell reasoning add-claim --help` "
|
|
1915
|
+
"for what each kind means.",
|
|
1916
|
+
)
|
|
1917
|
+
@click.option(
|
|
1918
|
+
"--label", default=None,
|
|
1919
|
+
help=f"Replacement <Label> text. {_LABEL_HELP}",
|
|
1920
|
+
)
|
|
1921
|
+
@click.option(
|
|
1922
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
1923
|
+
help=_text_file_help("replacement <Label>", "label"),
|
|
1924
|
+
)
|
|
1925
|
+
@click.option("--body", default=None, help="Replacement <Body> text.")
|
|
1926
|
+
@click.option(
|
|
1927
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
1928
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
1929
|
+
)
|
|
1930
|
+
@click.option(
|
|
1931
|
+
"--body-notation", "body_notation", default=None,
|
|
1932
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
1933
|
+
help=_BODY_NOTATION_HELP,
|
|
1934
|
+
)
|
|
1935
|
+
@click.option(
|
|
1936
|
+
"--status-ref", default=None,
|
|
1937
|
+
help="Model Status dimension id (e.g. actual / projected) — NOT the "
|
|
1938
|
+
"claim lifecycle state (that is --status).",
|
|
1939
|
+
)
|
|
1940
|
+
@click.option(
|
|
1941
|
+
"--calc-ref", default=None,
|
|
1942
|
+
help="CalcDef id anchoring this claim to a formula in the model.",
|
|
1943
|
+
)
|
|
1944
|
+
@click.option(
|
|
1945
|
+
"--item-refs", default=None,
|
|
1946
|
+
help="Comma-separated item ids anchoring this claim to a Spreadsheet — "
|
|
1947
|
+
"the fix for unanchored_claim when the file has Items.",
|
|
1948
|
+
)
|
|
1949
|
+
@click.option(
|
|
1950
|
+
"--probability", default=None, type=float,
|
|
1951
|
+
help="Likelihood, 0-1 (required for kind=risk / catalyst).",
|
|
1952
|
+
)
|
|
1953
|
+
@click.option(
|
|
1954
|
+
"--severity", default=None, type=click.Choice(_SEVERITY_VALUES),
|
|
1955
|
+
help="Qualitative impact (required for kind=risk).",
|
|
1956
|
+
)
|
|
1957
|
+
@click.option(
|
|
1958
|
+
"--confidence", default=None, type=float,
|
|
1959
|
+
help="How strongly you hold this claim, 0-1.",
|
|
1960
|
+
)
|
|
1961
|
+
@click.option(
|
|
1962
|
+
"--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
|
|
1963
|
+
help="How strongly the claim is held, qualitatively. Independent of "
|
|
1964
|
+
"--confidence (the numeric form); a claim may carry both.",
|
|
1965
|
+
)
|
|
1966
|
+
@click.option(
|
|
1967
|
+
"--parent-claim-ref", default=None,
|
|
1968
|
+
help="Claim id this one hangs under (hierarchy, not an Argument edge).",
|
|
1969
|
+
)
|
|
1970
|
+
@click.option(
|
|
1971
|
+
"--attribution", default=None,
|
|
1972
|
+
help="Who holds this view (required for kind=market_consensus).",
|
|
1973
|
+
)
|
|
1974
|
+
@click.option(
|
|
1975
|
+
"--status", default=None, type=click.Choice(_CLAIM_STATUSES),
|
|
1976
|
+
help="Claim lifecycle state — NOT the model's Status dimension "
|
|
1977
|
+
"(that is --status-ref).",
|
|
1978
|
+
)
|
|
1979
|
+
@_coverage_anchor_options
|
|
1980
|
+
@pass_ctx
|
|
1981
|
+
def reasoning_update_claim_cmd(
|
|
1982
|
+
ctx: Ctx,
|
|
1983
|
+
filename: str,
|
|
1984
|
+
claim_id: str,
|
|
1985
|
+
kind: str | None,
|
|
1986
|
+
label: str | None,
|
|
1987
|
+
label_file: str | None,
|
|
1988
|
+
body: str | None,
|
|
1989
|
+
body_file: str | None,
|
|
1990
|
+
body_notation: str | None,
|
|
1991
|
+
status_ref: str | None,
|
|
1992
|
+
calc_ref: str | None,
|
|
1993
|
+
item_refs: str | None,
|
|
1994
|
+
context_refs: str | None,
|
|
1995
|
+
scenario_ref: str | None,
|
|
1996
|
+
custom_dimensions: str | None,
|
|
1997
|
+
probability: float | None,
|
|
1998
|
+
severity: str | None,
|
|
1999
|
+
confidence: float | None,
|
|
2000
|
+
strength: str | None,
|
|
2001
|
+
parent_claim_ref: str | None,
|
|
2002
|
+
attribution: str | None,
|
|
2003
|
+
status: str | None,
|
|
2004
|
+
) -> None:
|
|
2005
|
+
"""Update attributes on an existing <Claim> CLAIM_ID in FILENAME.
|
|
2006
|
+
|
|
2007
|
+
Partial update: only the options you pass change; everything else on
|
|
2008
|
+
the node is preserved. Typical use: anchor an existing claim
|
|
2009
|
+
(`update-claim m.deepcell t1 --item-refs Revenue`).
|
|
2010
|
+
"""
|
|
2011
|
+
_check_single_stdin(label_file, body_file)
|
|
2012
|
+
label = _resolve_text_option(label, label_file, "label")
|
|
2013
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2014
|
+
payload = {
|
|
2015
|
+
k: v for k, v in {
|
|
2016
|
+
"kind": kind,
|
|
2017
|
+
"label_text": label,
|
|
2018
|
+
"body_text": body,
|
|
2019
|
+
"body_notation": body_notation,
|
|
2020
|
+
"statusRef": status_ref,
|
|
2021
|
+
"calcRef": calc_ref,
|
|
2022
|
+
"itemRefs": item_refs,
|
|
2023
|
+
"contextRefs": context_refs,
|
|
2024
|
+
"scenarioRef": scenario_ref,
|
|
2025
|
+
"customDimensions": custom_dimensions,
|
|
2026
|
+
"probability": probability,
|
|
2027
|
+
"severity": severity,
|
|
2028
|
+
"confidence": confidence,
|
|
2029
|
+
"strength": strength,
|
|
2030
|
+
"parentClaimRef": parent_claim_ref,
|
|
2031
|
+
"attribution": attribution,
|
|
2032
|
+
"status": status,
|
|
2033
|
+
}.items() if v is not None
|
|
2034
|
+
}
|
|
2035
|
+
_update_reasoning_node(ctx, filename, "update_claim", claim_id, payload)
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
@reasoning.command("update-assumption", cls=FileFirstWriteCommand)
|
|
2039
|
+
@click.argument("filename")
|
|
2040
|
+
@click.argument("assumption_id")
|
|
2041
|
+
@click.option(
|
|
2042
|
+
"--label", default=None,
|
|
2043
|
+
help=f"Replacement <Label> text. {_LABEL_HELP}",
|
|
2044
|
+
)
|
|
2045
|
+
@click.option(
|
|
2046
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
2047
|
+
help=_text_file_help("replacement <Label>", "label"),
|
|
2048
|
+
)
|
|
2049
|
+
@click.option("--body", default=None, help="Replacement <Body> text.")
|
|
2050
|
+
@click.option(
|
|
2051
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2052
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
2053
|
+
)
|
|
2054
|
+
@click.option(
|
|
2055
|
+
"--body-notation", "body_notation", default=None,
|
|
2056
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
2057
|
+
help=_BODY_NOTATION_HELP,
|
|
2058
|
+
)
|
|
2059
|
+
@click.option(
|
|
2060
|
+
"--status-ref",
|
|
2061
|
+
default=None,
|
|
2062
|
+
help="Model Status dimension id (e.g. actual / projected) — NOT the "
|
|
2063
|
+
"assumption's lifecycle state (that is --status).",
|
|
2064
|
+
)
|
|
2065
|
+
@click.option(
|
|
2066
|
+
"--calc-ref",
|
|
2067
|
+
default=None,
|
|
2068
|
+
help="CalcDef id anchoring this assumption to a formula in the model.",
|
|
2069
|
+
)
|
|
2070
|
+
@click.option(
|
|
2071
|
+
"--item-refs", default=None,
|
|
2072
|
+
help="Comma-separated item ids the assumption constrains — the fix for "
|
|
2073
|
+
"unanchored_assumption when the file has Items.",
|
|
2074
|
+
)
|
|
2075
|
+
@click.option(
|
|
2076
|
+
"--confidence",
|
|
2077
|
+
default=None,
|
|
2078
|
+
type=float,
|
|
2079
|
+
help="How strongly you hold this assumption, 0-1.",
|
|
2080
|
+
)
|
|
2081
|
+
@click.option(
|
|
2082
|
+
"--status",
|
|
2083
|
+
default=None,
|
|
2084
|
+
type=click.Choice(_ASSUMPTION_STATUSES),
|
|
2085
|
+
help="Assumption lifecycle state: holding until something contradicts it, "
|
|
2086
|
+
"broken once actuals do, superseded when a newer assumption replaces it.",
|
|
2087
|
+
)
|
|
2088
|
+
@click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
|
|
2089
|
+
@click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
|
|
2090
|
+
@_coverage_anchor_options
|
|
2091
|
+
@pass_ctx
|
|
2092
|
+
def reasoning_update_assumption_cmd(
|
|
2093
|
+
ctx: Ctx,
|
|
2094
|
+
filename: str,
|
|
2095
|
+
assumption_id: str,
|
|
2096
|
+
label: str | None,
|
|
2097
|
+
label_file: str | None,
|
|
2098
|
+
body: str | None,
|
|
2099
|
+
body_file: str | None,
|
|
2100
|
+
body_notation: str | None,
|
|
2101
|
+
status_ref: str | None,
|
|
2102
|
+
calc_ref: str | None,
|
|
2103
|
+
item_refs: str | None,
|
|
2104
|
+
context_refs: str | None,
|
|
2105
|
+
scenario_ref: str | None,
|
|
2106
|
+
custom_dimensions: str | None,
|
|
2107
|
+
confidence: float | None,
|
|
2108
|
+
status: str | None,
|
|
2109
|
+
verified_at: str | None,
|
|
2110
|
+
broken_at: str | None,
|
|
2111
|
+
) -> None:
|
|
2112
|
+
"""Update attributes on an existing <Assumption> ASSUMPTION_ID.
|
|
2113
|
+
|
|
2114
|
+
Partial update: only the options you pass change; everything else on
|
|
2115
|
+
the node is preserved.
|
|
2116
|
+
|
|
2117
|
+
A lifecycle flip is a plain update, not a supersede: `--status broken`
|
|
2118
|
+
records *that* the assumption failed, `--broken-at` records *when*.
|
|
2119
|
+
Supersede only when replacing the assumption's content.
|
|
2120
|
+
"""
|
|
2121
|
+
_check_single_stdin(label_file, body_file)
|
|
2122
|
+
label = _resolve_text_option(label, label_file, "label")
|
|
2123
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2124
|
+
payload = {
|
|
2125
|
+
k: v for k, v in {
|
|
2126
|
+
"label_text": label,
|
|
2127
|
+
"body_text": body,
|
|
2128
|
+
"body_notation": body_notation,
|
|
2129
|
+
"statusRef": status_ref,
|
|
2130
|
+
"calcRef": calc_ref,
|
|
2131
|
+
"itemRefs": item_refs,
|
|
2132
|
+
"contextRefs": context_refs,
|
|
2133
|
+
"scenarioRef": scenario_ref,
|
|
2134
|
+
"customDimensions": custom_dimensions,
|
|
2135
|
+
"confidence": confidence,
|
|
2136
|
+
"status": status,
|
|
2137
|
+
"verifiedAt": verified_at,
|
|
2138
|
+
"brokenAt": broken_at,
|
|
2139
|
+
}.items() if v is not None
|
|
2140
|
+
}
|
|
2141
|
+
_update_reasoning_node(
|
|
2142
|
+
ctx, filename, "update_assumption", assumption_id, payload,
|
|
2143
|
+
)
|
|
2144
|
+
|
|
2145
|
+
|
|
2146
|
+
@reasoning.command("add-evidence", cls=WriteCommand)
|
|
2147
|
+
@click.argument("filename")
|
|
2148
|
+
@click.option("--id", "evidence_id", required=True, help="New Evidence id.")
|
|
2149
|
+
@click.option("--source-ref", required=True, help="sourceId of a <Source> in <SourceDefinitions>.")
|
|
2150
|
+
@click.option("--excerpt", default=None, help="<Excerpt> text.")
|
|
2151
|
+
@click.option(
|
|
2152
|
+
"--excerpt-file", "excerpt_file", type=_TEXT_FILE, default=None,
|
|
2153
|
+
help=_text_file_help("<Excerpt>", "excerpt"),
|
|
2154
|
+
)
|
|
2155
|
+
@click.option("--at", "at", default=None, help="Position in the source (page:47, t:00:14:32, text:HEADING).")
|
|
2156
|
+
@click.option("--reliability", default=None, help="primary | secondary | tertiary.")
|
|
2157
|
+
@click.option("--effective-date", default=None, help="ISO8601 date.")
|
|
2158
|
+
@click.option("--retrieved-at", default=None, help="ISO8601 date.")
|
|
2159
|
+
@click.option(
|
|
2160
|
+
"--item-refs", "item_refs", default=None,
|
|
2161
|
+
help="Comma-separated item ids this evidence bears on. Anchoring it here "
|
|
2162
|
+
"is what lets `reasoning impact` reach the evidence when one of those "
|
|
2163
|
+
"items moves; `update-evidence` can retrofit it later.",
|
|
2164
|
+
)
|
|
2165
|
+
@click.option(
|
|
2166
|
+
"--status-ref", "status_ref", default=None,
|
|
2167
|
+
help="Model Status dimension id (e.g. actual / projected) the evidence "
|
|
2168
|
+
"speaks to.",
|
|
2169
|
+
)
|
|
2170
|
+
@_coverage_anchor_options
|
|
2171
|
+
@pass_ctx
|
|
2172
|
+
def reasoning_add_evidence_cmd(
|
|
2173
|
+
ctx: Ctx,
|
|
2174
|
+
filename: str,
|
|
2175
|
+
evidence_id: str,
|
|
2176
|
+
source_ref: str,
|
|
2177
|
+
excerpt: str | None,
|
|
2178
|
+
excerpt_file: str | None,
|
|
2179
|
+
at: str | None,
|
|
2180
|
+
reliability: str | None,
|
|
2181
|
+
effective_date: str | None,
|
|
2182
|
+
retrieved_at: str | None,
|
|
2183
|
+
item_refs: str | None,
|
|
2184
|
+
status_ref: str | None,
|
|
2185
|
+
context_refs: str | None,
|
|
2186
|
+
scenario_ref: str | None,
|
|
2187
|
+
custom_dimensions: str | None,
|
|
2188
|
+
) -> None:
|
|
2189
|
+
"""Add a new <Evidence> to FILENAME's <Reasoning> section."""
|
|
2190
|
+
excerpt = _resolve_text_option(excerpt, excerpt_file, "excerpt")
|
|
2191
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2192
|
+
payload = {
|
|
2193
|
+
k: v for k, v in {
|
|
2194
|
+
"id": evidence_id,
|
|
2195
|
+
"sourceRef": source_ref,
|
|
2196
|
+
"excerpt_text": excerpt,
|
|
2197
|
+
"at": at,
|
|
2198
|
+
"reliability": reliability,
|
|
2199
|
+
"effectiveDate": effective_date,
|
|
2200
|
+
"retrievedAt": retrieved_at,
|
|
2201
|
+
"itemRefs": item_refs,
|
|
2202
|
+
"statusRef": status_ref,
|
|
2203
|
+
"contextRefs": context_refs,
|
|
2204
|
+
"scenarioRef": scenario_ref,
|
|
2205
|
+
"customDimensions": custom_dimensions,
|
|
2206
|
+
}.items() if v is not None
|
|
2207
|
+
}
|
|
2208
|
+
data = ctx.client.post(
|
|
2209
|
+
"/reasoning/edit",
|
|
2210
|
+
json={"xml": xml, "op": "add_evidence", "payload": payload},
|
|
2211
|
+
)
|
|
2212
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
@reasoning.command("set-conclusion", cls=FileFirstWriteCommand)
|
|
2216
|
+
@click.argument("filename")
|
|
2217
|
+
@click.argument("claim_id", required=False)
|
|
2218
|
+
@click.option(
|
|
2219
|
+
"--clear",
|
|
2220
|
+
is_flag=True,
|
|
2221
|
+
default=False,
|
|
2222
|
+
help="Remove the declaration and fall back to deriving the apex from the "
|
|
2223
|
+
"argument topology.",
|
|
2224
|
+
)
|
|
2225
|
+
@pass_ctx
|
|
2226
|
+
def reasoning_set_conclusion_cmd(
|
|
2227
|
+
ctx: Ctx,
|
|
2228
|
+
filename: str,
|
|
2229
|
+
claim_id: str | None,
|
|
2230
|
+
clear: bool,
|
|
2231
|
+
) -> None:
|
|
2232
|
+
"""Declare which Claim is FILENAME's main conclusion.
|
|
2233
|
+
|
|
2234
|
+
Writes `<Reasoning conclusionRef="CLAIM_ID">`. Readers open the document on
|
|
2235
|
+
that Claim instead of guessing at the apex from the argument graph — which
|
|
2236
|
+
is wrong whenever a document chains its claims chronologically, because a
|
|
2237
|
+
`depends_on` edge to last week's claim is structurally identical to
|
|
2238
|
+
subordination.
|
|
2239
|
+
|
|
2240
|
+
This does not create a Claim. Author the apex `thesis` with `add-claim`
|
|
2241
|
+
first, then point the section at it.
|
|
2242
|
+
|
|
2243
|
+
Pass `--clear` to remove the declaration.
|
|
2244
|
+
"""
|
|
2245
|
+
if clear and claim_id:
|
|
2246
|
+
raise click.UsageError("pass either CLAIM_ID or --clear, not both")
|
|
2247
|
+
if not clear and not claim_id:
|
|
2248
|
+
raise click.UsageError("CLAIM_ID is required (or pass --clear)")
|
|
2249
|
+
|
|
2250
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2251
|
+
data = ctx.client.post(
|
|
2252
|
+
"/reasoning/edit",
|
|
2253
|
+
json={
|
|
2254
|
+
"xml": xml,
|
|
2255
|
+
"op": "set_conclusion",
|
|
2256
|
+
"target_id": claim_id,
|
|
2257
|
+
"clear": clear,
|
|
2258
|
+
},
|
|
2259
|
+
)
|
|
2260
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
@reasoning.command("set-key-question", cls=FileFirstWriteCommand)
|
|
2264
|
+
@click.argument("filename")
|
|
2265
|
+
@click.argument("claim_id", required=False)
|
|
2266
|
+
@click.option(
|
|
2267
|
+
"--clear",
|
|
2268
|
+
is_flag=True,
|
|
2269
|
+
default=False,
|
|
2270
|
+
help="Remove the declaration. The header then falls back to the `answers` "
|
|
2271
|
+
"edge off the conclusion, then to the document's only question, and "
|
|
2272
|
+
"otherwise shows nothing.",
|
|
2273
|
+
)
|
|
2274
|
+
@click.option(
|
|
2275
|
+
"--needs-review",
|
|
2276
|
+
"needs_review",
|
|
2277
|
+
is_flag=True,
|
|
2278
|
+
default=False,
|
|
2279
|
+
help="Mark the question as inferred rather than stated, so it renders as "
|
|
2280
|
+
"provisional until someone confirms it. Declaring without this flag "
|
|
2281
|
+
"clears it.",
|
|
2282
|
+
)
|
|
2283
|
+
@pass_ctx
|
|
2284
|
+
def reasoning_set_key_question_cmd(
|
|
2285
|
+
ctx: Ctx,
|
|
2286
|
+
filename: str,
|
|
2287
|
+
claim_id: str | None,
|
|
2288
|
+
clear: bool,
|
|
2289
|
+
needs_review: bool,
|
|
2290
|
+
) -> None:
|
|
2291
|
+
"""Declare which question FILENAME exists to answer.
|
|
2292
|
+
|
|
2293
|
+
Writes `<Reasoning keyQuestionRef="CLAIM_ID">`. Readers show that question
|
|
2294
|
+
in the Reasoning header instead of taking whichever `kind="question"` Claim
|
|
2295
|
+
happens to be authored first — which meant reordering, importing or
|
|
2296
|
+
deleting a question silently changed what the document appeared to be
|
|
2297
|
+
asking, in 15 of this repo's 34 reasoning-bearing documents.
|
|
2298
|
+
|
|
2299
|
+
This does not create a Claim. Author it with `add-claim --kind question`
|
|
2300
|
+
first, then point the section at it, and record the pairing with
|
|
2301
|
+
`add-argument --from-id <conclusion> --rel answers --to-id CLAIM_ID`.
|
|
2302
|
+
|
|
2303
|
+
Pass `--clear` to remove the declaration.
|
|
2304
|
+
"""
|
|
2305
|
+
if clear and claim_id:
|
|
2306
|
+
raise click.UsageError("pass either CLAIM_ID or --clear, not both")
|
|
2307
|
+
if not clear and not claim_id:
|
|
2308
|
+
raise click.UsageError("CLAIM_ID is required (or pass --clear)")
|
|
2309
|
+
if clear and needs_review:
|
|
2310
|
+
raise click.UsageError("--needs-review makes no sense with --clear")
|
|
2311
|
+
|
|
2312
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2313
|
+
data = ctx.client.post(
|
|
2314
|
+
"/reasoning/edit",
|
|
2315
|
+
json={
|
|
2316
|
+
"xml": xml,
|
|
2317
|
+
"op": "set_key_question",
|
|
2318
|
+
"target_id": claim_id,
|
|
2319
|
+
"clear": clear,
|
|
2320
|
+
"needs_review": needs_review,
|
|
2321
|
+
},
|
|
2322
|
+
)
|
|
2323
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2324
|
+
|
|
2325
|
+
|
|
2326
|
+
@reasoning.command("set-exhibit", cls=FileFirstWriteCommand)
|
|
2327
|
+
@click.argument("filename")
|
|
2328
|
+
@click.argument("exhibit_ref", required=False)
|
|
2329
|
+
@click.option(
|
|
2330
|
+
"--clear",
|
|
2331
|
+
is_flag=True,
|
|
2332
|
+
default=False,
|
|
2333
|
+
help="Remove the declaration. The conclusion then renders full width.",
|
|
2334
|
+
)
|
|
2335
|
+
@pass_ctx
|
|
2336
|
+
def reasoning_set_exhibit_cmd(
|
|
2337
|
+
ctx: Ctx,
|
|
2338
|
+
filename: str,
|
|
2339
|
+
exhibit_ref: str | None,
|
|
2340
|
+
clear: bool,
|
|
2341
|
+
) -> None:
|
|
2342
|
+
"""Declare which deck or slide to show beside FILENAME's conclusion.
|
|
2343
|
+
|
|
2344
|
+
Writes `<Reasoning exhibitRef="EXHIBIT_REF">`, where EXHIBIT_REF is a
|
|
2345
|
+
`deepcell:` URI — `deepcell:deck/executive` or
|
|
2346
|
+
`deepcell:slide/executive.overview`.
|
|
2347
|
+
|
|
2348
|
+
A URI rather than a bare id, unlike its `set-conclusion` and
|
|
2349
|
+
`set-key-question` neighbours: those name Claims inside `<Reasoning>`, and
|
|
2350
|
+
this one points at another surface.
|
|
2351
|
+
|
|
2352
|
+
Optional, and it degrades. No deck in the file means no slot; a reference
|
|
2353
|
+
that does not resolve renders the conclusion full width and lints, because
|
|
2354
|
+
a broken link never breaks a render.
|
|
2355
|
+
|
|
2356
|
+
Pass `--clear` to remove the declaration.
|
|
2357
|
+
"""
|
|
2358
|
+
if clear and exhibit_ref:
|
|
2359
|
+
raise click.UsageError("pass either EXHIBIT_REF or --clear, not both")
|
|
2360
|
+
if not clear and not exhibit_ref:
|
|
2361
|
+
raise click.UsageError("EXHIBIT_REF is required (or pass --clear)")
|
|
2362
|
+
|
|
2363
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2364
|
+
data = ctx.client.post(
|
|
2365
|
+
"/reasoning/edit",
|
|
2366
|
+
json={
|
|
2367
|
+
"xml": xml,
|
|
2368
|
+
"op": "set_exhibit",
|
|
2369
|
+
"exhibit_ref": exhibit_ref,
|
|
2370
|
+
"clear": clear,
|
|
2371
|
+
},
|
|
2372
|
+
)
|
|
2373
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2374
|
+
|
|
2375
|
+
|
|
2376
|
+
#: Built from the Choice so the sentence can never list fewer rels than the
|
|
2377
|
+
#: flag accepts — it did, and `answers` was the one it left out.
|
|
2378
|
+
_ADD_ARGUMENT_HELP = (
|
|
2379
|
+
"Add a new <Argument> edge to FILENAME's <Reasoning> section.\n\n"
|
|
2380
|
+
"`--from-id` and `--to-id` are required; `--rel` picks the edge type ("
|
|
2381
|
+
+ ", ".join(_ARGUMENT_RELS)
|
|
2382
|
+
+ ")."
|
|
2383
|
+
)
|
|
2384
|
+
|
|
2385
|
+
|
|
2386
|
+
@reasoning.command("add-argument", cls=WriteCommand, help=_ADD_ARGUMENT_HELP)
|
|
2387
|
+
@click.argument("filename")
|
|
2388
|
+
@click.option("--from-id", "from_id", required=True, help="Source node id.")
|
|
2389
|
+
@click.option("--to-id", "to_id", required=True, help="Target node id.")
|
|
2390
|
+
@click.option(
|
|
2391
|
+
"--rel",
|
|
2392
|
+
required=True,
|
|
2393
|
+
type=click.Choice(_ARGUMENT_RELS),
|
|
2394
|
+
help="How --from-id relates to --to-id: supports / refutes (evidential), "
|
|
2395
|
+
"depends_on (falsifying the target falsifies the source), derives_from "
|
|
2396
|
+
"(computed or inferred from it), answers (this conclusion settles that "
|
|
2397
|
+
"question), variant_of (your view vs a consensus claim), supersedes "
|
|
2398
|
+
"(replaces an older node), contradicts, references. "
|
|
2399
|
+
"See `deepcell ref rel`.",
|
|
2400
|
+
)
|
|
2401
|
+
@click.option("--id", "arg_id", default=None, help="Argument id (auto-generated if omitted).")
|
|
2402
|
+
@click.option(
|
|
2403
|
+
"--weight",
|
|
2404
|
+
default=None,
|
|
2405
|
+
type=float,
|
|
2406
|
+
help="How much this edge carries, 0-1 (optional).",
|
|
2407
|
+
)
|
|
2408
|
+
@click.option("--body", default=None, help="<Body> text.")
|
|
2409
|
+
@click.option(
|
|
2410
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2411
|
+
help=_text_file_help("<Body>", "body"),
|
|
2412
|
+
)
|
|
2413
|
+
@click.option(
|
|
2414
|
+
"--body-notation", "body_notation", default=None,
|
|
2415
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
2416
|
+
help=_BODY_NOTATION_HELP,
|
|
2417
|
+
)
|
|
2418
|
+
@click.option("--status", default=None, help="Argument lifecycle status (active/superseded/revoked).")
|
|
2419
|
+
@pass_ctx
|
|
2420
|
+
def reasoning_add_argument_cmd(
|
|
2421
|
+
ctx: Ctx,
|
|
2422
|
+
filename: str,
|
|
2423
|
+
from_id: str,
|
|
2424
|
+
to_id: str,
|
|
2425
|
+
rel: str,
|
|
2426
|
+
arg_id: str | None,
|
|
2427
|
+
weight: float | None,
|
|
2428
|
+
body: str | None,
|
|
2429
|
+
body_file: str | None,
|
|
2430
|
+
body_notation: str | None,
|
|
2431
|
+
status: str | None,
|
|
2432
|
+
) -> None:
|
|
2433
|
+
# Help text is `_ADD_ARGUMENT_HELP`, generated from `_ARGUMENT_RELS`.
|
|
2434
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2435
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2436
|
+
payload = {
|
|
2437
|
+
k: v for k, v in {
|
|
2438
|
+
"from": from_id,
|
|
2439
|
+
"to": to_id,
|
|
2440
|
+
"rel": rel,
|
|
2441
|
+
"id": arg_id,
|
|
2442
|
+
"weight": weight,
|
|
2443
|
+
"body_text": body,
|
|
2444
|
+
"body_notation": body_notation,
|
|
2445
|
+
"status": status,
|
|
2446
|
+
}.items() if v is not None
|
|
2447
|
+
}
|
|
2448
|
+
data = ctx.client.post(
|
|
2449
|
+
"/reasoning/edit",
|
|
2450
|
+
json={"xml": xml, "op": "add_argument", "payload": payload},
|
|
2451
|
+
)
|
|
2452
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
# ---------------------------------------------------------------------------
|
|
2456
|
+
# Supersede + delete + the remaining updates.
|
|
2457
|
+
#
|
|
2458
|
+
# `deepcell guide revise/reasoning` teaches "never delete, always supersede" and says
|
|
2459
|
+
# the supersede op does it mechanically — but no CLI command emitted it, so the
|
|
2460
|
+
# workflow the guide prescribes was unreachable from the CLI and from MCP.
|
|
2461
|
+
# ---------------------------------------------------------------------------
|
|
2462
|
+
|
|
2463
|
+
|
|
2464
|
+
def _supersede(
|
|
2465
|
+
ctx: Ctx, filename: str, op: str, target_id: str, new_node_key: str,
|
|
2466
|
+
new_node: dict[str, Any],
|
|
2467
|
+
) -> None:
|
|
2468
|
+
"""POST a supersede op: flip the old node to superseded, add the new one,
|
|
2469
|
+
and emit the <Argument rel="supersedes"> edge between them."""
|
|
2470
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2471
|
+
data = ctx.client.post(
|
|
2472
|
+
"/reasoning/edit",
|
|
2473
|
+
json={"xml": xml, "op": op, "target_id": target_id, new_node_key: new_node},
|
|
2474
|
+
)
|
|
2475
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
@reasoning.command("supersede-claim", cls=FileFirstWriteCommand)
|
|
2479
|
+
@click.argument("filename")
|
|
2480
|
+
@click.argument("claim_id")
|
|
2481
|
+
@click.option("--id", "new_id", required=True, help="Id of the replacement Claim.")
|
|
2482
|
+
@click.option(
|
|
2483
|
+
"--label", default=None,
|
|
2484
|
+
help=f"<Label> text of the replacement. {_LABEL_HELP}",
|
|
2485
|
+
)
|
|
2486
|
+
@click.option(
|
|
2487
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
2488
|
+
help=_text_file_help("replacement <Label>", "label"),
|
|
2489
|
+
)
|
|
2490
|
+
@click.option("--body", default=None, help="<Body> text of the replacement.")
|
|
2491
|
+
@click.option(
|
|
2492
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2493
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
2494
|
+
)
|
|
2495
|
+
@click.option(
|
|
2496
|
+
"--body-notation", "body_notation", default=None,
|
|
2497
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
2498
|
+
help=_BODY_NOTATION_HELP,
|
|
2499
|
+
)
|
|
2500
|
+
@click.option(
|
|
2501
|
+
"--kind",
|
|
2502
|
+
default=None,
|
|
2503
|
+
type=click.Choice(_CLAIM_KINDS),
|
|
2504
|
+
help="Kind of the replacement (defaults to the superseded claim's kind).",
|
|
2505
|
+
)
|
|
2506
|
+
@click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
|
|
2507
|
+
@click.option("--context-refs", "context_refs", default=None, help="Comma-separated context ids.")
|
|
2508
|
+
@click.option("--status-ref", "status_ref", default=None, help="Model Status dimension id.")
|
|
2509
|
+
@click.option("--calc-ref", "calc_ref", default=None, help="CalcDef id this claim is anchored to.")
|
|
2510
|
+
@click.option(
|
|
2511
|
+
"--confidence", default=None, type=float, help="Confidence in the replacement, 0-1."
|
|
2512
|
+
)
|
|
2513
|
+
@click.option(
|
|
2514
|
+
"--strength", default=None, type=click.Choice(_CLAIM_STRENGTHS),
|
|
2515
|
+
help="How strongly the replacement is held, qualitatively. Not inherited "
|
|
2516
|
+
"from the superseded claim: a reassessment that reused the old strength "
|
|
2517
|
+
"would publish a judgement nobody made.",
|
|
2518
|
+
)
|
|
2519
|
+
@_narrow_anchor_options
|
|
2520
|
+
@pass_ctx
|
|
2521
|
+
def reasoning_supersede_claim_cmd(
|
|
2522
|
+
ctx: Ctx,
|
|
2523
|
+
filename: str,
|
|
2524
|
+
claim_id: str,
|
|
2525
|
+
new_id: str,
|
|
2526
|
+
label: str | None,
|
|
2527
|
+
label_file: str | None,
|
|
2528
|
+
body: str | None,
|
|
2529
|
+
body_file: str | None,
|
|
2530
|
+
body_notation: str | None,
|
|
2531
|
+
kind: str | None,
|
|
2532
|
+
item_refs: str | None,
|
|
2533
|
+
context_refs: str | None,
|
|
2534
|
+
scenario_ref: str | None,
|
|
2535
|
+
custom_dimensions: str | None,
|
|
2536
|
+
status_ref: str | None,
|
|
2537
|
+
calc_ref: str | None,
|
|
2538
|
+
confidence: float | None,
|
|
2539
|
+
strength: str | None,
|
|
2540
|
+
) -> None:
|
|
2541
|
+
"""Replace CLAIM_ID with a new Claim, keeping the old one as history.
|
|
2542
|
+
|
|
2543
|
+
This is the canonical way to change what a Claim asserts — a Claim is a
|
|
2544
|
+
dated position, and rewriting one in place destroys the record of what
|
|
2545
|
+
was believed when. In one atomic edit it flips CLAIM_ID's status to
|
|
2546
|
+
`superseded`, adds the new Claim, and links them with
|
|
2547
|
+
`<Argument rel="supersedes">`, so `deepcell claim history` can walk back.
|
|
2548
|
+
|
|
2549
|
+
The replacement INHERITS the old claim's anchors (--item-refs,
|
|
2550
|
+
--context-refs, --status-ref, --calc-ref, and scenario / custom
|
|
2551
|
+
dimensions) for any you do not pass; passing one overrides it.
|
|
2552
|
+
|
|
2553
|
+
Supersession does not move Arguments that touch the old Claim or the
|
|
2554
|
+
document's conclusionRef. After reassessment, add current edges to the
|
|
2555
|
+
replacement and run `reasoning set-conclusion` if the old Claim was the
|
|
2556
|
+
declared conclusion. See `deepcell guide revise/premise-change`.
|
|
2557
|
+
|
|
2558
|
+
\b
|
|
2559
|
+
deepcell reasoning supersede-claim model.deepcell t_margin_v1 \\
|
|
2560
|
+
--id t_margin_v2 --label "Gross margin reaches 72% by 2027"
|
|
2561
|
+
"""
|
|
2562
|
+
_check_single_stdin(label_file, body_file)
|
|
2563
|
+
label = _resolve_text_option(label, label_file, "label", required=True)
|
|
2564
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2565
|
+
new_claim = {
|
|
2566
|
+
k: v for k, v in {
|
|
2567
|
+
"id": new_id,
|
|
2568
|
+
"kind": kind,
|
|
2569
|
+
"label_text": label,
|
|
2570
|
+
"body_text": body,
|
|
2571
|
+
"body_notation": body_notation,
|
|
2572
|
+
"itemRefs": item_refs,
|
|
2573
|
+
"contextRefs": context_refs,
|
|
2574
|
+
"scenarioRef": scenario_ref,
|
|
2575
|
+
"customDimensions": custom_dimensions,
|
|
2576
|
+
"statusRef": status_ref,
|
|
2577
|
+
"calcRef": calc_ref,
|
|
2578
|
+
"confidence": confidence,
|
|
2579
|
+
"strength": strength,
|
|
2580
|
+
}.items() if v is not None
|
|
2581
|
+
}
|
|
2582
|
+
_supersede(ctx, filename, "supersede_claim", claim_id, "new_claim", new_claim)
|
|
2583
|
+
|
|
2584
|
+
|
|
2585
|
+
@reasoning.command("supersede-assumption", cls=FileFirstWriteCommand)
|
|
2586
|
+
@click.argument("filename")
|
|
2587
|
+
@click.argument("assumption_id")
|
|
2588
|
+
@click.option("--id", "new_id", required=True, help="Id of the replacement Assumption.")
|
|
2589
|
+
@click.option(
|
|
2590
|
+
"--label", default=None,
|
|
2591
|
+
help=f"<Label> text of the replacement. {_LABEL_HELP}",
|
|
2592
|
+
)
|
|
2593
|
+
@click.option(
|
|
2594
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
2595
|
+
help=_text_file_help("replacement <Label>", "label"),
|
|
2596
|
+
)
|
|
2597
|
+
@click.option("--body", default=None, help="<Body> text of the replacement.")
|
|
2598
|
+
@click.option(
|
|
2599
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2600
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
2601
|
+
)
|
|
2602
|
+
@click.option(
|
|
2603
|
+
"--body-notation", "body_notation", default=None,
|
|
2604
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
2605
|
+
help=_BODY_NOTATION_HELP,
|
|
2606
|
+
)
|
|
2607
|
+
@click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
|
|
2608
|
+
@click.option("--status-ref", "status_ref", default=None, help="Model Status dimension id.")
|
|
2609
|
+
@click.option("--calc-ref", "calc_ref", default=None, help="CalcDef id this assumption drives.")
|
|
2610
|
+
@click.option(
|
|
2611
|
+
"--confidence", default=None, type=float, help="Confidence in the replacement, 0-1."
|
|
2612
|
+
)
|
|
2613
|
+
@click.option(
|
|
2614
|
+
"--status",
|
|
2615
|
+
default=None,
|
|
2616
|
+
type=click.Choice(_ASSUMPTION_STATUSES),
|
|
2617
|
+
help="Lifecycle status of the replacement.",
|
|
2618
|
+
)
|
|
2619
|
+
@click.option("--verified-at", "verified_at", default=None, help=_VERIFIED_AT_HELP)
|
|
2620
|
+
@click.option("--broken-at", "broken_at", default=None, help=_BROKEN_AT_HELP)
|
|
2621
|
+
@_coverage_anchor_options
|
|
2622
|
+
@pass_ctx
|
|
2623
|
+
def reasoning_supersede_assumption_cmd(
|
|
2624
|
+
ctx: Ctx,
|
|
2625
|
+
filename: str,
|
|
2626
|
+
assumption_id: str,
|
|
2627
|
+
new_id: str,
|
|
2628
|
+
label: str | None,
|
|
2629
|
+
label_file: str | None,
|
|
2630
|
+
body: str | None,
|
|
2631
|
+
body_file: str | None,
|
|
2632
|
+
body_notation: str | None,
|
|
2633
|
+
item_refs: str | None,
|
|
2634
|
+
context_refs: str | None,
|
|
2635
|
+
scenario_ref: str | None,
|
|
2636
|
+
custom_dimensions: str | None,
|
|
2637
|
+
status_ref: str | None,
|
|
2638
|
+
calc_ref: str | None,
|
|
2639
|
+
confidence: float | None,
|
|
2640
|
+
status: str | None,
|
|
2641
|
+
verified_at: str | None,
|
|
2642
|
+
broken_at: str | None,
|
|
2643
|
+
) -> None:
|
|
2644
|
+
"""Replace ASSUMPTION_ID with a new Assumption, keeping the old as history.
|
|
2645
|
+
|
|
2646
|
+
Same mechanics as `supersede-claim`: the old node's status becomes
|
|
2647
|
+
`superseded`, the new node is added, and a `supersedes` Argument links
|
|
2648
|
+
them. Every Claim that depended on the old assumption keeps its edge, so
|
|
2649
|
+
`deepcell assumption impact` still answers for the superseded node.
|
|
2650
|
+
|
|
2651
|
+
Supersession does not attach those Claims to the replacement. After
|
|
2652
|
+
reassessing each surviving Claim, add a new `depends_on` Argument to the
|
|
2653
|
+
replacement and keep the old edge as history. See
|
|
2654
|
+
`deepcell guide revise/premise-change`.
|
|
2655
|
+
|
|
2656
|
+
`--verified-at` / `--broken-at` land on the replacement in the same
|
|
2657
|
+
commit, so recording that the new premise was checked does not take a
|
|
2658
|
+
second `update-assumption`.
|
|
2659
|
+
"""
|
|
2660
|
+
_check_single_stdin(label_file, body_file)
|
|
2661
|
+
label = _resolve_text_option(label, label_file, "label", required=True)
|
|
2662
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2663
|
+
new_assumption = {
|
|
2664
|
+
k: v for k, v in {
|
|
2665
|
+
"id": new_id,
|
|
2666
|
+
"label_text": label,
|
|
2667
|
+
"body_text": body,
|
|
2668
|
+
"body_notation": body_notation,
|
|
2669
|
+
"itemRefs": item_refs,
|
|
2670
|
+
"contextRefs": context_refs,
|
|
2671
|
+
"scenarioRef": scenario_ref,
|
|
2672
|
+
"customDimensions": custom_dimensions,
|
|
2673
|
+
"statusRef": status_ref,
|
|
2674
|
+
"calcRef": calc_ref,
|
|
2675
|
+
"confidence": confidence,
|
|
2676
|
+
"status": status,
|
|
2677
|
+
"verifiedAt": verified_at,
|
|
2678
|
+
"brokenAt": broken_at,
|
|
2679
|
+
}.items() if v is not None
|
|
2680
|
+
}
|
|
2681
|
+
_supersede(
|
|
2682
|
+
ctx, filename, "supersede_assumption", assumption_id,
|
|
2683
|
+
"new_assumption", new_assumption,
|
|
2684
|
+
)
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
_CASCADE_HELP = (
|
|
2688
|
+
"Also remove every Argument touching the node (default). --no-cascade "
|
|
2689
|
+
"keeps them, and is refused with code=would_dangle unless you also pass "
|
|
2690
|
+
"--allow-dangling — a dangling edge lints, and is still addressable by "
|
|
2691
|
+
"its (from, rel, to) or its id, so it can be cleaned up later."
|
|
2692
|
+
)
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def _delete_reasoning_node(
|
|
2696
|
+
ctx: Ctx, filename: str, op: str, target_id: str | None,
|
|
2697
|
+
cascade: bool, allow_dangling: bool, address: dict | None = None,
|
|
2698
|
+
) -> None:
|
|
2699
|
+
xml, revision = _get_xml_with_revision(ctx, filename)
|
|
2700
|
+
data = ctx.client.post(
|
|
2701
|
+
"/reasoning/edit",
|
|
2702
|
+
json={
|
|
2703
|
+
"xml": xml, "op": op, "target_id": target_id,
|
|
2704
|
+
"cascade": cascade, "allow_dangling": allow_dangling,
|
|
2705
|
+
**(address or {}),
|
|
2706
|
+
},
|
|
2707
|
+
)
|
|
2708
|
+
_persist_edit_result(ctx, filename, data, revision)
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def _edge_address(
|
|
2712
|
+
argument_id: str | None, from_id: str | None, edge_rel: str | None,
|
|
2713
|
+
to_id: str | None,
|
|
2714
|
+
) -> dict | None:
|
|
2715
|
+
"""The triple to send beside `target_id`, or a usage error.
|
|
2716
|
+
|
|
2717
|
+
Both addresses at once is not refused: they may disagree, and the backend
|
|
2718
|
+
resolves `target_id` first, so the triple would be ignored silently. Say
|
|
2719
|
+
so instead.
|
|
2720
|
+
"""
|
|
2721
|
+
given = [v for v in (from_id, edge_rel, to_id) if v is not None]
|
|
2722
|
+
if argument_id and given:
|
|
2723
|
+
raise click.UsageError(
|
|
2724
|
+
"Give ARGUMENT_ID or the --from-id/--edge-rel/--to-id triple, not "
|
|
2725
|
+
"both — the id wins and the triple would be ignored."
|
|
2726
|
+
)
|
|
2727
|
+
if not argument_id and len(given) != 3:
|
|
2728
|
+
raise click.UsageError(
|
|
2729
|
+
"Name the edge: either ARGUMENT_ID, or all three of --from-id, "
|
|
2730
|
+
"--edge-rel and --to-id."
|
|
2731
|
+
)
|
|
2732
|
+
if not given:
|
|
2733
|
+
return None
|
|
2734
|
+
return {"from_id": from_id, "edge_rel": edge_rel, "to_id": to_id}
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
def _delete_command(name: str, op: str, noun: str, guidance: str):
|
|
2738
|
+
"""Build one `reasoning delete-<noun>` command — the four differ only in
|
|
2739
|
+
the op name and the wording, so the wiring is shared.
|
|
2740
|
+
|
|
2741
|
+
`help=` is passed to the decorator rather than set as `__doc__`
|
|
2742
|
+
afterwards: Click reads the docstring when the command is constructed, so
|
|
2743
|
+
a later assignment leaves the command with no help at all.
|
|
2744
|
+
"""
|
|
2745
|
+
@reasoning.command(
|
|
2746
|
+
name,
|
|
2747
|
+
cls=FileFirstWriteCommand,
|
|
2748
|
+
help=(
|
|
2749
|
+
f"Delete the {noun} NODE_ID from FILENAME's <Reasoning> section."
|
|
2750
|
+
f"\n\n{guidance}"
|
|
2751
|
+
),
|
|
2752
|
+
)
|
|
2753
|
+
@click.argument("filename")
|
|
2754
|
+
@click.argument("node_id")
|
|
2755
|
+
@click.option("--cascade/--no-cascade", default=True, help=_CASCADE_HELP)
|
|
2756
|
+
@click.option(
|
|
2757
|
+
"--allow-dangling",
|
|
2758
|
+
"allow_dangling",
|
|
2759
|
+
is_flag=True,
|
|
2760
|
+
default=False,
|
|
2761
|
+
help="With --no-cascade, permit the delete even though it orphans Arguments.",
|
|
2762
|
+
)
|
|
2763
|
+
@pass_ctx
|
|
2764
|
+
def _cmd(
|
|
2765
|
+
ctx: Ctx, filename: str, node_id: str, cascade: bool, allow_dangling: bool
|
|
2766
|
+
) -> None:
|
|
2767
|
+
_delete_reasoning_node(ctx, filename, op, node_id, cascade, allow_dangling)
|
|
2768
|
+
|
|
2769
|
+
return _cmd
|
|
2770
|
+
|
|
2771
|
+
|
|
2772
|
+
reasoning_delete_claim_cmd = _delete_command(
|
|
2773
|
+
"delete-claim", "delete_claim", "<Claim>",
|
|
2774
|
+
"Prefer `deepcell reasoning supersede-claim`: a Claim is a dated "
|
|
2775
|
+
"position, and deleting one erases the record that it was ever held. "
|
|
2776
|
+
"Delete is for claims authored in error, not for claims that turned "
|
|
2777
|
+
"out wrong — mark those falsified with `update-claim --status "
|
|
2778
|
+
"falsified`.",
|
|
2779
|
+
)
|
|
2780
|
+
reasoning_delete_assumption_cmd = _delete_command(
|
|
2781
|
+
"delete-assumption", "delete_assumption", "<Assumption>",
|
|
2782
|
+
"Prefer `deepcell reasoning supersede-assumption`. Check what depends "
|
|
2783
|
+
"on it first with `deepcell assumption impact`.",
|
|
2784
|
+
)
|
|
2785
|
+
reasoning_delete_evidence_cmd = _delete_command(
|
|
2786
|
+
"delete-evidence", "delete_evidence", "<Evidence>",
|
|
2787
|
+
"Removes the cited support itself, so any Claim that rested on it is "
|
|
2788
|
+
"left asserting the same thing with less behind it.",
|
|
2789
|
+
)
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
@reasoning.command("delete-argument", cls=FileFirstWriteCommand)
|
|
2793
|
+
@click.argument("filename")
|
|
2794
|
+
@click.argument("argument_id", required=False)
|
|
2795
|
+
@click.option("--from-id", "from_id", default=None, help="Address: the edge's @from.")
|
|
2796
|
+
@click.option(
|
|
2797
|
+
"--edge-rel", "edge_rel", default=None, type=click.Choice(_ARGUMENT_RELS),
|
|
2798
|
+
help="Address: the edge's @rel.",
|
|
2799
|
+
)
|
|
2800
|
+
@click.option("--to-id", "to_id", default=None, help="Address: the edge's @to.")
|
|
2801
|
+
@click.option("--cascade/--no-cascade", default=True, help=_CASCADE_HELP)
|
|
2802
|
+
@click.option(
|
|
2803
|
+
"--allow-dangling",
|
|
2804
|
+
"allow_dangling",
|
|
2805
|
+
is_flag=True,
|
|
2806
|
+
default=False,
|
|
2807
|
+
help="With --no-cascade, permit the delete even though it orphans Arguments.",
|
|
2808
|
+
)
|
|
2809
|
+
@pass_ctx
|
|
2810
|
+
def reasoning_delete_argument_cmd(
|
|
2811
|
+
ctx: Ctx,
|
|
2812
|
+
filename: str,
|
|
2813
|
+
argument_id: str | None,
|
|
2814
|
+
from_id: str | None,
|
|
2815
|
+
edge_rel: str | None,
|
|
2816
|
+
to_id: str | None,
|
|
2817
|
+
cascade: bool,
|
|
2818
|
+
allow_dangling: bool,
|
|
2819
|
+
) -> None:
|
|
2820
|
+
"""Delete one <Argument> edge from FILENAME's <Reasoning> section.
|
|
2821
|
+
|
|
2822
|
+
Removes one edge; both nodes it connected stay. --cascade has no effect
|
|
2823
|
+
here, since an Argument has no inbound edges of its own.
|
|
2824
|
+
|
|
2825
|
+
An Argument has two permanent addresses: its id, which is derived from
|
|
2826
|
+
the edge as arg_{from}__{rel}__{to} and shown by `describe` and the
|
|
2827
|
+
Reasoning panel, and the (from, rel, to) triple it was added with. Give
|
|
2828
|
+
ARGUMENT_ID, or all three of --from-id, --edge-rel and --to-id. Edges
|
|
2829
|
+
written before ids were required have no @id on disk and resolve by
|
|
2830
|
+
either address without the file being rewritten.
|
|
2831
|
+
"""
|
|
2832
|
+
address = _edge_address(argument_id, from_id, edge_rel, to_id)
|
|
2833
|
+
_delete_reasoning_node(
|
|
2834
|
+
ctx, filename, "delete_argument", argument_id, cascade, allow_dangling,
|
|
2835
|
+
address=address,
|
|
2836
|
+
)
|
|
2837
|
+
|
|
2838
|
+
|
|
2839
|
+
@reasoning.command("update-evidence", cls=FileFirstWriteCommand)
|
|
2840
|
+
@click.argument("filename")
|
|
2841
|
+
@click.argument("evidence_id")
|
|
2842
|
+
@click.option(
|
|
2843
|
+
"--label", default=None,
|
|
2844
|
+
help=f"Replacement <Label> text. {_LABEL_HELP}",
|
|
2845
|
+
)
|
|
2846
|
+
@click.option(
|
|
2847
|
+
"--label-file", "label_file", type=_TEXT_FILE, default=None,
|
|
2848
|
+
help=_text_file_help("replacement <Label>", "label"),
|
|
2849
|
+
)
|
|
2850
|
+
@click.option("--body", default=None, help="Replacement <Body> text.")
|
|
2851
|
+
@click.option(
|
|
2852
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2853
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
2854
|
+
)
|
|
2855
|
+
@click.option("--source", default=None, help="Replacement @source (where the evidence came from).")
|
|
2856
|
+
@click.option("--url", default=None, help="Replacement @url.")
|
|
2857
|
+
@click.option("--as-of", "as_of", default=None, help="Replacement @asOf date (ISO).")
|
|
2858
|
+
@click.option("--item-refs", "item_refs", default=None, help="Comma-separated item ids.")
|
|
2859
|
+
@click.option("--context-refs", "context_refs", default=None, help="Comma-separated context ids.")
|
|
2860
|
+
@click.option(
|
|
2861
|
+
"--status-ref", "status_ref", default=None,
|
|
2862
|
+
help="Model Status dimension id (e.g. actual / projected) the evidence "
|
|
2863
|
+
"speaks to.",
|
|
2864
|
+
)
|
|
2865
|
+
@_narrow_anchor_options
|
|
2866
|
+
@pass_ctx
|
|
2867
|
+
def reasoning_update_evidence_cmd(
|
|
2868
|
+
ctx: Ctx,
|
|
2869
|
+
filename: str,
|
|
2870
|
+
evidence_id: str,
|
|
2871
|
+
label: str | None,
|
|
2872
|
+
label_file: str | None,
|
|
2873
|
+
body: str | None,
|
|
2874
|
+
body_file: str | None,
|
|
2875
|
+
source: str | None,
|
|
2876
|
+
url: str | None,
|
|
2877
|
+
as_of: str | None,
|
|
2878
|
+
item_refs: str | None,
|
|
2879
|
+
context_refs: str | None,
|
|
2880
|
+
status_ref: str | None,
|
|
2881
|
+
scenario_ref: str | None,
|
|
2882
|
+
custom_dimensions: str | None,
|
|
2883
|
+
) -> None:
|
|
2884
|
+
"""Patch an <Evidence> node; only the attributes you pass are changed.
|
|
2885
|
+
|
|
2886
|
+
Use it to retrofit anchors onto evidence added before the items existed,
|
|
2887
|
+
or to correct a source URL without losing the Arguments that cite it.
|
|
2888
|
+
"""
|
|
2889
|
+
_check_single_stdin(label_file, body_file)
|
|
2890
|
+
label = _resolve_text_option(label, label_file, "label")
|
|
2891
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2892
|
+
payload = {
|
|
2893
|
+
k: v for k, v in {
|
|
2894
|
+
"label_text": label,
|
|
2895
|
+
"body_text": body,
|
|
2896
|
+
"source": source,
|
|
2897
|
+
"url": url,
|
|
2898
|
+
"asOf": as_of,
|
|
2899
|
+
"itemRefs": item_refs,
|
|
2900
|
+
"contextRefs": context_refs,
|
|
2901
|
+
"statusRef": status_ref,
|
|
2902
|
+
"scenarioRef": scenario_ref,
|
|
2903
|
+
"customDimensions": custom_dimensions,
|
|
2904
|
+
}.items() if v is not None
|
|
2905
|
+
}
|
|
2906
|
+
_update_reasoning_node(ctx, filename, "update_evidence", evidence_id, payload)
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
@reasoning.command("update-argument", cls=FileFirstWriteCommand)
|
|
2910
|
+
@click.argument("filename")
|
|
2911
|
+
@click.argument("argument_id", required=False)
|
|
2912
|
+
@click.option("--from-id", "from_id", default=None, help="Address: the edge's @from.")
|
|
2913
|
+
@click.option(
|
|
2914
|
+
"--edge-rel", "edge_rel", default=None, type=click.Choice(_ARGUMENT_RELS),
|
|
2915
|
+
help="Address: the @rel the edge has now. --rel is what to change it to.",
|
|
2916
|
+
)
|
|
2917
|
+
@click.option("--to-id", "to_id", default=None, help="Address: the edge's @to.")
|
|
2918
|
+
@click.option(
|
|
2919
|
+
"--rel",
|
|
2920
|
+
default=None,
|
|
2921
|
+
type=click.Choice(_ARGUMENT_RELS),
|
|
2922
|
+
help="Reclassify the edge — see `deepcell reasoning add-argument --help`.",
|
|
2923
|
+
)
|
|
2924
|
+
@click.option("--weight", default=None, type=float, help="How much this edge carries, 0-1.")
|
|
2925
|
+
@click.option("--body", default=None, help="Replacement <Body> text.")
|
|
2926
|
+
@click.option(
|
|
2927
|
+
"--body-file", "body_file", type=_TEXT_FILE, default=None,
|
|
2928
|
+
help=_text_file_help("replacement <Body>", "body"),
|
|
2929
|
+
)
|
|
2930
|
+
@click.option(
|
|
2931
|
+
"--body-notation", "body_notation", default=None,
|
|
2932
|
+
type=click.Choice(_BODY_NOTATIONS),
|
|
2933
|
+
help=_BODY_NOTATION_HELP,
|
|
2934
|
+
)
|
|
2935
|
+
@click.option(
|
|
2936
|
+
"--status",
|
|
2937
|
+
default=None,
|
|
2938
|
+
help="Argument lifecycle status (active / superseded / revoked).",
|
|
2939
|
+
)
|
|
2940
|
+
@pass_ctx
|
|
2941
|
+
def reasoning_update_argument_cmd(
|
|
2942
|
+
ctx: Ctx,
|
|
2943
|
+
filename: str,
|
|
2944
|
+
argument_id: str | None,
|
|
2945
|
+
from_id: str | None,
|
|
2946
|
+
edge_rel: str | None,
|
|
2947
|
+
to_id: str | None,
|
|
2948
|
+
rel: str | None,
|
|
2949
|
+
weight: float | None,
|
|
2950
|
+
body: str | None,
|
|
2951
|
+
body_file: str | None,
|
|
2952
|
+
body_notation: str | None,
|
|
2953
|
+
status: str | None,
|
|
2954
|
+
) -> None:
|
|
2955
|
+
"""Patch an <Argument> edge; only the attributes you pass are changed.
|
|
2956
|
+
|
|
2957
|
+
An Argument has two permanent addresses: its id, which is derived from
|
|
2958
|
+
the edge as arg_{from}__{rel}__{to} and shown by `describe` and the
|
|
2959
|
+
Reasoning panel, and the (from, rel, to) triple it was added with. Give
|
|
2960
|
+
ARGUMENT_ID, or all three of --from-id, --edge-rel and --to-id. Edges
|
|
2961
|
+
written before ids were required have no @id on disk and resolve by
|
|
2962
|
+
either address without the file being rewritten.
|
|
2963
|
+
|
|
2964
|
+
Reclassifying is --rel; the address is --edge-rel. Recasting a reason
|
|
2965
|
+
as a condition is therefore one command:
|
|
2966
|
+
`--from-id r --edge-rel supports --to-id t --rel depends_on`.
|
|
2967
|
+
"""
|
|
2968
|
+
address = _edge_address(argument_id, from_id, edge_rel, to_id)
|
|
2969
|
+
body = _resolve_text_option(body, body_file, "body")
|
|
2970
|
+
payload = {
|
|
2971
|
+
k: v for k, v in {
|
|
2972
|
+
"rel": rel,
|
|
2973
|
+
"weight": weight,
|
|
2974
|
+
"body_text": body,
|
|
2975
|
+
"body_notation": body_notation,
|
|
2976
|
+
"status": status,
|
|
2977
|
+
}.items() if v is not None
|
|
2978
|
+
}
|
|
2979
|
+
_update_reasoning_node(
|
|
2980
|
+
ctx, filename, "update_argument", argument_id, payload, address=address,
|
|
2981
|
+
)
|