Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support snippets in citations on slackbot #295

Merged
merged 10 commits into from
Nov 23, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/sixty-tigers-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/integration-slack': minor
---

Adds support for snippets to be displayed in answers from GitBook AI
1 change: 1 addition & 0 deletions integrations/slack/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ scopes:
- space:content:read
- space:metadata:read
- capture:write
- snippets:read
scazan marked this conversation as resolved.
Show resolved Hide resolved
summary: |
# Overview
With the GitBook Slack integration, your teams have instant access to your GitBook knowledge base, and can get AI-summarized answers about your content. Plus, if you solve a problem in a thread, you can ask GitBook AI to summarize it into useable documentation.
Expand Down
173 changes: 114 additions & 59 deletions integrations/slack/src/actions/queryLens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
Revision,
RevisionPage,
RevisionPageGroup,
SearchAIAnswerSource,
} from '@gitbook/api';

import {
Expand All @@ -13,9 +14,35 @@ import {
} from '../configuration';
import { acknowledgeQuery } from '../middlewares';
import { slackAPI } from '../slack';
import { PagesBlock, QueryDisplayBlock, ShareTools, decodeSlackEscapeChars, Spacer } from '../ui';
import { QueryDisplayBlock, ShareTools, decodeSlackEscapeChars, Spacer, SourcesBlock } from '../ui';
import { getInstallationApiClient, stripBotName, stripMarkdown } from '../utils';

export type RelatedSource = {
id: string;
sourceUrl: string;
page: { path: string; title: string };
scazan marked this conversation as resolved.
Show resolved Hide resolved
};

export interface IQueryLens {
channelId: string;
channelName?: string;
responseUrl?: string;
teamId: string;
text: string;
context: SlackRuntimeContext;

/* postEphemeral vs postMessage */
messageType: 'ephemeral' | 'permanent';

/* needed for postEphemeral */
userId?: string;

/* Get lens reply in thread */
threadId?: string;

authorization?: string;
}

// Recursively extracts all pages from a collection of RevisionPages
function extractAllPages(rootPages: Array<RevisionPage>) {
const result: Array<RevisionPage> = [];
Expand All @@ -34,26 +61,31 @@ function extractAllPages(rootPages: Array<RevisionPage>) {
return result;
}

const capitalizeFirstLetter = (text: string) =>
text?.trim().charAt(0).toUpperCase() + text?.trim().slice(1);

/*
* Pulls out the top related pages from page IDs returned from Lens and resolves them using a provided GitBook API client.
*/
async function getRelatedPages(params: {
pages?: SearchAIAnswer['pages'];
async function getRelatedSources(params: {
sources?: SearchAIAnswer['sources'];
client: GitBookAPI;
environment: SlackRuntimeEnvironment;
}) {
const { pages, client } = params;
organization: string;
}): Promise<RelatedSource[]> {
const { sources, client, organization } = params;

if (!pages || pages.length === 0) {
if (!sources || sources.length === 0) {
return [];
}

// return top 3 pages (pages are ordered by score by default)
const sourcePages = pages.slice(0, 3);

// return top 3 sources (sources are ordered by score by default)
const topSources = sources.slice(0, 3);
// collect all spaces from page results (and de-dupe)
const allSpaces = sourcePages.reduce((accum, page) => {
accum.add(page.space);
const allSpaces = topSources.reduce((accum, source) => {
if (source.type === 'page') {
accum.add(source.space);
}

return accum;
}, new Set<string>());
Expand All @@ -70,58 +102,80 @@ async function getRelatedPages(params: {
return accum;
}, []);

// extract all related pages from the Revisions along with the related public URL
const relatedPages: Array<{ sourceUrl: string; page: RevisionPage }> = sourcePages.reduce(
(accum, page) => {
// TODO: we can probably combine finding the currentRevision with extracting the appropriate page
const currentRevision = allRevisions.find((revision: Revision) =>
extractAllPages(revision.pages).find(
(revisionPage) => revisionPage.id === page.page
)
);

if (currentRevision) {
const sourceUrl = currentRevision.urls.public || currentRevision.urls.app;

const allRevisionPages = extractAllPages(currentRevision.pages);
const revisionPage = allRevisionPages.find((revPage) => revPage.id === page.page);

accum.push({
sourceUrl,
page: revisionPage,
});
}
const getResolvedPage = (page: SearchAIAnswerSource & { type: 'page' }) => {
// TODO: we can probably combine finding the currentRevision with extracting the appropriate page
const currentRevision = allRevisions.find((revision: Revision) =>
extractAllPages(revision.pages).find((revisionPage) => revisionPage.id === page.page)
);

return accum;
},
[]
);
if (currentRevision) {
const sourceUrl = currentRevision.urls.public || currentRevision.urls.app;

// filter related pages from current revision
return relatedPages;
}
const allRevisionPages = extractAllPages(currentRevision.pages);
const revisionPage = allRevisionPages.find((revPage) => revPage.id === page.page);

const capitalizeFirstLetter = (text: string) =>
text?.trim().charAt(0).toUpperCase() + text?.trim().slice(1);
return {
id: page.page,
sourceUrl,
page: revisionPage,
} as RelatedSource;
}
};

export interface IQueryLens {
channelId: string;
channelName?: string;
responseUrl?: string;
teamId: string;
text: string;
context: SlackRuntimeContext;
const getResolvedSnippet = async (
source: SearchAIAnswerSource & { type: 'snippet' | 'capture' }
): Promise<RelatedSource> => {
const snippetRequest = await client.orgs.getSnippet(organization, source.captureId);
scazan marked this conversation as resolved.
Show resolved Hide resolved
const snippet = snippetRequest.data;

/* postEphemeral vs postMessage */
messageType: 'ephemeral' | 'permanent';
const sourceUrl = snippet.urls.app;

/* needed for postEphemeral */
userId?: string;
return {
id: snippet.id,
sourceUrl,
page: { path: '', title: snippet.title },
scazan marked this conversation as resolved.
Show resolved Hide resolved
};
};

/* Get lens reply in thread */
threadId?: string;
const resolvedSnippetsPromises = await Promise.allSettled(
topSources
.filter((source) => source.type === 'capture' || source.type === 'snippet')
.map(getResolvedSnippet)
);

authorization?: string;
const resolvedSnippets = resolvedSnippetsPromises.reduce((accum, result) => {
if (result.status === 'fulfilled') {
accum.push(result.value);
}

return accum;
}, [] as RelatedSource[]);

// extract all related sources from the Revisions along with the related public URL
const relatedSources: Array<RelatedSource> = topSources.reduce((accum, source) => {
switch (source.type) {
case 'page':
const resolvedPage = getResolvedPage(source);
accum.push(resolvedPage);
break;

case 'snippet':
case 'capture':
const resolvedSnippet = resolvedSnippets.find(
(snippet) => snippet.id === source.captureId
);

if (resolvedSnippet) {
accum.push(resolvedSnippet);
}
break;
}

return accum;
}, []);

// filter related sources from current revision
return relatedSources;
}

/*
Expand Down Expand Up @@ -173,10 +227,11 @@ export async function queryLens({
const messageTypePath = messageType === 'ephemeral' ? 'chat.postEphemeral' : 'chat.postMessage';

if (answer && answer.text) {
const relatedPages = await getRelatedPages({
pages: answer.pages,
const relatedSources = await getRelatedSources({
sources: answer.sources,
client,
environment,
organization: installation.target.organization,
});

const answerText = capitalizeFirstLetter(answer.text);
Expand All @@ -200,9 +255,9 @@ export async function queryLens({
{
type: 'divider',
},
...PagesBlock({
...SourcesBlock({
title: 'Sources',
items: relatedPages,
items: relatedSources,
}),
Spacer,
{
Expand Down
18 changes: 7 additions & 11 deletions integrations/slack/src/ui/blocks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { RevisionPage } from '@gitbook/api';
import { RelatedSource } from '../../actions/';

// Slack only encodes these specific characters so we need to remove them in the output (specifically used for inputs to slack)
export function decodeSlackEscapeChars(text: string) {
Expand All @@ -11,24 +11,20 @@ export function decodeSlackEscapeChars(text: string) {
}, text);
}

export function PageBlock(page: RevisionPage, sourceUrl: string) {
// TODO: note for review. is this the best way to do this?
const nonRevisionPublicUrl = sourceUrl.split('~/')[0];
const url = `${nonRevisionPublicUrl}${page.path}`;
export function SourceBlock(source: RelatedSource) {
const nonRevisionPublicUrl = source.sourceUrl.split('~/')[0];
const url = `${nonRevisionPublicUrl}${source.page.path || ''}`;
return {
type: 'mrkdwn',
text: `*<${url}|:spiral_note_pad: ${page.title}>*`,
text: `*<${url}|:spiral_note_pad: ${source.page.title}>*`,
};
}

export function PagesBlock(params: {
title?: string;
items: Array<{ sourceUrl: string; page: RevisionPage }>;
}) {
export function SourcesBlock(params: { title?: string; items: Array<RelatedSource> }) {
const { title, items } = params;

const blocks = items.reduce<Array<any>>((acc, pageData) => {
const pageResultBlock = PageBlock(pageData.page, pageData.sourceUrl);
const pageResultBlock = SourceBlock(pageData);
acc.push(pageResultBlock);

return acc;
Expand Down