Summarize a web page using langchain.js and Gemini in a NestJS application

Connie Leung - May 18 - - Dev Community

Introduction

As a software engineer, staying relevant in web technology is important by reading blog posts from blogger sites such as dev.to and HashNode. Many blog authors publish posts daily, and readers must select good ones from all of them to learn smartly and quickly. In the past, I scanned the first few paragraphs of the post and decided whether or not to finish reading it. Now, I can use applications to summarize a web page using langchain.js and Gemini 1.5 Pro. If I read the summary and find the topic interesting, I will continue to read the entire blog post.

What is langchain.js?

Langchain is a framework for developing applications powered by language models. It offers libraries to create prompts, text embedding, retrievers, and chat models and integrate with third parties such as Google.

Generate Gemini API Key

Go to https://aistudio.google.com/app/apikey to generate an API key for a new or an existing Google Cloud project.

Create a new NestJS Project

nest new nestjs-text-summarization
Enter fullscreen mode Exit fullscreen mode

Install dependencies

npm i --save-exact @nestjs/swagger @nestjs/throttler dotenv compression helmet langchain @langchain/google-genai @langchain/community class-validator class-transformer 
Enter fullscreen mode Exit fullscreen mode

Generate a Translation Module

nest g mo summarization
nest g co summarization/presenters/http/summarization --flat
nest g s summarization/application/geminiSummarization --flat
nest g s summarization/application/summarizationChain --flat
Enter fullscreen mode Exit fullscreen mode

Create a Summarization module, a controller, a service for the API and another service to build summarization chains.

Define Gemini environment variables

// .env.example

PORT=3000
GOOGLE_GEMINI_API_KEY=<google gemini api key>
GOOGLE_GEMINI_MODEL=gemini-pro
Enter fullscreen mode Exit fullscreen mode

Copy .env.example to .env, and replace GOOGLE_GEMINI_API_KEY and GOOGLE_GEMINI_MODEL with the actual API Key and the Gemini model name, respectively.

  • GOOGLE_GEMINI_API_KEY - Gemini API key of Gemini
  • GOOGLE_GEMINI_MODEL - Gemini mode name. In this application, I use Gemini 1.5 Pro to perform web page summarization

To prevent accidentally committing the Gemini API Key to the GitHub repository, add '.env' to the '.gitignore' file.

Add configuration files

The project has 3 configuration files. validate.config.ts validates the payload is valid before any request can route to the controller to execute

// validate.config.ts

import { ValidationPipe } from '@nestjs/common';

export const validateConfig = new ValidationPipe({
  whitelist: true,
  stopAtFirstError: true,
  forbidUnknownValues: false,
});
Enter fullscreen mode Exit fullscreen mode

env.config.ts extracts the environment variables from process.env and stores the values in the env object.

// env.config.ts

import dotenv from 'dotenv';
import { ModelTypes } from '~summarization/infrastructure/types/model.type';

dotenv.config();

export const env = {
  PORT: parseInt(process.env.PORT || '3000'),
  GEMINI: {
    API_KEY: process.env.GOOGLE_GEMINI_API_KEY || '',
    MODEL_NAME: process.env.GOOGLE_GEMINI_MODEL || 'gemini-pro',
  },
};
Enter fullscreen mode Exit fullscreen mode

throttler.config.ts defines the rate limit of the Translation API

// throttler.config.ts

import { ThrottlerModule } from '@nestjs/throttler';

export const throttlerConfig = ThrottlerModule.forRoot([
  {
    ttl: 60000,
    limit: 10,
  },
]);
Enter fullscreen mode Exit fullscreen mode

Each route allows ten requests in 60,000 milliseconds or 1 minute.

Bootstrap the application

// bootstrap.ts

export class Bootstrap {
  private app: NestExpressApplication;

  async initApp() {
    this.app = await NestFactory.create(AppModule);
  }

  enableCors() {
    this.app.enableCors();
  }

  setupMiddleware() {
    this.app.use(express.json({ limit: '1000kb' }));
    this.app.use(express.urlencoded({ extended: false }));
    this.app.use(compression());
    this.app.use(helmet());
  }

  setupGlobalPipe() {
    this.app.useGlobalPipes(validateConfig);
  }

  async startApp() {
    await this.app.listen(env.PORT);
  }

