Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f31b481
docs(AI-122): add escalate-to-human design
stevenarnold-ed-fi Jun 22, 2026
fd04413
docs(AI-122): add /fiona escalate implementation plan
stevenarnold-ed-fi Jun 22, 2026
e2347c3
feat(AI-122): add summarizeForEscalation helper
stevenarnold-ed-fi Jun 22, 2026
a462612
test(AI-122): cover summarizeForEscalation unconfigured-client path
stevenarnold-ed-fi Jun 22, 2026
351cee2
feat(AI-122): allow optional interactionType on feedback records
stevenarnold-ed-fi Jun 22, 2026
50faed9
feat(AI-122): add shared postEscalation helper
stevenarnold-ed-fi Jun 22, 2026
20ce410
test(AI-122): harden postEscalation tests (thread_ts, null-summary, f…
stevenarnold-ed-fi Jun 22, 2026
cf7aa83
feat(AI-122): add /fiona escalate sub-command
stevenarnold-ed-fi Jun 22, 2026
b98be68
chore(AI-122): wire escalation config and docs
stevenarnold-ed-fi Jun 22, 2026
e81e291
fix(AI-122): exclude escalation rows from feedback response-rate KPI
stevenarnold-ed-fi Jun 22, 2026
01752cd
docs: add escalation redesign spec [AI-122]
stevenarnold-ed-fi Jun 23, 2026
9c35e74
docs: refine escalation spec — Get Live Help label, drop suggestions …
stevenarnold-ed-fi Jun 23, 2026
331b83e
docs: add escalation redesign implementation plan [AI-122]
stevenarnold-ed-fi Jun 23, 2026
36f7431
Merge branch 'main' into AI-122
stevenarnold-ed-fi Jun 29, 2026
af4f24d
Potential fix for pull request finding
stevenarnold-ed-fi Jun 29, 2026
350efbc
fix(AI-122): include /fiona escalate in /fiona help text
Copilot Jun 29, 2026
74b6d4b
docs(AI-122): update feedback-store JSDoc for escalation metadata
Copilot Jun 29, 2026
7df4fda
Merge branch 'AI-122' of https://github.com/Ed-Fi-Alliance-OSS/Fiona …
stevenarnold-ed-fi Jun 29, 2026
a754937
Update deploy-fiona-slack-container.yml
stevenarnold-ed-fi Jun 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/deploy-fiona-slack-container.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ jobs:
cosmosAccountName="${{ vars.COSMOS_ACCOUNT_NAME }}" \
captureAllConversations=${{ vars.CAPTURE_ALL_CONVERSATIONS || 'false' }} \
deploymentType="${{ inputs.environment || 'insiders' }}" \
escalationUsergroupId=${{ vars.ESCALATION_USERGROUP_ID || '' }} \
escalationChannel="${{ vars.ESCALATION_CHANNEL || '' }}" \
skipRoleAssignments=true

- name: Verify deployment
Expand Down
6 changes: 6 additions & 0 deletions apps/fiona-slack/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ RATE_LIMIT_WINDOW_MS=3600000

# Optional, override the default system prompt sent to the LLM.
# SYSTEM_PROMPT=Your custom system prompt here.

# Escalation (/fiona escalate). ESCALATION_CHANNEL is the destination channel ID
# (e.g. C0123456789) — the bot must be a member of that channel to post.
# ESCALATION_CHANNEL=C0123456789
# Optional: a Slack user group ID (e.g. S0123456789) to @-mention on escalation.
# ESCALATION_USERGROUP_ID=S0123456789
2 changes: 1 addition & 1 deletion apps/fiona-slack/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
{
"command": "/fiona",
"description": "Interact with Fiona, your Ed-Fi AI assistant",
"usage_hint": "[help | ask <question> | search <query>]",
"usage_hint": "[help | ask <question> | search <query> | escalate]",
"should_escape": false
}
]
Expand Down
166 changes: 166 additions & 0 deletions apps/fiona-slack/src/agent/escalation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// SPDX-License-Identifier: Apache-2.0
// Licensed to the Ed-Fi Alliance under one or more agreements.
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { recordFeedback } from './feedback-store.js';
import { recordInteraction } from './interaction-store.js';
import { summarizeForEscalation } from './llm-caller.js';
import { getUser } from './slack-users-store.js';

const HISTORY_LIMIT = 20;
const SLACK_BLOCK_TEXT_LIMIT = 2900; // leave headroom under Slack's 3000-char section limit

