意図分類ロジック(ArachneOrderIntentPlanner.java)

order-serviceorder-intake-agent は、自然言語メッセージを DIRECT_ITEM / RECOMMENDATION / REORDER / REFINEMENT の 4 種類に分類します。分類は LLM の structured output に委ねており、Java コード側ではキーワードマッチなどのヒューリスティックを使いません。Java コードの役割は LLM 出力の検証(有効な intentMode 値かどうか)と、structured output が返らなかった場合のデフォルト値設定のみです。

// ArachneOrderIntentPlanner.java - plan() メソッド
@Override
public NormalizedOrderIntent plan(
        String sessionId,
        SuggestOrderRequest request,
        OrderSession existing,
        Optional<StoredOrder> recentOrder) {
    OrderIntentAgentUserPrompt prompt = OrderIntentAgentUserPrompt.from(request, existing, recentOrder);
    // LLM に NormalizedOrderIntent.class で structured output を要求
    AgentResult result = observationSupport.observe(
            "order-service",
            AGENT_NAME,
            sessionId,
            prompt.render(),
            () -> agentFactory.builder()
                    .systemPrompt(systemPrompt())
                    .build()
                    .run(prompt.render(), NormalizedOrderIntent.class));
    // LLM 出力を Java 側で正規化
    return normalize(result.structuredOutput(NormalizedOrderIntent.class), request, existing, recentOrder);
}

// 検証・フォールバック処理
private String normalizeIntentMode(
        String plannedIntentMode,
        SuggestOrderRequest request,
        OrderSession existing) {
    String normalized = blankToNull(plannedIntentMode);
    if (normalized != null) {
        String upper = normalized.toUpperCase(Locale.ROOT);
        if (List.of("DIRECT_ITEM", "RECOMMENDATION", "REORDER", "REFINEMENT").contains(upper)) {
            return upper;  // LLM の判断をそのまま採用
        }
    }
    // LLM が未回答の場合の構造的フォールバック
    if (request.refinement() != null && !request.refinement().isBlank() && existing.pendingProposal() != null) {
        return "REFINEMENT";
    }
    return "RECOMMENDATION";
}

「テリヤキバーガーください」に DIRECT_ITEM を付けるかどうかは LLM が判断します。Java コードはその値を検証するだけです。再注文コンテキストが必要かどうかも LLM に委ねるため、recentOrder は常に order-intake-agent に渡されます。

grounding context の受け渡し(MenuSuggestionPromptRequestFactory.java)

分類された intentModedirectItemHint は HTTP ペイロード(MenuGroundingContext)として menu-service に渡されます。

// MenuSuggestionPromptRequestFactory.java
static MenuSuggestionRequest build(String sessionId, SuggestOrderRequest request,
        NormalizedOrderIntent normalizedIntent) {
    // request.refinement() が空白なら null として渡す(menu-agent への追加制約)
    String refinement = request.refinement() == null || request.refinement().isBlank()
            ? null : request.refinement().trim();
    return new MenuSuggestionRequest(
            sessionId,
            normalizedIntent.menuQuery(),
            refinement,
            normalizedIntent.recentOrderSummary(),
            new MenuGroundingContext(
                    normalizedIntent.intentMode(),      // "DIRECT_ITEM" or "RECOMMENDATION"
                    normalizedIntent.directItemHint(),  // 商品名(DIRECT_ITEM の場合のみ)
                    normalizedIntent.partySize(),
                    normalizedIntent.budgetUpperBound(),
                    normalizedIntent.childCount(),
                    normalizedIntent.rationale()));
}

order-service が呼ぶ URL は POST /internal/menu/suggest のまま変わりません。grounding context は既存フィールドへの追記であり、エンドポイントのシグネチャは変わりません。

明示指定と追加提案の型レベル分離(MenuApplicationService.java)

menu-agent は structured output で explicitItemIds(明示指定)と additionalItemIds(追加提案)を別フィールドで返します。コード側でそれを先頭固定にマージします。

// MenuApplicationService.java
List<String> explicitIds = selection.explicitItemIds() != null
        ? selection.explicitItemIds() : List.of();
List<String> additionalIds = selection.additionalItemIds() != null
        ? selection.additionalItemIds() : List.of();