  setupSwagger() {
    const config = new DocumentBuilder()
      .setTitle('Generative AI Text Summarization')
      .setDescription('Integrate with LangChain and Gemini to summarize a web page')
      .setVersion('1.0')
      .addTag('Langchain, Gemini 1.5 Pro Model')
      .build();
    const document = SwaggerModule.createDocument(this.app, config);
    SwaggerModule.setup('api', this.app, document);
  }
}
Enter fullscreen mode Exit fullscreen mode

Add a Bootstrap class to setup Swagger, middleware, global validation, CORS, and finally application start.

// main.ts

import { Bootstrap } from '~core/bootstrap';

async function bootstrap() {
  const bootstrap = new Bootstrap();
  await bootstrap.initApp();
  bootstrap.enableCors();
  bootstrap.setupMiddleware();
  bootstrap.setupGlobalPipe();
  bootstrap.setupSwagger();
  await bootstrap.startApp();
}
bootstrap()
  .then(() => console.log('The application starts successfully'))
  .catch((error) => console.error(error));
Enter fullscreen mode Exit fullscreen mode

The bootstrap function enables CORS, registers middleware to the application, sets up Swagger documentation, and uses a global pipe to validate payloads.

I have laid down the groundwork, and the next step is to add routes to receive payload to summarize a web page and provide a summary.

Define Summarize DTO

// summarize.dto.ts

import { IsNotEmpty, IsOptional, IsString, IsUrl } from 'class-validator';

export class SummarizeDto {
  @IsUrl()
  @IsNotEmpty()
  url: string;

  @IsOptional()
  @IsString()
  topic?: string;
}
Enter fullscreen mode Exit fullscreen mode

SummarizeDto accepts a URL and an optional topic. When an appropriate topic is provided, the summary matches the blog post's content with high accuracy.

Define Summarize Interface

This application is designed to summarize a web page using langchain.js and Gemini Pro Model. Therefore, I created a Summarize interface and all services that implement the interface must fulfill the contract.

// model-provider.interface.ts

export interface ModelProvider {
  company: string;
  model: string;
  developer: string;
}
Enter fullscreen mode Exit fullscreen mode
// summarize-input.interface.ts

export interface SummarizeInput {
  url: string;
  topic?: string;
}
Enter fullscreen mode Exit fullscreen mode
// summarize-result.interface.ts

export interface SummarizationResult {
  url: string;
  text: string;
}
Enter fullscreen mode Exit fullscreen mode
//  summarize.interface.ts

import { ModelProvider } from './model-provider.interface';
import { SummarizeInput } from './summarize-input.interface';
import { SummarizationResult } from './summarize-result.interface';

export interface Summarize {
  getLLModel(): ModelProvider;
  summarize(input: SummarizeInput): Promise<SummarizationResult>;
  bulletPoints(input: SummarizeInput): Promise<SummarizationResult>;
}
Enter fullscreen mode Exit fullscreen mode
  • getLLModel - returns the company and model used to summarize a web page
  • summarize - this method loads the web page, splits the document into chunks, and returns the summary in paragraphs
  • bulletPoints - this method loads the web page, split the document into chunks, and returns the bullet point summary

Implement Gemini Summarization Service

// gemini.model.ts

import { HarmBlockThreshold, HarmCategory } from '@google/generative-ai';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { env } from '~configs/env.config';

export const googleChatModel = new ChatGoogleGenerativeAI({
  model: env.GEMINI.MODEL_NAME,
  maxOutputTokens: 2048,
  safetySettings: [
    {
      category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
      threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
    },
    {
      category: HarmCategory.HARM_CATEGORY_HARASSMENT,
      threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
    },
    {
      category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
      threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
    },
    {
      category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
      threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE,
    },
  ],
  temperature: 0,
  topK: 10,
  topP: 0.5,
  apiKey: env.GEMINI.API_KEY,
});
Enter fullscreen mode Exit fullscreen mode
// local-llm.provider.ts

import { Provider } from '@nestjs/common';
import { LLM } from '~core/constants/translator.constant';
import { googleChatModel } from '../models/gemini.model';

export const LLM_PROVIDER: Provider = {
  provide: LLM,
  useValue: googleChatModel,
};
Enter fullscreen mode Exit fullscreen mode

