ラボ BAF3 - Mistral AI を使用したビジョン解析の追加
学習内容
- Mistral AI ビジョンモデルを統合し、マルチモーダル解析を行う方法
- Azure Blob Storage から画像データを処理する方法
- AI を活用した損傷評価の実装
- AI が生成した解析結果に対する承認ワークフローの作成
概要
Lab BAF2 では、Azure AI Search Knowledgebases を使用して請求検索を追加しました。今回は、マルチモーダルの AI ビジョン機能を強化し、損傷写真を解析して詳細な評価レポートを生成できるようにします。
Vision Service は Azure AI Services にデプロイされた mistral-medium-2505 モデルを使用し、画像を解析して損傷評価を含む構造化 JSON 応答を生成します。
エクササイズ 1: 前提条件の更新
ビジョン解析を追加する前に、Mistral ビジョンモデルをデプロイする必要があります。
手順 1: Microsoft Foundry で Mistral Vision モデルをデプロイ
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に変更
手順 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 をクリック
Public Access に必要
匿名 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 Public Access の理由
コンテナーを「Blob」公開アクセスレベルに設定すると、次の利点があります:
- AI ビジョンモデルが個別画像の URL に直接アクセス可能
- チャットインターフェイスで画像を表示可能
- 読み取りのための認証が不要
ただし、公開されるのは個別の BLOB URL のみであり、コンテナーリストは公開されないためセキュリティが保たれます。
手順 3: 構成の更新
ビジョンモデルと BLOB ストレージの構成を環境変数に追加します。
1️⃣ .env.local ファイルを開きます。
2️⃣ ビジョンモデルと BLOB ストレージの構成を追加します:
# 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
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 ストレージを処理するサービスを作成します。
手順 1: VisionService と BlobStorageService を作成
このコードの概要
VisionService: Mistral AI ビジョンモデルで損傷写真を解析
- Azure OpenAI の 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 にメソッドを追加します。これにより、プラグイン作成時にすべての依存関係が存在するようになります。
手順 1: KnowledgeBaseService コンストラクターの更新
変更点
KnowledgeBaseService のコンストラクターが、損傷写真アップロード用のオプション引数として BlobStorageService を受け取るようになります。Lab BAF2 でコンストラクターは IConfiguration だけを取るように簡略化されていましたが、今回はそれに BlobStorageService を追加します。
1️⃣ src/Services/KnowledgeBaseService.cs を開きます。
2️⃣ KnowledgeBaseService のコンストラクターを探し、以下のコードブロックに置き換えて BlobStorageService フィールドとコンストラクター引数を追加します:
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 にアップロードするために使用します。
手順 2: ビジョン関連メソッドの追加
このコードの概要
GetClaimImageUrlAsync: claims インデックスに対して imageUrl フィールドのみを直接クエリし、シンプルな検索を高速化。NULL 可能な文字列を返します。
UploadSampleDamagePhotosAsync: 写真アップロードの完全なワークフロー
- JSON ファイルから請求を読み込み
- policyholder 名パターン (firstname-lastname-*.jpg) で画像をマッチング
- Blob Storage に請求番号ごとに写真をアップロード
- claim-documents-index に検索可能ドキュメントを作成
- claims インデックスの imageUrl フィールドを更新
- 初回起動時に自動実行
これにより、35 枚のサンプル損傷写真が即座にアップロード・インデックス化されます。
KnowledgeBaseService クラスの末尾(閉じ括弧の直前)に、次の 2 つのメソッドを追加します:
GetClaimImageUrlAsync メソッド - claims インデックスから直接 imageUrl を取得:
/// <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 にアップロード: 画像を請求番号フォルダーにアップロード
- 請求を更新: claims-index の各レコードに imageUrl フィールドをマージ
- 自動実行: IndexSampleDataAsync 実行時に呼び出されます
これにより、
- ✅ 画像が永続的に Blob Storage へアップロード
- ✅ claims インデックスに imageUrl が追加され直接参照可能
- ✅ 画像は即座に AI ビジョン解析で利用可能
になります。
手順 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 を作成します。
手順 1: 完全な VisionPlugin を作成
このコードの概要
VisionPlugin は AI ビジョン解析機能を提供します:
- ShowDamagePhoto: 損傷写真を取得してチャット内で表示
- AnalyzeAndShowDamagePhoto: 画像をダウンロードし、Mistral AI で解析し結果を表示
- ApproveAnalysis/RejectAnalysis: AI 解析への承認ワークフロー
- NotifyUserAsync: 長時間処理中のリアルタイムストリーミング更新
各メソッドには [Description] 属性が付いており、エージェントはユーザー意図に応じて呼び出します。
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 を更新し損傷写真を表示
手順 1: ClaimsPlugin を写真表示対応に更新
このコードの概要
KnowledgeBaseService に GetClaimImageUrlAsync が追加されたため、ClaimsPlugin に画像表示機能を復活させます。
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();
更新が必要な理由
Lab BAF2 では GetClaimImageUrlAsync が存在しなかったため画像表示コードを省いていました。Exercise 3 でメソッドが追加されたので復活させます。
エクササイズ 6: サービス登録とエージェント設定の更新
Program.cs とエージェント設定を更新し、すべてを連携させます。
手順 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(オプション)
手順 2: エージェントに VisionPlugin を追加
このコードの概要
- AgentInstructions: VisionPlugin ツールを含むようシステムプロンプトを更新
- プラグイン生成: VisionPlugin をインスタンス化し依存関係を注入
- ツール登録: Vision 関連ツールをエージェントに追加
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 にツールを追加している部分に Vision ツールを追加します:
// 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));
手順 3: 画像プロキシエンドポイントの追加
必要な理由
Microsoft 365 Copilot はネットワーク制限により直接 Azure Blob Storage URL にアクセスできません。画像をチャット内で表示するには、ボットの devtunnel エンドポイント経由でプロキシする必要があります。
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 を呼び出し、BLOB URL を取得
- プロキシ URL(
/api/image?url=...)を生成 - Copilot がボットエンドポイントにリクエスト
- ボットが BLOB から画像を取得し返却
- 画像がチャット内でインライン表示
手順 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: ビジョン解析をテスト
ビジョン解析機能をテストしてみましょう!
手順 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 枚の画像が請求番号ごとにアップロードされていることを確認
5️⃣ Azure AI Search Knowledge Sources を確認(任意推奨):
- Azure Portal → Azure AI Search サービス
- Agentic retrieval → 左メニューの Knowledge Sources
- claims-knowledge-source が表示されていることを確認
6️⃣ ブラウザーが開き、Microsoft 365 Copilot が起動します。エージェントは前回のラボで既にインストール済みです。
手順 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
手順 2: AI ビジョン解析をテスト
1️⃣ 次を試します:
Analyze the damage photo for claim CLM-2025-001007
エージェントは以下を実行します:
- AnalyzeAndShowDamagePhoto ツールを使用
- 画像をダウンロードして Mistral AI で解析
- 損傷タイプ、重症度、修理費見積もり、推奨事項など詳細を提示
- 承認または却下を求めるプロンプトを表示
2️⃣ 解析を確認した後、次を試します:
Approve the analysis
エージェントは ApproveAnalysis を使用し、承認と次のステップを通知します。
手順 3: 複合ワークフローをテスト
1️⃣ 次を試します:
Show me high severity claims in the Northeast region, then analyze their damage photos
エージェントはまず請求を検索し、その後該当する請求の損傷写真を解析します。
🎉 おめでとうございます!
保険エージェントに AI ビジョン解析を正常に追加できました!
達成したこと:
✅ Mistral medium-2505 ビジョンモデルをデプロイ
✅ 画像解析用 VisionService を作成
✅ 複数のビジョン機能を持つ VisionPlugin を構築
✅ 構造化出力による AI 損傷評価を実装
✅ AI 解析結果の承認ワークフローを追加
エージェントができること:
- 請求の損傷写真を表示
- マルチモーダル AI で写真を解析
- 損傷タイプ、重症度、修理費を抽出
- 安全上の懸念点を特定し修理を推奨
- AI 解析の承認ワークフローを提供
次のラボでは、認証とメール機能を追加し、エージェントのセキュリティと通信機能を強化します。