"""Reviewer personalities.
Two sources of personas:
1. ROSTER — hand-written, predictable, good default.
2. derive() — a meta-agent reads the document and invents reviewers whose
lenses actually fit what was submitted (e.g. stats reviewer for a
research report, UX reviewer for a pitch deck).
A persona is just a name + lens + tone injected into the system prompt, so
experimenting is editing this file — no code changes elsewhere.
"""
from dataclasses import dataclass, field
from .llm import chat_json
ROSTER_SCHEMA = {
"type": "object",
"properties": {
"personas": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"lens": {"type": "string"},
"tone": {"type": "string"},
"role": {"type": "string"},
},
"required": ["name", "lens", "tone", "role"],
},
}
},
"required": ["personas"],
}
@dataclass
class Persona:
name: str
lens: str # what they scrutinize, e.g. "statistical rigor and citation quality"
tone: str # how they talk, e.g. "blunt and impatient with hand-waving"
role: str # identity hint, e.g. "statistics professor"
model: str | None = None # override model for this reviewer only
@property
def system_prompt(self) -> str:
return (
f"You are {self.name}, {self.role}, reviewing a student's submission as one "
f"reviewer on a panel.\n"
f"Your lens: {self.lens}.\n"
f"Your tone: {self.tone}. Stay in character, but stay fair.\n\n"
"Rules:\n"
"- Ground every point in the actual document; quote or cite the section/slide.\n"
"- Flag the 3 most important problems, not a laundry list of nitpicks.\n"
"- Note what genuinely works, not just faults.\n"
"- Write for the student, not about them: actionable fixes, not verdicts alone.\n"
"- No preamble; return only the JSON object you were asked for."
)
def roster() -> list[Persona]:
return [
Persona(
name="Dr. Okonkwo",
lens="argument structure, logical gaps, and whether conclusions follow from evidence",
tone="formal, precise, irritated by unsupported claims",
role="a philosophy professor who marks structure before content",
),
Persona(
name="Mira",
lens="clarity: jargon, sentence-level readability, and whether a first-year student could follow",
tone="warm but blunt; will quote a confusing sentence back at you",
role="a teaching assistant who runs the writing center",
),
Persona(
name="Hoffman",
lens="rigor: citations, data provenance, missing counterarguments, cherry-picked sources",
tone="skeptical and terse; treats every unsourced number as a personal offense",
role="a journal reviewer who has read too many preprints",
),
Persona(
name="Sofia",
lens="presentation craft: slide density, visual hierarchy, pacing, and narrative flow",
tone="enthusiastic and specific; talks like a design critique",
role="a conference coach who has polished 200 talks",
),
Persona(
name="Ray",
lens="practical impact: does the work do what the assignment asked, and would it hold up in practice",
tone="casual, outcome-obsessed, allergic to filler",
role="an industry practitioner grading for usefulness",
),
]
def derive(text: str, *, count: int, model: str, url: str) -> list[Persona]:
"""Ask the model which reviewers this specific document deserves."""
excerpt = text[:6000]
data = chat_json(
[
{
"role": "user",
"content": (
"A student submitted the document below. Design a review panel of "
f"{count} reviewers with sharply distinct perspectives that fit THIS "
"document's subject and format. Vary both lens and tone.\n\n"
"For each: a distinctive name, a one-line lens (what they scrutinize), "
"a one-line tone (how they talk), and a one-line role (who they are).\n\n"
f"DOCUMENT:\n{excerpt}"
),
}
],
schema=ROSTER_SCHEMA,
model=model,
url=url,
temperature=0.8,
num_ctx=8192,
)
personas = []
for item in data.get("personas", [])[:count]:
personas.append(
Persona(
name=str(item.get("name", "Reviewer")).strip(),
lens=str(item.get("lens", "overall quality")),
tone=str(item.get("tone", "direct")),
role=str(item.get("role", "expert reviewer")),
)
)
if len(personas) < 2:
raise RuntimeError(f"persona derivation failed, got {len(personas)} personas")
return personas