diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 580a43ba2..dfd9f1beb 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -44,7 +44,8 @@
"Bash(python:*)",
"Bash(python3:*)",
"Bash(test:*)",
- "Bash(Select-Object -Last 20)"
+ "Bash(Select-Object -Last 20)",
+ "Bash(git mv:*)"
],
"deny": []
}
diff --git a/Directory.Packages.props b/Directory.Packages.props
index f0b889ee9..2ab4e7b80 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -36,6 +36,7 @@
+
@@ -142,6 +143,7 @@
+
diff --git a/modules/Insurance/Files/Microsoft/2026/Microsoft.xlsx b/modules/Insurance/Files/Microsoft/2026/Microsoft.xlsx
index 33fd6cc1e..ab1b522fe 100644
Binary files a/modules/Insurance/Files/Microsoft/2026/Microsoft.xlsx and b/modules/Insurance/Files/Microsoft/2026/Microsoft.xlsx differ
diff --git a/modules/Insurance/Files/Microsoft/2026/Slip.md b/modules/Insurance/Files/Microsoft/2026/Slip.md
index fdb61dab4..dc26a1d3d 100644
--- a/modules/Insurance/Files/Microsoft/2026/Slip.md
+++ b/modules/Insurance/Files/Microsoft/2026/Slip.md
@@ -14,69 +14,78 @@
**Address:** One Microsoft Way, Redmond, WA 98052, United States
## Period of Insurance
-**From:** 1 January 2026
-**To:** 31 December 2026
+**From:** 1 January 2026
+**To:** 31 December 2026
**Local Standard Time at the address of the Insured**
---
+## Reinsurance Terms
+
+**Estimated Premium Income (EPI):** USD 200,000,000
+**Brokerage:** 10%
+
+---
+
## Coverage Sections
### 1. Fire Damage
- **Layer 1:**
- - Attachment Point: USD 0
- - Limit per Occurrence: USD 100,000,000
- - **Annual Aggregate Limit:** USD 300,000,000
- - **Annual Aggregate Deductible:** USD 25,000,000 (applies to Fire only)
+ - Deductible per Occurrence: USD 5,000,000
+ - Limit per Occurrence: USD 100,000,000
+ - **Annual Aggregate Deductible:** USD 25,000,000
+ - **Annual Aggregate Limit:** USD 300,000,000
- **Layer 2:**
- - Attachment Point: USD 100,000,000
- - Limit per Occurrence: USD 150,000,000
- - **Annual Aggregate Limit:** USD 450,000,000
+ - Attachment Point: USD 105,000,000
+ - Limit per Occurrence: USD 145,000,000
+ - **Annual Aggregate Limit:** USD 435,000,000
- **Layer 3:**
- - Attachment Point: USD 250,000,000
- - Limit per Occurrence: USD 250,000,000
- - **Annual Aggregate Limit:** USD 600,000,000
+ - Attachment Point: USD 250,000,000
+ - Limit per Occurrence: USD 250,000,000
+ - **Annual Aggregate Limit:** USD 750,000,000
---
-### 2. Natural Catastrophe (Earthquake, Flood, Storm)
+### 2. Natural Catastrophe (Windstorm, Earthquake)
- **Layer 1:**
- - Attachment Point: USD 0
- - Limit per Occurrence: USD 75,000,000
- - **Annual Aggregate Limit:** USD 225,000,000
+ - Deductible per Occurrence: USD 5,000,000
+ - Limit per Occurrence: USD 100,000,000
+ - **Annual Aggregate Deductible:** USD 25,000,000
+ - **Annual Aggregate Limit:** USD 300,000,000
- **Layer 2:**
- - Attachment Point: USD 75,000,000
- - Limit per Occurrence: USD 125,000,000
- - **Annual Aggregate Limit:** USD 375,000,000
+ - Attachment Point: USD 105,000,000
+ - Limit per Occurrence: USD 145,000,000
+ - **Annual Aggregate Limit:** USD 435,000,000
- **Layer 3:**
- - Attachment Point: USD 200,000,000
- - Limit per Occurrence: USD 200,000,000
- - **Annual Aggregate Limit:** USD 500,000,000
+ - Attachment Point: USD 250,000,000
+ - Limit per Occurrence: USD 250,000,000
+ - **Annual Aggregate Limit:** USD 750,000,000
---
### 3. Business Interruption
- **Layer 1:**
- - Attachment Point: USD 0
- - Limit per Occurrence: USD 50,000,000
- - **Annual Aggregate Limit:** USD 150,000,000
+ - Deductible per Occurrence: USD 5,000,000
+ - Limit per Occurrence: USD 100,000,000
+ - **Annual Aggregate Deductible:** USD 25,000,000
+ - **Annual Aggregate Limit:** USD 300,000,000
- **Layer 2:**
- - Attachment Point: USD 50,000,000
- - Limit per Occurrence: USD 100,000,000
- - **Annual Aggregate Limit:** USD 300,000,000
+ - Attachment Point: USD 105,000,000
+ - Limit per Occurrence: USD 145,000,000
+ - **Annual Aggregate Limit:** USD 435,000,000
- **Layer 3:**
- - Attachment Point: USD 150,000,000
- - Limit per Occurrence: USD 150,000,000
- - **Annual Aggregate Limit:** USD 450,000,000
+ - Attachment Point: USD 250,000,000
+ - Limit per Occurrence: USD 250,000,000
+ - **Annual Aggregate Limit:** USD 750,000,000
---
diff --git a/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAIExtensions.cs b/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAIExtensions.cs
index 7a562ce36..9a42b4f16 100644
--- a/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAIExtensions.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAIExtensions.cs
@@ -14,6 +14,8 @@ public static class InsuranceAIExtensions
public static IServiceCollection AddInsuranceAI(this IServiceCollection services)
{
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
return services;
}
diff --git a/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAgent.cs b/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAgent.cs
index e6b406b3f..4d9d5feaa 100644
--- a/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAgent.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.AI/InsuranceAgent.cs
@@ -12,8 +12,8 @@ namespace MeshWeaver.Insurance.AI;
///
/// Main Insurance agent that provides access to insurance pricing data and collections.
///
-[DefaultAgent]
-public class InsuranceAgent(IMessageHub hub) : IInitializableAgent, IAgentWithPlugins, IAgentWithContext
+[ExposedInNavigator]
+public class InsuranceAgent(IMessageHub hub) : IInitializableAgent, IAgentWithPlugins, IAgentWithContext, IAgentWithDelegations
{
private Dictionary? typeDefinitionMap;
private Dictionary? layoutAreaMap;
@@ -23,34 +23,65 @@ public class InsuranceAgent(IMessageHub hub) : IInitializableAgent, IAgentWithPl
public string Description =>
"Handles all questions and actions related to insurance pricings, property risks, and dimensions. " +
"Provides access to pricing data, allows creation and management of pricings and property risks. " +
- "Also manages submission documents and files for each pricing.";
+ "Also manages submission documents and files for each pricing. " +
+ "Can delegate to specialized import agents for processing risk data files and slip documents.";
+
+ public IEnumerable Delegations
+ {
+ get
+ {
+ yield return new DelegationDescription(
+ nameof(RiskImportAgent),
+ "Delegate to RiskImportAgent when the user wants to import property risks from Excel files, " +
+ "or when working with risk data files (.xlsx, .xls) that contain property information like " +
+ "location, TSI (Total Sum Insured), address, country, currency, building values, etc. " +
+ "Common file names include: risks.xlsx, exposure.xlsx, property schedule, location schedule, etc."
+ );
+
+ yield return new DelegationDescription(
+ nameof(SlipImportAgent),
+ "Delegate to SlipImportAgent when the user wants to import insurance slips from PDF documents, " +
+ "or when working with slip files (.pdf) that contain insurance submission information like " +
+ "insured details, coverage terms, premium information, reinsurance structure layers, limits, rates, etc. " +
+ "Common file names include: slip.pdf, submission.pdf, placement.pdf, quote.pdf, etc."
+ );
+ }
+ }
public string Instructions =>
$$$"""
The agent is the InsuranceAgent, specialized in managing insurance pricings:
+ ## Content Collection Context
+
+ IMPORTANT: The current context is set to pricing/{pricingId} where pricingId follows the format {company}-{uwy}.
+ - The submission files collection is named "Submissions-{pricingId}"
+ - All file paths are relative to the root (/) of this collection
+ - Example: For pricing "AXA-2024", the collection is "Submissions-AXA-2024" and files are at paths like "/slip.pdf", "/risks.xlsx"
+
## Working with Submission Documents and Files
CRITICAL: When users ask about submission files, documents, or content:
- DO NOT call {{{nameof(DataPlugin.GetData)}}} for Pricing or any other data first
- DO NOT try to verify the pricing exists before accessing files
- - The SubmissionPlugin is already configured for the current pricing context
- - Simply call the SubmissionPlugin functions directly
-
- Available SubmissionPlugin functions (all collectionName parameters are optional):
- - {{{nameof(ContentCollectionPlugin.ListFiles)}}}() - List all files in the current pricing's submissions
- - {{{nameof(ContentCollectionPlugin.ListFolders)}}}() - List all folders
- - {{{nameof(ContentCollectionPlugin.ListCollectionItems)}}}() - List both files and folders
- - {{{nameof(ContentCollectionPlugin.GetDocument)}}}(documentPath) - Get document content
- - {{{nameof(ContentCollectionPlugin.SaveDocument)}}}(documentPath, content) - Save a document
- - {{{nameof(ContentCollectionPlugin.DeleteFile)}}}(filePath) - Delete a file
- - {{{nameof(ContentCollectionPlugin.CreateFolder)}}}(folderPath) - Create a folder
- - {{{nameof(ContentCollectionPlugin.DeleteFolder)}}}(folderPath) - Delete a folder
+ - The ContentPlugin is already configured for the current pricing context
+ - Simply call the ContentPlugin functions directly
+ - All file paths should start with "/" (e.g., "/slip.pdf", "/risks.xlsx")
+
+ Available ContentPlugin functions (all collectionName parameters are optional):
+ - {{{nameof(ContentPlugin.ListFiles)}}}() - List all files in the current pricing's submissions
+ - {{{nameof(ContentPlugin.ListFolders)}}}() - List all folders
+ - {{{nameof(ContentPlugin.ListCollectionItems)}}}() - List both files and folders
+ - {{{nameof(ContentPlugin.GetDocument)}}}(documentPath) - Get document content (use path like "/Slip.md")
+ - {{{nameof(ContentPlugin.SaveFile)}}}(documentPath, content) - Save a document
+ - {{{nameof(ContentPlugin.DeleteFile)}}}(filePath) - Delete a file
+ - {{{nameof(ContentPlugin.CreateFolder)}}}(folderPath) - Create a folder
+ - {{{nameof(ContentPlugin.DeleteFolder)}}}(folderPath) - Delete a folder
Examples:
- - User: "Show me the submission files" → You: Call {{{nameof(ContentCollectionPlugin.ListFiles)}}}()
- - User: "What files are in the submissions?" → You: Call {{{nameof(ContentCollectionPlugin.ListFiles)}}}()
- - User: "Read the slip document" → You: Call {{{nameof(ContentCollectionPlugin.GetDocument)}}}("Slip.md")
+ - User: "Show me the submission files" → You: Call {{{nameof(ContentPlugin.ListFiles)}}}()
+ - User: "What files are in the submissions?" → You: Call {{{nameof(ContentPlugin.ListFiles)}}}()
+ - User: "Read the slip document" → You: Call {{{nameof(ContentPlugin.GetDocument)}}}("/Slip.md")
## Working with Pricing Data
@@ -71,14 +102,14 @@ IEnumerable IAgentWithPlugins.GetPlugins(IAgentChat chat)
yield return new DataPlugin(hub, chat, typeDefinitionMap).CreateKernelPlugin();
yield return new LayoutAreaPlugin(hub, chat, layoutAreaMap).CreateKernelPlugin();
- // Always provide ContentCollectionPlugin - it will use ContextToConfigMap to determine the collection
- var submissionPluginConfig = CreateSubmissionPluginConfig(chat);
- yield return new ContentCollectionPlugin(hub, submissionPluginConfig, chat).CreateKernelPlugin();
+ // Always provide ContentPlugin - it will use ContextToConfigMap to determine the collection
+ var submissionPluginConfig = CreateSubmissionPluginConfig();
+ yield return new ContentPlugin(hub, submissionPluginConfig, chat).CreateKernelPlugin();
}
- private static ContentCollectionPluginConfig CreateSubmissionPluginConfig(IAgentChat chat)
+ private static ContentPluginConfig CreateSubmissionPluginConfig()
{
- return new ContentCollectionPluginConfig
+ return new ContentPluginConfig
{
Collections = [],
ContextToConfigMap = context =>
@@ -118,7 +149,7 @@ async Task IInitializableAgent.InitializeAsync()
var typesResponse = await hub.AwaitResponse(
new GetDomainTypesRequest(),
o => o.WithTarget(new PricingAddress("default")));
- typeDefinitionMap = typesResponse.Message.Types.Select(t => t with { Address = null }).ToDictionary(x => x.Name);
+ typeDefinitionMap = typesResponse?.Message?.Types?.Select(t => t with { Address = null }).ToDictionary(x => x.Name!);
}
catch
{
@@ -130,7 +161,7 @@ async Task IInitializableAgent.InitializeAsync()
var layoutAreaResponse = await hub.AwaitResponse(
new GetLayoutAreasRequest(),
o => o.WithTarget(new PricingAddress("default")));
- layoutAreaMap = layoutAreaResponse.Message.Areas.ToDictionary(x => x.Area);
+ layoutAreaMap = layoutAreaResponse?.Message?.Areas?.ToDictionary(x => x.Area);
}
catch
{
diff --git a/modules/Insurance/MeshWeaver.Insurance.AI/MeshWeaver.Insurance.AI.csproj b/modules/Insurance/MeshWeaver.Insurance.AI/MeshWeaver.Insurance.AI.csproj
index f5e01a264..292eb0d9d 100644
--- a/modules/Insurance/MeshWeaver.Insurance.AI/MeshWeaver.Insurance.AI.csproj
+++ b/modules/Insurance/MeshWeaver.Insurance.AI/MeshWeaver.Insurance.AI.csproj
@@ -14,4 +14,10 @@
+
+
+
+
+
+
diff --git a/modules/Insurance/MeshWeaver.Insurance.AI/RiskImportAgent.cs b/modules/Insurance/MeshWeaver.Insurance.AI/RiskImportAgent.cs
new file mode 100644
index 000000000..fbd697eec
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.AI/RiskImportAgent.cs
@@ -0,0 +1,198 @@
+using System.Text.Json.Nodes;
+using MeshWeaver.AI;
+using MeshWeaver.AI.Plugins;
+using MeshWeaver.ContentCollections;
+using MeshWeaver.Data;
+using MeshWeaver.Insurance.Domain;
+using MeshWeaver.Messaging;
+using Microsoft.SemanticKernel;
+
+namespace MeshWeaver.Insurance.AI;
+
+public class RiskImportAgent(IMessageHub hub) : IInitializableAgent, IAgentWithPlugins, IAgentWithContext
+{
+ private Dictionary? typeDefinitionMap;
+ private string? propertyRiskSchema;
+ private string? excelImportConfigSchema;
+
+ public string Name => nameof(RiskImportAgent);
+
+ public string Description => "Runs risk imports for a pricing. Creates mappings and imports property risk data from Excel files.";
+
+ public string Instructions
+ {
+ get
+ {
+ var baseText =
+ $$$"""
+ You control risk imports for a specific pricing. Use the provided tool:
+
+ ## Content Collection Context
+
+ IMPORTANT: The current context is set to pricing/{pricingId} where pricingId follows the format {company}-{uwy}.
+ - The submission files collection is named "Submissions-{pricingId}"
+ - All file paths are relative to the root (/) of this collection
+ - When listing files, you'll see paths like "/risks.xlsx", "/exposure.xlsx"
+ - When accessing files, use paths starting with "/" (e.g., "/risks.xlsx")
+
+ # Importing Risks
+ When the user asks you to import risks, you should:
+ 1) Get the existing risk mapping configuration for the specified file using DataPlugin's GetData function with type="ExcelImportConfiguration" and entityId=filename.
+ 2) If no import configuration was returned in 1, get a sample of the worksheet using ContentPlugin's GetContent function with the collection name "Submissions-{pricingId}", the filename, and numberOfRows=20. Extract the table start row as well as the mapping as in the schema provided below.
+ Consider any input from the user to modify the configuration. Ensure the JSON includes "name" field set to the filename. Use DataPlugin's UpdateData function with type="ExcelImportConfiguration" to save the configuration.
+ 3) Call ContentPlugin's Import function with path=filename, collection="Submissions-{pricingId}", address=PricingAddress, and configuration=the JSON configuration you created or retrieved.
+
+ # Updating Risk Import Configuration
+ When the user asks you to update the risk import configuration, you should:
+ 1) Get the existing risk mapping configuration for the specified file using DataPlugin's GetData function with type="ExcelImportConfiguration" and entityId=filename.
+ 2) Modify it according to the user's input, ensuring it follows the schema provided below.
+ 3) Upload the new configuration using DataPlugin's UpdateData function with type="ExcelImportConfiguration" and the updated JSON (ensure "name" field is set to filename).
+
+ # Automatic Risk Import Configuration
+ - Use ContentPlugin's GetContent with numberOfRows=20 to get a sample of the file. It returns a markdown table with:
+ - First column: Row numbers (1-based)
+ - Remaining columns: Labeled A, B, C, D, etc. (Excel column letters)
+ - Empty cells appear as empty values in the table (not "null")
+ - Column letters start with A (first data column after Row number). Empty columns are still shown with their letters.
+ - Row numbers are 1-based. When specifying tableStartRow, use the row number from the Row column (e.g., if headers are on row 1 and data starts on row 2, set tableStartRow=2).
+ - Look for the header row in the markdown table and map column letters (A, B, C, etc.) to PropertyRisk properties.
+ - Map to the properties of the PropertyRisk type (see schema below). Only these names are allowed for mappings. Read the descriptions contained in the schema to get guidance on which field to map where.
+ - IMPORTANT: Each TargetProperty should appear ONLY ONCE in the configuration. If a property maps to multiple columns, use the SourceColumns list (e.g., "sourceColumns": ["A", "B"]) instead of creating multiple entries with the same TargetProperty.
+ - IMPORTANT: Each column (A, B, C, etc.) should be mapped ONLY ONCE across all mappings. Do not include the same column in multiple targetProperty mappings or sourceColumns lists.
+ - Columns you cannot map ==> ignore (don't include them in the configuration).
+ - Empty columns at the beginning still get column letters (A, B, C...). You can see which columns are empty by looking at the markdown table.
+
+ # TsiContent Mapping
+ - MOST COLUMNS will be mapped to the 'tsiContent' property (Total Sum Insured content breakdown).
+ - Common column headers for tsiContent include: Stock, Fixtures, Fittings, IT Equipment, Land, Leasehold Improv., Leasehold Improvements, Plant & Equipment, Tooling, Workshop Equipment, Rent Forecast.
+ - These columns typically represent different categories of insured content and should be mapped to tsiContent using the SourceColumns list.
+ - Example: If you see columns for "Stock", "Fixtures", "IT Equipment", map them as: "targetProperty": "tsiContent", "sourceColumns": ["E", "F", "G"]
+
+ Notes:
+ - The agent defaults to ignoring rows where Id or Address is missing (adds "Id == null" and "Address == null" to ignoreRowExpressions).
+ - Provide only the file name (e.g., "risks.xlsx"); it is resolved relative to the pricing's content collection.
+
+ IMPORTANT OUTPUT RULES:
+ - do not output JSON to the user.
+ - When the user asks you to import, your job is not finished by creating the risk import configuration. You will actually have to call ContentPlugin's Import function.
+ """;
+
+ if (excelImportConfigSchema is not null)
+ baseText += $"\n\n# Schema for ExcelImportConfiguration\n{excelImportConfigSchema}";
+ if (propertyRiskSchema is not null)
+ baseText += $"\n\n# Schema for PropertyRisk (Target for Mapping)\n{propertyRiskSchema}";
+
+ return baseText;
+ }
+ }
+
+ public bool Matches(AgentContext? context)
+ {
+ return context?.Address?.Type == PricingAddress.TypeName;
+ }
+
+ IEnumerable IAgentWithPlugins.GetPlugins(IAgentChat chat)
+ {
+ yield return new DataPlugin(hub, chat, typeDefinitionMap).CreateKernelPlugin();
+
+ // Add ContentPlugin for submissions and import functionality
+ var submissionPluginConfig = CreateSubmissionPluginConfig();
+ yield return new ContentPlugin(hub, submissionPluginConfig, chat).CreateKernelPlugin();
+ }
+
+ private static ContentPluginConfig CreateSubmissionPluginConfig()
+ {
+ return new ContentPluginConfig
+ {
+ Collections = [],
+ ContextToConfigMap = context =>
+ {
+ // Only handle pricing contexts
+ if (context?.Address?.Type != PricingAddress.TypeName)
+ return null!;
+
+ var pricingId = context.Address.Id;
+
+ // Parse pricingId in format {company}-{uwy}
+ var parts = pricingId.Split('-');
+ if (parts.Length != 2)
+ return null!;
+
+ // Use Hub-based collection config pointing to the pricing address
+ // This allows the ContentPlugin to query the pricing hub for the actual collection configuration
+ return new ContentCollectionConfig
+ {
+ SourceType = HubStreamProviderFactory.SourceType,
+ Name = $"Submissions-{pricingId}",
+ Address = context.Address
+ };
+ }
+ };
+ }
+
+ async Task IInitializableAgent.InitializeAsync()
+ {
+ try
+ {
+ var typesResponse = await hub.AwaitResponse(
+ new GetDomainTypesRequest(),
+ o => o.WithTarget(new PricingAddress("default")));
+ var types = typesResponse?.Message?.Types;
+ typeDefinitionMap = types?.Select(t => t with { Address = null }).ToDictionary(x => x.Name!);
+ }
+ catch
+ {
+ typeDefinitionMap = null;
+ }
+
+ try
+ {
+ var resp = await hub.AwaitResponse(
+ new GetSchemaRequest("ExcelImportConfiguration"),
+ o => o.WithTarget(new PricingAddress("default")));
+
+ // Hard-code TypeName to "PropertyRisk" in the schema
+ var schema = resp?.Message?.Schema;
+ if (!string.IsNullOrEmpty(schema))
+ {
+ // Parse the schema as JSON to modify it
+ try
+ {
+ var schemaJson = JsonNode.Parse(schema) as JsonObject;
+ if (schemaJson?["anyOf"] is JsonArray array && array.First() is JsonObject obj && obj["properties"] is JsonObject properties)
+ {
+ // Set TypeName property to have a constant value of "PropertyRisk"
+ properties["typeName"] = new JsonObject
+ {
+ ["type"] = "string",
+ ["const"] = "PropertyRisk",
+ ["description"] = "The fully qualified type name of the entity to import. This is hard-coded to 'PropertyRisk' for risk imports."
+ };
+ schema = schemaJson.ToJsonString();
+ }
+ }
+ catch
+ {
+ // If parsing fails, use original schema
+ }
+ }
+ excelImportConfigSchema = schema;
+ }
+ catch
+ {
+ excelImportConfigSchema = null;
+ }
+
+ try
+ {
+ var resp = await hub.AwaitResponse(
+ new GetSchemaRequest(nameof(PropertyRisk)),
+ o => o.WithTarget(new PricingAddress("default")));
+ propertyRiskSchema = resp?.Message?.Schema;
+ }
+ catch
+ {
+ propertyRiskSchema = null;
+ }
+ }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.AI/SlipImportAgent.cs b/modules/Insurance/MeshWeaver.Insurance.AI/SlipImportAgent.cs
new file mode 100644
index 000000000..e448a6371
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.AI/SlipImportAgent.cs
@@ -0,0 +1,232 @@
+using MeshWeaver.AI;
+using MeshWeaver.AI.Plugins;
+using MeshWeaver.ContentCollections;
+using MeshWeaver.Data;
+using MeshWeaver.Insurance.Domain;
+using MeshWeaver.Messaging;
+using Microsoft.SemanticKernel;
+
+namespace MeshWeaver.Insurance.AI;
+
+public class SlipImportAgent(IMessageHub hub) : IInitializableAgent, IAgentWithPlugins, IAgentWithContext
+{
+ private Dictionary? typeDefinitionMap;
+ private string? pricingSchema;
+ private string? acceptanceSchema;
+ private string? sectionSchema;
+
+ public string Name => nameof(SlipImportAgent);
+
+ public string Description => "Imports insurance slip documents from PDF or Markdown files and structures them into Pricing and ReinsuranceAcceptance data models using LLM-based extraction.";
+
+ public string Instructions
+ {
+ get
+ {
+ var baseText =
+ $$$"""
+ You are a slip import agent that processes insurance submission slip documents in PDF or Markdown format.
+ Your task is to extract structured data and map it to the insurance domain models using the provided schemas.
+
+ ## Content Collection Context
+
+ IMPORTANT: The current context is set to pricing/{pricingId} where pricingId follows the format {company}-{uwy}.
+ - The submission files collection is automatically named "Submissions-{pricingId}"
+ - Files are stored at the root level of this collection
+ - When listing files, you'll see filenames like "Slip.pdf", "Slip.md", etc.
+ - When accessing files with ExtractCompleteText, use just the filename (e.g., "Slip.pdf" or "Slip.md")
+
+ # Importing Slips
+ When the user asks you to import a slip:
+ 1) First, use ContentCollectionPlugin's ListFiles() to see available files in the submissions collection
+ 2) Use ContentPlugin's GetContent function to extract the document content from PDF or Markdown files
+ - Pass collectionName="Submissions-{pricingId}" and filePath=filename (e.g., "Slip.pdf" or "Slip.md")
+ - For PDFs, this will extract all pages of text
+ 3) Review the extracted text and identify data that matches the domain schemas
+ 4) Create JSON objects for each entity type following the schemas below
+ 5) Import the data using DataPlugin's UpdateData function:
+ - First, retrieve existing Pricing data using DataPlugin's GetData with type="Pricing" and entityId=pricingId
+ - Merge new pricing fields with existing data and call DataPlugin's UpdateData with type="Pricing"
+ - For each ReinsuranceAcceptance (layer), create JSON and call DataPlugin's UpdateData with type="ReinsuranceAcceptance"
+ - For each ReinsuranceSection (coverage within layer), create JSON and call DataPlugin's UpdateData with type="ReinsuranceSection"
+ 6) Provide feedback on what data was successfully imported or if any issues were encountered
+
+ # Data Mapping Guidelines
+ Based on the extracted document text, create JSON objects that match the schemas provided below:
+ - **Pricing**: Basic pricing information including:
+ - Insured name (e.g., "Microsoft Corporation")
+ - Primary insurance company (labeled as "Primary Insurer" or similar in slip header) - populate the PrimaryInsurance field
+ - Broker name (labeled as "Broker" in slip header) - populate the BrokerName field
+ - Dates (inception, expiration), premium, country, legal entity
+ - **ReinsuranceAcceptance**: Represents a reinsurance layer (Layer 1, Layer 2, Layer 3) with financial terms
+ - **ReinsuranceSection**: Represents a coverage type within a layer (Fire Damage, Natural Catastrophe, Business Interruption)
+
+ # Structure Hierarchy
+ The data structure follows this hierarchy:
+ 1. **Pricing** (the main insurance program)
+ 2. **ReinsuranceAcceptance** (the layers: Layer 1, Layer 2, Layer 3, etc.)
+ 3. **ReinsuranceSection** (the coverage types within each layer: Fire Damage, Natural Catastrophe, Business Interruption, etc.)
+
+ # Important Rules
+ - Only extract data that is explicitly present in the document text
+ - Use null or default values for missing data points
+ - Ensure all monetary values are properly formatted as numbers
+ - Convert percentages to decimal format (e.g., 25% → 0.25)
+ - Provide clear feedback on what data was successfully extracted
+ - If data is ambiguous or unclear, note it in your response
+
+ # Creating ReinsuranceAcceptance Records (Layers)
+ - First, create ReinsuranceAcceptance records for each layer (Layer 1, Layer 2, Layer 3)
+ - Use IDs like "Layer1", "Layer2", "Layer3"
+ - Set the Name property to "Layer 1", "Layer 2", "Layer 3"
+ - Include financial terms like share, cession, rate, commission on the acceptance
+ - If there is a "Reinsurance Terms" section in the header with properties like EPI and Brokerage, apply these values to ALL ReinsuranceAcceptance records (all layers get the same EPI and Brokerage)
+ - Convert percentage values to decimals (e.g., 10% → 0.10, 100% → 1.0)
+
+ # Creating ReinsuranceSection Records (Coverage Types)
+ - Then, create ReinsuranceSection records for each coverage type within each layer
+ - Use IDs like "Layer1-Fire", "Layer1-NatCat", "Layer1-BI", "Layer2-Fire", etc.
+ - Set the AcceptanceId to link the section to its parent layer (e.g., "Layer1")
+ - Set the LineOfBusiness to the coverage type (e.g., "Fire Damage", "Natural Catastrophe", "Business Interruption")
+ - Set the Name to a descriptive name (e.g., "Fire Damage - Layer 1")
+ - Include the attachment point (Attach), limit, aggregate deductible (AggAttach), and aggregate limit (AggLimit)
+
+ # Example from a Slip
+ If the slip shows:
+ - Fire Damage → Layer 1: Attach 5M, Limit 100M, AAD 25M, AAL 300M
+ - Fire Damage → Layer 2: Attach 100M, Limit 150M, AAL 450M
+ - Natural Catastrophe → Layer 1: Attach 10M, Limit 75M, AAD 30M, AAL 225M
+
+ Create:
+ 1. ReinsuranceAcceptance: Id="Layer1", Name="Layer 1"
+ 2. ReinsuranceAcceptance: Id="Layer2", Name="Layer 2"
+ 3. ReinsuranceSection: Id="Layer1-Fire", AcceptanceId="Layer1", LineOfBusiness="Fire Damage", Attach=5000000, Limit=100000000, AggAttach=25000000, AggLimit=300000000
+ 4. ReinsuranceSection: Id="Layer2-Fire", AcceptanceId="Layer2", LineOfBusiness="Fire Damage", Attach=100000000, Limit=150000000, AggLimit=450000000
+ 5. ReinsuranceSection: Id="Layer1-NatCat", AcceptanceId="Layer1", LineOfBusiness="Natural Catastrophe", Attach=10000000, Limit=75000000, AggAttach=30000000, AggLimit=225000000
+
+ # Document Section Processing
+ Look for common sections in insurance slips:
+ - **Header section**: Insured name, Primary Insurer, Broker, dates
+ - **Insured information**: Name, location, industry
+ - **Coverage details**: Inception/expiration dates, policy terms
+ - **Premium and financial information**: Premium amounts, currency
+ - **Reinsurance terms section**: EPI (Estimated Premium Income), Brokerage percentage, Commission, Taxes
+ - **Layer structures**: Layer 1, Layer 2, Layer 3 with limits, attachments, rates
+ - **Coverage types within layers**: Fire Damage, Natural Catastrophe, Business Interruption, etc.
+
+ Notes:
+ - When listing files, you may see paths with "/" prefix (e.g., "/Slip.pdf", "/Slip.md")
+ - When calling ContentPlugin's GetContent, use collectionName="Submissions-{pricingId}" and provide the filename
+ - Both PDF and Markdown (.md) files are supported
+ - When updating data, ensure each JSON object has the correct $type field and required ID fields (id, pricingId, acceptanceId, etc.)
+ - Remove null-valued properties from JSON before calling UpdateData
+ """;
+
+ if (pricingSchema is not null)
+ baseText += $"\n\n# Pricing Schema\n```json\n{pricingSchema}\n```";
+ if (acceptanceSchema is not null)
+ baseText += $"\n\n# ReinsuranceAcceptance Schema\n```json\n{acceptanceSchema}\n```";
+ if (sectionSchema is not null)
+ baseText += $"\n\n# ReinsuranceSection Schema\n```json\n{sectionSchema}\n```";
+
+ return baseText;
+ }
+ }
+
+ IEnumerable IAgentWithPlugins.GetPlugins(IAgentChat chat)
+ {
+ yield return new DataPlugin(hub, chat, typeDefinitionMap).CreateKernelPlugin();
+
+ // Add ContentPlugin for submissions and file reading functionality
+ var submissionPluginConfig = CreateSubmissionPluginConfig();
+ yield return new ContentPlugin(hub, submissionPluginConfig, chat).CreateKernelPlugin();
+ }
+
+ private static ContentPluginConfig CreateSubmissionPluginConfig()
+ {
+ return new ContentPluginConfig
+ {
+ Collections = [],
+ ContextToConfigMap = context =>
+ {
+ // Only handle pricing contexts
+ if (context?.Address?.Type != PricingAddress.TypeName)
+ return null!;
+
+ var pricingId = context.Address.Id;
+
+ // Parse pricingId in format {company}-{uwy}
+ var parts = pricingId.Split('-');
+ if (parts.Length != 2)
+ return null!;
+
+ // Use Hub-based collection config pointing to the pricing address
+ // This allows the ContentPlugin to query the pricing hub for the actual collection configuration
+ return new ContentCollectionConfig
+ {
+ SourceType = HubStreamProviderFactory.SourceType,
+ Name = $"Submissions-{pricingId}",
+ Address = context.Address
+ };
+ }
+ };
+ }
+
+ async Task IInitializableAgent.InitializeAsync()
+ {
+ var pricingAddress = new PricingAddress("default");
+
+ try
+ {
+ var typesResponse = await hub.AwaitResponse(
+ new GetDomainTypesRequest(),
+ o => o.WithTarget(pricingAddress));
+ typeDefinitionMap = typesResponse?.Message?.Types?.Select(t => t with { Address = null }).ToDictionary(x => x.Name!);
+ }
+ catch
+ {
+ typeDefinitionMap = null;
+ }
+
+ try
+ {
+ var resp = await hub.AwaitResponse(
+ new GetSchemaRequest(nameof(Pricing)),
+ o => o.WithTarget(pricingAddress));
+ pricingSchema = resp?.Message?.Schema;
+ }
+ catch
+ {
+ pricingSchema = null;
+ }
+
+ try
+ {
+ var resp = await hub.AwaitResponse(
+ new GetSchemaRequest(nameof(ReinsuranceAcceptance)),
+ o => o.WithTarget(pricingAddress));
+ acceptanceSchema = resp?.Message?.Schema;
+ }
+ catch
+ {
+ acceptanceSchema = null;
+ }
+
+ try
+ {
+ var resp = await hub.AwaitResponse(
+ new GetSchemaRequest(nameof(ReinsuranceSection)),
+ o => o.WithTarget(pricingAddress));
+ sectionSchema = resp?.Message?.Schema;
+ }
+ catch
+ {
+ sectionSchema = null;
+ }
+ }
+
+ public bool Matches(AgentContext? context)
+ {
+ return context?.Address?.Type == PricingAddress.TypeName;
+ }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/Dimensions.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/Dimensions.cs
index 9aaa5bd69..55adaea99 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/Dimensions.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/Dimensions.cs
@@ -5,6 +5,7 @@ namespace MeshWeaver.Insurance.Domain;
///
/// Line of business dimension for insurance classification.
///
+[Display(GroupName = "Reference Data")]
public record LineOfBusiness
{
///
@@ -27,6 +28,7 @@ public record LineOfBusiness
///
/// Country dimension for geographic classification.
///
+[Display(GroupName = "Reference Data")]
public record Country
{
///
@@ -54,6 +56,7 @@ public record Country
///
/// Legal entity dimension for organizational structure.
///
+[Display(GroupName = "Reference Data")]
public record LegalEntity
{
///
@@ -81,6 +84,7 @@ public record LegalEntity
///
/// Currency dimension for monetary values.
///
+[Display(GroupName = "Reference Data")]
public record Currency
{
///
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/GeocodingRequest.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/GeocodingRequest.cs
new file mode 100644
index 000000000..f12dcd3c0
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/GeocodingRequest.cs
@@ -0,0 +1,34 @@
+using MeshWeaver.Messaging;
+
+namespace MeshWeaver.Insurance.Domain;
+
+///
+/// Request to geocode property risks.
+///
+public record GeocodingRequest : IRequest;
+
+///
+/// Response from geocoding operation.
+///
+public record GeocodingResponse
+{
+ ///
+ /// Whether the geocoding operation was successful.
+ ///
+ public required bool Success { get; init; }
+
+ ///
+ /// Number of risks successfully geocoded.
+ ///
+ public int GeocodedCount { get; init; }
+
+ ///
+ /// Error message if geocoding failed.
+ ///
+ public string? Error { get; init; }
+
+ ///
+ /// List of updated risks with geocoded locations.
+ ///
+ public IReadOnlyList? UpdatedRisks { get; init; }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/InsuranceApplicationExtensions.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/InsuranceApplicationExtensions.cs
index f809ed66b..32c75887a 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/InsuranceApplicationExtensions.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/InsuranceApplicationExtensions.cs
@@ -1,8 +1,11 @@
+using System.Reactive.Linq;
using MeshWeaver.ContentCollections;
using MeshWeaver.Data;
+using MeshWeaver.Import;
using MeshWeaver.Import.Configuration;
using MeshWeaver.Insurance.Domain.Services;
using MeshWeaver.Layout;
+using MeshWeaver.Layout.Domain;
using MeshWeaver.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -15,12 +18,23 @@ namespace MeshWeaver.Insurance.Domain;
///
public static class InsuranceApplicationExtensions
{
+ ///
+ /// Adds Insurance domain services to the service collection.
+ ///
+ private static IServiceCollection AddInsuranceDomainServices(this IServiceCollection services)
+ {
+ // Register pricing service
+ services.AddSingleton();
+
+ return services;
+ }
+
///
/// Configures the root Insurance application hub with dimension data and pricing catalog.
///
public static MessageHubConfiguration ConfigureInsuranceApplication(this MessageHubConfiguration configuration)
=> configuration
- .WithTypes(typeof(PricingAddress))
+ .WithTypes(typeof(PricingAddress), typeof(ImportConfiguration), typeof(ExcelImportConfiguration), typeof(ReinsuranceAcceptance), typeof(ReinsuranceSection), typeof(ImportRequest), typeof(CollectionSource), typeof(GeocodingRequest), typeof(GeocodingResponse))
.AddData(data =>
{
var svc = data.Hub.ServiceProvider.GetRequiredService();
@@ -43,17 +57,12 @@ public static MessageHubConfiguration ConfigureInsuranceApplication(this Message
public static MessageHubConfiguration ConfigureSinglePricingApplication(this MessageHubConfiguration configuration)
{
return configuration
- .WithTypes(typeof(InsuranceApplicationExtensions))
+ .WithServices(AddInsuranceDomainServices)
.AddContentCollection(sp =>
{
var hub = sp.GetRequiredService();
var addressId = hub.Address.Id;
- var configuration = sp.GetRequiredService();
-
- // Get the global Submissions configuration from appsettings
- var globalConfig = configuration.GetSection("Submissions").Get();
- if (globalConfig == null)
- throw new InvalidOperationException("Submissions collection not found in configuration");
+ var conf = sp.GetRequiredService();
// Parse addressId in format {company}-{uwy}
var parts = addressId.Split('-');
@@ -64,11 +73,28 @@ public static MessageHubConfiguration ConfigureSinglePricingApplication(this Mes
var uwy = parts[1];
var subPath = $"{company}/{uwy}";
+ // Get the global Submissions configuration from appsettings, or create a default one
+ var globalConfig = conf.GetSection("Submissions").Get();
+
+ // If no configuration exists, create a default FileSystem-based collection
+ if (globalConfig == null)
+ {
+ // Default to a "Submissions" folder in the current directory
+ var defaultBasePath = Path.Combine(Directory.GetCurrentDirectory(), "Submissions");
+ globalConfig = new ContentCollectionConfig
+ {
+ SourceType = FileSystemStreamProvider.SourceType,
+ Name = "Submissions",
+ BasePath = defaultBasePath,
+ DisplayName = "Submission Files"
+ };
+ }
+
// Create localized config with modified name and basepath
var localizedName = GetLocalizedCollectionName("Submissions", addressId);
var fullPath = string.IsNullOrEmpty(subPath)
? globalConfig.BasePath ?? ""
- : System.IO.Path.Combine(globalConfig.BasePath ?? "", subPath);
+ : Path.Combine(globalConfig.BasePath ?? "", subPath);
return globalConfig with
{
@@ -89,6 +115,8 @@ public static MessageHubConfiguration ConfigureSinglePricingApplication(this Mes
}))
.WithType(t => t.WithInitialData(async ct =>
(IEnumerable)await svc.GetRisksAsync(pricingId, ct)))
+ .WithType(t => t.WithInitialData(_ => Task.FromResult(Enumerable.Empty())))
+ .WithType(t => t.WithInitialData(_ => Task.FromResult(Enumerable.Empty())))
.WithType(t => t.WithInitialData(async ct =>
await svc.GetImportConfigurationsAsync(pricingId).ToArrayAsync(ct)))
);
@@ -102,8 +130,85 @@ await svc.GetImportConfigurationsAsync(pricingId).ToArrayAsync(ct)))
LayoutAreas.PropertyRisksLayoutArea.PropertyRisks)
.WithView(nameof(LayoutAreas.RiskMapLayoutArea.RiskMap),
LayoutAreas.RiskMapLayoutArea.RiskMap)
+ .WithView(nameof(LayoutAreas.ReinsuranceAcceptanceLayoutArea.Structure),
+ LayoutAreas.ReinsuranceAcceptanceLayoutArea.Structure)
.WithView(nameof(LayoutAreas.ImportConfigsLayoutArea.ImportConfigs),
LayoutAreas.ImportConfigsLayoutArea.ImportConfigs)
- );
+ .AddDomainViews()
+ )
+ .AddImport()
+ .WithHandler(HandleGeocodingRequest);
+ }
+
+ private static async Task HandleGeocodingRequest(
+ IMessageHub hub,
+ IMessageDelivery request,
+ CancellationToken ct)
+ {
+ try
+ {
+ // Get the geocoding service
+ var geocodingService = hub.ServiceProvider.GetRequiredService();
+
+ // Get the current property risks from the workspace
+ var workspace = hub.GetWorkspace();
+ var riskStream = workspace.GetStream();
+ if (riskStream == null)
+ {
+ var errorResponse = new GeocodingResponse
+ {
+ Success = false,
+ GeocodedCount = 0,
+ Error = "No property risks found in workspace"
+ };
+ hub.Post(errorResponse, o => o.ResponseFor(request));
+ return request.Processed();
+ }
+
+ var risks = await riskStream.FirstAsync();
+ var riskList = risks?.ToList() ?? new List();
+
+ if (!riskList.Any())
+ {
+ var errorResponse = new GeocodingResponse
+ {
+ Success = false,
+ GeocodedCount = 0,
+ Error = "No property risks available to geocode"
+ };
+ hub.Post(errorResponse, o => o.ResponseFor(request));
+ return request.Processed();
+ }
+
+ // Geocode the risks
+ var geocodingResponse = await geocodingService.GeocodeRisksAsync(riskList, ct);
+
+ // If successful and we have updated risks, update the workspace
+ if (geocodingResponse.Success && geocodingResponse.UpdatedRisks != null && geocodingResponse.UpdatedRisks.Any())
+ {
+ // Update the workspace with the geocoded risks
+ var dataChangeRequest = new DataChangeRequest
+ {
+ Updates = geocodingResponse.UpdatedRisks.ToList()
+ };
+
+ hub.Post(dataChangeRequest, o => o.WithTarget(hub.Address));
+ }
+
+ // Post the response
+ hub.Post(geocodingResponse, o => o.ResponseFor(request));
+ }
+ catch (Exception ex)
+ {
+ var errorResponse = new GeocodingResponse
+ {
+ Success = false,
+ GeocodedCount = 0,
+ Error = $"Geocoding failed: {ex.Message}"
+ };
+ hub.Post(errorResponse, o => o.ResponseFor(request));
+ }
+
+ return request.Processed();
}
}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ImportConfigsLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ImportConfigsLayoutArea.cs
index cecf8a2a9..4f0240afa 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ImportConfigsLayoutArea.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ImportConfigsLayoutArea.cs
@@ -1,4 +1,4 @@
-using System.Reactive.Linq;
+using System.Reactive.Linq;
using System.Text.Json;
using MeshWeaver.Import.Configuration;
using MeshWeaver.Insurance.Domain.LayoutAreas.Shared;
@@ -42,51 +42,9 @@ public static IObservable ImportConfigs(LayoutAreaHost host, Renderin
foreach (var cfg in list.OrderBy(x => x.Name))
{
parts.Add($"\n## {cfg.Name}");
- parts.Add($"\n**Worksheet:** {cfg.WorksheetName}");
- parts.Add($"**Data Start Row:** {cfg.DataStartRow}");
-
- if (cfg.Mappings.Any())
- {
- parts.Add("\n### Column Mappings");
- parts.Add("\n| Target Property | Mapping Kind | Source Columns | Constant Value |");
- parts.Add("|----------------|--------------|----------------|----------------|");
- foreach (var mapping in cfg.Mappings)
- {
- var sourceColumns = string.Join(", ", mapping.SourceColumns);
- var constantValue = mapping.ConstantValue?.ToString() ?? "";
- parts.Add($"| {mapping.TargetProperty} | {mapping.Kind} | {sourceColumns} | {constantValue} |");
- }
- }
-
- if (cfg.Allocations.Any())
- {
- parts.Add("\n### Allocations");
- parts.Add("\n| Target Property | Total Cell | Weight Columns | Currency Property |");
- parts.Add("|----------------|------------|----------------|-------------------|");
- foreach (var alloc in cfg.Allocations)
- {
- var weightColumns = string.Join(", ", alloc.WeightColumns);
- parts.Add($"| {alloc.TargetProperty} | {alloc.TotalCell} | {weightColumns} | {alloc.CurrencyProperty ?? ""} |");
- }
- }
-
- if (cfg.TotalRowMarkers.Any())
- {
- parts.Add($"\n**Total Row Markers:** {string.Join(", ", cfg.TotalRowMarkers)}");
- }
-
- if (cfg.IgnoreRowExpressions.Any())
- {
- parts.Add("\n**Ignore Row Expressions:**");
- foreach (var expr in cfg.IgnoreRowExpressions)
- {
- parts.Add($"- `{expr}`");
- }
- }
-
// Add full JSON configuration in a collapsible section
var json = JsonSerializer.Serialize(cfg, options);
- parts.Add($"\n\nView Full JSON Configuration
\n\n```json\n{json}\n```\n \n");
+ parts.Add($"```json\n{json}\n```");
}
var md = string.Join("\n", parts);
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingCatalogLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingCatalogLayoutArea.cs
index e6cb6221b..7223810f7 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingCatalogLayoutArea.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingCatalogLayoutArea.cs
@@ -1,4 +1,4 @@
-using System.Reactive.Linq;
+using System.Reactive.Linq;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
@@ -28,23 +28,20 @@ private static string RenderPricingTable(IReadOnlyCollection pricings)
var lines = new List
{
- "# Insurance Pricing Catalog",
+ "# Insurance Pricing Catalog | [Data Model](/pricing/default/DataModel)",
"",
- "| Insured | Line of Business | Country | Legal Entity | Inception | Expiration | Premium | Status |",
- "|---------|------------------|---------|--------------|-----------|------------|---------|--------|"
+ "| Insured | Line of Business | Country | Legal Entity | Inception | Expiration | Status |",
+ "|---------|------------------|---------|--------------|-----------|------------|--------|"
};
lines.AddRange(pricings
- .OrderByDescending(p => p.InceptionDate)
+ .OrderByDescending(p => p.InceptionDate ?? DateTime.MaxValue)
.Select(p =>
{
var link = $"[{p.InsuredName}](/pricing/{p.Id}/Overview)";
var inception = p.InceptionDate?.ToString("yyyy-MM-dd") ?? "-";
var expiration = p.ExpirationDate?.ToString("yyyy-MM-dd") ?? "-";
- var premium = p.Premium.HasValue && p.Currency != null
- ? $"{p.Currency} {p.Premium:N0}"
- : "-";
- return $"| {link} | {p.LineOfBusiness ?? "-"} | {p.Country ?? "-"} | {p.LegalEntity ?? "-"} | {inception} | {expiration} | {premium} | {p.Status ?? "-"} |";
+ return $"| {link} | {p.LineOfBusiness ?? "-"} | {p.Country ?? "-"} | {p.LegalEntity ?? "-"} | {inception} | {expiration} | {p.Status ?? "-"} |";
}));
return string.Join("\n", lines);
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingOverviewLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingOverviewLayoutArea.cs
index 5987db795..6925fa2fb 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingOverviewLayoutArea.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PricingOverviewLayoutArea.cs
@@ -1,4 +1,4 @@
-using System.Reactive.Linq;
+using System.Reactive.Linq;
using MeshWeaver.Insurance.Domain.LayoutAreas.Shared;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
@@ -56,12 +56,9 @@ private static string RenderPricingOverview(Pricing pricing)
$"- **Country:** {pricing.Country ?? "N/A"}",
$"- **Legal Entity:** {pricing.LegalEntity ?? "N/A"}",
"",
- "### Financial",
- $"- **Premium:** {(pricing.Premium.HasValue && pricing.Currency != null ? $"{pricing.Currency} {pricing.Premium:N2}" : "N/A")}",
- $"- **Currency:** {pricing.Currency ?? "N/A"}",
- "",
"### Parties",
- $"- **Broker:** {pricing.BrokerName ?? "N/A"}"
+ $"- **Broker:** {pricing.BrokerName ?? "N/A"}",
+ $"- **Primary Insurance:** {pricing.PrimaryInsurance ?? "N/A"}"
};
return string.Join("\n", lines);
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PropertyRisksLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PropertyRisksLayoutArea.cs
index 76f11187f..3db9474af 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PropertyRisksLayoutArea.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/PropertyRisksLayoutArea.cs
@@ -1,10 +1,12 @@
using System.Reactive.Linq;
using MeshWeaver.Data;
using MeshWeaver.Insurance.Domain.LayoutAreas.Shared;
+using MeshWeaver.Insurance.Domain.Services;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
using MeshWeaver.Layout.DataGrid;
using MeshWeaver.Utils;
+using Microsoft.Extensions.DependencyInjection;
namespace MeshWeaver.Insurance.Domain.LayoutAreas;
@@ -37,7 +39,8 @@ public static IObservable PropertyRisks(LayoutAreaHost host, Renderin
return Controls.Stack
.WithView(PricingLayoutShared.BuildToolbar(pricingId, "PropertyRisks"))
- .WithView(dataGrid);
+ .WithView(dataGrid)
+ .WithView(GeocodingArea);
})
.StartWith(Controls.Stack
.WithView(PricingLayoutShared.BuildToolbar(pricingId, "PropertyRisks"))
@@ -94,6 +97,40 @@ private static UiControl RenderRisksDataGrid(LayoutAreaHost host, IReadOnlyColle
.WithView(Controls.Title("Property Risks", 1))
.WithView(dataGrid);
}
+
+ private static IObservable GeocodingArea(LayoutAreaHost host, RenderingContext ctx)
+ {
+ var svc = host.Hub.ServiceProvider.GetRequiredService();
+ return svc.Progress.Select(p => p is null
+ ? (UiControl)Controls.Button("Geocode").WithClickAction(ClickGeocoding)
+ : Controls.Progress($"Processing {p.CurrentRiskName}: {p.ProcessedRisks} of {p.TotalRisks}",
+ p.TotalRisks == 0 ? 0 : (int)(100.0 * p.ProcessedRisks / p.TotalRisks)));
+ }
+
+ private static async Task ClickGeocoding(UiActionContext obj)
+ {
+ // Show initial progress
+ obj.Host.UpdateArea(obj.Area, Controls.Progress("Starting geocoding...", 0));
+
+ try
+ {
+ // Start the geocoding process
+ var response = await obj.Host.Hub.AwaitResponse(
+ new GeocodingRequest(),
+ o => o.WithTarget(obj.Hub.Address));
+
+ // Show completion message
+ var resultMessage = response?.Message?.Success == true
+ ? $"✅ Geocoding Complete: {response.Message.GeocodedCount} locations geocoded successfully."
+ : $"❌ Geocoding Failed: {response?.Message?.Error}";
+
+ obj.Host.UpdateArea(obj.Area, Controls.Markdown($"**{resultMessage}**"));
+ }
+ catch (Exception ex)
+ {
+ obj.Host.UpdateArea(obj.Area, Controls.Markdown($"**Geocoding Failed**: {ex.Message}"));
+ }
+ }
}
///
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ReinsuranceAcceptanceLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ReinsuranceAcceptanceLayoutArea.cs
new file mode 100644
index 000000000..7631b9dc3
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/ReinsuranceAcceptanceLayoutArea.cs
@@ -0,0 +1,189 @@
+using System.Reactive.Linq;
+using System.Text;
+using MeshWeaver.Insurance.Domain.LayoutAreas.Shared;
+using MeshWeaver.Layout;
+using MeshWeaver.Layout.Composition;
+
+namespace MeshWeaver.Insurance.Domain.LayoutAreas;
+
+///
+/// Layout area for displaying reinsurance acceptances associated with a pricing.
+///
+public static class ReinsuranceAcceptanceLayoutArea
+{
+ // Color definitions for diagram elements
+ private static readonly string PricingColor = "#2c7bb6"; // Blue - dark background, white text
+ private static readonly string PricingTextColor = "#ffffff";
+ private static readonly string AcceptanceColor = "#fdae61"; // Light Orange - light background, dark text
+ private static readonly string AcceptanceTextColor = "#000000";
+ private static readonly string SectionColor = "#abd9e9"; // Light Blue - light background, dark text
+ private static readonly string SectionTextColor = "#000000";
+
+ ///
+ /// Renders the reinsurance acceptances structure for a specific pricing.
+ ///
+ public static IObservable Structure(LayoutAreaHost host, RenderingContext ctx)
+ {
+ _ = ctx;
+ var pricingId = host.Hub.Address.Id;
+ var pricingStream = host.Workspace.GetStream()!;
+ var acceptanceStream = host.Workspace.GetStream()!;
+ var sectionStream = host.Workspace.GetStream()!;
+
+ return Observable.CombineLatest(
+ pricingStream,
+ acceptanceStream,
+ sectionStream,
+ (pricings, acceptances, sections) => (pricings, acceptances, sections))
+ .Select(data =>
+ {
+ var pricing = data.pricings?.FirstOrDefault();
+ var acceptanceList = data.acceptances?.ToList() ?? new List();
+ var sectionList = data.sections?.ToList() ?? new List();
+
+ if (!acceptanceList.Any() && !sectionList.Any())
+ {
+ return Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "Structure"))
+ .WithView(Controls.Markdown("# Reinsurance Structure\n\n*No reinsurance acceptances loaded. Import or add acceptances to begin.*"));
+ }
+
+ var diagram = BuildMermaidDiagram(pricingId, pricing, acceptanceList, sectionList);
+ var mermaidControl = new MarkdownControl($"```mermaid\n{diagram}\n```")
+ .WithStyle(style => style.WithWidth("100%").WithHeight("600px"));
+
+ return Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "Structure"))
+ .WithView(Controls.Title("Reinsurance Structure", 1))
+ .WithView(mermaidControl);
+ })
+ .StartWith(Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "Structure"))
+ .WithView(Controls.Markdown("# Reinsurance Structure\n\n*Loading...*")));
+ }
+
+ private static string BuildMermaidDiagram(string pricingId, Pricing? pricing, List acceptances, List sections)
+ {
+ var sb = new StringBuilder();
+
+ // Start with flowchart definition for cards
+ sb.AppendLine("flowchart TD");
+ sb.AppendLine(" classDef leftAlign text-align:left");
+
+ // Add the pricing node (main node)
+ var pricingContent = new StringBuilder();
+ pricingContent.Append($"Pricing: {pricingId}");
+
+ if (pricing?.PrimaryInsurance != null)
+ {
+ pricingContent.Append($"
Primary: {pricing.PrimaryInsurance}");
+ }
+
+ if (pricing?.BrokerName != null)
+ {
+ pricingContent.Append($"
Broker: {pricing.BrokerName}");
+ }
+
+ sb.AppendLine($" pricing[\"{pricingContent}\"]");
+ sb.AppendLine($" style pricing fill:{PricingColor},color:{PricingTextColor},stroke:#333,stroke-width:1px");
+ sb.AppendLine($" class pricing leftAlign");
+
+ // Group sections by acceptanceId
+ var sectionsByAcceptance = sections
+ .Where(s => s.AcceptanceId != null)
+ .GroupBy(s => s.AcceptanceId)
+ .ToDictionary(g => g.Key!, g => g.ToList());
+
+ // Add acceptances and their sections
+ // Sort acceptances by name to ensure Layer 1, Layer 2, Layer 3 order
+ foreach (var acceptance in acceptances.OrderBy(a => a.Name ?? a.Id))
+ {
+ RenderAcceptance(sb, acceptance, pricingId);
+
+ // Add sections for this acceptance
+ // Sort sections by LineOfBusiness first, then by Attach to group coverage types together
+ if (sectionsByAcceptance.TryGetValue(acceptance.Id, out var acceptanceSections))
+ {
+ foreach (var section in acceptanceSections.OrderBy(s => s.LineOfBusiness).ThenBy(s => s.Attach))
+ {
+ RenderSection(sb, section, acceptance.Id);
+ }
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private static void RenderAcceptance(StringBuilder sb, ReinsuranceAcceptance acceptance, string pricingId)
+ {
+ string acceptanceId = SanitizeId(acceptance.Id);
+ var acceptanceName = acceptance.Name ?? acceptance.Id;
+
+ // Build acceptance content
+ var acceptanceContent = new StringBuilder();
+ acceptanceContent.Append($"{acceptanceName}");
+
+ if (acceptance.EPI > 0)
+ acceptanceContent.Append($"
EPI: {acceptance.EPI:N0}");
+
+ if (acceptance.Rate > 0)
+ acceptanceContent.Append($"
Rate: {acceptance.Rate:P2}");
+
+ if (acceptance.Share > 0)
+ acceptanceContent.Append($"
Share: {acceptance.Share:P2}");
+
+ if (acceptance.Cession > 0)
+ acceptanceContent.Append($"
Cession: {acceptance.Cession:P2}");
+
+ if (acceptance.Brokerage > 0)
+ acceptanceContent.Append($"
Brokerage: {acceptance.Brokerage:P2}");
+
+ if (acceptance.Commission > 0)
+ acceptanceContent.Append($"
Commission: {acceptance.Commission:P2}");
+
+ sb.AppendLine($" acc_{acceptanceId}[\"{acceptanceContent}\"]");
+ sb.AppendLine($" style acc_{acceptanceId} fill:{AcceptanceColor},color:{AcceptanceTextColor},stroke:#333,stroke-width:1px");
+ sb.AppendLine($" class acc_{acceptanceId} leftAlign");
+ sb.AppendLine($" pricing --> acc_{acceptanceId}");
+ }
+
+ private static void RenderSection(StringBuilder sb, ReinsuranceSection section, string acceptanceId)
+ {
+ string sectionId = SanitizeId(section.Id);
+ string sanitizedAcceptanceId = SanitizeId(acceptanceId);
+
+ // Build section content
+ var sectionContent = new StringBuilder();
+ sectionContent.Append($"{section.Name ?? section.LineOfBusiness ?? section.Id}
");
+
+ if (!string.IsNullOrEmpty(section.LineOfBusiness) && section.LineOfBusiness != section.Name)
+ {
+ sectionContent.Append($"LoB: {section.LineOfBusiness}
");
+ }
+
+ sectionContent.Append($"Attach: {section.Attach:N0}
");
+ sectionContent.Append($"Limit: {section.Limit:N0}
");
+
+ if (section.AggAttach.HasValue && section.AggAttach.Value > 0)
+ {
+ sectionContent.Append($"AAD: {section.AggAttach.Value:N0}
");
+ }
+
+ if (section.AggLimit.HasValue && section.AggLimit.Value > 0)
+ {
+ sectionContent.Append($"AAL: {section.AggLimit.Value:N0}");
+ }
+
+ // Create node
+ sb.AppendLine($" sec_{sectionId}[\"{sectionContent}\"]");
+ sb.AppendLine($" style sec_{sectionId} fill:{SectionColor},color:{SectionTextColor},stroke:#333,stroke-width:1px");
+ sb.AppendLine($" class sec_{sectionId} leftAlign");
+ sb.AppendLine($" acc_{sanitizedAcceptanceId} --> sec_{sectionId}");
+ }
+
+ private static string SanitizeId(string id)
+ {
+ // Replace characters that might cause issues in Mermaid IDs
+ return id.Replace("-", "_").Replace(" ", "_").Replace(".", "_");
+ }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/RiskMapLayoutArea.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/RiskMapLayoutArea.cs
index be3819ff1..f35463e38 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/RiskMapLayoutArea.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/RiskMapLayoutArea.cs
@@ -1,7 +1,13 @@
using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using MeshWeaver.Data;
+using MeshWeaver.GoogleMaps;
using MeshWeaver.Insurance.Domain.LayoutAreas.Shared;
+using MeshWeaver.Insurance.Domain.Services;
using MeshWeaver.Layout;
using MeshWeaver.Layout.Composition;
+using MeshWeaver.Messaging;
+using Microsoft.Extensions.DependencyInjection;
namespace MeshWeaver.Insurance.Domain.LayoutAreas;
@@ -13,72 +19,164 @@ public static class RiskMapLayoutArea
///
/// Renders a map view of property risks for a specific pricing.
///
- public static IObservable RiskMap(LayoutAreaHost host, RenderingContext ctx)
+ public static IObservable RiskMap(LayoutAreaHost host, RenderingContext _)
{
- _ = ctx;
var pricingId = host.Hub.Address.Id;
- var riskStream = host.Workspace.GetStream()!;
- return riskStream.Select(risks =>
- {
- var riskList = risks?.ToList() ?? new List();
- var geocodedRisks = riskList.Where(r => r.GeocodedLocation?.Latitude != null && r.GeocodedLocation?.Longitude != null).ToList();
-
- if (!riskList.Any())
+ return host.Workspace.GetStream()!
+ .SelectMany(risks =>
{
- return Controls.Stack
- .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
- .WithView(Controls.Markdown("# Risk Map\n\n*No risks loaded. Import or add risks to begin.*"));
- }
+ var riskList = risks?.ToList() ?? new List();
+ var geocodedRisks = riskList.Where(r => r.GeocodedLocation?.Latitude != null && r.GeocodedLocation?.Longitude != null).ToList();
- if (!geocodedRisks.Any())
- {
- return Controls.Stack
- .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
- .WithView(Controls.Markdown($"# Risk Map\n\n*No geocoded risks found. {riskList.Count} risk(s) available but none have valid coordinates.*"));
- }
+ if (!riskList.Any())
+ {
+ return Observable.Return(Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
+ .WithView(Controls.Markdown("# Risk Map\n\n*No risks loaded. Import or add risks to begin.*")));
+ }
+
+ if (!geocodedRisks.Any())
+ {
+ return Observable.Return(Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
+ .WithView(Controls.Markdown($"# Risk Map\n\n*No geocoded risks found. {riskList.Count} risk(s) available but none have valid coordinates.*"))
+ .WithView(GeocodingArea));
+ }
- var mapContent = RenderMapContent(geocodedRisks);
+ var mapControl = BuildGoogleMapControl(geocodedRisks);
- return Controls.Stack
+ return Observable.Using(
+ () => new ReplaySubject(1),
+ riskDetailsSubject =>
+ {
+ riskDetailsSubject.OnNext(null);
+ var mapControlWithClick = mapControl.WithClickAction(ctx => riskDetailsSubject.OnNext(ctx.Payload?.ToString()));
+
+ return Observable.Return(
+ Controls.Stack
+ .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
+ .WithView(Controls.Title("Risk Map", 2))
+ .WithView(mapControlWithClick)
+ .WithView(GeocodingArea)
+ .WithView(Controls.Title("Risk Details", 3))
+ .WithView((h, c) => riskDetailsSubject
+ .SelectMany(id => id == null ?
+ Observable.Return(Controls.Html("Click marker to see details.")) : RenderRiskDetails(host.Hub, id))
+ )
+ );
+ }
+ );
+ })
+ .StartWith(Controls.Stack
.WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
- .WithView(Controls.Markdown(mapContent));
- })
- .StartWith(Controls.Stack
- .WithView(PricingLayoutShared.BuildToolbar(pricingId, "RiskMap"))
- .WithView(Controls.Markdown("# Risk Map\n\n*Loading...*")));
+ .WithView(Controls.Markdown("# Risk Map\n\n*Loading...*")));
+ }
+
+ private static IObservable GeocodingArea(LayoutAreaHost host, RenderingContext ctx)
+ {
+ var svc = host.Hub.ServiceProvider.GetRequiredService();
+ return svc.Progress.Select(p => p is null
+ ? (UiControl)Controls.Button("Geocode").WithClickAction(ClickGeocoding)
+ : Controls.Progress($"Processing {p.CurrentRiskName}: {p.ProcessedRisks} of {p.TotalRisks}",
+ p.TotalRisks == 0 ? 0 : (int)(100.0 * p.ProcessedRisks / p.TotalRisks)));
}
- private static string RenderMapContent(List geocodedRisks)
+ private static async Task ClickGeocoding(UiActionContext obj)
{
- var lines = new List
+ // Show initial progress
+ obj.Host.UpdateArea(obj.Area, Controls.Progress("Starting geocoding...", 0));
+
+ try
{
- "# Risk Map",
- "",
- $"**Total Geocoded Risks:** {geocodedRisks.Count}",
- "",
- "## Risk Locations",
- ""
- };
+ // Start the geocoding process
+ var response = await obj.Host.Hub.AwaitResponse(
+ new GeocodingRequest(),
+ o => o.WithTarget(obj.Hub.Address));
- foreach (var risk in geocodedRisks.Take(10))
+ // Show completion message
+ var resultMessage = response?.Message?.Success == true
+ ? $"✅ Geocoding Complete: {response.Message.GeocodedCount} locations geocoded successfully."
+ : $"❌ Geocoding Failed: {response?.Message?.Error}";
+
+ obj.Host.UpdateArea(obj.Area, Controls.Markdown($"**{resultMessage}**"));
+ }
+ catch (Exception ex)
{
- lines.Add($"- **{risk.LocationName ?? "Unknown"}**: {risk.City}, {risk.State}, {risk.Country}");
- lines.Add($" - Coordinates: {risk.GeocodedLocation!.Latitude:F6}, {risk.GeocodedLocation.Longitude:F6}");
- lines.Add($" - TSI Building: {risk.Currency} {risk.TsiBuilding:N0}");
- lines.Add("");
+ obj.Host.UpdateArea(obj.Area, Controls.Markdown($"**Geocoding Failed**: {ex.Message}"));
}
+ }
- if (geocodedRisks.Count > 10)
+ private static IObservable RenderRiskDetails(IMessageHub hub, string id)
+ {
+ return hub.GetWorkspace()
+ .GetStream(new EntityReference(nameof(PropertyRisk), id))!
+ .Select(r => BuildRiskDetailsMarkdown(r.Value as PropertyRisk));
+ }
+
+ private static UiControl BuildRiskDetailsMarkdown(PropertyRisk? risk)
+ {
+ if (risk is null)
+ return Controls.Html("Risk not found");
+
+ return Controls.Stack
+ .WithView(Controls.Markdown("## Risk Details"))
+ .WithView(Controls.Markdown($"**ID:** {risk.Id}"))
+ .WithView(Controls.Markdown($"**Location:** {risk.LocationName ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**Address:** {risk.GeocodedLocation?.FormattedAddress ?? risk.Address ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**City:** {risk.City ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**State:** {risk.State ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**Country:** {risk.Country ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**Currency:** {risk.Currency ?? "N/A"}"))
+ .WithView(Controls.Markdown($"**TSI Building:** {risk.TsiBuilding:N0}"))
+ .WithView(Controls.Markdown($"**TSI Content:** {risk.TsiContent:N0}"))
+ .WithView(Controls.Markdown($"**TSI BI:** {risk.TsiBi:N0}"))
+ .WithView(Controls.Markdown($"**Latitude:** {risk.GeocodedLocation?.Latitude:F6}"))
+ .WithView(Controls.Markdown($"**Longitude:** {risk.GeocodedLocation?.Longitude:F6}"));
+ }
+
+ private static GoogleMapControl BuildGoogleMapControl(IReadOnlyCollection risks)
+ {
+ var riskList = risks.Where(r => r.GeocodedLocation?.Latitude is not null && r.GeocodedLocation?.Longitude is not null).ToList();
+
+ // Find center point
+ LatLng center;
+ if (riskList.Any())
{
- lines.Add($"*... and {geocodedRisks.Count - 10} more risk(s)*");
- lines.Add("");
+ var avgLat = riskList.Average(r => r.GeocodedLocation!.Latitude!.Value);
+ var avgLng = riskList.Average(r => r.GeocodedLocation!.Longitude!.Value);
+ center = new LatLng(avgLat, avgLng);
+ }
+ else
+ {
+ center = new LatLng(0, 0);
}
- lines.Add("---");
- lines.Add("");
- lines.Add("*Interactive map visualization coming soon...*");
+ // Create markers
+ var markers = riskList.Select(r => new MapMarker
+ {
+ Position = new LatLng(r.GeocodedLocation!.Latitude!.Value, r.GeocodedLocation.Longitude!.Value),
+ Title = ((r.LocationName ?? r.Address) + " " + (r.City ?? "")).Trim(),
+ Id = r.Id,
+ Data = r
+ }).ToList();
- return string.Join("\n", lines);
+ var mapOptions = new MapOptions
+ {
+ Center = center,
+ Zoom = riskList.Any() ? 6 : 2,
+ MapTypeId = "roadmap",
+ ZoomControl = true,
+ MapTypeControl = true,
+ StreetViewControl = false,
+ FullscreenControl = true
+ };
+
+ return new GoogleMapControl()
+ {
+ Options = mapOptions,
+ Markers = markers,
+ Id = "risk-map"
+ }.WithStyle(style => style.WithHeight("500px").WithWidth("80%"));
}
}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/Shared/PricingLayoutShared.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/Shared/PricingLayoutShared.cs
index 05fc4b5a1..f00adbec9 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/Shared/PricingLayoutShared.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/LayoutAreas/Shared/PricingLayoutShared.cs
@@ -18,6 +18,7 @@ string Item(string key, string icon, string text)
{Item("Submission", "📎", "Submission")}
{Item("PropertyRisks", "📄", "Risks")}
{Item("RiskMap", "🗺️", "Map")}
+{Item("Structure", "🏦", "Reinsurance")}
{Item("ImportConfigs", "⚙️", "Import")}
";
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/MeshWeaver.Insurance.Domain.csproj b/modules/Insurance/MeshWeaver.Insurance.Domain/MeshWeaver.Insurance.Domain.csproj
index e1e43d7f3..1bed21874 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/MeshWeaver.Insurance.Domain.csproj
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/MeshWeaver.Insurance.Domain.csproj
@@ -10,6 +10,7 @@
+
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/Pricing.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/Pricing.cs
index d724f0c20..bb126a234 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/Pricing.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/Pricing.cs
@@ -1,4 +1,4 @@
-using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations;
using MeshWeaver.Domain;
namespace MeshWeaver.Insurance.Domain;
@@ -6,6 +6,7 @@ namespace MeshWeaver.Insurance.Domain;
///
/// Represents an insurance pricing entity with dimension-based classification.
///
+[Display(GroupName = "Structure")]
public record Pricing
{
///
@@ -58,9 +59,9 @@ public record Pricing
public string? BrokerName { get; init; }
///
- /// Premium amount in the pricing currency.
+ /// Name of the primary insurance company.
///
- public decimal? Premium { get; init; }
+ public string? PrimaryInsurance { get; init; }
///
/// Currency code for the premium.
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/PropertyRisk.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/PropertyRisk.cs
index 7a27187e4..d47e9884d 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/PropertyRisk.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/PropertyRisk.cs
@@ -8,11 +8,12 @@ namespace MeshWeaver.Insurance.Domain;
/// Represents a property risk within an insurance pricing.
/// Contains location details, values, and dimensions for property insurance underwriting.
///
+[Display(GroupName = "Risk")]
public record PropertyRisk
{
///
/// Unique identifier for the property risk record.
- /// Synonyms: "Plant code", "Plant ID", "Site Code", "Asset ID", "Code".
+ /// Synonyms: Plant code, Plant ID, Site Code, Asset ID, Code.
///
[Key]
public required string Id { get; init; }
@@ -41,38 +42,38 @@ public record PropertyRisk
///
/// Human-friendly site or facility name.
- /// Synonyms: "Plant Description", "Site Name", "Location Name".
+ /// Synonyms: Plant Description, Site Name, Location Name.
///
public string? LocationName { get; init; }
///
/// Country; typically ISO code or name (dimension).
- /// Synonyms: "Country Code", "Country".
+ /// Synonyms: Country Code, Country.
///
[Dimension]
public string? Country { get; init; }
///
/// Street address.
- /// Synonyms: "Property Address", "Address".
+ /// Synonyms: Property Address, Address.
///
public string? Address { get; init; }
///
/// State/region/province.
- /// Synonyms: "State/Province", "Region".
+ /// Synonyms: State/Province, Region.
///
public string? State { get; init; }
///
/// County/district.
- /// Synonyms: "District", "County".
+ /// Synonyms: District, County.
///
public string? County { get; init; }
///
/// Postal/ZIP code.
- /// Synonyms: "ZIP", "Postcode".
+ /// Synonyms: ZIP, Postcode.
///
public string? ZipCode { get; init; }
@@ -83,14 +84,14 @@ public record PropertyRisk
///
/// Base currency for the risk.
- /// Synonyms: "Currency", "Curr.", "Curr", "CCY".
+ /// Synonyms: Currency, Curr., Curr, CCY.
///
[Dimension]
public string? Currency { get; init; }
///
/// Sum insured for buildings.
- /// Synonyms: "Buildings", "Building Value", "TSI Building(s)".
+ /// Synonyms: Buildings, Building Value, TSI Building(s).
///
public double TsiBuilding { get; init; }
@@ -101,7 +102,7 @@ public record PropertyRisk
///
/// Sum insured for contents.
- /// Synonyms: "Stock", "Fixtures & Fittings", "IT Equipment", "Equipment".
+ /// Synonyms: Stock, Fixtures, Fittings, IT Equipment, Equipment.
///
public double TsiContent { get; init; }
@@ -112,7 +113,7 @@ public record PropertyRisk
///
/// Business Interruption TSI.
- /// Synonyms: "BI", "Business Interruption", "Gross Profit".
+ /// Synonyms: BI, Business Interruption, Gross Profit.
///
public double TsiBi { get; init; }
@@ -123,7 +124,7 @@ public record PropertyRisk
///
/// Account identifier.
- /// Synonyms: "Account #", "Account No".
+ /// Synonyms: Account #, Account No.
///
public string? AccountNumber { get; init; }
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceAcceptance.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceAcceptance.cs
new file mode 100644
index 000000000..7300dabf2
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceAcceptance.cs
@@ -0,0 +1,87 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace MeshWeaver.Insurance.Domain;
+
+///
+/// Represents the reinsurance acceptance with financial terms and coverage sections.
+///
+[Display(GroupName = "Structure")]
+public record ReinsuranceAcceptance
+{
+ ///
+ /// Gets or initializes the unique acceptance identifier.
+ ///
+ [Key]
+ public required string Id { get; init; }
+
+ ///
+ /// Gets or initializes the pricingId this acceptance belongs to.
+ ///
+ public string? PricingId { get; init; }
+
+ ///
+ /// Gets or initializes the acceptance name or description.
+ ///
+ public string? Name { get; init; }
+
+ ///
+ /// Gets or initializes the cession percentage.
+ ///
+ public double Cession { get; init; }
+
+ ///
+ /// Gets or initializes the share percentage.
+ ///
+ public double Share { get; init; }
+
+ ///
+ /// Gets or initializes the collection of reinsurance sections (layers).
+ ///
+ public IReadOnlyCollection? Sections { get; init; }
+
+ ///
+ /// Gets or initializes the Estimated Premium Income (EPI).
+ ///
+ public double EPI { get; init; }
+
+ ///
+ /// Gets or initializes the rate.
+ ///
+ public double Rate { get; init; }
+
+ ///
+ /// Gets or initializes the commission percentage.
+ ///
+ public double Commission { get; init; }
+
+ ///
+ /// Gets or initializes the brokerage percentage.
+ ///
+ public double Brokerage { get; init; }
+
+ ///
+ /// Gets or initializes the tax percentage.
+ ///
+ public double Tax { get; init; }
+
+ ///
+ /// Gets or initializes the reinstatement premium.
+ ///
+ public double ReinstPrem { get; init; }
+
+ ///
+ /// Gets or initializes the no claims bonus percentage.
+ ///
+ public double NoClaimsBonus { get; init; }
+
+ ///
+ /// Gets or initializes the profit commission percentage.
+ ///
+ public double ProfitComm { get; init; }
+
+
+ ///
+ /// Gets or initializes the minimum and deposit premium.
+ ///
+ public double MDPrem { get; init; }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceSection.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceSection.cs
new file mode 100644
index 000000000..42c21572c
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/ReinsuranceSection.cs
@@ -0,0 +1,51 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace MeshWeaver.Insurance.Domain;
+
+///
+/// Represents a reinsurance coverage section with layer structure and financial terms.
+///
+[Display(GroupName = "Structure")]
+public record ReinsuranceSection
+{
+ ///
+ /// Gets or initializes the unique section identifier.
+ ///
+ [Key]
+ public required string Id { get; init; }
+
+ ///
+ /// Gets or initializes the acceptanceId this section belongs to.
+ ///
+ public string? AcceptanceId { get; init; }
+
+ ///
+ /// Gets or initializes the section name or description.
+ ///
+ public string? Name { get; init; }
+
+ ///
+ /// Gets or initializes the section type (e.g., "Fire Damage", "Natural Catastrophe", "Business Interruption").
+ ///
+ public string? LineOfBusiness { get; init; }
+
+ ///
+ /// Gets or initializes the attachment point.
+ ///
+ public decimal Attach { get; init; }
+
+ ///
+ /// Gets or initializes the layer limit.
+ ///
+ public decimal Limit { get; init; }
+
+ ///
+ /// Gets or initializes the aggregate attachment point (annual aggregate deductible).
+ ///
+ public decimal? AggAttach { get; init; }
+
+ ///
+ /// Gets or initializes the aggregate limit (annual aggregate limit).
+ ///
+ public decimal? AggLimit { get; init; }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/SampleDataProvider.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/SampleDataProvider.cs
index 23f9f1f96..a4970841a 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Domain/SampleDataProvider.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/SampleDataProvider.cs
@@ -1,4 +1,4 @@
-namespace MeshWeaver.Insurance.Domain;
+namespace MeshWeaver.Insurance.Domain;
///
/// Provides sample data for insurance dimensions and entities.
@@ -233,14 +233,12 @@ public static IEnumerable GetSamplePricings()
{
Id = "PRC-2024-001",
InsuredName = "Global Manufacturing Corp",
- BrokerName = "Marsh McLennan",
InceptionDate = new DateTime(2024, 1, 1),
ExpirationDate = new DateTime(2024, 12, 31),
UnderwritingYear = 2024,
LineOfBusiness = "PROP",
Country = "US",
LegalEntity = "MW-US",
- Premium = 125000m,
Currency = "USD",
Status = "Bound"
},
@@ -248,14 +246,12 @@ public static IEnumerable GetSamplePricings()
{
Id = "PRC-2024-002",
InsuredName = "European Logistics Ltd",
- BrokerName = "Aon",
InceptionDate = new DateTime(2024, 3, 1),
ExpirationDate = new DateTime(2025, 2, 28),
UnderwritingYear = 2024,
LineOfBusiness = "PROP",
Country = "GB",
LegalEntity = "MW-UK",
- Premium = 85000m,
Currency = "GBP",
Status = "Quoted"
},
@@ -263,14 +259,12 @@ public static IEnumerable GetSamplePricings()
{
Id = "PRC-2024-003",
InsuredName = "Tech Industries GmbH",
- BrokerName = "Willis Towers Watson",
InceptionDate = new DateTime(2024, 6, 1),
ExpirationDate = new DateTime(2025, 5, 31),
UnderwritingYear = 2024,
LineOfBusiness = "PROP",
Country = "DE",
LegalEntity = "MW-EU",
- Premium = 95000m,
Currency = "EUR",
Status = "Draft"
},
@@ -278,14 +272,10 @@ public static IEnumerable GetSamplePricings()
{
Id = "Microsoft-2026",
InsuredName = "Microsoft",
- BrokerName = "Marsh McLennan",
- InceptionDate = new DateTime(2026, 1, 1),
- ExpirationDate = new DateTime(2026, 12, 31),
UnderwritingYear = 2026,
LineOfBusiness = "PROP",
Country = "US",
LegalEntity = "MW-US",
- Premium = 2500000m,
Currency = "USD",
Status = "Bound"
}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/Services/GoogleGeocodingService.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/Services/GoogleGeocodingService.cs
new file mode 100644
index 000000000..4a0cb3758
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/Services/GoogleGeocodingService.cs
@@ -0,0 +1,237 @@
+using System.Collections.Concurrent;
+using System.Net.Http.Json;
+using System.Reactive.Subjects;
+using MeshWeaver.GoogleMaps;
+using Microsoft.Extensions.Options;
+
+namespace MeshWeaver.Insurance.Domain.Services;
+
+///
+/// Google Maps-based geocoding service for property risks.
+///
+public class GoogleGeocodingService(IOptions googleConfig) : IGeocodingService
+{
+ private readonly ReplaySubject progressSubject = InitializeProgress();
+ private readonly object progressLock = new();
+ private readonly HttpClient http = new();
+
+ private static ReplaySubject InitializeProgress()
+ {
+ var ret = new ReplaySubject(1);
+ ret.OnNext(null);
+ return ret;
+ }
+
+ public IObservable Progress => progressSubject;
+
+ public async Task GeocodeRisksAsync(IReadOnlyCollection risks, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(0, 0, null, "Starting geocoding..."));
+ }
+
+ if (!risks.Any())
+ {
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(0, 0, null, "No risks to geocode", true));
+ }
+ return new GeocodingResponse
+ {
+ Success = true,
+ GeocodedCount = 0,
+ Error = "No risks found to geocode"
+ };
+ }
+
+ // Check Google Maps API key
+ if (string.IsNullOrEmpty(googleConfig.Value.ApiKey))
+ {
+ var error = "Google Maps API key not configured";
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(0, 0, null, "Configuration error", true));
+ }
+ return new GeocodingResponse
+ {
+ Success = false,
+ GeocodedCount = 0,
+ Error = error
+ };
+ }
+
+ // Filter risks that need geocoding
+ var risksToGeocode = risks
+ .Where(r => r.GeocodedLocation?.Latitude == null || r.GeocodedLocation?.Longitude == null)
+ .ToList();
+
+ if (!risksToGeocode.Any())
+ {
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(risks.Count, risks.Count, null,
+ "All risks already geocoded", true));
+ }
+ return new GeocodingResponse
+ {
+ Success = true,
+ GeocodedCount = 0,
+ Error = null
+ };
+ }
+
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(risksToGeocode.Count, 0, null, "Initializing geocoding..."));
+ }
+
+ var geocodedCount = 0;
+ var updatedRisks = new ConcurrentBag();
+ var processedCount = 0;
+
+ var parallelOptions = new ParallelOptions
+ {
+ MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 10),
+ CancellationToken = cancellationToken
+ };
+
+ await Parallel.ForEachAsync(risksToGeocode, parallelOptions, async (risk, ct) =>
+ {
+ var riskName = risk.LocationName ?? risk.Address ?? $"Risk {risk.Id}";
+
+ try
+ {
+ var geocodedLocation = await GeocodeAsync(risk, ct);
+
+ if (geocodedLocation.Latitude.HasValue && geocodedLocation.Longitude.HasValue)
+ {
+ // Update the risk with geocoded data
+ var updatedRisk = risk with { GeocodedLocation = geocodedLocation };
+ updatedRisks.Add(updatedRisk);
+ Interlocked.Increment(ref geocodedCount);
+ }
+ else
+ {
+ // Still add the risk with the geocoding attempt result
+ updatedRisks.Add(risk with { GeocodedLocation = geocodedLocation });
+ }
+ }
+ catch (Exception)
+ {
+ // Add the original risk unchanged
+ updatedRisks.Add(risk);
+ }
+
+ // Update progress after processing each risk
+ var currentProcessed = Interlocked.Increment(ref processedCount);
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(
+ risksToGeocode.Count,
+ currentProcessed,
+ risk.Id,
+ $"Processing {currentProcessed}/{risksToGeocode.Count} risks..."
+ ));
+ }
+ });
+
+ // Final progress update
+ lock (progressLock)
+ {
+ progressSubject.OnNext(new GeocodingProgress(
+ risksToGeocode.Count,
+ risksToGeocode.Count,
+ null,
+ $"Completed processing {risksToGeocode.Count} risks",
+ true
+ ));
+ }
+
+ return new GeocodingResponse
+ {
+ Success = true,
+ GeocodedCount = geocodedCount,
+ Error = null,
+ UpdatedRisks = updatedRisks.ToList()
+ };
+ }
+ catch (Exception ex)
+ {
+ var error = $"Geocoding failed: {ex.Message}";
+ return new GeocodingResponse
+ {
+ Success = false,
+ GeocodedCount = 0,
+ Error = error
+ };
+ }
+ finally
+ {
+ lock (progressLock)
+ {
+ progressSubject.OnNext(null);
+ }
+ }
+ }
+
+ private async Task GeocodeAsync(PropertyRisk risk, CancellationToken ct = default)
+ {
+ var query = BuildQuery(risk);
+ var url = $"https://maps.googleapis.com/maps/api/geocode/json?address={Uri.EscapeDataString(query)}&key={googleConfig.Value.ApiKey}";
+
+ var response = await http.GetFromJsonAsync(url, cancellationToken: ct);
+ if (response == null)
+ {
+ return new GeocodedLocation { Status = "NoResponse" };
+ }
+
+ if (response.status != "OK" || response.results == null || response.results.Length == 0)
+ {
+ return new GeocodedLocation { Status = response.status };
+ }
+
+ var r = response.results[0];
+ return new GeocodedLocation
+ {
+ Latitude = r.geometry.location.lat,
+ Longitude = r.geometry.location.lng,
+ FormattedAddress = r.formatted_address,
+ PlaceId = r.place_id,
+ Status = response.status
+ };
+ }
+
+ private static string BuildQuery(PropertyRisk risk)
+ {
+ var parts = new[] { risk.LocationName, risk.Address, risk.City, risk.State, risk.ZipCode, risk.Country }
+ .Where(s => !string.IsNullOrWhiteSpace(s));
+ return string.Join(", ", parts);
+ }
+
+ private sealed class GoogleGeocodeResponse
+ {
+ public string status { get; set; } = string.Empty;
+ public GoogleGeocodeResult[]? results { get; set; }
+ }
+
+ private sealed class GoogleGeocodeResult
+ {
+ public string formatted_address { get; set; } = string.Empty;
+ public string place_id { get; set; } = string.Empty;
+ public GoogleGeometry geometry { get; set; } = new();
+ }
+
+ private sealed class GoogleGeometry
+ {
+ public GoogleLocation location { get; set; } = new();
+ }
+
+ private sealed class GoogleLocation
+ {
+ public double lat { get; set; }
+ public double lng { get; set; }
+ }
+}
diff --git a/modules/Insurance/MeshWeaver.Insurance.Domain/Services/IGeocodingService.cs b/modules/Insurance/MeshWeaver.Insurance.Domain/Services/IGeocodingService.cs
new file mode 100644
index 000000000..bfbd5b3c3
--- /dev/null
+++ b/modules/Insurance/MeshWeaver.Insurance.Domain/Services/IGeocodingService.cs
@@ -0,0 +1,28 @@
+namespace MeshWeaver.Insurance.Domain.Services;
+
+///
+/// Service for geocoding property risks.
+///
+public interface IGeocodingService
+{
+ ///
+ /// Observable stream of geocoding progress.
+ ///
+ IObservable Progress { get; }
+
+ ///
+ /// Geocodes a collection of property risks.
+ ///
+ Task GeocodeRisksAsync(IReadOnlyCollection risks, CancellationToken cancellationToken = default);
+}
+
+///
+/// Progress information for geocoding operations.
+///
+public record GeocodingProgress(
+ int TotalRisks,
+ int ProcessedRisks,
+ string? CurrentRiskId,
+ string CurrentRiskName,
+ bool IsComplete = false
+);
diff --git a/modules/Insurance/MeshWeaver.Insurance.Test/InsuranceTestBase.cs b/modules/Insurance/MeshWeaver.Insurance.Test/InsuranceTestBase.cs
index aa7543a80..e60de47a4 100644
--- a/modules/Insurance/MeshWeaver.Insurance.Test/InsuranceTestBase.cs
+++ b/modules/Insurance/MeshWeaver.Insurance.Test/InsuranceTestBase.cs
@@ -1,9 +1,10 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using FluentAssertions.Extensions;
using MeshWeaver.Data;
using MeshWeaver.Hosting.Monolith.TestBase;
using MeshWeaver.Insurance.Domain;
using MeshWeaver.Mesh;
-using System.Text.Json;
-using System.Text.Json.Nodes;
using Xunit;
namespace MeshWeaver.Insurance.Test;
@@ -15,8 +16,8 @@ protected override MeshBuilder ConfigureMesh(MeshBuilder builder)
return base.ConfigureMesh(builder)
.ConfigureHub(c => c
.AddData()
- .ConfigureInsuranceApplication()
- );
+ )
+ .InstallAssemblies(typeof(InsuranceApplicationAttribute).Assembly.Location);
}
protected async Task> GetPropertyRisksAsync(PricingAddress address)
@@ -27,7 +28,7 @@ protected async Task> GetPropertyRisksAsync(Pr
o => o.WithTarget(address),
TestContext.Current.CancellationToken);
- return (risksResp.Message.Data as IEnumerable