LLM_PROVIDER creates a provider that returns a Google Generative AI Chat Model.

// summarization-chain.service.ts

// Omit the import statements 

@Injectable()
export class SummarizationChainService {
  constructor(@Inject(LLM) private model: ChatGoogleGenerativeAI) {}

  createParagraphsChain(topic: string): StuffDocumentsChain {
    const topicInput = topic ? ` of {topic}` : '';
    const systemMessage = `Write a summary of the following text delimited by triple dashes which covers the key points${topicInput}.`;
    const stuffPromptTemplate = `
        ${systemMessage}
        Return your response in two paragraphs without bullet points.
        ---{text}---
        PARAGRAPH:`;

    const inputVariables = topic ? ['text', 'topic'] : ['text'];
    const stuffPrompt = new PromptTemplate({
      template: stuffPromptTemplate,
      inputVariables,
    });

    return loadSummarizationChain(this.model, {
      type: 'stuff',
      prompt: stuffPrompt,
      verbose: true,
    }) as StuffDocumentsChain;
  }

  createBulletPointsChain(topic: string): StuffDocumentsChain {
    const topicInput = topic ? ` of {topic}` : '';
    const systemMessage = `Write a summary of the following text delimited by triple dashes which covers the key points${topicInput}.`;
    const stuffPromptTemplate = `
        ${systemMessage}
        Return your response in bullet points.
        ---{text}---
        BULLET POINT SUMMARY:`;

    const inputVariables = topic ? ['text', 'topic'] : ['text'];
    const stuffPrompt = new PromptTemplate({
      template: stuffPromptTemplate,
      inputVariables,
    });

    return loadSummarizationChain(this.model, {
      type: 'stuff',
      prompt: stuffPrompt,
      verbose: true,
    }) as StuffDocumentsChain;
  }

  private async createDocumentsFromUrl(url: string) {
    const loader = new CheerioWebBaseLoader(url);
    const textSplitter = new RecursiveCharacterTextSplitter({ chunkSize: 2000, chunkOverlap: 100 });
    return loader.loadAndSplit(textSplitter);
  }

  async generateAnswer(stuffChain: StuffDocumentsChain, input: SummarizeInput): Promise<string> {
    const { url, topic } = input;
    const docs = await this.createDocumentsFromUrl(url);
    const topicInputVariable = topic ? { topic } : {};
    const results = await stuffChain.invoke({
      ...topicInputVariable,
      input_documents: docs,
    });

    return results.text as string;
  }
}
Enter fullscreen mode Exit fullscreen mode

SummarizationChainService implements methods that construct a StuffDocumentsChain.

  • createDocumentsFromUrl - a private method that loads the web page and splits the content into small documents.
  • createParagraphsChain - creates a StuffDocumentsChain that generates summary in paragraphs.
  • createBulletPointsChain- creates a StuffDocumentsChain that generates bullet point summary.
  • generateAnswer - the method uses the StuffDocumentsChain to generate the answer from the documents, URL, and topic.
// gemini-summarization.service.

// Omit the import statements to save space

@Injectable()
export class GeminiSummarizationService implements Summarize {
  constructor(private chainService: SummarizationChainService) {}

  getLLModel(): ModelProvider {
    return {
      company: 'Google',
      developer: 'Google',
      model: env.GEMINI.MODEL_NAME,
    };
  }

  async summarize(input: SummarizeInput): Promise<SummarizationResult> {
    const stuffChain = await this.chainService.createParagraphsChain(input.topic);
    const text = await this.chainService.generateAnswer(stuffChain, input);

    return {
      url: input.url,
      text,
    };
  }

