diff --git a/.eslintrc b/.eslintrc index 35cfb79..6ddaba6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,5 +1,14 @@ { "root": true, + "rules": { + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-namespace": "off", + "@typescript-eslint/restrict-template-expressions": "off" + }, "extends": [ "lxsmnsyc/typescript" ], diff --git a/package.json b/package.json index 9daae67..5a64d55 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "access": "public" }, "dependencies": { + "@modal-sh/mio-ai": "link:../../../openai-utils", "fetch-ponyfill": "^7.1.0", "handlebars": "^4.7.7" }, diff --git a/pridepack.json b/pridepack.json index 841fb58..64b00cd 100644 --- a/pridepack.json +++ b/pridepack.json @@ -1,3 +1,3 @@ { - "target": "es2018" -} \ No newline at end of file + "target": "esnext" +} diff --git a/src/common.ts b/src/common.ts deleted file mode 100644 index 132df8e..0000000 --- a/src/common.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type ProcessEvent = { - processType: string, - phase: string, - command?: string, - content?: string, - contentType?: string, -}; - -export type ProcessEventCallback = (event: ProcessEvent) => void; - -export type ErrorEventCallback = (event: Error) => void; - -export interface SummarizerProcessParams { - url: string; - language?: string; - country?: string; -} - -export interface SummarizerEventEmitter extends NodeJS.EventEmitter { - process(params: T): void; - on(eventType: 'process', callback: ProcessEventCallback): this; - on(eventType: 'error', callback: ErrorEventCallback): this; - on(eventType: 'end', callback: () => void): this; -} - -export interface OpenAiParams { - apiKey: string; - organizationId?: string; - model?: string; - temperature?: number; -} - -export interface CreateBaseSummarizerParams { - openAiParams: OpenAiParams; -} diff --git a/src/index.ts b/src/index.ts index a5a8dc4..140b4bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,33 +1,44 @@ -import { SummarizerEventEmitter } from './common'; -import { - CreateYouTubeSummarizerParams, - YouTubeSummarizerEventEmitter, -} from './video-types/youtube'; - -export enum VideoType { - YOUTUBE = 'youtube', -} - -export interface CreateSummarizerParams extends CreateYouTubeSummarizerParams { - type: VideoType; -} - -export const createSummarizer = (params: CreateSummarizerParams): SummarizerEventEmitter => { - const { - type: videoType, - openAiParams, - } = params; - - switch (videoType as string) { - case VideoType.YOUTUBE: - return new YouTubeSummarizerEventEmitter({ - openAiParams, - }); - default: - break; +import { OpenAi } from '@modal-sh/mio-ai'; +import { SummarizerEventEmitter, SummarizerEventEmitterImpl } from './summarizer'; +import * as YouTube from './video-types/youtube'; + +const SUPPORTED_VIDEO_TYPES = [ + YouTube, +] as const; + +export type CreateTranscriptFetcherParams = ( + YouTube.CreateTranscriptFetcherParams +); + +export type SummarizerProcessParams = ( + YouTube.SummarizerProcessParams +); + +export type VideoType = typeof YouTube.VIDEO_TYPE; + +export * from './summarizer'; +export * from './transcript'; +export * as YouTube from './video-types/youtube'; + +export const createTranscriptFetcher = (params: CreateTranscriptFetcherParams) => { + const { type: videoType } = params; + + const theVideoTypeModule = SUPPORTED_VIDEO_TYPES + .find((videoTypeModule) => videoTypeModule.VIDEO_TYPE === videoType); + + if (!theVideoTypeModule) { + const validVideoTypes = SUPPORTED_VIDEO_TYPES.map((videoTypeModule) => videoTypeModule.VIDEO_TYPE).join(', '); + throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${validVideoTypes}`); } - throw new TypeError(`Invalid video type: "${videoType}". Valid values are: ${JSON.stringify(Object.values(VideoType))}`); + // shadow the original method for protection + return (...transcriptFetcherParams: Parameters) => ( + theVideoTypeModule.getRawTranscript(...transcriptFetcherParams) + ); }; -export * from './common'; +export const createSummarizer = (params: OpenAi.Configuration): SummarizerEventEmitter => ( + new SummarizerEventEmitterImpl(params) +); + +export const OPENAI_API_VERSION = OpenAi.ApiVersion.V1 as const; diff --git a/src/summarizer.ts b/src/summarizer.ts index c00cc22..9c98180 100644 --- a/src/summarizer.ts +++ b/src/summarizer.ts @@ -1,122 +1,83 @@ -import fetchPonyfill from 'fetch-ponyfill'; -import Handlebars from 'handlebars'; -import { resolve } from 'path'; -import { readFile } from 'fs/promises'; -import * as config from './config'; -import { OpenAiParams } from './common'; +import { OpenAi, createAiClient } from '@modal-sh/mio-ai'; +import { EventEmitter } from 'events'; +import { BaseTranscriptItem } from './transcript'; -export interface MakeAiCallParams { - prompts: string[]; - openAiParams: OpenAiParams; -} +export type DataEventCallback = (event: string) => void; -export class AiCallError extends Error { - constructor(message: string, public readonly response: Response) { - super(message); - this.name = 'AiCallError'; - } -} +export type ErrorEventCallback = (event: Error) => void; -const makeAiCall = async (params: MakeAiCallParams): Promise => { - const { - prompts, - openAiParams: { - apiKey, - organizationId, - model = 'gpt-3.5-turbo', - temperature = 0.6, - }, - } = params; +export interface SummarizerEventEmitter extends NodeJS.EventEmitter { + normalize(transcriptItems: T[]): Promise; + summarize(transcript: string): void; + on(eventType: 'data', callback: DataEventCallback): this; + on(eventType: 'error', callback: ErrorEventCallback): this; + on(eventType: 'end', callback: () => void): this; +} - const headers: Record = { - 'Content-Type': 'application/json', - Accept: 'application/json', - Authorization: `Bearer ${apiKey}`, - }; +export class SummarizerEventEmitterImpl extends EventEmitter { + private readonly openAiClient: OpenAi.PlatformEventEmitter; - if (organizationId) { - headers['OpenAI-Organization'] = organizationId; + constructor(params: OpenAi.Configuration) { + super(); + this.openAiClient = createAiClient({ + platform: OpenAi.PLATFORM_ID, + platformConfiguration: params, + }); } - const { fetch } = fetchPonyfill(); - const response = await fetch( - new URL('/v1/chat/completions', 'https://api.openai.com'), - { - method: 'POST', - headers, - body: JSON.stringify({ - model, - temperature, - messages: [ - { - role: 'user', - content: prompts[Math.floor(Math.random() * prompts.length)].trim(), - }, - ], - }), - }, - ); - - if (!response.ok) { - const { error } = await response.json(); - throw new AiCallError(`OpenAI API call failed with status ${response.status}: ${error.message}`, response); + normalize(transcript: T[]) { + return new Promise((resolve, reject) => { + this.openAiClient.once('data', (data) => { + const normalizedTranscript = data.choices[0].text; + resolve(normalizedTranscript); + }); + + this.openAiClient.once('error', (error) => { + reject(error); + }); + + this.openAiClient.createEdit({ + input: transcript.map((item) => item.text).join(' '), + instruction: 'Put proper punctuation and correct capitalization', + model: OpenAi.EditModel.TEXT_DAVINCI_EDIT_001, + }); + }); } - const { choices } = await response.json(); - - // should we use all the response choices? - return choices[0].message.content; -}; - -const compilePrompts = async (filename: string, params: Record): Promise => { - const rawPromptText = await readFile(resolve(config.openAi.promptsDir, filename), 'utf-8'); - const fill = Handlebars.compile(rawPromptText, { noEscape: true }); - const filledText = fill(params); - return filledText.split('---').map((s) => s.trim()); -}; - -export interface NormalizeTranscriptTextParams { - rawTranscriptText: string, - openAiParams: OpenAiParams, -} - -export const normalizeTranscriptText = async (params: NormalizeTranscriptTextParams) => { - const { - rawTranscriptText, - openAiParams, - } = params; - const prompts = await compilePrompts( - 'normalize-transcript-text.hbs', - { - transcript: rawTranscriptText, - }, - ); - - return makeAiCall({ - prompts, - openAiParams, - }); -}; - -export interface SummarizeTranscriptParams { - normalizedTranscript: string, - openAiParams: OpenAiParams, + summarize(normalizedTranscript: string) { + const listener = (data: OpenAi.ChatCompletionChunkDataEvent) => { + const theContent = data.choices[0].delta.content; + if (typeof theContent !== 'string') { + return; + } + + this.emit('data', theContent) + }; + + this.openAiClient.on('data', listener); + + this.openAiClient.once('error', (error) => { + this.openAiClient.off('data', listener); + this.emit('error', error); + }); + + this.openAiClient.once('end', () => { + this.openAiClient.off('data', listener); + this.emit('end'); + }); + + this.openAiClient.createChatCompletion({ + model: OpenAi.ChatCompletionModel.GPT_3_5_TURBO, + messages: [ + { + role: OpenAi.MessageRole.SYSTEM, + content: 'You are working on video transcripts.', + }, + { + role: OpenAi.MessageRole.USER, + content: `Summarize the following transcript:\n\n${normalizedTranscript}`, + }, + ], + }); + } } - -export const summarizeTranscript = async (params: SummarizeTranscriptParams) => { - const { - normalizedTranscript, - openAiParams, - } = params; - const prompts = await compilePrompts( - 'summarize-transcript.hbs', - { - transcript: normalizedTranscript, - }, - ); - - return makeAiCall({ - prompts, - openAiParams, - }); -}; diff --git a/src/transcript.ts b/src/transcript.ts new file mode 100644 index 0000000..b53e082 --- /dev/null +++ b/src/transcript.ts @@ -0,0 +1,3 @@ +export interface BaseTranscriptItem { + text: string; +} diff --git a/src/video-types/youtube/common.ts b/src/video-types/youtube/common.ts new file mode 100644 index 0000000..640c91f --- /dev/null +++ b/src/video-types/youtube/common.ts @@ -0,0 +1 @@ +export const VIDEO_TYPE = 'youtube' as const; diff --git a/src/video-types/youtube/crypto.ts b/src/video-types/youtube/crypto.ts new file mode 100644 index 0000000..726befb --- /dev/null +++ b/src/video-types/youtube/crypto.ts @@ -0,0 +1,54 @@ +/* eslint-disable no-bitwise */ + +const alphabet = 'ABCDEFGHIJKLMOPQRSTUVWXYZabcdefghjijklmnopqrstuvwxyz0123456789' as const; +const jda = [ + `${alphabet}+/=`, + `${alphabet}+/`, + `${alphabet}-_=`, + `${alphabet}-_.`, + `${alphabet}-_`, +] as const; + +export type Nonce = string; + +export const generateNonce = (): Nonce => { + const rnd = Math.random().toString(); + const b = jda[3]; + const a = []; + for (let i = 0; i < rnd.length - 1; i += 1) { + a.push(rnd[i].charCodeAt(i)); + } + let c = ''; + let d = 0; + let m; let n; let q; let r; let f; let + g; + while (d < a.length) { + f = a[d]; + g = d + 1 < a.length; + + if (g) { + m = a[d + 1]; + } else { + m = 0; + } + n = d + 2 < a.length; + if (n) { + q = a[d + 2]; + } else { + q = 0; + } + r = f >> 2; + f = ((f & 3) << 4) | (m >> 4); + m = ((m & 15) << 2) | (q >> 6); + q &= 63; + if (!n) { + q = 64; + if (!q) { + m = 64; + } + } + c += b[r] + b[f] + b[m] + b[q]; + d += 3; + } + return c; +}; diff --git a/src/video-types/youtube/index.ts b/src/video-types/youtube/index.ts index ef8cc93..d32ffbd 100644 --- a/src/video-types/youtube/index.ts +++ b/src/video-types/youtube/index.ts @@ -1,90 +1,3 @@ -import { EventEmitter } from 'events'; -import { CreateBaseSummarizerParams, SummarizerEventEmitter, SummarizerProcessParams } from '../../common'; -import { - retrieveVideoId, - getVideoPage, - extractDataFromPage, - fetchTranscriptItems, -} from './transcript'; -import { normalizeTranscriptText, summarizeTranscript } from '../../summarizer'; - -export type CreateYouTubeSummarizerParams = CreateBaseSummarizerParams - -export class YouTubeSummarizerEventEmitter extends EventEmitter implements SummarizerEventEmitter { - constructor(private readonly params: CreateYouTubeSummarizerParams) { - super(); - } - - process(params: SummarizerProcessParams) { - const { url, ...config } = params; - const { openAiParams } = this.params; - const identifier = retrieveVideoId(url); - - this.emit('process', { - processType: 'extract-data', - phase: 'download-page', - }); - - getVideoPage(identifier) - .then((videoPageBody) => { - const pageData = extractDataFromPage(videoPageBody); - this.emit('process', { - processType: 'extract-data', - phase: 'success', - }); - - this.emit('process', { - processType: 'fetch-transcript', - phase: 'start', - }); - return fetchTranscriptItems(pageData, config); - }) - .then((transcript) => { - this.emit('process', { - processType: 'fetch-transcript', - phase: 'success', - content: JSON.stringify(transcript), - contentType: 'application/json', - }); - - this.emit('process', { - processType: 'normalize-transcript', - phase: 'start', - }); - - return normalizeTranscriptText({ - rawTranscriptText: transcript.map((item) => item.text).join(' '), - openAiParams, - }); - }) - .then((normalizedTranscript) => { - this.emit('process', { - processType: 'normalize-transcript', - phase: 'success', - content: normalizedTranscript, - contentType: 'text/plain', - }); - - this.emit('process', { - processType: 'summarize-transcript', - phase: 'start', - }); - - return summarizeTranscript({ normalizedTranscript, openAiParams }); - }) - .then((summary) => { - this.emit('process', { - processType: 'summarize-transcript', - phase: 'success', - content: summary, - contentType: 'text/plain', - }); - - this.emit('end'); - }) - .catch((error) => { - this.emit('error', error); - this.emit('end'); - }); - } -} +export * from './common'; +export * from './transcript'; +export * from './errors'; diff --git a/src/video-types/youtube/transcript.ts b/src/video-types/youtube/transcript.ts index 0cd53bd..4f76533 100644 --- a/src/video-types/youtube/transcript.ts +++ b/src/video-types/youtube/transcript.ts @@ -4,40 +4,34 @@ import fetchPonyfill from 'fetch-ponyfill'; import { - InvalidVideoIdError, CannotRetrieveVideoPageError, FetchTranscriptRequestFailureError, InnerTubeApiKeyMissingError, InvalidTranscriptActionsError, InvalidTranscriptResponseContextError, } from './errors'; +import { BaseTranscriptItem } from '../../transcript'; +import { generateNonce } from './crypto'; +import { retrieveVideoId } from './url'; +import { VIDEO_TYPE } from './common'; -const RE_YOUTUBE = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/im; +export interface CreateTranscriptFetcherParams { + type: typeof VIDEO_TYPE; +} -export interface TranscriptConfig { +interface TranscriptConfig { language?: string; country?: string; } -export interface TranscriptResponse { - text: string; + +export interface TranscriptItem extends BaseTranscriptItem { duration: number; offset: number; } const { fetch: f } = fetchPonyfill(); -export const retrieveVideoId = (videoId: string): string => { - if (videoId.length === 11) { - return videoId; - } - const matchId = videoId.match(RE_YOUTUBE); - if (matchId && matchId.length) { - return matchId[1]; - } - throw new InvalidVideoIdError('Impossible to retrieve Youtube video ID.'); -}; - -export const getVideoPage = async (videoId: string): Promise => { +const getVideoPage = async (videoId: string): Promise => { const identifier = retrieveVideoId(videoId); const videoUrl = new URL('/watch', 'https://www.youtube.com'); const videoUrlParams = new URLSearchParams({ @@ -45,63 +39,13 @@ export const getVideoPage = async (videoId: string): Promise => { }); videoUrl.search = videoUrlParams.toString(); const videoPageResponse = await f(videoUrl.toString()); - if (!videoPageResponse.ok) { - throw new CannotRetrieveVideoPageError('Unable to get video page.'); - } - return videoPageResponse.text(); -}; - -const generateNonce = () => { - const rnd = Math.random().toString(); - const alphabet = 'ABCDEFGHIJKLMOPQRSTUVWXYZabcdefghjijklmnopqrstuvwxyz0123456789'; - const jda = [ - `${alphabet}+/=`, - `${alphabet}+/`, - `${alphabet}-_=`, - `${alphabet}-_.`, - `${alphabet}-_`, - ]; - const b = jda[3]; - const a = []; - for (let i = 0; i < rnd.length - 1; i++) { - a.push(rnd[i].charCodeAt(i)); - } - let c = ''; - let d = 0; - let m; let n; let q; let r; let f; let - g; - while (d < a.length) { - f = a[d]; - g = d + 1 < a.length; - - if (g) { - m = a[d + 1]; - } else { - m = 0; - } - n = d + 2 < a.length; - if (n) { - q = a[d + 2]; - } else { - q = 0; - } - r = f >> 2; - f = ((f & 3) << 4) | (m >> 4); - m = ((m & 15) << 2) | (q >> 6); - q &= 63; - if (!n) { - q = 64; - if (!q) { - m = 64; - } - } - c += b[r] + b[f] + b[m] + b[q]; - d += 3; + if (videoPageResponse.ok) { + return videoPageResponse.text(); } - return c; + throw new CannotRetrieveVideoPageError('Unable to get video page.'); }; -const extractInnterTubeApiKeyFromPage = (videoPageBody: string): string => videoPageBody +const extractInnerTubeApiKeyFromPage = (videoPageBody: string): string => videoPageBody .split('"INNERTUBE_API_KEY":"')[1] .split('"')[0]; @@ -123,7 +67,24 @@ interface VideoPageData { clickTrackingParams?: string; } -export interface Cue { +interface TranscriptResponse { + responseContext?: unknown, + actions?: { + updateEngagementPanelAction: { + content: { + transcriptRenderer: { + body: { + transcriptBodyRenderer: { + cueGroups: Cue[], + } + } + } + } + }, + }[]; +} + +interface Cue { transcriptCueGroupRenderer: { cues: { transcriptCueRenderer: { @@ -137,8 +98,8 @@ export interface Cue { }, } -export const extractDataFromPage = (page: string): VideoPageData => ({ - innerTubeApiKey: extractInnterTubeApiKeyFromPage(page), +const extractDataFromPage = (page: string): VideoPageData => ({ + innerTubeApiKey: extractInnerTubeApiKeyFromPage(page), serializedShareEntity: extractSerializedShareEntityFromPage(page), visitorData: extractVisitorDataFromPage(page), sessionId: extractSessionIdFromPage(page), @@ -191,7 +152,7 @@ const generateGetTranscriptRequestBody = ( }; }; -export const fetchTranscriptItems = async (pageData: VideoPageData, config?: TranscriptConfig) => { +const fetchTranscriptItems = async (pageData: VideoPageData, config?: TranscriptConfig) => { const { innerTubeApiKey } = pageData; if (!(innerTubeApiKey && innerTubeApiKey.length > 0)) { throw new InnerTubeApiKeyMissingError('InnerTube API key not found on video page.'); @@ -214,7 +175,7 @@ export const fetchTranscriptItems = async (pageData: VideoPageData, config?: Tra throw new FetchTranscriptRequestFailureError(`Fetching transcript failed with status ${transcriptResponse.status}.`); } - const transcriptBody = await transcriptResponse.json(); + const transcriptBody = await transcriptResponse.json() as TranscriptResponse; if (!transcriptBody.responseContext) { throw new InvalidTranscriptResponseContextError('No responseContext found on get transcript response.'); } @@ -244,5 +205,19 @@ export const fetchTranscriptItems = async (pageData: VideoPageData, config?: Tra .startOffsetMs, 10, ), - })) as TranscriptResponse[]; + })) as TranscriptItem[]; +}; + +export interface SummarizerProcessParams { + url: string; + language?: string; + country?: string; +} + +export const getRawTranscript = async (params: SummarizerProcessParams) => { + const { url, ...config } = params; + const identifier = retrieveVideoId(url); + const videoPageBody = await getVideoPage(identifier); + const pageData = extractDataFromPage(videoPageBody); + return fetchTranscriptItems(pageData, config); }; diff --git a/src/video-types/youtube/url.ts b/src/video-types/youtube/url.ts new file mode 100644 index 0000000..27c01fd --- /dev/null +++ b/src/video-types/youtube/url.ts @@ -0,0 +1,19 @@ +import { InvalidVideoIdError } from './errors'; + +const STANDARD_YOUTUBE_VIDEO_ID_LENGTH = 11 as const; + +export const RE_YOUTUBE = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v?:i?=|&v?:i?=))([^#&?]*).*/im; + +export const retrieveVideoId = (videoId: string): string => { + if (typeof (videoId as unknown) !== 'string') { + throw new InvalidVideoIdError('The video ID must be a string.'); + } + if (videoId.length === STANDARD_YOUTUBE_VIDEO_ID_LENGTH) { + return videoId; + } + const matchId = videoId.match(RE_YOUTUBE); + if (matchId && matchId.length > 1) { + return matchId[1]; + } + throw new InvalidVideoIdError('Impossible to retrieve Youtube video ID.'); +}; diff --git a/test/index.test.ts b/test/index.test.ts index ec7cb83..9d15d83 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -52,7 +52,7 @@ describe('blah', () => { done(); }); - summarizer.process({ + summarizer.summarize({ url: 'https://www.youtube.com/watch?v=WeNgDxtBiyw', }); }), { timeout: 180000 }); diff --git a/yarn.lock b/yarn.lock index 870e2f8..5298d8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -233,6 +233,11 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@dqbd/tiktoken@^1.0.6": + version "1.0.6" + resolved "https://registry.yarnpkg.com/@dqbd/tiktoken/-/tiktoken-1.0.6.tgz#96bfd0a4909726c61551a8c783493f01841bd163" + integrity sha512-umSdeZTy/SbPPKVuZKV/XKyFPmXSN145CcM3iHjBbmhlohBJg7vaDp4cPCW+xNlWL6L2U1sp7T2BD+di2sUKdA== + "@esbuild/android-arm64@0.17.16": version "0.17.16" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.16.tgz#7b18cab5f4d93e878306196eed26b6d960c12576" @@ -436,6 +441,10 @@ resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.2.49.tgz#b4322b2610173bf71185ab394923d49f467f8f97" integrity sha512-tXJUP9EFcfeTcn3hpn616qtcbaLMrhqfgsljRnIv/qYckL8ywLodk7Cj3oJlZed3zWLZLnE9LHHsfpO8w4yJuw== +"@modal-sh/mio-ai@link:../../../openai-utils": + version "0.0.0" + uid "" + "@next/eslint-plugin-next@^13.2.4": version "13.3.0" resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-13.3.0.tgz#3a4742b0817575cc0dd4d152cb10363584c215ac"