cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
6.62 kB
"""Shared `--explain*` CLI plumbing for the four target commands.
Spec v0.4 Β§3.2 enumerates the explain-specific options; rather than
duplicating the argparse setup in each command, this module provides:
- `add_explain_arguments(parser)` β€” registers the standard flag set
- `args_to_options(args, pack_name) -> InterpretationOptions`
- `print_envelope(env, format)` β€” renders to text/json/markdown
- `print_degradation(exc, mode, format)` β€” handles graceful degradation
per spec Β§3.7
Each command's `run()` calls these after running its primary work.
"""
from __future__ import annotations
import json as _json
import sys
from typing import Literal
from uofa_cli.interpretation.degrade import make_degradation_notice
from uofa_cli.interpretation.envelope import InterpretationEnvelope
from uofa_cli.interpretation.formatters import render_envelope
from uofa_cli.interpretation.pipeline import InterpretationOptions
from uofa_cli.llm.errors import LLMError
def add_explain_arguments(parser) -> None:
"""Register the `--explain*` flag set on a command's argparse parser.
Identical across rules/check/diff/shacl per spec Β§3.2 β€” duplicating
the wiring per command would be a maintenance trap.
"""
parser.add_argument(
"--explain", action="store_true",
help="run the interpretation pipeline after the primary analysis",
)
parser.add_argument(
"--explain-functions", default=None,
help="comma-separated list of interpretation functions to run "
"(values: explain, group, contextualize, cross, narrative). "
"Default: all applicable for the command.",
)
parser.add_argument(
"--explain-format", default=None,
choices=["text", "json", "markdown", "html"],
help="output format for the interpretation block. Default: same as "
"the command's primary --format, falling back to text.",
)
parser.add_argument(
"--explain-backend", default=None,
choices=["ollama", "anthropic", "openai", "openai-compatible", "bundled", "mock"],
help="LLM backend for explain (overrides [llm] backend in uofa.toml)",
)
parser.add_argument(
"--explain-model", default=None,
help="model name on the chosen backend (overrides [llm] model)",
)
parser.add_argument(
"--explain-base-url", default=None,
help="base URL for openai-compatible backends",
)
parser.add_argument(
"--explain-max-items", type=int, default=None,
help="limit interpretation to top N items by severity",
)
parser.add_argument(
"--explain-no-cache", action="store_true",
help="bypass cached interpretation results",
)
def args_to_options(args, *, pack_name: str = "vv40") -> InterpretationOptions:
"""Convert argparse-parsed args into InterpretationOptions.
Resolves the backend from the `--explain-*` flags via the unified
LLM config resolver (spec Β§3.6 precedence). Callers pass `pack_name`
so the right pack templates are selected.
"""
# Build cli_overrides for the LLM config resolver if the user passed
# any --explain-backend / --explain-model / --explain-base-url flag.
backend = None
if any((args.explain_backend, args.explain_model, args.explain_base_url)):
from uofa_cli.llm import resolve_llm_config, get_backend
cli_overrides: dict = {}
if args.explain_backend:
cli_overrides["backend"] = args.explain_backend
if args.explain_model:
cli_overrides["model"] = args.explain_model
if args.explain_base_url:
cli_overrides["base_url"] = args.explain_base_url
# Convention env var defaults (mirror extract_cmd's logic)
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"]],
)
config = resolve_llm_config(cli_overrides=cli_overrides)
backend = get_backend(config)
functions: list[str] = ["all"]
if args.explain_functions:
functions = [name.strip() for name in args.explain_functions.split(",") if name.strip()]
# CLI invocations get the animated TTY spinner around each LLM call
# (no-op on non-TTY); programmatic callers building InterpretationOptions
# directly fall back to the dataclass default (also no-op).
from uofa_cli.output import spinner
return InterpretationOptions(
functions=functions,
max_items=args.explain_max_items,
no_cache=args.explain_no_cache,
backend=backend,
pack_name=pack_name,
spinner_factory=spinner,
)
# ── Rendering ──────────────────────────────────────────────
Format = Literal["text", "json", "markdown", "html"]
def print_envelope(env: InterpretationEnvelope, *, format: Format = "text") -> None:
"""Render `env` to the chosen format and print to stdout.
Thin shell over `formatters.render_envelope`; tests / programmatic
consumers wanting the rendered string call the formatter directly.
"""
rendered = render_envelope(env, format=format)
if rendered:
# Strip trailing newline so print's own newline doesn't double-up
# for formats that already terminate (text/markdown/html do; json
# doesn't). Conditional dropping keeps things tidy in all cases.
print(rendered, end="" if rendered.endswith("\n") else "\n")
def print_degradation(
exc: LLMError,
*,
mode: Literal["explain", "extract"] = "explain",
format: Format = "text",
command: str | None = None,
structured_output=None,
) -> None:
"""Print a graceful-degradation notice (spec Β§3.7) for an LLM error.
Returns nothing; caller decides exit code (explain β†’ 0, extract β†’ 1).
"""
notice = make_degradation_notice(exc, mode=mode)
if format == "json":
if mode == "extract":
envelope = notice.to_extract_envelope()
else:
envelope = notice.to_explain_envelope(
command=command or "unknown",
structured_output=structured_output if structured_output is not None else {},
)
print(_json.dumps(envelope, indent=2))
return
# text / markdown fall through to the bracket-wrapped notice
print()
print(notice.to_text(), file=sys.stderr if mode == "extract" else sys.stdout)