Initial commit

This commit is contained in:
2026-08-07 23:53:15 +05:30
commit 90f14a73b2
222 changed files with 35000 additions and 0 deletions

809
src/api/aiEngine.js Normal file
View File

@@ -0,0 +1,809 @@
/**
* Local AI engine for the KROW demo.
*
* The app funnels every AI workflow through a single call —
* `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })` — so
* reimplementing that one function keeps `lib/krowAi.js` and
* `lib/provingGround.js` byte-for-byte identical to the reference.
*
* Each workflow is recognized by a stable phrase its prompt opens with, and the
* structured fields the prompt already carries ("Years Experience: 6",
* "Required Certifications: …", the talent-pool JSON block) are read back out
* and scored deterministically. Same input, same output — no network, no key,
* and results that actually respond to the data instead of being canned.
*/
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
/** Simulated model latency so streaming/pending UI behaves as designed. */
const think = (ms = 900) => new Promise((resolve) => setTimeout(resolve, ms));
/* ── Prompt field readers ──────────────────────────────────────────────── */
/** Reads a `Label: value` line out of a prompt. */
function field(prompt, label) {
const match = prompt.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'mi'));
return match ? match[1].trim() : '';
}
function numField(prompt, label) {
const raw = field(prompt, label);
const match = raw.match(/-?\d+(\.\d+)?/);
return match ? Number(match[0]) : 0;
}
/** Reads a comma-separated line, treating the app's "None" sentinel as empty. */
function listField(prompt, label) {
const raw = field(prompt, label);
if (!raw || /^(none|not specified|not provided)$/i.test(raw)) return [];
return raw.split(',').map((s) => s.trim()).filter(Boolean);
}
/** Pulls the first triple-quoted block (transcripts, free-text resumes). */
function quotedBlock(prompt) {
const match = prompt.match(/"""\s*([\s\S]*?)\s*"""/);
return match ? match[1].trim() : '';
}
const ENGLISH_RANK = { basic: 1, conversational: 2, fluent: 3, native: 4 };
const isSpanish = (prompt) => /Spanish \(español\)/.test(prompt) && !/default to English/.test(prompt);
/* ── 1. Candidate screening ────────────────────────────────────────────── */
function screenCandidate(prompt) {
// Weights are stated inline: "experience 25%, english 20%, …"
const weightOf = (name) => {
const match = prompt.match(new RegExp(`${name} (\\d+)%`));
return match ? Number(match[1]) : 20;
};
const weights = {
experience: weightOf('experience'),
english: weightOf('english'),
reliability: weightOf('reliability'),
certifications: weightOf('certifications'),
availability: weightOf('availability'),
};
const minYears = numField(prompt, 'Min Experience');
const requiredEnglish = ENGLISH_RANK[field(prompt, 'English Required').toLowerCase()] || 1;
const requiredCertNames = listField(prompt, 'Required Certifications');
const requiredCerts = requiredCertNames.map((c) => c.toLowerCase());
const customRequirements = field(prompt, 'Custom Requirements');
const name = field(prompt, 'Name');
const years = numField(prompt, 'Years Experience');
const englishLevel = ENGLISH_RANK[field(prompt, 'English Level').toLowerCase()] || 1;
const certs = listField(prompt, 'Certifications');
const skills = listField(prompt, 'Skills');
const availability = listField(prompt, 'Availability');
const summary = field(prompt, 'Professional Summary');
const hasSummary = Boolean(summary) && !/^not provided$/i.test(summary);
// Experience: meeting the minimum is a pass, exceeding it earns headroom.
const experience = minYears > 0
? clamp(years >= minYears ? 78 + Math.min(21, (years - minYears) * 4) : (years / minYears) * 70)
: clamp(45 + years * 8);
// English: at or above the requirement scores well, each level short costs.
const english = clamp(englishLevel >= requiredEnglish
? 84 + (englishLevel - requiredEnglish) * 5
: 84 - (requiredEnglish - englishLevel) * 24);
// Certifications: fraction of required credentials actually held.
const held = requiredCerts.filter((req) =>
certs.some((c) => c.toLowerCase().includes(req) || req.includes(c.toLowerCase())));
const certifications = requiredCerts.length
? clamp((held.length / requiredCerts.length) * 92 + (certs.length > requiredCerts.length ? 8 : 0))
: clamp(certs.length ? 88 : 55);
const availabilityScore = clamp(availability.length ? 52 + availability.length * 14 : 40);
// Reliability has no direct field — infer it from tenure, credentials and
// whether the candidate bothered to describe their own work.
const reliability = clamp(
45 + Math.min(30, years * 4) + (certifications > 70 ? 14 : 0) + (hasSummary ? 8 : 0)
);
const weightTotal = Object.values(weights).reduce((a, b) => a + b, 0) || 100;
const overall = clamp(
(experience * weights.experience +
english * weights.english +
reliability * weights.reliability +
certifications * weights.certifications +
availabilityScore * weights.availability) / weightTotal
);
const skillDepth = clamp(skills.length ? 55 + skills.length * 9 : 30);
const seniority = clamp(years * 8);
const label = overall >= 85 ? 'Excellent Match'
: overall >= 70 ? 'Good Match'
: overall >= 50 ? 'Possible Fit'
: 'Not a Fit';
const recommendation = overall >= 85 ? 'Shortlisted'
: overall >= 65 ? 'Interview'
: overall >= 50 ? 'Maybe'
: 'Reject';
const strengths = [];
if (years >= Math.max(1, minYears)) strengths.push(`${years} year${years === 1 ? '' : 's'} of relevant experience`);
if (held.length) strengths.push(`${held.length === requiredCerts.length ? 'All' : 'Some'} required certifications on file`);
if (englishLevel >= requiredEnglish) strengths.push(`${field(prompt, 'English Level') || 'Adequate'} English meets the requirement`);
if (availability.length >= 2) strengths.push(`Broad availability (${availability.join(', ')})`);
if (skills.length >= 3) strengths.push(`Relevant skills: ${skills.slice(0, 3).join(', ')}`);
if (!strengths.length) strengths.push('Available and actively seeking work');
const gaps = [];
if (minYears > 0 && years < minYears) gaps.push(`${minYears - years} year${minYears - years === 1 ? '' : 's'} short of the experience minimum`);
// Report the credential using the posting's own capitalization.
const missing = requiredCertNames.filter((c) => !held.includes(c.toLowerCase()));
if (missing.length) gaps.push(`Missing certification${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`);
if (englishLevel < requiredEnglish) gaps.push('English level below the posting requirement');
if (availability.length <= 1) gaps.push('Narrow availability window');
if (!skills.length) gaps.push('No skills listed on the application');
if (!hasSummary) gaps.push('No professional summary provided');
if (!gaps.length) gaps.push('No material gaps against this posting');
const verdictSentence = overall >= 85
? `Strong fit for this posting.`
: overall >= 70
? `Meets the core requirements for this posting.`
: overall >= 50
? `Partial fit — worth a conversation, with caveats.`
: `Below the requirement bar for this posting.`;
return {
overall_score: overall,
match_label: label,
summary: `${verdictSentence} ${name || 'The candidate'} brings ${years} year${years === 1 ? '' : 's'} of experience with ${certs.length ? certs.join(' and ') : 'no listed certifications'}. ${gaps[0]}.`,
strengths: strengths.slice(0, 4),
gaps: gaps.slice(0, 3),
recommendation,
score_breakdown: {
experience,
english,
reliability,
certifications,
availability: availabilityScore,
personality: clamp(58 + skillDepth * 0.25 + (hasSummary ? 10 : 0)),
culture_fit: clamp(overall * 0.9 + 6),
communication_style: clamp(english * 0.85 + (hasSummary ? 12 : 0)),
attendance_expectations: clamp(reliability * 0.95 + availabilityScore * 0.1),
physical_requirements: clamp(70 + Math.min(20, years * 3)),
leadership_expectations: clamp(seniority * 0.8 + (/lead|captain|chef|supervisor|manager/i.test(summary) ? 22 : 0)),
job_related_answers: clamp(overall * 0.92 + skillDepth * 0.08),
verified_skills: skillDepth,
scenario_judgment: clamp(overall * 0.88 + (hasSummary ? 8 : 0)),
employer_requirements: clamp(customRequirements && !/^none$/i.test(customRequirements)
? overall * 0.85
: overall * 0.95),
},
};
}
/* ── 2. Job description generator ──────────────────────────────────────── */
const RESPONSIBILITY_LIBRARY = {
Bartender: [
'Set up and break down a full service bar',
'Pour to spec at event pace without sacrificing presentation',
'Verify guest age and refuse service responsibly',
'Track consumption and flag low stock before service',
'Keep the bar clean and compliant throughout the shift',
'Reconcile counts and close out the bar after service',
],
Server: [
'Execute synchronized coursed service for seated events',
'Polish and set glassware, china, and flatware',
'Describe menus and handle allergen questions accurately',
'Clear and reset tables discreetly between courses',
'Support captains through arrivals, toasts, and departures',
'Complete side work and floor close-out',
],
Security: [
'Staff entry points and verify credentials',
'Conduct bag checks per venue policy',
'De-escalate guest conflicts calmly and early',
'Document incidents accurately and promptly',
'Coordinate with venue staff and local authorities',
'Patrol assigned zones throughout the event',
],
Chef: [
'Design and cost menus to target margins',
'Lead and schedule the kitchen brigade',
'Own food safety compliance across every kitchen',
'Manage vendor relationships and food cost',
'Execute tastings and client consultations',
'Hold plate standards at volume',
],
Picker: [
'Pick and stage orders against pick lists',
'Scan and verify SKUs for accuracy',
'Stage outbound pallets for dispatch',
'Report damaged inventory immediately',
'Keep aisles and staging lanes clear',
'Meet daily accuracy and throughput targets',
],
};
const DEFAULT_RESPONSIBILITIES = [
'Deliver consistent, guest-ready service every shift',
'Set up and break down your station',
'Communicate clearly with leads and teammates',
'Follow safety and compliance standards',
'Adapt to changing event timing without losing composure',
'Complete close-out duties before leaving',
];
function generateJobDescription(prompt) {
const title = field(prompt, 'Role Title');
const category = field(prompt, 'Role Category');
const minYears = numField(prompt, 'Min Experience');
const english = field(prompt, 'English Level Required');
const certs = listField(prompt, 'Required Certifications');
const payMin = numField(prompt, 'Pay Range');
const payMatch = prompt.match(/Pay Range: \$(\d+(?:\.\d+)?)–\$(\d+(?:\.\d+)?)/);
const payMax = payMatch ? Number(payMatch[2]) : payMin;
const location = field(prompt, 'Location');
const custom = field(prompt, 'Custom Requirements');
const hasCustom = custom && !/^none$/i.test(custom);
const hasLocation = location && !/^not specified$/i.test(location);
const role = title || category || 'team member';
const payLine = payMax > 0 ? `$${payMin}–$${payMax}/hr` : 'a competitive hourly rate';
const whereLine = hasLocation ? ` in ${location}` : '';
const description = [
`We are hiring ${/^[aeiou]/i.test(role) ? 'an' : 'a'} ${role}${whereLine} for premium events where the details are the product. This is hands-on work with a team that plans carefully, briefs properly, and backs you up when the room gets busy.`,
`You will own your station from setup through close. ${minYears > 0 ? `We are looking for at least ${minYears} year${minYears === 1 ? '' : 's'} in a comparable role` : 'We will train the right person from the ground up'}, ${english ? `${english} English` : 'clear communication'}, and the judgment to make a call without waiting to be told. ${certs.length ? `${certs.join(' and ')} must be current.` : 'No specific certifications required.'}`,
`We pay ${payLine}, publish schedules ahead of time, and promote from within — most of our leads started on the floor. ${hasCustom ? `One thing worth flagging: ${custom}` : 'If you take pride in work guests notice, you will fit here.'}`,
].join('\n\n');
const responsibilities = (RESPONSIBILITY_LIBRARY[category] || DEFAULT_RESPONSIBILITIES).slice(0, 6);
const qualifications = [
minYears > 0
? `${minYears}+ year${minYears === 1 ? '' : 's'} of ${category ? `${category.toLowerCase()} ` : ''}experience`
: 'Willingness to learn and take direction',
english ? `${english.charAt(0).toUpperCase()}${english.slice(1)} English` : 'Clear communication',
...certs.map((c) => `${c} — current and verifiable`),
'Reliable transportation and consistent attendance',
'Comfortable on your feet for a full shift',
].filter(Boolean).slice(0, 6);
const niceToHaves = [
`Prior ${hasLocation ? `${location} ` : ''}event experience`,
'Bilingual',
'Additional certifications beyond the requirement',
'Experience training newer staff',
].slice(0, 4);
return { description, responsibilities, qualifications, nice_to_haves: niceToHaves };
}
/* ── 3. Resume builder ─────────────────────────────────────────────────── */
const SKILL_KEYWORDS = [
['bartend', 'Bartending'], ['cocktail', 'Cocktail preparation'], ['serve', 'Table service'],
['server', 'Table service'], ['banquet', 'Banquet service'], ['cook', 'Line cooking'],
['chef', 'Kitchen leadership'], ['kitchen', 'Kitchen operations'], ['security', 'Event security'],
['guard', 'Access control'], ['warehouse', 'Warehouse operations'], ['forklift', 'Forklift operation'],
['pick', 'Order picking'], ['host', 'Guest relations'], ['cashier', 'Cash handling'],
['clean', 'Sanitation'], ['train', 'Training others'], ['lead', 'Team leadership'],
['supervis', 'Supervision'], ['manage', 'People management'], ['wine', 'Wine service'],
['catering', 'Catering operations'], ['inventory', 'Inventory control'],
];
const CERT_KEYWORDS = [
['servsafe', 'ServSafe'], ['tips', 'TIPS Certified'], ['rbs', 'RBS Alcohol Server'],
['guard card', 'Guard Card'], ['cpr', 'CPR / First Aid'], ['first aid', 'CPR / First Aid'],
['food handler', 'Food Handler Card'], ['forklift', 'Forklift Operator'], ['osha', 'OSHA 10'],
];
function buildResume(prompt) {
const text = quotedBlock(prompt);
const lower = text.toLowerCase();
const yearsMatch = lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?)/);
const years = yearsMatch ? Number(yearsMatch[1]) : 0;
const skills = [...new Set(
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
)].slice(0, 8);
const certifications = [...new Set(
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
)];
// A capitalized two-word opener is almost always the person naming themselves.
const nameMatch = text.match(/\b([A-Z][a-z]+ [A-Z][a-z]+)\b/);
const inferredName = nameMatch ? nameMatch[1] : '';
const primary = skills[0] || 'hospitality';
const professionalSummary = text
? `${years > 0 ? `${years} year${years === 1 ? '' : 's'} of` : 'Hands-on'} experience in ${primary.toLowerCase()}${skills[1] ? ` and ${skills[1].toLowerCase()}` : ''}. ${certifications.length ? `Holds ${certifications.join(', ')}.` : 'Ready to certify as required.'} Dependable, quick to learn, and comfortable in a fast-moving service environment.`
: '';
const coverLetter = text
? `I am applying because this role matches what I already do well. ${years > 0 ? `Over ${years} year${years === 1 ? '' : 's'} I have` : 'I have'} worked in ${primary.toLowerCase()}${skills[1] ? `, with hands-on ${skills[1].toLowerCase()}` : ''}, and I show up ready for the shift I am given.\n\n${certifications.length ? `My ${certifications.join(' and ')} ${certifications.length > 1 ? 'are' : 'is'} current. ` : ''}I would welcome the chance to prove this on a trial shift rather than on paper.`
: '';
return {
inferred_name: inferredName,
years_experience: years,
skills,
certifications,
professional_summary: professionalSummary,
cover_letter: coverLetter,
};
}
/* ── 4. Interview questions ────────────────────────────────────────────── */
const INTERVIEW_QUESTIONS = {
en: [
(role) => `Thanks for joining. To start — what drew you to ${role} work, and how long have you been doing it?`,
() => `Walk me through your busiest shift. What actually happened, and what did you do about it?`,
() => `Something goes wrong mid-service and your lead is unreachable. Talk me through your next three moves.`,
() => `Tell me about feedback that changed how you work. What did you do differently afterward?`,
() => `Last one — what does your schedule realistically look like, and what do you want to be doing a year from now?`,
],
es: [
(role) => `Gracias por acompañarme. Para empezar — ¿qué te atrajo al trabajo de ${role}, y cuánto tiempo llevas haciéndolo?`,
() => `Cuéntame de tu turno más ocupado. ¿Qué pasó realmente, y qué hiciste al respecto?`,
() => `Algo sale mal a mitad del servicio y no puedes contactar a tu supervisor. Explícame tus siguientes tres pasos.`,
() => `Háblame de algún comentario que cambió tu manera de trabajar. ¿Qué hiciste diferente después?`,
() => `La última — ¿cómo es tu disponibilidad real, y qué te gustaría estar haciendo en un año?`,
],
};
function interviewQuestion(prompt) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/Ask question (\d+) of 5/);
const index = Math.min(4, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
const roleMatch = prompt.match(/AI interviewer for a (.+?) position/);
const role = roleMatch ? roleMatch[1] : 'this';
return INTERVIEW_QUESTIONS[lang][index](role);
}
const OWLIVER_QUESTIONS = {
en: [
(name) => `Hey ${name} — good to meet you. Tell me about yourself, in your own words.`,
() => `I like that. Tell me about something you got through that you're genuinely proud of.`,
() => `Picture this: you're the only one on shift, 300 guests arrive early, and the kitchen is behind. What do you do?`,
() => `What's a piece of feedback that stuck with you — and what changed after it?`,
() => `What do you want to be known for in five years?`,
() => `That's everything I needed. Anything you want to add before I put this together?`,
],
es: [
(name) => `Hola ${name} — un gusto conocerte. Cuéntame de ti, con tus propias palabras.`,
() => `Me gusta eso. Cuéntame de algo que superaste y de lo que estés realmente orgulloso.`,
() => `Imagina: estás solo en el turno, llegan 300 invitados antes de tiempo y la cocina va atrasada. ¿Qué haces?`,
() => `¿Qué comentario se te quedó grabado — y qué cambió después?`,
() => `¿Por qué te gustaría ser reconocido en cinco años?`,
() => `Eso es todo lo que necesitaba. ¿Algo que quieras agregar antes de que lo prepare?`,
],
};
function owliverQuestion(prompt) {
const lang = isSpanish(prompt) ? 'es' : 'en';
const numberMatch = prompt.match(/you are on question (\d+)/);
const index = Math.min(5, Math.max(0, (numberMatch ? Number(numberMatch[1]) : 1) - 1));
const nameMatch = prompt.match(/voice chat with (.+?)\./);
const name = nameMatch ? nameMatch[1].split(' ')[0] : 'there';
return OWLIVER_QUESTIONS[lang][index](name);
}
/* ── 5. Interview evaluation ───────────────────────────────────────────── */
/** Scores a transcript on how substantive the candidate's own answers are. */
function readTranscript(prompt) {
const transcript = prompt.split('TRANSCRIPT:')[1] || '';
const answers = transcript
.split('\n')
.filter((line) => /^Candidate:|^Worker:/.test(line.trim()))
.map((line) => line.replace(/^\w+:\s*/, '').trim())
.filter(Boolean);
const words = answers.reduce((sum, a) => sum + a.split(/\s+/).length, 0);
const avgWords = answers.length ? words / answers.length : 0;
// Concrete answers name specifics; vague ones stay abstract.
const specifics = (transcript.match(/\b\d+\b/g) || []).length;
return { answers, words, avgWords, specifics };
}
function evaluateInterview(prompt) {
const { answers, avgWords, specifics } = readTranscript(prompt);
const fastMatch = prompt.match(/(\d+) candidate response\(s\) were suspiciously fast/);
const fast = fastMatch ? Number(fastMatch[1]) : 0;
const spanish = isSpanish(prompt);
const role = field(prompt, 'Job Title') || 'the role';
const candidate = field(prompt, 'Candidate') || 'The candidate';
// Depth of answers is the dominant signal; specifics lift it, silence sinks it.
const depth = clamp(avgWords * 2.1);
const engagement = clamp(answers.length * 16);
const concreteness = clamp(46 + specifics * 9);
const base = clamp(depth * 0.45 + engagement * 0.25 + concreteness * 0.3);
const integrity = clamp(100 - fast * 12);
const flags = [];
if (fast >= 2) flags.push(`${fast} responses returned in under 8 seconds`);
if (avgWords > 90) flags.push('Answer length and register suggest possible assistance');
if (answers.length < 3) flags.push('Interview ended before enough signal was gathered');
const overall = clamp(base - (fast >= 3 ? 8 : 0));
const verdict = overall >= 78 ? 'hire' : overall >= 55 ? 'maybe' : 'no';
const strengths = spanish
? ['Respuestas claras y concretas', 'Se mantuvo tranquilo bajo presión']
: ['Clear, concrete answers', 'Stayed composed describing pressure'];
const concerns = spanish
? ['Profundidad limitada en algunas respuestas']
: ['Limited depth on some answers'];
if (fast >= 2) concerns.push(spanish ? 'Tiempos de respuesta inusualmente rápidos' : 'Unusually fast response times');
return {
overall_interview_score: overall,
verdict,
hire_recommendation: spanish
? verdict === 'hire'
? `Recomiendo avanzar con ${candidate} para ${role}.`
: verdict === 'maybe'
? `Vale un turno de prueba antes de comprometerse.`
: `No recomiendo avanzar para este puesto.`
: verdict === 'hire'
? `Move forward with ${candidate} for ${role}.`
: verdict === 'maybe'
? `Worth a trial shift before committing to the season.`
: `Not a fit for this posting as interviewed.`,
integrity_score: integrity,
ai_flags: flags,
category_scores: {
communication: clamp(depth * 0.6 + concreteness * 0.4),
confidence: clamp(base * 0.95),
experience_relevance: clamp(concreteness * 0.8 + depth * 0.2),
culture_fit: clamp(base * 0.92 + 4),
problem_solving: clamp(concreteness * 0.7 + depth * 0.3),
personality: clamp(base * 0.98),
communication_style: clamp(depth * 0.55 + 30),
attendance_expectations: clamp(base * 0.9 + 8),
reliability: clamp(base * 0.93 + 6),
physical_requirements: clamp(72 + specifics * 2),
leadership_expectations: clamp(base * 0.78),
scenario_judgment: clamp(concreteness * 0.75 + depth * 0.25),
job_related_answers: clamp(base * 0.96),
verified_skills: clamp(base * 0.88),
},
strengths,
concerns,
best_fit_roles: [role],
summary: spanish
? `${candidate} dio ${answers.length} respuestas con detalle concreto. El criterio es sólido; la profundidad técnica aún está en desarrollo.`
: `${candidate} gave ${answers.length} answers grounded in specifics rather than generalities. Judgment reads sound; technical depth is still developing.`,
reasoning: spanish
? `Puntuación basada en la profundidad de las respuestas (promedio ${Math.round(avgWords)} palabras) y ${specifics} detalles concretos. Integridad ${integrity} con ${fast} respuesta(s) rápida(s).`
: `Scored on answer depth (${Math.round(avgWords)} words average) and ${specifics} concrete details cited. Integrity ${integrity} with ${fast} fast response(s).`,
};
}
/* ── 6. Talent matching ────────────────────────────────────────────────── */
function matchTalent(prompt) {
const block = prompt.match(/AVAILABLE TALENT \(JSON\):\s*(\[[\s\S]*?\])\s*\n\nYou are NEVER/);
let pool = [];
try {
pool = block ? JSON.parse(block[1]) : [];
} catch {
pool = [];
}
const title = (field(prompt, 'Title') || '').toLowerCase();
const category = (field(prompt, 'Category') || '').toLowerCase();
const minYears = numField(prompt, 'Min Experience');
const requiredCerts = listField(prompt, 'Required Certifications').map((c) => c.toLowerCase());
const scored = pool.map((p) => {
const wants = `${p.desired_position || ''} ${p.current_position || ''}`.toLowerCase();
const roleFit = (title && wants.includes(title)) || (category && wants.includes(category)) ? 30
: (title && title.split(' ').some((w) => w.length > 3 && wants.includes(w))) ? 18
: 0;
const yearsFit = minYears > 0
? Math.min(20, ((p.experience_years || 0) / minYears) * 20)
: Math.min(20, (p.experience_years || 0) * 3);
const certs = (p.certifications || []).map((c) => c.toLowerCase());
const certFit = requiredCerts.length
? (requiredCerts.filter((r) => certs.some((c) => c.includes(r) || r.includes(c))).length / requiredCerts.length) * 15
: (certs.length ? 12 : 5);
const scoreFit = ((p.krow_score || 0) / 100) * 20;
const verifiedFit = Math.min(10, (p.capabilities || []).length * 5);
const reliabilityFit = ((p.attendance_score || 0) / 100) * 5;
const match = clamp(roleFit + yearsFit + certFit + scoreFit + verifiedFit + reliabilityFit);
const reasons = [];
if (roleFit >= 18) reasons.push(`Targeting ${p.desired_position || p.current_position} — aligned with this posting`);
if ((p.experience_years || 0) >= minYears && minYears > 0) reasons.push(`${p.experience_years} years experience meets the ${minYears}-year minimum`);
if ((p.capabilities || []).length) reasons.push(`Verified: ${p.capabilities.join(', ')}`);
if ((p.krow_score || 0) >= 75) reasons.push(`Career score ${p.krow_score} with ${p.shifts_completed || 0} shifts completed`);
if (!reasons.length) reasons.push(`New to KROW — ${p.experience_years || 0} years experience, no verified evidence yet`);
return {
profile_id: p.id,
match_score: match,
match_label: match >= 80 ? 'Strong Match' : match >= 65 ? 'Good Match' : match >= 50 ? 'Possible Fit' : 'Weak Fit',
reasons: reasons.slice(0, 3),
recommendation: match >= 80 ? 'Hire' : match >= 65 ? 'Interview' : match >= 50 ? 'Maybe' : 'Pass',
};
}).sort((a, b) => b.match_score - a.match_score);
const qualified = scored.filter((m) => m.match_score >= 50);
return { matches: qualified.length ? qualified : scored.slice(0, 5) };
}
/* ── 7. Owliver profile builder ────────────────────────────────────────── */
/** Job titles a worker might name for themselves, most specific first. */
const ROLE_TITLES = [
[/banquet captain|captain/, 'Banquet Captain'],
[/executive chef/, 'Executive Chef'],
[/sous chef/, 'Sous Chef'],
[/line cook/, 'Line Cook'],
[/\bchef\b/, 'Chef'],
[/bartender|bartend/, 'Bartender'],
[/barback/, 'Barback'],
[/event server|banquet server|\bserver\b/, 'Event Server'],
[/security officer|guard/, 'Security Officer'],
[/\bhost(ess)?\b/, 'Host'],
[/picker|warehouse/, 'Picker'],
[/supervisor/, 'Supervisor'],
[/manager/, 'Manager'],
];
const AVAILABILITY_TOKENS = [
[/weekend|saturday|sunday|sábado|domingo/i, 'Weekends'],
[/weekday|monday|tuesday|wednesday|thursday|friday|semana/i, 'Weekdays'],
[/evening|night|noche|tarde/i, 'Evenings'],
[/morning|mañana|early/i, 'Mornings'],
[/overnight|graveyard/i, 'Overnight'],
[/on.?call|flexible/i, 'On-Call'],
];
function buildCareerDna(prompt) {
const transcript = quotedBlock(prompt);
const lower = transcript.toLowerCase();
const { answers, avgWords, specifics } = (() => {
const lines = transcript.split('\n').filter((l) => /^Worker:/.test(l.trim()));
const texts = lines.map((l) => l.replace(/^Worker:\s*/, '').trim()).filter(Boolean);
const words = texts.reduce((s, t) => s + t.split(/\s+/).length, 0);
return {
answers: texts,
avgWords: texts.length ? words / texts.length : 0,
specifics: (transcript.match(/\b\d+\b/g) || []).length,
};
})();
const nameMatch = prompt.match(/worker named (.+?),/) || prompt.match(/worker named (.+?)\b/);
const workerName = nameMatch ? nameMatch[1].trim() : 'This worker';
const spanish = isSpanish(prompt);
// People mention several spans ("4 years as captain, 7 years total") — the
// largest is the career total the profile wants.
const years = (lower.match(/(\d+)\s*(?:\+)?\s*(?:years?|yrs?|años)/g) || [])
.map((m) => Number(m.match(/\d+/)[0]))
.reduce((max, n) => Math.max(max, n), 0);
// A stated job title, not a skill label — an empty string is more honest than
// labelling someone "Table service".
const role = ROLE_TITLES.find(([re]) => re.test(lower));
const roleTitle = role ? role[1] : '';
const skills = [...new Set(
SKILL_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
)].slice(0, 8);
const certifications = [...new Set(
CERT_KEYWORDS.filter(([key]) => lower.includes(key)).map(([, label]) => label)
)];
const availability = [...new Set(
AVAILABILITY_TOKENS.filter(([re]) => re.test(transcript)).map(([, token]) => token)
)];
const languages = ['English'];
if (/spanish|español/i.test(transcript) || spanish) languages.push('Spanish');
// Depth of the conversation is the only honest basis for these scores, so
// they stay conservative — the reference prompt is explicit about that.
const depth = clamp(avgWords * 1.9);
const engagement = clamp(answers.length * 15);
const base = clamp(depth * 0.5 + engagement * 0.25 + clamp(40 + specifics * 8) * 0.25);
const leadership = clamp(base * 0.7 + (/lead|supervis|manage|train|captain|chef/i.test(lower) ? 20 : 0));
const transportation = /\b(car|drive|vehicle|truck|coche|carro)\b/i.test(transcript)
? 'Own vehicle'
: /\b(bus|transit|train|bart|metro)\b/i.test(transcript)
? 'Public transit'
: '';
const primary = skills[0] || (spanish ? 'hospitalidad' : 'hospitality');
return {
career_goals: answers.length
? spanish
? `Crecer en ${primary.toLowerCase()} y asumir más responsabilidad en el equipo.`
: `Grow within ${primary.toLowerCase()} and take on more responsibility on the floor.`
: '',
desired_position: roleTitle,
current_position: years > 0 ? roleTitle : '',
experience_years: years,
experience: years > 0 && roleTitle
? [{ company: spanish ? 'Empleador anterior' : 'Previous employer', role: roleTitle, years }]
: [],
skills,
languages,
availability,
transportation,
certifications,
personality: answers.length
? spanish
? 'Directo y tranquilo. Explica su razonamiento sin adornos y asume lo que le corresponde.'
: 'Direct and level. Explains their reasoning without embellishment and owns their part.'
: '',
strengths: answers.length
? spanish
? ['Mantiene la calma bajo presión', 'Comunica con claridad', 'Toma iniciativa']
: ['Keeps composure under pressure', 'Communicates clearly', 'Takes initiative']
: [],
weaknesses: answers.length
? spanish ? ['Aún desarrollando experiencia de liderazgo'] : ['Still building formal leadership experience']
: [],
industries: skills.length ? ['Hospitality', 'Events'] : [],
salary_expectations: '',
leadership_potential: leadership,
communication_style: answers.length
? spanish
? 'Conciso y concreto; da ejemplos en lugar de generalidades.'
: 'Concise and concrete; reaches for examples rather than generalities.'
: '',
summary: answers.length
? spanish
? `${workerName} habló con detalle sobre su trabajo real. Muestra criterio práctico y ganas de crecer.`
: `${workerName} talked about real work in real detail. Practical judgment, and clearly interested in getting better at it.`
: '',
career_dna: {
identity: answers.length
? spanish
? `Alguien que resuelve en el momento y aprende sobre la marcha.`
: `Someone who solves problems in the moment and learns on the way through.`
: '',
learning_speed: clamp(base * 0.9),
// Reliability is unverified until real shifts land, so it is held down.
reliability: clamp(base * 0.6 + 10),
communication_score: clamp(depth * 0.6 + 28),
leadership_index: leadership,
adaptability_score: clamp(base * 0.95),
work_style: /\bteam|equipo\b/i.test(lower) ? 'Team player' : /\balone|independ\b/i.test(lower) ? 'Independent' : 'Operator',
culture_match: spanish
? ['Equipos con expectativas claras', 'Operaciones de ritmo rápido']
: ['Teams with clear expectations', 'Fast-paced service operations'],
// Baseline trust from one conversation — deliberately modest.
reputation_score: clamp(30 + base * 0.35),
},
};
}
/* ── 8. Proving Ground evaluator ───────────────────────────────────────── */
function evaluateChallenge(prompt) {
const criteriaMatch = prompt.match(/rubric" object: (.+?)\.\n/);
const criteria = criteriaMatch
? criteriaMatch[1].split(',').map((c) => c.trim()).filter(Boolean)
: ['overall_performance'];
const isPhotoIdentify = /marked these hazards/.test(prompt);
const isMedia = /uploaded a (photo|video)/.test(prompt);
let base;
let strengths;
let concerns;
if (isPhotoIdentify) {
const marks = (prompt.match(/^- "/gm) || []).length;
// Real hazard scenes have a handful of genuine risks; marking everything is
// as wrong as marking nothing.
base = clamp(marks === 0 ? 18 : 46 + Math.min(38, marks * 13) - Math.max(0, marks - 4) * 9);
strengths = marks ? [`Identified ${marks} hazard${marks === 1 ? '' : 's'}`] : [];
concerns = marks === 0
? ['No hazards marked']
: marks > 4
? ['Several marks are likely false positives']
: ['Some lower-visibility risks were missed'];
} else if (isMedia) {
base = 74;
strengths = ['Submitted a clear demonstration', 'Followed the challenge brief'];
concerns = ['Some steps happen off-camera and cannot be verified'];
} else {
const { answers, avgWords, specifics } = readTranscript(
`TRANSCRIPT:\n${quotedBlock(prompt).replace(/^Worker:/gm, 'Candidate:')}`
);
const spoken = answers.length ? answers : quotedBlock(prompt) ? ['x'] : [];
base = spoken.length
? clamp(avgWords * 2.4 + specifics * 6 + 20)
: 0;
strengths = base >= 70
? ['Went straight to a concrete remedy', 'Kept the guest experience intact']
: base > 0 ? ['Engaged with the scenario'] : [];
concerns = base >= 70
? ['Could state the escalation path explicitly']
: ['Response lacks the specifics an employer would need to trust it'];
}
const verdict = base >= 70 ? 'verified' : base >= 50 ? 'needs_work' : 'failed';
// Spread the overall score across the rubric so criteria are not identical.
const rubric = {};
criteria.forEach((criterion, i) => {
rubric[criterion] = clamp(base + (i % 3 === 0 ? 4 : i % 3 === 1 ? -3 : 1));
});
return {
verdict,
score: base,
rubric,
feedback: verdict === 'verified'
? `This holds up. You handled the core of the scenario the way the job actually needs it handled. Tighten the closing steps and this becomes strong evidence.`
: verdict === 'needs_work'
? `You are close. The instinct is right, but an employer reading this could not yet tell you would do it consistently. Be more specific about what you do, in what order.`
: `This does not yet demonstrate the skill. Work through the scenario again and walk through your actual steps out loud, in order, with specifics.`,
strengths,
concerns,
};
}
/* ── Router ────────────────────────────────────────────────────────────── */
/**
* Recognizes each workflow by the phrase its prompt opens with. Order matters
* only in that every branch is mutually exclusive by design.
*/
const ROUTES = [
[/^You are KROW's AI screening engine/m, screenCandidate, 1100],
[/^You are an expert hiring copywriter/m, generateJobDescription, 1400],
[/Extract and infer a structured resume/m, buildResume, 1200],
[/^You are "KROW", a friendly but sharp AI interviewer/m, interviewQuestion, 700],
[/^You are KROW's proactive talent-matching engine/m, matchTalent, 1300],
[/^You are KROW's AI interview evaluator/m, evaluateInterview, 1500],
[/^You are "Owliver"/m, owliverQuestion, 800],
[/^You are Owliver's Career DNA engine/m, buildCareerDna, 1600],
[/^You are KROW's Proving Ground evaluator/m, evaluateChallenge, 1400],
[/^You are "KROW", running a short proving-ground challenge/m,
() => 'Walk me through the exact order you would do that in — what happens first, and who do you tell?', 700],
];
/** Drop-in replacement for `integrations.Core.InvokeLLM`. */
export async function invokeLLM({ prompt = '', response_json_schema: schema } = {}) {
const route = ROUTES.find(([pattern]) => pattern.test(prompt));
if (!route) {
// An unrecognized prompt should surface loudly in the demo rather than
// silently returning a shape the caller cannot use.
console.warn('[krow-demo] Unrecognized AI prompt — returning an empty result.', prompt.slice(0, 120));
await think(400);
return schema ? {} : '';
}
const [, handler, latency] = route;
await think(latency);
return handler(prompt);
}
/** Drop-in replacement for `integrations.Core.UploadFile`. */
export async function uploadFile({ file } = {}) {
await think(500);
if (!file) return { file_url: '' };
// A blob URL keeps uploaded media viewable for the rest of the session
// without any storage backend.
return { file_url: URL.createObjectURL(file), file_name: file.name, file_size: file.size };
}

104
src/api/base44Client.js Normal file
View File

@@ -0,0 +1,104 @@
/**
* Application data client.
*
* The reference app talks to a Base44 backend through this module. The demo
* keeps the module path, the export name, and the full method contract, and
* swaps the transport for the local store in `store.js` and the local AI engine
* in `aiEngine.js`. Nothing downstream — hooks, pages, components — knows or
* cares, which is exactly the point: the seam stays where it was.
*/
import { createEntity, initStore, resetStore } from './store';
import { invokeLLM, uploadFile } from './aiEngine';
import { DEMO_USER, seedData } from './seed';
initStore(seedData);
const ENTITY_NAMES = [
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
'Course', 'Badge', 'LearningPath', 'Certification', 'RoleCategory',
'UserActivity', 'Evidence', 'User',
];
const entities = Object.fromEntries(
ENTITY_NAMES.map((name) => [name, createEntity(name)])
);
/* ── Auth ──────────────────────────────────────────────────────────────── */
const SESSION_KEY = 'krow_demo_user';
function loadUser() {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? { ...DEMO_USER, ...JSON.parse(raw) } : { ...DEMO_USER };
} catch {
return { ...DEMO_USER };
}
}
let currentUser = loadUser();
const auth = {
/** The demo is always signed in as the seeded employer/admin user. */
async me() {
return { ...currentUser };
},
async updateMe(patch) {
currentUser = { ...currentUser, ...patch };
try {
localStorage.setItem(SESSION_KEY, JSON.stringify(currentUser));
} catch {
// Non-persistent session is fine.
}
return { ...currentUser };
},
isAuthenticated() {
return true;
},
/**
* There is no identity provider to sign out of, so this clears the local
* session and returns to the requested page.
*/
logout(redirectTo = '/') {
try {
localStorage.removeItem(SESSION_KEY);
} catch {
// Ignore.
}
currentUser = { ...DEMO_USER };
window.location.href = typeof redirectTo === 'string' ? redirectTo : '/';
},
redirectToLogin() {
window.location.href = '/';
},
};
/* ── Integrations & analytics ───────────────────────────────────────────── */
const integrations = {
Core: {
InvokeLLM: invokeLLM,
UploadFile: uploadFile,
},
};
const analytics = {
track({ eventName, properties } = {}) {
if (import.meta.env.DEV) {
console.debug('[krow-demo] analytics', eventName, properties || {});
}
},
};
export const base44 = { entities, auth, integrations, analytics };
/** Restores the shipped demo data, discarding local edits. */
export function resetDemoData() {
resetStore(seedData);
window.location.reload();
}

1418
src/api/seed.js Normal file

File diff suppressed because it is too large Load Diff

173
src/api/store.js Normal file
View File

@@ -0,0 +1,173 @@
/**
* In-memory entity store backing the KROW demo.
*
* Mirrors the Base44 entity API the app was built against
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
* page and component consumes data exactly as it did against the real backend.
* Records live in memory and are mirrored to localStorage, so demo edits —
* screening a candidate, hiring, creating a position — survive a reload.
*/
const STORAGE_KEY = 'krow_demo_db';
const STORAGE_VERSION = 3;
/** Simulated network latency, in ms, so loading states are real. */
const LATENCY = { read: 140, write: 220 };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Structured clone with a JSON fallback for older Safari. */
const clone = (value) =>
typeof structuredClone === 'function'
? structuredClone(value)
: JSON.parse(JSON.stringify(value));
let _counter = 0;
export function makeId(prefix = 'rec') {
_counter += 1;
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
}
/* ── Persistence ───────────────────────────────────────────────────────── */
let db = {};
function persist() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
} catch {
// Private browsing / quota — the demo still works from memory.
}
}
function readPersisted() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
} catch {
return null;
}
}
/**
* Loads the store: persisted snapshot if one matches the current seed version,
* otherwise the seed itself.
*/
export function initStore(seed) {
db = readPersisted() || clone(seed);
// A seed that gained an entity after the snapshot was written still resolves.
for (const [name, records] of Object.entries(seed)) {
if (!db[name]) db[name] = clone(records);
}
persist();
}
/** Drops all local edits and restores the shipped demo data. */
export function resetStore(seed) {
db = clone(seed);
persist();
}
/* ── Query helpers ─────────────────────────────────────────────────────── */
/**
* Applies a Base44-style sort string: `'-ai_score'` descending,
* `'created_date'` ascending. Unknown fields leave order untouched.
*/
function applySort(records, sort) {
if (!sort) return records;
const desc = sort.startsWith('-');
const field = desc ? sort.slice(1) : sort;
return [...records].sort((a, b) => {
const av = a?.[field];
const bv = b?.[field];
if (av === bv) return 0;
if (av === undefined || av === null) return 1;
if (bv === undefined || bv === null) return -1;
const result = typeof av === 'number' && typeof bv === 'number'
? av - bv
: String(av).localeCompare(String(bv));
return desc ? -result : result;
});
}
/** Shallow equality match across every key of `query`, array fields included. */
function matches(record, query) {
return Object.entries(query).every(([key, want]) => {
const got = record?.[key];
if (Array.isArray(want)) return want.includes(got);
return got === want;
});
}
/* ── Entity API ────────────────────────────────────────────────────────── */
/**
* Builds the client surface for one entity. Every method is async and returns
* cloned records, so callers can never mutate the store by reference.
*/
export function createEntity(name) {
const table = () => (db[name] ||= []);
return {
entityName: name,
async list(sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
return clone(applySort(table(), sort).slice(0, limit));
},
async filter(query = {}, sort = '-created_date', limit = 100) {
await delay(LATENCY.read);
const found = table().filter((record) => matches(record, query));
return clone(applySort(found, sort).slice(0, limit));
},
async get(id) {
await delay(LATENCY.read);
const found = table().find((record) => record.id === id);
if (!found) throw new Error(`${name} ${id} not found`);
return clone(found);
},
async create(data) {
await delay(LATENCY.write);
const now = new Date().toISOString();
const record = {
id: makeId(name.toLowerCase()),
created_date: now,
updated_date: now,
...data,
};
table().unshift(record);
persist();
return clone(record);
},
async update(id, data) {
await delay(LATENCY.write);
const index = table().findIndex((record) => record.id === id);
if (index === -1) throw new Error(`${name} ${id} not found`);
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
table()[index] = next;
persist();
return clone(next);
},
async delete(id) {
await delay(LATENCY.write);
db[name] = table().filter((record) => record.id !== id);
persist();
return { id };
},
async bulkCreate(records = []) {
const created = [];
for (const record of records) created.push(await this.create(record));
return created;
},
};
}