agnets done

This commit is contained in:
2026-08-28 11:02:02 +05:30
parent ab9adde6cd
commit eaf08e061d
58 changed files with 3506 additions and 4308 deletions

View File

@@ -6,13 +6,7 @@
* up disagreeing with the drawer it opens.
*/
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/** Everyone who reached this stage or went past it. */
const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
};
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
/**
* Hiring health.
@@ -80,7 +74,7 @@ export function buildPosition(posting, applications) {
const screened = atOrBeyond(apps, 'ai_screened').length;
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
const interviews = atOrBeyond(apps, 'interview').length;
const hired = apps.filter((a) => a.status === 'hired').length;
const hired = apps.filter((a) => HIRED_STATUSES.includes(a.status)).length;
const scored = apps.filter((a) => a.ai_score > 0);
const qualified = scored.filter((a) => a.ai_score >= 70).length;

View File

@@ -300,6 +300,16 @@ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
webSearch: data.webSearch === true || data.web_search === true,
pages: normalizePages(data.pages, { errors }),
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
/* The tools this agent may call, by registry name.
Skills are guidance the model reads; tools are what it can actually do.
An agent with skills and no tools can discuss the work and look nothing
up — which is what every agent authored in this editor was, because
this field did not exist and the parser dropped `tools:` on the way in.
The choices come from GET /api/v1/tools rather than a list kept here,
so a tool renamed in the backend cannot leave a stale option in a form. */
tools: uniqueStrings(data.tools, { where: 'tools', errors, label: 'a tool name' }),
subagents,
knowledge,
starters,

View File

