ラボ BAF3 - Mistral AI を使用したビジョン分析の追加
このラボでは、保険請求の損傷写真を分析するために AI ビジョン機能を追加し、 mistral-medium-2505 モデルを使用します。エージェントは車両損傷を自動で評価し、修理費用を見積もり、安全上の懸念事項を写真から特定できるようになります。
学習内容
- Mistral AI ビジョンモデルを統合してマルチモーダル分析を行う方法
- Azure Blob Storage から画像データを処理する方法
- AI を活用した損傷評価の実装
- AI 生成の分析結果に対する承認ワークフローの作成
概要
ラボ BAF2 では、Azure AI Search Knowledgebases を使用して請求検索を追加しました。今回はマルチモーダルな AI ビジョン機能を強化し、損傷写真を分析して詳細な評価レポートを提供します。
Vision Service は Azure AI Services にデプロイされた mistral-medium-2505 モデルを使用し、画像を分析して損傷評価を含む構造化された JSON 応答を生成します。
演習 1: 前提条件の更新
ビジョン分析を追加する前に、Mistral ビジョンモデルをデプロイする必要があります。
Step 1: Microsoft Foundry で Mistral ビジョンモデルをデプロイする
1️⃣ Microsoft Foundry にサインインします。
2️⃣ プロジェクトを開くか、新規作成します。
3️⃣ Models + endpoints → Deploy model → Deploy base model に移動します。
4️⃣ mistral-medium-2505 を検索してデプロイします:
- Model:
mistral-medium-2505 - Deployment name:
mistral-medium-2505(この名前を正確に使用) - Version: 最新
5️⃣ デプロイ完了まで待機します(約 2~3 分)。
6️⃣ デプロイ情報をメモします:
- gpt-4.1 デプロイと 同じエンドポイント を使用
- 他のモデルと 同じ API キー を使用
- モデル名 のみ
mistral-medium-2505に変更
Step 2: Azure Storage アカウントとコンテナーを作成する
損傷写真用のストレージアカウントとコンテナーを作成します。
1️⃣ Azure Portal にサインインします。
2️⃣ 新しい Storage Account を作成します:
- 上部検索バーで「Storage accounts」を検索
- + Create をクリック
-
詳細を入力:
- Subscription: 対象のサブスクリプション
- Resource group: 既存リソースグループを使用
- Storage account name: 一意の名前(例:
zavadamagestorage+ イニシャル) - Region: AI サービスと同じリージョン
- Performance: Standard
- Redundancy: Locally-redundant storage (LRS)
-
Review + Create → Create をクリック
- デプロイ完了まで待機(約 1~2 分)
3️⃣ 新しいストレージアカウントに移動し、匿名アクセスを有効化:
- 左メニューの Settings 内 Configuration を選択
- Allow Blob anonymous access を Enabled に設定
- 上部の Save をクリック
パブリックアクセスに必須
ストレージアカウントレベルで匿名 Blob アクセスを有効化しないと、個別コンテナーでのパブリックアクセス設定が機能しません。
4️⃣ 左メニューの Data storage 配下から Containers を選択。
5️⃣ 上部の + Container をクリック。
5️⃣ 新しいコンテナーを設定:
- Name:
claim-photos - Public access level:
Blob (anonymous read access for blobs only) - Create をクリック
6️⃣ 構成用にストレージアカウントの詳細をコピー:
- 左メニューの Access keys へ
- key1 または key2 の Connection string をコピー
- Storage account name をコピー
なぜ Blob パブリックアクセス?
コンテナーを「Blob」パブリックアクセスレベルに設定することで:
- AI ビジョンモデルが個別画像 URL に直接アクセス可能
- チャットインターフェイスで画像を表示
- 読み取り専用で認証不要
個別 Blob URL のみが公開され、コンテナー一覧は非公開のためセキュリティを保持します。
Step 3: 構成を更新する
ビジョンモデルと Blob Storage の構成を環境変数に追加します。
1️⃣ .env.local ファイルを開きます。
2️⃣ ビジョンモデルと Blob Storage の構成を追加します:
# Vision & Fraud analysis model (mistral-medium-2505)
VISION_MODEL_NAME=mistral-medium-2505
# Storage
AZURE_STORAGE_ACCOUNT_NAME=YOUR-STORAGE-ACCOUNT
# Blob Storage for Damage Photos
BLOB_STORAGE_CONTAINER_NAME=claim-photos
BLOB_STORAGE_BASE_URL=https://YOUR-STORAGE-ACCOUNT.blob.core.windows.net
3️⃣ .env.local.user ファイルを開きます。
4️⃣ Blob Storage 接続文字列を追加します:
# Storage
SECRET_AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=YOUR-STORAGE-ACCOUNT;AccountKey=YOUR-STORAGE-KEY;EndpointSuffix=core.windows.net
構成メモ
- SECRET_AZURE_STORAGE_CONNECTION_STRING: Azure Portal からコピーした接続文字列
- AZURE_STORAGE_ACCOUNT_NAME: ストレージアカウント名
- BLOB_STORAGE_CONTAINER_NAME:
claim-photos固定 - BLOB_STORAGE_BASE_URL:
YOUR-STORAGE-ACCOUNTを実際のストレージアカウント名に置換
演習 2: Vision と Storage サービスを作成する
AI ビジョン分析と Blob Storage を扱うサービスを作成します。
Step 1: VisionService と BlobStorageService を作成する
コードの概要
VisionService: Mistral AI ビジョンモデルで損傷写真を分析
mistral-medium-2505デプロイに接続- 画像バイトを受け取り、構造化された損傷分析 JSON を生成
- 損傷タイプ、深刻度、費用見積もり、安全上の懸念、修理推奨を提供
- 失敗時のフォールバックロジック
- 一貫性のある事実ベース回答のため温度 0.3 を使用
BlobStorageService: Azure Blob Storage で損傷写真を管理
- 請求番号ごとにタイムスタンプ付きで写真をアップロード
- AI 分析用に写真をダウンロード
- 不要になった写真を削除
- MIME タイプ (JPEG, PNG, GIF, BMP, WebP) を自動検出
- 直接 URL アクセス用にパブリック Blob アクセスを設定
これは完全な実装で、後から追加するメソッドはありません。
1️⃣ src/Services/VisionService.cs を新規作成し、完全な実装を追加:
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.ClientModel;
using System.Text.Json;
using OpenAI.Chat;
namespace InsuranceAgent.Services;
/// <summary>
/// Service for analyzing images using Mistral AI model capabilities
/// </summary>
public class VisionService
{
private readonly ChatClient _chatClient;
private readonly IConfiguration _configuration;
private readonly ILogger<VisionService> _logger;
public VisionService(
IConfiguration configuration,
ILogger<VisionService> logger)
{
_configuration = configuration;
_logger = logger;
// Use shared endpoint and API key, but different model for vision analysis
var endpoint = configuration["AIModels:Endpoint"]
?? throw new InvalidOperationException("AIModels:Endpoint not configured");
var apiKey = configuration["AIModels:ApiKey"]
?? throw new InvalidOperationException("AIModels:ApiKey not configured");
var deployment = configuration["AIModels:VisionModel:Name"]
?? throw new InvalidOperationException("AIModels:VisionModel:Name not configured");
_logger.LogInformation("🔍 VisionService Configuration:");
_logger.LogInformation(" Endpoint: {Endpoint}", endpoint);
_logger.LogInformation(" Deployment: {DeploymentName}", deployment);
var azureClient = new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(apiKey));
_chatClient = azureClient.GetChatClient(deployment);
}
/// <summary>
/// Analyzes an insurance claim damage photo using Mistral AI model
/// </summary>
/// <param name="imageBytes">The image file bytes</param>
/// <param name="fileName">The image file name</param>
/// <returns>Detailed damage analysis</returns>
public async Task<DamageAnalysisResult> AnalyzeDamagePhotoAsync(byte[] imageBytes, string fileName)
{
try
{
_logger.LogInformation("Starting vision analysis for {FileName} ({Size} bytes)", fileName, imageBytes.Length);
// Create the vision analysis prompt
var prompt = @"You are an expert insurance claims adjuster analyzing damage photos.
Analyze this image and provide a detailed assessment in the following JSON format:
{
""damageType"": ""<type of damage: water, fire, storm, hail, flood, etc.>"",
""severity"": ""<Low, Medium, High, or Critical>"",
""detailedDescription"": ""<detailed description of what you see in the image>"",
""affectedAreas"": [""<list of affected areas/structures as array>""],
""estimatedRepairCost"": <numeric estimate in dollars>,
""safetyConcerns"": ""<any immediate safety concerns>"",
""repairRecommendations"": ""<recommended repair actions>"",
""urgency"": ""<Immediate, Within 1 week, Within 1 month, Non-urgent>"",
""requiresSpecialist"": <true/false>,
""specialistType"": ""<type of specialist needed, if any>""
}
Be specific, professional, and focus on details that would help with claims processing.";
// Create chat completion request with image
var messages = new List<ChatMessage>
{
new UserChatMessage(
ChatMessageContentPart.CreateTextPart(prompt),
ChatMessageContentPart.CreateImagePart(BinaryData.FromBytes(imageBytes), GetMimeType(fileName))
)
};
var chatOptions = new ChatCompletionOptions
{
Temperature = 0.3f, // Lower temperature for more consistent analysis
ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat()
};
// Call Mistral AI model for vision analysis
var response = await _chatClient.CompleteChatAsync(messages, chatOptions);
var analysisJson = response.Value.Content[0].Text ?? "{}";
_logger.LogInformation("Vision analysis completed for {FileName}", fileName);
_logger.LogDebug("Analysis result: {Analysis}", analysisJson);
// Parse the JSON response
var result = JsonSerializer.Deserialize<DamageAnalysisResult>(analysisJson, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (result == null)
{
_logger.LogWarning("Failed to parse vision analysis result for {FileName}", fileName);
return CreateFallbackResult(fileName);
}
result.AnalyzedAt = DateTime.UtcNow;
result.FileName = fileName;
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error analyzing image {FileName}", fileName);
return CreateFallbackResult(fileName);
}
}
private string GetMimeType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".webp" => "image/webp",
_ => "image/jpeg"
};
}
private DamageAnalysisResult CreateFallbackResult(string fileName)
{
return new DamageAnalysisResult
{
FileName = fileName,
DamageType = "Unknown",
Severity = "Medium",
DetailedDescription = "Unable to analyze image automatically. Manual review required.",
AffectedAreas = new[] { "Unknown" },
EstimatedRepairCost = 0,
SafetyConcerns = "Please review manually",
RepairRecommendations = "Manual assessment required",
Urgency = "Within 1 week",
RequiresSpecialist = true,
SpecialistType = "Insurance Adjuster",
AnalyzedAt = DateTime.UtcNow
};
}
}
/// <summary>
/// Result of damage analysis using Mistral AI model
/// </summary>
public class DamageAnalysisResult
{
public string FileName { get; set; } = "";
public string DamageType { get; set; } = "";
public string Severity { get; set; } = "";
public string DetailedDescription { get; set; } = "";
public string[] AffectedAreas { get; set; } = Array.Empty<string>();
public double EstimatedRepairCost { get; set; }
public string SafetyConcerns { get; set; } = "";
public string RepairRecommendations { get; set; } = "";
public string Urgency { get; set; } = "";
public bool RequiresSpecialist { get; set; }
public string SpecialistType { get; set; } = "";
public DateTime AnalyzedAt { get; set; }
}
2️⃣ src/Services/BlobStorageService.cs を新規作成し、完全な実装を追加:
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Configuration;
namespace InsuranceAgent.Services;
/// <summary>
/// Service for uploading and managing damage photos in Azure Blob Storage
/// </summary>
public class BlobStorageService
{
private readonly BlobServiceClient _blobServiceClient;
private readonly string _containerName;
private readonly string _baseUrl;
public BlobStorageService(IConfiguration configuration)
{
var connectionString = configuration["SECRET_AZURE_STORAGE_CONNECTION_STRING"]
?? throw new InvalidOperationException("SECRET_AZURE_STORAGE_CONNECTION_STRING not configured");
_containerName = configuration["BLOB_STORAGE_CONTAINER_NAME"] ?? "claim-photos";
_baseUrl = configuration["BLOB_STORAGE_BASE_URL"] ?? "";
_blobServiceClient = new BlobServiceClient(connectionString);
}
/// <summary>
/// Uploads a damage photo and returns the public URL
/// </summary>
/// <param name="claimNumber">The claim number for organizing photos</param>
/// <param name="imageBytes">The image file bytes</param>
/// <param name="fileName">Original filename</param>
/// <returns>Public URL to the uploaded blob</returns>
public async Task<string> UploadDamagePhotoAsync(string claimNumber, byte[] imageBytes, string fileName)
{
// Ensure container exists
var containerClient = _blobServiceClient.GetBlobContainerClient(_containerName);
await containerClient.CreateIfNotExistsAsync(PublicAccessType.Blob);
// Generate unique blob name: {claimNumber}/{timestamp}_{filename}
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmss");
var blobName = $"{claimNumber}/{timestamp}_{fileName}";
var blobClient = containerClient.GetBlobClient(blobName);
// Check if blob already exists
if (await blobClient.ExistsAsync())
{
Console.WriteLine($"⏭️ Blob already exists: {blobName}");
return blobClient.Uri.ToString();
}
// Set content type based on file extension
var contentType = GetContentType(fileName);
var blobHttpHeaders = new BlobHttpHeaders { ContentType = contentType };
// Upload the image
using var stream = new MemoryStream(imageBytes);
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
HttpHeaders = blobHttpHeaders
});
// Return public URL
return blobClient.Uri.ToString();
}
/// <summary>
/// Downloads a damage photo by URL
/// </summary>
public async Task<byte[]> DownloadPhotoAsync(string blobUrl)
{
var blobClient = new BlobClient(new Uri(blobUrl));
var response = await blobClient.DownloadContentAsync();
return response.Value.Content.ToArray();
}
/// <summary>
/// Deletes a damage photo by URL
/// </summary>
public async Task<bool> DeletePhotoAsync(string blobUrl)
{
try
{
var blobClient = new BlobClient(new Uri(blobUrl));
await blobClient.DeleteIfExistsAsync();
return true;
}
catch
{
return false;
}
}
private string GetContentType(string fileName)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".jpg" or ".jpeg" => "image/jpeg",
".png" => "image/png",
".gif" => "image/gif",
".bmp" => "image/bmp",
".webp" => "image/webp",
_ => "application/octet-stream"
};
}
}
演習 3: KnowledgeBaseService をビジョン機能で拡張する
VisionPlugin を作成する前に、KnowledgeBaseService にプラグインが使用するメソッドを追加します。これによりプラグイン作成時の依存関係が満たされます。
Step 1: KnowledgeBaseService コンストラクターを更新する
変更点
KnowledgeBaseService コンストラクターに、損傷写真をアップロードするためのオプションの BlobStorageService パラメーターを追加します。コンストラクターはラボ BAF2 ですでに IConfiguration のみを受け取るよう簡略化されており、今回は BlobStorageService を追加するだけです。
1️⃣ src/Services/KnowledgeBaseService.cs を開きます。
2️⃣ KnowledgeBaseService コンストラクターを見つけ、以下のコードブロックに置き換えます:
private readonly BlobStorageService? _blobStorageService;
public KnowledgeBaseService(IConfiguration configuration, BlobStorageService? blobStorageService = null)
{
_configuration = configuration;
_searchEndpoint = configuration["AZURE_AI_SEARCH_ENDPOINT"]
?? throw new InvalidOperationException("AZURE_AI_SEARCH_ENDPOINT not configured");
_searchApiKey = configuration["SECRET_AZURE_AI_SEARCH_API_KEY"]
?? throw new InvalidOperationException("SECRET_AZURE_AI_SEARCH_API_KEY not configured");
_aiEndpoint = configuration["MODELS_ENDPOINT"]
?? throw new InvalidOperationException("MODELS_ENDPOINT not configured");
_aiApiKey = configuration["AIModels:ApiKey"]
?? throw new InvalidOperationException("AIModels:ApiKey not configured");
_embeddingModel = configuration["EMBEDDING_MODEL_NAME"]
?? "text-embedding-ada-002";
var credential = new AzureKeyCredential(_searchApiKey);
_indexClient = new SearchIndexClient(new Uri(_searchEndpoint), credential);
_retrievalClient = new KnowledgeBaseRetrievalClient(new Uri(_searchEndpoint), KnowledgeBaseName, credential);
_openAIClient = new AzureOpenAIClient(new Uri(_aiEndpoint), new AzureKeyCredential(_aiApiKey));
_blobStorageService = blobStorageService;
}
コンストラクターの変更
コンストラクターはオプションの BlobStorageService パラメーターを受け取り、データインデックス時に損傷写真を Azure Blob Storage へアップロードするために使用します。
Step 2: ビジョン関連メソッドを追加する
コードの概要
GetClaimImageUrlAsync: imageUrl フィールドを直接検索インデックスから取得。単純なフィールド取得には RetrieveAsync より効率的。nullable string を返す。
UploadSampleDamagePhotosAsync: 写真アップロードの完全ワークフロー
- JSON ファイルから請求を読み込み
- policyholder 名のパターン (firstname-lastname-*.jpg) で画像をマッチ
- Blob Storage に請求番号ごとにアップロード
- claim-documents-index に検索可能ドキュメントを作成
- claims インデックスを imageUrl フィールドで更新
- 初回起動時に自動実行
これにより 35 枚のサンプル損傷写真がすぐにアップロード・インデックス化されます。
KnowledgeBaseService クラスの終了直前に、以下の 2 つの新メソッドを追加します:
GetClaimImageUrlAsync メソッド - 請求インデックスから画像 URL を直接取得:
/// <summary>
/// Gets the damage photo URL for a specific claim
/// Checks both claims index and claim-documents index
/// </summary>
/// <param name="claimNumber">The claim number to retrieve the image for</param>
/// <returns>The image URL or null if not found</returns>
public async Task<string?> GetClaimImageUrlAsync(string claimNumber)
{
// Check claims index for imageUrl (still stored there for direct access)
var claimsClient = _indexClient.GetSearchClient(ClaimsIndex);
var searchOptions = new SearchOptions
{
Filter = $"claimNumber eq '{claimNumber}'",
Size = 1,
Select = { "imageUrl" }
};
var searchResults = await claimsClient.SearchAsync<SearchDocument>("*", searchOptions);
await foreach (var searchResult in searchResults.Value.GetResultsAsync())
{
var doc = searchResult.Document;
if (doc.ContainsKey("imageUrl") && doc["imageUrl"] != null)
{
return doc["imageUrl"].ToString();
}
}
return null;
}
RetrieveAsync を使わない理由
シンプルなフィールド取得のみの場合、Knowledge Base の RetrieveAsync API を使うよりも検索インデックスを直接クエリする方が効率的です。
UploadSampleDamagePhotosAsync メソッド - infra/img/sample-images から損傷写真をアップロードする完全実装:
/// <summary>
/// Uploads sample damage photos to blob storage and indexes them in Azure AI Search
/// Reads claims from claims.json, matches images from infra/img/sample-images by policyholder name,
/// uploads to blob storage, creates searchable documents, and updates claims with imageUrl
/// </summary>
private async Task UploadSampleDamagePhotosAsync()
{
if (_blobStorageService == null) return;
Console.WriteLine("📸 Uploading sample damage photos to blob storage and indexing...");
var baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
var dataPath = Path.Combine(baseDirectory, "infra", "data", "sample-data");
var filePath = Path.Combine(dataPath, "claims.json");
var imagesPath = Path.Combine(baseDirectory, "infra", "img", "sample-images");
if (!File.Exists(filePath))
{
Console.WriteLine($"⚠️ Claims data file not found: {filePath}");
return;
}
if (!Directory.Exists(imagesPath))
{
Console.WriteLine($"⚠️ Sample images directory not found: {imagesPath}");
return;
}
var json = await File.ReadAllTextAsync(filePath);
var claimsData = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(json);
if (claimsData == null || !claimsData.Any())
{
Console.WriteLine("⚠️ No claims data to process");
return;
}
var uploadCount = 0;
var claimsClient = _indexClient.GetSearchClient(ClaimsIndex);
var claimsToUpdate = new List<SearchDocument>();
Console.WriteLine($"📋 Processing {claimsData.Count} total claims for damage photos...");
Console.WriteLine($"📸 Uploading to blob storage...");
foreach (var claimData in claimsData)
{
var claimNumber = claimData.GetProperty("claimNumber").GetString() ?? "";
var policyholderName = claimData.GetProperty("policyholderName").GetString() ?? "";
// Build the expected image filename based on policyholder name
// Format: firstname-lastname-description.jpg (e.g., "ajlal-nueimat-deer-collision.jpg")
var nameKey = policyholderName.ToLower().Replace(" ", "-");
// Find matching image file in sample-images directory
var imageFiles = Directory.GetFiles(imagesPath, $"{nameKey}*.jpg");
if (imageFiles.Length == 0)
{
Console.WriteLine($"⏭️ No image found for {claimNumber} ({policyholderName})");
continue;
}
var imageFile = imageFiles[0];
var fileName = Path.GetFileName(imageFile);
Console.WriteLine($"📸 Processing damage photo for claim {claimNumber}: {fileName}");
try
{
// Read image from local file
var imageBytes = await File.ReadAllBytesAsync(imageFile);
// Upload to blob storage - blob URL will be directly accessible for viewing and AI analysis
var blobUrl = await _blobStorageService.UploadDamagePhotoAsync(claimNumber, imageBytes, fileName);
// Update the claim record with the image URL for direct access
claimsToUpdate.Add(new SearchDocument
{
["id"] = claimNumber,
["imageUrl"] = blobUrl
});
uploadCount++;
Console.WriteLine($"✅ Uploaded photo for {claimNumber}: {blobUrl}");
}
catch (Exception ex)
{
Console.WriteLine($"⚠️ Failed to upload photo for {claimNumber}: {ex.Message}");
}
}
// Update claims with image URLs
if (claimsToUpdate.Any())
{
Console.WriteLine($"📝 Updating {claimsToUpdate.Count} claims with image URLs...");
var claimsBatch = IndexDocumentsBatch.MergeOrUpload(claimsToUpdate);
await claimsClient.IndexDocumentsAsync(claimsBatch);
Console.WriteLine($"✅ Updated {claimsToUpdate.Count} claims with image URLs");
}
if (uploadCount > 0)
{
Console.WriteLine($"📸 Total: Uploaded {uploadCount} damage photos to blob storage");
}
else
{
Console.WriteLine("⚠️ No damage photos found to upload");
}
}
コードの詳細
UploadSampleDamagePhotosAsync は以下を行います:
- 請求データの読み込み:
infra/data/sample-data/claims.jsonから全請求を読み込み - 画像マッチング:
infra/img/sample-imagesで policyholder 名パターンに一致する画像を検索 - Blob Storage へアップロード: BlobStorageService を使用し請求番号階層で各画像をアップロード
- 請求更新: 既存の請求レコードに imageUrl フィールドをマージし
claims-indexを更新 - 自動実行: IndexSampleDataAsync 呼び出し時に実行
これにより: - ✅ 画像を永続的に保存 - ✅ claims インデックスに imageUrl を追加 - ✅ 画像が即座にビジョン分析可能
Step 3: IndexSampleDataAsync メソッドを更新する
IndexSampleDataAsync メソッドを見つけ、写真アップロード呼び出しを追加する以下のコードに更新します:
public async Task IndexSampleDataAsync()
{
await IndexClaimsDataAsync();
// Upload damage photos to blob storage if BlobStorageService is available
if (_blobStorageService != null)
{
await UploadSampleDamagePhotosAsync();
}
Console.WriteLine("✅ Sample data indexed successfully");
}
演習 4: Vision プラグインを作成する
KnowledgeBaseService に必要なメソッドが追加されたので、これらを使用する VisionPlugin を作成します。
Step 1: 完全な VisionPlugin を作成する
コードの概要
VisionPlugin は AI ビジョン分析機能を提供します:
- ShowDamagePhoto: 請求から損傷写真を取得し、チャット内で表示
- AnalyzeAndShowDamagePhoto: 写真をダウンロードし、Mistral AI で分析。構造化結果を抽出し、フォーマットして提示
- ApproveAnalysis / RejectAnalysis: AI 分析に対するユーザー承認フローを処理(本番では DB 更新やワークフローをトリガー)
- NotifyUserAsync: 長時間処理中のリアルタイムストリーミング更新用ヘルパー
各メソッドは [Description] 属性を持ち、AI エージェントがユーザーの意図に応じて呼び出せるようにします。
1️⃣ src/Plugins/VisionPlugin.cs を新規作成し、完全な実装を追加:
using Microsoft.Agents.Builder;
using Microsoft.Agents.Core;
using Microsoft.Agents.Core.Models;
using System.ComponentModel;
using InsuranceAgent.Services;
using Microsoft.Extensions.Configuration;
namespace ZavaInsurance.Plugins
{
/// <summary>
/// Vision Plugin for Zava Insurance
/// Uses AI vision models to analyze damage photos from insurance claims
/// Provides damage assessment, severity analysis, and repair cost estimates
/// </summary>
public class VisionPlugin
{
private readonly ITurnContext _turnContext;
private readonly KnowledgeBaseService _knowledgeBaseService;
private readonly VisionService _visionService;
private readonly BlobStorageService _blobStorageService;
private readonly IConfiguration _configuration;
public VisionPlugin(
ITurnContext turnContext,
KnowledgeBaseService knowledgeBaseService,
VisionService visionService,
BlobStorageService blobStorageService,
IConfiguration configuration)
{
_turnContext = turnContext ?? throw new ArgumentNullException(nameof(turnContext));
_knowledgeBaseService = knowledgeBaseService ?? throw new ArgumentNullException(nameof(knowledgeBaseService));
_visionService = visionService ?? throw new ArgumentNullException(nameof(visionService));
_blobStorageService = blobStorageService ?? throw new ArgumentNullException(nameof(blobStorageService));
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <summary>
/// Finds and shows the first damage photo for a claim (without analyzing)
/// Proxies image through devtunnel for inline display in chat
/// </summary>
[Description("Finds and shows the first damage photo for a claim. Use this when user wants to see/view the damage photo. Does not perform AI analysis.")]
public async Task<string> ShowDamagePhoto(string claimNumber)
{
await NotifyUserAsync($"🔍 Searching for damage photos in claim {claimNumber}...");
try
{
// Search for the claim with image URL
var imageUrl = await _knowledgeBaseService.GetClaimImageUrlAsync(claimNumber);
if (string.IsNullOrEmpty(imageUrl))
{
return $"❌ No damage photo found for claim {claimNumber}.\n\n" +
$"_The claim may not have an uploaded damage photo yet._";
}
// Get bot endpoint for devtunnel proxy (required for image display in M365 Copilot)
var botEndpoint = _configuration["BOT_ENDPOINT"];
if (string.IsNullOrEmpty(botEndpoint))
{
var botDomain = _configuration["BOT_DOMAIN"];
botEndpoint = !string.IsNullOrEmpty(botDomain) ? $"https://{botDomain}" : "http://localhost:3978";
}
botEndpoint = botEndpoint.TrimEnd('/');
// Proxy the blob storage URL through devtunnel for inline display
var proxyUrl = $"{botEndpoint}/api/image?url={Uri.EscapeDataString(imageUrl)}";
// Return the image with Markdown syntax for inline display
return $"📸 **Damage Photo for Claim {claimNumber}**\n\n" +
$"\n\n" +
$"_Image stored in Azure Blob Storage_";
}
catch (Exception ex)
{
return $"❌ Error retrieving damage photo: {ex.Message}";
}
}
/// <summary>
/// Analyzes a damage photo using Mistral AI vision model and presents results
/// Downloads image, calls VisionService, formats structured analysis
/// </summary>
[Description("Analyzes a damage photo using Mistral AI model and requests user approval before updating the system.")]
public async Task<string> AnalyzeAndShowDamagePhoto(string claimNumber, string documentId)
{
await NotifyUserAsync($"🤖 Starting AI Vision Analysis for claim {claimNumber}...");
try
{
// Get the image URL for this claim from knowledge base
var imageUrl = await _knowledgeBaseService.GetClaimImageUrlAsync(claimNumber);
if (string.IsNullOrEmpty(imageUrl))
{
return $"❌ No damage photo found for claim {claimNumber}.\n\n" +
$"Please ensure a damage photo has been uploaded first.";
}
await NotifyUserAsync($"📸 Downloading image from blob storage...");
// Download the image bytes from blob storage
using var httpClient = new HttpClient();
var imageBytes = await httpClient.GetByteArrayAsync(imageUrl);
var fileName = Path.GetFileName(new Uri(imageUrl).LocalPath);
await NotifyUserAsync($"🤖 Analyzing damage with Mistral AI Vision...");
// Analyze the image using Vision AI (Mistral model)
var analysisResult = await _visionService.AnalyzeDamagePhotoAsync(imageBytes, fileName);
await NotifyUserAsync($"✅ Analysis complete!");
// Format the structured analysis results for user
var response = $"🔍 **AI Vision Analysis Results**\n\n";
response += $"**Claim:** {claimNumber}\n";
response += $"**Image:** {imageUrl}\n\n";
response += $"**Analysis:**\n";
response += $"- **Damage Type:** {analysisResult.DamageType}\n";
response += $"- **Severity:** {analysisResult.Severity}\n";
response += $"- **Estimated Cost:** ${analysisResult.EstimatedRepairCost:N2}\n";
response += $"- **Urgency:** {analysisResult.Urgency}\n";
response += $"- **Description:** {analysisResult.DetailedDescription}\n";
if (analysisResult.AffectedAreas.Length > 0)
response += $"- **Affected Areas:** {string.Join(", ", analysisResult.AffectedAreas)}\n";
if (!string.IsNullOrEmpty(analysisResult.SafetyConcerns))
response += $"- **Safety Concerns:** {analysisResult.SafetyConcerns}\n";
if (!string.IsNullOrEmpty(analysisResult.RepairRecommendations))
response += $"- **Recommendations:** {analysisResult.RepairRecommendations}\n";
if (analysisResult.RequiresSpecialist)
response += $"- **Specialist Required:** {analysisResult.SpecialistType}\n";
response += $"\n---\n\n";
response += $"Would you like to:\n";
response += $"- Approve this analysis and update the claim\n";
response += $"- Reject the analysis\n";
response += $"- Check for fraud indicators\n";
return response;
}
catch (Exception ex)
{
return $"❌ Error analyzing damage photo: {ex.Message}\n\n" +
$"Please try again or contact support if the issue persists.";
}
}
/// <summary>
/// Approves a damage photo analysis via text command
/// In production: would update database, trigger workflows, assign adjusters
/// </summary>
[Description("Approves a damage photo analysis by claim number and document ID. Use this when user says 'approve' or 'approve analysis'.")]
public async Task<string> ApproveAnalysis(string claimNumber, string documentId, string userFeedback = "")
{
return await HandleAnalysisApproval(claimNumber, documentId, true, userFeedback);
}
/// <summary>
/// Rejects a damage photo analysis via text command
/// In production: would flag for manual review, assign human adjuster
/// </summary>
[Description("Rejects a damage photo analysis by claim number and document ID. Use this when user says 'reject' or 'reject analysis'.")]
public async Task<string> RejectAnalysis(string claimNumber, string documentId, string userFeedback = "")
{
return await HandleAnalysisApproval(claimNumber, documentId, false, userFeedback);
}
/// <summary>
/// Common logic for handling analysis approval or rejection
/// Provides structured feedback and next steps
/// </summary>
private async Task<string> HandleAnalysisApproval(string claimNumber, string documentId, bool approved, string userFeedback = "")
{
await NotifyUserAsync($"Processing {(approved ? "approval" : "rejection")}...");
try
{
var action = approved ? "approved" : "rejected";
var emoji = approved ? "✅" : "❌";
// In a real system, you would:
// 1. Update the claim status in the database
// 2. Store the analysis results
// 3. Update estimated costs
// 4. Trigger workflow actions (assign adjuster, schedule inspection, etc.)
var response = $"{emoji} **Analysis {action.ToUpper()}**\n\n";
response += $"**Claim:** {claimNumber}\n";
if (approved)
{
response += $"**Status:** The AI analysis has been accepted and the claim has been updated.\n\n";
response += $"**Next Steps:**\n";
response += $"- The estimated repair costs have been added to the claim\n";
response += $"- An adjuster will be notified for final review\n";
response += $"- The claim is ready for processing\n";
}
else
{
response += $"**Status:** The AI analysis has been rejected.\n\n";
response += $"**Next Steps:**\n";
response += $"- The claim will be flagged for manual review\n";
response += $"- An adjuster will be assigned to inspect the damage\n";
response += $"- Additional documentation may be requested\n";
}
if (!string.IsNullOrEmpty(userFeedback))
{
response += $"\n**Your Feedback:** {userFeedback}\n";
}
response += $"\n_Note: In a production system, this would update the claim database and trigger automated workflows._";
return response;
}
catch (Exception ex)
{
return $"❌ Error processing {(approved ? "approval" : "rejection")}: {ex.Message}";
}
}
/// <summary>
/// Helper to send real-time streaming updates during long operations
/// Shows as typing indicators with messages in chat
/// </summary>
private async Task NotifyUserAsync(string message)
{
// Use StreamingResponse for real-time feedback
if (!_turnContext.Activity.ChannelId.Channel!.Contains(Channels.Webchat))
{
await _turnContext.StreamingResponse.QueueInformativeUpdateAsync(message);
}
else
{
await _turnContext.StreamingResponse.QueueInformativeUpdateAsync(message).ConfigureAwait(false);
}
}
}
}
演習 5: ClaimsPlugin で損傷写真を表示する
Step 1: ClaimsPlugin を更新して損傷写真を表示する
コードの概要
GetClaimImageUrlAsync が利用可能になったため、ClaimsPlugin を更新して請求詳細に損傷写真を表示できるようにします。ラボ BAF2 で削除していた画像表示機能を復活させます。
1️⃣ src/Plugins/ClaimsPlugin.cs を開きます。
2️⃣ GetClaimDetails メソッドを見つけ、終盤付近の以下の部分を探します:
result.AppendLine("**Documentation Status:**");
var isComplete = GetFieldValue(claimDoc, "isDocumentationComplete");
result.AppendLine($"- Documentation Complete: {(isComplete == "True" || isComplete == "true" ? "Yes" : "No")}");
var missingDocs = GetFieldValue(claimDoc, "missingDocumentation");
result.AppendLine($"- Missing Documentation: {(string.IsNullOrWhiteSpace(missingDocs) ? "None" : missingDocs)}");
await NotifyUserAsync($"Retrieved details for claim {claimId}");
return result.ToString();
3️⃣ これを画像表示を含む更新版に置き換えます:
result.AppendLine("**Documentation Status:**");
var isComplete = GetFieldValue(claimDoc, "isDocumentationComplete");
result.AppendLine($"- Documentation Complete: {(isComplete == "True" || isComplete == "true" ? "Yes" : "No")}");
var missingDocs = GetFieldValue(claimDoc, "missingDocumentation");
result.AppendLine($"- Missing Documentation: {(string.IsNullOrWhiteSpace(missingDocs) ? "None" : missingDocs)}");
// Get damage photo URL if available
var imageUrl = await _knowledgeBaseService.GetClaimImageUrlAsync(claimId);
if (!string.IsNullOrEmpty(imageUrl))
{
// Get bot endpoint for devtunnel proxy
var botEndpoint = _configuration["BOT_ENDPOINT"];
if (string.IsNullOrEmpty(botEndpoint))
{
var botDomain = _configuration["BOT_DOMAIN"];
botEndpoint = !string.IsNullOrEmpty(botDomain) ? $"https://{botDomain}" : "http://localhost:3978";
}
botEndpoint = botEndpoint.TrimEnd('/');
// Proxy the blob storage URL through devtunnel
var proxyUrl = $"{botEndpoint}/api/image?url={Uri.EscapeDataString(imageUrl)}";
result.AppendLine();
result.AppendLine("**Damage Photo:**");
result.AppendLine($"");
}
await NotifyUserAsync($"Retrieved details for claim {claimId}");
return result.ToString();
更新理由
ラボ BAF2 では GetClaimImageUrlAsync が存在しなかったため画像表示を省略していました。Exercise 3 で追加したので、ここで機能を復元します。
演習 6: サービス登録とエージェント構成を更新する
すべてを結び付けるために、Program.cs とエージェント構成を更新します。
Step 1: サービス登録と KnowledgeBaseService ファクトリを更新する
コードの概要
- サービス登録: BlobStorageService(シングルトン)と VisionService(スコープ)を DI に登録
- KnowledgeBaseService ファクトリ: BlobStorageService を簡略化されたコンストラクターへ渡すよう更新
ファクトリパターンで適切なサービス解決と初期化順序を保証します。
1️⃣ src/Program.cs を開きます。
2️⃣ builder.Services.AddSingleton<KnowledgeBaseService>(); を見つけ、以下の登録ブロックに置き換えます:
// Register Blob Storage Service for damage photo uploads
builder.Services.AddSingleton<BlobStorageService>();
// Register VisionService for Mistral AI vision analysis
builder.Services.AddScoped<VisionService>();
// Register Knowledge Base Service with BlobStorageService dependency
builder.Services.AddSingleton<KnowledgeBaseService>(serviceProvider =>
{
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
var blobStorageService = serviceProvider.GetRequiredService<BlobStorageService>();
return new KnowledgeBaseService(configuration, blobStorageService);
});
簡略化されたコンストラクター
更新後の KnowledgeBaseService コンストラクターは以下を受け取ります:
- IConfiguration: 接続文字列やエンドポイント
- BlobStorageService: 損傷写真アップロード用(オプション)
サービス内部で SearchIndexClient、KnowledgeBaseRetrievalClient、AzureOpenAIClient を生成し、依存を簡素化します。
Step 2: エージェントに VisionPlugin を追加する
コードの概要
Agent Instructions: システムプロンプトを更新し VisionPlugin のツール (ShowDamagePhoto, AnalyzeAndShowDamagePhoto, ApproveAnalysis) を追加
Plugin 作成: 必要な依存関係 (context, knowledge base, vision service, blob storage, configuration) を用いて VisionPlugin をインスタンス化
Tool 登録: 4 つのビジョンツールをエージェントの機能セットに追加
1️⃣ src/Agent/ZavaInsuranceAgent.cs を開きます。
2️⃣ AgentInstructions プロパティを見つけ、以下で置き換えます:
private readonly string AgentInstructions = """
You are a professional insurance claims assistant for Zava Insurance.
Whenever the user starts a new conversation or provides a prompt to start a new conversation like "start over", "restart",
"new conversation", "what can you do?", "how can you help me?", etc. use {{StartConversationPlugin.StartConversation}} and
provide to the user exactly the message you get back from the plugin.
**Available Tools:**
Use {{DateTimeFunctionTool.getDate}} to get the current date and time.
For claims search, use {{ClaimsPlugin.SearchClaims}} and {{ClaimsPlugin.GetClaimDetails}}.
For damage photo viewing, use {{VisionPlugin.ShowDamagePhoto}}.
For AI vision damage analysis, use {{VisionPlugin.AnalyzeAndShowDamagePhoto}} and require approval via {{VisionPlugin.ApproveAnalysis}}.
Stick to the scenario above and use only the information from the tools when answering questions.
Be concise and professional in your responses.
""";
3️⃣ GetClientAgent メソッド内で、var knowledgeBaseService = ... の後に以下を追加:
// Resolve vision and storage services
var visionService = scope.ServiceProvider.GetRequiredService<VisionService>();
var blobStorageService = scope.ServiceProvider.GetRequiredService<BlobStorageService>();
4️⃣ ClaimsPlugin claimsPlugin = new(...) の直後に以下を追加:
// Create VisionPlugin with all dependencies
VisionPlugin visionPlugin = new(context, knowledgeBaseService, visionService, blobStorageService, configuration);
5️⃣ toolOptions.Tools にツールを追加している箇所を見つけ、ビジョンツールを追加:
// Register Vision tools for AI damage photo analysis
toolOptions.Tools.Add(AIFunctionFactory.Create(visionPlugin.AnalyzeAndShowDamagePhoto));
toolOptions.Tools.Add(AIFunctionFactory.Create(visionPlugin.ShowDamagePhoto));
toolOptions.Tools.Add(AIFunctionFactory.Create(visionPlugin.ApproveAnalysis));
toolOptions.Tools.Add(AIFunctionFactory.Create(visionPlugin.RejectAnalysis));
Step 3: 画像プロキシエンドポイントを追加する
必要な理由
Microsoft 365 Copilot はネットワーク制限により Azure Blob Storage の URL へ直接アクセスできません。画像をチャット内で表示するため、ボットの devtunnel 経由で /api/image エンドポイントからプロキシする必要があります。
1️⃣ src/Program.cs を開きます。
2️⃣ app.MapControllers() がある箇所を探します(ファイル終盤、app.Run() の前)。
3️⃣ app.MapGet("/api/citation" の 後ろ に画像プロキシエンドポイントを追加します:
app.MapGet("/api/image", async (string url) =>
{
try
{
using var httpClient = new HttpClient();
var imageBytes = await httpClient.GetByteArrayAsync(url);
var contentType = url.EndsWith(".png") ? "image/png" : "image/jpeg";
return Results.File(imageBytes, contentType);
}
catch (Exception ex)
{
return Results.Problem($"Error retrieving image: {ex.Message}");
}
});
プロキシの仕組み
- VisionPlugin が ShowDamagePhoto を呼び出し請求番号を指定
- knowledge base から Blob Storage URL を取得 (例:
https://storage.blob.core.windows.net/claim-photos/image.jpg) - プロキシ URL を構築:
https://your-devtunnel.devtunnels.ms/api/image?url=<escaped-blob-url> - Copilot がボットエンドポイントへリクエスト
- ボットが Blob Storage から画像を取得し返却
- チャット内で画像が表示
Step 4: StartConversationPlugin のウェルカムメッセージを更新する
ビジョン分析機能を追加したため、ウェルカムメッセージも更新します。
1️⃣ src/Plugins/StartConversationPlugin.cs を開きます。
2️⃣ StartConversation メソッド内の welcomeMessage 変数を以下に置き換えます:
var welcomeMessage = "👋 Welcome to Zava Insurance Claims Assistant!\n\n" +
"I'm your AI-powered insurance claims specialist. I help adjusters and investigators streamline the claims process.\n\n" +
"**What I can do:**\n\n" +
"- Search and retrieve detailed claim information\n" +
"- Use Mistral AI to analyze damage photos instantly\n" +
"- Provide damage assessments with cost estimates\n" +
"- Identify safety concerns from photos\n" +
"- Provide current date and time\n\n" +
"🎯 Try this workflow:\n" +
"1. \"Get details for claim CLM-2025-001007\"\n" +
"2. \"Show damage photo for this claim\"\n" +
"3. \"Analyze this damage photo\"\n" +
"4. \"Approve the analysis\" or \"Reject the analysis\"\n\n" +
"Ready to help with your claims investigation. What would you like to start with?";
段階的な機能アップデート
ウェルカムメッセージにビジョン分析機能 (損傷写真表示と Mistral による AI 分析) を追加しました。各ラボの進行に合わせてメッセージを更新します。
演習 7: ビジョン分析をテストする
ビジョン分析機能をテストしましょう。
Step 1: エージェントを実行する
1️⃣ VS Code で F5 を押してデバッグを開始します。
2️⃣ プロンプトが出たら (Preview) Debug in Copilot (Edge) を選択します。
3️⃣ ターミナル出力を確認し、次のような表示を確認します:
🔍 Initializing Azure AI Search Knowledge Base...
📝 Creating claims index 'claims-index'...
✅ Claims index 'claims-index' created successfully
✅ Knowledge source 'claims-knowledge-source' created
✅ Knowledge base 'zava-insurance-kb' created with model 'gpt-4.1'
📝 Indexing sample claims...
✅ Indexed 35 claims
📸 Uploading sample damage photos to blob storage and indexing...
📋 Processing 35 total claims for damage photos...
📸 Uploading to blob storage...
📸 Processing damage photo for claim CLM-2025-001001: ajlal-nueimat-deer-collision.jpg
✅ Uploaded photo for CLM-2025-001001: https://your-storage.blob.core.windows.net/claim-photos/...
...
📸 Total: Uploaded 35 damage photos to blob storage
✅ Sample data indexed successfully
4️⃣ Blob Storage を確認(任意だが推奨):
- Azure Portal → ストレージアカウントへ
- Containers → claim-photos を選択
- 35 枚の画像が請求番号ごとにアップロードされていることを確認
- 各画像は直接アクセス可能なパブリック Blob URL を持つ
5️⃣ Azure AI Search Knowledge Sources を確認(任意だが推奨):
- Azure Portal → Azure AI Search サービスを検索
- Agentic retrieval → 左メニューの Knowledge Sources
claims-knowledge-sourceが表示されていることを確認- これが請求インデックスを Knowledge Base に接続し AI 検索を可能にします
6️⃣ ブラウザーが開き、Microsoft 365 Copilot が起動します。前のラボでエージェントはすでにインストール済みです。
Step 2: 損傷写真の表示をテストする
1️⃣ Microsoft 365 Copilot で次を試します:
Show me the damage photo for claim CLM-2025-001007
エージェントは ShowDamagePhoto ツールを使用し、損傷写真を表示するはずです。
画像読み込み時間
画像は Azure Blob Storage からボットエンドポイント経由でプロキシされるため、チャット内で読み込まれるまで数秒かかる場合があります。正常な動作です。
2️⃣ 別の請求を試します:
View the damage photo for claim CLM-2025-001003
Step 2: AI ビジョン分析をテストする
1️⃣ 次を試します:
Analyze the damage photo for claim CLM-2025-001007
エージェントは以下を行います:
- AnalyzeAndShowDamagePhoto ツールを使用
- 画像をダウンロードして Mistral AI で分析
- 損傷タイプ、深刻度、費用見積もり、推奨事項を含む詳細分析を提示
- 承認または拒否を求める
2️⃣ 分析を確認後、次を試します:
Approve the analysis
エージェントは ApproveAnalysis を使用し、承認と次のステップを確認するはずです。
🎉 おめでとうございます!
AI ビジョン分析を保険エージェントに追加できました!
達成したこと:
✅ Mistral medium-2505 ビジョンモデルをデプロイ
✅ 画像分析用の VisionService を作成
✅ 複数のビジョン機能を持つ VisionPlugin を構築
✅ 構造化出力による AI 損傷評価を実装
✅ AI 生成分析の承認ワークフローを追加
エージェントができること:
- 請求の損傷写真を表示
- マルチモーダル AI で写真を分析
- 損傷タイプ、深刻度、費用見積もりを抽出
- 安全上の懸念を特定し修理を推奨
- AI 分析の承認ワークフローを提供
次のラボでは、認証とメール機能を追加し、エージェントのセキュリティとコミュニケーション機能を強化します。