f3071e4b79
Task 5-2-01: prompts/examiner.py (Socratic oral-defense examiner; one question per
turn; grounded in TraceDigest + variant statement — never raw trace, never learner id,
D-028 mirror; rubric internals never revealed) + agents/examiner.py — ExaminerAgent
(next_question for the SSE pipeline; final_verdict -> DefenseVerdict via the D-020
defense). BOUNDARY: the examiner is a text agent and imports NO voice/ (STT/TTS belong
to the endpoints; integrity signals computed from turn metadata — A-109). Registry
registers all seven agents centrally (G-4); registry test updated six -> seven.
6 examiner tests (digest-grounded prompt w/o learner id; D-020 retry; 7-agent roster;
boundary import scan). Suite 367 green; ruff clean.
---ci---
phase: 5
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-006], partial: []}
---/ci---
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""ExaminerAgent — the seventh agent: oral-defense examiner (REQ-3-006, A-109).
|
|
|
|
BOUNDARY DECISION (PERSONAS conflict rule, honored by construction): the
|
|
examiner is a TEXT agent. It composes the LLM provider through BaseAgent and
|
|
consumes defense transcript turns; it NEVER imports voice/ — STT/TTS belong
|
|
to the API endpoints (they move audio bytes; the agent moves question text).
|
|
Integrity signals (long pauses, off-scope cadence) are computed by the
|
|
endpoint layer from turn metadata (latency_ms etc.), not by the agent.
|
|
|
|
Digest discipline (D-028 mirror): questions are grounded in the compact
|
|
TraceDigest + variant statement — never the raw trace, never learner ids.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from ..grading.features import TraceDigest
|
|
from ..llm.types import Message
|
|
from ..prompts.examiner import SYSTEM_PROMPT, VERDICT_SCHEMA_HINT, render_digest_context
|
|
from .base import BaseAgent
|
|
|
|
|
|
class DefenseVerdict(BaseModel):
|
|
"""D-20-validated final defense verdict (structured mode)."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
verdict: str = Field(pattern="^(mastered|developing|not_yet)$")
|
|
understanding: str = Field(min_length=1)
|
|
process_justification: str = Field(min_length=1)
|
|
communication: str = Field(min_length=1)
|
|
strengths: list[str] = Field(min_length=1, max_length=2)
|
|
gaps: list[str] = Field(min_length=1, max_length=2)
|
|
|
|
|
|
class ExaminerAgent(BaseAgent):
|
|
"""Conducts the oral defense: next_question + final_verdict."""
|
|
|
|
name = "examiner"
|
|
|
|
def system_prompt(self, learner_context=None) -> str: # noqa: ANN001
|
|
"""Examiner is context-free (digest-anonymous, D-028 mirror)."""
|
|
return SYSTEM_PROMPT
|
|
|
|
def build_defense_messages(
|
|
self,
|
|
trace_digest: TraceDigest | None = None,
|
|
variant_statement: str | None = None,
|
|
history: list[Message] | None = None,
|
|
) -> list[Message]:
|
|
"""System + grounding + defense transcript (no learner id — D-028)."""
|
|
digest_json = (
|
|
trace_digest.model_dump_json() if trace_digest is not None else "{}"
|
|
)
|
|
messages: list[Message] = [
|
|
Message(role="system", content=SYSTEM_PROMPT),
|
|
Message(role="user", content=render_digest_context(digest_json, variant_statement)),
|
|
Message(
|
|
role="assistant",
|
|
content="Understood. I will question the learner about this build session.",
|
|
),
|
|
]
|
|
for m in history or []:
|
|
messages.append(m)
|
|
return messages
|
|
|
|
async def next_question(
|
|
self,
|
|
history: list[Message],
|
|
trace_digest: TraceDigest | None = None,
|
|
variant_statement: str | None = None,
|
|
) -> str:
|
|
"""One examiner question (streamed over SSE by the endpoints)."""
|
|
messages = self.build_defense_messages(trace_digest, variant_statement, history)
|
|
messages.append(
|
|
Message(role="user", content="Ask the learner your next question now.")
|
|
)
|
|
reply = await self.provider.chat(messages, model=self.settings.model)
|
|
return reply
|
|
|
|
async def final_verdict(
|
|
self,
|
|
history: list[Message],
|
|
trace_digest: TraceDigest | None = None,
|
|
variant_statement: str | None = None,
|
|
) -> DefenseVerdict:
|
|
"""Structured verdict via the D-020 4-layer defense."""
|
|
from .structured import structured_completion # module-direct (G-4)
|
|
|
|
messages = self.build_defense_messages(trace_digest, variant_statement, history)
|
|
messages.append(
|
|
Message(
|
|
role="user",
|
|
content="The defense is finished. Return the final verdict JSON now.",
|
|
)
|
|
)
|
|
return await structured_completion(
|
|
self.provider,
|
|
messages,
|
|
model=self.settings.model,
|
|
schema=DefenseVerdict,
|
|
schema_hint=VERDICT_SCHEMA_HINT,
|
|
)
|