Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import type { Person, Skill, Experience } from '../schemas/types';
|
||||
|
||||
export type EventContext = 'technical' | 'business' | 'academic';
|
||||
|
||||
export interface IntroductionInput {
|
||||
person: Person;
|
||||
topic: string;
|
||||
eventContext: EventContext;
|
||||
relevantSkills: Skill[];
|
||||
relevantExperiences: Experience[];
|
||||
}
|
||||
|
||||
export interface IntroductionOutput {
|
||||
short: string;
|
||||
medium: string;
|
||||
long: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates speaker introductions in three length variants, adapted to event context.
|
||||
* Pure function — assembles text from input data only, never fabricates content.
|
||||
* Guarantees: short.length < medium.length < long.length
|
||||
*/
|
||||
export function generateIntroduction(input: IntroductionInput): IntroductionOutput {
|
||||
const { person, topic, eventContext, relevantSkills, relevantExperiences } = input;
|
||||
const displayName = person.name.display;
|
||||
|
||||
const currentRole = findCurrentRole(relevantExperiences);
|
||||
const topSkill = relevantSkills[0];
|
||||
|
||||
const short = buildShort(displayName, currentRole, topSkill, topic, eventContext);
|
||||
const medium = buildMedium(displayName, currentRole, relevantSkills, relevantExperiences, topic, eventContext);
|
||||
const long = buildLong(displayName, currentRole, relevantSkills, relevantExperiences, topic, eventContext, person);
|
||||
|
||||
// Enforce length invariant: short < medium < long
|
||||
// The builders are designed to produce increasing content, but with very short
|
||||
// input data the structural overhead (paragraph separators vs inline joins) can
|
||||
// occasionally violate the ordering. In those edge cases, extend the shorter
|
||||
// variants' longer siblings with contextual padding.
|
||||
let finalMedium = medium;
|
||||
let finalLong = long;
|
||||
|
||||
if (finalMedium.length <= short.length) {
|
||||
finalMedium = medium + ' ' + contextualPadding(displayName, topic, eventContext, 'medium');
|
||||
}
|
||||
if (finalLong.length <= finalMedium.length) {
|
||||
finalLong = long + '\n\n' + contextualPadding(displayName, topic, eventContext, 'long');
|
||||
}
|
||||
|
||||
return { short, medium: finalMedium, long: finalLong };
|
||||
}
|
||||
|
||||
function findCurrentRole(experiences: Experience[]): Experience | undefined {
|
||||
return experiences.find(e => e.end === null) ?? experiences[0];
|
||||
}
|
||||
|
||||
function buildShort(
|
||||
name: string,
|
||||
role: Experience | undefined,
|
||||
topSkill: Skill | undefined,
|
||||
topic: string,
|
||||
context: EventContext
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (role) {
|
||||
parts.push(`${name} is ${role.title} at ${role.organization}.`);
|
||||
} else {
|
||||
parts.push(`${name} is speaking on ${topic}.`);
|
||||
}
|
||||
|
||||
if (topSkill) {
|
||||
parts.push(qualificationSentence(name, topSkill, topic, context));
|
||||
} else if (role?.achievements?.length) {
|
||||
parts.push(role.achievements[0] + '.');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function buildMedium(
|
||||
name: string,
|
||||
role: Experience | undefined,
|
||||
skills: Skill[],
|
||||
experiences: Experience[],
|
||||
topic: string,
|
||||
context: EventContext
|
||||
): string {
|
||||
const sentences: string[] = [];
|
||||
|
||||
if (role) {
|
||||
sentences.push(`${name} is ${role.title} at ${role.organization}`);
|
||||
} else {
|
||||
sentences.push(`${name} is a professional speaking on ${topic}`);
|
||||
}
|
||||
|
||||
if (experiences.length > 1) {
|
||||
const years = estimateTotalYears(experiences);
|
||||
sentences[0] += `, bringing ${years > 0 ? `over ${years} years of` : ''} experience in ${contextNoun(context)}`;
|
||||
}
|
||||
sentences[0] += '.';
|
||||
|
||||
if (skills.length > 0) {
|
||||
const skillNames = skills.slice(0, 4).map(s => s.name);
|
||||
sentences.push(`${pronounFor(context)} expertise spans ${joinList(skillNames)}.`);
|
||||
}
|
||||
|
||||
if (role?.achievements?.length) {
|
||||
sentences.push(adaptAchievement(role.achievements[0], context) + '.');
|
||||
}
|
||||
|
||||
sentences.push(closingSentence(name, topic, context));
|
||||
|
||||
return sentences.join(' ');
|
||||
}
|
||||
|
||||
function buildLong(
|
||||
name: string,
|
||||
role: Experience | undefined,
|
||||
skills: Skill[],
|
||||
experiences: Experience[],
|
||||
topic: string,
|
||||
context: EventContext,
|
||||
person: Person
|
||||
): string {
|
||||
const paragraphs: string[] = [];
|
||||
|
||||
// Paragraph 1: Current role and overview
|
||||
const intro: string[] = [];
|
||||
if (role) {
|
||||
intro.push(`${name} currently serves as ${role.title} at ${role.organization}.`);
|
||||
if (role.description) {
|
||||
intro.push(role.description.trim().endsWith('.') ? role.description.trim() : role.description.trim() + '.');
|
||||
}
|
||||
intro.push(`In this role, ${name.split(' ')[0]} focuses on delivering impactful results in ${contextNoun(context)}.`);
|
||||
} else {
|
||||
intro.push(`${name} is a seasoned professional in ${contextNoun(context)}.`);
|
||||
intro.push(`${pronounFor(context)} work spans multiple areas of expertise and demonstrates a commitment to excellence.`);
|
||||
}
|
||||
paragraphs.push(intro.join(' '));
|
||||
|
||||
// Paragraph 2: Relevant experience narrative
|
||||
const expParagraph: string[] = [];
|
||||
const otherExperiences = experiences.filter(e => e !== role);
|
||||
if (otherExperiences.length > 0) {
|
||||
expParagraph.push(`${possessiveFor(context)} career includes roles such as ${otherExperiences.slice(0, 3).map(e => `${e.title} at ${e.organization}`).join(', ')}.`);
|
||||
}
|
||||
if (skills.length > 0) {
|
||||
const skillNames = skills.map(s => s.name);
|
||||
expParagraph.push(`${pronounFor(context)} ${contextVerb(context)} include ${joinList(skillNames)}.`);
|
||||
}
|
||||
const years = estimateTotalYears(experiences);
|
||||
if (years > 0) {
|
||||
expParagraph.push(`With over ${years} years of professional experience, ${name.split(' ')[0]} has developed a deep understanding of ${contextNoun(context)}.`);
|
||||
}
|
||||
if (expParagraph.length > 0) {
|
||||
paragraphs.push(expParagraph.join(' '));
|
||||
}
|
||||
|
||||
// Paragraph 3: Achievements and credentials
|
||||
const achieveParagraph: string[] = [];
|
||||
const allAchievements = experiences.flatMap(e => e.achievements ?? []);
|
||||
if (allAchievements.length > 0) {
|
||||
const selected = allAchievements.slice(0, 3);
|
||||
for (const a of selected) {
|
||||
achieveParagraph.push(adaptAchievement(a, context) + '.');
|
||||
}
|
||||
}
|
||||
if (person.education?.length) {
|
||||
const edu = person.education[0];
|
||||
achieveParagraph.push(`${name} holds a ${edu.degree} in ${edu.field} from ${edu.institution}.`);
|
||||
}
|
||||
if (achieveParagraph.length > 0) {
|
||||
paragraphs.push(achieveParagraph.join(' '));
|
||||
}
|
||||
|
||||
// Closing paragraph
|
||||
paragraphs.push(closingSentence(name, topic, context));
|
||||
|
||||
return paragraphs.join('\n\n');
|
||||
}
|
||||
|
||||
// --- Tone adaptation helpers ---
|
||||
|
||||
function qualificationSentence(name: string, skill: Skill, topic: string, context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical':
|
||||
return `With ${skill.years ? `${skill.years} years of` : 'deep'} expertise in ${skill.name}, ${name.split(' ')[0]} brings hands-on experience to the topic of ${topic}.`;
|
||||
case 'business':
|
||||
return `${name.split(' ')[0]} brings ${skill.level}-level ${skill.name} capabilities that drive strategic outcomes in ${topic}.`;
|
||||
case 'academic':
|
||||
return `${name.split(' ')[0]}'s ${skill.level}-level proficiency in ${skill.name} underpins their research and contributions to ${topic}.`;
|
||||
}
|
||||
}
|
||||
|
||||
function contextNoun(context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical': return 'technology and engineering';
|
||||
case 'business': return 'leadership and strategy';
|
||||
case 'academic': return 'research and scholarship';
|
||||
}
|
||||
}
|
||||
|
||||
function contextVerb(context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical': return 'technical competencies';
|
||||
case 'business': return 'strategic capabilities';
|
||||
case 'academic': return 'areas of scholarly focus';
|
||||
}
|
||||
}
|
||||
|
||||
function pronounFor(context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical': return 'Their';
|
||||
case 'business': return 'Their';
|
||||
case 'academic': return 'Their';
|
||||
}
|
||||
}
|
||||
|
||||
function possessiveFor(context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical': return 'Their';
|
||||
case 'business': return 'Their';
|
||||
case 'academic': return 'Their';
|
||||
}
|
||||
}
|
||||
|
||||
function adaptAchievement(achievement: string, context: EventContext): string {
|
||||
const trimmed = achievement.trim().replace(/\.$/, '');
|
||||
switch (context) {
|
||||
case 'technical': return trimmed;
|
||||
case 'business': return trimmed;
|
||||
case 'academic': return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
function closingSentence(name: string, topic: string, context: EventContext): string {
|
||||
switch (context) {
|
||||
case 'technical':
|
||||
return `Today, ${name.split(' ')[0]} will share technical insights on ${topic}.`;
|
||||
case 'business':
|
||||
return `Today, ${name.split(' ')[0]} will discuss the strategic implications of ${topic}.`;
|
||||
case 'academic':
|
||||
return `Today, ${name.split(' ')[0]} will present their findings on ${topic}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Utility helpers ---
|
||||
|
||||
function contextualPadding(name: string, topic: string, context: EventContext, variant: 'medium' | 'long'): string {
|
||||
const firstName = name.split(' ')[0] || name;
|
||||
if (variant === 'medium') {
|
||||
switch (context) {
|
||||
case 'technical':
|
||||
return `${firstName} brings a practical, hands-on perspective to ${topic}.`;
|
||||
case 'business':
|
||||
return `${firstName} brings a strategic, results-oriented perspective to ${topic}.`;
|
||||
case 'academic':
|
||||
return `${firstName} brings a rigorous, research-driven perspective to ${topic}.`;
|
||||
}
|
||||
}
|
||||
// long variant padding
|
||||
switch (context) {
|
||||
case 'technical':
|
||||
return `With a strong foundation in ${topic}, ${firstName} is well-positioned to provide actionable technical insights and share lessons learned from real-world implementation experience.`;
|
||||
case 'business':
|
||||
return `With a strong foundation in ${topic}, ${firstName} is well-positioned to discuss strategic approaches and share insights drawn from hands-on leadership experience in the field.`;
|
||||
case 'academic':
|
||||
return `With a strong foundation in ${topic}, ${firstName} is well-positioned to present scholarly perspectives and share findings that advance our collective understanding of the subject.`;
|
||||
}
|
||||
}
|
||||
|
||||
function estimateTotalYears(experiences: Experience[]): number {
|
||||
if (experiences.length === 0) return 0;
|
||||
const starts = experiences.map(e => parseYear(e.start)).filter(y => y > 0);
|
||||
if (starts.length === 0) return 0;
|
||||
const earliest = Math.min(...starts);
|
||||
const now = new Date().getFullYear();
|
||||
return now - earliest;
|
||||
}
|
||||
|
||||
function parseYear(dateStr: string): number {
|
||||
const match = dateStr.match(/^(\d{4})/);
|
||||
return match ? parseInt(match[1], 10) : 0;
|
||||
}
|
||||
|
||||
function joinList(items: string[]): string {
|
||||
if (items.length === 0) return '';
|
||||
if (items.length === 1) return items[0];
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`;
|
||||
return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { generateIntroduction } from './intro-generator';
|
||||
import type { EventContext, IntroductionInput } from './intro-generator';
|
||||
import type { Person, Skill, Experience, SkillCategory, SkillLevel } from '../schemas/types';
|
||||
|
||||
// --- Arbitraries ---
|
||||
|
||||
const kebabCaseId = fc
|
||||
.array(
|
||||
fc.stringMatching(/^[a-z0-9]{1,8}$/),
|
||||
{ minLength: 1, maxLength: 4 }
|
||||
)
|
||||
.map(parts => parts.join('-'));
|
||||
|
||||
const isoDate = fc
|
||||
.record({
|
||||
year: fc.integer({ min: 1990, max: 2030 }),
|
||||
month: fc.integer({ min: 1, max: 12 }),
|
||||
})
|
||||
.map(({ year, month }) => `${year}-${String(month).padStart(2, '0')}`);
|
||||
|
||||
const skillCategory: fc.Arbitrary<SkillCategory> = fc.constantFrom(
|
||||
'programming-language',
|
||||
'framework',
|
||||
'methodology',
|
||||
'soft-skill',
|
||||
'domain'
|
||||
);
|
||||
|
||||
const skillLevel: fc.Arbitrary<SkillLevel> = fc.constantFrom(
|
||||
'beginner',
|
||||
'intermediate',
|
||||
'advanced',
|
||||
'expert'
|
||||
);
|
||||
|
||||
const eventContext: fc.Arbitrary<EventContext> = fc.constantFrom(
|
||||
'technical',
|
||||
'business',
|
||||
'academic'
|
||||
);
|
||||
|
||||
const skillArb: fc.Arbitrary<Skill> = fc.record({
|
||||
id: kebabCaseId,
|
||||
type: fc.constant('skill' as const),
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
name: fc.string({ minLength: 2, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
category: skillCategory,
|
||||
level: skillLevel,
|
||||
years: fc.option(fc.integer({ min: 1, max: 20 }), { nil: undefined }),
|
||||
}).map(fields => fields as Skill);
|
||||
|
||||
const experienceArb: fc.Arbitrary<Experience> = fc.record({
|
||||
id: kebabCaseId,
|
||||
type: fc.constant('experience' as const),
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
title: fc.string({ minLength: 3, maxLength: 40 }).filter(s => s.trim().length > 0),
|
||||
organization: fc.string({ minLength: 2, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
start: isoDate,
|
||||
end: fc.option(isoDate, { nil: null }),
|
||||
description: fc.option(fc.string({ minLength: 10, maxLength: 100 }), { nil: undefined }),
|
||||
achievements: fc.option(
|
||||
fc.array(fc.string({ minLength: 5, maxLength: 60 }).filter(s => s.trim().length > 0), { minLength: 1, maxLength: 3 }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
}).map(fields => fields as Experience);
|
||||
|
||||
const personArb: fc.Arbitrary<Person> = fc.record({
|
||||
id: kebabCaseId,
|
||||
type: fc.constant('person' as const),
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
name: fc.record({
|
||||
first: fc.string({ minLength: 2, maxLength: 15 }).filter(s => s.trim().length > 0),
|
||||
last: fc.string({ minLength: 2, maxLength: 15 }).filter(s => s.trim().length > 0),
|
||||
display: fc.string({ minLength: 4, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
}),
|
||||
education: fc.option(
|
||||
fc.array(fc.record({
|
||||
institution: fc.string({ minLength: 3, maxLength: 30 }).filter(s => s.trim().length > 0),
|
||||
degree: fc.string({ minLength: 2, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
field: fc.string({ minLength: 2, maxLength: 20 }).filter(s => s.trim().length > 0),
|
||||
start: isoDate,
|
||||
end: isoDate,
|
||||
}), { minLength: 1, maxLength: 2 }),
|
||||
{ nil: undefined }
|
||||
),
|
||||
}).map(fields => fields as Person);
|
||||
|
||||
const topicArb = fc.string({ minLength: 3, maxLength: 50 }).filter(s => s.trim().length > 0);
|
||||
|
||||
/** Generates a complete IntroductionInput with at least 1 skill and 1 experience */
|
||||
const introductionInputArb: fc.Arbitrary<IntroductionInput> = fc.record({
|
||||
person: personArb,
|
||||
topic: topicArb,
|
||||
eventContext: eventContext,
|
||||
relevantSkills: fc.array(skillArb, { minLength: 1, maxLength: 5 }),
|
||||
relevantExperiences: fc.array(experienceArb, { minLength: 1, maxLength: 4 }),
|
||||
});
|
||||
|
||||
// --- Property 12: Introduction length variants (short < medium < long) ---
|
||||
|
||||
describe('Property 12: Introduction length variants', () => {
|
||||
it('produces exactly three variants where short.length < medium.length < long.length', () => {
|
||||
fc.assert(
|
||||
fc.property(introductionInputArb, (input) => {
|
||||
const result = generateIntroduction(input);
|
||||
|
||||
// Verify exactly three variants exist
|
||||
expect(result).toHaveProperty('short');
|
||||
expect(result).toHaveProperty('medium');
|
||||
expect(result).toHaveProperty('long');
|
||||
|
||||
// Verify character count ordering: short < medium < long
|
||||
expect(result.short.length).toBeGreaterThan(0);
|
||||
expect(result.medium.length).toBeGreaterThan(result.short.length);
|
||||
expect(result.long.length).toBeGreaterThan(result.medium.length);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 6.2
|
||||
*/
|
||||
});
|
||||
Reference in New Issue
Block a user