4.1
ナレッジ管理アプリ

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 にアクセスしてログインすると、質問者用のトップ画面が表示されます。

問合せの起票

  • ナビゲーションの「新規問合せ」を開き、件名と最初の質問を入力します。ファイルを添付することもできます。
    「送信」をクリックすると問合せが起票され、チャット形式の問合せ画面に遷移します。

    sample km inquiry new input
    sample km inquiry chat question
関連ナレッジの検索
  • 問合せ画面の右側のパネルで、質問に関連するナレッジをキーワードで検索できます。

  • 類似ナレッジ検索: Enterprise Editionの場合、このパネルではRAGによる関連ナレッジを提案します。詳細はRAG 検索・類似ナレッジ提案を参照してください。

    sample km inquiry chat knowledge panel
  • ナレッジ検索(RAG): ナビゲーションの「ナレッジ検索」では、自由文で質問を入力すると、AI がナレッジを検索して回答文を生成し、参照元のナレッジとともに表示します。質問者は外部公開ナレッジのみが検索対象です。

    sample km knowledge ask result

回答の確認とクローズ

  • 回答者から回答が投稿されると、問合せは「回答済」ステータスに変わります。
    質問者は追加で質問を投稿するか、解決した場合は「解決済み」、解決しなかった場合は「キャンセル」で問合せをクローズします。

    sample km inquiry chat close
  • クローズ後も、質問者は問合せを再オープンして再質問できます。

3.2. 回答者の操作

サンプルアプリケーションのURL http(s)://[server]/[tenantContextPath]/km/index にアクセスしてログインすると、回答者用のトップ画面が表示されます。

問合せの検索と回答

  • ナビゲーションの「問合せ一覧」で、問合せをステータス・タグ・全文検索で検索します。

  • 問合せの要約生成: 一覧の各行には AI が生成した問合せの要約が表示されます。詳細については問合せの AI 機能を参照してください。

    sample km inquiry list search
  • 問合せ画面を開いて回答を投稿します。

  • タグの自動付与: 問合せの会話内容に基づき、問合せにタグが自動設定されます。

    sample km inquiry chat answer

ナレッジの作成

  • 解決した問合せをもとにナレッジを作成します。
    ナレッジ追加・編集画面で、ナレッジ文・タグ・関連問合せ・公開範囲(内部/外部)を入力して登録します。

  • ナレッジのドラフト生成: 問合せ内容から AI がナレッジのドラフトを生成します。詳細はナレッジの AI 機能を参照してください。

    sample km knowledge new input

ナレッジの検索・編集・マージ

  • ナレッジ管理画面で、登録済みのナレッジを検索・編集・削除します。

    sample km knowledge manage list
  • 内容が重複する複数のナレッジを選択し、1つにマージできます。

  • ナレッジのマージ: 選択されたナレッジから、AI がマージ結果のドラフトを生成します。詳細はナレッジの AI 機能を参照してください。

    sample km knowledge merge review

4. セットアップ

サンプルアプリのセットアップ手順です。
iPLAss の開発環境の構築が未実施の場合は、先にそちらを実施することをお勧めします。

  • サンプルアプリのプロジェクトを GitHub から取得します。

  • サンプルアプリを起動する前に、フロントエンドをビルドしておく必要があります。ビルドやデプロイなどの操作は、プロジェクト内のREADME.mdを参照してください。

    mise run setup     # 依存ライブラリのインストール
    mise run tenant    # テナントの作成
    mise run deploy    # フロントエンド + バックエンドのビルド・デプロイ・起動
  • 起動後は、グローバル設定に従い必要な設定を行ってください。

5. グローバル設定

このサンプルの利用にあたり、事前に必要となる設定について説明します。

5.1. ロールとグループ

このサンプルでは、ユーザーのロールによって機能と参照できるデータの範囲を制御しています。参照範囲を制御する仕組みはセキュリティ(ロールと参照範囲)を参照してください。

ロールは、対応するグループに所属しているか(条件式 user.memberOf(…​))によって割り当てられます。各ユーザーを、付与したいロールに対応するグループへ所属させてください。

ロール名 グループ 役割

inquiry_responder

inquiry_responder

回答者(サポート担当者)

inquiry_user

inquiry_user

質問者(問合せを起票するユーザー)

  • グループへの所属は、問合せデータの参照範囲を制御するためにも使用します。

    グループの所属はログイン時に判定されます。
    既にログイン中のユーザーのグループを変更した場合は、変更を反映するために再ログインしてください。

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 ビルド済みのフロントエンド(即時実行関数形式の単一ファイル)を読み込みます。
  • この画面を配信する ActionMappingTemplate は、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/ 配下)は、WebFrontendServiceexcludePath で 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 返ってきた検索結果を画面に反映します。
ファイルを添付する問合せの作成では、FormDataapi.post に渡します。composable は FormData を検知して Content-Type を自動で切り替えます(stores/inquiry.tscreateInquiry)。

サーバー側の 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} など)を受け取る場合は、@WebApiparamMapping = @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(定義名) 主なプロパティと関係

問合せ(km.inquiry.Inquiry

status(Select 型のステータス)、posts(投稿への複数参照/親子)、tags(タグへの複数参照)、accessibleGroupCodes(複数値 String の公開範囲)。

投稿(km.inquiry.Post

contentattachments(添付ファイル。複数の Binary 型)。問合せと親子関係を持ちます。

ナレッジ(km.knowledge.Knowledge

contentrelatedInquiries(問合せへの複数参照)、tags(タグへの複数参照)、visibility(Select 型の公開範囲)、mergedTo(自身への単一参照によるマージ先。未マージなら null)。

タグ(km.tag.Tag

tagName(一意なタグ名)。参照や Select を持たない単純な Entity です。標準の name は改変できないため、一意なタグ名を別プロパティで保持しています。

このサンプル特有の 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 時は配列で返ります。
  • pubpublic の値変換
    ステータスと公開範囲は 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. 全文検索

ナレッジ検索と問合せ一覧のキーワード検索は、iPLAss の全文検索機能を利用しています。有効化・クロールについてはグローバル設定を、機能の詳細は開発者ガイドの全文検索を参照してください。

  • ナレッジ検索では、構造化された検索条件(公開範囲・マージ済みの除外など)と全文検索のキーワードを組み合わせます。

    ファイル名

    /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 のメッセージ定義の仕組みで管理します。詳細は開発者ガイドを参照してください。

8. 関連機能