fix Owliver ai chat

This commit is contained in:
2026-08-10 13:19:10 +05:30
parent 52fe6d9c14
commit 76412b08b4
8 changed files with 57 additions and 272 deletions

View File

@@ -5,7 +5,7 @@ import { Surface } from '@/components/ds/Surface';
import { IconButton } from '@/components/ds/IconButton';
import { Alert } from '@/components/ds/Alert';
import { KROW_LOGO_URL } from '@/assets/brand';
import { useAssistantFacts, useConversation } from './useAssistant';
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
import { buildIntro, buildPrompts } from './dynamic';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
@@ -43,7 +43,8 @@ export default function KrowAssistant({
onExpand,
onRestore,
}) {
const facts = useAssistantFacts({ needsWorkerProfile: context.needsWorkerProfile });
const facts = useAssistantFacts();
const userName = useCurrentUserName();
const { messages, pending, error, busy, send, stop, reset } = useConversation({
contextId: context.id,
facts,
@@ -56,8 +57,8 @@ export default function KrowAssistant({
/* Greeting and suggestions come from live data, so they recompute only when
the data or the page actually changes. */
const intro = React.useMemo(
() => buildIntro(context.id, facts, facts.profile?.full_name),
[context.id, facts]
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
const prompts = React.useMemo(() => buildPrompts(context.id, facts), [context.id, facts]);

View File

@@ -1,204 +0,0 @@
import { BookOpen, Compass, FileText } from 'lucide-react';
import { actions, answer, badges, doc, heading, insights, kpis, meters, note, status, table, text } from '../blocks';
import { plural } from '../insights';
import { getScoreBand, toFICO } from '@/lib/talentHome';
/**
* Talent capabilities — My Portal.
*
* The register shifts here: an employer wants a read on a pipeline, a worker
* wants to know what to do next to earn more. Answers name the specific course
* or step, because "improve your skills" helps nobody.
*/
const scoreOf = (p) => Number(p?.krow_score) || 0;
const NO_PROFILE = 'I do not have your profile yet. Once it exists I can give you specifics instead of generalities.';
const resumeReview = (f) => {
const p = f.profile;
if (!p) return answer(NO_PROFILE);
const fields = [
{ label: 'Photo', ok: Boolean(p.selfie_url), note: 'The KROW ID replaces a résumé — a profile with a face gets opened more.' },
{ label: 'Availability', ok: (p.availability || []).length > 0, note: 'Employers filter on this before they read anything else.' },
{ label: 'Certifications', ok: (p.certifications || []).length > 0, note: 'A Food Handler Card is the cheapest credential in this industry.' },
{ label: 'Skills', ok: (p.skills || []).length > 0, note: 'Without these you do not appear in skill searches.' },
{ label: 'Work history', ok: (p.experience || []).length > 0, note: 'Even one role gives employers something to anchor on.' },
{ label: 'Career goals', ok: Boolean(p.career_goals), note: 'Lets matching put you in front of the right roles, not every role.' },
{ label: 'Verified evidence', ok: (p.capabilities || []).length > 0, note: 'The only part of your profile an employer does not have to take on trust.' },
];
const complete = fields.filter((x) => x.ok).length;
return doc(
heading(`Your profile is ${p.profile_completion || 0}% complete`, `${complete} of ${fields.length} things employers look for`),
status(fields.map((x) => ({ label: x.label, ok: x.ok, note: x.ok ? undefined : x.note }))),
(p.certifications || []).length && [
heading('Credentials on file'),
badges(p.certifications.map((label) => ({ label, tone: 'success' }))),
],
text(
complete === fields.length
? 'Nothing is missing. From here, more verified evidence is what moves you — not more fields.'
: 'If you fix two things, fix availability and certifications. Those are the two employers filter on first.'
),
note(
(p.capabilities || []).length
? 'Your verified capabilities came with evidence attached, not a checkbox. That is why they carry weight.'
: 'Nothing is verified yet. One completed challenge is worth more than ten claimed skills.'
)
);
};
const careerGuidance = (f) => {
const p = f.profile;
if (!p) return answer(NO_PROFILE);
const score = scoreOf(p);
const band = getScoreBand(score);
const target = score >= 90 ? null : score >= 75 ? 90 : score >= 60 ? 75 : 60;
return doc(
heading(`${toFICO(score)} — ${band.label}`, 'Career score, on a 300–850 scale'),
kpis([
{ label: 'Reliability', value: p.reliability_score || 0, tone: (p.reliability_score || 0) >= 70 ? 'success' : 'warning' },
{ label: 'Attendance', value: p.attendance_score ?? 0, tone: (p.attendance_score ?? 0) >= 90 ? 'success' : undefined },
{ label: 'Shifts worked', value: p.shifts_completed || 0, tone: (p.shifts_completed || 0) === 0 ? 'warning' : undefined },
{ label: 'Verified skills', value: (p.capabilities || []).length },
]),
heading("What's driving it"),
meters([
{ label: 'Reliability', value: p.reliability_score || 0, weight: 25 },
{ label: 'Attendance', value: p.attendance_score ?? 0, weight: 20 },
{ label: 'Communication', value: p.performance_score || 0, weight: 15 },
{ label: 'Leadership', value: p.leadership_potential || 0, weight: 15 },
{ label: 'Learning', value: Math.min(100, (p.completed_courses || []).length * 8 + (p.xp || 0) / 10), weight: 15 },
{ label: 'Problem solving', value: p.ai_interview_score || 0, weight: 10 },
]),
insights([
target
? {
tone: 'info',
title: `To reach ${target}`,
body: 'Verified work is the fastest lever. Courses move the score; verified shifts move it more, because employers weight what someone else confirmed over what you claimed.',
}
: {
tone: 'success',
title: 'You are in the top band',
body: 'From here it is about holding it — the score decays if shifts stop.',
},
(p.shifts_completed || 0) === 0 && {
tone: 'warning',
title: 'Work one shift',
body: 'Attendance, ratings and reliability all need a first data point before anything else compounds.',
},
p.desired_position
? {
tone: score >= 75 ? 'success' : 'info',
title: `Aiming for ${p.desired_position}`,
body: score >= 75
? 'You are already scoring in the range employers shortlist for that.'
: 'The gap to that role is verification, not ambition.',
}
: {
tone: 'info',
title: 'No target role set',
body: 'Setting one lets me tell you which courses matter and which are noise.',
},
])
);
};
const learningRecommendations = (f) => {
const p = f.profile;
if (!p) return answer(NO_PROFILE);
const completedIds = new Set((p.completed_courses || []).map((c) => c.course_id));
const done = (p.completed_courses || []).length;
const shifts = p.shifts_completed || 0;
const available = (f.courses || []).filter((c) => c.status === 'active' && !completedIds.has(c.id));
if (!available.length) {
return doc(
heading('You have completed everything available'),
kpis([
{ label: 'Courses', value: done, tone: 'success' },
{ label: 'Badges', value: (p.earned_badges || []).length, tone: 'success' },
{ label: 'XP', value: p.xp || 0 },
{ label: 'Verified', value: (p.capabilities || []).length },
]),
text('The next lever is verified shifts — that is what employers weight most.')
);
}
const ranked = [...available].sort((a, b) => (b.xp || 0) - (a.xp || 0));
return doc(
heading('What to learn next', 'Ranked by what moves your score most'),
table(
[
{ key: 'title', label: 'Course' },
{ key: 'minutes', label: 'Time', align: 'right' },
{ key: 'xp', label: 'XP', align: 'right' },
],
ranked.slice(0, 4).map((c) => {
const gate = c.unlock_requirements?.min_shifts || 0;
const locked = gate > shifts;
return {
title: c.title,
minutes: `${c.estimated_minutes}m`,
xp: locked
? { badge: `${gate} shifts`, tone: 'warning' }
: { value: `+${c.xp}`, tone: 'success' },
};
})
),
heading('Where you stand'),
kpis([
{ label: 'Courses done', value: done },
{ label: 'Badges', value: (p.earned_badges || []).length },
{ label: 'XP', value: p.xp || 0 },
{ label: 'Verified skills', value: (p.capabilities || []).length },
]),
actions([
{
title: done === 0 ? `Start with ${ranked[ranked.length - 1].title}` : `Take ${ranked[0].title}`,
body: done === 0
? 'The point of the first course is not the XP — it is getting one verified capability on the board.'
: done < 3
? `Three completions is where the score starts moving noticeably. You are at ${done}.`
: 'You are past the easy gains. Advanced courses are worth double the XP and unlock supervisor-tier roles.',
},
shifts < 5 && {
title: `Log ${plural(5 - shifts, 'more shift')}`,
body: `Advanced challenges stay locked until 5 shifts — you have ${shifts}. That gate is deliberate: leadership evidence from someone who has never run a floor is not evidence.`,
},
]),
note('Each course ends in a challenge, not a quiz — you demonstrate the skill and the evidence gets scored.')
);
};
/* ── Free-text responder ────────────────────────────────────────────────── */
const has = (q, ...words) => words.some((w) => q.includes(w));
export const respondTalent = (question, f) => {
const q = question.toLowerCase();
if (has(q, 'resume', 'résumé', 'cv', 'profile', 'improve')) return resumeReview(f);
if (has(q, 'learn', 'course', 'training', 'study', 'skill', 'badge', 'path')) return learningRecommendations(f);
if (has(q, 'career', 'earn more', 'pay', 'score', 'guidance', 'promotion', 'next', 'started')) return careerGuidance(f);
const p = f.profile;
return answer(
p
? `I can help with your career score (currently ${toFICO(scoreOf(p))}), your profile, or what to learn next. Where would you like to start?`
: NO_PROFILE
);
};
export const TALENT_CAPABILITIES = [
{ id: 'resume-review', label: 'Resume Review', icon: FileText, run: resumeReview },
{ id: 'career-guidance', label: 'Career Guidance', icon: Compass, run: careerGuidance },
{ id: 'learning-recommendations', label: 'Learning Recommendations', icon: BookOpen, run: learningRecommendations },
];

View File

@@ -6,7 +6,6 @@ import {
ACTIVITY_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, CONTROL_CENTER_CAPABILITIES,
respondActivity, respondAdminCandidates, respondControlCenter,
} from './capabilities/admin';
import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
/**
* Page contexts for the Krow AI Assistant.
@@ -16,8 +15,8 @@ import { TALENT_CAPABILITIES, respondTalent } from './capabilities/talent';
* live data in `dynamic.js`, not written here. A context only declares what this
* page's assistant can *do*: its capabilities and its free-text responder.
*
* `needsWorkerProfile` gates the one query with a side effect, so employer and
* admin sessions never touch worker-profile records.
* Krow AI serves Employer and Admin. The talent portal has Owliver, a separate
* product with its own UI and workflow — nothing here targets it.
*/
export const ASSISTANT_CONTEXTS = {
'employer.overview': {
@@ -56,13 +55,6 @@ export const ASSISTANT_CONTEXTS = {
capabilities: ACTIVITY_CAPABILITIES,
respond: respondActivity,
},
'talent.portal': {
id: 'talent.portal',
page: 'My Portal',
capabilities: TALENT_CAPABILITIES,
respond: respondTalent,
needsWorkerProfile: true,
},
};
export const getContext = (id) => ASSISTANT_CONTEXTS[id] ?? null;

View File

@@ -98,14 +98,6 @@ const GREETINGS = {
return `${plural(f.activity24h.length, 'event')} today, ${f.activity7d.length} this week.`;
},
'talent.portal': (f) => {
const p = f.profile;
if (!p) return 'Let us get your profile started.';
const completion = p.profile_completion || 0;
if (completion >= 90) return 'Your profile is nearly complete — nice work.';
if (completion > 0) return `You are ${completion}% of the way through your profile.`;
return 'Your profile is empty. A few fields will change what employers see.';
},
};
/** Title lines. First names read better than a formal greeting in a small panel. */
@@ -116,7 +108,6 @@ const TITLES = {
'admin.controlCenter': (f) => `Platform check, ${timeOfDay(f.today)}`,
'admin.candidates': () => 'Talent analysis',
'admin.activity': () => 'Activity review',
'talent.portal': (f, name) => `Good ${timeOfDay(f.today)}${name ? `, ${name}` : ''}`,
};
/** Descriptions state what this page's assistant can actually do with the data. */
@@ -128,7 +119,6 @@ const DESCRIPTIONS = {
'admin.controlCenter': () => 'Health checks, data integrity and what needs intervention.',
'admin.candidates': () => 'Talent supply and risk across the candidate pool.',
'admin.activity': (f) => `${plural(f.activity.length, 'logged event')}, reconciled and profiled.`,
'talent.portal': () => 'Your score, your profile, and what to learn next.',
};
/** Placeholders phrased as things this page's user would actually type. */
@@ -151,7 +141,6 @@ const PLACEHOLDERS = {
'admin.controlCenter': () => ['Show unusual activity…', 'Summarize platform usage…'],
'admin.candidates': () => ['Where is the risk in the pool?', 'Summarize recruitment trends…'],
'admin.activity': () => ['Show unusual activity…', 'Summarize platform usage…'],
'talent.portal': () => ['Improve my profile…', 'Recommend learning paths…'],
};
/* ── Suggested prompts ──────────────────────────────────────────────────── */
@@ -229,16 +218,6 @@ const PROMPTS = {
: null,
].filter(Boolean),
'talent.portal': (f) => {
const p = f.profile;
return [
{ label: 'Improve my profile', prompt: 'Review my profile and tell me what is missing', capability: 'resume-review' },
{ label: 'Recommend learning paths', prompt: 'What should I learn next?', capability: 'learning-recommendations' },
p && (p.krow_score || 0) > 0
? { label: 'How do I score higher?', prompt: 'How do I raise my career score?', capability: 'career-guidance' }
: { label: 'How do I get started?', prompt: 'How do I raise my career score?', capability: 'career-guidance' },
];
},
};
/* ── Public API ─────────────────────────────────────────────────────────── */

View File

@@ -22,9 +22,6 @@ const PLACEMENT = {
'/candidates': 'admin.candidates', // Candidates Analysis
'/tracking': 'admin.activity', // Activity
},
talent: {
'/employee': 'talent.portal', // My Portal
},
};
/**
@@ -34,13 +31,16 @@ const PLACEMENT = {
* Employer: Positions, Position Detail, Create Position, Hired History,
* Talent Pool, Profile.
* Admin: everything except the three above.
* Talent: everything except My Portal.
* Talent: every page. The talent portal has Owliver — a separate product with
* its own voice experience, branding and workflow. Krow AI does not
* appear there and must not replace it.
*/
export const EXCLUDED_ROUTES = [
'/positions', '/positions/new', '/positions/:id',
'/hired', '/talent-pool', '/profile', '/apply',
'/university', '/university/:id', '/me', '/identity',
'/owliver', '/design-system',
'/employee', // Talent portal — Owliver's, not Krow AI's.
];
/** Returns the context for a role and path, or `null` to render no assistant. */

View File

@@ -1,7 +1,9 @@
import * as React from 'react';
import { useQuery } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import {
useApplications, useCourses, useInterviews, useJobPostings, useStaff,
useUserActivity, useWorkerProfile, useWorkerProfiles,
useUserActivity, useWorkerProfiles,
} from '@/lib/krowHooks';
import { buildFacts } from './insights';
import { createAssistantProvider } from './provider';
@@ -14,7 +16,7 @@ const provider = createAssistantProvider();
* costs no extra requests and cannot be looking at a different snapshot than the
* card beside it.
*/
export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
export function useAssistantFacts() {
const { data: applications = [] } = useApplications();
const { data: postings = [] } = useJobPostings();
const { data: interviews = [] } = useInterviews();
@@ -23,19 +25,30 @@ export function useAssistantFacts({ needsWorkerProfile = false } = {}) {
const { data: activity = [] } = useUserActivity();
const { data: courses = [] } = useCourses();
// Gated: this query creates the record if absent, so it must not run for
// employer or admin sessions.
const { data: profile = null } = useWorkerProfile({ enabled: needsWorkerProfile });
// Krow AI serves Employer and Admin only, so it never reads the signed-in
// worker's own profile — that query also creates the record if absent, and it
// belongs to the talent portal, not here.
return React.useMemo(
() => buildFacts({
applications, postings, interviews, staff, profiles, activity, courses,
profile: needsWorkerProfile ? profile : null,
}),
[applications, postings, interviews, staff, profiles, activity, courses, profile, needsWorkerProfile]
() => buildFacts({ applications, postings, interviews, staff, profiles, activity, courses }),
[applications, postings, interviews, staff, profiles, activity, courses]
);
}
/**
* The signed-in user's first name, for personalizing the greeting.
*
* Shares the `['user']` query key with the rest of the app, so this is a cache
* read rather than another request.
*/
export function useCurrentUserName() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: () => base44.auth.me().catch(() => null),
staleTime: Infinity,
});
return data?.full_name || '';
}
/**
* Conversation state for one page context.
*