// explicit を先頭に、additional の重複を除外して結合する
List<String> mergedIds = Stream.concat(
        explicitIds.stream(),
        additionalIds.stream().filter(id -> !explicitIds.contains(id)))
        .toList();

no-match を明示するシステムプロンプト(MenuApplicationService.java)

menu-agent の system prompt には、exact match が弱い場合でも代替提案の根拠を明示するよう指示があります。AI が「勝手に選んだ」とユーザーに見えないための設計です。

// MenuApplicationService.java - buildSuggestionSystemPrompt()
private String buildSuggestionSystemPrompt() {
    String skillActivationSection = skillActivationHints.entrySet().stream()
            .map(e -> "- " + e.getValue() + " は **" + e.getKey() + "** を有効化してください。")
            .reduce((a, b) -> a + "\n" + b)
            .map(s -> "\n\n【スキル有効化】\n" + s + "\n")
            .orElse("\n");
    return """
            あなたは単一ブランドのクラウドキッチンアプリの menu-agent です。
            このビジネスは1つのキッチンのみです。現在のメニューからのみアイテムを推奨してください。
            ...
            【ルール】
            ...
            
            recommendationReason には価格・合計金額を含めず、商品の特徴・人数・予算適合の根拠のみ記載してください。
            exact match が弱い場合でも、menu 側で説明できる代替候補を同ブランド内から選んでください。
            欠品・提供可否・調理 ETA は kitchen-service 側で行われます。在庫や提供時間を約束しないでください。
            最終回答は structured_output を使い、explicitItemIds, additionalItemIds, skillTag, recommendationReason を返してください。""";
}

代替提案が根拠と一緒に返るため、推薦結果が透明になります。

観察された振る舞い

  • 「テリヤキバーガーください」→ order-intake-agent が DIRECT_ITEM と分類(LLM 判断)→ menu-service が alias-based explicit match で該当 ID を返した。
  • 「子ども向けにいくつか見繕ってください」→ order-intake-agent が RECOMMENDATION と分類(LLM 判断)→ family-order-guide スキルが有効化され、Kids 系メニューが提案された。
  • どちらのケースでも recentOrder が order-intake-agent に渡される。LLM はその情報を使うかどうかを自身で判断する(DIRECT_ITEM の場合は参照しなかった)。
  • menu-service が exact match できない場合、summary に「カタログ外の要望であることを伝える必要があります」と明示された。
  • どちらの経路でも menu-service は同じ POST /internal/menu/suggest を受け取り、API 契約は変わらなかった。

ランタイム API 応答

DIRECT_ITEM パス: 「テリヤキバーガーください」

POST /api/order/suggest{"intent":{"rawMessage":"テリヤキバーガーください"},"locale":"ja-JP"} を送信したときの実際のレスポンスです。

{
  "sessionId": "session-df8a20b9",
  "workflowStep": "item-selection",
  "headline": "menu-agent が 1 件のメニューオプションをマッチしました",
  "summary": "menu-agent が Smash Burger Combo x1 をおすすめします。 カタログ検索では「テリヤキバーガー」という明示的なリクエストに対して該当する商品が見つかりませんでした。提供可能なメニューには「Smash Burger Combo」しか存在しないため、代替提案ができません。ただし、注文意図は明確に「テリヤキバーガー」であるため、カタログ外の要望であることを伝える必要があります。 キッチン確認後の提供目安は約16分。",
  "etaMinutes": 16,
  "proposals": [
    { "itemId": "combo-smash", "name": "Smash Burger Combo", "quantity": 1, "unitPrice": 1050.0 }
  ],
  "trace": [
    {
      "service": "order-service",
      "agent": "order-intake-agent",
      "detail": "顧客が「テリヤキバーガーください」と明示的に注文意図を示しています。最近の注文はSmash Burger Comboでしたが、今回は異なるメニューをリクエストしています。構造化データとして、明示的な商品指定、参加人数1人、子ども人数0人を保持します。 intentMode=DIRECT_ITEM"
    },
    {
      "service": "menu-service",
      "agent": "menu-agent",
      "detail": "menu-agent が Smash Burger Combo x1 をおすすめします。 カタログ検索では「テリヤキバーガー」という明示的なリクエストに対して該当する商品が見つかりませんでした。"
    }
  ]
}

