Skip to content

grip_hook.providers

grip_hook.providers

LLM providers.

A provider turns a diff into questions and answers into grades. Add a new one by implementing :class:grip_hook.providers.base.Provider and registering it in :data:REGISTRY.

REGISTRY module-attribute

REGISTRY: dict[str, ProviderFactory] = {'anthropic': _anthropic, 'openai': _openai, 'ollama': _openai, 'claude-code': _claude_code, 'claude': _claude_code, 'codex': _codex, 'gemini': _gemini, 'fake': _fake}

Provider name to factory.

ollama is openai with a local default URL; claude is an alias of claude-code. The agent providers shell out to a CLI that is already installed and signed in.

Provider

Bases: Protocol

Something that can write quiz questions and grade answers.

Source code in src/grip_hook/providers/base.py
@runtime_checkable
class Provider(Protocol):
    """Something that can write quiz questions and grade answers."""

    name: str
    """Short provider name shown in reports."""

    model: str
    """Model identifier shown in reports."""

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        ...

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """Draw the whiteboard for ``diff``; ``problems`` explain why a first try was rejected."""
        ...

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one go."""
        ...

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Grade one answer again, given the developer's case against ``grade``."""
        ...

name instance-attribute

name: str

Short provider name shown in reports.

model instance-attribute

model: str

Model identifier shown in reports.

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/base.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    ...

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

Draw the whiteboard for diff; problems explain why a first try was rejected.

Source code in src/grip_hook/providers/base.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """Draw the whiteboard for ``diff``; ``problems`` explain why a first try was rejected."""
    ...

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one go.

Source code in src/grip_hook/providers/base.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one go."""
    ...

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Grade one answer again, given the developer's case against grade.

Source code in src/grip_hook/providers/base.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Grade one answer again, given the developer's case against ``grade``."""
    ...

get_provider

get_provider(cfg: Config) -> Provider

Instantiate the provider named by cfg.provider (auto resolved first).

Source code in src/grip_hook/providers/__init__.py
def get_provider(cfg: Config) -> Provider:
    """Instantiate the provider named by ``cfg.provider`` (``auto`` resolved first)."""
    try:
        factory = REGISTRY[cfg.provider_name]
    except KeyError as exc:
        known = ", ".join(sorted(REGISTRY))
        raise ConfigError(f"unknown provider {cfg.provider!r}; known providers: {known}") from exc
    return factory(cfg)

grip_hook.providers.base

Provider interface.

Provider

Bases: Protocol

Something that can write quiz questions and grade answers.

Source code in src/grip_hook/providers/base.py
@runtime_checkable
class Provider(Protocol):
    """Something that can write quiz questions and grade answers."""

    name: str
    """Short provider name shown in reports."""

    model: str
    """Model identifier shown in reports."""

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        ...

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """Draw the whiteboard for ``diff``; ``problems`` explain why a first try was rejected."""
        ...

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one go."""
        ...

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Grade one answer again, given the developer's case against ``grade``."""
        ...

name instance-attribute

name: str

Short provider name shown in reports.

model instance-attribute

model: str

Model identifier shown in reports.

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/base.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    ...

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

Draw the whiteboard for diff; problems explain why a first try was rejected.

Source code in src/grip_hook/providers/base.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """Draw the whiteboard for ``diff``; ``problems`` explain why a first try was rejected."""
    ...

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one go.

Source code in src/grip_hook/providers/base.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one go."""
    ...

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Grade one answer again, given the developer's case against grade.

Source code in src/grip_hook/providers/base.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Grade one answer again, given the developer's case against ``grade``."""
    ...

CallLog

Keeps the :class:ModelCall of every request a provider makes, oldest first.

Providers that time their requests inherit it; :func:calls_of reads it.

Source code in src/grip_hook/providers/base.py
class CallLog:
    """Keeps the :class:`ModelCall` of every request a provider makes, oldest first.

    Providers that time their requests inherit it; :func:`calls_of` reads it.
    """

    model_calls: list[ModelCall]

    def _log_call(
        self,
        schema: type,
        started: float,
        input_tokens: int | None = None,
        output_tokens: int | None = None,
    ) -> None:
        """Record a request for ``schema`` that began at ``time.perf_counter() == started``."""
        if not hasattr(self, "model_calls"):
            self.model_calls = []
        self.model_calls.append(
            ModelCall(
                kind=call_kind(schema),
                seconds=round(time.perf_counter() - started, 3),
                input_tokens=input_tokens,
                output_tokens=output_tokens,
            )
        )

call_kind

call_kind(schema: type) -> str

What a request for schema is for: questions, board, grade, appeal.

Source code in src/grip_hook/providers/base.py
def call_kind(schema: type) -> str:
    """What a request for ``schema`` is for: ``questions``, ``board``, ``grade``, ``appeal``."""
    return _KINDS.get(schema.__name__, schema.__name__.lower())

calls_of

calls_of(provider: object) -> list[ModelCall]

The requests provider made so far; empty for one that does not keep them.

Source code in src/grip_hook/providers/base.py
def calls_of(provider: object) -> list[ModelCall]:
    """The requests ``provider`` made so far; empty for one that does not keep them."""
    calls = getattr(provider, "model_calls", None)
    return list(calls) if isinstance(calls, list) else []

