refactor(decnet_web/PersonaGeneration): extract types + helpers with tests
This commit is contained in:
89
decnet_web/src/components/PersonaGeneration/helpers.test.ts
Normal file
89
decnet_web/src/components/PersonaGeneration/helpers.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { coercePersona, mergePersonas, validate, BLANK } from './helpers';
|
||||
import type { EmailPersona } from './types';
|
||||
|
||||
const persona = (over: Partial<EmailPersona> = {}): EmailPersona => ({
|
||||
...BLANK, name: 'Jane', email: 'jane@example.com', role: 'admin', ...over,
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('rejects missing required fields', () => {
|
||||
expect(validate({ ...BLANK })).toBe('name is required');
|
||||
expect(validate(persona({ email: '' }))).toBe('email is required');
|
||||
expect(validate(persona({ email: 'bad' }))).toMatch(/user@domain/);
|
||||
expect(validate(persona({ role: '' }))).toBe('role is required');
|
||||
});
|
||||
|
||||
it('requires tone_custom when tone=custom', () => {
|
||||
expect(validate(persona({ tone: 'custom' }))).toMatch(/custom tone/);
|
||||
expect(validate(persona({ tone: 'custom', tone_custom: 'wry' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('caps mannerisms at 12', () => {
|
||||
const many = Array.from({ length: 13 }, (_, i) => `m${i}`);
|
||||
expect(validate(persona({ mannerisms: many }))).toMatch(/at most 12/);
|
||||
});
|
||||
|
||||
it('returns null for a valid persona', () => {
|
||||
expect(validate(persona())).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('coercePersona', () => {
|
||||
it('rejects non-objects and missing fields', () => {
|
||||
expect(coercePersona(null)).toEqual({ error: 'entry is not an object' });
|
||||
expect(coercePersona({})).toEqual({ error: 'missing name' });
|
||||
expect(coercePersona({ name: 'a' })).toEqual({ error: 'missing email' });
|
||||
expect(coercePersona({ name: 'a', email: 'a@b.c' })).toEqual({ error: 'missing role' });
|
||||
});
|
||||
|
||||
it('rejects invalid emails', () => {
|
||||
const r = coercePersona({ name: 'a', email: 'noat', role: 'r' });
|
||||
expect('error' in r && r.error).toMatch(/invalid email/);
|
||||
});
|
||||
|
||||
it('clamps mannerisms and slices long tone_custom', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const r = coercePersona({
|
||||
name: 'a', email: 'a@b.c', role: 'r',
|
||||
tone: 'custom', tone_custom: long,
|
||||
mannerisms: Array.from({ length: 20 }, (_, i) => `m${i}`).concat([42, null]),
|
||||
});
|
||||
expect('ok' in r).toBe(true);
|
||||
if ('ok' in r) {
|
||||
expect(r.ok.tone_custom?.length).toBe(128);
|
||||
expect(r.ok.mannerisms.length).toBe(12);
|
||||
expect(r.ok.mannerisms.every((m) => typeof m === 'string')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back tone/reply_latency to defaults on bad input', () => {
|
||||
const r = coercePersona({
|
||||
name: 'a', email: 'a@b.c', role: 'r',
|
||||
tone: 'bogus', reply_latency: 'wat',
|
||||
});
|
||||
expect('ok' in r).toBe(true);
|
||||
if ('ok' in r) {
|
||||
expect(r.ok.tone).toBe('formal');
|
||||
expect(r.ok.reply_latency).toBe('normal');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects tone=custom without tone_custom', () => {
|
||||
const r = coercePersona({
|
||||
name: 'a', email: 'a@b.c', role: 'r', tone: 'custom',
|
||||
});
|
||||
expect('error' in r && r.error).toMatch(/tone="custom"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePersonas', () => {
|
||||
it('adds new and replaces by lowercased email', () => {
|
||||
const current = [persona({ email: 'A@x.com', name: 'old' })];
|
||||
const incoming = [persona({ email: 'a@x.com', name: 'new' }), persona({ email: 'b@x.com' })];
|
||||
const r = mergePersonas(current, incoming);
|
||||
expect(r.added).toBe(1);
|
||||
expect(r.replaced).toBe(1);
|
||||
expect(r.merged.find((p) => p.email.toLowerCase() === 'a@x.com')?.name).toBe('new');
|
||||
});
|
||||
});
|
||||
124
decnet_web/src/components/PersonaGeneration/helpers.ts
Normal file
124
decnet_web/src/components/PersonaGeneration/helpers.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ApiError } from '../../utils/api';
|
||||
import type { EmailPersona, ReplyLatency, Tone } from './types';
|
||||
|
||||
export const TONES: Tone[] = ['formal', 'direct', 'casual', 'technical', 'custom'];
|
||||
export const LATENCIES: ReplyLatency[] = ['fast', 'normal', 'slow'];
|
||||
|
||||
export const BLANK: EmailPersona = {
|
||||
name: '',
|
||||
email: '',
|
||||
role: '',
|
||||
tone: 'formal',
|
||||
tone_custom: null,
|
||||
mannerisms: [],
|
||||
language: 'en',
|
||||
signature: null,
|
||||
active_hours: '09:00-18:00',
|
||||
reply_latency: 'normal',
|
||||
uses_llms_heavily: false,
|
||||
};
|
||||
|
||||
export const TEMPLATE: { personas: EmailPersona[] } = {
|
||||
personas: [
|
||||
{
|
||||
name: 'Jane Operator',
|
||||
email: 'jane@example.com',
|
||||
role: 'Network Admin',
|
||||
tone: 'formal',
|
||||
tone_custom: null,
|
||||
mannerisms: ["uses bullet points", "signs off with 'Best regards'"],
|
||||
language: 'en',
|
||||
signature: 'Jane Operator\nNetwork Admin',
|
||||
active_hours: '09:00-18:00',
|
||||
reply_latency: 'normal',
|
||||
uses_llms_heavily: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function extractErrorDetail(err: unknown, fallback: string): string {
|
||||
const e = err as ApiError;
|
||||
if (e?.response?.data?.detail) return e.response.data.detail;
|
||||
if (e?.response?.status === 403) return 'Insufficient permissions (admin only)';
|
||||
if (e?.response?.status === 401) return 'Session expired — please log in again';
|
||||
if (e?.message) return e.message;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Light client-side validation — server re-validates with the same
|
||||
* Pydantic schema the worker uses, this is just the early-warn UX. */
|
||||
export function validate(p: EmailPersona): string | null {
|
||||
if (!p.name.trim()) return 'name is required';
|
||||
if (!p.email.trim()) return 'email is required';
|
||||
if (!p.email.includes('@') || !p.email.split('@')[1]?.includes('.')) {
|
||||
return 'email must look like user@domain.tld';
|
||||
}
|
||||
if (!p.role.trim()) return 'role is required';
|
||||
if (p.tone === 'custom' && !(p.tone_custom ?? '').trim()) {
|
||||
return 'custom tone requires a description';
|
||||
}
|
||||
if (p.mannerisms.length > 12) return 'at most 12 mannerisms per persona';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Soft client-side normalizer for an uploaded persona entry.
|
||||
* Mirrors the Pydantic rules in decnet/realism/personas.py.
|
||||
* Server re-validates on save, so this is just early-warn UX. */
|
||||
export function coercePersona(raw: unknown): { ok: EmailPersona } | { error: string } {
|
||||
if (!raw || typeof raw !== 'object') return { error: 'entry is not an object' };
|
||||
const r = raw as Record<string, unknown>;
|
||||
const name = typeof r.name === 'string' ? r.name.trim() : '';
|
||||
const email = typeof r.email === 'string' ? r.email.trim() : '';
|
||||
const role = typeof r.role === 'string' ? r.role.trim() : '';
|
||||
if (!name) return { error: 'missing name' };
|
||||
if (!email) return { error: 'missing email' };
|
||||
if (!email.includes('@') || !email.split('@')[1]?.includes('.')) {
|
||||
return { error: `invalid email "${email}"` };
|
||||
}
|
||||
if (!role) return { error: 'missing role' };
|
||||
const tone = TONES.includes(r.tone as Tone) ? (r.tone as Tone) : 'formal';
|
||||
const tone_custom = typeof r.tone_custom === 'string' && r.tone_custom.trim()
|
||||
? r.tone_custom.slice(0, 128) : null;
|
||||
if (tone === 'custom' && !tone_custom) {
|
||||
return { error: 'tone="custom" requires a non-empty tone_custom' };
|
||||
}
|
||||
const reply_latency = LATENCIES.includes(r.reply_latency as ReplyLatency)
|
||||
? (r.reply_latency as ReplyLatency) : 'normal';
|
||||
const mannerisms = Array.isArray(r.mannerisms)
|
||||
? r.mannerisms.filter((m): m is string => typeof m === 'string').slice(0, 12)
|
||||
: [];
|
||||
const language = typeof r.language === 'string' && r.language
|
||||
? r.language.slice(0, 8) : null;
|
||||
const signature = typeof r.signature === 'string' && r.signature
|
||||
? r.signature : null;
|
||||
const active_hours = typeof r.active_hours === 'string' && r.active_hours
|
||||
? r.active_hours : '09:00-18:00';
|
||||
return {
|
||||
ok: {
|
||||
name, email, role, tone, tone_custom, mannerisms, language, signature,
|
||||
active_hours, reply_latency,
|
||||
uses_llms_heavily: r.uses_llms_heavily === true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
merged: EmailPersona[];
|
||||
added: number;
|
||||
replaced: number;
|
||||
}
|
||||
|
||||
/** Dedupe by lowercased email; uploaded entries replace existing matches. */
|
||||
export function mergePersonas(current: EmailPersona[], incoming: EmailPersona[]): MergeResult {
|
||||
const byEmail = new Map<string, EmailPersona>();
|
||||
for (const p of current) byEmail.set(p.email.toLowerCase(), p);
|
||||
let added = 0;
|
||||
let replaced = 0;
|
||||
for (const p of incoming) {
|
||||
const key = p.email.toLowerCase();
|
||||
if (byEmail.has(key)) replaced += 1;
|
||||
else added += 1;
|
||||
byEmail.set(key, p);
|
||||
}
|
||||
return { merged: Array.from(byEmail.values()), added, replaced };
|
||||
}
|
||||
25
decnet_web/src/components/PersonaGeneration/types.ts
Normal file
25
decnet_web/src/components/PersonaGeneration/types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Tone = 'formal' | 'direct' | 'casual' | 'technical' | 'custom';
|
||||
export type ReplyLatency = 'fast' | 'normal' | 'slow';
|
||||
|
||||
export interface EmailPersona {
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
tone: Tone;
|
||||
tone_custom: string | null;
|
||||
mannerisms: string[];
|
||||
language: string | null;
|
||||
signature: string | null;
|
||||
active_hours: string;
|
||||
reply_latency: ReplyLatency;
|
||||
uses_llms_heavily: boolean;
|
||||
}
|
||||
|
||||
export interface PersonasResponse {
|
||||
path?: string;
|
||||
topology_name?: string;
|
||||
language_default?: string;
|
||||
personas: EmailPersona[];
|
||||
}
|
||||
|
||||
export type FilterKey = 'all' | Tone;
|
||||
Reference in New Issue
Block a user