diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..061384f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +yarn.lock +package-lock.json +.env +.babelrc \ No newline at end of file diff --git a/agents/search_reporter.js b/agents/search_reporter.js new file mode 100644 index 0000000..d44779b --- /dev/null +++ b/agents/search_reporter.js @@ -0,0 +1,36 @@ +import dotenv from 'dotenv'; +import * as gemini from '../system/llm/gemini.js'; +import * as redmine from '../system/redmine/driver.js'; + +dotenv.config(); + +let params = { + REDMINE_API_KEY: process.env.REDMINE_API_KEY, + REDMINE_URL: process.env.REDMINE_URL, + PROJECT_ID: process.env.PROJECT_ID +}; + +let personality = { + "role": "system", + "content": + ` + あなたはuserの質問を正確に理解して、丁寧に仕事をする調査報告者です。 + 最後まであきらめずに回答します。 + ` +}; + +export async function work() { + await redmine.getTicketsByStatus(params, 1).then(async (response) => { + for (let issue of response) { + if (issue.status.id < 3) { + let token = issue.description; + let prompt = await gemini.createPrompt(token); + prompt.system.push(personality); + let answer = await gemini.getAnswer(prompt); + await redmine.addCommentToTicket(params, issue.id, answer); + console.log("done."); + } + + } + }); +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..95cd6fa --- /dev/null +++ b/main.js @@ -0,0 +1,17 @@ +import dotenv from 'dotenv'; +import * as searchReporter from './agents/search_reporter.js'; + +dotenv.config(); + +let executable = true; + +async function process(){ + if(executable){ + executable = false; + await searchReporter.work(); + executable = true; + } +} + +setInterval(process, 10000); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..daacb08 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "ai-team", + "version": "0.0.1", + "main": "main.js", + "type": "module", + "scripts": { + "debug": "node --inspect-brk main.js", + "start": "node main.js", + "test": "jest" + }, + "repository": "https://gitbucket.tmworks.club/git/dtmoyaji/ai-team.git", + "author": "dtmoyaji", + "license": "MIT", + "dependencies": { + "@google/generative-ai": "^0.12.0", + "axios": "^1.7.2", + "cheerio": "^1.0.0-rc.12", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "jest": "^29.7.0" + }, + "devDependencies": { + "@babel/core": "^7.24.6", + "@babel/preset-env": "^7.24.6", + "babel-jest": "^29.7.0" + } +} diff --git a/system/llm/externalsearch.js b/system/llm/externalsearch.js new file mode 100644 index 0000000..a72c84f --- /dev/null +++ b/system/llm/externalsearch.js @@ -0,0 +1,105 @@ +import axios from 'axios'; +import cheerio from 'cheerio'; +import dotenv from 'dotenv'; + +dotenv.config(); + +export async function searchDuckDuckGo(query, maxResults = 3) { + const url = `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + console.log(url); + try { + const response = await axios.get(url); + const $ = cheerio.load(response.data); + const results = []; + const promises = []; + $('.result__title').each((index, element) => { + if (index < maxResults) { + promises.push((async () => { + let pageTitle = $(element).text(); + // 改行で分割して再結合する。 + pageTitle = pageTitle.split('\n').join(''); + // トリムする。 + pageTitle = pageTitle.trim(); + + let url = $(element).find('a').attr('href'); + url = decodeURIComponent(url.replace('//duckduckgo.com/l/?uddg=', '')); + url = url.split('&rut=')[0]; + results.push({ + "role": "note", + "title": pageTitle, + "link": url, + "content": await getPageContent(url) + }); + })()); + } + }); + await Promise.all(promises); + return results; + } catch (error) { + console.error(error); + } +} + +// Google CSE を使って、外部情報を取得する。検索結果のURLから情報を取得し、JSON形式で返す。 +async function searchGoogleCSE(query, maxResults = 3) { + // 改行でsplitして、trimして再結合する。 + query = query.split('\n').map((line) => line.trim()).join(' '); + // \\nをスペースに置換する。 + query = query.replace(/\\n/g, ' '); + // 連続する空白を削除する。 + query = query.replace(/\s+/g, ' '); + + try { + let keyworkds = query; + let returnData = []; + const response = await axios.get('https://www.googleapis.com/customsearch/v1', { + params: { + key: process.env.GOOGLE_API_KEY, + cx: process.env.GOOGLE_CSE_ID, + q: keyworkds, + num: maxResults, + } + }); + + if (response.data.searchInformation.totalResults !== '0') { + for (let item of response.data.items) { + if (item.mime === undefined || item.mime !== 'application/pdf') { + let itemLink = item.link; + console.log(`get ${itemLink}`); + // itemLinkから情報を取得する。 + try { + returnData.push({ + "role": "note", + "title": item.title, + "link": itemLink, + "content": await getPageContent(itemLink) + }); + } catch (error) { + console.error(error); // エラーメッセージをログに出力 + } + } + } + } + return returnData; + } catch (error) { + console.error(error.response.data); // エラーメッセージをログに出力 + } +} + +async function getPageContent(url, textLimit = 2048) { + try { + let itemResponse = await axios.get(url,{timecout: 3000}); + let itemData = itemResponse.data; + const $ = cheerio.load(itemData); + itemData = $('body').text(); + // 改行でsplitして、trimして再結合する。 + itemData = itemData.split('\n').map((line) => line.trim()).join(' '); + // 連続する空白を削除する。 + itemData = itemData.replace(/\s+/g, ' '); + // 先頭から2k文字までで切り取る。 + itemData = itemData.substring(0, textLimit); + return itemData; + } catch (error) { + return ''; + } +} diff --git a/system/llm/gemini.js b/system/llm/gemini.js new file mode 100644 index 0000000..9dbc8ba --- /dev/null +++ b/system/llm/gemini.js @@ -0,0 +1,79 @@ +import { GoogleGenerativeAI } from "@google/generative-ai"; +import { searchDuckDuckGo } from './externalsearch.js'; + +// Access your API key (see "Set up your API key" above) +const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); + +export function generateModel() { + const generationConfig = { + temperature: process.env.GEMINI_TEMPERATURE, + maxOutputTokens: process.env.GEMINI_MAX_OUTPUT_TOKENS, + topK: process.env.GEMINI_TOP_K, + topP: process.env.GEMINI_TOP_P + }; + + // For text-only input, use the gemini-pro model + const model = genAI.getGenerativeModel({ + model: + process.env.GEMINI_MODEL, generationConfig + }, [ + { "category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE" }, + { "category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE" }, + { "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE" }, + { "category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE" } + ] + ); + return model; +} + +async function queryGemini(prompt) { + try { + const model = generateModel(); + let result = await model.generateContent(prompt); + const response = result.response; + const text = response.text(); + return text; + } catch (e) { + console.log(e.message); + throw e; + } +} + +// プロンプトを作成する。TODOあとでsystemの追加ロジックを組込む。 +export async function createPrompt(token) { + + let prompt = { + "system": [], + "data": [], + "chart": [], + "currentToken": {"role": "user", "content": "${token}"}, + "refinfo": "" + }; + + let externalInfo = await searchDuckDuckGo(token); + if (externalInfo !== undefined && externalInfo.length > 0) { + for (let item of externalInfo) { + prompt.data.push(item); + prompt.refinfo += `\n\n[${item.title}](${item.link}) `; + } + } + return prompt; +} + +export async function getAnswer(prompt) { + // 質問の回答を取得する。 + let pastPrompt = []; + pastPrompt = [ + ...prompt.system, + ...prompt.data, + ...prompt.chart + ]; + pastPrompt.push(prompt.currentToken) + + let newPrompt = JSON.stringify(pastPrompt); + let replyMessage = await queryGemini(newPrompt); + if (prompt.refinfo !== '') { + replyMessage += `\n\n**参考**\n${prompt.refinfo}`; + } + return replyMessage; +} diff --git a/system/redmine/driver.js b/system/redmine/driver.js new file mode 100644 index 0000000..986cf58 --- /dev/null +++ b/system/redmine/driver.js @@ -0,0 +1,81 @@ +import axios from 'axios'; + +export async function getProjects(params) { + const redmineApiKey = params.REDMINE_API_KEY; + const redmineUrl = params.REDMINE_URL; + try { + const url = `${redmineUrl}/projects.json`; + console.log(`Fetching projects from ${url}`); + const response = await axios.get(url, { + headers: { + 'X-Redmine-API-Key': redmineApiKey, + 'Accept': 'application/json' + } + }); + return response.data.projects; + } catch (error) { + console.error(error); + } +} + +// statusを指定してチケットを取得する +export async function getTicketsByStatus(params, statusId) { + const redmineApiKey = params.REDMINE_API_KEY; + const redmineUrl = params.REDMINE_URL; + const projectId = params.PROJECT_ID; + try { + const url = `${redmineUrl}/issues.json?project_id=${projectId}&status_id=${statusId}`; + console.log(`Fetching tickets from ${url}`); + const response = await axios.get(url, { + headers: { + 'X-Redmine-API-Key': redmineApiKey, + 'Accept': 'application/json' + } + }); + return response.data.issues; + } catch (error) { + console.error(error); + } +} + +export async function getTickets(params) { + const redmineApiKey = params.REDMINE_API_KEY; + const redmineUrl = params.REDMINE_URL; + const projectId = params.PROJECT_ID; + try { + const url = `${redmineUrl}/issues.json?project_id=${projectId}`; + console.log(`Fetching tickets from ${url}`); + const response = await axios.get(url, { + headers: { + 'X-Redmine-API-Key': redmineApiKey, + 'Accept': 'application/json' + } + }); + return response.data.issues; + } catch (error) { + console.error(error); + } +} + +export async function addCommentToTicket(params, ticketId, comment) { + const redmineApiKey = params.REDMINE_API_KEY; + const redmineUrl = params.REDMINE_URL; + try { + const url = `${redmineUrl}/issues/${ticketId}.json`; + console.log(`Adding comment to ticket at ${url}`); + const response = await axios.put(url, { + issue: { + notes: comment, + status_id: 3 + } + }, { + headers: { + 'X-Redmine-API-Key': redmineApiKey, + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (error) { + console.error(error); + } +} diff --git a/test.js b/test.js new file mode 100644 index 0000000..70696fd --- /dev/null +++ b/test.js @@ -0,0 +1,33 @@ +const dotenv = require('dotenv'); +const driver = require('./system/redmine/driver'); +const gemini = require('./system/llm/gemini'); + +dotenv.config(); + +test('getProjects', async () => { + let params = { + REDMINE_API_KEY: process.env.REDMINE_API_KEY, + REDMINE_URL: process.env.REDMINE_URL, + PROJECT_ID: process.env.PROJECT_ID + }; + await driver.getProjects(params).then(async (forums) => { + expect(forums).toBeDefined(); + }); +}); + +test('getTickets', async () => { + let params = { + REDMINE_API_KEY: process.env.REDMINE_API_KEY, + REDMINE_URL: process.env.REDMINE_URL, + PROJECT_ID: process.env.PROJECT_ID + }; + await driver.getTickets(params).then(async (forums) => { + expect(forums).toBeDefined(); + }); +}); + +test('getAnswer', async () => { + let prompt = await gemini.createPrompt('日本語でHello Worldはどう言うの?'); + await expect(gemini.getAnswer(prompt)).resolves.toBeDefined(); +}); +