agnets done
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
181
src/lib/agents/agentStore.js
Normal file
181
src/lib/agents/agentStore.js
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'] });
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.')
|
||||
);
|
||||
}
|
||||
|
||||
93
src/lib/skills/serverSuggestions.js
Normal file
93
src/lib/skills/serverSuggestions.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user