Data models shared across grip.
The pydantic models double as the JSON schemas sent to LLM providers for
structured output, so keep field descriptions precise: the model reads them.
Stage
Bases: StrEnum
Where in the git workflow grip is running.
Source code in src/grip_hook/models.py
| class Stage(StrEnum):
"""Where in the git workflow grip is running."""
PRE_COMMIT = "pre-commit"
PRE_PUSH = "pre-push"
MANUAL = "manual"
|
Difficulty
Bases: StrEnum
How demanding the questions and grading should be.
Source code in src/grip_hook/models.py
| class Difficulty(StrEnum):
"""How demanding the questions and grading should be."""
EASY = "easy"
NORMAL = "normal"
HARD = "hard"
|
Question
Bases: BaseModel
One quiz question, generated from the diff.
Source code in src/grip_hook/models.py
| class Question(BaseModel):
"""One quiz question, generated from the diff."""
question: str = Field(
description=(
"A single, specific question about the change, phrased so a developer who "
"wrote the code could answer in one to three sentences."
)
)
rubric: str = Field(
description=(
"The key points a fully correct answer must cover. Never shown to the "
"developer; used only for grading."
)
)
focus: str = Field(
description=(
"A short label for what the question tests, e.g. 'behaviour', 'edge case', "
"'motivation', 'risk', 'testing', 'dependency'."
)
)
|
QuestionSet
Bases: BaseModel
The full set of questions for one quiz.
Source code in src/grip_hook/models.py
| class QuestionSet(BaseModel):
"""The full set of questions for one quiz."""
summary: str = Field(
description="One or two sentences describing what the change does, for the developer."
)
questions: list[Question] = Field(
description=f"Exactly {QUESTION_COUNT} questions.",
min_length=QUESTION_COUNT,
max_length=QUESTION_COUNT,
)
|
Answer
Bases: BaseModel
The developer's answer to a question.
Source code in src/grip_hook/models.py
| class Answer(BaseModel):
"""The developer's answer to a question."""
question_index: int
text: str
@field_validator("text")
@classmethod
def _tidy(cls, text: str) -> str:
"""Line breaks kept as newlines, whatever the platform sent; outer blanks trimmed."""
return text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
QuestionGrade
Bases: BaseModel
The grade for a single answer.
Source code in src/grip_hook/models.py
| class QuestionGrade(BaseModel):
"""The grade for a single answer."""
question_index: int = Field(description="Zero-based index of the question being graded.")
score: int = Field(
ge=0,
le=POINTS_PER_QUESTION,
description=f"Points awarded, from 0 to {POINTS_PER_QUESTION} inclusive.",
)
feedback: str = Field(
description=(
"One or two sentences: what was right and, when points were lost, where in "
"the code to look again and what kind of point is missing or wrong. Never "
"state the missing rubric point itself."
)
)
|
GradeSheet
Bases: BaseModel
Grades for every answer plus an overall verdict.
Source code in src/grip_hook/models.py
| class GradeSheet(BaseModel):
"""Grades for every answer plus an overall verdict."""
grades: list[QuestionGrade] = Field(
description=f"Exactly one grade per question, {QUESTION_COUNT} in total, in order.",
min_length=QUESTION_COUNT,
max_length=QUESTION_COUNT,
)
verdict: str = Field(
description=(
"One sentence for the developer summarising how well they understand this change."
)
)
@property
def total(self) -> int:
"""Sum of all per-question scores, capped at :data:`grip_hook.MAX_SCORE`."""
return min(sum(g.score for g in self.grades), MAX_SCORE)
|
total
property
Sum of all per-question scores, capped at :data:grip_hook.MAX_SCORE.
ModelCall
Bases: BaseModel
One request to the model: what it was for, how long it took, what it used.
Source code in src/grip_hook/models.py
| class ModelCall(BaseModel):
"""One request to the model: what it was for, how long it took, what it used."""
kind: str
"""``questions``, ``board``, ``grade`` or ``appeal``."""
seconds: float
"""Wall time of the request, retries included."""
input_tokens: int | None = None
"""Tokens the model read, when the provider reports it (the APIs do; most coding-agent
CLIs do not)."""
output_tokens: int | None = None
"""Tokens the model wrote, when the provider reports it."""
@property
def tokens(self) -> int | None:
"""Input plus output tokens, or ``None`` when either is unknown."""
if self.input_tokens is None or self.output_tokens is None:
return None
return self.input_tokens + self.output_tokens
|
kind
instance-attribute
questions, board, grade or appeal.
seconds
instance-attribute
Wall time of the request, retries included.
input_tokens: int | None = None
Tokens the model read, when the provider reports it (the APIs do; most coding-agent
CLIs do not).
output_tokens
class-attribute
instance-attribute
output_tokens: int | None = None
Tokens the model wrote, when the provider reports it.
tokens
property
Input plus output tokens, or None when either is unknown.
Appeal
Bases: BaseModel
A developer's appeal against the grade of one question, and how it ended.
Source code in src/grip_hook/models.py
| class Appeal(BaseModel):
"""A developer's appeal against the grade of one question, and how it ended."""
question_index: int
"""Zero-based index of the appealed question."""
justification: str
"""Why the developer says their answer was right, as they wrote it."""
old_score: int
"""Points before the appeal."""
new_score: int
"""Points after it: never lower than ``old_score``."""
feedback: str
"""What the second grading said about the appeal."""
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
@property
def upheld(self) -> bool:
"""Whether the appeal raised the score."""
return self.new_score > self.old_score
|
question_index
instance-attribute
Zero-based index of the appealed question.
justification
instance-attribute
Why the developer says their answer was right, as they wrote it.
old_score
instance-attribute
Points before the appeal.
new_score
instance-attribute
Points after it: never lower than old_score.
feedback
instance-attribute
What the second grading said about the appeal.
upheld
property
Whether the appeal raised the score.
Report
Bases: BaseModel
Everything about one quiz run, persisted as JSON for later inspection.
Source code in src/grip_hook/models.py
| class Report(BaseModel):
"""Everything about one quiz run, persisted as JSON for later inspection."""
version: int = 1
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
stage: Stage
provider: str
model: str
diff_digest: str
passing_score: int
score: int
passed: bool
summary: str
questions: list[Question]
answers: list[Answer]
grades: list[QuestionGrade]
verdict: str
calls: list[ModelCall] = Field(default_factory=list)
"""The model requests behind this report, oldest first. Questions or a board reused
from the cache made none. Empty in reports from before grip measured them."""
appeals: list[Appeal] = Field(default_factory=list)
"""``grip appeal`` re-grades, oldest first; at most one per question."""
@property
def max_score(self) -> int:
"""The maximum reachable score."""
return MAX_SCORE
@property
def seconds(self) -> float | None:
"""Time spent waiting for the model, or ``None`` when nothing was measured."""
return sum(c.seconds for c in self.calls) if self.calls else None
@property
def tokens(self) -> int | None:
"""Tokens used by every request, or ``None`` when one of them did not say."""
counts = [c.tokens for c in self.calls]
if not counts or any(n is None for n in counts):
return None
return sum(n for n in counts if n is not None)
|
calls
class-attribute
instance-attribute
calls: list[ModelCall] = Field(default_factory=list)
The model requests behind this report, oldest first. Questions or a board reused
from the cache made none. Empty in reports from before grip measured them.
appeals
class-attribute
instance-attribute
appeals: list[Appeal] = Field(default_factory=list)
grip appeal re-grades, oldest first; at most one per question.
max_score
property
The maximum reachable score.
seconds
property
Time spent waiting for the model, or None when nothing was measured.
tokens
property
Tokens used by every request, or None when one of them did not say.