update the chatbox

This commit is contained in:
2026-08-25 18:56:07 +05:30
parent 6c105f5b76
commit ab9adde6cd
13 changed files with 1158 additions and 201 deletions

View File

@@ -30,6 +30,16 @@ import { useActiveAgent } from './AgentContext';
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
import { PromptInput } from './PromptInput';
import { PromptChips } from './PromptChips';
import { rankPrompts } from './matchPrompts';
/**
* The chip row's "nothing to offer", as one shared array.
*
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
* fresh `[]` each time would rerender the row — and anything memoized against
* it — throughout the whole of a query that is still too short to match.
*/
const EMPTY_PROMPTS = [];
/**
* Owliver History — the conversations that came before.
@@ -522,12 +532,25 @@ export default function KrowAssistant({
() => buildIntro(context.id, facts, userName),
[context.id, facts, userName]
);
/* Suggestions are the page's own, with any skill that offers one on the front.
The last answer can also propose follow-ups — the role list after "create a
position" — which replace the standing set until the next turn. */
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
/**
* Everything this page can be asked, in the chips that already know how to
* ask it.
*
* Unchanged in what it collects and in what order: the agent's starters, the
* declared suggestions of every attached skill, the skills that carry a
* prompt, and the page's own derived prompts, de-duplicated by intent. Each
* chip keeps the metadata that makes it executable — a page `capability`, a
* `skillId`/`skillCapability` pair, a `positionId`, a `route` — because that
* is what `runPrompt` dispatches on.
*
* What changed is only that this is no longer what the panel renders. It is
* the set that `prompts` below chooses from, so an opening panel can offer
* nothing while a typed query can still reach any of it. Building it eagerly
* costs nothing — it is derived from data already in hand and memoized on it
* — and building it lazily would mean the first keystroke paid for the whole
* catalogue.
*/
const catalogue = React.useMemo(() => {
/* A definition's own suggestions come first: they are the only ones written
for this workspace rather than derived from the page, and they are capped
by the resolver so a workspace with several skills attached cannot bury
@@ -586,7 +609,40 @@ export default function KrowAssistant({
});
/* `pageContext` decides which suggestions can answer without asking, so the
chips re-rank when a position is opened or closed. */
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
}, [skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
/**
* What the chip row actually shows, which is one of three separate things.
*
* They are separate states, not one merged list, because they answer to
* different owners. Follow-ups belong to the answer that raised them; typed
* matches belong to the composer; the catalogue belongs to the page. Only one
* of them can be true at a time, and the order below is that precedence.
*
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
* the answers to it — the role list after "create a position". They are
* never capped and never filtered, and they stand until the next turn or
* until the reader starts typing something else.
*
* 2. Typed matches. From the second meaningful character, the catalogue
* above is ranked against the query and the best three are offered.
* These are the catalogue's own chip objects, so clicking one runs the
* same capability or skill it would have run when the panel offered the
* whole list outright.
*
* 3. Nothing. An empty composer offers no chips at all. The panel used to
* open on a dozen of them, which taught the range of what could be asked
* by saying all of it at once and pushed the composer — the thing the
* reader came for — under a wall of suggestions. The greeting still
* carries the page's context; `buildIntro` reads the same fact sheet it
* always did.
*/
const followUp = messages[messages.length - 1]?.followUp;
const typed = input.trim();
const prompts = React.useMemo(() => {
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
return rankPrompts(catalogue, typed);
}, [typed, followUp, catalogue]);
/* The newest assistant turn, which is the one that carries the rating. */
const lastAnswerIndex = React.useMemo(

View File

@@ -697,7 +697,7 @@ const userActivityInsights = (f) => {
* panel and the log can never disagree about what matters.
*/
const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview'];
const MEDIUM_SEVERITY = ['assign_employee', 'screen_candidate', 'start_interview'];
/**
* Unusual activity — what is out of pattern, stated as a pattern.

View File

@@ -0,0 +1,171 @@
/**
* Ranking the panel's own suggestions against what is being typed.
*
* This is not a catalogue and deliberately does not own one. Owliver already
* decides what can be asked on a page — the agent's starters, every attached
* skill's declared suggestions, the page's own derived prompts — and each of
* those chips carries the metadata that makes it *executable*: a `capability`
* that names one of the page's answers, a `skillId`/`skillCapability` pair that
* names a skill's reading, a `positionId` that says which record, a `route` for
* the ones that navigate. All this module does is choose which of those
* already-built chips are worth showing for a given query, and hand them back
* unchanged so that clicking one runs exactly what it always ran.
*
* Returning the original object rather than a copy is the whole contract. A
* ranked chip is the same chip; `runPrompt` cannot tell it apart from one that
* arrived unfiltered, so nothing about how a suggestion executes depends on
* whether it was typed towards or offered outright.
*/
/**
* The shortest query worth ranking. Below it the panel shows nothing at all —
* one character matches most of the catalogue, which is the wall of chips this
* replaced.
*/
export const MIN_QUERY_CHARS = 2;
/** Never more than a row. The composer is what the reader came for. */
export const MAX_MATCHES = 3;
/** One shared empty array, so a keystroke that matches nothing is referentially
stable and does not rerender the chip row. */
const NONE = [];
const lower = (value) => String(value ?? '').toLowerCase();
/**
* Letters and digits only, Unicode aware — the same thing the reader would
* count. Punctuation alone never counts as having typed anything.
*/
const meaningful = (value) => (String(value ?? '').match(/[\p{L}\p{N}]/gu) || []).length;
/** A string as the words worth matching on. */
const words = (value) => lower(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
/**
* Words too common to carry a subject.
*
* Only used to stop a query made *entirely* of them from matching the whole
* catalogue: "show me the" should offer nothing rather than everything. A
* stop word alongside a real term is still scored, because "show pipeline"
* should rank a chip saying both above one saying only the second.
*/
const STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did',
'show', 'me', 'my', 'our', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or',
'with', 'what', 'how', 'why', 'can', 'you', 'i', 'it', 'please', 'give',
'tell', 'about', 'this', 'that', 'any', 'all',
]);
/**
* How well one term matches one field.
*
* Prefix matching is what makes this feel like typing rather than searching:
* "platf" has to find "Platform health" four characters before the word is
* finished. Infix matching is allowed only from four characters, where a
* fragment is specific enough that finding it mid-word is a hit rather than an
* accident — "line" must not match "pipeline" while "peli" reasonably does.
*/
function fieldScore(term, fieldWords, exact, partial) {
let best = 0;
for (const word of fieldWords) {
if (word === term) return exact;
if (word.startsWith(term)) best = Math.max(best, partial);
else if (term.length >= 4 && word.includes(term)) best = Math.max(best, partial - 1);
}
return best;
}
/**
* What a chip resolves to, as words.
*
* A capability id is not decoration — `pipeline-health` is the name of the
* reading the chip runs, and it is frequently the only place the subject
* appears. The Control Center's bottleneck chip reads "Bottleneck at
* interviewed" and sends a sentence about candidates dropping between stages;
* nothing in either says "pipeline", and typing that word is exactly how a
* reader would look for it. So the chip's own routing metadata is matched too,
* at the lowest weight of the three fields — it is what the chip *is*, not what
* it says, and a chip that says the word should always rank above one that
* merely resolves to it.
*/
const intentWords = (chip) => words(
[chip?.capability, chip?.skillId, chip?.skillCapability].filter(Boolean).join(' ')
);
/**
* A chip's score for a query, or 0 for "do not offer this".
*
* The label is weighted above the prompt because the label is what the reader
* sees: a chip that reads "Platform health" is a better answer to "platform"
* than one that happens to mention the word in the sentence it sends, even
* though both would answer.
*/
function score(chip, terms, phrase) {
const label = lower(chip?.label);
const prompt = lower(chip?.prompt);
if (!label && !prompt) return 0;
let total = 0;
let matched = 0;
/* The whole query as one phrase, which is the strongest signal there is —
"pipeline health" typed in full should beat two chips that each carry one
of those words. */
if (label.includes(phrase)) total += 12;
else if (prompt.includes(phrase)) total += 7;
const labelWords = words(label);
const promptWords = words(prompt);
const routeWords = intentWords(chip);
for (const term of terms) {
const hit = Math.max(
fieldScore(term, labelWords, 6, 4),
fieldScore(term, promptWords, 4, 2),
fieldScore(term, routeWords, 3, 2)
);
if (hit) {
matched += 1;
total += hit;
}
}
/* A chip has to actually be about something that was typed. */
if (!matched) return 0;
/* Every term landing somewhere is worth more than most of them landing. */
if (matched === terms.length) total += 3;
/* A suggestion that would have to ask which record before it could answer
ranks below one that answers — the same order the resolver already puts
them in when they are offered unfiltered. */
if (chip?.deferred) total -= 3;
return total;
}
/**
* The best few of `prompts` for `query`, in the chips' own objects.
*
* Stable: chips scoring equally keep the order they arrived in, which is the
* order the panel already considers most useful — agent starters, then declared
* skill suggestions, then the page's derived prompts.
*/
export function rankPrompts(prompts = [], query = '', max = MAX_MATCHES) {
const text = String(query ?? '').trim();
if (meaningful(text) < MIN_QUERY_CHARS) return NONE;
const terms = words(text);
if (!terms.length || terms.every((term) => STOP_WORDS.has(term))) return NONE;
const phrase = lower(text);
const scored = [];
prompts.forEach((chip, index) => {
const value = score(chip, terms, phrase);
if (value > 0) scored.push({ chip, value, index });
});
scored.sort((a, b) => b.value - a.value || a.index - b.index);
return scored.length ? scored.slice(0, max).map((entry) => entry.chip) : NONE;
}