trace[0].detailintentMode=DIRECT_ITEM が明示されています。LLM が “バーガー” を含む商品名指定と判断し、Java コードのヒューリスティックなしに DIRECT_ITEM を選択しました。recent_order も渡されていますが、LLM は今回の注文が新規指定であると判断して参照しませんでした。

RECOMMENDATION パス: 「子ども向けにいくつか見繕ってください」

{
  "sessionId": "session-2fa6498e",
  "workflowStep": "item-selection",
  "headline": "menu-agent が 1 件のメニューオプションをマッチしました",
  "summary": "[family-order-guide] menu-agent が Kids Cheeseburger Set x1 をおすすめします。 子ども向けにミニチーズバーガー、コーンカップ、アップルジュースのキッズセットを提案。辛さ控えめで食べやすい構成です。単品のドリンクは人数が明記されていないため含めません。 キッチン確認後の提供目安は約9分。",
  "etaMinutes": 9,
  "proposals": [
    { "itemId": "combo-kids", "name": "Kids Cheeseburger Set", "quantity": 1, "unitPrice": 720.0 }
  ],
  "trace": [
    {
      "service": "order-service",
      "agent": "order-intake-agent",
      "detail": "子どもの向けのメニューをリクエスト。再注文の文脈から予算と人数を推定。 intentMode=RECOMMENDATION"
    },
    {
      "service": "menu-service",
      "agent": "menu-agent",
      "detail": "[family-order-guide] menu-agent が Kids Cheeseburger Set x1 をおすすめします。 子ども向けにミニチーズバーガー、コーンカップ、アップルジュースのキッズセットを提案。"
    }
  ]
}

summarytrace[1].detail 冒頭の [family-order-guide] タグが、family-order-guide スキルが有効化されたことを示しています。order-intake-agent は「向け」というキーワードに関わらず、LLM がコンテキストを読んで RECOMMENDATION を選択しました。

order-service 実行履歴(session-df8a20b9)

{
  "sessionId": "session-df8a20b9",
  "events": [
    {
      "sequence": 1,
      "occurredAt": "2026-05-08T14:32:10.100Z",
      "category": "agent",
      "service": "order-service",
      "component": "order-intake-agent",
      "operation": "invoke",
      "outcome": "started",
      "durationMs": 0,
      "headline": "order-intake-agent を開始",
      "detail": "raw_message=テリヤキバーガーください recent_order=1x Smash Burger Combo"
    },
    {
      "sequence": 2,
      "occurredAt": "2026-05-08T14:32:12.744Z",
      "category": "agent",
      "service": "order-service",
      "component": "order-intake-agent",
      "operation": "invoke",
      "outcome": "success",
      "durationMs": 2644,
      "headline": "order-intake-agent が完了",
      "detail": "intentMode=DIRECT_ITEM menuQuery=テリヤキバーガー"
    },
    {
      "sequence": 3,
      "occurredAt": "2026-05-08T14:32:22.014Z",
      "category": "downstream",
      "service": "order-service",
      "component": "menu-service",
      "operation": "suggest",
      "outcome": "success",
      "durationMs": 9270,
      "headline": "menu-service が catalog grounding を完了",
      "detail": "query=テリヤキバーガー => menu-agent が 1 件のメニューオプションをマッチしました / items=1 / eta=16min"
    },
    {
      "sequence": 4,
      "occurredAt": "2026-05-08T14:32:22.050Z",
      "category": "workflow",
      "service": "order-service",
      "component": "order-workflow",
      "operation": "suggest",
      "outcome": "success",
      "durationMs": 12093,
      "headline": "order-suggest ワークフローが完了",
      "detail": "session-df8a20b9 が item-selection ステップへ遷移"
    }
  ]
}

確認できる場所

  • /api/order/suggest → order-service の実行履歴(/api/order/execution-history/{sessionId}
  • /agents ページの「概要」タブ(order-intake-agent の system prompt)
  • /agents ページの「API 仕様」タブ(menu-service の x-ai-prompt-contract