Skip to content

Commit

Permalink
refactor: ai langchain (#1732)
Browse files Browse the repository at this point in the history
* chore: init langchain

Signed-off-by: Innei <[email protected]>

* feat: langchain function call

Signed-off-by: Innei <[email protected]>

* fix: update

Signed-off-by: Innei <[email protected]>

* update

Signed-off-by: Innei <[email protected]>

---------

Signed-off-by: Innei <[email protected]>
  • Loading branch information
Innei authored May 28, 2024
1 parent b187cac commit a043cfa
Show file tree
Hide file tree
Showing 6 changed files with 552 additions and 85 deletions.
3 changes: 3 additions & 0 deletions apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@fastify/multipart": "8.2.0",
"@fastify/static": "7.0.4",
"@innei/next-async": "0.3.0",
"@langchain/openai": "0.0.33",
"@mx-space/external": "workspace:*",
"@nestjs/cache-manager": "2.2.2",
"@nestjs/common": "10.3.8",
Expand Down Expand Up @@ -98,6 +99,7 @@
"json5": "2.2.3",
"jsonwebtoken": "9.0.2",
"jszip": "3.10.1",
"langchain": "0.2.0",
"linkedom": "0.18.0",
"lodash": "^4.17.21",
"lru-cache": "10.2.2",
Expand Down Expand Up @@ -131,6 +133,7 @@
"zx-cjs": "7.0.7-0"
},
"devDependencies": {
"@langchain/core": "0.2.0",
"@nestjs/cli": "10.3.2",
"@nestjs/schematics": "10.1.1",
"@nestjs/testing": "10.3.8",
Expand Down
78 changes: 50 additions & 28 deletions apps/core/src/modules/ai/ai-summary/ai-summary.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import removeMdCodeblock from 'remove-md-codeblock'
import { Injectable, Logger } from '@nestjs/common'
import { OnEvent } from '@nestjs/event-emitter'

import { JsonOutputFunctionsParser } from 'langchain/output_parsers'
import { BizException } from '~/common/exceptions/biz.exception'
import { BusinessEvents } from '~/constants/business-event.constant'
import { CollectionRefTypes } from '~/constants/db.constant'
Expand All @@ -14,11 +15,10 @@ import { transformDataToPaginate } from '~/transformers/paginate.transformer'
import { md5 } from '~/utils'

import { ConfigsService } from '../../configs/configs.service'
import { DEFAULT_SUMMARY_LANG, LANGUAGE_CODE_TO_NAME } from '../ai.constants'
import { DEFAULT_SUMMARY_LANG } from '../ai.constants'
import { AiService } from '../ai.service'
import { AISummaryModel } from './ai-summary.model'
import type { PagerDto } from '~/shared/dto/pager.dto'

@Injectable()
export class AiSummaryService {
private readonly logger: Logger
Expand All @@ -39,20 +39,63 @@ export class AiSummaryService {
private serializeText(text: string) {
return removeMdCodeblock(text)
}

private async summaryChain(articleId: string, lang = DEFAULT_SUMMARY_LANG) {
const {
ai: { enableSummary },
} = await this.configService.waitForConfigReady()

if (!enableSummary) {
throw new BizException(ErrorCodeEnum.AINotEnabled)
}

const openai = await this.aiService.getOpenAiChain()

const article = await this.databaseService.findGlobalById(articleId)
if (!article || article.type === CollectionRefTypes.Recently) {
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
}

const parser = new JsonOutputFunctionsParser()

const runnable = openai
.bind({
functions: [
{
name: 'extractor',
parameters: {
type: 'object',
properties: {
summary: {
type: 'string',
description: `The summary of the input text in the natural language ${lang}, and the length of the summary is less than 150 words.`,
},
},
required: ['summary'],
},
},
],
function_call: { name: 'extractor' },
})
.pipe(parser)
const result = await runnable.invoke([
this.serializeText(article.document.text),
])

return (result as any).summary
}
async generateSummaryByOpenAI(
articleId: string,
lang = DEFAULT_SUMMARY_LANG,
) {
const {
ai: { enableSummary, openAiPreferredModel },
ai: { enableSummary },
} = await this.configService.waitForConfigReady()

if (!enableSummary) {
throw new BizException(ErrorCodeEnum.AINotEnabled)
}

const openai = await this.aiService.getOpenAiClient()

const article = await this.databaseService.findGlobalById(articleId)
if (!article) {
throw new BizException(ErrorCodeEnum.ContentNotFoundCantProcess)
Expand Down Expand Up @@ -84,35 +127,14 @@ export class AiSummaryService {
this.cachedTaskId2AiPromise.set(taskId, taskPromise)
return await taskPromise

async function handle(
this: AiSummaryService,
id: string,
text: string,
title: string,
) {
async function handle(this: AiSummaryService, id: string, text: string) {
// 等待 30s
await redis.set(taskId, 'processing', 'EX', 30)

const completion = await openai.chat.completions.create({
messages: [
{
role: 'user',
content: `Summarize this article in ${LANGUAGE_CODE_TO_NAME[lang] || 'Chinese'} to 150 words:
"${text}"
CONCISE SUMMARY:`,
},
],
model: openAiPreferredModel,
})
const summary = await this.summaryChain(id, lang)

await redis.del(taskId)

const summary = completion.choices[0].message.content

this.logger.log(
`OpenAI 生成文章 ${id}${title}」的摘要花费了 ${completion.usage?.total_tokens}token`,
)
const contentMd5 = md5(text)

const doc = await this.aiSummaryModel.create({
Expand Down
89 changes: 53 additions & 36 deletions apps/core/src/modules/ai/ai-writer/ai-writer.service.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,72 @@
import { Injectable, Logger } from '@nestjs/common'

import { ConfigsService } from '~/modules/configs/configs.service'
import { safeJSONParse } from '~/utils'
import { BizException } from '~/common/exceptions/biz.exception'
import { ErrorCodeEnum } from '~/constants/error-code.constant'
import { JsonOutputFunctionsParser } from 'langchain/output_parsers'
import { AiService } from '../ai.service'
import type { FunctionDefinition } from '@langchain/core/language_models/base'

@Injectable()
export class AiWriterService {
private readonly logger: Logger
constructor(
private readonly configService: ConfigsService,

private readonly aiService: AiService,
) {
constructor(private readonly aiService: AiService) {
this.logger = new Logger(AiWriterService.name)
}

async queryOpenAI(prompt: string): Promise<any> {
const openai = await this.aiService.getOpenAiClient()
const { openAiPreferredModel } = await this.configService.get('ai')
const result = await openai.chat.completions.create({
model: openAiPreferredModel,
messages: [{ role: 'user', content: prompt }],
})
async queryByFunctionSchema(
text: string,
parameters: FunctionDefinition['parameters'],
) {
const functionSchema: FunctionDefinition = {
name: 'extractor',
description: 'Extracts fields from the input.',
parameters,
}
const model = await this.aiService.getOpenAiChain()
const parser = new JsonOutputFunctionsParser()

const content = result.choices[0].message.content || ''
this.logger.log(
`查询 OpenAI 返回结果:${content} 花费了 ${result.usage?.total_tokens} 个 token`,
)
const runnable = model
.bind({
functions: [functionSchema],
function_call: { name: 'extractor' },
})
.pipe(parser)
const result = await runnable.invoke([text])

const json = safeJSONParse(content)
if (!json) {
throw new BizException(ErrorCodeEnum.AIResultParsingError)
}
return json
return result
}

async generateTitleAndSlugByOpenAI(text: string) {
return this
.queryOpenAI(`Please give the following text a title in the same language as the text and a slug in English, the output format is JSON. {title:string, slug:string}:
"${text}"
RESULT:`)
return this.queryByFunctionSchema(text, {
type: 'object',
properties: {
title: {
type: 'string',
description:
'The title of the article generated from the input text, the natural language of the title should be the same as the natural language of the input text',
},
slug: {
type: 'string',
description:
'The slug is named after the text entered, and is in English and conforms to url specifications',
},
lang: {
type: 'string',
description: 'The natural language of the input text',
},
},
required: ['title', 'slug', 'lang'],
})
}

async generateSlugByTitleViaOpenAI(title: string) {
return this
.queryOpenAI(`Please give the following title a slug in English, the output format is JSON. {slug:string}:
"${title}"
RESULT:`)
return this.queryByFunctionSchema(title, {
type: 'object',
properties: {
slug: {
type: 'string',
description:
'The slug is named after the text entered, and is in English and conforms to url specifications',
},
},
required: ['slug'],
})
}
}
16 changes: 10 additions & 6 deletions apps/core/src/modules/ai/ai.service.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,27 @@
import { ChatOpenAI } from '@langchain/openai'
import { Injectable } from '@nestjs/common'
import OpenAI from 'openai'
import { BizException } from '~/common/exceptions/biz.exception'
import { ErrorCodeEnum } from '~/constants/error-code.constant'
import { ConfigsService } from '../configs/configs.service'

@Injectable()
export class AiService {
constructor(private readonly configService: ConfigsService) {}
public async getOpenAiClient() {

public async getOpenAiChain() {
const {
ai: { openAiEndpoint, openAiKey },
ai: { openAiKey, openAiEndpoint, openAiPreferredModel },
} = await this.configService.waitForConfigReady()
if (!openAiKey) {
throw new BizException(ErrorCodeEnum.AINotEnabled, 'Key not found')
}
return new OpenAI({

return new ChatOpenAI({
model: openAiPreferredModel,
apiKey: openAiKey,
baseURL: openAiEndpoint || void 0,
fetch: isDev ? fetch : void 0,
configuration: {
baseURL: openAiEndpoint || void 0,
},
})
}
}
4 changes: 2 additions & 2 deletions paw.paw
Git LFS file not shown
Loading

0 comments on commit a043cfa

Please sign in to comment.