From c6f0f7f3f8f016154a727714b92dcbbdd64c0b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:39:10 +0200 Subject: [PATCH 1/5] refactor(indexing): the chunking pipeline leaves the content surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave B of the carve-out, platform half. Stacked on the Embeddings branch; paired with Systemorph/MeshWeaver.Plugins#1045's follow-up, which merges FIRST. MeshWeaver.ContentCollections.Indexing and .Indexing.Graph leave the platform repo AND FrameworkBuildIdentity.ContentSurfaceAssemblies. That second half is the real change: the pipeline is the Indexing MODULE now, composed into content compiles per-mesh via ComposeWithModules rather than being part of the framework identity — the MeshWeaver.AI / Markdown.Collaboration shape, and the comment left beside those two now covers this one. Leaving the surface is only safe because NO in-mesh content binds it: verified across every gated sample tree and the doc data, where the only occurrences of the namespace are prose in .md files. (Contrast MeshWeaver.Maps, which one gated sample does bind — it is held back deliberately so the identity flips ONCE rather than twice.) THE CONTRACT SPLIT. Mesh.Operations stays here and needs exactly one type, DocumentPaths, so DocumentPaths moves to MeshWeaver.Mesh.Contract KEEPING its MeshWeaver.ContentCollections.Indexing namespace — every `using` still resolves and no consumer changed. Mesh.Contract rather than ContentCollections because the indexing base uses DocumentPaths too (ContentChunkSearch), and ContentCollections would have handed the pipeline a new dependency on Layout and Markdown — exactly what its own "storage-agnostic core" note rules out. Both remaining consumers already reference Mesh.Contract, so the split adds no edge to the graph. Also deletes test/MeshWeaver.ContentCollections.Indexing.Graph.Test, which had been duplicated into the plugins repo and never removed here — 7 files, 7 of 7 byte-identical. The third instance of this pattern in one change set, and the first caught before it drifted. Three ships-the-bits ProjectReferences dropped (PluginTester, Portal.Shared ×2) and one real one (Mesh.Operations, now served by Mesh.Contract). Verified (Release, -warnaserror): Mesh.Contract, Mesh.Operations and Portal.Shared all 0 Warning(s) 0 Error(s); FrameworkBuildIdentity tests 23/23 with the two names removed from the surface. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XM3bKbVAFCCDH9ZyYCxKQy --- MeshWeaver.slnx | 4 - .../Memex.Portal.Shared.csproj | 2 - .../FrameworkBuildIdentity.cs | 9 +- .../ChatClientImageDescriber.cs | 123 ------ .../ChatClientSummarizer.cs | 70 --- .../ContentChunkAutocompleteProvider.cs | 162 ------- .../ContentIndexSettingsTab.cs | 403 ------------------ .../ContentIndexingActivity.cs | 330 -------------- .../ContentIndexingObserver.cs | 333 --------------- .../ContentIndexingPipelineExtensions.cs | 165 ------- .../DocumentIndexingExtensions.cs | 167 -------- .../DocumentLayoutAreas.cs | 336 --------------- .../DocumentNodeType.cs | 60 --- .../MeshDocumentSink.cs | 104 ----- ...r.ContentCollections.Indexing.Graph.csproj | 31 -- .../README.md | 17 - .../ChunkPosition.cs | 32 -- .../ChunkPositionJson.cs | 46 -- .../ContentChunk.cs | 40 -- .../ContentChunkSearch.cs | 350 --------------- .../ContentIndexingOptions.cs | 21 - .../ContentIndexingService.cs | 304 ------------- .../Document.cs | 48 --- .../DocumentInfo.cs | 25 -- .../ExtractedDocument.cs | 50 --- .../ExtractiveSummarizer.cs | 52 --- .../IChunkEmbedder.cs | 20 - .../IChunkedContentVectorStore.cs | 60 --- .../IDocumentSink.cs | 40 -- .../IImageDescriber.cs | 32 -- .../ISummarizer.cs | 25 -- .../ITextExtractor.cs | 28 -- .../InMemoryChunkedContentVectorStore.cs | 156 ------- .../InertContentIndex.cs | 144 ------- ...hWeaver.ContentCollections.Indexing.csproj | 19 - .../README.md | 20 - .../TextChunker.cs | 141 ------ .../TextExtractor.cs | 162 ------- .../TypeForwards.cs | 18 - src/MeshWeaver.Mesh.Contract/DocumentPaths.cs | 12 +- .../MeshWeaver.Mesh.Operations.csproj | 1 - .../ChatClientImageDescriberTest.cs | 98 ----- .../ChatClientSummarizerTest.cs | 102 ----- .../ContentChunkAutocompleteProviderTest.cs | 139 ------ .../ContentIndexingPipelineTest.cs | 281 ------------ .../DocumentBlocksAreaRenderTest.cs | 147 ------- .../DocumentPathsTest.cs | 78 ---- .../MeshDocumentSinkTest.cs | 213 --------- ...tentCollections.Indexing.Graph.Test.csproj | 21 - .../ChunkNavigationStoreTest.cs | 93 ---- .../ContentChunkSearchTest.cs | 278 ------------ .../ContentIndexingServiceTest.cs | 192 --------- .../ContentSearchHitContractTest.cs | 55 --- .../DocumentSummaryIndexingTest.cs | 181 -------- .../FakeDocumentSink.cs | 42 -- .../FakeEmbedder.cs | 29 -- .../FakeImageDescriber.cs | 33 -- .../FakeSummarizer.cs | 32 -- .../ImageDescriptionIndexingTest.cs | 124 ------ .../InertContentIndexTest.cs | 99 ----- ...er.ContentCollections.Indexing.Test.csproj | 14 - .../PdfTestFixtures.cs | 43 -- .../TextChunkerTest.cs | 146 ------- .../TextExtractorTest.cs | 111 ----- .../MeshWeaver.PluginTester.csproj | 1 - 65 files changed, 17 insertions(+), 6697 deletions(-) delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ChatClientImageDescriber.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ChatClientSummarizer.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ContentChunkAutocompleteProvider.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ContentIndexSettingsTab.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ContentIndexingActivity.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ContentIndexingObserver.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/ContentIndexingPipelineExtensions.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/DocumentIndexingExtensions.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/DocumentLayoutAreas.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/DocumentNodeType.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/MeshDocumentSink.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/MeshWeaver.ContentCollections.Indexing.Graph.csproj delete mode 100644 src/MeshWeaver.ContentCollections.Indexing.Graph/README.md delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ChunkPosition.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ChunkPositionJson.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ContentChunk.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ContentChunkSearch.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ContentIndexingOptions.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ContentIndexingService.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/Document.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/DocumentInfo.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ExtractedDocument.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ExtractiveSummarizer.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/IChunkEmbedder.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/IChunkedContentVectorStore.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/IDocumentSink.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/IImageDescriber.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ISummarizer.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/ITextExtractor.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/InMemoryChunkedContentVectorStore.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/InertContentIndex.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/MeshWeaver.ContentCollections.Indexing.csproj delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/README.md delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/TextChunker.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/TextExtractor.cs delete mode 100644 src/MeshWeaver.ContentCollections.Indexing/TypeForwards.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientImageDescriberTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientSummarizerTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentChunkAutocompleteProviderTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentIndexingPipelineTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentBlocksAreaRenderTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentPathsTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshDocumentSinkTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshWeaver.ContentCollections.Indexing.Graph.Test.csproj delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/ChunkNavigationStoreTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/ContentChunkSearchTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/ContentIndexingServiceTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/ContentSearchHitContractTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/DocumentSummaryIndexingTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/FakeDocumentSink.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/FakeEmbedder.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/FakeImageDescriber.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/FakeSummarizer.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/ImageDescriptionIndexingTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/InertContentIndexTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/MeshWeaver.ContentCollections.Indexing.Test.csproj delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/PdfTestFixtures.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/TextChunkerTest.cs delete mode 100644 test/MeshWeaver.ContentCollections.Indexing.Test/TextExtractorTest.cs diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index 71daa5390b..0bb3834b4c 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -34,8 +34,6 @@ - - @@ -76,8 +74,6 @@ - - diff --git a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj index db77cee881..27ee5a9620 100644 --- a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj +++ b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj @@ -91,8 +91,6 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MeshWeaver.ContentCollections.Indexing.Graph/README.md b/src/MeshWeaver.ContentCollections.Indexing.Graph/README.md deleted file mode 100644 index bd46c28923..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing.Graph/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# MeshWeaver.ContentCollections.Indexing.Graph - -Mesh/portal integration for the MeshWeaver content indexing pipeline. Watches content -collections for changes, runs indexing as mesh activities, and surfaces the index in the -portal (autocomplete, settings). - -## Features - -- `ContentIndexingObserver` / `ContentIndexingActivity` — change-driven re-indexing as activity-control-plane operations -- `ContentChunkAutocompleteProvider` — chunk search results in `@` autocomplete -- `ChatClientSummarizer` / `ChatClientImageDescriber` — AI-generated summaries and image descriptions for indexed content -- `ContentIndexSettingsTab` — per-space index configuration in the portal settings - -## Links - -- [MeshWeaver repository](https://github.com/Systemorph/MeshWeaver) -- [Documentation](https://memex.meshweaver.cloud/Doc) diff --git a/src/MeshWeaver.ContentCollections.Indexing/ChunkPosition.cs b/src/MeshWeaver.ContentCollections.Indexing/ChunkPosition.cs deleted file mode 100644 index 63832b0628..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ChunkPosition.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// The location of a chunk on its source page, as a normalized bounding box: every coordinate is a -/// fraction of the page in [0, 1] with a top-left origin (the UI/screen convention — x grows -/// right, y grows down). Normalization is deliberate: a viewer can overlay a highlight rectangle at any -/// render scale (a thumbnail, a full page, a zoomed page) without knowing the source page's point size. -/// -/// Produced by from the source document's own text-layout coordinates -/// (for PDFs, PdfPig word bounding boxes) and carried on a so a consumer can -/// literally open the source page and mark the region a chunk (or an extracted value) came from. Null when -/// the source format carries no layout (txt/markdown/docx) or the position could not be resolved. -/// -/// Left edge as a fraction of page width, [0, 1]. -/// Top edge as a fraction of page height, [0, 1] (top-left origin). -/// Box width as a fraction of page width, [0, 1]. -/// Box height as a fraction of page height, [0, 1]. -public sealed record ChunkPosition(double X, double Y, double Width, double Height) -{ - /// - /// The smallest box that contains both this and — used to grow a chunk's box - /// to cover every word (span) that falls inside its text window on the same page. - /// - public ChunkPosition Union(ChunkPosition other) - { - var left = Math.Min(X, other.X); - var top = Math.Min(Y, other.Y); - var right = Math.Max(X + Width, other.X + other.Width); - var bottom = Math.Max(Y + Height, other.Y + other.Height); - return new ChunkPosition(left, top, right - left, bottom - top); - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ChunkPositionJson.cs b/src/MeshWeaver.ContentCollections.Indexing/ChunkPositionJson.cs deleted file mode 100644 index 36f5089525..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ChunkPositionJson.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Text.Json.Nodes; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// The one canonical JSON shape for a — { "x", "y", "w", "h" }, each a -/// fraction of the page in [0, 1] with a top-left origin. Shared by every surface (the pgvector -/// bbox column, the search_chunks/get_chunk tool envelopes, and the document viewer), -/// so the box a chunk is stored with is exactly the box the UI marks — no per-surface reformatting drift. -/// -public static class ChunkPositionJson -{ - /// Serializes a position to the canonical JSON string, or null when there is no position. - public static string? Serialize(ChunkPosition? position) => - ToJsonObject(position)?.ToJsonString(); - - /// Builds the canonical {x,y,w,h} node, or null when there is no position. - public static JsonObject? ToJsonObject(ChunkPosition? position) => - position is null - ? null - : new JsonObject - { - ["x"] = position.X, - ["y"] = position.Y, - ["w"] = position.Width, - ["h"] = position.Height, - }; - - /// - /// Parses the canonical JSON back into a , or null for null/empty input. - /// Missing keys default to 0 (a degenerate but harmless box) — the JSONB is validated by Postgres on - /// write and always written by , so this never has to defend against invalid JSON. - /// - public static ChunkPosition? Deserialize(string? json) - { - if (string.IsNullOrEmpty(json)) - return null; - if (JsonNode.Parse(json) is not JsonObject obj) - return null; - return new ChunkPosition( - obj["x"]?.GetValue() ?? 0, - obj["y"]?.GetValue() ?? 0, - obj["w"]?.GetValue() ?? 0, - obj["h"]?.GetValue() ?? 0); - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ContentChunk.cs b/src/MeshWeaver.ContentCollections.Indexing/ContentChunk.cs deleted file mode 100644 index 8f8ba57d63..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ContentChunk.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Immutable; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// One indexed window of text extracted from a content file, together with its embedding. -/// Immutable: a chunk is produced once by and stored -/// verbatim in an . -/// -/// Path of the owning content collection (storage-agnostic key). -/// Path of the source file within the collection. -/// Zero-based position of this chunk within the file's chunk sequence. -/// The chunk text (a character window of the extracted document text). -/// -/// SHA-256 (hex) of the WHOLE source file's bytes — identical across every chunk of one file. -/// This is the value the hash gate compares to decide whether the file changed since it was -/// last indexed. (Per-chunk identity is , not this.) -/// -/// -/// The embedding vector for , or null if the chunk has not been embedded yet. -/// -/// Optional free-form metadata (e.g. source content-type). -/// -/// One-based source page the chunk begins on, or null when the source format carries no layout -/// (txt/markdown/docx) or the page could not be resolved. Provenance for citing/marking the source. -/// -/// -/// The chunk's normalized bounding box on (fractions of the page, top-left origin), or -/// null when unknown. Lets a viewer open the source page and mark the exact region the chunk came from. -/// -public sealed record ContentChunk( - string CollectionPath, - string FilePath, - int ChunkIndex, - string Text, - string ContentHash, - float[]? Embedding, - ImmutableDictionary? Metadata = null, - int? Page = null, - ChunkPosition? Position = null); diff --git a/src/MeshWeaver.ContentCollections.Indexing/ContentChunkSearch.cs b/src/MeshWeaver.ContentCollections.Indexing/ContentChunkSearch.cs deleted file mode 100644 index afced6d880..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ContentChunkSearch.cs +++ /dev/null @@ -1,350 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json.Nodes; -using MeshWeaver.Mesh; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// The storage-agnostic content-chunk search engine shared by every surface: the agent / MCP -/// search_chunks tool (via ChunkNavigation in MeshWeaver.AI) and the in-portal -/// "Explore index" GUI (via the Content-Indexing settings tab). Keeping it here — in the indexing core -/// both of those reference — means the GUI's "this maps to a tool call" claim is honest by construction: -/// the same code resolves the query for the agent and for the explorer. -/// -/// Two scope models. -/// -/// Anchored (): the caller passes a node path; the collection -/// candidates are that path plus each ancestor prefix ("what content is relevant to where I am"). -/// This is the agent's context-anchored retrieval. -/// Targeted grammar (): the query carries -/// namespace:<node>/<collection> and an optional scope: qualifier. The -/// default scope is subtree — the named collection plus every collection nested under it -/// ("check only this collection"). scope:exact restricts to the one collection; -/// scope:ancestors/ancestorsandself reproduce the ancestor walk. -/// -/// dispatches by query shape: a namespace: token routes to the grammar engine, -/// otherwise the anchored walk runs. -/// -/// Reactive + cold: every method returns an that embeds + reads the -/// store on subscribe. The store + embedder are passed in (resolved by the caller from DI); when content -/// indexing is OFF — never wired into the host (null) or wired but not configured for this deployment -/// (an stand-in) — the engine emits a result whose -/// says so, never throwing. A capability that is switched off -/// must SAY it is switched off: an opaque error here sends the next caller hunting a data bug. -/// -public static class ContentChunkSearch -{ - /// - /// Dispatches by query shape: a query carrying a namespace: token routes to the targeted - /// grammar engine (, subtree by default); otherwise the query is anchored at - /// and walks that path plus each ancestor prefix - /// (). - /// - public static IObservable Search( - IChunkedContentVectorStore? store, IChunkEmbedder? embedder, - string query, string? anchorPath, int limit = 20) => - MentionsNamespace(query) - ? SearchContent(store, embedder, query, limit, defaultNamespace: anchorPath) - : SearchAnchored(store, embedder, query, anchorPath, limit); - - /// - /// Targeted grammar search: parses for - /// namespace:<node>/<collection> (which collection) and scope: (how to resolve - /// collections) plus the free text (embedded), and returns the ranked hits. The default scope is - /// subtree ("check only this collection") when no scope: is given. - /// - public static IObservable SearchContent( - IChunkedContentVectorStore? store, IChunkEmbedder? embedder, - string query, int limit = 20, string? defaultNamespace = null) - { - var parsed = new QueryParser().Parse(query); - var ns = NormalizePath(parsed.Path ?? defaultNamespace); - var text = (parsed.TextSearch ?? string.Empty).Trim(); - // The parser forces scope:Children when namespace: is used WITHOUT an explicit scope; detect the - // explicit form off the raw string so the content-search default (subtree) wins when it's absent. - var explicitScope = !string.IsNullOrEmpty(query) - && query.Contains("scope:", StringComparison.OrdinalIgnoreCase); - var scope = ResolveScope(parsed.Scope, explicitScope); - limit = Math.Clamp(limit, 1, 200); - - var toolCall = BuildToolCall(ns, scope, text, limit); - - ContentSearchResult Empty(string message) => - new(text, ns, scope, limit, Array.Empty(), toolCall, message) - { Searched = false }; // 🚨 nothing was searched — see ContentSearchResult.Searched (#2741) - - if (ContentIndexAvailability.IsOff(store, embedder)) - return Observable.Return(Empty(ContentIndexAvailability.ReasonOff(store, embedder))); - if (string.IsNullOrWhiteSpace(text)) - return Observable.Return(Empty("Pass query text (free-text terms) to search content chunks.")); - if (string.IsNullOrWhiteSpace(ns)) - return Observable.Return(Empty( - "No namespace to search. Add 'namespace:/' to the query " + - "(e.g. 'namespace:ACME/content laptop pricing').")); - - return embedder.Embed(text) - .SelectMany(vector => ResolveChunks(store, ns!, scope, vector, limit)) - .Select(chunks => new ContentSearchResult( - text, ns, scope, limit, ToHits(chunks, limit), toolCall)); - } - - /// - /// Anchored search: embeds the query and searches the collection at plus - /// each ancestor-prefix collection, most-similar-first, capped at . This is the - /// agent's context-anchored retrieval (no namespace: token in the query). - /// - private static IObservable SearchAnchored( - IChunkedContentVectorStore? store, IChunkEmbedder? embedder, - string query, string? anchorPath, int limit) - { - var text = (query ?? string.Empty).Trim(); - limit = Math.Clamp(limit, 1, 200); - var toolCall = BuildAnchoredToolCall(anchorPath, text, limit); - - ContentSearchResult Empty(string message) => - new(text, NormalizePath(anchorPath), ContentSearchScope.AncestorsAndSelf, limit, - Array.Empty(), toolCall, message) - { Searched = false }; // 🚨 nothing was searched — see ContentSearchResult.Searched (#2741) - - if (ContentIndexAvailability.IsOff(store, embedder)) - return Observable.Return(Empty(ContentIndexAvailability.ReasonOff(store, embedder))); - if (string.IsNullOrWhiteSpace(text)) - return Observable.Return(Empty("Pass a non-empty 'query' to search content chunks.")); - - var collections = CollectionScope(anchorPath); - if (collections.Count == 0) - return Observable.Return(Empty( - "No scope to search. Pass 'scope' as the node path whose content (and ancestors') chunks to " + - "search — e.g. 'ACME/Reports' — or use 'namespace:/' in the query.")); - - return embedder.Embed(text) - .SelectMany(vector => collections.ToObservable() - .SelectMany(collection => store.Search(collection, vector, limit)) - .SelectMany(list => list.ToObservable()) - .ToList()) - .Select(chunks => new ContentSearchResult( - text, NormalizePath(anchorPath), ContentSearchScope.AncestorsAndSelf, limit, - ToHits((IReadOnlyList)chunks, limit), toolCall)); - } - - /// Resolves the chunk set for a namespace + scope into the store call(s) it implies. - private static IObservable> ResolveChunks( - IChunkedContentVectorStore store, string ns, ContentSearchScope scope, float[] vector, int limit) => - scope switch - { - ContentSearchScope.Exact => store.Search(ns, vector, limit), - ContentSearchScope.AncestorsAndSelf => CollectionScope(ns).ToObservable() - .SelectMany(collection => store.Search(collection, vector, limit)) - .SelectMany(list => list.ToObservable()) - .ToList() - .Select(merged => (IReadOnlyList)merged), - _ => store.SearchSubtree(ns, vector, limit), // Subtree (default) - }; - - /// - /// The error discriminator carried by an envelope whose search never ran, so a caller can - /// branch on a stable token instead of parsing the human-readable message. - /// - public const string NotSearchedError = "search-not-performed"; - - /// Serializes a result to the tool's JSON envelope {count, results, message?}. - public static string ToJson(ContentSearchResult result) - { - var results = new JsonArray(); - foreach (var hit in result.Hits) - { - var hitObj = new JsonObject - { - ["documentPath"] = hit.DocumentPath, - ["collectionPath"] = hit.CollectionPath, - ["filePath"] = hit.FilePath, - ["chunkIndex"] = hit.ChunkIndex, - ["rank"] = hit.Rank, - ["snippet"] = hit.Snippet, - }; - // Provenance (only when the source carried it): the page the chunk begins on and its normalized - // box there, so a consumer can cite the page and open+mark the exact region. - if (hit.Page is int page) - hitObj["page"] = page; - if (ChunkPositionJson.ToJsonObject(hit.Position) is { } bbox) - hitObj["bbox"] = bbox; - results.Add(hitObj); - } - - // 🚨 A search that did not RUN gets no count and no results array (MeshWeaver#2741). - // Reporting `{"count":0,"results":[]}` for a disabled index made "nothing matched" and - // "nothing was searched" byte-identical to the one field every caller actually reads, and - // the caller most likely to read it is an agent performing the pre-deletion sweep AGENTS.md - // mandates. Omitting the fields is what makes the difference impossible to miss: a consumer - // testing `count == 0` now finds no `count` at all, rather than a zero that means the - // opposite of what it looks like. - var obj = new JsonObject { ["searched"] = result.Searched }; - if (result.Searched) - { - obj["count"] = results.Count; - obj["results"] = results; - } - else - { - obj["error"] = NotSearchedError; - } - - if (!string.IsNullOrEmpty(result.Message)) - obj["message"] = result.Message; - return obj.ToJsonString(); - } - - /// - /// The collection candidates for a scope path: the path itself plus every ancestor prefix, so a scope - /// of part/Space/Sub searches collections keyed at part/Space/Sub, part/Space, and - /// part. Empty when there is no scope. - /// - private static IReadOnlyCollection CollectionScope(string? scopePath) - { - var path = NormalizePath(scopePath); - if (path is null) - return []; - - var scope = new List(); - while (path!.Length > 0) - { - scope.Add(path); - var lastSlash = path.LastIndexOf('/'); - if (lastSlash <= 0) - break; - path = path[..lastSlash]; - } - return scope; - } - - /// Projects ranked chunks into the capped, 0-based-ranked hit list shared by every surface. - private static IReadOnlyList ToHits(IEnumerable chunks, int limit) => - chunks.Take(limit) - .Select((hit, rank) => new ChunkHit( - DocumentPaths.For(hit.CollectionPath, hit.FilePath), - hit.CollectionPath, hit.FilePath, hit.ChunkIndex, rank, Snippet(hit.Text), - hit.Page, hit.Position)) - .ToArray(); - - /// Maps a parsed to the content-search scope, defaulting to subtree. - private static ContentSearchScope ResolveScope(QueryScope parsed, bool explicitScope) - { - if (!explicitScope) - return ContentSearchScope.Subtree; // content-search default — "check only this collection" - return parsed switch - { - QueryScope.Exact => ContentSearchScope.Exact, - QueryScope.Ancestors or QueryScope.AncestorsAndSelf => ContentSearchScope.AncestorsAndSelf, - _ => ContentSearchScope.Subtree, // subtree / descendants / hierarchy / children / nextlevel - }; - } - - /// The equivalent grammar search_chunks call for a resolved query — surfaced in the explorer. - private static string BuildToolCall(string? ns, ContentSearchScope scope, string text, int limit) - { - var scopeToken = scope switch - { - ContentSearchScope.Exact => "exact", - ContentSearchScope.AncestorsAndSelf => "ancestorsandself", - _ => "subtree", - }; - var grammar = string.Join(' ', new[] - { - string.IsNullOrEmpty(ns) ? null : $"namespace:{ns}", - $"scope:{scopeToken}", - string.IsNullOrEmpty(text) ? null : text, - }.Where(s => s is not null)); - return $"search_chunks(query: \"{grammar}\", limit: {limit})"; - } - - /// The equivalent anchored search_chunks call (scope is a node path, not the grammar). - private static string BuildAnchoredToolCall(string? anchorPath, string text, int limit) - { - var ns = NormalizePath(anchorPath); - var scopeArg = ns is null ? string.Empty : $", scope: \"{ns}\""; - return $"search_chunks(query: \"{text}\"{scopeArg}, limit: {limit})"; - } - - /// Whether the query carries a namespace: qualifier (grammar mode). - private static bool MentionsNamespace(string? query) => - !string.IsNullOrEmpty(query) - && query.Contains("namespace:", StringComparison.OrdinalIgnoreCase); - - /// Trims a path of a leading @, surrounding whitespace, and slashes; null when empty. - private static string? NormalizePath(string? path) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - var p = path.Trim().TrimStart('@').Trim().Trim('/'); - return p.Length == 0 ? null : p; - } - - /// A single-line, ~160-char snippet of a chunk's text for the search result. - private static string Snippet(string text) - { - const int max = 160; - var oneLine = text.Replace('\r', ' ').Replace('\n', ' ').Trim(); - oneLine = string.Join(' ', oneLine.Split(' ', StringSplitOptions.RemoveEmptyEntries)); - return oneLine.Length <= max ? oneLine : oneLine[..max].TrimEnd() + "…"; - } -} - -/// A single chunk hit with its coordinates — the typed form behind the JSON envelope. -/// The hit's Document-node path (DocumentPaths.For), for linking. -/// The collection the chunk belongs to. -/// The file within the collection. -/// The 0-based chunk index within the file (feed back into get_chunk). -/// 0-based best-first relevance position. -/// A one-line, ~160-char preview of the chunk text. -/// One-based source page the chunk begins on, or null when the source carries no layout. -/// The chunk's normalized on-page box, or null when unknown — for open+mark. -public record ChunkHit( - string DocumentPath, string CollectionPath, string FilePath, int ChunkIndex, int Rank, string Snippet, - int? Page = null, ChunkPosition? Position = null); - -/// -/// The outcome of a content search: the resolved namespace + scope, the ranked hits, and a human-readable -/// descriptor of the equivalent search_chunks call (for the explorer's tool-call inspector). -/// is set instead of hits when the search could not run (indexing off, no namespace, -/// empty text). -/// -public record ContentSearchResult( - string Text, - string? Namespace, - ContentSearchScope Scope, - int Limit, - IReadOnlyList Hits, - string ToolCall, - string? Message = null) -{ - /// - /// Whether a search actually RAN. False when the engine returned before embedding anything — - /// content indexing is off on this deployment, or the query carried no text / no namespace. - /// - /// 🚨 This exists because "we did not look" and "we looked and found nothing" were the - /// same answer (MeshWeaver#2741). AGENTS.md requires a live-mesh search_chunks sweep - /// before deleting any public framework surface, precisely because the mesh holds callers the - /// repo has already dropped. On a deployment without an embedding provider that sweep answered - /// {"count":0,"results":[]} — and an agent following the procedure reads count: 0 - /// as permission to delete. That is a verification step that cannot fail, which this repo - /// treats as a defect class in its own words. - /// - /// An init-only property rather than a constructor parameter on purpose: adding a - /// positional parameter would change the primary constructor's signature, which the public - /// binary-compatibility gate is right to refuse. - /// - public bool Searched { get; init; } = true; -} - -/// How a content search resolves which collections to read from a single named namespace. -public enum ContentSearchScope -{ - /// The named collection plus every collection nested under it — the default ("check only this collection"). - Subtree, - - /// Only the exact named collection. - Exact, - - /// The named collection plus each ancestor-prefix collection (the agent's context walk). - AncestorsAndSelf, -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingOptions.cs b/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingOptions.cs deleted file mode 100644 index 553a3f1cd7..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingOptions.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Tuning for . Immutable record so a host can override any -/// field via options with { ChunkSize = 2000 } without a new type. -/// -public sealed record ContentIndexingOptions -{ - /// Character-window size handed to . Defaults to 1000. - public int ChunkSize { get; init; } = 1000; - - /// Character overlap between consecutive chunks. Defaults to 150. - public int ChunkOverlap { get; init; } = 150; - - /// - /// Max chunks embedded concurrently (the bounded merge degree). The embedder's own - /// IIoPool still governs the real network/compute concurrency; this only caps how many - /// embed observables one file's indexing subscribes to at once. Defaults to 8. - /// - public int MaxConcurrentEmbeddings { get; init; } = 8; -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingService.cs b/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingService.cs deleted file mode 100644 index 750241eb11..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ContentIndexingService.cs +++ /dev/null @@ -1,304 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using System.Security.Cryptography; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// Outcome of a single call. -public enum IndexStatus -{ - /// The file changed (or was new); its chunks were (re)built, embedded and stored. - Indexed, - - /// The file's content hash matched the stored hash; nothing was re-extracted or re-embedded. - Skipped, - - /// The file produced no extractable text (unknown/binary or empty); no chunks stored. - NoText, -} - -/// Result of indexing one file. -/// What happened. -/// Chunks stored (0 for / ). -public sealed record IndexResult(IndexStatus Status, int ChunkCount) -{ - /// Creates a result for a file that was (re)indexed, recording how many chunks were stored. - /// The number of chunks stored for the file. - /// An IndexResult with status and the given chunk count. - public static IndexResult Indexed(int chunkCount) => new(IndexStatus.Indexed, chunkCount); - - /// The shared result for a file skipped by the hash gate (unchanged; zero chunks). - public static readonly IndexResult Skipped = new(IndexStatus.Skipped, 0); - - /// The shared result for a file that yielded no extractable text (zero chunks). - public static readonly IndexResult NoText = new(IndexStatus.NoText, 0); -} - -/// -/// The orchestrator: content-file bytes → text → chunks → embeddings → vector store. Fully -/// reactive (no async/await/Task), storage-agnostic (composes -/// , , ), -/// and pool-correct: it holds NO I/O-pool slot itself — every leaf (extract, embed, store) -/// acquires and releases its own. The orchestration is pure composition. -/// -public sealed class ContentIndexingService -{ - private readonly ITextExtractor _extractor; - private readonly IChunkEmbedder _embedder; - private readonly IChunkedContentVectorStore _store; - private readonly ISummarizer? _summarizer; - private readonly IDocumentSink? _documentSink; - private readonly IImageDescriber? _imageDescriber; - private readonly ContentIndexingOptions _options; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the ContentIndexingService class. - /// - /// Extracts plain text from raw file bytes (dispatched by extension). - /// Produces the embedding vector for each chunk of text. - /// Persists file hashes and chunk sets and serves vector search. - /// Chunking and concurrency options; defaults are used when null. - /// Logger for the indexing pipeline; a no-op logger is used when null. - /// Optional per-document summarizer; the document branch runs only when both this and are supplied. - /// Optional sink that persists the per-file ; paired with . - /// Optional vision describer; when supplied, an image file (by extension) is described by it instead of the text extractor, and the description becomes both the indexed text and the Document summary. - public ContentIndexingService( - ITextExtractor extractor, - IChunkEmbedder embedder, - IChunkedContentVectorStore store, - ContentIndexingOptions? options = null, - ILogger? logger = null, - ISummarizer? summarizer = null, - IDocumentSink? documentSink = null, - IImageDescriber? imageDescriber = null) - { - _extractor = extractor ?? throw new ArgumentNullException(nameof(extractor)); - _embedder = embedder ?? throw new ArgumentNullException(nameof(embedder)); - _store = store ?? throw new ArgumentNullException(nameof(store)); - // summarizer + documentSink are OPTIONAL: both null ⇒ exactly the chunk-embed-store - // behavior (no per-file Document, no summary). They are wired together — a document is - // written only when BOTH are present. - _summarizer = summarizer; - _documentSink = documentSink; - // imageDescriber is OPTIONAL: absent ⇒ images extract to empty text (today's NoText behavior). - _imageDescriber = imageDescriber; - _options = options ?? new ContentIndexingOptions(); - _logger = logger ?? NullLogger.Instance; - } - - /// - /// Indexes one file. Computes the file's SHA-256, checks the hash gate (skips unchanged files - /// without re-extracting or re-embedding), then — on change — extracts text, chunks it, embeds - /// every chunk (bounded merge), and replaces the file's chunks in the store. Cold: subscribe to - /// run. Emits exactly one . - /// - /// - /// When true the hash gate is bypassed: the file is always re-extracted, re-chunked, re-embedded and - /// its chunks replaced, even if its content is unchanged. This is how a reindex backfills new - /// per-chunk provenance (page/position) onto files that were indexed before it existed — the content - /// is identical, so the plain hash gate would skip them forever. - /// - public IObservable IndexFile( - string collectionPath, string filePath, string fileName, byte[] bytes, bool force = false) - { - var hash = ComputeSha256(bytes); - - if (force) - return IndexChanged(collectionPath, filePath, fileName, bytes, hash); - - return _store.GetFileHash(collectionPath, filePath) - .Take(1) - .SelectMany(storedHash => - { - if (string.Equals(storedHash, hash, StringComparison.Ordinal)) - { - _logger.LogDebug( - "Hash gate: '{FilePath}' in '{Collection}' unchanged ({Hash}); skipping.", - filePath, collectionPath, hash); - // Chunks are up to date — but the per-file Document may still be MISSING - // (chunks indexed before the document branch was wired, or the Document write - // failed). A search hit for such a file resolves to a Document node that does - // not exist → the click lands on Not Found, and the gate would skip it forever. - // Heal: when the document branch is wired and no Document exists, run - // extract + summarize + sink for THIS file only (the chunk store is untouched). - return EnsureDocument(collectionPath, filePath, fileName, bytes, hash) - .Select(_ => IndexResult.Skipped); - } - - return IndexChanged(collectionPath, filePath, fileName, bytes, hash); - }); - } - - /// - /// The hash-gate HEAL: when the document branch is wired but no Document exists for the - /// (unchanged) file, extracts the text again and runs the summarize + sink branch, reporting - /// the stored chunk count. No-op (single ) when the branch isn't wired, the - /// Document already exists, or the file has no extractable text. - /// - private IObservable EnsureDocument( - string collectionPath, string filePath, string fileName, byte[] bytes, string hash) - { - // Nothing to heal without a sink; a summary can still come from the summarizer OR an image - // describer, so do NOT gate on _summarizer here (WriteDocumentBranch decides). - if (_documentSink is null) - return Observable.Return(Unit.Default); - - return _documentSink.DocumentExists(collectionPath, filePath) - .Take(1) - .SelectMany(exists => - { - if (exists) - return Observable.Return(Unit.Default); - - _logger.LogInformation( - "Hash gate: '{FilePath}' in '{Collection}' unchanged but its Document is missing; healing.", - filePath, collectionPath); - return _store.GetChunkCount(collectionPath, filePath) - .Take(1) - .SelectMany(chunkCount => ExtractIndexable(fileName, bytes) - .SelectMany(extracted => string.IsNullOrWhiteSpace(extracted.Document.Text) - ? Observable.Return(Unit.Default) - : WriteDocumentBranch( - collectionPath, filePath, fileName, extracted.Document.Text, bytes, hash, chunkCount, - extracted.SummaryOverride))); - }); - } - - private IObservable IndexChanged( - string collectionPath, string filePath, string fileName, byte[] bytes, string hash) => - ExtractIndexable(fileName, bytes) - .SelectMany(extracted => - { - var (document, summaryOverride) = extracted; - var chunks = TextChunker.ChunkPositioned(document, _options.ChunkSize, _options.ChunkOverlap); - if (chunks.Count == 0) - { - _logger.LogInformation( - "No text extracted from '{FilePath}' in '{Collection}'; recording NoText.", - filePath, collectionPath); - // Replace with an empty set so any stale chunks for this file are removed and - // the (now absent) hash forces a re-attempt if the file later gains content. - return _store.ReplaceFileChunks(collectionPath, filePath, []) - .Select(_ => IndexResult.NoText); - } - - // Chunk branch: embed every chunk (carrying its page/position provenance), replace the - // file's stored chunk set, and report how many were stored. - var chunkBranch = EmbedChunks(collectionPath, filePath, hash, chunks) - .SelectMany(embedded => - _store.ReplaceFileChunks(collectionPath, filePath, embedded) - .Select(_ => embedded.Count)); - - // Document branch: ONE summarize per document (never per chunk), then write the - // per-file Document via the sink. Only runs when BOTH summarizer + sink are wired; - // otherwise it's a no-op that emits a single Unit so the Zip below still completes - // — giving exactly today's behavior when either is absent. The summarize call is - // its own IIoPool leaf inside the ISummarizer impl; the orchestration holds no slot. - // For an image, summaryOverride carries the vision description → used verbatim as the - // summary (the describe call already happened once in ExtractIndexable). - return chunkBranch.SelectMany(storedCount => - WriteDocumentBranch(collectionPath, filePath, fileName, document.Text, bytes, hash, storedCount, summaryOverride) - .Select(_ => IndexResult.Indexed(storedCount))); - }); - - /// - /// Produces the file's indexable document plus an optional summary override, dispatched by - /// extension: a normal file goes through (no override — - /// the summarizer produces the Document summary); an IMAGE (a describer is wired and its extension - /// matches) goes through , whose description is wrapped as positionless - /// text and is BOTH the indexable content AND the summary (ONE model call, no re-summarize). With no - /// describer, images fall to the extractor → empty → NoText, exactly today's behavior. - /// - private IObservable<(ExtractedDocument Document, string? SummaryOverride)> ExtractIndexable( - string fileName, byte[] bytes) - { - var ext = System.IO.Path.GetExtension(fileName).ToLowerInvariant(); - if (_imageDescriber is not null && _imageDescriber.SupportedExtensions.Contains(ext)) - return _imageDescriber.Describe(bytes, fileName) - .Take(1) - .Select(description => ( - ExtractedDocument.PlainText(description ?? string.Empty), - string.IsNullOrWhiteSpace(description) ? (string?)null : description)); - - return _extractor.ExtractDocument(fileName, bytes) - .Take(1) - .Select(document => (document, (string?)null)); - } - - /// - /// Writes the per-file through the sink, deriving the summary from - /// when present (e.g. an image's vision description, used - /// verbatim) or otherwise from the condensing the extracted text. A - /// no-op emitting a single when there is no sink, or no way to produce a summary - /// (no override and no summarizer) — so the caller's composition completes identically to the - /// original (chunk-only) flow when the document branch isn't wired. - /// - private IObservable WriteDocumentBranch( - string collectionPath, string filePath, string fileName, string text, byte[] bytes, - string hash, int chunkCount, string? summaryOverride = null) - { - if (_documentSink is null) - return Observable.Return(Unit.Default); - - // Summary source: an override (image description) is used verbatim; otherwise the summarizer - // condenses the extracted text. With neither, there is nothing to write. - IObservable summarySource; - if (summaryOverride is not null) - summarySource = Observable.Return(summaryOverride); - else if (_summarizer is not null) - summarySource = _summarizer.Summarize(text, fileName).Take(1); - else - return Observable.Return(Unit.Default); - - var documentSink = _documentSink; - return summarySource - .SelectMany(summary => documentSink.WriteDocument(new DocumentInfo( - CollectionPath: collectionPath, - FilePath: filePath, - FileName: fileName, - Summary: summary, - ContentHash: hash, - Mime: null, - SizeBytes: bytes.LongLength, - ChunkCount: chunkCount))); - } - - /// - /// Embeds every chunk's text via the embedder, bounded-merged (NOT a held-slot serial loop), and - /// reassembles them back into s in their original order — each carrying its - /// page/position provenance from the . - /// - private IObservable> EmbedChunks( - string collectionPath, string filePath, string hash, IReadOnlyList chunks) => - chunks - // Pair each chunk with its index up front so order survives the unordered merge. - .Select((chunk, index) => - _embedder.Embed(chunk.Text) - .Take(1) - .Select(embedding => new ContentChunk( - CollectionPath: collectionPath, - FilePath: filePath, - ChunkIndex: index, - Text: chunk.Text, - ContentHash: hash, - Embedding: embedding, - Page: chunk.Page, - Position: chunk.Position))) - .ToObservable() - // Each Embed leaf acquires its OWN pool slot; this merge just bounds how many we - // subscribe to at once. The orchestration holds no slot while they run. - .Merge(Math.Max(1, _options.MaxConcurrentEmbeddings)) - .ToList() - .Select(chunks => (IReadOnlyList)chunks - .OrderBy(c => c.ChunkIndex) - .ToArray()); - - private static string ComputeSha256(byte[] bytes) - { - var digest = SHA256.HashData(bytes); - return Convert.ToHexStringLower(digest); - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/Document.cs b/src/MeshWeaver.ContentCollections.Indexing/Document.cs deleted file mode 100644 index 5f2d058e6d..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/Document.cs +++ /dev/null @@ -1,48 +0,0 @@ -using MeshWeaver.Domain; -using MeshWeaver.Mesh; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Content of the framework-generic Document NodeType: one indexed source file's -/// AI summary plus its indexing metadata. A real (in a hosting -/// project) creates/updates a mesh node with this as its Content, mapping -/// to the node's Name. -/// -/// -/// TODO (hosting): the actual Document NodeType definition (registration via -/// AddMeshNodes/a NodeType class) and the real that writes it -/// through workspace.GetMeshNodeStream(path).Update(...) land in a hosting project later — -/// they are intentionally out of scope for the storage-agnostic indexing core. This record only -/// fixes the content shape so the sink and the future NodeType agree. -/// -public sealed record Document -{ - /// The document title (the source file name). Maps to the mesh node's Name. - [MeshNodeProperty(nameof(MeshNode.Name))] - public string Name { get; init; } = string.Empty; - - /// The AI-produced summary of the document's text (the node body). - public string Summary { get; init; } = string.Empty; - - /// Path of the owning content collection. - public string CollectionPath { get; init; } = string.Empty; - - /// Path of the source file within the collection (the source reference). - public string FilePath { get; init; } = string.Empty; - - /// Optional content-type of the source file. - public string? Mime { get; init; } - - /// Size of the source file in bytes. - public long SizeBytes { get; init; } - - /// SHA-256 (hex) of the whole source file's bytes — the value the hash gate compares. - public string ContentHash { get; init; } = string.Empty; - - /// Number of chunks the file was split into and stored. - public int ChunkCount { get; init; } - - /// When the file was indexed (this Document last written). - public DateTimeOffset IndexedAt { get; init; } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/DocumentInfo.cs b/src/MeshWeaver.ContentCollections.Indexing/DocumentInfo.cs deleted file mode 100644 index 20fbd8af1a..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/DocumentInfo.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// The per-file record handed to an after a changed file has been -/// extracted, summarized and chunked. Immutable; carries everything a Document mesh-node -/// instance needs (title = , the AI , the source path -/// and the indexing metadata). -/// -/// Path of the owning content collection. -/// Path of the source file within the collection (the source reference). -/// Display name / title of the file (the document's Name). -/// The AI-produced summary of the document's text. -/// SHA-256 (hex) of the whole source file's bytes — the same hash the gate compares. -/// Optional content-type of the source file, when the host knows it. -/// Size of the source file in bytes. -/// Number of chunks the file was split into and stored. -public sealed record DocumentInfo( - string CollectionPath, - string FilePath, - string FileName, - string Summary, - string ContentHash, - string? Mime, - long SizeBytes, - int ChunkCount); diff --git a/src/MeshWeaver.ContentCollections.Indexing/ExtractedDocument.cs b/src/MeshWeaver.ContentCollections.Indexing/ExtractedDocument.cs deleted file mode 100644 index 8b7eea0edb..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ExtractedDocument.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Immutable; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// A run of extracted text that maps a character range in to the -/// source it came from and its on that page. For a PDF, one span per -/// extracted word — so any character window (a chunk) resolves to the page(s) and box(es) it overlaps. -/// -/// Zero-based start offset of the run within . -/// Length of the run in characters. -/// One-based source page number the run was extracted from. -/// The run's normalized bounding box on . -public sealed record PositionedSpan(int Start, int Length, int Page, ChunkPosition Box) -{ - /// The exclusive end offset ( + ). - public int End => Start + Length; -} - -/// -/// The result of extracting a content file: the plain that gets chunked and embedded, -/// plus optional positional that pin ranges of that text back to their source page and -/// on-page box. is empty for formats with no layout (txt/markdown/docx) — those chunks -/// carry no page/position. For PDFs it is dense (one span per word), so -/// can attribute each chunk to a page + region. -/// -/// The extracted document text (unchanged in role — this is what is chunked/embedded). -/// -/// Positional spans over , ordered by . Empty when the -/// source format carries no layout. -/// -public sealed record ExtractedDocument(string Text, ImmutableArray Spans) -{ - /// An empty extraction (no text, no spans) — the result for unknown/binary/empty files. - public static readonly ExtractedDocument Empty = new(string.Empty, ImmutableArray.Empty); - - /// Wraps plain text with no positional information (the txt/markdown/docx case). - public static ExtractedDocument PlainText(string text) => - string.IsNullOrEmpty(text) ? Empty : new(text, ImmutableArray.Empty); -} - -/// -/// One chunk produced by : the chunk plus the -/// source it begins on and its (box) on that page. -/// and are null when the source carries no layout (txt/markdown/docx). -/// -/// The chunk text (a character window of the extracted document text). -/// One-based page the chunk begins on, or null when unknown. -/// The chunk's normalized box on , or null when unknown. -public sealed record PositionedChunk(string Text, int? Page, ChunkPosition? Position); diff --git a/src/MeshWeaver.ContentCollections.Indexing/ExtractiveSummarizer.cs b/src/MeshWeaver.ContentCollections.Indexing/ExtractiveSummarizer.cs deleted file mode 100644 index e1aab4eaf6..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ExtractiveSummarizer.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Reactive.Linq; -using System.Text; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// A dependency-free that derives a short summary from the document text -/// itself — no AI/LLM call, no external dependency, no cost. It takes the leading prose (the first -/// non-blank lines, with markdown heading/list markers stripped) collapsed to a single line and -/// length-bounded. -/// -/// This is the sensible DEFAULT summary for a host that has not wired a chat model: the -/// per-file Document node still gets a meaningful, human-readable summary. Swap in -/// ChatClientSummarizer (via the chat-client overloads of the pipeline/document -/// extensions) for AI-generated summaries — the rest of the pipeline is identical. -/// -public sealed class ExtractiveSummarizer : ISummarizer -{ - private const int MaxChars = 280; - - /// - public IObservable Summarize(string text, string fileName) => - Observable.Return(Extract(text)); - - /// - /// Leading prose of , one line, ≤ chars. Markdown - /// heading markers (#), block-quote (>) and list bullets (-/*) are - /// stripped from each line so the summary reads as prose rather than raw markup. - /// - private static string Extract(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return string.Empty; - - var sb = new StringBuilder(MaxChars + 16); - foreach (var raw in text.Split('\n')) - { - var line = raw.Trim().TrimStart('#', '>', '-', '*', ' ', '\t').Trim(); - if (line.Length == 0) - continue; - if (sb.Length > 0) - sb.Append(' '); - sb.Append(line); - if (sb.Length >= MaxChars) - break; - } - - // Collapse any internal whitespace runs introduced by the line joins. - var oneLine = string.Join(' ', sb.ToString().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); - return oneLine.Length <= MaxChars ? oneLine : oneLine[..MaxChars].TrimEnd() + "…"; - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/IChunkEmbedder.cs b/src/MeshWeaver.ContentCollections.Indexing/IChunkEmbedder.cs deleted file mode 100644 index 34a7bd6e54..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/IChunkEmbedder.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Small embedding abstraction owned by the indexing core, so it does not take a heavy -/// dependency on any concrete embedding stack. A thin adapter from the framework's -/// IEmbeddingProvider (MeshWeaver.Hosting.PostgreSql) can layer on top later without -/// changing this contract; tests supply a deterministic fake. -/// -public interface IChunkEmbedder -{ - /// - /// Embeds into a vector of length . Reactive: - /// the genuine network/compute leaf is expected to run through an IIoPool internally and - /// emit a single vector. Composed (never awaited) by . - /// - IObservable Embed(string text); - - /// Dimensionality of every vector this embedder produces. - int Dimensions { get; } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/IChunkedContentVectorStore.cs b/src/MeshWeaver.ContentCollections.Indexing/IChunkedContentVectorStore.cs deleted file mode 100644 index 9a562ab176..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/IChunkedContentVectorStore.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Reactive; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Storage-agnostic sink + query surface for indexed content chunks. The in-memory -/// implementation backs the unit-tested core; a Postgres/pgvector adapter layers on this exact -/// contract later without changing the orchestration. -/// -public interface IChunkedContentVectorStore -{ - /// - /// Returns the content hash recorded for the last time it was - /// indexed, or null if the file has never been indexed. The hash gate compares this to the - /// current file hash to skip unchanged files. - /// - IObservable GetFileHash(string collectionPath, string filePath); - - /// - /// Replaces ALL chunks for with - /// (delete-then-insert) and records the file's content hash for the next hash-gate check. - /// Idempotent: re-running with the same chunks leaves the store in the same state. - /// - IObservable ReplaceFileChunks( - string collectionPath, string filePath, IReadOnlyList chunks); - - /// - /// Returns the chunks in most - /// similar to by cosine similarity, most-similar first. - /// - IObservable> Search(string collectionPath, float[] query, int topK); - - /// - /// Like , but matches a collection-path SUBTREE: the chunks of the collection - /// at AND of every collection nested under it - /// (collection_path = prefix OR collection_path starts with prefix/), ranked - /// together by cosine similarity, most-similar first. This is the scope:subtree content-search - /// resolution — restrict to one collection (and anything below it) rather than walking UP to ancestor - /// collections the way the chunk-navigation tools' default ancestor-prefix scope does. - /// - IObservable> SearchSubtree( - string collectionPathPrefix, float[] query, int topK); - - /// - /// Returns the single chunk at (zero-based) of - /// within , or null if no chunk - /// exists at that index (out of range, or the file has never been indexed). This is the - /// read-by-index counterpart to — it resolves a known - /// (collection, file, index) position, which the chunk-navigation tools use to step - /// prev/next through a file's chunk sequence. - /// - IObservable GetChunk(string collectionPath, string filePath, int chunkIndex); - - /// - /// Returns the number of chunks recorded for within - /// (0 if the file has never been indexed). Callers use this - /// to bound an index and decide whether a next/prev step is valid. - /// - IObservable GetChunkCount(string collectionPath, string filePath); -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/IDocumentSink.cs b/src/MeshWeaver.ContentCollections.Indexing/IDocumentSink.cs deleted file mode 100644 index af96f9c4c6..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/IDocumentSink.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Reactive; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Sink for the per-file produced when a changed file is indexed. -/// A thin abstraction owned by the indexing core; the real implementation — which creates or -/// updates a framework-generic Document NodeType instance via the workspace — lives in a -/// hosting project and is OUT of scope here. Tests supply a fake that records the last -/// written. -/// -/// -/// Reactive contract (no async/await/Task): the real implementation will -/// create/update a Document mesh node (Name = , body = -/// the , plus the source path and metadata — -/// , , -/// , ) through -/// workspace.GetMeshNodeStream(path).Update(...) (or CreateOrUpdateNodeRequest for a -/// new node), and is therefore composed — never awaited. Cold: subscribe to run. Emits one -/// on success then completes; errors surface via OnError. -/// -public interface IDocumentSink -{ - /// - /// Creates or updates the Document node for . Cold observable — - /// the write runs on subscribe. Composed (never awaited) by . - /// - IObservable WriteDocument(DocumentInfo doc); - - /// - /// Whether a Document already exists for (collectionPath, filePath). Drives the - /// hash-gate HEAL in : a file whose chunks are - /// up to date may still be missing its per-file Document (indexed before the document branch - /// was wired, or its write failed) — the gate skips re-chunking but must not skip a missing - /// Document forever. Cold; emits one value then completes. Indeterminate probes should lean - /// towards false: is an idempotent upsert, so a spurious - /// heal is harmless while a spurious "exists" wedges the missing Document permanently. - /// - IObservable DocumentExists(string collectionPath, string filePath); -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/IImageDescriber.cs b/src/MeshWeaver.ContentCollections.Indexing/IImageDescriber.cs deleted file mode 100644 index d5a01844af..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/IImageDescriber.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Produces a natural-language description of an IMAGE file — the vision counterpart to -/// . An image carries no extractable text, so without a describer it -/// indexes to nothing; with one, its description becomes the file's indexable content (chunked + -/// embedded for search) AND its Document summary. Owned by the storage-agnostic indexing core -/// as a thin abstraction so the core takes NO heavy AI dependency — the real, multimodal -/// IChatClient-backed implementation lives in a hosting/AI project; tests supply a fake. -/// -/// -/// Reactive contract (no async/await/Task): the real implementation wraps a single -/// multimodal chat-completion call through IIoPool.Invoke(...) — ONE I/O-pool leaf that takes -/// its OWN slot. composes it with .SelectMany and never -/// awaits it. Called at most ONCE per image. Emits a single description string (empty when the model -/// is unavailable — the file then indexes as no-text, exactly today's behavior) then completes. -/// -public interface IImageDescriber -{ - /// - /// File extensions this describer handles (e.g. .png, .jpg). The indexing service - /// routes a file to the describer instead of the text extractor when its extension is in this set. - /// - IReadOnlyCollection SupportedExtensions { get; } - - /// - /// Describes the image in . is a prompt - /// hint (name / format cue). Emits exactly one description then completes; emits empty when no - /// vision model is available so the caller degrades to no-text. - /// - IObservable Describe(byte[] imageBytes, string fileName); -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ISummarizer.cs b/src/MeshWeaver.ContentCollections.Indexing/ISummarizer.cs deleted file mode 100644 index 273b618dc5..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ISummarizer.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Produces a short natural-language summary of a document's extracted text. Owned by the -/// indexing core as a thin abstraction so the core takes NO heavy AI dependency — the real, -/// IChatClient-backed implementation lives in a hosting/AI project and is out of scope -/// here; tests supply a deterministic fake. -/// -/// -/// Reactive contract (no async/await/Task): the real implementation wraps a -/// single IChatClient chat-completion call through IIoPool.Invoke(ct => chat...(ct)) -/// — the summarize call is ONE I/O-pool leaf that takes its OWN slot. The orchestration in -/// holds no slot while it runs; it composes this observable -/// with .SelectMany and never awaits it. Emits a single summary string then completes. -/// -public interface ISummarizer -{ - /// - /// Summarizes (the file's extracted document text). - /// is supplied as a prompt hint (title / format cue). Emits exactly one summary string then - /// completes. Composed (never awaited) by ; called at most - /// ONCE per document, never per chunk. - /// - IObservable Summarize(string text, string fileName); -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/ITextExtractor.cs b/src/MeshWeaver.ContentCollections.Indexing/ITextExtractor.cs deleted file mode 100644 index a65e790894..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/ITextExtractor.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Reactive.Linq; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Extracts plain text from a content file's raw bytes, dispatched by file extension. -/// Reactive: the decode/parse work is a CPU leaf run through an IIoPool, so the public -/// surface returns and never Task. -/// -public interface ITextExtractor -{ - /// - /// Extracts the document text from , choosing the parser from - /// 's extension. Emits a single string (empty for unknown/binary - /// formats) then completes. - /// - IObservable ExtractText(string fileName, byte[] bytes); - - /// - /// Extracts the document text together with positional spans (page + on-page box per run), - /// so chunks can carry page/position provenance. For formats with a text layout (PDF) the result's - /// is dense; for plain formats it is empty and this degrades to - /// . Default implementation wraps as plain text, so - /// an extractor with no layout information (or a test double) need not override it. - /// - IObservable ExtractDocument(string fileName, byte[] bytes) => - ExtractText(fileName, bytes).Select(ExtractedDocument.PlainText); -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/InMemoryChunkedContentVectorStore.cs b/src/MeshWeaver.ContentCollections.Indexing/InMemoryChunkedContentVectorStore.cs deleted file mode 100644 index ab308e6811..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/InMemoryChunkedContentVectorStore.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Immutable; -using System.Reactive; -using System.Reactive.Linq; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// In-memory for the unit-tested core. State lives in -/// INSTANCE fields (never static — process-wide -/// static state bleeds across tests). Reactive throughout: every method returns a cold observable -/// that performs its work on subscribe. -/// -public sealed class InMemoryChunkedContentVectorStore : IChunkedContentVectorStore -{ - // (collectionPath, filePath) -> the file's recorded content hash. - private readonly ConcurrentDictionary<(string Collection, string File), string> _fileHashes = new(); - - // (collectionPath, filePath) -> the file's ordered chunks (the whole set, replaced atomically). - private readonly ConcurrentDictionary<(string Collection, string File), ImmutableArray> _chunks = new(); - - /// Returns the recorded content hash for a file, or null when the file has none. - /// The collection the file belongs to. - /// The file's path within the collection. - /// A cold observable emitting the stored hash, or null if the file is not recorded. - public IObservable GetFileHash(string collectionPath, string filePath) => - Observable.Defer(() => - Observable.Return( - _fileHashes.TryGetValue((collectionPath, filePath), out var hash) ? hash : null)); - - /// - /// Atomically replaces all stored chunks for a file with the supplied set and updates the file's - /// recorded hash (an empty set clears both the chunks and the hash). - /// - /// The collection the file belongs to. - /// The file's path within the collection. - /// The new ordered chunk set; pass an empty list to remove the file's chunks. - /// A cold observable that performs the replacement on subscribe and emits a single . - public IObservable ReplaceFileChunks( - string collectionPath, string filePath, IReadOnlyList chunks) => - Observable.Defer(() => - { - var key = (collectionPath, filePath); - var snapshot = chunks as ContentChunk[] ?? chunks.ToArray(); - - // Delete-then-insert: the new set wholly replaces any prior chunks for this file. - _chunks[key] = ImmutableArray.Create(snapshot); - - // Record the file hash so the next GetFileHash hits the gate. Every chunk of a file - // carries the same whole-file ContentHash; take the first (empty set clears the hash). - if (snapshot.Length > 0) - _fileHashes[key] = snapshot[0].ContentHash; - else - _fileHashes.TryRemove(key, out _); - - return Observable.Return(Unit.Default); - }); - - /// - /// Ranks the collection's chunks by cosine similarity to the query vector and returns the top matches. - /// - /// The collection to search within. - /// The query embedding vector. - /// The maximum number of chunks to return, highest similarity first. - /// A cold observable emitting the ranked chunks (at most ). - public IObservable> Search( - string collectionPath, float[] query, int topK) => - Observable.Defer(() => - { - var ranked = _chunks - .Where(kvp => kvp.Key.Collection == collectionPath) - .SelectMany(kvp => kvp.Value) - .Where(chunk => chunk.Embedding is { Length: > 0 }) - .Select(chunk => (chunk, score: CosineSimilarity(query, chunk.Embedding!))) - .OrderByDescending(x => x.score) - .Take(topK) - .Select(x => x.chunk) - .ToArray(); - - return Observable.Return>(ranked); - }); - - /// - /// Ranks the chunks of the collection at AND of every - /// collection nested under it by cosine similarity, returning the top matches. The subtree match is - /// exact-collection OR collection-path that starts with prefix/ — the scope:subtree - /// resolution. - /// - /// The collection (and subtree root) to search within. - /// The query embedding vector. - /// The maximum number of chunks to return, highest similarity first. - /// A cold observable emitting the ranked chunks (at most ). - public IObservable> SearchSubtree( - string collectionPathPrefix, float[] query, int topK) => - Observable.Defer(() => - { - var childPrefix = collectionPathPrefix + "/"; - var ranked = _chunks - .Where(kvp => kvp.Key.Collection == collectionPathPrefix - || kvp.Key.Collection.StartsWith(childPrefix, StringComparison.Ordinal)) - .SelectMany(kvp => kvp.Value) - .Where(chunk => chunk.Embedding is { Length: > 0 }) - .Select(chunk => (chunk, score: CosineSimilarity(query, chunk.Embedding!))) - .OrderByDescending(x => x.score) - .Take(topK) - .Select(x => x.chunk) - .ToArray(); - - return Observable.Return>(ranked); - }); - - /// Returns a single chunk of a file by its chunk index, or null when not found. - /// The collection the file belongs to. - /// The file's path within the collection. - /// The zero-based index of the chunk to retrieve. - /// A cold observable emitting the matching , or null if absent. - public IObservable GetChunk(string collectionPath, string filePath, int chunkIndex) => - Observable.Defer(() => - { - // The chunk set is stored ordered by ChunkIndex (it is built that way), but read it by - // ChunkIndex rather than by position so a non-contiguous set can never mis-resolve. - ContentChunk? chunk = null; - if (chunkIndex >= 0 - && _chunks.TryGetValue((collectionPath, filePath), out var chunks)) - chunk = chunks.FirstOrDefault(c => c.ChunkIndex == chunkIndex); - return Observable.Return(chunk); - }); - - /// Returns the number of chunks stored for a file (0 when the file has none). - /// The collection the file belongs to. - /// The file's path within the collection. - /// A cold observable emitting the stored chunk count for the file. - public IObservable GetChunkCount(string collectionPath, string filePath) => - Observable.Defer(() => - Observable.Return( - _chunks.TryGetValue((collectionPath, filePath), out var chunks) ? chunks.Length : 0)); - - private static double CosineSimilarity(float[] a, float[] b) - { - if (a.Length != b.Length || a.Length == 0) - return 0d; - - double dot = 0d, normA = 0d, normB = 0d; - for (var i = 0; i < a.Length; i++) - { - dot += a[i] * (double)b[i]; - normA += a[i] * (double)a[i]; - normB += b[i] * (double)b[i]; - } - - if (normA == 0d || normB == 0d) - return 0d; - - return dot / (Math.Sqrt(normA) * Math.Sqrt(normB)); - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/InertContentIndex.cs b/src/MeshWeaver.ContentCollections.Indexing/InertContentIndex.cs deleted file mode 100644 index aef8a956de..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/InertContentIndex.cs +++ /dev/null @@ -1,144 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Reactive; -using System.Reactive.Linq; -using Microsoft.Extensions.DependencyInjection; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Marks the stand-ins the content-indexing pipeline registers when the deployment is NOT configured -/// for indexing (no vector store connection, no embedding provider). Every consumer of the indexing -/// services tests for this state and answers "the capability is off" — it must never surface as an -/// exception. -/// -/// Why a stand-in and not "no registration". Whether the deployment is configured can -/// only be decided at RESOLVE time (a boot-loaded module has no IConfiguration when it folds -/// in), so the services ARE registered and the gate lives in the factory. The obvious shape — a -/// factory returning null so GetService answers null — does not work on the container -/// MeshWeaver actually runs: Autofac REFUSES a delegate registration that returns null and throws -/// "An exception was thrown while activating λ:T" out of GetService. An inert INSTANCE -/// is therefore the only shape in which "registered but switched off" can be resolved at all. -/// -public interface IInertContentIndex -{ - /// - /// Why content indexing is off on this deployment, phrased for a tool caller or a page — it is - /// carried into the search_chunks envelope's message, so it must name what to - /// configure rather than merely stating that nothing was found. - /// - string Reason { get; } -} - -/// -/// The switched-off : every read answers the empty result and -/// every write is dropped, so nothing throws and nothing is silently half-indexed. Resolved only via -/// , which maps it back to null for the -/// callers that branch on "no store". -/// -public sealed class InertChunkedContentVectorStore(string reason) - : IChunkedContentVectorStore, IInertContentIndex -{ - /// - public string Reason { get; } = reason; - - /// - public IObservable GetFileHash(string collectionPath, string filePath) => - Observable.Return(null); - - /// - public IObservable ReplaceFileChunks( - string collectionPath, string filePath, IReadOnlyList chunks) => - Observable.Return(Unit.Default); - - /// - public IObservable> Search(string collectionPath, float[] query, int topK) => - Observable.Return>(Array.Empty()); - - /// - public IObservable> SearchSubtree( - string collectionPathPrefix, float[] query, int topK) => - Observable.Return>(Array.Empty()); - - /// - public IObservable GetChunk(string collectionPath, string filePath, int chunkIndex) => - Observable.Return(null); - - /// - public IObservable GetChunkCount(string collectionPath, string filePath) => - Observable.Return(0); -} - -/// -/// The switched-off . It never embeds: every caller resolves it through -/// (null when inert) or tests for -/// first, so reaching would mean a caller skipped -/// the availability check — which must fail loudly rather than return a zero vector that ranks as a -/// silent, meaningless similarity. -/// -public sealed class InertChunkEmbedder(string reason) : IChunkEmbedder, IInertContentIndex -{ - /// - public string Reason { get; } = reason; - - /// - public IObservable Embed(string text) => - Observable.Throw(new InvalidOperationException(Reason)); - - /// - public int Dimensions => 0; -} - -/// -/// Resolves the content-indexing services in the ONE shape every consumer already branches on: -/// a live service, or null when the deployment is not configured for content indexing. -/// -public static class ContentIndexAvailability -{ - /// - /// The fallback message for the "indexing is off" envelope, used when no inert stand-in carried a - /// deployment-specific (e.g. the pipeline was never wired - /// into this host at all). - /// - public const string NotEnabledMessage = - "Content chunk indexing is not enabled in this host — no chunk store is configured."; - - /// - /// The live chunk store, or null when content indexing is off (never wired, or wired but not - /// configured — see ). - /// - public static IChunkedContentVectorStore? GetActiveChunkStore(this IServiceProvider services) => - Active(services.GetService()); - - /// - /// The live chunk embedder, or null when content indexing is off — the counterpart to - /// . - /// - public static IChunkEmbedder? GetActiveChunkEmbedder(this IServiceProvider services) => - Active(services.GetService()); - - /// - /// The reason indexing is off on this host, read from whichever registered service is an inert - /// stand-in — the line a tool envelope or a page shows instead of "no results". - /// - public static string ReasonOff(this IServiceProvider services) => - ReasonOff(services.GetService(), services.GetService()); - - /// The reason indexing is off, from whichever of the two services is an inert stand-in. - public static string ReasonOff(IChunkedContentVectorStore? store, IChunkEmbedder? embedder) => - (store as IInertContentIndex)?.Reason - ?? (embedder as IInertContentIndex)?.Reason - ?? NotEnabledMessage; - - /// - /// Whether the pair can actually answer a search: false only when both are present and neither is an - /// inert stand-in — which is exactly when the caller may dereference them, hence the - /// pair. - /// - public static bool IsOff( - [NotNullWhen(false)] IChunkedContentVectorStore? store, - [NotNullWhen(false)] IChunkEmbedder? embedder) => - store is null or IInertContentIndex || embedder is null or IInertContentIndex; - - private static T? Active(T? service) where T : class => - service is IInertContentIndex ? null : service; -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/MeshWeaver.ContentCollections.Indexing.csproj b/src/MeshWeaver.ContentCollections.Indexing/MeshWeaver.ContentCollections.Indexing.csproj deleted file mode 100644 index 5a431ec04b..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/MeshWeaver.ContentCollections.Indexing.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - Content-to-vector indexing pipeline for MeshWeaver: chunking, document tracking, and chunk-search abstractions, storage- and embedding-agnostic. - - - - - - - - - - - - - diff --git a/src/MeshWeaver.ContentCollections.Indexing/README.md b/src/MeshWeaver.ContentCollections.Indexing/README.md deleted file mode 100644 index 14c4379ab1..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# MeshWeaver.ContentCollections.Indexing - -Core content-to-vector indexing pipeline for MeshWeaver content collections. Splits content -into chunks, tracks per-file `Document` records, and exposes chunk-level search abstractions. -This package is storage- and embedding-agnostic — pair it with -`MeshWeaver.ContentCollections.Indexing.PostgreSql` (pgvector store) and -`MeshWeaver.ContentCollections.Indexing.Graph` (portal integration). - -## Features - -- `ContentIndexingService` — the chunk/embed/store pipeline over content collections -- `ContentChunk` / `ChunkPosition` — chunk model with stable positions into the source file -- `Document` / `DocumentInfo` — per-file index bookkeeping -- `ContentChunkSearch` — search API over indexed chunks -- `ContentIndexingOptions` — chunking and indexing configuration - -## Links - -- [MeshWeaver repository](https://github.com/Systemorph/MeshWeaver) -- [Documentation](https://memex.meshweaver.cloud/Doc) diff --git a/src/MeshWeaver.ContentCollections.Indexing/TextChunker.cs b/src/MeshWeaver.ContentCollections.Indexing/TextChunker.cs deleted file mode 100644 index 642076b808..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/TextChunker.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Immutable; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Pure, deterministic character-window chunker. No I/O — it only slices a string, so it is -/// CPU-only and may be invoked inside the extractor's InvokeBlocking leaf or on its own. -/// -public static class TextChunker -{ - /// A half-open character window [Start, Start+Length) of the source text. - private readonly record struct TextWindow(int Start, int Length) - { - public int End => Start + Length; - } - - /// - /// Splits into fixed-size character windows that overlap by - /// characters. Deterministic for given inputs. - /// - /// The text to chunk. Null/empty produces no chunks. - /// Window size in characters. Must be positive. - /// - /// Characters shared between consecutive windows. Must be in [0, size) so the window - /// always advances; values outside that range are clamped. - /// - /// - /// The ordered list of chunk texts. Text shorter than one window yields a single chunk; - /// empty/whitespace-only or null text yields an empty list. - /// - public static IReadOnlyList Chunk(string? text, int size, int overlap) - { - if (size < 1) - throw new ArgumentOutOfRangeException(nameof(size), size, "Chunk size must be positive."); - - if (string.IsNullOrEmpty(text)) - return ImmutableArray.Empty; - - var builder = ImmutableArray.CreateBuilder(); - foreach (var window in Windows(text!.Length, size, overlap)) - builder.Add(text.Substring(window.Start, window.Length)); - return builder.ToImmutable(); - } - - /// - /// Chunks the extracted document into the same character windows as , but attributes - /// each chunk to its source page and on-page box using the document's positional spans. - /// A chunk is attributed to the page where it begins (the first overlapping span's page), and its - /// is the union of every span on that page inside the window — so - /// the box covers exactly the chunk's content on its starting page, even across a line break. When the - /// document has no spans (txt/markdown/docx) every chunk carries a null page/position. - /// - /// The extracted text + positional spans (from ). - /// Window size in characters. Must be positive. - /// Characters shared between consecutive windows; clamped into [0, size). - /// The ordered positioned chunks (empty when the document text is empty). - public static IReadOnlyList ChunkPositioned(ExtractedDocument document, int size, int overlap) - { - ArgumentNullException.ThrowIfNull(document); - if (size < 1) - throw new ArgumentOutOfRangeException(nameof(size), size, "Chunk size must be positive."); - - var text = document.Text; - if (string.IsNullOrEmpty(text)) - return ImmutableArray.Empty; - - var spans = document.Spans; - var builder = ImmutableArray.CreateBuilder(); - - // Spans are ordered by Start and windows advance monotonically, so a single cursor over the spans - // suffices — advance it past every span that ends at/before the current window's start (those can - // never overlap this or any later window). - var cursor = 0; - foreach (var window in Windows(text.Length, size, overlap)) - { - while (cursor < spans.Length && spans[cursor].End <= window.Start) - cursor++; - - int? page = null; - ChunkPosition? position = null; - for (var i = cursor; i < spans.Length && spans[i].Start < window.End; i++) - { - var span = spans[i]; - if (span.End <= window.Start) - continue; // fully before the window (defensive; cursor should have skipped it) - if (page is null) - { - page = span.Page; - position = span.Box; - } - else if (span.Page == page) - { - position = position!.Union(span.Box); - } - // Spans on a page after the chunk's start page are left out of the box — the chunk is - // attributed to where it begins. - } - - builder.Add(new PositionedChunk(text.Substring(window.Start, window.Length), page, position)); - } - - return builder.ToImmutable(); - } - - /// - /// The ordered character windows for a text of : fixed - /// windows advancing by size - overlap, the last one truncated to the text end. Shared by both - /// and so the two never drift. - /// - private static IEnumerable Windows(int textLength, int size, int overlap) - { - if (textLength <= 0) - yield break; - - // Clamp overlap into [0, size-1] so the stride (size - overlap) is always >= 1 — a stride of 0 - // would loop forever. Guard, not band-aid: a pure function must terminate for every input. - if (overlap < 0) - overlap = 0; - if (overlap >= size) - overlap = size - 1; - - // Shorter than one window → exactly one chunk (the whole text). - if (textLength <= size) - { - yield return new TextWindow(0, textLength); - yield break; - } - - var stride = size - overlap; - for (var start = 0; start < textLength; start += stride) - { - var length = Math.Min(size, textLength - start); - yield return new TextWindow(start, length); - - // The final window reaches the end of the text — stop so we never emit a trailing duplicate - // sub-window covering only the overlap tail. - if (start + length >= textLength) - yield break; - } - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/TextExtractor.cs b/src/MeshWeaver.ContentCollections.Indexing/TextExtractor.cs deleted file mode 100644 index accfc71914..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/TextExtractor.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using DocSharp.Docx; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using UglyToad.PdfPig; - -namespace MeshWeaver.ContentCollections.Indexing; - -/// -/// Default . Dispatches by file extension and runs the actual -/// decode/parse as a CPU leaf through — never on the -/// calling hub scheduler, bounded by the pool. Unknown / binary formats extract to empty (logged). -/// -/// For PDFs, extraction is positional: the text is built word-by-word from PdfPig's laid-out -/// words, and each word becomes a (page + normalized on-page box), so a chunk -/// can be pinned back to the page + region it came from. Other formats extract to plain text (no spans). -/// -public sealed class TextExtractor : ITextExtractor -{ - private readonly IIoPool _ioPool; - private readonly ILogger _logger; - - /// - /// Creates a text extractor. defaults to - /// (ThreadPool offload, no cap) when constructed outside DI, - /// e.g. in tests. - /// - public TextExtractor(IIoPool? ioPool = null, ILogger? logger = null) - { - _ioPool = ioPool ?? IoPool.Unbounded; - _logger = logger ?? NullLogger.Instance; - } - - /// - /// Extracts plain text from the file bytes, dispatching by the file extension and running the - /// CPU-bound parse off the calling scheduler via the I/O pool. Unknown/binary formats yield empty text. - /// - /// The file name; its extension selects the decoder. - /// The raw file content to decode. - /// A cold observable emitting the extracted text (empty for unsupported formats). - public IObservable ExtractText(string fileName, byte[] bytes) - { - var extension = Path.GetExtension(fileName).ToLowerInvariant(); - // The whole parse is a synchronous, CPU-bound leaf — PdfPig, DocSharp and StreamReader all - // block. InvokeBlocking runs it on the limited-concurrency scheduler, off the hub thread. - return _ioPool.InvokeBlocking(_ => Extract(extension, fileName, bytes).Text); - } - - /// - public IObservable ExtractDocument(string fileName, byte[] bytes) - { - var extension = Path.GetExtension(fileName).ToLowerInvariant(); - return _ioPool.InvokeBlocking(_ => Extract(extension, fileName, bytes)); - } - - private ExtractedDocument Extract(string extension, string fileName, byte[] bytes) - { - switch (extension) - { - case ".pdf": - return ExtractPdf(bytes); - case ".docx": - return ExtractedDocument.PlainText(ExtractDocx(bytes)); - case ".md": - case ".markdown": - case ".txt": - case ".csv": - case ".json": - case ".html": - case ".htm": - case ".xml": - return ExtractedDocument.PlainText(DecodeUtf8(bytes)); - default: - _logger.LogInformation( - "No text extractor for extension '{Extension}' (file '{FileName}'); extracting to empty.", - extension, fileName); - return ExtractedDocument.Empty; - } - } - - /// - /// Extracts a PDF word-by-word (PdfPig's laid-out words, in reading order): builds the text as words - /// joined by spaces within a page and newlines between pages, and records one - /// per word (its page + normalized top-left-origin box). So any character window of the resulting text - /// resolves to the page(s) + region(s) it covers. - /// - private static ExtractedDocument ExtractPdf(byte[] bytes) - { - using var document = PdfDocument.Open(bytes); - var sb = new StringBuilder(); - var spans = ImmutableArray.CreateBuilder(); - - foreach (var page in document.GetPages()) - { - var pageWidth = page.Width; - var pageHeight = page.Height; - if (sb.Length > 0) - sb.Append('\n'); - - var first = true; - foreach (var word in page.GetWords()) - { - var text = word.Text; - if (string.IsNullOrEmpty(text)) - continue; - if (!first) - sb.Append(' '); - first = false; - - var start = sb.Length; - sb.Append(text); - - // Skip positioning on degenerate page sizes rather than dividing by zero — the word's - // text is still indexed, just without a resolvable box. - if (pageWidth > 0 && pageHeight > 0) - spans.Add(new PositionedSpan( - start, text.Length, page.Number, NormalizeBox(word.BoundingBox, pageWidth, pageHeight))); - } - } - - return new ExtractedDocument(sb.ToString(), spans.ToImmutable()); - } - - /// - /// Normalizes a PdfPig word bounding box (PDF space: points, origin bottom-left, y up) to a - /// (fractions of the page, origin top-left, y down) so a viewer can overlay - /// the highlight at any render scale. Uses min/max so a rotated/flipped rectangle still yields a - /// positive box, and clamps to [0, 1]. - /// - private static ChunkPosition NormalizeBox(UglyToad.PdfPig.Core.PdfRectangle box, double pageWidth, double pageHeight) - { - var leftPt = Math.Min(box.Left, box.Right); - var rightPt = Math.Max(box.Left, box.Right); - var topPt = Math.Max(box.Top, box.Bottom); // higher PDF y = visual top - var bottomPt = Math.Min(box.Top, box.Bottom); - - var x = Clamp01(leftPt / pageWidth); - var width = Clamp01((rightPt - leftPt) / pageWidth); - var y = Clamp01(1.0 - topPt / pageHeight); // flip to top-left origin - var height = Clamp01((topPt - bottomPt) / pageHeight); - return new ChunkPosition(x, y, width, height); - } - - private static double Clamp01(double value) => value < 0 ? 0 : value > 1 ? 1 : value; - - private static string ExtractDocx(byte[] bytes) - { - using var stream = new MemoryStream(bytes, writable: false); - var converter = new DocxToMarkdownConverter(); - return converter.ConvertToString(stream); - } - - private static string DecodeUtf8(byte[] bytes) - { - using var stream = new MemoryStream(bytes, writable: false); - // detectEncodingFromByteOrderMarks: true honours a BOM if present, else UTF-8. - using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); - return reader.ReadToEnd(); - } -} diff --git a/src/MeshWeaver.ContentCollections.Indexing/TypeForwards.cs b/src/MeshWeaver.ContentCollections.Indexing/TypeForwards.cs deleted file mode 100644 index b8235f269f..0000000000 --- a/src/MeshWeaver.ContentCollections.Indexing/TypeForwards.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Runtime.CompilerServices; - -// DocumentPaths moved to MeshWeaver.Mesh.Contract, keeping its -// MeshWeaver.ContentCollections.Indexing namespace, and this assembly forwards the name so nothing -// that binds it has to change — not source in this repo, not a module already published against -// this assembly. -// -// WHY THE FORWARDER COMES FIRST, AS ITS OWN CHANGE. -// The indexing pipeline is moving to MeshWeaver.Plugins, and Mesh.Operations stays here needing -// exactly this one type — so the type stays and the pipeline goes. That split is CIRCULAR if done -// in one step: the plugins copy of the pipeline needs DocumentPaths from Mesh.Contract (so it wants -// the platform change first), while the platform cannot delete the pipeline until plugins ships it -// (so it wants plugins first). Neither side can be green before the other. -// -// Landing the relocation PLUS this forwarder on its own breaks the cycle. Afterwards both trees -// compile at every point: this assembly still answers for the name, and Mesh.Contract carries the -// definition the plugins copy binds. -[assembly: TypeForwardedTo(typeof(MeshWeaver.ContentCollections.Indexing.DocumentPaths))] diff --git a/src/MeshWeaver.Mesh.Contract/DocumentPaths.cs b/src/MeshWeaver.Mesh.Contract/DocumentPaths.cs index c119266fbd..8ca3a80513 100644 --- a/src/MeshWeaver.Mesh.Contract/DocumentPaths.cs +++ b/src/MeshWeaver.Mesh.Contract/DocumentPaths.cs @@ -3,8 +3,16 @@ namespace MeshWeaver.ContentCollections.Indexing; /// -/// Deterministic, url-safe mesh-node path for a file's Document node. Lives in the -/// storage-agnostic indexing core (no MeshWeaver.Graph dependency) so both the indexing +/// Deterministic, url-safe mesh-node path for a file's Document node. It keeps the +/// MeshWeaver.ContentCollections.Indexing NAMESPACE while living in this assembly: the +/// indexing pipeline moved to MeshWeaver.Plugins as a module, and this is the one type the +/// platform still needs (MeshWeaver.Mesh.Operations resolves document paths). Splitting it out +/// this way is what let the pipeline leave without core taking a dependency on a module. +/// It sits in Mesh.Contract because BOTH remaining consumers — Mesh.Operations here and the +/// indexing pipeline in the plugins repo — already reference it, so the split adds no edge to +/// the graph; putting it in ContentCollections would have given the pipeline a new dependency +/// on Layout and Markdown, which is exactly what its "storage-agnostic core" note rules out. +/// No MeshWeaver.Graph dependency, so both the indexing /// service and the Graph-side IDocumentSink compute the SAME path for a given /// (collectionPath, filePath) pair. /// diff --git a/src/MeshWeaver.Mesh.Operations/MeshWeaver.Mesh.Operations.csproj b/src/MeshWeaver.Mesh.Operations/MeshWeaver.Mesh.Operations.csproj index 1103c633c0..ae214311ec 100644 --- a/src/MeshWeaver.Mesh.Operations/MeshWeaver.Mesh.Operations.csproj +++ b/src/MeshWeaver.Mesh.Operations/MeshWeaver.Mesh.Operations.csproj @@ -7,7 +7,6 @@ - diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientImageDescriberTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientImageDescriberTest.cs deleted file mode 100644 index 2cb8030bad..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientImageDescriberTest.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Reactive.Linq; -using System.Runtime.CompilerServices; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.AI; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// Focused tests for : the description comes from a single -/// MULTIMODAL completion whose message carries the image bytes as a -/// part (correct media type) plus a text prompt; the client is resolved -/// lazily and a null client (or a failing call) degrades to an empty description rather than throwing. -/// -public class ChatClientImageDescriberTest -{ - private static readonly byte[] SampleBytes = [0x89, 0x50, 0x4E, 0x47, 0x01, 0x02, 0x03]; - - [Fact(Timeout = 30_000)] - public async Task Describe_SendsImageAsDataContent_AndReturnsModelText() - { - var chat = new RecordingChatClient("A bar chart of quarterly revenue."); - var describer = new ChatClientImageDescriber(() => chat, new IoPoolRegistry()); - - var description = await describer.Describe(SampleBytes, "chart.jpg").FirstAsync().Await(); - - description.Should().Be("A bar chart of quarterly revenue."); - chat.LastPrompt.Should().Contain("chart.jpg", "the file name is a prompt hint"); - chat.LastImage.Should().NotBeNull("the image is sent as a DataContent part"); - chat.LastImage!.MediaType.Should().Be("image/jpeg", ".jpg maps to image/jpeg"); - chat.LastImage.Data.ToArray().Should().Equal(SampleBytes); - } - - [Fact] - public async Task Describe_NoClient_ReturnsEmpty() - { - var describer = new ChatClientImageDescriber(() => null, new IoPoolRegistry()); - - var description = await describer.Describe(SampleBytes, "chart.png").FirstAsync().Await(); - - description.Should().BeEmpty("no vision model available → the image indexes as no-text"); - } - - [Fact] - public async Task Describe_ClientThrows_ReturnsEmpty() - { - var chat = new RecordingChatClient("unused", onInvoke: () => throw new InvalidOperationException("boom")); - var describer = new ChatClientImageDescriber(() => chat, new IoPoolRegistry()); - - var description = await describer.Describe(SampleBytes, "chart.png").FirstAsync().Await(); - - description.Should().BeEmpty("a failed describe call must not fail the indexing activity"); - } - - [Fact] - public void SupportedExtensions_CoverCommonRasterImages() - { - var describer = new ChatClientImageDescriber(() => null, new IoPoolRegistry()); - describer.SupportedExtensions.Should().Contain([".png", ".jpg", ".jpeg", ".gif", ".webp"]); - } - - /// - /// Minimal multimodal test double: records the last text prompt and the - /// last image part, returns a fixed response, and optionally runs a hook - /// (e.g. to throw) inside the call. - /// - private sealed class RecordingChatClient(string response, Action? onInvoke = null) : IChatClient - { - public string? LastPrompt { get; private set; } - public DataContent? LastImage { get; private set; } - - public Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - var all = messages.SelectMany(m => m.Contents).ToList(); - LastPrompt = string.Concat(all.OfType().Select(t => t.Text)); - LastImage = all.OfType().FirstOrDefault(d => d.MediaType?.StartsWith("image/") == true); - onInvoke?.Invoke(); - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, response))); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var resp = await GetResponseAsync(messages, options, cancellationToken); - yield return new ChatResponseUpdate(ChatRole.Assistant, resp.Text); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientSummarizerTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientSummarizerTest.cs deleted file mode 100644 index 181e15c1ef..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ChatClientSummarizerTest.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Reactive.Linq; -using System.Runtime.CompilerServices; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.AI; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// Focused tests for : the summary comes from a single -/// completion, the prompt carries the file name + (truncated) text, and the -/// model call is routed through the bounded pool (so it takes a pool -/// slot — never runs inline on the subscribing scheduler). -/// -public class ChatClientSummarizerTest -{ - [Fact(Timeout = 30_000)] - public async Task Summarize_ReturnsModelText_AndPromptCarriesFileNameAndText() - { - var chat = new RecordingChatClient("MODEL SUMMARY"); - var registry = new IoPoolRegistry(); - var summarizer = new ChatClientSummarizer(chat, registry); - - var summary = await summarizer.Summarize("Quarterly pension report body text.", "report.pdf") - .FirstAsync().Await(); - - summary.Should().Be("MODEL SUMMARY"); - chat.LastPrompt.Should().NotBeNull(); - chat.LastPrompt!.Should().Contain("report.pdf", "the file name is a prompt hint"); - chat.LastPrompt.Should().Contain("Quarterly pension report body text.", "the document text is in the prompt"); - } - - [Fact] - public void BuildPrompt_TruncatesLongText() - { - // The summarizer truncates to MaxTextChars before the call so a huge file can't blow the - // context window. The prompt is built from the truncated text, never the full body. - var longText = new string('x', ChatClientSummarizer.MaxTextChars + 5_000); - var prompt = ChatClientSummarizer.BuildPrompt("big.txt", longText[..ChatClientSummarizer.MaxTextChars]); - prompt.Length.Should().BeLessThan(longText.Length, "the body is truncated before prompting"); - prompt.Should().Contain("big.txt"); - } - - [Fact(Timeout = 30_000)] - public async Task Summarize_RoutesThroughHttpIoPool() - { - var registry = new IoPoolRegistry(); - var httpPool = registry.Get(IoPoolNames.Http); - - // The fake blocks inside GetResponseAsync until released; while it blocks, the operation is - // in flight THROUGH the Http pool — proving the leaf took a pool slot rather than running - // inline on the subscribe thread. - var gate = new TaskCompletionSource(); - var entered = new TaskCompletionSource(); - var chat = new RecordingChatClient("ok", onInvoke: () => { entered.TrySetResult(); return gate.Task; }); - var summarizer = new ChatClientSummarizer(chat, registry); - - var resultTask = summarizer.Summarize("text", "f.txt").FirstAsync().Await(); - - await entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); - httpPool.CurrentInFlight.Should().Be(1, "the summarize call holds exactly one Http pool slot while running"); - - gate.SetResult(); - (await resultTask).Should().Be("ok"); - httpPool.CurrentInFlight.Should().Be(0, "the slot is released when the call completes"); - } - - /// - /// Minimal test double: records the last prompt (the single user - /// message's text), returns a fixed response, and optionally awaits a gate so a test can observe - /// the in-flight pool slot. - /// - private sealed class RecordingChatClient(string response, Func? onInvoke = null) : IChatClient - { - public string? LastPrompt { get; private set; } - - public async Task GetResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - CancellationToken cancellationToken = default) - { - LastPrompt = string.Concat(messages.Select(m => m.Text)); - if (onInvoke is not null) - await onInvoke(); - return new ChatResponse(new ChatMessage(ChatRole.Assistant, response)); - } - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, - ChatOptions? options = null, - [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - var resp = await GetResponseAsync(messages, options, cancellationToken); - yield return new ChatResponseUpdate(ChatRole.Assistant, resp.Text); - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - - public void Dispose() { } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentChunkAutocompleteProviderTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentChunkAutocompleteProviderTest.cs deleted file mode 100644 index 4ef598d5ca..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentChunkAutocompleteProviderTest.cs +++ /dev/null @@ -1,139 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Data.Completion; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// Unit tests for : a text query is embedded and run -/// through the chunk vector store, and every hit resolves to its source file's Document node -/// (). No mesh is needed — the provider takes the store, -/// the embedder, and a collection-scope delegate directly, so this stays a pure unit test over the -/// in-memory store + a deterministic fake embedder. -/// -public class ContentChunkAutocompleteProviderTest -{ - private const string Collection = "rbuergi/MyContent"; - private const string OtherCollection = "rbuergi/Other"; - - /// - /// Deterministic in-process embedder: the vector is derived from the SHA-256 of the text, so equal - /// texts get the SAME vector (cosine 1.0) and distinct texts get distinct vectors. Mirrors the - /// core test project's FakeEmbedder. - /// - private sealed class FakeEmbedder : IChunkEmbedder - { - public int Dimensions => 8; - - public IObservable Embed(string text) => - Observable.Defer(() => Observable.Return(Vectorize(text))); - - private float[] Vectorize(string text) - { - var digest = SHA256.HashData(Encoding.UTF8.GetBytes(text)); - var vector = new float[Dimensions]; - for (var i = 0; i < Dimensions; i++) - vector[i] = (digest[i] / 255f) * 2f - 1f; - return vector; - } - } - - private readonly FakeEmbedder _embedder = new(); - private readonly InMemoryChunkedContentVectorStore _store = new(); - - private ContentChunk Chunk(string collection, string filePath, int index, string text) => - new(collection, filePath, index, text, - ContentHash: "hash-" + filePath, - Embedding: _embedder.Embed(text).Wait()); - - private void Seed(string collection, string filePath, params string[] chunkTexts) - { - var chunks = chunkTexts - .Select((t, i) => Chunk(collection, filePath, i, t)) - .ToList(); - _store.ReplaceFileChunks(collection, filePath, chunks).Wait(); - } - - private ContentChunkAutocompleteProvider CreateProvider( - Func>? scope = null) => - new(_store, _embedder, scope ?? (_ => new[] { Collection, OtherCollection })); - - private async Task> Query( - ContentChunkAutocompleteProvider provider, string text, string? contextPath = null) => - // FromItems is a progressive snapshot stream that completes when the source completes; the LAST - // emission is the final, fully-accumulated, score-sorted list. - await provider.GetItems(text, contextPath).LastAsync().Await(); - - [Fact] - public async Task Query_ResolvesHitToDocumentNodeOfNearestFile() - { - const string targetText = "Quarterly pension contributions and accrued benefit obligations."; - Seed(Collection, "reports/pension.txt", targetText, "Unrelated second chunk about indexing."); - Seed(Collection, "reports/recipes.txt", "How to bake sourdough bread at home."); - - var provider = CreateProvider(); - - // Querying with the EXACT target chunk text → its embedding is identical (cosine 1.0), so the - // pension file's chunk ranks first. - var items = await Query(provider, targetText); - - var top = items.First(); - var expectedPath = DocumentPaths.For(Collection, "reports/pension.txt"); - top.Path.Should().Be(expectedPath); - top.InsertText.Should().Be($"@{expectedPath} "); - // Label is the file name (last path segment), not the whole path. - top.Label.Should().Be("pension.txt"); - // Description is a snippet of the matching chunk text. - top.Description.Should().Contain("Quarterly pension contributions"); - top.Kind.Should().Be(AutocompleteKind.File); - } - - [Fact] - public async Task Query_DedupesByFile_OneCompletionPerFileKeepingBestChunk() - { - const string bestText = "Pension fund net asset value reconciliation."; - // Same file, TWO chunks — both can match; the result must collapse to ONE completion for the file. - Seed(Collection, "reports/pension.txt", bestText, "Pension fund secondary commentary chunk."); - - var provider = CreateProvider(_ => new[] { Collection }); - - var items = await Query(provider, bestText); - - var expectedPath = DocumentPaths.For(Collection, "reports/pension.txt"); - items.Count(i => i.Path == expectedPath).Should().Be(1); - // The surviving completion carries the best-scoring (exact-match) chunk's snippet. - items.Single(i => i.Path == expectedPath).Description.Should().Contain("net asset value"); - } - - [Fact] - public async Task Query_EmptyText_ReturnsEmptySnapshot() - { - Seed(Collection, "reports/pension.txt", "Some indexed content."); - var provider = CreateProvider(); - - var items = await Query(provider, " "); - - items.Should().BeEmpty(); - } - - [Fact] - public async Task Query_EmptyScope_ReturnsEmptySnapshot() - { - Seed(Collection, "reports/pension.txt", "Some indexed content."); - var provider = CreateProvider(_ => Array.Empty()); - - var items = await Query(provider, "Some indexed content."); - - items.Should().BeEmpty(); - } - - [Fact] - public void Prefix_IsDocument() - { - CreateProvider().Prefix.Should().Be("document"); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentIndexingPipelineTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentIndexingPipelineTest.cs deleted file mode 100644 index cf62b8ab14..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/ContentIndexingPipelineTest.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.ContentCollections; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Data; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// End-to-end coverage for STEP 5 — index-on-upload as an Activity + re-index-all: -/// -/// A file uploaded through the content-upload seam ( -/// — the exact call MeshOperations.Upload makes after SaveFileAsync) fires the -/// registered , which runs an indexing Activity: the -/// Activity reaches terminal Succeeded, a Document mesh node appears, and chunks -/// land in the store. -/// Re-index-all over two files indexes both; an immediate re-run is hash-gate Skipped. -/// -/// The store is the in-memory vector store and the embedder/summarizer are deterministic fakes, so -/// NO real pg/AI is needed — the point under test is the upload→Activity→IndexFile wiring. -/// -public class ContentIndexingPipelineTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // Qualified collection path (node path + collection name) — the same shape MeshOperations.Upload - // forms as `{prefix}/{collectionName}` and hands the indexing pipeline. - private static readonly string Collection = TestPartition + "/IndexedContent"; - - // Per-test-class temp backing dir so the FileSystem collection is real and re-readable. - private readonly string _basePath = Path.Combine( - Path.GetTempPath(), $"meshweaver-index-pipeline-{Guid.NewGuid():N}"); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - // A real, writable FileSystem collection named exactly like the qualified path the - // indexing pipeline keys on, so the observer can read the uploaded bytes back. Registered - // on the mesh hub so IContentService resolves it via Mesh.ServiceProvider. - .ConfigureHub(hub => hub.AddContentCollections(new ContentCollectionConfig - { - Name = Collection, - SourceType = FileSystemStreamProvider.SourceType, - BasePath = _basePath, - IsEditable = true, - ExposeInChildren = true, - Settings = new Dictionary { ["BasePath"] = _basePath }, - })) - // The full pipeline: upload observer + indexing Activity + indexing core, with the - // in-memory store + deterministic fakes (no pg/AI). - .AddContentIndexingPipeline( - _ => new InMemoryChunkedContentVectorStore(), - _ => new FakeEmbedder(), - _ => new FakeSummarizer(), - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }); - - public override async ValueTask DisposeAsync() - { - await base.DisposeAsync(); - try { if (Directory.Exists(_basePath)) Directory.Delete(_basePath, recursive: true); } catch { } - } - - private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); - - /// Saves a file into the collection exactly as the upload handler does (via the collection's SaveFile). - private async Task SaveFile(string filePath, byte[] bytes) - { - var contentService = Mesh.ServiceProvider.GetRequiredService(); - var collection = await contentService.GetCollection(Collection) - .FirstAsync().Await(TestContext.Current.CancellationToken); - collection.Should().NotBeNull(); - var dir = Path.GetDirectoryName(filePath)?.Replace('\\', '/') ?? ""; - var fileName = Path.GetFileName(filePath); - using var ms = new MemoryStream(bytes); - await collection!.SaveFile(dir, fileName, ms).Await(TestContext.Current.CancellationToken); - } - - /// Reads the Document node back, polling until the typed content lands (write is debounced/persisted). - private async Task ReadDocumentNode(string path) => - await Observable.Interval(TimeSpan.FromMilliseconds(100)) - .StartWith(0L) - .SelectMany(_ => Mesh.GetMeshNode(path, 5.Seconds())) - .Where(n => n?.Content is Document) - .FirstAsync() - .Timeout(30.Seconds()) - .Await(TestContext.Current.CancellationToken); - - /// The store the pipeline wrote chunks into. - private IChunkedContentVectorStore Store => - Mesh.ServiceProvider.GetRequiredService(); - - [Fact(Timeout = 60_000)] - public async Task Upload_FiresIndexingActivity_WritesDocumentAndChunks() - { - const string fileName = "notes.txt"; - var filePath = $"docs/{fileName}"; - var body = string.Concat(Enumerable.Repeat( - "MeshWeaver indexes content files into overlapping chunks. " + - "Each chunk is embedded into a vector for similarity search. ", 6)); - var bytes = Encoding.UTF8.GetBytes(body); - - // 1) Save the file, then raise the upload seam — exactly what MeshOperations.Upload does after - // SaveFileAsync. This fires the registered observer, which runs the indexing Activity. - await SaveFile(filePath, bytes); - Mesh.RaiseContentUploaded(Collection, filePath); - - // 2) The indexing Activity ran end-to-end: the Document node appears (written by the Activity - // body's IndexFile → MeshDocumentSink). Reading it back proves the upload→Activity→IndexFile - // chain executed. - var expectedPath = DocumentPaths.For(Collection, filePath); - var node = await ReadDocumentNode(expectedPath); - - node.Should().NotBeNull(); - node!.NodeType.Should().Be(DocumentNodeType.NodeType); - var document = node.Content.Should().BeOfType().Which; - document.Name.Should().Be(fileName); - document.FilePath.Should().Be(filePath); - document.CollectionPath.Should().Be(Collection); - document.ContentHash.Should().Be(Sha256Hex(bytes)); - document.ChunkCount.Should().BeGreaterThan(1); - - // 3) Chunks landed in the store under the (collection, file) key (proves the embed/store branch - // ran inside the Activity). The Document node above is the LAST step of the Activity body, - // so its presence already proves the upload→Activity→IndexFile chain ran to completion. - var storedHash = await Store.GetFileHash(Collection, filePath).FirstAsync().Await(TestContext.Current.CancellationToken); - storedHash.Should().Be(Sha256Hex(bytes)); - } - - [Fact(Timeout = 60_000)] - public async Task ReindexAll_IndexesEveryFile_UnchangedRerunSkipped() - { - var files = new (string Path, byte[] Bytes)[] - { - ("a/one.txt", Encoding.UTF8.GetBytes("# One\n\nBody about chunking and embeddings, alpha.\n")), - ("b/two.md", Encoding.UTF8.GetBytes("# Two\n\nBody about vectors and similarity, beta.\n")), - }; - foreach (var (path, bytes) in files) - await SaveFile(path, bytes); - - var observer = Mesh.ServiceProvider.GetRequiredService(); - - // Re-index-all (the mount-a-fresh-DB operation) — walks every file and indexes it. - var activityPath = await observer.ReindexAll([Collection]) - .FirstAsync().Await(TestContext.Current.CancellationToken); - - // The re-index-all Activity reached terminal Succeeded. - var activity = await WaitForActivityStatus(activityPath, ActivityStatus.Succeeded); - activity.Status.Should().Be(ActivityStatus.Succeeded); - - // Both files are indexed: their hashes are recorded and a Document node exists for each. - foreach (var (path, bytes) in files) - { - var hash = await Store.GetFileHash(Collection, path).FirstAsync().Await(TestContext.Current.CancellationToken); - hash.Should().Be(Sha256Hex(bytes), "{0} should be indexed", path); - - var doc = await ReadDocumentNode(DocumentPaths.For(Collection, path)); - (doc!.Content as Document)!.FilePath.Should().Be(path); - } - - // A second re-index-all over the SAME (unchanged) bytes is hash-gate Skipped — every file's - // recorded hash is unchanged, so the Document nodes are NOT re-written (versions stable). - var versionsBefore = new Dictionary(); - foreach (var (path, _) in files) - versionsBefore[path] = (await ReadDocumentNode(DocumentPaths.For(Collection, path)))!.Version; - - var secondActivityPath = await observer.ReindexAll([Collection]) - .FirstAsync().Await(TestContext.Current.CancellationToken); - var secondActivity = await WaitForActivityStatus(secondActivityPath, ActivityStatus.Succeeded); - secondActivity.Status.Should().Be(ActivityStatus.Succeeded); - - foreach (var (path, _) in files) - { - var doc = await ReadDocumentNode(DocumentPaths.For(Collection, path)); - doc!.Version.Should().Be(versionsBefore[path], - "unchanged {0} must be skipped (Document not re-written) on a re-run", path); - } - } - - /// - /// 🚨 THE REGRESSION GUARD for the O(n²) activity-log shape (#1341 / #1172). The re-index - /// activity must cost a number of WRITES that does not grow with the number of files walked. - /// - /// Every stream.Update on the activity node re-serialises that node's WHOLE content - /// to compute its patch, so appending one line per file over n files serialises 1 + 2 + … + n - /// copies of a growing document — O(n²) CPU and allocation. On memex-cloud that shape cost 719 MB - /// of serialisation on a single import activity and was the dominant term in the pod sitting at - /// 51% of CFS periods throttled. - /// - /// What is asserted is the WRITE COUNT — the activity node's own revision counter - /// (), never the log text — so the test cannot be satisfied by - /// rewording a message. RED before the batching (writes grew 1:1 with the file count), GREEN - /// after (identical for both sizes). Both sizes stay under ProgressEvery, so no heartbeat - /// line is involved either way. - /// - [Fact(Timeout = 120_000)] - public async Task ReindexActivityWrites_DoNotGrowWithTheNumberOfFiles() - { - const int smallWalk = 3; - const int largeWalk = 12; - - var observer = Mesh.ServiceProvider.GetRequiredService(); - - for (var i = 0; i < smallWalk; i++) - await SaveFile($"scale/file-{i:D3}.txt", Encoding.UTF8.GetBytes($"# File {i}\n\nAlpha beta gamma {i}.\n")); - var smallWrites = await ReindexAndCountWrites(observer); - - // Grow the collection, then walk it again — a strictly larger walk on the same pipeline. - for (var i = smallWalk; i < largeWalk; i++) - await SaveFile($"scale/file-{i:D3}.txt", Encoding.UTF8.GetBytes($"# File {i}\n\nAlpha beta gamma {i}.\n")); - var largeWrites = await ReindexAndCountWrites(observer); - - Output.WriteLine($"activity writes: {smallWalk} files -> {smallWrites}; {largeWalk} files -> {largeWrites}"); - - largeWrites.Should().Be(smallWrites, - $"the walk must log its per-file detail in ONE write regardless of how many files it " - + $"handled — {largeWalk} files cost {largeWrites} writes vs {smallWrites} for {smallWalk}, " - + "i.e. the per-file ctx.Log is back and every one of those writes re-serialises the whole " - + "activity node (the O(n²) CPU shape behind #1172)"); - - // …and the absolute figure stays a small constant. Comparing the two sizes alone would still - // pass if BOTH grew with the file count. - largeWrites.Should().BeLessThan(largeWalk, - "the write count is per-collection, so it must stay well under the per-file count"); - } - - /// Runs one re-index-all to terminal Succeeded and returns how many times its activity - /// node was written ( — the node's own "+1 per real modification"). - private async Task ReindexAndCountWrites(ContentIndexingObserver observer) - { - var activityPath = await observer.ReindexAll([Collection]) - .FirstAsync().Await(TestContext.Current.CancellationToken); - await WaitForActivityStatus(activityPath, ActivityStatus.Succeeded); - // Terminal status is the LAST write the runner makes, so the node is settled here. - var node = await Mesh.GetWorkspace().GetMeshNodeStream(activityPath) - .Where(n => (n?.Content as ActivityLog)?.Status == ActivityStatus.Succeeded) - .FirstAsync() - .Timeout(30.Seconds()) - .Await(TestContext.Current.CancellationToken); - return node.Version; - } - - private async Task WaitForActivityStatus(string activityPath, ActivityStatus status) => - await Mesh.GetWorkspace().GetMeshNodeStream(activityPath) - .Select(n => n?.Content as ActivityLog) - .Where(log => log is not null && log.Status == status) - .Select(log => log!) - .FirstAsync() - .Timeout(30.Seconds()) - .Await(TestContext.Current.CancellationToken); - - // ----- deterministic test doubles (chunk/summarize leaves, NOT the sink) ----- - - private sealed class FakeSummarizer : ISummarizer - { - public IObservable Summarize(string text, string fileName) => - Observable.Defer(() => Observable.Return( - "SUMMARY: " + (text.Length <= 40 ? text : text[..40]))); - } - - private sealed class FakeEmbedder : IChunkEmbedder - { - public int Dimensions => 8; - - public IObservable Embed(string text) => - Observable.Defer(() => Observable.Return(Vectorize(text))); - - private float[] Vectorize(string text) - { - var digest = SHA256.HashData(Encoding.UTF8.GetBytes(text)); - var vector = new float[Dimensions]; - for (var i = 0; i < Dimensions; i++) - vector[i] = digest[i] / 255f * 2f - 1f; - return vector; - } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentBlocksAreaRenderTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentBlocksAreaRenderTest.cs deleted file mode 100644 index 6e3a8f8ada..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentBlocksAreaRenderTest.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Data; -using MeshWeaver.Fixture; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Layout; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// Renders — the content-index-block reader (excerpt + -/// prev/next navigation) — through the standard per-node layout-area machinery, i.e. the exact -/// path the portal GUI takes when a vector-index search hit is opened. -/// -/// Covers BOTH content shapes: a Document written through the pipeline (typed content) AND -/// the LEGACY persisted shape (content = raw JSON without a $type discriminator — what -/// production carried while the mesh hub's TypeRegistry lacked the Document registration). -/// Legacy rows must still render: ContentAs<Document> recovers the untyped JSON at the -/// read site. -/// -public class DocumentBlocksAreaRenderTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // The collection segment is deliberately "content" — the production naming whose search hits - // land on {collection}/_Documents/{slug} node paths. - private static readonly string Collection = TestPartition + "/content"; - private const string FilePath = "vertrag_2025.txt"; - - /// Registers the full indexing pipeline with in-memory store + deterministic fakes. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder) - .AddContentIndexingPipeline( - _ => new InMemoryChunkedContentVectorStore(), - _ => new FakeEmbedder(), - _ => new FakeSummarizer(), - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }); - - private IChunkedContentVectorStore Store => - Mesh.ServiceProvider.GetRequiredService(); - - private async Task SeedChunks(params string[] texts) - { - var chunks = texts - .Select((text, index) => new ContentChunk( - Collection, FilePath, index, text, ContentHash: "deadbeef", Embedding: null)) - .ToArray(); - await Store.ReplaceFileChunks(Collection, FilePath, chunks) - .FirstAsync().Await(TestContext.Current.CancellationToken); - } - - /// - /// Renders the Blocks area on the Document node at and waits until the - /// wire store contains . - /// - private async Task RenderBlocksUntil(string path, string expected) - { - var reference = new LayoutAreaReference(DocumentLayoutAreas.BlocksArea); - var stream = Mesh.GetWorkspace() - .GetRemoteStream(new Address(path), reference); - return await stream - .Select(change => change.Value.GetRawText()) - .Where(json => json.Contains(expected, StringComparison.Ordinal)) - .Should().Within(40.Seconds()) - .Match(_ => true); - } - - [Fact(Timeout = 60_000)] - public async Task LegacyUntypedDocumentNode_BlocksArea_RendersExcerptAndNavigation() - { - await SeedChunks( - "Erster Vertragsblock mit dem massgeblichen Passus.", - "Zweiter Vertragsblock mit weiteren Bestimmungen."); - - // The LEGACY persisted shape: content is a raw JsonElement with camelCase fields and NO - // $type discriminator — byte-for-byte what production rows carried while the type was - // unregistered on the writing hub. - var path = DocumentPaths.For(Collection, FilePath); - var legacyContent = JsonSerializer.SerializeToElement(new - { - name = FilePath, - summary = "Zusammenfassung des Vertrags.", - collectionPath = Collection, - filePath = FilePath, - mime = "text/plain", - sizeBytes = 42L, - contentHash = "deadbeef", - chunkCount = 2, - indexedAt = DateTimeOffset.UtcNow, - }); - var node = MeshNode.FromPath(path) with - { - NodeType = DocumentNodeType.NodeType, - Name = FilePath, - State = MeshNodeState.Active, - Content = legacyContent, - }; - var created = await Mesh.ServiceProvider.GetRequiredService() - .CreateNode(node).FirstAsync().Await(TestContext.Current.CancellationToken); - created.Should().NotBeNull(); - - // The block reader renders the FIRST chunk's text (the excerpt) … - var json = await RenderBlocksUntil(path, "Erster Vertragsblock"); - // … with forward navigation to the next block (index 0 of 2 ⇒ Next, no Previous). - json.Should().Contain("Next"); - json.Should().NotContain("No document data"); - } - - [Fact(Timeout = 60_000)] - public async Task PipelineWrittenDocumentNode_BlocksArea_RendersExcerptAndNavigation() - { - // Index through the real service + real sink — the post-fix (typed) write path. - var body = string.Concat(Enumerable.Repeat( - "Der massgebliche Passus über die Rentenverpflichtungen steht in diesem Vertrag. ", 6)); - var service = Mesh.ServiceProvider.GetRequiredService(); - var result = await service - .IndexFile(Collection, FilePath, FilePath, System.Text.Encoding.UTF8.GetBytes(body)) - .FirstAsync().Await(TestContext.Current.CancellationToken); - result.Status.Should().Be(IndexStatus.Indexed); - - var path = DocumentPaths.For(Collection, FilePath); - var json = await RenderBlocksUntil(path, "Der massgebliche Passus"); - json.Should().Contain("Next"); - json.Should().NotContain("No document data"); - } - - // ----- deterministic test doubles (chunk/summarize leaves, NOT the sink) ----- - - private sealed class FakeSummarizer : ISummarizer - { - public IObservable Summarize(string text, string fileName) => - Observable.Defer(() => Observable.Return( - "SUMMARY: " + (text.Length <= 40 ? text : text[..40]))); - } - - private sealed class FakeEmbedder : IChunkEmbedder - { - public int Dimensions => 8; - - public IObservable Embed(string text) => - Observable.Defer(() => Observable.Return(new float[Dimensions])); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentPathsTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentPathsTest.cs deleted file mode 100644 index 20e47bb720..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/DocumentPathsTest.cs +++ /dev/null @@ -1,78 +0,0 @@ -using MeshWeaver.ContentCollections.Indexing; -using Xunit; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// Unit tests for : the per-file Document node path must be -/// DETERMINISTIC (same inputs → same path, so a re-index updates the same node) and url-safe -/// (no character that would fork the path into extra mesh segments). -/// -public class DocumentPathsTest -{ - [Fact] - public void For_IsDeterministic_SameInputsSamePath() - { - var a = DocumentPaths.For("rbuergi/MyContent", "docs/report.pdf"); - var b = DocumentPaths.For("rbuergi/MyContent", "docs/report.pdf"); - a.Should().Be(b); - } - - [Fact] - public void For_PlacesDocumentUnderReservedSubNamespace() - { - var path = DocumentPaths.For("rbuergi/MyContent", "report.pdf"); - path.Should().StartWith("rbuergi/MyContent/_Documents/"); - } - - [Fact] - public void For_ProducesExactlyOneSegmentForTheFileSlug() - { - // The file path's own directory separators must NOT leak into the mesh path as extra - // segments — the whole file path collapses into ONE slug segment under _Documents. - var path = DocumentPaths.For("rbuergi/MyContent", "a/b/c/deep file.pdf"); - var afterDocuments = path["rbuergi/MyContent/_Documents/".Length..]; - afterDocuments.Should().NotContain("/", "the file slug is a single path segment"); - afterDocuments.Should().NotContain(" ", "whitespace must be replaced for url-safety"); - } - - [Fact] - public void For_DistinctFilesGetDistinctPaths() - { - var a = DocumentPaths.For("c", "report-2024.pdf"); - var b = DocumentPaths.For("c", "report-2025.pdf"); - a.Should().NotBe(b); - } - - [Fact] - public void For_TrailingCollectionSlashIsNormalized() - { - DocumentPaths.For("c/", "f.txt").Should().Be(DocumentPaths.For("c", "f.txt")); - } - - [Theory] - [InlineData("simple.txt", "simple.txt")] - [InlineData("dir/sub/file.md", "dir-sub-file.md")] - [InlineData("has spaces.docx", "has-spaces.docx")] - [InlineData("weird&chars!@#.pdf", "weird-chars-.pdf")] - [InlineData("/leading/slash.txt", "leading-slash.txt")] - public void Slug_IsUrlSafeAndReadable(string filePath, string expected) - { - DocumentPaths.Slug(filePath).Should().Be(expected); - } - - [Fact] - public void Slug_OnlySeparators_FallsBackToNonEmptySegment() - { - // Degenerate all-separator input must still yield a non-empty, url-safe segment. - DocumentPaths.Slug("///").Should().Be("document"); - } - - [Fact] - public void Slug_PreservesCase() - { - // Mesh paths are case-sensitive; two files differing only by case are distinct. - DocumentPaths.Slug("Report.PDF").Should().Be("Report.PDF"); - DocumentPaths.Slug("Report.PDF").Should().NotBe(DocumentPaths.Slug("report.pdf")); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshDocumentSinkTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshDocumentSinkTest.cs deleted file mode 100644 index a3597421d5..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshDocumentSinkTest.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Threading; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Graph.Test; - -/// -/// End-to-end coverage for the registered Document mesh NodeType: a changed file indexed with -/// the real (wired into a ) writes -/// a first-class Document mesh node at carrying the expected -/// Name + Summary + metadata, read back authoritatively via workspace.GetMeshNodeStream(path). -/// Re-indexing identical bytes is Skipped by the hash gate — neither the summarizer nor the -/// sink runs again, so the node is unchanged. -/// -public class MeshDocumentSinkTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - private static readonly string Collection = TestPartition + "/MyContent"; - - /// Register the Document NodeType (+ MeshDocumentSink) on the test mesh. - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - => base.ConfigureMesh(builder).AddDocumentIndexing(); - - /// - /// Builds a over in-memory chunk storage with the REAL mesh - /// sink and a deterministic fake summarizer — exactly the wiring a host would use, minus the AI - /// call. The summarizer is a fake so the node assertion is deterministic; - /// has its own focused test. - /// - private (ContentIndexingService service, FakeSummarizer summarizer, MeshDocumentSink sink) BuildService() - { - var summarizer = new FakeSummarizer(); - var sink = new MeshDocumentSink(Mesh); - var service = new ContentIndexingService( - new TextExtractor(IoPool.Unbounded), - new FakeEmbedder(), - new InMemoryChunkedContentVectorStore(), - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }, - logger: null, - summarizer: summarizer, - documentSink: sink); - return (service, summarizer, sink); - } - - private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); - - /// - /// Reads the Document node at via the per-node hub (request/response), - /// retrying until the typed content is present. The Document write is - /// debounced + persisted, so a single point read can race it — poll on the actual condition. - /// - private async Task ReadDocumentNode(string path) => - await Observable.Interval(TimeSpan.FromMilliseconds(100)) - .StartWith(0L) - .SelectMany(_ => Mesh.GetMeshNode(path, 5.Seconds())) - .Where(n => n?.Content is Document) - .FirstAsync() - .Timeout(30.Seconds()) - .Await(); - - [Fact(Timeout = 60_000)] - public async Task IndexedFile_WritesFirstClassDocumentMeshNode() - { - var (service, summarizer, _) = BuildService(); - - const string fileName = "notes.txt"; - var filePath = $"docs/{fileName}"; - var body = string.Concat(Enumerable.Repeat( - "MeshWeaver indexes content files into overlapping chunks. " + - "Each chunk is embedded into a vector for similarity search. ", 6)); - var bytes = Encoding.UTF8.GetBytes(body); - - var result = await service.IndexFile(Collection, filePath, fileName, bytes) - .FirstAsync().Await(); - result.Status.Should().Be(IndexStatus.Indexed); - result.ChunkCount.Should().BeGreaterThan(1); - summarizer.Calls.Should().Be(1); - - // The Document node exists at the deterministic path — read it back authoritatively via the - // per-node hub (request/response single-node read; activates the hub and returns the typed - // node). The Document branch is debounced/persisted, so poll the read until it lands. - var expectedPath = DocumentPaths.For(Collection, filePath); - var node = await ReadDocumentNode(expectedPath); - - node.Should().NotBeNull(); - node!.NodeType.Should().Be(DocumentNodeType.NodeType); - node.Name.Should().Be(fileName); - - var document = node.Content.Should().BeOfType().Which; - document.Name.Should().Be(fileName); - document.Summary.Should().Be(FakeSummarizer.Expected(body)); - document.CollectionPath.Should().Be(Collection); - document.FilePath.Should().Be(filePath); - document.ContentHash.Should().Be(Sha256Hex(bytes)); - document.ChunkCount.Should().Be(result.ChunkCount); - document.SizeBytes.Should().Be(bytes.Length); - document.IndexedAt.Should().NotBe(default); - } - - /// - /// Pins the serialization boundary the in-memory store never crosses: on a JSON-backed store - /// (Postgres jsonb) the Document content is serialized with the MESH hub's options (the write - /// travels MeshDocumentSink → CreateOrUpdateNodeRequest → mesh hub → storage), NOT the - /// Document per-node hub's. Prod repro (prod 2026-07-01): the mesh hub's TypeRegistry lacked - /// Document, content persisted WITHOUT a $type discriminator, and every reader got - /// an untyped JsonElement ("stayed an untyped JsonElement (TypeRegistry lacks the $type - /// discriminator)") — document pages rendered empty / not found. - /// - [Fact(Timeout = 60_000)] - public async Task DocumentContent_RoundTripsTyped_ThroughMeshHubSerializer() - { - var (service, _, _) = BuildService(); - - const string fileName = "roundtrip.txt"; - var filePath = $"docs/{fileName}"; - var bytes = Encoding.UTF8.GetBytes("Round-trip body long enough to chunk and summarize."); - - var result = await service.IndexFile(Collection, filePath, fileName, bytes) - .FirstAsync().Await(); - result.Status.Should().Be(IndexStatus.Indexed); - - var node = await ReadDocumentNode(DocumentPaths.For(Collection, filePath)); - - // Serialize with the MESH hub's options — the exact boundary a jsonb store exercises. - var json = System.Text.Json.JsonSerializer.Serialize(node, Mesh.JsonSerializerOptions); - using (var parsed = System.Text.Json.JsonDocument.Parse(json)) - { - parsed.RootElement.GetProperty("content").TryGetProperty("$type", out var discriminator) - .Should().BeTrue("the mesh hub must emit the Document $type discriminator"); - discriminator.GetString().Should().Be(nameof(Document)); - } - - // And back: a reader deserializing with the same options gets TYPED content. - var roundTripped = System.Text.Json.JsonSerializer.Deserialize(json, Mesh.JsonSerializerOptions); - roundTripped!.Content.Should().BeOfType() - .Which.FilePath.Should().Be(filePath); - } - - [Fact(Timeout = 60_000)] - public async Task ReindexIdenticalBytes_IsSkipped_NodeUnchanged() - { - var (service, summarizer, _) = BuildService(); - - const string fileName = "doc.md"; - var filePath = $"pages/{fileName}"; - var bytes = Encoding.UTF8.GetBytes("# Title\n\nBody about chunking and embeddings.\n"); - - var first = await service.IndexFile(Collection, filePath, fileName, bytes).FirstAsync().Await(); - first.Status.Should().Be(IndexStatus.Indexed); - summarizer.Calls.Should().Be(1); - - var expectedPath = DocumentPaths.For(Collection, filePath); - var afterFirst = await ReadDocumentNode(expectedPath); - var versionAfterFirst = afterFirst!.Version; - var hashAfterFirst = (afterFirst.Content as Document)!.ContentHash; - - // Re-index identical bytes -> hash gate short-circuits BEFORE extract/summarize/document. - var second = await service.IndexFile(Collection, filePath, fileName, bytes).FirstAsync().Await(); - second.Status.Should().Be(IndexStatus.Skipped); - second.ChunkCount.Should().Be(0); - - // The summarizer never ran a second time, and the node still carries the first hash - // (the skip path never re-wrote the Document). - summarizer.Calls.Should().Be(1); - - var node = await ReadDocumentNode(expectedPath); - (node!.Content as Document)!.ContentHash.Should().Be(hashAfterFirst); - node.Version.Should().Be(versionAfterFirst, "the skipped re-index must not re-write the node"); - } - - // ----- deterministic test doubles (the chunk/summarize leaves, NOT the sink) ----- - - /// Deterministic : "SUMMARY: " + first 40 chars; counts calls. - private sealed class FakeSummarizer : ISummarizer - { - private const int HeadChars = 40; - private int _calls; - public int Calls => Volatile.Read(ref _calls); - - public IObservable Summarize(string text, string fileName) => - Observable.Defer(() => - { - Interlocked.Increment(ref _calls); - return Observable.Return(Expected(text)); - }); - - public static string Expected(string text) => - "SUMMARY: " + (text.Length <= HeadChars ? text : text[..HeadChars]); - } - - /// Deterministic : vector from SHA-256 of the text. - private sealed class FakeEmbedder : IChunkEmbedder - { - public int Dimensions => 8; - - public IObservable Embed(string text) => - Observable.Defer(() => Observable.Return(Vectorize(text))); - - private float[] Vectorize(string text) - { - var digest = SHA256.HashData(Encoding.UTF8.GetBytes(text)); - var vector = new float[Dimensions]; - for (var i = 0; i < Dimensions; i++) - vector[i] = digest[i] / 255f * 2f - 1f; - return vector; - } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshWeaver.ContentCollections.Indexing.Graph.Test.csproj b/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshWeaver.ContentCollections.Indexing.Graph.Test.csproj deleted file mode 100644 index 8c802c9de6..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Graph.Test/MeshWeaver.ContentCollections.Indexing.Graph.Test.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/ChunkNavigationStoreTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/ChunkNavigationStoreTest.cs deleted file mode 100644 index b8c2a55688..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/ChunkNavigationStoreTest.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.ContentCollections.Indexing; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Unit tests for the read-by-index surface on : -/// (one chunk by zero-based index, null out of -/// range) and (chunk count for a file). -/// These back the chunk-navigation tools' prev/next stepping. Pure unit test over the in-memory -/// store + the deterministic — no mesh. -/// -public class ChunkNavigationStoreTest -{ - private const string Collection = "rbuergi/MyContent"; - private const string FilePath = "reports/pension.txt"; - - private readonly FakeEmbedder _embedder = new(); - private readonly InMemoryChunkedContentVectorStore _store = new(); - - private ContentChunk Chunk(int index, string text) => - new(Collection, FilePath, index, text, - ContentHash: "hash-" + FilePath, - Embedding: _embedder.Embed(text).Wait()); - - private void Seed(int chunkCount) - { - var chunks = Enumerable.Range(0, chunkCount) - .Select(i => Chunk(i, $"Chunk number {i} of the pension report.")) - .ToList(); - _store.ReplaceFileChunks(Collection, FilePath, chunks).Wait(); - } - - private async Task GetChunk(int index) => - await _store.GetChunk(Collection, FilePath, index).FirstAsync().Await(); - - private async Task GetCount(string filePath) => - await _store.GetChunkCount(Collection, filePath).FirstAsync().Await(); - - [Fact] - public async Task GetChunk_ReturnsChunkAtIndex() - { - Seed(5); - - for (var i = 0; i < 5; i++) - { - var chunk = await GetChunk(i); - chunk.Should().NotBeNull(); - chunk!.ChunkIndex.Should().Be(i); - chunk.Text.Should().Be($"Chunk number {i} of the pension report."); - chunk.FilePath.Should().Be(FilePath); - chunk.CollectionPath.Should().Be(Collection); - } - } - - [Fact] - public async Task GetChunk_OutOfRange_ReturnsNull() - { - Seed(3); - - (await GetChunk(3)).Should().BeNull(); // one past the last (last is index 2) - (await GetChunk(99)).Should().BeNull(); // far past the end - (await GetChunk(-1)).Should().BeNull(); // negative - } - - [Fact] - public async Task GetChunk_UnknownFile_ReturnsNull() - { - Seed(3); - - var chunk = await _store.GetChunk(Collection, "reports/never-indexed.txt", 0) - .FirstAsync().Await(); - chunk.Should().BeNull(); - } - - [Fact] - public async Task GetChunkCount_EqualsNumberOfChunks() - { - Seed(7); - - (await GetCount(FilePath)).Should().Be(7); - } - - [Fact] - public async Task GetChunkCount_UnknownFile_IsZero() - { - Seed(7); - - (await GetCount("reports/never-indexed.txt")).Should().Be(0); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentChunkSearchTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/ContentChunkSearchTest.cs deleted file mode 100644 index 324c689896..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentChunkSearchTest.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System.Reactive.Linq; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Unit tests for the content-chunk search engine () and the subtree-scoped -/// store query () it builds on. These pin the -/// scope semantics the GUI explorer and the agent/MCP search_chunks grammar both rely on: -/// subtree (the default — "check only this collection [and nested]"), exact, and -/// ancestorsandself (the context walk). Pure unit test over the in-memory store + the deterministic -/// — no mesh. Each search uses limit:50 so every in-scope chunk is -/// returned regardless of the fake embedder's (SHA-derived) ranking — the assertions are on WHICH -/// collections are searched, which is the scope contract. -/// -public class ContentChunkSearchTest -{ - private readonly FakeEmbedder _embedder = new(); - private readonly InMemoryChunkedContentVectorStore _store = new(); - - public ContentChunkSearchTest() - { - // A small collection tree under the ACME partition, plus a sibling partition collection: - // ACME/content (the collection) — file a.txt - // ACME/content/sub (nested under it) — file b.txt - // ACME (an ancestor collection) — file root.txt - // OTHER/content (a different partition) — file c.txt - Seed("ACME/content", "a.txt", "alpha apple annual report"); - Seed("ACME/content/sub", "b.txt", "beta banana benefit obligation"); - Seed("ACME", "root.txt", "gamma grape governance"); - Seed("OTHER/content", "c.txt", "delta date disclosure"); - } - - private void Seed(string collection, string file, string text) => - _store.ReplaceFileChunks(collection, file, new[] - { - new ContentChunk( - CollectionPath: collection, FilePath: file, ChunkIndex: 0, Text: text, - ContentHash: "h-" + collection + "/" + file, Embedding: _embedder.Embed(text).Wait()), - }).Wait(); - - private async Task Search(string query, string? defaultNs = null) => - await ContentChunkSearch.SearchContent(_store, _embedder, query, 50, defaultNs).FirstAsync().Await(); - - private async Task Anchored(string query, string anchor) => - await ContentChunkSearch.Search(_store, _embedder, query, anchor, 50).FirstAsync().Await(); - - private static IEnumerable Collections(ContentSearchResult r) => - r.Hits.Select(h => h.CollectionPath); - - /// Order-insensitive set assertion — the custom BeEquivalentTo in this repo wants a serializer. - private static void AssertSet(IEnumerable actual, params string[] expected) => - actual.Distinct().OrderBy(x => x, StringComparer.Ordinal) - .Should().Equal(expected.OrderBy(x => x, StringComparer.Ordinal)); - - // ── Store-level subtree scoping ────────────────────────────────────────── - - [Fact] - public async Task SearchSubtree_MatchesCollectionAndDescendants_NotAncestorsOrSiblings() - { - var hits = await _store.SearchSubtree("ACME/content", _embedder.Embed("alpha").Wait(), 50) - .FirstAsync().Await(); - - AssertSet(hits.Select(h => h.CollectionPath), "ACME/content", "ACME/content/sub"); - } - - [Fact] - public async Task SearchSubtree_ExactCollectionWithNoChildren_ReturnsOnlyThatCollection() - { - var hits = await _store.SearchSubtree("OTHER/content", _embedder.Embed("delta").Wait(), 50) - .FirstAsync().Await(); - - AssertSet(hits.Select(h => h.CollectionPath), "OTHER/content"); - } - - // ── Grammar: scope resolution ──────────────────────────────────────────── - - [Fact] - public async Task Grammar_NoScope_DefaultsToSubtree() - { - var result = await Search("namespace:ACME/content alpha apple"); - - result.Scope.Should().Be(ContentSearchScope.Subtree); - AssertSet(Collections(result), "ACME/content", "ACME/content/sub"); - result.ToolCall.Should().Contain("namespace:ACME/content scope:subtree"); - } - - [Fact] - public async Task Grammar_ScopeExact_RestrictsToTheOneCollection() - { - var result = await Search("namespace:ACME/content scope:exact alpha"); - - result.Scope.Should().Be(ContentSearchScope.Exact); - AssertSet(Collections(result), "ACME/content"); - result.ToolCall.Should().Contain("scope:exact"); - } - - [Fact] - public async Task Grammar_ScopeAncestorsAndSelf_WalksUp_NotDown() - { - var result = await Search("namespace:ACME/content scope:ancestorsandself alpha"); - - result.Scope.Should().Be(ContentSearchScope.AncestorsAndSelf); - AssertSet(Collections(result), "ACME/content", "ACME"); // up to ACME, never down into sub - } - - [Fact] - public async Task Grammar_NamespaceFromDefault_WhenNoNamespaceToken() - { - var result = await Search("alpha apple", defaultNs: "ACME/content"); - - result.Namespace.Should().Be("ACME/content"); - result.Scope.Should().Be(ContentSearchScope.Subtree); - AssertSet(Collections(result), "ACME/content", "ACME/content/sub"); - } - - [Fact] - public async Task Grammar_NamespaceTokenWins_OverDefault() - { - var result = await Search("namespace:OTHER/content delta", defaultNs: "ACME/content"); - - result.Namespace.Should().Be("OTHER/content"); - AssertSet(Collections(result), "OTHER/content"); - } - - // ── Grammar: hint envelopes (no throw) ─────────────────────────────────── - - [Fact] - public async Task Grammar_NoNamespaceAndNoDefault_ReturnsHint_NoHits() - { - var result = await Search("alpha apple"); - - result.Hits.Should().BeEmpty(); - result.Message.Should().NotBeNullOrEmpty(); - result.Message.Should().Contain("namespace:"); - } - - [Fact] - public async Task Grammar_EmptyText_ReturnsHint_NoHits() - { - var result = await Search("namespace:ACME/content scope:subtree"); - - result.Hits.Should().BeEmpty(); - result.Message.Should().NotBeNullOrEmpty(); - } - - [Fact] - public async Task Grammar_NoStore_ReturnsNotEnabledMessage_NoThrow() - { - var result = await ContentChunkSearch.SearchContent(null, null, "namespace:ACME/content alpha") - .FirstAsync().Await(); - - result.Hits.Should().BeEmpty(); - result.Message.Should().Contain("not enabled"); - } - - // ── Dispatch: Search routes by query shape ─────────────────────────────── - - [Fact] - public async Task Search_NamespaceToken_RoutesToGrammar_IgnoringAnchor() - { - // The anchor is a bogus path; the namespace: token must win and drive the search. - var result = await Anchored("namespace:ACME/content alpha", anchor: "ZZZ/nowhere"); - - result.Scope.Should().Be(ContentSearchScope.Subtree); - AssertSet(Collections(result), "ACME/content", "ACME/content/sub"); - } - - [Fact] - public async Task Search_NoNamespace_AnchoredAncestorWalk() - { - var result = await Anchored("beta", anchor: "ACME/content/sub"); - - result.Scope.Should().Be(ContentSearchScope.AncestorsAndSelf); - // Ancestor walk of ACME/content/sub = {ACME/content/sub, ACME/content, ACME} — never OTHER. - Collections(result).Should().NotContain("OTHER/content"); - Collections(result).Should().Contain("ACME/content/sub"); - } - - [Fact] - public async Task ToJson_HasCountAndResults() - { - var result = await Search("namespace:ACME/content alpha"); - - var json = ContentChunkSearch.ToJson(result); - json.Should().Contain("\"count\":"); - json.Should().Contain("\"results\":"); - json.Should().Contain("\"chunkIndex\":"); - } - - [Fact] - public async Task ToJson_IncludesPageAndBbox_WhenChunkCarriesProvenance() - { - var pos = new ChunkPosition(0.1, 0.2, 0.3, 0.05); - _store.ReplaceFileChunks("PROV/content", "p.pdf", new[] - { - new ContentChunk("PROV/content", "p.pdf", 0, "provenance carrying chunk", - ContentHash: "h", Embedding: _embedder.Embed("provenance carrying chunk").Wait(), - Metadata: null, Page: 3, Position: pos), - }).Wait(); - - var result = await Search("namespace:PROV/content provenance"); - - var hit = result.Hits.Should().ContainSingle().Subject; - hit.Page.Should().Be(3); - hit.Position.Should().Be(pos); - - var json = ContentChunkSearch.ToJson(result); - json.Should().Contain("\"page\":3"); - json.Should().Contain("\"bbox\":"); - json.Should().Contain("\"x\":0.1"); - } - - /// - /// 🚨 #2741 — a sweep that cannot fail is not a sweep. - /// - /// AGENTS.md requires a live-mesh search_chunks sweep before deleting any public - /// framework surface, because the mesh holds callers the repo has already dropped and no - /// compiler can see them. On a deployment with no embedding provider that sweep answered - /// {"count":0,"results":[]} — byte-identical to "I searched and found no callers" — so an - /// agent following the prescribed procedure reads it as permission to delete. Measured on BOTH - /// reachable portals on 2026-08-30. - /// - /// The envelope now carries no count at all when nothing was searched, which is the - /// point: a consumer testing count == 0 finds the field ABSENT rather than finding a zero - /// that means the opposite of what it looks like. - /// - [Fact] - public async Task ToJson_WhenIndexingIsOff_CarriesNoCount_SoZeroCannotBeReadAsNoResults() - { - var off = "Content indexing is not active on this deployment."; - var result = await ContentChunkSearch - .Search(new InertChunkedContentVectorStore(off), new InertChunkEmbedder(off), - "namespace:ACME/content alpha", anchorPath: null, limit: 50) - .FirstAsync(); - - result.Searched.Should().BeFalse("nothing was embedded and no collection was read"); - - var json = ContentChunkSearch.ToJson(result); - json.Should().NotContain("\"count\":", - "a count of 0 for a search that never ran is the false pass this test exists to prevent"); - json.Should().NotContain("\"results\":"); - json.Should().Contain("\"searched\":false"); - json.Should().Contain(ContentChunkSearch.NotSearchedError); - json.Should().Contain(off, "the envelope must still say WHAT to configure, not merely that it did not search"); - } - - /// - /// The same rule for the other way a search fails to run: no query text. "You gave me nothing to - /// search for" and "I searched and found nothing" are different answers and must not share an - /// envelope either. - /// - [Fact] - public async Task ToJson_WhenTheQueryCarriesNoText_CarriesNoCount() - { - var result = await Search("namespace:ACME/content"); - - result.Searched.Should().BeFalse(); - ContentChunkSearch.ToJson(result).Should().NotContain("\"count\":"); - } - - /// - /// The control, and the half that keeps the guard honest: a search that DID run still reports its - /// count, including the genuine zero. Without this, "omit the count" could quietly become "omit - /// the count whenever it is zero", which would hide a real empty result. - /// - [Fact] - public async Task ToJson_WhenASearchRanAndMatchedNothing_StillReportsZero() - { - var result = await Search("namespace:EMPTY/collection alpha"); - - result.Searched.Should().BeTrue(); - var json = ContentChunkSearch.ToJson(result); - json.Should().Contain("\"searched\":true"); - json.Should().Contain("\"count\":0"); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentIndexingServiceTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/ContentIndexingServiceTest.cs deleted file mode 100644 index 282f968720..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentIndexingServiceTest.cs +++ /dev/null @@ -1,192 +0,0 @@ -using System.Reactive.Linq; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Mesh.Threading; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// The "run indexing" end-to-end test: real files on disk → bytes → IndexFile against the -/// in-memory store + fake embedder + the unbounded I/O pool. Exercises the hash gate -/// (index → skip-unchanged → re-index-on-change) and that chunks are stored with embeddings and -/// the whole-file hash. -/// -public class ContentIndexingServiceTest : IDisposable -{ - private const string Collection = "rbuergi/MyContent"; - - private readonly string _tempDir; - private readonly InMemoryChunkedContentVectorStore _store = new(); - private readonly FakeEmbedder _embedder = new(); - private readonly ContentIndexingService _service; - - public ContentIndexingServiceTest() - { - _tempDir = Path.Combine(Path.GetTempPath(), "mw-indexing-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - - _service = new ContentIndexingService( - new TextExtractor(IoPool.Unbounded), - _embedder, - _store, - // Small windows so the multi-paragraph .txt produces SEVERAL chunks (exercises the - // bounded-merge embed path + ordered reassembly), not just one. - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }); - } - - private string WriteFile(string name, string content) - { - var path = Path.Combine(_tempDir, name); - File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - return path; - } - - private async Task Index(string filePath, string fileName, bool force = false) => - await _service.IndexFile(Collection, filePath, fileName, File.ReadAllBytes(filePath), force) - .FirstAsync().Await(); - - private async Task> Search(string text, int topK) => - await _store.Search(Collection, await _embedder.Embed(text).FirstAsync().Await(), topK) - .FirstAsync().Await(); - - [Fact] - public async Task IndexFile_ProducesChunksWithEmbeddingsAndHash() - { - var body = string.Concat(Enumerable.Repeat( - "MeshWeaver indexes content files into overlapping chunks. " + - "Each chunk is embedded into a vector for similarity search. ", 6)); - var filePath = WriteFile("notes.txt", body); - - var result = await Index(filePath, "notes.txt"); - - result.Status.Should().Be(IndexStatus.Indexed); - result.ChunkCount.Should().BeGreaterThan(1); - - // Every indexed chunk has an embedding of the right dimensionality and the whole-file hash. - var hits = await Search("similarity search vector", topK: 100); - hits.Should().NotBeEmpty(); - hits.Should().HaveCount(result.ChunkCount); - hits.Should().OnlyContain(c => c.Embedding != null && c.Embedding.Length == _embedder.Dimensions); - hits.Should().OnlyContain(c => c.CollectionPath == Collection && c.FilePath == filePath); - - // The recorded file hash matches the bytes and is identical on every chunk. - var expectedHash = await _store.GetFileHash(Collection, filePath).FirstAsync().Await(); - expectedHash.Should().NotBeNull(); - hits.Should().OnlyContain(c => c.ContentHash == expectedHash); - } - - [Fact] - public async Task SecondIndex_SameBytes_IsSkippedByHashGate() - { - var filePath = WriteFile("doc.md", "# Title\n\nBody about chunking and embeddings.\n"); - - var first = await Index(filePath, "doc.md"); - first.Status.Should().Be(IndexStatus.Indexed); - - // Re-index identical bytes -> hash gate short-circuits, no re-extract/re-embed. - var second = await Index(filePath, "doc.md"); - second.Status.Should().Be(IndexStatus.Skipped); - second.ChunkCount.Should().Be(0); - } - - [Fact] - public async Task ModifiedBytes_AreReIndexed() - { - var filePath = WriteFile("changing.txt", "Original content about pensions."); - (await Index(filePath, "changing.txt")).Status.Should().Be(IndexStatus.Indexed); - (await Index(filePath, "changing.txt")).Status.Should().Be(IndexStatus.Skipped); - - // Mutate the file on disk -> different SHA-256 -> re-indexed. - File.WriteAllText(filePath, "Completely different content about insurance and risk modelling."); - var reindexed = await Index(filePath, "changing.txt"); - reindexed.Status.Should().Be(IndexStatus.Indexed); - reindexed.ChunkCount.Should().BeGreaterThan(0); - - // The store reflects the NEW content (old chunks were replaced, not appended). - var hash = await _store.GetFileHash(Collection, filePath).FirstAsync().Await(); - var hits = await Search("insurance risk", topK: 100); - hits.Should().OnlyContain(c => c.ContentHash == hash); - hits.Should().Contain(c => c.Text.Contains("insurance")); - hits.Should().NotContain(c => c.Text.Contains("pensions")); - } - - [Fact] - public async Task PdfFile_IndexesEndToEnd() - { - var bytes = PdfTestFixtures.OnePagePdf( - "Quarterly report", - "Revenue grew across all regions this quarter."); - var filePath = Path.Combine(_tempDir, "report.pdf"); - File.WriteAllBytes(filePath, bytes); - - var result = await Index(filePath, "report.pdf"); - - result.Status.Should().Be(IndexStatus.Indexed); - result.ChunkCount.Should().BeGreaterThan(0); - - var hits = await Search("revenue regions", topK: 10); - hits.Should().NotBeEmpty(); - hits.Should().Contain(c => c.Text.Contains("Revenue")); - } - - [Fact] - public async Task PdfFile_ChunksCarryPageAndPosition() - { - var bytes = PdfTestFixtures.OnePagePdf( - "Quarterly report", - "Revenue grew across all regions this quarter."); - var filePath = Path.Combine(_tempDir, "prov.pdf"); - File.WriteAllBytes(filePath, bytes); - - (await Index(filePath, "prov.pdf")).Status.Should().Be(IndexStatus.Indexed); - - var hits = await Search("revenue regions", topK: 50); - hits.Should().NotBeEmpty(); - // Every chunk of a paged PDF carries its source page + on-page box (provenance for open+mark). - hits.Should().OnlyContain(c => c.Page == 1); - hits.Should().OnlyContain(c => c.Position != null); - } - - [Fact] - public async Task ForceReindex_BypassesHashGate_AndReExtractsProvenance() - { - var bytes = PdfTestFixtures.OnePagePdf( - "Balance sheet 2024", - "Total assets grew year over year."); - var filePath = Path.Combine(_tempDir, "backfill.pdf"); - File.WriteAllBytes(filePath, bytes); - - (await Index(filePath, "backfill.pdf")).Status.Should().Be(IndexStatus.Indexed); - // Unchanged bytes → a normal re-index is skipped by the hash gate. - (await Index(filePath, "backfill.pdf")).Status.Should().Be(IndexStatus.Skipped); - // force → re-extracted + re-stored even though the content is identical — the backfill path. - var forced = await Index(filePath, "backfill.pdf", force: true); - forced.Status.Should().Be(IndexStatus.Indexed); - forced.ChunkCount.Should().BeGreaterThan(0); - - (await Search("total assets", topK: 50)) - .Should().OnlyContain(c => c.Page == 1 && c.Position != null); - } - - [Fact] - public async Task BinaryFile_YieldsNoText() - { - var filePath = Path.Combine(_tempDir, "image.bin"); - File.WriteAllBytes(filePath, new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01 }); - - var result = await Index(filePath, "image.bin"); - - result.Status.Should().Be(IndexStatus.NoText); - result.ChunkCount.Should().Be(0); - (await _store.Search(Collection, new float[_embedder.Dimensions], 10).FirstAsync().Await()) - .Should().BeEmpty(); - } - - public void Dispose() - { - try { Directory.Delete(_tempDir, recursive: true); } - catch { /* best-effort temp cleanup */ } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentSearchHitContractTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/ContentSearchHitContractTest.cs deleted file mode 100644 index 9b2f762b51..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/ContentSearchHitContractTest.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Reactive.Linq; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Pins the data contract the global-search "In documents" rows rely on: every -/// the engine returns carries the coordinates needed to deep-link into the Document Blocks reader -/// ( + ) and a non-empty -/// that actually contains the searched term (so the row can highlight -/// the matched passage). Pure unit test over the in-memory store + the deterministic FakeEmbedder. -/// -public class ContentSearchHitContractTest -{ - private readonly FakeEmbedder _embedder = new(); - private readonly InMemoryChunkedContentVectorStore _store = new(); - - public ContentSearchHitContractTest() - { - Seed("ACME/content", "reports/annual.txt", "alpha apple annual report figures"); - Seed("ACME/content/sub", "b.txt", "beta banana benefit obligation"); - } - - private void Seed(string collection, string file, string text) => - _store.ReplaceFileChunks(collection, file, new[] - { - new ContentChunk(collection, file, 0, text, "h-" + file, _embedder.Embed(text).Wait()), - }).Wait(); - - private async Task Search(string query) => - await ContentChunkSearch.SearchContent(_store, _embedder, query, 50).FirstAsync().Await(); - - [Fact] - public async Task Hit_DocumentPath_RoundTripsToDocumentPathsFor() - { - var result = await Search("namespace:ACME/content scope:subtree alpha apple"); - - result.Hits.Should().NotBeEmpty(); - foreach (var hit in result.Hits) - hit.DocumentPath.Should().Be(DocumentPaths.For(hit.CollectionPath, hit.FilePath)); - } - - [Fact] - public async Task Hit_Snippet_IsPresentAndContainsTheTermForHighlighting() - { - var result = await Search("namespace:ACME/content scope:subtree alpha"); - - var hit = result.Hits.Single(h => h.FilePath == "reports/annual.txt"); - hit.Snippet.Should().NotBeNullOrWhiteSpace(); - hit.Snippet.Should().Contain("alpha"); - // The row deep-links with the chunk index — it must be the real 0-based position. - hit.ChunkIndex.Should().Be(0); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/DocumentSummaryIndexingTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/DocumentSummaryIndexingTest.cs deleted file mode 100644 index a385495ef3..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/DocumentSummaryIndexingTest.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Mesh.Threading; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// STEP 3 coverage: a changed file, indexed with both an and an -/// wired, writes ONE per-file (summary + -/// file name + content hash + chunk count == stored chunk count) AND stores the chunks; the hash -/// gate guards BOTH branches — an unchanged re-index re-writes neither the summary nor the -/// document, and a modified file re-runs both. -/// -public class DocumentSummaryIndexingTest : IDisposable -{ - private const string Collection = "rbuergi/MyContent"; - - private readonly string _tempDir; - private readonly InMemoryChunkedContentVectorStore _store = new(); - private readonly FakeEmbedder _embedder = new(); - private readonly FakeSummarizer _summarizer = new(); - private readonly FakeDocumentSink _sink = new(); - private readonly ContentIndexingService _service; - - public DocumentSummaryIndexingTest() - { - _tempDir = Path.Combine(Path.GetTempPath(), "mw-doc-indexing-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - - _service = new ContentIndexingService( - new TextExtractor(IoPool.Unbounded), - _embedder, - _store, - // Small windows so multi-paragraph text yields several chunks (proves ONE summary per - // document, not one per chunk). - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }, - logger: null, - summarizer: _summarizer, - documentSink: _sink); - } - - private string WriteFile(string name, string content) - { - var path = Path.Combine(_tempDir, name); - File.WriteAllText(path, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - return path; - } - - private async Task Index(string filePath, string fileName) => - await _service.IndexFile(Collection, filePath, fileName, File.ReadAllBytes(filePath)) - .FirstAsync().Await(); - - private static string ExtractedText(string filePath) => - File.ReadAllText(filePath, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - - private static string Sha256Hex(byte[] bytes) => Convert.ToHexStringLower(SHA256.HashData(bytes)); - - [Fact] - public async Task ChangedFile_WritesDocumentWithSummaryAndChunks() - { - var body = string.Concat(Enumerable.Repeat( - "MeshWeaver indexes content files into overlapping chunks. " + - "Each chunk is embedded into a vector for similarity search. ", 6)); - var bytes = Encoding.UTF8.GetBytes(body); - var filePath = WriteFile("notes.txt", body); - - var result = await Index(filePath, "notes.txt"); - - result.Status.Should().Be(IndexStatus.Indexed); - result.ChunkCount.Should().BeGreaterThan(1); - - // Chunks were stored (the chunk branch ran alongside the document branch). - var stored = await _store.Search(Collection, await _embedder.Embed("vector").FirstAsync().Await(), 1000) - .FirstAsync().Await(); - stored.Should().HaveCount(result.ChunkCount); - - // Exactly ONE summary + ONE document write for the whole file (never per chunk). - _summarizer.Calls.Should().Be(1); - _sink.Writes.Should().Be(1); - - // The document carries the expected summary, file name, whole-file hash, and the chunk count - // == the number of chunks actually stored. - var doc = _sink.LastDocument; - doc.Should().NotBeNull(); - doc!.FileName.Should().Be("notes.txt"); - doc.CollectionPath.Should().Be(Collection); - doc.FilePath.Should().Be(filePath); - doc.Summary.Should().Be(FakeSummarizer.Expected(ExtractedText(filePath))); - doc.ContentHash.Should().Be(Sha256Hex(bytes)); - doc.ChunkCount.Should().Be(result.ChunkCount); - doc.ChunkCount.Should().Be(stored.Count); - doc.SizeBytes.Should().Be(bytes.Length); - } - - [Fact] - public async Task SecondIndex_SameBytes_WritesNeitherSummaryNorDocument() - { - var filePath = WriteFile("doc.md", "# Title\n\nBody about chunking and embeddings.\n"); - - var first = await Index(filePath, "doc.md"); - first.Status.Should().Be(IndexStatus.Indexed); - _summarizer.Calls.Should().Be(1); - _sink.Writes.Should().Be(1); - - // Re-index identical bytes -> hash gate short-circuits BEFORE extract/summarize/document. - var second = await Index(filePath, "doc.md"); - second.Status.Should().Be(IndexStatus.Skipped); - second.ChunkCount.Should().Be(0); - - // Neither the summarizer nor the sink ran a second time. - _summarizer.Calls.Should().Be(1); - _sink.Writes.Should().Be(1); - } - - [Fact] - public async Task Skip_WithMissingDocument_HealsTheDocument() - { - const string body = "Chunked before the document branch existed; the document is missing."; - var filePath = WriteFile("legacy.txt", body); - - (await Index(filePath, "legacy.txt")).Status.Should().Be(IndexStatus.Indexed); - _sink.Writes.Should().Be(1); - - // Simulate the legacy state: chunks + hash stored, but no Document (the file was indexed - // by a build without the document branch, or the Document write failed). Without the heal - // the hash gate skips this file FOREVER and its search hits land on a Not Found node. - _sink.Forget(Collection, filePath); - - var second = await Index(filePath, "legacy.txt"); - second.Status.Should().Be(IndexStatus.Skipped); - - // The heal ran the document branch exactly once more; the chunk store was untouched. - _summarizer.Calls.Should().Be(2); - _sink.Writes.Should().Be(2); - var doc = _sink.LastDocument!; - doc.FilePath.Should().Be(filePath); - doc.ContentHash.Should().Be(Sha256Hex(Encoding.UTF8.GetBytes(body))); - doc.ChunkCount.Should().BeGreaterThan(0); - doc.Summary.Should().Be(FakeSummarizer.Expected(body)); - } - - [Fact] - public async Task ModifiedBytes_ReRunBothSummaryAndDocument() - { - var filePath = WriteFile("changing.txt", "Original content about pensions."); - (await Index(filePath, "changing.txt")).Status.Should().Be(IndexStatus.Indexed); - (await Index(filePath, "changing.txt")).Status.Should().Be(IndexStatus.Skipped); - - _summarizer.Calls.Should().Be(1); - _sink.Writes.Should().Be(1); - _sink.LastDocument!.Summary.Should().Contain("pensions"); - - // Mutate the file -> different hash -> both branches re-run. "insurance" sits within the - // first chars so the deterministic head-of-text summary contains it. - var newBody = "Insurance and risk modelling content, entirely different from before."; - File.WriteAllText(filePath, newBody, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - var reindexed = await Index(filePath, "changing.txt"); - reindexed.Status.Should().Be(IndexStatus.Indexed); - reindexed.ChunkCount.Should().BeGreaterThan(0); - - _summarizer.Calls.Should().Be(2); - _sink.Writes.Should().Be(2); - - // The re-written document reflects the NEW content + the new hash + the new chunk count. - var doc = _sink.LastDocument!; - doc.Summary.Should().Be(FakeSummarizer.Expected(newBody)); - doc.Summary.Should().Contain("Insurance"); - doc.ContentHash.Should().Be(Sha256Hex(Encoding.UTF8.GetBytes(newBody))); - doc.ChunkCount.Should().Be(reindexed.ChunkCount); - } - - public void Dispose() - { - try { Directory.Delete(_tempDir, recursive: true); } - catch { /* best-effort temp cleanup */ } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeDocumentSink.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/FakeDocumentSink.cs deleted file mode 100644 index 4ee5b02da1..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeDocumentSink.cs +++ /dev/null @@ -1,42 +0,0 @@ -using System.Reactive; -using System.Reactive.Linq; -using MeshWeaver.ContentCollections.Indexing; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// In-process for tests: records the last -/// written and counts writes, so a test can assert WHAT was written (summary, file name, hash, -/// chunk count) and that a write happened exactly once on change / not at all when skipped. -/// -public sealed class FakeDocumentSink : IDocumentSink -{ - private int _writes; - private readonly System.Collections.Concurrent.ConcurrentDictionary<(string Collection, string File), DocumentInfo> _documents = new(); - - /// The most recent handed to , or null if none. - public DocumentInfo? LastDocument { get; private set; } - - /// Number of times was invoked (subscribed). - public int Writes => Volatile.Read(ref _writes); - - public IObservable WriteDocument(DocumentInfo doc) => - Observable.Defer(() => - { - LastDocument = doc; - _documents[(doc.CollectionPath, doc.FilePath)] = doc; - Interlocked.Increment(ref _writes); - return Observable.Return(Unit.Default); - }); - - public IObservable DocumentExists(string collectionPath, string filePath) => - Observable.Defer(() => Observable.Return(_documents.ContainsKey((collectionPath, filePath)))); - - /// - /// Drops the recorded document for (collectionPath, filePath) — simulates a file whose - /// chunks were indexed before the document branch existed (or whose Document write failed), so - /// tests can assert the hash-gate HEAL re-creates it. - /// - public void Forget(string collectionPath, string filePath) => - _documents.TryRemove((collectionPath, filePath), out _); -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeEmbedder.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/FakeEmbedder.cs deleted file mode 100644 index eb172a1488..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeEmbedder.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Reactive.Linq; -using System.Security.Cryptography; -using MeshWeaver.ContentCollections.Indexing; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Deterministic in-process for tests. The vector is derived -/// from the SHA-256 of the text, so it is stable across runs and requires no network — yet -/// distinct texts get distinct (and L2-comparable) vectors, which is enough for the cosine -/// search assertions. -/// -public sealed class FakeEmbedder : IChunkEmbedder -{ - public int Dimensions => 8; - - public IObservable Embed(string text) => - Observable.Defer(() => Observable.Return(Vectorize(text))); - - private float[] Vectorize(string text) - { - var digest = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)); - var vector = new float[Dimensions]; - for (var i = 0; i < Dimensions; i++) - // Map each of the first Dimensions hash bytes into [-1, 1]. - vector[i] = (digest[i] / 255f) * 2f - 1f; - return vector; - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeImageDescriber.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/FakeImageDescriber.cs deleted file mode 100644 index 0ead6d3ded..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeImageDescriber.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.ContentCollections.Indexing; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Deterministic in-process for tests: "describes" any image by echoing -/// a fixed sentence that embeds the file name, so a test can assert the exact description that flowed -/// into the chunk store and the Document summary. Counts calls so a test can prove the describer ran -/// (once) for an image and the text summarizer did NOT. No network, no AI dependency. -/// -public sealed class FakeImageDescriber : IImageDescriber -{ - private int _calls; - - /// Number of times was invoked (subscribed). - public int Calls => Volatile.Read(ref _calls); - - /// Handles the common raster image extensions. - public IReadOnlyCollection SupportedExtensions { get; } = - new HashSet(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".webp" }; - - public IObservable Describe(byte[] imageBytes, string fileName) => - Observable.Defer(() => - { - Interlocked.Increment(ref _calls); - return Observable.Return(Expected(fileName)); - }); - - /// The exact description this fake produces for . - public static string Expected(string fileName) => - $"A chart titled quarterly revenue by region, from {fileName}."; -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeSummarizer.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/FakeSummarizer.cs deleted file mode 100644 index b676f817c4..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/FakeSummarizer.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Reactive.Linq; -using MeshWeaver.ContentCollections.Indexing; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Deterministic in-process for tests: the "summary" is the literal -/// prefix "SUMMARY: " followed by the first characters of the text. -/// No network, no AI dependency — stable across runs, and lets a test assert the exact summary -/// that should have been written to the document. Also counts calls so a test can prove the -/// summarizer ran ONCE per document (never per chunk) and not at all when the file is skipped. -/// -public sealed class FakeSummarizer : ISummarizer -{ - private const int HeadChars = 40; - - private int _calls; - - /// Number of times was invoked (subscribed). - public int Calls => Volatile.Read(ref _calls); - - public IObservable Summarize(string text, string fileName) => - Observable.Defer(() => - { - Interlocked.Increment(ref _calls); - return Observable.Return(Expected(text)); - }); - - /// The exact summary string this fake produces for . - public static string Expected(string text) => - "SUMMARY: " + (text.Length <= HeadChars ? text : text[..HeadChars]); -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/ImageDescriptionIndexingTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/ImageDescriptionIndexingTest.cs deleted file mode 100644 index 530bfe3a4f..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/ImageDescriptionIndexingTest.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System.Reactive.Linq; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Mesh.Threading; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Image indexing: with an wired, an image file (which has NO extractable -/// text) is captioned by the describer, and that description flows through the SAME pipeline as text — -/// it is embedded into the chunk store (so the image becomes vector-searchable) AND written verbatim as -/// the file's (ONE describe call, no re-summarize). With no describer, -/// the same image indexes as NoText (today's behavior). -/// -public class ImageDescriptionIndexingTest : IDisposable -{ - private const string Collection = "rbuergi/MyContent"; - - private readonly string _tempDir; - private readonly InMemoryChunkedContentVectorStore _store = new(); - private readonly FakeEmbedder _embedder = new(); - private readonly FakeSummarizer _summarizer = new(); - private readonly FakeDocumentSink _sink = new(); - private readonly FakeImageDescriber _describer = new(); - - public ImageDescriptionIndexingTest() - { - _tempDir = Path.Combine(Path.GetTempPath(), "mw-image-indexing-test-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(_tempDir); - } - - private ContentIndexingService Service(IImageDescriber? describer) => - new( - new TextExtractor(IoPool.Unbounded), - _embedder, - _store, - new ContentIndexingOptions { ChunkSize = 120, ChunkOverlap = 20 }, - logger: null, - summarizer: _summarizer, - documentSink: _sink, - imageDescriber: describer); - - private string WriteImage(string name) - { - // Content need not be a valid image: the describer is faked and the text extractor is never - // reached for an image extension when a describer is wired. - var path = Path.Combine(_tempDir, name); - File.WriteAllBytes(path, Encoding.UTF8.GetBytes("‰PNG\r\n\n binary image bytes")); - return path; - } - - private async Task Index(ContentIndexingService service, string filePath, string fileName) => - await service.IndexFile(Collection, filePath, fileName, File.ReadAllBytes(filePath)) - .FirstAsync().Await(); - - [Fact] - public async Task Image_WithDescriber_EmbedsDescriptionAndWritesDocumentSummary() - { - var filePath = WriteImage("chart.png"); - - var result = await Index(Service(_describer), filePath, "chart.png"); - - // Indexed with at least one chunk — the description became embeddable text. - result.Status.Should().Be(IndexStatus.Indexed); - result.ChunkCount.Should().BeGreaterThan(0); - - // The describer ran once; the TEXT summarizer did NOT (the description is the summary verbatim). - _describer.Calls.Should().Be(1); - _summarizer.Calls.Should().Be(0); - - // The stored chunk carries the description → the image is now vector-searchable. - var stored = await _store.Search(Collection, await _embedder.Embed("chart").FirstAsync().Await(), 1000) - .FirstAsync().Await(); - stored.Should().HaveCount(result.ChunkCount); - stored.Should().Contain(c => c.Text.Contains("quarterly revenue")); - - // The Document summary is the description, used verbatim. - _sink.Writes.Should().Be(1); - var doc = _sink.LastDocument; - doc.Should().NotBeNull(); - doc!.FileName.Should().Be("chart.png"); - doc.Summary.Should().Be(FakeImageDescriber.Expected("chart.png")); - doc.ChunkCount.Should().Be(result.ChunkCount); - } - - [Fact] - public async Task Image_WithoutDescriber_IndexesAsNoText() - { - var filePath = WriteImage("photo.png"); - - var result = await Index(Service(describer: null), filePath, "photo.png"); - - // No describer → the text extractor yields empty for a .png → NoText, no chunks, no Document. - result.Status.Should().Be(IndexStatus.NoText); - result.ChunkCount.Should().Be(0); - _describer.Calls.Should().Be(0); - _summarizer.Calls.Should().Be(0); - _sink.Writes.Should().Be(0); - } - - [Fact] - public async Task NonImage_WithDescriber_StillUsesTextExtractorAndSummarizer() - { - // A describer being wired must NOT hijack a text file: .txt goes through the extractor + the - // text summarizer exactly as before. - var path = Path.Combine(_tempDir, "notes.txt"); - File.WriteAllText(path, "Plain text about pensions and risk.", new UTF8Encoding(false)); - - var result = await Index(Service(_describer), path, "notes.txt"); - - result.Status.Should().Be(IndexStatus.Indexed); - _describer.Calls.Should().Be(0); - _summarizer.Calls.Should().Be(1); - _sink.LastDocument!.Summary.Should().Be(FakeSummarizer.Expected("Plain text about pensions and risk.")); - } - - public void Dispose() - { - try { Directory.Delete(_tempDir, recursive: true); } - catch { /* best-effort temp cleanup */ } - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/InertContentIndexTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/InertContentIndexTest.cs deleted file mode 100644 index 9e81d4c4e3..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/InertContentIndexTest.cs +++ /dev/null @@ -1,99 +0,0 @@ -using System.Reactive.Linq; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// The "content indexing is switched off" contract at the engine level (#1642). A deployment can have the -/// pipeline WIRED and still not be CONFIGURED for it; the services then resolve -/// stand-ins rather than the concrete store/embedder, and every search -/// surface must answer the documented {count:0, message:…} envelope carrying the stand-in's reason — -/// never an exception, and never a bare "no results" that reads as missing data. -/// -public class InertContentIndexTest -{ - private const string Reason = "Content indexing is not active on this deployment: configure Embedding:Endpoint."; - - private readonly InertChunkedContentVectorStore _store = new(Reason); - private readonly InertChunkEmbedder _embedder = new(Reason); - private readonly FakeEmbedder _liveEmbedder = new(); - private readonly InMemoryChunkedContentVectorStore _liveStore = new(); - - private static async Task Anchored( - IChunkedContentVectorStore? store, IChunkEmbedder? embedder) => - await ContentChunkSearch.Search(store, embedder, "benefit obligation", "ACME/content", 20) - .FirstAsync().Await(); - - private static async Task Grammar( - IChunkedContentVectorStore? store, IChunkEmbedder? embedder) => - await ContentChunkSearch.Search( - store, embedder, "namespace:ACME/content scope:subtree benefit obligation", null, 20) - .FirstAsync().Await(); - - [Fact] - public void IsOff_TrueForNullAndForInert_FalseOnlyWhenBothAreLive() - { - Assert.True(ContentIndexAvailability.IsOff(null, null)); - Assert.True(ContentIndexAvailability.IsOff(_store, _liveEmbedder)); - Assert.True(ContentIndexAvailability.IsOff(_liveStore, _embedder)); - Assert.False(ContentIndexAvailability.IsOff(_liveStore, _liveEmbedder)); - } - - [Fact] - public void ReasonOff_PrefersTheStandIns_Reason_OverTheGenericFallback() - { - Assert.Equal(Reason, ContentIndexAvailability.ReasonOff(_store, _liveEmbedder)); - Assert.Equal(Reason, ContentIndexAvailability.ReasonOff(_liveStore, _embedder)); - // Nothing registered at all (the pipeline was never wired) has no deployment-specific reason. - Assert.Equal(ContentIndexAvailability.NotEnabledMessage, - ContentIndexAvailability.ReasonOff(null, null)); - } - - [Fact] - public async Task Search_WithInertServices_ReturnsTheReason_InBothQueryShapes() - { - foreach (var result in new[] { await Anchored(_store, _embedder), await Grammar(_store, _embedder) }) - { - Assert.Empty(result.Hits); - Assert.Equal(Reason, result.Message); - // The tool envelope the MCP/agent surface actually returns. - // - // 🚨 It carries NO `count` (MeshWeaver#2741). This assertion used to require - // `"count":0`, and that was the defect rather than the contract: `count: 0` is - // byte-identical to "I searched and found nothing", and `count` is the field a caller - // reads. AGENTS.md requires a live-mesh `search_chunks` sweep before deleting any public - // framework surface, so on a deployment with no embedding provider the prescribed safety - // step returned a PASS having verified nothing. Omitting the field is what makes the - // difference impossible to miss. - var json = ContentChunkSearch.ToJson(result); - Assert.False(result.Searched); - Assert.DoesNotContain("\"count\":", json); - Assert.Contains("\"searched\":false", json); - Assert.Contains(ContentChunkSearch.NotSearchedError, json); - Assert.Contains(Reason, json); - } - } - - [Fact] - public async Task Search_WithInertServices_NeverEmbeds() - { - // The inert embedder faults if asked to embed — reaching it would mean the availability check was - // skipped and a meaningless zero vector got ranked. Both query shapes must settle before that. - Assert.NotNull((await Anchored(_liveStore, _embedder)).Message); - Assert.NotNull((await Grammar(_liveStore, _embedder)).Message); - } - - [Fact] - public async Task InertStore_AnswersEveryReadEmptily_AndDropsWrites() - { - Assert.Null(await _store.GetFileHash("ACME/content", "a.txt").FirstAsync().Await()); - Assert.Equal(0, await _store.GetChunkCount("ACME/content", "a.txt").FirstAsync().Await()); - Assert.Null(await _store.GetChunk("ACME/content", "a.txt", 0).FirstAsync().Await()); - Assert.Empty(await _store.Search("ACME/content", [1f], 5).FirstAsync().Await()); - Assert.Empty(await _store.SearchSubtree("ACME", [1f], 5).FirstAsync().Await()); - // A write completes (uploads proceed) and stores nothing. - await _store.ReplaceFileChunks("ACME/content", "a.txt", []).FirstAsync().Await(); - Assert.Equal(0, await _store.GetChunkCount("ACME/content", "a.txt").FirstAsync().Await()); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/MeshWeaver.ContentCollections.Indexing.Test.csproj b/test/MeshWeaver.ContentCollections.Indexing.Test/MeshWeaver.ContentCollections.Indexing.Test.csproj deleted file mode 100644 index c373cb4ce2..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/MeshWeaver.ContentCollections.Indexing.Test.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/PdfTestFixtures.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/PdfTestFixtures.cs deleted file mode 100644 index 3a2936d96c..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/PdfTestFixtures.cs +++ /dev/null @@ -1,43 +0,0 @@ -using UglyToad.PdfPig.Core; -using UglyToad.PdfPig.Fonts.Standard14Fonts; -using UglyToad.PdfPig.Writer; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -/// -/// Generates tiny single-page PDFs at test time with PdfPig's , -/// so the extractor tests run against a real PDF byte stream without checking a binary into the repo. -/// -internal static class PdfTestFixtures -{ - /// Builds a one-page PDF containing each line of as PDF text. - public static byte[] OnePagePdf(params string[] lines) - { - var builder = new PdfDocumentBuilder(); - var font = builder.AddStandard14Font(Standard14Font.Helvetica); - var page = builder.AddPage(595, 842); // A4 in points. - - var y = 800; - foreach (var line in lines) - { - page.AddText(line, 12, new PdfPoint(50, y), font); - y -= 20; - } - - return builder.Build(); - } - - /// Builds a multi-page PDF; each element of is one page's single line. - public static byte[] MultiPagePdf(params string[] pages) - { - var builder = new PdfDocumentBuilder(); - var font = builder.AddStandard14Font(Standard14Font.Helvetica); - foreach (var line in pages) - { - var page = builder.AddPage(595, 842); - page.AddText(line, 12, new PdfPoint(50, 800), font); - } - - return builder.Build(); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/TextChunkerTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/TextChunkerTest.cs deleted file mode 100644 index 08aebd835a..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/TextChunkerTest.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Collections.Immutable; -using MeshWeaver.ContentCollections.Indexing; -using Xunit; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -public class TextChunkerTest -{ - // ── ChunkPositioned: page/position attribution ─────────────────────────── - - [Fact] - public void ChunkPositioned_PlainDocument_HasNullPageAndPosition() - { - var doc = ExtractedDocument.PlainText(new string('x', 250)); - var chunks = TextChunker.ChunkPositioned(doc, 100, 10); - - chunks.Should().HaveCountGreaterThan(1); - chunks.Should().OnlyContain(c => c.Page == null && c.Position == null); - // The text windows match the plain Chunk() windows exactly. - chunks.Select(c => c.Text).Should().Equal(TextChunker.Chunk(doc.Text, 100, 10)); - } - - [Fact] - public void ChunkPositioned_AttributesToStartPage_AndUnionsBoxesOnThatPage() - { - // "AAAA BBBB CCCC" — AAAA/BBBB on page 1, CCCC on page 2. - var text = "AAAA BBBB CCCC"; - var spans = ImmutableArray.Create( - new PositionedSpan(0, 4, 1, new ChunkPosition(0.10, 0.10, 0.20, 0.05)), - new PositionedSpan(5, 4, 1, new ChunkPosition(0.10, 0.20, 0.20, 0.05)), - new PositionedSpan(10, 4, 2, new ChunkPosition(0.10, 0.10, 0.20, 0.05))); - var doc = new ExtractedDocument(text, spans); - - // One window over the whole text: attributed to page 1 (where it begins); box unions the two - // page-1 words (AAAA + BBBB) and EXCLUDES the page-2 word. - var single = TextChunker.ChunkPositioned(doc, 100, 0); - single.Should().ContainSingle(); - single[0].Page.Should().Be(1); - single[0].Position!.X.Should().BeApproximately(0.10, 1e-9); - single[0].Position!.Y.Should().BeApproximately(0.10, 1e-9); - single[0].Position!.Width.Should().BeApproximately(0.20, 1e-9); - single[0].Position!.Height.Should().BeApproximately(0.15, 1e-9); // 0.10..0.25 - } - - [Fact] - public void ChunkPositioned_ChunkStartingOnLaterPage_TakesThatPage() - { - var text = "AAAA BBBB CCCC"; // len 14 - var spans = ImmutableArray.Create( - new PositionedSpan(0, 4, 1, new ChunkPosition(0.10, 0.10, 0.20, 0.05)), - new PositionedSpan(5, 4, 1, new ChunkPosition(0.10, 0.20, 0.20, 0.05)), - new PositionedSpan(10, 4, 2, new ChunkPosition(0.30, 0.40, 0.20, 0.05))); - var doc = new ExtractedDocument(text, spans); - - // size 9, overlap 0 → windows [0,9) and [9,14). The first is page 1 (AAAA+BBBB), the second is - // page 2 (CCCC only) — proving per-chunk attribution across a page boundary. - var chunks = TextChunker.ChunkPositioned(doc, 9, 0); - chunks.Should().HaveCount(2); - chunks[0].Page.Should().Be(1); - chunks[1].Page.Should().Be(2); - chunks[1].Position!.X.Should().BeApproximately(0.30, 1e-9); - chunks[1].Position!.Y.Should().BeApproximately(0.40, 1e-9); - } - - [Fact] - public void EmptyText_ProducesNoChunks() - { - TextChunker.Chunk("", 100, 10).Should().BeEmpty(); - TextChunker.Chunk(null, 100, 10).Should().BeEmpty(); - } - - [Fact] - public void TextShorterThanWindow_ProducesSingleChunk() - { - var chunks = TextChunker.Chunk("hello world", 100, 10); - chunks.Should().ContainSingle().Which.Should().Be("hello world"); - } - - [Fact] - public void TextEqualToWindow_ProducesSingleChunk() - { - var text = new string('a', 100); - var chunks = TextChunker.Chunk(text, 100, 10); - chunks.Should().ContainSingle().Which.Should().Be(text); - } - - [Fact] - public void WindowAndOverlap_SliceDeterministically() - { - // 26 chars, size 10, overlap 4 -> stride 6. Windows start at 0, 6, 12, 18. - // The window at 18 is [18,26) (length 8) — it reaches the end, so chunking stops there - // rather than emitting a redundant trailing sub-window. Hence 4 chunks, not 5. - var text = "abcdefghijklmnopqrstuvwxyz"; - var chunks = TextChunker.Chunk(text, 10, 4); - - chunks.Should().HaveCount(4); - chunks[0].Should().Be("abcdefghij"); // [0,10) - chunks[1].Should().Be("ghijklmnop"); // [6,16) - chunks[2].Should().Be("mnopqrstuv"); // [12,22) - chunks[3].Should().Be("stuvwxyz"); // [18,26) truncated to end - } - - [Fact] - public void Overlap_IsExactlyTheSharedTail() - { - var text = "0123456789ABCDEFGHIJ"; // 20 chars - var chunks = TextChunker.Chunk(text, 8, 3); // stride 5 - - // window 0: [0,8) = "01234567", window 1: [5,13) = "56789ABC" - chunks[0].Should().Be("01234567"); - chunks[1].Should().Be("56789ABC"); - - // The 3-char overlap is the tail of chunk0 == head of chunk1. - var tail = chunks[0].Substring(chunks[0].Length - 3); - chunks[1].Should().StartWith(tail); - tail.Should().Be("567"); - } - - [Fact] - public void IsDeterministic_SameInputsSameOutput() - { - var text = new string('x', 5000); - var a = TextChunker.Chunk(text, 1000, 150); - var b = TextChunker.Chunk(text, 1000, 150); - a.Should().Equal(b); - } - - [Fact] - public void ZeroOverlap_TilesWithoutSharing() - { - var text = "abcdefghij"; // 10 chars - var chunks = TextChunker.Chunk(text, 4, 0); - chunks.Should().Equal("abcd", "efgh", "ij"); - } - - [Fact] - public void OverlapClampedBelowSize_StillTerminates() - { - // overlap >= size would mean stride 0 (infinite loop) without the clamp. - var text = "abcdefghij"; - var chunks = TextChunker.Chunk(text, 4, 10); - chunks.Should().NotBeEmpty(); - // stride clamps to 1, so windows start at every index until the end. - chunks[0].Should().Be("abcd"); - } -} diff --git a/test/MeshWeaver.ContentCollections.Indexing.Test/TextExtractorTest.cs b/test/MeshWeaver.ContentCollections.Indexing.Test/TextExtractorTest.cs deleted file mode 100644 index a61d414a29..0000000000 --- a/test/MeshWeaver.ContentCollections.Indexing.Test/TextExtractorTest.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System.Reactive.Linq; -using System.Text; -using MeshWeaver.ContentCollections.Indexing; -using MeshWeaver.Mesh.Threading; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.ContentCollections.Indexing.Test; - -public class TextExtractorTest -{ - private readonly ITextExtractor _extractor = new TextExtractor(IoPool.Unbounded); - - private async Task Extract(string fileName, byte[] bytes) => - await _extractor.ExtractText(fileName, bytes).FirstAsync().Await(); - - private async Task ExtractDoc(string fileName, byte[] bytes) => - await _extractor.ExtractDocument(fileName, bytes).FirstAsync().Await(); - - [Fact] - public async Task Txt_DecodesUtf8() - { - var bytes = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog."); - var text = await Extract("notes.txt", bytes); - text.Should().Contain("quick brown fox"); - } - - [Fact] - public async Task Md_DecodesUtf8WithMarkup() - { - var bytes = Encoding.UTF8.GetBytes("# Heading\n\nSome **bold** body text about embeddings."); - var text = await Extract("doc.md", bytes); - text.Should().Contain("Heading"); - text.Should().Contain("embeddings"); - } - - [Fact] - public async Task Utf8Bom_IsStripped() - { - var preamble = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble(); - var body = Encoding.UTF8.GetBytes("hello bom"); - var bytes = preamble.Concat(body).ToArray(); - var text = await Extract("bom.txt", bytes); - text.Should().Be("hello bom"); - } - - [Fact] - public async Task Pdf_ExtractsPageText() - { - var bytes = PdfTestFixtures.OnePagePdf( - "Indexing pipeline overview", - "Content files become chunks become embeddings."); - - var text = await Extract("overview.pdf", bytes); - - text.Should().Contain("Indexing pipeline overview"); - text.Should().Contain("embeddings"); - } - - [Fact] - public async Task UnknownExtension_ExtractsEmpty() - { - var bytes = new byte[] { 0x00, 0x01, 0x02, 0xFF, 0xFE }; - var text = await Extract("blob.bin", bytes); - text.Should().BeEmpty(); - } - - // ── ExtractDocument: positional spans ──────────────────────────────────── - - [Fact] - public async Task ExtractDocument_Pdf_CarriesPageOneSpansInsideTheUnitBox() - { - var bytes = PdfTestFixtures.OnePagePdf("Revenue grew across regions this quarter"); - var doc = await ExtractDoc("report.pdf", bytes); - - doc.Text.Should().Contain("Revenue"); - doc.Spans.Should().NotBeEmpty(); - // Every span is on page 1 and every normalized coordinate is within the unit page box. - doc.Spans.Should().OnlyContain(s => s.Page == 1); - doc.Spans.Should().OnlyContain(s => - s.Box.X >= 0 && s.Box.X <= 1 && s.Box.Y >= 0 && s.Box.Y <= 1 && - s.Box.Width > 0 && s.Box.Width <= 1 && s.Box.Height > 0 && s.Box.Height <= 1); - - // The line sits at x=50/595 (left margin) and y=800/842 (near the top) — so the first word's box - // is on the left and near the top (small Y with the top-left origin). - var first = doc.Spans[0]; - first.Box.X.Should().BeLessThan(0.2); - first.Box.Y.Should().BeLessThan(0.15); - // The span text is a slice of the document text at the span's offset. - doc.Text.Substring(first.Start, first.Length).Should().Be("Revenue"); - } - - [Fact] - public async Task ExtractDocument_Pdf_MultiPage_SpansCarryDistinctPages() - { - var bytes = PdfTestFixtures.MultiPagePdf("First page alpha", "Second page bravo"); - var doc = await ExtractDoc("two.pdf", bytes); - - doc.Spans.Select(s => s.Page).Distinct().OrderBy(p => p).Should().Equal(1, 2); - // A page-2 word maps to page 2. - doc.Spans.Should().Contain(s => s.Page == 2 && doc.Text.Substring(s.Start, s.Length) == "bravo"); - } - - [Fact] - public async Task ExtractDocument_Txt_HasTextButNoSpans() - { - var doc = await ExtractDoc("notes.txt", Encoding.UTF8.GetBytes("plain text has no layout")); - doc.Text.Should().Contain("plain text"); - doc.Spans.Should().BeEmpty(); - } -} diff --git a/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj b/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj index 8aa076ea84..959090568d 100644 --- a/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj +++ b/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj @@ -253,7 +253,6 @@ content surface (in-mesh source compiles against them), so the closure keeps them directly now that the engine is gone. --> - From e7f88b566c07977d408cc96e6e5fb6852558961b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:01:19 +0200 Subject: [PATCH 2/5] refactor(maps,cornerstone): the map control and its one gated consumer leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes wave B's platform half. MeshWeaver.Maps and the Cornerstone sample tree leave the repo, and Maps leaves FrameworkBuildIdentity.ContentSurfaceAssemblies alongside the indexing pipeline — ONE identity flip for all three, which is the point of doing them together: the flip is the cost, not the count. Maps could not leave alone. samples/Graph/Data/Cornerstone/Pricing/Source/ PricingLayoutAreas.cs binds MapControl, and it is a Source/*.cs MESH node — compiled at runtime in the portal, invisible to dotnet build, and baked into the shipped image. That is why five test projects referenced MeshWeaver.Maps while not one line of their own C# mentioned it: their meshes compile that sample. So the content travels with the module, exactly as Markdown.Collaboration took its Review sources. Also removed: AddCornerstone from SampleDataExtensions (declaration and its AddAllSampleData entry) — a one-line IncludePartition wrapper the moved suites now call directly; seven ships-the-bits Maps ProjectReferences; and the six suites that exercise Cornerstone, which are in MeshWeaver.Plugins now and pass 114/114 there. 🚨 FIXED A GUARD THAT WOULD HAVE CHECKED NOTHING. FrameworkBuildIdentityTest's SurfaceIdentity_RecordsAbsence removed "MeshWeaver.Maps" from the baseline to prove that a missing assembly changes the identity. With Maps out of the baseline that Remove() removes nothing, and the test would have compared two identical dictionaries and passed having proved nothing about absence. It now names an assembly that is staying AND asserts the removal actually happened, so the next name to go stale fails loudly instead of silently. TestUsers keeps its Cornerstone grant on purpose: it is the SHARED test-user fixture both repos build against, and the suites that need that grant run in the other one now. Verified (Release, -warnaserror): Content.Test, Persistence.Test, Hosting.Monolith.Test and PluginTester all 0 Error(s) after the deletions; FrameworkBuildIdentity 23/23 with Maps and both Indexing assemblies out of the surface. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XM3bKbVAFCCDH9ZyYCxKQy --- MeshWeaver.slnx | 1 - .../Memex.Portal.Shared.csproj | 1 - samples/Graph/Data/Cornerstone/Article.json | 1 - .../Cornerstone/Article/Source/Article.cs | 25 - .../Article/Source/ArticleLayoutAreas.cs | 132 --- .../Documentation/AIAgentIntegration.md | 446 --------- .../Cornerstone/Documentation/Architecture.md | 336 ------- .../Documentation/GettingStarted.md | 246 ----- .../Cornerstone/Documentation/Overview.md | 186 ---- .../Documentation/UnifiedReferences.md | 337 ------- .../Data/Cornerstone/EuropeanLogistics.json | 19 - .../Cornerstone/EuropeanLogistics/2024.json | 25 - .../Data/Cornerstone/GlobalManufacturing.json | 19 - .../Cornerstone/GlobalManufacturing/2024.json | 25 - samples/Graph/Data/Cornerstone/Insured.json | 1 - .../Insured/Source/InsuranceLayoutAreas.cs | 100 -- .../Cornerstone/Insured/Source/Insured.cs | 46 - .../Insured/Source/PricingStatus.cs | 102 -- samples/Graph/Data/Cornerstone/Microsoft.json | 20 - .../Data/Cornerstone/Microsoft/2026.json | 27 - samples/Graph/Data/Cornerstone/Nestle.json | 20 - .../Graph/Data/Cornerstone/Nestle/2026.json | 25 - samples/Graph/Data/Cornerstone/Pricing.json | 24 - .../Cornerstone/Pricing/Source/Country.cs | 160 ---- .../Cornerstone/Pricing/Source/Currency.cs | 111 --- .../Cornerstone/Pricing/Source/LegalEntity.cs | 111 --- .../Pricing/Source/LineOfBusiness.cs | 83 -- .../Pricing/Source/MicrosoftDataLoader.cs | 201 ---- .../Cornerstone/Pricing/Source/Pricing.cs | 134 --- .../Pricing/Source/PricingLayoutAreas.cs | 586 ------------ .../Pricing/Source/PricingStatus.cs | 102 -- .../Pricing/Source/PropertyRisk.cs | 247 ----- .../Pricing/Source/ReinsuranceAcceptance.cs | 97 -- .../Pricing/Source/ReinsuranceSection.cs | 62 -- .../Cornerstone/Pricing/Source/SlipParser.cs | 323 ------- .../Graph/Data/Cornerstone/Source/Country.cs | 160 ---- .../Graph/Data/Cornerstone/Source/Currency.cs | 111 --- .../Data/Cornerstone/Source/LegalEntity.cs | 111 --- .../Data/Cornerstone/Source/LineOfBusiness.cs | 83 -- .../Data/Cornerstone/Source/PricingStatus.cs | 102 -- .../Data/Cornerstone/TechIndustries.json | 19 - .../Data/Cornerstone/TechIndustries/2024.json | 25 - samples/Graph/Data/Cornerstone/Tesla.json | 20 - .../Graph/Data/Cornerstone/Tesla/2026.json | 25 - .../Cornerstone/_Access/Alice_Access.json | 17 - .../Data/Cornerstone/_Access/Bob_Access.json | 17 - .../Cornerstone/_Access/Carol_Access.json | 17 - .../Cornerstone/_Access/David_Access.json | 17 - .../Data/Cornerstone/_Access/Emma_Access.json | 17 - .../Cornerstone/_Access/Oliver_Access.json | 17 - .../Data/Cornerstone/_Access/Paul_Access.json | 17 - .../Cornerstone/_Access/Quinn_Access.json | 17 - .../Cornerstone/_Access/Roland_Access.json | 17 - .../Cornerstone/_Access/Samuel_Access.json | 17 - .../Cornerstone/_Access/TestUser_Access.json | 17 - samples/Graph/Data/Cornerstone/index.md | 102 -- .../FrameworkBuildIdentity.cs | 5 +- .../SampleDataExtensions.cs | 5 - .../GoogleMapsConfiguration.cs | 17 - src/MeshWeaver.Maps/MapControl.cs | 100 -- src/MeshWeaver.Maps/MeshWeaver.Maps.csproj | 14 - src/MeshWeaver.Maps/README.md | 242 ----- .../ContentCollectionReferenceTest.cs | 673 ------------- .../MarkdownFileParserTest.cs | 900 ------------------ .../MarkdownNodeIntegrationTest.cs | 878 ----------------- .../MeshWeaver.Content.Test.csproj | 1 - .../SourceDocumentDataLoadingTest.cs | 329 ------- .../FrameworkBuildIdentityTest.cs | 7 +- .../MeshWeaver.Hosting.Monolith.Test.csproj | 1 - .../UserActivityAreaTest.cs | 291 ------ .../TestUsers.cs | 3 + .../MeshWeaver.Persistence.Test.csproj | 1 - .../PageLoadingTest.cs | 415 -------- .../MeshWeaver.Query.Test.csproj | 1 - .../MeshWeaver.Security.Test.csproj | 1 - .../MeshWeaver.PluginTester.csproj | 1 - 76 files changed, 13 insertions(+), 9198 deletions(-) delete mode 100644 samples/Graph/Data/Cornerstone/Article.json delete mode 100644 samples/Graph/Data/Cornerstone/Article/Source/Article.cs delete mode 100644 samples/Graph/Data/Cornerstone/Article/Source/ArticleLayoutAreas.cs delete mode 100644 samples/Graph/Data/Cornerstone/Documentation/AIAgentIntegration.md delete mode 100644 samples/Graph/Data/Cornerstone/Documentation/Architecture.md delete mode 100644 samples/Graph/Data/Cornerstone/Documentation/GettingStarted.md delete mode 100644 samples/Graph/Data/Cornerstone/Documentation/Overview.md delete mode 100644 samples/Graph/Data/Cornerstone/Documentation/UnifiedReferences.md delete mode 100644 samples/Graph/Data/Cornerstone/EuropeanLogistics.json delete mode 100644 samples/Graph/Data/Cornerstone/EuropeanLogistics/2024.json delete mode 100644 samples/Graph/Data/Cornerstone/GlobalManufacturing.json delete mode 100644 samples/Graph/Data/Cornerstone/GlobalManufacturing/2024.json delete mode 100644 samples/Graph/Data/Cornerstone/Insured.json delete mode 100644 samples/Graph/Data/Cornerstone/Insured/Source/InsuranceLayoutAreas.cs delete mode 100644 samples/Graph/Data/Cornerstone/Insured/Source/Insured.cs delete mode 100644 samples/Graph/Data/Cornerstone/Insured/Source/PricingStatus.cs delete mode 100644 samples/Graph/Data/Cornerstone/Microsoft.json delete mode 100644 samples/Graph/Data/Cornerstone/Microsoft/2026.json delete mode 100644 samples/Graph/Data/Cornerstone/Nestle.json delete mode 100644 samples/Graph/Data/Cornerstone/Nestle/2026.json delete mode 100644 samples/Graph/Data/Cornerstone/Pricing.json delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/Country.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/Currency.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/LegalEntity.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/LineOfBusiness.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/MicrosoftDataLoader.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/Pricing.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/PricingLayoutAreas.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/PricingStatus.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/PropertyRisk.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceAcceptance.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceSection.cs delete mode 100644 samples/Graph/Data/Cornerstone/Pricing/Source/SlipParser.cs delete mode 100644 samples/Graph/Data/Cornerstone/Source/Country.cs delete mode 100644 samples/Graph/Data/Cornerstone/Source/Currency.cs delete mode 100644 samples/Graph/Data/Cornerstone/Source/LegalEntity.cs delete mode 100644 samples/Graph/Data/Cornerstone/Source/LineOfBusiness.cs delete mode 100644 samples/Graph/Data/Cornerstone/Source/PricingStatus.cs delete mode 100644 samples/Graph/Data/Cornerstone/TechIndustries.json delete mode 100644 samples/Graph/Data/Cornerstone/TechIndustries/2024.json delete mode 100644 samples/Graph/Data/Cornerstone/Tesla.json delete mode 100644 samples/Graph/Data/Cornerstone/Tesla/2026.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Alice_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Bob_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Carol_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/David_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Emma_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Oliver_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Paul_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Quinn_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Roland_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/Samuel_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/_Access/TestUser_Access.json delete mode 100644 samples/Graph/Data/Cornerstone/index.md delete mode 100644 src/MeshWeaver.Maps/GoogleMapsConfiguration.cs delete mode 100644 src/MeshWeaver.Maps/MapControl.cs delete mode 100644 src/MeshWeaver.Maps/MeshWeaver.Maps.csproj delete mode 100644 src/MeshWeaver.Maps/README.md delete mode 100644 test/MeshWeaver.Content.Test/ContentCollectionReferenceTest.cs delete mode 100644 test/MeshWeaver.Content.Test/MarkdownFileParserTest.cs delete mode 100644 test/MeshWeaver.Content.Test/MarkdownNodeIntegrationTest.cs delete mode 100644 test/MeshWeaver.Content.Test/SourceDocumentDataLoadingTest.cs delete mode 100644 test/MeshWeaver.Hosting.Monolith.Test/UserActivityAreaTest.cs delete mode 100644 test/MeshWeaver.Persistence.Test/PageLoadingTest.cs diff --git a/MeshWeaver.slnx b/MeshWeaver.slnx index 0bb3834b4c..e1fd7e1641 100644 --- a/MeshWeaver.slnx +++ b/MeshWeaver.slnx @@ -41,7 +41,6 @@ - diff --git a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj index 27ee5a9620..039bfe142d 100644 --- a/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj +++ b/memex/Memex.Portal.Shared/Memex.Portal.Shared.csproj @@ -44,7 +44,6 @@ recurred here the moment the map packs left, and FrameworkBuildIdentityTest.CanonicalContentSurface_IsRecordedByEverySurfaceManifestHost is what caught it. No portal code uses these types — the reference IS the point. --> - ChatClient - ChatClient --> MeshPlugin - ChatClient --> Documentation - MeshPlugin --> MeshGraph - Documentation --> MeshGraph - MeshGraph --> UserInterface[Updated User Interface] -``` - -When a user types a natural language request, the AI agent analyzes intent, determines which tools to call, executes them with appropriate parameters, and returns results through the chat interface. - -# MeshPlugin - Universal Data Access - -The `MeshPlugin` provides AI agents with tools to interact with the mesh. For the complete API reference, see [MeshPlugin Tools](/Doc/AI/Tools/MeshPlugin). - -| Tool | Purpose | -|------|---------| -| **Get** | Retrieve nodes by path (`@path` or `@path/*` for children) | -| **Search** | Query nodes using GitHub-style syntax | -| **NavigateTo** | Display a node's visual representation | -| **Update** | Create or modify nodes | - -The MeshPlugin is completely generic - it works with Pricings, PropertyRisks, Insureds, or any other MeshNode type. - -## Example MeshPlugin Usage - -Here's how the AI agent might use the MeshPlugin to handle a user request: - -**User Request**: "Show me all bound pricings" - -**Agent Process**: -1. `Search("nodeType:Cornerstone/Pricing status:Bound")` → Finds bound pricings -2. `NavigateTo("@Cornerstone/Microsoft/PricingCatalog")` → Displays results in catalog view - -# Insurance Domain Context - -AI agents working with Cornerstone have access to essential context that helps understand the insurance domain: - -- **Business Structure**: Knows the relationships between insured, primary insurer, broker, and reinsurer -- **Reference Data**: Understands lines of business, countries, currencies, and statuses -- **Views**: Knows which views are available (Overview, Property Risks, Risk Map, etc.) -- **Data Model**: Understands PropertyRisk, ReinsuranceAcceptance, and ReinsuranceSection entities - -# Natural Language Processing Examples - -The AI agent integration enables sophisticated natural language interactions for insurance tasks. - -## Viewing Pricings - -**Display Pricing Overview**: -``` -User: "Show me the Microsoft 2026 pricing" -Agent: [Displays Overview view for Cornerstone/Microsoft/2026] -``` - -**View Property Risks**: -``` -User: "What property risks are in the Microsoft pricing?" -Agent: [Displays Property Risks DataGrid showing locations and TSI values] -``` - -**Geographic Visualization**: -``` -User: "Show me the risk map for Microsoft" -Agent: [Displays Risk Map with geocoded property markers on Google Maps] -``` - -## Querying Data - -**Find Pricings by Status**: -``` -User: "Show me all draft pricings" -Agent: [Searches for pricings with status:Draft and displays results] -``` - -**Find by Line of Business**: -``` -User: "What property pricings do we have?" -Agent: [Searches for pricings with lineOfBusiness:PROP] -``` - -**Find by Insured**: -``` -User: "Show me all pricings for Global Manufacturing" -Agent: [Navigates to Cornerstone/GlobalManufacturing/PricingCatalog] -``` - -## Creating Pricings - -**New Pricing**: -``` -User: "Create a new pricing for Microsoft, 2027 underwriting year" -Agent: "Created draft pricing 'Microsoft 2027' with: - - Inception: 2027-01-01 - - Expiration: 2027-12-31 - - Line of Business: Property - - Status: Draft" -``` - -**With Specific Details**: -``` -User: "Create a marine pricing for European Logistics, starting April 2026" -Agent: "Created draft pricing with: - - Insured: European Logistics Ltd - - Line of Business: Marine - - Inception: 2026-04-01 - - Currency: GBP" -``` - -## Understanding Insurance Terms - -**Reference Data Queries**: -``` -User: "What lines of business are available?" -Agent: "Available lines of business: - - PROP: Property - - CAS: Casualty - - MARINE: Marine - - AVIATION: Aviation - - ENERGY: Energy" -``` - -**Status Workflow**: -``` -User: "What pricing statuses can I use?" -Agent: "Pricing statuses (in order): - 1. Draft - Initial pricing draft - 2. Quoted - Quote issued to client - 3. Bound - Policy bound, coverage in effect - 4. Declined - Rejected by client or underwriter - 5. Expired - Quote or policy has expired" -``` - -## Reinsurance Structure - -**View Structure Diagram**: -``` -User: "Show me the reinsurance structure for Microsoft 2026" -Agent: [Displays Structure view with reinsurance layers and sections] -``` - -**Layer Details**: -``` -User: "What are the coverage layers?" -Agent: "Reinsurance layers: - - Layer 1: $10M xs $5M (Fire, NatCat, BI) - - Layer 2: $25M xs $15M (Fire, NatCat) - - Layer 3: $50M xs $40M (Fire)" -``` - -## File Management - -**View Submissions**: -``` -User: "Show me the submission files for Microsoft 2026" -Agent: [Displays Submission file browser] -``` - -**Import Data**: -``` -User: "Import property risks from the uploaded Excel file" -Agent: [Processes Excel file and imports PropertyRisk entities] -``` - -# Excel Import Workflow - -MeshWeaver supports importing property risk data from Excel files. This section details the complete workflow for analyzing Excel files and creating import configurations. - -## Import Capabilities - -- Analyze uploaded Excel files to understand their structure (columns, headers, data types) -- Intelligently match Excel columns to PropertyRisk properties -- Generate ExcelImportConfiguration mappings dynamically -- Preview and confirm mappings before importing -- Execute imports and provide feedback on results - -## Available Import Tools - -| Tool | Purpose | -|------|---------| -| **GetContent** | Read Excel file contents as markdown table with column letters | -| **ListFiles** | List files in the Submissions folder | -| **Import** | Execute import with a configuration | -| **GetSchema** | Get the PropertyRisk schema for reference | - -## Import Workflow Steps - -### Step 1: Identify the File - -If the user doesn't specify a file: -- Call `ListFiles` on the Submissions collection to show available files -- Ask which file to import - -### Step 2: Analyze the Excel Structure - -Call `GetContent` with the file path and `numberOfRows: 20` to see: -- All worksheet names -- Column letters (A, B, C, ...) and their content -- Header row location -- Sample data rows - -### Step 3: Identify Headers and Data Start Row - -Examine the returned markdown table to determine: -- Which row contains column headers (often row 1, but may be row 2-6 in broker files) -- Which row the actual data starts -- Any total rows at the bottom to exclude - -Common patterns: -- Simple files: Headers in row 1, data starts row 2 -- Broker files: Title/metadata in rows 1-5, headers in row 6, data starts row 7 -- Files with freeze panes often have headers at the freeze row - -### Step 4: Match Columns to PropertyRisk Properties - -Map the Excel columns to these PropertyRisk properties: - -| PropertyRisk Field | Common Excel Header Patterns | -|--------------------|------------------------------| -| `Id` | ID, Asset ID, Location ID, Plant, Plant ID, Reference | -| `LocationName` | Name, Location, Site, Site Name, Location Name | -| `Address` | Address, Street, Street Address | -| `City` | City, Town | -| `State` | State, Province, Region | -| `Country` | Country, Country Code, Nation, Ctry | -| `ZipCode` | ZIP, Zip Code, Postal Code, Post Code | -| `TsiBuilding` | Building, Building Value, TSI Building, Building TSI, Property Value | -| `TsiContent` | Content, Contents, TSI Content, Content Value, Equipment, Machinery | -| `TsiBi` | BI, Business Interruption, TSI BI, Loss of Income | -| `Latitude` | Lat, Latitude | -| `Longitude` | Lng, Long, Longitude | -| `BuildYear` | Year Built, Build Year, Construction Year | -| `OccupancyType` | Occupancy, Occupancy Type, Use, Building Use | -| `ConstructionType` | Construction, Construction Type, Building Type | - -For numeric value columns (TsiBuilding, TsiContent, TsiBi): -- If values are split across multiple columns, use `MappingKind.Sum` -- If a total needs to be allocated proportionally, use an `Allocation` with weight columns - -### Step 5: Present Mapping to User - -Present the proposed mapping clearly and wait for user confirmation: - -``` -I analyzed the Excel file. Here's my suggested mapping: - -**File**: NewRisks.xlsx -**Worksheet**: Sheet1 -**Headers in row**: 6 -**Data starts at row**: 7 - -**Column Mappings**: -- Column A "Plant ID" -> PropertyRisk.Id -- Column B "Site Name" -> PropertyRisk.LocationName -- Column C "Street" -> PropertyRisk.Address -- Column D "Country" -> PropertyRisk.Country -- Column E "Building USD" -> PropertyRisk.TsiBuilding -- Column F "Contents USD" -> PropertyRisk.TsiContent -- Column G "BI USD" -> PropertyRisk.TsiBi - -**Excluded**: Columns H-J (appear to be calculated totals) -**Total row markers**: "Total", "Grand Total" - -Should I proceed with this mapping? -``` - -### Step 6: Generate Configuration - -Create the ExcelImportConfiguration JSON: - -```json -{ - "$type": "MeshWeaver.Import.Configuration.ExcelImportConfiguration", - "name": "NewRisks.xlsx", - "typeName": "PropertyRisk", - "worksheetName": "Sheet1", - "dataStartRow": 7, - "totalRowMarkers": ["Total", "Grand Total"], - "ignoreRowExpressions": ["Id == null"], - "mappings": [ - { "targetProperty": "Id", "kind": "Direct", "sourceColumns": ["A"] }, - { "targetProperty": "LocationName", "kind": "Direct", "sourceColumns": ["B"] }, - { "targetProperty": "Address", "kind": "Direct", "sourceColumns": ["C"] }, - { "targetProperty": "Country", "kind": "Direct", "sourceColumns": ["D"] }, - { "targetProperty": "TsiBuilding", "kind": "Direct", "sourceColumns": ["E"] }, - { "targetProperty": "TsiContent", "kind": "Direct", "sourceColumns": ["F"] }, - { "targetProperty": "TsiBi", "kind": "Direct", "sourceColumns": ["G"] }, - { "targetProperty": "PricingId", "kind": "Constant", "constantValue": "2026" } - ] -} -``` - -### Step 7: Execute Import - -Call `Import` with the configuration: - -``` -Import( - path: "Submissions@Microsoft-2026:NewRisks.xlsx", - address: "Cornerstone/Microsoft/2026", - configuration: "" -) -``` - -### Step 8: Report Results - -After import completes: -- Report the number of records imported -- Mention any warnings or errors from the import log -- Suggest viewing the Property Risks view to verify the data - -## Mapping Kind Reference - -| Kind | Usage | Example | -|------|-------|---------| -| `Direct` | Single column to property | Column B -> LocationName | -| `Sum` | Sum multiple columns | Columns E+F+G -> TsiContent | -| `Difference` | Subtract columns | Column F - Column E -> some value | -| `Constant` | Fixed value for all rows | "2026" -> PricingId | - -## Allocation Reference - -For distributing a total value proportionally across rows: - -```json -{ - "allocations": [ - { - "targetProperty": "TsiBi", - "totalCell": "Q50", - "weightColumns": ["Q"] - } - ] -} -``` - -This takes the value in cell Q50 and distributes it across all rows based on each row's weight in column Q. - -## Import Error Handling - -- If GetContent fails, the file may not exist or be corrupted -- If column headers are unclear, ask the user to clarify -- If import fails, report the error and suggest checking the mapping -- Always offer to adjust the mapping if the user identifies issues - -# View Integration - -Agents display results using appropriate views rather than raw data: - -``` -CRITICAL: When users ask to view, show, list, or display pricings: -- First check available views using GetLayoutAreas -- If an appropriate view exists: - 1. Call DisplayLayoutArea with the appropriate area name - 2. Provide a brief confirmation message - 3. DO NOT also output the raw data as text -``` - -Available views in Cornerstone: - -| View | Use Case | -|------|----------| -| **Overview** | Pricing details and summary | -| **Property Risks** | Property location data | -| **Risk Map** | Geographic visualization | -| **Structure** | Reinsurance layers and sections | -| **Submission** | File browser | -| **Import Configs** | Import settings | -| **PricingCatalog** | Insured's pricings by status | - -# Architecture Benefits - -## Scalability - -- **Independent Scaling**: AI agents scale separately from the core application -- **Multiple Agents**: Different agents can specialize in different domains -- **Load Distribution**: Agent processing distributes across multiple servers - -## Maintainability - -- **Clean Separation**: Agent logic is separate from business logic -- **Markdown Definition**: Agents are defined in simple markdown files -- **Easy Updates**: Agent context can be updated without code changes - -## Security - -- **Standard Access Controls**: Agents use the same authentication as human users -- **Audit Trail**: All agent actions are logged and traceable -- **Sandboxing**: Agents operate through well-defined interfaces - -# Domain-Specific Intelligence - -AI agents working with Cornerstone documentation have domain-specific capabilities: - -| Capability | Description | -|------------|-------------| -| **TSI Understanding** | Knows Building, Content, and BI TSI values | -| **Coverage Limits** | Understands attachment points and layer limits | -| **Industry Terms** | Recognizes EPI, rates, commissions | -| **Geographic Context** | Handles property geocoding and mapping | -| **Workflow Status** | Manages Draft → Quoted → Bound progression | - -# Conclusion - -MeshWeaver's AI agent integration in Cornerstone demonstrates how thoughtful architectural design enables powerful AI capabilities for insurance applications. The documentation-driven approach provides: - -- Natural language access to complex insurance data -- Domain-aware responses using insurance terminology -- Visual representations through views -- File management and Excel import workflows -- Real-time updates as data changes - -This approach positions insurance applications to take full advantage of advancing AI capabilities while maintaining clean architecture, security, and scalability. diff --git a/samples/Graph/Data/Cornerstone/Documentation/Architecture.md b/samples/Graph/Data/Cornerstone/Documentation/Architecture.md deleted file mode 100644 index 1470efeac2..0000000000 --- a/samples/Graph/Data/Cornerstone/Documentation/Architecture.md +++ /dev/null @@ -1,336 +0,0 @@ ---- -NodeType: "Cornerstone/Article" -Title: "Cornerstone Architecture" -Abstract: "Deep dive into MeshWeaver's architecture with Cornerstone examples" -Icon: "Document" -Published: "2025-01-31" -Authors: - - "MeshWeaver Team" -Tags: - - "Cornerstone" - - "Architecture" ---- - -Understanding the architectural principles behind MeshWeaver applications through the lens of the Cornerstone sample. This document explores how reinsurance pricing, property risks, and file management are organized in a distributed, message-driven architecture. - -# Core Architectural Principles - -MeshWeaver applications are built with three fundamental principles in mind: - -1. **Cloud-First Design**: Every component is designed to run efficiently in cloud environments, leveraging horizontal scaling and cloud services -2. **Distributed Systems**: The application architecture embraces distribution as a core concept, with components communicating through well-defined message protocols -3. **Asynchronous Interaction Patterns**: User interactions, especially those involving AI chat agents, are designed to handle long-running operations that may take hours or even days to complete - -## Architecture Patterns - -MeshWeaver applications can be organized using various architectural patterns: - -- **[Clean Architecture](https://medium.com/multinetinventiv/clean-architecture-part-one-what-is-clean-architecture-3d9f16e831bf)**: Message-driven design supports clean architecture by enforcing clear boundaries between business logic and infrastructure -- **[Modular Monolith](https://medium.com/design-microservices-architecture-with-patterns/microservices-killer-modular-monolithic-architecture-ac83814f6862)**: Different modules are deployed together but maintain logical separation through message hubs -- **[Vertical Slice Architecture](https://en.wikipedia.org/wiki/Hexagonal_architecture_(software))**: Each feature is implemented as a complete vertical slice from UI to data - -The Cornerstone sample demonstrates a modular approach where the Pricing functionality is encapsulated in a reusable NodeType with clear message-based interfaces. - -# The MeshNode and Namespace Hierarchy - -MeshWeaver organizes data using MeshNodes in a hierarchical namespace. For the complete architecture details, see [Mesh Graph Architecture](/Doc/Architecture/MeshGraph). - -## Cornerstone Namespace Hierarchy - -``` -Cornerstone/ # Reinsurance company namespace -├── Insured.json # Insured NodeType definition -├── Pricing.json # Pricing NodeType definition -├── Pricing/ # Pricing-related assets -│ └── Code/ # Business logic and views -│ ├── Pricing.cs # Pricing entity -│ ├── PricingViews.cs # View definitions -│ ├── PricingStatus.cs # Status dimension -│ ├── PropertyRisk.cs # Risk entity -│ ├── ReinsuranceAcceptance.cs # Layer entity -│ └── ReinsuranceSection.cs # Section entity -├── Code/ # Shared reference data -│ ├── Country.cs # Country dimension -│ ├── Currency.cs # Currency dimension -│ ├── LineOfBusiness.cs # LOB dimension -│ └── LegalEntity.cs # Legal entity dimension -├── Microsoft/ # Insured instance -│ ├── Microsoft.json # Insured data -│ └── 2026/ # Pricing instance -│ ├── 2026.json # Pricing data -│ └── Submissions/ # Content collection -├── GlobalManufacturing/ # Insured instance -│ └── 2024/ # Pricing instance -├── EuropeanLogistics/ # Insured instance -│ └── 2024/ # Pricing instance -├── TechIndustries/ # Insured instance -│ └── 2024/ # Pricing instance -├── Tesla/ # Insured instance -│ └── 2026/ # Pricing instance -└── Nestle/ # Insured instance - └── 2026/ # Pricing instance -``` - -## Path Examples - -| Node | Full Path | -|------|-----------| -| Microsoft insured | `Cornerstone/Microsoft` | -| Microsoft 2026 pricing | `Cornerstone/Microsoft/2026` | -| Pricing NodeType | `Cornerstone/Pricing` | -| Insured NodeType | `Cornerstone/Insured` | - -# NodeType Configuration - -NodeTypes define the behavior for instances. The Cornerstone sample uses two primary NodeTypes. - -## Insured NodeType - -The Insured NodeType (`Cornerstone/Insured`) defines client organizations: - -```json -{ - "id": "Insured", - "namespace": "Cornerstone", - "name": "Insured", - "nodeType": "NodeType", - "content": { - "$type": "NodeTypeDefinition", - "configuration": "config => config - .WithContentType() - .AddData(data => data.AddSource(source => source - .WithType(t => t.WithInitialData(PricingStatus.All)))) - .AddDefaultLayoutAreas() - .AddLayout(layout => layout - .WithDefaultArea(\"PricingCatalog\") - .AddInsuranceViews())" - } -} -``` - -This configuration: -- Sets `Insured` as the content type -- Provides PricingStatus dimension data for filtering pricings -- Registers views for the pricing catalog - -## Pricing NodeType - -The Pricing NodeType (`Cornerstone/Pricing`) defines individual pricings: - -```json -{ - "id": "Pricing", - "namespace": "Cornerstone", - "name": "Pricing", - "nodeType": "NodeType", - "content": { - "$type": "NodeTypeDefinition", - "configuration": "config => config - .WithContentType() - .AddContentCollection(sp => { - var hub = sp.GetRequiredService(); - var pricingId = hub.Address.Id; - var basePath = Path.Combine(\"...\", \"Submissions\"); - return new ContentCollectionConfig { - SourceType = FileSystemStreamProvider.SourceType, - Name = $\"Submissions@{pricingId}\", - BasePath = basePath, - DisplayName = \"Submission Files\" - }; - }) - .AddData(data => data.AddSource(source => source - .WithType(t => t.WithInitialData(LineOfBusiness.All)) - .WithType(t => t.WithInitialData(Country.All)) - .WithType(t => t.WithInitialData(Currency.All)) - .WithType(t => t.WithInitialData(LegalEntity.All)) - .WithType(t => t.WithInitialData(PricingStatus.All)) - .WithType() - .WithType() - .WithType() - .WithType())) - .AddDefaultLayoutAreas() - .AddLayout(layout => layout - .WithDefaultArea(\"Overview\") - .AddPricingViews())" - } -} -``` - -This configuration: -- Sets `Pricing` as the content type -- Configures a ContentCollection for file uploads (Submissions) -- Registers multiple dimension types (LineOfBusiness, Country, Currency, etc.) -- Registers business entities (PropertyRisk, ReinsuranceAcceptance, ReinsuranceSection) -- Sets Overview as the default view - -# Data Model - -## Dimensions - -Dimensions provide reference data for filtering and classification: - -| Dimension | Values | Purpose | -|-----------|--------|---------| -| **LineOfBusiness** | PROP, CAS, MARINE, AVIATION, ENERGY | Insurance classification | -| **Country** | US, GB, DE, FR, JP, CN, AU, CA, CH, SG | Geographic classification | -| **Currency** | USD, EUR, GBP, JPY, CHF, AUD, CAD | Monetary values | -| **PricingStatus** | Draft, Quoted, Bound, Declined, Expired | Workflow status | -| **LegalEntity** | CS-US, CS-UK, CS-EU, CS-ASIA | Cornerstone legal entities | - -## Business Entities - -Business entities store pricing-specific data: - -| Entity | Description | Key Fields | -|--------|-------------|------------| -| **PropertyRisk** | Insured locations | Address, City, Country, TSI values, Coordinates | -| **ReinsuranceAcceptance** | Coverage layers | Layer name, Attachment point, Limit, EPI | -| **ReinsuranceSection** | Layer sections | Section name, Coverage type, Rate | -| **ExcelImportConfiguration** | Import settings | Column mappings, Sheet name | - -# Message Hub Architecture - -## Hub Addressing and Partitioning - -Cornerstone uses namespace paths for hub partitioning: - -- `Cornerstone/Microsoft` - Microsoft insured hub -- `Cornerstone/Microsoft/2026` - Microsoft 2026 pricing hub -- `Cornerstone/GlobalManufacturing` - GlobalManufacturing insured hub -- `Cornerstone/GlobalManufacturing/2024` - GlobalManufacturing 2024 pricing hub - -Each pricing gets its own hub context, enabling: -- Independent scaling of busy pricings -- Data isolation between clients -- Separate file storage per pricing - -## Portal Architecture - -The `portal/[id]` address pattern creates individual hub instances for each browser session: - -- **Session Isolation**: Each user gets their own portal instance -- **Personalized Experience**: User-specific state and preferences -- **Scalable Connections**: Portal instances distribute across servers - -# Interaction Flow - -The following diagram illustrates how components interact when a user views property risks: - -```mermaid -sequenceDiagram - participant Portal as Portal Hub
(portal/[session-id]) - participant PricingHub as Pricing Hub
(Cornerstone/Microsoft/2026) - participant UI as User Interface - participant LayoutArea as PropertyRisks View - - Note over Portal,LayoutArea: Initial Setup - Portal->>PricingHub: Subscribe to Property Risks view - PricingHub->>LayoutArea: Activate view stream - LayoutArea->>Portal: Stream initial DataGrid - Portal->>UI: Render property risks table - - Note over Portal,LayoutArea: User Imports Excel - UI->>Portal: User uploads Excel file - Portal->>PricingHub: Send file upload message - PricingHub->>PricingHub: Process Excel import - PricingHub->>PricingHub: Create PropertyRisk entities - PricingHub->>LayoutArea: Data change triggers update - LayoutArea->>Portal: Stream updated DataGrid - Portal->>UI: Refresh with new property risks -``` - -## Step-by-Step Breakdown - -1. **Subscription Phase**: The portal subscribes to views in the pricing hub -2. **User Action**: User uploads an Excel file with property data -3. **Message Dispatch**: The portal sends the file to the pricing hub -4. **Data Processing**: The hub processes the Excel and creates PropertyRisk entities -5. **Layout Reaction**: Data changes trigger the PropertyRisks view to regenerate -6. **Stream Update**: The updated DataGrid is streamed to all subscribed portals -7. **UI Refresh**: The portal receives the update and refreshes the table - -# Views - -MeshWeaver follows the MVVM pattern. Views are registered through NodeType configuration and executed reactively. - -## Pricing Views - -| View | Description | Content | -|------|-------------|---------| -| **Overview** | Pricing summary | Header, dates, parties, status | -| **Property Risks** | Risk data | DataGrid with location details and TSI | -| **Risk Map** | Geographic view | Google Maps with risk markers | -| **Structure** | Reinsurance layers | DataGrids showing reinsurance layers and sections | -| **Submission** | File management | File browser for uploads | -| **Import Configs** | Import settings | Excel column mappings | - -## View Registration - -Views are defined in `PricingViews.cs` and registered via the NodeType: - -```csharp -public static LayoutDefinition AddPricingViews(this LayoutDefinition layout) => - layout - .WithView("Overview", Overview) - .WithView("PropertyRisks", PropertyRisks) - .WithView("RiskMap", RiskMap) - .WithView("Structure", Structure) - .WithView("Submission", Submission) - .WithView("ImportConfigs", ImportConfigs); -``` - -## Reactive View Updates - -Views return `IObservable`, enabling reactive updates: - -```csharp -public static IObservable PropertyRisks(LayoutAreaHost host, RenderingContext _) -{ - var risks = host.Hub.GetDataContext() - .ObserveCollection(); - - return risks.Select(riskList => - { - // Build DataGrid with property risk rows - return (UiControl?)new DataGridControl() - .WithColumns(...) - .WithData(riskList); - }); -} -``` - -When property risks change, the observable emits new values and the view automatically updates. - -# Content Collections - -Pricings use ContentCollections for file management: - -```csharp -.AddContentCollection(sp => { - var hub = sp.GetRequiredService(); - return new ContentCollectionConfig { - SourceType = FileSystemStreamProvider.SourceType, - Name = $"Submissions@{hub.Address.Id}", - BasePath = "path/to/Submissions", - DisplayName = "Submission Files" - }; -}) -``` - -This enables: -- **File Uploads**: Upload slip documents, Excel data, PDFs -- **File Browsing**: View and download submitted files -- **Version Tracking**: Track file changes over time -- **Excel Import**: Import property risks from spreadsheets - -# Conclusion - -MeshWeaver's architecture provides a robust foundation for insurance applications. The Cornerstone sample demonstrates: - -- **Hierarchical Organization**: Insured → Pricing hierarchy with natural data partitioning -- **Shared NodeTypes**: Pricing NodeType reused across all insureds -- **Rich Data Model**: Dimensions, business entities, and file collections -- **Reactive Views**: Real-time updates for property risks, maps, and diagrams -- **Message-Driven**: Clean separation through well-defined message contracts - -This architecture enables scalable, maintainable, and testable insurance applications with excellent performance and real-time collaboration. diff --git a/samples/Graph/Data/Cornerstone/Documentation/GettingStarted.md b/samples/Graph/Data/Cornerstone/Documentation/GettingStarted.md deleted file mode 100644 index 1cbed439ec..0000000000 --- a/samples/Graph/Data/Cornerstone/Documentation/GettingStarted.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -NodeType: "Cornerstone/Article" -Title: "Getting Started with Cornerstone" -Abstract: "Run the sample and explore the pricing interface" -Icon: "Document" -Published: "2025-01-31" -Authors: - - "MeshWeaver Team" -Tags: - - "Cornerstone" - - "Getting Started" ---- - -This guide walks you through running the Cornerstone sample and exploring the pricing interface. - -# Running the Sample - -## Clone and Build - -```bash -git clone https://github.com/MeshWeaver/MeshWeaver.git -cd MeshWeaver -dotnet build -``` - -## Start the Portal - -```bash -cd ../MeshWeaver.Plugins/src/Memex.Portal.Monolith -dotnet run -``` - -Navigate to `http://localhost:7122` in your browser. - -## Navigate to Cornerstone - -1. Navigate to **Cornerstone** namespace -2. Explore **Microsoft**, **GlobalManufacturing**, **Tesla**, **Nestle**, or other insureds -3. Select a pricing (e.g., 2026) to view details - -# Exploring the Pricing Interface - -## Insured Views - -Each insured (Microsoft, GlobalManufacturing, etc.) has these views: - -| View | Description | -|------|-------------| -| **PricingCatalog** | All pricings grouped by status (Draft, Quoted, Bound, etc.) | - -## Pricing Views - -Each pricing instance has these views: - -| View | Description | -|------|-------------| -| **Overview** | Pricing header with insured details, dates, and financial summary | -| **Property Risks** | DataGrid showing all property locations with TSI values | -| **Risk Map** | Google Maps visualization of geocoded property locations | -| **Structure** | DataGrids showing reinsurance layers and sections | -| **Thumbnail** | Compact card display for catalogs | -| **Submission** | File browser for uploaded submission documents | -| **Import Configs** | Excel import configuration settings | - -## Pricing Details View - -The Overview displays: -- Insured name and description -- Inception and expiration dates -- Line of business and currency -- Broker and primary insurer information -- Current pricing status with promotion options - -## Property Risks DataGrid - -The Property Risks view shows: -- Location details (address, city, state, country, ZIP) -- TSI values (Building, Content, Business Interruption) -- Construction details (build year, occupancy type) -- Geocoded coordinates for mapping - -## Reinsurance Structure - -The Structure view visualizes: -- Multiple coverage layers with attachment points and limits -- Financial terms (EPI, rates, commissions) -- Sections per layer (Fire, Natural Catastrophe, Business Interruption) - -# Using the AI Agent - -The AI chat agent understands natural language and can help manage pricings. - -## Querying Pricings - -``` -"Show me the Microsoft 2026 pricing" -→ Displays the Overview view - -"What property risks are in the Microsoft pricing?" -→ Displays the Property Risks DataGrid - -"Show me the reinsurance structure" -→ Displays the Structure view -``` - -## Creating Pricings - -``` -"Create a new pricing for Microsoft with inception January 1, 2027" -→ Creates draft pricing with specified dates - -"Add a property pricing for Global Manufacturing, 2025 underwriting year" -→ Creates new pricing in GlobalManufacturing namespace -``` - -## Navigating Views - -``` -"Show the risk map for Microsoft 2026" -→ Displays geographic visualization of property locations - -"Open the submission files" -→ Shows the file browser for uploaded documents -``` - -## Understanding Insurance Data - -``` -"What lines of business are available?" -→ Lists: Property, Casualty, Marine, Aviation, Energy - -"Show available pricing statuses" -→ Lists: Draft, Quoted, Bound, Declined, Expired - -"What countries are supported?" -→ Lists supported country codes with regions -``` - -# Understanding the Code - -## Pricing Data Model (`Pricing.cs`) - -```csharp -public record Pricing : IContentInitializable -{ - [Key] - public string Id { get; init; } = string.Empty; - - [Required] - public string InsuredName { get; init; } = string.Empty; - - [Markdown] - public string? Description { get; init; } - - public DateTime InceptionDate { get; init; } - public DateTime ExpirationDate { get; init; } - public int UnderwritingYear { get; init; } - - [Dimension] - public string LineOfBusiness { get; init; } = "PROP"; - - [Dimension] - public string Country { get; init; } = "US"; - - [Dimension] - public string Currency { get; init; } = "USD"; - - [Dimension] - public string Status { get; init; } = "Draft"; - - public string? BrokerName { get; init; } - public string? PrimaryInsurance { get; init; } -} -``` - -Key features: -- `[Key]` marks the primary identifier -- `[Dimension]` links to dimension types for filtering -- `[Markdown]` enables markdown rendering for descriptions - -## NodeType Configuration (`Pricing.json`) - -```json -{ - "id": "Pricing", - "namespace": "Cornerstone", - "name": "Pricing", - "nodeType": "NodeType", - "content": { - "$type": "NodeTypeDefinition", - "configuration": "config => config - .WithContentType() - .AddContentCollection(...) - .AddData(data => data.AddSource(...)) - .AddDefaultLayoutAreas() - .AddLayout(layout => layout.WithDefaultArea(\"Overview\").AddPricingViews())" - } -} -``` - -## Pricing Instance (`Microsoft/2026.json`) - -```json -{ - "id": "2026", - "namespace": "Cornerstone/Microsoft", - "name": "Microsoft 2026", - "nodeType": "Cornerstone/Pricing", - "content": { - "$type": "Pricing", - "id": "2026", - "insuredName": "Microsoft Corporation", - "inceptionDate": "2026-01-01T00:00:00Z", - "expirationDate": "2026-12-31T00:00:00Z", - "lineOfBusiness": "PROP", - "country": "US", - "currency": "USD", - "status": "Bound" - } -} -``` - -# Working with Submissions - -## Uploading Files - -Pricings support file uploads for submission documents: -1. Navigate to a pricing (e.g., Cornerstone/Microsoft/2026) -2. Select the **Submission** view -3. Upload files such as slip documents, Excel data, or PDFs - -## Excel Import - -Property risk data can be imported from Excel: -1. Upload an Excel file to Submissions -2. Configure import settings in **Import Configs** -3. Map columns to PropertyRisk fields -4. Execute import to populate the Property Risks DataGrid - -# Next Steps - -1. **Explore the Data**: Navigate through Cornerstone insureds and examine pricing data -2. **Use the Agent**: Try natural language commands in the chat interface -3. **View Property Risks**: Explore the Property Risks and Risk Map views -4. **Examine the Structure**: Review reinsurance layers in the Structure view -5. **Upload Submissions**: Try the file upload workflow diff --git a/samples/Graph/Data/Cornerstone/Documentation/Overview.md b/samples/Graph/Data/Cornerstone/Documentation/Overview.md deleted file mode 100644 index 9d044937cb..0000000000 --- a/samples/Graph/Data/Cornerstone/Documentation/Overview.md +++ /dev/null @@ -1,186 +0,0 @@ ---- -NodeType: "Cornerstone/Article" -Title: "Cornerstone Case Studies" -Abstract: "Learn MeshWeaver through practical examples with the Cornerstone reinsurance pricing sample" -Icon: "Document" -Published: "2025-01-31" -Authors: - - "MeshWeaver Team" -Tags: - - "Cornerstone" - - "Getting Started" ---- - -The Cornerstone sample organization demonstrates MeshWeaver capabilities through a realistic reinsurance pricing scenario, including property risk management, geographic visualization, and document-based workflows. - ---- - -# What do you want to learn? - -| Topic | Go here | -|-------|---------| -| Get up and running | [Getting Started](Cornerstone/Documentation/GettingStarted) - Setup, navigation, first steps | -| Understand the architecture | [Architecture](Cornerstone/Documentation/Architecture) - Data model, NodeTypes, pricing pipeline | -| Add AI to your app | [AI Agent Integration](Cornerstone/Documentation/AIAgentIntegration) - Pricing assistant, risk queries | -| Reference paths and queries | [Unified References](Cornerstone/Documentation/UnifiedReferences) - Paths, queries, layout areas | - ---- - -# The Cornerstone Organization - -Cornerstone is a reinsurance company with corporate clients organized by underwriting year: - -``` -Cornerstone/ # Organization -├── Insured/ # NodeType definition -│ └── Code/ -│ ├── Insured.cs # Insured data model -│ └── InsuranceViews.cs # Insured-level views -├── Pricing/ # NodeType definition -│ └── Code/ -│ ├── Pricing.cs # Pricing data model -│ ├── PricingViews.cs # 7 views (Overview, PropertyRisks, ...) -│ ├── PropertyRisk.cs # Property risk entity -│ ├── ReinsuranceAcceptance.cs # Layer definitions -│ ├── ReinsuranceSection.cs # Section details -│ ├── MicrosoftDataLoader.cs # Sample data loading -│ └── SlipParser.cs # Document parsing -├── Microsoft/ # Sample insured -│ └── 2026/ # Pricing year -│ ├── PropertyRisks.json # 50+ property locations -│ └── Submissions/ # Uploaded documents -├── Tesla/ # Sample insured -│ └── 2026/ # Pricing year -└── Nestle/ # Sample insured - └── 2026/ # Pricing year -``` - ---- - -# Key Concepts Demonstrated - -## Hierarchical NodeTypes - -The `Insured` → `Pricing` hierarchy models the natural structure of reinsurance: -- **Insured**: Corporate clients with multiple underwriting years -- **Pricing**: Year-specific pricing instances with risks, structure, and documents - -Each level inherits configuration while allowing specialization: -``` -Insured (NodeType) - └── Pricing (NodeType) - ├── PropertyRisk (data) - ├── ReinsuranceAcceptance (data) - └── ReinsuranceSection (data) -``` - -## Property Risk Data Model - -The `PropertyRisk` entity captures insurance-specific property details: - -| Field | Description | -|-------|-------------| -| LocationName | Site or facility name | -| Address, City, State, Country | Location details | -| TsiBuilding | Total Sum Insured for buildings | -| TsiContent | Total Sum Insured for contents | -| TsiBi | Business Interruption coverage | -| GeocodedLocation | Resolved coordinates for mapping | -| OccupancyCode, ConstructionCode | Classification codes | -| BuildYear, NumberOfStories, Sprinklers | Physical attributes | - -## Reinsurance Structure - -Multi-layer programs are modeled with: -- **ReinsuranceAcceptance**: Layer definitions with EPI, rate, brokerage -- **ReinsuranceSection**: Sections within layers with attachment/limit points - -## Content Collections - -File uploads are managed via content collections: -- Excel submissions for property schedules -- Email chains for broker correspondence -- Slip documents for coverage terms - ---- - -# Views Architecture - -Each pricing includes 7 views for different perspectives: - -| View | Purpose | Key Features | -|------|---------|--------------| -| Overview | Summary dashboard | Status badge, coverage period, classification | -| PropertyRisks | Property schedule | DataGrid with TSI columns, source tracking | -| RiskMap | Geographic visualization | Google Maps integration, marker clustering | -| Structure | Reinsurance layers | Acceptance and section DataGrids | -| Submission | File management | FileBrowser with upload capability | -| ImportConfigs | Import mappings | Excel column configurations | -| Thumbnail | Catalog card | Styled card for catalog display | - -## View Implementation Pattern - -Views follow a reactive pattern using `IObservable`: - -```csharp -public static IObservable PropertyRisks(LayoutAreaHost host, RenderingContext _) -{ - return host.Workspace.GetStream() - .Select(risks => BuildDataGrid(risks)); -} -``` - ---- - -# Sample Data - -## Microsoft Pricing (Full Sample) - -The Microsoft 2026 pricing includes complete sample data: - -| Entity | Count | Description | -|--------|-------|-------------| -| PropertyRisk | 50+ | Locations across US, Europe, Asia | -| ReinsuranceAcceptance | 4 | Multi-layer program | -| ReinsuranceSection | 8 | Sections per layer | -| Submission Files | 4 | Excel schedules, email chains | - -## Other Insureds - -| Insured | Year | Status | -|---------|------|--------| -| Tesla | 2026 | Sample pricing structure | -| Nestle | 2026 | Sample pricing structure | -| GlobalManufacturing | 2024 | Historical reference | -| EuropeanLogistics | 2024 | Historical reference | -| TechIndustries | 2024 | Historical reference | - ---- - -# Dimension System - -Pricings use standardized dimensions for filtering: - -| Dimension | Values | -|-----------|--------| -| LineOfBusiness | Property, Liability, Specialty, Marine, Aviation | -| Country | US, DE, UK, CH, FR, JP, CN, ... | -| Currency | USD, EUR, CHF, GBP, JPY, CNY | -| PricingStatus | Draft, InReview, Quoted, Bound, Declined, Expired | -| LegalEntity | Primary insurers and reinsurers | - -Dimensions enable: -- Consistent filtering across views -- Aggregation in analytics -- Validation in data entry - ---- - -# Explore Further - -Navigate to `Cornerstone` in the portal to explore: -- The Microsoft pricing with full sample data -- Property risk visualization on the RiskMap -- Reinsurance structure in the Structure view -- File upload and import workflows -- The AI chat agent for pricing queries diff --git a/samples/Graph/Data/Cornerstone/Documentation/UnifiedReferences.md b/samples/Graph/Data/Cornerstone/Documentation/UnifiedReferences.md deleted file mode 100644 index d2f7255bc4..0000000000 --- a/samples/Graph/Data/Cornerstone/Documentation/UnifiedReferences.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -NodeType: "Cornerstone/Article" -Title: "Unified References in Cornerstone" -Abstract: "Reference guide for namespace paths, data queries, and views in the Cornerstone sample" -Icon: "Document" -Published: "2025-01-31" -Authors: - - "MeshWeaver Team" -Tags: - - "Cornerstone" - - "References" ---- - -This document demonstrates unified references in the Cornerstone sample. For the complete Unified Path syntax reference, see [Unified Path](/Doc/DataMesh/UnifiedPath). - -It covers namespace hierarchy, data queries, content paths, and views specific to the Cornerstone reinsurance use case. - -# Organization Structure - -For the full namespace hierarchy, see [Cornerstone Architecture](Cornerstone/Documentation/Architecture). - -# Namespace Paths - -## Root Level - -| Path | Description | -|------|-------------| -| `Cornerstone` | The reinsurance company namespace | - -## NodeType Definitions - -| Path | Description | -|------|-------------| -| `Cornerstone/Insured` | Insured NodeType (client organizations) | -| `Cornerstone/Pricing` | Pricing NodeType (reinsurance pricings) | - -## Insured Instances - -| Path | Insured | -|------|---------| -| `Cornerstone/Microsoft` | Microsoft Corporation | -| `Cornerstone/GlobalManufacturing` | Global Manufacturing Corp | -| `Cornerstone/EuropeanLogistics` | European Logistics Ltd | -| `Cornerstone/TechIndustries` | Tech Industries GmbH | -| `Cornerstone/Tesla` | Tesla, Inc. | -| `Cornerstone/Nestle` | Nestle S.A. | - -## Pricing Instances - -| Path | Pricing | -|------|---------| -| `Cornerstone/Microsoft/2026` | Microsoft 2026 Property Reinsurance | -| `Cornerstone/GlobalManufacturing/2024` | Global Manufacturing 2024 Pricing | -| `Cornerstone/EuropeanLogistics/2024` | European Logistics 2024 Pricing | -| `Cornerstone/TechIndustries/2024` | Tech Industries 2024 Pricing | -| `Cornerstone/Tesla/2026` | Tesla 2026 Property Reinsurance | -| `Cornerstone/Nestle/2026` | Nestle 2026 Property Reinsurance | - -# Query Syntax - -MeshWeaver uses a GitHub-style query syntax for searching nodes. For complete query syntax reference, see [Query Syntax](/Doc/DataMesh/QuerySyntax). - -## Cornerstone Query Examples - -**All Pricings**: -``` -nodeType:Cornerstone/Pricing -``` - -**Pricings by Status** (Bound pricings): -``` -nodeType:Cornerstone/Pricing status:Bound -``` - -**Pricings by Line of Business** (Property pricings): -``` -nodeType:Cornerstone/Pricing lineOfBusiness:PROP -``` - -**Pricings for an Insured** (Microsoft pricings): -``` -path:Cornerstone/Microsoft nodeType:Cornerstone/Pricing scope:subtree -``` - -**All Insureds**: -``` -nodeType:Cornerstone/Insured -``` - -# Data References - -See [Data Prefix](/Doc/DataMesh/UnifiedPath/DataPrefix) for the generic data reference syntax. - -## Display Insured - -``` -@Cornerstone/Microsoft -``` - -@Cornerstone/Microsoft - -## Display Pricing - -``` -@Cornerstone/Microsoft/2026 -``` - -@Cornerstone/Microsoft/2026 - -## Display Property Risks - -``` -@@Cornerstone/Microsoft/2026/data/PropertyRisk -``` - -@@Cornerstone/Microsoft/2026/data/PropertyRisk - -## Display Reinsurance Acceptances - -``` -@@Cornerstone/Microsoft/2026/data/ReinsuranceAcceptance -``` - -@@Cornerstone/Microsoft/2026/data/ReinsuranceAcceptance - -## Display Reinsurance Sections - -``` -@@Cornerstone/Microsoft/2026/data/ReinsuranceSection -``` - -@@Cornerstone/Microsoft/2026/data/ReinsuranceSection - -# Dimension References - -Dimensions are shared across all pricings using the same NodeType. - -## PricingStatus Dimension - -| Status | Description | Order | -|--------|-------------|-------| -| Draft | Initial pricing draft, not yet submitted for quote | 0 | -| Quoted | Quote has been issued to the client | 1 | -| Bound | Policy has been bound and coverage is in effect | 2 | -| Declined | Pricing was declined by client or underwriter | 3 | -| Expired | Quote or policy has expired | 4 | - -## LineOfBusiness Dimension - -| Code | Name | Description | -|------|------|-------------| -| PROP | Property | Property insurance covering buildings, contents, and business interruption | -| CAS | Casualty | Casualty insurance covering liability and workers compensation | -| MARINE | Marine | Marine and cargo insurance | -| AVIATION | Aviation | Aviation and aerospace insurance | -| ENERGY | Energy | Energy sector insurance including oil & gas | - -## Country Dimension - -| Code | Name | Region | -|------|------|--------| -| US | United States | North America | -| GB | United Kingdom | Europe | -| DE | Germany | Europe | -| FR | France | Europe | -| JP | Japan | Asia | -| CN | China | Asia | -| AU | Australia | Oceania | -| CA | Canada | North America | -| CH | Switzerland | Europe | -| SG | Singapore | Asia | - -## Currency Dimension - -| Code | Name | Symbol | -|------|------|--------| -| USD | US Dollar | $ | -| EUR | Euro | € | -| GBP | British Pound | £ | -| JPY | Japanese Yen | ¥ | -| CHF | Swiss Franc | CHF | -| AUD | Australian Dollar | A$ | -| CAD | Canadian Dollar | C$ | - -## LegalEntity Dimension - -| Code | Name | Country | -|------|------|---------| -| CS-US | Cornerstone US Inc. | United States | -| CS-UK | Cornerstone UK Ltd. | United Kingdom | -| CS-EU | Cornerstone Europe AG | Switzerland | -| CS-ASIA | Cornerstone Asia Pte. Ltd. | Singapore | - -# Content References - -## Submission Files - -Content collections allow file references: - -``` -@@Cornerstone/Microsoft/2026/content/Submissions/Slip.md -``` - -@@Cornerstone/Microsoft/2026/content/Submissions/Slip.md - -``` -@@Cornerstone/Microsoft/2026/content/Submissions/Microsoft.xlsx -``` - -# View References - -Views are defined in `PricingViews.cs` and available for all pricings. See [Area Prefix](/Doc/DataMesh/UnifiedPath/AreaPrefix) for view syntax. - -## Overview - Pricing Summary - -Shows pricing header with insured details, dates, and financial summary: - -``` -@Cornerstone/Microsoft/2026/Overview -``` - -@Cornerstone/Microsoft/2026/Overview - -## Property Risks - Risk Data Grid - -DataGrid showing all property locations with TSI values: - -``` -@Cornerstone/Microsoft/2026/PropertyRisks -``` - -@Cornerstone/Microsoft/2026/PropertyRisks - -## Risk Map - Geographic Visualization - -Google Maps visualization of geocoded property locations: - -``` -@Cornerstone/Microsoft/2026/RiskMap -``` - -@Cornerstone/Microsoft/2026/RiskMap - -## Structure - Reinsurance Layers - -DataGrids showing reinsurance layers and sections: - -``` -@Cornerstone/Microsoft/2026/Structure -``` - -@Cornerstone/Microsoft/2026/Structure - -## Submission - File Browser - -File browser for uploaded submission documents: - -``` -@Cornerstone/Microsoft/2026/Submission -``` - -@Cornerstone/Microsoft/2026/Submission - -## Import Configs - Import Settings - -Excel import configuration settings: - -``` -@Cornerstone/Microsoft/2026/ImportConfigs -``` - -@Cornerstone/Microsoft/2026/ImportConfigs - -## PricingCatalog - Insured's Pricings - -All pricings for an insured grouped by status: - -``` -@Cornerstone/Microsoft/PricingCatalog -``` - -@Cornerstone/Microsoft/PricingCatalog - -# Navigation Links - -## Link to Insured - -```markdown -[Microsoft Corporation](Cornerstone/Microsoft) -[Global Manufacturing](Cornerstone/GlobalManufacturing) -[European Logistics](Cornerstone/EuropeanLogistics) -[Tech Industries](Cornerstone/TechIndustries) -[Tesla](Cornerstone/Tesla) -[Nestle](Cornerstone/Nestle) -``` - -## Link to Pricing - -```markdown -[Microsoft 2026](Cornerstone/Microsoft/2026) -[Global Manufacturing 2024](Cornerstone/GlobalManufacturing/2024) -[Tesla 2026](Cornerstone/Tesla/2026) -[Nestle 2026](Cornerstone/Nestle/2026) -``` - -## Link to View - -```markdown -[Overview](Cornerstone/Microsoft/2026/Overview) -[Property Risks](Cornerstone/Microsoft/2026/PropertyRisks) -[Risk Map](Cornerstone/Microsoft/2026/RiskMap) -[Reinsurance Structure](Cornerstone/Microsoft/2026/Structure) -``` - -# Sample Insureds - -| Insured | Industry | Location | -|---------|----------|----------| -| Microsoft Corporation | Technology | United States | -| Global Manufacturing Corp | Manufacturing | United States | -| European Logistics Ltd | Logistics & Transportation | United Kingdom | -| Tech Industries GmbH | Technology Manufacturing | Germany | -| Tesla, Inc. | Automotive & Energy | United States | -| Nestle S.A. | Food & Beverage | Switzerland | - -# Summary - -The Cornerstone sample demonstrates MeshWeaver's unified path system for insurance: - -- **Namespace paths** define the hierarchical Insured → Pricing structure -- **NodeType references** enable shared definitions across all insureds -- **Query syntax** allows flexible searching by status, line of business, etc. -- **Data references** embed live insurance data (PropertyRisk, Reinsurance) -- **Views** provide consistent displays (Overview, Property Risks, Risk Map, Structure) -- **Content paths** reference submission files and imported documents - -All paths follow the pattern: `Cornerstone/[Insured]/[Pricing]/[View]` diff --git a/samples/Graph/Data/Cornerstone/EuropeanLogistics.json b/samples/Graph/Data/Cornerstone/EuropeanLogistics.json deleted file mode 100644 index 6eb903d56d..0000000000 --- a/samples/Graph/Data/Cornerstone/EuropeanLogistics.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "EuropeanLogistics", - "namespace": "Cornerstone", - "name": "European Logistics Ltd", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "UK logistics and transportation company - Insurance client", - "icon": "/api/content/Cornerstone/icons/europeanlogistics.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "EuropeanLogistics", - "name": "European Logistics Ltd", - "description": "UK logistics and transportation company", - "address": "Logistics Park, Birmingham B1 1AA, United Kingdom", - "industry": "Logistics & Transportation", - "location": "United Kingdom" - } -} diff --git a/samples/Graph/Data/Cornerstone/EuropeanLogistics/2024.json b/samples/Graph/Data/Cornerstone/EuropeanLogistics/2024.json deleted file mode 100644 index 2a1a40a52b..0000000000 --- a/samples/Graph/Data/Cornerstone/EuropeanLogistics/2024.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "2024", - "namespace": "Cornerstone/EuropeanLogistics", - "name": "European Logistics 2024", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for European Logistics Ltd - UW Year 2024", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2024", - "insuredName": "European Logistics Ltd", - "description": "Property reinsurance program for European Logistics Ltd covering UK and European warehouses and distribution centers.", - "inceptionDate": "2024-03-01T00:00:00Z", - "expirationDate": "2025-02-28T00:00:00Z", - "underwritingYear": 2024, - "lineOfBusiness": "PROP", - "country": "GB", - "currency": "GBP", - "legalEntity": "MW-UK", - "status": "Quoted" - } -} diff --git a/samples/Graph/Data/Cornerstone/GlobalManufacturing.json b/samples/Graph/Data/Cornerstone/GlobalManufacturing.json deleted file mode 100644 index 15e97f4b4c..0000000000 --- a/samples/Graph/Data/Cornerstone/GlobalManufacturing.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "GlobalManufacturing", - "namespace": "Cornerstone", - "name": "Global Manufacturing Corp", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "US manufacturing corporation - Insurance client", - "icon": "/api/content/Cornerstone/icons/globalmanufacturing.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "GlobalManufacturing", - "name": "Global Manufacturing Corp", - "description": "US manufacturing corporation with global operations", - "address": "500 Industrial Boulevard, Detroit, MI 48201, United States", - "industry": "Manufacturing", - "location": "United States" - } -} diff --git a/samples/Graph/Data/Cornerstone/GlobalManufacturing/2024.json b/samples/Graph/Data/Cornerstone/GlobalManufacturing/2024.json deleted file mode 100644 index 70716f4d40..0000000000 --- a/samples/Graph/Data/Cornerstone/GlobalManufacturing/2024.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "2024", - "namespace": "Cornerstone/GlobalManufacturing", - "name": "Global Manufacturing 2024", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for Global Manufacturing Corp - UW Year 2024", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2024", - "insuredName": "Global Manufacturing Corp", - "description": "Property reinsurance program for Global Manufacturing Corp covering US and international manufacturing plants.", - "inceptionDate": "2024-01-01T00:00:00Z", - "expirationDate": "2024-12-31T00:00:00Z", - "underwritingYear": 2024, - "lineOfBusiness": "PROP", - "country": "US", - "currency": "USD", - "legalEntity": "MW-US", - "status": "Bound" - } -} diff --git a/samples/Graph/Data/Cornerstone/Insured.json b/samples/Graph/Data/Cornerstone/Insured.json deleted file mode 100644 index bf7d81e88d..0000000000 --- a/samples/Graph/Data/Cornerstone/Insured.json +++ /dev/null @@ -1 +0,0 @@ -{"$type":"MeshNode","id":"Insured","namespace":"Cornerstone","path":"Cornerstone/Insured","mainNode":"Cornerstone/Insured","name":"Insured","description":"An insurance client with pricings grouped by status","nodeType":"NodeType","category":"Types","icon":"/api/content/Cornerstone/icons/insured.svg","lastModified":"2026-06-26T09:24:11.07986+00:00","lastModifiedBy":"system-security","version":10,"state":"Active","content":{"$type":"NodeTypeDefinition","description":"An insurance client with pricings","configuration":"config =\u003E config.WithContentType\u003CInsured\u003E().AddData(data =\u003E data.AddSource(source =\u003E source.WithType\u003CPricingStatus\u003E(t =\u003E t.WithInitialData(PricingStatus.All)))).AddDefaultLayoutAreas().AddLayout(layout =\u003E layout.WithDefaultArea(\u0022PricingCatalog\u0022).AddInsuranceLayoutAreas())","showChildrenInDetails":true,"detailsChildrenLimit":10,"includeGlobalTypes":true}} \ No newline at end of file diff --git a/samples/Graph/Data/Cornerstone/Insured/Source/InsuranceLayoutAreas.cs b/samples/Graph/Data/Cornerstone/Insured/Source/InsuranceLayoutAreas.cs deleted file mode 100644 index 32b0c44427..0000000000 --- a/samples/Graph/Data/Cornerstone/Insured/Source/InsuranceLayoutAreas.cs +++ /dev/null @@ -1,100 +0,0 @@ -// -// Id: InsuranceLayoutAreas -// DisplayName: Insurance Views -// - -using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Catalog; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; - -/// -/// Catalog views for ACME Insurance portfolio using CatalogControl with LayoutAreaControl thumbnails. -/// -public static class InsuranceLayoutAreas -{ - /// - /// Registers all ACME Insurance views with the layout definition. - /// - public static LayoutDefinition AddInsuranceLayoutAreas(this LayoutDefinition layout) => - layout.WithView("PricingCatalog", PricingCatalog); - - private static Dictionary ApplyChanges( - Dictionary current, QueryResultChange change) - { - var result = change.ChangeType == QueryChangeType.Initial || change.ChangeType == QueryChangeType.Reset - ? new Dictionary(StringComparer.OrdinalIgnoreCase) - : new Dictionary(current, StringComparer.OrdinalIgnoreCase); - foreach (var item in change.Items) - { - if (change.ChangeType == QueryChangeType.Removed) result.Remove(item.Path); - else result[item.Path] = item; - } - return result; - } - - private static string? GetProp(MeshNode node, string prop) - { - if (node.Content is not JsonElement json) return null; - if (json.TryGetProperty(prop, out var p) && p.ValueKind == JsonValueKind.String) return p.GetString(); - var pascal = char.ToUpperInvariant(prop[0]) + prop.Substring(1); - if (json.TryGetProperty(pascal, out var pp) && pp.ValueKind == JsonValueKind.String) return pp.GetString(); - return null; - } - - private static ImmutableList Thumbnails(IEnumerable nodes) => - nodes.Select(n => (UiControl)new LayoutAreaControl(n.Path, new LayoutAreaReference("Thumbnail")) - .WithSpinnerType(SpinnerType.Dots)).ToImmutableList(); - - /// - /// Pricings grouped by status. - /// - [Display(GroupName = "Overview", Order = 0)] - public static IObservable PricingCatalog(LayoutAreaHost host, RenderingContext _) - { - var hubPath = host.Hub.Address.ToString(); - var query = $"path:{hubPath} nodeType:Cornerstone/Pricing state:Active scope:subtree"; - - var statuses = host.Workspace.GetObservable() - .StartWith(PricingStatus.All.ToList()) - .Select(s => s.OrderBy(x => x.Order).ToList()); - - var meshQuery = host.Hub.ServiceProvider.GetRequiredService(); - - var nodes = meshQuery - .Query(MeshQueryRequest.FromQuery(query)) - .Scan(new Dictionary(StringComparer.OrdinalIgnoreCase), ApplyChanges); - - return nodes.CombineLatest(statuses, (dict, statusList) => - { - var groups = statusList.Select(status => - { - var items = dict.Values - .Where(n => (GetProp(n, "status") ?? "Draft") == status.Id) - .OrderBy(n => n.Name).ToList(); - return items.Any() ? new CatalogGroup - { - Key = status.Id, - Label = status.Name, - Emoji = status.Emoji, - Order = status.Order, - IsExpanded = status.IsExpandedByDefault, - Items = Thumbnails(items), - TotalCount = items.Count - } : null; - }).Where(g => g != null).Cast().ToImmutableList(); - - if (!groups.Any()) - { - return (UiControl?)Controls.Markdown("*No pricings available. Create a new pricing to get started.*"); - } - - return (UiControl?)new CatalogControl().WithGroups(groups); - }); - } -} diff --git a/samples/Graph/Data/Cornerstone/Insured/Source/Insured.cs b/samples/Graph/Data/Cornerstone/Insured/Source/Insured.cs deleted file mode 100644 index 663b1eaff2..0000000000 --- a/samples/Graph/Data/Cornerstone/Insured/Source/Insured.cs +++ /dev/null @@ -1,46 +0,0 @@ -// -// Id: Insured -// DisplayName: Insured Data Model -// - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; -using MeshWeaver.Mesh; - -/// -/// Represents an insurance client (the insured party). -/// -public record Insured -{ - [Key] - public string Id { get; init; } = string.Empty; - - [Required] - [MeshNodeProperty(nameof(MeshNode.Name))] - [Description("Legal name of the insured company or individual")] - public string Name { get; init; } = string.Empty; - - [Description("Brief description of the insured's business activities")] - public string? Description { get; init; } - - /// - /// Physical address of the insured. - /// - [Description("Physical headquarters or primary business address")] - public string? Address { get; init; } - - [Description("Company website URL")] - [Url(ErrorMessage = "Please enter a valid website URL")] - public string? Website { get; init; } - - [Description("Primary contact email address")] - [EmailAddress(ErrorMessage = "Please enter a valid email address")] - public string? Email { get; init; } - - [Description("Industry sector or business classification")] - public string? Industry { get; init; } - - [Description("Primary geographic location of operations")] - public string? Location { get; init; } -} diff --git a/samples/Graph/Data/Cornerstone/Insured/Source/PricingStatus.cs b/samples/Graph/Data/Cornerstone/Insured/Source/PricingStatus.cs deleted file mode 100644 index 977729f1c8..0000000000 --- a/samples/Graph/Data/Cornerstone/Insured/Source/PricingStatus.cs +++ /dev/null @@ -1,102 +0,0 @@ -// -// Id: PricingStatus -// DisplayName: Pricing Status Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - -/// -/// Represents a pricing status with display metadata. -/// -public record PricingStatus : INamed -{ - /// - /// Status identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Display name for the status. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Description of the status. - /// - public string? Description { get; init; } - - /// - /// Emoji icon for visual representation. - /// - public string Emoji { get; init; } = string.Empty; - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - /// - /// Whether groups with this status should be expanded by default in catalog views. - /// - public bool IsExpandedByDefault { get; init; } = true; - - string INamed.DisplayName => Name; - - public static readonly PricingStatus Draft = new() - { - Id = "Draft", - Name = "Draft", - Emoji = "\ud83d\udcdd", - Description = "Initial pricing draft, not yet submitted for quote", - Order = 0, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Quoted = new() - { - Id = "Quoted", - Name = "Quoted", - Emoji = "\ud83d\udcb0", - Description = "Quote has been issued to the client", - Order = 1, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Bound = new() - { - Id = "Bound", - Name = "Bound", - Emoji = "\u2705", - Description = "Policy has been bound and coverage is in effect", - Order = 2, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Declined = new() - { - Id = "Declined", - Name = "Declined", - Emoji = "\u274c", - Description = "Pricing was declined by client or underwriter", - Order = 3, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus Expired = new() - { - Id = "Expired", - Name = "Expired", - Emoji = "\u23f0", - Description = "Quote or policy has expired", - Order = 4, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus[] All = [Draft, Quoted, Bound, Declined, Expired]; - - public static PricingStatus GetById(string? id) => - All.FirstOrDefault(s => s.Id == id) ?? Draft; -} diff --git a/samples/Graph/Data/Cornerstone/Microsoft.json b/samples/Graph/Data/Cornerstone/Microsoft.json deleted file mode 100644 index 381ca1620a..0000000000 --- a/samples/Graph/Data/Cornerstone/Microsoft.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "Microsoft", - "namespace": "Cornerstone", - "name": "Microsoft Corporation", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "Global technology company - Insurance client", - "icon": "/api/content/Cornerstone/icons/microsoft.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "Microsoft", - "name": "Microsoft Corporation", - "description": "Global technology company", - "address": "One Microsoft Way, Redmond, WA 98052, United States", - "website": "https://microsoft.com", - "industry": "Technology", - "location": "United States" - } -} diff --git a/samples/Graph/Data/Cornerstone/Microsoft/2026.json b/samples/Graph/Data/Cornerstone/Microsoft/2026.json deleted file mode 100644 index 87ac1cf9d6..0000000000 --- a/samples/Graph/Data/Cornerstone/Microsoft/2026.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "id": "2026", - "namespace": "Cornerstone/Microsoft", - "name": "Microsoft 2026", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for Microsoft Corporation - UW Year 2026", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2026", - "insuredName": "Microsoft Corporation", - "description": "Global property reinsurance program for Microsoft Corporation covering all worldwide locations. Reinsurance provided to Sentinel Global Insurance SE via Orion Risk Partners Ltd.", - "inceptionDate": "2026-01-01T00:00:00Z", - "expirationDate": "2026-12-31T00:00:00Z", - "underwritingYear": 2026, - "lineOfBusiness": "PROP", - "country": "US", - "brokerName": "Orion Risk Partners Ltd.", - "primaryInsurance": "Sentinel Global Insurance SE", - "currency": "USD", - "legalEntity": "MW-US", - "status": "Bound" - } -} diff --git a/samples/Graph/Data/Cornerstone/Nestle.json b/samples/Graph/Data/Cornerstone/Nestle.json deleted file mode 100644 index 008f4fa195..0000000000 --- a/samples/Graph/Data/Cornerstone/Nestle.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "Nestle", - "namespace": "Cornerstone", - "name": "Nestle S.A.", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "Global food and beverage company - Insurance client", - "icon": "/api/content/Cornerstone/icons/nestle.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "Nestle", - "name": "Nestle S.A.", - "description": "Global food and beverage company", - "address": "Avenue Nestle 55, 1800 Vevey, Switzerland", - "website": "https://nestle.com", - "industry": "Food & Beverage", - "location": "Switzerland" - } -} diff --git a/samples/Graph/Data/Cornerstone/Nestle/2026.json b/samples/Graph/Data/Cornerstone/Nestle/2026.json deleted file mode 100644 index cdc12a7978..0000000000 --- a/samples/Graph/Data/Cornerstone/Nestle/2026.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "2026", - "namespace": "Cornerstone/Nestle", - "name": "Nestle 2026", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for Nestle S.A. - UW Year 2026", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2026", - "insuredName": "Nestle S.A.", - "description": "Property reinsurance program for Nestle S.A. covering production facilities, distribution centers, and offices across Europe and worldwide.", - "inceptionDate": "2026-01-01T00:00:00Z", - "expirationDate": "2026-12-31T00:00:00Z", - "underwritingYear": 2026, - "lineOfBusiness": "PROP", - "country": "CH", - "currency": "CHF", - "legalEntity": "MW-EU", - "status": "Draft" - } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing.json b/samples/Graph/Data/Cornerstone/Pricing.json deleted file mode 100644 index b85047b5bf..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "$type": "MeshNode", - "id": "Pricing", - "namespace": "Cornerstone", - "path": "Cornerstone/Pricing", - "mainNode": "Cornerstone/Pricing", - "name": "Pricing", - "description": "Cornerstone pricing with property risks and reinsurance structure", - "nodeType": "NodeType", - "category": "Types", - "icon": "/api/content/Cornerstone/icons/pricing.svg", - "lastModified": "2026-06-26T09:24:11.080473+00:00", - "lastModifiedBy": "system-security", - "version": 10, - "state": "Active", - "content": { - "$type": "NodeTypeDefinition", - "description": "Cornerstone pricing with property risks and reinsurance structure", - "configuration": "config => config.WithContentType().AddContentCollection(sp => { var hub = sp.GetRequiredService(); var pricingId = hub.Address.Id; var basePath = System.IO.Path.Combine(\"../../samples/Graph/attachments/Cornerstone\", hub.Address.Segments[1], hub.Address.Segments[2], \"Submissions\"); return new ContentCollectionConfig { SourceType = FileSystemStreamProvider.SourceType, Name = $\"Submissions@{pricingId}\", BasePath = basePath, DisplayName = \"Submission Files\" }; }).AddData(data => data.AddSource(source => { var hub = source.Workspace.Hub; var pricingId = hub.Address.Id; var insuredName = hub.Address.Segments[1]; var isMicrosoft = insuredName == \"Microsoft\"; return source.WithType(t => t.WithInitialData(LineOfBusiness.All)).WithType(t => t.WithInitialData(Country.All)).WithType(t => t.WithInitialData(Currency.All)).WithType(t => t.WithInitialData(LegalEntity.All)).WithType(t => t.WithInitialData(PricingStatus.All)).WithType(t => isMicrosoft ? t.WithInitialData(() => MicrosoftDataLoader.LoadPropertyRisks(hub)) : t.WithInitialData(Array.Empty())).WithType(t => isMicrosoft ? t.WithInitialData(() => MicrosoftDataLoader.LoadReinsuranceAcceptances(hub, pricingId)) : t.WithInitialData(Array.Empty())).WithType(t => isMicrosoft ? t.WithInitialData(() => MicrosoftDataLoader.LoadReinsuranceSections(hub, pricingId)) : t.WithInitialData(Array.Empty())); })).AddDefaultLayoutAreas().AddLayout(layout => layout.WithDefaultArea(\"Overview\").AddPricingLayoutAreas())", - "showChildrenInDetails": true, - "detailsChildrenLimit": 10, - "includeGlobalTypes": true - } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/Country.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/Country.cs deleted file mode 100644 index 62f1545e34..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/Country.cs +++ /dev/null @@ -1,160 +0,0 @@ -// -// Id: Country -// DisplayName: Country Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Country dimension for geographic classification. -/// -public record Country : INamed -{ - /// - /// Country code (typically ISO 3166-1 alpha-2). - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full country name. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// ISO 3166-1 alpha-3 code. - /// - public string? Alpha3Code { get; init; } - - /// - /// Geographic region. - /// - public string? Region { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly Country UnitedStates = new() - { - Id = "US", - Name = "United States", - Alpha3Code = "USA", - Region = "North America", - Order = 0 - }; - - public static readonly Country UnitedKingdom = new() - { - Id = "GB", - Name = "United Kingdom", - Alpha3Code = "GBR", - Region = "Europe", - Order = 1 - }; - - public static readonly Country Germany = new() - { - Id = "DE", - Name = "Germany", - Alpha3Code = "DEU", - Region = "Europe", - Order = 2 - }; - - public static readonly Country France = new() - { - Id = "FR", - Name = "France", - Alpha3Code = "FRA", - Region = "Europe", - Order = 3 - }; - - public static readonly Country Japan = new() - { - Id = "JP", - Name = "Japan", - Alpha3Code = "JPN", - Region = "Asia", - Order = 4 - }; - - public static readonly Country China = new() - { - Id = "CN", - Name = "China", - Alpha3Code = "CHN", - Region = "Asia", - Order = 5 - }; - - public static readonly Country Australia = new() - { - Id = "AU", - Name = "Australia", - Alpha3Code = "AUS", - Region = "Oceania", - Order = 6 - }; - - public static readonly Country Canada = new() - { - Id = "CA", - Name = "Canada", - Alpha3Code = "CAN", - Region = "North America", - Order = 7 - }; - - public static readonly Country Switzerland = new() - { - Id = "CH", - Name = "Switzerland", - Alpha3Code = "CHE", - Region = "Europe", - Order = 8 - }; - - public static readonly Country Singapore = new() - { - Id = "SG", - Name = "Singapore", - Alpha3Code = "SGP", - Region = "Asia", - Order = 9 - }; - - public static readonly Country Ireland = new() - { - Id = "IE", - Name = "Ireland", - Alpha3Code = "IRL", - Region = "Europe", - Order = 10 - }; - - public static readonly Country India = new() - { - Id = "IN", - Name = "India", - Alpha3Code = "IND", - Region = "Asia", - Order = 11 - }; - - public static readonly Country[] All = - [ - UnitedStates, UnitedKingdom, Germany, France, Japan, - China, Australia, Canada, Switzerland, Singapore, Ireland, India - ]; - - public static Country GetById(string? id) => - All.FirstOrDefault(c => c.Id == id) ?? UnitedStates; -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/Currency.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/Currency.cs deleted file mode 100644 index ed02804e73..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/Currency.cs +++ /dev/null @@ -1,111 +0,0 @@ -// -// Id: Currency -// DisplayName: Currency Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Currency dimension for monetary values. -/// -public record Currency : INamed -{ - /// - /// Currency code (ISO 4217). - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full currency name. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Currency symbol. - /// - public string? Symbol { get; init; } - - /// - /// Number of decimal places typically used. - /// - public int DecimalPlaces { get; init; } = 2; - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly Currency USD = new() - { - Id = "USD", - Name = "US Dollar", - Symbol = "$", - DecimalPlaces = 2, - Order = 0 - }; - - public static readonly Currency EUR = new() - { - Id = "EUR", - Name = "Euro", - Symbol = "\u20ac", - DecimalPlaces = 2, - Order = 1 - }; - - public static readonly Currency GBP = new() - { - Id = "GBP", - Name = "British Pound", - Symbol = "\u00a3", - DecimalPlaces = 2, - Order = 2 - }; - - public static readonly Currency JPY = new() - { - Id = "JPY", - Name = "Japanese Yen", - Symbol = "\u00a5", - DecimalPlaces = 0, - Order = 3 - }; - - public static readonly Currency CHF = new() - { - Id = "CHF", - Name = "Swiss Franc", - Symbol = "CHF", - DecimalPlaces = 2, - Order = 4 - }; - - public static readonly Currency AUD = new() - { - Id = "AUD", - Name = "Australian Dollar", - Symbol = "A$", - DecimalPlaces = 2, - Order = 5 - }; - - public static readonly Currency CAD = new() - { - Id = "CAD", - Name = "Canadian Dollar", - Symbol = "C$", - DecimalPlaces = 2, - Order = 6 - }; - - public static readonly Currency[] All = [USD, EUR, GBP, JPY, CHF, AUD, CAD]; - - public static Currency GetById(string? id) => - All.FirstOrDefault(c => c.Id == id) ?? USD; -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/LegalEntity.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/LegalEntity.cs deleted file mode 100644 index 878e82420f..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/LegalEntity.cs +++ /dev/null @@ -1,111 +0,0 @@ -// -// Id: LegalEntity -// DisplayName: Legal Entity Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Legal entity dimension for organizational structure. -/// -public record LegalEntity : INamed -{ - /// - /// Legal entity identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full legal name of the entity. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Country of incorporation. - /// - public string? CountryOfIncorporation { get; init; } - - /// - /// Entity type (e.g., Corporation, LLC, Partnership). - /// - public string? EntityType { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly LegalEntity AcmeUS = new() - { - Id = "CS-US", - Name = "ACME Insurance US Inc.", - CountryOfIncorporation = "US", - EntityType = "Corporation", - Order = 0 - }; - - public static readonly LegalEntity AcmeUK = new() - { - Id = "CS-UK", - Name = "ACME Insurance UK Ltd.", - CountryOfIncorporation = "GB", - EntityType = "Limited Company", - Order = 1 - }; - - public static readonly LegalEntity AcmeEU = new() - { - Id = "CS-EU", - Name = "ACME Insurance Europe AG", - CountryOfIncorporation = "CH", - EntityType = "Corporation", - Order = 2 - }; - - public static readonly LegalEntity AcmeAsia = new() - { - Id = "CS-ASIA", - Name = "ACME Insurance Asia Pte. Ltd.", - CountryOfIncorporation = "SG", - EntityType = "Private Limited", - Order = 3 - }; - - public static readonly LegalEntity MeshWeaverUS = new() - { - Id = "MW-US", - Name = "MeshWeaver Insurance US Inc.", - CountryOfIncorporation = "US", - EntityType = "Corporation", - Order = 4 - }; - - public static readonly LegalEntity MeshWeaverUK = new() - { - Id = "MW-UK", - Name = "MeshWeaver Insurance UK Ltd.", - CountryOfIncorporation = "GB", - EntityType = "Limited Company", - Order = 5 - }; - - public static readonly LegalEntity MeshWeaverEU = new() - { - Id = "MW-EU", - Name = "MeshWeaver Insurance Europe AG", - CountryOfIncorporation = "DE", - EntityType = "Corporation", - Order = 6 - }; - - public static readonly LegalEntity[] All = [AcmeUS, AcmeUK, AcmeEU, AcmeAsia, MeshWeaverUS, MeshWeaverUK, MeshWeaverEU]; - - public static LegalEntity GetById(string? id) => - All.FirstOrDefault(e => e.Id == id) ?? AcmeUS; -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/LineOfBusiness.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/LineOfBusiness.cs deleted file mode 100644 index aa9d410b85..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/LineOfBusiness.cs +++ /dev/null @@ -1,83 +0,0 @@ -// -// Id: LineOfBusiness -// DisplayName: Line of Business Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Line of business dimension for insurance classification. -/// -public record LineOfBusiness : INamed -{ - /// - /// Line of business code. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Display name for the line of business. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Description of the line of business. - /// - public string? Description { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly LineOfBusiness Property = new() - { - Id = "PROP", - Name = "Property", - Description = "Property insurance covering buildings, contents, and business interruption", - Order = 0 - }; - - public static readonly LineOfBusiness Casualty = new() - { - Id = "CAS", - Name = "Casualty", - Description = "Casualty insurance covering liability and workers compensation", - Order = 1 - }; - - public static readonly LineOfBusiness Marine = new() - { - Id = "MARINE", - Name = "Marine", - Description = "Marine and cargo insurance", - Order = 2 - }; - - public static readonly LineOfBusiness Aviation = new() - { - Id = "AVIATION", - Name = "Aviation", - Description = "Aviation and aerospace insurance", - Order = 3 - }; - - public static readonly LineOfBusiness Energy = new() - { - Id = "ENERGY", - Name = "Energy", - Description = "Energy sector insurance including oil & gas", - Order = 4 - }; - - public static readonly LineOfBusiness[] All = [Property, Casualty, Marine, Aviation, Energy]; - - public static LineOfBusiness GetById(string? id) => - All.FirstOrDefault(l => l.Id == id) ?? Property; -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/MicrosoftDataLoader.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/MicrosoftDataLoader.cs deleted file mode 100644 index e34fe696ea..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/MicrosoftDataLoader.cs +++ /dev/null @@ -1,201 +0,0 @@ -// -// Id: MicrosoftDataLoader -// DisplayName: Microsoft Data Loader -// - -using System; -using System.Collections.Generic; -using System.IO; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Messaging; -using MeshWeaver.Mesh.Threading; -using Microsoft.Extensions.DependencyInjection; - -/// -/// Loader utility for Microsoft pricing data. -/// Loads PropertyRisks from JSON and reinsurance structure from Slip.md. -/// Reads files directly from the file system to avoid timing issues with content service initialization. -/// Public surface is reactive only (IObservable via the FileSystem IIoPool); -/// the async file reads are private leaves pumped exclusively inside the pool. -/// -public static class MicrosoftDataLoader -{ - private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - /// - /// Loads PropertyRisk records from PropertyRisks.json in the content folder. - /// PRIVATE async leaf — only ever awaited inside the IIoPool bridge - /// (), never from hub-reachable code. - /// - /// The message hub for resolving paths - /// Cancellation token - /// Array of PropertyRisk records - private static async Task LoadPropertyRisksAsync( - IMessageHub hub, - CancellationToken cancellationToken = default) - { - try - { - var filePath = GetPropertyRisksFilePath(hub); - - if (!File.Exists(filePath)) - return Array.Empty(); - - using var stream = File.OpenRead(filePath); - var risks = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken); - return risks ?? Array.Empty(); - } - catch - { - return Array.Empty(); - } - } - - /// - /// Gets the file system path for PropertyRisks.json. - /// Tries multiple locations to support both production and test environments. - /// - /// The message hub - /// Absolute path to the PropertyRisks.json file - private static string GetPropertyRisksFilePath(IMessageHub hub) - { - var insuredName = hub.Address.Segments[1]; - var pricingYear = hub.Address.Segments[2]; - - // Try production path first (relative to current working directory) - var productionPath = Path.GetFullPath(Path.Combine( - "../../samples/Graph/content/Cornerstone", - insuredName, - pricingYear, - "PropertyRisks.json" - )); - - if (File.Exists(productionPath)) - return productionPath; - - // Try test path (SamplesGraph copied to bin directory) - var testPath = Path.Combine( - AppContext.BaseDirectory, - "SamplesGraph", - "content", - "Cornerstone", - insuredName, - pricingYear, - "PropertyRisks.json" - ); - - if (File.Exists(testPath)) - return testPath; - - // Fallback: return production path (will fail gracefully with empty data) - return productionPath; - } - - /// - /// Loads reinsurance structure from Slip.md in the Submissions folder. - /// PRIVATE async leaf — only ever awaited inside the IIoPool bridges - /// ( / ). - /// - /// The message hub for resolving paths - /// The pricing ID for generated records - /// Cancellation token - /// Tuple of acceptances and sections - private static async Task<(ReinsuranceAcceptance[] Acceptances, ReinsuranceSection[] Sections)> LoadReinsuranceStructureAsync( - IMessageHub hub, - string pricingId, - CancellationToken cancellationToken = default) - { - try - { - var basePath = GetSubmissionsBasePath(hub); - var filePath = Path.Combine(basePath, "Slip.md"); - - if (!File.Exists(filePath)) - return (Array.Empty(), Array.Empty()); - - var markdown = await File.ReadAllTextAsync(filePath, cancellationToken); - return SlipParser.ParseSlip(markdown, pricingId); - } - catch - { - return (Array.Empty(), Array.Empty()); - } - } - - /// - /// Gets the file system base path for Submissions folder. - /// Tries multiple locations to support both production and test environments. - /// - /// The message hub - /// Absolute path to the Submissions folder - private static string GetSubmissionsBasePath(IMessageHub hub) - { - var insuredName = hub.Address.Segments[1]; - var pricingYear = hub.Address.Segments[2]; - var submissionsSubPath = Path.Combine("attachments", "Cornerstone", insuredName, pricingYear, "Submissions"); - - // Try production path first (relative to current working directory) - var productionPath = Path.GetFullPath(Path.Combine( - "../../samples/Graph/attachments/Cornerstone", - insuredName, - pricingYear, - "Submissions" - )); - - if (Directory.Exists(productionPath)) - return productionPath; - - // Try test path (SamplesGraph copied to bin directory) - var testPath = Path.Combine(AppContext.BaseDirectory, "SamplesGraph", submissionsSubPath); - if (Directory.Exists(testPath)) - return testPath; - - // Fallback: return production path (will fail gracefully with empty data) - return productionPath; - } - - /// - /// Resolves the bounded FileSystem I/O pool from the hub (falling back to the - /// unbounded pool when no registry is present, e.g. lightweight test hubs). - /// All file reads below run on this pool — never Observable.FromAsync. - /// - private static IIoPool FileSystemPool(IMessageHub hub) => - hub.ServiceProvider.GetService()?.Get(IoPoolNames.FileSystem) - ?? IoPool.Unbounded; - - /// - /// Reactive projection of for - /// WithInitialData(Func<IObservable<IEnumerable<PropertyRisk>>>). - /// The async file read is bridged through the FileSystem I/O pool. - /// - public static IObservable> LoadPropertyRisks(IMessageHub hub) => - FileSystemPool(hub).Invoke(async ct => - (IEnumerable)await LoadPropertyRisksAsync(hub, ct)); - - /// - /// Reactive projection of the acceptances half of . - /// - public static IObservable> LoadReinsuranceAcceptances( - IMessageHub hub, string pricingId) => - FileSystemPool(hub).Invoke(async ct => - { - var (acceptances, _) = await LoadReinsuranceStructureAsync(hub, pricingId, ct); - return (IEnumerable)acceptances; - }); - - /// - /// Reactive projection of the sections half of . - /// - public static IObservable> LoadReinsuranceSections( - IMessageHub hub, string pricingId) => - FileSystemPool(hub).Invoke(async ct => - { - var (_, sections) = await LoadReinsuranceStructureAsync(hub, pricingId, ct); - return (IEnumerable)sections; - }); -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/Pricing.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/Pricing.cs deleted file mode 100644 index 1e6ff6600c..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/Pricing.cs +++ /dev/null @@ -1,134 +0,0 @@ -// -// Id: Pricing -// DisplayName: ACME Insurance Pricing Data Model -// - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Represents an insurance pricing entity with dimension-based classification. -/// -public record Pricing : IContentInitializable -{ - /// - /// Unique identifier for the pricing record. - /// - [Key] - [Browsable(false)] - public string Id { get; init; } = string.Empty; - - /// - /// The name of the insured party - used as the MeshNode name. - /// - [Required] - [MeshNodeProperty(nameof(MeshNode.Name))] - [UiControl(Style = "width: 100%;")] - [Description("The full legal name of the insured company or individual")] - public string InsuredName { get; init; } = string.Empty; - - /// - /// Brief description of the pricing. - /// - [Markdown(EditorHeight = "150px", ShowPreview = false)] - [Description("Detailed description of the pricing, coverage scope, and special conditions")] - public string? Description { get; init; } - - /// - /// The date when the insurance coverage begins. - /// - [DisplayName("Inception Date")] - [UiControl(Style = "width: 180px;")] - [Description("The effective start date of the insurance coverage")] - public DateTime? InceptionDate { get; init; } - - /// - /// The date when the insurance coverage ends. - /// - [DisplayName("Expiration Date")] - [UiControl(Style = "width: 180px;")] - [Description("The end date of the insurance coverage period")] - public DateTime? ExpirationDate { get; init; } - - /// - /// The underwriting year for this pricing. - /// - [DisplayName("Underwriting Year")] - [UiControl(Style = "width: 120px;")] - [Description("The fiscal year for underwriting purposes (auto-filled from inception date if not set)")] - [Range(2000, 2100, ErrorMessage = "Underwriting Year must be between 2000 and 2100")] - public int? UnderwritingYear { get; init; } - - /// - /// Line of business classification (dimension). - /// - [Dimension] - [DisplayName("Line of Business")] - [UiControl(Style = "width: 200px;")] - [Description("The insurance line of business category (Property, Casualty, Marine, etc.)")] - public string? LineOfBusiness { get; init; } - - /// - /// Country code or name (dimension). - /// - [Dimension] - [UiControl(Style = "width: 200px;")] - [Description("The primary country where the insured risk is located")] - public string? Country { get; init; } - - /// - /// Name of the broker handling this pricing. - /// - [DisplayName("Broker")] - [UiControl(Style = "width: 200px;")] - [Description("The insurance broker or intermediary managing this placement")] - public string? BrokerName { get; init; } - - /// - /// Name of the primary insurance company. - /// - [DisplayName("Primary Insurance")] - [UiControl(Style = "width: 200px;")] - [Description("The primary insurer providing direct coverage to the insured")] - public string? PrimaryInsurance { get; init; } - - /// - /// Currency code for the premium. - /// - [Dimension] - [UiControl(Style = "width: 120px;")] - [Description("The currency used for premium and limit calculations")] - public string? Currency { get; init; } - - /// - /// Legal entity underwriting this pricing. - /// - [Dimension] - [DisplayName("Legal Entity")] - [UiControl(Style = "width: 200px;")] - [Description("The ACME Insurance legal entity that will underwrite this risk")] - public string? LegalEntity { get; init; } - - /// - /// Current status of the pricing. - /// - [Dimension] - [UiControl(Style = "width: 150px;")] - [Description("The current workflow status of the pricing (Draft, Quoted, Bound, etc.)")] - public string Status { get; init; } = "Draft"; - - /// - /// Initializes the Pricing record. - /// - public object Initialize() - { - // Set default underwriting year if not provided - if (!UnderwritingYear.HasValue && InceptionDate.HasValue) - { - return this with { UnderwritingYear = InceptionDate.Value.Year }; - } - return this; - } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/PricingLayoutAreas.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/PricingLayoutAreas.cs deleted file mode 100644 index 4cde30c378..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/PricingLayoutAreas.cs +++ /dev/null @@ -1,586 +0,0 @@ -// -// Id: PricingLayoutAreas -// DisplayName: ACME Insurance Pricing Views -// - -using System.Collections.Immutable; -using System.ComponentModel.DataAnnotations; -using System.Reactive.Linq; -using System.Text.Json; -using MeshWeaver.ContentCollections; -using MeshWeaver.Data; -using MeshWeaver.Maps; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Layout.DataGrid; -using MeshWeaver.Mesh; -using MeshWeaver.Utils; -using Microsoft.Extensions.DependencyInjection; -using static MeshWeaver.ContentCollections.ContentCollectionsExtensions; - -/// -/// Views for ACME Insurance Pricing nodes. -/// -public static class PricingLayoutAreas -{ - /// - /// Registers all pricing views with the layout definition. - /// - public static LayoutDefinition AddPricingLayoutAreas(this LayoutDefinition layout) => - layout - .WithView("Overview", Overview) - .WithView("PropertyRisks", PropertyRisks) - .WithView("RiskMap", RiskMap) - .WithView("Structure", Structure) - .WithView("Submission", Submission) - .WithView("Thumbnail", Thumbnail); - - /// - /// Builds a navigation toolbar for pricing views. - /// - private static UiControl BuildToolbar(string pricingPath, string activeView) - { - return Controls.Toolbar - .WithView(ToolbarButton("Overview", $"/{pricingPath}/Overview", activeView == "Overview")) - .WithView(ToolbarButton("Submission", $"/{pricingPath}/Submission", activeView == "Submission")) - .WithView(ToolbarButton("Property Risks", $"/{pricingPath}/PropertyRisks", activeView == "PropertyRisks")) - .WithView(ToolbarButton("Risk Map", $"/{pricingPath}/RiskMap", activeView == "RiskMap")) - .WithView(ToolbarButton("Structure", $"/{pricingPath}/Structure", activeView == "Structure")); - } - - private static ButtonControl ToolbarButton(string text, string href, bool isActive) - { - var button = Controls.Button(text).WithNavigateToHref(href); - return isActive - ? button.WithAppearance(Appearance.Accent) - : button.WithAppearance(Appearance.Stealth); - } - - /// - /// The node's pricing content. ContentAs — never is + a hand-rolled JsonElement - /// branch: the accessor covers the already-typed value AND the degraded JsonElement/JsonNode AND - /// a same-short-named Pricing from another build (every recompile of a dynamic NodeType - /// mints a new collectible assembly, so "the same" record has a different CLR identity per - /// build — the case the hand-rolled version had no round-trip to recover, leaving every pricing - /// view blank after a recompile with nothing to grep). It also reads with the HUB's options - /// rather than a locally-invented JsonSerializerOptions, so the registry behind them can - /// resolve the content's $type, and it logs instead of swallowing in a bare catch. - /// - private static Pricing? ExtractPricing(LayoutAreaHost host, MeshNode? node) => - node.ContentAs(host.Hub.JsonSerializerOptions); - - /// - /// Overview view showing pricing header details. - /// - [Display(GroupName = "Details", Order = 0)] - public static IObservable Overview(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - var nodeStream = host.Workspace.GetStream()?.Select(nodes => nodes ?? Array.Empty()); - - if (nodeStream is null) - return Observable.Return((UiControl?)Controls.Markdown("# Pricing Overview\n\n*Unable to load pricing data.*")); - - return nodeStream.Select(nodes => - { - var node = nodes?.FirstOrDefault(); - var pricing = ExtractPricing(host, node); - - if (pricing is null) - { - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Overview")) - .WithView(Controls.Markdown("# Pricing Overview\n\n*Loading pricing data...*")); - } - - var status = PricingStatus.GetById(pricing.Status); - var statusDisplay = $"{status.Emoji} {status.Name}"; - - var coveragePeriod = pricing.InceptionDate.HasValue && pricing.ExpirationDate.HasValue - ? $"{pricing.InceptionDate:yyyy-MM-dd} to {pricing.ExpirationDate:yyyy-MM-dd}" - : "Not specified"; - - var markdown = $@"# {pricing.InsuredName} - -**Status:** {statusDisplay} - -## Coverage Period -{coveragePeriod} - -## Classification -| Field | Value | -|-------|-------| -| Line of Business | {pricing.LineOfBusiness ?? "N/A"} | -| Country | {pricing.Country ?? "N/A"} | -| Currency | {pricing.Currency ?? "N/A"} | -| Underwriting Year | {pricing.UnderwritingYear?.ToString() ?? "N/A"} | - -## Parties -| Role | Name | -|------|------| -| Primary Insurance | {pricing.PrimaryInsurance ?? "N/A"} | -| Broker | {pricing.BrokerName ?? "N/A"} | - -## Description -{pricing.Description ?? "*No description provided.*"} -"; - - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Overview")) - .WithView(Controls.Markdown(markdown)); - }); - } - - /// - /// Property Risks view with DataGrid display. - /// - [Display(GroupName = "Data", Order = 1)] - public static IObservable PropertyRisks(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - var riskStream = host.Workspace.GetStream(); - - return riskStream.Select(risks => - { - var riskList = risks?.ToList() ?? new List(); - - if (!riskList.Any()) - { - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "PropertyRisks")) - .WithView(Controls.Markdown("# Property Risks\n\n*No risks loaded. Import or add risks to begin.*")); - } - - var dataGrid = RenderRisksDataGrid(host, riskList); - - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "PropertyRisks")) - .WithView(Controls.Title("Property Risks", 1)) - .WithView(dataGrid); - }); - } - - private static UiControl RenderRisksDataGrid(LayoutAreaHost host, IReadOnlyCollection risks) - { - var riskDetails = risks.Select(r => new PropertyRiskDetailsModel - { - Id = r.Id, - SourceRow = r.SourceRow, - LocationName = r.LocationName, - Address = r.Address, - City = r.City, - State = r.State, - Country = r.Country, - TsiBuilding = r.TsiBuilding, - TsiContent = r.TsiContent, - TsiBi = r.TsiBi, - Currency = r.Currency, - Latitude = r.GeocodedLocation?.Latitude, - Longitude = r.GeocodedLocation?.Longitude, - }).ToList(); - - var id = Guid.NewGuid().ToString(); - - host.RegisterForDisposal(Observable.Return(riskDetails) - .Subscribe(data => host.UpdateData(id, data))); - - var dataGrid = new DataGridControl(new JsonPointerReference(LayoutAreaReference.GetDataPointer(id))) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.SourceRow).ToCamelCase() }.WithTitle("Row")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.LocationName).ToCamelCase() }.WithTitle("Location")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.Address).ToCamelCase() }.WithTitle("Address")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.City).ToCamelCase() }.WithTitle("City")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.State).ToCamelCase() }.WithTitle("State")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.Country).ToCamelCase() }.WithTitle("Country")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.TsiBuilding).ToCamelCase() }.WithTitle("TSI Building").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.TsiContent).ToCamelCase() }.WithTitle("TSI Content").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.TsiBi).ToCamelCase() }.WithTitle("TSI BI").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.Currency).ToCamelCase() }.WithTitle("Currency")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.Latitude).ToCamelCase() }.WithTitle("Latitude").WithFormat("F4")) - .WithColumn(new PropertyColumnControl { Property = nameof(PropertyRiskDetailsModel.Longitude).ToCamelCase() }.WithTitle("Longitude").WithFormat("F4")) - .WithItemSize(40) - .Resizable(); - - return dataGrid; - } - - /// - /// Risk Map view showing geocoded locations on a Google Map. - /// - [Display(GroupName = "Data", Order = 2)] - public static IObservable RiskMap(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - - return host.Workspace.GetStream()! - .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 (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "RiskMap")) - .WithView(Controls.Markdown("# Risk Map\n\n*No risks loaded. Import or add risks to begin.*")); - } - - if (!geocodedRisks.Any()) - { - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "RiskMap")) - .WithView(Controls.Markdown($"# Risk Map\n\n*No geocoded risks found. {riskList.Count} risk(s) available but none have valid coordinates.*\n\n*Use the geocoding feature to resolve addresses to coordinates.*")); - } - - var mapControl = BuildMapControl(geocodedRisks); - - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "RiskMap")) - .WithView(Controls.Title("Risk Map", 1)) - .WithView(mapControl); - }); - } - - private static MapControl BuildMapControl(IReadOnlyCollection risks) - { - var riskList = risks.Where(r => r.GeocodedLocation?.Latitude is not null && r.GeocodedLocation?.Longitude is not null).ToList(); - - LatLng center; - if (riskList.Any()) - { - 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); - } - - 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(); - - var mapOptions = new MapOptions - { - Center = center, - Zoom = riskList.Any() ? 6 : 2, - MapTypeId = "roadmap", - ZoomControl = true, - MapTypeControl = true, - StreetViewControl = false, - FullscreenControl = true - }; - - return new MapControl() - { - Options = mapOptions, - Markers = markers, - Id = "risk-map" - }.WithStyle(style => style.WithHeight("500px").WithWidth("100%")); - } - - /// - /// Structure view showing reinsurance layers and sections in data grid tables. - /// - [Display(GroupName = "Data", Order = 3)] - public static IObservable Structure(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - - var acceptanceStream = host.Workspace.GetStream(); - var sectionStream = host.Workspace.GetStream(); - - return Observable.CombineLatest( - acceptanceStream, - sectionStream, - (acceptances, sections) => (acceptances, sections)) - .Select(data => - { - var acceptanceList = data.acceptances?.ToList() ?? new List(); - var sectionList = data.sections?.ToList() ?? new List(); - - if (!acceptanceList.Any() && !sectionList.Any()) - { - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Structure")) - .WithView(Controls.Markdown("# Reinsurance Structure\n\n*No reinsurance acceptances loaded. Import or add acceptances to begin.*")); - } - - var acceptancesGrid = RenderAcceptancesDataGrid(host, acceptanceList); - var sectionsGrid = RenderSectionsDataGrid(host, sectionList); - - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Structure")) - .WithView(Controls.Title("Reinsurance Structure", 1)) - .WithView(Controls.Title("Layers", 2)) - .WithView(acceptancesGrid) - .WithView(Controls.Title("Sections", 2)) - .WithView(sectionsGrid); - }) - .StartWith((UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Structure")) - .WithView(Controls.Markdown("# Reinsurance Structure\n\n*Loading...*"))); - } - - private static UiControl RenderAcceptancesDataGrid(LayoutAreaHost host, IReadOnlyCollection acceptances) - { - var acceptanceDetails = acceptances.Select(a => new AcceptanceDetailsModel - { - Id = a.Id, - Name = a.Name, - EPI = a.EPI, - Rate = a.Rate, - Brokerage = a.Brokerage, - Commission = a.Commission - }).OrderBy(a => a.Name ?? a.Id).ToList(); - - var id = Guid.NewGuid().ToString(); - - host.RegisterForDisposal(Observable.Return(acceptanceDetails) - .Subscribe(data => host.UpdateData(id, data))); - - var dataGrid = new DataGridControl(new JsonPointerReference(LayoutAreaReference.GetDataPointer(id))) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.Id).ToCamelCase() }.WithTitle("ID")) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.Name).ToCamelCase() }.WithTitle("Layer Name")) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.EPI).ToCamelCase() }.WithTitle("EPI").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.Rate).ToCamelCase() }.WithTitle("Rate").WithFormat("P4")) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.Brokerage).ToCamelCase() }.WithTitle("Brokerage").WithFormat("P2")) - .WithColumn(new PropertyColumnControl { Property = nameof(AcceptanceDetailsModel.Commission).ToCamelCase() }.WithTitle("Commission").WithFormat("P2")) - .WithItemSize(40) - .Resizable(); - - return dataGrid; - } - - private static UiControl RenderSectionsDataGrid(LayoutAreaHost host, IReadOnlyCollection sections) - { - var sectionDetails = sections.Select(s => new SectionDetailsModel - { - Id = s.Id, - AcceptanceId = s.AcceptanceId, - Name = s.Name, - LineOfBusiness = s.LineOfBusiness, - Attach = s.Attach, - Limit = s.Limit, - AggAttach = s.AggAttach, - AggLimit = s.AggLimit - }).OrderBy(s => s.AcceptanceId).ThenBy(s => s.Name).ToList(); - - var id = Guid.NewGuid().ToString(); - - host.RegisterForDisposal(Observable.Return(sectionDetails) - .Subscribe(data => host.UpdateData(id, data))); - - var dataGrid = new DataGridControl(new JsonPointerReference(LayoutAreaReference.GetDataPointer(id))) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.Id).ToCamelCase() }.WithTitle("ID")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.AcceptanceId).ToCamelCase() }.WithTitle("Layer")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.Name).ToCamelCase() }.WithTitle("Section Name")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.LineOfBusiness).ToCamelCase() }.WithTitle("LoB")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.Attach).ToCamelCase() }.WithTitle("Attachment").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.Limit).ToCamelCase() }.WithTitle("Limit").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.AggAttach).ToCamelCase() }.WithTitle("Agg. Attach").WithFormat("N0")) - .WithColumn(new PropertyColumnControl { Property = nameof(SectionDetailsModel.AggLimit).ToCamelCase() }.WithTitle("Agg. Limit").WithFormat("N0")) - .WithItemSize(40) - .Resizable(); - - return dataGrid; - } - - /// - /// Submission view showing file browser for submission files. - /// - [Display(GroupName = "Files", Order = 4)] - public static IObservable Submission(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - var pricingId = host.Hub.Address.Id; - var nodeStream = host.Workspace.GetStream()?.Select(nodes => nodes ?? Array.Empty()); - - if (nodeStream is null) - return Observable.Return((UiControl?)Controls.Markdown("# Submission\n\n*Unable to load submission data.*")); - - // Get content service and collection config outside the Select to avoid disposal issues - var contentService = host.Hub.ServiceProvider.GetService(); - var localizedCollectionName = GetLocalizedCollectionName("Submissions", pricingId); - var collectionConfig = contentService?.GetCollectionConfig(localizedCollectionName); - - return nodeStream.Select(nodes => - { - var node = nodes?.FirstOrDefault(); - var pricing = ExtractPricing(host, node); - - if (pricing is null) - { - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Submission")) - .WithView(Controls.Markdown("# Submission\n\n*Loading...*")); - } - - var fileBrowser = new FileBrowserControl(localizedCollectionName); - if (collectionConfig != null) - { - fileBrowser = fileBrowser - .WithCollectionConfiguration(collectionConfig) - .CreatePath(); - } - - return (UiControl?)Controls.Stack - .WithView(BuildToolbar(pricingPath, "Submission")) - .WithView(Controls.Title($"Submission - {pricing.InsuredName}", 1)) - .WithView(fileBrowser); - }); - } - - // The ImportConfigs view moved out with MeshWeaver.Import: the import pipeline is an - // installable module now, so a sample that renders ExcelImportConfiguration cannot compile - // against the platform alone. The pricing views below are unaffected. - - /// - /// Thumbnail view for catalog display. - /// Note: Uses Controls.Html for styled card layout as there's no generic Card control - /// in MeshWeaver.Layout that supports the required domain-specific fields (status badge, - /// coverage period, etc.). This is an acceptable pattern for complex styled components. - /// - [Display(GroupName = "Cards", Order = 0)] - public static IObservable Thumbnail(LayoutAreaHost host, RenderingContext _) - { - var pricingPath = host.Hub.Address.ToString(); - var nodeStream = host.Workspace.GetStream()?.Select(nodes => nodes ?? Array.Empty()); - - if (nodeStream is null) - return Observable.Return((UiControl?)Controls.Html(BuildPricingCardHtml(pricingPath, null, null, null))); - - return nodeStream.Select(nodes => - { - var node = nodes?.FirstOrDefault(); - var pricing = ExtractPricing(host, node); - - if (pricing is null) - { - return (UiControl?)Controls.Html(BuildPricingCardHtml(pricingPath, null, null, null)); - } - - var status = PricingStatus.GetById(pricing.Status); - var coveragePeriod = pricing.InceptionDate.HasValue && pricing.ExpirationDate.HasValue - ? $"{pricing.InceptionDate:MMM yyyy} - {pricing.ExpirationDate:MMM yyyy}" - : "Coverage period not set"; - - return (UiControl?)Controls.Html(BuildPricingCardHtml(pricingPath, pricing, status, coveragePeriod)); - }); - } - - /// - /// Builds the HTML for a pricing card thumbnail. - /// - private static string BuildPricingCardHtml(string pricingPath, Pricing? pricing, PricingStatus? status, string? coveragePeriod) - { - if (pricing is null) - { - return $@" - Loading... - -"; - } - - var insuredName = System.Net.WebUtility.HtmlEncode(pricing.InsuredName ?? "Unknown"); - var statusColor = GetStatusColor(status?.Id ?? "Draft"); - var statusEmoji = status?.Emoji ?? ""; - var statusName = status?.Name ?? "Unknown"; - var lob = pricing.LineOfBusiness ?? "N/A"; - var country = pricing.Country ?? "N/A"; - var currency = pricing.Currency ?? "N/A"; - - return $@" -
-
-
{insuredName}
-
{statusEmoji} {statusName}
-
-
{coveragePeriod}
-
- {lob} - {country} - {currency} -
-
-
-"; - } - - private static string GetStatusColor(string statusId) - { - return statusId switch - { - "Draft" => "#6c757d", - "Quoted" => "#0d6efd", - "Bound" => "#198754", - "Declined" => "#dc3545", - "Expired" => "#6c757d", - _ => "#6c757d" - }; - } -} - -/// -/// Display model for property risk details in the data grid. -/// -internal record PropertyRiskDetailsModel -{ - public string? Id { get; init; } - public int? SourceRow { get; init; } - public string? LocationName { get; init; } - public string? Address { get; init; } - public string? City { get; init; } - public string? State { get; init; } - public string? Country { get; init; } - public double TsiBuilding { get; init; } - public double TsiContent { get; init; } - public double TsiBi { get; init; } - public string? Currency { get; init; } - public double? Latitude { get; init; } - public double? Longitude { get; init; } -} - -/// -/// Display model for reinsurance acceptance (layer) details in the data grid. -/// -internal record AcceptanceDetailsModel -{ - public string? Id { get; init; } - public string? Name { get; init; } - public double EPI { get; init; } - public double Rate { get; init; } - public double Brokerage { get; init; } - public double Commission { get; init; } -} - -/// -/// Display model for reinsurance section details in the data grid. -/// -internal record SectionDetailsModel -{ - public string? Id { get; init; } - public string? AcceptanceId { get; init; } - public string? Name { get; init; } - public string? LineOfBusiness { get; init; } - public decimal Attach { get; init; } - public decimal Limit { get; init; } - public decimal? AggAttach { get; init; } - public decimal? AggLimit { get; init; } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/PricingStatus.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/PricingStatus.cs deleted file mode 100644 index 977729f1c8..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/PricingStatus.cs +++ /dev/null @@ -1,102 +0,0 @@ -// -// Id: PricingStatus -// DisplayName: Pricing Status Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - -/// -/// Represents a pricing status with display metadata. -/// -public record PricingStatus : INamed -{ - /// - /// Status identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Display name for the status. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Description of the status. - /// - public string? Description { get; init; } - - /// - /// Emoji icon for visual representation. - /// - public string Emoji { get; init; } = string.Empty; - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - /// - /// Whether groups with this status should be expanded by default in catalog views. - /// - public bool IsExpandedByDefault { get; init; } = true; - - string INamed.DisplayName => Name; - - public static readonly PricingStatus Draft = new() - { - Id = "Draft", - Name = "Draft", - Emoji = "\ud83d\udcdd", - Description = "Initial pricing draft, not yet submitted for quote", - Order = 0, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Quoted = new() - { - Id = "Quoted", - Name = "Quoted", - Emoji = "\ud83d\udcb0", - Description = "Quote has been issued to the client", - Order = 1, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Bound = new() - { - Id = "Bound", - Name = "Bound", - Emoji = "\u2705", - Description = "Policy has been bound and coverage is in effect", - Order = 2, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Declined = new() - { - Id = "Declined", - Name = "Declined", - Emoji = "\u274c", - Description = "Pricing was declined by client or underwriter", - Order = 3, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus Expired = new() - { - Id = "Expired", - Name = "Expired", - Emoji = "\u23f0", - Description = "Quote or policy has expired", - Order = 4, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus[] All = [Draft, Quoted, Bound, Declined, Expired]; - - public static PricingStatus GetById(string? id) => - All.FirstOrDefault(s => s.Id == id) ?? Draft; -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/PropertyRisk.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/PropertyRisk.cs deleted file mode 100644 index 57f784307d..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/PropertyRisk.cs +++ /dev/null @@ -1,247 +0,0 @@ -// -// Id: PropertyRisk -// DisplayName: Property Risk Data Model -// - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Represents a property risk within an insurance pricing. -/// Contains location details, values, and dimensions for property insurance underwriting. -/// -public record PropertyRisk -{ - /// - /// Unique identifier for the property risk record. - /// Synonyms: Plant code, Plant ID, Site Code, Asset ID, Code. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Associated pricing header id. - /// - [Browsable(false)] - public string? PricingId { get; init; } - - /// - /// Source row number in the worksheet (1-based). - /// - [DisplayName("Source Row")] - public int? SourceRow { get; init; } - - /// - /// Originating file name of the import. - /// - [DisplayName("Source File")] - public string? SourceFile { get; init; } - - /// - /// Resolved geocoding for the address. - /// - [Browsable(false)] - public GeocodedLocation? GeocodedLocation { get; init; } - - /// - /// Human-friendly site or facility name. - /// Synonyms: Plant Description, Site Name, Location Name. - /// - [DisplayName("Location Name")] - [Description("Human-friendly name for the site or facility (e.g., 'Main Manufacturing Plant')")] - public string? LocationName { get; init; } - - /// - /// Country; typically ISO code or name (dimension). - /// Synonyms: Country Code, Country. - /// - [Dimension] - [Description("Country where the property is located")] - public string? Country { get; init; } - - /// - /// Street address. - /// Synonyms: Property Address, Address. - /// - [Description("Full street address of the property location")] - public string? Address { get; init; } - - /// - /// State/region/province. - /// Synonyms: State/Province, Region. - /// - [Description("State, province, or region where the property is located")] - public string? State { get; init; } - - /// - /// County/district. - /// Synonyms: District, County. - /// - [Description("County or administrative district")] - public string? County { get; init; } - - /// - /// Postal/ZIP code. - /// Synonyms: ZIP, Postcode. - /// - [DisplayName("ZIP Code")] - [Description("Postal or ZIP code for the property address")] - public string? ZipCode { get; init; } - - /// - /// City or town. - /// - [Description("City or town where the property is located")] - public string? City { get; init; } - - /// - /// Base currency for the risk. - /// Synonyms: Currency, Curr., Curr, CCY. - /// - [Dimension] - [Description("Base currency for all monetary values at this location")] - public string? Currency { get; init; } - - /// - /// Sum insured for buildings. - /// Synonyms: Buildings, Building Value, TSI Building(s). - /// - [DisplayName("TSI Building")] - [Description("Total Sum Insured for building structures at this location")] - [Range(0, double.MaxValue, ErrorMessage = "TSI Building must be non-negative")] - public double TsiBuilding { get; init; } - - /// - /// Currency for building TSI when provided per-row. - /// - [DisplayName("TSI Building Currency")] - public string? TsiBuildingCurrency { get; init; } - - /// - /// Sum insured for contents. - /// Synonyms: Stock, Fixtures, Fittings, IT Equipment, Equipment. - /// - [DisplayName("TSI Content")] - [Description("Total Sum Insured for contents (stock, fixtures, equipment)")] - [Range(0, double.MaxValue, ErrorMessage = "TSI Content must be non-negative")] - public double TsiContent { get; init; } - - /// - /// Currency for contents TSI when provided per-row. - /// - [DisplayName("TSI Content Currency")] - public string? TsiContentCurrency { get; init; } - - /// - /// Business Interruption TSI. - /// Synonyms: BI, Business Interruption, Gross Profit. - /// - [DisplayName("TSI BI")] - [Description("Total Sum Insured for Business Interruption coverage")] - [Range(0, double.MaxValue, ErrorMessage = "TSI BI must be non-negative")] - public double TsiBi { get; init; } - - /// - /// Currency for BI TSI when provided per-row. - /// - [DisplayName("TSI BI Currency")] - public string? TsiBiCurrency { get; init; } - - /// - /// Account identifier. - /// Synonyms: Account #, Account No. - /// - [DisplayName("Account Number")] - public string? AccountNumber { get; init; } - - /// - /// Occupancy scheme / taxonomy name. - /// - [DisplayName("Occupancy Scheme")] - [Description("Occupancy classification scheme (e.g., ISO, AIR, RMS)")] - public string? OccupancyScheme { get; init; } - - /// - /// Occupancy code within the scheme. - /// - [DisplayName("Occupancy Code")] - [Description("Specific occupancy code within the selected scheme")] - public string? OccupancyCode { get; init; } - - /// - /// Construction classification scheme. - /// - [DisplayName("Construction Scheme")] - [Description("Construction classification scheme (e.g., ISO, AIR, RMS)")] - public string? ConstructionScheme { get; init; } - - /// - /// Construction code within scheme. - /// - [DisplayName("Construction Code")] - [Description("Specific construction code within the selected scheme")] - public string? ConstructionCode { get; init; } - - /// - /// Original build year (YYYY). - /// - [DisplayName("Build Year")] - [Description("Year the building was originally constructed")] - [Range(1800, 2100, ErrorMessage = "Build Year must be between 1800 and 2100")] - public int? BuildYear { get; init; } - - /// - /// Major upgrade/renovation year (YYYY). - /// - [DisplayName("Upgrade Year")] - [Description("Year of last major renovation or upgrade")] - [Range(1800, 2100, ErrorMessage = "Upgrade Year must be between 1800 and 2100")] - public int? UpgradeYear { get; init; } - - /// - /// Number of above-ground stories/floors. - /// - [DisplayName("Number of Stories")] - [Description("Number of above-ground floors in the building")] - [Range(1, 200, ErrorMessage = "Number of Stories must be between 1 and 200")] - public int? NumberOfStories { get; init; } - - /// - /// Automatic sprinkler protection present. - /// - [Description("Whether automatic sprinkler fire protection is installed")] - public bool? Sprinklers { get; init; } -} - -/// -/// Geocoded location information for a property risk. -/// -public record GeocodedLocation -{ - /// - /// Latitude coordinate. - /// - public double? Latitude { get; init; } - - /// - /// Longitude coordinate. - /// - public double? Longitude { get; init; } - - /// - /// Formatted address from geocoding service. - /// - public string? FormattedAddress { get; init; } - - /// - /// Place ID from geocoding service. - /// - public string? PlaceId { get; init; } - - /// - /// Status of geocoding operation. - /// - public string? Status { get; init; } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceAcceptance.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceAcceptance.cs deleted file mode 100644 index 233c962ee1..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceAcceptance.cs +++ /dev/null @@ -1,97 +0,0 @@ -// -// Id: ReinsuranceAcceptance -// DisplayName: Reinsurance Acceptance Data Model -// - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; - - -/// -/// Represents the reinsurance acceptance with financial terms and coverage sections. -/// -public record ReinsuranceAcceptance -{ - /// - /// Gets or initializes the unique acceptance identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Gets or initializes the pricingId this acceptance belongs to. - /// - [Browsable(false)] - 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). - /// - [Browsable(false)] - public IReadOnlyCollection? Sections { get; init; } - - /// - /// Gets or initializes the Estimated Premium Income (EPI). - /// - [DisplayName("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. - /// - [DisplayName("Reinstatement Premium")] - public double ReinstPrem { get; init; } - - /// - /// Gets or initializes the no claims bonus percentage. - /// - [DisplayName("No Claims Bonus")] - public double NoClaimsBonus { get; init; } - - /// - /// Gets or initializes the profit commission percentage. - /// - [DisplayName("Profit Commission")] - public double ProfitComm { get; init; } - - /// - /// Gets or initializes the minimum and deposit premium. - /// - [DisplayName("M&D Premium")] - public double MDPrem { get; init; } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceSection.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceSection.cs deleted file mode 100644 index 5ceef21338..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/ReinsuranceSection.cs +++ /dev/null @@ -1,62 +0,0 @@ -// -// Id: ReinsuranceSection -// DisplayName: Reinsurance Section Data Model -// - -using System.ComponentModel; -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Represents a reinsurance coverage section with layer structure and financial terms. -/// -public record ReinsuranceSection -{ - /// - /// Gets or initializes the unique section identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Gets or initializes the acceptanceId this section belongs to. - /// - [Browsable(false)] - 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"). - /// - [Dimension] - [DisplayName("Line of Business")] - public string? LineOfBusiness { get; init; } - - /// - /// Gets or initializes the attachment point. - /// - [DisplayName("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). - /// - [DisplayName("Aggregate Attachment")] - public decimal? AggAttach { get; init; } - - /// - /// Gets or initializes the aggregate limit (annual aggregate limit). - /// - [DisplayName("Aggregate Limit")] - public decimal? AggLimit { get; init; } -} diff --git a/samples/Graph/Data/Cornerstone/Pricing/Source/SlipParser.cs b/samples/Graph/Data/Cornerstone/Pricing/Source/SlipParser.cs deleted file mode 100644 index 8528e531c3..0000000000 --- a/samples/Graph/Data/Cornerstone/Pricing/Source/SlipParser.cs +++ /dev/null @@ -1,323 +0,0 @@ -// -// Id: SlipParser -// DisplayName: Slip Markdown Parser -// - -using System; -using System.Collections.Generic; -using System.Text.RegularExpressions; - -/// -/// Parser utility to extract reinsurance structure from Slip.md markdown files. -/// Extracts EPI, Brokerage, and layer/section definitions from structured markdown. -/// -public static class SlipParser -{ - // Pre-compiled regex patterns - private static readonly Regex EpiPattern = new(@"Estimated Premium Income \(EPI\):\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex BrokeragePattern = new(@"Brokerage:\s*(\d+)%", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex CoverageSectionPattern = new(@"###\s*\d+\.\s*([^\n]+)\n(.*?)(?=###|\z)", RegexOptions.Singleline | RegexOptions.Compiled); - private static readonly Regex LayerPattern = new(@"-\s*\*\*Layer\s*(\d+):\*\*\s*(.*?)(?=-\s*\*\*Layer|\z)", RegexOptions.Singleline | RegexOptions.Compiled); - private static readonly Regex DeductiblePattern = new(@"Deductible per Occurrence:\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex AttachmentPattern = new(@"Attachment Point:\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex LimitPattern = new(@"Limit per Occurrence:\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex AggregateDeductiblePattern = new(@"Annual Aggregate Deductible:\*\*\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Regex AggregateLimitPattern = new(@"Annual Aggregate Limit:\*\*\s*USD\s*([\d,]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - - /// - /// Parses a Slip.md file and extracts reinsurance acceptances and sections. - /// - /// The markdown content of the slip file - /// The pricing ID to assign to generated records - /// Tuple of acceptances and sections extracted from the slip - public static (ReinsuranceAcceptance[] Acceptances, ReinsuranceSection[] Sections) ParseSlip( - string markdown, - string pricingId) - { - var epi = ParseEpi(markdown); - var brokerage = ParseBrokerage(markdown); - - var coverages = ParseCoverages(markdown); - - var acceptances = GenerateAcceptances(pricingId, epi, brokerage); - var sections = GenerateSections(pricingId, coverages); - - return (acceptances, sections); - } - - /// - /// Parses the Estimated Premium Income (EPI) from the markdown. - /// - private static decimal ParseEpi(string markdown) - { - var match = EpiPattern.Match(markdown); - if (match.Success && decimal.TryParse( - match.Groups[1].Value.Replace(",", ""), - out var epi)) - { - return epi; - } - return 200_000_000m; // Default from Microsoft slip - } - - /// - /// Parses the Brokerage percentage from the markdown. - /// - private static double ParseBrokerage(string markdown) - { - var match = BrokeragePattern.Match(markdown); - if (match.Success && double.TryParse(match.Groups[1].Value, out var brokerage)) - { - return brokerage / 100.0; - } - return 0.10; // Default 10% - } - - /// - /// Parses all coverage sections from the markdown. - /// - private static List ParseCoverages(string markdown) - { - var coverages = new List(); - - // Extract each coverage section - var coverageMatches = CoverageSectionPattern.Matches(markdown); - - foreach (Match coverageMatch in coverageMatches) - { - var coverageName = coverageMatch.Groups[1].Value.Trim(); - var coverageContent = coverageMatch.Groups[2].Value; - - var coverage = new CoverageDefinition - { - Name = coverageName, - Layers = ParseLayers(coverageContent) - }; - - coverages.Add(coverage); - } - - // If no coverages found, use defaults based on Slip.md structure - if (coverages.Count == 0) - { - coverages = GetDefaultCoverages(); - } - - return coverages; - } - - /// - /// Parses layer definitions from a coverage section. - /// - private static List ParseLayers(string content) - { - var layers = new List(); - - // Match Layer 1, Layer 2, Layer 3 sections - var layerMatches = LayerPattern.Matches(content); - - foreach (Match layerMatch in layerMatches) - { - var layerNumber = int.Parse(layerMatch.Groups[1].Value); - var layerContent = layerMatch.Groups[2].Value; - - var layer = new LayerDefinition - { - Number = layerNumber, - Deductible = ParseAmount(layerContent, DeductiblePattern), - Attachment = ParseAmount(layerContent, AttachmentPattern), - Limit = ParseAmount(layerContent, LimitPattern), - AggregateDeductible = ParseAmount(layerContent, AggregateDeductiblePattern), - AggregateLimit = ParseAmount(layerContent, AggregateLimitPattern) - }; - - // For Layer 1, use deductible as attachment if no explicit attachment - if (layer.Number == 1 && layer.Attachment == 0 && layer.Deductible > 0) - { - layer.Attachment = layer.Deductible; - } - - layers.Add(layer); - } - - // If no layers found, use defaults - if (layers.Count == 0) - { - layers = GetDefaultLayers(); - } - - return layers; - } - - /// - /// Parses a USD amount from a regex match. - /// - private static decimal ParseAmount(string content, Regex regex) - { - var match = regex.Match(content); - if (match.Success && decimal.TryParse( - match.Groups[1].Value.Replace(",", ""), - out var amount)) - { - return amount; - } - return 0; - } - - /// - /// Generates ReinsuranceAcceptance records from parsed data. - /// - private static ReinsuranceAcceptance[] GenerateAcceptances( - string pricingId, - decimal totalEpi, - double brokerage) - { - var epiPerLayer = totalEpi / 3; - var rate = 0.00033; // Standard rate - - return new[] - { - new ReinsuranceAcceptance - { - Id = "L1", - PricingId = pricingId, - Name = "Layer 1 - Primary", - EPI = (double)epiPerLayer, - Rate = rate, - Brokerage = brokerage, - Commission = 0.05 - }, - new ReinsuranceAcceptance - { - Id = "L2", - PricingId = pricingId, - Name = "Layer 2 - First Excess", - EPI = (double)epiPerLayer, - Rate = rate, - Brokerage = brokerage, - Commission = 0.05 - }, - new ReinsuranceAcceptance - { - Id = "L3", - PricingId = pricingId, - Name = "Layer 3 - Second Excess", - EPI = (double)epiPerLayer, - Rate = rate, - Brokerage = brokerage, - Commission = 0.05 - } - }; - } - - /// - /// Generates ReinsuranceSection records from parsed coverages. - /// - private static ReinsuranceSection[] GenerateSections( - string pricingId, - List coverages) - { - var sections = new List(); - var coverageAbbreviations = new Dictionary - { - ["Fire Damage"] = "FIRE", - ["Natural Catastrophe"] = "NAT", - ["Natural Catastrophe (Windstorm, Earthquake)"] = "NAT", - ["Business Interruption"] = "BI" - }; - - foreach (var coverage in coverages) - { - var abbrev = coverageAbbreviations.GetValueOrDefault(coverage.Name, "UNK"); - - foreach (var layer in coverage.Layers) - { - var acceptanceId = $"L{layer.Number}"; - - sections.Add(new ReinsuranceSection - { - Id = $"{acceptanceId}-{abbrev}", - AcceptanceId = acceptanceId, - Name = $"{coverage.Name} - Layer {layer.Number}", - LineOfBusiness = "PROP", - Attach = layer.Attachment, - Limit = layer.Limit, - AggAttach = layer.AggregateDeductible > 0 ? layer.AggregateDeductible : (decimal?)null, - AggLimit = layer.AggregateLimit > 0 ? layer.AggregateLimit : (decimal?)null - }); - } - } - - return sections.ToArray(); - } - - /// - /// Returns default coverages based on Microsoft Slip.md structure. - /// - private static List GetDefaultCoverages() - { - var defaultLayers = GetDefaultLayers(); - return new List - { - new CoverageDefinition { Name = "Fire Damage", Layers = defaultLayers }, - new CoverageDefinition { Name = "Natural Catastrophe", Layers = defaultLayers }, - new CoverageDefinition { Name = "Business Interruption", Layers = defaultLayers } - }; - } - - /// - /// Returns default layers based on Microsoft Slip.md structure. - /// - private static List GetDefaultLayers() - { - return new List - { - new LayerDefinition - { - Number = 1, - Attachment = 5_000_000m, - Limit = 100_000_000m, - AggregateDeductible = 25_000_000m, - AggregateLimit = 300_000_000m - }, - new LayerDefinition - { - Number = 2, - Attachment = 105_000_000m, - Limit = 145_000_000m, - AggregateDeductible = 0, - AggregateLimit = 435_000_000m - }, - new LayerDefinition - { - Number = 3, - Attachment = 250_000_000m, - Limit = 250_000_000m, - AggregateDeductible = 0, - AggregateLimit = 750_000_000m - } - }; - } -} - -/// -/// Internal model for coverage definitions during parsing. -/// -internal class CoverageDefinition -{ - public string Name { get; set; } = string.Empty; - public List Layers { get; set; } = new List(); -} - -/// -/// Internal model for layer definitions during parsing. -/// -internal class LayerDefinition -{ - public int Number { get; set; } - public decimal Deductible { get; set; } - public decimal Attachment { get; set; } - public decimal Limit { get; set; } - public decimal AggregateDeductible { get; set; } - public decimal AggregateLimit { get; set; } -} diff --git a/samples/Graph/Data/Cornerstone/Source/Country.cs b/samples/Graph/Data/Cornerstone/Source/Country.cs deleted file mode 100644 index 62f1545e34..0000000000 --- a/samples/Graph/Data/Cornerstone/Source/Country.cs +++ /dev/null @@ -1,160 +0,0 @@ -// -// Id: Country -// DisplayName: Country Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Country dimension for geographic classification. -/// -public record Country : INamed -{ - /// - /// Country code (typically ISO 3166-1 alpha-2). - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full country name. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// ISO 3166-1 alpha-3 code. - /// - public string? Alpha3Code { get; init; } - - /// - /// Geographic region. - /// - public string? Region { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly Country UnitedStates = new() - { - Id = "US", - Name = "United States", - Alpha3Code = "USA", - Region = "North America", - Order = 0 - }; - - public static readonly Country UnitedKingdom = new() - { - Id = "GB", - Name = "United Kingdom", - Alpha3Code = "GBR", - Region = "Europe", - Order = 1 - }; - - public static readonly Country Germany = new() - { - Id = "DE", - Name = "Germany", - Alpha3Code = "DEU", - Region = "Europe", - Order = 2 - }; - - public static readonly Country France = new() - { - Id = "FR", - Name = "France", - Alpha3Code = "FRA", - Region = "Europe", - Order = 3 - }; - - public static readonly Country Japan = new() - { - Id = "JP", - Name = "Japan", - Alpha3Code = "JPN", - Region = "Asia", - Order = 4 - }; - - public static readonly Country China = new() - { - Id = "CN", - Name = "China", - Alpha3Code = "CHN", - Region = "Asia", - Order = 5 - }; - - public static readonly Country Australia = new() - { - Id = "AU", - Name = "Australia", - Alpha3Code = "AUS", - Region = "Oceania", - Order = 6 - }; - - public static readonly Country Canada = new() - { - Id = "CA", - Name = "Canada", - Alpha3Code = "CAN", - Region = "North America", - Order = 7 - }; - - public static readonly Country Switzerland = new() - { - Id = "CH", - Name = "Switzerland", - Alpha3Code = "CHE", - Region = "Europe", - Order = 8 - }; - - public static readonly Country Singapore = new() - { - Id = "SG", - Name = "Singapore", - Alpha3Code = "SGP", - Region = "Asia", - Order = 9 - }; - - public static readonly Country Ireland = new() - { - Id = "IE", - Name = "Ireland", - Alpha3Code = "IRL", - Region = "Europe", - Order = 10 - }; - - public static readonly Country India = new() - { - Id = "IN", - Name = "India", - Alpha3Code = "IND", - Region = "Asia", - Order = 11 - }; - - public static readonly Country[] All = - [ - UnitedStates, UnitedKingdom, Germany, France, Japan, - China, Australia, Canada, Switzerland, Singapore, Ireland, India - ]; - - public static Country GetById(string? id) => - All.FirstOrDefault(c => c.Id == id) ?? UnitedStates; -} diff --git a/samples/Graph/Data/Cornerstone/Source/Currency.cs b/samples/Graph/Data/Cornerstone/Source/Currency.cs deleted file mode 100644 index ed02804e73..0000000000 --- a/samples/Graph/Data/Cornerstone/Source/Currency.cs +++ /dev/null @@ -1,111 +0,0 @@ -// -// Id: Currency -// DisplayName: Currency Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Currency dimension for monetary values. -/// -public record Currency : INamed -{ - /// - /// Currency code (ISO 4217). - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full currency name. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Currency symbol. - /// - public string? Symbol { get; init; } - - /// - /// Number of decimal places typically used. - /// - public int DecimalPlaces { get; init; } = 2; - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly Currency USD = new() - { - Id = "USD", - Name = "US Dollar", - Symbol = "$", - DecimalPlaces = 2, - Order = 0 - }; - - public static readonly Currency EUR = new() - { - Id = "EUR", - Name = "Euro", - Symbol = "\u20ac", - DecimalPlaces = 2, - Order = 1 - }; - - public static readonly Currency GBP = new() - { - Id = "GBP", - Name = "British Pound", - Symbol = "\u00a3", - DecimalPlaces = 2, - Order = 2 - }; - - public static readonly Currency JPY = new() - { - Id = "JPY", - Name = "Japanese Yen", - Symbol = "\u00a5", - DecimalPlaces = 0, - Order = 3 - }; - - public static readonly Currency CHF = new() - { - Id = "CHF", - Name = "Swiss Franc", - Symbol = "CHF", - DecimalPlaces = 2, - Order = 4 - }; - - public static readonly Currency AUD = new() - { - Id = "AUD", - Name = "Australian Dollar", - Symbol = "A$", - DecimalPlaces = 2, - Order = 5 - }; - - public static readonly Currency CAD = new() - { - Id = "CAD", - Name = "Canadian Dollar", - Symbol = "C$", - DecimalPlaces = 2, - Order = 6 - }; - - public static readonly Currency[] All = [USD, EUR, GBP, JPY, CHF, AUD, CAD]; - - public static Currency GetById(string? id) => - All.FirstOrDefault(c => c.Id == id) ?? USD; -} diff --git a/samples/Graph/Data/Cornerstone/Source/LegalEntity.cs b/samples/Graph/Data/Cornerstone/Source/LegalEntity.cs deleted file mode 100644 index 878e82420f..0000000000 --- a/samples/Graph/Data/Cornerstone/Source/LegalEntity.cs +++ /dev/null @@ -1,111 +0,0 @@ -// -// Id: LegalEntity -// DisplayName: Legal Entity Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Legal entity dimension for organizational structure. -/// -public record LegalEntity : INamed -{ - /// - /// Legal entity identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Full legal name of the entity. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Country of incorporation. - /// - public string? CountryOfIncorporation { get; init; } - - /// - /// Entity type (e.g., Corporation, LLC, Partnership). - /// - public string? EntityType { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly LegalEntity AcmeUS = new() - { - Id = "CS-US", - Name = "ACME Insurance US Inc.", - CountryOfIncorporation = "US", - EntityType = "Corporation", - Order = 0 - }; - - public static readonly LegalEntity AcmeUK = new() - { - Id = "CS-UK", - Name = "ACME Insurance UK Ltd.", - CountryOfIncorporation = "GB", - EntityType = "Limited Company", - Order = 1 - }; - - public static readonly LegalEntity AcmeEU = new() - { - Id = "CS-EU", - Name = "ACME Insurance Europe AG", - CountryOfIncorporation = "CH", - EntityType = "Corporation", - Order = 2 - }; - - public static readonly LegalEntity AcmeAsia = new() - { - Id = "CS-ASIA", - Name = "ACME Insurance Asia Pte. Ltd.", - CountryOfIncorporation = "SG", - EntityType = "Private Limited", - Order = 3 - }; - - public static readonly LegalEntity MeshWeaverUS = new() - { - Id = "MW-US", - Name = "MeshWeaver Insurance US Inc.", - CountryOfIncorporation = "US", - EntityType = "Corporation", - Order = 4 - }; - - public static readonly LegalEntity MeshWeaverUK = new() - { - Id = "MW-UK", - Name = "MeshWeaver Insurance UK Ltd.", - CountryOfIncorporation = "GB", - EntityType = "Limited Company", - Order = 5 - }; - - public static readonly LegalEntity MeshWeaverEU = new() - { - Id = "MW-EU", - Name = "MeshWeaver Insurance Europe AG", - CountryOfIncorporation = "DE", - EntityType = "Corporation", - Order = 6 - }; - - public static readonly LegalEntity[] All = [AcmeUS, AcmeUK, AcmeEU, AcmeAsia, MeshWeaverUS, MeshWeaverUK, MeshWeaverEU]; - - public static LegalEntity GetById(string? id) => - All.FirstOrDefault(e => e.Id == id) ?? AcmeUS; -} diff --git a/samples/Graph/Data/Cornerstone/Source/LineOfBusiness.cs b/samples/Graph/Data/Cornerstone/Source/LineOfBusiness.cs deleted file mode 100644 index aa9d410b85..0000000000 --- a/samples/Graph/Data/Cornerstone/Source/LineOfBusiness.cs +++ /dev/null @@ -1,83 +0,0 @@ -// -// Id: LineOfBusiness -// DisplayName: Line of Business Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - - -/// -/// Line of business dimension for insurance classification. -/// -public record LineOfBusiness : INamed -{ - /// - /// Line of business code. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Display name for the line of business. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Description of the line of business. - /// - public string? Description { get; init; } - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - string INamed.DisplayName => Name; - - public static readonly LineOfBusiness Property = new() - { - Id = "PROP", - Name = "Property", - Description = "Property insurance covering buildings, contents, and business interruption", - Order = 0 - }; - - public static readonly LineOfBusiness Casualty = new() - { - Id = "CAS", - Name = "Casualty", - Description = "Casualty insurance covering liability and workers compensation", - Order = 1 - }; - - public static readonly LineOfBusiness Marine = new() - { - Id = "MARINE", - Name = "Marine", - Description = "Marine and cargo insurance", - Order = 2 - }; - - public static readonly LineOfBusiness Aviation = new() - { - Id = "AVIATION", - Name = "Aviation", - Description = "Aviation and aerospace insurance", - Order = 3 - }; - - public static readonly LineOfBusiness Energy = new() - { - Id = "ENERGY", - Name = "Energy", - Description = "Energy sector insurance including oil & gas", - Order = 4 - }; - - public static readonly LineOfBusiness[] All = [Property, Casualty, Marine, Aviation, Energy]; - - public static LineOfBusiness GetById(string? id) => - All.FirstOrDefault(l => l.Id == id) ?? Property; -} diff --git a/samples/Graph/Data/Cornerstone/Source/PricingStatus.cs b/samples/Graph/Data/Cornerstone/Source/PricingStatus.cs deleted file mode 100644 index 977729f1c8..0000000000 --- a/samples/Graph/Data/Cornerstone/Source/PricingStatus.cs +++ /dev/null @@ -1,102 +0,0 @@ -// -// Id: PricingStatus -// DisplayName: Pricing Status Reference Data -// - -using System.ComponentModel.DataAnnotations; -using MeshWeaver.Domain; - -/// -/// Represents a pricing status with display metadata. -/// -public record PricingStatus : INamed -{ - /// - /// Status identifier. - /// - [Key] - public string Id { get; init; } = string.Empty; - - /// - /// Display name for the status. - /// - [Required] - public string Name { get; init; } = string.Empty; - - /// - /// Description of the status. - /// - public string? Description { get; init; } - - /// - /// Emoji icon for visual representation. - /// - public string Emoji { get; init; } = string.Empty; - - /// - /// Display order in lists. - /// - public int Order { get; init; } - - /// - /// Whether groups with this status should be expanded by default in catalog views. - /// - public bool IsExpandedByDefault { get; init; } = true; - - string INamed.DisplayName => Name; - - public static readonly PricingStatus Draft = new() - { - Id = "Draft", - Name = "Draft", - Emoji = "\ud83d\udcdd", - Description = "Initial pricing draft, not yet submitted for quote", - Order = 0, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Quoted = new() - { - Id = "Quoted", - Name = "Quoted", - Emoji = "\ud83d\udcb0", - Description = "Quote has been issued to the client", - Order = 1, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Bound = new() - { - Id = "Bound", - Name = "Bound", - Emoji = "\u2705", - Description = "Policy has been bound and coverage is in effect", - Order = 2, - IsExpandedByDefault = true - }; - - public static readonly PricingStatus Declined = new() - { - Id = "Declined", - Name = "Declined", - Emoji = "\u274c", - Description = "Pricing was declined by client or underwriter", - Order = 3, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus Expired = new() - { - Id = "Expired", - Name = "Expired", - Emoji = "\u23f0", - Description = "Quote or policy has expired", - Order = 4, - IsExpandedByDefault = false - }; - - public static readonly PricingStatus[] All = [Draft, Quoted, Bound, Declined, Expired]; - - public static PricingStatus GetById(string? id) => - All.FirstOrDefault(s => s.Id == id) ?? Draft; -} diff --git a/samples/Graph/Data/Cornerstone/TechIndustries.json b/samples/Graph/Data/Cornerstone/TechIndustries.json deleted file mode 100644 index f4c2c99f98..0000000000 --- a/samples/Graph/Data/Cornerstone/TechIndustries.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "id": "TechIndustries", - "namespace": "Cornerstone", - "name": "Tech Industries GmbH", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "German technology manufacturing company - Insurance client", - "icon": "/api/content/Cornerstone/icons/techindustries.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "TechIndustries", - "name": "Tech Industries GmbH", - "description": "German technology manufacturing company", - "address": "Industriestrasse 42, 80333 Munich, Germany", - "industry": "Technology Manufacturing", - "location": "Germany" - } -} diff --git a/samples/Graph/Data/Cornerstone/TechIndustries/2024.json b/samples/Graph/Data/Cornerstone/TechIndustries/2024.json deleted file mode 100644 index 3ce0995b55..0000000000 --- a/samples/Graph/Data/Cornerstone/TechIndustries/2024.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "2024", - "namespace": "Cornerstone/TechIndustries", - "name": "Tech Industries 2024", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for Tech Industries GmbH - UW Year 2024", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2024", - "insuredName": "Tech Industries GmbH", - "description": "Property reinsurance program for Tech Industries GmbH covering European manufacturing facilities.", - "inceptionDate": "2024-06-01T00:00:00Z", - "expirationDate": "2025-05-31T00:00:00Z", - "underwritingYear": 2024, - "lineOfBusiness": "PROP", - "country": "DE", - "currency": "EUR", - "legalEntity": "MW-EU", - "status": "Draft" - } -} diff --git a/samples/Graph/Data/Cornerstone/Tesla.json b/samples/Graph/Data/Cornerstone/Tesla.json deleted file mode 100644 index 6d3a1ec225..0000000000 --- a/samples/Graph/Data/Cornerstone/Tesla.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "Tesla", - "namespace": "Cornerstone", - "name": "Tesla, Inc.", - "nodeType": "Cornerstone/Insured", - "category": "Clients", - "description": "Electric vehicle and clean energy company - Insurance client", - "icon": "/api/content/Cornerstone/icons/tesla.svg", - "isPersistent": true, - "content": { - "$type": "Insured", - "id": "Tesla", - "name": "Tesla, Inc.", - "description": "Electric vehicle and clean energy company", - "address": "1 Tesla Road, Austin, TX 78725, United States", - "website": "https://tesla.com", - "industry": "Automotive & Energy", - "location": "United States" - } -} diff --git a/samples/Graph/Data/Cornerstone/Tesla/2026.json b/samples/Graph/Data/Cornerstone/Tesla/2026.json deleted file mode 100644 index b6a3c5174c..0000000000 --- a/samples/Graph/Data/Cornerstone/Tesla/2026.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "id": "2026", - "namespace": "Cornerstone/Tesla", - "name": "Tesla 2026", - "nodeType": "Cornerstone/Pricing", - "category": "Pricings", - "description": "Property reinsurance for Tesla, Inc. - UW Year 2026", - "icon": "/static/NodeTypeIcons/document.svg", - "isPersistent": true, - "state": "Active", - "content": { - "$type": "Pricing", - "id": "2026", - "insuredName": "Tesla, Inc.", - "description": "Property reinsurance program for Tesla, Inc. covering manufacturing facilities, Gigafactories, and energy installations worldwide.", - "inceptionDate": "2026-01-01T00:00:00Z", - "expirationDate": "2026-12-31T00:00:00Z", - "underwritingYear": 2026, - "lineOfBusiness": "PROP", - "country": "US", - "currency": "USD", - "legalEntity": "MW-US", - "status": "Draft" - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Alice_Access.json b/samples/Graph/Data/Cornerstone/_Access/Alice_Access.json deleted file mode 100644 index 961d5592a5..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Alice_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Alice_Access", - "namespace": "Cornerstone/_Access", - "name": "Alice Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Alice", - "displayName": "Alice Chen", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Bob_Access.json b/samples/Graph/Data/Cornerstone/_Access/Bob_Access.json deleted file mode 100644 index 9e36eac860..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Bob_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Bob_Access", - "namespace": "Cornerstone/_Access", - "name": "Bob Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Bob", - "displayName": "Bob Smith", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Carol_Access.json b/samples/Graph/Data/Cornerstone/_Access/Carol_Access.json deleted file mode 100644 index 3011d411c2..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Carol_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Carol_Access", - "namespace": "Cornerstone/_Access", - "name": "Carol Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Carol", - "displayName": "Carol Johnson", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/David_Access.json b/samples/Graph/Data/Cornerstone/_Access/David_Access.json deleted file mode 100644 index c2bdab6bc0..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/David_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "David_Access", - "namespace": "Cornerstone/_Access", - "name": "David Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "David", - "displayName": "David Lee", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Emma_Access.json b/samples/Graph/Data/Cornerstone/_Access/Emma_Access.json deleted file mode 100644 index c2640833b3..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Emma_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Emma_Access", - "namespace": "Cornerstone/_Access", - "name": "Emma Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Emma", - "displayName": "Emma Wilson", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Oliver_Access.json b/samples/Graph/Data/Cornerstone/_Access/Oliver_Access.json deleted file mode 100644 index 8c635049c4..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Oliver_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Oliver_Access", - "namespace": "Cornerstone/_Access", - "name": "Oliver Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Oliver", - "displayName": "Oliver Chen", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Paul_Access.json b/samples/Graph/Data/Cornerstone/_Access/Paul_Access.json deleted file mode 100644 index 80ae61ea1e..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Paul_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Paul_Access", - "namespace": "Cornerstone/_Access", - "name": "Paul Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Paul", - "displayName": "Paul Martinez", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Quinn_Access.json b/samples/Graph/Data/Cornerstone/_Access/Quinn_Access.json deleted file mode 100644 index c7c50d0ba3..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Quinn_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Quinn_Access", - "namespace": "Cornerstone/_Access", - "name": "Quinn Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Quinn", - "displayName": "Quinn Johnson", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Roland_Access.json b/samples/Graph/Data/Cornerstone/_Access/Roland_Access.json deleted file mode 100644 index 1f75ffa110..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Roland_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Roland_Access", - "namespace": "Cornerstone/_Access", - "name": "Roland Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Roland", - "displayName": "Roland Buergi", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/Samuel_Access.json b/samples/Graph/Data/Cornerstone/_Access/Samuel_Access.json deleted file mode 100644 index 52226e6bb4..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/Samuel_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "Samuel_Access", - "namespace": "Cornerstone/_Access", - "name": "Samuel Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "Samuel", - "displayName": "Samuel Glauser", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/_Access/TestUser_Access.json b/samples/Graph/Data/Cornerstone/_Access/TestUser_Access.json deleted file mode 100644 index aab488e237..0000000000 --- a/samples/Graph/Data/Cornerstone/_Access/TestUser_Access.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "TestUser_Access", - "namespace": "Cornerstone/_Access", - "name": "TestUser Access", - "icon": "/static/NodeTypeIcons/shield.svg", - "nodeType": "AccessAssignment", - "content": { - "$type": "AccessAssignment", - "accessObject": "TestUser", - "displayName": "Test User", - "roles": [ - { - "role": "Viewer" - } - ] - } -} diff --git a/samples/Graph/Data/Cornerstone/index.md b/samples/Graph/Data/Cornerstone/index.md deleted file mode 100644 index f74058921a..0000000000 --- a/samples/Graph/Data/Cornerstone/index.md +++ /dev/null @@ -1,102 +0,0 @@ ---- -NodeType: Space -Name: Cornerstone -Category: Insurance -Description: Reinsurance pricing demo showcasing property risk management, geographic visualization, and Excel data import -Icon: /api/content/Cornerstone/icon.svg ---- - -Welcome to Cornerstone, a reinsurance pricing demo showcasing MeshWeaver's capabilities for managing complex insurance data. Explore property risk pricings, geographic visualizations, and document-based workflows for corporate insurance clients. - -# Quick Start - -| Resource | Description | -|----------|-------------| -| [Microsoft](Cornerstone/Microsoft) | Technology sector pricing with full sample data | -| [Tesla](Cornerstone/Tesla) | Industrial and specialty lines | -| [Nestle](Cornerstone/Nestle) | Consumer and agricultural coverage | -| [Getting Started](Cornerstone/Documentation/GettingStarted) | Setup and first steps | -| [Documentation](Cornerstone/Documentation) | Complete guides and references | - ---- - -# What's Inside - -## Organization Structure - -Cornerstone demonstrates a hierarchical structure for insurance pricing: - -``` -Cornerstone (Organization) - └── Insured (NodeType) - └── Pricing (NodeType) -``` - -Each insured company contains yearly pricing instances with property risks, reinsurance layers, and submission documents. - ---- - -## Sample Insureds - -| Insured | Sector | Status | -|---------|--------|--------| -| [Microsoft](Cornerstone/Microsoft) | Technology | Full sample data (2026) | -| [Tesla](Cornerstone/Tesla) | Industrial | Sample pricing (2026) | -| [Nestle](Cornerstone/Nestle) | Consumer/Agriculture | Sample pricing (2026) | -| [GlobalManufacturing](Cornerstone/GlobalManufacturing) | Manufacturing | Historical (2024) | -| [EuropeanLogistics](Cornerstone/EuropeanLogistics) | Logistics | Historical (2024) | -| [TechIndustries](Cornerstone/TechIndustries) | Technology | Historical (2024) | - ---- - -## Key Features Demonstrated - -**Property Risk Management** -Track individual property locations with values, construction types, and protection classes. Microsoft pricing includes 50+ property risks across multiple countries. - -**Geographic Visualization** -View property risks on an interactive map with risk aggregation by region. Identify concentration risks and natural catastrophe exposures. - -**Excel Import** -Upload broker submissions in Excel format. Configure import mappings to parse property schedules, reinsurance structures, and coverage terms. - -**Reinsurance Structure** -Model multi-layer reinsurance programs with sections, participation percentages, and acceptance tracking. - ---- - -# Pricing Views - -Each pricing includes multiple analysis perspectives: - -| View | Purpose | -|------|---------| -| Overview | Summary metrics and key figures | -| PropertyRisks | Detailed property schedule | -| RiskMap | Geographic distribution of risks | -| Structure | Reinsurance layers and sections | -| Submission | Uploaded documents and files | -| ImportConfigs | Excel import configurations | - ---- - -# Dimensions - -Pricings use standardized dimensions for filtering and aggregation: - -| Dimension | Purpose | -|-----------|---------| -| LineOfBusiness | Property, Liability, Specialty | -| Country | Geographic classification | -| Currency | USD, EUR, CHF, etc. | -| PricingStatus | Draft, In Review, Quoted, Bound | - ---- - -# Learn More - -| Topic | Link | -|-------|------| -| Architecture | [How Cornerstone is built](Cornerstone/Documentation/Architecture) | -| AI Integration | [Using the pricing assistant](Cornerstone/Documentation/AIAgentIntegration) | -| References | [Paths, queries, and areas](Cornerstone/Documentation/UnifiedReferences) | diff --git a/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs index 1e4eaadf4b..cd83d151eb 100644 --- a/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs +++ b/src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs @@ -121,6 +121,10 @@ public static class FrameworkBuildIdentity // it too), and an image shipping AI.dll in its app closure would 409 the registry module. "MeshWeaver.Compiler", "MeshWeaver.ContentCollections", + // MeshWeaver.Maps left the content surface with them: MapControl is the Maps MODULE now, + // pre-installed so every portal still lands it, and the one gated tree that binds it + // (Cornerstone) moved to the plugins repo as a Store package that REQUIRES Maps — the + // "in-mesh content travels with the module" rule, applied. // MeshWeaver.ContentCollections.Indexing and .Indexing.Graph left the content surface // with the indexing carve-out, for the same reason MeshWeaver.Markdown.Collaboration did // below: the chunking/embedding pipeline is the Indexing MODULE now, composed into @@ -137,7 +141,6 @@ public static class FrameworkBuildIdentity "MeshWeaver.Kernel", "MeshWeaver.Kernel.Hub", "MeshWeaver.Layout", - "MeshWeaver.Maps", "MeshWeaver.Markdown", // MeshWeaver.Markdown.Collaboration left the content surface with the collaboration // carve-out, for the same reason MeshWeaver.AI did above: comments and the tracked-change diff --git a/src/MeshWeaver.Hosting/SampleDataExtensions.cs b/src/MeshWeaver.Hosting/SampleDataExtensions.cs index 8e5063f75a..a2cde4d17a 100644 --- a/src/MeshWeaver.Hosting/SampleDataExtensions.cs +++ b/src/MeshWeaver.Hosting/SampleDataExtensions.cs @@ -28,10 +28,6 @@ public static TBuilder AddAcme(this TBuilder builder) where TBuilder : public static TBuilder AddPensionFund(this TBuilder builder) where TBuilder : MeshBuilder => builder.IncludePartition("PensionFund"); - /// Includes the Cornerstone insurance sample data. - public static TBuilder AddCornerstone(this TBuilder builder) where TBuilder : MeshBuilder - => builder.IncludePartition("Cornerstone"); - /// Includes the Systemorph sample data. public static TBuilder AddSystemorph(this TBuilder builder) where TBuilder : MeshBuilder => builder.IncludePartition("Systemorph"); @@ -99,7 +95,6 @@ public static TBuilder AddAllSampleData(this TBuilder builder) where T .AddFutuRe() .AddAcme() .AddPensionFund() - .AddCornerstone() .AddSystemorph() .AddTestSpace() .AddMeshWeaverDocs() diff --git a/src/MeshWeaver.Maps/GoogleMapsConfiguration.cs b/src/MeshWeaver.Maps/GoogleMapsConfiguration.cs deleted file mode 100644 index d94e6f955e..0000000000 --- a/src/MeshWeaver.Maps/GoogleMapsConfiguration.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace MeshWeaver.Maps; - -/// -/// Options for loading the Google Maps JavaScript API, typically bound from application configuration. -/// -public class GoogleMapsConfiguration -{ - /// API key used to authenticate requests to the Google Maps JavaScript API. - public string ApiKey { get; set; } = string.Empty; - /// Comma-separated list of optional Google Maps libraries to load (e.g. places, marker). - public string? Libraries { get; set; } = "places,visualization,drawing,marker"; - /// Language code used to localize the map UI; falls back to the browser setting when null. - public string? Language { get; set; } - /// Region code biasing geocoding and map behavior to a country; none when null. - public string? Region { get; set; } - -} \ No newline at end of file diff --git a/src/MeshWeaver.Maps/MapControl.cs b/src/MeshWeaver.Maps/MapControl.cs deleted file mode 100644 index 2d17b2bb90..0000000000 --- a/src/MeshWeaver.Maps/MapControl.cs +++ /dev/null @@ -1,100 +0,0 @@ -using MeshWeaver.Layout; - -namespace MeshWeaver.Maps; - -/// -/// A layout-area control that renders an interactive map (provider-supplied: Google Maps in the portal, MapKit in MAUI), optionally overlaid with markers and circles. -/// -public record MapControl() : UiControl("MeshWeaver.Maps", "1.0.0") -{ - - /// Map configuration such as center, zoom level and which UI controls are shown. - public MapOptions? Options { get; set; } - /// Markers to display on the map. - public IEnumerable? Markers { get; set; } - /// Circles to display on the map. - public IEnumerable? Circles { get; set; } - -} - -/// -/// Configuration controlling the map's initial view and which built-in UI controls are shown. -/// -public record MapOptions -{ - /// Geographic point the map is centered on. - public LatLng? Center { get; init; } - /// Initial zoom level; higher values zoom in closer. - public int Zoom { get; init; } = 15; - /// Base map type, e.g. roadmap, satellite, hybrid or terrain. - public string? MapTypeId { get; init; } = "roadmap"; - /// Hides all default map UI controls when true. - public bool DisableDefaultUI { get; init; } = false; - /// Whether the zoom control is shown. - public bool ZoomControl { get; init; } = true; - /// Whether the map-type (roadmap/satellite/...) control is shown. - public bool MapTypeControl { get; init; } = true; - /// Whether the scale control is shown. - public bool ScaleControl { get; init; } = false; - /// Whether the Street View pegman control is shown. - public bool StreetViewControl { get; init; } = true; - /// Whether the rotate control is shown. - public bool RotateControl { get; init; } = false; - /// Whether the fullscreen control is shown. - public bool FullscreenControl { get; init; } = false; -} - -/// -/// A geographic coordinate expressed as latitude and longitude in decimal degrees. -/// -/// Latitude in decimal degrees. -/// Longitude in decimal degrees. -public record LatLng(double Lat, double Lng); - -/// -/// A marker pin placed on the map at a given position. -/// -public record MapMarker -{ - /// Geographic position of the marker. - public LatLng Position { get; init; } = new(0, 0); - /// Tooltip text shown when hovering over the marker. - public string? Title { get; init; } - /// Short label rendered on the marker. - public string? Label { get; init; } - /// Whether the user can drag the marker to a new position. - public bool Draggable { get; init; } = false; - /// URL of a custom icon image for the marker. - public string? Icon { get; init; } - /// Identifier reported back in click events for this marker. - public string? Id { get; init; } - /// Arbitrary application data associated with the marker. - public object? Data { get; init; } -} - -/// -/// A circular overlay drawn on the map, centered on a point with a radius in meters. -/// -public record MapCircle -{ - /// Geographic center of the circle. - public LatLng Center { get; init; } = new(0, 0); - /// Radius of the circle in meters. - public double Radius { get; init; } = 1000; - /// Fill color of the circle as a CSS/hex color string. - public string? FillColor { get; init; } = "#FF0000"; - /// Fill opacity between 0 (transparent) and 1 (opaque). - public double FillOpacity { get; init; } = 0.35; - /// Stroke (border) color as a CSS/hex color string. - public string? StrokeColor { get; init; } = "#FF0000"; - /// Stroke (border) opacity between 0 and 1. - public double StrokeOpacity { get; init; } = 0.8; - /// Stroke (border) width in pixels. - public int StrokeWeight { get; init; } = 2; - /// Identifier reported back in click events for this circle. - public string? Id { get; init; } - /// Arbitrary application data associated with the circle. - public object? Data { get; init; } -} - - diff --git a/src/MeshWeaver.Maps/MeshWeaver.Maps.csproj b/src/MeshWeaver.Maps/MeshWeaver.Maps.csproj deleted file mode 100644 index a5cb6aac1a..0000000000 --- a/src/MeshWeaver.Maps/MeshWeaver.Maps.csproj +++ /dev/null @@ -1,14 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - - \ No newline at end of file diff --git a/src/MeshWeaver.Maps/README.md b/src/MeshWeaver.Maps/README.md deleted file mode 100644 index 447e731959..0000000000 --- a/src/MeshWeaver.Maps/README.md +++ /dev/null @@ -1,242 +0,0 @@ -# MeshWeaver Maps - -Provider-neutral map controls for MeshWeaver layout areas: `MapControl` plus the `MapOptions`, `LatLng`, `MapMarker` and `MapCircle` records. The control carries pure geographic data — WHICH map engine renders it is a per-host choice supplied by a provider view package. - -## Providers - -### MeshWeaver.Blazor.GoogleMaps -Blazor Server renderer using the Google Maps JavaScript API (needs a `GoogleMaps:ApiKey`). - -### MeshWeaver.Maui (built-in MapView) -Native MAUI renderer using platform maps (MapKit on iOS/Mac Catalyst) — no API key. - -OpenStreetMap and Apple MapKit JS provider packages are planned; the control surface stays identical across providers. - -## Features - -- **Interactive Maps**: Display Google Maps with customizable options -- **Markers**: Add clickable markers with titles, labels, and custom icons -- **Circles**: Display circular overlays with customizable styling -- **Click Events**: Handle marker and circle click events through MeshWeaver's message hub -- **Responsive Design**: Maps automatically adapt to container dimensions -- **Dynamic Updates**: Real-time marker and circle updates without page refresh - -## Quick Start - -### 1. Configuration - -Add Google Maps to your MeshWeaver hub configuration: - -```csharp -public static MessageHubConfiguration ConfigureHub(this MessageHubConfiguration config) -{ - return config.AddGoogleMaps(); -} -``` - -Configure your Google Maps API key: - -```csharp -services.Configure(options => -{ - options.ApiKey = "your-google-maps-api-key"; - options.Libraries = "places,visualization,drawing,marker"; // Optional - options.Language = "en"; // Optional - options.Region = "US"; // Optional -}); -``` - -### 2. Creating a Map Control - -```csharp -public static UiControl CreateMap() => - new MapControl - { - Options = new MapOptions - { - Center = new LatLng(37.7749, -122.4194), // San Francisco - Zoom = 12, - MapTypeId = "roadmap" - }, - Markers = new[] - { - new MapMarker - { - Position = new LatLng(37.7749, -122.4194), - Title = "San Francisco", - Label = "SF", - Id = "sf-marker" - } - }, - Circles = new[] - { - new MapCircle - { - Center = new LatLng(37.7749, -122.4194), - Radius = 5000, // 5km radius - FillColor = "#FF0000", - FillOpacity = 0.35, - Id = "sf-circle" - } - } - }.WithStyle("height: 400px; width: 100%;"); -``` - -## API Reference - -### MapControl - -Main control for rendering Google Maps. - -**Properties:** -- `Options`: Map configuration options -- `Markers`: Collection of map markers -- `Circles`: Collection of map circles - -### MapOptions - -Configuration options for the map display. - -**Properties:** -- `Center`: Map center coordinates (LatLng) -- `Zoom`: Zoom level (default: 15) -- `MapTypeId`: Map type - "roadmap", "satellite", "hybrid", "terrain" (default: "roadmap") -- `DisableDefaultUI`: Hide all default UI controls (default: false) -- `ZoomControl`: Show zoom controls (default: true) -- `MapTypeControl`: Show map type selector (default: true) -- `ScaleControl`: Show scale control (default: false) -- `StreetViewControl`: Show street view control (default: true) -- `RotateControl`: Show rotation control (default: false) -- `FullscreenControl`: Show fullscreen control (default: false) - -### MapMarker - -Represents a clickable point on the map. - -**Properties:** -- `Position`: Marker coordinates (LatLng) -- `Title`: Hover tooltip text -- `Label`: Single character marker label -- `Draggable`: Allow marker dragging (default: false) -- `Icon`: Custom marker icon URL -- `Id`: Unique identifier for click events -- `Data`: Additional data payload - -### MapCircle - -Represents a circular overlay on the map. - -**Properties:** -- `Center`: Circle center coordinates (LatLng) -- `Radius`: Circle radius in meters (default: 1000) -- `FillColor`: Interior fill color (default: "#FF0000") -- `FillOpacity`: Interior fill opacity (default: 0.35) -- `StrokeColor`: Border color (default: "#FF0000") -- `StrokeOpacity`: Border opacity (default: 0.8) -- `StrokeWeight`: Border width in pixels (default: 2) -- `Id`: Unique identifier for click events -- `Data`: Additional data payload - -### LatLng - -Represents geographical coordinates. - -**Constructor:** -- `LatLng(double Lat, double Lng)` - -## Event Handling - -The GoogleMapView automatically handles marker and circle clicks, posting `ClickedEvent` messages through MeshWeaver's message hub: - -```csharp -public class MyHandler -{ - public IMessageDelivery HandleMapClick(MessageHub hub, IMessageDelivery delivery) - { - var clickedId = delivery.Message.Payload; // Marker or circle ID - // Handle the click event - return delivery.Processed(); - } -} -``` - -Register the handler in your hub configuration: - -```csharp -config.WithHandler(HandleMapClick) -``` - -## GoogleMapsConfiguration - -Configuration options for the Google Maps API integration. - -**Properties:** -- `ApiKey`: Your Google Maps JavaScript API key (required) -- `Libraries`: Comma-separated list of additional libraries to load (optional) -- `Language`: Language for map labels and controls (optional) -- `Region`: Localization region (optional) - -## Requirements - -- **Google Maps JavaScript API Key**: Required for map functionality -- **Internet Connection**: Maps require external API access -- **.NET 9.0**: Target framework -- **Blazor Server**: For rendering implementation - -## Dependencies - -### MeshWeaver.Maps -- MeshWeaver.Layout - -### MeshWeaver.Blazor.GoogleMaps -- MeshWeaver.Blazor -- MeshWeaver.Maps -- Microsoft.AspNetCore.Components.Web - -## Integration Example - -```csharp -public static class MyLayoutAreas -{ - public static void AddMapAreas(this LayoutConfiguration config) - { - config.AddLayoutArea("LocationMap", LocationMap); - } - - public static UiControl LocationMap(LayoutAreaHost host, RenderingContext ctx) - { - var locations = GetLocations(); // Your data source - - return new MapControl - { - Options = new MapOptions - { - Center = new LatLng(locations.First().Latitude, locations.First().Longitude), - Zoom = 10 - }, - Markers = locations.Select(loc => new MapMarker - { - Position = new LatLng(loc.Latitude, loc.Longitude), - Title = loc.Name, - Id = loc.Id.ToString() - }).ToArray() - }.WithStyle("height: 500px;"); - } -} -``` - -## Styling - -The map container automatically adapts to its parent's dimensions. Use CSS styling through the `WithStyle()` method: - -```csharp -var map = new MapControl { /* options */ } - .WithStyle("height: 400px; width: 100%; border: 1px solid #ccc;"); -``` - -## Performance Notes - -- Maps are rendered asynchronously after initial page load -- Marker and circle updates are batched and delayed to prevent excessive API calls -- JavaScript module loading is optimized with dynamic imports -- Component implements proper cleanup to prevent memory leaks \ No newline at end of file diff --git a/test/MeshWeaver.Content.Test/ContentCollectionReferenceTest.cs b/test/MeshWeaver.Content.Test/ContentCollectionReferenceTest.cs deleted file mode 100644 index a4df4b9d10..0000000000 --- a/test/MeshWeaver.Content.Test/ContentCollectionReferenceTest.cs +++ /dev/null @@ -1,673 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.ContentCollections; -using MeshWeaver.Data; -using MeshWeaver.Documentation; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence; -using MeshWeaver.Layout; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -using System.Reactive.Threading.Tasks; -namespace MeshWeaver.Content.Test; - -/// -/// Tests for ContentCollectionReference resolution via GetDataRequest. -/// -[Collection("SamplesGraphData")] -public class ContentCollectionReferenceTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// Share Mesh/SP across [Fact]s — see MonolithMeshTestBase.ShareMeshAcrossTests. - protected override bool ShareMeshAcrossTests => true; - - private static string GetSamplesGraphPath() - { - var currentDir = Directory.GetCurrentDirectory(); - var solutionRoot = Path.GetFullPath(Path.Combine(currentDir, "..", "..", "..", "..", "..")); - return Path.Combine(solutionRoot, "samples", "Graph"); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var graphPath = TestPaths.SamplesGraph; - var dataDirectory = TestPaths.SamplesGraphData; - // Stable cache directory — the timestamped-subdir cache (a3ab9909e) - // gives each compile its own subdir so prior-process DLLs aren't touched. - var cacheDirectory = Path.Combine(Path.GetTempPath(), "MeshWeaverContentCollectionTests", ".mesh-cache"); - Directory.CreateDirectory(cacheDirectory); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:SourceType"] = "FileSystem", - ["Graph:Storage:BasePath"] = graphPath - }) - .Build(); - - // Create the "storage" collection config that node types (Organization, Person) will map from. - // ExposeInChildren=true so per-node MapContentCollection wrappers (which copy this flag - // from the source config) surface in GetAllCollectionConfigs after the default flip. - var storageConfig = new ContentCollectionConfig - { - Name = "storage", - SourceType = "FileSystem", - BasePath = graphPath, - ExposeInChildren = true - }; - - return builder - .UseMonolithMesh() - .AddPartitionedFileSystemPersistence(dataDirectory) - .AddAcme() - .AddCornerstone() - .AddSystemorph() - .AddUserData() - .AddMeshWeaverDocs() - .AddDoc() - .AddDocumentation() - .ConfigureServices(services => - { - services.Configure(o => o.CacheDirectory = cacheDirectory); - services.AddSingleton(configuration); - return services; - }) - // Register the storage collection at mesh level so node types can map from it - .ConfigureHub(hub => hub.AddContentCollections([storageConfig])) - // Configure default content collections for all node hubs - // Order matters: AddContentCollections registers $Content area first, - // then AddDefaultLayoutAreas sets DetailsArea as default (can be overridden by node type config) - .ConfigureDefaultNodeHub(config => - { - var nodePath = config.Address.ToString(); - config = config - .AddContentCollections() // Register $Content layout area first - .MapContentCollection("attachments", "storage", $"attachments/{nodePath}") - .MapContentCollection("content", "storage", $"content/{nodePath}"); - - // Add mesh node views last (sets DetailsArea as default, can be overridden by node type) - return config.AddDefaultLayoutAreas(); - }) - .AddGraph(); - } - - /// - /// Tests that GetDataRequest with ContentCollectionReference returns collection configurations - /// from a Person instance hub (Alice). - /// - [Fact(Timeout = 20000)] - public async Task GetDataRequest_WithContentCollectionReference_ReturnsConfig() - { - var aliceAddress = new Address("Cornerstone/Microsoft"); - var client = GetClient(c => c.AddContentCollections()); - - // Initialize Alice hub - await client.Observe(new PingRequest(), o => o.WithTarget(aliceAddress)).Should().Within(19.Seconds()).Emit(); - - // Request the "attachments" collection configuration - var response = await client.Observe(new GetDataRequest(new ContentCollectionReference(["attachments"])), o => o.WithTarget(aliceAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull(); - configs.Should().HaveCount(1); - - var attachmentsConfig = configs!.First(); - attachmentsConfig.Name.Should().Be("attachments"); - attachmentsConfig.SourceType.Should().Be("FileSystem"); - attachmentsConfig.BasePath.Should().Contain("attachments").And.EndWith("Cornerstone/Microsoft"); - } - - /// - /// Tests that GetDataRequest with empty ContentCollectionReference returns all collections. - /// - [Fact(Timeout = 20000)] - public async Task GetDataRequest_WithEmptyContentCollectionReference_ReturnsAllCollections() - { - var aliceAddress = new Address("Cornerstone/Microsoft"); - var client = GetClient(c => c.AddContentCollections()); - - await client.Observe(new PingRequest(), o => o.WithTarget(aliceAddress)).Should().Within(19.Seconds()).Emit(); - - var response = await client.Observe(new GetDataRequest(new ContentCollectionReference()), o => o.WithTarget(aliceAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull(); - configs.Should().Contain(c => c.Name == "attachments"); - } - - /// - /// Tests that Organization instance hub's (ACME) collection is accessible via ContentCollectionReference. - /// - [Fact(Timeout = 20000)] - public async Task GetDataRequest_WithContentCollectionReference_ForOrganization_ReturnsConfig() - { - var acmeAddress = new Address("ACME"); - var client = GetClient(c => c.AddContentCollections()); - - await client.Observe(new PingRequest(), o => o.WithTarget(acmeAddress)).Should().Within(19.Seconds()).Emit(); - - var response = await client.Observe(new GetDataRequest(new ContentCollectionReference(["attachments"])), o => o.WithTarget(acmeAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull(); - configs.Should().HaveCount(1); - - var attachmentsConfig = configs!.First(); - attachmentsConfig.Name.Should().Be("attachments"); - attachmentsConfig.SourceType.Should().Be("FileSystem"); - attachmentsConfig.BasePath.Should().Contain("attachments").And.EndWith("ACME"); - } - - /// - /// Tests that UnifiedReference with "collection:attachments" prefix resolves collection config from ACME. - /// - [Fact(Timeout = 20000)] - public async Task UnifiedReference_CollectionPrefix_ReturnsConfig() - { - var acmeAddress = new Address("ACME"); - var client = GetClient(c => c.AddContentCollections()); - - await client.Observe(new PingRequest(), o => o.WithTarget(acmeAddress)).Should().Within(19.Seconds()).Emit(); - - // Request collection:attachments from ACME hub using prefix:path format - var response = await client.Observe(new GetDataRequest(new UnifiedReference("collection:attachments")), o => o.WithTarget(acmeAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull(); - configs.Should().Contain(c => c.Name == "attachments"); - } - - /// - /// Tests that UnifiedReference with "content:attachments/test.txt" prefix resolves file content from ACME. - /// - [Fact(Timeout = 20000)] - public async Task UnifiedReference_ContentPrefix_ReturnsFileContent() - { - var acmeAddress = new Address("ACME"); - var client = GetClient(c => c.AddContentCollections()); - - await client.Observe(new PingRequest(), o => o.WithTarget(acmeAddress)).Should().Within(19.Seconds()).Emit(); - - // Request content:attachments/test.txt from ACME hub using prefix:path format - var response = await client.Observe(new GetDataRequest(new UnifiedReference("content:attachments/test.txt")), o => o.WithTarget(acmeAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - // Content should be test text - var content = response.Message.Data as string; - content.Should().NotBeNull(); - content.Should().Contain("Test attachment"); - } - - /// - /// Tests that UnifiedReference with "data:" prefix resolves to DataPathReference behavior. - /// - [Fact(Timeout = 20000)] - public async Task UnifiedReference_DataPrefix_ReturnsData() - { - var acmeAddress = new Address("ACME"); - var client = GetClient(c => c.AddContentCollections()); - - await client.Observe(new PingRequest(), o => o.WithTarget(acmeAddress)).Should().Within(19.Seconds()).Emit(); - - // Request data without prefix (defaults to data:) from ACME hub - var response = await client.Observe(new GetDataRequest(new UnifiedReference("data:")), o => o.WithTarget(acmeAddress)).Should().Emit(); - - // Should return default data (may be null or empty store) - response.Should().NotBeNull(); - } - - /// - /// Tests concurrent collection requests to multiple organizations using UnifiedReference. - /// This test launches parallel requests to ACME and Systemorph simultaneously - /// to verify thread-safe hub initialization with collection:name format. - /// Both ACME and Systemorph are Organizations which have the "attachments" collection. - /// - [Fact(Timeout = 20000)] - public async Task ConcurrentCollectionRequests_WithUnifiedReference_AllSucceed() - { - var acmeAddress = new Address("ACME"); - var systemorphAddress = new Address("Systemorph"); - var client = GetClient(c => c.AddContentCollections()); - - // Launch concurrent requests to both organizations WITHOUT pre-initializing them. - // This tests the race condition where multiple hubs are initialized simultaneously. - // hub.Observe posts eagerly, so building the list fires all 10 requests at once; - // Observable.Merge then collects every response concurrently. - var observables = new List>>(); - - for (int i = 0; i < 10; i++) - { - // Alternate between ACME and Systemorph - both request attachments collection - var address = i % 2 == 0 ? acmeAddress : systemorphAddress; - - observables.Add(client.Observe(new GetDataRequest(new UnifiedReference("collection:attachments")), o => o.WithTarget(address))); - } - - // Wait for all requests to complete - var responses = await Observable.Merge(observables).Take(10).ToList().Should().Within(15.Seconds()).Emit(); - - // Verify all requests succeeded - responses.Should().HaveCount(10, "all 10 concurrent requests should respond"); - foreach (var response in responses) - { - response.Should().NotBeNull("Request should not be null"); - response.Message.Should().NotBeNull("Request message should not be null"); - response.Message.Data.Should().NotBeNull("Request data should not be null"); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull("Request configs should not be null"); - configs.Should().HaveCount(1, "Request should return exactly 1 config"); - } - } - - /// - /// Tests concurrent content file requests to verify thread-safe file resolution. - /// - [Fact(Timeout = 20000)] - public async Task ConcurrentContentRequests_ToSameOrganization_AllSucceed() - { - var acmeAddress = new Address("ACME"); - var client = GetClient(c => c.AddContentCollections()); - - // Launch concurrent content requests WITHOUT pre-initializing. - // hub.Observe posts eagerly, so all 10 fire at build time; Merge collects them. - var observables = new List>>(); - - for (int i = 0; i < 10; i++) - { - observables.Add(client.Observe(new GetDataRequest(new UnifiedReference("content:attachments/test.txt")), o => o.WithTarget(acmeAddress))); - } - - // Wait for all requests to complete - var responses = await Observable.Merge(observables).Take(10).ToList().Should().Within(15.Seconds()).Emit(); - - // Verify all requests succeeded with test content - responses.Should().HaveCount(10, "all 10 concurrent requests should respond"); - foreach (var response in responses) - { - response.Should().NotBeNull(); - response.Message.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var content = response.Message.Data as string; - content.Should().NotBeNull(); - content.Should().Contain("Test attachment"); - } - } - - /// - /// Tests that simulate what BlazorHostingExtensions.MapStaticContent does: - /// 1. Get collection config from target hub (ACME or Systemorph) - /// 2. Add config to portal's content service - /// 3. Retrieve content from the collection - /// - /// This test verifies that ACME/attachments and Systemorph/attachments are stored separately - /// and don't overwrite each other when added to the portal's content service. - /// - [Fact(Timeout = 20000)] - public async Task StaticContentFlow_MultipleOrganizations_ReturnsCorrectContent() - { - var acmeAddress = new Address("ACME"); - var systemorphAddress = new Address("Systemorph"); - var client = GetClient(c => c.AddContentCollections()); - - // Initialize both hubs - await client.Observe(new PingRequest(), o => o.WithTarget(acmeAddress)).Should().Within(19.Seconds()).Emit(); - - await client.Observe(new PingRequest(), o => o.WithTarget(systemorphAddress)).Should().Within(19.Seconds()).Emit(); - - // Step 1: Get collection config from ACME (like BlazorHostingExtensions does) - var acmeConfigResponse = await client.Observe(new GetDataRequest(new ContentCollectionReference(["attachments"])), o => o.WithTarget(acmeAddress)).Should().Emit(); - - var acmeConfigs = ParseCollectionConfigs(acmeConfigResponse.Message.Data); - acmeConfigs.Should().NotBeNull(); - var acmeLogosConfig = acmeConfigs!.First(); - Output.WriteLine($"ACME attachments config: Name={acmeLogosConfig.Name}, BasePath={acmeLogosConfig.BasePath}"); - - // Step 2: Get collection config from Systemorph - var systemorphConfigResponse = await client.Observe(new GetDataRequest(new ContentCollectionReference(["attachments"])), o => o.WithTarget(systemorphAddress)).Should().Emit(); - - var systemorphConfigs = ParseCollectionConfigs(systemorphConfigResponse.Message.Data); - systemorphConfigs.Should().NotBeNull(); - var systemorphLogosConfig = systemorphConfigs!.First(); - Output.WriteLine($"Systemorph attachments config: Name={systemorphLogosConfig.Name}, BasePath={systemorphLogosConfig.BasePath}"); - - // Verify they have different base paths - acmeLogosConfig.BasePath.Should().Contain("ACME", "ACME attachments should point to ACME directory"); - systemorphLogosConfig.BasePath.Should().Contain("Systemorph", "Systemorph attachments should point to Systemorph directory"); - acmeLogosConfig.BasePath.Should().NotBe(systemorphLogosConfig.BasePath, "ACME and Systemorph should have different base paths"); - - // Step 3: Simulate what the portal does - add both configs to its content service - // and verify we can retrieve content from both without collision - var contentService = client.ServiceProvider.GetRequiredService(); - - // Add ACME config with qualified name to avoid collision - var acmeQualifiedConfig = acmeLogosConfig with - { - Name = $"{acmeAddress}/attachments", - Address = acmeAddress - }; - contentService.AddConfiguration(acmeQualifiedConfig); - Output.WriteLine($"Added ACME config with qualified name: {acmeQualifiedConfig.Name}"); - - // Add Systemorph config with qualified name - var systemorphQualifiedConfig = systemorphLogosConfig with - { - Name = $"{systemorphAddress}/attachments", - Address = systemorphAddress - }; - contentService.AddConfiguration(systemorphQualifiedConfig); - Output.WriteLine($"Added Systemorph config with qualified name: {systemorphQualifiedConfig.Name}"); - - // Verify we can get both collections separately - var acmeCollection = await contentService.GetCollection($"{acmeAddress}/attachments").Should().Emit(); - acmeCollection.Should().NotBeNull("ACME attachments collection should be retrievable"); - Output.WriteLine($"ACME collection retrieved: {acmeCollection?.Collection}"); - - var systemorphCollection = await contentService.GetCollection($"{systemorphAddress}/attachments").Should().Emit(); - systemorphCollection.Should().NotBeNull("Systemorph attachments collection should be retrievable"); - Output.WriteLine($"Systemorph collection retrieved: {systemorphCollection?.Collection}"); - - // Verify they are different collections with different base paths - acmeCollection!.Collection.Should().NotBe(systemorphCollection!.Collection, - "ACME and Systemorph should have different collection names"); - } - - /// - /// Tests that "content" collection is properly mapped for Markdown nodes. - /// The content files at samples/Graph/content/{nodePath}/ should be accessible. - /// - [Fact(Timeout = 20000)] - public async Task ContentCollection_ForMarkdownNode_ResolvesCorrectly() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c.AddContentCollections()); - - // Initialize the UCR node hub - await client.Observe(new PingRequest(), o => o.WithTarget(ucrAddress)).Should().Within(19.Seconds()).Emit(); - - // Request the "content" collection configuration - var response = await client.Observe(new GetDataRequest(new ContentCollectionReference(["content"])), o => o.WithTarget(ucrAddress)).Should().Emit(); - - response.Should().NotBeNull(); - response.Message.Should().NotBeNull(); - response.Message.Data.Should().NotBeNull(); - - var configs = ParseCollectionConfigs(response.Message.Data); - configs.Should().NotBeNull(); - configs.Should().HaveCount(1); - - var contentConfig = configs!.First(); - contentConfig.Name.Should().Be("content"); - contentConfig.SourceType.Should().Be("FileSystem"); - // BasePath should contain content/Doc/DataMesh/UnifiedPath - contentConfig.BasePath.Should().Contain("content"); - contentConfig.BasePath.Should().EndWith("Doc/DataMesh/UnifiedPath"); - } - - /// - /// Tests that $Content layout area for icon.svg returns content without hanging. - /// This simulates what happens when @@Doc/DataMesh/UnifiedPath/content:icon.svg is rendered. - /// - [Fact(Timeout = 20000)] - public async Task ContentLayoutArea_IconSvg_ReturnsWithoutHanging() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing $Content area for icon.svg on {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the $Content area with icon.svg as the id - // This is what @@Doc/DataMesh/UnifiedPath/content:icon.svg does - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference("$Content") { Id = "icon.svg" }; - - Output.WriteLine($"Requesting layout area: {reference.Area} with id: {reference.Id}"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - this is where eternal spinner would occur - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(200, rawText.Length))}..."); - - // Assert - should have received something (content or error message) - value.Should().NotBe(default(JsonElement), "Should receive a layout area response"); - } - - /// - /// Tests that $Content layout area for sample.md returns content without hanging. - /// This simulates what happens when @@Doc/DataMesh/UnifiedPath/content:sample.md is rendered. - /// - [Fact(Timeout = 20000)] - public async Task ContentLayoutArea_SampleMd_ReturnsWithoutHanging() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing $Content area for sample.md on {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the $Content area with sample.md as the id - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference("$Content") { Id = "sample.md" }; - - Output.WriteLine($"Requesting layout area: {reference.Area} with id: {reference.Id}"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(200, rawText.Length))}..."); - - // Assert - value.Should().NotBe(default(JsonElement), "Should receive a layout area response"); - } - - /// - /// Tests that $Content layout area for non-existent file returns error message, not eternal spinner. - /// - [Fact(Timeout = 20000)] - public async Task ContentLayoutArea_NonExistentFile_ReturnsErrorMessage() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing $Content area for non-existent file on {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the $Content area with a non-existent file - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference("$Content") { Id = "does-not-exist.txt" }; - - Output.WriteLine($"Requesting layout area: {reference.Area} with id: {reference.Id}"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - should return error message, not hang - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(500, rawText.Length))}..."); - - // Assert - should have received something (error message) - value.Should().NotBe(default(JsonElement), "Should receive a layout area response with error message"); - } - - /// - /// Tests that $Schema layout area returns schema without hanging. - /// This simulates what happens when @@Doc/DataMesh/UnifiedPath/schema: is rendered. - /// - [Fact(Timeout = 20000)] - public async Task SchemaLayoutArea_SelfReference_ReturnsWithoutHanging() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing $Schema area on {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the $Schema area with empty id (self-reference) - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference("$Schema") { Id = "" }; - - Output.WriteLine($"Requesting layout area: {reference.Area} with id: {reference.Id}"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(500, rawText.Length))}..."); - - // Assert - value.Should().NotBe(default(JsonElement), "Should receive a layout area response with schema"); - } - - /// - /// Tests that $Data layout area returns data without hanging. - /// This simulates what happens when @@Doc/DataMesh/UnifiedPath/data: is rendered. - /// - [Fact(Timeout = 20000)] - public async Task DataLayoutArea_SelfReference_ReturnsWithoutHanging() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing $Data area on {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the $Data area with empty id (self-reference) - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference("$Data") { Id = "" }; - - Output.WriteLine($"Requesting layout area: {reference.Area} with id: {reference.Id}"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(500, rawText.Length))}..."); - - // Assert - value.Should().NotBe(default(JsonElement), "Should receive a layout area response with data"); - } - - /// - /// Tests that Markdown node's default area is $Content, not Catalog. - /// This uses the same configuration pattern as MemexConfiguration.cs. - /// When requesting the default area (empty area), it should resolve to $Content for Markdown nodes. - /// - [Fact(Timeout = 20000)] - public async Task MarkdownNode_DefaultArea_IsContentNotCatalog() - { - var ucrAddress = new Address("Doc/DataMesh/UnifiedPath"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing default area for Markdown node at {ucrAddress}"); - - // No ping: the layout-area GetRemoteStream subscription below activates the - // hub + triggers the cold NodeType compile itself. The generous span covers it. - // Request the default area (empty string) - should resolve to $Content for Markdown nodes - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(""); // Empty area = default area - - Output.WriteLine($"Requesting default layout area"); - var stream = workspace.GetRemoteStream(ucrAddress, reference); - - // Wait for the stream to emit a value - // The nodeType config (Markdown) should be compiled before hub creation, - // so the first emission should already have $Content as default area - Output.WriteLine("Waiting for stream value..."); - var changeItem = await stream.Should().Within(60.Seconds()).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(500, rawText.Length))}..."); - - // For Markdown nodes, default area should be Overview which renders the markdown content - // NOT $Catalog which shows a search grid - // Check that the resolved area is Overview, not $Catalog - rawText.Should().Contain("\"area\":\"Overview\"", - "Markdown node default should resolve to Overview area, not $Catalog"); - } - - private static IReadOnlyCollection? ParseCollectionConfigs(object? data) - { - if (data is JsonElement jsonElement) - { - return jsonElement.EnumerateArray() - .Select(e => - { - var config = new ContentCollectionConfig - { - Name = e.TryGetProperty("name", out var name) ? name.GetString() ?? "" - : e.TryGetProperty("Name", out name) ? name.GetString() ?? "" : "", - SourceType = e.TryGetProperty("sourceType", out var st) ? st.GetString() ?? "" - : e.TryGetProperty("SourceType", out st) ? st.GetString() ?? "" : "", - BasePath = e.TryGetProperty("basePath", out var bp) ? bp.GetString() - : e.TryGetProperty("BasePath", out bp) ? bp.GetString() : null - }; - return config; - }) - .ToArray(); - } - return data as IReadOnlyCollection; - } -} diff --git a/test/MeshWeaver.Content.Test/MarkdownFileParserTest.cs b/test/MeshWeaver.Content.Test/MarkdownFileParserTest.cs deleted file mode 100644 index 7cb5009c90..0000000000 --- a/test/MeshWeaver.Content.Test/MarkdownFileParserTest.cs +++ /dev/null @@ -1,900 +0,0 @@ -using System; -using System.Threading.Tasks; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Persistence.Parsers; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using Xunit; - -namespace MeshWeaver.Content.Test; - -/// -/// Unit tests for MarkdownFileParser - parsing and serialization of markdown files with YAML front matter. -/// -public class MarkdownFileParserTest -{ - private readonly MarkdownFileParser _parser = new(); - - #region Parse Tests - - [Fact(Timeout = 20000)] - public void Parse_WithFullYamlFrontMatter_ExtractsAllProperties() - { - // Arrange - var content = """ - --- - NodeType: Article - Name: My Article - Category: Documentation - Description: A detailed article - Icon: BookOpen - State: Transient - Authors: - - John Doe - - Jane Smith - Tags: - - tutorial - - beginner - Thumbnail: /images/thumb.png - --- - - # My Article - - This is the content. - """; - - // Act - var node = _parser.Parse("/test/article.md", content, "test/article.md"); - - // Assert - node.Should().NotBeNull(); - node!.Id.Should().Be("article"); - node.Namespace.Should().Be("test"); - node.NodeType.Should().Be("Article"); - node.Name.Should().Be("My Article"); - node.Category.Should().Be("Documentation"); - node.Icon.Should().Be("BookOpen"); - node.State.Should().Be(MeshNodeState.Transient); - // The YAML Description/Abstract is surfaced on the node's Description column so - // catalog/TOC cards and Postgres search show a real one-line summary. - node.Description.Should().Be("A detailed article"); - - var mdContent = node.Content.Should().BeOfType().Subject; - mdContent.Authors.Should().BeEquivalentTo(new[] { "John Doe", "Jane Smith" }, System.Text.Json.JsonSerializerOptions.Default); - mdContent.Tags.Should().BeEquivalentTo(new[] { "tutorial", "beginner" }, System.Text.Json.JsonSerializerOptions.Default); - mdContent.Thumbnail.Should().Be("/images/thumb.png"); - mdContent.Abstract.Should().Be("A detailed article"); // Mapped from Description - mdContent.Content.Should().Contain("# My Article"); - } - - [Fact(Timeout = 20000)] - public void Parse_ExcludeFromContext_RoundTrips() - { - // A marketing/landing page ships chrome-less: `ExcludeFromContext: [header]` - // maps 1:1 onto MeshNode.ExcludeFromContext (the ONE visibility mechanism — - // no parallel HideHeader flag) and must survive the serialize round-trip. - var content = """ - --- - Name: Landing - ExcludeFromContext: - - header - --- - - Full-bleed hero starts here. - """; - - var node = _parser.Parse("/test/landing.md", content, "test/landing.md"); - - node.Should().NotBeNull(); - node!.ExcludeFromContext.Should().BeEquivalentTo(new[] { "header" }, System.Text.Json.JsonSerializerOptions.Default); - node.IsExcludedFromContext(MeshNodeVisibility.HeaderContext).Should().BeTrue(); - - var serialized = _parser.Serialize(node); - serialized.Should().Contain("ExcludeFromContext:"); - serialized.Should().Contain("header"); - - // And a node without the opt-out never emits the key. - var plain = _parser.Parse("/test/plain.md", "Just text.", "test/plain.md"); - plain!.ExcludeFromContext.Should().BeNull(); - _parser.Serialize(plain).Should().NotContain("ExcludeFromContext"); - } - - [Fact(Timeout = 20000)] - public void Parse_WithMinimalYaml_UsesDefaults() - { - // Arrange - var content = """ - --- - Name: Simple Doc - --- - - Content here. - """; - - // Act - var node = _parser.Parse("/docs/simple.md", content, "docs/simple.md"); - - // Assert - node.Should().NotBeNull(); - node!.Name.Should().Be("Simple Doc"); - node.NodeType.Should().Be("Markdown"); // Default - node.Icon.Should().Be("Document"); // Default - node.State.Should().Be(MeshNodeState.Active); // Default - } - - [Fact(Timeout = 20000)] - public void Parse_WithoutYaml_UsesIdAsName() - { - // Arrange - var content = """ - # Plain Markdown - - No YAML here. - """; - - // Act - var node = _parser.Parse("/docs/plain.md", content, "docs/plain.md"); - - // Assert - node.Should().NotBeNull(); - node!.Id.Should().Be("plain"); - node.Name.Should().Be("plain"); // Defaults to Id - node.NodeType.Should().Be("Markdown"); - } - - [Fact(Timeout = 20000)] - public void Parse_WithLegacyArticleProperties_MapsCorrectly() - { - // Arrange - Uses legacy Title/Abstract instead of Name/Description - var content = """ - --- - Title: Legacy Article - Abstract: This is the legacy abstract - --- - - Content. - """; - - // Act - var node = _parser.Parse("/articles/legacy.md", content, "articles/legacy.md"); - - // Assert - node.Should().NotBeNull(); - node!.Name.Should().Be("Legacy Article"); // Mapped from Title - - var mdContent = node.Content.Should().BeOfType().Subject; - mdContent.Abstract.Should().Be("This is the legacy abstract"); // Mapped from Abstract - } - - [Fact(Timeout = 20000)] - public void Parse_DerivesPathFromRelativePath() - { - // Arrange - var content = "# Test"; - - // Act - var node = _parser.Parse("/root/folder/subfolder/doc.md", content, "folder/subfolder/doc.md"); - - // Assert - node.Should().NotBeNull(); - node!.Id.Should().Be("doc"); - node.Namespace.Should().Be("folder/subfolder"); - node.Path.Should().Be("folder/subfolder/doc"); - } - - [Fact(Timeout = 20000)] - public void Parse_RelativeIconPath_ResolvesToAbsoluteUrl() - { - // Arrange - var content = """ - --- - Name: Test Node - Icon: icons/custom.svg - --- - - Content. - """; - - // Act - var node = _parser.Parse("/root/Cornerstone/doc.md", content, "Cornerstone/doc.md"); - - // Assert - node.Should().NotBeNull(); - node!.Icon.Should().Be("/api/content/Cornerstone/icons/custom.svg"); - } - - [Fact(Timeout = 20000)] - public void Parse_AbsoluteIconPath_PassesThroughUnchanged() - { - // Arrange - var content = """ - --- - Name: Test Node - Icon: /static/storage/content/path/icon.svg - --- - - Content. - """; - - // Act - var node = _parser.Parse("/root/ns/doc.md", content, "ns/doc.md"); - - // Assert - node.Should().NotBeNull(); - node!.Icon.Should().Be("/static/storage/content/path/icon.svg"); - } - - [Fact(Timeout = 20000)] - public void Parse_HttpUrlIcon_PassesThroughUnchanged() - { - // Arrange - var content = """ - --- - Name: Test Node - Icon: https://example.com/icon.svg - --- - - Content. - """; - - // Act - var node = _parser.Parse("/root/ns/doc.md", content, "ns/doc.md"); - - // Assert - node.Should().NotBeNull(); - node!.Icon.Should().Be("https://example.com/icon.svg"); - } - - #endregion - - #region Serialize Tests - - [Fact(Timeout = 20000)] - public void Serialize_WithAllProperties_WritesCompleteYaml() - { - // Arrange - var node = new MeshNode("doc", "test") - { - NodeType = "Article", - Name = "Test Article", - Category = "Docs", - Icon = "BookOpen", - State = MeshNodeState.Transient, - Content = new MarkdownContent - { - Content = "# Hello World\n\nContent here.", - Authors = ["Author 1", "Author 2"], - Tags = ["tag1", "tag2"], - Thumbnail = "/thumb.png", - Abstract = "A brief summary" - } - }; - - // Act - var result = _parser.Serialize(node); - - // Assert - result.Should().Contain("---"); - result.Should().Contain("NodeType: Article"); - result.Should().Contain("Name: Test Article"); - result.Should().Contain("Category: Docs"); - result.Should().Contain("Icon: BookOpen"); - result.Should().Contain("State: Transient"); - result.Should().Contain("Author 1"); - result.Should().Contain("Author 2"); - result.Should().Contain("tag1"); - result.Should().Contain("tag2"); - result.Should().Contain("Thumbnail: /thumb.png"); - result.Should().Contain("Abstract: A brief summary"); - result.Should().Contain("# Hello World"); - result.Should().Contain("Content here."); - } - - [Fact(Timeout = 20000)] - public void Serialize_OmitsDefaultValues() - { - // Arrange - Node with default values that should be omitted - var node = new MeshNode("doc", "test") - { - NodeType = "Markdown", // Default - should be omitted - Name = "doc", // Same as Id - should be omitted - Icon = "Document", // Default - should be omitted - State = MeshNodeState.Active, // Default - should be omitted - Content = new MarkdownContent { Content = "# Content" } - }; - - // Act - var result = _parser.Serialize(node); - - // Assert - should not have YAML block since all values are defaults - result.Should().NotContain("NodeType:"); - result.Should().NotContain("Name:"); - result.Should().NotContain("Icon:"); - result.Should().NotContain("State:"); - result.Should().Contain("# Content"); - } - - [Fact(Timeout = 20000)] - public void Serialize_WithStringContent_WritesDirectly() - { - // Arrange - var node = new MeshNode("doc", "test") - { - Name = "String Content Doc", - Content = "# Direct String\n\nThis is string content." - }; - - // Act - var result = _parser.Serialize(node); - - // Assert - result.Should().Contain("Name: String Content Doc"); - result.Should().Contain("# Direct String"); - result.Should().Contain("This is string content."); - } - - [Fact(Timeout = 20000)] - public void Serialize_WithNullContent_WritesOnlyYaml() - { - // Arrange - var node = new MeshNode("doc", "test") - { - Name = "No Content Doc", - Category = "Empty", - Content = null - }; - - // Act - var result = _parser.Serialize(node); - - // Assert - result.Should().Contain("Name: No Content Doc"); - result.Should().Contain("Category: Empty"); - // Should end after YAML block - } - - [Fact(Timeout = 20000)] - public void Serialize_ResolvedAbsoluteIconPath_IsOmittedFromYaml() - { - // Arrange - Icon starts with the resolved content route (was resolved from a relative path) - var node = new MeshNode("doc", "ns") - { - Name = "Test Node", - Icon = "/api/content/ns/icons/custom.svg", - Content = new MarkdownContent { Content = "# Content" } - }; - - // Act - var result = _parser.Serialize(node); - - // Assert - The serialization guard should strip resolved paths - result.Should().NotContain("Icon:"); - result.Should().Contain("Name: Test Node"); - } - - #endregion - - #region Round-Trip Tests - - [Fact(Timeout = 20000)] - public void RoundTrip_RelativeIcon_ResolvesCorrectlyAfterReParse() - { - // Arrange - Markdown with a relative icon path - var originalContent = """ - --- - Name: Icon Test - Thumbnail: icons/custom.svg - --- - - # Icon Round-Trip Test - """; - - // Act - Parse (resolves relative icon), serialize, re-parse - var node = _parser.Parse("/root/Cornerstone/doc.md", originalContent, "Cornerstone/doc.md"); - node!.Icon.Should().Be("/api/content/Cornerstone/icons/custom.svg"); - - var serialized = _parser.Serialize(node); - - // The resolved absolute path should NOT appear in serialized YAML - serialized.Should().NotContain("/api/content/"); - - // Re-parse — Thumbnail is still in YAML and should resolve again - var reparsed = _parser.Parse("/root/Cornerstone/doc.md", serialized, "Cornerstone/doc.md"); - - // Assert — icon resolves to the same absolute URL (no double-resolution) - reparsed.Should().NotBeNull(); - reparsed!.Icon.Should().Be("/api/content/Cornerstone/icons/custom.svg"); - } - - [Fact(Timeout = 20000)] - public void RoundTrip_PreservesAllData() - { - // Arrange - var originalContent = """ - --- - NodeType: Tutorial - Name: Complete Tutorial - Category: Learning - Description: A comprehensive tutorial - Abstract: A comprehensive tutorial - Icon: School - Authors: - - Teacher One - Tags: - - advanced - --- - - # Tutorial Title - - Step 1: Do this. - Step 2: Do that. - """; - - // Act - Parse then serialize - var node = _parser.Parse("/tutorials/complete.md", originalContent, "tutorials/complete.md"); - var serialized = _parser.Serialize(node!); - - // Re-parse to verify - var reparsed = _parser.Parse("/tutorials/complete.md", serialized, "tutorials/complete.md"); - - // Assert - reparsed.Should().NotBeNull(); - reparsed!.NodeType.Should().Be("Tutorial"); - reparsed.Name.Should().Be("Complete Tutorial"); - reparsed.Category.Should().Be("Learning"); - reparsed.Icon.Should().Be("School"); - - var mdContent = reparsed.Content.Should().BeOfType().Subject; - mdContent.Authors.Should().BeEquivalentTo(new[] { "Teacher One" }, System.Text.Json.JsonSerializerOptions.Default); - mdContent.Tags.Should().BeEquivalentTo(new[] { "advanced" }, System.Text.Json.JsonSerializerOptions.Default); - mdContent.Abstract.Should().Be("A comprehensive tutorial"); - mdContent.Content.Should().Contain("# Tutorial Title"); - mdContent.Content.Should().Contain("Step 1: Do this."); - } - - #endregion - - #region Slide + Order Frontmatter (Notes / Background / Order) - - [Fact(Timeout = 20000)] - public void RoundTrip_SlideNode_PreservesNotesBackgroundAndOrder() - { - // Arrange - a typed Slide node as the mesh holds it (framework SlideContent) - var node = new MeshNode("S01", "deck") - { - NodeType = "Slide", - Name = "Opening", - Order = 1, - Content = new SlideContent - { - Content = "# Opening\n\nWelcome to the deck.", - Notes = "Speak slowly.\nPause after the title.", - Background = "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" - } - }; - - // Act - serialize to canonical .md, then parse back - var serialized = _parser.Serialize(node); - var reparsed = _parser.Parse("/root/deck/S01.md", serialized, "deck/S01.md"); - - // Assert - the .md carries everything in frontmatter + body - serialized.Should().Contain("NodeType: Slide"); - serialized.Should().Contain("Order: 1"); - serialized.Should().Contain("# Opening"); - - // ... and a git reimport no longer downgrades Slide → Markdown - reparsed.Should().NotBeNull(); - reparsed!.NodeType.Should().Be("Slide"); - reparsed.Name.Should().Be("Opening"); - reparsed.Order.Should().Be(1); - var slide = reparsed.Content.Should().BeOfType().Subject; - slide.Content.Should().Be("# Opening\n\nWelcome to the deck."); - slide.Notes.Should().Be("Speak slowly.\nPause after the title."); - slide.Background.Should().Be("linear-gradient(135deg, #667eea 0%, #764ba2 100%)"); - } - - [Fact(Timeout = 20000)] - public void Serialize_SlideNode_WithJsonElementContent_EmitsNotesBackgroundAndBody() - { - // Arrange - the untyped wire form a hub without the typed registry resolves - var json = """ - {"$type":"SlideContent","content":"# Body slide","notes":"Presenter note.","background":"#123456"} - """; - var node = new MeshNode("S02", "deck") - { - NodeType = "Slide", - Name = "S02", - Order = 2, - Content = System.Text.Json.JsonDocument.Parse(json).RootElement - }; - - // Act - var serialized = _parser.Serialize(node); - var reparsed = _parser.Parse("/root/deck/S02.md", serialized, "deck/S02.md"); - - // Assert - serialized.Should().Contain("NodeType: Slide"); - serialized.Should().Contain("Order: 2"); - serialized.Should().Contain("Presenter note."); - serialized.Should().Contain("# Body slide"); - - reparsed!.NodeType.Should().Be("Slide"); - reparsed.Order.Should().Be(2); - var slide = reparsed.Content.Should().BeOfType().Subject; - slide.Content.Should().Be("# Body slide"); - slide.Notes.Should().Be("Presenter note."); - slide.Background.Should().Be("#123456"); - } - - [Fact(Timeout = 20000)] - public void Parse_NamespacedSlideNode_BuildsSlideContent_AndRoundTrips() - { - // Arrange - a plugin-namespaced slide file exactly as the education repo ships - // it (NodeType Slides/Slide). Matching only the bare "Slide" constant degraded - // these to MarkdownContent, and the next mesh→repo export dropped their - // Notes/Background frontmatter from git. - var raw = """ - --- - NodeType: Slides/Slide - Name: After a while - Order: 4 - Notes: Pause here. This is the payoff of the daily pages. - Background: linear-gradient(135deg,#0b1d3a,#0655bf) - --- - - # After a while - """; - - // Act - var parsed = _parser.Parse("/root/deck/S04.md", raw, "deck/S04.md"); - var serialized = _parser.Serialize(parsed!); - - // Assert - the namespaced type keeps its typed SlideContent ... - parsed!.NodeType.Should().Be("Slides/Slide"); - var slide = parsed.Content.Should().BeOfType().Subject; - slide.Notes.Should().Be("Pause here. This is the payoff of the daily pages."); - slide.Background.Should().Be("linear-gradient(135deg,#0b1d3a,#0655bf)"); - - // ... and the export re-emits the frontmatter instead of destroying it - serialized.Should().Contain("NodeType: Slides/Slide"); - serialized.Should().Contain("Notes: Pause here. This is the payoff of the daily pages."); - serialized.Should().Contain("Background: linear-gradient(135deg,#0b1d3a,#0655bf)"); - } - - [Fact(Timeout = 20000)] - public void Serialize_NamespacedSlideNode_WithJsonElementContent_EmitsNotesAndBackground() - { - // Arrange - untyped wire form of a namespaced slide (hub without typed registry) - var json = """ - {"$type":"SlideContent","content":"# Body","notes":"A note.","background":"#0b1d3a"} - """; - var node = new MeshNode("S05", "deck") - { - NodeType = "Slides/Slide", - Name = "S05", - Content = System.Text.Json.JsonDocument.Parse(json).RootElement - }; - - // Act - var serialized = _parser.Serialize(node); - - // Assert - serialized.Should().Contain("NodeType: Slides/Slide"); - serialized.Should().Contain("Notes: A note."); - serialized.Should().Contain("Background:").And.Contain("#0b1d3a"); - serialized.Should().Contain("# Body"); - } - - [Fact(Timeout = 20000)] - public void RoundTrip_OrderedMarkdownNode_PreservesOrder() - { - // Arrange - Order applies to ANY node type (lesson/module ordering) - var node = new MeshNode("L05", "course") - { - Name = "Lesson Five", - Order = 5, - Content = new MarkdownContent { Content = "# Lesson 5" } - }; - - // Act - var serialized = _parser.Serialize(node); - var reparsed = _parser.Parse("/root/course/L05.md", serialized, "course/L05.md"); - - // Assert - Order round-trips; content stays MarkdownContent - serialized.Should().Contain("Order: 5"); - reparsed!.Order.Should().Be(5); - reparsed.NodeType.Should().Be("Markdown"); - reparsed.Content.Should().BeOfType(); - } - - [Fact(Timeout = 20000)] - public void Serialize_NodeWithoutNewKeys_EmissionIsByteIdenticalToLegacyFormat() - { - // Arrange - a node carrying none of Order/Notes/Background. Its emission is - // pinned byte-for-byte: the new frontmatter keys are OmitNull and declared - // AFTER the legacy keys, so existing git mirrors see zero diff churn. - var node = new MeshNode("doc", "test") - { - NodeType = "Article", - Name = "My Doc", - Category = "Docs", - Content = new MarkdownContent - { - Content = "# Body\n\nText.", - Abstract = "Summary" - } - }; - - var nl = Environment.NewLine; - var expected = - "---" + nl + - "NodeType: Article" + nl + - "Name: My Doc" + nl + - "Category: Docs" + nl + - "Abstract: Summary" + nl + - "---" + nl + - nl + - "# Body\n\nText."; - - // Act - var serialized = _parser.Serialize(node); - - // Assert - serialized.Should().Be(expected); - } - - [Fact(Timeout = 20000)] - public void Parse_LegacyFileWithoutNewKeys_ParsesUnchanged() - { - // Arrange - a pre-existing mirror file that never carried the new keys - var content = """ - --- - NodeType: Article - Name: Legacy File - --- - - # Legacy - - Body. - """; - - // Act - var node = _parser.Parse("/test/legacy.md", content, "test/legacy.md"); - - // Assert - no Order, regular MarkdownContent, nothing invented - node.Should().NotBeNull(); - node!.NodeType.Should().Be("Article"); - node.Order.Should().BeNull(); - node.Content.Should().BeOfType(); - } - - [Fact(Timeout = 20000)] - public void Parse_LegacySlideFileWithoutNotesOrBackground_ReconstructsSlideContent() - { - // Arrange - a Slide exported before the frontmatter carried Notes/Background - var content = """ - --- - NodeType: Slide - Name: Old Slide - --- - - # Old slide body - """; - - // Act - var node = _parser.Parse("/deck/old.md", content, "deck/old.md"); - - // Assert - Slide is reconstructed typed (no downgrade to Markdown), extras null - node.Should().NotBeNull(); - node!.NodeType.Should().Be("Slide"); - var slide = node.Content.Should().BeOfType().Subject; - slide.Content.Should().Contain("# Old slide body"); - slide.Notes.Should().BeNull(); - slide.Background.Should().BeNull(); - } - - [Fact(Timeout = 20000)] - public void Parse_NonSlideFileWithStrayNotes_IgnoresThem() - { - // Arrange - Notes/Background only mean something on a Slide; on any other - // node type they are ignored (MarkdownContent has no such fields, and - // inventing an untyped content to carry them would downgrade the node). - var content = """ - --- - Name: Not A Slide - Notes: stray presenter notes - Background: red - --- - - Body. - """; - - // Act - var node = _parser.Parse("/test/plain.md", content, "test/plain.md"); - var reserialized = _parser.Serialize(node!); - - // Assert - node!.Content.Should().BeOfType(); - reserialized.Should().NotContain("Notes:"); - reserialized.Should().NotContain("Background:"); - } - - #endregion - - #region CanSerialize Tests - - [Fact(Timeout = 20000)] - public void CanSerialize_WithMarkdownNodeType_ReturnsTrue() - { - var node = new MeshNode("doc") { NodeType = "Markdown" }; - _parser.CanSerialize(node).Should().BeTrue(); - } - - [Fact(Timeout = 20000)] - public void CanSerialize_WithMarkdownContent_ReturnsTrue() - { - var node = new MeshNode("doc") { Content = new MarkdownContent { Content = "test" } }; - _parser.CanSerialize(node).Should().BeTrue(); - } - - [Fact(Timeout = 20000)] - public void CanSerialize_WithStringContent_ReturnsTrue() - { - var node = new MeshNode("doc") { Content = "# Markdown string" }; - _parser.CanSerialize(node).Should().BeTrue(); - } - - [Fact(Timeout = 20000)] - public void CanSerialize_WithTypedSlideContent_ReturnsTrue() - { - // Typed SlideContent serializes as canonical .md (frontmatter carries - // Notes/Background) — not as a .json fallback. - var node = new MeshNode("S01") { NodeType = "Slide", Content = new SlideContent { Content = "# Slide" } }; - _parser.CanSerialize(node).Should().BeTrue(); - } - - [Fact(Timeout = 20000)] - public void CanSerialize_WithOtherNodeType_ReturnsFalse() - { - var node = new MeshNode("doc") { NodeType = "Space", Content = new { Id = "test" } }; - _parser.CanSerialize(node).Should().BeFalse(); - } - - #endregion - - #region Edge Cases - - [Fact(Timeout = 20000)] - public void Parse_WithMalformedYaml_UsesDefaults() - { - // Arrange - var content = """ - --- - Name: [invalid yaml - --- - - Content. - """; - - // Act - var node = _parser.Parse("/test/malformed.md", content, "test/malformed.md"); - - // Assert - Should not throw, uses defaults - node.Should().NotBeNull(); - node!.Id.Should().Be("malformed"); - node.Name.Should().Be("malformed"); // Falls back to Id - } - - [Fact(Timeout = 20000)] - public void Parse_WithEmptyContent_ReturnsEmptyMarkdownContent() - { - // Arrange - var content = ""; - - // Act - var node = _parser.Parse("/test/empty.md", content, "test/empty.md"); - - // Assert - node.Should().NotBeNull(); - var mdContent = node!.Content.Should().BeOfType().Subject; - mdContent.Content.Should().BeEmpty(); - } - - [Fact(Timeout = 20000)] - public void Serialize_WithSpecialCharacters_EscapesCorrectly() - { - // Arrange - var node = new MeshNode("doc", "test") - { - Name = "Doc with: colons", - Content = new MarkdownContent { Content = "# Content" } - }; - - // Act - var result = _parser.Serialize(node); - - // Parse back to verify - var reparsed = _parser.Parse("/test/doc.md", result, "test/doc.md"); - - // Assert - reparsed!.Name.Should().Be("Doc with: colons"); - } - - #endregion - - #region Relative-link prerender base (core #582) - - /// - /// Pins core #582: the prerender resolves RELATIVE markdown links against the relativePath the - /// caller hands in — so an importer must pass the FULL mesh path ({space}/{in-repo path}), not - /// the bare in-repo path. With the space-prefixed path, "../../Solution/X" on - /// {space}/TDD/Exercise/X prerenders as href="/{space}/TDD/Solution/X"; with the bare path the - /// partition segment is lost (href="/TDD/…" — the broken hrefs the courses shipped). - /// GitHubSyncService.ParseFile passes the prefixed path since the fix. - /// - [Fact(Timeout = 20000)] - public void Parse_RelativeLinks_PrerenderAgainstTheGivenNodePath() - { - var content = "See **[the solution](../../Solution/OrdinalSuffix)** and [the module](../../)."; - - var prefixed = _parser.Parse( - "/repo/TDD/Exercise/OrdinalSuffix.md", content, - "AgenticEngineering/TDD/Exercise/OrdinalSuffix.md"); - var md = Assert.IsType(prefixed!.Content); - Assert.Contains("href=\"/AgenticEngineering/TDD/Solution/OrdinalSuffix\"", md.PrerenderedHtml); - Assert.Contains("href=\"/AgenticEngineering/TDD\"", md.PrerenderedHtml); - - // The bug shape the fix guards against: a bare in-repo path loses the partition segment. - var bare = _parser.Parse( - "/repo/TDD/Exercise/OrdinalSuffix.md", content, - "TDD/Exercise/OrdinalSuffix.md"); - var bareMd = Assert.IsType(bare!.Content); - Assert.Contains("href=\"/TDD/Solution/OrdinalSuffix\"", bareMd.PrerenderedHtml); - Assert.DoesNotContain("/AgenticEngineering/", bareMd.PrerenderedHtml); - } - - #endregion - - /// - /// 🚨 #1984: front matter binds a key in EITHER casing. Two parsers claim .md — - /// AgentFileParser binds camelCase but claims only nodeType: Agent, so everything else - /// lands here. A PascalCase-only deserializer never bound nodeType:, the miss was - /// swallowed by IgnoreUnmatchedProperties, and the node silently defaulted to Markdown: - /// eleven skills authored exactly as the plugin repos' guidance prescribes imported as plain - /// Markdown pages and never reached the slash-command list. - /// - /// Both spellings are pinned because the tempting fix — swapping in a camelCase - /// convention — would merely move the breakage onto the 1146 files that author these keys - /// PascalCase. - /// - [Theory] - [InlineData("nodeType")] - [InlineData("NodeType")] - public void FrontMatter_BindsANodeTypeInEitherCasing(string key) - { - var markdown = $""" - --- - id: package-versions - {key}: Skill - name: /package-versions - --- - - # The skill's body. - """; - - var node = new MarkdownFileParser().Parse("Skill/package-versions.md", markdown, "Skill/package-versions.md"); - - Assert.NotNull(node); - Assert.Equal("Skill", node!.NodeType); - Assert.Equal("/package-versions", node.Name); - } - - /// An unspecified node type still defaults to Markdown — the fallback is intact. - [Fact] - public void FrontMatter_WithNoNodeType_StillDefaultsToMarkdown() - { - var node = new MarkdownFileParser().Parse( - "Doc/page.md", "---\nid: page\n---\n\n# Body\n", "Doc/page.md"); - Assert.Equal("Markdown", node!.NodeType); - } -} diff --git a/test/MeshWeaver.Content.Test/MarkdownNodeIntegrationTest.cs b/test/MeshWeaver.Content.Test/MarkdownNodeIntegrationTest.cs deleted file mode 100644 index 4a54b6d718..0000000000 --- a/test/MeshWeaver.Content.Test/MarkdownNodeIntegrationTest.cs +++ /dev/null @@ -1,878 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; - -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Documentation; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence; -using MeshWeaver.Layout; -using Markdig; -using Markdig.Syntax; - -using MeshWeaver.Kernel; -using MeshWeaver.Markdown; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -using System.Reactive.Threading.Tasks; -namespace MeshWeaver.Content.Test; - -/// -/// Integration tests for Markdown nodes including the collaborative editing documentation. -/// Tests verify that MarkdownDocument content type works correctly with the sample data. -/// -[Collection("MarkdownNodeTests")] -public class MarkdownNodeIntegrationTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// Share Mesh/SP across [Fact]s — see MonolithMeshTestBase.ShareMeshAcrossTests. - protected override bool ShareMeshAcrossTests => true; - - - private static readonly string SharedCacheDirectory = Path.Combine( - Path.GetTempPath(), - "MeshWeaverMarkdownTests", - ".mesh-cache"); - - private static string GetSamplesGraphPath() - { - var currentDir = Directory.GetCurrentDirectory(); - var solutionRoot = Path.GetFullPath(Path.Combine(currentDir, "..", "..", "..", "..", "..")); - return Path.Combine(solutionRoot, "samples", "Graph"); - } - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var graphPath = TestPaths.SamplesGraph; - var dataDirectory = TestPaths.SamplesGraphData; - Directory.CreateDirectory(SharedCacheDirectory); - - Output.WriteLine($"Graph path: {graphPath}"); - Output.WriteLine($"Data directory: {dataDirectory}"); - Output.WriteLine($"Cache directory: {SharedCacheDirectory}"); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:SourceType"] = "FileSystem", - ["Graph:Storage:BasePath"] = graphPath - }) - .Build(); - - return builder - .UseMonolithMesh() - .AddPartitionedFileSystemPersistence(dataDirectory) - .AddMeshWeaverDocs() - .AddDoc() - .AddDocumentation() - .AddSystemorph() - .ConfigureServices(services => - { - services.Configure(o => - { - o.CacheDirectory = SharedCacheDirectory; - o.EnableDiskCache = true; - }); - services.AddSingleton(configuration); - return services; - }) - .AddGraph(); - } - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - { - return base.ConfigureClient(configuration) - .AddLayoutClient(); // Required for workspace/layout area streams - } - - #region Markdown Node Loading Tests - - /// - /// Test that the CollaborativeEditing markdown node exists in the MeshWeaver namespace. - /// - [Fact(Timeout = 20000)] - public async Task CollaborativeEditing_NodeExists_InMeshWeaverNamespace() - { - var node = await Mesh.GetMeshNode("Doc/DataMesh/CollaborativeEditing", ReadNodeTimeout).Should().Emit(); - - node.Should().NotBeNull("CollaborativeEditing node should exist"); - node!.Path.Should().Be("Doc/DataMesh/CollaborativeEditing"); - node.NodeType.Should().Be("Markdown"); - node.Name.Should().Be("Collaborative Editing"); - } - - /// - /// Test that the markdown node has MarkdownDocument content. - /// - [Fact(Timeout = 20000)] - public async Task CollaborativeEditing_HasMarkdownDocumentContent() - { - var node = await Mesh.GetMeshNode("Doc/DataMesh/CollaborativeEditing", ReadNodeTimeout).Should().Emit(); - - node.Should().NotBeNull(); - node!.Content.Should().NotBeNull("Node should have content"); - - // Content should be deserializable as MarkdownDocument - var jsonContent = node.Content as JsonElement?; - if (jsonContent.HasValue) - { - var typeDiscriminator = jsonContent.Value.GetProperty("$type").GetString(); - typeDiscriminator.Should().Be("MarkdownDocument"); - } - } - - /// - /// Test that the markdown content contains documentation about collaborative editing. - /// - [Fact(Timeout = 20000)] - public async Task CollaborativeEditing_ContentContainsDocumentation() - { - var node = await Mesh.GetMeshNode("Doc/DataMesh/CollaborativeEditing", ReadNodeTimeout).Should().Emit(); - - node.Should().NotBeNull(); - - // Extract content field from the MarkdownDocument - var jsonContent = node!.Content as JsonElement?; - if (jsonContent.HasValue && jsonContent.Value.TryGetProperty("content", out var contentProp)) - { - var markdownContent = contentProp.GetString(); - markdownContent.Should().NotBeNullOrEmpty(); - markdownContent.Should().Contain("Adding Comments"); - markdownContent.Should().Contain("powerful platform"); - // The example is kept CLEAN — comments and tracked changes are satellites now, not - // embedded markers in the document text. - markdownContent.Should().NotContain(" - diff --git a/test/MeshWeaver.Hosting.Monolith.Test/UserActivityAreaTest.cs b/test/MeshWeaver.Hosting.Monolith.Test/UserActivityAreaTest.cs deleted file mode 100644 index fb32dee852..0000000000 --- a/test/MeshWeaver.Hosting.Monolith.Test/UserActivityAreaTest.cs +++ /dev/null @@ -1,291 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence; -using MeshWeaver.Layout; -using MeshWeaver.Layout.Composition; -using MeshWeaver.Mesh; -using MeshWeaver.Mesh.Security; -using MeshWeaver.Mesh.Services; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; - -namespace MeshWeaver.Hosting.Monolith.Test; - -/// -/// Integration test for User node's Activity layout area. -/// Uses the same samples/Graph/Data setup to verify that -/// AddUserActivityViews() is properly invoked when a User hub is created. -/// Regression test for: built-in HubConfiguration was overwritten by compiled config. -/// -[Collection("SamplesGraphData")] -public class UserActivityAreaTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// Share Mesh/SP across [Fact]s — see MonolithMeshTestBase.ShareMeshAcrossTests. - protected override bool ShareMeshAcrossTests => true; - - private static readonly string SharedCacheDirectory = Path.Combine( - Path.GetTempPath(), - "MeshWeaverUserActivityTests", - ".mesh-cache"); - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var graphPath = TestPaths.SamplesGraph; - var dataDirectory = TestPaths.SamplesGraphData; - Directory.CreateDirectory(SharedCacheDirectory); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:SourceType"] = "FileSystem", - ["Graph:Storage:BasePath"] = graphPath - }) - .Build(); - - return builder - .UseMonolithMesh() - .AddPartitionedFileSystemPersistence(dataDirectory) - .AddUserData() - .AddCornerstone() - .AddMeshWeaverDocs() - .AddMeshNodes(MeshNode.FromPath("User/TestUser") with - { - Name = "Test User", - NodeType = "User", - State = MeshNodeState.Active, - }) - .ConfigureServices(services => - { - services.Configure(o => - { - o.CacheDirectory = SharedCacheDirectory; - o.EnableDiskCache = true; - }); - services.AddSingleton(configuration); - return services; - }) - .AddGraph(); - } - - protected override MessageHubConfiguration ConfigureClient(MessageHubConfiguration configuration) - { - return base.ConfigureClient(configuration) - .AddLayoutClient(); - } - - /// - /// Verify that the User node type is registered and has HubConfiguration. - /// - [Fact(Timeout = 20000)] - public void UserNodeType_IsRegistered() - { - var typeNode = Mesh.ServiceProvider.FindStaticNode("User"); - typeNode.Should().NotBeNull("User node type should be registered as a static node"); - typeNode!.HubConfiguration.Should().NotBeNull( - "User node type should be registered via AddGraph() → AddUserType() with HubConfiguration"); - } - - /// - /// Verify that NodeTypeService returns cached HubConfiguration for the "User" node type. - /// - [Fact(Timeout = 20000)] - public void UserNodeType_HasHubConfig_InMeshConfiguration() - { - var typeNode = Mesh.ServiceProvider.FindStaticNode("User"); - typeNode.Should().NotBeNull("User node type should be registered as a static node"); - typeNode!.HubConfiguration.Should().NotBeNull( - "User node type should have HubConfiguration in static MeshConfiguration"); - } - - /// - /// Verify that the Roland user node can be loaded and enriched with HubConfiguration. - /// - [Fact(Timeout = 20000)] - public async Task UserNode_Roland_CanBeLoaded() - { - var resolver = Mesh.ServiceProvider.GetRequiredService(); - - var node = await ReadNode("User/TestUser").Should().Emit(); - node.Should().NotBeNull("Oliver user node should exist in samples/Graph/Data/User/TestUser.json"); - node!.NodeType.Should().Be("User"); - - // Enrich with node type — this should attach HubConfiguration - var enriched = await resolver.ResolveConfiguration(node).Should().Emit(); - enriched.HubConfiguration.Should().NotBeNull( - "After resolution, User node should have HubConfiguration from UserNodeType"); - } - - /// - /// Verify that a hub can be created for the User/TestUser address - /// and that it responds to PingRequest (hub is alive). - /// - [Fact(Timeout = 20000)] - public async Task UserHub_Roland_CanBeCreated() - { - var client = GetClient(); - var rolandAddress = new Address("User/TestUser"); - - var response = await client.Observe(new PingRequest(), o => o.WithTarget(rolandAddress)).Should().Within(15.Seconds()).Emit(); - - response.Should().NotBeNull("User/TestUser hub should be created and respond to ping"); - } - - /// - /// The main test: resolve the Activity layout area on User/TestUser. - /// This is the area registered by AddUserActivityViews(). - /// Regression test: compilation of User/Source/Person.cs was overwriting - /// the built-in HubConfiguration, losing layout areas. - /// - [Fact(Timeout = 20000)] - public async Task ActivityArea_CanBeResolved_ForUserRoland() - { - var client = GetClient(); - var rolandAddress = new Address("User/TestUser"); - - await client.Observe(new PingRequest(), o => o.WithTarget(rolandAddress)).Should().Within(15.Seconds()).Emit(); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(UserActivityLayoutAreas.ActivityArea); - - var stream = workspace.GetRemoteStream( - rolandAddress, - reference); - - var value = await stream.Should().Within(TimeSpan.FromSeconds(15)).Emit(); - - value.Should().NotBe(default(JsonElement), - "Activity area should render for User/TestUser — AddUserActivityViews() must be invoked"); - } - - /// - /// The public profile area (/{user}/Profile) resolves end-to-end — the polished, read-only - /// showcase every visitor sees. Proves is wired - /// into the User hub's layout by AddUserActivityLayoutAreas. - /// - [Fact(Timeout = 20000)] - public async Task ProfileArea_CanBeResolved_ForUserRoland() - { - var client = GetClient(); - var rolandAddress = new Address("User/TestUser"); - - await client.Observe(new PingRequest(), o => o.WithTarget(rolandAddress)).Should().Within(15.Seconds()).Emit(); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(UserActivityLayoutAreas.ProfileArea); - - var stream = workspace.GetRemoteStream( - rolandAddress, reference); - - var value = await stream.Should().Within(TimeSpan.FromSeconds(15)).Emit(); - - value.Should().NotBe(default(JsonElement), - "the public Profile area should render for User/TestUser"); - } - - /// - /// The owner-only profile editor area (/{user}/EditProfile) resolves end-to-end — either the - /// node-bound bio/links/showcase editor (owner) or an access-denied card (visitor). Either way it - /// renders, proving is registered. - /// - [Fact(Timeout = 20000)] - public async Task EditProfileArea_CanBeResolved_ForUserRoland() - { - var client = GetClient(); - var rolandAddress = new Address("User/TestUser"); - - await client.Observe(new PingRequest(), o => o.WithTarget(rolandAddress)).Should().Within(15.Seconds()).Emit(); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(UserActivityLayoutAreas.EditProfileArea); - - var stream = workspace.GetRemoteStream( - rolandAddress, reference); - - var value = await stream.Should().Within(TimeSpan.FromSeconds(15)).Emit(); - - value.Should().NotBe(default(JsonElement), - "the EditProfile area should render (editor or access-denied) for User/TestUser"); - } - - /// - /// Simulates the production onboarding flow: creates a User node at runtime - /// (not pre-registered via AddMeshNodes), then verifies the Activity area resolves. - /// This tests the path: persistence → MeshCatalog.ResolvePathAsync → routing → hub creation. - /// - [Fact(Timeout = 15000)] - public async Task ActivityArea_WorksForRuntimeCreatedUser() - { - // Arrange — create a user node at runtime (simulating onboarding) - var username = $"RuntimeUser_{Guid.NewGuid():N}"[..20]; - var meshService = Mesh.ServiceProvider.GetRequiredService(); - var accessService = Mesh.ServiceProvider.GetRequiredService(); - - using (accessService.ImpersonateAsHub(Mesh)) - { - await meshService.CreateNode(new MeshNode(username, "User") - { - Name = "Runtime Test User", - NodeType = "User", - State = MeshNodeState.Active, - Content = new User { Email = "runtime@test.com", Bio = "Created at runtime" } - }).Should().Within(15.Seconds()).Emit(); - } - - // Act — request the Activity area (same as Index.razor does) - var client = GetClient(); - var userAddress = new Address("User", username); - - // First, verify the hub can be created - var pingResponse = await client.Observe(new PingRequest(), o => o.WithTarget(userAddress)).Should().Within(15.Seconds()).Emit(); - pingResponse.Should().NotBeNull($"User/{username} hub should be created and respond to ping"); - - // Now request the Activity layout area - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(UserActivityLayoutAreas.ActivityArea); - var stream = workspace.GetRemoteStream( - userAddress, reference); - - var value = await stream.Should().Within(TimeSpan.FromSeconds(10)).Emit(); - - // Assert - value.Should().NotBe(default(JsonElement), - "Activity area should render for a runtime-created User node — " + - "this simulates the production onboarding → Index.razor flow"); - } - - /// - /// Also verify the Overview area works (baseline — this uses AddDefaultLayoutAreas). - /// - [Fact(Timeout = 20000)] - public async Task OverviewArea_CanBeResolved_ForUserRoland() - { - var client = GetClient(); - var rolandAddress = new Address("User/TestUser"); - - await client.Observe(new PingRequest(), o => o.WithTarget(rolandAddress)).Should().Within(15.Seconds()).Emit(); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(MeshNodeLayoutAreas.OverviewArea); - - var stream = workspace.GetRemoteStream( - rolandAddress, - reference); - - var value = await stream.Should().Within(TimeSpan.FromSeconds(15)).Emit(); - - value.Should().NotBe(default(JsonElement), - "Overview area should render for User/TestUser — AddDefaultLayoutAreas() must be invoked"); - } -} - diff --git a/test/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs b/test/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs index ddedee5050..8058c0298f 100644 --- a/test/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs +++ b/test/MeshWeaver.Hosting.Monolith.TestBase/TestUsers.cs @@ -85,6 +85,9 @@ public static MeshNode[] PublicAdminAccess() CreateAccessNode("ACME", assignment), CreateAccessNode("FutuRe", assignment), CreateAccessNode("Northwind", assignment), + // Cornerstone's content and its suites live in MeshWeaver.Plugins now, but this file + // is the SHARED test-user fixture both repos build against ($(MeshWeaverRoot)/test), + // so the grant stays here — it is what those suites authenticate against. CreateAccessNode("Cornerstone", assignment), CreateAccessNode("MeshWeaver", assignment), CreateAccessNode("Doc", assignment), diff --git a/test/MeshWeaver.Persistence.Test/MeshWeaver.Persistence.Test.csproj b/test/MeshWeaver.Persistence.Test/MeshWeaver.Persistence.Test.csproj index a9c15a4165..b07093ed2b 100644 --- a/test/MeshWeaver.Persistence.Test/MeshWeaver.Persistence.Test.csproj +++ b/test/MeshWeaver.Persistence.Test/MeshWeaver.Persistence.Test.csproj @@ -50,7 +50,6 @@ -
diff --git a/test/MeshWeaver.Persistence.Test/PageLoadingTest.cs b/test/MeshWeaver.Persistence.Test/PageLoadingTest.cs deleted file mode 100644 index 7445d7aaa7..0000000000 --- a/test/MeshWeaver.Persistence.Test/PageLoadingTest.cs +++ /dev/null @@ -1,415 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Reactive.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using MeshWeaver.ContentCollections; -using MeshWeaver.Data; -using MeshWeaver.Graph; -using MeshWeaver.Graph.Configuration; -using MeshWeaver.Hosting; -using MeshWeaver.Hosting.Monolith; -using MeshWeaver.Hosting.Monolith.TestBase; -using MeshWeaver.Hosting.Persistence; -using MeshWeaver.Layout; -using MeshWeaver.Mesh; -using MeshWeaver.Messaging; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Xunit; -using MeshWeaver.Fixture; - -namespace MeshWeaver.Persistence.Test; - -/// -/// Tests for page loading to prevent infinite spinner regression. -/// These tests verify that pages load within reasonable timeouts and don't hang. -/// The tests cover various node types to ensure reactive streams emit properly. -/// -[Collection("PageLoadingTests")] -public class PageLoadingTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - /// Share Mesh/SP across [Fact]s — see MonolithMeshTestBase.ShareMeshAcrossTests. - protected override bool ShareMeshAcrossTests => true; - - // Stream-level Timeout budget. 50s leaves 10s of headroom under the - // per-test [Theory(Timeout = 120000)] / [Fact(Timeout = 120000)] caps — - // enough that on a slow CI runner a cold Roslyn compile of three custom - // NodeTypes (Cornerstone/Insured + Pricing + Article, ACME/Article + - // Project + Todo) lands within budget without papering over a genuine - // hang. Local cache-hit runs complete in ~300 ms; the wide budget is - // only burned on actual cold compile. - private const int DefaultTimeoutSeconds = 50; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var graphPath = TestPaths.SamplesGraph; - var dataDirectory = TestPaths.SamplesGraphData; - // Stable cache directory (no Guid.NewGuid()) so the compiled NodeType - // assemblies survive across test runs. The source files at - // TestPaths.SamplesGraph have stable LastModified — combined with - // CompilationCacheService.TryGetLatestCachedDllPath (timestamped - // subdir lookup), the second-and-onwards test invocations skip the - // 9 s Roslyn cold compile of Cornerstone/Insured / Northwind / etc. - // and complete inside the 10 s per-test timeout. - var cacheDirectory = Path.Combine(Path.GetTempPath(), "MeshWeaverPageLoadingTests", ".mesh-cache"); - Directory.CreateDirectory(cacheDirectory); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:SourceType"] = "FileSystem", - ["Graph:Storage:BasePath"] = graphPath - }) - .Build(); - - var storageConfig = new ContentCollectionConfig - { - Name = "storage", - SourceType = "FileSystem", - BasePath = graphPath - // NOTE: storage stays ExposeInChildren=false here. The test - // doesn't query GetAllCollectionConfigs from descendant hubs; - // setting ExposeInChildren=true cascades visibility of the - // per-node mapped wrappers ("attachments", "content") through - // the hierarchy, which slowed down LoadsWithoutHanging assertions - // past their 10 s budget. If a future test needs cross-hub - // visibility, prefer a per-test fixture override. - }; - - return builder - .UseMonolithMesh() - .AddPartitionedFileSystemPersistence(dataDirectory) - .AddSpaceType() - .AddAcme() - .AddSystemorph() - .AddCornerstone() - .AddUserData() - .AddNorthwind() - .AddMeshWeaverDocs() - .ConfigureServices(services => - { - services.Configure(o => o.CacheDirectory = cacheDirectory); - services.AddSingleton(configuration); - return services; - }) - .ConfigureHub(hub => hub.AddContentCollections([storageConfig])) - .ConfigureDefaultNodeHub(config => - { - var nodePath = config.Address.ToString(); - config = config - .AddContentCollections() - .MapContentCollection("attachments", "storage", $"attachments/{nodePath}") - .MapContentCollection("content", "storage", $"content/{nodePath}"); - - return config.AddDefaultLayoutAreas(); - }) - .AddGraph(); - } - - /// - /// Helper method to test that a node's default area loads without hanging. - /// - private async Task AssertNodeLoadsWithoutHanging(string nodePath, int timeoutSeconds = DefaultTimeoutSeconds) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - var address = new Address(nodePath); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] new Address({nodePath}) done"); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] GetClient done"); - - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] Sending PingRequest to {nodePath}"); - - await client.Observe(new PingRequest(), o => o.WithTarget(address)).Should().Emit(); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] Ping returned for {nodePath}"); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(string.Empty); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] About to GetRemoteStream for {nodePath}"); - - var stream = workspace.GetRemoteStream(address, reference); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] GetRemoteStream returned (subscribed below)"); - - var changeItem = await stream.Should().Within(TimeSpan.FromSeconds(timeoutSeconds)).Emit(); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] stream emitted"); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {nodePath} text len={rawText.Length}"); - - value.Should().NotBe(default(JsonElement), $"{nodePath} should load and emit a value"); - } - - /// - /// Helper method to test that a specific layout area loads without hanging. - /// - private async Task AssertAreaLoadsWithoutHanging(string nodePath, string areaName, int timeoutSeconds = DefaultTimeoutSeconds) - { - var address = new Address(nodePath); - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - - Output.WriteLine($"Testing {areaName} area loading for {nodePath}"); - - await client.Observe(new PingRequest(), o => o.WithTarget(address)).Should().Emit(); - Output.WriteLine($"Hub initialized for {nodePath}"); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(areaName); - - var stream = workspace.GetRemoteStream(address, reference); - - var changeItem = await stream.Should().Within(TimeSpan.FromSeconds(timeoutSeconds)).Emit(); - var value = changeItem.Value; - - var rawText = value.GetRawText(); - Output.WriteLine($"Received value: {rawText.Substring(0, Math.Min(300, rawText.Length))}..."); - - value.Should().NotBe(default(JsonElement), $"{nodePath}/{areaName} should emit a value"); - Output.WriteLine($"{nodePath}/{areaName} loaded successfully"); - } - - #region Space Node Tests - - /// - /// Tests that Space nodes load without hanging. - /// 60 s timeout (vs 10 s default) because ACME's first activation triggers - /// cold compile of its three child NodeTypes (Article, Project, Todo) + - /// loads access assignments. Subsequent runs hit the timestamped-subdir - /// cache via CompilationCacheService.TryGetLatestCachedDllPath. - /// - [Theory(Timeout = 120000)] - [InlineData("ACME")] - [InlineData("Systemorph")] - public async Task Space_LoadsWithoutHanging(string nodePath) - { - await AssertNodeLoadsWithoutHanging(nodePath); - } - - #endregion - - #region Person Node Tests - - /// - /// Tests that Person nodes load without hanging. - /// - [Theory(Timeout = 120000)] - [InlineData("Cornerstone/Microsoft")] - [InlineData("Cornerstone/Tesla")] - public async Task Person_LoadsWithoutHanging(string nodePath) - { - await AssertNodeLoadsWithoutHanging(nodePath); - } - - /// - /// Tests that the Activity area loads for User nodes. - /// This verifies UserActivityLayoutAreas is properly registered via UserNodeType. - /// - [Fact(Timeout = 120000)] - public async Task User_Activity_LoadsWithoutHanging() - { - await AssertAreaLoadsWithoutHanging("Cornerstone/Microsoft", "Activity"); - } - - #endregion - - #region Cornerstone Node Tests - - /// - /// Tests that Cornerstone Insured nodes load without hanging. - /// This specifically tests the CombineLatest fix with StartWith. - /// - [Theory(Timeout = 120000)] - [InlineData("Cornerstone/Microsoft")] - [InlineData("Cornerstone/EuropeanLogistics")] - [InlineData("Cornerstone/GlobalManufacturing")] - public async Task CornerstoneInsured_LoadsWithoutHanging(string nodePath) - { - await AssertNodeLoadsWithoutHanging(nodePath); - } - - /// - /// Tests that the PricingCatalog area loads for Cornerstone nodes. - /// This directly tests the CombineLatest fix with StartWith. - /// - [Fact(Timeout = 120000)] - public async Task CornerstoneInsured_PricingCatalog_LoadsWithoutHanging() - { - await AssertAreaLoadsWithoutHanging("Cornerstone/Microsoft", "PricingCatalog"); - } - - #endregion - - #region Markdown Node Tests - - /// - /// Tests that Markdown documentation nodes load without hanging. - /// - [Theory(Timeout = 120000)] - [InlineData("MeshWeaver/Welcome")] - public async Task MarkdownNode_LoadsWithoutHanging(string nodePath) - { - await AssertNodeLoadsWithoutHanging(nodePath); - } - - #endregion - - #region Northwind Node Tests - - /// - /// Tests that Northwind nodes load without hanging. - /// - [Theory(Timeout = 120000)] - [InlineData("Northwind")] - public async Task NorthwindNode_LoadsWithoutHanging(string nodePath) - { - await AssertNodeLoadsWithoutHanging(nodePath); - } - - #endregion - -} - -/// -/// Concurrent-load stress test. Lives in its OWN test class (NOT shared mesh) -/// because the multi-hub fan-out is sensitive to accumulated subscriber-stream -/// state. 's 11 prior tests each leak a server-side -/// LayoutAreaReference sync stream paired with the now-abandoned client/1 hub -/// from that test; the LATEST client/1 (the one this concurrent test creates) -/// becomes the routing destination for every leaked stream's outbound traffic. -/// On the per-client action block, the new SubscribeAck + initial DataChangedEvent -/// queue behind the leaked-stream emissions, blowing past the 20s -/// per-stream wait even though each hub responds to Ping in <100ms. -/// -/// Running this test in its OWN class (default ShareMeshAcrossTests = false) -/// gives it a fresh mesh with no prior clients leaking. The 4-concurrent -/// SubscribeRequest fan-out is the SUT; everything else is a clean slate. -/// -[Collection("ConcurrentRequestsTest")] -public class ConcurrentRequestsTest(ITestOutputHelper output) : MonolithMeshTestBase(output) -{ - // Stream-level Timeout budget. 50s leaves 10s of headroom under the - // per-test [Theory(Timeout = 120000)] / [Fact(Timeout = 120000)] caps — - // enough that on a slow CI runner a cold Roslyn compile of three custom - // NodeTypes (Cornerstone/Insured + Pricing + Article, ACME/Article + - // Project + Todo) lands within budget without papering over a genuine - // hang. Local cache-hit runs complete in ~300 ms; the wide budget is - // only burned on actual cold compile. - private const int DefaultTimeoutSeconds = 50; - - protected override MeshBuilder ConfigureMesh(MeshBuilder builder) - { - var graphPath = TestPaths.SamplesGraph; - var dataDirectory = TestPaths.SamplesGraphData; - var cacheDirectory = Path.Combine(Path.GetTempPath(), "MeshWeaverConcurrentRequestsTest", ".mesh-cache"); - Directory.CreateDirectory(cacheDirectory); - - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Graph:Storage:SourceType"] = "FileSystem", - ["Graph:Storage:BasePath"] = graphPath - }) - .Build(); - - var storageConfig = new ContentCollectionConfig - { - Name = "storage", - SourceType = "FileSystem", - BasePath = graphPath - }; - - return builder - .UseMonolithMesh() - .AddPartitionedFileSystemPersistence(dataDirectory) - .AddSpaceType() - .AddAcme() - .AddCornerstone() - .AddNorthwind() - .ConfigureServices(services => - { - services.Configure(o => o.CacheDirectory = cacheDirectory); - services.AddSingleton(configuration); - return services; - }) - .ConfigureHub(hub => hub.AddContentCollections([storageConfig])) - .ConfigureDefaultNodeHub(config => - { - var nodePath = config.Address.ToString(); - config = config - .AddContentCollections() - .MapContentCollection("attachments", "storage", $"attachments/{nodePath}") - .MapContentCollection("content", "storage", $"content/{nodePath}"); - - return config.AddDefaultLayoutAreas(); - }) - .AddGraph(); - } - - /// - /// Tests that multiple concurrent requests to different node types don't cause hanging. - /// - [Fact(Timeout = 120000)] - public async Task ConcurrentRequests_MultipleNodeTypes_AllLoadWithoutHanging() - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - var nodePaths = new[] - { - "Cornerstone/Microsoft", - "ACME", - "Cornerstone/Microsoft", - "Northwind" - }; - - var client = GetClient(c => c - .AddLayoutClient(cc => cc) - .AddContentCollections()); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] GetClient done; starting concurrent loads"); - - var tasks = new List>(); - - foreach (var nodePath in nodePaths) - { - var path = nodePath; // Capture for closure - tasks.Add(Task.Run(async () => - { - try - { - var address = new Address(path); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path}: Ping starting"); - await client.Observe(new PingRequest(), o => o.WithTarget(address)).FirstAsync().Await(TestContext.Current.CancellationToken); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path}: Ping returned"); - - var workspace = client.GetWorkspace(); - var reference = new LayoutAreaReference(string.Empty); - var stream = workspace.GetRemoteStream(address, reference); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path}: GetRemoteStream returned, awaiting FirstAsync"); - - var changeItem = await stream.Timeout(TimeSpan.FromSeconds(DefaultTimeoutSeconds)).FirstAsync(); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path}: FirstAsync returned"); - return (path, changeItem.Value.ValueKind != JsonValueKind.Undefined); - } - catch (Exception ex) - { - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path}: Error: {ex.GetType().Name}: {ex.Message}"); - return (path, false); - } - })); - } - - var results = await Task.WhenAll(tasks); - - foreach (var (path, success) in results) - { - success.Should().BeTrue($"{path} should load successfully"); - Output.WriteLine($"[{sw.ElapsedMilliseconds}ms] {path} loaded: {success}"); - } - } -} - -[CollectionDefinition("ConcurrentRequestsTest", DisableParallelization = true)] -public class ConcurrentRequestsTestCollection { } diff --git a/test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj b/test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj index 01e279a526..98e153846a 100644 --- a/test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj +++ b/test/MeshWeaver.Query.Test/MeshWeaver.Query.Test.csproj @@ -36,7 +36,6 @@ - diff --git a/test/MeshWeaver.Security.Test/MeshWeaver.Security.Test.csproj b/test/MeshWeaver.Security.Test/MeshWeaver.Security.Test.csproj index dfe2c54fca..bd00c9b006 100644 --- a/test/MeshWeaver.Security.Test/MeshWeaver.Security.Test.csproj +++ b/test/MeshWeaver.Security.Test/MeshWeaver.Security.Test.csproj @@ -33,7 +33,6 @@ - diff --git a/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj b/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj index 959090568d..f42ddc3df2 100644 --- a/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj +++ b/tools/MeshWeaver.PluginTester/MeshWeaver.PluginTester.csproj @@ -261,7 +261,6 @@ PRECISELY so in-mesh sample/plugin source (Cornerstone/Pricing's Excel loader + map, Northwind/Product) can compile at runtime. Without them the gate flags content the real host compiles fine — the gate must judge against the surface content actually gets. --> - +