Files
nextcraft/apps/ai-service/ai_service/agents/examiner.py
T
CIAgent f3071e4b79 feat(P05): Examiner agent — seventh agent (Wave 2)
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---
2026-09-12 04:20:43 +00:00

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,
)