swarm / swarm/personas.py

raw file 5209 bytes
"""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