From 7cd816367386bcea1eeb20aeb373baa7b052c6e8 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 17:37:21 +0100 Subject: [PATCH 01/11] Preserve raw linked transcript source edits --- .../Services/CaptureService.cs | 29 ++- ...ptureServiceTransactionIntegrationTests.cs | 226 ++++++++++++++++++ 2 files changed, 243 insertions(+), 12 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index 619120c0e7..e294b3ea5a 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -858,7 +858,7 @@ public async Task> UpdateSuggestionAsync( // stored bytes. The record of what the user first typed or pasted survives every correction, // and a representation can still name the exact asset it was derived from. Staged into the // same unit of work as the queue row, so the edit and the new source commit together. - var durable = await SupersedeDurableTextAsync( + var durable = await UpdateDurableCaptureAsync( userId, item.Id, dto.Text, @@ -968,10 +968,10 @@ private async Task> UpdateLinkedTranscriptSuggestionAsync currentPayload.TitleHint, StringComparison.Ordinal)) { - durable = await SupersedeDurableTextAsync( + durable = await UpdateDurableCaptureAsync( userId, item.Id, - normalizedText, + textChanged ? dto.Text : null, updatedPayload.TitleHint, cancellationToken); } @@ -1031,10 +1031,11 @@ private static string NormalizeLineEndings(string text) => text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n'); /// - /// Appends the corrected text as a superseding SourceAsset on the durable capture, if - /// there is one, and carries the edited title hint onto the aggregate in the same unit of work. - /// Returns the mutated aggregate so the caller's DTO reflects the new current text; null when - /// the capture is not (yet) durable, which leaves the queue-row reading intact. + /// Applies a corrected text as a superseding SourceAsset on the durable capture when + /// is provided, and carries the edited title hint onto the + /// aggregate in the same unit of work. Returns the mutated aggregate so the caller's DTO + /// reflects the new current text; null when the capture is not (yet) durable, which leaves the + /// queue-row reading intact. /// /// Deliberately NOT gated on DualWriteCaptures. That flag governs whether a NEW capture /// reaches the aggregate; it must never mean that an aggregate which already exists is allowed @@ -1042,10 +1043,10 @@ private static string NormalizeLineEndings(string text) => /// and turning it back on would serve that text through the read switch. /// /// - private async Task SupersedeDurableTextAsync( + private async Task UpdateDurableCaptureAsync( Guid userId, Guid captureId, - string text, + string? sourceText, string? titleHint, CancellationToken cancellationToken) { @@ -1062,7 +1063,11 @@ private static string NormalizeLineEndings(string text) => try { - capture.SupersedeInlineTextSource(text); + if (sourceText is not null) + { + capture.SupersedeInlineTextSource(sourceText); + } + // The queue payload carries the edited title hint, so the aggregate has to take it too -- // otherwise UserTitle silently keeps the pre-edit value forever. capture.Retitle(titleHint); @@ -1074,7 +1079,7 @@ private static string NormalizeLineEndings(string text) => // detects that and the reconcile pass repairs it on the next start. _logger?.LogWarning( ex, - "Context Fabric: could not record a superseding source for capture {CaptureId}; " + + "Context Fabric: could not update the durable capture {CaptureId}; " + "the edit still applied to the queue row and the backfill will reconcile it.", captureId); return null; @@ -1182,7 +1187,7 @@ private async Task CancelInternalAsync( DateTimeOffset queueUpdatedAt, CancellationToken cancellationToken) { - // Not gated on DualWriteCaptures, for the same reason as SupersedeDurableTextAsync: the flag + // Not gated on DualWriteCaptures, for the same reason as UpdateDurableCaptureAsync: the flag // decides whether new captures reach the aggregate, never whether an existing one may drift. if (_captureStore is null) { diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs index e1764d9644..672831a7e5 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs @@ -156,4 +156,230 @@ [new TranscriptSegment(0, 0, "Speaker", 10)], } } } + + [Theory] + [InlineData("corrected\r\nsecond line", "corrected\nsecond line")] + [InlineData("corrected\rsecond line", "corrected\nsecond line")] + public async Task LinkedCorrection_PreservesSubmittedLineEndingsInDurableSource_WhileCanonicalTranscriptNormalizes( + string submittedText, + string normalizedText) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-source-fidelity-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var service = CreateTransactionalService(db, fixture.Item, queueCasResult: true, out var unitOfWork); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(submittedText)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + result.Value.RawText.Should().Be(normalizedText); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedTranscripts = await db.Transcripts + .AsNoTracking() + .Where(value => value.CreatedFromCaptureId == fixture.Item.Id) + .ToListAsync(); + persistedTranscripts.Should().HaveCount(2); + persistedTranscripts.Single(value => value.Id == fixture.Original.Id).Text + .Should().Be("original transcript"); + persistedTranscripts.Single(value => value.Id != fixture.Original.Id).Text + .Should().Be(normalizedText); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .OrderBy(value => value.Ordinal) + .ToListAsync(); + persistedAssets.Should().HaveCount(2); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeFalse(); + persistedAssets[1].TextPayload!.Text.Should().Be(submittedText); + persistedAssets[1].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + [Theory] + [InlineData("original\ntranscript", "Retitled transcript")] + [InlineData("original\r\ntranscript", null)] + public async Task LinkedCorrection_TitleOnlyOrNormalizedEquivalentText_DoesNotAppendSource( + string submittedText, + string? titleHint) + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-title-only-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original\ntranscript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var service = CreateTransactionalService(db, fixture.Item, queueCasResult: true, out var unitOfWork); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(submittedText, TitleHint: titleHint)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedCapture = await db.Captures + .AsNoTracking() + .SingleAsync(value => value.Id == fixture.Item.Id); + persistedCapture.UserTitle.Should().Be(titleHint); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .ToListAsync(); + persistedAssets.Should().ContainSingle(); + persistedAssets[0].TextPayload!.Text.Should().Be("original\ntranscript"); + persistedAssets[0].IsActive.Should().BeTrue(); + + (await db.Transcripts.AsNoTracking() + .CountAsync(value => value.CreatedFromCaptureId == fixture.Item.Id)) + .Should().Be(1); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + private static (User User, LlmRequest Item, Transcript Original, Taskdeck.Domain.Entities.Capture DurableCapture) + CreateLinkedTranscriptFixture(string canonicalText) + { + var user = new User("source-fidelity", "source-fidelity@example.com", "hash"); + var item = new LlmRequest( + user.Id, + CaptureRequestContract.RequestTypeTranscriptV1, + CaptureRequestContract.SerializePayload( + new CapturePayloadV1(1, CaptureSource.TranscriptPaste, canonicalText))); + item.MarkAsProcessing(); + item.MarkAsCompleted(); + var original = new Transcript( + user.Id, + CaptureSource.TranscriptPaste, + canonicalText, + createdFromCaptureId: item.Id); + item.AttachTranscript(original.Id); + var durableCapture = Taskdeck.Domain.Entities.Capture.FromQueueRequest( + item.Id, + user.Id, + CaptureSource.TranscriptPaste, + contextBoardId: null, + capturedAtClient: null, + userTitle: null, + capturedAtServer: item.CreatedAt, + sourceText: canonicalText); + + return (user, item, original, durableCapture); + } + + private static CaptureService CreateTransactionalService( + TaskdeckDbContext db, + LlmRequest item, + bool queueCasResult, + out Mock unitOfWork) + { + var queue = new Mock(); + queue.Setup(repository => repository.GetByIdAsync(item.Id, It.IsAny())) + .ReturnsAsync(item); + queue.Setup(repository => repository.TryCorrectLinkedTranscriptCaptureAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(queueCasResult); + + unitOfWork = new Mock(); + unitOfWork.SetupGet(value => value.LlmQueue).Returns(queue.Object); + IDbContextTransaction? transaction = null; + unitOfWork.Setup(value => value.BeginTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + transaction = await db.Database.BeginTransactionAsync(cancellationToken); + }); + unitOfWork.Setup(value => value.SaveChangesAsync(It.IsAny())) + .Returns((CancellationToken cancellationToken) => db.SaveChangesAsync(cancellationToken)); + unitOfWork.Setup(value => value.RollbackTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + if (transaction is not null) + { + await transaction.RollbackAsync(cancellationToken); + await transaction.DisposeAsync(); + transaction = null; + } + }); + unitOfWork.Setup(value => value.CommitTransactionAsync(It.IsAny())) + .Returns(async (CancellationToken cancellationToken) => + { + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + await transaction.DisposeAsync(); + transaction = null; + } + }); + + return new CaptureService( + unitOfWork.Object, + new Mock().Object, + captureStore: new EfCaptureStore(db), + contextFabricSettings: new ContextFabricSettings { DualWriteCaptures = false }, + backfillStore: null, + logger: null, + transcriptRepository: new TranscriptRepository(db)); + } } From 0ed0726294e8f0dc9e6e1633c284d4af2d8e93a0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 17:48:09 +0100 Subject: [PATCH 02/11] Enforce raw linked transcript length cap --- .../Services/CaptureService.cs | 7 +- ...ptureServiceTransactionIntegrationTests.cs | 191 +++++++++++++++++- 2 files changed, 189 insertions(+), 9 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index e294b3ea5a..739d6abf0f 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -903,15 +903,16 @@ private async Task> UpdateLinkedTranscriptSuggestionAsync "The linked transcript cannot be corrected"); } - var normalizedText = NormalizeLineEndings(dto.Text); - var textChanged = !string.Equals(normalizedText, canonical.Text, StringComparison.Ordinal); var maxTextLength = CaptureRequestContract.MaxTranscriptTextLength; - if (normalizedText.Length > maxTextLength) + if (dto.Text.Length > maxTextLength) { return Result.Failure(ErrorCodes.ValidationError, $"Text exceeds maximum length of {maxTextLength} characters"); } + var normalizedText = NormalizeLineEndings(dto.Text); + var textChanged = !string.Equals(normalizedText, canonical.Text, StringComparison.Ordinal); + var updatedPayload = currentPayload with { Text = normalizedText, diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs index 672831a7e5..7258377c70 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureServiceTransactionIntegrationTests.cs @@ -180,7 +180,12 @@ public async Task LinkedCorrection_PreservesSubmittedLineEndingsInDurableSource_ db.Captures.Add(fixture.DurableCapture); await db.SaveChangesAsync(); - var service = CreateTransactionalService(db, fixture.Item, queueCasResult: true, out var unitOfWork); + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); var result = await service.UpdateSuggestionAsync( fixture.User.Id, fixture.Item.Id, @@ -188,6 +193,9 @@ public async Task LinkedCorrection_PreservesSubmittedLineEndingsInDurableSource_ result.IsSuccess.Should().BeTrue(result.ErrorMessage); result.Value.RawText.Should().Be(normalizedText); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be(normalizedText); unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); db.ChangeTracker.Clear(); @@ -250,13 +258,21 @@ public async Task LinkedCorrection_TitleOnlyOrNormalizedEquivalentText_DoesNotAp db.Captures.Add(fixture.DurableCapture); await db.SaveChangesAsync(); - var service = CreateTransactionalService(db, fixture.Item, queueCasResult: true, out var unitOfWork); + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); var result = await service.UpdateSuggestionAsync( fixture.User.Id, fixture.Item.Id, new UpdateCaptureSuggestionDto(submittedText, TitleHint: titleHint)); result.IsSuccess.Should().BeTrue(result.ErrorMessage); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be("original\ntranscript"); unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); db.ChangeTracker.Clear(); @@ -292,15 +308,172 @@ public async Task LinkedCorrection_TitleOnlyOrNormalizedEquivalentText_DoesNotAp } } + [Fact] + public async Task LinkedCorrection_ShouldRejectRawTranscriptOverCapBeforeTransaction() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-raw-cap-rejection-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript", "Original title"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var oversizedText = string.Concat(Enumerable.Repeat("x\r\n", 66_667)); + oversizedText.Length.Should().Be(CaptureRequestContract.MaxTranscriptTextLength + 1); + oversizedText.Replace("\r\n", "\n", StringComparison.Ordinal).Length + .Should().BeLessThan(CaptureRequestContract.MaxTranscriptTextLength); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(oversizedText, TitleHint: "Changed title")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(Domain.Exceptions.ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain(CaptureRequestContract.MaxTranscriptTextLength.ToString()); + getReplacementPayload().Should().BeNull(); + unitOfWork.Verify(value => value.BeginTransactionAsync(It.IsAny()), Times.Never); + unitOfWork.Verify(value => value.SaveChangesAsync(It.IsAny()), Times.Never); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Never); + + db.ChangeTracker.Clear(); + var persistedItem = await db.LlmRequests.AsNoTracking().SingleAsync(value => value.Id == fixture.Item.Id); + var persistedPayload = CaptureRequestContract.ParseStoredPayload(persistedItem.Payload); + persistedPayload.Text.Should().Be("original transcript"); + persistedPayload.TitleHint.Should().Be("Original title"); + + var persistedTranscript = await db.Transcripts + .AsNoTracking() + .SingleAsync(value => value.CreatedFromCaptureId == fixture.Item.Id); + persistedTranscript.Text.Should().Be("original transcript"); + + var persistedCapture = await db.Captures + .AsNoTracking() + .SingleAsync(value => value.Id == fixture.Item.Id); + persistedCapture.UserTitle.Should().Be("Original title"); + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .ToListAsync(); + persistedAssets.Should().ContainSingle(); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + + [Fact] + public async Task LinkedCorrection_ShouldAcceptExactRawTranscriptCapAndNormalizeCanonicalData() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-transcript-raw-cap-accepted-{Guid.NewGuid():N}.db"); + try + { + var options = new DbContextOptionsBuilder() + .UseSqlite(TestSqlite.ConnectionString(dbPath)) + .Options; + await using var db = new TaskdeckDbContext(options); + await db.Database.MigrateAsync(); + + var fixture = CreateLinkedTranscriptFixture("original transcript"); + db.Users.Add(fixture.User); + db.LlmRequests.Add(fixture.Item); + db.Transcripts.Add(fixture.Original); + db.Captures.Add(fixture.DurableCapture); + await db.SaveChangesAsync(); + + var exactCapText = string.Concat(Enumerable.Repeat("x\r\n", 66_666)) + "xx"; + exactCapText.Length.Should().Be(CaptureRequestContract.MaxTranscriptTextLength); + var normalizedText = exactCapText.Replace("\r\n", "\n", StringComparison.Ordinal); + normalizedText.Length.Should().BeLessThan(exactCapText.Length); + + var service = CreateTransactionalService( + db, + fixture.Item, + queueCasResult: true, + out var unitOfWork, + out var getReplacementPayload); + var result = await service.UpdateSuggestionAsync( + fixture.User.Id, + fixture.Item.Id, + new UpdateCaptureSuggestionDto(exactCapText)); + + result.IsSuccess.Should().BeTrue(result.ErrorMessage); + result.Value.RawText.Should().Be(normalizedText); + var queuedPayload = getReplacementPayload(); + queuedPayload.Should().NotBeNull(); + CaptureRequestContract.ParseStoredPayload(queuedPayload!).Text.Should().Be(normalizedText); + unitOfWork.Verify(value => value.CommitTransactionAsync(It.IsAny()), Times.Once); + + db.ChangeTracker.Clear(); + var persistedTranscripts = await db.Transcripts + .AsNoTracking() + .Where(value => value.CreatedFromCaptureId == fixture.Item.Id) + .ToListAsync(); + persistedTranscripts.Should().HaveCount(2); + persistedTranscripts.Single(value => value.Id != fixture.Original.Id).Text.Should().Be(normalizedText); + + var persistedAssets = await db.SourceAssets + .AsNoTracking() + .Include(value => value.TextPayload) + .Where(value => value.CaptureId == fixture.Item.Id) + .OrderBy(value => value.Ordinal) + .ToListAsync(); + persistedAssets.Should().HaveCount(2); + persistedAssets[0].TextPayload!.Text.Should().Be("original transcript"); + persistedAssets[0].IsActive.Should().BeFalse(); + persistedAssets[1].TextPayload!.Text.Should().Be(exactCapText); + persistedAssets[1].IsActive.Should().BeTrue(); + } + finally + { + foreach (var suffix in new[] { "", "-wal", "-shm", "-journal" }) + { + var path = dbPath + suffix; + if (File.Exists(path)) + { + try { File.Delete(path); } + catch (IOException) { } + } + } + } + } + private static (User User, LlmRequest Item, Transcript Original, Taskdeck.Domain.Entities.Capture DurableCapture) - CreateLinkedTranscriptFixture(string canonicalText) + CreateLinkedTranscriptFixture(string canonicalText, string? userTitle = null) { var user = new User("source-fidelity", "source-fidelity@example.com", "hash"); var item = new LlmRequest( user.Id, CaptureRequestContract.RequestTypeTranscriptV1, CaptureRequestContract.SerializePayload( - new CapturePayloadV1(1, CaptureSource.TranscriptPaste, canonicalText))); + new CapturePayloadV1(1, CaptureSource.TranscriptPaste, canonicalText, TitleHint: userTitle))); item.MarkAsProcessing(); item.MarkAsCompleted(); var original = new Transcript( @@ -315,7 +488,7 @@ private static (User User, LlmRequest Item, Transcript Original, Taskdeck.Domain CaptureSource.TranscriptPaste, contextBoardId: null, capturedAtClient: null, - userTitle: null, + userTitle: userTitle, capturedAtServer: item.CreatedAt, sourceText: canonicalText); @@ -326,9 +499,11 @@ private static CaptureService CreateTransactionalService( TaskdeckDbContext db, LlmRequest item, bool queueCasResult, - out Mock unitOfWork) + out Mock unitOfWork, + out Func getReplacementPayload) { var queue = new Mock(); + string? replacementPayload = null; queue.Setup(repository => repository.GetByIdAsync(item.Id, It.IsAny())) .ReturnsAsync(item); queue.Setup(repository => repository.TryCorrectLinkedTranscriptCaptureAsync( @@ -340,6 +515,8 @@ private static CaptureService CreateTransactionalService( It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, _, _, _, payload, _) => replacementPayload = payload) .ReturnsAsync(queueCasResult); unitOfWork = new Mock(); @@ -373,6 +550,8 @@ private static CaptureService CreateTransactionalService( } }); + getReplacementPayload = () => replacementPayload; + return new CaptureService( unitOfWork.Object, new Mock().Object, From eaf68aca4bd1042c529f2507844697c6378180e6 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 9 Sep 2026 17:56:19 +0100 Subject: [PATCH 03/11] Record comparison scenarios and build attribution --- .../src/store/workspaceExperimentStore.ts | 43 ++++++++++++++-- .../store/workspaceExperimentStore.spec.ts | 22 ++++++++- .../overhaul/WorkspaceExperiencesView.vue | 49 +++++++++++++++---- 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts b/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts index 748d6fc5e3..540c1393a1 100644 --- a/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts +++ b/frontend/taskdeck-web/src/store/workspaceExperimentStore.ts @@ -6,28 +6,61 @@ export interface WorkspaceTrial { experience: string presentation: string theme: string + build: string | null + scenario: WorkspaceScenario + completionOutcome: WorkspaceCompletionOutcome recordedAt: string - ease: number + ease: number | null note: string } +export const WORKSPACE_COMPARISON_SCENARIOS = [ + { id: 'capture-review-board', label: 'Capture → Review → Board' }, + { id: 'resume-thinking', label: 'Resume a card and leave a next step' }, + { id: 'insight-memory', label: 'Check insights and answer a Memory question' }, +] as const + +export type WorkspaceScenario = typeof WORKSPACE_COMPARISON_SCENARIOS[number]['id'] + +export const WORKSPACE_COMPLETION_OUTCOMES = [ + { id: 'completed', label: 'Completed the scenario' }, + { id: 'stopped', label: 'Stopped with a next step' }, + { id: 'blocked', label: 'Reached a blocker' }, + { id: 'not-completed', label: 'Did not complete the scenario' }, +] as const + +export type WorkspaceCompletionOutcome = typeof WORKSPACE_COMPLETION_OUTCOMES[number]['id'] + +export type WorkspaceTrialInput = Omit & { build?: string | null } + +function isScenario(value: unknown): value is WorkspaceScenario { + return WORKSPACE_COMPARISON_SCENARIOS.some(scenario => scenario.id === value) +} + +function isCompletionOutcome(value: unknown): value is WorkspaceCompletionOutcome { + return WORKSPACE_COMPLETION_OUTCOMES.some(outcome => outcome.id === value) +} + /** Deliberately session-only. No automatic assignment, telemetry or persisted work content. */ export const useWorkspaceExperimentStore = defineStore('workspaceExperiment', () => { const session = useSessionStore() const trials = ref([]) watch(() => session.userId, () => { trials.value = [] }, { flush: 'sync' }) - function record(trial: Omit) { - if (!session.userId || !Number.isInteger(trial.ease) || trial.ease < 1 || trial.ease > 5) return false + function record(trial: WorkspaceTrialInput) { + const ease = trial.ease ?? null + if (!session.userId || !isScenario(trial.scenario) || !isCompletionOutcome(trial.completionOutcome)) return false + if (ease !== null && (!Number.isInteger(ease) || ease < 1 || ease > 5)) return false if (!['classic', 'studio', 'companion', 'unified'].includes(trial.experience) || !['zen', 'studio', 'control'].includes(trial.presentation)) return false - trials.value.push({ ...trial, note: trial.note.slice(0, 2000), recordedAt: new Date().toISOString() }) + const build = typeof trial.build === 'string' && trial.build.trim() ? trial.build.trim().slice(0, 256) : null + trials.value.push({ ...trial, build, ease, note: trial.note.slice(0, 2000), recordedAt: new Date().toISOString() }) return true } function clear() { trials.value = [] } function exportJson() { - return JSON.stringify({ kind: 'taskdeck-workspace-comparison', version: 1, trials: trials.value }, null, 2) + return JSON.stringify({ kind: 'taskdeck-workspace-comparison', version: 2, trials: trials.value }, null, 2) } return { trials, record, clear, exportJson } diff --git a/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts b/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts index 8e8e4dd95a..9cc93296ca 100644 --- a/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/workspaceExperimentStore.spec.ts @@ -5,16 +5,32 @@ import { useWorkspaceExperimentStore } from '../../store/workspaceExperimentStor const session = reactive({ userId: 'first' as string | null }) vi.mock('../../store/sessionStore', () => ({ useSessionStore: () => session })) -const trial = { experience: 'studio', presentation: 'zen', theme: 'grove', ease: 4, note: 'I found the next step.' } +const trial = { + experience: 'studio', + presentation: 'zen', + theme: 'grove', + build: 'v0.3.0', + scenario: 'capture-review-board' as const, + completionOutcome: 'completed' as const, + ease: 4, + note: 'I found the next step.', +} describe('workspace comparison observations', () => { beforeEach(() => { setActivePinia(createPinia()); session.userId = 'first'; localStorage.clear() }) it('exports only manually recorded trials and keeps notes out of browser storage', () => { const store = useWorkspaceExperimentStore() expect(store.record(trial)).toBe(true) - expect(JSON.parse(store.exportJson()).trials[0]).toMatchObject(trial) + const exported = JSON.parse(store.exportJson()) + expect(exported.version).toBe(2) + expect(exported.trials[0]).toMatchObject(trial) expect(localStorage.length).toBe(0) }) + it('keeps an unselected ease rating unobserved and accepts an unavailable build', () => { + const store = useWorkspaceExperimentStore() + expect(store.record({ ...trial, build: null, ease: null })).toBe(true) + expect(store.trials[0]).toMatchObject({ build: null, ease: null }) + }) it('clears observations immediately on identity change or sign out', () => { const store = useWorkspaceExperimentStore() store.record(trial) @@ -30,6 +46,8 @@ describe('workspace comparison observations', () => { expect(store.record({ ...trial, ease: 0 })).toBe(false) expect(store.record({ ...trial, ease: 4.5 })).toBe(false) expect(store.record({ ...trial, experience: 'automatic' })).toBe(false) + expect(store.record({ ...trial, scenario: 'free-form' as never })).toBe(false) + expect(store.record({ ...trial, completionOutcome: 'assumed' as never })).toBe(false) expect(store.trials).toEqual([]) }) }) diff --git a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue index e2fe2c1569..5f1f84ce48 100644 --- a/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue +++ b/frontend/taskdeck-web/src/views/overhaul/WorkspaceExperiencesView.vue @@ -2,12 +2,22 @@ import { ref } from 'vue' import { useWorkspaceLayoutStore } from '../../store/workspaceLayoutStore' import { usePaperThemeStore } from '../../store/paperThemeStore' -import { useWorkspaceExperimentStore } from '../../store/workspaceExperimentStore' +import { + useWorkspaceExperimentStore, + WORKSPACE_COMPARISON_SCENARIOS, + WORKSPACE_COMPLETION_OUTCOMES, + type WorkspaceCompletionOutcome, + type WorkspaceScenario, +} from '../../store/workspaceExperimentStore' +import { useProductVersion } from '../../composables/useProductVersion' const layout = useWorkspaceLayoutStore() const theme = usePaperThemeStore() const experiment = useWorkspaceExperimentStore() -const ease = ref(3) +const productVersion = useProductVersion() +const scenario = ref(null) +const completionOutcome = ref(null) +const ease = ref(null) const note = ref('') const saved = ref(false) const versions = [ @@ -17,9 +27,28 @@ const versions = [ { id: 'unified', name: 'Unified', headline: 'One workspace. Your pace.', detail: 'A familiar sidebar, adjustable detail, Thinking Decks, quiet insights and maintainable memory.', glyph: '▱' }, ] as const -function record() { - saved.value = experiment.record({ experience: layout.experience, presentation: layout.presentation, theme: theme.mode, ease: Number(ease.value), note: note.value }) - if (saved.value) note.value = '' +async function record() { + if (!scenario.value || !completionOutcome.value) { + saved.value = false + return + } + await productVersion.ensureLoaded() + saved.value = experiment.record({ + experience: layout.experience, + presentation: layout.presentation, + theme: theme.mode, + build: productVersion.version.value, + scenario: scenario.value, + completionOutcome: completionOutcome.value, + ease: ease.value, + note: note.value, + }) + if (saved.value) { + scenario.value = null + completionOutcome.value = null + ease.value = null + note.value = '' + } } function download() { @@ -44,12 +73,14 @@ function download() {

Open your workspace ↗ · Choose a theme and detail level

Same task, different perspective.

  1. Capture a rough thought and follow it through Review to a board.
  2. Open a card’s Thinking Deck and leave a thread for next time.
  3. Check quiet insights and answer a useful question in Memory.
  4. Switch experience. See what becomes easier to find or harder to understand.

These are personal comparison notes. They are not a statistical A/B result. Nothing is assigned automatically or sent as telemetry.

-

Keep an observation

Recording {{ layout.experience }} / {{ layout.presentation }} / {{ theme.mode }}. Notes stay in this session; export them before reloading or signing out.

- +

Keep an observation

Recording {{ layout.experience }} / {{ layout.presentation }} / {{ theme.mode }} · build {{ productVersion.displayVersion || 'unavailable' }}. Notes stay in this session; export them before reloading or signing out.

+ + +