Beyond Text: Modeling Character Psychology with LLMs
Deconstructing Personality with AI

In the previous articles, we explored the Pipeline and Extraction layers. Today, we look at the “brain” of scenario-parser: the Character Analysis V2 engine.
This article focuses on Stage 3: Character Analysis - the LLM-powered psychological modeling that happens after our competitive parsing system has selected the best screenplay. We take the highest-quality parsed screenplay and use a hybrid graph-theory + multi-pass LLM approach to generate professional-grade character breakdowns.
Teaching an AI to understand a character is not as simple as asking, “Who is this person?” To get professional-grade analysis, the kind a casting director uses, we must deconstruct the character into component parts: Voice, Psychology, and Narrative Function.
The “Context Trap” & Dossier Generation
A feature film script is 120 pages long. Even with 128k context windows, dumping the entire script into an LLM is inefficient and prone to “hallucinations.” The model loses track of supporting characters in the noise.
Our solution is the Dossier System. After quality selection picks the best parsed screenplay from our 4 competing parsers, we generate a focused CharacterDossier for each role from that winning screenplay.
export type CharacterDossier = {
name: string;
sceneCount: number;
// We extract ONLY the dialogue lines for this character,
// plus the immediate action lines before/after for context.
dialogues: DialogueWithContext[];
stats: {
totalWords: number;
averageDialogueLength: number;
};
};
This “distillate” ensures the LLM sees only what is relevant to “Dr. Smith,” eliminating the noise of the other 50 characters.
Context Window Management: The Sampling Strategy
For lead characters with hundreds of dialogue lines, even a focused dossier can overflow context windows. We implement intelligent sampling rather than truncation:
// Smart dialogue sampling for lead characters
// Samples: first, last, and longest dialogue per scene
function prepareLeadCharacterData(characterName: string, config: CharacterAnalysisConfig): string {
const dialoguesByScene = groupByScene(charContext.dialogueWithContext);
// For each scene, sample: first, last, longest
for (const [sceneIdx, dialogues] of dialoguesByScene) {
const sampled = new Set<number>();
sampled.add(0); // First dialogue (establishes presence)
sampled.add(dialogues.length - 1); // Last dialogue (shows evolution)
sampled.add(findLongestDialogue()); // Longest (captures voice depth)
}
}
This gives the LLM enough data to understand character progression without drowning in thousands of tokens. Key insight: The first and last dialogue in a scene often show the most character change, while the longest dialogue reveals voice complexity.
Extracting Dialogue with Full Context
The dossier generator (packages/analyzer/src/character-analysis-v2/dossier-generator.ts:122-184) preserves narrative context:
function extractDialoguesFromScene(characterName: string, scene: BaseScene): DialogueWithContext[] {
const dialogues: DialogueWithContext[] = [];
let previousSpeaker: string | null = null;
for (const element of scene.elements) {
if (element.type === "dialogue" && element.character.normalizedName === characterName) {
const dialogue: DialogueWithContext = {
text: element.text,
actionBefore: findActionBefore(elements, i), // Stage direction before line
actionAfter: findActionAfter(elements, i), // Reaction after line
respondingTo: previousSpeaker // Conversational context
? { character: previousSpeaker, dialogue: previousDialogue }
: undefined
};
dialogues.push(dialogue);
}
}
return dialogues;
}
This captures what matters: who they’re talking to, what was just said, and what they do while speaking. This is screenplay analysis, not just text extraction.
The Hybrid Architecture: Graph + LLM
The breakthrough in V2 was realizing that not everything needs an LLM. We use a hybrid approach combining fast graph analysis with targeted LLM calls:
Key principle: Use deterministic graph algorithms for structural questions (“Who’s important?”), reserve LLMs for semantic questions (“What do they want?”).
Phase 1: Social Network Analysis (SNA) as Foundation
Before we make a single LLM call, we run graph-based analysis (packages/analyzer/src/character-analysis-v2/sna/index.ts:42-103):
export function analyzeCharacterGraph(
characterDossiers: Map<string, CharacterDossier>,
pairDossiers: CharacterPairDossier[],
): GraphAnalysisResult {
// Step 1: Build the graph (characters = nodes, co-occurrences = edges)
const graph = buildCharacterGraph(characterDossiers, pairDossiers);
// Step 2: Calculate centrality metrics
const centralityMetrics = computeCentralityMetrics(graph);
// - Degree: Number of connections (social breadth)
// - Betweenness: Bridge between groups (information broker)
// - Harmonic: Distance to all other nodes (protagonist proxy)
// - Eigenvector: Connected to well-connected characters (power/influence)
// Step 3: Calculate percentiles for ranking
calculatePercentiles(characterMetrics);
// Step 4: Classify tiers BEFORE LLM sees data
classifyTiers(characterMetrics); // LEAD/FEATURED/SUPPORTING/MINOR based on graph
return { characterMetrics, relationshipMetrics, graph, summary };
}
This gives us quantitative anchors that prevent LLM hallucination. The LLM can’t call a 3-line character a “protagonist” when the graph shows 5% centrality.
Injecting Graph Metrics into LLM Prompts
We format graph metrics as quantitative context (packages/analyzer/src/character-analysis-v2/sna/formatting.ts):
export function formatCharacterMetricsForPrompt(metrics: CharacterGraphMetrics): string {
return `
QUANTITATIVE PROFILE for ${metrics.name}:
Basic Metrics:
- Dialogue count: ${metrics.dialogueCount}
- Scene count: ${metrics.sceneCount}
- Importance rank: #${metrics.importanceRank} of ${totalCharacters} (${metrics.importancePercentile}th percentile)
Network Centrality (0-100 scale):
- Story centrality: ${metrics.storyCentrality.toFixed(1)}% (distance to all other characters - protagonist indicator)
- Social centrality: ${metrics.socialCentrality.toFixed(1)}% (number of connections - ensemble indicator)
- Bridge centrality: ${metrics.bridgeCentrality.toFixed(1)}% (connects separate groups - mediator indicator)
- Influence centrality: ${metrics.influenceCentrality.toFixed(1)}% (connected to important characters - power indicator)
Connected to: ${metrics.connectedCharacters.join(", ")}
`.trim();
}
The LLM now sees objective data before making qualitative judgments. This is the key to preventing hallucinations.
The Multi-Pass Architecture
We don’t ask for everything at once. We use a specialized multi-pass architecture defined in packages/analyzer.
Pass 1: The Anchor (Quantitative)
We explicitly anchor the LLM with hard data calculated during the parsing phase. In per-character-prompts.ts:58-68:
// From the actual prompt
`For TIER classification, USE THE QUANTITATIVE DATA AS YOUR PRIMARY ANCHOR:
- START with the concrete numbers: dialogue count, scene count, network position
- The QUANTITATIVE PROFILE above gives you objective metrics calculated from the entire screenplay
- Importance rank (percentile) tells you EXACTLY where this character ranks relative to all others
- Story centrality >80% almost always indicates a LEAD character
- Story centrality 60-80% typically indicates FEATURED_MAJOR
- Story centrality 40-60% typically indicates FEATURED
- Then layer in qualitative factors: presence across acts, impact on plot, character arc depth`
This prevents the common error where an LLM over-inflates a minor character’s importance just because they have one catchy monologue. The graph tells the truth.
Pass 2: The Psychological Deep Dive
This is where we extract the “Big Five” equivalent for narrative. In lead-character-prompts.ts:604-616, we force the model to identify conflicting forces.
We use a structured JSON schema (via Zod) to enforce this output:
export type PsychologicalProfile = {
corePersonality: string;
motivations: {
primary: string; // The engine driving the car
secondary: string[];
};
fears: string[]; // The brake pedal
desires: string[]; // The destination
// The most important field:
contradictions: string[]; // e.g. "Wants intimacy but pushes people away"
moralAlignment: string;
};
By explicitly asking for contradictions, we force the LLM to look for nuance. A flat character has no contradictions. A deep character always does.
Zod Schema Validation: Enforcing Structured Outputs
We use Zod schemas to constrain LLM outputs to professional formats (packages/analyzer/src/character-analysis-v2/prompts/lead-character-prompts.ts:521-597):
export const getPass3ASchema = (desc) => z.object({
performanceRequirements: z.object({
emotionalRange: z.enum(["EXTREME", "WIDE", "MODERATE", "LIMITED"])
.describe("Range of emotions actor must portray"),
physicalDemands: z.array(z.string()).describe("Physical activities required"),
specialSkills: z.array(z.string()).describe("Special skills needed"),
nudity: z.boolean().describe("Whether role involves nudity"),
violence: z.boolean().describe("Whether role involves violence"),
}),
chemistryRequirements: z.array(z.object({
withCharacter: z.string(),
importance: z.enum(["ESSENTIAL", "IMPORTANT", "HELPFUL"]),
reason: z.string()
})),
dialogueAnalysis: z.object({
vocabularyLevel: z.enum(["SOPHISTICATED", "EDUCATED", "COLLOQUIAL", "SIMPLE"]),
humor: z.enum(["WITTY", "SARCASTIC", "SLAPSTICK", "DRY", "NONE"]),
formality: z.enum(["FORMAL", "CASUAL", "MIXED"]),
// ... more structured enums
})
});
Why this matters for CTOs: This isn’t just type safety: it’s semantic validation. The LLM can’t return “very high” when we need "EXTREME" | "WIDE" | "MODERATE" | "LIMITED". This makes the output parseable, queryable, and database-ready.
The Vercel AI SDK’s generateObject() uses these schemas to guide token generation, not just validate after the fact.
Example Output (Raw JSON)
Here is an actual output from the engine for a “Protagonist” character:
{
"psychologicalProfile": {
"corePersonality": "Resilient survivor masking deep vulnerability",
"motivations": {
"primary": "To protect his younger brother from the cartel",
"secondary": ["To redeem himself for his father's death"]
},
"fears": [
"Becoming the monster he fights",
"Total isolation"
],
"contradictions": [
"Preaches non-violence to his brother but solves problems with brutality",
"Craves connection but trusts no one"
],
"moralAlignment": "Chaotic Good"
},
"narrativeArc": {
"arcType": "Redemption",
"startingPoint": "Cynical enforcer",
"endingPoint": "Self-sacrificing guardian",
"completeness": "COMPLETE"
}
}
The Synthesis Pass: Solving the “Rashomon” Effect
When you analyze characters individually, you get the “Blind Men and the Elephant” problem. Character A thinks they are the hero; Character B thinks A is the villain.
To fix this, we run a final Synthesis Pass (synthesis-pass.ts). We feed the summaries of all characters into one context to determine the global truth.
Prompt Strategy: The Conflict Resolution Algorithm
The synthesis pass acts as a conflict resolution algorithm (packages/analyzer/src/character-analysis-v2/prompts/synthesis-prompts.ts:8-62):
export function buildSynthesisPrompt(input: SynthesisInput, characterSummary: string, relationshipSummary: string): string {
return `
You are a professional screenplay analyst. You have completed detailed character and relationship analysis.
Now synthesize the global picture.
CHARACTER CLASSIFICATIONS:
${characterSummary}
KEY RELATIONSHIPS:
${relationshipSummary}
TASK:
Based on the character classifications and relationships above, determine:
1. ENSEMBLE TYPE:
- SINGLE_PROTAGONIST: One clear main character driving the story
- DUAL_PROTAGONIST: Two equal protagonists (buddy film, romantic pairs)
- ENSEMBLE: Multiple characters with roughly equal narrative weight (3-8 leads)
- ANTHOLOGY: Multiple separate storylines with different protagonists
2. RELATIONSHIP CLUSTERS:
Groups of 3-8 characters who are strongly connected to each other.
GUIDELINES:
- Count the number of LEAD and FEATURED_MAJOR characters
- 1 LEAD → SINGLE_PROTAGONIST
- 2 LEADs with PRIMARY relationship → DUAL_PROTAGONIST
- 3-8 LEADs → ENSEMBLE
- Use the synopsis to understand the narrative structure
`;
}
This pass allows us to detect “Ensembles” vs “Single Protagonists” accurately. If we just counted lines, a chatty villain might look like a protagonist. The Synthesis pass understands narrative weight.
Technical insight: This is a map-reduce pattern for LLM orchestration. Map = individual character analysis in parallel. Reduce = synthesis pass aggregates and resolves conflicts.
Error Handling: LLM Failures and Retries
LLMs are probabilistic. Content safety filters can trigger on screenplay violence. We implement sophisticated retry logic (packages/analyzer/src/character-analysis-v2/passes/lead-character-pass.ts:221-289):
export async function executePass3A(
characterName: string,
config: CharacterAnalysisConfig,
interactionMapping: InteractionMapping,
logger?: Logger
): Promise<PerformanceCastingAnalysis> {
try {
// First attempt with standard system prompt
const systemPrompt = getCharacterAnalysisSystemPrompt(language);
return await executePass3AInternal(characterName, config, interactionMapping, systemPrompt);
} catch (error) {
// Check if this is a content safety error
if (!isContentSafetyError(error)) {
logger?.error({ character: characterName, error }, "Fatal error, using fallback analysis");
return createPass3AFallback(characterName, config);
}
logger?.warn({ character: characterName }, "Content safety filter triggered, retrying with enhanced safety prompt");
try {
// Retry with enhanced safety system prompt
const enhancedSystemPrompt = getEnhancedSafetySystemPrompt(language);
return await executePass3AInternal(characterName, config, interactionMapping, enhancedSystemPrompt);
} catch (retryError) {
logger?.error({ character: characterName }, "All retry attempts failed, using fallback analysis");
return createPass3AFallback(characterName, config);
}
}
}
Graceful degradation strategy:
- Try with standard prompt
- If content safety error detected → retry with abstract terminology prompt
- If still fails → return deterministic fallback based on graph metrics
The enhanced safety prompt (packages/analyzer/src/character-analysis-v2/system-prompts.ts:83-108) instructs:
`IMPORTANT SAFETY REMINDER:
The previous analysis was blocked by a content safety filter. Please approach this analysis with extra caution:
- Use ONLY abstract, clinical terminology for any sensitive content
- Do NOT quote explicit dialogue or graphic descriptions directly
- Focus on narrative function and thematic purpose
- Maintain maximum professional distance from the content
- If describing violence, intimacy, or sensitive topics, use high-level summaries only`
This prompt engineering pattern treats safety errors as recoverable exceptions, not fatal crashes.
Fallback Strategy: When LLMs Fail
When all retries fail, we return a fallback based purely on graph metrics:
function createPass3AFallback(characterName: string, config: CharacterAnalysisConfig): PerformanceCastingAnalysis {
const charContext = config.characterContexts.find(c => c.name === characterName);
return {
name: characterName,
performanceRequirements: {
emotionalRange: charContext.sceneCount > 20 ? "WIDE" : "MODERATE",
physicalDemands: [],
specialSkills: [],
nudity: false,
violence: false
},
actorProfile: {
tier: charContext.sceneCount > 30 ? "A_LIST" : "ESTABLISHED",
strengths: ["Character work required"],
challenges: ["Complex role"]
},
chemistryRequirements: [],
dialogueAnalysis: {
voiceDescription: "Analysis unavailable due to content restrictions",
vocabularyLevel: "EDUCATED",
speechPatterns: [],
emotionalExpression: "Varied",
humor: "NONE",
formality: "MIXED",
distinctiveness: "MODERATELY_DISTINCTIVE"
},
sceneCount: charContext.sceneCount,
dialogueCount: charContext.dialogueCount,
locationCount: new Set(/* calculate from scenes */).size
};
}
Production principle: Always have a deterministic fallback. The show must go on.
Preventing Hallucinations: Quantitative Anchors
The #1 problem with LLMs analyzing creative content is over-interpretation. An LLM will invent character arcs that don’t exist. Our defense: quantitative anchors.
In every prompt, we inject hard numbers:
// From per-character-prompts.ts:28-33
`CAST OVERVIEW (by dialogue and scene count):
${castOverview}
QUANTITATIVE PROFILE:
${quantitativeContext} // Graph metrics injected here
CHARACTER DOSSIER:
${dossier}
IMPORTANT GUIDELINES:
- Base your analysis ONLY on the provided dossier and synopsis
- Do not invent details not present in the text
- If dialogue count is very low (<3), tier should be DAY_PLAYER
- Use the complete dialogue context provided - don't guess at character arcs`
The rule: Make the LLM cite its sources implicitly. If a character has 3 lines, the LLM can’t claim they’re complex.
The Lead Character Deep Dive: 3-Pass Splitting
For lead characters, we split analysis into 3 parallel LLM calls instead of one monolithic prompt (packages/analyzer/src/character-analysis-v2/passes/lead-character-pass.ts:30-127):
Why split?
- Expertise specialization: Each prompt has a different “persona” with domain-specific language
- Token efficiency: We don’t send casting info to the psychologist or vice versa
- Parallel execution: 3 smaller calls are faster than 1 large call
- Failure isolation: If casting analysis fails, psychology can still succeed
From lead-character-prompts.ts:13-72, Pass 3A uses industry-specific language:
`You are an elite casting director and talent agent analyzing a LEAD ROLE for casting and representation.
YOUR GOAL: Provide comprehensive casting and performance analysis to help:
1. Casting directors evaluate actor fit
2. Talent agents pitch actors for this role
3. Production companies understand performance requirements
1. CASTING DIRECTION (ONLY from explicit screenplay information):
- Physical requirements: age, gender, ethnicity, physical demands (ONLY if mentioned in screenplay)
CRITICAL: Do NOT invent casting information. Only extract what is explicitly stated.
2. PERFORMANCE REQUIREMENTS:
- Emotional range needed (EXTREME, WIDE, MODERATE, LIMITED)
- Special skills required (languages, musical instruments, accents, etc.)
- Nudity (boolean)
- Violence (boolean)
4. CHEMISTRY REQUIREMENTS:
- With which other characters is chemistry essential/important?
- CRITICAL: Each character should appear only ONCE. Do NOT create duplicate entries.`
Pass 3B speaks like a narrative psychologist (lead-character-prompts.ts:148-208):
`You are an elite screenplay analyst and character psychologist analyzing a LEAD CHARACTER.
1. PSYCHOLOGICAL PROFILE:
- Core personality traits
- Primary motivation (the main thing driving this character)
- Deepest fears
- Internal contradictions and conflicts
- Psychological complexity level (EXTREMELY_COMPLEX, COMPLEX, NUANCED, STRAIGHTFORWARD)
2. NARRATIVE ARC:
- Arc type (e.g., "Hero's journey", "Tragic fall", "Redemption arc")
- Turning points: Key moments that change the character (with scene numbers)
- CRITICAL: Each scene number should appear only ONCE. Do NOT repeat the same scene.
3. EMOTIONAL JOURNEY:
- Dominant emotions throughout the screenplay (top 3-5)
- Key emotional moments (scene number, emotion, context)
BE SPECIFIC: Reference specific scenes, dialogue, and actions.
BE ANALYTICAL: Dig deep into psychology and motivation.`
Pass 3C synthesizes both analyses into actionable insights (lead-character-prompts.ts:286-320):
`You are a talent agent and production executive synthesizing character analysis for marketing and decision-making.
CASTING & PERFORMANCE ANALYSIS:
${castingAnalysis}
NARRATIVE & PSYCHOLOGICAL ANALYSIS:
${narrativeAnalysis}
YOUR GOAL: Synthesize these analyses into actionable insights.
1. KEY SCENES (synthesize from both analyses):
- Identify 4-6 most important scenes for this character
- CRITICAL: Each scene number should appear only ONCE.
2. ROLE SUMMARY:
- Write a compelling 2-3 sentence pitch for this role
- Think: "What would make an A-list actor say yes to this?"`
The synthesis pass (3C) is where we deduplicate scene references and create the final coherent narrative. It’s a conflict resolution pass.
Prompt Versioning and Effectiveness
In production systems, prompts are code. We version them, track effectiveness, and iterate. Each prompt file includes the language configuration and schema descriptions:
// From prompts/per-character-prompts.ts:202-219
export const perCharacterSchemaDescriptions = {
en: {
tier: "Character tier based on screen time and narrative weight (LEAD, FEATURED_MAJOR, ...)",
role: "Character role in the narrative (PROTAGONIST, ANTAGONIST, ...)",
functionalArchetype: "Classic story archetype (e.g., Hero, Mentor, Trickster, ...)",
narrativeFunction: "Specific narrative function in this screenplay (2-3 sentences)"
},
fr: {
tier: "Niveau du personnage basé sur le temps d'écran...",
// French translations
}
};
These descriptions are injected into the Zod schema as field-level guidance for the LLM. The model sees:
tier: [enum] "Character tier based on screen time and narrative weight (LEAD, FEATURED_MAJOR, ...)"
This is in-context learning via schema descriptions. The LLM understands field semantics without bloating the system prompt.
Token Economics: The 80/20 Rule
We allocate LLM tokens strategically:
- Phase 0 (Dossier): 0 tokens (pure TypeScript)
- Phase 1 (Graph): 0 tokens (pure graph algorithms)
- Pass 1 (Classification): ~500 tokens/character × N characters in parallel
- Pass 2 (Dyads): ~800 tokens/pair × Top 100 pairs in parallel
- Pass 3 (Synthesis): ~2000 tokens (1 call)
- Pass 4 (Lead Deep Dives): ~1500 tokens × 3 passes × M leads in parallel
- Pass 5 (Supporting): ~600 tokens/cluster
- Pass 6 (Day Players): ~300 tokens/batch
For a 50-character screenplay:
- Old approach: 50 × 5000 tokens = 250k tokens (sequential)
- V2 approach: ~150k tokens (parallel, with most spent on top 10 characters)
60% token reduction by using graph metrics to prioritize.
Before/After: The Impact of Multi-Pass
Before V2 (Single-Pass Analysis):
- Asked LLM to analyze everything in one shot
- Frequently misclassified character tiers
- Hallucinated relationships for minor characters
- Inconsistent personality descriptions (model drift within response)
- Failed on large casts (40+ characters)
After V2 (Multi-Pass + Graph Hybrid):
- Graph metrics prevent tier misclassification
- Only analyze relationships with graph evidence
- Contradictions explicitly requested, reducing flat characters
- Each pass has consistent scope (no drift)
- Scales to 100+ character casts
Real improvement: In testing with professional screenwriters, character tier accuracy went from ~65% to ~92% when validated against human casting director classifications.
The Engineering Philosophy: Prompt Engineering as Software Engineering
The key lessons for CTOs:
-
LLMs are probabilistic functions with high latency and cost. Treat them like expensive API calls. Use deterministic code when possible.
-
Schema validation is not optional. Zod schemas enforce semantic constraints that prevent “creative” LLM outputs from breaking your pipeline.
-
Quantitative anchors prevent hallucinations. Always inject hard metrics before asking for qualitative analysis.
-
Map-reduce for LLM orchestration. Parallel small prompts > serial large prompts. Split by expertise domain.
-
Graceful degradation is required. LLMs will fail. Content filters will trigger. Always have deterministic fallbacks.
-
Prompts are code. Version them, test them, review them. A prompt change is a code change.
-
Context window management is algorithmic. Don’t truncate, sample intelligently. First/last/longest captures the narrative arc.
-
Graph algorithms unlock LLM scalability. Use O(n²) graph algorithms to avoid O(n³) LLM calls.
Conclusion
The scenario-parser project demonstrates that “AI for Screenwriting” isn’t about generating scripts: it’s about understanding them. By combining:
- Graph Theory (Pipeline)
- Heuristic Extraction (Extraction)
- Psychological Modeling (Analysis)
We create a tool that mirrors the intuition of a human reader but scales to thousands of scripts. The result is not just data, but insight.
The breakthrough in V2 was recognizing that LLMs excel at semantic understanding, not structural analysis. By handling structure with deterministic algorithms and reserving LLMs for psychology and nuance, we built a system that is:
- Fast: Parallel execution with graph-driven prioritization
- Accurate: Quantitative anchors prevent hallucinations
- Scalable: Handles casts of 100+ characters
- Reliable: Graceful degradation when LLMs fail
- Professional: Structured outputs ready for production databases
This is the future of prompt engineering: hybrid systems where classical algorithms and neural networks each handle what they do best.