Skip to content

Commit

Permalink
Support snippets in citations on slackbot (#295)
Browse files Browse the repository at this point in the history
* WIP adding in snippets to sources used display

* committing WIP code

* update snippet fields

* resolve all snippets, add read scope

* use proper source url

* some renaming

* cleanup and naming

* changeset

* use new endpoint

* fix type
  • Loading branch information
scazan authored Nov 23, 2023
1 parent 9046bfb commit 58eee59
Show file tree
Hide file tree
Showing 4 changed files with 127 additions and 70 deletions.
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
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 };
};

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: { path: revisionPage.path, title: revisionPage.title },
} 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);
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: { title: snippet.title },
};
};

/* 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

0 comments on commit 58eee59

Please sign in to comment.