├── .devcontainer
├── Dockerfile
├── create-db-user.sql
├── devcontainer.json
├── docker-compose.yml
├── install-ble.sh
├── plugin_generator.sh
├── post-create.sh
└── redmine.code-workspace
├── .github
├── build-scripts
│ ├── build.sh
│ ├── cleanup.sh
│ ├── database.yml
│ ├── env.sh
│ └── install.sh
├── copilot-instructions.md
├── release.yml
└── workflows
│ ├── base_branch_check.yml
│ ├── build.yml
│ └── release.yml
├── .gitignore
├── .qlty
└── qlty.toml
├── Gemfile
├── LICENSE
├── README.md
├── app
├── controllers
│ ├── ai_helper_controller.rb
│ ├── ai_helper_model_profiles_controller.rb
│ └── ai_helper_settings_controller.rb
├── helpers
│ ├── ai_helper_helper.rb
│ ├── ai_helper_model_profiles_helper.rb
│ └── ai_helper_settings_helper.rb
├── models
│ ├── ai_helper_conversation.rb
│ ├── ai_helper_message.rb
│ ├── ai_helper_model_profile.rb
│ ├── ai_helper_setting.rb
│ ├── ai_helper_summary_cache.rb
│ └── ai_helper_vector_data.rb
└── views
│ ├── ai_helper
│ ├── _chat.html.erb
│ ├── _chat_form.html.erb
│ ├── _history.html.erb
│ ├── _html_header.html.erb
│ ├── _issue_bottom.html.erb
│ ├── _issue_form.html.erb
│ ├── _issue_reply.html.erb
│ ├── _issue_summary.html.erb
│ └── _sidebar.html.erb
│ ├── ai_helper_model_profiles
│ ├── _form.html.erb
│ ├── _show.html.erb
│ ├── edit.html.erb
│ └── new.html.erb
│ └── ai_helper_settings
│ └── index.html.erb
├── assets
├── images
│ └── icons.svg
├── javascripts
│ ├── ai_helper.js
│ └── ai_helper_markdown_parser.js
├── prompt_templates
│ ├── base_agent
│ │ ├── system_prompt.yml
│ │ └── system_prompt_ja.yml
│ ├── board_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── issue_agent
│ │ ├── backstory.yml
│ │ ├── backstory_ja.yml
│ │ ├── generate_reply.yml
│ │ ├── generate_reply_ja.yml
│ │ ├── summary.yml
│ │ └── summary_ja.yml
│ ├── issue_update_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── leader_agent
│ │ ├── backstory.yml
│ │ ├── backstory_ja.yml
│ │ ├── generate_steps.yml
│ │ ├── generate_steps_ja.yml
│ │ ├── goal.yml
│ │ ├── goal_ja.yml
│ │ ├── system_prompt.yml
│ │ └── system_prompt_ja.yml
│ ├── mcp_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── project_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── repository_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── system_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── user_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ ├── version_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
│ └── wiki_agent
│ │ ├── backstory.yml
│ │ └── backstory_ja.yml
└── stylesheets
│ └── ai_helper.css
├── config
├── config.yaml.example
├── icon_source.yml
├── locales
│ ├── en.yml
│ └── ja.yml
└── routes.rb
├── db
└── migrate
│ ├── 20250112041436_create_ai_helper_conversations.rb
│ ├── 20250112041940_create_ai_helper_messages.rb
│ ├── 20250112120759_add_project_id_to_ai_helper_conversations.rb
│ ├── 20250129221712_remove_project_id_from_ai_helper_conversations.rb
│ ├── 20250130045041_rename_update_at_helper_conversations.rb
│ ├── 20250130142054_change_date_to_datetime_for_conversation.rb
│ ├── 20250405080724_create_ai_helper_model_profiles.rb
│ ├── 20250405080829_create_ai_helper_settings.rb
│ ├── 20250405122505_add_model_name_to_ai_helper_model_profile.rb
│ ├── 20250405134714_change_type_to_llm_type.rb
│ ├── 20250414221050_add_fields_for_vector_search.rb
│ ├── 20250415220808_create_ai_helper_vector_data.rb
│ ├── 20250419035418_add_emmbeding_model_to_setting.rb
│ ├── 20250517141147_add_temperature_to_ai_helper_model_profiles.rb
│ ├── 20250519133924_create_ai_helper_summary_caches.rb
│ ├── 20250529133107_add_dimention_to_setting.rb
│ ├── 20250603101602_add_max_tokens_to_model_profile.rb
│ └── 20250604122130_add_embedding_url_to_ai_helper_setting.rb
├── example
└── redmine_fortune
│ ├── fortune_agent.rb
│ ├── fortune_tools.rb
│ └── init.rb
├── init.rb
├── lib
├── redmine_ai_helper
│ ├── agents
│ │ ├── board_agent.rb
│ │ ├── issue_agent.rb
│ │ ├── issue_update_agent.rb
│ │ ├── leader_agent.rb
│ │ ├── mcp_agent.rb
│ │ ├── project_agent.rb
│ │ ├── repository_agent.rb
│ │ ├── system_agent.rb
│ │ ├── user_agent.rb
│ │ ├── version_agent.rb
│ │ └── wiki_agent.rb
│ ├── assistant.rb
│ ├── assistant_provider.rb
│ ├── assistants
│ │ └── gemini_assistant.rb
│ ├── base_agent.rb
│ ├── base_tools.rb
│ ├── chat_room.rb
│ ├── langfuse_util
│ │ ├── anthropic.rb
│ │ ├── azure_open_ai.rb
│ │ ├── gemini.rb
│ │ ├── langfuse_wrapper.rb
│ │ └── open_ai.rb
│ ├── llm.rb
│ ├── llm_client
│ │ ├── anthropic_provider.rb
│ │ ├── azure_open_ai_provider.rb
│ │ ├── base_provider.rb
│ │ ├── gemini_provider.rb
│ │ ├── open_ai_compatible_provider.rb
│ │ └── open_ai_provider.rb
│ ├── llm_provider.rb
│ ├── logger.rb
│ ├── tool_response.rb
│ ├── tools
│ │ ├── board_tools.rb
│ │ ├── issue_search_tools.rb
│ │ ├── issue_tools.rb
│ │ ├── issue_update_tools.rb
│ │ ├── mcp_tools.rb
│ │ ├── project_tools.rb
│ │ ├── repository_tools.rb
│ │ ├── system_tools.rb
│ │ ├── user_tools.rb
│ │ ├── vector_tools.rb
│ │ ├── version_tools.rb
│ │ └── wiki_tools.rb
│ ├── util
│ │ ├── config_file.rb
│ │ ├── issue_json.rb
│ │ ├── langchain_patch.rb
│ │ ├── mcp_tools_loader.rb
│ │ ├── prompt_loader.rb
│ │ ├── system_prompt.rb
│ │ └── wiki_json.rb
│ ├── vector
│ │ ├── issue_vector_db.rb
│ │ ├── qdrant.rb
│ │ ├── vector_db.rb
│ │ └── wiki_vector_db.rb
│ └── view_hook.rb
└── tasks
│ ├── scm.rake
│ └── vector.rake
└── test
├── functional
├── ai_helper_controller_test.rb
├── ai_helper_model_profiles_controller_test.rb
└── ai_helper_settings_controller_test.rb
├── model_factory.rb
├── redmine_ai_helper_test_repo.git.tgz
├── test_config.json
├── test_helper.rb
└── unit
├── agents
├── agents_test.rb
├── issue_agent_test.rb
└── leader_agent_test.rb
├── ai_helper_helper_test.rb
├── ai_helper_summary_cache_test.rb
├── base_agent_test.rb
├── base_tools_test.rb
├── chat_room_test.rb
├── langfuse_util
├── anthropic_test.rb
├── azure_open_ai_test.rb
├── gemini_test.rb
├── langfuse_wrapper_test.rb
└── open_ai_test.rb
├── llm_client
├── anthropic_provider_test.rb
├── azure_open_ai_provider_test.rb
├── base_provider_test.rb
├── gemini_provider_test.rb
└── open_ai_compatible_provider_test.rb
├── llm_provider_test.rb
├── llm_test.rb
├── logger_test.rb
├── models
├── ai_helper_conversation_test.rb
├── ai_helper_message_test.rb
├── ai_helper_model_profile_test.rb
├── ai_helper_setting_test.rb
└── ai_helper_vector_data_test.rb
├── tool_response_test.rb
├── tools
├── board_tools_test.rb
├── issue_search_tools_test.rb
├── issue_tools_test.rb
├── issue_update_tools_test.rb
├── mcp_tools_test.rb
├── project_tools_test.rb
├── repository_tools_test.rb
├── system_tools_test.rb
├── user_tools_test.rb
├── vector_tools_test.rb
├── version_tool_provider_test.rb
└── wiki_tool_provider_test.rb
├── util
├── config_file_test.rb
├── issue_json_test.rb
├── mcp_tools_loader_test.rb
└── system_prompt_test.rb
└── vector
├── issue_vector_db_test.rb
├── qdrant_test.rb
├── vector_db_test.rb
└── wiki_vector_db_test.rb
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | ARG RUBY_VERSION=3.2
2 | ARG REDMINE_VERSION=master
3 | FROM haru/redmine_devcontainer:${REDMINE_VERSION}-ruby${RUBY_VERSION}
4 | COPY .devcontainer/post-create.sh /post-create.sh
5 | COPY .devcontainer/install-ble.sh /install-ble.sh
6 |
7 | RUN gem install ruby-lsp
8 | RUN gem install htmlbeautifier
9 | RUN gem install rubocop
10 | RUN gem install logger
11 | RUN apt-get update && apt-get install -y npm --no-install-recommends
12 | USER vscode
13 | RUN bash -x /install-ble.sh
14 |
--------------------------------------------------------------------------------
/.devcontainer/create-db-user.sql:
--------------------------------------------------------------------------------
1 | CREATE USER vscode CREATEDB;
2 | CREATE DATABASE vscode WITH OWNER vscode;
3 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // Redmine plugin boilerplate
2 | // version: 1.0.0
3 | {
4 | "name": "Redmine plugin",
5 | "dockerComposeFile": "docker-compose.yml",
6 | "service": "app",
7 | "mounts": [
8 | "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind"
9 | ],
10 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
11 | // Set *default* container specific settings.json values on container create.
12 | "settings": {
13 | "sqltools.connections": [
14 | {
15 | "name": "Rails Development Database",
16 | "driver": "PostgreSQL",
17 | "previewLimit": 50,
18 | "server": "localhost",
19 | "port": 5432,
20 | // update this to match config/database.yml
21 | "database": "app_development",
22 | "username": "vscode"
23 | },
24 | {
25 | "name": "Rails Test Database",
26 | "driver": "PostgreSQL",
27 | "previewLimit": 50,
28 | "server": "localhost",
29 | "port": 5432,
30 | // update this to match config/database.yml
31 | "database": "app_test",
32 | "username": "vscode"
33 | }
34 | ]
35 | },
36 | // Add the IDs of extensions you want installed when the container is created.
37 | "extensions": [
38 | "mtxr.sqltools",
39 | "mtxr.sqltools-driver-pg",
40 | "craigmaslowski.erb",
41 | "hridoy.rails-snippets",
42 | "misogi.ruby-rubocop",
43 | "jnbt.vscode-rufo",
44 | "donjayamanne.git-extension-pack",
45 | "ms-azuretools.vscode-docker",
46 | "KoichiSasada.vscode-rdbg",
47 | "Serhioromano.vscode-gitflow",
48 | "github.vscode-github-actions",
49 | "Shopify.ruby-extensions-pack",
50 | "ritwickdey.LiveServer",
51 | "aliariff.vscode-erb-beautify",
52 | "bysabi.prettier-vscode-standard",
53 | "GitHub.copilot",
54 | "Shunqian.prettier-plus",
55 | "Gruntfuggly.todo-tree"
56 | ],
57 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
58 | // "forwardPorts": [3000, 5432],
59 | // Use 'postCreateCommand' to run commands after the container is created.
60 | "postCreateCommand": "sh -x /post-create.sh",
61 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
62 | "remoteUser": "vscode",
63 | "features": {
64 | // "git": "latest"
65 | },
66 | "containerEnv": {
67 | "PLUGIN_NAME": "${localWorkspaceFolderBasename}"
68 | },
69 | "forwardPorts": [
70 | 3000
71 | ]
72 | }
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | app:
5 | build:
6 | context: ..
7 | dockerfile: .devcontainer/Dockerfile
8 | args:
9 | # Update 'VARIANT' to pick a version of Ruby: 3, 3.0, 2, 2.7, 2.6
10 | # Append -bullseye or -buster to pin to an OS version.
11 | # Use -bullseye variants on local arm64/Apple Silicon.
12 | RUBY_VERSION: "3.2"
13 | # Optional Node.js version to install
14 | NODE_VERSION: "lts/*"
15 | REDMINE_VERSION: "6.0-stable"
16 |
17 | # Overrides default command so things don't shut down after the process ends.
18 | command: sleep infinity
19 | volumes:
20 | - ..:/usr/local/redmine/plugins/redmine_ai_helper
21 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
22 | # network_mode: service:postgres
23 | # Uncomment the next line to use a non-root user for all processes.
24 | # user: vscode
25 |
26 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
27 | # (Adding the "ports" property to this file will not forward from a Codespace.)
28 |
29 | postgres:
30 | image: postgres:latest
31 | restart: unless-stopped
32 | volumes:
33 | - postgres-data:/var/lib/postgresql/data
34 | - ./create-db-user.sql:/docker-entrypoint-initdb.d/create-db-user.sql
35 | environment:
36 | POSTGRES_USER: postgres
37 | POSTGRES_DB: redmine
38 | POSTGRES_PASSWORD: postgres
39 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally.
40 | # (Adding the "ports" property to this file will not forward from a Codespace.)
41 |
42 | mysql:
43 | image: mysql:latest
44 | restart: unless-stopped
45 | volumes:
46 | - mysql-data:/var/lib/mysql
47 | # network_mode: service:postgres
48 | command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
49 | environment:
50 | MYSQL_ROOT_PASSWORD: root
51 | MYSQL_USER: redmine
52 | MYSQL_DB: redmine
53 | MYSQL_PASSWORD: remine
54 | qdrant:
55 | image: qdrant/qdrant
56 | ports:
57 | - 6333:6333
58 | volumes:
59 | - ./qdrant/storage:/qdrant/storage
60 | # weaviate:
61 | # command:
62 | # - --host
63 | # - 0.0.0.0
64 | # - --port
65 | # - "8080"
66 | # - --scheme
67 | # - http
68 | # image: cr.weaviate.io/semitechnologies/weaviate:1.30.0
69 | # # ports:
70 | # # - 8080:8080
71 | # # - 50051:50051
72 | # volumes:
73 | # - ./weaviate_data:/var/lib/weaviate
74 | # restart: on-failure:0
75 | # environment:
76 | # QUERY_DEFAULTS_LIMIT: 25
77 | # AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
78 | # PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
79 | # DEFAULT_VECTORIZER_MODULE: "text2vec-openai"
80 | # ENABLE_MODULES: "text2vec-openai,ref2vec-centroid,generative-openai,generative-aws,reranker-cohere"
81 | # CLUSTER_HOSTNAME: "node1"
82 | volumes:
83 | postgres-data: null
84 | mysql-data: null
85 |
--------------------------------------------------------------------------------
/.devcontainer/install-ble.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | cd /tmp
4 | git clone --recursive --depth 1 --shallow-submodules https://github.com/akinomyoga/ble.sh.git
5 | make -C ble.sh install PREFIX=~/.local
6 | echo 'source ~/.local/share/blesh/ble.sh' >> ~/.bashrc
7 |
--------------------------------------------------------------------------------
/.devcontainer/plugin_generator.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | cd `dirname $0`
5 | cd ..
6 | BASEDIR=`pwd`
7 | PLUGIN_NAME=`basename $BASEDIR`
8 | echo $PLUGIN_NAME
9 |
10 | cd $REDMINE_ROOT
11 |
12 | export RAILS_ENV="development"
13 |
14 | bundle exec rails generate redmine_plugin $PLUGIN_NAME
--------------------------------------------------------------------------------
/.devcontainer/post-create.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd $REDMINE_ROOT
3 |
4 | if [ -d .git.sv ]
5 | then
6 | mv -s .git.sv .git
7 | git pull
8 | rm .git
9 | fi
10 |
11 | # ln -s /workspaces/${PLUGIN_NAME} plugins/${PLUGIN_NAME}
12 | if [ -f plugins/${PLUGIN_NAME}/Gemfile_for_test ]
13 | then
14 | cp plugins/${PLUGIN_NAME}/Gemfile_for_test plugins/${PLUGIN_NAME}/Gemfile
15 | fi
16 |
17 |
18 | bundle install
19 | bundle exec rake redmine:plugins:ai_helper:setup_scm
20 |
21 | initdb() {
22 | if [ $DB != "sqlite3" ]
23 | then
24 | bundle exec rake db:create
25 | fi
26 | bundle exec rake db:migrate
27 | bundle exec rake redmine:plugins:migrate
28 |
29 | if [ $DB != "sqlite3" ]
30 | then
31 | bundle exec rake db:drop RAILS_ENV=test
32 | bundle exec rake db:create RAILS_ENV=test
33 | fi
34 |
35 | bundle exec rake db:migrate RAILS_ENV=test
36 | bundle exec rake redmine:plugins:migrate RAILS_ENV=test
37 | }
38 |
39 | export DB=mysql2
40 | export DB_NAME=redmine
41 | export DB_USERNAME=root
42 | export DB_PASSWORD=root
43 | export DB_HOST=mysql
44 | export DB_PORT=3306
45 |
46 | initdb
47 |
48 | export DB=postgresql
49 | export DB_NAME=redmine
50 | export DB_USERNAME=postgres
51 | export DB_PASSWORD=postgres
52 | export DB_HOST=postgres
53 | export DB_PORT=5432
54 |
55 | initdb
56 |
57 | rm -f db/redmine.sqlite3_test
58 | export DB=sqlite3
59 | initdb
60 |
--------------------------------------------------------------------------------
/.devcontainer/redmine.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "/usr/local/redmine/plugins/redmine_ai_helper"
5 | },
6 | {
7 | "path": "/usr/local/redmine"
8 | }
9 | ],
10 | "settings": {
11 | "editor.formatOnSave": true,
12 | "editor.wordWrap": "on",
13 | "files.trimFinalNewlines": true,
14 | "files.insertFinalNewline": true,
15 | "files.trimTrailingWhitespace": true,
16 | "git.ignoredRepositories": [
17 | "/workspace/redmine_ai_helper"
18 | ],
19 | "liveServer.settings.multiRootWorkspaceName": "redmine_ai_helper",
20 | "[ruby]": {
21 | "editor.defaultFormatter": "jnbt.vscode-rufo",
22 | "editor.formatOnSave": true
23 | },
24 | "files.associations": {
25 | "*.html.erb": "erb"
26 | },
27 | "[erb]": {
28 | "editor.defaultFormatter": "aliariff.vscode-erb-beautify"
29 | },
30 | "vscode-erb-beautify.useBundler": true,
31 | "github.copilot.nextEditSuggestions.enabled": false,
32 | "rubyLsp.formatter": "auto"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.github/build-scripts/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | cd `dirname $0`
6 | . ./env.sh
7 | cd ../..
8 |
9 | if [ "$NAME_OF_PLUGIN" == "" ]
10 | then
11 | export NAME_OF_PLUGIN=`basename $PATH_TO_PLUGIN`
12 | fi
13 |
14 | cd $PATH_TO_REDMINE
15 |
16 | # create scms for test
17 | bundle exec rake test:scm:setup:all
18 |
19 | # run tests
20 | # bundle exec rake TEST=test/unit/role_test.rb
21 | bundle exec rake redmine:plugins:test NAME=$NAME_OF_PLUGIN
22 |
23 | cp -pr plugins/$NAME_OF_PLUGIN/coverage $PATH_TO_PLUGIN
24 |
--------------------------------------------------------------------------------
/.github/build-scripts/cleanup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | cd `dirname $0`
6 | . env.sh
7 | cd ../..
8 |
9 | rm -rf $TESTSPACE
--------------------------------------------------------------------------------
/.github/build-scripts/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | #
7 | sqlite3: &sqlite3
8 | adapter: sqlite3
9 | pool: 5
10 | timeout: 5000
11 | database: db/redmine.sqlite3
12 |
13 | mysql: &mysql
14 | adapter: mysql2
15 | encoding: utf8
16 | database: <%= ENV['DB_NAME'] || 'redmine' %>
17 | username: <%= ENV['DB_USERNAME'] %>
18 | password: <%= ENV['DB_PASSWORD'] %>
19 | host: <%= ENV['DB_HOST'] %>
20 | port: <%= ENV['DB_PORT'] || 3306 %>
21 |
22 | postgres: &postgres
23 | adapter: postgresql
24 | encoding: utf8
25 | database: <%= ENV['DB_NAME'] || 'redmine' %>
26 | username: <%= ENV['DB_USERNAME'] %>
27 | password: <%= ENV['DB_PASSWORD'] %>
28 | host: <%= ENV['DB_HOST'] %>
29 | port: <%= ENV['DB_PORT'] || 5432 %>
30 |
31 | development:
32 | <<: *<%= ENV['DB'] || 'sqlite3' %>
33 |
34 | # Warning: The database defined as "test" will be erased and
35 | # re-generated from your development database when you run "rake".
36 | # Do not set this db to the same as development or production.
37 | test:
38 | <<: *<%= ENV['DB'] || 'sqlite3' %>
39 |
40 | production:
41 | <<: *<%= ENV['DB'] || 'sqlite3' %>
42 |
43 |
44 |
--------------------------------------------------------------------------------
/.github/build-scripts/env.sh:
--------------------------------------------------------------------------------
1 | GITHUB_DIR=$(dirname $(pwd))
2 | export PATH_TO_PLUGIN=`dirname ${GITHUB_DIR}`
3 | export TESTSPACE_NAME=testspace
4 | export TESTSPACE=$PATH_TO_PLUGIN/$TESTSPACE_NAME
5 | export PATH_TO_REDMINE=$TESTSPACE/redmine
6 | export RAILS_ENV=test
7 |
--------------------------------------------------------------------------------
/.github/build-scripts/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -e
4 |
5 | cd `dirname $0`
6 | . env.sh
7 | SCRIPTDIR=$(pwd)
8 | cd ../..
9 |
10 | if [[ ! "$TESTSPACE" = /* ]] ||
11 | [[ ! "$PATH_TO_REDMINE" = /* ]] ||
12 | [[ ! "$PATH_TO_PLUGIN" = /* ]];
13 | then
14 | echo "You should set"\
15 | " TESTSPACE, PATH_TO_REDMINE,"\
16 | " PATH_TO_PLUGIN"\
17 | " environment variables"
18 | echo "You set:"\
19 | "$TESTSPACE"\
20 | "$PATH_TO_REDMINE"\
21 | "$PATH_TO_PLUGIN"
22 | exit 1;
23 | fi
24 |
25 | apt-get update
26 | apt-get install -y clang
27 |
28 | if [ "$REDMINE_VER" = "" ]
29 | then
30 | export REDMINE_VER=master
31 | fi
32 |
33 | if [ "$NAME_OF_PLUGIN" == "" ]
34 | then
35 | export NAME_OF_PLUGIN=`basename $PATH_TO_PLUGIN`
36 | fi
37 |
38 | mkdir -p $TESTSPACE
39 |
40 | export REDMINE_GIT_REPO=https://github.com/redmine/redmine.git
41 | export REDMINE_GIT_TAG=$REDMINE_VER
42 | export BUNDLE_GEMFILE=$PATH_TO_REDMINE/Gemfile
43 |
44 | if [ -f Gemfile_for_test ]
45 | then
46 | cp Gemfile_for_test Gemfile
47 | fi
48 |
49 | # checkout redmine
50 | git clone $REDMINE_GIT_REPO $PATH_TO_REDMINE
51 | if [ -d test/fixtures ]
52 | then
53 | if [ -n "$(ls -A test/fixtures/ 2>/dev/null)" ]; then
54 | cp -f test/fixtures/* ${PATH_TO_REDMINE}/test/fixtures/
55 | fi
56 | fi
57 |
58 | cd $PATH_TO_REDMINE
59 | if [ ! "$REDMINE_GIT_TAG" = "master" ];
60 | then
61 | git checkout -b $REDMINE_GIT_TAG origin/$REDMINE_GIT_TAG
62 | fi
63 |
64 |
65 | mkdir -p plugins/$NAME_OF_PLUGIN
66 | find $PATH_TO_PLUGIN -mindepth 1 -maxdepth 1 ! -name $TESTSPACE_NAME -exec cp -r {} plugins/$NAME_OF_PLUGIN/ \;
67 |
68 | cp "$SCRIPTDIR/database.yml" config/database.yml
69 |
70 |
71 |
72 | # install gems
73 | bundle install
74 |
75 | # run redmine database migrations
76 | bundle exec rake db:create
77 | bundle exec rake db:migrate
78 |
79 | # run plugin database migrations
80 | bundle exec rake redmine:plugins:migrate
81 |
82 | bundle exec rake redmine:plugins:ai_helper:setup_scm
83 |
84 | apt-get update && apt-get install -y npm
85 |
--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------
1 | Please write comments in the source code in English.
2 |
3 | Text and messages displayed on the screen must support i18n (internationalization).
4 |
5 | Please write test code using shoulda.
6 |
--------------------------------------------------------------------------------
/.github/release.yml:
--------------------------------------------------------------------------------
1 | changelog:
2 | exclude:
3 | labels:
4 | - ignore-for-release
5 | authors:
6 | - octocat
7 | categories:
8 | - title: Breaking Changes 🛠
9 | labels:
10 | - Semver-Major
11 | - breaking-change
12 | - title: New Features 🎉
13 | labels:
14 | - Semver-Minor
15 | - enhancement
16 | - title: Bug Fixes 🐛
17 | labels:
18 | - Semver-Patch
19 | - bug
20 | - title: Other Changes 📝
21 | labels:
22 | - "*"
23 |
--------------------------------------------------------------------------------
/.github/workflows/base_branch_check.yml:
--------------------------------------------------------------------------------
1 | name: Check Base Branch for Pull Requests
2 |
3 | on:
4 | pull_request:
5 | types:
6 | - opened
7 |
8 | jobs:
9 | check_base_branch:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: checkout repository
14 | uses: actions/checkout@v2
15 |
16 | - name: Check if base branch is develop
17 | run: |
18 | if [[ "${{ github.actor }}" == "github-actions[bot]" ]]; then
19 | echo "Skipping base branch check for GitHub Actions bot."
20 | exit 0
21 | elif [[ "${{ github.base_ref }}" != "develop" ]]; then
22 | echo "The base branch of this pull request is not 'develop'. Please update the base branch to 'develop'."
23 | COMMENT_BODY="◤◢◤◢ Base branch must be a develop!! @${{ github.actor }} ◤◢◤◢"
24 | COMMENT_PAYLOAD=$(echo '{}' | jq --arg body "$COMMENT_BODY" '.body = $body')
25 | curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" -d "$COMMENT_PAYLOAD" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments"
26 | exit 0
27 | fi
28 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on:
3 | push:
4 | branches:
5 | - '**'
6 | pull_request:
7 | branches:
8 | - '**'
9 | workflow_dispatch:
10 | env:
11 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | # Supported Matrix:
16 | # Redmine 6.0-stable: Ruby 3.1, 3.2, 3.3
17 | # Redmine master: Ruby 3.2, 3.3, 3.4
18 | strategy:
19 | max-parallel: 10
20 | matrix:
21 | db: [sqlite3, mysql, postgres]
22 | ruby_version: ["3.1", "3.2", "3.3", "3.4"]
23 | redmine_version: [6.0-stable, master]
24 | exclude:
25 | - ruby_version: "3.4"
26 | redmine_version: 6.0-stable
27 | - ruby_version: "3.1"
28 | redmine_version: master
29 | services:
30 | mysql:
31 | image: mysql:5.7
32 | options: --health-cmd "mysqladmin ping -h localhost" --health-interval 20s --health-timeout 10s --health-retries 10
33 | env:
34 | MYSQL_ROOT_PASSWORD: dbpassword
35 | postgres:
36 | image: postgres
37 | env:
38 | POSTGRES_USER: root
39 | POSTGRES_PASSWORD: dbpassword
40 | options: >-
41 | --health-cmd pg_isready
42 | --health-interval 10s
43 | --health-timeout 5s
44 | --health-retries 5
45 | container:
46 | image: ruby:${{ matrix.ruby_version }}
47 | env:
48 | DB: ${{ matrix.db }}
49 | DB_USERNAME: root
50 | DB_PASSWORD: dbpassword
51 | DB_HOST: ${{ matrix.db }}
52 | REDMINE_VER: ${{ matrix.redmine_version }}
53 | steps:
54 | - uses: actions/checkout@v4
55 | - name: Install
56 | run: bash -x ./.github/build-scripts/install.sh
57 | - name: Build
58 | run: bash -x ./.github/build-scripts/build.sh
59 | - name: Clean
60 | run: bash -x ./.github/build-scripts/cleanup.sh
61 | - uses: codecov/codecov-action@v5
62 | with:
63 | token: ${{ secrets.CODECOV_TOKEN }}
64 | fail_ci_if_error: true
65 | - name: Slack Notification on Failure
66 | uses: rtCamp/action-slack-notify@v2
67 | if: failure()
68 | env:
69 | SLACK_CHANNEL: general
70 | SLACK_TITLE: Test Failure
71 | SLACK_COLOR: danger
72 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: build_archive
2 | on:
3 | push:
4 | branches-ignore:
5 | - '**'
6 | tags:
7 | - '**'
8 | workflow_dispatch:
9 | permissions:
10 | contents: write
11 | env:
12 | SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
13 | jobs:
14 | archive:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Set version
18 | id: version
19 | run: |
20 | REPOSITORY=$(echo ${{ github.repository }} | sed -e "s#.*/##")
21 | VERSION=$(echo ${{ github.ref }} | sed -e "s#refs/tags/##g")
22 | echo ::set-output name=version::$VERSION
23 | echo ::set-output name=filename::$REPOSITORY-$VERSION
24 | echo ::set-output name=plugin::$REPOSITORY
25 | - uses: actions/checkout@v4
26 | - name: Archive
27 | run: |
28 | cd ..; zip -r ${{ steps.version.outputs.filename }}.zip ${{ steps.version.outputs.plugin }}/ -x "*.git*"; mv ${{ steps.version.outputs.filename }}.zip ${{ steps.version.outputs.plugin }}/
29 | - name: Create Release
30 | id: create_release
31 | uses: actions/create-release@v1
32 | env:
33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34 | with:
35 | tag_name: ${{ steps.version.outputs.version }}
36 | release_name: ${{ steps.version.outputs.version }}
37 | body: ''
38 | draft: false
39 | prerelease: true
40 | - name: Upload Release Asset
41 | id: upload-release-asset
42 | uses: actions/upload-release-asset@v1
43 | env:
44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45 | with:
46 | upload_url: ${{ steps.create_release.outputs.upload_url }}
47 | asset_path: ${{ steps.version.outputs.filename }}.zip
48 | asset_name: ${{ steps.version.outputs.filename }}.zip
49 | asset_content_type: application/zip
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.rbc
2 | capybara-*.html
3 | .rspec
4 | /db/*.sqlite3
5 | /db/*.sqlite3-journal
6 | /db/*.sqlite3-[0-9]*
7 | /public/system
8 | /coverage/
9 | /spec/tmp
10 | *.orig
11 | rerun.txt
12 | pickle-email-*.html
13 |
14 | # Ignore all logfiles and tempfiles.
15 | /log/*
16 | /tmp/*
17 | !/log/.keep
18 | !/tmp/.keep
19 |
20 | # TODO Comment out this rule if you are OK with secrets being uploaded to the repo
21 | config/initializers/secret_token.rb
22 | config/master.key
23 |
24 | # Only include if you have production secrets in this file, which is no longer a Rails default
25 | # config/secrets.yml
26 |
27 | # dotenv, dotenv-rails
28 | # TODO Comment out these rules if environment variables can be committed
29 | .env
30 | .env*.local
31 |
32 | ## Environment normalization:
33 | /.bundle
34 | /vendor/bundle
35 |
36 | # these should all be checked in to normalize the environment:
37 | # Gemfile.lock, .ruby-version, .ruby-gemset
38 |
39 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
40 | .rvmrc
41 |
42 | # if using bower-rails ignore default bower_components path bower.json files
43 | /vendor/assets/bower_components
44 | *.bowerrc
45 | bower.json
46 |
47 | # Ignore pow environment settings
48 | .powenv
49 |
50 | # Ignore Byebug command history file.
51 | .byebug_history
52 |
53 | # Ignore node_modules
54 | node_modules/
55 |
56 | # Ignore precompiled javascript packs
57 | /public/packs
58 | /public/packs-test
59 | /public/assets
60 |
61 | # Ignore yarn files
62 | /yarn-error.log
63 | yarn-debug.log*
64 | .yarn-integrity
65 |
66 | # Ignore uploaded files in development
67 | /storage/*
68 | !/storage/.keep
69 | /public/uploads
70 | Gemfile.lock
71 | config/config.yaml
72 | .devcontainer/weaviate_data/*
73 | .devcontainer/qdrant/*
74 |
--------------------------------------------------------------------------------
/.qlty/qlty.toml:
--------------------------------------------------------------------------------
1 | # This file was automatically generated by `qlty init`.
2 | # You can modify it to suit your needs.
3 | # We recommend you to commit this file to your repository.
4 | #
5 | # This configuration is used by both Qlty CLI and Qlty Cloud.
6 | #
7 | # Qlty CLI -- Code quality toolkit for developers
8 | # Qlty Cloud -- Fully automated Code Health Platform
9 | #
10 | # Try Qlty Cloud: https://qlty.sh
11 | #
12 | # For a guide to configuration, visit https://qlty.sh/d/config
13 | # Or for a full reference, visit https://qlty.sh/d/qlty-toml
14 | config_version = "0"
15 |
16 | exclude_patterns = [
17 | "*_min.*",
18 | "*-min.*",
19 | "*.min.*",
20 | "**/*.d.ts",
21 | "**/.yarn/**",
22 | "**/bower_components/**",
23 | "**/build/**",
24 | "**/cache/**",
25 | "**/config/**",
26 | "**/db/**",
27 | "**/deps/**",
28 | "**/dist/**",
29 | "**/extern/**",
30 | "**/external/**",
31 | "**/generated/**",
32 | "**/Godeps/**",
33 | "**/gradlew/**",
34 | "**/mvnw/**",
35 | "**/node_modules/**",
36 | "**/protos/**",
37 | "**/seed/**",
38 | "**/target/**",
39 | "**/testdata/**",
40 | "**/vendor/**",
41 | "**/assets/**",
42 | ]
43 |
44 | test_patterns = [
45 | "**/test/**",
46 | "**/spec/**",
47 | "**/*.test.*",
48 | "**/*.spec.*",
49 | "**/*_test.*",
50 | "**/*_spec.*",
51 | "**/test_*.*",
52 | "**/spec_*.*",
53 | ]
54 |
55 | [smells]
56 | mode = "comment"
57 |
58 | [[source]]
59 | name = "default"
60 | default = true
61 |
62 | [[plugin]]
63 | name = "actionlint"
64 |
65 | [[plugin]]
66 | name = "brakeman"
67 |
68 | [[plugin]]
69 | name = "checkov"
70 |
71 | [[plugin]]
72 | name = "golangci-lint"
73 |
74 | [[plugin]]
75 | name = "hadolint"
76 |
77 | [[plugin]]
78 | name = "markdownlint"
79 | version = "0.41.0"
80 |
81 | [[plugin]]
82 | name = "prettier"
83 |
84 | [[plugin]]
85 | name = "reek"
86 |
87 | [[plugin]]
88 | name = "ripgrep"
89 |
90 | [[plugin]]
91 | name = "rubocop"
92 |
93 | [[plugin]]
94 | name = "shellcheck"
95 |
96 | [[plugin]]
97 | name = "shfmt"
98 |
99 | [[plugin]]
100 | name = "trivy"
101 | drivers = [
102 | "config",
103 | "fs-vuln",
104 | ]
105 |
106 | [[plugin]]
107 | name = "trufflehog"
108 |
109 | [[plugin]]
110 | name = "yamllint"
111 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 | gem "ruby-openai", "~> 8.0.0"
3 | gem "langchainrb", "~> 0.19.5"
4 | gem "ruby-anthropic", "~> 0.4.2"
5 | # gem "weaviate-ruby", "~> 0.9.2"
6 | gem "qdrant-ruby", "~> 0.9.9"
7 | gem "langfuse", "~> 0.1.1"
8 |
9 | group :test do
10 | gem "simplecov-cobertura"
11 | gem "factory_bot_rails"
12 | gem "shoulda"
13 | gem "rails-controller-testing"
14 | end
15 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Haruyuki Iida
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/app/controllers/ai_helper_model_profiles_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # Controller for performing CRUD operations on ModelProfile
3 | class AiHelperModelProfilesController < ApplicationController
4 | layout "admin"
5 | before_action :require_admin
6 | before_action :find_model_profile, only: [:show, :edit, :update, :destroy]
7 | self.main_menu = false
8 |
9 | DUMMY_ACCESS_KEY = "___DUMMY_ACCESS_KEY___"
10 |
11 | # Display the model profile
12 | def show
13 | render partial: "ai_helper_model_profiles/show"
14 | end
15 |
16 | # Display the form for creating a new model profile
17 | def new
18 | @title = l("ai_helper.model_profiles.create_profile_title")
19 | @model_profile = AiHelperModelProfile.new
20 | end
21 |
22 | # Create a new model profile
23 | def create
24 | @model_profile = AiHelperModelProfile.new
25 | @model_profile.safe_attributes = params[:ai_helper_model_profile]
26 | if @model_profile.save
27 | flash[:notice] = l(:notice_successful_create)
28 | redirect_to ai_helper_setting_path
29 | else
30 | render action: :new
31 | end
32 | end
33 |
34 | # Display the form for editing an existing model profile
35 | def edit
36 | end
37 |
38 | # Update an existing model profile
39 | def update
40 | original_access_key = @model_profile.access_key
41 | @model_profile.safe_attributes = params[:ai_helper_model_profile]
42 | @model_profile.access_key = original_access_key if @model_profile.access_key == DUMMY_ACCESS_KEY
43 | if @model_profile.save
44 | flash[:notice] = l(:notice_successful_update)
45 | redirect_to ai_helper_setting_path
46 | else
47 | render action: :edit
48 | end
49 | end
50 |
51 | # Delete an existing model profile
52 | def destroy
53 | if @model_profile.destroy
54 | flash[:notice] = l(:notice_successful_delete)
55 | redirect_to ai_helper_setting_path
56 | else
57 | flash[:error] = l(:error_failed_delete)
58 | redirect_to ai_helper_setting_path
59 | end
60 | rescue ActiveRecord::RecordNotFound
61 | render_404
62 | end
63 |
64 | private
65 |
66 | # Find the model profile based on the provided ID
67 | def find_model_profile
68 | id = params[:id] # TODO: remove this line
69 | return if params[:id].blank?
70 | @model_profile = AiHelperModelProfile.find(params[:id])
71 | rescue ActiveRecord::RecordNotFound
72 | render_404
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/app/controllers/ai_helper_settings_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperSetting Controller for managing AI Helper settings
3 | class AiHelperSettingsController < ApplicationController
4 | layout "admin"
5 | before_action :require_admin, :find_setting
6 | self.main_menu = false
7 |
8 | # Display the settings page
9 | def index
10 | end
11 |
12 | # Update the settings
13 | def update
14 | @setting.safe_attributes = params[:ai_helper_setting]
15 | if @setting.save
16 | flash[:notice] = l(:notice_successful_update)
17 | redirect_to action: :index
18 | else
19 | render action: :index
20 | end
21 | end
22 |
23 | private
24 |
25 | # Find or create the AI Helper setting and load model profiles
26 | def find_setting
27 | @setting = AiHelperSetting.find_or_create
28 | @model_profiles = AiHelperModelProfile.order(:name)
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/app/helpers/ai_helper_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperHelper module for AI Helper plugin
3 | module AiHelperHelper
4 | include Redmine::WikiFormatting::CommonMark
5 |
6 | # Converts a given Markdown text to HTML using the Markdown pipeline.
7 | def md_to_html(text)
8 | text = text.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
9 | MarkdownPipeline.call(text)[:output].to_s.html_safe
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/app/helpers/ai_helper_model_profiles_helper.rb:
--------------------------------------------------------------------------------
1 | module AiHelperModelProfilesHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/helpers/ai_helper_settings_helper.rb:
--------------------------------------------------------------------------------
1 | module AiHelperSettingsHelper
2 | end
3 |
--------------------------------------------------------------------------------
/app/models/ai_helper_conversation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperConversation model for managing AI Helper conversations
3 | class AiHelperConversation < ApplicationRecord
4 | has_many :messages, class_name: "AiHelperMessage", foreign_key: "conversation_id", dependent: :destroy
5 | belongs_to :user
6 | validates :title, presence: true
7 | validates :user_id, presence: true
8 |
9 | # Returns the last message in the conversation
10 | def messages_for_openai
11 | messages.map do |message|
12 | {
13 | role: message.role,
14 | content: message.content,
15 | }
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/app/models/ai_helper_message.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperMessage model for managing AI Helper messages
3 | class AiHelperMessage < ApplicationRecord
4 | belongs_to :conversation, class_name: "AiHelperConversation", foreign_key: "conversation_id", touch: true
5 | validates :content, presence: true
6 | validates :role, presence: true
7 | validates :conversation_id, presence: true
8 | end
9 |
--------------------------------------------------------------------------------
/app/models/ai_helper_model_profile.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperModelProfile model for managing AI Helper model profiles
3 | class AiHelperModelProfile < ApplicationRecord
4 | include Redmine::SafeAttributes
5 | validates :name, presence: true, uniqueness: true
6 | validates :llm_type, presence: true
7 | validates :access_key, presence: true, if: :access_key_required?
8 | validates :llm_model, presence: true
9 | validates :base_uri, presence: true, if: :base_uri_required?
10 | validates :base_uri, format: { with: URI::regexp(%w[http https]), message: l("ai_helper.model_profiles.messages.must_be_valid_url") }, if: :base_uri_required?
11 | validates :temperature, presence: true, numericality: { greater_than_or_equal_to: 0.0 }
12 |
13 | safe_attributes "name", "llm_type", "access_key", "organization_id", "base_uri", "version", "llm_model", "temperature", "max_tokens"
14 |
15 | # Replace all characters after the 4th with *
16 | def masked_access_key
17 | return access_key if access_key.blank? || access_key.length <= 4
18 | masked_key = access_key.dup
19 | masked_key[4..-1] = "*" * (masked_key.length - 4)
20 | masked_key
21 | end
22 |
23 | # Returns the String which is displayed in the select box
24 | def display_name
25 | "#{name} (#{llm_type}: #{llm_model})"
26 | end
27 |
28 | # returns true if base_uri is required.
29 | def base_uri_required?
30 | # Check if the llm_type is OpenAICompatible
31 | llm_type == RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE ||
32 | llm_type == RedmineAiHelper::LlmProvider::LLM_AZURE_OPENAI
33 | end
34 |
35 | # returns true if access_key is required.
36 | def access_key_required?
37 | llm_type != RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE
38 | end
39 |
40 | # Returns the LLM type name for display
41 | def display_llm_type
42 | names = RedmineAiHelper::LlmProvider.option_for_select
43 | name = names.find { |n| n[1] == llm_type }
44 | name ? name[0] : ""
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/app/models/ai_helper_setting.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | #
3 | # AiHelperSetting model for storing settings related to AI helper
4 | class AiHelperSetting < ApplicationRecord
5 | include Redmine::SafeAttributes
6 | belongs_to :model_profile, class_name: "AiHelperModelProfile"
7 | validates :vector_search_uri, :presence => true, if: :vector_search_enabled?
8 | validates :vector_search_uri, :format => { with: URI::regexp(%w[http https]), message: l("ai_helper.model_profiles.messages.must_be_valid_url") }, if: :vector_search_enabled?
9 |
10 | safe_attributes "model_profile_id", "additional_instructions", "version", "vector_search_enabled", "vector_search_uri", "vector_search_api_key", "embedding_model", "dimension", "vector_search_index_name", "vector_search_index_type", "embedding_url"
11 |
12 | class << self
13 | # This method is used to find or create an AiHelperSetting record.
14 | # It first tries to find the first record in the AiHelperSetting table.
15 | def find_or_create
16 | data = AiHelperSetting.order(:id).first
17 | data || AiHelperSetting.create!
18 | end
19 |
20 | def setting
21 | find_or_create
22 | end
23 |
24 | def vector_search_enabled?
25 | setting.vector_search_enabled
26 | end
27 | end
28 |
29 | # returns true if embedding_url is required.
30 | def embedding_url_enabled?
31 | model_profile&.llm_type == RedmineAiHelper::LlmProvider::LLM_AZURE_OPENAI
32 | end
33 |
34 | def max_tokens
35 | return nil unless model_profile&.max_tokens
36 | return nil if model_profile.max_tokens <= 0
37 | model_profile.max_tokens
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/app/models/ai_helper_summary_cache.rb:
--------------------------------------------------------------------------------
1 | class AiHelperSummaryCache < ApplicationRecord
2 | validates :object_class, presence: true
3 | validates :object_id, presence: true, uniqueness: { scope: :object_class }
4 | validates :content, presence: true
5 |
6 | def self.issue_cache(issue_id:)
7 | AiHelperSummaryCache.find_by(object_class: "Issue", object_id: issue_id)
8 | end
9 |
10 | def self.update_issue_cache(issue_id:, content:)
11 | cache = issue_cache(issue_id: issue_id)
12 | if cache
13 | cache.update(content: content)
14 | cache.save!
15 | else
16 | cache = AiHelperSummaryCache.new(object_class: "Issue", object_id: issue_id, content: content)
17 | cache.save!
18 | end
19 | cache
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/app/models/ai_helper_vector_data.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # AiHelperVectorData model for storing vector data related to Issue data
3 | class AiHelperVectorData < ApplicationRecord
4 | validates :object_id, presence: true
5 | validates :index, presence: true
6 | validates :uuid, presence: true
7 | end
8 |
--------------------------------------------------------------------------------
/app/views/ai_helper/_chat.html.erb:
--------------------------------------------------------------------------------
1 | <% @conversation.messages.each do |message| %>
2 |
3 | <%if message.role == 'user' %>
4 |
5 | <%= avatar(@user) %>
6 | <%= link_to_user(@user) %>
7 |
8 | <% end %>
9 | <% if message.role == 'user' %>
10 |
<%= message.content %>
11 | <% else %>
12 |
13 | <%= md_to_html(message.content) %>
14 |
15 | <% end %>
16 |
17 | <% end %>
18 |
21 |
22 | <% unless @conversation.messages.empty? %>
23 |
26 | <% else %>
27 |
31 | <% end %>
32 |
35 |
--------------------------------------------------------------------------------
/app/views/ai_helper/_chat_form.html.erb:
--------------------------------------------------------------------------------
1 | <%= form_with model: @message, url: ai_helper_chat_path(@project), id: 'ai_helper_chat_form', local: true do |f| %>
2 |
3 | <%= f.text_area :content, rows: 5, placeholder: t(:label_chat_placeholder), id: 'ai_helper_chat_input', style: 'width: 100%;' %>
4 |
5 | <%= hidden_field_tag :controller_name, "", id: 'ai_helper_controller_name' %>
6 | <%= hidden_field_tag :action_name, "", id: 'ai_helper_action_name' %>
7 | <%= hidden_field_tag :content_id, "", id: 'ai_helper_content_id' %>
8 | <%= f.submit t(:button_submit), id: 'aihelper-chat-submit' %>
9 | <% end %>
10 |
--------------------------------------------------------------------------------
/app/views/ai_helper/_history.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <%= sprite_icon('history', t(:label_history)) %>
3 |
4 |
5 | <% @conversations.each do |conversation| %>
6 | <% next if conversation.messages.empty? %>
7 | -
8 | <%= link_to sprite_icon('comment',conversation.messages.first.content), ai_helper_conversation_path(@project, conversation.id), onclick: "ai_helper.jump_to_history(event, '#{ai_helper_conversation_path(@project, conversation.id)}')" %>
9 |
10 | <%= link_to sprite_icon('del'), ai_helper_delete_conversation_path(@project, conversation.id), onclick: "ai_helper.delete_history(event, '#{ai_helper_delete_conversation_path(@project, conversation.id)}')" %>
11 |
12 |
13 | <% end %>
14 |
--------------------------------------------------------------------------------
/app/views/ai_helper/_html_header.html.erb:
--------------------------------------------------------------------------------
1 |
2 | <% if @project and @project.module_enabled?(:ai_helper) and User.current.allowed_to?({controller: :ai_helper, action: :chat_form}, @project)%>
3 | <%= stylesheet_link_tag('ai_helper.css', plugin: :redmine_ai_helper) %>
4 | <%= javascript_include_tag('ai_helper.js', plugin: :redmine_ai_helper) %>
5 | <%= javascript_include_tag('ai_helper_markdown_parser.js', plugin: :redmine_ai_helper) %>
6 | <% end %>
--------------------------------------------------------------------------------
/app/views/ai_helper/_issue_reply.html.erb:
--------------------------------------------------------------------------------
1 |
15 |
16 |
21 |
--------------------------------------------------------------------------------
/app/views/ai_helper/_issue_summary.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 | <%= md_to_html(summary.content) %>
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/views/ai_helper_model_profiles/_form.html.erb:
--------------------------------------------------------------------------------
1 | <%= error_messages_for "model_profile" %>
2 |
3 |
<%= f.select :llm_type, RedmineAiHelper::LlmProvider.option_for_select %>
4 |
5 | <%= f.text_field :name, size: 60, required: true, autocomplete: "off" %>
6 |
7 | <%
8 | access_key = ""
9 | access_key = AiHelperModelProfilesController::DUMMY_ACCESS_KEY unless @model_profile.new_record?
10 | %>
11 |
<%= f.password_field :access_key, value: access_key, size: 60, required: true %>
12 |
13 | <%= f.text_field :llm_model, size: 60, required: true %>
14 |
15 |
16 | <%= f.text_field :temperature, size: 60, required: true %>
17 |
18 |
19 | <%= f.text_field :max_tokens, size: 60 %>
20 |
21 |
22 | <%= f.text_field :organization_id, size: 60 %>
23 |
24 |
25 | <%= f.text_field :base_uri, size: 90, required: true %>
26 |
27 |
28 |
29 |
30 |
31 |
77 |
--------------------------------------------------------------------------------
/app/views/ai_helper_model_profiles/_show.html.erb:
--------------------------------------------------------------------------------
1 |
2 |
3 | <%= link_to(sprite_icon('edit', l(:button_edit)), ai_helper_model_profiles_edit_path(@model_profile), :class => 'icon icon-edit') %>
4 |
5 | <%= link_to(sprite_icon('del', l(:button_delete)), ai_helper_model_profiles_destroy_url(id: @model_profile), method: :delete, data: { confirm: l('ai_helper.model_profiles.messages.confirm_delete') }, :class => 'icon icon-del') %>
6 |
7 |
8 |
9 | <%= label_tag AiHelperModelProfile.human_attribute_name(:llm_type) %>
10 | <%= @model_profile.display_llm_type %>
11 |
12 |
13 | <%= label_tag AiHelperModelProfile.human_attribute_name(:access_key) %>
14 | <%= @model_profile.masked_access_key %>
15 |
16 |
17 | <%= label_tag AiHelperModelProfile.human_attribute_name(:llm_model) %>
18 | <%= @model_profile.llm_model %>
19 |
20 |
21 | <%= label_tag AiHelperModelProfile.human_attribute_name(:temperature) %>
22 | <%= @model_profile.temperature %>
23 |
24 |
25 | <%= label_tag AiHelperModelProfile.human_attribute_name(:max_tokens) %>
26 | <%= @model_profile.max_tokens %>
27 |
28 | <% if@model_profile.llm_type == RedmineAiHelper::LlmProvider::LLM_OPENAI %>
29 |
30 | <%= label_tag AiHelperModelProfile.human_attribute_name(:organization_id) %>
31 | <%= @model_profile.organization_id %>
32 |
33 | <% end %>
34 | <% if @model_profile.base_uri_required? %>
35 |
36 | <%= label_tag AiHelperModelProfile.human_attribute_name(:base_uri) %>
37 | <% unless @model_profile.base_uri.blank? %>
38 | <%= link_to @model_profile.base_uri, @model_profile.base_uri %>
39 | <% end %>
40 |
41 | <% end %>
42 | <%= @model_profile.llm_type %>
43 |
--------------------------------------------------------------------------------
/app/views/ai_helper_model_profiles/edit.html.erb:
--------------------------------------------------------------------------------
1 | <% html_title l('ai_helper.model_profiles.edit_profile_title')%>
2 | <%= l('ai_helper.model_profiles.edit_profile_title') %>
3 | <%= labelled_form_for @model_profile, url: ai_helper_model_profiles_update_path(id: @model_profile), method: :post, html: {autocomplete: "off"} do |f| %>
4 | <%= render partial: "form", locals: {f: f} %>
5 |
6 | <%= f.submit t(:button_submit) %>
7 | <%= link_to l(:button_cancel), ai_helper_setting_path %>
8 | <% end %>
9 |
--------------------------------------------------------------------------------
/app/views/ai_helper_model_profiles/new.html.erb:
--------------------------------------------------------------------------------
1 | <% html_title l('ai_helper.model_profiles.create_profile_title')%>
2 | <%= l('ai_helper.model_profiles.create_profile_title') %>
3 | <%= labelled_form_for @model_profile, html: {autocomplete: "off"} do |f| %>
4 | <%= render partial: "form", locals: {f: f} %>
5 | <%= f.submit t(:button_submit) %>
6 | <%= link_to l(:button_cancel), ai_helper_setting_path %>
7 | <% end %>
8 |
--------------------------------------------------------------------------------
/assets/images/icons.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
--------------------------------------------------------------------------------
/assets/prompt_templates/base_agent/system_prompt.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - role
5 | - time
6 | - backstory
7 | - lang
8 | template: |-
9 | You are an agent of the RedmineAIHelper plugin.
10 | The RedmineAIHelper plugin answers questions from Redmine users about Redmine features, projects, issues, and more.
11 |
12 | The final answers created by your team of agents will be displayed within the user's Redmine site. If your answer includes links to pages within Redmine, do not include the hostname in the URL; only specify the path starting with "/".
13 |
14 | **Your role is {role}. This is very important. Do not forget it.**
15 |
16 | There are multiple agent roles in RedmineAIHelper.
17 | You will work together with other agents to provide services to RedmineAIHelper users.
18 | You will receive instructions from the agent with the <> role.
19 |
20 | The current time is {time}.
21 |
22 | - You can speak various languages such as Japanese, English, and Chinese, but unless otherwise specified by the user, you should answer in {lang}.
23 |
24 | ----
25 |
26 | Your backstory is as follows.
27 | {backstory}
28 |
--------------------------------------------------------------------------------
/assets/prompt_templates/base_agent/system_prompt_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - role
5 | - time
6 | - backstory
7 | - lang
8 | template: |-
9 | あなたは RedmineAIHelper プラグインのエージェントです。
10 | RedmineAIHelper プラグインは、Redmine のユーザーにRedmine の機能やプロジェクト、チケットなどに関する問い合わせに答えます。
11 |
12 | あなた方エージェントのチームが作成した最終回答はユーザーのRedmineサイト内に表示さます。もし回答の中にRedmine内のページへのリンクが含まれる場合、そのURLにはホスト名は含めず、"/"から始まるパスのみを記載してください。
13 |
14 | **あなたのロールは {role} です。これはとても重要です。忘れないでください。**
15 |
16 | RedmineAIHelperには複数のロールのエージェントが存在します。
17 | あなたは他のエージェントと協力して、RedmineAIHelper のユーザーにサービスを提供します。
18 | あなたへの指示は <> ロールのエージェントから受け取ります。
19 |
20 | 現在の時刻は{time}です。
21 |
22 | - あなたは日本語、英語、中国語などいろいろな国の言語を話すことができますが、あなたが回答する際の言語は、特にユーザーからの指定が無い限りは{lang}で話します。
23 |
24 | ----
25 |
26 | あなたのバックストーリーは以下の通りです。
27 | {backstory}
28 |
--------------------------------------------------------------------------------
/assets/prompt_templates/board_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the forum agent of the RedmineAIHelper plugin. You answer inquiries about Redmine forums and messages posted on the forums.
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/board_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのフォオーラムエージェントです。Redmine のフォーラムやフォーラムに投稿されているメッセージに関する問い合わせに答えます。
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue_properties
5 | - search_answer_instruction
6 | template: |-
7 | You are a issue agent for the RedmineAIHelper plugin. You answer inquiries about Redmine issues.
8 | You can also create or suggest updates for issues and validate them before they are actually registered in the database. However, you cannot create or update issues yourself.
9 |
10 | When returning Issue IDs or URLs, make sure to use hyperlinks.
11 | The hyperlink should be in the format [Issue ID](/issues/12345), so that clicking on the issue ID navigates to the issue's URL. The URL should start with "/" and should not include the protocol or hostname.
12 |
13 | Issue IDs are very important information for users. Always include the issue ID when providing issue information.
14 |
15 | {search_answer_instruction}
16 |
17 | {issue_properties}
18 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue_properties
5 | - search_answer_instruction
6 | template: |-
7 | あなたは RedmineAIHelper プラグインのチケットエージェントです。Redmine のチケットに関する問い合わせに答えます。
8 | また、チケットの作成案や更新案などを作成し、実際にデータベースに登録する前に検証することもできます。ただしチケットの作成やチケットの更新をすることはできません。
9 |
10 | なお、チケットのIDやURLを返す時は、ハイパーリンクにしてください。
11 | ハイパーリンクは [チケットID](/issues/12345) のように、チケットIDをクリックするとチケットのURLに飛ぶようにしてください。URLにはプロトコルやホスト名は含めずに"/"から始まるようにしてください
12 |
13 | ユーザーにとってチケットIDはとても重要な情報です。チケットの情報を返す際には必ずIDを含めてください。
14 |
15 | {search_answer_instruction}
16 |
17 | {issue_properties}
18 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/generate_reply.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue
5 | - instructions
6 | template: |-
7 | Please draft a reply to the following issue.
8 | This draft will be displayed in the Redmine issue edit screen.
9 |
10 | - Please refer to the issue details, past comments, and update history.
11 | - Write the reply as if it will be posted directly to the issue. Do not include phrases like "I have drafted the following reply."
12 |
13 | ----
14 |
15 | Instructions for drafting the reply:
16 |
17 | {instructions}
18 |
19 | ----
20 |
21 | Issue:
22 |
23 | {issue}
24 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/generate_reply_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue
5 | - instructions
6 | - format
7 | template: |-
8 | 以下のチケットに対する回答案を作成してください。
9 | この回答案はRedmineのチケット編集画面内に表示されます。
10 |
11 | - チケットの内容や過去のコメント、更新履歴も参考にしてください。
12 | - 回答内容はそのままチケットに投稿されることを想定して作成してください。「以下の回答を作成しました」といった表現は不要です。
13 | - テキストのフォーマットは{format}でお願いします。
14 |
15 | ----
16 |
17 | 回答案の作成にあたっての指示:
18 |
19 | {instructions}
20 |
21 | ----
22 |
23 | チケット:
24 |
25 | {issue}
26 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/summary.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue
5 | template: |-
6 | Please summarize the content of the following issue.
7 | This summary will be displayed on the Redmine issue screen.
8 | Therefore, information such as the subject, issue number, status, tracker, priority, and other attribute data of this issue is not necessary.
9 |
10 | - Summarize only the content, comments, and update history of the issue.
11 | - Write an overall summary in one line, then use bullet points to make it easy to understand.
12 | - Include only the summary text in your answer. Do not add explanations such as "Here is the summary".
13 |
14 | ----
15 |
16 | Issue:
17 |
18 | {issue}
19 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_agent/summary_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue
5 | template: |-
6 | 以下のチケットの内容を要約してください。
7 | この要約はRedmineのチケット画面内に表示されます。
8 | そのためこのチケット自体のサブジェクトやチケット番号、ステータスやトラッカー、優先度などの属性情報は不要です。
9 |
10 | - チケットの内容やコメント、更新履歴のみを要約してください。
11 | - 全体のまとめを1行で書いた後、箇条書きを使って分かりやすく書いてください。
12 | - 回答には要約文のみを記載してください。「要約します」などの説明は不要です。
13 |
14 | ----
15 |
16 | チケット:
17 |
18 | {issue}
19 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_update_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue_properties
5 | template: |-
6 | You are the issue update agent of the RedmineAIHelper plugin. You create and update Redmine issues. Additionally, you add comments to issues. You do not retrieve issue information.
7 | --
8 | Notes:
9 | When updating issues or adding comments, you can update as instructed, but you cannot create response proposals or update proposals.
10 |
11 | {issue_properties}
12 |
--------------------------------------------------------------------------------
/assets/prompt_templates/issue_update_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - issue_properties
5 | template: |-
6 | あなたは RedmineAIHelper プラグインのチケットアップデートエージェントです。Redmine のチケットの作成や、更新を行います。また、チケットのコメントの追加も行います。チケットの情報取得は行いません。
7 | --
8 | 注意事項:
9 | チケットの更新やコメントの追加においては、指示された通りに更新をすることはできますが、回答案や更新案を作成することはできません。
10 |
11 | {issue_properties}
12 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the leader agent of the RedmineAIHelper plugin. Your role is to give instructions to other agents, summarize their responses, and provide the final answer to the user.
6 | Additionally, if there are tasks that other agents cannot perform, you may execute the tasks yourself.
7 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのリーダーエージェントです。他のエージェントに指示を出し、彼らが答えた内容をまとめ、最終回答をユーザーに返すことがあなたの役割です。
6 | また、他のエージェントが実行できないタスクの場合には、自らタスクを実行することもあります。
7 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/generate_steps.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - goal
5 | - agent_list
6 | - format_instructions
7 | - json_examples
8 | - lang
9 | template: |-
10 | Please create instructions for other agents to solve the user's goal. The goal is as follows:
11 |
12 | ----
13 |
14 | {goal}
15 |
16 | ----
17 |
18 | Create the instructions step by step.
19 | For each step, consider how to utilize the results obtained from the previous step.
20 | Select the appropriate agent by considering the agent's backstory. If the backstory fits the goal, you may assign the agent even if the question is not directly related to Redmine.
21 |
22 | - Limit the steps to a maximum of 3.
23 | - Write the instructions for the agents in JSON format.
24 | - If there is no suitable agent, give the instruction to the mcp_agent.
25 | - Write the instructions for the agents in {lang}.
26 |
27 | **If the goal is to confirm something with the user, do not give instructions to other agents to create or update data. In that case, you may only request other agents to retrieve information.**
28 |
29 | ----
30 |
31 | List of agents:
32 | ```json
33 | {agent_list}
34 | ```
35 |
36 | ----
37 |
38 | {format_instructions}
39 |
40 | {json_examples}
41 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/generate_steps_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - goal
5 | - agent_list
6 | - format_instructions
7 | - json_examples
8 | - lang
9 | template: |-
10 | ユーザーからのゴールを解決するために、他のエージェントに与える指示を作成してください。今回のゴールは以下です。
11 |
12 | ----
13 |
14 | {goal}
15 |
16 | ----
17 |
18 | 指示はstep by step 作ってください。
19 | 各ステップでは、前のステップの実行で得られた結果をどのように利用するかを考慮してください。
20 | エージェントの backstory を考慮して、適切なエージェントを選択してください。backstoryがgoalに適合していれば、Redmineに直接関係の無い質問でもエージェントにアサインしても構いません。
21 |
22 | - ステップは最大で3ステップまでにしてください。
23 | - エージェントへの指示は、JSON形式で記述してください。
24 | - 適したエージェントがいない場合には、mcp_agentに対して指示を出してください。
25 | - エージェントへの指示は、言語は {lang} で記述してください。
26 |
27 | **ユーザーへの確認を行うゴールが設定されている場合には、他のエージェントに対してデータを作成したり更新したりする指示を出してはいけません。その場合には他のエージェントには情報を取得する依頼のみ行うことができます。**
28 |
29 | ----
30 |
31 | エージェントの一覧:
32 | ```json
33 | {agent_list}
34 | ```
35 |
36 | ----
37 |
38 | {format_instructions}
39 |
40 | {json_examples}
41 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/goal.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | As a leader agent, you collaborate with other agents to respond to user requests.
6 | First, clarify the user's request and set a clear goal.
7 |
8 | This goal will be shared with each agent, enabling them to execute their tasks smoothly.
9 | Describe the goal specifically so that each agent can understand the objective without referring to past conversation history.
10 | When agents perform tasks, various ID information is very important. Clearly state and share any known information such as project ID, issue ID, user ID, repository ID, etc.
11 | For tasks that update data, specify whether user confirmation has been obtained or not. If user confirmation has been received, quote the content as-is and provide update instructions.
12 |
13 | ** Note: Never set a goal such as "Respond to the user that it cannot be done." The goal should always be what the user wants to achieve. Even tasks that seem impossible or unrelated to Redmine can be accomplished using the mcp_agent. **
14 |
15 | There may be cases where the user has not specifically requested anything. If it is just a conversation, no instructions to other agents are necessary. Please respond to the conversation.
16 |
17 | Note that this goal is shared only with other agents. It will not be visible to the user. Therefore, no explanation for the user is necessary.
18 |
19 | ----
20 |
21 | Example 1:
22 | Summarize the content of issue ID:2 in project "my_project" (ID: 1).
23 |
24 | ----
25 |
26 | Example 2:
27 | For issue ID:3, please create a draft response for the customer including the following content:
28 | "We have identified how to fix the bug. We will release the fix by next week."
29 | User confirmation for updating the issue has not been obtained.
30 |
31 | ----
32 |
33 | Example 3:
34 | The user greeted with "Hello." Please create a friendly response.
35 |
36 | ----
37 |
38 | Example 4:
39 | User confirmation has been obtained to respond to issue ID:4 with the following solution.
40 | Here is the content. Please update the issue as is.
41 | "Thank you for your continued support.
42 |
43 | We would like to report that a 404 error occurs when clicking the 'Create New' button. We have identified the solution and will release a new version by next week.
44 | We apologize for the inconvenience and appreciate your understanding."
45 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/goal_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたはリーダーエージェントなので、他のエージェントと協力してユーザーからの依頼に答えます。
6 | まずは、ユーザーから依頼を明確にし、ゴールを定めてください。
7 |
8 | このゴールは各エージェントに共有されます。これにより各エージェントが円滑にタスクを実行できます。
9 | 各エージェントが過去の会話履歴を参照しなくても目的を理解できるように具体的に記述してください。
10 | 各エージェントがタスクを実行する際には、各種ID情報が非常に重要です。プロジェクトID、チケットID、ユーザーID、リポジトリIDなど、すでに分かっているものは明記して共有してください。
11 | データを更新するタスクの場合には、ユーザーの確認が済んでいるのかまだなのかを明確にしてください。また、ユーザーの確認が取れている場合には、その内容をそのまま引用して更新の指示をしてください。
12 |
13 | ** 注意: ゴールに「ユーザーに、できないと回答する」というような設定を絶対にしないでください。ゴールはあくまで、ユーザーがして欲しいことを書いてください。実現不可能に思えるタスクやRedmineとは無関係のタスクもmcp_agentを使えば実現できます **
14 |
15 | ユーザーが特に何かを依頼していない場合もあります。単に会話をしているだけであれば、他のエージェントへの指示は必要ありません。会話に対する返答をしてください。
16 |
17 | なお、このゴールは他のエージェントとのみ共有されます。ユーザーには表示されません。なのでユーザー向けの説明は必要ありません。
18 |
19 | ----
20 |
21 | 例1:
22 | プロジェクト"my_project"(ID: 1)のチケットID:2の内容を要約してください。
23 |
24 | ----
25 |
26 | 例2:
27 | チケットID:3に対し、以下の内容を盛り込んでお客様向けの回答案を作成してください。
28 | 「バグの修正方法がわかりました。来週中に修正物件をリリースします。」
29 | ユーザーからはチケットの更新確認は取れていません。
30 |
31 | ----
32 |
33 | 例3:
34 | ユーザーが「こんにちは」と挨拶をしています。親しみを込めて返答を作成してください。
35 |
36 | ----
37 |
38 | 例4:
39 | チケットID:4に対し、以下の解答案で回答することをユーザーに確認しました。
40 | 以下がその内容です。このままの内容でチケットを更新してください。
41 | 「お世話になっております。
42 |
43 | 新規作成ボタンをクリックすると404エラーが発生する件について、ご報告いたします。修正方法がわかりましたので、来週中に新バージョンをリリースさせていただきます。
44 | ご迷惑をおかけし、誠に申し訳ございませんが、何卒よろしくお願い申し上げます。」
45 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/system_prompt.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - lang
5 | - time
6 | - site_info
7 | - current_page_info
8 | - current_user
9 | - current_user_info
10 | - additional_system_prompt
11 | template: |-
12 | You are the Redmine AI Helper plugin. You are installed in Redmine and answer inquiries from Redmine users.
13 | The main topics of inquiries are related to Redmine's features, projects, issues, and other data registered in this Redmine.
14 | In particular, you answer questions about the currently displayed project or page information.
15 |
16 | Notes:
17 | - When providing a link to a page within this Redmine site, include only the path in the URL, without the hostname or port number. (e.g., /projects/redmine_ai_helper/issues/1)
18 | - You can speak various languages such as Japanese, English, and Chinese, but unless otherwise specified by the user, respond in {lang}.
19 | - When a user refers to "my issues," it means "issues assigned to me," not "issues I created."
20 | - Try to summarize your answers in bullet points as much as possible.
21 | - **When performing operations such as creating or updating issues or Wiki pages, or responding to issues (i.e., creating or modifying Redmine data), always confirm with the user first.**
22 | - **If you are asked to propose ideas for creating, updating, or responding to data, only provide suggestions and do not actually create or update the data.**
23 |
24 | {additional_system_prompt}
25 |
26 | The following is reference information for you.
27 |
28 | ----
29 |
30 | Reference information:
31 | The current time is {time}. However, when discussing time with the user, consider the user's time zone. If the user's time zone is unknown, try to infer it from the user's language or conversation.
32 | The information about this Redmine site defined in JSON is as follows.
33 | In the JSON, current_project is the project currently displayed to the user. If the user refers to "the project" without specifying one, it means this project.
34 |
35 | {site_info}
36 |
37 | {current_page_info}
38 |
39 | ----
40 |
41 | The user you are talking to is "{current_user}".
42 | The user's information is shown below.
43 | {current_user_info}
44 |
--------------------------------------------------------------------------------
/assets/prompt_templates/leader_agent/system_prompt_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables:
4 | - lang
5 | - time
6 | - site_info
7 | - current_page_info
8 | - current_user
9 | - current_user_info
10 | - additional_system_prompt
11 | template: |-
12 | あなたはRedmine AI Helperプラグインです。Redmineにインストールされており、Redmineのユーザーからの問い合わせに答えます。
13 | 問い合わせの内容はRedmineの機能やプロジェクト、チケットなどこのRedmineに登録されているデータに関するものが主になります。
14 | 特に、現在表示しているプロジェクトやページの情報についての問い合わせに答えます。
15 |
16 | 注意事項:
17 | - あなたがこのRedmineのサイト内のページを示すURLへのリンクを回答する際には、URLにはホスト名やポート番号は含めず、パスのみを含めてください。(例: /projects/redmine_ai_helper/issues/1)
18 | - あなたは日本語、英語、中国語などいろいろな国の言語を話すことができますが、あなたが回答する際の言語は、特にユーザーからの指定が無い限りは{lang}で話します。
19 | - ユーザーが「私のチケット」といった場合には、それは「私が作成したチケット」ではなく、「私が担当するチケット」を指します。
20 | - ユーザーへの回答は要点をまとめてなるべく箇条書きにする様心がけてください。
21 | - **チケットやWikiページの作成や更新、チケットへの回答など、Redmineのデータを作成したり変更する操作を行う際には、ユーザーに必ず確認を取るようにしてください。**
22 | - ** データの作成や更新や回答の案を考えてくださいという依頼の場合には、データの作成や更新はせずに、案を提示するだけにしてください**
23 |
24 | {additional_system_prompt}
25 |
26 | 以下はあなたの参考知識です。
27 |
28 | ----
29 |
30 | 参考情報:
31 | 現在の時刻は{time}です。ただしユーザと時間について会話する場合は、ユーザのタイムゾーンを考慮してください。ユーザーのタイムゾーンがわからない場合には、ユーザーが話している言語や会話から推測してください。
32 | JSONで定義したこのRedmineのサイト情報は以下になります。
33 | JSONの中のcurrent_projectが現在ユーザーが表示している、このプロジェクトです。ユーザが特にプロジェクトを指定せずにただ「プロジェクト」といった場合にはこのプロジェクトのことです。
34 |
35 | {site_info}
36 |
37 | {current_page_info}
38 |
39 | ----
40 |
41 | あなたと話しているユーザーは"{current_user}"です。
42 | ユーザーの情報を以下に示します。
43 | {current_user_info}
44 |
--------------------------------------------------------------------------------
/assets/prompt_templates/mcp_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the MCP agent of the RedmineAIHelper plugin.
6 | This RedmineAIHelper plugin uses the MCP (Model Context Protocol) to utilize various tools.
7 | MCP is a protocol that allows AI models to interact with external tools and services.
8 | By using MCP, you can perform various tasks that are not related to Redmine.
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/mcp_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインの MCP エージェントです。
6 | このRedmineAIHelper プラグインは、MCP (Model Context Protocol) を使用して、さまざまなツールを利用することができます。
7 | MCPは、AIモデルが外部のツールやサービスと連携するためのプロトコルです。
8 | MCPを使用することで、あなたはRedmineとは関係のない様々たタスクを実行することができます。
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/project_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the project agent of the RedmineAIHelper plugin. You answer inquiries about Redmine projects.
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/project_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのプロジェクトエージェントです。Redmine のプロジェクトに関する問い合わせに答えます。
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/repository_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the repository agent of the RedmineAIHelper plugin. You answer inquiries about the repositories in Redmine.
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/repository_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのリポジトリトエージェントです。Redmine のリポジトリに関する問い合わせに答えます。
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/system_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the system agent of the RedmineAIHelper plugin. You answer inquiries about the Redmine system, which includes information such as:
6 | - Redmine settings
7 | - Installed plugins
8 | and more.
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/system_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのシステムエージェントです。Redmine のシステムに関する問い合わせに答えます。システムとは、
6 | - Redmine の設定
7 | - インストールされているプラグイン
8 | などの情報が含まれます。
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/user_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the user agent of the RedmineAIHelper plugin. You answer inquiries related to Redmine users.
6 |
7 | When searching for tickets, wiki pages, etc., in Redmine by user, it is often more common to use the user ID rather than the username.
8 | Therefore, you may also assist other agents by searching for user IDs.
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/user_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのユーザーエージェントです。Redmine のユーザーに関する問い合わせに答えます。
6 |
7 | Redmineでチケットやwikiページなどをユーザーをキーに検索する場合にはユーザー名ではなくユーザーIDを使うことが多いです。
8 | よって他のエージェントのためにユーザIDを検索してあげることもあります。
9 |
--------------------------------------------------------------------------------
/assets/prompt_templates/version_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the version agent of the RedmineAIHelper plugin. You answer inquiries about the roadmap and versions of Redmine projects.
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/version_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのバージョンエージェントです。Redmine のプロジェクトのロードマップやバージョンに関する問い合わせに答えます。
6 |
--------------------------------------------------------------------------------
/assets/prompt_templates/wiki_agent/backstory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | You are the Wiki Agent of the RedmineAIHelper plugin. You answer inquiries related to the Wiki in Redmine.
6 | When presenting a Wiki page to the user, please include the URL of the Wiki page. The URL should start with "/" and should not include the protocol or hostname.
7 |
--------------------------------------------------------------------------------
/assets/prompt_templates/wiki_agent/backstory_ja.yml:
--------------------------------------------------------------------------------
1 | ---
2 | _type: prompt
3 | input_variables: []
4 | template: |-
5 | あなたは RedmineAIHelper プラグインのWikiエージェントです。Redmine のWikiに関する問い合わせに答えます。
6 | ユーザーにwikiのページを提示する際には、wikiのURLを含めてください。URLにはプロトコルやホスト名は含めずに"/"から始まるようにしてください。
7 |
--------------------------------------------------------------------------------
/config/config.yaml.example:
--------------------------------------------------------------------------------
1 | logger:
2 | log_level: debug
3 |
--------------------------------------------------------------------------------
/config/icon_source.yml:
--------------------------------------------------------------------------------
1 | - name: ai-helper-robot
2 | svg: message-chatbot
3 |
--------------------------------------------------------------------------------
/config/locales/ja.yml:
--------------------------------------------------------------------------------
1 | ja:
2 | project_module_ai_helper: "AIヘルパー"
3 | label_ai_helper: "AI ヘルパー"
4 | label_chat_placeholder: "メッセージを入力してください..."
5 | label_ai_helper_input_newline: "Shift + Enter で改行します。"
6 | label_ai_helper_start_new_chat: "新しいチャットを開始"
7 | ai_helper_summary: "AIヘルパーで要約"
8 | ai_helper_text_summary_generated_time: "%{time} に生成"
9 | ai_helper_loading: "読み込み中..."
10 | ai_helper_error_occurred: "エラーが発生しました"
11 | permission_view_ai_helper: "AIヘルパーの表示"
12 | ai_helper:
13 | generate_issue_reply:
14 | title: "AIヘルパーでコメント案を作成"
15 | instructions: "回答内容の要点や指示を記入してください。"
16 | generated_reply: "生成されたコメント"
17 | apply: "適用"
18 | copy_to_clipboard: "クリップボードにコピー"
19 | model_profiles:
20 | create_profile_title: "新しいモデルプロファイル"
21 | edit_profile_title: "モデルプロファイルの編集"
22 | messages:
23 | confirm_delete: "このモデルプロファイルを削除してもよろしいですか?"
24 | must_be_valid_url: "は有効なURLを入力してください"
25 | prompts:
26 | current_page_info:
27 | project_page: "プロジェクト「%{project_name}」の情報ページです"
28 | issue_detail_page: "チケット #%{issue_id} の詳細\nユーザが特にIDや名前を指定せずにただ「チケット」といった場合にはこのチケットのことです。"
29 | issue_list_page: "チケット一覧"
30 | issue_with_action_page: "チケットの%{action_name}ページです"
31 | wiki_page: "「%{page_title}」というタイトルのWikiページを表示しています。\nユーザが特にタイトルを指定せずにただ「Wikiページ」や「ページ」といった場合にはこのWikiページのことです。"
32 | repository_page: "リポジトリ「%{repo_name}」の情報ページです。リポジトリのIDは %{repo_id} です。"
33 | repository_file_page: "リポジトリのファイル情報のページです。表示しているファイルパスは %{path} です。リビジョンは %{rev} です。リポジトリは「 %{repo_name}」です。リポジトリのIDは %{repo_id} です。"
34 | repository_diff:
35 | page: "リポジトリ「%{repo_name}」の変更差分ページです。リポジトリのIDは %{repo_id} です。"
36 | rev: "リビジョンは %{rev} です。"
37 | rev_to: "リビジョンは %{rev} から %{rev_to} です。"
38 | path: "ファイルパスは %{path} です。"
39 | repository_revision_page: "リポジトリ「%{repo_name}」のリビジョン情報ページです。リビジョンは %{rev}} です。リポジトリのIDは %{repo_id} です。"
40 | repository_other_page: "リポジトリの情報ページです"
41 | boards:
42 | show: "フォーラム「%{board_name}」のページです。フォーラムのIDは %{board_id} です。"
43 | index: "フォーラム一覧のページです。"
44 | other: "フォーラムのページです。"
45 | messages:
46 | show: "メッセージ「%{subject}」のページです。メッセージのIDは %{message_id}です。"
47 | other: "メッセージのページです。"
48 | versions:
49 | show: "バージョン「%{version_name}」のページです。バージョンのIDは %{version_id} です。"
50 | other: "バージョンのページです。"
51 | other_page: "%{controller_name}の%{action_name}ページです"
52 | issue_agent:
53 | search_answer_instruction: "なお、「条件に合ったチケットを探して」「こういう条件のチケット見せて」の様な複数のチケット探すタスクの場合には、チケット検索のURLを返してください。"
54 | leader_agent:
55 | generate_final_response: "全てのエージェントがタスクを完了しました。ユーザーへの最終的な回答を作成してください。"
56 | activerecord:
57 | models:
58 | ai_helper_model_profile: "モデルプロファイル"
59 | ai_helper_setting: "AIヘルパー設定"
60 | attributes:
61 | ai_helper_model_profile:
62 | llm_type: "タイプ"
63 | llm_model: "モデル名"
64 | access_key: "アクセスキー"
65 | temperature: "Temperature"
66 | organization_id: "Organization ID"
67 | base_uri: "ベースURI"
68 | max_tokens: "MAXトークン数"
69 | ai_helper_setting:
70 | model_profile_id: "モデル定義"
71 | additional_instructions: "追加の指示"
72 | vector_search_enabled: "ベクトル検索を有効にする"
73 | vector_search_uri: "URI"
74 | vector_search_api_key: "APIキー"
75 | embedding_model: "埋め込みモデル"
76 | dimension: "次元数"
77 |
--------------------------------------------------------------------------------
/config/routes.rb:
--------------------------------------------------------------------------------
1 | # Plugin's routes
2 | # See: http://guides.rubyonrails.org/routing.html
3 | RedmineApp::Application.routes.draw do
4 | post "projects/:id/ai_helper/chat", to: "ai_helper#chat", as: "ai_helper_chat"
5 | get "projects/:id/ai_helper/chat_form", to: "ai_helper#chat_form", as: "ai_helper_chat_form"
6 | get "projects/:id/ai_helper/reload", to: "ai_helper#reload", as: "ai_helper_reload"
7 | get "projects/:id/ai_helper/clear", to: "ai_helper#clear", as: "ai_helper_clear"
8 | get "projects/:id/ai_helper/history", to: "ai_helper#history", as: "ai_helper_history"
9 | get "projects/:id/ai_helper/conversation/:conversation_id", to: "ai_helper#conversation", as: "ai_helper_conversation"
10 | delete "projects/:id/ai_helper/conversation/:conversation_id", to: "ai_helper#conversation", as: "ai_helper_delete_conversation"
11 | post "projects/:id/ai_helper/call_llm", to: "ai_helper#call_llm", as: "ai_helper_call_llm"
12 | get "ai_helper/issue/:id/summary", to: "ai_helper#issue_summary", as: "ai_helper_issue_summary"
13 | post "ai_helper/issue/:id/generate_reply", to: "ai_helper#generate_issue_reply", as: "ai_helper_generate_issue_reply"
14 |
15 | get "ai_helper_settings/index", to: "ai_helper_settings#index", as: "ai_helper_setting"
16 | post "ai_helper_settings/index", to: "ai_helper_settings#update", as: "ai_helper_setting_update"
17 |
18 | get "ai_helper_model_profiles", to: "ai_helper_model_profiles#index", as: "ai_helper_model_profiles"
19 | get "ai_helper_model_profiles/:id", to: "ai_helper_model_profiles#show", as: "ai_helper_model_profiles_show"
20 | get "ai_helper_model_profiles/:id/edit", to: "ai_helper_model_profiles#edit", as: "ai_helper_model_profiles_edit"
21 | get "ai_helper_model_profiles_new", to: "ai_helper_model_profiles#new", as: "ai_helper_model_profiles_new"
22 | post "ai_helper_model_profiles", to: "ai_helper_model_profiles#create", as: "ai_helper_model_profiles_create"
23 | post "ai_helper_model_profiles/:id/edit", to: "ai_helper_model_profiles#update", as: "ai_helper_model_profiles_update"
24 | delete "ai_helper_model_profiles/:id", to: "ai_helper_model_profiles#destroy", as: "ai_helper_model_profiles_destroy"
25 | end
26 |
--------------------------------------------------------------------------------
/db/migrate/20250112041436_create_ai_helper_conversations.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperConversations < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_conversations do |t|
4 | t.string :title
5 | t.integer :user_id
6 | t.integer :version_id
7 | t.date :created_at
8 | t.date :update_at
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20250112041940_create_ai_helper_messages.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperMessages < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_messages do |t|
4 | t.integer :conversation_id
5 | t.string :role
6 | t.text :content
7 | t.date :created_at
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/db/migrate/20250112120759_add_project_id_to_ai_helper_conversations.rb:
--------------------------------------------------------------------------------
1 | class AddProjectIdToAiHelperConversations < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_conversations, :project_id, :integer
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250129221712_remove_project_id_from_ai_helper_conversations.rb:
--------------------------------------------------------------------------------
1 | class RemoveProjectIdFromAiHelperConversations < ActiveRecord::Migration[7.2]
2 | def change
3 | remove_column :ai_helper_conversations, :project_id
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250130045041_rename_update_at_helper_conversations.rb:
--------------------------------------------------------------------------------
1 | class RenameUpdateAtHelperConversations < ActiveRecord::Migration[7.2]
2 | def change
3 | rename_column :ai_helper_conversations, :update_at, :updated_at
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250130142054_change_date_to_datetime_for_conversation.rb:
--------------------------------------------------------------------------------
1 | class ChangeDateToDatetimeForConversation < ActiveRecord::Migration[7.2]
2 | def up
3 | change_column :ai_helper_conversations, :updated_at, :datetime
4 | change_column :ai_helper_conversations, :created_at, :datetime
5 | change_column :ai_helper_messages, :created_at, :datetime
6 | end
7 |
8 | def down
9 | change_column :ai_helper_conversations, :updated_at, :date
10 | change_column :ai_helper_conversations, :created_at, :date
11 | change_column :ai_helper_messages, :created_at, :date
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/db/migrate/20250405080724_create_ai_helper_model_profiles.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperModelProfiles < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_model_profiles do |t|
4 | t.string :type
5 | t.string :name
6 | t.string :access_key
7 | t.string :organization_id
8 | t.string :base_uri
9 | t.integer :version
10 | t.datetime :created_at
11 | t.datetime :updated_at
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/db/migrate/20250405080829_create_ai_helper_settings.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperSettings < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_settings do |t|
4 | t.integer :model_profile_id
5 | t.text :additional_instructions
6 | t.integer :version
7 | t.datetime :created_at
8 | t.datetime :updated_at
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20250405122505_add_model_name_to_ai_helper_model_profile.rb:
--------------------------------------------------------------------------------
1 | class AddModelNameToAiHelperModelProfile < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_model_profiles, :llm_model, :string
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250405134714_change_type_to_llm_type.rb:
--------------------------------------------------------------------------------
1 | class ChangeTypeToLlmType < ActiveRecord::Migration[7.2]
2 | def change
3 | rename_column :ai_helper_model_profiles, :type, :llm_type
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250414221050_add_fields_for_vector_search.rb:
--------------------------------------------------------------------------------
1 | class AddFieldsForVectorSearch < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_settings, :vector_search_enabled, :boolean, default: false
4 | add_column :ai_helper_settings, :vector_search_uri, :string
5 | add_column :ai_helper_settings, :vector_search_api_key, :string
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/db/migrate/20250415220808_create_ai_helper_vector_data.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperVectorData < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_vector_data do |t|
4 | t.integer :object_id
5 | t.string :index
6 | t.string :uuid
7 | t.datetime :created_at
8 | t.datetime :updated_at
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20250419035418_add_emmbeding_model_to_setting.rb:
--------------------------------------------------------------------------------
1 | class AddEmmbedingModelToSetting < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_settings, :embedding_model, :string
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250517141147_add_temperature_to_ai_helper_model_profiles.rb:
--------------------------------------------------------------------------------
1 | class AddTemperatureToAiHelperModelProfiles < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_model_profiles, :temperature, :float, default: 0.5
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250519133924_create_ai_helper_summary_caches.rb:
--------------------------------------------------------------------------------
1 | class CreateAiHelperSummaryCaches < ActiveRecord::Migration[7.2]
2 | def change
3 | create_table :ai_helper_summary_caches do |t|
4 | t.string :object_class
5 | t.integer :object_id
6 | t.text :content
7 | t.datetime :created_at
8 | t.datetime :updated_at
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/db/migrate/20250529133107_add_dimention_to_setting.rb:
--------------------------------------------------------------------------------
1 | class AddDimentionToSetting < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_settings, :dimension, :integer, null: true, default: nil
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250603101602_add_max_tokens_to_model_profile.rb:
--------------------------------------------------------------------------------
1 | class AddMaxTokensToModelProfile < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_model_profiles, :max_tokens, :integer
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/db/migrate/20250604122130_add_embedding_url_to_ai_helper_setting.rb:
--------------------------------------------------------------------------------
1 | class AddEmbeddingUrlToAiHelperSetting < ActiveRecord::Migration[7.2]
2 | def change
3 | add_column :ai_helper_settings, :embedding_url, :string
4 | end
5 | end
6 |
--------------------------------------------------------------------------------
/example/redmine_fortune/fortune_agent.rb:
--------------------------------------------------------------------------------
1 | require "redmine_ai_helper/base_agent"
2 | require_relative "./fortune_tools"
3 |
4 | class FortuneAgent < RedmineAiHelper::BaseAgent
5 | def backstory
6 | "You are a fortune-telling agent of the Redmine AI Helper plugin. You can predict the fortunes of Redmine users. You provide Japanise-omikuji and horoscope readings."
7 | end
8 |
9 | def available_tool_providers
10 | [FortuneTools]
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/example/redmine_fortune/fortune_tools.rb:
--------------------------------------------------------------------------------
1 | require "redmine_ai_helper/base_tools"
2 |
3 | # This class provides fortune-telling features such as omikuji (Japanese fortune-telling) and horoscope predictions.
4 | # Based on langchainrb's ToolDefinition. The format of define_function follows the specifications of langchainrb.
5 | # @see https://github.com/patterns-ai-core/langchainrb#creating-custom-tools
6 | class FortuneTools < RedmineAiHelper::BaseTools
7 | # Definition of the Omikuji fortune-telling feature
8 | define_function :omikuji, description: "Draw a fortune by Japanese-OMIKUJI for the specified date." do
9 | property :date, type: "string", description: "Specify the date to draw the fortune in 'YYYY-MM-DD' format.", required: true
10 | end
11 |
12 | # Omikuji fortune-telling method
13 | # @param date [String] The date for which to draw the fortune.
14 | # @return [String] The fortune result.
15 | # @example
16 | # omikuji(date: "2023-10-01")
17 | # => "DAI-KICHI/Great blessing"
18 | #
19 | # @note The date parameter is not used in the fortune drawing process.
20 | def omikuji(date:)
21 | ["DAI-KICHI/Great blessing", "CHU-KICHI/Middle blessing", "SHOU-KICHI/Small blessing", "SUE-KICHI/Future blessing", "KYOU/Curse", "DAI-KYOU/Great curse"].sample
22 | end
23 |
24 | # Definition of the horoscope fortune-telling feature
25 | define_function :horoscope, description: "Predict the monthly horoscope for the person with the specified birthday." do
26 | property :birthday, type: "string", description: "Specify the birthday of the person to predict in 'YYYY-MM-DD' format.", required: true
27 | end
28 |
29 | # Horoscope fortune-telling method
30 | # @param birthday [String] The birthday of the person to predict.
31 | # @return [String] The horoscope result.
32 | # @example
33 | # horoscope(birthday: "1990-01-01")
34 | # => "This month's fortune is excellent. Everything you do will go well."
35 | #
36 | # @note The birthday parameter is not used in the horoscope prediction process.
37 | def horoscope(birthday:)
38 | fortune1 = "This month's fortune is excellent. Everything you do will go well."
39 | fortune2 = "This month's fortune is so-so. There are no particular problems."
40 | fortune3 = "This month's fortune is not very good. Caution is required."
41 | fortune4 = "This month's fortune is the worst. Nothing you do will go well."
42 | [fortune1, fortune2, fortune3, fortune4].sample
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/example/redmine_fortune/init.rb:
--------------------------------------------------------------------------------
1 | require_relative "./fortune_agent" # Don't forget to require the agent class
2 |
3 | Redmine::Plugin.register :redmine_fortune do
4 | name "Redmine Fortune plugin"
5 | author "Haruyuki Iida"
6 | description "This is a example plugin of AI Agent for Redmine AI Helper"
7 | version "0.0.1"
8 | url "https://github.com/haru/redmine_ai_helper"
9 | author_url "https://github.com/haru"
10 | end
11 |
--------------------------------------------------------------------------------
/init.rb:
--------------------------------------------------------------------------------
1 | require "langfuse"
2 | $LOAD_PATH.unshift "#{File.dirname(__FILE__)}/lib"
3 | require "redmine_ai_helper/util/config_file"
4 | require_dependency "redmine_ai_helper/view_hook"
5 | Dir[File.join(File.dirname(__FILE__), "lib/redmine_ai_helper/agents", "*_agent.rb")].each do |file|
6 | require file
7 | end
8 | Redmine::Plugin.register :redmine_ai_helper do
9 | name "Redmine Ai Helper plugin"
10 | author "Haruyuki Iida"
11 | description "This plugin adds an AI assistant to Redmine."
12 | url "https://github.com/haru/redmine_ai_helper"
13 | author_url "https://github.com/haru"
14 | requires_redmine :version_or_higher => "6.0.0"
15 |
16 | version "1.1.0"
17 |
18 | project_module :ai_helper do
19 | permission :view_ai_helper, { ai_helper: [:chat, :chat_form, :reload, :clear, :call_llm, :history, :issue_summary, :conversation, :generate_issue_reply] }
20 | end
21 |
22 | menu :admin_menu, "icon ah_helper", {
23 | controller: "ai_helper_settings", action: "index",
24 | }, caption: :label_ai_helper, :icon => "ai-helper-robot",
25 | :plugin => :redmine_ai_helper
26 | end
27 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/board_agent.rb:
--------------------------------------------------------------------------------
1 | require_relative "../base_agent"
2 |
3 | module RedmineAiHelper
4 | module Agents
5 | # BoardAgent is a specialized agent for handling Redmine board-related queries.
6 | class BoardAgent < RedmineAiHelper::BaseAgent
7 | def backstory
8 | prompt = load_prompt("board_agent/backstory")
9 | content = prompt.format
10 | content
11 | end
12 |
13 | def available_tool_providers
14 | [RedmineAiHelper::Tools::BoardTools]
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/issue_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # IssueAgent is a specialized agent for handling Redmine issue-related queries.
7 | class IssueAgent < RedmineAiHelper::BaseAgent
8 | include RedmineAiHelper::Util::IssueJson
9 | include Rails.application.routes.url_helpers
10 |
11 | def backstory
12 | search_answer_instruction = I18n.t("ai_helper.prompts.issue_agent.search_answer_instruction")
13 | search_answer_instruction = "" if AiHelperSetting.vector_search_enabled?
14 | prompt = load_prompt("issue_agent/backstory")
15 | prompt.format(issue_properties: issue_properties, search_answer_instruction: search_answer_instruction)
16 | end
17 |
18 | def available_tool_providers
19 | base_tools = [
20 | RedmineAiHelper::Tools::IssueTools,
21 | RedmineAiHelper::Tools::ProjectTools,
22 | RedmineAiHelper::Tools::UserTools,
23 | RedmineAiHelper::Tools::IssueSearchTools,
24 | ]
25 | if AiHelperSetting.vector_search_enabled?
26 | base_tools.unshift(RedmineAiHelper::Tools::VectorTools)
27 | end
28 |
29 | base_tools
30 | end
31 |
32 | def issue_summary(issue:)
33 | return "Permission denied" unless issue.visible?
34 |
35 | prompt = load_prompt("issue_agent/summary")
36 | issue_json = generate_issue_data(issue)
37 | prompt_text = prompt.format(issue: JSON.pretty_generate(issue_json))
38 | message = { role: "user", content: prompt_text }
39 | messages = [message]
40 | chat(messages)
41 | end
42 |
43 | def generate_issue_reply(issue:, instructions:)
44 | return "Permission denied" unless issue.visible?
45 |
46 | prompt = load_prompt("issue_agent/generate_reply")
47 | issue_json = generate_issue_data(issue)
48 | prompt_text = prompt.format(issue: JSON.pretty_generate(issue_json), instructions: instructions, format: Setting.text_formatting)
49 | message = { role: "user", content: prompt_text }
50 | messages = [message]
51 | chat(messages)
52 | end
53 |
54 | private
55 |
56 | # Generate a available issue properties string
57 | def issue_properties
58 | return "" unless @project
59 | provider = RedmineAiHelper::Tools::IssueTools.new
60 | properties = provider.capable_issue_properties(project_id: @project.id)
61 | content = <<~EOS
62 |
63 | ----
64 |
65 | The following issue properties are available for Project ID: #{@project.id}.
66 |
67 | ```json
68 | #{JSON.pretty_generate(properties)}
69 | ```
70 | EOS
71 | content
72 | end
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/issue_update_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # IssueUpdateAgent is a specialized agent for handling Redmine issue updates.
7 | class IssueUpdateAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("issue_update_agent/backstory")
10 | content = prompt.format(issue_properties: issue_properties)
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [
16 | RedmineAiHelper::Tools::IssueTools,
17 | RedmineAiHelper::Tools::IssueUpdateTools,
18 | RedmineAiHelper::Tools::ProjectTools,
19 | RedmineAiHelper::Tools::UserTools,
20 | ]
21 | end
22 |
23 | private
24 |
25 | def issue_properties
26 | return "" unless @project
27 | provider = RedmineAiHelper::Tools::IssueTools.new
28 | properties = provider.capable_issue_properties(project_id: @project.id)
29 | content = <<~EOS
30 |
31 | ----
32 |
33 | The following issue properties are available for Project ID: #{@project.id}.
34 |
35 | ```json
36 | #{JSON.pretty_generate(properties)}
37 | ```
38 | EOS
39 | content
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/mcp_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "redmine_ai_helper/base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # MCPAgent is a specialized agent for handling tasks using the Model Context Protocol (MCP).
7 | class McpAgent < RedmineAiHelper::BaseAgent
8 | def role
9 | "mcp_agent"
10 | end
11 |
12 | def available_tool_providers
13 | list = Util::McpToolsLoader.load
14 | list
15 | end
16 |
17 | def backstory
18 | prompt = load_prompt("mcp_agent/backstory")
19 | content = prompt.format
20 | content
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/project_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # ProjectAgent is a specialized agent for handling Redmine project-related queries.
7 | class ProjectAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("project_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [RedmineAiHelper::Tools::ProjectTools]
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/repository_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # RepositoryAgent is a specialized agent for handling Redmine repository-related queries.
7 | class RepositoryAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("repository_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [RedmineAiHelper::Tools::RepositoryTools]
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/system_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # SystemAgent is a specialized agent for handling Redmine system-related queries.
7 | class SystemAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("system_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [RedmineAiHelper::Tools::SystemTools]
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/user_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # UserAgent is a specialized agent for handling Redmine user-related queries.
7 | class UserAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("user_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [RedmineAiHelper::Tools::UserTools]
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/version_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # VersionAgent is a specialized agent for handling Redmine version-related queries.
7 | class VersionAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("version_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | [RedmineAiHelper::Tools::VersionTools]
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/agents/wiki_agent.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "../base_agent"
3 |
4 | module RedmineAiHelper
5 | module Agents
6 | # WikiAgent is a specialized agent for handling Redmine wiki-related queries.
7 | class WikiAgent < RedmineAiHelper::BaseAgent
8 | def backstory
9 | prompt = load_prompt("wiki_agent/backstory")
10 | content = prompt.format
11 | content
12 | end
13 |
14 | def available_tool_providers
15 | base_tools = [RedmineAiHelper::Tools::WikiTools]
16 | if AiHelperSetting.vector_search_enabled?
17 | base_tools.unshift(RedmineAiHelper::Tools::VectorTools)
18 | end
19 | base_tools
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/assistant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # TODO: Move this to assistants directory.
3 | require "langchain"
4 |
5 | module RedmineAiHelper
6 | # Base class for all assistants.
7 | class Assistant < Langchain::Assistant
8 | attr_accessor :llm_provider
9 | @llm_provider = nil
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/assistant_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | class AssistantProvider
4 | # This class is responsible for providing the appropriate assistant based on the LLM type.
5 | class << self
6 | # Returns an instance of the appropriate assistant based on the LLM type.
7 | # @param llm_type [String] The type of LLM (e.g., LLM_GEMINI).
8 | # @param llm [Object] The LLM client to use.
9 | # @param instructions [String] The instructions for the assistant.
10 | # @param tools [Array] The tools to be used by the assistant.
11 | # @return [Object] An instance of the appropriate assistant.
12 | def get_assistant(llm_type:, llm:, instructions:, tools: [])
13 | case llm_type
14 | when LlmProvider::LLM_GEMINI
15 | return RedmineAiHelper::Assistants::GeminiAssistant.new(
16 | llm: llm,
17 | instructions: instructions,
18 | tools: tools,
19 | )
20 | else
21 | return RedmineAiHelper::Assistant.new(
22 | llm: llm,
23 | instructions: instructions,
24 | tools: tools,
25 | )
26 | end
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/assistants/gemini_assistant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | module Assistants
4 | # GeminiAssistant is a specialized assistant for handling Gemini messages.
5 | class GeminiAssistant < RedmineAiHelper::Assistant
6 | # Adjust the message format to match Gemini. Convert "assistant" role to "model".
7 | def add_message(role: "user", content: nil, image_url: nil, tool_calls: [], tool_call_id: nil)
8 | new_role = role
9 | case role
10 | when "assistant"
11 | new_role = "model"
12 | end
13 | super(
14 | role: new_role,
15 | content: content,
16 | image_url: image_url,
17 | tool_calls: tool_calls,
18 | tool_call_id: tool_call_id,
19 | )
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/base_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "langchain"
3 | require "redmine_ai_helper/logger"
4 |
5 | module RedmineAiHelper
6 |
7 | # Base class for all tools.
8 | class BaseTools
9 | extend Langchain::ToolDefinition
10 |
11 | include RedmineAiHelper::Logger
12 | include Rails.application.routes.url_helpers
13 |
14 | # Check if the specified project is accessible
15 | # @param project [Project] The project
16 | # @return [Boolean] true if accessible, false otherwise
17 | def accessible_project?(project)
18 | return false unless project.visible?
19 | return false unless project.module_enabled?(:ai_helper)
20 | User.current.allowed_to?({ controller: :ai_helper, action: :chat_form }, project)
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/chat_room.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "redmine_ai_helper/logger"
3 |
4 | module RedmineAiHelper
5 | # A class representing a chat room where the LeaderAgent and other agents collaborate to achieve the user's goal.
6 | # The chat room is created by the LeaderAgent, and additional agents are added as needed.
7 | class ChatRoom
8 | include RedmineAiHelper::Logger
9 | attr_accessor :agents, :messages
10 |
11 | # @param goal [String] The user's goal.
12 | def initialize(goal)
13 | @agents = []
14 | @goal = goal
15 | @messages = []
16 | end
17 |
18 | # Share the user's goal with all agents in the chat room.
19 | def share_goal
20 | first_message = <<~EOS
21 | The user's goal is as follows. Collaborate with all agents to achieve this goal.
22 |
23 | ----
24 |
25 | goal:
26 | #{goal}
27 | EOS
28 | add_message("user", "leader", first_message, "all")
29 | end
30 |
31 | # @return [String] The user's goal.
32 | def goal
33 | @goal
34 | end
35 |
36 | # Add an agent to the chat room.
37 | # @param agent [BaseAgent] The agent to be added to the chat room.
38 | # @return [Array] The list of agents in the chat room.
39 | def add_agent(agent)
40 | @agents << agent
41 | end
42 |
43 | # Add a message to the chat room.
44 | # @param role [String] The role of the agent sending the message.
45 | # @param message [String] The message content.
46 | # @param to [String] The recipient of the message.
47 | # @return [Array] The list of messages in the chat room.
48 | def add_message(llm_role, from, message, to)
49 | ai_helper_logger.debug "from: #{from}\n @#{to}, #{message}"
50 | @messages ||= []
51 | content = "From: #{from}\n\n----\n\nTo: #{to}\n#{message}"
52 | @messages << { role: llm_role, content: content }
53 | @agents.each do |agent|
54 | agent.add_message(role: llm_role, content: content)
55 | end
56 | end
57 |
58 | # Get an agent by its role.
59 | # @param [String] role The role of the agent to be retrieved.
60 | # @return [BaseAgent] The agent with the specified role.
61 | def get_agent(role)
62 | @agents.find { |agent| agent.role == role }
63 | end
64 |
65 | # Send a task from one agent to another. The task is saved in the messages.
66 | # @param [String] from the role of the agent sending the task
67 | # @param [String] to the role of the agent receiving the task
68 | # @param [String] task the task to be sent
69 | # @param [Hash] option options for the task
70 | # @param [Proc] proc a block to be executed after the task is sent
71 | # @return [String] the response from the agent
72 | def send_task(from, to, task, option = {}, proc = nil)
73 | add_message("user", from, task, to)
74 | agent = get_agent(to)
75 | unless agent
76 | error = "Agent not found: #{to}"
77 | ai_helper_logger.error error
78 | raise error
79 | end
80 | answer = agent.perform_task(option, proc)
81 | add_message("assistant", to, answer, from)
82 | answer
83 | end
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/langfuse_util/anthropic.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module LangfuseUtil
3 | # Wrapper for Anthropic.
4 | class Anthropic < Langchain::LLM::Anthropic
5 | attr_accessor :langfuse
6 |
7 | # Override the chat method to handle tool calls.
8 | # @param [Hash] params Parameters for the chat request.
9 | # @param [Proc] block Block to handle the response.
10 | # @return The response from the chat.
11 | def chat(params = {}, &block)
12 | generation = nil
13 | if @langfuse&.current_span
14 | parameters = chat_parameters.to_params(params)
15 | span = @langfuse.current_span
16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens]
17 | new_messages = []
18 | new_messages << { role: "system", content: params[:system] } if params[:system]
19 | new_messages = new_messages + params[:messages]
20 | generation = span.create_generation(name: "chat", messages: new_messages, model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens)
21 | end
22 | response = super(params, &block)
23 | if generation
24 | usage = {
25 | prompt_tokens: response.prompt_tokens,
26 | completion_tokens: response.completion_tokens,
27 | total_tokens: response.total_tokens,
28 | }
29 | generation.finish(output: response.chat_completion, usage: usage)
30 | end
31 | response
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/langfuse_util/azure_open_ai.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module LangfuseUtil
3 | # Wrapper for OpenAI.
4 | class AzureOpenAi < Langchain::LLM::Azure
5 | attr_accessor :langfuse
6 |
7 | # Override the chat method to handle tool calls.
8 | # @param [Hash] params Parameters for the chat request.
9 | # @param [Proc] block Block to handle the response.
10 | # @return The response from the chat.
11 | def chat(params = {}, &block)
12 | generation = nil
13 | if @langfuse&.current_span
14 | parameters = chat_parameters.to_params(params)
15 | span = @langfuse.current_span
16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens]
17 | generation = span.create_generation(name: "chat", messages: params[:messages], model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens)
18 | end
19 | response = super(params, &block)
20 | if generation
21 | usage = {
22 | prompt_tokens: response.prompt_tokens,
23 | completion_tokens: response.completion_tokens,
24 | total_tokens: response.total_tokens,
25 | }
26 | generation.finish(output: response.chat_completion, usage: usage)
27 | end
28 | response
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/langfuse_util/gemini.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module LangfuseUtil
3 | # Wrapper for GoogleGemini.
4 | class Gemini < Langchain::LLM::GoogleGemini
5 | attr_accessor :langfuse
6 |
7 | # Override the chat method to handle tool calls.
8 | # @param [Hash] params Parameters for the chat request.
9 | # @param [Proc] block Block to handle the response.
10 | # @return The response from the chat.
11 | def chat(params = {})
12 | generation = nil
13 | if @langfuse&.current_span
14 | parameters = chat_parameters.to_params(params)
15 | span = @langfuse.current_span
16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens]
17 | new_messages = []
18 | new_messages << { role: "system", content: params[:system] } if params[:system]
19 | params[:messages].each do |message|
20 | new_messages << { role: message[:role], content: message.dig(:parts, 0, :text) }
21 | end
22 | generation = span.create_generation(name: "chat", messages: new_messages, model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens)
23 | end
24 | response = super(params)
25 | if generation
26 | usage = {
27 | prompt_tokens: response.prompt_tokens,
28 | completion_tokens: response.completion_tokens,
29 | total_tokens: response.total_tokens,
30 | }
31 | generation.finish(output: response.chat_completion, usage: usage)
32 | end
33 | response
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/langfuse_util/open_ai.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module LangfuseUtil
3 | # Wrapper for OpenAI.
4 | class OpenAi < Langchain::LLM::OpenAI
5 | attr_accessor :langfuse
6 |
7 | # Override the chat method to handle tool calls.
8 | # @param [Hash] params Parameters for the chat request.
9 | # @param [Proc] block Block to handle the response.
10 | # @return The response from the chat.
11 | def chat(params = {}, &block)
12 | generation = nil
13 | if @langfuse&.current_span
14 | parameters = chat_parameters.to_params(params)
15 | span = @langfuse.current_span
16 | max_tokens = parameters[:max_tokens] || @defaults[:max_tokens]
17 | generation = span.create_generation(name: "chat", messages: params[:messages], model: parameters[:model], temperature: parameters[:temperature], max_tokens: max_tokens)
18 | end
19 | response = super(params, &block)
20 | if generation
21 | usage = {
22 | prompt_tokens: response.prompt_tokens,
23 | completion_tokens: response.completion_tokens,
24 | total_tokens: response.total_tokens,
25 | }
26 | generation.finish(output: response.chat_completion, usage: usage)
27 | end
28 | response
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "logger"
3 | require_relative "base_agent"
4 | require_relative "langfuse_util/langfuse_wrapper"
5 |
6 | module RedmineAiHelper
7 |
8 | # A class that is directly called from the controller to interact with AI using LLM.
9 | # TODO: クラス名を変えたい
10 | class Llm
11 | include RedmineAiHelper::Logger
12 | attr_accessor :model
13 |
14 | def initialize(params = {})
15 | end
16 |
17 | # chat with the AI
18 | # @param conversation [Conversation] The conversation object
19 | # @param proc [Proc] A block to be executed after the task is sent
20 | # @param option [Hash] Options for the task
21 | # @return [AiHelperMessage] The AI's response
22 | def chat(conversation, proc, option = {})
23 | task = conversation.messages.last.content
24 | ai_helper_logger.debug "#### ai_helper: chat start ####"
25 | ai_helper_logger.info "user:#{User.current}, task: #{task}, option: #{option}"
26 | begin
27 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: task)
28 | option[:langfuse] = langfuse
29 | agent = RedmineAiHelper::Agents::LeaderAgent.new(option)
30 | langfuse.create_span(name: "user_request", input: task)
31 | answer = agent.perform_user_request(conversation.messages_for_openai, option, proc)
32 | langfuse.finish_current_span(output: answer)
33 | langfuse.flush
34 | rescue => e
35 | ai_helper_logger.error "error: #{e.full_message}"
36 | answer = e.message
37 | end
38 | ai_helper_logger.info "answer: #{answer}"
39 | AiHelperMessage.new(role: "assistant", content: answer, conversation: conversation)
40 | end
41 |
42 | # IssueAgentを使用して、issueの要約を取得する
43 | # @param issue [Issue] The issue object
44 | # return [String] The summary of the issue
45 | def issue_summary(issue:)
46 | begin
47 | prompt = "Please summarize the issue #{issue.id}."
48 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: prompt)
49 | agent = RedmineAiHelper::Agents::IssueAgent.new(project: issue.project, langfuse: langfuse)
50 | langfuse.create_span(name: "user_request", input: prompt)
51 | answer = agent.issue_summary(issue: issue)
52 | langfuse.finish_current_span(output: answer)
53 | langfuse.flush
54 | rescue => e
55 | ai_helper_logger.error "error: #{e.full_message}"
56 | answer = e.message
57 | end
58 | ai_helper_logger.info "answer: #{answer}"
59 | answer
60 | end
61 |
62 | def generate_issue_reply(issue:, instructions:)
63 | begin
64 | prompt = "Please generate a reply to the issue #{issue.id} with the instructions.\n\n#{instructions}"
65 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: prompt)
66 | agent = RedmineAiHelper::Agents::IssueAgent.new(project: issue.project, langfuse: langfuse)
67 | langfuse.create_span(name: "user_request", input: prompt)
68 | answer = agent.generate_issue_reply(issue: issue, instructions: instructions)
69 | langfuse.finish_current_span(output: answer)
70 | langfuse.flush
71 | rescue => e
72 | ai_helper_logger.error "error: #{e.full_message}"
73 | answer = e.message
74 | end
75 | ai_helper_logger.info "answer: #{answer}"
76 | answer
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_client/anthropic_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | module LlmClient
4 | ## AnthropicProvider is a specialized provider for handling requests to the Anthropic LLM.
5 | class AnthropicProvider < RedmineAiHelper::LlmClient::BaseProvider
6 | # generate a client for the Anthropic LLM
7 | # @return [Langchain::LLM::Anthropic] client
8 | def generate_client
9 | setting = AiHelperSetting.find_or_create
10 | model_profile = setting.model_profile
11 | raise "Model Profile not found" unless model_profile
12 | default_options = {
13 | chat_model: model_profile.llm_model,
14 | temperature: model_profile.temperature,
15 | max_tokens: 2000,
16 | }
17 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens
18 | client = RedmineAiHelper::LangfuseUtil::Anthropic.new(
19 | api_key: model_profile.access_key,
20 | default_options: default_options,
21 | )
22 | raise "Anthropic LLM Create Error" unless client
23 | client
24 | end
25 |
26 | # Generate a chat completion request
27 | # @param [Hash] system_prompt
28 | # @param [Array] messages
29 | # @return [Hash] chat_params
30 | def create_chat_param(system_prompt, messages)
31 | new_messages = messages.dup
32 | chat_params = {
33 | messages: new_messages,
34 | }
35 | chat_params[:system] = system_prompt[:content]
36 | chat_params
37 | end
38 |
39 | # Extract a message from the chunk
40 | # @param [Hash] chunk
41 | # @return [String] message
42 | def chunk_converter(chunk)
43 | chunk.dig("delta", "text")
44 | end
45 |
46 | # Clear the messages held by the Assistant, set the system prompt, and add messages
47 | # @param [RedmineAiHelper::Assistant] assistant
48 | # @param [Hash] system_prompt
49 | # @param [Array] messages
50 | # @return [void]
51 | def reset_assistant_messages(assistant:, system_prompt:, messages:)
52 | assistant.clear_messages!
53 | assistant.instructions = system_prompt
54 | messages.each do |message|
55 | assistant.add_message(role: message[:role], content: message[:content])
56 | end
57 | end
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_client/azure_open_ai_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "base_provider"
4 |
5 | module RedmineAiHelper
6 | module LlmClient
7 | # OpenAiProvider is a specialized provider for handling OpenAI-related queries.
8 | class AzureOpenAiProvider < RedmineAiHelper::LlmClient::BaseProvider
9 | # Generate a client for OpenAI LLM
10 | # @return [Langchain::LLM::OpenAI] the OpenAI client
11 | def generate_client
12 | setting = AiHelperSetting.find_or_create
13 | model_profile = setting.model_profile
14 | raise "Model Profile not found" unless model_profile
15 | llm_options = {
16 | api_type: :azure,
17 | chat_deployment_url: model_profile.base_uri,
18 | embedding_deployment_url: setting.embedding_url,
19 | api_version: "2024-12-01-preview",
20 | }
21 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id
22 | llm_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank?
23 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id
24 | llm_options[:max_tokens] = setting.max_tokens if setting.max_tokens
25 | default_options = {
26 | model: model_profile.llm_model,
27 | chat_model: model_profile.llm_model,
28 | temperature: model_profile.temperature,
29 | embedding_deployment_url: setting.embedding_url,
30 | }
31 | default_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank?
32 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens
33 | client = RedmineAiHelper::LangfuseUtil::AzureOpenAi.new(
34 | api_key: model_profile.access_key,
35 | llm_options: llm_options,
36 | default_options: default_options,
37 | chat_deployment_url: model_profile.base_uri,
38 | embedding_deployment_url: setting.embedding_url,
39 | )
40 | raise "OpenAI LLM Create Error" unless client
41 | client
42 | end
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_client/base_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | module LlmClient
4 | # BaseProvider is an abstract class that defines the interface for LLM providers.
5 | class BaseProvider
6 |
7 | # @return LLM Client of the LLM provider.
8 | def generate_client
9 | raise NotImplementedError, "LLM provider not found"
10 | end
11 |
12 | # @return [Hash] The system prompt for the LLM provider.
13 | def create_chat_param(system_prompt, messages)
14 | new_messages = messages.dup
15 | new_messages.unshift(system_prompt)
16 | {
17 | messages: new_messages,
18 | }
19 | end
20 |
21 | # Extracts a message from the chunk
22 | # @param [Hash] chunk
23 | # @return [String] message
24 | def chunk_converter(chunk)
25 | chunk.dig("delta", "content")
26 | end
27 |
28 | # Clears the messages held by the Assistant, sets the system prompt, and adds messages
29 | # @param [RedmineAiHelper::Assistant] assistant
30 | # @param [Hash] system_prompt
31 | # @param [Array] messages
32 | # @return [void]
33 | def reset_assistant_messages(assistant:, system_prompt:, messages:)
34 | assistant.clear_messages!
35 | assistant.add_message(role: "system", content: system_prompt[:content])
36 | messages.each do |message|
37 | assistant.add_message(role: message[:role], content: message[:content])
38 | end
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_client/gemini_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | module LlmClient
4 | # GeminiProvider is a specialized provider for handling Google Gemini LLM requests.
5 | class GeminiProvider < RedmineAiHelper::LlmClient::BaseProvider
6 | # Generate a new Gemini client using the provided API key and model profile.
7 | # @return [Langchain::LLM::GoogleGemini] client
8 | def generate_client
9 | setting = AiHelperSetting.find_or_create
10 | model_profile = setting.model_profile
11 | raise "Model Profile not found" unless model_profile
12 | default_options = {
13 | chat_model: model_profile.llm_model,
14 | temperature: model_profile.temperature,
15 | }
16 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens
17 | client = RedmineAiHelper::LangfuseUtil::Gemini.new(
18 | api_key: model_profile.access_key,
19 | default_options: default_options,
20 | )
21 | raise "Gemini LLM Create Error" unless client
22 | client
23 | end
24 |
25 | # Generate a parameter for chat completion request for the Gemini LLM.
26 | # @param [Hash] system_prompt
27 | # @param [Array] messages
28 | # @return [Hash] chat_params
29 | def create_chat_param(system_prompt, messages)
30 | new_messages = messages.map do |message|
31 | {
32 | role: message[:role],
33 | parts: [
34 | {
35 | text: message[:content],
36 | },
37 | ],
38 | }
39 | end
40 | chat_params = {
41 | messages: new_messages,
42 | system: system_prompt[:content],
43 | }
44 | chat_params
45 | end
46 |
47 | # Reset the assistant's messages, set the system prompt, and add messages.
48 | # @param [RedmineAiHelper::Assistant] assistant
49 | # @param [Hash] system_prompt
50 | # @param [Array] messages
51 | # @return [void]
52 | def reset_assistant_messages(assistant:, system_prompt:, messages:)
53 | assistant.clear_messages!
54 | assistant.instructions = system_prompt
55 | messages.each do |message|
56 | role = message[:role]
57 | role = "model" if role == "assistant"
58 | assistant.add_message(role: role, content: message[:content])
59 | end
60 | end
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_client/open_ai_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "redmine_ai_helper/langfuse_util/open_ai"
3 | require_relative "base_provider"
4 |
5 | module RedmineAiHelper
6 | module LlmClient
7 | # OpenAiProvider is a specialized provider for handling OpenAI-related queries.
8 | class OpenAiProvider < RedmineAiHelper::LlmClient::BaseProvider
9 | # Generate a client for OpenAI LLM
10 | # @return [Langchain::LLM::OpenAI] the OpenAI client
11 | def generate_client
12 | setting = AiHelperSetting.find_or_create
13 | model_profile = setting.model_profile
14 | raise "Model Profile not found" unless model_profile
15 | llm_options = {}
16 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id
17 | llm_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank?
18 | llm_options[:organization_id] = model_profile.organization_id if model_profile.organization_id
19 | llm_options[:max_tokens] = setting.max_tokens if setting.max_tokens
20 | default_options = {
21 | model: model_profile.llm_model,
22 | chat_model: model_profile.llm_model,
23 | temperature: model_profile.temperature,
24 | }
25 | default_options[:embedding_model] = setting.embedding_model unless setting.embedding_model.blank?
26 | default_options[:max_tokens] = setting.max_tokens if setting.max_tokens
27 | client = RedmineAiHelper::LangfuseUtil::OpenAi.new(
28 | api_key: model_profile.access_key,
29 | llm_options: llm_options,
30 | default_options: default_options,
31 | )
32 | raise "OpenAI LLM Create Error" unless client
33 | client
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/llm_provider.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require_relative "llm_client/open_ai_provider"
3 | require_relative "llm_client/anthropic_provider"
4 |
5 | module RedmineAiHelper
6 | # This class is responsible for providing the appropriate LLM client based on the LLM type.
7 | class LlmProvider
8 | LLM_OPENAI = "OpenAI".freeze
9 | LLM_OPENAI_COMPATIBLE = "OpenAICompatible".freeze
10 | LLM_GEMINI = "Gemini".freeze
11 | LLM_ANTHROPIC = "Anthropic".freeze
12 | LLM_AZURE_OPENAI = "AzureOpenAi".freeze
13 | class << self
14 | # Returns an instance of the appropriate LLM client based on the system settings.
15 | # @return [Object] An instance of the appropriate LLM client.
16 | def get_llm_provider
17 | case type
18 | when LLM_OPENAI
19 | return RedmineAiHelper::LlmClient::OpenAiProvider.new
20 | when LLM_OPENAI_COMPATIBLE
21 | return RedmineAiHelper::LlmClient::OpenAiCompatibleProvider.new
22 | when LLM_GEMINI
23 | return RedmineAiHelper::LlmClient::GeminiProvider.new
24 | when LLM_ANTHROPIC
25 | return RedmineAiHelper::LlmClient::AnthropicProvider.new
26 | when LLM_AZURE_OPENAI
27 | return RedmineAiHelper::LlmClient::AzureOpenAiProvider.new
28 | else
29 | raise NotImplementedError, "LLM provider not found"
30 | end
31 | end
32 |
33 | # Returns the LLM type based on the system settings.
34 | # @return [String] The LLM type (e.g., LLM_OPENAI).
35 | def type
36 | setting = AiHelperSetting.find_or_create
37 | setting.model_profile.llm_type
38 | end
39 |
40 | # Returns the options to display in the settings screen's dropdown menu
41 | # @return [Array] An array of options for the select menu.
42 | def option_for_select
43 | [
44 | ["OpenAI", LLM_OPENAI],
45 | ["OpenAI Compatible(Experimental)", LLM_OPENAI_COMPATIBLE],
46 | ["Gemini(Experimental)", LLM_GEMINI],
47 | ["Anthropic(Experimental)", LLM_ANTHROPIC],
48 | ["Azure OpenAI(Experimental)", LLM_AZURE_OPENAI],
49 | ]
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/logger.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | module Logger
4 | def self.included(base)
5 | base.extend(ClassMethods)
6 | end
7 |
8 | module ClassMethods
9 | def ai_helper_logger
10 | @ai_helper_logger ||= begin
11 | RedmineAiHelper::CustomLogger.instance
12 | end
13 | end
14 |
15 | def debug(message)
16 | ai_helper_logger.debug("[#{self.name}] #{message}")
17 | end
18 |
19 | def info(message)
20 | ai_helper_logger.info("[#{self.name}] #{message}")
21 | end
22 |
23 | def warn(message)
24 | ai_helper_logger.warn("[#{self.name}] #{message}")
25 | end
26 |
27 | def error(message)
28 | ai_helper_logger.error("[#{self.name}] #{message}")
29 | end
30 | end
31 |
32 | def ai_helper_logger
33 | self.class.ai_helper_logger
34 | end
35 |
36 | def debug(message)
37 | ai_helper_logger.debug("[#{self.class.name}] #{message}")
38 | end
39 |
40 | def info(message)
41 | ai_helper_logger.info("[#{self.class.name}] #{message}")
42 | end
43 |
44 | def warn(message)
45 | ai_helper_logger.warn("[#{self.class.name}] #{message}")
46 | end
47 |
48 | def error(message)
49 | ai_helper_logger.error("[#{self.class.name}] #{message}")
50 | end
51 | end
52 |
53 | class CustomLogger
54 | include Singleton
55 |
56 | def initialize
57 | log_file_path = Rails.root.join("log", "ai_helper.log")
58 |
59 | config = RedmineAiHelper::Util::ConfigFile.load_config
60 |
61 | logger = config[:logger]
62 | unless logger
63 | @logger = Rails.logger
64 | return
65 | end
66 | log_level = "info"
67 | log_level = logger[:level] if logger[:level]
68 | log_file_path = Rails.root.join("log", logger[:file]) if logger[:file]
69 | @logger = ::Logger.new(log_file_path, "daily")
70 | @logger.formatter = proc do |severity, datetime, progname, msg|
71 | "[#{datetime}] #{severity} -- #{msg}\n"
72 | end
73 | set_log_level(log_level)
74 | end
75 |
76 | def debug(message)
77 | @logger.debug(message)
78 | end
79 |
80 | def info(message)
81 | @logger.info(message)
82 | end
83 |
84 | def warn(message)
85 | @logger.warn(message)
86 | end
87 |
88 | def error(message)
89 | @logger.error(message)
90 | end
91 |
92 | def set_log_level(log_level)
93 | level = case log_level.to_s
94 | when "debug"
95 | ::Logger::DEBUG
96 | when "info"
97 | ::Logger::INFO
98 | when "warn"
99 | ::Logger::WARN
100 | when "error"
101 | ::Logger::ERROR
102 | else
103 | ::Logger::INFO
104 | end
105 | @logger.level = level
106 | end
107 | end
108 | end
109 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/tool_response.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | # ツールからのレスポンスを格納するクラス
4 | # TODO: 不要かも
5 | class ToolResponse
6 | attr_reader :status, :value, :error
7 | ToolResponse::STATUS_SUCCESS = "success"
8 | ToolResponse::STATUS_ERROR = "error"
9 |
10 | def initialize(response = {})
11 | @status = response[:status] || response["status"]
12 | @value = response[:value] || response["value"]
13 | @error = response[:error] || response["error"]
14 | end
15 |
16 | def to_json
17 | to_hash().to_json
18 | end
19 |
20 | def to_hash
21 | { status: status, value: value, error: error }
22 | end
23 |
24 | def to_h
25 | to_hash
26 | end
27 |
28 | def to_s
29 | to_hash.to_s
30 | end
31 |
32 | def is_success?
33 | status == ToolResponse::STATUS_SUCCESS
34 | end
35 |
36 | def is_error?
37 | !is_success?
38 | end
39 |
40 | def self.create_error(error)
41 | ToolResponse.new(status: ToolResponse::STATUS_ERROR, error: error)
42 | end
43 |
44 | def self.create_success(value)
45 | ToolResponse.new(status: ToolResponse::STATUS_SUCCESS, value: value)
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/tools/system_tools.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "redmine_ai_helper/base_tools"
3 |
4 | module RedmineAiHelper
5 | module Tools
6 | # SystemTools is a specialized tool provider for handling system-related queries in Redmine.
7 | class SystemTools < RedmineAiHelper::BaseTools
8 | define_function :list_plugins, description: "Returns a list of all plugins installed in Redmine." do
9 | property :dummy, type: "string", description: "Dummy property. No need to specify.", required: false
10 | end
11 |
12 | # Returns a list of all plugins installed in Redmine.
13 | # A dummy property is defined because at least one property is required in the tool
14 | # definition for langchainrb.
15 | # @param dummy [String] Dummy property to satisfy the tool definition requirement.
16 | # @return [Array] An array of hashes containing plugin information.
17 | def list_plugins(dummy: nil)
18 | plugins = Redmine::Plugin.all
19 | plugin_list = []
20 | plugins.map do |plugin|
21 | plugin_list <<
22 | {
23 | name: plugin.name,
24 | version: plugin.version,
25 | author: plugin.author,
26 | url: plugin.url,
27 | author_url: plugin.author_url,
28 | }
29 | end
30 | json = { plugins: plugin_list }
31 | return json
32 | end
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/util/config_file.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module Util
3 | class ConfigFile
4 | def self.load_config
5 | unless File.exist?(config_file_path)
6 | return {}
7 | end
8 |
9 | yaml = YAML.load_file(config_file_path)
10 | yaml.deep_symbolize_keys
11 | end
12 |
13 | def self.config_file_path
14 | Rails.root.join("config", "ai_helper", "config.yml")
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/util/langchain_patch.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "langchain"
3 |
4 | module RedmineAiHelper
5 | module Util
6 | module LangchainPatch
7 | # A patch to enable recursive calls for creating Object properties when automatically
8 | # generating Tool definitions in MCPTools
9 | refine Langchain::ToolDefinition::ParameterBuilder do
10 | def build_properties_from_json(json)
11 | properties = json["properties"] || {}
12 | items = json["items"]
13 | properties.each do |key, value|
14 | type = value["type"]
15 | case type
16 | when "object", "array"
17 | property key.to_sym, type: type, description: value["description"] do
18 | build_properties_from_json(value)
19 | end
20 | else
21 | property key.to_sym, type: type, description: value["description"]
22 | end
23 | end
24 | if items
25 | @parent_type = "array"
26 | type = items["type"]
27 | description = items["description"]
28 | case type
29 | when "object", "array"
30 | item type: type, description: description do
31 | @parent_type = type
32 | build_properties_from_json(items)
33 | end
34 | else
35 | item type: type
36 | end
37 | end
38 | end
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/util/mcp_tools_loader.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "redmine_ai_helper/logger"
3 |
4 | module RedmineAiHelper
5 | module Util
6 | # A class that reads config.json and generates tool classes for MCPTools.
7 | # The Singleton pattern is adopted to avoid generating the same class multiple times.
8 | class McpToolsLoader
9 | include Singleton
10 | include RedmineAiHelper::Logger
11 |
12 | # Load the configuration file and generate tool classes.
13 | # @return [Array] An array of tool classes generated from the configuration file.
14 | def self.load
15 | loader.generate_tools_instances
16 | end
17 |
18 | # Retrieves the singleton instance of this class.
19 | # @return [McpToolsLoader] The singleton instance of this class.
20 | def self.loader
21 | McpToolsLoader.instance
22 | end
23 |
24 | # Generate instances of all MCPTools
25 | # @return [Array] An array of tool classes generated from the configuration file.
26 | def generate_tools_instances
27 | return @list if @list and @list.length > 0
28 | # Check if the config file exists
29 | unless File.exist?(config_file)
30 | return []
31 | end
32 |
33 | # Load the configuration file
34 | config = JSON.parse(File.read(config_file))
35 |
36 | mcp_servers = config["mcpServers"]
37 | return [] unless mcp_servers
38 |
39 | list = []
40 | # Generate tool classes based on the configuration
41 | mcp_servers.each do |name, json|
42 | begin
43 | tool_class = RedmineAiHelper::Tools::McpTools.generate_tool_class(name: name, json: json)
44 | list << tool_class
45 | rescue => e
46 | ai_helper_logger.error "Error generating tool class for #{name}: #{e.message}"
47 | throw "Error generating tool class for #{name}: #{e.message}"
48 | end
49 | end
50 | @list = list
51 | end
52 |
53 | # Returns the path to the configuration file.
54 | def config_file
55 | @config_file ||= Rails.root.join("config", "ai_helper", "config.json").to_s
56 | end
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/util/prompt_loader.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "langchain"
3 |
4 | module RedmineAiHelper
5 | module Util
6 | # A class that loads prompt templates from YAML files.
7 | # The templates are stored in the assets/prompt_templates directory.
8 | class PromptLoader
9 | class << self
10 | # Loads a prompt template from a YAML file.
11 | # @param name [String] The name of the template file (without extension).
12 | # @return [Langchain::Prompt] The loaded prompt template.
13 | def load_template(name)
14 | tepmlate_base_dir = File.dirname(__FILE__) + "/../../../assets/prompt_templates"
15 | locale_string = I18n.locale.to_s
16 | template_file = "#{tepmlate_base_dir}/#{name}_#{locale_string}.yml"
17 | # Check if the locale-specific template file exists
18 | unless File.exist?(template_file)
19 | template_file = "#{tepmlate_base_dir}/#{name}.yml"
20 | end
21 | Langchain::Prompt.load_from_path(file_path: template_file)
22 | end
23 | end
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/util/wiki_json.rb:
--------------------------------------------------------------------------------
1 | module RedmineAiHelper
2 | module Util
3 | # This module provides methods to generate JSON data for wiki pages.
4 | module WikiJson
5 | # Generates a JSON representation of a wiki page.
6 | # @param page [WikiPage] The wiki page to be represented in JSON.
7 | # @return [Hash] A hash representing the wiki page in JSON format.
8 | def generate_wiki_data(page)
9 | json = {
10 | title: page.title,
11 | text: page.text,
12 | page_url: project_wiki_page_path(page.wiki.project, page.title),
13 | author: {
14 | id: page.content.author.id,
15 | name: page.content.author.name,
16 | },
17 | version: page.version,
18 | created_on: page.created_on,
19 | updated_on: page.updated_on,
20 | children: page.children.filter(&:visible?).map do |child|
21 | {
22 | title: child.title,
23 | }
24 | end,
25 | parent: page.parent ? { title: page.parent.title } : nil,
26 | attachments: page.attachments.map do |attachment|
27 | {
28 | filename: attachment.filename,
29 | filesize: attachment.filesize,
30 | content_type: attachment.content_type,
31 | description: attachment.description,
32 | created_on: attachment.created_on,
33 | attachment_url: attachment_path(attachment),
34 | }
35 | end,
36 | }
37 | end
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/vector/issue_vector_db.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "json"
3 |
4 | module RedmineAiHelper
5 | module Vector
6 | # This class is responsible for managing the vector database for issues in Redmine.
7 | class IssueVectorDb < VectorDb
8 | include Rails.application.routes.url_helpers
9 |
10 | def index_name
11 | "RedmineIssue"
12 | end
13 |
14 | # Checks whether an Issue with the specified ID exists.
15 | # @param object_id [Integer] The ID of the issue to check.
16 | def data_exists?(object_id)
17 | Issue.exists?(id: object_id)
18 | end
19 |
20 | # A method to generate content and payload for registering an issue into the vector database
21 | # @param issue [Issue] The issue to be registered.
22 | # @return [Hash] A hash containing the content and payload for the issue.
23 | # @note This method is used to prepare the data for vector database registration.
24 | def data_to_json(issue)
25 | payload = {
26 | issue_id: issue.id,
27 | project_id: issue.project.id,
28 | project_name: issue.project.name,
29 | author_id: issue.author&.id,
30 | author_name: issue.author&.name,
31 | subject: issue.subject,
32 | description: issue.description,
33 | status_id: issue.status.id,
34 | status: issue.status.name,
35 | priority_id: issue.priority.id,
36 | priority: issue.priority.name,
37 | assigned_to_id: issue.assigned_to&.id,
38 | assigned_to_name: issue.assigned_to&.name,
39 | created_on: issue.created_on,
40 | updated_on: issue.updated_on,
41 | due_date: issue.due_date,
42 | tracker_id: issue.tracker.id,
43 | tracker_name: issue.tracker.name,
44 | version_id: issue.fixed_version&.id,
45 | version_name: issue.fixed_version&.name,
46 | category_name: issue.category&.name,
47 | issue_url: issue_url(issue, only_path: true),
48 | }
49 | content = "#{issue.subject} #{issue.description}"
50 | content += " " + issue.journals.map { |journal| journal.notes.to_s }.join(" ")
51 |
52 | return { content: content, payload: payload }
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/vector/qdrant.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "langchain"
3 |
4 | module RedmineAiHelper
5 | module Vector
6 | # Langchainrb's Qdrant does not support payload filtering,
7 | # so it is implemented independently by inheritance
8 | class Qdrant < Langchain::Vectorsearch::Qdrant
9 |
10 | # search data from vector db with filter for payload.
11 | # @param query [String] The query string to search for.
12 | # @param filter [Hash] The filter to apply to the search.
13 | # @param k [Integer] The number of results to return.
14 | # @return [Array] An array of issues that match the query and filter.
15 | def ask_with_filter(query:, k: 20, filter: nil)
16 | return [] unless client
17 |
18 | embedding = llm.embed(text: query).embedding
19 |
20 | response = client.points.search(
21 | collection_name: index_name,
22 | limit: k,
23 | vector: embedding,
24 | with_payload: true,
25 | with_vector: true,
26 | filter: filter,
27 | )
28 | results = response.dig("result")
29 | return [] unless results.is_a?(Array)
30 |
31 | results.map do |result|
32 | result.dig("payload")
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/vector/wiki_vector_db.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | require "json"
3 |
4 | module RedmineAiHelper
5 | module Vector
6 | # This class is responsible for managing the vector database for issues in Redmine.
7 | class WikiVectorDb < VectorDb
8 | include Rails.application.routes.url_helpers
9 |
10 | def index_name
11 | "RedmineWiki"
12 | end
13 |
14 | # Checks whether an Issue with the specified ID exists.
15 | # @param object_id [Integer] The ID of the issue to check.
16 | def data_exists?(object_id)
17 | WikiPage.exists?(id: object_id)
18 | end
19 |
20 | # A method to generate content and payload for registering an issue into the vector database
21 | # @param issue [Issue] The issue to be registered.
22 | # @return [Hash] A hash containing the content and payload for the issue.
23 | # @note This method is used to prepare the data for vector database registration.
24 | def data_to_json(wiki)
25 | payload = {
26 | wiki_id: wiki.id,
27 | project_id: wiki.project&.id,
28 | project_name: wiki.project&.name,
29 | created_on: wiki.created_on,
30 | updated_on: wiki.updated_on,
31 | parent_id: wiki.parent_id,
32 | parent_title: wiki.parent_title,
33 | page_url: "#{project_wiki_page_path(wiki.project, wiki.title)}",
34 | }
35 | content = "#{wiki.title} #{wiki.content.text}"
36 |
37 | return { content: content, payload: payload }
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/redmine_ai_helper/view_hook.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | module RedmineAiHelper
3 | # Hook to display the chat screen in the sidebar
4 | class ViewHook < Redmine::Hook::ViewListener
5 | render_on :view_layouts_base_html_head, :partial => "ai_helper/html_header"
6 | render_on :view_layouts_base_body_top, :partial => "ai_helper/sidebar"
7 | render_on :view_issues_show_details_bottom, :partial => "ai_helper/issue_bottom"
8 | render_on :view_issues_edit_notes_bottom, :partial => "ai_helper/issue_form"
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/tasks/scm.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | # generate git repository for Redmine AI Helper tests
3 | namespace :redmine do
4 | namespace :plugins do
5 | namespace :ai_helper do
6 | desc "Setup SCM for Redmine AI Helper tests"
7 | task :setup_scm => :environment do
8 | plugin_dir = Rails.root.join("plugins/redmine_ai_helper").to_s
9 | scm_archive = "#{plugin_dir}/test/redmine_ai_helper_test_repo.git.tgz"
10 | puts scm_archive
11 | plugin_tmp = "#{plugin_dir}/tmp"
12 | puts plugin_tmp
13 | system("mkdir -p #{plugin_tmp}")
14 | Dir.chdir(plugin_tmp) do
15 | system("rm -rf redmine_ai_helper_test_repo.git")
16 | system("tar xvfz #{scm_archive}")
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/tasks/vector.rake:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 | namespace :redmine do
3 | namespace :plugins do
4 | namespace :ai_helper do
5 | # Rake tasks for initializing, registering, and deleting the vector DB
6 | namespace :vector do
7 | desc "Register vector data for Redmine AI Helper"
8 | task :regist => :environment do
9 | if enabled?
10 | issue_vector_db.generate_schema
11 | wiki_vector_db.generate_schema
12 | puts "Registering vector data for Redmine AI Helper..."
13 | issues = Issue.order(:id).all
14 | puts "Issues:"
15 | issue_vector_db.add_datas(datas: issues)
16 | wikis = WikiPage.order(:id).all
17 | puts "Wiki Pages:"
18 | wiki_vector_db.add_datas(datas: wikis)
19 | puts "Vector data registration completed."
20 | else
21 | puts "Vector search is not enabled. Skipping registration."
22 | end
23 | end
24 |
25 | desc "generate"
26 | task :generate => :environment do
27 | if enabled?
28 | puts "Generating vector index for Redmine AI Helper..."
29 | issue_vector_db.generate_schema
30 | wiki_vector_db.generate_schema
31 | else
32 | puts "Vector search is not enabled. Skipping generation."
33 | end
34 | end
35 |
36 | desc "Destroy vector data for Redmine AI Helper"
37 | task :destroy => :environment do
38 | if enabled?
39 | puts "Destroying vector data for Redmine AI Helper..."
40 | issue_vector_db.destroy_schema
41 | wiki_vector_db.destroy_schema
42 | else
43 | puts "Vector search is not enabled. Skipping destruction."
44 | end
45 | end
46 |
47 | def issue_vector_db
48 | return nil unless enabled?
49 | @issue_vector_db ||= RedmineAiHelper::Vector::IssueVectorDb.new(llm: llm)
50 | end
51 |
52 | def wiki_vector_db
53 | return nil unless enabled?
54 | @wiki_vector_db ||= RedmineAiHelper::Vector::WikiVectorDb.new(llm: llm)
55 | end
56 |
57 | def llm
58 | @llm ||= RedmineAiHelper::LlmProvider.get_llm_provider.generate_client
59 | end
60 |
61 | def enabled?
62 | setting = AiHelperSetting.find_or_create
63 | setting.vector_search_enabled
64 | end
65 | end
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/test/functional/ai_helper_model_profiles_controller_test.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 |
3 | class AiHelperModelProfilesControllerTest < ActionController::TestCase
4 | setup do
5 | AiHelperModelProfile.delete_all
6 | @request.session[:user_id] = 1 # Assuming user with ID 1 is an admin
7 | @model_profile = AiHelperModelProfile.create!(name: 'Test Profile', access_key: 'test_key', llm_type: "OpenAI", llm_model: "gpt-3.5-turbo")
8 |
9 | end
10 |
11 | should "show model profile" do
12 | get :show, params: { id: @model_profile.id }
13 | assert_response :success
14 | assert_template partial: '_show'
15 | assert_not_nil assigns(:model_profile)
16 | end
17 |
18 | should "get new model profile form" do
19 | get :new
20 | assert_response :success
21 | assert_template :new
22 | assert_not_nil assigns(:model_profile)
23 | end
24 |
25 | should "create model profile with valid attributes" do
26 | assert_difference('AiHelperModelProfile.count', 1) do
27 | post :create, params: { ai_helper_model_profile: { name: 'New Profile', access_key: 'new_key', llm_type: "OpenAI", llm_model: "model" } }
28 | end
29 | assert_redirected_to ai_helper_setting_path
30 | end
31 |
32 | should "not create model profile with invalid attributes" do
33 | assert_no_difference('AiHelperModelProfile.count') do
34 | post :create, params: { ai_helper_model_profile: { name: '', access_key: '' } }
35 | end
36 | assert_response :success
37 | assert_template :new
38 | end
39 |
40 | should "get edit model profile form" do
41 | get :edit, params: { id: @model_profile.id }
42 | assert_response :success
43 | assert_template :edit
44 | assert_not_nil assigns(:model_profile)
45 | end
46 |
47 | should "update model profile with valid attributes" do
48 | patch :update, params: { id: @model_profile.id, ai_helper_model_profile: { name: 'Updated Profile' } }
49 | assert_redirected_to ai_helper_setting_path
50 | @model_profile.reload
51 | assert_equal 'Updated Profile', @model_profile.name
52 | end
53 |
54 | should "not update model profile with invalid attributes" do
55 | patch :update, params: { id: @model_profile.id, ai_helper_model_profile: { name: '' } }
56 | assert_response :success
57 | assert_template :edit
58 | @model_profile.reload
59 | assert_not_equal '', @model_profile.name
60 | end
61 |
62 | should "destroy model profile" do
63 | assert_difference('AiHelperModelProfile.count', -1) do
64 | delete :destroy, params: { id: @model_profile.id }
65 | end
66 | assert_redirected_to ai_helper_setting_path
67 | end
68 |
69 | should "handle destroy for non-existent model profile" do
70 | assert_no_difference('AiHelperModelProfile.count') do
71 | delete :destroy, params: { id: 9999 } # Non-existent ID
72 | end
73 | assert_response :not_found
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/functional/ai_helper_settings_controller_test.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 |
3 | class AiHelperSettingsControllerTest < ActionController::TestCase
4 | setup do
5 | AiHelperSetting.delete_all
6 | AiHelperModelProfile.delete_all
7 | @request.session[:user_id] = 1 # Assuming user with ID 1 is an admin
8 |
9 | @model_profile = AiHelperModelProfile.create!(name: 'Test Profile', access_key: 'test_key', llm_type: "OpenAI", llm_model: "gpt-3.5-turbo")
10 | @model_profile.reload
11 | @ai_helper_setting = AiHelperSetting.find_or_create
12 | end
13 |
14 | should "get index" do
15 | get :index
16 | assert_response :success
17 | assert_template :index
18 | assert_not_nil assigns(:setting)
19 | assert_not_nil assigns(:model_profiles)
20 | end
21 |
22 | should "update setting with valid attributes" do
23 | post :update, params: { ai_helper_setting: { model_profile_id: @model_profile.id } }
24 | assert_redirected_to action: :index
25 | @ai_helper_setting.reload
26 | assert_equal @model_profile.id, @ai_helper_setting.model_profile_id
27 | end
28 |
29 | should "not update setting with invalid attributes" do
30 | post :update, params: { id: @ai_helper_setting, ai_helper_setting: { some_attribute: nil } }
31 | assert_response :redirect
32 | assert_not_nil assigns(:setting)
33 | assert_not_nil assigns(:model_profiles)
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/test/model_factory.rb:
--------------------------------------------------------------------------------
1 | require "factory_bot"
2 |
3 | FactoryBot::SyntaxRunner.class_eval do
4 | include ActionDispatch::TestProcess
5 | include ActiveSupport::Testing::FileFixtures
6 | end
7 |
8 | FactoryBot.define do
9 | factory :project do
10 | sequence(:name) { |n| "Project #{n}" }
11 | sequence(:identifier) { |n| "project-#{n}" }
12 | description { "Project description" }
13 | homepage { "http://example.com" }
14 | is_public { true }
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/test/redmine_ai_helper_test_repo.git.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/haru/redmine_ai_helper/9c80d67ab87508815c25762eeb9e8153e29fcb73/test/redmine_ai_helper_test_repo.git.tgz
--------------------------------------------------------------------------------
/test/test_config.json:
--------------------------------------------------------------------------------
1 | {
2 | "mcpServers": {
3 | "slack": {
4 | "command": "npx",
5 | "args": [
6 | "-y",
7 | "@modelcontextprotocol/server-slack"
8 | ],
9 | "env": {
10 | "SLACK_BOT_TOKEN": "xoxb-your-bot-token",
11 | "SLACK_TEAM_ID": "T01234567",
12 | "SLACK_CHANNEL_IDS": "C01234567, C76543210"
13 | }
14 | },
15 | "filesystem": {
16 | "command": "npx",
17 | "args": [
18 | "-y",
19 | "@modelcontextprotocol/server-filesystem",
20 | "/tmp"
21 | ]
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # Load the Redmine helper
2 |
3 | require "simplecov"
4 | require "simplecov-cobertura"
5 |
6 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
7 | SimpleCov.formatter = SimpleCov::Formatter::CoberturaFormatter,
8 | SimpleCov::Formatter::HTMLFormatter
9 | # Coveralls::SimpleCov::Formatter
10 | ]
11 |
12 | SimpleCov.start do
13 | root File.expand_path(File.dirname(__FILE__) + "/..")
14 | add_filter "/test/"
15 | add_filter "lib/tasks"
16 |
17 | add_group "Controllers", "app/controllers"
18 | add_group "Models", "app/models"
19 | add_group "Helpers", "app/helpers"
20 |
21 | add_group "Plugin Features", "lib/redmine_ai_helper"
22 | end
23 |
24 | require File.expand_path(File.dirname(__FILE__) + "/../../../test/test_helper")
25 |
26 | require File.expand_path(File.dirname(__FILE__) + "/model_factory")
27 |
28 | # このファイルと同じフォルダにあるmodel_factory.rbを読み込む
29 | require_relative "./model_factory"
30 |
31 | AiHelperModelProfile.delete_all
32 | profile = AiHelperModelProfile.create!(
33 | name: "Test Profile",
34 | llm_type: "OpenAI",
35 | llm_model: "gpt-3.5-turbo",
36 | access_key: "test_key",
37 | organization_id: "test_org_id",
38 | base_uri: "https://api.openai.com/v1",
39 | )
40 |
41 | setting = AiHelperSetting.find_or_create
42 | setting.model_profile_id = profile.id
43 | setting.additional_instructions = "This is a test system prompt."
44 | setting.save!
45 |
--------------------------------------------------------------------------------
/test/unit/ai_helper_helper_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../test_helper", __FILE__)
2 |
3 | class AiHelperHelperTest < ActiveSupport::TestCase
4 | include AiHelperHelper
5 |
6 | def test_md_to_html
7 | markdown_text = "# Hello World\nThis is a test."
8 | expected_html = "Hello World
\nThis is a test.
"
9 |
10 | assert_equal expected_html, md_to_html(markdown_text)
11 | end
12 |
13 | def test_md_to_html_with_invalid_characters
14 | markdown_text = "# Hello World\xC2\nThis is a test."
15 | expected_html = "Hello World
\nThis is a test.
"
16 |
17 | assert_equal expected_html, md_to_html(markdown_text)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/test/unit/ai_helper_summary_cache_test.rb:
--------------------------------------------------------------------------------
1 | require_relative '../test_helper'
2 |
3 | class AiHelperSummaryCacheTest < ActiveSupport::TestCase
4 |
5 | # Replace this with your real tests.
6 | def test_truth
7 | assert true
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/unit/base_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../test_helper", __FILE__)
2 |
3 | class BaseToolsTest < ActiveSupport::TestCase
4 |
5 | def setup
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/test/unit/chat_room_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../test_helper", __FILE__)
2 | require "redmine_ai_helper/chat_room"
3 |
4 | class RedmineAiHelper::ChatRoomTest < ActiveSupport::TestCase
5 | context "ChatRoom" do
6 | setup do
7 | @goal = "Complete the project successfully"
8 | @chat_room = RedmineAiHelper::ChatRoom.new(@goal)
9 | @mock_agent = mock("Agent")
10 | @mock_agent.stubs(:role).returns("mock_agent")
11 | @mock_agent.stubs(:perform_task).returns("Task completed")
12 | @mock_agent.stubs(:add_message).returns(nil)
13 | end
14 |
15 | should "initialize with goal" do
16 | assert_equal @goal, @chat_room.goal
17 | assert_equal 0, @chat_room.messages.size
18 | end
19 |
20 | should "add agent" do
21 | @chat_room.add_agent(@mock_agent)
22 | assert_includes @chat_room.agents, @mock_agent
23 | end
24 |
25 | should "add message" do
26 | @chat_room.add_message("user", "leader", "Test message", "all")
27 | assert_equal 1, @chat_room.messages.size
28 | assert_match "Test message", @chat_room.messages.last[:content]
29 | end
30 |
31 | should "get agent by role" do
32 | @chat_room.add_agent(@mock_agent)
33 | agent = @chat_room.get_agent("mock_agent")
34 | assert_equal @mock_agent, agent
35 | end
36 |
37 | should "send task to agent and receive response" do
38 | @chat_room.add_agent(@mock_agent)
39 | response = @chat_room.send_task("leader", "mock_agent", "Perform this task")
40 | assert_equal "Task completed", response
41 | assert_equal 2, @chat_room.messages.size
42 | assert_match "Perform this task", @chat_room.messages[-2][:content]
43 | assert_match "Task completed", @chat_room.messages.last[:content]
44 | end
45 |
46 | should "raise error if agent not found" do
47 | error = assert_raises(RuntimeError) do
48 | @chat_room.send_task("leader", "non_existent_agent", "Perform this task")
49 | end
50 | assert_match "Agent not found: non_existent_agent", error.message
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/test/unit/langfuse_util/anthropic_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class RedmineAiHelper::LangfuseUtil::AnthropicTest < ActiveSupport::TestCase
4 | setup do
5 | # Save original :chat method if defined
6 |
7 | Langchain::LLM::Anthropic.class_eval do
8 | alias_method :__original_chat, :chat
9 | end
10 |
11 | Langchain::LLM::Anthropic.class_eval do
12 | define_method(:chat) do |*args, **kwargs|
13 | # Stubbed parent result for testing
14 | DummyResponse.new
15 | end
16 | end
17 | @client = RedmineAiHelper::LangfuseUtil::Anthropic.new(
18 | api_key: "test_key",
19 | )
20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test")
21 | langfuse.stubs(:enabled?).returns(true)
22 | @client.langfuse = langfuse
23 | @client.langfuse.create_span(name: "test_span", input: "test_input")
24 | @client.langfuse.create_span(name: "test_span", input: "test_input")
25 | end
26 |
27 | teardown do
28 | Langchain::LLM::Anthropic.class_eval do
29 | remove_method :chat
30 | alias_method :chat, :__original_chat
31 | remove_method :__original_chat
32 | end
33 | end
34 |
35 | context "chat" do
36 | should "create an observation with correct parameters" do
37 | messages = [{ role: "user", content: "Test input" }]
38 |
39 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5)
40 | assert answer
41 | end
42 | end
43 |
44 | class DummyResponse
45 | def chat_completion(*args)
46 | { "choices" => [{ "message" => { "content" => "answer" } }] }
47 | end
48 |
49 | def prompt_tokens
50 | 10
51 | end
52 |
53 | def completion_tokens
54 | 5
55 | end
56 |
57 | def total_tokens
58 | prompt_tokens + completion_tokens
59 | end
60 | end
61 |
62 | class DummyHttp
63 | attr_accessor :use_ssl, :read_timeout, :open_timeout
64 |
65 | def request(*args)
66 | return Response.new
67 | end
68 |
69 | class Response
70 | def body
71 | json = { "choices" => [{ "message" => { "content" => "answer" } }] }
72 | JSON.pretty_generate(json)
73 | end
74 |
75 | def code
76 | "200"
77 | end
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/unit/langfuse_util/azure_open_ai_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class RedmineAiHelper::LangfuseUtil::AzureOpenAiTest < ActiveSupport::TestCase
4 | setup do
5 | # Save original :chat method if defined
6 |
7 | Langchain::LLM::Azure.class_eval do
8 | alias_method :__original_chat, :chat
9 | end
10 |
11 | Langchain::LLM::Azure.class_eval do
12 | define_method(:chat) do |*args, **kwargs|
13 | # Stubbed parent result for testing
14 | DummyResponse.new
15 | end
16 | end
17 | @client = RedmineAiHelper::LangfuseUtil::AzureOpenAi.new(
18 | api_key: "test_key",
19 | )
20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test")
21 | langfuse.stubs(:enabled?).returns(true)
22 | @client.langfuse = langfuse
23 | @client.langfuse.create_span(name: "test_span", input: "test_input")
24 | end
25 |
26 | teardown do
27 | Langchain::LLM::Azure.class_eval do
28 | remove_method :chat
29 | alias_method :chat, :__original_chat
30 | remove_method :__original_chat
31 | end
32 | end
33 |
34 | context "chat" do
35 | should "create an observation with correct parameters" do
36 | messages = [{ role: "user", content: "Test input" }]
37 |
38 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5)
39 | assert answer
40 | end
41 | end
42 |
43 | class DummyResponse
44 | def chat_completion(*args)
45 | { "choices" => [{ "message" => { "content" => "answer" } }] }
46 | end
47 |
48 | def prompt_tokens
49 | 10
50 | end
51 |
52 | def completion_tokens
53 | 5
54 | end
55 |
56 | def total_tokens
57 | prompt_tokens + completion_tokens
58 | end
59 | end
60 |
61 | class DummyHttp
62 | attr_accessor :use_ssl, :read_timeout, :open_timeout
63 |
64 | def request(*args)
65 | return Response.new
66 | end
67 |
68 | class Response
69 | def body
70 | json = { "choices" => [{ "message" => { "content" => "answer" } }] }
71 | JSON.pretty_generate(json)
72 | end
73 |
74 | def code
75 | "200"
76 | end
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/test/unit/langfuse_util/gemini_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class RedmineAiHelper::LangfuseUtil::GeminiTest < ActiveSupport::TestCase
4 | context "Gemini" do
5 | setup do
6 | Langchain::LLM::GoogleGeminiResponse.stubs(:new).returns(DummyGeminiResponse.new)
7 | Net::HTTP.stubs(:new).returns(DummyHttp.new)
8 | Net::HTTP::Post.stubs(:new).returns(DummyHttpPost.new)
9 | @gemini = RedmineAiHelper::LangfuseUtil::Gemini.new(api_key: "test")
10 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test")
11 | langfuse.stubs(:enabled?).returns(true)
12 | @gemini.langfuse = langfuse
13 | @gemini.langfuse.create_span(name: "test_span", input: "test_input")
14 | end
15 |
16 | context "chat" do
17 | should "create an observation with correct parameters" do
18 | messages = [{ role: "user", content: "Test input" }]
19 |
20 | answer = @gemini.chat(messages: messages, model: "gemini-1.0", temperature: 0.5)
21 | assert answer
22 | end
23 | end
24 | end
25 |
26 | class DummyGeminiResponse
27 | def chat_completion(*args)
28 | { "choices" => [{ "message" => { "content" => "answer" } }] }
29 | end
30 |
31 | def prompt_tokens
32 | 10
33 | end
34 |
35 | def completion_tokens
36 | 5
37 | end
38 |
39 | def total_tokens
40 | endprompt_tokens + completion_tokens
41 | end
42 |
43 | def endprompt_tokens
44 | 10
45 | end
46 | end
47 |
48 | class DummyHttp
49 | attr_accessor :use_ssl, :read_timeout, :open_timeout
50 |
51 | def request(*args)
52 | return Response.new
53 | end
54 |
55 | # Dummy HTTP response class for testing
56 | class Response
57 | def body
58 | json = { "choices" => [{ "message" => { "content" => "answer" } }] }
59 | JSON.pretty_generate(json)
60 | end
61 |
62 | def code
63 | "200"
64 | end
65 | end
66 | end
67 |
68 | class DummyHttpPost
69 | attr_accessor :content_type, :body
70 | end
71 |
72 | class DummyLangfuse
73 | def observation(name:, input:, output:, model: nil, metadata: {})
74 | DummyObservation.new
75 | end
76 |
77 | def span(name:, input:)
78 | @current_span = DummyObservation.new
79 | end
80 |
81 | def flush
82 | # No-op for testing
83 | end
84 |
85 | def current_span
86 | nil
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/test/unit/langfuse_util/langfuse_wrapper_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "mocha/minitest"
3 |
4 | class RedmineAiHelper::LangfuseUtil::LangfuseWrapperTest < ActiveSupport::TestCase
5 | setup do
6 | RedmineAiHelper::Util::ConfigFile.stubs(:load_config).returns({
7 | langfuse: {
8 | public_key: "test_public_key",
9 | endpoint: nil,
10 | },
11 | })
12 | Langfuse::Client.stubs(:instance).returns(DummyClient.new)
13 |
14 | @langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test input")
15 | @langfuse.stubs(:enabled?).returns(true)
16 | end
17 |
18 | teardown do
19 | Langfuse.shutdown()
20 | end
21 |
22 | should "create a span with correct parameters" do
23 | @langfuse.create_span(name: "test_span", input: "test_input")
24 | assert @langfuse.current_span
25 | end
26 |
27 | should "find the current span" do
28 | span = @langfuse.create_span(name: "test_span", input: "test_input")
29 | assert_equal span, @langfuse.current_span
30 | assert @langfuse.finish_current_span(output: "test output")
31 | end
32 |
33 | context "SpanWrapper" do
34 | should "finish the span and return the span object" do
35 | span = @langfuse.create_span(name: "test_span", input: "test_input")
36 | assert span.finish(output: "end output")
37 | end
38 |
39 | should "create generation" do
40 | span = @langfuse.create_span(name: "test_span", input: "test_input")
41 | generation = span.create_generation(name: "aaa", messages: ["messages"], model: "test_model")
42 | assert generation
43 | assert generation
44 | end
45 | end
46 |
47 | context "GenerationWrapper" do
48 | should "finish the generation and return the generation object" do
49 | span = @langfuse.create_span(name: "test_span", input: "test_input")
50 | generation = span.create_generation(name: "test_gen", messages: ["message1", "message2"], model: "test_model")
51 | assert generation.finish(output: "test output", usage: { prompt_tokens: 10, completion_tokens: 5 })
52 | end
53 | end
54 |
55 | class DummyClient
56 | def trace(attr = {})
57 | Langfuse::Models::Trace.new(
58 | id: "test_trace_id",
59 | name: attr[:name],
60 | user_id: attr[:user_id],
61 | input: attr[:input],
62 | metadata: attr[:metadata],
63 | )
64 | end
65 |
66 | def shutdown(**args)
67 | true
68 | end
69 |
70 | def span(attr = {})
71 | Langfuse::Models::Span.new(
72 | id: "test_span_id",
73 | name: attr[:name],
74 | trace_id: 1,
75 | input: attr[:input],
76 | parent_span_id: attr[:parent_span_id],
77 | )
78 | end
79 |
80 | def generation(attr = {})
81 | Langfuse::Models::Generation.new(
82 | id: "test_generation_id",
83 | name: attr[:name],
84 | messages: attr[:messages],
85 | model: attr[:model],
86 | output: "test output",
87 | span_id: "test_span_id",
88 | )
89 | end
90 |
91 | def update_span(span)
92 | span
93 | end
94 |
95 | def update_generation(generation)
96 | generation
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/test/unit/langfuse_util/open_ai_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class RedmineAiHelper::LangfuseUtil::OpenAiTest < ActiveSupport::TestCase
4 | setup do
5 | # Save original :chat method if defined
6 |
7 | Langchain::LLM::OpenAI.class_eval do
8 | alias_method :__original_chat, :chat
9 | end
10 |
11 | Langchain::LLM::OpenAI.class_eval do
12 | define_method(:chat) do |*args, **kwargs|
13 | # Stubbed parent result for testing
14 | DummyResponse.new
15 | end
16 | end
17 | @client = RedmineAiHelper::LangfuseUtil::OpenAi.new(
18 | api_key: "test_key",
19 | )
20 | langfuse = RedmineAiHelper::LangfuseUtil::LangfuseWrapper.new(input: "test")
21 | langfuse.stubs(:enabled?).returns(true)
22 | @client.langfuse = langfuse
23 | @client.langfuse.create_span(name: "test_span", input: "test_input")
24 | @client.langfuse.create_span(name: "test_span", input: "test_input")
25 | end
26 |
27 | teardown do
28 | Langchain::LLM::OpenAI.class_eval do
29 | remove_method :chat
30 | alias_method :chat, :__original_chat
31 | remove_method :__original_chat
32 | end
33 | end
34 |
35 | context "chat" do
36 | should "create an observation with correct parameters" do
37 | messages = [{ role: "user", content: "Test input" }]
38 |
39 | answer = @client.chat(messages: messages, model: "gemini-1.0", temperature: 0.5)
40 | assert answer
41 | end
42 | end
43 |
44 | class DummyResponse
45 | def chat_completion(*args)
46 | { "choices" => [{ "message" => { "content" => "answer" } }] }
47 | end
48 |
49 | def prompt_tokens
50 | 10
51 | end
52 |
53 | def completion_tokens
54 | 5
55 | end
56 |
57 | def total_tokens
58 | prompt_tokens + completion_tokens
59 | end
60 | end
61 |
62 | class DummyHttp
63 | attr_accessor :use_ssl, :read_timeout, :open_timeout
64 |
65 | def request(*args)
66 | return Response.new
67 | end
68 |
69 | class Response
70 | def body
71 | json = { "choices" => [{ "message" => { "content" => "answer" } }] }
72 | JSON.pretty_generate(json)
73 | end
74 |
75 | def code
76 | "200"
77 | end
78 | end
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/unit/llm_client/anthropic_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/llm_client/anthropic_provider"
3 |
4 | class RedmineAiHelper::LlmClient::AnthropicProviderTest < ActiveSupport::TestCase
5 | context "AnthropicProvider" do
6 | setup do
7 | @provider = RedmineAiHelper::LlmClient::AnthropicProvider.new
8 | end
9 |
10 | should "generate a valid client" do
11 | Langchain::LLM::Anthropic.stubs(:new).returns(mock("AnthropicClient"))
12 | client = @provider.generate_client
13 | assert_not_nil client
14 | end
15 |
16 | should "raise an error if client generation fails" do
17 | Langchain::LLM::Anthropic.stubs(:new).returns(nil)
18 | assert_raises(RuntimeError, "Anthropic LLM Create Error") do
19 | @provider.generate_client
20 | end
21 | end
22 |
23 | should "create valid chat parameters" do
24 | system_prompt = { role: "system", content: "This is a system prompt" }
25 | messages = [{ role: "user", content: "Hello" }]
26 | chat_params = @provider.create_chat_param(system_prompt, messages)
27 |
28 | assert_equal messages, chat_params[:messages]
29 | assert_equal "This is a system prompt", chat_params[:system]
30 | end
31 |
32 | should "convert chunk correctly" do
33 | chunk = { "delta" => { "text" => "Test content" } }
34 | result = @provider.chunk_converter(chunk)
35 | assert_equal "Test content", result
36 | end
37 |
38 | should "return nil if chunk content is missing" do
39 | chunk = { "delta" => {} }
40 | result = @provider.chunk_converter(chunk)
41 | assert_nil result
42 | end
43 |
44 | should "reset assistant messages correctly" do
45 | assistant = mock("Assistant")
46 | assistant.expects(:clear_messages!).once
47 | assistant.expects(:instructions=).with("System instructions").once
48 | assistant.expects(:add_message).with(role: "user", content: "Hello").once
49 |
50 | system_prompt = "System instructions"
51 | messages = [{ role: "user", content: "Hello" }]
52 |
53 | @provider.reset_assistant_messages(
54 | assistant: assistant,
55 | system_prompt: system_prompt,
56 | messages: messages,
57 | )
58 | end
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/test/unit/llm_client/azure_open_ai_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/llm_client/open_ai_compatible_provider"
3 |
4 | class RedmineAiHelper::LlmClient::AzureOpenAiProviderTest < ActiveSupport::TestCase
5 | context "AzureOpenAiProvider" do
6 | setup do
7 | @setting = AiHelperSetting.find_or_create
8 | @openai_mock = AzureOpenAiProviderTest::DummyOpenAIClient.new
9 | Langchain::LLM::OpenAI.stubs(:new).returns(@openai_mock)
10 | @provider = RedmineAiHelper::LlmClient::AzureOpenAiProvider.new
11 |
12 | @original_model_profile = @setting.model_profile
13 | end
14 |
15 | teardown do
16 | @setting.model_profile = @original_model_profile
17 | @setting.save!
18 | end
19 |
20 | should "generate a valid client" do
21 | client = @provider.generate_client
22 | assert_not_nil client
23 | end
24 |
25 | should "raise an error if model profile is missing" do
26 | @setting.model_profile = nil
27 | @setting.save!
28 | assert_raises(RuntimeError, "Model Profile not found") do
29 | @provider.generate_client
30 | end
31 | end
32 | end
33 |
34 | context "OpenAiCompatible" do
35 | setup do
36 | @openai_mock = OpenAiCompatibleTest::DummyOpenAIClient.new
37 | ::OpenAI::Client.stubs(:new).returns(@openai_mock)
38 | # Langchain::LLM::OpenAI.stubs(:new).returns(@openai_mock)
39 | @client = RedmineAiHelper::LlmClient::OpenAiCompatibleProvider::OpenAiCompatible.new(
40 | api_key: "test_key",
41 | )
42 | end
43 |
44 | should "return a valid response for chat" do
45 | params = {
46 | messages: [{ role: "user", content: "Hello" }],
47 | }
48 | response = @client.chat(params).chat_completion
49 | assert_not_nil response
50 | end
51 | end
52 | end
53 |
54 | module AzureOpenAiProviderTest
55 | class DummyOpenAIClient
56 | def chat(params = {}, &block)
57 | messages = params[:parameters][:messages] || []
58 | # puts "Messages: #{messages.inspect}"
59 | message = messages.first[:content]
60 | # puts "Message content: #{message}"
61 |
62 | json_content = {
63 | tool_calls: [
64 | {
65 | id: "call_rCE6Kk94VZZyUIrIlrZSH0Cr",
66 | type: "function",
67 | function: {
68 | name: "weather_tool__weather",
69 | arguments: "{\"location\": \"Tokyo\", \"date\": \"2023-10-01\"}",
70 | },
71 | },
72 | ],
73 | }
74 | content = JSON.pretty_generate(json_content)
75 |
76 | if message == "Hello"
77 | content = "This is a dummy response for testing."
78 | end
79 |
80 | response = {
81 | "choices" => [
82 | {
83 | "message" => {
84 | "role" => "assistant",
85 | "content" => content,
86 | },
87 | },
88 | ],
89 | }
90 | block.call(response) if block_given?
91 | response
92 | end
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/test/unit/llm_client/base_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/llm_client/base_provider"
3 |
4 | class RedmineAiHelper::LlmClient::BaseProviderTest < ActiveSupport::TestCase
5 | context "BaseProvider" do
6 | setup do
7 | @provider = RedmineAiHelper::LlmClient::BaseProvider.new
8 | end
9 |
10 | should "raise NotImplementedError when generate_client is called" do
11 | assert_raises(NotImplementedError, "LLM provider not found") do
12 | @provider.generate_client
13 | end
14 | end
15 |
16 | should "create chat parameters correctly" do
17 | system_prompt = { content: "This is a system prompt" }
18 | messages = [
19 | { role: "user", content: "Hello" },
20 | { role: "assistant", content: "Hi there!" },
21 | ]
22 | chat_params = @provider.create_chat_param(system_prompt, messages)
23 |
24 | assert_equal 3, chat_params[:messages].size
25 | assert_equal "This is a system prompt", chat_params[:messages][0][:content]
26 | assert_equal "Hello", chat_params[:messages][1][:content]
27 | assert_equal "Hi there!", chat_params[:messages][2][:content]
28 | end
29 |
30 | should "convert chunk correctly" do
31 | chunk = { "delta" => { "content" => "Test content" } }
32 | result = @provider.chunk_converter(chunk)
33 | assert_equal "Test content", result
34 | end
35 |
36 | should "return nil if chunk content is missing" do
37 | chunk = { "delta" => {} }
38 | result = @provider.chunk_converter(chunk)
39 | assert_nil result
40 | end
41 |
42 | should "reset assistant messages correctly" do
43 | mock_assistant = mock("Assistant")
44 | mock_assistant.expects(:clear_messages!).once
45 | mock_assistant.expects(:add_message).with(role: "system", content: "System instructions").once
46 | mock_assistant.expects(:add_message).with(role: "user", content: "Hello").once
47 | mock_assistant.expects(:add_message).with(role: "assistant", content: "Hi there!").once
48 |
49 | system_prompt = { content: "System instructions" }
50 | messages = [
51 | { role: "user", content: "Hello" },
52 | { role: "assistant", content: "Hi there!" },
53 | ]
54 |
55 | @provider.reset_assistant_messages(
56 | assistant: mock_assistant,
57 | system_prompt: system_prompt,
58 | messages: messages,
59 | )
60 | end
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/test/unit/llm_client/gemini_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/llm_client/gemini_provider"
3 |
4 | class RedmineAiHelper::LlmClient::GeminiProviderTest < ActiveSupport::TestCase
5 | context "GeminiProvider" do
6 | setup do
7 | @provider = RedmineAiHelper::LlmClient::GeminiProvider.new
8 | end
9 |
10 | should "generate a valid client" do
11 | Langchain::LLM::GoogleGemini.stubs(:new).returns(mock("GeminiClient"))
12 | client = @provider.generate_client
13 | assert_not_nil client
14 | end
15 |
16 | should "raise an error if client generation fails" do
17 | Langchain::LLM::GoogleGemini.stubs(:new).returns(nil)
18 | assert_raises(RuntimeError, "Gemini LLM Create Error") do
19 | @provider.generate_client
20 | end
21 | end
22 |
23 | should "create valid chat parameters" do
24 | system_prompt = { content: "This is a system prompt" }
25 | messages = [
26 | { role: "user", content: "Hello" },
27 | { role: "assistant", content: "Hi there!" },
28 | ]
29 | chat_params = @provider.create_chat_param(system_prompt, messages)
30 |
31 | assert_equal 2, chat_params[:messages].size
32 | assert_equal "user", chat_params[:messages][0][:role]
33 | assert_equal "Hello", chat_params[:messages][0][:parts][0][:text]
34 | assert_equal "assistant", chat_params[:messages][1][:role]
35 | assert_equal "Hi there!", chat_params[:messages][1][:parts][0][:text]
36 | assert_equal "This is a system prompt", chat_params[:system]
37 | end
38 |
39 | should "reset assistant messages correctly" do
40 | assistant = mock("Assistant")
41 | assistant.expects(:clear_messages!).once
42 | assistant.expects(:instructions=).with("System instructions").once
43 | assistant.expects(:add_message).with(role: "user", content: "Hello").once
44 | assistant.expects(:add_message).with(role: "model", content: "Hi there!").once
45 |
46 | system_prompt = "System instructions"
47 | messages = [
48 | { role: "user", content: "Hello" },
49 | { role: "assistant", content: "Hi there!" },
50 | ]
51 |
52 | @provider.reset_assistant_messages(
53 | assistant: assistant,
54 | system_prompt: system_prompt,
55 | messages: messages,
56 | )
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/test/unit/llm_provider_test.rb:
--------------------------------------------------------------------------------
1 | # filepath: lib/redmine_ai_helper/llm_provider_test.rb
2 | require File.expand_path("../../test_helper", __FILE__)
3 |
4 | class LlmProviderTest < ActiveSupport::TestCase
5 | context "LlmProvider" do
6 | setup do
7 | @llm_provider = RedmineAiHelper::LlmProvider
8 | end
9 |
10 | should "return correct options for select" do
11 | expected_options = [
12 | ["OpenAI", "OpenAI"],
13 | ["OpenAI Compatible(Experimental)", "OpenAICompatible"],
14 | ["Gemini(Experimental)", "Gemini"],
15 | ["Anthropic(Experimental)", "Anthropic"],
16 | ["Azure OpenAI(Experimental)", "AzureOpenAi"],
17 | ]
18 | assert_equal expected_options, @llm_provider.option_for_select
19 | end
20 |
21 | context "get_llm_provider" do
22 | setup do
23 | @setting = AiHelperSetting.find_or_create
24 | end
25 | teardown do
26 | @setting.model_profile.llm_type = "OpenAI"
27 | @setting.model_profile.save!
28 | end
29 |
30 | should "return OpenAiProvider when OpenAI is selected" do
31 | @setting.model_profile.llm_type = "OpenAI"
32 | @setting.model_profile.save!
33 |
34 | provider = @llm_provider.get_llm_provider
35 | assert_instance_of RedmineAiHelper::LlmClient::OpenAiProvider, provider
36 | end
37 |
38 | should "return GeminiProvider when Gemini is selected" do
39 | @setting.model_profile.llm_type = "Gemini"
40 | @setting.model_profile.save!
41 | provider = @llm_provider.get_llm_provider
42 | assert_instance_of RedmineAiHelper::LlmClient::GeminiProvider, provider
43 | end
44 |
45 | should "raise NotImplementedError when Anthropic is selected" do
46 | @setting.model_profile.llm_type = "Anthropic"
47 | @setting.model_profile.save!
48 | provider = @llm_provider.get_llm_provider
49 | assert_instance_of RedmineAiHelper::LlmClient::AnthropicProvider, provider
50 | end
51 |
52 | should "raise NotImplementedError when an unknown LLM is selected" do
53 | @setting.model_profile.llm_type = "Unknown"
54 | @setting.model_profile.save!
55 | assert_raises(NotImplementedError) do
56 | @llm_provider.get_llm_provider
57 | end
58 | end
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/test/unit/logger_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../test_helper", __FILE__)
2 | require "redmine_ai_helper/logger"
3 |
4 | class LoggerTest < ActiveSupport::TestCase
5 | include RedmineAiHelper::Logger
6 |
7 | # def self.setup
8 | # remove_log
9 | # end
10 |
11 | # def setup
12 | # @logger = RedmineAiHelper::CustomLogger.instance
13 | # end
14 |
15 | # def test_debug_logging
16 | # message = "This is a debug message"
17 | # @logger.debug(message)
18 | # # assert_includes read_log, "DEBUG -- #{message}"
19 | # end
20 |
21 | # def test_info_logging
22 | # message = "This is an info message"
23 | # @logger.info(message)
24 | # assert_includes read_log, "INFO -- #{message}"
25 | # end
26 |
27 | # def test_warn_logging
28 | # message = "This is a warn message"
29 | # @logger.warn(message)
30 | # assert_includes read_log, "WARN -- #{message}"
31 | # end
32 |
33 | # def test_error_logging
34 | # message = "This is an error message"
35 | # @logger.error(message)
36 | # assert_includes read_log, "ERROR -- #{message}"
37 | # end
38 |
39 | # def test_log_level_setting
40 | # @logger.set_log_level("debug")
41 | # assert_equal ::Logger::DEBUG, @logger.instance_variable_get(:@logger).level
42 |
43 | # @logger.set_log_level("info")
44 | # assert_equal ::Logger::INFO, @logger.instance_variable_get(:@logger).level
45 |
46 | # @logger.set_log_level("warn")
47 | # assert_equal ::Logger::WARN, @logger.instance_variable_get(:@logger).level
48 |
49 | # @logger.set_log_level("error")
50 | # assert_equal ::Logger::ERROR, @logger.instance_variable_get(:@logger).level
51 | # end
52 |
53 | # private
54 |
55 | # def read_log
56 | # log_file_path = Rails.root.join("log", "ai_helper.log")
57 | # File.read(log_file_path)
58 | # end
59 |
60 | # def remove_log
61 | # log_file_path = Rails.root.join("log", "ai_helper.log")
62 | # File.delete(log_file_path) if File.exist?(log_file_path)
63 | # end
64 | end
65 |
--------------------------------------------------------------------------------
/test/unit/models/ai_helper_conversation_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class AiHelperConversationTest < ActiveSupport::TestCase
4 | def setup
5 | @ai_helper = AiHelperConversation.new
6 | end
7 |
8 |
9 | def test_ai_helper_initialization
10 | assert_not_nil @ai_helper
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/test/unit/models/ai_helper_message_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class AiHelperMessageTest < ActiveSupport::TestCase
4 | def setup
5 | @message = AiHelperMessage.new
6 | end
7 |
8 | def test_message_initialization
9 | assert_not_nil @message
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/test/unit/models/ai_helper_model_profile_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../test_helper"
2 |
3 | class AiHelperModelProfileTest < ActiveSupport::TestCase
4 |
5 | # Replace this with your real tests.
6 | def test_truth
7 | assert true
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/unit/models/ai_helper_setting_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../test_helper"
2 |
3 | class AiHelperSettingTest < ActiveSupport::TestCase
4 | # Setup method to create a default setting before each test
5 | setup do
6 | AiHelperSetting.destroy_all
7 | @setting = AiHelperSetting.setting
8 | model_profile = AiHelperModelProfile.create!(
9 | name: "Default Model Profile",
10 | llm_model: "gpt-3.5-turbo",
11 | access_key: "test_access_key",
12 | temperature: 0.7,
13 | base_uri: "https://api.openai.com/v1",
14 | max_tokens: 2048,
15 | llm_type: RedmineAiHelper::LlmProvider::LLM_OPENAI_COMPATIBLE,
16 | )
17 | @setting.model_profile = model_profile
18 | end
19 |
20 | teardown do
21 | AiHelperSetting.destroy_all
22 | end
23 |
24 | context "max_tokens" do
25 | should "return nil if not set" do
26 | @setting.model_profile.max_tokens = nil
27 | @setting.model_profile.save!
28 | assert !@setting.max_tokens
29 | end
30 |
31 | should "return nil if max_tokens is 0" do
32 | @setting.model_profile.max_tokens = 0
33 | @setting.model_profile.save!
34 | assert !@setting.max_tokens
35 | end
36 |
37 | should "return value if max_token is setted" do
38 | @setting.model_profile.max_tokens = 1000
39 | @setting.model_profile.save!
40 | assert_equal 1000, @setting.max_tokens
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/unit/models/ai_helper_vector_data_test.rb:
--------------------------------------------------------------------------------
1 | require_relative "../../test_helper"
2 |
3 | class AiHelperVectorDataTest < ActiveSupport::TestCase
4 |
5 | # Replace this with your real tests.
6 | def test_truth
7 | assert true
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/test/unit/tool_response_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../test_helper", __FILE__)
2 |
3 |
4 | class ToolResponseTest < ActiveSupport::TestCase
5 | def test_initialize_with_success_status
6 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value")
7 | assert_equal RedmineAiHelper::ToolResponse::STATUS_SUCCESS, response.status
8 | assert_equal "Test value", response.value
9 | assert_nil response.error
10 | end
11 |
12 | def test_initialize_with_error_status
13 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_ERROR, error: "Test error")
14 | assert_equal RedmineAiHelper::ToolResponse::STATUS_ERROR, response.status
15 | assert_nil response.value
16 | assert_equal "Test error", response.error
17 | end
18 |
19 | def test_to_json
20 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value")
21 | expected_json = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }.to_json
22 | assert_equal expected_json, response.to_json
23 | end
24 |
25 | def test_to_hash
26 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value")
27 | expected_hash = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }
28 | assert_equal expected_hash, response.to_hash
29 | end
30 |
31 | def test_to_h
32 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value")
33 | expected_hash = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }
34 | assert_equal expected_hash, response.to_h
35 | end
36 |
37 | def test_to_s
38 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value")
39 | expected_string = { status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS, value: "Test value", error: nil }.to_s
40 | assert_equal expected_string, response.to_s
41 | end
42 |
43 | def test_is_success?
44 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_SUCCESS)
45 | assert response.is_success?
46 | end
47 |
48 | def test_is_error?
49 | response = RedmineAiHelper::ToolResponse.new(status: RedmineAiHelper::ToolResponse::STATUS_ERROR)
50 | assert response.is_error?
51 | end
52 |
53 | def test_create_error
54 | response = RedmineAiHelper::ToolResponse.create_error("Test error")
55 | assert_equal RedmineAiHelper::ToolResponse::STATUS_ERROR, response.status
56 | assert_equal "Test error", response.error
57 | assert_nil response.value
58 | end
59 |
60 | def test_create_success
61 | response = RedmineAiHelper::ToolResponse.create_success("Test value")
62 | assert_equal RedmineAiHelper::ToolResponse::STATUS_SUCCESS, response.status
63 | assert_equal "Test value", response.value
64 | assert_nil response.error
65 | end
66 | end
67 |
--------------------------------------------------------------------------------
/test/unit/tools/board_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class BoardToolsTest < ActiveSupport::TestCase
4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :boards, :messages
5 |
6 | def setup
7 | @provider = RedmineAiHelper::Tools::BoardTools.new
8 | @project = Project.find(1)
9 | @board = @project.boards.first
10 | @message = @board.messages.first
11 | end
12 |
13 | def test_list_boards_success
14 | response = @provider.list_boards(project_id: @project.id)
15 | assert_equal @project.boards.count, response.size
16 | end
17 |
18 | def test_list_boards_project_not_found
19 | assert_raises(RuntimeError, "Project not found") do
20 | @provider.list_boards(project_id: 999)
21 | end
22 | end
23 |
24 | def test_board_info_success
25 | response = @provider.board_info(board_id: @board.id)
26 | assert_equal @board.id, response[:id]
27 | assert_equal @board.name, response[:name]
28 | end
29 |
30 | def test_board_info_not_found
31 | assert_raises(RuntimeError, "Board not found") do
32 | @provider.board_info(board_id: 999)
33 | end
34 | end
35 |
36 | def test_read_message_success
37 | response = @provider.read_message(message_id: @message.id)
38 | assert_equal @message.id, response[:id]
39 | assert_equal @message.content, response[:content]
40 | end
41 |
42 | def test_read_message_not_found
43 | assert_raises(RuntimeError, "Message not found") do
44 | @provider.read_message(message_id: 999)
45 | end
46 | end
47 |
48 | def test_generate_board_url
49 | response = @provider.generate_board_url(board_id: @board.id)
50 | assert_match(%r{boards/\d+}, response[:url])
51 | end
52 |
53 | def test_generate_board_url_no_board_id
54 | assert_raises(ArgumentError) do
55 | @provider.generate_board_url(project_id: @project.id)
56 | end
57 | end
58 |
59 | def test_generate_message_url_no_message_id
60 | assert_raises(ArgumentError) do
61 | @provider.generate_message_url(board_id: @board.id)
62 | end
63 | end
64 |
65 | def test_generate_message_url
66 | response = @provider.generate_message_url(message_id: @message.id)
67 | assert_match(%r{/boards/\d+/topics/\d+}, response[:url])
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/unit/tools/mcp_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/tools/mcp_tools"
3 |
4 | class RedmineAiHelper::Tools::McpToolsTest < ActiveSupport::TestCase
5 | context "McpTools" do
6 | setup do
7 | @tools = RedmineAiHelper::Tools::McpTools.new
8 | end
9 |
10 | should "return empty command array" do
11 | assert_equal [], RedmineAiHelper::Tools::McpTools.command_array
12 | end
13 |
14 | should "return empty env hash" do
15 | assert_equal({}, RedmineAiHelper::Tools::McpTools.env_hash)
16 | end
17 |
18 | context "method_missing" do
19 | setup do
20 | @command_json = {
21 | "command" => "npx",
22 | "args" => [
23 | "-y",
24 | "@modelcontextprotocol/server-filesystem",
25 | "/tmp",
26 | ],
27 | }
28 | tool_class = RedmineAiHelper::Tools::McpTools.generate_tool_class(
29 | name: "filesystem2",
30 | json: @command_json,
31 | )
32 | @filesystem_tool = tool_class.new
33 | end
34 |
35 | should "raise ArgumentError if function not exist" do
36 | assert_raises ArgumentError do
37 | @filesystem_tool.non_existent_function
38 | end
39 | end
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/test/unit/tools/project_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class ProjectToolsTest < ActiveSupport::TestCase
4 | fixtures :projects, :projects_trackers, :trackers, :users, :repositories, :changesets, :changes, :issues, :issue_statuses, :enumerations, :issue_categories, :trackers
5 |
6 | def setup
7 | @provider = RedmineAiHelper::Tools::ProjectTools.new
8 | enabled_module = EnabledModule.new
9 | enabled_module.project_id = 1
10 | enabled_module.name = "ai_helper"
11 | enabled_module.save!
12 | User.current = User.find(1)
13 | end
14 |
15 | def test_list_projects
16 | enabled_module = EnabledModule.new
17 | enabled_module.project_id = 2
18 | enabled_module.name = "ai_helper"
19 | enabled_module.save!
20 |
21 | response = @provider.list_projects()
22 | assert_equal 2, response.size
23 | project1 = Project.find(1)
24 | project2 = Project.find(2)
25 | [project1, project2].each_with_index do |project, index|
26 | value = response[index]
27 | assert_equal project.id, value[:id]
28 | assert_equal project.name, value[:name]
29 | end
30 | end
31 |
32 | def test_read_project_by_id
33 | project = Project.find(1)
34 |
35 | response = @provider.read_project(project_id: project.id)
36 | assert_equal project.id, response[:id]
37 | assert_equal project.name, response[:name]
38 | end
39 |
40 | def test_read_project_by_name
41 | project = Project.find(1)
42 |
43 | response = @provider.read_project(project_name: project.name)
44 | assert_equal project.id, response[:id]
45 | assert_equal project.name, response[:name]
46 | end
47 |
48 | def test_read_project_by_identifier
49 | project = Project.find(1)
50 |
51 | response = @provider.read_project(project_identifier: project.identifier)
52 | assert_equal project.id, response[:id]
53 | assert_equal project.name, response[:name]
54 | end
55 |
56 | def test_read_project_not_found
57 | assert_raises(RuntimeError, "Project not found") do
58 | @provider.read_project(project_id: 999)
59 | end
60 | end
61 |
62 | def test_read_project_no_args
63 | assert_raises(RuntimeError, "No id or name or Identifier specified.") do
64 | @provider.read_project
65 | end
66 | end
67 |
68 | def test_project_members
69 | project = Project.find(1)
70 | members = project.members
71 |
72 | response = @provider.project_members(project_ids: [project.id])
73 | assert_equal members.size, response[:projects][0][:members].size
74 | assert_equal members.first.user_id, response[:projects][0][:members].first[:user_id]
75 | end
76 |
77 | def test_project_enabled_modules
78 | project = Project.find(1)
79 | enabled_modules = project.enabled_modules
80 |
81 | response = @provider.project_enabled_modules(project_id: project.id)
82 | assert_equal enabled_modules.size, response[:enabled_modules].size
83 | assert_equal enabled_modules.first.name, response[:enabled_modules].first[:name]
84 | end
85 |
86 | def test_list_project_activities
87 | assert_nothing_raised do
88 | project = Project.find(1)
89 | @provider.list_project_activities(project_id: project.id)
90 |
91 | author = User.find(1)
92 | @provider.list_project_activities(project_id: project.id, author_id: author.id)
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/test/unit/tools/system_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class SystemToolsTest < ActiveSupport::TestCase
4 | def setup
5 | @provider = RedmineAiHelper::Tools::SystemTools.new
6 | end
7 |
8 | def test_list_plugins
9 | response = @provider.list_plugins
10 |
11 | assert response[:plugins].any?
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/unit/tools/user_tools_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class UserToolsTest < ActiveSupport::TestCase
4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :custom_values, :groups_users, :members, :member_roles, :roles, :user_preferences
5 | include RedmineAiHelper::Tools
6 |
7 | context "UserTools" do
8 | setup do
9 | @provider = UserTools.new
10 | @users = User.where(status: User::STATUS_ACTIVE)
11 | end
12 |
13 | context "list users" do
14 | should "success by default" do
15 | result = @provider.list_users
16 | assert_equal @users.count, result[:total]
17 | assert_equal @users.map(&:id).sort, result[:users].map { |u| u[:id] }.sort
18 | end
19 |
20 | should "success with limit" do
21 | result = @provider.list_users(query: { limit: 5 })
22 | assert_equal 5, result[:users].size
23 | end
24 |
25 | should "success with status" do
26 | locked = User.where(status: User::STATUS_LOCKED)
27 | result = @provider.list_users(query: { status: "locked" })
28 | assert_equal locked.count, result[:users].size
29 | end
30 |
31 | should "success with date fields" do
32 | 2.times { |i| @users[i].last_login_on = (i + 1).days.ago; @users[i].save! }
33 | result = @provider.list_users(query: { date_fields: [{ field_name: "last_login_on", operator: ">=", value: 1.year.ago.to_s }] })
34 | assert_equal 2, result[:users].size
35 |
36 | @users.update_all(last_login_on: 1.days.ago)
37 | 3.times { |i| @users[i].update_attribute(:last_login_on, (i + 2).years.ago) }
38 | @users[4].last_login_on = nil
39 | @users.each { |u| u.save! }
40 | result = @provider.list_users(query: { date_fields: [{ field_name: "last_login_on", operator: "<=", value: 1.year.ago.to_s }] })
41 | assert_equal 4, result[:users].size
42 | end
43 |
44 | should "success with sort" do
45 | result = @provider.list_users(query: { sort: { field_name: "created_on", order: "asc" } })
46 | assert_equal @users.order(:created_on).map(&:id), result[:users].map { |u| u[:id] }
47 | end
48 | end
49 |
50 | context "find user" do
51 | should "find user by name" do
52 | result = @provider.find_user(name: "admin")
53 | assert_equal 1, result[:users].size
54 | assert_equal "admin", result[:users].first[:login]
55 | end
56 |
57 | should "find user by login" do
58 | result = @provider.find_user(name: "admin")
59 | assert_equal 1, result[:users].size
60 | assert_equal "admin", result[:users].first[:login]
61 | end
62 |
63 | should "return error if name is not provided" do
64 | assert_raises(ArgumentError) do
65 | @provider.find_user()
66 | end
67 | end
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/test/unit/tools/version_tool_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class VersionToolsTest < ActiveSupport::TestCase
4 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :boards, :messages
5 |
6 | def setup
7 | @provider = RedmineAiHelper::Tools::VersionTools.new
8 | @project = Project.find(1)
9 | @version = @project.versions.first
10 | end
11 |
12 | def test_list_versions_success
13 | response = @provider.list_versions(project_id: @project.id)
14 | assert_equal @project.versions.count, response.size
15 | end
16 |
17 | def test_list_versions_project_not_found
18 | assert_raises(RuntimeError, "Project not found") do
19 | @provider.list_versions(project_id: 999)
20 | end
21 | end
22 |
23 | def test_version_info_success
24 | response = @provider.version_info(version_ids: [@version.id])
25 | assert_equal @version.id, response.first[:id]
26 | assert_equal @version.name, response.first[:name]
27 | end
28 |
29 | def test_version_info_not_found
30 | assert_raises(RuntimeError, "Version not found") do
31 | @provider.version_info(version_ids: [999])
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/unit/tools/wiki_tool_provider_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class WikiToolsTest < ActiveSupport::TestCase
4 | fixtures :projects, :wikis, :wiki_pages, :users
5 |
6 | def setup
7 | @provider = RedmineAiHelper::Tools::WikiTools.new
8 | @project = Project.find(1)
9 | @wiki = @project.wiki
10 | @page = @wiki.pages.first
11 | end
12 |
13 | def test_read_wiki_page_success
14 | response = @provider.read_wiki_page(project_id: @project.id, title: @page.title)
15 | assert_equal @page.title, response[:title]
16 | end
17 |
18 | def test_read_wiki_page_not_found
19 | assert_raises(RuntimeError, "Page not found: title = Nonexistent Page") do
20 | @provider.read_wiki_page(project_id: @project.id, title: "Nonexistent Page")
21 | end
22 | end
23 |
24 | def test_list_wiki_pages
25 | response = @provider.list_wiki_pages(project_id: @project.id)
26 | assert_equal @wiki.pages.count, response.size
27 | end
28 |
29 | def test_generate_url_for_wiki_page
30 | response = @provider.generate_url_for_wiki_page(project_id: @project.id, title: @page.title)
31 | expected_url = "/projects/#{@project.identifier}/wiki/#{@page.title}"
32 | assert_equal expected_url, response[:url]
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/unit/util/config_file_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/util/config_file"
3 |
4 | class RedmineAiHelper::Util::ConfigFileTest < ActiveSupport::TestCase
5 | context "ConfigFile" do
6 | setup do
7 | @config_path = Rails.root.join("config", "ai_helper", "config.yml")
8 | end
9 |
10 | should "return an empty hash if the config file does not exist" do
11 | File.stubs(:exist?).with(@config_path).returns(false)
12 | config = RedmineAiHelper::Util::ConfigFile.load_config
13 | assert_equal({}, config)
14 | end
15 |
16 | should "load and symbolize keys from the config file" do
17 | mock_yaml = {
18 | "logger" => { "level" => "debug" },
19 | "langfuse" => { "public_key" => "test_key" },
20 | }
21 | File.stubs(:exist?).with(@config_path).returns(true)
22 | YAML.stubs(:load_file).with(@config_path).returns(mock_yaml)
23 |
24 | config = RedmineAiHelper::Util::ConfigFile.load_config
25 | expected_config = {
26 | logger: { level: "debug" },
27 | langfuse: { public_key: "test_key" },
28 | }
29 | assert_equal(expected_config, config)
30 | end
31 |
32 | should "return the correct config file path" do
33 | assert_equal @config_path, RedmineAiHelper::Util::ConfigFile.config_file_path
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/test/unit/util/issue_json_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/util/issue_json"
3 |
4 | class RedmineAiHelper::Util::IssueJsonTest < ActiveSupport::TestCase
5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :issue_categories, :versions, :custom_fields, :attachments, :changesets, :journals, :journal_details, :changes
6 |
7 | context "generate_issue_data" do
8 | setup do
9 | @issue = Issue.first
10 | @issue.assigned_to = User.find(2)
11 |
12 | @issue.status = IssueStatus.find(2)
13 | @issue.save!
14 | issue2 = Issue.find(2)
15 | issue2.parent = @issue
16 | issue2.save!
17 | @issue.reload
18 | changeset = Changeset.first
19 | changeset.issues << @issue
20 | changeset.save!
21 | attachment = Attachment.find(1)
22 | attachment.container = @issue
23 | attachment.save!
24 | @issue.reload
25 | @test_class = TestClass.new
26 | @issue.due_date = Date.today + 7
27 | end
28 |
29 | should "generate correct issue data" do
30 | issue_data = @test_class.generate_issue_data(@issue)
31 | # puts JSON.pretty_generate(issue_data)
32 |
33 | assert_equal @issue.id, issue_data[:id]
34 | assert_equal @issue.subject, issue_data[:subject]
35 | assert_equal @issue.project.id, issue_data[:project][:id]
36 | assert_equal @issue.project.name, issue_data[:project][:name]
37 | assert_equal @issue.tracker.id, issue_data[:tracker][:id]
38 | assert_equal @issue.tracker.name, issue_data[:tracker][:name]
39 | assert_equal @issue.status.id, issue_data[:status][:id]
40 | assert_equal @issue.status.name, issue_data[:status][:name]
41 | assert_equal @issue.priority.id, issue_data[:priority][:id]
42 | assert_equal @issue.priority.name, issue_data[:priority][:name]
43 | assert_equal @issue.author.id, issue_data[:author][:id]
44 | assert_equal @issue.assigned_to.id, issue_data[:assigned_to][:id]
45 | assert_equal @issue.description, issue_data[:description]
46 | assert_equal @issue.start_date, issue_data[:start_date]
47 | assert_equal @issue.due_date, issue_data[:due_date]
48 | assert_equal @issue.done_ratio, issue_data[:done_ratio]
49 | assert_equal @issue.is_private, issue_data[:is_private]
50 | assert_equal @issue.estimated_hours, issue_data[:estimated_hours]
51 | assert_equal @issue.created_on.to_s, issue_data[:created_on].to_s
52 | assert_equal @issue.updated_on.to_s, issue_data[:updated_on].to_s
53 | end
54 | end
55 |
56 | class TestClass < RedmineAiHelper::BaseTools
57 | # This class is used to test the IssueJson module
58 | # It includes the IssueJson module to access its methods
59 | include RedmineAiHelper::Util::IssueJson
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/test/unit/util/mcp_tools_loader_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/util/mcp_tools_loader"
3 |
4 | class RedmineAiHelper::Util::McpToolsLoaderTest < ActiveSupport::TestCase
5 | teardown do
6 | end
7 | context "McpToolsLoader" do
8 | teardown do
9 | # Clean up stubs
10 | RedmineAiHelper::Util::McpToolsLoader.instance_variable_set(:@list, nil)
11 | RedmineAiHelper::Util::McpToolsLoader.instance_variable_set(:@config_file, nil)
12 | end
13 | should "return config file path" do
14 | tools_loader = RedmineAiHelper::Util::McpToolsLoader.instance
15 | config_file = tools_loader.config_file
16 | assert_equal File.join(Rails.root, "config", "ai_helper", "config.json"), config_file
17 | end
18 | end
19 |
20 | context "McpToolsLoader with test_config" do
21 | setup do
22 | loader = RedmineAiHelper::Util::McpToolsLoader.instance
23 | loader.instance_variable_set(:@config_file, nil)
24 | loader.instance_variable_set(:@list, nil)
25 | end
26 |
27 | teardown do
28 | loader = RedmineAiHelper::Util::McpToolsLoader.instance
29 | loader.instance_variable_set(:@config_file, nil)
30 | loader.instance_variable_set(:@list, nil)
31 | end
32 |
33 | should "load tools from config file" do
34 | test_config_file = File.expand_path("../../../test_config.json", __FILE__)
35 |
36 | loader = RedmineAiHelper::Util::McpToolsLoader.instance
37 | loader.instance_variable_set(:@config_file, test_config_file)
38 | tools = RedmineAiHelper::Util::McpToolsLoader.load
39 |
40 | assert_not_nil tools, "tools should not be nil"
41 | assert tools.is_a?(Array), "tools should be an Array"
42 | assert_equal 2, tools.length, "tools count should be 1"
43 | assert_equal "McpSlack", tools[0].name, "First tool should be McpSlack"
44 | end
45 |
46 | should "return empty array if config file does not exist" do
47 | loader = RedmineAiHelper::Util::McpToolsLoader.instance
48 | loader.instance_variable_set(:@config_file, "non_existent_config.json")
49 |
50 | tools = RedmineAiHelper::Util::McpToolsLoader.load
51 |
52 | assert_equal [], tools, "tools should be an empty array when config file does not exist"
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/test/unit/vector/issue_vector_db_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/vector/issue_vector_db"
3 |
4 | class RedmineAiHelper::Vector::IssueVectorDbTest < ActiveSupport::TestCase
5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :journals
6 |
7 | context "IssueVectorDb" do
8 | setup do
9 | @issue = Issue.find(1)
10 | @issue.assigned_to = User.find(2)
11 | @vector_db = RedmineAiHelper::Vector::IssueVectorDb.new
12 | end
13 |
14 | should "return correct index name" do
15 | assert_equal "RedmineIssue", @vector_db.index_name
16 | end
17 |
18 | should "convert issue data to JSON text" do
19 | json_data = @vector_db.data_to_json(@issue)
20 |
21 | payload = json_data[:payload]
22 | assert_equal @issue.id, payload[:issue_id]
23 | assert_equal @issue.project.name, payload[:project_name]
24 | end
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/test/unit/vector/qdrant_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 |
3 | class RedmineAiHelper::Vector::QdrantTest < ActiveSupport::TestCase
4 | context "Qdrant" do
5 | setup do
6 | # Create a mock client and LLM
7 | @mock_client = mock("client")
8 | @mock_points = mock("points")
9 | @mock_llm = mock("llm")
10 | @mock_embedding = mock("embedding")
11 | @mock_llm.stubs(:embed).returns(@mock_embedding)
12 | @mock_embedding.stubs(:embedding).returns([0.1, 0.2, 0.3])
13 |
14 | # Stub Langchain::Vectorsearch::Qdrant initializer
15 | @qdrant = RedmineAiHelper::Vector::Qdrant.allocate
16 | @qdrant.instance_variable_set(:@client, @mock_client)
17 | @qdrant.instance_variable_set(:@llm, @mock_llm)
18 | @qdrant.instance_variable_set(:@index_name, "test_collection")
19 | end
20 |
21 | should "return empty array if client is nil" do
22 | @qdrant.instance_variable_set(:@client, nil)
23 | results = @qdrant.ask_with_filter(query: "test", k: 5, filter: nil)
24 | assert_equal [], results
25 | end
26 |
27 | should "call client.points.search with correct parameters and return payloads" do
28 | # Prepare mock response
29 | mock_response = {
30 | "result" => [
31 | { "payload" => { "id" => 1, "title" => "Issue 1" } },
32 | { "payload" => { "id" => 2, "title" => "Issue 2" } },
33 | ],
34 | }
35 | @mock_client.stubs(:points).returns(@mock_points)
36 | @mock_points.expects(:search).with(
37 | collection_name: "test_collection",
38 | limit: 2,
39 | vector: [0.1, 0.2, 0.3],
40 | with_payload: true,
41 | with_vector: true,
42 | filter: { foo: "bar" },
43 | ).returns(mock_response)
44 |
45 | results = @qdrant.ask_with_filter(query: "test", k: 2, filter: { foo: "bar" })
46 | assert_equal [{ "id" => 1, "title" => "Issue 1" }, { "id" => 2, "title" => "Issue 2" }], results
47 | end
48 |
49 | should "return empty array if result is nil" do
50 | @mock_client.stubs(:points).returns(@mock_points)
51 | @mock_points.stubs(:search).returns({ "result" => nil })
52 | results = @qdrant.ask_with_filter(query: "test", k: 1, filter: nil)
53 | assert_equal [], results
54 | end
55 |
56 | should "return empty array if result is empty" do
57 | @mock_client.stubs(:points).returns(@mock_points)
58 | @mock_points.stubs(:search).returns({ "result" => [] })
59 | results = @qdrant.ask_with_filter(query: "test", k: 1, filter: nil)
60 | assert_equal [], results
61 | end
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/unit/vector/wiki_vector_db_test.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path("../../../test_helper", __FILE__)
2 | require "redmine_ai_helper/vector/issue_vector_db"
3 |
4 | class RedmineAiHelper::Vector::WikiVectorDbTest < ActiveSupport::TestCase
5 | fixtures :projects, :issues, :issue_statuses, :trackers, :enumerations, :users, :journals, :wikis, :wiki_pages, :wiki_contents
6 |
7 | context "WikiVectorDb" do
8 | setup do
9 | @page = WikiPage.find(1)
10 | @vector_db = RedmineAiHelper::Vector::WikiVectorDb.new
11 | end
12 |
13 | should "return correct index name" do
14 | assert_equal "RedmineWiki", @vector_db.index_name
15 | end
16 |
17 | should "convert wiki data to JSON text" do
18 | json_data = @vector_db.data_to_json(@page)
19 |
20 | payload = json_data[:payload]
21 | assert_equal @page.id, payload[:wiki_id]
22 | assert_equal @page.project.name, payload[:project_name]
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------