diff --git a/src/App.jsx b/src/App.jsx index dcbc3a7..f47818f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -44,6 +44,7 @@ import AdminSettings from '@/pages/admin/Settings'; import AdminWorkspace from '@/pages/admin/Workspace'; import AdminWorkspaceSkills from '@/pages/admin/WorkspaceSkills'; import AdminSkillEditor from '@/pages/admin/SkillEditor'; +import AdminOwliverSkillEditor from '@/pages/admin/OwliverSkillEditor'; import AdminSkillDevelopment from '@/pages/admin/SkillDevelopment'; import AdminLogin from '@/pages/admin/Login'; import AdminRoute from '@/pages/admin/AdminRoute'; @@ -110,7 +111,13 @@ const AuthenticatedApp = () => { } /> } /> } /> + {/* Two editors, because a UI skill and an Owliver skill configure + different things. Static segments rank above the dynamic `:id`, + so `skills/owliver/...` cannot be read as a skill called + "owliver" — and the two original addresses are untouched. */} } /> + } /> + } /> } /> } /> diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx index 0eb41cf..8ffdc79 100644 --- a/src/components/ai-assistant/AssistantPanelContext.jsx +++ b/src/components/ai-assistant/AssistantPanelContext.jsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { base44 } from '@/api/base44Client'; import { resolveAssistantContext } from './placement'; +import { PageContextProvider } from './PageContext'; /** * Window state for the Owliver panel: open/collapsed and default/expanded. @@ -149,9 +150,12 @@ export function AssistantPanelProvider({ role, pathname, children }) { restore: () => setExpandedState(false), }), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]); + /* The page's own selection travels beside the window state, mounted here so + both the page and the panel are inside it — a skill's data source resolves + against the record the reader has open, whichever of the two is asking. */ return ( - {children} + {children} ); } diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index ae78c0d..91755a5 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1,6 +1,8 @@ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; -import { Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles } from 'lucide-react'; +import { + ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Sparkles, Trash2, +} from 'lucide-react'; import { cn } from '@/lib/utils'; import { Surface } from '@/components/ds/Surface'; import { IconButton } from '@/components/ds/IconButton'; @@ -13,12 +15,171 @@ import { ROLE_CATEGORIES } from '@/lib/roleCategories'; import { AddSkillDialog } from '@/components/skills/AddSkillDialog'; import { runAction } from '@/lib/skills/actions'; import { skillsForContext } from '@/lib/skills/registry'; +import { owliverSuggestions } from '@/lib/skills/owliverResolver'; +import { useWorkforcePaths } from '@/lib/skills/usePageSkills'; +import { profileForEmail } from '@/lib/skillGraph'; +import { groupByRecency } from './history'; +import { usePageContext } from './PageContext'; import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant'; import { buildIntro, buildPrompts } from './dynamic'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; import { PromptInput } from './PromptInput'; import { PromptChips } from './PromptChips'; +/** + * Owliver History — the conversations that came before. + * + * A list rather than a second panel: it replaces the thread in the body while + * the header and composer stay exactly where they are, so History is a state of + * the panel rather than a place you navigate to and have to find your way out + * of. Grouped by when, because that is how people look for a conversation they + * half-remember. + */ +function HistoryView({ groups, currentId, onOpen, onForget }) { + if (!groups.length) { + return ( +
+

No conversations yet

+

+ Ask Owliver something and it will be here afterwards. History is kept on this device. +

