Hook specifications¶
The 8 hookspecs live in backend/app/hookspecs.py. Each
hookspec defines the calling contract; plugins implement them
with @hookimpl.
get_assessment_questions¶
@hookspec
def get_assessment_questions(lang: str) -> list[dict] | None:
"""Return the question pack for the requested language.
Implemented by: assessment plugin.
Mode: list (not firstresult). The route currently uses
the first plugin's result; a future "multiple question
packs" feature could let providers register named packs.
"""
Return shape:
[
{
"id": "q01",
"type": "single" | "multi",
"text": "...",
"answers": [
{"id": "a", "text": "...", "weights": {"deductive": 1.0}},
...
]
},
...
]
calculate_profile¶
@hookspec(firstresult=True)
def calculate_profile(answers: list[dict]) -> dict[str, float]:
"""Aggregate raw answers into a 6-method profile.
Implemented by: assessment plugin.
firstresult: exactly one plugin computes the profile.
"""
Input answers shape:
[
{"question_id": "q01", "answer_ids": ["a", "b"]}, # multi
{"question_id": "q02", "answer_id": "c"}, # single
...
]
Return: dict[method, float] with the six method keys.
create_session_prompt¶
@hookspec(firstresult=True)
def create_session_prompt(
project: dict,
profile: dict,
method: str,
step: int,
lang: str,
) -> str:
"""Compose the system prompt for one (method, step, lang) cell.
Implemented by: session plugin.
firstresult: exactly one plugin composes the prompt.
"""
Return: a string ready to be sent as the system role
message. The session plugin's implementation reads from the
42-cell _PROMPTS dict and appends a context block.
ai_complete¶
@hookspec(firstresult=True)
def ai_complete(
messages: list[dict[str, Any]],
model: str,
api_key: str,
max_tokens: int = 1024,
) -> str | None:
"""Call the AI provider, return the assistant text.
Implemented by: ai-anthropic, ai-openai, ai-gemini.
firstresult: the matching provider plugin returns the
text; others return None.
"""
Each provider plugin checks the model prefix
(claude-*, gpt-*, gemini-*) and returns the assistant
text if it owns the model. Non-matching plugins return None,
letting firstresult fall through to the next.
messages shape (OpenAI-style):
[
{"role": "system", "content": "..."},
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."},
{"role": "user", "content": "..."},
]
Provider plugins normalise this shape to their own API
(Anthropic separates system out; Gemini folds it in).
recommend_method_switch¶
@hookspec
def recommend_method_switch(
history: list[dict],
profile: dict,
) -> dict | None:
"""Return a switch recommendation, or None.
Implemented by: session plugin.
Mode: list. Plugins return individual recommendations;
a future arbiter picks the highest-confidence non-None.
"""
Return shape:
{
"recommended": True,
"to_method": "dialogic",
"reason": "Three sessions of stagnant understanding.",
"confidence": 0.75, # optional
}
Return None (or {"recommended": False}) when no switch is
warranted.
on_session_complete¶
@hookspec
def on_session_complete(
session: dict,
rating: dict,
) -> None:
"""Side-effect hook: fired when a session is ended.
Implemented by: tracking plugin (writes a ProgressCommit).
Mode: list. Each subscriber runs; errors are caught and
logged but don't roll back the session-end.
"""
Errors in this hook MUST NOT propagate — the
_fire_on_session_complete wrapper in
backend/app/main.py catches and logs them.
get_progress_summary¶
@hookspec
def get_progress_summary(
project_id: str,
db: Session,
) -> dict | None:
"""Return one namespace slice of the progress summary.
Implemented by: tracking plugin (returns the "tracking"
namespace slice).
Mode: list. Slices are shallow-merged in the route.
"""
Each plugin returns a dict keyed by a unique namespace. The
tracking plugin returns {"tracking": {...}, "step_evaluation": {...}}.
A future stagnation-detector plugin could return
{"stagnation": {...}} etc.
get_tool_recommendations¶
@hookspec
def get_tool_recommendations(
profile: dict,
lang: str,
limit: int = 5,
) -> list[dict] | None:
"""Return ranked external-tool recommendations.
Implemented by: tools plugin.
Mode: list. The route currently uses the first plugin's
result; a future multi-source recommender could merge.
"""
Return shape:
[
{
"name": "Anki",
"url": "https://apps.ankiweb.net/",
"why": "...",
"weight_keys": ["deductive", "error_based"],
"score": 0.5
},
...
]
firstresult vs list mode¶
| Hookspec | Mode | Why |
|---|---|---|
| get_assessment_questions | list (effective firstresult) | Currently one pack; future could register named packs |
| calculate_profile | firstresult | Exactly one algorithm |
| create_session_prompt | firstresult | Exactly one prompt composer |
| ai_complete | firstresult | The matching provider owns the call |
| recommend_method_switch | list | Multiple plugins can suggest |
| on_session_complete | list | Side-effects fan out |
| get_progress_summary | list | Namespace slices merge |
| get_tool_recommendations | list (effective firstresult) | Currently one source |
The list modes that currently behave as firstresult are
deliberate: the contract is open to multi-plugin extension
but the v0.7.0 implementation uses just the first.