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,159 @@
|
||||
/**
|
||||
* Completeness Score — calculates how complete an entity's profile is.
|
||||
*
|
||||
* Pure function, no I/O. Uses the same field definitions as gap-detection.ts.
|
||||
*/
|
||||
|
||||
import type { Entity, EntityType } from '../schemas/types';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface CompletenessResult {
|
||||
/** Completeness ratio from 0.0 to 1.0 */
|
||||
score: number;
|
||||
/** Number of filled attributes */
|
||||
filledCount: number;
|
||||
/** Total number of defined attributes */
|
||||
totalCount: number;
|
||||
/** List of missing field names */
|
||||
missingFields: string[];
|
||||
}
|
||||
|
||||
// --- Field Definitions per Entity Type ---
|
||||
|
||||
interface FieldDef {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PERSON_FIELDS: FieldDef[] = [
|
||||
{ field: 'name.first', description: 'First name' },
|
||||
{ field: 'name.last', description: 'Last name' },
|
||||
{ field: 'name.display', description: 'Display name' },
|
||||
{ field: 'contact', description: 'Contact information' },
|
||||
{ field: 'summary', description: 'Professional summary' },
|
||||
{ field: 'languages', description: 'Languages spoken' },
|
||||
{ field: 'education', description: 'Education history' },
|
||||
{ field: 'skills', description: 'Skills references' },
|
||||
{ field: 'experiences', description: 'Experience references' },
|
||||
{ field: 'certifications', description: 'Certification references' },
|
||||
];
|
||||
|
||||
const EXPERIENCE_FIELDS: FieldDef[] = [
|
||||
{ field: 'title', description: 'Job title' },
|
||||
{ field: 'organization', description: 'Organization reference' },
|
||||
{ field: 'start', description: 'Start date' },
|
||||
{ field: 'description', description: 'Role description' },
|
||||
{ field: 'achievements', description: 'Achievements list' },
|
||||
{ field: 'skillsUsed', description: 'Skills used in this role' },
|
||||
];
|
||||
|
||||
const SKILL_FIELDS: FieldDef[] = [
|
||||
{ field: 'name', description: 'Skill name' },
|
||||
{ field: 'category', description: 'Skill category' },
|
||||
{ field: 'level', description: 'Proficiency level' },
|
||||
{ field: 'years', description: 'Years of experience' },
|
||||
{ field: 'context', description: 'Context of skill usage' },
|
||||
];
|
||||
|
||||
const ORGANIZATION_FIELDS: FieldDef[] = [
|
||||
{ field: 'name', description: 'Organization name' },
|
||||
{ field: 'industry', description: 'Industry sector' },
|
||||
{ field: 'parent', description: 'Parent organization' },
|
||||
{ field: 'website', description: 'Website URL' },
|
||||
];
|
||||
|
||||
const PROJECT_FIELDS: FieldDef[] = [
|
||||
{ field: 'name', description: 'Project name' },
|
||||
{ field: 'organization', description: 'Organization reference' },
|
||||
{ field: 'start', description: 'Start date' },
|
||||
{ field: 'description', description: 'Project description' },
|
||||
{ field: 'end', description: 'End date' },
|
||||
{ field: 'skillsUsed', description: 'Skills used in this project' },
|
||||
{ field: 'persons', description: 'Persons involved' },
|
||||
];
|
||||
|
||||
const CERTIFICATION_FIELDS: FieldDef[] = [
|
||||
{ field: 'name', description: 'Certification name' },
|
||||
{ field: 'issuer', description: 'Issuing organization' },
|
||||
{ field: 'date', description: 'Date obtained' },
|
||||
{ field: 'person', description: 'Person reference' },
|
||||
{ field: 'expires', description: 'Expiration date' },
|
||||
];
|
||||
|
||||
const TANDEM_FIELDS: FieldDef[] = [
|
||||
{ field: 'partners', description: 'Partner references (exactly 2)' },
|
||||
{ field: 'sharedVision', description: 'Shared professional vision' },
|
||||
{ field: 'complementarySkills', description: 'Complementary skills description' },
|
||||
{ field: 'collaborationModel', description: 'Collaboration model description' },
|
||||
{ field: 'combinedNarrative', description: 'Combined professional narrative' },
|
||||
{ field: 'jointCompetencies', description: 'Joint competencies list' },
|
||||
];
|
||||
|
||||
const FIELD_DEFS: Record<EntityType, FieldDef[]> = {
|
||||
person: PERSON_FIELDS,
|
||||
experience: EXPERIENCE_FIELDS,
|
||||
skill: SKILL_FIELDS,
|
||||
organization: ORGANIZATION_FIELDS,
|
||||
project: PROJECT_FIELDS,
|
||||
certification: CERTIFICATION_FIELDS,
|
||||
tandem: TANDEM_FIELDS,
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/**
|
||||
* Resolves a dot-notation field path on an object.
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a value counts as "filled" (not missing/empty).
|
||||
*/
|
||||
function isFilled(value: unknown): boolean {
|
||||
if (value === undefined || value === null) return false;
|
||||
if (typeof value === 'string' && value.trim() === '') return false;
|
||||
if (Array.isArray(value) && value.length === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Main Function ---
|
||||
|
||||
/**
|
||||
* Calculates the completeness score for an entity.
|
||||
* Score = filledCount / totalCount (ratio of filled to total defined attributes).
|
||||
*/
|
||||
export function calculateCompleteness(entity: Entity): CompletenessResult {
|
||||
const fieldDefs = FIELD_DEFS[entity.type];
|
||||
if (!fieldDefs || fieldDefs.length === 0) {
|
||||
return { score: 1.0, filledCount: 0, totalCount: 0, missingFields: [] };
|
||||
}
|
||||
|
||||
const obj = entity as unknown as Record<string, unknown>;
|
||||
const missingFields: string[] = [];
|
||||
let filledCount = 0;
|
||||
|
||||
for (const def of fieldDefs) {
|
||||
const value = getNestedValue(obj, def.field);
|
||||
if (isFilled(value)) {
|
||||
filledCount++;
|
||||
} else {
|
||||
missingFields.push(def.field);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCount = fieldDefs.length;
|
||||
const score = totalCount > 0 ? filledCount / totalCount : 1.0;
|
||||
|
||||
return { score, filledCount, totalCount, missingFields };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Prioritization — ranks entities for review based on completeness and staleness.
|
||||
*
|
||||
* Pure function, no I/O.
|
||||
* Priority formula: priority = (1 - completenessScore) * 0.6 + staleness * 0.4
|
||||
* where staleness = normalized days since modification (0 to 1, capped at 365 days).
|
||||
*/
|
||||
|
||||
import type { Entity, EntityType } from '../schemas/types';
|
||||
import type { CompletenessResult } from './completeness-score';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface PrioritizedEntity {
|
||||
entityId: string;
|
||||
entityType: EntityType;
|
||||
completenessScore: number;
|
||||
lastModified: string;
|
||||
/** Higher value = more urgent */
|
||||
priority: number;
|
||||
/** Human-readable explanation */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const MAX_STALENESS_DAYS = 365;
|
||||
const COMPLETENESS_WEIGHT = 0.6;
|
||||
const STALENESS_WEIGHT = 0.4;
|
||||
|
||||
// --- Main Function ---
|
||||
|
||||
/**
|
||||
* Prioritizes entities for review.
|
||||
* Lower completeness + older modification date = higher priority.
|
||||
* Returns sorted list (highest priority first).
|
||||
*/
|
||||
export function prioritizeForReview(
|
||||
entities: Array<{ entity: Entity; completeness: CompletenessResult }>,
|
||||
now: Date = new Date()
|
||||
): PrioritizedEntity[] {
|
||||
const results: PrioritizedEntity[] = entities.map(({ entity, completeness }) => {
|
||||
const lastModified = entity.modified;
|
||||
const daysSinceModified = getDaysSince(lastModified, now);
|
||||
const staleness = Math.min(daysSinceModified / MAX_STALENESS_DAYS, 1.0);
|
||||
|
||||
const priority =
|
||||
(1 - completeness.score) * COMPLETENESS_WEIGHT +
|
||||
staleness * STALENESS_WEIGHT;
|
||||
|
||||
const reason = buildReason(completeness.score, daysSinceModified);
|
||||
|
||||
return {
|
||||
entityId: entity.id,
|
||||
entityType: entity.type,
|
||||
completenessScore: completeness.score,
|
||||
lastModified,
|
||||
priority,
|
||||
reason,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by priority descending (highest priority first)
|
||||
results.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function getDaysSince(isoDate: string, now: Date): number {
|
||||
const modified = new Date(isoDate);
|
||||
const diffMs = now.getTime() - modified.getTime();
|
||||
return Math.max(0, Math.floor(diffMs / (1000 * 60 * 60 * 24)));
|
||||
}
|
||||
|
||||
function buildReason(completenessScore: number, daysSinceModified: number): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (completenessScore < 0.5) {
|
||||
parts.push(`low completeness (${Math.round(completenessScore * 100)}%)`);
|
||||
} else if (completenessScore < 0.8) {
|
||||
parts.push(`moderate completeness (${Math.round(completenessScore * 100)}%)`);
|
||||
}
|
||||
|
||||
if (daysSinceModified > 180) {
|
||||
parts.push(`not updated in ${daysSinceModified} days`);
|
||||
} else if (daysSinceModified > 90) {
|
||||
parts.push(`last updated ${daysSinceModified} days ago`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Routine review';
|
||||
}
|
||||
|
||||
return parts.join('; ');
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
import { calculateCompleteness } from './completeness-score';
|
||||
import { prioritizeForReview } from './prioritization';
|
||||
import type { Person, Entity } from '../schemas/types';
|
||||
|
||||
// --- Arbitraries ---
|
||||
|
||||
/** Generates a valid kebab-case ID */
|
||||
const kebabCaseId = fc
|
||||
.array(
|
||||
fc.stringMatching(/^[a-z0-9]{1,8}$/),
|
||||
{ minLength: 1, maxLength: 4 }
|
||||
)
|
||||
.map(parts => parts.join('-'));
|
||||
|
||||
/** Generates a valid ISO date string */
|
||||
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')}`);
|
||||
|
||||
/** Generates a Person entity with varying completeness */
|
||||
const personWithVaryingCompleteness = fc.record({
|
||||
id: kebabCaseId,
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
hasContact: fc.boolean(),
|
||||
hasSummary: fc.boolean(),
|
||||
hasLanguages: fc.boolean(),
|
||||
hasEducation: fc.boolean(),
|
||||
hasSkills: fc.boolean(),
|
||||
hasExperiences: fc.boolean(),
|
||||
hasCertifications: fc.boolean(),
|
||||
}).map(fields => {
|
||||
const person: Record<string, unknown> = {
|
||||
id: fields.id,
|
||||
type: 'person',
|
||||
created: fields.created,
|
||||
modified: fields.modified,
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
};
|
||||
|
||||
if (fields.hasContact) person.contact = { email: 'test@example.com' };
|
||||
if (fields.hasSummary) person.summary = 'A professional summary.';
|
||||
if (fields.hasLanguages) person.languages = [{ language: 'German', level: 'native' }];
|
||||
if (fields.hasEducation) person.education = [{ institution: 'TU', degree: 'MSc', field: 'CS', start: '2000-09', end: '2004-06' }];
|
||||
if (fields.hasSkills) person.skills = ['python', 'typescript'];
|
||||
if (fields.hasExperiences) person.experiences = ['exp-1'];
|
||||
if (fields.hasCertifications) person.certifications = ['cert-1'];
|
||||
|
||||
return person as unknown as Person;
|
||||
});
|
||||
|
||||
/** Generates a fully complete Person entity */
|
||||
const fullPerson = fc.record({
|
||||
id: kebabCaseId,
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
}).map(fields => ({
|
||||
id: fields.id,
|
||||
type: 'person' as const,
|
||||
created: fields.created,
|
||||
modified: fields.modified,
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
contact: { email: 'test@example.com', linkedin: 'linkedin.com/in/test' },
|
||||
summary: 'Professional summary text.',
|
||||
languages: [{ language: 'German', level: 'native' }],
|
||||
education: [{ institution: 'TU Berlin', degree: 'MSc', field: 'CS', start: '2000-09', end: '2004-06' }],
|
||||
skills: ['python', 'typescript'],
|
||||
experiences: ['exp-1', 'exp-2'],
|
||||
certifications: ['cert-1'],
|
||||
}));
|
||||
|
||||
/** Generates a minimal Person entity (only required header + name) */
|
||||
const emptyPerson = fc.record({
|
||||
id: kebabCaseId,
|
||||
created: isoDate,
|
||||
modified: isoDate,
|
||||
}).map(fields => ({
|
||||
id: fields.id,
|
||||
type: 'person' as const,
|
||||
created: fields.created,
|
||||
modified: fields.modified,
|
||||
name: { first: 'Andre', last: 'Knie', display: 'Andre Knie' },
|
||||
}));
|
||||
|
||||
// --- Property 13: Completeness score calculation ---
|
||||
|
||||
describe('Property 13: Completeness score calculation', () => {
|
||||
it('completeness score equals ratio of filled attributes to total defined attributes', () => {
|
||||
fc.assert(
|
||||
fc.property(personWithVaryingCompleteness, (person) => {
|
||||
const result = calculateCompleteness(person);
|
||||
|
||||
// Score must equal filledCount / totalCount
|
||||
const expectedScore = result.totalCount > 0
|
||||
? result.filledCount / result.totalCount
|
||||
: 1.0;
|
||||
expect(result.score).toBeCloseTo(expectedScore, 10);
|
||||
|
||||
// filledCount + missingFields.length must equal totalCount
|
||||
expect(result.filledCount + result.missingFields.length).toBe(result.totalCount);
|
||||
|
||||
// Score must be between 0 and 1
|
||||
expect(result.score).toBeGreaterThanOrEqual(0.0);
|
||||
expect(result.score).toBeLessThanOrEqual(1.0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a profile with all attributes filled scores 1.0', () => {
|
||||
fc.assert(
|
||||
fc.property(fullPerson, (person) => {
|
||||
const result = calculateCompleteness(person as unknown as Entity);
|
||||
expect(result.score).toBe(1.0);
|
||||
expect(result.missingFields).toHaveLength(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it('a profile with only required header fields scores close to 0.0', () => {
|
||||
fc.assert(
|
||||
fc.property(emptyPerson, (person) => {
|
||||
const result = calculateCompleteness(person as unknown as Entity);
|
||||
// With only name fields filled (3 out of 10 total person fields), score should be low
|
||||
expect(result.score).toBeLessThan(0.5);
|
||||
expect(result.missingFields.length).toBeGreaterThan(0);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 7.3
|
||||
*/
|
||||
});
|
||||
|
||||
// --- Property 14: Review prioritization ordering ---
|
||||
|
||||
describe('Property 14: Review prioritization ordering', () => {
|
||||
it('entities with lower completeness and older modification dates rank higher than entities with higher completeness and recent modifications', () => {
|
||||
// Generate a pair: one entity with low completeness + old date, another with high completeness + recent date
|
||||
const entityPair = fc.record({
|
||||
id1: kebabCaseId,
|
||||
id2: kebabCaseId,
|
||||
// Low completeness entity: modified long ago
|
||||
daysAgoOld: fc.integer({ min: 100, max: 365 }),
|
||||
// High completeness entity: modified recently
|
||||
daysAgoRecent: fc.integer({ min: 0, max: 30 }),
|
||||
}).filter(f => f.id1 !== f.id2);
|
||||
|
||||
fc.assert(
|
||||
fc.property(entityPair, (pair) => {
|
||||
const now = new Date('2025-06-01');
|
||||
|
||||
// Entity A: low completeness, old modification
|
||||
const oldDate = new Date(now.getTime() - pair.daysAgoOld * 24 * 60 * 60 * 1000);
|
||||
const oldModified = oldDate.toISOString().slice(0, 10);
|
||||
|
||||
// Entity B: high completeness, recent modification
|
||||
const recentDate = new Date(now.getTime() - pair.daysAgoRecent * 24 * 60 * 60 * 1000);
|
||||
const recentModified = recentDate.toISOString().slice(0, 10);
|
||||
|
||||
// Entity A: minimal person (low completeness)
|
||||
const entityA: Entity = {
|
||||
id: pair.id1,
|
||||
type: 'person',
|
||||
created: '2020-01-01',
|
||||
modified: oldModified,
|
||||
name: { first: 'Low', last: 'Complete', display: 'Low Complete' },
|
||||
} as unknown as Entity;
|
||||
|
||||
// Entity B: full person (high completeness)
|
||||
const entityB: Entity = {
|
||||
id: pair.id2,
|
||||
type: 'person',
|
||||
created: '2020-01-01',
|
||||
modified: recentModified,
|
||||
name: { first: 'High', last: 'Complete', display: 'High Complete' },
|
||||
contact: { email: 'test@example.com' },
|
||||
summary: 'Full summary.',
|
||||
languages: [{ language: 'English', level: 'native' }],
|
||||
education: [{ institution: 'MIT', degree: 'PhD', field: 'CS', start: '2010-09', end: '2014-06' }],
|
||||
skills: ['python'],
|
||||
experiences: ['exp-1'],
|
||||
certifications: ['cert-1'],
|
||||
} as unknown as Entity;
|
||||
|
||||
const completenessA = calculateCompleteness(entityA);
|
||||
const completenessB = calculateCompleteness(entityB);
|
||||
|
||||
const results = prioritizeForReview(
|
||||
[
|
||||
{ entity: entityA, completeness: completenessA },
|
||||
{ entity: entityB, completeness: completenessB },
|
||||
],
|
||||
now
|
||||
);
|
||||
|
||||
// Entity A (lower completeness + older) should rank higher (appear first)
|
||||
expect(results[0].entityId).toBe(pair.id1);
|
||||
expect(results[1].entityId).toBe(pair.id2);
|
||||
|
||||
// Priority values should reflect the ordering
|
||||
expect(results[0].priority).toBeGreaterThan(results[1].priority);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates: Requirements 7.2
|
||||
*/
|
||||
});
|
||||
Reference in New Issue
Block a user