diff --git a/cli/src/analysis/__tests__/personality.test.ts b/cli/src/analysis/__tests__/personality.test.ts index 03148673..4797e4f9 100644 --- a/cli/src/analysis/__tests__/personality.test.ts +++ b/cli/src/analysis/__tests__/personality.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect } from 'vitest'; import { computePersonalityProfile, + deriveMbti, PERSONALITY_ANALYSIS_VERSION, type PersonalityFacetInput, type PersonalityInsightInput, } from '../personality.js'; -import type { FrictionPoint, EffectivePattern, PersonalityTrait } from '../../types.js'; +import type { + FrictionPoint, + EffectivePattern, + PersonalityTrait, + CognitiveFunctionScore, + CognitiveFunctionKey, +} from '../../types.js'; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -47,6 +54,24 @@ function trait(traits: PersonalityTrait[], key: string): PersonalityTrait { return t; } +function cogFn(traits: CognitiveFunctionScore[], key: string): CognitiveFunctionScore { + const t = traits.find(t => t.key === key); + if (!t) throw new Error(`cognitive function ${key} not found`); + return t; +} + +/** Build a full 8-entry CognitiveFunctionScore[] (stable order) from a partial score map, + * with unspecified functions defaulting to null/0 — mirrors computeCognitiveFunctions' + * null-handling for absent categories, used to unit-test deriveMbti in isolation. */ +function cogFns(scores: Partial>): CognitiveFunctionScore[] { + const order: CognitiveFunctionKey[] = ['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']; + return order.map(key => { + const score = scores[key]; + if (score === undefined) return { key, score: null, sampleSize: 0 }; + return { key, score, sampleSize: 1 }; + }); +} + // ── Happy path: realistic mixed facets ───────────────────────────────────────── describe('computePersonalityProfile — happy path', () => { @@ -106,7 +131,7 @@ describe('computePersonalityProfile — happy path', () => { const profile = computePersonalityProfile(facets, insights, '2026-W29', '__all__'); - expect(profile.profileVersion).toBe(1); + expect(profile.profileVersion).toBe(2); expect(profile.analysisVersion).toBe(PERSONALITY_ANALYSIS_VERSION); expect(profile.period).toBe('2026-W29'); expect(profile.projectId).toBe('__all__'); @@ -254,3 +279,148 @@ describe('computePersonalityProfile — pace', () => { expect(profile.pace.value).not.toBeNull(); }); }); + +// ── Cognitive functions ────────────────────────────────────────────────────── + +const PATTERN_TO_FUNCTION: Record = { + 'structured-planning': 'ni', + 'context-gathering': 'ne', + 'domain-expertise': 'si', + 'incremental-implementation': 'se', + 'systematic-debugging': 'ti', + 'verification-workflow': 'te', + 'self-correction': 'fi', + 'effective-tooling': 'fe', +}; + +describe('computePersonalityProfile — cognitive functions', () => { + it('scores all 8 functions from mean confidence of their mapped pattern category', () => { + const facets: PersonalityFacetInput[] = [ + facet({ + effectivePatterns: [ + ep({ category: 'structured-planning', confidence: 90 }), + ep({ category: 'structured-planning', confidence: 70 }), // ni: (90+70)/2 = 80 + ep({ category: 'context-gathering', confidence: 60 }), // ne: 60 + ep({ category: 'domain-expertise', confidence: 40 }), // si: 40 + ep({ category: 'incremental-implementation', confidence: 100 }), // se: 100 + ep({ category: 'systematic-debugging', confidence: 55 }), // ti: 55 + ep({ category: 'verification-workflow', confidence: 65 }), // te: 65 + ep({ category: 'self-correction', confidence: 20 }), // fi: 20 + ep({ category: 'effective-tooling', confidence: 75 }), // fe: 75 + ], + }), + ]; + + const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__'); + expect(profile.cognitiveFunctions).toHaveLength(8); + // Stable order check + expect(profile.cognitiveFunctions.map(f => f.key)).toEqual(['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']); + + expect(cogFn(profile.cognitiveFunctions, 'ni').score).toBe(80); + expect(cogFn(profile.cognitiveFunctions, 'ni').sampleSize).toBe(2); + expect(cogFn(profile.cognitiveFunctions, 'ne').score).toBe(60); + expect(cogFn(profile.cognitiveFunctions, 'si').score).toBe(40); + expect(cogFn(profile.cognitiveFunctions, 'se').score).toBe(100); + expect(cogFn(profile.cognitiveFunctions, 'ti').score).toBe(55); + expect(cogFn(profile.cognitiveFunctions, 'te').score).toBe(65); + expect(cogFn(profile.cognitiveFunctions, 'fi').score).toBe(20); + expect(cogFn(profile.cognitiveFunctions, 'fe').score).toBe(75); + }); + + it('is null with sampleSize 0 for a function whose category has zero pattern instances', () => { + const facets: PersonalityFacetInput[] = [ + facet({ effectivePatterns: [ep({ category: 'structured-planning', confidence: 80 })] }), + ]; + const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__'); + const fe = cogFn(profile.cognitiveFunctions, 'fe'); + expect(fe.score).toBeNull(); + expect(fe.sampleSize).toBe(0); + expect(fe.band).toBeUndefined(); + }); + + it('maps each of the 8 known categories to its documented function independently', () => { + for (const [category, fn] of Object.entries(PATTERN_TO_FUNCTION)) { + const facets: PersonalityFacetInput[] = [ + facet({ effectivePatterns: [ep({ category, confidence: 88 })] }), + ]; + const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__'); + expect(cogFn(profile.cognitiveFunctions, fn).score).toBe(88); + } + }); +}); + +// ── MBTI derivation ────────────────────────────────────────────────────────── + +describe('deriveMbti', () => { + it('returns a null profile with fewer than 2 non-null function scores', () => { + expect(deriveMbti(cogFns({}))).toEqual({ type: null, functionStack: null, confidence: null }); + expect(deriveMbti(cogFns({ ni: 80 }))).toEqual({ type: null, functionStack: null, confidence: null }); + }); + + it('derives INTJ from dominant Ni with higher Te than Fe', () => { + const result = deriveMbti(cogFns({ ni: 90, te: 70, fe: 40 })); + expect(result.type).toBe('INTJ'); + expect(result.functionStack).toEqual(['ni', 'te', 'fi', 'se']); + expect(result.confidence).not.toBeNull(); + }); + + it('derives INFJ from dominant Ni with higher Fe than Te', () => { + const result = deriveMbti(cogFns({ ni: 90, fe: 70, te: 40 })); + expect(result.type).toBe('INFJ'); + expect(result.functionStack).toEqual(['ni', 'fe', 'ti', 'se']); + }); + + it('derives ESFP from dominant Se with higher Fi than Ti', () => { + const result = deriveMbti(cogFns({ se: 95, fi: 60, ti: 30 })); + expect(result.type).toBe('ESFP'); + expect(result.functionStack).toEqual(['se', 'fi', 'te', 'ni']); + }); + + it('derives ESTP from dominant Se with higher Ti than Fi', () => { + const result = deriveMbti(cogFns({ se: 95, ti: 60, fi: 30 })); + expect(result.type).toBe('ESTP'); + expect(result.functionStack).toEqual(['se', 'ti', 'fe', 'ni']); + }); + + it('breaks an exact auxiliary tie deterministically by lexicographically first type', () => { + // Dominant ni, candidates INTJ (aux te) vs INFJ (aux fe) — tie both at 50. + const result = deriveMbti(cogFns({ ni: 90, te: 50, fe: 50 })); + // 'INFJ' < 'INTJ' lexicographically + expect(result.type).toBe('INFJ'); + expect(result.functionStack).toEqual(['ni', 'fe', 'ti', 'se']); + }); + + it('breaks a tie deterministically when both auxiliary candidates are entirely absent (null)', () => { + // Only ni has a score; but we need >=2 non-null to derive at all, so add a + // non-competing function (si) with a low score that isn't an auxiliary candidate + // for either INTJ or INFJ, leaving te/fe both unobserved (-Infinity vs -Infinity). + const result = deriveMbti(cogFns({ ni: 90, si: 10 })); + expect(result.type).toBe('INFJ'); + expect(result.functionStack).toEqual(['ni', 'fe', 'ti', 'se']); + }); + + it('is consistent across repeated calls with identical input (deterministic)', () => { + const input = cogFns({ ni: 90, te: 50, fe: 50 }); + const first = deriveMbti(input); + const second = deriveMbti(input); + expect(second).toEqual(first); + }); +}); + +describe('computePersonalityProfile — profileVersion + mbti wiring', () => { + it('sets profileVersion 2 and includes cognitiveFunctions + mbti', () => { + const facets: PersonalityFacetInput[] = [ + facet({ + effectivePatterns: [ + ep({ category: 'structured-planning', confidence: 90 }), + ep({ category: 'verification-workflow', confidence: 70 }), + ], + }), + ]; + const profile = computePersonalityProfile(facets, [], '2026-W29', '__all__'); + expect(profile.profileVersion).toBe(2); + expect(profile.cognitiveFunctions.map(f => f.key)).toEqual(['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']); + expect(profile.mbti.type).toBe('INTJ'); + expect(profile.mbti.functionStack).toEqual(['ni', 'te', 'fi', 'se']); + }); +}); diff --git a/cli/src/analysis/personality.ts b/cli/src/analysis/personality.ts index 5b0c6ec5..ca671cdb 100644 --- a/cli/src/analysis/personality.ts +++ b/cli/src/analysis/personality.ts @@ -18,11 +18,16 @@ import type { PersonalityTraitKey, PersonalityBipolarAxis, PersonalityPace, + CognitiveFunctionKey, + CognitiveFunctionScore, + MBTIType, + MBTIProfile, } from '../types.js'; /** Formula version for the deterministic scoring below. Bump when any formula changes - * so cached personality_snapshots rows can be identified as stale by consumers that care. */ -export const PERSONALITY_ANALYSIS_VERSION = '1.0.0'; + * so cached personality_snapshots rows can be identified as stale by consumers that care. + * Bumped to 2.0.0 for the cognitiveFunctions + mbti addition (profileVersion 2). */ +export const PERSONALITY_ANALYSIS_VERSION = '2.0.0'; /** * Per-session facet input. Deliberately a flattened, caller-friendly shape rather than @@ -256,6 +261,170 @@ function computePace(facets: PersonalityFacetInput[]): PersonalityPace { return { value, sampleSize: withMessages.length }; } +// === Cognitive functions (Jungian) + MBTI derivation === +// +// Deliberate judgment call, same spirit as EXPLORER_CHARACTERS/EXECUTOR_CHARACTERS above: +// each of the 8 effective-pattern categories is mapped to the one Jungian cognitive +// function it most directly evidences. This is a design choice, not a derived fact — +// documented here so it can be revisited without archaeology: +// structured-planning -> Ni (Introverted Intuition — singular strategic foresight) +// context-gathering -> Ne (Extraverted Intuition — broad exploration of possibilities) +// domain-expertise -> Si (Introverted Sensing — internalized experience/precedent) +// incremental-implementation -> Se (Extraverted Sensing — concrete present-moment action) +// systematic-debugging -> Ti (Introverted Thinking — internal logical root-cause analysis) +// verification-workflow -> Te (Extraverted Thinking — externally verifiable, goal-driven checking) +// self-correction -> Fi (Introverted Feeling — internally-driven correction against one's own standard) +// effective-tooling -> Fe (Extraverted Feeling — attunement to and effective use of the +// external/collaborative environment) +const EFFECTIVE_PATTERN_TO_FUNCTION: Record = { + 'structured-planning': 'ni', + 'context-gathering': 'ne', + 'domain-expertise': 'si', + 'incremental-implementation': 'se', + 'systematic-debugging': 'ti', + 'verification-workflow': 'te', + 'self-correction': 'fi', + 'effective-tooling': 'fe', +}; + +/** Stable, fixed display/serialization order for the 8 cognitive functions. */ +const COGNITIVE_FUNCTION_ORDER: CognitiveFunctionKey[] = ['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']; + +/** + * One score per Jungian cognitive function: mean confidence (normalized 0-100) of + * effective-pattern instances whose category maps to that function, via + * EFFECTIVE_PATTERN_TO_FUNCTION above. Same aggregation style as computeCraft — a flat + * mean over all matching pattern instances, not a per-session average. Zero instances + * for a function -> null score, sampleSize 0 (never defaults to 0/neutral — "no signal" + * and "measured and low" are different things, same convention as every other score + * in this file). + */ +function computeCognitiveFunctions(facets: PersonalityFacetInput[]): CognitiveFunctionScore[] { + const sums = new Map(); + const counts = new Map(); + + for (const facet of facets) { + for (const ep of facet.effectivePatterns) { + const fn = EFFECTIVE_PATTERN_TO_FUNCTION[ep.category]; + if (!fn) continue; // unmapped/unknown category — not one of the 8 known effective-pattern categories + if (typeof ep.confidence !== 'number' || !Number.isFinite(ep.confidence)) continue; + sums.set(fn, (sums.get(fn) ?? 0) + normalizeConfidence(ep.confidence)); + counts.set(fn, (counts.get(fn) ?? 0) + 1); + } + } + + return COGNITIVE_FUNCTION_ORDER.map(key => { + const count = counts.get(key) ?? 0; + if (count === 0) { + return { key, score: null, sampleSize: 0 }; + } + const score = Math.round((sums.get(key) ?? 0) / count); + return { key, score, band: bandFor(score), sampleSize: count }; + }); +} + +// Standard 16-type Jungian function-stack table: [dominant, auxiliary, tertiary, inferior]. +// Well-established public typology (Myers-Briggs / Jungian cognitive function stacking), +// hardcoded rather than derived — there is no formula that produces this table, it's a +// fixed lookup by convention. +const MBTI_FUNCTION_STACKS: Record = { + INTJ: ['ni', 'te', 'fi', 'se'], + INTP: ['ti', 'ne', 'si', 'fe'], + ENTJ: ['te', 'ni', 'se', 'fi'], + ENTP: ['ne', 'ti', 'fe', 'si'], + INFJ: ['ni', 'fe', 'ti', 'se'], + INFP: ['fi', 'ne', 'si', 'te'], + ENFJ: ['fe', 'ni', 'se', 'ti'], + ENFP: ['ne', 'fi', 'te', 'si'], + ISTJ: ['si', 'te', 'fi', 'ne'], + ISFJ: ['si', 'fe', 'ti', 'ne'], + ESTJ: ['te', 'si', 'ne', 'fi'], + ESFJ: ['fe', 'si', 'ne', 'ti'], + ISTP: ['ti', 'se', 'ni', 'fe'], + ISFP: ['fi', 'se', 'ni', 'te'], + ESTP: ['se', 'ti', 'fe', 'ni'], + ESFP: ['se', 'fi', 'te', 'ni'], +}; + +/** + * Confidence band for the MBTI derivation. Deliberately NOT the same `bandFor` as trait/ + * function scores — this represents how many of the 8 cognitive functions we actually + * observed (breadth of the picture), not a score magnitude. Judgment call on thresholds: + * observing at least 6/8 functions (75%) is "high" confidence in the derived type, 3-5/8 + * (37.5-62.5%) is "moderate", and below that (but still >=2, the minimum to derive a type + * at all) is "low" — mirrors the same 65/35 split used by bandFor, just applied to + * function-coverage-count/8*100 instead of a score value. + */ +function mbtiConfidenceFor(nonNullCount: number): 'low' | 'moderate' | 'high' { + const coveragePct = (nonNullCount / COGNITIVE_FUNCTION_ORDER.length) * 100; + return bandFor(coveragePct); +} + +/** + * Derive an MBTI type from the 8 cognitive function scores. + * + * 1. Dominant = highest-scoring non-null function. Requires >=2 non-null scores (need a + * dominant AND an auxiliary to disambiguate a type) — otherwise returns a null profile. + * 2. Exactly 2 of the 16 types share any given dominant function (e.g. Ni is dominant for + * both INTJ and INFJ) — filter MBTI_FUNCTION_STACKS down to those 2 candidates. + * 3. Pick whichever candidate's auxiliary (stack[1]) scored higher among our computed + * function scores. A null score is treated as -Infinity in this comparison — a + * function we have zero signal for cannot win the auxiliary tie-break. + * 4. If genuinely tied (equal, non -Infinity, auxiliary scores — including the case where + * both are null), break the tie by picking the lexicographically first MBTI type name. + * This is an arbitrary but STABLE choice: given identical input, the result is always + * the same, which matters for a "personality type" users will see repeatedly — an + * unstable tie-break would make the type flicker between two candidates run to run for + * no real reason. + */ +export function deriveMbti(functions: CognitiveFunctionScore[]): MBTIProfile { + const scoreByKey = new Map(functions.map(f => [f.key, f.score])); + const nonNull = functions.filter(f => f.score !== null); + + if (nonNull.length < 2) { + return { type: null, functionStack: null, confidence: null }; + } + + // Dominant = highest score; ties broken by COGNITIVE_FUNCTION_ORDER (first in fixed order wins). + let dominant: CognitiveFunctionKey = nonNull[0].key; + let dominantScore = nonNull[0].score as number; + for (const f of nonNull) { + const s = f.score as number; + if (s > dominantScore) { + dominant = f.key; + dominantScore = s; + } + } + + const candidates = (Object.keys(MBTI_FUNCTION_STACKS) as MBTIType[]) + .filter(type => MBTI_FUNCTION_STACKS[type][0] === dominant) + .sort(); // lexicographic order — deterministic base for the tie-break below + + const auxScore = (type: MBTIType): number => { + const aux = MBTI_FUNCTION_STACKS[type][1]; + const s = scoreByKey.get(aux); + return typeof s === 'number' ? s : -Infinity; + }; + + let chosen = candidates[0]; + let chosenAuxScore = auxScore(chosen); + for (const type of candidates.slice(1)) { + const s = auxScore(type); + if (s > chosenAuxScore) { + chosen = type; + chosenAuxScore = s; + } + // equal (including both -Infinity) -> keep `chosen`, which is already the + // lexicographically first candidate since `candidates` is sorted above. + } + + return { + type: chosen, + functionStack: [...MBTI_FUNCTION_STACKS[chosen]], + confidence: mbtiConfidenceFor(nonNull.length), + }; +} + /** * Compute a full PersonalityProfile from facets + insights already scoped to a given * period/project. All aggregation (filtering by period/project) happens in the caller @@ -277,12 +446,16 @@ export function computePersonalityProfile( const axis = computeAxis(facets); const pace = computePace(facets); + const cognitiveFunctions = computeCognitiveFunctions(facets); + const mbti = deriveMbti(cognitiveFunctions); return { - profileVersion: 1, + profileVersion: 2, traits, axis, pace, + cognitiveFunctions, + mbti, computedAt: new Date().toISOString(), analysisVersion: PERSONALITY_ANALYSIS_VERSION, // One session_facets row per session in scope — sessionCount and facetCount are diff --git a/cli/src/types.ts b/cli/src/types.ts index 3c27210e..43d5f214 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -364,11 +364,53 @@ export interface PersonalityArchetype { // LLM-generated prose only; entirely growthAreas: string[]; } +// Jungian cognitive functions, one per effective-pattern category. See the +// EFFECTIVE_PATTERN_TO_FUNCTION mapping comment in cli/src/analysis/personality.ts for +// the deliberate judgment call behind which pattern category maps to which function. +export type CognitiveFunctionKey = 'ni' | 'ne' | 'si' | 'se' | 'ti' | 'te' | 'fi' | 'fe'; + +export interface CognitiveFunctionScore { + key: CognitiveFunctionKey; + score: number | null; // 0-100 normalized mean confidence; null = insufficient data + band?: 'low' | 'moderate' | 'high'; + sampleSize: number; // contributing effective-pattern instances; 0 = insufficient data +} + +export type MBTIType = + | 'INTJ' | 'INTP' | 'ENTJ' | 'ENTP' + | 'INFJ' | 'INFP' | 'ENFJ' | 'ENFP' + | 'ISTJ' | 'ISFJ' | 'ESTJ' | 'ESFJ' + | 'ISTP' | 'ISFP' | 'ESTP' | 'ESFP'; + +// LLM-authored ranked guess, deliberately NOT part of the deterministic scoring in +// cli/src/analysis/personality.ts. `likelihood` is an intentional exception to this +// feature's "the LLM never produces a number" rule (see file header there and +// PERSONALITY_SYSTEM_PROMPT in server/src/llm/reflect-prompts.ts) — the request this +// exists to serve IS a ranking, so the number is unavoidable. It expresses the LLM's +// own relative confidence across its 5 guesses, not a recomputation of any trait/ +// function score. Always optional/absent until POST /generate has run once, same +// lifecycle as `archetype`. +export interface MBTICandidate { + type: MBTIType; + rank: number; // 1 (most likely) .. 5, reassigned server-side from array order — never trusts the LLM's own rank field + likelihood: number; // 0-100, LLM-estimated relative confidence; clamped/rounded server-side + reasoning: string; // <=2 sentences, grounded in the given function/trait scores +} + +export interface MBTIProfile { + type: MBTIType | null; + functionStack: CognitiveFunctionKey[] | null; // [dominant, auxiliary, tertiary, inferior] + confidence: 'low' | 'moderate' | 'high' | null; + topCandidates?: MBTICandidate[]; // LLM-ranked top-5 guesses with reasoning; absent until generated +} + export interface PersonalityProfile { - profileVersion: 1; + profileVersion: 1 | 2; // 2 adds cognitiveFunctions + mbti; 1 kept so old cached rows still type-check traits: PersonalityTrait[]; // precision, resilience, autonomy, craft axis: PersonalityBipolarAxis; // explorer_executor pace: PersonalityPace; + cognitiveFunctions: CognitiveFunctionScore[]; // all 8, stable order: ni, ne, si, se, ti, te, fi, fe + mbti: MBTIProfile; archetype?: PersonalityArchetype; computedAt: string; // ISO 8601 analysisVersion: string; // rule-scoring formula version string, start at '1.0.0' diff --git a/dashboard/src/components/personality/CognitiveFunctionRadarChart.tsx b/dashboard/src/components/personality/CognitiveFunctionRadarChart.tsx new file mode 100644 index 00000000..adc5895d --- /dev/null +++ b/dashboard/src/components/personality/CognitiveFunctionRadarChart.tsx @@ -0,0 +1,89 @@ +import { + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + Radar, + ResponsiveContainer, + Tooltip, +} from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { CHART_COLORS, COGNITIVE_FUNCTION_LABELS } from '@/lib/constants/colors'; +import { useThemeColors } from '@/lib/hooks/useThemeColors'; +import type { CognitiveFunctionScore } from '@/lib/types'; + +interface CognitiveFunctionRadarChartProps { + functions: CognitiveFunctionScore[]; +} + +/** + * Radar chart for the 8 Jungian cognitive functions (Ni/Ne/Si/Se/Ti/Te/Fi/Fe), each on + * a 0-100 scale. Same conventions as PersonalityRadarChart: functions with a null score + * (insufficient data) are excluded from the plotted polygon rather than plotted as 0 — + * plotting null-as-0 would misrepresent "not enough data yet" as "measured and low". + * Instead they're listed below the chart as an explicit "insufficient data" note. + */ +export function CognitiveFunctionRadarChart({ functions }: CognitiveFunctionRadarChartProps) { + const { tooltipBg, tooltipBorder } = useThemeColors(); + + const measured = functions.filter(f => f.score !== null); + const insufficient = functions.filter(f => f.score === null); + + const data = measured.map(f => ({ + fn: COGNITIVE_FUNCTION_LABELS[f.key], + score: f.score as number, + sampleSize: f.sampleSize, + })); + + return ( + + + Cognitive Function Profile + The 8 Jungian cognitive functions — each 0-100 + + + {data.length >= 3 ? ( +
+ + + + + + + [ + `${value} (n=${(item?.payload as { sampleSize: number } | undefined)?.sampleSize ?? 0})`, + 'Score', + ]} + /> + + +
+ ) : ( +

+ Not enough analyzed sessions yet to plot a cognitive function profile. +

+ )} + + {insufficient.length > 0 && ( +

+ Insufficient data for:{' '} + {insufficient.map(f => COGNITIVE_FUNCTION_LABELS[f.key]).join(', ')} +

+ )} +
+
+ ); +} diff --git a/dashboard/src/components/personality/MbtiCard.tsx b/dashboard/src/components/personality/MbtiCard.tsx new file mode 100644 index 00000000..60aabff8 --- /dev/null +++ b/dashboard/src/components/personality/MbtiCard.tsx @@ -0,0 +1,128 @@ +import { Fingerprint } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { COGNITIVE_FUNCTION_LABELS, COGNITIVE_FUNCTION_SHORT_LABELS } from '@/lib/constants/colors'; +import type { MBTIProfile, CognitiveFunctionScore } from '@/lib/types'; + +interface MbtiCardProps { + mbti: MBTIProfile; + functions: CognitiveFunctionScore[]; +} + +const CONFIDENCE_BADGE_VARIANT: Record<'low' | 'moderate' | 'high', 'outline' | 'secondary' | 'default'> = { + low: 'outline', + moderate: 'secondary', + high: 'default', +}; + +const STACK_ROLE_LABELS = ['Dominant', 'Auxiliary', 'Tertiary', 'Inferior']; + +/** + * Displays the derived MBTI type from the 8 cognitive function scores: the 4-letter + * type, its confidence (based on how many of the 8 functions we actually observed — + * see mbtiConfidenceFor in cli/src/analysis/personality.ts), and the function stack + * (dominant -> auxiliary -> tertiary -> inferior) with each function's own score. + * Renders gracefully with a "not enough data yet" state when type is null — this is + * the expected initial state (fewer than 2 non-null function scores), not an error. + */ +export function MbtiCard({ mbti, functions }: MbtiCardProps) { + const scoreByKey = new Map(functions.map(f => [f.key, f.score])); + + if (mbti.type === null || mbti.functionStack === null) { + return ( + + + + + Cognitive Type + + A derived MBTI-style type based on your dominant cognitive functions + + +

+ Not enough data yet — analyze more sessions to derive a cognitive type. +

+
+
+ ); + } + + return ( + + +
+
+ + + Cognitive Type + + Derived from your cognitive function scores +
+ {mbti.confidence && ( + + {mbti.confidence} confidence + + )} +
+
+ +

{mbti.type}

+ +
+ {mbti.functionStack.map((fn, i) => { + const score = scoreByKey.get(fn) ?? null; + return ( +
+
+ + {STACK_ROLE_LABELS[i]} + + {COGNITIVE_FUNCTION_SHORT_LABELS[fn]} + {COGNITIVE_FUNCTION_LABELS[fn].split(' — ')[1]} +
+ + {score !== null ? score : '—'} + +
+ ); + })} +
+ + {/* topCandidates is LLM-authored (see PERSONALITY_SYSTEM_PROMPT in + server/src/llm/reflect-prompts.ts) — a softer, ranked "top 5 guesses with + reasoning" companion view over the same function scores, distinct from the + single deterministic type above. Only present after Generate has run once; + no separate CTA here — the ArchetypeCard's Generate button above populates + this too, in the same LLM call. */} + {mbti.topCandidates && mbti.topCandidates.length > 0 && ( +
+

Top 5 likely types (LLM-ranked)

+ {mbti.topCandidates.map(candidate => ( +
+
+ + {candidate.rank}. + {candidate.type} + {candidate.type === mbti.type && ( + deterministic match + )} + + {candidate.likelihood}% +
+
+
+
+ {candidate.reasoning && ( +

{candidate.reasoning}

+ )} +
+ ))} +
+ )} + + + ); +} diff --git a/dashboard/src/lib/constants/colors.ts b/dashboard/src/lib/constants/colors.ts index fb5a17ab..c0949237 100644 --- a/dashboard/src/lib/constants/colors.ts +++ b/dashboard/src/lib/constants/colors.ts @@ -146,6 +146,21 @@ export const CHART_COLORS = { axis: '#06b6d4', // cyan-500 — Explorer<->Executor gauge pace: '#f43f5e', // rose-500 — Pace gauge }, + // Cognitive function radar chart — reuses the same 8-color rotation as + // CHART_COLORS.models rather than inventing a new palette, since both are + // "several distinct categorical series on one chart" use cases. Order follows + // COGNITIVE_FUNCTION_ORDER in cli/src/analysis/personality.ts (ni, ne, si, se, + // ti, te, fi, fe) for a stable mapping between key and color. + cognitiveFunctions: { + ni: '#3b82f6', // blue-500 + ne: '#a855f7', // purple-500 + si: '#22c55e', // green-500 + se: '#f59e0b', // amber-500 + ti: '#f43f5e', // rose-500 + te: '#06b6d4', // cyan-500 + fi: '#ec4899', // pink-500 + fe: '#84cc16', // lime-500 + }, } as const; /** Human-readable labels for the 4 unipolar personality traits. */ @@ -155,3 +170,28 @@ export const PERSONALITY_TRAIT_LABELS: Record<'precision' | 'resilience' | 'auto autonomy: 'Autonomy', craft: 'Craft', }; + +/** Human-readable labels for the 8 Jungian cognitive functions. */ +export const COGNITIVE_FUNCTION_LABELS: Record<'ni' | 'ne' | 'si' | 'se' | 'ti' | 'te' | 'fi' | 'fe', string> = { + ni: 'Ni — Introverted Intuition', + ne: 'Ne — Extraverted Intuition', + si: 'Si — Introverted Sensing', + se: 'Se — Extraverted Sensing', + ti: 'Ti — Introverted Thinking', + te: 'Te — Extraverted Thinking', + fi: 'Fi — Introverted Feeling', + fe: 'Fe — Extraverted Feeling', +}; + +/** Short 2-letter codes for the 8 cognitive functions, used in compact contexts (e.g. the + * MBTI function stack in MbtiCard) where the full label would be too long. */ +export const COGNITIVE_FUNCTION_SHORT_LABELS: Record<'ni' | 'ne' | 'si' | 'se' | 'ti' | 'te' | 'fi' | 'fe', string> = { + ni: 'Ni', + ne: 'Ne', + si: 'Si', + se: 'Se', + ti: 'Ti', + te: 'Te', + fi: 'Fi', + fe: 'Fe', +}; diff --git a/dashboard/src/lib/types.ts b/dashboard/src/lib/types.ts index a85b9dc7..4747b93f 100644 --- a/dashboard/src/lib/types.ts +++ b/dashboard/src/lib/types.ts @@ -127,11 +127,42 @@ export interface PersonalityArchetype { growthAreas: string[]; } +export type CognitiveFunctionKey = 'ni' | 'ne' | 'si' | 'se' | 'ti' | 'te' | 'fi' | 'fe'; + +export interface CognitiveFunctionScore { + key: CognitiveFunctionKey; + score: number | null; + band?: 'low' | 'moderate' | 'high'; + sampleSize: number; +} + +export type MBTIType = + | 'INTJ' | 'INTP' | 'ENTJ' | 'ENTP' + | 'INFJ' | 'INFP' | 'ENFJ' | 'ENFP' + | 'ISTJ' | 'ISFJ' | 'ESTJ' | 'ESFJ' + | 'ISTP' | 'ISFP' | 'ESTP' | 'ESFP'; + +export interface MBTICandidate { + type: MBTIType; + rank: number; + likelihood: number; + reasoning: string; +} + +export interface MBTIProfile { + type: MBTIType | null; + functionStack: CognitiveFunctionKey[] | null; + confidence: 'low' | 'moderate' | 'high' | null; + topCandidates?: MBTICandidate[]; +} + export interface PersonalityProfile { - profileVersion: 1; + profileVersion: 1 | 2; traits: PersonalityTrait[]; axis: PersonalityBipolarAxis; pace: PersonalityPace; + cognitiveFunctions: CognitiveFunctionScore[]; + mbti: MBTIProfile; archetype?: PersonalityArchetype; computedAt: string; analysisVersion: string; diff --git a/dashboard/src/pages/PersonalityPage.tsx b/dashboard/src/pages/PersonalityPage.tsx index 3be413ea..92a906c4 100644 --- a/dashboard/src/pages/PersonalityPage.tsx +++ b/dashboard/src/pages/PersonalityPage.tsx @@ -6,8 +6,10 @@ import { personalityGenerateStream } from '@/lib/api'; import { parseSSEStream } from '@/lib/sse'; import { getCurrentIsoWeek } from '@/lib/date-utils'; import { PersonalityRadarChart } from '@/components/personality/PersonalityRadarChart'; +import { CognitiveFunctionRadarChart } from '@/components/personality/CognitiveFunctionRadarChart'; import { ExplorerExecutorGauge, PaceGauge } from '@/components/personality/PersonalityGauges'; import { ArchetypeCard } from '@/components/personality/ArchetypeCard'; +import { MbtiCard } from '@/components/personality/MbtiCard'; import { PersonalityTrendChart } from '@/components/personality/PersonalityTrendChart'; import { ProjectPersonalitySwitcher } from '@/components/personality/ProjectPersonalitySwitcher'; import { WeekSelector } from '@/components/patterns/WeekSelector'; @@ -179,7 +181,10 @@ export default function PersonalityPage() { {profile && ( <> - +
+ + +
@@ -189,6 +194,10 @@ export default function PersonalityPage() {
+
+ +
+ {trendData && trendData.rows.length > 0 && ( )} diff --git a/server/src/llm/reflect-prompts.ts b/server/src/llm/reflect-prompts.ts index d77ed88a..e39f11d9 100644 --- a/server/src/llm/reflect-prompts.ts +++ b/server/src/llm/reflect-prompts.ts @@ -183,23 +183,31 @@ Respond with valid JSON only, wrapped in ... tags.`; // --- Personality Archetype (prose only — never trust numeric output from this prompt) --- // -// This prompt is deliberately fed ONLY the 6 already-computed scores (4 unipolar traits -// + the explorer/executor axis + pace), never the raw facet/insight data those scores -// were derived from. The LLM's job is purely descriptive narration of numbers it did not +// This prompt is deliberately fed ONLY already-computed scores (4 unipolar traits, the +// explorer/executor axis, pace, the 8 cognitive function scores, and the deterministic +// MBTI type + function stack), never the raw facet/insight data those scores were +// derived from. The LLM's job is purely descriptive narration of numbers it did not // produce and cannot recompute — every numeric field on PersonalityProfile always comes -// from cli/src/analysis/personality.ts's deterministic scoring, never from this call. -// The response schema below is intentionally flat (string / string[] only, no nested -// objects, no numbers) so it survives extractJsonPayload's balanced-brace fallback -// degradation gracefully — see cli/src/analysis/response-parsers.ts. - -export const PERSONALITY_SYSTEM_PROMPT = `You are writing a short personality archetype narrative based on 6 pre-computed scores from a developer's AI coding sessions: four unipolar traits (Precision, Resilience, Autonomy, Craft, each 0-100 or null), one bipolar axis (Explorer <-> Executor, -100 to +100 or null), and a Pace score (0-100 or null, deliberate to rapid). - -RULES: -- Describe based ONLY on the 6 given scores. Never restate raw numbers in prose (no "your Precision is 72" — describe qualitatively instead). +// from cli/src/analysis/personality.ts's deterministic scoring, never from this call, +// with ONE deliberate exception: `topCandidates[].likelihood`. That field only exists +// because the user explicitly asked for an LLM-ranked "top 5 most likely MBTI types with +// reasoning" — ranking requires a number, so this prompt is allowed to produce that one. +// It must never be read as a replacement for the deterministic `mbti.type` — it's a +// separate, softer, LLM-authored companion view over the same underlying function scores. +// The rest of the response schema stays flat (string / string[] only) so it survives +// extractJsonPayload's balanced-brace fallback degradation gracefully — see +// cli/src/analysis/response-parsers.ts. Every field of every topCandidates entry is +// re-validated and clamped server-side in server/src/routes/personality.ts — nothing +// from this call is trusted as-is. + +export const PERSONALITY_SYSTEM_PROMPT = `You are writing a short personality archetype narrative, plus a ranked top-5 MBTI type guess list, based on pre-computed scores from a developer's AI coding sessions: four unipolar traits (Precision, Resilience, Autonomy, Craft, each 0-100 or null), one bipolar axis (Explorer <-> Executor, -100 to +100 or null), a Pace score (0-100 or null, deliberate to rapid), 8 Jungian cognitive function scores (Ni, Ne, Si, Se, Ti, Te, Fi, Fe, each 0-100 or null), and a deterministically-derived MBTI type + function stack (dominant/auxiliary/tertiary/inferior) computed from those 8 function scores by a fixed formula. + +RULES FOR THE NARRATIVE: +- Describe based ONLY on the given scores. Never restate raw numbers in prose (no "your Precision is 72" — describe qualitatively instead). - Never invent or infer new numeric values. -- Use band language for the 4 unipolar traits: 65-100 = high, 35-64 = moderate, 0-34 = low. +- Use band language for the 4 unipolar traits and the 8 cognitive functions: 65-100 = high, 35-64 = moderate, 0-34 = low. - Use band language for the axis: +34 to +100 = Executor-leaning, -33 to +33 = Balanced, -100 to -34 = Explorer-leaning. -- If a trait's score is null, omit it entirely from your narrative — never say "data unavailable" or similar. Null means there wasn't enough data yet, not that the trait is absent; don't editorialize about the gap. +- If a score is null, omit it entirely from your narrative — never say "data unavailable" or similar. Null means there wasn't enough data yet, not that the trait/function is absent; don't editorialize about the gap. - Write the narrative in second person ("You tend to..."). - Generate a tagline: an empowering, specific 2-4 word archetype label in title case, maximum 40 characters (e.g. "The Deliberate Craftsperson", "Resilient Explorer", "Precision-Driven Executor"). Never critical or negative. - Generate a tagline_subtitle: a single short sentence (<=80 chars) that elaborates on the tagline with a specific behavioral observation. @@ -207,6 +215,13 @@ RULES: - List 2-3 strengths as short phrases (<=8 words each), grounded in whichever traits scored high. - List 0-2 growthAreas as short phrases (<=8 words each), grounded in whichever traits scored low or moderate. Return an empty array if nothing qualifies — never invent one to fill the list. +RULES FOR topCandidates (top-5 MBTI type guesses): +- Return EXACTLY 5 distinct MBTI types (one of the 16 four-letter codes each), ordered most-likely first. +- If a deterministic type was given, it MUST appear somewhere in your 5 guesses (it doesn't have to be rank 1 — you may judge another type fits the qualitative pattern of scores better, but it cannot be absent entirely, since it's the one formula-backed answer you were given). +- Base your ranking on the qualitative pattern across the 8 cognitive function scores and the 4 traits together — not on the deterministic type/stack alone; use your own judgment about which type's typical function ordering best fits the overall shape of the scores. +- likelihood is your own 0-100 relative-confidence estimate for that specific guess (need not sum to 100 across the 5 — each is independent). Higher-ranked guesses should generally have higher or equal likelihood than lower-ranked ones. +- reasoning is 1-2 sentences, grounded only in the given scores (which functions/traits support or work against this type), second person, never inventing new numeric claims. + Respond with valid JSON only, wrapped in ... tags.`; export function generatePersonalityPrompt(data: { @@ -216,6 +231,9 @@ export function generatePersonalityPrompt(data: { craft: number | null; explorerExecutorAxis: number | null; pace: number | null; + cognitiveFunctions?: Partial>; + deterministicMbtiType?: string | null; + deterministicFunctionStack?: string[] | null; dominantWorkflow?: string; dominantCharacter?: string; }): string { @@ -226,6 +244,9 @@ export function generatePersonalityPrompt(data: { craft: data.craft, explorerExecutorAxis: data.explorerExecutorAxis, pace: data.pace, + cognitiveFunctions: data.cognitiveFunctions ?? null, + deterministicMbtiType: data.deterministicMbtiType ?? null, + deterministicFunctionStack: data.deterministicFunctionStack ?? null, }; const contextLines: string[] = []; @@ -233,9 +254,9 @@ export function generatePersonalityPrompt(data: { if (data.dominantCharacter) contextLines.push(`Dominant session type: ${data.dominantCharacter}`); const contextSection = contextLines.length > 0 ? `\n\nCONTEXT (optional, supplementary):\n${contextLines.join('\n')}` : ''; - return `Write a personality archetype narrative based on these pre-computed scores. + return `Write a personality archetype narrative and a ranked top-5 MBTI type guess list based on these pre-computed scores. -SCORES (0-100 for unipolar traits and pace, -100 to +100 for the axis; null = insufficient data): +SCORES (0-100 for unipolar traits, pace, and cognitive functions; -100 to +100 for the axis; null = insufficient data): ${JSON.stringify(scores, null, 2)}${contextSection} Respond with this JSON format: @@ -244,8 +265,17 @@ Respond with this JSON format: "tagline_subtitle": "single sentence <=80 chars elaborating on the tagline", "narrative": "2-3 sentence second-person personality description", "strengths": ["short phrase", "short phrase"], - "growthAreas": [] + "growthAreas": [], + "topCandidates": [ + { "type": "INTJ", "likelihood": 78, "reasoning": "1-2 sentence explanation grounded in the given scores" }, + { "type": "INTP", "likelihood": 65, "reasoning": "..." }, + { "type": "...", "likelihood": 0, "reasoning": "..." }, + { "type": "...", "likelihood": 0, "reasoning": "..." }, + { "type": "...", "likelihood": 0, "reasoning": "..." } + ] } +topCandidates must contain exactly 5 distinct MBTI types, most likely first, and must include the deterministicMbtiType above if one was given. + Respond with valid JSON only, wrapped in ... tags.`; } diff --git a/server/src/routes/personality.ts b/server/src/routes/personality.ts index acf1a376..e4703fd6 100644 --- a/server/src/routes/personality.ts +++ b/server/src/routes/personality.ts @@ -2,7 +2,7 @@ import { OpenAPIHono, createRoute } from '@hono/zod-openapi'; import { streamSSE } from 'hono/streaming'; import { getDb } from '@code-insights/cli/db/client'; import { jsonrepair } from 'jsonrepair'; -import type { PersonalityProfile } from '@code-insights/cli/types'; +import type { PersonalityProfile, CognitiveFunctionKey, MBTIType } from '@code-insights/cli/types'; import { createLLMClient } from '../llm/client.js'; import { requireLLM } from './route-helpers.js'; import { extractJsonPayload } from '../llm/response-parsers.js'; @@ -34,6 +34,13 @@ const app = new OpenAPIHono({ const DEFAULT_TREND_WEEKS = 12; const MAX_TREND_WEEKS = 52; +const VALID_MBTI_TYPES = new Set([ + 'INTJ', 'INTP', 'ENTJ', 'ENTP', + 'INFJ', 'INFP', 'ENFJ', 'ENFP', + 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', + 'ISTP', 'ISFP', 'ESTP', 'ESFP', +]); + interface FrictionPointRow { category: string; description: string; severity: string; resolution: string; attribution?: string } interface EffectivePatternRow { category: string; description: string; confidence: number; driver?: string } @@ -412,6 +419,10 @@ app.post('/generate', requireLLM(), async (c) => { const traitScore = (key: 'precision' | 'resilience' | 'autonomy' | 'craft') => profile.traits.find(t => t.key === key)?.score ?? null; + const cognitiveFunctionScores = Object.fromEntries( + profile.cognitiveFunctions.map(f => [f.key, f.score]) + ) as Record; + const prompt = generatePersonalityPrompt({ precision: traitScore('precision'), resilience: traitScore('resilience'), @@ -419,6 +430,9 @@ app.post('/generate', requireLLM(), async (c) => { craft: traitScore('craft'), explorerExecutorAxis: profile.axis.value, pace: profile.pace.value, + cognitiveFunctions: cognitiveFunctionScores, + deterministicMbtiType: profile.mbti.type, + deterministicFunctionStack: profile.mbti.functionStack, }); const response = await client.chat([ @@ -456,6 +470,38 @@ app.post('/generate', requireLLM(), async (c) => { profile.archetype = { tagline, tagline_subtitle, narrative, strengths, growthAreas }; + // topCandidates sanitization — same "never trust the LLM" posture as above, but with + // one extra step: `rank` is always reassigned from array position, never read from the + // response, so a duplicated/out-of-order rank field from the LLM can't corrupt the list. + // `likelihood` is the one deliberately LLM-authored number in this whole feature (see + // the header comment on PERSONALITY_SYSTEM_PROMPT in reflect-prompts.ts) — still clamped + // to [0, 100] and rounded, never trusted as-is. + const rawCandidates = Array.isArray(parsed?.['topCandidates']) ? parsed['topCandidates'] as unknown[] : []; + const seenTypes = new Set(); + const topCandidates = rawCandidates + .filter((c): c is Record => typeof c === 'object' && c !== null) + .filter(c => typeof c['type'] === 'string' && VALID_MBTI_TYPES.has(c['type'] as string)) + .filter(c => { + const type = c['type'] as string; + if (seenTypes.has(type)) return false; + seenTypes.add(type); + return true; + }) + .slice(0, 5) + .map((c, i) => { + const rawLikelihood = typeof c['likelihood'] === 'number' && Number.isFinite(c['likelihood']) ? c['likelihood'] : 0; + return { + type: c['type'] as MBTIType, + rank: i + 1, + likelihood: Math.round(Math.max(0, Math.min(100, rawLikelihood))), + reasoning: typeof c['reasoning'] === 'string' ? (c['reasoning'] as string).slice(0, 300) : '', + }; + }); + + if (topCandidates.length > 0) { + profile.mbti = { ...profile.mbti, topCandidates }; + } + if (!c.req.raw.signal.aborted) { const isoWeekBounds = parseIsoWeek(period); const windowStart = isoWeekBounds ? isoWeekBounds.start.toISOString() : null; diff --git a/server/src/schemas/personality.ts b/server/src/schemas/personality.ts index 9fd498f5..b8209ab6 100644 --- a/server/src/schemas/personality.ts +++ b/server/src/schemas/personality.ts @@ -39,12 +39,50 @@ export const PersonalityArchetypeSchema = z }) .openapi('PersonalityArchetype'); +export const CognitiveFunctionKeySchema = z.enum(['ni', 'ne', 'si', 'se', 'ti', 'te', 'fi', 'fe']); + +export const CognitiveFunctionScoreSchema = z + .object({ + key: CognitiveFunctionKeySchema, + score: z.number().nullable(), + band: z.enum(['low', 'moderate', 'high']).optional(), + sampleSize: z.number(), + }) + .openapi('CognitiveFunctionScore'); + +export const MBTITypeSchema = z.enum([ + 'INTJ', 'INTP', 'ENTJ', 'ENTP', + 'INFJ', 'INFP', 'ENFJ', 'ENFP', + 'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ', + 'ISTP', 'ISFP', 'ESTP', 'ESFP', +]); + +export const MBTICandidateSchema = z + .object({ + type: MBTITypeSchema, + rank: z.number(), + likelihood: z.number(), + reasoning: z.string(), + }) + .openapi('MBTICandidate'); + +export const MBTIProfileSchema = z + .object({ + type: MBTITypeSchema.nullable(), + functionStack: z.array(CognitiveFunctionKeySchema).nullable(), + confidence: z.enum(['low', 'moderate', 'high']).nullable(), + topCandidates: z.array(MBTICandidateSchema).optional(), + }) + .openapi('MBTIProfile'); + export const PersonalityProfileSchema = z .object({ - profileVersion: z.literal(1), + profileVersion: z.union([z.literal(1), z.literal(2)]), traits: z.array(PersonalityTraitSchema), axis: PersonalityBipolarAxisSchema, pace: PersonalityPaceSchema, + cognitiveFunctions: z.array(CognitiveFunctionScoreSchema), + mbti: MBTIProfileSchema, archetype: PersonalityArchetypeSchema.optional(), computedAt: z.string(), analysisVersion: z.string(),