@@ -31,6 +31,7 @@ export const EMPTY_AGENT_FIELDS = Object.freeze({
webSearch: false,
pages: [],
skills: [],
tools: [],
subagents: [],
knowledge: [],
starters: [],
@@ -66,6 +67,7 @@ export function agentFieldsFromSource(source) {
webSearch: agent.webSearch,
pages: [...agent.pages],
skills: [...agent.skills],
tools: [...(agent.tools || [])],
subagents: [...agent.subagents],
knowledge: agent.knowledge.map((k) => ({ ...k })),
starters: agent.starters.map((s) => ({ ...s })),
@@ -116,6 +118,9 @@ export function agentPatch(fields = {}) {
}
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
/* Capability, as opposed to guidance. Written the same way as skills so the
round trip is the same one: form → frontmatter → parser → form. */
if (fields.tools !== undefined) patch.tools = listOrRemove(fields.tools);
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
if (fields.starters !== undefined) {

View File

@@ -0,0 +1,181 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { request } from '@/api/httpClient';
import toast from 'react-hot-toast';
import { parseAgent } from './registry';
/**
* The authored-agent registry, over the API.
*
* Authored agents used to live in the account's preferences as Markdown. That
* persisted — preferences are a real column in a real table — but it persisted
* to the wrong place: the runtime resolves an agent from `agent_definitions`
* and never reads preferences, so an agent created in the editor showed as
* "published" in the list and answered every run with 404.
*
* This writes to `/api/v1/agent-definitions`, which is the table the runtime
* loads from. An agent saved here is one Owliver can actually be asked to run.
*
* Markdown stays the artefact on both sides, and the same parser reads it in
* both processes — `internal/definition` is checked against this one by a
* conformance test replaying a capture of the real frontend module graph, so
* "the backend understood it differently" is a failing test rather than a
* support ticket.
*/
const KEY = ['agentDefinitions'];
/** Every authored agent this account can see, newest first. */
export function useAgentDefinitions() {
return useQuery({
queryKey: KEY,
queryFn: () => base44.entities.AgentDefinition.list('-created_date', 200),
});
}
/**
* The stored rows as the registry reader wants them.
*
* `{ path, raw }` is the shape `readAgentRegistry` already takes, so the reader
* did not have to learn where definitions come from — only the store changed.
*/
export function sourcesFrom(rows) {
return (rows || [])
.filter((r) => r && typeof r.markdown === 'string' && r.markdown.trim())
.map((r) => ({ path: `authored/${r.definition_id || r.id}.md`, raw: r.markdown }));
}
/**
* Create or update by definition id.
*
* The id an author writes (`activity-agent`) is not the row's id (a uuid), and
* the two endpoints take different ones: the registry is a CRUD resource keyed
* by uuid, while a run is addressed by the definition id. Resolving that here
* keeps every caller in the author's vocabulary.
*/
export function useSaveAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
/* visibility is immutable after creation, so it is sent only on create —
patching it back is rejected by the service and would turn a plain save
into an error the author cannot act on. */
return existing
? base44.entities.AgentDefinition.update(existing.id, { markdown })
: base44.entities.AgentDefinition.create({ markdown, visibility });
},
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
/** Deletes the authored definition. A shipped agent returns to its shipped form. */
export function useDeleteAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (definitionId) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
if (!existing) return { ok: true };
return base44.entities.AgentDefinition.delete(existing.id);
},
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
});
}
/**
* The tools an author may choose from, as the backend reports them.
*
* Served rather than listed here: a tool renamed or removed in the registry
* would otherwise leave a stale option in this form, and the agent built from
* it would fail at resolve time with nothing on screen to explain why.
*/
export function useToolCatalogue() {
return useQuery({
queryKey: ['toolCatalogue'],
queryFn: () => request('GET', '/tools'),
/* The tool set changes when the backend is deployed, not while somebody is
filling in a form. */
staleTime: 10 * 60 * 1000,
});
}
/** Row lookup by the id an author writes, for callers that need the uuid. */
export function useRowFor(rows) {
return useCallback(
(definitionId) => (rows || []).find((r) => r.definition_id === definitionId) || null,
[rows]
);
}
/** Memoised sources, so the registry is not rebuilt on every render. */
export function useAuthoredSources(rows) {
return useMemo(() => sourcesFrom(rows), [rows]);
}
/**
* Moves agents left in the account's preferences into the registry.
*
* Authored agents used to be stored as `preferences.customAgents`. Reading
* moved to the registry, and without this that history would simply stop being
* shown: the rows stay in the preferences column, the list no longer reads
* them, and an agent somebody wrote disappears with no message. Losing an
* author's work quietly is worse than any of the problems this change fixed.
*
* Runs once per session, and only forward:
*
* - an id already in the registry is left alone. Re-posting would overwrite
* a definition the author may have edited since.
* - preferences are cleared only after every write has succeeded, so a failed
* migration can be retried rather than having eaten the originals.
* - a definition the backend refuses (an unknown tool, say) leaves everything
* in place and reports, rather than dropping that one on the floor.
*/
export function useMigrateStoredAgents({ rows, stored, clearStored, enabled }) {
const save = useSaveAgentDefinition();
const done = useRef(false);
useEffect(() => {
if (!enabled || done.current || !stored?.length) return;
done.current = true;
(async () => {
const known = new Set((rows || []).map((r) => r.definition_id));
const failures = [];
let moved = 0;
for (const entry of stored) {
const markdown = typeof entry === 'string' ? entry : entry?.raw;
if (!markdown) continue;
let parsed = null;
try {
parsed = parseAgent(markdown, { custom: true });
} catch {
failures.push('a definition that could not be read');
continue;
}
if (known.has(parsed.id)) continue;
try {
await save.mutateAsync({ definitionId: parsed.id, markdown });
moved += 1;
} catch (error) {
failures.push(`${parsed.id}: ${error?.message || 'refused'}`);
}
}
if (failures.length) {
toast.error(`Some stored agents could not be moved. ${failures.join('; ')}`);
return;
}
if (moved > 0) {
await clearStored();
toast.success(`${moved} stored agent${moved === 1 ? '' : 's'} moved into the registry.`);
} else {
/* Nothing to move — every stored id already exists in the registry, so
the preferences copy is redundant and can go. */
await clearStored();
}
})();
}, [enabled, rows, stored, clearStored, save]);
}