  async bulletPoints(input: SummarizeInput): Promise<SummarizationResult> {
    const stuffChain = await this.chainService.createBulletPointsChain(input.topic);
    const text = await this.chainService.generateAnswer(stuffChain, input);

    return {
      url: input.url,
      text,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

GeminiSummarizationService injects SummarizationChainService and calls the chain to generate a summary.

Implement Summarization Controller

// summarization.controller.ts

// Omit the import statements to save space

@Controller('summarization')
export class SummarizationController {
  constructor(private service: GeminiSummarizationService) {}

  @Post()
  summarize(@Body() dto: SummarizeDto): Promise<SummarizationResult> {
    return this.service.summarize(dto);
  }

  @Post('bullet-points')
  createBulletPoints(@Body() dto: SummarizeDto): Promise<SummarizationResult> {
    return this.service.bulletPoints(dto);
  }

  @Get('llm')
  getLLModel(): ModelProvider {
    return this.service.getLLModel();
  }
}
Enter fullscreen mode Exit fullscreen mode

The SummarizationController injects GeminiSummarizationService using langchain.js and Gemini 1.5 Pro. The endpoints invoke the methods to load the web page to generate a summary .

  • /summarization - generate a summary in paragraph format
  • /summarization/bullet-points - generate a bullet point summary
  • /summarization/llm - return the information of the LLM, company and developer

Module Registration

The SummarizationModule provides SummarizationChainService, GeminiSummarizationService, and LLM_PROVIDER. The module has one controller that is SummarizationController.

// summarization.module.ts

// Omit the import statements due to brevity reason 

@Module({
  controllers: [SummarizationController],
  providers: [SummarizationChainService, GeminiSummarizationService, LLM_PROVIDER],
})
export class SummarizationModule {}
Enter fullscreen mode Exit fullscreen mode

Import SummarizationModule into AppModule.

// app.module.ts

@Module({
  imports: [throttlerConfig, SummarizationModule],
  controllers: [AppController],
  providers: [
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard,
    },
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Test the endpoints

I can test the endpoints with cURL, Postman or Swagger documentation after launching the application.

npm run start:dev
Enter fullscreen mode Exit fullscreen mode

The URL of the Swagger documentation is http://localhost:3000/api.

In cURL

curl --location 'http://localhost:3000/summarization' \
--header 'Content-Type: application/json' \
--data '{
    "url": "https://js.langchain.com/docs/expression_language/streaming#chains",
    "topic": "langchain, streaming"
}'
Enter fullscreen mode Exit fullscreen mode
curl --location 'http://localhost:3000/summarization/bullet-points' \
--header 'Content-Type: application/json' \
--data '{
    "url": "https://js.langchain.com/docs/expression_language/streaming#chains",
    "topic": "langchain, streaming"
}'
Enter fullscreen mode Exit fullscreen mode

Dockerize the application

// .dockerignore

.git
.gitignore
node_modules/
dist/
Dockerfile
.dockerignore
npm-debug.log
Enter fullscreen mode Exit fullscreen mode

Create a .dockerignore file for Docker to ignore some files and directories.

// Dockerfile

# Use an official Node.js runtime as the base image
FROM node:20-alpine

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json to the working directory
COPY package*.json ./

# Install the dependencies
RUN npm install

# Copy the rest of the application code to the working directory
COPY . .

# Expose a port (if your application listens on a specific port)
EXPOSE 3000

# Define the command to run your application
CMD [ "npm", "run", "start:dev"]
Enter fullscreen mode Exit fullscreen mode

I added the Dockerfile that installs the dependencies, builds the NestJS application, and starts it at port 3000.

//  .env.docker.example

PORT=3000
GOOGLE_GEMINI_API_KEY=<google gemini api key>
GOOGLE_GEMINI_MODEL=gemini-pro
WEB_PORT=4200
Enter fullscreen mode Exit fullscreen mode

.env.docker.example stores the relevant environment variables that I copied from the NestJS application.

// docker-compose.yaml

version: '3.8'

services:
  backend:
    build:
      context: ./nestjs-text-summarization
      dockerfile: Dockerfile
    environment:
      - PORT=${PORT}
      - GOOGLE_GEMINI_API_KEY=${GOOGLE_GEMINI_API_KEY}
      - GOOGLE_GEMINI_MODEL=${GOOGLE_GEMINI_MODEL}
    ports:
      - "${PORT}:${PORT}"
    networks:
      - ai
    restart: unless-stopped
networks:
  ai:
Enter fullscreen mode Exit fullscreen mode

I added the docker-compose.yaml in the root folder, which was responsible for creating the NestJS application container.

This concludes my blog post about using langchain.js and Gemini 1.5 Pro model to solve a real-world problem. I only scratched the surface of LangChain LLM, and Langchain supports many integrations to solve problems in different domains. I hope you like the content and continue to follow my learning experience in Angular, NestJS, and other technologies.

Resources:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .