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
02aa8a4
Initial plan
Copilot Jul 23, 2026
623ce03
feat(AI-179): implement /fiona search — browse raw sources without sy…
Copilot Jul 23, 2026
0d02a67
fix: address code review feedback — rename subCommand param, improve …
Copilot Jul 23, 2026
931ce57
fix: address second round of code review feedback
Copilot Jul 23, 2026
447ec7a
feat(AI-179): switch searchForSources to Perplexity Search API (POST …
Copilot Jul 23, 2026
aa24bc1
fix: improve error message and JSDoc based on code review feedback
Copilot Jul 23, 2026
2dca58a
feat(AI-179): env-backed SEARCH_MAX_SOURCES with hard cap; strip mark…
Copilot Jul 23, 2026
9717ea5
fix: env-back SNIPPET_MAX_CHARS; use non-greedy italic regex in trunc…
Copilot Jul 23, 2026
efaf681
fix: inline SNIPPET_MAX_CHARS parse; keep parsePositiveIntEnv unexpor…
Copilot Jul 23, 2026
51be89c
fix: use [\s\S]*? in bold regex to handle asterisks inside bold markers
Copilot Jul 23, 2026
3617e51
feat(AI-179): implement review feedback - perplexity SDK, domain filt…
Copilot Jul 23, 2026
b720e9c
feat(AI-179): use Perplexity SDK, domain filter, 160-word snippets, B…
Copilot Jul 23, 2026
61620ea
Disable Slack unfurls for search results
Copilot Jul 23, 2026
eadc96f
Add search feedback context support
Copilot Jul 23, 2026
2d47bf3
Refine feedback context handling
Copilot Jul 23, 2026
1bf7dd8
Document feedback helpers
Copilot Jul 23, 2026
ed96924
Tighten feedback block metadata
Copilot Jul 23, 2026
dd62e98
Polish feedback helpers
Copilot Jul 23, 2026
2366d30
fix: resolve biome lint/format errors in feedback files
Copilot Jul 24, 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
6 changes: 6 additions & 0 deletions apps/fiona-slack/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
# Defaults to www.ed-fi.org,docs.ed-fi.org
# PERPLEXITY_DOMAIN_FILTER=www.ed-fi.org,docs.ed-fi.org

# Optional, /fiona search settings.
# Maximum number of sources returned per search (default 5, hard cap 10).
# SEARCH_MAX_SOURCES=5
# Maximum words per source snippet before truncation (default 160).
# SEARCH_SNIPPET_MAX_WORDS=160

# Deployment type label recorded with feedback in Cosmos DB (local, insiders, production).
DEPLOYMENT_TYPE=local

Expand Down
7 changes: 7 additions & 0 deletions apps/fiona-slack/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/fiona-slack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dependencies": {
"@azure/cosmos": "^4.9.1",
"@azure/identity": "^4.13.0",
"@perplexity-ai/perplexity_ai": "^0.37.0",
"@slack/bolt": "^4.6.0",
"dotenv": "~17.3.1",
"openai": "^6.25.0"
Expand Down
10 changes: 10 additions & 0 deletions apps/fiona-slack/src/agent/feedback-response-types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// 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.

export const FEEDBACK_RESPONSE_TYPES = Object.freeze({
SYNTHESIS: 'synthesis',
SEARCH: 'search',
ASK: 'ask',
});
8 changes: 8 additions & 0 deletions apps/fiona-slack/src/agent/feedback-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { CosmosClient } from '@azure/cosmos';
import { DefaultAzureCredential } from '@azure/identity';
import { FEEDBACK_RESPONSE_TYPES } from './feedback-response-types.js';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
const COSMOS_KEY = process.env.COSMOS_KEY;
Expand Down Expand Up @@ -146,6 +147,7 @@ async function getContainer(logger) {
* @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 {'synthesis'|'search'|'ask'} [feedback.responseType] - Response category for the feedback
* @param {string} [feedback.interactionType] - Optional interaction type (e.g., 'slash_escalate')
* @param {{ warn?: (msg: string) => void }} [feedback.logger] - Optional logger for warnings
*/
Expand All @@ -157,12 +159,17 @@ export async function recordFeedback({
reason,
userMessage,
botResponse,
responseType,
interactionType,
logger,
}) {
const c = await getContainer(logger);
if (!c) return;

const normalizedResponseType = Object.values(FEEDBACK_RESPONSE_TYPES).includes(responseType)
? responseType
: FEEDBACK_RESPONSE_TYPES.SYNTHESIS;

const doc = {
id: `${userId}_${messageTs}`,
// feedbackId duplicates id because the partition key path requires /feedbackId;
Expand All @@ -175,6 +182,7 @@ export async function recordFeedback({
reason: reason?.trim() ? reason.trim() : null,
userMessage,
botResponse,
responseType: normalizedResponseType,
...(interactionType ? { interactionType } : {}),
deploymentType: process.env.DEPLOYMENT_TYPE || 'local',
timestamp: new Date().toISOString(),
Expand Down
51 changes: 51 additions & 0 deletions apps/fiona-slack/src/agent/llm-caller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// 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 Perplexity from '@perplexity-ai/perplexity_ai';
import { OpenAI } from 'openai';
import {
incrementDegradedNoMetadataCount,
Expand Down Expand Up @@ -88,12 +89,16 @@ const SYSTEM_PROMPT = process.env.SYSTEM_PROMPT || DEFAULT_SYSTEM_PROMPT;
// use the OpenAI SDK with a custom baseURL.
/** @type {OpenAI | undefined} */
let perplexityClient;
// Dedicated Perplexity SDK client for the Search API (POST /search).
/** @type {Perplexity | undefined} */
let perplexitySearchClient;

if (PERPLEXITY_API_KEY) {
perplexityClient = new OpenAI({
apiKey: PERPLEXITY_API_KEY,
baseURL: 'https://api.perplexity.ai',
});
perplexitySearchClient = new Perplexity({ apiKey: PERPLEXITY_API_KEY });
}

/**
Expand Down Expand Up @@ -485,6 +490,52 @@ export async function callLLM(streamer, prompts, logger) {
return { metadata, botText, systemPromptVersion: SYSTEM_PROMPT_VERSION };
}

// ─── Source Search ─────────────────────────────────────────────────────────
// Read from env (default 5). Hard-capped at SEARCH_ABSOLUTE_MAX before the API call.
const SEARCH_MAX_SOURCES = parsePositiveIntEnv(process.env.SEARCH_MAX_SOURCES, 5);
const SEARCH_ABSOLUTE_MAX = 10;

/**
* Call the Perplexity Search API to retrieve source documents for a query.
* Returns a list of normalized sources (URL, title, optional snippet) with no
* synthesized answer. Used by the `/fiona search` command.
*
* Uses POST /search (not chat completions) so the API handles result ranking
* and count limiting natively via `max_results`.
*
* @param {string} query - Search query from the user (unsanitized)
* @param {Object} [options]
* @param {number} [options.maxSources] - Maximum sources to return (defaults to SEARCH_MAX_SOURCES env, capped at 10)
* @param {import('@slack/logger').Logger} [options.logger]
* @returns {Promise<Array<import('./utils/source-normalizer.js').NormalizedSource>>}
*/
export async function searchForSources(query, { maxSources = SEARCH_MAX_SOURCES, logger } = {}) {
if (!perplexitySearchClient) return [];
if (!query || !query.trim()) return [];

const cappedMaxSources = Math.min(maxSources, SEARCH_ABSOLUTE_MAX);

try {
const response = await perplexitySearchClient.search.create({
query,
max_results: cappedMaxSources,
search_domain_filter: PERPLEXITY_DOMAIN_FILTER,
});

const rawResults = response?.results;

if (Array.isArray(rawResults) && rawResults.length > 0) {
const { sources } = normalizeSources(rawResults, { maxSources: cappedMaxSources });
return sources;
}

return [];
} catch (error) {
logger?.warn?.(`Search failed: ${error.message}`);
return [];
}
}

// ─── 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. ' +
Expand Down
113 changes: 113 additions & 0 deletions apps/fiona-slack/src/agent/search-caller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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.

/**
* Search abstraction layer for the `/fiona search` skill.
* Wraps the LLM-layer search call and provides Slack-specific formatting.
* Imported by fiona.js so the slash command handler never depends on llm-caller
* directly (preserving the architectural separation of concerns).
*/

import { searchForSources } from './llm-caller.js';

export { searchForSources };

const SEARCH_NO_RESULTS_TEXT = '🔍 No sources found for _"{{query}}"_. Try rephrasing your query.';
// Exported so slash and say()-based handlers can share a single error string.
export const SEARCH_ERROR_TEXT = ':warning: Search encountered an error. Please try again later.';

// Read snippet max word count from SEARCH_SNIPPET_MAX_WORDS env var.
// Falls back to 160 when the env var is absent, non-numeric, or not a positive integer.
const _snippetMaxRaw = Number.parseInt(process.env.SEARCH_SNIPPET_MAX_WORDS ?? '', 10);
const SNIPPET_MAX_WORDS = Number.isFinite(_snippetMaxRaw) && _snippetMaxRaw > 0 ? _snippetMaxRaw : 160;

/**
* Strip common markdown syntax from a snippet and truncate to the configured
* maximum word count (SNIPPET_MAX_WORDS).
* Perplexity Search API snippets can contain bold markers, headings, and multi-
* paragraph text; stripping and truncating keeps the Slack source list compact.
*
* Only `**bold**` and `### heading` patterns are removed; single-asterisk italic
* is intentionally left untouched to avoid false positives on maths/code notation
* (e.g. `a * b`).
*
* @param {string} text
* @returns {string}
*/
function truncateSnippet(text) {
if (!text || typeof text !== 'string') return '';
// Collapse newlines and runs of whitespace to a single space
let cleaned = text
.replace(/\n+/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
// Strip bold markers (**text**) and heading markers (## …)
cleaned = cleaned.replace(/\*\*([\s\S]*?)\*\*/g, '$1').replace(/#{1,6}\s+/g, '');
const words = cleaned.split(/\s+/);
if (words.length <= SNIPPET_MAX_WORDS) return cleaned;
return `${words.slice(0, SNIPPET_MAX_WORDS).join(' ')}…`;
}

/**
* Escape user-supplied text before embedding it in a Slack mrkdwn message.
* Only `&`, `<`, and `>` need HTML-entity encoding per the Slack API spec.
*
* @param {string} text
* @returns {string}
*/
export function escapeMrkdwn(text) {
if (!text || typeof text !== 'string') return '';
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

/**
* Format a list of normalized sources into a Slack Block Kit message with
* a mrkdwn plain-text fallback. Each result is rendered as a section block
* (title link) and an optional context block (snippet), separated by dividers.
*
* @param {string} query - The original search query (unsanitized)
* @param {Array<import('./llm-caller.js').NormalizedSource>} sources - Normalized source list
* @returns {{ text: string, blocks: Array<object> | null }} Slack message payload
*/
export function formatSearchResults(query, sources) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking pretty good. One issue I see is the response items appear as a long, unformatted list, making it difficult to read. Lets improve the readabilitiy, perhaps formatting the snippet and truncating the content more so the total number of sources is easier to see.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a truncateSnippet helper in search-caller.js that:

  • Collapses newlines and extra whitespace to a single space
  • Strips **bold** markers (using [\s\S]*? to handle asterisks inside bold text) and ### heading markers
  • Truncates to 150 chars at a word boundary and appends

The SNIPPET_MAX_CHARS limit is also env-backed via SEARCH_SNIPPET_MAX_CHARS (default 150) so operators can tune it. Single-asterisk italic is intentionally not stripped to avoid false positives on math/code notation like a * b. Commit: feat(AI-179): env-backed SEARCH_MAX_SOURCES with hard cap; strip markdown and truncate snippets

const safeQuery = escapeMrkdwn(query);

if (!Array.isArray(sources) || sources.length === 0) {
return { text: SEARCH_NO_RESULTS_TEXT.replace('{{query}}', safeQuery), blocks: null };
}

const headerText = `🔍 *Search results for:* _"${safeQuery}"_`;

// ── Block Kit layout ───────────────────────────────────────────────────────
const blocks = [{ type: 'section', text: { type: 'mrkdwn', text: headerText } }, { type: 'divider' }];

for (let i = 0; i < sources.length; i++) {
const source = sources[i];
const link = `*<${source.url}|${escapeMrkdwn(source.title || source.hostname)}>*`;
blocks.push({ type: 'section', text: { type: 'mrkdwn', text: `${i + 1}. ${link}` } });

const rawSnippet = truncateSnippet(source.snippet);
if (rawSnippet) {
blocks.push({
type: 'context',
elements: [{ type: 'mrkdwn', text: `_"${escapeMrkdwn(rawSnippet)}"_` }],
});
}

if (i < sources.length - 1) {
blocks.push({ type: 'divider' });
}
}

// ── Plain-text fallback (used by Slack notifications / accessibility) ──────
const items = sources.map((source, i) => {
const rawSnippet = truncateSnippet(source.snippet);
const snippet = rawSnippet ? `\n${rawSnippet}` : '';
return `${i + 1}. ${source.title || source.hostname} — ${source.url}${snippet}`;
});
const text = `${headerText}\n\n${items.join('\n\n')}`;

return { text, blocks };
}
38 changes: 38 additions & 0 deletions apps/fiona-slack/src/listeners/actions/feedback.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,38 @@
// 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 { FEEDBACK_RESPONSE_TYPES, parseFeedbackBlockId } from '../views/feedback_block.js';

const SEARCH_QUERY_PATTERN = /^🔍 \*Search results for:\* _"([\s\S]+?)"_/;

/**
* Resolve the contextual feedback block id from the action payload.
*
* @param {import("@slack/bolt").SlackAction} body
* @param {Record<string, any>} action
* @returns {string|null}
*/
function getFeedbackBlockId(body, action) {
if (typeof action?.block_id === 'string' && action.block_id.length > 0) {
return action.block_id;
}

if (!Array.isArray(body?.message?.blocks)) return null;
return body.message.blocks.find((block) => block?.type === 'context_actions')?.block_id ?? null;
}

/**
* Extract the original query from a formatted Fiona search response.
*
* @param {string} messageText
* @returns {string|null}
*/
function extractSearchQuery(messageText) {
if (typeof messageText !== 'string') return null;
const match = messageText.match(SEARCH_QUERY_PATTERN);
return match?.[1] ?? null;
}

/**
* The `feedbackActionCallback` action responds to the `feedbackBlock` that displays
* positive and negative feedback icons. This block is attached to the bottom of LLM
Expand Down Expand Up @@ -34,6 +66,7 @@ export const feedbackActionCallback = async ({ ack, body, client, logger }) => {
const channel_id = body.channel.id;
const user_id = body.user.id;
const value = action.value;
const { responseType, interactionType } = parseFeedbackBlockId(getFeedbackBlockId(body, action));

if (value !== 'good-feedback' && value !== 'bad-feedback') {
logger.warn('Received unexpected feedback value', { value, channel_id, user_id, message_ts });
Expand Down Expand Up @@ -61,6 +94,11 @@ export const feedbackActionCallback = async ({ ack, body, client, logger }) => {
userId: user_id,
value,
thread_ts,
responseType,
interactionType,
...(responseType === FEEDBACK_RESPONSE_TYPES.SEARCH
? { searchQuery: extractSearchQuery(body.message?.text) }
: {}),
}),
blocks: [
{
Expand Down
19 changes: 16 additions & 3 deletions apps/fiona-slack/src/listeners/assistant/message.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { buildThreadHistory } from '../../agent/thread-history.js';
import { generateResponseId, shouldFinalize } from '../../agent/utils/idempotent-finalize.js';
import { dispatchKeywordViaSay } from '../commands/command-dispatch.js';
import { parseCommandKeyword } from '../commands/command-handler.js';
import { feedbackBlock } from '../views/feedback_block.js';
import { createFeedbackBlock, FEEDBACK_RESPONSE_TYPES } from '../views/feedback_block.js';

/**
* Handles when users send messages or select a prompt in an assistant thread
Expand Down Expand Up @@ -113,6 +113,7 @@ export const message = async ({ client, context, logger, message, say, setStatus
threadTs: thread_ts,
messageTs,
source: 'assistant_escalate',
interactionType: 'assistant_message',
});
return;
}
Expand Down Expand Up @@ -211,7 +212,12 @@ export const message = async ({ client, context, logger, message, say, setStatus
text: 'The crowd appears to be astounded and applauds :popcorn:',
},
],
blocks: [feedbackBlock],
blocks: [
createFeedbackBlock({
responseType: FEEDBACK_RESPONSE_TYPES.SYNTHESIS,
interactionType: 'assistant_message',
}),
],
});
} else {
// This second example shows a generated text response for the provided prompt
Expand Down Expand Up @@ -253,7 +259,14 @@ export const message = async ({ client, context, logger, message, say, setStatus
logger.info(`[citations] state=${metadata.finalize_state} sources=${metadata.sources?.length ?? 0}`);
}

await streamer.stop({ blocks: [feedbackBlock] });
await streamer.stop({
blocks: [
createFeedbackBlock({
responseType: FEEDBACK_RESPONSE_TYPES.SYNTHESIS,
interactionType: 'assistant_message',
}),
],
});
finalizeMetadataEnvelope(metadata);

try {
Expand Down
Loading
Loading