View File

@@ -1,4 +1,4 @@
import { canonicalPage } from '@/lib/skills/surfaces';
import { DOMAIN_SURFACES, canonicalPage } from '@/lib/skills/surfaces';
import { pageKeyForContext } from '@/lib/skills/registry';
import { getAgent } from './registry';
import { reasoningFor } from './vocabulary';
@@ -122,19 +122,33 @@ export function agentsForContext(agents = [], contextId) {
}
/**
* The general agent every page falls back to.
* Whether an agent is a *general* one: it covers every domain surface.
*
* Named once, here, because two different things need it and neither should
* carry its own copy: resolving a default, and deciding whether a page has an
* agent *of its own*.
* Derived from the definition rather than matched against an id. This used to
* be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that
* two functions below branched on, which is precisely the thing agent specs
* being data is supposed to make impossible. With a key in the runtime,
* renaming the general agent silently demotes it, deleting it leaves two dead
* branches, and a workspace can never write a second general agent because
* only one id is privileged.
*
* Reading `pages` instead makes it a fact about the spec: an agent listing
* every surface that holds workforce records is an agent with no speciality,
* which is exactly what makes it the sensible fallback. A general agent may
* list *more* than the domain surfaces — the workforce agent also covers the
* agent workspace — so this is a subset test, never an equality one.
*/
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
export function isGeneralAgent(agent) {
if (!agent?.pages?.length) return false;
const covered = new Set(agent.pages);
return DOMAIN_SURFACES.every((id) => covered.has(id));
}
/**
* The agent written *for* this page, if there is one.
*
* The general agent is deliberately excluded. It covers every surface — which
* is what makes it a fallback — so counting it as a page's own agent would make
* General agents are deliberately excluded. One covers every surface — which is
* what makes it a fallback — so counting it as a page's own agent would make
* "does this page have a native agent?" true everywhere and the distinction
* meaningless.
*
@@ -142,20 +156,26 @@ export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
* a broken one: see `resolveDefaultAgent`.
*/
export function nativeAgentForContext(agents = [], contextId) {
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
return agentsForContext(agents, contextId).find((a) => !isGeneralAgent(a)) || null;
}
/**
* The general agent, when it can answer here.
* The general agent, when one can answer here.
*
* Falls through to whichever published agent covers the page if the general one
* has been archived or does not list this surface — a page must never be left
* without an agent because of how the registry happens to be configured.
* `agentsForContext` has already narrowed to published agents covering this
* page and sorted them most-specific-first, so general agents sit at the end
* and the *last* of them is the broadest. Taking that one is identical to the
* old behaviour while exactly one general agent exists, and is a stated choice
* rather than an arbitrary one once a workspace has written a second.
*
* Falls through to whichever published agent covers the page when no general
* one does — a page must never be left without an agent because of how the
* registry happens to be configured.
*/
export function fallbackAgentForContext(agents = [], contextId) {
const general = getAgent(agents, FALLBACK_AGENT_ID);
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
return agentsForContext(agents, contextId)[0] || null;
const covering = agentsForContext(agents, contextId);
const general = covering.filter(isGeneralAgent);
return general[general.length - 1] || covering[0] || null;
}
/**

View File

@@ -1,8 +1,12 @@
import { useCallback, useMemo } from 'react';
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
import {
useAgentDefinitions, useAuthoredSources, useDeleteAgentDefinition,
useMigrateStoredAgents, useSaveAgentDefinition,
} from './agentStore';
import { reportSave } from '@/lib/skills/saveFeedback';
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
import { customAgentSource } from './customAgents';
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
/**
@@ -24,11 +28,36 @@ import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agen
*/
export function useAgents() {
const preferences = usePreferences();
const updatePreferences = useUpdatePreferences();
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
/* Authored agents come from the registry the runtime resolves from, not from
this account's preferences. They used to come from preferences, and the
consequence was an agent that the list called "published" and every run
answered 404: the backend stored it faithfully, in a table the runtime does
not read. Skills are still preferences-backed — that is a separate move. */
const definitions = useAgentDefinitions();
const saveDefinition = useSaveAgentDefinition();
const deleteDefinition = useDeleteAgentDefinition();
const rows = useMemo(() => definitions.data || [], [definitions.data]);
const stored = useAuthoredSources(rows);
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
/* Anything an author wrote before the store moved. Without this it would stop
being displayed rather than being carried across — the rows would sit in
the preferences column, unread, and the agent would appear to have been
deleted. Runs once, and clears the old copy only after every write lands. */
const updatePreferences = useUpdatePreferences();
const legacy = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
useMigrateStoredAgents({
rows,
stored: legacy,
enabled: !definitions.isPending,
clearStored: useCallback(
() => updatePreferences.mutateAsync({ customAgents: [] }),
[updatePreferences]
),
});
const { agents, diagnostics } = useMemo(
() => readAgentRegistry(stored, { customSkills }),
[stored, customSkills]
@@ -63,23 +92,29 @@ export function useAgents() {
const problem = validateAgentSource(source);
if (problem) return { ok: false, error: problem };
const { agent, next } = upsertCustomAgent(stored, source);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(message || `${agent.name} saved`)
);
const agent = parseAgent(source, { custom: true });
try {
await saveDefinition.mutateAsync({ definitionId: agent.id, markdown: source });
} catch (error) {
/* The service refuses a definition naming a tool that does not exist, and
says which. Surfaced rather than swallowed: the author picked it, and
the alternative is an agent quietly missing the capability. */
return { ok: false, error: error?.message || 'That agent could not be saved.' };
}
reportSave(message || `${agent.name} saved`);
return { ok: true, agent };
}, [stored, updatePreferences]);
}, [saveDefinition]);
/** Removes the account's definition. A shipped agent returns to its shipped form. */
const remove = useCallback(async (id) => {
const next = removeCustomAgent(stored, id);
await updatePreferences.mutateAsync(
{ customAgents: next },
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
);
try {
await deleteDefinition.mutateAsync(id);
} catch (error) {
return { ok: false, error: error?.message || 'That agent could not be removed.' };
}
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed');
return { ok: true };
}, [stored, updatePreferences, isShipped]);
}, [deleteDefinition, isShipped]);
/**
* Publishes, refusing to overwrite a newer published version.
@@ -123,7 +158,8 @@ export function useAgents() {
return {
agents,
diagnostics,
saving: updatePreferences.isPending,
loading: definitions.isPending,
saving: saveDefinition.isPending || deleteDefinition.isPending,
isShipped,
isOverridden,
sourceFor,

View File

@@ -95,8 +95,47 @@ export function summarise(hires) {
};
}
/** The application stages this product counts, in the order they happen. */
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
/**
* The application stages this product counts, in the order they happen.
*
* `assigned` sits past `hired`: a candidate assigned to a shift was hired to get
* there, which is why the backend counts the two together everywhere it asks how
* many people a role actually has (`status IN ('hired','assigned')`).
*/
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'assigned'];
/**
* Every value of the `application_status` enum. Declared here so the ladder can
* be checked against it: the funnel lost `rejected` and `assigned` for as long
* as it did because nothing in the app held the full list to check against.
*/
export const APPLICATION_STATUSES = [
'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned',
];
/** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
export const HIRED_STATUSES = ['hired', 'assigned'];
/**
* How far a candidate got.
*
* `rejected` is terminal from any stage and overwrites the stage it was reached
* from, so its status alone cannot place it on the ladder. The furthest honest
* claim is that somebody assessed them, so it ranks with `ai_screened`: counted
* as screened, never counted as shortlisted.
*
* A status this does not recognise ranks -1 and drops out of every bucket
* including `applied` — which is how `rejected` and `assigned` used to vanish
* from a position's funnel entirely. The suite checks this covers the schema.
*/
export const rankOf = (status) =>
status === 'rejected' ? STAGE_ORDER.indexOf('ai_screened') : STAGE_ORDER.indexOf(status);
/** Everyone who reached this stage or went past it. */
export const atOrBeyond = (applications, stage) => {
const from = STAGE_ORDER.indexOf(stage);
return applications.filter((a) => rankOf(a.status) >= from);
};
/**
* Applied → screened → shortlisted → interview → hired, with the pass-through
@@ -107,17 +146,14 @@ export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview'
* 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 reached = (stage) => atOrBeyond(applications, stage).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 },
{ key: 'ai_screened', label: 'Screened', count: reached('ai_screened') },
{ key: 'shortlisted', label: 'Shortlisted', count: reached('shortlisted') },
{ key: 'interview', label: 'Interview', count: reached('interview') },
{ key: 'hired', label: 'Hired', count: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
];
const transitions = stages.slice(1).map((stage, i) => {

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { base44 } from '@/api/base44Client';
import { API_BASE_URL, base44 } from '@/api/base44Client';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
@@ -177,12 +177,141 @@ export function useUserActivity() {
});
}
/**
* What Owliver could usefully be asked on this page.
*
* The server answers, and the server is the only thing that decides: it filters
* the readings against the caller's role and — with nothing typed — ranks them
* against the organization's actual state in PostgreSQL. This hook requests and
* caches; it does not score, filter or reorder.
*
* Keyed on the page and the query together, so every distinct composer state
* has its own cache entry and typing back to something already asked is
* answered without a request. `placeholderData` keeps the previous answer on
* screen while the next one is in flight, which is what stops the chip row
* emptying and refilling between keystrokes.
*
* A failure resolves to no suggestions rather than to an error state. The panel
* still works with none — the composer is what the reader came for — and an
* error banner over a suggestion is a bigger interruption than the suggestion
* was worth.
*
* The untyped request is the one that reads the database, so it is invalidated
* whenever something the context counts has changed. See `owliverContextKey`.
*/
export const owliverContextKey = ['owliverSuggestions'];
/**
* Whether this backend serves the endpoint at all.
*
* A deployment lag, not a bug, and it has happened: the route exists in the Go
* source and is not yet on the host a given dev server proxies to, which
* answers `404` for it and `200` for everything else. Every keystroke would
* then be a request that cannot succeed, and no later keystroke can change the
* answer — so the first `404` closes the circuit for the rest of the page load.
*
* Only `404` closes it. A `401` means the session is not signed in, a `500`
* means the server had a bad moment, and an unreachable API means it is being
* restarted: all three are worth trying again, and treating them as "this
* backend does not have the route" would silence suggestions permanently over a
* temporary fault. Module-level rather than component state because the fact is
* about the backend, not about one panel.
*/
let routeUnavailable = false;
/**
* Say it once, out loud.
*
* The circuit breaker below stops the requests; it must not stop the reader
* finding out why the chip row is empty. A suggestion that quietly never
* arrives is indistinguishable from a page that has nothing to suggest, and
* that ambiguity is what makes this fault expensive — it looks like a frontend
* bug and it is a deployment lag.
*
* So the panel degrades to no suggestions, and the console says exactly which
* request failed and what would fix it. Nothing is substituted for the missing
* answer: there is no fallback list, and no locally-ranked catalogue standing
* in for the server's. An empty row is the honest rendering of "the backend
* could not tell us".
*/
function reportMissingRoute() {
if (routeUnavailable) return;
routeUnavailable = true;
console.warn(
`[krow] ${API_BASE_URL}/owliver/suggestions answered 404. The backend this ` +
'frontend is talking to does not register that route, so Owliver will show ' +
'no suggestion chips until it is deployed. This is a backend deployment ' +
'version mismatch, not a frontend fault — the route exists in the Go source ' +
'(go-api/internal/httpserver/owliver.go). Nothing is being substituted for ' +
'the missing answer.'
);
}
/**
* Ask the API what could usefully be asked here.
*
* The one place the request is made, so the circuit breaker above cannot be
* bypassed by a caller that reaches for `base44.owliver` directly. Resolves to
* an empty list on any failure: the panel works with no suggestions — the
* composer is what the reader came for — and an error banner over a suggestion
* is a bigger interruption than the suggestion was worth.
*/
/** @param {any} request */
export async function fetchOwliverSuggestions({ page, query = '' } = {}) {
if (!page || routeUnavailable) return [];
try {
return await base44.owliver.suggestions({ page, query });
} catch (error) {
if (error?.status === 404) reportMissingRoute();
return [];
}
}
/**
* What Owliver could usefully be asked on this page.
*
* The server answers, and the server is the only thing that decides: it filters
* the readings against the caller's role and — with nothing typed — ranks them
* against the organization's actual state in PostgreSQL. This hook requests and
* caches; it does not score, filter or reorder.
*
* Keyed on the page and the query together, so every distinct composer state
* has its own cache entry and typing back to something already asked is
* answered without a request. `placeholderData` keeps the previous answer on
* screen while the next one is in flight, which is what stops the chip row
* emptying and refilling between keystrokes.
*
* The untyped request is the one that reads the database, so it is invalidated
* whenever something the context counts has changed. See `owliverContextKey`.
*/
/** @param {any} request */
export function useOwliverSuggestions({ page, query = '', enabled = true } = {}) {
const typed = String(query || '').trim();
return useQuery({
queryKey: ['owliverSuggestions', page || null, typed],
queryFn: () => fetchOwliverSuggestions({ page, query: typed }),
enabled: Boolean(page) && enabled,
/* Long enough that a keystroke returned to is instant, short enough that a
position created in another tab is reflected on the next open. */
staleTime: 30_000,
placeholderData: (previous) => previous,
/* `fetchOwliverSuggestions` never rejects, so a retry would only repeat a
request that already resolved. Said explicitly so the default of one
retry does not read as a safety net that is doing something. */
retry: false,
});
}
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
/* The position count the server ranks suggestions against has changed, so
what Owliver offers next has to be asked again rather than read from a
cache written before the record existed. */
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('create_position');
},
});
@@ -195,6 +324,7 @@ export function useUpdateJobPosting() {
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
@@ -205,6 +335,7 @@ export function useCreateApplication() {
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('apply_job');
},
});
@@ -216,6 +347,7 @@ export function useUpdateApplication() {
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
@@ -238,6 +370,7 @@ export function useScreenCandidate() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('screen_candidate');
},
});
@@ -268,6 +401,7 @@ export function useScreenAllCandidates() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('screen_candidate');
},
});
@@ -326,6 +460,7 @@ export function useHireCandidate() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['staff'] });
/* The `hire_candidate` entry is written inside the same transaction as the
hire, so there is nothing to log here — only something to refetch. A
@@ -358,6 +493,7 @@ export function useCreateInterview() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
logActivity('start_interview');
},
});
@@ -507,6 +643,7 @@ export function useAssignWorkers() {
looking at three different moments. */
queryClient.invalidateQueries({ queryKey: ['assignments'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},
@@ -549,6 +686,7 @@ export function useMarkInterviewReady() {
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
},

View File

@@ -5,7 +5,18 @@
const clamp = (n) => Math.max(0, Math.min(100, n));
export function computeKrowScore(profile) {
const attendance = clamp(profile.attendance_score ?? 100);
/**
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar
* rather than an accusatory 0. That default must not become a scoring input.
*
* With no shifts behind it, attendance is an assumption, and it carries 30% of
* reliability and 12% of the KROW score — enough on its own to lift a profile
* with no evidence whatsoever from 0 to 12, out of "Not yet scored" and into
* the low-scoring band, where every ranking then reads it as a poor worker
* rather than an unassessed one. `shifts_completed` is the record it needs.
*/
const hasAttendanceRecord = (profile.shifts_completed || 0) > 0;
const attendance = hasAttendanceRecord ? clamp(profile.attendance_score ?? 100) : 0;
const performance = clamp(profile.performance_score ?? 0);
const education = clamp(
(profile.completed_courses?.length || 0) * 12 +
@@ -34,7 +45,20 @@ export function computeKrowScore(profile) {
return {
krow_score,
reliability,
breakdown: { attendance, performance, education, clientReviews, supervisorReviews, growth, experience },
/* Rounded for output only — the weighted sums above use the exact values.
These are percentages a person reads, and (4.4 / 5) * 100 is
88.00000000000001 in binary floating point. */
breakdown: {
/* null, not 0, where there is no record — the product's own rule for an
absent dimension, so a reader renders "—" instead of a flat zero. */
attendance: hasAttendanceRecord ? Math.round(attendance) : null,
performance: Math.round(performance),
education: Math.round(education),
clientReviews: Math.round(clientReviews),
supervisorReviews: Math.round(supervisorReviews),
growth: Math.round(growth),
experience: Math.round(experience),
},
};
}
@@ -50,7 +74,11 @@ export function computeProfileCompletion(profile) {
if (profile.completed_courses?.length) filled++;
if (profile.earned_badges?.length) filled++;
if (profile.ai_interview_score > 0) filled++;
const total = CORE_FIELDS.length + 5;
/* Seven optional fields can increment above, not five: languages,
availability, skills, experience, completed_courses, earned_badges and the
interview score. With a denominator of five the percentage ran past 100 —
the most complete seeded profile computed 107% complete. */
const total = CORE_FIELDS.length + 7;
return Math.round((filled / total) * 100);
}

View File

@@ -1,4 +1,5 @@
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
import { CRITERIA_LABELS } from '@/lib/positionModel';
import { poolFor } from '@/lib/workforce';
import { candidateRoute } from './workforceFlow';
@@ -108,13 +109,8 @@ function matchDetail(row) {
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);
};
/* The stage ladder lives in hiringRecords: one derivation, so a skill and the
page it is read beside cannot disagree about how many were screened. */
/** Applications counted by period — the reading behind an activity section. */
function activityOverTime(applications, periods, now) {
@@ -152,7 +148,7 @@ function pipelineOf(applications) {
{ 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 },
{ id: 'hired', label: 'Hired', value: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
];
return {
@@ -304,7 +300,7 @@ const RESOLVERS = {
...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' && {
HIRED_STATUSES.includes(candidate?.status) && {
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
},
].filter(Boolean);
@@ -437,7 +433,7 @@ const RESOLVERS = {
* applications and the staff records they became, never stored.
*/
'hires.performance': ({ applications = [], staff = [] }) => {
const hired = applications.filter((a) => a.status === 'hired');
const hired = applications.filter((a) => HIRED_STATUSES.includes(a.status));
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))

View File

@@ -44,7 +44,19 @@ const PER_SKILL = 3;
const TOTAL = 4;
/**
* The chips a page's skills offer.
* The chips a page's skills *declare*.
*
* NOT what the panel renders, and no longer on the production path. Which
* suggestions Owliver offers is decided by `GET /api/v1/owliver/suggestions` —
* the backend filters against the caller's role and ranks against the rows in
* PostgreSQL, and `lib/skills/serverSuggestions.js` turns the reply into chips.
* Nothing in `src/` ranks a suggestion any more.
*
* What is left here is the reading of a *definition*: given a skill file, what
* did its author write under `owliver.suggestions`, and which of those name a
* capability the skill actually offers. That is a question about the Markdown
* rather than about the workspace, and it is what the editor's preview and the
* definition checks in `scripts/skill-check.mjs` ask.
*
* Capped deliberately. A workspace with six skills attached would otherwise
* bury the page's own suggestions under twenty of them, and a suggestion nobody
@@ -365,52 +377,3 @@ export function presentable(data) {
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);
}

View File

@@ -477,10 +477,25 @@ export function positionCreatedReply(position) {
);
}
/** The write failed. The draft is kept, so the answers are not lost. */
export function positionFailedReply() {
/**
* The write failed. The draft is kept, so the answers are not lost.
*
* The server's own message is said out loud when there is one. This used to be
* a fixed sentence, and a fixed sentence is the wrong answer to three different
* failures: a validation error naming a field, a permission refusal, and an API
* that is not running all read as "I could not create that position", leaving
* the reader to guess which of the three they are looking at and what to change.
*
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
* server's wording verbatim — so the reason is the API's, not one invented here
* from a status code.
*/
export function positionFailedReply(reason = null) {
const said = String(reason || '').trim();
return doc(
text('I could not create that position.'),
said ? note(said) : null,
note('Nothing was saved. Choose Create position to try again.')
);
}

View File

@@ -0,0 +1,93 @@
import { getContext } from '@/components/ai-assistant/contexts';
/**
* The server's suggestions, as chips the panel can already run.
*
* This module is the whole of the frontend's part in suggestions, and it is
* deliberately small. `GET /api/v1/owliver/suggestions` decides *which*
* questions are worth offering and *in what order* — against the caller's role
* and, when nothing has been typed, against the organization's actual state in
* PostgreSQL. Nothing here re-decides any of that. There is no score, no
* threshold, no reordering and no filter: the list arrives ranked and leaves in
* the same order it arrived.
*
* What is left to do is a translation, and only one. A suggestion names an
* `intent`, which is the id of one of the page's own capabilities — the same id
* the manifests in `components/ai-assistant/capabilities/` declare, held to the
* server's catalogue by `TestIntentIDsAreFrontendCapabilities`. A chip carrying
* that id in its `capability` field runs that capability directly, through the
* path a page chip has always taken. So this maps `intent` onto `capability`
* and hands the chip back.
*
* An `intent` the page does not declare is not dropped and not guessed at: the
* chip is handed over as an ordinary question, and the existing intent routing
* answers it exactly as the same words typed by hand would be. One path, and a
* server that names something this build has not shipped yet degrades to asking
* rather than to a dead chip.
*/
/**
* The capability ids a page context declares.
*
* Read from the same manifest the panel dispatches on, so "can this chip run
* directly" is answered by the thing that would have to run it rather than by a
* list kept alongside.
*/
function capabilityIds(contextId) {
const context = getContext(contextId);
return new Set((context?.capabilities || []).map((capability) => capability.id));
}
/**
* One server suggestion as a chip.
*
* `label` and `prompt` are both the server's text. They are the same string on
* purpose: the text *is* the question, so showing one wording and sending
* another would mean the reader clicked something other than what ran.
*
* `shape` carries the server's `capability` — the section type the query asked
* to be drawn as ("as a flow", "as a table"). It rides along for the renderer
* and is deliberately not merged into `capability`, which names the reading
* rather than its drawing; conflating the two would dispatch a question about
* hiring activity to a capability called `flow` that does not exist.
*/
export function suggestionChip(suggestion, declared) {
const text = String(suggestion?.text || '').trim();
if (!text) return null;
const intent = String(suggestion?.intent || '').trim();
const shape = String(suggestion?.capability || '').trim();
return {
label: text,
prompt: text,
/* Only when the page can actually run it. Anything else is asked. */
...(intent && declared.has(intent) ? { capability: intent } : {}),
...(shape ? { shape } : {}),
/* Provenance, so a chip that came from the API is distinguishable from a
follow-up an answer raised. Nothing dispatches on it; it exists because
"where did this suggestion come from" is the first question anyone asks
of a suggestion that looks wrong. */
source: 'api',
};
}
/**
* A whole response, in the order the server ranked it.
*
* Returns a new array only when there is something in it, so an empty answer is
* referentially stable and a composer that matches nothing does not rerender
* the chip row on every keystroke.
*/
const NONE = [];
export function suggestionChips(suggestions = [], contextId = null) {
if (!Array.isArray(suggestions) || !suggestions.length) return NONE;
const declared = capabilityIds(contextId);
const chips = suggestions
.map((suggestion) => suggestionChip(suggestion, declared))
.filter(Boolean);
return chips.length ? chips : NONE;
}