/**
* Build a plain-text transcript of the recent conversation. Uses thread replies
* when a threadTs is available, otherwise the channel's recent history.
*
* @returns {Promise<string>} Newline-joined "*Who:* text" lines, or '' on failure.
*/
async function fetchTranscript(client, { channelId, threadTs, logger }) {
try {
let messages;
if (threadTs) {
const res = await client.conversations.replies({ channel: channelId, ts: threadTs, limit: 50 });
messages = res.messages ?? [];
} else {
const res = await client.conversations.history({ channel: channelId, limit: HISTORY_LIMIT });
messages = (res.messages ?? []).reverse(); // history returns newest-first
}
return messages
.filter((m) => m.text)
.map((m) => {
const who = m.bot_id ? 'Fiona' : m.user ? `<@${m.user}>` : 'User';
const text = (m.text ?? '').replace(/^(<@[A-Z0-9]+>\s*)+/, '').trim();
return text ? `*${who}:* ${text}` : null;
})
.filter(Boolean)
.join('\n');
} catch (err) {
logger?.warn?.(`Failed to fetch transcript for escalation: ${err.message}`);
return '';
}
}

/**
* Post an escalation to the configured channel and record it. Shared by the
* /fiona escalate slash command and the proactive escalation flow.
*
* @param {Object} params
* @param {import("@slack/web-api").WebClient} params.client
* @param {string} params.userId
* @param {string} [params.teamId]
* @param {string} params.channelId
* @param {string|null} [params.threadTs]
* @param {string} params.messageTs
* @param {'slash_escalate'|'auto_escalation'} params.source
* @param {boolean} [params.isDm]
* @param {import("@slack/logger").Logger} [params.logger]
* @returns {Promise<{ ok: boolean, errorType: string|null }>}
*/
export async function postEscalation({
client,
userId,
teamId,
channelId,
threadTs = null,
messageTs,
source,
isDm = false,
logger,
}) {
const targetChannel = process.env.ESCALATION_CHANNEL;
if (!targetChannel) {
logger?.warn?.('ESCALATION_CHANNEL is not configured; cannot post escalation.');
return { ok: false, errorType: 'channel_not_configured' };
}

const user = await getUser(userId, logger);
const displayName = user?.displayName || user?.realName || user?.name || `<@${userId}>`;

const transcript = await fetchTranscript(client, { channelId, threadTs, logger });
const summary = await summarizeForEscalation(transcript, logger);

let permalink = null;
if (!isDm) {
try {
const res = await client.chat.getPermalink({ channel: channelId, message_ts: threadTs ?? messageTs });
permalink = res?.permalink ?? null;
} catch (err) {
logger?.warn?.(`Failed to get permalink for escalation: ${err.message}`);
}
}
Comment on lines +84 to +92

const usergroupId = process.env.ESCALATION_USERGROUP_ID;
const mention = usergroupId ? `<!subteam^${usergroupId}> ` : '';

Comment thread
Copilot marked this conversation as resolved.
const locationLink = isDm
? 'Direct message (no permalink)'
: permalink
? `<${permalink}|View conversation>`
: `<#${channelId}>`;
const headerLines = [
`${mention}:rotating_light: *Escalation requested* by *${displayName}*`,
`*Where:* ${locationLink}`,
`*When:* ${new Date().toISOString()}`,
];
if (summary) headerLines.push(`*Summary:* ${summary}`);

let postedTs = null;
try {
const res = await client.chat.postMessage({
channel: targetChannel,
text: `Escalation requested by ${displayName}`,
blocks: [{ type: 'section', text: { type: 'mrkdwn', text: headerLines.join('\n') } }],
});
postedTs = res?.ts ?? null;
} catch (err) {
logger?.error?.(`Failed to post escalation to ${targetChannel}: ${err.message}`);
return { ok: false, errorType: 'post_failed' };
}

if (transcript && postedTs) {
try {
await client.chat.postMessage({
channel: targetChannel,
thread_ts: postedTs,
text: 'Conversation transcript',
blocks: [
{
type: 'section',
text: { type: 'mrkdwn', text: `*Transcript:*\n${transcript}`.slice(0, SLACK_BLOCK_TEXT_LIMIT) },
},
],
});
} catch (err) {
logger?.warn?.(`Failed to post escalation transcript: ${err.message}`);
}
}

recordInteraction({
userId,
teamId,
channelId,
threadTs: threadTs ?? messageTs,
messageTs,
interactionType: source,
status: 'success',
errorType: null,
rateLimited: false,
logger,
}).catch((e) => logger?.warn?.(`Failed to record escalation interaction: ${e.message}`));

recordFeedback({
userId,
channelId,
messageTs,
value: 'escalation',
interactionType: source,
reason: null,
userMessage: transcript || null,
botResponse: summary || null,
logger,
}).catch((e) => logger?.warn?.(`Failed to record escalation feedback: ${e.message}`));

return { ok: true, errorType: null };
}
7 changes: 5 additions & 2 deletions apps/fiona-slack/src/agent/feedback-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,12 @@ async function getContainer(logger) {
* @param {Object} feedback
* @param {string} feedback.userId - Slack user ID
* @param {string} feedback.channelId - Slack channel ID
* @param {string} feedback.messageTs - Timestamp of the bot message being rated
* @param {string} feedback.value - 'good-feedback' or 'bad-feedback'
* @param {string} feedback.messageTs - Bot message timestamp or slash-command trigger_id for escalations
* @param {string} feedback.value - 'good-feedback', 'bad-feedback', or 'escalation'
* @param {string|null} [feedback.reason] - Optional reason for the feedback
* @param {string|null} feedback.userMessage - The user's message that prompted the response
* @param {string|null} feedback.botResponse - The bot's response being rated
* @param {string} [feedback.interactionType] - Optional interaction type (e.g., 'slash_escalate')
* @param {{ warn?: (msg: string) => void }} [feedback.logger] - Optional logger for warnings
*/
export async function recordFeedback({
Expand All @@ -145,6 +146,7 @@ export async function recordFeedback({
reason,
userMessage,
botResponse,
interactionType,
logger,
}) {
const c = await getContainer(logger);
Expand All @@ -162,6 +164,7 @@ export async function recordFeedback({
reason: reason?.trim() ? reason.trim() : null,
userMessage,
botResponse,
...(interactionType ? { interactionType } : {}),
deploymentType: process.env.DEPLOYMENT_TYPE || 'local',
timestamp: new Date().toISOString(),
};
Expand Down
2 changes: 1 addition & 1 deletion apps/fiona-slack/src/agent/interaction-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ export async function getContainer(logger) {
* @param {string} interaction.channelId - Slack channel ID
* @param {string} interaction.threadTs - Interaction session identifier (`thread_ts` for message flows, `trigger_id` for slash commands)
* @param {string} interaction.messageTs - Interaction event identifier (`message_ts` for message flows, `trigger_id` for slash commands)
* @param {string} interaction.interactionType - 'app_mention', 'assistant_message', 'slash_help', 'slash_ask', 'slash_search', or 'slash_unknown'
* @param {string} interaction.interactionType - 'app_mention', 'assistant_message', 'slash_help', 'slash_ask', 'slash_search', 'slash_escalate', 'auto_escalation', or 'slash_unknown'
* @param {string} interaction.status - 'success' or 'error'
* @param {string|null} [interaction.errorType] - Error category if status is 'error'
* @param {boolean} interaction.rateLimited - true if request was rate-limited
Expand Down
36 changes: 36 additions & 0 deletions apps/fiona-slack/src/agent/llm-caller.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,3 +484,39 @@ export async function callLLM(streamer, prompts, logger) {

return { metadata, botText, systemPromptVersion: SYSTEM_PROMPT_VERSION };
}

// ─── Escalation Summary ─────────────────────────────────────────────────────
const ESCALATION_SUMMARY_SYSTEM_PROMPT =
'You summarize a Slack conversation between a user and Fiona (an Ed-Fi AI assistant) for a human support team. ' +
'In 2-4 sentences, state what the user is trying to do and where they got stuck. ' +
'Be factual and concise. Do not add greetings or sign-offs.';

/**
* Produce a short human-readable summary of a conversation transcript for an
* escalation post. Non-streaming. Returns null when the LLM is unconfigured,
* the transcript is empty, or the call fails — callers degrade to transcript-only.
*
* @param {string} transcriptText
* @param {{ warn?: (msg: string) => void }} [logger]
* @returns {Promise<string | null>}
*/
export async function summarizeForEscalation(transcriptText, logger) {
if (!perplexityClient) return null;
if (!transcriptText || !transcriptText.trim()) return null;

try {
const response = await perplexityClient.chat.completions.create({
model: PERPLEXITY_API_MODEL,
messages: [
{ role: 'system', content: ESCALATION_SUMMARY_SYSTEM_PROMPT },
{ role: 'user', content: transcriptText },
],
stream: false,
});
const summary = response?.choices?.[0]?.message?.content;
return typeof summary === 'string' && summary.trim() ? summary.trim() : null;
} catch (error) {
logger?.warn?.(`Failed to generate escalation summary: ${error.message}`);
return null;
}
}
83 changes: 75 additions & 8 deletions apps/fiona-slack/src/listeners/commands/fiona.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// The Ed-Fi Alliance licenses this file to you under the Apache License, Version 2.0.
// See the LICENSE and NOTICES files in the project root for more information.

import { postEscalation } from '../../agent/escalation.js';
import { recordInteraction } from '../../agent/interaction-store.js';
import { checkRateLimit } from '../../agent/rate-limiter.js';

const HELP_TEXT = `*Fiona — your Ed-Fi AI assistant* :wave:
Fiona helps you navigate Ed-Fi documentation, standards, and community resources using natural language.
Comment thread
stevenarnold-ed-fi marked this conversation as resolved.
Expand All @@ -13,6 +15,7 @@ Fiona helps you navigate Ed-Fi documentation, standards, and community resources
/fiona help Show this help message
/fiona ask <question> Ask Fiona a question about Ed-Fi (coming soon)
/fiona search <query> Search Ed-Fi documentation (coming soon)
/fiona escalate Escalate your conversation to a human responder
\`\`\`
_Tip: You can also @-mention Fiona in any channel, or send her a direct message._`;

Expand All @@ -24,11 +27,17 @@ const SEARCH_NOT_YET_TEXT =
`*/fiona search* is not yet available. ` +
`In the meantime, @-mention Fiona in any channel or send her a direct message.`;

const ESCALATE_CONFIRM_TEXT =
'✅ Your conversation has been escalated to #escalation. A team member will follow up shortly.';
const ESCALATE_DM_TEXT = '✅ A team member will follow up shortly.';
const ESCALATE_ERROR_TEXT =
':warning: Sorry, I could not escalate your conversation right now. Please reach out to the team directly.';

/**
* Handles the /fiona slash command. Routes to a sub-command handler or falls
* back to help for unrecognized / missing input. Never invokes the LLM.
*/
export const fionaCommandCallback = async ({ command, ack, logger }) => {
export const fionaCommandCallback = async ({ command, ack, respond, client, logger }) => {
logger?.info?.(`/fiona slash command invoked: ${command.text ?? '(empty)'}`);
const subCommand = (command.text ?? '').trim().split(/\s+/)[0].toLowerCase();

Expand All @@ -41,13 +50,10 @@ export const fionaCommandCallback = async ({ command, ack, logger }) => {
await handleComingSoon({ command, ack, logger, subCommand: 'ask', text: ASK_NOT_YET_TEXT });
break;
case 'search':
await handleComingSoon({
command,
ack,
logger,
subCommand: 'search',
text: SEARCH_NOT_YET_TEXT,
});
await handleComingSoon({ command, ack, logger, subCommand: 'search', text: SEARCH_NOT_YET_TEXT });
break;
case 'escalate':
await handleEscalate({ command, ack, respond, client, logger });
break;
default:
await handleUnknown({ command, ack, logger, subCommand });
Expand Down Expand Up @@ -121,3 +127,64 @@ async function handleUnknown({ command, ack, logger, subCommand }) {
}
fireAndForgetRecord({ command, logger, interactionType: 'slash_unknown' });
}

function isDmChannel(command) {
return command.channel_name === 'directmessage' || (command.channel_id || '').startsWith('D');
}

async function handleEscalate({ command, ack, respond, client, logger }) {
try {
await ack();
} catch (err) {
logger?.error?.(`Failed to acknowledge /fiona escalate: ${err.name}`);
return;
}

if (!hasRequiredFields(command)) {
logger?.warn?.('Missing required slash command fields; skipping escalate');
return;
}

const { allowed, retryAfterMs } = checkRateLimit(command.user_id);
if (!allowed) {
const minutes = Math.ceil(retryAfterMs / 60000);
await respond({
response_type: 'ephemeral',
text: `:no_entry: You've reached the request limit. Please wait ${minutes} minute${minutes !== 1 ? 's' : ''} before trying again.`,
});
recordInteraction({
...slashInteractionRecord(command, 'slash_escalate'),
status: 'error',
errorType: 'rate_limited',
rateLimited: true,
logger,
}).catch((err) => logger?.warn?.(`Failed to record slash_escalate interaction: ${err.name}`));
return;
}

const dm = isDmChannel(command);
const result = await postEscalation({
client,
userId: command.user_id,
teamId: command.team_id,
channelId: command.channel_id,
threadTs: null,
messageTs: command.trigger_id,
source: 'slash_escalate',
isDm: dm,
logger,
});

if (result.ok) {
await respond({ response_type: 'ephemeral', text: dm ? ESCALATE_DM_TEXT : ESCALATE_CONFIRM_TEXT });
} else {
await respond({ response_type: 'ephemeral', text: ESCALATE_ERROR_TEXT });
recordInteraction({
...slashInteractionRecord(command, 'slash_escalate'),
status: 'error',
errorType: result.errorType,
rateLimited: false,
logger,
}).catch((err) => logger?.warn?.(`Failed to record slash_escalate interaction: ${err.name}`));
}
}
Loading
Loading