+
+ ); + } + + return ( +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
    + {group.items.map((record) => ( +
  • + + + {/* Destructive, so it stays out of the way until wanted rather + than sitting beside every row waiting to be mis-clicked. */} + +
  • + ))} +
+
+ ))} +
+ ); +} + +/** + * Show the Back to Home row on scroll direction, not scroll position. + * + * Hidden while reading downwards, back the instant the user scrolls up — the + * way out of a long answer should not be something you have to scroll all the + * way to the top to reach. Direction is read off the panel's own scrolling + * element (the body region below), never the window: the page behind the panel + * does not move when the thread does. + * + * The listener is passive and rAF-throttled, and `setVisible` is only ever + * called with a value that can change — React bails out on an identical one, so + * a fast scroll costs at most one render per direction change. + * + * `pinToBottom` is the same follow-the-stream scroll the panel already did, + * routed through here so a programmatic jump is not mistaken for the user + * scrolling down and does not hide the control under them. + */ +function useDirectionalNav(scrollRef, { active, resetKey }) { + const [visible, setVisible] = React.useState(true); + const lastY = React.useRef(0); + + /* Ignore sub-pixel and trackpad jitter, but nothing a deliberate scroll would + produce: a real direction change clears this within one frame. */ + const NOISE = 4; + + const pinToBottom = React.useCallback(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + /* Adopt the new position before the scroll event lands, so the next read + sees no delta and the row keeps whatever state the user left it in. */ + lastY.current = el.scrollTop; + }, [scrollRef]); + + React.useEffect(() => { + const el = scrollRef.current; + /* Nothing to hide when the row is not rendered — and a fresh thread or a + newly opened panel always starts with it showing. */ + setVisible(true); + if (!el || !active) return undefined; + + lastY.current = el.scrollTop; + let frame = 0; + + const read = () => { + frame = 0; + const y = el.scrollTop; + if (y <= 0) { + lastY.current = y; + setVisible(true); + return; + } + const delta = y - lastY.current; + /* Leave `lastY` alone below the threshold so slow scrolls accumulate + rather than being swallowed frame by frame. */ + if (Math.abs(delta) < NOISE) return; + lastY.current = y; + setVisible(delta < 0); + }; + + const onScroll = () => { + if (!frame) frame = requestAnimationFrame(read); + }; + + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + if (frame) cancelAnimationFrame(frame); + }; + }, [scrollRef, active, resetKey]); + + return { visible, pinToBottom }; +} + /** * Owliver — the dashboard's contextual panel. * @@ -88,8 +249,50 @@ export default function KrowAssistant({ () => skillsForContext(context.id, disabledSkills, customSkills), [context.id, disabledSkills, customSkills] ); + + /** + * What a declared skill reads from. + * + * The same collections `SkillSurface` hands the resolver on the page, plus + * whatever record the page has published as open. One context, one resolver: + * the card on the page and the answer in the panel are two readings of the + * same data rather than two queries that happen to agree. + */ + const pageContext = usePageContext(); + const { statesFor } = useWorkforcePaths(); + const trainingPaths = React.useMemo( + () => statesFor(facts.forge?.library || [], profileForEmail(facts.profiles || [], facts.user?.email)), + [statesFor, facts.forge, facts.profiles, facts.user] + ); + const { data: assignments = [] } = useAssignments(); + const skillContext = React.useMemo(() => ({ + ...pageContext, + applications: facts.applications || [], + positions: facts.postings || [], + interviews: facts.interviews || [], + courses: facts.forge?.library || [], + workerProfiles: facts.profiles || [], + /* The same records under the workforce engine's name, and the commitments + it reads availability from — so a source that scores candidates against a + position resolves here exactly as it does on the page. */ + profiles: facts.profiles || [], + assignments, + staff: facts.staff || [], + activity: facts.activity || [], + trainingPaths, + }), [pageContext, facts, assignments, trainingPaths]); const [addSkillOpen, setAddSkillOpen] = React.useState(false); + /** + * Which face of the panel the body is showing. + * + * A state rather than a route: the header, the composer and the panel's width + * are unchanged between the two, and only the scrolling region swaps. That is + * also why there is always a way back — leaving History is a state change this + * component owns, not a browser-history entry it has to hope exists. + */ + const [view, setView] = React.useState('chat'); + /** * A skill asked for something to happen. The names come from the skill file; * `runAction` decides what they mean and refuses anything the file did not @@ -111,8 +314,8 @@ export default function KrowAssistant({ * for a skill whose file does not, and then nothing is written. */ const createJob = useCreateJobPosting(); - const createPosition = React.useCallback(async (draft, skill) => { - const result = runAction('create_position', { draft, skill }); + const createPosition = React.useCallback(async (draft, skill, status) => { + const result = runAction('create_position', { draft, skill, status }); if (result?.type !== 'create_position') return null; return createJob.mutateAsync(result.data); }, [createJob]); @@ -125,10 +328,13 @@ export default function KrowAssistant({ * routing layer because this is where the app's data already is; routing stays * pure and testable. */ - const { data: assignments = [] } = useAssignments(); const workforce = React.useMemo(() => ({ positions: facts.postings || [], - currentPositionId: null, + /* The position the page has open, so "who matches this position?" resolves + against the record in front of the reader rather than asking which role + they meant. A page with nothing open publishes nothing, and the question + is answered by name or asked for, exactly as before. */ + currentPositionId: pageContext.position?.id || null, context: { profiles: facts.profiles || [], applications: facts.applications || [], @@ -136,7 +342,8 @@ export default function KrowAssistant({ courses: facts.forge?.library || [], staff: facts.staff || [], }, - }), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments]); + }), [facts.postings, facts.profiles, facts.applications, facts.forge, facts.staff, assignments, + pageContext.position?.id]); /** * The workforce write, through the mutation the app already has. @@ -169,9 +376,13 @@ export default function KrowAssistant({ [markInterviewReady] ); - const { messages, pending, error, busy, send, stop, reset } = useConversation({ + const { + messages, pending, error, busy, send, stop, reset, + history, conversationId, openConversation, forgetConversation, + } = useConversation({ contextId: context.id, facts, + pageLabel: context.page, onNavigate: goToPage, onAction: performAction, onCreatePosition: createPosition, @@ -183,6 +394,7 @@ export default function KrowAssistant({ roles, skillCategories, courses: facts.forge?.library || [], + skillContext, }); /* A block inside an answer asking the next question, in place. Same entry @@ -192,10 +404,37 @@ export default function KrowAssistant({ if (!busy) send({ question }); }, [busy, send]); + const historyGroups = React.useMemo(() => groupByRecency(history), [history]); + + /** + * Home is the empty panel: greeting, page fact, suggestions. + * + * One handler for every way back — the History header button, the Back to + * Home control, and reopening after reading an old thread — so "home" cannot + * mean two slightly different states depending on how you got there. Nothing + * is lost: the thread being left is already archived. + */ + const goHome = React.useCallback(() => { + setView('chat'); + reset(); + }, [reset]); + + const openHistoryItem = React.useCallback((record) => { + openConversation(record); + setView('chat'); + }, [openConversation]); + const [input, setInput] = React.useState(''); const scrollRef = React.useRef(null); const isEmpty = messages.length === 0 && !pending; + /* The Back to Home row only exists in the states that are not already home. */ + const showBackRow = view === 'history' || messages.length > 0 || Boolean(pending); + const { visible: backVisible, pinToBottom } = useDirectionalNav(scrollRef, { + active: showBackRow, + resetKey: `${view}:${conversationId || ''}`, + }); + /* Greeting and suggestions come from live data, so they recompute only when the data or the page actually changes. */ const intro = React.useMemo( @@ -208,19 +447,46 @@ export default function KrowAssistant({ const followUp = messages[messages.length - 1]?.followUp; const prompts = React.useMemo(() => { if (followUp?.length) return followUp; + /* A definition's own suggestions come first: they are the only ones written + for this workspace rather than derived from the page, and they are capped + by the resolver so a workspace with several skills attached cannot bury + the page's own. */ + const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext); const skillPrompts = skills .filter((s) => s.prompt) .map((s) => ({ label: s.prompt, prompt: s.prompt })); - return [...skillPrompts, ...buildPrompts(context.id, facts, workforce)]; - }, [followUp, skills, context.id, facts, workforce]); + + /* Three sources can propose the same question — a skill's declared + suggestion and the page's own derived one often word it identically — + and two chips reading "Summarize hiring activity" is both a duplicate + React key and a duplicate offer. First wins, so the definition's own + wording survives and the derived copy drops out. */ + /* A declared suggestion that cannot answer yet — it reads one record and + none is selected — ranks behind the page's own offers rather than + leading with a question. The lifecycle reads correctly either way: + before a position exists the page's actions lead; once one is open or + has just been created, the readings about it come first. */ + const ready = declared.filter((c) => !c.deferred); + const asking = declared.filter((c) => c.deferred); + + const seen = new Set(); + return [...ready, ...skillPrompts, ...buildPrompts(context.id, facts, workforce), ...asking] + .filter((chip) => { + const key = String(chip?.label ?? '').trim().toLowerCase(); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); + /* `pageContext` decides which suggestions can answer without asking, so the + chips re-rank when a position is opened or closed. */ + }, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext]); /* Follow the newest content. Direct `scrollTop` rather than smooth scrolling: at streaming frequency a smooth scroll never catches up and the thread visibly lags the text. */ React.useEffect(() => { - const el = scrollRef.current; - if (el) el.scrollTop = el.scrollHeight; - }, [messages, pending]); + pinToBottom(); + }, [messages, pending, pinToBottom]); /** * The one entry point for every message, whatever raised it. @@ -233,6 +499,9 @@ export default function KrowAssistant({ const text = String(question).trim(); if (!text || busy) return; setInput(''); + /* Asking something while reading History returns to the conversation — the + answer is about to arrive there, and leaving the list up would hide it. */ + setView('chat'); send({ question: text, capability }); }, [busy, send]); @@ -271,7 +540,7 @@ export default function KrowAssistant({ padding="none" elevation="sm" data-wide={expanded || undefined} - className={cn('flex flex-col overflow-hidden', className)} + className={cn('relative flex flex-col overflow-hidden', className)} role="region" aria-label="Owliver assistant" > @@ -297,7 +566,19 @@ export default function KrowAssistant({ size="sm" onClick={() => setAddSkillOpen(true)} /> - {messages.length > 0 && ( + {/* History lives with the other window controls rather than in the + body, so the layout of the panel is unchanged whether or not + there is anything to show. It toggles: pressing it again returns + to the conversation you were reading. */} + setView((v) => (v === 'history' ? 'chat' : 'history'))} + /> + {messages.length > 0 && view === 'chat' && ( )} {expanded @@ -313,14 +594,53 @@ export default function KrowAssistant({ + {/* Floating directional Back to Home row — reveals on UP-scroll, hides on DOWN-scroll */} + {showBackRow && ( +
+ + + {view === 'history' + ? `${history.length} conversation${history.length === 1 ? '' : 's'}` + : context.page} + +
+ )} + {/* ── Body: the only region that scrolls ─────────────────────────── */}
- {isEmpty ? ( + + {view === 'history' ? ( + + ) : isEmpty ? ( /* Greeting and the single most relevant fact about this page. Top aligned rather than centred: it is the first thing in a conversation, not a splash screen, so it belongs where the first @@ -373,7 +693,7 @@ export default function KrowAssistant({ Follow-ups are never capped: when Owliver has asked a question, its chips *are* the answers, and hiding three of the six roles would make the flow look broken. */} - {!busy && ( + {!busy && view === 'chat' && ( { + handlersRef.current = handlers || {}; + const next = Object.keys(handlersRef.current).sort(); + /* Only a change in *what* is writable is worth a render. */ + setWritable((prev) => (prev.join('|') === next.join('|') ? prev : next)); + }, []); + + /** + * Runs a page's handler for a source. Returns false when the page is not + * accepting that write, so a caller can stay read-only rather than pretending + * the change landed. + */ + const runAction = React.useCallback((source, payload) => { + const handler = handlersRef.current[source]; + if (typeof handler !== 'function') return false; + handler(payload); + return true; + }, []); + + const value = React.useMemo( + () => ({ entity, setEntity, publishActions, runAction, writable }), + [entity, publishActions, runAction, writable] + ); + + return {children}; +} + +/** + * Publishes this page's current selection for as long as it is mounted. + * + * Keyed on the content rather than the object, because a page rebuilds its + * records on every render and an identity-keyed effect would publish in a loop. + * Unmounting clears the selection, so leaving a page cannot leave Owliver + * answering about a record nobody is looking at. + */ +export function usePublishPageContext(entity) { + const context = React.useContext(PageContext); + const setEntity = context?.setEntity; + + /* `undefined` values would vanish from the serialized key, so a selection + being cleared has to read as an explicit null. */ + const key = React.useMemo(() => { + try { + return entity ? JSON.stringify(entity) : null; + } catch { + return null; + } + }, [entity]); + + React.useEffect(() => { + if (!setEntity) return undefined; + setEntity(key ? JSON.parse(key) : null); + return () => setEntity(null); + }, [key, setEntity]); +} + +/** + * The records the page has published. Always an object, so a consumer can read + * `position` without checking whether anything published at all. + */ +export function usePageContext() { + return React.useContext(PageContext)?.entity || {}; +} + +/** + * Offers this page's writes for as long as it is mounted. + * + * Pass a memoized map of `{ [sourceId]: handler }`. Handlers should be stable — + * a functional `setState` closes over nothing, which is what lets the map be + * memoized once rather than rebuilt as the form is typed into. + * + * const applyWeights = useCallback( + * (next) => setForm((f) => ({ ...f, vetting_criteria: next })), []); + * usePublishPageActions(useMemo( + * () => ({ 'position.vetting': applyWeights }), [applyWeights])); + * + * Unpublished on unmount, so leaving a page cannot leave a control on screen + * that writes into a form nobody is looking at. + */ +export function usePublishPageActions(handlers) { + const publishActions = React.useContext(PageContext)?.publishActions; + + React.useEffect(() => { + if (!publishActions) return undefined; + publishActions(handlers); + return () => publishActions(null); + }, [publishActions, handlers]); +} + +/** + * How a section writes back, for one source — or `null` when nothing on this + * page is accepting that write. + * + * Returning null rather than a no-op is deliberate: a renderer checks it to + * decide whether to draw controls at all, so a section declared `editable` on a + * page that does not own the data stays an honest read-out. + */ +export function usePageAction(source) { + const context = React.useContext(PageContext); + const accepted = Boolean(source) && (context?.writable || []).includes(source); + const runAction = context?.runAction; + + return React.useMemo( + () => (accepted && runAction ? (payload) => runAction(source, payload) : null), + [accepted, runAction, source] + ); +} diff --git a/src/components/ai-assistant/PromptChips.jsx b/src/components/ai-assistant/PromptChips.jsx index 0d1343a..b6368a1 100644 --- a/src/components/ai-assistant/PromptChips.jsx +++ b/src/components/ai-assistant/PromptChips.jsx @@ -44,7 +44,10 @@ export function PromptChips({ prompts = [], onSelect, max, align = 'center', cla > {visible.map((prompt, i) => ( + )} +
+ + ); +} + +/** + * The registry: declared type → component. + * + * The only mapping from a definition to rendering. Adding a type means adding a + * component here and a name in `surfaces.js` — never a branch on a skill id. + */ +export const SECTION_COMPONENTS = { + card: CardSection, + stats: StatsSection, + list: ListSection, + timeline: TimelineSection, + flow: FlowSection, + table: TableSection, + progress: ProgressSection, + insight: InsightSection, + weights: WeightsSection, +}; diff --git a/src/components/skills/SkillSurface.jsx b/src/components/skills/SkillSurface.jsx new file mode 100644 index 0000000..c3290c6 --- /dev/null +++ b/src/components/skills/SkillSurface.jsx @@ -0,0 +1,181 @@ +import React, { useMemo } from 'react'; +import { Sparkles } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useApplications, useAssignments, useCourses, useCurrentUser, useInterviews, useJobPostings, + usePreferences, useStaff, useUserActivity, useWorkerProfiles, +} from '@/lib/krowHooks'; +import { allSkills } from '@/lib/skills/registry'; +import { sectionsForPage } from '@/lib/skills/uiConfig'; +import { resolveSkillData } from '@/lib/skills/dataResolver'; +import { useWorkforcePaths } from '@/lib/skills/usePageSkills'; +import { profileForEmail } from '@/lib/skillGraph'; +import { SECTION_COMPONENTS } from '@/components/skills/SkillSections'; +/* The page's current selection, from the one channel that carries it. Imported + from the module rather than the package index so a page section never pulls + the assistant panel in behind it. */ +import { usePageAction, usePageContext } from '@/components/ai-assistant/PageContext'; + +/** + * The extension point: one controlled slot a page offers to skills. + * + * A page says where a skill *may* appear; a skill definition says where it + * *does*. Neither knows the other exists — the page names a surface and a + * placement, the definition names the same two things, and this component is + * what joins them. That is why a new skill needs no change to any page, and why + * a page can be redesigned without touching a skill. + * + * + * + * What it will not do is as important as what it does. It renders only sections + * that survived validation, only from skills that are active, only through the + * component table, and only with data the resolver produced from real records. + * Nothing from the Markdown reaches the DOM as markup. + */ + +/** The skills contributing sections to this page right now. */ +function useSkillSections(page, placement) { + const preferences = usePreferences(); + const customKey = JSON.stringify(preferences.customSkills || []); + const disabledKey = JSON.stringify(preferences.disabledSkills || []); + + return useMemo(() => { + const custom = JSON.parse(customKey); + const disabled = JSON.parse(disabledKey); + + return allSkills(custom) + /* Inactive means registered but not offered — the same rule the assistant + follows, so switching a skill off removes its UI too. */ + .filter((skill) => skill.status === 'active' && !disabled.includes(skill.id)) + .flatMap((skill) => sectionsForPage(skill, page) + .filter((section) => !placement || section.placement === placement) + .map((section) => ({ skill, section }))); + }, [page, placement, customKey, disabledKey]); +} + +/** + * The records a section may be read from. + * + * Loaded once per surface rather than per section, and passed to the resolver — + * which is the only thing that touches them. A section never queries anything. + * + * Exported because the panel needs the identical reading: an answer drawn in the + * chat and a card drawn on the page must resolve from one set of collections, or + * the two surfaces of the same definition can disagree. Every hook inside is a + * cache read the app has already paid for. + */ +export function useSkillDataContext(context) { + /* What the page published about itself, for a surface that was not handed a + record directly. An explicit `context` prop still wins — a card knows which + position it is, and that is more specific than what the page says. */ + const published = usePageContext(); + const { data: applications = [] } = useApplications(); + const { data: positions = [] } = useJobPostings(); + const { data: interviews = [] } = useInterviews(); + const { data: courses = [] } = useCourses(); + const { data: workerProfiles = [] } = useWorkerProfiles(); + /* The hires and the audit trail, for the sources that read outcomes and + events. Same caches the pages themselves render from, so a section and the + panel beside it can never disagree. */ + const { data: staff = [] } = useStaff(); + const { data: activity = [] } = useUserActivity(); + /* Who is already committed, for the sources that score people against a role: + the matching engine reads availability from assignments, and without them + everybody would look free. */ + const { data: assignments = [] } = useAssignments(); + const { data: user } = useCurrentUser(); + const { statesFor } = useWorkforcePaths(); + + const trainingPaths = useMemo( + () => statesFor(courses, profileForEmail(workerProfiles, user?.email)), + [statesFor, courses, workerProfiles, user?.email] + ); + + return useMemo(() => ({ + ...published, + ...context, + applications, + positions, + interviews, + courses, + workerProfiles, + /* The workforce engine's own name for the same records. Both keys are + published so a resolver can be written against either without a rename + rippling through every existing source. */ + profiles: workerProfiles, + assignments, + staff, + activity, + trainingPaths, + /* `published` belongs here: it is what changes when the reader edits the + form this panel is sitting beside, and leaving it out froze every + section on the first value the page ever published. */ + }), [published, context, applications, positions, interviews, courses, workerProfiles, assignments, + staff, activity, trainingPaths]); +} + +/** One declared section, resolved and drawn. */ +function SkillSection({ skill, section, context }) { + const Component = SECTION_COMPONENTS[section.type]; + const data = useMemo( + () => resolveSkillData(section, context), + [section, context] + ); + + /* How this section writes back, if the page is offering that write at all. + Null on every page that is not, which is what keeps a section declared + `editable` honest on a surface that only reads. */ + const apply = usePageAction(section.editable ? section.source : null); + + /* Validation refuses an unknown type long before this, so a missing component + means the registry and the vocabulary have drifted apart. Render nothing + rather than a broken panel. */ + if (!Component) return null; + + return ( +
+
+
+

+ {section.title || skill.name} +

+ {section.description && ( +

{section.description}

+ )} +
+ {/* Attribution, so an admin can tell an extension from a built-in panel + and knows which skill to switch off. */} + + +
+ + +
+ ); +} + +export function SkillSurface({ page, placement, context = null, className }) { + const sections = useSkillSections(page, placement); + const resolved = useSkillDataContext(context); + + if (!sections.length) return null; + + return ( +
+ {sections.map(({ skill, section }) => ( + + ))} +
+ ); +} diff --git a/src/lib/hiringRecords.js b/src/lib/hiringRecords.js new file mode 100644 index 0000000..c3b26c6 --- /dev/null +++ b/src/lib/hiringRecords.js @@ -0,0 +1,315 @@ +/** + * The hiring record, derived once. + * + * Analytics and Hired History ask different questions of the same facts — "how + * is our hiring performing" and "who did we hire, and what happened" — and they + * answer them with different pages. What they must never do is *count* + * differently: a total on one page and the same total on the other have to be + * the same number, or the two pages stop being two views and become two claims. + * + * So the counting lives here, in plain functions over the collections the app + * already holds, and each page renders what it needs from the result. Nothing in + * this module knows what either page looks like. + */ + +/** The mean of a numeric list, rounded. Zero for an empty list. */ +export const avg = (xs) => { + const values = xs.filter((n) => Number.isFinite(n)); + return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0; +}; + +/** + * Everyone hired, as one list. + * + * A Staff record is the hire; the application it came from carries how long it + * took and what it scored, and the posting carries the department. Joined here + * so neither page repeats the join — and so "department" means the same thing + * on both. + */ +export function buildHires({ staff = [], applications = [], postings = [] }) { + return staff.map((s) => { + const app = applications.find((a) => a.id === s.application_id); + const posting = postings.find((p) => p.id === s.job_posting_id); + const days = app + ? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000)) + : null; + + return { + ...s, + company: s.company || posting?.company || '—', + department: posting?.role_category || s.department || '—', + role: s.role || posting?.title || '—', + timeToHire: days || s.timeToHire || null, + score: s.ai_score || app?.ai_score || s.score || null, + profile_tier: s.profile_tier || 'skilled', + hire_date: s.hire_date || s.created_date || null, + applicationId: app?.id || s.application_id || null, + }; + }); +} + +/** + * Demo fill, carried over from the page this module was extracted from. + * + * The store seeds three Staff records; Hired History has always padded that to + * eight so the page reads as a hiring history rather than as three rows. That + * padding is pre-existing product behaviour, not something derived — it is kept + * here, named for what it is, so both pages show what the page has always shown + * and there is one list to delete when the deployment has real volume. + * + * It only ever *adds* people the store does not already have, matched on email, + * so a real hire is never shadowed by a demo one. + */ +const DEMO_FILL = [ + { id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' }, + { id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' }, + { id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' }, + { id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' }, + { id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' }, +]; + +/** The joined hires, with the demo fill applied for anyone not already on file. */ +export function hiresWithFill(sources) { + const live = buildHires(sources); + const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase())); + + const filled = [...live]; + for (const person of DEMO_FILL) { + const key = String(person.email || person.name).toLowerCase(); + if (!seen.has(key)) { + filled.push({ ...person, company: person.company || '—' }); + seen.add(key); + } + } + return filled; +} + +/** Headline figures: volume, speed, quality, and how many are still on. */ +export function summarise(hires) { + return { + total: hires.length, + speed: avg(hires.map((h) => h.timeToHire)), + quality: avg(hires.map((h) => h.score)), + active: hires.filter((h) => h.status !== 'inactive').length, + onboarding: hires.filter((h) => h.status === 'onboarding').length, + }; +} + +/** The application stages this product counts, in the order they happen. */ +export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired']; + +/** + * Applied → screened → shortlisted → interview → hired, with the pass-through + * rate and the loss at each step. + * + * Counted at-or-beyond, so a candidate who reached interview is counted as + * having been screened — a funnel that counts only the current status shows + * later stages as larger than earlier ones, which is not a funnel. + */ +export function buildFunnel(applications) { + const atOrBeyond = (stage) => { + const from = STAGE_ORDER.indexOf(stage); + return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length; + }; + + const stages = [ + { key: 'applied', label: 'Applied', count: applications.length }, + { key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') }, + { key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') }, + { key: 'interview', label: 'Interview', count: atOrBeyond('interview') }, + { key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length }, + ]; + + const transitions = stages.slice(1).map((stage, i) => { + const previous = stages[i]; + return { + from: previous.key, + to: stage.key, + rate: previous.count ? Math.round((stage.count / previous.count) * 100) : 0, + lost: Math.max(0, previous.count - stage.count), + }; + }); + + /* The step losing the most people — the one worth acting on. */ + const weakest = transitions.reduce( + (worst, t) => (!worst || t.rate < worst.rate ? t : worst), + null + ); + + return { + stages, + transitions, + weakestKey: weakest?.to || null, + conversion: applications.length + ? Math.round((stages[4].count / applications.length) * 100) + : 0, + }; +} + +/** Cumulative hires by month — a trend needs a baseline, not a single bar. */ +export function buildTrend(hires) { + const byMonth = new Map(); + hires.filter((h) => h.hire_date).forEach((h) => { + const d = new Date(h.hire_date); + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; + byMonth.set(key, (byMonth.get(key) || 0) + 1); + }); + + let running = 0; + return [...byMonth.entries()].sort().map(([key, count]) => { + running += count; + const [y, m] = key.split('-'); + return { + label: new Date(Number(y), Number(m) - 1).toLocaleDateString(undefined, { month: 'short' }), + hires: count, + cumulative: running, + }; + }); +} + +/** Hires grouped by department, best-performing first. */ +export function byDepartment(hires) { + const map = new Map(); + + hires.forEach((h) => { + const name = h.department && h.department !== '—' ? h.department : 'General'; + const entry = map.get(name) || { + name, department: name, count: 0, scores: [], times: [], rolesSet: new Set(), hiresList: [], + }; + entry.count += 1; + if (h.score) entry.scores.push(h.score); + if (h.timeToHire) entry.times.push(h.timeToHire); + if (h.role && h.role !== '—') entry.rolesSet.add(h.role); + entry.hiresList.push(h); + map.set(name, entry); + }); + + return [...map.values()] + .map((e) => ({ + name: e.name, + department: e.department, + count: e.count, + scores: e.scores, + avgScore: avg(e.scores), + avgTimeToHire: avg(e.times), + roles: [...e.rolesSet], + hiresList: e.hiresList, + })) + .sort((a, b) => (b.avgScore || 0) - (a.avgScore || 0) || b.count - a.count); +} + +/** Hires grouped by the role they were hired into, most-filled first. */ +export function byPosition(hires) { + const map = new Map(); + + hires.forEach((h) => { + const key = h.role && h.role !== '—' ? h.role : 'Unspecified'; + const entry = map.get(key) || { role: key, count: 0, scores: [], days: [], rated: 0, ratings: [] }; + entry.count += 1; + if (h.score) entry.scores.push(h.score); + if (h.timeToHire) entry.days.push(h.timeToHire); + if (h.client_rating) { entry.rated += 1; entry.ratings.push(h.client_rating); } + map.set(key, entry); + }); + + return [...map.values()] + .map((e) => ({ + role: e.role, + count: e.count, + avgScore: avg(e.scores), + avgDays: avg(e.days), + rated: e.rated, + avgRating: e.ratings.length + ? Number((e.ratings.reduce((a, b) => a + b, 0) / e.ratings.length).toFixed(1)) + : null, + })) + .sort((a, b) => b.count - a.count); +} + +/** + * Where hiring is slow, and where it is fast. + * + * Velocity is only meaningful against something, so each role is measured + * against the workspace's own median rather than an industry figure nobody + * here can check. + */ +export function buildEfficiency(hires) { + const timed = hires.filter((h) => h.timeToHire); + if (!timed.length) return { median: 0, fastest: [], slowest: [], within48h: 0 }; + + const sorted = [...timed].sort((a, b) => a.timeToHire - b.timeToHire); + const median = sorted[Math.floor(sorted.length / 2)].timeToHire; + + const roles = byPosition(timed).filter((r) => r.avgDays); + + return { + median, + fastest: [...roles].sort((a, b) => a.avgDays - b.avgDays).slice(0, 4), + slowest: [...roles].sort((a, b) => b.avgDays - a.avgDays).slice(0, 4), + within48h: Math.round((timed.filter((h) => h.timeToHire <= 2).length / timed.length) * 100), + }; +} + +/** + * What the numbers say, as findings rather than figures. + * + * Every one is conditional on the data supporting it: a claim about the fastest + * department is only made when there is more than one department to compare, and + * a risk is only raised when something is actually at risk. A page that always + * shows three insights is showing decoration. + */ +export function buildInsights({ hires, departments, positions, funnel, efficiency }) { + const out = []; + const summary = summarise(hires); + + if (departments.length > 1) { + const best = departments[0]; + out.push({ + tone: 'success', + title: `${best.department} is hiring the strongest candidates`, + body: `${best.count} hire${best.count === 1 ? '' : 's'} at an average score of ${best.avgScore}, against ${summary.quality} across the workspace.`, + }); + } + + if (funnel.weakestKey) { + const weak = funnel.transitions.find((t) => t.to === funnel.weakestKey); + const label = funnel.stages.find((s) => s.key === funnel.weakestKey)?.label; + if (weak && weak.lost > 0) { + out.push({ + tone: 'warning', + title: `The largest drop-off is into ${label}`, + body: `${weak.rate}% pass through and ${weak.lost} candidate${weak.lost === 1 ? '' : 's'} stop there. It is the step with the most to recover.`, + }); + } + } + + if (efficiency.slowest.length && efficiency.median) { + const slow = efficiency.slowest[0]; + if (slow.avgDays > efficiency.median) { + out.push({ + tone: 'risk', + title: `${slow.role} takes longest to fill`, + body: `${slow.avgDays} days on average against a median of ${efficiency.median}. ${slow.count} hire${slow.count === 1 ? '' : 's'} on that record.`, + }); + } + } + + const unrated = positions.reduce((n, p) => n + (p.count - p.rated), 0); + if (unrated > 0) { + out.push({ + tone: 'info', + title: `${unrated} hire${unrated === 1 ? '' : 's'} ${unrated === 1 ? 'has' : 'have'} no client review`, + body: 'Quality of hire is measured on the AI score alone until a review lands. Chasing these closes the loop on outcomes.', + }); + } + + if (funnel.conversion) { + out.push({ + tone: 'info', + title: `${funnel.conversion}% of applicants are hired`, + body: `${funnel.stages[4].count} of ${funnel.stages[0].count} applications reached a hire.`, + }); + } + + return out; +} diff --git a/src/lib/skills/actions.js b/src/lib/skills/actions.js index ecce915..a3e8bb5 100644 --- a/src/lib/skills/actions.js +++ b/src/lib/skills/actions.js @@ -432,9 +432,12 @@ const HANDLERS = { * Reached only from the confirmation step, after the position has been read * back and the user has chosen to create it. */ - create_position: ({ draft }) => ({ + /* `status` is the one the conversation's confirmation step chose — draft or + active — and is passed to the payload builder the form already uses, so + both routes write the same record with the same defaults. */ + create_position: ({ draft, status }) => ({ type: 'create_position', - data: toPositionPayload(draft || {}), + data: toPositionPayload(draft || {}, status ? { status } : undefined), }), /** diff --git a/src/lib/skills/customSkills.js b/src/lib/skills/customSkills.js index 5718be3..825fc81 100644 --- a/src/lib/skills/customSkills.js +++ b/src/lib/skills/customSkills.js @@ -9,30 +9,132 @@ import { parseSkill } from './registry'; * writers with two shapes would be a second skill system by accident. */ -/** The starting definition offered to an author, in the existing format. */ -export const skillTemplate = ({ id = '', name = '', description = '', pages = [] } = {}) => `--- -id: ${id || 'my-skill'} -name: ${name || 'My Skill'} -description: ${description || 'What this skill helps Owliver do.'} -pages: -${(pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n')} -status: active -triggers: - - ${(name || 'my skill').toLowerCase()} ---- +/** + * The starting definitions offered to an author. + * + * Two templates, because there are two jobs and one of them was being learned + * from the other's example. A UI skill's first draft declares a section; an + * Owliver skill's declares triggers and the shapes of an answer. Both are the + * same format, read by the same parser — what differs is which half of it the + * author is being handed. + */ -# ${name || 'My Skill'} +const frontMatter = ({ id, name, description, pages, fallback }) => [ + `id: ${id || fallback.id}`, + `name: ${name || fallback.name}`, + `description: ${description || fallback.description}`, + 'pages:', + (pages?.length ? pages : ['positions']).map((p) => ` - ${p}`).join('\n'), + 'status: active', +].join('\n'); + +/** A definition that draws a section on the pages it names. */ +export const uiSkillTemplate = ({ + id = '', name = '', description = '', pages = [], + type = 'flow', placement = '', source = 'position.activity', periods = [], +} = {}) => `--- +${frontMatter({ + id, + name, + description, + pages, + fallback: { + id: 'my-ui-skill', + name: 'My UI Skill', + description: 'What this skill adds to the page.', + }, + })} +ui: + type: ${type} +${placement ? ` placement: ${placement}\n` : ''} title: ${name || 'My UI Skill'} + source: ${source} +${periods.length ? ` periods:\n${periods.map((p) => ` - ${p}`).join('\n')}\n` : ''}--- + +# ${name || 'My UI Skill'} ## Purpose -Describe what Owliver should help with on these pages. +Describe what this section shows, and why it belongs on these pages. ## Capabilities -- Describe one thing the skill can do. +- Describe one thing the section reports. - Add more as needed. `; +/** A definition that teaches Owliver what it can be asked for. */ +export const owliverSkillTemplate = ({ + id = '', name = '', description = '', pages = [], + triggers = [], suggestions = [], capabilities = [], source = '', periods = [], +} = {}) => { + const label = name || 'My Owliver Skill'; + const lines = [`--- +${frontMatter({ + id, + name, + description, + pages, + fallback: { + id: 'my-owliver-skill', + name: label, + description: 'What this skill helps Owliver answer.', + }, + })}`]; + + lines.push('triggers:'); + lines.push((triggers.length ? triggers : [label.toLowerCase()]).map((t) => ` - ${t}`).join('\n')); + + lines.push('owliver:'); + lines.push(' enabled: true'); + + if (suggestions.length) { + lines.push(' suggestions:'); + lines.push(suggestions.map((s) => ` - ${s}`).join('\n')); + } + + if (capabilities.length) { + lines.push(' capabilities:'); + lines.push(capabilities.map((c) => ` - ${c}`).join('\n')); + + if (source) { + lines.push(' responses:'); + for (const capability of capabilities) { + lines.push(` ${capability}:`); + lines.push(` source: ${source}`); + if (periods.length) { + lines.push(' periods:'); + lines.push(periods.map((p) => ` - ${p}`).join('\n')); + } + } + } + } + + lines.push(`--- + +# ${label} + +## Purpose + +Describe what Owliver should be able to answer on these pages. + +## Capabilities + +- Describe one thing Owliver can be asked for. +- Add more as needed. +`); + + return lines.join('\n'); +}; + +/** + * The template the Add Skill dialog offers. + * + * That dialog opens from Owliver's own header, mid-conversation, so what it + * hands the author is an Owliver skill. Kept under its original name because + * it is what the dialog already imports. + */ +export const skillTemplate = owliverSkillTemplate; + /** Parses a stored entry, tolerating the bare-string form. */ const sourceOf = (entry) => (typeof entry === 'string' ? entry : entry?.raw ?? ''); diff --git a/src/lib/skills/dataResolver.js b/src/lib/skills/dataResolver.js new file mode 100644 index 0000000..84bfc73 --- /dev/null +++ b/src/lib/skills/dataResolver.js @@ -0,0 +1,516 @@ +import { SUPPORTED_PERIODS, periodLabel } from './surfaces'; +import { CRITERIA_LABELS } from '@/lib/positionModel'; +import { poolFor } from '@/lib/workforce'; +import { candidateRoute } from './workforceFlow'; + +/** + * The one place a skill's declared data source becomes real data. + * + * A section says `data.source: position.activity`. It does not say where that + * comes from, cannot reach a store, and cannot name a field. This module owns + * the mapping — source id in, normalized reading out — which is what keeps a + * declarative file from turning into a query language. + * + * Two rules hold throughout: + * + * - **Real records only.** Every figure is counted from the collections the + * application already holds. Nothing is generated to make a section look + * populated; a source with nothing to report returns `empty: true` and the + * renderer says so. + * - **Time is computed, never stored.** "Today" is a window over the record + * timestamps, evaluated against the current date at read time. No date is + * written into a definition and none is hard-coded here. + */ + +const DAY = 24 * 60 * 60 * 1000; + +/** Midnight at the start of the given day, in local time. */ +const startOfDay = (date) => { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +}; + +/** + * The window a period covers, as `[from, to)`. + * + * Weeks run Monday to Monday and months from the 1st, which is how the rest of + * the product reports them. + */ +export function periodRange(period, now = new Date()) { + const today = startOfDay(now); + + switch (period) { + case 'today': + return { from: today, to: new Date(today.getTime() + DAY) }; + case 'yesterday': + return { from: new Date(today.getTime() - DAY), to: today }; + case 'last-7-days': + return { from: new Date(today.getTime() - 7 * DAY), to: new Date(today.getTime() + DAY) }; + case 'last-week': { + /* The calendar week before the one we are in. */ + const weekday = (today.getDay() + 6) % 7; + const thisMonday = new Date(today.getTime() - weekday * DAY); + return { from: new Date(thisMonday.getTime() - 7 * DAY), to: thisMonday }; + } + case 'this-month': { + const from = new Date(today.getFullYear(), today.getMonth(), 1); + return { from, to: new Date(today.getFullYear(), today.getMonth() + 1, 1) }; + } + case 'previous-month': { + const from = new Date(today.getFullYear(), today.getMonth() - 1, 1); + return { from, to: new Date(today.getFullYear(), today.getMonth(), 1) }; + } + default: + return null; + } +} + +/** Records whose `created_date` falls inside the window. */ +const inPeriod = (records, period, now) => { + const range = periodRange(period, now); + if (!range) return []; + return records.filter((record) => { + const at = new Date(record.created_date || record.updated_date || 0).getTime(); + return at >= range.from.getTime() && at < range.to.getTime(); + }); +}; + +/** + * Why a matched candidate fits, in one line. + * + * Met requirements first, then the gaps, then what is known about availability — + * the same order and the same words the panel's match cards use, because they + * are read from the same row. A gap is stated as a gap: a line that only listed + * strengths would make every candidate look like a strong match. + */ +function matchDetail(row) { + if (!row.match) return 'Nothing on this position to score this candidate against'; + + const parts = [ + ...row.match.met.map((line) => `✓ ${line.name} — ${line.heldLabel}`), + ...row.match.gaps.map((line) => `⚠ ${line.name} — ${line.heldLabel}, needs ${line.requiredLabel}`), + ]; + + if (row.availability?.known === false) parts.push('Availability not on file'); + else if (row.availability) { + parts.push(row.availability.available ? '✓ Available when this starts' : `✕ ${row.availability.reason}`); + } + + return parts.join(' · '); +} + +/** The application stages this product counts, in the order they happen. */ +const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired']; + +const atOrBeyond = (applications, stage) => { + const from = STAGE_ORDER.indexOf(stage); + return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from); +}; + +/** Applications counted by period — the reading behind an activity section. */ +function activityOverTime(applications, periods, now) { + const wanted = periods.length ? periods : ['today', 'yesterday', 'last-week']; + + const steps = wanted + .filter((period) => SUPPORTED_PERIODS.includes(period)) + .map((period) => { + const records = inPeriod(applications, period, now); + return { + id: period, + label: periodLabel(period), + value: records.length, + detail: records.length + ? `${records.length} application${records.length === 1 ? '' : 's'}` + : 'No applications', + records, + }; + }); + + return { + steps, + total: applications.length, + empty: steps.every((s) => s.value === 0), + emptyNote: applications.length + ? 'No applications in these periods.' + : 'No applications on this position yet.', + }; +} + +/** The hiring funnel for a set of applications. */ +function pipelineOf(applications) { + const steps = [ + { id: 'applied', label: 'Applied', value: applications.length }, + { id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length }, + { id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length }, + { id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length }, + { id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length }, + ]; + + return { + steps, + total: applications.length, + empty: applications.length === 0, + emptyNote: 'No applications on this position yet.', + }; +} + +/** + * Every source the vocabulary offers, and how each is read. + * + * Keyed by the same ids `surfaces.js` publishes, so the list an author can + * choose from and the list that can actually be resolved are the same list. + */ +const RESOLVERS = { + 'position.activity': ({ position, applications }, section, now) => { + const mine = applications.filter((a) => a.job_posting_id === position?.id); + return activityOverTime(mine, section.periods, now); + }, + + 'position.pipeline': ({ position, applications }) => + pipelineOf(applications.filter((a) => a.job_posting_id === position?.id)), + + 'position.candidates': ({ position, applications }, section) => { + const mine = applications + .filter((a) => a.job_posting_id === position?.id) + .sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0)) + .slice(0, section.limit || 5); + + return { + items: mine.map((a) => ({ + id: a.id, + title: a.applicant_name, + detail: [ + a.years_experience != null ? `${a.years_experience} yrs experience` : null, + String(a.status || '').replace(/_/g, ' '), + ].filter(Boolean).join(' · '), + value: a.ai_score > 0 ? a.ai_score : null, + to: `/admin/candidates/${a.id}`, + })), + columns: [ + { key: 'title', label: 'Candidate' }, + { key: 'detail', label: 'Status' }, + { key: 'value', label: 'Score', align: 'right' }, + ], + empty: mine.length === 0, + emptyNote: 'No candidates have applied to this position yet.', + }; + }, + + /** + * The candidate pool, scored against this position. + * + * Every figure here comes from `poolFor` — the same engine behind the + * workforce conversation and the position page's own recommendations. This + * resolver ranks nothing and scores nothing; it reads the rows the engine + * returned and states them, including *why* each one fits, in the engine's own + * terms. A candidate the position gives nothing to measure against is reported + * as unscored rather than given a number. + * + * `to` is the candidate's real record, carrying the position they were being + * considered for — the same route the panel's match cards open. + */ + 'position.matches': ({ position, ...context }, section) => { + if (!position) { + return { items: [], empty: true, emptyNote: 'No position to match candidates against.' }; + } + + const rows = poolFor(position, { + profiles: context.profiles || context.workerProfiles || [], + applications: context.applications || [], + assignments: context.assignments || [], + courses: context.courses || [], + staff: context.staff || [], + }).slice(0, section.limit || 5); + + return { + items: rows.map((row) => ({ + id: row.candidateId, + title: row.name, + /* The met requirements and the gaps, as the engine stated them. */ + detail: matchDetail(row), + value: row.scored ? row.score : null, + to: candidateRoute(row, context.applications || [], position), + })), + columns: [ + { key: 'title', label: 'Candidate' }, + { key: 'detail', label: 'Why' }, + { key: 'value', label: 'Match', align: 'right' }, + ], + empty: rows.length === 0, + emptyNote: 'No candidates on file to score against this position yet.', + }; + }, + + 'position.requirements': ({ position }) => { + const items = [ + position?.min_experience_years + ? { id: 'experience', title: 'Minimum experience', detail: `${position.min_experience_years} years` } + : null, + position?.english_required + ? { id: 'english', title: 'English level', detail: String(position.english_required) } + : null, + ...(position?.certifications_required || []).map((c) => ({ + id: `cert-${c}`, title: 'Certification', detail: c, + })), + ...(position?.skill_requirements || []).map((r) => ({ + id: `skill-${r.skill_id}`, title: r.skill_id, detail: `${r.level} · weight ${r.weight}`, + })), + ].filter(Boolean); + + return { + items, + columns: [{ key: 'title', label: 'Requirement' }, { key: 'detail', label: 'Needs' }], + empty: items.length === 0, + emptyNote: 'This position states no requirements.', + }; + }, + + 'candidate.readiness': ({ candidate }) => { + const breakdown = candidate?.score_breakdown || {}; + const items = Object.entries(breakdown) + .filter(([, value]) => Number(value) > 0) + .map(([key, value]) => ({ + id: key, + title: key.replace(/_/g, ' '), + value: Number(value), + max: 100, + })); + + return { + items, + columns: [{ key: 'title', label: 'Dimension' }, { key: 'value', label: 'Score', align: 'right' }], + empty: items.length === 0, + emptyNote: 'This candidate has not been screened, so there are no dimensions to show.', + }; + }, + + 'candidate.activity': ({ candidate, interviews = [] }) => { + const events = [ + candidate?.created_date && { + id: 'applied', title: 'Applied', detail: candidate.job_title, at: candidate.created_date, + }, + candidate?.ai_score > 0 && { + id: 'screened', title: 'AI screened', detail: `Scored ${candidate.ai_score}`, at: candidate.updated_date, + }, + ...interviews + .filter((i) => i.application_id === candidate?.id) + .map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })), + candidate?.status === 'hired' && { + id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date, + }, + ].filter(Boolean); + + return { + items: events, + empty: events.length === 0, + emptyNote: 'Nothing has happened on this record yet.', + }; + }, + + 'candidates.pipeline': ({ applications }) => pipelineOf(applications), + + 'candidates.activity': ({ applications }, section, now) => + activityOverTime(applications, section.periods, now), + + 'positions.demand': ({ positions = [], applications }, section) => { + const items = positions + .filter((p) => p.status === 'active') + .slice(0, section.limit || 5) + .map((p) => { + const mine = applications.filter((a) => a.job_posting_id === p.id); + return { + id: p.id, + title: p.title, + detail: [p.company, p.location].filter(Boolean).join(' · '), + value: mine.length, + to: `/admin/positions/${p.id}`, + }; + }); + + return { + items, + columns: [ + { key: 'title', label: 'Position' }, + { key: 'detail', label: 'Client' }, + { key: 'value', label: 'Applicants', align: 'right' }, + ], + empty: items.length === 0, + emptyNote: 'No open positions.', + }; + }, + + 'workforce.training': ({ trainingPaths = [] }, section) => { + const items = trainingPaths.slice(0, section.limit || 10).map(({ definition, state }) => ({ + id: definition.id, + title: state.name, + detail: state.verifiedLabel, + value: state.totalModules ? Math.round((state.totalCompleted / state.totalModules) * 100) : 0, + max: 100, + })); + + return { + items, + columns: [ + { key: 'title', label: 'Path' }, + { key: 'detail', label: 'Level' }, + { key: 'value', label: 'Complete', align: 'right' }, + ], + empty: items.length === 0, + emptyNote: 'No training paths are registered.', + }; + }, + + /** + * The vetting weights on the position in context. + * + * The same reading whether that position is a saved record or the draft being + * typed into Create Position — both carry `vetting_criteria`, so a section + * declared once reports the specification as it stands on either surface. + */ + 'position.vetting': ({ position }) => { + const criteria = position?.vetting_criteria || {}; + const steps = Object.entries(criteria).map(([key, value]) => ({ + id: key, + label: CRITERIA_LABELS[key] || key.replace(/_/g, ' '), + title: CRITERIA_LABELS[key] || key.replace(/_/g, ' '), + value: Number(value) || 0, + max: 100, + detail: `${Number(value) || 0}% of the screening score`, + })); + + const total = steps.reduce((sum, s) => sum + s.value, 0); + + return { + steps, + items: steps, + total, + columns: [ + { key: 'title', label: 'Criterion' }, + { key: 'value', label: 'Weight', align: 'right' }, + ], + empty: steps.length === 0, + emptyNote: 'This position states no vetting weights.', + }; + }, + + /** Everyone hired, most recent first — the record rather than the analysis. */ + 'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => { + const items = [...staff] + .sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0)) + .slice(0, section.limit || 10) + .map((s) => { + const app = applications.find((a) => a.id === s.application_id); + const posting = positions.find((p) => p.id === s.job_posting_id); + return { + id: s.id, + title: s.name, + detail: [s.role || posting?.title, posting?.role_category || s.department] + .filter(Boolean).join(' · '), + value: s.ai_score || app?.ai_score || s.score || null, + at: s.hire_date || s.created_date || null, + }; + }); + + return { + items, + columns: [ + { key: 'title', label: 'Hire' }, + { key: 'detail', label: 'Role' }, + { key: 'value', label: 'Score', align: 'right' }, + ], + empty: items.length === 0, + emptyNote: 'Nobody has been hired yet.', + }; + }, + + /** + * The four figures that answer "how is our hiring performing" — counted from + * applications and the staff records they became, never stored. + */ + 'hires.performance': ({ applications = [], staff = [] }) => { + const hired = applications.filter((a) => a.status === 'hired'); + const scores = applications.map((a) => a.ai_score).filter((n) => n > 0); + const days = hired + .map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000)) + .filter((n) => Number.isFinite(n) && n >= 0); + + const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0); + const total = staff.length || hired.length; + + const steps = [ + { id: 'hires', label: 'Total hires', title: 'Total hires', value: total }, + { id: 'speed', label: 'Avg days to hire', title: 'Avg days to hire', value: mean(days) }, + { id: 'quality', label: 'Quality of hire', title: 'Quality of hire', value: mean(scores) }, + { + id: 'conversion', + label: 'Conversion rate', + title: 'Conversion rate', + value: applications.length ? Math.round((hired.length / applications.length) * 100) : 0, + }, + ]; + + return { + steps, + items: steps, + total, + columns: [ + { key: 'title', label: 'Measure' }, + { key: 'value', label: 'Value', align: 'right' }, + ], + empty: applications.length === 0 && total === 0, + emptyNote: 'No hiring activity has been recorded yet.', + }; + }, + + /** The workspace audit trail, most recent first. */ + 'activity.events': ({ activity = [] }, section) => { + const items = [...activity] + .sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0)) + .slice(0, section.limit || 10) + .map((event) => ({ + id: event.id, + title: String(event.event_type || 'event').replace(/_/g, ' '), + detail: [event.user_name, event.details].filter(Boolean).join(' — '), + at: event.created_date, + })); + + return { + items, + columns: [ + { key: 'title', label: 'Event' }, + { key: 'detail', label: 'Who' }, + ], + empty: items.length === 0, + emptyNote: 'No activity has been recorded yet.', + }; + }, +}; + +/** + * One section's data, read from the application's own records. + * + * `context` is what the page supplies — the position or candidate being looked + * at, plus the collections it already loaded. A source whose required context + * is missing returns `unavailable`, which the renderer states rather than + * filling in. + */ +export function resolveSkillData(section, context = {}, now = new Date()) { + const resolve = RESOLVERS[section?.source]; + if (!resolve) return { unavailable: true, emptyNote: `No resolver for ${section?.source}.` }; + + if (section.context === 'positionId' && !context.position) { + return { unavailable: true, emptyNote: 'This section needs a position to read.' }; + } + if (section.context === 'candidateId' && !context.candidate) { + return { unavailable: true, emptyNote: 'This section needs a candidate to read.' }; + } + + try { + return resolve(context, section, now); + } catch { + /* A resolver that throws is a bug in this file, not in the definition — + the section reports it has nothing rather than taking the page down. */ + return { unavailable: true, emptyNote: 'This section could not be read.' }; + } +} diff --git a/src/lib/skills/owliverConfig.js b/src/lib/skills/owliverConfig.js new file mode 100644 index 0000000..81dc9e7 --- /dev/null +++ b/src/lib/skills/owliverConfig.js @@ -0,0 +1,247 @@ +import { normalizeSection } from './uiConfig'; +import { + SUPPORTED_OWLIVER_CAPABILITIES, SUPPORTED_SECTION_TYPES, owliverCapabilityFor, +} from './surfaces'; + +/** + * The `owliver:` block of a skill definition, checked and normalized. + * + * The same file that extends a page can extend the panel beside it. `ui:` says + * what the page renders; `owliver:` says what can be *asked for*, and both name + * the same data source — so the card and the answer are two readings of one + * declaration rather than two definitions that have to be kept in step. + * + * owliver: + * enabled: true + * suggestions: + * - Show hiring activity + * - Summarize hiring activity + * capabilities: + * - summary + * - flow + * responses: + * flow: + * title: Hiring Activity Flow + * source: position.activity + * steps: [today, yesterday, last-week] + * + * Three properties hold, and each one is a rule the rest of the system relies + * on: + * + * - **A response is a section.** It is normalized by the same function the + * page's `ui:` sections go through, so a capability resolves to the same + * record, the same data source and the same renderer. There is no second + * shape for "the chat version". + * - **The block is optional.** A definition with no `owliver:` normalizes to + * a disabled record and behaves exactly as it did before this existed. + * - **Nothing unknown survives.** Capabilities, sources, periods and shapes + * are checked against the closed vocabulary in `surfaces.js`; an + * unrecognised value is a named error rather than a dropped key. + */ + +/** What a definition with no `owliver:` block gets. */ +export const NO_OWLIVER = Object.freeze({ + enabled: false, + suggestions: [], + capabilities: [], + responses: {}, +}); + +/** The section type a capability is drawn with — `summary` draws nothing. */ +const shapeOf = (capability) => owliverCapabilityFor(capability)?.shape || null; + +/** + * The reading a response inherits when it does not name one. + * + * A skill that already declares a `ui:` section has stated its source once; + * making it state it again for the panel would be the format asking the author + * to repeat themselves, and would let the two drift apart. The first declared + * section wins, in declaration order. + */ +function inheritedSection(ui) { + for (const page of Object.values(ui || {})) { + const section = (page.sections || [])[0]; + if (section) return section; + } + return null; +} + +/** One suggestion, in either the plain-string or the mapping form. */ +function normalizeSuggestion(raw, { errors, capabilities, index }) { + const where = `owliver.suggestions[${index}]`; + + if (typeof raw === 'string' || typeof raw === 'number') { + const label = String(raw).trim(); + if (!label) { + errors.push(`${where}: a suggestion needs text.`); + return null; + } + return { label, prompt: label, capability: null }; + } + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push(`${where}: a suggestion must be a line of text, or a mapping of options.`); + return null; + } + + const label = String(raw.label ?? raw.prompt ?? '').trim(); + if (!label) { + errors.push(`${where}: a suggestion needs a \`label\`.`); + return null; + } + + /* A suggestion may say which capability it asks for. That is what makes a + chip exact — the words are the author's, and the answer is not left to be + re-derived from them. */ + const capability = raw.capability == null ? null : String(raw.capability).trim(); + if (capability && !SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) { + errors.push( + `Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.` + ); + return null; + } + if (capability && capabilities.length && !capabilities.includes(capability)) { + errors.push(`${where}: \`${capability}\` is not listed under \`owliver.capabilities\`.`); + return null; + } + + return { + label, + prompt: String(raw.prompt ?? raw.label).trim(), + capability: capability || null, + }; +} + +/** + * The whole `owliver:` block, normalized. + * + * Returns `{ owliver, errors }`. As with `ui:`, what validates is kept and what + * does not is reported: a definition with one bad response still registers its + * good ones, and the author is told why the other was refused. + */ +export function normalizeSkillOwliver(raw, { ui = {}, skillId = '', skillName = '' } = {}) { + const errors = []; + + if (raw == null) return { owliver: NO_OWLIVER, errors }; + + if (typeof raw !== 'object' || Array.isArray(raw)) { + return { owliver: NO_OWLIVER, errors: ['`owliver` must be a mapping of options.'] }; + } + + /* Declaring the block is the opt-in; `enabled: false` is how it is switched + off without deleting what was written. */ + const enabled = raw.enabled !== false; + + /* Capabilities may be listed, or left to be read off the responses — which is + what a definition that writes one response and nothing else means. */ + const declared = Array.isArray(raw.capabilities) + ? raw.capabilities.map((c) => String(c).trim()).filter(Boolean) + : []; + const responsesRaw = raw.responses && typeof raw.responses === 'object' && !Array.isArray(raw.responses) + ? raw.responses + : {}; + if (raw.responses != null && !Object.keys(responsesRaw).length) { + errors.push('`owliver.responses` must be a mapping of capability names to responses.'); + } + + const capabilities = []; + for (const capability of [...declared, ...Object.keys(responsesRaw)]) { + if (!SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)) { + errors.push( + `Unsupported Owliver capability: ${capability}. Supported capabilities: ${SUPPORTED_OWLIVER_CAPABILITIES.join(', ')}.` + ); + continue; + } + if (!capabilities.includes(capability)) capabilities.push(capability); + } + + /* Suggestions are read after capabilities, so one naming a capability can be + checked against what the skill actually offers. */ + const suggestionsRaw = raw.suggestions == null ? [] : raw.suggestions; + let suggestions = []; + if (!Array.isArray(suggestionsRaw)) { + errors.push('`owliver.suggestions` must be a list.'); + } else { + suggestions = suggestionsRaw + .map((entry, index) => normalizeSuggestion(entry, { errors, capabilities, index })) + .filter(Boolean); + } + + /* Every capability resolves to a section — declared, or inherited from the + page UI this skill already configures. A capability that can name no + reading is refused: it would otherwise register as something Owliver + offers and then have nothing to answer with. */ + const inherited = inheritedSection(ui); + const responses = {}; + const seen = new Set(); + + for (const capability of capabilities) { + const declaredResponse = responsesRaw[capability]; + if (declaredResponse != null + && (typeof declaredResponse !== 'object' || Array.isArray(declaredResponse))) { + errors.push(`owliver.responses.${capability}: expected a mapping of options.`); + continue; + } + + const response = declaredResponse || {}; + /* `steps:` is what a flow reads like in a definition; `periods:` is what + the rest of the format calls the same list. */ + const periods = response.steps ?? response.periods ?? (declaredResponse ? null : inherited?.periods); + const source = response.data?.source ?? response.source ?? inherited?.source; + + if (!source) { + errors.push( + `owliver.responses.${capability}: a response needs a \`source\`, or a \`ui:\` section to read from.` + ); + continue; + } + + const section = normalizeSection( + { + id: `${skillId || 'skill'}-${capability}`, + type: capability, + title: response.title ?? null, + description: response.description ?? null, + source, + periods: periods ?? [], + limit: response.limit ?? inherited?.limit ?? null, + /* Editing is a property of the capability, and is inherited from the + page section the same way the source is: a definition that made its + card adjustable meant the answer to be adjustable too, unless it + says otherwise. */ + editable: response.editable ?? (declaredResponse ? false : inherited?.editable) ?? false, + }, + { + errors, + seen, + where: `owliver.responses.${capability}`, + fallbackId: skillId, + placement: false, + types: [...SUPPORTED_SECTION_TYPES, 'summary'], + shapeFor: shapeOf, + } + ); + + if (!section) continue; + + responses[capability] = { + ...section, + capability, + /* The shape the answer is drawn with, resolved once here so no consumer + has to know that `summary` is the one capability with no component. */ + shape: shapeOf(capability), + title: section.title || skillName || null, + }; + } + + return { + owliver: { + enabled, + suggestions, + /* Only capabilities that resolved to a reading are offered. */ + capabilities: capabilities.filter((c) => responses[c]), + responses, + }, + errors, + }; +} diff --git a/src/lib/skills/owliverResolver.js b/src/lib/skills/owliverResolver.js new file mode 100644 index 0000000..9728e38 --- /dev/null +++ b/src/lib/skills/owliverResolver.js @@ -0,0 +1,416 @@ +import { OWLIVER_CAPABILITIES, dataSourceLabel, owliverCapabilityFor } from './surfaces'; +import { skillsForContext } from './registry'; +import { resolveSkillData } from './dataResolver'; +import { resolvePosition } from './workforceFlow'; + +/** + * The Owliver half of a skill definition, resolved. + * + * The page reads a definition through `SkillSurface`; this is the other reader. + * Given the page you are on and what you asked, it answers three questions and + * nothing else: + * + * 1. which registered skills apply here, + * 2. which of them you are asking for, and which capability, + * 3. what the answer is, read from the source the definition names. + * + * The rule that makes this an architecture rather than a lookup table: **no + * skill is named here.** There is no `if (skill.id === …)`, no prompt string + * matched against a constant, and no component per skill. A definition is + * matched by what it declares — its triggers, its suggestions, its name, its + * description — and answered by the shape it declares. A skill written after + * this file was last edited resolves exactly as well as one written before it. + */ + +/* ── Which skills apply ─────────────────────────────────────────────────── */ + +/** + * The Owliver-enabled skills registered for a page context. + * + * `skillsForContext` is the single answer to "what is attached here", already + * honouring `pages:`, `status:` and the account's switched-off list — so the + * page's sections and the panel's answers are filtered by one rule, and + * switching a skill off in Settings removes both at once. + */ +export function owliverSkillsForContext(contextId, disabled = [], customSources = []) { + return skillsForContext(contextId, disabled, customSources) + .filter((skill) => skill.owliver?.enabled && skill.owliver.capabilities.length > 0); +} + +/* ── Suggestions ────────────────────────────────────────────────────────── */ + +/** How many chips one skill may contribute, and how many all of them may. */ +const PER_SKILL = 3; +const TOTAL = 4; + +/** + * The chips a page's skills offer. + * + * Capped deliberately. A workspace with six skills attached would otherwise + * bury the page's own suggestions under twenty of them, and a suggestion nobody + * can find is not a suggestion. A skill contributes its first few, and the set + * as a whole stays within what the composer can show without becoming a menu. + * + * A suggestion naming a capability the skill does not offer is dropped rather + * than shown and then refused. + */ +export function owliverSuggestions(contextId, disabled = [], customSources = [], context = {}) { + const chips = []; + + for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) { + /** + * Can this skill answer without asking a question back? + * + * A capability reading one position cannot say anything until it knows + * which — so on a page with nothing selected, clicking it produces a + * question rather than an answer. Those suggestions are still offered, but + * they are marked so the panel can rank them behind the ones that will + * actually answer. That is a property of the declared source, not of any + * particular skill: a definition added tomorrow reading one record is + * ranked the same way. + */ + const needs = (capability) => skill.owliver.responses[capability]?.context || null; + const met = (need) => !need + || (need === 'positionId' && Boolean(context.position)) + || (need === 'candidateId' && Boolean(context.candidate)); + + const offered = skill.owliver.suggestions + .filter((s) => !s.capability || skill.owliver.capabilities.includes(s.capability)) + .slice(0, PER_SKILL) + /* Deliberately not carried as `capability`: a chip with that field is one + of the *page's* own answers and bypasses routing entirely. A skill's + chip is an ordinary question, and is resolved the same way the same + words typed by hand would be — one path, so a chip can never answer + something the typed form would not. */ + .map((s) => { + const need = needs(s.capability || skill.owliver.capabilities[0]); + return { + label: s.label, + prompt: s.prompt, + skillId: skill.id, + skillCapability: s.capability || null, + /* True when clicking this would have to ask which record first. */ + deferred: !met(need), + }; + }); + chips.push(...offered); + } + + /* Answerable suggestions first, then the ones that would ask a question + back — stable within each group, so a definition's own order is kept. */ + const ready = chips.filter((c) => !c.deferred); + const asking = chips.filter((c) => c.deferred); + return [...ready, ...asking].slice(0, TOTAL); +} + +/* ── Matching ───────────────────────────────────────────────────────────── */ + +const lower = (value) => String(value ?? '').toLowerCase(); + +/** Words worth matching on — the ones that carry the subject of a question. */ +const STOP_WORDS = new Set([ + 'the', 'a', 'an', 'this', 'that', 'these', 'those', 'my', 'our', 'is', 'are', 'was', 'were', + 'show', 'me', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or', 'with', 'what', 'how', 'can', + 'you', 'i', 'it', 'please', 'give', 'tell', 'about', 'here', 'now', 'current', 'currently', +]); + +const words = (value) => lower(value).split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP_WORDS.has(w)); + +/** + * Does a declared trigger match? `*` stands for anything in between, the same + * way the registry's own trigger matching reads it. + */ +function triggerMatches(trigger, question) { + if (!trigger.includes('*')) return question.includes(trigger); + const pattern = trigger + .split('*') + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('[\\s\\S]{0,40}?'); + return new RegExp(pattern).test(question); +} + +/** + * How strongly a question asks for this skill. + * + * Evidence is weighted by how deliberate it is. A suggestion the author wrote + * and the reader clicked is the strongest signal there is; a declared trigger + * is next; the skill's own name is next; and shared words with its description + * are the weakest — enough to break a tie, never enough to win on their own. + */ +function scoreSkill(skill, question) { + const q = lower(question); + + const suggestion = skill.owliver.suggestions.find((s) => lower(s.prompt) === q || lower(s.label) === q); + if (suggestion) return { score: 100, suggestion }; + + /** + * Deliberate evidence: the author said this skill answers this. + * + * A suggestion the reader is echoing, a declared trigger, or the skill's own + * name. One of these must hold before a definition may claim a question at + * all — see the floor below. + */ + let deliberate = 0; + if (skill.owliver.suggestions.some((s) => q.includes(lower(s.label)))) deliberate += 40; + if (skill.triggers.some((t) => triggerMatches(t, q))) deliberate += 30; + if (skill.name && q.includes(lower(skill.name))) deliberate += 20; + + /** + * The floor. Sharing a word with a description is not a claim. + * + * This is the bug that let "create a position" be answered by a candidate + * matching skill: its description happened to contain the word "position", + * which scored three points, and three points beat nothing. Corroboration + * was being treated as evidence. + * + * So description overlap can now only *break a tie* between definitions that + * already named the subject, and can never qualify one on its own. A skill + * about candidates cannot claim a question about creating a role however its + * description happens to be worded — which is the general property, not a + * fix aimed at these two definitions. + */ + if (!deliberate) return { score: 0, suggestion: null }; + + const overlap = words(skill.description).filter((w) => q.includes(w)).length; + return { score: deliberate + Math.min(overlap * 3, 9), suggestion: null }; +} + +/** The capability a question asks for, from the shape words it uses. */ +function scoreCapability(capability, question) { + const q = lower(question); + const definition = owliverCapabilityFor(capability); + if (!definition) return 0; + + /* Longest matching term wins, so "as a flow" beats "flow" and a question + naming two shapes resolves to the more explicit one. */ + return definition.terms.reduce( + (best, term) => (q.includes(term) && term.length > best ? term.length : best), + 0 + ); +} + +/** + * The skill and capability a question resolves to, or null. + * + * Both halves have to hold: a question that names no registered skill is not + * this system's to answer, and a skill matched with no capability falls back to + * the first one its definition declares — which is what makes "Show hiring + * activity" work without the author writing a trigger per shape. + */ +export function matchOwliverSkill(question, skills = []) { + let best = null; + + for (const skill of skills) { + const { score, suggestion } = scoreSkill(skill, question); + if (score <= 0) continue; + if (!best || score > best.score) best = { skill, score, suggestion }; + } + if (!best) return null; + + const { skill, suggestion, score } = best; + const available = skill.owliver.capabilities; + + /** + * `exact` means the question *is* a suggestion this definition published — + * the reader clicked a chip, or typed its words. It is the strongest claim + * anything can have on a question, and callers weighing this match against + * another matcher need to be able to see that rather than infer it from a + * number. + */ + const exact = Boolean(suggestion); + + /* A chip that declared its capability has already answered this. */ + if (suggestion?.capability && available.includes(suggestion.capability)) { + return { skill, capability: suggestion.capability, score, exact }; + } + + const asked = available + .map((capability) => ({ capability, weight: scoreCapability(capability, question) })) + .filter((c) => c.weight > 0) + .sort((a, b) => b.weight - a.weight)[0]; + + return { skill, capability: asked?.capability || available[0], score, exact }; +} + +/* ── The record a response is about ─────────────────────────────────────── */ + +/** + * The entity a source needs, resolved from the question and the page. + * + * Sources declare what they need — a position, a candidate, a draft, or + * nothing — and this is the one place that need is met. Three orders of + * evidence, most specific first: + * + * 1. the question named a record ("summarize hiring activity for Line Cook"), + * 2. the page has one open (the drawer, the form being filled in), + * 3. neither, and the answer has to ask. + * + * A source needing nothing resolves against the workspace and is always met. + * + * Naming the record wins over the page's selection deliberately: an admin who + * says which role they mean has said so, and answering about a different one + * because a drawer happened to be open would be worse than asking. + */ +export function resolveEntity(section, question, context = {}) { + const need = section.context; + if (!need) return { context, ok: true }; + + if (need === 'positionId') { + const named = resolvePosition(question, context.positions || [], null); + const position = named || context.position || null; + return position + ? { context: { ...context, position }, ok: true } + : { ok: false, need: 'position' }; + } + + if (need === 'candidateId') { + const q = lower(question); + const named = (context.applications || []).find( + (a) => a.applicant_name && q.includes(lower(a.applicant_name)) + ); + const candidate = named || context.candidate || null; + return candidate + ? { context: { ...context, candidate }, ok: true } + : { ok: false, need: 'candidate' }; + } + + return { context, ok: true }; +} + +/* ── The answer ─────────────────────────────────────────────────────────── */ + +/** The rows a reading offers, whatever shape its source returns them in. */ +const rowsOf = (data) => data?.steps || data?.items || []; + +/** + * One row, read back as a line. + * + * A reading carries a figure, a description of it, or both, depending on the + * source — so the line is assembled from what is there rather than from a fixed + * template, and a row with a description that already states its figure + * ("2 applications") does not repeat it. + */ +function rowLine(row) { + const label = String(row.label || row.title || row.id || '').trim(); + if (!label) return null; + + const value = row.value == null || row.value === '' ? null : String(row.value); + const detail = row.detail ? String(row.detail) : null; + + const tail = detail + ? (value && !detail.includes(value) ? `${detail} · ${value}` : detail) + : value; + + return tail ? `${label} — ${tail}` : label; +} + +/** + * A summary, built from whatever the source returned. + * + * Generic on purpose: it reads rows and states them. Nothing here knows what a + * period is, what a stage is, or which skill asked — which is precisely why a + * definition written tomorrow gets a summary without this function changing. + */ +export function summaryLines(data) { + return rowsOf(data).map((row) => rowLine(row)).filter(Boolean); +} + +/** The label a reading is introduced by — the definition's words, then the source's. */ +export const responseTitle = (skill, section) => + section.title || `${skill.name} — ${dataSourceLabel(section.source)}`; + +/** + * Everything an answer needs, resolved: the section, the data, and whether the + * page could supply the record the source required. + * + * Returns `{ skill, capability, section, data, missing }`. `missing` names what + * the caller must ask for; when it is null the reading is real and complete. + */ +export function resolveOwliverResponse({ skill, capability, question, context = {}, now = new Date() }) { + const section = skill.owliver.responses[capability]; + if (!section) return null; + + const entity = resolveEntity(section, question, context); + if (!entity.ok) { + return { skill, capability, section, data: null, missing: entity.need }; + } + + /* The same resolver the page's own sections go through, on the same + collections — so the panel and the card beside it cannot report different + figures for the same position. */ + const data = resolveSkillData(section, entity.context, now); + return { skill, capability, section, data, missing: null, context: entity.context }; +} + +/** + * A reading, reduced to what a drawn section reads. + * + * A reply is kept in the thread, so it is stored: the records a source counted + * are the evidence behind a figure, not part of the answer, and writing every + * application into session storage to draw one bar would be paying for the + * whole dataset per turn. Nothing the renderers use is dropped. + */ +export function presentable(data) { + if (!data) return data; + const strip = ({ records, ...row }) => row; + + return { + ...data, + ...(data.steps ? { steps: data.steps.map(strip) } : null), + ...(data.items ? { items: data.items.map(strip) } : null), + }; +} + +/** Every capability the product understands, for the editor and the previews. */ +export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map( + ({ id, label, summary }) => ({ id, label, summary }) +); + +/* ── Suggestions for a record that has just appeared ────────────────────── */ + +/** + * What can now be asked about a record the conversation just produced. + * + * Creating a position is the moment "who could do this?" becomes worth asking, + * and the panel is the only thing that knows a position now exists. Rather than + * naming a skill to offer — which would put a candidate-matching feature inside + * the position-creation flow — this asks the registry the general question: of + * the skills attached to this page, which declare a capability whose reading is + * *about one position*? Those are exactly the ones that can say something about + * the record just made. + * + * The record's own title is appended to each prompt, so the answer resolves + * against it directly and the reader is never asked to pick from a list that + * includes the position they are looking at. Nothing is named here: a skill + * added tomorrow that reads a position is offered on the same terms. + */ +export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) { + if (!position?.title) return []; + + const chips = []; + + for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) { + /* Only capabilities that read one position — a workspace-wide reading has + nothing to do with the record that was just created. */ + const scoped = skill.owliver.capabilities.filter( + (capability) => skill.owliver.responses[capability]?.context === 'positionId' + ); + if (!scoped.length) continue; + + const offered = skill.owliver.suggestions + .filter((s) => !s.capability || scoped.includes(s.capability)) + .slice(0, 1) + .map((s) => ({ + label: s.label, + /* Named, so the reading resolves against this position rather than + asking which one. */ + prompt: `${s.prompt} for ${position.title}`, + skillId: skill.id, + skillCapability: s.capability || null, + })); + + chips.push(...offered); + } + + return chips.slice(0, 2); +} diff --git a/src/lib/skills/positionFlow.js b/src/lib/skills/positionFlow.js index 31e04aa..05237f4 100644 --- a/src/lib/skills/positionFlow.js +++ b/src/lib/skills/positionFlow.js @@ -56,6 +56,26 @@ const FREE_TEXT = /^(?:other|another|custom|enter|enter .*|type .*|somewhere els * understood — which re-asks with `retry` rather than storing a guess. */ const FIELDS = { + /** + * The client this role is being staffed for. + * + * A "client" is not a record of its own in this product — it is the `company` + * on the position, which is the field the Create Position form writes, the + * Positions card leads with, and Hired History reports against. So the + * conversation collects it into the same field rather than into a store of + * its own, and asking Owliver to create a client starts here. + */ + company: { + label: 'Company', + settled: (draft) => Boolean(String(draft.company || '').trim()), + retry: 'Type the client or company name — "Fairmont San Jose".', + parse: (answer) => { + const value = cleanPhrase(answer, 60); + return value && value.length > 1 ? { company: titleCase(value) } : null; + }, + summary: (draft) => (draft.company ? `Company: ${draft.company}` : null), + }, + role_category: { label: 'Role', settled: (draft) => Boolean(draft.title), @@ -233,10 +253,13 @@ function review(flow, steps) { doc: doc( text('Ready to create this position?'), list(summaryLines(flow, steps)), - note('Nothing is saved until you choose Create position.') + note('Nothing is saved until you choose one. Save as Draft keeps it unpublished — the same as the button on the form.') ), + /* The two the form offers, in the same words and the same order, so the + conversation and the page commit a position the same two ways. */ followUp: [ - { label: 'Create position', prompt: 'Create position' }, + { label: 'Save as Draft', prompt: 'Save as draft' }, + { label: 'Publish Job Posting', prompt: 'Publish job posting' }, { label: 'Change details', prompt: 'Change details' }, ], }; @@ -305,9 +328,15 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) { /* The confirmation step. "Create position" is the only path to a record. */ if (flow.stage === 'review') { - if (/^(?:create position|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) { + /* Saving unpublished. The same write, with the status the form's own + "Save as Draft" button sets — one create path, two statuses. */ + if (/^(?:save as draft|save draft|draft|save it as a draft)$/i.test(said)) { if (missingRequired(flow, steps).length) return review(flow, steps); - return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft } }; + return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'draft' } }; + } + if (/^(?:create position|publish job posting|publish|create|create it|yes|confirm|looks good|go ahead)$/i.test(said)) { + if (missingRequired(flow, steps).length) return review(flow, steps); + return { flow: { ...flow, stage: 'creating' }, create: { draft: flow.draft, status: 'active' } }; } if (/^(?:change|change details|edit|change something|no)$/i.test(said)) return changeMenu(flow, steps); @@ -322,7 +351,7 @@ export function advancePositionFlow({ flow, answer, skill, roles = [] }) { doc: doc( text('I did not catch that. Ready to create this position?'), list(summaryLines(flow, steps)), - note('Choose Create position, or tell me what to change.') + note('Choose Save as Draft or Publish Job Posting, or tell me what to change.') ), }; } @@ -430,14 +459,19 @@ function applyStatement(flow, steps, said, roles) { /** The position exists. Said plainly, with what was created. */ export function positionCreatedReply(position) { + const isDraft = position.status === 'draft'; + return doc( - text('Position created successfully.'), + text(isDraft ? 'Saved as a draft.' : 'Position created successfully.'), list([ + position.company, position.title, position.location, payLabel(position), ].filter(Boolean)), - note('It is on the Positions list now — applications will start appearing against it.') + note(isDraft + ? 'It is on the Positions list as a draft — nobody can apply until it is published, and it stays a draft until you publish it.' + : 'It is on the Positions list now — applications will start appearing against it.') ); } @@ -449,7 +483,20 @@ export function positionFailedReply() { ); } -/** What the panel offers after a position is created. */ -export const createdFollowUp = (position) => [ - { label: 'View position', route: `/admin/positions/${position.id}` }, -]; +/** + * What the panel offers after a position is created. + * + * A published role has an obvious next question — who can fill it — so it is + * offered here rather than left to be typed. The chip carries the position's + * own title and the wording the workforce engine already answers, so it is an + * ordinary question resolved by the path that was already there: no handler, + * no navigation, and the same answer as asking it by hand. + */ +export const createdFollowUp = (position) => (position.status === 'draft' + /* A draft is unfinished, so the way on is the form that finishes it — the + same route the Positions card's Continue uses. */ + ? [{ label: 'Continue to save', route: `/admin/positions/new?draft=${encodeURIComponent(position.id)}` }] + : [ + { label: 'View position', route: `/admin/positions/${position.id}` }, + { label: 'Match candidates', prompt: `Who matches ${position.title}?` }, + ]); diff --git a/src/lib/skills/registry.js b/src/lib/skills/registry.js index 1efbe71..790c12d 100644 --- a/src/lib/skills/registry.js +++ b/src/lib/skills/registry.js @@ -1,4 +1,8 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement'; +import { parseYaml } from './yaml'; +import { normalizeSkillUi } from './uiConfig'; +import { normalizeSkillOwliver } from './owliverConfig'; +import { SUPPORTED_SKILL_PAGES, canonicalPage, surfaceFor, surfaceForRoute } from './surfaces'; /** * Owliver skill registry. @@ -16,43 +20,25 @@ import { PLACEMENT_ROUTES } from '@/components/ai-assistant/placement'; const FILES = import.meta.glob('/src/skills/**/*.md', { query: '?raw', import: 'default', eager: true }); /** - * Frontmatter, parsed to the subset the format actually uses: `key: value` and - * `key:` followed by an indented `- item` list. + * Frontmatter, as data. * - * Deliberately not a YAML library — the app has none, this needs no dependency, - * and a skill file that reaches for anchors or nested maps has outgrown being a - * declaration anyway. + * Skills grew declarative UI configuration, which is nested, so this reads the + * YAML subset in `yaml.js` rather than the flat `key: value` pairs it used to. + * The old shapes are a strict subset of the new one — a definition written for + * the previous parser parses identically here. + * + * A file whose frontmatter cannot be read raises rather than registering a + * half-understood definition; `parseSkill` decides what to do with that. */ function parseFrontmatter(raw) { const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw); if (!match) return { data: {}, body: raw }; - const data = {}; - let listKey = null; - - for (const line of match[1].split(/\r?\n/)) { - if (!line.trim()) continue; - - const item = /^\s*-\s+(.*)$/.exec(line); - if (item && listKey) { - data[listKey].push(item[1].trim()); - continue; - } - - const pair = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); - if (!pair) continue; - - const [, key, value] = pair; - if (value === '') { - listKey = key; - data[key] = []; - } else { - listKey = null; - data[key] = value.trim(); - } - } - - return { data, body: raw.slice(match[0].length).trim() }; + const data = parseYaml(match[1]); + return { + data: data && typeof data === 'object' && !Array.isArray(data) ? data : {}, + body: raw.slice(match[0].length).trim(), + }; } /** Bullets under a `## Heading`, for the capability list shown in Settings. */ @@ -131,10 +117,14 @@ function sectionLevels(body) { /** * The page key a route belongs to — `/admin/positions` → `positions`. * - * Derived from the placement table rather than written down again, so a route - * added there is addressable by a skill without touching this file. + * The surface table answers first, because a surface already states its own + * route and its key is not always the path tail: `/admin/positions/new` is + * `create-position`, not `positions/new`. Falling back to the tail keeps every + * route that has no declared surface behaving exactly as it did. */ export function pageKeyForRoute(route) { + const surface = surfaceForRoute(route); + if (surface) return surface.id; const tail = route.replace(/^\/admin\/?/, ''); return tail === '' ? 'control-center' : tail; } @@ -154,6 +144,59 @@ const ROUTE_BY_PAGE_KEY = Object.entries(PLACEMENT_ROUTES).reduce((acc, [route, export const routeForPageKey = (key) => ROUTE_BY_PAGE_KEY[key]?.route ?? null; export const pageKeyForContext = (contextId) => PAGE_KEY_BY_CONTEXT[contextId] ?? null; +/** + * The two management surfaces a definition can belong to. + * + * `ui` extends a KROW page; `owliver` extends the assistant. They share the + * parser, the registry, the validator, the persistence and the data resolver — + * only the authoring and management experience is separate, which is what this + * classification serves. + */ +export const SKILL_FACETS = ['ui', 'owliver']; + +/** + * Which of them a definition belongs to. + * + * Declared, never configured: a `ui:` block is a page extension, and an + * `owliver:` block, triggers, actions, a prompt or a conversation is an + * assistant extension. A definition that declares neither is an assistant + * skill — that is what every definition written before the split was, and + * reading it any other way would drop it out of both lists. + * + * Workforce paths are neither. They define a capability the workforce holds + * and are managed in Skill Development, so they carry no facet and appear on + * neither list. + */ +export function skillFacets({ data = {}, kind, ui = {}, owliver, conversation = [] }) { + if (kind === 'workforce') return []; + + const extendsPage = Object.keys(ui).length > 0; + + /** + * Behaviour Owliver actually gains: something to answer with, something to + * open, or questions to ask. + * + * Triggers alone are deliberately not on this list. A trigger is a way of + * being *named*, and a page-drawing definition that names itself is still a + * page-drawing definition — listing it as an Owliver skill would offer an + * author a capability list it never declared. A definition with no `ui:` is + * the other way round: triggers are all it has, and they are what it does. + */ + const teachesOwliver = Boolean( + data.owliver + || (Array.isArray(data.actions) && data.actions.length) + || data.prompt + || conversation.length + || owliver?.capabilities?.length + || owliver?.suggestions?.length + ); + + return [ + extendsPage ? 'ui' : null, + teachesOwliver || !extendsPage ? 'owliver' : null, + ].filter(Boolean); +} + /** * One Markdown definition → one skill. * @@ -179,9 +222,49 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) { */ const kind = data.kind || (levels.length ? 'workforce' : 'assistant'); + /* The declarative UI this definition contributes, checked against the + closed vocabulary in `surfaces.js`. A definition with no `ui:` block is + exactly what it was before this existed. */ + const { ui, errors: uiErrors } = normalizeSkillUi(data.ui, { + declaredPages: pages, + skillId: id, + }); + + /* The same definition's second consumer. `owliver:` declares what can be + asked for in the panel, reading the source the page section already + names — so one file answers "what does this page show" and "what can + Owliver be asked here" without either being written twice. A definition + with no `owliver:` block is exactly what it was before this existed. */ + const { owliver, errors: owliverErrors } = normalizeSkillOwliver(data.owliver, { + ui, + skillId: id, + skillName: data.name || '', + }); + + /* The questions this skill asks, when it collects its input in the chat + rather than by opening something. */ + const conversation = sectionSteps(body, 'Conversation'); + return { id, kind, + ui, + uiErrors, + owliver, + owliverErrors, + /** + * What this definition extends, derived from what it declares. + * + * Two things wear the same format and are managed as different lists: a + * definition with a `ui:` block extends a *page*, and one that teaches + * Owliver — an `owliver:` block, triggers, actions, a conversation — + * extends the *assistant*. Reading that off the declaration rather than + * off a `type:` field is what makes the split free: every definition + * already written classifies itself, nothing stored has to be migrated, + * and a definition doing both is listed in both places rather than + * losing half of itself to a category. + */ + facets: skillFacets({ data, kind, ui, owliver, conversation }), name: data.name || 'Untitled skill', description: data.description || '', status: data.status === 'inactive' ? 'inactive' : 'active', @@ -213,12 +296,21 @@ export function parseSkill(raw, { path = 'custom', custom = false } = {}) { ? data.triggers : [data.name].filter(Boolean) ).map((t) => String(t).toLowerCase()), + /** + * Whether those triggers were *claimed* or merely inherited. + * + * The fallback above is convenient and, until this field existed, + * indistinguishable from the real thing — so a definition that only draws + * a card was silently claiming its own name as a phrase Owliver answers + * to, and could take a question from a definition written to answer it. + * Keeping the distinction lets the matcher weigh a claim differently from + * a default without changing what `triggers` contains. + */ + declaredTriggers: Boolean(Array.isArray(data.triggers) && data.triggers.length), prompt: data.prompt || null, capabilities: sectionBullets(body, 'Capabilities'), purpose: sectionBullets(body, 'Purpose'), - /* The questions this skill asks, when it collects its input in the chat - rather than by opening something. */ - conversation: sectionSteps(body, 'Conversation'), + conversation, path, body, custom, @@ -255,23 +347,41 @@ export function allSkills(customSources = []) { return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); } -/** Validates a definition before it is stored. Returns an error string or null. */ +/** + * Validates a definition before it is stored. Returns an error string or null. + * + * Frontmatter first, then the declarative UI — an author is told the first + * thing that is wrong, in the order they would fix it. + */ export function validateSkillSource(raw) { if (!String(raw).trim()) return 'Paste or upload a Markdown definition.'; let skill; try { skill = parseSkill(raw, { custom: true }); - } catch { - return 'That definition could not be parsed.'; + } catch (error) { + /* The YAML subset reports the line it failed on; that is far more useful + than "could not be parsed". */ + return `That definition could not be parsed. ${error.message || ''}`.trim(); } if (!skill.id) return 'The frontmatter needs an `id`.'; if (!/^[a-z0-9][a-z0-9-]*$/.test(skill.id)) return '`id` must be lower-case letters, numbers and dashes.'; if (!skill.name) return 'The frontmatter needs a `name`.'; if (!skill.pages.length) return 'The frontmatter needs at least one `pages` entry.'; - const unknown = skill.pages.filter((p) => !ROUTE_BY_PAGE_KEY[p]); + + const unknown = skill.pages.filter((p) => !surfaceFor(p)); if (unknown.length) { - return `Unknown page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Known pages: ${Object.keys(ROUTE_BY_PAGE_KEY).join(', ')}.`; + return `Unsupported page${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.`; } + + /* A UI block that names something the product does not offer is refused + outright rather than registered with the offending section dropped. */ + if (skill.uiErrors?.length) return skill.uiErrors[0]; + + /* Same rule for the panel half of the definition: a capability, source or + step the product cannot honour is refused now rather than registered as + something Owliver offers and then cannot answer. */ + if (skill.owliverErrors?.length) return skill.owliverErrors[0]; + return null; } @@ -280,7 +390,10 @@ export const PAGE_KEYS = Object.keys(ROUTE_BY_PAGE_KEY).sort(); /** Context ids a skill applies to, resolved through the placement table. */ export function contextIdsForSkill(skill) { - return skill.pages.map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId).filter(Boolean); + return skill.pages + .map((key) => ROUTE_BY_PAGE_KEY[key]?.contextId + || ROUTE_BY_PAGE_KEY[pageKeyForRoute(surfaceFor(key)?.route || '')]?.contextId) + .filter(Boolean); } /** @@ -297,10 +410,11 @@ export function contextIdsForSkill(skill) { */ export function getSkillsForPage(pageId, { disabled = [], customSources = [], kind } = {}) { if (!pageId) return []; + const wanted = canonicalPage(pageId) || pageId; return allSkills(customSources).filter( (s) => s.status === 'active' && !disabled.includes(s.id) - && s.pages.includes(pageId) + && s.pages.some((p) => (canonicalPage(p) || p) === wanted) && (!kind || s.kind === kind) ); } @@ -338,10 +452,40 @@ function triggerMatches(trigger, question) { return new RegExp(pattern).test(question); } -/** The first skill on this page whose triggers match the question. */ +/** + * The first skill on this page whose triggers match the question. + * + * A definition has to be *addressable by the assistant* before its triggers + * count, and there are two ways to be: teach Owliver something — an `owliver:` + * block, an action, a conversation — or explicitly claim a phrase with + * `triggers:`. A definition that does neither is a page extension that happens + * to have a name, and matching it here means answering a question with a + * restatement of a card's description. + * + * That was live: `hiring-activity` draws a flow on the Positions page, declares + * no triggers, and inherited "hiring activity" from its own name — enough to + * take "show hiring activity" from the definition written to answer it. + * + * Both conditions are read off what the definition declares, so a skill written + * tomorrow is admitted or excluded by the same rule, and nothing that claimed a + * phrase loses it. + */ +const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver'); + export function matchSkill(question, contextId, disabled = [], customSources = []) { const q = String(question).toLowerCase(); return skillsForContext(contextId, disabled, customSources).find( - (s) => s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q)) + (s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q)) ) ?? null; } + +/** + * The definitions belonging to one management surface. + * + * The single answer to "what belongs on the UI Skills list" and "what belongs + * on the Owliver Skills list". Both lists come from `allSkills` — one registry, + * two readings of it — so a definition cannot exist on one list and be unknown + * to the other system. + */ +export const skillsWithFacet = (skills = [], facet) => + skills.filter((s) => s.facets?.includes(facet)); diff --git a/src/lib/skills/surfaces.js b/src/lib/skills/surfaces.js new file mode 100644 index 0000000..c77e98b --- /dev/null +++ b/src/lib/skills/surfaces.js @@ -0,0 +1,478 @@ +/** + * What a skill definition is allowed to say about the product. + * + * A skill can extend a KROW page: name a surface, declare a section, and the + * page renders it. That only stays safe — and only stays a *product* rather + * than a scripting host — because the vocabulary is closed. Everything a + * definition may name is in this file: the surfaces, the placements on each + * surface, the component types, and the data sources. + * + * The rule that makes it safe: **nothing here is code, and nothing here is + * looked up dynamically from the file.** A definition names a key; this module + * says whether that key exists; the renderer maps it to a component the app + * already ships. A definition that names something absent is rejected with a + * message, never rendered as an unknown thing and never executed. + */ + +/** + * The surfaces a skill can extend. + * + * `aliases` keep the page keys the existing skills already use — `hired`, + * `university` — working under the names the product now shows, so the eight + * definitions on disk did not have to be rewritten to gain this feature. + */ +export const SKILL_SURFACES = [ + { + id: 'control-center', + label: 'Control Center', + route: '/admin', + placements: ['after-header', 'before-footer'], + }, + { + id: 'positions', + label: 'Positions', + route: '/admin/positions', + /* Mounted in three places, because Positions is three experiences: the page + that lists the roles, the card for each one, and the drawer "View + position" opens. + + `after-position-list-summary` and `after-position-list` are the *page*: + they render once, above and below the grid, with no position in context. + `after-position-card` renders inside each card. The rest render inside + the drawer and on the full position page, where one position is being + read. Every one of these is mounted — a placement the vocabulary offers + and no page provides is a definition that validates and then silently + does nothing. */ + placements: [ + 'after-position-list-summary', + 'after-position-list', + 'after-header', + 'after-position-card', + 'after-position-summary', + 'before-candidates', + 'after-candidates', + 'before-footer', + ], + }, + { + /* The authoring form, which is a surface in its own right: what a skill has + to say there is about the position being specified, not about the ones + that already exist. Its placements follow the form's own three parts, so + a definition can sit beside the field group it is about. */ + id: 'create-position', + label: 'Create Position', + route: '/admin/positions/new', + aliases: ['new-position'], + placements: [ + 'after-header', + 'after-job-description', + 'after-vetting-weights', + 'before-footer', + ], + }, + { + id: 'candidates', + label: 'Candidates', + route: '/admin/candidates', + placements: ['after-header', 'after-candidate-summary', 'before-footer'], + }, + { + id: 'hired-history', + label: 'Hired History', + route: '/admin/hired', + aliases: ['hired'], + placements: ['after-header', 'before-footer'], + }, + { + id: 'talent-pool', + label: 'Talent Pool', + route: '/admin/talent-pool', + placements: ['after-header', 'before-footer'], + }, + { + id: 'krow-forge', + label: 'KROW Forge', + route: '/admin/university', + aliases: ['university', 'forge'], + placements: ['after-header', 'before-footer'], + }, + { + id: 'analytics', + label: 'Analytics', + route: '/admin/analytics', + placements: ['after-header', 'before-footer'], + }, + { + id: 'activity', + label: 'Activity', + route: '/admin/activity', + placements: ['after-header', 'before-footer'], + }, + /* Not in the eight product surfaces, but skills already attach to it and the + account page reads them. Kept so nothing that works today stops working. */ + { + id: 'profile', + label: 'Profile', + route: '/admin/profile', + placements: ['after-header', 'before-footer'], + }, + { + id: 'candidates-analysis', + label: 'Candidate Analysis', + route: '/admin/candidates-analysis', + placements: ['after-header', 'before-footer'], + }, +]; + +const BY_KEY = new Map(); +for (const surface of SKILL_SURFACES) { + BY_KEY.set(surface.id, surface); + for (const alias of surface.aliases || []) BY_KEY.set(alias, surface); +} + +/** Every name a definition may use for a surface, for error messages. */ +export const SUPPORTED_SKILL_PAGES = SKILL_SURFACES.map((s) => s.id); + +/** The surface a declared page name refers to, or null. */ +export const surfaceFor = (page) => BY_KEY.get(String(page || '').trim()) || null; + +/** The canonical id for a declared page name — `hired` → `hired-history`. */ +export const canonicalPage = (page) => surfaceFor(page)?.id || null; + +/** + * The surface an Admin route belongs to — `/admin/positions/new` → + * `create-position`. + * + * The surfaces already carry their routes, so this reads the answer off the + * table rather than deriving a page key from the path a second time. That + * matters for the surfaces whose key is not their path tail: without it, + * `/admin/positions/new` would key as `positions/new`, which is a page nothing + * declares and no definition could attach to. + */ +export const surfaceForRoute = (route) => + SKILL_SURFACES.find((s) => s.route === String(route || '').trim()) || null; + +/** + * The section types a definition may ask for. + * + * Each entry names a component the application already ships. A type is a key + * in this table and nothing else: there is no path from a definition to a + * component that is not listed here, which is what stops `type:` from being an + * import statement in disguise. + */ +export const SECTION_TYPES = [ + { id: 'card', label: 'Card', summary: 'A titled panel of prose and figures.' }, + { id: 'stats', label: 'Stats', summary: 'A row of counted figures.' }, + { id: 'list', label: 'List', summary: 'A ranked or plain list of records.' }, + { id: 'timeline', label: 'Timeline', summary: 'Dated events, most recent first.' }, + { id: 'flow', label: 'Flow', summary: 'A sequence of stages or periods.' }, + { id: 'table', label: 'Table', summary: 'Rows and columns.' }, + { id: 'progress', label: 'Progress', summary: 'Bars against a total.' }, + { id: 'insight', label: 'Insight', summary: 'One finding, stated plainly.' }, + /* Weighted criteria that share a budget. Distinct from `progress`, which is + bars against an independent maximum: these bars are shares of one total, + and the total is a fact about the set rather than about any one row. */ + { id: 'weights', label: 'Weights', summary: 'Weighted criteria as shares of one total.' }, +]; + +export const SUPPORTED_SECTION_TYPES = SECTION_TYPES.map((t) => t.id); + +/** + * What a definition may ask *Owliver* to do with the same reading. + * + * A skill declares `ui:` for the page and `owliver:` for the panel, and both + * name the same data source. A capability is the second half of that: the shape + * the answer takes when it is asked for in conversation rather than rendered on + * the page. + * + * `shape` is the section type the answer is drawn with, so `flow` in a chat + * reply is the *same* component the page renders — there is one flow renderer, + * not one per consumer. `summary` has no shape because a summary is prose: the + * figures are read back as sentences rather than drawn. + * + * `terms` are how a question is recognised as asking for this shape. They are + * deliberately about the *shape* and never about a subject: "as a flow" belongs + * here, "hiring activity" belongs in a definition's `triggers`. That split is + * what keeps this table closed while the skills stay open. + */ +export const OWLIVER_CAPABILITIES = [ + { + id: 'summary', + label: 'Summary', + shape: null, + summary: 'Reads the figures back as sentences.', + terms: ['summary', 'summarise', 'summarize', 'summarised', 'summarized', 'summarising', + 'summarizing', 'sum up', 'recap', 'overview', 'brief me', 'in short', 'tell me about', + 'what is the', 'how is'], + }, + { + id: 'flow', + label: 'Flow', + shape: 'flow', + summary: 'Draws the stages or periods as a sequence.', + terms: ['flow', 'as a flow', 'chart', 'graph', 'diagram', 'funnel', 'stages', 'visual', + 'visualise', 'visualize', 'step by step'], + }, + { + id: 'stats', + label: 'Stats', + shape: 'stats', + summary: 'A row of counted figures.', + terms: ['stats', 'statistics', 'figures', 'numbers', 'counts', 'how many'], + }, + { + id: 'list', + label: 'List', + shape: 'list', + summary: 'A ranked or plain list of records.', + terms: ['list', 'who are', 'which ones', 'show me the records'], + }, + { + id: 'table', + label: 'Table', + shape: 'table', + summary: 'Rows and columns.', + terms: ['table', 'as a table', 'rows', 'grid', 'spreadsheet'], + }, + { + id: 'timeline', + label: 'Timeline', + shape: 'timeline', + summary: 'Dated events, most recent first.', + terms: ['timeline', 'history', 'chronology', 'over time', 'what happened'], + }, + { + id: 'progress', + label: 'Progress', + shape: 'progress', + summary: 'Bars against a total.', + terms: ['progress', 'bars', 'completion', 'how far'], + }, + { + id: 'weights', + label: 'Weights', + shape: 'weights', + summary: 'The weighted criteria, adjustable when the page accepts the write.', + terms: ['weight', 'weights', 'weighting', 'weightings', 'importance', 'balance', + 'set the weights', 'adjust the weights', 'screening weight', 'vetting weight', + 'criteria'], + }, + { + id: 'insight', + label: 'Insight', + shape: 'insight', + summary: 'One finding, stated plainly.', + terms: ['insight', 'finding', 'takeaway', 'headline', 'what stands out'], + }, + { + id: 'card', + label: 'Card', + shape: 'card', + summary: 'A titled panel of figures.', + terms: ['card', 'panel', 'at a glance'], + }, +]; + +export const SUPPORTED_OWLIVER_CAPABILITIES = OWLIVER_CAPABILITIES.map((c) => c.id); + +/** The capability a declared name refers to, or null. */ +export const owliverCapabilityFor = (id) => + OWLIVER_CAPABILITIES.find((c) => c.id === String(id || '').trim()) || null; + +/** `flow` → `Flow`. */ +export const owliverCapabilityLabel = (id) => owliverCapabilityFor(id)?.label || id; + +/** + * The data a section may ask for. + * + * Each source is a named reading of data the application already holds, and + * `context` says what a page must know for the reading to be possible — a + * position id, a candidate id, or nothing. A source is resolved by + * `dataResolver.js`; a definition cannot reach a store directly, cannot write, + * and cannot name a field that is not offered here. + */ +export const DATA_SOURCES = [ + { + id: 'position.activity', + label: 'Position activity', + context: 'positionId', + summary: 'Applications to this position, counted over time.', + shapes: ['flow', 'stats', 'timeline', 'table', 'insight', 'card'], + }, + { + id: 'position.pipeline', + label: 'Position pipeline', + context: 'positionId', + summary: 'Applied → screened → shortlisted → interviewed → hired.', + shapes: ['flow', 'stats', 'progress', 'table', 'card'], + }, + { + id: 'position.candidates', + label: 'Position candidates', + context: 'positionId', + summary: 'Candidates matched to this position, best first.', + shapes: ['list', 'table', 'stats', 'card'], + }, + { + /** + * Who could actually do this work, ranked. + * + * Distinct from `position.candidates`, which lists the people who *applied + * here*. This one reads the whole candidate pool against what the position + * states — so a role published a minute ago, with no applications at all, + * still has an answer. The reading is `poolFor` in `lib/workforce.js`, the + * same engine the panel's workforce conversation and the position page use; + * nothing about matching is decided in the resolver. + */ + id: 'position.matches', + label: 'Position candidate matches', + context: 'positionId', + summary: 'The candidate pool scored against this position, best first.', + shapes: ['list', 'table', 'stats', 'card', 'insight'], + }, + { + id: 'position.requirements', + label: 'Position requirements', + context: 'positionId', + summary: 'What this position states it needs.', + shapes: ['list', 'table', 'card'], + }, + { + id: 'candidate.readiness', + label: 'Candidate readiness', + context: 'candidateId', + summary: 'Screening dimensions for one candidate.', + shapes: ['progress', 'stats', 'list', 'table', 'card'], + }, + { + id: 'candidate.activity', + label: 'Candidate activity', + context: 'candidateId', + summary: 'What has happened on this candidate’s record.', + shapes: ['timeline', 'list', 'table', 'card'], + }, + { + id: 'candidates.pipeline', + label: 'Candidate pipeline', + context: null, + summary: 'Every candidate, counted by stage.', + shapes: ['flow', 'stats', 'progress', 'table', 'card'], + }, + { + id: 'candidates.activity', + label: 'Candidate activity', + context: null, + summary: 'Applications across the workspace, counted over time.', + shapes: ['flow', 'stats', 'timeline', 'table', 'card'], + }, + { + id: 'positions.demand', + label: 'Position demand', + context: null, + summary: 'Open positions and what they still need.', + shapes: ['list', 'table', 'stats', 'card'], + }, + { + id: 'workforce.training', + label: 'Workforce training', + context: null, + summary: 'Training paths and progress against them.', + shapes: ['progress', 'list', 'stats', 'table', 'card'], + }, + { + /* The vetting weights a position is being specified with. `positionId` + context, but the "position" on Create Position is the draft in the form + rather than a saved record — which is the point: the resolver reads the + same field either way, so one source serves the form and the position it + becomes. */ + id: 'position.vetting', + label: 'Position vetting weights', + context: 'positionId', + summary: 'How this position weights each screening criterion.', + shapes: ['weights', 'flow', 'progress', 'stats', 'table', 'card', 'insight'], + /** + * This reading can be written back. + * + * `writable` is what lets a definition declare `editable: true` and get + * controls instead of a read-out. It is a property of the *source*, not of + * the definition — a skill cannot make a reading writable by asking, and a + * source with no page willing to accept the write simply renders read-only. + * That keeps the closed vocabulary closed in both directions. + */ + writable: true, + writeSummary: 'Sets the screening weights on the position being specified.', + }, + { + id: 'hires.recent', + label: 'Recent hires', + context: null, + summary: 'Who was hired, for which role, and when.', + shapes: ['list', 'table', 'timeline', 'stats', 'card'], + }, + { + id: 'hires.performance', + label: 'Hiring performance', + context: null, + summary: 'Hires, time-to-hire, quality and conversion, counted together.', + shapes: ['stats', 'card', 'table', 'flow', 'insight'], + }, + { + id: 'activity.events', + label: 'Workspace activity', + context: null, + summary: 'What has happened across the workspace, most recent first.', + shapes: ['timeline', 'list', 'table', 'stats', 'card'], + }, +]; + +export const SUPPORTED_DATA_SOURCES = DATA_SOURCES.map((s) => s.id); + +export const dataSourceFor = (id) => DATA_SOURCES.find((s) => s.id === id) || null; + +/** + * Can this reading be written back? + * + * The one question `editable:` is checked against. A page opts in by publishing + * a handler for the source (see `usePublishPageActions`); a definition opts in + * by declaring `editable: true`. Both have to hold before a control is drawn, + * so neither the author nor the page can enable editing on its own. + */ +export const isSourceWritable = (id) => Boolean(dataSourceFor(id)?.writable); + +/** + * The periods a time-based section may ask for. + * + * Only the ones the data layer can actually compute from record timestamps. + * A period is a window over `created_date`, resolved at read time against the + * current date — never a stored figure and never a hard-coded date. + */ +export const PERIODS = [ + { id: 'today', label: 'Today' }, + { id: 'yesterday', label: 'Yesterday' }, + { id: 'last-7-days', label: 'Last 7 days' }, + { id: 'last-week', label: 'Last week' }, + { id: 'this-month', label: 'This month' }, + { id: 'previous-month', label: 'Previous month' }, +]; + +export const SUPPORTED_PERIODS = PERIODS.map((p) => p.id); + +export const periodLabel = (id) => PERIODS.find((p) => p.id === id)?.label || id; + +/* ── Human labels ─────────────────────────────────────────────────────────── + The vocabulary is written in kebab-case because it is configuration; it is + read by people, so every id has a label. One place, so the preview, the + rendered section and any future surface all say the same words. */ + +/** `flow` → `Flow`. */ +export const sectionTypeLabel = (id) => + SECTION_TYPES.find((t) => t.id === id)?.label || id; + +/** `position.activity` → `Position activity`. */ +export const dataSourceLabel = (id) => dataSourceFor(id)?.label || id; + +/** `after-position-summary` → `After position summary`. */ +export const placementLabel = (id) => { + const words = String(id || '').replace(/-/g, ' ').trim(); + return words ? words[0].toUpperCase() + words.slice(1) : id; +}; diff --git a/src/lib/skills/uiConfig.js b/src/lib/skills/uiConfig.js new file mode 100644 index 0000000..9098331 --- /dev/null +++ b/src/lib/skills/uiConfig.js @@ -0,0 +1,293 @@ +import { + SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES, + SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor, +} from './surfaces'; + +/** + * The `ui:` block of a skill definition, checked and normalized. + * + * A definition declares what it wants; this decides whether the product can + * honour it, and turns a loose YAML shape into one predictable record. Two + * things follow from doing it here rather than in the renderer: + * + * - **Nothing unknown reaches a component.** Every page, placement, type, + * data source and period is checked against the closed vocabulary in + * `surfaces.js`. An unrecognised value is an error with a message naming + * it, not a silently dropped key and not a rendered blank. + * - **Only listed keys survive.** The normalized section carries exactly the + * fields the renderers read. Anything else an author writes is ignored + * rather than passed through, so no property can arrive at React that this + * module did not put there. + */ + +/** + * A section as the renderers receive it. Nothing else is carried. + * + * Used by both consumers of a definition. The page passes a `page`, so the + * section is checked against that surface's placements; Owliver passes + * `placement: false`, because a chat reply has no placement to sit at — the + * rest of the checks, and the record that comes out, are identical. That is + * deliberate: it is what makes a flow drawn in the panel the same section as + * the flow drawn on the page rather than a parallel shape that resembles it. + * + * `types` narrows what `type:` may be. The page offers the components it can + * mount; Owliver offers those plus the shapes that are only answers. + */ +export function normalizeSection(raw, { + page, errors, seen, fallbackId = '', where: label = null, + placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES, + shapeFor = (type) => type, +}) { + const where = label || `ui.${page}`; + /* Where an error points. A page section is addressed by the id it was given; + a capability response is already addressed by the capability it answers, so + appending a generated section id there would name something the author + never wrote. */ + const at = label ? where : null; + + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + errors.push(`${where}: each section must be a mapping of options.`); + return null; + } + + /* An id is how a section is keyed and de-duplicated, not something an author + should have to invent for a definition that declares exactly one. Falls + back to the title, then to the skill's own id. */ + const slug = (value) => String(value || '') + .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const id = slug(raw.id) || slug(raw.title) || slug(fallbackId); + if (!id) { + errors.push(`${where}: a section needs an \`id\`.`); + return null; + } + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`); + return null; + } + if (seen.has(id)) { + errors.push(`${where}: two sections share the id \`${id}\`.`); + return null; + } + seen.add(id); + + const type = String(raw.type || '').trim(); + if (!type) { + errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`); + return null; + } + if (!types.includes(type)) { + errors.push( + `Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.` + ); + return null; + } + + /* `position:` is what the format calls it; `placement:` is accepted because + it is the word the rest of the system uses. A section that is an answer + rather than a panel has nowhere to be placed, and says so with `null`. */ + let placement = null; + if (wantPlacement) { + const surface = surfaceFor(page); + const declaredPlacement = String(raw.position || raw.placement || '').trim(); + placement = declaredPlacement || surface.placements[0]; + if (!surface.placements.includes(placement)) { + errors.push( + `Unsupported placement: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.` + ); + return null; + } + } + + /* `data.source:` and a flat `source:` mean the same thing. The nested form + groups options when a section grows; the flat form is what a one-section + definition actually reads like, and refusing it would be the format being + precious about punctuation. */ + const source = String(raw.data?.source ?? raw.source ?? '').trim(); + if (!source) { + errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`); + return null; + } + if (!SUPPORTED_DATA_SOURCES.includes(source)) { + errors.push( + `Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.` + ); + return null; + } + + /* A source knows which shapes it can fill. Asking for a timeline of something + that has no dates is an authoring mistake worth naming now rather than + rendering as an empty panel later. */ + const definition = dataSourceFor(source); + /* `shapeFor` is how a type that is not a drawn component is exempted: a + summary is prose, so there is no shape for a source to be incompatible + with. Every drawn type maps to itself. */ + const shape = shapeFor(type); + if (shape && definition.shapes && !definition.shapes.includes(shape)) { + errors.push( + `${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.` + ); + return null; + } + + const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : []; + const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p)); + if (unknownPeriod) { + errors.push( + `Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.` + ); + return null; + } + + const limit = Number(raw.limit); + + /** + * Whether this section offers controls rather than a read-out. + * + * Two independent things must agree before anything is editable, and this is + * the first: the definition asking for it, and the source declaring that it + * can be written at all. The second is the page publishing a handler for that + * source at render time. A definition asking to edit a reading the product + * does not expose for writing is an authoring mistake worth naming here, + * rather than a control that silently does nothing. + */ + const editable = raw.editable === true || raw.editable === 'true'; + if (editable && !isSourceWritable(source)) { + errors.push( + `${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.` + ); + return null; + } + + return { + id, + title: String(raw.title || '').trim() || null, + description: String(raw.description || '').trim() || null, + type, + placement, + source, + /* Declared intent only. A section stays read-only wherever no page offers + to accept the write — see `SkillSurface` and `SkillSectionBlock`. */ + editable, + /* Context the page must supply for this source to resolve. */ + context: definition.context, + periods, + limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null, + }; +} + +/** + * The keys that mean "this object *is* a section". + * + * How the two shapes are told apart. A definition may write its UI either way: + * + * ui: ui: + * type: flow positions: + * placement: … sections: + * source: … - type: flow + * + * The first is one section applying to every page the skill declares; the + * second addresses pages by name and can differ per page. Both are legitimate, + * and the difference is structural — an object carrying `type` or `source` is a + * section, an object whose keys are page names is a page map. Nothing is + * decided by a skill id, and neither shape is privileged. + */ +const SECTION_KEYS = new Set([ + 'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods', + 'limit', 'editable', +]); + +const looksLikeSection = (value) => + Boolean(value) + && typeof value === 'object' + && !Array.isArray(value) + && Object.keys(value).some((key) => SECTION_KEYS.has(key)); + +/** The sections a page entry declares, in either the list or single-section form. */ +function sectionsOf(config) { + if (Array.isArray(config?.sections)) return config.sections; + if (Array.isArray(config)) return config; + if (looksLikeSection(config)) return [config]; + return null; +} + +/** + * The whole `ui:` block, normalized per page. + * + * Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with + * one bad section still registers its good ones — and the author still sees why + * the other was refused. + */ +export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) { + const errors = []; + const ui = {}; + + if (rawUi == null) return { ui, errors }; + + if (typeof rawUi !== 'object' || Array.isArray(rawUi)) { + return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] }; + } + + const pages = declaredPages.map(canonicalPage).filter(Boolean); + + /* Shorthand: one section, applied to every page the skill declares. The keys + inside it are section options and are never read as page names — which is + exactly what this branch exists to prevent. */ + if (looksLikeSection(rawUi)) { + if (!pages.length) { + return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] }; + } + + for (const page of pages) { + const section = normalizeSection(rawUi, { + page, errors, seen: new Set(), fallbackId: skillId, + }); + if (section) ui[page] = { sections: [section] }; + } + return { ui, errors }; + } + + /* Otherwise every key is a page name. */ + for (const [rawPage, config] of Object.entries(rawUi)) { + const page = canonicalPage(rawPage); + if (!page) { + errors.push( + `Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.` + ); + continue; + } + + /* A page cannot be extended unless the skill also declares it. Otherwise a + definition could render onto a surface it never said it applied to. */ + if (pages.length && !pages.includes(page)) { + errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`); + continue; + } + + const rawSections = sectionsOf(config); + if (!rawSections) { + errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`); + continue; + } + + const seen = new Set(); + const sections = rawSections + .map((section) => normalizeSection(section, { + page: rawPage, errors, seen, fallbackId: skillId, + })) + .filter(Boolean); + + if (sections.length) ui[page] = { sections }; + } + + return { ui, errors }; +} + +/** Every section this skill contributes to a page, in declaration order. */ +export const sectionsForPage = (skill, page) => { + const key = canonicalPage(page); + return key ? skill?.ui?.[key]?.sections || [] : []; +}; + +/** How many UI sections a definition registers, across every page. */ +export const countSections = (skill) => + Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0); diff --git a/src/lib/skills/yaml.js b/src/lib/skills/yaml.js new file mode 100644 index 0000000..4a93fea --- /dev/null +++ b/src/lib/skills/yaml.js @@ -0,0 +1,174 @@ +/** + * The YAML subset skill frontmatter is allowed to use. + * + * Skills gained declarative UI configuration, which is nested — `ui:` holds a + * page, which holds sections, which hold their own options. The old parser read + * flat `key: value` pairs and one level of `- item`, so nesting was impossible + * to express. + * + * This is deliberately a *subset*, not a YAML library: + * + * - block maps and block sequences, nested to any depth + * - scalars: strings, integers, floats, booleans, null + * - quoted strings, for values containing `:` or `#` + * - `- key: value` — a mapping that starts on the dash + * - `#` comments, and blank lines + * + * Everything else — anchors, aliases, merge keys, multi-document files, flow + * mappings, block scalars, tags — is not supported and is not silently + * half-read: an unparseable line raises, so a definition either means what it + * says or is rejected with a line number. + * + * It returns plain data and nothing else. There is no code path from this file + * to evaluation of any kind: no `eval`, no `Function`, no dynamic import, no + * JSON with a reviver. A skill definition is configuration, and this is the + * boundary that keeps it configuration. + */ + +/** `true` / `false` / `null` / numbers, or the string as written. */ +function toScalar(raw) { + const value = String(raw).trim(); + + if (value === '' || value === '~' || value === 'null') return null; + if (value === 'true') return true; + if (value === 'false') return false; + + /* Quoted: taken literally, which is how a value containing `:` or `#` is + written. No escape processing beyond the doubled quote. */ + const quoted = /^(['"])([\s\S]*)\1$/.exec(value); + if (quoted) return quoted[2].replace(new RegExp(quoted[1] + quoted[1], 'g'), quoted[1]); + + if (/^-?\d+$/.test(value)) return Number(value); + if (/^-?\d*\.\d+$/.test(value)) return Number(value); + + /* An unquoted trailing comment is a comment. `#` inside a word is not. */ + return value.replace(/\s+#.*$/, '').trim(); +} + +/** One line, reduced to what the parser needs to decide. */ +function readLines(source) { + return String(source) + .split(/\r?\n/) + .map((text, i) => ({ text, line: i + 1 })) + .filter(({ text }) => text.trim() !== '' && !/^\s*#/.test(text)) + .map(({ text, line }) => ({ + line, + indent: text.match(/^\s*/)[0].replace(/\t/g, ' ').length, + content: text.trim(), + })); +} + +/** + * Parses one block at `indent` or deeper, starting at `cursor.i`. + * + * Returns a map or an array depending on what the first line at this level is, + * which is how YAML itself decides. Recursion handles nesting; the cursor is + * shared so a child can consume the lines it owns. + */ +function parseBlock(lines, cursor, indent) { + const first = lines[cursor.i]; + if (!first) return null; + + return first.content.startsWith('- ') + || first.content === '-' + ? parseSequence(lines, cursor, indent) + : parseMapping(lines, cursor, indent); +} + +function parseSequence(lines, cursor, indent) { + const out = []; + + while (cursor.i < lines.length) { + const { content, indent: at, line } = lines[cursor.i]; + if (at < indent) break; + if (at > indent) throw new Error(`Unexpected indentation on line ${line}`); + if (!content.startsWith('-')) break; + + const rest = content.replace(/^-\s*/, ''); + cursor.i += 1; + + if (rest === '') { + /* `-` alone: the item is the indented block beneath it. */ + out.push(cursor.i < lines.length && lines[cursor.i].indent > indent + ? parseBlock(lines, cursor, lines[cursor.i].indent) + : null); + continue; + } + + /* `- key: value` opens a mapping whose first key sits on the dash. The + remaining keys are indented to where that key started. */ + const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest); + if (pair) { + const keyIndent = indent + (content.length - rest.length); + const item = {}; + const [, key, value] = pair; + + item[key] = value === '' + && cursor.i < lines.length + && lines[cursor.i].indent > indent + ? parseBlock(lines, cursor, lines[cursor.i].indent) + : toScalar(value); + + while (cursor.i < lines.length && lines[cursor.i].indent === keyIndent + && !lines[cursor.i].content.startsWith('- ')) { + Object.assign(item, parseMapping(lines, cursor, keyIndent)); + } + out.push(item); + continue; + } + + out.push(toScalar(rest)); + } + + return out; +} + +function parseMapping(lines, cursor, indent) { + const out = {}; + + while (cursor.i < lines.length) { + const { content, indent: at, line } = lines[cursor.i]; + if (at < indent) break; + if (at > indent) throw new Error(`Unexpected indentation on line ${line}`); + if (content.startsWith('- ')) break; + + const pair = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(content); + if (!pair) throw new Error(`Line ${line} is not \`key: value\`: ${content}`); + + const [, key, value] = pair; + cursor.i += 1; + + if (value !== '') { + out[key] = toScalar(value); + continue; + } + + /* An empty value means the value is the block below — or nothing. */ + const next = lines[cursor.i]; + out[key] = next && next.indent > indent + ? parseBlock(lines, cursor, next.indent) + : null; + } + + return out; +} + +/** + * A YAML document, as plain data. + * + * Throws on anything it cannot read rather than guessing, so a malformed + * definition is reported to its author instead of being registered in a shape + * nobody intended. + */ +export function parseYaml(source) { + const lines = readLines(source); + if (!lines.length) return {}; + + const cursor = { i: 0 }; + const value = parseBlock(lines, cursor, lines[0].indent); + + if (cursor.i < lines.length) { + throw new Error(`Unexpected indentation on line ${lines[cursor.i].line}`); + } + return value; +} diff --git a/src/pages/CreatePosition.jsx b/src/pages/CreatePosition.jsx index 72ba23a..67a110c 100644 --- a/src/pages/CreatePosition.jsx +++ b/src/pages/CreatePosition.jsx @@ -1,11 +1,13 @@ import React, { useState } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Sparkles, ChevronDown, ChevronUp, Sliders, Loader2, Star } from 'lucide-react'; -import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks'; +import { useCreateJobPosting, useUpdateJobPosting, useGenerateJobDescription, useJobPosting, useRoleCategories, useCreateRoleCategory } from '@/lib/krowHooks'; import { toast } from 'react-hot-toast'; import { ROLE_CATEGORIES } from '@/lib/roleCategories'; import { CERT_OPTIONS, CRITERIA_LABELS, ENGLISH_LEVELS, defaultPosition } from '@/lib/positionModel'; import { SkillRequirementsField } from '@/components/krow/SkillRequirementsField'; +import { SkillSurface } from '@/components/skills/SkillSurface'; +import { usePublishPageActions, usePublishPageContext } from '@/components/ai-assistant'; /** * Create a Position. @@ -38,9 +40,32 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false, const [showWeights, setShowWeights] = useState(false); const allCategories = [...new Set([...ROLE_CATEGORIES, ...customCategories.map(c => c.name)])]; - const [draftId, setDraftId] = useState(null); + + /** + * Continuing a draft. + * + * `?draft=` is how the Positions list hands an unfinished position back to + * the form that was writing it. It is the same route, the same component and + * the same state as writing a new one — the only difference is that the form + * starts from the saved record and saves back to it, so nothing the author + * already entered is asked for twice and no second editor exists. + */ + const [searchParams] = useSearchParams(); + const continuingId = searchParams.get('draft'); + const { data: continuing } = useJobPosting(continuingId); + + const [draftId, setDraftId] = useState(continuingId || null); + const [loadedId, setLoadedId] = useState(null); const [aiResult, setAiResult] = useState(null); + /* Seeded once per record, not on every render of it: after this the form owns + the values, and re-seeding would discard whatever has been typed since. */ + if (continuing && continuing.id !== loadedId) { + setLoadedId(continuing.id); + setDraftId(continuing.id); + setForm({ ...defaultPosition(), ...continuing }); + } + const update = (field, value) => setForm({ ...form, [field]: value }); const toggleCert = (cert) => { @@ -55,6 +80,46 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false, const totalWeight = Object.values(form.vetting_criteria).reduce((a, b) => a + b, 0); + /* What a skill on this surface reads: the position being specified, as it + stands right now. The draft carries the same fields a saved position does, + so one declared section reports the form and the record it becomes. + Memoized on the form so the sections re-resolve on a change rather than on + every render. */ + const skillContext = React.useMemo(() => ({ position: form }), [form]); + + /* The same draft, published to the panel beside the form. A skill declaring + `create-position` is available to both consumers, and both read this one + record — so "summarize the vetting weights" answers about the weights on + screen, updating as they are edited. */ + usePublishPageContext(skillContext); + + /** + * The write that goes with that read. + * + * Owliver can already see the weights on this form; this is what lets it set + * them. The handler is the form's own setter — there is no second copy of the + * weights anywhere, so a change made in the panel and a change made on the + * slider below are the same change, and the section further down this page + * re-renders from the same state either way. + * + * Keyed by the data source rather than by a skill or an invented action name: + * any definition reading `position.vetting` can offer to set it, and no + * definition can write anywhere it cannot read. A functional update keeps the + * handler stable, so the published map is memoized once rather than rebuilt as + * the form is typed into. + */ + const applyVettingWeights = React.useCallback((next) => { + setForm((previous) => ({ + ...previous, + vetting_criteria: { ...previous.vetting_criteria, ...next }, + })); + toast.success('Vetting weights updated'); + }, []); + + usePublishPageActions( + React.useMemo(() => ({ 'position.vetting': applyVettingWeights }), [applyVettingWeights]) + ); + const handleGenerateDescription = async () => { if (!form.title) { toast.error('Please enter a job title first'); @@ -107,7 +172,11 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false, if (onDone) { onDone(createdId); } else if (status === 'active' && createdId) { - navigate(`/admin/positions/${createdId}`); + /* Back to the board, with the published role named in the URL. The list + is where the position now lives, and it is the surface Owliver sits + beside — so the role that was just published is the one the panel is + reasoning about, and offering candidates for, on arrival. */ + navigate(`/admin/positions?published=${encodeURIComponent(createdId)}`); } else { navigate('/admin/positions'); } @@ -141,6 +210,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,

Create Job Posting

+ {/* Extension points. A definition naming `create-position` and one of + these placements renders here, against the draft in the form. The + form knows no skill; the definition names the slot. Nothing below is + moved or replaced — the sections sit between what is already here. */} + + {/* Row 1 */}
@@ -312,6 +387,12 @@ export default function CreatePosition({ prefill: prefillProp, embedded = false,
)} + + {/* AI Vetting Weights */}
+ + + + {/* Bottom Actions */}
+ + {/* Header Card */}
@@ -410,6 +413,13 @@ export default function PositionDetail() { )}
+ {/* Extension points. A skill definition naming `positions` and one of + these placements renders here; with none registered they render + nothing at all. No page code changes when a skill is added. Every + placement the vocabulary offers for this surface is mounted, so a + valid definition can never point at a slot that does not exist. */} + + {/* Which people can actually do this work, scored against the position's @@ -459,6 +469,8 @@ export default function PositionDetail() {
)} + + {/* Applicants */}
@@ -497,6 +509,9 @@ export default function PositionDetail() { )}
+ + + {/* Modals */} {interviewApp && setInterviewApp(null)} application={interviewApp} job={job} />} {scheduleApp && setScheduleApp(null)} application={scheduleApp} />} diff --git a/src/pages/Positions.jsx b/src/pages/Positions.jsx index 988045f..8fe0942 100644 --- a/src/pages/Positions.jsx +++ b/src/pages/Positions.jsx @@ -43,20 +43,20 @@ export default function Positions() { {/* Action Bar */}
- + setSearch(e.target.value)} placeholder="Search positions..." - className="w-full h-10 pl-10 pr-4 rounded-lg border border-[#E5E7EB] bg-white text-[13px] focus:border-[#2563EB] focus:outline-none" + className="w-full h-9 pl-10 pr-4 rounded-full border border-[#E5E7EB] bg-white text-[13px] text-[#111827] shadow-2xs hover:border-[#D1D5DB] focus:border-[#2563EB] focus:ring-2 focus:ring-[#2563EB]/20 focus:outline-none transition-all" />
setEntry(e.target.value)} + /* Enter adds the line rather than submitting anything: this list is + one field among several, and losing a half-typed trigger to a page + navigation would be its own bug. */ + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + add(); + }} + /> + +
+
+ ); +} + +export default function OwliverSkillEditor() { + const navigate = useNavigate(); + const { id: routeId } = useParams(); + const editingId = routeId && routeId !== 'new' ? routeId : null; + + const preferences = usePreferences(); + const updatePreferences = useUpdatePreferences(); + const customSkills = preferences.customSkills || []; + + /** + * The definition being worked on. + * + * Editing an account skill loads its stored Markdown; editing one shipped + * with KROW loads that file's Markdown as a starting point, and saving writes + * an account definition with the same id — the override the registry already + * understands, not a duplicate. + */ + const initial = useMemo(() => { + if (!editingId) return owliverSkillTemplate({}); + const stored = customSkillSource(customSkills, editingId); + if (stored) return stored; + const shipped = allSkills(customSkills).find((s) => s.id === editingId); + return shipped?.markdown || owliverSkillTemplate({}); + /* Deliberately keyed on the skill alone: retyping must not be overwritten + by a preferences write elsewhere in the app. */ + }, [editingId]); + + const [source, setSource] = useState(initial); + const [draft, setDraft] = useState(() => (editingId ? draftFromSource(initial) : emptyDraft)); + const [touched, setTouched] = useState(Boolean(editingId)); + const [error, setError] = useState(''); + const fileRef = React.useRef(null); + + /* What the registry will make of what is in the editor right now, so the + preview cannot flatter the definition. */ + const preview = useMemo(() => { + try { + return parseSkill(source, { custom: true }); + } catch { + return null; + } + }, [source]); + + const disabledSkills = preferences.disabledSkills || []; + const declaredActive = preview?.status !== 'inactive'; + const active = declaredActive && !(preview?.id && disabledSkills.includes(preview.id)); + + /** + * Typing in the fields composes the Markdown until the Markdown itself is + * edited, after which it wins — it is the thing being saved, and silently + * regenerating over an author's own text would lose work they can see. + */ + const update = (patch) => { + const next = { ...draft, ...patch }; + setDraft(next); + if (!touched) setSource(owliverSkillTemplate(next)); + }; + + const setStatus = (nextActive) => { + const next = /^status:\s*\w+$/m.test(source) + ? source.replace(/^status:\s*\w+$/m, `status: ${nextActive ? 'active' : 'inactive'}`) + : source.replace(/^---\n/, `---\nstatus: ${nextActive ? 'active' : 'inactive'}\n`); + setSource(next); + setTouched(true); + }; + + const readFile = (event) => { + const file = event.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + setSource(String(reader.result)); + setTouched(true); + setError(''); + }; + reader.readAsText(file); + event.target.value = ''; + }; + + const save = () => { + const problem = validateSkillSource(source); + if (problem) { + setError(problem); + return; + } + const { skill, next } = upsertCustomSkill(customSkills, source); + const nextDisabled = skill.status === 'inactive' + ? [...new Set([...disabledSkills, skill.id])] + : disabledSkills.filter((id) => id !== skill.id); + updatePreferences.mutate({ customSkills: next, disabledSkills: nextDisabled }); + toast.success(editingId ? `${skill.name} updated` : `${skill.name} added`); + navigate('/admin/workspace/skills?tab=owliver'); + }; + + /* Only the sources the declared pages can actually supply context for are + worth offering — a source needing a candidate is not answerable from the + Analytics panel, and offering it would validate and then never resolve. */ + const source_ = draft.source ? dataSourceFor(draft.source) : null; + const timeBased = Boolean(source_?.shapes?.includes('flow') || source_?.shapes?.includes('timeline')); + + /* The other half of the same definition, when it has one. Read-only: this + editor does not own it, but hiding it would misrepresent the skill. */ + const uiSections = Object.values(preview?.ui || {}).flatMap((page) => page.sections || []); + + return ( + + + + + } + > +
+
+ {/* ── Identity and reach ─────────────────────────────────────── */} +
+ + +
+ + update({ name: e.target.value })} + /> + + + update({ id: e.target.value })} + /> + +
+ + + update({ description: e.target.value })} + /> + + + +
+ ({ value: k, label: surfaceFor(k)?.label || k }))} + value={draft.pages} + onChange={(pages) => update({ pages })} + placeholder="Select pages (e.g. positions, candidates...)" + maxChips={6} + /> +
+ Available pages: + {PAGE_KEYS.map((pageKey) => { + const selected = draft.pages.includes(pageKey); + return ( + + ); + })} +
+
+
+ +
+
+

Active

+

+ An inactive skill stays registered, but Owliver offers neither its suggestions nor its answers. +

+
+ +
+
+
+ + {/* ── What starts it ─────────────────────────────────────────── */} +
+ + + + update({ triggers })} + placeholder="hiring activity" + addLabel="Add trigger" + ariaLabel="Triggers" + /> + + +
+ + {/* ── What it offers ─────────────────────────────────────────── */} +
+ + + + update({ suggestions })} + placeholder="Show hiring activity" + addLabel="Add suggestion" + ariaLabel="Suggestions" + /> + + +
+ + {/* ── What an answer looks like ──────────────────────────────── */} +
+ + +
+ {OWLIVER_CAPABILITIES.map((capability) => { + const checked = draft.capabilities.includes(capability.id); + return ( + + ); + })} +
+
+
+ + {/* ── What it reads ──────────────────────────────────────────── */} +
+ + + + + + + {timeBased && ( + +
+ {PERIODS.map((period) => { + const selected = draft.periods.includes(period.id); + return ( + + ); + })} +
+
+ )} +
+
+ + {/* ── The artefact ───────────────────────────────────────────── */} +
+ + + +