1. サンプルアプリケーションについて
社内サポート業務を題材にしたサンプルアプリです。
テナントにサンプルアプリ用のメタデータとEntityデータを取り込むことで動作します。
2. アプリケーション全体像
2.1. 登場人物
このサンプルは2種類のユーザーを想定しています。ユーザーが属するグループ(ロール)によって、参照できるデータや実行できる操作が変わります。
-
質問者
問合せを起票するユーザーです。自分が所属するグループの問合せのみ参照でき、外部公開されたナレッジのみ閲覧できます。 -
回答者
サポート担当者です。すべての問合せに回答でき、すべてのナレッジを作成・編集・マージできます。
2.2. 2つの機能
-
問合せ管理
質問者と回答者がチャット形式でやり取りし、問合せを起票・回答・クローズします。
問合せは全文検索やタグ、ステータスで検索できます。 -
ナレッジ管理
回答者が、解決した問合せをもとにナレッジを作成・公開します。ナレッジは全文検索やタグで検索でき、公開範囲(内部/外部)を設定できます。
2.3. Enterprise Editionでの追加機能
Enterprise Editionのサンプルアプリでは、Community Editionの機能に加えて、AIを利用した機能を利用できます。
-
問合せ管理
問合せ要約・タグの自動付与・類似ナレッジ提案機能を提供します。 -
ナレッジ管理
問合せからナレッジドラフトの生成・類似ナレッジのマージ・RAGによる回答生成機能を提供します。
機能の詳細や導入方法については、Enterprise Edition機能の追加パッケージを参照してください。
3. 業務フロー
問合せの起票からクローズ、解決した問合せをもとにしたナレッジの作成・公開までの流れを、画面操作に沿って説明します。
ロールによって表示・操作できる内容が変わるため、質問者と回答者に分けて説明します。
| 以下のうち AI を利用する操作は Enterprise Edition の機能です。該当する操作には「EE only」を付しています。 |
3.1. 質問者の操作
サンプルアプリケーションのURL http(s)://[server]/[tenantContextPath]/km/index にアクセスしてログインすると、質問者用のトップ画面が表示されます。
問合せの起票
-
ナビゲーションの「新規問合せ」を開き、件名と最初の質問を入力します。ファイルを添付することもできます。
「送信」をクリックすると問合せが起票され、チャット形式の問合せ画面に遷移します。
関連ナレッジの検索
-
問合せ画面の右側のパネルで、質問に関連するナレッジをキーワードで検索できます。
-
類似ナレッジ検索: Enterprise Editionの場合、このパネルではRAGによる関連ナレッジを提案します。詳細はRAG 検索・類似ナレッジ提案を参照してください。
-
ナレッジ検索(RAG): ナビゲーションの「ナレッジ検索」では、自由文で質問を入力すると、AI がナレッジを検索して回答文を生成し、参照元のナレッジとともに表示します。質問者は外部公開ナレッジのみが検索対象です。
回答の確認とクローズ
-
回答者から回答が投稿されると、問合せは「回答済」ステータスに変わります。
質問者は追加で質問を投稿するか、解決した場合は「解決済み」、解決しなかった場合は「キャンセル」で問合せをクローズします。
-
クローズ後も、質問者は問合せを再オープンして再質問できます。
3.2. 回答者の操作
サンプルアプリケーションのURL http(s)://[server]/[tenantContextPath]/km/index にアクセスしてログインすると、回答者用のトップ画面が表示されます。
問合せの検索と回答
-
ナビゲーションの「問合せ一覧」で、問合せをステータス・タグ・全文検索で検索します。
-
問合せの要約生成: 一覧の各行には AI が生成した問合せの要約が表示されます。詳細については問合せの AI 機能を参照してください。
-
問合せ画面を開いて回答を投稿します。
-
タグの自動付与: 問合せの会話内容に基づき、問合せにタグが自動設定されます。
ナレッジの作成
-
解決した問合せをもとにナレッジを作成します。
ナレッジ追加・編集画面で、ナレッジ文・タグ・関連問合せ・公開範囲(内部/外部)を入力して登録します。 -
ナレッジのドラフト生成: 問合せ内容から AI がナレッジのドラフトを生成します。詳細はナレッジの AI 機能を参照してください。
ナレッジの検索・編集・マージ
-
ナレッジ管理画面で、登録済みのナレッジを検索・編集・削除します。
-
内容が重複する複数のナレッジを選択し、1つにマージできます。
-
ナレッジのマージ: 選択されたナレッジから、AI がマージ結果のドラフトを生成します。詳細はナレッジの AI 機能を参照してください。
4. セットアップ
サンプルアプリのセットアップ手順です。
iPLAss の開発環境の構築が未実施の場合は、先にそちらを実施することをお勧めします。
-
サンプルアプリのプロジェクトを GitHub から取得します。
-
サンプルアプリを起動する前に、フロントエンドをビルドしておく必要があります。ビルドやデプロイなどの操作は、プロジェクト内のREADME.mdを参照してください。
mise run setup # 依存ライブラリのインストール mise run tenant # テナントの作成 mise run deploy # フロントエンド + バックエンドのビルド・デプロイ・起動 -
起動後は、グローバル設定に従い必要な設定を行ってください。
5. グローバル設定
このサンプルの利用にあたり、事前に必要となる設定について説明します。
5.1. ロールとグループ
このサンプルでは、ユーザーのロールによって機能と参照できるデータの範囲を制御しています。参照範囲を制御する仕組みはセキュリティ(ロールと参照範囲)を参照してください。
ロールは、対応するグループに所属しているか(条件式 user.memberOf(…))によって割り当てられます。各ユーザーを、付与したいロールに対応するグループへ所属させてください。
| ロール名 | グループ | 役割 |
|---|---|---|
|
|
回答者(サポート担当者) |
|
|
質問者(問合せを起票するユーザー) |
-
グループへの所属は、問合せデータの参照範囲を制御するためにも使用します。
グループの所属はログイン時に判定されます。
既にログイン中のユーザーのグループを変更した場合は、変更を反映するために再ログインしてください。
5.2. 全文検索
このサンプルは全文検索機能を利用します。検索を有効にするには、対象の Entity をあらかじめクロール(インデックス作成)しておく必要があります。
| 全文検索機能の有効化やクロールの手順は、開発者ガイドの全文検索を参照してください。 |
6. 機能
6.1. フロントエンドの構成
画面は、JSP テンプレート index.jsp から読み込まれる Vue.js のシングルページアプリケーションとして動作します。
-
index.jspは、サーバーが解決したログインユーザーの情報(ユーザー・ロール)・コンテキストパス・ロケールを HTML に埋め込み、ビルド済みのフロントエンドを読み込みます。ファイル名
/src/main/webapp/jsp/km/index.jsp
var __INITIAL_AUTH__ = { user: { oid: '<%=request.getAttribute("userOid") %>', name: '<%=request.getAttribute("userName") %>' }, roles: ['<%=request.getAttribute("roleName") %>'] }; (1) // ... <div id="app"></div> (2) <script type="text/javascript" src="${m:esc(staticContentPath)}/km/assets/index.js?cv=<%=TemplateUtil.getAPIVersion()%>"></script> (3)1 サーバーが解決した現在のユーザーとロールをフロントエンドへ渡します。フロントエンドはこの値で API のベース URL・認証情報・表示言語を初期化します。 2 フロントエンドのマウント先です。 3 ビルド済みのフロントエンド(即時実行関数形式の単一ファイル)を読み込みます。 -
この画面を配信する ActionMapping と Template は、Java のアノテーションで定義しています。
ファイル名
/src/main/java/km/common/command/AuthSessionCommand.java
@Template( name = "km/index", displayName = "ナレッジ管理トップ", path = "/jsp/km/index.jsp", contentType = "text/html; charset=utf-8") @ActionMapping(name = "km/index", result = @Result(status = "*", type = Type.TEMPLATE, value = "km/index")) @CommandClass(name = "km/auth/AuthSessionCommand") public class AuthSessionCommand implements Command { -
サーバーから画面への情報の受け渡しは、
index.jspがグローバル変数として埋め込む値で行います。フロントエンドは起動時にこれを読み取ります。ファイル名
/src/main/vue/types/globals.d.ts
var lang: string (1) var tcPath: string (2) var __INITIAL_AUTH__: { user: { oid: string; name: string } roles: string[] (3) }1 表示言語。多言語の切り替えに使います。 2 コンテキストパス。WebApi のベース URL の組み立てに使います。 3 現在のユーザーとロール。ロールによる画面・操作の出し分けに使います。 -
フロントエンドの静的リソースのパス(
/km/assets/配下)は、WebFrontendService のexcludePathで iPLAss の管理対象外として設定しています。ファイル名
/src/main/resources/mtp-service-config.xml
<property name="excludePath" value="(/km/assets/.*)" additional="true" />
6.2. WebApi との連携
画面とサーバーの通信は、iPLAss の WebApi(REST/JSON)を介して行います。WebApi の作成方法は、開発者ガイドのWebApiの作成方法を参照してください。
ここではナレッジ検索を例に、フロントエンドからサーバーまでの流れを説明します。
フロントエンドからの呼び出し
サーバーとの通信は、ブラウザ標準の fetch を薄くラップした composable(Vue で再利用するロジックをまとめた関数)に集約しています。WebApi のベース URL やレスポンスの取り出し、エラー処理をここで一元化し、各画面は機能ごとのパスだけを指定します。
ファイル名 |
/src/main/vue/composables/useApi.ts |
|---|
function createApiClient(): ApiClient {
const basePath = window.tcPath || ''
const baseURL = `${basePath}/api/km` (1)
const defaultHeaders: HeadersInit = { 'X-Requested-With': 'XMLHttpRequest' } (2)
// ...
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(url, { /* ... */ })
if (!res.ok) return handleErrorResponse(res) (3)
const json = await res.json()
return json.result ?? json (4)
}
| 1 | WebApi のベース URL です。コンテキストパス + /api/km で、各画面はこの後ろのパスだけを書きます。 |
| 2 | Ajax からのリクエストであることを示すヘッダーです。iPLAss はこれを利用して CSRF 対策のトークンチェックを行います。 |
| 3 | エラー応答は composable 側で集約して扱い、各画面には例外として伝えます。 |
| 4 | iPLAss の WebApi はレスポンスを { status, result } で包むため、result を取り出して返します。 |
ナレッジ検索画面は、この composable を使って検索 WebApi を呼び出します。
ファイル名 |
/src/main/vue/components/knowledge/KnowledgeSearch.vue |
|---|
const { api } = useApi()
const res = await api.get<{ data: Knowledge[] }>('/knowledge/search', { (1)
params: { q: query.value, limit: '10' },
})
results.value = res.data (2)
| 1 | 検索 WebApi を呼び出します。ベース URL は composable が補うため、ここでは /knowledge/search と検索条件だけを指定します。 |
| 2 | 返ってきた検索結果を画面に反映します。 |
ファイルを添付する問合せの作成では、FormData を api.post に渡します。composable は FormData を検知して Content-Type を自動で切り替えます(stores/inquiry.ts の createInquiry)。
|
サーバー側の Command
WebApi の実体は Command クラスです。WebApi 名と HTTP メソッドはアノテーションで宣言します。
ファイル名 |
/src/main/java/km/inquiry/command/InquiryListCommand.java |
|---|
@WebApi(
name = "km/inquiry/list", (1)
accepts = RequestType.REST_JSON,
methods = MethodType.GET,
responseResults = {@WebApiResultAttribute(name = "result")}, (2)
privileged = false)
@CommandClass(name = "km/inquiry/InquiryListCommand")
public class InquiryListCommand implements Command {
| 1 | WebApi 名です。/api/km/inquiry/list としてフロントエンドから呼ばれます。 |
| 2 | レスポンスに含める属性名です。Command 内で result 属性にセットした値が返されます。 |
検索条件は Condition クラスを組み立てて作ります。EQL を文字列連結しないため、インジェクションの心配がありません。
List<Condition> conditions = new ArrayList<>();
addStatusConditions(conditions, statuses);
addTagConditions(conditions, tagOids);
// ...
Condition where = combine(conditions); (1)
Query dataQuery = new Query()
.select(Inquiry.OID, Inquiry.NAME, Inquiry.SUMMARY_SHORT, Inquiry.STATUS, /* ... */)
.from(Inquiry.DEFINITION_NAME)
.order(new SortSpec(eqlSortField, eqlSortType)); (2)
// ...
SearchResult<Entity> searchResult = em.searchEntity(dataQuery);
// ...
CommandResponseUtil.setResult(
request, CommandResponseUtil.successListResponse(dataList, totalCount, offset, limit)); (3)
return "SUCCESS";
| 1 | 条件オブジェクトを結合して WHERE 句を作ります。 |
| 2 | ソートフィールドはホワイトリストから選ぶため、外部からの値をそのまま EQL に渡しません。 |
| 3 | レスポンスは共通のユーティリティで { status, data, totalCount, … } の形に整形します。フロントエンドはこの data を受け取ります。 |
パスパラメータ(/inquiry/detail/{oid} など)を受け取る場合は、@WebApi に paramMapping = @WebApiParamMapping(name = "oid", mapFrom = "${0}") を加えます(InquiryDetailCommand)。
|
6.3. 共通エラーハンドラー
このサンプルは Vue.js/WebAPI による SPA で、画面遷移をクライアント側のルーターで行っています。そのため、サーバー側でエラー画面へ遷移させる iPLAss 標準の ErrorUrlSelector(Java/JSP 版や Groovy/GroovyTemplate 版のサンプルが利用する機能)は使えません。代わりに、useApi composable で WebApi のエラー応答を一元的に捕捉し、各画面には例外として伝えることで、エラー処理を共通化しています。エラー応答を集約する useApi の実装はWebApi との連携を参照してください。
ユーザーに表示するエラーメッセージは、多言語(i18n)で解決します。メッセージの多言語解決の仕組みは多言語対応を参照してください。
6.4. Entity の構成
このサンプルは、問合せ・投稿・ナレッジ・タグの4つの Entity で構成します。問合せと投稿は親子関係、ナレッジは関連問合せ・タグを参照で持つ、というのが中心のデータモデルです。Entity 定義に対応する Java のマッピングクラスは、Admin Console の Mapping Class 機能で生成できます(詳細は開発者ガイドのMapping Classを参照)。
各 Entity の概要は次のとおりです。
| Entity(定義名) | 主なプロパティと関係 |
|---|---|
問合せ( |
|
投稿( |
|
ナレッジ( |
|
タグ( |
|
このサンプル特有の Entity の扱いとして、以下の2点に注意します。
-
複数参照プロパティの単一/配列差
複数参照プロパティは、検索(searchEntity)では1件のとき単一要素・load では配列、という形で返ります。マッピングクラスのゲッターでこの差を吸収しておくと、呼び出し側が型を気にせず扱えます。public Tag[] getTags() { Object value = getValue(TAGS); if (value instanceof Tag) { return new Tag[] {(Tag) value}; (1) } else { return (Tag[]) value; (2) } }1 検索結果では、参照が1件のとき単一要素で返るため配列に包みます。 2 load 時は配列で返ります。 -
pub→publicの値変換
ステータスと公開範囲は Java の enum で定義し、Select 型の値として扱います。公開範囲は、publicが Java の予約語で enum 名にできないため、enum 名(pub)と保存される値(public)が異なります。両者を変換して扱います。public enum InquiryStatus { Open, Answered, Resolved, Canceled; /* ... */ } public enum Visibility { internal, // 内部公開(回答者のみ閲覧可) pub("public"); // 外部公開(全ユーザー閲覧可)。保存される値は "public" }
6.5. 全文検索
-
ナレッジ検索では、構造化された検索条件(公開範囲・マージ済みの除外など)と全文検索のキーワードを組み合わせます。
ファイル名
/src/main/java/km/knowledge/command/KnowledgeSearchCommand.java
// マージ済み(統合先に置き換わったナレッジ)は検索結果から除外する。 conditions.add(new IsNull(Knowledge.MERGED_TO + ".oid")); (1) // ... Query query = new Query() .select(Knowledge.OID, Knowledge.NAME, Knowledge.CONTENT, /* ... */) .from(Knowledge.DEFINITION_NAME) .where(where); query.limit(limit); SearchResult<Entity> searchResult = em.fulltextSearchEntity(query, q.trim(), new SearchOption()); (2)1 参照型プロパティ自体には IsNull を直接適用できないため、 mergedTo.oidのようにパス式で指定します。2 構造化条件(Query)と全文検索キーワードを組み合わせて検索します。Query の select には OID を含める必要があります。 -
問合せ一覧の検索では、問合せ本体と投稿の両方を対象に全文検索し、投稿でヒットしたものは親の問合せに逆引きして合流させます。
ファイル名
/src/main/java/km/inquiry/command/InquiryListCommand.java
Map<String, List<String>> hits = em.fulltextSearchOidList(List.of(Inquiry.DEFINITION_NAME, Post.DEFINITION_NAME), keyword); (1) // ... // 投稿でヒットしたものは親問合せへ逆引きする Query parentQuery = new Query() .select(Inquiry.OID) .from(Inquiry.DEFINITION_NAME) .where(new In(Inquiry.POSTS + ".oid", postOids.toArray())); (2)1 複数の Entity を横断して全文検索し、Entity 種別ごとにヒットした OID を取得します。 2 投稿(子)でヒットした OID を、親の問合せに posts.oidのパス式で逆引きします。
| 全文検索の参照先プロパティはインデックスできないため、問合せに紐づく投稿文を検索したい場合は、投稿を対象に検索してから親へ逆引きする、という構成にしています。 |
6.6. セキュリティ(ロールと参照範囲)
参照できるデータの範囲はユーザーのロールや所属グループに基づき制御しています。
ロールの判定はヘルパークラスで行います。
ファイル名 |
/src/main/java/km/common/auth/AuthHelper.java |
|---|
public static final String ROLE_RESPONDER = "inquiry_responder"; // 回答者
public static final String ROLE_USER = "inquiry_user"; // 質問者
// ...
public static boolean isResponder() {
return AuthContext.getCurrentContext().userInRole(ROLE_RESPONDER); (1)
}
| 1 | 現在のユーザーが回答者ロールを持つかを判定します。 |
問合せの参照範囲(Entity 権限)
質問者が「自分の所属グループの問合せのみ参照できる」制御は、iPLAss の Entity 権限(参照可能な条件式)で行います。Command 側でクエリ条件を分岐させるのではなく、作成時にグループコードを記録しておき、参照時の絞り込みは iPLAss に任せます。
ファイル名 |
/src/main/java/km/inquiry/command/InquiryCreateCommand.java |
|---|
// アクセス可能なグループコードを、現在のユーザーの所属グループから設定する(作成時点のスナップショット)。
User currentUser = AuthHelper.getCurrentUser();
Group[] groups = currentUser.getGroups();
String[] groupCodes = /* groups から code を抽出 */;
if (groupCodes != null && groupCodes.length > 0) {
inquiry.setValue(Inquiry.ACCESSIBLE_GROUP_CODES, groupCodes); (1)
}
| 1 | 作成時点の所属グループコードを問合せ自身に記録します。参照時は、この値に対する Entity 権限の条件式で絞り込まれます。 |
ナレッジの参照範囲(クエリ条件)
一方、ナレッジの公開範囲による絞り込みは、Command のクエリ条件で明示的に分岐させています。
ファイル名 |
/src/main/java/km/knowledge/command/KnowledgeSearchCommand.java |
|---|
// 公開範囲フィルタ: 質問者ロールは外部公開のナレッジのみ参照できる
if (!AuthHelper.isResponder()) {
conditions.add(new Equals(Knowledge.VISIBILITY, Visibility.pub.getValue())); (1)
}
| 1 | 回答者でなければ、外部公開(public)のナレッジに絞り込みます。 |
フロントエンドでの出し分け
画面側でも、ロールに応じて表示を切り替えます。判定には、サーバーから渡された認証情報を使います。
ファイル名 |
/src/main/vue/components/layout/DefaultLayout.vue |
|---|
<router-link
v-if="isResponder" (1)
to="/knowledge/manage"
>
| 1 | 回答者だけにナレッジ管理へのリンクを表示します。isResponder は、サーバーが埋め込んだロール情報から判定します。 |
| フロントエンドでの出し分けは利便性のためで、データの保護はサーバー側(Entity 権限・Command のクエリ条件)が担います。WebApi のリクエストに対する CSRF 対策(トークンチェック)も働きます(WebApi との連携参照)。 |
6.7. 多言語対応
画面の表示言語は vue-i18n で切り替えます。日本語と英語に対応しています。
-
表示ロケールは、サーバーが埋め込んだ
window.langから決定します。ファイル名
/src/main/vue/i18n/index.ts
function resolveLocale(): AppLocale { const raw = (typeof window !== 'undefined' && window.lang) || 'ja' (1) return raw.toLowerCase().startsWith('en') ? 'en' : 'ja' } export const i18n = createI18n<[MessageSchema], AppLocale, false>({ legacy: false, locale: resolveLocale(), fallbackLocale: 'ja', (2) messages: { ja, en }, globalInjection: true, })1 サーバーが埋め込んだロケールを使います。地域付き( ja_JP等)も先頭で判定します。2 未定義のキーは日本語へフォールバックします。 -
メッセージは言語ごとのファイルに定義します。
ファイル名
/src/main/vue/i18n/locales/ja.ts
export const ja = { error: { code: { /* errorCode ごとのメッセージ */ }, exception: { /* iPLAss 例外型ごとのメッセージ */ }, http: { /* HTTP ステータスごとのメッセージ */ }, }, // ... }WebApi のエラーは、エラーコード・iPLAss の例外型・HTTP ステータスの3段でメッセージを解決する構成にしています。
7. リソース定義
このサンプルが利用するメタデータ・メッセージの定義について説明します。
7.1. メタデータ定義
このサンプルの WebApi・Command・ActionMapping・Template はJava のアノテーションで定義しています。
-
メタデータ生成の対象となる Command は、1つのクラスに集約して列挙しています。
ファイル名
/src/main/java/km/common/command/CommandList.java
@MetaDataSeeAlso({ AuthSessionCommand.class, InquiryListCommand.class, // ... }) @CommandClass(name = "km/CommandList") public class CommandList {} -
各 Command は
@WebApi(WebApi 名・HTTP メソッド)と@CommandClass(Command 名)を直接持ちます(WebApi との連携参照)。画面は@ActionMappingと@Templateで定義します(フロントエンドの構成参照)。
| Entity 定義と管理画面の View 定義は、Admin Console で作成・登録します。AI 機能に関するメタデータ(プロンプト・データ連携)は、Enterprise Editionで別途インポートします(AI 機能のセットアップ参照)。 |
7.2. メッセージ定義
画面の表示文言は、フロントエンドの言語ファイルで管理します。日本語と英語の2系統を用意し、日本語を基準とします(多言語対応参照)。
日本語 |
/src/main/vue/i18n/locales/ja.ts |
|---|---|
英語 |
/src/main/vue/i18n/locales/en.ts |
サーバー側で出力するメッセージ(入力値のバリデーションなど)は、iPLAss のメッセージ定義の仕組みで管理します。詳細は開発者ガイドを参照してください。