File size: 10,732 Bytes
a28ec65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
"""uofa explain β€” interpret pre-existing structured output (spec v0.4 Β§3.3).

Operates on JSON output captured from a previous `uofa rules/check/diff/shacl
--explain --explain-format json` invocation. Useful for re-rendering, format
conversion, or running interpretation on cached output without re-running
the underlying analysis.

Usage:
    uofa explain --from-file FILE [OPTIONS]
    uofa explain --from-stdin [OPTIONS]

Spec Β§3.3 enumerates the option set, mirroring `--explain-*` on the four
target commands but without the prefix (since this command IS explain). The
input source is mutually exclusive (file or stdin) and required.
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

from uofa_cli.output import error, info

HELP = "interpret pre-existing structured output from rules/check/diff/shacl"


def add_arguments(parser):
    src = parser.add_mutually_exclusive_group(required=True)
    src.add_argument("--from-file", type=Path, dest="from_file",
                     help="read structured output from FILE")
    src.add_argument("--from-stdin", action="store_true", dest="from_stdin",
                     help="read structured output from stdin")
    parser.add_argument("--input-type", dest="input_type",
                        choices=["rules", "check", "diff", "shacl"],
                        help="override auto-detection of input type")
    parser.add_argument("--functions", default=None,
                        help="comma-separated list of interpretation functions to run")
    parser.add_argument("--format", default="text",
                        choices=["text", "json", "markdown"],
                        help="output format (default: text)")
    parser.add_argument("--max-items", type=int, default=None, dest="max_items",
                        help="limit interpretation to top N items by severity")
    parser.add_argument("--no-cache", action="store_true", dest="no_cache",
                        help="bypass cached interpretation results")
    parser.add_argument("--backend", default=None,
                        choices=["ollama", "anthropic", "openai", "openai-compatible", "bundled", "mock"],
                        help="LLM backend (overrides [llm] backend in uofa.toml)")
    parser.add_argument("--model", default=None,
                        help="model name on the chosen backend")
    parser.add_argument("--base-url", default=None, dest="base_url",
                        help="base URL for openai-compatible backends")
    # NOTE: --pack is a global flag inherited from the parent parser; we
    # read `args.pack` in run() rather than redefining the option here.


def run(args) -> int:
    # ── 1. Read input ────────────────────────────────────────
    try:
        if args.from_stdin:
            text = sys.stdin.read()
        else:
            text = args.from_file.read_text(encoding="utf-8")
    except OSError as exc:
        error(f"Could not read input: {exc}")
        return 1

    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        error(f"Input is not valid JSON: {exc}")
        return 1

    # ── 2. Detect input type ─────────────────────────────────
    input_type = args.input_type or _detect_input_type(data)
    if input_type is None:
        error(
            "Could not detect input type from JSON shape. "
            "Pass --input-type rules|check|diff|shacl explicitly."
        )
        info(f"  Top-level keys seen: {sorted(data.keys()) if isinstance(data, dict) else type(data).__name__}")
        return 1

    # ── 3. Build interpretation options ──────────────────────
    options = _build_options(args)

    # ── 4. Route to the right interpret_*_output() ──────────
    from uofa_cli.interpretation.cli import print_degradation, print_envelope
    from uofa_cli.llm.errors import LLMError

    try:
        env = _route_and_interpret(input_type, data, options)
    except LLMError as exc:
        print_degradation(
            exc, mode="explain", format=args.format,
            command=input_type,
            structured_output=_unwrap_structured(data),
        )
        # Spec Β§3.7: explain graceful degradation β†’ exit 0
        return 0

    # ── 5. Render the envelope ───────────────────────────────
    print_envelope(env, format=args.format)
    return 0


# ── Auto-detection ─────────────────────────────────────────


def _detect_input_type(data) -> str | None:
    """Inspect JSON shape and return the input type, or None if unclear.

    Detection order:
      1. Envelope: top-level `command` field with a known value (matches
         output from `--explain-format json` on any of the four commands).
      2. Shape distinguishers (most specific first):
         - `only_a` or `only_b` present β†’ diff
         - `shacl` AND `rules` keys β†’ check
         - `violations` AND `conforms` β†’ shacl
         - `firings` β†’ rules
    """
    if not isinstance(data, dict):
        return None

    cmd = data.get("command")
    if cmd in ("rules", "check", "diff", "shacl"):
        return cmd

    # Unwrap envelope if present so we can sniff the structured_output shape
    inner = data.get("structured_output", data) if isinstance(data, dict) else data
    if not isinstance(inner, dict):
        return None

    if "only_a" in inner or "only_b" in inner or "divergence_count" in inner:
        return "diff"
    if "shacl" in inner and "rules" in inner:
        return "check"
    if "violations" in inner and "conforms" in inner:
        return "shacl"
    if "firings" in inner:
        return "rules"
    return None


def _unwrap_structured(data):
    """Return the structured_output if `data` is a full envelope, else `data` itself."""
    if isinstance(data, dict) and "structured_output" in data:
        return data["structured_output"]
    return data


# ── Options builder (mirrors interpretation.cli.args_to_options but
#    with the standalone command's flag names) ────────────────


def _build_options(args):
    from uofa_cli.interpretation import InterpretationOptions

    backend = None
    if args.backend or args.model or args.base_url:
        from uofa_cli.llm import get_backend, resolve_llm_config
        cli_overrides: dict = {}
        if args.backend:
            cli_overrides["backend"] = args.backend
        if args.model:
            cli_overrides["model"] = args.model
        if args.base_url:
            cli_overrides["base_url"] = args.base_url
        if cli_overrides.get("backend") in ("anthropic", "openai"):
            cli_overrides.setdefault(
                "api_key_env",
                {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY"}[cli_overrides["backend"]],
            )
        backend = get_backend(resolve_llm_config(cli_overrides=cli_overrides))

    functions: list[str] = ["all"]
    if args.functions:
        functions = [n.strip() for n in args.functions.split(",") if n.strip()]

    pack_name = getattr(args, "pack", None) or "vv40"
    if isinstance(pack_name, list):
        # The parent parser stores --pack as a list (--pack can be repeated)
        pack_name = pack_name[0] if pack_name else "vv40"
    return InterpretationOptions(
        functions=functions,
        max_items=args.max_items,
        no_cache=args.no_cache,
        backend=backend,
        pack_name=pack_name,
    )


# ── Routing ────────────────────────────────────────────────


def _route_and_interpret(input_type: str, data, options):
    """Dispatch to the right `interpret_<command>_output()`.

    `data` may be a full envelope (with `structured_output` key) or just the
    structured payload. We extract the relevant fields per command and pass
    them through. `package_doc` is unavailable from cached output (the
    package itself isn't in the envelope), so context extraction loses the
    COU info β€” interpretation functions handle this gracefully by treating
    missing context as an empty CouContext.
    """
    from uofa_cli.interpretation import (
        interpret_check_output,
        interpret_diff_output,
        interpret_rules_output,
        interpret_shacl_output,
    )

    structured = _unwrap_structured(data)

    if input_type == "rules":
        firings = structured.get("firings", []) if isinstance(structured, dict) else []
        return interpret_rules_output(
            structured_output=structured,
            package_doc={},
            firings=firings,
            options=options,
        )
    if input_type == "shacl":
        violations = structured.get("violations", []) if isinstance(structured, dict) else []
        return interpret_shacl_output(
            structured_output=structured,
            violations=violations,
            options=options,
        )
    if input_type == "diff":
        return interpret_diff_output(
            structured_output=structured,
            only_a=structured.get("only_a", []) if isinstance(structured, dict) else [],
            only_b=structured.get("only_b", []) if isinstance(structured, dict) else [],
            weakeners_a=structured.get("weakeners_a", []) if isinstance(structured, dict) else [],
            weakeners_b=structured.get("weakeners_b", []) if isinstance(structured, dict) else [],
            cou_identity_a=structured.get("cou_identity_a", {}) if isinstance(structured, dict) else {},
            cou_identity_b=structured.get("cou_identity_b", {}) if isinstance(structured, dict) else {},
            options=options,
        )
    if input_type == "check":
        rules_data = structured.get("rules", {}) if isinstance(structured, dict) else {}
        shacl_data = structured.get("shacl", {}) if isinstance(structured, dict) else {}
        rules_firings = rules_data.get("firings", []) if isinstance(rules_data, dict) else []
        shacl_violations = shacl_data.get("violations", []) if isinstance(shacl_data, dict) else None
        return interpret_check_output(
            structured_output=structured,
            package_doc={},
            rules_firings=rules_firings,
            shacl_violations=shacl_violations,
            options=options,
        )
    raise ValueError(f"Unknown input_type: {input_type!r}")