token_count

token_count(value: object) -> int | None

value when it is a usable token count (a non-negative int), else None.

Source code in src/grip_hook/providers/base.py
def token_count(value: object) -> int | None:
    """``value`` when it is a usable token count (a non-negative int), else ``None``."""
    if isinstance(value, bool) or not isinstance(value, int) or value < 0:
        return None
    return value

missing_credentials

missing_credentials(provider: str, key_env: str) -> str

What to do when provider has no key in key_env: every way out, concretely.

A coding agent installed on this machine is named, since it needs no key at all. The lines stay short enough not to wrap in an 80-column terminal.

Source code in src/grip_hook/providers/base.py
def missing_credentials(provider: str, key_env: str) -> str:
    """What to do when ``provider`` has no key in ``key_env``: every way out, concretely.

    A coding agent installed on this machine is named, since it needs no key at all. The
    lines stay short enough not to wrap in an 80-column terminal.
    """
    installed = [name for name, command in AGENT_PROVIDERS if shutil.which(command)]
    if installed:
        others = f" (or {', '.join(installed[1:])})" if installed[1:] else ""
        agent = (
            f"use a coding agent installed here, no key needed: --provider {installed[0]}"
            f'{others}\n    or provider = "{installed[0]}" in .grip.toml'
        )
    else:
        agent = 'install Claude Code, Codex or Gemini CLI and sign in; provider = "auto"\n'
        agent += "    then uses it with no key"
    return (
        f"no credentials for the {provider} provider. Any of these fixes it:\n"
        f"  - set {key_env}: export {key_env}=...\n"
        f"  - {agent}\n"
        "  - try grip offline with --provider fake (canned questions, no model)"
    )

grip_hook.providers.anthropic

Anthropic provider built on the official anthropic SDK.

AnthropicProvider

Bases: CallLog

Ask Claude for questions and grades using structured outputs.

Source code in src/grip_hook/providers/anthropic.py
class AnthropicProvider(CallLog):
    """Ask Claude for questions and grades using structured outputs."""

    name = "anthropic"

    def __init__(self, cfg: Config, client: anthropic.Anthropic | None = None) -> None:
        self.model = cfg.model or DEFAULT_MODEL
        self._effort = cfg.effort.strip().lower()
        self._key_env = cfg.api_key_env or _KEY_ENV
        self._client = client or self._build_client(cfg)
        self.model_calls = []

    @staticmethod
    def _build_client(cfg: Config) -> anthropic.Anthropic:
        kwargs: dict[str, object] = {"timeout": cfg.timeout, "max_retries": 2}
        if cfg.api_key_env:
            key = os.environ.get(cfg.api_key_env)
            if not key:
                raise ProviderError(missing_credentials("anthropic", cfg.api_key_env))
            kwargs["api_key"] = key
        if cfg.base_url:
            kwargs["base_url"] = cfg.base_url
        try:
            client = anthropic.Anthropic(**kwargs)  # type: ignore[arg-type]
        except anthropic.AnthropicError as exc:
            raise ProviderError(
                "could not create the Anthropic client. Set ANTHROPIC_API_KEY or run "
                f"`ant auth login`. ({exc})"
            ) from exc
        # A gateway at base_url may add its own credentials, so only the direct API is checked.
        if not cfg.base_url and not _has_credentials(client):
            raise ProviderError(missing_credentials("anthropic", _KEY_ENV))
        return client

    def _output_config(self) -> dict[str, str] | None:
        # `effort` is accepted by Claude 4.5+ models; Haiku 4.5 and older reject it.
        if self._effort in {"", "none"} or self._effort not in _EFFORT_LEVELS:
            return None
        if self.model.startswith("claude-haiku"):
            return None
        return {"effort": self._effort}

    def _parse(self, system: str, user: str, schema: type[T]) -> T:
        kwargs: dict[str, object] = {}
        output_config = self._output_config()
        if output_config:
            kwargs["output_config"] = output_config
        started = time.perf_counter()
        try:
            response = self._client.messages.parse(
                model=self.model,
                max_tokens=_MAX_TOKENS,
                system=system,
                messages=[{"role": "user", "content": user}],
                output_format=schema,
                **kwargs,  # type: ignore[arg-type]
            )
        except anthropic.AuthenticationError as exc:
            raise ProviderError(
                "Anthropic rejected the API key. Set ANTHROPIC_API_KEY or run `ant auth login`."
            ) from exc
        except anthropic.NotFoundError as exc:
            raise ProviderError(f"model {self.model!r} was not found: {exc.message}") from exc
        except anthropic.RateLimitError as exc:
            raise ProviderError(
                f"Anthropic rate limit hit, try again shortly: {exc.message}"
            ) from exc
        except anthropic.APIStatusError as exc:
            raise ProviderError(f"Anthropic API error ({exc.status_code}): {exc.message}") from exc
        except anthropic.APIConnectionError as exc:
            raise ProviderError(f"could not reach the Anthropic API: {exc}") from exc
        except TypeError as exc:
            # The SDK only finds out at request time that it has no key, token or login.
            if "authentication" not in str(exc):
                raise
            raise ProviderError(missing_credentials("anthropic", self._key_env)) from exc

        if response.stop_reason == "refusal":
            detail = ""
            if response.stop_details is not None:
                detail = f" ({response.stop_details.category}: {response.stop_details.explanation})"
            raise ProviderError(f"the model declined to process this diff{detail}")
        if response.stop_reason == "max_tokens":
            raise ProviderError("the model's response was cut off; try a smaller diff")
        parsed = response.parsed_output
        if parsed is None:
            raise ProviderError("the model returned no structured output")
        usage = getattr(response, "usage", None)
        self._log_call(
            schema,
            started,
            token_count(getattr(usage, "input_tokens", None)),
            token_count(getattr(usage, "output_tokens", None)),
        )
        return parsed

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._parse(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """Draw the whiteboard for ``diff``."""
        return self._parse(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._parse(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Grade one answer again, given the developer's case against ``grade``."""
        prompt = appeal_prompt(
            diff, question, answer, grade, justification=justification, difficulty=difficulty
        )
        return self._parse(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/anthropic.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._parse(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

Draw the whiteboard for diff.

Source code in src/grip_hook/providers/anthropic.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """Draw the whiteboard for ``diff``."""
    return self._parse(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/anthropic.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._parse(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Grade one answer again, given the developer's case against grade.

Source code in src/grip_hook/providers/anthropic.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Grade one answer again, given the developer's case against ``grade``."""
    prompt = appeal_prompt(
        diff, question, answer, grade, justification=justification, difficulty=difficulty
    )
    return self._parse(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

grip_hook.providers.openai_compat

Provider for any OpenAI-compatible chat completions API (OpenAI, Ollama, vLLM, ...).

Implemented with the standard library so grip carries no extra dependency for it. JSON output is requested through response_format with a JSON schema, which Ollama and most OpenAI-compatible servers honour; the reply is validated with the same pydantic models the Anthropic provider uses.

OpenAICompatibleProvider

Bases: CallLog

Talk to {base_url}/chat/completions.

Source code in src/grip_hook/providers/openai_compat.py
class OpenAICompatibleProvider(CallLog):
    """Talk to ``{base_url}/chat/completions``."""

    name = "openai"

    def __init__(self, cfg: Config, opener: Any = None) -> None:
        self.name = cfg.provider_name
        if not cfg.model:
            raise ProviderError(
                f"the {self.name} provider needs a model: set `model = ...` in .grip.toml"
            )
        self.model = cfg.model
        default_url = OLLAMA_URL if self.name == "ollama" else OPENAI_URL
        self._base_url = (cfg.base_url or default_url).rstrip("/")
        default_key_env = "OLLAMA_API_KEY" if self.name == "ollama" else "OPENAI_API_KEY"
        key_env = cfg.api_key_env or default_key_env
        self._api_key = os.environ.get(key_env, "")
        # Ollama and self-hosted servers at a base_url often take no key at all.
        if not self._api_key and self.name != "ollama" and not cfg.base_url:
            raise ProviderError(missing_credentials(self.name, key_env))
        self._timeout = cfg.timeout
        self._opener = opener or urllib.request.urlopen
        self.model_calls = []

    def _post(self, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode("utf-8")
        headers = {"Content-Type": "application/json", "User-Agent": "grip-hook"}
        if self._api_key:
            headers["Authorization"] = f"Bearer {self._api_key}"
        request = urllib.request.Request(
            f"{self._base_url}/chat/completions", data=body, headers=headers, method="POST"
        )
        try:
            with self._opener(request, timeout=self._timeout) as resp:
                raw = resp.read()
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", "replace")[:500]
            raise ProviderError(f"{self.name} API error ({exc.code}): {detail}") from exc
        except urllib.error.URLError as exc:
            raise ProviderError(f"could not reach {self._base_url}: {exc.reason}") from exc
        try:
            data: dict[str, Any] = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ProviderError(f"{self.name} returned invalid JSON") from exc
        return data

    def _complete(self, system: str, user: str, schema: type[T]) -> T:
        payload = {
            "model": self.model,
            "messages": [
                {"role": "system", "content": system},
                {"role": "user", "content": user},
            ],
            "response_format": {
                "type": "json_schema",
                "json_schema": {
                    "name": schema.__name__,
                    "schema": schema.model_json_schema(),
                },
            },
        }
        started = time.perf_counter()
        data = self._post(payload)
        try:
            content = data["choices"][0]["message"]["content"]
        except (KeyError, IndexError, TypeError) as exc:
            raise ProviderError(
                f"unexpected response shape from {self.name}: {data!r}"[:500]
            ) from exc
        try:
            result = schema.model_validate_json(content)
        except ValidationError as exc:
            raise ProviderError(
                f"{self.name} returned output that does not match the schema: {exc}"
            ) from exc
        usage = data.get("usage")
        usage = usage if isinstance(usage, dict) else {}
        self._log_call(
            schema,
            started,
            token_count(usage.get("prompt_tokens")),
            token_count(usage.get("completion_tokens")),
        )
        return result

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._complete(
            QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet
        )

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """Draw the whiteboard for ``diff``."""
        return self._complete(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._complete(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Grade one answer again, given the developer's case against ``grade``."""
        prompt = appeal_prompt(
            diff, question, answer, grade, justification=justification, difficulty=difficulty
        )
        return self._complete(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/openai_compat.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._complete(
        QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet
    )

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

Draw the whiteboard for diff.

Source code in src/grip_hook/providers/openai_compat.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """Draw the whiteboard for ``diff``."""
    return self._complete(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/openai_compat.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._complete(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Grade one answer again, given the developer's case against grade.

Source code in src/grip_hook/providers/openai_compat.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Grade one answer again, given the developer's case against ``grade``."""
    prompt = appeal_prompt(
        diff, question, answer, grade, justification=justification, difficulty=difficulty
    )
    return self._complete(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

grip_hook.providers.agents

Providers that reuse a coding agent CLI already installed and signed in.

No API key is needed: each provider shells out to the agent in non-interactive mode, sends the same prompts the API providers use, and asks for JSON back.

  • claude-code: claude -p with --json-schema (schema-enforced output).
  • codex: codex exec with --output-schema (schema-enforced output).
  • gemini: gemini --output-format json; the JSON is requested in the prompt and extracted from the reply.

Every agent runs with tools disabled where the CLI allows it, in an empty scratch directory, so it cannot read the repository, run commands, or pick up project-level instructions and hooks. It only ever sees the diff, its commit messages and the answers.

AgentCLIProvider

Bases: CallLog

Common machinery: build a command, run it, validate the JSON it returns.

Requests are timed; the CLIs do not report token usage in a form grip relies on, so their calls carry none.

Source code in src/grip_hook/providers/agents.py
class AgentCLIProvider(CallLog):
    """Common machinery: build a command, run it, validate the JSON it returns.

    Requests are timed; the CLIs do not report token usage in a form grip relies on, so
    their calls carry none.
    """

    name: str = "agent"
    executable: ClassVar[str] = ""
    install_hint: ClassVar[str] = ""
    enforces_schema: ClassVar[bool] = False
    """Whether the CLI constrains its output to the schema; otherwise it is asked for."""
    system_flag: ClassVar[str] = ""
    """Flag that takes the system prompt; empty means prepend it to the user prompt."""

    def __init__(self, cfg: Config) -> None:
        self.model = cfg.model
        self._timeout = cfg.timeout
        self._binary = shutil.which(self.executable)
        self.model_calls = []

    # -- to implement per agent -------------------------------------------------------

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """The argv to run. The prompt is written to stdin."""
        raise NotImplementedError

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Turn the process output into the JSON payload (a dict)."""
        raise NotImplementedError

    # -- shared -----------------------------------------------------------------------

    def _prompt(self, system: str, user: str, schema: dict[str, Any]) -> str:
        parts = [user.strip()] if self.system_flag else [system.strip(), user.strip()]
        if not self.enforces_schema:
            parts.append(_JSON_INSTRUCTION + json.dumps(schema))
        return "\n\n".join(parts) + "\n"

    def _run(self, system: str, user: str, output: type[T]) -> T:
        if self._binary is None:
            raise ProviderError(
                f"{self.executable!r} was not found on PATH. {self.install_hint} "
                "Or pick another provider in .grip.toml."
            )
        schema = _schema(output)
        prompt = self._prompt(system, user, schema)
        started = time.perf_counter()
        with tempfile.TemporaryDirectory(prefix="grip-agent-") as tmp:
            workdir = Path(tmp)
            cmd = [self._binary, *self.command(schema, workdir)]
            if self.system_flag:
                cmd += [self.system_flag, system.strip()]
            try:
                proc = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    text=True,
                    encoding="utf-8",
                    errors="replace",
                    timeout=self._timeout,
                    cwd=workdir,
                    env=os.environ.copy(),
                    check=False,
                )
            except subprocess.TimeoutExpired as exc:
                raise ProviderError(
                    f"{self.executable} did not answer within {self._timeout:.0f}s "
                    "(raise `timeout` in .grip.toml)"
                ) from exc
            except OSError as exc:
                raise ProviderError(f"could not run {self.executable}: {exc}") from exc
            if proc.returncode != 0:
                detail = (proc.stderr or proc.stdout).strip()[-600:]
                raise ProviderError(f"{self.executable} exited with {proc.returncode}: {detail}")
            payload = self.parse_output(proc.stdout, workdir)
        try:
            result = output.model_validate(payload)
        except ValidationError as exc:
            raise ProviderError(
                f"{self.executable} returned output that does not match the schema: {exc}"
            ) from exc
        self._log_call(output, started)
        return result

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Write the question set for ``diff``."""
        return self._run(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """Draw the whiteboard for ``diff``."""
        return self._run(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Grade every answer in one call."""
        return self._run(
            GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
        )

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Grade one answer again, given the developer's case against ``grade``."""
        prompt = appeal_prompt(
            diff, question, answer, grade, justification=justification, difficulty=difficulty
        )
        return self._run(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

enforces_schema class-attribute

enforces_schema: bool = False

Whether the CLI constrains its output to the schema; otherwise it is asked for.

system_flag class-attribute

system_flag: str = ''

Flag that takes the system prompt; empty means prepend it to the user prompt.

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

The argv to run. The prompt is written to stdin.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """The argv to run. The prompt is written to stdin."""
    raise NotImplementedError

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Turn the process output into the JSON payload (a dict).

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Turn the process output into the JSON payload (a dict)."""
    raise NotImplementedError

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Write the question set for diff.

Source code in src/grip_hook/providers/agents.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Write the question set for ``diff``."""
    return self._run(QUESTION_SYSTEM_PROMPT, question_prompt(diff, difficulty), QuestionSet)

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

Draw the whiteboard for diff.

Source code in src/grip_hook/providers/agents.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """Draw the whiteboard for ``diff``."""
    return self._run(BOARD_SYSTEM_PROMPT, board_prompt(diff, difficulty, problems), Board)

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Grade every answer in one call.

Source code in src/grip_hook/providers/agents.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Grade every answer in one call."""
    return self._run(
        GRADING_SYSTEM_PROMPT, grading_prompt(diff, questions, answers, difficulty), GradeSheet
    )

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Grade one answer again, given the developer's case against grade.

Source code in src/grip_hook/providers/agents.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Grade one answer again, given the developer's case against ``grade``."""
    prompt = appeal_prompt(
        diff, question, answer, grade, justification=justification, difficulty=difficulty
    )
    return self._run(APPEAL_SYSTEM_PROMPT, prompt, QuestionGrade)

ClaudeCodeProvider

Bases: AgentCLIProvider

Claude Code (claude) in print mode with schema-enforced structured output.

Source code in src/grip_hook/providers/agents.py
class ClaudeCodeProvider(AgentCLIProvider):
    """Claude Code (``claude``) in print mode with schema-enforced structured output."""

    name = "claude-code"
    executable = "claude"
    install_hint = "Install it with `npm install -g @anthropic-ai/claude-code` and sign in."
    enforces_schema = True
    system_flag = "--system-prompt"

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """``claude -p`` with no tools, no session persistence, JSON envelope output."""
        cmd = [
            "-p",
            "--output-format",
            "json",
            "--json-schema",
            json.dumps(schema),
            "--tools",
            "",
            "--no-session-persistence",
        ]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Read ``structured_output`` from the JSON envelope."""
        try:
            envelope = json.loads(stdout)
        except json.JSONDecodeError as exc:
            raise ProviderError("claude returned an unreadable envelope") from exc
        if envelope.get("is_error"):
            raise ProviderError(f"claude reported an error: {envelope.get('result', '')}")
        structured = envelope.get("structured_output")
        if structured is not None:
            return structured
        return extract_json(str(envelope.get("result", "")))

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

claude -p with no tools, no session persistence, JSON envelope output.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """``claude -p`` with no tools, no session persistence, JSON envelope output."""
    cmd = [
        "-p",
        "--output-format",
        "json",
        "--json-schema",
        json.dumps(schema),
        "--tools",
        "",
        "--no-session-persistence",
    ]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Read structured_output from the JSON envelope.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Read ``structured_output`` from the JSON envelope."""
    try:
        envelope = json.loads(stdout)
    except json.JSONDecodeError as exc:
        raise ProviderError("claude returned an unreadable envelope") from exc
    if envelope.get("is_error"):
        raise ProviderError(f"claude reported an error: {envelope.get('result', '')}")
    structured = envelope.get("structured_output")
    if structured is not None:
        return structured
    return extract_json(str(envelope.get("result", "")))

CodexProvider

Bases: AgentCLIProvider

OpenAI Codex CLI (codex exec) with --output-schema.

Source code in src/grip_hook/providers/agents.py
class CodexProvider(AgentCLIProvider):
    """OpenAI Codex CLI (``codex exec``) with ``--output-schema``."""

    name = "codex"
    executable = "codex"
    install_hint = "Install it with `npm install -g @openai/codex` and run `codex login`."
    enforces_schema = True

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """Non-interactive, read-only sandbox, last message written to a file."""
        schema_file = workdir / "schema.json"
        schema_file.write_text(json.dumps(schema), "utf-8")
        cmd = [
            "exec",
            "-",
            "--skip-git-repo-check",
            "--sandbox",
            "read-only",
            "--output-schema",
            str(schema_file),
            "--output-last-message",
            str(workdir / "last-message.txt"),
        ]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Prefer the last-message file; fall back to scanning stdout."""
        last = workdir / "last-message.txt"
        text = last.read_text("utf-8") if last.exists() else stdout
        return extract_json(text)

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

Non-interactive, read-only sandbox, last message written to a file.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """Non-interactive, read-only sandbox, last message written to a file."""
    schema_file = workdir / "schema.json"
    schema_file.write_text(json.dumps(schema), "utf-8")
    cmd = [
        "exec",
        "-",
        "--skip-git-repo-check",
        "--sandbox",
        "read-only",
        "--output-schema",
        str(schema_file),
        "--output-last-message",
        str(workdir / "last-message.txt"),
    ]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Prefer the last-message file; fall back to scanning stdout.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Prefer the last-message file; fall back to scanning stdout."""
    last = workdir / "last-message.txt"
    text = last.read_text("utf-8") if last.exists() else stdout
    return extract_json(text)

GeminiProvider

Bases: AgentCLIProvider

Gemini CLI (gemini) in non-interactive JSON mode.

Source code in src/grip_hook/providers/agents.py
class GeminiProvider(AgentCLIProvider):
    """Gemini CLI (``gemini``) in non-interactive JSON mode."""

    name = "gemini"
    executable = "gemini"
    install_hint = "Install it with `npm install -g @google/gemini-cli` and sign in."
    enforces_schema = False

    def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
        """Prompt comes from stdin; the reply is a JSON envelope with a ``response`` key."""
        cmd = ["--output-format", "json"]
        if self.model:
            cmd += ["--model", self.model]
        return cmd

    def parse_output(self, stdout: str, workdir: Path) -> Any:
        """Unwrap the envelope, then extract the JSON object from the reply text."""
        try:
            envelope = json.loads(stdout)
        except json.JSONDecodeError:
            return extract_json(stdout)
        if isinstance(envelope, dict):
            if "error" in envelope:
                err = envelope["error"]
                message = err.get("message", err) if isinstance(err, dict) else err
                raise ProviderError(f"gemini reported an error: {message}")
            if "response" in envelope:
                return extract_json(str(envelope["response"]))
        return envelope

command

command(schema: dict[str, Any], workdir: Path) -> list[str]

Prompt comes from stdin; the reply is a JSON envelope with a response key.

Source code in src/grip_hook/providers/agents.py
def command(self, schema: dict[str, Any], workdir: Path) -> list[str]:
    """Prompt comes from stdin; the reply is a JSON envelope with a ``response`` key."""
    cmd = ["--output-format", "json"]
    if self.model:
        cmd += ["--model", self.model]
    return cmd

parse_output

parse_output(stdout: str, workdir: Path) -> Any

Unwrap the envelope, then extract the JSON object from the reply text.

Source code in src/grip_hook/providers/agents.py
def parse_output(self, stdout: str, workdir: Path) -> Any:
    """Unwrap the envelope, then extract the JSON object from the reply text."""
    try:
        envelope = json.loads(stdout)
    except json.JSONDecodeError:
        return extract_json(stdout)
    if isinstance(envelope, dict):
        if "error" in envelope:
            err = envelope["error"]
            message = err.get("message", err) if isinstance(err, dict) else err
            raise ProviderError(f"gemini reported an error: {message}")
        if "response" in envelope:
            return extract_json(str(envelope["response"]))
    return envelope

extract_json

extract_json(text: str) -> Any

Parse the first JSON object in text, tolerating code fences and chatter.

Source code in src/grip_hook/providers/agents.py
def extract_json(text: str) -> Any:
    """Parse the first JSON object in ``text``, tolerating code fences and chatter."""
    text = text.strip()
    if text.startswith("```"):
        text = text.strip("`")
        if text.startswith("json"):
            text = text[4:]
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass
    start, end = text.find("{"), text.rfind("}")
    if start == -1 or end <= start:
        raise ProviderError("the agent did not return a JSON object")
    try:
        return json.loads(text[start : end + 1])
    except json.JSONDecodeError as exc:
        raise ProviderError(f"the agent returned invalid JSON: {exc}") from exc

grip_hook.providers.fake

A deterministic provider for tests, demos and grip quiz --provider fake.

Two modes:

  • Default. Questions are derived from the changed file names; an answer scores full marks when it mentions the word because or is at least twenty characters long, half marks when it is non-empty, and zero otherwise.
  • Scripted. When GRIP_FAKE_SCRIPT points to a JSON file, questions, grading rules and verdicts come from that file (see :func:load_script). This is how the demo recording and some tests get realistic, reproducible output without a network. An optional board in the script is the whiteboard; its pins are graded by the rules of the scripted question at the same position.

Without a script, the whiteboard is drawn from the changed files: an untrusted caller, one box per file in order, and the five whiteboard questions pinned across them.

An appeal (grip appeal) is upheld with full marks when the justification says because, or with a scripted rule's score when one matches it and beats the first grade.

GRIP_FAKE_DELAY (seconds, float) makes each call sleep, so the spinner is visible.

Every call is timed and reports fixed token counts (:data:FAKE_USAGE), so the cost and latency shown after a quiz can be tested.

FAKE_USAGE module-attribute

FAKE_USAGE: dict[str, tuple[int, int]] = {'questions': (1200, 400), 'board': (1500, 700), 'grade': (1400, 300), 'appeal': (900, 100)}

(input, output) tokens the fake provider reports for each kind of request.

GradeRule

Bases: BaseModel

Award score when the lower-cased answer contains match.

Source code in src/grip_hook/providers/fake.py
class GradeRule(BaseModel):
    """Award ``score`` when the lower-cased answer contains ``match``."""

    match: str
    score: int = Field(ge=0, le=POINTS_PER_QUESTION)
    feedback: str

DefaultGrade

Bases: BaseModel

Grade used when no rule matches.

Source code in src/grip_hook/providers/fake.py
class DefaultGrade(BaseModel):
    """Grade used when no rule matches."""

    score: int = Field(default=0, ge=0, le=POINTS_PER_QUESTION)
    feedback: str = "No answer, or nothing that shows understanding of the change."

ScriptedQuestion

Bases: Question

A question plus the rules used to grade answers to it.

Source code in src/grip_hook/providers/fake.py
class ScriptedQuestion(Question):
    """A question plus the rules used to grade answers to it."""

    rules: list[GradeRule] = Field(default_factory=list)
    default: DefaultGrade = Field(default_factory=DefaultGrade)

    def grade(self, answer: str) -> tuple[int, str]:
        """First matching rule wins; rules are checked in order."""
        text = answer.lower()
        for rule in self.rules:
            if rule.match.lower() in text:
                return rule.score, rule.feedback
        return self.default.score, self.default.feedback

grade

grade(answer: str) -> tuple[int, str]

First matching rule wins; rules are checked in order.

Source code in src/grip_hook/providers/fake.py
def grade(self, answer: str) -> tuple[int, str]:
    """First matching rule wins; rules are checked in order."""
    text = answer.lower()
    for rule in self.rules:
        if rule.match.lower() in text:
            return rule.score, rule.feedback
    return self.default.score, self.default.feedback

Verdict

Bases: BaseModel

Verdict text for totals at or above min.

Source code in src/grip_hook/providers/fake.py
class Verdict(BaseModel):
    """Verdict text for totals at or above ``min``."""

    min: int = Field(ge=0, le=100)
    text: str

Script

Bases: BaseModel

The scripted provider's data file.

Source code in src/grip_hook/providers/fake.py
class Script(BaseModel):
    """The scripted provider's data file."""

    summary: str
    questions: list[ScriptedQuestion] = Field(min_length=QUESTION_COUNT, max_length=QUESTION_COUNT)
    verdicts: list[Verdict] = Field(default_factory=list)
    board: Board | None = None

    def verdict_for(self, total: int) -> str:
        """Pick the verdict with the highest ``min`` not above ``total``."""
        eligible = [v for v in self.verdicts if v.min <= total]
        if not eligible:
            return f"Scripted verdict: {total} points."
        return max(eligible, key=lambda v: v.min).text

verdict_for

verdict_for(total: int) -> str

Pick the verdict with the highest min not above total.

Source code in src/grip_hook/providers/fake.py
def verdict_for(self, total: int) -> str:
    """Pick the verdict with the highest ``min`` not above ``total``."""
    eligible = [v for v in self.verdicts if v.min <= total]
    if not eligible:
        return f"Scripted verdict: {total} points."
    return max(eligible, key=lambda v: v.min).text

FakeProvider

Bases: CallLog

Offline stand-in for a real model.

Source code in src/grip_hook/providers/fake.py
class FakeProvider(CallLog):
    """Offline stand-in for a real model."""

    name = "fake"

    def __init__(self, cfg: Config) -> None:
        self.model = cfg.model or "fake"
        script_path = os.environ.get(SCRIPT_ENV)
        self.script = load_script(Path(script_path)) if script_path else None
        try:
            self.delay = float(os.environ.get(DELAY_ENV, "0") or 0)
        except ValueError:
            self.delay = 0.0
        self.model_calls = []

    def _pause(self) -> float:
        """Sleep for ``GRIP_FAKE_DELAY``; return when the call started, for the log."""
        started = time.perf_counter()
        if self.delay > 0:
            time.sleep(self.delay)
        return started

    def _done(self, schema: type, started: float) -> None:
        self._log_call(schema, started, *FAKE_USAGE[call_kind(schema)])

    def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
        """Produce five questions, scripted or templated over the changed files."""
        started = self._pause()
        self._done(QuestionSet, started)
        if self.script is not None:
            questions = [
                Question(question=q.question, rubric=q.rubric, focus=q.focus)
                for q in self.script.questions
            ]
            return QuestionSet(summary=self.script.summary, questions=questions)
        files = list(diff.files) or ["the diff"]
        questions = [
            Question(
                question=template.format(file=files[i % len(files)]),
                rubric="Any specific, non-empty explanation.",
                focus=focus,
            )
            for i, (focus, template) in enumerate(_TEMPLATES[:QUESTION_COUNT])
        ]
        return QuestionSet(
            summary=(
                f"Fake summary of {len(files)} changed file(s) at {difficulty.value} difficulty."
            ),
            questions=questions,
        )

    def generate_board(
        self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
    ) -> Board:
        """The scripted board, or one drawn from the changed files."""
        self._done(Board, self._pause())
        if self.script is not None and self.script.board is not None:
            return self.script.board
        return _board_from_files(list(diff.files)[:5] or ["the diff"])

    def grade(
        self,
        diff: Diff,
        questions: list[Question],
        answers: list[Answer],
        difficulty: Difficulty,
    ) -> GradeSheet:
        """Score answers deterministically, by script rules or by length."""
        self._done(GradeSheet, self._pause())
        by_index = {a.question_index: a.text.strip() for a in answers}
        grades = []
        for i in range(len(questions)):
            text = by_index.get(i, "")
            if self.script is not None:
                score, feedback = self.script.questions[i].grade(text)
            elif not text:
                score, feedback = 0, "No answer given."
            elif "because" in text.lower() or len(text) >= 20:
                score, feedback = POINTS_PER_QUESTION, "Specific and complete."
            else:
                score, feedback = POINTS_PER_QUESTION // 2, "Too short to show understanding."
            grades.append(QuestionGrade(question_index=i, score=score, feedback=feedback))
        total = sum(g.score for g in grades)
        if self.script is not None:
            return GradeSheet(grades=grades, verdict=self.script.verdict_for(total))
        return GradeSheet(grades=grades, verdict=f"Fake verdict: {total} points.")

    def appeal(
        self,
        diff: Diff,
        question: Question,
        answer: Answer,
        grade: QuestionGrade,
        *,
        justification: str,
        difficulty: Difficulty,
    ) -> QuestionGrade:
        """Uphold an appeal that says ``because`` (or matches a scripted rule), else keep it."""
        self._done(QuestionGrade, self._pause())
        index = answer.question_index
        score, feedback = grade.score, "Appeal not upheld: the first grade stands."
        if self.script is not None:
            scripted, why = self.script.questions[index].grade(justification)
            if scripted > score:
                score, feedback = scripted, f"Appeal upheld. {why}"
        elif "because" in justification.lower():
            score, feedback = POINTS_PER_QUESTION, "Appeal upheld: the answer was right."
        return QuestionGrade(question_index=index, score=score, feedback=feedback)

generate_questions

generate_questions(diff: Diff, difficulty: Difficulty) -> QuestionSet

Produce five questions, scripted or templated over the changed files.

Source code in src/grip_hook/providers/fake.py
def generate_questions(self, diff: Diff, difficulty: Difficulty) -> QuestionSet:
    """Produce five questions, scripted or templated over the changed files."""
    started = self._pause()
    self._done(QuestionSet, started)
    if self.script is not None:
        questions = [
            Question(question=q.question, rubric=q.rubric, focus=q.focus)
            for q in self.script.questions
        ]
        return QuestionSet(summary=self.script.summary, questions=questions)
    files = list(diff.files) or ["the diff"]
    questions = [
        Question(
            question=template.format(file=files[i % len(files)]),
            rubric="Any specific, non-empty explanation.",
            focus=focus,
        )
        for i, (focus, template) in enumerate(_TEMPLATES[:QUESTION_COUNT])
    ]
    return QuestionSet(
        summary=(
            f"Fake summary of {len(files)} changed file(s) at {difficulty.value} difficulty."
        ),
        questions=questions,
    )

generate_board

generate_board(diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]) -> Board

The scripted board, or one drawn from the changed files.

Source code in src/grip_hook/providers/fake.py
def generate_board(
    self, diff: Diff, difficulty: Difficulty, problems: tuple[str, ...]
) -> Board:
    """The scripted board, or one drawn from the changed files."""
    self._done(Board, self._pause())
    if self.script is not None and self.script.board is not None:
        return self.script.board
    return _board_from_files(list(diff.files)[:5] or ["the diff"])

grade

grade(diff: Diff, questions: list[Question], answers: list[Answer], difficulty: Difficulty) -> GradeSheet

Score answers deterministically, by script rules or by length.

Source code in src/grip_hook/providers/fake.py
def grade(
    self,
    diff: Diff,
    questions: list[Question],
    answers: list[Answer],
    difficulty: Difficulty,
) -> GradeSheet:
    """Score answers deterministically, by script rules or by length."""
    self._done(GradeSheet, self._pause())
    by_index = {a.question_index: a.text.strip() for a in answers}
    grades = []
    for i in range(len(questions)):
        text = by_index.get(i, "")
        if self.script is not None:
            score, feedback = self.script.questions[i].grade(text)
        elif not text:
            score, feedback = 0, "No answer given."
        elif "because" in text.lower() or len(text) >= 20:
            score, feedback = POINTS_PER_QUESTION, "Specific and complete."
        else:
            score, feedback = POINTS_PER_QUESTION // 2, "Too short to show understanding."
        grades.append(QuestionGrade(question_index=i, score=score, feedback=feedback))
    total = sum(g.score for g in grades)
    if self.script is not None:
        return GradeSheet(grades=grades, verdict=self.script.verdict_for(total))
    return GradeSheet(grades=grades, verdict=f"Fake verdict: {total} points.")

appeal

appeal(diff: Diff, question: Question, answer: Answer, grade: QuestionGrade, *, justification: str, difficulty: Difficulty) -> QuestionGrade

Uphold an appeal that says because (or matches a scripted rule), else keep it.

Source code in src/grip_hook/providers/fake.py
def appeal(
    self,
    diff: Diff,
    question: Question,
    answer: Answer,
    grade: QuestionGrade,
    *,
    justification: str,
    difficulty: Difficulty,
) -> QuestionGrade:
    """Uphold an appeal that says ``because`` (or matches a scripted rule), else keep it."""
    self._done(QuestionGrade, self._pause())
    index = answer.question_index
    score, feedback = grade.score, "Appeal not upheld: the first grade stands."
    if self.script is not None:
        scripted, why = self.script.questions[index].grade(justification)
        if scripted > score:
            score, feedback = scripted, f"Appeal upheld. {why}"
    elif "because" in justification.lower():
        score, feedback = POINTS_PER_QUESTION, "Appeal upheld: the answer was right."
    return QuestionGrade(question_index=index, score=score, feedback=feedback)

load_script

load_script(path: Path) -> Script

Read and validate a script file.

Source code in src/grip_hook/providers/fake.py
def load_script(path: Path) -> Script:
    """Read and validate a script file."""
    try:
        data: Any = json.loads(path.read_text("utf-8"))
        return Script.model_validate(data)
    except (OSError, json.JSONDecodeError, ValidationError) as exc:
        raise ProviderError(f"could not load {SCRIPT_ENV}={path}: {exc}") from exc